[ API design ]
@sdxc/openapi
Build, serve and check OpenAPI 3.1 documents from typed operations
- Depends on
@standard-schema/spec- Used by
- uptime, auth-saas
- Source
- packages/openapi
Build, serve and check OpenAPI 3.1 documents from typed operations.
An operation binds one route to the schemas of its params, query, body and responses, its problem types and its security. Handlers parse their input through it, and the OpenAPI 3.1 document is built from the same value, so the published contract and the code that enforces it never drift apart.
Installation
npm add @sdxc/openapi
Schemas come from @sdxc/json-schema,
problem types from an @sdxc/problem catalog and
routes from a remix route map; results are
@sdxc/result values. Install all four alongside.
Usage
Declare an operation
import * as s from "@sdxc/json-schema";
import * as checks from "@sdxc/json-schema/checks";
import { defineOperation } from "@sdxc/openapi";
import { get, patch, route } from "remix/routes";
export const ROUTES = route("/api/v1", {
openapi: get("/openapi.json"),
books: { show: get("/books/:bookId"), update: patch("/books/:bookId") },
});
export const BOOK = s
.object({ id: s.string(), title: s.string(), createdAt: s.integer() })
.meta({ id: "Book" });
export const BOOK_SHOW = defineOperation("bookShow", ROUTES.books.show, {
summary: "Show a book",
responses: { 200: { description: "The book", body: s.object({ data: BOOK }) } },
problems: ["notFound"],
});
export const BOOK_UPDATE = defineOperation("bookUpdate", ROUTES.books.update, {
summary: "Update a book",
tags: ["Books"],
params: s.object({ bookId: s.string() }),
body: s.object({ title: s.optional(s.string().pipe(checks.minLength(1))) }),
responses: { 200: { description: "The updated book", body: s.object({ data: BOOK }) } },
problems: ["validationError", "notFound"],
});
The first argument becomes the operationId. params, when given, names exactly the route
pattern's variables, or the call fails to compile.
Build and serve the document
import * as s from "@sdxc/json-schema";
import { createDocument } from "@sdxc/openapi";
import { openapiHandler } from "@sdxc/openapi/router";
import { defineProblems, ISSUES_SCHEMA } from "@sdxc/problem";
import { createRouter } from "remix/router";
export const PROBLEMS = defineProblems("https://docs.example.com/errors/", {
validationError: {
slug: "validation-error",
status: 422,
title: "The request failed validation",
extensions: s.object({ errors: ISSUES_SCHEMA }),
},
notFound: { slug: "not-found", status: 404, title: "The resource does not exist" },
});
let router = createRouter();
export function buildApiDocument() {
return createDocument({
info: { title: "Books API", version: "1" },
servers: [{ url: "https://api.example.com" }],
problems: PROBLEMS,
}).add(BOOK_SHOW, BOOK_UPDATE);
}
router.get(
ROUTES.openapi,
openapiHandler(() => buildApiDocument().build()),
);
/api/v1/openapi.json answers JSON, and YAML for ?format=yaml or an Accept preferring
application/yaml.
Parse a request through the operation
import { issuesFrom } from "@sdxc/problem";
import { isFailure } from "@sdxc/result";
router.patch(ROUTES.books.update, async ({ request, params }) => {
let input = await BOOK_UPDATE.parse(request, params);
if (isFailure(input)) {
return PROBLEMS.validationError({ extensions: { errors: issuesFrom(input.error) } });
}
let { params: path, body } = input.data; // { bookId: string }, { title?: string }
let book = await updateBook(path.bookId, body);
if (book === null) return PROBLEMS.notFound({ detail: "No book has that id." });
return Response.json({ data: book });
});
Answering with the catalog's builders sends exactly the problem type and extensions the
document declares for the operation.
API
@sdxc/openapi
defineOperation(name, route, spec): Operation
Binds a spec to one route; name becomes the operationId. spec takes:
summary(required),description,tags,deprecated: copied to the operation.params: an object schema whose keys equal the pattern's variables. Without one, each variable documents as a string andparseyields the router's params.query: an object schema; each key becomes a query parameter, required unlessoptional.body: a schema forapplication/json, or a record of media types to schemas.responses: status to{ description, body?, headers? }, wherebodytakes the same two forms and a header is{ schema, description?, required? }.problems: entry names from the document's catalog.security: a list of scheme name to scopes;[]marks an unauthenticated operation.
operation.parse(request, params): Promise<Result<{ params, query, body }, OperationInputError>>
Validates params, query and body in that order and stops at the first failure. A repeated
query key arrives as an array. The body is read by its Content-Type: JSON media types are
parsed, forms become objects, anything else is text, and a request without a body validates
undefined. An operation also exposes route, operationId and spec.
createDocument(options): DocumentBuilder
Starts a document; nothing is assembled until build(). options takes:
info,tags: copied to the document.servers: absolute URLs.securitySchemes: the schemes operations may name.security: applied to every operation that declares none of its own.problems: a catalog fromdefineProblems.
DocumentBuilder
add(...operations): collects operations; one listing a problem the catalog lacks fails to compile.build(): Result<OpenAPI.Document, OpenAPIBuildError>: assembles the document. Path variables become required{name}parameters, schemas named withmeta({ id })land incomponents.schemas, and each catalog entry becomes a<Name>Problemschema and acomponents.responses.<Name>; problems sharing a status merge into oneoneOfresponse.scopes(): Record<string, string[]>: every scope any operation requires, per scheme.operations()andproblems(): what the builder holds, for tooling.
parse(text): Result<OpenAPI.Document, OpenAPIParseError>
Reads JSON (text starting with {) or YAML, and checks openapi: 3.1.x, info.title,
info.version and the top-level shape.
stringify(document, options?): Result<string, OpenAPIStringifyError>
Writes JSON, or YAML with { format: "yaml" }, at indent spaces per level (2 by default),
ending with a newline.
OpenAPIBuildError
What build() fails with: a duplicate operationId, a route OpenAPI cannot express (an
optional segment, an unnamed wildcard, a hostname variable, a method-agnostic route), params
that disagree with the pattern, an unknown scheme or problem, a status declared both as a
response and a problem, or a schema without a JSON Schema form. operationId names the
operation (null at document level) and pointer is a JSON Pointer into the document.
OperationInputError
What parse fails with: location ("params", "query" or "body") and the schema's
issues, which issuesFrom from @sdxc/problem accepts directly.
OpenAPIParseError and OpenAPIStringifyError
What parse and stringify fail with.
Constants
MEDIA_TYPE_JSON (application/json), MEDIA_TYPE_YAML (application/yaml),
OPENAPI_VERSION (3.1.1) and JSON_SCHEMA_DIALECT
(https://spec.openapis.org/oas/3.1/dialect/base), the last two declared by every built
document.
@sdxc/openapi/security
bearer({ description?, bearerFormat? }), apiKey({ in, name, description? }), oauth2({ description?, flows }), openIdConnect({ openIdConnectUrl, description? })
Return the scheme objects securitySchemes lists. oauth2 takes clientCredentials and
authorizationCode flows, each with its URLs and scopes.
@sdxc/openapi/router
openapiHandler(build, options?): RequestHandler
Serves the document as JSON or YAML. build runs on the first request and its outcome is
reused, so the handler is safe to create at module scope. Responses carry a strong ETag,
Vary: Accept and Cache-Control (options.cacheControl, public, max-age=300 by default),
and a matching If-None-Match answers 304. A build failure answers 500 with a problem
document.
@sdxc/openapi/testing
checkResponse(document, request, response): Promise<Result<void, ConformanceError>>
Checks one exchange against the builder's operations: the status, media type, required headers and body, validated with the declared schema. The response body is read from a clone.
createConformanceRecorder(document)
Returns { middleware, violations(), uncovered() }. Installed first on a router, middleware
checks every exchange; uncovered() lists the declared operationId and status pairs no
exchange produced.
ConformanceError
Carries violations, every departure found.
Types
Operation, OperationSpec, ResponseSpec
An operation, its spec and one declared response.
PathParams<Route>
The params type a route's pattern implies.
DocumentOptions, StringifyOptions, ServeOptions
The options of createDocument, stringify and openapiHandler (from ./router).
Violation
One departure, from ./testing: operationId (null when the request matched no operation),
message, pointer into the body for a body mismatch, and a kind: undocumented-operation,
undocumented-status, undocumented-media-type, undocumented-problem-type,
missing-header or body-mismatch.
OpenAPI
A namespace of the OpenAPI 3.1 object model: OpenAPI.Document, OpenAPI.Operation,
OpenAPI.SecurityScheme and the rest.
Pattern: Query Strings, Forms And Response Headers
import * as s from "@sdxc/json-schema";
import * as coerce from "@sdxc/json-schema/coerce";
import { defineOperation } from "@sdxc/openapi";
import { get, post } from "remix/routes";
let NOTE = s.object({ id: s.string(), text: s.string() }).meta({ id: "Note" });
let listNotes = defineOperation("noteList", get("/notes"), {
summary: "List notes",
query: s.object({ cursor: s.optional(s.string()), limit: s.optional(coerce.number()) }),
responses: {
200: {
description: "One page of notes",
body: s.object({ data: s.array(NOTE) }),
headers: { Link: { schema: s.string(), description: "The next page", required: false } },
},
},
});
let createNote = defineOperation("noteCreate", post("/notes"), {
summary: "Create a note",
body: {
"application/json": s.object({ text: s.string() }),
"application/x-www-form-urlencoded": s.object({ text: s.string() }),
},
responses: {
201: {
description: "The created note",
body: NOTE,
headers: { Location: { schema: s.string(), required: true } },
},
},
});
listNotes.parse yields query.limit as a number. createNote.parse reads a JSON or a form
body by its Content-Type and validates it with that media type's schema; any other media type
fails with an OperationInputError at "body".
Pattern: Secured Operations And Their Scopes
Declare the schemes once, apply a default to every operation, and let scopes() feed OAuth
protected-resource metadata the same list the operations require.
import * as s from "@sdxc/json-schema";
import { createDocument, defineOperation } from "@sdxc/openapi";
import { bearer, oauth2 } from "@sdxc/openapi/security";
import { get, post } from "remix/routes";
let health = defineOperation("health", get("/health"), {
summary: "Check the service",
responses: { 204: { description: "The service is up" } },
security: [],
});
let createReport = defineOperation("reportCreate", post("/reports"), {
summary: "Create a report",
body: s.object({ title: s.string() }),
responses: { 201: { description: "The created report" } },
security: [{ oauth: ["reports:write"] }],
});
let listReports = defineOperation("reportList", get("/reports"), {
summary: "List reports",
responses: { 200: { description: "The reports" } },
});
let document = createDocument({
info: { title: "Reports API", version: "1" },
servers: [{ url: "https://api.example.com" }],
securitySchemes: {
apiKey: bearer({ description: "An API key, sent as a bearer token" }),
oauth: oauth2({
description: "Metadata at https://api.example.com/.well-known/oauth-protected-resource",
flows: {
clientCredentials: {
tokenUrl: "https://auth.example.com/oauth/token",
scopes: { "reports:read": "Read reports", "reports:write": "Create reports" },
},
},
}),
},
security: [{ apiKey: [] }, { oauth: ["reports:read"] }],
}).add(health, createReport, listReports);
export function protectedResourceMetadata() {
let { oauth } = document.scopes();
return Response.json({
resource: "https://api.example.com",
authorization_servers: ["https://auth.example.com"],
scopes_supported: oauth ?? [],
});
}
health is unauthenticated, createReport requires its own scope, and listReports takes
either scheme from the document default. OpenAPI 3.1 has no field for an authorization server's
metadata, so the oauth2 description links it.
Pattern: A Drift Test
Commit the serialized document, so every contract change shows up as a reviewable diff.
import { stringify } from "@sdxc/openapi";
import { unwrap } from "@sdxc/result";
import { expect, test } from "vitest";
import { buildApiDocument } from "./api-document.js";
test("the document matches the committed snapshot", async () => {
let document = unwrap(buildApiDocument().build());
await expect(unwrap(stringify(document, { format: "yaml" }))).toMatchFileSnapshot(
"./openapi.snapshot.yaml",
);
});
build() failing here also catches a duplicate operationId, a params schema out of step with
its route, or a problem missing from the catalog before deploy.
Pattern: Conformance Across A Test Suite
Record every exchange a test router serves, then assert the suite stayed inside the document and reached every declared status.
import { createConformanceRecorder } from "@sdxc/openapi/testing";
import { createRouter } from "remix/router";
import { afterAll, expect, test } from "vitest";
import { buildApiDocument, mapApiRoutes } from "./api.js";
let recorder = createConformanceRecorder(buildApiDocument());
let router = createRouter({ middleware: [recorder.middleware] });
mapApiRoutes(router);
test("an unknown book is a 404", async () => {
let response = await router.fetch("https://api.example.com/api/v1/books/missing");
expect(response.status).toBe(404);
});
afterAll(() => {
expect(recorder.violations()).toEqual([]);
expect(recorder.uncovered()).toEqual([]);
});
For a single exchange, checkResponse answers the same verdict:
import { checkResponse } from "@sdxc/openapi/testing";
import { isFailure } from "@sdxc/result";
import { expect } from "vitest";
let request = new Request("https://api.example.com/api/v1/books/missing");
let result = await checkResponse(buildApiDocument(), request, await router.fetch(request));
expect(isFailure(result) ? result.error.violations : []).toEqual([]);
Pattern: Read A Published Document
import { parse } from "@sdxc/openapi";
import { isFailure } from "@sdxc/result";
let response = await fetch("https://api.example.com/api/v1/openapi.json?format=yaml");
let document = parse(await response.text());
if (isFailure(document)) throw document.error;
let paths = Object.keys(document.data.paths ?? {}); // ["/api/v1/books/{bookId}", ...]
let update = document.data.paths?.["/api/v1/books/{bookId}"]?.patch?.operationId; // "bookUpdate"