Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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