@croct/content-model
Version:
A library for modeling, validating and interpolating structured content.
663 lines (662 loc) • 26.5 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ContentJsonSchemaGenerator = void 0;
const content_1 = require("../../content");
const definition_1 = require("../../definition");
const utils_1 = require("../../utils");
var Dispatcher = definition_1.ContentDefinition.Dispatcher;
/**
* A JSON schema generator.
*
* This visitor is stateful and should be used only once per resolution – reason why it is
* not exported.
*/
class ContentJsonSchemaVisitor extends Dispatcher {
constructor(configuration) {
super();
this.referencedDefinitions = new Map();
this.configuration = {
docs: configuration.docs ?? ContentJsonSchemaVisitor.DEFAULT_DOCS,
inlinePrimitives: configuration.inlinePrimitives ?? false,
relaxDynamicTextConstraints: configuration.relaxDynamicTextConstraints ?? false,
allowDynamicValues: configuration.allowDynamicValues ?? {},
maximumStringLength: configuration.maximumStringLength,
maximumListLength: configuration.maximumListLength,
includeExpressionType: configuration.includeExpressionType ?? false,
};
}
getReferencedDefinitions() {
return Object.fromEntries(this.referencedDefinitions.entries());
}
visitBoolean(_, __, nullable = false) {
return this.getPrimitiveValueSchema('boolean', nullable);
}
visitNumber(definition, _, nullable = false) {
const hasConstraints = definition.maximum !== undefined
|| definition.minimum !== undefined
|| definition.integer === true;
if (!hasConstraints) {
return this.getPrimitiveValueSchema('number', nullable);
}
const docs = this.configuration.docs.primitive;
const valueSchema = {
type: definition.integer === true ? 'integer' : 'number',
...(0, utils_1.clean)({
minimum: definition.minimum,
maximum: definition.maximum,
}),
};
const rules = this.configuration.allowDynamicValues.number ?? {};
const allowsDynamicValue = (definition.maximum === undefined || rules.maximum === true)
&& (definition.minimum === undefined || rules.minimum === true)
&& (definition.integer !== true || rules.integer === true);
if (allowsDynamicValue) {
return this.getPrimitiveValueInlineSchema('number', nullable, valueSchema);
}
return {
type: 'object',
title: docs.$title,
description: docs.$description,
properties: {
type: {
type: 'string',
const: 'number',
title: docs.type.$title,
description: docs.type.$description,
},
value: {
type: 'object',
title: docs.value.$title,
description: docs.value.$description,
properties: {
type: {
type: 'string',
const: 'static',
title: docs.value.type.$title,
description: docs.value.type.$description,
},
value: {
title: docs.value.static.value.$title,
description: docs.value.static.value.$description,
...valueSchema,
},
},
additionalProperties: false,
required: ['type', 'value'],
},
},
required: ['type', 'value'],
};
}
visitText(definition, _, nullable = false) {
const hasConstraints = definition.minimumLength !== undefined
|| definition.maximumLength !== undefined
|| definition.pattern !== undefined
|| definition.format !== undefined
|| definition.choices !== undefined;
if (!hasConstraints) {
return this.getPrimitiveValueSchema('text', nullable);
}
const docs = this.configuration.docs.primitive;
const maxLength = this.getTextMaxLength(definition.format, definition.maximumLength);
const minLength = definition.minimumLength !== undefined
? Math.min(definition.minimumLength, maxLength ?? definition.minimumLength)
: undefined;
const valueSchema = {
type: 'string',
...(0, utils_1.clean)({
maxLength: maxLength,
minLength: minLength,
format: definition.format,
pattern: definition.pattern,
enum: definition.choices !== undefined
? Object.keys(definition.choices)
: undefined,
}),
};
const rules = this.configuration.allowDynamicValues.text ?? {};
const allowsDynamicValue = (definition.minimumLength === undefined || rules.minimumLength === true)
&& (definition.maximumLength === undefined || rules.maximumLength === true)
&& (definition.pattern === undefined || rules.pattern === true)
&& (definition.format === undefined || rules.format === true)
&& (definition.choices === undefined || rules.choices === true);
if (allowsDynamicValue) {
return this.getPrimitiveValueInlineSchema('text', nullable, valueSchema);
}
return {
type: 'object',
title: docs.$title,
description: docs.$description,
properties: {
type: {
type: 'string',
const: 'text',
title: docs.type.$title,
description: docs.type.$description,
},
value: {
type: 'object',
title: docs.value.$title,
description: docs.value.$description,
properties: {
type: {
type: 'string',
const: 'static',
title: docs.value.type.$title,
description: docs.value.type.$description,
},
value: {
title: docs.value.static.value.$title,
description: docs.value.static.value.$description,
...valueSchema,
},
},
additionalProperties: false,
required: ['type', 'value'],
},
},
required: ['type', 'value'],
};
}
visitList(definition, path) {
const docs = this.configuration.docs.list;
const maxLengthCap = this.configuration.maximumListLength;
const maxLength = definition.maximumLength !== undefined
? Math.min(definition.maximumLength, maxLengthCap ?? definition.maximumLength)
: maxLengthCap;
const minLength = definition.minimumLength !== undefined
? Math.min(definition.minimumLength, maxLength ?? definition.minimumLength)
: undefined;
return {
type: 'object',
title: docs.$title,
description: docs.$description,
properties: {
type: {
const: 'list',
},
items: {
type: 'array',
title: docs.values.$title,
description: docs.values.$description,
items: this.visit(definition.items, path.joinedWith(['-'])),
...(0, utils_1.clean)({
maxItems: maxLength,
minItems: minLength,
}),
},
},
additionalProperties: false,
required: ['type', 'items'],
};
}
visitUnion(definition, path) {
return {
type: 'object',
properties: {
name: {
type: 'string',
enum: Object.keys(definition.types),
},
},
allOf: Object.entries(definition.types).map(([discriminator, subtype]) => ({
if: {
properties: {
name: {
const: discriminator,
},
},
},
then: subtype.type === 'structure'
? this.visitStructure(subtype, path, true)
: this.visitReference(subtype),
})),
unevaluatedProperties: false,
required: ['name'],
};
}
visitStructure(definition, path, allowAdditionalProperties = false) {
const attributes = {};
const required = [];
const docs = this.configuration.docs.structure;
for (const [name, attribute] of Object.entries(definition.attributes)) {
attributes[name] = {
title: docs.attributes.attribute.$title,
description: docs.attributes.attribute.$description,
...this.visitAttribute(attribute, path.joinedWith([name])),
...(0, utils_1.clean)({
title: attribute.label,
description: attribute.description,
}),
};
if (attribute.optional !== true) {
required.push(name);
}
}
return {
type: 'object',
title: definition.title ?? docs.$title,
description: definition.description ?? docs.$description,
properties: {
type: {
const: 'structure',
},
attributes: {
type: 'object',
title: docs.attributes.$title,
description: docs.attributes.$description,
...(Object.keys(attributes).length > 0 ? { properties: attributes } : null),
...(required.length > 0 ? { required: required } : null),
additionalProperties: false,
},
},
required: ['type', 'attributes'],
...(allowAdditionalProperties ? {} : { additionalProperties: false }),
};
}
visitAttribute(definition, path) {
if (definition.optional !== true) {
return this.visit(definition.type, path);
}
const attributeType = definition.type;
switch (attributeType.type) {
case 'boolean':
return this.visitBoolean(attributeType, path, true);
case 'number':
return this.visitNumber(attributeType, path, true);
case 'text':
return this.visitText(attributeType, path, true);
default:
return this.visit(attributeType, path);
}
}
visitReference(definition) {
return {
$ref: `#/$defs/external/${ContentJsonSchemaVisitor.getReferenceRefId(definition.id)}`,
};
}
/**
* Returns the JSON schema for a primitive value.
*
* @param type The primitive value type.
* @param nullable Whether the value is nullable.
*
* @returns The primitive schema or reference depending on the inlining option.
*/
getPrimitiveValueSchema(type, nullable = false) {
return this.configuration.inlinePrimitives
? this.getPrimitiveValueInlineSchema(type, nullable)
: this.getPrimitiveValueReference(type, nullable);
}
/**
* Returns the JSON schema reference for a primitive value.
*
* @param type The primitive value type.
* @param nullable Whether the value is nullable.
*
* @returns The primitive schema reference.
*/
getPrimitiveValueReference(type, nullable = false) {
const id = `${nullable ? 'nullable-' : ''}${type}`;
if (!this.referencedDefinitions.has(id)) {
this.referencedDefinitions.set(id, this.getPrimitiveValueInlineSchema(type, nullable));
}
return {
$ref: `#/$defs/internal/${id}`,
};
}
/**
* Returns the JSON schema for a primitive value.
*
* @param type The primitive value type.
* @param nullable Whether the value is nullable.
* @param schema The JSON type of the primitive value.
*
* @returns The primitive schema.
*/
getPrimitiveValueInlineSchema(type, nullable = false, schema = {}) {
const docs = this.configuration.docs.primitive;
const { type: typeName, ...valueSchema } = this.getPrimitiveDefaultValueSchema(type, schema);
const relaxDynamicTextConstraints = this.configuration.relaxDynamicTextConstraints && type === 'text';
return {
type: 'object',
title: docs.$title,
description: docs.$description,
properties: {
type: {
type: 'string',
const: type,
title: docs.type.$title,
description: docs.type.$description,
},
value: {
type: 'object',
title: docs.value.$title,
description: docs.value.$description,
properties: {
type: {
type: 'string',
enum: ['static', 'dynamic'],
title: docs.value.type.$title,
description: docs.value.type.$description,
},
},
allOf: [
{
type: 'object',
if: {
properties: {
type: {
type: 'string',
const: 'static',
},
},
},
then: {
properties: {
value: {
type: typeName,
title: docs.value.static.value.$title,
description: docs.value.static.value.$description,
...(relaxDynamicTextConstraints
? {
if: {
pattern: content_1.Placeholder.PATTERN.source,
},
then: true,
else: valueSchema,
}
: valueSchema),
},
},
required: ['value'],
},
},
{
type: 'object',
if: {
properties: {
type: {
type: 'string',
const: 'dynamic',
},
},
},
then: {
properties: {
nullable: {
type: 'boolean',
const: nullable,
title: docs.value.dynamic.nullable.$title,
description: docs.value.dynamic.nullable.$description,
},
expression: {
type: 'string',
title: docs.value.dynamic.expression.$title,
description: docs.value.dynamic.expression.$description,
...(this.configuration.includeExpressionType
? {
result: {
type: type,
nullable: nullable,
},
}
: {}),
},
default: {
type: nullable ? ['null', typeName] : typeName,
title: docs.value.dynamic.default.$title,
description: docs.value.dynamic.default.$description,
...(relaxDynamicTextConstraints
? {
if: {
pattern: content_1.Placeholder.PATTERN.source,
},
then: true,
else: valueSchema,
}
: valueSchema),
},
},
required: [
'expression',
nullable ? 'nullable' : 'default',
],
},
},
],
required: ['type'],
unevaluatedProperties: false,
},
},
required: ['type', 'value'],
additionalProperties: false,
};
}
getPrimitiveDefaultValueSchema(type, schema) {
switch (type) {
case 'text':
return this.getTextDefaultValueSchema(schema);
case 'number':
return this.getNumberDefaultValueSchema(schema);
case 'boolean':
return this.getBooleanDefaultValueSchema(schema);
}
}
getTextDefaultValueSchema(schema) {
const textSchema = {
type: schema.type ?? 'string',
...schema,
};
const maxLength = this.getTextMaxLength(textSchema.format, textSchema.maxLength);
if (maxLength !== undefined) {
textSchema.maxLength = maxLength;
}
return textSchema;
}
getNumberDefaultValueSchema(schema) {
const numberSchema = {
type: schema.type ?? 'number',
...schema,
};
if (numberSchema.minimum === undefined) {
numberSchema.minimum = Number.MIN_SAFE_INTEGER;
}
if (numberSchema.maximum === undefined) {
numberSchema.maximum = Number.MAX_SAFE_INTEGER;
}
return numberSchema;
}
getBooleanDefaultValueSchema(schema) {
return {
type: schema.type ?? 'boolean',
...schema,
};
}
static getReferenceRefId(referenceId) {
const readableName = referenceId.replace(/^[^a-z0-9]+|[^a-z0-9]+$/gi, '')
.replace(/[^a-z0-9]+/gi, '-');
const hash = this.getReferenceIdHash(referenceId);
return `${readableName}-${hash}`;
}
/**
* Returns a hash for a reference ID.
*
* The purpose of this hash is to act as a seed and decrease the
* likelihood of collisions among identifiers for reference ID.
*
* Although the result is not guaranteed to be unique for different types,
* but the collision probability is low enough for practical purposes.
*
* @param id The reference ID.
*/
static getReferenceIdHash(id) {
let hash = 0;
for (let index = 0; index < id.length; index++) {
// The resulting hash must be always positive
// to avoid minus sign in the identifier.
// The bitwise AND operation with 0x7FFFFFFF
// forces the result to be an always positive
// 32-bit signed integer by wrapping the result
// around the maximum value and dropping any leading bits.
hash = (hash * 31 + id.charCodeAt(index)) & 0x7FFFFFFF;
}
return hash.toString(16);
}
getTextMaxLength(format, maxLength) {
const formatMaxLength = this.getTextFormatMaxLength(format);
if (formatMaxLength !== undefined) {
return maxLength !== undefined
? Math.min(maxLength, formatMaxLength)
: formatMaxLength;
}
const { maximumStringLength } = this.configuration;
if (maxLength !== undefined && maximumStringLength !== undefined) {
return Math.min(maxLength, maximumStringLength.default);
}
return maxLength ?? maximumStringLength?.default;
}
getTextFormatMaxLength(format) {
const { maximumStringLength } = this.configuration;
if (maximumStringLength === undefined) {
return undefined;
}
switch (format) {
case 'multiline':
return maximumStringLength.multiline;
case 'url':
return maximumStringLength.url;
default:
return undefined;
}
}
}
/**
* The default documentation.
*/
ContentJsonSchemaVisitor.DEFAULT_DOCS = {
primitive: {
$title: 'Value definition',
$description: 'Defines the data type and value.',
type: {
$title: 'Data type',
$description: 'The data type of the resolved value.',
},
value: {
$title: 'Value',
$description: 'Defines how to resolve the value.',
type: {
$title: 'Value type',
$description: 'Defines whether the value is static or dynamically resolved at runtime.',
},
static: {
value: {
$title: 'Static value',
$description: 'The resolved value.',
},
},
dynamic: {
expression: {
$title: 'Expression',
$description: 'A CQL expression that resolves to the value.',
},
nullable: {
$title: 'Nullable?',
$description: 'Whether the expression result is nullable.',
},
default: {
$title: 'Default',
$description: 'A static value used in case the expression evaluation '
+ 'fails, results in null, or yields a value of unexpected type.',
},
},
},
},
list: {
$title: 'List',
$description: 'A list of values.',
values: {
$title: 'Values',
$description: 'The list of values.',
},
},
structure: {
$title: 'Structure',
$description: 'A set of attributes as key-value pairs.',
attributes: {
$title: 'Attributes',
$description: 'The structure-specific attributes.',
attribute: {
$title: 'Attribute value',
$description: 'The attribute value.',
},
},
},
};
/**
* A content JSON schema generator.
*
* This generator can generate JSON schemas for validating a content against a definition.
*/
class ContentJsonSchemaGenerator {
/**
* Constructs a new instance.
*
* @param options The options for the generator.
*/
constructor(options = {}) {
this.options = options;
}
/**
* Generates the schema in JSON format for validating a content against a given definition.
*
* @param bundle The bundle to generate the schema based on.
*
* @returns The JSON schema in JSON format.
*/
generate(bundle) {
return JSON.stringify(this.visitBundle(bundle), null, 2);
}
/**
* Generates the schema in object form for validating a content against a given definition.
*
* @param bundle The bundle to generate the schema based on.
*
* @returns The JSON schema in object form.
*/
visitBundle(bundle) {
const visitor = this.getVisitor();
return {
...visitor.visit(bundle.root),
$defs: {
external: Object.fromEntries(Object.entries(bundle.definitions).map(([name, definition]) => {
const referencedSchema = visitor.visit(definition);
if (definition.type === 'structure') {
delete referencedSchema.additionalProperties;
}
return [ContentJsonSchemaVisitor.getReferenceRefId(name), referencedSchema];
})),
internal: visitor.getReferencedDefinitions(),
},
};
}
/**
* Generates the schema in object form for validating a content against the given definition.
*
* @param definition The definition to generate the schema based on.
*
* @returns The JSON schema in object form.
*/
visit(definition) {
return this.getVisitor().visit(definition);
}
/**
* Creates a visitor for generating JSON schemas.
*
* @returns A visitor for generating JSON schemas.
*/
getVisitor() {
return new ContentJsonSchemaVisitor(this.options);
}
}
exports.ContentJsonSchemaGenerator = ContentJsonSchemaGenerator;