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..99461d0 100644 --- a/src/middleware/with-response-object-check.ts +++ b/src/middleware/with-response-object-check.ts @@ -1,18 +1,37 @@ -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(value) or another Response object instead of returning raw route values 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 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< { routeSpec: RouteSpec }, {} > = async (req, ctx, next) => { 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." - ) - } + 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 8682796..f2e8780 100644 --- a/tests/errors/do-not-allow-raw-json.test.ts +++ b/tests/errors/do-not-allow-raw-json.test.ts @@ -1,8 +1,16 @@ -import test from "ava" +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, +} 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,17 +31,92 @@ 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, }) - t.true( - data.error.includes( - "Use ctx.json({...}) instead of returning an object directly" - ) - ) + + 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 + }) + + assertInvalidRawReturnError(t, error) +}) + +for (const [label, value] of [ + ["string", "plain text"], + ["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, () => { + return value as any + }) + + assertInvalidRawReturnError(t, error) + }) +} + +test("should throw an error when responding with null", async (t) => { + const error = await getInvalidResponseError(t, () => { + return null as any + }) + + assertInvalidRawReturnError(t, error) +}) + +test("should throw an error when responding with undefined", async (t) => { + const error = await getInvalidResponseError(t, () => { + return undefined as any + }) + + assertInvalidRawReturnError(t, error) +}) + +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") })