From b76157a13fcbbe69aa987033219ab9f1ce185698 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ro=C5=BCek?= Date: Wed, 24 Jan 2024 18:27:45 +0100 Subject: [PATCH] feat: support dictionaries (#249) --- package.json | 2 +- src/__fixtures__/formats-schema.json | 30 +++-- src/__tests__/index.spec.tsx | 117 +++++++++++++++++- src/components/JsonSchemaViewer.tsx | 17 +-- src/components/shared/Format.tsx | 10 -- src/components/shared/Types.tsx | 78 ++++++------ .../shared/__tests__/Format.spec.tsx | 41 ------ .../shared/__tests__/Property.spec.tsx | 76 +++++++++++- src/components/shared/__tests__/utils.ts | 4 +- src/components/shared/index.ts | 1 - src/tree/types.ts | 18 ++- src/tree/utils.ts | 95 ++++++++++++-- src/utils/getApplicableFormats.ts | 21 ++++ src/utils/printName.ts | 55 +++++--- yarn.lock | 8 +- 15 files changed, 408 insertions(+), 165 deletions(-) delete mode 100644 src/components/shared/Format.tsx delete mode 100644 src/components/shared/__tests__/Format.spec.tsx create mode 100644 src/utils/getApplicableFormats.ts diff --git a/package.json b/package.json index dd126106..625353d4 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ }, "dependencies": { "@stoplight/json": "^3.20.1", - "@stoplight/json-schema-tree": "^3.0.0", + "@stoplight/json-schema-tree": "^4.0.0", "@stoplight/react-error-boundary": "^2.0.0", "@types/json-schema": "^7.0.7", "classnames": "^2.2.6", diff --git a/src/__fixtures__/formats-schema.json b/src/__fixtures__/formats-schema.json index 17056a54..194a672d 100644 --- a/src/__fixtures__/formats-schema.json +++ b/src/__fixtures__/formats-schema.json @@ -3,11 +3,7 @@ "type": "object", "properties": { "date-of-birth": { - "type": [ - "number", - "string", - "array" - ], + "type": ["number", "string", "array"], "format": "date-time", "items": {} }, @@ -23,20 +19,28 @@ "format": "int32" }, "size": { - "type": [ - "number", - "string" - ], + "type": ["number", "string"], "format": "byte" }, "notype": { "format": "date-time" }, + "array-of-integers": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + }, + "map-of-ids": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int32" + } + }, "permissions": { - "type": [ - "string", - "object" - ], + "type": ["string", "object"], "format": "password", "properties": { "ids": { diff --git a/src/__tests__/index.spec.tsx b/src/__tests__/index.spec.tsx index 12e2383a..4b86b121 100644 --- a/src/__tests__/index.spec.tsx +++ b/src/__tests__/index.spec.tsx @@ -1,7 +1,7 @@ import 'jest-enzyme'; import { mount, ReactWrapper } from 'enzyme'; -import { JSONSchema4 } from 'json-schema'; +import { JSONSchema4, JSONSchema7 } from 'json-schema'; import * as React from 'react'; import { JsonSchemaViewer } from '../components'; @@ -165,6 +165,121 @@ describe('HTML Output', () => { expect(dumpDom()).toMatchSnapshot(); }); + it('given dictionary with defined properties, should not render them', () => { + const schema: JSONSchema7 = { + type: ['object', 'null'], + properties: { + id: { + type: 'string', + readOnly: true, + }, + description: { + type: 'string', + writeOnly: true, + }, + }, + additionalProperties: { + type: 'string', + }, + }; + + expect(dumpDom()).toMatchInlineSnapshot(` + "
+
+
+
+
+
+
+
+
+ dictionary[string, string] + or + null +
+
+
+
+
+
+
+
+ " + `); + }); + + it('should not render true/false additionalProperties', () => { + const schema: JSONSchema7 = { + type: 'object', + properties: { + id: { + type: 'string', + }, + }, + additionalProperties: true, + }; + + const additionalTrue = dumpDom(); + const additionalFalse = dumpDom( + , + ); + expect(additionalTrue).toEqual(additionalFalse); + expect(additionalTrue).toMatchInlineSnapshot(` + "
+
+
+
+
+
+
+
+
+
id
+ string +
+
+
+
+
+
+
+
+ " + `); + }); + + it('should not render additionalItems', () => { + const schema: JSONSchema7 = { + type: 'array', + additionalItems: { + type: 'object', + properties: { + id: { + type: 'string', + }, + }, + }, + }; + + expect(dumpDom()).toMatchInlineSnapshot(` + "
+
+
+
+
+
+
+
array
+
+
+
+
+
+
+ " + `); + }); + describe('top level descriptions', () => { const schema: JSONSchema4 = { description: 'This is a description that should be rendered', diff --git a/src/components/JsonSchemaViewer.tsx b/src/components/JsonSchemaViewer.tsx index 25553f34..e7f51457 100644 --- a/src/components/JsonSchemaViewer.tsx +++ b/src/components/JsonSchemaViewer.tsx @@ -1,7 +1,6 @@ import { isRegularNode, RootNode, - SchemaNode, SchemaTree as JsonSchemaTree, SchemaTreeRefDereferenceFn, } from '@stoplight/json-schema-tree'; @@ -13,7 +12,8 @@ import { useUpdateAtom } from 'jotai/utils'; import * as React from 'react'; import { JSVOptions, JSVOptionsContextProvider } from '../contexts'; -import type { JSONSchema } from '../types'; +import { shouldNodeBeIncluded } from '../tree/utils'; +import { JSONSchema } from '../types'; import { PathCrumbs } from './PathCrumbs'; import { TopLevelSchemaRow } from './SchemaRow'; import { hoveredNodeAtom } from './SchemaRow/state'; @@ -114,20 +114,9 @@ const JsonSchemaViewerInner = ({ }); let nodeCount = 0; - const shouldNodeBeIncluded = (node: SchemaNode) => { - if (!isRegularNode(node)) return true; - - const { validations } = node; - - if (!!validations.writeOnly === !!validations.readOnly) { - return true; - } - - return !((viewMode === 'read' && !!validations.writeOnly) || (viewMode === 'write' && !!validations.readOnly)); - }; jsonSchemaTree.walker.hookInto('filter', node => { - if (shouldNodeBeIncluded(node)) { + if (shouldNodeBeIncluded(node, viewMode)) { nodeCount++; return true; } diff --git a/src/components/shared/Format.tsx b/src/components/shared/Format.tsx deleted file mode 100644 index bbfbae7c..00000000 --- a/src/components/shared/Format.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { Box } from '@stoplight/mosaic'; -import * as React from 'react'; - -type FormatProps = { - format: string; -}; - -export const Format: React.FunctionComponent = ({ format }) => { - return {`<${format}>`}; -}; diff --git a/src/components/shared/Types.tsx b/src/components/shared/Types.tsx index 3d04273c..8386f447 100644 --- a/src/components/shared/Types.tsx +++ b/src/components/shared/Types.tsx @@ -1,4 +1,5 @@ import { + isBooleanishNode, isReferenceNode, isRegularNode, RegularNode, @@ -9,10 +10,8 @@ import { import { Box } from '@stoplight/mosaic'; import * as React from 'react'; -import { COMMON_JSON_SCHEMA_AND_OAS_FORMATS } from '../../consts'; -import { isPrimitiveArray } from '../../tree'; import { printName } from '../../utils'; -import { Format } from './Format'; +import { getApplicableFormats } from '../../utils/getApplicableFormats'; function shouldRenderName(type: SchemaNodeKind | SchemaCombinerName | '$ref'): boolean { return type === SchemaNodeKind.Array || type === SchemaNodeKind.Object || type === '$ref'; @@ -32,32 +31,6 @@ function getTypes(schemaNode: RegularNode): Array> { - const formats: Partial> = {}; - - if (isPrimitiveArray(schemaNode) && schemaNode.children[0].format !== null) { - formats.array = schemaNode.children[0].format; - } - - if (schemaNode.format === null) { - return formats; - } - - const types = getTypes(schemaNode); - - for (const type of types) { - if (!(type in COMMON_JSON_SCHEMA_AND_OAS_FORMATS)) continue; - - if (COMMON_JSON_SCHEMA_AND_OAS_FORMATS[type].includes(schemaNode.format)) { - formats[type] = schemaNode.format; - return formats; - } - } - - formats.string = schemaNode.format; - return formats; -} - export const Types: React.FunctionComponent<{ schemaNode: SchemaNode }> = ({ schemaNode }) => { if (isReferenceNode(schemaNode)) { return ( @@ -67,32 +40,51 @@ export const Types: React.FunctionComponent<{ schemaNode: SchemaNode }> = ({ sch ); } + if (isBooleanishNode(schemaNode)) { + return ( + + {schemaNode.fragment ? 'any' : 'never'} + + ); + } + if (!isRegularNode(schemaNode)) { return null; } + const formats = getApplicableFormats(schemaNode); const types = getTypes(schemaNode); - const formats = getFormats(schemaNode); if (types.length === 0) { - return formats.string !== void 0 ? : null; - } - - const rendered = types.map((type, i, { length }) => ( - + return ( - {shouldRenderName(type) ? printName(schemaNode) ?? type : type} + {formats === null ? 'any' : `<${formats[1]}>`} + ); + } + + const rendered = types.map((type, i, { length }) => { + let printedName; + if (shouldRenderName(type)) { + printedName = printName(schemaNode); + } - {type in formats ? : null} + printedName ??= type + (formats === null || formats[0] !== type ? '' : `<${formats[1]}>`); - {i < length - 1 && ( - - {' or '} + return ( + + + {printedName} - )} - - )); + + {i < length - 1 && ( + + {' or '} + + )} + + ); + }); return rendered.length > 1 ? {rendered} : <>{rendered}; }; diff --git a/src/components/shared/__tests__/Format.spec.tsx b/src/components/shared/__tests__/Format.spec.tsx deleted file mode 100644 index 21e3f8f2..00000000 --- a/src/components/shared/__tests__/Format.spec.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import 'jest-enzyme'; - -import { mount } from 'enzyme'; -import { JSONSchema4 } from 'json-schema'; -import * as React from 'react'; - -import { SchemaRow } from '../../SchemaRow'; -import { Types } from '../Types'; -import { buildTree, findNodeWithPath } from './utils'; - -describe('Format component', () => { - const schema: JSONSchema4 = require('../../../__fixtures__/formats-schema.json'); - let tree = buildTree(schema); - - it('should render next to a single type', () => { - const wrapper = mount(); - expect(wrapper.find(Types)).toHaveText('number'); - wrapper.unmount(); - }); - - it.each` - property | text - ${'count'} | ${'integer or null'} - ${'date-of-birth'} | ${'number or string or array'} - ${'size'} | ${'number or string'} - `('given $property property, should render next to the appropriate type', ({ property, text }) => { - const wrapper = mount( - , - ); - expect(wrapper.find(Types)).toHaveText(text); - wrapper.unmount(); - }); - - it('should render even when the type(s) is/are missing', () => { - const wrapper = mount( - , - ); - expect(wrapper.find(Types)).toHaveText(''); - wrapper.unmount(); - }); -}); diff --git a/src/components/shared/__tests__/Property.spec.tsx b/src/components/shared/__tests__/Property.spec.tsx index 70836550..1be616f5 100644 --- a/src/components/shared/__tests__/Property.spec.tsx +++ b/src/components/shared/__tests__/Property.spec.tsx @@ -2,7 +2,7 @@ import 'jest-enzyme'; import { Provider as MosaicProvider } from '@stoplight/mosaic'; import { mount, ReactWrapper } from 'enzyme'; -import { JSONSchema4 } from 'json-schema'; +import type { JSONSchema4, JSONSchema7 } from 'json-schema'; import * as React from 'react'; import { SchemaRow, Types } from '../..'; @@ -11,7 +11,7 @@ import { buildTree, findNodeWithPath } from './utils'; describe('Property component', () => { const toUnmount: ReactWrapper[] = []; - function render(schema: JSONSchema4, nodePath?: readonly string[]) { + function render(schema: JSONSchema4 | JSONSchema7, nodePath?: readonly string[]) { const tree = buildTree(schema); const node = nodePath ? findNodeWithPath(tree, nodePath) : tree.children[0]; @@ -37,17 +37,29 @@ describe('Property component', () => { }); it('should render Types with proper type and subtype', () => { - const schema: JSONSchema4 = { + let schema: JSONSchema4 = { type: 'array', items: { type: 'string', }, }; - const wrapper = render(schema); + let wrapper = render(schema); expect(wrapper.find(Types).first().html()).toMatchInlineSnapshot( `"array[string]"`, ); + + schema = { + type: 'object', + additionalProperties: { + type: 'string', + }, + }; + + wrapper = render(schema); + expect(wrapper.find(Types).first().html()).toMatchInlineSnapshot( + `"dictionary[string, string]"`, + ); }); it('should handle nullish items', () => { @@ -73,6 +85,34 @@ describe('Property component', () => { ); }); + it('should display true schemas', () => { + const schema: JSONSchema7 = { + type: 'object', + properties: { + foo: true, + }, + }; + + const wrapper = render(schema, ['properties', 'foo']); + expect(wrapper.find(Types).first().html()).toMatchInlineSnapshot( + `"any"`, + ); + }); + + it('should display false schemas', () => { + const schema: JSONSchema7 = { + type: 'object', + properties: { + foo: false, + }, + }; + + const wrapper = render(schema, ['properties', 'foo']); + expect(wrapper.find(Types).first().html()).toMatchInlineSnapshot( + `"never"`, + ); + }); + describe('properties names', () => { it('given an object, should display the names of its properties', () => { const schema: JSONSchema4 = { @@ -166,7 +206,7 @@ describe('Property component', () => { const wrapper = render(schema); expect(wrapper.html()).toMatchInlineSnapshot( - `"
array[object]
foo
bar
baz
"`, + `"
array[object]
foo
any
bar
any
baz
any
"`, ); }); @@ -303,4 +343,30 @@ describe('Property component', () => { ); }); }); + + describe('format', () => { + const schema: JSONSchema4 = require('../../../__fixtures__/formats-schema.json'); + + it('should render next to a single type', () => { + const wrapper = render(schema, ['properties', 'id']); + expect(wrapper.find(Types)).toHaveText('number'); + }); + + it.each` + property | text + ${'count'} | ${'integer or null'} + ${'date-of-birth'} | ${'number or string or array'} + ${'array-of-integers'} | ${'array[integer]'} + ${'map-of-ids'} | ${'dictionary[string, integer]'} + ${'size'} | ${'number or string'} + `('given $property property, should render next to the appropriate type', ({ property, text }) => { + const wrapper = render(schema, ['properties', property]); + expect(wrapper.find(Types)).toHaveText(text); + }); + + it('should render even when the type(s) is/are missing', () => { + const wrapper = render(schema, ['properties', 'notype']); + expect(wrapper.find(Types)).toHaveText(''); + }); + }); }); diff --git a/src/components/shared/__tests__/utils.ts b/src/components/shared/__tests__/utils.ts index 6c05485e..116be1ef 100644 --- a/src/components/shared/__tests__/utils.ts +++ b/src/components/shared/__tests__/utils.ts @@ -5,10 +5,10 @@ import { SchemaTree as JsonSchemaTree, SchemaTreeOptions, } from '@stoplight/json-schema-tree'; -import { JSONSchema4 } from 'json-schema'; +import type { JSONSchema4, JSONSchema7 } from 'json-schema'; import { isEqual } from 'lodash'; -export function buildTree(schema: JSONSchema4, options: Partial = {}) { +export function buildTree(schema: JSONSchema4 | JSONSchema7, options: Partial = {}) { const jsonSchemaTree = new JsonSchemaTree(schema, { mergeAllOf: true, ...options, diff --git a/src/components/shared/index.ts b/src/components/shared/index.ts index 676fbd43..8c7e23f6 100644 --- a/src/components/shared/index.ts +++ b/src/components/shared/index.ts @@ -1,5 +1,4 @@ export * from './Caret'; export * from './Description'; -export * from './Format'; export * from './Types'; export * from './Validations'; diff --git a/src/tree/types.ts b/src/tree/types.ts index 3cf9e3b0..36da8343 100644 --- a/src/tree/types.ts +++ b/src/tree/types.ts @@ -24,16 +24,32 @@ export type ArrayNode = RegularNode & { primaryType: SchemaNodeKind.Array; }; +export type DictionaryNode = RegularNode & { + primaryType: SchemaNodeKind.Object; + fragment: { + additionalProperties: Record; + [key: string]: unknown; + }; +}; + export type PrimitiveArrayNode = ArrayNode & { children: [RegularNode & { simple: true }]; }; +export type PrimitiveDictionaryNode = DictionaryNode & { + children: [RegularNode & { simple: true }]; +}; + export type ComplexArrayNode = ArrayNode & { children: [RegularNode & { simple: false }]; }; +export type ComplexDictionaryNode = DictionaryNode & { + children: [RegularNode & { simple: false }]; +}; + export type BrokenRefArrayNode = ArrayNode & { children: [ReferenceNode & { error: string }]; }; -export type FlattenableNode = PrimitiveArrayNode | ComplexArrayNode | BrokenRefArrayNode; +export type FlattenableNode = PrimitiveArrayNode | PrimitiveDictionaryNode | ComplexArrayNode | BrokenRefArrayNode; diff --git a/src/tree/utils.ts b/src/tree/utils.ts index ea0d2e78..3394a4dd 100644 --- a/src/tree/utils.ts +++ b/src/tree/utils.ts @@ -1,15 +1,28 @@ +import { isPlainObject } from '@stoplight/json'; import { + type MirroredSchemaNode, + type ReferenceNode, + type RegularNode, + type SchemaNode, + isBooleanishNode, isReferenceNode, isRegularNode, - MirroredSchemaNode, - ReferenceNode, - RegularNode, - SchemaNode, + isRootNode, SchemaNodeKind, } from '@stoplight/json-schema-tree'; +import { BooleanishNode } from '@stoplight/json-schema-tree/nodes/BooleanishNode'; import { isNonNullable } from '../guards/isNonNullable'; -import { ComplexArrayNode, FlattenableNode, PrimitiveArrayNode } from './types'; +import type { ViewMode } from '../types'; +import type { + ArrayNode, + ComplexArrayNode, + ComplexDictionaryNode, + DictionaryNode, + FlattenableNode, + PrimitiveArrayNode, + PrimitiveDictionaryNode, +} from './types'; export type ChildNode = RegularNode | ReferenceNode | MirroredSchemaNode; @@ -22,7 +35,7 @@ export const isNonEmptyParentNode = ( export function isFlattenableNode(node: SchemaNode): node is FlattenableNode { if (!isRegularNode(node)) return false; - if (node.primaryType !== SchemaNodeKind.Array || !isNonNullable(node.children) || node.children.length === 0) { + if ((!isArrayNode(node) && !isDictionaryNode(node)) || !isNonNullable(node.children) || node.children.length === 0) { return false; } @@ -33,11 +46,35 @@ export function isFlattenableNode(node: SchemaNode): node is FlattenableNode { } export function isPrimitiveArray(node: SchemaNode): node is PrimitiveArrayNode { - return isFlattenableNode(node) && isRegularNode(node.children[0]) && node.children[0].simple; + return isFlattenableNode(node) && isArrayNode(node) && isRegularNode(node.children[0]) && node.children[0].simple; +} + +export function isPrimitiveDictionary(node: SchemaNode): node is PrimitiveDictionaryNode { + return ( + isFlattenableNode(node) && isDictionaryNode(node) && isRegularNode(node.children[0]) && node.children[0].simple + ); } export function isComplexArray(node: SchemaNode): node is ComplexArrayNode { - return isFlattenableNode(node) && isRegularNode(node.children[0]) && !node.children[0].simple; + return isFlattenableNode(node) && isArrayNode(node) && isRegularNode(node.children[0]) && !node.children[0].simple; +} + +export function isComplexDictionary(node: SchemaNode): node is ComplexDictionaryNode { + return ( + isFlattenableNode(node) && isDictionaryNode(node) && isRegularNode(node.children[0]) && !node.children[0].simple + ); +} + +export function isDictionaryNode(node: SchemaNode): node is DictionaryNode { + return ( + isRegularNode(node) && + node.primaryType === SchemaNodeKind.Object && + isPlainObject(node.fragment.additionalProperties) + ); +} + +export function isArrayNode(node: SchemaNode): node is ArrayNode { + return isRegularNode(node) && node.primaryType === SchemaNodeKind.Array; } /** @@ -46,10 +83,10 @@ export function isComplexArray(node: SchemaNode): node is ComplexArrayNode { * handling (flattening). */ export function visibleChildren(node: SchemaNode): SchemaNode[] { - if (!isRegularNode(node) || isPrimitiveArray(node)) { + if (!isRegularNode(node) || isPrimitiveArray(node) || isPrimitiveDictionary(node)) { return []; } - if (isComplexArray(node)) { + if (isComplexArray(node) || isComplexDictionary(node)) { // flatten the tree here, and show the properties of the item type directly return node.children[0].children ?? []; } @@ -64,3 +101,41 @@ export function isPropertyRequired(schemaNode: SchemaNode): boolean { return !!parent.required?.includes(schemaNode.subpath[schemaNode.subpath.length - 1]); } + +function isValidViewMode(node: RegularNode, viewMode: ViewMode): boolean { + const { validations } = node; + + if (!!validations.writeOnly === !!validations.readOnly) { + return true; + } + + return !((viewMode === 'read' && !!validations.writeOnly) || (viewMode === 'write' && !!validations.readOnly)); +} + +function isRenderableNode(node: BooleanishNode | RegularNode): boolean { + if (node.parent === null) return true; + + if (isDictionaryNode(node.parent)) { + // if dictionary, do not render explicitly defined properties + return node.subpath.length !== 2 || node.subpath[0] !== 'properties'; + } + + // do not render additionalItems + if (isArrayNode(node.parent)) { + return node.subpath[0] !== 'additionalItems'; + } + + // do not render true/false additionalProperties + if (isRegularNode(node.parent) && node.parent.primaryType === SchemaNodeKind.Object && isBooleanishNode(node)) { + return !(node.subpath.length === 1 || node.subpath[0] === 'additionalProperties'); + } + + return true; +} + +export function shouldNodeBeIncluded(node: SchemaNode, viewMode: ViewMode = 'standalone'): boolean { + return ( + (isReferenceNode(node) || isRootNode(node) || isRenderableNode(node)) && + (!isRegularNode(node) || isValidViewMode(node, viewMode)) + ); +} diff --git a/src/utils/getApplicableFormats.ts b/src/utils/getApplicableFormats.ts new file mode 100644 index 00000000..a4d49e92 --- /dev/null +++ b/src/utils/getApplicableFormats.ts @@ -0,0 +1,21 @@ +import { RegularNode, SchemaNodeKind } from '@stoplight/json-schema-tree'; + +import { COMMON_JSON_SCHEMA_AND_OAS_FORMATS } from '../consts'; + +export function getApplicableFormats(schemaNode: RegularNode): [type: SchemaNodeKind, format: string] | null { + if (schemaNode.format === null) { + return null; + } + + if (schemaNode.types !== null) { + for (const type of schemaNode.types) { + if (!(type in COMMON_JSON_SCHEMA_AND_OAS_FORMATS)) continue; + + if (COMMON_JSON_SCHEMA_AND_OAS_FORMATS[type].includes(schemaNode.format)) { + return [type, schemaNode.format]; + } + } + } + + return [SchemaNodeKind.String, schemaNode.format]; +} diff --git a/src/utils/printName.ts b/src/utils/printName.ts index 401fb5b8..57e932a0 100644 --- a/src/utils/printName.ts +++ b/src/utils/printName.ts @@ -3,7 +3,15 @@ import { isReferenceNode, isRegularNode, RegularNode, SchemaNodeKind } from '@st import upperFirst from 'lodash/upperFirst.js'; import { isNonNullable } from '../guards/isNonNullable'; -import { isComplexArray, isPrimitiveArray } from '../tree'; +import { + isComplexArray, + isComplexDictionary, + isDictionaryNode, + isFlattenableNode, + isPrimitiveArray, + isPrimitiveDictionary, +} from '../tree'; +import { getApplicableFormats } from './getApplicableFormats'; type PrintNameOptions = { shouldUseRefNameFallback?: boolean; @@ -13,18 +21,14 @@ export function printName( schemaNode: RegularNode, { shouldUseRefNameFallback = false }: PrintNameOptions = {}, ): string | undefined { - if ( - schemaNode.primaryType !== SchemaNodeKind.Array || - !isNonNullable(schemaNode.children) || - schemaNode.children.length === 0 - ) { + if (!isFlattenableNode(schemaNode)) { return schemaNode.title ?? (shouldUseRefNameFallback ? getNodeNameFromOriginalRef(schemaNode) : undefined); } - return printArrayName(schemaNode, { shouldUseRefNameFallback }); + return printFlattenedName(schemaNode, { shouldUseRefNameFallback }); } -function printArrayName( +function printFlattenedName( schemaNode: RegularNode, { shouldUseRefNameFallback = false }: PrintNameOptions, ): string | undefined { @@ -33,41 +37,54 @@ function printArrayName( } if (schemaNode.children.length === 1 && isReferenceNode(schemaNode.children[0])) { - return `$ref(${schemaNode.children[0].value})[]`; + const value = `$ref(${schemaNode.children[0].value})`; + return isDictionaryNode(schemaNode) ? `dictionary[string, ${value}]` : `${value}[]`; } - if (isPrimitiveArray(schemaNode)) { + const format = isDictionaryNode(schemaNode) ? 'dictionary[string, %s]' : 'array[%s]'; + + if (isPrimitiveArray(schemaNode) || isPrimitiveDictionary(schemaNode)) { const val = - schemaNode.children?.reduce((mergedTypes, child) => { + schemaNode.children?.reduce<(SchemaNodeKind | `${SchemaNodeKind}<${string}>`)[] | null>((mergedTypes, child) => { if (mergedTypes === null) return null; if (!isRegularNode(child)) return null; if (child.types !== null && child.types.length > 0) { + const formats = getApplicableFormats(child); for (const type of child.types) { if (mergedTypes.includes(type)) continue; - mergedTypes.push(type); + + if (formats !== null && formats[0] === type) { + mergedTypes.push(`${type}<${formats[1]}>`); + } else { + mergedTypes.push(type); + } } } return mergedTypes; }, []) ?? null; - return val !== null && val.length > 0 ? `array[${val.join(' or ')}]` : 'array'; + if (val !== null && val.length > 0) { + return format.replace('%s', val.join(' or ')); + } + + return isDictionaryNode(schemaNode) ? 'dictionary[string, any]' : 'array'; } - if (isComplexArray(schemaNode)) { + if (isComplexArray(schemaNode) || isComplexDictionary(schemaNode)) { const firstChild = schemaNode.children[0]; if (firstChild.title) { - return `array[${firstChild.title}]`; + return format.replace('%s', firstChild.title); } else if (shouldUseRefNameFallback && getNodeNameFromOriginalRef(schemaNode)) { - return `array[${getNodeNameFromOriginalRef(schemaNode)}]`; + return format.replace('%s', getNodeNameFromOriginalRef(schemaNode) ?? 'any'); } else if (firstChild.primaryType) { - return `array[${firstChild.primaryType}]`; + return format.replace('%s', firstChild.primaryType); } else if (firstChild.combiners?.length) { - return `array[${firstChild.combiners.join('/')}]`; + return format.replace('%s', firstChild.combiners.join(' ')); } - return 'array'; + return isComplexArray(schemaNode) ? 'array' : format.replace('%s', 'any'); } return undefined; diff --git a/yarn.lock b/yarn.lock index 9db3d6c4..89e88e29 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2392,10 +2392,10 @@ json-schema-compare "^0.2.2" lodash "^4.17.4" -"@stoplight/json-schema-tree@^3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@stoplight/json-schema-tree/-/json-schema-tree-3.0.0.tgz#967cecda59b0dd8efa2275e580d5a8c71868cc3c" - integrity sha512-lxALWJtl7ev+iTNbW+QHDr66+nmspwrDyVEhNSIjWjp8Vd6+qYxqk3r9zr8Ktfrx4pREfG7iTq6rwNSxYdP+Nw== +"@stoplight/json-schema-tree@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@stoplight/json-schema-tree/-/json-schema-tree-4.0.0.tgz#402790a21e076d69c1b8f5dded0bbe158134ab77" + integrity sha512-SAGtof+ihIdPqETR+7XXOaqZJcrbSih/xEahaw5t1nXk5sVW6ss2l5A1WCIuvtvnQiUKnBfanmZU4eoM1ZvItg== dependencies: "@stoplight/json" "^3.12.0" "@stoplight/json-schema-merge-allof" "^0.8.0"