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);