Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/fix-interceptor-order.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hey-api/openapi-ts": patch
---

fix(clients): defer URL construction and thread finalError through interceptors
34 changes: 10 additions & 24 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,36 +21,25 @@
"type": "module",
"scripts": {
"build": "turbo run build",
"tb": "turbo run build",
"examples:generate": "node scripts/examples-generate.js",
"examples:check": "node scripts/examples-check.js",
"gen": "pnpm examples:generate",
"check": "pnpm examples:check",
"changelog:assemble": "tsx scripts/changelog/assemble.ts",
"changelog:release:name": "tsx scripts/changelog/release-name.ts",
"changelog:release:notes": "tsx scripts/changelog/release-notes.ts",
"changelog:release:tag": "tsx scripts/changelog/release-tag.ts",
"changeset": "changeset",
"examples:check": "sh ./scripts/examples-check.sh",
"examples:generate": "sh ./scripts/examples-generate.sh",
"format": "oxfmt .",
"format:next": "oxfmt . && uv run ruff format packages/openapi-python/src/py-compiler/__snapshots__",
"lint": "oxfmt --check . && eslint .",
"lint:next": "oxfmt --check . && eslint . && uv run ruff check packages/openapi-python/src/py-compiler/__snapshots__",
"lint:fix": "oxfmt . && eslint . --fix",
"lint:fix:next": "oxfmt . && eslint . --fix && uv run ruff check --fix packages/openapi-python/src/py-compiler/__snapshots__",
"prepare": "husky",
"readme:sync": "tsx scripts/readme-sync.ts",
"test:changelog": "vitest run __tests__/*.test.ts",
"test:changelog:watch": "vitest watch __tests__/*.test.ts",
"test:coverage": "turbo run build && vitest run --coverage",
"test:update": "turbo run build && vitest watch --update",
"test:watch": "turbo run build && vitest watch",
"test": "turbo run build && vitest",
"test:watch": "turbo run build && vitest watch",
"test:coverage": "turbo run build && vitest run --coverage",
"typecheck": "turbo run typecheck",
"td": "turbo run dev --filter",
"tt": "turbo run build && vitest run --project",
"tw": "turbo run build && vitest watch --project",
"tu": "turbo run build && vitest watch --update --project",
"tb": "turbo run build --filter",
"ty": "turbo run typecheck --filter",
"dev:ts": "cd dev && HEYAPI_CODEGEN_ENV=development tsx watch --clear-screen=false ../packages/openapi-ts/src/run.ts",
"dev:py": "cd dev && HEYAPI_CODEGEN_ENV=development tsx watch --clear-screen=false ../packages/openapi-python/src/run.ts"
"dev:ts": "cd dev && set HEYAPI_CODEGEN_ENV=development && tsx watch ../packages/openapi-ts/src/run.ts",
"dev:py": "cd dev && set HEYAPI_CODEGEN_ENV=development && tsx watch ../packages/openapi-python/src/run.ts"
},
"devDependencies": {
"@arethetypeswrong/core": "0.18.2",
Expand All @@ -63,24 +52,21 @@
"@hey-api/openapi-ts": "workspace:*",
"@types/node": "24.12.2",
"@typescript-eslint/eslint-plugin": "8.54.0",
"@typescript/native-preview": "7.0.0-dev.20260430.1",
"@vitest/coverage-v8": "4.1.0",
"eslint": "9.39.2",
"eslint-plugin-simple-import-sort": "12.1.1",
"eslint-plugin-sort-destructure-keys": "3.0.0",
"eslint-plugin-sort-keys-fix": "1.1.2",
"eslint-plugin-typescript-sort-keys": "3.3.0",
"eslint-plugin-vue": "10.7.0",
"globals": "17.4.0",
"husky": "9.1.7",
"lint-staged": "16.4.0",
"oxfmt": "0.45.0",
"publint": "0.3.18",
"tsdown": "0.21.8",
"tsx": "4.21.0",
"turbo": "2.9.6",
"typescript": "6.0.2",
"typescript-eslint": "8.54.0",
"typescript-eslint": "8.29.1",
"vitest": "4.1.0"
},
"engines": {
Expand Down
140 changes: 87 additions & 53 deletions packages/custom-client/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ import {
} from './utils';

type ReqInit = Omit<RequestInit, 'body' | 'headers'> & {
body?: any;
body?: BodyInit | null;
headers: ReturnType<typeof mergeHeaders>;
};

type ParseAs = 'json' | 'text' | 'blob' | 'arrayBuffer' | 'formData' | 'stream';

export const createClient = (config: Config = {}): Client => {
let _config = mergeConfigs(createConfig(), config);

Expand All @@ -35,56 +37,66 @@ export const createClient = (config: Config = {}): Client => {
headers: mergeHeaders(_config.headers, options.headers),
};

// security
if (opts.security) {
await setAuthParams({
...opts,
security: opts.security,
});
}

// request validator
if (opts.requestValidator) {
await opts.requestValidator(opts);
}

// serialize body
if (opts.body && opts.bodySerializer) {
opts.body = opts.bodySerializer(opts.body);
}

// remove Content-Type header if body is empty to avoid sending invalid requests
// remove content-type if empty body
if (opts.body === undefined || opts.body === '') {
opts.headers.delete('Content-Type');
}

let requestObj = new Request('http://localhost', {
...(opts as RequestInit),
headers: opts.headers,
});

// request interceptors
for (const fn of interceptors.request.fns) {
if (fn) {
requestObj = await fn(requestObj, opts);
}
}

const url = buildUrl(opts);

const requestInit: ReqInit = {
redirect: 'follow',
...opts,
...(opts as Omit<typeof opts, 'body'>),
body: opts.body as BodyInit | null | undefined,
};

let request = new Request(url, requestInit);
const finalRequest = new Request(url, requestInit);

for (const fn of interceptors.request.fns) {
if (fn) {
request = await fn(request, opts);
}
}
const response = await opts.fetch!(finalRequest);

// fetch must be assigned here, otherwise it would throw the error:
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
const _fetch = opts.fetch!;
let response = await _fetch(request);
const result = {
request: finalRequest,
response,
};

// response interceptors
for (const fn of interceptors.response.fns) {
if (fn) {
response = await fn(response, request, opts);
await fn(response, finalRequest, opts);
}
}

const result = {
request,
response,
};

// SUCCESS HANDLING
if (response.ok) {
if (response.status === 204 || response.headers.get('Content-Length') === '0') {
return {
Expand All @@ -96,36 +108,46 @@ export const createClient = (config: Config = {}): Client => {
const parseAs =
(opts.parseAs === 'auto'
? getParseAs(response.headers.get('Content-Type'))
: opts.parseAs) ?? 'json';
: (opts.parseAs as ParseAs)) ?? 'json';

let data: unknown;

let data: any;
switch (parseAs) {
case 'arrayBuffer':
data = await response.arrayBuffer();
break;

case 'blob':
data = await response.blob();
break;

case 'formData':
case 'text':
data = await response[parseAs]();
data = await response.formData();
break;
case 'json': {
// Some servers return 200 with no Content-Length and empty body.
// response.json() would throw; read as text and parse if non-empty.
const text = await response.text();
data = text ? JSON.parse(text) : {};

case 'text':
data = await response.text();
break;
}

case 'stream':
return {
data: response.body,
data: response.body ?? null,
...result,
};
}
if (parseAs === 'json') {
if (opts.responseValidator) {
await opts.responseValidator(data);
}

if (opts.responseTransformer) {
data = await opts.responseTransformer(data);
case 'json':
default: {
const text = await response.text();

data = text ? JSON.parse(text) : {};

if (opts.responseValidator) {
await opts.responseValidator(data);
}

if (opts.responseTransformer) {
data = await opts.responseTransformer(data);
}
}
}

Expand All @@ -135,48 +157,60 @@ export const createClient = (config: Config = {}): Client => {
};
}

let error = await response.text();
// ERROR HANDLING
let error: unknown = await response.text();

try {
error = JSON.parse(error);
error = JSON.parse(error as string);
} catch {
// noop
// ignore JSON parse errors
}

let finalError = error;

for (const fn of interceptors.error.fns) {
if (fn) {
finalError = (await fn(finalError, response, request, opts)) as string;
finalError = await fn(finalError, response, finalRequest, opts);
}
}

finalError = finalError || ({} as string);

if (opts.throwOnError) {
throw finalError;
}

return {
error: finalError,
error: finalError || {},
...result,
};
};

return {
buildUrl,
connect: (options) => request({ ...options, method: 'CONNECT' }),
delete: (options) => request({ ...options, method: 'DELETE' }),
get: (options) => request({ ...options, method: 'GET' }),

connect: (o) => request({ ...o, method: 'CONNECT' }),

delete: (o) => request({ ...o, method: 'DELETE' }),

get: (o) => request({ ...o, method: 'GET' }),

getConfig,
head: (options) => request({ ...options, method: 'HEAD' }),

head: (o) => request({ ...o, method: 'HEAD' }),

interceptors,
options: (options) => request({ ...options, method: 'OPTIONS' }),
patch: (options) => request({ ...options, method: 'PATCH' }),
post: (options) => request({ ...options, method: 'POST' }),
put: (options) => request({ ...options, method: 'PUT' }),

options: (o) => request({ ...o, method: 'OPTIONS' }),

patch: (o) => request({ ...o, method: 'PATCH' }),

post: (o) => request({ ...o, method: 'POST' }),

put: (o) => request({ ...o, method: 'PUT' }),

request,

setConfig,
trace: (options) => request({ ...options, method: 'TRACE' }),

trace: (o) => request({ ...o, method: 'TRACE' }),
};
};
Loading