@gitbook/react-openapi 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # @gitbook/react-openapi
2
+
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 57adb3e: Second release to fix publishing with changeset
8
+
9
+ ## 0.1.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 5f8a8fe: Initial release
package/README.md ADDED
@@ -0,0 +1,12 @@
1
+ # `@gitbook/react-openapi`
2
+
3
+ React components to render OpenAPI operations.
4
+
5
+ ## Features
6
+
7
+ - Generate code samples for the request
8
+ - Support custom cde samples with `x-codeSamples` (Redocly syntax)
9
+
10
+ ## TODO
11
+
12
+ - Support for trying out the request
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@gitbook/react-openapi",
3
+ "exports": "./src/index.ts",
4
+ "version": "0.2.0",
5
+ "dependencies": {
6
+ "@scalar/api-client-react": "^0.3.7",
7
+ "@scalar/oas-utils": "0.1.6",
8
+ "classnames": "^2.5.1",
9
+ "flatted": "^3.2.9",
10
+ "openapi-types": "^12.1.3",
11
+ "yaml": "1.10.2",
12
+ "swagger2openapi": "^7.0.8"
13
+ },
14
+ "devDependencies": {
15
+ "@types/swagger2openapi": "^7.0.4"
16
+ },
17
+ "peerDependencies": {
18
+ "react": "*"
19
+ }
20
+ }
@@ -0,0 +1,129 @@
1
+ 'use client';
2
+
3
+ import classNames from 'classnames';
4
+ import React from 'react';
5
+
6
+ /**
7
+ * To optimize rendering, most of the components are server-components,
8
+ * and the interactiveness is mainly handled by a few key components like this one.
9
+ */
10
+ export function InteractiveSection(props: {
11
+ id?: string;
12
+ /** Class name to be set on the section, sub-elements will use it as prefix */
13
+ className: string;
14
+ /** If true, the content can be toggeable */
15
+ toggeable?: boolean;
16
+ /** Default state of the toggle */
17
+ defaultOpened?: boolean;
18
+ /** Icons to display for the toggle */
19
+ toggleOpenIcon?: React.ReactNode;
20
+ toggleCloseIcon?: React.ReactNode;
21
+ /** Tabs of content to display */
22
+ tabs?: Array<{
23
+ key: string;
24
+ label: string;
25
+ body: React.ReactNode;
26
+ }>;
27
+ /** Default tab to have opened */
28
+ defaultTab?: string;
29
+ /** Content of the header */
30
+ header: React.ReactNode;
31
+ /** Body of the section */
32
+ children?: React.ReactNode;
33
+ /** Children to display within the container */
34
+ overlay?: React.ReactNode;
35
+ }) {
36
+ const {
37
+ id,
38
+ className,
39
+ toggeable = false,
40
+ defaultOpened = true,
41
+ tabs = [],
42
+ defaultTab = tabs[0]?.key,
43
+ header,
44
+ children,
45
+ overlay,
46
+ toggleOpenIcon = '▶',
47
+ toggleCloseIcon = '▼',
48
+ } = props;
49
+
50
+ const [opened, setOpened] = React.useState(defaultOpened);
51
+ const [selectedTab, setSelectedTab] = React.useState(defaultTab);
52
+
53
+ const tabBody = tabs.find((tab) => tab.key === selectedTab)?.body;
54
+
55
+ return (
56
+ <div
57
+ id={id}
58
+ className={classNames(
59
+ 'openapi-section',
60
+ toggeable ? 'openapi-section-toggeable' : null,
61
+ className,
62
+ toggeable ? `${className}-${opened ? 'opened' : 'closed'}` : null,
63
+ )}
64
+ >
65
+ <div
66
+ onClick={() => {
67
+ if (toggeable) {
68
+ setOpened(!opened);
69
+ }
70
+ }}
71
+ className={classNames('openapi-section-header', `${className}-header`)}
72
+ >
73
+ <div
74
+ className={classNames(
75
+ 'openapi-section-header-content',
76
+ `${className}-header-content`,
77
+ )}
78
+ >
79
+ {header}
80
+ </div>
81
+ <div
82
+ className={classNames(
83
+ 'openapi-section-header-controls',
84
+ `${className}-header-controls`,
85
+ )}
86
+ onClick={(event) => {
87
+ event.stopPropagation();
88
+ }}
89
+ >
90
+ {tabs.length ? (
91
+ <select
92
+ className={classNames(
93
+ 'openapi-section-select',
94
+ 'openapi-select',
95
+ `${className}-tabs-select`,
96
+ )}
97
+ value={selectedTab}
98
+ onChange={(event) => {
99
+ setSelectedTab(event.target.value);
100
+ setOpened(true);
101
+ }}
102
+ >
103
+ {tabs.map((tab) => (
104
+ <option key={tab.key} value={tab.key}>
105
+ {tab.label}
106
+ </option>
107
+ ))}
108
+ </select>
109
+ ) : null}
110
+ {(children || tabBody) && toggeable ? (
111
+ <button
112
+ className={classNames('openapi-section-toggle', `${className}-toggle`)}
113
+ onClick={() => setOpened(!opened)}
114
+ >
115
+ {opened ? toggleCloseIcon : toggleOpenIcon}
116
+ </button>
117
+ ) : null}
118
+ </div>
119
+ </div>
120
+ {(!toggeable || opened) && (children || tabBody) ? (
121
+ <div className={classNames('openapi-section-body', `${className}-body`)}>
122
+ {children}
123
+ {tabBody}
124
+ </div>
125
+ ) : null}
126
+ {overlay}
127
+ </div>
128
+ );
129
+ }
@@ -0,0 +1,12 @@
1
+ import classNames from 'classnames';
2
+
3
+ export function Markdown(props: { source: string; className?: string }) {
4
+ const { source, className } = props;
5
+
6
+ return (
7
+ <div
8
+ className={classNames('openapi-markdown', className)}
9
+ dangerouslySetInnerHTML={{ __html: source }}
10
+ />
11
+ );
12
+ }
@@ -0,0 +1,111 @@
1
+ import { OpenAPIV3 } from 'openapi-types';
2
+
3
+ import { CodeSampleInput, codeSampleGenerators } from './code-samples';
4
+ import { OpenAPIOperationData, toJSON } from './fetchOpenAPIOperation';
5
+ import { generateMediaTypeExample } from './generateSchemaExample';
6
+ import { InteractiveSection } from './InteractiveSection';
7
+ import { getServersURL } from './OpenAPIServerURL';
8
+ import { ScalarApiButton } from './ScalarApiButton';
9
+ import { OpenAPIContextProps } from './types';
10
+ import { noReference } from './utils';
11
+
12
+ /**
13
+ * Display code samples to execute the operation.
14
+ * It supports the Redocly custom syntax as well (https://redocly.com/docs/api-reference-docs/specification-extensions/x-code-samples/)
15
+ */
16
+ export function OpenAPICodeSample(props: {
17
+ data: OpenAPIOperationData;
18
+ context: OpenAPIContextProps;
19
+ }) {
20
+ const { data, context } = props;
21
+
22
+ const requestBody = noReference(data.operation.requestBody);
23
+ const requestBodyContent = requestBody ? Object.entries(requestBody.content)[0] : undefined;
24
+
25
+ const input: CodeSampleInput = {
26
+ url: getServersURL(data.servers) + data.path,
27
+ method: data.method,
28
+ body: requestBodyContent
29
+ ? generateMediaTypeExample(requestBodyContent[1], { onlyRequired: true })
30
+ : undefined,
31
+ headers: {
32
+ ...getSecurityHeaders(data.securities),
33
+ ...(requestBodyContent
34
+ ? {
35
+ 'Content-Type': requestBodyContent[0],
36
+ }
37
+ : undefined),
38
+ },
39
+ };
40
+
41
+ const autoCodeSamples = codeSampleGenerators.map((generator) => ({
42
+ key: `default-${generator.id}`,
43
+ label: generator.label,
44
+ body: <context.CodeBlock code={generator.generate(input)} syntax={generator.syntax} />,
45
+ }));
46
+
47
+ // Use custom samples if defined
48
+ let customCodeSamples: null | Array<{
49
+ key: string;
50
+ label: string;
51
+ body: React.ReactNode;
52
+ }> = null;
53
+ (['x-custom-examples', 'x-code-samples', 'x-codeSamples'] as const).forEach((key) => {
54
+ const customSamples = data.operation[key];
55
+ if (customSamples) {
56
+ customCodeSamples = customSamples.map((sample) => ({
57
+ key: `redocly-${sample.lang}`,
58
+ label: sample.label,
59
+ body: <context.CodeBlock code={sample.source} syntax={sample.lang} />,
60
+ }));
61
+ }
62
+ });
63
+
64
+ const samples = customCodeSamples ?? (data['x-codeSamples'] !== false ? autoCodeSamples : []);
65
+ if (samples.length === 0) {
66
+ return null;
67
+ }
68
+
69
+ async function fetchOperationData() {
70
+ 'use server';
71
+ return toJSON(data);
72
+ }
73
+
74
+ return (
75
+ <InteractiveSection
76
+ header="Request"
77
+ className="openapi-codesample"
78
+ tabs={samples}
79
+ overlay={
80
+ data['x-hideTryItPanel'] || data.operation['x-hideTryItPanel'] ? null : (
81
+ <ScalarApiButton fetchOperationData={fetchOperationData} />
82
+ )
83
+ }
84
+ />
85
+ );
86
+ }
87
+
88
+ function getSecurityHeaders(securities: OpenAPIOperationData['securities']): {
89
+ [key: string]: string;
90
+ } {
91
+ const security = securities[0];
92
+ if (!security) {
93
+ return {};
94
+ }
95
+
96
+ switch (security[1].type) {
97
+ case 'http': {
98
+ let scheme = security[1].scheme;
99
+ if (scheme === 'bearer') {
100
+ scheme = 'Bearer';
101
+ }
102
+
103
+ return {
104
+ Authorization: scheme + ' ' + (security[1].bearerFormat ?? '<token>'),
105
+ };
106
+ }
107
+ default: {
108
+ return {};
109
+ }
110
+ }
111
+ }
@@ -0,0 +1,65 @@
1
+ import classNames from 'classnames';
2
+
3
+ import { OpenAPIOperationData, toJSON } from './fetchOpenAPIOperation';
4
+ import { Markdown } from './Markdown';
5
+ import { OpenAPICodeSample } from './OpenAPICodeSample';
6
+ import { OpenAPIResponseExample } from './OpenAPIResponseExample';
7
+ import { OpenAPIServerURL } from './OpenAPIServerURL';
8
+ import { OpenAPISpec } from './OpenAPISpec';
9
+ import { ScalarApiClient } from './ScalarApiButton';
10
+ import { OpenAPIClientContext, OpenAPIContextProps } from './types';
11
+
12
+ /**
13
+ * Display an interactive OpenAPI operation.
14
+ */
15
+ export function OpenAPIOperation(props: {
16
+ className?: string;
17
+ data: OpenAPIOperationData;
18
+ context: OpenAPIContextProps;
19
+ }) {
20
+ const { className, data, context } = props;
21
+ const { operation, servers, method, path } = data;
22
+
23
+ const clientContext: OpenAPIClientContext = {
24
+ defaultInteractiveOpened: context.defaultInteractiveOpened,
25
+ icons: context.icons,
26
+ };
27
+
28
+ return (
29
+ <ScalarApiClient>
30
+ <div className={classNames('openapi-operation', className)}>
31
+ <div className="openapi-intro">
32
+ <h2 className="openapi-summary">{operation.summary}</h2>
33
+ {operation.description ? (
34
+ <Markdown className="openapi-description" source={operation.description} />
35
+ ) : null}
36
+ <div className="openapi-target">
37
+ <span
38
+ className={classNames(
39
+ 'openapi-method',
40
+ `openapi-method-${method.toLowerCase()}`,
41
+ )}
42
+ >
43
+ {method.toUpperCase()}
44
+ </span>
45
+ <span className="openapi-url">
46
+ <OpenAPIServerURL servers={servers} />
47
+ {path}
48
+ </span>
49
+ </div>
50
+ </div>
51
+ <div className={classNames('openapi-columns')}>
52
+ <div className={classNames('openapi-column-spec')}>
53
+ <OpenAPISpec rawData={toJSON(data)} context={clientContext} />
54
+ </div>
55
+ <div className={classNames('openapi-column-preview')}>
56
+ <div className={classNames('openapi-column-preview-body')}>
57
+ <OpenAPICodeSample {...props} />
58
+ <OpenAPIResponseExample {...props} />
59
+ </div>
60
+ </div>
61
+ </div>
62
+ </div>
63
+ </ScalarApiClient>
64
+ );
65
+ }
@@ -0,0 +1,45 @@
1
+ import { OpenAPIV3 } from 'openapi-types';
2
+ import { OpenAPIRootSchema } from './OpenAPISchema';
3
+ import { noReference } from './utils';
4
+ import { OpenAPIClientContext } from './types';
5
+ import { InteractiveSection } from './InteractiveSection';
6
+ import { Markdown } from './Markdown';
7
+
8
+ /**
9
+ * Display an interactive request body.
10
+ */
11
+ export function OpenAPIRequestBody(props: {
12
+ requestBody: OpenAPIV3.RequestBodyObject;
13
+ context: OpenAPIClientContext;
14
+ }) {
15
+ const { requestBody, context } = props;
16
+
17
+ return (
18
+ <InteractiveSection
19
+ header="Body"
20
+ className="openapi-requestbody"
21
+ tabs={Object.entries(requestBody.content ?? {}).map(
22
+ ([contentType, mediaTypeObject]) => {
23
+ return {
24
+ key: contentType,
25
+ label: contentType,
26
+ body: (
27
+ <OpenAPIRootSchema
28
+ schema={noReference(mediaTypeObject.schema) ?? {}}
29
+ context={context}
30
+ />
31
+ ),
32
+ };
33
+ },
34
+ )}
35
+ defaultOpened={context.defaultInteractiveOpened}
36
+ >
37
+ {requestBody.description ? (
38
+ <Markdown
39
+ source={requestBody.description}
40
+ className="openapi-requestbody-description"
41
+ />
42
+ ) : null}
43
+ </InteractiveSection>
44
+ );
45
+ }
@@ -0,0 +1,71 @@
1
+ import classNames from 'classnames';
2
+ import { OpenAPIV3 } from 'openapi-types';
3
+ import { OpenAPIRootSchema, OpenAPISchemaProperties } from './OpenAPISchema';
4
+ import { noReference } from './utils';
5
+ import { OpenAPIClientContext } from './types';
6
+ import { InteractiveSection } from './InteractiveSection';
7
+ import { Markdown } from './Markdown';
8
+
9
+ /**
10
+ * Display an interactive response body.
11
+ */
12
+ export function OpenAPIResponse(props: {
13
+ response: OpenAPIV3.ResponseObject;
14
+ context: OpenAPIClientContext;
15
+ }) {
16
+ const { response, context } = props;
17
+ const content = Object.entries(response.content ?? {});
18
+ const headers = Object.entries(response.headers ?? {}).map(
19
+ ([name, header]) => [name, noReference(header) ?? {}] as const,
20
+ );
21
+
22
+ if (content.length === 0 && !response.description && headers.length === 0) {
23
+ return null;
24
+ }
25
+
26
+ return (
27
+ <>
28
+ {response.description ? (
29
+ <Markdown source={response.description} className="openapi-response-description" />
30
+ ) : null}
31
+
32
+ {headers.length > 0 ? (
33
+ <InteractiveSection
34
+ toggeable
35
+ defaultOpened={!!context.defaultInteractiveOpened}
36
+ toggleCloseIcon={context.icons.chevronDown}
37
+ toggleOpenIcon={context.icons.chevronRight}
38
+ header="Headers"
39
+ className={classNames('openapi-responseheaders')}
40
+ >
41
+ <OpenAPISchemaProperties
42
+ properties={headers.map(([name, header]) => ({
43
+ propertyName: name,
44
+ schema: noReference(header.schema) ?? {},
45
+ required: header.required,
46
+ }))}
47
+ context={context}
48
+ />
49
+ </InteractiveSection>
50
+ ) : null}
51
+ {content.length > 0 ? (
52
+ <InteractiveSection
53
+ header="Body"
54
+ className={classNames('openapi-responsebody')}
55
+ tabs={content.map(([contentType, mediaType]) => {
56
+ return {
57
+ key: contentType,
58
+ label: contentType,
59
+ body: (
60
+ <OpenAPIRootSchema
61
+ schema={noReference(mediaType.schema) ?? {}}
62
+ context={context}
63
+ />
64
+ ),
65
+ };
66
+ })}
67
+ />
68
+ ) : null}
69
+ </>
70
+ );
71
+ }
@@ -0,0 +1,71 @@
1
+ import { InteractiveSection } from './InteractiveSection';
2
+ import { OpenAPIOperationData } from './fetchOpenAPIOperation';
3
+ import { generateSchemaExample } from './generateSchemaExample';
4
+ import { OpenAPIContextProps } from './types';
5
+ import { noReference } from './utils';
6
+
7
+ /**
8
+ * Display an example of the response content.
9
+ */
10
+ export function OpenAPIResponseExample(props: {
11
+ data: OpenAPIOperationData;
12
+ context: OpenAPIContextProps;
13
+ }) {
14
+ const { data, context } = props;
15
+
16
+ // if there are no responses defined for the operation
17
+ if (!data.operation.responses) {
18
+ return null;
19
+ }
20
+
21
+ const responses = Object.entries(data.operation.responses);
22
+ // Sort response to get 200, and 2xx first
23
+ responses.sort(([a], [b]) => {
24
+ if (a === 'default') {
25
+ return 1;
26
+ }
27
+ if (b === 'default') {
28
+ return -1;
29
+ }
30
+ if (a === '200') {
31
+ return -1;
32
+ }
33
+ if (b === '200') {
34
+ return 1;
35
+ }
36
+ return Number(a) - Number(b);
37
+ });
38
+ // Take the first one
39
+ const response = responses[0];
40
+
41
+ if (!response) {
42
+ return null;
43
+ }
44
+
45
+ const responseObject = noReference(response[1]);
46
+
47
+ const schema = noReference(
48
+ (
49
+ responseObject.content?.['application/json'] ??
50
+ responseObject.content?.[Object.keys(responseObject.content)[0]]
51
+ )?.schema,
52
+ );
53
+
54
+ if (!schema) {
55
+ return null;
56
+ }
57
+
58
+ const example = generateSchemaExample(schema);
59
+ if (example === undefined) {
60
+ return null;
61
+ }
62
+
63
+ return (
64
+ <InteractiveSection header="Response" className="openapi-response-example">
65
+ <context.CodeBlock
66
+ code={typeof example === 'string' ? example : JSON.stringify(example, null, 2)}
67
+ syntax="json"
68
+ />
69
+ </InteractiveSection>
70
+ );
71
+ }
@@ -0,0 +1,30 @@
1
+ import classNames from 'classnames';
2
+ import { OpenAPIV3 } from 'openapi-types';
3
+ import { noReference } from './utils';
4
+ import { OpenAPIResponse } from './OpenAPIResponse';
5
+ import { OpenAPIClientContext } from './types';
6
+ import { InteractiveSection } from './InteractiveSection';
7
+
8
+ /**
9
+ * Display an interactive response body.
10
+ */
11
+ export function OpenAPIResponses(props: {
12
+ responses: OpenAPIV3.ResponsesObject;
13
+ context: OpenAPIClientContext;
14
+ }) {
15
+ const { responses, context } = props;
16
+
17
+ return (
18
+ <InteractiveSection
19
+ header="Response"
20
+ className={classNames('openapi-responses')}
21
+ tabs={Object.entries(responses).map(([statusCode, response]) => {
22
+ return {
23
+ key: statusCode,
24
+ label: statusCode,
25
+ body: <OpenAPIResponse response={noReference(response)} context={context} />,
26
+ };
27
+ })}
28
+ />
29
+ );
30
+ }
@@ -0,0 +1,101 @@
1
+ import { it, describe, expect } from 'bun:test';
2
+ import { getSchemaAlternatives } from './OpenAPISchema';
3
+ import { OpenAPIV3 } from 'openapi-types';
4
+
5
+ describe('getSchemaAlternatives', () => {
6
+ it('should flatten oneOf', () => {
7
+ expect(
8
+ getSchemaAlternatives({
9
+ oneOf: [
10
+ {
11
+ oneOf: [
12
+ {
13
+ type: 'number',
14
+ },
15
+ {
16
+ type: 'boolean',
17
+ },
18
+ ],
19
+ },
20
+ {
21
+ type: 'string',
22
+ },
23
+ ],
24
+ }),
25
+ ).toEqual([
26
+ [
27
+ {
28
+ type: 'number',
29
+ },
30
+ {
31
+ type: 'boolean',
32
+ },
33
+ {
34
+ type: 'string',
35
+ },
36
+ ],
37
+ undefined,
38
+ ]);
39
+ });
40
+
41
+ it('should not flatten oneOf and allOf', () => {
42
+ expect(
43
+ getSchemaAlternatives({
44
+ oneOf: [
45
+ {
46
+ allOf: [
47
+ {
48
+ type: 'number',
49
+ },
50
+ {
51
+ type: 'boolean',
52
+ },
53
+ ],
54
+ },
55
+ {
56
+ type: 'string',
57
+ },
58
+ ],
59
+ }),
60
+ ).toEqual([
61
+ [
62
+ {
63
+ allOf: [
64
+ {
65
+ type: 'number',
66
+ },
67
+ {
68
+ type: 'boolean',
69
+ },
70
+ ],
71
+ },
72
+ {
73
+ type: 'string',
74
+ },
75
+ ],
76
+ undefined,
77
+ ]);
78
+ });
79
+
80
+ it('should stop at circular references', () => {
81
+ const a: OpenAPIV3.SchemaObject = {
82
+ anyOf: [
83
+ {
84
+ type: 'string',
85
+ },
86
+ ],
87
+ };
88
+
89
+ a.anyOf!.push(a);
90
+
91
+ expect(getSchemaAlternatives(a)).toEqual([
92
+ [
93
+ {
94
+ type: 'string',
95
+ },
96
+ a,
97
+ ],
98
+ undefined,
99
+ ]);
100
+ });
101
+ });