As a developer advocate, I find myself writing a lot of demos for folks around .NET and, specifically, the ASP.NET Core parts of the stack. Unfortunately, writing repetitive boilerplate can make me feel like Jack Torrence from The Shining movie: “All work and no play makes Khalid a dull boy.” Nobody likes the drudgery of repeating yourself, nobody! So, given my situation, I thought I’d chase this problem down in a maze with a metaphorical axe. So, what am I trying to accomplish?

What if I could take an existing C# model and build a low-ceremony set of test endpoints that follow standard HTTP semantics, and how would I accomplish that?

In this article, you’ll see how I built a single registration for test endpoints that you can use to prototype UIs quickly for frameworks like React, Blazor, Angular, or vanilla JavaScript. Let’s get started.

The Basics of an HTTP API

For folks unfamiliar with building an HTTP API, the HTTP semantics are essential to the implementation on the server. Elements of an HTTP request include the method, headers, path, query-string values, and the optional payload. Without all these elements, your HTTP API limits how and what your user can send to your endpoint.

On the flip side, you have the HTTP Request, which has headers, payload, and status code elements. The server uses these values to tell a client what occurred on the server via a status code and what the client can expect regarding the type of payload.

In addition to HTTP semantics, I typically center the creation of HTTP APIs around a “resource”. A resource is a logical entity that a user reads and writes through requests and receives in response payloads. In my experience, an API resource can be something like a Person, Quotes, and so on.

I’ve found that building my HTTP APIs around HTTP semantics and resources makes the process of reasoning and maintaining a project simpler in the long run.

The ASP.NET Core Registration

We’ll start with the user experience I’m aiming for and then look at how to implement the code behind the endpoint registration. I aim to have a low-ceremony registration to add essential create, read, update, and delete endpoints. The endpoints will also adhere to standard HTTP semantics. Additionally, I want persistence between requests, so the user can create or update a new resource and then retrieve it using the identity. First, let’s look at the Program.cs.

using BogusEndpoints;

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

app.MapGet("/", () => "Hello World!");

app.MapAutoBogusEndpoint<Person>("/people", rules =>
{
    rules.RuleFor(p => p.Id, f => f.IndexGlobal + 1);
    rules.RuleFor(p => p.FullName, f => f.Name.FullName());
});

app.Run();

public record Person(int Id, string FullName)
{
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
    public Dog Dog { get; set; }
}

public record Dog(string Name);

You’ll notice the call to MapAutoBogusEndpoint allows the user to set a root path and the ability to configure the kind of data for the resource. Any property the user does not set will get random data based on the property type. For example, let’s make an HTTP call to the Index endpoint to retrieve a list of Person resources, limiting the result to

GET http://localhost:5208/people?pageSize=1

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Thu, 02 Feb 2023 16:20:23 GMT
Server: Kestrel
Transfer-Encoding: chunked

{
  "results": [
    {
      "id": 1,
      "fullName": "Ofelia Vandervort",
      "createdAt": "2023-02-02T01:32:03.1170216-05:00",
      "dog": {
        "name": "Toys & Health"
      }
    }
  ],
  "page": 1,
  "pageSize": 1,
  "totalItemCount": 1000
}

That’s pretty cool, right?! Other calls like creating, updating, and deleting also work.

PUT http://localhost:5208/people/1
Content-Type: application/json

{
  "id": 1,
  "fullName": "Khalid Abuhakmeh",
  "createdAt": "2023-02-02T01:32:03.1170216-05:00",
  "dog": {
    "name": "Toys & Health"
  }
}
###

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Thu, 02 Feb 2023 16:28:34 GMT
Server: Kestrel
Transfer-Encoding: chunked

{
  "id": 1,
  "fullName": "Khalid Abuhakmeh",
  "createdAt": "2023-02-02T01:32:03.1170216-05:00",
  "dog": {
    "name": "Toys & Health"
  }
}

I make the best effort to assign the identifiers if an identifier needs to be incremented or generated. That said, in most cases, an HTTP API will expect that the user passes most data representing the state.

So how is this all done?

Generating Boilerplate Prototype Endpoints

I accomplish most of the work using the NuGet library AutoBogus. The configuration action allows you to configure each property accordingly when registering the endpoint. I’ll paste most of the code here.

using AutoBogus;
using Microsoft.AspNetCore.Mvc;
using X.PagedList;

namespace BogusEndpoints;

public static class BogusEndpointsExtensions
{
    public static RouteGroupBuilder MapAutoBogusEndpoint<TResource>(
        this WebApplication app,
        PathString prefix,
        Action<AutoFaker<TResource>>? builder = null
    ) where TResource : class
    {
        var group = app.MapGroup(prefix)
            .WithGroupName($"{typeof(TResource).FullName}_Bogus");

        var faker = new AutoFaker<TResource>();
        builder?.Invoke(faker);

        // generate a working collection
        // will allocate objects in memory
        var db = faker.Generate(1000);

        // INDEX
        group.MapGet("", (int? pageSize, int? page) =>
            {
                var result = db
                    .ToPagedList(
                        page.GetValueOrDefault(1),
                        pageSize.GetValueOrDefault(10));

                return new
                {
                    results = result,
                    page = result.PageNumber,
                    pageSize = result.PageSize,
                    totalItemCount = result.TotalItemCount
                };
            })
        .WithName($"{typeof(TResource).FullName}_Bogus+List");

        // SHOW
        group.MapGet("{id}", (string id) =>
        {
            var result = db.FirstOrDefault(t => FindById(t, id));
            return result != null ? Results.Ok(result) : Results.NotFound();
        })
        .WithName($"{typeof(TResource).FullName}_Bogus+Show");

        // POST
        group.MapPost("", (TResource item) =>
        {
            try
            {
                dynamic generated = faker.Generate(1)[0];
                SetId(item, generated.Id);
                db.Add(item);
                return Results.CreatedAtRoute(
                    $"{typeof(TResource).FullName}_Bogus+Show",
                    new { id = generated.Id },
                    item
                );
            }
            catch
            {
                return Results.Ok(item);
            }
        }).WithName($"{typeof(TResource).FullName}_Bogus+Create");

        // PUT
        group.MapPut("{id}", (string id, [FromBody] TResource item) =>
        {
            var index = db.FindIndex(t => FindById(t, id));
            if (index < 0) return Results.NotFound();
            SetId(id, item);
            db[index] = item;
            return Results.Ok(item);
        }).WithName($"{typeof(TResource).FullName}_Bogus+Update");

        // DELETE
        group.MapDelete("{id}", (string id) =>
        {
            db.RemoveAll(t => FindById(t, id));
            return Results.Accepted();
        })
        .WithName($"{typeof(TResource).FullName}_Bogus+Delete");
        
        return group;
    }

    private static bool FindById<TResource>(TResource target, object? id)
    {
        if (id is null)
            return false;

        var type = typeof(TResource);
        var identifier = type
            .GetProperties()
            .FirstOrDefault(p => p.Name == "Id");

        if (identifier == null)
            return false;

        object? converted = Convert.ChangeType(id, identifier.PropertyType);
        if (converted == null) return false;
        var value = identifier.GetValue(target);
        var result = converted.Equals(value);
        return result;
    }
    
    private static void SetId<TResource>(TResource target, object? id)
    {
        if (id is null)
            return;

        var type = typeof(TResource);
        var identifier = type
            .GetProperties()
            .FirstOrDefault(p => p.Name == "Id");

        if (identifier == null)
            return;

        object? converted = Convert.ChangeType(id, identifier.PropertyType);
        if (converted == null) return;
        identifier.SetValue(target, converted);
    }
}

All you need to do is create your resource types, such as Person, and you’ll get all the necessary machinery for an HTTP API. But there’s still something missing. What about validation results?

Adding Validation to Bogus Endpoints

I made a calculated effort to return the RouteGroupBuilder from each registration call, allowing you to add any endpoint filter to a group. Exposing this object instance makes it trivial to add FluentValidation as an endpoint filter.

using BogusEndpoints;
using FluentValidation;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddValidatorsFromAssemblyContaining<Program>();

var app = builder.Build();

app.MapGet("/", () => "Hello World!");

app.MapAutoBogusEndpoint<Person>("/people", rules =>
{
    rules.RuleFor(p => p.Id, f => f.IndexGlobal + 1);
    rules.RuleFor(p => p.FullName, f => f.Name.FullName());
})
.AddEndpointFilter<PersonValidationFilter>();

app.Run();

public record Person(int Id, string FullName)
{
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
    public Dog Dog { get; set; }
}

public record Dog(string Name);

We tie the validator specifically to the resource of Person.

using FluentValidation;

public class PersonValidationFilter : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
    {
        var validator = context.HttpContext.RequestServices.GetRequiredService<PersonValidator>();
        foreach (var arg in context.Arguments)
        {
            if (arg is not Person person) continue;
            var result = await validator.ValidateAsync(person);
            if (result.IsValid) continue;
            var errors = result.Errors
                .GroupBy(e => e.PropertyName)
                .ToDictionary(
                    g => g.Key, 
                    g => g.Select(e => e.ErrorMessage).ToArray()
                );

            return Results.ValidationProblem(errors);
        }

        return await next(context);
    }
}

public class PersonValidator : AbstractValidator<Person>
{
    public PersonValidator()
    {
        RuleFor(m => m.FullName).NotEmpty();
    }
}

Any call to an endpoint not meeting your validation criteria will return an appropriate problem details response and status code.

POST http://localhost:5208/people

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
Date: Thu, 02 Feb 2023 16:56:57 GMT
Server: Kestrel
Transfer-Encoding: chunked

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "FullName": [
      "'Full Name' must not be empty."
    ]
  }
}

Cool!

Other Ideas around Bogus Endpoints

This current implementation relies on runtime generation, which is ok for testing purposes, but there are a lot of potential pitfalls, primarily around identifiers. Using source code generators, you could make the endpoints rely less on reflection and more on the design-time types of the resource models. In addition, the use of source generators could lead to fewer edge case bugs.

.NET Community member João Antunes has an excellent blog post detailing registering Minimal API endpoints using source code generators. I could see someone adapting both our ideas into something useful for the community at large.

Conclusion

If you’ve ever found yourself in a situation where you needed some test endpoints to match your models but were more focused on the UI/UX rather than the implementation, this solution might be for you. The approach in this post could use some optimizations and edge case handling, but I’ll leave it up to you to work through those.

As always, thanks for reading, and I hope you enjoyed this post. If you did, please feel to share it with friends and colleagues.