From 291b62a206b68f8b4d98e4b74b71c0cad20a8b9b Mon Sep 17 00:00:00 2001 From: Alex Varchuk Date: Tue, 17 May 2022 10:56:28 +0300 Subject: [PATCH] feat: support conditional operators (#1939) * fix: merge type and enum in allOf for 3.1 * chore: add example for OpenApi 3.1 * fix: correct merge constraints in allOf --- demo/openapi-3-1.yaml | 59 +++++++++- src/components/Fields/Field.tsx | 3 +- src/components/Fields/FieldDetails.tsx | 5 +- src/components/JsonViewer/JsonViewer.tsx | 10 +- src/services/OpenAPIParser.ts | 56 +++++---- .../fixtures/3.1/conditionalField.json | 40 +++++++ .../fixtures/3.1/conditionalSchema.json | 40 +++++++ src/services/__tests__/models/Schema.test.ts | 26 +++++ .../models/__snapshots__/Schema.test.ts.snap | 107 ++++++++++++++++++ src/services/models/Schema.ts | 37 ++++++ src/types/open-api.ts | 4 + .../loadAndBundleSpec.test.ts.snap | 93 ++++++++++++++- src/utils/__tests__/openapi.test.ts | 2 +- src/utils/openapi.ts | 4 + 14 files changed, 449 insertions(+), 37 deletions(-) create mode 100644 src/services/__tests__/fixtures/3.1/conditionalField.json create mode 100644 src/services/__tests__/fixtures/3.1/conditionalSchema.json create mode 100644 src/services/__tests__/models/__snapshots__/Schema.test.ts.snap diff --git a/demo/openapi-3-1.yaml b/demo/openapi-3-1.yaml index 484fd33d..9c8d0f07 100644 --- a/demo/openapi-3-1.yaml +++ b/demo/openapi-3-1.yaml @@ -960,6 +960,33 @@ components: schemas: ApiResponse: type: object + patternProperties: + ^S_\\w+\\.[1-9]{2,4}$: + description: The measured skill for hunting + if: + x-displayName: fieldName === 'status' + else: + minLength: 1 + maxLength: 10 + then: + format: url + type: string + enum: + - success + - failed + ^O_\\w+\\.[1-9]{2,4}$: + type: object + properties: + nestedProperty: + type: [string, boolean] + description: The measured skill for hunting + default: lazy + example: adventurous + enum: + - clueless + - lazy + - adventurous + - aggressive properties: code: type: integer @@ -975,7 +1002,7 @@ components: - type: object properties: huntingSkill: - type: string + type: [string, boolean] description: The measured skill for hunting default: lazy example: adventurous @@ -1099,15 +1126,26 @@ components: example: Guru photoUrls: description: The list of URL to a cute photos featuring pet - type: [string, integer, 'null', array] + type: [string, integer, 'null'] minItems: 1 - maxItems: 20 + maxItems: 10 xml: name: photoUrl wrapped: true items: type: string format: url + if: + x-displayName: isString + type: string + then: + minItems: 1 + maxItems: 15 + else: + x-displayName: notString + type: [integer, 'null'] + minItems: 1 + maxItems: 20 friend: $ref: '#/components/schemas/Pet' tags: @@ -1131,6 +1169,12 @@ components: petType: description: Type of a pet type: string + huntingSkill: + type: [integer] + enum: + - 0 + - 1 + - 2 xml: name: Pet Tag: @@ -1198,6 +1242,15 @@ components: type: string contentEncoding: base64 contentMediaType: image/png + if: + title: userStatus === 10 + properties: + userStatus: + enum: [10] + then: + required: ['phone'] + else: + required: [] xml: name: User requestBodies: diff --git a/src/components/Fields/Field.tsx b/src/components/Fields/Field.tsx index 0f0d1226..5c35b04b 100644 --- a/src/components/Fields/Field.tsx +++ b/src/components/Fields/Field.tsx @@ -16,6 +16,7 @@ import { } from '../../common-elements/fields-layout'; import { ShelfIcon } from '../../common-elements/'; import { Schema } from '../Schema/Schema'; + import type { SchemaOptions } from '../Schema/Schema'; import type { FieldModel } from '../../services/models'; @@ -48,7 +49,7 @@ export class Field extends React.Component { }; render() { - const { className, field, isLast, expandByDefault } = this.props; + const { className = '', field, isLast, expandByDefault } = this.props; const { name, deprecated, required, kind } = field; const withSubSchema = !field.schema.isPrimitive && !field.schema.isCircular; diff --git a/src/components/Fields/FieldDetails.tsx b/src/components/Fields/FieldDetails.tsx index c04a5b0f..36a43d3e 100644 --- a/src/components/Fields/FieldDetails.tsx +++ b/src/components/Fields/FieldDetails.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { observer } from 'mobx-react'; import { RecursiveLabel, @@ -24,7 +25,7 @@ import { OptionsContext } from '../OptionsProvider'; import { Pattern } from './Pattern'; import { ArrayItemDetails } from './ArrayItemDetails'; -function FieldDetailsComponent(props: FieldProps) { +export const FieldDetailsComponent = observer((props: FieldProps) => { const { enumSkipQuotes, hideSchemaTitles } = React.useContext(OptionsContext); const { showExamples, field, renderDiscriminatorSwitch } = props; @@ -107,6 +108,6 @@ function FieldDetailsComponent(props: FieldProps) { {(_const && ) || null} ); -} +}); export const FieldDetails = React.memo(FieldDetailsComponent); diff --git a/src/components/JsonViewer/JsonViewer.tsx b/src/components/JsonViewer/JsonViewer.tsx index 8464765f..7bf03de9 100644 --- a/src/components/JsonViewer/JsonViewer.tsx +++ b/src/components/JsonViewer/JsonViewer.tsx @@ -27,20 +27,20 @@ class Json extends React.PureComponent { } renderInner = ({ renderCopyButton }) => { - const showFoldingButtons = this.props.data && Object.values(this.props.data).some( - (value) => typeof value === 'object' && value !== null, - ); + const showFoldingButtons = + this.props.data && + Object.values(this.props.data).some(value => typeof value === 'object' && value !== null); return ( {renderCopyButton()} - {showFoldingButtons && + {showFoldingButtons && ( <> - } + )} {options => ( diff --git a/src/services/OpenAPIParser.ts b/src/services/OpenAPIParser.ts index 66536997..6cf3604d 100644 --- a/src/services/OpenAPIParser.ts +++ b/src/services/OpenAPIParser.ts @@ -268,29 +268,44 @@ export class OpenAPIParser { }>; for (const { $ref: subSchemaRef, schema: subSchema } of allOfSchemas) { - if ( - receiver.type !== subSchema.type && - receiver.type !== undefined && - subSchema.type !== undefined - ) { - console.warn( - `Incompatible types in allOf at "${$ref}": "${receiver.type}" and "${subSchema.type}"`, - ); + const { + type, + enum: enumProperty, + properties, + items, + required, + ...otherConstraints + } = subSchema; + + if (receiver.type !== type && receiver.type !== undefined && type !== undefined) { + console.warn(`Incompatible types in allOf at "${$ref}": "${receiver.type}" and "${type}"`); } - if (subSchema.type !== undefined) { - receiver.type = subSchema.type; + if (type !== undefined) { + if (Array.isArray(type) && Array.isArray(receiver.type)) { + receiver.type = [...type, ...receiver.type]; + } else { + receiver.type = type; + } } - if (subSchema.properties !== undefined) { + if (enumProperty !== undefined) { + if (Array.isArray(enumProperty) && Array.isArray(receiver.enum)) { + receiver.enum = [...enumProperty, ...receiver.enum]; + } else { + receiver.enum = enumProperty; + } + } + + if (properties !== undefined) { receiver.properties = receiver.properties || {}; - for (const prop in subSchema.properties) { + for (const prop in properties) { if (!receiver.properties[prop]) { - receiver.properties[prop] = subSchema.properties[prop]; + receiver.properties[prop] = properties[prop]; } else { // merge inner properties const mergedProp = this.mergeAllOf( - { allOf: [receiver.properties[prop], subSchema.properties[prop]] }, + { allOf: [receiver.properties[prop], properties[prop]] }, $ref + '/properties/' + prop, ); receiver.properties[prop] = mergedProp; @@ -299,22 +314,19 @@ export class OpenAPIParser { } } - if (subSchema.items !== undefined) { + if (items !== undefined) { receiver.items = receiver.items || {}; // merge inner properties - receiver.items = this.mergeAllOf( - { allOf: [receiver.items, subSchema.items] }, - $ref + '/items', - ); + receiver.items = this.mergeAllOf({ allOf: [receiver.items, items] }, $ref + '/items'); } - if (subSchema.required !== undefined) { - receiver.required = (receiver.required || []).concat(subSchema.required); + if (required !== undefined) { + receiver.required = (receiver.required || []).concat(required); } // merge rest of constraints // TODO: do more intelligent merge - receiver = { ...subSchema, ...receiver }; + receiver = { ...receiver, ...otherConstraints }; if (subSchemaRef) { receiver.parentRefs!.push(subSchemaRef); diff --git a/src/services/__tests__/fixtures/3.1/conditionalField.json b/src/services/__tests__/fixtures/3.1/conditionalField.json new file mode 100644 index 00000000..3c010f5d --- /dev/null +++ b/src/services/__tests__/fixtures/3.1/conditionalField.json @@ -0,0 +1,40 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Schema definition field with conditional operators", + "version": "1.0.0" + }, + "components": { + "schemas": { + "Test": { + "type": "object", + "properties": { + "test": { + "type": ["string", "integer", "null"], + "minItems": 1, + "maxItems": 20, + "items": { + "type": "string", + "format": "url" + }, + "if": { + "x-displayName": "isString", + "type": "string" + }, + "then": { + "type": "string", + "minItems": 1, + "maxItems": 20 + }, + "else": { + "x-displayName": "notString", + "minItems": 1, + "maxItems": 10, + "pattern": "\\d+" + } + } + } + } + } + } +} diff --git a/src/services/__tests__/fixtures/3.1/conditionalSchema.json b/src/services/__tests__/fixtures/3.1/conditionalSchema.json new file mode 100644 index 00000000..452747ae --- /dev/null +++ b/src/services/__tests__/fixtures/3.1/conditionalSchema.json @@ -0,0 +1,40 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Schema definition with conditional operators", + "version": "1.0.0" + }, + "components": { + "schemas": { + "Test": { + "type": "object", + "properties": { + "test": { + "description": "The list of URL to a cute photos featuring pet", + "type": ["string", "integer", "null"], + "minItems": 1, + "maxItems": 20, + "items": { + "type": "string", + "format": "url" + } + } + }, + "if": { + "title": "=== 10", + "properties": { + "test": { + "enum": [10] + } + } + }, + "then": { + "maxItems": 2 + }, + "else": { + "maxItems": 20 + } + } + } + } +} diff --git a/src/services/__tests__/models/Schema.test.ts b/src/services/__tests__/models/Schema.test.ts index b4e94d95..d6eb6738 100644 --- a/src/services/__tests__/models/Schema.test.ts +++ b/src/services/__tests__/models/Schema.test.ts @@ -49,6 +49,32 @@ describe('Models', () => { expect(schema.pointer).toBe('#/components/schemas/Child'); }); + test('schemaDefinition should resolve schema with conditional operators', () => { + const spec = require('../fixtures/3.1/conditionalSchema.json'); + parser = new OpenAPIParser(spec, undefined, opts); + const schema = new SchemaModel(parser, spec.components.schemas.Test, '', opts); + expect(schema.oneOf).toHaveLength(2); + + expect(schema.oneOf![0].schema.title).toBe('=== 10'); + expect(schema.oneOf![1].schema.title).toBe('case 2'); + + expect(schema.oneOf![0].schema).toMatchSnapshot(); + expect(schema.oneOf![1].schema).toMatchSnapshot(); + }); + + test('schemaDefinition should resolve field with conditional operators', () => { + const spec = require('../fixtures/3.1/conditionalField.json'); + parser = new OpenAPIParser(spec, undefined, opts); + const schema = new SchemaModel(parser, spec.components.schemas.Test, '', opts); + expect(schema.fields).toHaveLength(1); + expect(schema.fields && schema.fields[0].schema.oneOf).toHaveLength(2); + expect(schema.fields && schema.fields[0].schema.oneOf![0].schema.title).toBe('isString'); + expect(schema.fields && schema.fields[0].schema.oneOf![1].schema.title).toBe('notString'); + + expect(schema.fields && schema.fields[0].schema.oneOf![0].schema).toMatchSnapshot(); + expect(schema.fields && schema.fields[0].schema.oneOf![1].schema).toMatchSnapshot(); + }); + test('schemaDefinition should resolve unevaluatedProperties in properties', () => { const spec = require('../fixtures/3.1/unevaluatedProperties.json'); parser = new OpenAPIParser(spec, undefined, opts); diff --git a/src/services/__tests__/models/__snapshots__/Schema.test.ts.snap b/src/services/__tests__/models/__snapshots__/Schema.test.ts.snap new file mode 100644 index 00000000..b0f6c73e --- /dev/null +++ b/src/services/__tests__/models/__snapshots__/Schema.test.ts.snap @@ -0,0 +1,107 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Models Schema schemaDefinition should resolve field with conditional operators 1`] = ` +Object { + "allOf": undefined, + "default": undefined, + "items": Object { + "allOf": undefined, + "format": "url", + "parentRefs": Array [], + "title": undefined, + "type": "string", + }, + "maxItems": 20, + "minItems": 1, + "parentRefs": Array [], + "title": "isString", + "type": "string", + "x-displayName": "isString", +} +`; + +exports[`Models Schema schemaDefinition should resolve field with conditional operators 2`] = ` +Object { + "allOf": undefined, + "default": undefined, + "items": Object { + "allOf": undefined, + "format": "url", + "parentRefs": Array [], + "title": undefined, + "type": "string", + }, + "maxItems": 10, + "minItems": 1, + "parentRefs": Array [], + "pattern": "\\\\d+", + "title": "notString", + "type": Array [ + "string", + "integer", + "null", + ], + "x-displayName": "notString", +} +`; + +exports[`Models Schema schemaDefinition should resolve schema with conditional operators 1`] = ` +Object { + "allOf": undefined, + "maxItems": 2, + "parentRefs": Array [], + "properties": Object { + "test": Object { + "allOf": undefined, + "description": "The list of URL to a cute photos featuring pet", + "enum": Array [ + 10, + ], + "items": Object { + "allOf": undefined, + "format": "url", + "parentRefs": Array [], + "title": undefined, + "type": "string", + }, + "maxItems": 20, + "minItems": 1, + "parentRefs": Array [], + "title": undefined, + "type": Array [ + "string", + "integer", + "null", + ], + }, + }, + "title": "=== 10", + "type": "object", +} +`; + +exports[`Models Schema schemaDefinition should resolve schema with conditional operators 2`] = ` +Object { + "allOf": undefined, + "maxItems": 20, + "parentRefs": Array [], + "properties": Object { + "test": Object { + "description": "The list of URL to a cute photos featuring pet", + "items": Object { + "format": "url", + "type": "string", + }, + "maxItems": 20, + "minItems": 1, + "type": Array [ + "string", + "integer", + "null", + ], + }, + }, + "title": "case 2", + "type": "object", +} +`; diff --git a/src/services/models/Schema.ts b/src/services/models/Schema.ts index 0b1f233f..71a3a480 100644 --- a/src/services/models/Schema.ts +++ b/src/services/models/Schema.ts @@ -152,6 +152,11 @@ export class SchemaModel { return; } + if ((schema.if && schema.then) || (schema.if && schema.else)) { + this.initConditionalOperators(schema, parser); + return; + } + if (!isChild && getDiscriminator(schema) !== undefined) { this.initDiscriminator(schema, parser); return; @@ -355,6 +360,38 @@ export class SchemaModel { return innerSchema; }); } + + private initConditionalOperators(schema: OpenAPISchema, parser: OpenAPIParser) { + const { + if: ifOperator, + else: elseOperator = {}, + then: thenOperator = {}, + ...restSchema + } = schema; + const groupedOperators = [ + { + allOf: [restSchema, thenOperator, ifOperator], + title: (ifOperator && ifOperator['x-displayName']) || ifOperator?.title || 'case 1', + }, + { + allOf: [restSchema, elseOperator], + title: (elseOperator && elseOperator['x-displayName']) || elseOperator?.title || 'case 2', + }, + ]; + + this.oneOf = groupedOperators.map( + (variant, idx) => + new SchemaModel( + parser, + { + ...variant, + } as OpenAPISchema, + this.pointer + '/oneOf/' + idx, + this.options, + ), + ); + this.oneOfType = 'One of'; + } } function buildFields( diff --git a/src/types/open-api.ts b/src/types/open-api.ts index 41a65916..60fdca26 100644 --- a/src/types/open-api.ts +++ b/src/types/open-api.ts @@ -148,6 +148,10 @@ export interface OpenAPISchema { minProperties?: number; enum?: any[]; example?: any; + + if?: OpenAPISchema; + else?: OpenAPISchema; + then?: OpenAPISchema; examples?: any[]; const?: string; contentEncoding?: string; diff --git a/src/utils/__tests__/__snapshots__/loadAndBundleSpec.test.ts.snap b/src/utils/__tests__/__snapshots__/loadAndBundleSpec.test.ts.snap index 8459b133..a2c8c189 100644 --- a/src/utils/__tests__/__snapshots__/loadAndBundleSpec.test.ts.snap +++ b/src/utils/__tests__/__snapshots__/loadAndBundleSpec.test.ts.snap @@ -1903,6 +1903,46 @@ Object { }, "schemas": Object { "ApiResponse": Object { + "patternProperties": Object { + "^O_\\\\\\\\w+\\\\\\\\.[1-9]{2,4}$": Object { + "properties": Object { + "nestedProperty": Object { + "default": "lazy", + "description": "The measured skill for hunting", + "enum": Array [ + "clueless", + "lazy", + "adventurous", + "aggressive", + ], + "example": "adventurous", + "type": Array [ + "string", + "boolean", + ], + }, + }, + "type": "object", + }, + "^S_\\\\\\\\w+\\\\\\\\.[1-9]{2,4}$": Object { + "description": "The measured skill for hunting", + "else": Object { + "maxLength": 10, + "minLength": 1, + }, + "if": Object { + "x-displayName": "fieldName === 'status'", + }, + "then": Object { + "enum": Array [ + "success", + "failed", + ], + "format": "url", + "type": "string", + }, + }, + }, "properties": Object { "code": Object { "format": "int32", @@ -1934,7 +1974,10 @@ Object { "aggressive", ], "example": "adventurous", - "type": "string", + "type": Array [ + "string", + "boolean", + ], }, }, "required": Array [ @@ -2086,6 +2129,16 @@ Object { "friend": Object { "$ref": "#/components/schemas/Pet", }, + "huntingSkill": Object { + "enum": Array [ + 0, + 1, + 2, + ], + "type": Array [ + "integer", + ], + }, "id": Object { "$ref": "#/components/schemas/Id", "description": "Pet ID", @@ -2105,17 +2158,33 @@ Object { }, "photoUrls": Object { "description": "The list of URL to a cute photos featuring pet", + "else": Object { + "maxItems": 20, + "minItems": 1, + "type": Array [ + "integer", + "null", + ], + "x-displayName": "notString", + }, + "if": Object { + "type": "string", + "x-displayName": "isString", + }, "items": Object { "format": "url", "type": "string", }, - "maxItems": 20, + "maxItems": 10, "minItems": 1, + "then": Object { + "maxItems": 15, + "minItems": 1, + }, "type": Array [ "string", "integer", "null", - "array", ], "xml": Object { "name": "photoUrl", @@ -2173,6 +2242,19 @@ Object { }, }, "User": Object { + "else": Object { + "required": Array [], + }, + "if": Object { + "properties": Object { + "userStatus": Object { + "enum": Array [ + 10, + ], + }, + }, + "title": "userStatus === 10", + }, "properties": Object { "email": Object { "description": "User email address", @@ -2238,6 +2320,11 @@ Object { "type": "string", }, }, + "then": Object { + "required": Array [ + "phone", + ], + }, "type": "object", "xml": Object { "name": "User", diff --git a/src/utils/__tests__/openapi.test.ts b/src/utils/__tests__/openapi.test.ts index e7d73b51..653a9118 100644 --- a/src/utils/__tests__/openapi.test.ts +++ b/src/utils/__tests__/openapi.test.ts @@ -277,7 +277,7 @@ describe('Utils', () => { expect(isPrimitiveType(schema)).toEqual(true); }); - it('Should return false for array of string which include the null value', () => { + it('Should return true for array of string which include the null value', () => { const schema = { type: ['object', 'string', 'null'], }; diff --git a/src/utils/openapi.ts b/src/utils/openapi.ts index 0540cecc..accf169f 100644 --- a/src/utils/openapi.ts +++ b/src/utils/openapi.ts @@ -125,6 +125,10 @@ export function isPrimitiveType( return false; } + if ((schema.if && schema.then) || (schema.if && schema.else)) { + return false; + } + let isPrimitive = true; const isArrayType = isArray(type);