API Design
Documenting APIs: OpenAPI, Contracts and Testing
Write an OpenAPI file, generate docs, validate requests and responses against it, contract tests and mock servers.
On this page
- Words used in this lesson
- What the file looks like
- A real file for the orders API
- Step 1: is the file valid?
- Step 2: lint, because "valid" is not "good"
- Step 3: docs for free
- Step 4: check requests and responses at runtime
- Step 5: a mock server from the contract
- Spec-first or code-first?
- Code generation and breaking-change checks
- A review checklist for your own OpenAPI file
- Try it
- Interview questions
- Key takeaways
- What's next
In one line: an OpenAPI file is a machine-readable contract for your API. One YAML file can give you docs, request checks, tests, a mock server and client code, but only if you keep it true: lint it, and test the real server against it, so the file and the code cannot quietly drift apart.
Picture a new developer joining a team. They ask, "What does POST /orders need? What does it return? What if I send a bad item?" Where do they look? A wiki page written two years ago? The code? A chat message?
Every one of those can be wrong. A contract file can be checked by a program. That is the value of OpenAPI: it turns the question "what does the API do?" into a file that tools can read, compare against the real server, and fail a build over.
This is lesson 7 of the API design series. In lesson 2 we shaped JSON and errors, in lesson 5 we added idempotency headers and webhooks, and in lesson 6 we added security rules. This lesson writes all of that down once. The next, lesson 8, looks at GraphQL and gRPC, which carry their contract in a different way.
What I ran / checked. I ran everything on Linux with Node.js v22.22.0. Tools from npm: @apidevtools/swagger-parser 13.1.0, @redocly/cli 2.62.1, ajv (JSON Schema validator) with ajv-formats, and yaml. I pasted real output. Linting output lists change between versions of Redocly, so your warnings may differ. Sources I read: the OpenAPI Specification version 3.1.0 text (from the OAI/OpenAPI-Specification repository on GitHub, versions/3.1.0.md), and the OWASP REST Security Cheat Sheet (the parts about content types and error handling). Could not reach: swagger.io and the Redocly docs site (blocked), so tool behaviour below is what I observed by running the tools, not what I read in their docs. Not run: Prism (mock server), openapi-generator (client code) and oasdiff (breaking change detection). I describe those from general knowledge and say so. I did not verify any GitHub star counts.
Words used in this lesson
- OpenAPI Specification (OAS) is the standard format for describing an HTTP API in YAML or JSON. Version 3.1 is the one in this lesson.
- Swagger is the old name of the specification before it was renamed OpenAPI. "Swagger UI" is still a popular docs viewer.
- Contract is the agreed description of what requests are valid and what responses come back.
- Schema is a description of the shape of data: fields, types, which are required.
- JSON Schema is the language used to write schemas. OpenAPI 3.1 uses JSON Schema Draft 2020-12 as its schema language.
$refis a pointer inside the file: "use the thing defined over there".- Linting means checking a file against style and quality rules, not only whether it is valid.
- Contract test is a test that calls the real server and checks the response against the contract.
- Mock server is a fake server that answers from the contract, so others can work before the real one exists.
- Spec-first means you write the OpenAPI file first and build the code to match. Code-first means you write the code and generate the file.
- Drift is when the file and the real API stop matching.
What the file looks like
Here is the top of a tiny OpenAPI 3.1 document. The full file is in the next section. An OpenAPI document has a few main parts:
| Part | What it holds |
|---|---|
openapi | The spec version, such as 3.1.0. |
info | Title, version of your API, description. |
servers | Base URLs, such as https://api.example.com/v1. |
paths | Every URL and the methods on it. This is the heart of the file. |
components | Reusable parts: schemas, responses, parameters, security schemes. |
security | Which security scheme applies to which operations. |
webhooks | Calls your server sends out (new in 3.1). |
The spec says a valid document must contain at least one of paths, components or webhooks. Under each path you list HTTP methods. Each method is an operation with parameters, an optional request body, and responses keyed by status code. The spec versions itself as major.minor.patch; the patch number only fixes wording in the spec, so tools that support 3.1 should work with any 3.1.x.
The idea to hold on to: describe each thing once in components, then point at it with $ref. One Order schema is used by the GET response, the POST response and the webhook. Change it in one place and everything follows.
A real file for the orders API
I wrote a file for the orders API we have been designing: list orders with cursor pagination (lesson 3), create an order with an idempotency key (lesson 5), get one order, a bearer token scheme (lesson 6), problem-details errors (lesson 2), and an order.paid webhook. Here it is, in full.
openapi: 3.1.0info: title: Orders API version: 1.0.0 description: A small API for orders, used to learn OpenAPI.servers: - url: https://api.example.com/v1security: - bearerAuth: []paths: /orders: get: operationId: listOrders summary: List orders tags: [orders] parameters: - name: limit in: query schema: { type: integer, minimum: 1, maximum: 100, default: 20 } - name: cursor in: query schema: { type: string } responses: "200": description: A page of orders. content: application/json: schema: type: object required: [data, next_cursor] properties: data: type: array items: { $ref: "#/components/schemas/Order" } next_cursor: type: [string, "null"] "401": { $ref: "#/components/responses/Unauthorized" } post: operationId: createOrder summary: Create an order tags: [orders] parameters: - name: Idempotency-Key in: header required: true schema: { type: string, minLength: 8, maxLength: 64 } requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/NewOrder" } example: { customer_id: "cus_1", items: [{ sku: "A1", quantity: 2 }] } responses: "201": description: Order created. headers: Location: schema: { type: string } content: application/json: schema: { $ref: "#/components/schemas/Order" } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/Unauthorized" } "429": { $ref: "#/components/responses/TooManyRequests" } /orders/{orderId}: get: operationId: getOrder summary: Get one order tags: [orders] parameters: - name: orderId in: path required: true schema: { type: string } responses: "200": description: The order. content: application/json: schema: { $ref: "#/components/schemas/Order" } "401": { $ref: "#/components/responses/Unauthorized" } "404": { $ref: "#/components/responses/NotFound" }webhooks: order.paid: post: summary: Sent when an order is paid requestBody: content: application/json: schema: { $ref: "#/components/schemas/Order" } responses: "200": { description: Acknowledged. }components: securitySchemes: bearerAuth: type: http scheme: bearer bearerFormat: JWT schemas: NewOrder: type: object additionalProperties: false required: [customer_id, items] properties: customer_id: { type: string } items: type: array minItems: 1 items: type: object required: [sku, quantity] properties: sku: { type: string } quantity: { type: integer, minimum: 1 } Order: type: object required: [id, status, total_cents, currency, created_at] properties: id: { type: string, examples: [ord_101] } status: { type: string, enum: [pending, paid, shipped, cancelled] } total_cents: { type: integer, minimum: 0 } currency: { type: string, pattern: "^[A-Z]{3}$" } created_at: { type: string, format: date-time } Problem: type: object required: [type, title, status] properties: type: { type: string } title: { type: string } status: { type: integer } detail: { type: string } responses: BadRequest: description: The request was not valid. content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } Unauthorized: description: Missing or invalid token. content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } NotFound: description: No such order. content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" } TooManyRequests: description: Rate limit exceeded. headers: Retry-After: schema: { type: integer } content: application/problem+json: schema: { $ref: "#/components/schemas/Problem" }Let me point out the parts that carry the most meaning.
operationIdis a unique name for each operation (listOrders,createOrder). Code generators use it to name functions:client.createOrder(...). Keep it stable. The spec says it must be unique across the whole document, and it is case-sensitive.- Path parameters like
{orderId}must be declared underparameterswithin: pathandrequired: true. A forgotten declaration is a very common mistake, and we will see a tool catch it. - Nullable values. In OpenAPI 3.1 you write
type: [string, "null"](a JSON Schema list of types). The old 3.0 keywordnullable: trueis gone. This is one of the visible results of 3.1 using JSON Schema 2020-12 as its schema language. additionalProperties: falseonNewOrdermeans unknown fields are errors. That is the validation behaviour that stops mass assignment (lesson 6).$refkeeps error shapes in one place. All four error responses use theProblemschema and the media typeapplication/problem+json, which is the RFC 9457 problem details format from lesson 2.- Headers can be described too:
Locationon201, andRetry-Afteron429. Clients depend on these. securityat the top applies bearer auth to every operation. You can override per operation (security: []makes an endpoint public).webhooksdescribes a request that you send. Closely related tocallbacks, the spec says, but webhooks are registered out of band, such as in a dashboard.
The OpenAPI file describes the shape of an API, not its rules. It can say that quantity is an integer of at least 1. It cannot say "the sku must exist" or "the customer must not be blocked". Those rules live in your code and your tests. Write them in the description text so humans know.
Step 1: is the file valid?
First check: does the file follow the OpenAPI format? I used @apidevtools/swagger-parser, a popular parser for JavaScript. I tested the good file and a copy where I deliberately misspelled one $ref (NewOrder became NewOrdr).
// validate.mjsimport SwaggerParser from "@apidevtools/swagger-parser";for (const f of ["openapi.yaml", "broken.yaml"]) { try { const api = await SwaggerParser.validate(f); console.log(f, "-> valid. paths:", Object.keys(api.paths).join(", ")); } catch (e) { console.log(f, "-> INVALID:", e.message.split("\n")[0]); }}openapi.yaml -> valid. paths: /orders, /orders/{orderId}broken.yaml -> INVALID: Missing $ref pointer "#/components/schemas/NewOrdr". Token "NewOrdr" does not exist.A typo in a reference is caught at once. This is the cheapest check and belongs in CI.
Step 2: lint, because "valid" is not "good"
A valid file can still be a poor contract: missing summaries, no error responses, undeclared path parameters. A linter applies quality rules. I used the Redocly CLI.
$ npx redocly lint openapi.yaml...[1] openapi.yaml:2:1 at #/infoWarning was generated by the info-license rule.[2] openapi.yaml:7:10 at #/servers/0/urlWarning was generated by the no-server-example.com rule.[3] openapi.yaml:85:5 at #/webhooks/order.paid/post/operationIdWarning was generated by the operation-operationId rule.openapi.yaml: validated in 104msWoohoo! Your API description is valid.You have 3 warnings.My good file passes with three warnings: no license in info, example.com as the server, and a missing operationId on the webhook. These are reasonable to fix or to switch off in your config.
Now a deliberately weak file. This one is valid OpenAPI, but a very poor description:
openapi: 3.1.0info: { title: T, version: "1" }paths: /orders/{orderId}: get: responses: "200": { description: ok }I ran both tools on it:
swagger-parser: validAnd Redocly reported these rules (names only, the messages are longer):
Error no-empty-serversError operation-summaryError path-parameters-definedError security-definedWarning info-licenseWarning operation-operationIdWarning operation-4xx-responseThis is a good lesson in tools. The parser said valid, because the file follows the format. The linter found a real hole: path-parameters-defined means the URL has {orderId} but the operation never declares it. That would produce broken docs and broken generated clients. Use both: a validator for correctness and a linter for quality.
Do not "fix" lint errors by deleting the rule. First ask what the rule protects. security-defined protects you from an endpoint that silently has no auth. If an endpoint is meant to be public, say so explicitly with security: [].
Step 3: docs for free
Now the payoff. From the same file, one command produced a single-page documentation site:
$ npx redocly build-docs openapi.yaml -o docs.htmlPrerendering docsbundled successfully in: docs.html (78 KiB) [5ms].I did not open it in a browser, since I was working in a terminal, so I can only tell you it built a 78 KiB HTML file without errors. Redocly's viewer, Swagger UI and Scalar are the common tools for turning the file into docs with a "try it" console. Whatever you choose, the point is the same: write the contract once, and the docs cannot disagree with it. Write good summary, description and example text. Docs made from empty descriptions are empty docs.
Tips for docs that people enjoy:
- Give every parameter and field a
descriptionthat says units and limits ("amount in cents", not just "amount"). - Add
examplevalues to request bodies and one example per error. - Group operations with
tagsso the docs have sections. - Put a "getting started" paragraph in
info.description: how to get a key, a first request, rate limits (lesson 5), and the versioning policy (lesson 4). - Document non-obvious behaviour in words: "Retrying with the same
Idempotency-Keyreturns the original response for 24 hours."
Step 4: check requests and responses at runtime
A schema in the file is also a validator. Because OpenAPI 3.1 schemas are JSON Schema 2020-12, any JSON Schema library can run them. I used ajv. Two uses:
- Request validation. Run the incoming body through the schema for that operation. Reject with
400and a problem details body when it fails. This is the "validate at the edge" idea from the Backend series, lesson 3, but driven by the contract. - Contract tests. In tests, call the server and run the response through the response schema. This is the check that catches drift.
Here is the setup. SwaggerParser.dereference replaces every $ref with the real schema, so Ajv gets self-contained schemas:
// contract.mjs (the setup)import Ajv2020 from "ajv/dist/2020.js";import addFormats from "ajv-formats";import SwaggerParser from "@apidevtools/swagger-parser";const api = await SwaggerParser.dereference("openapi.yaml");const ajv = new Ajv2020({ strict: false, allErrors: true }); addFormats(ajv);const vNewOrder = ajv.compile(api.paths["/orders"].post.requestBody.content["application/json"].schema);const vOrder = ajv.compile(api.paths["/orders/{orderId}"].get.responses["200"].content["application/json"].schema);const vProblem = ajv.compile(api.components.schemas.Problem);The server under test validates incoming bodies with vNewOrder. A flag called drift switches the server to return a response that no longer matches the file, simulating a developer who changed the code and forgot the spec:
// inside POST /ordersif (!vNewOrder(body)) return send(res, 400, { type: "about:blank", title: "Validation failed", status: 400, detail: ajv.errorsText(vNewOrder.errors) }, "application/problem+json");return send(res, 201, drift ? { id: "ord_1", status: "PAID", total: 4200, currency: "usd", created_at: "yesterday" } // oops : { id: "ord_1", status: "pending", total_cents: 4200, currency: "USD", created_at: new Date().toISOString() });The contract test calls the server and checks both the status and the shape:
async function contractTest(label, path, init, validate, expectStatus) { const r = await fetch(base + path, init); const body = await r.json(); const okStatus = r.status === expectStatus; const okShape = validate(body); console.log(`${okStatus && okShape ? "PASS" : "FAIL"} ${label} (status ${r.status})`); if (!okShape) for (const e of validate.errors) console.log(" ", e.instancePath || "(root)", e.message);}Real output:
--- server follows the specPASS GET /orders/ord_1 (status 200)PASS POST /orders (valid) (status 201)PASS POST /orders (empty items) (status 400)PASS POST /orders (extra field) (status 400)--- someone changes the code and forgets the specFAIL POST /orders (valid) (status 201) (root) must have required property 'total_cents' /status must be equal to one of the allowed values /currency must match pattern "^[A-Z]{3}$" /created_at must match format "date-time"Read the second half carefully. The status code was still 201, so a test that only checks status would pass. The contract test found four breaks: a renamed field (total instead of total_cents), a status in capital letters that is not in the enum, a lower-case currency, and a date that is not a date. Each one would break real clients. This is exactly the kind of bug that slips into production when "the docs" and "the code" are separate things.
The first half also shows the contract working as a request validator: an empty items array (violates minItems: 1) and an unknown field coupon (violates additionalProperties: false) both got 400.
Passing a contract test does not mean the API is right. It means the API matches the file. If the file is wrong or too loose (for example type: object with no properties), every response passes. Write schemas with required, enum, pattern, minimum and additionalProperties, not just type.
Where to run contract tests
- In CI, against a local or test instance, on every pull request.
- Against staging, after deploys (a "smoke" run of a few operations).
- From the client side, as a consumer test: the front-end team checks that the examples in the contract still parse in their code.
There are tools that read the file and generate such tests for you (Schemathesis, Dredd and others exist). I did not run them here, so I only name them as things to look up. The hand-written version above is about forty lines and works well to start.
Step 5: a mock server from the contract
A mock server answers requests with made-up data that matches the contract. The front-end team can build against it while the back end is still being written. Tools like Prism do this. I did not install Prism; instead I wrote a ~25 line mock to show the idea. It picks the first 2xx response and builds a value from the schema, using example/examples, enum, and minimum where they exist.
function example(schema) { if (schema.example !== undefined) return schema.example; if (schema.examples) return schema.examples[0]; if (schema.enum) return schema.enum[0]; const t = Array.isArray(schema.type) ? schema.type.find(x => x !== "null") : schema.type; if (t === "object") return Object.fromEntries(Object.entries(schema.properties ?? {}).map(([k, v]) => [k, example(v)])); if (t === "array") return [example(schema.items)]; if (t === "integer") return schema.minimum ?? 0; if (t === "string") return schema.format === "date-time" ? "2026-01-01T00:00:00Z" : schema.pattern ? "USD" : "string"; return null;}Real output when I called the mock:
GET /orders/ord_9 200 {"id":"ord_101","status":"pending","total_cents":0,"currency":"USD","created_at":"2026-01-01T00:00:00Z"}GET /orders?limit=2 200 {"data":[{"id":"ord_101","status":"pending","total_cents":0,"currency":"USD","created_at":"2026-01-01T00:00:00Z"}],"next_cursor":"string"}POST /orders 201 {"id":"ord_101","status":"pending","total_cents":0,"currency":"USD","created_at":"2026-01-01T00:00:00Z"}Two honest notes. The id is ord_101 because I put an examples value in the schema; the mock ignored the ord_9 in the URL. And the next_cursor came out as the word "string", which shows why good examples in the file matter: the mock is only as useful as the examples you wrote.
Mock servers are most useful in three situations: the front-end team starts before the back end exists, you want stable data in front-end tests, and you want a sandbox for outside developers.
Spec-first or code-first?
| Spec-first | Code-first | |
|---|---|---|
| Start | Write the YAML, review it with the team, then build. | Write the code (with decorators or schemas), generate the YAML. |
| Good | Design is reviewed before code. Front end and back end work in parallel. The contract is a deliberate document. | No second file to keep in sync by hand. Fast for small teams. |
| Bad | The code can drift from the file unless you test it. | Generated files can be ugly and accidental: every internal change is a public API change. |
| Works best when | There are outside consumers or several teams. | One team owns both sides. |
My view for a public or cross-team API: spec-first, with a contract test in CI. For an internal API with one consumer, code-first with a generated file is fine, as long as someone reads the generated file in code review. For TypeScript teams that control both ends, tools such as tRPC skip the file entirely; see Full-Stack lesson 30, which compares tRPC with REST plus OpenAPI.
Code generation and breaking-change checks
Two more benefits, described here from general knowledge because I did not run these tools:
- Generated clients and server stubs. Tools like openapi-generator and several TypeScript generators read the file and produce a typed client (
client.createOrder({...})). This is why stableoperationIds and accurate schemas matter. Wrong types in the file mean wrong types in every client. - Breaking-change detection. A tool such as oasdiff compares two versions of the file and flags changes that break clients: a removed field, a new required request field, a narrowed enum, a changed type. Run it in CI between the main branch and the pull request. This turns the compatibility rules of lesson 4 into an automatic check. Whether you adopt this tool or another, the habit is what counts: never merge an API change that nobody has diffed.
A review checklist for your own OpenAPI file
- Every operation has
operationId,summary, and at least one error response (401,404,429where they apply). - Every parameter and field has a
description, units, and anexample. - Request schemas have
required, limits, andadditionalProperties: falsewhere unknown fields would be a bug. - Response schemas list
requiredfields (so clients can rely on them) and useenumfor fixed sets. - Errors use one shared
Problemschema andapplication/problem+json. - Security is declared globally, and public endpoints say
security: []on purpose. - Rate limit,
Retry-After,LocationandIdempotency-Keyheaders are described. - Webhooks are in the
webhookssection, with the signature headers noted in prose. info.versionfollows your versioning policy. Deprecated operations are markeddeprecated: true.- CI runs: validate, lint, contract tests, and a diff against the last release.
Try it
- Write a minimal OpenAPI 3.1 file for a
GET /healthendpoint that returns{ "status": "ok" }. Validate it withswagger-parserornpx @redocly/cli lint. - Add a
DELETE /orders/{orderId}operation with204and404responses. Forget to declare the path parameter. Which tool catches it, and which does not? - In the contract test, change the server so
created_atis1700000000(a number). Which line of output do you expect? - Make the
Orderschema stricter by addingadditionalProperties: false. What happens to a server that adds a new fieldnotesto its responses? Is that good or bad for your clients? - Mark
GET /ordersasdeprecated: trueand add aSunsetheader description (lesson 4). Where in the file does each go?
Answers
- A file with
openapi: 3.1.0,info, aserversentry, andpaths: /health: get: responses: "200": ...with a schema{ type: object, required: [status], properties: { status: { type: string, enum: [ok] } } }. Both tools should accept it. The linter may ask for a summary, a license and a 4xx response. - The Redocly linter reports
path-parameters-defined.swagger-parserstill calls the file valid, as shown above. /created_at must be string. The check against theformat: date-timefails because the type is wrong, and Ajv reports the type error.- The contract test fails on the new field:
must NOT have additional properties. For responses this is usually too strict, because adding fields is a safe change under lesson 4's rules and clients should ignore unknown fields. KeepadditionalProperties: falsefor requests (to catch mistakes) and leave it open for responses. deprecated: truegoes on the operation object. TheSunsetheader is described underresponses.200.headerswith a schema of type string and a description of the date format and meaning.
Interview questions
What is OpenAPI and why would a team use it? (Must know)
OpenAPI is a standard, machine-readable format for describing an HTTP API: paths, methods, parameters, bodies, responses, security. A team uses it because one file can drive docs, request validation, contract tests, mock servers, client SDKs and breaking-change checks. It also moves design discussion to a reviewable document before code is written. The main risk is drift, so I would add a contract test in CI.
What is the difference between OpenAPI and Swagger? (Trap)
Swagger is the former name of the specification. Since version 3.0 the specification is called OpenAPI. "Swagger" now mostly refers to a set of tools such as Swagger UI and Swagger Editor. If someone says "Swagger file" they usually mean an OpenAPI or older Swagger 2.0 document, so I ask which version.
What changed in OpenAPI 3.1 compared with 3.0? (Senior signal)
The big change is that schemas are now a superset of JSON Schema Draft 2020-12, so any JSON Schema tool can validate them. Nullable is written type: [string, "null"] instead of nullable: true. 3.1 also adds a top-level webhooks section for calls the API provider makes. A document needs at least one of paths, components or webhooks. Tool support for 3.1 was slower to arrive than for 3.0, so I check my tools.
Spec-first or code-first? How do you decide? (Senior signal)
I use spec-first when there are outside consumers or several teams, because the design gets reviewed and teams can work in parallel with a mock. I use code-first when one team owns both sides and speed matters, but I still read the generated file in review, because every accidental change becomes part of the public contract. In both cases I run contract tests in CI, since otherwise the file and the code will drift.
Your OpenAPI file is valid but a developer says the docs are bad. What do you check? (Senior signal)
Valid only means the format is right. I run a linter for missing summaries, descriptions, error responses and undeclared parameters. In my test, a file with an undeclared path parameter was "valid" for the parser but failed the linter's path-parameters-defined rule. Then I check for missing examples, unclear units, and undocumented behaviour such as idempotency and rate limits.
What is a contract test and how does it differ from a unit test? (Must know)
A contract test calls the real running API and checks that status codes, headers and body shapes match the contract. A unit test checks one function's logic. In my demo, the drifted server still returned 201, so a status-only test passed, but the contract test caught a missing total_cents, a bad enum value, a lower-case currency and a bad date. Contract tests protect clients; unit tests protect internal logic.
Why use additionalProperties: false on requests but not on responses? (Trap)
On requests it rejects unknown fields, which catches typos and blocks mass assignment. On responses it would make adding a field a breaking change for the contract test, but adding a response field is normally a safe, backward-compatible change. Clients should ignore unknown fields. So I keep responses open, and I tell clients in the docs to tolerate new fields.
How can OpenAPI help prevent breaking changes? (Senior signal)
I can diff the OpenAPI file from the main branch against the pull request with a tool such as oasdiff and fail the build on changes that break clients: removed fields or endpoints, newly required request fields, narrowed enums or changed types. It automates the compatibility rules (adding is safe, removing or tightening is breaking). I have not run oasdiff in my own labs for this lesson, so I would trial it on a real file first.
How do you describe authentication and error responses in OpenAPI? (Must know)
Authentication goes in components.securitySchemes (for example an http scheme with bearer), and is applied with a top-level security list or per operation. A public endpoint sets security: [] on purpose. Errors are shared in components.responses using one Problem schema and the media type application/problem+json. Each operation lists the errors it can produce and references the shared ones.
What can OpenAPI not tell you? (Senior signal)
It describes shapes, not business rules. It cannot say that a SKU must exist, that a customer is not blocked, or that two calls must happen in a certain order. It also does not enforce itself: nothing keeps the file true unless I validate requests and run contract tests. I put such rules in the description text and in tests.
How would you let a front-end team start before the back end exists? (Must know)
I would agree the OpenAPI file first, review it, and start a mock server from it (Prism is a common choice; I wrote a tiny one for this lesson that answers from examples). The front end builds against the mock. I make sure the file has good examples, because the mock is only as good as them. When the real server is ready, the contract test proves it matches the file the front end used.
Key takeaways
- OpenAPI is a contract file: paths, operations, schemas, security, webhooks. Describe each thing once in
componentsand point at it with$ref. - Use both a validator and a linter. The validator said my weak file was valid; the linter found the undeclared path parameter.
- Docs, request validation, mocks, SDKs and breaking-change checks all come from the same file. Examples and descriptions decide how good they are.
- A contract test against the real server catches drift. My drifted server returned the right status code and four wrong fields.
- In requests, be strict (
additionalProperties: false). In responses, stay open, and tell clients to ignore unknown fields. - OpenAPI describes shape, not business rules. Spec-first suits shared APIs, code-first suits one-team APIs, and CI checks suit both.
What's next
REST and OpenAPI are not the only way to design an API. Next is Lesson 8: GraphQL and gRPC. We will build a small GraphQL server, show the N+1 problem with a real count of database calls and fix it with batching, see how a query can become far more expensive than it looks, then run a small gRPC service with typed messages, streaming, deadlines and status codes, and finish with a decision table. Practise now: take one real endpoint from a project, write its OpenAPI operation by hand, and lint it.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- Integration and API Tests · Testing From Zero
- Building an Evaluation Harness for an LLM Feature · AI Engineering
- Testing an API: Unit, Integration and Contract · Backend in Depth
Part of the study guide
Related series