Web
An endpoint beside each Feature, authenticated access by default, one problem response for every failure, and outgoing HTTP that carries the Operation Context.
PouryaKit.Web is the Kit's HTTP boundary. Each Feature has its own endpoint, a small IEndpoint class beside its command or query, which a Module's Composition Root maps under the Module's route prefix. Before the endpoint runs, the Kit authenticates the caller and begins the request's Operation with an Operation Context built from the caller's verified claims. The endpoint passes that context to its Module's Gateway and turns the Result into a response with ToHttpResult(): the success as its response, and every failure as one problem response that lists each Error (From Result to response).
The same package binds page requests from the query string, requires an Idempotency-Key on a repeatable write, carries the Operation Context on outgoing calls to other Kit Hosts with the standard retry, and configures CORS, response compression and a rate limit per Actor.
Every endpoint is authenticated unless it says otherwise
A caller without a verified identity is refused before any endpoint runs (The caller and the Actor). An endpoint that is public states the system Actor its anonymous callers run as, with AllowAnonymousAs. A tenant-scoped Module maps no public endpoint, since an anonymous caller has no tenant.
When to use it
- You are writing an endpoint, or composing the Host's HTTP pipeline.
- You choose a response's shape, or bind page, sort and filter parameters from the query string.
- An endpoint must be public, or a repeatable write must require an
Idempotency-Key. - You read the caller's claims into the Operation Context, or call another Host with it.
- You configure CORS policies, response compression or the rate limit per Actor, or test over HTTP.
How it works
Compose the pipeline in the Host
AddPouryaKitWeb sets authenticated access as the fallback policy and adds the problem responses, and MapPouryaKitModuleEndpoints maps every Module's endpoints inside its own authenticated route group.
Map one endpoint per Feature
AddPouryaKitEndpoints(routePrefix) in the Composition Root finds the Module's IEndpoint classes; there is no central route list. Each maps its route relative to the prefix, binds the route and body, and holds no business logic.
Send through the Gateway with the request's context
The endpoint injects OperationContext, its Module's Gateway and the cancellation token, sends the command or query, and returns ToHttpResult(). It never builds a context itself.
Let the Errors pick the status
The Errors' kinds decide the status, and when they differ the most severe decides, so a caller never fixes its input while a weightier reason stands. The full table, and what an exception answers, are under Problem responses.
In the Proving Sample
Inventory's GetStockItem endpoint: one route, the request's context passed to the Gateway, the Result turned into a response.
// No :guid route constraint: an id that does not parse is the caller's mistake (400), not a missing route (404).
internal sealed class GetStockItemEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder routes) =>
routes.MapGet(
"/stock-items/{productId}",
async (
Guid productId,
OperationContext context,
IGatewayProcessor<InventoryModule> inventory,
CancellationToken cancellationToken
) =>
(
await inventory.QueryAsync(
new GetStockItemQuery(productId),
context,
cancellationToken
)
).ToHttpResult()
);
}Guarantees and failures
| You want to know | Read |
|---|---|
| How the Host's pipeline is composed, in order | The Host |
| What a Result answers on success and on failure | From Result to response, Problem responses |
| How page, sort and filter parameters bind, and what a bad one answers | A Page over HTTP |
| How a repeated request with the same key replays | A repeatable write |
| How the caller becomes the Actor and tenant, and how a public endpoint is declared | The caller and the Actor |
| How one Host calls another and continues its Operation | Calls between Kit Hosts |
| The codes of the Errors a response lists | Error codes |
Next steps
Add an endpoint
A Feature's endpoint, from its route to its test over HTTP.
Request idempotency
Make a write safe to send again with an Idempotency-Key.
API versions and OpenAPI
Versions per Module, and one OpenAPI document per version.
Reference
PouryaKit.Web: an endpoint beside each Feature, authenticated access by default, a Result as an HTTP response, one problem response for every failure, the Operation Context built from the authenticated caller or continued from a trusted peer Host, outgoing HTTP that carries the Operation Context with the standard retry, and configured CORS policies, response compression and a rate limit per Actor. Namespace PouryaKit.Web. It references PouryaKit.Cqrs, PouryaKit.Core, the ASP.NET Core shared framework and Microsoft.Extensions.Http.Resilience; a Module project that declares endpoints references it, and it brings ASP.NET Core with it.
- The Host
- Endpoints
- From Result to response
- A Page over HTTP
- Problem responses
- The caller and the Actor
- Calls between Kit Hosts
- CORS, compression and rate limits
- Testing over HTTP
The Proving Sample's CreateProduct endpoint, beside its command in Products/Features/CreateProduct/:
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using PouryaKit.Cqrs;
using PouryaKit.Modularity;
using PouryaKit.Web;
internal sealed class CreateProductEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder routes) =>
routes.MapPost(
"/products",
async (
CreateProductBody body,
OperationContext context,
IGatewayProcessor<CatalogModule> catalog,
CancellationToken cancellationToken
) =>
(
await catalog.SendAsync(
new CreateProductCommand(body.ProductId, body.CategoryId, body.Name),
context,
cancellationToken
)
).ToHttpResult(id => TypedResults.Created($"/catalog/products/{id}", new { id }))
);
}
internal sealed record CreateProductBody(Guid ProductId, Guid CategoryId, string Name);
// In the Composition Root: module.AddPouryaKitEndpoints("/catalog");
// The Proving Sample's Catalog declares API versions, so its CreateProduct generates the location with LinkGenerator
// in the requested version instead (openapi.md).The Host
The Proving Sample's Host composition, one class shared by Program and the tests:
using Billing;
using Catalog;
using Catalog.Products.Events.Notifications;
using Inventory;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using PouryaKit.Messaging.Wolverine.EFCore;
using PouryaKit.Modularity;
using PouryaKit.Security;
using PouryaKit.Security.JwtBearer;
using PouryaKit.Web;
using PouryaKit.Web.OpenApi;
public static class ModularMonolithHost
{
public const string DownstreamClient = "downstream";
public const string BrowserPolicy = "browser"; // the CORS policy the pipeline answers with
public const string TenantClaim = "tenant";
public static WebApplicationBuilder CreateBuilder(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
// Bearer tokens, verified against Authentication:Schemes:Bearer (security.md); "sub" is the Actor.
builder.Services.AddPouryaKitJwtBearer(claims => claims.TenantClaim = TenantClaim);
builder.Services.AddAuthorization();
// Also: authenticated access by default. The tenant is the one the bearer scheme verified.
builder.Services.AddPouryaKitWeb();
// The peer Hosts that may continue an Operation of theirs here; none by default.
builder.Services.Configure<PouryaKitTrustedPeerOptions>(builder.Configuration.GetSection("PouryaKit:TrustedPeers"));
// Named CORS policies, response compression and the rate limit per Actor; each is off until configured.
builder.Services.Configure<PouryaKitCorsOptions>(builder.Configuration.GetSection("PouryaKit:Web:Cors"));
builder.Services.Configure<PouryaKitCompressionOptions>(builder.Configuration.GetSection("PouryaKit:Web:Compression"));
builder.Services.Configure<PouryaKitRateLimitOptions>(builder.Configuration.GetSection("PouryaKit:Web:RateLimit"));
// The API versions the Modules declare, one OpenAPI document each, and the configured sunsets (openapi.md).
builder.Services.AddPouryaKitOpenApi();
builder.Services.Configure<PouryaKitOpenApiOptions>(builder.Configuration.GetSection("PouryaKit:OpenApi"));
// Outgoing calls: the standard retry of safe methods, then the Operation Context on every attempt.
var downstream = builder.Services.AddHttpClient(DownstreamClient);
downstream.AddPouryaKitResilience();
downstream.AddPouryaKitOperationContext();
// The permissions Catalog's RemoveProducts declares are the HTTP caller's permission claims (security.md).
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<IActorPermissions, CallerPermissions>();
builder.AddPouryaKitModules(modules =>
{
modules.Add<CatalogModule>();
modules.Add<InventoryModule>();
modules.Add<BillingModule>();
});
builder.UsePouryaKitWolverine(connectionName: "app"); // the Modules' deliveries (messaging.md)
builder.Services.AddSingleton<INewProductAnnouncer, LoggedNewProductAnnouncer>();
return builder;
}
public static WebApplication Build(WebApplicationBuilder builder)
{
var app = builder.Build();
app.UseExceptionHandler(); // first, so an exception in routing or authentication still answers a problem response
app.UseRouting();
app.UseCors(BrowserPolicy); // before authentication, so a preflight is answered without credentials
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter(); // after authorization: it partitions by the authenticated Actor
app.MapPouryaKitModuleEndpoints();
if (app.Environment.IsDevelopment())
{
app.MapPouryaKitOpenApi().AllowAnonymous(); // the documents and Scalar, public in Development
}
return app;
}
}
// Program.cs: return await ModularMonolithHost.Build(ModularMonolithHost.CreateBuilder(args)).RunJasperFxCommands(args);builder.Services.AddPouryaKitWeb()registers what every endpoint answers with: problem responses, the handler that turns an exception into one, the same answer to an unreadable request in every environment, a problem response for every failure answered with an empty body (Problem responses), the Kit's one JSON representation (UsePouryaKitJson, typed IDs written as their bare values, the same as in messages, and a body that names one member twice, in any case, refused as a request that cannot be read rather than read with its last value; domain-primitives.md),PouryaKitWebOptions, authenticated access by default (The caller and the Actor), and the configured CORS policies, response compression and rate limit, each off until the Host configures it (CORS, compression and rate limits).- The application configures its own authentication and authorization; the Kit chooses no identity provider and issues no token.
app.UseExceptionHandler()comes first in the pipeline, andUseRouting(),UseAuthentication()andUseAuthorization()follow it, each called explicitly. Left out,WebApplicationinserts those three ahead of everything the application adds, and an exception thrown in authentication escapes the handler.- Compose the rest in this order:
UseExceptionHandler(),UseRouting(),UseCors(policyName),UseAuthentication(),UseAuthorization(),UseRateLimiter(), then the endpoints. CORS answers a preflight before authentication would refuse it. The rate limiter runs after authorization, because it partitions by the authenticated Actor and an unauthenticated caller of a protected endpoint is refused before it counts. Compression needs no call: when enabled,AddPouryaKitWebadds it outermost, before anything that writes a response. - The Kit checks the rate limiter's place on each request it partitions. In a Host that registers authentication, an enabled rate limit throws at a request that reaches it before authentication ran, naming the order, rather than count every caller in one partition.
WebApplicationinsertsUseAuthentication()ahead of the application's middleware when the Host does not call it, so only aUseAuthentication()called afterUseRateLimiter()makes it throw. A Host without authentication has only anonymous callers, each partitioned by the Actor its endpoint names. A limiter beforeUseRouting()is not detected, because routing marks no request it matched nothing for: it puts the anonymous callers of a public endpoint in the one partition of requests no Actor resolves for. app.MapPouryaKitModuleEndpoints()maps every Module's endpoints, each Module under its own route group. A Module that declares API versions has them served inside that group, by URL segment and by header, through theIModuleRouteGroupsthat the optionalPouryaKit.Web.OpenApiregisters (openapi.md); without it, or for a Module that declares none, the endpoints are mapped on the group itself. With the Kit's checks (AddPouryaKitCompositionChecks, whichAddPouryaKitModulescalls), a startup check fails the Host's start, since the application maps its routes afterBuild(), when anIEndpointin the Feature assemblies of a Module it composed, or of its own services, is declared by noAddPouryaKitEndpoints, or declared and mapped by noMapPouryaKitModuleEndpoints, naming the call: such an endpoint would never answer.group.BeginOperationPerRequest()on aRouteGroupBuilderis the convention every Module's group carries: before an endpoint binds, it resolves the caller and begins the request's Operation, a trusted peer's headers continuing its own. It applies once per endpoint, so a nested group's call is harmless. Apply it, withRequireAuthorization(), to a root group outside every Module's, such as another transport's, and its endpoints injectOperationContextas a Module's endpoints do.
Endpoints
- Write one
internal sealedclass implementingIEndpointbeside its Feature, named after it with theEndpointsuffix, with a parameterless constructor. ItsMap(routes)maps the Feature's route with a minimal API (MapGet,MapPost, ...) on its Module's route group, so the path is relative to the group's prefix. module.AddPouryaKitEndpoints(routePrefix)in the Module's Composition Root finds everyIEndpointin the Module's own assembly, internal ones included, and maps them underroutePrefix(/catalog); assemblies named after the prefix replace it (modules.md). There is no central route list. It throws when the assemblies have no endpoint, and when its route prefix, compared ignoring case, as the router matches a literal, or one of its assemblies is already declared, whose endpoints would be mapped twice. An application without Modules calls it on its own services, naming its assembly.- The endpoint injects
OperationContext, its Module'sIGatewayProcessor<TModule>and theCancellationToken, builds the command or query from the route and the body, sends it through the Gateway with that context, and returnsToHttpResult(). It holds no business logic and touches no repository or DbContext. - Declare a request body as an
internal sealed recordbeside the endpoint (CreateProductBody) and map it to the command by hand. - Leave a route parameter unconstrained (
/products/{productId}, not{productId:guid}): an id that does not parse is the caller's mistake and answers 400, where a constraint would answer 404. Check input in the command's or query's validator, which answers 400 with every failure.
The GetProduct and ListProducts endpoints, in their own Feature folders:
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using PouryaKit.Cqrs;
using PouryaKit.Modularity;
using PouryaKit.Web;
internal sealed class GetProductEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder routes) =>
routes.MapGet(
"/products/{productId}",
async (
Guid productId,
OperationContext context,
IGatewayProcessor<CatalogModule> catalog,
CancellationToken cancellationToken
) => (await catalog.QueryAsync(new GetProductQuery(productId), context, cancellationToken)).ToHttpResult()
);
}
// Authenticated, as every Catalog endpoint is: Catalog is tenant-scoped, and a tenant comes only from a verified claim,
// so an anonymous visitor would have none.
internal sealed class ListProductsEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder routes) =>
routes.MapGet(
"/products",
async (
HttpPageRequest page, // ?page=2&size=20&sort=name&direction=desc&filter[name][contains]=lamp
OperationContext context,
IGatewayProcessor<CatalogModule> catalog,
CancellationToken cancellationToken
) => (await catalog.QueryAsync(new ListProductsQuery(page.Request), context, cancellationToken)).ToHttpResult()
);
}From Result to response
ToHttpResult()is the one place a Result becomes a response. On aResultwithout a value, success answers 204 No Content. On aResult<T>, success answers 200 OK with the value as the JSON body.ToHttpResult(value => ...)answers another success shape, such as 201 Created with a location:.ToHttpResult(id => TypedResults.Created($"/catalog/products/{id}", new { id })). In a Module that declares API versions, generate the location withLinkGeneratorinstead, so it names the requested version (openapi.md).- A failure answers a problem response, whatever overload the endpoint used.
A Page over HTTP
- An endpoint of a query that answers a Page takes an
HttpPageRequestparameter, asListProductsdoes above, and passes itsRequestto the query. ASP.NET Core binds it from the query string:page(default 1),size(default 20),sort,direction(ascordesc) and any number offilter[field][operator]=value, the operator by its exact short name (eq,ne,contains,startswith,gt,gte,lt,lte). Thefilter[prefix is read ignoring case, as ASP.NET Core reads the other keys. - The success body is the Page:
{"items":[…],"number":2,"size":20,"total":42,"totalPages":3,"hasNext":true,"hasPrevious":true}, fromToHttpResult()of the query'sPage<T>. - A page or size that is not a whole number, another direction, an unknown operator, a malformed filter key or a
page,size,sortordirectiongiven twice, answers 400 as a problem response withouterrors, whosedetailnames the rule it broke (The direction query parameter is asc or desc.); a request ASP.NET Core itself refuses has nodetail. Everything else reaches the query's validator, so a size above the declared maximum, an undeclared sort key or field, an operator the field does not support, a value that does not parse or a filter's field and operator given twice answers 400 with each validation Error'scodeandtarget(Page.SortUnknown, the value ofPageErrorCodes.SortUnknown, andSort). - Declare and validate the query's paging as commands-queries-validation.md shows; never read paging parameters by hand.
A repeatable write
The Proving Sample's CreateProduct endpoint, whose command opts in with [Idempotent] (commands-queries-validation.md):
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using PouryaKit.Cqrs;
using PouryaKit.Modularity;
using PouryaKit.Web;
internal sealed class CreateProductEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder routes) =>
routes
.MapPost(
"/products",
async (
CreateProductBody body,
OperationContext context,
IGatewayProcessor<CatalogModule> catalog,
CancellationToken cancellationToken
) =>
(
await catalog.SendAsync(
new CreateProductCommand(body.ProductId, body.CategoryId, body.Name),
context,
cancellationToken
)
).ToHttpResult(id => TypedResults.Created($"/catalog/products/{id}", new { id }))
)
.RequireIdempotencyKey();
}RequireIdempotencyKey()on a Module's POST, PUT, PATCH or DELETE endpoint whose command opts in requires anIdempotency-Keyheader on every request. Its value becomes the request'sOperationContext.RequestId, so a repeat by the same Actor and tenant answers what the first request answered, without running the command again. On an endpoint mapped for GET, HEAD, OPTIONS or TRACE it throws when the endpoint is built, which fails the Host's start: such an endpoint sends a query, and a query ignores the request id. So it does on an endpoint mapped for no method, withMap(pattern, handler)or an empty method list, which answers every method, GET included: map the command withMapPost,MapPut,MapPatchorMapDelete. Whether the command opts in with[Idempotent]is known only when it is dispatched, since the endpoint's delegate builds it: a request id on a command that does not opt in throws there.- The header is one value of 1 to 256 visible ASCII characters, with spaces only between them, the rule of the Kit's context headers, taken as sent: a quoted value keeps its quotes. A request without one, with two, or with a value the rule refuses answers 400 as a problem response without
errors, whosedetailstates the rule, before its body is read. Otherwise the endpoint answers as any other, its validation failures and Errors included. - A repeat answers the same status, location and body, because the replayed Result goes through the same
ToHttpResultmapping: a secondPOST /catalog/productswith one key answers 201 with the first location and{"id":…}, and creates and sends nothing. A first request that failed recorded nothing, so its repeat runs again. - A client sends a new key with each new request and the same key only to repeat one.
- Only a Module's endpoints read the header, in the step that begins the request's Operation; an endpoint that does not require it ignores it. With
PouryaKit.Web.OpenApi, the endpoint's OpenAPI operation lists the header as a required parameter (openapi.md), from the endpoint'sIdempotencyKeyRequirementmetadata.
Problem responses
- Every failure answers an RFC 9457 problem response, written as
application/problem+jsoneven when the caller'sAcceptasks for something else. It carries ASP.NET Core'stitleandstatus, itstypewhere ASP.NET Core defines one for the status (it defines none for 429), and, for a failed Result, anerrorsarray listing every Error in order with itscode, itsmessage, and itstargetwhen a validation Error names one. - The status comes from the Errors' kinds:
Validation400,NotFound404,Conflict409,Forbidden403,Unexpected500. When the Errors are of several kinds, the most severe decides, in the order unexpected, forbidden, not found, conflict, validation: the caller cannot succeed by correcting its input while a more severe reason stands. Theerrorsarray still lists every Error. - Validation failures from the pipeline answer 400 with each failure's code and target. The Unit of Work's Errors answer by their kinds as well: a concurrent change (
UnitOfWork.ConcurrencyConflict) 409, and a unique index the Module mapped the status of its Error, 409 forProduct.NameInUse(persistence.md). - An exception, from a handler, the database, authentication or an endpoint, answers 500 with no message, type or stack in the body. The Kit logs it at Error level.
- A request that cannot be read answers 400 as a problem response in every environment, with no
errors, and is logged only at Debug level. One the Kit refuses, a page query string, anIdempotency-Keyor a trusted peer's context headers, carries the Kit's reason as itsdetail, the rule the request broke. One ASP.NET Core refuses, such as malformed JSON, a body that names one member twice or a route value that does not parse, has nodetail, because ASP.NET Core's message quotes what the caller sent. - A failure that ASP.NET Core answers itself with an empty body, before any endpoint or exception handler runs, is written as the same problem response, with its status and no
errors: routing's 405 for a method the route does not serve and 415 for a body of a content type the endpoint does not accept, the 401, 403 or 404 that authentication, authorization or routing answers, and the rate limiter's 429.AddPouryaKitWebadds this through a startup filter, outermost in the pipeline but for response compression, which wraps it when enabled, so the Host adds nothing for it. A response that already has a body is left as it is.
The caller and the Actor
- HTTP access is authenticated by default.
AddPouryaKitWebsets the authorizationFallbackPolicyto require an authenticated user, once, while the Host is composed, so every endpoint that states nothing, a Module's or one the Host maps itself (a health check, a document), refuses an unauthenticated caller before it runs: 401, written as a problem response. A path no route matches has no endpoint, and the fallback policy applies to it too: an anonymous request answers 401, an authenticated one 404. A fallback policy the application set itself is kept. Never changeAuthorizationOptionswhile the Host runs, such as from a policy provider that adds policies on demand. - Every Module's route group also requires an authenticated caller explicitly, through the default policy.
- An endpoint the Host maps outside every Module, on a group that does not begin the Operation, opts out with the standard
.AllowAnonymous(); it runs no Operation, so it names no Actor. On a root group withBeginOperationPerRequest()an endpoint runs an Operation, and is public only as a Module's endpoint is, below. - Before the endpoint binds its parameters, the Kit begins the request's Operation with an
OperationContextbuilt from the caller:Actor.Person(id)from the Actor claim, the tenant from the tenant claim when one is configured, and a new chain of work (correlated with its ownOperationId, no causation). Only a trusted peer Host continues an Operation of its own instead (Calls between Kit Hosts); every other caller's context headers are ignored. The endpoint injects that context and passes it to the Gateway; it never builds one itself. PouryaKitWebOptionsnames the claims:ActorClaim, by defaultClaimTypes.NameIdentifier, andTenantClaim, by defaultPouryaKitClaimTypes.Tenant(PouryaKit.Cqrs). The Kit's bearer scheme (AddPouryaKitJwtBearer, security.md) writes the Actor underClaimTypes.NameIdentifierand the tenant underPouryaKitClaimTypes.Tenant, so a Host that uses it sets neither: the token's own claim names are configured once, in the bearer scheme. With a scheme of the application's own, name the claims it issues, withAddPouryaKitWeb(...)orConfigure<PouryaKitWebOptions>; setTenantClaimto null to read no tenant at all. A blankActorClaim, which would fail every authenticated request, or aTenantClaimblank rather than null, which would give every caller a null tenant unnoticed, stops the Host with anOptionsValidationExceptionnaming the option: when it starts, or earlier, inMapPouryaKitModuleEndpointsorBeginOperationPerRequest, which read both.- A caller whose identity lacks the tenant claim, or carries it blank, runs with a null tenant and is not refused at the boundary; a tenant-scoped Module then refuses that caller's Operations with
Tenant.Required(multi-tenancy.md). A tenant is never blank:new OperationContext(actor, tenant)throws for one, so null is the only way to say there is none. An authenticated caller without the Actor claim is a configuration bug: the request answers 500 and the log names the missing claim. - A public endpoint states the Actor its anonymous callers run as with
.AllowAnonymousAs(Actor.System("storefront.visitor")), a system Actor of its own; an authenticated caller of it still runs as themselves. A plain.AllowAnonymous()on an endpoint whose group begins the Operation, a Module's or a root group withBeginOperationPerRequest(), fails the Host when it starts, because no Operation runs as an unnamed Actor. A tenant-scoped Module maps no public endpoint: an anonymous caller has no tenant, so its Operation would be refused (multi-tenancy.md); Catalog'sListProductsis authenticated for that reason. - A permission a command or query requires is declared on it with
[RequiresPermission(name)]and checked against the application'sIActorPermissions(security.md); a caller without it gets 403 withPermission.Denied. Any other rule for one command or query beyond "authenticated" is anIAuthorizer(commands-queries-validation.md), which answers 403 with itsForbiddenError. Never add a role or claim policy per endpoint for either: the check belongs to the Operation, however it arrives.
Calls between Kit Hosts
A Host calls another over HTTP through a named or typed HttpClient it registers with the two Kit handlers, as the Host above registers downstream:
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http.Resilience;
using PouryaKit.Web;
// In the Host's composition: limits on the standard pipeline's builder, then the context handler.
var stock = builder.Services.AddHttpClient<StockClient>(client => client.BaseAddress = new("https://inventory.example/"));
stock.AddPouryaKitResilience().Configure(options => options.Retry.MaxRetryAttempts = 2);
stock.AddPouryaKitOperationContext();using PouryaKit.Cqrs;
using PouryaKit.Web;
// A typed client is resolved in the calling Operation's scope, so it injects that Operation's context.
internal sealed class StockClient(HttpClient http, OperationContext context)
{
public async Task<string> LevelAsync(Guid productId, CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Get, $"stock/{productId}").WithOperationContext(context);
using var response = await http.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync(cancellationToken);
}
}AddPouryaKitOperationContext()sends the context a request was given withrequest.WithOperationContext(context)as the Kit'spouryakit-*headers, the names Messaging uses: the Actor and its kind, the tenant, the correlation, and the calling Operation'sOperationIdas the causation. A request without a context throws before it is sent. The handlers of anHttpClientare built in a scope of their own, never the Operation's, which is why the context travels on the request: give it the one the endpoint or handler injected, never one built for the call.OperationContextHttpHeaders.Pairs(context)returns those header names and values, checked as below: the pairs the handler writes, for a transport that carries the context in headers of its own and reuses Web's reader, such as gRPC metadata, so it needs no second codec. Messaging keeps its own extension,WithOperationContexton Wolverine'sDeliveryOptions, which carries the values without this rule.- An Actor id or tenant goes on the wire only when it is 1 to 256 visible ASCII characters with spaces only between them. Any other value, a CR or LF, another control character or a character outside ASCII, fails the call with an
InvalidOperationExceptionnaming the header before anything is sent; a receiving Host answers 400 to a trusted peer's header that breaks the same rule. Keep identities a claim carries within it. - The incoming request's
Authorizationheader and bearer token are never forwarded. A peer that authenticates this Host gets the Host's own credentials (a client-credentials token or a client certificate), added by the application. AddPouryaKitResilience()is the standard .NET resilience handler (AddStandardResilienceHandler): attempt and total timeouts, a circuit breaker and retries of transient failures that stop when the call is cancelled. It needs no Aspire and no ServiceDefaults. Set limits on the builder it returns (.Configure(o => o.Retry.MaxRetryAttempts = 2)or.Configure(configurationSection)), and add the context handler after it, so every attempt carries the same context.- By default (
HttpReplay.SafeMethods) only GET, HEAD, OPTIONS and TRACE are retried: every other method, POST, PUT, PATCH, DELETE or an extension method such as PROPPATCH, is sent once, whether the attempt failed with a status or a broken connection, because a write that failed in transit may still have taken effect. PassAddPouryaKitResilience(HttpReplay.AllRequests)only for a client whose downstream API makes every write replay-safe, such as with an idempotency key. - One pipeline per client: ServiceDefaults adds a resilience handler to every client through
ConfigureHttpClientDefaults. The Kit's service defaults recipe already callsAddPouryaKitResilience()there in place of Aspire'sAddStandardResilienceHandler()(host-recipes.md), so a client of a Host that uses it adds onlyAddPouryaKitOperationContext(); a client that needs other limits orHttpReplay.AllRequestscallsRemoveAllResilienceHandlers()before its ownAddPouryaKitResilience(...). A client whose handler chain, once built, holds two resilience handlers, as when a client adds its own over the defaults', throws anInvalidOperationExceptionnamingRemoveAllResilienceHandlers()whenever it is created, so its calls never retry each other's attempts. A client that replaces the defaults' pipeline needs a name: an empty name would share the defaults' pipeline,-standard, with its options and replay policy, soAddPouryaKitResiliencerefuses it with anArgumentException. The lastAddPouryaKitResilience(...)call for a client decides its replay policy. - The receiving Host continues the Operation only for a caller it trusts as a peer. List the peers by the value of their authenticated identity's
ActorClaiminPouryaKitTrustedPeerOptions.Peers, which the sample binds fromPouryaKit:TrustedPeers:Peers; empty, the default, trusts no one. A blank peer stops the Host the same way as a blank claim:AddPouryaKitWebvalidates the peers however the Host configures them. A trusted peer's request runs with the Actor, tenant and correlation its headers carry, caused by the peer's Operation, so a permission declared on the command is not checked again (security.md). A trusted peer without the headers runs as itself; one with incomplete or unreadable headers answers 400, with the header and the rule it broke as the problem'sdetail. Every other caller keeps its own identity and a new chain, whatever headers it sends. - A trusted peer speaks for any Actor. Use a peer identity no person can hold, such as a client-credentials token's subject or a client certificate's thumbprint, as the Host's authentication scheme issues it; never a header, which anyone can send.
CORS, compression and rate limits
The Host binds three options from configuration, as the Host above does, and AddPouryaKitWeb registers ASP.NET Core's own CORS, response compression and rate limiting with them; there is no package to add. A Host that configures none of them answers no cross-origin request, compresses nothing and limits no one. Their options are validated when the Host starts, so a misconfiguration fails StartAsync with an OptionsValidationException, never a request.
{
"PouryaKit": {
"Web": {
"Cors": {
"Policies": {
"browser": {
"Origins": [ "https://app.example" ],
"Methods": [ "GET", "POST" ],
"Headers": [ "Content-Type", "Authorization" ],
"ExposedHeaders": [ "Location" ],
"AllowCredentials": false,
"PreflightMaxAge": "00:10:00"
}
}
},
"Compression": { "Enabled": true },
"RateLimit": { "Enabled": true, "PermitLimit": 100, "Window": "00:01:00", "QueueLimit": 10 }
}
}
}- CORS. Each entry of
PouryaKitCorsOptions.Policiesis a named policy of ASP.NET Core's CORS services, allowing exactly the origins, methods, headers and exposed headers it lists, with credentials and a preflight cache time when set. Apply one to the pipeline withapp.UseCors(name), which also answers preflight requests. An endpoint'sRequireCors(name)adds no middleware: it only names the policy the CORS middleware applies to that endpoint. A policy with no origins allows no origin, and a name nobody configured allows nothing: missing configuration never opens the Host. There is noAllowAnyOrigin: an origin isscheme://host[:port]as a browser sends it (https://app.example, no path or trailing slash), and a wildcard (*) anywhere, a malformed origin or a negativePreflightMaxAgefails the Host when it starts. A disallowed origin gets noAccess-Control-*header at all; the browser then refuses the response. - Compression.
PouryaKitCompressionOptions.Enabledturns on ASP.NET Core's response compression, Brotli or gzip as the caller'sAccept-Encodingasks, for its default content types (JSON included, problem responses not), over HTTPS as well as HTTP (EnableForHttps). Compressing a response that reflects what the caller sent beside a secret of another party, such as an anti-forgery token, can leak that secret through its length (BREACH); enable it for an API whose responses do not mix the two. - Rate limit.
PouryaKitRateLimitOptionsis ASP.NET Core's global limiter, a fixed window per partition:PermitLimitrequests in eachWindow, then up toQueueLimitmore wait, oldest first, for the next window, and one beyond the queue is refused.QueueLimitis its own setting, 0 by default (refuse at once). The partition is the Operation's Actor as the HTTP boundary establishes it (The caller and the Actor): the person an authenticated caller'sActorClaimnames; for an unauthenticated caller, the Actor its endpoint names withAllowAnonymousAs(actor), so a public endpoint's anonymous visitors share its Actor's partition and two public endpoints with different Actors have independent ones; and for a trusted peer's call, the Actor it continues, so it shares that Actor's partition with the Actor's own calls. The limiter resolves the caller's Operation Context once, and the endpoint begins its Operation with that same context. A request no Actor resolves for, such as an unauthenticated caller of an endpoint outside every Module that allows anonymous callers, shares one partition; the service defaults exempt their/alivefrom the limit, so a liveness probe is never refused there (host-recipes.md). No other header,HostandX-Forwarded-Forincluded, chooses a partition: an untrusted caller's context headers are ignored. The limits are counted in each Host process. Enabled, it fails the Host when it starts unlessPermitLimitis at least 1,Windowis positive andQueueLimitis not negative. - Test them on a Host composed with the configuration the test needs (
builder.Configuration.AddInMemoryCollection(...)beforeBuild): a preflight is anOPTIONSrequest withOriginandAccess-Control-Request-Method; send to anhttps://base address for HTTPS, and readContent-Encodingon the raw response, which the test client does not decompress.
Testing over HTTP
The test project references Microsoft.AspNetCore.TestHost. Its fixture builds the Host with the same CreateBuilder and Build as Program, serves it in memory with builder.WebHost.UseTestServer(), and makes a test authentication scheme the default (testing.md). The Proving Sample's scheme reads the caller from the Authorization header and issues the claim the Kit reads:
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
// "Authorization: Test <id>" is the authenticated person <id>; no header is an anonymous caller.
internal sealed class TestAuthentication(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder
) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
public const string SchemeName = "Test";
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var header = Request.Headers.Authorization.ToString();
if (!header.StartsWith($"{SchemeName} ", StringComparison.Ordinal)) return Task.FromResult(AuthenticateResult.NoResult());
var identity = new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, header[(SchemeName.Length + 1)..])], SchemeName);
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), SchemeName)));
}
}A test sends real requests through the whole pipeline and asserts the status, the problem response and the rows:
[Fact]
public async Task Creating_a_product_with_a_name_in_use_answers_409_with_its_error_and_writes_nothing()
{
using var client = Fixture.Host.GetTestClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthentication.SchemeName, "ada");
client.DefaultRequestHeaders.Add("api-version", "2.0"); // Catalog's paths name their API version (openapi.md)
var category = Guid.CreateVersion7();
await client.PostAsJsonAsync("/catalog/categories", new { categoryId = category, name = "Lamps" }, TestContext.Current.CancellationToken);
await client.PostAsJsonAsync(
"/catalog/products",
new { productId = Guid.CreateVersion7(), categoryId = category, name = "Desk lamp" },
TestContext.Current.CancellationToken
);
var response = await client.PostAsJsonAsync(
"/catalog/products",
new { productId = Guid.CreateVersion7(), categoryId = category, name = "Desk lamp" },
TestContext.Current.CancellationToken
);
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType);
using var problem = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
Assert.Equal(
"""[{"code":"Product.NameInUse","message":"Another product already has this name."}]""",
problem.RootElement.GetProperty("errors").GetRawText()
);
}Fixture.Host.GetTestClient()(Microsoft.AspNetCore.TestHost) returns anHttpClientover the in-memory server; a client without the header is an anonymous caller.- A second
AddAuthentication(scheme)call after the Host's makes the test scheme the default. The Host's own scheme is still registered and still checked when the Host starts, so give it its configuration in tests too: the Proving Sample'sAppFixturepasses the bearer section of itsTestIssuer(security.md). Issue the claim the Host'sActorClaimnames, the tenant under itsTenantClaim(PouryaKitClaimTypes.Tenantby default), and the permission claims the Host'sIActorPermissionsreads: the Proving Sample's scheme turns aTest-Permissionsheader into them (security.md). - Prove the authenticated default on an endpoint outside every Module's route group, which has no authorization of its own: map one on a Host the test builds, and an anonymous request to it must answer 401.
- A test that needs another composition, such as another environment or an endpoint the shared Host must not map, builds and starts a Host of its own from the same composition methods.
- Test outgoing calls against a WireMock.Net server (test-only, Apache-2.0): its scenarios fail requests on cue (503, 503, then 200) and its request journal (
server.LogEntries) shows the headers every attempt arrived with. Prove the receiving side, trusted and untrusted callers, on the real Host with headers named byPouryaKit.Messaging.OperationContextHeaders; a stub's configured response proves nothing about the receiver.
Multi-tenancy
Make a Module tenant-scoped: every command and query of it needs a tenant, every EF Core read sees only the Operation's tenant, and no save ever reaches another tenant's aggregate.
API versions and OpenAPI
API versions a Module declares, served by URL segment and by header, with a configured sunset for a deprecated version and one OpenAPI document per version.