Skip to content
Merged
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
9 changes: 9 additions & 0 deletions .changeset/slow-pianos-obey.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"typed-openapi": minor
---

feat: allow specifying overrides on any request
fix: infer/narrow response with multiple json media types
fix: properly handle mutation errors while retaining genericity on output based on mutationFn withResponse: true/false
feat: decodePathParams/encodeSearchParams/parseResponseData
feat: allow passing overrides/withResponse even if there's no endpoint parameters
113 changes: 64 additions & 49 deletions example/api-client-example.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,89 +9,104 @@
* - Basic error handling
*
* Usage:
* 1. Replace './generated/api' with your actual generated file path
* 2. Set your API_BASE_URL
* 1. Replace './tmp/generated-client' with your actual generated file path
* 2. Set your baseUrl
* 3. Customize error handling and headers as needed
*/

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

// Basic configuration
const API_BASE_URL = process.env["API_BASE_URL"] || "https://api.example.com";

// Generic types for when you haven't imported the generated types yet
type EndpointParameters = {
body?: unknown;
query?: Record<string, unknown>;
header?: Record<string, unknown>;
path?: Record<string, unknown>;
};

type Fetcher = (method: string, url: string, params?: EndpointParameters) => Promise<Response>;
const VALIDATE_REQUESTS = true; // Set to false to skip request validation
const VALIDATE_RESPONSES = true; // Set to false to skip response validation

/**
* Simple fetcher implementation without external dependencies
*/
const fetcher: Fetcher = async (method, apiUrl, params) => {
const fetcher: Fetcher["fetch"] = async (input) => {
const headers = new Headers();

// Replace path parameters (supports both {param} and :param formats)
const actualUrl = replacePathParams(apiUrl, (params?.path ?? {}) as Record<string, string>);
const url = new URL(actualUrl);

// Handle query parameters
if (params?.query) {
const searchParams = new URLSearchParams();
Object.entries(params.query).forEach(([key, value]) => {
if (value != null) {
// Skip null/undefined values
if (Array.isArray(value)) {
value.forEach((val) => val != null && searchParams.append(key, String(val)));
} else {
searchParams.append(key, String(value));
}
}
});
url.search = searchParams.toString();
if (input.urlSearchParams) {
input.url.search = input.urlSearchParams.toString();
}

// Handle request body for mutation methods
const body = ["post", "put", "patch", "delete"].includes(method.toLowerCase())
? JSON.stringify(params?.body)
const body = ["post", "put", "patch", "delete"].includes(input.method.toLowerCase())
? JSON.stringify(input.parameters?.body)
: undefined;

if (VALIDATE_REQUESTS) {
try {
// Example for Zod validation:
// const endpoint = EndpointByMethod[input.method as keyof typeof EndpointByMethod];
// const pathSchema = endpoint?.[input.url as keyof typeof endpoint];
// if (pathSchema?.body) {
// pathSchema.body.parse(input.parameters?.body);
// }

// For now, just log that validation would happen here
console.debug("Request validation would happen here for:", input.method, input.url);
} catch (error) {
throw new ValidationError("Request body validation failed", "request", error);
}
}

if (body) {
headers.set("Content-Type", "application/json");
}

// Add custom headers
if (params?.header) {
Object.entries(params.header).forEach(([key, value]) => {
if (input.parameters?.header) {
Object.entries(input.parameters.header).forEach(([key, value]) => {
if (value != null) {
headers.set(key, String(value));
}
});
}

const response = await fetch(url, {
method: method.toUpperCase(),
const response = await fetch(input.url, {
method: input.method.toUpperCase(),
...(body && { body }),
headers,
...input.overrides,
});

// TODO: Add response validation here
if (VALIDATE_RESPONSES) {
try {
// Clone response for validation (since response can only be read once)
const responseClone = response.clone();
const responseData = await responseClone.json();

// Example for Zod validation:
// const endpoint = EndpointByMethod[input.method as keyof typeof EndpointByMethod];
// const pathSchema = endpoint?.[input.url as keyof typeof endpoint];
// const statusSchema = pathSchema?.responses?.[response.status as keyof typeof pathSchema.responses];
// if (statusSchema) {
// statusSchema.parse(responseData);
// }

// For now, just log that validation would happen here
console.debug("Response validation would happen here for:", input.method, input.url, response.status);
} catch (error) {
throw new ValidationError("Response validation failed", "response", error);
}
}

return response;
};

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

// Example of how to create the client once you have the generated code:
// export const api = createApiClient(fetcher, API_BASE_URL);
class ValidationError extends Error {
constructor(
message: string,
public readonly type: "request" | "response",
public readonly validationErrors: unknown,
) {
super(message);
this.name = "ValidationError";
}
}
2 changes: 2 additions & 0 deletions packages/typed-openapi/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"dev": "tsup --watch",
"build": "tsup",
"test": "vitest",
"test:types": "tstyche",
"gen:runtime": "node bin.js ./tests/samples/petstore.yaml --output ./tmp/generated-client.ts --tanstack generated-tanstack.ts --default-fetcher",
"test:runtime:run": "vitest run tests/integration-runtime-msw.test.ts",
"test:runtime": "pnpm run gen:runtime && pnpm run test:runtime:run",
Expand All @@ -45,6 +46,7 @@
"@types/node": "^22.15.17",
"@types/prettier": "3.0.0",
"msw": "2.10.5",
"tstyche": "4.3.0",
"tsup": "^8.4.0",
"typescript": "^5.8.3",
"vitest": "^3.1.3",
Expand Down
52 changes: 13 additions & 39 deletions packages/typed-openapi/src/default-fetcher.generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,80 +22,54 @@ export const generateDefaultFetcher = (options: {
* - Basic error handling
*
* Usage:
* 1. Replace './generated/api' with your actual generated file path
* 1. Replace './${clientPath}' with your actual generated file path
* 2. Set your ${envApiBaseUrl}
* 3. Customize error handling and headers as needed
*/

// @ts-ignore
import { type Fetcher, createApiClient } from "./${clientPath}";
import { type Fetcher, createApiClient } from "${clientPath}";

// Basic configuration
const ${envApiBaseUrl} = process.env["${envApiBaseUrl}"] || "https://api.example.com";

/**
* Simple fetcher implementation without external dependencies
*/
export const ${fetcherName}: Fetcher = async (method, apiUrl, params) => {
const ${fetcherName}: Fetcher["fetch"] = async (input) => {
const headers = new Headers();

// Replace path parameters (supports both {param} and :param formats)
const actualUrl = replacePathParams(apiUrl, (params?.path ?? {}) as Record<string, string>);
const url = new URL(actualUrl);

// Handle query parameters
if (params?.query) {
const searchParams = new URLSearchParams();
Object.entries(params.query).forEach(([key, value]) => {
if (value != null) {
// Skip null/undefined values
if (Array.isArray(value)) {
value.forEach((val) => val != null && searchParams.append(key, String(val)));
} else {
searchParams.append(key, String(value));
}
}
});
url.search = searchParams.toString();
if (input.urlSearchParams) {
input.url.search = input.urlSearchParams.toString();
}

// Handle request body for mutation methods
const body = ["post", "put", "patch", "delete"].includes(method.toLowerCase())
? JSON.stringify(params?.body)
const body = ["post", "put", "patch", "delete"].includes(input.method.toLowerCase())
? JSON.stringify(input.parameters?.body)
: undefined;

if (body) {
headers.set("Content-Type", "application/json");
}

// Add custom headers
if (params?.header) {
Object.entries(params.header).forEach(([key, value]) => {
if (input.parameters?.header) {
Object.entries(input.parameters.header).forEach(([key, value]) => {
if (value != null) {
headers.set(key, String(value));
}
});
}

const response = await fetch(url, {
method: method.toUpperCase(),
const response = await fetch(input.url, {
method: input.method.toUpperCase(),
...(body && { body }),
headers,
...input.overrides,
});

return response;
};

/**
* Replace path parameters in URL
* Supports both OpenAPI format {param} and Express format :param
*/
export function replacePathParams(url: string, params: Record<string, string>): string {
return url
.replace(/\{(\\w+)\}/g, function(_, key) { return params[key] || '{' + key + '}'; })
.replace(/:([a-zA-Z0-9_]+)/g, function(_, key) { return params[key] || ':' + key; });
}

export const ${apiName} = createApiClient(${fetcherName}, API_BASE_URL);
`;
export const ${apiName} = createApiClient({ fetch: ${fetcherName} }, API_BASE_URL);`;
};
2 changes: 1 addition & 1 deletion packages/typed-openapi/src/generate-client-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ export async function generateClientFiles(input: string, options: GenerateClient
if (options.defaultFetcher) {
const defaultFetcherContent = generateDefaultFetcher({
envApiBaseUrl: options.defaultFetcher.envApiBaseUrl,
clientPath: options.defaultFetcher.clientPath ?? basename(outputPath),
clientPath: options.defaultFetcher.clientPath ?? join(dirname(outputPath), basename(outputPath)),
fetcherName: options.defaultFetcher.fetcherName,
apiName: options.defaultFetcher.apiName,
});
Expand Down
Loading
Loading