Skip to content

Commit 129d05c

Browse files
weyertP0lip
andauthored
feat: add support for vendor extensions (#245)
--------- Co-authored-by: Weyert de Boer <[email protected]> Co-authored-by: Jakub Rożek <[email protected]>
1 parent 0737d66 commit 129d05c

File tree

10 files changed

+207
-12
lines changed

10 files changed

+207
-12
lines changed
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
{
2+
"title": "User",
3+
"type": "object",
4+
"x-stoplight": { "id": "root-id" },
5+
"properties": {
6+
"name": {
7+
"type": "string",
8+
"const": "Constant name",
9+
"examples": ["Example name", "Different name"],
10+
"x-stoplight": { "id": "name-id" }
11+
},
12+
"age": {
13+
"type": "number",
14+
"minimum": 10,
15+
"maximum": 40,
16+
"x-stoplight": { "id": "age-id" }
17+
},
18+
"completed_at": {
19+
"type": "string",
20+
"format": "date-time",
21+
"x-stoplight": { "id": "completed_at-id" }
22+
},
23+
"list": {
24+
"type": ["null", "array"],
25+
"items": {
26+
"type": ["string", "number"],
27+
"x-stoplight": { "id": "list-items-id" }
28+
},
29+
"minItems": 1,
30+
"maxItems": 4,
31+
"x-stoplight": { "id": "list-id" }
32+
},
33+
"email": {
34+
"type": "string",
35+
"format": "email",
36+
37+
"deprecated": true,
38+
"default": "[email protected]",
39+
"minLength": 2,
40+
"x-stoplight": { "id": "email-id" }
41+
},
42+
"list-of-objects": {
43+
"type": "array",
44+
"items": {
45+
"type": "object",
46+
"x-stoplight": { "id": "list-of-objects-items-id" },
47+
"properties": {
48+
"id": {
49+
"type": "string",
50+
"x-stoplight": { "id": "list-of-objects-items-id-id" }
51+
},
52+
"friend": {
53+
"type": "object",
54+
"x-stoplight": { "id": "list-of-objects-items-friend-id" },
55+
"properties": {
56+
"id": {
57+
"type": "string",
58+
"x-stoplight": { "id": "list-of-objects-items-friend-id-id" }
59+
},
60+
"name": {
61+
"type": "object",
62+
"x-stoplight": { "id": "list-of-objects-items-friend-name-id" },
63+
"properties": {
64+
"first": {
65+
"type": "string",
66+
"x-stoplight": { "id": "list-of-objects-items-friend-name-first-id" }
67+
},
68+
"last": {
69+
"type": "string",
70+
"x-stoplight": { "id": "list-of-objects-items-friend-name-last-id" }
71+
}
72+
}
73+
}
74+
}
75+
}
76+
}
77+
},
78+
"minItems": 1,
79+
"maxItems": 4,
80+
"x-stoplight": { "id": "list-of-objects-id" }
81+
},
82+
"friend": {
83+
"type": "object",
84+
"x-stoplight": { "id": "friend-id" },
85+
"properties": {
86+
"id": {
87+
"type": "string",
88+
"x-stoplight": { "id": "friend-id-id" }
89+
},
90+
"name": {
91+
"type": "string",
92+
"x-stoplight": { "id": "friend-name-id" }
93+
}
94+
}
95+
}
96+
}
97+
}

src/__stories__/Default.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ CustomRowAddon.args = {
3030
),
3131
};
3232

33+
export const Expansions = Template.bind({});
34+
Expansions.args = {
35+
schema: arrayOfComplexObjects as JSONSchema4,
36+
renderRootTreeLines: true,
37+
defaultExpandedDepth: 0,
38+
};
39+
3340
export const ArrayOfObjects = Template.bind({});
3441
ArrayOfObjects.args = { schema: arrayOfComplexObjects as JSONSchema4, renderRootTreeLines: true };
3542

src/__stories__/VendorExtensions.tsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { Flex } from '@stoplight/mosaic';
2+
import { Story } from '@storybook/react';
3+
import { JSONSchema4 } from 'json-schema';
4+
import React from 'react';
5+
6+
import { JsonSchemaProps, JsonSchemaViewer } from '../components/JsonSchemaViewer';
7+
8+
const defaultSchema = require('../__fixtures__/default-schema.json');
9+
const extensionsSchema = require('../__fixtures__/extensions/simple.json');
10+
11+
export default {
12+
component: JsonSchemaViewer,
13+
argTypes: {},
14+
};
15+
16+
const Template: Story<JsonSchemaProps> = ({ schema = defaultSchema as JSONSchema4, ...args }) => (
17+
<JsonSchemaViewer schema={schema} {...args} />
18+
);
19+
20+
export const ExtensionRowSchema = Template.bind({});
21+
ExtensionRowSchema.args = {
22+
schema: extensionsSchema as JSONSchema4,
23+
defaultExpandedDepth: Infinity,
24+
renderRootTreeLines: true,
25+
renderExtensionAddon: ({ nestingLevel, vendorExtensions }) => {
26+
if (nestingLevel < 1) {
27+
return null;
28+
}
29+
30+
if (typeof vendorExtensions['x-stoplight'] === 'undefined') {
31+
return null;
32+
}
33+
34+
return (
35+
<Flex h="full" alignItems="center">
36+
<strong>{JSON.stringify(vendorExtensions['x-stoplight'], null, 2)}</strong>
37+
</Flex>
38+
);
39+
},
40+
};

src/components/JsonSchemaViewer.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const JsonSchemaViewerComponent = ({
3636
defaultExpandedDepth = 1,
3737
onGoToRef,
3838
renderRowAddon,
39+
renderExtensionAddon,
3940
hideExamples,
4041
renderRootTreeLines,
4142
disableCrumbs,
@@ -49,6 +50,7 @@ const JsonSchemaViewerComponent = ({
4950
viewMode,
5051
onGoToRef,
5152
renderRowAddon,
53+
renderExtensionAddon,
5254
hideExamples,
5355
renderRootTreeLines,
5456
disableCrumbs,
@@ -59,6 +61,7 @@ const JsonSchemaViewerComponent = ({
5961
viewMode,
6062
onGoToRef,
6163
renderRowAddon,
64+
renderExtensionAddon,
6265
hideExamples,
6366
renderRootTreeLines,
6467
disableCrumbs,

src/components/SchemaRow/SchemaRow.tsx

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { COMBINER_NAME_MAP } from '../../consts';
1010
import { useJSVOptionsContext } from '../../contexts';
1111
import { getNodeId, getOriginalNodeId } from '../../hash';
1212
import { isPropertyRequired, visibleChildren } from '../../tree';
13+
import { extractVendorExtensions } from '../../utils/extractVendorExtensions';
1314
import { Caret, Description, getValidationsFromSchema, Types, Validations } from '../shared';
1415
import { ChildStack } from '../shared/ChildStack';
1516
import { Error } from '../shared/Error';
@@ -30,6 +31,7 @@ export const SchemaRow: React.FunctionComponent<SchemaRowProps> = React.memo(
3031
const {
3132
defaultExpandedDepth,
3233
renderRowAddon,
34+
renderExtensionAddon,
3335
onGoToRef,
3436
hideExamples,
3537
renderRootTreeLines,
@@ -65,6 +67,12 @@ export const SchemaRow: React.FunctionComponent<SchemaRowProps> = React.memo(
6567
const validations = isRegularNode(schemaNode) ? schemaNode.validations : {};
6668
const hasProperties = useHasProperties({ required, deprecated, validations });
6769

70+
const [totalVendorExtensions, vendorExtensions] = React.useMemo(
71+
() => extractVendorExtensions(schemaNode.fragment),
72+
[schemaNode.fragment],
73+
);
74+
const hasVendorProperties = totalVendorExtensions > 0;
75+
6876
const annotationRootOffset = renderRootTreeLines ? 0 : 8;
6977
let annotationLeftOffset = -20 - annotationRootOffset;
7078
if (nestingLevel > 1) {
@@ -100,11 +108,9 @@ export const SchemaRow: React.FunctionComponent<SchemaRowProps> = React.memo(
100108
}}
101109
>
102110
{!isRootLevel && <Box borderT w={isCollapsible ? 1 : 3} ml={-3} mr={3} mt={2} />}
103-
104111
{parentChangeType !== 'added' && parentChangeType !== 'removed' ? (
105112
<NodeAnnotation change={hasChanged} style={{ left: annotationLeftOffset }} />
106113
) : null}
107-
108114
<VStack spacing={1} maxW="full" flex={1} ml={isCollapsible && !isRootLevel ? 2 : undefined}>
109115
<Flex
110116
alignItems="center"
@@ -113,7 +119,6 @@ export const SchemaRow: React.FunctionComponent<SchemaRowProps> = React.memo(
113119
cursor={isCollapsible ? 'pointer' : undefined}
114120
>
115121
{isCollapsible ? <Caret isExpanded={isExpanded} /> : null}
116-
117122
<Flex alignItems="baseline" fontSize="base">
118123
{schemaNode.subpath.length > 0 && shouldShowPropertyName(schemaNode) && (
119124
<Box
@@ -167,22 +172,19 @@ export const SchemaRow: React.FunctionComponent<SchemaRowProps> = React.memo(
167172
/>
168173
)}
169174
</Flex>
170-
171175
{hasProperties && <Divider atom={isNodeHoveredAtom(schemaNode)} />}
172-
173176
<Properties required={required} deprecated={deprecated} validations={validations} />
174177
</Flex>
175-
176178
{typeof description === 'string' && description.length > 0 && <Description value={description} />}
177-
178179
<Validations
179180
validations={isRegularNode(schemaNode) ? getValidationsFromSchema(schemaNode) : {}}
180181
hideExamples={hideExamples}
181182
/>
183+
{hasVendorProperties && renderExtensionAddon ? (
184+
<Box>{renderExtensionAddon({ schemaNode, nestingLevel, vendorExtensions })}</Box>
185+
) : null}
182186
</VStack>
183-
184187
<Error schemaNode={schemaNode} />
185-
186188
{renderRowAddon ? <Box>{renderRowAddon({ schemaNode, nestingLevel })}</Box> : null}
187189
</Flex>
188190
{isCollapsible && isExpanded ? (

src/components/SchemaRow/TopLevelSchemaRow.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import { isEmpty } from 'lodash';
55
import * as React from 'react';
66

77
import { COMBINER_NAME_MAP } from '../../consts';
8+
import { useJSVOptionsContext } from '../../contexts';
89
import { useIsOnScreen } from '../../hooks/useIsOnScreen';
910
import { isComplexArray, isDictionaryNode, visibleChildren } from '../../tree';
11+
import { extractVendorExtensions } from '../../utils/extractVendorExtensions';
1012
import { showPathCrumbsAtom } from '../PathCrumbs/state';
1113
import { Description, getValidationsFromSchema, Validations } from '../shared';
1214
import { ChildStack } from '../shared/ChildStack';
@@ -18,18 +20,28 @@ export const TopLevelSchemaRow = ({
1820
schemaNode,
1921
skipDescription,
2022
}: Pick<SchemaRowProps, 'schemaNode'> & { skipDescription?: boolean }) => {
23+
const { renderExtensionAddon } = useJSVOptionsContext();
24+
2125
const { selectedChoice, setSelectedChoice, choices } = useChoices(schemaNode);
2226
const childNodes = React.useMemo(() => visibleChildren(selectedChoice.type), [selectedChoice.type]);
2327
const nestingLevel = 0;
2428

2529
const nodeId = schemaNode.fragment?.['x-stoplight']?.id;
30+
const [totalVendorExtensions, vendorExtensions] = React.useMemo(
31+
() => extractVendorExtensions(schemaNode.fragment),
32+
[schemaNode.fragment],
33+
);
34+
const hasVendorProperties = totalVendorExtensions > 0;
2635

2736
// regular objects are flattened at the top level
2837
if (isRegularNode(schemaNode) && isPureObjectNode(schemaNode)) {
2938
return (
3039
<>
3140
<ScrollCheck />
3241
{!skipDescription ? <Description value={schemaNode.annotations.description} /> : null}
42+
{hasVendorProperties && renderExtensionAddon
43+
? renderExtensionAddon({ schemaNode, nestingLevel, vendorExtensions })
44+
: null}
3345
<ChildStack
3446
schemaNode={schemaNode}
3547
childNodes={childNodes}
@@ -48,7 +60,6 @@ export const TopLevelSchemaRow = ({
4860
<>
4961
<ScrollCheck />
5062
<Description value={schemaNode.annotations.description} />
51-
5263
<HStack spacing={3} pb={4}>
5364
<Menu
5465
aria-label="Pick a type"
@@ -77,7 +88,6 @@ export const TopLevelSchemaRow = ({
7788
</Flex>
7889
) : null}
7990
</HStack>
80-
8191
{childNodes.length > 0 ? (
8292
<ChildStack
8393
schemaNode={schemaNode}

src/contexts/jsvOptions.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import type { NodeHasChangedFn } from '@stoplight/types';
22
import * as React from 'react';
33

4-
import { GoToRefHandler, RowAddonRenderer, ViewMode } from '../types';
4+
import { ExtensionAddonRenderer, GoToRefHandler, RowAddonRenderer, ViewMode } from '../types';
55

66
export type JSVOptions = {
77
defaultExpandedDepth: number;
88
viewMode: ViewMode;
99
onGoToRef?: GoToRefHandler;
1010
renderRowAddon?: RowAddonRenderer;
11+
renderExtensionAddon?: ExtensionAddonRenderer;
1112
hideExamples?: boolean;
1213
renderRootTreeLines?: boolean;
1314
disableCrumbs?: boolean;

src/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ export interface SchemaRowProps {
1111

1212
export type RowAddonRenderer = (props: SchemaRowProps) => React.ReactNode;
1313

14+
export interface ExtensionRowProps {
15+
schemaNode: SchemaNode;
16+
nestingLevel: number;
17+
vendorExtensions: Record<string, unknown>;
18+
}
19+
20+
export type ExtensionAddonRenderer = (props: ExtensionRowProps) => React.ReactNode;
21+
1422
export type ViewMode = 'read' | 'write' | 'standalone';
1523

1624
export type JSONSchema = JSONSchema4 | JSONSchema6 | JSONSchema7;

src/utils/extractVendorExtensions.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { SchemaFragment } from '@stoplight/json-schema-tree';
2+
3+
export type VendorExtensionsList = {
4+
[keyof: string]: unknown;
5+
};
6+
7+
export type VendorExtensionsResult = [number, VendorExtensionsList];
8+
9+
/**
10+
* Extract all vendor extensions or properties prefix with 'x-' from the schema definition
11+
* @param fragment The fragment to extract the vendor extensions from
12+
* @returns VendorExtensionsResult
13+
*/
14+
export function extractVendorExtensions(fragment: SchemaFragment | boolean): VendorExtensionsResult {
15+
if (typeof fragment === 'boolean') {
16+
return [0, {}];
17+
}
18+
19+
const extensionKeys = Object.keys(fragment).filter(key => key.startsWith('x-'));
20+
let vendorExtensions = {};
21+
extensionKeys.forEach(key => {
22+
vendorExtensions[key] = fragment[key];
23+
});
24+
25+
return [extensionKeys.length, vendorExtensions];
26+
}

src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
export * from './extractVendorExtensions';
12
export * from './printName';

0 commit comments

Comments
 (0)