@ryuzaki13/react-foundation-api 1.1.15 → 1.1.17
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/README.md +32 -43
- package/dist/chunks/{odataFetchFn-vnAXC-c0.js → odataFetchFn-B9wSQpUS.js} +11 -11
- package/dist/chunks/{odataFetchFn-vnAXC-c0.js.map → odataFetchFn-B9wSQpUS.js.map} +1 -1
- package/dist/odata/fetchCollectionData.d.ts +1 -1
- package/dist/odata/fetchCollectionData.d.ts.map +1 -1
- package/dist/odata/index.js +111 -112
- package/dist/odata/index.js.map +1 -1
- package/dist/odata/projectODataCollectionSort.d.ts +1 -1
- package/dist/odata/projectODataCollectionSort.d.ts.map +1 -1
- package/dist/odata/types.d.ts +1 -2
- package/dist/odata/types.d.ts.map +1 -1
- package/dist/odata/useODataCollection.d.ts +1 -1
- package/dist/odata/useODataCollection.d.ts.map +1 -1
- package/dist/odata/useODataCollectionQuery.d.ts +1 -1
- package/dist/odata/useODataCollectionQuery.d.ts.map +1 -1
- package/dist/odata/useODataEntity.d.ts +1 -1
- package/dist/odata/useODataEntity.d.ts.map +1 -1
- package/dist/persisted/index.js +1 -1
- package/package.json +8 -3
- package/src/adt/README.mdx +164 -0
- package/src/async/README.mdx +253 -0
- package/src/error-report/README.mdx +148 -0
- package/src/foundationApi.mdx +123 -0
- package/src/http/README.mdx +221 -0
- package/src/odata/README.mdx +790 -0
- package/src/persisted/README.mdx +454 -0
- package/src/resource/README.mdx +358 -0
- package/src/server-fn/README.mdx +194 -0
- package/src/transport/README.mdx +183 -0
- package/src/README.md +0 -937
- package/src/async/README.md +0 -623
- package/src/async/async.mdx +0 -6
- package/src/odata/README.md +0 -761
- package/src/odata/odataFetchFn.mdx +0 -6
- package/src/persisted/README.md +0 -598
- package/src/persisted/persisted.mdx +0 -6
package/src/README.md
DELETED
|
@@ -1,937 +0,0 @@
|
|
|
1
|
-
# shared/api: руководство пользователя публичного API
|
|
2
|
-
|
|
3
|
-
Этот документ описывает, как пользоваться публичным API слоя `src/shared/api`.
|
|
4
|
-
|
|
5
|
-
Если нужно сопровождать внутреннюю реализацию, менять границы слоёв, добавлять transport или править cache orchestration, сначала читать [ARCHITECTURE.md](./ARCHITECTURE.md).
|
|
6
|
-
|
|
7
|
-
## Быстрый выбор слоя
|
|
8
|
-
|
|
9
|
-
`shared/api` теперь разделён на несколько независимых назначений:
|
|
10
|
-
|
|
11
|
-
| Слой | Когда использовать | Что не делать |
|
|
12
|
-
| ---------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
|
|
13
|
-
| `shared/api/http` | Обычный HTTP/REST без SAP, OData, SAML2 и CSRF | Не использовать для OData Gateway |
|
|
14
|
-
| `shared/api/odata` | SAP Gateway / OData v2 / metadata-aware запросы | Не собирать OData URL вручную в feature/entity |
|
|
15
|
-
| `shared/api/odata/transport` | Низкоуровневый OData transport: `fetchJson`, `fetchBase`, SSO, CSRF | Не использовать как общий fetch для SSR/REST |
|
|
16
|
-
| `shared/api/resource` | Универсальная модель ресурса с произвольными read/write operation names | Не вшивать сюда конкретные transport details |
|
|
17
|
-
| `shared/api/server-fn` | Адаптация TanStack Start server functions к `resource`/`persisted` operation contract | Не импортировать `@tanstack/react-start` в shared |
|
|
18
|
-
| `shared/api/persisted` | Узкий фасад для сохранённых записей с capability `list/latest/history/save/create/delete` | Не расширять под произвольные операции, для этого есть `resource` |
|
|
19
|
-
|
|
20
|
-
## Правило выбора
|
|
21
|
-
|
|
22
|
-
1. Если endpoint OData/SAP Gateway, использовать `shared/api/odata`.
|
|
23
|
-
2. Если endpoint обычный HTTP/REST, использовать `shared/api/http`.
|
|
24
|
-
3. Если нужно описать ресурс с query/mutation, query keys и cache policy, использовать `shared/api/resource`.
|
|
25
|
-
4. Если ресурс хранит сохранённые записи и укладывается в `list/latest/history/save/create/delete`, можно использовать `shared/api/persisted`.
|
|
26
|
-
5. Если проект на TanStack Start вызывает serverFn, транспортную operation создавать через `shared/api/server-fn`.
|
|
27
|
-
|
|
28
|
-
## Импорты
|
|
29
|
-
|
|
30
|
-
Для прикладного кода предпочтителен публичный barrel:
|
|
31
|
-
|
|
32
|
-
```ts
|
|
33
|
-
import { httpJsonQueryFn, odataQueryFn } from "@/shared/api";
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
Для специализированных подслоёв допустимы точные публичные импорты:
|
|
37
|
-
|
|
38
|
-
```ts
|
|
39
|
-
import { createResourceDescriptor } from "@/shared/api/resource";
|
|
40
|
-
import { createServerFnQueryOperation } from "@/shared/api/server-fn";
|
|
41
|
-
import { createPersistedResourceDescriptor } from "@/shared/api/persisted";
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
Не использовать удалённый путь:
|
|
45
|
-
|
|
46
|
-
```ts
|
|
47
|
-
// Нельзя: такого слоя больше нет.
|
|
48
|
-
import { fetchJson } from "@/shared/api/fetch";
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
Если нужен `fetchJson`, это OData-specific transport:
|
|
52
|
-
|
|
53
|
-
```ts
|
|
54
|
-
import { fetchJson } from "@/shared/api";
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
или внутри `shared/api`:
|
|
58
|
-
|
|
59
|
-
```ts
|
|
60
|
-
import { fetchJson } from "../odata/transport";
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
## `shared/api/http`
|
|
64
|
-
|
|
65
|
-
`http` — это чистый HTTP-слой без SAP/OData side effects.
|
|
66
|
-
|
|
67
|
-
Он не добавляет:
|
|
68
|
-
|
|
69
|
-
- SAP base URL;
|
|
70
|
-
- SAP client;
|
|
71
|
-
- X-CSRF token;
|
|
72
|
-
- SAML2/SSO recovery;
|
|
73
|
-
- OData envelope parsing;
|
|
74
|
-
- OData metadata;
|
|
75
|
-
- OData-specific error reporting.
|
|
76
|
-
|
|
77
|
-
### Публичные функции
|
|
78
|
-
|
|
79
|
-
```ts
|
|
80
|
-
httpFetch(input, options): Promise<Response>
|
|
81
|
-
httpFetchPayload(input, options): Promise<unknown>
|
|
82
|
-
httpJsonQueryFn(url, options): queryFn
|
|
83
|
-
httpJsonMutationFn(url, options): mutationFn
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
### `httpFetch`
|
|
87
|
-
|
|
88
|
-
Использовать, когда нужен сырой `Response`.
|
|
89
|
-
|
|
90
|
-
```ts
|
|
91
|
-
import { httpFetch } from "@/shared/api";
|
|
92
|
-
|
|
93
|
-
const response = await httpFetch("/api/files/report", {
|
|
94
|
-
baseUrl: "",
|
|
95
|
-
init: {
|
|
96
|
-
headers: {
|
|
97
|
-
Accept: "application/pdf"
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
});
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
`httpFetch` бросает `Error`, если `response.ok === false`.
|
|
104
|
-
|
|
105
|
-
### `httpFetchPayload`
|
|
106
|
-
|
|
107
|
-
Использовать, когда нужен JSON/text payload без TanStack Query factory.
|
|
108
|
-
|
|
109
|
-
```ts
|
|
110
|
-
import { httpFetchPayload } from "@/shared/api";
|
|
111
|
-
|
|
112
|
-
const payload = await httpFetchPayload("/api/profile", {
|
|
113
|
-
baseUrl: ""
|
|
114
|
-
});
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
Результат всегда `unknown`. Его нужно сузить самостоятельно.
|
|
118
|
-
|
|
119
|
-
```ts
|
|
120
|
-
type Profile = {
|
|
121
|
-
readonly id: string;
|
|
122
|
-
readonly name: string;
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
function parseProfile(payload: unknown): Profile {
|
|
126
|
-
if (typeof payload !== "object" || payload === null || !("id" in payload) || !("name" in payload)) {
|
|
127
|
-
throw new Error("Некорректный профиль.");
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
if (typeof payload.id !== "string" || typeof payload.name !== "string") {
|
|
131
|
-
throw new Error("Некорректный профиль.");
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
return {
|
|
135
|
-
id: payload.id,
|
|
136
|
-
name: payload.name
|
|
137
|
-
};
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const profile = parseProfile(payload);
|
|
141
|
-
```
|
|
142
|
-
|
|
143
|
-
### `httpJsonQueryFn`
|
|
144
|
-
|
|
145
|
-
Использовать как `queryFn` для обычного REST endpoint.
|
|
146
|
-
|
|
147
|
-
```ts
|
|
148
|
-
import { queryOptions, useQuery } from "@tanstack/react-query";
|
|
149
|
-
|
|
150
|
-
import { httpJsonQueryFn } from "@/shared/api";
|
|
151
|
-
|
|
152
|
-
type Profile = {
|
|
153
|
-
readonly id: string;
|
|
154
|
-
readonly name: string;
|
|
155
|
-
};
|
|
156
|
-
|
|
157
|
-
function parseProfile(payload: unknown): Profile {
|
|
158
|
-
if (typeof payload !== "object" || payload === null || !("id" in payload) || !("name" in payload)) {
|
|
159
|
-
throw new Error("Некорректный профиль.");
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
if (typeof payload.id !== "string" || typeof payload.name !== "string") {
|
|
163
|
-
throw new Error("Некорректный профиль.");
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
return {
|
|
167
|
-
id: payload.id,
|
|
168
|
-
name: payload.name
|
|
169
|
-
};
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const profileQueryOptions = (userId: string) =>
|
|
173
|
-
queryOptions({
|
|
174
|
-
queryKey: ["profile", userId],
|
|
175
|
-
queryFn: httpJsonQueryFn(`/api/users/${userId}/profile`, {
|
|
176
|
-
parse: parseProfile
|
|
177
|
-
})
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
export function useProfileQuery(userId: string) {
|
|
181
|
-
return useQuery(profileQueryOptions(userId));
|
|
182
|
-
}
|
|
183
|
-
```
|
|
184
|
-
|
|
185
|
-
Особенности:
|
|
186
|
-
|
|
187
|
-
- `parse` обязателен;
|
|
188
|
-
- `signal` из TanStack Query добавляется автоматически, если в `init.signal` не передан свой;
|
|
189
|
-
- `swCache` добавляет заголовок `x-sw-cache`;
|
|
190
|
-
- JSON определяется по `Content-Type: application/json`, иначе payload будет строкой.
|
|
191
|
-
|
|
192
|
-
### `httpJsonMutationFn`
|
|
193
|
-
|
|
194
|
-
Использовать как `mutationFn` для REST endpoint.
|
|
195
|
-
|
|
196
|
-
```ts
|
|
197
|
-
import { useMutation } from "@tanstack/react-query";
|
|
198
|
-
|
|
199
|
-
import { httpJsonMutationFn } from "@/shared/api";
|
|
200
|
-
|
|
201
|
-
type SaveProfileInput = {
|
|
202
|
-
readonly name: string;
|
|
203
|
-
};
|
|
204
|
-
|
|
205
|
-
type SaveProfileResult = {
|
|
206
|
-
readonly id: string;
|
|
207
|
-
};
|
|
208
|
-
|
|
209
|
-
function parseSaveProfileResult(payload: unknown): SaveProfileResult {
|
|
210
|
-
if (typeof payload !== "object" || payload === null || !("id" in payload) || typeof payload.id !== "string") {
|
|
211
|
-
throw new Error("Некорректный результат сохранения профиля.");
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
return { id: payload.id };
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
export function useSaveProfileMutation(userId: string) {
|
|
218
|
-
return useMutation({
|
|
219
|
-
mutationKey: ["profile", userId, "save"],
|
|
220
|
-
mutationFn: httpJsonMutationFn<SaveProfileInput, SaveProfileResult>(`/api/users/${userId}/profile`, {
|
|
221
|
-
method: "PUT",
|
|
222
|
-
parse: parseSaveProfileResult
|
|
223
|
-
})
|
|
224
|
-
});
|
|
225
|
-
}
|
|
226
|
-
```
|
|
227
|
-
|
|
228
|
-
Если нужно изменить body перед отправкой, использовать `mapBody`:
|
|
229
|
-
|
|
230
|
-
```ts
|
|
231
|
-
httpJsonMutationFn<SaveProfileInput, SaveProfileResult>("/api/profile", {
|
|
232
|
-
method: "POST",
|
|
233
|
-
mapBody: (input) => ({
|
|
234
|
-
displayName: input.name.trim()
|
|
235
|
-
}),
|
|
236
|
-
parse: parseSaveProfileResult
|
|
237
|
-
});
|
|
238
|
-
```
|
|
239
|
-
|
|
240
|
-
## `shared/api/odata`
|
|
241
|
-
|
|
242
|
-
`odata` — слой для SAP Gateway / OData v2.
|
|
243
|
-
|
|
244
|
-
Использовать его, если запрос зависит от:
|
|
245
|
-
|
|
246
|
-
- OData metadata;
|
|
247
|
-
- TextEntitySet / EntityType / FunctionImport;
|
|
248
|
-
- `$select`, `$expand`, `$filter`, `$orderby`, `$top`, `$skip`;
|
|
249
|
-
- OData key serialization;
|
|
250
|
-
- SAP base URL;
|
|
251
|
-
- X-CSRF;
|
|
252
|
-
- SAML2/SSO handling.
|
|
253
|
-
|
|
254
|
-
### Высокоуровневые helper-ы
|
|
255
|
-
|
|
256
|
-
Для большинства задач использовать:
|
|
257
|
-
|
|
258
|
-
```ts
|
|
259
|
-
odataQueryFn;
|
|
260
|
-
odataReadFn;
|
|
261
|
-
odataCreateFn;
|
|
262
|
-
odataUpdateFn;
|
|
263
|
-
odataDeleteFn;
|
|
264
|
-
odataFunctionImportFn;
|
|
265
|
-
```
|
|
266
|
-
|
|
267
|
-
Пример query:
|
|
268
|
-
|
|
269
|
-
```ts
|
|
270
|
-
import { queryOptions, useQuery } from "@tanstack/react-query";
|
|
271
|
-
|
|
272
|
-
import { odataQueryFn } from "@/shared/api";
|
|
273
|
-
import { createFilterEqual } from "@ryuzaki13/react-foundation-lib/odata-service";
|
|
274
|
-
|
|
275
|
-
type RawUser = {
|
|
276
|
-
ID: string;
|
|
277
|
-
NAME: string;
|
|
278
|
-
};
|
|
279
|
-
|
|
280
|
-
type User = {
|
|
281
|
-
readonly id: string;
|
|
282
|
-
readonly name: string;
|
|
283
|
-
};
|
|
284
|
-
|
|
285
|
-
const usersQueryOptions = (departmentId: string) =>
|
|
286
|
-
queryOptions({
|
|
287
|
-
queryKey: ["users", departmentId],
|
|
288
|
-
queryFn: odataQueryFn<RawUser, User>({
|
|
289
|
-
odata: {
|
|
290
|
-
service: "TEXT_USER_SRV",
|
|
291
|
-
target: "TextUserSet"
|
|
292
|
-
},
|
|
293
|
-
options: {
|
|
294
|
-
expression: {
|
|
295
|
-
filters: [createFilterEqual("DEPARTMENT_ID", departmentId)]
|
|
296
|
-
}
|
|
297
|
-
},
|
|
298
|
-
transform: (rows) =>
|
|
299
|
-
rows.map((row) => ({
|
|
300
|
-
id: row.ID,
|
|
301
|
-
name: row.NAME
|
|
302
|
-
}))
|
|
303
|
-
})
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
export function useUsersQuery(departmentId: string) {
|
|
307
|
-
return useQuery(usersQueryOptions(departmentId));
|
|
308
|
-
}
|
|
309
|
-
```
|
|
310
|
-
|
|
311
|
-
Подробнее по metadata-aware helper-ам: [odata/README.md](./odata/README.md).
|
|
312
|
-
|
|
313
|
-
### Низкоуровневый OData transport
|
|
314
|
-
|
|
315
|
-
Из `shared/api/odata/transport` доступны:
|
|
316
|
-
|
|
317
|
-
```ts
|
|
318
|
-
fetchBase;
|
|
319
|
-
fetchODataJson;
|
|
320
|
-
fetchJson;
|
|
321
|
-
fetchJsonQueryFn;
|
|
322
|
-
fetchJsonMutationFn;
|
|
323
|
-
fetchDeleteFn;
|
|
324
|
-
fetchQueryFn;
|
|
325
|
-
fetchMetadata;
|
|
326
|
-
resolveODataBaseUrl;
|
|
327
|
-
normalizeODataServiceName;
|
|
328
|
-
SsoRequiredError;
|
|
329
|
-
recoverSsoSession;
|
|
330
|
-
```
|
|
331
|
-
|
|
332
|
-
Этот слой исторически назывался `shared/api/fetch`, но теперь находится внутри `odata`, потому что его поведение SAP/OData-specific.
|
|
333
|
-
|
|
334
|
-
Использовать transport напрямую стоит только если:
|
|
335
|
-
|
|
336
|
-
- нужен запрос к SAP endpoint без metadata-aware `odataFetchFn`;
|
|
337
|
-
- нужен bootstrap-запрос;
|
|
338
|
-
- нужен SSO recovery;
|
|
339
|
-
- нужен raw OData JSON helper;
|
|
340
|
-
- существующий helper не покрывает сценарий.
|
|
341
|
-
|
|
342
|
-
Пример:
|
|
343
|
-
|
|
344
|
-
```ts
|
|
345
|
-
import { fetchJson } from "@/shared/api";
|
|
346
|
-
|
|
347
|
-
type TransportRequestRaw = {
|
|
348
|
-
readonly TRKORR: string;
|
|
349
|
-
readonly AS4TEXT: string;
|
|
350
|
-
};
|
|
351
|
-
|
|
352
|
-
const rows = await fetchJson<TransportRequestRaw[]>("/TextTransportRequestSet", undefined, "odataDp0");
|
|
353
|
-
```
|
|
354
|
-
|
|
355
|
-
Не использовать `fetchJson` для обычного REST/SSR endpoint. Для этого есть `http`.
|
|
356
|
-
|
|
357
|
-
## `shared/api/resource`
|
|
358
|
-
|
|
359
|
-
`resource` — generic orchestration-слой поверх TanStack Query.
|
|
360
|
-
|
|
361
|
-
Он решает задачи:
|
|
362
|
-
|
|
363
|
-
- стабильные query keys;
|
|
364
|
-
- нормализация `scope`;
|
|
365
|
-
- произвольные read operation names;
|
|
366
|
-
- произвольные write operation names;
|
|
367
|
-
- `queryOptions`;
|
|
368
|
-
- `useQuery`;
|
|
369
|
-
- `fetchQuery`;
|
|
370
|
-
- `useMutation`;
|
|
371
|
-
- cache strategies после успешных мутаций.
|
|
372
|
-
|
|
373
|
-
Он не знает:
|
|
374
|
-
|
|
375
|
-
- как выполнять HTTP;
|
|
376
|
-
- как выполнять OData;
|
|
377
|
-
- как вызывать serverFn;
|
|
378
|
-
- что такое конкретная бизнес-сущность;
|
|
379
|
-
- какие operation names должны существовать.
|
|
380
|
-
|
|
381
|
-
### Базовая модель
|
|
382
|
-
|
|
383
|
-
Ресурс описывается descriptor-ом:
|
|
384
|
-
|
|
385
|
-
```ts
|
|
386
|
-
const descriptor = createResourceDescriptor({
|
|
387
|
-
namespace: "profile",
|
|
388
|
-
resource: "settings",
|
|
389
|
-
normalizeScope: (scope: ProfileScope | null | undefined) => ({
|
|
390
|
-
userId: scope?.userId.trim() ?? ""
|
|
391
|
-
}),
|
|
392
|
-
isEnabled: (scope) => Boolean(scope?.userId),
|
|
393
|
-
getScopeError: () => "Не указан userId.",
|
|
394
|
-
operations: {
|
|
395
|
-
queries: {
|
|
396
|
-
detail: createResourceQueryOperation(...)
|
|
397
|
-
},
|
|
398
|
-
mutations: {
|
|
399
|
-
save: createResourceMutationOperation(...)
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
});
|
|
403
|
-
```
|
|
404
|
-
|
|
405
|
-
`namespace` и `resource` участвуют в query key. Они должны быть стабильными строками.
|
|
406
|
-
|
|
407
|
-
`scope` — внешний контекст ресурса: пользователь, приложение, ракурс, tenant, документ или другой идентификатор.
|
|
408
|
-
|
|
409
|
-
`args` — аргументы конкретной read-операции.
|
|
410
|
-
|
|
411
|
-
`input` — payload write-операции.
|
|
412
|
-
|
|
413
|
-
### Query key
|
|
414
|
-
|
|
415
|
-
По умолчанию ключ строится так:
|
|
416
|
-
|
|
417
|
-
```ts
|
|
418
|
-
[namespace, resource, normalizedScope, operationName, normalizedArgs?]
|
|
419
|
-
```
|
|
420
|
-
|
|
421
|
-
Пример:
|
|
422
|
-
|
|
423
|
-
```ts
|
|
424
|
-
descriptor.keys.operation("detail", { userId: " USER " }, { version: 2 });
|
|
425
|
-
// ["profile", "settings", { userId: "USER" }, "detail", { version: 2 }]
|
|
426
|
-
```
|
|
427
|
-
|
|
428
|
-
Нормализация:
|
|
429
|
-
|
|
430
|
-
- `null` и `undefined` превращаются в `null`;
|
|
431
|
-
- строки trim-ятся;
|
|
432
|
-
- массивы нормализуются поэлементно;
|
|
433
|
-
- ключи объектов сортируются;
|
|
434
|
-
- остальные значения приводятся к строке.
|
|
435
|
-
|
|
436
|
-
Если этого недостаточно, передать `normalizeScope`.
|
|
437
|
-
|
|
438
|
-
### Read operation
|
|
439
|
-
|
|
440
|
-
```ts
|
|
441
|
-
import { createResourceQueryOperation } from "@/shared/api/resource";
|
|
442
|
-
|
|
443
|
-
type ProfileScope = {
|
|
444
|
-
readonly userId: string;
|
|
445
|
-
};
|
|
446
|
-
|
|
447
|
-
type ProfileArgs = {
|
|
448
|
-
readonly includePermissions: boolean;
|
|
449
|
-
};
|
|
450
|
-
|
|
451
|
-
type Profile = {
|
|
452
|
-
readonly id: string;
|
|
453
|
-
readonly name: string;
|
|
454
|
-
};
|
|
455
|
-
|
|
456
|
-
const detail = createResourceQueryOperation<ProfileScope, ProfileArgs, Profile>({
|
|
457
|
-
staleTime: 1000 * 60,
|
|
458
|
-
execute: async ({ scope, args, signal }) => {
|
|
459
|
-
const response = await fetch(`/api/users/${scope.userId}?permissions=${args.includePermissions}`, { signal });
|
|
460
|
-
const payload: unknown = await response.json();
|
|
461
|
-
return parseProfile(payload);
|
|
462
|
-
}
|
|
463
|
-
});
|
|
464
|
-
```
|
|
465
|
-
|
|
466
|
-
### Descriptor + hook
|
|
467
|
-
|
|
468
|
-
```ts
|
|
469
|
-
import { createResourceDescriptor, useResourceQuery } from "@/shared/api/resource";
|
|
470
|
-
|
|
471
|
-
const profileResource = createResourceDescriptor({
|
|
472
|
-
namespace: "profile",
|
|
473
|
-
resource: "user",
|
|
474
|
-
isEnabled: (scope: ProfileScope | null | undefined) => Boolean(scope?.userId),
|
|
475
|
-
operations: {
|
|
476
|
-
queries: {
|
|
477
|
-
detail
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
});
|
|
481
|
-
|
|
482
|
-
export function useProfileQuery(scope: ProfileScope, args: ProfileArgs) {
|
|
483
|
-
return useResourceQuery(profileResource, "detail", scope, args);
|
|
484
|
-
}
|
|
485
|
-
```
|
|
486
|
-
|
|
487
|
-
### Imperative preload
|
|
488
|
-
|
|
489
|
-
```ts
|
|
490
|
-
import { getResourceQueryData } from "@/shared/api/resource";
|
|
491
|
-
|
|
492
|
-
const profile = await getResourceQueryData(profileResource, "detail", scope, args, queryClient);
|
|
493
|
-
```
|
|
494
|
-
|
|
495
|
-
### Mutation operation
|
|
496
|
-
|
|
497
|
-
```ts
|
|
498
|
-
import { createResourceMutationOperation, createInvalidateResourceScopeCacheStrategy } from "@/shared/api/resource";
|
|
499
|
-
|
|
500
|
-
type SaveProfileInput = {
|
|
501
|
-
readonly name: string;
|
|
502
|
-
};
|
|
503
|
-
|
|
504
|
-
const save = createResourceMutationOperation<ProfileScope, SaveProfileInput, Profile, typeof profileResource>({
|
|
505
|
-
execute: async ({ scope, input }) => {
|
|
506
|
-
const response = await fetch(`/api/users/${scope.userId}`, {
|
|
507
|
-
method: "PUT",
|
|
508
|
-
headers: { "Content-Type": "application/json" },
|
|
509
|
-
body: JSON.stringify(input)
|
|
510
|
-
});
|
|
511
|
-
const payload: unknown = await response.json();
|
|
512
|
-
return parseProfile(payload);
|
|
513
|
-
},
|
|
514
|
-
cacheStrategy: createInvalidateResourceScopeCacheStrategy()
|
|
515
|
-
});
|
|
516
|
-
```
|
|
517
|
-
|
|
518
|
-
На практике `typeof profileResource` в mutation operation часто неудобен из-за порядка объявления. В таком случае можно:
|
|
519
|
-
|
|
520
|
-
- объявить mutation после descriptor через отдельный factory;
|
|
521
|
-
- использовать `ResourceDescriptor<Scope, Queries, Mutations>` как именованный тип;
|
|
522
|
-
- передать cache strategy на уровне `useResourceMutation`.
|
|
523
|
-
|
|
524
|
-
### Mutation hook
|
|
525
|
-
|
|
526
|
-
```ts
|
|
527
|
-
import { useResourceMutation } from "@/shared/api/resource";
|
|
528
|
-
|
|
529
|
-
export function useSaveProfileMutation(scope: ProfileScope) {
|
|
530
|
-
return useResourceMutation(profileResource, "save", scope);
|
|
531
|
-
}
|
|
532
|
-
```
|
|
533
|
-
|
|
534
|
-
### Cache strategies
|
|
535
|
-
|
|
536
|
-
Доступны:
|
|
537
|
-
|
|
538
|
-
```ts
|
|
539
|
-
createInvalidateResourceScopeCacheStrategy;
|
|
540
|
-
createSetResourceQueryDataCacheStrategy;
|
|
541
|
-
composeResourceCacheStrategies;
|
|
542
|
-
applyResourceCacheStrategy;
|
|
543
|
-
```
|
|
544
|
-
|
|
545
|
-
Инвалидация всего scope:
|
|
546
|
-
|
|
547
|
-
```ts
|
|
548
|
-
cacheStrategy: createInvalidateResourceScopeCacheStrategy();
|
|
549
|
-
```
|
|
550
|
-
|
|
551
|
-
Точечное обновление query:
|
|
552
|
-
|
|
553
|
-
```ts
|
|
554
|
-
createSetResourceQueryDataCacheStrategy({
|
|
555
|
-
getQueryKey: ({ descriptor, scope }) => descriptor.keys.operation("detail", scope, undefined),
|
|
556
|
-
update: (_current, { result }) => result
|
|
557
|
-
});
|
|
558
|
-
```
|
|
559
|
-
|
|
560
|
-
Композиция:
|
|
561
|
-
|
|
562
|
-
```ts
|
|
563
|
-
composeResourceCacheStrategies(
|
|
564
|
-
createSetResourceQueryDataCacheStrategy(...),
|
|
565
|
-
createInvalidateResourceScopeCacheStrategy()
|
|
566
|
-
);
|
|
567
|
-
```
|
|
568
|
-
|
|
569
|
-
## `shared/api/server-fn`
|
|
570
|
-
|
|
571
|
-
`server-fn` адаптирует TanStack Start server functions к operation contract из `resource`.
|
|
572
|
-
|
|
573
|
-
Слой специально не импортирует `@tanstack/react-start`.
|
|
574
|
-
|
|
575
|
-
Он знает только переносимую форму:
|
|
576
|
-
|
|
577
|
-
```ts
|
|
578
|
-
type ServerFnTransport<TData, TResponse> = (request: { readonly data: TData }) => Promise<TResponse>;
|
|
579
|
-
```
|
|
580
|
-
|
|
581
|
-
### Query operation
|
|
582
|
-
|
|
583
|
-
```ts
|
|
584
|
-
import { createResourceDescriptor, useResourceQuery } from "@/shared/api/resource";
|
|
585
|
-
import { createServerFnQueryOperation } from "@/shared/api/server-fn";
|
|
586
|
-
|
|
587
|
-
type Scope = {
|
|
588
|
-
readonly userId: string;
|
|
589
|
-
};
|
|
590
|
-
|
|
591
|
-
type Profile = {
|
|
592
|
-
readonly id: string;
|
|
593
|
-
readonly name: string;
|
|
594
|
-
};
|
|
595
|
-
|
|
596
|
-
const profileResource = createResourceDescriptor({
|
|
597
|
-
namespace: "profile",
|
|
598
|
-
resource: "user",
|
|
599
|
-
operations: {
|
|
600
|
-
queries: {
|
|
601
|
-
detail: createServerFnQueryOperation<Scope, void, Scope, Profile>({
|
|
602
|
-
serverFn: getProfileServerFn,
|
|
603
|
-
buildData: (scope) => scope,
|
|
604
|
-
staleTime: 1000 * 60
|
|
605
|
-
})
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
});
|
|
609
|
-
|
|
610
|
-
export function useProfileQuery(scope: Scope) {
|
|
611
|
-
return useResourceQuery(profileResource, "detail", scope, undefined);
|
|
612
|
-
}
|
|
613
|
-
```
|
|
614
|
-
|
|
615
|
-
### Mutation operation
|
|
616
|
-
|
|
617
|
-
```ts
|
|
618
|
-
import { createServerFnMutationOperation } from "@/shared/api/server-fn";
|
|
619
|
-
|
|
620
|
-
type SaveProfileInput = {
|
|
621
|
-
readonly name: string;
|
|
622
|
-
};
|
|
623
|
-
|
|
624
|
-
type SaveProfileData = Scope & SaveProfileInput;
|
|
625
|
-
|
|
626
|
-
const saveProfileOperation = createServerFnMutationOperation<Scope, SaveProfileInput, SaveProfileData, Profile, typeof profileResource>({
|
|
627
|
-
serverFn: saveProfileServerFn,
|
|
628
|
-
buildData: (scope, input) => ({
|
|
629
|
-
...scope,
|
|
630
|
-
name: input.name
|
|
631
|
-
})
|
|
632
|
-
});
|
|
633
|
-
```
|
|
634
|
-
|
|
635
|
-
### `transform`
|
|
636
|
-
|
|
637
|
-
Если serverFn возвращает DTO, а resource должен отдавать доменную модель, использовать `transform`:
|
|
638
|
-
|
|
639
|
-
```ts
|
|
640
|
-
createServerFnQueryOperation<Scope, void, Scope, RawProfile, Profile>({
|
|
641
|
-
serverFn: getProfileServerFn,
|
|
642
|
-
buildData: (scope) => scope,
|
|
643
|
-
transform: (raw) => ({
|
|
644
|
-
id: raw.ID,
|
|
645
|
-
name: raw.NAME
|
|
646
|
-
})
|
|
647
|
-
});
|
|
648
|
-
```
|
|
649
|
-
|
|
650
|
-
### `executor`
|
|
651
|
-
|
|
652
|
-
`executor` нужен редко:
|
|
653
|
-
|
|
654
|
-
- тесты;
|
|
655
|
-
- tracing;
|
|
656
|
-
- retry wrapper;
|
|
657
|
-
- дополнительная интеграционная обвязка конкретного проекта.
|
|
658
|
-
|
|
659
|
-
```ts
|
|
660
|
-
createServerFnQueryOperation({
|
|
661
|
-
serverFn,
|
|
662
|
-
buildData,
|
|
663
|
-
executor: async (currentServerFn, request, context) => {
|
|
664
|
-
console.debug("serverFn", context.client);
|
|
665
|
-
return await currentServerFn(request);
|
|
666
|
-
}
|
|
667
|
-
});
|
|
668
|
-
```
|
|
669
|
-
|
|
670
|
-
## `shared/api/persisted`
|
|
671
|
-
|
|
672
|
-
`persisted` — специализированный фасад для ресурсов сохранённых записей.
|
|
673
|
-
|
|
674
|
-
Использовать, если ресурс действительно описывается capability:
|
|
675
|
-
|
|
676
|
-
```ts
|
|
677
|
-
list
|
|
678
|
-
latest
|
|
679
|
-
history
|
|
680
|
-
save
|
|
681
|
-
create
|
|
682
|
-
delete
|
|
683
|
-
```
|
|
684
|
-
|
|
685
|
-
Если нужны операции вроде `publish`, `archive`, `clone`, `recalculate`, `preview`, `restore`, лучше использовать `shared/api/resource`.
|
|
686
|
-
|
|
687
|
-
### Что делает `persisted`
|
|
688
|
-
|
|
689
|
-
- создаёт стандартные query keys;
|
|
690
|
-
- нормализует scope через `resource` key normalization;
|
|
691
|
-
- предоставляет hooks под фиксированные capability;
|
|
692
|
-
- содержит JSON payload helpers;
|
|
693
|
-
- содержит OData и REST operation adapters;
|
|
694
|
-
- применяет cache strategies после mutation.
|
|
695
|
-
|
|
696
|
-
### Descriptor
|
|
697
|
-
|
|
698
|
-
```ts
|
|
699
|
-
import { createPersistedResourceDescriptor, usePersistedLatestQuery, usePersistedSaveMutation } from "@/shared/api/persisted";
|
|
700
|
-
|
|
701
|
-
type ViewConfigScope = {
|
|
702
|
-
readonly appId: string;
|
|
703
|
-
readonly viewId: string;
|
|
704
|
-
};
|
|
705
|
-
|
|
706
|
-
const viewConfigResource = createPersistedResourceDescriptor({
|
|
707
|
-
namespace: "viewConfig",
|
|
708
|
-
resource: "view",
|
|
709
|
-
normalizeScope: (scope: ViewConfigScope | null | undefined) => ({
|
|
710
|
-
appId: scope?.appId.trim() ?? "",
|
|
711
|
-
viewId: scope?.viewId.trim() ?? ""
|
|
712
|
-
}),
|
|
713
|
-
isEnabled: (scope) => Boolean(scope?.appId && scope?.viewId),
|
|
714
|
-
getScopeError: () => "Не задан scope конфигурации.",
|
|
715
|
-
transport: {
|
|
716
|
-
latest: latestOperation,
|
|
717
|
-
save: saveOperation
|
|
718
|
-
}
|
|
719
|
-
});
|
|
720
|
-
|
|
721
|
-
export function useViewConfigLatestQuery(scope: ViewConfigScope) {
|
|
722
|
-
return usePersistedLatestQuery(viewConfigResource, scope);
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
export function useSaveViewConfigMutation(scope: ViewConfigScope) {
|
|
726
|
-
return usePersistedSaveMutation(viewConfigResource, scope);
|
|
727
|
-
}
|
|
728
|
-
```
|
|
729
|
-
|
|
730
|
-
### OData persisted operation
|
|
731
|
-
|
|
732
|
-
```ts
|
|
733
|
-
import {
|
|
734
|
-
createInvalidatePersistedScopeCacheStrategy,
|
|
735
|
-
createPersistedODataMutationOperation,
|
|
736
|
-
createPersistedODataQueryOperation,
|
|
737
|
-
parsePersistedJson,
|
|
738
|
-
stringifyPersistedJson
|
|
739
|
-
} from "@/shared/api/persisted";
|
|
740
|
-
import { createFilterEqual } from "@ryuzaki13/react-foundation-lib/odata-service";
|
|
741
|
-
|
|
742
|
-
type ViewConfigRaw = {
|
|
743
|
-
readonly APP_ID: string;
|
|
744
|
-
readonly VIEW_ID: string;
|
|
745
|
-
readonly PAYLOAD: string | null;
|
|
746
|
-
};
|
|
747
|
-
|
|
748
|
-
type ViewConfigPayload = {
|
|
749
|
-
readonly columns: readonly string[];
|
|
750
|
-
};
|
|
751
|
-
|
|
752
|
-
const latestOperation = createPersistedODataQueryOperation<ViewConfigScope, void, readonly ViewConfigRaw[], ViewConfigPayload | null>({
|
|
753
|
-
odata: {
|
|
754
|
-
service: "TEXT_CONFIG_SRV",
|
|
755
|
-
target: "TextConfigLatestSet"
|
|
756
|
-
},
|
|
757
|
-
buildOptions: (scope) => ({
|
|
758
|
-
expression: {
|
|
759
|
-
filters: [createFilterEqual("APP_ID", scope.appId), createFilterEqual("VIEW_ID", scope.viewId)]
|
|
760
|
-
}
|
|
761
|
-
}),
|
|
762
|
-
transform: (rows) => parsePersistedJson<ViewConfigPayload>(rows[0]?.PAYLOAD)
|
|
763
|
-
});
|
|
764
|
-
|
|
765
|
-
const saveOperation = createPersistedODataMutationOperation<ViewConfigScope, { readonly payload: ViewConfigPayload }, unknown, unknown>({
|
|
766
|
-
odata: {
|
|
767
|
-
service: "TEXT_CONFIG_SRV",
|
|
768
|
-
target: "TextConfigSet"
|
|
769
|
-
},
|
|
770
|
-
method: "create",
|
|
771
|
-
bodyMapper: (scope, input) => ({
|
|
772
|
-
APP_ID: scope.appId,
|
|
773
|
-
VIEW_ID: scope.viewId,
|
|
774
|
-
PAYLOAD: stringifyPersistedJson(input.payload)
|
|
775
|
-
}),
|
|
776
|
-
cacheStrategy: createInvalidatePersistedScopeCacheStrategy()
|
|
777
|
-
});
|
|
778
|
-
```
|
|
779
|
-
|
|
780
|
-
### REST persisted operation
|
|
781
|
-
|
|
782
|
-
REST operation использует `shared/api/http`, поэтому без `executor` нужно обязательно передать `parseResponse`.
|
|
783
|
-
|
|
784
|
-
```ts
|
|
785
|
-
import { createPersistedRestQueryOperation, createPersistedRestMutationOperation } from "@/shared/api/persisted";
|
|
786
|
-
|
|
787
|
-
type Preset = {
|
|
788
|
-
readonly id: string;
|
|
789
|
-
readonly title: string;
|
|
790
|
-
};
|
|
791
|
-
|
|
792
|
-
function parsePresetList(payload: unknown): readonly Preset[] {
|
|
793
|
-
if (!Array.isArray(payload)) {
|
|
794
|
-
throw new Error("Некорректный список preset.");
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
return payload.map((item) => {
|
|
798
|
-
if (typeof item !== "object" || item === null || !("id" in item) || !("title" in item)) {
|
|
799
|
-
throw new Error("Некорректный preset.");
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
if (typeof item.id !== "string" || typeof item.title !== "string") {
|
|
803
|
-
throw new Error("Некорректный preset.");
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
return {
|
|
807
|
-
id: item.id,
|
|
808
|
-
title: item.title
|
|
809
|
-
};
|
|
810
|
-
});
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
const listOperation = createPersistedRestQueryOperation<ViewConfigScope, void, readonly Preset[]>({
|
|
814
|
-
baseUrl: "",
|
|
815
|
-
buildUrl: (scope) => `/api/views/${scope.appId}/${scope.viewId}/presets`,
|
|
816
|
-
parseResponse: parsePresetList
|
|
817
|
-
});
|
|
818
|
-
```
|
|
819
|
-
|
|
820
|
-
Если используется `executor`, он сам отвечает за тип результата:
|
|
821
|
-
|
|
822
|
-
```ts
|
|
823
|
-
const listOperation = createPersistedRestQueryOperation<ViewConfigScope, void, readonly Preset[]>({
|
|
824
|
-
buildUrl: (scope) => `/api/views/${scope.appId}/${scope.viewId}/presets`,
|
|
825
|
-
executor: async (request) => {
|
|
826
|
-
const payload = await customHttpClient(request.url, request.init);
|
|
827
|
-
return parsePresetList(payload);
|
|
828
|
-
}
|
|
829
|
-
});
|
|
830
|
-
```
|
|
831
|
-
|
|
832
|
-
### serverFn внутри persisted descriptor
|
|
833
|
-
|
|
834
|
-
serverFn operation берётся из `shared/api/server-fn`, а descriptor остаётся `persisted`.
|
|
835
|
-
|
|
836
|
-
```ts
|
|
837
|
-
import { createPersistedResourceDescriptor, usePersistedLatestQuery } from "@/shared/api/persisted";
|
|
838
|
-
import { createServerFnQueryOperation } from "@/shared/api/server-fn";
|
|
839
|
-
|
|
840
|
-
const viewConfigResource = createPersistedResourceDescriptor({
|
|
841
|
-
namespace: "viewConfig",
|
|
842
|
-
resource: "view",
|
|
843
|
-
transport: {
|
|
844
|
-
latest: createServerFnQueryOperation<ViewConfigScope, void, ViewConfigScope, ViewConfigPayload | null>({
|
|
845
|
-
serverFn: getViewConfigServerFn,
|
|
846
|
-
buildData: (scope) => scope
|
|
847
|
-
})
|
|
848
|
-
}
|
|
849
|
-
});
|
|
850
|
-
|
|
851
|
-
export function useLatest(scope: ViewConfigScope) {
|
|
852
|
-
return usePersistedLatestQuery(viewConfigResource, scope);
|
|
853
|
-
}
|
|
854
|
-
```
|
|
855
|
-
|
|
856
|
-
## Что не делать
|
|
857
|
-
|
|
858
|
-
### Не использовать OData transport как generic fetch
|
|
859
|
-
|
|
860
|
-
```ts
|
|
861
|
-
// Плохо: обычный REST endpoint идёт через OData/SAP transport.
|
|
862
|
-
fetchJson<Profile>("/api/profile", undefined, "");
|
|
863
|
-
```
|
|
864
|
-
|
|
865
|
-
Лучше:
|
|
866
|
-
|
|
867
|
-
```ts
|
|
868
|
-
httpJsonQueryFn("/api/profile", {
|
|
869
|
-
parse: parseProfile
|
|
870
|
-
});
|
|
871
|
-
```
|
|
872
|
-
|
|
873
|
-
### Не расширять `persisted` новыми произвольными capability
|
|
874
|
-
|
|
875
|
-
```ts
|
|
876
|
-
// Плохо: persisted начинает превращаться в универсальный resource.
|
|
877
|
-
transport: {
|
|
878
|
-
publish: ...
|
|
879
|
-
}
|
|
880
|
-
```
|
|
881
|
-
|
|
882
|
-
Лучше использовать `resource`:
|
|
883
|
-
|
|
884
|
-
```ts
|
|
885
|
-
operations: {
|
|
886
|
-
mutations: {
|
|
887
|
-
publish: createResourceMutationOperation(...)
|
|
888
|
-
}
|
|
889
|
-
}
|
|
890
|
-
```
|
|
891
|
-
|
|
892
|
-
### Не импортировать TanStack Start в shared
|
|
893
|
-
|
|
894
|
-
```ts
|
|
895
|
-
// Плохо: shared начнёт зависеть от SSR runtime.
|
|
896
|
-
import { createServerFn } from "@tanstack/react-start";
|
|
897
|
-
```
|
|
898
|
-
|
|
899
|
-
В shared передаётся уже созданная serverFn через `ServerFnTransport`.
|
|
900
|
-
|
|
901
|
-
### Не приводить внешний payload без parser-а
|
|
902
|
-
|
|
903
|
-
```ts
|
|
904
|
-
// Плохо: внешний контракт не проверяется.
|
|
905
|
-
const payload = await httpFetchPayload("/api/profile");
|
|
906
|
-
return payload as Profile;
|
|
907
|
-
```
|
|
908
|
-
|
|
909
|
-
Лучше:
|
|
910
|
-
|
|
911
|
-
```ts
|
|
912
|
-
const payload = await httpFetchPayload("/api/profile");
|
|
913
|
-
return parseProfile(payload);
|
|
914
|
-
```
|
|
915
|
-
|
|
916
|
-
## Ответ на частый вопрос: почему `odata/transport` не использует `http`
|
|
917
|
-
|
|
918
|
-
Это сделано намеренно в текущей версии слоя.
|
|
919
|
-
|
|
920
|
-
`shared/api/http` — минимальный HTTP transport для обычных endpoint-ов. Он умеет получить `Response` или payload, но не знает ничего про SAP/OData.
|
|
921
|
-
|
|
922
|
-
`shared/api/odata/transport` — stateful transport для SAP Gateway. Он дополнительно отвечает за:
|
|
923
|
-
|
|
924
|
-
- выбор OData base URL;
|
|
925
|
-
- SAP client;
|
|
926
|
-
- X-CSRF token cache;
|
|
927
|
-
- повторное получение CSRF token;
|
|
928
|
-
- обнаружение HTML/SAML2 формы вместо JSON;
|
|
929
|
-
- восстановление SSO-сессии;
|
|
930
|
-
- `SsoRequiredError`;
|
|
931
|
-
- OData envelope `{ d, results, __count }`;
|
|
932
|
-
- report unexpected HTML response;
|
|
933
|
-
- поддержку `x-sw-cache` в OData-запросах.
|
|
934
|
-
|
|
935
|
-
Если заставить OData transport использовать текущий `httpFetchPayload`, он потеряет доступ к части lifecycle на уровне `Response` и ошибочно смешает generic HTTP error policy с SAP/OData policy.
|
|
936
|
-
|
|
937
|
-
Теоретически можно выделить ещё более низкий primitive, например `httpFetchRaw`, который только вызывает `fetch` и не парсит payload. Но это отдельный рефакторинг. Пока разделение намеренное: `http` для neutral HTTP, `odata/transport` для SAP/OData.
|