Skip to content

Commit

Permalink
fix: Use HttpClient wrappers that ensure success to match FusionCache…
Browse files Browse the repository at this point in the history
… expectations (#684)

## 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 <[email protected]>
  • Loading branch information
elsand and oskogstad authored Apr 30, 2024
1 parent c9a5606 commit 7c1e966
Show file tree
Hide file tree
Showing 9 changed files with 124 additions and 66 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -170,20 +170,7 @@ await SendRequest<List<AuthorizedPartiesResultDto>>(

private async Task<T?> SendRequest<T>(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<T>(responseData, SerializerOptions);
_logger.LogDebug("Authorization request to {Url}: {RequestJson}", url, JsonSerializer.Serialize(request, SerializerOptions));
return await _httpClient.PostAsJsonEnsuredAsync<T>(url, request, serializerOptions: SerializerOptions, cancellationToken: cancellationToken);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<NameRegistryClient> _logger;

private static readonly JsonSerializerOptions SerializerOptions = new()

{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault
};

public NameRegistryClient(HttpClient client, IFusionCacheProvider cacheProvider, ILogger<NameRegistryClient> 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<string?> GetName(string personalIdentificationNumber, CancellationToken cancellationToken)
Expand All @@ -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<NameLookupResult>(
apiUrl,
nameLookup,
serializerOptions: SerializerOptions,
cancellationToken: cancellationToken);

var responseData = await response.Content.ReadAsStringAsync(cancellationToken);
var nameLookupResult = JsonSerializer.Deserialize<NameLookupResult>(responseData, SerializerOptions);
return nameLookupResult?.PartyNames.FirstOrDefault()?.Name;
return nameLookupResult.PartyNames.FirstOrDefault()?.Name;
}

private sealed class NameLookup
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;

Expand All @@ -33,8 +27,9 @@ public OrganizationRegistryClient(HttpClient client, IFusionCacheProvider cacheP
private async Task<Dictionary<string, OrganizationInfo>> GetOrgInfo(CancellationToken cancellationToken)
{
const string searchEndpoint = "orgs/altinn-orgs.json";

var response = await _client
.GetFromJsonAsync<OrganizationRegistryResponse>(searchEndpoint, cancellationToken) ?? throw new UnreachableException();
.GetFromJsonEnsuredAsync<OrganizationRegistryResponse>(searchEndpoint, cancellationToken: cancellationToken);

var orgInfoByOrgNumber = response
.Orgs
Expand Down Expand Up @@ -65,4 +60,4 @@ private sealed class OrganizationDetails
public string? Homepage { get; init; }
public IList<string>? Environments { get; init; }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,10 @@ public async Task<IReadOnlyCollection<string>> GetResourceIds(string org, Cancel
private async Task<Dictionary<string, string[]>> GetResourceIdsByOrg(CancellationToken cancellationToken)
{
const string searchEndpoint = "resourceregistry/api/v1/resource/resourcelist";

var response = await _client
.GetFromJsonAsync<List<ResourceRegistryResponse>>(searchEndpoint, cancellationToken)
?? throw new UnreachableException();
.GetFromJsonEnsuredAsync<List<ResourceRegistryResponse>>(searchEndpoint,
cancellationToken: cancellationToken);

var resourceIdsByOrg = response
.Where(x => x.ResourceType is ResourceTypeGenericAccess or ResourceTypeAltinnApp)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
namespace Digdir.Domain.Dialogporten.Infrastructure.Common.Exceptions;

public interface IUpstreamServiceError;
public class UpstreamServiceException(Exception innerException)
: Exception(innerException.Message, innerException), IUpstreamServiceError;
Original file line number Diff line number Diff line change
@@ -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<T> GetFromJsonEnsuredAsync<T>(
this HttpClient client,
string requestUri,
Action<HttpRequestHeaders>? 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<T>(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<HttpResponseMessage> PostAsJsonEnsuredAsync(
this HttpClient client,
string requestUri,
object content,
Action<HttpRequestHeaders>? configureHeaders = null,
Action<HttpContentHeaders>? 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<T> PostAsJsonEnsuredAsync<T>(
this HttpClient client,
string requestUri,
object content,
Action<HttpRequestHeaders>? configureHeaders = null,
Action<HttpContentHeaders>? 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<T>(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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,15 @@ public static object ResponseBuilder(List<ValidationFailure> 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.",
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -22,7 +23,9 @@ public static IApplicationBuilder UseProblemDetailsExceptionHandler(this IApplic
var error = exHandlerFeature.Error.Message;
var logger = ctx.Resolve<ILogger<ExceptionHandler>>();
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));
});
Expand Down

0 comments on commit 7c1e966

Please sign in to comment.