TestingAPIs is still being built. If something breaks, email support@testingapis.com.

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/v1.json, outline
{
  "openapi": "3.0.4",
  "info": { "title": "TestingAPIs", "version": "v1", "description": "..." },
  "paths": { ... 290 paths ... },
  "components": {
    "schemas": { ... },
    "securitySchemes": { ... }
  },
  "tags": [ ... ]
}
SectionHolds
openapiWhich version of the OpenAPI format the document uses: 3.0.4 here
infoThe API's name, its own version, and a description
pathsEvery endpoint and what it does: most of the document
componentsPieces defined once and referred to from many places, mainly schemas
tagsGroups 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:

paths (excerpt)
"/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.
  • operationId is a unique name for the operation. Code generators turn it into a method name, so changing it is a change consumers notice.
  • parameters lists the inputs outside the body. Each says where it goes (in: path, query, header or cookie), whether it's required, and its type as a schema.
  • responses is 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 no content, 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.

POST /api/Products responses
"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.

components.schemas (excerpt)
"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:

src/openapi.mjs
// 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;
}
This example uses endpoints for signed-in members.Sign inCreate a free account
  • resolveRef follows a $ref by 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/categories is more specific than /api/Products/{id}.

The tests show both in action:

test/openapi.test.mjs
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/openapi.test.mjs
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. 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. 2.What does $ref: "#/components/schemas/Error" mean?Show answer

    Use the schema defined at components → schemas → Error in the same document.

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

Practise and go further