Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/create-with-winter-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,13 @@ export const createWithWinterSpec = <const GS extends GlobalSpec>(
// 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 ?? [
Expand Down
19 changes: 12 additions & 7 deletions src/middleware/with-response-object-check.ts
Original file line number Diff line number Diff line change
@@ -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<any> },
{}
> = 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."
)
Expand Down
37 changes: 36 additions & 1 deletion src/types/winter-spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Middleware } from "src/middleware/types.js"
import {
createWinterSpecRequest,
type SerializableToResponse,
type WinterSpecRouteFn,
type WinterSpecRouteParams,
WinterSpecRequest,
Expand All @@ -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) =>
| {
Expand Down Expand Up @@ -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
}
39 changes: 39 additions & 0 deletions tests/bundling/make-request/make-request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
40 changes: 40 additions & 0 deletions tests/errors/do-not-allow-raw-json.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
)
})
Loading