Skip to content

API Documentation — OpenAPI 3.1, Versioning & Scalar

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.

PackageRoleDepends on
Granit.Http.ApiDocumentationOpenAPI generation, Asp.Versioning wiring, RFC 8594 deprecation, transformersGranit
Granit.Http.ApiDocumentation.ScalarScalar interactive UI, scoped CSP relaxation, OAuth2 popupGranit.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"
}
}

In Program.cs:

// With the Scalar package — documents + UI
app.UseGranitApiDocumentation(); // /openapi/v1.json, /openapi/v2.json, /scalar
// Headless host — documents only (generation package)
app.MapGranitOpenApiDocuments();

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.

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 metadata
v1Group.MapGet("/legacy-visits", GetLegacyVisits)
.Deprecated(sunsetDate: new DateOnly(2026, 6, 1),
link: "https://docs.example.com/migration/v2");

Response headers:

Deprecation: true
Sunset: Mon, 01 Jun 2026 00:00:00 GMT
Link: <https://docs.example.com/migration/v2>; rel="sunset"

Each call to a deprecated endpoint is logged at Warning level.

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.

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.

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.

Exclude inter-service endpoints from public documentation:

app.MapPost("/webhooks/keycloak", HandleKeycloakWebhook)
.WithMetadata(new InternalApiAttribute());

The module registers these OpenAPI transformers automatically:

TransformerPurpose
JwtBearerSecuritySchemeTransformerAdds Bearer security scheme when JWT is configured
OAuth2SecuritySchemeTransformerReplaces Bearer with OAuth2 Authorization Code when configured
SecurityRequirementOperationTransformerAnonymous endpoints override global security
ProblemDetailsSchemaDocumentTransformerAdds RFC 7807 ProblemDetails schema
ProblemDetailsResponseOperationTransformerDocuments 4xx/5xx Problem Details responses
DeprecationOperationTransformerMarks operations carrying DeprecatedAttribute as deprecated: true
ParameterDescriptionOperationTransformerAdds human-readable descriptions to well-known path/query parameters
SingleValueObjectSchemaTransformerRenders SingleValueObject<T> schemas as their underlying primitive
SortedTagsDocumentTransformerEmits an alphabetically sorted document.Tags array
TenantHeaderOperationTransformerDocuments X-Tenant-Id header when enabled
InternalApiDocumentTransformerRemoves [InternalApi] endpoints
WolverineOpenApiOperationTransformerEnhances Wolverine HTTP endpoint documentation
SchemaExampleSchemaTransformerApplies ISchemaExampleProvider examples
Int32SchemaTransformerStrips the spurious ["integer", "string"] fallback ASP.NET Core emits on int32 properties (only matters for int64 / JS Number precision)
QueryEngineOperationTransformerEmits $ref for PagedResult<T> / GroupedResult<T> 200 responses

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 }.

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.

Generation package — section Http:ApiDocumentation:

PropertyDefaultDescription
Title"API"OpenAPI document title
MajorVersions[1]Major versions to route and document
DefaultMajorVersion1Version assumed when the client omits it (must be in MajorVersions)
ReportApiVersionstrueInclude api-supported-versions / api-deprecated-versions headers
DescriptionnullOpenAPI description (Markdown supported)
ContactEmailnullContact email in OpenAPI info
LogoUrlnullLogo URL (x-logo extension)
EnableTenantHeaderfalseDocument required tenant header
TenantHeaderName"X-Tenant-Id"Tenant header name
OAuth2.AuthorizationUrlnullOAuth2 authorization endpoint
OAuth2.TokenUrlnullOAuth2 token endpoint
OAuth2.Scopes["openid"]OAuth2 scopes to request

Scalar package — section Http:ApiDocumentation:Scalar:

PropertyDefaultDescription
FaviconUrlnullFavicon for the Scalar page
EnableInProductionfalseExpose the UI in Production
AuthorizationPolicynullPolicy for doc/UI endpoints (null = inherit, "" = anonymous)
OAuth2.ClientIdnullPublic OAuth2 client ID (PKCE-capable, browser client)
OAuth2.EnablePkcetrueEnable PKCE with S256
OAuth2.RedirectUrinullOverride the OAuth2 redirect URI
CategoryKey typesPackage
ModulesGranitHttpApiDocumentationModule, GranitHttpApiDocumentationScalarModule
OptionsApiDocumentationOptions, OAuth2OptionsGranit.Http.ApiDocumentation
Options (UI)ScalarOptions, ScalarOAuth2OptionsGranit.Http.ApiDocumentation.Scalar
DeprecationDeprecatedAttribute, .Deprecated()Granit.Http.ApiDocumentation
Extension pointsISchemaExampleProvider, InternalApiAttributeGranit.Http.ApiDocumentation
ExtensionsAddGranitApiDocumentation(), MapGranitOpenApiDocuments()Granit.Http.ApiDocumentation
Extensions (UI)UseGranitApiDocumentation()Granit.Http.ApiDocumentation.Scalar