/C#

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 that need to be injected. This, of course, means that your constructor parameter 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 the 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 map the Voyager routes by calling endpoints.MapVoyager().

Voyager 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.UseRouting();
        app.UseAuthorization();
        app.UseEndpoints(endpoints =>
        {
        	endpoints.MapVoyager();
            endpoints.MapControllers();
        });
    }

    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’ve used MediatR before, Voyager will feel familiar 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 be returned from this request. This is necessary for MediatR to do its 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 for. 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 inherit from the EndpointHandler base class and provide the type of our Request class and the type that is being returned. The base class provides some convenience methods like Ok(), BadRequest(), etc.

You could instead just implement the IEndpointHandler interface if you don’t want to add inheritance to your implementations.

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).

In fact, you can just use the normal [Authorize] attributes that you’re used to.

[Authorize(Policy = "MyPolicy")]
public class GetWeatherForecastHandler : EndpointHandler<GetWeatherForecastRequest>
{
    public override IActionResult HandleRequest(GetWeatherForecastRequest request)
    {
        ...
    }
}

Voyager also provides an alternative way to define Policies using types instead of strings.

You can make your own policies that group requirements 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.

Brent

Brent

I do some software development

Read More