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.