Announcing Voyager: An Alternative to Controllers in ASP.NET Core

I'm happy to announce my new project called Voyager!

What Is It?

Voyager is an alternative way to route requests in ASP.NET core.

Why Might I Use It?

Controller classes can get pretty big. Each endpoint can have different dependencies they need injected which means your constructor paramater list can get long.

With Voyager you can break apart your giant controller classes into individual small classes for each endpoint. You only have to work with code that applies to your task.

Getting Started

To get started you first need to install the Voyager nuget package.

The next step is to add Voyager in the ConfigureServices function of your startup class. As part of configuration you need to tell voyager what assemblies contain your Request and Handler classes.

public void ConfigureServices(IServiceCollection services)
{
    services.AddVoyager(c =>
    {
        c.AddAssemblyWith<Startup>();
    });
}

Next you need to add some middleware in the Configure function. You can call the UseVoyagerRouting and UseVoyagerEndpoints extension methods to add the required middleware.

Voyager also provides an exception handler middleware that will catch any unhandled exceptions (including validation exceptions). These will be returned as a 500 response using the problem details specification. You can use your own exception handler middleware if you prefer.

A complete example of what a Startup class might look like is included below.

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public IConfiguration Configuration { get; }

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

        app.UseVoyagerRouting();
        app.UseRouting();
        app.UseAuthorization();
        app.UseEndpoints(endpoints =>
        {
            endpoints.MapControllers();
        });
        app.UseVoyagerEndpoints();
    }

    public void ConfigureServices(IServiceCollection services)
    {
        services.AddControllers();
        services.AddVoyager(c =>
        {
            c.AddAssemblyWith<Startup>();
        });
    }
}

How It Works

Voyager uses the great MediatR library to do a lot of the heavy lifting. If you're used MediatR before, Voyager will feel familier to you.

To explain in further detail I'm going to start with a normal controller class and show how we can do the same thing using Voyager.

[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
    private static readonly string[] Summaries = new[]
    {
        "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
    };

    [HttpGet]
    [Route("{city}")]
    public IEnumerable<GetWeatherForecastResponse> Get([FromRoute] string city, [FromQuery(Name = "d")]int days = 5)
    {
        var rng = new Random();
        return Enumerable.Range(1, days).Select(index => new GetWeatherForecastResponse
        {
            City = city,
            Date = DateTime.Now.AddDays(index),
            TemperatureC = rng.Next(-20, 55),
            Summary = Summaries[rng.Next(Summaries.Length)]
        })
        .ToArray();
    }
}

public class GetWeatherForecastResponse
{
    public string City { get; set; }
    public DateTime Date { get; set; }
    public string Summary { get; set; }
    public int TemperatureC { get; set; }
    public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}

An example request that would match this example would be /WeatherForecast/Kansas City?d=10

Request

The first thing we need to do when using Voyager is to create a request object.

[Route(HttpMethod.Get, "WeatherForecast/{city}")]
public class GetWeatherForecastRequest : EndpointRequest<IEnumerable<GetWeatherForecastResponse>>
{
    [FromRoute]
    public string City { get; set; }

    [FromQuery("d")]
    public int Days { get; set; } = 5;
}

Let's go over each part of this code.

Route

[Route(HttpMethod.Get, "WeatherForecast/{city}")]

We use the [Route] attribute to indicate what http method and path is used for this request. You can use the same template syntax in the path that you are used to in controller routing.

Interface

public class GetWeatherForecastRequest : EndpointRequest<IEnumerable<GetWeatherForecastResponse>>

The request object implements the EndpointRequest interface. The EndpointRequest interfaces also needs the type that will returned from this request. This is necessary for MediatR to do it's work. You could also just implement MediatR's IRequest interface directly if you wish.

public class GetWeatherForecastRequest : IRequest<ActionResult<<IEnumerable<GetWeatherForecastResponse>>>>

Model Binding

[FromRoute]
public string City { get; set; }

[FromQuery("d")]
public int Days { get; set; } = 5;

You can use the [FromRoute] and [FromQuery] attributes to tell voyager where it should get the data from. As you can see in the [FromQuery] example if you want to use a different name for your property you can pass in the name that Voyager should look. If you don't provide a name Voyager will use the name of the property.

If a property does not use a From attribute then Voyager will look for the value in the body of the request.

Handler

Every request must have a corresponding handler class.

public class GetWeatherForecastHandler : EndpointHandler<GetWeatherForecastRequest, IEnumerable<GetWeatherForecastResponse>>
{
    private static readonly string[] Summaries = new[]
    {
        "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
    };

    public override ActionResult<IEnumerable<GetWeatherForecastResponse>> HandleRequest(GetWeatherForecastRequest request)
    {
        if (request.Days < 1)
        {
            throw new ArgumentException("Days must be greater than 0");
        }
        var rng = new Random();
        return Enumerable.Range(1, request.Days).Select(index => new GetWeatherForecastResponse
        {
            Date = DateTime.Now.AddDays(index),
            TemperatureC = rng.Next(-20, 55),
            Summary = Summaries[rng.Next(Summaries.Length)],
            City = request.City
        })
        .ToArray();
    }
}

As you can see this is very similar to the function that was used in the controller class. There are only two lines to point out.

public class GetWeatherForecastHandler : EndpointHandler<GetWeatherForecastRequest, IEnumerable<GetWeatherForecastResponse>>

Here you see that we need to inherit from the EndpointHandler base class and provide the type of our Request class and the type that is being returned.

public override ActionResult<IEnumerable<GetWeatherForecastResponse>> HandleRequest(GetWeatherForecastRequest request)

This is the signature for the function that is called to handle the request.

If you want to use async/await all you have to do is override HandleRequestAsync.

IActionResult

If you prefer to not have a strongly typed return value you can also omit the return type and instead return an IActionResult.

public class GetWeatherForecastRequest : EndpointRequest
{
    ...
}

public class GetWeatherForecastHandler : EndpointHandler<GetWeatherForecastRequest>
{
    public override IActionResult HandleRequest(GetWeatherForecastRequest request)
    {
        ...
    }
}

Providing the return type is useful if you are writing tests or using tools like OpenApi(Swagger).

Validation

Validation is performed using Fluent Validation. Simply add a Validator class for your Request object. Voyager will handle the rest.

public class GetWeatherForecastRequestValidator : AbstractValidator<GetWeatherForecastRequest>
{
    public GetWeatherForecastRequestValidator()
    {
        RuleFor(request => request.Days).GreaterThanOrEqualTo(1);
    }
}

Authorization

Authorization is handled by the IAuthorizationService (using standard AspNet core Requirements and Handlers).

Requirements are grouped together into policies. You can make your own policies by creating a class that implements the Policy interface. The interface requires a single GetRequirements function that returns a list of all the requirements that must be satisfied.

public class AuthenticatedPolicy : Policy
{
    public IList<IAuthorizationRequirement> GetRequirements()
    {
        return new IAuthorizationRequirement[]
        {
            new AuthenticatedRequirement(),
        };
    }
}

Voyager provides an AnonymousPolicy and AuthenticatedPolicy for you.

You apply a policy to a handler by adding the Enforce interface and providing a policy.

public class GetWeatherForecastHandler : EndpointHandler<GetWeatherForecastRequest>, Enforce<AuthenticatedPolicy>
{
    ...
}

Project Info

The project is on Github so check it out and let me know what you think.