@zmdb/web 1.0.0-beta.1

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 (179) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +49 -0
  3. package/dist/app/bridge.d.ts +12 -0
  4. package/dist/app/bridge.d.ts.map +1 -0
  5. package/dist/app/bridge.js +16 -0
  6. package/dist/app/bridge.js.map +1 -0
  7. package/dist/app/index.d.ts +23 -0
  8. package/dist/app/index.d.ts.map +1 -0
  9. package/dist/app/index.js +47 -0
  10. package/dist/app/index.js.map +1 -0
  11. package/dist/bench/index.d.ts +68 -0
  12. package/dist/bench/index.d.ts.map +1 -0
  13. package/dist/bench/index.js +250 -0
  14. package/dist/bench/index.js.map +1 -0
  15. package/dist/compression/index.d.ts +13 -0
  16. package/dist/compression/index.d.ts.map +1 -0
  17. package/dist/compression/index.js +211 -0
  18. package/dist/compression/index.js.map +1 -0
  19. package/dist/context/index.d.ts +74 -0
  20. package/dist/context/index.d.ts.map +1 -0
  21. package/dist/context/index.js +118 -0
  22. package/dist/context/index.js.map +1 -0
  23. package/dist/contract/__fixtures__/http-client.generated.d.ts +70 -0
  24. package/dist/contract/__fixtures__/http-client.generated.d.ts.map +1 -0
  25. package/dist/contract/__fixtures__/http-client.generated.js +86 -0
  26. package/dist/contract/__fixtures__/http-client.generated.js.map +1 -0
  27. package/dist/contract/__fixtures__/http-convergence.d.ts +396 -0
  28. package/dist/contract/__fixtures__/http-convergence.d.ts.map +1 -0
  29. package/dist/contract/__fixtures__/http-convergence.js +425 -0
  30. package/dist/contract/__fixtures__/http-convergence.js.map +1 -0
  31. package/dist/contract/compiler/client.d.ts +12 -0
  32. package/dist/contract/compiler/client.d.ts.map +1 -0
  33. package/dist/contract/compiler/client.js +1293 -0
  34. package/dist/contract/compiler/client.js.map +1 -0
  35. package/dist/contract/compiler/index.d.ts +18 -0
  36. package/dist/contract/compiler/index.d.ts.map +1 -0
  37. package/dist/contract/compiler/index.js +1058 -0
  38. package/dist/contract/compiler/index.js.map +1 -0
  39. package/dist/contract/index.d.ts +237 -0
  40. package/dist/contract/index.d.ts.map +1 -0
  41. package/dist/contract/index.js +22 -0
  42. package/dist/contract/index.js.map +1 -0
  43. package/dist/csrf/index.d.ts +15 -0
  44. package/dist/csrf/index.d.ts.map +1 -0
  45. package/dist/csrf/index.js +218 -0
  46. package/dist/csrf/index.js.map +1 -0
  47. package/dist/data/index.d.ts +35 -0
  48. package/dist/data/index.d.ts.map +1 -0
  49. package/dist/data/index.js +58 -0
  50. package/dist/data/index.js.map +1 -0
  51. package/dist/devtools/index.d.ts +74 -0
  52. package/dist/devtools/index.d.ts.map +1 -0
  53. package/dist/devtools/index.js +552 -0
  54. package/dist/devtools/index.js.map +1 -0
  55. package/dist/dto-pipes/index.d.ts +45 -0
  56. package/dist/dto-pipes/index.d.ts.map +1 -0
  57. package/dist/dto-pipes/index.js +78 -0
  58. package/dist/dto-pipes/index.js.map +1 -0
  59. package/dist/gateways/index.d.ts +31 -0
  60. package/dist/gateways/index.d.ts.map +1 -0
  61. package/dist/gateways/index.js +138 -0
  62. package/dist/gateways/index.js.map +1 -0
  63. package/dist/health/index.d.ts +20 -0
  64. package/dist/health/index.d.ts.map +1 -0
  65. package/dist/health/index.js +148 -0
  66. package/dist/health/index.js.map +1 -0
  67. package/dist/http-token.d.ts +2 -0
  68. package/dist/http-token.d.ts.map +1 -0
  69. package/dist/http-token.js +21 -0
  70. package/dist/http-token.js.map +1 -0
  71. package/dist/index.d.ts +18 -0
  72. package/dist/index.d.ts.map +1 -0
  73. package/dist/index.js +40 -0
  74. package/dist/index.js.map +1 -0
  75. package/dist/integrations/index.d.ts +11 -0
  76. package/dist/integrations/index.d.ts.map +1 -0
  77. package/dist/integrations/index.js +39 -0
  78. package/dist/integrations/index.js.map +1 -0
  79. package/dist/middleware/errors.d.ts +14 -0
  80. package/dist/middleware/errors.d.ts.map +1 -0
  81. package/dist/middleware/errors.js +18 -0
  82. package/dist/middleware/errors.js.map +1 -0
  83. package/dist/middleware/index.d.ts +46 -0
  84. package/dist/middleware/index.d.ts.map +1 -0
  85. package/dist/middleware/index.js +64 -0
  86. package/dist/middleware/index.js.map +1 -0
  87. package/dist/modules/__fixtures__/large-graph.d.ts +239 -0
  88. package/dist/modules/__fixtures__/large-graph.d.ts.map +1 -0
  89. package/dist/modules/__fixtures__/large-graph.js +483 -0
  90. package/dist/modules/__fixtures__/large-graph.js.map +1 -0
  91. package/dist/openapi/__fixtures__/entities.d.ts +8 -0
  92. package/dist/openapi/__fixtures__/entities.d.ts.map +1 -0
  93. package/dist/openapi/__fixtures__/entities.js +4 -0
  94. package/dist/openapi/__fixtures__/entities.js.map +1 -0
  95. package/dist/openapi/__fixtures__/openapi-tools.fixture.d.ts +38 -0
  96. package/dist/openapi/__fixtures__/openapi-tools.fixture.d.ts.map +1 -0
  97. package/dist/openapi/__fixtures__/openapi-tools.fixture.js +172 -0
  98. package/dist/openapi/__fixtures__/openapi-tools.fixture.js.map +1 -0
  99. package/dist/openapi/__fixtures__/route-schemas.d.ts +2 -0
  100. package/dist/openapi/__fixtures__/route-schemas.d.ts.map +1 -0
  101. package/dist/openapi/__fixtures__/route-schemas.js +17 -0
  102. package/dist/openapi/__fixtures__/route-schemas.js.map +1 -0
  103. package/dist/openapi/index.d.ts +61 -0
  104. package/dist/openapi/index.d.ts.map +1 -0
  105. package/dist/openapi/index.js +314 -0
  106. package/dist/openapi/index.js.map +1 -0
  107. package/dist/pipeline/guards.d.ts +9 -0
  108. package/dist/pipeline/guards.d.ts.map +1 -0
  109. package/dist/pipeline/guards.js +11 -0
  110. package/dist/pipeline/guards.js.map +1 -0
  111. package/dist/pipeline/index.d.ts +141 -0
  112. package/dist/pipeline/index.d.ts.map +1 -0
  113. package/dist/pipeline/index.js +1624 -0
  114. package/dist/pipeline/index.js.map +1 -0
  115. package/dist/routing/index.d.ts +40 -0
  116. package/dist/routing/index.d.ts.map +1 -0
  117. package/dist/routing/index.js +162 -0
  118. package/dist/routing/index.js.map +1 -0
  119. package/dist/static/index.d.ts +14 -0
  120. package/dist/static/index.d.ts.map +1 -0
  121. package/dist/static/index.js +348 -0
  122. package/dist/static/index.js.map +1 -0
  123. package/dist/testing/index.d.ts +20 -0
  124. package/dist/testing/index.d.ts.map +1 -0
  125. package/dist/testing/index.js +32 -0
  126. package/dist/testing/index.js.map +1 -0
  127. package/dist/traced-router.d.ts +5 -0
  128. package/dist/traced-router.d.ts.map +1 -0
  129. package/dist/traced-router.js +6 -0
  130. package/dist/traced-router.js.map +1 -0
  131. package/dist/upload/index.d.ts +28 -0
  132. package/dist/upload/index.d.ts.map +1 -0
  133. package/dist/upload/index.js +410 -0
  134. package/dist/upload/index.js.map +1 -0
  135. package/dist/versioning/index.d.ts +35 -0
  136. package/dist/versioning/index.d.ts.map +1 -0
  137. package/dist/versioning/index.js +66 -0
  138. package/dist/versioning/index.js.map +1 -0
  139. package/dist/versioning/runtime.d.ts +5 -0
  140. package/dist/versioning/runtime.d.ts.map +1 -0
  141. package/dist/versioning/runtime.js +34 -0
  142. package/dist/versioning/runtime.js.map +1 -0
  143. package/package.json +143 -0
  144. package/src/app/bridge.ts +39 -0
  145. package/src/app/index.ts +76 -0
  146. package/src/bench/index.ts +329 -0
  147. package/src/compression/index.ts +255 -0
  148. package/src/context/index.ts +181 -0
  149. package/src/contract/__fixtures__/http-client.generated.ts +73 -0
  150. package/src/contract/__fixtures__/http-client.generated.ts.map +1 -0
  151. package/src/contract/__fixtures__/http-convergence.ts +564 -0
  152. package/src/contract/compiler/client.ts +1518 -0
  153. package/src/contract/compiler/index.ts +1457 -0
  154. package/src/contract/index.ts +266 -0
  155. package/src/csrf/index.ts +264 -0
  156. package/src/data/index.ts +65 -0
  157. package/src/devtools/index.ts +727 -0
  158. package/src/dto-pipes/index.ts +93 -0
  159. package/src/gateways/index.ts +180 -0
  160. package/src/health/index.ts +198 -0
  161. package/src/http-token.ts +22 -0
  162. package/src/index.ts +167 -0
  163. package/src/integrations/index.ts +50 -0
  164. package/src/middleware/errors.ts +17 -0
  165. package/src/middleware/index.ts +108 -0
  166. package/src/modules/__fixtures__/large-graph.ts +540 -0
  167. package/src/openapi/__fixtures__/entities.ts +19 -0
  168. package/src/openapi/__fixtures__/openapi-tools.fixture.ts +212 -0
  169. package/src/openapi/__fixtures__/route-schemas.ts +27 -0
  170. package/src/openapi/index.ts +483 -0
  171. package/src/pipeline/guards.ts +23 -0
  172. package/src/pipeline/index.ts +2041 -0
  173. package/src/routing/index.ts +201 -0
  174. package/src/static/index.ts +443 -0
  175. package/src/testing/index.ts +58 -0
  176. package/src/traced-router.ts +8 -0
  177. package/src/upload/index.ts +489 -0
  178. package/src/versioning/index.ts +106 -0
  179. package/src/versioning/runtime.ts +38 -0
@@ -0,0 +1,1518 @@
1
+ // Deterministic HttpContractIR -> typed @zmdb/client module emission.
2
+ //
3
+ // This is build-time code. The generated module contains only precomputed request
4
+ // plans, response dispatch and AOT validation helpers, and imports its runtime ABI
5
+ // exclusively from @zmdb/client.
6
+
7
+ import { Emitter } from '@zmdb/compiler/emit';
8
+
9
+ import type {
10
+ HttpBodyIR,
11
+ HttpContractIR,
12
+ HttpOperationIR,
13
+ HttpParameterIR,
14
+ HttpResponseIR,
15
+ HttpTypeIR,
16
+ SecurityScheme,
17
+ } from '../index.js';
18
+
19
+ export const HTTP_CLIENT_GENERATOR_VERSION = '1.0.0';
20
+
21
+ type TypeIR = HttpTypeIR['type'];
22
+ type ObjectIR = Extract<TypeIR, { readonly kind: 'object' }>;
23
+
24
+ export interface GeneratedHttpClientModule {
25
+ readonly source: string;
26
+ readonly sourceMap: string;
27
+ readonly operations: readonly string[];
28
+ readonly contractFormat: 1;
29
+ readonly generatorVersion: string;
30
+ }
31
+
32
+ interface BodyModel {
33
+ readonly body: HttpBodyIR;
34
+ readonly type: string;
35
+ readonly validator?: string;
36
+ }
37
+
38
+ interface ResponseModel {
39
+ readonly response: HttpResponseIR;
40
+ readonly headersType: string;
41
+ readonly headersDecoder?: string;
42
+ readonly bodies: ReadonlyMap<string, BodyModel>;
43
+ }
44
+
45
+ interface OperationModel {
46
+ readonly operation: HttpOperationIR;
47
+ readonly stem: string;
48
+ readonly inputType: string;
49
+ readonly resultType: string;
50
+ readonly resultTypesByVersion: ReadonlyMap<string, string>;
51
+ readonly callOptionsType: string;
52
+ readonly responses: readonly ResponseModel[];
53
+ }
54
+
55
+ const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/u;
56
+ const HTTP_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u;
57
+ const JSON_BODY_KIND: HttpBodyIR['kind'] = 'json';
58
+ const TEXT_BODY_KIND: HttpBodyIR['kind'] = 'text';
59
+ const RESERVED_IDENTIFIERS = new Set([
60
+ 'await',
61
+ 'break',
62
+ 'case',
63
+ 'catch',
64
+ 'class',
65
+ 'const',
66
+ 'continue',
67
+ 'debugger',
68
+ 'default',
69
+ 'delete',
70
+ 'do',
71
+ 'else',
72
+ 'enum',
73
+ 'export',
74
+ 'extends',
75
+ 'false',
76
+ 'finally',
77
+ 'for',
78
+ 'function',
79
+ 'if',
80
+ 'implements',
81
+ 'import',
82
+ 'in',
83
+ 'instanceof',
84
+ 'interface',
85
+ 'let',
86
+ 'new',
87
+ 'null',
88
+ 'package',
89
+ 'private',
90
+ 'protected',
91
+ 'public',
92
+ 'return',
93
+ 'static',
94
+ 'super',
95
+ 'switch',
96
+ 'this',
97
+ 'throw',
98
+ 'true',
99
+ 'try',
100
+ 'typeof',
101
+ 'undefined',
102
+ 'var',
103
+ 'void',
104
+ 'while',
105
+ 'with',
106
+ 'yield',
107
+ ]);
108
+
109
+ function generationError(field: string, problem: string): Error {
110
+ return new Error(`HTTP client generator at ${field}: ${problem}`);
111
+ }
112
+
113
+ function isJsonBody(body: HttpBodyIR): body is Extract<HttpBodyIR, { readonly kind: 'json' }> {
114
+ return body.kind === JSON_BODY_KIND;
115
+ }
116
+
117
+ function isTextBody(body: HttpBodyIR): body is { readonly kind: 'text'; readonly mediaType: string } {
118
+ return body.kind === TEXT_BODY_KIND;
119
+ }
120
+
121
+ function operationError(operationId: string, field: string, problem: string): Error {
122
+ return generationError(`${operationId}.${field}`, problem);
123
+ }
124
+
125
+ function literal(value: string | number | boolean): string {
126
+ if (typeof value === 'number' && !Number.isFinite(value)) {
127
+ throw generationError('type', 'non-finite number literals have no stable TypeScript spelling');
128
+ }
129
+ const printed = JSON.stringify(value);
130
+ if (printed === undefined) throw generationError('type', 'could not print a literal');
131
+ return printed;
132
+ }
133
+
134
+ function propertyName(name: string): string {
135
+ return IDENTIFIER.test(name) ? name : JSON.stringify(name);
136
+ }
137
+
138
+ function propertyAccess(target: string, name: string, optional: boolean): string {
139
+ if (IDENTIFIER.test(name)) return `${target}${optional ? '?.' : '.'}${name}`;
140
+ return `${target}${optional ? '?.' : ''}[${JSON.stringify(name)}]`;
141
+ }
142
+
143
+ function typeToken(value: string): string {
144
+ const words = value.split(/[^A-Za-z0-9$]+/u).filter(word => word.length > 0);
145
+ const token = words.map(word => `${word[0]?.toUpperCase() ?? ''}${word.slice(1)}`).join('');
146
+ return token.length === 0 ? 'Value' : /^[A-Za-z_$]/u.test(token) ? token : `Value${token}`;
147
+ }
148
+
149
+ function operationStem(operationId: string): string {
150
+ return typeToken(operationId);
151
+ }
152
+
153
+ function stableJson(value: unknown): string {
154
+ if (value === null) return 'null';
155
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
156
+ switch (typeof value) {
157
+ case 'boolean':
158
+ case 'string': {
159
+ const printed = JSON.stringify(value);
160
+ if (printed === undefined) throw generationError('contract', 'contains an unprintable scalar');
161
+ return printed;
162
+ }
163
+ case 'number': {
164
+ if (!Number.isFinite(value)) throw generationError('contract', 'contains a non-finite number');
165
+ return JSON.stringify(value);
166
+ }
167
+ case 'object': {
168
+ const fields = Object.keys(value)
169
+ .toSorted()
170
+ .flatMap(key => {
171
+ const nested = Reflect.get(value, key);
172
+ return nested === undefined ? [] : [`${JSON.stringify(key)}:${stableJson(nested)}`];
173
+ });
174
+ return `{${fields.join(',')}}`;
175
+ }
176
+ default:
177
+ throw generationError('contract', `contains non-JSON ${typeof value}`);
178
+ }
179
+ }
180
+
181
+ function typeScriptPrelude(source: string): string {
182
+ return source.replace(
183
+ /function ([A-Za-z_$][A-Za-z0-9_$]*)\(([^)]*)\)/gu,
184
+ (_match: string, name: string, parameters: string) => {
185
+ const typed = parameters
186
+ .split(',')
187
+ .map(parameter => parameter.trim())
188
+ .filter(parameter => parameter.length > 0)
189
+ .map(parameter => `${parameter}: any`)
190
+ .join(', ');
191
+ return `function ${name}(${typed})`;
192
+ },
193
+ );
194
+ }
195
+
196
+ function typeScriptValidationExpression(source: string): string {
197
+ const wrappedArrow = '((() => {';
198
+ const arrow = '(() => {';
199
+ const returnType =
200
+ '{ readonly success: true; readonly data: unknown } | ' +
201
+ '{ readonly success: false; readonly errors: readonly ValidationIssue[] }';
202
+ if (!source.startsWith(wrappedArrow) && !source.startsWith(arrow)) {
203
+ throw generationError('validator', 'AOT emitter returned an unknown expression shape');
204
+ }
205
+ return source
206
+ .replace(
207
+ source.startsWith(wrappedArrow) ? wrappedArrow : arrow,
208
+ source.startsWith(wrappedArrow) ? `(((): ${returnType} => {` : `((): ${returnType} => {`,
209
+ )
210
+ .replaceAll('const _e = [];', 'const _e: ValidationIssue[] = [];');
211
+ }
212
+
213
+ function collectDefinitions(node: TypeIR, definitions: Map<string, ObjectIR>, seen: Set<TypeIR>): void {
214
+ if (seen.has(node)) return;
215
+ seen.add(node);
216
+ switch (node.kind) {
217
+ case 'array':
218
+ collectDefinitions(node.element, definitions, seen);
219
+ return;
220
+ case 'tuple':
221
+ for (const element of node.elements) collectDefinitions(element, definitions, seen);
222
+ return;
223
+ case 'union':
224
+ for (const member of node.members) collectDefinitions(member, definitions, seen);
225
+ return;
226
+ case 'object': {
227
+ if (node.name !== undefined) {
228
+ const present = definitions.get(node.name);
229
+ if (present !== undefined && stableJson(present) !== stableJson(node)) {
230
+ throw generationError('type', `named object ${node.name} has two different definitions`);
231
+ }
232
+ definitions.set(node.name, node);
233
+ }
234
+ for (const property of node.properties) collectDefinitions(property.type, definitions, seen);
235
+ return;
236
+ }
237
+ default:
238
+ return;
239
+ }
240
+ }
241
+
242
+ class TypePrinter {
243
+ readonly #rootName: string;
244
+ readonly #definitions = new Map<string, ObjectIR>();
245
+ readonly #helpers = new Map<string, string>();
246
+
247
+ constructor(rootName: string, node: TypeIR, uniqueName: (base: string) => string) {
248
+ this.#rootName = rootName;
249
+ collectDefinitions(node, this.#definitions, new Set());
250
+ for (const name of this.#definitions.keys()) {
251
+ const base = `${rootName}${typeToken(name)}`;
252
+ this.#helpers.set(name, uniqueName(base));
253
+ }
254
+ }
255
+
256
+ declarations(): readonly string[] {
257
+ const declarations: string[] = [];
258
+ for (const [name, node] of this.#definitions) {
259
+ const helper = this.#helpers.get(name);
260
+ if (helper === undefined) throw generationError(this.#rootName, `missing helper for ${name}`);
261
+ declarations.push(`type ${helper} = ${this.#object(node)};`);
262
+ }
263
+ return declarations;
264
+ }
265
+
266
+ print(node: TypeIR): string {
267
+ switch (node.kind) {
268
+ case 'scalar':
269
+ switch (node.scalar) {
270
+ case 'string':
271
+ return 'string';
272
+ case 'number':
273
+ case 'integer':
274
+ return 'number';
275
+ case 'bigint':
276
+ return 'bigint';
277
+ case 'boolean':
278
+ return 'boolean';
279
+ case 'date':
280
+ return 'Date';
281
+ }
282
+ case 'literal':
283
+ return literal(node.value);
284
+ case 'null':
285
+ return 'null';
286
+ case 'undefined':
287
+ return 'undefined';
288
+ case 'array':
289
+ return `readonly (${this.print(node.element)})[]`;
290
+ case 'tuple':
291
+ return `readonly [${node.elements.map(element => this.print(element)).join(', ')}]`;
292
+ case 'union':
293
+ if (node.members.length === 0) throw generationError(this.#rootName, 'an empty union matches nothing');
294
+ return node.members.map(member => this.print(member)).join(' | ');
295
+ case 'object':
296
+ if (node.name === undefined) return this.#object(node);
297
+ return this.#named(node.name);
298
+ case 'ref':
299
+ return this.#named(node.name);
300
+ case 'unknown':
301
+ throw generationError(this.#rootName, 'unknown cannot produce an exact generated signature');
302
+ case 'unsupported':
303
+ throw generationError(this.#rootName, `${node.reason}${node.source === undefined ? '' : ` (${node.source})`}`);
304
+ }
305
+ }
306
+
307
+ #named(name: string): string {
308
+ const helper = this.#helpers.get(name);
309
+ if (helper === undefined) throw generationError(this.#rootName, `back-reference ${name} has no definition`);
310
+ return helper;
311
+ }
312
+
313
+ #object(node: ObjectIR): string {
314
+ if (node.properties.length === 0) return 'Readonly<Record<string, unknown>>';
315
+ const properties = node.properties.map(property => {
316
+ const modifier = property.readonly ? 'readonly ' : '';
317
+ const optional = property.optional ? '?' : '';
318
+ return `${modifier}${propertyName(property.name)}${optional}: ${this.print(property.type)};`;
319
+ });
320
+ return `{ ${properties.join(' ')} }`;
321
+ }
322
+ }
323
+
324
+ function definitionsOf(node: TypeIR): ReadonlyMap<string, ObjectIR> {
325
+ const definitions = new Map<string, ObjectIR>();
326
+ collectDefinitions(node, definitions, new Set());
327
+ return definitions;
328
+ }
329
+
330
+ function resolveObject(node: TypeIR, definitions: ReadonlyMap<string, ObjectIR>): ObjectIR | undefined {
331
+ if (node.kind === 'object') return node;
332
+ if (node.kind === 'ref') return definitions.get(node.name);
333
+ return undefined;
334
+ }
335
+
336
+ function containsDate(
337
+ node: TypeIR,
338
+ definitions: ReadonlyMap<string, ObjectIR>,
339
+ visiting: ReadonlySet<string> = new Set(),
340
+ ): boolean {
341
+ switch (node.kind) {
342
+ case 'scalar':
343
+ return node.scalar === 'date';
344
+ case 'array':
345
+ return containsDate(node.element, definitions, visiting);
346
+ case 'tuple':
347
+ return node.elements.some(element => containsDate(element, definitions, visiting));
348
+ case 'union':
349
+ return node.members.some(member => containsDate(member, definitions, visiting));
350
+ case 'object': {
351
+ if (node.name !== undefined && visiting.has(node.name)) return false;
352
+ const nested = new Set(visiting);
353
+ if (node.name !== undefined) nested.add(node.name);
354
+ return node.properties.some(property => containsDate(property.type, definitions, nested));
355
+ }
356
+ case 'ref': {
357
+ if (visiting.has(node.name)) return false;
358
+ const target = definitions.get(node.name);
359
+ if (target === undefined) throw generationError('type', `back-reference ${node.name} has no definition`);
360
+ return containsDate(target, definitions, new Set([...visiting, node.name]));
361
+ }
362
+ default:
363
+ return false;
364
+ }
365
+ }
366
+
367
+ function literalDiscriminant(
368
+ members: readonly TypeIR[],
369
+ definitions: ReadonlyMap<string, ObjectIR>,
370
+ ): { readonly property: string; readonly values: readonly (string | number | boolean)[] } | undefined {
371
+ const objects = members.map(member => resolveObject(member, definitions));
372
+ if (objects.some(object => object === undefined)) return undefined;
373
+ const first = objects[0];
374
+ if (first === undefined) return undefined;
375
+ for (const candidate of first.properties) {
376
+ if (candidate.type.kind !== 'literal') continue;
377
+ const values: (string | number | boolean)[] = [candidate.type.value];
378
+ let complete = true;
379
+ for (const object of objects.slice(1)) {
380
+ const property = object?.properties.find(entry => entry.name === candidate.name);
381
+ if (property?.type.kind !== 'literal' || values.includes(property.type.value)) {
382
+ complete = false;
383
+ break;
384
+ }
385
+ values.push(property.type.value);
386
+ }
387
+ if (complete) return { property: candidate.name, values };
388
+ }
389
+ return undefined;
390
+ }
391
+
392
+ class DecoderPrinter {
393
+ readonly #prefix: string;
394
+ readonly #definitions: ReadonlyMap<string, ObjectIR>;
395
+ readonly #helpers: string[] = [];
396
+ readonly #objectHelpers = new Map<ObjectIR, string>();
397
+ readonly #arrayHelpers = new Map<TypeIR, string>();
398
+ #counter = 0;
399
+ #dateHelper: string | undefined;
400
+
401
+ constructor(prefix: string, node: TypeIR) {
402
+ this.#prefix = prefix;
403
+ this.#definitions = definitionsOf(node);
404
+ }
405
+
406
+ declarations(): readonly string[] {
407
+ return this.#helpers;
408
+ }
409
+
410
+ decode(node: TypeIR, value: string): string {
411
+ if (!containsDate(node, this.#definitions)) return value;
412
+ switch (node.kind) {
413
+ case 'scalar':
414
+ return `${this.#date()}(${value})`;
415
+ case 'array':
416
+ return `${this.#array(node)}(${value})`;
417
+ case 'tuple':
418
+ return `${this.#tuple(node)}(${value})`;
419
+ case 'object':
420
+ return `${this.#object(node)}(${value})`;
421
+ case 'ref': {
422
+ const target = this.#definitions.get(node.name);
423
+ if (target === undefined) throw generationError(this.#prefix, `back-reference ${node.name} has no definition`);
424
+ return `${this.#object(target)}(${value})`;
425
+ }
426
+ case 'union':
427
+ return this.#union(node, value);
428
+ default:
429
+ return value;
430
+ }
431
+ }
432
+
433
+ #name(hint: string): string {
434
+ const name = `${this.#prefix}${hint}${String(this.#counter)}`;
435
+ this.#counter += 1;
436
+ return name;
437
+ }
438
+
439
+ #date(): string {
440
+ if (this.#dateHelper !== undefined) return this.#dateHelper;
441
+ const name = this.#name('Date');
442
+ this.#dateHelper = name;
443
+ this.#helpers.push(
444
+ `function ${name}(value: unknown): unknown { return typeof value === 'string' ? new Date(value) : value; }`,
445
+ );
446
+ return name;
447
+ }
448
+
449
+ #object(node: ObjectIR): string {
450
+ const present = this.#objectHelpers.get(node);
451
+ if (present !== undefined) return present;
452
+ const name = this.#name('Object');
453
+ this.#objectHelpers.set(node, name);
454
+ const properties = node.properties.flatMap(property => {
455
+ if (!containsDate(property.type, this.#definitions)) return [];
456
+ const access = `Reflect.get(value, ${JSON.stringify(property.name)})`;
457
+ const decoded = this.decode(property.type, access);
458
+ const field = `${propertyName(property.name)}: ${decoded}`;
459
+ return property.optional
460
+ ? [`...(Object.hasOwn(value, ${JSON.stringify(property.name)}) ? { ${field} } : {})`]
461
+ : [field];
462
+ });
463
+ const body = properties.length === 0 ? 'return value;' : `return { ...value, ${properties.join(', ')} };`;
464
+ this.#helpers.push(
465
+ `function ${name}(value: unknown): unknown { ` +
466
+ `if (typeof value !== 'object' || value === null || Array.isArray(value)) return value; ${body} }`,
467
+ );
468
+ return name;
469
+ }
470
+
471
+ #array(node: Extract<TypeIR, { readonly kind: 'array' }>): string {
472
+ const present = this.#arrayHelpers.get(node);
473
+ if (present !== undefined) return present;
474
+ const name = this.#name('Array');
475
+ this.#arrayHelpers.set(node, name);
476
+ const decoded = this.decode(node.element, 'value[index]');
477
+ this.#helpers.push(
478
+ `function ${name}(value: unknown): unknown { if (!Array.isArray(value)) return value; ` +
479
+ `const output: unknown[] = []; for (let index = 0; index < value.length; index += 1) ` +
480
+ `output.push(${decoded}); return output; }`,
481
+ );
482
+ return name;
483
+ }
484
+
485
+ #tuple(node: Extract<TypeIR, { readonly kind: 'tuple' }>): string {
486
+ const present = this.#arrayHelpers.get(node);
487
+ if (present !== undefined) return present;
488
+ const name = this.#name('Tuple');
489
+ this.#arrayHelpers.set(node, name);
490
+ const values = node.elements.map((element, index) => this.decode(element, `value[${String(index)}]`));
491
+ this.#helpers.push(
492
+ `function ${name}(value: unknown): unknown { if (!Array.isArray(value)) return value; ` +
493
+ `return [${values.join(', ')}]; }`,
494
+ );
495
+ return name;
496
+ }
497
+
498
+ #union(node: Extract<TypeIR, { readonly kind: 'union' }>, value: string): string {
499
+ const transformed = node.members.filter(member => containsDate(member, this.#definitions));
500
+ const inert = node.members.filter(member => !containsDate(member, this.#definitions));
501
+ if (transformed.length === 1 && inert.every(member => member.kind === 'null' || member.kind === 'undefined')) {
502
+ const decoded = this.decode(transformed[0] ?? { kind: 'unknown' }, value);
503
+ return `${value} === null || ${value} === undefined ? ${value} : ${decoded}`;
504
+ }
505
+
506
+ const discriminant = literalDiscriminant(node.members, this.#definitions);
507
+ if (discriminant === undefined) {
508
+ throw generationError(
509
+ this.#prefix,
510
+ 'a date-bearing union has no literal object discriminant or nullable single transformed arm',
511
+ );
512
+ }
513
+ const helper = this.#name('Union');
514
+ const branches = node.members.map((member, index) => {
515
+ const decoded = this.decode(member, 'value');
516
+ const discriminator = discriminant.values[index];
517
+ if (discriminator === undefined) {
518
+ throw generationError(this.#prefix, 'a discriminated union lost one discriminator');
519
+ }
520
+ return (
521
+ `if (Reflect.get(value, ${JSON.stringify(discriminant.property)}) === ${literal(discriminator)}) ` +
522
+ `return ${decoded};`
523
+ );
524
+ });
525
+ this.#helpers.push(
526
+ `function ${helper}(value: unknown): unknown { ` +
527
+ `if (typeof value !== 'object' || value === null || Array.isArray(value)) return value; ` +
528
+ `${branches.join(' ')} return value; }`,
529
+ );
530
+ return `${helper}(${value})`;
531
+ }
532
+ }
533
+
534
+ function assertJsonType(
535
+ node: TypeIR,
536
+ field: string,
537
+ definitions: ReadonlyMap<string, ObjectIR>,
538
+ visiting: ReadonlySet<string> = new Set(),
539
+ ): void {
540
+ switch (node.kind) {
541
+ case 'unsupported':
542
+ throw generationError(field, node.reason);
543
+ case 'unknown':
544
+ throw generationError(field, 'unknown cannot produce an exact JSON validator');
545
+ case 'undefined':
546
+ throw generationError(field, 'undefined has no JSON wire representation');
547
+ case 'scalar':
548
+ if (node.scalar === 'bigint') throw generationError(field, 'bigint has no JSON wire representation');
549
+ return;
550
+ case 'array':
551
+ assertJsonType(node.element, `${field}[]`, definitions, visiting);
552
+ return;
553
+ case 'tuple':
554
+ for (const [index, element] of node.elements.entries()) {
555
+ assertJsonType(element, `${field}[${String(index)}]`, definitions, visiting);
556
+ }
557
+ return;
558
+ case 'union':
559
+ for (const member of node.members) assertJsonType(member, field, definitions, visiting);
560
+ return;
561
+ case 'object': {
562
+ if (node.name !== undefined && visiting.has(node.name)) return;
563
+ const nested = new Set(visiting);
564
+ if (node.name !== undefined) nested.add(node.name);
565
+ for (const property of node.properties) {
566
+ assertJsonType(property.type, `${field}.${property.name}`, definitions, nested);
567
+ }
568
+ return;
569
+ }
570
+ case 'ref': {
571
+ if (visiting.has(node.name)) return;
572
+ const target = definitions.get(node.name);
573
+ if (target === undefined) throw generationError(field, `back-reference ${node.name} has no definition`);
574
+ assertJsonType(target, field, definitions, new Set([...visiting, node.name]));
575
+ return;
576
+ }
577
+ default:
578
+ return;
579
+ }
580
+ }
581
+
582
+ type ScalarCategory = 'string' | 'number' | 'bigint' | 'boolean' | 'date';
583
+
584
+ function scalarCategory(node: TypeIR, field: string): ScalarCategory {
585
+ if (node.kind === 'scalar') {
586
+ return node.scalar === 'integer' ? 'number' : node.scalar;
587
+ }
588
+ if (node.kind === 'literal') {
589
+ if (typeof node.value === 'string') return 'string';
590
+ if (typeof node.value === 'number') return 'number';
591
+ return 'boolean';
592
+ }
593
+ if (node.kind === 'union' && node.members.length > 0) {
594
+ const categories = new Set(node.members.map(member => scalarCategory(member, field)));
595
+ if (categories.size === 1) {
596
+ const category = categories.values().next().value;
597
+ if (category !== undefined) return category;
598
+ }
599
+ if (categories.size === 2 && categories.has('string') && categories.has('date')) return 'string';
600
+ }
601
+ throw generationError(field, `${node.kind} has incompatible HTTP scalar spellings`);
602
+ }
603
+
604
+ function versionKeys(operation: HttpOperationIR): readonly string[] {
605
+ return operation.version.kind === 'media-type' ? operation.version.values : [''];
606
+ }
607
+
608
+ function bodyForVersion(operation: HttpOperationIR, response: HttpResponseIR, version: string): HttpBodyIR {
609
+ if (version.length === 0 || response.versions === undefined) return response.body;
610
+ const body = response.versions[version];
611
+ if (body === undefined) {
612
+ throw operationError(
613
+ operation.operationId,
614
+ `responses.${String(response.status)}.versions`,
615
+ `does not declare version ${version}`,
616
+ );
617
+ }
618
+ return body;
619
+ }
620
+
621
+ function mediaParameterValue(value: string): string {
622
+ return HTTP_TOKEN.test(value) ? value : `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
623
+ }
624
+
625
+ function versionedMediaType(operation: HttpOperationIR, body: HttpBodyIR, version: string): string {
626
+ if (body.kind === 'empty') {
627
+ throw operationError(operation.operationId, 'responses', 'an empty body has no media type');
628
+ }
629
+ if (operation.version.kind !== 'media-type') return body.mediaType;
630
+ return `${body.mediaType}; ${operation.version.key.toLowerCase()}=${mediaParameterValue(version)}`;
631
+ }
632
+
633
+ function bodyType(body: HttpBodyIR, jsonType: string | undefined): string {
634
+ if (isJsonBody(body)) {
635
+ if (jsonType === undefined) throw generationError('body', `missing type ${body.typeId}`);
636
+ return jsonType;
637
+ }
638
+ if (isTextBody(body)) return 'string';
639
+ if (body.kind === 'bytes') return 'Uint8Array<ArrayBuffer>';
640
+ if (body.kind === 'stream') return 'ReadableStream<Uint8Array<ArrayBuffer>>';
641
+ return 'void';
642
+ }
643
+
644
+ function clientSecurityScheme(scheme: SecurityScheme): Readonly<Record<string, string>> {
645
+ switch (scheme.type) {
646
+ case 'http':
647
+ return { type: scheme.type, scheme: scheme.scheme };
648
+ case 'apiKey':
649
+ return { type: scheme.type, in: scheme.in, name: scheme.name };
650
+ case 'mutualTLS':
651
+ case 'oauth2':
652
+ case 'openIdConnect':
653
+ return { type: scheme.type };
654
+ }
655
+ }
656
+
657
+ function sourceBody(body: HttpBodyIR): Readonly<Record<string, unknown>> {
658
+ if (isJsonBody(body)) {
659
+ return { kind: body.kind, mediaType: body.mediaType, typeId: body.typeId };
660
+ }
661
+ if (body.kind === 'empty') return { kind: body.kind };
662
+ return { kind: body.kind, mediaType: body.mediaType };
663
+ }
664
+
665
+ function sourceOperation(operation: HttpOperationIR): Readonly<Record<string, unknown>> {
666
+ return {
667
+ operationId: operation.operationId,
668
+ method: operation.method,
669
+ path: operation.path,
670
+ parameters: operation.parameters.map(parameter => ({
671
+ property: parameter.property,
672
+ name: parameter.name,
673
+ in: parameter.in,
674
+ required: parameter.required,
675
+ typeId: parameter.typeId,
676
+ })),
677
+ ...(operation.requestBody === undefined
678
+ ? {}
679
+ : {
680
+ requestBody: {
681
+ ...sourceBody(operation.requestBody),
682
+ required: operation.requestBody.required,
683
+ },
684
+ }),
685
+ responses: operation.responses.map(response => ({
686
+ status: response.status,
687
+ headers: response.headers.map(header => ({
688
+ property: header.property,
689
+ name: header.name,
690
+ required: header.required,
691
+ typeId: header.typeId,
692
+ })),
693
+ body: sourceBody(response.body),
694
+ ...(response.versions === undefined
695
+ ? {}
696
+ : {
697
+ versions: Object.fromEntries(
698
+ Object.keys(response.versions)
699
+ .toSorted()
700
+ .flatMap(version => {
701
+ const body = response.versions?.[version];
702
+ return body === undefined ? [] : [[version, sourceBody(body)]];
703
+ }),
704
+ ),
705
+ }),
706
+ })),
707
+ security: operation.security,
708
+ version: operation.version,
709
+ deprecated: operation.deprecated,
710
+ };
711
+ }
712
+
713
+ function sourceContract(contract: HttpContractIR, typeIds: ReadonlySet<string>): Readonly<Record<string, unknown>> {
714
+ const types: Record<string, TypeIR> = {};
715
+ for (const typeId of [...typeIds].toSorted()) {
716
+ const type = contract.types[typeId];
717
+ if (type !== undefined) types[typeId] = type.type;
718
+ }
719
+ const schemeNames = new Set(
720
+ contract.operations.flatMap(operation => operation.security.flatMap(requirement => Object.keys(requirement))),
721
+ );
722
+ const schemes: Record<string, Readonly<Record<string, string>>> = {};
723
+ for (const name of [...schemeNames].toSorted()) {
724
+ const scheme = contract.securitySchemes[name];
725
+ if (scheme !== undefined) schemes[name] = clientSecurityScheme(scheme);
726
+ }
727
+ return {
728
+ format: 1,
729
+ types,
730
+ operations: [...contract.operations]
731
+ .toSorted((left, right) => left.operationId.localeCompare(right.operationId))
732
+ .map(sourceOperation),
733
+ securitySchemes: schemes,
734
+ };
735
+ }
736
+
737
+ class ModuleGenerator {
738
+ readonly #contract: HttpContractIR;
739
+ readonly #emitter = new Emitter({ prefix: '_zmdbClient' });
740
+ readonly #declarations: string[] = [];
741
+ readonly #validators: string[] = [];
742
+ readonly #moduleHelpers = new Map<string, string>();
743
+ readonly #typeNames = new Map<string, string>();
744
+ readonly #usedNames = new Set<string>();
745
+ readonly #usedStems = new Set<string>();
746
+
747
+ constructor(contract: HttpContractIR) {
748
+ this.#contract = contract;
749
+ }
750
+
751
+ generate(): GeneratedHttpClientModule {
752
+ this.#assertContract();
753
+ const operations = [...this.#contract.operations].toSorted((left, right) =>
754
+ left.operationId.localeCompare(right.operationId),
755
+ );
756
+ const seen = new Set<string>();
757
+ const models: OperationModel[] = [];
758
+ for (const operation of operations) {
759
+ if (seen.has(operation.operationId)) {
760
+ throw operationError(operation.operationId, 'operationId', 'appears more than once');
761
+ }
762
+ seen.add(operation.operationId);
763
+ models.push(this.#operationModel(operation));
764
+ }
765
+
766
+ const operationBlocks = models.map(model => this.#operationBlock(model));
767
+ const api = this.#apiInterface(models);
768
+ const factory = this.#factory(models);
769
+ const prelude = this.#emitter.hasPrelude ? typeScriptPrelude(this.#emitter.prelude()) : '';
770
+ const sections = [
771
+ `// @generated by zmdb http-client; contract-format=1; generator=${HTTP_CLIENT_GENERATOR_VERSION}`,
772
+ `import { ClientRequestError, ClientResponseError, ResponseValidationError, createClientRuntime, prepareClientBody, stringifyClientScalar, substituteClientPath } from '@zmdb/client';`,
773
+ `import type { CallOptions, ClientHeaders, ClientOptions, DecodeResult, GeneratedOperation, ValidationIssue } from '@zmdb/client';`,
774
+ prelude,
775
+ [...this.#moduleHelpers.values()].join('\n'),
776
+ this.#declarations.join('\n\n'),
777
+ this.#validators.join('\n\n'),
778
+ operationBlocks.join('\n\n'),
779
+ api,
780
+ factory,
781
+ ].filter(section => section.length > 0);
782
+ const source = `${sections.join('\n\n')}\n`;
783
+ const sourceMap = this.#sourceMap(source);
784
+ return Object.freeze({
785
+ source,
786
+ sourceMap,
787
+ operations: Object.freeze(models.map(model => model.operation.operationId)),
788
+ contractFormat: 1,
789
+ generatorVersion: HTTP_CLIENT_GENERATOR_VERSION,
790
+ });
791
+ }
792
+
793
+ #assertContract(): void {
794
+ if (
795
+ this.#contract === null ||
796
+ typeof this.#contract !== 'object' ||
797
+ this.#contract.format !== 1 ||
798
+ !Array.isArray(this.#contract.operations) ||
799
+ this.#contract.types === null ||
800
+ typeof this.#contract.types !== 'object' ||
801
+ this.#contract.securitySchemes === null ||
802
+ typeof this.#contract.securitySchemes !== 'object'
803
+ ) {
804
+ throw generationError('contract', 'expected HttpContractIR format 1');
805
+ }
806
+ }
807
+
808
+ #uniqueName(base: string): string {
809
+ if (!this.#usedNames.has(base)) {
810
+ this.#usedNames.add(base);
811
+ return base;
812
+ }
813
+ let suffix = 2;
814
+ while (this.#usedNames.has(`${base}${String(suffix)}`)) suffix += 1;
815
+ const name = `${base}${String(suffix)}`;
816
+ this.#usedNames.add(name);
817
+ return name;
818
+ }
819
+
820
+ #node(typeId: string, field: string): TypeIR {
821
+ const type = this.#contract.types[typeId];
822
+ if (type === undefined) throw generationError(field, `references missing type ${typeId}`);
823
+ return type.type;
824
+ }
825
+
826
+ #declareType(typeId: string, hint: string): string {
827
+ const present = this.#typeNames.get(typeId);
828
+ if (present !== undefined) return present;
829
+ const name = this.#uniqueName(hint);
830
+ const node = this.#node(typeId, hint);
831
+ const printer = new TypePrinter(name, node, base => this.#uniqueName(base));
832
+ this.#declarations.push(...printer.declarations(), `export type ${name} = ${printer.print(node)};`);
833
+ this.#typeNames.set(typeId, name);
834
+ return name;
835
+ }
836
+
837
+ #jsonValidator(typeId: string, typeName: string, hint: string): string {
838
+ const node = this.#node(typeId, hint);
839
+ const definitions = definitionsOf(node);
840
+ assertJsonType(node, hint, definitions);
841
+ const decoder = new DecoderPrinter(`_zmdbDecode${hint}`, node);
842
+ const decoded = decoder.decode(node, 'wire');
843
+ const before = this.#emitter.diagnostics.length;
844
+ const emitted = this.#emitter.emitValidate(node, 'value');
845
+ if (emitted === undefined) {
846
+ const refusal = this.#emitter.diagnostics[before];
847
+ throw generationError(
848
+ hint,
849
+ refusal === undefined
850
+ ? 'the AOT validator refused the response type'
851
+ : `${refusal.path.length === 0 ? '' : `${refusal.path}: `}${refusal.reason}`,
852
+ );
853
+ }
854
+ const expression = typeScriptValidationExpression(emitted);
855
+ const name = this.#uniqueName(`_decode${hint}`);
856
+ this.#validators.push(
857
+ ...decoder.declarations(),
858
+ `function ${name}(wire: unknown): DecodeResult<${typeName}> { ` +
859
+ `const value: any = ${decoded}; const result = ${expression}; ` +
860
+ `return result.success ? { ok: true, value: result.data as ${typeName} } : ` +
861
+ `{ ok: false, issues: result.errors }; }`,
862
+ );
863
+ return name;
864
+ }
865
+
866
+ #headerScalar(raw: string, node: TypeIR, field: string): string {
867
+ switch (scalarCategory(node, field)) {
868
+ case 'string':
869
+ return raw;
870
+ case 'number':
871
+ this.#moduleHelpers.set(
872
+ 'header-number',
873
+ `function _zmdbHeaderNumber(value: string | undefined): unknown { ` +
874
+ `if (value === undefined || value.length === 0) return value; const decoded = Number(value); ` +
875
+ `return Number.isFinite(decoded) ? decoded : value; }`,
876
+ );
877
+ return `_zmdbHeaderNumber(${raw})`;
878
+ case 'bigint':
879
+ this.#moduleHelpers.set(
880
+ 'header-bigint',
881
+ `function _zmdbHeaderBigInt(value: string | undefined): unknown { ` +
882
+ `if (value === undefined || !/^-?\\d+$/u.test(value)) return value; ` +
883
+ `try { return BigInt(value); } catch { return value; } }`,
884
+ );
885
+ return `_zmdbHeaderBigInt(${raw})`;
886
+ case 'boolean':
887
+ this.#moduleHelpers.set(
888
+ 'header-boolean',
889
+ `function _zmdbHeaderBoolean(value: string | undefined): unknown { ` +
890
+ `return value === 'true' ? true : value === 'false' ? false : value; }`,
891
+ );
892
+ return `_zmdbHeaderBoolean(${raw})`;
893
+ case 'date':
894
+ this.#moduleHelpers.set(
895
+ 'header-date',
896
+ `function _zmdbHeaderDate(value: string | undefined): unknown { ` +
897
+ `return value === undefined ? value : new Date(value); }`,
898
+ );
899
+ return `_zmdbHeaderDate(${raw})`;
900
+ }
901
+ }
902
+
903
+ #headersModel(
904
+ operation: HttpOperationIR,
905
+ response: HttpResponseIR,
906
+ stem: string,
907
+ ): {
908
+ readonly type: string;
909
+ readonly decoder?: string;
910
+ } {
911
+ if (response.headers.length === 0) return { type: 'Readonly<Record<never, never>>' };
912
+ const typeName = this.#uniqueName(`${stem}Response${String(response.status)}Headers`);
913
+ const properties: string[] = [];
914
+ const objectProperties: ObjectIR['properties'][number][] = [];
915
+ const values: string[] = [];
916
+ for (const header of response.headers) {
917
+ const field = `${operation.operationId}.responses.${String(response.status)}.headers.${header.property}`;
918
+ const node = this.#node(header.typeId, field);
919
+ scalarCategory(node, field);
920
+ const propertyType = this.#declareType(
921
+ header.typeId,
922
+ `${stem}Response${String(response.status)}Header${typeToken(header.property)}`,
923
+ );
924
+ properties.push(
925
+ `readonly ${propertyName(header.property)}${header.required ? '' : '?'}: ` +
926
+ `${propertyType}${header.required ? '' : ' | undefined'};`,
927
+ );
928
+ objectProperties.push({
929
+ name: header.property,
930
+ type: node,
931
+ optional: !header.required,
932
+ readonly: true,
933
+ });
934
+ const raw = `headers[${JSON.stringify(header.name)}]`;
935
+ const decoded = this.#headerScalar(raw, node, field);
936
+ const fieldValue = `${propertyName(header.property)}: ${decoded}`;
937
+ values.push(
938
+ header.required
939
+ ? fieldValue
940
+ : `...(headers[${JSON.stringify(header.name)}] === undefined ? {} : { ${fieldValue} })`,
941
+ );
942
+ }
943
+ this.#declarations.push(`export interface ${typeName} { ${properties.join(' ')} }`);
944
+ const validatorNode: TypeIR = { kind: 'object', properties: objectProperties };
945
+ const before = this.#emitter.diagnostics.length;
946
+ const emitted = this.#emitter.emitValidate(validatorNode, 'value');
947
+ if (emitted === undefined) {
948
+ const refusal = this.#emitter.diagnostics[before];
949
+ throw operationError(
950
+ operation.operationId,
951
+ `responses.${String(response.status)}.headers`,
952
+ refusal?.reason ?? 'the AOT validator refused the response headers',
953
+ );
954
+ }
955
+ const expression = typeScriptValidationExpression(emitted);
956
+ const decoder = this.#uniqueName(`_decode${stem}Response${String(response.status)}Headers`);
957
+ this.#validators.push(
958
+ `function ${decoder}(headers: ClientHeaders): DecodeResult<${typeName}> { ` +
959
+ `const value = { ${values.join(', ')} }; const result = ${expression}; ` +
960
+ `return result.success ? { ok: true, value: result.data as ${typeName} } : ` +
961
+ `{ ok: false, issues: result.errors }; }`,
962
+ );
963
+ return { type: typeName, decoder };
964
+ }
965
+
966
+ #bodyModel(operation: HttpOperationIR, response: HttpResponseIR, body: HttpBodyIR, stem: string): BodyModel {
967
+ if (!isJsonBody(body)) return { body, type: bodyType(body, undefined) };
968
+ const type = this.#declareType(
969
+ body.typeId,
970
+ `${stem}Response${String(response.status)}${typeToken(body.typeId.split('/').slice(-3).join('_'))}`,
971
+ );
972
+ const validator = this.#jsonValidator(
973
+ body.typeId,
974
+ type,
975
+ `${stem}Response${String(response.status)}${typeToken(body.typeId.split('/').slice(-3).join('_'))}`,
976
+ );
977
+ return { body, type, validator };
978
+ }
979
+
980
+ #responseModels(operation: HttpOperationIR, stem: string): readonly ResponseModel[] {
981
+ if (operation.responses.length === 0) {
982
+ throw operationError(operation.operationId, 'responses', 'must declare at least one exact status');
983
+ }
984
+ const statuses = new Set<number>();
985
+ return [...operation.responses]
986
+ .toSorted((left, right) => left.status - right.status)
987
+ .map(response => {
988
+ if (!Number.isInteger(response.status) || response.status < 200 || response.status > 599) {
989
+ throw operationError(operation.operationId, 'responses', `invalid status ${String(response.status)}`);
990
+ }
991
+ if (statuses.has(response.status)) {
992
+ throw operationError(operation.operationId, 'responses', `status ${String(response.status)} appears twice`);
993
+ }
994
+ statuses.add(response.status);
995
+ const headers = this.#headersModel(operation, response, stem);
996
+ const bodies = new Map<string, BodyModel>();
997
+ if (response.versions !== undefined) {
998
+ if (operation.version.kind !== 'media-type') {
999
+ throw operationError(
1000
+ operation.operationId,
1001
+ `responses.${String(response.status)}.versions`,
1002
+ 'is allowed only for media-type versioning',
1003
+ );
1004
+ }
1005
+ const declared = Object.keys(response.versions).toSorted();
1006
+ const expected = [...operation.version.values].toSorted();
1007
+ if (stableJson(declared) !== stableJson(expected)) {
1008
+ throw operationError(
1009
+ operation.operationId,
1010
+ `responses.${String(response.status)}.versions`,
1011
+ `must declare exactly [${operation.version.values.join(', ')}]`,
1012
+ );
1013
+ }
1014
+ }
1015
+ for (const version of versionKeys(operation)) {
1016
+ const body = bodyForVersion(operation, response, version);
1017
+ bodies.set(version, this.#bodyModel(operation, response, body, stem));
1018
+ }
1019
+ return {
1020
+ response,
1021
+ headersType: headers.type,
1022
+ ...(headers.decoder === undefined ? {} : { headersDecoder: headers.decoder }),
1023
+ bodies,
1024
+ };
1025
+ });
1026
+ }
1027
+
1028
+ #parameterType(operation: HttpOperationIR, parameter: HttpParameterIR, stem: string): string {
1029
+ const field = `${operation.operationId}.parameters.${parameter.in}.${parameter.property}`;
1030
+ const node = this.#node(parameter.typeId, field);
1031
+ if (parameter.in === 'query' && node.kind === 'array') scalarCategory(node.element, field);
1032
+ else scalarCategory(node, field);
1033
+ return this.#declareType(parameter.typeId, `${stem}${typeToken(parameter.in)}${typeToken(parameter.property)}`);
1034
+ }
1035
+
1036
+ #inputType(operation: HttpOperationIR, stem: string): string {
1037
+ const groups = new Map<string, HttpParameterIR[]>([
1038
+ ['path', []],
1039
+ ['query', []],
1040
+ ['headers', []],
1041
+ ['cookies', []],
1042
+ ]);
1043
+ for (const parameter of operation.parameters) {
1044
+ const group = parameter.in === 'header' ? 'headers' : parameter.in === 'cookie' ? 'cookies' : parameter.in;
1045
+ groups.get(group)?.push(parameter);
1046
+ }
1047
+ const fields: string[] = [];
1048
+ for (const [group, parameters] of groups) {
1049
+ if (parameters.length === 0) continue;
1050
+ const required = parameters.some(parameter => parameter.required);
1051
+ const properties = parameters.map(parameter => {
1052
+ const type = this.#parameterType(operation, parameter, stem);
1053
+ return (
1054
+ `readonly ${propertyName(parameter.property)}${parameter.required ? '' : '?'}: ` +
1055
+ `${type}${parameter.required ? '' : ' | undefined'};`
1056
+ );
1057
+ });
1058
+ fields.push(`readonly ${group}${required ? '' : '?'}: { ${properties.join(' ')} };`);
1059
+ }
1060
+ if (operation.requestBody !== undefined) {
1061
+ const body = operation.requestBody;
1062
+ let type: string;
1063
+ if (isJsonBody(body)) {
1064
+ const node = this.#node(body.typeId, `${operation.operationId}.requestBody`);
1065
+ assertJsonType(node, `${operation.operationId}.requestBody`, definitionsOf(node));
1066
+ type = this.#declareType(body.typeId, `${stem}RequestBody`);
1067
+ } else {
1068
+ type = bodyType(body, undefined);
1069
+ }
1070
+ fields.push(`readonly body${body.required ? '' : '?'}: ${type}${body.required ? '' : ' | undefined'};`);
1071
+ }
1072
+ const name = this.#uniqueName(`${stem}Input`);
1073
+ this.#declarations.push(
1074
+ fields.length === 0
1075
+ ? `export type ${name} = Readonly<Record<never, never>>;`
1076
+ : `export interface ${name} { ${fields.join(' ')} }`,
1077
+ );
1078
+ return name;
1079
+ }
1080
+
1081
+ #resultTypeForVersion(operation: HttpOperationIR, responses: readonly ResponseModel[], version: string): string {
1082
+ const successes = responses.filter(model => model.response.status >= 200 && model.response.status < 300);
1083
+ if (successes.length === 0) return 'never';
1084
+ const variants = successes.map(model => {
1085
+ const body = model.bodies.get(version);
1086
+ if (body === undefined) {
1087
+ throw operationError(operation.operationId, 'responses', `missing version ${version}`);
1088
+ }
1089
+ if (successes.length === 1 && model.response.headers.length === 0) return body.type;
1090
+ if (successes.length === 1) {
1091
+ return `{ readonly body: ${body.type}; readonly headers: ${model.headersType}; }`;
1092
+ }
1093
+ return (
1094
+ `{ readonly status: ${String(model.response.status)}; readonly body: ${body.type}; ` +
1095
+ `readonly headers: ${model.headersType}; }`
1096
+ );
1097
+ });
1098
+ return variants.join(' | ');
1099
+ }
1100
+
1101
+ #resultTypes(
1102
+ operation: HttpOperationIR,
1103
+ stem: string,
1104
+ responses: readonly ResponseModel[],
1105
+ ): { readonly result: string; readonly byVersion: ReadonlyMap<string, string> } {
1106
+ const byVersion = new Map<string, string>();
1107
+ if (operation.version.kind !== 'media-type') {
1108
+ const name = this.#uniqueName(`${stem}Result`);
1109
+ this.#declarations.push(`export type ${name} = ${this.#resultTypeForVersion(operation, responses, '')};`);
1110
+ byVersion.set('', name);
1111
+ return { result: name, byVersion };
1112
+ }
1113
+
1114
+ for (const version of operation.version.values) {
1115
+ const name = this.#uniqueName(`${stem}ResultV${typeToken(version)}`);
1116
+ this.#declarations.push(`export type ${name} = ${this.#resultTypeForVersion(operation, responses, version)};`);
1117
+ byVersion.set(version, name);
1118
+ }
1119
+ const result = this.#uniqueName(`${stem}Result`);
1120
+ this.#declarations.push(`export type ${result} = ${[...byVersion.values()].join(' | ')};`);
1121
+ return { result, byVersion };
1122
+ }
1123
+
1124
+ #errorType(operation: HttpOperationIR, stem: string, responses: readonly ResponseModel[]): void {
1125
+ const errors = responses.filter(model => model.response.status < 200 || model.response.status >= 300);
1126
+ if (errors.length === 0) return;
1127
+ const members: string[] = [];
1128
+ for (const model of errors) {
1129
+ for (const version of versionKeys(operation)) {
1130
+ const body = model.bodies.get(version);
1131
+ if (body === undefined) continue;
1132
+ members.push(`ClientResponseError<${String(model.response.status)}, ${body.type}, ${model.headersType}>`);
1133
+ }
1134
+ }
1135
+ const unique = [...new Set(members)];
1136
+ const name = this.#uniqueName(`${stem}Error`);
1137
+ const guard = this.#uniqueName(`is${stem}Error`);
1138
+ const statuses = [...new Set(errors.map(model => model.response.status))];
1139
+ this.#declarations.push(
1140
+ `export type ${name} = ${unique.join(' | ')};`,
1141
+ `export function ${guard}(error: unknown): error is ${name} { ` +
1142
+ `return error instanceof ClientResponseError && error.operationId === ${JSON.stringify(operation.operationId)} ` +
1143
+ `&& [${statuses.join(', ')}].includes(error.status); }`,
1144
+ );
1145
+ }
1146
+
1147
+ #callOptions(operation: HttpOperationIR, stem: string): string {
1148
+ if (operation.version.kind !== 'header' && operation.version.kind !== 'media-type') return 'CallOptions';
1149
+ const name = this.#uniqueName(`${stem}CallOptions`);
1150
+ const values = operation.version.values.map(value => literal(value)).join(' | ');
1151
+ this.#declarations.push(`export type ${name} = CallOptions & { readonly version?: ${values}; };`);
1152
+ return name;
1153
+ }
1154
+
1155
+ #operationModel(operation: HttpOperationIR): OperationModel {
1156
+ if (!IDENTIFIER.test(operation.operationId) || RESERVED_IDENTIFIERS.has(operation.operationId)) {
1157
+ throw operationError(
1158
+ operation.operationId,
1159
+ 'operationId',
1160
+ 'must be a non-reserved TypeScript identifier; rename the operation in the HTTP contract',
1161
+ );
1162
+ }
1163
+ this.#assertVersion(operation);
1164
+ const stem = operationStem(operation.operationId);
1165
+ if (this.#usedStems.has(stem)) {
1166
+ throw operationError(
1167
+ operation.operationId,
1168
+ 'operationId',
1169
+ `collides with another generated type stem ${stem}; rename one operation`,
1170
+ );
1171
+ }
1172
+ this.#usedStems.add(stem);
1173
+ const inputType = this.#inputType(operation, stem);
1174
+ const responses = this.#responseModels(operation, stem);
1175
+ const results = this.#resultTypes(operation, stem, responses);
1176
+ this.#errorType(operation, stem, responses);
1177
+ const callOptionsType = this.#callOptions(operation, stem);
1178
+ return {
1179
+ operation,
1180
+ stem,
1181
+ inputType,
1182
+ resultType: results.result,
1183
+ resultTypesByVersion: results.byVersion,
1184
+ callOptionsType,
1185
+ responses,
1186
+ };
1187
+ }
1188
+
1189
+ #assertVersion(operation: HttpOperationIR): void {
1190
+ const version = operation.version;
1191
+ if (version.kind === 'path') {
1192
+ if (version.value.length === 0) {
1193
+ throw operationError(operation.operationId, 'version.value', 'must not be empty');
1194
+ }
1195
+ return;
1196
+ }
1197
+ if (version.kind !== 'header' && version.kind !== 'media-type') return;
1198
+ if (version.values.length === 0) {
1199
+ throw operationError(operation.operationId, 'version.values', 'must contain at least one version');
1200
+ }
1201
+ const seen = new Set<string>();
1202
+ for (const value of version.values) {
1203
+ if (value.length === 0) {
1204
+ throw operationError(operation.operationId, 'version.values', 'contains an empty version');
1205
+ }
1206
+ if (seen.has(value)) {
1207
+ throw operationError(operation.operationId, 'version.values', `contains duplicate ${literal(value)}`);
1208
+ }
1209
+ seen.add(value);
1210
+ }
1211
+ if (!seen.has(version.default)) {
1212
+ throw operationError(
1213
+ operation.operationId,
1214
+ 'version.default',
1215
+ `${literal(version.default)} is not one of the declared values`,
1216
+ );
1217
+ }
1218
+ const wireName = version.kind === 'header' ? version.name : version.key;
1219
+ if (!HTTP_TOKEN.test(wireName)) {
1220
+ throw operationError(
1221
+ operation.operationId,
1222
+ version.kind === 'header' ? 'version.name' : 'version.key',
1223
+ 'must be a non-empty HTTP token',
1224
+ );
1225
+ }
1226
+ }
1227
+
1228
+ #acceptHeader(model: OperationModel): string | undefined {
1229
+ const byVersion = new Map<string, string>();
1230
+ for (const version of versionKeys(model.operation)) {
1231
+ const mediaTypes: string[] = [];
1232
+ for (const response of model.responses) {
1233
+ const body = response.bodies.get(version)?.body;
1234
+ if (body !== undefined && body.kind !== 'empty') {
1235
+ const mediaType = versionedMediaType(model.operation, body, version);
1236
+ if (!mediaTypes.includes(mediaType)) mediaTypes.push(mediaType);
1237
+ }
1238
+ }
1239
+ if (mediaTypes.length > 0) byVersion.set(version, mediaTypes.join(', '));
1240
+ }
1241
+ if (byVersion.size === 0) return undefined;
1242
+ if (model.operation.version.kind !== 'media-type') return literal(byVersion.get('') ?? '');
1243
+ const fallback = literal(byVersion.get(model.operation.version.default) ?? '');
1244
+ return [...byVersion]
1245
+ .toReversed()
1246
+ .reduce(
1247
+ (otherwise, [version, value]) => `version === ${literal(version)} ? ${literal(value)} : ${otherwise}`,
1248
+ fallback,
1249
+ );
1250
+ }
1251
+
1252
+ #prepare(model: OperationModel): string {
1253
+ const operation = model.operation;
1254
+ const lines = [
1255
+ `let path = ${JSON.stringify(operation.path)};`,
1256
+ 'const query: { name: string; value: string }[] = [];',
1257
+ ];
1258
+ lines.push(`const headers: Record<string, string> = {};`, `const cookies: { name: string; value: string }[] = [];`);
1259
+ for (const parameter of operation.parameters) {
1260
+ const group = parameter.in === 'header' ? 'headers' : parameter.in === 'cookie' ? 'cookies' : parameter.in;
1261
+ const access = propertyAccess(`input.${group}`, parameter.property, !parameter.required);
1262
+ const node = this.#node(parameter.typeId, `${operation.operationId}.parameters.${parameter.property}`);
1263
+ if (parameter.in === 'path') {
1264
+ lines.push(`path = substituteClientPath(path, ${JSON.stringify(parameter.name)}, ${access});`);
1265
+ continue;
1266
+ }
1267
+ if (parameter.in === 'query') {
1268
+ if (node.kind === 'array') {
1269
+ lines.push(
1270
+ `if (${access} !== undefined) { for (const value of ${access}) ` +
1271
+ `query.push({ name: ${JSON.stringify(parameter.name)}, value: stringifyClientScalar(value) }); }`,
1272
+ );
1273
+ } else {
1274
+ lines.push(
1275
+ `if (${access} !== undefined) query.push({ name: ${JSON.stringify(parameter.name)}, ` +
1276
+ `value: stringifyClientScalar(${access}) });`,
1277
+ );
1278
+ }
1279
+ continue;
1280
+ }
1281
+ if (parameter.in === 'header') {
1282
+ lines.push(
1283
+ `if (${access} !== undefined) headers[${JSON.stringify(parameter.name)}] = stringifyClientScalar(${access});`,
1284
+ );
1285
+ continue;
1286
+ }
1287
+ lines.push(
1288
+ `if (${access} !== undefined) cookies.push({ name: ${JSON.stringify(parameter.name)}, ` +
1289
+ `value: stringifyClientScalar(${access}) });`,
1290
+ );
1291
+ }
1292
+
1293
+ if (operation.version.kind === 'header') {
1294
+ lines.push(
1295
+ `headers[${JSON.stringify(operation.version.name)}] = version ?? ${literal(operation.version.default)};`,
1296
+ );
1297
+ }
1298
+ const accept = this.#acceptHeader(model);
1299
+ if (accept !== undefined) lines.push(`headers.accept = ${accept};`);
1300
+ if (operation.requestBody !== undefined) {
1301
+ const body = operation.requestBody;
1302
+ lines.push(
1303
+ `const body = ${body.required ? '' : 'input.body === undefined ? undefined : '}` +
1304
+ `prepareClientBody(${literal(body.kind)}, input.body);`,
1305
+ );
1306
+ lines.push(
1307
+ body.required
1308
+ ? `headers['content-type'] = ${literal(body.mediaType)};`
1309
+ : `if (body !== undefined) headers['content-type'] = ${literal(body.mediaType)};`,
1310
+ );
1311
+ lines.push(`return { path, query, headers, cookies, ...(body === undefined ? {} : { body }) };`);
1312
+ } else {
1313
+ lines.push('return { path, query, headers, cookies };');
1314
+ }
1315
+ return `prepare(input, version) { ${lines.join(' ')} }`;
1316
+ }
1317
+
1318
+ #readBody(operation: HttpOperationIR, model: BodyModel, version: string): string {
1319
+ const mediaType = model.body.kind === 'empty' ? undefined : versionedMediaType(operation, model.body, version);
1320
+ if (isJsonBody(model.body)) {
1321
+ if (model.validator === undefined) throw generationError('response', 'JSON body has no validator');
1322
+ if (mediaType === undefined) throw generationError('response', 'JSON body has no media type');
1323
+ return `const body = await response.body.json<${model.type}>(${literal(mediaType)}, ${model.validator});`;
1324
+ }
1325
+ if (isTextBody(model.body)) {
1326
+ if (mediaType === undefined) throw generationError('response', 'text body has no media type');
1327
+ return `const body = await response.body.text(${literal(mediaType)});`;
1328
+ }
1329
+ if (model.body.kind === 'bytes') {
1330
+ if (mediaType === undefined) throw generationError('response', 'bytes body has no media type');
1331
+ return `const body = await response.body.bytes(${literal(mediaType)});`;
1332
+ }
1333
+ if (model.body.kind === 'stream') {
1334
+ if (mediaType === undefined) throw generationError('response', 'stream body has no media type');
1335
+ return `const body = response.body.stream(${literal(mediaType)});`;
1336
+ }
1337
+ return 'await response.body.empty(); const body: void = undefined;';
1338
+ }
1339
+
1340
+ #readHeaders(operation: HttpOperationIR, model: ResponseModel): string {
1341
+ if (model.headersDecoder === undefined) return 'const headers = {};';
1342
+ return (
1343
+ `const decodedHeaders = ${model.headersDecoder}(response.headers); ` +
1344
+ `if (!decodedHeaders.ok) throw new ResponseValidationError(${literal(operation.operationId)}, ` +
1345
+ `${String(model.response.status)}, decodedHeaders.issues); const headers = decodedHeaders.value;`
1346
+ );
1347
+ }
1348
+
1349
+ #successfulValue(model: OperationModel, response: ResponseModel): string {
1350
+ const successes = model.responses.filter(
1351
+ candidate => candidate.response.status >= 200 && candidate.response.status < 300,
1352
+ );
1353
+ if (successes.length === 1 && response.response.headers.length === 0) return 'return body;';
1354
+ if (successes.length === 1) return 'return { body, headers };';
1355
+ return `return { status: ${String(response.response.status)}, body, headers };`;
1356
+ }
1357
+
1358
+ #responseBranch(model: OperationModel, response: ResponseModel, version: string): string {
1359
+ const body = response.bodies.get(version);
1360
+ if (body === undefined) {
1361
+ throw operationError(model.operation.operationId, 'responses', `missing response version ${version}`);
1362
+ }
1363
+ const lines = [this.#readHeaders(model.operation, response), this.#readBody(model.operation, body, version)];
1364
+ if (response.response.status >= 200 && response.response.status < 300) {
1365
+ lines.push(this.#successfulValue(model, response));
1366
+ } else {
1367
+ lines.push(
1368
+ `throw new ClientResponseError(${literal(model.operation.operationId)}, ${String(response.response.status)}, ` +
1369
+ `body, headers);`,
1370
+ );
1371
+ }
1372
+ return lines.join(' ');
1373
+ }
1374
+
1375
+ #read(model: OperationModel): string {
1376
+ const cases = model.responses.map(response => {
1377
+ if (model.operation.version.kind !== 'media-type') {
1378
+ return `case ${String(response.response.status)}: { ${this.#responseBranch(model, response, '')} }`;
1379
+ }
1380
+ const branches = model.operation.version.values.map(
1381
+ version => `if (version === ${literal(version)}) { ${this.#responseBranch(model, response, version)} }`,
1382
+ );
1383
+ branches.push(
1384
+ `throw new ClientRequestError(${literal(`Operation ${model.operation.operationId} has no selected response version`)}, ` +
1385
+ `{ operationId: ${literal(model.operation.operationId)} });`,
1386
+ );
1387
+ return `case ${String(response.response.status)}: { ${branches.join(' ')} }`;
1388
+ });
1389
+ return (
1390
+ `async read(response, version) { switch (response.status) { ${cases.join(' ')} ` +
1391
+ `default: return response.unexpectedStatus(); } }`
1392
+ );
1393
+ }
1394
+
1395
+ #schemes(operation: HttpOperationIR): Readonly<Record<string, Readonly<Record<string, string>>>> {
1396
+ const names = new Set(operation.security.flatMap(requirement => Object.keys(requirement)));
1397
+ const schemes: Record<string, Readonly<Record<string, string>>> = {};
1398
+ for (const name of [...names].toSorted()) {
1399
+ const scheme = this.#contract.securitySchemes[name];
1400
+ if (scheme === undefined) {
1401
+ throw operationError(operation.operationId, 'security', `references unknown scheme ${name}`);
1402
+ }
1403
+ schemes[name] = clientSecurityScheme(scheme);
1404
+ }
1405
+ return schemes;
1406
+ }
1407
+
1408
+ #versionPlan(operation: HttpOperationIR): Readonly<Record<string, unknown>> {
1409
+ if (operation.version.kind === 'header' || operation.version.kind === 'media-type') {
1410
+ return {
1411
+ kind: operation.version.kind,
1412
+ values: operation.version.values,
1413
+ default: operation.version.default,
1414
+ };
1415
+ }
1416
+ return { kind: 'none' };
1417
+ }
1418
+
1419
+ #operationBlock(model: OperationModel): string {
1420
+ const name = `_operation${model.stem}`;
1421
+ return (
1422
+ `// operation ${model.operation.operationId}\n` +
1423
+ `const ${name}: GeneratedOperation<${model.inputType}, ${model.resultType}> = ` +
1424
+ `Object.freeze<GeneratedOperation<${model.inputType}, ${model.resultType}>>({ ` +
1425
+ `abi: 1, operationId: ${literal(model.operation.operationId)}, method: ${literal(model.operation.method)}, ` +
1426
+ `security: ${stableJson(model.operation.security)}, schemes: ${stableJson(this.#schemes(model.operation))}, ` +
1427
+ `version: ${stableJson(this.#versionPlan(model.operation))}, ${this.#prepare(model)}, ${this.#read(model)} });\n` +
1428
+ `// end operation ${model.operation.operationId}`
1429
+ );
1430
+ }
1431
+
1432
+ #methodSignatures(model: OperationModel): readonly string[] {
1433
+ const method = model.operation.operationId;
1434
+ if (model.operation.version.kind !== 'media-type') {
1435
+ return [
1436
+ `${method}(input: ${model.inputType}, options?: ${model.callOptionsType}): Promise<${model.resultType}>;`,
1437
+ ];
1438
+ }
1439
+ const signatures = model.operation.version.values.map(version => {
1440
+ const result = model.resultTypesByVersion.get(version);
1441
+ if (result === undefined) {
1442
+ throw operationError(model.operation.operationId, 'version', `missing result type for ${version}`);
1443
+ }
1444
+ return (
1445
+ `${method}(input: ${model.inputType}, options: CallOptions & { readonly version: ${literal(version)} }): ` +
1446
+ `Promise<${result}>;`
1447
+ );
1448
+ });
1449
+ const defaultResult = model.resultTypesByVersion.get(model.operation.version.default);
1450
+ if (defaultResult === undefined) {
1451
+ throw operationError(
1452
+ model.operation.operationId,
1453
+ 'version',
1454
+ `default ${model.operation.version.default} has no generated result`,
1455
+ );
1456
+ }
1457
+ signatures.push(
1458
+ `${method}(input: ${model.inputType}, options?: CallOptions & ` +
1459
+ `{ readonly version?: ${literal(model.operation.version.default)} }): Promise<${defaultResult}>;`,
1460
+ );
1461
+ return signatures;
1462
+ }
1463
+
1464
+ #apiInterface(models: readonly OperationModel[]): string {
1465
+ const methods = models.flatMap(model => [
1466
+ ...(model.operation.deprecated ? ['/** @deprecated */'] : []),
1467
+ ...this.#methodSignatures(model),
1468
+ ]);
1469
+ return `export interface ApiClient { ${methods.join(' ')} }`;
1470
+ }
1471
+
1472
+ #factoryMethod(model: OperationModel): string {
1473
+ const operation = `_operation${model.stem}`;
1474
+ const method = model.operation.operationId;
1475
+ const local = `_call${model.stem}`;
1476
+ if (model.operation.version.kind !== 'media-type') {
1477
+ return (
1478
+ `function ${local}(input: ${model.inputType}, callOptions?: ${model.callOptionsType}): ` +
1479
+ `Promise<${model.resultType}> { return runtime.call(${operation}, input, callOptions); }`
1480
+ );
1481
+ }
1482
+ const overloads = this.#methodSignatures(model).map(
1483
+ signature => `function ${signature.replace(`${method}(`, `${local}(`)}`,
1484
+ );
1485
+ return (
1486
+ `${overloads.join(' ')} ` +
1487
+ `function ${local}(input: ${model.inputType}, callOptions?: ${model.callOptionsType}): ` +
1488
+ `Promise<${model.resultType}> { return runtime.call(${operation}, input, callOptions); }`
1489
+ );
1490
+ }
1491
+
1492
+ #factory(models: readonly OperationModel[]): string {
1493
+ const functions = models.map(model => this.#factoryMethod(model)).join(' ');
1494
+ const members = models.map(model => `${propertyName(model.operation.operationId)}: _call${model.stem}`).join(', ');
1495
+ return (
1496
+ `export function createApiClient(options: ClientOptions): ApiClient { ` +
1497
+ `const runtime = createClientRuntime(options); ${functions} return { ${members} }; }`
1498
+ );
1499
+ }
1500
+
1501
+ #sourceMap(source: string): string {
1502
+ const lines = source.endsWith('\n') ? source.slice(0, -1).split('\n').length : source.split('\n').length;
1503
+ const map = {
1504
+ version: 3,
1505
+ file: 'http-client.generated.ts',
1506
+ sources: ['zmdb:http-contract'],
1507
+ sourcesContent: [stableJson(sourceContract(this.#contract, new Set(this.#typeNames.keys())))],
1508
+ names: [],
1509
+ mappings: Array.from({ length: lines }, () => 'AAAA').join(';'),
1510
+ };
1511
+ return `${JSON.stringify(map)}\n`;
1512
+ }
1513
+ }
1514
+
1515
+ /** Render one deterministic typed client module and its path-independent source map. */
1516
+ export function generateHttpClient(contract: HttpContractIR): GeneratedHttpClientModule {
1517
+ return new ModuleGenerator(contract).generate();
1518
+ }