UNPKG

@mintlify/validation

Version:

Validates mint.json files

554 lines (553 loc) 28.2 kB
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; };