The purpose of this post is to walk through the basics of starting an ASP.NET Core HTTP API. A fundamental understanding of all the parts that make an ASP.NET Core HTTP API work and why each element matters. We’ll also explore writing an extension method that will turn any class into a grouping for HTTP Endpoints. All the code is also available via GitHub on my repository.

The HTTP protocol

The HTTP specification is a critically important part of modern infrastructure, and without it, many of our favorite applications would not work. The HTTP protocol is the most widely used across cross-application communication, even when it’s not always the best. HTTP’s ability to deliver different content payloads to varying clients makes it convenient for developers everywhere. Response content types can range from HTML, JavaScript, CSS, and other binary file formats.

While the HTTP specification has many aspects, HTTP itself is a plain text format and is human readable. The creators of HTTP built it on the tenants of being simple, extensible, and stateless. As a protocol, HTTP has formats for both requests and responses, with elements overlapping across each. When building our HTTP APIs, we generally need to think of the HTTP in terms of the following components.

  Request Response
Headers
Version Protocol
Method  
Body
Path & Querystring  

It is essential for folks building an HTTP API to understand the limitations of HTTP methods and the proper usage of each HTTP method.

The Basics Of HTTP Methods

When working with the HTTP protocol, there are nine known request methods: CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, and TRACE. While we can use all methods when building HTTP APIs, most developers will predominantly stick with GET, POST, PUT, PATCH, and DELETE methods. Understanding the use cases for each can help us structure an API for a better client experience. Using proper semantics also helps reduce the explosion of paths in our API, as requests with similar paths but varied methods can have different outcomes.

GET HTTP Endpoints

Methods utilizing the GET method are typically read-only endpoints. Calling GET endpoints in our API should not cause any side-effects. Side-effects include updating a database resource, calling a third-party service, or generally changing a resource’s state. Side-effects don’t include logging and analytics. The advantage of using GET endpoints is that they can usually be cached by the calling client, along with any intermediate proxies.

Calls to a GET endpoints should not include any payload information within the HTTP request body. We must include any additional information to our API in the headers, path, and query string.

POST, PUT, and PATCH HTTP Endpoints

We consider the methods POST, PUT, and PATCH to be where the action happens in an HTTP API. These methods allow the client to specify the request’s body and the format they are sending to the server. We can set the body type in the Content-Type header. For most modern APIs, the Content-Type would generally be application/json but may also be application/x-www-form-urlencoded for APIs supporting HTML forms. We should consider these methods when passing data that will change a resource within our application.

We generally do not consider these methods safe to call repeatedly, as each call will mutate the resource’s state. We can cache the response, but caching freshness is dictated by the server and respected by the client.

The POST method is allowed by HTML forms, but the PUT and PATCH methods are not. We should consider our clients and their ability to specify methods when building HTTP APIs.

DELETE Endpoints

DELETE endpoints are used for destructive actions performed on the server, like removing a resource. It behaves similarly to the methods POST, PUT, and PATCH but offers more semantic correctness.

The Gist Of HTTP Methods In An API

We can help align methods with the actions they perform. Most APIs will have a combination of reading, creating, updating, and deleting endpoints. Consult the table below when considering what method to use for a new endpoint.

  Read Create Update Delete
GET      
POST      
PUT      
PATCH      
DELETE      

Content Negotiation

Content negotiation in an HTTP API can be as elaborate or as simple as we want it to be. It’s best to pick a single content type format across an API as a good rule of thumb. Currently, JavaScript Object Notation (JSON) is the most commonly used across many APIs for its simplicity and ubiquity. Some APIs still work with XML, and others pick other formats entirely.

When working with content negotiation, we set the Content-Type header on the HTTP request telling the server what content to expect from the client. If our API expects JSON, then the value would be application/json. For example, in the following line, we wish to read a JSON payload from a request.

await context.Request.ReadFromJsonAsync<Person>();

In ASP.NET Core, this call will fail if the Content-Type of our request is not application/json.

We can also set the Accept header from the client, informing the server to respond in a particular format. The server is under no obligation to respond in any specific form. A client indicates its preference for a structure because of its ability to process the response. Here we have code that sends a JSON response back to the client.

await context.Response.WriteAsJsonAsync<Person>(person);

Why Not ASP.NET Core MVC?

ASP.NET Core MVC is a capable framework for building APIs, and folks should consider it a valid option. That said, MVC comes with overhead in the pipeline that many folks may not need or utilize. This overhead can slow the performance of our eventual API. Overhead originates in multiple model-binding value providers, validation infrastructure, and overall pipeline extensibility points. The approach that we will walk through in the next section adopts a more barebones mentality. Admittedly, each situation will be different, and individuals should evaluate their needs before adopting any approach.

Let’s Build An API

Let’s start with a new Empty ASP.NET Core application. Either in your favorite IDE or using the dotnet CLI. Folks using the CLI can use the following command.

> dotnet new web -o basic

After the command is complete, we’ll have a single project with the following files.

  • appsettings.json
  • Program.cs
  • Startup.cs

We can see how developers can build simple HTTP APIs using ASP.NET Core in the Startup class.

app.UseEndpoints(endpoints =>
{
    endpoints.MapGet("/", async context =>
    {
        await context.Response.WriteAsync("Hello World!");
    });
});

While an acceptable approach, it can become tedious and noisy to build with this approach. Let’s look at how we can create a Module approach to HTTP APIs. The following class will allow us to define the same endpoints in separate classes. In the project, create a new class file named HttpEndpointsModulesRegistrationExtensions (wow! So verbose).

#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Routing;

namespace HttpApi
{
    public static class HttpEndpointsModulesRegistrationExtensions
    {
        /// <summary>
        /// Will map all public instance endpoint methods that have an HttpMethodAttribute, which includes:
        ///
        /// - HttpGet
        /// - HttpPost
        /// - HttpPut
        /// - HttpDelete
        /// - HttpPatch
        ///
        /// Modules must be added to the services collection in ConfigureServices. Modules will be created
        /// using the DI features of ASP.NET Core.
        ///
        /// Endpoint methods are allowed to have additional parameters, which will be resolved via the
        /// services collection as a courtesy to developers.
        /// </summary>
        /// <param name="endpoints"></param>
        /// <typeparam name="THttpEndpointsModule"></typeparam>
        /// <returns></returns>
        /// <exception cref="Exception"></exception>
        public static IEndpointRouteBuilder Map<THttpEndpointsModule>(this IEndpointRouteBuilder endpoints)
        {
            var type = typeof(THttpEndpointsModule);

            var methods = type
                .GetMethods(BindingFlags.Public | BindingFlags.Instance)
                .Select(m => new {method = m, attribute = m.GetCustomAttribute<HttpMethodAttribute>(true)})
                .Where(m => m.attribute is not null)
                .Select(m => new {
                    methodInfo = m.method, 
                    template = m.attribute?.Template, 
                    httpMethod = m.attribute?.HttpMethods
                })
                .ToList();
            
            foreach (var method in methods)
            {
                if (method.methodInfo.ReturnType != typeof(Task))
                    throw new Exception($"Endpoints must return a {nameof(Task)}.");
                
                if (method.methodInfo.GetParameters().FirstOrDefault()?.ParameterType != typeof(HttpContext))
                    throw new Exception($"{nameof(HttpContext)} must be the first parameter of any endpoint.");
                
                endpoints.MapMethods(method.template, method.httpMethod, context => {
                    var module = context.RequestServices.GetService(type);

                    if (module is null) {
                        throw new Exception($"{type.Name} is not registered in services collection.");
                    }
                    
                    var parameters = method.methodInfo.GetParameters();
                    List<object?> arguments = new() { context };
                    
                    // skip httpContext
                    foreach (var parameter in parameters.Skip(1))
                    {
                        var arg = context.RequestServices.GetService(parameter.ParameterType);
                        if (arg is null) {
                            throw new Exception($"{parameter.ParameterType} is not registered in services collection.");
                        }

                        arguments.Add(arg);
                    }
                    
                    var task = method.methodInfo.Invoke(module, arguments.ToArray()) as Task;
                    return task!;
                });
            }

            return endpoints;
        }
    }
}

Folks shouldn’t worry too much about the implementation details of this class. This extension method will also allow us to register modules using the following syntax in our Startup and the module class’s endpoints.

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }

    app.UseRouting();

    app.UseEndpoints(endpoints =>
    {
        endpoints.Map<HelloWorldModule>();
    });
}

Let’s define our module now. In a new C# file, add the following HelloWorldModule class. It’s important to note we are using ASP.NET Core MVC’s HttpMethodAttribute types. They help us define the method and the path to our HTTP endpoint. We could have created custom attributes to hold this metadata, but why reinvent the wheel?

public class HelloWorldModule
{
    [HttpGet("/")]
    public Task Get(HttpContext context)
    {
        return context.Response.WriteAsync("Hello World!");
    }
}

We can update your Startup class to register the module. You may need to add a using HttpApi namespace to the top of your Startup file. We also need to register our module with the service collection in ASP.NET Core. When complete, our Startup should look like the following.

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

namespace basic
{
    public class Startup
    {
        // This method gets called by the runtime. Use this method to add services to the container.
        // For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddTransient<HelloWorldModule>();
        }

        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.UseRouting();

            app.UseEndpoints(endpoints =>
            {
                endpoints.Map<HelloWorldModule>();
            });
        }
    }
}

Running our application, we see the response from our HelloWorldModule.

Hello World!

Now, in our module, we can handle all types of requests from our clients. One of the advantages to the Module approach is we can take full advantage of ASP.NET Core’s dependency injection, from taking in dependencies via constructor injection or taking in dependencies via parameters. It all just works!

Below is an example from the GitHub Repository of this project.

public class HelloWorldModule
{
    private readonly Person _person;

    public HelloWorldModule(Person person)
    {
        _person = person;
    }
    
    [HttpGet("/")]
    public async Task Index(HttpContext context, Person person)
    {
        await context.Response.WriteAsync($"Hello {person.Name}!");
    }

    [HttpGet("/bye")]
    public Task Get(HttpContext context)
    {
        return context.Response.WriteAsync($"Goodbye, {_person.Name}!");
    }

    [HttpGet("/person")]
    public async Task GetPerson(HttpContext context)
    {
        var reader = File.OpenText("./Views/Form.html");
        context.Response.ContentType = "text/html";
        var html = await reader.ReadToEndAsync();
        await context.Response.WriteAsync(html);
    }

    [HttpPost("/person")]
    public async Task Post(HttpContext context)
    {
        var person = await context.Request.BindFromFormAsync<PersonRequest>();
        context.Response.ContentType = "text/html";
        await context.Response.WriteAsync($"<h1>&#129321; Happy Birthday, {person.Name} ({person.Birthday:d})!</h1>");
        await context.Response.WriteAsync($"<h2>Counting {string.Join(",",person.Count)}...</h2>");
    }
    
}

Bonus: Form Binding

For folks looking at the example above, you may have noticed an extension method of BindFromFormAsync. The implementation is a trusting model binder written to map form values to a C# object. Trusting in the sense that it doesn’t support all edge cases or nested requests. It does support C# primitives and generic collections of primitives. While it’s not perfect, it works pretty well, and we can extend the code to meet most development needs. Here is the

using System;
using System.Collections;
using System.Globalization;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;

namespace HttpApi
{
    public static class FormCollectionExtensions
    {
        /// <summary>
        /// A naive model binding that takes the values found in the IFormCollection
        /// </summary>
        /// <param name="formCollection"></param>
        /// <param name="model"></param>
        /// <typeparam name="TModel"></typeparam>
        /// <returns></returns>
        public static TModel Bind<TModel>(this IFormCollection formCollection, TModel model = default)
            where TModel : new()
        {
            model ??= new TModel();
            
            var properties = typeof(TModel)
                .GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.SetProperty);

            foreach (var property in properties)
            {
                if (formCollection.TryGetValue(property.Name, out var values) ||
                    formCollection.TryGetValue($"{property.Name}[]", out values)) 
                {
                    // support generic collections
                    if (property.PropertyType.IsAssignableTo(typeof(IEnumerable)) &&
                        property.PropertyType != typeof(string) &&
                        property.PropertyType.IsGenericType)
                    {
                        var collectionType = property.PropertyType.GenericTypeArguments[0];
                        
                        var mi = typeof(Enumerable).GetMethod(nameof(Enumerable.Cast));
                        var cast = mi?.MakeGenericMethod(collectionType);

                        var items = values.Select<string, object?>(v => ConvertToType(v, collectionType));
                        var collection = cast?.Invoke(null, new object?[]{ items });
                        property.SetValue(model, collection);
                    }
                    else
                    {
                        // last in wins
                        var result = ConvertToType(values[^1], property.PropertyType);
                        property.SetValue(model, result);    
                    }
                }
            }

            return model;
        }

        public static async Task<TModel> BindFromFormAsync<TModel>(this HttpRequest request, TModel model = default)
            where TModel : new()
        {
            var form = await request.ReadFormAsync();
            return form.Bind(model);
        }

        private static object? ConvertToType(string value, Type type)
        {
            var underlyingType = Nullable.GetUnderlyingType(type);

            if (value.Length > 0)
            {
                if (type == typeof(DateTimeOffset) || underlyingType == typeof(DateTimeOffset))
                {
                    return DateTimeOffset.Parse(value, CultureInfo.InvariantCulture);
                }
                
                if (type == typeof(DateTime) || underlyingType == typeof(DateTime))
                {
                    return DateTime.Parse(value, CultureInfo.InvariantCulture);
                }

                if (type == typeof(Guid) || underlyingType == typeof(Guid))
                {
                    return new Guid(value);
                }

                if (type == typeof(Uri) || underlyingType == typeof(Uri))
                {
                    if (Uri.TryCreate(value, UriKind.RelativeOrAbsolute, out var uri))
                    {
                        return uri;
                    }

                    return null;
                }
            }
            else
            {
                if (type == typeof(Guid))
                {
                    return default(Guid);
                }

                if (underlyingType != null)
                {
                    return null;
                }
            }

            if (underlyingType is not null)
            {
                return Convert.ChangeType(value, underlyingType);
            }

            return Convert.ChangeType(value, type);
        }
    }
}

Conclusion

Through this post, I hope you learned about an HTTP API’s structure, when each method is appropriate to use, and how content negotiation can play an essential part in an API experience. With a few extension methods, we were also able to create a powerful module approach that allows us to build and manage an HTTP API. Development teams wanting community-supported solutions should consider Carter, which follows the method outlined in this post. Another option includes FeatherHttp, which is still in alpha but shows how minimal of an HTTP API we can build using ASP.NET Core.

If you have any thoughts, please leave them in the comments. Does this approach work for you? What’s missing? What could be better? I’d love to hear from you.

As always, thanks for reading.

Resources