I've started to play around a bit with Blazor WASM.
I also have an API that's protected with a JWT, and since I want to follow the "no tokens in the browser-policy", I've introduced a BFF (backend for frontend).
Now, the main point that get's everyone hyped about Blazor is code reusability, or code sharing. I have a typed HttpClient in a nuget package that looks like this:
public class MyHttpClient
{
private readonly HttpClient _httpClient;
public MyHttpClient(HttpClient httpClient)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
}
public async Task<HealthResponse> CheckHealth()
{
var request = new HttpRequestMessage(HttpMethod.Get, "/health");
var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
// Error handling omitted for brevity
response.EnsureSuccessStatusCode()
var responseBody = await response.Content.ReadAsStreamAsync();
return await JsonSerializer.DeserializeAsync<HealthResponse>(responseBody);
}
...
// A bunch of other methods
}
This code talks directly with my API and it works great. But...since I've now want to use a BFF, I want all of my calls to go via the BFF instead.
I've configured the BFF so that all calls that starts with the /my-api/
prefix are proxied to my API.
So the only thing left for me to do is to update the request above to the following:
var request = new HttpRequestMessage(HttpMethod.Get, "/my-api/health");
Great success.
But...
It turns out that I'm not the only consumer of this nuget package. A bunch of very angry people are telling me that the latest version of my nuget package "SUCKS". They don't want to proxy their calls via the BFF...ooops.
Luckily, there's a really easy solution for this. Let's check it out.
First, let's remove the /my-api/
prefix from the code again and publish a new version so that people will stop shouting at me.
DelegatingHandler to the rescue
I still want all of my calls to go via the BFF.
Therefor, I've created the following DelegatingHandler.
public abstract class PrefixPathDelegatingHandler : DelegatingHandler
{
private readonly string _prefix;
protected PrefixPathDelegatingHandler(string prefix)
{
_prefix = prefix ?? throw new ArgumentNullException(nameof(prefix));
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
var requestPath = request.RequestUri?.LocalPath ?? string.Empty;
if(requestPath.StartsWith(_prefix, StringComparison.OrdinalIgnoreCase))
{
return base.SendAsync(request, cancellationToken);
}
var baseUrl = request.RequestUri!.GetLeftPart(UriPartial.Authority);
var prefixedPath = $"{_prefix}{PrefixWithSlashIfMissing(request.RequestUri.PathAndQuery)}";
request.RequestUri = new Uri(new Uri(baseUrl), new Uri(prefixedPath, UriKind.Relative));
return base.SendAsync(request, cancellationToken);
}
private static string PrefixWithSlashIfMissing(string path)
{
return path.StartsWith("/") ? path : $"/{path}";
}
}
I've then implemented the abstract class like this:
public class MyApiPrefixPathDelegatingHandler : PrefixPathDelegatingHandler
{
public MyApiPrefixPathDelegatingHandler() : base("/my-api") {}
}
To start using the DelegatingHandler, I register it like this:
builder.Services.AddHttpClient<MyHttpClient>((provider, client) =>
{
var bffHost = configuration["Bff:Host"]!;
client.BaseAddress = new Uri(bffHost);
client.DefaultRequestHeaders.Add("X-CSRF", "1");
}).AddHttpMessageHandler<MyApiPrefixPathDelegatingHandler>();
The AddHttpMessageHandler
adds our DelegatingHandler to the httpclient "pipeline". This means that all calls via our MyHttpClient
will run the code in MyApiPrefixPathDelegatingHandler
, resulting in all calls getting the /my-api
prefix.
Here's some tests that verifies that everything works as intended.
[Theory]
[InlineData("", "/my-api/")]
[InlineData("/", "/my-api/")]
[InlineData("/health", "/my-api/health")]
public async Task ShouldAddMyApiPrefixWhenMissingFromRequest(string path, string expected)
{
var response = new HttpResponseMessage(HttpStatusCode.OK);
var fakeHttpMessageHandler = new FakeHttpMessageHandler(response);
var request = new HttpRequestMessage(HttpMethod.Get, path);
var myApiPrefixDelegatingHandler = new MyApiPrefixPathDelegatingHandler
{
InnerHandler = fakeHttpMessageHandler
};
var sut = new HttpClient(myApiPrefixDelegatingHandler)
{
BaseAddress = new Uri("http://any-bff.local")
};
var result = await sut.SendAsync(request);
result.StatusCode.ShouldBe(HttpStatusCode.OK);
request.RequestUri!.AbsolutePath.ShouldBe(expected);
}
[Fact]
public async Task ShouldNotAddMyApiPrefixWhenAlreadyPresent()
{
var response = new HttpResponseMessage(HttpStatusCode.OK);
var fakeHttpMessageHandler = new FakeHttpMessageHandler(response);
var request = new HttpRequestMessage(HttpMethod.Get, "/my-api/health");
var myApiPrefixDelegatingHandler = new MyApiPrefixPathDelegatingHandler
{
InnerHandler = fakeHttpMessageHandler
};
var sut = new HttpClient(myApiPrefixDelegatingHandler)
{
BaseAddress = new Uri("http://any-bff.local")
};
var result = await sut.SendAsync(request);
result.StatusCode.ShouldBe(HttpStatusCode.OK);
request.RequestUri!.AbsolutePath.ShouldBe("/my-api/health");
}
public class FakeHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, Task<HttpResponseMessage>> _responseFactory;
public FakeHttpMessageHandler(HttpResponseMessage httpResponseMessage) :
this(_ => Task.FromResult(httpResponseMessage))
{
}
public FakeHttpMessageHandler(Func<HttpRequestMessage, Task<HttpResponseMessage>> responseFactory)
{
_responseFactory = responseFactory ?? throw new ArgumentNullException(nameof(responseFactory));
}
protected async override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return await _responseFactory.Invoke(request);
}
}
Problem solved.