Reading an OpenAPI document
Paths, operations, parameters, request bodies, responses and components, in the API's real spec.
OpenAPI is the standard format for describing an HTTP API in a file. One JSON or YAML document lists every endpoint, what it accepts and what it returns. Tools build documentation pages, client code and test cases from it, and in provider-driven contract testing it is the contract. Reading one fluently is the first skill this tutorial needs. This lesson goes through the real document this API publishes.
Where to find it
Most APIs serve their document at a well-known address. This one is at /openapi/v1.jsonsign in to see, and /swaggersign in to see shows the same document as an interactive page. The older name for the format, Swagger, survives in tool names; OpenAPI 2.0 was called Swagger 2.0, and versions 3.0 and 3.1 are the ones in use today.
At the top level, the document has a handful of sections:
{
"openapi": "3.0.4",
"info": { "title": "TestingAPIs", "version": "v1", "description": "..." },
"paths": { ... 290 paths ... },
"components": {
"schemas": { ... },
"securitySchemes": { ... }
},
"tags": [ ... ]
}| Section | Holds |
|---|---|
| openapi | Which version of the OpenAPI format the document uses: 3.0.4 here |
| info | The API's name, its own version, and a description |
| paths | Every endpoint and what it does: most of the document |
| components | Pieces defined once and referred to from many places, mainly schemas |
| tags | Groups for the documentation page, such as Products |
Paths and operations
Under paths, each key is a path template, and under it each HTTP method is an operation. Here is /api/Products/{id}, with the get operation in full:
"/api/Products/{id}": {
"get": {
"tags": ["Products"],
"summary": "Get a product by ID.",
"operationId": "Products_GetById",
"parameters": [
{
"name": "id",
"in": "path",
"description": "Product ID.",
"required": true,
"schema": { "type": "integer", "format": "int32" }
}
],
"responses": {
"200": {
"description": "The product.",
"content": {
"application/json": { "schema": { "$ref": "#/components/schemas/ProductResponse" } }
}
},
"404": {
"description": "The product does not exist.",
"content": {
"application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
}
},
"500": { "description": "Unexpected server error." }
}
},
"put": { ... },
"delete": { ... }
}{id}in the template is a path parameter: a real request has a value there, as in/api/Products/1.operationIdis a unique name for the operation. Code generators turn it into a method name, so changing it is a change consumers notice.parameterslists the inputs outside the body. Each says where it goes (in:path,query,headerorcookie), whether it's required, and its type as a schema.responsesis keyed by status code. Each response has a description and, if it has a body,content: one entry per content type, each with a schema. The 500 here has nocontent, so the document promises nothing about that body.
Request bodies, and responses that wrap the record
POST /api/Products has a requestBody with a schema per content type, pointing at Product. Its responses show a different shape from the get: the record wrapped in an object with a message.
"responses": {
"201": {
"description": "Created; returns { message, data } with the generated ID.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["message", "data"],
"properties": {
"message": { "type": "string" },
"data": { "$ref": "#/components/schemas/ProductResponse" }
},
"additionalProperties": false
}
}
}
},
"400": { "description": "title is missing.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"500": { "description": "Unexpected server error." }
}Here the schema is written inline instead of referring to components: an object with exactly two properties, both required, nothing else allowed. Lesson 3 explains each of those words.
Components and $ref
A schema used in many places is defined once under components.schemas, and other places point at it with $ref. The value is a path inside the document: #/components/schemas/ProductResponse means start at the top (#), then go to components, then schemas, then ProductResponse.
"Product": {
"type": "object",
"properties": {
"id": { "type": "integer", "format": "int32" },
"title": { "type": "string" },
"price": { "type": "number", "format": "double" },
...
},
"additionalProperties": false
},
"ProductResponse": {
"type": "object",
"required": ["id", "title", "price", "description", "category", "image", "rating", "inStock", "createdAt"],
"properties": { ... the same properties, with rating pointing at RatingResponse ... },
"additionalProperties": false,
"description": "Product as the API returns it: every property is always present."
},
"Error": {
"type": "object",
"required": ["status", "error", "message"],
"properties": {
"status": { "type": "integer", "format": "int32" },
"error": { "type": "string" },
"message": { "type": "string" }
},
"additionalProperties": false
}Notice there are two schemas for a product. Product describes what a client sends, and marks nothing required, though the API does insist on a title (lesson 5 comes back to that). ProductResponse describes what the API returns: every field, always present. Using different schemas for the two directions is common, because a server usually fills in fields itself, such as the ID and the creation time, that clients shouldn't have to send.
Finding things in code
Two small functions do most of the work in the rest of the tutorial:
// Loading an OpenAPI document and finding things in it.
import { BASE_URL, apiHeaders } from "./api.mjs";
/** Fetches the API's OpenAPI document. */
export async function loadSpec(url = `${BASE_URL}/openapi/v1.json`) {
const response = await fetch(url, { headers: apiHeaders() });
if (!response.ok) throw new Error(`Couldn't load the spec from ${url}: HTTP ${response.status}`);
return response.json();
}
/** Follows a reference such as "#/components/schemas/ProductResponse" to the part of the document it points at. */
export function resolveRef(spec, ref) {
const target = ref
.replace(/^#\//, "")
.split("/")
.reduce((node, key) => node?.[key], spec);
if (target === undefined) throw new Error(`Reference ${ref} points at nothing`);
return target;
}
/**
* Finds the operation for a real request, such as GET /api/Products/1, by matching it against the path templates
* in the spec, such as /api/Products/{id}. Literal paths win over templates with parameters, so
* /api/Products/categories matches its own entry, not /api/Products/{id}.
*/
export function findOperation(spec, method, path) {
const segments = path.split("?")[0].split("/");
const candidates = [];
for (const [template, item] of Object.entries(spec.paths)) {
const operation = item[method.toLowerCase()];
if (!operation) continue;
const parts = template.split("/");
if (parts.length !== segments.length) continue;
const params = {};
const matches = parts.every((part, i) => {
if (part.startsWith("{") && part.endsWith("}")) {
params[part.slice(1, -1)] = decodeURIComponent(segments[i]);
return segments[i] !== "";
}
return part.toLowerCase() === segments[i].toLowerCase(); // ASP.NET routes ignore case
});
if (matches) candidates.push({ template, operation, params, paramCount: Object.keys(params).length });
}
candidates.sort((a, b) => a.paramCount - b.paramCount);
return candidates[0] ?? null;
}resolveReffollows a$refby splitting it on/and walking into the document one key at a time.findOperationturns a real request into the operation that describes it. It compares the request's path segment by segment with each template; a segment in braces matches anything and is recorded as a parameter. When several templates match, the one with fewer parameters wins, because a literal path such as/api/Products/categoriesis more specific than/api/Products/{id}.
The tests show both in action:
test("a real request finds its operation through the path template", () => {
const found = findOperation(spec, "GET", "/api/products/1");
assert.equal(found.template, "/api/Products/{id}");
assert.equal(found.operation.operationId, "Products_GetById");
assert.deepEqual(found.params, { id: "1" });
});test("the 200 response points at a schema in components", () => {
const { operation } = findOperation(spec, "GET", "/api/Products/1");
const schema = operation.responses["200"].content["application/json"].schema;
assert.deepEqual(schema, { $ref: "#/components/schemas/ProductResponse" });
const product = resolveRef(spec, schema.$ref);
assert.equal(product.properties.price.type, "number");
assert.ok(product.required.includes("price"));
assert.equal(product.additionalProperties, false);
});Check your understanding
1.Where in an OpenAPI document do you find what GET /api/Products/{id} returns with status 200?Show answer
paths → /api/Products/{id} → get → responses → 200 → content → application/json → schema.
2.What does $ref: "#/components/schemas/Error" mean?Show answer
Use the schema defined at components → schemas → Error in the same document.
3.A response in the spec has a description but no content. What does the spec promise about its body?Show answer
Nothing. Without content, the document doesn't say whether there is a body or what it looks like.
4.Why does the spec have both Product and ProductResponse?Show answer
Requests and responses differ: the server fills in some fields itself, and always returns every field. One schema describes what clients send, the other what they get back.
5.Both /api/Products/{id} and /api/Products/categories match /api/Products/categories. Which should win?Show answer
The literal path, /api/Products/categories, because it's more specific.
Exercise
Write a script that lists every operation under /api/Posts with its method, path, operationId and the status codes it documents. Then find the operation for GET /api/Posts/1/comments with findOperation, follow its 200 schema with resolveRef, and print the names of the properties a comment always has.