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:
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:

View File

@ -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<FieldProps> {
};
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;

View File

@ -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 && <FieldDetail label={l('const') + ':'} value={_const} />) || null}
</div>
);
}
});
export const FieldDetails = React.memo<FieldProps>(FieldDetailsComponent);

View File

@ -27,20 +27,20 @@ class Json extends React.PureComponent<JsonProps> {
}
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 (
<JsonViewerWrap>
<SampleControls>
{renderCopyButton()}
{showFoldingButtons &&
{showFoldingButtons && (
<>
<button onClick={this.expandAll}> Expand all </button>
<button onClick={this.collapseAll}> Collapse all </button>
</>
}
)}
</SampleControls>
<OptionsContext.Consumer>
{options => (

View File

@ -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);

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');
});
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);

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;
}
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(

View File

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

View File

@ -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",

View File

@ -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'],
};

View File

@ -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);