import { Schema, SchemaContextCreator, SchemaMappedType, SchemaType, SchemaValidationError, validateAndMap, validateAndUnmap, } from '../schema'; import { OptionalizeObject } from '../typeUtils'; import { isOptional, isOptionalNullable, isOptionalOrNullableType, literalToString, objectEntries, objectKeyEncode, omitKeysFromObject, } from '../utils'; import { dict } from './dict'; import { optional } from './optional'; type AnyObjectSchema = Record< string, [string, Schema, ObjectXmlOptions?] >; type AllValues = { [P in keyof T]: { key: P; value: T[P][0]; schema: T[P][1] }; }[keyof T]; export type MappedObjectType = OptionalizeObject<{ [P in AllValues['value']]: SchemaMappedType< Extract, { value: P }>['schema'] >; }>; export type ObjectType = OptionalizeObject<{ [K in keyof T]: SchemaType; }>; export interface ObjectXmlOptions { isAttr?: boolean; xmlName?: string; } export interface StrictObjectSchema< V extends string, T extends Record, ObjectXmlOptions?]> > extends Schema, MappedObjectType> { readonly objectSchema: T; } export interface ObjectSchema< V extends string, T extends Record, ObjectXmlOptions?]> > extends Schema< ObjectType & { [key: string]: unknown }, MappedObjectType & { [key: string]: unknown } > { readonly objectSchema: T; } export interface ExtendedObjectSchema< V extends string, T extends Record, ObjectXmlOptions?]>, K extends string, U > extends Schema< ObjectType & { [key in K]?: Record }, MappedObjectType & { [key in K]?: Record } > { readonly objectSchema: T; } /** * Create a Strict Object type schema. * * A strict-object does not allow additional properties during mapping or * unmapping. Additional properties will result in a validation error. */ export function strictObject< V extends string, T extends Record, ObjectXmlOptions?]> >(objectSchema: T): StrictObjectSchema { const schema = internalObject(objectSchema, false, false); schema.type = () => `StrictObject<{${Object.keys(objectSchema) .map(objectKeyEncode) .join(',')}}>`; return schema; } /** * Create an Expandable Object type schema, allowing all additional properties. * * The object schema allows additional properties during mapping and unmapping. The * additional properties are copied over as is. */ export function expandoObject< V extends string, T extends Record, ObjectXmlOptions?]> >(objectSchema: T): ObjectSchema { return internalObject(objectSchema, true, true); } /** * Create an Expandable Object type schema, allowing only typed additional properties. * * The object schema allows additional properties during mapping and unmapping. The * additional properties are copied over in a Record> * with key represented by K. */ export function typedExpandoObject< V extends string, T extends Record, ObjectXmlOptions?]>, K extends string, S extends Schema >( objectSchema: T, additionalPropertyKey: K, additionalPropertySchema: S ): ExtendedObjectSchema> { return internalObject(objectSchema, true, [ additionalPropertyKey, optional(dict(additionalPropertySchema)), ]); } /** * Create an Object Type schema. * * The Object schema allows additional properties during mapping and unmapping * but discards them. */ export function object< V extends string, T extends Record, ObjectXmlOptions?]> >(objectSchema: T): StrictObjectSchema { const schema = internalObject(objectSchema, true, false); schema.type = () => `Object<{${Object.keys(objectSchema).map(objectKeyEncode).join(',')}}>`; return schema; } /** * Create a strict-object schema that extends an existing schema. */ export function extendStrictObject< V extends string, T extends Record, ObjectXmlOptions?]>, A extends string, B extends Record, ObjectXmlOptions?]> >( parentObjectSchema: StrictObjectSchema, objectSchema: B ): StrictObjectSchema { return strictObject({ ...parentObjectSchema.objectSchema, ...objectSchema }); } /** * Create an object schema that extends an existing schema. */ export function extendExpandoObject< V extends string, T extends Record, ObjectXmlOptions?]>, A extends string, B extends Record, ObjectXmlOptions?]> >( parentObjectSchema: ObjectSchema, objectSchema: B ): ObjectSchema { return expandoObject({ ...parentObjectSchema.objectSchema, ...objectSchema }); } /** * Create an Object schema that extends an existing object schema. */ export function extendObject< V extends string, T extends Record, ObjectXmlOptions?]>, A extends string, B extends Record, ObjectXmlOptions?]> >( parentObjectSchema: StrictObjectSchema, objectSchema: B ): StrictObjectSchema { return object({ ...parentObjectSchema.objectSchema, ...objectSchema }); } /** * Internal utility to create object schema with different options. */ function internalObject< V extends string, T extends Record, ObjectXmlOptions?]> >( objectSchema: T, skipAdditionalPropValidation: boolean, mapAdditionalProps: boolean | [string, Schema] ): StrictObjectSchema { const keys = Object.keys(objectSchema); const reverseObjectSchema = createReverseObjectSchema(objectSchema); const xmlMappingInfo = getXmlPropMappingForObjectSchema(objectSchema); const xmlObjectSchema = createXmlObjectSchema(objectSchema); const reverseXmlObjectSchema = createReverseXmlObjectSchema(xmlObjectSchema); return { type: () => `Object<{${keys.map(objectKeyEncode).join(',')},...}>`, validateBeforeMap: validateObject( objectSchema, 'validateBeforeMap', skipAdditionalPropValidation, mapAdditionalProps ), validateBeforeUnmap: validateObject( reverseObjectSchema, 'validateBeforeUnmap', skipAdditionalPropValidation, mapAdditionalProps ), map: mapObject(objectSchema, 'map', mapAdditionalProps), unmap: mapObject(reverseObjectSchema, 'unmap', mapAdditionalProps), validateBeforeMapXml: validateObjectBeforeMapXml( objectSchema, xmlMappingInfo, skipAdditionalPropValidation, mapAdditionalProps ), mapXml: mapObjectFromXml(xmlObjectSchema, mapAdditionalProps), unmapXml: unmapObjectToXml(reverseXmlObjectSchema, mapAdditionalProps), objectSchema, }; } function validateObjectBeforeMapXml( objectSchema: Record, ObjectXmlOptions?]>, xmlMappingInfo: ReturnType, skipAdditionalPropValidation: boolean, mapAdditionalProps: boolean | [string, Schema] ) { const { elementsToProps, attributesToProps } = xmlMappingInfo; return ( value: unknown, ctxt: SchemaContextCreator ): SchemaValidationError[] => { if (typeof value !== 'object' || value === null) { return ctxt.fail(); } if (Array.isArray(value)) { return ctxt.fail( `Expected value to be of type '${ ctxt.type }' but found 'Array<${typeof value}>'.` ); } const valueObject = value as { $?: Record; [key: string]: unknown; }; const { $: attrs, ...elements } = valueObject; let validationObj = { validationMethod: 'validateBeforeMapXml', propTypeName: 'child elements', propTypePrefix: 'element', valueTypeName: 'element', propMapping: elementsToProps, objectSchema, valueObject: elements, ctxt, skipAdditionalPropValidation, mapAdditionalProps, }; // Validate all known elements using the schema const elementErrors = validateValueObject(validationObj); validationObj = { ...validationObj, propTypeName: 'attributes', propTypePrefix: '@', propMapping: attributesToProps, valueObject: attrs ?? {}, }; // Validate all known attributes using the schema const attributesErrors = validateValueObject(validationObj); return elementErrors.concat(attributesErrors); }; } function mapObjectFromXml( xmlObjectSchema: XmlObjectSchema, mapAdditionalProps: boolean | [string, Schema] ) { const { elementsSchema, attributesSchema } = xmlObjectSchema; const mapElements = mapObject(elementsSchema, 'mapXml', mapAdditionalProps); const mapAttributes = mapObject( attributesSchema, 'mapXml', false // Always false; additional attributes are handled differently below. ); // These are later used to omit know attribute props from the attributes object // so that the remaining props can be copied over as additional props. const attributeKeys = objectEntries(attributesSchema).map( ([_, [name]]) => name ); return (value: unknown, ctxt: SchemaContextCreator): any => { const valueObject = value as { $?: Record; [key: string]: unknown; }; const { $: attrs, ...elements } = valueObject; const attributes = attrs ?? {}; const output: Record = { ...mapAttributes(attributes, ctxt), ...mapElements(elements, ctxt), }; if (mapAdditionalProps) { // Omit known attributes and copy the rest as additional attributes. const additionalAttrs = omitKeysFromObject(attributes, attributeKeys); if (Object.keys(additionalAttrs).length > 0) { // These additional attrs are set in the '$' property by convention. output.$ = additionalAttrs; } } return output; }; } function unmapObjectToXml( xmlObjectSchema: XmlObjectSchema, mapAdditionalProps: boolean | [string, Schema] ) { const { elementsSchema, attributesSchema } = xmlObjectSchema; const mapElements = mapObject(elementsSchema, 'unmapXml', mapAdditionalProps); const mapAttributes = mapObject( attributesSchema, 'unmapXml', false // Always false so that element props are not copied during mapping ); // These are later used to omit attribute props from the value object so that they // do not get mapped during element mapping, if the mapAdditionalProps is set. const attributeKeys = objectEntries(attributesSchema).map( ([_, [name]]) => name ); return (value: unknown, ctxt: SchemaContextCreator): any => { // Get additional attributes which are set in the '$' property by convention const { $: attributes, ...rest } = value as { $?: unknown; [key: string]: unknown; }; // Ensure 'attributes' is an object and non-null const additionalAttributes = typeof attributes === 'object' && attributes !== null && mapAdditionalProps ? attributes : {}; return { ...mapElements(omitKeysFromObject(rest, attributeKeys), ctxt), $: { ...additionalAttributes, ...mapAttributes(value, ctxt) }, }; }; } function validateValueObject({ validationMethod, propTypeName, propTypePrefix, valueTypeName, propMapping, objectSchema, valueObject, ctxt, skipAdditionalPropValidation, mapAdditionalProps, }: { validationMethod: string; propTypeName: string; propTypePrefix: string; valueTypeName: string; propMapping: Record; objectSchema: AnyObjectSchema; valueObject: { [key: string]: any }; ctxt: SchemaContextCreator; skipAdditionalPropValidation: boolean; mapAdditionalProps: boolean | [string, Schema]; }) { const errors: SchemaValidationError[] = []; const missingProps: Set = new Set(); const conflictingProps: Set = new Set(); const unknownProps: Set = new Set(Object.keys(valueObject)); if ( validationMethod !== 'validateBeforeMap' && typeof mapAdditionalProps !== 'boolean' && mapAdditionalProps[0] in valueObject ) { for (const [key, _] of Object.entries(valueObject[mapAdditionalProps[0]])) { if (Object.prototype.hasOwnProperty.call(objectSchema, key)) { conflictingProps.add(key); } } } // Create validation errors for conflicting additional properties keys addErrorsIfAny( conflictingProps, (names) => createErrorMessage( `Some keys in additional properties are conflicting with the keys in`, valueTypeName, names ), errors, ctxt ); // Validate all known properties using the schema for (const key in propMapping) { if (Object.prototype.hasOwnProperty.call(propMapping, key)) { const propName = propMapping[key]; const schema = objectSchema[propName][1]; unknownProps.delete(key); if (key in valueObject) { schema[validationMethod]( valueObject[key], ctxt.createChild(propTypePrefix + key, valueObject[key], schema) ).forEach((e) => errors.push(e)); } else if (!isOptionalOrNullableType(schema.type())) { // Add to missing keys if it is not an optional property missingProps.add(key); } } } // Create validation error for unknown properties encountered if (!skipAdditionalPropValidation) { addErrorsIfAny( unknownProps, (names) => createErrorMessage( `Some unknown ${propTypeName} were found in the`, valueTypeName, names ), errors, ctxt ); } // Create validation error for missing required properties addErrorsIfAny( missingProps, (names) => createErrorMessage( `Some ${propTypeName} are missing in the`, valueTypeName, names ), errors, ctxt ); return errors; } function createErrorMessage( message: string, type: string, properties: string[] ): string { return `${message} ${type}: ${properties.map(literalToString).join(', ')}.`; } function addErrorsIfAny( conflictingProps: Set, messageGetter: (propNames: string[]) => string, errors: SchemaValidationError[], ctxt: SchemaContextCreator ) { const conflictingPropsArray = Array.from(conflictingProps); if (conflictingPropsArray.length > 0) { const message = messageGetter(conflictingPropsArray); ctxt.fail(message).forEach((e) => errors.push(e)); } } function validateObject( objectSchema: AnyObjectSchema, validationMethod: | 'validateBeforeMap' | 'validateBeforeUnmap' | 'validateBeforeMapXml', skipAdditionalPropValidation: boolean, mapAdditionalProps: boolean | [string, Schema] ) { const propMapping = getPropMappingForObjectSchema(objectSchema); return (value: unknown, ctxt: SchemaContextCreator) => { if (typeof value !== 'object' || value === null) { return ctxt.fail(); } if (Array.isArray(value)) { return ctxt.fail( `Expected value to be of type '${ ctxt.type }' but found 'Array<${typeof value}>'.` ); } return validateValueObject({ validationMethod, propTypeName: 'properties', propTypePrefix: '', valueTypeName: 'object', propMapping, objectSchema, valueObject: value as Record, ctxt, skipAdditionalPropValidation, mapAdditionalProps, }); }; } function mapObject( objectSchema: T, mappingFn: 'map' | 'unmap' | 'mapXml' | 'unmapXml', mapAdditionalProps: boolean | [string, Schema] ) { return (value: unknown, ctxt: SchemaContextCreator): any => { const output: Record = {}; const objectValue = { ...(value as Record) }; const isUnmaping = mappingFn === 'unmap' || mappingFn === 'unmapXml'; if ( isUnmaping && typeof mapAdditionalProps !== 'boolean' && mapAdditionalProps[0] in objectValue ) { // Pre process to flatten additional properties in objectValue Object.entries(objectValue[mapAdditionalProps[0]]).forEach( ([k, v]) => (objectValue[k] = v) ); delete objectValue[mapAdditionalProps[0]]; } // Map known properties to output using the schema Object.entries(objectSchema).forEach(([key, element]) => { const propName = element[0]; const propValue = objectValue[propName]; delete objectValue[propName]; if (isOptionalNullable(element[1].type(), propValue)) { if (typeof propValue === 'undefined') { // Skip mapping to avoid creating properties with value 'undefined' return; } output[key] = null; return; } if (isOptional(element[1].type(), propValue)) { // Skip mapping to avoid creating properties with value 'undefined' return; } output[key] = element[1][mappingFn]( propValue, ctxt.createChild(propName, propValue, element[1]) ); }); // Copy the additional unknown properties in output when allowed Object.entries( extractAdditionalProperties(objectValue, isUnmaping, mapAdditionalProps) ).forEach(([k, v]) => (output[k] = v)); return output; }; } function extractAdditionalProperties( objectValue: Record, isUnmaping: boolean, mapAdditionalProps: boolean | [string, Schema] ): Record { const properties: Record = {}; if (!mapAdditionalProps) { return properties; } if (typeof mapAdditionalProps === 'boolean') { Object.entries(objectValue).forEach(([k, v]) => (properties[k] = v)); return properties; } Object.entries(objectValue).forEach(([k, v]) => { const testValue = { [k]: v }; const mappingResult = isUnmaping ? validateAndUnmap(testValue, mapAdditionalProps[1]) : validateAndMap(testValue, mapAdditionalProps[1]); if (mappingResult.errors) { return; } properties[k] = mappingResult.result[k]; }); if (isUnmaping || Object.entries(properties).length === 0) { return properties; } return { [mapAdditionalProps[0]]: properties }; } function getXmlPropMappingForObjectSchema(objectSchema: AnyObjectSchema) { const elementsToProps: Record = {}; const attributesToProps: Record = {}; for (const key in objectSchema) { /* istanbul ignore else */ if (Object.prototype.hasOwnProperty.call(objectSchema, key)) { const [propName, , xmlOptions] = objectSchema[key]; if (xmlOptions?.isAttr === true) { attributesToProps[xmlOptions.xmlName ?? propName] = key; } else { elementsToProps[xmlOptions?.xmlName ?? propName] = key; } } } return { elementsToProps, attributesToProps }; } function getPropMappingForObjectSchema( objectSchema: AnyObjectSchema ): Record { const propsMapping: Record = {}; for (const key in objectSchema) { /* istanbul ignore else */ if (Object.prototype.hasOwnProperty.call(objectSchema, key)) { const propDef = objectSchema[key]; propsMapping[propDef[0]] = key; } } return propsMapping; } const createReverseObjectSchema = ( objectSchema: T ): AnyObjectSchema => Object.entries(objectSchema).reduce( (result, [key, element]) => ({ ...result, [element[0]]: [key, element[1], element[2]], }), {} as AnyObjectSchema ); interface XmlObjectSchema { elementsSchema: AnyObjectSchema; attributesSchema: AnyObjectSchema; } function createXmlObjectSchema(objectSchema: AnyObjectSchema): XmlObjectSchema { const elementsSchema: AnyObjectSchema = {}; const attributesSchema: AnyObjectSchema = {}; for (const key in objectSchema) { /* istanbul ignore else */ if (Object.prototype.hasOwnProperty.call(objectSchema, key)) { const element = objectSchema[key]; const [serializedName, schema, xmlOptions] = element; const xmlObjectSchema = xmlOptions?.isAttr ? attributesSchema : elementsSchema; xmlObjectSchema[key] = [ xmlOptions?.xmlName ?? serializedName, schema, xmlOptions, ]; } } return { elementsSchema, attributesSchema }; } function createReverseXmlObjectSchema( xmlObjectSchema: XmlObjectSchema ): XmlObjectSchema { return { attributesSchema: createReverseObjectSchema( xmlObjectSchema.attributesSchema ), elementsSchema: createReverseObjectSchema(xmlObjectSchema.elementsSchema), }; }