From 6349f0e4970e93e7e0dcc1f09cdc83b7cc0568ab Mon Sep 17 00:00:00 2001 From: zergzorg Date: Thu, 21 May 2026 13:55:13 +0300 Subject: [PATCH 1/5] Improve invalid response guidance --- src/create-with-winter-spec.ts | 6 ++- src/middleware/with-response-object-check.ts | 16 +++++-- tests/errors/do-not-allow-raw-json.test.ts | 47 +++++++++++++++++--- 3 files changed, 58 insertions(+), 11 deletions(-) diff --git a/src/create-with-winter-spec.ts b/src/create-with-winter-spec.ts index 229a28c..79e4303 100644 --- a/src/create-with-winter-spec.ts +++ b/src/create-with-winter-spec.ts @@ -17,7 +17,10 @@ import { withMethods } from "./middleware/with-methods.js" import { withInputValidation } from "./middleware/with-input-validation.js" import { withUnhandledExceptionHandling } from "./middleware/with-unhandled-exception-handling.js" import { ResponseValidationError } from "./middleware/http-exceptions.js" -import { withResponseObjectCheck } from "./middleware/with-response-object-check.js" +import { + assertRouteReturnsResponse, + withResponseObjectCheck, +} from "./middleware/with-response-object-check.js" const attachMetadataToRouteFn = < const GS extends GlobalSpec, @@ -126,6 +129,7 @@ function serializeResponse( ): Middleware { return async (req, ctx, next) => { const rawResponse = await next(req, ctx) + assertRouteReturnsResponse(rawResponse) const statusCode = rawResponse instanceof WinterSpecResponse diff --git a/src/middleware/with-response-object-check.ts b/src/middleware/with-response-object-check.ts index 9f8cae0..24d9b89 100644 --- a/src/middleware/with-response-object-check.ts +++ b/src/middleware/with-response-object-check.ts @@ -1,7 +1,17 @@ -import { ResponseValidationError } from "./http-exceptions.js" import { Middleware } from "./types.js" import { RouteSpec } from "src/types/route-spec.js" +export const RAW_RESPONSE_OBJECT_ERROR_MESSAGE = + "Use ctx.json({...}) instead of returning an object directly." + +export function assertRouteReturnsResponse(rawResponse: unknown) { + if (rawResponse == null) { + throw new Error( + `${RAW_RESPONSE_OBJECT_ERROR_MESSAGE} Route handlers must return a Response or ctx.json(...).` + ) + } +} + export const withResponseObjectCheck: Middleware< { routeSpec: RouteSpec }, {} @@ -9,9 +19,7 @@ export const withResponseObjectCheck: Middleware< const rawResponse = await next(req, ctx) if (typeof rawResponse === "object" && !(rawResponse instanceof Response)) { - throw new Error( - "Use ctx.json({...}) instead of returning an object directly." - ) + throw new Error(RAW_RESPONSE_OBJECT_ERROR_MESSAGE) } return rawResponse diff --git a/tests/errors/do-not-allow-raw-json.test.ts b/tests/errors/do-not-allow-raw-json.test.ts index 8682796..f754fc9 100644 --- a/tests/errors/do-not-allow-raw-json.test.ts +++ b/tests/errors/do-not-allow-raw-json.test.ts @@ -1,8 +1,12 @@ -import test from "ava" +import test, { type ExecutionContext } from "ava" import { z } from "zod" import { getTestRoute } from "tests/fixtures/get-test-route.js" +import type { WinterSpecRouteFn } from "src/types/web-handler.js" -test("should throw an error when responding with raw JSON", async (t) => { +const getInvalidResponseError = async ( + t: ExecutionContext, + routeFn: WinterSpecRouteFn +) => { const { axios } = await getTestRoute(t, { globalSpec: { authMiddleware: {}, @@ -23,16 +27,47 @@ test("should throw an error when responding with raw JSON", async (t) => { jsonResponse: z.any(), }, routePath: "/", - routeFn: (req, ctx) => { - return { foo: "bar" } as any - }, + routeFn, }) const { data } = await axios.get("/", { validateStatus: () => true, }) + + return data.error +} + +test("should throw an error when responding with raw JSON", async (t) => { + const error = await getInvalidResponseError(t, () => { + return { foo: "bar" } as any + }) + + t.true( + error.includes( + "Use ctx.json({...}) instead of returning an object directly" + ) + ) +}) + +test("should throw an error when responding with null", async (t) => { + const error = await getInvalidResponseError(t, () => { + return null as any + }) + + t.true( + error.includes( + "Use ctx.json({...}) instead of returning an object directly" + ) + ) +}) + +test("should throw an error when responding with undefined", async (t) => { + const error = await getInvalidResponseError(t, () => { + return undefined as any + }) + t.true( - data.error.includes( + error.includes( "Use ctx.json({...}) instead of returning an object directly" ) ) From d5f1516c548247328b280f18f2d03b24f7dbc485 Mon Sep 17 00:00:00 2001 From: zergzorg Date: Fri, 22 May 2026 13:50:26 +0300 Subject: [PATCH 2/5] Handle primitive route return errors --- src/middleware/with-response-object-check.ts | 25 ++++++++++++++------ tests/errors/do-not-allow-raw-json.test.ts | 19 +++++++++++++++ 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/src/middleware/with-response-object-check.ts b/src/middleware/with-response-object-check.ts index 24d9b89..7642ca0 100644 --- a/src/middleware/with-response-object-check.ts +++ b/src/middleware/with-response-object-check.ts @@ -4,12 +4,25 @@ import { RouteSpec } from "src/types/route-spec.js" export const RAW_RESPONSE_OBJECT_ERROR_MESSAGE = "Use ctx.json({...}) instead of returning an object directly." +function hasWinterSpecSerializer( + rawResponse: unknown +): rawResponse is { serializeToResponse: (...args: any[]) => Response } { + return ( + typeof rawResponse === "object" && + rawResponse !== null && + "serializeToResponse" in rawResponse && + typeof rawResponse.serializeToResponse === "function" + ) +} + export function assertRouteReturnsResponse(rawResponse: unknown) { - if (rawResponse == null) { - throw new Error( - `${RAW_RESPONSE_OBJECT_ERROR_MESSAGE} Route handlers must return a Response or ctx.json(...).` - ) + if (rawResponse instanceof Response || hasWinterSpecSerializer(rawResponse)) { + return } + + throw new Error( + `${RAW_RESPONSE_OBJECT_ERROR_MESSAGE} Route handlers must return a Response or ctx.json(...).` + ) } export const withResponseObjectCheck: Middleware< @@ -18,9 +31,7 @@ export const withResponseObjectCheck: Middleware< > = async (req, ctx, next) => { const rawResponse = await next(req, ctx) - if (typeof rawResponse === "object" && !(rawResponse instanceof Response)) { - throw new Error(RAW_RESPONSE_OBJECT_ERROR_MESSAGE) - } + assertRouteReturnsResponse(rawResponse) return rawResponse } diff --git a/tests/errors/do-not-allow-raw-json.test.ts b/tests/errors/do-not-allow-raw-json.test.ts index f754fc9..2c2f6b7 100644 --- a/tests/errors/do-not-allow-raw-json.test.ts +++ b/tests/errors/do-not-allow-raw-json.test.ts @@ -49,6 +49,25 @@ test("should throw an error when responding with raw JSON", async (t) => { ) }) +for (const [label, value] of [ + ["string", "plain text"], + ["number", 200], + ["boolean", true], + ["bigint", 200n], +] as const) { + test(`should throw an error when responding with raw ${label}`, async (t) => { + const error = await getInvalidResponseError(t, () => { + return value as any + }) + + t.true( + error.includes( + "Use ctx.json({...}) instead of returning an object directly" + ) + ) + }) +} + test("should throw an error when responding with null", async (t) => { const error = await getInvalidResponseError(t, () => { return null as any From e808478b90e2867259e6b4f99f56d5c3604ee506 Mon Sep 17 00:00:00 2001 From: zergzorg Date: Fri, 22 May 2026 17:33:57 +0300 Subject: [PATCH 3/5] Cover function and symbol route returns --- tests/errors/do-not-allow-raw-json.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/errors/do-not-allow-raw-json.test.ts b/tests/errors/do-not-allow-raw-json.test.ts index 2c2f6b7..6e0b600 100644 --- a/tests/errors/do-not-allow-raw-json.test.ts +++ b/tests/errors/do-not-allow-raw-json.test.ts @@ -54,6 +54,8 @@ for (const [label, value] of [ ["number", 200], ["boolean", true], ["bigint", 200n], + ["symbol", Symbol("raw")], + ["function", () => "raw"], ] as const) { test(`should throw an error when responding with raw ${label}`, async (t) => { const error = await getInvalidResponseError(t, () => { From 8cd0b85be1b7a0dd178f659761fd3148edf0bc5d Mon Sep 17 00:00:00 2001 From: zergzorg Date: Fri, 22 May 2026 20:07:00 +0300 Subject: [PATCH 4/5] test: preserve custom serializable responses Signed-off-by: zergzorg --- tests/errors/do-not-allow-raw-json.test.ts | 40 +++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/tests/errors/do-not-allow-raw-json.test.ts b/tests/errors/do-not-allow-raw-json.test.ts index 6e0b600..15ee88d 100644 --- a/tests/errors/do-not-allow-raw-json.test.ts +++ b/tests/errors/do-not-allow-raw-json.test.ts @@ -1,7 +1,10 @@ import test, { type ExecutionContext } from "ava" import { z } from "zod" import { getTestRoute } from "tests/fixtures/get-test-route.js" -import type { WinterSpecRouteFn } from "src/types/web-handler.js" +import type { + SerializableToResponse, + WinterSpecRouteFn, +} from "src/types/web-handler.js" const getInvalidResponseError = async ( t: ExecutionContext, @@ -93,3 +96,38 @@ test("should throw an error when responding with undefined", async (t) => { ) ) }) + +test("should allow custom serializable response objects", async (t) => { + class PlainTextResponse implements SerializableToResponse { + statusCode() { + return 200 + } + + serializeToResponse() { + return new Response("custom response", { + headers: { "content-type": "text/plain" }, + }) + } + } + + const { axios } = await getTestRoute(t, { + globalSpec: { + authMiddleware: {}, + }, + routeSpec: { + methods: ["GET"], + jsonResponse: z.object({ + message: z.string(), + }), + }, + routePath: "/", + routeFn: () => new PlainTextResponse() as any, + }) + + const response = await axios.get("/", { + responseType: "text", + }) + + t.is(response.status, 200) + t.is(response.data, "custom response") +}) From d75113e9fe8c5e431bb640b6cb0795c1a7020c37 Mon Sep 17 00:00:00 2001 From: zergzorg Date: Mon, 25 May 2026 06:14:55 +0300 Subject: [PATCH 5/5] Clarify raw route return guidance --- src/middleware/with-response-object-check.ts | 2 +- tests/errors/do-not-allow-raw-json.test.ts | 29 ++++++-------------- 2 files changed, 10 insertions(+), 21 deletions(-) diff --git a/src/middleware/with-response-object-check.ts b/src/middleware/with-response-object-check.ts index 7642ca0..99461d0 100644 --- a/src/middleware/with-response-object-check.ts +++ b/src/middleware/with-response-object-check.ts @@ -2,7 +2,7 @@ import { Middleware } from "./types.js" import { RouteSpec } from "src/types/route-spec.js" export const RAW_RESPONSE_OBJECT_ERROR_MESSAGE = - "Use ctx.json({...}) instead of returning an object directly." + "Use ctx.json(value) or another Response object instead of returning raw route values directly." function hasWinterSpecSerializer( rawResponse: unknown diff --git a/tests/errors/do-not-allow-raw-json.test.ts b/tests/errors/do-not-allow-raw-json.test.ts index 15ee88d..f2e8780 100644 --- a/tests/errors/do-not-allow-raw-json.test.ts +++ b/tests/errors/do-not-allow-raw-json.test.ts @@ -1,6 +1,7 @@ import test, { type ExecutionContext } from "ava" import { z } from "zod" import { getTestRoute } from "tests/fixtures/get-test-route.js" +import { RAW_RESPONSE_OBJECT_ERROR_MESSAGE } from "src/middleware/with-response-object-check.js" import type { SerializableToResponse, WinterSpecRouteFn, @@ -40,16 +41,16 @@ const getInvalidResponseError = async ( return data.error } +const assertInvalidRawReturnError = (t: ExecutionContext, error: string) => { + t.true(error.includes(RAW_RESPONSE_OBJECT_ERROR_MESSAGE)) +} + test("should throw an error when responding with raw JSON", async (t) => { const error = await getInvalidResponseError(t, () => { return { foo: "bar" } as any }) - t.true( - error.includes( - "Use ctx.json({...}) instead of returning an object directly" - ) - ) + assertInvalidRawReturnError(t, error) }) for (const [label, value] of [ @@ -65,11 +66,7 @@ for (const [label, value] of [ return value as any }) - t.true( - error.includes( - "Use ctx.json({...}) instead of returning an object directly" - ) - ) + assertInvalidRawReturnError(t, error) }) } @@ -78,11 +75,7 @@ test("should throw an error when responding with null", async (t) => { return null as any }) - t.true( - error.includes( - "Use ctx.json({...}) instead of returning an object directly" - ) - ) + assertInvalidRawReturnError(t, error) }) test("should throw an error when responding with undefined", async (t) => { @@ -90,11 +83,7 @@ test("should throw an error when responding with undefined", async (t) => { return undefined as any }) - t.true( - error.includes( - "Use ctx.json({...}) instead of returning an object directly" - ) - ) + assertInvalidRawReturnError(t, error) }) test("should allow custom serializable response objects", async (t) => {