feat: add support prefix items

This commit is contained in:
Alex Varchuk 2022-05-23 11:32:15 +03:00
parent eb0917d002
commit 27a9dbaf46
13 changed files with 644 additions and 19 deletions

View File

@ -1242,6 +1242,26 @@ components:
type: string
contentEncoding: base64
contentMediaType: image/png
addresses:
type: array
minItems: 0
maxLength: 10
prefixItems:
- type: object
properties:
city:
type: string
minLength: 0
country:
type: string
minLength: 0
street:
description: includes build/apartment number
type: string
minLength: 0
- type: number
items:
type: string
if:
title: userStatus === 10
properties:

View File

@ -1135,6 +1135,26 @@ components:
description: User status
type: integer
format: int32
addresses:
type: array
minItems: 0
maxLength: 10
items:
- type: object
properties:
city:
type: string
minLength: 0
country:
type: string
minLength: 0
street:
description: includes build/apartment number
type: string
minLength: 0
- type: number
additionalItems:
type: string
xml:
name: User
requestBodies:

View File

@ -14,7 +14,7 @@ export function ArrayItemDetails({ schema }: { schema: SchemaModel }) {
((!schema?.pattern || hideSchemaPattern) &&
!schema.items &&
!schema.displayFormat &&
!schema.constraints.length)
!schema.constraints.length) // return null for cases where all constraints are empty
) {
return null;
}

View File

@ -6,6 +6,7 @@ import { ArrayClosingLabel, ArrayOpenningLabel } from '../../common-elements';
import styled from '../../styled-components';
import { humanizeConstraints } from '../../utils';
import { TypeName } from '../../common-elements/fields';
import { ObjectSchema } from './ObjectSchema';
const PaddedSchema = styled.div`
padding-left: ${({ theme }) => theme.spacing.unit * 2}px;
@ -21,6 +22,9 @@ export class ArraySchema extends React.PureComponent<SchemaProps> {
? ''
: `(${humanizeConstraints(schema)})`;
if (schema.fields) {
return <ObjectSchema {...(this.props as any)} level={this.props.level} />;
}
if (schema.displayType && !itemsSchema && !minMaxItems.length) {
return (
<div>

View File

@ -1,6 +1,6 @@
import { OpenAPIRef, OpenAPISchema, OpenAPISpec, Referenced } from '../types';
import { appendToMdHeading, isArray, IS_BROWSER } from '../utils/';
import { appendToMdHeading, isArray, isBoolean, IS_BROWSER } from '../utils/';
import { JsonPointer } from '../utils/JsonPointer';
import {
getDefinitionName,
@ -318,9 +318,19 @@ export class OpenAPIParser {
}
if (items !== undefined) {
receiver.items = receiver.items || {};
const receiverItems = isBoolean(receiver.items)
? { items: receiver.items }
: receiver.items
? (Object.assign({}, receiver.items) as OpenAPISchema)
: {};
const subSchemaItems = isBoolean(items)
? { items }
: (Object.assign({}, items) as OpenAPISchema);
// merge inner properties
receiver.items = this.mergeAllOf({ allOf: [receiver.items, items] }, $ref + '/items');
receiver.items = this.mergeAllOf(
{ allOf: [receiverItems, subSchemaItems] },
$ref + '/items',
);
}
if (required !== undefined) {

View File

@ -0,0 +1,154 @@
{
"openapi": "3.1.0",
"info": {
"title": "Schema definition with prefixItems",
"version": "1.0.0"
},
"servers": [
{
"url": "example.com"
}
],
"components": {
"schemas": {
"Case1": {
"type": "array",
"minItems": 1,
"maxItems": 10,
"prefixItems": [
{
"type": "string",
"minLength": 0,
"maxLength": 10
},
{
"type": "number",
"minimum": 0,
"maximum": 10
},
{
"$ref": "#/components/schemas/Cat"
}
],
"items": false
},
"Case2": {
"type": "array",
"minItems": 1,
"maxItems": 10,
"prefixItems": [
{
"type": "string",
"minLength": 0,
"maxLength": 10
},
{
"type": "number",
"minimum": 0,
"maximum": 10
},
{
"$ref": "#/components/schemas/Cat"
}
],
"items": true
},
"Case3": {
"type": "array",
"minItems": 1,
"maxItems": 10,
"prefixItems": [
{
"type": "string",
"minLength": 0,
"maxLength": 10
},
{
"type": "number",
"minimum": 0,
"maximum": 10
},
{
"$ref": "#/components/schemas/Cat"
}
],
"items": {
"$ref": "#/components/schemas/Dog"
}
},
"Case4": {
"type": "array",
"minItems": 1,
"maxItems": 10,
"prefixItems": [
{
"type": "string",
"minLength": 0,
"maxLength": 10
},
{
"type": "number",
"minimum": 0,
"maximum": 10
},
{
"$ref": "#/components/schemas/Cat"
}
],
"items": {
"type": "object",
"properties": {
"firstItem": {
"type": "string"
}
}
}
},
"Case5": {
"type": "array",
"minItems": 1,
"maxItems": 10,
"prefixItems": [
{
"type": "string",
"minLength": 0,
"maxLength": 10
},
{
"type": "number",
"minimum": 0,
"maximum": 10
},
{
"$ref": "#/components/schemas/Cat"
}
],
"items": {
"type": "array",
"items": [
{
"type": "string",
"minLength": 0
}
]
}
},
"Cat": {
"type": "object",
"properties": {
"color": {
"type": "string"
}
}
},
"Dog": {
"type": "object",
"properties": {
"size": {
"type": "string"
}
}
}
}
}
}

View File

@ -0,0 +1,154 @@
{
"openapi": "3.0.0",
"info": {
"title": "Schema definition with array items",
"version": "1.0.0"
},
"servers": [
{
"url": "example.com"
}
],
"components": {
"schemas": {
"Case1": {
"type": "array",
"minItems": 1,
"maxItems": 10,
"items": [
{
"type": "string",
"minLength": 0,
"maxLength": 10
},
{
"type": "number",
"minimum": 0,
"maximum": 10
},
{
"$ref": "#/components/schemas/Cat"
}
],
"additionalItems": false
},
"Case2": {
"type": "array",
"minItems": 1,
"maxItems": 10,
"items": [
{
"type": "string",
"minLength": 0,
"maxLength": 10
},
{
"type": "number",
"minimum": 0,
"maximum": 10
},
{
"$ref": "#/components/schemas/Cat"
}
],
"additionalItems": true
},
"Case3": {
"type": "array",
"minItems": 1,
"maxItems": 10,
"items": [
{
"type": "string",
"minLength": 0,
"maxLength": 10
},
{
"type": "number",
"minimum": 0,
"maximum": 10
},
{
"$ref": "#/components/schemas/Cat"
}
],
"additionalItems": {
"$ref": "#/components/schemas/Dog"
}
},
"Case4": {
"type": "array",
"minItems": 1,
"maxItems": 10,
"items": [
{
"type": "string",
"minLength": 0,
"maxLength": 10
},
{
"type": "number",
"minimum": 0,
"maximum": 10
},
{
"$ref": "#/components/schemas/Cat"
}
],
"additionalItems": {
"type": "object",
"properties": {
"firstItem": {
"type": "string"
}
}
}
},
"Case5": {
"type": "array",
"minItems": 1,
"maxItems": 10,
"items": [
{
"type": "string",
"minLength": 0,
"maxLength": 10
},
{
"type": "number",
"minimum": 0,
"maximum": 10
},
{
"$ref": "#/components/schemas/Cat"
}
],
"additionalItems": {
"type": "array",
"items": [
{
"type": "string",
"minLength": 0
}
]
}
},
"Cat": {
"type": "object",
"properties": {
"color": {
"type": "string"
}
}
},
"Dog": {
"type": "object",
"properties": {
"size": {
"type": "string"
}
}
}
}
}
}

View File

@ -113,5 +113,105 @@ describe('Models', () => {
expect(schema.fields![1].kind).toEqual('patternProperties');
expect(schema.fields![1].schema.type).toEqual('object');
});
describe('type array', () => {
function testImmutablePart(schema: SchemaModel) {
expect(schema.minItems).toEqual(1);
expect(schema.maxItems).toEqual(10);
expect(schema.fields![0].schema.type).toEqual('string');
expect(schema.fields![1].schema.type).toEqual('number');
}
const eachArray = ['../fixtures/3.1/prefixItems.json', '../fixtures/arrayItems.json'];
test.each(eachArray)(
'schemaDefinition should resolve prefixItems without additional items',
specFixture => {
const spec = require(specFixture);
const parser = new OpenAPIParser(spec, undefined, opts);
const schema = new SchemaModel(parser, spec.components.schemas.Case1, '', opts);
testImmutablePart(schema);
expect(schema.fields).toHaveLength(3);
expect(schema.fields![2].name).toEqual('[2]');
expect(schema.fields![2].schema.pointer).toEqual('#/components/schemas/Cat');
expect(schema.fields![2].schema.type).toEqual('object');
},
);
test.each(eachArray)(
'schemaDefinition should resolve prefixItems with additional items',
specFixture => {
const spec = require(specFixture);
const parser = new OpenAPIParser(spec, undefined, opts);
const schema = new SchemaModel(parser, spec.components.schemas.Case2, '', opts);
testImmutablePart(schema);
expect(schema.fields).toHaveLength(4);
expect(schema.fields![3].name).toEqual('[3...]');
expect(schema.fields![2].schema.type).toEqual('object');
expect(schema.fields![2].schema.pointer).toEqual('#/components/schemas/Cat');
expect(schema.fields![3].schema.type).toEqual('any');
},
);
test.each(eachArray)(
'schemaDefinition should resolve prefixItems with additional items with $ref',
specFixture => {
const spec = require(specFixture);
const parser = new OpenAPIParser(spec, undefined, opts);
const schema = new SchemaModel(parser, spec.components.schemas.Case3, '', opts);
testImmutablePart(schema);
expect(schema.fields).toHaveLength(4);
expect(schema.fields![3].name).toEqual('[3...]');
expect(schema.fields![2].schema.type).toEqual('object');
expect(schema.fields![2].schema.pointer).toEqual('#/components/schemas/Cat');
expect(schema.fields![3].schema.type).toEqual('object');
expect(schema.fields![3].schema.pointer).toEqual('#/components/schemas/Dog');
},
);
test.each(eachArray)(
'schemaDefinition should resolve prefixItems with additional schema items',
specFixture => {
const spec = require(specFixture);
const parser = new OpenAPIParser(spec, undefined, opts);
const schema = new SchemaModel(parser, spec.components.schemas.Case4, '', opts);
testImmutablePart(schema);
expect(schema.fields).toHaveLength(4);
expect(schema.fields![3].name).toEqual('[3...]');
expect(schema.fields![2].schema.type).toEqual('object');
expect(schema.fields![2].schema.pointer).toEqual('#/components/schemas/Cat');
expect(schema.fields![3].schema.type).toEqual('object');
},
);
test.each(eachArray)(
'schemaDefinition should resolve prefixItems with additional array items',
specFixture => {
const spec = require(specFixture);
const parser = new OpenAPIParser(spec, undefined, opts);
const schema = new SchemaModel(parser, spec.components.schemas.Case5, '', opts);
testImmutablePart(schema);
expect(schema.fields).toHaveLength(4);
expect(schema.fields![3].name).toEqual('[3...]');
expect(schema.fields![2].schema.type).toEqual('object');
expect(schema.fields![2].schema.pointer).toEqual('#/components/schemas/Cat');
expect(schema.fields![3].schema.type).toEqual('array');
expect(schema.fields![3].schema.fields).toHaveLength(1);
expect(schema.fields![3].schema.fields![0].schema.type).toEqual('string');
expect(schema.fields![3].schema.fields![0].schema.constraints).toEqual([
'>= 0 characters',
]);
},
);
});
});
});

View File

@ -12,7 +12,9 @@ import {
extractExtensions,
humanizeConstraints,
isArray,
isBoolean,
isNamedDefinition,
isObject,
isPrimitiveType,
JsonPointer,
pluralizeType,
@ -188,17 +190,31 @@ export class SchemaModel {
if (this.hasType('object')) {
this.fields = buildFields(parser, schema, this.pointer, this.options);
} else if (this.hasType('array') && schema.items) {
this.items = new SchemaModel(parser, schema.items, this.pointer + '/items', this.options);
this.displayType = pluralizeType(this.items.displayType);
this.displayFormat = this.items.format;
this.typePrefix = this.items.typePrefix + l('arrayOf');
this.title = this.title || this.items.title;
this.isPrimitive = this.items.isPrimitive;
if (this.example === undefined && this.items.example !== undefined) {
} else if (this.hasType('array')) {
if (isArray(schema.items) || isArray(schema.prefixItems)) {
this.fields = buildFields(parser, schema, this.pointer, this.options);
} else if (isObject(schema.items)) {
this.items = new SchemaModel(
parser,
schema.items as OpenAPISchema,
this.pointer + '/items',
this.options,
);
}
this.displayType =
schema.prefixItems || isArray(schema.items)
? 'items'
: pluralizeType(this.items?.displayType || this.displayType);
this.displayFormat = this.items?.format || '';
this.typePrefix = this.items?.typePrefix || '' + l('arrayOf');
this.title = this.title || this.items?.title || '';
this.isPrimitive = this.items?.isPrimitive || this.isPrimitive;
if (this.example === undefined && this.items?.example !== undefined) {
this.example = [this.items.example];
}
if (this.items.isPrimitive) {
if (this.items?.isPrimitive) {
this.enum = this.items.enum;
}
if (isArray(this.type)) {
@ -400,9 +416,10 @@ function buildFields(
$ref: string,
options: RedocNormalizedOptions,
): FieldModel[] {
const props = schema.properties || {};
const props = schema.properties || schema.prefixItems || schema.items || {};
const patternProps = schema.patternProperties || {};
const additionalProps = schema.additionalProperties || schema.unevaluatedProperties;
const itemsProps = schema.prefixItems ? schema.items : schema.additionalItems;
const defaults = schema.default;
let fields = Object.keys(props || []).map(fieldName => {
let field = props[fieldName];
@ -420,7 +437,7 @@ function buildFields(
return new FieldModel(
parser,
{
name: fieldName,
name: schema.properties ? fieldName : `[${fieldName}]`,
required,
schema: {
...field,
@ -484,9 +501,82 @@ function buildFields(
);
}
fields.push(
...buildAdditionalItems({
parser,
schema: itemsProps,
fieldsCount: fields.length,
$ref,
options,
}),
);
return fields;
}
function buildAdditionalItems({
parser,
schema = false,
fieldsCount,
$ref,
options,
}: {
parser: OpenAPIParser;
schema?: OpenAPISchema | OpenAPISchema[] | boolean;
fieldsCount: number;
$ref: string;
options: RedocNormalizedOptions;
}) {
if (isBoolean(schema)) {
return schema
? [
new FieldModel(
parser,
{
name: `[${fieldsCount}...]`,
schema: {},
},
`${$ref}/additionalItems`,
options,
),
]
: [];
}
if (isArray(schema)) {
return [
...schema.map(
(field, idx) =>
new FieldModel(
parser,
{
name: `[${fieldsCount + idx}]`,
schema: field,
},
`${$ref}/additionalItems`,
options,
),
),
];
}
if (isObject(schema)) {
return [
new FieldModel(
parser,
{
name: `[${fieldsCount}...]`,
schema: schema,
},
`${$ref}/additionalItems`,
options,
),
];
}
return [];
}
function getDiscriminator(schema: OpenAPISchema): OpenAPISchema['discriminator'] {
return schema.discriminator || schema['x-discriminator'];
}

View File

@ -118,7 +118,7 @@ export interface OpenAPISchema {
unevaluatedProperties?: boolean | OpenAPISchema;
description?: string;
default?: any;
items?: OpenAPISchema;
items?: OpenAPISchema | OpenAPISchema[] | boolean;
required?: string[];
readOnly?: boolean;
writeOnly?: boolean;
@ -156,6 +156,8 @@ export interface OpenAPISchema {
const?: string;
contentEncoding?: string;
contentMediaType?: string;
prefixItems?: OpenAPISchema[];
additionalItems?: OpenAPISchema | boolean;
}
export interface OpenAPIDiscriminator {

View File

@ -352,6 +352,37 @@ Object {
},
"User": Object {
"properties": Object {
"addresses": Object {
"additionalItems": Object {
"type": "string",
},
"items": Array [
Object {
"properties": Object {
"city": Object {
"minLength": 0,
"type": "string",
},
"country": Object {
"minLength": 0,
"type": "string",
},
"street": Object {
"description": "includes build/apartment number",
"minLength": 0,
"type": "string",
},
},
"type": "object",
},
Object {
"type": "number",
},
],
"maxLength": 10,
"minItems": 0,
"type": "array",
},
"email": Object {
"description": "User email address",
"example": "john.smith@example.com",
@ -2257,6 +2288,37 @@ Object {
"title": "userStatus === 10",
},
"properties": Object {
"addresses": Object {
"items": Object {
"type": "string",
},
"maxLength": 10,
"minItems": 0,
"prefixItems": Array [
Object {
"properties": Object {
"city": Object {
"minLength": 0,
"type": "string",
},
"country": Object {
"minLength": 0,
"type": "string",
},
"street": Object {
"description": "includes build/apartment number",
"minLength": 0,
"type": "string",
},
},
"type": "object",
},
Object {
"type": "number",
},
],
"type": "array",
},
"email": Object {
"description": "User email address",
"example": "john.smith@example.com",

View File

@ -107,7 +107,7 @@ export const mergeObjects = (target: any, ...sources: any[]): any => {
return mergeObjects(target, ...sources);
};
const isObject = (item: any): boolean => {
export const isObject = (item: unknown): item is Record<string, unknown> => {
return item !== null && typeof item === 'object';
};
@ -210,6 +210,10 @@ export function unescapeHTMLChars(str: string): string {
.replace(/&quot;/g, '"');
}
export function isArray(value: unknown): value is Array<any> {
export function isArray(value: unknown): value is any[] {
return Array.isArray(value);
}
export function isBoolean(value: unknown): value is boolean {
return typeof value === 'boolean';
}

View File

@ -16,7 +16,7 @@ import {
Referenced,
} from '../types';
import { IS_BROWSER } from './dom';
import { isNumeric, removeQueryString, resolveUrl, isArray } from './helpers';
import { isNumeric, removeQueryString, resolveUrl, isArray, isBoolean } from './helpers';
function isWildcardStatusCode(statusCode: string | number): statusCode is string {
return typeof statusCode === 'string' && /\dxx/i.test(statusCode);
@ -139,8 +139,13 @@ export function isPrimitiveType(
: schema.additionalProperties === undefined && schema.unevaluatedProperties === undefined;
}
if (isArray(schema.items) || isArray(schema.prefixItems)) {
return false;
}
if (
schema.items !== undefined &&
!isBoolean(schema.items) &&
(type === 'array' || (isArrayType && type?.includes('array')))
) {
isPrimitive = isPrimitiveType(schema.items, schema.items.type);