Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ jobs:

- run: pnpm install --frozen-lockfile

- run: pnpm lint

- run: pnpm build

- run: pnpm typecheck
Expand Down
29 changes: 29 additions & 0 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": [],
"jsPlugins": ["./lint/plugin.mjs"],
"categories": { "correctness": "off" },
"ignorePatterns": ["dist/**", "src/**"],
"overrides": [
{
"files": ["build.mjs", "scripts/**", "lib/**"],
"rules": { "sentry-api/no-typescript-source-strings": "error" }
},
{
"files": [
"build.mjs",
"scripts/generate-api-base-path.mjs",
"scripts/generate-base-url-resolver.mjs",
"scripts/generate-client.mjs",
"scripts/generate-error-results.mjs",
"scripts/generate-operations.mjs",
"scripts/generate-pagination.mjs",
"scripts/generate-query-enum-types.mjs",
"scripts/generate-request-preparation.mjs",
"scripts/generate-response-contract.mjs",
"scripts/make-tree-shakeable.mjs"
],
"rules": { "sentry-api/no-typescript-source-strings": "off" }
}
]
}
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ For long-term knowledge entries managed by [lore](https://github.com/BYK/loreai)

Use pnpm and Node.js (22.13 or later); do not add Bun-only APIs or tooling. Run `pnpm build` before `pnpm typecheck` and `pnpm test`: both read the generated `src/` and `dist/` output.

## Generated TypeScript

Build generated `.ts` modules from TypeScript compiler nodes (`ts.factory`) and write them with `writeSource` from `lib/generated-source.mjs`. Do not write TypeScript from strings, templates, or `.join()`, and do not patch it with `replaceOnce`. `pnpm lint` enforces this with the `sentry-api/no-typescript-source-strings` rule in `lint/plugin.mjs`. The generators that predate the rule are listed in `.oxlintrc.json`; take a file off that list when you move it to the printer, and do not add new ones.

## URLs

Parse URLs with `URL` (`new URL`, `URL.canParse`), never with regular expressions. `test/url-parsing.test.ts` fails on any regex that matches a scheme separator (`://`).
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ Each `createSentryClient` call creates an independent transport configuration. T

Supply the base URL and authentication appropriate for your deployment. See Sentry's [API authentication documentation](https://docs.sentry.io/api/auth/).

The base URL is the Sentry origin, such as `https://sentry.io` or a self-hosted `https://sentry.example.com`. Operation paths are relative to the API base path, which the client adds after the origin. It comes from the server URL in Sentry's OpenAPI spec and is exported as `API_BASE_PATH` (`"/api/0"`) from `@sentry/api`, `@sentry/api/client`, and `@sentry/api/operations`. Raw requests take the same relative paths, such as `sentry.client.get({ url: "/organizations/" })`.
The base URL is the Sentry origin, such as `https://sentry.io` or a self-hosted `https://sentry.example.com`. Operation paths are relative to the API base path, which the client adds after the origin. It comes from the server URL in Sentry's OpenAPI spec and is exported as `API_BASE_PATH` from `@sentry/api`, `@sentry/api/client`, and `@sentry/api/operations`. Raw requests take the same relative paths, such as `sentry.client.get({ url: "/organizations/" })`.

### Standalone operations

Expand Down Expand Up @@ -137,6 +137,31 @@ 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 { API_BASE_PATH, operations, tags } from "@sentry/api/docs";

for (const operation of operations) {
console.log(operation.method, `${API_BASE_PATH}${operation.path}`, operation.summary);
for (const response of operation.responses) console.log(response.status, response.example);
}
```

Operation paths are relative to `API_BASE_PATH`, which comes from the spec's server URL, as in the client and `@sentry/api/operations`. Server overrides keep only their origin. A page shows an operation's full path as `${API_BASE_PATH}${operation.path}`.

`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 { apiBasePath, 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 build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,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 @@ -118,6 +120,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"]]),
]);
102 changes: 102 additions & 0 deletions lib/docs-catalog.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/** Project the dereferenced OpenAPI document onto what an API reference shows. */
import { splitApiBasePath } from "./api-base-path.mjs";
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.
* Paths are relative to the API base path and server URLs keep only their
* origin, as in the client and the operation catalog.
* @param {{servers?: {url: string}[], paths: object, tags?: object[]}} source Dereferenced
* OpenAPI document, such as this repository's `openapi-derefed.json` or a local spec build.
* It is not modified.
* @returns {import("./docs-types.ts").ApiDocs}
*/
export function extractDocs(source) {
const spec = structuredClone(source);
const apiBasePath = splitApiBasePath(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 { apiBasePath, tags: (spec.tags ?? []).map(tag), operations };
}
90 changes: 90 additions & 0 deletions lib/docs-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
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"];
/** OpenAPI path template, relative to `apiBasePath`, such as `/organizations/{organization_id_or_slug}/`. */
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;
/** Origins of the servers 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 = {
/** The path every operation path is relative to, such as `/api/0`, from the spec's server URL. */
readonly apiBasePath: string;
/** Categories in specification order. */
readonly tags: readonly ApiDocsTag[];
/** Operations in specification order. */
readonly operations: readonly ApiDocsOperation[];
};
13 changes: 13 additions & 0 deletions lib/generated-source.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,19 @@ export function update(name, transform) {
writeFileSync(path, transform(readFileSync(path, "utf8"), name));
}

/**
* Write src/`name` from TypeScript AST `statements`, printed by the compiler,
* so generated modules are built from nodes rather than source strings.
*/
export function writeSource(name, statements) {
const path = srcPath(name);
const file = ts.factory.updateSourceFile(
ts.createSourceFile(path, "", ts.ScriptTarget.Latest, false, ts.ScriptKind.TS),
statements,
);
writeFileSync(path, ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }).printFile(file));
}

/** Parse src/`name`, keeping its text for position-based edits. */
export function parse(name) {
const path = srcPath(name);
Expand Down
2 changes: 2 additions & 0 deletions 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 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
Loading
Loading