Skip to content

Commit b4a94cb

Browse files
committed
Add response headers
1 parent 2fe48c8 commit b4a94cb

27 files changed

+387
-142
lines changed

.changeset/mean-walls-lick.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"typed-openapi": minor
3+
---
4+
5+
Add response headers in endpoint types

packages/typed-openapi/src/generator.ts

Lines changed: 96 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -70,29 +70,29 @@ export const generateFile = (options: GeneratorOptions) => {
7070
ctx.runtime === "none"
7171
? (file: string) => file
7272
: (file: string) => {
73-
const model = Codegen.TypeScriptToModel.Generate(file);
74-
const transformer = runtimeValidationGenerator[ctx.runtime as Exclude<typeof ctx.runtime, "none">];
75-
// tmp fix for typebox, there's currently a "// todo" only with Codegen.ModelToTypeBox.Generate
76-
// https://github.com/sinclairzx81/typebox-codegen/blob/44d44d55932371b69f349331b1c8a60f5d760d9e/src/model/model-to-typebox.ts#L31
77-
const generated = ctx.runtime === "typebox" ? Codegen.TypeScriptToTypeBox.Generate(file) : transformer(model);
78-
79-
let converted = "";
80-
const match = generated.match(/(const __ENDPOINTS_START__ =)([\s\S]*?)(export type __ENDPOINTS_END__)/);
81-
const content = match?.[2];
82-
83-
if (content && ctx.runtime in replacerByRuntime) {
84-
const before = generated.slice(0, generated.indexOf("export type __ENDPOINTS_START"));
85-
converted =
86-
before +
87-
replacerByRuntime[ctx.runtime as keyof typeof replacerByRuntime](
88-
content.slice(content.indexOf("export")),
89-
);
90-
} else {
91-
converted = generated;
92-
}
73+
const model = Codegen.TypeScriptToModel.Generate(file);
74+
const transformer = runtimeValidationGenerator[ctx.runtime as Exclude<typeof ctx.runtime, "none">];
75+
// tmp fix for typebox, there's currently a "// todo" only with Codegen.ModelToTypeBox.Generate
76+
// https://github.com/sinclairzx81/typebox-codegen/blob/44d44d55932371b69f349331b1c8a60f5d760d9e/src/model/model-to-typebox.ts#L31
77+
const generated = ctx.runtime === "typebox" ? Codegen.TypeScriptToTypeBox.Generate(file) : transformer(model);
78+
79+
let converted = "";
80+
const match = generated.match(/(const __ENDPOINTS_START__ =)([\s\S]*?)(export type __ENDPOINTS_END__)/);
81+
const content = match?.[2];
82+
83+
if (content && ctx.runtime in replacerByRuntime) {
84+
const before = generated.slice(0, generated.indexOf("export type __ENDPOINTS_START"));
85+
converted =
86+
before +
87+
replacerByRuntime[ctx.runtime as keyof typeof replacerByRuntime](
88+
content.slice(content.indexOf("export")),
89+
);
90+
} else {
91+
converted = generated;
92+
}
9393

94-
return converted;
95-
};
94+
return converted;
95+
};
9696

9797
const file = `
9898
${transform(schemaList + endpointSchemaList)}
@@ -132,6 +132,24 @@ const parameterObjectToString = (parameters: Box<AnyBoxDef> | Record<string, Any
132132
}
133133
return str + "}";
134134
};
135+
136+
const responseHeadersObjectToString = (responseHeaders: Record<string, AnyBox>, ctx: GeneratorContext) => {
137+
let str = "{";
138+
for (const [key, responseHeader] of Object.entries(responseHeaders)) {
139+
const value = ctx.runtime === "none"
140+
? responseHeader.recompute((box) => {
141+
if (Box.isReference(box) && !box.params.generics && box.value !== "null") {
142+
box.value = `Schemas.${box.value}`;
143+
}
144+
145+
return box;
146+
}).value
147+
: responseHeader.value
148+
str += `${wrapWithQuotesIfNeeded(key)}: ${value},\n`;
149+
}
150+
return str + "}";
151+
}
152+
135153
const generateEndpointSchemaList = (ctx: GeneratorContext) => {
136154
let file = `
137155
${ctx.runtime === "none" ? "export namespace Endpoints {" : ""}
@@ -145,39 +163,42 @@ const generateEndpointSchemaList = (ctx: GeneratorContext) => {
145163
path: "${endpoint.path}",
146164
requestFormat: "${endpoint.requestFormat}",
147165
${
148-
endpoint.meta.hasParameters
149-
? `parameters: {
166+
endpoint.meta.hasParameters
167+
? `parameters: {
150168
${parameters.query ? `query: ${parameterObjectToString(parameters.query)},` : ""}
151169
${parameters.path ? `path: ${parameterObjectToString(parameters.path)},` : ""}
152170
${parameters.header ? `header: ${parameterObjectToString(parameters.header)},` : ""}
153171
${
154172
parameters.body
155173
? `body: ${parameterObjectToString(
156-
ctx.runtime === "none"
157-
? parameters.body.recompute((box) => {
158-
if (Box.isReference(box) && !box.params.generics) {
159-
box.value = `Schemas.${box.value}`;
160-
}
161-
return box;
162-
})
163-
: parameters.body,
164-
)},`
174+
ctx.runtime === "none"
175+
? parameters.body.recompute((box) => {
176+
if (Box.isReference(box) && !box.params.generics) {
177+
box.value = `Schemas.${box.value}`;
178+
}
179+
return box;
180+
})
181+
: parameters.body,
182+
)},`
165183
: ""
166184
}
167185
}`
168-
: "parameters: never,"
169-
}
186+
: "parameters: never,"
187+
}
170188
response: ${
171-
ctx.runtime === "none"
172-
? endpoint.response.recompute((box) => {
173-
if (Box.isReference(box) && !box.params.generics && box.value !== "null") {
174-
box.value = `Schemas.${box.value}`;
175-
}
176-
177-
return box;
178-
}).value
179-
: endpoint.response.value
180-
},
189+
ctx.runtime === "none"
190+
? endpoint.response.recompute((box) => {
191+
if (Box.isReference(box) && !box.params.generics && box.value !== "null") {
192+
box.value = `Schemas.${box.value}`;
193+
}
194+
195+
return box;
196+
}).value
197+
: endpoint.response.value
198+
},
199+
${
200+
endpoint.responseHeaders ? `responseHeaders: ${responseHeadersObjectToString(endpoint.responseHeaders, ctx)},` : ""
201+
}
181202
}\n`;
182203
});
183204

@@ -199,14 +220,14 @@ const generateEndpointByMethod = (ctx: GeneratorContext) => {
199220
// <EndpointByMethod>
200221
export ${ctx.runtime === "none" ? "type" : "const"} EndpointByMethod = {
201222
${Object.entries(byMethods)
202-
.map(([method, list]) => {
203-
return `${method}: {
223+
.map(([method, list]) => {
224+
return `${method}: {
204225
${list.map(
205-
(endpoint) => `"${endpoint.path}": ${ctx.runtime === "none" ? "Endpoints." : ""}${endpoint.meta.alias}`,
206-
)}
226+
(endpoint) => `"${endpoint.path}": ${ctx.runtime === "none" ? "Endpoints." : ""}${endpoint.meta.alias}`,
227+
)}
207228
}`;
208-
})
209-
.join(",\n")}
229+
})
230+
.join(",\n")}
210231
}
211232
${ctx.runtime === "none" ? "" : "export type EndpointByMethod = typeof EndpointByMethod;"}
212233
// </EndpointByMethod>
@@ -216,8 +237,8 @@ const generateEndpointByMethod = (ctx: GeneratorContext) => {
216237
217238
// <EndpointByMethod.Shorthands>
218239
${Object.keys(byMethods)
219-
.map((method) => `export type ${capitalize(method)}Endpoints = EndpointByMethod["${method}"]`)
220-
.join("\n")}
240+
.map((method) => `export type ${capitalize(method)}Endpoints = EndpointByMethod["${method}"]`)
241+
.join("\n")}
221242
// </EndpointByMethod.Shorthands>
222243
`;
223244

@@ -246,6 +267,7 @@ type RequestFormat = "json" | "form-data" | "form-url" | "binary" | "text";
246267
export type DefaultEndpoint = {
247268
parameters?: EndpointParameters | undefined;
248269
response: unknown;
270+
responseHeaders?: Record<string, unknown>;
249271
};
250272
251273
export type Endpoint<TConfig extends DefaultEndpoint = DefaultEndpoint> = {
@@ -260,6 +282,7 @@ export type Endpoint<TConfig extends DefaultEndpoint = DefaultEndpoint> = {
260282
areParametersRequired: boolean;
261283
};
262284
response: TConfig["response"];
285+
responseHeaders?: TConfig["responseHeaders"]
263286
};
264287
265288
export type Fetcher = (method: Method, url: string, parameters?: EndpointParameters | undefined) => Promise<Response>;
@@ -303,18 +326,18 @@ export class ApiClient {
303326
${method}<Path extends keyof ${capitalizedMethod}Endpoints, TEndpoint extends ${capitalizedMethod}Endpoints[Path]>(
304327
path: Path,
305328
...params: MaybeOptionalArg<${match(ctx.runtime)
306-
.with("zod", "yup", () => infer(`TEndpoint["parameters"]`))
307-
.with("arktype", "io-ts", "typebox", "valibot", () => infer(`TEndpoint`) + `["parameters"]`)
308-
.otherwise(() => `TEndpoint["parameters"]`)}>
329+
.with("zod", "yup", () => infer(`TEndpoint["parameters"]`))
330+
.with("arktype", "io-ts", "typebox", "valibot", () => infer(`TEndpoint`) + `["parameters"]`)
331+
.otherwise(() => `TEndpoint["parameters"]`)}>
309332
): Promise<${match(ctx.runtime)
310-
.with("zod", "yup", () => infer(`TEndpoint["response"]`))
311-
.with("arktype", "io-ts", "typebox", "valibot", () => infer(`TEndpoint`) + `["response"]`)
312-
.otherwise(() => `TEndpoint["response"]`)}> {
333+
.with("zod", "yup", () => infer(`TEndpoint["response"]`))
334+
.with("arktype", "io-ts", "typebox", "valibot", () => infer(`TEndpoint`) + `["response"]`)
335+
.otherwise(() => `TEndpoint["response"]`)}> {
313336
return this.fetcher("${method}", this.baseUrl + path, params[0])
314337
.then(response => this.parseResponse(response))${match(ctx.runtime)
315-
.with("zod", "yup", () => `as Promise<${infer(`TEndpoint["response"]`)}>`)
316-
.with("arktype", "io-ts", "typebox", "valibot", () => `as Promise<${infer(`TEndpoint`) + `["response"]`}>`)
317-
.otherwise(() => `as Promise<TEndpoint["response"]>`)};
338+
.with("zod", "yup", () => `as Promise<${infer(`TEndpoint["response"]`)}>`)
339+
.with("arktype", "io-ts", "typebox", "valibot", () => `as Promise<${infer(`TEndpoint`) + `["response"]`}>`)
340+
.otherwise(() => `as Promise<TEndpoint["response"]>`)};
318341
}
319342
// </ApiClient.${method}>
320343
`
@@ -334,17 +357,17 @@ export class ApiClient {
334357
method: TMethod,
335358
path: TPath,
336359
...params: MaybeOptionalArg<${match(ctx.runtime)
337-
.with("zod", "yup", () =>
338-
inferByRuntime[ctx.runtime](`TEndpoint extends { parameters: infer Params } ? Params : never`),
339-
)
340-
.with(
341-
"arktype",
342-
"io-ts",
343-
"typebox",
344-
"valibot",
345-
() => inferByRuntime[ctx.runtime](`TEndpoint`) + `["parameters"]`,
346-
)
347-
.otherwise(() => `TEndpoint extends { parameters: infer Params } ? Params : never`)}>)
360+
.with("zod", "yup", () =>
361+
inferByRuntime[ctx.runtime](`TEndpoint extends { parameters: infer Params } ? Params : never`),
362+
)
363+
.with(
364+
"arktype",
365+
"io-ts",
366+
"typebox",
367+
"valibot",
368+
() => inferByRuntime[ctx.runtime](`TEndpoint`) + `["parameters"]`,
369+
)
370+
.otherwise(() => `TEndpoint extends { parameters: infer Params } ? Params : never`)}>)
348371
: Promise<Omit<Response, "json"> & {
349372
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/json) */
350373
json: () => Promise<TEndpoint extends { response: infer Res } ? Res : never>;

packages/typed-openapi/src/map-openapi-endpoints.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,16 @@ export const mapOpenApiEndpoints = (doc: OpenAPIObject) => {
145145
}
146146
}
147147

148+
// Map response headers
149+
const headers = responseObject?.headers;
150+
if (headers) {
151+
endpoint.responseHeaders = Object.entries(headers).reduce((acc, [name, headerOrRef]) => {
152+
const header = refs.unwrap(headerOrRef);
153+
acc[name] = openApiSchemaToTs({ schema: header.schema ?? {}, ctx });
154+
return acc;
155+
}, {} as Record<string, Box>);
156+
}
157+
148158
endpointList.push(endpoint);
149159
});
150160
});
@@ -184,6 +194,7 @@ type RequestFormat = "json" | "form-data" | "form-url" | "binary" | "text";
184194
type DefaultEndpoint = {
185195
parameters?: EndpointParameters | undefined;
186196
response: AnyBox;
197+
responseHeaders?: Record<string, AnyBox>
187198
};
188199

189200
export type Endpoint<TConfig extends DefaultEndpoint = DefaultEndpoint> = {
@@ -198,4 +209,5 @@ export type Endpoint<TConfig extends DefaultEndpoint = DefaultEndpoint> = {
198209
areParametersRequired: boolean;
199210
};
200211
response: TConfig["response"];
201-
};
212+
responseHeaders?: TConfig["responseHeaders"];
213+
};

packages/typed-openapi/tests/generate-runtime.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ const samples = ["petstore", "docker.openapi", "long-operation-id"];
1010
const runtimes = allowedRuntimes.toJsonSchema().enum;
1111

1212
samples.forEach((sample) => {
13-
describe(`generate-rutime-${sample}`, async () => {
13+
describe(`generate-runtime-${sample}`, async () => {
1414
const filePath = `${__dirname}/samples/${sample}.yaml`;
1515
const openApiDoc = (await SwaggerParser.parse(filePath)) as OpenAPIObject;
1616
const ctx = mapOpenApiEndpoints(openApiDoc);

packages/typed-openapi/tests/generator.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ describe("generator", () => {
186186
query: Partial<{ username: string; password: string }>;
187187
};
188188
response: string;
189+
responseHeaders: { "X-Rate-Limit": number; "X-Expires-After": string };
189190
};
190191
export type get_LogoutUser = {
191192
method: "GET";
@@ -283,6 +284,7 @@ describe("generator", () => {
283284
export type DefaultEndpoint = {
284285
parameters?: EndpointParameters | undefined;
285286
response: unknown;
287+
responseHeaders?: Record<string, unknown>;
286288
};
287289
288290
export type Endpoint<TConfig extends DefaultEndpoint = DefaultEndpoint> = {
@@ -297,6 +299,7 @@ describe("generator", () => {
297299
areParametersRequired: boolean;
298300
};
299301
response: TConfig["response"];
302+
responseHeaders?: TConfig["responseHeaders"];
300303
};
301304
302305
export type Fetcher = (method: Method, url: string, parameters?: EndpointParameters | undefined) => Promise<Response>;
@@ -718,6 +721,7 @@ describe("generator", () => {
718721
export type DefaultEndpoint = {
719722
parameters?: EndpointParameters | undefined;
720723
response: unknown;
724+
responseHeaders?: Record<string, unknown>;
721725
};
722726
723727
export type Endpoint<TConfig extends DefaultEndpoint = DefaultEndpoint> = {
@@ -732,6 +736,7 @@ describe("generator", () => {
732736
areParametersRequired: boolean;
733737
};
734738
response: TConfig["response"];
739+
responseHeaders?: TConfig["responseHeaders"];
735740
};
736741
737742
export type Fetcher = (method: Method, url: string, parameters?: EndpointParameters | undefined) => Promise<Response>;
@@ -928,6 +933,7 @@ describe("generator", () => {
928933
export type DefaultEndpoint = {
929934
parameters?: EndpointParameters | undefined;
930935
response: unknown;
936+
responseHeaders?: Record<string, unknown>;
931937
};
932938
933939
export type Endpoint<TConfig extends DefaultEndpoint = DefaultEndpoint> = {
@@ -942,6 +948,7 @@ describe("generator", () => {
942948
areParametersRequired: boolean;
943949
};
944950
response: TConfig["response"];
951+
responseHeaders?: TConfig["responseHeaders"];
945952
};
946953
947954
export type Fetcher = (method: Method, url: string, parameters?: EndpointParameters | undefined) => Promise<Response>;

packages/typed-openapi/tests/map-openapi-endpoints.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2297,6 +2297,16 @@ describe("map-openapi-endpoints", () => {
22972297
"type": "keyword",
22982298
"value": "string",
22992299
},
2300+
"responseHeaders": {
2301+
"X-Expires-After": {
2302+
"type": "keyword",
2303+
"value": "string",
2304+
},
2305+
"X-Rate-Limit": {
2306+
"type": "keyword",
2307+
"value": "number",
2308+
},
2309+
},
23002310
},
23012311
{
23022312
"meta": {

0 commit comments

Comments
 (0)