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
This commit is contained in:
Alex Varchuk 2022-05-17 10:56:28 +03:00 committed by GitHub
parent ddcc76b5fa
commit 291b62a206
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 449 additions and 37 deletions

View File

@ -960,6 +960,33 @@ components:
schemas: schemas:
ApiResponse: ApiResponse:
type: object 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: properties:
code: code:
type: integer type: integer
@ -975,7 +1002,7 @@ components:
- type: object - type: object
properties: properties:
huntingSkill: huntingSkill:
type: string type: [string, boolean]
description: The measured skill for hunting description: The measured skill for hunting
default: lazy default: lazy
example: adventurous example: adventurous
@ -1099,15 +1126,26 @@ components:
example: Guru example: Guru
photoUrls: photoUrls:
description: The list of URL to a cute photos featuring pet description: The list of URL to a cute photos featuring pet
type: [string, integer, 'null', array] type: [string, integer, 'null']
minItems: 1 minItems: 1
maxItems: 20 maxItems: 10
xml: xml:
name: photoUrl name: photoUrl
wrapped: true wrapped: true
items: items:
type: string type: string
format: url 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: friend:
$ref: '#/components/schemas/Pet' $ref: '#/components/schemas/Pet'
tags: tags:
@ -1131,6 +1169,12 @@ components:
petType: petType:
description: Type of a pet description: Type of a pet
type: string type: string
huntingSkill:
type: [integer]
enum:
- 0
- 1
- 2
xml: xml:
name: Pet name: Pet
Tag: Tag:
@ -1198,6 +1242,15 @@ components:
type: string type: string
contentEncoding: base64 contentEncoding: base64
contentMediaType: image/png contentMediaType: image/png
if:
title: userStatus === 10
properties:
userStatus:
enum: [10]
then:
required: ['phone']
else:
required: []
xml: xml:
name: User name: User
requestBodies: requestBodies:

View File

@ -16,6 +16,7 @@ import {
} from '../../common-elements/fields-layout'; } from '../../common-elements/fields-layout';
import { ShelfIcon } from '../../common-elements/'; import { ShelfIcon } from '../../common-elements/';
import { Schema } from '../Schema/Schema'; import { Schema } from '../Schema/Schema';
import type { SchemaOptions } from '../Schema/Schema'; import type { SchemaOptions } from '../Schema/Schema';
import type { FieldModel } from '../../services/models'; import type { FieldModel } from '../../services/models';
@ -48,7 +49,7 @@ export class Field extends React.Component<FieldProps> {
}; };
render() { render() {
const { className, field, isLast, expandByDefault } = this.props; const { className = '', field, isLast, expandByDefault } = this.props;
const { name, deprecated, required, kind } = field; const { name, deprecated, required, kind } = field;
const withSubSchema = !field.schema.isPrimitive && !field.schema.isCircular; const withSubSchema = !field.schema.isPrimitive && !field.schema.isCircular;

View File

@ -1,4 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { observer } from 'mobx-react';
import { import {
RecursiveLabel, RecursiveLabel,
@ -24,7 +25,7 @@ import { OptionsContext } from '../OptionsProvider';
import { Pattern } from './Pattern'; import { Pattern } from './Pattern';
import { ArrayItemDetails } from './ArrayItemDetails'; import { ArrayItemDetails } from './ArrayItemDetails';
function FieldDetailsComponent(props: FieldProps) { export const FieldDetailsComponent = observer((props: FieldProps) => {
const { enumSkipQuotes, hideSchemaTitles } = React.useContext(OptionsContext); const { enumSkipQuotes, hideSchemaTitles } = React.useContext(OptionsContext);
const { showExamples, field, renderDiscriminatorSwitch } = props; const { showExamples, field, renderDiscriminatorSwitch } = props;
@ -107,6 +108,6 @@ function FieldDetailsComponent(props: FieldProps) {
{(_const && <FieldDetail label={l('const') + ':'} value={_const} />) || null} {(_const && <FieldDetail label={l('const') + ':'} value={_const} />) || null}
</div> </div>
); );
} });
export const FieldDetails = React.memo<FieldProps>(FieldDetailsComponent); export const FieldDetails = React.memo<FieldProps>(FieldDetailsComponent);

View File

@ -27,20 +27,20 @@ class Json extends React.PureComponent<JsonProps> {
} }
renderInner = ({ renderCopyButton }) => { renderInner = ({ renderCopyButton }) => {
const showFoldingButtons = this.props.data && Object.values(this.props.data).some( const showFoldingButtons =
(value) => typeof value === 'object' && value !== null, this.props.data &&
); Object.values(this.props.data).some(value => typeof value === 'object' && value !== null);
return ( return (
<JsonViewerWrap> <JsonViewerWrap>
<SampleControls> <SampleControls>
{renderCopyButton()} {renderCopyButton()}
{showFoldingButtons && {showFoldingButtons && (
<> <>
<button onClick={this.expandAll}> Expand all </button> <button onClick={this.expandAll}> Expand all </button>
<button onClick={this.collapseAll}> Collapse all </button> <button onClick={this.collapseAll}> Collapse all </button>
</> </>
} )}
</SampleControls> </SampleControls>
<OptionsContext.Consumer> <OptionsContext.Consumer>
{options => ( {options => (

View File

@ -268,29 +268,44 @@ export class OpenAPIParser {
}>; }>;
for (const { $ref: subSchemaRef, schema: subSchema } of allOfSchemas) { for (const { $ref: subSchemaRef, schema: subSchema } of allOfSchemas) {
if ( const {
receiver.type !== subSchema.type && type,
receiver.type !== undefined && enum: enumProperty,
subSchema.type !== undefined properties,
) { items,
console.warn( required,
`Incompatible types in allOf at "${$ref}": "${receiver.type}" and "${subSchema.type}"`, ...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) { if (type !== undefined) {
receiver.type = subSchema.type; 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 || {}; receiver.properties = receiver.properties || {};
for (const prop in subSchema.properties) { for (const prop in properties) {
if (!receiver.properties[prop]) { if (!receiver.properties[prop]) {
receiver.properties[prop] = subSchema.properties[prop]; receiver.properties[prop] = properties[prop];
} else { } else {
// merge inner properties // merge inner properties
const mergedProp = this.mergeAllOf( const mergedProp = this.mergeAllOf(
{ allOf: [receiver.properties[prop], subSchema.properties[prop]] }, { allOf: [receiver.properties[prop], properties[prop]] },
$ref + '/properties/' + prop, $ref + '/properties/' + prop,
); );
receiver.properties[prop] = mergedProp; receiver.properties[prop] = mergedProp;
@ -299,22 +314,19 @@ export class OpenAPIParser {
} }
} }
if (subSchema.items !== undefined) { if (items !== undefined) {
receiver.items = receiver.items || {}; receiver.items = receiver.items || {};
// merge inner properties // merge inner properties
receiver.items = this.mergeAllOf( receiver.items = this.mergeAllOf({ allOf: [receiver.items, items] }, $ref + '/items');
{ allOf: [receiver.items, subSchema.items] },
$ref + '/items',
);
} }
if (subSchema.required !== undefined) { if (required !== undefined) {
receiver.required = (receiver.required || []).concat(subSchema.required); receiver.required = (receiver.required || []).concat(required);
} }
// merge rest of constraints // merge rest of constraints
// TODO: do more intelligent merge // TODO: do more intelligent merge
receiver = { ...subSchema, ...receiver }; receiver = { ...receiver, ...otherConstraints };
if (subSchemaRef) { if (subSchemaRef) {
receiver.parentRefs!.push(subSchemaRef); receiver.parentRefs!.push(subSchemaRef);

View File

@ -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+"
}
}
}
}
}
}
}

View File

@ -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
}
}
}
}
}

View File

@ -49,6 +49,32 @@ describe('Models', () => {
expect(schema.pointer).toBe('#/components/schemas/Child'); 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', () => { test('schemaDefinition should resolve unevaluatedProperties in properties', () => {
const spec = require('../fixtures/3.1/unevaluatedProperties.json'); const spec = require('../fixtures/3.1/unevaluatedProperties.json');
parser = new OpenAPIParser(spec, undefined, opts); parser = new OpenAPIParser(spec, undefined, opts);

View File

@ -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",
}
`;

View File

@ -152,6 +152,11 @@ export class SchemaModel {
return; return;
} }
if ((schema.if && schema.then) || (schema.if && schema.else)) {
this.initConditionalOperators(schema, parser);
return;
}
if (!isChild && getDiscriminator(schema) !== undefined) { if (!isChild && getDiscriminator(schema) !== undefined) {
this.initDiscriminator(schema, parser); this.initDiscriminator(schema, parser);
return; return;
@ -355,6 +360,38 @@ export class SchemaModel {
return innerSchema; 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( function buildFields(

View File

@ -148,6 +148,10 @@ export interface OpenAPISchema {
minProperties?: number; minProperties?: number;
enum?: any[]; enum?: any[];
example?: any; example?: any;
if?: OpenAPISchema;
else?: OpenAPISchema;
then?: OpenAPISchema;
examples?: any[]; examples?: any[];
const?: string; const?: string;
contentEncoding?: string; contentEncoding?: string;

View File

@ -1903,6 +1903,46 @@ Object {
}, },
"schemas": Object { "schemas": Object {
"ApiResponse": 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 { "properties": Object {
"code": Object { "code": Object {
"format": "int32", "format": "int32",
@ -1934,7 +1974,10 @@ Object {
"aggressive", "aggressive",
], ],
"example": "adventurous", "example": "adventurous",
"type": "string", "type": Array [
"string",
"boolean",
],
}, },
}, },
"required": Array [ "required": Array [
@ -2086,6 +2129,16 @@ Object {
"friend": Object { "friend": Object {
"$ref": "#/components/schemas/Pet", "$ref": "#/components/schemas/Pet",
}, },
"huntingSkill": Object {
"enum": Array [
0,
1,
2,
],
"type": Array [
"integer",
],
},
"id": Object { "id": Object {
"$ref": "#/components/schemas/Id", "$ref": "#/components/schemas/Id",
"description": "Pet ID", "description": "Pet ID",
@ -2105,17 +2158,33 @@ Object {
}, },
"photoUrls": Object { "photoUrls": Object {
"description": "The list of URL to a cute photos featuring pet", "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 { "items": Object {
"format": "url", "format": "url",
"type": "string", "type": "string",
}, },
"maxItems": 20, "maxItems": 10,
"minItems": 1, "minItems": 1,
"then": Object {
"maxItems": 15,
"minItems": 1,
},
"type": Array [ "type": Array [
"string", "string",
"integer", "integer",
"null", "null",
"array",
], ],
"xml": Object { "xml": Object {
"name": "photoUrl", "name": "photoUrl",
@ -2173,6 +2242,19 @@ Object {
}, },
}, },
"User": Object { "User": Object {
"else": Object {
"required": Array [],
},
"if": Object {
"properties": Object {
"userStatus": Object {
"enum": Array [
10,
],
},
},
"title": "userStatus === 10",
},
"properties": Object { "properties": Object {
"email": Object { "email": Object {
"description": "User email address", "description": "User email address",
@ -2238,6 +2320,11 @@ Object {
"type": "string", "type": "string",
}, },
}, },
"then": Object {
"required": Array [
"phone",
],
},
"type": "object", "type": "object",
"xml": Object { "xml": Object {
"name": "User", "name": "User",

View File

@ -277,7 +277,7 @@ describe('Utils', () => {
expect(isPrimitiveType(schema)).toEqual(true); 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 = { const schema = {
type: ['object', 'string', 'null'], type: ['object', 'string', 'null'],
}; };

View File

@ -125,6 +125,10 @@ export function isPrimitiveType(
return false; return false;
} }
if ((schema.if && schema.then) || (schema.if && schema.else)) {
return false;
}
let isPrimitive = true; let isPrimitive = true;
const isArrayType = isArray(type); const isArrayType = isArray(type);