diff --git a/src/components/Schema/__tests__/DiscriminatorDropdown.test.tsx b/src/components/Schema/__tests__/DiscriminatorDropdown.test.tsx index c2f2414a..609403ec 100644 --- a/src/components/Schema/__tests__/DiscriminatorDropdown.test.tsx +++ b/src/components/Schema/__tests__/DiscriminatorDropdown.test.tsx @@ -1,3 +1,4 @@ +import { RedocNormalizedOptions } from '../../../services/RedocNormalizedOptions'; import * as React from 'react'; import { shallow } from 'enzyme'; import toJson from 'enzyme-to-json'; @@ -17,6 +18,7 @@ describe('Components', () => { parser, { $ref: '#/components/schemas/Pet' }, '#/components/schemas/Pet', + new RedocNormalizedOptions({}), ); const schemaView = shallow(); expect(toJson(schemaView)).toMatchSnapshot(); @@ -29,6 +31,7 @@ describe('Components', () => { parser, { $ref: '#/components/schemas/Pet' }, '#/components/schemas/Pet', + new RedocNormalizedOptions({}), ); const schemaView = shallow( number; hideHostname: boolean; expandResponses: { [code: string]: boolean } | 'all'; + requiredPropsFirst: boolean; constructor(raw: RedocRawOptions) { this.theme = { ...(raw.theme || {}), ...defaultTheme }; // todo: merge deep this.scrollYOffset = RedocNormalizedOptions.normalizeScrollYOffset(raw.scrollYOffset); this.hideHostname = RedocNormalizedOptions.normalizeHideHostname(raw.hideHostname); this.expandResponses = RedocNormalizedOptions.normalizeExpandResponses(raw.expandResponses); + this.requiredPropsFirst = argValueToBoolean(raw.requiredPropsFirst); } static normalizeExpandResponses(value: RedocRawOptions['expandResponses']) { @@ -32,12 +41,12 @@ export class RedocNormalizedOptions { res[code.trim()] = true; }); return res; - } else { + } else if (value !== undefined) { console.warn( `expandResponses must be a string but received value "${value}" of type ${typeof value}`, ); - return {}; } + return {}; } static normalizeHideHostname(value: RedocRawOptions['hideHostname']): boolean { diff --git a/src/services/__tests__/models/Response.test.ts b/src/services/__tests__/models/Response.test.ts index 8965eecc..5f433012 100644 --- a/src/services/__tests__/models/Response.test.ts +++ b/src/services/__tests__/models/Response.test.ts @@ -1,6 +1,8 @@ import { ResponseModel } from '../../models/Response'; import { OpenAPIParser } from '../../OpenAPIParser'; +import { RedocNormalizedOptions } from '../../RedocNormalizedOptions'; +const opts = new RedocNormalizedOptions({}); describe('Models', () => { describe('ResponseModel', () => { let parser; @@ -10,23 +12,23 @@ describe('Models', () => { }); test('should calculate response type based on code', () => { - let resp = new ResponseModel(parser, '200', false, {}); + let resp = new ResponseModel(parser, '200', false, {}, opts); expect(resp.type).toEqual('success'); - resp = new ResponseModel(parser, '120', false, {}); + resp = new ResponseModel(parser, '120', false, {}, opts); expect(resp.type).toEqual('info'); - resp = new ResponseModel(parser, '301', false, {}); + resp = new ResponseModel(parser, '301', false, {}, opts); expect(resp.type).toEqual('redirect'); - resp = new ResponseModel(parser, '400', false, {}); + resp = new ResponseModel(parser, '400', false, {}, opts); expect(resp.type).toEqual('error'); }); test('default should be sucessful by default', () => { - let resp = new ResponseModel(parser, 'default', false, {}); + let resp = new ResponseModel(parser, 'default', false, {}, opts); expect(resp.type).toEqual('success'); }); test('default should be error if defaultAsError is true', () => { - let resp = new ResponseModel(parser, 'default', true, {}); + let resp = new ResponseModel(parser, 'default', true, {}, opts); expect(resp.type).toEqual('error'); }); }); diff --git a/src/services/models/Field.ts b/src/services/models/Field.ts index f4d9c783..85e9d105 100644 --- a/src/services/models/Field.ts +++ b/src/services/models/Field.ts @@ -1,6 +1,7 @@ import { observable, action } from 'mobx'; import { OpenAPIParameter, Referenced } from '../../types'; +import { RedocNormalizedOptions } from '../RedocNormalizedOptions'; import { SchemaModel } from './Schema'; import { OpenAPIParser } from '../OpenAPIParser'; @@ -19,13 +20,18 @@ export class FieldModel { public deprecated: boolean; public in?: string; - constructor(parser: OpenAPIParser, infoOrRef: Referenced, pointer: string) { + constructor( + parser: OpenAPIParser, + infoOrRef: Referenced, + pointer: string, + options: RedocNormalizedOptions, + ) { const info = parser.deref(infoOrRef); this.name = info.name; this.in = info.in; this.required = !!info.required; - this.schema = new SchemaModel(parser, info.schema, pointer + '/schema'); + this.schema = new SchemaModel(parser, info.schema || {}, pointer + '/schema', options); this.description = info.description === undefined ? this.schema.description || '' : info.description; const example = info.example || this.schema.example; diff --git a/src/services/models/MediaContent.ts b/src/services/models/MediaContent.ts index 07c8fb8f..e943755a 100644 --- a/src/services/models/MediaContent.ts +++ b/src/services/models/MediaContent.ts @@ -3,6 +3,7 @@ import { observable, action, computed } from 'mobx'; import { OpenAPIMediaType } from '../../types'; import { MediaTypeModel } from './MediaType'; +import { RedocNormalizedOptions } from '../RedocNormalizedOptions'; import { OpenAPIParser } from '../OpenAPIParser'; /** @@ -20,13 +21,14 @@ export class MediaContentModel { constructor( public parser: OpenAPIParser, info: { [mime: string]: OpenAPIMediaType }, - public isRequestType: boolean = false, + public isRequestType: boolean, + options: RedocNormalizedOptions, ) { this.mediaTypes = Object.keys(info).map(name => { const mime = info[name]; // reset deref cache just in case something is left there parser.resetVisited(); - return new MediaTypeModel(parser, name, isRequestType, mime); + return new MediaTypeModel(parser, name, isRequestType, mime, options); }); } diff --git a/src/services/models/MediaType.ts b/src/services/models/MediaType.ts index 8d2320ff..00c12f8f 100644 --- a/src/services/models/MediaType.ts +++ b/src/services/models/MediaType.ts @@ -2,6 +2,7 @@ import * as Sampler from 'openapi-sampler'; import { OpenAPIExample, OpenAPIMediaType } from '../../types'; import { SchemaModel } from './Schema'; +import { RedocNormalizedOptions } from '../RedocNormalizedOptions'; import { mapValues, isJsonLike } from '../../utils'; import { OpenAPIParser } from '../OpenAPIParser'; @@ -16,10 +17,16 @@ export class MediaTypeModel { /** * @param isRequestType needed to know if skipe RO/RW fields in objects */ - constructor(parser: OpenAPIParser, name: string, isRequestType: boolean, info: OpenAPIMediaType) { + constructor( + parser: OpenAPIParser, + name: string, + isRequestType: boolean, + info: OpenAPIMediaType, + options: RedocNormalizedOptions, + ) { this.name = name; this.isRequestType = isRequestType; - this.schema = info.schema && new SchemaModel(parser, info.schema, ''); + this.schema = info.schema && new SchemaModel(parser, info.schema, '', options); if (info.examples !== undefined) { this.examples = mapValues(info.examples, example => new ExampleModel(parser, example)); } else if (info.example !== undefined) { diff --git a/src/services/models/Operation.ts b/src/services/models/Operation.ts index 1c806158..e2dd6fe6 100644 --- a/src/services/models/Operation.ts +++ b/src/services/models/Operation.ts @@ -71,12 +71,12 @@ export class OperationModel implements IMenuItem { this.deprecated = !!operationSpec.deprecated; this.operationId = operationSpec.operationId; this.requestBody = - operationSpec.requestBody && new RequestBodyModel(parser, operationSpec.requestBody); + operationSpec.requestBody && new RequestBodyModel(parser, operationSpec.requestBody, options); this.codeSamples = operationSpec['x-code-samples'] || []; this.path = JsonPointer.baseName(this._$ref, 2); this.parameters = (operationSpec.parameters || []).map( - paramOrRef => new FieldModel(parser, paramOrRef, this._$ref), + paramOrRef => new FieldModel(parser, paramOrRef, this._$ref, options), ); let hasSuccessResponses = false; diff --git a/src/services/models/RequestBody.ts b/src/services/models/RequestBody.ts index dde14ce6..f9ed68f0 100644 --- a/src/services/models/RequestBody.ts +++ b/src/services/models/RequestBody.ts @@ -2,19 +2,24 @@ import { OpenAPIRequestBody, Referenced } from '../../types'; import { MediaContentModel } from './MediaContent'; import { OpenAPIParser } from '../OpenAPIParser'; +import { RedocNormalizedOptions } from '../RedocNormalizedOptions'; export class RequestBodyModel { description: string; required: boolean; content?: MediaContentModel; - constructor(parser: OpenAPIParser, infoOrRef: Referenced) { + constructor( + parser: OpenAPIParser, + infoOrRef: Referenced, + options: RedocNormalizedOptions, + ) { const info = parser.deref(infoOrRef); this.description = info.description || ''; this.required = !!info.required; parser.exitRef(infoOrRef); if (info.content !== undefined) { - this.content = new MediaContentModel(parser, info.content, true); + this.content = new MediaContentModel(parser, info.content, true, options); } } } diff --git a/src/services/models/Response.ts b/src/services/models/Response.ts index 7691ee23..066e10a6 100644 --- a/src/services/models/Response.ts +++ b/src/services/models/Response.ts @@ -30,7 +30,7 @@ export class ResponseModel { parser.exitRef(infoOrRef); this.code = code; if (info.content !== undefined) { - this.content = new MediaContentModel(parser, info.content, false); + this.content = new MediaContentModel(parser, info.content, false, options); } this.description = info.description || ''; this.type = getStatusCodeType(code, defaultAsError); @@ -39,7 +39,7 @@ export class ResponseModel { if (headers !== undefined) { this.headers = Object.keys(headers).map(name => { const header = headers[name]; - return new FieldModel(parser, { ...header, name }, ''); + return new FieldModel(parser, { ...header, name }, '', options); }); } } diff --git a/src/services/models/Schema.ts b/src/services/models/Schema.ts index 1929460b..6373a8a9 100644 --- a/src/services/models/Schema.ts +++ b/src/services/models/Schema.ts @@ -1,10 +1,11 @@ -import { MergedOpenAPISchema } from '../'; import { observable, action } from 'mobx'; import { OpenAPISchema, Referenced } from '../../types'; import { FieldModel } from './Field'; import { OpenAPIParser } from '../OpenAPIParser'; +import { RedocNormalizedOptions } from '../RedocNormalizedOptions'; + import { detectType, humanizeConstraints, @@ -12,6 +13,7 @@ import { isPrimitiveType, JsonPointer, } from '../../utils/'; +import { MergedOpenAPISchema } from '../'; // TODO: refactor this model, maybe use getters instead of copying all the values export class SchemaModel { @@ -55,14 +57,11 @@ export class SchemaModel { */ constructor( parser: OpenAPIParser, - schemaOrRef?: Referenced, - $ref?: string, + schemaOrRef: Referenced, + $ref: string, + private options: RedocNormalizedOptions, isChild: boolean = false, ) { - if (schemaOrRef === undefined) { - return; - } - this._$ref = schemaOrRef.$ref || $ref || ''; this.rawSchema = parser.deref(schemaOrRef); this.schema = parser.mergeAllOf(this.rawSchema, this._$ref, isChild); @@ -133,9 +132,9 @@ export class SchemaModel { } if (this.type === 'object') { - this.fields = buildFields(parser, schema, this._$ref); + this.fields = buildFields(parser, schema, this._$ref, this.options); } else if (this.type === 'array' && schema.items) { - this.items = new SchemaModel(parser, schema.items, this._$ref + '/items'); + this.items = new SchemaModel(parser, schema.items, this._$ref + '/items', this.options); this.displayType = this.items.displayType; this.typePrefix = this.items.typePrefix + 'Array of '; this.isPrimitive = this.items.isPrimitive; @@ -149,7 +148,7 @@ export class SchemaModel { this.oneOf = oneOf!.map( (variant, idx) => // TODO: merge base schema into each oneOf - new SchemaModel(parser, variant, this._$ref + '/oneOf/' + idx), + new SchemaModel(parser, variant, this._$ref + '/oneOf/' + idx, this.options), ); this.displayType = this.oneOf.map(schema => schema.displayType).join(' or '); } @@ -178,15 +177,20 @@ export class SchemaModel { const refs = Object.keys(derived); this.oneOf = refs.map(ref => { - const schema = new SchemaModel(parser, parser.byRef(ref)!, ref, true); + const schema = new SchemaModel(parser, parser.byRef(ref)!, ref, this.options, true); schema.title = derived[ref]; return schema; }); } } -function buildFields(parser: OpenAPIParser, schema: OpenAPISchema, $ref: string): FieldModel[] { - const props = schema.properties || []; +function buildFields( + parser: OpenAPIParser, + schema: OpenAPISchema, + $ref: string, + options: RedocNormalizedOptions, +): FieldModel[] { + const props = schema.properties || {}; const additionalProps = schema.additionalProperties; const defaults = schema.default || {}; const fields = Object.keys(props || []).map(fieldName => { @@ -204,9 +208,14 @@ function buildFields(parser: OpenAPIParser, schema: OpenAPISchema, $ref: string) }, }, $ref + '/properties/' + fieldName, + options, ); }); + if (options.requiredPropsFirst) { + sortFields(fields, schema.required); + } + if (typeof additionalProps === 'object') { fields.push( new FieldModel( @@ -217,9 +226,24 @@ function buildFields(parser: OpenAPIParser, schema: OpenAPISchema, $ref: string) schema: additionalProps, }, $ref + '/additionalProperties', + options, ), ); } return fields; } + +function sortFields(fields: FieldModel[], order: string[] = []) { + fields.sort((a, b) => { + if (!a.required && b.required) { + return 1; + } else if (a.required && !b.required) { + return -1; + } else if (a.required && b.required) { + return order.indexOf(a.name) > order.indexOf(b.name) ? 1 : -1; + } else { + return 0; + } + }); +}