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..62aef56e2acc3c --- /dev/null +++ b/src/build/resolveOpenAPI.spec.ts @@ -0,0 +1,46 @@ +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 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, + required: false, + 'x-sentry-experimental': true, + }); + + expect(parameter.experimental).toBe(true); + expect(parameter.description).toBe(description); + }); + + 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..86aafb07c8ff09 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[]; }; }; }; @@ -99,10 +102,27 @@ 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. +// 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 = { + description?: string; + 'x-sentry-experimental'?: boolean; +}; + +// `extensions={"x-sentry-experimental": True}` on an OpenAPI parameter or body +// property lands as a sibling vendor extension. +export function toDocumentedParameter( + param: T +): T & {experimental: boolean} { + return { + ...param, + experimental: param['x-sentry-experimental'] === true, + }; +} + function isDeprecatedOperationId(operationId: string | undefined): boolean { return operationId ? DEPRECATED_PREFIX_REGEX.test(operationId) : false; } @@ -164,12 +184,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 +224,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 +253,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
}