OpenAPI in depth

Full JSON Schema alignment in a served OpenAPI document — type-array nullable, top-level webhooks — and the living-contract drift check that keeps a generated doc matching the shared schema.

⏱ 28 minChapter 20Multi-protocol contracts & framework deep-dives

Chapter 4 put the contract first: one schema file, two stacks, a CI gate that schema-validates every live response against it. That chapter stopped at the parts of OpenAPI every reader already half-knows — paths, methods, status codes, a $ref into a shared schema. This chapter goes into parts most REST work never touches: what it means for components.schemas to be JSON Schema rather than a lookalike of it, what a top-level webhooks section documents that a path never can, and what it takes to keep a generated document matching the document a team agreed to ship. None of this changes a response body. It changes what the document describing those responses can say, and how confidently a reader, a generator, or a CI job can trust what it says.

What “the schema section is JSON Schema” unlocks

Chapter 4 already leaned on this without naming it directly: contracts/schemas/shipping.schema.json is written in JSON Schema’s 2020-12 dialect, and contracts/openapi/shipping-api.yaml references into it with plain $ref. That only works cleanly because modern OpenAPI’s schema object is defined as a JSON Schema dialect itself, not a parallel vocabulary that happens to look similar. Earlier OpenAPI generations weren’t so clean. They defined their own schema object — a subset of an older JSON Schema draft, with a handful of OpenAPI-specific extensions bolted on — which meant a schema authored for OpenAPI and a schema authored for a general-purpose JSON Schema validator were two different things that merely resembled each other. A $ref from an OpenAPI document into a standalone JSON Schema file either didn’t resolve cleanly or required the consuming OpenAPI tool to special-case it. Full dialect alignment removes the special-casing: the exact same schema, validated the exact same way, means the exact same thing whether a general-purpose JSON Schema validator checks it or an OpenAPI-aware tool renders it into documentation.

The clearest everyday consequence of that alignment is how a field gets to be nullable. Before dialect alignment, OpenAPI’s answer to “this field can be absent or explicitly null” was its own keyword, nullable: true, sitting beside type: string as a sibling flag — a keyword that exists nowhere in JSON Schema itself and that a generic JSON Schema validator has never heard of. It works, but only inside tooling that specifically knows to look for it; hand that schema to a JSON Schema library with no OpenAPI awareness and the nullable flag is inert decoration, silently ignored. Full JSON Schema alignment replaces that bespoke flag with JSON Schema’s own native mechanism: type stops being a single string and becomes an array, so type: [string, "null"] says, in JSON Schema’s own vocabulary, that this value is a string or it is null — nothing else, nothing proprietary. The practical gap between the two forms is interoperability, not expressiveness: nullable: true says the same thing but only to a reader that has memorized OpenAPI’s extension vocabulary, while a type array says it to anything that speaks JSON Schema, full stop.

META-INF/openapi.yaml in this chapter’s example makes the contrast concrete on a single property. Booking.cancellationReason is declared type: [string, 'null'] — a string once a booking is cancelled, null until then, expressed as a plain JSON Schema type array rather than a bespoke flag. check_openapi.py, the structural checker verify.sh runs against each stack’s served document, treats the two forms as interchangeable by design: it walks the whole document looking for either a type array containing "null" or the older nullable: true, because the point being demonstrated is “this document expresses nullability in a JSON-Schema-recognizable way,” not “this document uses one specific spelling.” A reader who flips cancellationReason back to nullable: true and re-runs verify.sh watches the check still pass — the gate cares about the capability, not the dialect vintage.

Composition keywords carry the same alignment benefit, even though this chapter’s example keeps its schemas simple enough not to need heavy use of them. oneOf, anyOf, allOf, and conditional if/then/else schemas are ordinary JSON Schema, available in an OpenAPI document’s schema objects exactly as a standalone JSON Schema file would use them, with no OpenAPI-specific reinterpretation of what they mean. A Cargo variant that needs different required fields depending on type — a HAZARDOUS cargo item requiring a regulatory classification code that a GENERAL item never carries — is expressible with an if/then pair the same way it would be in any JSON Schema document, because by this point there is no meaningful distinction between “an OpenAPI schema” and “a JSON Schema” to reconcile.

Webhooks: documenting the call the server makes outward

Every path this book has written so far describes an inbound call: a client sends a request, the server responds. A webhooks section documents the opposite direction — a call the server initiates outward, to a URL the client registered in advance, using exactly the same request/response machinery OpenAPI already has for paths. Before webhooks existed as a first-class top-level section, documenting this kind of outbound notification meant either skipping it entirely or writing prose beside the spec explaining what the server would eventually POST and hoping a reader found it. There was no way to say “here is the exact shape of the call we will make to you” in the same structured, tool-readable form as “here is the exact shape of the call you can make to us.”

This chapter’s example declares one: bookingStatusChanged, a webhook entry describing a POST the server would issue to a client’s registered callback whenever a booking’s status changes, carrying a BookingEvent payload. The shape of that entry matches a path operation exactly: a requestBody with a schema, a map of expected responses, because a webhook callback is a request/response exchange; it’s just initiated by the party usually on the receiving end. BookingEvent reuses the same component types the synchronous API already defines (BookingStatus, the bkg_-prefixed id pattern), mirroring #/$defs/BookingEvent in the shared contract schema the same way Booking and Cargo already do, rather than inventing a parallel shape just because this is a webhook instead of a path. The event this chapter’s webhooks entry describes is documentation-only (neither stack’s server code issues the call), which shows what the example proves: that a real, parseable webhooks section renders and validates, not that this particular teaching app runs a notification dispatcher. A production booking-confirmation or shipment-tracking integration would wire a real HTTP client to fire that POST on a status transition; this chapter stops at proving the contract for it is expressible and servable.

Generating — or not generating — the document in each stack

Chapter 4’s two stacks never had to think about how their OpenAPI documents got served; a framework-level scanner read the annotated resource classes or the decorated route functions and produced a document nobody had to hand-write. This chapter’s two headline constructs expose a real gap in that convenience, and the two stacks close the gap in opposite ways.

Quarkus’s smallrye-openapi extension builds its document by scanning JAX-RS-annotated resource classes — @Path, @GET, @Produces, and the MicroProfile OpenAPI annotation set layered on top for descriptions and examples. That annotation model has no clean way to express either of this chapter’s constructs today: there’s no annotation for “this schema should serialize its generated field as a JSON Schema type array” and no annotation for a top-level webhooks map at all, because webhooks doesn’t correspond to any annotated Java method the scanner could discover. Rather than fighting the annotation model to force constructs it wasn’t built for, this chapter’s Quarkus side serves a hand-authored static document instead of a generated one: application.properties sets mp.openapi.scan.disable=true, which tells smallrye-openapi to serve META-INF/openapi.yaml verbatim at /q/openapi rather than merging it with whatever the annotation scanner would have produced. BookingResource.java still serves working POST/GET /bookings endpoints completely independent of that flag — the static document and the live resource are not wired together in this example, which is exactly why the drift check below catches something the framework’s own consistency guarantees would otherwise miss.

FastAPI’s story runs the other direction: it generates a document natively, including full support for both constructs, but the generated form of one of them isn’t the form this chapter is demonstrating. FastAPI accepts a webhooks= constructor argument (an APIRouter of documentation-only route declarations that render under the top-level webhooks key of /openapi.json with no extra plumbing), which is this chapter’s webhooks_router and its one bookingStatusChanged route. Nullability is where the generated default diverges from the construct this chapter wants to show: a pydantic v2 model field typed Optional[str] renders in FastAPI’s generated JSON Schema as anyOf: [{"type": "string"}, {"type": "null"}], because that’s how pydantic’s own schema exporter expresses “string or null” by default. anyOf with a null branch is entirely valid JSON Schema and says the same thing semantically as a type array — but it isn’t the type: ["string", "null"] form this chapter is specifically contrasting against the older nullable: true flag. main.py closes that gap with a custom_openapi() function wired onto app.openapi: it calls FastAPI’s own get_openapi() to build the real document from the real routes, then patches one field, cancellationReason (described identically to its Quarkus counterpart), directly onto the generated Booking schema with an explicit "type": ["string", "null"], overriding what pydantic’s exporter would have produced on its own.

Both patches land on the same field, with the same description, appearing only in the served documents and never in an actual response body — the shared contract’s Booking schema sets additionalProperties: false and doesn’t declare cancellationReason, so a real response carrying it would fail validate_response.py’s schema check. That constraint is deliberate: it keeps this chapter’s illustrative field from leaking into the live wire format Chapter 4 already locked down, while still giving both stacks a real, servable place to show the construct.

cancellationReason:
  description: >
    Null until the booking is cancelled. Declared as an OpenAPI 3.1
    JSON Schema `type` ARRAY (["string", "null"]) rather than an
    OAS-3.0 `nullable: true` flag.
  type: [string, 'null']
booking_schema["properties"]["cancellationReason"] = {
    "description": (
        "Null until the booking is cancelled. Declared as an OpenAPI "
        "3.1 JSON Schema `type` ARRAY ([\"string\", \"null\"]) rather "
        "than an OAS-3.0 `nullable: true` flag."
    ),
    "type": ["string", "null"],
}

The Quarkus side writes the construct directly, because the whole document is hand-authored YAML. The Python side writes the identical construct as a dictionary literal patched into a generator’s output, because that’s the one place pydantic’s own export disagrees with the form being demonstrated. Same field, same shape, same reason for existing — reached by two different mechanisms because one framework hand-authors its document and the other generates it and needs one targeted override.

The living-contract drift check

A hand-authored static document and a generator’s output share the same failure mode: nothing stops either one from quietly disagreeing with the schema a team committed to. Quarkus’s static META-INF/openapi.yaml is a second, independently maintained copy of the Booking shape — nothing mechanically ties its required list to contracts/schemas/shipping.schema.json’s unless something checks. FastAPI’s generated document is driven by its pydantic models, which is tighter, but a pydantic model can still drift from the shared contract one field at a time as both evolve under different reviewers on different timelines. Either way, “the served document” and “the committed contract” are two artifacts, and two artifacts that aren’t mechanically checked against each other will eventually disagree, silently, exactly the way Chapter 4 argued a generated-only document always eventually disagrees with the code it was supposed to describe.

check_openapi.py closes that gap with a structural comparison rather than a byte-equal diff, and the distinction matters. A served document and the shared contract schema will never be byte-identical — different $ids, different info blocks, different descriptions, $refs structured differently depending on how each stack’s tooling expands them. Byte-equal diffing would fail on every one of those harmless differences, which trains a team to ignore the gate’s output, or delete the gate outright, long before it ever catches a real drift. Instead, the check pulls the set of required property names declared for Booking out of each stack’s live, served document and compares it against the same set declared under #/$defs/Booking.required in the shared contract schema — a comparison that only fails when the two disagree about which fields a booking must have, which is the one thing a client integrating against either document depends on.

verify.sh runs this check against both stacks on every invocation, after fetching each one’s live OpenAPI document over HTTP — not a checked-in copy, not a snapshot from the last time someone remembered to regenerate one, but whatever the running service answers with right now. The README’s drift probe makes the failure mode concrete: delete cargo from the required list in META-INF/openapi.yaml and re-run verify.sh. The contract side still lists cargo as required; the live Quarkus document no longer does; the check prints exactly which field is missing from which side and fails the build. Revert the line and the same run returns to green. Nothing about the live POST/GET /bookings round trip changes during that probe (the running resource still returns a perfectly good 201 with a complete Booking body); the point is that a status code and a response body can both be correct while the document describing them has quietly stopped matching what the team agreed the shape should be, and only a check that reads the served document itself, independent of any response, catches that.

That loop — edit the contract, each served document reflects the edit or doesn’t, a drift check compares the two, CI goes green or red — is what makes the contract living rather than a document written once and trusted forever. A contract frozen at Chapter 4 and never checked again is as reliable as the generated-document problem that chapter opened with: a description of what was true the day it was written, with no mechanism forcing it to stay true as both stacks keep changing around it.

Running it

cd examples/20-openapi-depth
./verify.sh

Quarkus comes up on port 8100, FastAPI on port 8020. For each stack, verify.sh fetches the live OpenAPI document, runs check_openapi.py’s three structural assertions (a JSON-Schema nullable construct present somewhere in the document, a non-empty top-level webhooks section, and the Booking required-field set matching the shared contract), then drives a real POST → GET booking round trip and schema-validates both bodies against #/$defs/Booking. It prints PASS only once every assertion holds for both stacks. The README documents three separate drift probes (dropping a required field, deleting the nullable property, deleting the webhooks section), each isolated to one file, each caught by a different assertion, each reverted back to green by undoing the one line that broke it.

A shared contract schema feeds a drift check; the Quarkus stack serves a hand-authored static OpenAPI document and the FastAPI stack serves a generated-and-patched OpenAPI document, each exposing a type-array nullable field and a webhooks section, and CI's drift check compares each served document's required-field set against the contract, passing on a match and failing the build on divergence.
Figure 20.1 — The living-contract loop: edit the contract, each stack serves its document, drift check compares, CI gates on the result.

Decision

Apply the Chapter 2 axes to whether OpenAPI depth and drift detection are worth the investment. Client diversity is the strongest driver: a document consumed only by one team reading its own server's source barely needs type-array nullability or a drift gate, because any disagreement surfaces immediately as a code review comment; a document consumed by external partners, a generated SDK pipeline, or a documentation portal nobody on the API team controls needs every nullability and requiredness signal to be exact, because those consumers can't read the implementation to resolve an ambiguity the document left open. Evolution pressure matters just as much as diversity: a contract that changes rarely can get away with an occasional manual comparison between a served document and the schema it's supposed to match; a contract under active development, with fields added and renamed across several chapters' worth of change the way this book's shipping domain has been, needs the comparison running on every push, because that's exactly the cadence at which a hand-authored static document and a generated one drift apart unnoticed. Webhooks specifically are worth the top-level section only when the API calls outward to client-registered endpoints (a booking-confirmation push, a shipment-tracking update) and not worth inventing for an API that only ever answers inbound requests; documenting a webhook nobody will ever receive just adds a section a reader has to figure out is aspirational. Skip the structural drift check only when a single stack's document is itself the generated source of truth with no second artifact to compare against — the moment a second document enters the picture, whether hand-authored or independently generated, the gate is what keeps "the contract" meaning one thing instead of two.