@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.
- package/package.json +1 -1
- package/src/adt/README.mdx +305 -8
- package/src/async/README.mdx +375 -0
- package/src/error-report/README.mdx +323 -0
- package/src/http/README.mdx +349 -0
- package/src/odata/README.mdx +4907 -555
- package/src/persisted/README.mdx +626 -0
- package/src/resource/README.mdx +462 -0
- package/src/server-fn/README.mdx +402 -0
- package/src/transport/README.mdx +345 -0
package/src/http/README.mdx
CHANGED
|
@@ -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 | Результат |
|