Basic JSON APIs with Route-to-code in ASP.NET Core

By James Newton-King

ASP.NET Core supports a number of ways of creating JSON web APIs:

  • ASP.NET Core web API provides a complete framework for creating APIs. A service is created by inheriting from ControllerBase. Some features provided by the framework include model binding, validation, content negotiation, input and output formatting, and OpenAPI.
  • Route-to-code is a non-framework alternative to ASP.NET Core web API. Route-to-code connects ASP.NET Core routing directly to your code. Your code reads from the request and writes the response. Route-to-code doesn't have web API's advanced features, but there's also no configuration required to use it.

Route-to-code is a good approach when building small and basic JSON web APIs.

Create JSON web APIs

ASP.NET Core provides helper methods that ease the creation of JSON web APIs:

  • HasJsonContentType checks the Content-Type header for a JSON content type.
  • ReadFromJsonAsync reads JSON from the request and deserializes it to the specified type.
  • WriteAsJsonAsync writes the specified value as JSON to the response body and sets the response content type to application/json.

Lightweight, route-based JSON APIs are specified in Startup.cs. The route and the API logic are configured in UseEndpoints as part of an app's request pipeline.

Write JSON response

Consider the following code that configures a JSON API for an app:

app.UseEndpoints(endpoints =>
{
    endpoints.MapGet("/hello/{name:alpha}", async context =>
    {
        var name = context.Request.RouteValues["name"];
        await context.Response.WriteAsJsonAsync(new { message = $"Hello {name}!" });
    });
});

The preceding code:

  • Adds an HTTP GET API endpoint with /hello/{name:alpha} as the route template.
  • When the route is matched, the API reads the name route value from the request.
  • Writes an anonymous type as a JSON response with WriteAsJsonAsync.

Read JSON request

HasJsonContentType and ReadFromJsonAsync can be used to deserialize a JSON response in a route-based JSON API:

app.UseEndpoints(endpoints =>
{
    endpoints.MapPost("/weather", async context =>
    {
        if (!context.Request.HasJsonContentType())
        {
            context.Response.StatusCode = StatusCodes.Status415UnsupportedMediaType;
            return;
        }

        var weather = await context.Request.ReadFromJsonAsync<WeatherForecast>();
        await UpdateDatabaseAsync(weather);

        context.Response.StatusCode = StatusCodes.Status202Accepted;
    });
});

The preceding code:

  • Adds an HTTP POST API endpoint with /weather as the route template.
  • When the route is matched, HasJsonContentType validates the request content type. A non-JSON content type returns a 415 status code.
  • If the content type is JSON, the request content is deserialized by ReadFromJsonAsync.

Configure JSON serialization

There are two ways to customize JSON serialization:

  • Default serialization options can be configured with JsonOptions in the Startup.ConfigureServices method.
  • WriteAsJsonAsync and ReadFromJsonAsync have overloads that accept a JsonSerializerOptions object. This options object overrides the default options.
public void ConfigureServices(IServiceCollection services)
{
    services.Configure<JsonOptions>(o =>
    {
        o.SerializerOptions.WriteIndented = true;
    });
}

Authentication and authorization

Route-to-code supports authentication and authorization. Attributes, such as [Authorize] and [AllowAnonymous], can't be placed on endpoints that map to a request delegate. Instead, authorization metadata is added using the RequireAuthorization and AllowAnonymous extension methods.

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

    // Matches request to an endpoint.
    app.UseRouting();

    // Endpoint aware middleware. 
    // Middleware can use metadata from the matched endpoint.
    app.UseAuthentication();
    app.UseAuthorization();

    // Execute the matched endpoint.
    app.UseEndpoints(endpoints =>
    {
        // This endpoint doesn't require authorization.
        endpoints.MapPost("/login", async context =>
        {
            // App login logic...
        });

        // Configure this endpoint to require an authorized user.
        endpoints.MapGet("/hello/{name:alpha}", async context =>
        {
            var name = context.Request.RouteValues["name"];
            await context.Response.WriteAsJsonAsync(new { message = $"Hello {name}!" });
        }).RequireAuthorization();
    });
}

Dependency injection

Dependency injection (DI) using a constructor isn't possible with Route-to-code. Web API creates a controller for you with services injected into the constructor. A type isn't created when an endpoint is executed, so services must be resolved manually.

Route-based APIs can use IServiceProvider to resolve services:

  • Transient and scoped lifetime services, such as DbContext, must be resolved from HttpContext.RequestServices inside an endpoint's request delegate.
  • Singleton lifetime services, such as ILogger, can be resolved from IEndpointRouteBuilder.ServiceProvider. Services can be resolved outside of request delegates and shared between endpoints.
app.UseEndpoints(endpoints =>
{
    var logger = endpoints.ServiceProvider.GetService<ILogger<Startup>>();

    endpoints.MapGet("/user/{id}", async context =>
    {
        var repository = context.RequestServices.GetService<UserRepository>();

        var id = context.Request.RouteValues["id"];

        logger.LogDebug($"Getting user {id}");
        await context.Response.WriteAsJsonAsync(repository.GetUser(id));
    });
});

APIs that use DI extensively should consider using an ASP.NET Core app type that supports DI. For example, ASP.NET Core web API. Service injection using a controller's constructor is easier than manually resolving services.

API project structure

Route-based APIs don't have to be located in Startup.cs. APIs can be placed in other files and mapped at startup with UseEndpoints. This approach reduces the startup configuration file size.

Consider the following static UserApi class that defines a Map method. The method maps route-based APIs.

public static class UserApi
{
    public static void Map(IEndpointRouteBuilder endpoints)
    {
        endpoints.MapGet("/user/{id}", async context =>
        {
            // Get user logic...
        });

        endpoints.MapGet("/user", async context =>
        {
            // Get all users logic...
        });
    }
}

In the Startup.Configure method, the Map method and other class's static methods are called in UseEndpoints:

app.UseEndpoints(endpoints =>
{
    // Route-based APIs
    UserApi.Map(endpoints);
    ProductApi.Map(endpoints);

    // Map other endpoints...
    endpoints.MapRazorPages();
});

Notable missing features compared to Web API

Route-to-code is designed for basic JSON APIs. It doesn't have support for many of the advanced features provided by ASP.NET Core Web API.

Features not provided by Route-to-code include:

  • Model binding
  • Model validation
  • OpenAPI/Swagger
  • Content negotiation
  • Constructor dependency injection
  • ProblemDetails (RFC 7807)

Consider using ASP.NET Core web API to create an API if it requires some of the features in the preceding list.

Additional resources