From b36ed4dd088341e6f69ced399f7d0b83a12c96ef Mon Sep 17 00:00:00 2001 From: Asad Ali Date: Thu, 28 Nov 2024 09:54:36 +0500 Subject: [PATCH] feat(additional-properties): adds typed expando object schema (#193) This commit adds a new schema for validating, mapping and un-mapping additional properties in an object. It will also validate and skip the additional properties that do not match the specified type while mapping. While the un-mapping step will fail if the additional properties key matches one of the objects original keys. --- packages/schema/src/schema.ts | 4 +- packages/schema/src/types/object.ts | 355 ++++++++++++------ .../schema/test/types/expandoObject.test.ts | 307 ++++++++++++++- 3 files changed, 541 insertions(+), 125 deletions(-) diff --git a/packages/schema/src/schema.ts b/packages/schema/src/schema.ts index a7604f4a..f974f20b 100644 --- a/packages/schema/src/schema.ts +++ b/packages/schema/src/schema.ts @@ -210,8 +210,8 @@ function createSchemaContextCreator( createSchemaContextCreator({ value, type: childSchema.type(), - branch: [...currentContext.branch, value], - path: [...currentContext.path, key], + branch: currentContext.branch.concat(value), + path: currentContext.path.concat(key), strictValidation: currentContext.strictValidation, }); diff --git a/packages/schema/src/types/object.ts b/packages/schema/src/types/object.ts index 77e9fd94..172438c0 100644 --- a/packages/schema/src/types/object.ts +++ b/packages/schema/src/types/object.ts @@ -4,6 +4,8 @@ import { SchemaMappedType, SchemaType, SchemaValidationError, + validateAndMap, + validateAndUnmap, } from '../schema'; import { OptionalizeObject } from '../typeUtils'; import { @@ -15,6 +17,8 @@ import { objectKeyEncode, omitKeysFromObject, } from '../utils'; +import { dict } from './dict'; +import { optional } from './optional'; type AnyObjectSchema = Record< string, @@ -61,6 +65,18 @@ export interface ObjectSchema< 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. * @@ -80,7 +96,7 @@ export function strictObject< } /** - * Create an Expandable Object type 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. @@ -92,6 +108,29 @@ export function expandoObject< 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. * @@ -161,11 +200,11 @@ function internalObject< T extends Record, ObjectXmlOptions?]> >( objectSchema: T, - skipValidateAdditionalProps: boolean, - mapAdditionalProps: boolean + skipAdditionalPropValidation: boolean, + mapAdditionalProps: boolean | [string, Schema] ): StrictObjectSchema { const keys = Object.keys(objectSchema); - const reverseObjectSchema = createReverseObjectSchema(objectSchema); + const reverseObjectSchema = createReverseObjectSchema(objectSchema); const xmlMappingInfo = getXmlPropMappingForObjectSchema(objectSchema); const xmlObjectSchema = createXmlObjectSchema(objectSchema); const reverseXmlObjectSchema = createReverseXmlObjectSchema(xmlObjectSchema); @@ -174,19 +213,22 @@ function internalObject< validateBeforeMap: validateObject( objectSchema, 'validateBeforeMap', - skipValidateAdditionalProps + skipAdditionalPropValidation, + mapAdditionalProps ), validateBeforeUnmap: validateObject( reverseObjectSchema, 'validateBeforeUnmap', - skipValidateAdditionalProps + skipAdditionalPropValidation, + mapAdditionalProps ), map: mapObject(objectSchema, 'map', mapAdditionalProps), unmap: mapObject(reverseObjectSchema, 'unmap', mapAdditionalProps), validateBeforeMapXml: validateObjectBeforeMapXml( objectSchema, xmlMappingInfo, - skipValidateAdditionalProps + skipAdditionalPropValidation, + mapAdditionalProps ), mapXml: mapObjectFromXml(xmlObjectSchema, mapAdditionalProps), unmapXml: unmapObjectToXml(reverseXmlObjectSchema, mapAdditionalProps), @@ -197,7 +239,8 @@ function internalObject< function validateObjectBeforeMapXml( objectSchema: Record, ObjectXmlOptions?]>, xmlMappingInfo: ReturnType, - allowAdditionalProperties: boolean + skipAdditionalPropValidation: boolean, + mapAdditionalProps: boolean | [string, Schema] ) { const { elementsToProps, attributesToProps } = xmlMappingInfo; return ( @@ -219,42 +262,42 @@ function validateObjectBeforeMapXml( [key: string]: unknown; }; const { $: attrs, ...elements } = valueObject; - const attributes = attrs ?? {}; - // Validate all known elements and attributes using the schema - return [ - ...validateValueObject({ - validationMethod: 'validateBeforeMapXml', - propTypeName: 'child elements', - propTypePrefix: 'element', - valueTypeName: 'element', - propMapping: elementsToProps, - objectSchema, - valueObject: elements, - ctxt, - allowAdditionalProperties, - }), - ...validateValueObject({ - validationMethod: 'validateBeforeMapXml', - propTypeName: 'attributes', - propTypePrefix: '@', - valueTypeName: 'element', - propMapping: attributesToProps, - objectSchema, - valueObject: attributes, - ctxt, - allowAdditionalProperties, - }), - ]; + 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, - allowAdditionalProps: boolean + mapAdditionalProps: boolean | [string, Schema] ) { const { elementsSchema, attributesSchema } = xmlObjectSchema; - const mapElements = mapObject(elementsSchema, 'mapXml', allowAdditionalProps); + const mapElements = mapObject(elementsSchema, 'mapXml', mapAdditionalProps); const mapAttributes = mapObject( attributesSchema, 'mapXml', @@ -280,7 +323,7 @@ function mapObjectFromXml( ...mapElements(elements, ctxt), }; - if (allowAdditionalProps) { + if (mapAdditionalProps) { // Omit known attributes and copy the rest as additional attributes. const additionalAttrs = omitKeysFromObject(attributes, attributeKeys); if (Object.keys(additionalAttrs).length > 0) { @@ -295,14 +338,10 @@ function mapObjectFromXml( function unmapObjectToXml( xmlObjectSchema: XmlObjectSchema, - allowAdditionalProps: boolean + mapAdditionalProps: boolean | [string, Schema] ) { const { elementsSchema, attributesSchema } = xmlObjectSchema; - const mapElements = mapObject( - elementsSchema, - 'unmapXml', - allowAdditionalProps - ); + const mapElements = mapObject(elementsSchema, 'unmapXml', mapAdditionalProps); const mapAttributes = mapObject( attributesSchema, 'unmapXml', @@ -310,7 +349,7 @@ function unmapObjectToXml( ); // These are later used to omit attribute props from the value object so that they - // do not get mapped during element mapping, if the allowAdditionalProps is true. + // do not get mapped during element mapping, if the mapAdditionalProps is set. const attributeKeys = objectEntries(attributesSchema).map( ([_, [name]]) => name ); @@ -326,7 +365,7 @@ function unmapObjectToXml( const additionalAttributes = typeof attributes === 'object' && attributes !== null && - allowAdditionalProps + mapAdditionalProps ? attributes : {}; @@ -346,25 +385,50 @@ function validateValueObject({ objectSchema, valueObject, ctxt, - allowAdditionalProperties, + skipAdditionalPropValidation, + mapAdditionalProps, }: { - validationMethod: - | 'validateBeforeMap' - | 'validateBeforeUnmap' - | 'validateBeforeMapXml'; + validationMethod: string; propTypeName: string; propTypePrefix: string; valueTypeName: string; propMapping: Record; objectSchema: AnyObjectSchema; - valueObject: { [key: string]: unknown }; + valueObject: { [key: string]: any }; ctxt: SchemaContextCreator; - allowAdditionalProperties: boolean; + 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)) { @@ -372,12 +436,10 @@ function validateValueObject({ const schema = objectSchema[propName][1]; unknownProps.delete(key); if (key in valueObject) { - errors.push( - ...schema[validationMethod]( - valueObject[key], - ctxt.createChild(propTypePrefix + key, valueObject[key], schema) - ) - ); + 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); @@ -386,41 +448,67 @@ function validateValueObject({ } // Create validation error for unknown properties encountered - const unknownPropsArray = Array.from(unknownProps); - if (unknownPropsArray.length > 0 && !allowAdditionalProperties) { - errors.push( - ...ctxt.fail( - `Some unknown ${propTypeName} were found in the ${valueTypeName}: ${unknownPropsArray - .map(literalToString) - .join(', ')}.` - ) + if (!skipAdditionalPropValidation) { + addErrorsIfAny( + unknownProps, + (names) => + createErrorMessage( + `Some unknown ${propTypeName} were found in the`, + valueTypeName, + names + ), + errors, + ctxt ); } // Create validation error for missing required properties - const missingPropsArray = Array.from(missingProps); - if (missingPropsArray.length > 0) { - errors.push( - ...ctxt.fail( - `Some ${propTypeName} are missing in the ${valueTypeName}: ${missingPropsArray - .map(literalToString) - .join(', ')}.` - ) - ); - } + 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', - allowAdditionalProperties: boolean + skipAdditionalPropValidation: boolean, + mapAdditionalProps: boolean | [string, Schema] ) { - const propsMapping = getPropMappingForObjectSchema(objectSchema); + const propMapping = getPropMappingForObjectSchema(objectSchema); return (value: unknown, ctxt: SchemaContextCreator) => { if (typeof value !== 'object' || value === null) { return ctxt.fail(); @@ -437,11 +525,12 @@ function validateObject( propTypeName: 'properties', propTypePrefix: '', valueTypeName: 'object', - propMapping: propsMapping, + propMapping, objectSchema, - valueObject: value as Record, + valueObject: value as Record, ctxt, - allowAdditionalProperties, + skipAdditionalPropValidation, + mapAdditionalProps, }); }; } @@ -449,55 +538,94 @@ function validateObject( function mapObject( objectSchema: T, mappingFn: 'map' | 'unmap' | 'mapXml' | 'unmapXml', - allowAdditionalProperties: boolean + mapAdditionalProps: boolean | [string, Schema] ) { return (value: unknown, ctxt: SchemaContextCreator): any => { const output: Record = {}; - const objectValue = value as Record; - /** Properties seen in the object but not in the schema */ - const unknownKeys = new Set(Object.keys(objectValue)); - - // Map known properties using the schema - for (const key in objectSchema) { - if (!Object.prototype.hasOwnProperty.call(objectSchema, key)) { - continue; - } + 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]]; + } - const element = objectSchema[key]; + // Map known properties to output using the schema + Object.entries(objectSchema).forEach(([key, element]) => { const propName = element[0]; const propValue = objectValue[propName]; - unknownKeys.delete(propName); + delete objectValue[propName]; if (isOptionalNullable(element[1].type(), propValue)) { if (typeof propValue === 'undefined') { // Skip mapping to avoid creating properties with value 'undefined' - continue; + return; } output[key] = null; - continue; + return; } if (isOptional(element[1].type(), propValue)) { // Skip mapping to avoid creating properties with value 'undefined' - continue; + 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)); - // Copy unknown properties over if additional properties flag is set - if (allowAdditionalProperties) { - unknownKeys.forEach((unknownKey) => { - output[unknownKey] = objectValue[unknownKey]; - }); - } 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 = {}; @@ -531,19 +659,16 @@ function getPropMappingForObjectSchema( return propsMapping; } -function createReverseObjectSchema( +const createReverseObjectSchema = ( objectSchema: T -): AnyObjectSchema { - const reverseObjectSchema: AnyObjectSchema = {}; - for (const key in objectSchema) { - /* istanbul ignore else */ - if (Object.prototype.hasOwnProperty.call(objectSchema, key)) { - const element = objectSchema[key]; - reverseObjectSchema[element[0]] = [key, element[1], element[2]]; - } - } - return reverseObjectSchema; -} +): AnyObjectSchema => + Object.entries(objectSchema).reduce( + (result, [key, element]) => ({ + ...result, + [element[0]]: [key, element[1], element[2]], + }), + {} as AnyObjectSchema + ); interface XmlObjectSchema { elementsSchema: AnyObjectSchema; diff --git a/packages/schema/test/types/expandoObject.test.ts b/packages/schema/test/types/expandoObject.test.ts index 329288cc..a9119915 100644 --- a/packages/schema/test/types/expandoObject.test.ts +++ b/packages/schema/test/types/expandoObject.test.ts @@ -7,13 +7,157 @@ import { string, validateAndMap, validateAndUnmap, + typedExpandoObject, + object, + anyOf, } from '../../src'; describe('Expando Object', () => { - describe('Mapping', () => { - const userSchema = expandoObject({ + const userSchema = expandoObject({ + id: ['user_id', string()], + age: ['user_age', number()], + }); + + const userSchemaWithAdditionalNumbers = typedExpandoObject( + { + id: ['user_id', string()], + age: ['user_age', number()], + }, + 'additionalProps', + number() + ); + + const workSchema = object({ + size: ['size', number()], + name: ['Name', string()], + }); + + const userSchemaWithAdditionalWorks = typedExpandoObject( + { + id: ['user_id', string()], + age: ['user_age', number()], + }, + 'additionalProps', + workSchema + ); + + const userSchemaWithAdditionalAnyOf = typedExpandoObject( + { id: ['user_id', string()], age: ['user_age', number()], + }, + 'additionalProps', + anyOf([workSchema, number()]) + ); + + describe('Mapping', () => { + it('AdditionalProperties: should map with additional properties', () => { + const input = { + user_id: 'John Smith', + user_age: 50, + number1: 123, + number2: 123.2, + invalid: 'string value', + }; + const output = validateAndMap(input, userSchemaWithAdditionalNumbers); + const expected: SchemaType = { + id: 'John Smith', + age: 50, + additionalProps: { + number1: 123, + number2: 123.2, + }, + }; + expect(output.errors).toBeFalsy(); + expect((output as any).result).toStrictEqual(expected); + }); + + it('AdditionalProperties: should map with only invalid additional properties', () => { + const input = { + user_id: 'John Smith', + user_age: 50, + invalid: 'string value', + }; + const output = validateAndMap(input, userSchemaWithAdditionalNumbers); + const expected: SchemaType = { + id: 'John Smith', + age: 50, + }; + expect(output.errors).toBeFalsy(); + expect((output as any).result).toStrictEqual(expected); + }); + + it('AdditionalProperties: should map without additional properties', () => { + const input = { + user_id: 'John Smith', + user_age: 50, + }; + const output = validateAndMap(input, userSchemaWithAdditionalNumbers); + const expected: SchemaType = { + id: 'John Smith', + age: 50, + }; + expect(output.errors).toBeFalsy(); + expect((output as any).result).toStrictEqual(expected); + }); + + it('AdditionalProperties: should map with object typed additional properties', () => { + const input = { + user_id: 'John Smith', + user_age: 50, + obj: { + size: 123, + Name: 'WorkA', + }, + invalid1: 123.2, + invalid2: { + size: '123 A', + Name: 'WorkA', + }, + }; + const output = validateAndMap(input, userSchemaWithAdditionalWorks); + const expected: SchemaType = { + id: 'John Smith', + age: 50, + additionalProps: { + obj: { + size: 123, + name: 'WorkA', + }, + }, + }; + expect(output.errors).toBeFalsy(); + expect((output as any).result).toStrictEqual(expected); + }); + + it('AdditionalProperties: should map with anyOf typed additional properties', () => { + const input = { + user_id: 'John Smith', + user_age: 50, + obj: { + size: 123, + Name: 'WorkA', + }, + number: 123.2, + invalid2: { + size: '123 A', + Name: 'WorkA', + }, + }; + const output = validateAndMap(input, userSchemaWithAdditionalAnyOf); + const expected: SchemaType = { + id: 'John Smith', + age: 50, + additionalProps: { + obj: { + size: 123, + name: 'WorkA', + }, + number: 123.2, + }, + }; + expect(output.errors).toBeFalsy(); + expect((output as any).result).toStrictEqual(expected); }); it('should map valid object', () => { @@ -147,13 +291,113 @@ describe('Expando Object', () => { `); }); }); + describe('Unmapping', () => { - const userSchema = expandoObject({ - id: ['user_id', string()], - age: ['user_age', number()], + it('AdditionalProperties: should unmap with additional properties', () => { + const input = { + id: 'John Smith', + age: 50, // takes precedence over additionalProps[user_age] + additionalProps: { + number1: 123, + number2: 123.2, + }, + }; + const output = validateAndUnmap(input, userSchemaWithAdditionalNumbers); + const expected = { + user_id: 'John Smith', + user_age: 50, + number1: 123, + number2: 123.2, + }; + expect(output.errors).toBeFalsy(); + expect((output as any).result).toStrictEqual(expected); }); - it('should map valid object', () => { + it('AdditionalProperties: should unmap without additional properties', () => { + const input = { + id: 'John Smith', + age: 50, + }; + const output = validateAndUnmap(input, userSchemaWithAdditionalNumbers); + const expected = { + user_id: 'John Smith', + user_age: 50, + }; + expect(output.errors).toBeFalsy(); + expect((output as any).result).toStrictEqual(expected); + }); + + it('AdditionalProperties: should unmap with object typed additional properties', () => { + const input = { + id: 'John Smith', + age: 50, + additionalProps: { + obj: { + size: 123, + name: 'WorkA', + }, + }, + }; + const output = validateAndUnmap(input, userSchemaWithAdditionalWorks); + const expected = { + user_id: 'John Smith', + user_age: 50, + obj: { + size: 123, + Name: 'WorkA', + }, + }; + expect(output.errors).toBeFalsy(); + expect((output as any).result).toStrictEqual(expected); + }); + + it('AdditionalProperties: should unmap with anyOf typed additional properties', () => { + const input = { + id: 'John Smith', + age: 50, + additionalProps: { + obj: { + size: 123, + name: 'WorkA', + }, + number: 123.2, + }, + }; + const output = validateAndUnmap(input, userSchemaWithAdditionalAnyOf); + const expected = { + user_id: 'John Smith', + user_age: 50, + obj: { + size: 123, + Name: 'WorkA', + }, + number: 123.2, + }; + expect(output.errors).toBeFalsy(); + expect((output as any).result).toStrictEqual(expected); + }); + + it('AdditionalProperties: should unmap with empty or blank additional property keys', () => { + const input = { + id: 'John Smith', + age: 50, + additionalProps: { + ' ': 123.2, + '': 52, + }, + }; + const output = validateAndUnmap(input, userSchemaWithAdditionalNumbers); + const expected = { + user_id: 'John Smith', + user_age: 50, + ' ': 123.2, + '': 52, + }; + expect(output.errors).toBeFalsy(); + expect((output as any).result).toStrictEqual(expected); + }); + + it('should unmap valid object', () => { const input = { id: 'John Smith', age: 50, @@ -167,7 +411,7 @@ describe('Expando Object', () => { expect((output as any).result).toStrictEqual(expected); }); - it('should map object with optional properties', () => { + it('should unmap object with optional properties', () => { const addressSchema = expandoObject({ address1: ['address1', string()], address2: ['address2', optional(string())], @@ -180,7 +424,7 @@ describe('Expando Object', () => { expect((output as any).result).toStrictEqual(input); }); - it('should map valid object with additional properties', () => { + it('should unmap valid object with additional properties', () => { const input = { id: 'John Smith', age: 50, @@ -196,6 +440,53 @@ describe('Expando Object', () => { expect((output as any).result).toStrictEqual(expected); }); + it('AdditionalProperties: should fail with conflicting additional properties', () => { + const input = { + id: 'John Smith', + age: 50, + additionalProps: { + number1: 123, + number2: 123.2, + user_age: 52, + }, + }; + const output = validateAndUnmap(input, userSchemaWithAdditionalNumbers); + expect(output.errors).toHaveLength(1); + expect(output.errors).toMatchInlineSnapshot(` + Array [ + Object { + "branch": Array [ + Object { + "additionalProps": Object { + "number1": 123, + "number2": 123.2, + "user_age": 52, + }, + "age": 50, + "id": "John Smith", + }, + ], + "message": "Some keys in additional properties are conflicting with the keys in object: \\"user_age\\". + + Given value: {\\"id\\":\\"John Smith\\",\\"age\\":50,\\"additionalProps\\":{\\"number1\\":123,\\"number2\\":123.2,\\"user_age\\":52}} + Type: 'object' + Expected type: 'Object<{id,age,...}>'", + "path": Array [], + "type": "Object<{id,age,...}>", + "value": Object { + "additionalProps": Object { + "number1": 123, + "number2": 123.2, + "user_age": 52, + }, + "age": 50, + "id": "John Smith", + }, + }, + ] + `); + }); + it('should fail on non-object value', () => { const input = 'not an object'; const output = validateAndUnmap(input as any, userSchema);