128 lines
4.9 KiB
C#
128 lines
4.9 KiB
C#
|
using System.Diagnostics;
|
||
|
using System.Net.Http.Headers;
|
||
|
using System.Net.Http.Json;
|
||
|
using System.Text.Json;
|
||
|
using Polly;
|
||
|
using Serilog;
|
||
|
|
||
|
namespace Foxcord.Rest;
|
||
|
|
||
|
public class BaseRestClient
|
||
|
{
|
||
|
public HttpClient Client { get; }
|
||
|
private readonly ILogger _logger;
|
||
|
private readonly JsonSerializerOptions _jsonSerializerOptions;
|
||
|
private readonly Func<string>? _tokenFactory;
|
||
|
private readonly Func<string, string> _pathCleaner;
|
||
|
private readonly Func<HttpResponseMessage, CancellationToken, Task> _errorHandler;
|
||
|
|
||
|
protected ResiliencePipeline<HttpResponseMessage> Pipeline { get; set; } =
|
||
|
new ResiliencePipelineBuilder<HttpResponseMessage>().Build();
|
||
|
|
||
|
private readonly string _apiBaseUrl;
|
||
|
|
||
|
public BaseRestClient(ILogger logger, RestClientOptions options)
|
||
|
{
|
||
|
Client = new HttpClient();
|
||
|
Client.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", options.UserAgent);
|
||
|
|
||
|
_logger = logger.ForContext<BaseRestClient>();
|
||
|
_jsonSerializerOptions = options.JsonSerializerOptions ?? JsonSerializerOptions.Default;
|
||
|
_apiBaseUrl = options.ApiBaseUrl;
|
||
|
_tokenFactory = options.TokenFactory;
|
||
|
_pathCleaner = options.PathLogCleaner ?? (s => s);
|
||
|
_errorHandler = options.ErrorHandler;
|
||
|
}
|
||
|
|
||
|
public async Task<T> RequestAsync<T>(HttpMethod method, string path, CancellationToken ct = default)
|
||
|
where T : class
|
||
|
{
|
||
|
var req = new HttpRequestMessage(method, $"{_apiBaseUrl}{path}");
|
||
|
if (_tokenFactory != null)
|
||
|
req.Headers.Add("Authorization", _tokenFactory());
|
||
|
|
||
|
var resp = await DoRequestAsync(path, req, ct);
|
||
|
return await resp.Content.ReadFromJsonAsync<T>(_jsonSerializerOptions, ct) ??
|
||
|
throw new DiscordRequestError("Content was deserialized as null");
|
||
|
}
|
||
|
|
||
|
public async Task<TResponse> RequestAsync<TRequest, TResponse>(HttpMethod method, string path, TRequest reqBody,
|
||
|
CancellationToken ct = default)
|
||
|
{
|
||
|
var req = new HttpRequestMessage(method, $"{_apiBaseUrl}{path}");
|
||
|
if (_tokenFactory != null)
|
||
|
req.Headers.Add("Authorization", _tokenFactory());
|
||
|
|
||
|
|
||
|
var body = JsonSerializer.Serialize(reqBody, _jsonSerializerOptions);
|
||
|
req.Content = new StringContent(body, new MediaTypeHeaderValue("application/json", "utf-8"));
|
||
|
|
||
|
var resp = await DoRequestAsync(path, req, ct);
|
||
|
return await resp.Content.ReadFromJsonAsync<TResponse>(_jsonSerializerOptions, ct) ??
|
||
|
throw new DiscordRequestError("Content was deserialized as null");
|
||
|
}
|
||
|
|
||
|
private async Task<HttpResponseMessage> DoRequestAsync(string path, HttpRequestMessage req,
|
||
|
CancellationToken ct = default)
|
||
|
{
|
||
|
var context = ResilienceContextPool.Shared.Get(ct);
|
||
|
context.Properties.Set(new ResiliencePropertyKey<string>("Path"), path);
|
||
|
try
|
||
|
{
|
||
|
return await Pipeline.ExecuteAsync(async ctx =>
|
||
|
{
|
||
|
HttpResponseMessage resp;
|
||
|
|
||
|
var stopwatch = new Stopwatch();
|
||
|
stopwatch.Start();
|
||
|
try
|
||
|
{
|
||
|
resp = await Client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead,
|
||
|
ctx.CancellationToken);
|
||
|
stopwatch.Stop();
|
||
|
}
|
||
|
catch (Exception e)
|
||
|
{
|
||
|
_logger.Error(e, "HTTP error: {Method} {Path}", req.Method, _pathCleaner(path));
|
||
|
throw;
|
||
|
}
|
||
|
|
||
|
_logger.Debug("Response: {Method} {Path} -> {StatusCode} {ReasonPhrase} (in {ResponseMs} ms)",
|
||
|
req.Method, _pathCleaner(path), (int)resp.StatusCode, resp.ReasonPhrase,
|
||
|
stopwatch.ElapsedMilliseconds);
|
||
|
|
||
|
await _errorHandler(resp, context.CancellationToken);
|
||
|
|
||
|
return resp;
|
||
|
}, context);
|
||
|
}
|
||
|
finally
|
||
|
{
|
||
|
ResilienceContextPool.Shared.Return(context);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
public class RestClientOptions
|
||
|
{
|
||
|
public JsonSerializerOptions? JsonSerializerOptions { get; init; }
|
||
|
public required string UserAgent { get; init; }
|
||
|
public required string ApiBaseUrl { get; init; }
|
||
|
|
||
|
/// <summary>
|
||
|
/// A function that converts non-2XX responses to errors. This should usually throw;
|
||
|
/// if not, the request will continue to be handled normally, which will probably cause errors.
|
||
|
/// </summary>
|
||
|
public required Func<HttpResponseMessage, CancellationToken, Task> ErrorHandler { get; init; }
|
||
|
|
||
|
/// <summary>
|
||
|
/// A function that returns a token. If not set, the client will not add an Authorization header.
|
||
|
/// </summary>
|
||
|
public Func<string>? TokenFactory { get; init; }
|
||
|
|
||
|
/// <summary>
|
||
|
/// A function that cleans up paths for logging. This should remove sensitive content (i.e. tokens).
|
||
|
/// If not set, paths will not be cleaned before being logged.
|
||
|
/// </summary>
|
||
|
public Func<string, string>? PathLogCleaner { get; init; }
|
||
|
}
|