WF.FastControllers 0.1.8
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 slicesFastController<TResponse>base class for true no-request endpointsFastControllerWithRequest<TRequest>base class for request-only endpoints with no payload responseFastControllerbase class for endpoints with no request and no payload responseIEndpointcontract for endpoint discovery/mappingAddFastControllers(...)to register endpoint classes by assembly scanAddFastControllersWithAuth(...)to register authentication + authorization + endpoints togetherMapFastControllers()to map all discovered endpoints- Secure-by-default endpoints (authentication required unless explicitly opted out)
- Native sparse field projection via
IHasFieldswith support for:- top-level fields (
status,version) - nested paths (
details.meta.build) - array wildcard paths (
details.components.*.name)
- top-level fields (
- JSON responses ignore
nullvalues by default
Installation
Package id: WF.FastControllers
dotnet add package WF.FastControllers
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
Option A (recommended): one-call auth + endpoint registration
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();
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(...)overridesoptions.Endpoints.RoutePrefix - Endpoint
Version(...)overridesoptions.Endpoints.DefaultVersion
Create a Vertical Slice (REPR)
A slice normally contains:
RequestResponseHandlerEndpoint : 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 FastController<TResponse> instead.
using Microsoft.AspNetCore.Http;
using WF.FastControllers.Abstractions;
public sealed class Endpoint : FastController<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 : FastController<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>toFastController<TResponse> - handler signature changes from
HandleAsync(NoRequest req, CancellationToken ct)toHandleAsync(CancellationToken ct) - response helper call changes from
Ok(req, response)toOk(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 FastControllerWithRequest<TRequest>:
using Microsoft.AspNetCore.Http;
using WF.FastControllers.Abstractions;
public sealed class Endpoint : FastControllerWithRequest<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 FastController:
using Microsoft.AspNetCore.Http;
using WF.FastControllers.Abstractions;
public sealed class Endpoint : FastController
{
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 nullproperties 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)
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 : FastController<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 : FastController<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 : FastController<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)Created(req, location, response)Created(location, response)Respond(req, statusCode, response)Respond(statusCode, response)RespondWithLocation(req, statusCode, location, response)RespondWithLocation(statusCode, location, response)StatusCode(statusCode)NoContent()Problem(statusCode, title, detail, type, instance, extensions)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.
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.
Showing the top 20 packages that depend on WF.FastControllers.
| Packages | Downloads |
|---|---|
|
WF.FastControllers.FluentValidation
FluentValidation add-on for WF.FastControllers automatic request validation.
|
28 |
|
WF.FastControllers.FluentValidation
FluentValidation add-on for WF.FastControllers automatic request validation.
|
8 |
|
WF.FastControllers.FluentValidation
FluentValidation add-on for WF.FastControllers automatic request validation.
|
6 |
|
WF.FastControllers.FluentValidation
FluentValidation add-on for WF.FastControllers automatic request validation.
|
5 |
.NET 8.0
- Microsoft.AspNetCore.OpenApi (>= 8.0.17)
- Microsoft.OpenApi (>= 1.6.23)
.NET 9.0
- Microsoft.AspNetCore.OpenApi (>= 9.0.4)
- Microsoft.OpenApi (>= 1.6.23)
.NET 10.0
- Microsoft.AspNetCore.OpenApi (>= 10.0.0)
- Microsoft.OpenApi (>= 2.3.0)
| Version | Downloads | Last updated |
|---|---|---|
| 0.1.22 | 27 | 04/13/2026 |
| 0.1.21 | 6 | 04/13/2026 |
| 0.1.20 | 6 | 04/13/2026 |
| 0.1.19 | 5 | 04/13/2026 |
| 0.1.18 | 6 | 04/13/2026 |
| 0.1.17 | 7 | 04/12/2026 |
| 0.1.16 | 7 | 04/12/2026 |
| 0.1.15 | 5 | 04/12/2026 |
| 0.1.14 | 5 | 04/12/2026 |
| 0.1.13 | 8 | 04/12/2026 |
| 0.1.12 | 5 | 04/12/2026 |
| 0.1.11 | 6 | 04/12/2026 |
| 0.1.10 | 6 | 04/12/2026 |
| 0.1.9 | 5 | 04/12/2026 |
| 0.1.8 | 7 | 04/12/2026 |
| 0.1.7 | 6 | 04/12/2026 |
| 0.1.6 | 5 | 04/12/2026 |
| 0.1.5 | 6 | 04/12/2026 |
| 0.1.4 | 7 | 04/10/2026 |
| 0.1.3 | 8 | 04/10/2026 |
| 0.1.2 | 7 | 04/10/2026 |
| 0.1.1 | 5 | 04/10/2026 |
| 0.1.0 | 5 | 04/10/2026 |