@aiao/rxdb-adapter-http 0.0.26

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 (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +269 -0
  3. package/dist/RxDBAdapterHttp.d.ts +251 -0
  4. package/dist/RxDBAdapterHttp.d.ts.map +1 -0
  5. package/dist/change-feed.d.ts +66 -0
  6. package/dist/change-feed.d.ts.map +1 -0
  7. package/dist/chunking.d.ts +29 -0
  8. package/dist/chunking.d.ts.map +1 -0
  9. package/dist/conditional-cache.d.ts +175 -0
  10. package/dist/conditional-cache.d.ts.map +1 -0
  11. package/dist/config.d.ts +30 -0
  12. package/dist/config.d.ts.map +1 -0
  13. package/dist/errors.d.ts +215 -0
  14. package/dist/errors.d.ts.map +1 -0
  15. package/dist/handler-contract.d.ts +41 -0
  16. package/dist/handler-contract.d.ts.map +1 -0
  17. package/dist/http.interface.d.ts +519 -0
  18. package/dist/http.interface.d.ts.map +1 -0
  19. package/dist/index.d.ts +25 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +864 -0
  22. package/dist/metadata.d.ts +15 -0
  23. package/dist/metadata.d.ts.map +1 -0
  24. package/dist/pagination.d.ts +30 -0
  25. package/dist/pagination.d.ts.map +1 -0
  26. package/dist/rest.d.ts +94 -0
  27. package/dist/rest.d.ts.map +1 -0
  28. package/dist/transport.d.ts +174 -0
  29. package/dist/transport.d.ts.map +1 -0
  30. package/package.json +64 -0
  31. package/src/RxDBAdapterHttp.ts +786 -0
  32. package/src/change-feed.ts +470 -0
  33. package/src/chunking.ts +68 -0
  34. package/src/conditional-cache.ts +271 -0
  35. package/src/config.ts +83 -0
  36. package/src/errors.ts +295 -0
  37. package/src/handler-contract.ts +77 -0
  38. package/src/http.interface.ts +554 -0
  39. package/src/index.ts +25 -0
  40. package/src/metadata.ts +94 -0
  41. package/src/pagination.ts +184 -0
  42. package/src/rest.ts +476 -0
  43. package/src/transport.ts +699 -0
package/src/rest.ts ADDED
@@ -0,0 +1,476 @@
1
+ /**
2
+ * @packageDocumentation
3
+ * REST resource URL 模板(US-212 阶段 B AC#27)。
4
+ *
5
+ * @remarks
6
+ * 阶段 A 的 handler 是**纯协议 mapping**,但绝大多数 REST 后端的 mapping 长得一模一样:
7
+ * 资源路径 + 固定 body 形状。让每个接入方手抄一遍,抄错的地方不会当场报错——一个漏了
8
+ * `:id` 的 `PATCH` 模板会打到集合资源上,一个把 `updatedAt` 解错的 parse 会静默返回错行。
9
+ * 本模块把那份样板收成一个工厂,**只替换 handler,不改适配器**:产出的仍是
10
+ * {@link HttpHandlers},阶段 A 的翻页、分块、发射契约、错误分类一条都不动
11
+ * (「缺省不得改变阶段 A 语义」)。
12
+ *
13
+ * **模板校验在构造期,不在请求期。** AC#27 要的是「模板解析失败 fail-fast,不发错 URL」,
14
+ * 而错 URL 的危险形态恰恰不会报错:`PATCH :entity` 少个 `:id` 会更新整个集合,
15
+ * `:entity` 缺席会让所有实体共用一个 URL。等到发出去再看响应码,那两条都可能是 200。
16
+ */
17
+
18
+ import { HttpConfigError, HttpHandlerContractError, HttpRequestBuildError } from './errors.js';
19
+ import type {
20
+ CreateContext,
21
+ FetchMetadataResult,
22
+ HttpHandlers,
23
+ HttpMethod,
24
+ HttpRequestSpec,
25
+ UpdateContext
26
+ } from './http.interface.js';
27
+
28
+ /** 模板支持的占位符,**只有这两个**。 */
29
+ export type RestPlaceholder = 'entity' | 'id';
30
+
31
+ /** 可用模板表达的操作,与 {@link HttpHandlers} 的七个字段一一对应。 */
32
+ export type RestOperation =
33
+ 'fetchMetadata' | 'findByIds' | 'create' | 'update' | 'delete' | 'version' | 'isTableExisted';
34
+
35
+ /** 单个操作的路径模板与方法覆盖。 */
36
+ export interface RestOperationTemplate {
37
+ /** 相对 `baseUrl` 的路径模板(也可给绝对 URL),占位符写作 `:entity` / `:id` */
38
+ path: string;
39
+ /** 缺省取该操作的默认方法,见 {@link createRestHandlers} */
40
+ method?: HttpMethod;
41
+ }
42
+
43
+ /** {@link createRestHandlers} 的配置。 */
44
+ export interface RestHandlersOptions {
45
+ /**
46
+ * 实体名 → 资源路径片段,例如 `{ Recipe: 'recipes' }`。
47
+ *
48
+ * @remarks
49
+ * 值**可以含 `/`**(`v1/recipes` 合法):它来自配置,是开发者写下的常量,不是数据。
50
+ * 与之相对,`:id` 的取值来自远端行,必须 `encodeURIComponent` —— 一个含 `/` 的 id
51
+ * 不做转义就会拼出另一个资源的地址。两者的来源不同,处理方式因此不同。
52
+ *
53
+ * 未列出的实体直接用实体名本身作路径片段。
54
+ */
55
+ resources?: Record<string, string>;
56
+ /**
57
+ * 逐操作覆盖路径与方法。
58
+ *
59
+ * @remarks
60
+ * 三种取值语义不同:**缺省** = 用默认模板(`version` / `isTableExisted` 的默认是「不产出」);
61
+ * **给对象** = 用它;**给 `null`** = 不产出该 handler。
62
+ *
63
+ * `null` 是有意义的一档而不是冗余写法:写 duck 的「不支持」在 core 侧表现为**属性缺席**
64
+ * (`QueryCacheEngine` 用 `if (!this.remoteAdapter.create)` 特性探测),
65
+ * 只读后端必须能把 `create` 关掉,否则 `repo.create()` 会发出一个注定 405 的请求,
66
+ * 而不是 AC#4 那句清晰的「Remote adapter does not support create」。
67
+ *
68
+ * `fetchMetadata` / `findByIds` 不可关:它们是 `RxDBAdapterRemoteBase` 两个 abstract
69
+ * 成员的落地点,关掉整条 QueryCache 读路径都不成立。
70
+ */
71
+ templates?: { [K in RestOperation]?: RestOperationTemplate | null };
72
+ }
73
+
74
+ /** 每个操作的默认模板与占位符契约 */
75
+ interface RestOperationSpec {
76
+ method: HttpMethod;
77
+ /**
78
+ * 默认路径;`undefined` = 默认不产出该 handler。
79
+ *
80
+ * @remarks
81
+ * `version` / `isTableExisted` 默认不产出,其余五个产出——判据是「REST 有没有公认形状」。
82
+ * 集合上的 `POST`、单项上的 `PATCH` 是公认的;`/version` 与探测端点不是,
83
+ * 给它们猜一个默认值等于替接入方发明一个不存在的端点,
84
+ * 而 `version()` 未配 handler 时抛 unsupported 本就是阶段 A 定好的诚实行为。
85
+ */
86
+ path?: string;
87
+ /**
88
+ * 模板中**必须恰好出现**的占位符集合(多一个少一个都抛)。
89
+ *
90
+ * @remarks
91
+ * 「恰好」不是洁癖:少 `:entity` 会让所有实体共用一个 URL,少 `:id` 会让 `PATCH`
92
+ * 打到集合上,多一个 `:id` 会在没有 id 的上下文里渲染出字面量 `:id`。三种都发得出去、
93
+ * 都可能拿到 2xx,靠观察响应发现不了。
94
+ */
95
+ placeholders: readonly RestPlaceholder[];
96
+ /** 能否用 `null` 关掉 */
97
+ closable: boolean;
98
+ /**
99
+ * 本操作产出的 handler 是否**恒带**请求体。
100
+ *
101
+ * @remarks
102
+ * 带 body 的操作不能被覆盖成 `GET` / `HEAD`:`fetch` 对这一组抛裸 `TypeError`,
103
+ * 与传输失败同型,最终会以「离线」的面目出现并降级到陈旧缓存。transport 侧已经拦了
104
+ * (`HttpRequestBuildError`,reason `method`),这里再拦一次是为了把它提到**构造期**——
105
+ * 本模块存在的理由就是「模板错了要 fail-fast,别等发出去看响应」,而这一条恰好是
106
+ * 那种发不出去、却只会以查询结果变旧的形式显形的错。
107
+ */
108
+ carriesBody: boolean;
109
+ }
110
+
111
+ const ENTITY_ONLY: readonly RestPlaceholder[] = ['entity'];
112
+
113
+ /**
114
+ * 七个操作的默认模板。
115
+ *
116
+ * @remarks
117
+ * `delete` 用 `POST :entity/delete` 而不是 `DELETE :entity` + `{ ids }` body,
118
+ * 是本模块唯一一处刻意偏离 REST 直觉的地方:批量删除必须把 id 放在 body 里,
119
+ * 而 DELETE 的请求体被代理、网关和不少服务端框架直接丢弃——那样一条
120
+ * 「删这 3 行」的请求会以「`DELETE /recipes`」的面目到达服务端。删错的代价不对称,
121
+ * 所以这里选一条丑但不会被中间层改写语义的路。需要真 `DELETE` 的接入方显式覆盖即可。
122
+ */
123
+ const REST_OPERATIONS: Readonly<Record<RestOperation, RestOperationSpec>> = {
124
+ fetchMetadata: {
125
+ method: 'POST',
126
+ path: ':entity/metadata',
127
+ placeholders: ENTITY_ONLY,
128
+ closable: false,
129
+ carriesBody: true
130
+ },
131
+ findByIds: { method: 'POST', path: ':entity/by-ids', placeholders: ENTITY_ONLY, closable: false, carriesBody: true },
132
+ create: { method: 'POST', path: ':entity', placeholders: ENTITY_ONLY, closable: true, carriesBody: true },
133
+ update: { method: 'PATCH', path: ':entity/:id', placeholders: ['entity', 'id'], closable: true, carriesBody: true },
134
+ delete: { method: 'POST', path: ':entity/delete', placeholders: ENTITY_ONLY, closable: true, carriesBody: true },
135
+ version: { method: 'GET', placeholders: [], closable: true, carriesBody: false },
136
+ // 不给默认路径:缺省时适配器复用 `onFetchMetadata` 的 `limit: 1` 探测,那是一个
137
+ // **确定存在**的端点。默认发 `HEAD :entity` 则是替接入方假设集合资源支持 HEAD——
138
+ // 不支持的后端会回 405,而 405 既不是 2xx 也不是 404,正好落进「抛错」那一支,
139
+ // 把一次能答上来的探测变成故障
140
+ isTableExisted: { method: 'HEAD', placeholders: ENTITY_ONLY, closable: true, carriesBody: false }
141
+ };
142
+
143
+ const HTTP_METHODS = new Set<string>(['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE']);
144
+
145
+ /** 按 fetch 规范不得携带请求体的方法,与 `transport.ts` 的 `BODYLESS_METHODS` 同源 */
146
+ const BODYLESS_METHODS = new Set<string>(['GET', 'HEAD']);
147
+
148
+ /**
149
+ * 占位符词法:`:` 后跟字母开头的标识符。
150
+ *
151
+ * @remarks
152
+ * 首字符限定为字母是为了放过端口号——`https://api.example.com:8080/:entity` 里的 `:8080`
153
+ * 不该被读成一个名叫 `8080` 的未知占位符而拒绝掉整个模板。
154
+ */
155
+ const PLACEHOLDER_PATTERN = /:([a-zA-Z][a-zA-Z0-9_]*)/g;
156
+
157
+ /** 路径片段里出现就会改变 URL 结构的字符 */
158
+ const UNSAFE_IN_SEGMENT = /[\s?#]/;
159
+
160
+ /** `version` 的 parse 出错时没有实体名可报,用它占位 */
161
+ const VERSION_SCOPE = '(server version)';
162
+
163
+ /**
164
+ * 「普通对象」判定,**数组不算**。
165
+ *
166
+ * @remarks
167
+ * `onVersion` 的 `{ version }` 形态判定要用它。数组不算对象这一条留在这里
168
+ * 与 `handler-contract.ts` 同源:`['1.0.0']` 不是一个版本回执。
169
+ */
170
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
171
+ typeof value === 'object' && value !== null && !Array.isArray(value);
172
+
173
+ const readPlaceholders = (path: string): string[] => [...path.matchAll(PLACEHOLDER_PATTERN)].map(match => match[1]);
174
+
175
+ /**
176
+ * 校验单个模板:路径形态、方法、占位符集合。
177
+ *
178
+ * @throws HttpConfigError 任一条不满足,错误信息含操作名与实际值
179
+ */
180
+ const assertTemplate = (operation: RestOperation, template: RestOperationTemplate, spec: RestOperationSpec): void => {
181
+ const field = `templates.${operation}`;
182
+ if (typeof template.path !== 'string' || template.path.trim().length === 0) {
183
+ throw new HttpConfigError(
184
+ `REST template "${field}.path" must be a non-empty string, received ${JSON.stringify(template.path)}`,
185
+ `${field}.path`,
186
+ template.path
187
+ );
188
+ }
189
+ if (UNSAFE_IN_SEGMENT.test(template.path)) {
190
+ throw new HttpConfigError(
191
+ `REST template "${field}.path" must not contain whitespace, "?" or "#", received "${template.path}"`,
192
+ `${field}.path`,
193
+ template.path
194
+ );
195
+ }
196
+ if (template.method !== undefined && !HTTP_METHODS.has(template.method)) {
197
+ throw new HttpConfigError(
198
+ `REST template "${field}.method" must be one of ${[...HTTP_METHODS].join(' / ')}, received ${JSON.stringify(template.method)}`,
199
+ `${field}.method`,
200
+ template.method
201
+ );
202
+ }
203
+ if (spec.carriesBody && template.method !== undefined && BODYLESS_METHODS.has(template.method)) {
204
+ throw new HttpConfigError(
205
+ `REST template "${field}.method" cannot be "${template.method}": the "${operation}" handler always sends a request body, and ${template.method} requests cannot carry a body`,
206
+ `${field}.method`,
207
+ template.method
208
+ );
209
+ }
210
+ const found = new Set(readPlaceholders(template.path));
211
+ const expected = new Set<string>(spec.placeholders);
212
+ const missing = [...expected].filter(name => !found.has(name));
213
+ const unexpected = [...found].filter(name => !expected.has(name));
214
+ if (missing.length === 0 && unexpected.length === 0) {
215
+ return;
216
+ }
217
+ const detail = [
218
+ missing.length > 0 ? `missing ${missing.map(name => `":${name}"`).join(', ')}` : undefined,
219
+ unexpected.length > 0 ? `unexpected ${unexpected.map(name => `":${name}"`).join(', ')}` : undefined
220
+ ]
221
+ .filter(Boolean)
222
+ .join('; ');
223
+ throw new HttpConfigError(
224
+ `REST template "${field}.path" must use exactly ${expected.size === 0 ? 'no placeholder' : [...expected].map(name => `":${name}"`).join(' and ')} — ${detail} in "${template.path}"`,
225
+ `${field}.path`,
226
+ template.path
227
+ );
228
+ };
229
+
230
+ /** 合并默认模板与用户覆盖,`null` 即关闭 */
231
+ const resolveTemplates = (
232
+ overrides: RestHandlersOptions['templates']
233
+ ): Partial<Record<RestOperation, Required<RestOperationTemplate>>> => {
234
+ const resolved: Partial<Record<RestOperation, Required<RestOperationTemplate>>> = {};
235
+ for (const operation of Object.keys(REST_OPERATIONS) as RestOperation[]) {
236
+ const spec = REST_OPERATIONS[operation];
237
+ const override = overrides?.[operation];
238
+ if (override === null) {
239
+ if (!spec.closable) {
240
+ throw new HttpConfigError(
241
+ `REST template "templates.${operation}" cannot be disabled: it backs a required RxDBAdapterRemoteBase member`,
242
+ `templates.${operation}`,
243
+ override
244
+ );
245
+ }
246
+ continue;
247
+ }
248
+ const path = override?.path ?? spec.path;
249
+ if (path === undefined) {
250
+ // 默认不产出、用户也没给路径:保持缺席,让阶段 A 的「未配 handler」语义生效
251
+ continue;
252
+ }
253
+ const template = { path, method: override?.method ?? spec.method };
254
+ assertTemplate(operation, template, spec);
255
+ resolved[operation] = template;
256
+ }
257
+ return resolved;
258
+ };
259
+
260
+ /** 校验 `resources` 的每个值都能安全地拼进路径 */
261
+ const validateResources = (resources: RestHandlersOptions['resources']): Record<string, string> | undefined => {
262
+ for (const [entityName, segment] of Object.entries(resources ?? {})) {
263
+ if (typeof segment !== 'string' || segment.trim().length === 0 || UNSAFE_IN_SEGMENT.test(segment)) {
264
+ throw new HttpConfigError(
265
+ `REST resource mapping "resources.${entityName}" must be a non-empty path fragment without whitespace, "?" or "#", received ${JSON.stringify(segment)}`,
266
+ `resources.${entityName}`,
267
+ segment
268
+ );
269
+ }
270
+ }
271
+ return resources;
272
+ };
273
+
274
+ /**
275
+ * 取实体对应的路径片段。
276
+ *
277
+ * @remarks
278
+ * 未映射时直接用实体名,但仍要校验——实体名理论上可以带 `/`,那会悄悄拼出另一个资源的
279
+ * 地址。这条在请求期抛而不是构造期,是因为工厂拿不到实体清单:适配器服务的是
280
+ * `rxdb.config.entities` 里所有挂在 remote 槽位的实体,工厂只看得见配置。
281
+ */
282
+ const resolveEntitySegment = (resources: Record<string, string> | undefined, entityName: string): string => {
283
+ const mapped = resources?.[entityName];
284
+ if (mapped !== undefined) {
285
+ return mapped;
286
+ }
287
+ if (UNSAFE_IN_SEGMENT.test(entityName) || entityName.includes('/')) {
288
+ throw new HttpConfigError(
289
+ `REST templates cannot use entity name "${entityName}" as a path fragment; add an explicit "resources.${entityName}" mapping`,
290
+ `resources.${entityName}`,
291
+ undefined
292
+ );
293
+ }
294
+ return entityName;
295
+ };
296
+
297
+ /**
298
+ * 渲染 `:id`:来自数据,必须转义。
299
+ *
300
+ * @remarks
301
+ * 抛 {@link HttpRequestBuildError} 而不是 {@link HttpConfigError}:主键的形态是**数据**
302
+ * 问题,没有任何配置项改了能修好它——邻近的 {@link resolveEntitySegment} 相反,
303
+ * 它指的是「补一条 `resources` 映射」,那才是配置。
304
+ *
305
+ * 校验不能省:`encodeURIComponent` 对 `42` 给出 `'42'`、对 `{}` 给出
306
+ * `'%5Bobject%20Object%5D'`,都拼得出语法合法的 URL,于是不校验的代价是一个静默指向
307
+ * 错误资源的请求;空串更糟,`PUT /Recipe/` 在不少路由框架上就是集合端点。
308
+ */
309
+ const resolveIdSegment = (operation: RestOperation, entityName: string, id: unknown): string => {
310
+ if (typeof id !== 'string' || id.length === 0) {
311
+ throw new HttpRequestBuildError(
312
+ 'id',
313
+ operation,
314
+ `REST templates require a non-empty string id for "${entityName}", received ${JSON.stringify(id) ?? typeof id}`
315
+ );
316
+ }
317
+ return encodeURIComponent(id);
318
+ };
319
+
320
+ /**
321
+ * 把模板渲染成请求描述(body 由各 handler 自己叠加)。
322
+ *
323
+ * @remarks
324
+ * 取不到片段时**抛**而不是把 `:name` 原样留在 URL 里。`assertTemplate` 已经保证占位符集合
325
+ * 与操作一一对应,所以这条按理走不到;但一旦哪天走到了,留字面量就是发一个错 URL 出去,
326
+ * 而那正是本模块存在的理由。
327
+ */
328
+ const render = (
329
+ operation: RestOperation,
330
+ template: Required<RestOperationTemplate>,
331
+ segments: Partial<Record<RestPlaceholder, string>>
332
+ ): HttpRequestSpec => ({
333
+ url: template.path.replace(PLACEHOLDER_PATTERN, (_match, name: string) => {
334
+ const segment = segments[name as RestPlaceholder];
335
+ if (segment === undefined) {
336
+ throw new HttpConfigError(
337
+ `REST template "templates.${operation}.path" has no value for placeholder ":${name}"`,
338
+ `templates.${operation}.path`,
339
+ template.path
340
+ );
341
+ }
342
+ return segment;
343
+ }),
344
+ method: template.method
345
+ });
346
+
347
+ /**
348
+ * 用 REST 资源 URL 模板产出一整套 {@link HttpHandlers}(US-212 AC#27)。
349
+ *
350
+ * @remarks
351
+ * 产出的 handler 与手写的**完全等价**:同样只做协议 mapping,不碰网络——发请求、注入
352
+ * auth、提取 `status`、翻页、分块、单次发射仍全在适配器一侧。因此本模块拿不到、也不需要
353
+ * 改动阶段 A 的任何契约。
354
+ *
355
+ * 默认模板(方法 / 路径):
356
+ *
357
+ * | 操作 | 方法 | 路径 | 默认产出 |
358
+ * | ---------------- | ------- | ------------------- | -------- |
359
+ * | `fetchMetadata` | `POST` | `:entity/metadata` | 是(不可关) |
360
+ * | `findByIds` | `POST` | `:entity/by-ids` | 是(不可关) |
361
+ * | `create` | `POST` | `:entity` | 是 |
362
+ * | `update` | `PATCH` | `:entity/:id` | 是 |
363
+ * | `delete` | `POST` | `:entity/delete` | 是 |
364
+ * | `version` | `GET` | — | 否 |
365
+ * | `isTableExisted` | `HEAD` | — | 否 |
366
+ *
367
+ * 最后两行的路径栏是 `—` 而**不是**留空:这两个操作没有默认路径,不配
368
+ * `templates.version.path` / `templates.isTableExisted.path` 就整个不产出 handler。
369
+ * `isTableExisted` 的方法栏仍写 `HEAD`,那是给了路径之后才生效的默认方法
370
+ * (不给默认路径的理由见 `REST_OPERATIONS` 里那一条的注释)。
371
+ *
372
+ * 请求体形状:`fetchMetadata` 发 `{ where, offset, limit, pageToken }`(`where` 是 JSON
373
+ * `RuleGroup`,**不是 SQL**),`findByIds` 与 `delete` 发 `{ ids }`,
374
+ * `create` / `update` 直接发调用方给的数据。
375
+ *
376
+ * @example
377
+ * ```typescript
378
+ * rxdb.adapter('http', db => new RxDBAdapterHttp(db, {
379
+ * baseUrl: 'https://api.example.com/v1',
380
+ * handlers: createRestHandlers({
381
+ * resources: { Recipe: 'recipes' },
382
+ * templates: { version: { path: 'meta/version' }, delete: null }
383
+ * })
384
+ * }));
385
+ * // fetchMetadata → POST https://api.example.com/v1/recipes/metadata
386
+ * // update → PATCH https://api.example.com/v1/recipes/{id}
387
+ * // delete → 不产出,repo.delete() 走 AC#4 的 fail-fast
388
+ * ```
389
+ *
390
+ * @param options - 资源映射与逐操作的模板覆盖
391
+ * @returns 可直接交给 `RxDBAdapterHttp` 的 handler 集合
392
+ * @throws HttpConfigError 模板路径为空 / 含 `?`、`#`、空白 / 方法非法 / 占位符集合不匹配 /
393
+ * 关闭了必选 handler / `resources` 的值不是合法路径片段
394
+ */
395
+ export const createRestHandlers = (options: RestHandlersOptions = {}): HttpHandlers => {
396
+ const resources = validateResources(options.resources);
397
+ const templates = resolveTemplates(options.templates);
398
+ const entityRequest = (
399
+ operation: RestOperation,
400
+ template: Required<RestOperationTemplate>,
401
+ entityName: string
402
+ ): HttpRequestSpec => render(operation, template, { entity: resolveEntitySegment(resources, entityName) });
403
+
404
+ // 非空断言在这里是安全的:两个必选操作既有默认路径,又被 resolveTemplates 拒绝关闭
405
+ const fetchMetadata = templates.fetchMetadata!;
406
+ const findByIds = templates.findByIds!;
407
+
408
+ const handlers: HttpHandlers = {
409
+ onFetchMetadata: {
410
+ request: ctx => ({
411
+ ...entityRequest('fetchMetadata', fetchMetadata, ctx.entityName),
412
+ // pageToken 为 undefined 时 JSON.stringify 直接丢键,首页因此不会带上一个空 token
413
+ body: { where: ctx.where, offset: ctx.offset, limit: ctx.limit, pageToken: ctx.pageToken }
414
+ }),
415
+ // 两种翻页形态原样透给适配器:形态判别、换形态检测与 updatedAt 规范化都在那一侧,
416
+ // 在这里再判一次只会让同一条契约有两个执行点
417
+ parse: body => body as FetchMetadataResult
418
+ },
419
+ onFindByIds: {
420
+ request: ctx => ({ ...entityRequest('findByIds', findByIds, ctx.entityName), body: { ids: ctx.ids } }),
421
+ parse: body => body as unknown[]
422
+ }
423
+ };
424
+
425
+ const { create, update, delete: remove, version, isTableExisted } = templates;
426
+ if (create) {
427
+ handlers.onCreate = {
428
+ request: (ctx: CreateContext) => ({ ...entityRequest('create', create, ctx.entityName), body: ctx.data }),
429
+ // 形态校验在适配器的 create duck 出口统一做(手写 handler 也得过那一关),
430
+ // 这里再查一遍只会让同一个判据有两个负责人
431
+ parse: body => body
432
+ };
433
+ }
434
+ if (update) {
435
+ handlers.onUpdate = {
436
+ request: (ctx: UpdateContext) => ({
437
+ ...render('update', update, {
438
+ entity: resolveEntitySegment(resources, ctx.entityName),
439
+ id: resolveIdSegment('update', ctx.entityName, ctx.id)
440
+ }),
441
+ body: ctx.data
442
+ }),
443
+ parse: body => body
444
+ };
445
+ }
446
+ if (remove) {
447
+ handlers.onDelete = {
448
+ request: ctx => ({ ...entityRequest('delete', remove, ctx.entityName), body: { ids: ctx.ids } })
449
+ };
450
+ }
451
+ if (version) {
452
+ handlers.onVersion = {
453
+ request: () => render('version', version, {}),
454
+ parse: body => {
455
+ // 两种形态都收:裸 JSON 串与 `{ version }` 在真实后端上一样常见,
456
+ // 逼接入方为此写一个手写 handler 就把这个工厂的价值抵消了
457
+ if (typeof body === 'string') {
458
+ return body;
459
+ }
460
+ if (isRecord(body) && typeof body['version'] === 'string') {
461
+ return body['version'];
462
+ }
463
+ throw new HttpHandlerContractError(
464
+ 'version',
465
+ VERSION_SCOPE,
466
+ `expected a version string or { version: string }, received ${JSON.stringify(body)}`
467
+ );
468
+ }
469
+ };
470
+ }
471
+ if (isTableExisted) {
472
+ // 没有 parse:2xx / 404 / 其余的三分支判定要看状态码,而 handler 拿不到 Response
473
+ handlers.onIsTableExisted = { request: ctx => entityRequest('isTableExisted', isTableExisted, ctx.entityName) };
474
+ }
475
+ return handlers;
476
+ };