@mintlify/validation
Version:
Validates mint.json files
554 lines (553 loc) • 28.2 kB
JavaScript
import lcm from 'lcm';
import _ from 'lodash';
import { BaseConverter } from './BaseConverter.js';
import { ConversionError, ImpossibleSchemaError, InvalidSchemaError } from './errors.js';
import { addKeyIfDefined, copyExampleIfDefined, copyKeyIfDefined, stringFileFormats, structuredDataContentTypes, } from './utils.js';
export class SchemaConverter extends BaseConverter {
constructor(schema, required, path = ['#'], location, contentType, safeParse = false) {
super(safeParse);
this.schema = schema;
this.required = required;
this.path = path;
this.location = location;
this.contentType = contentType;
this.safeParse = safeParse;
}
/**
* This function converts the `schema` property into a `DataSchemaArray`. Due to
* the recursive nature of OpenAPI schemas, this conversion happens in two parts:
*
* 1. **Reduction**\*: The schema is transformed into its *reduced form*. In this form,
* the schema and any subschemas are represented by a schema with one property, `oneOf`,
* whose items are guaranteed NOT to have a `oneOf`, `anyOf`, or `allOf` property.
*
* 2. **Conversion**: In this step, we take a schema in its reduced form and convert it
* into a new data type. This is fairly straightforward, as we just need to convert
* each element of each `oneOf` schema to a `DataSchema`, and do this for all subschemas.
*
* \*We call this step a reduction rather than a conversion because the result is still
* of the `OpenAPIV3_1.SchemaObject` type.
*
* @returns An array of `DataSchema` objects representing all valid schemas
*/
convert() {
if (this.schema === undefined) {
// If the content type can feasibly be interpreted as a file (i.e. if it does NOT start
// with "application/json" or another structured data format), return a file type.
if (this.contentType &&
!structuredDataContentTypes.some((type) => { var _a; return (_a = this.contentType) === null || _a === void 0 ? void 0 : _a.startsWith(type); })) {
return [{ type: 'file', contentMediaType: this.contentType }];
}
this.handleNewError(InvalidSchemaError, this.path, 'schema undefined');
return [{ type: 'any' }];
}
try {
// TODO(ronan): remove when fully migrated to endpoint type, or don't modify schema in evaluateCompositionsRecursive
let schema = _.cloneDeep(this.schema);
// REDUCTION
schema = this.reduceCompositionsRecursive(this.path, schema);
// CONVERSION
return this.convertSchemaRecursive(this.path, schema, this.required);
}
catch (error) {
this.handleExistingError(error, this.path, 'error converting top-level schema');
return [{ type: 'any' }];
}
}
/**
* This function should be used to reduce strictly `oneOf` and `anyOf` compositions.
*
* @param schemaArray `schema.allOf` or `schema.oneOf`
* @returns a schema array equivalent to the `schemaArray` argument, but in reduced form
*/
reduceOptionsCompositions(path, schemaArray) {
const evaluatedArray = schemaArray.flatMap((subschema, i) => {
var _a;
try {
return (_a = this.reduceCompositionsRecursive([...path, i.toString()], subschema).oneOf) !== null && _a !== void 0 ? _a : [];
}
catch (error) {
if (error instanceof ImpossibleSchemaError) {
return [];
}
else {
throw error;
}
}
});
if (evaluatedArray.length === 0) {
throw new ImpossibleSchemaError(path, 'no valid options in schema:', JSON.stringify(schemaArray, undefined, 2));
}
return evaluatedArray;
}
reduceCompositionsRecursive(path, schema) {
// reduce compositions first; we are currently ignoring `not`
if (schema.oneOf && schema.oneOf.length > 0) {
schema.oneOf = this.reduceOptionsCompositions([...path, 'oneOf'], schema.oneOf);
}
else {
schema.oneOf = [];
}
if (schema.anyOf && schema.anyOf.length > 0) {
schema.anyOf = this.reduceOptionsCompositions([...path, 'anyOf'], schema.anyOf);
}
if (schema.allOf && schema.allOf.length > 0) {
const totalAllOfObj = schema.allOf
.map((subschema, i) => this.reduceCompositionsRecursive([...path, 'allOf', i.toString()], subschema))
.reduce((schema1, schema2, i) => this.combineReducedSchemas([...path, 'allOf', i.toString()], schema1, schema2), {
oneOf: [],
});
schema.oneOf = this.multiplySchemaArrays(path, schema.oneOf, totalAllOfObj.oneOf);
}
// reduce subschemas, if present
if (schema.properties) {
for (const key in schema.properties) {
const subschema = schema.properties[key];
// remove readOnly and writeOnly properties BEFORE we combine anything
if ((subschema.readOnly && this.location === 'request') ||
(subschema.writeOnly && this.location === 'response')) {
delete schema.properties[key];
continue;
}
const propertyPath = [...path, 'properties', key];
try {
schema.properties[key] = this.reduceCompositionsRecursive(propertyPath, subschema);
}
catch (error) {
this.handleExistingError(error, propertyPath, 'error reducing property schema');
schema.properties[key] = { oneOf: [{}] }; // an "any" schema
}
}
}
if ('items' in schema && schema.items) {
const itemsPath = [...path, 'items'];
try {
schema.items = this.reduceCompositionsRecursive(itemsPath, schema.items);
}
catch (error) {
this.handleExistingError(error, itemsPath, 'error reducing items schema');
schema.items = { oneOf: [{}] }; // an "any" schema
}
}
if (schema.additionalProperties && typeof schema.additionalProperties === 'object') {
const addlPropsPath = [...path, 'additionalProperties'];
try {
schema.additionalProperties = this.reduceCompositionsRecursive(addlPropsPath, schema.additionalProperties);
}
catch (error) {
if (error instanceof ImpossibleSchemaError) {
// if additionalProperties schema is impossible, rather than error, just disallow additionalProperties
schema.additionalProperties = false;
}
else {
this.handleExistingError(error, addlPropsPath, 'error reducing additionalProperties schema');
schema.additionalProperties = true; // an "any" schema
}
}
}
if (schema.anyOf && schema.anyOf.length > 0) {
schema.oneOf = this.multiplySchemaArrays(path, schema.oneOf, schema.anyOf);
}
const topLevelSchemaArray = this.generateTopLevelSchemaArray(schema);
return { oneOf: this.multiplySchemaArrays(path, schema.oneOf, topLevelSchemaArray) };
}
generateTopLevelSchemaArray(schema) {
if (schema.nullable) {
const typedSchema = Object.assign({}, schema);
delete typedSchema.oneOf;
delete typedSchema.nullable;
const nullSchema = Object.assign({}, schema);
delete nullSchema.oneOf;
delete nullSchema.nullable;
nullSchema.type = 'null';
return [typedSchema, nullSchema];
}
if (Array.isArray(schema.type)) {
if (schema.type.length === 0) {
const topLevelSchema = Object.assign({}, schema);
delete topLevelSchema.oneOf;
delete topLevelSchema.type;
return [topLevelSchema];
}
return schema.type.map((typeString) => {
const topLevelSchema = Object.assign({}, schema);
delete topLevelSchema.oneOf;
topLevelSchema.type = typeString;
return topLevelSchema;
});
}
const topLevelSchema = Object.assign({}, schema);
delete topLevelSchema.oneOf;
return [topLevelSchema];
}
/**
* Given two arrays representing schema options, return an array representing schema options that satisfy one element in both arrays.
*
* It is helpful to think of each array as a union of all the schemas in the array. This function can then be thought of as taking
* the intersection of the two union types.
*
* Used in the Reduction step.
*
* @param a first array of schema options
* @param b second array of schema options
* @returns array of schemas that satisfy both arrays
*/
multiplySchemaArrays(path, a, b) {
if (a.length === 0 && b.length === 0) {
return [{}];
}
if (a.length === 0) {
return b;
}
if (b.length === 0) {
return a;
}
const product = a.flatMap((schema1) => {
return b.flatMap((schema2) => {
try {
const combinedSchema = this.combineTopLevelSchemas(path, schema1, schema2);
return [combinedSchema];
}
catch (error) {
if (error instanceof ImpossibleSchemaError) {
return [];
}
else {
throw error;
}
}
});
});
if (product.length === 0) {
throw new ImpossibleSchemaError(path, 'impossible schema combination:', 'schema array 1:', JSON.stringify(a, undefined, 2), 'schema array 2:', JSON.stringify(b, undefined, 2));
}
return product;
}
combineReducedSchemas(path, schema1, schema2) {
var _a, _b;
return {
oneOf: this.multiplySchemaArrays(path, ((_a = schema1.oneOf) !== null && _a !== void 0 ? _a : []), ((_b = schema2.oneOf) !== null && _b !== void 0 ? _b : [])),
};
}
combineTopLevelSchemas(path, schema1, schema2) {
var _a, _b;
let type1 = schema1.type;
let type2 = schema2.type;
// don't throw an error if number type is being constricted
if (type1 === 'integer' && type2 === 'number') {
type2 = 'integer';
}
else if (type1 === 'number' && type2 === 'integer') {
type1 = 'integer';
}
if (type1 && type2 && type1 !== type2) {
throw new ImpossibleSchemaError(path, `mismatched type in composition: "${type1}" "${type2}"`);
}
for (const schema of [schema1, schema2]) {
if (typeof schema.exclusiveMaximum === 'number') {
if (schema.maximum === undefined || schema.maximum >= schema.exclusiveMaximum) {
schema.maximum = schema.exclusiveMaximum;
schema.exclusiveMaximum = true;
}
else {
schema.exclusiveMaximum = undefined;
}
}
if (typeof schema.exclusiveMinimum === 'number') {
if (schema.minimum === undefined || schema.minimum <= schema.exclusiveMinimum) {
schema.minimum = schema.exclusiveMinimum;
schema.exclusiveMinimum = true;
}
else {
schema.exclusiveMinimum = undefined;
}
}
}
const combinedSchema = {
title: takeLast(schema1, schema2, 'title'),
description: takeLast(schema1, schema2, 'description'),
format: takeLast(schema1, schema2, 'format'),
multipleOf: combine(schema1, schema2, 'multipleOf', lcm),
maximum: combine(schema1, schema2, 'maximum', Math.min),
minimum: combine(schema1, schema2, 'minimum', Math.max),
maxLength: combine(schema1, schema2, 'maxLength', Math.min),
minLength: combine(schema1, schema2, 'minLength', Math.max),
maxItems: combine(schema1, schema2, 'maxItems', Math.min),
minItems: combine(schema1, schema2, 'minItems', Math.max),
maxProperties: combine(schema1, schema2, 'maxProperties', Math.min),
minProperties: combine(schema1, schema2, 'minProperties', Math.max),
required: combine(schema1, schema2, 'required', (a, b) => b.concat(a.filter((value) => !b.includes(value)))),
enum: combine(schema1, schema2, 'enum', (a, b) => b.filter((value) => a.includes(value))),
readOnly: schema1.readOnly && schema2.readOnly,
writeOnly: schema1.writeOnly && schema2.writeOnly,
deprecated: schema1.deprecated || schema2.deprecated,
};
combinedSchema.exclusiveMaximum =
(schema1.maximum === combinedSchema.maximum ? schema1.exclusiveMaximum : undefined) ||
(schema2.maximum === combinedSchema.maximum ? schema2.exclusiveMaximum : undefined);
combinedSchema.exclusiveMinimum =
(schema1.minimum === combinedSchema.minimum ? schema1.exclusiveMinimum : undefined) ||
(schema2.minimum === combinedSchema.minimum ? schema2.exclusiveMinimum : undefined);
// don't use coalesce operator, since null is a valid example
const example1 = ((_a = schema1.examples) === null || _a === void 0 ? void 0 : _a[0]) !== undefined ? schema1.examples[0] : schema1.example;
const example2 = ((_b = schema2.examples) === null || _b === void 0 ? void 0 : _b[0]) !== undefined ? schema2.examples[0] : schema2.example;
if (example1 && example2 && typeof example1 === 'object' && typeof example2 === 'object') {
combinedSchema.example = Object.assign(Object.assign({}, example1), example2);
}
else {
// don't use coalesce operator, since null is a valid example
combinedSchema.example = example2 !== undefined ? example2 : example1;
}
const type = type1 !== null && type1 !== void 0 ? type1 : type2;
if (type === 'array') {
return Object.assign({ type, items: this.combineReducedSchemas([...path, 'items'], 'items' in schema1 && schema1.items ? schema1.items : {}, 'items' in schema2 && schema2.items ? schema2.items : {}) }, combinedSchema);
}
if (schema1.properties && schema2.properties) {
const combinedProperties = Object.assign({}, schema1.properties);
Object.entries(schema2.properties).forEach(([property, schema]) => {
const schema1Property = combinedProperties[property];
if (schema1Property) {
combinedProperties[property] = this.combineReducedSchemas([...path, 'properties', property], schema1Property, schema);
}
else {
combinedProperties[property] = schema;
}
});
combinedSchema.properties = combinedProperties;
}
else if (schema1.properties || schema2.properties) {
combinedSchema.properties = Object.assign(Object.assign({}, schema1.properties), schema2.properties);
}
if (schema1.additionalProperties === false || schema2.additionalProperties === false) {
combinedSchema.additionalProperties = false;
}
else if (schema1.additionalProperties &&
typeof schema1.additionalProperties === 'object' &&
schema2.additionalProperties &&
typeof schema2.additionalProperties === 'object') {
combinedSchema.additionalProperties = this.combineReducedSchemas([...path, 'additionalProperties'], schema1.additionalProperties, schema2.additionalProperties);
}
else if (schema1.additionalProperties && typeof schema1.additionalProperties === 'object') {
combinedSchema.additionalProperties = schema1.additionalProperties;
}
else if (schema2.additionalProperties && typeof schema2.additionalProperties === 'object') {
combinedSchema.additionalProperties = schema2.additionalProperties;
}
return Object.assign({ type }, combinedSchema);
}
convertSchemaRecursive(path, schema, required) {
if (schema.oneOf === undefined || schema.oneOf.length === 0) {
throw new ConversionError(path, 'missing schema definition');
}
const schemaArray = schema.oneOf.map((schema) => {
const sharedProps = {};
addKeyIfDefined('required', required, sharedProps);
copyKeyIfDefined('title', schema, sharedProps);
copyKeyIfDefined('description', schema, sharedProps);
copyKeyIfDefined('readOnly', schema, sharedProps);
copyKeyIfDefined('writeOnly', schema, sharedProps);
copyKeyIfDefined('deprecated', schema, sharedProps);
if (schema.type === undefined) {
const inferredType = inferType(schema);
if (inferredType === undefined) {
return Object.assign({ type: 'any' }, sharedProps);
}
schema.type = inferredType;
}
switch (schema.type) {
case 'boolean':
const booleanProps = sharedProps;
copyKeyIfDefined('default', schema, booleanProps);
copyKeyIfDefined('x-default', schema, booleanProps);
copyExampleIfDefined(schema, booleanProps);
return Object.assign({ type: schema.type }, booleanProps);
case 'number':
case 'integer':
if (schema.enum) {
const numberEnumProps = sharedProps;
copyKeyIfDefined('default', schema, numberEnumProps);
copyKeyIfDefined('x-default', schema, numberEnumProps);
copyExampleIfDefined(schema, numberEnumProps);
return Object.assign({ type: schema.type === 'number' ? 'enum<number>' : 'enum<integer>', enum: schema.enum.filter((option) => typeof option === 'number') }, numberEnumProps);
}
const numberProps = sharedProps;
copyKeyIfDefined('multipleOf', schema, numberProps);
copyKeyIfDefined('maximum', schema, numberProps);
copyKeyIfDefined('exclusiveMaximum', schema, numberProps);
copyKeyIfDefined('minimum', schema, numberProps);
copyKeyIfDefined('exclusiveMinimum', schema, numberProps);
copyKeyIfDefined('default', schema, numberProps);
copyKeyIfDefined('x-default', schema, numberProps);
copyExampleIfDefined(schema, numberProps);
return Object.assign({ type: schema.type }, numberProps);
case 'string':
if (schema.enum) {
const stringEnumProps = sharedProps;
copyKeyIfDefined('default', schema, stringEnumProps);
copyKeyIfDefined('x-default', schema, stringEnumProps);
copyExampleIfDefined(schema, stringEnumProps);
return Object.assign({ type: 'enum<string>', enum: schema.enum.filter((option) => typeof option === 'string') }, stringEnumProps);
}
if (schema.format && stringFileFormats.includes(schema.format)) {
const fileProps = sharedProps;
return Object.assign({ type: 'file', contentEncoding: schema.format }, fileProps);
}
const stringProps = sharedProps;
copyKeyIfDefined('format', schema, stringProps);
copyKeyIfDefined('pattern', schema, stringProps);
copyKeyIfDefined('maxLength', schema, stringProps);
copyKeyIfDefined('minLength', schema, stringProps);
copyKeyIfDefined('default', schema, stringProps);
copyKeyIfDefined('x-default', schema, stringProps);
copyKeyIfDefined('const', schema, stringProps);
copyExampleIfDefined(schema, stringProps);
return Object.assign({ type: schema.type }, stringProps);
case 'array':
const arrayProps = sharedProps;
let items = undefined;
const itemsPath = [...path, 'items'];
try {
items =
// validator allows items to be null
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
typeof schema.items === 'object' && schema.items != null
? this.convertSchemaRecursive(itemsPath, schema.items)
: [{ type: 'any' }];
}
catch (error) {
this.handleExistingError(error, itemsPath, 'error converting items schema');
items = [{ type: 'any' }];
}
copyKeyIfDefined('maxItems', schema, arrayProps);
copyKeyIfDefined('minItems', schema, arrayProps);
copyKeyIfDefined('uniqueItems', schema, arrayProps);
copyKeyIfDefined('default', schema, arrayProps);
copyKeyIfDefined('x-default', schema, arrayProps);
copyExampleIfDefined(schema, arrayProps);
return Object.assign({ type: schema.type, items }, arrayProps);
case 'object':
const properties = this.convertProperties([...path, 'properties'], schema.properties, schema.required);
let additionalProperties = undefined;
const addlPropsPath = [...path, 'additionalProperties'];
try {
additionalProperties =
// validator allows additionalProperties to be null
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
typeof schema.additionalProperties === 'object' && schema.additionalProperties != null
? this.convertSchemaRecursive(addlPropsPath, schema.additionalProperties)
: schema.additionalProperties;
}
catch (error) {
this.handleExistingError(error, addlPropsPath, 'error converting additionalProperties schema');
// don't need to assign additionalProperties because undefined = "any"
}
const objectProperties = sharedProps;
addKeyIfDefined('additionalProperties', additionalProperties, objectProperties);
copyKeyIfDefined('maxProperties', schema, objectProperties);
copyKeyIfDefined('minProperties', schema, objectProperties);
copyKeyIfDefined('default', schema, objectProperties);
copyKeyIfDefined('x-default', schema, objectProperties);
copyExampleIfDefined(schema, objectProperties);
return Object.assign({ type: schema.type, properties }, objectProperties);
case 'null':
const nullProps = sharedProps;
copyKeyIfDefined('default', schema, nullProps);
copyKeyIfDefined('x-default', schema, nullProps);
copyExampleIfDefined(schema, nullProps);
return Object.assign({ type: schema.type }, nullProps);
default:
throw new InvalidSchemaError(path, `invalid schema type: ${schema.type}`);
}
});
if (!schemaArray[0]) {
throw new ConversionError(path, 'missing schema definition in position 0');
}
// must unpack first element to satisfy type
return [schemaArray[0], ...schemaArray.slice(1)];
}
convertProperties(path, properties, required) {
if (properties === undefined) {
return {};
}
const newEntries = Object.entries(properties).map(([name, schema]) => {
const propPath = [...path, name];
let convertedSchema;
try {
convertedSchema = this.convertSchemaRecursive(propPath, schema, (required === null || required === void 0 ? void 0 : required.includes(name)) ? true : undefined);
}
catch (error) {
this.handleExistingError(error, propPath, 'error converting property schema');
convertedSchema = [{ type: 'any' }];
}
return [name, convertedSchema];
});
return Object.fromEntries(newEntries);
}
static convert({ schema, required, path, location, contentType, safeParse, }) {
return new SchemaConverter(schema, required, path, location, contentType, safeParse).convert();
}
}
const takeLast = (schema1, schema2, key) => {
var _a;
return (_a = schema2[key]) !== null && _a !== void 0 ? _a : schema1[key];
};
const combine = (schema1, schema2, key, transform) => {
var _a;
return schema1[key] !== undefined && schema2[key] !== undefined
? transform(schema1[key], schema2[key])
: (_a = schema1[key]) !== null && _a !== void 0 ? _a : schema2[key];
};
/**
* Given an OpenAPI 3.1 schema, this function will attempt to determine the schema type
* based on the properties present in the schema. This is useful for assigning types to
* schemas that are missing a type.
*
* For example, if a schema has no type but has `schema.properties`, we can infer the
* intended type is `object`.
*
* Used in the Conversion step.
*
* @param schema
* @returns if exactly one type can be inferred, the string corresponding to that type; otherwise `undefined`
*/
export const inferType = (schema) => {
var _a, _b;
let type = undefined;
if (schema.format !== undefined ||
schema.pattern !== undefined ||
schema.minLength !== undefined ||
schema.maxLength !== undefined ||
((_a = schema.enum) === null || _a === void 0 ? void 0 : _a.every((option) => typeof option === 'string'))) {
type = 'string';
}
if (schema.multipleOf !== undefined ||
schema.minimum !== undefined ||
schema.maximum !== undefined ||
schema.exclusiveMinimum !== undefined ||
schema.exclusiveMaximum !== undefined ||
((_b = schema.enum) === null || _b === void 0 ? void 0 : _b.every((option) => typeof option === 'number'))) {
if (type !== undefined) {
return undefined;
}
type = 'number'; // less specific than 'integer'
}
if (('items' in schema && schema.items !== undefined) ||
schema.minItems !== undefined ||
schema.maxItems !== undefined ||
schema.uniqueItems !== undefined) {
if (type !== undefined) {
return undefined;
}
type = 'array';
}
if (schema.additionalProperties !== undefined ||
schema.properties !== undefined ||
schema.minProperties !== undefined ||
schema.maxProperties !== undefined) {
if (type !== undefined) {
return undefined;
}
type = 'object';
}
return type;
};