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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
## [0.11.0] - 2024-09-11

### Changed

- if a handler function throws because of a `Zod` validation error (ie. from
`ZodSchema.parse()`), the response will automatically have status `400` and
the whole `ZodError` as the response text

## [0.10.0] - 2024-09-08

### Added
Expand Down
4 changes: 3 additions & 1 deletion deps.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
export { join } from "jsr:@std/path@^1.0.4";

export { Router } from "jsr:@oak/oak@^17.0.0";
export { Router, Status } from "jsr:@oak/oak@^17.0.0";

export type {
Application,
Context,
ErrorStatus,
Next,
RouteContext,
} from "jsr:@oak/oak@^17.0.0";
Expand Down Expand Up @@ -90,6 +91,7 @@ type SubsetOfZ = Pick<
| "union"
| "unknown"
| "util"
| "ZodError"
>;
/**
* entry to the `Zod` API, enhanced with `@asteasolutions/zod-to-openapi`;
Expand Down
1 change: 1 addition & 0 deletions dev_deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export { Body } from "jsr:@oak/oak@^17.0.0/body";

export {
assertSpyCall,
assertSpyCallArg,
assertSpyCalls,
type MethodSpy,
type Spy,
Expand Down
2 changes: 1 addition & 1 deletion jsr.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@dklab/oak-routing-ctrl",
"version": "0.10.0",
"version": "0.11.0",
"exports": {
".": "./mod.ts",
"./mod": "./mod.ts"
Expand Down
35 changes: 35 additions & 0 deletions src/__snapshots__/useOakServer_test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -163,5 +163,40 @@ snapshot[`useOakServer - fully decorated Controller 1`] = `
path: "/test/uah",
regexp: /^\\/test\\/uah[\\/#\\?]?\$/i,
},
{
methods: [
"HEAD",
"GET",
],
middleware: [
[AsyncFunction (anonymous)],
],
options: {
end: undefined,
ignoreCaptures: undefined,
sensitive: undefined,
strict: undefined,
},
paramNames: [],
path: "/test/zodError",
regexp: /^\\/test\\/zodError[\\/#\\?]?\$/i,
},
{
methods: [
"POST",
],
middleware: [
[AsyncFunction (anonymous)],
],
options: {
end: undefined,
ignoreCaptures: undefined,
sensitive: undefined,
strict: undefined,
},
paramNames: [],
path: "/test/arbitraryError",
regexp: /^\\/test\\/arbitraryError[\\/#\\?]?\$/i,
},
]
`;
21 changes: 14 additions & 7 deletions src/useOakServer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { debug } from "./utils/logger.ts";
import { type Application, Router } from "../deps.ts";
import { type Application, Router, Status, z } from "../deps.ts";
import type { ControllerClass } from "./Controller.ts";
import { store } from "./Store.ts";

Expand Down Expand Up @@ -28,12 +28,19 @@ export const useOakServer = (
Ctrl.prototype,
propName,
)?.value;
const handlerRetVal = await handler.call(ctrl, ctx);
// some developers set body within the handler,
// some developers return something from the handler
// and expect that it gets assigned to the response,
// so by doing the following, we satisfy both use cases
ctx.response.body = ctx.response.body ?? handlerRetVal;
try {
const handlerRetVal = await handler.call(ctrl, ctx);
// some developers set body within the handler,
// some developers return something from the handler
// and expect that it gets assigned to the response,
// so by doing the following, we satisfy both use cases
ctx.response.body = ctx.response.body ?? handlerRetVal;
} catch (e) {
if (e instanceof z.ZodError) {
return ctx.throw(Status.BadRequest, e.toString());
}
throw e;
}
await next();
},
);
Expand Down
87 changes: 84 additions & 3 deletions src/useOakServer_test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
import type { SupportedVerb } from "./Store.ts";
import { type Context, RouteContext } from "../deps.ts";
import { assertEquals, assertSnapshot, oakTesting } from "../dev_deps.ts";
import {
type Context,
type ErrorStatus,
RouteContext,
Status,
z,
} from "../deps.ts";
import {
assertEquals,
assertSnapshot,
assertSpyCallArg,
assertSpyCalls,
oakTesting,
spy,
} from "../dev_deps.ts";
import {
Controller,
type ControllerMethodArg,
Expand Down Expand Up @@ -94,6 +107,14 @@ class TestController {
uah(body: ArrayBuffer) {
return `hello, ArrayBuffer body with byteLength=${body.byteLength}`;
}
@Get("/zodError")
zodError() {
z.enum(["alice", "bob"]).parse("camela");
}
@Post("/arbitraryError")
arbitraryError() {
throw new Error("nah");
}
}

/**
Expand All @@ -109,6 +130,9 @@ type TestCaseDefinition = {
mockRequestPathParams?: Record<string, string>;
mockRequestBody?: MockRequestBodyDefinition;
expectedResponse: unknown;
expectedCtxThrow?: boolean;
expectedError?: unknown;
expectedResponseStatus?: Status;
};

Deno.test("useOakServer - noop Controller", () => {
Expand Down Expand Up @@ -218,6 +242,32 @@ Deno.test({
},
expectedResponse: "hello, ArrayBuffer body with byteLength=42",
},
{
caseDescription: "handler where a ZodError (validation error) happens",
method: "get",
expectedCtxThrow: true,
expectedError: `[
{
"received": "camela",
"code": "invalid_enum_value",
"options": [
"alice",
"bob"
],
"path": [],
"message": "Invalid enum value. Expected 'alice' | 'bob', received 'camela'"
}
]`,
expectedResponse: undefined,
expectedResponseStatus: Status.BadRequest,
},
{
caseDescription: "handler where an arbitrary error happens",
method: "post",
expectedError: "nah",
expectedResponse: undefined,
expectedResponseStatus: Status.InternalServerError,
},
];

await Promise.all(
Expand All @@ -228,6 +278,9 @@ Deno.test({
mockRequestPathParams = undefined,
mockRequestBody = undefined,
expectedResponse,
expectedCtxThrow,
expectedError,
expectedResponseStatus,
}, i) =>
t.step({
name: `case ${i + 1}: ${caseDescription}`,
Expand All @@ -245,7 +298,35 @@ Deno.test({
const next = oakTesting.createMockNext();
useOakServer(ctx.app, [TestController]);
const routes = Array.from(useOakServerInternal.oakRouter.values());
await routes[i].middleware[0]?.(ctx, next); // simulate the route being requested
const spyCtxThrow = spy(ctx, "throw");
try {
// simulate the route being requested
await routes[i].middleware[0]?.(ctx, next);
} catch (e) {
const theErrMsg = (e as Error).message;
if (expectedCtxThrow) {
assertSpyCalls(spyCtxThrow, 1);
assertSpyCallArg(
spyCtxThrow,
0,
0,
expectedResponseStatus as ErrorStatus,
);
assertSpyCallArg(
spyCtxThrow,
0,
1,
JSON.stringify(
JSON.parse(expectedError as string),
undefined,
2,
),
);
} else {
assertSpyCalls(spyCtxThrow, 0);
assertEquals(theErrMsg, expectedError);
}
}
assertEquals(ctx.response.body, expectedResponse);
},
sanitizeOps: false,
Expand Down