Cover

A Minimal API Discovery Tool for Large APIs

February 22, 2023
No Comments.

I’ve been posting and making videos about ideas I’ve had for discovering Minimal APIs instead of mapping them all in Program.cs for a while. I’ve finally codified it into an experimental nuget package. Let’s talk about how it works.

I also made a Coding Short video that covers this same topic, if you’d rather watch than read:

How It Works

The package can be installed via the dotnet tool:

dotnet add package WilderMinds.MinimalApiDiscovery

Once it is installed, you can use an interface called IApi to implement classes that can register Minimal APIs. The IApi interface looks like this:

/// <summary>
/// An interface for Identifying and registering APIs
/// </summary>
public interface IApi
{
  /// <summary>
  /// This is automatically called by the library to add your APIs
  /// </summary>
  /// <param name="app">The WebApplication object to register the API </param>
  void Register(WebApplication app);
}

Essentially, you can implement classes that get passed the WebApplication object to map your API calls:

public class StateApi : IApi
{
  public void Register(WebApplication app)
  {
    app.MapGet("/api/states", (StateCollection states) =>
    {
      return states;
    });
  }
}

This would allow you to register a number of related API calls. I think one class per API is too restrictive. When used in .NET 7 and later, you could make a class per group:

  public void Register(WebApplication app)
  {
    var group = app.MapGroup("/api/films");

    group.MapGet("", async (BechdelRepository repo) =>
    {
      return Results.Ok(await repo.GetAll());
    })
      .Produces(200);

    group.MapGet("{id:regex(tt[0-9]*)}",
      async (BechdelRepository repo, string id) =>
    {
      Console.WriteLine(id);
      var film = await repo.GetOne(id);
      if (film is null) return Results.NotFound("Couldn't find Film");
      return Results.Ok(film);
    })
      .Produces(200);

    group.MapGet("{year:int}", (BechdelRepository repo,
        int year,
        bool? passed = false) =>
        {
          var results = await repo.GetByYear(year, passed);
          if (results.Count() == 0)
          {
            return Results.NoContent();
          }

          return Results.Ok(results);
        })
      .Produces(200);

    group.MapPost("", (Film model) =>
    {
      return Results.Created($"/api/films/{model.IMDBId}", model);
    })
      .Produces(201);

  }

Because of lambdas missing some features (e.g. default values), you can always move the lambdas to just static methods:

public void Register(WebApplication app)
{
  var grp = app.MapGroup("/api/customers");
  grp.MapGet("", GetCustomers);
  grp.MapGet("", GetCustomer);
  grp.MapPost("{id:int}", SaveCustomer);
  grp.MapPut("{id:int}", UpdateCustomer);
  grp.MapDelete("{id:int}", DeleteCustomer);
}

static async Task<IResult> GetCustomers(CustomerRepository repo)
{
  return Results.Ok(await repo.GetCustomers());
}

//...

The reason for the suggestion of using static methods (instance methods would work too) is that you do not want these methods to rely on state. You might think that constructor service injection would be a good idea:

public class CustomerApi : IApi
{
  private CustomerRepository _repo;

  // MinimalApiDiscovery will log a warning because
  // the repo will become a singleton and lifetime
  // will be tied to the implementation methods.
  // Better to use method injection in this case.
  public CustomerApi(CustomerRepository repo)
  {
    _repo = repo;
  }

// ...

This doesn’t work well as the call to Register happens once at startup and since this class is sharing that state, the injected service becomes a singleton for the lifetime of the server. The library will log a warning if you do this to help you avoid it. Because of that I suggest that you use static methods instead to prevent this from accidently happening.

NOTE: I considered using static interfaces, but that requires that the instance is still a non-static class. It would also limit this library to use in .NET 7/C# 11 - which I didn’t want to do. It works in .NET 6 and above.

When you’ve created these classes, you can simple make two calls in startup to register all IApi classes:

using UsingMinimalApiDiscovery.Data;
using WilderMinds.MinimalApiDiscovery;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddTransient<CustomerRepository>();
builder.Services.AddTransient<StateCollection>();

// Add all IApi classes to the Service Collection
builder.Services.AddApis();

var app = builder.Build();

// Call Register on all IApi classes
app.MapApis();

app.Run();

The idea here is to use reflection to find all IApi classes and add them to the service collection. Then the call to MapApis() will get all IApi from the service collection and call Register.

How it works

The call to AddApis simply uses reflection to find all classes that implement IApi and add them to the service collection:

  var apis = assembly.GetTypes()
    .Where(t => t.IsAssignableTo(typeof(IApi)) &&
                t.IsClass &&
                !t.IsAbstract)
    .ToArray();

  // Add them all to the Service Collection
  foreach (var api in apis)
  {
    // ...
    coll.Add(new ServiceDescriptor(typeof(IApi), api, lifetime));
  }

Once they’re all registered, the call to MapApis is pretty simple:

var apis = app.Services.GetServices<IApi>();

foreach (var api in apis)
{
  if (api is null) throw new InvalidProgramException("Apis not found");

  api.Register(app);
}

Futures

While I’m happy with this use of Reflection since it is only a ‘startup’ time cost, I have it on my list to look at using a Source Generator instead.

If you have experience with Source Generators and want to give it a shot, feel free to do a pull request at https://github.com/wilder-minds/minimalapidiscovery.

I’m also considering removing the AddApis and just have the MapApis call just reflect to find all the IApis and call register since we don’t actually need them in the Service Collection.

You can see the complete source and example here:

https://github.com/wilder-minds/minimalapidiscovery