From 7c1e96650cf54594a33fb380c8b42518392f1741 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Dybvik=20Langfors?= Date: Tue, 30 Apr 2024 14:16:47 +0200 Subject: [PATCH] fix: Use HttpClient wrappers that ensure success to match FusionCache expectations (#684) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description This fixes #671 by implementing a wrapper via extension methods that causes post/get requests that don't return a successful message to throw exceptions. ## Related Issue(s) - #671 ## Verification - [x] **Your** code builds clean without any errors or warnings - [x] Manual testing done (required) - [ ] Relevant automated test added (if you find this hard, leave it and we'll help out) ## Documentation - [ ] Documentation is updated (either in `docs`-directory, Altinnpedia or a separate linked PR in [altinn-studio-docs.](https://github.com/Altinn/altinn-studio-docs), if applicable) --------- Co-authored-by: Ole Jørgen Skogstad --- .../AltinnAuthorizationClient.cs | 17 +--- .../Altinn/Events/AltinnEventsClient.cs | 18 ++-- .../Altinn/NameRegistry/NameRegistryClient.cs | 35 ++------ .../OrganizationRegistryClient.cs | 11 +-- .../ResourceRegistryClient.cs | 5 +- .../Exceptions/UpstreamServiceException.cs | 5 ++ .../HttpClientExtensions.cs | 83 +++++++++++++++++++ .../ErrorResponseBuilderExtensions.cs | 9 ++ .../Extensions/ExceptionHandlerExtensions.cs | 7 +- 9 files changed, 124 insertions(+), 66 deletions(-) create mode 100644 src/Digdir.Domain.Dialogporten.Infrastructure/Common/Exceptions/UpstreamServiceException.cs create mode 100644 src/Digdir.Domain.Dialogporten.Infrastructure/HttpClientExtensions.cs diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/AltinnAuthorizationClient.cs b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/AltinnAuthorizationClient.cs index 9d5f9ee6b..1170ec1b6 100644 --- a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/AltinnAuthorizationClient.cs +++ b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/AltinnAuthorizationClient.cs @@ -170,20 +170,7 @@ await SendRequest>( private async Task SendRequest(string url, object request, CancellationToken cancellationToken) { - var requestJson = JsonSerializer.Serialize(request, SerializerOptions); - _logger.LogDebug("Authorization request to {Url}: {RequestJson}", url, requestJson); - var httpContent = new StringContent(requestJson, Encoding.UTF8, "application/json"); - var response = await _httpClient.PostAsync(url, httpContent, cancellationToken); - if (response.StatusCode != HttpStatusCode.OK) - { - var errorResponse = await response.Content.ReadAsStringAsync(cancellationToken); - _logger.LogWarning("AltinnAuthorizationClient.SendRequest failed with non-successful status code: {StatusCode} {Response}", - response.StatusCode, errorResponse); - - return default; - } - - var responseData = await response.Content.ReadAsStringAsync(cancellationToken); - return JsonSerializer.Deserialize(responseData, SerializerOptions); + _logger.LogDebug("Authorization request to {Url}: {RequestJson}", url, JsonSerializer.Serialize(request, SerializerOptions)); + return await _httpClient.PostAsJsonEnsuredAsync(url, request, serializerOptions: SerializerOptions, cancellationToken: cancellationToken); } } diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Events/AltinnEventsClient.cs b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Events/AltinnEventsClient.cs index b14c0b218..ea66ede2c 100644 --- a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Events/AltinnEventsClient.cs +++ b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Events/AltinnEventsClient.cs @@ -1,5 +1,4 @@ using System.Net.Http.Headers; -using System.Net.Http.Json; using Digdir.Domain.Dialogporten.Application.Externals; using Digdir.Domain.Dialogporten.Infrastructure.Common.Serialization; using Microsoft.Extensions.Logging; @@ -16,16 +15,13 @@ public AltinnEventsClient(HttpClient client) } public async Task Publish(CloudEvent cloudEvent, CancellationToken cancellationToken) - { - var uriBuilder = new UriBuilder(_client.BaseAddress!) { Path = "/events/api/v1/events" }; - var msg = new HttpRequestMessage(HttpMethod.Post, uriBuilder.Uri) - { - Content = JsonContent.Create(cloudEvent, options: SerializerOptions.CloudEventSerializerOptions) - }; - msg.Content.Headers.ContentType = new MediaTypeHeaderValue("application/cloudevents+json"); - var response = await _client.SendAsync(msg, cancellationToken); - response.EnsureSuccessStatusCode(); - } + => await _client.PostAsJsonEnsuredAsync( + "/events/api/v1/events", + cloudEvent, + serializerOptions: SerializerOptions.CloudEventSerializerOptions, + configureContentHeaders: h + => h.ContentType = new MediaTypeHeaderValue("application/cloudevents+json"), + cancellationToken: cancellationToken); } internal class ConsoleLogEventBus : ICloudEventBus diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/NameRegistry/NameRegistryClient.cs b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/NameRegistry/NameRegistryClient.cs index da0579782..fd1d48f08 100644 --- a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/NameRegistry/NameRegistryClient.cs +++ b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/NameRegistry/NameRegistryClient.cs @@ -1,35 +1,25 @@ -using System.Net; -using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using Digdir.Domain.Dialogporten.Application.Externals; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Logging; using ZiggyCreatures.Caching.Fusion; namespace Digdir.Domain.Dialogporten.Infrastructure.Altinn.NameRegistry; internal class NameRegistryClient : INameRegistry { - private static readonly DistributedCacheEntryOptions OneDayCacheDuration = new() { AbsoluteExpiration = DateTimeOffset.UtcNow.AddDays(1) }; - private static readonly DistributedCacheEntryOptions ZeroCacheDuration = new() { AbsoluteExpiration = DateTimeOffset.MinValue }; - private readonly IFusionCache _cache; private readonly HttpClient _client; - private readonly ILogger _logger; private static readonly JsonSerializerOptions SerializerOptions = new() - { PropertyNameCaseInsensitive = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault }; - public NameRegistryClient(HttpClient client, IFusionCacheProvider cacheProvider, ILogger logger) + public NameRegistryClient(HttpClient client, IFusionCacheProvider cacheProvider) { _client = client ?? throw new ArgumentNullException(nameof(client)); _cache = cacheProvider.GetCache(nameof(NameRegistry)) ?? throw new ArgumentNullException(nameof(cacheProvider)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task GetName(string personalIdentificationNumber, CancellationToken cancellationToken) @@ -51,24 +41,13 @@ public NameRegistryClient(HttpClient client, IFusionCacheProvider cacheProvider, ] }; - var requestJson = JsonSerializer.Serialize(nameLookup, SerializerOptions); - var httpContent = new StringContent(requestJson, Encoding.UTF8, "application/json"); - - var response = await _client.PostAsync(apiUrl, httpContent, cancellationToken); - - if (response.StatusCode != HttpStatusCode.OK) - { - var errorResponse = await response.Content.ReadAsStringAsync(cancellationToken); - _logger.LogWarning( - nameof(NameRegistryClient) + ".SendRequest failed with non-successful status code: {StatusCode} {Response}", - response.StatusCode, errorResponse); - - return null; - } + var nameLookupResult = await _client.PostAsJsonEnsuredAsync( + apiUrl, + nameLookup, + serializerOptions: SerializerOptions, + cancellationToken: cancellationToken); - var responseData = await response.Content.ReadAsStringAsync(cancellationToken); - var nameLookupResult = JsonSerializer.Deserialize(responseData, SerializerOptions); - return nameLookupResult?.PartyNames.FirstOrDefault()?.Name; + return nameLookupResult.PartyNames.FirstOrDefault()?.Name; } private sealed class NameLookup diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/OrganizationRegistry/OrganizationRegistryClient.cs b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/OrganizationRegistry/OrganizationRegistryClient.cs index a371f9dcf..316c4aeb1 100644 --- a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/OrganizationRegistry/OrganizationRegistryClient.cs +++ b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/OrganizationRegistry/OrganizationRegistryClient.cs @@ -1,7 +1,4 @@ -using System.Diagnostics; -using System.Net.Http.Json; using Digdir.Domain.Dialogporten.Application.Externals; -using Microsoft.Extensions.Caching.Distributed; using ZiggyCreatures.Caching.Fusion; namespace Digdir.Domain.Dialogporten.Infrastructure.Altinn.OrganizationRegistry; @@ -10,9 +7,6 @@ internal class OrganizationRegistryClient : IOrganizationRegistry { private const string OrgNameReferenceCacheKey = "OrgNameReference"; - private static readonly DistributedCacheEntryOptions OneDayCacheDuration = new() { AbsoluteExpiration = DateTimeOffset.UtcNow.AddDays(1) }; - private static readonly DistributedCacheEntryOptions ZeroCacheDuration = new() { AbsoluteExpiration = DateTimeOffset.MinValue }; - private readonly IFusionCache _cache; private readonly HttpClient _client; @@ -33,8 +27,9 @@ public OrganizationRegistryClient(HttpClient client, IFusionCacheProvider cacheP private async Task> GetOrgInfo(CancellationToken cancellationToken) { const string searchEndpoint = "orgs/altinn-orgs.json"; + var response = await _client - .GetFromJsonAsync(searchEndpoint, cancellationToken) ?? throw new UnreachableException(); + .GetFromJsonEnsuredAsync(searchEndpoint, cancellationToken: cancellationToken); var orgInfoByOrgNumber = response .Orgs @@ -65,4 +60,4 @@ private sealed class OrganizationDetails public string? Homepage { get; init; } public IList? Environments { get; init; } } -} \ No newline at end of file +} diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/ResourceRegistry/ResourceRegistryClient.cs b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/ResourceRegistry/ResourceRegistryClient.cs index 65562efeb..b7c71bc1e 100644 --- a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/ResourceRegistry/ResourceRegistryClient.cs +++ b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/ResourceRegistry/ResourceRegistryClient.cs @@ -34,9 +34,10 @@ public async Task> GetResourceIds(string org, Cancel private async Task> GetResourceIdsByOrg(CancellationToken cancellationToken) { const string searchEndpoint = "resourceregistry/api/v1/resource/resourcelist"; + var response = await _client - .GetFromJsonAsync>(searchEndpoint, cancellationToken) - ?? throw new UnreachableException(); + .GetFromJsonEnsuredAsync>(searchEndpoint, + cancellationToken: cancellationToken); var resourceIdsByOrg = response .Where(x => x.ResourceType is ResourceTypeGenericAccess or ResourceTypeAltinnApp) diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/Common/Exceptions/UpstreamServiceException.cs b/src/Digdir.Domain.Dialogporten.Infrastructure/Common/Exceptions/UpstreamServiceException.cs new file mode 100644 index 000000000..8505d6f2e --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.Infrastructure/Common/Exceptions/UpstreamServiceException.cs @@ -0,0 +1,5 @@ +namespace Digdir.Domain.Dialogporten.Infrastructure.Common.Exceptions; + +public interface IUpstreamServiceError; +public class UpstreamServiceException(Exception innerException) + : Exception(innerException.Message, innerException), IUpstreamServiceError; diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/HttpClientExtensions.cs b/src/Digdir.Domain.Dialogporten.Infrastructure/HttpClientExtensions.cs new file mode 100644 index 000000000..0cb237e07 --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.Infrastructure/HttpClientExtensions.cs @@ -0,0 +1,83 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using Digdir.Domain.Dialogporten.Infrastructure.Common.Exceptions; + +namespace Digdir.Domain.Dialogporten.Infrastructure; + +public static class HttpClientExtensions +{ + public static async Task GetFromJsonEnsuredAsync( + this HttpClient client, + string requestUri, + Action? configureHeaders = null, + CancellationToken cancellationToken = default) + { + try + { + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, requestUri); + configureHeaders?.Invoke(httpRequestMessage.Headers); + var response = await client.SendAsync(httpRequestMessage, cancellationToken); + response.EnsureSuccessStatusCode(); + var result = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + return result is null + ? throw new JsonException($"Failed to deserialize JSON to type {typeof(T).FullName} from {requestUri}") + : result; + } + catch (Exception e) + { + throw new UpstreamServiceException(e); + } + } + + public static async Task PostAsJsonEnsuredAsync( + this HttpClient client, + string requestUri, + object content, + Action? configureHeaders = null, + Action? configureContentHeaders = null, + JsonSerializerOptions? serializerOptions = null, + CancellationToken cancellationToken = default) + { + try + { + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, requestUri) + { + Content = JsonContent.Create(content, options: serializerOptions) + }; + configureHeaders?.Invoke(httpRequestMessage.Headers); + configureContentHeaders?.Invoke(httpRequestMessage.Content.Headers); + var response = await client.SendAsync(httpRequestMessage, cancellationToken); + response.EnsureSuccessStatusCode(); + return response; + } + catch (Exception e) + { + throw new UpstreamServiceException(e); + } + } + + public static async Task PostAsJsonEnsuredAsync( + this HttpClient client, + string requestUri, + object content, + Action? configureHeaders = null, + Action? configureContentHeaders = null, + JsonSerializerOptions? serializerOptions = null, + CancellationToken cancellationToken = default) + { + var response = await client.PostAsJsonEnsuredAsync(requestUri, content, configureHeaders, + configureContentHeaders, serializerOptions, cancellationToken); + try + { + var result = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + return result is null + ? throw new JsonException($"Failed to deserialize JSON to type {typeof(T).FullName} from {requestUri}") + : result; + } + catch (Exception e) + { + throw new UpstreamServiceException(e); + } + } +} diff --git a/src/Digdir.Domain.Dialogporten.WebApi/Common/Extensions/ErrorResponseBuilderExtensions.cs b/src/Digdir.Domain.Dialogporten.WebApi/Common/Extensions/ErrorResponseBuilderExtensions.cs index c092029de..08ca550e4 100644 --- a/src/Digdir.Domain.Dialogporten.WebApi/Common/Extensions/ErrorResponseBuilderExtensions.cs +++ b/src/Digdir.Domain.Dialogporten.WebApi/Common/Extensions/ErrorResponseBuilderExtensions.cs @@ -64,6 +64,15 @@ public static object ResponseBuilder(List failures, HttpConte Instance = ctx.Request.Path, Extensions = { { "traceId", Activity.Current?.Id ?? ctx.TraceIdentifier } } }, + StatusCodes.Status502BadGateway => new ProblemDetails + { + Title = "Bad gateway.", + Detail = "An upstream server is down or returned an invalid response. Please try again later.", + Type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.6.3", + Status = statusCode, + Instance = ctx.Request.Path, + Extensions = { { "traceId", Activity.Current?.Id ?? ctx.TraceIdentifier } } + }, _ => new ProblemDetails { Title = "An error occurred while processing the request.", diff --git a/src/Digdir.Domain.Dialogporten.WebApi/Common/Extensions/ExceptionHandlerExtensions.cs b/src/Digdir.Domain.Dialogporten.WebApi/Common/Extensions/ExceptionHandlerExtensions.cs index e7bf33b5a..9e447c760 100644 --- a/src/Digdir.Domain.Dialogporten.WebApi/Common/Extensions/ExceptionHandlerExtensions.cs +++ b/src/Digdir.Domain.Dialogporten.WebApi/Common/Extensions/ExceptionHandlerExtensions.cs @@ -1,4 +1,5 @@ -using FastEndpoints; +using Digdir.Domain.Dialogporten.Infrastructure.Common.Exceptions; +using FastEndpoints; using Microsoft.AspNetCore.Diagnostics; namespace Digdir.Domain.Dialogporten.WebApi.Common.Extensions; @@ -22,7 +23,9 @@ public static IApplicationBuilder UseProblemDetailsExceptionHandler(this IApplic var error = exHandlerFeature.Error.Message; var logger = ctx.Resolve>(); logger.LogError(exHandlerFeature.Error, "{@Http}{@Type}{@Reason}", http, type, error); - ctx.Response.StatusCode = StatusCodes.Status500InternalServerError; + ctx.Response.StatusCode = exHandlerFeature.Error is IUpstreamServiceError + ? StatusCodes.Status502BadGateway + : StatusCodes.Status500InternalServerError; ctx.Response.ContentType = "application/problem+json"; await ctx.Response.WriteAsJsonAsync(ctx.ResponseBuilder(ctx.Response.StatusCode)); });