@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.
@@ -0,0 +1,159 @@
1
+ 'use client';
2
+ import {
3
+ Cookie,
4
+ getHarRequest,
5
+ getParametersFromOperation,
6
+ type TransformedOperation,
7
+ getRequestFromOperation,
8
+ Query,
9
+ Header,
10
+ RequestBody,
11
+ } from '@scalar/oas-utils';
12
+ import React from 'react';
13
+
14
+ import { OpenAPIOperationData, fromJSON } from './fetchOpenAPIOperation';
15
+
16
+ const ApiClientReact = React.lazy(async () => {
17
+ const mod = await import('@scalar/api-client-react');
18
+ return { default: mod.ApiClientReact };
19
+ });
20
+
21
+ const ScalarContext = React.createContext<
22
+ (fetchOperationData: () => Promise<OpenAPIOperationData>) => void
23
+ >(() => {});
24
+
25
+ /**
26
+ * Button which launches the Scalar API Client
27
+ */
28
+ export function ScalarApiButton(props: {
29
+ fetchOperationData: () => Promise<OpenAPIOperationData>;
30
+ }) {
31
+ const { fetchOperationData } = props;
32
+ const open = React.useContext(ScalarContext);
33
+
34
+ return (
35
+ <div className="scalar scalar-activate">
36
+ <button
37
+ className="scalar-activate-button"
38
+ onClick={() => {
39
+ open(fetchOperationData);
40
+ }}
41
+ >
42
+ <svg xmlns="http://www.w3.org/2000/svg" width="10" height="12" fill="none">
43
+ <path
44
+ stroke="currentColor"
45
+ strokeWidth="1.5"
46
+ d="M1 10.05V1.43c0-.2.2-.31.37-.22l7.26 4.08c.17.1.17.33.01.43l-7.26 4.54a.25.25 0 0 1-.38-.21Z"
47
+ />
48
+ </svg>
49
+ Test it
50
+ </button>
51
+ </div>
52
+ );
53
+ }
54
+
55
+ /**
56
+ * Wrap the rendering with a context to open the scalar modal.
57
+ */
58
+ export function ScalarApiClient(props: { children: React.ReactNode }) {
59
+ const { children } = props;
60
+
61
+ const [active, setActive] = React.useState<null | {
62
+ operationData: OpenAPIOperationData | null;
63
+ }>(null);
64
+
65
+ const proxy = '/~scalar/proxy';
66
+
67
+ const open = React.useCallback(
68
+ async (fetchOperationData: () => Promise<OpenAPIOperationData>) => {
69
+ setActive({ operationData: null });
70
+ const operationData = fromJSON(await fetchOperationData());
71
+ setActive({ operationData });
72
+ },
73
+ [],
74
+ );
75
+
76
+ const onClose = React.useCallback(() => {
77
+ setActive(null);
78
+ }, []);
79
+
80
+ const request = React.useMemo(() => {
81
+ const operationData = active?.operationData;
82
+
83
+ if (!operationData) {
84
+ return null;
85
+ }
86
+
87
+ const operationId =
88
+ operationData.operation.operationId ?? operationData.method + operationData.path;
89
+
90
+ const operation = {
91
+ ...operationData,
92
+ httpVerb: operationData.method,
93
+ pathParameters: operationData.operation.parameters,
94
+ } as TransformedOperation;
95
+
96
+ const variables = getParametersFromOperation(operation, 'path', false);
97
+
98
+ const request = getHarRequest(
99
+ {
100
+ url: operationData.path,
101
+ },
102
+ getRequestFromOperation(
103
+ {
104
+ ...operation,
105
+ information: {
106
+ requestBody: operationData.operation.requestBody as RequestBody,
107
+ },
108
+ },
109
+ { requiredOnly: false },
110
+ ),
111
+ );
112
+
113
+ return {
114
+ id: operationId,
115
+ type: operationData.method,
116
+ path: operationData.path,
117
+ variables,
118
+ cookies: request.cookies.map((cookie: Cookie) => {
119
+ return { ...cookie, enabled: true };
120
+ }),
121
+ query: request.queryString.map((queryString: Query) => {
122
+ const query: typeof queryString & { required?: boolean } = queryString;
123
+ return { ...queryString, enabled: query.required ?? true };
124
+ }),
125
+ headers: request.headers.map((header: Header) => {
126
+ return { ...header, enabled: true };
127
+ }),
128
+ url: operationData.servers[0]?.url,
129
+ body: request.postData?.text,
130
+ };
131
+ }, [active]);
132
+
133
+ return (
134
+ <ScalarContext.Provider value={open}>
135
+ {children}
136
+ {active ? (
137
+ <div className="scalar">
138
+ <div className="scalar-container">
139
+ <div className="scalar-app">
140
+ <React.Suspense fallback={<ScalarLoading />}>
141
+ <ApiClientReact
142
+ close={onClose}
143
+ proxy={proxy}
144
+ isOpen={true}
145
+ request={request}
146
+ />
147
+ </React.Suspense>
148
+ </div>
149
+ <div onClick={() => onClose()} className="scalar-app-exit"></div>
150
+ </div>
151
+ </div>
152
+ ) : null}
153
+ </ScalarContext.Provider>
154
+ );
155
+ }
156
+
157
+ function ScalarLoading() {
158
+ return <div className="scalar-app-loading">Loading...</div>;
159
+ }
@@ -0,0 +1,76 @@
1
+ export interface CodeSampleInput {
2
+ method: string;
3
+ url: string;
4
+ headers?: Record<string, string>;
5
+ body?: any;
6
+ }
7
+
8
+ interface CodeSampleGenerator {
9
+ id: string;
10
+ label: string;
11
+ syntax: string;
12
+ generate: (operation: CodeSampleInput) => string;
13
+ }
14
+
15
+ export const codeSampleGenerators: CodeSampleGenerator[] = [
16
+ {
17
+ id: 'javascript',
18
+ label: 'JavaScript',
19
+ syntax: 'javascript',
20
+ generate: ({ method, url, headers, body }) => {
21
+ let code = '';
22
+
23
+ code += `const response = await fetch('${url}', {
24
+ method: '${method.toUpperCase()}',\n`;
25
+
26
+ if (headers) {
27
+ code += indent(`headers: ${JSON.stringify(headers, null, 2)},\n`, 4);
28
+ }
29
+
30
+ if (body) {
31
+ code += indent(`body: JSON.stringify(${JSON.stringify(body, null, 2)}),\n`, 4);
32
+ }
33
+
34
+ code += `});\n`;
35
+ code += `const data = await response.json();`;
36
+
37
+ return code;
38
+ },
39
+ },
40
+ {
41
+ id: 'curl',
42
+ label: 'Curl',
43
+ syntax: 'bash',
44
+ generate: ({ method, url, headers, body }) => {
45
+ const separator = ' \\\n';
46
+
47
+ const lines: string[] = ['curl -L'];
48
+
49
+ if (method.toUpperCase() !== 'GET') {
50
+ lines.push(`-X ${method.toUpperCase()}`);
51
+ }
52
+
53
+ if (headers) {
54
+ Object.entries(headers).forEach(([key, value]) => {
55
+ lines.push(`-H '${key}: ${value}'`);
56
+ });
57
+ }
58
+
59
+ lines.push(`'${url}'`);
60
+
61
+ if (body) {
62
+ lines.push(`-d '${JSON.stringify(body)}'`);
63
+ }
64
+
65
+ return lines.map((line, index) => (index > 0 ? indent(line, 2) : line)).join(separator);
66
+ },
67
+ },
68
+ ];
69
+
70
+ function indent(code: string, spaces: number) {
71
+ const indent = ' '.repeat(spaces);
72
+ return code
73
+ .split('\n')
74
+ .map((line) => (line ? indent + line : ''))
75
+ .join('\n');
76
+ }
@@ -0,0 +1,185 @@
1
+ import { it, expect } from 'bun:test';
2
+
3
+ import { fetchOpenAPIOperation, parseOpenAPIV3 } from './fetchOpenAPIOperation';
4
+ import { OpenAPIFetcher } from './types';
5
+
6
+ const fetcher: OpenAPIFetcher = {
7
+ fetch: async (url) => {
8
+ const response = await fetch(url);
9
+ return parseOpenAPIV3(url, await response.text());
10
+ },
11
+ };
12
+
13
+ it('should resolve refs', async () => {
14
+ const resolved = await fetchOpenAPIOperation(
15
+ {
16
+ url: 'https://petstore3.swagger.io/api/v3/openapi.json',
17
+ method: 'put',
18
+ path: '/pet',
19
+ },
20
+ fetcher,
21
+ );
22
+
23
+ expect(resolved).toMatchObject({
24
+ servers: [
25
+ {
26
+ url: '/api/v3',
27
+ },
28
+ ],
29
+ operation: {
30
+ tags: ['pet'],
31
+ summary: 'Update an existing pet',
32
+ description: 'Update an existing pet by Id',
33
+ requestBody: {
34
+ content: {
35
+ 'application/json': {
36
+ schema: {
37
+ type: 'object',
38
+ required: ['name', 'photoUrls'],
39
+ },
40
+ },
41
+ },
42
+ },
43
+ },
44
+ });
45
+ });
46
+
47
+ it('should support yaml', async () => {
48
+ const resolved = await fetchOpenAPIOperation(
49
+ {
50
+ url: 'https://petstore3.swagger.io/api/v3/openapi.yaml',
51
+ method: 'put',
52
+ path: '/pet',
53
+ },
54
+ fetcher,
55
+ );
56
+
57
+ expect(resolved).toMatchObject({
58
+ servers: [
59
+ {
60
+ url: '/api/v3',
61
+ },
62
+ ],
63
+ operation: {
64
+ tags: ['pet'],
65
+ summary: 'Update an existing pet',
66
+ description: 'Update an existing pet by Id',
67
+ requestBody: {
68
+ content: {
69
+ 'application/json': {
70
+ schema: {
71
+ type: 'object',
72
+ required: ['name', 'photoUrls'],
73
+ },
74
+ },
75
+ },
76
+ },
77
+ },
78
+ });
79
+ });
80
+
81
+ it('should resolve circular refs', async () => {
82
+ const resolved = await fetchOpenAPIOperation(
83
+ {
84
+ url: 'https://api.gitbook.com/openapi.json',
85
+ method: 'post',
86
+ path: '/search/ask',
87
+ },
88
+ fetcher,
89
+ );
90
+
91
+ expect(resolved).toMatchObject({
92
+ servers: [
93
+ {
94
+ url: '{host}/v1',
95
+ },
96
+ ],
97
+ operation: {
98
+ operationId: 'askQuery',
99
+ },
100
+ });
101
+ });
102
+
103
+ it('should resolve to null if the method is not supported', async () => {
104
+ const resolved = await fetchOpenAPIOperation(
105
+ {
106
+ url: 'https://petstore3.swagger.io/api/v3/openapi.json',
107
+ method: 'dontexist',
108
+ path: '/pet',
109
+ },
110
+ fetcher,
111
+ );
112
+
113
+ expect(resolved).toBe(null);
114
+ });
115
+
116
+ it('should parse Swagger 2.0', async () => {
117
+ const resolved = await fetchOpenAPIOperation(
118
+ {
119
+ url: 'https://petstore.swagger.io/v2/swagger.json',
120
+ method: 'put',
121
+ path: '/pet',
122
+ },
123
+ fetcher,
124
+ );
125
+
126
+ expect(resolved).toMatchObject({
127
+ servers: [
128
+ {
129
+ url: 'https://petstore.swagger.io/v2',
130
+ },
131
+ {
132
+ url: 'http://petstore.swagger.io/v2',
133
+ },
134
+ ],
135
+ operation: {
136
+ tags: ['pet'],
137
+ summary: 'Update an existing pet',
138
+ description: '',
139
+ requestBody: {
140
+ content: {
141
+ 'application/json': {
142
+ schema: {
143
+ type: 'object',
144
+ required: ['name', 'photoUrls'],
145
+ },
146
+ },
147
+ },
148
+ },
149
+ },
150
+ });
151
+ });
152
+
153
+ it('should resolve a ref with whitespace', async () => {
154
+ const resolved = await fetchOpenAPIOperation(
155
+ {
156
+ url: ' https://petstore3.swagger.io/api/v3/openapi.json',
157
+ method: 'put',
158
+ path: '/pet',
159
+ },
160
+ fetcher,
161
+ );
162
+
163
+ expect(resolved).toMatchObject({
164
+ servers: [
165
+ {
166
+ url: '/api/v3',
167
+ },
168
+ ],
169
+ operation: {
170
+ tags: ['pet'],
171
+ summary: 'Update an existing pet',
172
+ description: 'Update an existing pet by Id',
173
+ requestBody: {
174
+ content: {
175
+ 'application/json': {
176
+ schema: {
177
+ type: 'object',
178
+ required: ['name', 'photoUrls'],
179
+ },
180
+ },
181
+ },
182
+ },
183
+ },
184
+ });
185
+ });
@@ -0,0 +1,230 @@
1
+ import { toJSON, fromJSON } from 'flatted';
2
+ import { OpenAPIV3 } from 'openapi-types';
3
+ import YAML from 'yaml';
4
+ import swagger2openapi, { ConvertOutputOptions } from 'swagger2openapi';
5
+
6
+ import { resolveOpenAPIPath } from './resolveOpenAPIPath';
7
+ import { OpenAPIFetcher } from './types';
8
+
9
+ export interface OpenAPIOperationData extends OpenAPICustomSpecProperties {
10
+ path: string;
11
+ method: string;
12
+
13
+ /** Servers to be used for this operation */
14
+ servers: OpenAPIV3.ServerObject[];
15
+
16
+ /** Spec of the operation */
17
+ operation: OpenAPIV3.OperationObject & OpenAPICustomOperationProperties;
18
+
19
+ /** Securities that should be used for this operation */
20
+ securities: [string, OpenAPIV3.SecuritySchemeObject][];
21
+ }
22
+
23
+ /**
24
+ * Custom properties that can be defined at the entire spec level.
25
+ */
26
+ export interface OpenAPICustomSpecProperties {
27
+ /**
28
+ * If `true`, code samples will not be displayed.
29
+ * This option can be used to hide code samples for the entire spec.
30
+ */
31
+ 'x-codeSamples'?: boolean;
32
+
33
+ /**
34
+ * If `true`, the "Try it" button will not be displayed.
35
+ * This option can be used to hide code samples for the entire spec.
36
+ */
37
+ 'x-hideTryItPanel'?: boolean;
38
+ }
39
+
40
+ /**
41
+ * Custom properties that can be defined at the operation level.
42
+ * These properties are not part of the OpenAPI spec.
43
+ */
44
+ export interface OpenAPICustomOperationProperties {
45
+ 'x-code-samples'?: OpenAPICustomCodeSample[];
46
+ 'x-codeSamples'?: OpenAPICustomCodeSample[];
47
+ 'x-custom-examples'?: OpenAPICustomCodeSample[];
48
+
49
+ /**
50
+ * If `true`, the "Try it" button will not be displayed.
51
+ * https://redocly.com/docs/api-reference-docs/specification-extensions/x-hidetryitpanel/
52
+ */
53
+ 'x-hideTryItPanel'?: boolean;
54
+ }
55
+
56
+ /**
57
+ * Custom code samples that can be defined at the operation level.
58
+ * It follows the spec defined by Redocly.
59
+ * https://redocly.com/docs/api-reference-docs/specification-extensions/x-code-samples/
60
+ */
61
+ export interface OpenAPICustomCodeSample {
62
+ lang: string;
63
+ label: string;
64
+ source: string;
65
+ }
66
+
67
+ export { toJSON, fromJSON };
68
+
69
+ /**
70
+ * Resolve an OpenAPI operation in a file and compile it to a more usable format.
71
+ */
72
+ export async function fetchOpenAPIOperation(
73
+ input: {
74
+ url: string;
75
+ path: string;
76
+ method: string;
77
+ },
78
+ rawFetcher: OpenAPIFetcher,
79
+ ): Promise<OpenAPIOperationData | null> {
80
+ const fetcher = cacheFetcher(rawFetcher);
81
+
82
+ let operation = await resolveOpenAPIPath<OpenAPIV3.OperationObject>(
83
+ input.url,
84
+ ['paths', input.path, input.method],
85
+ fetcher,
86
+ );
87
+
88
+ if (!operation) {
89
+ return null;
90
+ }
91
+
92
+ const specData = await fetcher.fetch(input.url);
93
+
94
+ // Resolve common parameters
95
+ const commonParameters = await resolveOpenAPIPath<OpenAPIV3.ParameterObject[]>(
96
+ input.url,
97
+ ['paths', input.path, 'parameters'],
98
+ fetcher,
99
+ );
100
+ if (commonParameters) {
101
+ operation = {
102
+ ...operation,
103
+ parameters: [...commonParameters, ...(operation.parameters ?? [])],
104
+ };
105
+ }
106
+
107
+ // Resolve servers
108
+ const servers = await resolveOpenAPIPath<OpenAPIV3.ServerObject[]>(
109
+ input.url,
110
+ ['servers'],
111
+ fetcher,
112
+ );
113
+
114
+ // Resolve securities
115
+ const securities: OpenAPIOperationData['securities'] = [];
116
+ for (const security of operation.security ?? []) {
117
+ const securityKey = Object.keys(security)[0];
118
+
119
+ const securityScheme = await resolveOpenAPIPath<OpenAPIV3.SecuritySchemeObject>(
120
+ input.url,
121
+ ['components', 'securitySchemes', securityKey],
122
+ fetcher,
123
+ );
124
+
125
+ if (securityScheme) {
126
+ securities.push([securityKey, securityScheme]);
127
+ }
128
+ }
129
+
130
+ return {
131
+ servers: servers ?? [],
132
+ operation,
133
+ method: input.method,
134
+ path: input.path,
135
+ securities,
136
+ 'x-codeSamples':
137
+ typeof specData['x-codeSamples'] === 'boolean' ? specData['x-codeSamples'] : undefined,
138
+ 'x-hideTryItPanel':
139
+ typeof specData['x-hideTryItPanel'] === 'boolean'
140
+ ? specData['x-hideTryItPanel']
141
+ : undefined,
142
+ };
143
+ }
144
+
145
+ function cacheFetcher(fetcher: OpenAPIFetcher): OpenAPIFetcher {
146
+ const cache = new Map<string, Promise<any>>();
147
+
148
+ return {
149
+ async fetch(url) {
150
+ if (cache.has(url)) {
151
+ return cache.get(url);
152
+ }
153
+
154
+ const promise = fetcher.fetch(url);
155
+ cache.set(url, promise);
156
+ return promise;
157
+ },
158
+ parseMarkdown: fetcher.parseMarkdown,
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Parse a raw string into an OpenAPI document.
164
+ * It will also convert Swagger 2.0 to OpenAPI 3.0.
165
+ * It can throw an `OpenAPIFetchError` if the document is invalid.
166
+ */
167
+ export async function parseOpenAPIV3(url: string, text: string): Promise<OpenAPIV3.Document> {
168
+ // Parse the JSON or YAML
169
+ let data: unknown;
170
+
171
+ // Try with JSON
172
+ try {
173
+ data = JSON.parse(text);
174
+ } catch (jsonError) {
175
+ try {
176
+ // Try with YAML
177
+ data = YAML.parse(text);
178
+ } catch (yamlError) {
179
+ if (yamlError instanceof Error && yamlError.name.startsWith('YAML')) {
180
+ throw new OpenAPIFetchError('Failed to parse YAML: ' + yamlError.message, url);
181
+ } else {
182
+ throw yamlError;
183
+ }
184
+ }
185
+ }
186
+
187
+ // Convert Swagger 2.0 to OpenAPI 3.0
188
+ // @ts-ignore
189
+ if (data && data.swagger) {
190
+ try {
191
+ // Convert Swagger 2.0 to OpenAPI 3.0
192
+ // @ts-ignore
193
+ const result = (await swagger2openapi.convertObj(data, {
194
+ resolve: false,
195
+ resolveInternal: false,
196
+ laxDefaults: true,
197
+ laxurls: true,
198
+ lint: false,
199
+ prevalidate: false,
200
+ anchors: true,
201
+ patch: true,
202
+ })) as ConvertOutputOptions;
203
+
204
+ data = result.openapi;
205
+ } catch (error) {
206
+ if ((error as Error).name === 'S2OError') {
207
+ throw new OpenAPIFetchError(
208
+ 'Failed to convert Swagger 2.0 to OpenAPI 3.0: ' + (error as Error).message,
209
+ url,
210
+ );
211
+ } else {
212
+ throw error;
213
+ }
214
+ }
215
+ }
216
+
217
+ // @ts-ignore
218
+ return data;
219
+ }
220
+
221
+ export class OpenAPIFetchError extends Error {
222
+ public name = 'OpenAPIFetchError';
223
+
224
+ constructor(
225
+ message: string,
226
+ public readonly url: string,
227
+ ) {
228
+ super(message);
229
+ }
230
+ }