OpenAPI Contract Generator — Build-Time Per-Module Specs
Granit.OpenApi.Generator is a build-only project that composes every
Granit.*.Endpoints module and emits one OpenAPI 3.1 document per module at
build time — with no running web host and no infrastructure (no database, Vault,
object store, or message broker). The generated JSON is the contract that
granit-front turns into TypeScript types, so the two repos never drift.
This page has two audiences:
- Framework contributors who maintain or extend the generator — read all of it, especially Keeping it complete and the two gotchas.
- Frontend developers who consume the generated artifacts — jump to Integer handling and Artifacts and consumption.
Why a dedicated generator?
Section titled “Why a dedicated generator?”A Granit host serves its OpenAPI document at runtime from the live request pipeline (see API Documentation). That is perfect for human exploration via Scalar, but a poor fit for a codegen contract:
- It needs a running host — with a database, Vault, and a broker wired up — just to read a static schema.
- A single host rarely composes every module, so no one document covers the whole framework surface.
- The output is one big document, not the per-package slices
granit-frontconsumes.
The generator solves all three: it composes the full set of endpoints modules once, runs no infrastructure, and writes one self-contained document per module.
How it works
Section titled “How it works”The generator never starts Kestrel. Build-time OpenAPI generation is driven by the
Microsoft.Extensions.ApiDescription.Server
package together with two MSBuild properties in the .csproj:
<OpenApiGenerateDocuments>true</OpenApiGenerateDocuments><OpenApiDocumentsDirectory>$(MSBuildProjectDirectory)/generated</OpenApiDocumentsDirectory>At build, the GetDocument.Insider tool launches the application entry point
against a mock server (no sockets, no Kestrel) and calls Host.StartAsync().
That means all startup logic runs — including ValidateOnStart() validators and
every IHostedService.StartAsync(). Left untouched, those would reach for real
infrastructure and the build would fail with no database in sight.
To keep generation config-free and infra-free, Program.cs strips three service
categories before builder.Build(). The OpenAPI document is assembled from
endpoint metadata in the request pipeline, so none of them are needed to produce it:
| Removed | Why it is safe to remove |
|---|---|
RemoveAll<IHostedService>() | The DB / Vault / Wolverine / seeder workers — nothing should run, only describe. |
RemoveAll<IStartupValidator>() | Every ValidateOnStart() check — there is no real config to validate. |
all IValidateOptions<> | Map-time options validators (e.g. MapGranitBff reads validated options while mapping routes). |
It then wires the two cheap, infra-free ASP.NET services the Granit OpenAPI transformers assume a host has registered:
builder.Services.AddAuthentication();builder.Services.AddAuthorizationBuilder();With those removals in place, Host.StartAsync() becomes an effective no-op and the
document is built purely from route metadata.
flowchart TD
A["dotnet build src/Granit.OpenApi.Generator"] --> B["MSBuild target<br/>(Microsoft.Extensions.ApiDescription.Server)"]
B --> C["GetDocument.Insider launches the app<br/>entry point on a mock server — no Kestrel"]
C --> D["Program.cs: compose every<br/>Granit.*.Endpoints module"]
D --> E["Strip IHostedService +<br/>IStartupValidator + IValidateOptions<>"]
E --> F["builder.Build()"]
F --> G["Host.StartAsync() — now a no-op,<br/>reaches no infrastructure"]
G --> H["Build one OpenAPI document<br/>per module from route metadata"]
H --> I["generated/Granit.OpenApi.Generator_<slug>.json"]
Per-module slicing
Section titled “Per-module slicing”Each module gets its own document, named by a slug (blob-storage,
identity-local, bff, …). Two pieces make a single composed app yield one
document per module:
-
Stamp the routes. Every module’s routes are mounted under an empty-prefix group tagged with the slug:
module.Map(app.MapGroup(string.Empty).WithGroupName(module.Slug)); -
Slice by group name. Each document’s
shouldIncludepredicate keeps only the endpoints whose group matches its slug:builder.AddGranitOpenApiDocument(slug, slug, description => description.GroupName == slug);
GroupName — not the declaring assembly — is the correct discriminator. Shared-helper
endpoints such as MapGranitQuery<T> have their handler in
Granit.QueryEngine.AspNetCore, yet they belong in the owning module’s document.
Because WithGroupName propagates to nested groups, those helper routes inherit the
module’s slug and land in the right contract.
The AddGranitOpenApiDocument API
Section titled “The AddGranitOpenApiDocument API”The generator is built on a new public extension shipped in
Granit.Http.ApiDocumentation:
public static IHostApplicationBuilder AddGranitOpenApiDocument( this IHostApplicationBuilder builder, string documentName, string title, Func<ApiDescription, bool> shouldInclude)It registers one OpenAPI document that carries the full Granit transformer
chain (int32 normalization, RFC 7807 Problem Details schema, sorted tags, security
schemes, …) plus your custom slice predicate. Reach for it — instead of a bare
AddOpenApi — whenever you want per-module or per-audience documents that stay
faithful to what the framework actually serves. A bare AddOpenApi emits raw
ASP.NET Core output (for example type: ["integer", "string"] on every int; see
below).
Integer handling for frontend consumers
Section titled “Integer handling for frontend consumers”This is the one section every frontend consumer must read.
ASP.NET Core’s native generator types every int as type: ["integer", "string"],
because System.Text.Json accepts a number written as a JSON string on read. The
Granit transformer chain cleans this up — but treats 32-bit and 64-bit integers
differently, on purpose:
| .NET type | Generated schema | Type it in TypeScript as |
|---|---|---|
int / int32 | normalized to "integer" (string variant + numeric pattern stripped) | number |
long / int64 | keeps the integer | string union | string | number (or string) |
The int64 union is deliberate, not a backend bug. JavaScript’s Number
precision tops out at 2^53, so a large long cannot round-trip as a JS number — the
backend serializes it as a string to stay lossless.
Two gotchas
Section titled “Two gotchas”BFF needs a stub frontend
Section titled “BFF needs a stub frontend”MapGranitBff maps one route group per configured Bff:Frontends entry. With an
empty list it emits zero paths, so the generator ships a stub appsettings.json
with a single frontend:
{ "Bff": { "Frontends": [{ "Name": "app" }] }}It is the only config-collection-driven module that needs seed config — the stripped
options validators mean the values themselves never have to be real. The documented
JSON surface is /bff/user and /bff/csrf-token; the OIDC redirect routes
(login / logout / back-channel) are marked ExcludeFromDescription and do not
appear in the contract.
Reflecting over every module at build time
Section titled “Reflecting over every module at build time”Composing the full module graph reflects over every module’s exported types at build time. The generator carries no Wolverine reference of its own — two framework decisions are what keep that reflection from blowing up:
-
Resilient discovery scans.
Granit.Reflection.SafeTypeLoader(granit-dotnet#2541) returns an assembly’s loadable types instead of lettingGetExportedTypes()throw when a referenced dependency is not present. One awkward assembly can no longer take down a “scan every loaded assembly” discovery site. -
Wolverine stays loadable.
Granit.Privacyexposes public Wolverine sagas (its export and deletion sagas), so its public surface references Wolverine types. It referencesWolverineFxwithPrivateAssets="analyzers", which privatises only the Wolverine source generator — stopping a duplicateGeneratedWolverineTypeLoader(CS0436) conflict in downstream projects — while letting the Wolverine runtime assembly flow transitively. Consumers that reflect over Privacy’s exported types (the generator, FluentValidation, Wolverine’s own discovery) therefore find Wolverine loadable.
How to run it
Section titled “How to run it”# Emits ./generated/Granit.OpenApi.Generator_<slug>.json — one per module.dotnet build src/Granit.OpenApi.Generator
# Force a regen when the build thinks it is up to date.dotnet build src/Granit.OpenApi.Generator --no-incrementalArtifacts and consumption
Section titled “Artifacts and consumption”The generator writes one document per module to its generated/ folder:
generated/ Granit.OpenApi.Generator_blob-storage.json Granit.OpenApi.Generator_identity-local.json Granit.OpenApi.Generator_bff.json …These artifacts are generated, not source — generated/ is gitignored and the
JSON is never committed. The pipeline is:
- CI builds the generator and publishes the per-module JSON as artifacts.
granit-frontrunsopenapi-typescriptover each document to emit per-module generated types (for example@granit/{module}/types/_generated.ts).tsccatches drift. If a backend contract change regenerates a type that no longer matches the frontend code, the TypeScript compiler fails downstream — the contract break surfaces at build time, not in production.
Keeping it complete
Section titled “Keeping it complete”A new .Endpoints module that ships without being wired into the generator would
silently drop its contract from the artifacts — and from the granit-front type feed
— while the generator still builds green. The architecture test
OpenApiGeneratorCompletenessTests prevents that by failing the build unless all
three wiring points agree with the set of src/Granit.*.Endpoints projects on disk:
- the
.csproj<ProjectReference>s exactly the endpoints projects; GeneratorModule’s[DependsOn]names each module (so its services are configured);GeneratorEndpoints.Allhas one registry entry per module (so its routes are mounted).
See also
Section titled “See also”- API Documentation — Scalar & OpenAPI 3.1 — the runtime side: the transformer chain and the human-facing Scalar UI.
- Module Structure — anatomy of a
Granit.*.Endpointspackage. - Testing Guide — how architecture tests like
OpenApiGeneratorCompletenessTestsare written. - Frontend HTTP Client — the consuming side:
@granit/api-clientand the generated types.