PouryaKit
Capabilities

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:

OpenApiTests.cs
[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 knowRead
How a Module declares versions, and the startup failure of a prefix no Module mapsDeclaring a Module's versions
What the Host registers and servesThe Host
What each request shape answers, and how a location names the requested versionWhat a caller sees
How a sunset is configured, validated and announcedDeprecation and sunset
Which operations carry a security requirement in a documentDocuments and Scalar

Next steps

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.

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's AddPouryaKitEndpoints. declare states the versions on the Module's API version set, Asp.Versioning's own ApiVersionSetBuilder: HasApiVersion for a supported version, HasDeprecatedApiVersion for 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 second IEndpoint mapped 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 its api-version header 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 required Idempotency-Key headers. A second call adds only its configure.
  • MapPouryaKitOpenApi() serves /openapi/v1.json, /openapi/v2.json and 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

RequestAnswer
GET /catalog/v1/products/{id}v1, from the URL segment
GET /catalog/products/{id} with api-version: 2.0v2, from the header
PUT /catalog/v1/products/{id}/name404 problem response: v1 has no such operation
PUT /catalog/products/{id}/name with api-version: 1.0400 problem response: the version does not serve it
GET /catalog/products with no version400 problem response
GET /catalog/v1/products with api-version: 2.0400 problem response: two versions named
  • Every response of a versioned Module carries api-supported-versions and api-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: CreateProduct answers Location: /catalog/v2/products/{id} to a v2 request by URL or by header, so a caller follows it with no header, or with its own api-version header, which then names the same version. It generates the link by name, passing the requested version as the version route 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, an IDictionary<string, ApiVersionSunset>, is keyed by the version as the Modules declare it, compared ordinally.
  • The Host does not start, and its OptionsValidationException names each refused key, when a key names no API version, two keys name one version (1 and 1.0, or 1.0-Beta and 1.0-beta: Asp.Versioning ignores the case of a version's status), a Link is not an absolute URI, or a sunset has neither a Date nor a Link. 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 Date or Link the 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's Configure call sets BinderOptions.ErrorOnUnknownConfiguration.
  • A response of a version with a sunset carries Sunset: Wed, 30 Jun 2027 00:00:00 GMT and Link: <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 in x-api-versioning.links, and marks each of its operations deprecated.
  • 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 IApiVersionDescriptionProvider enumerates them, never a list in the Host. A document holds the operations its version serves, on both paths; the unversioned one lists its api-version header 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 AddPouryaKitWeb sets 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. An AllowAnonymous or AllowAnonymousAs(actor) operation carries no security.
  • Only a JWT bearer scheme (its handler is JwtBearerHandler or derives from it) is stated, as components.securitySchemes.<scheme name> with type: http, scheme: bearer; the Proving Sample's is Bearer. 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 parameter Idempotency-Key of type string, whose description states the rule its value follows; no other operation lists it. The Proving Sample's POST /catalog/products carries 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).

On this page