Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
46 changes: 35 additions & 11 deletions src/dynamic-handle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -530,15 +530,25 @@ export const createDynamicHandler = (app: AnyElysia) => {

if (responseValidator?.Check(response) === false) {
if (responseValidator?.Clean) {
const temp = responseValidator.Clean(response)
if (responseValidator?.Check(temp) === false)
try {
const temp = responseValidator.Clean(response)
if (responseValidator?.Check(temp) === false)
throw new ValidationError(
'response',
responseValidator,
response
)

response = temp
} catch (error) {
if (error instanceof ValidationError) throw error

throw new ValidationError(
'response',
responseValidator,
response
)

response = temp
}
} else
throw new ValidationError(
'response',
Expand All @@ -551,7 +561,9 @@ export const createDynamicHandler = (app: AnyElysia) => {
response = responseValidator.Encode(response)

if (responseValidator?.Clean)
response = responseValidator.Clean(response)
try {
response = responseValidator.Clean(response)
} catch {}
} else {
;(
context as Context & {
Expand Down Expand Up @@ -589,15 +601,25 @@ export const createDynamicHandler = (app: AnyElysia) => {

if (responseValidator?.Check(response) === false) {
if (responseValidator?.Clean) {
const temp = responseValidator.Clean(response)
if (responseValidator?.Check(temp) === false)
try {
const temp = responseValidator.Clean(response)
if (responseValidator?.Check(temp) === false)
throw new ValidationError(
'response',
responseValidator,
response
)

response = temp
} catch (error) {
if (error instanceof ValidationError) throw error

throw new ValidationError(
'response',
responseValidator,
response
)

response = temp
}
} else
throw new ValidationError(
'response',
Expand All @@ -611,8 +633,10 @@ export const createDynamicHandler = (app: AnyElysia) => {
responseValidator.Encode(response)

if (responseValidator?.Clean)
context.response = response =
responseValidator.Clean(response)
try {
context.response = response =
responseValidator.Clean(response)
} catch {}

const result = mapEarlyResponse(response, context.set)
// @ts-expect-error
Expand Down
135 changes: 135 additions & 0 deletions test/validator/response-validation-nested.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { describe, expect, it } from 'bun:test'
import { Elysia, t } from '../../src'

// Issue #1659: Response validation with nested schemas crashes with 500 instead of 422
// https://github.com/elysiajs/elysia/issues/1659
//
// Root cause: exact-mirror's Clean() function assumes valid data structure
// and throws when accessing nested properties on null values.
// Fix: Wrap Clean() calls in try-catch in dynamic-handle.ts

describe('Response validation nested schemas', () => {
it('should return 422 for invalid nested response (aot: false)', async () => {
const app = new Elysia({ aot: false }).post(
'/test',
// @ts-expect-error - intentionally returning invalid data to test validation
() => ({
items: [
['t1', { file: { ver: { s: '', m: null } } }],
['t2', { file: { ver: null } }] // Invalid - ver should be object
]
}),
{
body: t.Object({}),
response: t.Object({
items: t.Array(
t.Tuple([
t.String(),
t.Union([
t.Object({
file: t.Object({
ver: t.Object({
s: t.String(),
m: t.Nullable(t.String())
})
})
})
])
])
)
})
}
)

const res = await app.handle(
new Request('http://localhost/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}'
})
)

// Should be 422 (validation error), not 500 (internal error)
expect(res.status).toBe(422)

const json = (await res.json()) as { type: string; errors?: unknown[] }
expect(json.type).toBe('validation')
expect(json.errors?.length).toBeGreaterThan(0)
})

it('should return 422 for invalid nested response (aot: true)', async () => {
const app = new Elysia({ aot: true }).post(
'/test',
// @ts-expect-error - intentionally returning invalid data to test validation
() => ({
items: [
['t1', { file: { ver: { s: '', m: null } } }],
['t2', { file: { ver: null } }] // Invalid
]
}),
{
body: t.Object({}),
response: t.Object({
items: t.Array(
t.Tuple([
t.String(),
t.Union([
t.Object({
file: t.Object({
ver: t.Object({
s: t.String(),
m: t.Nullable(t.String())
})
})
})
])
])
)
})
}
)

const res = await app.handle(
new Request('http://localhost/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}'
})
)

expect(res.status).toBe(422)

const json = (await res.json()) as { type: string; errors?: unknown[] }
expect(json.type).toBe('validation')
expect(json.errors?.length).toBeGreaterThan(0)
})

it('should return 422 for tuple with null nested object (aot: false)', async () => {
const app = new Elysia({ aot: false }).get(
'/test',
// @ts-expect-error - intentionally returning invalid data to test validation
() => ({
data: ['id', { nested: null }] // nested should be object with 'value'
}),
{
response: t.Object({
data: t.Tuple([
t.String(),
t.Object({
nested: t.Object({
value: t.String()
})
})
])
})
}
)

const res = await app.handle(new Request('http://localhost/test'))

expect(res.status).toBe(422)

const json = (await res.json()) as { type: string }
expect(json.type).toBe('validation')
})
})
Loading