diff --git a/src/build/open-api/types.ts b/src/build/open-api/types.ts index 3ed36b1a07cb1..a5983e0933d43 100644 --- a/src/build/open-api/types.ts +++ b/src/build/open-api/types.ts @@ -36,12 +36,20 @@ type Tag = { 'x-sidebar-name': string; }; -type ServerMeta = { - description: string; +export type ServerVariable = { + default: string; + description?: string; + enum?: string[]; +}; + +export type ServerMeta = { + description?: string; url: string; + variables?: Record; }; export type DeRefedOpenAPI = { + servers?: ServerMeta[]; paths: { [key: string]: { [key: string]: { diff --git a/src/build/resolveOpenAPI.ts b/src/build/resolveOpenAPI.ts index 2c32a29c787d4..85697f4cd3fdd 100644 --- a/src/build/resolveOpenAPI.ts +++ b/src/build/resolveOpenAPI.ts @@ -2,7 +2,11 @@ import {promises as fs} from 'fs'; -import {DeRefedOpenAPI} from './open-api/types'; +import { + type DeRefedOpenAPI, + type ServerMeta, + type ServerVariable, +} from './open-api/types'; // SENTRY_API_SCHEMA_SHA is used in the sentry-docs GHA workflow in getsentry/sentry-api-schema. // DO NOT change variable name unless you change it in the sentry-docs GHA workflow in getsentry/sentry-api-schema. @@ -75,6 +79,7 @@ export type API = { descriptionMarkdown?: string; requestBodyContent?: any; security?: {[key: string]: string[]}; + serverVariables?: Record; summary?: string; }; @@ -133,17 +138,16 @@ async function apiCategoriesUncached(): Promise { const isDeprecated = isDeprecatedOperationId(apiData.operationId); const cleanOperationId = stripDeprecatedPrefix(apiData.operationId ?? ''); - let server = 'https://sentry.io'; - if (apiData.servers && apiData.servers[0]) { - server = apiData.servers[0].url; - } + const resolvedServer: ServerMeta = apiData.servers?.[0] ?? + data.servers?.[0] ?? {url: 'https://sentry.io'}; apiData.tags.forEach(tag => { categoryMap[tag].apis.push({ apiPath, method, name: cleanOperationId, deprecated: isDeprecated, - server, + server: resolvedServer.url, + serverVariables: resolvedServer.variables, slug: slugify(cleanOperationId), summary: apiData.summary, descriptionMarkdown: apiData.description, diff --git a/src/components/apiExamples/apiExamples.module.scss b/src/components/apiExamples/apiExamples.module.scss index 90ea811ac4fb3..7e6cc6f322519 100644 --- a/src/components/apiExamples/apiExamples.module.scss +++ b/src/components/apiExamples/apiExamples.module.scss @@ -19,3 +19,62 @@ margin: 0; } } + +.region-selector { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; + font-size: 0.8rem; +} + +.region-label { + color: var(--text-color); + font-weight: 500; +} + +.region-tabs { + display: flex; + border-radius: 3px; + overflow: hidden; + border: 1px solid var(--border-color); +} + +.region-tab { + background: var(--gray-100); + color: var(--text-color); + border: none; + border-right: 1px solid var(--border-color); + padding: 0.2rem 0.6rem; + cursor: pointer; + font-size: 0.75rem; + font-weight: 500; + transition: background-color 0.15s, color 0.15s; + + &:last-child { + border-right: none; + } + + &:hover:not(.region-tab-selected) { + background: var(--gray-200); + } +} + +:global(.dark) .region-tab { + background: #2d2d2d; + color: #9990ab; +} + +:global(.dark) .region-tab:hover:not(.region-tab-selected) { + background: #3d3d3d; +} + +.region-tab-selected { + background: var(--accent); + color: var(--white); +} + +:global(.dark) .region-tab-selected { + background: var(--accent); + color: var(--white); +} diff --git a/src/components/apiExamples/apiExamples.tsx b/src/components/apiExamples/apiExamples.tsx index bb909e1bed1dd..cf385ed7aca13 100644 --- a/src/components/apiExamples/apiExamples.tsx +++ b/src/components/apiExamples/apiExamples.tsx @@ -1,11 +1,12 @@ 'use client'; -import {Fragment, useEffect, useState} from 'react'; +import {Fragment, useContext, useEffect, useMemo, useState} from 'react'; import {Clipboard} from 'react-feather'; import {type API} from 'sentry-docs/build/resolveOpenAPI'; import {CodeBlock} from '../codeBlock'; import codeBlockStyles from '../codeBlock/code-blocks.module.scss'; +import {CodeContext} from '../codeContext'; import {CodeTabs} from '../codeTabs'; import {codeToJsx} from '../highlightCode'; import styles from './apiExamples.module.scss'; @@ -18,13 +19,63 @@ const strFormat = (str: string) => { return s + '.'; }; +function resolveServerUrl( + serverTemplate: string, + variables: Record | undefined, + overrides?: Record +): string { + if (!variables) { + return serverTemplate; + } + let url = serverTemplate; + for (const [name, variable] of Object.entries(variables)) { + const value = overrides?.[name] ?? variable.default; + url = url.replace(`{${name}}`, value); + } + return url; +} + +function detectRegionFromApiUrl(apiUrl: string): string | undefined { + const match = apiUrl.match(/^https?:\/\/(\w+)\.sentry\.io\//); + return match?.[1]; +} + type Props = { api: API; }; export function ApiExamples({api}: Props) { + const codeContext = useContext(CodeContext); + const regionVar = api.serverVariables?.region; + const regionOptions = useMemo(() => regionVar?.enum ?? [], [regionVar?.enum]); + + const [sharedSelection] = codeContext?.sharedKeywordSelection ?? [ + {} as Record, + ]; + const projectIdx = sharedSelection.PROJECT ?? 0; + const selectedProject = + codeContext?.codeKeywords.USER && codeContext.codeKeywords.PROJECT[projectIdx] + ? codeContext.codeKeywords.PROJECT[projectIdx] + : undefined; + + const userRegion = selectedProject + ? detectRegionFromApiUrl(selectedProject.API_URL) + : undefined; + + const [selectedRegion, setSelectedRegion] = useState(regionVar?.default ?? 'us'); + + useEffect(() => { + if (userRegion && regionOptions.includes(userRegion)) { + setSelectedRegion(userRegion); + } + }, [userRegion, regionOptions]); + + const resolvedServer = resolveServerUrl(api.server, api.serverVariables, { + region: selectedRegion, + }); + const apiExample = [ - `curl ${api.server}${api.apiPath}`, + `curl ${resolvedServer}${api.apiPath}`, ` -H 'Authorization: Bearer '`, ]; if (['put', 'options', 'delete'].includes(api.method.toLowerCase())) { @@ -84,6 +135,22 @@ export function ApiExamples({api}: Props) { return ( + {regionOptions.length > 1 && ( +
+ Region +
+ {regionOptions.map(region => ( + + ))} +
+
+ )}
{codeToJsx(apiExample.join(' \\\n'), 'bash')}