Skip to content
kododo-devPublic

About

Lightweight request/handler abstraction for ASP.NET Core Minimal APIs, plus a helper for serving embedded SPAs.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

57 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Reiho.AspNetCore

CI

Lightweight request/handler abstraction for ASP.NET Core Minimal APIs, plus a helper for serving embedded SPAs.


Installation

dotnet add package Kododo.Reiho.AspNetCore

Request / Handler

Define a request

Without a return value:

public sealed record DeleteUser(int Id) : IRequest;

With a return value:

public sealed record GetUser(int Id) : IRequest<UserDto>;

Implement a handler

public sealed class DeleteUserHandler : IRequestHandler<DeleteUser>
{
    public Task HandleAsync(DeleteUser request, CancellationToken ct)
    {
        // ...
        return Task.CompletedTask;
    }
}

public sealed class GetUserHandler : IRequestHandler<GetUser, UserDto>
{
    public Task<UserDto> HandleAsync(GetUser request, CancellationToken ct)
    {
        return Task.FromResult(new UserDto { Id = request.Id });
    }
}

Register handlers

// Scans the given assembly for handlers
builder.Services.AddRequestHandlers(typeof(Program).Assembly);

Map endpoints

// Maps all requests found in the given assembly
app.MapRequests(typeof(Program).Assembly);

// With a base path — use MapGroup
app.MapGroup("/api").MapRequests(typeof(Program).Assembly);

MapRequests returns the mapped endpoints: conventions on it apply to all of them, and For<TRequest>() configures one. Map<TRequest>() maps a single request and returns its endpoint:

var requests = app.MapGroup("/api").MapRequests(typeof(Program).Assembly);
requests.WithTags("Orders");
requests.For<ExportOrders>().CacheOutput();

app.Map<Ping>("/ping").AllowAnonymous();

Conventions

Each request type is mapped to a POST endpoint named after the type:

POST /GetUser
POST /DeleteUser

Request body:

Situation Behaviour
Body present Deserialized from JSON
No body, parameterless constructor exists Empty instance created
No body, no parameterless constructor 400 Bad Request, problem details
Body that is not valid JSON for the request 400 Bad Request, problem details; a value of the wrong type is named in errors

Response:

Request type Status Body
IRequest 204 No Content —
IRequest<TResult> 200 OK JSON
invalid request, RequestException 4xx problem details (application/problem+json)

Attributes: attributes on the request type apply to its endpoint, as on a controller action, including those inherited from a base request:

[Authorize(Roles = "Admin")]
public sealed record DeleteUser(int Id) : IRequest;

[AllowAnonymous]   // open, even in a group with RequireAuthorization()
public sealed record GetStatus : IRequest<Status>;

This works for any endpoint metadata attribute, e.g. [EnableRateLimiting] or [Tags]. An [Authorize] request needs UseAuthorization() in the pipeline; without it, ASP.NET Core rejects calls to that endpoint rather than serving them unprotected.


Validation and errors

Data annotations on the request are checked before its handler runs, also on the parameters of a positional record; IValidatableObject runs once they pass. A request that fails gets 400 with the messages per field, under the fields' JSON names:

public sealed record CreateUser([Required, StringLength(50)] string Name, [Range(18, 120)] int Age) : IRequest<UserDto>;
{ "title": "One or more validation errors occurred.", "status": 400,
  "errors": { "name": ["The name field is required."], "age": ["The field age must be between 18 and 120."] } }

Checks go on into nested objects and collections of them, with the field's path as the key (address.street, lines[1].quantity). Requests are also strict about their shape:

A field the request does not have, e.g. a typo 400, "The request has no such field." under its path
null, or no value, for a reference the code declares non-nullable (string Name) 400, "The name field is required."; an empty string is a value
A nullable reference (string? Note) or a value type as before

Non-nullable references count as required as in MVC; it takes nullable reference types to be enabled in the project that declares the request.

A handler ends a request with an error the caller can act on by throwing RequestException:

var user = await users.FindAsync(request.Id, ct)
    ?? throw RequestException.NotFound($"User {request.Id} does not exist.");    // 404
if (await users.NameTakenAsync(request.Name, ct))
    throw RequestException.Invalid(nameof(request.Name), "The name is taken.");  // 400, errors.name
Status
new RequestException(message) 400, or the status passed
RequestException.Invalid(field, message) 400 with errors
RequestException.NotFound(message) 404
RequestException.Forbidden(message) 403
RequestException.Conflict(message) 409

The message becomes the problem's detail, so write it for the caller. Other exceptions are left to the application, e.g. its UseExceptionHandler(). With AddProblemDetails(), Reiho writes its problems through the app's IProblemDetailsService, so CustomizeProblemDetails applies to them too.


Files

A request receives files through IFormFile properties, or a list such as IReadOnlyList<IFormFile>, and takes multipart/form-data: its other properties come from the form's fields, the files from its file parts of the same name. Validation applies as usual:

public sealed record UploadAvatar(int UserId) : IRequest<string>
{
    [Required] public IFormFile? Avatar { get; init; }
}

A form needs no CORS preflight, so it can be posted from another site with the user's cookies. As minimal APIs do for forms, such a request requires an antiforgery token: add AddAntiforgery() and UseAntiforgery(), and send the token with the form. A request that is safe without one, e.g. behind bearer authentication only, says so with [RequireAntiforgeryToken(false)]. Requests without files keep taking JSON only.

A handler returns a file with FileResponse; it is the response body with its content type, and a download when it has a name:

public sealed record ExportOrders : IRequest<FileResponse>;
// in the handler
return FileResponse.FromText(csv, "text/csv", "orders.csv");
return new FileResponse(File.OpenRead(path), "application/pdf", "invoice.pdf");

An MCP tool returns a FileResponse as an embedded resource, as text for a text type; a request that receives files cannot be an MCP tool.


Request filters

An IRequestFilter runs around the handler of every request, over HTTP and as an MCP tool alike, once the request has been read and validated. Unlike an endpoint filter, it sees the request object, e.g. for auditing:

public sealed class AuditFilter(ILogger<AuditFilter> logger) : IRequestFilter
{
    public async ValueTask<object?> InvokeAsync(RequestFilterContext context, RequestFilterDelegate next)
    {
        var result = await next(context);
        logger.LogInformation("{User} ran {Request}", context.HttpContext?.User.Identity?.Name, context.Request);
        return result;
    }
}

builder.Services.AddRequestFilter<AuditFilter>();

Filters run in the order they are added, scoped to the request. context.Transport says whether the request came over HTTP or MCP; context.HttpContext is null for an MCP tool call. A filter stops a request with a RequestException, or answers it without calling next, with a result of the request's result type.


Observability

Each request gets an activity from the Kododo.Reiho source, around its filters and handler, named after the request and tagged with reiho.request, reiho.transport (http or mcp) and, for a RequestException, reiho.status_code. The HTTP request's own activity gets reiho.request too, so traces show FindOrders rather than only POST /api/FindOrders:

builder.Services.AddOpenTelemetry().WithTracing(t => t.AddSource(ReihoTelemetry.ActivitySourceName));

Requests turned away, whether not valid or ended by a RequestException, are logged at debug level under Kododo.Reiho.Requests, with the request, the reason and the fields at fault:

builder.Logging.AddFilter(ReihoTelemetry.LogCategory, LogLevel.Debug);

OpenAPI

Endpoints describe themselves for ApiExplorer, so Microsoft.AspNetCore.OpenApi, Swashbuckle and Scalar document them without extra setup:

Operation id the full route (/api/GetUser → Api_GetUser)
Summary the request's [Description]
Request body the request as JSON; optional when it has a parameterless constructor
Responses 200 with the result type, or 204 for an IRequest; 400 as a validation problem
[Description("Finds the orders of a customer, newest first.")]
public sealed record FindOrders([property: Description("Customer id, e.g. c-63")] string CustomerId) : IRequest<Order[]>;
builder.Services.AddOpenApi();
app.MapOpenApi();
app.MapRequests(typeof(Program).Assembly);

Each endpoint is named after its full route, group prefix included (/api/admin/GetUser → Api_Admin_GetUser), which becomes the operation id that client generators (NSwag, Kiota, openapi-generator) build method names from. A route is unique, so requests of the same name mapped in different groups, e.g. by two libraries, do not clash.

Reiho writes enums as strings. The document's schemas follow the app's own JSON options, so add the same converter there to document them as strings:

builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));

Embedded SPA

Serves a single-page application bundled as embedded resources, with automatic base path injection and aggressive caching for static assets.

Project setup

Add to your .csproj:

<PropertyGroup>
  <GenerateEmbeddedFilesManifest>true</GenerateEmbeddedFilesManifest>
</PropertyGroup>

<ItemGroup>
  <EmbeddedResource Include="Frontend\dist\**\*" />
  <PackageReference Include="Microsoft.Extensions.FileProviders.Embedded" Version="10.0.12" />
</ItemGroup>

Microsoft.Extensions.FileProviders.Embedded provides the build task that writes the manifest. Use a 10.x version on every target framework, net8.0 and net9.0 included. The .NET 10 SDK prunes 8.x and 9.x as part of the shared framework, which drops the task, so the manifest is missing and MapEmbeddedSpa fails at startup.

Place __BASE_PATH__ somewhere in your index.html — it is replaced at runtime with the actual mount path so the SPA router works correctly regardless of where it is mounted:

<script>window.__BASE_PATH__ = '__BASE_PATH__'</script>

Mount

The mount path comes from MapGroup. MapEmbeddedSpa requires an assembly and accepts an optional rootPath:

app.MapGroup("/ui").MapEmbeddedSpa(Assembly.GetExecutingAssembly(), "Frontend/dist");

rootPath defaults to "SPA/dist" if not specified. To add ASP.NET Core conventions such as authorization, apply them on the group builder before calling MapEmbeddedSpa:

app.MapGroup("/ui")
   .RequireAuthorization()
   .MapEmbeddedSpa(Assembly.GetExecutingAssembly(), "Frontend/dist");

Base path behind a reverse proxy

By default the base path is calculated from the incoming request, including Request.PathBase. This breaks when the application runs behind a reverse proxy that strips a path prefix before forwarding, for example Caddy's handle_path: the app never sees the prefix the browser uses.

Set the ASPNETCORE_PATHBASE environment variable to the prefix the proxy strips:

ASPNETCORE_PATHBASE=/configway/demo

With MapGroup("/ui"):

  • public URL: https://example.com/configway/demo/ui/
  • Caddy strips /configway/demo, passes /ui/… to the backend
  • ASPNETCORE_PATHBASE=/configway/demo → <base href="/configway/demo/ui/">
  • browser resolves assets/app.js → /configway/demo/ui/assets/app.js → Caddy strips prefix → /ui/assets/app.js ✓

An app can also apply the same value to its own routing and link generation, which is what it needs when the proxy passes the prefix on:

app.UsePathBase(app.Configuration["PathBase"] ?? "");

When UsePathBase has already removed the prefix, it is in Request.PathBase and is not added a second time, so the same setting works whether or not the proxy strips the prefix.

Custom base path placeholder

app.MapGroup("/ui").MapEmbeddedSpa(
    Assembly.GetExecutingAssembly(),
    rootPath: "Frontend/dist",
    basePathPlaceholder: "%%BASE%%");

Per-request placeholders

Other values can be put into index.html the same way as the base path: settings for the SPA, an import map, a nonce for a Content-Security-Policy. Use the overload that takes options:

app.MapGroup("/ui").MapEmbeddedSpa(Assembly.GetExecutingAssembly(), o =>
{
    o.RootPath = "Frontend/dist";
    o.Placeholder("__NONCE__", ctx => ctx.Items["csp-nonce"] as string ?? "");
    o.Placeholder("__SETTINGS__", async ctx =>
    {
        var settings = await ctx.RequestServices.GetRequiredService<ISettingsStore>().GetAsync();
        return JsonSerializer.Serialize(settings);
    });
});
<script nonce="__NONCE__">window.settings = __SETTINGS__</script>
  • Placeholders are replaced after the base path, in the order they were added.
  • Values are inserted as is. Encode them for where they land: JSON in a script, attribute encoding in an attribute.
  • A value is only computed when its placeholder appears in index.html.
  • index.html is never cached, so every request gets its own values. Other files are served unchanged.

Caching behaviour

File Cache-Control
index.html no-store
All other assets public, max-age=31536000, immutable

Files are read once and cached in memory. Multiple SPAs mounted at different paths are cached independently.


MCP tools

Kododo.Reiho.AspNetCore.Mcp offers requests as tools to AI agents over the Model Context Protocol, next to their HTTP endpoints, with the official MCP C# SDK.

dotnet add package Kododo.Reiho.AspNetCore.Mcp

Mark the requests to offer with [McpTool] and describe them; agents choose and call tools from these descriptions:

[McpTool(ReadOnly = true), Description("Finds the orders of a customer, newest first.")]
public sealed record FindOrders([property: Description("Customer id, e.g. c-63")] string CustomerId) : IRequest<Order[]>;
builder.Services.AddRequestHandlers(typeof(Program).Assembly);
builder.Services.AddMcpServer()
    .WithHttpTransport()
    .WithRequests(typeof(Program).Assembly);

app.MapMcp("/mcp").RequireAuthorization();
Tool name the request's name in snake case (find_orders), or [McpTool(Name = ...)]; o.Prefix puts a prefix before every name
Input schema the request's properties, with their [Description]
Result the handler's result as JSON; Done. for an IRequest
Invalid arguments an error result the agent can read and correct, also for the request's validation
RequestException an error result with its status and message, e.g. 404 Not Found: User 9 does not exist.
Hints ReadOnly, Destructive, Idempotent, OpenWorld on [McpTool]

Requests without [McpTool] are not offered. A library that cannot reference the attribute's version of Reiho includes its requests with o.Include<TRequest>(new McpToolAttribute { ReadOnly = true }).

A tool call does not go through the request's HTTP endpoint, so endpoint filters and the endpoint's authorization do not apply: protect the MCP endpoint itself. [Authorize] on a request is honoured per tool after AddAuthorizationFilters(). On net8.0 and net9.0 the MCP SDK brings the 10.x Microsoft.Extensions.* packages; align the app's own Microsoft.Extensions.Logging with them if it references an older one.


Sample

src/Reiho.Sample is a runnable order service using all of the above, with curl commands to try it.


Full example

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRequestHandlers(typeof(Program).Assembly);

var app = builder.Build();

app.MapGroup("/api").MapRequests(typeof(Program).Assembly);

app.MapGroup("/ui")
   .RequireAuthorization()
   .MapEmbeddedSpa(Assembly.GetExecutingAssembly(), "Frontend/dist");

app.Run();

Requirements

  • .NET 8 or later
  • ASP.NET Core

License

MIT

About

Lightweight request/handler abstraction for ASP.NET Core Minimal APIs, plus a helper for serving embedded SPAs.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages