diff --git a/src/create-with-winter-spec.ts b/src/create-with-winter-spec.ts index 229a28c..967c85e 100644 --- a/src/create-with-winter-spec.ts +++ b/src/create-with-winter-spec.ts @@ -17,7 +17,11 @@ 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_OBJECT_RESPONSE_ERROR_MESSAGE, + isRawObjectResponse, + withResponseObjectCheck, +} from "./middleware/with-response-object-check.js" const attachMetadataToRouteFn = < const GS extends GlobalSpec, @@ -81,8 +85,8 @@ export const createWithWinterSpec = ( onMultipleAuthMiddlewareFailures ), ...(globalSpec.afterAuthMiddleware ?? []), - ...(routeSpec.middleware ?? []), withResponseObjectCheck, + ...(routeSpec.middleware ?? []), withMethods(routeSpec.methods), withInputValidation({ supportedArrayFormats: globalSpec.supportedArrayFormats ?? [ @@ -127,6 +131,10 @@ function serializeResponse( return async (req, ctx, next) => { const rawResponse = await next(req, ctx) + if (isRawObjectResponse(rawResponse)) { + throw new Error(RAW_OBJECT_RESPONSE_ERROR_MESSAGE) + } + const statusCode = rawResponse instanceof WinterSpecResponse ? rawResponse.statusCode() diff --git a/src/middleware/with-response-object-check.ts b/src/middleware/with-response-object-check.ts index 9f8cae0..e8d21b8 100644 --- a/src/middleware/with-response-object-check.ts +++ b/src/middleware/with-response-object-check.ts @@ -1,17 +1,23 @@ -import { ResponseValidationError } from "./http-exceptions.js" import { Middleware } from "./types.js" import { RouteSpec } from "src/types/route-spec.js" +export const RAW_OBJECT_RESPONSE_ERROR_MESSAGE = + "Use ctx.json({...}) instead of returning an object directly." + +export const isRawObjectResponse = (response: unknown) => + response === null || + (typeof response === "object" && + !(response instanceof Response) && + !("serializeToResponse" in response)) + 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." - ) + if (isRawObjectResponse(rawResponse)) { + throw new Error(RAW_OBJECT_RESPONSE_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..b9f4afc 100644 --- a/tests/errors/do-not-allow-raw-json.test.ts +++ b/tests/errors/do-not-allow-raw-json.test.ts @@ -2,7 +2,10 @@ import test from "ava" import { z } from "zod" import { getTestRoute } from "tests/fixtures/get-test-route.js" -test("should throw an error when responding with raw JSON", async (t) => { +const rawObjectResponseError = + "Use ctx.json({...}) instead of returning an object directly" + +test("should throw an error when a route handler responds with raw JSON", async (t) => { const { axios } = await getTestRoute(t, { globalSpec: { authMiddleware: {}, @@ -31,9 +34,108 @@ test("should throw an error when responding with raw JSON", async (t) => { const { data } = await axios.get("/", { validateStatus: () => true, }) - t.true( - data.error.includes( - "Use ctx.json({...}) instead of returning an object directly" - ) - ) + t.true(data.error.includes(rawObjectResponseError)) +}) + +test("should throw an error when route middleware responds with raw JSON", async (t) => { + const { axios } = await getTestRoute(t, { + globalSpec: { + authMiddleware: {}, + beforeAuthMiddleware: [ + async (req, ctx, next) => { + try { + return await next(req, ctx) + } catch (e: any) { + return Response.json({ error: e.message }, { status: 500 }) + } + }, + ], + }, + routeSpec: { + methods: ["GET"], + jsonBody: z.any(), + jsonResponse: z.any(), + middleware: [ + async () => { + return { foo: "bar" } as any + }, + ], + }, + routePath: "/", + routeFn: (req, ctx) => { + return ctx.json({ ok: true }) + }, + }) + + const { data } = await axios.get("/", { + validateStatus: () => true, + }) + t.true(data.error.includes(rawObjectResponseError)) +}) + +test("should throw an error when a route handler responds with raw null", async (t) => { + const { axios } = await getTestRoute(t, { + globalSpec: { + authMiddleware: {}, + beforeAuthMiddleware: [ + async (req, ctx, next) => { + try { + return await next(req, ctx) + } catch (e: any) { + return Response.json({ error: e.message }, { status: 500 }) + } + }, + ], + }, + routeSpec: { + methods: ["GET"], + jsonBody: z.any(), + jsonResponse: z.any(), + }, + routePath: "/", + routeFn: () => { + return null as any + }, + }) + + const { data } = await axios.get("/", { + validateStatus: () => true, + }) + t.true(data.error.includes(rawObjectResponseError)) +}) + +test("should throw an error when route middleware responds with raw null", async (t) => { + const { axios } = await getTestRoute(t, { + globalSpec: { + authMiddleware: {}, + beforeAuthMiddleware: [ + async (req, ctx, next) => { + try { + return await next(req, ctx) + } catch (e: any) { + return Response.json({ error: e.message }, { status: 500 }) + } + }, + ], + }, + routeSpec: { + methods: ["GET"], + jsonBody: z.any(), + jsonResponse: z.any(), + middleware: [ + async () => { + return null as any + }, + ], + }, + routePath: "/", + routeFn: (req, ctx) => { + return ctx.json({ ok: true }) + }, + }) + + const { data } = await axios.get("/", { + validateStatus: () => true, + }) + t.true(data.error.includes(rawObjectResponseError)) })