@ryuzaki13/react-foundation-api 1.1.16 → 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.
Files changed (36) hide show
  1. package/README.md +32 -43
  2. package/dist/chunks/{odataFetchFn-vnAXC-c0.js → odataFetchFn-B9wSQpUS.js} +11 -11
  3. package/dist/chunks/{odataFetchFn-vnAXC-c0.js.map → odataFetchFn-B9wSQpUS.js.map} +1 -1
  4. package/dist/odata/fetchCollectionData.d.ts +1 -1
  5. package/dist/odata/fetchCollectionData.d.ts.map +1 -1
  6. package/dist/odata/index.js +111 -112
  7. package/dist/odata/index.js.map +1 -1
  8. package/dist/odata/projectODataCollectionSort.d.ts +1 -1
  9. package/dist/odata/projectODataCollectionSort.d.ts.map +1 -1
  10. package/dist/odata/types.d.ts +1 -2
  11. package/dist/odata/types.d.ts.map +1 -1
  12. package/dist/odata/useODataCollection.d.ts +1 -1
  13. package/dist/odata/useODataCollection.d.ts.map +1 -1
  14. package/dist/odata/useODataCollectionQuery.d.ts +1 -1
  15. package/dist/odata/useODataCollectionQuery.d.ts.map +1 -1
  16. package/dist/odata/useODataEntity.d.ts +1 -1
  17. package/dist/odata/useODataEntity.d.ts.map +1 -1
  18. package/dist/persisted/index.js +1 -1
  19. package/package.json +2 -2
  20. package/src/adt/README.mdx +461 -0
  21. package/src/async/README.mdx +628 -0
  22. package/src/error-report/README.mdx +471 -0
  23. package/src/foundationApi.mdx +123 -0
  24. package/src/http/README.mdx +570 -0
  25. package/src/odata/README.mdx +5142 -0
  26. package/src/persisted/README.mdx +1080 -0
  27. package/src/resource/README.mdx +820 -0
  28. package/src/server-fn/README.mdx +596 -0
  29. package/src/transport/README.mdx +528 -0
  30. package/src/README.md +0 -937
  31. package/src/async/README.md +0 -623
  32. package/src/async/async.mdx +0 -6
  33. package/src/odata/README.md +0 -761
  34. package/src/odata/odataFetchFn.mdx +0 -6
  35. package/src/persisted/README.md +0 -598
  36. package/src/persisted/persisted.mdx +0 -6
@@ -0,0 +1,570 @@
1
+ import { Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ <Meta title="Foundation API/Transport/HTTP" />
4
+
5
+ # Обычный HTTP через `@ryuzaki13/react-foundation-api/http`
6
+
7
+ Модуль выполняет нейтральные HTTP/REST запросы и создаёт функции, совместимые с TanStack Query. Внешний payload остаётся `unknown`, пока consumer явно не проверит его через `parse`.
8
+
9
+ ## Содержание
10
+
11
+ - [Когда использовать](#когда-использовать)
12
+ - [Импорт](#импорт)
13
+ - [`httpFetch`](#httpfetch)
14
+ - [`httpFetchPayload`](#httpfetchpayload)
15
+ - [`httpJsonQueryFn`](#httpjsonqueryfn)
16
+ - [`httpJsonMutationFn`](#httpjsonmutationfn)
17
+ - [`RouteError`](#routeerror)
18
+ - [Ошибки, runtime и безопасность](#ошибки-runtime-и-безопасность)
19
+ - [Полный API](#полный-api)
20
+
21
+ ## Когда использовать
22
+
23
+ ```text
24
+ обычный REST/HTTP ──► /http
25
+ SAP Gateway/OData ──► /odata
26
+ ```
27
+
28
+ `http` не добавляет SAP headers, `credentials: include`, X-CSRF token, SSO recovery, OData V2 headers и не снимает envelope `{ d: { results } }`. Для SAP/OData используйте [`/odata`](../odata/README.mdx).
29
+
30
+ Модуль намеренно остаётся небольшим. Он не пытается быть REST framework: выполняет request, проверяет HTTP status, разбирает базовый payload и требует от consumer-а runtime parser.
31
+
32
+ ## Импорт
33
+
34
+ ```ts
35
+ import {
36
+ httpFetch,
37
+ httpFetchPayload,
38
+ httpJsonMutationFn,
39
+ httpJsonQueryFn,
40
+ RouteError
41
+ } from "@ryuzaki13/react-foundation-api/http";
42
+
43
+ import type {
44
+ HttpMutationFnOptions,
45
+ HttpQueryFnOptions,
46
+ HttpRequestOptions
47
+ } from "@ryuzaki13/react-foundation-api/http";
48
+ ```
49
+
50
+ Нужны глобальные Web Fetch API: `fetch`, `Request`, `Response`, `Headers`, `URL`.
51
+
52
+ ## `httpFetch`
53
+
54
+ ```ts
55
+ const response = await httpFetch("/health", {
56
+ baseUrl: "https://api.example.com",
57
+ init: {
58
+ method: "GET",
59
+ headers: { Accept: "text/plain" }
60
+ }
61
+ });
62
+
63
+ console.log(await response.text());
64
+ ```
65
+
66
+ Функция возвращает исходный `Response`, если `response.ok === true`. Body ещё не прочитан.
67
+
68
+ Для string input URL строится простой конкатенацией:
69
+
70
+ ```text
71
+ baseUrl + input
72
+ ```
73
+
74
+ Helper не исправляет slash:
75
+
76
+ ```ts
77
+ // Правильно.
78
+ { baseUrl: "https://api.example.com", input: "/users" }
79
+
80
+ // Получится https://api.example.comusers — неверно.
81
+ { baseUrl: "https://api.example.com", input: "users" }
82
+ ```
83
+
84
+ Если input является `Request` или `URL`, `baseUrl` игнорируется. Это не proxy/url resolver.
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
+
134
+ ## `httpFetchPayload`
135
+
136
+ ```ts
137
+ const payload: unknown = await httpFetchPayload("/api/profile");
138
+ ```
139
+
140
+ Правила parser-а:
141
+
142
+ | Response | Результат |
143
+ | --- | --- |
144
+ | status `204` или `205` | `null` |
145
+ | `Content-Type` содержит `application/json` | `response.json()` |
146
+ | любой другой content type | `response.text()` |
147
+
148
+ Результат намеренно `unknown`. Generic type не может проверить данные, пришедшие по сети:
149
+
150
+ ```ts
151
+ function parseProfile(value: unknown): Profile {
152
+ if (!isProfile(value)) throw new Error("Некорректный Profile payload");
153
+ return value;
154
+ }
155
+
156
+ const profile = parseProfile(await httpFetchPayload("/api/profile"));
157
+ ```
158
+
159
+ Если сервер отдаёт JSON без корректного `Content-Type`, модуль вернёт JSON как string. Исправьте backend или разберите string на своей boundary.
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
+
165
+ ## `httpJsonQueryFn`
166
+
167
+ Factory возвращает async-функцию с формой TanStack Query `queryFn`:
168
+
169
+ ```ts
170
+ const loadProfile = httpJsonQueryFn("/api/profile", {
171
+ baseUrl: "https://api.example.com",
172
+ parse: parseProfile,
173
+ swCache: "ttl=10m;name=profile"
174
+ });
175
+
176
+ const profile = await loadProfile({ signal });
177
+ ```
178
+
179
+ С React Query:
180
+
181
+ ```tsx
182
+ const profileQuery = useQuery({
183
+ queryKey: ["profile", userId],
184
+ queryFn: httpJsonQueryFn(`/api/users/${userId}`, {
185
+ parse: parseProfile
186
+ })
187
+ });
188
+ ```
189
+
190
+ Factory:
191
+
192
+ - использует `init.signal`, если он задан явно;
193
+ - иначе подставляет signal, переданный TanStack Query;
194
+ - добавляет `x-sw-cache`, только если `swCache` truthy;
195
+ - вызывает обязательный `parse(payload)` после успешного запроса.
196
+
197
+ Header `x-sw-cache` — только protocol с Service Worker. Без соответствующего SW он не создаёт cache.
198
+
199
+ `parse` может вернуть преобразованную domain model, но обычно лучше ограничиться runtime validation и вынести бизнес-mapping владельцу entity.
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
+
267
+ ## `httpJsonMutationFn`
268
+
269
+ ```ts
270
+ type CreateUserInput = { name: string };
271
+
272
+ const createUser = httpJsonMutationFn<CreateUserInput, User>("/api/users", {
273
+ method: "POST",
274
+ mapBody: (input) => ({ displayName: input.name }),
275
+ parse: parseUser
276
+ });
277
+
278
+ const user = await createUser({ name: "Анна" });
279
+ ```
280
+
281
+ С `useMutation`:
282
+
283
+ ```tsx
284
+ const mutation = useMutation({ mutationFn: createUser });
285
+ mutation.mutate({ name: "Анна" });
286
+ ```
287
+
288
+ Поведение:
289
+
290
+ 1. Вычисляет body через `mapBody(input)` или использует input.
291
+ 2. Сериализует body через `JSON.stringify`.
292
+ 3. Использует method `POST` по умолчанию; поддерживаются `POST`, `PUT`, `PATCH`, `DELETE`.
293
+ 4. Добавляет `Content-Type: application/json`, только если header ещё не задан.
294
+ 5. Выполняет запрос и передаёт payload в обязательный `parse`.
295
+
296
+ Сигнатура mutation function также допускает второй `AbortSignal` при ручном вызове. Стандартный `useMutation` TanStack Query не передаёт signal автоматически. Если `options.init.signal` уже задан, второй signal не заменит его.
297
+
298
+ `JSON.stringify` может бросить ошибку для `BigInt`/циклического объекта. `undefined` body сериализуется в `undefined`; убедитесь, что это соответствует endpoint.
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
+
393
+ ## HTTP errors
394
+
395
+ Для `response.ok === false` обе низкоуровневые функции бросают обычный `Error`:
396
+
397
+ ```text
398
+ HTTP error: <message>
399
+ ```
400
+
401
+ Если error body — непустой text, он становится message. Для JSON error object поля `message`, `error` и `details` не извлекаются; fallback — `<status> <statusText>`.
402
+
403
+ Network error/CORS/abort от глобального `fetch` пробрасывается без обёртки.
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
+
419
+ ## `RouteError`
420
+
421
+ ```ts
422
+ throw new RouteError(404);
423
+ ```
424
+
425
+ Это маленький `Error` с числовым полем `status` и пустым message. `httpFetch` сам его не создаёт. Класс нужен для routing/error-boundary policy приложения, если оно договорилось понимать такой error.
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
+
523
+ ## Ошибки, runtime и безопасность
524
+
525
+ - TypeScript generic не валидирует сеть; используйте `parse`/schema guard.
526
+ - `baseUrl` и path не нормализуются и не кодируются.
527
+ - Authorization, cookies, CORS и retry задаёт consumer через `init` или внешний слой.
528
+ - Модуль не делает timeout. Для него используйте `AbortController`.
529
+ - Не помещайте token/secret в query string и cache key.
530
+ - `x-sw-cache` для персональных данных требует изолированной cache policy.
531
+ - На SSR абсолютный/относительный URL и cookies должны быть настроены server runtime-ом.
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
+
559
+ ## Полный API
560
+
561
+ | Export | Результат |
562
+ | --- | --- |
563
+ | `httpFetch` | Успешный `Response` |
564
+ | `httpFetchPayload` | JSON/text/`null` как `unknown` |
565
+ | `httpJsonQueryFn` | Query-compatible async function |
566
+ | `httpJsonMutationFn` | Mutation-compatible async function |
567
+ | `RouteError` | Error с `status` |
568
+ | `HttpRequestOptions` | `baseUrl` и `init` |
569
+ | `HttpQueryFnOptions` | Request options + `swCache` + `parse` |
570
+ | `HttpMutationFnOptions` | Request options + method/body mapper/parse |