From eab2a333a40822ad081b1e36b9439a1a301111b5 Mon Sep 17 00:00:00 2001 From: William Mak Date: Wed, 14 Oct 2026 14:56:52 -0400 Subject: [PATCH 1/2] feat(api): Show an experimental tag on OpenAPI parameters Parameters marked with x-sentry-experimental now get the same Experimental badge as experimental endpoints, without repeating the description notice. Co-authored-by: Cursor --- src/build/open-api/types.ts | 1 + src/build/resolveOpenAPI.spec.ts | 47 ++++++++++++++++++ src/build/resolveOpenAPI.ts | 83 ++++++++++++++++++++++++-------- src/components/apiPage/index.tsx | 12 ++++- 4 files changed, 121 insertions(+), 22 deletions(-) create mode 100644 src/build/resolveOpenAPI.spec.ts diff --git a/src/build/open-api/types.ts b/src/build/open-api/types.ts index d9311e252f7015..20d07a6947d26a 100644 --- a/src/build/open-api/types.ts +++ b/src/build/open-api/types.ts @@ -17,6 +17,7 @@ export type Parameter = { type: string; items?: {[key: string]: unknown}; }; + 'x-sentry-experimental'?: boolean; }; type Markdown = { diff --git a/src/build/resolveOpenAPI.spec.ts b/src/build/resolveOpenAPI.spec.ts new file mode 100644 index 00000000000000..431fcbd1f32595 --- /dev/null +++ b/src/build/resolveOpenAPI.spec.ts @@ -0,0 +1,47 @@ +import {describe, expect, test} from 'vitest'; + +import {toDocumentedParameter} from './resolveOpenAPI'; + +const EXPERIMENTAL_NOTICE = + '**Experimental:** This parameter is under active development and may change.\n\n'; + +describe('toDocumentedParameter', () => { + test('marks a parameter experimental and drops the duplicated notice', () => { + const parameter = toDocumentedParameter({ + name: 'expand', + description: `${EXPERIMENTAL_NOTICE}Optional fields to expand. Pass \`context\` to include attribute metadata.`, + required: false, + 'x-sentry-experimental': true, + }); + + expect(parameter.experimental).toBe(true); + expect(parameter.description).toBe( + 'Optional fields to expand. Pass `context` to include attribute metadata.' + ); + }); + + test('keeps the description when the extension is set without a notice', () => { + const parameter = toDocumentedParameter({ + name: 'expand', + description: 'Optional fields to expand.', + required: false, + 'x-sentry-experimental': true, + }); + + expect(parameter.experimental).toBe(true); + expect(parameter.description).toBe('Optional fields to expand.'); + }); + + test('leaves stable parameters unchanged', () => { + const parameter = toDocumentedParameter({ + name: 'project', + description: '**Experimental:** not really, this is just text.', + required: true, + }); + + expect(parameter.experimental).toBe(false); + expect(parameter.description).toBe( + '**Experimental:** not really, this is just text.' + ); + }); +}); diff --git a/src/build/resolveOpenAPI.ts b/src/build/resolveOpenAPI.ts index 12588201e435ba..4f92d4accd0fc2 100644 --- a/src/build/resolveOpenAPI.ts +++ b/src/build/resolveOpenAPI.ts @@ -2,7 +2,7 @@ import {promises as fs} from 'fs'; -import {DeRefedOpenAPI} from './open-api/types'; +import {DeRefedOpenAPI, Parameter} 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. @@ -31,13 +31,16 @@ async function resolveOpenAPI(): Promise { export type APIParameter = { description: string; + experimental: boolean; name: string; required: boolean; schema: { type: string; + enum?: string[]; format?: string; items?: { type: string; + enum?: string[]; }; }; }; @@ -98,11 +101,33 @@ function slugify(s: string): string { const DEPRECATED_PREFIX_REGEX = /^\(DEPRECATED\)\s*/; -// Sentry prepends this notice to the description of every experimental operation, so -// that consumers with no badge of their own still warn the reader. We render a badge, -// so strip it rather than saying the same thing twice. +// Sentry prepends this notice to the description of every experimental operation and +// parameter, so that consumers with no badge of their own still warn the reader. We +// render a badge, so strip it rather than saying the same thing twice. const EXPERIMENTAL_NOTICE_REGEX = /^\*\*Experimental:\*\*[^\n]*\n+/; +type ExperimentalExtension = { + description?: string; + 'x-sentry-experimental'?: boolean; +}; + +// `extensions={"x-sentry-experimental": True}` on an OpenAPI parameter or body +// property lands as a sibling vendor extension. Keep the flag, and drop the +// matching description prefix when the schema includes one. +export function toDocumentedParameter( + param: T +): T & {experimental: boolean} { + const experimental = param['x-sentry-experimental'] === true; + if (!experimental || !param.description) { + return {...param, experimental}; + } + return { + ...param, + experimental, + description: param.description.replace(EXPERIMENTAL_NOTICE_REGEX, ''), + }; +} + function isDeprecatedOperationId(operationId: string | undefined): boolean { return operationId ? DEPRECATED_PREFIX_REGEX.test(operationId) : false; } @@ -164,12 +189,8 @@ async function apiCategoriesUncached(): Promise { descriptionMarkdown: isExperimental ? apiData.description?.replace(EXPERIMENTAL_NOTICE_REGEX, '') : apiData.description, - pathParameters: (apiData.parameters || []).filter( - p => p.in === 'path' - ) as APIParameter[], - queryParameters: (apiData.parameters || []).filter( - p => p.in === 'query' - ) as APIParameter[], + pathParameters: documentedParameters(apiData.parameters, 'path'), + queryParameters: documentedParameters(apiData.parameters, 'query'), requestBodyContent: { example: apiData.requestBody?.content && @@ -208,6 +229,25 @@ async function apiCategoriesUncached(): Promise { return categories; } +function documentedParameters( + parameters: Parameter[] | undefined, + location: 'path' | 'query' +): APIParameter[] { + return (parameters || []) + .filter(param => param.in === location) + .map(param => { + const documented = toDocumentedParameter(param); + return { + name: documented.name, + description: documented.description, + required: documented.required, + experimental: documented.experimental, + // OpenAPI item schemas are an open record; the page only reads type and enum. + schema: documented.schema as APIParameter['schema'], + }; + }); +} + function getBodyParameters(apiData: APIData): APIParameter[] { const content = apiData.requestBody?.content; const contentType = content && Object.values(content)[0]; @@ -218,16 +258,19 @@ function getBodyParameters(apiData: APIData): APIParameter[] { const required: string[] = contentType?.schema?.required || []; - return Object.entries(properties).map(([name, props]: [string, any]) => ({ - name, - description: props.description, - required: required.includes(name), - schema: { - type: props.type, - format: '', - items: props.items, - }, - })); + return Object.entries(properties).map(([name, props]: [string, any]) => + toDocumentedParameter({ + name, + description: props.description, + required: required.includes(name), + 'x-sentry-experimental': props['x-sentry-experimental'], + schema: { + type: props.type, + format: '', + items: props.items, + }, + }) + ); } function getBodyContentType(apiData: APIData): string | undefined { diff --git a/src/components/apiPage/index.tsx b/src/components/apiPage/index.tsx index 3599b95e160f39..c5db2cf2f614db 100644 --- a/src/components/apiPage/index.tsx +++ b/src/components/apiPage/index.tsx @@ -2,7 +2,7 @@ import './styles.scss'; import {bundleMDX} from 'mdx-bundler'; import {Fragment, ReactElement, useMemo} from 'react'; -import {type API} from 'sentry-docs/build/resolveOpenAPI'; +import {type API, type APIParameter} from 'sentry-docs/build/resolveOpenAPI'; import {getMDXComponent} from 'sentry-docs/getMDXComponent'; import {mdxComponents} from 'sentry-docs/mdxComponents'; import remarkCodeTabs from 'sentry-docs/remark-code-tabs'; @@ -12,7 +12,7 @@ import {ApiExamples} from '../apiExamples/apiExamples'; import {DocPage} from '../docPage'; import {SmartLink} from '../smartLink'; -function Params({params}) { +function Params({params}: {params: APIParameter[]}) { return (
{params.map(param => { @@ -27,6 +27,14 @@ function Params({params}) { {param.schema.items && `(${param.schema.items.type})`}) )} + {param.experimental && ( + + Experimental + + )} {!!param.required &&
REQUIRED
} From 8c3b6394fee4831de6a923a350f2d25f1b4c1c3a Mon Sep 17 00:00:00 2001 From: William Mak Date: Wed, 14 Oct 2026 15:03:48 -0400 Subject: [PATCH 2/2] fix(api): Keep the experimental notice on parameter descriptions The parameter badge does not repeat the warning, so the OpenAPI notice stays in the description. Co-authored-by: Cursor --- src/build/resolveOpenAPI.spec.ts | 9 ++++----- src/build/resolveOpenAPI.ts | 17 ++++++----------- 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/src/build/resolveOpenAPI.spec.ts b/src/build/resolveOpenAPI.spec.ts index 431fcbd1f32595..62aef56e2acc3c 100644 --- a/src/build/resolveOpenAPI.spec.ts +++ b/src/build/resolveOpenAPI.spec.ts @@ -6,18 +6,17 @@ const EXPERIMENTAL_NOTICE = '**Experimental:** This parameter is under active development and may change.\n\n'; describe('toDocumentedParameter', () => { - test('marks a parameter experimental and drops the duplicated notice', () => { + test('marks a parameter experimental and keeps the notice in the description', () => { + const description = `${EXPERIMENTAL_NOTICE}Optional fields to expand. Pass \`context\` to include attribute metadata.`; const parameter = toDocumentedParameter({ name: 'expand', - description: `${EXPERIMENTAL_NOTICE}Optional fields to expand. Pass \`context\` to include attribute metadata.`, + description, required: false, 'x-sentry-experimental': true, }); expect(parameter.experimental).toBe(true); - expect(parameter.description).toBe( - 'Optional fields to expand. Pass `context` to include attribute metadata.' - ); + expect(parameter.description).toBe(description); }); test('keeps the description when the extension is set without a notice', () => { diff --git a/src/build/resolveOpenAPI.ts b/src/build/resolveOpenAPI.ts index 4f92d4accd0fc2..86aafb07c8ff09 100644 --- a/src/build/resolveOpenAPI.ts +++ b/src/build/resolveOpenAPI.ts @@ -101,9 +101,10 @@ function slugify(s: string): string { const DEPRECATED_PREFIX_REGEX = /^\(DEPRECATED\)\s*/; -// Sentry prepends this notice to the description of every experimental operation and -// parameter, so that consumers with no badge of their own still warn the reader. We -// render a badge, so strip it rather than saying the same thing twice. +// Sentry prepends this notice to the description of every experimental operation, so +// that consumers with no badge of their own still warn the reader. We render a badge +// and the same sentence beside it, so strip the prefix rather than saying it twice. +// Parameter notices stay in the description: the parameter badge does not repeat them. const EXPERIMENTAL_NOTICE_REGEX = /^\*\*Experimental:\*\*[^\n]*\n+/; type ExperimentalExtension = { @@ -112,19 +113,13 @@ type ExperimentalExtension = { }; // `extensions={"x-sentry-experimental": True}` on an OpenAPI parameter or body -// property lands as a sibling vendor extension. Keep the flag, and drop the -// matching description prefix when the schema includes one. +// property lands as a sibling vendor extension. export function toDocumentedParameter( param: T ): T & {experimental: boolean} { - const experimental = param['x-sentry-experimental'] === true; - if (!experimental || !param.description) { - return {...param, experimental}; - } return { ...param, - experimental, - description: param.description.replace(EXPERIMENTAL_NOTICE_REGEX, ''), + experimental: param['x-sentry-experimental'] === true, }; }