API-First with OpenAPI 3.2: Design-First Flow, Linting with Spectral, Breaking Change Diffs and Mock Servers
In a design-first flow the OpenAPI contract is a build input: every pull request lints the style guide, looks for breaking changes against the target branch, and checks the freshness of the generated client and drift against the document generated from the code. At Bookstore we see with real output why a Spectral rule never failed, why oasdiff counted a removed parameter as only a warning, the drift check that found the old RPC endpoint, the Prism mock, and Angular types from openapi-typescript.
Version note: The code in this post was built and run with C# 14 and .NET 10 (ASP.NET Core 10.0.15), and the Angular example with Angular 22.2 and TypeScript 6.0; the HTTP examples are real responses from this application. Tools: Redocly CLI 2.78.0, Spectral 6.17.1, oasdiff 1.37.0, Prism 5.16.0, openapi-typescript 7.13.0.
Guess First
Emre opens a pull request against the API of Bookstore, the online bookstore used as the shared example across this site's series. It has two changes: he renames the price filters to priceFrom/priceTo, which he finds more readable, and adds an endpoint that regenerates an invoice for institutions whose tax number has changed. The change in the contract:
$ git diff contract/bookstore.openapi.yaml
diff --git a/contract/bookstore.openapi.yaml b/contract/bookstore.openapi.yaml
index 7a516b2..8f72d7e 100644
--- a/contract/bookstore.openapi.yaml
+++ b/contract/bookstore.openapi.yaml
@@ -53,11 +53,11 @@ paths:
description: Exact name of one of the authors, case-insensitive.
schema:
type: string
- - name: minPrice
+ - name: priceFrom
in: query
schema:
type: number
- - name: maxPrice
+ - name: priceTo
in: query
schema:
type: number
@@ -567,6 +567,30 @@ paths:
$ref: '#/components/responses/Problem'
'404':
$ref: '#/components/responses/Problem'
+ /orders/{orderId}/invoices/{invoiceId}:regenerate:
+ parameters:
+ - $ref: '#/components/parameters/ApiVersion'
+ - $ref: '#/components/parameters/OrderId'
+ - name: invoiceId
+ in: path
+ required: true
+ schema:
+ type: string
+ format: uuid
+ post:
+ operationId: regenerateInvoice
+ summary: Regenerate an invoice
+ description: Builds the PDF again, for example after the tax number changed.
+ tags: [Orders]
+ responses:
+ '202':
+ description: Accepted; the invoice is being regenerated.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Operation'
+ '404':
+ $ref: '#/components/responses/Problem'
/operations/{operationId}:
parameters:
- $ref: '#/components/parameters/ApiVersion'
The contract has two problems. Renaming the parameters sends every client that sends minPrice to a 400, because since post 05 unknown parameters get an error. The new endpoint also breaks the rule from post 07: it returns 202 but has no Operation-Location or Retry-After. The question: which tool should catch which problem so that this pull request stops before it reaches review? Make your guess; on the first try neither was caught.
Code-First or Design-First?
In a code-first flow the endpoint is written first and the OpenAPI document is generated from the code. It is fast and the document always matches the code, but design decisions become a by-product of the code: the consumer sees the contract after the endpoint is done, and when they object the work is redone. In a design-first flow the contract is written and reviewed first, and the code follows it. That is what we have been doing since the first post of this series, with one gap: linting checked that the contract followed the rules, but nobody checked whether a change to the contract broke consumers or whether the code matched the contract. In this post we close those two gaps and record the decision as ADR-0007.
The cost of design-first is discipline: keeping the contract in sync with the code takes a tool chain. The benefit is early feedback. The schools' integration teams can take a mock server and client types from the contract and start their work before the endpoint is even written. The Integration and E2E Testing series looks at the same problem from the Angular side: the post "The Contract Between Angular and the API: OpenAPI Documents, Breaking Change Detection and When to Use Pact" covers the client's dependency on the contract from a testing point of view; here we are on the side of the API team that owns the contract.
OpenAPI 3.2 and the State of the Tools
Our contract has been written in OpenAPI 3.2.0 since the first post. 3.2.0 was published on 19 September 2025; 3.2.1, released on 10 September 2026, is a patch release. The additions over 3.1 that matter to us: a query operation on the Path Item, a field for the QUERY method defined by RFC 10008, and additionalOperations for non-standard methods; an in: querystring parameter that treats the whole query string as one value; itemSchema on media types for content that arrives item by item (JSON Lines, Server-Sent Events); parent and kind for tags; $self for the document's own address; deprecated on security schemes, oauth2MetadataUrl and the OAuth device authorization flow. The spec is ready; the tools are arriving at different speeds. What we see today with the tools used in this series:
| Tool | Version | Status with OpenAPI 3.2 |
|---|---|---|
| Redocly CLI | 2.78.0 | Validates 3.2 and knows the query operation |
| Spectral | 6.17.1 | Skips schema validation on a 3.2 document and leaves the query operation out of its rules |
| oasdiff | 1.37.0 | Compares 3.2 documents and sees QUERY /books too |
| Prism | 5.16.0 | Mocks the document, answers a QUERY request with 405 |
| openapi-typescript | 7.13.0 | Its built-in validator rejects query; with it turned off it generates types, but none for QUERY |
| ASP.NET Core | 10.0.15 | Generates OpenAPI 3.1.1 from the code; the QUERY endpoint is not in the document |
This table has a consequence for the decision: writing the contract in 3.2 means accepting that some tools ignore the new fields. For us the only affected part is QUERY, and we wrote that explicitly into ADR-0007. Writing it in 3.1 was also an option, but then we could not describe QUERY in the contract at all.
Turning the Style Guide into Lint Rules
The rules tagged [lint] in the style guide are each a rule in .spectral.yaml. For BS-63 from post 07, the rule "every 202 points to its status monitor", the Spectral rule we first wrote was this:
bookstore-lro-headers:
description: A 202 response points to its status monitor (BS-63).
message: "{{path}} needs Operation-Location and Retry-After"
severity: error
given: $.paths[*][*].responses['202'].headers
then:
- field: Operation-Location
function: truthy
- field: Retry-After
function: truthy
The rule raised no error on the existing contract, and we assumed it worked. It raised no error on Emre's pull request either. The reason is in given: the rule selects the 202's headers object, and Emre's 202 has no headers at all. With nothing selected, Spectral finds nothing to check. Making the 202 itself the rule's target catches the pull request:
# Given the 202 itself, not its headers: a 202 without headers must fail too.
given: $.paths[*][*].responses['202']
then:
- field: headers.Operation-Location
function: truthy
- field: headers.Retry-After
function: truthy
$ npx spectral lint contract/bookstore.openapi.yaml
/home/user/bookstore/contract/bookstore.openapi.yaml
586:15 error bookstore-lro-headers #/paths/~1orders~1%7BorderId%7D~1invoices~1%7BinvoiceId%7D%3Aregenerate/post/responses/202 needs Operation-Location and Retry-After paths./orders/{orderId}/invoices/{invoiceId}:regenerate.post.responses[202]
✖ 1 problem (1 error, 0 warnings, 0 infos, 0 hints)
The lesson: a lint rule needs testing just like code. When you write a rule, do not trust it until you have run it once against a small document that breaks it and seen it fail. We added one more rule in this post: every operation and property marked deprecated: true must carry a description, saying what replaces it and when it goes away (BS-54). The general approach of turning decisions into automated checks is covered in the Architecture Decisions series post "From ADR to Automated Check: Verifying Decisions with Fitness Functions and CI".
Catching Breaking Changes in the Pull Request
A linter looks at one document; a breaking change is the difference between two. oasdiff compares two OpenAPI documents and classifies the changes by its rules. We first tried the tool against history: what broke between the contract of post 05 and today's?
$ git show rest-api-tasarimi/05:contract/bookstore.openapi.yaml > v05.yaml
$ oasdiff breaking v05.yaml contract/bookstore.openapi.yaml | head -1
9 changes: 9 error, 0 warning, 0 info
$ oasdiff breaking v05.yaml contract/bookstore.openapi.yaml --format singleline | grep -o -E '\[[a-z-]+\]' | sort | uniq -c
2 [request-parameter-became-required]
7 [response-property-became-optional]
All nine are changes we know about: author leaving the required fields (post 06, in seven responses) and If-Match becoming required on PATCH and DELETE (post 07). We made both on purpose and wrote down why in ADRs. The tool does not know the reasons; its job is to make the change visible, and the decision belongs to review. That is Bookstore's rule too (BS-82): a breaking change reported by oasdiff is accepted only with a new api-version or an ADR explaining why no consumer breaks. Now Emre's pull request:
$ git show main:contract/bookstore.openapi.yaml > base.yaml
$ oasdiff breaking base.yaml contract/bookstore.openapi.yaml --fail-on ERR; echo "exit code: $?"
2 changes: 0 error, 2 warning, 0 info
warning [request-parameter-removed] at contract/bookstore.openapi.yaml
in API GET /books
deleted the `query` request parameter `maxPrice`
This is a warning because some clients may return an error when receiving an unexpected parameter. It is recommended to deprecate the parameter first.
warning [request-parameter-removed] at contract/bookstore.openapi.yaml
in API GET /books
deleted the `query` request parameter `minPrice`
This is a warning because some clients may return an error when receiving an unexpected parameter. It is recommended to deprecate the parameter first.
exit code: 0
oasdiff sees the removed parameter, but as a warning; with --fail-on ERR the build passes. The tool assumes that a server ignores a parameter it does not know. In Bookstore that assumption is wrong: since post 05 an unknown parameter gets a 400, so every client that sends minPrice will get an error tomorrow. The tool's severity levels are tuned for a general API; if your contract's rules differ, you have to pick the threshold to match. In CI we set the threshold to --fail-on WARN:
$ oasdiff breaking base.yaml contract/bookstore.openapi.yaml --fail-on WARN --format singleline; echo "exit code: $?"
2 changes: 0 error, 2 warning, 0 info
warning at contract/bookstore.openapi.yaml, in API GET /books deleted the `query` request parameter `maxPrice` [request-parameter-removed]. This is a warning because some clients may return an error when receiving an unexpected parameter. It is recommended to deprecate the parameter first.
warning at contract/bookstore.openapi.yaml, in API GET /books deleted the `query` request parameter `minPrice` [request-parameter-removed]. This is a warning because some clients may return an error when receiving an unexpected parameter. It is recommended to deprecate the parameter first.
exit code: 1
oasdiff's suggestion is also the right path: add the new names, mark the old ones deprecated, and remove them once usage ends, as in post 06. These two sections also answer Guess First: Spectral caught the style rule violation (after we fixed the rule) and oasdiff caught the breaking change (after we fixed the threshold). In their first form, both let the pull request through.
Drift Between Code and Contract
The known weak point of design-first is the code drifting silently away from the contract: someone adds an endpoint that is not in the contract, or an endpoint in the contract is never written. ASP.NET Core 10 can generate an OpenAPI document from the code. That document is not the contract we publish, but it is a reliable list of what the code really serves. A small Node.js script compares the operations in the two documents. The output of the first run:
$ npm run -s drift
bundling contract/bookstore.openapi.yaml...
📦 Created a bundle for contract/bookstore.openapi.yaml at contract/.bundled.json 9ms.
only in code: POST /legacy/rpc
allowed: QUERY /books (ASP.NET Core 10 generates OpenAPI 3.1.1, which has no query operation)
27 operations in the contract, 27 in the code, 1 drifting
The old RPC endpoint that has been in the code since the first post was never in the contract. In post 06 we added the Deprecation and Sunset headers to it, but someone reading the contract did not know it existed. Undocumented, forgotten endpoints (shadow APIs) are a security risk as well; keeping the API inventory with OpenAPI is covered in the API Security series post on misconfiguration and inventory. The fix was to add the endpoint to the contract with deprecated: true; the new lint rule then asked it for a description. QUERY is a known difference: the generated document is 3.1.1 and cannot describe QUERY, so the script allows it, with the reason.
$ curl -s http://127.0.0.1:52318/openapi/v1.json | jq -r .openapi
3.1.1
$ npm run -s drift
allowed: QUERY /books (ASP.NET Core 10 generates OpenAPI 3.1.1, which has no query operation)
28 operations in the contract, 27 in the code, 0 drifting
A Mock Server and a Client from the Contract
The most tangible benefit of a contract is that it can be used before any code is written. Prism reads the OpenAPI document and starts a mock server that returns, for each operation, the examples in the contract or values generated from the schema (npm run mock). You can also pick the status code you want with the Prefer: code=404 header. On the first try that request got a response with status 404 but a 422 stock error in its body: every error response used one shared Problem response, and Prism picked the first example in it, the stock error. A mock says what the examples in the contract say. We took the examples out of the shared response and defined responses named NotFound and ValidationFailed with their own examples:
$ curl -s -i "http://127.0.0.1:52328/books/9780441172719?api-version=2026-11-15" | grep -E '^(HTTP|Content-type)|^\{'
HTTP/1.1 200 OK
Content-type: application/json
{"isbn":"9780441172719","title":"Dune","authors":["Frank Herbert"],"category":"science-fiction","price":{"amount":489,"currency":"TRY"},"inStock":true}
$ curl -s -i -H "Prefer: code=404" "http://127.0.0.1:52328/books/9780441172719" | grep -E '^HTTP|^\{'
HTTP/1.1 404 Not Found
{"type":"https://api.example.com/problems/not-found","title":"Not found","status":404,"detail":"Nothing exists at /books/9780000000002.","instance":"/books/9780000000002","traceId":"00-6f1c2a9e0b4d4e8f9a7c3b2d1e0f4a5b-1a2b3c4d5e6f7a8b-00"}
$ curl -s -X QUERY -H "Content-Type: application/json" -d '{"isbns":["9780441172719"]}' http://127.0.0.1:52328/books | jq -c '{status, title}'
{"status":405,"title":"Route resolved, but no method matched"}
A mock is not a test server: there are no business rules, stock does not go down, and the ISBN in the 404's body is not the ISBN in the request. Its purpose is to let the client team see the shapes and the error paths early. Postman's mock server is covered in the API Tools series post "Postman in Depth: Collections, Environments, Variable Scopes, Scripts, Tests, Mock Servers and the CLI".
On the Angular side, openapi-typescript generates only TypeScript types from the contract; Angular's HttpClient makes the HTTP call. So neither a runtime library nor a generated service class ends up in the bundle. In the generated Book type, author is marked with a @deprecated JSDoc tag and appears struck through in the editor; OrderStatus is string, because the contract defines it as an open list. The tolerant reader rule from post 06 has made it into the type too. The catalog service:
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import type { components, operations } from './api/bookstore';
// Types generated from contract/bookstore.openapi.yaml (npm run generate:client); never edit them by hand.
export type Book = components['schemas']['Book'];
export type BookList = components['schemas']['BookList'];
export type ListBooksQuery = Omit<NonNullable<operations['listBooks']['parameters']['query']>, 'api-version'>;
@Injectable({ providedIn: 'root' })
export class CatalogService {
private readonly http = inject(HttpClient);
// Parameter names and types come from the contract: a misspelled filter does not compile.
listBooks(query: ListBooksQuery = {}): Observable<BookList> {
let params = new HttpParams().set('api-version', '2026-11-15');
for (const [name, value] of Object.entries(query)) {
if (value !== undefined) {
params = params.set(name, String(value));
}
}
return this.http.get<BookList>('/books', { params });
}
}
$ npx tsc -p tsconfig.json --noEmit
src/catalog.usage.ts(5,45): error TS2561: Object literal may only specify known properties, but 'catgory' does not exist in type 'ListBooksQuery'. Did you mean to write 'category'?
The catgory typo from post 05 is caught by the compiler before it ever reaches the API. Two notes: openapi-typescript 7.13 ships with a validator that does not know OpenAPI 3.2 and treats the query operation as an error; we turned that check off with a config file, since Redocly CLI already does the real validation of the contract. The tool's TypeScript peer dependency is also 5.x; because the Angular project uses TypeScript 6.0, the generator is installed in the repository root together with the contract tools, and only the .ts file it generates goes into the Angular project.
Everything Together in CI
Four checks run on every pull request: lint, breaking changes against the target branch, freshness of the generated client and drift. The GitHub Actions workflow:
# Runs on every pull request that touches the contract or the API (style guide BS-xx, ADR-0007).
name: contract
on:
pull_request:
paths: ["contract/**", "src/**", ".spectral.yaml", "redocly.yaml", "package*.json"]
jobs:
contract:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-node@v7
with:
node-version: 24
- uses: actions/setup-dotnet@v6
with:
global-json-file: global.json
- run: npm ci
# 1. Style guide rules (Redocly recommended + Spectral BS-xx rules).
- run: npm run lint:contract
# 2. Breaking changes against the target branch. WARN fails too: unknown query
# parameters are 400 here (BS-35), so a removed parameter breaks clients.
- name: Breaking changes
run: |
curl -fsSL https://github.com/oasdiff/oasdiff/releases/download/v1.37.0/oasdiff_1.37.0_linux_amd64.tar.gz | tar -xz oasdiff
git show "origin/${{ github.base_ref }}:contract/bookstore.openapi.yaml" > base.yaml
./oasdiff breaking base.yaml contract/bookstore.openapi.yaml --fail-on WARN --format githubactions
# 3. The generated client must be up to date with the contract.
- run: npm run generate:client && git diff --exit-code clients/angular-snippets/src/api
# 4. Code and contract describe the same operations.
- name: Drift between code and contract
run: |
dotnet run --project src/Bookstore.Api --urls http://127.0.0.1:52318 &
for i in $(seq 1 60); do curl -fs http://127.0.0.1:52318/openapi/v1.json > /dev/null && break; sleep 1; done
npm run drift
We validated the workflow with actionlint and ran its steps one by one locally. --format githubactions makes oasdiff post its findings as annotations next to the relevant line in the pull request. The freshness of the generated client is checked with git diff --exit-code: if someone changes the contract and forgets to regenerate the client, the build breaks.
In ASP.NET Core
For the generated document, the Microsoft.AspNetCore.OpenApi package (10.0.15), AddOpenApi() and a MapOpenApi() that is only enabled in Development are enough; the document is at /openapi/v1.json. For us this document is a test artifact: it lacks the descriptions, examples and error responses of the contract, but it knows exactly which method really exists on which path. Adding metadata to endpoints with WithSummary, Produces and ProducesProblem, showing the document with Scalar, and OpenAPI 3.2 support in .NET 11 are covered in the ASP.NET Core Web API series posts "What's New in .NET 9: Built-in OpenAPI, Scalar and HybridCache in ASP.NET Core", "What's New in .NET 10: AddValidation, OpenAPI 3.1 and Server-Sent Events in ASP.NET Core" and "What's New in .NET 11: Async Validation, OpenAPI 3.2 and C# 15 Union Types in ASP.NET Core".
Common Pitfalls
Treating OpenAPI as documentation only. If the document is only shown to people, it drifts from the code and nobody notices. A contract stays alive when it is a build input, with lint, diff, mock, client and drift checks.
Editing the generated client by hand. A manual fix is lost on the next generation or stops the generation. If the contract is wrong, fix the contract; if the tool falls short, configure the generator.
Trusting a lint rule without testing it. A rule whose selector matches nothing never fails, and that does not mean the rule passes.
Not questioning a tool's default severity. A change oasdiff calls a "warning" may be a breaking change under your contract's rules.
Shared examples. When all error responses share a single set of examples, the mock and the documentation show the wrong status code next to the wrong body.
A Small Experiment
★☆☆ Show that the bookstore-deprecated-explained rule works with a small OpenAPI document that breaks it. Does the rule fail on both operations and properties?
★★☆ Rewrite Emre's pull request so that it breaks nothing: add priceFrom/priceTo and mark minPrice/maxPrice as deprecated. What does oasdiff say? What should the API do when both pairs arrive together?
★★★ The drift script only compares operations. Extend it to compare the response status codes in the generated document too. Which differences are real drift, and which are only missing metadata in ASP.NET Core?
In the last post we move to how these tools are used in a team: an API review checklist, keeping decisions in ADRs, an API inventory and the end-to-end design of Bookstore's institutional partner API.