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.
A Module that changes its HTTP contract keeps serving its callers on the old version while new callers move to the new one. PouryaKit.Web.OpenApi lets the Module declare its versions in its Composition Root, on Asp.Versioning's own version set, and serves each endpoint on a versioned path, with the version as a URL segment, and on the unversioned path, where a version header selects it (What a caller sees). A deprecated version announces the sunset the Host configures, and every supported version gets an OpenAPI document of its own, listed by the Scalar API reference.
The documents are ASP.NET Core's own OpenAPI: there is no Swagger generator. A Module that declares no version keeps the plain unversioned paths of Web, and one that declares versions needs the Host to register the package (Declaring a Module's versions).
A version belongs to the Module, but its number is shared
Versions of different Modules that share a number share one document and one sunset. So a Module that joins a Host declares a version no other Module deprecates, as Inventory declares Catalog's current version rather than the one Catalog deprecates (Declaring a Module's versions).
When to use it
- A Module's HTTP contract changes and existing callers must keep working.
- An endpoint exists only in some versions of its Module.
- You deprecate a version and announce the date it is meant to end.
- You serve one OpenAPI document per version and the Scalar reference over them.
- You check an operation's security requirement in a document.
How it works
Declare the Module's versions
AddPouryaKitApiVersions in the Composition Root names the same route prefix as the Module's AddPouryaKitEndpoints and states each version as supported or deprecated. Every endpoint of the Module serves every declared version.
Map an endpoint to some versions only
An endpoint that exists in some versions only says so with MapToApiVersion. A caller of any other version is refused with a problem response.
Register versions and documents in the Host
AddPouryaKitOpenApi registers the version readers, the version headers on every response, the sunsets and one document per supported version. MapPouryaKitOpenApi serves the documents and the Scalar page, authenticated like every endpoint unless the Host says otherwise.
In the Kit's tests
Catalog's rename exists only in its current version. The Kit's tests select a version by its URL segment and by its header, and see the deprecated version refuse the rename while it still answers the read:
[Fact]
public async Task A_version_is_selected_by_its_url_segment_or_by_the_api_version_header()
{
var lamp = await CreateLampAsync();
using var client = Unversioned(Fixture.Client("ada", AppFixture.TestTenant));
var renamedByUrl = await RenameAsync(
client,
$"/catalog/v2/products/{lamp}/name",
null,
"Desk lamp"
);
var refusedByUrl = await RenameAsync(
client,
$"/catalog/v1/products/{lamp}/name",
null,
"Floor lamp"
);
var renamedByHeader = await RenameAsync(
client,
$"/catalog/products/{lamp}/name",
"2.0",
"Reading lamp"
);
var refusedByHeader = await RenameAsync(
client,
$"/catalog/products/{lamp}/name",
"1.0",
"Floor lamp"
);
var readByUrl = await SendAsync(
client,
HttpMethod.Get,
$"/catalog/v1/products/{lamp}",
null
);
var readByHeader = await SendAsync(
client,
HttpMethod.Get,
$"/catalog/products/{lamp}",
"1.0"
);
Assert.Equal(HttpStatusCode.NoContent, renamedByUrl.StatusCode);
Assert.Equal(HttpStatusCode.NoContent, renamedByHeader.StatusCode);
// Renaming arrived in 2.0: version 1.0 serves no such operation.
await ProblemAsync(refusedByUrl, HttpStatusCode.NotFound);
await ProblemAsync(refusedByHeader, HttpStatusCode.BadRequest);
Assert.Equal(
"Reading lamp",
await Fixture.ScalarAsync<string>(
$"select name from catalog.products where id = '{lamp}'"
)
);
Assert.Equal(HttpStatusCode.OK, readByUrl.StatusCode);
Assert.Equal(HttpStatusCode.OK, readByHeader.StatusCode);
Assert.Equal(["2.0"], readByUrl.Headers.GetValues("api-supported-versions"));
Assert.Equal(["1.0"], readByUrl.Headers.GetValues("api-deprecated-versions"));
}Guarantees and failures
| You want to know | Read |
|---|---|
| How a Module declares versions, and the startup failure of a prefix no Module maps | Declaring a Module's versions |
| What the Host registers and serves | The Host |
| What each request shape answers, and how a location names the requested version | What a caller sees |
| How a sunset is configured, validated and announced | Deprecation and sunset |
| Which operations carry a security requirement in a document | Documents and Scalar |
Next steps
Web
The endpoints a version serves, and the problem response a refusal answers.
Add an endpoint
A Feature's endpoint, mapped in the versions it serves.
Reference
PouryaKit.Web.OpenApi, optional: the API versions a Module declares, served on its own route group by URL segment and by header, a deprecated version's configured sunset, one OpenAPI document per supported version with each operation's security requirement, and the Scalar API reference over those documents. Namespace PouryaKit.Web.OpenApi. It references PouryaKit.Web, Asp.Versioning.Http, Asp.Versioning.OpenApi, Microsoft.AspNetCore.OpenApi and Scalar.AspNetCore. A Module that declares no version keeps the unversioned paths of web.md, and one that declares versions needs the Host to register it (Declaring a Module's versions); PouryaKit.Web references none of this stack.
- Declaring a Module's versions
- The Host
- What a caller sees
- Deprecation and sunset
- Documents and Scalar
- Testing versions and documents
There is no Swagger, Swashbuckle, NSwag or AsyncAPI: the documents are ASP.NET Core's own OpenAPI.
Declaring a Module's versions
The Proving Sample's Catalog declares its versions in its Composition Root, beside its endpoints, and its v2-only Feature maps itself to v2:
using Asp.Versioning;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using PouryaKit.Cqrs;
using PouryaKit.Modularity;
using PouryaKit.Web;
using PouryaKit.Web.OpenApi;
// In the Composition Root:
// module.AddPouryaKitEndpoints("/catalog");
// module.AddPouryaKitApiVersions(
// "/catalog",
// versions => versions.HasDeprecatedApiVersion(new ApiVersion(1, 0)).HasApiVersion(new ApiVersion(2, 0))
// );
// Renaming arrived in v2; v1 callers cannot rename.
internal sealed class RenameProductEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder routes) =>
routes
.MapPut(
"/products/{productId}/name",
async (
Guid productId,
RenameProductBody body,
OperationContext context,
IGatewayProcessor<CatalogModule> catalog,
CancellationToken cancellationToken
) =>
(
await catalog.SendAsync(new RenameProductCommand(productId, body.Name), context, cancellationToken)
).ToHttpResult()
)
.MapToApiVersion(new ApiVersion(2, 0));
}
internal sealed record RenameProductBody(string Name);-
module.AddPouryaKitApiVersions(routePrefix, declare)names the same prefix as the Module'sAddPouryaKitEndpoints.declarestates the versions on the Module's API version set, Asp.Versioning's ownApiVersionSetBuilder:HasApiVersionfor a supported version,HasDeprecatedApiVersionfor a deprecated one. A second declaration for one prefix throws, and a declaration whose prefix no Module's endpoints are mapped under fails the Host's start, among the startup checks (modules.md):API versions are declared for the route group /catlog, but no Module's endpoints are mapped under it. Declare them with the prefix the Module's AddPouryaKitEndpoints names, and map the Modules with MapPouryaKitModuleEndpoints.A Host never starts with the checks skipped: one without Modules or Wolverine calls
builder.AddPouryaKitCompositionChecks()itself, and one that does not fails when it starts, naming that call, before any check could pass it unverified. -
Every endpoint of the Module serves every declared version. An endpoint that exists in some versions only says so with
MapToApiVersion(version); an endpoint whose contract changes in a version can be a secondIEndpointmapped to that version, beside the first mapped to the others (the Proving Sample has none). -
A version is the Module's: the Host lists none. Versions of different Modules that share a number share one document. That document is deprecated only while no Module supports its version (Asp.Versioning's description provider drops a version any Module supports from the deprecated ones), and a sunset configured for the version is announced on every Module's responses of it, since the Kit keys sunsets by version alone. So a Module that joins a Host declares a version no other Module deprecates: the Proving Sample's Inventory declares 2.0, Catalog's current version, not 1.0.
-
Each endpoint is mapped twice, on the versioned and on the unversioned path (What a caller sees). An endpoint's name (
WithName) stays on its versioned copy only, because ASP.NET Core's endpoint names are unique: a link generated by name is a versioned path, and the document's operation id is on the versioned operation. -
A Module that declares versions in a Host that does not register them fails when its endpoints are mapped:
The Module mapped under /catalog declares API versions, but the Host does not register them. Call AddPouryaKitOpenApi on the Host's services.
AddPouryaKitApiVersions registers the one IModuleRouteGroups of PouryaKit.Web this package supplies; MapPouryaKitModuleEndpoints maps each Module's endpoints on the route builders it returns, inside the Module's authenticated route group (web.md).
The Host
// In ModularMonolithHost.CreateBuilder, after AddPouryaKitWeb:
builder.Services.AddPouryaKitOpenApi();
builder.Services.Configure<PouryaKitOpenApiOptions>(builder.Configuration.GetSection("PouryaKit:OpenApi"));
// In ModularMonolithHost.Build, after MapPouryaKitModuleEndpoints:
if (app.Environment.IsDevelopment())
{
app.MapPouryaKitOpenApi().AllowAnonymous();
}AddPouryaKitOpenApi()registers Asp.Versioning with its URL segment reader and itsapi-versionheader reader, reports each Module's supported and deprecated versions in every response, applies the configured sunsets, and registers one OpenAPI document per supported version with the Kit's notices, security requirements and requiredIdempotency-Keyheaders. A second call adds only itsconfigure.MapPouryaKitOpenApi()serves/openapi/v1.json,/openapi/v2.jsonand so on, and the Scalar reference at/scalar/. Like every endpoint they require an authenticated caller unless the returned builder says otherwise; the Proving Sample serves them in Development only, to anyone.
What a caller sees
| Request | Answer |
|---|---|
GET /catalog/v1/products/{id} | v1, from the URL segment |
GET /catalog/products/{id} with api-version: 2.0 | v2, from the header |
PUT /catalog/v1/products/{id}/name | 404 problem response: v1 has no such operation |
PUT /catalog/products/{id}/name with api-version: 1.0 | 400 problem response: the version does not serve it |
GET /catalog/products with no version | 400 problem response |
GET /catalog/v1/products with api-version: 2.0 | 400 problem response: two versions named |
-
Every response of a versioned Module carries
api-supported-versionsandapi-deprecated-versions. A refusal is the same RFC 9457 problem response as every other failure (web.md). -
A resource's location names the version the request selected:
CreateProductanswersLocation: /catalog/v2/products/{id}to a v2 request by URL or by header, so a caller follows it with no header, or with its ownapi-versionheader, which then names the same version. It generates the link by name, passing the requested version as theversionroute value:links.GetPathByName( httpContext, GetProductEndpoint.Name, // GetProduct maps itself .WithName(Name) new { productId = id, version = httpContext.RequestedApiVersion?.ToString("VVV", CultureInfo.InvariantCulture) } )
Deprecation and sunset
{
"PouryaKit": {
"OpenApi": {
"Sunsets": {
"1.0": {
"Date": "2027-06-30T00:00:00+00:00",
"Link": "https://example.com/catalog/api-v1-sunset"
}
}
}
}
}- The Module deprecates a version (
HasDeprecatedApiVersion); the Host configures the date it is announced to end on. The Kit reports that date and keeps serving the version after it: a Module ends a version by no longer declaring it.PouryaKitOpenApiOptions.Sunsets, anIDictionary<string, ApiVersionSunset>, is keyed by the version as the Modules declare it, compared ordinally. - The Host does not start, and its
OptionsValidationExceptionnames each refused key, when a key names no API version, two keys name one version (1and1.0, or1.0-Betaand1.0-beta: Asp.Versioning ignores the case of a version's status), aLinkis not an absolute URI, or a sunset has neither aDatenor aLink. A date in the past stays valid, since nothing reads the clock, and so does a key no Module declares, whose versions are known only once the endpoints are mapped. - Bound from configuration, an entry whose
DateorLinkthe binder cannot convert (next June,http://) is left out without an error, so its version reports no sunset: the configuration binder skips a dictionary entry it fails to bind unless the Host'sConfigurecall setsBinderOptions.ErrorOnUnknownConfiguration. - A response of a version with a sunset carries
Sunset: Wed, 30 Jun 2027 00:00:00 GMTandLink: <https://example.com/catalog/api-v1-sunset>; rel="sunset"; type="text/html". - The version's document states it in
info.description(This API version is deprecated. Its sunset date is Wed, 30 Jun 2027 00:00:00 GMT., then the link) and inx-api-versioning.links, and marks each of its operationsdeprecated. - Nothing is derived from the current time: the date is the configured one, never "now + n days", and the notice names it the same way before and after it passes. Asp.Versioning's default notice says "was" or "will be" from the clock; the Kit replaces it.
Documents and Scalar
- The documents are the versions the Modules declared, as
IApiVersionDescriptionProviderenumerates them, never a list in the Host. A document holds the operations its version serves, on both paths; the unversioned one lists itsapi-versionheader parameter. An endpoint of a Module that declares no version appears in every document. - An operation carries a security requirement exactly when the authorization middleware would challenge an anonymous caller: it allows no anonymous caller, and its authorization data or the authenticated default
AddPouryaKitWebsets give it a policy. The requirement names the authentication schemes that policy names, or else the Host's default authenticate scheme, one requirement per scheme, since any of them satisfies it. AnAllowAnonymousorAllowAnonymousAs(actor)operation carries nosecurity. - Only a JWT bearer scheme (its handler is
JwtBearerHandleror derives from it) is stated, ascomponents.securitySchemes.<scheme name>withtype: http,scheme: bearer; the Proving Sample's isBearer. A scheme of any other handler (a cookie, an API key, a test scheme) is left out of the document, so an operation protected only by such a scheme carries no requirement although it refuses anonymous callers. The fixture's Host, whose default scheme is its test scheme, documents none. - An operation whose endpoint calls
RequireIdempotencyKey()(web.md) lists a required header parameterIdempotency-Keyof type string, whose description states the rule its value follows; no other operation lists it. The Proving Sample'sPOST /catalog/productscarries it in every version, on both paths. - The Scalar page at
/scalar/lists every version's document by its group name (v1,v2), each read from/openapi/{name}.json.
Testing versions and documents
- The Proving Sample's fixture clients name
api-version: 2.0(AppFixture.ApiVersion), so tests of other Capabilities call the unversioned paths as a v2 caller. A test of versioning removes that header and names its own version per request. - Read a document over HTTP (
/openapi/v2.json) and assert its JSON; assert a security requirement against the status an anonymous request gets, so the document cannot drift from the pipeline. A requirement follows the Host's schemes, so a test of the bearer requirement composes the Host with its own default scheme (AddAuthentication(JwtBearerDefaults.AuthenticationScheme)after the fixture's).
Web
An endpoint beside each Feature, authenticated access by default, one problem response for every failure, and outgoing HTTP that carries the Operation Context.
gRPC
Serve a Module's gRPC service from a Protobuf contract in its Contracts, answer a failed Result as a gRPC status with every Error in a trailer, and call another Host with the Operation Context.