diff --git a/src/create-with-winter-spec.ts b/src/create-with-winter-spec.ts index 229a28c..c57ee2b 100644 --- a/src/create-with-winter-spec.ts +++ b/src/create-with-winter-spec.ts @@ -76,13 +76,13 @@ export const createWithWinterSpec = ( // error response that does not match the routeSpec's response shape serializeResponse(globalSpec, routeSpec, false), ...(globalSpec.beforeAuthMiddleware ?? []), + withResponseObjectCheck, firstAuthMiddlewareThatSucceeds( authMiddlewares, onMultipleAuthMiddlewareFailures ), ...(globalSpec.afterAuthMiddleware ?? []), ...(routeSpec.middleware ?? []), - withResponseObjectCheck, withMethods(routeSpec.methods), withInputValidation({ supportedArrayFormats: globalSpec.supportedArrayFormats ?? [ diff --git a/src/middleware/with-response-object-check.ts b/src/middleware/with-response-object-check.ts index 9f8cae0..6475c5b 100644 --- a/src/middleware/with-response-object-check.ts +++ b/src/middleware/with-response-object-check.ts @@ -1,14 +1,19 @@ -import { ResponseValidationError } from "./http-exceptions.js" import { Middleware } from "./types.js" -import { RouteSpec } from "src/types/route-spec.js" -export const withResponseObjectCheck: Middleware< - { routeSpec: RouteSpec }, - {} -> = async (req, ctx, next) => { +export const withResponseObjectCheck: Middleware = async (req, ctx, next) => { const rawResponse = await next(req, ctx) - if (typeof rawResponse === "object" && !(rawResponse instanceof Response)) { + const canSerializeToResponse = + rawResponse !== null && + typeof rawResponse === "object" && + "serializeToResponse" in rawResponse && + typeof rawResponse.serializeToResponse === "function" + + if ( + typeof rawResponse === "object" && + !(rawResponse instanceof Response) && + !canSerializeToResponse + ) { throw new Error( "Use ctx.json({...}) instead of returning an object directly." ) diff --git a/src/types/winter-spec.ts b/src/types/winter-spec.ts index ba5adac..c21352d 100644 --- a/src/types/winter-spec.ts +++ b/src/types/winter-spec.ts @@ -1,6 +1,7 @@ import type { Middleware } from "src/middleware/types.js" import { createWinterSpecRequest, + type SerializableToResponse, type WinterSpecRouteFn, type WinterSpecRouteParams, WinterSpecRequest, @@ -10,6 +11,7 @@ import type { ReadonlyDeep } from "type-fest" import { wrapMiddlewares } from "src/create-with-winter-spec.js" import { getDefaultContext } from "./context.js" import { Server } from "node:http" +import { z } from "zod" export type WinterSpecRouteMatcher = (pathname: string) => | { @@ -126,11 +128,44 @@ export function makeRequestAgainstWinterSpec( return await handle404(winterSpecRequest, getDefaultContext()) } - return wrapMiddlewares( + const rawResponse = await wrapMiddlewares( options.middleware ?? [], routeFn, winterSpecRequest, getDefaultContext() ) + + return serializeMakeRequestMiddlewareResponse(rawResponse) + } +} + +function isSerializableToResponse( + response: unknown +): response is SerializableToResponse { + return ( + response !== null && + typeof response === "object" && + "serializeToResponse" in response && + typeof response.serializeToResponse === "function" + ) +} + +function serializeMakeRequestMiddlewareResponse( + response: Response | SerializableToResponse +): Response { + if (response instanceof Response) { + return response } + + if (isSerializableToResponse(response)) { + return response.serializeToResponse(z.any()) + } + + if (response !== null && typeof response === "object") { + throw new Error( + "Invalid response object. Use ctx.json({...}) instead of returning an object directly." + ) + } + + return response as Response } diff --git a/tests/bundling/make-request/make-request.test.ts b/tests/bundling/make-request/make-request.test.ts index 8ceaf6b..200fa84 100644 --- a/tests/bundling/make-request/make-request.test.ts +++ b/tests/bundling/make-request/make-request.test.ts @@ -57,6 +57,45 @@ test.serial("custom middleware works", async (t) => { t.is(await response.text(), "intercepted") }) +test.serial("custom middleware can return ctx.json", async (t) => { + const bundle = await createAndLoadBundle(t) + t.truthy(bundle.makeRequest) + + const response = await bundle.makeRequest( + new Request(new URL("https://example.com/health")), + { + middleware: [ + (_req, ctx: any) => { + return ctx.json({ ok: true }) + }, + ], + } + ) + t.is(response.status, 200) + t.deepEqual(await response.json(), { ok: true }) +}) + +test.serial("custom middleware rejects raw object responses", async (t) => { + const bundle = await createAndLoadBundle(t) + t.truthy(bundle.makeRequest) + + const error = await t.throwsAsync(() => + bundle.makeRequest(new Request(new URL("https://example.com/health")), { + middleware: [ + () => { + return { ok: true } as any + }, + ], + }) + ) + + t.true( + error?.message.includes( + "Use ctx.json({...}) instead of returning an object directly" + ) + ) +}) + test.serial("can make request when hosted on subpath", async (t) => { const bundle = await createAndLoadBundle(t) diff --git a/tests/errors/do-not-allow-raw-json.test.ts b/tests/errors/do-not-allow-raw-json.test.ts index 8682796..073a94f 100644 --- a/tests/errors/do-not-allow-raw-json.test.ts +++ b/tests/errors/do-not-allow-raw-json.test.ts @@ -37,3 +37,43 @@ test("should throw an error when responding with raw JSON", async (t) => { ) ) }) + +test("should throw an error when 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( + "Use ctx.json({...}) instead of returning an object directly" + ) + ) +})