WF.FastControllers.FluentValidation 0.1.20

WF.FastControllers

FastEndpoints-like primitives for building ASP.NET Core Minimal APIs with a REPR + vertical-slice style.

What You Get

  • FastController<TRequest, TResponse> base class for endpoint slices
  • FastControllerWithoutRequest<TResponse> base class for true no-request endpoints
  • FastController<TRequest> base class for request-only endpoints with no payload response
  • FastControllerWithoutRequest base class for endpoints with no request and no payload response
  • FastController compatibility alias for FastControllerWithoutRequest
  • IEndpoint contract for endpoint discovery/mapping
  • AddFastControllers(...) to register endpoint classes by assembly scan
  • AddFastControllersWithAuth(...) to register authentication + authorization + endpoints together
  • AddFastControllersValidation() (from WF.FastControllers.FluentValidation) to enable automatic FluentValidation request validation
  • MapFastControllers() to map all discovered endpoints
  • Secure-by-default endpoints (authentication required unless explicitly opted out)
  • Native sparse field projection via IHasFields with support for:
    • top-level fields (status,version)
    • nested paths (details.meta.build)
    • array wildcard paths (details.components.*.name)
  • JSON responses ignore null values by default

Installation

Package id: WF.FastControllers

dotnet add package WF.FastControllers

Validation add-on package id: WF.FastControllers.FluentValidation

dotnet add package WF.FastControllers.FluentValidation

If you are working from source, add a project reference instead:

dotnet add reference /absolute/path/to/WF.FastControllers/WF.FastControllers.csproj

Supported .NET Versions

  • .NET 8
  • .NET 9
  • .NET 10

The package is multi-targeted to stay compatible with the current runtime and the two previous major versions.

Minimal Setup in Your API Host

using WF.FastControllers.Extensions;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddFastControllersWithAuth(
    configureJwtAuthentication: opt =>
    {
        opt.SigningKey = builder.Configuration["Jwt:Key"]!;
        opt.SigningStyle = TokenSigningStyle.Symmetric;
    },
    configureJwtBearer: options =>
    {
        options.TokenValidationParameters.ValidateIssuer = true;
        options.TokenValidationParameters.ValidateAudience = true;
        options.TokenValidationParameters.ValidateLifetime = true;
        options.TokenValidationParameters.ValidIssuer = builder.Configuration["Jwt:Issuer"];
        options.TokenValidationParameters.ValidAudience = builder.Configuration["Jwt:Audience"];
    },
    configureFastControllers: options => options.Scan(typeof(Program).Assembly));

var app = builder.Build();
app.MapFastControllers();
app.Run();

Option A2: one-call auth + endpoint registration (raw AddJwtBearer)

using Microsoft.AspNetCore.Authentication.JwtBearer;
using WF.FastControllers.Extensions;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddFastControllersWithAuth(
    JwtBearerDefaults.AuthenticationScheme,
    authentication => authentication.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
    {
        options.Authority = "https://your-auth-server";
        options.Audience = "your-api";
    }),
    configureFastControllers: options => options.Scan(typeof(Program).Assembly),
    configureAuthorization: options =>
        options.AddPolicy("ManagerOnly", policy => policy.RequireClaim("access_level", "manager")));

var app = builder.Build();

app.MapFastControllers();

app.Run();

Option B: auth scheme already registered elsewhere

using Microsoft.AspNetCore.Authentication.JwtBearer;
using WF.FastControllers.Extensions;

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
    {
        options.Authority = "https://your-auth-server";
        options.Audience = "your-api";
    });

builder.Services.AddFastControllersWithAuth(
    JwtBearerDefaults.AuthenticationScheme,
    configureFastControllers: options => options.Scan(typeof(Program).Assembly));

var app = builder.Build();

app.MapFastControllers();

app.Run();

Option C: fully explicit registration

using WF.FastControllers.Extensions;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication("Bearer")
    .AddJwtBearer("Bearer", options =>
    {
        options.Authority = "https://your-auth-server";
        options.Audience = "your-api";
    });
builder.Services.AddAuthorization();
builder.Services.AddFastControllers(options =>
    options.Scan(typeof(Program).Assembly));

var app = builder.Build();

app.MapFastControllers();

app.Run();

Option D: FastControllers JWT helper

using System.Text;
using Microsoft.IdentityModel.Tokens;
using WF.FastControllers.Extensions;

builder.Services.AddAuthenticationJwtBearer(
    opt =>
    {
        opt.SigningKey = builder.Configuration["Jwt:Key"]!;
        opt.SigningStyle = TokenSigningStyle.Symmetric;
    },
    options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = builder.Configuration["Jwt:Issuer"],
            ValidAudience = builder.Configuration["Jwt:Audience"],
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!))
        };
    });

app.MapFastControllers() on WebApplication automatically adds UseAuthentication() and UseAuthorization() before mapping endpoints.

If you already add auth middleware manually, MapFastControllers() detects that and skips adding duplicates.

If you need full manual control over middleware order, call:

app.MapFastControllers(useAuthentication: false, useAuthorization: false);

You can also configure mapping with options, including global route defaults for endpoints that do not explicitly call Group(...) or Version(...):

app.MapFastControllers(options =>
{
    options.UseAuthentication = true;
    options.UseAuthorization = true;
    options.Endpoints.RoutePrefix = "api";
    options.Endpoints.DefaultVersion = 1;
});

Precedence rules:

  • Endpoint Group(...) overrides options.Endpoints.RoutePrefix
  • Endpoint Version(...) overrides options.Endpoints.DefaultVersion

Create a Vertical Slice (REPR)

A slice normally contains:

  • Request
  • Response
  • Handler
  • Endpoint : FastController<TRequest, TResponse>

Request

Use IHasFields if you want ?Fields= projection.

For endpoints that are always field-aware, inherit FastControllerWithFields<TRequest, TResponse> to enforce this at compile time.

using WF.FastControllers.Abstractions;

public sealed record GetHealthRequest(bool Detailed = false, string? Fields = null) : IHasFields;

public sealed class Endpoint : FastControllerWithFields<GetHealthRequest, GetHealthResponse>
{
    public override void Configure()
    {
        Group("api");
        Version(1);
        Get("/health");
        AllowAnonymous();
    }

    public override Task<IResult> HandleAsync(GetHealthRequest req, CancellationToken ct)
        => Task.FromResult(Ok(req, new GetHealthResponse("ok")));
}

For endpoints with no request payload or query model, inherit FastControllerWithoutRequest<TResponse>.

using Microsoft.AspNetCore.Http;
using WF.FastControllers.Abstractions;

public sealed class Endpoint : FastControllerWithoutRequest<PingResponse>
{
    public override void Configure()
    {
        Group("api");
        Version(1);
        Get("/ping");
        AllowAnonymous();
    }

    public override Task<IResult> HandleAsync(CancellationToken ct)
        => Task.FromResult(Ok(new PingResponse("pong")));
}

public sealed record PingResponse(string Message);

Migration Pattern: NoRequest -> Native No-Request Base

If you previously modeled no-input endpoints with an empty request type (or NoRequest), migrate like this:

// Before
public sealed class Endpoint : FastController<NoRequest, PingResponse>
{
    public override void Configure()
    {
        Get("/ping");
        AllowAnonymous();
    }

    public override Task<IResult> HandleAsync(NoRequest req, CancellationToken ct)
        => Task.FromResult(Ok(req, new PingResponse("pong")));
}

// After
public sealed class Endpoint : FastControllerWithoutRequest<PingResponse>
{
    public override void Configure()
    {
        Get("/ping");
        AllowAnonymous();
    }

    public override Task<IResult> HandleAsync(CancellationToken ct)
        => Task.FromResult(Ok(new PingResponse("pong")));
}

Migration deltas:

  • base type changes from FastController<NoRequest, TResponse> to FastControllerWithoutRequest<TResponse>
  • handler signature changes from HandleAsync(NoRequest req, CancellationToken ct) to HandleAsync(CancellationToken ct)
  • response helper call changes from Ok(req, response) to Ok(response)

Keep using FastController<TRequest, TResponse> whenever you need request binding (query, route, or body).

Native No-Response Patterns

If you only need a request (no response payload), inherit FastController<TRequest>:

using Microsoft.AspNetCore.Http;
using WF.FastControllers.Abstractions;

public sealed class Endpoint : FastController<CreateTodoRequest>
{
    public override void Configure()
    {
        Post("/todos");
    }

    public override Task<IResult> HandleAsync(CreateTodoRequest req, CancellationToken ct)
        => Task.FromResult(NoContent());
}

If you need neither request nor response payload, inherit non-generic FastControllerWithoutRequest:

using Microsoft.AspNetCore.Http;
using WF.FastControllers.Abstractions;

public sealed class Endpoint : FastControllerWithoutRequest
{
    public override void Configure()
    {
        Delete("/cache");
    }

    public override Task<IResult> HandleAsync(CancellationToken ct)
        => Task.FromResult(NoContent());
}

Response

public sealed record GetHealthResponse(string Status, string? Version = null, HealthDetails? Details = null);
public sealed record HealthDetails(string Environment, HealthMeta Meta, IReadOnlyList<HealthComponent> Components);
public sealed record HealthMeta(string Build);
public sealed record HealthComponent(string Name, string State);

Handler

public static class GetHealthHandler
{
    public static GetHealthResponse Handle(GetHealthRequest request)
    {
        var components = new List<HealthComponent>
        {
            new("database", "ok"),
            new("queue", "ok")
        };

        var details = new HealthDetails("prod", new HealthMeta("2026.04.09"), components);
        return new GetHealthResponse("ok", request.Detailed ? "v1" : null, details);
    }
}

Endpoint

using Microsoft.AspNetCore.Http;
using WF.FastControllers.Abstractions;

public sealed class Endpoint : FastController<GetHealthRequest, GetHealthResponse>
{
    public override void Configure()
    {
        Group("api");
        Version(1);
        Get("/health");
        Named("GetHealth");
        Tagged("Health");
        // Opt out for public endpoints.
        AllowAnonymous();
    }

    public override Task<IResult> HandleAsync(GetHealthRequest req, CancellationToken ct)
    {
        var response = GetHealthHandler.Handle(req);
        return Task.FromResult(Ok(req, response));
    }
}

Fields Projection

When request implements IHasFields, response is projected to the selected paths.

FastControllerWithFields<TRequest, TResponse> is an optional convenience base that ensures your request model exposes Fields.

  • Non-selected properties are set to null
  • null properties are omitted in JSON output
  • Result remains strongly typed internally

Examples:

  • ?Fields=status,version
  • ?Fields=details.meta.build
  • ?Fields=details.components.*.name
  • ?Fields=* (full response)

Validation Add-on

Enable automatic request validation when a validator exists for the request type. This requires the WF.FastControllers.FluentValidation package:

using FluentValidation;
using WF.FastControllers.Extensions;

builder.Services.AddFastControllersValidation();
builder.Services.AddScoped<IValidator<CreateUserRequest>, CreateUserRequestValidator>();

When enabled, FastControllers checks registered IValidator<TRequest> instances before HandleAsync(...). If validation fails, the endpoint short-circuits and returns an RFC7807 validation problem response containing all validation errors.

Endpoint DSL

Inside Configure() you can use:

  • Get(route)
  • Post(route)
  • Put(route)
  • Delete(route)
  • Patch(route)
  • Group(prefix)
  • Version(major)
  • Named(name)
  • Tagged(params tags)
  • AllowAnonymous()
  • Roles(params roles)
  • Policy(name)
  • Policies(params names)
  • Policy(builder => ...)
  • Policies(params (builder => ...))
  • AnyOfPolicies(params names)
  • AnyOf(params (builder => ...))
  • Summary(text)
  • Description(text)
  • Produces<T>(statusCode, contentType, ...)
  • Produces(statusCode, typeof(T), contentType, ...)
  • ProducesProblem(statusCode, contentType, ...)
  • ProducesValidationProblem(statusCode = 400, contentType, ...)
  • Example(statusCode, payload, contentType = "application/json", name = "default")
  • OpenApi(operation => { ... }) (preferred)

Inside HandleAsync(...), you can also read route parameters directly:

  • Route<T>("name") (example: var id = Route<Guid>("id"); for /items/{id:guid})

OpenApi(Action<OpenApiOperation>) is the recommended overload for new code. OpenApi(Func<OpenApiOperation, OpenApiOperation>) remains available for compatibility.

By default, endpoints require authenticated users. Use AllowAnonymous() to opt out on a specific endpoint.

For custom access-level checks, configure named policies in authorization options and reference them from endpoints:

public sealed class Endpoint : FastControllerWithoutRequest<ReportResponse>
{
    public override void Configure()
    {
        Get("/reports/manager");
        Policy("ManagerOnly");
    }

    public override Task<IResult> HandleAsync(CancellationToken ct)
        => Task.FromResult(Ok(new ReportResponse("ok")));
}

You can also define explicit policy rules directly in Configure():

public sealed class Endpoint : FastControllerWithoutRequest<ReportResponse>
{
    public override void Configure()
    {
        Get("/reports/executive");
        Policy(policy => policy.RequireClaim("access_level", "executive"));
    }

    public override Task<IResult> HandleAsync(CancellationToken ct)
        => Task.FromResult(Ok(new ReportResponse("ok")));
}

For explicit chained access handling (this AND this OR that AND that), use AnyOf(...):

public sealed class Endpoint : FastControllerWithoutRequest<ReportResponse>
{
    public override void Configure()
    {
        Get("/reports/tiered");
        AnyOf(
            policy =>
            {
                policy.RequireRole("Admin");
                policy.RequireClaim("access_level", "manager");
            },
            policy =>
            {
                policy.RequireRole("Auditor");
                policy.RequireClaim("access_level", "executive");
            });
    }

    public override Task<IResult> HandleAsync(CancellationToken ct)
        => Task.FromResult(Ok(new ReportResponse("ok")));
}

Response Helpers

Inside HandleAsync(...) use:

  • Ok(req, response)
  • Ok(response)
  • Ok(content, contentType, fileName, encoding)
  • Created(req, location, response)
  • Created(location, response)
  • Respond(req, statusCode, response)
  • Respond(statusCode, response)
  • Respond(statusCode)
  • RespondWithLocation(req, statusCode, location, response)
  • RespondWithLocation(statusCode, location, response)
  • StatusCode(statusCode)
  • NoContent()
  • BadRequest()
  • Unauthorized()
  • Forbidden()
  • NotFound()
  • Conflict()
  • UnprocessableEntity()
  • Problem(statusCode, title, detail, type, instance, extensions)
  • ThrowError(message, statusCode)
  • ValidationProblem(errors, detail, instance)
  • BadRequestProblem(detail)
  • UnauthorizedProblem(detail)
  • ForbiddenProblem(detail)
  • NotFoundProblem(detail)
  • ConflictProblem(detail)

These helpers apply Fields shaping automatically (when request implements IHasFields). If you call overloads without req, shaping uses the current request context automatically.

statusCode can be provided as either int (for StatusCodes.StatusXXX) or HttpStatusCode.

OpenAPI Metadata Example

public sealed class Endpoint : FastController<GetHealthRequest, GetHealthResponse>
{
    public override void Configure()
    {
        Group("api");
        Version(1);
        Get("/health");

        Summary("Get current health status");
        Description("Returns current health details and supports sparse field selection.");

        Produces<GetHealthResponse>(200);
        ProducesValidationProblem(400);
        ProducesProblem(404);

        Example(200, new GetHealthResponse("ok"));
    }

    public override Task<IResult> HandleAsync(GetHealthRequest req, CancellationToken ct)
    {
        if (string.IsNullOrWhiteSpace(req.Fields) && req.Detailed is false)
        {
            return Task.FromResult(BadRequestProblem("Either Detailed=true or Fields must be provided."));
        }

        var response = GetHealthHandler.Handle(req);
        return Task.FromResult(Ok(req, response));
    }
}

Testing Helpers

WF.FastControllers.Testing provides helpers for both unit and integration tests.

Unit testing helpers

using WF.FastControllers.Testing;

var endpoint = new GetHealth.Endpoint();
var result = await endpoint.ExecuteAsync(new GetHealth.Request(Detailed: true));
var context = await result.ExecuteResultAsync();

Assert.Equal(200, context.Response.StatusCode);

For no-request endpoints:

using WF.FastControllers.Testing;

var endpoint = new Ping.Endpoint();
var result = await endpoint.ExecuteAsync();

You can also assert endpoint configuration metadata (verb/route/name/tags/auth):

using WF.FastControllers.Testing;

new DeleteEndpoint().AssertConfigured(definition =>
    definition
        .ShouldUseVerb("DELETE")
        .ShouldUseRoute("/api/v1/no-request/delete")
        .ShouldUseGroup("api")
        .ShouldUseVersion(1)
        .ShouldUseName("NoRequestDelete")
        .ShouldHaveTag("NoRequest")
        .ShouldAllowAnonymous());

new TieredEndpoint().AssertConfigured(definition =>
    definition
        .ShouldRequireAuthorization()
        .ShouldRequireAnyOfAlternatives(2));

new NamedAnyOfEndpoint().AssertConfigured(definition =>
    definition.ShouldRequireAnyOfPolicies("ManagerOnly", "ExecutiveOnly"));

Integration testing helper

using System.Security.Claims;
using WF.FastControllers.Extensions;
using WF.FastControllers.Testing;

builder.Services.AddFastControllersTestAuth(
    principalFactory: httpContext =>
    {
        if (!httpContext.Request.Headers.TryGetValue(FastControllersTestingDefaults.UserHeader, out var user))
        {
            return null;
        }

        var claims = new List<Claim> { new(ClaimTypes.Name, user[0]!) };
        return new ClaimsPrincipal(new ClaimsIdentity(claims, FastControllersTestingDefaults.Scheme));
    },
    configureFastControllers: options => options.Scan(typeof(Program).Assembly));

This helper wires test authentication + authorization + FastControllers registration in one call.

JWT token helper for tests

Use JwtBearer.CreateToken(...) when you want realistic bearer tokens in integration tests:

using System.Security.Claims;
using WF.FastControllers.Testing;

var expirationUtc = DateTimeOffset.UtcNow.AddMinutes(30);

var token = JwtBearer.CreateToken(o =>
{
    o.Issuer = configuration["Jwt:Issuer"]!;
    o.SigningKey = configuration["Jwt:Key"]!;
    o.ExpireAt = expirationUtc;
    o.Audience = configuration["Jwt:Audience"]!;

    o.User.Roles.AddRange(roles);
    o.User.Claims.AddRange(claims);
    o.User["UserId"] = $"{user.UserId}";
});

The helper signs tokens with HmacSha256 by default and includes roles/claims/custom key-values as JWT claims.

Try It

curl "http://localhost:5000/api/v1/health?Detailed=true"
curl "http://localhost:5000/api/v1/health?Detailed=true&Fields=details.meta.build"
curl "http://localhost:5000/api/v1/health?Detailed=true&Fields=details.components.*.name"

Notes

  • Keep concrete endpoints in your app (or test) assembly; package only provides infrastructure.
  • If no fields are provided, full response is returned.
  • Field matching is case-insensitive.

No packages depend on WF.FastControllers.FluentValidation.

.NET 8.0

.NET 9.0

.NET 10.0

Version Downloads Last updated
0.1.22 28 04/13/2026
0.1.21 8 04/13/2026
0.1.20 5 04/13/2026
0.1.19 6 04/13/2026
0.1.18 5 04/13/2026
0.1.17 8 04/12/2026
0.1.16 6 04/12/2026