Skip to content

Commit 04a41a6

Browse files
authored
refactor: fetcher + pass overrides (#108)
* wip: allow specifying headers with HeadersInit on any request fix: infer/narrow response with multiple json media types test: typings / inference * fix: properly handle mutation errors while retaining genericity on output based on mutationFn withResponse: true/false test: add more types inference cases chore: update snapshots * feat: decodePathParams/encodeSearchParams/parseResponseData feat: allow passing overrides/withResponse even if there's no endpoint parameters * chore: update snapshots * fix: add overrides?: RequestInit; on mutationFn test: abort signal example * fix: passing signal to tanstack queryFn chore: rm mutationKey/mutationOptions on query fns * chore: update default fetcher
1 parent 2b24b36 commit 04a41a6

40 files changed

+6397
-2767
lines changed

.changeset/slow-pianos-obey.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"typed-openapi": minor
3+
---
4+
5+
feat: allow specifying overrides on any request
6+
fix: infer/narrow response with multiple json media types
7+
fix: properly handle mutation errors while retaining genericity on output based on mutationFn withResponse: true/false
8+
feat: decodePathParams/encodeSearchParams/parseResponseData
9+
feat: allow passing overrides/withResponse even if there's no endpoint parameters

example/api-client-example.ts

Lines changed: 64 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -9,89 +9,104 @@
99
* - Basic error handling
1010
*
1111
* Usage:
12-
* 1. Replace './generated/api' with your actual generated file path
13-
* 2. Set your API_BASE_URL
12+
* 1. Replace './tmp/generated-client' with your actual generated file path
13+
* 2. Set your baseUrl
1414
* 3. Customize error handling and headers as needed
1515
*/
1616

17-
// TODO: Replace with your generated API client imports
18-
// import { type EndpointParameters, type Fetcher, createApiClient } from './openapi.client.ts';
17+
import { type Fetcher, createApiClient } from "../tmp/generated-client.ts";
1918

2019
// Basic configuration
2120
const API_BASE_URL = process.env["API_BASE_URL"] || "https://api.example.com";
22-
23-
// Generic types for when you haven't imported the generated types yet
24-
type EndpointParameters = {
25-
body?: unknown;
26-
query?: Record<string, unknown>;
27-
header?: Record<string, unknown>;
28-
path?: Record<string, unknown>;
29-
};
30-
31-
type Fetcher = (method: string, url: string, params?: EndpointParameters) => Promise<Response>;
21+
const VALIDATE_REQUESTS = true; // Set to false to skip request validation
22+
const VALIDATE_RESPONSES = true; // Set to false to skip response validation
3223

3324
/**
3425
* Simple fetcher implementation without external dependencies
3526
*/
36-
const fetcher: Fetcher = async (method, apiUrl, params) => {
27+
const fetcher: Fetcher["fetch"] = async (input) => {
3728
const headers = new Headers();
3829

39-
// Replace path parameters (supports both {param} and :param formats)
40-
const actualUrl = replacePathParams(apiUrl, (params?.path ?? {}) as Record<string, string>);
41-
const url = new URL(actualUrl);
42-
4330
// Handle query parameters
44-
if (params?.query) {
45-
const searchParams = new URLSearchParams();
46-
Object.entries(params.query).forEach(([key, value]) => {
47-
if (value != null) {
48-
// Skip null/undefined values
49-
if (Array.isArray(value)) {
50-
value.forEach((val) => val != null && searchParams.append(key, String(val)));
51-
} else {
52-
searchParams.append(key, String(value));
53-
}
54-
}
55-
});
56-
url.search = searchParams.toString();
31+
if (input.urlSearchParams) {
32+
input.url.search = input.urlSearchParams.toString();
5733
}
5834

5935
// Handle request body for mutation methods
60-
const body = ["post", "put", "patch", "delete"].includes(method.toLowerCase())
61-
? JSON.stringify(params?.body)
36+
const body = ["post", "put", "patch", "delete"].includes(input.method.toLowerCase())
37+
? JSON.stringify(input.parameters?.body)
6238
: undefined;
6339

40+
if (VALIDATE_REQUESTS) {
41+
try {
42+
// Example for Zod validation:
43+
// const endpoint = EndpointByMethod[input.method as keyof typeof EndpointByMethod];
44+
// const pathSchema = endpoint?.[input.url as keyof typeof endpoint];
45+
// if (pathSchema?.body) {
46+
// pathSchema.body.parse(input.parameters?.body);
47+
// }
48+
49+
// For now, just log that validation would happen here
50+
console.debug("Request validation would happen here for:", input.method, input.url);
51+
} catch (error) {
52+
throw new ValidationError("Request body validation failed", "request", error);
53+
}
54+
}
55+
6456
if (body) {
6557
headers.set("Content-Type", "application/json");
6658
}
6759

6860
// Add custom headers
69-
if (params?.header) {
70-
Object.entries(params.header).forEach(([key, value]) => {
61+
if (input.parameters?.header) {
62+
Object.entries(input.parameters.header).forEach(([key, value]) => {
7163
if (value != null) {
7264
headers.set(key, String(value));
7365
}
7466
});
7567
}
7668

77-
const response = await fetch(url, {
78-
method: method.toUpperCase(),
69+
const response = await fetch(input.url, {
70+
method: input.method.toUpperCase(),
7971
...(body && { body }),
8072
headers,
73+
...input.overrides,
8174
});
8275

76+
// TODO: Add response validation here
77+
if (VALIDATE_RESPONSES) {
78+
try {
79+
// Clone response for validation (since response can only be read once)
80+
const responseClone = response.clone();
81+
const responseData = await responseClone.json();
82+
83+
// Example for Zod validation:
84+
// const endpoint = EndpointByMethod[input.method as keyof typeof EndpointByMethod];
85+
// const pathSchema = endpoint?.[input.url as keyof typeof endpoint];
86+
// const statusSchema = pathSchema?.responses?.[response.status as keyof typeof pathSchema.responses];
87+
// if (statusSchema) {
88+
// statusSchema.parse(responseData);
89+
// }
90+
91+
// For now, just log that validation would happen here
92+
console.debug("Response validation would happen here for:", input.method, input.url, response.status);
93+
} catch (error) {
94+
throw new ValidationError("Response validation failed", "response", error);
95+
}
96+
}
97+
8398
return response;
8499
};
85100

86-
/**
87-
* Replace path parameters in URL
88-
* Supports both OpenAPI format {param} and Express format :param
89-
*/
90-
function replacePathParams(url: string, params: Record<string, string>): string {
91-
return url
92-
.replace(/{(\w+)}/g, (_, key: string) => params[key] || `{${key}}`)
93-
.replace(/:([a-zA-Z0-9_]+)/g, (_, key: string) => params[key] || `:${key}`);
94-
}
101+
export const api = createApiClient({ fetch: fetcher }, API_BASE_URL);
95102

96-
// Example of how to create the client once you have the generated code:
97-
// export const api = createApiClient(fetcher, API_BASE_URL);
103+
class ValidationError extends Error {
104+
constructor(
105+
message: string,
106+
public readonly type: "request" | "response",
107+
public readonly validationErrors: unknown,
108+
) {
109+
super(message);
110+
this.name = "ValidationError";
111+
}
112+
}

packages/typed-openapi/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"dev": "tsup --watch",
2323
"build": "tsup",
2424
"test": "vitest",
25+
"test:types": "tstyche",
2526
"gen:runtime": "node bin.js ./tests/samples/petstore.yaml --output ./tmp/generated-client.ts --tanstack generated-tanstack.ts --default-fetcher",
2627
"test:runtime:run": "vitest run tests/integration-runtime-msw.test.ts",
2728
"test:runtime": "pnpm run gen:runtime && pnpm run test:runtime:run",
@@ -45,6 +46,7 @@
4546
"@types/node": "^22.15.17",
4647
"@types/prettier": "3.0.0",
4748
"msw": "2.10.5",
49+
"tstyche": "4.3.0",
4850
"tsup": "^8.4.0",
4951
"typescript": "^5.8.3",
5052
"vitest": "^3.1.3",

packages/typed-openapi/src/default-fetcher.generator.ts

Lines changed: 13 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -22,80 +22,54 @@ export const generateDefaultFetcher = (options: {
2222
* - Basic error handling
2323
*
2424
* Usage:
25-
* 1. Replace './generated/api' with your actual generated file path
25+
* 1. Replace './${clientPath}' with your actual generated file path
2626
* 2. Set your ${envApiBaseUrl}
2727
* 3. Customize error handling and headers as needed
2828
*/
2929
30-
// @ts-ignore
31-
import { type Fetcher, createApiClient } from "./${clientPath}";
30+
import { type Fetcher, createApiClient } from "${clientPath}";
3231
3332
// Basic configuration
3433
const ${envApiBaseUrl} = process.env["${envApiBaseUrl}"] || "https://api.example.com";
3534
3635
/**
3736
* Simple fetcher implementation without external dependencies
3837
*/
39-
export const ${fetcherName}: Fetcher = async (method, apiUrl, params) => {
38+
const ${fetcherName}: Fetcher["fetch"] = async (input) => {
4039
const headers = new Headers();
4140
42-
// Replace path parameters (supports both {param} and :param formats)
43-
const actualUrl = replacePathParams(apiUrl, (params?.path ?? {}) as Record<string, string>);
44-
const url = new URL(actualUrl);
45-
4641
// Handle query parameters
47-
if (params?.query) {
48-
const searchParams = new URLSearchParams();
49-
Object.entries(params.query).forEach(([key, value]) => {
50-
if (value != null) {
51-
// Skip null/undefined values
52-
if (Array.isArray(value)) {
53-
value.forEach((val) => val != null && searchParams.append(key, String(val)));
54-
} else {
55-
searchParams.append(key, String(value));
56-
}
57-
}
58-
});
59-
url.search = searchParams.toString();
42+
if (input.urlSearchParams) {
43+
input.url.search = input.urlSearchParams.toString();
6044
}
6145
6246
// Handle request body for mutation methods
63-
const body = ["post", "put", "patch", "delete"].includes(method.toLowerCase())
64-
? JSON.stringify(params?.body)
47+
const body = ["post", "put", "patch", "delete"].includes(input.method.toLowerCase())
48+
? JSON.stringify(input.parameters?.body)
6549
: undefined;
6650
6751
if (body) {
6852
headers.set("Content-Type", "application/json");
6953
}
7054
7155
// Add custom headers
72-
if (params?.header) {
73-
Object.entries(params.header).forEach(([key, value]) => {
56+
if (input.parameters?.header) {
57+
Object.entries(input.parameters.header).forEach(([key, value]) => {
7458
if (value != null) {
7559
headers.set(key, String(value));
7660
}
7761
});
7862
}
7963
80-
const response = await fetch(url, {
81-
method: method.toUpperCase(),
64+
const response = await fetch(input.url, {
65+
method: input.method.toUpperCase(),
8266
...(body && { body }),
8367
headers,
68+
...input.overrides,
8469
});
8570
8671
return response;
8772
};
8873
89-
/**
90-
* Replace path parameters in URL
91-
* Supports both OpenAPI format {param} and Express format :param
92-
*/
93-
export function replacePathParams(url: string, params: Record<string, string>): string {
94-
return url
95-
.replace(/\{(\\w+)\}/g, function(_, key) { return params[key] || '{' + key + '}'; })
96-
.replace(/:([a-zA-Z0-9_]+)/g, function(_, key) { return params[key] || ':' + key; });
97-
}
98-
99-
export const ${apiName} = createApiClient(${fetcherName}, API_BASE_URL);
100-
`;
74+
export const ${apiName} = createApiClient({ fetch: ${fetcherName} }, API_BASE_URL);`;
10175
};

packages/typed-openapi/src/generate-client-files.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ export async function generateClientFiles(input: string, options: GenerateClient
110110
if (options.defaultFetcher) {
111111
const defaultFetcherContent = generateDefaultFetcher({
112112
envApiBaseUrl: options.defaultFetcher.envApiBaseUrl,
113-
clientPath: options.defaultFetcher.clientPath ?? basename(outputPath),
113+
clientPath: options.defaultFetcher.clientPath ?? join(dirname(outputPath), basename(outputPath)),
114114
fetcherName: options.defaultFetcher.fetcherName,
115115
apiName: options.defaultFetcher.apiName,
116116
});

0 commit comments

Comments
 (0)