API Documentation — OpenAPI 3.1, Versioning & Scalar
Why interactive API documentation?
Section titled “Why interactive API documentation?”An API without discoverable documentation creates friction: frontend developers read source code, QA teams guess request shapes, and onboarding new team members takes days instead of minutes. Static Swagger pages help, but they lack real authentication flows — testers still reach for Postman or curl.
Granit.Http.ApiDocumentation generates one OpenAPI 3.1 document per API major
version and configures URL-segment API versioning from the same declaration —
Http:ApiDocumentation:MajorVersions is the single source of truth for both the
/api/v{n}/ route constraint and the /openapi/v{n}.json documents. The optional
companion package Granit.Http.ApiDocumentation.Scalar serves
Scalar, a modern interactive UI where developers authenticate
with OAuth2/PKCE and execute requests directly in the browser.
Package structure
Section titled “Package structure”| Package | Role | Depends on |
|---|---|---|
Granit.Http.ApiDocumentation | OpenAPI generation, Asp.Versioning wiring, RFC 8594 deprecation, transformers | Granit |
Granit.Http.ApiDocumentation.Scalar | Scalar interactive UI, scoped CSP relaxation, OAuth2 popup | Granit.Http.ApiDocumentation |
Headless hosts (pure APIs, microservices consumed by generated clients) reference
only the generation package and call MapGranitOpenApiDocuments(). Hosts that want
the interactive UI add the Scalar package and call UseGranitApiDocumentation()
instead — it maps the documents and the UI.
[DependsOn(typeof(GranitHttpApiDocumentationScalarModule))]public class AppModule : GranitModule { }{ "Http:ApiDocumentation": { "Title": "Clinic API", "MajorVersions": [1, 2], "DefaultMajorVersion": 1, "Description": "Patient and appointment management API" }}{ "Http:ApiDocumentation": { "Title": "Clinic API", "MajorVersions": [1], "OAuth2": { "AuthorizationUrl": "https://keycloak.example.com/realms/clinic/protocol/openid-connect/auth", "TokenUrl": "https://keycloak.example.com/realms/clinic/protocol/openid-connect/token", "Scopes": ["openid", "profile"] }, "Scalar": { "OAuth2": { "ClientId": "clinic-scalar", "EnablePkce": true } } }}The document-level OAuth2 section (endpoints + scopes) describes the flow in the
OpenAPI document; the Http:ApiDocumentation:Scalar:OAuth2 section holds the browser-client bits
(ClientId, PKCE) the UI needs to actually run it. When both are configured,
Scalar enables interactive Authorization Code + PKCE authentication.
In Program.cs:
// With the Scalar package — documents + UIapp.UseGranitApiDocumentation(); // /openapi/v1.json, /openapi/v2.json, /scalar
// Headless host — documents only (generation package)app.MapGranitOpenApiDocuments();API versioning
Section titled “API versioning”The module registers Asp.Versioning
with a URL segment primary reader (/api/v{version:apiVersion}/resource) and a
query string fallback (?api-version=1.0). Every version listed in
MajorVersions gets a routable segment and its own OpenAPI document;
DefaultMajorVersion (validated at startup to be a member of MajorVersions)
applies when the client omits the version.
var v1 = app.NewVersionedApi("Appointments").MapGroup("/api/v{version:apiVersion}");
var v1Group = v1.MapGroup("/appointments").HasApiVersion(1);v1Group.MapGet("/", GetAppointments);
var v2Group = v1.MapGroup("/appointments").HasApiVersion(2);v2Group.MapGet("/", GetAppointmentsV2);With ReportApiVersions enabled (the default), responses carry
api-supported-versions and api-deprecated-versions headers.
Deprecation (RFC 8594)
Section titled “Deprecation (RFC 8594)”Attaching DeprecatedAttribute metadata is all it takes — the response middleware
is auto-registered by the module (an IStartupFilter prepends it), and the
document transformer marks the operation deprecated: true so generated clients
see the deprecation too:
v1Group.MapGet("/legacy-patients", GetLegacyPatients) .WithMetadata(new DeprecatedAttribute { SunsetDate = new DateOnly(2026, 6, 1), Link = "https://docs.example.com/migration/v2", });
// Equivalent sugar — only attaches the same metadatav1Group.MapGet("/legacy-visits", GetLegacyVisits) .Deprecated(sunsetDate: new DateOnly(2026, 6, 1), link: "https://docs.example.com/migration/v2");Response headers:
Deprecation: trueSunset: Mon, 01 Jun 2026 00:00:00 GMTLink: <https://docs.example.com/migration/v2>; rel="sunset"Each call to a deprecated endpoint is logged at Warning level.
Scalar UI
Section titled “Scalar UI”UseGranitApiDocumentation() (in Granit.Http.ApiDocumentation.Scalar) maps the
OpenAPI documents and the Scalar UI at /scalar. The UI is enabled in Development
always; in Production only when Http:ApiDocumentation:Scalar:EnableInProduction is true — and the
module warns when the UI is exposed in Production without an
AuthorizationPolicy.
CSP relaxation for Scalar
Section titled “CSP relaxation for Scalar”UseGranitApiDocumentation registers a ScalarCspContributor that relaxes the
strict default CSP on the /scalar route only —
script-src/style-src 'self' 'unsafe-inline', font-src 'self' data: https://fonts.scalar.com, and the few other sources Scalar needs to render.
The relaxation is scoped to the Scalar endpoint via an internal metadata
marker, so no other route inherits it. When the OAuth2 popup is active, COOP is
relaxed to unsafe-none on /scalar as well. See
Security Headers — CSP contributors
for the underlying pattern.
Schema examples
Section titled “Schema examples”Provide realistic example values for request DTOs without coupling to OpenAPI:
public class AppointmentSchemaExamples : ISchemaExampleProvider{ public IReadOnlyDictionary<Type, JsonNode> GetExamples() => new Dictionary<Type, JsonNode> { [typeof(CreateAppointmentRequest)] = new JsonObject { ["patientId"] = "d4e5f6a7-1234-5678-9abc-def012345678", ["doctorId"] = "a1b2c3d4-5678-9abc-def0-123456789abc", ["scheduledAt"] = "2026-04-15T09:30:00Z", ["durationMinutes"] = 30 } };}Implementations of ISchemaExampleProvider are auto-discovered at startup.
Internal API exclusion
Section titled “Internal API exclusion”Exclude inter-service endpoints from public documentation:
app.MapPost("/webhooks/keycloak", HandleKeycloakWebhook) .WithMetadata(new InternalApiAttribute());Document transformers
Section titled “Document transformers”The module registers these OpenAPI transformers automatically:
| Transformer | Purpose |
|---|---|
JwtBearerSecuritySchemeTransformer | Adds Bearer security scheme when JWT is configured |
OAuth2SecuritySchemeTransformer | Replaces Bearer with OAuth2 Authorization Code when configured |
SecurityRequirementOperationTransformer | Anonymous endpoints override global security |
ProblemDetailsSchemaDocumentTransformer | Adds RFC 7807 ProblemDetails schema |
ProblemDetailsResponseOperationTransformer | Documents 4xx/5xx Problem Details responses |
DeprecationOperationTransformer | Marks operations carrying DeprecatedAttribute as deprecated: true |
ParameterDescriptionOperationTransformer | Adds human-readable descriptions to well-known path/query parameters |
SingleValueObjectSchemaTransformer | Renders SingleValueObject<T> schemas as their underlying primitive |
SortedTagsDocumentTransformer | Emits an alphabetically sorted document.Tags array |
TenantHeaderOperationTransformer | Documents X-Tenant-Id header when enabled |
InternalApiDocumentTransformer | Removes [InternalApi] endpoints |
WolverineOpenApiOperationTransformer | Enhances Wolverine HTTP endpoint documentation |
SchemaExampleSchemaTransformer | Applies ISchemaExampleProvider examples |
Int32SchemaTransformer | Strips the spurious ["integer", "string"] fallback ASP.NET Core emits on int32 properties (only matters for int64 / JS Number precision) |
QueryEngineOperationTransformer | Emits $ref for PagedResult<T> / GroupedResult<T> 200 responses |
Single Value Objects render as primitives
Section titled “Single Value Objects render as primitives”Domain types deriving from SingleValueObject<T> serialize on the wire as their
inner primitive (via SingleValueObjectJsonConverterFactory), but the default
schema generator would describe them as an object with a value property — a
phantom shape no payload ever has. SingleValueObjectSchemaTransformer rewrites
these schemas to the underlying primitive type and format, so generated clients
type an Iban property as string, not { value: string }.
QueryEngine schema references
Section titled “QueryEngine schema references”Query endpoints (MapGranitQuery<T>) return one of PagedResult<T> or
GroupedResult<T> depending on whether the caller passes a groupBy. Before
Granit 0.31, both schemas were inlined in the oneOf of every operation —
duplicating ~5 KB per endpoint and leaving orphan GroupedResultOf* schemas in
components.schemas.
From 0.31, the transformer constructs an explicit OpenApiSchemaReference so the
operation response serializes as:
{ "200": { "content": { "application/json": { "schema": { "oneOf": [ { "$ref": "#/components/schemas/PagedResultOfInvoice" }, { "$ref": "#/components/schemas/GroupedResultOfInvoice" } ] } } } }}Net effect: the generated OpenAPI doc is dramatically smaller for hosts with many
query endpoints, and components.schemas no longer contains orphan entries — both
help code generators (openapi-typescript, NSwag, Kiota) produce tighter clients.
Configuration reference
Section titled “Configuration reference”Generation package — section Http:ApiDocumentation:
| Property | Default | Description |
|---|---|---|
Title | "API" | OpenAPI document title |
MajorVersions | [1] | Major versions to route and document |
DefaultMajorVersion | 1 | Version assumed when the client omits it (must be in MajorVersions) |
ReportApiVersions | true | Include api-supported-versions / api-deprecated-versions headers |
Description | null | OpenAPI description (Markdown supported) |
ContactEmail | null | Contact email in OpenAPI info |
LogoUrl | null | Logo URL (x-logo extension) |
EnableTenantHeader | false | Document required tenant header |
TenantHeaderName | "X-Tenant-Id" | Tenant header name |
OAuth2.AuthorizationUrl | null | OAuth2 authorization endpoint |
OAuth2.TokenUrl | null | OAuth2 token endpoint |
OAuth2.Scopes | ["openid"] | OAuth2 scopes to request |
Scalar package — section Http:ApiDocumentation:Scalar:
| Property | Default | Description |
|---|---|---|
FaviconUrl | null | Favicon for the Scalar page |
EnableInProduction | false | Expose the UI in Production |
AuthorizationPolicy | null | Policy for doc/UI endpoints (null = inherit, "" = anonymous) |
OAuth2.ClientId | null | Public OAuth2 client ID (PKCE-capable, browser client) |
OAuth2.EnablePkce | true | Enable PKCE with S256 |
OAuth2.RedirectUri | null | Override the OAuth2 redirect URI |
Public API summary
Section titled “Public API summary”| Category | Key types | Package |
|---|---|---|
| Modules | GranitHttpApiDocumentationModule, GranitHttpApiDocumentationScalarModule | — |
| Options | ApiDocumentationOptions, OAuth2Options | Granit.Http.ApiDocumentation |
| Options (UI) | ScalarOptions, ScalarOAuth2Options | Granit.Http.ApiDocumentation.Scalar |
| Deprecation | DeprecatedAttribute, .Deprecated() | Granit.Http.ApiDocumentation |
| Extension points | ISchemaExampleProvider, InternalApiAttribute | Granit.Http.ApiDocumentation |
| Extensions | AddGranitApiDocumentation(), MapGranitOpenApiDocuments() | Granit.Http.ApiDocumentation |
| Extensions (UI) | UseGranitApiDocumentation() | Granit.Http.ApiDocumentation.Scalar |
See also
Section titled “See also”- ADR-009: Scalar — Why Scalar was chosen over Swagger UI
- ADR-004: Asp.Versioning — Why Asp.Versioning was chosen
- Add API Versioning guide — step-by-step versioned route groups
- Authentication module — JWT Bearer, Keycloak
- API & Http overview — All HTTP infrastructure packages
- Blog: Scalar is the new Swagger UI — a hands-on comparison and why Granit ships Scalar by default
- OpenAPI Contract Generator — build-time, per-module documents for frontend codegen (uses the same transformer chain)