diff --git a/demo/openapi-3-1.yaml b/demo/openapi-3-1.yaml index 9c8d0f07..9c875fc6 100644 --- a/demo/openapi-3-1.yaml +++ b/demo/openapi-3-1.yaml @@ -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: diff --git a/demo/openapi.yaml b/demo/openapi.yaml index c18063e7..4008f2e2 100644 --- a/demo/openapi.yaml +++ b/demo/openapi.yaml @@ -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: diff --git a/src/components/Fields/ArrayItemDetails.tsx b/src/components/Fields/ArrayItemDetails.tsx index 776d229d..f56ab0a7 100644 --- a/src/components/Fields/ArrayItemDetails.tsx +++ b/src/components/Fields/ArrayItemDetails.tsx @@ -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; } diff --git a/src/components/Schema/ArraySchema.tsx b/src/components/Schema/ArraySchema.tsx index 25ac633e..84bd716a 100644 --- a/src/components/Schema/ArraySchema.tsx +++ b/src/components/Schema/ArraySchema.tsx @@ -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 { ? '' : `(${humanizeConstraints(schema)})`; + if (schema.fields) { + return ; + } if (schema.displayType && !itemsSchema && !minMaxItems.length) { return (
diff --git a/src/services/OpenAPIParser.ts b/src/services/OpenAPIParser.ts index b4d1edb5..897f1a79 100644 --- a/src/services/OpenAPIParser.ts +++ b/src/services/OpenAPIParser.ts @@ -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) { diff --git a/src/services/__tests__/fixtures/3.1/prefixItems.json b/src/services/__tests__/fixtures/3.1/prefixItems.json new file mode 100644 index 00000000..63a4d078 --- /dev/null +++ b/src/services/__tests__/fixtures/3.1/prefixItems.json @@ -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" + } + } + } + } + } +} diff --git a/src/services/__tests__/fixtures/arrayItems.json b/src/services/__tests__/fixtures/arrayItems.json new file mode 100644 index 00000000..ebe25f34 --- /dev/null +++ b/src/services/__tests__/fixtures/arrayItems.json @@ -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" + } + } + } + } + } +} diff --git a/src/services/__tests__/models/Schema.test.ts b/src/services/__tests__/models/Schema.test.ts index d6eb6738..3dfbf6f8 100644 --- a/src/services/__tests__/models/Schema.test.ts +++ b/src/services/__tests__/models/Schema.test.ts @@ -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', + ]); + }, + ); + }); }); }); diff --git a/src/services/models/Schema.ts b/src/services/models/Schema.ts index 71a3a480..4816a149 100644 --- a/src/services/models/Schema.ts +++ b/src/services/models/Schema.ts @@ -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']; } diff --git a/src/types/open-api.ts b/src/types/open-api.ts index 60fdca26..afa98642 100644 --- a/src/types/open-api.ts +++ b/src/types/open-api.ts @@ -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 { diff --git a/src/utils/__tests__/__snapshots__/loadAndBundleSpec.test.ts.snap b/src/utils/__tests__/__snapshots__/loadAndBundleSpec.test.ts.snap index 0c3f8d4f..17fda88e 100644 --- a/src/utils/__tests__/__snapshots__/loadAndBundleSpec.test.ts.snap +++ b/src/utils/__tests__/__snapshots__/loadAndBundleSpec.test.ts.snap @@ -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", diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 35b19e52..16ed57c1 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -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 => { return item !== null && typeof item === 'object'; }; @@ -210,6 +210,10 @@ export function unescapeHTMLChars(str: string): string { .replace(/"/g, '"'); } -export function isArray(value: unknown): value is Array { +export function isArray(value: unknown): value is any[] { return Array.isArray(value); } + +export function isBoolean(value: unknown): value is boolean { + return typeof value === 'boolean'; +} diff --git a/src/utils/openapi.ts b/src/utils/openapi.ts index 5c9986c5..89ad4330 100644 --- a/src/utils/openapi.ts +++ b/src/utils/openapi.ts @@ -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);