8000 feat: Automatically set response `Content-Type` header based on the `… · aklinker1/zeta@8884f54 · GitHub
[go: up one dir, main page]

Skip to content

Commit 8884f54

Browse files
committed
feat: Automatically set response Content-Type header based on the contentType meta
1 parent f3a36a9 commit 8884f54

5 files changed

Lines changed: 86 additions & 31 deletions

File tree

‎README.md‎

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,9 @@ When a response schema(s) are defined, the return value from the function is typ
268268

269269
#### Custom Content Types
270270

271-
By default, Zeta will use `application/json` as the content type in the OpenAPI docs. you can override this by setting the `contentType` metadata on your schema:
271+
By default, Zeta will use `application/json` as the content type in the OpenAPI docs and infer the content type of the response at runtime based on the response type.
272+
273+
You can override both the docs and the response content type by setting the `contentType` metadata on your schema:
272274

273275
```ts
274276
app.get(
@@ -282,24 +284,6 @@ app.get(
282284
);
283285
```
284286

285-
> [!WARNING]
286-
>
287-
> Zeta ignores this metadata when building the response. Make sure to set the `Content-Type` header in your handler:
288-
>
289-
> ```ts
290-
> app.get(
291-
> "/csv",
292-
> {
293-
> responses: z.string().meta({ contentType: "text/csv" }),
294-
> },
295-
> ({ set }) => {
296-
> // ...
297-
> set.headers["Content-Type"] = "text/csv";
298-
> return "...";
299-
> },
300-
> );
301-
> ```
302-
303287
## Life Cycle Hooks
304288

305289
## `App#decorate`

‎example.ts‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -139,10 +139,7 @@ const app = createApp({
139139
tags: ["Entries"],
140140
responses: z.string().meta({ contentType: "text/csv" }),
141141
},
142-
({ set }) => {
143-
set.headers["Content-Type"] = "text/csv";
144-
return "test";
145-
},
142+
() => "test",
146143
);
147144

148145
console.log(

‎src/__tests__/app.test.ts‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { z } from "zod/v4";
44
import { createTestAppClient } from "../testing";
55
import { expectTypeOf } from "expect-type";
66
import type { AnyDef, GetAppData } from "../types";
7+
import { zodSchemaAdapter } from "../adapters/zod-schema-adapter";
8+
import { HttpStatus } from "../status";
79

810
// Silence console.error logs
911
globalThis.console.error = mock();
@@ -67,6 +69,68 @@ describe("App", () => {
6769
);
6870
});
6971

72+
describe("Response content type meta", () => {
73+
describe("Single response routes", () => {
74+
const app = createApp({ schemaAdapter: zodSchemaAdapter }).get(
75+
"/",
76+
{
77+
responses: z.string().meta({ contentType: "text/csv" }),
78+
},
79+
() => "test,1,2,3",
80+
);
81+
const fetch = app.build();
82+
83+
it("should set the content type automatically for single response routes", async () => {
84+
const request = new Request("http://localhost");
85+
86+
const response = await fetch(request);
87+
88+
expect(response.headers.get("content-type")).toBe("text/csv");
89+
});
90+
});
91+
92+
describe("Multiple response routes", () => {
93+
const app = createApp({ schemaAdapter: zodSchemaAdapter }).get(
94+
"/",
95+
{
96+
query: z.object({
97+
status: z.coerce
98+
.number()
99+
.pipe(
100+
z.union([
101+
z.literal(HttpStatus.Ok),
102+
z.literal(HttpStatus.Accepted),
103+
]),
104+
),
105+
}),
106+
responses: {
107+
[HttpStatus.Ok]: z.string().meta({ contentType: "text/csv" }),
108+
[HttpStatus.Accepted]: z
109+
.string()
110+
.meta({ contentType: "application/xml" }),
111+
},
112+
},
113+
({ query, status }) => status(query.status, ""),
114+
);
115+
const fetch = app.build();
116+
117+
it.each([
118+
[HttpStatus.Ok, "text/csv"],
119+
[HttpStatus.Accepted, "application/xml"],
120+
])(
121+
"should set the content type automatically based on the status",
122+
async (status, contentType) => {
123+
const request = new Request(`http://localhost/?status=${status}`);
124+
125+
const response = await fetch(request);
126+
127+
expect(response.status).toBe(status);
128+
expect(response.headers.get("content-type")).toBe(contentType);
129+
},
130+
);
131+
});
132+
});
133+
70134
describe("request body inference", () => {
71135
it.each<{
72136
input: any;

‎src/app.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,11 @@ export function createApp<TPrefix extends BasePrefix = "">(
168168
return onGlobalRequestResponse;
169169
}
170170

171-
const response = await callHandler(ctx, getRoute);
171+
const response = await callHandler(
172+
ctx,
173+
getRoute,
174+
options?.schemaAdapter,
175+
);
172176
ctx.response = response;
173177

174178
return response;

‎src/internal/call-handler.ts‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { MatchedRoute } from "rou3";
2-
import type { RouterData } from "../types";
2+
import type { RouterData, SchemaAdapter } from "../types";
33
import { NotFoundHttpError } from "../errors";
44
import {
55
callCtxModifierHooks,
@@ -18,6 +18,7 @@ export async function callHandler(
1818
method: string,
1919
path: string,
2020
) => MatchedRoute<RouterData> | undefined,
21+
schemaAdapter: SchemaAdapter | undefined,
2122
): Promise<Response> {
2223
const route = getRoute(ctx.method, ctx.path);
2324
if (route == null) {
@@ -69,10 +70,12 @@ export async function callHandler(
6970
body,
7071
});
7172

72-
let response: any = route.data.handler(ctx);
73-
if (response instanceof Promise) response = await response;
73+
{
74+
let response: any = route.data.handler(ctx);
75+
if (response instanceof Promise) response = await response;
7476

75-
ctx.response = response;
77+
ctx.response = response;
78+
}
7679

7780
for (const hook of route.data.hooks.onAfterHandle) {
7881
let res = hook.callback(ctx);
@@ -81,17 +84,19 @@ export async function callHandler(
8184
ctx.response = res;
8285
}
8386

87+
let responseMeta: Record<string, any> | undefined;
8488
if (!(ctx.response instanceof Response)) {
8589
if (route.data.def?.responses) {
8690
if ("~standard" in route.data.def.responses) {
8791
ctx.response = validateOutputSchema(
8892
route.data.def.responses,
8993
ctx.response,
9094
);
95+
responseMeta = schemaAdapter?.getMeta(route.data.def.responses);
9196
} else {
9297
if (!ctx.response || !isStatusResult(ctx.response)) {
9398
throw new Error(
94-
"When `responses` is a record of schemas, you must return a value from `ctx.status()`.",
99+
"When `responses` is a record of schemas, you must return a value from `ctx.status(...)`.",
95100
);
96101
}
97102
const { status, body } = ctx.response;
@@ -102,11 +107,12 @@ export async function callHandler(
102107
}
103108
ctx.set.status = status;
104109
ctx.response = validateOutputSchema(schema, body);
110+
responseMeta = schemaAdapter?.getMeta(schema);
105111
}
106112
}
107113
}
108114

109-
if (response instanceof Response) return response;
115+
if (ctx.response instanceof Response) return ctx.response;
110116

111117
for (const hook of route.data.hooks.onMapResponse) {
112118
let res = hook.callback(ctx);
@@ -125,7 +131,7 @@ export async function callHandler(
125131
return new Response(resBody.serialized, {
126132
status: ctx.set.status,
127133
headers: {
128-
"Content-Type": resBody.contentType,
134+
"Content-Type": responseMeta?.contentType ?? resBody.contentType,
129135
...ctx.set.headers,
130136
},
131137
});

0 commit comments

Comments
 (0)
0