Skip to content

Commit 661b9fd

Browse files
authored
Merge pull request #5 from moznion/default_header_generics
[BREAKING CHANGE] Support the default HTTP header by generic type in the generated client instead of CLI option
2 parents 0cbec79 + 90be1c7 commit 661b9fd

File tree

7 files changed

+301
-323
lines changed

7 files changed

+301
-323
lines changed

README.md

Lines changed: 81 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -67,30 +67,29 @@ async function doSomething() {
6767

6868
### Default HTTP Headers
6969

70-
This tool provides a `--default-headers` CLI option to configure the generated client to use default HTTP headers.
70+
Generated clients support a generic type for default HTTP headers.
7171

72-
For example, when you specify:
72+
Example:
7373

74-
```bash
75-
npx openapi-fetch-gen \
76-
--input ./schema.d.ts \
77-
--output ./client.ts \
78-
--default-headers "Authorization, Application-Version"
74+
```typescript
75+
export class Client<HT extends Record<string, string>> {
76+
constructor(clientOptions: ClientOptions, defaultHeaders?: HT) {
77+
this.client = createClient<paths>(clientOptions);
78+
this.defaultHeaders = defaultHeaders ?? ({} as HT);
79+
}
80+
...
81+
}
7982
```
8083

81-
the `Client` constructor accepts those default values:
84+
You can create a client instance with default headers like this:
8285

8386
```typescript
84-
type DefaultHeaders = Record<'Authorization' | 'Application-Version', string>;
85-
86-
export class Client {
87-
constructor(clientOptions: ClientOptions, defaultHeaders: DefaultHeaders) { ... }
88-
}
87+
new Client({}, {"Authorization": "Bearer your-token", "Application-Version": "1.0.0"});
8988
```
9089

91-
Endpoint methods that require those headers no longer need the default values to be passed explicitly.
90+
With this setup, endpoint methods that require these headers no longer need them to be explicitly passed each time.
9291

93-
For example, given this schema:
92+
For example, given the following schema:
9493

9594
```typescript
9695
"/users/bulk/{jobId}": {
@@ -113,35 +112,29 @@ For example, given this schema:
113112
};
114113
```
115114
116-
When `--default-headers` is not specified, the generated client method looks like this:
117-
118-
```typescript
119-
async getUsersBulkJobid(params: {
120-
header: {
121-
Authorization: string;
122-
"Application-Version": string;
123-
"Something-Id": string;
124-
};
125-
path: { jobId: string };
126-
}) {
127-
...
128-
}
129-
```
130-
131-
This requires you to pass all header values explicitly.
132-
133-
When you do specify `--default-headers`, the signature becomes:
115+
This tool generates an endpoint method using a type-level trick like this:
134116
135117
```typescript
136118
async getUsersBulkJobid(
137-
params: keyof Omit<
138-
{
139-
Authorization: string;
140-
"Application-Version": string;
141-
"Something-Id": string;
142-
},
143-
keyof DefaultHeaders
144-
> extends never
119+
params: [
120+
Exclude<
121+
// Missed Header Keys for default headers
122+
keyof {
123+
Authorization: string;
124+
"Application-Version": string;
125+
"Something-Id": string;
126+
},
127+
Extract<
128+
// Provided header keys by default headers' keys
129+
keyof HT,
130+
keyof {
131+
Authorization: string;
132+
"Application-Version": string;
133+
"Something-Id": string;
134+
}
135+
>
136+
>,
137+
] extends [never]
145138
? {
146139
header?: {
147140
Authorization: string;
@@ -152,14 +145,46 @@ async getUsersBulkJobid(
152145
}
153146
: {
154147
header:
155-
| Omit<
148+
| (Pick<
149+
// Pick the header keys that are not in the default headers
156150
{
157151
Authorization: string;
158152
"Application-Version": string;
159153
"Something-Id": string;
160154
},
161-
keyof DefaultHeaders
162-
>
155+
Exclude<
156+
// Missed Header Keys for default headers
157+
keyof {
158+
Authorization: string;
159+
"Application-Version": string;
160+
"Something-Id": string;
161+
},
162+
Extract<
163+
// Provided header keys by default headers' keys
164+
keyof HT,
165+
keyof {
166+
Authorization: string;
167+
"Application-Version": string;
168+
"Something-Id": string;
169+
}
170+
>
171+
>
172+
> &
173+
Partial<
174+
// Disallow default headers' keys to be in the header param
175+
Record<
176+
Extract<
177+
// Provided header keys by default headers' keys
178+
keyof HT,
179+
keyof {
180+
Authorization: string;
181+
"Application-Version": string;
182+
"Something-Id": string;
183+
}
184+
>,
185+
never
186+
>
187+
>)
163188
| {
164189
Authorization: string;
165190
"Application-Version": string;
@@ -168,11 +193,20 @@ async getUsersBulkJobid(
168193
path: { jobId: string };
169194
},
170195
) {
171-
...
196+
return await this.client.GET("/users/bulk/{jobId}", {
197+
params: {
198+
...params,
199+
header: { ...this.defaultHeaders, ...params.header } as {
200+
Authorization: string;
201+
"Application-Version": string;
202+
"Something-Id": string;
203+
},
204+
},
205+
});
172206
}
173207
```
174208
175-
That signature means you can:
209+
This signature allows you to:
176210
177211
- **Omit** the defaulted headers and only pass additional ones (here, `Something-Id`):
178212
@@ -186,7 +220,7 @@ client.getUsersBulkJobid({header: {"Something-Id": "foobar"}, path: {jobId: "123
186220
client.getUsersBulkJobid({header: {"Authorization": "foo", "Application-Version": "bar", "Something-Id": "foobar"}, path: {jobId: "123"}});
187221
```
188222
189-
If your default headers cover **all** required headers for the endpoint (e.g. `--default-headers 'Authorization, Application-Version, Something-Id'`), you can omit the `header` parameter entirely:
223+
If your default headers already include **all** required headers for the endpoint (e.g. `{"Authorization": "Bearer your-token", "Application-Version": "1.0.0", "Something-Id": "123"}` as the second constructor argument), you can omit the `header` parameter entirely:
190224
191225
```typescript
192226
client.getUsersBulkJobid({path: {jobId: "123"}});

src/cli.ts

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,37 +23,26 @@ program
2323
"path to output generated client file",
2424
"./client.ts",
2525
)
26-
.option(
27-
"--default-headers <comma_separated_names_of_headers>",
28-
"header names so that the generated client includes the default HTTP headers across all endpoints",
29-
"",
30-
)
3126
.parse(process.argv);
3227

3328
const options = program.opts();
3429

3530
try {
3631
const inputPath = path.resolve(options["input"]);
3732
const outputPath = path.resolve(options["output"]);
38-
const defaultHeaderNamesString = options["defaultHeaders"] as string;
3933

4034
if (!fs.existsSync(inputPath)) {
4135
console.error(`Error: Input file not found: ${inputPath}`);
4236
process.exit(1);
4337
}
4438

45-
const defaultHeaderNames = defaultHeaderNamesString
46-
.split(",")
47-
.map((h) => h.trim())
48-
.filter((h) => h !== "");
49-
50-
const clientCode = generateClient(inputPath, defaultHeaderNames);
39+
const clientCode = generateClient(inputPath);
5140

5241
fs.writeFileSync(outputPath, clientCode);
53-
console.log(`Successfully generated client at: ${outputPath}`);
42+
console.log(`🏁 Successfully generated client at: ${outputPath}`);
5443
} catch (error) {
5544
console.error(
56-
"Error:",
45+
"😵 Error:",
5746
error instanceof Error ? error.message : String(error),
5847
);
5948
process.exit(1);

src/index.ts

Lines changed: 48 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,7 @@ import {
1515
* Generates a TypeScript API client using openapi-fetch based on TypeScript interface definitions
1616
* generated by openapi-typescript.
1717
*/
18-
export function generateClient(
19-
schemaFilePath: string,
20-
defaultHeaderNames: string[],
21-
): string {
18+
export function generateClient(schemaFilePath: string): string {
2219
const project = new Project({
2320
compilerOptions: {
2421
target: ScriptTarget.Latest,
@@ -32,11 +29,7 @@ export function generateClient(
3229
throw new Error(`Interface "paths" not found in ${schemaFilePath}`);
3330
}
3431

35-
return generateClientCode(
36-
pathsInterface,
37-
path.basename(schemaFilePath),
38-
defaultHeaderNames,
39-
);
32+
return generateClientCode(pathsInterface, path.basename(schemaFilePath));
4033
}
4134

4235
function findInterface(
@@ -53,23 +46,16 @@ interface EndpointInfo {
5346
commentLines: string[];
5447
paramsType: string | null;
5548
bodyType: string | null;
56-
headerExists: boolean;
49+
headerType: string | null;
5750
}
5851

5952
function generateClientCode(
6053
pathsInterface: InterfaceDeclaration,
6154
schemaFileName: string,
62-
defaultHeaderNames: string[],
6355
): string {
64-
const defaultHeaderExists = defaultHeaderNames.length > 0;
65-
66-
const endpoints = extractEndpointsInfo(pathsInterface, defaultHeaderExists);
56+
const endpoints = extractEndpointsInfo(pathsInterface);
6757

68-
const defaultHeaderType =
69-
defaultHeaderNames.length > 0
70-
? `type DefaultHeaders = Record<${defaultHeaderNames.map((n) => `"${n}"`).join("|")}, string>\n`
71-
: "";
72-
const clientClass = generateClientClass(endpoints, defaultHeaderExists);
58+
const clientClass = generateClientClass(endpoints);
7359

7460
const code = [
7561
"// THIS FILE IS AUTO-GENERATED BY openapi-fetch-gen.",
@@ -78,7 +64,6 @@ function generateClientCode(
7864
`import createClient, { type ClientOptions } from "openapi-fetch";`,
7965
`import type { paths } from "./${schemaFileName}"; // generated by openapi-typescript`,
8066
"",
81-
defaultHeaderType,
8267
clientClass,
8368
].join("\n");
8469

@@ -90,7 +75,6 @@ function generateClientCode(
9075

9176
function extractEndpointsInfo(
9277
pathsInterface: InterfaceDeclaration,
93-
defaultHeaderExists: boolean,
9478
): EndpointInfo[] {
9579
const endpoints: EndpointInfo[] = [];
9680

@@ -148,20 +132,49 @@ function extractEndpointsInfo(
148132
.filter((kv) => kv["text"] !== "");
149133

150134
const headerType = paramTypes.find((kv) => kv.name === "header");
151-
if (defaultHeaderExists && headerType) {
135+
if (headerType) {
136+
// header exists in params
152137
const nonHeaderParams = paramTypes
153138
.filter((kv) => kv.name !== "header")
154139
.map((kv) => `${kv.name}: ${kv.text}`)
155140
.join("\n");
156141

157-
paramsType = `keyof Omit<${headerType["text"]}, keyof DefaultHeaders> extends never ?
158-
{
142+
paramsType =
143+
`[
144+
Exclude< // Missed Header Keys for default headers
145+
keyof ${headerType["text"]},
146+
Extract< // Provided header keys by default headers' keys
147+
keyof HT, keyof ${headerType["text"]}
148+
>
149+
>,
150+
] extends [never] ? ` +
151+
// When the default headers cover all the headers (i.e. `Exclude<...>` derived as `never`),
152+
// header parameter becomes optional (omitting or )overriding default headers.
153+
`{
159154
header?: ${headerType["text"]},
160155
${nonHeaderParams}
161-
} :
162-
{
156+
} : ` + // Else, header parameter is required as either follows:
157+
// 1. requires sorely missed header values
158+
// 2. requires all the header values (overriding default headers)
159+
`{
163160
header:
164-
| Omit<${headerType["text"]}, keyof DefaultHeaders>
161+
| (Pick< // Pick the header keys that are not in the default headers
162+
${headerType["text"]},
163+
Exclude< // Missed Header Keys for default headers
164+
keyof ${headerType["text"]},
165+
Extract< // Provided header keys by default headers' keys
166+
keyof HT, keyof ${headerType["text"]}
167+
>
168+
>
169+
> &
170+
Partial< // Disallow default headers' keys to be in the header param
171+
Record<
172+
Extract< // Provided header keys by default headers' keys
173+
keyof HT, keyof ${headerType["text"]}
174+
>,
175+
never
176+
>
177+
>)
165178
| ${headerType["text"]},
166179
${nonHeaderParams}
167180
}
@@ -227,26 +240,23 @@ function extractEndpointsInfo(
227240
commentLines,
228241
paramsType,
229242
bodyType: requestBodyType,
230-
headerExists: headerType !== undefined,
243+
headerType: headerType ? headerType["text"] : null,
231244
});
232245
}
233246
}
234247

235248
return endpoints;
236249
}
237250

238-
function generateClientClass(
239-
endpoints: EndpointInfo[],
240-
defaultHeaderExists: boolean,
241-
): string {
251+
function generateClientClass(endpoints: EndpointInfo[]): string {
242252
const classCode = [
243-
`export class Client {
253+
`export class Client<HT extends Record<string, string>> {
244254
private readonly client;
245-
${defaultHeaderExists ? "private readonly defaultHeaders: DefaultHeaders;" : ""}
255+
private readonly defaultHeaders: HT;
246256
247-
constructor(clientOptions: ClientOptions${defaultHeaderExists ? ", defaultHeaders: DefaultHeaders" : ""}) {
257+
constructor(clientOptions: ClientOptions, defaultHeaders?: HT) {
248258
this.client = createClient<paths>(clientOptions);
249-
${defaultHeaderExists ? "this.defaultHeaders = defaultHeaders;" : ""}
259+
this.defaultHeaders = defaultHeaders ?? ({} as HT);
250260
}
251261
`,
252262
];
@@ -297,10 +307,10 @@ function generateClientClass(
297307
);
298308

299309
if (paramsType) {
300-
if (defaultHeaderExists && endpoint.headerExists) {
310+
if (endpoint.headerType) {
301311
classCode.push(`params: {
302312
...params,
303-
header: {...this.defaultHeaders, ...params.header},
313+
header: {...this.defaultHeaders, ...params.header} as ${endpoint.headerType},
304314
},`);
305315
} else {
306316
classCode.push("params,");

0 commit comments

Comments
 (0)