Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions static/packages/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions static/packages/api/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"]]),
]);
96 changes: 96 additions & 0 deletions static/packages/api/lib/docs-catalog.mjs
Original file line number Diff line number Diff line change
@@ -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 };
}
88 changes: 88 additions & 0 deletions static/packages/api/lib/docs-types.ts
Original file line number Diff line number Diff line change
@@ -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[];
};
2 changes: 2 additions & 0 deletions static/packages/api/lib/http-methods.mjs
Original file line number Diff line number Diff line change
@@ -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"]);
2 changes: 1 addition & 1 deletion static/packages/api/lib/operation-catalog.mjs
Original file line number Diff line number Diff line change
@@ -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"]);

Expand Down
4 changes: 4 additions & 0 deletions static/packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
28 changes: 28 additions & 0 deletions static/packages/api/scripts/generate-docs.mjs
Original file line number Diff line number Diff line change
@@ -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"));
}
Loading
Loading