Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/build/open-api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type Parameter = {
type: string;
items?: {[key: string]: unknown};
};
'x-sentry-experimental'?: boolean;
};

type Markdown = {
Expand Down
46 changes: 46 additions & 0 deletions src/build/resolveOpenAPI.spec.ts
Original file line number Diff line number Diff line change
@@ -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.'
);
});
});
76 changes: 57 additions & 19 deletions src/build/resolveOpenAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -31,13 +31,16 @@ async function resolveOpenAPI(): Promise<DeRefedOpenAPI> {

export type APIParameter = {
description: string;
experimental: boolean;
name: string;
required: boolean;
schema: {
type: string;
enum?: string[];
format?: string;
items?: {
type: string;
enum?: string[];
};
};
};
Expand Down Expand Up @@ -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<T extends ExperimentalExtension>(
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;
}
Expand Down Expand Up @@ -164,12 +184,8 @@ async function apiCategoriesUncached(): Promise<APICategory[]> {
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 &&
Expand Down Expand Up @@ -208,6 +224,25 @@ async function apiCategoriesUncached(): Promise<APICategory[]> {
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];
Expand All @@ -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 {
Expand Down
12 changes: 10 additions & 2 deletions src/components/apiPage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 (
<dl className="api-params">
{params.map(param => {
Expand All @@ -27,6 +27,14 @@ function Params({params}) {
{param.schema.items && `(${param.schema.items.type})`})
</em>
)}
{param.experimental && (
<span
className="ml-2 align-middle text-xs font-medium px-2 py-0.5 rounded bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200"
title="This parameter is under active development and may change."
>
Experimental
</span>
)}
</div>
{!!param.required && <div className="required">REQUIRED</div>}
</dt>
Expand Down
Loading