diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f838e8..5a1b029 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/deps.ts b/deps.ts index 43b7b8d..c0d6aa1 100644 --- a/deps.ts +++ b/deps.ts @@ -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"; @@ -90,6 +91,7 @@ type SubsetOfZ = Pick< | "union" | "unknown" | "util" + | "ZodError" >; /** * entry to the `Zod` API, enhanced with `@asteasolutions/zod-to-openapi`; diff --git a/dev_deps.ts b/dev_deps.ts index b9a4025..40c79fa 100644 --- a/dev_deps.ts +++ b/dev_deps.ts @@ -18,6 +18,7 @@ export { Body } from "jsr:@oak/oak@^17.0.0/body"; export { assertSpyCall, + assertSpyCallArg, assertSpyCalls, type MethodSpy, type Spy, diff --git a/jsr.json b/jsr.json index 55531ba..ee30a21 100644 --- a/jsr.json +++ b/jsr.json @@ -1,6 +1,6 @@ { "name": "@dklab/oak-routing-ctrl", - "version": "0.10.0", + "version": "0.11.0", "exports": { ".": "./mod.ts", "./mod": "./mod.ts" diff --git a/src/__snapshots__/useOakServer_test.ts.snap b/src/__snapshots__/useOakServer_test.ts.snap index 3a00831..2e4a738 100644 --- a/src/__snapshots__/useOakServer_test.ts.snap +++ b/src/__snapshots__/useOakServer_test.ts.snap @@ -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, + }, ] `; diff --git a/src/useOakServer.ts b/src/useOakServer.ts index c8a429e..7834106 100644 --- a/src/useOakServer.ts +++ b/src/useOakServer.ts @@ -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"; @@ -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(); }, ); diff --git a/src/useOakServer_test.ts b/src/useOakServer_test.ts index 661ee56..2545242 100644 --- a/src/useOakServer_test.ts +++ b/src/useOakServer_test.ts @@ -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, @@ -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"); + } } /** @@ -109,6 +130,9 @@ type TestCaseDefinition = { mockRequestPathParams?: Record; mockRequestBody?: MockRequestBodyDefinition; expectedResponse: unknown; + expectedCtxThrow?: boolean; + expectedError?: unknown; + expectedResponseStatus?: Status; }; Deno.test("useOakServer - noop Controller", () => { @@ -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( @@ -228,6 +278,9 @@ Deno.test({ mockRequestPathParams = undefined, mockRequestBody = undefined, expectedResponse, + expectedCtxThrow, + expectedError, + expectedResponseStatus, }, i) => t.step({ name: `case ${i + 1}: ${caseDescription}`, @@ -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,