@distilled.cloud/core 1.0.0-rc.1 → 1.0.0-rc.10

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 (148) hide show
  1. package/LICENSE +201 -0
  2. package/lib/api.d.ts.map +1 -1
  3. package/lib/api.js +16 -4
  4. package/lib/api.js.map +1 -1
  5. package/lib/category.d.ts +4 -4
  6. package/lib/category.js +4 -4
  7. package/lib/codegen/boolean-string-enums.d.ts +36 -0
  8. package/lib/codegen/boolean-string-enums.d.ts.map +1 -0
  9. package/lib/codegen/boolean-string-enums.js +94 -0
  10. package/lib/codegen/boolean-string-enums.js.map +1 -0
  11. package/lib/codegen/boolean-string-enums.test.d.ts +2 -0
  12. package/lib/codegen/boolean-string-enums.test.d.ts.map +1 -0
  13. package/lib/codegen/boolean-string-enums.test.js +147 -0
  14. package/lib/codegen/boolean-string-enums.test.js.map +1 -0
  15. package/lib/codegen/cli.d.ts +56 -6
  16. package/lib/codegen/cli.d.ts.map +1 -1
  17. package/lib/codegen/cli.js +59 -75
  18. package/lib/codegen/cli.js.map +1 -1
  19. package/lib/codegen/emit.d.ts +3 -7
  20. package/lib/codegen/emit.d.ts.map +1 -1
  21. package/lib/codegen/emit.js +6 -10
  22. package/lib/codegen/emit.js.map +1 -1
  23. package/lib/codegen/generator.d.ts.map +1 -1
  24. package/lib/codegen/generator.js +142 -20
  25. package/lib/codegen/generator.js.map +1 -1
  26. package/lib/codegen/graphql-client.d.ts +62 -0
  27. package/lib/codegen/graphql-client.d.ts.map +1 -0
  28. package/lib/codegen/graphql-client.js +294 -0
  29. package/lib/codegen/graphql-client.js.map +1 -0
  30. package/lib/codegen/graphql-client.test.d.ts +2 -0
  31. package/lib/codegen/graphql-client.test.d.ts.map +1 -0
  32. package/lib/codegen/graphql-client.test.js +311 -0
  33. package/lib/codegen/graphql-client.test.js.map +1 -0
  34. package/lib/codegen/graphql.d.ts +207 -0
  35. package/lib/codegen/graphql.d.ts.map +1 -0
  36. package/lib/codegen/graphql.js +799 -0
  37. package/lib/codegen/graphql.js.map +1 -0
  38. package/lib/codegen/openapi-cli.d.ts +17 -3
  39. package/lib/codegen/openapi-cli.d.ts.map +1 -1
  40. package/lib/codegen/openapi-cli.js +50 -48
  41. package/lib/codegen/openapi-cli.js.map +1 -1
  42. package/lib/codegen/openapi.d.ts +69 -6
  43. package/lib/codegen/openapi.d.ts.map +1 -1
  44. package/lib/codegen/openapi.js +173 -16
  45. package/lib/codegen/openapi.js.map +1 -1
  46. package/lib/codegen/patches.d.ts +65 -0
  47. package/lib/codegen/patches.d.ts.map +1 -0
  48. package/lib/codegen/patches.js +236 -0
  49. package/lib/codegen/patches.js.map +1 -0
  50. package/lib/codegen/patches.test.d.ts +2 -0
  51. package/lib/codegen/patches.test.d.ts.map +1 -0
  52. package/lib/codegen/patches.test.js +105 -0
  53. package/lib/codegen/patches.test.js.map +1 -0
  54. package/lib/codegen/proto.d.ts +121 -0
  55. package/lib/codegen/proto.d.ts.map +1 -0
  56. package/lib/codegen/proto.js +962 -0
  57. package/lib/codegen/proto.js.map +1 -0
  58. package/lib/codegen/rewrite-operation-ids.d.ts +131 -0
  59. package/lib/codegen/rewrite-operation-ids.d.ts.map +1 -0
  60. package/lib/codegen/rewrite-operation-ids.js +1079 -0
  61. package/lib/codegen/rewrite-operation-ids.js.map +1 -0
  62. package/lib/codegen/rewrite-operation-ids.test.d.ts +2 -0
  63. package/lib/codegen/rewrite-operation-ids.test.d.ts.map +1 -0
  64. package/lib/codegen/rewrite-operation-ids.test.js +533 -0
  65. package/lib/codegen/rewrite-operation-ids.test.js.map +1 -0
  66. package/lib/codegen/spec-path.d.ts +16 -0
  67. package/lib/codegen/spec-path.d.ts.map +1 -0
  68. package/lib/codegen/spec-path.js +101 -0
  69. package/lib/codegen/spec-path.js.map +1 -0
  70. package/lib/errors.d.ts.map +1 -1
  71. package/lib/errors.js +17 -13
  72. package/lib/errors.js.map +1 -1
  73. package/lib/graphql.d.ts +284 -0
  74. package/lib/graphql.d.ts.map +1 -0
  75. package/lib/graphql.fixture.d.ts +249 -0
  76. package/lib/graphql.fixture.d.ts.map +1 -0
  77. package/lib/graphql.fixture.js +240 -0
  78. package/lib/graphql.fixture.js.map +1 -0
  79. package/lib/graphql.js +718 -0
  80. package/lib/graphql.js.map +1 -0
  81. package/lib/graphql.test.d.ts +2 -0
  82. package/lib/graphql.test.d.ts.map +1 -0
  83. package/lib/graphql.test.js +780 -0
  84. package/lib/graphql.test.js.map +1 -0
  85. package/lib/graphql.types.d.ts +2 -0
  86. package/lib/graphql.types.d.ts.map +1 -0
  87. package/lib/graphql.types.js +45 -0
  88. package/lib/graphql.types.js.map +1 -0
  89. package/lib/json-patch.d.ts +18 -11
  90. package/lib/json-patch.d.ts.map +1 -1
  91. package/lib/json-patch.js +63 -25
  92. package/lib/json-patch.js.map +1 -1
  93. package/lib/pagination.d.ts +43 -3
  94. package/lib/pagination.d.ts.map +1 -1
  95. package/lib/pagination.js +91 -5
  96. package/lib/pagination.js.map +1 -1
  97. package/lib/protocol-http.d.ts.map +1 -1
  98. package/lib/protocol-http.js +62 -26
  99. package/lib/protocol-http.js.map +1 -1
  100. package/lib/protocol-http.test.d.ts +2 -0
  101. package/lib/protocol-http.test.d.ts.map +1 -0
  102. package/lib/protocol-http.test.js +88 -0
  103. package/lib/protocol-http.test.js.map +1 -0
  104. package/lib/protocol-rest.d.ts +12 -2
  105. package/lib/protocol-rest.d.ts.map +1 -1
  106. package/lib/protocol-rest.js +17 -3
  107. package/lib/protocol-rest.js.map +1 -1
  108. package/lib/retry.js +1 -1
  109. package/lib/schema.d.ts +1 -1
  110. package/lib/schema.d.ts.map +1 -1
  111. package/lib/schema.js +1 -1
  112. package/lib/schema.js.map +1 -1
  113. package/lib/trait.d.ts +27 -3
  114. package/lib/trait.d.ts.map +1 -1
  115. package/lib/trait.js +17 -1
  116. package/lib/trait.js.map +1 -1
  117. package/package.json +13 -10
  118. package/src/api.ts +18 -4
  119. package/src/category.ts +4 -4
  120. package/src/codegen/boolean-string-enums.test.ts +168 -0
  121. package/src/codegen/boolean-string-enums.ts +106 -0
  122. package/src/codegen/cli.ts +127 -110
  123. package/src/codegen/emit.ts +6 -10
  124. package/src/codegen/generator.ts +151 -21
  125. package/src/codegen/graphql-client.test.ts +386 -0
  126. package/src/codegen/graphql-client.ts +419 -0
  127. package/src/codegen/graphql.ts +1217 -0
  128. package/src/codegen/openapi-cli.ts +75 -59
  129. package/src/codegen/openapi.ts +255 -16
  130. package/src/codegen/patches.test.ts +130 -0
  131. package/src/codegen/patches.ts +291 -0
  132. package/src/codegen/proto.ts +1128 -0
  133. package/src/codegen/rewrite-operation-ids.test.ts +563 -0
  134. package/src/codegen/rewrite-operation-ids.ts +1206 -0
  135. package/src/codegen/spec-path.ts +115 -0
  136. package/src/errors.ts +20 -25
  137. package/src/graphql.fixture.ts +371 -0
  138. package/src/graphql.test.ts +974 -0
  139. package/src/graphql.ts +1321 -0
  140. package/src/graphql.types.ts +185 -0
  141. package/src/json-patch.ts +82 -25
  142. package/src/pagination.ts +134 -7
  143. package/src/protocol-http.test.ts +107 -0
  144. package/src/protocol-http.ts +67 -31
  145. package/src/protocol-rest.ts +28 -4
  146. package/src/retry.ts +1 -1
  147. package/src/schema.ts +2 -2
  148. package/src/trait.ts +39 -3
@@ -2,29 +2,29 @@
2
2
  * OpenAPI → Smithy pipeline helper (dev-time only, provider-agnostic).
3
3
  *
4
4
  * Owns the spec-side pipeline every OpenAPI-sourced provider shares: read the
5
- * spec file, apply the RFC-6902 patch chain to the OpenAPI document (v0
6
- * semantics: `*.patch.json` files in sorted name order, `{ description,
7
- * patches }` shape; stale targets warn+skip, malformed patches fail the run),
8
- * run the optional preprocess hook, convert with
9
- * {@link convertOpenApiToSmithy}, and write `.generated-specs/<name>.json`.
5
+ * spec file, apply OpenAPI RFC-6902 ops to the document, convert with
6
+ * {@link convertOpenApiToSmithy} (which owns verbNoun naming), apply Smithy
7
+ * RFC-6902 ops (`/shapes`, `/metadata`) to the model, and write
8
+ * `.generated-specs/<name>.json`. Stale targets fail unless
9
+ * `onStalePatch: "warn"`.
10
10
  *
11
- * A provider's `scripts/convert.ts` is: a `runOpenApiConvert` call. The
12
- * separate `scripts/generate.ts` (an SdkSpec + `runGeneratorCli`) then
13
- * compiles the written models. Note: providers whose OpenAPI patches live in
14
- * `patches/` should pass `patchesDir: false` to `runGeneratorCli` — the
15
- * patch chain applies HERE, to the OpenAPI document, not to the Smithy model.
11
+ * A provider's `scripts/convert.ts` is: a `runOpenApiConvert` call.
12
+ * `scripts/generate.ts` compiles the already-patched models and does not
13
+ * apply patches.
16
14
  */
17
15
  import * as fs from "node:fs/promises";
18
16
  import * as path from "node:path";
19
- import {
20
- applyOperation,
21
- isStaleTargetError,
22
- type PatchFile,
23
- } from "../json-patch.ts";
24
17
  import {
25
18
  convertOpenApiToSmithy,
26
19
  type OpenApiConvertOptions,
27
20
  } from "./openapi.ts";
21
+ import {
22
+ applyRfc6902Files,
23
+ finalizeConvert,
24
+ isSmithyPatchPath,
25
+ listRfc6902PatchFiles,
26
+ } from "./patches.ts";
27
+ import { resolveSpecPath } from "./spec-path.ts";
28
28
 
29
29
  export interface OpenApiSpecEntry {
30
30
  /** Output model name — written to `<outDir>/<name>.json`. */
@@ -35,7 +35,7 @@ export interface OpenApiSpecEntry {
35
35
  * Hook between patching and conversion (e.g. path prefixing, server
36
36
  * rewrites). May mutate the spec in place or return a replacement.
37
37
  */
38
- readonly preprocess?: (spec: any) => unknown | void;
38
+ readonly preprocess?: (spec: any) => unknown | void | Promise<unknown | void>;
39
39
  /** Per-spec converter option overrides (merged over the shared options). */
40
40
  readonly options?: Partial<OpenApiConvertOptions>;
41
41
  }
@@ -46,9 +46,10 @@ export interface RunOpenApiConvertOptions {
46
46
  readonly specs: readonly OpenApiSpecEntry[];
47
47
  /**
48
48
  * RFC-6902 patch chain root, relative to `root`. Default `"patches"`;
49
- * `false` disables. Layout: `<patchesDir>/<name>/*.patch.json` when the
49
+ * `false` disables. Layout: `<patchesDir>/<name>/*.json` when the
50
50
  * per-spec directory exists; for single-spec providers a flat
51
- * `<patchesDir>/*.patch.json` also works.
51
+ * `<patchesDir>/*.json` also works. Ops under `/shapes` or `/metadata`
52
+ * apply to the Smithy model after conversion; the rest apply to OpenAPI.
52
53
  */
53
54
  readonly patchesDir?: string | false;
54
55
  /** Output directory, relative to `root`. Default `".generated-specs"`. */
@@ -60,6 +61,19 @@ export interface RunOpenApiConvertOptions {
60
61
  readonly parse?: (text: string, specPath: string) => unknown;
61
62
  /** Shared converter options (per-spec `options` merge over these). */
62
63
  readonly options: OpenApiConvertOptions;
64
+ /**
65
+ * What to do when a patch JSON pointer does not resolve. Default `"fail"`:
66
+ * silent skip is how a whole patch chain can vanish after an upstream path
67
+ * prefix change. `"warn"` restores the old skip-and-continue behaviour.
68
+ */
69
+ readonly onStalePatch?: "fail" | "warn";
70
+ /**
71
+ * Run {@link finalizeConvert} on the written models (reference check +
72
+ * finalized marker; naming already happened in the converter). Default
73
+ * true. Pass false when the caller finalizes once after several convert
74
+ * steps (Fly machines + sprites + addons).
75
+ */
76
+ readonly finalize?: boolean;
63
77
  }
64
78
 
65
79
  const exists = async (p: string): Promise<boolean> => {
@@ -81,6 +95,7 @@ export const runOpenApiConvert = async (
81
95
  ? undefined
82
96
  : path.resolve(o.root, o.patchesDir ?? "patches");
83
97
  const parse = o.parse ?? ((text: string) => JSON.parse(text));
98
+ const onStalePatch = o.onStalePatch ?? "fail";
84
99
 
85
100
  await fs.mkdir(outDir, { recursive: true });
86
101
 
@@ -88,10 +103,11 @@ export const runOpenApiConvert = async (
88
103
  console.log(` Output: ${outDir}`);
89
104
 
90
105
  for (const entry of o.specs) {
91
- const specPath = path.resolve(o.root, entry.specPath);
106
+ // Production path by default; `specs/.local` under DISTILLED_SPECS_LOCAL.
107
+ const specPath = resolveSpecPath(o.root, entry.specPath);
92
108
  let spec: any = parse(await fs.readFile(specPath, "utf8"), specPath);
93
109
 
94
- // ---- RFC-6902 patch chain (applies to the OpenAPI document) ----
110
+ // ---- RFC-6902: OpenAPI ops before convert, Smithy ops after ----
95
111
  let patchDir: string | undefined;
96
112
  if (patchRoot && (await exists(patchRoot))) {
97
113
  const perSpec = path.join(patchRoot, entry.name);
@@ -101,66 +117,66 @@ export const runOpenApiConvert = async (
101
117
  patchDir = patchRoot;
102
118
  }
103
119
  }
104
- let patchFileCount = 0;
105
- let staleOps = 0;
106
- const badPatches: string[] = [];
107
- if (patchDir) {
108
- const files = (await fs.readdir(patchDir))
109
- .filter((f) => f.endsWith(".patch.json"))
110
- .sort((a, b) => a.localeCompare(b));
111
- for (const pf of files) {
112
- const parsed = JSON.parse(
113
- await fs.readFile(path.join(patchDir, pf), "utf8"),
114
- ) as PatchFile;
115
- for (const patchOp of parsed.patches ?? []) {
116
- try {
117
- applyOperation(spec, patchOp);
118
- } catch (e) {
119
- const msg = e instanceof Error ? e.message : String(e);
120
- if (isStaleTargetError(msg)) {
121
- // Spec drift — the patch target no longer exists upstream.
122
- staleOps++;
123
- console.warn(
124
- ` ⚠️ stale: ${entry.name}/${pf} [${patchOp.op} ${patchOp.path}]`,
125
- );
126
- } else {
127
- badPatches.push(
128
- `${entry.name}/${pf} [${patchOp.op} ${patchOp.path}]: ${msg}`,
129
- );
130
- }
131
- }
132
- }
133
- patchFileCount++;
134
- }
135
- }
136
- if (badPatches.length) {
137
- for (const b of badPatches) console.error(`❌ bad patch: ${b}`);
120
+ const patchFiles = patchDir ? await listRfc6902PatchFiles(patchDir) : [];
121
+ const patchLabel = (file: string) => `${entry.name}/${path.basename(file)}`;
122
+ const openapiPatches = await applyRfc6902Files(spec, patchFiles, {
123
+ onStalePatch,
124
+ include: (op) => !isSmithyPatchPath(op.path),
125
+ label: patchLabel,
126
+ });
127
+ if (openapiPatches.errors.length) {
128
+ for (const b of openapiPatches.errors)
129
+ console.error(`❌ bad patch: ${b}`);
138
130
  throw new Error(
139
- `${badPatches.length} malformed patch operation(s) — fix or remove them`,
131
+ `${openapiPatches.errors.length} patch operation(s) failed — fix the pointers or delete the patch`,
140
132
  );
141
133
  }
142
134
 
143
135
  // ---- Preprocess hook ----
144
136
  if (entry.preprocess) {
145
- const replaced = entry.preprocess(spec);
137
+ const replaced = await entry.preprocess(spec);
146
138
  if (replaced !== undefined) spec = replaced;
147
139
  }
148
140
 
149
- // ---- Convert + write ----
141
+ // ---- Convert, Smithy patches, write ----
150
142
  const model = convertOpenApiToSmithy(spec, {
151
143
  ...o.options,
152
144
  ...entry.options,
153
145
  });
146
+ const smithyPatches = await applyRfc6902Files(model, patchFiles, {
147
+ onStalePatch,
148
+ include: (op) => isSmithyPatchPath(op.path),
149
+ label: patchLabel,
150
+ });
151
+ if (smithyPatches.errors.length) {
152
+ for (const b of smithyPatches.errors) console.error(`❌ bad patch: ${b}`);
153
+ throw new Error(
154
+ `${smithyPatches.errors.length} Smithy patch operation(s) failed — fix the pointers or delete the patch`,
155
+ );
156
+ }
154
157
  const opCount = Object.values(model.shapes).filter(
155
158
  (s: any) => s.type === "operation",
156
159
  ).length;
157
160
  const outPath = path.join(outDir, `${entry.name}.json`);
158
161
  await fs.writeFile(outPath, JSON.stringify(model, null, 2) + "\n");
162
+ const staleOps = openapiPatches.stale + smithyPatches.stale;
159
163
  console.log(
160
164
  ` ✅ ${entry.name}: ${opCount} operations, ${Object.keys(model.shapes).length} shapes` +
161
- (patchFileCount
162
- ? ` (${patchFileCount} patch file(s) applied${staleOps ? `, ${staleOps} stale op(s) skipped` : ""})`
165
+ (patchFiles.length
166
+ ? ` (${patchFiles.length} patch file(s) applied${staleOps ? `, ${staleOps} stale op(s) skipped` : ""})`
163
167
  : ""),
164
168
  );
165
169
  }
170
+
171
+ if (o.finalize !== false) {
172
+ const written = new Set(o.specs.map((s) => s.name));
173
+ await finalizeConvert({
174
+ root: o.root,
175
+ outDir,
176
+ // Patches and naming already ran above.
177
+ patchesDir: false,
178
+ operationNaming: "as-is",
179
+ include: (resource) => written.has(resource),
180
+ });
181
+ }
166
182
  };
@@ -7,20 +7,28 @@
7
7
  * `generateService` compiler. Conversion fidelity follows distilled v0's
8
8
  * `generate-openapi.ts` feature matrix:
9
9
  *
10
- * • one operation per (path × get/post/put/patch/delete), deprecated
11
- * skipped by default; op shape name = PascalCase(operationId)
10
+ * • one operation per (path × get/post/put/patch/delete — plus head/options
11
+ * when a provider opts in via `extraHttpMethods`), deprecated skipped by
12
+ * default; op shape name = PascalCase(operationId), or verbNoun
13
+ * (`Apps_list` → `ListApps`) when {@link OpenApiConvertOptions.operationNaming}
14
+ * is `"verbNoun"`
12
15
  * • 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);
16
+ * query params → `smithy.api#httpQuery`, header params →
17
+ * `smithy.api#httpHeader` when `headerParams` is on (dropped otherwise,
18
+ * as cookie params always are),
19
+ * body properties flattened alongside
20
+ * (labels win, then query, then headers, then body);
15
21
  * non-object bodies become a sole `body` member with `smithy.api#httpPayload`
16
22
  * • `$ref`s become NAMED shapes (`components/schemas/X` → `<ns>#X`), reused
17
23
  * across operations; anonymous nested objects synthesize names from the
18
24
  * parent + member path
19
- * • responses: 200 → 201 → 204 precedence, `application/json` only; object
25
+ * • responses: 200 → 201 → 204 precedence (`successStatuses` overrides),
26
+ * `application/json` only; object
20
27
  * results become `<Op>Response` structures (a sole `$ref` reuses the named
21
28
  * shape); bare array/scalar results wrap in a structure whose single
22
29
  * member carries `com.distilled.openapi#rawResponse` (the SdkSpec maps it
23
- * to a root pipe); no schema → `smithy.api#Unit`
30
+ * to a root pipe); no schema → `smithy.api#Unit`; `head` is always
31
+ * `smithy.api#Unit`, whatever content the spec declares
24
32
  * • nullability (3.0 `nullable`, 3.1 `type: [..., "null"]`, 2.0
25
33
  * `x-nullable`, `oneOf`/`anyOf` null branches) → member-level
26
34
  * `com.distilled.openapi#nullable` trait
@@ -36,6 +44,15 @@
36
44
  * (with the non-standard `mode` member the core runtime dispatches on)
37
45
  */
38
46
 
47
+ import {
48
+ isMechanicalOperationId,
49
+ isVerbatimRouteId,
50
+ pathToVerbNoun,
51
+ resolveOperationName,
52
+ toVerbNoun,
53
+ type OperationIdRewrite,
54
+ } from "./rewrite-operation-ids.ts";
55
+
39
56
  // ============================================================================
40
57
  // Trait ids (the `com.distilled.openapi` vocabulary)
41
58
  // ============================================================================
@@ -122,6 +139,37 @@ export interface OpenApiConvertOptions {
122
139
  readonly defaultErrorStatuses?: Iterable<string>;
123
140
  /** Skip operations marked `deprecated: true`. Default true. */
124
141
  readonly skipDeprecated?: boolean;
142
+ /**
143
+ * HTTP methods to convert IN ADDITION to get/post/put/patch/delete —
144
+ * currently `"head"` and `"options"`. Opt-in, so a provider that models
145
+ * them (Vercel probes cache artifacts and sandbox files with HEAD) gets
146
+ * them without every other provider silently gaining operations.
147
+ *
148
+ * `head` operations always output `smithy.api#Unit` — see the output-shape
149
+ * comment in {@link convertOpenApiToSmithy} for why a declared response
150
+ * body on a HEAD can never arrive.
151
+ */
152
+ readonly extraHttpMethods?: readonly ("head" | "options")[];
153
+ /**
154
+ * Emit `in: header` parameters as `smithy.api#httpHeader` members instead
155
+ * of dropping them. Opt-in for the same reason as
156
+ * {@link extraHttpMethods}: turning it on adds input members to every
157
+ * operation whose spec declares a header parameter, and most providers
158
+ * declare ones the protocol already sends itself (Accept, Authorization,
159
+ * an API-version pin). Providers whose headers are real per-call inputs
160
+ * — Vercel's `x-Artifact-*` remote-cache metadata, `x-Vercel-Digest` —
161
+ * ask for them.
162
+ */
163
+ readonly headerParams?: boolean;
164
+ /**
165
+ * Response statuses to read the operation's output shape from, most
166
+ * preferred first. Default `["200", "201", "204"]`. Extend it for an API
167
+ * that answers asynchronous work with a body under another status — Vercel
168
+ * returns `202 Accepted` with a payload from nine endpoints (artifact
169
+ * upload, account deletion, VCR blob/manifest writes), which would
170
+ * otherwise generate as a `void` output.
171
+ */
172
+ readonly successStatuses?: readonly string[];
125
173
  /**
126
174
  * Azure-style fixed `api-version`: drops `api-version` query params and
127
175
  * stamps {@link API_VERSION_TRAIT} on every operation.
@@ -140,6 +188,29 @@ export interface OpenApiConvertOptions {
140
188
  * {@link ERROR_MATCHERS_TRAIT} traits; overrides are merged over it.
141
189
  */
142
190
  readonly errorShapes?: Readonly<Record<string, any>>;
191
+ /**
192
+ * How to turn an OpenAPI `operationId` into the Smithy/SDK operation name.
193
+ * This is a convert policy, not a spec patch — the OpenAPI document is
194
+ * left alone.
195
+ *
196
+ * - `"verbNoun"` (default): `Apps_list` / `ConfigsList` / `showContact` →
197
+ * `listApps` / `listConfigs` / `getApp`. Already verb-first ids stay.
198
+ * An operation with no `operationId`, or a mechanical one that only
199
+ * restates the method and path (`get-api-card`, `post_v1_users`), is
200
+ * named from the route instead: `GET /users` → `listUsers`,
201
+ * `GET /users/{id}` → `getUser`, `POST /users/{id}/reset` →
202
+ * `resetUser`.
203
+ * - `"as-is"`: `PascalCase(operationId)` (`Apps_list` → `AppsList`).
204
+ *
205
+ * {@link operationNames} overrides win (lookup by `"METHOD path"`, then
206
+ * operationId).
207
+ */
208
+ readonly operationNaming?: "as-is" | "verbNoun";
209
+ /**
210
+ * Per-operation name overrides for {@link operationNaming}. Use
211
+ * `"METHOD path"` keys when two methods share an `operationId`.
212
+ */
213
+ readonly operationNames?: OperationIdRewrite;
143
214
  }
144
215
 
145
216
  export interface SmithyModel {
@@ -177,6 +248,24 @@ const memberIdent = (name: string): string => {
177
248
  return out || "_";
178
249
  };
179
250
 
251
+ /**
252
+ * Header name → member identifier (`x-Artifact-Tag` → `xArtifactTag`). The
253
+ * wire name is carried by the `smithy.api#httpHeader` trait, so the member
254
+ * can read like the rest of the input rather than like a header.
255
+ */
256
+ const headerMemberName = (name: string): string => {
257
+ const parts = name.split(/[^A-Za-z0-9]+/).filter(Boolean);
258
+ if (parts.length === 0) return "_";
259
+ const out = parts
260
+ .map((p, i) =>
261
+ i === 0
262
+ ? p.charAt(0).toLowerCase() + p.slice(1)
263
+ : p.charAt(0).toUpperCase() + p.slice(1),
264
+ )
265
+ .join("");
266
+ return /^[0-9]/.test(out) ? `_${out}` : out;
267
+ };
268
+
180
269
  const enumMemberName = (value: string): string => {
181
270
  let out = value
182
271
  .toUpperCase()
@@ -763,7 +852,14 @@ const convertSchema = (
763
852
  const item = convertSchema(ctx, def.items, `${hint}Item`, depth + 1, dir);
764
853
  return {
765
854
  target: emit(
766
- { type: "list", member: { target: item.target }, traits: docTraits },
855
+ {
856
+ type: "list",
857
+ member: {
858
+ target: item.target,
859
+ ...(item.nullable ? { traits: { [NULLABLE_TRAIT]: {} } } : {}),
860
+ },
861
+ traits: docTraits,
862
+ },
767
863
  reservedId !== undefined ? hint : `${hint}List`,
768
864
  ),
769
865
  nullable,
@@ -800,7 +896,10 @@ const convertSchema = (
800
896
  {
801
897
  type: "map",
802
898
  key: { target: PRELUDE.String },
803
- value: { target: value.target },
899
+ value: {
900
+ target: value.target,
901
+ ...(value.nullable ? { traits: { [NULLABLE_TRAIT]: {} } } : {}),
902
+ },
804
903
  traits: docTraits,
805
904
  },
806
905
  reservedId !== undefined ? hint : `${hint}Map`,
@@ -937,7 +1036,11 @@ const collectParams = (ctx: Ctx, pathItem: any, op: any): Param[] => {
937
1036
  byKey.set(`${p.in}${p.name}`, p);
938
1037
  }
939
1038
  return [...byKey.values()].filter(
940
- (p) => p.in === "path" || p.in === "query" || p.in === "body",
1039
+ (p) =>
1040
+ p.in === "path" ||
1041
+ p.in === "query" ||
1042
+ p.in === "body" ||
1043
+ p.in === "header",
941
1044
  );
942
1045
  };
943
1046
 
@@ -1001,12 +1104,13 @@ const opDoc = (op: any): string | undefined => {
1001
1104
  // Responses
1002
1105
  // ============================================================================
1003
1106
 
1004
- /** 200 → 201 → 204 precedence; response-level `$ref` resolved; JSON only. */
1107
+ /** First declared status in `order` wins; response-level `$ref` resolved; JSON only. */
1005
1108
  const successSchema = (
1006
1109
  ctx: Ctx,
1007
1110
  responses: any,
1111
+ order: readonly string[],
1008
1112
  ): { schema: any | undefined } => {
1009
- for (const code of ["200", "201", "204"]) {
1113
+ for (const code of order) {
1010
1114
  const raw = responses?.[code];
1011
1115
  if (!raw) continue;
1012
1116
  const resp = raw.$ref ? resolvePointer(ctx.spec, raw.$ref) : raw;
@@ -1019,6 +1123,37 @@ const successSchema = (
1019
1123
  return { schema: undefined };
1020
1124
  };
1021
1125
 
1126
+ /**
1127
+ * Whether the success body is a collection: an array, or an object whose
1128
+ * array members outnumber its scalar ones (envelopes like `{ data: [] }`,
1129
+ * `{ items: [], total }`). `undefined` when there is no body to judge.
1130
+ */
1131
+ const responseIsCollection = (
1132
+ ctx: Ctx,
1133
+ responses: any,
1134
+ order: readonly string[],
1135
+ ): boolean | undefined => {
1136
+ const { schema } = successSchema(ctx, responses, order);
1137
+ if (!schema) return undefined;
1138
+ const s = deref(ctx, schema);
1139
+ if (!s || typeof s !== "object") return undefined;
1140
+ if (s.type === "array" || s.items) return true;
1141
+ const props = s.properties;
1142
+ if (!props || typeof props !== "object") {
1143
+ return s.allOf || s.oneOf || s.anyOf ? undefined : false;
1144
+ }
1145
+ let arrays = 0;
1146
+ let others = 0;
1147
+ for (const prop of Object.values(props)) {
1148
+ const p = deref(ctx, prop);
1149
+ if (p?.type === "array" || p?.items) arrays++;
1150
+ else others++;
1151
+ }
1152
+ if (arrays === 0) return false;
1153
+ // `{ data: [...] }`, `{ items, next_cursor }`, `{ results, count, page }`
1154
+ return arrays >= 1 && others <= 3;
1155
+ };
1156
+
1022
1157
  // ============================================================================
1023
1158
  // Pagination detection (v0 detectPagination)
1024
1159
  // ============================================================================
@@ -1099,6 +1234,14 @@ const detectPagination = (
1099
1234
 
1100
1235
  const HTTP_METHODS = ["get", "post", "put", "patch", "delete"] as const;
1101
1236
 
1237
+ /**
1238
+ * Methods a spec may declare beyond {@link HTTP_METHODS}, opt-in via
1239
+ * {@link OpenApiConvertOptions.extraHttpMethods} — a provider that models
1240
+ * `head` (Vercel's cache-artifact and file existence probes) asks for it
1241
+ * rather than every provider silently gaining operations on regeneration.
1242
+ */
1243
+ const OPTIONAL_HTTP_METHODS = ["head", "options"] as const;
1244
+
1102
1245
  const DEFAULT_STATUS_TO_ERROR_CLASS: Readonly<Record<string, string>> = {
1103
1246
  "400": "BadRequest",
1104
1247
  "403": "Forbidden",
@@ -1109,6 +1252,8 @@ const DEFAULT_STATUS_TO_ERROR_CLASS: Readonly<Record<string, string>> = {
1109
1252
 
1110
1253
  const DEFAULT_ERROR_STATUSES = ["401", "429", "500", "503"];
1111
1254
 
1255
+ const DEFAULT_SUCCESS_STATUSES = ["200", "201", "204"];
1256
+
1112
1257
  export const convertOpenApiToSmithy = (
1113
1258
  spec: unknown,
1114
1259
  options: OpenApiConvertOptions,
@@ -1131,6 +1276,13 @@ export const convertOpenApiToSmithy = (
1131
1276
  options.defaultErrorStatuses ?? DEFAULT_ERROR_STATUSES,
1132
1277
  );
1133
1278
  const skipDeprecated = options.skipDeprecated ?? true;
1279
+ const successStatuses = options.successStatuses ?? DEFAULT_SUCCESS_STATUSES;
1280
+ const httpMethods = [
1281
+ ...HTTP_METHODS,
1282
+ ...OPTIONAL_HTTP_METHODS.filter((m) =>
1283
+ options.extraHttpMethods?.includes(m),
1284
+ ),
1285
+ ];
1134
1286
 
1135
1287
  // Error class names and the service name are reserved up front so schema
1136
1288
  // components can never steal them.
@@ -1145,16 +1297,65 @@ export const convertOpenApiToSmithy = (
1145
1297
 
1146
1298
  for (const [rawPath, pathItem] of Object.entries(doc.paths ?? {})) {
1147
1299
  if (!pathItem || typeof pathItem !== "object") continue;
1148
- for (const method of HTTP_METHODS) {
1300
+ for (const method of httpMethods) {
1149
1301
  const op = (pathItem as any)[method];
1150
1302
  if (!op || typeof op !== "object") continue;
1151
1303
  if (skipDeprecated && op.deprecated === true) continue;
1152
1304
 
1153
- const opName = pascal(
1305
+ const rawOperationId =
1154
1306
  typeof op.operationId === "string" && op.operationId
1155
1307
  ? op.operationId
1156
- : `${method}_${rawPath}`,
1157
- );
1308
+ : `${method}_${rawPath}`;
1309
+ const idCtx = { path: rawPath, method };
1310
+ const naming = options.operationNaming ?? "verbNoun";
1311
+ let named =
1312
+ options.operationNames !== undefined
1313
+ ? resolveOperationName(options.operationNames, rawOperationId, idCtx)
1314
+ : undefined;
1315
+ if (named === undefined && naming === "verbNoun") {
1316
+ // A mechanical id (`get-api-card`, or none at all) is named from
1317
+ // the route. A hand-chosen one that merely starts with the method
1318
+ // and reads the route in order (`get-feeds` on /feeds) keeps its
1319
+ // own nouns; only the HTTP-method verb is normalised.
1320
+ if (typeof op.operationId !== "string" || !op.operationId) {
1321
+ // No id: everything comes from the route, including whether a
1322
+ // GET on a collection route is `list` or `get`.
1323
+ named = pathToVerbNoun(idCtx, {
1324
+ returnsCollection: responseIsCollection(
1325
+ ctx,
1326
+ op.responses,
1327
+ successStatuses,
1328
+ ),
1329
+ });
1330
+ } else if (isMechanicalOperationId(op.operationId, idCtx)) {
1331
+ // Method-prefixed and reading the route (`get-feeds`,
1332
+ // `postV1AppsByAppIdPromote`, `deleteProjectJWKS`): keep the
1333
+ // author's tokens and casing; normalise `post`/`patch`, drop
1334
+ // parameter clauses (`ByAppId`, `ById`) and api/version roots.
1335
+ named = pathToVerbNoun(idCtx, {
1336
+ nouns: op.operationId,
1337
+ verbatim: isVerbatimRouteId(op.operationId, idCtx),
1338
+ });
1339
+ } else {
1340
+ named = toVerbNoun(rawOperationId);
1341
+ }
1342
+ }
1343
+ let resolved: string = named ?? rawOperationId;
1344
+ // Two routes can derive the same name (`/collections/{slug}` and
1345
+ // `/collections/{slug}-{id}`); suffix the trailing parameter rather
1346
+ // than a counter.
1347
+ if (
1348
+ naming === "verbNoun" &&
1349
+ ctx.names.has(pascal(resolved)) &&
1350
+ isMechanicalOperationId(
1351
+ typeof op.operationId === "string" ? op.operationId : undefined,
1352
+ idCtx,
1353
+ )
1354
+ ) {
1355
+ const lastParam = /\{([^}]+)\}[^/]*$/.exec(rawPath)?.[1];
1356
+ if (lastParam) resolved = `${resolved}By${pascal(lastParam)}`;
1357
+ }
1358
+ const opName = pascal(resolved);
1158
1359
 
1159
1360
  const params = collectParams(ctx, pathItem, op);
1160
1361
 
@@ -1212,6 +1413,32 @@ export const convertOpenApiToSmithy = (
1212
1413
  });
1213
1414
  }
1214
1415
 
1416
+ // ---- Header params (opt-in) ----
1417
+ if (options.headerParams) {
1418
+ for (const p of params) {
1419
+ if (p.in !== "header") continue;
1420
+ const conv = convertSchema(
1421
+ ctx,
1422
+ p.schema,
1423
+ `${opName}Request${pascal(p.name)}`,
1424
+ 0,
1425
+ "in",
1426
+ );
1427
+ addMember(headerMemberName(p.name), {
1428
+ target: conv.target,
1429
+ traits: {
1430
+ // The wire name rides on the trait, so the member is free to be
1431
+ // a normal identifier.
1432
+ "smithy.api#httpHeader": p.name,
1433
+ ...(p.required ? { "smithy.api#required": {} } : {}),
1434
+ ...(p.description
1435
+ ? { "smithy.api#documentation": p.description }
1436
+ : {}),
1437
+ },
1438
+ });
1439
+ }
1440
+ }
1441
+
1215
1442
  // ---- Request body (json > form-urlencoded > multipart) ----
1216
1443
  let contentType: "form-urlencoded" | "multipart" | undefined;
1217
1444
  let bodySchema: any;
@@ -1289,7 +1516,19 @@ export const convertOpenApiToSmithy = (
1289
1516
  : PRELUDE.Unit;
1290
1517
 
1291
1518
  // ---- Output shape ----
1292
- const { schema: respSchema } = successSchema(ctx, op.responses);
1519
+ // A HEAD response carries no content, and not merely by convention:
1520
+ // HTTP/1.1 framing terminates it at the end of the header section
1521
+ // "regardless of the header fields present in the message" (RFC 9112
1522
+ // §6.3), so a declared `Content-Length` describes what a GET *would*
1523
+ // return and no body can reach the client. Specs declare one anyway —
1524
+ // Vercel mirrors the GET's schema onto two of its HEAD probes and
1525
+ // gives the other two a bare `{"nullable": true}` — and honouring it
1526
+ // generates an output struct with required members that then fails to
1527
+ // decode against the empty body on every successful call.
1528
+ const { schema: respSchema } =
1529
+ method === "head"
1530
+ ? { schema: undefined }
1531
+ : successSchema(ctx, op.responses, successStatuses);
1293
1532
  let outputTarget: string = PRELUDE.Unit;
1294
1533
  if (respSchema !== undefined) {
1295
1534
  const flat = flattenObject(ctx, respSchema);