From 7aed41bbf811643f99e02fcad7cacb92d27e0893 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 13 Nov 2026 12:16:06 +0000 Subject: [PATCH 1/2] feat(api-client)!: Read the API base path from the spec's server URL The client hard-coded /api/0 in four places, and the catalog, SDK URLs and internal keys all spelled it out in every path. Read it from the OpenAPI spec's server URL instead, export it as API_BASE_PATH, and keep every path relative to it. The build accepts both spec shapes: the current one, where every path starts with /api/0, and the one where servers carries /api/0 and paths are relative. Both produce the same package, so the getsentry/sentry spec change that follows is a no-op here. BREAKING CHANGE: Catalog paths (`operations[].path`), `IdempotentOperation` paths, and raw `client.request`/`prepareRequest` URLs are relative to `API_BASE_PATH`, such as `/organizations/{organization_id_or_slug}/`. `baseUrl` stays the Sentry origin; the client adds the base path. Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- README.md | 8 +- build.mjs | 10 +- lib/api-base-path.mjs | 64 +++++++++++ lib/api-client/api-path-scope.ts | 29 ++--- lib/api-client/api-path.ts | 4 +- lib/api-client/core.ts | 10 +- lib/api-client/idempotent-operations.ts | 11 +- lib/api-client/response-cache/invalidation.ts | 18 +-- lib/normalize-spec.mjs | 12 +- lib/operation-types.ts | 2 +- lib/query-defaults.mjs | 4 +- lib/source-analysis.ts | 3 +- scripts/generate-api-base-path.mjs | 30 +++++ scripts/generate-operations.mjs | 3 +- scripts/generate-pagination.mjs | 6 +- test/api-client/core.test.ts | 2 +- test/api-client/idempotent-operations.test.ts | 14 +-- test/base-url-resolution.test.ts | 6 +- test/client-types.ts | 6 +- test/client.test.ts | 2 +- test/normalize-spec.test.ts | 108 +++++++++--------- test/operations.test.ts | 25 +++- test/package.test.ts | 4 +- test/request-preparation.test.ts | 18 +-- test/source-analysis-bindings.test.ts | 4 +- test/source-analysis-modules.test.ts | 4 +- test/source-analysis-values.test.ts | 2 +- test/source-analysis.test.ts | 8 +- 28 files changed, 267 insertions(+), 150 deletions(-) create mode 100644 lib/api-base-path.mjs create mode 100644 scripts/generate-api-base-path.mjs diff --git a/README.md b/README.md index 90da2d41..ce9fd261 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ 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/" })`. + ### Standalone operations Individual operation functions remain available as a modular alternative and accept transport configuration directly: @@ -118,7 +120,7 @@ const logOperations = operations.filter(operation => console.log(logOperations.map(({ functionName, method, path }) => ({ functionName, method, path }))); ``` -Each `ApiOperation` records the actual exported `functionName`, original OpenAPI `operationId`, method, path, summary, description, tags, path parameter names, query metadata, explicit deprecation flag, `experimental` flag, and `idempotent` flag. `ApiQueryParameter` includes the name, required flag, description, type, and declared enum values (item values for arrays). Operation parameters override inherited path parameters. +Each `ApiOperation` records the actual exported `functionName`, original OpenAPI `operationId`, method, path (relative to `API_BASE_PATH`), summary, description, tags, path parameter names, query metadata, explicit deprecation flag, `experimental` flag, and `idempotent` flag. `ApiQueryParameter` includes the name, required flag, description, type, and declared enum values (item values for arrays). Operation parameters override inherited path parameters. `experimental` is `true` when Sentry marks the operation with the `x-sentry-experimental` OpenAPI extension, which it sets for endpoints under active development that may change. Their SDK docs also start with an **Experimental:** note. To find or reject experimental operations, for example in a lint rule or build check: @@ -221,7 +223,7 @@ validation, body serialization, and request interceptors. const sentry = createSentryClient({ baseUrl: "https://example.test/sentry" }); const request = await sentry.client.prepareRequest({ method: "GET", - url: "/api/0/organizations/example/events/?field=id&field=title&cursor=old#view", + url: "/organizations/example/events/?field=id&field=title&cursor=old#view", query: { cursor: "opaque-next", per_page: 50 }, }); // Both field values remain; the old cursor is replaced and the fragment removed. @@ -607,7 +609,7 @@ Generic helpers delegate cancellation to your fetcher. Pass its signal through t `@sentry/api/client` is the request layer the Sentry CLI and MCP servers share, built on the generated operations. It uses only web-standard APIs (`fetch`, `Request`, `Headers`, `URL`, `AbortSignal`, `crypto.subtle`), so it runs in Node, Cloudflare Workers, and browsers. Hosts inject what differs between them: - **Transport.** `createApiTransport` sets `Authorization` from a `CredentialProvider`, refreshes the credential once after a 401, times out each attempt, retries through the shared retry policy (429 for any method; 500, 502, 503, 504, timeouts, and network errors only for GET, HEAD, OPTIONS, or writes marked idempotent), follows redirects only to trusted origins, and serves and invalidates cached GETs. `CredentialProvider.current` receives the request URL and the organization and project its path targets (`parseApiPathScope`), so a host with several logins can pick the one that can reach that organization. The host passes credentials, a `TransportCache`, the request-scoped `fresh` hook, a logger, a span hook, and its error classes. -- **Request core.** `createApiClientCore` sends `/api/0` through the transport and throws the status's `ApiError` subclass for an error response. `sdkClient` gives a generated operation a client that sends through the same path. +- **Request core.** `createApiClientCore` sends `` through the transport and throws the status's `ApiError` subclass for an error response. `sdkClient` gives a generated operation a client that sends through the same path. - **Response cache.** The cache policy (`lookupResponse`, `storeResponse`, tiers, identity-scoped keys, invalidation) works on any `CacheStore` and `BodyCodec`. `createMemoryStore` is a byte-budgeted in-memory store. Compressing codecs need host APIs such as `node:zlib`, so hosts supply them. - **Validation.** Endpoint wrappers validate responses with any [Standard Schema](https://standardschema.dev) validator the host passes in, such as the `@sentry/api/zod` or `@sentry/api/valibot` schemas. The client depends on neither. - **Pagination.** `paginate` and `fetchAllPages` walk list endpoints on top of `paginateUpTo` and `consumePage`. Their cursors are Sentry cursors that also record rows already returned from a page, so a resumed walk neither repeats nor skips rows. diff --git a/build.mjs b/build.mjs index 53197bfc..528db91e 100644 --- a/build.mjs +++ b/build.mjs @@ -1,11 +1,13 @@ import { build } from "esbuild"; import { cpSync, appendFileSync, readFileSync, writeFileSync } from "node:fs"; import { emitDeclarations } from "./lib/emit-declarations.mjs"; +import { splitApiBasePath } from "./lib/api-base-path.mjs"; import { generateClient } from "./lib/hey-api.mjs"; import { normalizeSpec } from "./lib/normalize-spec.mjs"; // 0.5 Normalize verbose English sentence operationIds to short REST-conventional -// identifiers (e.g. "List Your Organizations" → "listOrganizations"). +// identifiers (e.g. "List Your Organizations" → "listOrganizations"), and +// make every path relative to the API base path in the server URL. // Writes a temporary openapi-normalized.json that the generator reads instead // of the source spec. The source spec is never modified. // OperationIds that are already identifiers (no spaces) are left untouched — @@ -29,7 +31,10 @@ cpSync("lib/api-client", "src/api-client", { recursive: true }); // The generators are post-processors, not Hey API plugins, because the // plugin API is documented as in-development and unstable. const sourceSpec = JSON.parse(readFileSync("./openapi-derefed.json", "utf8")); +const apiBasePath = splitApiBasePath(sourceSpec); const steps = [ + // Export the base path, and send every operation under it. + "generate-api-base-path", // Keep the pinned Fetch generator's runtime and types aligned with our contract. "generate-response-contract", "generate-request-preparation", @@ -53,7 +58,7 @@ const steps = [ ]; for (const name of steps) { const { emit } = await import(`./scripts/${name}.mjs`); - emit({ normalizedSpec, sourceSpec }); + emit({ normalizedSpec, sourceSpec, apiBasePath }); } // 4. Append re-exports to the generated index.ts so the pagination @@ -62,6 +67,7 @@ appendFileSync( "src/index.ts", [ "", + "export { API_BASE_PATH } from './api-base-path.gen.ts';", "export { callWithTypedErrors, narrowError, SentryApiError } from './sentry-errors.ts';", "export type { DocumentedSentryApiError, NarrowedResult, SentryApiResultError, SentryApiTransportError, SdkResult, UndocumentedSentryApiError } from './sentry-errors.ts';", "export { parseSentryLinkHeader, unwrapResult, unwrapPaginatedResult, fetchPage, paginateAll, paginateUpTo, consumePage } from './sentry-pagination.ts';", diff --git a/lib/api-base-path.mjs b/lib/api-base-path.mjs new file mode 100644 index 00000000..fec65344 --- /dev/null +++ b/lib/api-base-path.mjs @@ -0,0 +1,64 @@ +/** + * Reads the API base path, such as `/api/0`, from the spec's server URL and + * leaves every operation path relative to it. + * + * The spec declares it in `servers` (`https://{region}.sentry.io/api/0`), and + * its paths are relative to it. The client takes the Sentry origin as + * `baseUrl` and adds the base path itself, so the servers keep only their + * origin. + */ + +const SERVER_URL = /^([a-z][a-z0-9+.-]*:\/\/[^/]*)(\/.*)?$/i; + +// TODO: Remove once Sentry's spec declares the base path in `servers`. +// Until then, every path starts with the same `/api/`. +const LEGACY_BASE_PATH = /^\/api\/[^/]+(?=\/)/; + +function splitServerUrl(url) { + const match = SERVER_URL.exec(url); + if (!match) throw new Error(`Unsupported server URL: ${url}`); + return { origin: match[1], path: (match[2] ?? "").replace(/\/+$/, "") }; +} + +function legacyBasePath(paths) { + const prefixes = new Set(Object.keys(paths).map(path => LEGACY_BASE_PATH.exec(path)?.[0])); + const [prefix] = prefixes; + if (prefixes.size !== 1 || prefix === undefined) { + throw new Error("The server URL has no base path, and the paths do not share one"); + } + return prefix; +} + +/** + * Make `spec`'s paths relative to its API base path, reduce its server URLs + * to their origins, and return the base path. A spec without servers, such + * as a test fixture, is left unchanged and has no base path. + * @param {{servers?: {url: string}[], paths: Record}} spec + * @returns {string} The base path, such as `/api/0`, or `""` + */ +export function splitApiBasePath(spec) { + if (!spec.servers?.length) return ""; + let basePath = splitServerUrl(spec.servers[0].url).path; + if (basePath === "") { + basePath = legacyBasePath(spec.paths); + spec.paths = Object.fromEntries( + Object.entries(spec.paths).map(([path, item]) => [path.slice(basePath.length), item]), + ); + } + + // An operation can override the servers, but not move off the base path. + const toOrigins = servers => servers.map(server => { + const { origin, path } = splitServerUrl(server.url); + if (path !== "" && path !== basePath) { + throw new Error(`Server ${server.url} is not under the API base path ${basePath}`); + } + return { ...server, url: origin }; + }); + spec.servers = toOrigins(spec.servers); + for (const pathItem of Object.values(spec.paths)) { + for (const operation of Object.values(pathItem)) { + if (operation?.servers) operation.servers = toOrigins(operation.servers); + } + } + return basePath; +} diff --git a/lib/api-client/api-path-scope.ts b/lib/api-client/api-path-scope.ts index 20a35611..56d582b8 100644 --- a/lib/api-client/api-path-scope.ts +++ b/lib/api-client/api-path-scope.ts @@ -1,8 +1,11 @@ /** - * Reading the organization and project an API path targets. No imports, so - * the generated client can use it without pulling in `@sentry/api/client`. + * Reading the organization and project an API path targets. It imports only + * the base path, so the generated client can use it without pulling in + * `@sentry/api/client`. */ +import { API_BASE_PATH } from "../api-base-path.gen.js"; + /** The organization, and project when present, an API path targets. */ export type ApiPathScope = { organization: string; project?: string }; @@ -16,19 +19,19 @@ function isApiPathIdentifier(segment: string | undefined): segment is string { return segment !== undefined && API_PATH_IDENTIFIER.test(segment); } -/** Path segments after `/api/0/`, or undefined for a URL outside the API. */ +/** Path segments after {@link API_BASE_PATH}, or undefined for a URL outside the API. */ function apiPathSegments(urlOrPath: string): string[] | undefined { const isUrl = URL.canParse(urlOrPath); - const path = isUrl + let path = isUrl ? new URL(urlOrPath).pathname : (urlOrPath.split(/[?#]/, 1)[0] ?? ""); - const segments = path.split("/").filter((segment) => segment.length > 0); - for (let index = 0; index < segments.length - 1; index += 1) { - if (segments[index] === "api" && segments[index + 1] === "0") { - return segments.slice(index + 2); - } + const apiIndex = path.indexOf(`${API_BASE_PATH}/`); + if (apiIndex !== -1) { + path = path.slice(apiIndex + API_BASE_PATH.length); + } else if (isUrl) { + return; } - return isUrl ? undefined : segments; + return path.split("/").filter((segment) => segment.length > 0); } /** @@ -36,9 +39,9 @@ function apiPathSegments(urlOrPath: string): string[] | undefined { * read from its path: `/organizations/{org}/`, `/projects/{org}/{project}/`, * `/teams/{org}/`, and `/organizations/{org}/projects/{project}/`. * - * Accepts a full URL, whose path must contain `/api/0/` (a self-hosted - * install may serve it under a prefix), or a path relative to `/api/0/` with - * or without that prefix. Undefined for unscoped paths, list endpoints such + * Accepts a full URL, whose path must contain {@link API_BASE_PATH} (a + * self-hosted install may serve it under a prefix), or a path relative to it + * with or without that prefix. Undefined for unscoped paths, list endpoints such * as `/organizations/`, and schema placeholders. * * Hosts with several credentials or regions use it to pick the one that diff --git a/lib/api-client/api-path.ts b/lib/api-client/api-path.ts index d13ea570..c166b306 100644 --- a/lib/api-client/api-path.ts +++ b/lib/api-client/api-path.ts @@ -1,5 +1,7 @@ +import { API_BASE_PATH } from "../api-base-path.gen.js"; import { UserInputError } from "./errors.js"; +export { API_BASE_PATH } from "../api-base-path.gen.js"; export { type ApiPathScope, parseApiPathScope } from "./api-path-scope.js"; /** @@ -26,7 +28,7 @@ export function buildSentryApiUrl(baseUrl: string, endpoint: string): string { while (end > 0 && baseUrl.charAt(end - 1) === "/") { end -= 1; } - return `${baseUrl.slice(0, end)}/api/0/${path}`; + return `${baseUrl.slice(0, end)}${API_BASE_PATH}/${path}`; } /** diff --git a/lib/api-client/core.ts b/lib/api-client/core.ts index 458b7080..d924e17f 100644 --- a/lib/api-client/core.ts +++ b/lib/api-client/core.ts @@ -14,7 +14,7 @@ import { parseSentryLinkHeader, type SentryClient, } from "@sentry/api"; -import { buildSentryApiUrl } from "./api-path.js"; +import { API_BASE_PATH, buildSentryApiUrl } from "./api-path.js"; import { type ApiError, ApiValidationError, @@ -56,7 +56,7 @@ export type ApiRequestOptions = { export type ApiClientCore = { /** - * Send a request to `origin` + `/api/0` + `path`. + * Send a request to `origin` + {@link API_BASE_PATH} + `path`. * * @throws {ApiError} For an error status not in `allowStatuses` * @throws {ConfigurationError} When the host is unreachable or misconfigured @@ -105,8 +105,6 @@ const NETWORK_ERROR_MESSAGES: Record = { ECONNRESET: "Connection reset. Try again in a moment.", }; -const API_PREFIX = "/api/0"; - /** Network failures that mean the host is wrong or unreachable. */ const CONFIGURATION_ERROR_CODES = new Set([ "ENOTFOUND", @@ -268,7 +266,7 @@ export function createApiClientCore({ const url = new URL(sdkRequest.url); if ( url.origin !== base || - url.pathname !== API_PREFIX.concat(expectedPath) || + url.pathname !== API_BASE_PATH.concat(expectedPath) || sdkRequest.method !== "GET" ) { throw new ConfigurationError( @@ -277,7 +275,7 @@ export function createApiClientCore({ } const response = await request( origin, - `${url.pathname.slice(API_PREFIX.length)}${url.search}`, + `${url.pathname.slice(API_BASE_PATH.length)}${url.search}`, { method: "GET", signal: sdkRequest.signal }, ); const contentType = response.headers.get("content-type"); diff --git a/lib/api-client/idempotent-operations.ts b/lib/api-client/idempotent-operations.ts index fac175ff..c1b9683f 100644 --- a/lib/api-client/idempotent-operations.ts +++ b/lib/api-client/idempotent-operations.ts @@ -7,13 +7,14 @@ * `@sentry/api/operations` directly. */ +import { API_BASE_PATH } from "../api-base-path.gen.js"; import { isIdempotentRequest } from "./retry.js"; /** A non-safe operation Sentry marks idempotent. */ export type IdempotentOperation = { /** Uppercase HTTP method */ method: string; - /** OpenAPI path template, such as `/api/0/organizations/{org}/chunk-upload/` */ + /** OpenAPI path template relative to {@link API_BASE_PATH}, such as `/organizations/{org}/chunk-upload/` */ path: string; }; @@ -59,8 +60,8 @@ function toMatcher({ method, path }: IdempotentOperation): Matcher { * Build `isIdempotent(method, url)` for a list of writes. The list is read on * the first call, so a caller may pass a thunk that loads it lazily. * - * Matches from the `/api/` segment on, so a self-hosted install served under - * a path prefix still matches. + * Matches the path after {@link API_BASE_PATH}, so a self-hosted install + * served under a path prefix still matches. */ export function createIdempotentOperationMatcher( operations: () => readonly IdempotentOperation[], @@ -71,11 +72,11 @@ export function createIdempotentOperationMatcher( return false; } const pathname = new URL(url).pathname; - const apiIndex = pathname.indexOf("/api/"); + const apiIndex = pathname.indexOf(`${API_BASE_PATH}/`); if (apiIndex === -1) { return false; } - const apiPath = pathname.slice(apiIndex); + const apiPath = pathname.slice(apiIndex + API_BASE_PATH.length); const normalized = apiPath.endsWith("/") ? apiPath : `${apiPath}/`; const upper = method.toUpperCase(); diff --git a/lib/api-client/response-cache/invalidation.ts b/lib/api-client/response-cache/invalidation.ts index 074cefdd..f583ce2b 100644 --- a/lib/api-client/response-cache/invalidation.ts +++ b/lib/api-client/response-cache/invalidation.ts @@ -7,7 +7,7 @@ * apply: * * 1. **Hierarchy walk.** Sweep the URL's own path and every ancestor - * up to `/api/0/`. A mutation on + * up to the API base path. A mutation on * `/organizations/{org}/releases/1.0.0/deploys/` sweeps itself, * `.../releases/1.0.0/`, and `.../releases/` — which catches the * detail, deploys-list, and releases-list GET caches in one pass. @@ -27,7 +27,9 @@ * query-param variant cached under that path). */ -const API_V0_SEGMENT = "/api/0/"; +import { API_BASE_PATH } from "../../api-base-path.gen.js"; + +const API_SEGMENT = `${API_BASE_PATH}/`; const TRAILING_SLASH_RE = /\/$/; /** @@ -45,7 +47,7 @@ const SKIP_INVALIDATION_PATTERNS: readonly RegExp[] = [ /** * Rule for mutations whose effects cross URL trees. Patterns match - * the path relative to `/api/0/`. `extra` returns additional + * the path relative to the API base path. `extra` returns additional * path-prefixes (swept under the mutation's own origin); `extraAbsolute` * returns absolute URL prefixes (when invalidation must cross origins, * e.g. region-scoped mutation clearing a cache under the control silo). @@ -80,7 +82,7 @@ const CROSS_ENDPOINT_RULES: CrossEndpointRule[] = [ { match: /^organizations\/[^/]+\/issues\/([^/]+)\/?$/, extraAbsolute: ([, issueId], { apiBaseUrl }) => [ - `${apiBaseUrl.replace(TRAILING_SLASH_RE, "")}/api/0/issues/${issueId}/`, + `${apiBaseUrl.replace(TRAILING_SLASH_RE, "")}${API_SEGMENT}issues/${issueId}/`, ], }, ]; @@ -95,7 +97,7 @@ const CROSS_ENDPOINT_RULES: CrossEndpointRule[] = [ * that need to clear caches under a different origin — e.g. the * legacy `/issues/{id}/` endpoint the CLI's `getIssue()` reads). * @returns Full-URL prefixes to sweep. Returns `[]` if the URL is - * not under `/api/0/` (e.g. sourcemap chunk upload to an arbitrary + * not under the API base path (e.g. sourcemap chunk upload to an arbitrary * endpoint) or can't be parsed. */ export function computeInvalidationPrefixes( @@ -110,7 +112,7 @@ export function computeInvalidationPrefixes( return []; } - const apiIdx = parsed.pathname.indexOf(API_V0_SEGMENT); + const apiIdx = parsed.pathname.indexOf(API_SEGMENT); if (apiIdx === -1) { return []; } @@ -119,8 +121,8 @@ export function computeInvalidationPrefixes( return []; } - const base = `${parsed.origin}${parsed.pathname.slice(0, apiIdx + API_V0_SEGMENT.length)}`; - const relPath = parsed.pathname.slice(apiIdx + API_V0_SEGMENT.length); + const base = `${parsed.origin}${parsed.pathname.slice(0, apiIdx + API_SEGMENT.length)}`; + const relPath = parsed.pathname.slice(apiIdx + API_SEGMENT.length); if (relPath === "") { return []; } diff --git a/lib/normalize-spec.mjs b/lib/normalize-spec.mjs index 7e592acd..9cd9a83a 100644 --- a/lib/normalize-spec.mjs +++ b/lib/normalize-spec.mjs @@ -20,6 +20,7 @@ */ import { readFileSync, writeFileSync } from "node:fs"; +import { splitApiBasePath } from "./api-base-path.mjs"; // --------------------------------------------------------------------------- // Singularization @@ -115,12 +116,11 @@ function segmentToCamel(s, { singular } = {}) { * * Empty segments (from leading/double slashes) are dropped upfront. * - * @param {string} path - e.g. "/api/0/organizations/{org}/issues/{id}/" + * @param {string} path - e.g. "/organizations/{org}/issues/{id}/" * @returns {{ value: string; singularValue: string; isParam: boolean; followedByParam: boolean }[]} */ export function parsePath(path) { - const clean = path.replace(/^\/api\/0\//, "").replace(/\/$/, ""); - const raw = clean.split("/").filter((s) => s.length > 0); + const raw = path.split("/").filter((s) => s.length > 0); return raw.map((s, i) => { const isParam = s.startsWith("{"); @@ -149,7 +149,7 @@ const VERBS = { * and URL path. * * @param {string} method - HTTP method (lowercase: "get", "post", …) - * @param {string} path - Full path, e.g. "/api/0/organizations/{org}/issues/{id}/" + * @param {string} path - Path relative to the API base path, e.g. "/organizations/{org}/issues/{id}/" * @returns {string} Normalized identifier, e.g. "getOrganizationIssue" * @throws {Error} If the path produces no usable resource name (no static * segments, or only separator-character segments) @@ -253,7 +253,8 @@ function inlineAnnotatedAllOf(node) { } /** - * Reads the spec at `inputPath`, rewrites all sentence-style operationIds to + * Reads the spec at `inputPath`, makes its paths relative to the API base path + * (lib/api-base-path.mjs), rewrites all sentence-style operationIds to * short REST-conventional names, drops uint64 formats and int64 formats from * integers that fit in a JS number, inlines annotated single-member `allOf`s, * and writes the result to `outputPath`. @@ -269,6 +270,7 @@ export function normalizeSpec(inputPath, outputPath) { // JSON.parse produces a fresh object every call; mutations are safe and // intentional — we rewrite operationIds in place before serialising. const spec = JSON.parse(readFileSync(inputPath, "utf8")); + splitApiBasePath(spec); // Track every final operationId (rewritten or pre-existing) so that a manually // set clean identifier cannot collide with a path-derived one without detection. const seen = new Map(); // final operationId → "METHOD /path" that first claimed it diff --git a/lib/operation-types.ts b/lib/operation-types.ts index baed4d0a..79b33b98 100644 --- a/lib/operation-types.ts +++ b/lib/operation-types.ts @@ -20,7 +20,7 @@ export type ApiOperation = { readonly operationId: string; /** HTTP method used by the generated operation. */ readonly method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS" | "TRACE"; - /** Canonical OpenAPI path including parameter placeholders. */ + /** OpenAPI path template, relative to `API_BASE_PATH`, such as `/organizations/{organization_id_or_slug}/`. */ readonly path: string; /** Short operation description, when documented. */ readonly summary?: string; diff --git a/lib/query-defaults.mjs b/lib/query-defaults.mjs index 00570f21..4b1f7092 100644 --- a/lib/query-defaults.mjs +++ b/lib/query-defaults.mjs @@ -22,9 +22,9 @@ const SKIP_ADJACENT_EVENTS = { */ export const QUERY_DEFAULTS = new Map([ // getsentry/sentry#129094 - ["GET /api/0/organizations/{organization_id_or_slug}/issues/{issue_id}/events/{event_id}/", SKIP_ADJACENT_EVENTS], + ["GET /organizations/{organization_id_or_slug}/issues/{issue_id}/events/{event_id}/", SKIP_ADJACENT_EVENTS], // getsentry/sentry#129123 - ["GET /api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/events/{event_id}/", SKIP_ADJACENT_EVENTS], + ["GET /projects/{organization_id_or_slug}/{project_id_or_slug}/events/{event_id}/", SKIP_ADJACENT_EVENTS], ]); /** Fail the build when the schema no longer documents a configured default. */ diff --git a/lib/source-analysis.ts b/lib/source-analysis.ts index 9abf2c65..d8a62160 100644 --- a/lib/source-analysis.ts +++ b/lib/source-analysis.ts @@ -89,7 +89,6 @@ type Module = { }; const SDK = "@sentry/api"; -const API_PREFIX = /^\/api\/\d+(?=\/)/; const PATH_PARAMETER = /\{[^}]*\}/g; function unwrap(node: ts.Node): ts.Node { @@ -298,7 +297,7 @@ export function analyzeSourceApiUsage( ); const identities = new Map(); const pathKey = (path: string) => - `/${path.replace(API_PREFIX, "").split("/").filter(Boolean).join("/")}/`.replace( + `/${path.split("/").filter(Boolean).join("/")}/`.replace( PATH_PARAMETER, "{}", ); diff --git a/scripts/generate-api-base-path.mjs b/scripts/generate-api-base-path.mjs new file mode 100644 index 00000000..260eae07 --- /dev/null +++ b/scripts/generate-api-base-path.mjs @@ -0,0 +1,30 @@ +/** + * Export the API base path the spec's server URL declares, and put it between + * the configured base URL, the Sentry origin, and each operation's path. + */ +import { writeFileSync } from "node:fs"; +import { replaceOnce, srcPath, update } from "../lib/generated-source.mjs"; + +export function emit({ apiBasePath }) { + if (!apiBasePath) throw new Error("The spec declares no API base path"); + writeFileSync(srcPath("api-base-path.gen.ts"), [ + "// This file is auto-generated by scripts/generate-api-base-path.mjs.", + "", + "/**", + " * The path every operation path is relative to, from the server URL in", + " * Sentry's OpenAPI spec. Requests go to the Sentry origin, then this, then", + " * the operation path.", + " */", + `export const API_BASE_PATH = ${JSON.stringify(apiBasePath)};`, + "", + ].join("\n")); + + update("client/utils.gen.ts", source => { + source = replaceOnce(source, + "import { getUrl } from '../core/utils.gen';", + "import { API_BASE_PATH } from '../api-base-path.gen';\nimport { getUrl } from '../core/utils.gen';"); + return replaceOnce(source, + " baseUrl: options.baseUrl as string,\n", + " baseUrl: `${(options.baseUrl as string | undefined) ?? ''}${API_BASE_PATH}`,\n"); + }); +} diff --git a/scripts/generate-operations.mjs b/scripts/generate-operations.mjs index 8994d86a..1c53d991 100644 --- a/scripts/generate-operations.mjs +++ b/scripts/generate-operations.mjs @@ -11,8 +11,9 @@ export function emit({ sourceSpec }) { "// This file is auto-generated by scripts/generate-operations.mjs.", "import type { ApiOperation } from './operation-types.ts';", "export type { ApiOperation, ApiQueryParameter } from './operation-types.ts';", + "export { API_BASE_PATH } from './api-base-path.gen.ts';", "", - "/** Offline metadata for the operations exported by this SDK version. */", + "/** Offline metadata for the operations exported by this SDK version. Paths are relative to `API_BASE_PATH`. */", `export const operations: readonly ApiOperation[] = ${JSON.stringify(operations, null, 2)};`, "", ].join("\n")); diff --git a/scripts/generate-pagination.mjs b/scripts/generate-pagination.mjs index dab26e84..ea6b5f82 100644 --- a/scripts/generate-pagination.mjs +++ b/scripts/generate-pagination.mjs @@ -202,9 +202,9 @@ export function emit({ normalizedSpec: spec }) { // sentry/search/snuba/executors.py (issue cursors), and // sentry/issues/endpoints/group_events.py (GenericOffsetPaginator, full cap). const resizablePages = new Map([ - ["GET /api/0/organizations/{organization_id_or_slug}/issues/", { parameter: "limit", max: 100 }], - ["GET /api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/issues/", { parameter: "limit", max: 100 }], - ["GET /api/0/organizations/{organization_id_or_slug}/issues/{issue_id}/events/", { parameter: "per_page", max: 100, fullMax: 10 }], + ["GET /organizations/{organization_id_or_slug}/issues/", { parameter: "limit", max: 100 }], + ["GET /projects/{organization_id_or_slug}/{project_id_or_slug}/issues/", { parameter: "limit", max: 100 }], + ["GET /organizations/{organization_id_or_slug}/issues/{issue_id}/events/", { parameter: "per_page", max: 100, fullMax: 10 }], ]); for (const [route, sizing] of resizablePages) { diff --git a/test/api-client/core.test.ts b/test/api-client/core.test.ts index 61e1724a..403f10f5 100644 --- a/test/api-client/core.test.ts +++ b/test/api-client/core.test.ts @@ -329,7 +329,7 @@ describe("createApiClientCore sdkClient", () => { const client = core.sdkClient(ORIGIN, "/organizations/"); await expect( - client.get({ url: "/api/0/projects/", throwOnError: true }), + client.get({ url: "/projects/", throwOnError: true }), ).rejects.toThrow("Unexpected SDK request target or method"); expect(transport.request).not.toHaveBeenCalled(); }); diff --git a/test/api-client/idempotent-operations.test.ts b/test/api-client/idempotent-operations.test.ts index 76f03a54..8cd5f07a 100644 --- a/test/api-client/idempotent-operations.test.ts +++ b/test/api-client/idempotent-operations.test.ts @@ -8,14 +8,14 @@ describe("selectIdempotentWrites", () => { test("keeps marked writes and drops safe methods and unmarked writes", () => { expect( selectIdempotentWrites([ - { method: "GET", path: "/api/0/b/", idempotent: true }, - { method: "PUT", path: "/api/0/b/", idempotent: true }, - { method: "POST", path: "/api/0/b/", idempotent: false }, - { method: "DELETE", path: "/api/0/a/", idempotent: true }, + { method: "GET", path: "/b/", idempotent: true }, + { method: "PUT", path: "/b/", idempotent: true }, + { method: "POST", path: "/b/", idempotent: false }, + { method: "DELETE", path: "/a/", idempotent: true }, ]), ).toEqual([ - { method: "DELETE", path: "/api/0/a/" }, - { method: "PUT", path: "/api/0/b/" }, + { method: "DELETE", path: "/a/" }, + { method: "PUT", path: "/b/" }, ]); }); }); @@ -24,7 +24,7 @@ describe("createIdempotentOperationMatcher", () => { const isIdempotent = createIdempotentOperationMatcher(() => [ { method: "POST", - path: "/api/0/organizations/{organization_id_or_slug}/chunk-upload/", + path: "/organizations/{organization_id_or_slug}/chunk-upload/", }, ]); const url = "https://us.sentry.io/api/0/organizations/acme/chunk-upload/"; diff --git a/test/base-url-resolution.test.ts b/test/base-url-resolution.test.ts index 7e268a02..3b4f9665 100644 --- a/test/base-url-resolution.test.ts +++ b/test/base-url-resolution.test.ts @@ -48,10 +48,10 @@ describe("resolveBaseUrl", () => { const project = await sentry.client.prepareRequest({ method: "GET", - url: "/api/0/projects/{org}/{project}/", + url: "/projects/{org}/{project}/", path: { org: "acme", project: "web" }, }); - const unscoped = await sentry.client.prepareRequest({ method: "GET", url: "/api/0/organizations/" }); + const unscoped = await sentry.client.prepareRequest({ method: "GET", url: "/organizations/" }); expect(seen.map(request => request.scope)).toEqual([{ organization: "acme", project: "web" }, undefined]); expect("scope" in seen[1]!).toBe(false); @@ -70,7 +70,7 @@ describe("resolveBaseUrl", () => { const request = await sentry.client.prepareRequest({ method: "POST", - url: "/api/0/organizations/acme/releases/?existing=1", + url: "/organizations/acme/releases/?existing=1", body: { version: "1.0.0" }, }); diff --git a/test/client-types.ts b/test/client-types.ts index 3710ac4d..56358d93 100644 --- a/test/client-types.ts +++ b/test/client-types.ts @@ -33,10 +33,10 @@ function publicFactory() { async function preparedRequests(sentry: SentryClient) { const request: Request = await sentry.client.prepareRequest({ - method: "GET", url: "/api/0/custom/", query: { cursor: "opaque" }, + method: "GET", url: "/custom/", query: { cursor: "opaque" }, }); // @ts-expect-error Generic preparation requires an explicit method. - await sentry.client.prepareRequest({ url: "/api/0/custom/" }); + await sentry.client.prepareRequest({ url: "/custom/" }); // Custom clients accepted by standalone operations need no new capability. const { prepareRequest, ...legacyClient } = sentry.client; await listOrganizations({ client: legacyClient }); @@ -45,7 +45,7 @@ async function preparedRequests(sentry: SentryClient) { async function streamingCallbacks(sentry: SentryClient) { const { stream } = await sentry.client.sse.get({ - url: "/api/0/events/", + url: "/events/", onSseEvent: event => { const value: string = event.data.toUpperCase(); // @ts-expect-error SSE callback data retains the requested type. diff --git a/test/client.test.ts b/test/client.test.ts index c3de7254..914143d2 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -33,7 +33,7 @@ describe("configured operation methods", () => { return request; }); const { stream } = await sentry.client.sse.get({ - url: "/api/0/events/", + url: "/events/", security: [{ type: "http", scheme: "bearer" }], onSseEvent: event => { callbacks.push(event.data.toUpperCase()); }, }); diff --git a/test/normalize-spec.test.ts b/test/normalize-spec.test.ts index 93221896..3b538af1 100644 --- a/test/normalize-spec.test.ts +++ b/test/normalize-spec.test.ts @@ -87,39 +87,39 @@ describe("singularize", () => { // --------------------------------------------------------------------------- describe("parsePath", () => { - it("strips /api/0/ prefix and trailing slash", () => { - const segs = parsePath("/api/0/organizations/"); + it("strips the leading and trailing slashes", () => { + const segs = parsePath("/organizations/"); expect(segs).toEqual([ { value: "organizations", singularValue: "organization", isParam: false, followedByParam: false }, ]); }); it("marks path params correctly", () => { - const segs = parsePath("/api/0/organizations/{org}/issues/{id}/"); + const segs = parsePath("/organizations/{org}/issues/{id}/"); expect(segs.map((s) => s.isParam)).toEqual([false, true, false, true]); expect(segs.map((s) => s.followedByParam)).toEqual([true, false, true, false]); }); it("converts kebab-case to camelCase", () => { - const segs = parsePath("/api/0/organizations/{org}/events-timeseries/"); + const segs = parsePath("/organizations/{org}/events-timeseries/"); const values = segs.filter((s) => !s.isParam).map((s) => s.value); expect(values).toEqual(["organizations", "eventsTimeseries"]); }); it("handles uppercase letters after a hyphen", () => { - const segs = parsePath("/api/0/foo-Bar/"); + const segs = parsePath("/foo-Bar/"); expect(segs[0]?.value).toBe("fooBar"); }); it("converts underscores to camelCase (e.g. stats_v2 → statsV2)", () => { - const segs = parsePath("/api/0/organizations/{org}/stats_v2/"); + const segs = parsePath("/organizations/{org}/stats_v2/"); const statics = segs.filter((s) => !s.isParam); expect(statics.at(-1)?.value).toBe("statsV2"); }); it("singularValue singularizes the last word of a compound segment", () => { const segs = parsePath( - "/api/0/organizations/{org}/release-threshold-statuses/{id}/", + "/organizations/{org}/release-threshold-statuses/{id}/", ); const statics = segs.filter((s) => !s.isParam); // "statuses" (last word) hits IRREGULAR → "status"; full camelCase result: @@ -131,13 +131,13 @@ describe("parsePath", () => { it("handles consecutive separators without crashing", () => { // "foo--bar" and "a__b" must not crash on the empty word between separators. // Both value and singularValue go through the same split-filter-join path. - const segs = parsePath("/api/0/foo--bar/a__b/"); + const segs = parsePath("/foo--bar/a__b/"); expect(segs[0]?.value).toBe("fooBar"); expect(segs[0]?.singularValue).toBe("fooBar"); expect(segs[1]?.value).toBe("aB"); }); - it("drops empty segments from leading slashes (non-/api/0/ paths)", () => { + it("drops empty segments from leading slashes", () => { const segs = parsePath("/custom/resources/"); expect(segs.every((s) => s.value.length > 0)).toBe(true); expect(segs.map((s) => s.value)).toEqual(["custom", "resources"]); @@ -145,7 +145,7 @@ describe("parsePath", () => { it("returns empty array for a path of only params", () => { // parsePath itself never throws — normalizeOperationId validates the result - const segs = parsePath("/api/0/{a}/{b}/"); + const segs = parsePath("/{a}/{b}/"); expect(segs.every((s) => s.isParam)).toBe(true); }); }); @@ -156,50 +156,50 @@ describe("parsePath", () => { describe("normalizeOperationId", () => { it("GET collection → list", () => { - expect(normalizeOperationId("get", "/api/0/organizations/")).toBe( + expect(normalizeOperationId("get", "/organizations/")).toBe( "listOrganizations", ); expect( - normalizeOperationId("get", "/api/0/organizations/{org}/issues/"), + normalizeOperationId("get", "/organizations/{org}/issues/"), ).toBe("listOrganizationIssues"); expect( normalizeOperationId( "get", - "/api/0/organizations/{org}/issues/{id}/events/", + "/organizations/{org}/issues/{id}/events/", ), ).toBe("listOrganizationIssueEvents"); }); it("GET detail (ends in param) → get + singularized", () => { expect( - normalizeOperationId("get", "/api/0/organizations/{org}/"), + normalizeOperationId("get", "/organizations/{org}/"), ).toBe("getOrganization"); expect( - normalizeOperationId("get", "/api/0/organizations/{org}/issues/{id}/"), + normalizeOperationId("get", "/organizations/{org}/issues/{id}/"), ).toBe("getOrganizationIssue"); expect( normalizeOperationId( "get", - "/api/0/organizations/{org}/dashboards/{id}/", + "/organizations/{org}/dashboards/{id}/", ), ).toBe("getOrganizationDashboard"); }); it("POST → create + singularized last segment", () => { expect( - normalizeOperationId("post", "/api/0/organizations/{org}/dashboards/"), + normalizeOperationId("post", "/organizations/{org}/dashboards/"), ).toBe("createOrganizationDashboard"); expect( - normalizeOperationId("post", "/api/0/organizations/{org}/teams/"), + normalizeOperationId("post", "/organizations/{org}/teams/"), ).toBe("createOrganizationTeam"); }); it("PUT/PATCH → update", () => { expect( - normalizeOperationId("put", "/api/0/organizations/{org}/dashboards/{id}/"), + normalizeOperationId("put", "/organizations/{org}/dashboards/{id}/"), ).toBe("updateOrganizationDashboard"); expect( - normalizeOperationId("patch", "/api/0/organizations/{org}/issues/{id}/"), + normalizeOperationId("patch", "/organizations/{org}/issues/{id}/"), ).toBe("updateOrganizationIssue"); }); @@ -207,14 +207,14 @@ describe("normalizeOperationId", () => { expect( normalizeOperationId( "delete", - "/api/0/organizations/{org}/dashboards/{id}/", + "/organizations/{org}/dashboards/{id}/", ), ).toBe("deleteOrganizationDashboard"); }); it("DELETE collection (bulk) → plural", () => { expect( - normalizeOperationId("delete", "/api/0/organizations/{org}/detectors/"), + normalizeOperationId("delete", "/organizations/{org}/detectors/"), ).toBe("deleteOrganizationDetectors"); }); @@ -222,26 +222,26 @@ describe("normalizeOperationId", () => { expect( normalizeOperationId( "get", - "/api/0/organizations/{org}/events-timeseries/", + "/organizations/{org}/events-timeseries/", ), ).toBe("listOrganizationEventsTimeseries"); expect( normalizeOperationId( "get", - "/api/0/organizations/{org}/release-threshold-statuses/", + "/organizations/{org}/release-threshold-statuses/", ), ).toBe("listOrganizationReleaseThresholdStatuses"); }); it("handles uppercase letters after a hyphen", () => { - expect(normalizeOperationId("get", "/api/0/foo-Bar/")).toBe("listFooBar"); + expect(normalizeOperationId("get", "/foo-Bar/")).toBe("listFooBar"); }); it("converts underscores in path segments to camelCase", () => { - expect(normalizeOperationId("get", "/api/0/organizations/{org}/stats_v2/")).toBe( + expect(normalizeOperationId("get", "/organizations/{org}/stats_v2/")).toBe( "listOrganizationStatsV2", ); - expect(normalizeOperationId("get", "/api/0/organizations/{org}/relay_usage/")).toBe( + expect(normalizeOperationId("get", "/organizations/{org}/relay_usage/")).toBe( "listOrganizationRelayUsage", ); }); @@ -251,7 +251,7 @@ describe("normalizeOperationId", () => { expect( normalizeOperationId( "get", - "/api/0/organizations/{org}/release-threshold-statuses/{id}/", + "/organizations/{org}/release-threshold-statuses/{id}/", ), ).toBe("getOrganizationReleaseThresholdStatus"); // not "...Statuse" @@ -259,7 +259,7 @@ describe("normalizeOperationId", () => { expect( normalizeOperationId( "post", - "/api/0/organizations/{org}/external-issues/", + "/organizations/{org}/external-issues/", ), ).toBe("createOrganizationExternalIssue"); }); @@ -268,58 +268,52 @@ describe("normalizeOperationId", () => { expect( normalizeOperationId( "get", - "/api/0/projects/{org}/{project}/keys/", + "/projects/{org}/{project}/keys/", ), ).toBe("listProjectKeys"); expect( normalizeOperationId( "get", - "/api/0/projects/{org}/{project}/keys/{id}/", + "/projects/{org}/{project}/keys/{id}/", ), ).toBe("getProjectKey"); }); it("singularizes parent segments that precede a path param", () => { expect( - normalizeOperationId("get", "/api/0/organizations/{org}/issues/"), + normalizeOperationId("get", "/organizations/{org}/issues/"), ).toBe("listOrganizationIssues"); expect( normalizeOperationId( "get", - "/api/0/organizations/{org}/issues/{id}/events/", + "/organizations/{org}/issues/{id}/events/", ), ).toBe("listOrganizationIssueEvents"); }); it("handles consecutive params (/projects/{org}/{project}/)", () => { expect( - normalizeOperationId("get", "/api/0/projects/{org}/{project}/"), + normalizeOperationId("get", "/projects/{org}/{project}/"), ).toBe("getProject"); }); - it("handles paths not under /api/0/ prefix", () => { - expect(normalizeOperationId("get", "/custom/resources/")).toBe( - "listCustomResources", - ); - }); - it("trailing slash presence does not affect the result", () => { - expect(normalizeOperationId("get", "/api/0/organizations")).toBe( + expect(normalizeOperationId("get", "/organizations")).toBe( "listOrganizations", ); - expect(normalizeOperationId("get", "/api/0/organizations/")).toBe( + expect(normalizeOperationId("get", "/organizations/")).toBe( "listOrganizations", ); }); it("throws when path has no static segments", () => { - expect(() => normalizeOperationId("get", "/api/0/{id}/")).toThrow( + expect(() => normalizeOperationId("get", "/{id}/")).toThrow( /no .*usable static segments/, ); }); it("throws on degenerate all-param path", () => { - expect(() => normalizeOperationId("get", "/api/0/{a}/{b}/")).toThrow( + expect(() => normalizeOperationId("get", "/{a}/{b}/")).toThrow( /no .*usable static segments/, ); }); @@ -328,10 +322,10 @@ describe("normalizeOperationId", () => { // splitWords("---") → [] → camelJoin → "", so statics.length is 1 but the // assembled resource is empty. The guard must catch this, not crash on // resource[0].toUpperCase(). - expect(() => normalizeOperationId("get", "/api/0/---/")).toThrow( + expect(() => normalizeOperationId("get", "/---/")).toThrow( /no .*usable static segments/, ); - expect(() => normalizeOperationId("get", "/api/0/___/")).toThrow( + expect(() => normalizeOperationId("get", "/___/")).toThrow( /no .*usable static segments/, ); }); @@ -339,10 +333,10 @@ describe("normalizeOperationId", () => { it("skips interior all-separator segments instead of crashing", () => { // An interior segment of pure separators camelCases to "". It must be // dropped before assembly, not crash camelJoin on p[0].toUpperCase(). - expect(normalizeOperationId("get", "/api/0/organizations/---/issues/")).toBe( + expect(normalizeOperationId("get", "/organizations/---/issues/")).toBe( "listOrganizationsIssues", ); - expect(normalizeOperationId("get", "/api/0/orgs/___/issues/")).toBe( + expect(normalizeOperationId("get", "/orgs/___/issues/")).toBe( "listOrgsIssues", ); }); @@ -360,10 +354,10 @@ describe("normalizeSpec collision detection", () => { } it("throws when two different paths normalize to the same name", () => { - // /api/0/x-y/ and /api/0/xY/ both produce "listXY" after kebab→camelCase. + // /x-y/ and /xY/ both produce "listXY" after kebab→camelCase. const input = writeSpec({ - "/api/0/x-y/": { get: { operationId: "List X Y" } }, - "/api/0/xY/": { get: { operationId: "List XY" } }, + "/x-y/": { get: { operationId: "List X Y" } }, + "/xY/": { get: { operationId: "List XY" } }, }); const output = join(tmpdir(), `out-${Date.now()}.json`); expect(() => normalizeSpec(input, output)).toThrow(/collision/); @@ -376,11 +370,11 @@ describe("normalizeSpec collision detection", () => { // "listOrganizationIssues" (clean) and path B sentence-style normalizes to // the same name, that's a collision even though B's ID was a sentence. const input = writeSpec({ - "/api/0/something/": { + "/something/": { // Manually set to the same name that the other path would generate. get: { operationId: "listOrganizationIssues" }, }, - "/api/0/organizations/{org}/issues/": { + "/organizations/{org}/issues/": { // Sentence-style → normalizes to "listOrganizationIssues". get: { operationId: "List An Organization's Issues" }, }, @@ -393,7 +387,7 @@ describe("normalizeSpec collision detection", () => { it("does not throw when operationIds are already clean identifiers", () => { const input = writeSpec({ - "/api/0/organizations/": { + "/organizations/": { get: { operationId: "listOrganizations" }, // already clean — skipped }, }); @@ -405,7 +399,7 @@ describe("normalizeSpec collision detection", () => { it("rewrites sentence-style operationIds and leaves clean ones alone", () => { const input = writeSpec({ - "/api/0/organizations/": { + "/organizations/": { get: { operationId: "List Your Organizations" }, post: { operationId: "createOrganization" }, // already clean }, @@ -413,10 +407,10 @@ describe("normalizeSpec collision detection", () => { const output = join(tmpdir(), `out-${Date.now()}.json`); normalizeSpec(input, output); const result = JSON.parse(readFileSync(output, "utf8")); - expect(result.paths["/api/0/organizations/"].get.operationId).toBe( + expect(result.paths["/organizations/"].get.operationId).toBe( "listOrganizations", ); - expect(result.paths["/api/0/organizations/"].post.operationId).toBe( + expect(result.paths["/organizations/"].post.operationId).toBe( "createOrganization", // untouched ); unlinkSync(input); diff --git a/test/operations.test.ts b/test/operations.test.ts index a4b49a26..dab5e47d 100644 --- a/test/operations.test.ts +++ b/test/operations.test.ts @@ -1,14 +1,15 @@ import { describe, expect, test } from "vitest"; import { createClient } from "@hey-api/openapi-ts"; -import { operations } from "@sentry/api/operations"; +import { API_BASE_PATH, operations } from "@sentry/api/operations"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { splitApiBasePath } from "../lib/api-base-path.mjs"; import { extractOperations } from "../lib/operation-catalog.mjs"; import { normalizeSpec } from "../lib/normalize-spec.mjs"; import * as sdk from "../src/sdk.gen"; -const eventsPath = "/api/0/organizations/{organization_id_or_slug}/events/"; +const eventsPath = "/organizations/{organization_id_or_slug}/events/"; // The events endpoint demonstrates both dataset discovery and repeated fields. const fixture = (datasets = ["logs", "spans"]) => ({ @@ -75,7 +76,7 @@ describe("operation discovery", () => { }]); const next = fixture(["logs", "spans", "new_dataset"]); - const newPath = "/api/0/new-capability/"; + const newPath = "/new-capability/"; const grown = await generate({ ...next, paths: { @@ -106,8 +107,8 @@ describe("operation discovery", () => { const { catalog } = await generate({ ...fixture(), paths: { - "/api/0/assemble/": { post: { operationId: "assemble", ...response, "x-sentry-idempotent": true } }, - "/api/0/things/": { + "/assemble/": { post: { operationId: "assemble", ...response, "x-sentry-idempotent": true } }, + "/things/": { post: { operationId: "createThing", ...response }, put: { operationId: "updateThing", ...response }, delete: { operationId: "deleteThing", ...response }, @@ -124,7 +125,7 @@ describe("operation discovery", () => { const { catalog } = await generate({ ...fixture(), paths: { - "/api/0/things/": { + "/things/": { get: { operationId: "listThings", ...response, "x-sentry-experimental": true }, post: { operationId: "createThing", ...response }, }, @@ -145,9 +146,21 @@ describe("operation discovery", () => { expect(source.match(/\*\*Experimental:\*\*/g)).toHaveLength(experimental.length); }); + test.each([ + ["the server URL", "https://{region}.sentry.io/api/0", "/organizations/", "/api/0"], + ["the paths, while Sentry's server URL lacks it", "https://{region}.sentry.io", "/api/0/organizations/", "/api/0"], + ["a later API version", "https://{region}.sentry.io/api/1", "/organizations/", "/api/1"], + ])("reads the API base path from %s", (_, url, path, basePath) => { + const spec = { servers: [{ url }], paths: { [path]: { get: { servers: [{ url }] } } } }; + expect(splitApiBasePath(spec)).toBe(basePath); + const origin = [{ url: "https://{region}.sentry.io" }]; + expect(spec).toEqual({ servers: origin, paths: { "/organizations/": { get: { servers: origin } } } }); + }); + test("ships one catalog entry for every actual standalone SDK export", () => { expect(operations.map(operation => operation.functionName).sort()).toEqual(Object.keys(sdk).sort()); const original = JSON.parse(readFileSync(new URL("../openapi-derefed.json", import.meta.url), "utf8")); + expect(splitApiBasePath(original)).toBe(API_BASE_PATH); const source = readFileSync(new URL("../src/sdk.gen.ts", import.meta.url), "utf8"); expect(operations).toEqual(extractOperations(original, source)); }); diff --git a/test/package.test.ts b/test/package.test.ts index 4c6c9d08..5f15a464 100644 --- a/test/package.test.ts +++ b/test/package.test.ts @@ -87,7 +87,7 @@ describe("package entry points", () => { assert.equal("analyzeSourceApiUsage" in client, false); const sentry = client.createSentryClient({baseUrl: "https://example.test/prefix"}); const request = await sentry.client.prepareRequest({ - method: "POST", url: "/api/0/custom/?field=id&field=name&cursor=old#view", + method: "POST", url: "/custom/?field=id&field=name&cursor=old#view", query: {cursor: "opaque:next"}, body: {message: "example"}, }); assert.equal(request.url, "https://example.test/prefix/api/0/custom/?field=id&field=name&cursor=opaque%3Anext"); @@ -146,7 +146,7 @@ describe("package entry points", () => { writeFileSync(join(directory, "types.ts"), ` import { consumePage, type PaginationContinuation, type PagePosition, type SentryClient } from "@sentry/api"; declare const sentry: SentryClient; - const prepared: Promise = sentry.client.prepareRequest({method: "GET", url: "/api/0/custom/"}); + const prepared: Promise = sentry.client.prepareRequest({method: "GET", url: "/custom/"}); const position: PagePosition = {cursor: "opaque", offset: 2}; const resume: PaginationContinuation = {...position, requestBudget: 10}; const rows: string[] = consumePage({data: ["one"]}, position).data; diff --git a/test/request-preparation.test.ts b/test/request-preparation.test.ts index a8b1762d..2e4b85e4 100644 --- a/test/request-preparation.test.ts +++ b/test/request-preparation.test.ts @@ -34,7 +34,7 @@ describe("request preparation", () => { }); const options = { method: "POST" as const, - url: "/api/0/organizations/{org}/items/", + url: "/organizations/{org}/items/", path: { org: "example" }, body: { title: "Overview" }, headers: { "x-override": "per-call" }, @@ -68,7 +68,7 @@ describe("request preparation", () => { try { const config = createBrowserSdkConfig({ baseUrl: "https://example.test", getCsrfToken: () => "csrf-example" }); const sentry = createSentryClient({ ...config, fetch: asFetch(request => config.fetch(request)) }); - const prepared = await sentry.client.prepareRequest({ method: "POST", url: "/api/0/custom/", body: { value: 1 } }); + const prepared = await sentry.client.prepareRequest({ method: "POST", url: "/custom/", body: { value: 1 } }); expect(requests).toHaveLength(0); expect(prepared.headers.has("X-CSRFToken")).toBe(false); const raw = await config.fetch(prepared); @@ -91,7 +91,7 @@ describe("request preparation", () => { }); const options = { method: "GET" as const, - url: "/api/0/items/a%2Fb/?cursor=old&cursor=older&field=id&field=name&?cursor=unrelated&query=a%20b#ignored", + url: "/items/a%2Fb/?cursor=old&cursor=older&field=id&field=name&?cursor=unrelated&query=a%20b#ignored", query: { cursor: "opaque/+?=#", per_page: 25 }, }; const request = await sentry.client.prepareRequest(options); @@ -111,7 +111,7 @@ describe("request preparation", () => { test("clears a cursor for the first page while leaving unspecified query parameters intact", async () => { const sentry = createSentryClient({ baseUrl: "https://example.test" }); const request = await sentry.client.prepareRequest({ - method: "GET", url: "/api/0/items/?cursor=one&cursor=two&per_page=25&field=id&field=name", + method: "GET", url: "/items/?cursor=one&cursor=two&per_page=25&field=id&field=name", query: { cursor: null, per_page: undefined }, }); const query = new URL(request.url).searchParams; @@ -123,7 +123,7 @@ describe("request preparation", () => { test("retains a custom serializer's bytes when overlaying an existing query", async () => { const sentry = createSentryClient({ baseUrl: "https://example.test" }); const request = await sentry.client.prepareRequest({ - method: "GET", url: "/api/0/items/?cursor=old&field=old&%3Fcursor=old&query=a%20b", + method: "GET", url: "/items/?cursor=old&field=old&%3Fcursor=old&query=a%20b", query: { cursor: "custom/cursor value", field: ["id", "name"] }, querySerializer: () => "??cursor=literal&cursor=custom%2fcursor+value&field=id&field=name", }); @@ -133,7 +133,7 @@ describe("request preparation", () => { test("supports native raw and multipart bodies without JSON serialization", async () => { const sentry = createSentryClient({ baseUrl: "https://example.test" }); const raw = await sentry.client.prepareRequest({ - method: "POST", url: "/api/0/custom/", body: "raw payload", bodySerializer: null, + method: "POST", url: "/custom/", body: "raw payload", bodySerializer: null, headers: { "Content-Type": "text/custom" }, }); expect(raw.headers.get("Content-Type")).toBe("text/custom"); @@ -142,7 +142,7 @@ describe("request preparation", () => { form.append("tag", "one"); form.append("tag", "two"); const multipart = await sentry.client.prepareRequest({ - method: "POST", url: "/api/0/upload/", body: form, bodySerializer: null, + method: "POST", url: "/upload/", body: form, bodySerializer: null, headers: { "Content-Type": null }, }); expect(multipart.headers.get("Content-Type")).toMatch(/^multipart\/form-data; boundary=/); @@ -155,7 +155,7 @@ describe("request preparation", () => { controller.abort(reason); let calls = 0; const sentry = createSentryClient({ baseUrl: "https://example.test", fetch: asFetch(async () => { calls++; return new Response(); }) }); - const request = await sentry.client.prepareRequest({ method: "GET", url: "/api/0/items/", signal: controller.signal }); + const request = await sentry.client.prepareRequest({ method: "GET", url: "/items/", signal: controller.signal }); expect(request.signal.aborted).toBe(true); expect(request.signal.reason).toBe(reason); expect(() => request.signal.throwIfAborted()).toThrow(reason); @@ -166,7 +166,7 @@ describe("request preparation", () => { const first = createSentryClient({ baseUrl: "https://first.example.test", headers: { "X-Context": "first" } }); const second = createSentryClient({ baseUrl: "https://second.example.test", headers: { "X-Context": "second" } }); const { prepareRequest } = first.client; - const options = { method: "POST" as const, url: "/api/0/items/", body: { value: "original" } }; + const options = { method: "POST" as const, url: "/items/", body: { value: "original" } }; const [prepared, other] = await Promise.all([prepareRequest(options), second.client.prepareRequest(options)]); first.client.setConfig({ baseUrl: "https://changed.example.test", headers: { "X-Context": "changed" } }); options.body.value = "edited"; diff --git a/test/source-analysis-bindings.test.ts b/test/source-analysis-bindings.test.ts index 6120f84a..3acdf168 100644 --- a/test/source-analysis-bindings.test.ts +++ b/test/source-analysis-bindings.test.ts @@ -5,12 +5,12 @@ const operations = [ { functionName: "deleteProject", method: "DELETE", - path: "/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/", + path: "/projects/{organization_id_or_slug}/{project_id_or_slug}/", }, { functionName: "listOrganizationEvents", method: "GET", - path: "/api/0/organizations/{organization_id_or_slug}/events/", + path: "/organizations/{organization_id_or_slug}/events/", }, ] as const satisfies readonly SourceApiOperation[]; diff --git a/test/source-analysis-modules.test.ts b/test/source-analysis-modules.test.ts index 07efe209..065adaae 100644 --- a/test/source-analysis-modules.test.ts +++ b/test/source-analysis-modules.test.ts @@ -2,8 +2,8 @@ import { describe, expect, test } from "vitest"; import { analyzeSourceApiUsage, type SourceApiOperation } from "../lib/source-analysis.ts"; const operations = [ - { functionName: "deleteProject", method: "DELETE", path: "/api/0/projects/{org}/{project}/" }, - { functionName: "listOrganizations", method: "GET", path: "/api/0/organizations/" }, + { functionName: "deleteProject", method: "DELETE", path: "/projects/{org}/{project}/" }, + { functionName: "listOrganizations", method: "GET", path: "/organizations/" }, ] as const satisfies readonly SourceApiOperation[]; const helper = `import { deleteProject } from "@sentry/api"; export const read = () => deleteProject({});`; diff --git a/test/source-analysis-values.test.ts b/test/source-analysis-values.test.ts index 88547148..cc43eb3e 100644 --- a/test/source-analysis-values.test.ts +++ b/test/source-analysis-values.test.ts @@ -4,7 +4,7 @@ import { analyzeSourceApiUsage, type SourceApiOperation } from "../lib/source-an const operation = { functionName: "listOrganizationEvents", method: "GET", - path: "/api/0/organizations/{organization_id_or_slug}/events/", + path: "/organizations/{organization_id_or_slug}/events/", } as const satisfies SourceApiOperation; describe("source analysis value certainty", () => { diff --git a/test/source-analysis.test.ts b/test/source-analysis.test.ts index 15edb2d9..d1aa212b 100644 --- a/test/source-analysis.test.ts +++ b/test/source-analysis.test.ts @@ -5,17 +5,17 @@ const operations = [ { functionName: "listOrganizationEvents", method: "GET", - path: "/api/0/organizations/{organization_id_or_slug}/events/", + path: "/organizations/{organization_id_or_slug}/events/", }, { functionName: "getOrganizationTrace", method: "GET", - path: "/api/0/organizations/{organization_id_or_slug}/trace/{trace_id}/", + path: "/organizations/{organization_id_or_slug}/trace/{trace_id}/", }, { functionName: "deleteProject", method: "DELETE", - path: "/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/", + path: "/projects/{organization_id_or_slug}/{project_id_or_slug}/", }, ] as const satisfies readonly SourceApiOperation[]; @@ -136,7 +136,7 @@ describe("consumer source analysis", () => { ]); const result = analyzeSourceApiUsage(sources, [ ...operations, - { functionName: "otherEvents", method: "GET", path: "/api/0/organizations/{org}/events/" }, + { functionName: "otherEvents", method: "GET", path: "/organizations/{org}/events/" }, ], { requests: [{ functionName: "request", pathArgument: 0, optionsArgument: 1 }], }).exports.get("src/requests.ts")!; From 1f41687f83b3c12e1700693a9f92b2f19a359130 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:14:42 +0000 Subject: [PATCH 2/2] ref(api-base-path): Parse server URLs with URL, not a regex Split the spec's server URL with the URL parser, and find the legacy /api/ prefix by path segments. A new test fails on any regex that matches a URL scheme separator, and AGENTS.md documents the rule. --- AGENTS.md | 4 +++ lib/api-base-path.mjs | 23 ++++++++------ test/base-url-resolution.test.ts | 5 +-- test/url-parsing.test.ts | 54 ++++++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 11 deletions(-) create mode 100644 test/url-parsing.test.ts diff --git a/AGENTS.md b/AGENTS.md index 8fa90347..26c51392 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,3 +7,7 @@ For long-term knowledge entries managed by [lore](https://github.com/BYK/loreai) ## Toolchain 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. + +## 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 (`://`). diff --git a/lib/api-base-path.mjs b/lib/api-base-path.mjs index fec65344..9da0d0db 100644 --- a/lib/api-base-path.mjs +++ b/lib/api-base-path.mjs @@ -8,20 +8,25 @@ * origin. */ -const SERVER_URL = /^([a-z][a-z0-9+.-]*:\/\/[^/]*)(\/.*)?$/i; - -// TODO: Remove once Sentry's spec declares the base path in `servers`. -// Until then, every path starts with the same `/api/`. -const LEGACY_BASE_PATH = /^\/api\/[^/]+(?=\/)/; +function trimTrailingSlashes(path) { + let end = path.length; + while (end > 0 && path[end - 1] === "/") end -= 1; + return path.slice(0, end); +} function splitServerUrl(url) { - const match = SERVER_URL.exec(url); - if (!match) throw new Error(`Unsupported server URL: ${url}`); - return { origin: match[1], path: (match[2] ?? "").replace(/\/+$/, "") }; + if (!URL.canParse(url)) throw new Error(`Unsupported server URL: ${url}`); + const { origin, pathname } = new URL(url); + return { origin, path: trimTrailingSlashes(pathname) }; } +// TODO: Remove once Sentry's spec declares the base path in `servers`. +// Until then, every path starts with the same `/api//`. function legacyBasePath(paths) { - const prefixes = new Set(Object.keys(paths).map(path => LEGACY_BASE_PATH.exec(path)?.[0])); + const prefixes = new Set(Object.keys(paths).map(path => { + const [root, api, version, ...rest] = path.split("/"); + return root === "" && api === "api" && version && rest.length > 0 ? `/api/${version}` : undefined; + })); const [prefix] = prefixes; if (prefixes.size !== 1 || prefix === undefined) { throw new Error("The server URL has no base path, and the paths do not share one"); diff --git a/test/base-url-resolution.test.ts b/test/base-url-resolution.test.ts index 3b4f9665..936cf863 100644 --- a/test/base-url-resolution.test.ts +++ b/test/base-url-resolution.test.ts @@ -31,8 +31,9 @@ describe("resolveBaseUrl", () => { scope: { organization: "acme" }, }]); expect(urls).toHaveLength(1); - expect(urls[0]).toMatch(/^https:\/\/us\.sentry\.io\/api\/0\/organizations\/acme\/issues\/\?/); - expect(new URL(urls[0]!).searchParams.get("query")).toBe("is:unresolved"); + const url = new URL(urls[0]!); + expect(`${url.origin}${url.pathname}`).toBe("https://us.sentry.io/api/0/organizations/acme/issues/"); + expect(url.searchParams.get("query")).toBe("is:unresolved"); }); test("passes the project scope and omits scope for unscoped paths", async () => { diff --git a/test/url-parsing.test.ts b/test/url-parsing.test.ts new file mode 100644 index 00000000..fb0289f4 --- /dev/null +++ b/test/url-parsing.test.ts @@ -0,0 +1,54 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import ts from "typescript"; +import { describe, expect, test } from "vitest"; +import { packageRoot } from "./packed-package.ts"; + +/** + * Lines with a regular expression that matches a URL scheme separator. The + * repo parses URLs with `URL` (`new URL`, `URL.canParse`), not regexes. + */ +function urlRegexLines(fileName: string, source: string): number[] { + const file = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true); + const lines: number[] = []; + const visit = (node: ts.Node): void => { + const pattern = ts.isRegularExpressionLiteral(node) + ? node.text + : (ts.isNewExpression(node) || ts.isCallExpression(node)) + && ts.isIdentifier(node.expression) + && node.expression.text === "RegExp" + && node.arguments?.[0] + && ts.isStringLiteralLike(node.arguments[0]) + ? node.arguments[0].text + : undefined; + if (pattern?.includes("://") || pattern?.includes(":\\/\\/")) { + lines.push(file.getLineAndCharacterOfPosition(node.getStart()).line + 1); + } + ts.forEachChild(node, visit); + }; + visit(file); + return lines; +} + +const SOURCE_EXTENSIONS = [".ts", ".mts", ".cts", ".js", ".mjs", ".cjs"]; + +describe("URL parsing", () => { + test.each([ + ["a regex literal", "const r = /^(https?:\\/\\/[^/]*)/;", [1]], + ["a RegExp constructor", 'const r = new RegExp("^https://");', [1]], + ["a path-only regex", "const r = /\\/api\\/0\\//;", []], + ["URL parsing", 'const { origin } = new URL("https://sentry.io/api/0");', []], + ])("flags %s", (_, source, lines) => { + expect(urlRegexLines("example.ts", source)).toEqual(lines); + }); + + test("the repo parses URLs with URL, not regexes", () => { + const files = execFileSync("git", ["ls-files"], { cwd: packageRoot, encoding: "utf8" }) + .split("\n") + .filter(file => SOURCE_EXTENSIONS.some(extension => file.endsWith(extension))); + const offenders = files.flatMap(file => + urlRegexLines(file, readFileSync(`${packageRoot}/${file}`, "utf8")).map(line => `${file}:${line}`), + ); + expect(offenders).toEqual([]); + }); +});