@distilled.cloud/core 0.30.3 → 1.0.0-rc.2

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 (132) hide show
  1. package/lib/api.d.ts +165 -0
  2. package/lib/api.d.ts.map +1 -0
  3. package/lib/api.js +178 -0
  4. package/lib/api.js.map +1 -0
  5. package/lib/codegen/cli.d.ts +29 -0
  6. package/lib/codegen/cli.d.ts.map +1 -0
  7. package/lib/codegen/cli.js +165 -0
  8. package/lib/codegen/cli.js.map +1 -0
  9. package/lib/codegen/emit.d.ts +129 -0
  10. package/lib/codegen/emit.d.ts.map +1 -0
  11. package/lib/codegen/emit.js +105 -0
  12. package/lib/codegen/emit.js.map +1 -0
  13. package/lib/codegen/format.d.ts +23 -0
  14. package/lib/codegen/format.d.ts.map +1 -0
  15. package/lib/codegen/format.js +28 -0
  16. package/lib/codegen/format.js.map +1 -0
  17. package/lib/codegen/generator.d.ts +334 -0
  18. package/lib/codegen/generator.d.ts.map +1 -0
  19. package/lib/codegen/generator.js +691 -0
  20. package/lib/codegen/generator.js.map +1 -0
  21. package/lib/codegen/graph.d.ts +36 -0
  22. package/lib/codegen/graph.d.ts.map +1 -0
  23. package/lib/codegen/graph.js +136 -0
  24. package/lib/codegen/graph.js.map +1 -0
  25. package/lib/codegen/members.d.ts +25 -0
  26. package/lib/codegen/members.d.ts.map +1 -0
  27. package/lib/codegen/members.js +55 -0
  28. package/lib/codegen/members.js.map +1 -0
  29. package/lib/codegen/naming.d.ts +29 -0
  30. package/lib/codegen/naming.d.ts.map +1 -0
  31. package/lib/codegen/naming.js +74 -0
  32. package/lib/codegen/naming.js.map +1 -0
  33. package/lib/codegen/openapi-cli.d.ts +38 -0
  34. package/lib/codegen/openapi-cli.d.ts.map +1 -0
  35. package/lib/codegen/openapi-cli.js +107 -0
  36. package/lib/codegen/openapi-cli.js.map +1 -0
  37. package/lib/codegen/openapi.d.ts +115 -0
  38. package/lib/codegen/openapi.d.ts.map +1 -0
  39. package/lib/codegen/openapi.js +1220 -0
  40. package/lib/codegen/openapi.js.map +1 -0
  41. package/lib/codegen/operations.d.ts +24 -0
  42. package/lib/codegen/operations.d.ts.map +1 -0
  43. package/lib/codegen/operations.js +56 -0
  44. package/lib/codegen/operations.js.map +1 -0
  45. package/lib/codegen/pagination.d.ts +39 -0
  46. package/lib/codegen/pagination.d.ts.map +1 -0
  47. package/lib/codegen/pagination.js +33 -0
  48. package/lib/codegen/pagination.js.map +1 -0
  49. package/lib/codegen/prelude.d.ts +15 -0
  50. package/lib/codegen/prelude.d.ts.map +1 -0
  51. package/lib/codegen/prelude.js +60 -0
  52. package/lib/codegen/prelude.js.map +1 -0
  53. package/lib/error-category.d.ts +28 -0
  54. package/lib/error-category.d.ts.map +1 -0
  55. package/lib/error-category.js +46 -0
  56. package/lib/error-category.js.map +1 -0
  57. package/lib/errors.d.ts +1 -0
  58. package/lib/errors.d.ts.map +1 -1
  59. package/lib/errors.js +1 -0
  60. package/lib/errors.js.map +1 -1
  61. package/lib/json-patch.d.ts +25 -32
  62. package/lib/json-patch.d.ts.map +1 -1
  63. package/lib/json-patch.js +23 -95
  64. package/lib/json-patch.js.map +1 -1
  65. package/lib/pagination.d.ts +37 -51
  66. package/lib/pagination.d.ts.map +1 -1
  67. package/lib/pagination.js +72 -90
  68. package/lib/pagination.js.map +1 -1
  69. package/lib/protocol-http.d.ts +74 -0
  70. package/lib/protocol-http.d.ts.map +1 -0
  71. package/lib/protocol-http.js +554 -0
  72. package/lib/protocol-http.js.map +1 -0
  73. package/lib/protocol-rest.d.ts +124 -0
  74. package/lib/protocol-rest.d.ts.map +1 -0
  75. package/lib/protocol-rest.js +242 -0
  76. package/lib/protocol-rest.js.map +1 -0
  77. package/lib/retry.d.ts +8 -2
  78. package/lib/retry.d.ts.map +1 -1
  79. package/lib/retry.js +21 -15
  80. package/lib/retry.js.map +1 -1
  81. package/lib/schema.d.ts +7 -8
  82. package/lib/schema.d.ts.map +1 -1
  83. package/lib/schema.js +7 -8
  84. package/lib/schema.js.map +1 -1
  85. package/lib/trait.d.ts +150 -0
  86. package/lib/trait.d.ts.map +1 -0
  87. package/lib/trait.js +107 -0
  88. package/lib/trait.js.map +1 -0
  89. package/package.json +18 -75
  90. package/src/api.ts +446 -0
  91. package/src/codegen/cli.ts +268 -0
  92. package/src/codegen/emit.ts +207 -0
  93. package/src/codegen/format.ts +47 -0
  94. package/src/codegen/generator.ts +1153 -0
  95. package/src/codegen/graph.ts +151 -0
  96. package/src/codegen/members.ts +71 -0
  97. package/src/codegen/naming.ts +86 -0
  98. package/src/codegen/openapi-cli.ts +166 -0
  99. package/src/codegen/openapi.ts +1450 -0
  100. package/src/codegen/operations.ts +76 -0
  101. package/src/codegen/pagination.ts +71 -0
  102. package/src/codegen/prelude.ts +70 -0
  103. package/src/error-category.ts +84 -0
  104. package/src/errors.ts +2 -0
  105. package/src/json-patch.ts +26 -110
  106. package/src/pagination.ts +86 -142
  107. package/src/protocol-http.ts +699 -0
  108. package/src/protocol-rest.ts +367 -0
  109. package/src/retry.ts +20 -21
  110. package/src/schema.ts +7 -8
  111. package/src/trait.ts +238 -0
  112. package/README.md +0 -30
  113. package/lib/client.d.ts +0 -167
  114. package/lib/client.d.ts.map +0 -1
  115. package/lib/client.js +0 -659
  116. package/lib/client.js.map +0 -1
  117. package/lib/schemas.d.ts +0 -60
  118. package/lib/schemas.d.ts.map +0 -1
  119. package/lib/schemas.js +0 -79
  120. package/lib/schemas.js.map +0 -1
  121. package/lib/sensitive.d.ts +0 -71
  122. package/lib/sensitive.d.ts.map +0 -1
  123. package/lib/sensitive.js +0 -96
  124. package/lib/sensitive.js.map +0 -1
  125. package/lib/traits.d.ts +0 -421
  126. package/lib/traits.d.ts.map +0 -1
  127. package/lib/traits.js +0 -737
  128. package/lib/traits.js.map +0 -1
  129. package/src/client.ts +0 -1177
  130. package/src/schemas.ts +0 -128
  131. package/src/sensitive.ts +0 -119
  132. package/src/traits.ts +0 -996
@@ -0,0 +1,1450 @@
1
+ /**
2
+ * OpenAPI → Smithy 2.0 JSON converter (dev-time only, provider-agnostic).
3
+ *
4
+ * Turns a Swagger 2.0 / OAS 3.0 / OAS 3.1 document into the same Smithy JSON
5
+ * model shape the other spec converters produce (`{ smithy: "2.0", metadata,
6
+ * shapes }`), so every OpenAPI-sourced provider flows through the one shared
7
+ * `generateService` compiler. Conversion fidelity follows distilled v0's
8
+ * `generate-openapi.ts` feature matrix:
9
+ *
10
+ * • one operation per (path × get/post/put/patch/delete), deprecated
11
+ * skipped by default; op shape name = PascalCase(operationId)
12
+ * • input `<Op>Request`: path params → `smithy.api#httpLabel` (+required),
13
+ * query params → `smithy.api#httpQuery`, header/cookie params dropped,
14
+ * body properties flattened alongside (labels win, then query, then body);
15
+ * non-object bodies become a sole `body` member with `smithy.api#httpPayload`
16
+ * • `$ref`s become NAMED shapes (`components/schemas/X` → `<ns>#X`), reused
17
+ * across operations; anonymous nested objects synthesize names from the
18
+ * parent + member path
19
+ * • responses: 200 → 201 → 204 precedence, `application/json` only; object
20
+ * results become `<Op>Response` structures (a sole `$ref` reuses the named
21
+ * shape); bare array/scalar results wrap in a structure whose single
22
+ * member carries `com.distilled.openapi#rawResponse` (the SdkSpec maps it
23
+ * to a root pipe); no schema → `smithy.api#Unit`
24
+ * • nullability (3.0 `nullable`, 3.1 `type: [..., "null"]`, 2.0
25
+ * `x-nullable`, `oneOf`/`anyOf` null branches) → member-level
26
+ * `com.distilled.openapi#nullable` trait
27
+ * • sensitive string members (x-sensitive or name-pattern match) →
28
+ * `smithy.api#sensitive` on the member (direction-agnostic; the SdkSpec
29
+ * decides the input/output treatment)
30
+ * • per-op non-2xx statuses (outside `defaultErrorStatuses`) → typed error
31
+ * shapes with `smithy.api#error`/`smithy.api#httpError` and
32
+ * `com.distilled.openapi#errorMatchers` status matchers
33
+ * • v0 pagination detection (`pagination.cursor` / `.next` / `.next_page`,
34
+ * `next_token`/`NextToken`/`nextToken`, `next_page` + input alias lists +
35
+ * first top-level array property) → `smithy.api#paginated` on the op
36
+ * (with the non-standard `mode` member the core runtime dispatches on)
37
+ */
38
+
39
+ // ============================================================================
40
+ // Trait ids (the `com.distilled.openapi` vocabulary)
41
+ // ============================================================================
42
+
43
+ /** Member-level nullability (`S.NullOr` + `| null` via `SdkSpec.nullableTrait`). */
44
+ export const NULLABLE_TRAIT = "com.distilled.openapi#nullable";
45
+ /**
46
+ * Marks the sole member of a synthesized response wrapper for bare
47
+ * array/scalar response bodies. SdkSpecs bind it with a `rootPipe`
48
+ * (`T.RawResponseRoot()` from `core/protocol-rest`), so the emitted response
49
+ * type IS the payload type.
50
+ */
51
+ export const RAW_RESPONSE_TRAIT = "com.distilled.openapi#rawResponse";
52
+ /**
53
+ * Wire-matching rules for a generated error class (see
54
+ * `SdkSpec.errorMatchersTrait` + `applyErrorMatchers`). The converter stamps
55
+ * `[{ status }]` from the response status the class was mapped from.
56
+ */
57
+ export const ERROR_MATCHERS_TRAIT = "com.distilled.openapi#errorMatchers";
58
+ /**
59
+ * Non-JSON request body encoding chosen by the v0 content-type precedence
60
+ * (json > form-urlencoded > multipart). Value: `"form-urlencoded"` |
61
+ * `"multipart"`. `"multipart"` is also merged into the `smithy.api#http`
62
+ * trait's `contentType` (which core's `buildRequest` understands);
63
+ * form-urlencoded is left to provider SdkSpecs/protocols.
64
+ */
65
+ export const CONTENT_TYPE_TRAIT = "com.distilled.openapi#contentType";
66
+ /**
67
+ * The `apiVersion` recorded on every operation when
68
+ * {@link OpenApiConvertOptions.apiVersion} is set (Azure ARM style). The
69
+ * matching `api-version` query parameter is dropped from inputs; the
70
+ * provider's protocol injects it at request time.
71
+ */
72
+ export const API_VERSION_TRAIT = "com.distilled.openapi#apiVersion";
73
+
74
+ // ============================================================================
75
+ // Sensitive-field name patterns (distilled v0's SENSITIVE_FIELD_PATTERNS)
76
+ // ============================================================================
77
+
78
+ export const SENSITIVE_FIELD_PATTERNS: readonly RegExp[] = [
79
+ /password/i,
80
+ /^secret$/i,
81
+ /secret[-_]?key/i,
82
+ /[-_]secret$/i,
83
+ /^client[-_]?secret$/i,
84
+ /^access[-_]?token$/i,
85
+ /^refresh[-_]?token$/i,
86
+ /^api[-_]?key$/i,
87
+ /^api[-_]?key[-_]?secret$/i,
88
+ /^api[-_]?token$/i,
89
+ /^private[-_]?key$/i,
90
+ /^secret[-_]?access[-_]?key$/i,
91
+ /^session[-_]?token$/i,
92
+ /^access[-_]?key[-_]?id$/i,
93
+ /^one[-_]?time[-_]?password$/i,
94
+ /^connection[-_]?string$/i,
95
+ /^connection[-_]?uri$/i,
96
+ /^plain[-_]?text$/i,
97
+ /^plain[-_]?text[-_]?refresh[-_]?token$/i,
98
+ ];
99
+
100
+ // ============================================================================
101
+ // Options
102
+ // ============================================================================
103
+
104
+ export interface OpenApiConvertOptions {
105
+ /** Shape namespace, e.g. `"com.neon.api"`. */
106
+ readonly namespace: string;
107
+ /** Service shape name (PascalCased), e.g. `"Neon"`. */
108
+ readonly serviceName: string;
109
+ /** Service shape `version`. Default: the spec's `info.version`, else "1.0". */
110
+ readonly serviceVersion?: string;
111
+ /**
112
+ * Non-2xx response status → error class name. Statuses in
113
+ * {@link defaultErrorStatuses} never produce per-op errors. Default:
114
+ * `{400: BadRequest, 403: Forbidden, 404: NotFound, 409: Conflict,
115
+ * 422: UnprocessableEntity}` (the v0 default).
116
+ */
117
+ readonly statusToErrorClass?: Readonly<Record<string, string>>;
118
+ /**
119
+ * Statuses covered by the SDK's common errors, excluded from per-op error
120
+ * lists. Default: `{"401","429","500","503"}` (the v0 default).
121
+ */
122
+ readonly defaultErrorStatuses?: Iterable<string>;
123
+ /** Skip operations marked `deprecated: true`. Default true. */
124
+ readonly skipDeprecated?: boolean;
125
+ /**
126
+ * Azure-style fixed `api-version`: drops `api-version` query params and
127
+ * stamps {@link API_VERSION_TRAIT} on every operation.
128
+ */
129
+ readonly apiVersion?: string;
130
+ /**
131
+ * Name patterns marking string members sensitive. Default
132
+ * {@link SENSITIVE_FIELD_PATTERNS}. Pass `[]` to disable name-based
133
+ * detection (explicit `x-sensitive: true` still applies).
134
+ */
135
+ readonly sensitivePatterns?: readonly RegExp[];
136
+ /**
137
+ * Optional full shape-definition overrides for emitted error classes
138
+ * (keyed by class name) — e.g. custom members. The converter's default is
139
+ * an empty structure with `smithy.api#error` + `smithy.api#httpError` +
140
+ * {@link ERROR_MATCHERS_TRAIT} traits; overrides are merged over it.
141
+ */
142
+ readonly errorShapes?: Readonly<Record<string, any>>;
143
+ }
144
+
145
+ export interface SmithyModel {
146
+ smithy: "2.0";
147
+ metadata: any;
148
+ shapes: Record<string, any>;
149
+ }
150
+
151
+ // ============================================================================
152
+ // String helpers (same conventions as cloudflare's spec-to-smithy)
153
+ // ============================================================================
154
+
155
+ const PRELUDE = {
156
+ Unit: "smithy.api#Unit",
157
+ String: "smithy.api#String",
158
+ Boolean: "smithy.api#Boolean",
159
+ Double: "smithy.api#Double",
160
+ Integer: "smithy.api#Integer",
161
+ Document: "smithy.api#Document",
162
+ } as const;
163
+
164
+ /** snake/kebab/space/camel → PascalCase identifier (inner caps preserved). */
165
+ const pascal = (s: string): string => {
166
+ const parts = s.split(/[^A-Za-z0-9]+/).filter(Boolean);
167
+ let out = parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join("");
168
+ if (out === "") out = "Shape";
169
+ if (/^[0-9]/.test(out)) out = `_${out}`;
170
+ return out;
171
+ };
172
+
173
+ /** Keep a JSON field name if it's a valid Smithy member identifier, else sanitize. */
174
+ const memberIdent = (name: string): string => {
175
+ let out = name.replace(/[^A-Za-z0-9_]/g, "_");
176
+ if (/^[0-9]/.test(out)) out = `_${out}`;
177
+ return out || "_";
178
+ };
179
+
180
+ const enumMemberName = (value: string): string => {
181
+ let out = value
182
+ .toUpperCase()
183
+ .replace(/[^A-Z0-9]+/g, "_")
184
+ .replace(/^_+|_+$/g, "");
185
+ if (out === "") out = "VALUE";
186
+ if (/^[0-9]/.test(out)) out = `_${out}`;
187
+ return out;
188
+ };
189
+
190
+ // ============================================================================
191
+ // Conversion context
192
+ // ============================================================================
193
+
194
+ const MAX_SCHEMA_DEPTH = 40;
195
+
196
+ /**
197
+ * Conversion direction: `"in"` for request-position schemas (drop
198
+ * `readOnly: true` members), `"out"` for response-position schemas (drop
199
+ * `writeOnly: true` members).
200
+ */
201
+ type Dir = "in" | "out";
202
+
203
+ interface Ctx {
204
+ readonly spec: any;
205
+ readonly version: "2.0" | "3.0" | "3.1";
206
+ readonly ns: string;
207
+ readonly shapes: Record<string, any>;
208
+ readonly names: Set<string>;
209
+ /**
210
+ * Variant-keyed `$ref` cache (`"*:<ref>"` for direction-agnostic schemas,
211
+ * `"in:<ref>"`/`"out:<ref>"` for direction-sensitive ones) → converted
212
+ * result (cycle-safe; mutated in place).
213
+ */
214
+ readonly refs: Map<string, { target: string; nullable: boolean }>;
215
+ /**
216
+ * Per-direction set of `$ref` pointers whose reachable schema graph
217
+ * contains the direction's excluded flag (see {@link dirSensitive}).
218
+ * Built lazily, once per direction, by {@link buildDirSensitiveRefs}.
219
+ */
220
+ readonly dirSensitiveRefs: Map<Dir, ReadonlySet<string>>;
221
+ readonly sensitivePatterns: readonly RegExp[];
222
+ }
223
+
224
+ interface Converted {
225
+ target: string;
226
+ nullable: boolean;
227
+ }
228
+
229
+ const uniqueName = (ctx: Ctx, base: string): string => {
230
+ const want = pascal(base);
231
+ let name = want;
232
+ let n = 2;
233
+ while (ctx.names.has(name)) name = `${want}${n++}`;
234
+ ctx.names.add(name);
235
+ return name;
236
+ };
237
+
238
+ const addShape = (ctx: Ctx, base: string, def: any): string => {
239
+ const id = `${ctx.ns}#${uniqueName(ctx, base)}`;
240
+ ctx.shapes[id] = def;
241
+ return id;
242
+ };
243
+
244
+ const detectVersion = (spec: any): "2.0" | "3.0" | "3.1" => {
245
+ if (spec?.swagger === "2.0") return "2.0";
246
+ const v = spec?.openapi;
247
+ if (typeof v === "string" && v.startsWith("3.1")) return "3.1";
248
+ if (typeof v === "string" && v.startsWith("3.0")) return "3.0";
249
+ throw new Error(
250
+ `Unsupported OpenAPI version (swagger=${spec?.swagger}, openapi=${spec?.openapi})`,
251
+ );
252
+ };
253
+
254
+ /** Resolve a local `#/...` JSON pointer against the spec document. */
255
+ const resolvePointer = (spec: any, ref: string): any => {
256
+ if (typeof ref !== "string" || !ref.startsWith("#/")) return undefined;
257
+ let cur = spec;
258
+ for (const raw of ref.slice(2).split("/")) {
259
+ const seg = raw.replace(/~1/g, "/").replace(/~0/g, "~");
260
+ if (cur === null || typeof cur !== "object") return undefined;
261
+ cur = cur[seg];
262
+ }
263
+ return cur;
264
+ };
265
+
266
+ /** Follow `$ref` chains (bounded) to the underlying schema object. */
267
+ const deref = (ctx: Ctx, def: any): any => {
268
+ let cur = def;
269
+ for (let i = 0; i < 16 && cur && typeof cur === "object" && cur.$ref; i++) {
270
+ cur = resolvePointer(ctx.spec, cur.$ref);
271
+ }
272
+ return cur;
273
+ };
274
+
275
+ // ============================================================================
276
+ // allOf flattening + nullability
277
+ // ============================================================================
278
+
279
+ interface FlatObject {
280
+ properties: Record<string, any>;
281
+ required: Set<string>;
282
+ isObject: boolean;
283
+ /** First `additionalProperties` schema seen (allOf-merged map bodies). */
284
+ additionalProperties?: any;
285
+ }
286
+
287
+ /**
288
+ * Flatten a schema into a property bag: `$ref`s resolved, `allOf` merged
289
+ * (properties + required unioned across resolved subschemas). A
290
+ * `oneOf`/`anyOf` encountered INSIDE an `allOf` merges its branches'
291
+ * properties as OPTIONAL members — the loosest satisfiable reading of the
292
+ * intersection — rather than dropping them; a top-level union is left to
293
+ * the union conversion path.
294
+ */
295
+ const flattenObject = (ctx: Ctx, def: any, depth = 0): FlatObject => {
296
+ const out: FlatObject = {
297
+ properties: {},
298
+ required: new Set(),
299
+ isObject: false,
300
+ };
301
+ // Revisiting a `$ref` under the same flags merges nothing new (property
302
+ // merge is first-wins) — dedupe so shared/cyclic allOf bases are walked
303
+ // once per flag combination instead of exponentially (MongoDB Atlas's
304
+ // polymorphic allOf/oneOf graph never finished under the bare depth cap).
305
+ const seen = new Set<string>();
306
+ const visit = (
307
+ d: any,
308
+ dep: number,
309
+ inAllOf: boolean,
310
+ inUnion: boolean,
311
+ ): void => {
312
+ if (dep > MAX_SCHEMA_DEPTH) return;
313
+ if (d && typeof d === "object" && typeof d.$ref === "string") {
314
+ const key = `${d.$ref}|${inAllOf}|${inUnion}`;
315
+ if (seen.has(key)) return;
316
+ seen.add(key);
317
+ }
318
+ const r = deref(ctx, d);
319
+ if (!r || typeof r !== "object") return;
320
+ if (Array.isArray(r.allOf)) {
321
+ for (const sub of r.allOf) visit(sub, dep + 1, true, inUnion);
322
+ }
323
+ if (inAllOf) {
324
+ const branches = r.oneOf ?? r.anyOf;
325
+ if (Array.isArray(branches)) {
326
+ for (const b of branches) {
327
+ if (!isNullBranch(ctx, b)) visit(b, dep + 1, inAllOf, true);
328
+ }
329
+ }
330
+ }
331
+ if (r.properties && typeof r.properties === "object") {
332
+ out.isObject = true;
333
+ for (const [k, v] of Object.entries(r.properties)) {
334
+ if (!(k in out.properties)) out.properties[k] = v;
335
+ }
336
+ }
337
+ if (r.type === "object") out.isObject = true;
338
+ if (
339
+ out.additionalProperties === undefined &&
340
+ r.additionalProperties !== undefined &&
341
+ r.additionalProperties !== false
342
+ ) {
343
+ out.additionalProperties = r.additionalProperties;
344
+ }
345
+ if (!inUnion && Array.isArray(r.required)) {
346
+ for (const k of r.required) out.required.add(k);
347
+ }
348
+ };
349
+ visit(def, depth, false, false);
350
+ return out;
351
+ };
352
+
353
+ /** Whether a `oneOf`/`anyOf` branch represents JSON null. */
354
+ const isNullBranch = (ctx: Ctx, branch: any): boolean => {
355
+ const r = deref(ctx, branch);
356
+ if (!r || typeof r !== "object") return false;
357
+ if (r.type === "null") return true;
358
+ if (Array.isArray(r.type) && r.type.every((t: unknown) => t === "null")) {
359
+ return true;
360
+ }
361
+ if (
362
+ Array.isArray(r.enum) &&
363
+ r.enum.length > 0 &&
364
+ r.enum.every((v: unknown) => v === null)
365
+ ) {
366
+ return true;
367
+ }
368
+ return false;
369
+ };
370
+
371
+ /** Intrinsic nullability flags on a schema node (not union null branches). */
372
+ const ownNullable = (ctx: Ctx, def: any): boolean => {
373
+ if (!def || typeof def !== "object") return false;
374
+ // `nullable: true` is 3.0 vocabulary but appears in real 3.1 (and even
375
+ // 2.0) documents — honor it everywhere rather than silently dropping the
376
+ // null arm.
377
+ if (def.nullable === true) return true;
378
+ if (def["x-nullable"] === true) return true;
379
+ if (Array.isArray(def.type) && def.type.includes("null")) return true;
380
+ return false;
381
+ };
382
+
383
+ /**
384
+ * Whether a property schema is excluded from the given direction:
385
+ * `readOnly: true` properties never appear in requests, `writeOnly: true`
386
+ * properties never appear in responses. Checks the property node itself and
387
+ * its `$ref` resolution.
388
+ */
389
+ const dirExcluded = (ctx: Ctx, prop: any, dir: Dir): boolean => {
390
+ const flag = dir === "in" ? "readOnly" : "writeOnly";
391
+ if (prop && typeof prop === "object" && prop[flag] === true) return true;
392
+ const r = deref(ctx, prop);
393
+ return !!r && typeof r === "object" && r[flag] === true;
394
+ };
395
+
396
+ /** Child schema slots the direction-sensitivity walk descends through. */
397
+ const schemaChildren = (d: any): any[] => [
398
+ ...(Array.isArray(d.allOf) ? d.allOf : []),
399
+ ...(Array.isArray(d.oneOf) ? d.oneOf : []),
400
+ ...(Array.isArray(d.anyOf) ? d.anyOf : []),
401
+ ...(d.properties && typeof d.properties === "object"
402
+ ? Object.values(d.properties)
403
+ : []),
404
+ ...(d.items ? [d.items] : []),
405
+ ...(d.additionalProperties && typeof d.additionalProperties === "object"
406
+ ? [d.additionalProperties]
407
+ : []),
408
+ ];
409
+
410
+ /**
411
+ * Walk an INLINE schema subtree (stopping at `$ref` nodes, which are
412
+ * appended to `refs` instead of followed). Returns whether the flag
413
+ * appears inline. Inline subtrees are JSON trees — no cycles.
414
+ */
415
+ const scanInlineForFlag = (d: any, flag: string, refs: string[]): boolean => {
416
+ if (!d || typeof d !== "object") return false;
417
+ if (typeof d.$ref === "string") {
418
+ refs.push(d.$ref);
419
+ return false;
420
+ }
421
+ if (d[flag] === true) return true;
422
+ for (const sub of schemaChildren(d)) {
423
+ if (scanInlineForFlag(sub, flag, refs)) return true;
424
+ }
425
+ return false;
426
+ };
427
+
428
+ /**
429
+ * Compute, for the WHOLE document at once, the set of `$ref` pointers that
430
+ * are direction-sensitive: the flag appears somewhere in the schema graph
431
+ * reachable from the ref's target. One inline scan per distinct ref target
432
+ * (local flag + outgoing ref edges), then reverse propagation from the
433
+ * locally-flagged nodes — linear in spec size, and cycles / diamond-shared
434
+ * refs cost nothing. (This replaces a per-call DFS whose memoization was
435
+ * disabled by ANY re-encountered ref; on large specs where every schema
436
+ * shares refs — e.g. MongoDB Atlas's ubiquitous `links` — that re-walked
437
+ * the graph for each of thousands of `$ref` sites and never finished.)
438
+ */
439
+ const buildDirSensitiveRefs = (ctx: Ctx, dir: Dir): ReadonlySet<string> => {
440
+ const flag = dir === "in" ? "readOnly" : "writeOnly";
441
+
442
+ // Every distinct `$ref` pointer in the document.
443
+ const allRefs = new Set<string>();
444
+ const collect = (d: any): void => {
445
+ if (Array.isArray(d)) {
446
+ for (const v of d) collect(v);
447
+ return;
448
+ }
449
+ if (!d || typeof d !== "object") return;
450
+ if (typeof d.$ref === "string") allRefs.add(d.$ref);
451
+ for (const v of Object.values(d)) collect(v);
452
+ };
453
+ collect(ctx.spec);
454
+
455
+ // ref → refs reachable in one hop; refs whose target carries the flag inline.
456
+ const reverse = new Map<string, string[]>();
457
+ const flagged: string[] = [];
458
+ for (const ref of allRefs) {
459
+ const outgoing: string[] = [];
460
+ if (scanInlineForFlag(resolvePointer(ctx.spec, ref), flag, outgoing)) {
461
+ flagged.push(ref);
462
+ continue; // already sensitive; its edges can't add anything
463
+ }
464
+ for (const to of outgoing) {
465
+ let from = reverse.get(to);
466
+ if (!from) reverse.set(to, (from = []));
467
+ from.push(ref);
468
+ }
469
+ }
470
+
471
+ // Sensitivity propagates from flagged targets to every ref that reaches them.
472
+ const sensitive = new Set<string>(flagged);
473
+ const queue = [...flagged];
474
+ while (queue.length > 0) {
475
+ const next = queue.pop()!;
476
+ for (const from of reverse.get(next) ?? []) {
477
+ if (!sensitive.has(from)) {
478
+ sensitive.add(from);
479
+ queue.push(from);
480
+ }
481
+ }
482
+ }
483
+ return sensitive;
484
+ };
485
+
486
+ /**
487
+ * Whether a schema subtree is direction-sensitive — contains
488
+ * `readOnly: true` (request direction) or `writeOnly: true` (response
489
+ * direction) anywhere, following local `$ref`s. Direction-sensitive named
490
+ * components convert to separate request/response shape variants
491
+ * (`<Name>Input`/`<Name>Output`); everything else is shared.
492
+ */
493
+ const dirSensitive = (ctx: Ctx, def: any, dir: Dir): boolean => {
494
+ let sensitiveRefs = ctx.dirSensitiveRefs.get(dir);
495
+ if (sensitiveRefs === undefined) {
496
+ sensitiveRefs = buildDirSensitiveRefs(ctx, dir);
497
+ ctx.dirSensitiveRefs.set(dir, sensitiveRefs);
498
+ }
499
+ const refs: string[] = [];
500
+ if (scanInlineForFlag(def, dir === "in" ? "readOnly" : "writeOnly", refs)) {
501
+ return true;
502
+ }
503
+ return refs.some((ref) => sensitiveRefs.has(ref));
504
+ };
505
+
506
+ /** Single non-array type from a possibly 3.1-style `type` value. */
507
+ const typeOf = (def: any): string | undefined => {
508
+ const t = def?.type;
509
+ if (typeof t === "string") return t;
510
+ if (Array.isArray(t)) {
511
+ const real = t.filter((x: unknown) => x !== "null");
512
+ if (real.length === 1 && typeof real[0] === "string") return real[0];
513
+ }
514
+ return undefined;
515
+ };
516
+
517
+ // ============================================================================
518
+ // Schema conversion
519
+ // ============================================================================
520
+
521
+ /**
522
+ * Whether a schema definition warrants a NAMED shape (vs an inline prelude
523
+ * target). Named shapes can participate in reference cycles, so `$ref`s to
524
+ * nameable schemas reserve their name before converting.
525
+ */
526
+ const isNameable = (ctx: Ctx, def: any): boolean => {
527
+ if (!def || typeof def !== "object") return false;
528
+ if (def.$ref) return isNameable(ctx, deref(ctx, def));
529
+ if (Array.isArray(def.enum)) {
530
+ const values = def.enum.filter((v: unknown) => v !== null);
531
+ return (
532
+ values.length > 0 &&
533
+ (values.every((v: unknown) => typeof v === "string") ||
534
+ values.every((v: unknown) => typeof v === "number"))
535
+ );
536
+ }
537
+ if (Array.isArray(def.allOf)) return true;
538
+ const branches = def.oneOf ?? def.anyOf;
539
+ if (Array.isArray(branches)) {
540
+ return branches.filter((b: any) => !isNullBranch(ctx, b)).length >= 2;
541
+ }
542
+ const t = typeOf(def);
543
+ if (t === "object" || def.properties || def.additionalProperties) return true;
544
+ if (t === "array") return true;
545
+ return false;
546
+ };
547
+
548
+ /**
549
+ * Convert one schema to a shape target. `dir` is the conversion direction
550
+ * (request vs response position — readOnly/writeOnly members are dropped
551
+ * accordingly). `reservedId` names the top-level shape when the caller
552
+ * pre-registered it (named `$ref` targets); otherwise anonymous shapes
553
+ * synthesize names from `hint`.
554
+ */
555
+ const convertSchema = (
556
+ ctx: Ctx,
557
+ def: any,
558
+ hint: string,
559
+ depth: number,
560
+ dir: Dir,
561
+ reservedId?: string,
562
+ ): Converted => {
563
+ const emit = (shapeDef: any, base: string): string => {
564
+ if (reservedId !== undefined) {
565
+ ctx.shapes[reservedId] = shapeDef;
566
+ return reservedId;
567
+ }
568
+ return addShape(ctx, base, shapeDef);
569
+ };
570
+ const inline = (target: string, nullable: boolean): Converted => {
571
+ // A reserved placeholder that turned out to be inline (scalar/alias):
572
+ // drop the placeholder; the burned name is harmless.
573
+ if (reservedId !== undefined) delete ctx.shapes[reservedId];
574
+ return { target, nullable };
575
+ };
576
+
577
+ if (depth > MAX_SCHEMA_DEPTH) return inline(PRELUDE.Document, false);
578
+ if (def === true || def === undefined || def === null) {
579
+ return inline(PRELUDE.Document, false);
580
+ }
581
+ if (typeof def !== "object") return inline(PRELUDE.Document, false);
582
+
583
+ // --- $ref → named (or cached inline) shape --------------------------------
584
+ if (def.$ref) {
585
+ // Sibling nullability next to the $ref (common tool output even where
586
+ // the spec says siblings are ignored) survives onto the member.
587
+ const siteNullable = ownNullable(ctx, def);
588
+ // Direction-sensitive components (readOnly members in request position,
589
+ // writeOnly in response position) get per-direction variants; the rest
590
+ // share one shape across both directions.
591
+ const sensitive = dirSensitive(ctx, def, dir);
592
+ const cacheKey = sensitive ? `${dir}:${def.$ref}` : `*:${def.$ref}`;
593
+ const cached = ctx.refs.get(cacheKey);
594
+ if (cached) return inline(cached.target, cached.nullable || siteNullable);
595
+ const resolved = resolvePointer(ctx.spec, def.$ref);
596
+ if (resolved === undefined) return inline(PRELUDE.Document, siteNullable);
597
+ const refName =
598
+ (String(def.$ref).split("/").pop() ?? "Shape") +
599
+ (sensitive ? (dir === "in" ? "Input" : "Output") : "");
600
+ if (!isNameable(ctx, resolved)) {
601
+ // Scalars and aliases can't cycle — convert inline and cache.
602
+ const entry: Converted = { target: PRELUDE.Document, nullable: false };
603
+ ctx.refs.set(cacheKey, entry);
604
+ const r = convertSchema(ctx, resolved, pascal(refName), depth + 1, dir);
605
+ entry.target = r.target;
606
+ entry.nullable = r.nullable;
607
+ return inline(r.target, r.nullable || siteNullable);
608
+ }
609
+ // Reserve the component name before converting so cycles resolve.
610
+ const id = `${ctx.ns}#${uniqueName(ctx, refName)}`;
611
+ const entry: Converted = { target: id, nullable: false };
612
+ ctx.refs.set(cacheKey, entry);
613
+ ctx.shapes[id] = { type: "structure", members: {} }; // placeholder
614
+ const r = convertSchema(ctx, resolved, pascal(refName), depth + 1, dir, id);
615
+ entry.target = r.target;
616
+ entry.nullable = r.nullable;
617
+ return inline(r.target, r.nullable || siteNullable);
618
+ }
619
+
620
+ let nullable = ownNullable(ctx, def);
621
+ const doc = typeof def.description === "string" ? def.description : undefined;
622
+ const docTraits = doc ? { "smithy.api#documentation": doc } : {};
623
+
624
+ // --- oneOf / anyOf --------------------------------------------------------
625
+ const branches = def.oneOf ?? def.anyOf;
626
+ if (Array.isArray(branches)) {
627
+ const real: any[] = [];
628
+ for (const b of branches) {
629
+ if (isNullBranch(ctx, b)) nullable = true;
630
+ else real.push(b);
631
+ }
632
+ if (real.length === 0) return inline(PRELUDE.Document, nullable);
633
+ if (real.length === 1) {
634
+ const r = convertSchema(ctx, real[0], hint, depth + 1, dir, reservedId);
635
+ return { target: r.target, nullable: nullable || r.nullable };
636
+ }
637
+ // Distinct branches → a union shape. Members are synthesized case names;
638
+ // duplicate targets are deduped.
639
+ const members: Record<string, any> = {};
640
+ const seenTargets = new Set<string>();
641
+ real.forEach((b, i) => {
642
+ const branchName =
643
+ typeof b?.$ref === "string"
644
+ ? pascal(String(b.$ref).split("/").pop() ?? `Case${i}`)
645
+ : `Case${i}`;
646
+ const r = convertSchema(ctx, b, `${hint}${branchName}`, depth + 1, dir);
647
+ if (seenTargets.has(r.target)) return;
648
+ seenTargets.add(r.target);
649
+ let mn = memberIdent(branchName);
650
+ let k = 2;
651
+ while (mn in members) mn = `${memberIdent(branchName)}_${k++}`;
652
+ members[mn] = { target: r.target };
653
+ });
654
+ const targets = Object.values(members);
655
+ if (targets.length === 1) {
656
+ return inline((targets[0] as any).target, nullable);
657
+ }
658
+ return {
659
+ target: emit({ type: "union", members, traits: docTraits }, hint),
660
+ nullable,
661
+ };
662
+ }
663
+
664
+ // --- allOf ----------------------------------------------------------------
665
+ if (Array.isArray(def.allOf)) {
666
+ // Single-entry allOf over a $ref: passthrough (v0 semantics), carrying
667
+ // the parent's nullability/description.
668
+ if (def.allOf.length === 1 && !def.properties) {
669
+ const r = convertSchema(
670
+ ctx,
671
+ def.allOf[0],
672
+ hint,
673
+ depth + 1,
674
+ dir,
675
+ reservedId,
676
+ );
677
+ return { target: r.target, nullable: nullable || r.nullable };
678
+ }
679
+ const flat = flattenObject(ctx, def, depth);
680
+ if (Object.keys(flat.properties).length === 0) {
681
+ // A property-less intersection of map schemas is a map, not an empty
682
+ // struct.
683
+ if (flat.additionalProperties !== undefined) {
684
+ const r = convertSchema(
685
+ ctx,
686
+ { type: "object", additionalProperties: flat.additionalProperties },
687
+ hint,
688
+ depth + 1,
689
+ dir,
690
+ reservedId,
691
+ );
692
+ return { target: r.target, nullable: nullable || r.nullable };
693
+ }
694
+ if (!flat.isObject) return inline(PRELUDE.Document, nullable);
695
+ }
696
+ const members = buildMembers(
697
+ ctx,
698
+ flat.properties,
699
+ flat.required,
700
+ hint,
701
+ depth + 1,
702
+ dir,
703
+ );
704
+ return {
705
+ target: emit({ type: "structure", members, traits: docTraits }, hint),
706
+ nullable,
707
+ };
708
+ }
709
+
710
+ // --- enum -----------------------------------------------------------------
711
+ if (Array.isArray(def.enum) && def.enum.length > 0) {
712
+ const values = def.enum.filter((v: unknown) => v !== null);
713
+ if (values.some((v: unknown) => v === null)) nullable = true;
714
+ if (values.length === 0) return inline(PRELUDE.Document, true);
715
+ if (values.every((v: unknown) => typeof v === "string")) {
716
+ const members: Record<string, any> = {};
717
+ const used = new Set<string>();
718
+ for (const lit of values as string[]) {
719
+ let mn = enumMemberName(lit);
720
+ let k = 2;
721
+ while (used.has(mn)) mn = `${enumMemberName(lit)}_${k++}`;
722
+ used.add(mn);
723
+ members[mn] = {
724
+ target: PRELUDE.Unit,
725
+ traits: { "smithy.api#enumValue": lit },
726
+ };
727
+ }
728
+ return {
729
+ target: emit({ type: "enum", members, traits: docTraits }, hint),
730
+ nullable,
731
+ };
732
+ }
733
+ // Numeric enums → intEnum shapes (closed numeric literal unions in the
734
+ // generated TS; the schema stays a plain number).
735
+ if (values.every((v: unknown) => typeof v === "number")) {
736
+ const members: Record<string, any> = {};
737
+ const used = new Set<string>();
738
+ for (const lit of values as number[]) {
739
+ let mn = enumMemberName(String(lit));
740
+ let k = 2;
741
+ while (used.has(mn)) mn = `${enumMemberName(String(lit))}_${k++}`;
742
+ used.add(mn);
743
+ members[mn] = {
744
+ target: PRELUDE.Unit,
745
+ traits: { "smithy.api#enumValue": lit },
746
+ };
747
+ }
748
+ return {
749
+ target: emit({ type: "intEnum", members, traits: docTraits }, hint),
750
+ nullable,
751
+ };
752
+ }
753
+ if (values.every((v: unknown) => typeof v === "boolean")) {
754
+ return inline(PRELUDE.Boolean, nullable);
755
+ }
756
+ return inline(PRELUDE.Document, nullable);
757
+ }
758
+
759
+ const t = typeOf(def);
760
+
761
+ // --- array ----------------------------------------------------------------
762
+ if (t === "array") {
763
+ const item = convertSchema(ctx, def.items, `${hint}Item`, depth + 1, dir);
764
+ return {
765
+ target: emit(
766
+ { type: "list", member: { target: item.target }, traits: docTraits },
767
+ reservedId !== undefined ? hint : `${hint}List`,
768
+ ),
769
+ nullable,
770
+ };
771
+ }
772
+
773
+ // --- object ---------------------------------------------------------------
774
+ if (t === "object" || def.properties || def.additionalProperties) {
775
+ if (def.properties && Object.keys(def.properties).length > 0) {
776
+ const required = new Set<string>(
777
+ Array.isArray(def.required) ? def.required : [],
778
+ );
779
+ const members = buildMembers(
780
+ ctx,
781
+ def.properties,
782
+ required,
783
+ hint,
784
+ depth + 1,
785
+ dir,
786
+ );
787
+ return {
788
+ target: emit({ type: "structure", members, traits: docTraits }, hint),
789
+ nullable,
790
+ };
791
+ }
792
+ const ap = def.additionalProperties;
793
+ if (ap !== undefined && ap !== false) {
794
+ const value =
795
+ ap === true || (typeof ap === "object" && Object.keys(ap).length === 0)
796
+ ? { target: PRELUDE.Document, nullable: false }
797
+ : convertSchema(ctx, ap, `${hint}Value`, depth + 1, dir);
798
+ return {
799
+ target: emit(
800
+ {
801
+ type: "map",
802
+ key: { target: PRELUDE.String },
803
+ value: { target: value.target },
804
+ traits: docTraits,
805
+ },
806
+ reservedId !== undefined ? hint : `${hint}Map`,
807
+ ),
808
+ nullable,
809
+ };
810
+ }
811
+ return inline(PRELUDE.Document, nullable);
812
+ }
813
+
814
+ // --- scalars --------------------------------------------------------------
815
+ switch (t) {
816
+ case "string":
817
+ return inline(PRELUDE.String, nullable);
818
+ case "boolean":
819
+ return inline(PRELUDE.Boolean, nullable);
820
+ case "integer":
821
+ return inline(PRELUDE.Integer, nullable);
822
+ case "number":
823
+ return inline(PRELUDE.Double, nullable);
824
+ case "null":
825
+ return inline(PRELUDE.Document, true);
826
+ default:
827
+ return inline(PRELUDE.Document, nullable);
828
+ }
829
+ };
830
+
831
+ /** Whether a property is a sensitive string (x-sensitive or name pattern). */
832
+ const isSensitiveProperty = (ctx: Ctx, name: string, def: any): boolean => {
833
+ const r = deref(ctx, def);
834
+ if (!r || typeof r !== "object") return false;
835
+ const isString = typeOf(r) === "string" && !Array.isArray(r.enum);
836
+ if (!isString) return false;
837
+ if (r["x-sensitive"] === true || def?.["x-sensitive"] === true) return true;
838
+ return ctx.sensitivePatterns.some((p) => p.test(name));
839
+ };
840
+
841
+ /**
842
+ * Build a structure's members from an OpenAPI property map. Direction-
843
+ * excluded properties (`readOnly` in requests, `writeOnly` in responses)
844
+ * are dropped — along with any response-side `required` they carried.
845
+ */
846
+ const buildMembers = (
847
+ ctx: Ctx,
848
+ properties: Record<string, any>,
849
+ required: ReadonlySet<string>,
850
+ hint: string,
851
+ depth: number,
852
+ dir: Dir,
853
+ ): Record<string, any> => {
854
+ const members: Record<string, any> = {};
855
+ for (const [name, prop] of Object.entries(properties)) {
856
+ if (dirExcluded(ctx, prop, dir)) continue;
857
+ let mn = memberIdent(name);
858
+ let k = 2;
859
+ while (mn in members) mn = `${memberIdent(name)}_${k++}`;
860
+ const conv = convertSchema(ctx, prop, `${hint}${pascal(name)}`, depth, dir);
861
+ const traits: Record<string, any> = {};
862
+ const doc =
863
+ prop && typeof prop === "object" && typeof prop.description === "string"
864
+ ? prop.description
865
+ : undefined;
866
+ if (doc) traits["smithy.api#documentation"] = doc;
867
+ if (required.has(name)) traits["smithy.api#required"] = {};
868
+ if (mn !== name) traits["smithy.api#jsonName"] = name;
869
+ if (conv.nullable) traits[NULLABLE_TRAIT] = {};
870
+ if (isSensitiveProperty(ctx, name, prop)) {
871
+ traits["smithy.api#sensitive"] = {};
872
+ }
873
+ members[mn] = {
874
+ target: conv.target,
875
+ ...(Object.keys(traits).length ? { traits } : {}),
876
+ };
877
+ }
878
+ return members;
879
+ };
880
+
881
+ // ============================================================================
882
+ // Parameters
883
+ // ============================================================================
884
+
885
+ interface Param {
886
+ in: string;
887
+ name: string;
888
+ required: boolean;
889
+ description?: string;
890
+ schema: any;
891
+ }
892
+
893
+ /** Normalize a (possibly `$ref`'d) parameter; Swagger 2.0 carries the schema inline. */
894
+ const normalizeParam = (ctx: Ctx, raw: any): Param | undefined => {
895
+ const p = raw?.$ref ? resolvePointer(ctx.spec, raw.$ref) : raw;
896
+ if (!p || typeof p !== "object" || typeof p.name !== "string") {
897
+ return undefined;
898
+ }
899
+ // Swagger 2.0: `in: body` parameters carry their (usually `$ref`'d)
900
+ // schema under `schema`; all other locations describe the type inline on
901
+ // the parameter itself.
902
+ const schema =
903
+ ctx.version === "2.0"
904
+ ? p.in === "body"
905
+ ? (p.schema ?? {})
906
+ : {
907
+ type: p.type,
908
+ enum: p.enum,
909
+ items: p.items,
910
+ format: p.format,
911
+ "x-nullable": p["x-nullable"],
912
+ }
913
+ : (p.schema ?? { type: "string" });
914
+ return {
915
+ in: p.in,
916
+ name: p.name,
917
+ required: p.required === true || p.in === "path",
918
+ description: typeof p.description === "string" ? p.description : undefined,
919
+ schema,
920
+ };
921
+ };
922
+
923
+ /**
924
+ * Path-level parameters merged before operation-level ones; an op-level
925
+ * parameter overrides a path-level one with the same (in, name). Header and
926
+ * cookie parameters are dropped (v0 parity); `in: body` (2.0) is handled by
927
+ * the request-body path.
928
+ */
929
+ const collectParams = (ctx: Ctx, pathItem: any, op: any): Param[] => {
930
+ const byKey = new Map<string, Param>();
931
+ for (const raw of [
932
+ ...(Array.isArray(pathItem?.parameters) ? pathItem.parameters : []),
933
+ ...(Array.isArray(op?.parameters) ? op.parameters : []),
934
+ ]) {
935
+ const p = normalizeParam(ctx, raw);
936
+ if (!p) continue;
937
+ byKey.set(`${p.in}${p.name}`, p);
938
+ }
939
+ return [...byKey.values()].filter(
940
+ (p) => p.in === "path" || p.in === "query" || p.in === "body",
941
+ );
942
+ };
943
+
944
+ /**
945
+ * Simple-type coercion for path labels (Smithy restricts label targets):
946
+ * enum → enum shape, integer → Integer, number → Double, boolean → Boolean,
947
+ * everything else → String.
948
+ */
949
+ const labelTarget = (ctx: Ctx, schema: any, hint: string): string => {
950
+ const r = deref(ctx, schema);
951
+ if (
952
+ Array.isArray(r?.enum) &&
953
+ r.enum.length > 0 &&
954
+ r.enum.every((v: unknown) => typeof v === "string")
955
+ ) {
956
+ return convertSchema(ctx, r, hint, 0, "in").target;
957
+ }
958
+ switch (typeOf(r)) {
959
+ case "integer":
960
+ return PRELUDE.Integer;
961
+ case "number":
962
+ return PRELUDE.Double;
963
+ case "boolean":
964
+ return PRELUDE.Boolean;
965
+ default:
966
+ return PRELUDE.String;
967
+ }
968
+ };
969
+
970
+ // ============================================================================
971
+ // Docs
972
+ // ============================================================================
973
+
974
+ /**
975
+ * v0 trimming rules: stop at a `### Authorization` heading, strip markdown
976
+ * table rows (lines starting with `|`).
977
+ */
978
+ const trimDescription = (desc: unknown): string | undefined => {
979
+ if (typeof desc !== "string" || !desc) return undefined;
980
+ const lines: string[] = [];
981
+ for (const line of desc.split(/\r?\n/)) {
982
+ if (line.trim().startsWith("### Authorization")) break;
983
+ if (line.trim().startsWith("|")) continue;
984
+ lines.push(line);
985
+ }
986
+ const out = lines.join("\n").trim();
987
+ return out || undefined;
988
+ };
989
+
990
+ const opDoc = (op: any): string | undefined => {
991
+ const summary =
992
+ typeof op.summary === "string" ? op.summary.trim() : undefined;
993
+ const desc = trimDescription(op.description);
994
+ const parts = [summary, desc].filter(
995
+ (s): s is string => s !== undefined && s !== "",
996
+ );
997
+ return parts.length ? parts.join("\n\n") : undefined;
998
+ };
999
+
1000
+ // ============================================================================
1001
+ // Responses
1002
+ // ============================================================================
1003
+
1004
+ /** 200 → 201 → 204 precedence; response-level `$ref` resolved; JSON only. */
1005
+ const successSchema = (
1006
+ ctx: Ctx,
1007
+ responses: any,
1008
+ ): { schema: any | undefined } => {
1009
+ for (const code of ["200", "201", "204"]) {
1010
+ const raw = responses?.[code];
1011
+ if (!raw) continue;
1012
+ const resp = raw.$ref ? resolvePointer(ctx.spec, raw.$ref) : raw;
1013
+ if (!resp || typeof resp !== "object") return { schema: undefined };
1014
+ if (ctx.version === "2.0") {
1015
+ return { schema: resp.schema };
1016
+ }
1017
+ return { schema: resp.content?.["application/json"]?.schema };
1018
+ }
1019
+ return { schema: undefined };
1020
+ };
1021
+
1022
+ // ============================================================================
1023
+ // Pagination detection (v0 detectPagination)
1024
+ // ============================================================================
1025
+
1026
+ interface DetectedPagination {
1027
+ mode: "cursor" | "page" | "token";
1028
+ inputToken: string;
1029
+ outputToken: string;
1030
+ items: string;
1031
+ }
1032
+
1033
+ const PAGINATION_INPUT_ALIASES: Record<string, readonly string[]> = {
1034
+ cursor: ["cursor", "page_token", "pageToken"],
1035
+ token: ["next_token", "NextToken", "nextToken"],
1036
+ page: ["page"],
1037
+ };
1038
+
1039
+ const detectPagination = (
1040
+ ctx: Ctx,
1041
+ params: readonly Param[],
1042
+ responseSchema: any,
1043
+ ): DetectedPagination | undefined => {
1044
+ if (!responseSchema) return undefined;
1045
+ const bag = flattenObject(ctx, responseSchema).properties;
1046
+ if (Object.keys(bag).length === 0) return undefined;
1047
+
1048
+ let mode: DetectedPagination["mode"] | undefined;
1049
+ let outputToken: string | undefined;
1050
+ const pag = bag["pagination"]
1051
+ ? flattenObject(ctx, bag["pagination"]).properties
1052
+ : undefined;
1053
+ if (pag?.["cursor"]) {
1054
+ mode = "cursor";
1055
+ outputToken = "pagination.cursor";
1056
+ } else if (pag?.["next"]) {
1057
+ mode = "cursor";
1058
+ outputToken = "pagination.next";
1059
+ } else if (pag?.["next_page"]) {
1060
+ mode = "page";
1061
+ outputToken = "pagination.next_page";
1062
+ } else if (bag["next_token"]) {
1063
+ mode = "token";
1064
+ outputToken = "next_token";
1065
+ } else if (bag["NextToken"]) {
1066
+ mode = "token";
1067
+ outputToken = "NextToken";
1068
+ } else if (bag["nextToken"]) {
1069
+ mode = "token";
1070
+ outputToken = "nextToken";
1071
+ } else if (bag["next_page"]) {
1072
+ mode = "page";
1073
+ outputToken = "next_page";
1074
+ }
1075
+ if (!mode || !outputToken) return undefined;
1076
+
1077
+ const aliases = PAGINATION_INPUT_ALIASES[mode]!;
1078
+ const inputToken = params.find(
1079
+ (p) => p.in === "query" && aliases.includes(p.name),
1080
+ )?.name;
1081
+ if (!inputToken) return undefined;
1082
+
1083
+ let items: string | undefined;
1084
+ for (const [k, v] of Object.entries(bag)) {
1085
+ if (k === "pagination" || k === "next_token" || k === "NextToken") continue;
1086
+ if (typeOf(deref(ctx, v)) === "array") {
1087
+ items = k;
1088
+ break;
1089
+ }
1090
+ }
1091
+ if (!items) return undefined;
1092
+
1093
+ return { mode, inputToken, outputToken, items };
1094
+ };
1095
+
1096
+ // ============================================================================
1097
+ // Main conversion
1098
+ // ============================================================================
1099
+
1100
+ const HTTP_METHODS = ["get", "post", "put", "patch", "delete"] as const;
1101
+
1102
+ const DEFAULT_STATUS_TO_ERROR_CLASS: Readonly<Record<string, string>> = {
1103
+ "400": "BadRequest",
1104
+ "403": "Forbidden",
1105
+ "404": "NotFound",
1106
+ "409": "Conflict",
1107
+ "422": "UnprocessableEntity",
1108
+ };
1109
+
1110
+ const DEFAULT_ERROR_STATUSES = ["401", "429", "500", "503"];
1111
+
1112
+ export const convertOpenApiToSmithy = (
1113
+ spec: unknown,
1114
+ options: OpenApiConvertOptions,
1115
+ ): SmithyModel => {
1116
+ const doc = spec as any;
1117
+ const version = detectVersion(doc);
1118
+ const ctx: Ctx = {
1119
+ spec: doc,
1120
+ version,
1121
+ ns: options.namespace,
1122
+ shapes: {},
1123
+ names: new Set(),
1124
+ refs: new Map(),
1125
+ dirSensitiveRefs: new Map(),
1126
+ sensitivePatterns: options.sensitivePatterns ?? SENSITIVE_FIELD_PATTERNS,
1127
+ };
1128
+ const statusToErrorClass =
1129
+ options.statusToErrorClass ?? DEFAULT_STATUS_TO_ERROR_CLASS;
1130
+ const defaultErrorStatuses = new Set(
1131
+ options.defaultErrorStatuses ?? DEFAULT_ERROR_STATUSES,
1132
+ );
1133
+ const skipDeprecated = options.skipDeprecated ?? true;
1134
+
1135
+ // Error class names and the service name are reserved up front so schema
1136
+ // components can never steal them.
1137
+ const errorIds = new Map<string, string>(); // class name → shape id
1138
+ for (const cls of new Set(Object.values(statusToErrorClass))) {
1139
+ errorIds.set(cls, `${ctx.ns}#${uniqueName(ctx, cls)}`);
1140
+ }
1141
+ const serviceName = uniqueName(ctx, options.serviceName);
1142
+
1143
+ const usedErrors = new Map<string, number>(); // class name → first status
1144
+ const serviceOps: Array<{ target: string }> = [];
1145
+
1146
+ for (const [rawPath, pathItem] of Object.entries(doc.paths ?? {})) {
1147
+ if (!pathItem || typeof pathItem !== "object") continue;
1148
+ for (const method of HTTP_METHODS) {
1149
+ const op = (pathItem as any)[method];
1150
+ if (!op || typeof op !== "object") continue;
1151
+ if (skipDeprecated && op.deprecated === true) continue;
1152
+
1153
+ const opName = pascal(
1154
+ typeof op.operationId === "string" && op.operationId
1155
+ ? op.operationId
1156
+ : `${method}_${rawPath}`,
1157
+ );
1158
+
1159
+ const params = collectParams(ctx, pathItem, op);
1160
+
1161
+ // ---- URI + labels (sanitize placeholder names to member idents) ----
1162
+ let uri = rawPath.split(/[?#]/)[0]!;
1163
+ const rawLabels = Array.from(uri.matchAll(/\{([^}]+)\}/g)).map(
1164
+ (m) => m[1]!,
1165
+ );
1166
+ const members: Record<string, any> = {};
1167
+ const addMember = (name: string, member: any): boolean => {
1168
+ if (name in members) return false;
1169
+ members[name] = member;
1170
+ return true;
1171
+ };
1172
+
1173
+ for (const raw of rawLabels) {
1174
+ const san = memberIdent(raw);
1175
+ if (san !== raw) uri = uri.split(`{${raw}}`).join(`{${san}}`);
1176
+ const p = params.find((x) => x.in === "path" && x.name === raw);
1177
+ addMember(san, {
1178
+ target: labelTarget(ctx, p?.schema, `${opName}Request${pascal(san)}`),
1179
+ traits: {
1180
+ "smithy.api#httpLabel": {},
1181
+ "smithy.api#required": {},
1182
+ ...(p?.description
1183
+ ? { "smithy.api#documentation": p.description }
1184
+ : {}),
1185
+ },
1186
+ });
1187
+ }
1188
+
1189
+ // ---- Query params ----
1190
+ for (const p of params) {
1191
+ if (p.in !== "query") continue;
1192
+ if (options.apiVersion !== undefined && p.name === "api-version") {
1193
+ continue;
1194
+ }
1195
+ const san = memberIdent(p.name);
1196
+ const conv = convertSchema(
1197
+ ctx,
1198
+ p.schema,
1199
+ `${opName}Request${pascal(p.name)}`,
1200
+ 0,
1201
+ "in",
1202
+ );
1203
+ addMember(san, {
1204
+ target: conv.target,
1205
+ traits: {
1206
+ "smithy.api#httpQuery": p.name,
1207
+ ...(p.required ? { "smithy.api#required": {} } : {}),
1208
+ ...(p.description
1209
+ ? { "smithy.api#documentation": p.description }
1210
+ : {}),
1211
+ },
1212
+ });
1213
+ }
1214
+
1215
+ // ---- Request body (json > form-urlencoded > multipart) ----
1216
+ let contentType: "form-urlencoded" | "multipart" | undefined;
1217
+ let bodySchema: any;
1218
+ let bodyRequired = false;
1219
+ if (version === "2.0") {
1220
+ const bodyParam = params.find((p) => p.in === "body");
1221
+ bodySchema = bodyParam?.schema;
1222
+ bodyRequired = bodyParam?.required === true;
1223
+ } else if (op.requestBody) {
1224
+ const rb = op.requestBody.$ref
1225
+ ? resolvePointer(doc, op.requestBody.$ref)
1226
+ : op.requestBody;
1227
+ bodyRequired = rb?.required === true;
1228
+ const content = rb?.content ?? {};
1229
+ if (content["application/json"]) {
1230
+ bodySchema = content["application/json"].schema;
1231
+ } else if (content["application/x-www-form-urlencoded"]) {
1232
+ bodySchema = content["application/x-www-form-urlencoded"].schema;
1233
+ contentType = "form-urlencoded";
1234
+ } else if (content["multipart/form-data"]) {
1235
+ bodySchema = content["multipart/form-data"].schema;
1236
+ contentType = "multipart";
1237
+ }
1238
+ }
1239
+ if (bodySchema !== undefined) {
1240
+ const flat = flattenObject(ctx, bodySchema);
1241
+ if (Object.keys(flat.properties).length > 0) {
1242
+ const bodyMembers = buildMembers(
1243
+ ctx,
1244
+ flat.properties,
1245
+ flat.required,
1246
+ `${opName}Request`,
1247
+ 0,
1248
+ "in",
1249
+ );
1250
+ for (const [mn, m] of Object.entries(bodyMembers)) {
1251
+ addMember(mn, m); // labels win, then query, then body
1252
+ }
1253
+ } else {
1254
+ // Non-flattenable body (bare array/scalar/map/union) → sole TYPED
1255
+ // `body` member sent as the entire request body
1256
+ // (`smithy.api#httpPayload`). A schema-less JSON body (empty or
1257
+ // bare-object schema → Document) gets NO body member at all — the
1258
+ // runtime's unknown-key passthrough is the escape hatch, and an
1259
+ // opaque `body: unknown` payload member would swallow the typed
1260
+ // surface.
1261
+ const conv = convertSchema(
1262
+ ctx,
1263
+ bodySchema,
1264
+ `${opName}RequestBody`,
1265
+ 0,
1266
+ "in",
1267
+ );
1268
+ if (conv.target !== PRELUDE.Document) {
1269
+ addMember("body", {
1270
+ target: conv.target,
1271
+ traits: {
1272
+ "smithy.api#httpPayload": {},
1273
+ ...(bodyRequired ? { "smithy.api#required": {} } : {}),
1274
+ ...(conv.nullable ? { [NULLABLE_TRAIT]: {} } : {}),
1275
+ },
1276
+ });
1277
+ }
1278
+ }
1279
+ }
1280
+
1281
+ // ---- Input shape ----
1282
+ const inputTarget =
1283
+ Object.keys(members).length > 0
1284
+ ? addShape(ctx, `${opName}Request`, {
1285
+ type: "structure",
1286
+ members,
1287
+ traits: { "smithy.api#input": {} },
1288
+ })
1289
+ : PRELUDE.Unit;
1290
+
1291
+ // ---- Output shape ----
1292
+ const { schema: respSchema } = successSchema(ctx, op.responses);
1293
+ let outputTarget: string = PRELUDE.Unit;
1294
+ if (respSchema !== undefined) {
1295
+ const flat = flattenObject(ctx, respSchema);
1296
+ if (Object.keys(flat.properties).length > 0) {
1297
+ const resolved = deref(ctx, respSchema);
1298
+ const isPlainRef =
1299
+ typeof respSchema.$ref === "string" &&
1300
+ !Array.isArray(resolved?.allOf) &&
1301
+ isNameable(ctx, resolved);
1302
+ if (isPlainRef) {
1303
+ // Reuse the named component shape as the output directly.
1304
+ outputTarget = convertSchema(
1305
+ ctx,
1306
+ respSchema,
1307
+ opName,
1308
+ 0,
1309
+ "out",
1310
+ ).target;
1311
+ } else {
1312
+ outputTarget = addShape(ctx, `${opName}Response`, {
1313
+ type: "structure",
1314
+ members: buildMembers(
1315
+ ctx,
1316
+ flat.properties,
1317
+ flat.required,
1318
+ `${opName}Response`,
1319
+ 0,
1320
+ "out",
1321
+ ),
1322
+ traits: { "smithy.api#output": {} },
1323
+ });
1324
+ }
1325
+ } else {
1326
+ // Non-flattenable response (bare array/scalar/map/union, or an
1327
+ // opaque object) → wrapper whose sole TYPED member IS the payload;
1328
+ // the SdkSpec's rootPipe collapses the wrapper.
1329
+ const conv = convertSchema(
1330
+ ctx,
1331
+ respSchema,
1332
+ `${opName}ResponseBody`,
1333
+ 0,
1334
+ "out",
1335
+ );
1336
+ if (conv.target !== PRELUDE.Document || deref(ctx, respSchema)) {
1337
+ outputTarget = addShape(ctx, `${opName}Response`, {
1338
+ type: "structure",
1339
+ members: {
1340
+ body: {
1341
+ target: conv.target,
1342
+ traits: {
1343
+ [RAW_RESPONSE_TRAIT]: {},
1344
+ "smithy.api#required": {},
1345
+ ...(conv.nullable ? { [NULLABLE_TRAIT]: {} } : {}),
1346
+ },
1347
+ },
1348
+ },
1349
+ traits: { "smithy.api#output": {} },
1350
+ });
1351
+ }
1352
+ }
1353
+ }
1354
+
1355
+ // ---- Errors ----
1356
+ const errors: Array<{ target: string }> = [];
1357
+ for (const status of Object.keys(op.responses ?? {})) {
1358
+ if (!/^[45]\d\d$/.test(status)) continue;
1359
+ if (defaultErrorStatuses.has(status)) continue;
1360
+ const cls = statusToErrorClass[status];
1361
+ if (!cls) continue;
1362
+ const id = errorIds.get(cls)!;
1363
+ if (!usedErrors.has(cls)) usedErrors.set(cls, Number(status));
1364
+ if (!errors.some((e) => e.target === id)) errors.push({ target: id });
1365
+ }
1366
+
1367
+ // ---- Pagination ----
1368
+ const pagination = detectPagination(ctx, params, respSchema);
1369
+
1370
+ // ---- Operation shape ----
1371
+ const httpTrait: Record<string, any> = {
1372
+ method: method.toUpperCase(),
1373
+ uri,
1374
+ code: 200,
1375
+ };
1376
+ if (contentType === "multipart") httpTrait.contentType = "multipart";
1377
+ const traits: Record<string, any> = { "smithy.api#http": httpTrait };
1378
+ const documentation = opDoc(op);
1379
+ if (documentation) traits["smithy.api#documentation"] = documentation;
1380
+ if (pagination) traits["smithy.api#paginated"] = pagination;
1381
+ if (contentType) traits[CONTENT_TYPE_TRAIT] = contentType;
1382
+ if (options.apiVersion !== undefined) {
1383
+ traits[API_VERSION_TRAIT] = options.apiVersion;
1384
+ }
1385
+
1386
+ const opId = addShape(ctx, opName, {
1387
+ type: "operation",
1388
+ input: { target: inputTarget },
1389
+ output: { target: outputTarget },
1390
+ ...(errors.length ? { errors } : {}),
1391
+ traits,
1392
+ });
1393
+ serviceOps.push({ target: opId });
1394
+ }
1395
+ }
1396
+
1397
+ // ---- Error shapes (one per used class) ----
1398
+ for (const [cls, status] of usedErrors) {
1399
+ const id = errorIds.get(cls)!;
1400
+ const base = {
1401
+ type: "structure",
1402
+ members: {},
1403
+ traits: {
1404
+ "smithy.api#error": status < 500 ? "client" : "server",
1405
+ "smithy.api#httpError": status,
1406
+ [ERROR_MATCHERS_TRAIT]: [{ status }],
1407
+ },
1408
+ };
1409
+ const override = options.errorShapes?.[cls];
1410
+ ctx.shapes[id] = override
1411
+ ? {
1412
+ ...base,
1413
+ ...override,
1414
+ traits: { ...base.traits, ...(override.traits ?? {}) },
1415
+ }
1416
+ : base;
1417
+ }
1418
+
1419
+ // ---- Service shape ----
1420
+ ctx.shapes[`${ctx.ns}#${serviceName}`] = {
1421
+ type: "service",
1422
+ version:
1423
+ options.serviceVersion ??
1424
+ (typeof doc.info?.version === "string" ? doc.info.version : "1.0"),
1425
+ operations: serviceOps,
1426
+ traits: {
1427
+ "smithy.api#title":
1428
+ typeof doc.info?.title === "string"
1429
+ ? doc.info.title
1430
+ : options.serviceName,
1431
+ ...(typeof doc.info?.description === "string"
1432
+ ? {
1433
+ "smithy.api#documentation": trimDescription(doc.info.description),
1434
+ }
1435
+ : {}),
1436
+ },
1437
+ };
1438
+
1439
+ return {
1440
+ smithy: "2.0",
1441
+ metadata: {
1442
+ suppressions: [
1443
+ { id: "HttpUriConflict", namespace: "*" },
1444
+ { id: "HttpMethodSemantics", namespace: "*" },
1445
+ { id: "UnreferencedShape", namespace: "*" },
1446
+ ],
1447
+ },
1448
+ shapes: ctx.shapes,
1449
+ };
1450
+ };