@gitbook/react-openapi 1.1.3 → 1.1.5

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/package.json CHANGED
@@ -8,7 +8,7 @@
8
8
  "default": "./dist/index.js"
9
9
  }
10
10
  },
11
- "version": "1.1.3",
11
+ "version": "1.1.5",
12
12
  "sideEffects": false,
13
13
  "dependencies": {
14
14
  "@gitbook/openapi-parser": "workspace:*",
@@ -1,4 +1,6 @@
1
+ import type { OpenAPIV3 } from '@gitbook/openapi-parser';
1
2
  import { OpenAPITabs, OpenAPITabsList, OpenAPITabsPanels } from './OpenAPITabs';
3
+ import { ScalarApiButton } from './ScalarApiButton';
2
4
  import { StaticSection } from './StaticSection';
3
5
  import { type CodeSampleInput, codeSampleGenerators } from './code-samples';
4
6
  import { generateMediaTypeExample, generateSchemaExample } from './generateSchemaExample';
@@ -26,13 +28,17 @@ export function OpenAPICodeSample(props: {
26
28
  }
27
29
 
28
30
  if (param.in === 'header' && param.required) {
29
- const example = param.schema ? generateSchemaExample(param.schema) : undefined;
31
+ const example = param.schema
32
+ ? generateSchemaExample(param.schema, { mode: 'write' })
33
+ : undefined;
30
34
  if (example !== undefined && param.name) {
31
35
  headersObject[param.name] =
32
36
  typeof example !== 'string' ? stringifyOpenAPI(example) : example;
33
37
  }
34
38
  } else if (param.in === 'query' && param.required) {
35
- const example = param.schema ? generateSchemaExample(param.schema) : undefined;
39
+ const example = param.schema
40
+ ? generateSchemaExample(param.schema, { mode: 'write' })
41
+ : undefined;
36
42
  if (example !== undefined && param.name) {
37
43
  searchParams.append(
38
44
  param.name,
@@ -75,6 +81,7 @@ export function OpenAPICodeSample(props: {
75
81
  code: generator.generate(input),
76
82
  syntax: generator.syntax,
77
83
  }),
84
+ footer: <OpenAPICodeSampleFooter data={data} context={context} />,
78
85
  }));
79
86
 
80
87
  // Use custom samples if defined
@@ -101,6 +108,7 @@ export function OpenAPICodeSample(props: {
101
108
  code: sample.source,
102
109
  syntax: sample.lang,
103
110
  }),
111
+ footer: <OpenAPICodeSampleFooter data={data} context={context} />,
104
112
  }));
105
113
  }
106
114
  });
@@ -124,6 +132,30 @@ export function OpenAPICodeSample(props: {
124
132
  );
125
133
  }
126
134
 
135
+ function OpenAPICodeSampleFooter(props: {
136
+ data: OpenAPIOperationData;
137
+ context: OpenAPIContextProps;
138
+ }) {
139
+ const { data, context } = props;
140
+ const { method, path } = data;
141
+ const { specUrl } = context;
142
+ const hideTryItPanel = data['x-hideTryItPanel'] || data.operation['x-hideTryItPanel'];
143
+
144
+ if (hideTryItPanel) {
145
+ return null;
146
+ }
147
+
148
+ if (!validateHttpMethod(method)) {
149
+ return null;
150
+ }
151
+
152
+ return (
153
+ <div className="openapi-codesample-footer">
154
+ <ScalarApiButton method={method} path={path} specUrl={specUrl} />
155
+ </div>
156
+ );
157
+ }
158
+
127
159
  function getSecurityHeaders(securities: OpenAPIOperationData['securities']): {
128
160
  [key: string]: string;
129
161
  } {
@@ -165,3 +197,7 @@ function getSecurityHeaders(securities: OpenAPIOperationData['securities']): {
165
197
  }
166
198
  }
167
199
  }
200
+
201
+ function validateHttpMethod(method: string): method is OpenAPIV3.HttpMethods {
202
+ return ['get', 'post', 'put', 'delete', 'patch', 'head', 'options', 'trace'].includes(method);
203
+ }
@@ -0,0 +1,54 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { Button, type ButtonProps, Tooltip, TooltipTrigger } from 'react-aria-components';
5
+
6
+ export function OpenAPICopyButton(
7
+ props: ButtonProps & {
8
+ value: string;
9
+ }
10
+ ) {
11
+ const { value } = props;
12
+ const { children, onPress, className } = props;
13
+ const [copied, setCopied] = useState(false);
14
+ const [isOpen, setIsOpen] = useState(false);
15
+
16
+ const handleCopy = () => {
17
+ if (!value) return;
18
+ navigator.clipboard.writeText(value).then(() => {
19
+ setIsOpen(true);
20
+ setCopied(true);
21
+
22
+ setTimeout(() => {
23
+ setCopied(false);
24
+ }, 2000);
25
+ });
26
+ };
27
+
28
+ return (
29
+ <TooltipTrigger isOpen={isOpen} onOpenChange={setIsOpen} closeDelay={200} delay={200}>
30
+ <Button
31
+ type="button"
32
+ preventFocusOnPress
33
+ onPress={(e) => {
34
+ handleCopy();
35
+ onPress?.(e);
36
+ }}
37
+ className={`openapi-copy-button ${className}`}
38
+ {...props}
39
+ >
40
+ {children}
41
+ </Button>
42
+
43
+ <Tooltip
44
+ isOpen={isOpen}
45
+ onOpenChange={setIsOpen}
46
+ placement="top"
47
+ offset={4}
48
+ className="openapi-tooltip"
49
+ >
50
+ {copied ? 'Copied' : 'Copy to clipboard'}{' '}
51
+ </Tooltip>
52
+ </TooltipTrigger>
53
+ );
54
+ }
@@ -35,6 +35,7 @@ export function OpenAPIOperation(props: {
35
35
  title: operation.summary,
36
36
  })
37
37
  : null}
38
+ <OpenAPIPath data={data} context={context} />
38
39
  {operation.deprecated && <div className="openapi-deprecated">Deprecated</div>}
39
40
  </div>
40
41
  <div className="openapi-columns">
@@ -49,7 +50,6 @@ export function OpenAPIOperation(props: {
49
50
  </div>
50
51
  ) : null}
51
52
  <OpenAPIOperationDescription operation={operation} context={context} />
52
- <OpenAPIPath data={data} context={context} />
53
53
  <OpenAPISpec data={data} context={clientContext} />
54
54
  </div>
55
55
  <div className="openapi-column-preview">
@@ -1,7 +1,6 @@
1
- import type { OpenAPIV3_1 } from '@gitbook/openapi-parser';
2
- import type React from 'react';
3
- import { ScalarApiButton } from './ScalarApiButton';
1
+ import { OpenAPICopyButton } from './OpenAPICopyButton';
4
2
  import type { OpenAPIContextProps, OpenAPIOperationData } from './types';
3
+ import { getDefaultServerURL } from './util/server';
5
4
 
6
5
  /**
7
6
  * Display the path of an operation.
@@ -10,63 +9,62 @@ export function OpenAPIPath(props: {
10
9
  data: OpenAPIOperationData;
11
10
  context: OpenAPIContextProps;
12
11
  }) {
13
- const { data, context } = props;
14
- const { method, path } = data;
15
- const { specUrl } = context;
16
- const hideTryItPanel = data['x-hideTryItPanel'] || data.operation['x-hideTryItPanel'];
12
+ const { data } = props;
13
+ const { method, path, operation } = data;
14
+
15
+ const server = getDefaultServerURL(data.servers);
16
+ const formattedPath = formatPath(path);
17
17
 
18
18
  return (
19
19
  <div className="openapi-path">
20
20
  <div className={`openapi-method openapi-method-${method}`}>{method}</div>
21
- <div className="openapi-path-title" data-deprecated={data.operation.deprecated}>
22
- <p>{formatPath(path)}</p>
23
- </div>
24
- {!hideTryItPanel && validateHttpMethod(method) && (
25
- <ScalarApiButton method={method} path={path} specUrl={specUrl} />
26
- )}
21
+
22
+ <OpenAPICopyButton
23
+ value={server + path}
24
+ className="openapi-path-title"
25
+ data-deprecated={operation.deprecated}
26
+ >
27
+ <span className="openapi-path-server">{server}</span>
28
+ {formattedPath}
29
+ </OpenAPICopyButton>
27
30
  </div>
28
31
  );
29
32
  }
30
33
 
31
- function validateHttpMethod(method: string): method is OpenAPIV3_1.HttpMethods {
32
- return ['get', 'post', 'put', 'delete', 'patch', 'head', 'options', 'trace'].includes(method);
33
- }
34
-
35
- // Format the path to highlight placeholders
34
+ /**
35
+ * Format the path by wrapping placeholders in <span> tags.
36
+ */
36
37
  function formatPath(path: string) {
37
38
  // Matches placeholders like {id}, {userId}, etc.
38
- const regex = /\{(\w+)\}/g;
39
+ const regex = /\{\s*(\w+)\s*\}|:\w+/g;
39
40
 
40
41
  const parts: (string | React.JSX.Element)[] = [];
41
42
  let lastIndex = 0;
42
43
 
43
- // Replace placeholders with <em> tags
44
- path.replace(regex, (match, key, offset) => {
45
- parts.push(path.slice(lastIndex, offset));
46
- parts.push(<em key={key}>{`{${key}}`}</em>);
44
+ //Wrap the variables in <span> tags and maintain either {variable} or :variable
45
+ path.replace(regex, (match, _, offset) => {
46
+ if (offset > lastIndex) {
47
+ parts.push(path.slice(lastIndex, offset));
48
+ }
49
+ parts.push(
50
+ <span key={offset} className="openapi-path-variable">
51
+ {match}
52
+ </span>
53
+ );
47
54
  lastIndex = offset + match.length;
48
55
  return match;
49
56
  });
50
57
 
51
- // Push remaining text after the last placeholder
52
- parts.push(path.slice(lastIndex));
53
-
54
- // Join parts with separators wrapped in <span>
55
- const formattedPath = parts.reduce(
56
- (acc, part, index) => {
57
- if (typeof part === 'string' && index > 0 && part === '/') {
58
- acc.push(
59
- <span className="openapi-path-separator" key={`sep-${index}`}>
60
- /
61
- </span>
62
- );
63
- }
58
+ if (lastIndex < path.length) {
59
+ parts.push(path.slice(lastIndex));
60
+ }
64
61
 
65
- acc.push(part);
66
- return acc;
67
- },
68
- [] as (string | React.JSX.Element)[]
69
- );
62
+ const formattedPath = parts.map((part, index) => {
63
+ if (typeof part === 'string') {
64
+ return <span key={index}>{part}</span>;
65
+ }
66
+ return part;
67
+ });
70
68
 
71
- return <span>{formattedPath}</span>;
69
+ return formattedPath;
72
70
  }
@@ -1,4 +1,5 @@
1
1
  import type { OpenAPIV3 } from '@gitbook/openapi-parser';
2
+ import { Markdown } from './Markdown';
2
3
  import { OpenAPITabs, OpenAPITabsList, OpenAPITabsPanels } from './OpenAPITabs';
3
4
  import { StaticSection } from './StaticSection';
4
5
  import { generateSchemaExample } from './generateSchemaExample';
@@ -39,44 +40,40 @@ export function OpenAPIResponseExample(props: {
39
40
  return Number(a) - Number(b);
40
41
  });
41
42
 
42
- const tabs = responses
43
- .map(([key, responseObject]) => {
44
- const description = resolveDescription(responseObject);
45
-
46
- if (checkIsReference(responseObject)) {
47
- return {
48
- key: key,
49
- label: key,
50
- description,
51
- body: (
52
- <OpenAPIExample
53
- example={getExampleFromReference(responseObject)}
54
- context={context}
55
- syntax="json"
56
- />
57
- ),
58
- };
59
- }
60
-
61
- if (!responseObject.content || Object.keys(responseObject.content).length === 0) {
62
- return {
63
- key: key,
64
- label: key,
65
- description,
66
- body: <OpenAPIEmptyResponseExample />,
67
- };
68
- }
43
+ const tabs = responses.map(([key, responseObject]) => {
44
+ const description = resolveDescription(responseObject);
69
45
 
46
+ if (checkIsReference(responseObject)) {
70
47
  return {
71
48
  key: key,
72
49
  label: key,
73
- description: resolveDescription(responseObject),
74
- body: <OpenAPIResponse context={context} content={responseObject.content} />,
50
+ body: (
51
+ <OpenAPIExample
52
+ example={getExampleFromReference(responseObject)}
53
+ context={context}
54
+ syntax="json"
55
+ />
56
+ ),
57
+ footer: description ? <Markdown source={description} /> : undefined,
75
58
  };
76
- })
77
- .filter((val): val is { key: string; label: string; body: any; description: string } =>
78
- Boolean(val)
79
- );
59
+ }
60
+
61
+ if (!responseObject.content || Object.keys(responseObject.content).length === 0) {
62
+ return {
63
+ key: key,
64
+ label: key,
65
+ body: <OpenAPIEmptyResponseExample />,
66
+ footer: description ? <Markdown source={description} /> : undefined,
67
+ };
68
+ }
69
+
70
+ return {
71
+ key: key,
72
+ label: key,
73
+ body: <OpenAPIResponse context={context} content={responseObject.content} />,
74
+ footer: description ? <Markdown source={description} /> : undefined,
75
+ };
76
+ });
80
77
 
81
78
  if (tabs.length === 0) {
82
79
  return null;
@@ -30,6 +30,7 @@ export function OpenAPISchemaName(props: OpenAPISchemaNameProps) {
30
30
  <span className="openapi-schema-type">{additionalItems}</span>
31
31
  ) : null}
32
32
  </span>
33
+ {schema?.readOnly ? <span className="openapi-schema-readonly">read-only</span> : null}
33
34
  {required ? <span className="openapi-schema-required">required</span> : null}
34
35
  {schema?.deprecated ? <span className="openapi-deprecated">Deprecated</span> : null}
35
36
  </div>
@@ -3,14 +3,13 @@
3
3
  import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
4
4
  import { type Key, Tab, TabList, TabPanel, Tabs, type TabsProps } from 'react-aria-components';
5
5
  import { useEventCallback } from 'usehooks-ts';
6
- import { Markdown } from './Markdown';
7
6
  import { getOrCreateTabStoreByKey } from './useSyncedTabsGlobalState';
8
7
 
9
8
  export type TabItem = {
10
9
  key: Key;
11
10
  label: string;
12
11
  body: React.ReactNode;
13
- description?: string;
12
+ footer?: React.ReactNode;
14
13
  };
15
14
 
16
15
  type OpenAPITabsContextData = {
@@ -140,9 +139,19 @@ export function OpenAPITabsPanels() {
140
139
  return (
141
140
  <TabPanel key={key} id={key} className="openapi-tabs-panel">
142
141
  {selectedTab.body}
143
- {selectedTab.description ? (
144
- <Markdown source={selectedTab.description} className="openapi-tabs-footer" />
142
+ {selectedTab.footer ? (
143
+ <OpenAPITabsPanelFooter>{selectedTab.footer}</OpenAPITabsPanelFooter>
145
144
  ) : null}
146
145
  </TabPanel>
147
146
  );
148
147
  }
148
+
149
+ /**
150
+ * The OpenAPI Tabs panel footer component.
151
+ * This component should be used as a child of the OpenAPITabs component.
152
+ */
153
+ function OpenAPITabsPanelFooter(props: { children: React.ReactNode }) {
154
+ const { children } = props;
155
+
156
+ return <div className="openapi-tabs-footer">{children}</div>;
157
+ }
@@ -27,14 +27,14 @@ export function ScalarApiButton(props: {
27
27
  setIsOpen(true);
28
28
  }}
29
29
  >
30
- <svg xmlns="http://www.w3.org/2000/svg" width="10" height="12" fill="none">
30
+ Test it
31
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 12" fill="currentColor">
31
32
  <path
32
33
  stroke="currentColor"
33
34
  strokeWidth="1.5"
34
35
  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"
35
36
  />
36
37
  </svg>
37
- Test it
38
38
  </button>
39
39
 
40
40
  {isOpen &&
@@ -4,13 +4,18 @@ import { OpenAPIRootSchema } from '../OpenAPISchema';
4
4
  import { Section, SectionBody } from '../StaticSection';
5
5
  import type { OpenAPIClientContext, OpenAPIContextProps, OpenAPISchemasData } from '../types';
6
6
 
7
+ type OpenAPISchemasContextProps = Omit<
8
+ OpenAPIContextProps,
9
+ 'renderCodeBlock' | 'renderHeading' | 'renderDocument'
10
+ >;
11
+
7
12
  /**
8
13
  * Display OpenAPI Schemas.
9
14
  */
10
15
  export function OpenAPISchemas(props: {
11
16
  className?: string;
12
17
  data: OpenAPISchemasData;
13
- context: OpenAPIContextProps;
18
+ context: OpenAPISchemasContextProps;
14
19
  }) {
15
20
  const { className, data, context } = props;
16
21
  const { schemas } = data;
@@ -0,0 +1,174 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+
3
+ import { parseOpenAPI, traverse } from '@gitbook/openapi-parser';
4
+ import { resolveOpenAPISchemas } from './resolveOpenAPISchemas';
5
+
6
+ async function fetchFilesystem(url: string) {
7
+ const response = await fetch(url);
8
+ const text = await response.text();
9
+ const filesystem = await parseOpenAPI({ value: text, rootURL: url });
10
+ const transformedFs = await traverse(filesystem, async (node) => {
11
+ if ('description' in node && typeof node.description === 'string' && node.description) {
12
+ node['x-gitbook-description-html'] = node.description;
13
+ }
14
+ return node;
15
+ });
16
+ return transformedFs;
17
+ }
18
+
19
+ describe('#resolveOpenAPISchemas', () => {
20
+ it('should resolve refs', async () => {
21
+ const filesystem = await fetchFilesystem(
22
+ 'https://petstore3.swagger.io/api/v3/openapi.json'
23
+ );
24
+ const resolved = await resolveOpenAPISchemas(filesystem, { schemas: ['Pet', 'Tag'] });
25
+
26
+ expect(resolved).toMatchObject({
27
+ schemas: [
28
+ {
29
+ name: 'Pet',
30
+ schema: {
31
+ properties: {
32
+ tags: {
33
+ type: 'array',
34
+ items: {
35
+ type: 'object',
36
+ properties: {
37
+ id: {
38
+ format: 'int64',
39
+ type: 'integer',
40
+ },
41
+ name: {
42
+ type: 'string',
43
+ },
44
+ },
45
+ },
46
+ },
47
+ },
48
+ },
49
+ },
50
+ {
51
+ name: 'Tag',
52
+ schema: {
53
+ type: 'object',
54
+ properties: {
55
+ id: {
56
+ type: 'integer',
57
+ format: 'int64',
58
+ },
59
+ name: {
60
+ type: 'string',
61
+ },
62
+ },
63
+ xml: {
64
+ name: 'tag',
65
+ },
66
+ },
67
+ },
68
+ ],
69
+ });
70
+ });
71
+
72
+ it('should support yaml', async () => {
73
+ const filesystem = await fetchFilesystem(
74
+ 'https://petstore3.swagger.io/api/v3/openapi.yaml'
75
+ );
76
+ const resolved = await resolveOpenAPISchemas(filesystem, { schemas: ['Pet'] });
77
+
78
+ expect(resolved).toMatchObject({
79
+ schemas: [
80
+ {
81
+ name: 'Pet',
82
+ schema: {
83
+ properties: {
84
+ tags: {
85
+ type: 'array',
86
+ items: {
87
+ properties: {
88
+ id: {
89
+ format: 'int64',
90
+ type: 'integer',
91
+ },
92
+ name: {
93
+ type: 'string',
94
+ },
95
+ },
96
+ type: 'object',
97
+ },
98
+ },
99
+ },
100
+ },
101
+ },
102
+ ],
103
+ });
104
+ });
105
+
106
+ it('should resolve circular refs', async () => {
107
+ const filesystem = await fetchFilesystem('https://api.gitbook.com/openapi.json');
108
+ const resolved = await resolveOpenAPISchemas(filesystem, {
109
+ schemas: ['DocumentBlockTabs'],
110
+ });
111
+
112
+ expect(resolved).toMatchObject({
113
+ schemas: [
114
+ {
115
+ name: 'DocumentBlockTabs',
116
+ schema: {
117
+ type: 'object',
118
+ properties: {
119
+ object: {
120
+ type: 'string',
121
+ enum: ['block'],
122
+ },
123
+ },
124
+ },
125
+ },
126
+ ],
127
+ });
128
+ });
129
+
130
+ it('should resolve to null if the schema does not exist', async () => {
131
+ const filesystem = await fetchFilesystem(
132
+ 'https://petstore3.swagger.io/api/v3/openapi.json'
133
+ );
134
+ const resolved = await resolveOpenAPISchemas(filesystem, {
135
+ schemas: ['NonExistentSchema'],
136
+ });
137
+
138
+ expect(resolved).toBe(null);
139
+ });
140
+
141
+ it('should parse Swagger 2.0', async () => {
142
+ const filesystem = await fetchFilesystem('https://petstore.swagger.io/v2/swagger.json');
143
+ const resolved = await resolveOpenAPISchemas(filesystem, {
144
+ schemas: ['Pet'],
145
+ });
146
+
147
+ expect(resolved).toMatchObject({
148
+ schemas: [
149
+ {
150
+ name: 'Pet',
151
+ schema: {
152
+ properties: {
153
+ tags: {
154
+ type: 'array',
155
+ items: {
156
+ properties: {
157
+ id: {
158
+ format: 'int64',
159
+ type: 'integer',
160
+ },
161
+ name: {
162
+ type: 'string',
163
+ },
164
+ },
165
+ type: 'object',
166
+ },
167
+ },
168
+ },
169
+ },
170
+ },
171
+ ],
172
+ });
173
+ });
174
+ });
@@ -15,21 +15,43 @@ import type { OpenAPISchema, OpenAPISchemasData } from '../types';
15
15
  * Schemas are extracted from the OpenAPI components.schemas
16
16
  */
17
17
  export async function resolveOpenAPISchemas(
18
- filesystem: Filesystem<OpenAPIV3xDocument>
18
+ filesystem: Filesystem<OpenAPIV3xDocument>,
19
+ options: {
20
+ schemas: string[];
21
+ }
19
22
  ): Promise<OpenAPISchemasData | null> {
23
+ const { schemas: selectedSchemas } = options;
24
+
20
25
  const schema = await dereferenceFilesystem(filesystem);
21
26
 
22
- const schemas = getOpenAPIComponents(schema);
27
+ const schemas = filterSelectedOpenAPISchemas(schema, selectedSchemas);
28
+
29
+ if (schemas.length === 0) {
30
+ return null;
31
+ }
23
32
 
24
33
  return { schemas };
25
34
  }
26
-
27
35
  /**
28
- * Get OpenAPI components.schemas that are not ignored.
36
+ * Extract selected schemas from the OpenAPI document.
29
37
  */
30
- function getOpenAPIComponents(schema: OpenAPIV3.Document | OpenAPIV3_1.Document): OpenAPISchema[] {
31
- const schemas = schema.components?.schemas ?? {};
32
- return Object.entries(schemas)
33
- .filter(([, schema]) => !shouldIgnoreEntity(schema))
34
- .map(([key, schema]) => ({ name: key, schema }));
38
+ export function filterSelectedOpenAPISchemas(
39
+ schema: OpenAPIV3.Document | OpenAPIV3_1.Document,
40
+ selectedSchemas: string[]
41
+ ): OpenAPISchema[] {
42
+ const componentsSchemas = schema.components?.schemas ?? {};
43
+
44
+ // Preserve the order of the selected schemas
45
+ return selectedSchemas
46
+ .map((name) => {
47
+ const schema = componentsSchemas[name];
48
+ if (schema && !shouldIgnoreEntity(schema)) {
49
+ return {
50
+ name,
51
+ schema,
52
+ };
53
+ }
54
+ return null;
55
+ })
56
+ .filter((schema): schema is OpenAPISchema => !!schema);
35
57
  }