From c92c1d8848c9f42187f6cfac4b6c90b09f9ec0bf Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 13 Nov 2026 09:50:30 +0000 Subject: [PATCH] feat(api-client): Export API_PREFIX from @sentry/api/client Sentry's OpenAPI spec starts every path with /api/0, and the client spelled that prefix out in four places. Export it once from api-path-scope.ts and use it in buildSentryApiUrl, parseApiPathScope, the SDK request check in core.ts, and cache invalidation, so hosts such as toolkit can import it rather than keep their own copy. A test checks that every path in the spec still starts with it. Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- lib/api-client/api-path-scope.ts | 15 +++++++++++++-- lib/api-client/api-path.ts | 9 +++++++-- lib/api-client/core.ts | 4 +--- lib/api-client/response-cache/invalidation.ts | 6 ++++-- test/operations.test.ts | 6 ++++++ 5 files changed, 31 insertions(+), 9 deletions(-) diff --git a/lib/api-client/api-path-scope.ts b/lib/api-client/api-path-scope.ts index 20a35611..a26e8877 100644 --- a/lib/api-client/api-path-scope.ts +++ b/lib/api-client/api-path-scope.ts @@ -3,6 +3,14 @@ * the generated client can use it without pulling in `@sentry/api/client`. */ +/** + * The path every Sentry API operation starts with, as the OpenAPI spec + * spells it. A self-hosted install may serve it under a path prefix. + */ +export const API_PREFIX = "/api/0"; + +const [API_SEGMENT, VERSION_SEGMENT] = API_PREFIX.slice(1).split("/"); + /** The organization, and project when present, an API path targets. */ export type ApiPathScope = { organization: string; project?: string }; @@ -16,7 +24,7 @@ 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_PREFIX}, or undefined for a URL outside the API. */ function apiPathSegments(urlOrPath: string): string[] | undefined { const isUrl = URL.canParse(urlOrPath); const path = isUrl @@ -24,7 +32,10 @@ function apiPathSegments(urlOrPath: string): string[] | undefined { : (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") { + if ( + segments[index] === API_SEGMENT && + segments[index + 1] === VERSION_SEGMENT + ) { return segments.slice(index + 2); } } diff --git a/lib/api-client/api-path.ts b/lib/api-client/api-path.ts index d13ea570..a2643530 100644 --- a/lib/api-client/api-path.ts +++ b/lib/api-client/api-path.ts @@ -1,6 +1,11 @@ +import { API_PREFIX } from "./api-path-scope.js"; import { UserInputError } from "./errors.js"; -export { type ApiPathScope, parseApiPathScope } from "./api-path-scope.js"; +export { + API_PREFIX, + type ApiPathScope, + parseApiPathScope, +} from "./api-path-scope.js"; /** * Encode one Sentry API path identifier without letting it become a URL path. @@ -26,7 +31,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_PREFIX}/${path}`; } /** diff --git a/lib/api-client/core.ts b/lib/api-client/core.ts index 458b7080..15997101 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_PREFIX, buildSentryApiUrl } from "./api-path.js"; import { type ApiError, ApiValidationError, @@ -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", diff --git a/lib/api-client/response-cache/invalidation.ts b/lib/api-client/response-cache/invalidation.ts index 074cefdd..a260dee4 100644 --- a/lib/api-client/response-cache/invalidation.ts +++ b/lib/api-client/response-cache/invalidation.ts @@ -27,7 +27,9 @@ * query-param variant cached under that path). */ -const API_V0_SEGMENT = "/api/0/"; +import { API_PREFIX } from "../api-path-scope.js"; + +const API_V0_SEGMENT = `${API_PREFIX}/`; const TRAILING_SLASH_RE = /\/$/; /** @@ -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_V0_SEGMENT}issues/${issueId}/`, ], }, ]; diff --git a/test/operations.test.ts b/test/operations.test.ts index a4b49a26..1ac018ab 100644 --- a/test/operations.test.ts +++ b/test/operations.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "vitest"; import { createClient } from "@hey-api/openapi-ts"; +import { API_PREFIX } from "@sentry/api/client"; import { operations } from "@sentry/api/operations"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -151,4 +152,9 @@ describe("operation discovery", () => { const source = readFileSync(new URL("../src/sdk.gen.ts", import.meta.url), "utf8"); expect(operations).toEqual(extractOperations(original, source)); }); + + test("starts every path in Sentry's spec with the exported API prefix", () => { + const original = JSON.parse(readFileSync(new URL("../openapi-derefed.json", import.meta.url), "utf8")); + expect(Object.keys(original.paths).filter(path => !path.startsWith(`${API_PREFIX}/`))).toEqual([]); + }); });