From 0e494af5a244d1ccb0f700876df8903094a0d016 Mon Sep 17 00:00:00 2001 From: MarcelOlsen Date: Tue, 23 Dec 2025 14:15:06 +0100 Subject: [PATCH 1/3] :wrench: fix: t.Record validation failure in response schemas Skip normalization for schemas with patternProperties to prevent Clean from removing valid Record properties --- src/schema.ts | 77 ++++++++++++----- test/validator/response.test.ts | 142 ++++++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+), 20 deletions(-) diff --git a/src/schema.ts b/src/schema.ts index cec272ef..4d046f9a 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -80,6 +80,39 @@ export const isOptional = ( return !!schema && OptionalKind in schema } +export const hasPatternProperties = ( + _schema: TAnySchema | TypeCheck | ElysiaTypeCheck +): boolean => { + if (!_schema) return false + + // @ts-expect-error private property + const schema: TAnySchema = (_schema as TypeCheck)?.schema ?? _schema + + if ('patternProperties' in schema) return true + + if (schema[Kind] === 'Import' && _schema.References) + return _schema.References().some(hasPatternProperties) + + if (schema.anyOf) return schema.anyOf.some(hasPatternProperties) + if (schema.oneOf) return schema.oneOf.some(hasPatternProperties) + if (schema.someOf) return schema.someOf.some(hasPatternProperties) + if (schema.allOf) return schema.allOf.some(hasPatternProperties) + if (schema.not) return schema.not.some(hasPatternProperties) + + if (schema.type === 'object' && schema.properties) { + const properties = schema.properties as Record + + for (const key of Object.keys(properties)) { + if (hasPatternProperties(properties[key])) return true + } + } + + if (schema.type === 'array' && schema.items && !Array.isArray(schema.items)) + return hasPatternProperties(schema.items) + + return false +} + export const hasAdditionalProperties = ( _schema: TAnySchema | TypeCheck | ElysiaTypeCheck ): boolean => { @@ -684,14 +717,15 @@ export const getSchemaValidator = < }) ]) - if (schema.type === 'object' && hasAdditional) + if (schema.type === 'object' && hasAdditional && !('patternProperties' in schema)) schema.additionalProperties = false } } else { if ( schema.type === 'object' && ('additionalProperties' in schema === false || - forceAdditionalProperties) + forceAdditionalProperties) && + !('patternProperties' in schema) ) schema.additionalProperties = additionalProperties else @@ -701,6 +735,7 @@ export const getSchemaValidator = < to(schema) { if (!schema.properties) return schema; if ("additionalProperties" in schema) return schema; + if ("patternProperties" in schema) return schema; return t.Object(schema.properties, { ...schema, @@ -764,7 +799,7 @@ export const getSchemaValidator = < if (validator?.schema?.config) delete validator.schema.config } - if (normalize && schema.additionalProperties === false) { + if (normalize && schema.additionalProperties === false && !hasPatternProperties(schema)) { if (normalize === true || normalize === 'exactMirror') { try { validator.Clean = createMirror(schema, { @@ -903,25 +938,27 @@ export const getSchemaValidator = < if (compiled?.schema?.config) delete compiled.schema.config } - if (normalize === true || normalize === 'exactMirror') { - try { - compiled.Clean = createMirror(schema, { - TypeCompiler, - sanitize: sanitize?.(), - modules - }) - } catch (error) { - console.warn( - 'Failed to create exactMirror. Please report the following code to https://github.com/elysiajs/elysia/issues' - ) - console.dir(schema, { - depth: null - }) + if (normalize && !hasPatternProperties(schema)) { + if (normalize === true || normalize === 'exactMirror') { + try { + compiled.Clean = createMirror(schema, { + TypeCompiler, + sanitize: sanitize?.(), + modules + }) + } catch (error) { + console.warn( + 'Failed to create exactMirror. Please report the following code to https://github.com/elysiajs/elysia/issues' + ) + console.dir(schema, { + depth: null + }) + compiled.Clean = createCleaner(schema) + } + } else if (normalize === 'typebox') compiled.Clean = createCleaner(schema) - } - } else if (normalize === 'typebox') - compiled.Clean = createCleaner(schema) + } } else { compiled = { provider: 'standard', diff --git a/test/validator/response.test.ts b/test/validator/response.test.ts index e1bb36b3..bf10e977 100644 --- a/test/validator/response.test.ts +++ b/test/validator/response.test.ts @@ -562,4 +562,146 @@ describe('Response Validator', () => { expect(result.join('')).toContain('data: {"name":"Name"}') }) + + // Regression tests for t.Record validation bug + // https://github.com/elysiajs/elysia/issues/XXXX + it('validate Record with nested objects (original bug)', async () => { + const app = new Elysia().get( + '', + () => ({ + list: [ + { + toto: { bar: 1 }, + foo: { link: 'first' } + } + ], + one: { + toto: { bar: 0 }, + foo: { link: 'second' } + } + }), + { + response: { + 200: t.Object({ + list: t.Array( + t.Object({ + toto: t.Object({ bar: t.Integer() }), + foo: t.Record(t.String(), t.String()) + }) + ), + one: t.Object({ + toto: t.Object({ bar: t.Integer() }), + foo: t.Record(t.String(), t.String()) + }) + }) + } + } + ) + + const res = await app.handle(req('/')) + const json = await res.json() + + // Should return 200, not 422 validation error + expect(res.status).toBe(200) + expect(json).toEqual({ + list: [ + { + toto: { bar: 1 }, + foo: { link: 'first' } + } + ], + one: { + toto: { bar: 0 }, + foo: { link: 'second' } + } + }) + }) + + it('validate simple Record response', async () => { + const app = new Elysia().get( + '/', + () => ({ key1: 'value1', key2: 'value2' }), + { + response: t.Record(t.String(), t.String()) + } + ) + + const res = await app.handle(req('/')) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ key1: 'value1', key2: 'value2' }) + }) + + it('validate Record with object values', async () => { + const app = new Elysia().get( + '/', + () => ({ + user1: { name: 'John', age: 30 }, + user2: { name: 'Jane', age: 25 } + }), + { + response: t.Record( + t.String(), + t.Object({ + name: t.String(), + age: t.Number() + }) + ) + } + ) + + const res = await app.handle(req('/')) + expect(res.status).toBe(200) + }) + + it('reject invalid Record response values', async () => { + const app = new Elysia().get( + '/', + () => ({ foo: 123 }) as any, + { + response: t.Record(t.String(), t.String()) + } + ) + + const res = await app.handle(req('/')) + expect(res.status).toBe(422) + }) + + it('validate nested Record in Record', async () => { + const app = new Elysia().get( + '/', + () => ({ + level1: { + level2: { + value: 'nested' + } + } + }), + { + response: t.Record( + t.String(), + t.Record(t.String(), t.Record(t.String(), t.String())) + ) + } + ) + + const res = await app.handle(req('/')) + expect(res.status).toBe(200) + }) + + it('preserve additionalProperties=false on regular Objects', async () => { + const app = new Elysia().get( + '/', + () => ({ name: 'test', extra: 'should be removed' }) as any, + { + response: t.Object({ + name: t.String() + }) + } + ) + + const res = await app.handle(req('/')) + expect(res.status).toBe(200) + // Clean function should remove extra property + expect(await res.json()).toEqual({ name: 'test' }) + }) }) From 970a604e3dc319a6dc74d5d4672a439767e31a8a Mon Sep 17 00:00:00 2001 From: MarcelOlsen Date: Tue, 23 Dec 2025 14:29:04 +0100 Subject: [PATCH 2/3] :wrench: fix: improve hasPatternProperties and test code quality - Fix schema.not handling (single schema, not array) - Simplify Clean condition for consistency - Replace 'as any' with @ts-expect-error in tests - Remove unnecessary comments --- src/schema.ts | 4 ++-- test/validator/response.test.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/schema.ts b/src/schema.ts index 4d046f9a..33a61278 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -97,7 +97,7 @@ export const hasPatternProperties = ( if (schema.oneOf) return schema.oneOf.some(hasPatternProperties) if (schema.someOf) return schema.someOf.some(hasPatternProperties) if (schema.allOf) return schema.allOf.some(hasPatternProperties) - if (schema.not) return schema.not.some(hasPatternProperties) + if (schema.not) return hasPatternProperties(schema.not) if (schema.type === 'object' && schema.properties) { const properties = schema.properties as Record @@ -799,7 +799,7 @@ export const getSchemaValidator = < if (validator?.schema?.config) delete validator.schema.config } - if (normalize && schema.additionalProperties === false && !hasPatternProperties(schema)) { + if (normalize && !hasPatternProperties(schema)) { if (normalize === true || normalize === 'exactMirror') { try { validator.Clean = createMirror(schema, { diff --git a/test/validator/response.test.ts b/test/validator/response.test.ts index bf10e977..12b7d952 100644 --- a/test/validator/response.test.ts +++ b/test/validator/response.test.ts @@ -563,8 +563,6 @@ describe('Response Validator', () => { expect(result.join('')).toContain('data: {"name":"Name"}') }) - // Regression tests for t.Record validation bug - // https://github.com/elysiajs/elysia/issues/XXXX it('validate Record with nested objects (original bug)', async () => { const app = new Elysia().get( '', @@ -656,7 +654,8 @@ describe('Response Validator', () => { it('reject invalid Record response values', async () => { const app = new Elysia().get( '/', - () => ({ foo: 123 }) as any, + // @ts-expect-error - Testing validation failure with wrong type + () => ({ foo: 123 }), { response: t.Record(t.String(), t.String()) } @@ -691,7 +690,8 @@ describe('Response Validator', () => { it('preserve additionalProperties=false on regular Objects', async () => { const app = new Elysia().get( '/', - () => ({ name: 'test', extra: 'should be removed' }) as any, + // @ts-expect-error - Testing Clean removes extra properties + () => ({ name: 'test', extra: 'should be removed' }), { response: t.Object({ name: t.String() From a22a463aadca69b8779140eac0dbb2f6a031cd2d Mon Sep 17 00:00:00 2001 From: MarcelOlsen Date: Fri, 26 Dec 2025 12:09:18 +0100 Subject: [PATCH 3/3] :wrench: fix: add TAnySchema type casts for exact-mirror compatibility - Update createMirror calls to use TAnySchema instead of TSchema - Add regression test for Record with nested objects - Ensures compatibility with exact-mirror's stricter type signature --- src/schema.ts | 6 +- test/validator/response.test.ts | 147 ++++---------------------------- 2 files changed, 20 insertions(+), 133 deletions(-) diff --git a/src/schema.ts b/src/schema.ts index 33a61278..3051fd30 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -540,7 +540,7 @@ export const getSchemaValidator = < let mirror: Function if (normalize === true || normalize === 'exactMirror') try { - mirror = createMirror(schema as TSchema, { + mirror = createMirror(schema as TAnySchema, { TypeCompiler, sanitize: sanitize?.(), modules @@ -802,7 +802,7 @@ export const getSchemaValidator = < if (normalize && !hasPatternProperties(schema)) { if (normalize === true || normalize === 'exactMirror') { try { - validator.Clean = createMirror(schema, { + validator.Clean = createMirror(schema as TAnySchema, { TypeCompiler, sanitize: sanitize?.(), modules @@ -941,7 +941,7 @@ export const getSchemaValidator = < if (normalize && !hasPatternProperties(schema)) { if (normalize === true || normalize === 'exactMirror') { try { - compiled.Clean = createMirror(schema, { + compiled.Clean = createMirror(schema as TAnySchema, { TypeCompiler, sanitize: sanitize?.(), modules diff --git a/test/validator/response.test.ts b/test/validator/response.test.ts index 12b7d952..58d1f9fb 100644 --- a/test/validator/response.test.ts +++ b/test/validator/response.test.ts @@ -563,145 +563,32 @@ describe('Response Validator', () => { expect(result.join('')).toContain('data: {"name":"Name"}') }) - it('validate Record with nested objects (original bug)', async () => { - const app = new Elysia().get( - '', - () => ({ - list: [ - { - toto: { bar: 1 }, - foo: { link: 'first' } - } - ], - one: { - toto: { bar: 0 }, - foo: { link: 'second' } - } - }), - { - response: { - 200: t.Object({ - list: t.Array( - t.Object({ - toto: t.Object({ bar: t.Integer() }), - foo: t.Record(t.String(), t.String()) - }) - ), - one: t.Object({ - toto: t.Object({ bar: t.Integer() }), - foo: t.Record(t.String(), t.String()) - }) - }) - } - } - ) - - const res = await app.handle(req('/')) - const json = await res.json() - - // Should return 200, not 422 validation error - expect(res.status).toBe(200) - expect(json).toEqual({ - list: [ - { - toto: { bar: 1 }, - foo: { link: 'first' } - } - ], + it('validate Record with nested objects', async () => { + const app = new Elysia().get('/', () => ({ + list: [{ + toto: { bar: 1 }, + foo: { link: 'first' } + }], one: { toto: { bar: 0 }, foo: { link: 'second' } } - }) - }) - - it('validate simple Record response', async () => { - const app = new Elysia().get( - '/', - () => ({ key1: 'value1', key2: 'value2' }), - { - response: t.Record(t.String(), t.String()) - } - ) - - const res = await app.handle(req('/')) - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ key1: 'value1', key2: 'value2' }) - }) - - it('validate Record with object values', async () => { - const app = new Elysia().get( - '/', - () => ({ - user1: { name: 'John', age: 30 }, - user2: { name: 'Jane', age: 25 } - }), - { - response: t.Record( - t.String(), - t.Object({ - name: t.String(), - age: t.Number() + }), { + response: { + 200: t.Object({ + list: t.Array(t.Object({ + toto: t.Object({ bar: t.Integer() }), + foo: t.Record(t.String(), t.String()) + })), + one: t.Object({ + toto: t.Object({ bar: t.Integer() }), + foo: t.Record(t.String(), t.String()) }) - ) - } - ) - - const res = await app.handle(req('/')) - expect(res.status).toBe(200) - }) - - it('reject invalid Record response values', async () => { - const app = new Elysia().get( - '/', - // @ts-expect-error - Testing validation failure with wrong type - () => ({ foo: 123 }), - { - response: t.Record(t.String(), t.String()) - } - ) - - const res = await app.handle(req('/')) - expect(res.status).toBe(422) - }) - - it('validate nested Record in Record', async () => { - const app = new Elysia().get( - '/', - () => ({ - level1: { - level2: { - value: 'nested' - } - } - }), - { - response: t.Record( - t.String(), - t.Record(t.String(), t.Record(t.String(), t.String())) - ) - } - ) - - const res = await app.handle(req('/')) - expect(res.status).toBe(200) - }) - - it('preserve additionalProperties=false on regular Objects', async () => { - const app = new Elysia().get( - '/', - // @ts-expect-error - Testing Clean removes extra properties - () => ({ name: 'test', extra: 'should be removed' }), - { - response: t.Object({ - name: t.String() }) } - ) + }) const res = await app.handle(req('/')) expect(res.status).toBe(200) - // Clean function should remove extra property - expect(await res.json()).toEqual({ name: 'test' }) }) })