diff --git a/AGENTS.md b/AGENTS.md index b603207..7e297c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,12 +108,15 @@ const httpClient = new HttpClient({ return params; } + const headers = new Headers(params.headers); + + if (!headers.has('Authorization')) { + headers.set('Authorization', `Bearer ${token}`); + } + return { ...params, - headers: { - ...params.headers, - Authorization: `Bearer ${token}`, - }, + headers, }; }, }); diff --git a/README.md b/README.md index 8bc2493..e82f7ef 100644 --- a/README.md +++ b/README.md @@ -156,12 +156,15 @@ const http = new HttpClient({ return params; } + const headers = new Headers(params.headers); + + if (!headers.has('Authorization')) { + headers.set('Authorization', `Bearer ${token}`); + } + return { ...params, - headers: { - ...params.headers, - Authorization: `Bearer ${token}`, - }, + headers, }; }, onResponse: (response) => response, @@ -178,6 +181,8 @@ const http = new HttpClient({ `onRequest` вызывается перед `fetch`, `onResponse` после успешного ответа, `onError` для HTTP-ошибок, network errors и ошибок парсинга. Для защищенных endpoints generated operation передает `secure: true`, поэтому авторизацию можно добавлять только там, где она нужна. +`onError` должен либо бросить ошибку, либо вернуть fallback-значение, либо вернуть результат `context.retry()`. Если вернуть `undefined`, ошибка будет считаться обработанной, а вызывающий код получит `undefined` вместо исключения. + ### Опции HttpClient `HttpClient` принимает плоский конфиг. Стандартные `fetch`-опции можно задавать прямо в конструкторе вместе с кастомными hooks клиента. @@ -200,10 +205,10 @@ const http = new HttpClient({ | `timeout` | `number` | Таймаут запроса в миллисекундах. Работает через `AbortSignal`. | | `customFetch` | `typeof fetch` | Замена стандартного `fetch`, например для тестов, SSR или custom transport. | | `paramsSerializer` | `(query) => string` | Кастомная сериализация query params в URL. | -| `responseParser` | `(response, format) => unknown` | Кастомный парсинг response body. | +| `responseParser` | `(response, format) => unknown` | Кастомный парсинг response body. Ошибки парсинга успешных ответов попадают в `onError`. | | `onRequest` | `(params, context) => params` | Request interceptor перед вызовом `fetch`. | | `onResponse` | `(response, context) => response` | Response interceptor после успешного HTTP ответа. | -| `onError` | `(error, context) => result` | Error interceptor для HTTP ошибок, network errors, retry и refresh-token сценариев. | +| `onError` | `(error, context) => result` | Error interceptor для HTTP ошибок, network errors, ошибок парсинга, retry и refresh-token сценариев. Должен вернуть fallback/retry или бросить ошибку. | Также можно использовать другие стандартные поля `RequestInit`, доступные в вашей TypeScript/Runtime среде, если они не относятся к конкретному request body или cancellation lifecycle. @@ -220,7 +225,7 @@ const http = new HttpClient({ ### Рецепты Кастомизации -Авторизация через `onRequest`: +Авторизация через `onRequest` без перезаписи явно переданного `Authorization`: ```typescript const http = new HttpClient({ @@ -231,12 +236,15 @@ const http = new HttpClient({ return params; } + const headers = new Headers(params.headers); + + if (!headers.has('Authorization')) { + headers.set('Authorization', `Bearer ${token}`); + } + return { ...params, - headers: { - ...params.headers, - Authorization: `Bearer ${token}`, - }, + headers, }; }, }); diff --git a/example.ts b/example.ts index 290b165..39de1b4 100644 --- a/example.ts +++ b/example.ts @@ -20,12 +20,15 @@ const httpClient = new HttpClient({ return params; } + const headers = new Headers(params.headers); + + if (!headers.has('Authorization')) { + headers.set('Authorization', `Bearer ${accessToken}`); + } + return { ...params, - headers: { - ...params.headers, - Authorization: `Bearer ${accessToken}`, - }, + headers, }; }, }); diff --git a/src/templates/http-client.ejs b/src/templates/http-client.ejs index 17a88a6..2c565a6 100644 --- a/src/templates/http-client.ejs +++ b/src/templates/http-client.ejs @@ -361,6 +361,10 @@ export class HttpClient implements ApiRequestClient } }) .catch((error) => { + if (parsedResponse.ok) { + throw error; + } + parsedResponse.error = error as E; }); diff --git a/tests/README.md b/tests/README.md index 3349b26..01bc8a3 100644 --- a/tests/README.md +++ b/tests/README.md @@ -168,7 +168,9 @@ bun test -t "should generate client with custom name" - ✅ POST с body - ✅ Обработка 404 статуса - ✅ Bearer authentication через `onRequest` +- ✅ Сохранение явно переданного `Authorization` - ✅ Retry через `onError` и `ApiError` +- ✅ Ошибки парсинга успешного ответа в `onError` - ✅ Timeout через `AbortSignal` diff --git a/tests/integration/e2e-generation.test.ts b/tests/integration/e2e-generation.test.ts index 928c686..ff44f6b 100644 --- a/tests/integration/e2e-generation.test.ts +++ b/tests/integration/e2e-generation.test.ts @@ -382,6 +382,61 @@ describe('E2E Generation', () => { expect(profile.email).toBe('test@example.com'); }, 60000); + test('onRequest не должен перетирать явно переданный Authorization', async () => { + const outputPath = join(tempDir, 'output'); + + await execa('bun', [ + 'run', + CLI_PATH, + '--input', + FIXTURES.MINIMAL, + '--output', + outputPath, + '--mode', + 'split', + ]); + + const generatedFile = join(outputPath, 'index.ts'); + const { HttpClient } = await import(generatedFile); + let authorizationHeader: string | null = null; + + const http = new HttpClient({ + customFetch: async (_url, init) => { + authorizationHeader = new Headers(init?.headers).get('Authorization'); + return Response.json({ ok: true }); + }, + onRequest: (params) => { + if (!params.secure) { + return params; + } + + const headers = new Headers(params.headers); + + if (!headers.has('Authorization')) { + headers.set('Authorization', 'Bearer default-token'); + } + + return { + ...params, + headers, + }; + }, + }); + + const result = await http.request({ + path: '/auth-header', + method: 'GET', + secure: true, + format: 'json', + headers: { + Authorization: 'Bearer explicit-token', + }, + }); + + expect(result).toEqual({ ok: true }); + expect(authorizationHeader).toBe('Bearer explicit-token'); + }, 60000); + test('onError должен поддерживать retry после ApiError', async () => { const outputPath = join(tempDir, 'output'); @@ -429,6 +484,47 @@ describe('E2E Generation', () => { expect(result).toEqual({ ok: true }); }, 60000); + test('onError должен получать ошибки парсинга успешного ответа', async () => { + const outputPath = join(tempDir, 'output'); + + await execa('bun', [ + 'run', + CLI_PATH, + '--input', + FIXTURES.MINIMAL, + '--output', + outputPath, + '--mode', + 'split', + ]); + + const generatedFile = join(outputPath, 'index.ts'); + const { HttpClient } = await import(generatedFile); + let handledError: unknown; + + const http = new HttpClient({ + customFetch: async () => new Response('{ invalid json', { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }), + onError: (error) => { + handledError = error; + return { parsed: false }; + }, + }); + + const result = await http.request({ + path: '/broken-json', + method: 'GET', + format: 'json', + }); + + expect(result).toEqual({ parsed: false }); + expect(handledError).toBeInstanceOf(Error); + }, 60000); + test('timeout должен отменять зависший запрос', async () => { const outputPath = join(tempDir, 'output');