UNPKG

@loopback/repository-json-schema

Version:

Converts TS classes into JSON Schemas using TypeScript's reflection API

650 lines (576 loc) 18.7 kB
// Copyright IBM Corp. and LoopBack contributors 2018,2020. All Rights Reserved. // Node module: @loopback/repository-json-schema // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT import {MetadataInspector} from '@loopback/core'; import { isBuiltinType, ModelDefinition, ModelMetadataHelper, Null, PropertyDefinition, PropertyType, RelationMetadata, resolveType, } from '@loopback/repository'; import debugFactory from 'debug'; import {inspect} from 'util'; import {JsonSchema} from './index'; import {JSON_SCHEMA_KEY} from './keys'; const debug = debugFactory('loopback:repository-json-schema:build-schema'); export interface JsonSchemaOptions<T extends object> { /** * The title to use in the generated schema. * * When using options like `exclude`, the auto-generated title can be * difficult to read for humans. Use this option to change the title to * a more meaningful value. */ title?: string; /** * Set this flag if you want the schema to define navigational properties * for model relations. */ includeRelations?: boolean; /** * Set this flag to mark all model properties as optional. This is typically * used to describe request body of PATCH endpoints. This option will be * overridden by the "optional" option if it is set and non-empty. * * The flag also applies to nested model instances if its value is set to * 'deep', such as: * * @example * ```ts * @model() * class Address { * @property() * street: string; * @property() * city: string; * @property() * state: string; * @property() * zipCode: string; * } * * @model() * class Customer { * @property() * address: Address; * } * * // The following schema allows properties of `customer` optional, but not * // `customer.address` * const schemaRef1 = getModelSchemaRef(Customer, {partial: true}); * * // The following schema allows properties of `customer` and * // `customer.address` optional * const schemaRef2 = getModelSchemaRef(Customer, {partial: 'deep'}); * ``` */ partial?: boolean | 'deep'; /** * List of model properties to exclude from the schema. */ exclude?: (keyof T)[]; /** * List of model properties to mark as optional. Overrides the "partial" * option if it is not empty. */ optional?: (keyof T)[]; /** * @internal */ visited?: {[key: string]: JsonSchema}; } /** * @internal */ export function buildModelCacheKey<T extends object>( options: JsonSchemaOptions<T> = {}, ): string { // Backwards compatibility: preserve cache key "modelOnly" if (Object.keys(options).length === 0) { return 'modelOnly'; } // New key schema: use the same suffix as we use for schema title // For example: "modelPartialWithRelations" // Note this new key schema preserves the old key "modelWithRelations" return 'model' + (options.title ?? '') + getTitleSuffix(options); } /** * Gets the JSON Schema of a TypeScript model/class by seeing if one exists * in a cache. If not, one is generated and then cached. * @param ctor - Constructor of class to get JSON Schema from */ export function getJsonSchema<T extends object>( ctor: Function & {prototype: T}, options?: JsonSchemaOptions<T>, ): JsonSchema { // In the near future the metadata will be an object with // different titles as keys const cached = MetadataInspector.getClassMetadata(JSON_SCHEMA_KEY, ctor, { ownMetadataOnly: true, }); const key = buildModelCacheKey(options); let schema = cached?.[key]; if (!schema) { // Create new json schema from model // if not found in cache for specific key schema = modelToJsonSchema(ctor, options); if (cached) { // Add a new key to the cached schema of the model cached[key] = schema; } else { // Define new metadata and set in cache MetadataInspector.defineMetadata( JSON_SCHEMA_KEY.key, {[key]: schema}, ctor, ); } } return schema; } /** * Describe the provided Model as a reference to a definition shared by multiple * endpoints. The definition is included in the returned schema. * * @example * * ```ts * const schema = { * $ref: '/definitions/Product', * definitions: { * Product: { * title: 'Product', * properties: { * // etc. * } * } * } * } * ``` * * @param modelCtor - The model constructor (e.g. `Product`) * @param options - Additional options */ export function getJsonSchemaRef<T extends object>( modelCtor: Function & {prototype: T}, options?: JsonSchemaOptions<T>, ): JsonSchema { const schemaWithDefinitions = getJsonSchema(modelCtor, options); const key = schemaWithDefinitions.title; // ctor is not a model if (!key) return schemaWithDefinitions; const definitions = Object.assign({}, schemaWithDefinitions.definitions); const schema = Object.assign({}, schemaWithDefinitions); delete schema.definitions; definitions[key] = schema; return { $ref: `#/definitions/${key}`, definitions, }; } /** * Gets the wrapper function of primitives string, number, and boolean * @param type - Name of type */ export function stringTypeToWrapper(type: string | Function): Function { if (typeof type === 'function') { return type; } type = type.toLowerCase(); let wrapper; switch (type) { case 'number': { wrapper = Number; break; } case 'string': { wrapper = String; break; } case 'boolean': { wrapper = Boolean; break; } case 'array': { wrapper = Array; break; } case 'object': case 'any': { wrapper = Object; break; } case 'date': { wrapper = Date; break; } case 'binary': case 'buffer': { wrapper = Buffer; break; } case 'null': { wrapper = Null; break; } default: { throw new Error('Unsupported type: ' + type); } } return wrapper; } /** * Determines whether a given string or constructor is array type or not * @param type - Type as string or wrapper */ export function isArrayType(type: string | Function | PropertyType) { return type === Array || type === 'array'; } /** * Converts property metadata into a JSON property definition * @param meta */ export function metaToJsonProperty(meta: PropertyDefinition): JsonSchema { const propDef: JsonSchema = {}; let result: JsonSchema; let propertyType = meta.type as string | Function; if (isArrayType(propertyType) && meta.itemType) { if (isArrayType(meta.itemType) && !meta.jsonSchema) { throw new Error( 'You must provide the "jsonSchema" field when define ' + 'a nested array property', ); } result = {type: 'array', items: propDef}; propertyType = meta.itemType as string | Function; } else { result = propDef; } const wrappedType = stringTypeToWrapper(propertyType); const resolvedType = resolveType(wrappedType); if (resolvedType === Date) { Object.assign(propDef, { type: 'string', format: 'date-time', }); } else if (propertyType === 'buffer') { Object.assign(propDef, { type: 'string', format: 'buffer', }); } else if (propertyType === 'Binary') { Object.assign(propDef, { type: 'string', format: 'binary', }); } else if (propertyType === 'any') { // no-op, the json schema for any type is {} } else if (isBuiltinType(resolvedType)) { Object.assign(propDef, { type: resolvedType.name.toLowerCase(), }); } else { Object.assign(propDef, {$ref: `#/definitions/${resolvedType.name}`}); } if (meta.description) { Object.assign(propDef, { description: meta.description, }); } if (meta.jsonSchema) { Object.assign(propDef, meta.jsonSchema); } return result; } /** * Checks and return navigational property definition for the relation * @param relMeta Relation metadata object * @param targetRef Schema definition for the target model */ export function getNavigationalPropertyForRelation( relMeta: RelationMetadata, targetRef: JsonSchema, ): JsonSchema { if (relMeta.targetsMany === true) { // Targets an array of object, like, hasMany return { type: 'array', items: targetRef, }; } else if (relMeta.targetsMany === false) { // Targets single object, like, hasOne, belongsTo return targetRef; } else { // targetsMany is undefined or null // not allowed if includeRelations is true throw new Error(`targetsMany attribute missing for ${relMeta.name}`); } } function buildSchemaTitle<T extends object>( ctor: Function & {prototype: T}, meta: ModelDefinition, options: JsonSchemaOptions<T>, ) { if (options.title) return options.title; const title = meta.title || ctor.name; return title + getTitleSuffix(options); } /** * Checks the options and generates a descriptive suffix using compatible chars * @param options json schema options */ function getTitleSuffix<T extends object>(options: JsonSchemaOptions<T> = {}) { let suffix = ''; if (options.optional?.length) { suffix += `Optional_${options.optional.join('-')}_`; } else if (options.partial) { suffix += 'Partial'; } if (options.exclude?.length) { suffix += `Excluding_${options.exclude.join('-')}_`; } if (options.includeRelations) { suffix += 'WithRelations'; } return suffix; } function stringifyOptions(modelSettings: object = {}) { return inspect(modelSettings, { depth: Infinity, maxArrayLength: Infinity, breakLength: Infinity, }); } function isEmptyJson(obj: object) { return !(obj && Object.keys(obj).length); } /** * Checks the options and generates a descriptive suffix that contains the * TypeScript type and options * @param typeName - TypeScript's type name * @param options - json schema options */ function getDescriptionSuffix<T extends object>( typeName: string, rawOptions: JsonSchemaOptions<T> = {}, ) { const options = {...rawOptions}; delete options.visited; if (options.optional && !options.optional.length) { delete options.optional; } const type = typeName; let tsType = type; if (options.includeRelations) { tsType = `${type}WithRelations`; } if (options.partial) { tsType = `Partial<${tsType}>`; } if (options.exclude) { const excludedProps = options.exclude.map(p => `'${String(p)}'`); tsType = `Omit<${tsType}, ${excludedProps.join(' | ')}>`; } if (options.optional) { const optionalProps = options.optional.map(p => `'${String(p)}'`); tsType = `@loopback/repository-json-schema#Optional<${tsType}, ${optionalProps.join( ' | ', )}>`; } return !isEmptyJson(options) ? `(tsType: ${tsType}, schemaOptions: ${stringifyOptions(options)})` : ''; } // NOTE(shimks) no metadata for: union, optional, nested array, any, enum, // string literal, anonymous types, and inherited properties /** * Converts a TypeScript class into a JSON Schema using TypeScript's * reflection API * @param ctor - Constructor of class to convert from */ export function modelToJsonSchema<T extends object>( ctor: Function & {prototype: T}, jsonSchemaOptions: JsonSchemaOptions<T> = {}, ): JsonSchema { const options = {...jsonSchemaOptions}; options.visited = options.visited ?? {}; options.optional = options.optional ?? []; const partial = options.partial && !options.optional.length; if (options.partial && !partial) { debug('Overriding "partial" option with "optional" option'); delete options.partial; } debug('Creating schema for model %s', ctor.name); debug('JSON schema options: %o', options); const modelDef = ModelMetadataHelper.getModelMetadata(ctor); // returns an empty object if metadata is an empty object if (modelDef == null || Object.keys(modelDef).length === 0) { return {}; } const meta = modelDef as ModelDefinition; debug('Model settings', meta.settings); const title = buildSchemaTitle(ctor, meta, options); if (options.visited[title]) return options.visited[title]; const result: JsonSchema = {title}; options.visited[title] = result; result.type = 'object'; const descriptionSuffix = getDescriptionSuffix(ctor.name, options); if (meta.description) { const formatSuffix = descriptionSuffix ? ` ${descriptionSuffix}` : ''; result.description = meta.description + formatSuffix; } else if (descriptionSuffix) { result.description = descriptionSuffix; } for (const p in meta.properties) { if (options.exclude?.includes(p as keyof T)) { debug('Property % is excluded by %s', p, options.exclude); continue; } const index = meta.properties[p].index; let indexInfo: {} = {}; if (index && Object.keys(index).length) { indexInfo = {[p]: index}; } if (indexInfo && Object.keys(indexInfo).length) { if (result.description === undefined) result.description = ''; if (result.description.includes('indexInfo')) { const indexInfoMatched = result.description.match(/\{"indexInfo".*$/s); if (indexInfoMatched) { const {indexInfo: existingIndexInfo} = JSON.parse( indexInfoMatched[0], ); existingIndexInfo[Object.keys(indexInfo)[0]] = { ...indexInfo, }; result.description = result.description.replace( /\{"indexInfo".*$/s, '', ); if (result.description) { result.description = result.description + `, ${JSON.stringify({indexInfo: existingIndexInfo})}`; } else { result.description = `${JSON.stringify({indexInfo: existingIndexInfo})}`; } } } else { if (result.description) { result.description = result.description + `, ${JSON.stringify({indexInfo})}`; } else { result.description = `${JSON.stringify({indexInfo})}`; } } } if (meta.properties[p].type == null) { // Circular import of model classes can lead to this situation throw new Error( `Property ${ctor.name}.${p} does not have "type" in its definition`, ); } result.properties = result.properties ?? {}; result.properties[p] = result.properties[p] || {}; const metaProperty = Object.assign({}, meta.properties[p]); // populating "properties" key result.properties[p] = metaToJsonProperty(metaProperty); // handling 'required' metadata const optional = options.optional.includes(p as keyof T); if (metaProperty.required && !(partial || optional)) { result.required = result.required ?? []; result.required.push(p); } // populating JSON Schema 'definitions' // shimks: ugly type casting; this should be replaced by logic to throw // error if itemType/type is not a string or a function const resolvedType = resolveType(metaProperty.type) as string | Function; const referenceType = isArrayType(resolvedType) ? // shimks: ugly type casting; this should be replaced by logic to throw // error if itemType/type is not a string or a function typeof metaProperty.itemType === 'string' ? resolveType(metaProperty.itemType) : resolveType(metaProperty.itemType) : resolvedType; if (typeof referenceType !== 'function' || isBuiltinType(referenceType)) { continue; } const propOptions = {...options}; if (propOptions.partial !== 'deep') { // Do not cascade `partial` to nested properties delete propOptions.partial; } if (propOptions.includeRelations === true) { // Do not cascade `includeRelations` to nested properties delete propOptions.includeRelations; } // `title` is the unique identity of a schema, // it should be removed from the `options` // when generating the relation or property schemas delete propOptions.title; // Do not cascade `exclude` to nested properties. delete propOptions.exclude; const propSchema = getJsonSchema(referenceType, propOptions); // JSONSchema6Definition allows both boolean and JSONSchema6 types if (typeof result.properties[p] !== 'boolean') { const prop = result.properties[p] as JsonSchema; const propTitle = propSchema.title ?? referenceType.name; const targetRef = {$ref: `#/definitions/${propTitle}`}; if (prop.type === 'array' && prop.items) { // Update $ref for array type prop.items = targetRef; } else { result.properties[p] = targetRef; } includeReferencedSchema(propTitle, propSchema); } } result.additionalProperties = meta.settings.strict === false; debug(' additionalProperties?', result.additionalProperties); if (options.includeRelations) { for (const r in meta.relations) { result.properties = result.properties ?? {}; const relMeta = meta.relations[r]; const targetType = resolveType(relMeta.target); // `title` is the unique identity of a schema, // it should be removed from the `options` // when generating the relation or property schemas const targetOptions = {...options}; delete targetOptions.title; const targetSchema = getJsonSchema(targetType, targetOptions); const targetRef = {$ref: `#/definitions/${targetSchema.title}`}; const propDef = getNavigationalPropertyForRelation(relMeta, targetRef); result.properties[relMeta.name] = result.properties[relMeta.name] || propDef; if ((relMeta as {keyFrom: string}).keyFrom) { result.properties.foreignKey = (relMeta as {keyFrom: string}) .keyFrom as JsonSchema; } includeReferencedSchema(targetSchema.title!, targetSchema); } } function includeReferencedSchema(name: string, schema: JsonSchema) { if (!schema || !Object.keys(schema).length) return; // promote nested definition to the top level if (result !== schema?.definitions) { for (const key in schema.definitions) { if (key === title) continue; result.definitions = result.definitions ?? {}; result.definitions[key] = schema.definitions[key]; } delete schema.definitions; } if (result !== schema) { result.definitions = result.definitions ?? {}; result.definitions[name] = schema; } } if (meta.jsonSchema) { Object.assign(result, meta.jsonSchema); } return result; }