Skip to content

Commit 291b62a

Browse files
authored
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
1 parent ddcc76b commit 291b62a

File tree

14 files changed

+449
-37
lines changed

14 files changed

+449
-37
lines changed

demo/openapi-3-1.yaml

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -960,6 +960,33 @@ components:
960960
schemas:
961961
ApiResponse:
962962
type: object
963+
patternProperties:
964+
^S_\\w+\\.[1-9]{2,4}$:
965+
description: The measured skill for hunting
966+
if:
967+
x-displayName: fieldName === 'status'
968+
else:
969+
minLength: 1
970+
maxLength: 10
971+
then:
972+
format: url
973+
type: string
974+
enum:
975+
- success
976+
- failed
977+
^O_\\w+\\.[1-9]{2,4}$:
978+
type: object
979+
properties:
980+
nestedProperty:
981+
type: [string, boolean]
982+
description: The measured skill for hunting
983+
default: lazy
984+
example: adventurous
985+
enum:
986+
- clueless
987+
- lazy
988+
- adventurous
989+
- aggressive
963990
properties:
964991
code:
965992
type: integer
@@ -975,7 +1002,7 @@ components:
9751002
- type: object
9761003
properties:
9771004
huntingSkill:
978-
type: string
1005+
type: [string, boolean]
9791006
description: The measured skill for hunting
9801007
default: lazy
9811008
example: adventurous
@@ -1099,15 +1126,26 @@ components:
10991126
example: Guru
11001127
photoUrls:
11011128
description: The list of URL to a cute photos featuring pet
1102-
type: [string, integer, 'null', array]
1129+
type: [string, integer, 'null']
11031130
minItems: 1
1104-
maxItems: 20
1131+
maxItems: 10
11051132
xml:
11061133
name: photoUrl
11071134
wrapped: true
11081135
items:
11091136
type: string
11101137
format: url
1138+
if:
1139+
x-displayName: isString
1140+
type: string
1141+
then:
1142+
minItems: 1
1143+
maxItems: 15
1144+
else:
1145+
x-displayName: notString
1146+
type: [integer, 'null']
1147+
minItems: 1
1148+
maxItems: 20
11111149
friend:
11121150
$ref: '#/components/schemas/Pet'
11131151
tags:
@@ -1131,6 +1169,12 @@ components:
11311169
petType:
11321170
description: Type of a pet
11331171
type: string
1172+
huntingSkill:
1173+
type: [integer]
1174+
enum:
1175+
- 0
1176+
- 1
1177+
- 2
11341178
xml:
11351179
name: Pet
11361180
Tag:
@@ -1198,6 +1242,15 @@ components:
11981242
type: string
11991243
contentEncoding: base64
12001244
contentMediaType: image/png
1245+
if:
1246+
title: userStatus === 10
1247+
properties:
1248+
userStatus:
1249+
enum: [10]
1250+
then:
1251+
required: ['phone']
1252+
else:
1253+
required: []
12011254
xml:
12021255
name: User
12031256
requestBodies:

src/components/Fields/Field.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
} from '../../common-elements/fields-layout';
1717
import { ShelfIcon } from '../../common-elements/';
1818
import { Schema } from '../Schema/Schema';
19+
1920
import type { SchemaOptions } from '../Schema/Schema';
2021
import type { FieldModel } from '../../services/models';
2122

@@ -48,7 +49,7 @@ export class Field extends React.Component<FieldProps> {
4849
};
4950

5051
render() {
51-
const { className, field, isLast, expandByDefault } = this.props;
52+
const { className = '', field, isLast, expandByDefault } = this.props;
5253
const { name, deprecated, required, kind } = field;
5354
const withSubSchema = !field.schema.isPrimitive && !field.schema.isCircular;
5455

src/components/Fields/FieldDetails.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as React from 'react';
2+
import { observer } from 'mobx-react';
23

34
import {
45
RecursiveLabel,
@@ -24,7 +25,7 @@ import { OptionsContext } from '../OptionsProvider';
2425
import { Pattern } from './Pattern';
2526
import { ArrayItemDetails } from './ArrayItemDetails';
2627

27-
function FieldDetailsComponent(props: FieldProps) {
28+
export const FieldDetailsComponent = observer((props: FieldProps) => {
2829
const { enumSkipQuotes, hideSchemaTitles } = React.useContext(OptionsContext);
2930

3031
const { showExamples, field, renderDiscriminatorSwitch } = props;
@@ -107,6 +108,6 @@ function FieldDetailsComponent(props: FieldProps) {
107108
{(_const && <FieldDetail label={l('const') + ':'} value={_const} />) || null}
108109
</div>
109110
);
110-
}
111+
});
111112

112113
export const FieldDetails = React.memo<FieldProps>(FieldDetailsComponent);

src/components/JsonViewer/JsonViewer.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,20 +27,20 @@ class Json extends React.PureComponent<JsonProps> {
2727
}
2828

2929
renderInner = ({ renderCopyButton }) => {
30-
const showFoldingButtons = this.props.data && Object.values(this.props.data).some(
31-
(value) => typeof value === 'object' && value !== null,
32-
);
30+
const showFoldingButtons =
31+
this.props.data &&
32+
Object.values(this.props.data).some(value => typeof value === 'object' && value !== null);
3333

3434
return (
3535
<JsonViewerWrap>
3636
<SampleControls>
3737
{renderCopyButton()}
38-
{showFoldingButtons &&
38+
{showFoldingButtons && (
3939
<>
4040
<button onClick={this.expandAll}> Expand all </button>
4141
<button onClick={this.collapseAll}> Collapse all </button>
4242
</>
43-
}
43+
)}
4444
</SampleControls>
4545
<OptionsContext.Consumer>
4646
{options => (

src/services/OpenAPIParser.ts

Lines changed: 34 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -268,29 +268,44 @@ export class OpenAPIParser {
268268
}>;
269269

270270
for (const { $ref: subSchemaRef, schema: subSchema } of allOfSchemas) {
271-
if (
272-
receiver.type !== subSchema.type &&
273-
receiver.type !== undefined &&
274-
subSchema.type !== undefined
275-
) {
276-
console.warn(
277-
`Incompatible types in allOf at "${$ref}": "${receiver.type}" and "${subSchema.type}"`,
278-
);
271+
const {
272+
type,
273+
enum: enumProperty,
274+
properties,
275+
items,
276+
required,
277+
...otherConstraints
278+
} = subSchema;
279+
280+
if (receiver.type !== type && receiver.type !== undefined && type !== undefined) {
281+
console.warn(`Incompatible types in allOf at "${$ref}": "${receiver.type}" and "${type}"`);
282+
}
283+
284+
if (type !== undefined) {
285+
if (Array.isArray(type) && Array.isArray(receiver.type)) {
286+
receiver.type = [...type, ...receiver.type];
287+
} else {
288+
receiver.type = type;
289+
}
279290
}
280291

281-
if (subSchema.type !== undefined) {
282-
receiver.type = subSchema.type;
292+
if (enumProperty !== undefined) {
293+
if (Array.isArray(enumProperty) && Array.isArray(receiver.enum)) {
294+
receiver.enum = [...enumProperty, ...receiver.enum];
295+
} else {
296+
receiver.enum = enumProperty;
297+
}
283298
}
284299

285-
if (subSchema.properties !== undefined) {
300+
if (properties !== undefined) {
286301
receiver.properties = receiver.properties || {};
287-
for (const prop in subSchema.properties) {
302+
for (const prop in properties) {
288303
if (!receiver.properties[prop]) {
289-
receiver.properties[prop] = subSchema.properties[prop];
304+
receiver.properties[prop] = properties[prop];
290305
} else {
291306
// merge inner properties
292307
const mergedProp = this.mergeAllOf(
293-
{ allOf: [receiver.properties[prop], subSchema.properties[prop]] },
308+
{ allOf: [receiver.properties[prop], properties[prop]] },
294309
$ref + '/properties/' + prop,
295310
);
296311
receiver.properties[prop] = mergedProp;
@@ -299,22 +314,19 @@ export class OpenAPIParser {
299314
}
300315
}
301316

302-
if (subSchema.items !== undefined) {
317+
if (items !== undefined) {
303318
receiver.items = receiver.items || {};
304319
// merge inner properties
305-
receiver.items = this.mergeAllOf(
306-
{ allOf: [receiver.items, subSchema.items] },
307-
$ref + '/items',
308-
);
320+
receiver.items = this.mergeAllOf({ allOf: [receiver.items, items] }, $ref + '/items');
309321
}
310322

311-
if (subSchema.required !== undefined) {
312-
receiver.required = (receiver.required || []).concat(subSchema.required);
323+
if (required !== undefined) {
324+
receiver.required = (receiver.required || []).concat(required);
313325
}
314326

315327
// merge rest of constraints
316328
// TODO: do more intelligent merge
317-
receiver = { ...subSchema, ...receiver };
329+
receiver = { ...receiver, ...otherConstraints };
318330

319331
if (subSchemaRef) {
320332
receiver.parentRefs!.push(subSchemaRef);
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
{
2+
"openapi": "3.1.0",
3+
"info": {
4+
"title": "Schema definition field with conditional operators",
5+
"version": "1.0.0"
6+
},
7+
"components": {
8+
"schemas": {
9+
"Test": {
10+
"type": "object",
11+
"properties": {
12+
"test": {
13+
"type": ["string", "integer", "null"],
14+
"minItems": 1,
15+
"maxItems": 20,
16+
"items": {
17+
"type": "string",
18+
"format": "url"
19+
},
20+
"if": {
21+
"x-displayName": "isString",
22+
"type": "string"
23+
},
24+
"then": {
25+
"type": "string",
26+
"minItems": 1,
27+
"maxItems": 20
28+
},
29+
"else": {
30+
"x-displayName": "notString",
31+
"minItems": 1,
32+
"maxItems": 10,
33+
"pattern": "\\d+"
34+
}
35+
}
36+
}
37+
}
38+
}
39+
}
40+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
{
2+
"openapi": "3.1.0",
3+
"info": {
4+
"title": "Schema definition with conditional operators",
5+
"version": "1.0.0"
6+
},
7+
"components": {
8+
"schemas": {
9+
"Test": {
10+
"type": "object",
11+
"properties": {
12+
"test": {
13+
"description": "The list of URL to a cute photos featuring pet",
14+
"type": ["string", "integer", "null"],
15+
"minItems": 1,
16+
"maxItems": 20,
17+
"items": {
18+
"type": "string",
19+
"format": "url"
20+
}
21+
}
22+
},
23+
"if": {
24+
"title": "=== 10",
25+
"properties": {
26+
"test": {
27+
"enum": [10]
28+
}
29+
}
30+
},
31+
"then": {
32+
"maxItems": 2
33+
},
34+
"else": {
35+
"maxItems": 20
36+
}
37+
}
38+
}
39+
}
40+
}

src/services/__tests__/models/Schema.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,32 @@ describe('Models', () => {
4949
expect(schema.pointer).toBe('#/components/schemas/Child');
5050
});
5151

52+
test('schemaDefinition should resolve schema with conditional operators', () => {
53+
const spec = require('../fixtures/3.1/conditionalSchema.json');
54+
parser = new OpenAPIParser(spec, undefined, opts);
55+
const schema = new SchemaModel(parser, spec.components.schemas.Test, '', opts);
56+
expect(schema.oneOf).toHaveLength(2);
57+
58+
expect(schema.oneOf![0].schema.title).toBe('=== 10');
59+
expect(schema.oneOf![1].schema.title).toBe('case 2');
60+
61+
expect(schema.oneOf![0].schema).toMatchSnapshot();
62+
expect(schema.oneOf![1].schema).toMatchSnapshot();
63+
});
64+
65+
test('schemaDefinition should resolve field with conditional operators', () => {
66+
const spec = require('../fixtures/3.1/conditionalField.json');
67+
parser = new OpenAPIParser(spec, undefined, opts);
68+
const schema = new SchemaModel(parser, spec.components.schemas.Test, '', opts);
69+
expect(schema.fields).toHaveLength(1);
70+
expect(schema.fields && schema.fields[0].schema.oneOf).toHaveLength(2);
71+
expect(schema.fields && schema.fields[0].schema.oneOf![0].schema.title).toBe('isString');
72+
expect(schema.fields && schema.fields[0].schema.oneOf![1].schema.title).toBe('notString');
73+
74+
expect(schema.fields && schema.fields[0].schema.oneOf![0].schema).toMatchSnapshot();
75+
expect(schema.fields && schema.fields[0].schema.oneOf![1].schema).toMatchSnapshot();
76+
});
77+
5278
test('schemaDefinition should resolve unevaluatedProperties in properties', () => {
5379
const spec = require('../fixtures/3.1/unevaluatedProperties.json');
5480
parser = new OpenAPIParser(spec, undefined, opts);

0 commit comments

Comments
 (0)