C# interpolate a raw JSON string

It’s sometimes handy (e.g, in test suites) to have a JSON string hardcoded. If you want to interpolate it to insert a variable, use $$, like this:


var ratingUnitNumber = "test";
var cloudEvent = $$"""
{
  "id": "5565db35-01a9-4596-b611-9b26d2210c6e",
  "source": "https://s4h-dev.blah.govt.nz",
  "type": "nz.govt.blah.s4.ratingunitchanged",
  "subject": "blah",
  "data": {
  "ratingUnits": [
    {
      "ratingUnitNumber": "{{ratingUnitNumber}}"
    }]
  },
  "time": "2024-03-11T23:09:27.5683052+00:00",
  "specversion": "1.0"
}
""";

Hopefully that helps someone.

Blazor WASM configuration in an Azure Static Web app

I had a simple enough requirement, to display the name of the current environment in the title of a Blazor WASM web app.

The Blazor code was simple enough:

appsettings.json:
{
  "Environment": "local"
}
Home.razor:
@page "/"
@inject IConfiguration configuration

@{
    var title = $"My cool app ({configuration["Environment"]})";
}
<PageTitle>@title</PageTitle>

<h1>@title</h1>

 

This displays “My cool app (local)” when debugging locally. I want to change it to “My cool app (dev)”, “My cool app (test)”, and “My cool app (prod)” in each environment it gets deployed to.

A couple of things I tried and failed:

    1. Set a value for “Environment” using the “Environment variables” blade of the Static Web App in the Azure portal – hoping that this would override what’s in the appsettings.json. This doesn’t work, as values set here are only accessible for a managed Azure Function hosted on the static web app and not for front end code running in the browser.
    2. I tried setting an Environment variable in the AzureStaticWebApp@0 task like
      inputs:
        app_location: 'src'
        api_location: 'api'
        output_location: 'public'
        azure_static_web_apps_api_token: $(deployment_token)
      env: # Add environment variables here
        Environment: 'poop'
      

      But that didn’t work.

Eventually I noticed that the Blazor WASM app loads appsettings.json – you can see it in the Browser network tools.
So in my build pipeline I can use the good ol’ Replace Tokens task to replace tokens in my appsettings.json.
Man, that’s a blast from the past, I first used this task about ten years ago!

appsettings.Development.json:
{
  "Environment": "local"
}

appsettings.json:
{
  "Environment": "#{Environment}#"
}

build.yml:
    - task: qetza.replacetokens.replacetokens-task.replacetokens@6
      inputs:
        sources: '**/appsettings.json'

My build pipeline already has a variable called “Environment” so I didn’t have to add that.

I used the same name, i.e. “Environment” in my appsettings.json token and it just worked:

Add icons to Blazor’s NavMenu in .NET 8

The default Blazor navigation menu is in NavMenu.razor. Its CSS is in NavMenu.razor.css, and contains such exciting classes as bi-plus-square-fill-nav-menu and bi-house-door-fill-nav-menu.

These are all Bootstrap Icons. Here I’ll show you how to add another Bootstrap icon to the NavMenu.

I’m going to add the “Envelope exclamation fill” icon. From the Bootstrap page, copy the SVG code to your clipboard, e.g.

<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-envelope-exclamation-fill" viewBox="0 0 16 16">
  <path d="M.05 3.555A2 2 0 0 1 2 2h12a2 2 0 0 1 1.95 1.555L8 8.414zM0 4.697v7.104l5.803-3.558zM6.761 8.83l-6.57 4.026A2 2 0 0 0 2 14h6.256A4.5 4.5 0 0 1 8 12.5a4.49 4.49 0 0 1 1.606-3.446l-.367-.225L8 9.586zM16 4.697v4.974A4.5 4.5 0 0 0 12.5 8a4.5 4.5 0 0 0-1.965.45l-.338-.207z"/>
  <path d="M12.5 16a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7m.5-5v1.5a.5.5 0 0 1-1 0V11a.5.5 0 0 1 1 0m0 3a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0"/>
</svg>

We’re gonna need to URL encode the above. Paste the above into Notepad, and then Ctrl-H to replace:

currentColor with white
" with '
< with %3C
> with %3E
and remove all new lines
which should give you
%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-envelope-exclamation-fill' viewBox='0 0 16 16'%3E%3Cpath d='M.05 3.555A2 2 0 0 1 2 2h12a2 2 0 0 1 1.95 1.555L8 8.414zM0 4.697v7.104l5.803-3.558zM6.761 8.83l-6.57 4.026A2 2 0 0 0 2 14h6.256A4.5 4.5 0 0 1 8 12.5a4.49 4.49 0 0 1 1.606-3.446l-.367-.225L8 9.586zM16 4.697v4.974A4.5 4.5 0 0 0 12.5 8a4.5 4.5 0 0 0-1.965.45l-.338-.207z'/%3E%3Cpath d='M12.5 16a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7m.5-5v1.5a.5.5 0 0 1-1 0V11a.5.5 0 0 1 1 0m0 3a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0'/%3E%3C/svg%3E

Now open the NavMenu.razor.css and copy one of the existing styles such as .bi-list-nested-nav-menu and give it a new name, such as .bi-envelope-fill-nav-menu. Paste in the above after “data:image/svg+xml,”.

.bi-envelope-fill-nav-menu {
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-envelope-exclamation-fill' viewBox='0 0 16 16'%3E%3Cpath d='M.05 3.555A2 2 0 0 1 2 2h12a2 2 0 0 1 1.95 1.555L8 8.414zM0 4.697v7.104l5.803-3.558zM6.761 8.83l-6.57 4.026A2 2 0 0 0 2 14h6.256A4.5 4.5 0 0 1 8 12.5a4.49 4.49 0 0 1 1.606-3.446l-.367-.225L8 9.586zM16 4.697v4.974A4.5 4.5 0 0 0 12.5 8a4.5 4.5 0 0 0-1.965.45l-.338-.207z'/%3E%3Cpath d='M12.5 16a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7m.5-5v1.5a.5.5 0 0 1-1 0V11a.5.5 0 0 1 1 0m0 3a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0'/%3E%3C/svg%3E");
}

Now you can use the bi-envelope-fill-nav-menu class in the NavMenu.razor:

<div class="nav-item px-3">
    <NavLink class="nav-link" href="rating-unit-dead-letter">
        <span class="bi bi-envelope-fill-nav-menu" aria-hidden="true"></span> Rating Unit Dead Letter
    </NavLink>
</div>

And here it is:

Add secrets.json to an Azure Function

I was surprised to see that .NET 8 Azure functions don’t include support for ASP.NET’s Secret Manager aka User secrets aka secrets.json out of the box.

Out of the box you get this:

var host = new HostBuilder()
    .ConfigureFunctionsWorkerDefaults()
    .ConfigureServices(services =>
    {
        services.AddApplicationInsightsTelemetryWorkerService();
        services.ConfigureFunctionsApplicationInsights();
    })
    .Build();

host.Run();

To add user secrets, you’ll need to install the Microsoft.Extensions.Configuration.UserSecrets NuGet package, and then call .ConfigureAppConfiguration(), like so:

var host = new HostBuilder()
    .ConfigureFunctionsWorkerDefaults()
    .ConfigureAppConfiguration((hostContext, config) =>
    {
        if (hostContext.HostingEnvironment.IsDevelopment())
        {
            config.AddJsonFile("local.settings.json");
            config.AddUserSecrets<Program>();
        }
    })
    .ConfigureServices(services =>
    {
        services.AddApplicationInsightsTelemetryWorkerService();
        services.ConfigureFunctionsApplicationInsights();
    })
    .Build();

host.Run();

Edit: According to this page, User secrets are supported in Azure Functions Core Tools (version 3.0.3233 or later) – so maybe the above isn’t needed any more.

Operation references schema that does not exist.

I was getting the above error while trying to deploy Bicep templates for an Azure API Management API.

Specifically, I was trying to use the validate-content policy to validate the JSON schema of the request body.

I had this to define my API’s operation (i.e. endpoint):

resource CreateRatingUnitOperation 'Microsoft.ApiManagement/service/apis/operations@2023-03-01-preview' = {
  parent: Api
  name: 'createratingunit'
  properties: {
    displayName: 'Create Rating Unit'
    method: 'POST'
    urlTemplate: '/rating-unit'
    description: 'Create a rating unit changed notification'
    request: {
      representations: [
        {
           contentType: 'application/json' 
           schemaId: 'property-definition' // this is needed for the validate-content policy to work
        }
      ]
    }
    responses: [
      {
        description: 'success'
        statusCode: 201
      }
    ]
  }
}

But actually, the schemaId is not needed to be defined here. Removing the schemaId (but leaving contentType: ‘application/json’) fixes the problem.

Bicep warning – BCP081: Resource type does not have types available

If you see the above BCP081 warning, it means you’re trying to use something like this, which is copied and pasted from Microsoft’s own reference page:


resource symbolicname 'Microsoft.ApiManagement/service/apis/schemas@2023-05-01-preview' = {

BCP081 means that the type Microsoft.ApiManagement/service/apis/schemas is not in the 2023-05-01-preview API version.

This Stack Overflow answer explains one way to find which API version the type is in, by searching Azure’s ARM GitHub repo.

So in my case, I went over to that repo, and then typed a / to search through the whole repo. I searched for Microsoft.ApiManagement/service/apis/schemas which returned these 5 results:

Github search results

Which tells me that Microsoft.ApiManagement/service/apis/schemas is in 5 API versions:

  • 2021-08-01
  • 2022-08-01
  • 2021-04-01-preview
  • 2021-12-01-preview
  • 2022-04-01-preview.

So I should change my bicep to use one of those versions to make the Warning go away:


resource symbolicname 'Microsoft.ApiManagement/service/apis/schemas@2022-08-01' = {

It seems to me that there’s a bug in Microsoft’s documentation generator in that it suggested I use 2023-05-01-preview, since using that version generates a warning.

As a side note, no matter which version of Microsoft.ApiManagement/service/apis/schemas I used, I couldn’t get it to deploy the Definition to APIM. The deployment would run without errors, but the Definition never appeared in the APIM portal. So I ended up using Microsoft.ApiManagement/service/schemas@2022-08-01 instead:


resource propertySchema 'Microsoft.ApiManagement/service/schemas@2022-08-01' = {
  name: 'property-definition'
  parent: apimService
  properties: {
    description: 'Used to validate /property request'
    document: loadJsonContent('apim/create-property-schema.json')
    schemaType: 'json'
    value: any(null)
  }
}

which deploys the schema to a different location, but is an acceptable workaround for my needs – the validate-content APIM policy.

Deploy .NET 8 Blazor app to Azure Static Web App

At work I was asked to create and deploy a new .NET 8 Blazor app to an Azure Static Web App.

I’m brand new to Blazor, and brand new to Azure Static Web Apps, so I had a few stumbling blocks.

One of them was that I was getting this error when deploying via Azure Pipelines (using the AzureStaticWebApp@0 task):

Failed to find a default file in the app artifacts folder. Valid default files: index.html,Index.html.

The root cause of this was that when I created my Blazor app, I did so in Visual Studio 2022 using the “Blazor Web App” project template:

Visual Studio new Project dialog box, showing Blazor Web App

I should have instead used the “Blazor WebAssembly Standalone App” project template:

Visual Studio new Project dialog box, showing Blazor WebAssembly Standalone App

This was an easy newbie mistake to make, because:

  1. The Blazor Web App template is suggested immediately, I didn’t need to search for it. I didn’t know the WebAssembly Standalone App template exists, until I searched for “Blazor” in the New project search box.
  2. The Blazor Web App template includes options to choose the Interactive Render Mode, and WebAssembly is one of them, so I thought I was choosing doing the correct option for an Azure Static Web app.

More instructions are on Thomas Gauvin’s blog post.

Here’s some more thoughts I have on Static Web Apps so far, particularly the AzureStaticWebApp@0 Pipeline task.

  1. I don’t like the deployment model, i.e. how it deploys from your source repo directly. I prefer a traditional Build Artifact approach, because a Build Artifact is a set in stone and can be deployed to another environment. Deploying from source feels wishy-washy.
  2. I also don’t like how the it needs a unique deployment token to deploy to it, via the azure_static_web_apps_api_token parameter. Why can’t it use the same parameters as other tasks, i.e. AzureFunctionApp@2, which takes azureSubscription and appName parameters.

Fun with a Raspberry Pi Zero 2

A few years ago I setup Pi-Hole on a Raspberry Pi 3B+. Recently we went away on holiday for a month, and I powered it down before we left. When we came back and I switched everything back on, it worked for a couple of hours but then something popped and it stopped working (along with my Asus RT-AC86U router). It’s now dead, and doesn’t boot (the router is dead too).

For a replacement I picked up a (headless) Raspberry Pi Zero 2. The existing power supply and SD card from the dead 3B+ still worked.

Raspberry Pi Zero case
A Raspberry Pi Zero in a case

Over the last couple of weeks I’ve been finding more and more uses for it. One thing that’s awesome about the Pi is the amount of help and guides available to do anything with it. Here’s some things I’m now doing with mine, to give you some ideas.

Pi-Hole

If I’m honest, I don’t know if I need Pi-Hole (a network-wide ad blocker) these days. My browser and phone have ad blockers. And it gets in the way fairly often – e.g. if I search for a product on my phone, the first results in google get blocked, and to get those links to work I have to whitelist a handful of domains, which feels like it’s negating the point of it, so I don’t whitelist. It also gets in the way on some of my LG TV’s apps.

Phone screenshot of google search results   phone screenshot of a blank page

I do enjoy Pi Hole’s dashboard though, especially the “Percentage blocked” graph:

UPnP / DLNA server

As I mentioned above, my ASUS RT-AC86U router also crapped out at the same time. This was annoying because when I bought it about 4 years ago it was a top of the line $450 router. I had a 4Tb USB HDD plugged into it’s USB 3.0 port, and the router came with Time Machine software to backup my Mac, and a DLNA server, which was handy for viewing photos and videos on our LG TV in the lounge, via the TV’s built in “Photos and Videos” app.

About a year ago I added a cheaper ASUS RT-AX53U router to our network and set that up as a node in a mesh network using ASUS’s AI Mesh, so that we would have stronger WiFi in our bedroom at the other end of the house.

I didn’t feel like spending $400 again to replace the broken router, given the first one didn’t last long enough, so as a temporary measure I made the cheaper AX53U router the primary router, and then I later bought another AX53U router and set that up as an AI Mesh node for our bedroom.

However, I didn’t realise the cheaper AX53U is missing some of these USB features – i.e. no Time Machine. And although it supports DLNA (UPnP server in the screenshot below), I found it to be buggy, and I couldn’t get it to work.

Screenshot of ASUS router config page, showing USB applications

It was pretty easy to set up my Pi Zero to be a DLNA server, following the instructions over here. One gotcha is that the Pi Zero doesn’t have standard USB ports, so I had to first buy a $4 Micro USB to USB A OTG adapter so that I could plug in my USB HDD to the Pi.

Once setup and running, you get this basic UI:

Screenshot of MiniDLNA status page

Pro tip: you can browse a DLNA server using VLC (e.g. on your phone) if you want to check it’s running.

(I still need to figure out why the DLNA database is empty after the Pi is rebooted)

AirPrint

This was a nice-to-have. I have a basic Brother DCP-1610 Laser printer, which does have WiFi printing on it – so I thought it would be easy to print stuff from my iPhone. I should be so lucky – instead, you have to install Brother’s own printing app on your phone, and use that to print, which isn’t very easy.

However, (as per this guide) by installing CUPS on the Pi, and adding the Brother printer to that, I can now AirPrint to the Pi and it prints on the Brother. Easy!

Screenshot of printing from an iPhone

Backing up my family photos

I don’t use Apple’s iCloud or Google photos, my photos workflow is very manual and old school. I don’t want to be tied to any Photo manager software or database, e.g. Apple Photos, so I still organize photos into folders.

  1. Move photos from iPhone via USB to Mac, using Image Capture
  2. On Mac, use Preview app to delete the bad / duplicate photos
  3. Rename and move to a folder based on the year and location, i.e. 2023/04 Warsaw/

I also do the same for home videos. For those I’ll occasionally edit them into something more presentable using iMovie, or Quicktime Player to trim the start and end.

I use rsync over ssh to copy these folders to from my Mac to the Pi’s DLNA folder, following the instructions here.

Time Machine

As already mentioned, since my replacement router doesn’t support Time Machine (which is Apple’s backup software), it’s easy enough to add Time Machine to the Pi Zero by following these instructions.

I’ve gotten it to work just now, but I’m not sure it’s a good idea, because the Pi Zero doesn’t have ethernet, only has 2.4Ghz WiFi, and only has a USB 2.0 connection to the HDD – so there’s a lot of bottlenecks there. I might be better off with a Raspberry Pi 5 with Ethernet and USB 3.0.

Screenshot of Apple Time machine, showing 3Gb out of 330Gb backed up.SSH

Since this is headless, to connect to it I use ssh, i.e. ssh pizero. To save me entering the password every time, I did

ssh-keygen  (this generates an rsa key, only need to do this once on my Mac)

ssh-copy-id pizero

Spin down external HDD

apt-get install hd-idle and then follow the instructions here to configure it to start at startup. Note: I never actually got this to work.

Update: February 2024

Well, after only 3 months the Pi Zero has stopped working. When I put the SD card into my Macbook, it thinks the SD card is write-protected for some reason. Looks like I need a new SD card. I’m not sure I’m going to bother. Of the above apps:

  • Pi-Hole. Not really needed, Safari on my iPhone has decent enough content blockers now, and it’s handy being able to quickly disable them when a site asks you to.
  • UPnP / DLNA. Streaming 4K home videos to our LG TV in the lounge would stutter, which was frustrating. Not sure where the bottleneck here is – could be network (Pi Zero only has 2.4Ghz WiFi, no ethernet) or CPU. Reverted to plugging in a USB HDD to the TV.
  • AirPrint – I’ll miss this
  • Time Machine – was too slow to backup over WiFi, and I don’t trust that a restore would have worked. Reverted to using an external USB HDD to do Time Machine backups.

ASP.NET 6 return a message in a ProblemDetails with a 404s and 400s

In ASP.NET 6, the standard way to return a 404 from a Controller action is with

return NotFound();

And if you want to return a message with it, you can do

return NotFound($"Couldn't find an account with id {accountId}.");

Which returns:

Couldn't find an account with id 123.

Which isn’t ideal. Ideally we want to return a ProblemDetails like a nice API should. I found this workaround:

return Problem(statusCode: StatusCodes.Status404NotFound, detail: $"Couldn't find an account with id {accountId}");

Which returns us a ProblemDetails:

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.4",
  "title": "Not Found",
  "status": 404,
  "detail": "Couldn't find an account with id 123.",
  "traceId": "00-8292718bbb9d727dd1108abe3165deac-82f256594498617d-00"
}

Similarly, for 400s, you might think the best thing to return is

return BadRequest("Invalid accountId.");

But sadly my friend, as with NotFound(“…”) above, you’ll just get a 400 with a string in the request body. To return a ProblemDetails with a 400, you can call

ModelState.AddModelError(nameof(accountId), "must be greater than zero");
return ValidationProblem(ModelState);

Which gives you:

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "traceId": "00-3ae564a748991dee1d1a269d73e73b3f-bcdb5d6686a1b66a-00",
  "errors": {
    "accountId": [
      "must be greater than zero"
    ]
  }
}

Or if you’re not happy with the shape of that response, you could use Problem() as above:

return Problem(statusCode: StatusCodes.Status400BadRequest, detail: $"{nameof(accountId)} must be greater than zero.");
{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
  "title": "Bad Request",
  "status": 400,
  "detail": "accountId must be greater than zero.",
  "traceId": "00-0fc60678b8c5dee4e6d22faa40d8f12a-4e1992ddf4c43cd0-00"
}

While we’re on the topic, don’t forget to decorate your controller actions with these responses:

[SwaggerResponse(StatusCodes.Status400BadRequest, null, typeof(ProblemDetails), "application/problem+json")]
[SwaggerResponse(StatusCodes.Status404NotFound, null, typeof(ProblemDetails), "application/problem+json")]

Stub typed HttpClients in ASP.NET 6 integration tests

For the last 8 years or so my work doesn’t have many unit tests in it, instead I favour integration tests which fire up and run the web API we are testing in memory, and then run tests against that. This way you test the entire stack as a black box, which has loads of benefits:

  • you’re testing the entire stack so all the Httphandlers etc
  • you can usually refactor the code freely, as your tests are not tied to the implementation

Years ago on .NET Framework we had to jump through all sorts of OWIN hurdles to get this to work, but fortunately ASP.NET Core has supported it since the beginning, and has decent documentation over at https://learn.microsoft.com/en-us/aspnet/core/test/integration-tests – although, the documentation of late seems to be targeted more at ASP.NET Razor Pages rather than at APIs like it was a few years ago.

In the early days we went to the hassle of writing full on BDD tests using Specflow, but for most teams that’s too much effort and unless the BAs or Product Owners can actually access your source code and have an interest in reading your tests, then that’s usually wasted effort, so XUnit tests with Given When Then comments (or Arrange Act Assert) is usually what I roll with these days.

Integration tests shouldn’t make any external calls, so this means you need to come up with a strategy for faking the database and stubbing HTTP calls to 3rd parties. The latter is the topic of this post.

Lately I’ve been using typed Httpclients in my solutions. I couldn’t find it documented anywhere a way to stub typed HttpClients, so this is the solution I came up with.

This relies on the Moq library to mock `HttpMessageHandler.SendAsync()` (which is protected), so it won’t work with NSubstitute.

In this example I override the HttpClientFactory to always return the same mocked HttpClient.
Tests can call the StubHttpRequest method to stub any http request.

public class TestWebApplicationFactory<TStartup>
        : WebApplicationFactory<TStartup> where TStartup : class
{
    private readonly Mock<HttpMessageHandler> mockHttpMessageHandler;
    private Mock<IHttpClientFactory> mockFactory;

    public TestWebApplicationFactory()
    {
        mockHttpMessageHandler = new Mock<HttpMessageHandler>();
        var client = new HttpClient(mockHttpMessageHandler.Object);
        client.BaseAddress = new Uri("https://test.com/");
        mockFactory = new Mock<IHttpClientFactory>();
        mockFactory.Setup(_ => _.CreateClient(It.IsAny<string>())).Returns(client);
    }

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureTestServices(sp =>
        {
            sp.AddScoped(sp => mockFactory.Object);
        });
    }

    public void StubHttpRequest<T>(string requestUrl, HttpStatusCode statusCode, T content)
    {
        StubHttpRequest(requestUrl, statusCode, JsonConvert.SerializeObject(content));
    }

    public void StubHttpRequest<T>(string requestUrl, HttpStatusCode statusCode, string content)
    {
        _mockHttpMessageHandler
            .Protected()
            .Setup<Task<HttpResponseMessage>>("SendAsync",
                ItExpr.Is<HttpRequestMessage>(msg => msg.RequestUri!.ToString().EndsWith(requestUrl, StringComparison.InvariantCultureIgnoreCase)),
                ItExpr.IsAny<CancellationToken>())
            .ReturnsAsync(new HttpResponseMessage
            {
                StatusCode = statusCode,
                Content = new StringContent(content),
            });
    }
}

Then, the test code that uses it might look like:

public class GetAccountMembershipFeature : IClassFixture<TestWebApplicationFactory<Program>>
{
    private const string URL = "customers/{0}/accounts/{1}/membership";
    private readonly TestWebApplicationFactory<Program> _factory;
    private readonly HttpClient _sut;

    public GetAccountMembershipFeature(TestWebApplicationFactory<Program> factory)
    {
        _factory = factory;
        _sut = factory.CreateClient();
    }

    [Fact]
    public async Task GetAccountMembership_IsRegistered()
    {
        // GIVEN this customer and account
        long customerId = 88888888;
        long accountId = 9828282828;

        // WHEN the downstream system contains this membership information
        var membership = new AccountMembershipByTypeResponse
        {
            AccountNo = accountId,
            Amount = null,
            EndDate = null,
            ExternalReference = "7",
            MembershipType = membershipType,
            StartDate = new DateTime(2021, 1, 1)
        };

        _factory.StubHttpRequest(ApiUrls.AccountMembershipByTypeUrl(accountId.ToString(), membershipType, true), 
            HttpStatusCode.OK, 
            new List<AccountMembershipByTypeResponse> { membership });

        // THEN our GET endpoint should return the correct Amount
        var response = await _sut.GetFromJsonAsync<MembershipDetails>(string.Format(URL, customerId, accountId));
        response.ShouldNotBeNull();
        response.Amount.ShouldBe("7");
        response.Registered.ShouldBe(true);
    }

There’s also the ApiUrls helper which is a public method in the production code, which the test code also calls:

public static class ApiUrls
{
    public static string RegisterBankTransferUrl => "api/api/payment/transaction/BankTransfer";
    public static string CreateDirectDebitUrl => "api/payment/directdebit/create";
    public static string CreateSmoothPayUrl => "api/payment/smoothpay/create";

    public static string AccountMembershipByTypeUrl(string accountNo, string membershipType, bool currentOnly) =>
        $"api/accounts/{accountNo}/membershipbytype/{membershipType}?currentOnly={currentOnly}";

Hopefully that helps someone.