diff --git a/KGSoft.TinyHttpClient.Test/HttpRequestBuilderTests.cs b/KGSoft.TinyHttpClient.Test/HttpRequestBuilderTests.cs index 9d9f3b7..32be375 100644 --- a/KGSoft.TinyHttpClient.Test/HttpRequestBuilderTests.cs +++ b/KGSoft.TinyHttpClient.Test/HttpRequestBuilderTests.cs @@ -1,7 +1,14 @@ -using KGSoft.TinyHttpClient.Model; +using KGSoft.TinyHttpClient.Logging; +using KGSoft.TinyHttpClient.Model; using KGSoft.TinyHttpClient.Test.Model; using Microsoft.VisualStudio.TestTools.UnitTesting; +using Polly; +using Polly.Retry; +using System; +using System.Collections.Generic; +using System.Net.Http; using System.Threading.Tasks; +using Newtonsoft.Json; namespace KGSoft.TinyHttpClient.Test; @@ -108,4 +115,273 @@ public async Task Test_DELETE() AssertResponse(response); } + + [TestMethod] + public async Task Test_GET_CtorUri() + { + var response = await new HttpRequestBuilder($"{ApiBase}api/users/2") + .Get() + .MakeRequestAsync(); + + AssertResponse(response); + } + + [TestMethod] + public async Task Test_PUT_CtorUri() + { + var response = await new HttpRequestBuilder($"{ApiBase}api/users/2") + .Put() + .AddBody(new Data() { FirstName = "Chewbacca" }) + .MakeRequestAsync(); + + AssertResponse(response); + } + + [TestMethod] + public async Task Test_POST_CtorUri() + { + var response = await new HttpRequestBuilder($"{ApiBase}api/users") + .Post() + .AddBody(new Data() { FirstName = "Chewbacca" }) + .MakeRequestAsync(); + + AssertResponse(response); + } + + [TestMethod] + public async Task Test_PATCH() + { + var response = await new HttpRequestBuilder() + .Patch($"{ApiBase}api/users/2") + .AddBody(new Data() { FirstName = "Chewbacca" }) + .MakeRequestAsync(); + + AssertResponse(response); + } + + [TestMethod] + public async Task Test_PATCH_CtorUri() + { + var response = await new HttpRequestBuilder($"{ApiBase}api/users/2") + .Patch() + .AddBody(new Data() { FirstName = "Chewbacca" }) + .MakeRequestAsync(); + + AssertResponse(response); + } + + [TestMethod] + public async Task Test_DELETE_CtorUri() + { + var response = await new HttpRequestBuilder($"{ApiBase}api/users") + .Delete() + .MakeRequestAsync(); + + AssertResponse(response); + } + + [TestMethod] + public async Task Test_HEAD() + { + var response = await new HttpRequestBuilder($"{ApiBase}api/users/2") + .Head() + .MakeRequestAsync(); + + AssertResponse(response); + } + + [TestMethod] + public async Task Test_OPTIONS() + { + var response = await new HttpRequestBuilder() + .Options($"{ApiBase}api/users/2") + .MakeRequestAsync(); + + AssertResponse(response); + } + + [TestMethod] + public async Task Test_WithUri() + { + var response = await new HttpRequestBuilder() + .WithUri($"{ApiBase}api/users/2") + .Get() + .MakeRequestAsync(); + + AssertResponse(response); + } + + [TestMethod] + public async Task Test_WithAliases() + { + var response = await new HttpRequestBuilder($"{ApiBase}api/users") + .Post() + .WithHeader("X-Correlation-ID", "123") + .WithHeader("X-Correlation-ID", "456") + .WithBearerToken("XYZ") + .WithBody(new Data() { FirstName = "Chewbacca" }) + .WithJsonSerializerSettings(new JsonSerializerSettings()) + .MakeRequestAsync(); + + AssertResponse(response); + } + + [TestMethod] + public async Task Test_AddQueryParams_Enumerable_AllowsDuplicates() + { + var response = await new HttpRequestBuilder($"{ApiBase}api/users/2") + .Get() + .AddQueryParams(new List> + { + new("page", "1"), + new("page", "2") + }) + .MakeRequestAsync(); + + AssertResponse(response); + } + + [TestMethod] + public async Task Test_WithContentFactory_WithRetry() + { + var contentFactoryCount = 0; + var retryPolicy = new ResiliencePipelineBuilder() + .AddRetry(new RetryStrategyOptions + { + ShouldHandle = new PredicateBuilder() + .HandleResult(response => !response.IsSuccess), + MaxRetryAttempts = 2, + Delay = TimeSpan.Zero + }) + .Build(); + + var response = await new HttpRequestBuilder($"{ApiBase}api/unknown/23") + .Post() + .WithContent(() => + { + contentFactoryCount++; + return new StringContent("{}"); + }) + .WithRetry(retryPolicy) + .MakeRequestAsync(); + + Assert.IsFalse(response.IsSuccess); + Assert.AreEqual(3, contentFactoryCount); + } + + [TestMethod] + public async Task Test_WithLogger_DefaultsToAllRequests() + { + var logger = new TestLogger(); + + var response = await new HttpRequestBuilder($"{ApiBase}api/users/2") + .Get() + .WithLogger(logger) + .MakeRequestAsync(); + + AssertResponse(response); + Assert.AreEqual(2, logger.Messages.Count); + } + + [TestMethod] + public async Task Test_WithLogger_OnlyFailedRequests_DoesNotLogSuccess() + { + var logger = new TestLogger(); + + var response = await new HttpRequestBuilder($"{ApiBase}api/users/2") + .Get() + .WithLogger(logger) + .WithLogScope(Enums.LogScope.OnlyFailedRequests) + .MakeRequestAsync(); + + AssertResponse(response); + Assert.AreEqual(0, logger.Messages.Count); + } + + [TestMethod] + public async Task Test_WithLogger_OnlyFailedRequests_LogsFailure() + { + var logger = new TestLogger(); + + var response = await new HttpRequestBuilder($"{ApiBase}api/unknown/23") + .Get() + .WithLogger(logger) + .WithLogScope(Enums.LogScope.OnlyFailedRequests) + .MakeRequestAsync(); + + Assert.IsFalse(response.IsSuccess); + Assert.AreEqual(1, logger.Messages.Count); + } + + [TestMethod] + public async Task Test_GET_WithRetry() + { + var retryCount = 0; + var retryPolicy = new ResiliencePipelineBuilder() + .AddRetry(new RetryStrategyOptions + { + ShouldHandle = new PredicateBuilder() + .HandleResult(response => !response.IsSuccess), + MaxRetryAttempts = 2, + Delay = TimeSpan.Zero, + OnRetry = args => + { + retryCount++; + return ValueTask.CompletedTask; + } + }) + .Build(); + + var response = await new HttpRequestBuilder() + .Get($"{ApiBase}api/unknown/23") + .WithRetry(retryPolicy) + .MakeRequestAsync(); + + Assert.IsFalse(response.IsSuccess); + Assert.AreEqual(2, retryCount); + } + + [TestMethod] + public async Task Test_GET_Result_WithRetry() + { + var retryCount = 0; + var retryPolicy = new ResiliencePipelineBuilder() + .AddRetry(new RetryStrategyOptions + { + ShouldHandle = new PredicateBuilder() + .HandleResult(response => !response.IsSuccess), + MaxRetryAttempts = 2, + Delay = TimeSpan.Zero, + OnRetry = args => + { + retryCount++; + return ValueTask.CompletedTask; + } + }) + .Build(); + + var response = await new HttpRequestBuilder() + .Get($"{ApiBase}api/unknown/23") + .WithRetry(retryPolicy) + .MakeRequestAsync(); + + Assert.IsFalse(response.IsSuccess); + Assert.AreEqual(2, retryCount); + } + + private sealed class TestLogger : ILogger + { + public List Messages { get; } = new(); + public List Exceptions { get; } = new(); + + public void LogMessage(string message) + { + Messages.Add(message); + } + + public void LogException(Exception ex) + { + Exceptions.Add(ex); + } + } } diff --git a/KGSoft.TinyHttpClient/Helper.cs b/KGSoft.TinyHttpClient/Helper.cs index f07bc59..348a56d 100644 --- a/KGSoft.TinyHttpClient/Helper.cs +++ b/KGSoft.TinyHttpClient/Helper.cs @@ -119,15 +119,15 @@ public static Task> DeleteAsync(string url, string body = "", Can /// Cancellation token for request /// Additional config /// - internal static async Task> MakeHttpRequest(string url, HttpMethod method, string body = "", HttpContent content = null, CancellationToken tkn = default, HeaderConfig cfg = default) - { - var message = content == null - ? await GetResponseMessage(url, method, body, tkn, cfg) - : await GetResponseMessage(url, method, content, tkn, cfg); - - var response = await message.BuildResponse(); + internal static async Task> MakeHttpRequest(string url, HttpMethod method, string body = "", HttpContent content = null, CancellationToken tkn = default, HeaderConfig cfg = default) + { + using var message = content == null + ? await GetResponseMessage(url, method, body, tkn, cfg) + : await GetResponseMessage(url, method, content, tkn, cfg); + + var response = await message.BuildResponse(); - HandleLogging(response); + HandleLogging(response, cfg); return response; } @@ -142,15 +142,15 @@ internal static async Task> MakeHttpRequest(string url, HttpMetho /// Cancellation token for request /// Additional config /// - internal static async Task MakeHttpRequest(string url, HttpMethod method, string body = "", HttpContent content = null, CancellationToken tkn = default, HeaderConfig cfg = default) - { - var message = content == null - ? await GetResponseMessage(url, method, body, tkn, cfg) - : await GetResponseMessage(url, method, content, tkn, cfg); - - var response = await message.BuildResponse(); + internal static async Task MakeHttpRequest(string url, HttpMethod method, string body = "", HttpContent content = null, CancellationToken tkn = default, HeaderConfig cfg = default) + { + using var message = content == null + ? await GetResponseMessage(url, method, body, tkn, cfg) + : await GetResponseMessage(url, method, content, tkn, cfg); + + var response = await message.BuildResponse(); - HandleLogging(response); + HandleLogging(response, cfg); return response; } @@ -175,21 +175,38 @@ private static async Task HandlePostRequestUnauthorizedActions(System.Net.HttpSt /// /// /// - private static void HandleLogging(Response response) - { - if (HttpConfig.LogScope == Enums.LogScope.OnlyFailedRequests && !response.IsSuccess) - Log(response); - else if (HttpConfig.LogScope == Enums.LogScope.AllRequests) - Log(response); - - void Log(Response r) - { - LogHelper.LogMessage(string.Format("{0}[ResponseCode]: {1} - {2}", - response.IsSuccess ? string.Empty : "FAILED HTTP REQUEST - ", - response.StatusCode, - response.Message)); - } - } + private static void HandleLogging(Response response, HeaderConfig cfg = default) + { + var logger = cfg?.Logger ?? HttpConfig.Logger; + if (logger == null) + return; + + var logScope = cfg?.LogScope ?? HttpConfig.LogScope; + + if (logScope == Enums.LogScope.OnlyFailedRequests && !response.IsSuccess) + Log(response); + else if (logScope == Enums.LogScope.AllRequests) + Log(response); + + void Log(Response r) + { + logger.LogMessage(string.Format("{0}[ResponseCode]: {1} - {2}", + response.IsSuccess ? string.Empty : "FAILED HTTP REQUEST - ", + response.StatusCode, + response.Message)); + } + } + + private static void LogRequest(HttpMethod method, string url, HeaderConfig cfg = default) + { + var logger = cfg?.Logger ?? HttpConfig.Logger; + if (logger == null) + return; + + var logScope = cfg?.LogScope ?? HttpConfig.LogScope; + if (logScope == Enums.LogScope.AllRequests) + logger.LogMessage($"[{method.Method}]: {url}"); + } /// /// Create and send an HttpRequestMessage with a string body @@ -217,7 +234,7 @@ private static async Task GetResponseMessage(string url, Ht HttpRequestMessage request = new(method, url); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(HttpConfig.MediaTypeHeader)); - LogHelper.LogMessage($"[{method.Method}]: {url}"); + LogRequest(method, url, cfg); // Fire any pre-auth delegates before applying headers so mutations affect this request. HttpConfig.PreRequestAuthAction?.Invoke(); diff --git a/KGSoft.TinyHttpClient/HttpRequestBuilder.cs b/KGSoft.TinyHttpClient/HttpRequestBuilder.cs index bd1bf15..88f7977 100644 --- a/KGSoft.TinyHttpClient/HttpRequestBuilder.cs +++ b/KGSoft.TinyHttpClient/HttpRequestBuilder.cs @@ -1,8 +1,12 @@ using KGSoft.TinyHttpClient.Model; +using KGSoft.TinyHttpClient.Logging; using Newtonsoft.Json; +using Polly; +using System; using System.Collections.Generic; using System.Linq; using System.Net.Http; +using System.Net.Http.Headers; using System.Threading; using System.Threading.Tasks; @@ -11,12 +15,17 @@ namespace KGSoft.TinyHttpClient; public class HttpRequestBuilder { private string _uri; - private readonly Dictionary _headers; - private readonly List _requestParams; + private readonly Dictionary _headers = new(); + private readonly List _requestParams = new(); private HttpMethod _method; private object _body; private CancellationToken _cancellationToken; - private HttpContent _content; + private Func _contentFactory; + private AuthenticationHeaderValue _authHeader; + private JsonSerializerSettings _serializerSettings; + private ILogger _logger; + private Enums.LogScope? _logScope; + private ResiliencePipeline _retryPolicy; private bool HasFormParams => _requestParams.Any(x => x.Type == Enums.RequestParamType.FormEncoded); private IEnumerable FormParams => _requestParams.Where(x => x.Type == Enums.RequestParamType.FormEncoded); @@ -27,22 +36,80 @@ public class HttpRequestBuilder /// public HttpRequestBuilder() { - _headers = new Dictionary(); - _requestParams = new List(); } /// - /// Signifies the intent for a GET request + /// Default ctor for the Request Builder with a URI + /// + /// The URI to be targeted + public HttpRequestBuilder(string uri) + { + _uri = uri; + } + + /// + /// Sets the URI to be targeted /// /// The URI to be targeted /// HttpRequestBuilder - public HttpRequestBuilder Get(string uri) + public HttpRequestBuilder WithUri(string uri) { _uri = uri; + return this; + } + + /// + /// Signifies the intent for a GET request + /// + /// HttpRequestBuilder + public HttpRequestBuilder Get() + { _method = HttpMethod.Get; return this; } + /// + /// Signifies the intent for a GET request + /// + /// The URI to be targeted + /// HttpRequestBuilder + public HttpRequestBuilder Get(string uri) + { + WithUri(uri); + return Get(); + } + + /// + /// Signifies the intent for a HEAD request + /// + /// HttpRequestBuilder + public HttpRequestBuilder Head() + { + _method = HttpMethod.Head; + return this; + } + + /// + /// Signifies the intent for a HEAD request + /// + /// The URI to be targeted + /// HttpRequestBuilder + public HttpRequestBuilder Head(string uri) + { + WithUri(uri); + return Head(); + } + + /// + /// Signifies the intent for a POST request + /// + /// HttpRequestBuilder + public HttpRequestBuilder Post() + { + _method = HttpMethod.Post; + return this; + } + /// /// Signifies the intent for a POST request /// @@ -50,8 +117,17 @@ public HttpRequestBuilder Get(string uri) /// HttpRequestBuilder public HttpRequestBuilder Post(string uri) { - _uri = uri; - _method = HttpMethod.Post; + WithUri(uri); + return Post(); + } + + /// + /// Signifies the intent for a PUT request + /// + /// HttpRequestBuilder + public HttpRequestBuilder Put() + { + _method = HttpMethod.Put; return this; } @@ -62,31 +138,111 @@ public HttpRequestBuilder Post(string uri) /// HttpRequestBuilder public HttpRequestBuilder Put(string uri) { - _uri = uri; - _method = HttpMethod.Put; + WithUri(uri); + return Put(); + } + + /// + /// Signifies the intent for a PATCH request + /// + /// HttpRequestBuilder + public HttpRequestBuilder Patch() + { + _method = HttpMethod.Patch; return this; } /// - /// Signifies the intent for a DELETE request + /// Signifies the intent for a PATCH request /// /// The URI to be targeted /// HttpRequestBuilder - public HttpRequestBuilder Delete(string uri) + public HttpRequestBuilder Patch(string uri) + { + WithUri(uri); + return Patch(); + } + + /// + /// Signifies the intent for an OPTIONS request + /// + /// HttpRequestBuilder + public HttpRequestBuilder Options() + { + _method = HttpMethod.Options; + return this; + } + + /// + /// Signifies the intent for an OPTIONS request + /// + /// The URI to be targeted + /// HttpRequestBuilder + public HttpRequestBuilder Options(string uri) + { + WithUri(uri); + return Options(); + } + + /// + /// Signifies the intent for a DELETE request + /// + /// HttpRequestBuilder + public HttpRequestBuilder Delete() { - _uri = uri; _method = HttpMethod.Delete; return this; } + /// + /// Signifies the intent for a DELETE request + /// + /// The URI to be targeted + /// HttpRequestBuilder + public HttpRequestBuilder Delete(string uri) + { + WithUri(uri); + return Delete(); + } + /// /// Adds HttpContent to the request /// /// /// public HttpRequestBuilder AddContent(HttpContent content) + => WithContent(content); + + /// + /// Sets HttpContent for the request + /// + /// + /// + public HttpRequestBuilder WithContent(HttpContent content) + { + if (content == null) + throw new ArgumentNullException(nameof(content)); + + _contentFactory = () => content; + return this; + } + + /// + /// Adds an HttpContent factory to the request. Prefer this overload when using retry policies. + /// + /// + /// + public HttpRequestBuilder AddContent(Func contentFactory) + => WithContent(contentFactory); + + /// + /// Sets an HttpContent factory for the request. Prefer this overload when using retry policies. + /// + /// + /// + public HttpRequestBuilder WithContent(Func contentFactory) { - _content = content; + _contentFactory = contentFactory ?? throw new ArgumentNullException(nameof(contentFactory)); return this; } @@ -98,8 +254,7 @@ public HttpRequestBuilder AddContent(HttpContent content) /// HttpRequestBuilder public HttpRequestBuilder AddQueryParam(string name, string value) { - if (!_requestParams.Any(x => x.Type == Enums.RequestParamType.QueryString && x.Key.TrimAndLower() == name.TrimAndLower())) - _requestParams.Add(new RequestParam(name, value, Enums.RequestParamType.QueryString)); + _requestParams.Add(new RequestParam(name, value, Enums.RequestParamType.QueryString)); return this; } @@ -109,11 +264,21 @@ public HttpRequestBuilder AddQueryParam(string name, string value) /// The dictionaly of parameters to be added /// HttpRequestBuilder public HttpRequestBuilder AddQueryParams(Dictionary keyValuePairs) + => AddQueryParams((IEnumerable>)keyValuePairs); + + /// + /// Adds a range of query string parameters to the request + /// + /// The collection of parameters to be added + /// HttpRequestBuilder + public HttpRequestBuilder AddQueryParams(IEnumerable> keyValuePairs) { - var builder = this; + if (keyValuePairs == null) + throw new ArgumentNullException(nameof(keyValuePairs)); + foreach (var kvp in keyValuePairs) - builder = AddQueryParam(kvp.Key, kvp.Value); - return builder; + AddQueryParam(kvp.Key, kvp.Value); + return this; } /// @@ -123,8 +288,7 @@ public HttpRequestBuilder AddQueryParams(Dictionary keyValuePair /// public HttpRequestBuilder AddFormParam(string name, string value) { - if (!_requestParams.Any(x => x.Type == Enums.RequestParamType.FormEncoded && x.Key.TrimAndLower() == name.TrimAndLower())) - _requestParams.Add(new RequestParam(name, value, Enums.RequestParamType.FormEncoded)); + _requestParams.Add(new RequestParam(name, value, Enums.RequestParamType.FormEncoded)); return this; } @@ -135,6 +299,14 @@ public HttpRequestBuilder AddFormParam(string name, string value) /// The CancellationToken to be added /// HttpRequestBuilder public HttpRequestBuilder AddCancellationToken(CancellationToken token) + => WithCancellationToken(token); + + /// + /// Sets the cancellation token for the request + /// + /// The CancellationToken to be used + /// HttpRequestBuilder + public HttpRequestBuilder WithCancellationToken(CancellationToken token) { _cancellationToken = token; return this; @@ -147,10 +319,40 @@ public HttpRequestBuilder AddCancellationToken(CancellationToken token) /// HttpRequestBuilder public HttpRequestBuilder AddAuthorizationHeader(string value) { - _headers.Add("Authorization", value); + if (value == null) + throw new ArgumentNullException(nameof(value)); + + var headerParts = value.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + return headerParts.Length == 2 + ? WithAuthorization(new AuthenticationHeaderValue(headerParts[0], headerParts[1])) + : WithHeader("Authorization", value); + } + + /// + /// Sets the Authorization header for the request + /// + /// The authorization header value. + /// HttpRequestBuilder + public HttpRequestBuilder WithAuthorization(AuthenticationHeaderValue value) + { + _authHeader = value ?? throw new ArgumentNullException(nameof(value)); + _headers.Remove("Authorization"); return this; } + /// + /// Sets a Bearer token Authorization header for the request + /// + /// The bearer token. + /// HttpRequestBuilder + public HttpRequestBuilder WithBearerToken(string token) + { + if (token == null) + throw new ArgumentNullException(nameof(token)); + + return WithAuthorization(new AuthenticationHeaderValue("Bearer", token)); + } + /// /// Adds a header to the request /// @@ -158,8 +360,20 @@ public HttpRequestBuilder AddAuthorizationHeader(string value) /// The value for the header /// public HttpRequestBuilder AddHeader(string name, string value) + => WithHeader(name, value); + + /// + /// Sets a header on the request + /// + /// The key for the header + /// The value for the header + /// + public HttpRequestBuilder WithHeader(string name, string value) { - _headers.Add(name, value); + if (string.Equals(name, "Authorization", StringComparison.OrdinalIgnoreCase)) + return AddAuthorizationHeader(value); + + _headers[name] = value; return this; } @@ -169,11 +383,63 @@ public HttpRequestBuilder AddHeader(string name, string value) /// /// public HttpRequestBuilder AddBody(object body) + => WithBody(body); + + /// + /// Sets a body object for the request + /// + /// + /// + public HttpRequestBuilder WithBody(object body) { _body = body; return this; } + /// + /// Sets Newtonsoft.Json serializer settings for the request body. + /// + /// The serializer settings to use. + /// HttpRequestBuilder + public HttpRequestBuilder WithJsonSerializerSettings(JsonSerializerSettings serializerSettings) + { + _serializerSettings = serializerSettings ?? throw new ArgumentNullException(nameof(serializerSettings)); + return this; + } + + /// + /// Sets a logger for this request. Defaults to logging all request activity unless WithLogScope is also specified. + /// + /// The logger to use for this request. + /// HttpRequestBuilder + public HttpRequestBuilder WithLogger(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + return this; + } + + /// + /// Sets the logging scope for this request. + /// + /// The log scope to use for this request. + /// HttpRequestBuilder + public HttpRequestBuilder WithLogScope(Enums.LogScope logScope) + { + _logScope = logScope; + return this; + } + + /// + /// Adds a Polly resilience pipeline to execute this request with retry behavior. + /// + /// The Polly resilience pipeline to execute the request with. + /// HttpRequestBuilder + public HttpRequestBuilder WithRetry(ResiliencePipeline retryPolicy) + { + _retryPolicy = retryPolicy ?? throw new ArgumentNullException(nameof(retryPolicy)); + return this; + } + /// /// Makes the Http Request, WITHOUT a return type expected /// @@ -181,13 +447,13 @@ public HttpRequestBuilder AddBody(object body) public Task MakeRequestAsync() { Validate(); - - return Helper.MakeHttpRequest(Utils.BuildUrl(_uri, QueryParams), + + return ExecuteWithRetryAsync(() => Helper.MakeHttpRequest(Utils.BuildUrl(_uri, QueryParams), _method, - _body != null ? JsonConvert.SerializeObject(_body) : string.Empty, + SerializeBody(), BuildContent(), _cancellationToken, - Utils.BuildHeaderConfig(_headers)); + Utils.BuildHeaderConfig(_headers, _authHeader, _logger, BuildLogScope()))); } /// @@ -198,12 +464,12 @@ public Task> MakeRequestAsync() { Validate(); - return Helper.MakeHttpRequest(Utils.BuildUrl(_uri, QueryParams), + return ExecuteWithRetryAsync(() => Helper.MakeHttpRequest(Utils.BuildUrl(_uri, QueryParams), _method, - _body != null ? JsonConvert.SerializeObject(_body) : string.Empty, + SerializeBody(), BuildContent(), _cancellationToken, - Utils.BuildHeaderConfig(_headers)); + Utils.BuildHeaderConfig(_headers, _authHeader, _logger, BuildLogScope()))); } private void Validate() @@ -212,17 +478,40 @@ private void Validate() throw new MissingUriException(); if (_method == null) throw new MissingHttpMethodException(); - if (_content != null && HasFormParams) + if (_contentFactory != null && HasFormParams) throw new ConflictingContentException(); } + private string SerializeBody() + => _body != null ? JsonConvert.SerializeObject(_body, _serializerSettings) : string.Empty; + + private Enums.LogScope? BuildLogScope() + => _logScope ?? (_logger != null ? Enums.LogScope.AllRequests : null); + private HttpContent BuildContent() { - if (_content != null) - return _content; + if (_contentFactory != null) + return _contentFactory(); else if (HasFormParams) return new FormUrlEncodedContent(FormParams.Select(x => new KeyValuePair(x.Key, x.Value))); return null; } + + private Task ExecuteWithRetryAsync(Func> request) + { + if (_retryPolicy == null) + return request(); + + return _retryPolicy.ExecuteAsync(async token => await request(), _cancellationToken).AsTask(); + } + + private async Task> ExecuteWithRetryAsync(Func>> request) + { + if (_retryPolicy == null) + return await request(); + + var response = await _retryPolicy.ExecuteAsync(async token => await request(), _cancellationToken); + return (Response)response; + } } diff --git a/KGSoft.TinyHttpClient/KGSoft.TinyHttpClient.csproj b/KGSoft.TinyHttpClient/KGSoft.TinyHttpClient.csproj index 1d6960f..7f7c38d 100644 --- a/KGSoft.TinyHttpClient/KGSoft.TinyHttpClient.csproj +++ b/KGSoft.TinyHttpClient/KGSoft.TinyHttpClient.csproj @@ -33,5 +33,6 @@ + - \ No newline at end of file + diff --git a/KGSoft.TinyHttpClient/Model/HeaderConfig.cs b/KGSoft.TinyHttpClient/Model/HeaderConfig.cs index 4d12394..9cfdc73 100644 --- a/KGSoft.TinyHttpClient/Model/HeaderConfig.cs +++ b/KGSoft.TinyHttpClient/Model/HeaderConfig.cs @@ -1,5 +1,7 @@ -using System.Collections.Generic; +using KGSoft.TinyHttpClient.Logging; +using System.Collections.Generic; using System.Net.Http.Headers; +using static KGSoft.TinyHttpClient.Enums; namespace KGSoft.TinyHttpClient; @@ -13,4 +15,12 @@ public class HeaderConfig /// The CustomHeaders to use for this request /// public Dictionary CustomHeaders = null; + /// + /// The Logger to use for this request + /// + public ILogger Logger = null; + /// + /// The LogScope to use for this request + /// + public LogScope? LogScope = null; } diff --git a/KGSoft.TinyHttpClient/Utils.cs b/KGSoft.TinyHttpClient/Utils.cs index b8821f1..c1ccfdc 100644 --- a/KGSoft.TinyHttpClient/Utils.cs +++ b/KGSoft.TinyHttpClient/Utils.cs @@ -1,9 +1,12 @@ using KGSoft.TinyHttpClient.Model; +using KGSoft.TinyHttpClient.Logging; using System; using System.Collections.Generic; using System.Linq; using System.Net; +using System.Net.Http.Headers; using System.Text; +using static KGSoft.TinyHttpClient.Enums; namespace KGSoft.TinyHttpClient; @@ -33,14 +36,21 @@ public static string BuildUrl(string baseUrl, IEnumerable requestP return sb.ToString(); } - public static HeaderConfig BuildHeaderConfig(Dictionary headers) + public static HeaderConfig BuildHeaderConfig( + Dictionary headers, + AuthenticationHeaderValue authHeader = null, + ILogger logger = null, + LogScope? logScope = null) { - if (headers.Count == 0) + if (headers.Count == 0 && authHeader == null && logger == null && logScope == null) return null; else { var cfg = new HeaderConfig { + AuthHeader = authHeader, + Logger = logger, + LogScope = logScope, CustomHeaders = new Dictionary() }; diff --git a/README.md b/README.md index e88383a..62bb3d8 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ https://www.nuget.org/packages/KGSoft.TinyHttpClient/ - Form-encoded request support - Custom `HttpContent` support - Global and per-request header configuration +- Per-request Polly retry support +- Configurable per-request JSON serialization settings - Reused `HttpClient` with configurable pooled connection lifetime, idle timeout, and request timeout - Logging support - Pre-request sync/async hooks for token acquisition or refresh @@ -102,6 +104,23 @@ var response = await new HttpRequestBuilder() .MakeRequestAsync(); ``` +You can also provide the URI to the builder constructor and call the HTTP verb without a URI: + +```csharp +var response = await new HttpRequestBuilder("https://example.com/api/users/1") + .Get() + .MakeRequestAsync(); +``` + +Or set the URI fluently: + +```csharp +var response = await new HttpRequestBuilder() + .WithUri("https://example.com/api/users/1") + .Get() + .MakeRequestAsync(); +``` + ### Query Parameters ```csharp @@ -117,7 +136,7 @@ var response = await new HttpRequestBuilder() ```csharp var response = await new HttpRequestBuilder() .Post("https://example.com/api/users") - .AddBody(new + .WithBody(new { first_name = "Jane", last_name = "Doe" @@ -125,6 +144,30 @@ var response = await new HttpRequestBuilder() .MakeRequestAsync(); ``` +### PATCH JSON Body + +```csharp +var response = await new HttpRequestBuilder("https://example.com/api/users/1") + .Patch() + .WithBody(new + { + first_name = "Jane" + }) + .MakeRequestAsync(); +``` + +### HEAD and OPTIONS + +```csharp +var headResponse = await new HttpRequestBuilder("https://example.com/api/users/1") + .Head() + .MakeRequestAsync(); + +var optionsResponse = await new HttpRequestBuilder() + .Options("https://example.com/api/users") + .MakeRequestAsync(); +``` + ### Form-Encoded Request ```csharp @@ -141,11 +184,13 @@ var response = await new HttpRequestBuilder() ```csharp var response = await new HttpRequestBuilder() .Get("https://example.com/api/users/1") - .AddHeader("X-Correlation-ID", correlationId) - .AddAuthorizationHeader("Bearer your-access-token") + .WithHeader("X-Correlation-ID", correlationId) + .WithBearerToken(accessToken) .MakeRequestAsync(); ``` +Calling `WithHeader` for the same header name replaces the previous value. + ### Cancellation Token ```csharp @@ -153,7 +198,71 @@ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); var response = await new HttpRequestBuilder() .Get("https://example.com/api/users/1") - .AddCancellationToken(cts.Token) + .WithCancellationToken(cts.Token) + .MakeRequestAsync(); +``` + +### Custom Content + +```csharp +var response = await new HttpRequestBuilder("https://example.com/upload") + .Post() + .WithContent(() => new ByteArrayContent(fileBytes)) + .MakeRequestAsync(); +``` + +Prefer the `Func` overload when using retry policies, because it creates fresh content for each retry attempt. + +### JSON Serialization Settings + +```csharp +var response = await new HttpRequestBuilder("https://example.com/api/users") + .Post() + .WithBody(user) + .WithJsonSerializerSettings(new JsonSerializerSettings + { + NullValueHandling = NullValueHandling.Ignore + }) + .MakeRequestAsync(); +``` + +### Retry With Polly + +```csharp +var retryPolicy = new ResiliencePipelineBuilder() + .AddRetry(new RetryStrategyOptions + { + ShouldHandle = new PredicateBuilder() + .HandleResult(response => !response.IsSuccess), + MaxRetryAttempts = 3, + Delay = TimeSpan.FromSeconds(1) + }) + .Build(); + +var response = await new HttpRequestBuilder() + .Get("https://example.com/api/users/1") + .WithRetry(retryPolicy) + .MakeRequestAsync(); +``` + +Builders are intended to compose and send one request. Create a new `HttpRequestBuilder` for each request so headers, body, content, and retry policy state do not carry over unexpectedly. + +### Per-Request Logging + +```csharp +var response = await new HttpRequestBuilder("https://example.com/api/users/1") + .Get() + .WithLogger(logger) + .MakeRequestAsync(); +``` + +`WithLogger` logs all request activity by default. Use `WithLogScope` to limit that request to failed responses: + +```csharp +var response = await new HttpRequestBuilder("https://example.com/api/users/1") + .Get() + .WithLogger(logger) + .WithLogScope(Enums.LogScope.OnlyFailedRequests) .MakeRequestAsync(); ```