@distilled.cloud/core 1.0.0-rc.8 → 1.0.0-rc.9
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.
- package/LICENSE +201 -0
- package/lib/codegen/cli.d.ts +8 -6
- package/lib/codegen/cli.d.ts.map +1 -1
- package/lib/codegen/cli.js +6 -62
- package/lib/codegen/cli.js.map +1 -1
- package/lib/codegen/graphql.d.ts +6 -0
- package/lib/codegen/graphql.d.ts.map +1 -1
- package/lib/codegen/graphql.js +14 -4
- package/lib/codegen/graphql.js.map +1 -1
- package/lib/codegen/openapi-cli.d.ts +17 -3
- package/lib/codegen/openapi-cli.d.ts.map +1 -1
- package/lib/codegen/openapi-cli.js +50 -48
- package/lib/codegen/openapi-cli.js.map +1 -1
- package/lib/codegen/openapi.d.ts +27 -1
- package/lib/codegen/openapi.d.ts.map +1 -1
- package/lib/codegen/openapi.js +81 -3
- package/lib/codegen/openapi.js.map +1 -1
- package/lib/codegen/patches.d.ts +65 -0
- package/lib/codegen/patches.d.ts.map +1 -0
- package/lib/codegen/patches.js +236 -0
- package/lib/codegen/patches.js.map +1 -0
- package/lib/codegen/patches.test.d.ts +2 -0
- package/lib/codegen/patches.test.d.ts.map +1 -0
- package/lib/codegen/patches.test.js +105 -0
- package/lib/codegen/patches.test.js.map +1 -0
- package/lib/codegen/proto.d.ts +121 -0
- package/lib/codegen/proto.d.ts.map +1 -0
- package/lib/codegen/proto.js +962 -0
- package/lib/codegen/proto.js.map +1 -0
- package/lib/codegen/rewrite-operation-ids.d.ts +131 -0
- package/lib/codegen/rewrite-operation-ids.d.ts.map +1 -0
- package/lib/codegen/rewrite-operation-ids.js +1079 -0
- package/lib/codegen/rewrite-operation-ids.js.map +1 -0
- package/lib/codegen/rewrite-operation-ids.test.d.ts +2 -0
- package/lib/codegen/rewrite-operation-ids.test.d.ts.map +1 -0
- package/lib/codegen/rewrite-operation-ids.test.js +533 -0
- package/lib/codegen/rewrite-operation-ids.test.js.map +1 -0
- package/lib/codegen/spec-path.d.ts +16 -0
- package/lib/codegen/spec-path.d.ts.map +1 -0
- package/lib/codegen/spec-path.js +101 -0
- package/lib/codegen/spec-path.js.map +1 -0
- package/lib/json-patch.d.ts +18 -11
- package/lib/json-patch.d.ts.map +1 -1
- package/lib/json-patch.js +63 -25
- package/lib/json-patch.js.map +1 -1
- package/package.json +1 -1
- package/src/codegen/cli.ts +14 -91
- package/src/codegen/graphql.ts +25 -3
- package/src/codegen/openapi-cli.ts +75 -59
- package/src/codegen/openapi.ts +118 -4
- package/src/codegen/patches.test.ts +130 -0
- package/src/codegen/patches.ts +291 -0
- package/src/codegen/proto.ts +1128 -0
- package/src/codegen/rewrite-operation-ids.test.ts +563 -0
- package/src/codegen/rewrite-operation-ids.ts +1206 -0
- package/src/codegen/spec-path.ts +115 -0
- package/src/json-patch.ts +82 -25
|
@@ -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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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>/*.
|
|
49
|
+
* `false` disables. Layout: `<patchesDir>/<name>/*.json` when the
|
|
50
50
|
* per-spec directory exists; for single-spec providers a flat
|
|
51
|
-
* `<patchesDir>/*.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
`${
|
|
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
|
|
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
|
-
(
|
|
162
|
-
? ` (${
|
|
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
|
};
|
package/src/codegen/openapi.ts
CHANGED
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
*
|
|
10
10
|
* • one operation per (path × get/post/put/patch/delete — plus head/options
|
|
11
11
|
* when a provider opts in via `extraHttpMethods`), deprecated skipped by
|
|
12
|
-
* default; op shape name = PascalCase(operationId)
|
|
12
|
+
* default; op shape name = PascalCase(operationId), or verbNoun
|
|
13
|
+
* (`Apps_list` → `ListApps`) when {@link OpenApiConvertOptions.operationNaming}
|
|
14
|
+
* is `"verbNoun"`
|
|
13
15
|
* • input `<Op>Request`: path params → `smithy.api#httpLabel` (+required),
|
|
14
16
|
* query params → `smithy.api#httpQuery`, header params →
|
|
15
17
|
* `smithy.api#httpHeader` when `headerParams` is on (dropped otherwise,
|
|
@@ -42,6 +44,15 @@
|
|
|
42
44
|
* (with the non-standard `mode` member the core runtime dispatches on)
|
|
43
45
|
*/
|
|
44
46
|
|
|
47
|
+
import {
|
|
48
|
+
isMechanicalOperationId,
|
|
49
|
+
isVerbatimRouteId,
|
|
50
|
+
pathToVerbNoun,
|
|
51
|
+
resolveOperationName,
|
|
52
|
+
toVerbNoun,
|
|
53
|
+
type OperationIdRewrite,
|
|
54
|
+
} from "./rewrite-operation-ids.ts";
|
|
55
|
+
|
|
45
56
|
// ============================================================================
|
|
46
57
|
// Trait ids (the `com.distilled.openapi` vocabulary)
|
|
47
58
|
// ============================================================================
|
|
@@ -177,6 +188,29 @@ export interface OpenApiConvertOptions {
|
|
|
177
188
|
* {@link ERROR_MATCHERS_TRAIT} traits; overrides are merged over it.
|
|
178
189
|
*/
|
|
179
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;
|
|
180
214
|
}
|
|
181
215
|
|
|
182
216
|
export interface SmithyModel {
|
|
@@ -1079,6 +1113,37 @@ const successSchema = (
|
|
|
1079
1113
|
return { schema: undefined };
|
|
1080
1114
|
};
|
|
1081
1115
|
|
|
1116
|
+
/**
|
|
1117
|
+
* Whether the success body is a collection: an array, or an object whose
|
|
1118
|
+
* array members outnumber its scalar ones (envelopes like `{ data: [] }`,
|
|
1119
|
+
* `{ items: [], total }`). `undefined` when there is no body to judge.
|
|
1120
|
+
*/
|
|
1121
|
+
const responseIsCollection = (
|
|
1122
|
+
ctx: Ctx,
|
|
1123
|
+
responses: any,
|
|
1124
|
+
order: readonly string[],
|
|
1125
|
+
): boolean | undefined => {
|
|
1126
|
+
const { schema } = successSchema(ctx, responses, order);
|
|
1127
|
+
if (!schema) return undefined;
|
|
1128
|
+
const s = deref(ctx, schema);
|
|
1129
|
+
if (!s || typeof s !== "object") return undefined;
|
|
1130
|
+
if (s.type === "array" || s.items) return true;
|
|
1131
|
+
const props = s.properties;
|
|
1132
|
+
if (!props || typeof props !== "object") {
|
|
1133
|
+
return s.allOf || s.oneOf || s.anyOf ? undefined : false;
|
|
1134
|
+
}
|
|
1135
|
+
let arrays = 0;
|
|
1136
|
+
let others = 0;
|
|
1137
|
+
for (const prop of Object.values(props)) {
|
|
1138
|
+
const p = deref(ctx, prop);
|
|
1139
|
+
if (p?.type === "array" || p?.items) arrays++;
|
|
1140
|
+
else others++;
|
|
1141
|
+
}
|
|
1142
|
+
if (arrays === 0) return false;
|
|
1143
|
+
// `{ data: [...] }`, `{ items, next_cursor }`, `{ results, count, page }`
|
|
1144
|
+
return arrays >= 1 && others <= 3;
|
|
1145
|
+
};
|
|
1146
|
+
|
|
1082
1147
|
// ============================================================================
|
|
1083
1148
|
// Pagination detection (v0 detectPagination)
|
|
1084
1149
|
// ============================================================================
|
|
@@ -1227,11 +1292,60 @@ export const convertOpenApiToSmithy = (
|
|
|
1227
1292
|
if (!op || typeof op !== "object") continue;
|
|
1228
1293
|
if (skipDeprecated && op.deprecated === true) continue;
|
|
1229
1294
|
|
|
1230
|
-
const
|
|
1295
|
+
const rawOperationId =
|
|
1231
1296
|
typeof op.operationId === "string" && op.operationId
|
|
1232
1297
|
? op.operationId
|
|
1233
|
-
: `${method}_${rawPath}
|
|
1234
|
-
|
|
1298
|
+
: `${method}_${rawPath}`;
|
|
1299
|
+
const idCtx = { path: rawPath, method };
|
|
1300
|
+
const naming = options.operationNaming ?? "verbNoun";
|
|
1301
|
+
let named =
|
|
1302
|
+
options.operationNames !== undefined
|
|
1303
|
+
? resolveOperationName(options.operationNames, rawOperationId, idCtx)
|
|
1304
|
+
: undefined;
|
|
1305
|
+
if (named === undefined && naming === "verbNoun") {
|
|
1306
|
+
// A mechanical id (`get-api-card`, or none at all) is named from
|
|
1307
|
+
// the route. A hand-chosen one that merely starts with the method
|
|
1308
|
+
// and reads the route in order (`get-feeds` on /feeds) keeps its
|
|
1309
|
+
// own nouns; only the HTTP-method verb is normalised.
|
|
1310
|
+
if (typeof op.operationId !== "string" || !op.operationId) {
|
|
1311
|
+
// No id: everything comes from the route, including whether a
|
|
1312
|
+
// GET on a collection route is `list` or `get`.
|
|
1313
|
+
named = pathToVerbNoun(idCtx, {
|
|
1314
|
+
returnsCollection: responseIsCollection(
|
|
1315
|
+
ctx,
|
|
1316
|
+
op.responses,
|
|
1317
|
+
successStatuses,
|
|
1318
|
+
),
|
|
1319
|
+
});
|
|
1320
|
+
} else if (isMechanicalOperationId(op.operationId, idCtx)) {
|
|
1321
|
+
// Method-prefixed and reading the route (`get-feeds`,
|
|
1322
|
+
// `postV1AppsByAppIdPromote`, `deleteProjectJWKS`): keep the
|
|
1323
|
+
// author's tokens and casing; normalise `post`/`patch`, drop
|
|
1324
|
+
// parameter clauses (`ByAppId`, `ById`) and api/version roots.
|
|
1325
|
+
named = pathToVerbNoun(idCtx, {
|
|
1326
|
+
nouns: op.operationId,
|
|
1327
|
+
verbatim: isVerbatimRouteId(op.operationId, idCtx),
|
|
1328
|
+
});
|
|
1329
|
+
} else {
|
|
1330
|
+
named = toVerbNoun(rawOperationId);
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
let resolved: string = named ?? rawOperationId;
|
|
1334
|
+
// Two routes can derive the same name (`/collections/{slug}` and
|
|
1335
|
+
// `/collections/{slug}-{id}`); suffix the trailing parameter rather
|
|
1336
|
+
// than a counter.
|
|
1337
|
+
if (
|
|
1338
|
+
naming === "verbNoun" &&
|
|
1339
|
+
ctx.names.has(pascal(resolved)) &&
|
|
1340
|
+
isMechanicalOperationId(
|
|
1341
|
+
typeof op.operationId === "string" ? op.operationId : undefined,
|
|
1342
|
+
idCtx,
|
|
1343
|
+
)
|
|
1344
|
+
) {
|
|
1345
|
+
const lastParam = /\{([^}]+)\}[^/]*$/.exec(rawPath)?.[1];
|
|
1346
|
+
if (lastParam) resolved = `${resolved}By${pascal(lastParam)}`;
|
|
1347
|
+
}
|
|
1348
|
+
const opName = pascal(resolved);
|
|
1235
1349
|
|
|
1236
1350
|
const params = collectParams(ctx, pathItem, op);
|
|
1237
1351
|
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
FINALIZED_KEY,
|
|
7
|
+
danglingTargets,
|
|
8
|
+
finalizeConvert,
|
|
9
|
+
syncServiceOperations,
|
|
10
|
+
} from "./patches.ts";
|
|
11
|
+
|
|
12
|
+
const model = () => ({
|
|
13
|
+
smithy: "2.0",
|
|
14
|
+
shapes: {
|
|
15
|
+
"ns#Svc": {
|
|
16
|
+
type: "service",
|
|
17
|
+
operations: [{ target: "ns#AppsList" }, { target: "ns#AppsGet" }],
|
|
18
|
+
},
|
|
19
|
+
"ns#AppsList": {
|
|
20
|
+
type: "operation",
|
|
21
|
+
input: { target: "ns#AppsListRequest" },
|
|
22
|
+
output: { target: "smithy.api#Unit" },
|
|
23
|
+
},
|
|
24
|
+
"ns#AppsListRequest": { type: "structure", members: {} },
|
|
25
|
+
"ns#AppsGet": {
|
|
26
|
+
type: "operation",
|
|
27
|
+
input: { target: "smithy.api#Unit" },
|
|
28
|
+
output: { target: "smithy.api#Unit" },
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const scaffold = (patches?: Record<string, unknown>) => {
|
|
34
|
+
const root = mkdtempSync(join(tmpdir(), "finalize-"));
|
|
35
|
+
mkdirSync(join(root, ".generated-specs"));
|
|
36
|
+
writeFileSync(
|
|
37
|
+
join(root, ".generated-specs", "svc.json"),
|
|
38
|
+
JSON.stringify(model()),
|
|
39
|
+
);
|
|
40
|
+
if (patches) {
|
|
41
|
+
mkdirSync(join(root, "patches", "svc"), { recursive: true });
|
|
42
|
+
writeFileSync(
|
|
43
|
+
join(root, "patches", "svc", "a.json"),
|
|
44
|
+
JSON.stringify(patches),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
return root;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const read = (root: string) =>
|
|
51
|
+
JSON.parse(readFileSync(join(root, ".generated-specs", "svc.json"), "utf8"));
|
|
52
|
+
|
|
53
|
+
describe("finalizeConvert", () => {
|
|
54
|
+
test("renames, syncs the service list, stamps the marker", async () => {
|
|
55
|
+
const root = scaffold();
|
|
56
|
+
await finalizeConvert({ root });
|
|
57
|
+
const m = read(root);
|
|
58
|
+
expect(Object.keys(m.shapes).sort()).toEqual(
|
|
59
|
+
["ns#Svc", "ns#ListApps", "ns#ListAppsRequest", "ns#GetApp"].sort(),
|
|
60
|
+
);
|
|
61
|
+
expect(m.shapes["ns#Svc"].operations).toEqual([
|
|
62
|
+
{ target: "ns#ListApps" },
|
|
63
|
+
{ target: "ns#GetApp" },
|
|
64
|
+
]);
|
|
65
|
+
expect(m.metadata[FINALIZED_KEY]).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("refuses a second pass", async () => {
|
|
69
|
+
const root = scaffold();
|
|
70
|
+
await finalizeConvert({ root });
|
|
71
|
+
await expect(finalizeConvert({ root })).rejects.toThrow(
|
|
72
|
+
/already finalized/,
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("applies Smithy patches and repairs the service list after a move", async () => {
|
|
77
|
+
const root = scaffold({
|
|
78
|
+
patches: [
|
|
79
|
+
{ op: "move", from: "/shapes/ns#AppsGet", path: "/shapes/ns#FetchApp" },
|
|
80
|
+
],
|
|
81
|
+
});
|
|
82
|
+
await finalizeConvert({ root });
|
|
83
|
+
const m = read(root);
|
|
84
|
+
expect(m.shapes["ns#FetchApp"]).toBeDefined();
|
|
85
|
+
expect(m.shapes["ns#Svc"].operations.map((o: any) => o.target)).toEqual([
|
|
86
|
+
"ns#ListApps",
|
|
87
|
+
"ns#FetchApp",
|
|
88
|
+
]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("fails on a stale patch pointer by default", async () => {
|
|
92
|
+
const root = scaffold({
|
|
93
|
+
patches: [{ op: "remove", path: "/shapes/ns#Missing" }],
|
|
94
|
+
});
|
|
95
|
+
await expect(finalizeConvert({ root })).rejects.toThrow(/failed/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("fails on dangling targets and leaves the model unstamped", async () => {
|
|
99
|
+
const root = scaffold({
|
|
100
|
+
patches: [
|
|
101
|
+
{
|
|
102
|
+
op: "replace",
|
|
103
|
+
path: "/shapes/ns#AppsList/output/target",
|
|
104
|
+
value: "ns#Nope",
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
});
|
|
108
|
+
await expect(finalizeConvert({ root })).rejects.toThrow(/do not exist/);
|
|
109
|
+
expect(read(root).metadata?.[FINALIZED_KEY]).toBeUndefined();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("danglingTargets", () => {
|
|
114
|
+
test("ignores prelude ids", () => {
|
|
115
|
+
expect(danglingTargets(model())).toEqual([]);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("syncServiceOperations", () => {
|
|
120
|
+
test("drops missing and appends unlisted, keeping order", () => {
|
|
121
|
+
const m = model();
|
|
122
|
+
delete (m.shapes as any)["ns#AppsGet"];
|
|
123
|
+
(m.shapes as any)["ns#Zed"] = { type: "operation" };
|
|
124
|
+
expect(syncServiceOperations(m)).toBe(1);
|
|
125
|
+
expect((m.shapes as any)["ns#Svc"].operations).toEqual([
|
|
126
|
+
{ target: "ns#AppsList" },
|
|
127
|
+
{ target: "ns#Zed" },
|
|
128
|
+
]);
|
|
129
|
+
});
|
|
130
|
+
});
|