fix: исправить edge cases HttpClient
- ошибки парсинга успешных ответов проброшены в onError - добавлена защита от перезаписи явного Authorization - обновлены тесты, README и примеры кастомизации
This commit is contained in:
11
AGENTS.md
11
AGENTS.md
@@ -108,12 +108,15 @@ const httpClient = new HttpClient({
|
|||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const headers = new Headers(params.headers);
|
||||||
|
|
||||||
|
if (!headers.has('Authorization')) {
|
||||||
|
headers.set('Authorization', `Bearer ${token}`);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...params,
|
...params,
|
||||||
headers: {
|
headers,
|
||||||
...params.headers,
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
30
README.md
30
README.md
@@ -156,12 +156,15 @@ const http = new HttpClient({
|
|||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const headers = new Headers(params.headers);
|
||||||
|
|
||||||
|
if (!headers.has('Authorization')) {
|
||||||
|
headers.set('Authorization', `Bearer ${token}`);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...params,
|
...params,
|
||||||
headers: {
|
headers,
|
||||||
...params.headers,
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
onResponse: (response) => response,
|
onResponse: (response) => response,
|
||||||
@@ -178,6 +181,8 @@ const http = new HttpClient({
|
|||||||
|
|
||||||
`onRequest` вызывается перед `fetch`, `onResponse` после успешного ответа, `onError` для HTTP-ошибок, network errors и ошибок парсинга. Для защищенных endpoints generated operation передает `secure: true`, поэтому авторизацию можно добавлять только там, где она нужна.
|
`onRequest` вызывается перед `fetch`, `onResponse` после успешного ответа, `onError` для HTTP-ошибок, network errors и ошибок парсинга. Для защищенных endpoints generated operation передает `secure: true`, поэтому авторизацию можно добавлять только там, где она нужна.
|
||||||
|
|
||||||
|
`onError` должен либо бросить ошибку, либо вернуть fallback-значение, либо вернуть результат `context.retry()`. Если вернуть `undefined`, ошибка будет считаться обработанной, а вызывающий код получит `undefined` вместо исключения.
|
||||||
|
|
||||||
### Опции HttpClient
|
### Опции HttpClient
|
||||||
|
|
||||||
`HttpClient` принимает плоский конфиг. Стандартные `fetch`-опции можно задавать прямо в конструкторе вместе с кастомными hooks клиента.
|
`HttpClient` принимает плоский конфиг. Стандартные `fetch`-опции можно задавать прямо в конструкторе вместе с кастомными hooks клиента.
|
||||||
@@ -200,10 +205,10 @@ const http = new HttpClient({
|
|||||||
| `timeout` | `number` | Таймаут запроса в миллисекундах. Работает через `AbortSignal`. |
|
| `timeout` | `number` | Таймаут запроса в миллисекундах. Работает через `AbortSignal`. |
|
||||||
| `customFetch` | `typeof fetch` | Замена стандартного `fetch`, например для тестов, SSR или custom transport. |
|
| `customFetch` | `typeof fetch` | Замена стандартного `fetch`, например для тестов, SSR или custom transport. |
|
||||||
| `paramsSerializer` | `(query) => string` | Кастомная сериализация query params в URL. |
|
| `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`. |
|
| `onRequest` | `(params, context) => params` | Request interceptor перед вызовом `fetch`. |
|
||||||
| `onResponse` | `(response, context) => response` | Response interceptor после успешного HTTP ответа. |
|
| `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.
|
Также можно использовать другие стандартные поля `RequestInit`, доступные в вашей TypeScript/Runtime среде, если они не относятся к конкретному request body или cancellation lifecycle.
|
||||||
|
|
||||||
@@ -220,7 +225,7 @@ const http = new HttpClient({
|
|||||||
|
|
||||||
### Рецепты Кастомизации
|
### Рецепты Кастомизации
|
||||||
|
|
||||||
Авторизация через `onRequest`:
|
Авторизация через `onRequest` без перезаписи явно переданного `Authorization`:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const http = new HttpClient({
|
const http = new HttpClient({
|
||||||
@@ -231,12 +236,15 @@ const http = new HttpClient({
|
|||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const headers = new Headers(params.headers);
|
||||||
|
|
||||||
|
if (!headers.has('Authorization')) {
|
||||||
|
headers.set('Authorization', `Bearer ${token}`);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...params,
|
...params,
|
||||||
headers: {
|
headers,
|
||||||
...params.headers,
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
11
example.ts
11
example.ts
@@ -20,12 +20,15 @@ const httpClient = new HttpClient({
|
|||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const headers = new Headers(params.headers);
|
||||||
|
|
||||||
|
if (!headers.has('Authorization')) {
|
||||||
|
headers.set('Authorization', `Bearer ${accessToken}`);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...params,
|
...params,
|
||||||
headers: {
|
headers,
|
||||||
...params.headers,
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -361,6 +361,10 @@ export class HttpClient<SecurityDataType = unknown> implements ApiRequestClient
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
if (parsedResponse.ok) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
parsedResponse.error = error as E;
|
parsedResponse.error = error as E;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -168,7 +168,9 @@ bun test -t "should generate client with custom name"
|
|||||||
- ✅ POST с body
|
- ✅ POST с body
|
||||||
- ✅ Обработка 404 статуса
|
- ✅ Обработка 404 статуса
|
||||||
- ✅ Bearer authentication через `onRequest`
|
- ✅ Bearer authentication через `onRequest`
|
||||||
|
- ✅ Сохранение явно переданного `Authorization`
|
||||||
- ✅ Retry через `onError` и `ApiError`
|
- ✅ Retry через `onError` и `ApiError`
|
||||||
|
- ✅ Ошибки парсинга успешного ответа в `onError`
|
||||||
- ✅ Timeout через `AbortSignal`
|
- ✅ Timeout через `AbortSignal`
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -382,6 +382,61 @@ describe('E2E Generation', () => {
|
|||||||
expect(profile.email).toBe('test@example.com');
|
expect(profile.email).toBe('test@example.com');
|
||||||
}, 60000);
|
}, 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 () => {
|
test('onError должен поддерживать retry после ApiError', async () => {
|
||||||
const outputPath = join(tempDir, 'output');
|
const outputPath = join(tempDir, 'output');
|
||||||
|
|
||||||
@@ -429,6 +484,47 @@ describe('E2E Generation', () => {
|
|||||||
expect(result).toEqual({ ok: true });
|
expect(result).toEqual({ ok: true });
|
||||||
}, 60000);
|
}, 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 () => {
|
test('timeout должен отменять зависший запрос', async () => {
|
||||||
const outputPath = join(tempDir, 'output');
|
const outputPath = join(tempDir, 'output');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user