Beautiful and compact Web APIs with C# 9, .NET 5.0 and ASP.NET Core

Β· 3013 words Β· 15 minutes to read

Almost fours year ago I blogged about building lightweight microservices with ASP.NET Core 1.2 (which actually never shipped in such version and later became ASP.NET Core 2.0). The idea there was to drop the notion of bloated MVC controllers, get rid of as much as we can of the usual verbosity of C# based applications, and use a set of simple extension methods and a few cutting edge features of ASP.NET Core to provide a node.js style experience for authoring Web APIs.

The article and the accompanying demo projects received quite a lot of attention, and I even got a chance to speak at some conference about these type of approaches to building focused, small microservices. With the .NET 5.0 in sight (.NET 5.0 RC2 is out at the time of writing this), and some remarkable features of C# 9, this “lightweight Web APIs” concept deserves a revisit, and this is what we will do in this blog post.

Excellent new features in C# 9 πŸ”—

There are two particularly exciting features in C# 9 that fit the notion of building lightweight, simple Web APIs really nicely - top level statements and records. Let’s quickly discuss them.

With top level statements, the compiler allows you to omit the noisy boilerplate code of Program class and the static async Task Main method. In other words, instead of:

using System;

static class Program
{
    static async Task Main(string[] args)
    {
        Console.WriteLine("hello");
    }
}

you can write the entire contents of the entry point method as a set of loose top level statements:

using System;

Console.WriteLine("hello");

What happens under the hood, is that the compiler will create the missing Program class and the static async Task Main method for us, wrapping all of the loose statements we wrote. This naturally has a very pleasant effect of allowing our code to become very concise. Should we define a loose function here, it would simply become a local function in the synthesized Main method. You can read more about the top level statements here.

This is of course dangerously close to csx scripting, and if you follow this blog, you know that I have dedicated many years of my open source life to that topic. Conceptually, however, there is still quite a big difference between scripting and top level statements - top level statements being considerably more limiting - although this is the first step for both C# “dialects” to converge. This warranties it’s own blog post so I will not go into those details now, as they are germane to our present discussion.

The other feature I wanted to mention here are record types. These are new category of reference types, immutable by default, compact in definition and providing equality semantics. Due to these characteristics records are excellent candidates for handling the models (DTOs) used by Web APIs. A typical record looks like this:

public record Contact
{
    public int ContactId { get; }
    public string Name { get; }

    public Person(string contactId, string name) => (ContactId, Name) = (contactId, name);
}

Records can also be defined as the so-called “positional records”. This allows us to collapse the above definition into even more compact structure:

public record Contact(int ContactId, string Name);

In this case the compiler will generate all the necessary members (constructor, properties, various method overrides, copy and clone methods and so on) automatically for us. The natural benefit is that the DTO declaration becomes very terse, which then allows us to list multiple of them side by side to quickly get an overview of the API surface - something that I have grown to value a lot in other languages (e.g. F#).

The intent of this post, however, is not to go into details about records - the quick overview we just went through should suffice for the purpose of this post - but if you are interested, you can read more about them here.

The simplest Web API πŸ”—

In order to get up and running, make sure you have the .NET 5.0 SDK RC2 (or higher) installed. For this version Visual Studio 16.8+ or VS Code with C# extension 1.23.3+ is required.

The project file looks as follows:

<Project Sdk="Microsoft.NET.Sdk.Web">

 <PropertyGroup>
  <OutputType>Exe</OutputType>
  <TargetFramework>net5.0</TargetFramework>
  <LangVersion>preview</LangVersion>
 </PropertyGroup>

</Project>

The Microsoft.NET.Sdk.Web SDK already brings in most of the things needed for building Web APIs - namely everything that is the integral part of ASP.NET Core, so any extra Nuget packages is needed only if you require additional non-core features of ASP.NET Core. TargetFramework is 5.0 (notice no “Core” anymore) and the language version is set to Preview to ensure we get all the cool C# 9 features.

The simplest possible Web API we can now build with ASP.NET Core, .NET 5.0 and C# 9 is literally a single line of code (not counting the using statements).

using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;

WebHost.CreateDefaultBuilder()
        .Configure(app => app.Run(c => c.Response.WriteAsync("Hello world!")))
        .Build().Run();

This application creates the default ASP.NET Core WebHost and runs it. Internally all the default logging, configuration and HTTP interfaces are setup, including the Kestrel server. In the application we configure a single piece of middleware using the app.Run… construct, which responds with a plain text Hello world! to any request into our Web API - irrespective of HTTP verb or request path. This is of course hardly useful, but it’s pretty amazing to see such a compact Web API being written in C#.

The IWebHostBuilder that gets created in this call chain exposes methods to configure application configuration, the dependency injection container and the middleware pipeline - so we can easily scale this up to a more sophisticated example.

using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;

WebHost.CreateDefaultBuilder()
        .ConfigureServices(s =>
            {
                // configure dependency injection and ASP.NET Core services
            })
        .Configure(app => 
            {
                // configure application pipeline
                app.Run(c => c.Response.WriteAsync("Hello world!"));
            })
        .Build().Run();

But before we scale up this approach, let’s first look at somenthing else that is new as well.

Adding routes πŸ”—

This first example used a catch-all middleware to handle the incoming traffic, let’s now see how we can build equally simple Web API, but this time with real route handlers, that can respond to a specific route template and HTTP method. To do that, we will leverage a new extension method in ASP.NET Core 5.0, IWebHost Start(Action routeBuilder) - one that creates the default builder under the hood (same as we just used moments ago), but additionally wires in routing and allows us to straightaway define routes via a delegate.

using System;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;

WebHost.Start(routes => 
    routes.MapGet("hello/{name}", (req, res, data) => res.WriteAsync($"Hello, {data.Values["name"]}")));
Console.ReadKey();

This has now grown to two lines of code, as we need to explicitly block to prevent the application from exiting, but in a way the application is even simpler now since we are no longer exposed to the builder pattern of the WebHost. At the same time, there is fully fledged routing here, including HTTP method handling and proper route parameter support. The entire bootstrapping comes down to just calling WebHost.Start(…). On the other hand, the limitation of this WebHost.Start approach is that it is really suited for super basic apps (demos, proof of concept) only - there is no way to add any middleware or configure dependency injection here and we only really have the possibility for adding routes and their handlers. There is also a sibling method WebHost.StartWith(…), which takes a configuration delegate for the IApplicationBuilder too, which at least allows us to configure the application application, but this approach still doesn’t expose any way of setting up the DI container.

Let’s now combine the two approaches - using WebHost.CreateDefaultBuilder(), as that gives us access to fully-fledged DI container and application pipeline configuration, as well as the ability to specify routes in a simple and convenient way.

WebHost.CreateDefaultBuilder().Configure(app => 
{
    app.UseRouting();
    app.UseEndpoints(e => 
    {
        e.MapGet("/", c => c.Response.WriteAsync("Hello world!"));
        e.MapGet("hello/{name}", c => c.Response.WriteAsync($"Hello, {c.Request.RouteValues["name"]}"));
    });
}).Build().Run();

In this example we have two application routes / and /hello/{name} which map to GET requests using the ASP.NET Core endpoint routing feature - the same feature on which the MVC framework or Razor Pages are built. However, we are still using only plain text response - and that will be the next thing to address.

JSON serialization and deserialization πŸ”—

While it is possible to manually write all the code to serialize/deserialize requests and responses, in ASP.NET Core for .NET 5.0, we are presented with some really helpful new extension methods for those tasks. The main advantage of using them, other than the fact that they are very concise, is that they use System.Text.Json under the hood and are really optimized for performance. Let’s now look at a slightly more complicated Web API - one that exposes various operations on a custom Contact resource.

using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;

// with endpoint routing and JSON endpoints
// see https://github.com/dotnet/aspnetcore/pull/23496/ for more on the JSON helpers
WebHost.CreateDefaultBuilder().
ConfigureServices(s => s.AddSingleton<ContactService>()).
Configure(app => 
{
    app.UseRouting();
    app.UseEndpoints(e => 
    {
        var contactService = e.ServiceProvider.GetRequiredService<ContactService>();

        e.MapGet("/contacts", 
            async c => await c.Response.WriteAsJsonAsync(await contactService.GetAll()));
        e.MapGet("/contacts/{id:int}", 
            async c => await c.Response.WriteAsJsonAsync(await contactService.Get(int.Parse((string)c.Request.RouteValues["id"]))));
        e.MapPost("/contacts", 
            async c => 
            {
                await contactService.Add(await c.Request.ReadFromJsonAsync<Contact>());
                c.Response.StatusCode = 201;
            });
        e.MapDelete("/contacts/{id:int}", 
            async c => 
            {
                await contactService.Delete(int.Parse((string)c.Request.RouteValues["id"]));
                c.Response.StatusCode = 204;
            });
    });
}).Build().Run();

We will dig into the Contact and ContactService in a moment - for now let’s focus on the endpoints. There are four of them, something that is typically to any REST API:

  • GET /contacts which returns a list of contact resources
  • GET /contacts/{id:int} which returns a single contact by ID
  • POST /contacts which allows us to create a new contact resource
  • DELETE /contacts/{id:int} which allows us to remove a contact resource

The two extensions methods for the HTTP request and HTTP response - WriteAsJsonAsync(…) and ReadFromJsonAsync(…) allow us to quite comfortably incorporate JSON into our API surface.

Let’s now quickly look at the Contact DTO, because that’s where our other interesting C# 9 feature manifests itself, and the corresponding ContactService that operates on it. For simplicity we will use the same model for requests and responses, but in reality you’d likely want to use a separate input and output models. Regardless, in “classic” approach to building Web APIs with ASP.NET Core, the Contact DTO might look something like this:

public class Contact
{
    public int ContactId { get; set; }
    
    public string Name { get; set; }
    
    public string Address { get; set; }
    
    public string City { get; set; }
}

In C# 9, we can easily rewrite it into a record, namely a single-line positional record - and get some extra immutability feature for free too:

public record Contact(int ContactId, string Name, string Address, string City);

This is a pretty dramatic improvement in terms of unnecessary space and verbosity of the language! It is also quite common to annotate properties of the request models with data annotation attributes such as for example [Required]. In the latest .NET 5.0 pre-release builds that is also possible with positional records - as they now also support attributes.

The ContactService is not that important to our entire discussion - I will list it here for the sake of completeness, but it’s only a helper to allow us to illustrate the API behavior. It only stores the data in memory, but of course the idea is that it represents any hypothetical data source that might be connected to your application.

public class ContactService
{
    private readonly List<Contact> _contacts = new List<Contact>
        {
            new Contact(1, "Filip W", "Bahnhofstrasse 1", "Zurich"),
            new Contact(2, "Josh Donaldson", "1 Blue Jays Way", "Toronto"),
            new Contact(3, "Aaron Sanchez", "1 Blue Jays Way", "Toronto"),
            new Contact(4, "Jose Bautista", "1 Blue Jays Way", "Toronto"),
            new Contact(5, "Edwin Encarnacion", "1 Blue Jays Way", "Toronto")
        };

    public Task<IEnumerable<Contact>> GetAll() => Task.FromResult(_contacts.AsEnumerable());

    public Task<Contact> Get(int id) => Task.FromResult(_contacts.FirstOrDefault(x => x.ContactId == id));

    public Task<int> Add(Contact contact)
    {
        var newId = (_contacts.LastOrDefault()?.ContactId ?? 0) + 1;
        _contacts.Add(contact with { ContactId = newId });
        return Task.FromResult(newId);
    }

    public async Task Delete(int id)
    {
        var contact = await Get(id);
        if (contact == null)
        {
            throw new InvalidOperationException(string.Format("Contact with id '{0}' does not exists", id));
        }

        _contacts.Remove(contact);
    }
}

If we go back to the original snippet, that ContactService was registered in the DI container as a singleton with ConfigureServices(s => s.AddSingleton()) call. Since it’s a singleton, it was possible to resolve it only once, and reuse from there making everything even more concise. However, if this dependency was transient or per-request (scoped), e.g. in the case of EF DbContext, then we’d need to manually resolve it in each of our endpoint handlers.

Adding security πŸ”—

We have already covered a bunch of useful concepts and how to make them work in a compact way - a WebHost setup that is as concise as possible, dependency injection, application pipeline (including middleware and routes) and finally JSON support. The final thing I wanted to include in this set of tiny Web API examples is adding security - which is of course an integral part of almost every Web API. In order to add security, we will configure authentication for our app - the ability to consume JWT tokens, and authorization - the permissions and rules which will be enforced on the authenticated caller.

To do that, we need to extend the ConfigureServices section with the following code (the rest of the API is omitted for brevity):

ConfigureServices(s =>
{
    s.AddSingleton<ContactService>();
    s.AddAuthorization(options =>
    {
        options.FallbackPolicy = new AuthorizationPolicyBuilder().
            AddAuthenticationSchemes("Bearer").
            RequireAuthenticatedUser().
            RequireClaim("scope", "read").
            Build();
    })
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(o =>
    {
        o.Authority = "http://localhost:5000/openid";
        o.Audience = "embedded";
        o.RequireHttpsMetadata = false;
    });
    s.AddIdentityServer().AddTestConfig();
}).

This code uses AddAuthorization(…) extension method to define an AuthorizationPolicy that will be globally applied to all callers. The trick here is to set the policy as global FallbackPolicy - then it automatically applies to every endpoint. On the other hand, setting it as DefaultPolicy or a named policy would require us to manually attach authorrization to the required endpoints. All of these approach are equally valid and have their use cases, but in this exercise where we are after the most concise API declaration, such global policy that automatically applies sounds like a best example to use.

Afterwards, AddAuthentication(…) is used in combination with AddJwtBearer(…) to configure the application to accept and validate JWT tokens. In this case the authority it points at is … itself, because the application will additionally act as an embedded identity server (token issuer). This is a pattern that is commonly used for simple applications that do not require an external standalone identity provider. It is also useful for demos and proof of concepts, because it allows us to encapsulate everything in a single project. In this case Identity Server 4 is configured using AddIdentityServer().AddTestConfig(). The AddTestConfig() extension method is my own helper to bootstrap Identity Server with in-memory clients. It is here just for illustrative purposes so that we have a working configuration of the identity provider.

public static IIdentityServerBuilder AddTestConfig(this IIdentityServerBuilder builder) =>
    builder.AddInMemoryClients(new[] { new Client
        {
            ClientId = "client1",
            ClientSecrets =
            {
                new Secret("secret1".Sha256())
            },
            AllowedGrantTypes = { GrantType.ClientCredentials },
            AllowedScopes = { "read" }
        }}).AddInMemoryApiResources(new[]
        {
            new ApiResource("embedded")
            {
                Scopes = { "read" },
                Enabled = true
            },
        }).AddInMemoryApiScopes(new[] { new ApiScope("read") })
        .AddDeveloperSigningCredential();

Finally, to put all these pieces together, the Configure section of the Web API should be extended to include authorization and authentication.

Configure(app =>
{
    app.UseRouting();
    app.Map("/openid", id =>
    {
        // use embedded identity server to issue tokens
        id.UseIdentityServer();
    });
    app.UseAuthentication();
    app.UseAuthorization();
    app.UseEndpoints(e =>
    {
        // omitted for brevity
    });
})

In addition to activating both authentication and authorization middlewares, we also enable the embedded identity server to run on the relative path /openid. And this is everything - the entire code of the entire simple demo API now fits into a single small file (excluding the embedded identity server setup, but this is something separate), and yet it does demonstrate a very wide array of features. It is shown below:

WebHost.CreateDefaultBuilder().
ConfigureServices(s =>
{
    s.AddIdentityServer().AddTestConfig();
    s.AddSingleton<ContactService>();
    s.AddAuthorization(options =>
    {
        options.FallbackPolicy = new AuthorizationPolicyBuilder().
            AddAuthenticationSchemes("Bearer").
            RequireAuthenticatedUser().
            RequireClaim("scope", "read").
            Build();
    })
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(o =>
    {
        o.Authority = "http://localhost:5000/openid";
        o.Audience = "embedded";
        o.RequireHttpsMetadata = false;
    });
}).
Configure(app =>
{
    app.UseRouting();
    app.Map("/openid", id =>
    {
        // use embedded identity server to issue tokens
        id.UseIdentityServer();
    });
    app.UseAuthentication();
    app.UseAuthorization();
    app.UseEndpoints(e =>
    {
        var contactService = e.ServiceProvider.GetRequiredService<ContactService>();

        e.MapGet("/contacts",
            async c => await c.Response.WriteAsJsonAsync(await contactService.GetAll()));
        e.MapGet("/contacts/{id:int}",
            async c => await c.Response.WriteAsJsonAsync(await contactService.Get(int.Parse((string)c.Request.RouteValues["id"]))));
        e.MapPost("/contacts",
            async c =>
            {
                await contactService.Add(await c.Request.ReadFromJsonAsync<Contact>());
                c.Response.StatusCode = 201;
            });
        e.MapDelete("/contacts/{id:int}",
            async c =>
            {
                await contactService.Delete(int.Parse((string)c.Request.RouteValues["id"]));
                c.Response.StatusCode = 204;
            });
    });
}).Build().Run();

public record Contact(int ContactId, string Name, string Address, string City);

public class ContactService
{
    private readonly List<Contact> _contacts = new List<Contact>
        {
            new Contact(1, "Filip W", "Bahnhofstrasse 1", "Zurich"),
            new Contact(2, "Josh Donaldson", "1 Blue Jays Way", "Toronto"),
            new Contact(3, "Aaron Sanchez", "1 Blue Jays Way", "Toronto"),
            new Contact(4, "Jose Bautista", "1 Blue Jays Way", "Toronto"),
            new Contact(5, "Edwin Encarnacion", "1 Blue Jays Way", "Toronto")
        };

    public Task<IEnumerable<Contact>> GetAll() => Task.FromResult(_contacts.AsEnumerable());

    public Task<Contact> Get(int id) => Task.FromResult(_contacts.FirstOrDefault(x => x.ContactId == id));

    public Task<int> Add(Contact contact)
    {
        var newId = (_contacts.LastOrDefault()?.ContactId ?? 0) + 1;
        _contacts.Add(new Contact(newId, contact.Name, contact.Address, contact.City));
        return Task.FromResult(newId);
    }

    public async Task Delete(int id)
    {
        var contact = await Get(id);
        if (contact == null)
        {
            throw new InvalidOperationException(string.Format("Contact with id '{0}' does not exists", id));
        }

        _contacts.Remove(contact);
    }
}

As mentioned earlier, should we choose not to apply authorization globally, then it can also be done per endpoint.

e.MapPost("/contacts",
    async c =>
    {
        await contactService.Add(await c.Request.ReadFromJsonAsync<Contact>());
        c.Response.StatusCode = 201;
    }).RequireAuthorization();

Summary πŸ”—

.NET 5.0, C# 9 and ASP.NET Core provide a very exciting set of simplification features when it comes to authoring compact and concise Web APIs. This is a topic very close to my heart, because through my involvement in the C# scripting community, I have been exploring the ideas of building tiny Web APIs for many years now.

I very much looking forward to writing my APIs this way in the future, and I hope you found something useful in this post. All the source code is available on Github.

About


Hi! I'm Filip W., a software architect from ZΓΌrich πŸ‡¨πŸ‡­. I like Toronto Maple Leafs πŸ‡¨πŸ‡¦, Rancid and quantum computing. Oh, and I love the Lowlands 🏴󠁧󠁒󠁳󠁣󠁴󠁿.

You can find me on Github, on Mastodon and on Bluesky.

My Introduction to Quantum Computing with Q# and QDK book
Microsoft MVP