diff --git a/static/packages/api/README.md b/static/packages/api/README.md index 45d5b77437bf..652537220958 100644 --- a/static/packages/api/README.md +++ b/static/packages/api/README.md @@ -135,6 +135,29 @@ experimental.has("listOrganizationTraceMetrics"); // true The catalog is generated from the same schema and SDK output as each release. Consumers own search, grouping, ranking, and command aliases; the catalog does not fetch a live schema or infer undocumented API behavior. +### API reference data + +`@sentry/api/docs` exports what an API reference page shows for each operation, so a docs site can render the reference from the package version it pins instead of fetching the spec: + +```ts +import { operations, tags } from "@sentry/api/docs"; + +for (const operation of operations) { + console.log(operation.method, operation.path, operation.summary); + for (const response of operation.responses) console.log(response.status, response.example); +} +``` + +`tags` lists the categories, with their sidebar names and descriptions. Each `ApiDocsOperation` has the original `operationId`, summary and description as the spec writes them, tags, deprecated and experimental flags, server overrides, security requirements, path and query parameters with their schemas, the request body's first content type with its schema and example, and every response with its schema and examples. The data comes from the same spec as the client in each release, so it is about 3.7 MB; import it only where you render the reference. Its shape follows semver: a breaking change to it is a major release. + +To preview a spec that isn't released yet, such as a local `make build-api-docs` output, build the same data from it with `extractDocs`: + +```ts +import { extractDocs } from "@sentry/api/docs"; + +const { tags, operations } = extractDocs(JSON.parse(readFileSync("tests/apidocs/openapi-derefed.json", "utf8"))); +``` + ### Build-time source analysis `@sentry/api/source-analysis` follows references in supplied TypeScript source to discover calls to catalog operations. Install TypeScript only when using this optional build tool; the analyzer is tested with TypeScript 5.9. Its optional peer uses a wildcard so it does not constrain the compiler used by applications that only import the client. diff --git a/static/packages/api/build.mjs b/static/packages/api/build.mjs index 01156b7b5475..6e4c13f53fc5 100644 --- a/static/packages/api/build.mjs +++ b/static/packages/api/build.mjs @@ -54,6 +54,8 @@ const steps = [ "apply-query-defaults", "generate-client", "generate-operations", + // API reference data (tags, parameters, bodies, responses) for docs sites. + "generate-docs", // Use Valibot's description action for the descriptions, which getDescription reads. "describe-valibot-schemas", // Annotate schema constructors as pure and defer the default client so bundlers @@ -121,6 +123,7 @@ await Promise.all([ bundle("api-client/index", { external: ["@sentry/api", "http-cache-semantics", "p-limit"] }), bundle("api-client/endpoints/index", { external: ["@sentry/api", "@sentry/api/client"] }), bundle("operations"), + bundle("docs"), bundle("source-analysis", { external: ["typescript"], platform: "node" }), emitDeclarations([["valibot.ts", "valibot.gen.ts"], ["zod.ts", "zod.gen.ts"]]), ]); diff --git a/static/packages/api/lib/docs-catalog.mjs b/static/packages/api/lib/docs-catalog.mjs new file mode 100644 index 000000000000..af3ed447820c --- /dev/null +++ b/static/packages/api/lib/docs-catalog.mjs @@ -0,0 +1,96 @@ +/** Project the dereferenced OpenAPI document onto what an API reference shows. */ +import { HTTP_METHODS } from "./http-methods.mjs"; + +/** Keep `key` only when the source defines it, so the output has no `undefined` values. */ +const optional = (key, value) => (value === undefined ? {} : { [key]: value }); + +function tag(source) { + return { + name: source.name, + ...optional("description", source.description), + ...optional("sidebarName", source["x-sidebar-name"]), + displayDescription: source["x-display-description"] === true, + }; +} + +/** Operation-level parameters replace path-level parameters with the same name/in. */ +function parameters(pathItem, operation) { + const merged = new Map(); + for (const parameter of [...(pathItem.parameters ?? []), ...(operation.parameters ?? [])]) { + merged.set(`${parameter.in} ${parameter.name}`, parameter); + } + return [...merged.values()].map(parameter => ({ + name: parameter.name, + in: parameter.in, + required: parameter.required === true, + ...optional("description", parameter.description), + experimental: parameter["x-sentry-experimental"] === true, + ...optional("schema", parameter.schema), + })); +} + +/** The first declared content type and its media type object, if any. */ +function firstContent(content) { + const [entry] = Object.entries(content ?? {}); + return entry ? { contentType: entry[0], media: entry[1] } : undefined; +} + +function requestBody(source) { + const first = firstContent(source?.content); + if (!first) return undefined; + return { + contentType: first.contentType, + required: source.required === true, + ...optional("schema", first.media.schema), + ...optional("example", first.media.example), + }; +} + +function response(status, source) { + const first = firstContent(source.content); + return { + status, + description: source.description ?? "", + ...(first + ? { + contentType: first.contentType, + ...optional("schema", first.media.schema), + ...optional("example", first.media.example), + ...optional("examples", first.media.examples), + } + : {}), + }; +} + +/** + * Build the API reference data for every operation, in specification order. + * Text fields are copied as written, so pages render exactly what the spec says. + * @param {{paths: object, tags?: object[]}} spec Dereferenced OpenAPI document, such as + * the `openapi-derefed.json` asset of an `api@` release or a local spec build. + * @returns {import("./docs-types.ts").ApiDocs} + */ +export function extractDocs(spec) { + const operations = []; + for (const [path, pathItem] of Object.entries(spec.paths)) { + for (const [method, operation] of Object.entries(pathItem)) { + if (!HTTP_METHODS.has(method)) continue; + const body = requestBody(operation.requestBody); + operations.push({ + operationId: operation.operationId ?? "", + method: method.toUpperCase(), + path, + ...optional("summary", operation.summary), + description: operation.description ?? "", + tags: operation.tags ?? [], + deprecated: operation.deprecated === true, + experimental: operation["x-sentry-experimental"] === true, + servers: (operation.servers ?? []).map(server => server.url), + security: operation.security ?? [], + parameters: parameters(pathItem, operation), + ...optional("requestBody", body), + responses: Object.entries(operation.responses ?? {}).map(([status, source]) => response(status, source)), + }); + } + } + return { tags: (spec.tags ?? []).map(tag), operations }; +} diff --git a/static/packages/api/lib/docs-types.ts b/static/packages/api/lib/docs-types.ts new file mode 100644 index 000000000000..a1418ae54fc1 --- /dev/null +++ b/static/packages/api/lib/docs-types.ts @@ -0,0 +1,88 @@ +import type { ApiOperation } from "./operation-types.ts"; + +/** A JSON Schema from the release's dereferenced OpenAPI specification, as written there. */ +export type ApiDocsSchema = { readonly [keyword: string]: unknown }; + +/** An API reference category. */ +export type ApiDocsTag = { + /** Name operations use in their `tags`. */ + readonly name: string; + /** Markdown description, when documented. */ + readonly description?: string; + /** Sidebar label (`x-sidebar-name`), when it differs from the name. */ + readonly sidebarName?: string; + /** Whether the category page shows the description (`x-display-description`). */ + readonly displayDescription: boolean; +}; + +/** An operation parameter. */ +export type ApiDocsParameter = { + readonly name: string; + readonly in: "path" | "query" | "header" | "cookie"; + readonly required: boolean; + /** Markdown description, when documented. */ + readonly description?: string; + /** Whether Sentry marks the parameter with `x-sentry-experimental`. */ + readonly experimental: boolean; + readonly schema?: ApiDocsSchema; +}; + +/** The request body, from its first declared content type. */ +export type ApiDocsRequestBody = { + readonly contentType: string; + readonly required: boolean; + readonly schema?: ApiDocsSchema; + readonly example?: unknown; +}; + +/** A named response example. */ +export type ApiDocsExample = { + readonly summary?: string; + readonly value: unknown; +}; + +/** A documented response, with the content of its first declared content type. */ +export type ApiDocsResponse = { + /** HTTP status code, or `default`, as the specification keys it. */ + readonly status: string; + readonly description: string; + readonly contentType?: string; + readonly schema?: ApiDocsSchema; + readonly example?: unknown; + readonly examples?: { readonly [name: string]: ApiDocsExample }; +}; + +/** Everything the API reference shows for one operation. */ +export type ApiDocsOperation = { + /** Original OpenAPI identifier; an empty string means it was not declared. */ + readonly operationId: string; + readonly method: ApiOperation["method"]; + /** Canonical OpenAPI path including parameter placeholders. */ + readonly path: string; + /** Title, as written in the specification, when documented. */ + readonly summary?: string; + /** Markdown description, as written in the specification; empty when undocumented. */ + readonly description: string; + readonly tags: readonly string[]; + /** Whether OpenAPI explicitly marks the operation deprecated. */ + readonly deprecated: boolean; + /** Whether Sentry marks the operation with `x-sentry-experimental`. */ + readonly experimental: boolean; + /** Server URLs that override the document's servers; empty when none do. */ + readonly servers: readonly string[]; + /** Accepted security requirements: each maps a scheme to the scopes it needs. */ + readonly security: readonly { readonly [scheme: string]: readonly string[] }[]; + /** Parameters after applying operation overrides to path-level parameters. */ + readonly parameters: readonly ApiDocsParameter[]; + readonly requestBody?: ApiDocsRequestBody; + /** Responses in specification order. */ + readonly responses: readonly ApiDocsResponse[]; +}; + +/** The API reference data for this SDK version. */ +export type ApiDocs = { + /** Categories in specification order. */ + readonly tags: readonly ApiDocsTag[]; + /** Operations in specification order. */ + readonly operations: readonly ApiDocsOperation[]; +}; diff --git a/static/packages/api/lib/http-methods.mjs b/static/packages/api/lib/http-methods.mjs new file mode 100644 index 000000000000..0a2f6ec4fe21 --- /dev/null +++ b/static/packages/api/lib/http-methods.mjs @@ -0,0 +1,2 @@ +/** Path item keys that are operations rather than shared fields such as `parameters`. */ +export const HTTP_METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "options", "trace"]); diff --git a/static/packages/api/lib/operation-catalog.mjs b/static/packages/api/lib/operation-catalog.mjs index e70345acf52e..70279ef90f7e 100644 --- a/static/packages/api/lib/operation-catalog.mjs +++ b/static/packages/api/lib/operation-catalog.mjs @@ -1,7 +1,7 @@ /** Combine original OpenAPI metadata with the generated SDK's operation identities. */ +import { HTTP_METHODS } from "./http-methods.mjs"; import { parseSdkOperations } from "./sdk-operations.mjs"; -const HTTP_METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "options", "trace"]); // RFC 9110 9.2.1: safe methods never change server state, so they are always idempotent. const SAFE_METHODS = new Set(["get", "head", "options", "trace"]); diff --git a/static/packages/api/package.json b/static/packages/api/package.json index 22f6d6f8a323..16f6e83b2ab5 100644 --- a/static/packages/api/package.json +++ b/static/packages/api/package.json @@ -52,6 +52,10 @@ "types": "./dist/operations.d.ts", "import": "./dist/operations.js" }, + "./docs": { + "types": "./dist/docs.d.ts", + "import": "./dist/docs.js" + }, "./source-analysis": { "types": "./dist/source-analysis.d.ts", "import": "./dist/source-analysis.js" diff --git a/static/packages/api/scripts/generate-docs.mjs b/static/packages/api/scripts/generate-docs.mjs new file mode 100644 index 000000000000..b74fcee6f846 --- /dev/null +++ b/static/packages/api/scripts/generate-docs.mjs @@ -0,0 +1,28 @@ +/** Generate the standalone API reference data from the same release inputs. */ +import { copyFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { srcPath } from "../lib/generated-source.mjs"; +import { extractDocs } from "../lib/docs-catalog.mjs"; + +export function emit({ sourceSpec }) { + for (const name of ["docs-types.ts", "docs-catalog.mjs", "http-methods.mjs"]) { + copyFileSync(fileURLToPath(new URL(`../lib/${name}`, import.meta.url)), srcPath(name)); + } + // A JSON string literal parses faster than an object literal and keeps tsc + // from inferring a type for megabytes of data. + writeFileSync(srcPath("docs.ts"), [ + "// This file is auto-generated by scripts/generate-docs.mjs.", + "import type { ApiDocs } from './docs-types.ts';", + "export type * from './docs-types.ts';", + "/** Build the same data from another spec, such as a local build, to preview it. */", + "export { extractDocs } from './docs-catalog.mjs';", + "", + `const docs: ApiDocs = JSON.parse(${JSON.stringify(JSON.stringify(extractDocs(sourceSpec)))});`, + "", + "/** API reference categories, in specification order. */", + "export const tags = docs.tags;", + "/** API reference data for every operation, in specification order. */", + "export const operations = docs.operations;", + "", + ].join("\n")); +} diff --git a/static/packages/api/test/docs.test.ts b/static/packages/api/test/docs.test.ts new file mode 100644 index 000000000000..6ae615539713 --- /dev/null +++ b/static/packages/api/test/docs.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, test } from "vitest"; +import { extractDocs, operations, tags } from "@sentry/api/docs"; +import { readFileSync } from "node:fs"; + +const fixture = { + openapi: "3.0.3", + info: { title: "API reference", version: "1" }, + tags: [ + { name: "Teams", description: "Manage teams.", "x-display-description": true }, + { name: "Organizations", "x-sidebar-name": "Orgs" }, + ], + paths: { + "/api/0/organizations/{organization_id_or_slug}/teams/": { + parameters: [ + { name: "organization_id_or_slug", in: "path", required: true, description: "The org.", schema: { type: "string" } }, + { name: "detailed", in: "query", schema: { type: "string" } }, + ], + get: { + operationId: "List an Organization's Teams", + summary: "(DEPRECATED) List teams ", + description: "Return teams.\n", + tags: ["Teams"], + deprecated: true, + security: [{ auth_token: ["team:read"] }], + parameters: [ + { name: "detailed", in: "query", description: "Overrides the path-level one.", "x-sentry-experimental": true, schema: { type: "string", enum: ["0", "1"] } }, + ], + responses: { + "200": { + description: "Success", + content: { + "application/json": { + schema: { type: "array", items: { type: "object" } }, + example: [{ slug: "web" }], + }, + }, + }, + "403": { description: "Forbidden" }, + }, + }, + post: { + operationId: "Create a Team", + tags: ["Teams"], + "x-sentry-experimental": true, + servers: [{ url: "https://us.sentry.io" }], + requestBody: { + required: true, + content: { + "application/json": { + schema: { type: "object", required: ["slug"], properties: { slug: { type: "string", description: "Team slug." } } }, + example: { slug: "web" }, + }, + "multipart/form-data": { schema: { type: "object" } }, + }, + }, + responses: { + "201": { + description: "Created", + content: { + "application/json": { + schema: { type: "object" }, + examples: { created: { summary: "A team", value: { slug: "web" } } }, + }, + }, + }, + }, + }, + }, + }, +}; + +describe("API reference data", () => { + test("copies what a reference page shows, in specification order", () => { + expect(extractDocs(fixture)).toEqual({ + tags: [ + { name: "Teams", description: "Manage teams.", displayDescription: true }, + { name: "Organizations", sidebarName: "Orgs", displayDescription: false }, + ], + operations: [ + { + operationId: "List an Organization's Teams", + method: "GET", + path: "/api/0/organizations/{organization_id_or_slug}/teams/", + summary: "(DEPRECATED) List teams ", + description: "Return teams.\n", + tags: ["Teams"], + deprecated: true, + experimental: false, + servers: [], + security: [{ auth_token: ["team:read"] }], + parameters: [ + { name: "organization_id_or_slug", in: "path", required: true, description: "The org.", experimental: false, schema: { type: "string" } }, + { name: "detailed", in: "query", required: false, description: "Overrides the path-level one.", experimental: true, schema: { type: "string", enum: ["0", "1"] } }, + ], + responses: [ + { + status: "200", + description: "Success", + contentType: "application/json", + schema: { type: "array", items: { type: "object" } }, + example: [{ slug: "web" }], + }, + { status: "403", description: "Forbidden" }, + ], + }, + { + operationId: "Create a Team", + method: "POST", + path: "/api/0/organizations/{organization_id_or_slug}/teams/", + description: "", + tags: ["Teams"], + deprecated: false, + experimental: true, + servers: ["https://us.sentry.io"], + security: [], + parameters: [ + { name: "organization_id_or_slug", in: "path", required: true, description: "The org.", experimental: false, schema: { type: "string" } }, + { name: "detailed", in: "query", required: false, experimental: false, schema: { type: "string" } }, + ], + requestBody: { + contentType: "application/json", + required: true, + schema: { type: "object", required: ["slug"], properties: { slug: { type: "string", description: "Team slug." } } }, + example: { slug: "web" }, + }, + responses: [ + { + status: "201", + description: "Created", + contentType: "application/json", + schema: { type: "object" }, + examples: { created: { summary: "A team", value: { slug: "web" } } }, + }, + ], + }, + ], + }); + }); + + describe("the published export", () => { + const spec = JSON.parse(readFileSync(new URL("../spec/openapi-derefed.json", import.meta.url), "utf8")); + const sourceOperations = Object.entries(spec.paths).flatMap(([path, pathItem]) => + Object.entries(pathItem as Record>).map(([method, operation]) => ({ path, method, operation })), + ); + + test("is built from this release's spec", () => { + expect({ tags, operations }).toEqual(extractDocs(spec)); + }); + + test("covers every tag and operation the spec documents", () => { + expect(tags.map(tag => tag.name)).toEqual(spec.tags.map((tag: { name: string }) => tag.name)); + expect(operations.map(operation => `${operation.method} ${operation.path}`)).toEqual( + sourceOperations.map(({ path, method }) => `${method.toUpperCase()} ${path}`), + ); + }); + + test("keeps each operation's text, parameters, examples and responses as the spec writes them", () => { + operations.forEach((operation, index) => { + const source = sourceOperations[index]!.operation; + const media = (content: object | undefined) => Object.values(content ?? {})[0]; + expect(operation.operationId).toBe(source.operationId); + expect(operation.summary).toBe(source.summary); + expect(operation.description).toBe(source.description ?? ""); + expect(operation.parameters.map(({ name, in: location }) => `${location} ${name}`)).toEqual( + (source.parameters ?? []).map((parameter: { name: string; in: string }) => `${parameter.in} ${parameter.name}`), + ); + expect(operation.requestBody?.example).toEqual(media(source.requestBody?.content)?.example); + expect(operation.requestBody?.schema).toEqual(media(source.requestBody?.content)?.schema); + expect(operation.responses.map(response => response.status)).toEqual(Object.keys(source.responses)); + operation.responses.forEach(response => { + const sourceMedia = media(source.responses[response.status].content); + expect(response.schema).toEqual(sourceMedia?.schema); + expect(response.example ?? response.examples).toEqual(sourceMedia?.example ?? sourceMedia?.examples); + }); + }); + }); + }); +}); diff --git a/static/packages/api/test/package.test.ts b/static/packages/api/test/package.test.ts index 76905647a376..7ab48fa13c92 100644 --- a/static/packages/api/test/package.test.ts +++ b/static/packages/api/test/package.test.ts @@ -106,6 +106,8 @@ describe("package entry points", () => { } const { operations } = await import("@sentry/api/operations"); assert(operations.some(operation => operation.functionName === "listOrganizations")); + const docs = await import("@sentry/api/docs"); + assert(docs.operations.some(operation => operation.path === "/api/0/organizations/")); // The root entry creates its default client on first use, not at import. const root = await import("@sentry/api"); assert.equal(typeof root.getOrganization, "function");