@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,1457 @@
1
+ // @zmdb/web/contract/compiler — build-time HTTP contract collection.
2
+ //
3
+ // The syntax walk below reads only the static declaration wrapper: exported
4
+ // contract name, operation keys, and each httpOperation<T>() type argument. Every
5
+ // question about what T means is delegated to the existing AOT Reflector. This
6
+ // module accepts a caller-owned ReflectSession and never opens or closes one.
7
+
8
+ import { resolve } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ import { Reflector, type ReflectSession } from '@zmdb/compiler/reflect';
12
+ import { jsonSchemaFromTypeIR, type TypeIR } from '@zmdb/schema/ir';
13
+ import type {
14
+ Expression,
15
+ Node,
16
+ ObjectLiteralExpression,
17
+ SourceFile,
18
+ TypeNode,
19
+ VariableDeclaration,
20
+ } from 'typescript/unstable/ast';
21
+ import {
22
+ isArrayLiteralExpression,
23
+ isAsExpression,
24
+ isBooleanLiteral,
25
+ isCallExpression,
26
+ isIdentifier,
27
+ isNullLiteral,
28
+ isNumericLiteral,
29
+ isObjectLiteralExpression,
30
+ isParenthesizedExpression,
31
+ isPropertyAccessExpression,
32
+ isPropertyAssignment,
33
+ isSatisfiesExpression,
34
+ isStringLiteral,
35
+ isVariableDeclaration,
36
+ } from 'typescript/unstable/ast/is';
37
+ import { SymbolFlags } from 'typescript/unstable/sync';
38
+ import type { Checker, Type } from 'typescript/unstable/sync';
39
+
40
+ import { getRoutes, isPublic } from '../../routing/index.js';
41
+ import { versionsOf } from '../../versioning/index.js';
42
+ import {
43
+ type CompiledHttpContract,
44
+ type CompiledHttpOperation,
45
+ type HttpBodyDeclaration,
46
+ type HttpBodyIR,
47
+ type HttpContractDeclaration,
48
+ type HttpContractIR,
49
+ type HttpOperationDeclaration,
50
+ type HttpOperationIR,
51
+ type HttpParameterIR,
52
+ type HttpRequestBodyIR,
53
+ type HttpResponseHeaderIR,
54
+ type HttpResponseIR,
55
+ type HttpTypeIR,
56
+ type HttpVersionIR,
57
+ type OAuthFlows,
58
+ type SecurityRequirement,
59
+ type SecurityScheme,
60
+ } from '../index.js';
61
+
62
+ export { generateHttpClient, HTTP_CLIENT_GENERATOR_VERSION, type GeneratedHttpClientModule } from './client.js';
63
+
64
+ export interface HttpContractSource {
65
+ /** File path or file URL containing the exported declaration. */
66
+ readonly file: string | URL;
67
+ /** Exported `const` whose initializer is `defineHttpContract({ ... })`. */
68
+ readonly exportName: string;
69
+ /** The inert value imported from that export. */
70
+ readonly contract: HttpContractDeclaration;
71
+ }
72
+
73
+ export interface CompileHttpContractsOptions {
74
+ /** One caller-owned compiler session for the whole build. */
75
+ readonly session: ReflectSession;
76
+ }
77
+
78
+ interface StaticOperation {
79
+ readonly operationId: string;
80
+ readonly type: TypeNode;
81
+ }
82
+
83
+ interface TypeProperty {
84
+ readonly name: string;
85
+ readonly type: Type;
86
+ readonly optional: boolean;
87
+ }
88
+
89
+ const ROOT_KEYS = new Set(['path', 'query', 'headers', 'cookies', 'body', 'responses']);
90
+ const HTTP_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
91
+
92
+ /** Compile and compose one deterministic contract IR from explicit modules. */
93
+ export function compileHttpContracts(
94
+ sources: readonly HttpContractSource[],
95
+ options: CompileHttpContractsOptions,
96
+ ): CompiledHttpContract {
97
+ if (sources.length === 0) {
98
+ throw new Error('HTTP contract compiler: at least one contract source is required');
99
+ }
100
+
101
+ const types: Record<string, HttpTypeIR> = {};
102
+ const operations: CompiledHttpOperation[] = [];
103
+ const operationIds = new Set<string>();
104
+ const sortedSources = [...sources].toSorted((left, right) =>
105
+ sourcePath(left.file).localeCompare(sourcePath(right.file)),
106
+ );
107
+ const securitySchemes = composeSecuritySchemes(sortedSources);
108
+
109
+ for (const source of sortedSources) {
110
+ const file = sourcePath(source.file);
111
+ const sourceFile = options.session.sourceFile(file);
112
+ if (sourceFile === undefined) {
113
+ throw new Error(`HTTP contract compiler: ${file} is not part of ${options.session.project}`);
114
+ }
115
+ const diagnostics = options.session.diagnostics(file);
116
+ if (diagnostics.length > 0) {
117
+ const first = diagnostics[0];
118
+ throw new Error(
119
+ `HTTP contract compiler: ${file} does not typecheck ` +
120
+ `(TS${String(first?.code ?? 0)}: ${first?.text ?? 'unknown diagnostic'})`,
121
+ );
122
+ }
123
+
124
+ assertExported(sourceFile, source.exportName, options.session.checker);
125
+ const staticOperations = operationsFromSource(sourceFile, source.exportName);
126
+ const runtimeIds = Object.keys(source.contract.operations);
127
+ const staticIds = staticOperations.map(operation => operation.operationId);
128
+ if (!sameStrings(runtimeIds, staticIds)) {
129
+ throw new Error(
130
+ `HTTP contract compiler: ${source.exportName}.operations must be one static object literal. ` +
131
+ `Source keys are [${staticIds.join(', ')}], runtime keys are [${runtimeIds.join(', ')}]`,
132
+ );
133
+ }
134
+
135
+ const reflector = new Reflector(options.session.checker, sourceFile);
136
+ for (const staticOperation of staticOperations) {
137
+ const declaration = source.contract.operations[staticOperation.operationId];
138
+ if (declaration === undefined) {
139
+ throw new Error(
140
+ `HTTP contract compiler: operation ${staticOperation.operationId} exists in source but not at runtime`,
141
+ );
142
+ }
143
+ if (operationIds.has(staticOperation.operationId)) {
144
+ throw operationError(staticOperation.operationId, 'operationId', 'appears more than once');
145
+ }
146
+ operationIds.add(staticOperation.operationId);
147
+
148
+ const generic = options.session.checker.getTypeFromTypeNode(staticOperation.type);
149
+ if (generic === undefined || generic.isErrorType()) {
150
+ throw operationError(
151
+ staticOperation.operationId,
152
+ 'type',
153
+ 'the compiler could not resolve the httpOperation<T>() type argument',
154
+ );
155
+ }
156
+
157
+ const operation = compileOperation(
158
+ staticOperation.operationId,
159
+ declaration,
160
+ generic,
161
+ sourceFile,
162
+ options.session.checker,
163
+ reflector,
164
+ types,
165
+ securitySchemes,
166
+ );
167
+ const conflicting = operations.find(binding => finalRoutesCollide(binding.operation, operation));
168
+ if (conflicting !== undefined) {
169
+ throw operationError(
170
+ operation.operationId,
171
+ 'method/path/version',
172
+ `${operation.method} ${operation.path} overlaps operation ${conflicting.operation.operationId}`,
173
+ );
174
+ }
175
+ operations.push({
176
+ operation,
177
+ controller: declaration.controller,
178
+ handler: declaration.handler,
179
+ });
180
+ }
181
+ }
182
+
183
+ const sortedOperations = operations.toSorted((left, right) =>
184
+ left.operation.operationId.localeCompare(right.operation.operationId),
185
+ );
186
+ assertCompatibleVersionStrategies(sortedOperations);
187
+ const ir: HttpContractIR = {
188
+ format: 1,
189
+ types: sortRecord(types),
190
+ operations: sortedOperations.map(binding => binding.operation),
191
+ securitySchemes,
192
+ };
193
+ return deepFreeze({
194
+ ir,
195
+ operations: sortedOperations,
196
+ dependencies: contractDependencies(sortedSources, options.session),
197
+ });
198
+ }
199
+
200
+ function contractDependencies(sources: readonly HttpContractSource[], session: ReflectSession): readonly string[] {
201
+ const pending = sources.map(source => sourcePath(source.file));
202
+ const dependencies = new Set<string>();
203
+
204
+ while (pending.length > 0) {
205
+ const path = pending.pop();
206
+ if (path === undefined || dependencies.has(path)) continue;
207
+ const source = session.sourceFile(path);
208
+ if (source === undefined || source.isDeclarationFile) continue;
209
+ const metadata = session.program.getSourceFileMetadata(path);
210
+ if (metadata?.isDefaultLibrary === true || metadata?.isFromExternalLibrary === true) continue;
211
+
212
+ dependencies.add(path);
213
+ for (const imported of source.imports) {
214
+ if (!isRelativeModuleSpecifier(imported)) continue;
215
+ const symbol = session.checker.getSymbolAtLocation(imported);
216
+ for (const declaration of symbol?.declarations ?? []) {
217
+ const importedPath = declarationSourcePath(declaration);
218
+ if (importedPath !== undefined && !dependencies.has(importedPath)) pending.push(importedPath);
219
+ }
220
+ }
221
+ }
222
+
223
+ return [...dependencies].toSorted();
224
+ }
225
+
226
+ function isRelativeModuleSpecifier(node: unknown): boolean {
227
+ const value = objectValue(node);
228
+ if (value === undefined) return false;
229
+ const text = Reflect.get(value, 'text');
230
+ return typeof text === 'string' && /^\.{1,2}\//u.test(text);
231
+ }
232
+
233
+ function declarationSourcePath(node: unknown): string | undefined {
234
+ const seen = new Set<object>();
235
+ let current = objectValue(node);
236
+ while (current !== undefined && !seen.has(current)) {
237
+ seen.add(current);
238
+ const fileName = Reflect.get(current, 'fileName');
239
+ if (typeof fileName === 'string') return resolve(fileName);
240
+ const path = Reflect.get(current, 'path');
241
+ if (typeof path === 'string') return resolve(path);
242
+ current = objectValue(Reflect.get(current, 'parent'));
243
+ }
244
+ return undefined;
245
+ }
246
+
247
+ function objectValue(value: unknown): object | undefined {
248
+ return (typeof value === 'object' && value !== null) || typeof value === 'function' ? value : undefined;
249
+ }
250
+
251
+ function assertCompatibleVersionStrategies(operations: readonly CompiledHttpOperation[]): void {
252
+ let unversioned: HttpOperationIR | undefined;
253
+ let neutral: HttpOperationIR | undefined;
254
+ let selected: HttpOperationIR | undefined;
255
+
256
+ for (const binding of operations) {
257
+ const operation = binding.operation;
258
+ if (operation.version.kind === 'none') {
259
+ unversioned ??= operation;
260
+ continue;
261
+ }
262
+ if (operation.version.kind === 'neutral') {
263
+ neutral ??= operation;
264
+ continue;
265
+ }
266
+ if (selected === undefined) {
267
+ selected = operation;
268
+ continue;
269
+ }
270
+ if (!sameVersionStrategy(selected.version, operation.version)) {
271
+ throw operationError(
272
+ operation.operationId,
273
+ 'version',
274
+ `does not use the same router strategy as operation ${selected.operationId}`,
275
+ );
276
+ }
277
+ }
278
+
279
+ if (unversioned !== undefined && (neutral !== undefined || selected !== undefined)) {
280
+ const conflicting = neutral ?? selected;
281
+ if (conflicting !== undefined) {
282
+ throw operationError(
283
+ conflicting.operationId,
284
+ 'version',
285
+ `cannot share one router with unversioned operation ${unversioned.operationId}`,
286
+ );
287
+ }
288
+ }
289
+ }
290
+
291
+ function sameVersionStrategy(left: HttpVersionIR, right: HttpVersionIR): boolean {
292
+ if (left.kind !== right.kind) return false;
293
+ if (left.kind === 'header') {
294
+ return right.kind === 'header' && left.name === right.name && left.default === right.default;
295
+ }
296
+ if (left.kind === 'media-type') {
297
+ return right.kind === 'media-type' && left.key === right.key && left.default === right.default;
298
+ }
299
+ return left.kind === 'path' && right.kind === 'path';
300
+ }
301
+
302
+ function compileOperation(
303
+ operationId: string,
304
+ declaration: HttpOperationDeclaration,
305
+ generic: Type,
306
+ sourceFile: SourceFile,
307
+ checker: Checker,
308
+ reflector: Reflector,
309
+ types: Record<string, HttpTypeIR>,
310
+ schemes: Readonly<Record<string, SecurityScheme>>,
311
+ ): HttpOperationIR {
312
+ const controllerName = declaration.controller.name;
313
+ if (controllerName.length === 0) {
314
+ throw operationError(operationId, 'controller', 'has no stable class name');
315
+ }
316
+ if (!callableHandler(declaration.controller, declaration.handler)) {
317
+ throw operationError(
318
+ operationId,
319
+ 'handler',
320
+ `${controllerName}.${declaration.handler} is missing or is not callable`,
321
+ );
322
+ }
323
+
324
+ const path = normalizePath(declaration.path);
325
+ assertLegacyRoute(operationId, declaration, path);
326
+
327
+ const root = propertiesOf(generic, sourceFile, checker, operationId, 'type');
328
+ for (const key of root.keys()) {
329
+ if (!ROOT_KEYS.has(key)) {
330
+ throw operationError(operationId, 'type', `property "${key}" is not an HTTP contract group`);
331
+ }
332
+ }
333
+
334
+ const groups = {
335
+ path: groupProperties(root, 'path', sourceFile, checker, operationId),
336
+ query: groupProperties(root, 'query', sourceFile, checker, operationId),
337
+ header: groupProperties(root, 'headers', sourceFile, checker, operationId),
338
+ cookie: groupProperties(root, 'cookies', sourceFile, checker, operationId),
339
+ } as const;
340
+
341
+ const parameters = compileParameters(operationId, declaration, path, groups, reflector, types);
342
+ const requestBody = compileRequestBody(operationId, declaration, root.get('body'), reflector, types, checker);
343
+ const version = canonicalVersion(operationId, declaration.version);
344
+ const responsesProperty = root.get('responses');
345
+ if (responsesProperty === undefined) {
346
+ throw operationError(operationId, 'responses', 'the generic type declares no responses object');
347
+ }
348
+ const responses = compileResponses(
349
+ operationId,
350
+ declaration,
351
+ responsesProperty.type,
352
+ sourceFile,
353
+ checker,
354
+ reflector,
355
+ types,
356
+ version,
357
+ );
358
+ const security = canonicalSecurity(operationId, declaration.security, schemes);
359
+ assertSecurityMatchesRoute(operationId, declaration, security);
360
+ assertSecurityWireCollisions(operationId, security, schemes, parameters, requestBody, responses, version);
361
+
362
+ return deepFreeze({
363
+ operationId,
364
+ controller: controllerName,
365
+ handler: declaration.handler,
366
+ method: declaration.method,
367
+ path,
368
+ parameters,
369
+ ...(requestBody === undefined ? {} : { requestBody }),
370
+ responses,
371
+ security,
372
+ version,
373
+ deprecated: declaration.deprecated,
374
+ });
375
+ }
376
+
377
+ function compileParameters(
378
+ operationId: string,
379
+ declaration: HttpOperationDeclaration,
380
+ path: string,
381
+ groups: Readonly<Record<'path' | 'query' | 'header' | 'cookie', ReadonlyMap<string, TypeProperty>>>,
382
+ reflector: Reflector,
383
+ types: Record<string, HttpTypeIR>,
384
+ ): readonly HttpParameterIR[] {
385
+ const claimed = new Set<string>();
386
+ const wireNames = new Set<string>();
387
+ const parameters: HttpParameterIR[] = [];
388
+
389
+ for (const parameter of declaration.parameters) {
390
+ const location = parameter.in;
391
+ const property = groups[location].get(parameter.property);
392
+ const field = `parameters.${location}.${parameter.property}`;
393
+ if (property === undefined) {
394
+ throw operationError(operationId, field, 'does not exist in the generic type');
395
+ }
396
+ const propertyKey = `${location}\u0000${parameter.property}`;
397
+ if (claimed.has(propertyKey)) {
398
+ throw operationError(operationId, field, 'is declared more than once');
399
+ }
400
+ claimed.add(propertyKey);
401
+
402
+ const name = canonicalParameterName(operationId, field, location, parameter.name);
403
+ const wireKey = `${location}\u0000${name}`;
404
+ if (wireNames.has(wireKey)) {
405
+ throw operationError(operationId, field, `wire name "${parameter.name}" is declared more than once`);
406
+ }
407
+ wireNames.add(wireKey);
408
+ if (location === 'path' && property.optional) {
409
+ throw operationError(operationId, field, 'a path parameter cannot be optional');
410
+ }
411
+
412
+ const typeId = `${operationId}/parameter/${location}/${parameter.property}`;
413
+ const node = reflectLocation(operationId, field, property.type, reflector);
414
+ assertParameterType(operationId, field, location, node);
415
+ addType(operationId, typeId, node, types);
416
+ parameters.push({
417
+ property: parameter.property,
418
+ name,
419
+ in: location,
420
+ required: !property.optional,
421
+ typeId,
422
+ });
423
+ }
424
+
425
+ for (const [location, properties] of Object.entries(groups)) {
426
+ for (const property of properties.values()) {
427
+ if (!claimed.has(`${location}\u0000${property.name}`)) {
428
+ throw operationError(
429
+ operationId,
430
+ `type.${location}.${property.name}`,
431
+ 'is not assigned to a parameter declaration',
432
+ );
433
+ }
434
+ }
435
+ }
436
+
437
+ const placeholders = pathPlaceholders(path);
438
+ const declaredPath = parameters.filter(parameter => parameter.in === 'path').map(parameter => parameter.name);
439
+ if (!sameStrings(placeholders, declaredPath)) {
440
+ throw operationError(
441
+ operationId,
442
+ 'path',
443
+ `placeholders [${placeholders.join(', ')}] do not match path parameters [${declaredPath.join(', ')}]`,
444
+ );
445
+ }
446
+
447
+ return parameters;
448
+ }
449
+
450
+ function canonicalParameterName(
451
+ operationId: string,
452
+ field: string,
453
+ location: 'path' | 'query' | 'header' | 'cookie',
454
+ name: string,
455
+ ): string {
456
+ const canonical = location === 'header' ? name.toLowerCase() : name;
457
+ if (canonical.length === 0) {
458
+ throw operationError(operationId, `${field}.name`, 'must not be empty');
459
+ }
460
+ if ((location === 'header' || location === 'cookie') && !HTTP_TOKEN.test(canonical)) {
461
+ throw operationError(operationId, `${field}.name`, `contains an invalid HTTP ${location} name`);
462
+ }
463
+ return canonical;
464
+ }
465
+
466
+ function compileRequestBody(
467
+ operationId: string,
468
+ declaration: HttpOperationDeclaration,
469
+ property: TypeProperty | undefined,
470
+ reflector: Reflector,
471
+ types: Record<string, HttpTypeIR>,
472
+ checker: Checker,
473
+ ): HttpRequestBodyIR | undefined {
474
+ const metadata = declaration.requestBody;
475
+ if (metadata === undefined && property === undefined) return undefined;
476
+ if (metadata === undefined) {
477
+ throw operationError(operationId, 'requestBody', 'the generic body type has no request-body declaration');
478
+ }
479
+ if (property === undefined) {
480
+ throw operationError(operationId, 'requestBody', 'is declared but the generic type has no body property');
481
+ }
482
+ if (metadata.required === property.optional) {
483
+ throw operationError(
484
+ operationId,
485
+ 'requestBody.required',
486
+ `is ${String(metadata.required)} but the generic body property is ${property.optional ? 'optional' : 'required'}`,
487
+ );
488
+ }
489
+
490
+ const body = compileBody(
491
+ operationId,
492
+ 'requestBody',
493
+ metadata,
494
+ property.type,
495
+ `${operationId}/request/body`,
496
+ reflector,
497
+ types,
498
+ checker,
499
+ );
500
+ if (body.kind === 'empty') {
501
+ throw operationError(operationId, 'requestBody.kind', 'cannot be empty');
502
+ }
503
+ return { ...body, required: metadata.required };
504
+ }
505
+
506
+ function compileResponses(
507
+ operationId: string,
508
+ declaration: HttpOperationDeclaration,
509
+ responseTypes: Type,
510
+ sourceFile: SourceFile,
511
+ checker: Checker,
512
+ reflector: Reflector,
513
+ types: Record<string, HttpTypeIR>,
514
+ version: HttpVersionIR,
515
+ ): readonly HttpResponseIR[] {
516
+ const declared = Object.entries(declaration.responses);
517
+ if (declared.length === 0) {
518
+ throw operationError(operationId, 'responses', 'must declare at least one exact status');
519
+ }
520
+ const generic = propertiesOf(responseTypes, sourceFile, checker, operationId, 'responses');
521
+ const statuses = new Set<string>();
522
+ const responses: HttpResponseIR[] = [];
523
+
524
+ for (const [statusText, metadata] of declared) {
525
+ const status = Number(statusText);
526
+ if (!Number.isInteger(status) || status < 200 || status > 599) {
527
+ throw operationError(operationId, `responses.${statusText}`, 'status must be an integer from 200 through 599');
528
+ }
529
+ const statusProperty = generic.get(statusText);
530
+ if (statusProperty === undefined) {
531
+ throw operationError(operationId, `responses.${statusText}`, 'has no matching generic response type');
532
+ }
533
+ statuses.add(statusText);
534
+ const fields = propertiesOf(statusProperty.type, sourceFile, checker, operationId, `responses.${statusText}`);
535
+ const bodyProperty = fields.get('body');
536
+ if (bodyProperty === undefined) {
537
+ throw operationError(operationId, `responses.${statusText}.body`, 'is missing from the generic response type');
538
+ }
539
+ for (const field of fields.keys()) {
540
+ if (field !== 'body' && field !== 'headers') {
541
+ throw operationError(
542
+ operationId,
543
+ `responses.${statusText}.${field}`,
544
+ 'is not a response body or headers group',
545
+ );
546
+ }
547
+ }
548
+
549
+ const body = compileBody(
550
+ operationId,
551
+ `responses.${statusText}.body`,
552
+ metadata.body,
553
+ bodyProperty.type,
554
+ `${operationId}/response/${statusText}/body`,
555
+ reflector,
556
+ types,
557
+ checker,
558
+ );
559
+ if ((status === 204 || status === 205 || status === 304) && body.kind !== 'empty') {
560
+ throw operationError(operationId, `responses.${statusText}.body`, `status ${statusText} must use an empty body`);
561
+ }
562
+
563
+ const headers = compileResponseHeaders(
564
+ operationId,
565
+ statusText,
566
+ metadata.headers ?? [],
567
+ fields.get('headers'),
568
+ sourceFile,
569
+ checker,
570
+ reflector,
571
+ types,
572
+ );
573
+ const versions = compileResponseVersions(
574
+ operationId,
575
+ statusText,
576
+ metadata.versions,
577
+ bodyProperty.type,
578
+ reflector,
579
+ types,
580
+ checker,
581
+ version,
582
+ );
583
+ responses.push({
584
+ status,
585
+ description: metadata.description,
586
+ headers,
587
+ body,
588
+ ...(versions === undefined ? {} : { versions }),
589
+ });
590
+ }
591
+
592
+ for (const status of generic.keys()) {
593
+ if (!statuses.has(status)) {
594
+ throw operationError(operationId, `responses.${status}`, 'exists in the generic type but not in metadata');
595
+ }
596
+ }
597
+ return responses.toSorted((left, right) => left.status - right.status);
598
+ }
599
+
600
+ function compileResponseHeaders(
601
+ operationId: string,
602
+ status: string,
603
+ declarations: readonly { readonly property: string; readonly name: string; readonly description?: string }[],
604
+ group: TypeProperty | undefined,
605
+ sourceFile: SourceFile,
606
+ checker: Checker,
607
+ reflector: Reflector,
608
+ types: Record<string, HttpTypeIR>,
609
+ ): readonly HttpResponseHeaderIR[] {
610
+ const properties =
611
+ group === undefined
612
+ ? new Map<string, TypeProperty>()
613
+ : propertiesOf(group.type, sourceFile, checker, operationId, `responses.${status}.headers`);
614
+ const claimed = new Set<string>();
615
+ const names = new Set<string>();
616
+ const headers: HttpResponseHeaderIR[] = [];
617
+
618
+ for (const declaration of declarations) {
619
+ const property = properties.get(declaration.property);
620
+ const field = `responses.${status}.headers.${declaration.property}`;
621
+ if (property === undefined) {
622
+ throw operationError(operationId, field, 'does not exist in the generic response headers type');
623
+ }
624
+ if (claimed.has(declaration.property)) {
625
+ throw operationError(operationId, field, 'is declared more than once');
626
+ }
627
+ claimed.add(declaration.property);
628
+ const name = declaration.name.toLowerCase();
629
+ if (!HTTP_TOKEN.test(name)) {
630
+ throw operationError(operationId, `${field}.name`, 'contains an invalid HTTP header name');
631
+ }
632
+ if (names.has(name)) {
633
+ throw operationError(operationId, field, `header name "${declaration.name}" is declared more than once`);
634
+ }
635
+ names.add(name);
636
+
637
+ const typeId = `${operationId}/response/${status}/header/${declaration.property}`;
638
+ const node = reflectLocation(operationId, field, property.type, reflector);
639
+ assertParameterType(operationId, field, 'header', node);
640
+ addType(operationId, typeId, node, types);
641
+ headers.push({
642
+ property: declaration.property,
643
+ name,
644
+ ...(declaration.description === undefined ? {} : { description: declaration.description }),
645
+ required: !property.optional,
646
+ typeId,
647
+ });
648
+ }
649
+ for (const property of properties.values()) {
650
+ if (!claimed.has(property.name)) {
651
+ throw operationError(
652
+ operationId,
653
+ `responses.${status}.headers.${property.name}`,
654
+ 'is not assigned to response-header metadata',
655
+ );
656
+ }
657
+ }
658
+ return headers;
659
+ }
660
+
661
+ function compileResponseVersions(
662
+ operationId: string,
663
+ status: string,
664
+ declarations: Readonly<Record<string, HttpBodyDeclaration>> | undefined,
665
+ bodyType: Type,
666
+ reflector: Reflector,
667
+ types: Record<string, HttpTypeIR>,
668
+ checker: Checker,
669
+ operationVersion: HttpVersionIR,
670
+ ): Readonly<Record<string, HttpBodyIR>> | undefined {
671
+ if (declarations === undefined) return undefined;
672
+ if (operationVersion.kind !== 'media-type') {
673
+ throw operationError(operationId, `responses.${status}.versions`, 'is allowed only for media-type versioning');
674
+ }
675
+ const declaredVersions = Object.keys(declarations);
676
+ if (!sameStringSets(declaredVersions, operationVersion.values)) {
677
+ throw operationError(
678
+ operationId,
679
+ `responses.${status}.versions`,
680
+ `must declare exactly [${operationVersion.values.join(', ')}]`,
681
+ );
682
+ }
683
+ const versions: Record<string, HttpBodyIR> = {};
684
+ for (const version of declaredVersions) {
685
+ const declaration = declarations[version];
686
+ if (declaration === undefined) continue;
687
+ versions[version] = compileBody(
688
+ operationId,
689
+ `responses.${status}.versions.${version}`,
690
+ declaration,
691
+ bodyType,
692
+ `${operationId}/response/${status}/version/${version}/body`,
693
+ reflector,
694
+ types,
695
+ checker,
696
+ );
697
+ }
698
+ return sortRecord(versions);
699
+ }
700
+
701
+ function compileBody(
702
+ operationId: string,
703
+ field: string,
704
+ declaration: HttpBodyDeclaration,
705
+ type: Type,
706
+ typeId: string,
707
+ reflector: Reflector,
708
+ types: Record<string, HttpTypeIR>,
709
+ checker: Checker,
710
+ ): HttpBodyIR {
711
+ if (declaration.kind === 'empty') {
712
+ if (!fixedBodyType('empty', type)) {
713
+ throw operationError(operationId, field, `empty bodies require void, received ${checker.typeToString(type)}`);
714
+ }
715
+ return { kind: 'empty' };
716
+ }
717
+ const mediaType = normalizeMediaType(operationId, `${field}.mediaType`, declaration.mediaType);
718
+ if (declaration.kind !== 'json') {
719
+ if (!fixedBodyType(declaration.kind, type)) {
720
+ throw operationError(
721
+ operationId,
722
+ field,
723
+ `${declaration.kind} body has incompatible type ${checker.typeToString(type)}`,
724
+ );
725
+ }
726
+ return { kind: declaration.kind, mediaType };
727
+ }
728
+
729
+ const node = reflectLocation(operationId, field, type, reflector);
730
+ addType(operationId, typeId, node, types);
731
+ return { kind: 'json', mediaType, typeId };
732
+ }
733
+
734
+ function reflectLocation(operationId: string, field: string, type: Type, reflector: Reflector): TypeIR {
735
+ const before = reflector.diagnostics.length;
736
+ const node = reflector.typeIR(type, field);
737
+ const diagnostics = reflector.diagnostics.slice(before);
738
+ if (diagnostics.length > 0) {
739
+ const first = diagnostics[0];
740
+ throw operationError(
741
+ operationId,
742
+ field,
743
+ `${first?.reason ?? 'the type was refused'}${first?.source === undefined ? '' : ` (${first.source})`}`,
744
+ );
745
+ }
746
+ assertSupportedNode(operationId, field, node);
747
+ return node;
748
+ }
749
+
750
+ function assertSupportedNode(operationId: string, field: string, node: TypeIR): void {
751
+ switch (node.kind) {
752
+ case 'unsupported':
753
+ throw operationError(operationId, field, node.reason);
754
+ case 'unknown':
755
+ case 'undefined':
756
+ case 'ref':
757
+ throw operationError(operationId, field, `${node.kind} is not a complete HTTP schema`);
758
+ case 'array':
759
+ assertSupportedNode(operationId, field, node.element);
760
+ return;
761
+ case 'tuple':
762
+ for (const element of node.elements) assertSupportedNode(operationId, field, element);
763
+ return;
764
+ case 'object':
765
+ for (const property of node.properties) {
766
+ assertSupportedNode(operationId, `${field}.${property.name}`, property.type);
767
+ }
768
+ return;
769
+ case 'union':
770
+ for (const member of node.members) assertSupportedNode(operationId, field, member);
771
+ return;
772
+ default:
773
+ return;
774
+ }
775
+ }
776
+
777
+ function addType(operationId: string, typeId: string, type: TypeIR, types: Record<string, HttpTypeIR>): void {
778
+ if (types[typeId] !== undefined) {
779
+ throw operationError(operationId, typeId, 'type location is declared more than once');
780
+ }
781
+ types[typeId] = deepFreeze({ type, openApi: jsonSchemaFromTypeIR(type) });
782
+ }
783
+
784
+ function assertParameterType(
785
+ operationId: string,
786
+ field: string,
787
+ location: 'path' | 'query' | 'header' | 'cookie',
788
+ node: TypeIR,
789
+ ): void {
790
+ if (location === 'query' && node.kind === 'array') {
791
+ if (isScalarNode(node.element)) return;
792
+ } else if (isScalarNode(node)) {
793
+ return;
794
+ }
795
+ throw operationError(operationId, field, `${node.kind} cannot be encoded in an HTTP ${location} parameter`);
796
+ }
797
+
798
+ function isScalarNode(node: TypeIR): boolean {
799
+ if (node.kind === 'scalar' || node.kind === 'literal') return true;
800
+ return node.kind === 'union' && node.members.length > 0 && node.members.every(isScalarNode);
801
+ }
802
+
803
+ function fixedBodyType(kind: 'text' | 'bytes' | 'stream' | 'empty', type: Type): boolean {
804
+ if (kind === 'text') return type.isIntrinsicType() && type.intrinsicName === 'string';
805
+ if (kind === 'empty') {
806
+ return type.isIntrinsicType() && (type.intrinsicName === 'void' || type.intrinsicName === 'undefined');
807
+ }
808
+ const name = type.getAliasSymbol()?.name ?? type.getSymbol()?.name;
809
+ return kind === 'bytes' ? name === 'Uint8Array' : name === 'ReadableStream';
810
+ }
811
+
812
+ function canonicalSecurity(
813
+ operationId: string,
814
+ requirements: readonly SecurityRequirement[],
815
+ schemes: Readonly<Record<string, SecurityScheme>>,
816
+ ): readonly SecurityRequirement[] {
817
+ return requirements.map((requirement, index) => {
818
+ const canonical: Record<string, readonly string[]> = {};
819
+ for (const name of Object.keys(requirement).toSorted()) {
820
+ if (schemes[name] === undefined) {
821
+ throw operationError(operationId, `security.${String(index)}.${name}`, 'references an undeclared scheme');
822
+ }
823
+ canonical[name] = [...new Set(requirement[name])].toSorted();
824
+ }
825
+ return canonical;
826
+ });
827
+ }
828
+
829
+ function canonicalSecuritySchemes(
830
+ schemes: Readonly<Record<string, SecurityScheme>>,
831
+ ): Readonly<Record<string, SecurityScheme>> {
832
+ const canonical: Record<string, SecurityScheme> = {};
833
+ for (const name of Object.keys(schemes).toSorted()) {
834
+ const scheme = schemes[name];
835
+ if (scheme !== undefined) canonical[name] = canonicalSecurityScheme(name, scheme);
836
+ }
837
+ return deepFreeze(canonical);
838
+ }
839
+
840
+ function composeSecuritySchemes(sources: readonly HttpContractSource[]): Readonly<Record<string, SecurityScheme>> {
841
+ const composed: Record<string, SecurityScheme> = {};
842
+ for (const source of sources) {
843
+ const schemes = canonicalSecuritySchemes(source.contract.securitySchemes);
844
+ for (const name of Object.keys(schemes)) {
845
+ const scheme = schemes[name];
846
+ if (scheme === undefined) continue;
847
+ const existing = composed[name];
848
+ if (existing !== undefined && JSON.stringify(existing) !== JSON.stringify(scheme)) {
849
+ throw new Error(
850
+ `HTTP contract compiler: ${source.exportName}.securitySchemes.${name} conflicts with another contract`,
851
+ );
852
+ }
853
+ composed[name] = scheme;
854
+ }
855
+ }
856
+ return deepFreeze(sortRecord(composed));
857
+ }
858
+
859
+ function canonicalSecurityScheme(name: string, scheme: SecurityScheme): SecurityScheme {
860
+ const description = scheme.description === undefined ? {} : { description: scheme.description };
861
+ switch (scheme.type) {
862
+ case 'http':
863
+ return scheme.scheme === 'bearer'
864
+ ? {
865
+ type: 'http',
866
+ scheme: 'bearer',
867
+ ...(scheme.bearerFormat === undefined ? {} : { bearerFormat: scheme.bearerFormat }),
868
+ ...description,
869
+ }
870
+ : { type: 'http', scheme: 'basic', ...description };
871
+ case 'apiKey': {
872
+ const wireName = scheme.in === 'header' ? scheme.name.toLowerCase() : scheme.name;
873
+ if (wireName.length === 0 || ((scheme.in === 'header' || scheme.in === 'cookie') && !HTTP_TOKEN.test(wireName))) {
874
+ throw new Error(`HTTP contract security scheme ${name}: apiKey name "${scheme.name}" is invalid`);
875
+ }
876
+ return { type: 'apiKey', in: scheme.in, name: wireName, ...description };
877
+ }
878
+ case 'mutualTLS':
879
+ return { type: 'mutualTLS', ...description };
880
+ case 'openIdConnect':
881
+ return {
882
+ type: 'openIdConnect',
883
+ openIdConnectUrl: scheme.openIdConnectUrl,
884
+ ...description,
885
+ };
886
+ case 'oauth2':
887
+ return { type: 'oauth2', flows: canonicalOAuthFlows(scheme.flows), ...description };
888
+ }
889
+ }
890
+
891
+ function canonicalOAuthFlows(flows: OAuthFlows): OAuthFlows {
892
+ const implicit =
893
+ flows.implicit === undefined
894
+ ? undefined
895
+ : {
896
+ authorizationUrl: flows.implicit.authorizationUrl,
897
+ ...(flows.implicit.refreshUrl === undefined ? {} : { refreshUrl: flows.implicit.refreshUrl }),
898
+ scopes: sortRecord(flows.implicit.scopes),
899
+ };
900
+ const password =
901
+ flows.password === undefined
902
+ ? undefined
903
+ : {
904
+ tokenUrl: flows.password.tokenUrl,
905
+ ...(flows.password.refreshUrl === undefined ? {} : { refreshUrl: flows.password.refreshUrl }),
906
+ scopes: sortRecord(flows.password.scopes),
907
+ };
908
+ const clientCredentials =
909
+ flows.clientCredentials === undefined
910
+ ? undefined
911
+ : {
912
+ tokenUrl: flows.clientCredentials.tokenUrl,
913
+ ...(flows.clientCredentials.refreshUrl === undefined
914
+ ? {}
915
+ : { refreshUrl: flows.clientCredentials.refreshUrl }),
916
+ scopes: sortRecord(flows.clientCredentials.scopes),
917
+ };
918
+ const authorizationCode =
919
+ flows.authorizationCode === undefined
920
+ ? undefined
921
+ : {
922
+ authorizationUrl: flows.authorizationCode.authorizationUrl,
923
+ tokenUrl: flows.authorizationCode.tokenUrl,
924
+ ...(flows.authorizationCode.refreshUrl === undefined
925
+ ? {}
926
+ : { refreshUrl: flows.authorizationCode.refreshUrl }),
927
+ scopes: sortRecord(flows.authorizationCode.scopes),
928
+ };
929
+ const canonical = {
930
+ ...(implicit === undefined ? {} : { implicit }),
931
+ ...(password === undefined ? {} : { password }),
932
+ ...(clientCredentials === undefined ? {} : { clientCredentials }),
933
+ ...(authorizationCode === undefined ? {} : { authorizationCode }),
934
+ };
935
+ if (implicit !== undefined) return { ...canonical, implicit };
936
+ if (password !== undefined) return { ...canonical, password };
937
+ if (clientCredentials !== undefined) return { ...canonical, clientCredentials };
938
+ if (authorizationCode !== undefined) return { ...canonical, authorizationCode };
939
+ throw new Error('HTTP contract oauth2 scheme has no flow');
940
+ }
941
+
942
+ function canonicalVersion(operationId: string, version: HttpVersionIR): HttpVersionIR {
943
+ if (version.kind === 'none' || version.kind === 'neutral') return version;
944
+ if (version.kind === 'path') {
945
+ if (version.value.length === 0) {
946
+ throw operationError(operationId, 'version.value', 'must not be empty');
947
+ }
948
+ return version;
949
+ }
950
+ const seen = new Set<string>();
951
+ for (const value of version.values) {
952
+ if (value.length === 0) {
953
+ throw operationError(operationId, 'version.values', 'contains an empty version');
954
+ }
955
+ if (seen.has(value)) {
956
+ throw operationError(operationId, 'version.values', `contains duplicate "${value}"`);
957
+ }
958
+ seen.add(value);
959
+ }
960
+ if (!seen.has(version.default)) {
961
+ throw operationError(operationId, 'version.default', `"${version.default}" is not one of the declared values`);
962
+ }
963
+ if (version.values.length === 0) {
964
+ throw operationError(operationId, 'version.values', 'must contain at least one version');
965
+ }
966
+ const wireName = (version.kind === 'header' ? version.name : version.key).trim().toLowerCase();
967
+ if (!HTTP_TOKEN.test(wireName)) {
968
+ throw operationError(
969
+ operationId,
970
+ version.kind === 'header' ? 'version.name' : 'version.key',
971
+ 'must be a non-empty HTTP token',
972
+ );
973
+ }
974
+ return version.kind === 'header'
975
+ ? { kind: 'header', name: wireName, values: [...version.values], default: version.default }
976
+ : { kind: 'media-type', key: wireName, values: [...version.values], default: version.default };
977
+ }
978
+
979
+ function assertLegacyRoute(operationId: string, declaration: HttpOperationDeclaration, path: string): void {
980
+ const routes = getRoutes(declaration.controller).filter(
981
+ route => route.handlerName === declaration.handler && route.method === declaration.method,
982
+ );
983
+ if (routes.length === 0) {
984
+ throw operationError(
985
+ operationId,
986
+ 'route',
987
+ `${declaration.controller.name}.${declaration.handler} has no matching ${declaration.method} decorator route`,
988
+ );
989
+ }
990
+ if (declaration.version.kind !== 'path' && !routes.some(route => route.path === path)) {
991
+ throw operationError(
992
+ operationId,
993
+ 'path',
994
+ `declares ${path}, but the decorator route is ${routes.map(route => route.path).join(' or ')}`,
995
+ );
996
+ }
997
+
998
+ const legacyVersion = versionsOf(declaration.controller, declaration.handler);
999
+ switch (declaration.version.kind) {
1000
+ case 'none':
1001
+ if (legacyVersion !== undefined) {
1002
+ throw operationError(operationId, 'version', 'declares none but the route has a version decorator');
1003
+ }
1004
+ return;
1005
+ case 'neutral':
1006
+ if (legacyVersion !== 'neutral') {
1007
+ throw operationError(operationId, 'version', 'declares neutral but the route is not @VersionNeutral()');
1008
+ }
1009
+ return;
1010
+ case 'path':
1011
+ if (!Array.isArray(legacyVersion) || !legacyVersion.includes(declaration.version.value)) {
1012
+ throw operationError(
1013
+ operationId,
1014
+ 'version',
1015
+ `path version "${declaration.version.value}" is not declared by @Version()`,
1016
+ );
1017
+ }
1018
+ return;
1019
+ case 'header':
1020
+ case 'media-type':
1021
+ if (!Array.isArray(legacyVersion) || !sameStrings(legacyVersion, declaration.version.values)) {
1022
+ throw operationError(
1023
+ operationId,
1024
+ 'version.values',
1025
+ `declares [${declaration.version.values.join(', ')}], but @Version() declares ` +
1026
+ `[${Array.isArray(legacyVersion) ? legacyVersion.join(', ') : String(legacyVersion)}]`,
1027
+ );
1028
+ }
1029
+ }
1030
+ }
1031
+
1032
+ function assertSecurityMatchesRoute(
1033
+ operationId: string,
1034
+ declaration: HttpOperationDeclaration,
1035
+ security: readonly SecurityRequirement[],
1036
+ ): void {
1037
+ const publicRoute = isPublic(declaration.controller, declaration.handler);
1038
+ if (publicRoute && security.length !== 0) {
1039
+ throw operationError(operationId, 'security', 'an @Public() route must declare an empty security array');
1040
+ }
1041
+ if (!publicRoute && security.length === 0) {
1042
+ throw operationError(operationId, 'security', 'an unprotected operation must be marked @Public()');
1043
+ }
1044
+ }
1045
+
1046
+ interface SecurityWireClaim {
1047
+ readonly location: 'header' | 'query' | 'cookie';
1048
+ readonly name: string;
1049
+ }
1050
+
1051
+ function assertSecurityWireCollisions(
1052
+ operationId: string,
1053
+ security: readonly SecurityRequirement[],
1054
+ schemes: Readonly<Record<string, SecurityScheme>>,
1055
+ parameters: readonly HttpParameterIR[],
1056
+ requestBody: HttpRequestBodyIR | undefined,
1057
+ responses: readonly HttpResponseIR[],
1058
+ version: HttpVersionIR,
1059
+ ): void {
1060
+ const owned = new Map<string, string>();
1061
+ for (const parameter of parameters) {
1062
+ if (parameter.in !== 'path') {
1063
+ owned.set(wireClaimKey(parameter.in, parameter.name), `parameter ${parameter.in}:${parameter.name}`);
1064
+ }
1065
+ }
1066
+ if (version.kind === 'header') {
1067
+ owned.set(wireClaimKey('header', version.name), `version header ${version.name}`);
1068
+ }
1069
+ if (requestBody !== undefined) {
1070
+ owned.set(wireClaimKey('header', 'content-type'), 'request content-type header');
1071
+ }
1072
+ if (responses.some(response => response.body.kind !== 'empty')) {
1073
+ owned.set(wireClaimKey('header', 'accept'), 'response accept header');
1074
+ }
1075
+ if (version.kind === 'media-type') {
1076
+ owned.set(wireClaimKey('header', 'content-type'), 'media-type version content-type header');
1077
+ owned.set(wireClaimKey('header', 'accept'), 'media-type version accept header');
1078
+ }
1079
+
1080
+ for (const [requirementIndex, requirement] of security.entries()) {
1081
+ const claimed = new Map<string, string>();
1082
+ for (const schemeName of Object.keys(requirement)) {
1083
+ const scheme = schemes[schemeName];
1084
+ if (scheme === undefined) continue;
1085
+ const claim = securityWireClaim(scheme);
1086
+ if (claim === undefined) continue;
1087
+ const key = wireClaimKey(claim.location, claim.name);
1088
+ const previousScheme = claimed.get(key);
1089
+ if (previousScheme !== undefined) {
1090
+ throw operationError(
1091
+ operationId,
1092
+ `security.${String(requirementIndex)}`,
1093
+ `schemes ${previousScheme} and ${schemeName} both occupy ${claim.location}:${claim.name}`,
1094
+ );
1095
+ }
1096
+ claimed.set(key, schemeName);
1097
+ const owner = owned.get(key);
1098
+ if (owner !== undefined) {
1099
+ throw operationError(
1100
+ operationId,
1101
+ `security.${String(requirementIndex)}.${schemeName}`,
1102
+ `${claim.location}:${claim.name} collides with the contract-owned ${owner}`,
1103
+ );
1104
+ }
1105
+ }
1106
+ }
1107
+ }
1108
+
1109
+ function securityWireClaim(scheme: SecurityScheme): SecurityWireClaim | undefined {
1110
+ switch (scheme.type) {
1111
+ case 'apiKey':
1112
+ return { location: scheme.in, name: scheme.name };
1113
+ case 'http':
1114
+ case 'oauth2':
1115
+ case 'openIdConnect':
1116
+ return { location: 'header', name: 'authorization' };
1117
+ case 'mutualTLS':
1118
+ return undefined;
1119
+ }
1120
+ }
1121
+
1122
+ function wireClaimKey(location: SecurityWireClaim['location'], name: string): string {
1123
+ return `${location}\u0000${location === 'header' ? name.toLowerCase() : name}`;
1124
+ }
1125
+
1126
+ function finalRoutesCollide(left: HttpOperationIR, right: HttpOperationIR): boolean {
1127
+ if (left.method !== right.method || left.path !== right.path) return false;
1128
+ const leftVersion = left.version;
1129
+ const rightVersion = right.version;
1130
+ if (leftVersion.kind === 'neutral' || rightVersion.kind === 'neutral') return true;
1131
+ if (leftVersion.kind !== rightVersion.kind) return true;
1132
+ if (leftVersion.kind === 'header') {
1133
+ if (rightVersion.kind !== 'header') return true;
1134
+ return leftVersion.values.some(version => rightVersion.values.includes(version));
1135
+ }
1136
+ if (leftVersion.kind === 'media-type') {
1137
+ if (rightVersion.kind !== 'media-type') return true;
1138
+ return leftVersion.values.some(version => rightVersion.values.includes(version));
1139
+ }
1140
+ return true;
1141
+ }
1142
+
1143
+ function groupProperties(
1144
+ root: ReadonlyMap<string, TypeProperty>,
1145
+ name: 'path' | 'query' | 'headers' | 'cookies',
1146
+ sourceFile: SourceFile,
1147
+ checker: Checker,
1148
+ operationId: string,
1149
+ ): ReadonlyMap<string, TypeProperty> {
1150
+ const property = root.get(name);
1151
+ return property === undefined
1152
+ ? new Map<string, TypeProperty>()
1153
+ : propertiesOf(property.type, sourceFile, checker, operationId, `type.${name}`);
1154
+ }
1155
+
1156
+ function propertiesOf(
1157
+ type: Type,
1158
+ location: Node,
1159
+ checker: Checker,
1160
+ operationId: string,
1161
+ field: string,
1162
+ ): ReadonlyMap<string, TypeProperty> {
1163
+ if (!type.isObjectType()) {
1164
+ throw operationError(operationId, field, `must be an object type, received ${checker.typeToString(type)}`);
1165
+ }
1166
+ const properties = new Map<string, TypeProperty>();
1167
+ for (const symbol of checker.getPropertiesOfType(type)) {
1168
+ properties.set(symbol.name, {
1169
+ name: symbol.name,
1170
+ type: checker.getTypeOfSymbolAtLocation(symbol, location),
1171
+ optional: (symbol.flags & SymbolFlags.Optional) !== 0,
1172
+ });
1173
+ }
1174
+ return properties;
1175
+ }
1176
+
1177
+ function operationsFromSource(sourceFile: SourceFile, exportName: string): readonly StaticOperation[] {
1178
+ const declaration = findVariable(sourceFile, exportName);
1179
+ const initializer = declaration.initializer;
1180
+ if (initializer === undefined) {
1181
+ throw new Error(`HTTP contract compiler: exported ${exportName} has no initializer`);
1182
+ }
1183
+ const call = unwrap(initializer);
1184
+ if (!isCallExpression(call) || calleeName(call.expression) !== 'defineHttpContract') {
1185
+ throw new Error(`HTTP contract compiler: exported ${exportName} must call defineHttpContract({ ... })`);
1186
+ }
1187
+ const argument = call.arguments[0];
1188
+ const contract = argument === undefined ? undefined : unwrap(argument);
1189
+ if (contract === undefined || !isObjectLiteralExpression(contract)) {
1190
+ throw new Error(`HTTP contract compiler: ${exportName} must pass one static object literal`);
1191
+ }
1192
+ const contractKeys = new Set<string>();
1193
+ for (const property of contract.properties) {
1194
+ if (!isPropertyAssignment(property)) {
1195
+ throw new Error(`HTTP contract compiler: ${exportName} may contain only static property assignments`);
1196
+ }
1197
+ const name = staticPropertyName(property.name);
1198
+ if (name === undefined) {
1199
+ throw new Error(`HTTP contract compiler: ${exportName} contains a computed property name`);
1200
+ }
1201
+ if (contractKeys.has(name)) {
1202
+ throw new Error(`HTTP contract compiler: ${exportName}.${name} is declared more than once`);
1203
+ }
1204
+ contractKeys.add(name);
1205
+ }
1206
+ const securitySchemes = objectProperty(contract, 'securitySchemes', exportName);
1207
+ assertStaticValue(exportName, 'securitySchemes', unwrap(securitySchemes.initializer));
1208
+ const operations = objectProperty(contract, 'operations', exportName);
1209
+ const operationsValue = unwrap(operations.initializer);
1210
+ if (!isObjectLiteralExpression(operationsValue)) {
1211
+ throw new Error(`HTTP contract compiler: ${exportName}.operations must be a static object literal`);
1212
+ }
1213
+
1214
+ const entries: StaticOperation[] = [];
1215
+ const ids = new Set<string>();
1216
+ for (const property of operationsValue.properties) {
1217
+ if (!isPropertyAssignment(property)) {
1218
+ throw new Error(
1219
+ `HTTP contract compiler: ${exportName}.operations may contain only operationId: httpOperation<T>({...}) entries`,
1220
+ );
1221
+ }
1222
+ const operationId = staticPropertyName(property.name);
1223
+ if (operationId === undefined) {
1224
+ throw new Error(`HTTP contract compiler: ${exportName}.operations contains a dynamic operationId`);
1225
+ }
1226
+ if (ids.has(operationId)) {
1227
+ throw operationError(operationId, 'operationId', 'appears more than once in the source object');
1228
+ }
1229
+ ids.add(operationId);
1230
+ const operation = unwrap(property.initializer);
1231
+ if (!isCallExpression(operation) || calleeName(operation.expression) !== 'httpOperation') {
1232
+ throw operationError(operationId, 'declaration', 'must be a direct httpOperation<T>({...}) call');
1233
+ }
1234
+ const type = operation.typeArguments?.[0];
1235
+ if (type === undefined || operation.typeArguments?.length !== 1) {
1236
+ throw operationError(operationId, 'type', 'httpOperation must have exactly one type argument');
1237
+ }
1238
+ assertStaticOperation(operationId, operation.arguments);
1239
+ entries.push({ operationId, type });
1240
+ }
1241
+ return entries;
1242
+ }
1243
+
1244
+ function assertStaticOperation(operationId: string, arguments_: readonly Expression[]): void {
1245
+ if (arguments_.length !== 1) {
1246
+ throw operationError(operationId, 'declaration', 'httpOperation must receive one static object literal');
1247
+ }
1248
+ const argument = arguments_[0];
1249
+ const declaration = argument === undefined ? undefined : unwrap(argument);
1250
+ if (declaration === undefined || !isObjectLiteralExpression(declaration)) {
1251
+ throw operationError(operationId, 'declaration', 'httpOperation must receive one static object literal');
1252
+ }
1253
+
1254
+ const controller = unwrap(objectProperty(declaration, 'controller', operationId).initializer);
1255
+ if (!isStaticReference(controller)) {
1256
+ throw operationError(operationId, 'controller', 'must be a static class reference');
1257
+ }
1258
+
1259
+ const seen = new Set<string>();
1260
+ for (const property of declaration.properties) {
1261
+ if (!isPropertyAssignment(property)) {
1262
+ throw operationError(operationId, 'declaration', 'may contain only static property assignments');
1263
+ }
1264
+ const name = staticPropertyName(property.name);
1265
+ if (name === undefined) {
1266
+ throw operationError(operationId, 'declaration', 'contains a computed property name');
1267
+ }
1268
+ if (seen.has(name)) {
1269
+ throw operationError(operationId, name, 'is declared more than once');
1270
+ }
1271
+ seen.add(name);
1272
+ if (name !== 'controller') {
1273
+ assertStaticValue(operationId, name, unwrap(property.initializer));
1274
+ }
1275
+ }
1276
+ }
1277
+
1278
+ function assertStaticValue(operationId: string, field: string, expression: Expression): void {
1279
+ if (
1280
+ isStringLiteral(expression) ||
1281
+ isNumericLiteral(expression) ||
1282
+ isBooleanLiteral(expression) ||
1283
+ isNullLiteral(expression)
1284
+ ) {
1285
+ return;
1286
+ }
1287
+ if (isArrayLiteralExpression(expression)) {
1288
+ for (const [index, element] of expression.elements.entries()) {
1289
+ assertStaticValue(operationId, `${field}.${String(index)}`, unwrap(element));
1290
+ }
1291
+ return;
1292
+ }
1293
+ if (isObjectLiteralExpression(expression)) {
1294
+ const seen = new Set<string>();
1295
+ for (const property of expression.properties) {
1296
+ if (!isPropertyAssignment(property)) {
1297
+ throw operationError(operationId, field, 'may contain only static property assignments');
1298
+ }
1299
+ const name = staticPropertyName(property.name);
1300
+ if (name === undefined) {
1301
+ throw operationError(operationId, field, 'contains a computed property name');
1302
+ }
1303
+ if (seen.has(name)) {
1304
+ throw operationError(operationId, `${field}.${name}`, 'is declared more than once');
1305
+ }
1306
+ seen.add(name);
1307
+ assertStaticValue(operationId, `${field}.${name}`, unwrap(property.initializer));
1308
+ }
1309
+ return;
1310
+ }
1311
+ throw operationError(operationId, field, 'must be a static literal, array, or object');
1312
+ }
1313
+
1314
+ function isStaticReference(expression: Expression): boolean {
1315
+ if (isIdentifier(expression)) return true;
1316
+ return isPropertyAccessExpression(expression) && isStaticReference(expression.expression);
1317
+ }
1318
+
1319
+ function findVariable(sourceFile: SourceFile, name: string): VariableDeclaration {
1320
+ let found: VariableDeclaration | undefined;
1321
+ const visit = (node: Node): undefined => {
1322
+ if (isVariableDeclaration(node) && isIdentifier(node.name) && node.name.text === name) {
1323
+ if (found !== undefined) {
1324
+ throw new Error(`HTTP contract compiler: ${sourceFile.fileName} declares ${name} more than once`);
1325
+ }
1326
+ found = node;
1327
+ }
1328
+ node.forEachChild(visit);
1329
+ return undefined;
1330
+ };
1331
+ sourceFile.forEachChild(visit);
1332
+ if (found === undefined) {
1333
+ throw new Error(`HTTP contract compiler: ${sourceFile.fileName} has no const named ${name}`);
1334
+ }
1335
+ return found;
1336
+ }
1337
+
1338
+ function assertExported(sourceFile: SourceFile, name: string, checker: Checker): void {
1339
+ const module = checker.getSymbolAtLocation(sourceFile);
1340
+ if (module === undefined) {
1341
+ throw new Error(`HTTP contract compiler: ${sourceFile.fileName} is not a module`);
1342
+ }
1343
+ if (!checker.getExportsOfModule(module).some(symbol => symbol.name === name)) {
1344
+ throw new Error(`HTTP contract compiler: ${sourceFile.fileName} does not export ${name}`);
1345
+ }
1346
+ }
1347
+
1348
+ function objectProperty(
1349
+ object: ObjectLiteralExpression,
1350
+ name: string,
1351
+ owner: string,
1352
+ ): Extract<ObjectLiteralExpression['properties'][number], { readonly initializer: Expression }> {
1353
+ const matches = object.properties.filter(
1354
+ property => isPropertyAssignment(property) && staticPropertyName(property.name) === name,
1355
+ );
1356
+ if (matches.length !== 1) {
1357
+ throw new Error(`HTTP contract compiler: ${owner} must declare exactly one static "${name}" property`);
1358
+ }
1359
+ const match = matches[0];
1360
+ if (match === undefined || !isPropertyAssignment(match)) {
1361
+ throw new Error(`HTTP contract compiler: ${owner}.${name} is not a property assignment`);
1362
+ }
1363
+ return match;
1364
+ }
1365
+
1366
+ function staticPropertyName(name: Node): string | undefined {
1367
+ if (isIdentifier(name) || isStringLiteral(name) || isNumericLiteral(name)) return name.text;
1368
+ return undefined;
1369
+ }
1370
+
1371
+ function unwrap(expression: Expression): Expression {
1372
+ let current = expression;
1373
+ while (isParenthesizedExpression(current) || isAsExpression(current) || isSatisfiesExpression(current)) {
1374
+ current = current.expression;
1375
+ }
1376
+ return current;
1377
+ }
1378
+
1379
+ function calleeName(expression: Expression): string | undefined {
1380
+ if (isIdentifier(expression)) return expression.text;
1381
+ if (isPropertyAccessExpression(expression) && isIdentifier(expression.name)) return expression.name.text;
1382
+ return undefined;
1383
+ }
1384
+
1385
+ function callableHandler(controller: HttpOperationDeclaration['controller'], handler: string): boolean {
1386
+ let prototype: object | null = controller.prototype;
1387
+ while (prototype !== null) {
1388
+ const descriptor = Object.getOwnPropertyDescriptor(prototype, handler);
1389
+ if (descriptor !== undefined) return typeof descriptor.value === 'function';
1390
+ prototype = Object.getPrototypeOf(prototype);
1391
+ }
1392
+ return false;
1393
+ }
1394
+
1395
+ function sourcePath(file: string | URL): string {
1396
+ if (file instanceof URL) return fileURLToPath(file);
1397
+ return file.startsWith('file:') ? fileURLToPath(file) : resolve(file);
1398
+ }
1399
+
1400
+ function normalizePath(path: string): string {
1401
+ const collapsed = `/${path}`.replace(/\/+/g, '/');
1402
+ return collapsed.length > 1 && collapsed.endsWith('/') ? collapsed.slice(0, -1) : collapsed;
1403
+ }
1404
+
1405
+ function pathPlaceholders(path: string): string[] {
1406
+ return [...path.matchAll(/:([^/]+)/g)].map(match => match[1]).filter(name => name !== undefined);
1407
+ }
1408
+
1409
+ function normalizeMediaType(operationId: string, field: string, value: string): string {
1410
+ const parts = value.split(';');
1411
+ const essence = parts.shift()?.trim().toLowerCase() ?? '';
1412
+ if (!/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(essence)) {
1413
+ throw operationError(operationId, field, `"${value}" is not a media type`);
1414
+ }
1415
+ const parameters = parts.map(part => {
1416
+ const separator = part.indexOf('=');
1417
+ if (separator === -1) {
1418
+ throw operationError(operationId, field, `parameter "${part.trim()}" has no value`);
1419
+ }
1420
+ const name = part.slice(0, separator).trim().toLowerCase();
1421
+ const parameterValue = part.slice(separator + 1).trim();
1422
+ if (name.length === 0 || parameterValue.length === 0) {
1423
+ throw operationError(operationId, field, `parameter "${part.trim()}" is incomplete`);
1424
+ }
1425
+ return `${name}=${parameterValue}`;
1426
+ });
1427
+ return parameters.length === 0 ? essence : `${essence}; ${parameters.join('; ')}`;
1428
+ }
1429
+
1430
+ function sameStrings(left: readonly string[], right: readonly string[]): boolean {
1431
+ return left.length === right.length && left.every((value, index) => value === right[index]);
1432
+ }
1433
+
1434
+ function sameStringSets(left: readonly string[], right: readonly string[]): boolean {
1435
+ if (left.length !== right.length) return false;
1436
+ const sortedRight = right.toSorted();
1437
+ return left.toSorted().every((value, index) => value === sortedRight[index]);
1438
+ }
1439
+
1440
+ function sortRecord<Value>(record: Readonly<Record<string, Value>>): Readonly<Record<string, Value>> {
1441
+ const sorted: Record<string, Value> = {};
1442
+ for (const key of Object.keys(record).toSorted()) {
1443
+ const value = record[key];
1444
+ if (value !== undefined) sorted[key] = value;
1445
+ }
1446
+ return sorted;
1447
+ }
1448
+
1449
+ function deepFreeze<Value>(value: Value): Value {
1450
+ if (typeof value !== 'object' || value === null) return value;
1451
+ for (const nested of Object.values(value)) deepFreeze(nested);
1452
+ return Object.freeze(value);
1453
+ }
1454
+
1455
+ function operationError(operationId: string, field: string, problem: string): Error {
1456
+ return new Error(`HTTP contract ${operationId} at ${field}: ${problem}`);
1457
+ }