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? _tokenFactory; private readonly Func _pathCleaner; private readonly Func _errorHandler; protected ResiliencePipeline Pipeline { get; set; } = new ResiliencePipelineBuilder().Build(); private readonly string _apiBaseUrl; public BaseRestClient(ILogger logger, RestClientOptions options) { Client = new HttpClient(); Client.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", options.UserAgent); _logger = logger.ForContext(); _jsonSerializerOptions = options.JsonSerializerOptions ?? JsonSerializerOptions.Default; _apiBaseUrl = options.ApiBaseUrl; _tokenFactory = options.TokenFactory; _pathCleaner = options.PathLogCleaner ?? (s => s); _errorHandler = options.ErrorHandler; } public async Task RequestAsync(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(_jsonSerializerOptions, ct) ?? throw new DiscordRequestError("Content was deserialized as null"); } public async Task RequestAsync(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(_jsonSerializerOptions, ct) ?? throw new DiscordRequestError("Content was deserialized as null"); } private async Task DoRequestAsync(string path, HttpRequestMessage req, CancellationToken ct = default) { var context = ResilienceContextPool.Shared.Get(ct); context.Properties.Set(new ResiliencePropertyKey("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; } /// /// 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. /// public required Func ErrorHandler { get; init; } /// /// A function that returns a token. If not set, the client will not add an Authorization header. /// public Func? TokenFactory { get; init; } /// /// 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. /// public Func? PathLogCleaner { get; init; } }