@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,483 @@
1
+ // @zmdb/web/openapi — deterministic HttpContractIR to OpenAPI 3.1 projection.
2
+ //
3
+ // Contract collection and TypeIR projection happen in @zmdb/web/contract/compiler.
4
+ // This module reads only serialisable operation data and the precomputed `openApi`
5
+ // documents attached to type IDs.
6
+
7
+ import type {
8
+ HttpBodyIR,
9
+ HttpContractIR,
10
+ HttpOperationIR,
11
+ HttpParameterIR,
12
+ HttpResponseIR,
13
+ JsonValue,
14
+ SecurityRequirement,
15
+ SecurityScheme,
16
+ } from '../contract/index.js';
17
+
18
+ export type {
19
+ AuthorizationCodeFlow,
20
+ ClientCredentialsFlow,
21
+ ImplicitFlow,
22
+ OAuthFlow,
23
+ OAuthFlows,
24
+ PasswordFlow,
25
+ SecurityRequirement,
26
+ SecurityScheme,
27
+ } from '../contract/index.js';
28
+
29
+ export type JsonSchema = Readonly<Record<string, JsonValue>>;
30
+
31
+ export interface OpenApiRenderOptions {
32
+ readonly info?: { readonly title: string; readonly version: string };
33
+ }
34
+
35
+ interface OpenApiParameter {
36
+ readonly name: string;
37
+ readonly in: 'path' | 'query' | 'header' | 'cookie';
38
+ readonly required: boolean;
39
+ readonly style: 'simple' | 'form';
40
+ readonly explode: boolean;
41
+ readonly allowReserved?: false;
42
+ readonly schema: JsonSchema;
43
+ }
44
+
45
+ interface OpenApiHeader {
46
+ readonly required: boolean;
47
+ readonly schema: JsonSchema;
48
+ readonly description?: string;
49
+ }
50
+
51
+ interface OpenApiMedia {
52
+ readonly schema: JsonSchema;
53
+ }
54
+
55
+ type OpenApiContent = Record<string, OpenApiMedia>;
56
+
57
+ interface OpenApiResponse {
58
+ readonly description: string;
59
+ readonly headers?: Record<string, OpenApiHeader>;
60
+ readonly content?: OpenApiContent;
61
+ }
62
+
63
+ interface OpenApiRequestBody {
64
+ readonly required: boolean;
65
+ readonly content: OpenApiContent;
66
+ }
67
+
68
+ interface OpenApiOperation {
69
+ readonly operationId: string;
70
+ readonly parameters?: readonly OpenApiParameter[];
71
+ readonly requestBody?: OpenApiRequestBody;
72
+ readonly responses: Record<string, OpenApiResponse>;
73
+ readonly security: readonly SecurityRequirement[];
74
+ readonly deprecated?: true;
75
+ }
76
+
77
+ type PathItem = Record<string, OpenApiOperation>;
78
+
79
+ export interface OpenApiDocument {
80
+ readonly openapi: '3.1.0';
81
+ readonly info: { readonly title: string; readonly version: string };
82
+ readonly paths: Record<string, PathItem>;
83
+ readonly components?: { readonly securitySchemes: Readonly<Record<string, SecurityScheme>> };
84
+ }
85
+
86
+ interface ProjectedOperation {
87
+ readonly operationId: string;
88
+ readonly path: string;
89
+ readonly method: string;
90
+ readonly value: OpenApiOperation;
91
+ }
92
+
93
+ const METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);
94
+ const FIXED_BODY_SCHEMAS: Readonly<Record<'text' | 'bytes' | 'stream', JsonSchema>> = {
95
+ text: { type: 'string' },
96
+ bytes: { type: 'string', format: 'binary' },
97
+ stream: { type: 'string', format: 'binary' },
98
+ };
99
+
100
+ function contractError(field: string, problem: string): Error {
101
+ return new Error(`OpenAPI contract at ${field}: ${problem}`);
102
+ }
103
+
104
+ function operationError(operationId: string, field: string, problem: string): Error {
105
+ return new Error(`OpenAPI contract ${operationId} at ${field}: ${problem}`);
106
+ }
107
+
108
+ function openApiPath(path: string): { readonly value: string; readonly parameters: readonly string[] } {
109
+ const parameters: string[] = [];
110
+ const value = path.replace(/:([^/]+)/g, (_match, name: string) => {
111
+ parameters.push(name);
112
+ return `{${name}}`;
113
+ });
114
+ return { value, parameters };
115
+ }
116
+
117
+ function sameStrings(left: readonly string[], right: readonly string[]): boolean {
118
+ return left.length === right.length && left.every((value, index) => value === right[index]);
119
+ }
120
+
121
+ function sameStringSets(left: readonly string[], right: readonly string[]): boolean {
122
+ if (left.length !== right.length) return false;
123
+ const expected = right.toSorted();
124
+ return left.toSorted().every((value, index) => value === expected[index]);
125
+ }
126
+
127
+ function schemaFor(contract: HttpContractIR, operation: HttpOperationIR, field: string, typeId: string): JsonSchema {
128
+ const schema = contract.types[typeId]?.openApi;
129
+ if (schema === undefined) {
130
+ throw operationError(operation.operationId, field, `references missing typeId "${typeId}"`);
131
+ }
132
+ return schema;
133
+ }
134
+
135
+ function schemaForBody(
136
+ contract: HttpContractIR,
137
+ operation: HttpOperationIR,
138
+ field: string,
139
+ body: Exclude<HttpBodyIR, { readonly kind: 'empty' }>,
140
+ ): JsonSchema {
141
+ if ('typeId' in body) {
142
+ return schemaFor(contract, operation, `${field}.typeId`, body.typeId);
143
+ }
144
+ return FIXED_BODY_SCHEMAS[body.kind];
145
+ }
146
+
147
+ function parameterFor(
148
+ contract: HttpContractIR,
149
+ operation: HttpOperationIR,
150
+ parameter: HttpParameterIR,
151
+ index: number,
152
+ ): OpenApiParameter {
153
+ const field = `parameters.${String(index)}`;
154
+ const schema = schemaFor(contract, operation, `${field}.typeId`, parameter.typeId);
155
+ switch (parameter.in) {
156
+ case 'path':
157
+ if (!parameter.required) {
158
+ throw operationError(operation.operationId, `${field}.required`, 'a path parameter must be required');
159
+ }
160
+ return {
161
+ name: parameter.name,
162
+ in: 'path',
163
+ required: true,
164
+ style: 'simple',
165
+ explode: false,
166
+ allowReserved: false,
167
+ schema,
168
+ };
169
+ case 'query':
170
+ return {
171
+ name: parameter.name,
172
+ in: 'query',
173
+ required: parameter.required,
174
+ style: 'form',
175
+ explode: true,
176
+ allowReserved: false,
177
+ schema,
178
+ };
179
+ case 'header':
180
+ return {
181
+ name: parameter.name,
182
+ in: 'header',
183
+ required: parameter.required,
184
+ style: 'simple',
185
+ explode: false,
186
+ schema,
187
+ };
188
+ case 'cookie':
189
+ return {
190
+ name: parameter.name,
191
+ in: 'cookie',
192
+ required: parameter.required,
193
+ style: 'form',
194
+ explode: true,
195
+ schema,
196
+ };
197
+ }
198
+ }
199
+
200
+ function assertVersionValues(operation: HttpOperationIR): void {
201
+ const version = operation.version;
202
+ if (version.kind !== 'header' && version.kind !== 'media-type') return;
203
+ if (version.values.length === 0) {
204
+ throw operationError(operation.operationId, 'version.values', 'must contain at least one version');
205
+ }
206
+ if (new Set(version.values).size !== version.values.length) {
207
+ throw operationError(operation.operationId, 'version.values', 'contains a duplicate version');
208
+ }
209
+ if (!version.values.includes(version.default)) {
210
+ throw operationError(
211
+ operation.operationId,
212
+ 'version.default',
213
+ `"${version.default}" is not one of the declared values`,
214
+ );
215
+ }
216
+ }
217
+
218
+ function versionParameter(operation: HttpOperationIR): OpenApiParameter | undefined {
219
+ const version = operation.version;
220
+ if (version.kind !== 'header') return undefined;
221
+ return {
222
+ name: version.name,
223
+ in: 'header',
224
+ required: false,
225
+ style: 'simple',
226
+ explode: false,
227
+ schema: { type: 'string', enum: [...version.values], default: version.default },
228
+ };
229
+ }
230
+
231
+ function contentForBody(
232
+ contract: HttpContractIR,
233
+ operation: HttpOperationIR,
234
+ field: string,
235
+ body: HttpBodyIR,
236
+ ): OpenApiContent | undefined {
237
+ if (body.kind === 'empty') return undefined;
238
+ return { [body.mediaType]: { schema: schemaForBody(contract, operation, field, body) } };
239
+ }
240
+
241
+ function contentForResponse(
242
+ contract: HttpContractIR,
243
+ operation: HttpOperationIR,
244
+ response: HttpResponseIR,
245
+ field: string,
246
+ ): OpenApiContent | undefined {
247
+ if (operation.version.kind !== 'media-type') {
248
+ if (response.versions !== undefined) {
249
+ throw operationError(
250
+ operation.operationId,
251
+ `${field}.versions`,
252
+ 'is present on a response that does not use media-type versioning',
253
+ );
254
+ }
255
+ return contentForBody(contract, operation, `${field}.body`, response.body);
256
+ }
257
+
258
+ const versions = response.versions;
259
+ if (versions !== undefined && !sameStringSets(Object.keys(versions), operation.version.values)) {
260
+ throw operationError(
261
+ operation.operationId,
262
+ `${field}.versions`,
263
+ `must declare exactly [${operation.version.values.join(', ')}]`,
264
+ );
265
+ }
266
+
267
+ const content: OpenApiContent = {};
268
+ for (const value of operation.version.values) {
269
+ const body = versions?.[value] ?? response.body;
270
+ if (body.kind === 'empty') continue;
271
+ const mediaType = `${body.mediaType}; ${operation.version.key}=${value}`;
272
+ if (content[mediaType] !== undefined) {
273
+ throw operationError(operation.operationId, `${field}.versions.${value}`, `duplicates media type "${mediaType}"`);
274
+ }
275
+ content[mediaType] = {
276
+ schema: schemaForBody(contract, operation, `${field}.versions.${value}`, body),
277
+ };
278
+ }
279
+ return Object.keys(content).length === 0 ? undefined : content;
280
+ }
281
+
282
+ function responseFor(
283
+ contract: HttpContractIR,
284
+ operation: HttpOperationIR,
285
+ response: HttpResponseIR,
286
+ index: number,
287
+ ): OpenApiResponse {
288
+ const field = `responses.${String(index)}`;
289
+ if (!Number.isInteger(response.status) || response.status < 200 || response.status > 599) {
290
+ throw operationError(operation.operationId, `${field}.status`, 'must be an integer from 200 through 599');
291
+ }
292
+
293
+ const names = new Set<string>();
294
+ const headers: Record<string, OpenApiHeader> = {};
295
+ for (const [headerIndex, header] of response.headers.entries()) {
296
+ const key = header.name.toLowerCase();
297
+ if (names.has(key)) {
298
+ throw operationError(
299
+ operation.operationId,
300
+ `${field}.headers.${String(headerIndex)}.name`,
301
+ `duplicates response header "${header.name}"`,
302
+ );
303
+ }
304
+ names.add(key);
305
+ headers[header.name] = {
306
+ required: header.required,
307
+ schema: schemaFor(contract, operation, `${field}.headers.${String(headerIndex)}.typeId`, header.typeId),
308
+ ...(header.description === undefined ? {} : { description: header.description }),
309
+ };
310
+ }
311
+
312
+ const content = contentForResponse(contract, operation, response, field);
313
+ return {
314
+ description: response.description,
315
+ ...(Object.keys(headers).length === 0 ? {} : { headers }),
316
+ ...(content === undefined ? {} : { content }),
317
+ };
318
+ }
319
+
320
+ function securityFor(contract: HttpContractIR, operation: HttpOperationIR): readonly SecurityRequirement[] {
321
+ return operation.security.map((requirement, index) => {
322
+ const canonical: Record<string, readonly string[]> = {};
323
+ for (const scheme of Object.keys(requirement).toSorted()) {
324
+ if (contract.securitySchemes[scheme] === undefined) {
325
+ throw operationError(
326
+ operation.operationId,
327
+ `security.${String(index)}.${scheme}`,
328
+ 'references an undeclared scheme',
329
+ );
330
+ }
331
+ canonical[scheme] = [...(requirement[scheme] ?? [])];
332
+ }
333
+ return canonical;
334
+ });
335
+ }
336
+
337
+ function projectOperation(contract: HttpContractIR, operation: HttpOperationIR): ProjectedOperation {
338
+ if (operation.operationId.length === 0) {
339
+ throw operationError('<empty>', 'operationId', 'must not be empty');
340
+ }
341
+ if (!METHODS.has(operation.method)) {
342
+ throw operationError(operation.operationId, 'method', `"${operation.method}" is not supported`);
343
+ }
344
+ if (!operation.path.startsWith('/')) {
345
+ throw operationError(operation.operationId, 'path', 'must start with "/"');
346
+ }
347
+ if (operation.responses.length === 0) {
348
+ throw operationError(operation.operationId, 'responses', 'must contain at least one exact status');
349
+ }
350
+ assertVersionValues(operation);
351
+
352
+ const projectedPath = openApiPath(operation.path);
353
+ const pathParameters = operation.parameters
354
+ .filter(parameter => parameter.in === 'path')
355
+ .map(parameter => parameter.name);
356
+ if (!sameStrings(projectedPath.parameters, pathParameters)) {
357
+ throw operationError(
358
+ operation.operationId,
359
+ 'path',
360
+ `placeholders [${projectedPath.parameters.join(', ')}] do not match path parameters ` +
361
+ `[${pathParameters.join(', ')}]`,
362
+ );
363
+ }
364
+
365
+ const wireNames = new Set<string>();
366
+ const parameters = operation.parameters.map((parameter, index) => {
367
+ const wireKey = `${parameter.in}\u0000${parameter.in === 'header' ? parameter.name.toLowerCase() : parameter.name}`;
368
+ if (wireNames.has(wireKey)) {
369
+ throw operationError(
370
+ operation.operationId,
371
+ `parameters.${String(index)}.name`,
372
+ `duplicates ${parameter.in} parameter "${parameter.name}"`,
373
+ );
374
+ }
375
+ wireNames.add(wireKey);
376
+ return parameterFor(contract, operation, parameter, index);
377
+ });
378
+ const version = versionParameter(operation);
379
+ if (version !== undefined) {
380
+ const wireKey = `header\u0000${version.name.toLowerCase()}`;
381
+ if (wireNames.has(wireKey)) {
382
+ throw operationError(operation.operationId, 'version.name', `duplicates header parameter "${version.name}"`);
383
+ }
384
+ parameters.push(version);
385
+ }
386
+
387
+ let requestBody: OpenApiRequestBody | undefined;
388
+ if (operation.requestBody !== undefined) {
389
+ const content = contentForBody(contract, operation, 'requestBody', operation.requestBody);
390
+ if (content === undefined) {
391
+ throw operationError(operation.operationId, 'requestBody.kind', 'cannot be empty');
392
+ }
393
+ requestBody = { required: operation.requestBody.required, content };
394
+ }
395
+
396
+ const responses: Record<string, OpenApiResponse> = {};
397
+ for (const [index, response] of operation.responses.toSorted((left, right) => left.status - right.status).entries()) {
398
+ const status = String(response.status);
399
+ if (responses[status] !== undefined) {
400
+ throw operationError(operation.operationId, `responses.${String(index)}.status`, `duplicates status ${status}`);
401
+ }
402
+ responses[status] = responseFor(contract, operation, response, index);
403
+ }
404
+
405
+ return {
406
+ operationId: operation.operationId,
407
+ path: projectedPath.value,
408
+ method: operation.method.toLowerCase(),
409
+ value: {
410
+ operationId: operation.operationId,
411
+ ...(parameters.length === 0 ? {} : { parameters }),
412
+ ...(requestBody === undefined ? {} : { requestBody }),
413
+ responses,
414
+ security: securityFor(contract, operation),
415
+ ...(operation.deprecated ? { deprecated: true } : {}),
416
+ },
417
+ };
418
+ }
419
+
420
+ function sortedSecuritySchemes(
421
+ schemes: Readonly<Record<string, SecurityScheme>>,
422
+ ): Readonly<Record<string, SecurityScheme>> {
423
+ const sorted: Record<string, SecurityScheme> = {};
424
+ for (const name of Object.keys(schemes).toSorted()) {
425
+ const scheme = schemes[name];
426
+ if (scheme !== undefined) sorted[name] = scheme;
427
+ }
428
+ return sorted;
429
+ }
430
+
431
+ /** Project one serialisable HTTP contract into an OpenAPI 3.1 document. */
432
+ export function toOpenApi(contract: HttpContractIR, options: OpenApiRenderOptions = {}): OpenApiDocument {
433
+ if (contract.format !== 1) {
434
+ throw contractError('format', `unsupported HttpContractIR format ${String(contract.format)}`);
435
+ }
436
+ if (!Array.isArray(contract.operations)) {
437
+ throw contractError('operations', 'must be an array');
438
+ }
439
+
440
+ const projected = contract.operations
441
+ .map(operation => projectOperation(contract, operation))
442
+ .toSorted((left, right) => {
443
+ if (left.path !== right.path) return left.path.localeCompare(right.path);
444
+ if (left.method !== right.method) return left.method.localeCompare(right.method);
445
+ return left.operationId.localeCompare(right.operationId);
446
+ });
447
+
448
+ const operationIds = new Set<string>();
449
+ const routes = new Map<string, string>();
450
+ const paths: Record<string, PathItem> = {};
451
+ for (const operation of projected) {
452
+ if (operationIds.has(operation.operationId)) {
453
+ throw operationError(operation.operationId, 'operationId', 'appears more than once');
454
+ }
455
+ operationIds.add(operation.operationId);
456
+
457
+ const routeKey = `${operation.method}\u0000${operation.path}`;
458
+ const previous = routes.get(routeKey);
459
+ if (previous !== undefined) {
460
+ throw operationError(
461
+ operation.operationId,
462
+ 'method/path',
463
+ `${operation.method.toUpperCase()} ${operation.path} overlaps operation ${previous}`,
464
+ );
465
+ }
466
+ routes.set(routeKey, operation.operationId);
467
+
468
+ const item = paths[operation.path] ?? {};
469
+ item[operation.method] = operation.value;
470
+ paths[operation.path] = item;
471
+ }
472
+
473
+ const info = options.info ?? { title: '@zmdb/web API', version: '0.0.0' };
474
+ const securitySchemes = sortedSecuritySchemes(contract.securitySchemes);
475
+ return Object.keys(securitySchemes).length === 0
476
+ ? { openapi: '3.1.0', info, paths }
477
+ : { openapi: '3.1.0', info, paths, components: { securitySchemes } };
478
+ }
479
+
480
+ /** Return a handler that serves one prebuilt document by identity. */
481
+ export function serveOpenApi(doc: OpenApiDocument): () => OpenApiDocument {
482
+ return () => doc;
483
+ }
@@ -0,0 +1,23 @@
1
+ import type { Guard } from '../middleware/index.js';
2
+
3
+ /** Guard instances applied before every route, or every route on one controller. */
4
+ export interface GuardRegistry {
5
+ readonly app?: readonly Guard[];
6
+ readonly controllers?: Readonly<Record<string, readonly Guard[]>>;
7
+ }
8
+
9
+ const NO_GUARDS: readonly Guard[] = [];
10
+
11
+ /** Resolve the effective guard chain in runtime order: app, controller, route. */
12
+ export function resolveGuards(
13
+ registry: GuardRegistry | undefined,
14
+ controllerName: string,
15
+ routeGuards: readonly Guard[] = NO_GUARDS,
16
+ ): readonly Guard[] {
17
+ const appGuards = registry?.app ?? NO_GUARDS;
18
+ const controllerGuards = registry?.controllers?.[controllerName] ?? NO_GUARDS;
19
+ if (appGuards.length === 0 && controllerGuards.length === 0) {
20
+ return routeGuards;
21
+ }
22
+ return [...appGuards, ...controllerGuards, ...routeGuards];
23
+ }