From cff64645c5c4c60798412edf1328f30ba516860b Mon Sep 17 00:00:00 2001 From: dk <2597375+Thesephi@users.noreply.github.com> Date: Wed, 11 Sep 2024 02:54:40 +0200 Subject: [PATCH 1/3] v0.11.0 - see CHANGELOG for details --- CHANGELOG.md | 8 ++ deps.ts | 4 +- dev_deps.ts | 1 + jsr.json | 2 +- src/__snapshots__/useOakServer_test.ts.snap | 35 ++++++++ src/useOakServer.ts | 21 +++-- src/useOakServer_test.ts | 96 +++++++++++++++++++-- 7 files changed, 150 insertions(+), 17 deletions(-) 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..41c9c95 100644 --- a/src/useOakServer_test.ts +++ b/src/useOakServer_test.ts @@ -1,6 +1,18 @@ 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, + assertSpyCalls, + oakTesting, + spy, +} from "../dev_deps.ts"; import { Controller, type ControllerMethodArg, @@ -19,6 +31,7 @@ import { type MockRequestBodyDefinition, mockRequestInternals, } from "../test_utils/mockRequestInternals.ts"; +import { assertSpyCallArg } from "jsr:@std/testing@^1.0.2/mock"; const staticFormData = new FormData(); staticFormData.append("foo", "phiil"); @@ -27,11 +40,11 @@ const arrayBufferLen42 = new ArrayBuffer(42); @Controller("/noop") class NoopController { - noop() {} + noop() { } } class UndecoratedController { - noop() {} + noop() { } } @Controller("/test") @@ -61,9 +74,8 @@ class TestController { @ControllerMethodArgs("body") taz(body: ArrayBuffer, ctx: RouteContext<"/taz/:someId">) { const td = new TextDecoder(); - return `hello, path param ${ctx.params.someId} with someBlob=${ - td.decode(body) - }`; + return `hello, path param ${ctx.params.someId} with someBlob=${td.decode(body) + }`; } @Delete("/raz/") raz() { @@ -94,6 +106,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 +129,9 @@ type TestCaseDefinition = { mockRequestPathParams?: Record; mockRequestBody?: MockRequestBodyDefinition; expectedResponse: unknown; + expectedCtxThrow?: boolean; + expectedError?: unknown; + expectedResponseStatus?: Status; }; Deno.test("useOakServer - noop Controller", () => { @@ -218,6 +241,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 +277,9 @@ Deno.test({ mockRequestPathParams = undefined, mockRequestBody = undefined, expectedResponse, + expectedCtxThrow, + expectedError, + expectedResponseStatus, }, i) => t.step({ name: `case ${i + 1}: ${caseDescription}`, @@ -245,7 +297,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, From d55c022d8e7d2e2d759d7b95c9e80bc59329cc26 Mon Sep 17 00:00:00 2001 From: dk <2597375+Thesephi@users.noreply.github.com> Date: Wed, 11 Sep 2024 02:57:18 +0200 Subject: [PATCH 2/3] using deno v1 for fmt --- src/useOakServer_test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/useOakServer_test.ts b/src/useOakServer_test.ts index 41c9c95..8f516f0 100644 --- a/src/useOakServer_test.ts +++ b/src/useOakServer_test.ts @@ -40,11 +40,11 @@ const arrayBufferLen42 = new ArrayBuffer(42); @Controller("/noop") class NoopController { - noop() { } + noop() {} } class UndecoratedController { - noop() { } + noop() {} } @Controller("/test") @@ -74,8 +74,9 @@ class TestController { @ControllerMethodArgs("body") taz(body: ArrayBuffer, ctx: RouteContext<"/taz/:someId">) { const td = new TextDecoder(); - return `hello, path param ${ctx.params.someId} with someBlob=${td.decode(body) - }`; + return `hello, path param ${ctx.params.someId} with someBlob=${ + td.decode(body) + }`; } @Delete("/raz/") raz() { From c5609cd67f8f30a36b53580017dcb2613d483b06 Mon Sep 17 00:00:00 2001 From: dk <2597375+Thesephi@users.noreply.github.com> Date: Wed, 11 Sep 2024 03:01:01 +0200 Subject: [PATCH 3/3] import cleanup --- src/useOakServer_test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/useOakServer_test.ts b/src/useOakServer_test.ts index 8f516f0..2545242 100644 --- a/src/useOakServer_test.ts +++ b/src/useOakServer_test.ts @@ -9,6 +9,7 @@ import { import { assertEquals, assertSnapshot, + assertSpyCallArg, assertSpyCalls, oakTesting, spy, @@ -31,7 +32,6 @@ import { type MockRequestBodyDefinition, mockRequestInternals, } from "../test_utils/mockRequestInternals.ts"; -import { assertSpyCallArg } from "jsr:@std/testing@^1.0.2/mock"; const staticFormData = new FormData(); staticFormData.append("foo", "phiil");