@goatlab/fluent
Version:
Readable query Interface & API generator for TS and Node
449 lines • 16 kB
JavaScript
;
// Copyright IBM Corp. 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
Object.defineProperty(exports, "__esModule", { value: true });
exports.JSON_SCHEMA_KEY = void 0;
exports.buildModelCacheKey = buildModelCacheKey;
exports.getJsonSchema = getJsonSchema;
exports.getJsonSchemaRef = getJsonSchemaRef;
exports.stringTypeToWrapper = stringTypeToWrapper;
exports.isArrayType = isArrayType;
exports.metaToJsonProperty = metaToJsonProperty;
exports.getNavigationalPropertyForRelation = getNavigationalPropertyForRelation;
exports.modelToJsonSchema = modelToJsonSchema;
const node_util_1 = require("node:util");
const metadata_1 = require("@loopback/metadata");
const metadata_2 = require("./metadata");
const type_resolver_1 = require("./type-resolver");
exports.JSON_SCHEMA_KEY = metadata_1.MetadataAccessor.create('loopback:json-schema');
const debug = console.log;
/**
* @internal
*/
function buildModelCacheKey(options = {}) {
// 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
*/
function getJsonSchema(ctor, options) {
// In the near future the metadata will be an object with
// different titles as keys
const cached = metadata_1.MetadataInspector.getClassMetadata(exports.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
metadata_1.MetadataInspector.defineMetadata(exports.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
*/
function getJsonSchemaRef(modelCtor, options) {
const schemaWithDefinitions = getJsonSchema(modelCtor, options);
const key = schemaWithDefinitions.title;
// ctor is not a model
if (!key) {
return schemaWithDefinitions;
}
const definitions = { ...schemaWithDefinitions.definitions };
const schema = { ...schemaWithDefinitions };
schema.definitions = undefined;
definitions[key] = schema;
return {
$ref: `#/definitions/${key}`,
definitions
};
}
/**
* Gets the wrapper function of primitives string, number, and boolean
* @param type - Name of type
*/
function stringTypeToWrapper(type) {
if (typeof type === 'function') {
return type;
}
const lowerType = type.toLowerCase();
let wrapper;
switch (lowerType) {
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 'buffer': {
wrapper = Buffer;
break;
}
case 'null': {
wrapper = type_resolver_1.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
*/
function isArrayType(type) {
return type === Array || type === 'array';
}
/**
* Converts property metadata into a JSON property definition
* @param meta
*/
function metaToJsonProperty(meta) {
const propDef = {};
let result;
let propertyType = meta.type;
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;
}
else {
result = propDef;
}
const wrappedType = stringTypeToWrapper(propertyType);
const resolvedType = (0, type_resolver_1.resolveType)(wrappedType);
if (resolvedType === Date) {
Object.assign(propDef, {
type: 'string',
format: 'date-time'
});
}
else if (propertyType === 'any') {
// no-op, the json schema for any type is {}
}
else if ((0, type_resolver_1.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
*/
function getNavigationalPropertyForRelation(relMeta, targetRef) {
if (relMeta.targetsMany === true) {
// Targets an array of object, like, hasMany
return {
type: 'array',
items: targetRef
};
}
if (relMeta.targetsMany === false) {
// Targets single object, like, hasOne, belongsTo
return targetRef;
}
// targetsMany is undefined or null
// not allowed if includeRelations is true
throw new Error(`targetsMany attribute missing for ${relMeta.name}`);
}
function buildSchemaTitle(ctor, meta, options) {
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(options = {}) {
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 = {}) {
return (0, node_util_1.inspect)(modelSettings, {
depth: Number.POSITIVE_INFINITY,
maxArrayLength: Number.POSITIVE_INFINITY,
breakLength: Number.POSITIVE_INFINITY
});
}
function isEmptyJson(obj) {
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(typeName, rawOptions = {}) {
const options = { ...rawOptions };
options.visited = undefined;
if (options.optional && !options.optional.length) {
options.optional = undefined;
}
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
*/
function modelToJsonSchema(ctor, jsonSchemaOptions = {}) {
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');
options.partial = undefined;
}
debug('Creating schema for model %s', ctor.name);
debug('JSON schema options: %o', options);
const modelDef = metadata_2.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;
debug('Model settings', meta.settings);
const title = buildSchemaTitle(ctor, meta, options);
if (options.visited[title]) {
return options.visited[title];
}
const result = { 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)) {
debug('Property % is excluded by %s', p, options.exclude);
continue;
}
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 = { ...meta.properties[p] };
if (!metaProperty.type) {
throw new Error(`Property ${p} does not have a type`);
}
// populating "properties" key
result.properties[p] = metaToJsonProperty(metaProperty);
// handling 'required' metadata
const optional = options.optional.includes(p);
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 = (0, type_resolver_1.resolveType)(metaProperty.type);
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
(0, type_resolver_1.resolveType)(metaProperty.itemType)
: resolvedType;
if (typeof referenceType !== 'function' || (0, type_resolver_1.isBuiltinType)(referenceType)) {
continue;
}
const propOptions = { ...options };
if (propOptions.partial !== 'deep') {
// Do not cascade `partial` to nested properties
propOptions.partial = undefined;
}
if (propOptions.includeRelations === true) {
// Do not cascade `includeRelations` to nested properties
propOptions.includeRelations = undefined;
}
// `title` is the unique identity of a schema,
// it should be removed from the `options`
// when generating the relation or property schemas
propOptions.title = undefined;
const propSchema = getJsonSchema(referenceType, propOptions);
// JSONSchema6Definition allows both boolean and JSONSchema6 types
if (typeof result.properties[p] !== 'boolean') {
const prop = result.properties[p];
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];
if (!relMeta) {
continue;
}
const targetType = (0, type_resolver_1.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 };
targetOptions.title = undefined;
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;
includeReferencedSchema(targetSchema.title, targetSchema);
}
}
function includeReferencedSchema(name, schema) {
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];
}
schema.definitions = undefined;
}
if (result !== schema) {
result.definitions = result.definitions ?? {};
result.definitions[name] = schema;
}
}
if (meta.jsonSchema) {
Object.assign(result, meta.jsonSchema);
}
return result;
}
//# sourceMappingURL=build-schema.js.map