From b8790222646a8bdf7c9575cfd4d98795c317dd91 Mon Sep 17 00:00:00 2001 From: Stuart Ferguson Date: Sat, 2 May 2026 18:56:31 +0100 Subject: [PATCH] Refactor client HTTP logic with new ClientBase class Introduce abstract ClientBase to centralize HTTP request logic, serialization, deserialization, and error handling. Refactor SecurityServiceClient to inherit from ClientBase, replacing custom HTTP methods with unified ClientBase methods. Switch to Newtonsoft.Json for (de)serialization and update all HTTP calls for consistency and maintainability. --- SecurityService.Client/ClientBase.cs | 712 ++++++++++++++++++ .../SecurityServiceClient.cs | 63 +- 2 files changed, 748 insertions(+), 27 deletions(-) create mode 100644 SecurityService.Client/ClientBase.cs diff --git a/SecurityService.Client/ClientBase.cs b/SecurityService.Client/ClientBase.cs new file mode 100644 index 00000000..2028cf2c --- /dev/null +++ b/SecurityService.Client/ClientBase.cs @@ -0,0 +1,712 @@ +using SimpleResults; +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace SecurityService.Client +{ + public abstract class ClientBase + { + private readonly HttpClient HttpClient; + private readonly Func Serialise; + private readonly Func Deserialise; + + public ClientBase( + HttpClient httpClient, + Func serialise, + Func deserialise) + { + HttpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + Serialise = serialise ?? throw new ArgumentNullException(nameof(serialise)); + Deserialise = deserialise ?? throw new ArgumentNullException(nameof(deserialise)); + } + + public Task> Get(string requestUri, CancellationToken cancellationToken = default) + => SendForResponse(HttpMethod.Get, requestUri, body: null, accessToken: null, additionalHeaders: null, cancellationToken); + + public Task> Get(string requestUri, List<(string header, string value)> additionalHeaders, CancellationToken cancellationToken = default) + => SendForResponse(HttpMethod.Get, requestUri, body: null, accessToken: null, additionalHeaders, cancellationToken); + + public Task> Get(string requestUri, string accessToken, CancellationToken cancellationToken = default) + => SendForResponse(HttpMethod.Get, requestUri, body: null, accessToken, additionalHeaders: null, cancellationToken); + + public Task> Get(string requestUri, string accessToken, List<(string header, string value)> additionalHeaders, CancellationToken cancellationToken = default) + => SendForResponse(HttpMethod.Get, requestUri, body: null, accessToken, additionalHeaders, cancellationToken); + + public Task Delete(string requestUri, CancellationToken cancellationToken = default) + => SendWithoutResponse(HttpMethod.Delete, requestUri, body: null, accessToken: null, additionalHeaders: null, cancellationToken); + + public Task Delete(string requestUri, List<(string header, string value)> additionalHeaders, CancellationToken cancellationToken = default) + => SendWithoutResponse(HttpMethod.Delete, requestUri, body: null, accessToken: null, additionalHeaders, cancellationToken); + + public Task Delete(string requestUri, string accessToken, CancellationToken cancellationToken = default) + => SendWithoutResponse(HttpMethod.Delete, requestUri, body: null, accessToken, additionalHeaders: null, cancellationToken); + + public Task Delete(string requestUri, string accessToken, List<(string header, string value)> additionalHeaders, CancellationToken cancellationToken = default) + => SendWithoutResponse(HttpMethod.Delete, requestUri, body: null, accessToken, additionalHeaders, cancellationToken); + + public Task Post(string requestUri, TRequest request, CancellationToken cancellationToken = default) + => SendWithoutResponse(HttpMethod.Post, requestUri, request, accessToken: null, additionalHeaders: null, cancellationToken); + + public Task Post(string requestUri, TRequest request, List<(string header, string value)> additionalHeaders, CancellationToken cancellationToken = default) + => SendWithoutResponse(HttpMethod.Post, requestUri, request, accessToken: null, additionalHeaders, cancellationToken); + + public Task Post(string requestUri, TRequest request, string accessToken, CancellationToken cancellationToken = default) + => SendWithoutResponse(HttpMethod.Post, requestUri, request, accessToken, additionalHeaders: null, cancellationToken); + + public Task Post(string requestUri, TRequest request, string accessToken, List<(string header, string value)> additionalHeaders, CancellationToken cancellationToken = default) + => SendWithoutResponse(HttpMethod.Post, requestUri, request, accessToken, additionalHeaders, cancellationToken); + + public Task Post(string requestUri, HttpContent content, CancellationToken cancellationToken = default) + => SendWithoutResponse(HttpMethod.Post, requestUri, content, accessToken: null, additionalHeaders: null, cancellationToken); + + public Task Post(string requestUri, HttpContent content, List<(string header, string value)> additionalHeaders, CancellationToken cancellationToken = default) + => SendWithoutResponse(HttpMethod.Post, requestUri, content, accessToken: null, additionalHeaders, cancellationToken); + + public Task Post(string requestUri, HttpContent content, string accessToken, CancellationToken cancellationToken = default) + => SendWithoutResponse(HttpMethod.Post, requestUri, content, accessToken, additionalHeaders: null, cancellationToken); + + public Task Post(string requestUri, HttpContent content, string accessToken, List<(string header, string value)> additionalHeaders, CancellationToken cancellationToken = default) + => SendWithoutResponse(HttpMethod.Post, requestUri, content, accessToken, additionalHeaders, cancellationToken); + + public Task Post(string requestUri, byte[] fileData, string fileName, List<(string field, string value)> formFields, string accessToken, CancellationToken cancellationToken = default) + => SendMultipartWithoutResponse(requestUri, fileData, fileName, formFields, accessToken, additionalHeaders: null, cancellationToken); + + public Task Post(string requestUri, byte[] fileData, string fileName, List<(string field, string value)> formFields, string accessToken, List<(string header, string value)> additionalHeaders, CancellationToken cancellationToken = default) + => SendMultipartWithoutResponse(requestUri, fileData, fileName, formFields, accessToken, additionalHeaders, cancellationToken); + + public Task> Post(string requestUri, TRequest request, CancellationToken cancellationToken = default) + => SendForResponse(HttpMethod.Post, requestUri, request, accessToken: null, additionalHeaders: null, cancellationToken); + + public Task> Post(string requestUri, TRequest request, List<(string header, string value)> additionalHeaders, CancellationToken cancellationToken = default) + => SendForResponse(HttpMethod.Post, requestUri, request, accessToken: null, additionalHeaders, cancellationToken); + + public Task> Post(string requestUri, TRequest request, string accessToken, CancellationToken cancellationToken = default) + => SendForResponse(HttpMethod.Post, requestUri, request, accessToken, additionalHeaders: null, cancellationToken); + + public Task> Post(string requestUri, TRequest request, string accessToken, List<(string header, string value)> additionalHeaders, CancellationToken cancellationToken = default) + => SendForResponse(HttpMethod.Post, requestUri, request, accessToken, additionalHeaders, cancellationToken); + + public Task> Post(string requestUri, HttpContent content, CancellationToken cancellationToken = default) + => SendForResponse(HttpMethod.Post, requestUri, content, accessToken: null, additionalHeaders: null, cancellationToken); + + public Task> Post(string requestUri, HttpContent content, List<(string header, string value)> additionalHeaders, CancellationToken cancellationToken = default) + => SendForResponse(HttpMethod.Post, requestUri, content, accessToken: null, additionalHeaders, cancellationToken); + + public Task> Post(string requestUri, HttpContent content, string accessToken, CancellationToken cancellationToken = default) + => SendForResponse(HttpMethod.Post, requestUri, content, accessToken, additionalHeaders: null, cancellationToken); + + public Task> Post(string requestUri, HttpContent content, string accessToken, List<(string header, string value)> additionalHeaders, CancellationToken cancellationToken = default) + => SendForResponse(HttpMethod.Post, requestUri, content, accessToken, additionalHeaders, cancellationToken); + + public Task Put(string requestUri, TRequest request, CancellationToken cancellationToken = default) + => SendWithoutResponse(HttpMethod.Put, requestUri, request, accessToken: null, additionalHeaders: null, cancellationToken); + + public Task Put(string requestUri, TRequest request, List<(string header, string value)> additionalHeaders, CancellationToken cancellationToken = default) + => SendWithoutResponse(HttpMethod.Put, requestUri, request, accessToken: null, additionalHeaders, cancellationToken); + + public Task Put(string requestUri, TRequest request, string accessToken, CancellationToken cancellationToken = default) + => SendWithoutResponse(HttpMethod.Put, requestUri, request, accessToken, additionalHeaders: null, cancellationToken); + + public Task Put(string requestUri, TRequest request, string accessToken, List<(string header, string value)> additionalHeaders, CancellationToken cancellationToken = default) + => SendWithoutResponse(HttpMethod.Put, requestUri, request, accessToken, additionalHeaders, cancellationToken); + + public Task Put(string requestUri, HttpContent content, CancellationToken cancellationToken = default) + => SendWithoutResponse(HttpMethod.Put, requestUri, content, accessToken: null, additionalHeaders: null, cancellationToken); + + public Task Put(string requestUri, HttpContent content, List<(string header, string value)> additionalHeaders, CancellationToken cancellationToken = default) + => SendWithoutResponse(HttpMethod.Put, requestUri, content, accessToken: null, additionalHeaders, cancellationToken); + + public Task Put(string requestUri, HttpContent content, string accessToken, CancellationToken cancellationToken = default) + => SendWithoutResponse(HttpMethod.Put, requestUri, content, accessToken, additionalHeaders: null, cancellationToken); + + public Task Put(string requestUri, HttpContent content, string accessToken, List<(string header, string value)> additionalHeaders, CancellationToken cancellationToken = default) + => SendWithoutResponse(HttpMethod.Put, requestUri, content, accessToken, additionalHeaders, cancellationToken); + + public Task> Put(string requestUri, TRequest request, CancellationToken cancellationToken = default) + => SendForResponse(HttpMethod.Put, requestUri, request, accessToken: null, additionalHeaders: null, cancellationToken); + + public Task> Put(string requestUri, TRequest request, List<(string header, string value)> additionalHeaders, CancellationToken cancellationToken = default) + => SendForResponse(HttpMethod.Put, requestUri, request, accessToken: null, additionalHeaders, cancellationToken); + + public Task> Put(string requestUri, TRequest request, string accessToken, CancellationToken cancellationToken = default) + => SendForResponse(HttpMethod.Put, requestUri, request, accessToken, additionalHeaders: null, cancellationToken); + + public Task> Put(string requestUri, TRequest request, string accessToken, List<(string header, string value)> additionalHeaders, CancellationToken cancellationToken = default) + => SendForResponse(HttpMethod.Put, requestUri, request, accessToken, additionalHeaders, cancellationToken); + + public Task> Put(string requestUri, HttpContent content, CancellationToken cancellationToken = default) + => SendForResponse(HttpMethod.Put, requestUri, content, accessToken: null, additionalHeaders: null, cancellationToken); + + public Task> Put(string requestUri, HttpContent content, List<(string header, string value)> additionalHeaders, CancellationToken cancellationToken = default) + => SendForResponse(HttpMethod.Put, requestUri, content, accessToken: null, additionalHeaders, cancellationToken); + + public Task> Put(string requestUri, HttpContent content, string accessToken, CancellationToken cancellationToken = default) + => SendForResponse(HttpMethod.Put, requestUri, content, accessToken, additionalHeaders: null, cancellationToken); + + public Task> Put(string requestUri, HttpContent content, string accessToken, List<(string header, string value)> additionalHeaders, CancellationToken cancellationToken = default) + => SendForResponse(HttpMethod.Put, requestUri, content, accessToken, additionalHeaders, cancellationToken); + + public Task Patch(string requestUri, TRequest request, CancellationToken cancellationToken = default) + => SendWithoutResponse(HttpMethod.Patch, requestUri, request, accessToken: null, additionalHeaders: null, cancellationToken); + + public Task Patch(string requestUri, TRequest request, List<(string header, string value)> additionalHeaders, CancellationToken cancellationToken = default) + => SendWithoutResponse(HttpMethod.Patch, requestUri, request, accessToken: null, additionalHeaders, cancellationToken); + + public Task Patch(string requestUri, TRequest request, string accessToken, CancellationToken cancellationToken = default) + => SendWithoutResponse(HttpMethod.Patch, requestUri, request, accessToken, additionalHeaders: null, cancellationToken); + + public Task Patch(string requestUri, TRequest request, string accessToken, List<(string header, string value)> additionalHeaders, CancellationToken cancellationToken = default) + => SendWithoutResponse(HttpMethod.Patch, requestUri, request, accessToken, additionalHeaders, cancellationToken); + + public Task Patch(string requestUri, HttpContent content, CancellationToken cancellationToken = default) + => SendWithoutResponse(HttpMethod.Patch, requestUri, content, accessToken: null, additionalHeaders: null, cancellationToken); + + public Task Patch(string requestUri, HttpContent content, List<(string header, string value)> additionalHeaders, CancellationToken cancellationToken = default) + => SendWithoutResponse(HttpMethod.Patch, requestUri, content, accessToken: null, additionalHeaders, cancellationToken); + + public Task Patch(string requestUri, HttpContent content, string accessToken, CancellationToken cancellationToken = default) + => SendWithoutResponse(HttpMethod.Patch, requestUri, content, accessToken, additionalHeaders: null, cancellationToken); + + public Task Patch(string requestUri, HttpContent content, string accessToken, List<(string header, string value)> additionalHeaders, CancellationToken cancellationToken = default) + => SendWithoutResponse(HttpMethod.Patch, requestUri, content, accessToken, additionalHeaders, cancellationToken); + + public Task> Patch(string requestUri, TRequest request, CancellationToken cancellationToken = default) + => SendForResponse(HttpMethod.Patch, requestUri, request, accessToken: null, additionalHeaders: null, cancellationToken); + + public Task> Patch(string requestUri, TRequest request, List<(string header, string value)> additionalHeaders, CancellationToken cancellationToken = default) + => SendForResponse(HttpMethod.Patch, requestUri, request, accessToken: null, additionalHeaders, cancellationToken); + + public Task> Patch(string requestUri, TRequest request, string accessToken, CancellationToken cancellationToken = default) + => SendForResponse(HttpMethod.Patch, requestUri, request, accessToken, additionalHeaders: null, cancellationToken); + + public Task> Patch(string requestUri, TRequest request, string accessToken, List<(string header, string value)> additionalHeaders, CancellationToken cancellationToken = default) + => SendForResponse(HttpMethod.Patch, requestUri, request, accessToken, additionalHeaders, cancellationToken); + + public Task> Patch(string requestUri, HttpContent content, CancellationToken cancellationToken = default) + => SendForResponse(HttpMethod.Patch, requestUri, content, accessToken: null, additionalHeaders: null, cancellationToken); + + public Task> Patch(string requestUri, HttpContent content, List<(string header, string value)> additionalHeaders, CancellationToken cancellationToken = default) + => SendForResponse(HttpMethod.Patch, requestUri, content, accessToken: null, additionalHeaders, cancellationToken); + + public Task> Patch(string requestUri, HttpContent content, string accessToken, CancellationToken cancellationToken = default) + => SendForResponse(HttpMethod.Patch, requestUri, content, accessToken, additionalHeaders: null, cancellationToken); + + public Task> Patch(string requestUri, HttpContent content, string accessToken, List<(string header, string value)> additionalHeaders, CancellationToken cancellationToken = default) + => SendForResponse(HttpMethod.Patch, requestUri, content, accessToken, additionalHeaders, cancellationToken); + + private async Task SendWithoutResponse(HttpMethod method, + string requestUri, + TRequest? body, + string? accessToken, + List<(string header, string value)>? additionalHeaders, + CancellationToken cancellationToken) + { + try + { + using var request = CreateRequest(method, requestUri, body, accessToken, additionalHeaders); + using var response = await HttpClient.SendAsync(request, cancellationToken); + return await MapNonGenericResult(method, response, cancellationToken); + } + catch (InvalidOperationException exception) + { + return Result.Failure(exception.Message); + } + catch (HttpRequestException exception) + { + return Result.CriticalError(exception.Message); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return Result.CriticalError("The HTTP request timed out."); + } + } + + private async Task SendWithoutResponse(HttpMethod method, + string requestUri, + HttpContent content, + string? accessToken, + List<(string header, string value)>? additionalHeaders, + CancellationToken cancellationToken) + { + try + { + using var request = CreateRequest(method, requestUri, content, accessToken, additionalHeaders); + using var response = await HttpClient.SendAsync(request, cancellationToken); + return await MapNonGenericResult(method, response, cancellationToken); + } + catch (InvalidOperationException exception) + { + return Result.Failure(exception.Message); + } + catch (HttpRequestException exception) + { + return Result.CriticalError(exception.Message); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return Result.CriticalError("The HTTP request timed out."); + } + } + + private async Task SendMultipartWithoutResponse( + string requestUri, + byte[] fileData, + string fileName, + List<(string field, string value)> formFields, + string accessToken, + List<(string header, string value)>? additionalHeaders, + CancellationToken cancellationToken) + { + try + { + using var request = CreateMultipartRequest(requestUri, fileData, fileName, formFields, accessToken, additionalHeaders); + using var response = await HttpClient.SendAsync(request, cancellationToken); + return await MapNonGenericResult(HttpMethod.Post, response, cancellationToken); + } + catch (InvalidOperationException exception) + { + return Result.Failure(exception.Message); + } + catch (HttpRequestException exception) + { + return Result.CriticalError(exception.Message); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return Result.CriticalError("The HTTP request timed out."); + } + } + + private async Task> SendForResponse(HttpMethod method, + string requestUri, + object? body, + string? accessToken, + List<(string header, string value)>? additionalHeaders, + CancellationToken cancellationToken) + { + try + { + using var request = CreateRequest(method, requestUri, body, accessToken, additionalHeaders); + using var response = await HttpClient.SendAsync(request, cancellationToken); + return await MapGenericResult(method, response, cancellationToken); + } + catch (InvalidOperationException exception) + { + return Result.Failure(exception.Message); + } + catch (HttpRequestException exception) + { + return Result.CriticalError(exception.Message); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return Result.CriticalError("The HTTP request timed out."); + } + } + + private async Task> SendForResponse(HttpMethod method, + string requestUri, + HttpContent content, + string? accessToken, + List<(string header, string value)>? additionalHeaders, + CancellationToken cancellationToken) + { + try + { + using var request = CreateRequest(method, requestUri, content, accessToken, additionalHeaders); + using var response = await HttpClient.SendAsync(request, cancellationToken); + return await MapGenericResult(method, response, cancellationToken); + } + catch (InvalidOperationException exception) + { + return Result.Failure(exception.Message); + } + catch (HttpRequestException exception) + { + return Result.CriticalError(exception.Message); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return Result.CriticalError("The HTTP request timed out."); + } + } + + private HttpRequestMessage CreateRequest(HttpMethod method, + string requestUri, + object? body, + string? accessToken, + List<(string header, string value)>? additionalHeaders) + { + if (string.IsNullOrWhiteSpace(requestUri)) + { + throw new ArgumentException("Request URI cannot be null, empty, or whitespace.", nameof(requestUri)); + } + + var request = new HttpRequestMessage(method, requestUri); + + if (accessToken is not null) + { + if (string.IsNullOrWhiteSpace(accessToken)) + { + throw new ArgumentException("Access token cannot be null, empty, or whitespace.", nameof(accessToken)); + } + + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + } + + if (body is HttpContent httpContent) + { + request.Content = httpContent; + } + else if (body is not null) + { + string serialisedBody; + + try + { + serialisedBody = Serialise(body); + } + catch (Exception exception) + { + throw new InvalidOperationException($"The request body could not be serialized: {exception.Message}", exception); + } + + if (serialisedBody is null) + { + throw new InvalidOperationException("The request body could not be serialized."); + } + + request.Content = new StringContent(serialisedBody, Encoding.UTF8, "application/json"); + } + + if (additionalHeaders is not null) + { + AddHeaders(request, additionalHeaders); + } + + return request; + } + + private HttpRequestMessage CreateRequest(HttpMethod method, + string requestUri, + HttpContent content, + string? accessToken, + List<(string header, string value)>? additionalHeaders) + { + if (content is null) + { + throw new ArgumentNullException(nameof(content)); + } + + var request = CreateRequest(method, requestUri, body: null, accessToken, additionalHeaders: null); + request.Content = content; + + if (additionalHeaders is not null) + { + AddHeaders(request, additionalHeaders); + } + + return request; + } + + private HttpRequestMessage CreateMultipartRequest( + string requestUri, + byte[] fileData, + string fileName, + List<(string field, string value)> formFields, + string accessToken, + List<(string header, string value)>? additionalHeaders) + { + if (fileData is null) + { + throw new ArgumentNullException(nameof(fileData)); + } + + if (string.IsNullOrWhiteSpace(fileName)) + { + throw new ArgumentException("File name cannot be null, empty, or whitespace.", nameof(fileName)); + } + + if (formFields is null) + { + throw new ArgumentNullException(nameof(formFields)); + } + + var request = CreateRequest(HttpMethod.Post, requestUri, body: null, accessToken, additionalHeaders: null); + request.Content = CreateMultipartContent(fileData, fileName, formFields); + + if (additionalHeaders is not null) + { + AddHeaders(request, additionalHeaders); + } + + return request; + } + + private static void AddHeaders(HttpRequestMessage request, List<(string header, string value)> additionalHeaders) + { + foreach (var (header, value) in additionalHeaders) + { + if (string.IsNullOrWhiteSpace(header)) + { + throw new ArgumentException("Header name cannot be null, empty, or whitespace.", nameof(additionalHeaders)); + } + + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException($"Header value for '{header}' cannot be null, empty, or whitespace.", nameof(additionalHeaders)); + } + + if (request.Headers.TryAddWithoutValidation(header, value)) + { + continue; + } + + if (request.Content is not null && request.Content.Headers.TryAddWithoutValidation(header, value)) + { + continue; + } + + throw new InvalidOperationException($"The header '{header}' could not be added to the request."); + } + } + + private static MultipartFormDataContent CreateMultipartContent( + byte[] fileData, + string fileName, + List<(string field, string value)> formFields) + { + var multipartContent = new MultipartFormDataContent(); + + var fileContent = new ByteArrayContent(fileData); + fileContent.Headers.ContentType = MediaTypeHeaderValue.Parse("multipart/form-data"); + multipartContent.Add(fileContent, "file", fileName); + + foreach (var (field, value) in formFields) + { + if (string.IsNullOrWhiteSpace(field)) + { + throw new ArgumentException("Form field name cannot be null, empty, or whitespace.", nameof(formFields)); + } + + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException($"Form field value for '{field}' cannot be null, empty, or whitespace.", nameof(formFields)); + } + + multipartContent.Add(new StringContent(value), field); + } + + return multipartContent; + } + + private async Task MapNonGenericResult(HttpMethod method, + HttpResponseMessage response, + CancellationToken cancellationToken) + { + if (response.IsSuccessStatusCode) + { + return MapSuccessfulResult(method, response.StatusCode); + } + + return await MapFailedResult(response, cancellationToken); + } + + private async Task> MapGenericResult(HttpMethod method, + HttpResponseMessage response, + CancellationToken cancellationToken) + { + if (!response.IsSuccessStatusCode) + { + return await MapFailedResult(response, cancellationToken); + } + + var rawContent = await ReadContent(response, cancellationToken); + if (string.IsNullOrWhiteSpace(rawContent)) + { + return Result.Failure("The response content was empty."); + } + + if (typeof(TResponse) == typeof(string)) + { + return Result.Success((TResponse)(object)rawContent); + } + + object deserialisedValue; + + try + { + deserialisedValue = Deserialise(rawContent, typeof(TResponse)); + } + catch (Exception exception) + { + return Result.Failure($"The response body could not be deserialized: {exception.Message}"); + } + + if (deserialisedValue is null) + { + return Result.Failure("The response body could not be deserialized."); + } + + if (deserialisedValue is not TResponse value) + { + return Result.Failure( + $"The deserialized response type '{deserialisedValue.GetType().FullName}' is not assignable to '{typeof(TResponse).FullName}'."); + } + + return method == HttpMethod.Get + ? Result.ObtainedResource(value) + : Result.Success(value); + } + + private static Result MapSuccessfulResult(HttpMethod method, HttpStatusCode statusCode) + { + if (method == HttpMethod.Delete) + { + return Result.DeletedResource(); + } + + if (method == HttpMethod.Post && statusCode == HttpStatusCode.Created) + { + return Result.CreatedResource(); + } + + if (method == HttpMethod.Put || method == HttpMethod.Patch) + { + return Result.UpdatedResource(); + } + + return Result.Success(); + } + + private static async Task MapFailedResult(HttpResponseMessage response, CancellationToken cancellationToken) + { + var rawContent = await ReadContent(response, cancellationToken); + var errorMessages = ExtractErrors(rawContent); + var primaryMessage = BuildPrimaryMessage(response, errorMessages); + + return response.StatusCode switch + { + HttpStatusCode.BadRequest => Result.Invalid(primaryMessage, errorMessages), + HttpStatusCode.Unauthorized => Result.Unauthorized(primaryMessage, errorMessages), + HttpStatusCode.Forbidden => Result.Forbidden(primaryMessage, errorMessages), + HttpStatusCode.NotFound => Result.NotFound(primaryMessage, errorMessages), + HttpStatusCode.Conflict => Result.Conflict(primaryMessage, errorMessages), + _ when (int)response.StatusCode >= 500 => Result.CriticalError(primaryMessage, errorMessages), + _ => Result.Failure(primaryMessage, errorMessages) + }; + } + + private static async Task ReadContent(HttpResponseMessage response, CancellationToken cancellationToken) + { + if (response.Content is null) + { + return null; + } + + return await response.Content.ReadAsStringAsync(cancellationToken); + } + + private static IReadOnlyList ExtractErrors(string? rawContent) + { + if (string.IsNullOrWhiteSpace(rawContent)) + { + return []; + } + + try + { + using var document = JsonDocument.Parse(rawContent); + var errors = new List(); + AppendErrors(document.RootElement, errors); + + return errors.Count == 0 ? [rawContent] : errors; + } + catch (JsonException) + { + return [rawContent]; + } + } + + private static void AppendErrors(JsonElement element, ICollection errors) + { + if (element.ValueKind == JsonValueKind.Object) + { + if (element.TryGetProperty("title", out var title) && title.ValueKind == JsonValueKind.String) + { + AddIfPresent(errors, title.GetString()); + } + + if (element.TryGetProperty("detail", out var detail) && detail.ValueKind == JsonValueKind.String) + { + AddIfPresent(errors, detail.GetString()); + } + + if (element.TryGetProperty("message", out var message) && message.ValueKind == JsonValueKind.String) + { + AddIfPresent(errors, message.GetString()); + } + + if (element.TryGetProperty("errors", out var nestedErrors)) + { + AppendErrors(nestedErrors, errors); + } + + foreach (var property in element.EnumerateObject()) + { + if (property.NameEquals("title") || property.NameEquals("detail") || property.NameEquals("message") || property.NameEquals("errors")) + { + continue; + } + + if (property.Value.ValueKind is JsonValueKind.Array or JsonValueKind.Object) + { + AppendErrors(property.Value, errors); + } + } + + return; + } + + if (element.ValueKind == JsonValueKind.Array) + { + foreach (var item in element.EnumerateArray()) + { + AppendErrors(item, errors); + } + + return; + } + + if (element.ValueKind == JsonValueKind.String) + { + AddIfPresent(errors, element.GetString()); + } + } + + private static void AddIfPresent(ICollection errors, string? value) + { + if (!string.IsNullOrWhiteSpace(value)) + { + errors.Add(value); + } + } + + private static string BuildPrimaryMessage(HttpResponseMessage response, IReadOnlyList errors) + { + if (errors.Count > 0) + { + return errors[0]; + } + + return response.ReasonPhrase ?? $"Request failed with status code {(int)response.StatusCode}."; + } + } +} diff --git a/SecurityService.Client/SecurityServiceClient.cs b/SecurityService.Client/SecurityServiceClient.cs index 17dd2d06..287e7fd8 100644 --- a/SecurityService.Client/SecurityServiceClient.cs +++ b/SecurityService.Client/SecurityServiceClient.cs @@ -1,4 +1,5 @@ -using Shared.Results; +using Newtonsoft.Json; +using Shared.Results; using SimpleResults; namespace SecurityService.Client @@ -17,8 +18,18 @@ namespace SecurityService.Client /// /// /// - public class SecurityServiceClient : ClientProxyBase, ISecurityServiceClient + public class SecurityServiceClient : ClientBase, ISecurityServiceClient { + private static String Serialise(Object arg) + { + return JsonConvert.SerializeObject(arg); + } + + private static Object Deserialise(String arg, Type type) + { + return JsonConvert.DeserializeObject(arg, type); + } + #region Fields /// @@ -34,20 +45,20 @@ public class SecurityServiceClient : ClientProxyBase, ISecurityServiceClient #endregion #region Constructors - + /// /// Initializes a new instance of the class. /// /// The base address resolver. /// The HTTP client. public SecurityServiceClient(Func baseAddressResolver, - HttpClient httpClient) : base(httpClient) + HttpClient httpClient) : base(httpClient, Serialise, Deserialise) { this.BaseAddressResolver = baseAddressResolver; this.BaseAddress = baseAddressResolver("SecurityService"); // Add the API version header - this.HttpClient.DefaultRequestHeaders.Add("api-version", "1.0"); + //this.HttpClient.DefaultRequestHeaders.Add("api-version", "1.0"); } #endregion @@ -61,7 +72,7 @@ public async Task CreateApiResource(CreateApiResourceRequest createApiRe try { - Result result = await this.SendHttpPostRequest(requestUri, createApiResourceRequest, cancellationToken); + Result result = await this.Post(requestUri, createApiResourceRequest, cancellationToken); if(result.IsFailed) return ResultHelpers.CreateFailure(result); @@ -84,7 +95,7 @@ public async Task CreateApiScope(CreateApiScopeRequest createApiScopeReq try { - Result result = await this.SendHttpPostRequest(requestUri, createApiScopeRequest, cancellationToken); + Result result = await this.Post(requestUri, createApiScopeRequest, cancellationToken); if (result.IsFailed) return ResultHelpers.CreateFailure(result); @@ -107,7 +118,7 @@ public async Task CreateClient(CreateClientRequest createClientRequest, try { - Result result = await this.SendHttpPostRequest(requestUri, createClientRequest, cancellationToken); + Result result = await this.Post(requestUri, createClientRequest, cancellationToken); if (result.IsFailed) return ResultHelpers.CreateFailure(result); @@ -130,7 +141,7 @@ public async Task CreateIdentityResource(CreateIdentityResourceRequest c try { - Result result = await this.SendHttpPostRequest(requestUri, createIdentityResourceRequest, cancellationToken); + Result result = await this.Post(requestUri, createIdentityResourceRequest, cancellationToken); if (result.IsFailed) return ResultHelpers.CreateFailure(result); @@ -153,7 +164,7 @@ public async Task CreateRole(CreateRoleRequest createRoleRequest, try { - Result result = await this.SendHttpPostRequest(requestUri, createRoleRequest, cancellationToken); + Result result = await this.Post(requestUri, createRoleRequest, cancellationToken); if (result.IsFailed) return ResultHelpers.CreateFailure(result); @@ -182,7 +193,7 @@ public async Task CreateUser(CreateUserRequest createUserRequest, try { - Result result = await this.SendHttpPostRequest(requestUri, createUserRequest, cancellationToken); + Result result = await this.Post(requestUri, createUserRequest, cancellationToken); if (result.IsFailed) return ResultHelpers.CreateFailure(result); @@ -204,7 +215,7 @@ public async Task> GetApiResource(String apiResource String requestUri = this.BuildRequestUrl($"/api/apiresources/{apiResourceName}"); try { - Result result = await this.SendHttpGetRequest(requestUri, cancellationToken); + Result result = await this.Get(requestUri, cancellationToken); if (result.IsFailed) return ResultHelpers.CreateFailure(result); @@ -226,7 +237,7 @@ public async Task>> GetApiResources(Cancellatio try { - Result> result = await this.SendHttpGetRequest>(requestUri, cancellationToken); + Result> result = await this.Get>(requestUri, cancellationToken); if (result.IsFailed) return ResultHelpers.CreateFailure(result); @@ -249,7 +260,7 @@ public async Task> GetApiScope(String apiScopeName, try { - var result = await this.SendHttpGetRequest(requestUri, cancellationToken); + var result = await this.Get(requestUri, cancellationToken); if (result.IsFailed) return ResultHelpers.CreateFailure(result); @@ -271,7 +282,7 @@ public async Task>> GetApiScopes(CancellationToken try { - var result = await this.SendHttpGetRequest>(requestUri, cancellationToken); + var result = await this.Get>(requestUri, cancellationToken); if (result.IsFailed) return ResultHelpers.CreateFailure(result); @@ -294,7 +305,7 @@ public async Task> GetClient(String clientId, try { - var result = await this.SendHttpGetRequest(requestUri, cancellationToken); + var result = await this.Get(requestUri, cancellationToken); if (result.IsFailed) return ResultHelpers.CreateFailure(result); @@ -316,7 +327,7 @@ public async Task>> GetClients(CancellationToken can try { - var result = await this.SendHttpGetRequest>(requestUri, cancellationToken); + var result = await this.Get>(requestUri, cancellationToken); if (result.IsFailed) return ResultHelpers.CreateFailure(result); @@ -339,7 +350,7 @@ public async Task> GetIdentityResource(String i try { - var result = await this.SendHttpGetRequest(requestUri, cancellationToken); + var result = await this.Get(requestUri, cancellationToken); if (result.IsFailed) return ResultHelpers.CreateFailure(result); @@ -361,7 +372,7 @@ public async Task>> GetIdentityResources(C try { - var result = await this.SendHttpGetRequest>(requestUri, cancellationToken); + var result = await this.Get>(requestUri, cancellationToken); if (result.IsFailed) return ResultHelpers.CreateFailure(result); @@ -384,7 +395,7 @@ public async Task> GetRole(String roleId, try { - var result = await this.SendHttpGetRequest(requestUri, cancellationToken); + var result = await this.Get(requestUri, cancellationToken); if (result.IsFailed) return ResultHelpers.CreateFailure(result); @@ -406,7 +417,7 @@ public async Task>> GetRoles(CancellationToken cancell try { - var result = await this.SendHttpGetRequest>(requestUri, cancellationToken); + var result = await this.Get>(requestUri, cancellationToken); if (result.IsFailed) return ResultHelpers.CreateFailure(result); @@ -474,7 +485,7 @@ public async Task> GetUser(String userId, try { - var result = await this.SendHttpGetRequest(requestUri, cancellationToken); + Result result = await this.Get(requestUri, cancellationToken); if (result.IsFailed) return ResultHelpers.CreateFailure(result); @@ -502,7 +513,7 @@ public async Task>> GetUsers(String userName, requestUri = $"{requestUri}?username={userName}"; } - var result = await this.SendHttpGetRequest>(requestUri, cancellationToken); + var result = await this.Get>(requestUri, cancellationToken); if (result.IsFailed) return ResultHelpers.CreateFailure(result); @@ -539,11 +550,9 @@ private async Task> GetToken(String tokenRequest, StringContent httpContent = new StringContent(tokenRequest, Encoding.UTF8, "application/x-www-form-urlencoded"); // Make the Http Call here - HttpResponseMessage httpResponse = await this.HttpClient.PostAsync(requestUri, httpContent, cancellationToken); - - // Process the response - Result result = await this.HandleResponseX(httpResponse, cancellationToken); + var result = await this.Post(requestUri, httpContent, cancellationToken); + if (result.IsFailed) return ResultHelpers.CreateFailure(result);