Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support conditional operators #1939

Merged
merged 10 commits into from
May 17, 2022
59 changes: 56 additions & 3 deletions demo/openapi-3-1.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -975,7 +1002,7 @@ components:
- type: object
properties:
huntingSkill:
type: string
type: [string, boolean]
description: The measured skill for hunting
default: lazy
example: adventurous
Expand Down Expand Up @@ -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:
Expand All @@ -1131,6 +1169,12 @@ components:
petType:
description: Type of a pet
type: string
huntingSkill:
type: [integer]
enum:
- 0
- 1
- 2
xml:
name: Pet
Tag:
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion src/components/Fields/Field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;

Expand Down
5 changes: 3 additions & 2 deletions src/components/Fields/FieldDetails.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as React from 'react';
import { observer } from 'mobx-react';

import {
RecursiveLabel,
Expand All @@ -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;
Expand Down Expand Up @@ -107,6 +108,6 @@ function FieldDetailsComponent(props: FieldProps) {
{(_const && <FieldDetail label={l('const') + ':'} value={_const} />) || null}
</div>
);
}
});

export const FieldDetails = React.memo<FieldProps>(FieldDetailsComponent);
10 changes: 5 additions & 5 deletions src/components/JsonViewer/JsonViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 => (
Expand Down
56 changes: 34 additions & 22 deletions src/services/OpenAPIParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (type !== undefined) {
if (Array.isArray(type) && Array.isArray(receiver.type)) {
receiver.type = [...type, ...receiver.type];
} else {
receiver.type = type;
}
}

if (subSchema.type !== undefined) {
receiver.type = subSchema.type;
if (enumProperty !== undefined) {
if (Array.isArray(enumProperty) && Array.isArray(receiver.enum)) {
receiver.enum = [...enumProperty, ...receiver.enum];
} else {
receiver.enum = enumProperty;
}
}

if (subSchema.properties !== undefined) {
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;
Expand All @@ -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);
Expand Down
40 changes: 40 additions & 0 deletions src/services/__tests__/fixtures/3.1/conditionalField.json
Original file line number Diff line number Diff line change
@@ -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+"
}
}
}
}
}
}
}
40 changes: 40 additions & 0 deletions src/services/__tests__/fixtures/3.1/conditionalSchema.json
Original file line number Diff line number Diff line change
@@ -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
}
}
}
}
}
26 changes: 26 additions & 0 deletions src/services/__tests__/models/Schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading