From b6aa0c28628d2d6b254177f73cb4826a27da0af0 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:29:06 +0000 Subject: [PATCH] feat(api): Read the API base path from the spec's server URL Sentry's OpenAPI spec is moving /api/0 out of every path and into its server URL (https://{region}.sentry.io/api/0). The API reference ignored the spec-level server, so pages and curl examples would lose /api/0. Join the server URL's path to each operation path, and keep only the origin as the server. Both spec shapes render the same pages. Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- src/build/open-api/types.ts | 1 + src/build/resolveOpenAPI.spec.ts | 22 +++++++++++++++++++++- src/build/resolveOpenAPI.ts | 27 +++++++++++++++++++++------ 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/build/open-api/types.ts b/src/build/open-api/types.ts index 20d07a6947d26..ce7bc72425b9b 100644 --- a/src/build/open-api/types.ts +++ b/src/build/open-api/types.ts @@ -78,6 +78,7 @@ export type DeRefedOpenAPI = { }; }; tags: Tag[]; + servers?: ServerMeta[]; }; export type ResponseContent = { diff --git a/src/build/resolveOpenAPI.spec.ts b/src/build/resolveOpenAPI.spec.ts index 62aef56e2acc3..52a9d563287e2 100644 --- a/src/build/resolveOpenAPI.spec.ts +++ b/src/build/resolveOpenAPI.spec.ts @@ -1,6 +1,6 @@ import {describe, expect, test} from 'vitest'; -import {toDocumentedParameter} from './resolveOpenAPI'; +import {apiLocation, toDocumentedParameter} from './resolveOpenAPI'; const EXPERIMENTAL_NOTICE = '**Experimental:** This parameter is under active development and may change.\n\n'; @@ -44,3 +44,23 @@ describe('toDocumentedParameter', () => { ); }); }); + +describe('apiLocation', () => { + const regional = [{url: 'https://{region}.sentry.io/api/0'}]; + + test.each([ + [ + 'paths carry the base path', + [{url: 'https://{region}.sentry.io'}], + undefined, + '/api/0/organizations/', + ], + ['the server URL carries it', regional, undefined, '/organizations/'], + ['an operation server carries it', regional, regional, '/organizations/'], + ])('shows the full path when %s', (_, specServers, operationServers, path) => { + expect(apiLocation(path, specServers, operationServers)).toEqual({ + apiPath: '/api/0/organizations/', + server: operationServers ? 'https://{region}.sentry.io' : 'https://sentry.io', + }); + }); +}); diff --git a/src/build/resolveOpenAPI.ts b/src/build/resolveOpenAPI.ts index 9eb45a1eb6ba4..a97963a4a4ad1 100644 --- a/src/build/resolveOpenAPI.ts +++ b/src/build/resolveOpenAPI.ts @@ -126,6 +126,25 @@ export function toDocumentedParameter( }; } +const DEFAULT_SERVER = 'https://sentry.io'; +const SERVER_URL = /^([a-z][a-z0-9+.-]*:\/\/[^/]*)(\/.*)?$/i; + +// Sentry's spec puts the API base path, such as `/api/0`, in its server URL, and its +// paths are relative to it. Pages show the origin and the full path after it. +export function apiLocation( + path: string, + specServers: {url: string}[] | undefined, + operationServers: {url: string}[] | undefined +): {apiPath: string; server: string} { + const operationServer = operationServers?.[0]?.url; + const [, origin = DEFAULT_SERVER, basePath = ''] = + SERVER_URL.exec(operationServer ?? specServers?.[0]?.url ?? '') ?? []; + return { + apiPath: `${basePath.replace(/\/+$/, '')}${path}`, + server: operationServer ? origin : DEFAULT_SERVER, + }; +} + function isDeprecatedOperationId(operationId: string | undefined): boolean { return operationId ? DEPRECATED_PREFIX_REGEX.test(operationId) : false; } @@ -168,18 +187,14 @@ async function apiCategoriesUncached(): Promise { const titleSource = apiData.summary || apiData.operationId || ''; const cleanName = stripDeprecatedPrefix(titleSource); - let server = 'https://sentry.io'; - if (apiData.servers && apiData.servers[0]) { - server = apiData.servers[0].url; - } + const location = apiLocation(apiPath, data.servers, apiData.servers); apiData.tags.forEach(tag => { categoryMap[tag].apis.push({ - apiPath, + ...location, method, name: cleanName, deprecated: isDeprecated, experimental: isExperimental, - server, slug: slugify(cleanName), summary: apiData.summary ? stripDeprecatedPrefix(apiData.summary)