@ryuzaki13/react-foundation-api 1.1.17 → 1.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -27,6 +27,8 @@ SAP Gateway/OData ──► /odata
27
27
 
28
28
  `http` не добавляет SAP headers, `credentials: include`, X-CSRF token, SSO recovery, OData V2 headers и не снимает envelope `{ d: { results } }`. Для SAP/OData используйте [`/odata`](../odata/README.mdx).
29
29
 
30
+ Модуль намеренно остаётся небольшим. Он не пытается быть REST framework: выполняет request, проверяет HTTP status, разбирает базовый payload и требует от consumer-а runtime parser.
31
+
30
32
  ## Импорт
31
33
 
32
34
  ```ts
@@ -81,6 +83,54 @@ Helper не исправляет slash:
81
83
 
82
84
  Если input является `Request` или `URL`, `baseUrl` игнорируется. Это не proxy/url resolver.
83
85
 
86
+ ### Полные request options
87
+
88
+ ```ts
89
+ type HttpRequestOptions = {
90
+ readonly baseUrl?: string;
91
+ readonly init?: RequestInit;
92
+ };
93
+ ```
94
+
95
+ Caller самостоятельно задаёт method, headers, cookies и signal:
96
+
97
+ ```ts
98
+ const controller = new AbortController();
99
+
100
+ const request = httpFetch("/profile", {
101
+ baseUrl: "/api",
102
+ init: {
103
+ method: "GET",
104
+ headers: {
105
+ Accept: "application/json",
106
+ Authorization: `Bearer ${token}`
107
+ },
108
+ credentials: "include",
109
+ signal: controller.signal
110
+ }
111
+ });
112
+
113
+ controller.abort();
114
+ await request;
115
+ ```
116
+
117
+ Отмена пробрасывается как native fetch error. Модуль не превращает её в специальный класс.
118
+
119
+ ### Зачем возвращать `Response`
120
+
121
+ Используйте `httpFetch`, а не `httpFetchPayload`, когда нужны headers, Blob, stream или custom parser:
122
+
123
+ ```ts
124
+ const response = await httpFetch("/reports/export.xlsx", {
125
+ baseUrl: "/api"
126
+ });
127
+
128
+ const file = await response.blob();
129
+ const disposition = response.headers.get("Content-Disposition");
130
+ ```
131
+
132
+ Response body читается один раз. После `blob()` нельзя повторно вызвать `json()` без предварительного `response.clone()`.
133
+
84
134
  ## `httpFetchPayload`
85
135
 
86
136
  ```ts
@@ -108,6 +158,10 @@ const profile = parseProfile(await httpFetchPayload("/api/profile"));
108
158
 
109
159
  Если сервер отдаёт JSON без корректного `Content-Type`, модуль вернёт JSON как string. Исправьте backend или разберите string на своей boundary.
110
160
 
161
+ Content-Type приводится к lowercase, но распознаётся только media type, содержащий literal `application/json`. Например, `application/problem+json` будет прочитан как text. Для такого endpoint используйте `httpFetch` с custom parsing либо расширьте общий package contract.
162
+
163
+ Invalid JSON с header `application/json` приводит к native parsing error. Он не заменяется на `null` или text.
164
+
111
165
  ## `httpJsonQueryFn`
112
166
 
113
167
  Factory возвращает async-функцию с формой TanStack Query `queryFn`:
@@ -144,6 +198,72 @@ Header `x-sw-cache` — только protocol с Service Worker. Без соот
144
198
 
145
199
  `parse` может вернуть преобразованную domain model, но обычно лучше ограничиться runtime validation и вынести бизнес-mapping владельцу entity.
146
200
 
201
+ ### Почему `parse` обязателен
202
+
203
+ TypeScript generic исчезает из runtime:
204
+
205
+ ```ts
206
+ // Аннотация User не проверяет network.
207
+ const unsafe = httpJsonQueryFn<User>("/api/user", {
208
+ parse: (payload) => payload as User
209
+ });
210
+ ```
211
+
212
+ Минимальный parser:
213
+
214
+ ```ts
215
+ function parseUser(payload: unknown): User {
216
+ if (!payload || typeof payload !== "object") {
217
+ throw new Error("User response должен быть object");
218
+ }
219
+
220
+ const record = payload as Record<string, unknown>;
221
+ if (typeof record.id !== "string" || typeof record.name !== "string") {
222
+ throw new Error("User response содержит неверные поля");
223
+ }
224
+
225
+ return { id: record.id, name: record.name };
226
+ }
227
+ ```
228
+
229
+ Для сложной формы используйте project-approved runtime schema validator.
230
+
231
+ ### Stable query options
232
+
233
+ ```ts
234
+ import { queryOptions } from "@tanstack/react-query";
235
+
236
+ const userKeys = {
237
+ all: ["users"] as const,
238
+ detail: (id: string) => [...userKeys.all, "detail", id] as const
239
+ };
240
+
241
+ export const userQueryOptions = (id: string) =>
242
+ queryOptions({
243
+ queryKey: userKeys.detail(id),
244
+ queryFn: httpJsonQueryFn(
245
+ `/api/users/${encodeURIComponent(id)}`,
246
+ {
247
+ parse: parseUser,
248
+ swCache: "off"
249
+ }
250
+ ),
251
+ enabled: id.length > 0
252
+ });
253
+ ```
254
+
255
+ Все inputs, меняющие response, должны быть в query key. URL encoding остаётся обязанностью caller-а.
256
+
257
+ ### AbortSignal priority
258
+
259
+ Если `options.init.signal` отсутствует, factory подставляет signal TanStack Query. Если explicit signal уже задан, он имеет приоритет и QueryClient cancellation его не заменит.
260
+
261
+ ### Service Worker header
262
+
263
+ Factory создаёт новый `Headers` object и выставляет `x-sw-cache`, когда `swCache` truthy. Если такой header уже был в `init.headers`, option `swCache` перезапишет его.
264
+
265
+ Сам header ничего не кэширует без Service Worker, который реализует protocol. Для auth-sensitive responses используйте `off`, пока isolation users/sessions явно не доказана.
266
+
147
267
  ## `httpJsonMutationFn`
148
268
 
149
269
  ```ts
@@ -177,6 +297,99 @@ mutation.mutate({ name: "Анна" });
177
297
 
178
298
  `JSON.stringify` может бросить ошибку для `BigInt`/циклического объекта. `undefined` body сериализуется в `undefined`; убедитесь, что это соответствует endpoint.
179
299
 
300
+ ### Полные mutation options
301
+
302
+ ```ts
303
+ type HttpMutationFnOptions<TInput, TResult> = HttpRequestOptions & {
304
+ readonly method?: "POST" | "PUT" | "PATCH" | "DELETE";
305
+ readonly mapBody?: (input: TInput) => unknown;
306
+ readonly parse: (data: unknown) => TResult;
307
+ };
308
+ ```
309
+
310
+ ### Mapping input в body
311
+
312
+ ```ts
313
+ type UpdateProfileInput = {
314
+ userId: string;
315
+ draft: {
316
+ name: string;
317
+ email: string;
318
+ };
319
+ };
320
+
321
+ const updateProfile = httpJsonMutationFn<UpdateProfileInput, User>(
322
+ "/api/profile",
323
+ {
324
+ method: "PATCH",
325
+ mapBody: ({ draft }) => ({
326
+ displayName: draft.name,
327
+ emailAddress: draft.email
328
+ }),
329
+ parse: parseUser
330
+ }
331
+ );
332
+ ```
333
+
334
+ URL factory фиксирован при создании. Если path зависит от input, создайте feature wrapper, который внутри строит подходящий mutation function или вызывает `httpFetchPayload`.
335
+
336
+ ### Особенность nullish body mapper
337
+
338
+ Implementation выбирает body так:
339
+
340
+ ```ts
341
+ const body = options.mapBody?.(input) ?? input;
342
+ ```
343
+
344
+ Если `mapBody` вернул `null` или `undefined`, будет сериализован исходный input. Mapper не может этим способом создать body `null` или пустой body.
345
+
346
+ Для endpoint, которому действительно нужен `null`, передайте `null` как input без mapper-а. Для запроса без body используйте `httpFetchPayload`.
347
+
348
+ ### Content-Type
349
+
350
+ `application/json` добавляется только при отсутствии header. Custom media type сохраняется:
351
+
352
+ ```ts
353
+ const patchResource = httpJsonMutationFn("/api/resource", {
354
+ method: "PATCH",
355
+ init: {
356
+ headers: {
357
+ "Content-Type": "application/merge-patch+json"
358
+ }
359
+ },
360
+ parse: parseResource
361
+ });
362
+ ```
363
+
364
+ ### Cache после mutation
365
+
366
+ Factory не знает query keys и ничего не инвалидирует:
367
+
368
+ ```tsx
369
+ const queryClient = useQueryClient();
370
+
371
+ const mutation = useMutation({
372
+ mutationFn: updateProfile,
373
+ onSuccess: (updated) => {
374
+ queryClient.setQueryData(userKeys.detail(updated.id), updated);
375
+ void queryClient.invalidateQueries({ queryKey: userKeys.all });
376
+ }
377
+ });
378
+ ```
379
+
380
+ Query cache хранит server snapshot. Не используйте object из cache как mutable form draft.
381
+
382
+ ### Manual abort mutation
383
+
384
+ ```ts
385
+ const controller = new AbortController();
386
+ const promise = updateProfile(input, controller.signal);
387
+ controller.abort();
388
+ await promise;
389
+ ```
390
+
391
+ `useMutation` сам не передаёт AbortSignal. Не переиспользуйте уже aborted controller.
392
+
180
393
  ## HTTP errors
181
394
 
182
395
  Для `response.ok === false` обе низкоуровневые функции бросают обычный `Error`:
@@ -189,6 +402,20 @@ HTTP error: <message>
189
402
 
190
403
  Network error/CORS/abort от глобального `fetch` пробрасывается без обёртки.
191
404
 
405
+ При non-2xx transport читает error body, чтобы построить message. Consumer уже не получает этот Response.
406
+
407
+ Для JSON error object поля не анализируются:
408
+
409
+ ```json
410
+ {
411
+ "message": "Нет доступа"
412
+ }
413
+ ```
414
+
415
+ При status `403` message будет fallback `HTTP error: 403 Forbidden`, потому что parsed payload является object, а не string.
416
+
417
+ Модуль не повторяет request. Retry policy принадлежит TanStack Query или caller. Обычный `Error` из `httpFetch` не содержит числовой status, поэтому status-aware retry требует отдельного project error contract.
418
+
192
419
  ## `RouteError`
193
420
 
194
421
  ```ts
@@ -197,6 +424,102 @@ throw new RouteError(404);
197
424
 
198
425
  Это маленький `Error` с числовым полем `status` и пустым message. `httpFetch` сам его не создаёт. Класс нужен для routing/error-boundary policy приложения, если оно договорилось понимать такой error.
199
426
 
427
+ ```ts
428
+ async function profileLoader() {
429
+ const profile = await loadProfile();
430
+ if (!profile) throw new RouteError(404);
431
+ return profile;
432
+ }
433
+ ```
434
+
435
+ `Object.setPrototypeOf` внутри class обеспечивает надёжный `instanceof RouteError`. Класс не содержит response body, headers или `cause` и не заменяет полноценную structured transport error.
436
+
437
+ ## Тестирование
438
+
439
+ ```ts
440
+ import { afterEach, expect, it, vi } from "vitest";
441
+ import {
442
+ httpFetchPayload,
443
+ httpJsonQueryFn
444
+ } from "@ryuzaki13/react-foundation-api/http";
445
+
446
+ afterEach(() => vi.unstubAllGlobals());
447
+
448
+ it("разбирает JSON payload", async () => {
449
+ vi.stubGlobal(
450
+ "fetch",
451
+ vi.fn().mockResolvedValue(
452
+ new Response(JSON.stringify({ id: "u-1", name: "Анна" }), {
453
+ status: 200,
454
+ headers: { "Content-Type": "application/json" }
455
+ })
456
+ )
457
+ );
458
+
459
+ await expect(httpFetchPayload("/api/user")).resolves.toEqual({
460
+ id: "u-1",
461
+ name: "Анна"
462
+ });
463
+ });
464
+
465
+ it("отклоняет неверный runtime shape", async () => {
466
+ vi.stubGlobal(
467
+ "fetch",
468
+ vi.fn().mockResolvedValue(
469
+ new Response(JSON.stringify({ id: 10 }), {
470
+ status: 200,
471
+ headers: { "Content-Type": "application/json" }
472
+ })
473
+ )
474
+ );
475
+
476
+ const queryFn = httpJsonQueryFn("/api/user", { parse: parseUser });
477
+ await expect(queryFn()).rejects.toThrow(
478
+ "User response содержит неверные поля"
479
+ );
480
+ });
481
+ ```
482
+
483
+ Проверяйте минимум:
484
+
485
+ - string `baseUrl + path`;
486
+ - Request/URL input игнорирует baseUrl;
487
+ - 204/205 возвращает `null`;
488
+ - JSON и text parsing;
489
+ - invalid JSON;
490
+ - non-2xx error message;
491
+ - explicit signal priority;
492
+ - `x-sw-cache` header;
493
+ - `mapBody` и JSON serialization;
494
+ - custom Content-Type;
495
+ - feature cache strategy после mutation.
496
+
497
+ ## Частые ошибки
498
+
499
+ ### Generic вместо runtime parser
500
+
501
+ `payload as User` только заставляет TypeScript поверить разработчику.
502
+
503
+ ### Неверные slashes
504
+
505
+ URL соединяется простой конкатенацией. Придерживайтесь одной convention.
506
+
507
+ ### Ожидать SAP side effects
508
+
509
+ `/http` не добавляет CSRF, SSO, SAP client или OData envelope parser.
510
+
511
+ ### Вернуть `null` из `mapBody`
512
+
513
+ Из-за `?? input` будет отправлен исходный input.
514
+
515
+ ### Ожидать automatic invalidation
516
+
517
+ Mutation factory не знает query keys. Стратегию cache задаёт owner ресурса.
518
+
519
+ ### Отправлять FormData через JSON factory
520
+
521
+ `httpJsonMutationFn` всегда вызывает `JSON.stringify`. Для FormData используйте `httpFetch`.
522
+
200
523
  ## Ошибки, runtime и безопасность
201
524
 
202
525
  - TypeScript generic не валидирует сеть; используйте `parse`/schema guard.
@@ -207,6 +530,32 @@ throw new RouteError(404);
207
530
  - `x-sw-cache` для персональных данных требует изолированной cache policy.
208
531
  - На SSR абсолютный/относительный URL и cookies должны быть настроены server runtime-ом.
209
532
 
533
+ ## FAQ
534
+
535
+ ### Чем `httpFetchPayload` отличается от `fetch(...).json()`?
536
+
537
+ Он проверяет `response.ok`, понимает 204/205 и выбирает JSON/text по Content-Type.
538
+
539
+ ### Можно ли скачать Blob?
540
+
541
+ Да: `httpFetch`, затем `response.blob()`.
542
+
543
+ ### Можно ли отправить FormData?
544
+
545
+ Да через `httpFetch`. JSON mutation factory для этого не подходит.
546
+
547
+ ### Как задать timeout?
548
+
549
+ Передайте signal от `AbortSignal.timeout(...)` в поддерживаемой среде или используйте `AbortController`.
550
+
551
+ ### Почему JSON query может получить text/null?
552
+
553
+ Low-level parser допускает эти shapes, а обязательный `parse` определяет валидный result endpoint-а.
554
+
555
+ ### Где хранить query keys?
556
+
557
+ Рядом с public API entity/feature либо в `/resource`, если нужен generic descriptor.
558
+
210
559
  ## Полный API
211
560
 
212
561
  | Export | Результат |