diff --git a/src/create-with-winter-spec.ts b/src/create-with-winter-spec.ts index 229a28c..38cc1d1 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 { + RAW_RESPONSE_OBJECT_ERROR_MESSAGE, + withResponseObjectCheck, +} from "./middleware/with-response-object-check.js" const attachMetadataToRouteFn = < const GS extends GlobalSpec, @@ -126,13 +129,17 @@ function serializeResponse( ): Middleware { return async (req, ctx, next) => { const rawResponse = await next(req, ctx) + assertValidRouteResponse(rawResponse) const statusCode = rawResponse instanceof WinterSpecResponse ? rawResponse.statusCode() - : rawResponse.status + : rawResponse instanceof Response + ? rawResponse.status + : undefined - const isSuccess = statusCode >= 200 && statusCode < 300 + const isSuccess = + typeof statusCode === "number" && statusCode >= 200 && statusCode < 300 try { const response = serializeToResponse( @@ -150,6 +157,22 @@ function serializeResponse( } } +function assertValidRouteResponse(response: unknown) { + if ( + response instanceof Response || + (typeof response === "object" && + response !== null && + "serializeToResponse" in response && + typeof response.serializeToResponse === "function") + ) { + return + } + + throw new Error( + `${RAW_RESPONSE_OBJECT_ERROR_MESSAGE} Route handlers must return a Response or ctx.json(...).` + ) +} + export async function wrapMiddlewares( middlewares: MiddlewareChain, routeFn: WinterSpecRouteFn, diff --git a/src/middleware/with-response-object-check.ts b/src/middleware/with-response-object-check.ts index 9f8cae0..b44af7b 100644 --- a/src/middleware/with-response-object-check.ts +++ b/src/middleware/with-response-object-check.ts @@ -1,7 +1,9 @@ -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 const withResponseObjectCheck: Middleware< { routeSpec: RouteSpec }, {} @@ -9,9 +11,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..bfce7eb 100644 --- a/tests/errors/do-not-allow-raw-json.test.ts +++ b/tests/errors/do-not-allow-raw-json.test.ts @@ -1,8 +1,15 @@ -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 { + 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 +30,89 @@ 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( - data.error.includes( + error.includes( "Use ctx.json({...}) instead of returning an object directly" ) ) }) + +for (const [name, value] of [ + ["string", "hello"], + ["number", 42], + ["boolean", true], +] as const) { + test(`should throw an error when responding with a raw ${name}`, 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 a raw bigint", async (t) => { + const error = await getInvalidResponseError(t, () => { + return 42n as any + }) + + t.true( + error.includes( + "Use ctx.json({...}) instead of returning an object directly" + ) + ) +}) + +test("should allow custom serializable response objects", async (t) => { + class CustomSerializableResponse 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 CustomSerializableResponse() as any, + }) + + const res = await axios.get("/", { + responseType: "text", + }) + + t.is(res.status, 200) + t.is(res.data, "custom response") +})