Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 57 additions & 20 deletions src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,39 @@ export const isOptional = (
return !!schema && OptionalKind in schema
}

export const hasPatternProperties = (
_schema: TAnySchema | TypeCheck<any> | ElysiaTypeCheck<any>
): boolean => {
if (!_schema) return false

// @ts-expect-error private property
const schema: TAnySchema = (_schema as TypeCheck<any>)?.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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

if (schema.type === 'object' && schema.properties) {
const properties = schema.properties as Record<string, TAnySchema>

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<any> | ElysiaTypeCheck<any>
): boolean => {
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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',
Expand Down
142 changes: 142 additions & 0 deletions test/validator/response.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
})
})
Loading