.NET Minimal API patterns for Code Samples

At Stripe, we create Stripe Samples, which
are end-to-end modular code samples that include one or many integrations, one
or many clients, and several server implementations.

If you have the Stripe CLI installed, you
can call stripe samples create issuing to see how to quickly install these.

We try to implement each server so that it exposes the same APIs so that we can
implement several clients that can be swapped in and out.

For instance, we might have a sample with directories like:

payment-element
├──client
│  └──html
│  └──react
│  server
│  └──ruby
│  └──dotnet
checkout
├──client
│  └──html
│  └──react
│  server
│  └──ruby
│  └──dotnet

We'll want the server endpoints for both the ruby and dotnet applications to
accept the same requests and return the same well formed responses. Ideally,
without compromising the readability or conventionality of the code.

I found that using the new .NET minimal APIs is much better than the older MVC
style .NET apps for building these samples for the primary reason that most
of the code is co-located in Program.cs.

Another trick to re-using the same clients is that we want to either serve those
static html files, or run the react/vue/svelte apps on separate ports and proxy
the relevant API calls to the servers.

Here are some tips for getting your .NET minimal API code samples.

Serving static files with .NET 6

Given we're going to serve static files, we want to use the
PhysicalFileProvider which we can import from
Microsoft.Extensions.FileProviders.

Next, once we've built our App, we can call UseStaticFiles and pass in
the StaticFileOptions with the path to our static files.

using Microsoft.Extensions.FileProviders;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.UseStaticFiles(new StaticFileOptions()
{
    FileProvider = new PhysicalFileProvider(
        Path.Combine(Directory.GetCurrentDirectory(), @"../../client")
    ),
    RequestPath = new PathString("")
});

Note that if you are serving a root file that you want to render, like
index.html for example, that won't act as the default response when handling
requests for the root route (/).

I worked around this by mapping / to a redirect to the index.html page.

app.MapGet("/", () => Results.Redirect("/index.html"));

Parsing incoming JSON

For years, I've used Newtonsoft.Json, but I was having some issues configuring
it so that it worked with SnakeCased json. There's this super long solution on
StackOverflow,
but that seemed like overkill for a simple example.

Instead, my teammate Cecil showed me how to
use System.Text.Json to parse the HTTP request from the client directly.

First, we need a class that we're going to bind to when deserializing. Check out this one
that I used to manually configure the property names. Note this is super similar
to Newtonsoft.Json's JsonProperty decorator/attribute thing, but is called JsonPropertyName and
is imported from System.Text.Json.Serializer.

using System.Text.Json;
using System.Text.Json.Serialization;

public class CreateCardholderRequest
{
    [JsonPropertyName("name")]
    public string Name { get; set; }

    [JsonPropertyName("email")]
    public string Email { get; set; }

    [JsonPropertyName("phone_number")]
    public string PhoneNumber { get; set; }
}

That class expects to bind to some JSON that looks like:

{
  "name": "CJ Avilla",
  "email": "wave@cjav.dev",
  "phone_number": "8008675309"
}

Here's the first few lines of the endpoint for creating Issuing Cardholders. Notice
that instead of doing model binding at the callback parameter level, we're
taking in an HttpContext and manually deserializing into an instance of CardHolderRequest.

app.MapPost("/create-cardholder", async (HttpContext ctx) =>
{
  using var requestBodyStream = new StreamReader(ctx.Request.Body);
  var payload = await requestBodyStream.ReadToEndAsync();
  var req = JsonSerializer.Deserialize<CreateCardholderRequest>(payload);

Seeing that using statement was new to me. Cecil taught me that it's syntactic sugar over the older
block style using statement like:

using(x) {
}

Here's the docs for using.

Don't try to serialize exceptions directly

I was running into this error:

fail: Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware[1]
      An unhandled exception has occurred while executing the request.
      System.NotSupportedException: Serialization and deserialization of 'System.IntPtr' instances are not supported. Path: $.TargetSite.MethodHandle.Value.
       ---> System.NotSupportedException: Serialization and deserialization of 'System.IntPtr' instances are not supported.
         at System.Text.Json.Serialization.Converters.UnsupportedTypeConverter`1.Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
         at System.Text.Json.Serialization.JsonConverter`1.TryWrite(Utf8JsonWriter writer, T& value, JsonSerializerOptions options, WriteStack& state)
         at System.Text.Json.Serialization.Metadata.JsonPropertyInfo`1.GetMemberAndWriteJson(Object obj, WriteStack& state, Utf8JsonWriter writer)
         at System.Text.Json.Serialization.Converters.ObjectDefaultConverter`1.OnTryWrite(Utf8JsonWriter writer, T value, JsonSerializerOptions options, WriteStack& state)
         at System.Text.Json.Serialization.JsonConverter`1.TryWrite(Utf8JsonWriter writer, T& value, JsonSerializerOptions options, WriteStack& state)
         at System.Text.Json.Serialization.Metadata.JsonPropertyInfo`1.GetMemberAndWriteJson(Object obj, WriteStack& state, Utf8JsonWriter writer)
         at System.Text.Json.Serialization.Converters.ObjectDefaultConverter`1.OnTryWrite(Utf8JsonWriter writer, T value, JsonSerializerOptions options, WriteStack& state)
         at System.Text.Json.Serialization.JsonConverter`1.TryWrite(Utf8JsonWriter writer, T& value, JsonSerializerOptions options, WriteStack& state)
         at System.Text.Json.Serialization.Metadata.JsonPropertyInfo`1.GetMemberAndWriteJson(Object obj, WriteStack& state, Utf8JsonWriter writer)
         at System.Text.Json.Serialization.Converters.ObjectDefaultConverter`1.OnTryWrite(Utf8JsonWriter writer, T value, JsonSerializerOptions options, WriteStack& state)
         at System.Text.Json.Serialization.JsonConverter`1.TryWrite(Utf8JsonWriter writer, T& value, JsonSerializerOptions options, WriteStack& state)
         at System.Text.Json.Serialization.JsonConverter`1.WriteCore(Utf8JsonWriter writer, T& value, JsonSerializerOptions options, WriteStack& state)
         --- End of inner exception stack trace ---
         at System.Text.Json.ThrowHelper.ThrowNotSupportedException(WriteStack& state, NotSupportedException ex)
         at System.Text.Json.Serialization.JsonConverter`1.WriteCore(Utf8JsonWriter writer, T& value, JsonSerializerOptions options, WriteStack& state)
         at System.Text.Json.Serialization.JsonConverter`1.WriteCoreAsObject(Utf8JsonWriter writer, Object value, JsonSerializerOptions options, WriteStack& state)
         at System.Text.Json.JsonSerializer.WriteCore[TValue](JsonConverter jsonConverter, Utf8JsonWriter writer, TValue& value, JsonSerializerOptions options, WriteStack& state)
         at System.Text.Json.JsonSerializer.WriteStreamAsync[TValue](Stream utf8Json, TValue value, JsonTypeInfo jsonTypeInfo, CancellationToken cancellationToken)
         at System.Text.Json.JsonSerializer.WriteStreamAsync[TValue](Stream utf8Json, TValue value, JsonTypeInfo jsonTypeInfo, CancellationToken cancellationToken)
         at System.Text.Json.JsonSerializer.WriteStreamAsync[TValue](Stream utf8Json, TValue value, JsonTypeInfo jsonTypeInfo, CancellationToken cancellationToken)
         at Microsoft.AspNetCore.Http.HttpResponseJsonExtensions.WriteAsJsonAsyncSlow(Stream body, Object value, Type type, JsonSerializerOptions options, CancellationToken cancellationToken)
         at Microsoft.AspNetCore.Http.RequestDelegateFactory.ExecuteTaskResult[T](Task`1 task, HttpContext httpContext)
         at Microsoft.AspNetCore.Routing.EndpointMiddleware.<Invoke>g__AwaitRequestTask|6_0(Endpoint endpoint, Task requestTask, ILogger logger)
         at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware.Invoke(HttpContext context)

And it was because, I was trying to directly serialize the Exception in the response using:

// BAD!
catch(StripeException e)
{
  Console.WriteLine($"API Call to Stripe failed. {e.StripeError.Message}");
  return Results.BadRequest(e);
}

Instead, BadRequest needs to be passed something that can be serialized
(apparently a StripeException isn't that!). Here's what worked:

// GOOD!
catch(StripeException e)
{
  Console.WriteLine($"API Call to Stripe failed. {e.StripeError.Message}");
  return Results.BadRequest(new { error = new { message = e.StripeError.Message }});
}