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.

Lesson 0713 min read#api#openapi#testing#documentation

On this page

  1. Words used in this lesson
  2. What the file looks like
  3. A real file for the orders API
  4. Step 1: is the file valid?
  5. Step 2: lint, because "valid" is not "good"
  6. Step 3: docs for free
  7. Step 4: check requests and responses at runtime
  8. Step 5: a mock server from the contract
  9. Spec-first or code-first?
  10. Code generation and breaking-change checks
  11. A review checklist for your own OpenAPI file
  12. Try it
  13. Interview questions
  14. Key takeaways
  15. 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.
  • $ref is 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:

PartWhat it holds
openapiThe spec version, such as 3.1.0.
infoTitle, version of your API, description.
serversBase URLs, such as https://api.example.com/v1.
pathsEvery URL and the methods on it. This is the heart of the file.
componentsReusable parts: schemas, responses, parameters, security schemes.
securityWhich security scheme applies to which operations.
webhooksCalls 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.

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

  • operationId is 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 under parameters with in: path and required: 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 keyword nullable: true is gone. This is one of the visible results of 3.1 using JSON Schema 2020-12 as its schema language.
  • additionalProperties: false on NewOrder means unknown fields are errors. That is the validation behaviour that stops mass assignment (lesson 6).
  • $ref keeps error shapes in one place. All four error responses use the Problem schema and the media type application/problem+json, which is the RFC 9457 problem details format from lesson 2.
  • Headers can be described too: Location on 201, and Retry-After on 429. Clients depend on these.
  • security at the top applies bearer auth to every operation. You can override per operation (security: [] makes an endpoint public).
  • webhooks describes a request that you send. Closely related to callbacks, 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).

JavaScript
// 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]); }}
TEXT
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.

TEXT
$ 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:

YAML
openapi: 3.1.0info: { title: T, version: "1" }paths:  /orders/{orderId}:    get:      responses:        "200": { description: ok }

I ran both tools on it:

TEXT
swagger-parser: valid

And Redocly reported these rules (names only, the messages are longer):

TEXT
Error   no-empty-serversError   operation-summaryError   path-parameters-definedError   security-definedWarning info-licenseWarning operation-operationIdWarning operation-4xx-response

This 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:

TEXT
$ 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 description that says units and limits ("amount in cents", not just "amount").
  • Add example values to request bodies and one example per error.
  • Group operations with tags so 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-Key returns 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:

  1. Request validation. Run the incoming body through the schema for that operation. Reject with 400 and 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.
  2. 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:

JavaScript
// 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:

JavaScript
// 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:

JavaScript
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:

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

JavaScript
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:

TEXT
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-firstCode-first
StartWrite the YAML, review it with the team, then build.Write the code (with decorators or schemas), generate the YAML.
GoodDesign 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.
BadThe 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 whenThere 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 stable operationIds 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, 429 where they apply).
  • Every parameter and field has a description, units, and an example.
  • Request schemas have required, limits, and additionalProperties: false where unknown fields would be a bug.
  • Response schemas list required fields (so clients can rely on them) and use enum for fixed sets.
  • Errors use one shared Problem schema and application/problem+json.
  • Security is declared globally, and public endpoints say security: [] on purpose.
  • Rate limit, Retry-After, Location and Idempotency-Key headers are described.
  • Webhooks are in the webhooks section, with the signature headers noted in prose.
  • info.version follows your versioning policy. Deprecated operations are marked deprecated: true.
  • CI runs: validate, lint, contract tests, and a diff against the last release.

Try it

  1. Write a minimal OpenAPI 3.1 file for a GET /health endpoint that returns { "status": "ok" }. Validate it with swagger-parser or npx @redocly/cli lint.
  2. Add a DELETE /orders/{orderId} operation with 204 and 404 responses. Forget to declare the path parameter. Which tool catches it, and which does not?
  3. In the contract test, change the server so created_at is 1700000000 (a number). Which line of output do you expect?
  4. Make the Order schema stricter by adding additionalProperties: false. What happens to a server that adds a new field notes to its responses? Is that good or bad for your clients?
  5. Mark GET /orders as deprecated: true and add a Sunset header description (lesson 4). Where in the file does each go?

Answers

  1. A file with openapi: 3.1.0, info, a servers entry, and paths: /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.
  2. The Redocly linter reports path-parameters-defined. swagger-parser still calls the file valid, as shown above.
  3. /created_at must be string. The check against the format: date-time fails because the type is wrong, and Ajv reports the type error.
  4. 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. Keep additionalProperties: false for requests (to catch mistakes) and leave it open for responses.
  5. deprecated: true goes on the operation object. The Sunset header is described under responses.200.headers with 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 components and 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

Part of the study guide

Related series