uniforms-bridge-json-schema
Version:
JSONSchema schema bridge for uniforms.
421 lines (354 loc) • 11.2 kB
text/typescript
import invariant from 'invariant';
import cloneDeep from 'lodash/cloneDeep';
import get from 'lodash/get';
import isEmpty from 'lodash/isEmpty';
import lowerCase from 'lodash/lowerCase';
import memoize from 'lodash/memoize';
import upperFirst from 'lodash/upperFirst';
import { Bridge, UnknownObject, joinName } from 'uniforms';
function fieldInvariant(name: string, condition: boolean): asserts condition {
invariant(condition, 'Field not found in schema: "%s"', name);
}
function resolveRef(reference: string, schema: UnknownObject) {
invariant(
reference.startsWith('#'),
'Reference is not an internal reference, and only such are allowed: "%s"',
reference,
);
const resolvedReference = reference
.split('/')
.filter(part => part && part !== '#')
.reduce((definition, next) => definition[next] as UnknownObject, schema);
invariant(
resolvedReference,
'Reference not found in schema: "%s"',
reference,
);
return resolvedReference;
}
function resolveRefIfNeeded(
partial: UnknownObject,
schema: UnknownObject,
): UnknownObject {
if (!('$ref' in partial)) {
return partial;
}
const { $ref, ...partialWithoutRef } = partial;
return resolveRefIfNeeded(
// @ts-expect-error The `partial` and `schema` should be typed more precisely.
Object.assign({}, partialWithoutRef, resolveRef($ref, schema)),
schema,
);
}
const partialNames = ['allOf', 'anyOf', 'oneOf'];
const propsToRemove = [
'default',
'enum',
'format',
'isRequired',
'title',
'uniforms',
];
const propsToRename: [string, string][] = [
['maxItems', 'maxCount'],
['maximum', 'max'],
['minItems', 'minCount'],
['minimum', 'min'],
['multipleOf', 'step'],
];
function pathToName(path: string) {
path = path.startsWith('/')
? path.replace(/\//g, '.').replace(/~0/g, '~').replace(/~1/g, '/')
: path
.replace(/\[('|")(.+?)\1\]/g, '.$2')
.replace(/\[(.+?)\]/g, '.$1')
.replace(/\\'/g, "'");
return path.slice(1);
}
function isValidatorResult(value: unknown): value is ValidatorResult {
return (
typeof value === 'object' &&
value !== null &&
Array.isArray((value as ValidatorResult).details)
);
}
/** Option type used in SelectField or RadioField */
type Option<Value> = {
disabled?: boolean;
label?: string;
key?: string;
value: Value;
};
type FieldError = {
instancePath?: string;
/** Provided by Ajv < 8 */
dataPath?: string;
params?: Record<string, unknown> & {
missingProperty?: string;
};
message?: string;
};
type ValidatorResult = { details: FieldError[] };
export default class JSONSchemaBridge extends Bridge {
provideDefaultLabelFromFieldName: boolean;
// FIXME: The `schema` should be typed more precisely.
schema: Record<string, any>;
validator: (model: UnknownObject) => ValidatorResult | null | undefined;
// FIXME: The `_compiledSchema` should be typed more precisely.
_compiledSchema: Record<string, any>;
constructor({
provideDefaultLabelFromFieldName = true,
schema,
validator,
}: {
provideDefaultLabelFromFieldName?: boolean;
schema: Record<string, any>;
validator: (model: UnknownObject) => ValidatorResult | null | undefined;
}) {
super();
this.provideDefaultLabelFromFieldName = provideDefaultLabelFromFieldName;
this.schema = resolveRefIfNeeded(schema, schema);
this._compiledSchema = { '': this.schema };
this.validator = validator;
// Memoize for performance and referential equality.
this.getField = memoize(this.getField.bind(this));
this.getInitialValue = memoize(this.getInitialValue.bind(this));
this.getSubfields = memoize(this.getSubfields.bind(this));
this.getType = memoize(this.getType.bind(this));
}
getError(name: string, error: unknown) {
const details = isValidatorResult(error) && error.details;
if (!details) {
return null;
}
const nameParts = joinName(null, name).map(joinName.unescape);
const unescapedName = joinName(nameParts);
const rootName = joinName(nameParts.slice(0, -1));
const baseName = nameParts[nameParts.length - 1];
const scopedError = details.find(error => {
const rawPath = error.instancePath ?? error.dataPath;
const path = rawPath ? pathToName(rawPath) : '';
return (
unescapedName === path ||
(rootName === path &&
error.params &&
baseName === error.params.missingProperty)
);
});
return scopedError || null;
}
getErrorMessage(name: string, error: unknown) {
const scopedError = this.getError(name, error);
return scopedError?.message || '';
}
getErrorMessages(error: unknown) {
if (!error) {
return [];
}
if (isValidatorResult(error)) {
const { details } = error;
return details.map(error => error.message || '');
}
if (error instanceof Error) {
return [error.message];
}
if (typeof error === 'object') {
return [];
}
return [String(error)];
}
getField(name: string) {
return joinName(null, name).reduce((definition, next, index, array) => {
const prevName = joinName(array.slice(0, index));
const nextName = joinName(prevName, next);
const definitionCache = (this._compiledSchema[nextName] ??= {});
definitionCache.isRequired = !!(
definition.required?.includes(next) ||
this._compiledSchema[prevName].required?.includes(next)
);
if (next === '$' || next === '' + parseInt(next, 10)) {
fieldInvariant(name, definition.type === 'array');
definition = Array.isArray(definition.items)
? definition.items[parseInt(next, 10)]
: definition.items;
fieldInvariant(name, !!definition);
} else if (definition.type === 'object') {
fieldInvariant(name, !!definition.properties);
definition = definition.properties[joinName.unescape(next)];
fieldInvariant(name, !!definition);
} else {
let nextFound = false;
partialNames.forEach(partialName => {
definition[partialName]?.forEach((partialElement: any) => {
if (!nextFound) {
partialElement = resolveRefIfNeeded(
partialElement as UnknownObject,
this.schema,
);
if (next in partialElement.properties) {
definition = partialElement.properties[next];
nextFound = true;
}
}
});
});
fieldInvariant(name, nextFound);
}
definition = resolveRefIfNeeded(definition, this.schema);
// Naive computation of combined type, properties and required.
const required = definition.required ? definition.required.slice() : [];
const properties = definition.properties
? Object.assign({}, definition.properties)
: {};
partialNames.forEach(partialName => {
definition[partialName]?.forEach((partial: any) => {
partial = resolveRefIfNeeded(partial as UnknownObject, this.schema);
if (partial.required) {
required.push(...partial.required);
}
Object.assign(properties, partial.properties);
if (!definitionCache.type && partial.type) {
definitionCache.type = partial.type;
}
});
});
if (required.length > 0) {
definitionCache.required = required;
}
if (!isEmpty(properties)) {
definitionCache.properties = properties;
}
return definition;
}, this.schema);
}
getInitialValue(name: string): any {
const field = this.getField(name);
const {
default: defaultValue = field.default ?? get(this.schema.default, name),
type = field.type,
} = this._compiledSchema[name];
if (defaultValue !== undefined) {
return cloneDeep(defaultValue);
}
if (type === 'array') {
if (!field.minItems) {
return [];
}
const item = this.getInitialValue(joinName(name, '$'));
if (item === undefined) {
return [];
}
return Array.from({ length: field.minItems }, () => item);
}
if (type === 'object') {
const value: UnknownObject = {};
this.getSubfields(name).forEach(key => {
const initialValue = this.getInitialValue(joinName(name, key));
if (initialValue !== undefined) {
value[key] = initialValue;
}
});
return value;
}
return undefined;
}
getProps(name: string) {
const field = this.getField(name);
const props = Object.assign(
{},
field,
field.uniforms,
this._compiledSchema[name],
);
props.label ??= props.title;
if (this.provideDefaultLabelFromFieldName && props.label === undefined) {
props.label = upperFirst(lowerCase(joinName(null, name).slice(-1)[0]));
}
if (field.type === 'number') {
props.decimal = true;
}
if (field.uniforms?.type !== undefined) {
props.type = field.uniforms.type;
}
if (props.required === undefined) {
props.required = props.isRequired;
}
if (props.type === field.type) {
delete props.type;
}
type OptionList = Option<unknown>[];
type OptionDict = Record<string, unknown>;
type Options = OptionList | OptionDict;
let options: Options | undefined = props.options;
if (options) {
if (!Array.isArray(options)) {
options = Object.entries(options).map(([key, value]) => ({
key,
label: key,
value,
}));
}
} else if (props.enum) {
options = Object.values(props.enum as Record<string, unknown>).map(
value => ({ value }),
);
}
propsToRename.forEach(([key, newKey]) => {
if (key in props) {
props[newKey] = props[key];
delete props[key];
}
});
propsToRemove.forEach(key => {
if (key in props) {
delete props[key];
}
});
return Object.assign(props, { options });
}
getSubfields(name = '') {
const field = this.getField(name);
const { properties = field.properties, type = field.type } =
this._compiledSchema[name];
if (type === 'object' && properties) {
return Object.keys(properties as Record<string, unknown>).map(
joinName.escape,
);
}
return [];
}
getType(name: string) {
const { type: _type, format: fieldFormat } = this.getField(name);
const { type: fieldType = _type } = this._compiledSchema[name];
if (fieldFormat === 'date-time') {
return Date;
}
if (fieldType === 'string') {
return String;
}
if (fieldType === 'number') {
return Number;
}
if (fieldType === 'integer') {
return Number;
}
if (fieldType === 'object') {
return Object;
}
if (fieldType === 'array') {
return Array;
}
if (fieldType === 'boolean') {
return Boolean;
}
invariant(
fieldType !== 'null',
'Field "%s" can not be represented as a type null',
name,
);
return fieldType;
}
getValidator() {
return this.validator;
}
}