Skip to content

Commit fe2bd5e

Browse files
authored
Merge pull request #143 from bufferings/fix-response-validation
Fix response validation bug
2 parents 3779b7e + 4433678 commit fe2bd5e

File tree

10 files changed

+58
-42
lines changed

10 files changed

+58
-42
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@korix/kori': patch
3+
---
4+
5+
Fix response validation bug where validators received stringified JSON instead of objects

docs/en/extensions/zod-openapi-plugin.md

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -369,27 +369,31 @@ Document all possible error responses:
369369

370370
```typescript
371371
const CommonErrorSchema = z.object({
372-
error: z
373-
.string()
374-
.meta({ description: 'Error type', example: 'Validation Error' }),
375-
message: z.string().meta({ description: 'Human-readable error message' }),
376-
details: z
377-
.array(
378-
z.object({
379-
field: z.string(),
380-
message: z.string(),
381-
}),
382-
)
383-
.optional(),
372+
error: z.object({
373+
type: z
374+
.string()
375+
.meta({ description: 'Error type', example: 'VALIDATION_ERROR' }),
376+
message: z.string().meta({ description: 'Human-readable error message' }),
377+
details: z
378+
.array(
379+
z.object({
380+
field: z.string(),
381+
message: z.string(),
382+
}),
383+
)
384+
.optional(),
385+
}),
384386
});
385387

386388
app.post('/users', {
387389
responseSchema: zodResponseSchema({
388390
'201': UserResponseSchema,
389391
'400': CommonErrorSchema,
390392
'409': z.object({
391-
error: z.literal('Conflict'),
392-
message: z.literal('Email already exists'),
393+
error: z.object({
394+
type: z.literal('CONFLICT'),
395+
message: z.literal('Email already exists'),
396+
}),
393397
}),
394398
}),
395399
// ... rest of configuration

docs/en/guide/openapi.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,8 +157,10 @@ const ProductSchema = z.object({
157157
});
158158

159159
const ErrorSchema = z.object({
160-
error: z.string(),
161-
message: z.string(),
160+
error: z.object({
161+
type: z.string(),
162+
message: z.string(),
163+
}),
162164
});
163165

164166
app.get('/products/:id', {

docs/en/guide/response-validation.md

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,10 @@ const UserSchema = z.object({
4040
});
4141

4242
const ErrorSchema = z.object({
43-
error: z.string(),
44-
message: z.string(),
43+
error: z.object({
44+
type: z.string(),
45+
message: z.string(),
46+
}),
4547
});
4648

4749
app.get('/users/:id', {
@@ -56,10 +58,7 @@ app.get('/users/:id', {
5658

5759
if (userId === 999) {
5860
// This 404 response will be validated against ErrorSchema
59-
return ctx.res.notFound({
60-
error: 'NOT_FOUND',
61-
message: 'User not found',
62-
});
61+
return ctx.res.notFound({ message: 'User not found' });
6362
}
6463

6564
// This 200 response will be validated against UserSchema

docs/ja/guide/openapi.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,8 +157,10 @@ const ProductSchema = z.object({
157157
});
158158

159159
const ErrorSchema = z.object({
160-
error: z.string(),
161-
message: z.string(),
160+
error: z.object({
161+
type: z.string(),
162+
message: z.string(),
163+
}),
162164
});
163165

164166
app.get('/products/:id', {

docs/ja/guide/response-validation.md

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,10 @@ const UserSchema = z.object({
4040
});
4141

4242
const ErrorSchema = z.object({
43-
error: z.string(),
44-
message: z.string(),
43+
error: z.object({
44+
type: z.string(),
45+
message: z.string(),
46+
}),
4547
});
4648

4749
app.get('/users/:id', {
@@ -56,10 +58,7 @@ app.get('/users/:id', {
5658

5759
if (userId === 999) {
5860
// この404レスポンスはErrorSchemaに対してバリデーションされる
59-
return ctx.res.notFound({
60-
error: 'NOT_FOUND',
61-
message: 'User not found',
62-
});
61+
return ctx.res.notFound({ message: 'User not found' });
6362
}
6463

6564
// この200レスポンスはUserSchemaに対してバリデーションされる

packages/kori/src/context/response.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -549,7 +549,7 @@ type BodyConfig<T> = {
549549

550550
function setBodyJsonInternal<T>({ res, body }: BodyConfig<T>): void {
551551
res.bodyKind = 'json';
552-
res.bodyValue = JSON.stringify(body);
552+
res.bodyValue = body;
553553
}
554554

555555
function setBodyTextInternal({ res, body }: BodyConfig<string>): void {
@@ -848,6 +848,13 @@ const sharedMethods = {
848848
let body: BodyInit | null = null;
849849
switch (this.bodyKind) {
850850
case 'json':
851+
try {
852+
body = JSON.stringify(this.bodyValue);
853+
} catch (error) {
854+
const message = error instanceof Error ? error.message : String(error);
855+
throw new KoriResponseBuildError(`Failed to serialize response body as JSON: ${message}`);
856+
}
857+
break;
851858
case 'text':
852859
case 'html':
853860
body = this.bodyValue as string;

packages/kori/tests/_internal/core/route-handler-composer.test.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ describe('composeRouteHandler', () => {
144144

145145
expect(res.getStatus()).toBe(200);
146146
expect(res.getContentType()).toBe('application/json; charset=utf-8');
147-
expect(res.getBody()).toBe(JSON.stringify({ ok: true }));
147+
expect(res.getBody()).toEqual({ ok: true });
148148
expect(handler).toHaveBeenCalledTimes(1);
149149
});
150150
});
@@ -354,7 +354,7 @@ describe('composeRouteHandler', () => {
354354
expect(res.getStatus()).toBe(200);
355355
expect(handler).toHaveBeenCalledTimes(1);
356356

357-
const responseBody = JSON.parse(res.getBody() as string);
357+
const responseBody = res.getBody() as any;
358358
expect(responseBody.body).toEqual({ test: 'data', __validated: 'body' });
359359
expect(responseBody.params).toEqual({ __validated: 'params' });
360360
expect(responseBody.queries).toEqual({ __validated: 'queries' });
@@ -386,7 +386,7 @@ describe('composeRouteHandler', () => {
386386
expect(routeOnFail).toHaveBeenCalledTimes(1);
387387
expect(instanceOnFail).not.toHaveBeenCalled();
388388
expect(res.getStatus()).toBe(400);
389-
expect(JSON.parse(res.getBody() as string).error.message).toBe('route handled');
389+
expect((res.getBody() as any).error.message).toBe('route handled');
390390
expect(handler).not.toHaveBeenCalled();
391391
});
392392

@@ -413,7 +413,7 @@ describe('composeRouteHandler', () => {
413413
expect(routeOnFail).toHaveBeenCalledTimes(1);
414414
expect(instanceOnFail).toHaveBeenCalledTimes(1);
415415
expect(res.getStatus()).toBe(400);
416-
expect(JSON.parse(res.getBody() as string).error.message).toBe('instance handled');
416+
expect((res.getBody() as any).error.message).toBe('instance handled');
417417
expect(handler).not.toHaveBeenCalled();
418418
});
419419

@@ -534,7 +534,7 @@ describe('composeRouteHandler', () => {
534534
expect(routeOnFail).toHaveBeenCalledTimes(1);
535535
expect(instanceOnFail).not.toHaveBeenCalled();
536536
expect(res.getStatus()).toBe(400);
537-
expect(JSON.parse(res.getBody() as string).error.message).toBe('route handled response failure');
537+
expect((res.getBody() as any).error.message).toBe('route handled response failure');
538538
});
539539

540540
test('stops early at instance handler', async () => {
@@ -560,7 +560,7 @@ describe('composeRouteHandler', () => {
560560
expect(routeOnFail).toHaveBeenCalledTimes(1);
561561
expect(instanceOnFail).toHaveBeenCalledTimes(1);
562562
expect(res.getStatus()).toBe(400);
563-
expect(JSON.parse(res.getBody() as string).error.message).toBe('instance handled response failure');
563+
expect((res.getBody() as any).error.message).toBe('instance handled response failure');
564564
});
565565

566566
test('cascades through all handlers to fallback', async () => {
@@ -631,7 +631,7 @@ describe('composeRouteHandler', () => {
631631
expect(errorHook2).toHaveBeenCalledTimes(1);
632632
expect(errorHook3).not.toHaveBeenCalled();
633633
expect(res.getStatus()).toBe(400);
634-
expect(JSON.parse(res.getBody() as string).error.message).toBe('handled by hook2');
634+
expect((res.getBody() as any).error.message).toBe('handled by hook2');
635635
});
636636

637637
test('falls back to 500 when no error hook handles', async () => {
@@ -674,7 +674,7 @@ describe('composeRouteHandler', () => {
674674
expect(errorHook2).toHaveBeenCalledTimes(1);
675675
expect(errorHook3).not.toHaveBeenCalled();
676676
expect(res.getStatus()).toBe(400);
677-
expect(JSON.parse(res.getBody() as string).error.message).toBe('recovered by hook2');
677+
expect((res.getBody() as any).error.message).toBe('recovered by hook2');
678678
});
679679
});
680680
});

packages/kori/tests/context/handler-context.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,7 @@ describe('KoriHandlerContext contract', () => {
4040
const extendedCtx = ctx.withRes({ ok: (data: unknown) => ctx.res.json({ ok: true, data }) });
4141

4242
const response = extendedCtx.res.ok({ message: 'success' });
43-
const bodyString = response.getBody() as string;
44-
const bodyObj = JSON.parse(bodyString);
43+
const bodyObj = response.getBody();
4544
expect(bodyObj).toEqual({ ok: true, data: { message: 'success' } });
4645
});
4746

turbo.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
{
22
"$schema": "https://turbo.build/schema.json",
3-
"ui": "tui",
43
"tasks": {
54
"//#clean:root": {
65
"cache": false

0 commit comments

Comments
 (0)