Skip to content

Commit f8a52b9

Browse files
authored
Merge pull request #1683 from raunak-rpm/fix/1659-validation-nested-schema-error
fix: response validation returns 500 instead of 422 for nested schemas
2 parents 36bc9b8 + 2872e3b commit f8a52b9

File tree

2 files changed

+170
-11
lines changed

2 files changed

+170
-11
lines changed

src/dynamic-handle.ts

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -530,15 +530,25 @@ export const createDynamicHandler = (app: AnyElysia) => {
530530

531531
if (responseValidator?.Check(response) === false) {
532532
if (responseValidator?.Clean) {
533-
const temp = responseValidator.Clean(response)
534-
if (responseValidator?.Check(temp) === false)
533+
try {
534+
const temp = responseValidator.Clean(response)
535+
if (responseValidator?.Check(temp) === false)
536+
throw new ValidationError(
537+
'response',
538+
responseValidator,
539+
response
540+
)
541+
542+
response = temp
543+
} catch (error) {
544+
if (error instanceof ValidationError) throw error
545+
535546
throw new ValidationError(
536547
'response',
537548
responseValidator,
538549
response
539550
)
540-
541-
response = temp
551+
}
542552
} else
543553
throw new ValidationError(
544554
'response',
@@ -551,7 +561,9 @@ export const createDynamicHandler = (app: AnyElysia) => {
551561
response = responseValidator.Encode(response)
552562

553563
if (responseValidator?.Clean)
554-
response = responseValidator.Clean(response)
564+
try {
565+
response = responseValidator.Clean(response)
566+
} catch {}
555567
} else {
556568
;(
557569
context as Context & {
@@ -589,15 +601,25 @@ export const createDynamicHandler = (app: AnyElysia) => {
589601

590602
if (responseValidator?.Check(response) === false) {
591603
if (responseValidator?.Clean) {
592-
const temp = responseValidator.Clean(response)
593-
if (responseValidator?.Check(temp) === false)
604+
try {
605+
const temp = responseValidator.Clean(response)
606+
if (responseValidator?.Check(temp) === false)
607+
throw new ValidationError(
608+
'response',
609+
responseValidator,
610+
response
611+
)
612+
613+
response = temp
614+
} catch (error) {
615+
if (error instanceof ValidationError) throw error
616+
594617
throw new ValidationError(
595618
'response',
596619
responseValidator,
597620
response
598621
)
599-
600-
response = temp
622+
}
601623
} else
602624
throw new ValidationError(
603625
'response',
@@ -611,8 +633,10 @@ export const createDynamicHandler = (app: AnyElysia) => {
611633
responseValidator.Encode(response)
612634

613635
if (responseValidator?.Clean)
614-
context.response = response =
615-
responseValidator.Clean(response)
636+
try {
637+
context.response = response =
638+
responseValidator.Clean(response)
639+
} catch {}
616640

617641
const result = mapEarlyResponse(response, context.set)
618642
// @ts-expect-error
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { describe, expect, it } from 'bun:test'
2+
import { Elysia, t } from '../../src'
3+
4+
// Issue #1659: Response validation with nested schemas crashes with 500 instead of 422
5+
// https://github.com/elysiajs/elysia/issues/1659
6+
//
7+
// Root cause: exact-mirror's Clean() function assumes valid data structure
8+
// and throws when accessing nested properties on null values.
9+
// Fix: Wrap Clean() calls in try-catch in dynamic-handle.ts
10+
11+
describe('Response validation nested schemas', () => {
12+
it('should return 422 for invalid nested response (aot: false)', async () => {
13+
const app = new Elysia({ aot: false }).post(
14+
'/test',
15+
// @ts-expect-error - intentionally returning invalid data to test validation
16+
() => ({
17+
items: [
18+
['t1', { file: { ver: { s: '', m: null } } }],
19+
['t2', { file: { ver: null } }] // Invalid - ver should be object
20+
]
21+
}),
22+
{
23+
body: t.Object({}),
24+
response: t.Object({
25+
items: t.Array(
26+
t.Tuple([
27+
t.String(),
28+
t.Union([
29+
t.Object({
30+
file: t.Object({
31+
ver: t.Object({
32+
s: t.String(),
33+
m: t.Nullable(t.String())
34+
})
35+
})
36+
})
37+
])
38+
])
39+
)
40+
})
41+
}
42+
)
43+
44+
const res = await app.handle(
45+
new Request('http://localhost/test', {
46+
method: 'POST',
47+
headers: { 'Content-Type': 'application/json' },
48+
body: '{}'
49+
})
50+
)
51+
52+
// Should be 422 (validation error), not 500 (internal error)
53+
expect(res.status).toBe(422)
54+
55+
const json = (await res.json()) as { type: string; errors?: unknown[] }
56+
expect(json.type).toBe('validation')
57+
expect(json.errors?.length).toBeGreaterThan(0)
58+
})
59+
60+
it('should return 422 for invalid nested response (aot: true)', async () => {
61+
const app = new Elysia({ aot: true }).post(
62+
'/test',
63+
// @ts-expect-error - intentionally returning invalid data to test validation
64+
() => ({
65+
items: [
66+
['t1', { file: { ver: { s: '', m: null } } }],
67+
['t2', { file: { ver: null } }] // Invalid
68+
]
69+
}),
70+
{
71+
body: t.Object({}),
72+
response: t.Object({
73+
items: t.Array(
74+
t.Tuple([
75+
t.String(),
76+
t.Union([
77+
t.Object({
78+
file: t.Object({
79+
ver: t.Object({
80+
s: t.String(),
81+
m: t.Nullable(t.String())
82+
})
83+
})
84+
})
85+
])
86+
])
87+
)
88+
})
89+
}
90+
)
91+
92+
const res = await app.handle(
93+
new Request('http://localhost/test', {
94+
method: 'POST',
95+
headers: { 'Content-Type': 'application/json' },
96+
body: '{}'
97+
})
98+
)
99+
100+
expect(res.status).toBe(422)
101+
102+
const json = (await res.json()) as { type: string; errors?: unknown[] }
103+
expect(json.type).toBe('validation')
104+
expect(json.errors?.length).toBeGreaterThan(0)
105+
})
106+
107+
it('should return 422 for tuple with null nested object (aot: false)', async () => {
108+
const app = new Elysia({ aot: false }).get(
109+
'/test',
110+
// @ts-expect-error - intentionally returning invalid data to test validation
111+
() => ({
112+
data: ['id', { nested: null }] // nested should be object with 'value'
113+
}),
114+
{
115+
response: t.Object({
116+
data: t.Tuple([
117+
t.String(),
118+
t.Object({
119+
nested: t.Object({
120+
value: t.String()
121+
})
122+
})
123+
])
124+
})
125+
}
126+
)
127+
128+
const res = await app.handle(new Request('http://localhost/test'))
129+
130+
expect(res.status).toBe(422)
131+
132+
const json = (await res.json()) as { type: string }
133+
expect(json.type).toBe('validation')
134+
})
135+
})

0 commit comments

Comments
 (0)