Skip to content

Commit

Permalink
feat: support conditional operators (#1939)
Browse files Browse the repository at this point in the history
* fix: merge type and enum in allOf for 3.1

* chore: add example for OpenApi 3.1

* fix: correct merge constraints in allOf
  • Loading branch information
AlexVarchuk authored May 17, 2022
1 parent ddcc76b commit 291b62a
Show file tree
Hide file tree
Showing 14 changed files with 449 additions and 37 deletions.
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

0 comments on commit 291b62a

Please sign in to comment.