foxcord/Foxcord/Rest/BaseRestClient.cs

128 lines
4.9 KiB
C#
Raw Normal View History

2024-09-03 00:07:12 +02:00
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; }
}