mirror of
https://github.com/Redocly/redoc.git
synced 2025-12-04 00:13:53 +03:00
447 lines
13 KiB
TypeScript
447 lines
13 KiB
TypeScript
import { action, observable, makeObservable } from 'mobx';
|
|
|
|
import { OpenAPIExternalDocumentation, OpenAPISchema, Referenced } from '../../types';
|
|
|
|
import { OpenAPIParser } from '../OpenAPIParser';
|
|
import { RedocNormalizedOptions } from '../RedocNormalizedOptions';
|
|
import { FieldModel } from './Field';
|
|
|
|
import { MergedOpenAPISchema } from '../';
|
|
import {
|
|
detectType,
|
|
extractExtensions,
|
|
humanizeConstraints,
|
|
isNamedDefinition,
|
|
isPrimitiveType,
|
|
JsonPointer,
|
|
pluralizeType,
|
|
sortByField,
|
|
sortByRequired,
|
|
} from '../../utils/';
|
|
|
|
import { l } from '../Labels';
|
|
|
|
// TODO: refactor this model, maybe use getters instead of copying all the values
|
|
export class SchemaModel {
|
|
pointer: string;
|
|
|
|
type: string;
|
|
displayType: string;
|
|
typePrefix: string = '';
|
|
title: string;
|
|
description: string;
|
|
externalDocs?: OpenAPIExternalDocumentation;
|
|
|
|
isPrimitive: boolean;
|
|
isCircular: boolean = false;
|
|
|
|
format?: string;
|
|
displayFormat?: string;
|
|
nullable: boolean;
|
|
deprecated: boolean;
|
|
pattern?: string;
|
|
example?: any;
|
|
enum: any[];
|
|
default?: any;
|
|
readOnly: boolean;
|
|
writeOnly: boolean;
|
|
|
|
constraints: string[];
|
|
|
|
private _fields?: FieldModel[];
|
|
items?: SchemaModel;
|
|
|
|
oneOf?: SchemaModel[];
|
|
oneOfType: string;
|
|
discriminatorProp: string;
|
|
@observable
|
|
activeOneOf: number = 0;
|
|
|
|
rawSchema: OpenAPISchema;
|
|
schema: MergedOpenAPISchema;
|
|
extensions?: Record<string, any>;
|
|
|
|
/**
|
|
* @param isChild if schema discriminator Child
|
|
* When true forces dereferencing in allOfs even if circular
|
|
*/
|
|
constructor(
|
|
private parser: OpenAPIParser,
|
|
schemaOrRef: Referenced<OpenAPISchema>,
|
|
pointer: string,
|
|
private options: RedocNormalizedOptions,
|
|
isChild: boolean = false,
|
|
private parent?: SchemaModel,
|
|
) {
|
|
makeObservable(this);
|
|
|
|
this.pointer = schemaOrRef.$ref || pointer || '';
|
|
this.rawSchema = parser.shallowDeref(schemaOrRef);
|
|
|
|
this.schema = parser.mergeAllOf(this.rawSchema, this.pointer, isChild);
|
|
this.init(parser, isChild);
|
|
parser.exitParents(this.schema);
|
|
|
|
if (options.showExtensions) {
|
|
this.extensions = extractExtensions(this.schema, options.showExtensions);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set specified alternative schema as active
|
|
* @param idx oneOf index
|
|
*/
|
|
@action
|
|
activateOneOf(idx: number) {
|
|
this.activeOneOf = idx;
|
|
}
|
|
|
|
get fields() {
|
|
if (this.isCircular) {
|
|
return undefined;
|
|
}
|
|
|
|
if (!this._fields && this.type === 'object') {
|
|
this._fields = buildFields(this.parser, this.schema, this.pointer, this.options, this);
|
|
}
|
|
return this._fields;
|
|
}
|
|
|
|
// check circular refs for lazy fields
|
|
hasCircularParent($refs: string[]) {
|
|
if (this.parent) {
|
|
const res = $refs.some($ref => {
|
|
const parent = this.parent!;
|
|
if (parent.pointer === $ref) return true;
|
|
if (parent.schema.parentRefs?.some?.(parentRef => parentRef === $ref)) return true;
|
|
})
|
|
if (res) return true;
|
|
if (this.parent.hasCircularParent($refs)) return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
init(parser: OpenAPIParser, isChild: boolean) {
|
|
const schema = this.schema;
|
|
this.isCircular = schema['x-circular-ref'] || this.hasCircularParent([this.pointer, ...(this.schema.parentRefs || [])]);
|
|
|
|
this.title =
|
|
schema.title || (isNamedDefinition(this.pointer) && JsonPointer.baseName(this.pointer)) || '';
|
|
this.description = schema.description || '';
|
|
this.type = schema.type || detectType(schema);
|
|
this.format = schema.format;
|
|
this.nullable = !!schema.nullable;
|
|
this.enum = schema.enum || [];
|
|
this.example = schema.example;
|
|
this.deprecated = !!schema.deprecated;
|
|
this.pattern = schema.pattern;
|
|
this.externalDocs = schema.externalDocs;
|
|
|
|
this.constraints = humanizeConstraints(schema);
|
|
this.displayType = this.type;
|
|
this.displayFormat = this.format;
|
|
this.isPrimitive = isPrimitiveType(schema, this.type);
|
|
this.default = schema.default;
|
|
this.readOnly = !!schema.readOnly;
|
|
this.writeOnly = !!schema.writeOnly;
|
|
|
|
if (this.isCircular) {
|
|
return;
|
|
}
|
|
|
|
if (!isChild && getDiscriminator(schema) !== undefined) {
|
|
this.initDiscriminator(schema, parser);
|
|
return;
|
|
} else if (
|
|
isChild &&
|
|
Array.isArray(schema.oneOf) &&
|
|
schema.oneOf.find((s) => s.$ref === this.pointer)
|
|
) {
|
|
// we hit allOf of the schema with the parent discriminator
|
|
delete schema.oneOf;
|
|
}
|
|
|
|
if (schema.oneOf !== undefined) {
|
|
this.initOneOf(schema.oneOf, parser);
|
|
this.oneOfType = 'One of';
|
|
if (schema.anyOf !== undefined) {
|
|
console.warn(
|
|
`oneOf and anyOf are not supported on the same level. Skipping anyOf at ${this.pointer}`,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (schema.anyOf !== undefined) {
|
|
this.initOneOf(schema.anyOf, parser);
|
|
this.oneOfType = 'Any of';
|
|
return;
|
|
}
|
|
|
|
if (this.type === 'array' && schema.items) {
|
|
this.items = new SchemaModel(parser, schema.items, this.pointer + '/items', this.options, false, this);
|
|
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) {
|
|
this.example = [this.items.example];
|
|
}
|
|
if (this.items.isPrimitive) {
|
|
this.enum = this.items.enum;
|
|
}
|
|
}
|
|
|
|
if (this.enum.length && this.options.sortEnumValuesAlphabetically) {
|
|
this.enum.sort();
|
|
}
|
|
}
|
|
|
|
private initOneOf(oneOf: OpenAPISchema[], parser: OpenAPIParser) {
|
|
this.oneOf = oneOf!.map((variant, idx) => {
|
|
const derefVariant = parser.deref(variant);
|
|
|
|
const merged = parser.mergeAllOf(derefVariant, this.pointer + '/oneOf/' + idx);
|
|
|
|
// try to infer title
|
|
const title =
|
|
isNamedDefinition(variant.$ref) && !merged.title
|
|
? JsonPointer.baseName(variant.$ref)
|
|
: merged.title;
|
|
|
|
const schema = new SchemaModel(
|
|
parser,
|
|
// merge base schema into each of oneOf's subschemas
|
|
{
|
|
// variant may already have allOf so merge it to not get overwritten
|
|
...merged,
|
|
title,
|
|
allOf: [{ ...this.schema, oneOf: undefined, anyOf: undefined }],
|
|
} as OpenAPISchema,
|
|
this.pointer + '/oneOf/' + idx,
|
|
this.options,
|
|
false,
|
|
this
|
|
);
|
|
|
|
parser.exitRef(variant);
|
|
// each oneOf should be independent so exiting all the parent refs
|
|
// otherwise it will cause false-positive recursive detection
|
|
parser.exitParents(merged);
|
|
|
|
return schema;
|
|
});
|
|
|
|
if (this.options.simpleOneOfTypeLabel) {
|
|
const types = collectUniqueOneOfTypesDeep(this);
|
|
this.displayType = types.join(' or ');
|
|
} else {
|
|
this.displayType = this.oneOf
|
|
.map((schema) => {
|
|
let name =
|
|
schema.typePrefix +
|
|
(schema.title ? `${schema.title} (${schema.displayType})` : schema.displayType);
|
|
if (name.indexOf(' or ') > -1) {
|
|
name = `(${name})`;
|
|
}
|
|
return name;
|
|
})
|
|
.join(' or ');
|
|
}
|
|
}
|
|
|
|
private initDiscriminator(
|
|
schema: OpenAPISchema & {
|
|
parentRefs?: string[];
|
|
},
|
|
parser: OpenAPIParser,
|
|
) {
|
|
const discriminator = getDiscriminator(schema)!;
|
|
this.discriminatorProp = discriminator.propertyName;
|
|
const implicitInversedMapping = parser.findDerived([
|
|
...(schema.parentRefs || []),
|
|
this.pointer,
|
|
]);
|
|
|
|
if (schema.oneOf) {
|
|
for (const variant of schema.oneOf) {
|
|
if (variant.$ref === undefined) {
|
|
continue;
|
|
}
|
|
const name = JsonPointer.baseName(variant.$ref);
|
|
implicitInversedMapping[variant.$ref] = name;
|
|
}
|
|
}
|
|
|
|
const mapping = discriminator.mapping || {};
|
|
|
|
// Defines if the mapping is exhaustive. This avoids having references
|
|
// that overlap with the mapping entries
|
|
let isLimitedToMapping = discriminator['x-explicitMappingOnly'] || false;
|
|
// if there are no mappings, assume non-exhaustive
|
|
if (Object.keys(mapping).length === 0) {
|
|
isLimitedToMapping = false;
|
|
}
|
|
|
|
const explicitInversedMapping = {};
|
|
for (const name in mapping) {
|
|
const $ref = mapping[name];
|
|
|
|
if (Array.isArray(explicitInversedMapping[$ref])) {
|
|
explicitInversedMapping[$ref].push(name);
|
|
} else {
|
|
// overrides implicit mapping here
|
|
explicitInversedMapping[$ref] = [name];
|
|
}
|
|
}
|
|
|
|
const inversedMapping = isLimitedToMapping
|
|
? { ...explicitInversedMapping }
|
|
: { ...implicitInversedMapping, ...explicitInversedMapping };
|
|
|
|
let refs: Array<{ $ref; name }> = [];
|
|
|
|
for (const $ref of Object.keys(inversedMapping)) {
|
|
const names = inversedMapping[$ref];
|
|
if (Array.isArray(names)) {
|
|
for (const name of names) {
|
|
refs.push({ $ref, name });
|
|
}
|
|
} else {
|
|
refs.push({ $ref, name: names });
|
|
}
|
|
}
|
|
|
|
// Make the listing respects the mapping
|
|
// in case a mapping is defined, the user usually wants to have the order shown
|
|
// as it was defined in the yaml. This will sort the names given the provided
|
|
// mapping (if provided).
|
|
// The logic is:
|
|
// - If a name is among the mapping, promote it to first
|
|
// - Names among the mapping are sorted by their order in the mapping
|
|
// - Names outside the mapping are sorted alphabetically
|
|
const names = Object.keys(mapping);
|
|
if (names.length !== 0) {
|
|
refs = refs.sort((left, right) => {
|
|
const indexLeft = names.indexOf(left.name);
|
|
const indexRight = names.indexOf(right.name);
|
|
|
|
if (indexLeft < 0 && indexRight < 0) {
|
|
// out of mapping, order by name
|
|
return left.name.localeCompare(right.name);
|
|
} else if (indexLeft < 0) {
|
|
// the right is found, so mapping wins
|
|
return 1;
|
|
} else if (indexRight < 0) {
|
|
// left wins as it's in mapping
|
|
return -1;
|
|
} else {
|
|
return indexLeft - indexRight;
|
|
}
|
|
});
|
|
}
|
|
|
|
this.oneOf = refs.map(({ $ref, name }) => {
|
|
const innerSchema = new SchemaModel(parser, parser.byRef($ref)!, $ref, this.options, true, this.parent);
|
|
innerSchema.title = name;
|
|
return innerSchema;
|
|
});
|
|
}
|
|
}
|
|
|
|
function buildFields(
|
|
parser: OpenAPIParser,
|
|
schema: OpenAPISchema,
|
|
$ref: string,
|
|
options: RedocNormalizedOptions,
|
|
parent?: SchemaModel
|
|
): FieldModel[] {
|
|
const props = schema.properties || {};
|
|
const additionalProps = schema.additionalProperties;
|
|
const defaults = schema.default || {};
|
|
let fields = Object.keys(props || []).map((fieldName) => {
|
|
let field = props[fieldName];
|
|
|
|
if (!field) {
|
|
console.warn(
|
|
`Field "${fieldName}" is invalid, skipping.\n Field must be an object but got ${typeof field} at "${$ref}"`,
|
|
);
|
|
field = {};
|
|
}
|
|
|
|
const required =
|
|
schema.required === undefined ? false : schema.required.indexOf(fieldName) > -1;
|
|
|
|
return new FieldModel(
|
|
parser,
|
|
{
|
|
name: fieldName,
|
|
required,
|
|
schema: {
|
|
...field,
|
|
default: field.default === undefined ? defaults[fieldName] : field.default,
|
|
},
|
|
},
|
|
$ref + '/properties/' + fieldName,
|
|
options,
|
|
parent
|
|
);
|
|
});
|
|
|
|
if (options.sortPropsAlphabetically) {
|
|
fields = sortByField(fields, 'name');
|
|
}
|
|
if (options.requiredPropsFirst) {
|
|
// if not sort alphabetically sort in the order from required keyword
|
|
fields = sortByRequired(fields, !options.sortPropsAlphabetically ? schema.required : undefined);
|
|
}
|
|
|
|
if (typeof additionalProps === 'object' || additionalProps === true) {
|
|
fields.push(
|
|
new FieldModel(
|
|
parser,
|
|
{
|
|
name: (typeof additionalProps === 'object'
|
|
? additionalProps['x-additionalPropertiesName'] || 'property name'
|
|
: 'property name'
|
|
).concat('*'),
|
|
required: false,
|
|
schema: additionalProps === true ? {} : additionalProps,
|
|
kind: 'additionalProperties',
|
|
},
|
|
$ref + '/additionalProperties',
|
|
options,
|
|
parent
|
|
),
|
|
);
|
|
}
|
|
|
|
return fields;
|
|
}
|
|
|
|
function getDiscriminator(schema: OpenAPISchema): OpenAPISchema['discriminator'] {
|
|
return schema.discriminator || schema['x-discriminator'];
|
|
}
|
|
|
|
function collectUniqueOneOfTypesDeep(schema: SchemaModel) {
|
|
const uniqueTypes = new Set();
|
|
|
|
function crawl(schema: SchemaModel) {
|
|
for (const oneOfType of schema.oneOf || []) {
|
|
if (oneOfType.oneOf) {
|
|
crawl(oneOfType);
|
|
continue;
|
|
}
|
|
|
|
if (oneOfType.type) {
|
|
uniqueTypes.add(oneOfType.type);
|
|
}
|
|
}
|
|
}
|
|
|
|
crawl(schema);
|
|
return Array.from(uniqueTypes.values());
|
|
}
|