@distilled.cloud/core 1.0.0-rc.7 → 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 +4 -4
- 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
|
@@ -0,0 +1,1206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operation naming (dev-time only).
|
|
3
|
+
*
|
|
4
|
+
* Distilled SDK operations are verbNoun (`listApps`, `getApp`,
|
|
5
|
+
* `createMachine`). Upstream ids come in a handful of shapes and
|
|
6
|
+
* {@link toVerbNoun} only reorders the ones it can recognise with confidence:
|
|
7
|
+
*
|
|
8
|
+
* - go-swagger `Resource_action` / `Resource_Sub_action` (`Apps_list`)
|
|
9
|
+
* - REST `NounsAction` with a trailing CRUD verb (`ConfigsList`,
|
|
10
|
+
* `ContainerCreate`, `ServiceMembers_getMetrics`)
|
|
11
|
+
* - GraphQL `nounAction` (`projectCreate`)
|
|
12
|
+
* - `show` → `get`, `index` → `list`, `retrieve` → `get`
|
|
13
|
+
*
|
|
14
|
+
* Anything else is left as-is (lowerFirst only). Reordering a verb out of
|
|
15
|
+
* the middle of an id (`WatchCoreV1PodList` → `listWatch…`) produced worse
|
|
16
|
+
* names than the input, so it is not attempted; per-package
|
|
17
|
+
* {@link OperationIdRewrite} maps cover the tail.
|
|
18
|
+
*
|
|
19
|
+
* RFC-6902 patches that target `/paths/~1foo/get/operationId` break the
|
|
20
|
+
* moment upstream prefixes paths. Naming is a convert policy, not a patch.
|
|
21
|
+
*/
|
|
22
|
+
export interface OperationIdContext {
|
|
23
|
+
readonly path: string;
|
|
24
|
+
readonly method: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Return the new operation name, or `undefined` to leave the current value.
|
|
29
|
+
* A `Record` is looked up by `"METHOD path"` first, then the spec's
|
|
30
|
+
* `operationId` — same id on PUT vs PATCH needs the path key.
|
|
31
|
+
*/
|
|
32
|
+
export type OperationIdRewrite =
|
|
33
|
+
| Readonly<Record<string, string>>
|
|
34
|
+
| ((operationId: string, ctx: OperationIdContext) => string | undefined);
|
|
35
|
+
|
|
36
|
+
/** Verb spellings normalised on the way out. */
|
|
37
|
+
const VERB_ALIAS: Readonly<Record<string, string>> = {
|
|
38
|
+
show: "get",
|
|
39
|
+
index: "list",
|
|
40
|
+
retrieve: "get",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Rails `index` is a verb only when it is the whole id or a whole `_`
|
|
45
|
+
* segment (`index`, `Apps_index`). Joined into a camel id it is the noun
|
|
46
|
+
* (`InfoIndex`, `IndexCreate`, `IndexDocument`).
|
|
47
|
+
*/
|
|
48
|
+
const isIndexVerb = (raw: string): boolean => raw.toLowerCase() === "index";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Unambiguous verbs. An id that STARTS with one is already verb-first and
|
|
52
|
+
* is never reordered (`WatchPodList`, `InsertCalendarList`,
|
|
53
|
+
* `BulkDeleteMessages`); one found in the middle of a resource name
|
|
54
|
+
* (`Apps_getOrCreate`) marks the id as a compound we leave alone.
|
|
55
|
+
*/
|
|
56
|
+
const STRONG_VERBS = new Set([
|
|
57
|
+
"get",
|
|
58
|
+
"list",
|
|
59
|
+
"create",
|
|
60
|
+
"delete",
|
|
61
|
+
"update",
|
|
62
|
+
"patch",
|
|
63
|
+
"put",
|
|
64
|
+
"post",
|
|
65
|
+
"show",
|
|
66
|
+
"index",
|
|
67
|
+
"retrieve",
|
|
68
|
+
"watch",
|
|
69
|
+
"read",
|
|
70
|
+
"replace",
|
|
71
|
+
"connect",
|
|
72
|
+
"disconnect",
|
|
73
|
+
"insert",
|
|
74
|
+
"mutate",
|
|
75
|
+
"fetch",
|
|
76
|
+
"remove",
|
|
77
|
+
"add",
|
|
78
|
+
"upload",
|
|
79
|
+
"download",
|
|
80
|
+
"send",
|
|
81
|
+
"resend",
|
|
82
|
+
"validate",
|
|
83
|
+
"verify",
|
|
84
|
+
"enable",
|
|
85
|
+
"disable",
|
|
86
|
+
"start",
|
|
87
|
+
"stop",
|
|
88
|
+
"restart",
|
|
89
|
+
"cancel",
|
|
90
|
+
"search",
|
|
91
|
+
"purge",
|
|
92
|
+
"revoke",
|
|
93
|
+
"approve",
|
|
94
|
+
"reject",
|
|
95
|
+
"accept",
|
|
96
|
+
"invoke",
|
|
97
|
+
"trigger",
|
|
98
|
+
"deploy",
|
|
99
|
+
"redeploy",
|
|
100
|
+
"describe",
|
|
101
|
+
"generate",
|
|
102
|
+
"regenerate",
|
|
103
|
+
"rotate",
|
|
104
|
+
"restore",
|
|
105
|
+
"suspend",
|
|
106
|
+
"resume",
|
|
107
|
+
"activate",
|
|
108
|
+
"deactivate",
|
|
109
|
+
"archive",
|
|
110
|
+
"unarchive",
|
|
111
|
+
"publish",
|
|
112
|
+
"unpublish",
|
|
113
|
+
"subscribe",
|
|
114
|
+
"unsubscribe",
|
|
115
|
+
"register",
|
|
116
|
+
"unregister",
|
|
117
|
+
"authenticate",
|
|
118
|
+
"authorize",
|
|
119
|
+
"deauthorize",
|
|
120
|
+
"attach",
|
|
121
|
+
"detach",
|
|
122
|
+
"assign",
|
|
123
|
+
"unassign",
|
|
124
|
+
"import",
|
|
125
|
+
"export",
|
|
126
|
+
"copy",
|
|
127
|
+
"move",
|
|
128
|
+
"rename",
|
|
129
|
+
"retry",
|
|
130
|
+
"reset",
|
|
131
|
+
"refresh",
|
|
132
|
+
"encrypt",
|
|
133
|
+
"decrypt",
|
|
134
|
+
"sign",
|
|
135
|
+
"edit",
|
|
136
|
+
"bulk",
|
|
137
|
+
"execute",
|
|
138
|
+
"submit",
|
|
139
|
+
"apply",
|
|
140
|
+
"undelete",
|
|
141
|
+
"redeliver",
|
|
142
|
+
"invalidate",
|
|
143
|
+
"upsert",
|
|
144
|
+
"cordon",
|
|
145
|
+
"uncordon",
|
|
146
|
+
"exec",
|
|
147
|
+
"reclaim",
|
|
148
|
+
"modify",
|
|
149
|
+
"install",
|
|
150
|
+
"uninstall",
|
|
151
|
+
"migrate",
|
|
152
|
+
"terminate",
|
|
153
|
+
"reboot",
|
|
154
|
+
"rebuild",
|
|
155
|
+
"resize",
|
|
156
|
+
"clone",
|
|
157
|
+
"duplicate",
|
|
158
|
+
"acknowledge",
|
|
159
|
+
"unpause",
|
|
160
|
+
"unblock",
|
|
161
|
+
"unlink",
|
|
162
|
+
"unpin",
|
|
163
|
+
"unmute",
|
|
164
|
+
"unhide",
|
|
165
|
+
"unstar",
|
|
166
|
+
"unfollow",
|
|
167
|
+
"unshare",
|
|
168
|
+
"unban",
|
|
169
|
+
"unlock",
|
|
170
|
+
"unflag",
|
|
171
|
+
"unclaim",
|
|
172
|
+
]);
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Words that act as the verb when they TRAIL a resource (`MachinesStart`,
|
|
176
|
+
* `SecretkeysSet`, `JobsRun`) but are ordinary nouns when they lead
|
|
177
|
+
* (`ReleaseGet`, `RequestGet`, `SetGet`, `CheckRunsList`). Includes every
|
|
178
|
+
* strong verb.
|
|
179
|
+
*/
|
|
180
|
+
const TRAILING_VERBS = new Set([
|
|
181
|
+
...STRONG_VERBS,
|
|
182
|
+
"notify",
|
|
183
|
+
"set",
|
|
184
|
+
"check",
|
|
185
|
+
"run",
|
|
186
|
+
"wait",
|
|
187
|
+
"signal",
|
|
188
|
+
"fork",
|
|
189
|
+
"extend",
|
|
190
|
+
"test",
|
|
191
|
+
"sync",
|
|
192
|
+
"review",
|
|
193
|
+
"invite",
|
|
194
|
+
"join",
|
|
195
|
+
"leave",
|
|
196
|
+
"capture",
|
|
197
|
+
"refund",
|
|
198
|
+
"settle",
|
|
199
|
+
"void",
|
|
200
|
+
"preview",
|
|
201
|
+
"complete",
|
|
202
|
+
"confirm",
|
|
203
|
+
"abort",
|
|
204
|
+
"kill",
|
|
205
|
+
"scale",
|
|
206
|
+
"promote",
|
|
207
|
+
"demote",
|
|
208
|
+
"renew",
|
|
209
|
+
"recover",
|
|
210
|
+
"reveal",
|
|
211
|
+
"presign",
|
|
212
|
+
"provision",
|
|
213
|
+
"trim",
|
|
214
|
+
"vacuum",
|
|
215
|
+
"compact",
|
|
216
|
+
"flush",
|
|
217
|
+
"merge",
|
|
218
|
+
"dispatch",
|
|
219
|
+
"poll",
|
|
220
|
+
"redeem",
|
|
221
|
+
"claim",
|
|
222
|
+
"lint",
|
|
223
|
+
"toggle",
|
|
224
|
+
"pin",
|
|
225
|
+
"mute",
|
|
226
|
+
"hide",
|
|
227
|
+
"ban",
|
|
228
|
+
"block",
|
|
229
|
+
"lock",
|
|
230
|
+
"share",
|
|
231
|
+
"follow",
|
|
232
|
+
"star",
|
|
233
|
+
"pause",
|
|
234
|
+
"finalize",
|
|
235
|
+
"transfer",
|
|
236
|
+
"lookup",
|
|
237
|
+
"clear",
|
|
238
|
+
"convert",
|
|
239
|
+
"dismiss",
|
|
240
|
+
"reply",
|
|
241
|
+
"squash",
|
|
242
|
+
"commit",
|
|
243
|
+
"swap",
|
|
244
|
+
"recreate",
|
|
245
|
+
"seal",
|
|
246
|
+
"ping",
|
|
247
|
+
"login",
|
|
248
|
+
"logout",
|
|
249
|
+
]);
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Leading tokens that are verbs often enough that an id starting with one
|
|
253
|
+
* is left as-is unless a STRONG verb trails it (`QueryCreate` →
|
|
254
|
+
* `createQuery`, but `QueryRun` stays).
|
|
255
|
+
*/
|
|
256
|
+
const WEAK_LEADING_VERBS = new Set([
|
|
257
|
+
...TRAILING_VERBS,
|
|
258
|
+
"request",
|
|
259
|
+
"release",
|
|
260
|
+
"report",
|
|
261
|
+
"query",
|
|
262
|
+
"stream",
|
|
263
|
+
"open",
|
|
264
|
+
"close",
|
|
265
|
+
"head",
|
|
266
|
+
"options",
|
|
267
|
+
"compute",
|
|
268
|
+
"batch",
|
|
269
|
+
"count",
|
|
270
|
+
"link",
|
|
271
|
+
"mark",
|
|
272
|
+
"grant",
|
|
273
|
+
"aggregate",
|
|
274
|
+
"skip",
|
|
275
|
+
"filter",
|
|
276
|
+
"change",
|
|
277
|
+
"vote",
|
|
278
|
+
"debug",
|
|
279
|
+
"expire",
|
|
280
|
+
"process",
|
|
281
|
+
"redirect",
|
|
282
|
+
"estimate",
|
|
283
|
+
"calculate",
|
|
284
|
+
"evaluate",
|
|
285
|
+
"resolve",
|
|
286
|
+
"load",
|
|
287
|
+
"unload",
|
|
288
|
+
"backup",
|
|
289
|
+
"snapshot",
|
|
290
|
+
"rollback",
|
|
291
|
+
"flag",
|
|
292
|
+
"certify",
|
|
293
|
+
"checkout",
|
|
294
|
+
"checkin",
|
|
295
|
+
"suggest",
|
|
296
|
+
"predict",
|
|
297
|
+
"analyze",
|
|
298
|
+
"annotate",
|
|
299
|
+
"translate",
|
|
300
|
+
"recognize",
|
|
301
|
+
"detect",
|
|
302
|
+
"classify",
|
|
303
|
+
"simulate",
|
|
304
|
+
"take",
|
|
305
|
+
"end",
|
|
306
|
+
"reassign",
|
|
307
|
+
"consume",
|
|
308
|
+
"prepare",
|
|
309
|
+
"action",
|
|
310
|
+
"act",
|
|
311
|
+
"ask",
|
|
312
|
+
"answer",
|
|
313
|
+
"handle",
|
|
314
|
+
"trace",
|
|
315
|
+
"track",
|
|
316
|
+
"log",
|
|
317
|
+
]);
|
|
318
|
+
|
|
319
|
+
/** Compound tokens the naive split would leave as `Secretkeys`. */
|
|
320
|
+
const TOKEN_ALIAS: Readonly<Record<string, string>> = {
|
|
321
|
+
secretkeys: "SecretKeys",
|
|
322
|
+
secretkey: "SecretKey",
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const IRREGULAR_SINGULAR: Readonly<Record<string, string>> = {
|
|
326
|
+
processes: "process",
|
|
327
|
+
statuses: "status",
|
|
328
|
+
addresses: "address",
|
|
329
|
+
aliases: "alias",
|
|
330
|
+
indexes: "index",
|
|
331
|
+
indices: "index",
|
|
332
|
+
matrices: "matrix",
|
|
333
|
+
vertices: "vertex",
|
|
334
|
+
analyses: "analysis",
|
|
335
|
+
bases: "base",
|
|
336
|
+
cases: "case",
|
|
337
|
+
databases: "database",
|
|
338
|
+
releases: "release",
|
|
339
|
+
responses: "response",
|
|
340
|
+
licenses: "license",
|
|
341
|
+
courses: "course",
|
|
342
|
+
purchases: "purchase",
|
|
343
|
+
phases: "phase",
|
|
344
|
+
leases: "lease",
|
|
345
|
+
clauses: "clause",
|
|
346
|
+
causes: "cause",
|
|
347
|
+
pauses: "pause",
|
|
348
|
+
houses: "house",
|
|
349
|
+
children: "child",
|
|
350
|
+
people: "person",
|
|
351
|
+
media: "media",
|
|
352
|
+
data: "data",
|
|
353
|
+
metadata: "metadata",
|
|
354
|
+
schemas: "schema",
|
|
355
|
+
criteria: "criterion",
|
|
356
|
+
feet: "foot",
|
|
357
|
+
teeth: "tooth",
|
|
358
|
+
mice: "mouse",
|
|
359
|
+
geese: "goose",
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Nouns whose plural and singular coincide, or product names that end in
|
|
364
|
+
* `s`. Never trimmed.
|
|
365
|
+
*/
|
|
366
|
+
const UNCOUNTABLE = new Set([
|
|
367
|
+
"postgres",
|
|
368
|
+
"redis",
|
|
369
|
+
"kubernetes",
|
|
370
|
+
"ios",
|
|
371
|
+
"macos",
|
|
372
|
+
"windows",
|
|
373
|
+
"dns",
|
|
374
|
+
"tls",
|
|
375
|
+
"https",
|
|
376
|
+
"sms",
|
|
377
|
+
"cors",
|
|
378
|
+
"oidc",
|
|
379
|
+
"sso",
|
|
380
|
+
"saas",
|
|
381
|
+
"aws",
|
|
382
|
+
"gcs",
|
|
383
|
+
"ecs",
|
|
384
|
+
"eks",
|
|
385
|
+
"rds",
|
|
386
|
+
"sqs",
|
|
387
|
+
"sns",
|
|
388
|
+
"kms",
|
|
389
|
+
"ses",
|
|
390
|
+
"efs",
|
|
391
|
+
"ebs",
|
|
392
|
+
"status",
|
|
393
|
+
"series",
|
|
394
|
+
"timeseries",
|
|
395
|
+
"analytics",
|
|
396
|
+
"metrics",
|
|
397
|
+
"settings",
|
|
398
|
+
"credentials",
|
|
399
|
+
"permissions",
|
|
400
|
+
"news",
|
|
401
|
+
"canvas",
|
|
402
|
+
"lens",
|
|
403
|
+
"bonus",
|
|
404
|
+
"campus",
|
|
405
|
+
"census",
|
|
406
|
+
"focus",
|
|
407
|
+
"virus",
|
|
408
|
+
"corpus",
|
|
409
|
+
"chorus",
|
|
410
|
+
"genus",
|
|
411
|
+
"radius",
|
|
412
|
+
"consensus",
|
|
413
|
+
"apparatus",
|
|
414
|
+
"bus",
|
|
415
|
+
"gas",
|
|
416
|
+
"plus",
|
|
417
|
+
"minus",
|
|
418
|
+
"nexus",
|
|
419
|
+
"prometheus",
|
|
420
|
+
"chaos",
|
|
421
|
+
"cosmos",
|
|
422
|
+
"ethos",
|
|
423
|
+
"pathos",
|
|
424
|
+
"atlas",
|
|
425
|
+
"bias",
|
|
426
|
+
"gps",
|
|
427
|
+
"cds",
|
|
428
|
+
"css",
|
|
429
|
+
"sass",
|
|
430
|
+
"less",
|
|
431
|
+
"js",
|
|
432
|
+
"ts",
|
|
433
|
+
"os",
|
|
434
|
+
"fs",
|
|
435
|
+
"vs",
|
|
436
|
+
"as",
|
|
437
|
+
"is",
|
|
438
|
+
"has",
|
|
439
|
+
"was",
|
|
440
|
+
"this",
|
|
441
|
+
"always",
|
|
442
|
+
"sis",
|
|
443
|
+
"axis",
|
|
444
|
+
"basis",
|
|
445
|
+
"crisis",
|
|
446
|
+
"thesis",
|
|
447
|
+
"diagnosis",
|
|
448
|
+
"synthesis",
|
|
449
|
+
"emphasis",
|
|
450
|
+
"hypothesis",
|
|
451
|
+
"oasis",
|
|
452
|
+
"iris",
|
|
453
|
+
"tennis",
|
|
454
|
+
"chassis",
|
|
455
|
+
"debris",
|
|
456
|
+
"physics",
|
|
457
|
+
"ethics",
|
|
458
|
+
"economics",
|
|
459
|
+
"logistics",
|
|
460
|
+
"mathematics",
|
|
461
|
+
"politics",
|
|
462
|
+
"statistics",
|
|
463
|
+
"dynamics",
|
|
464
|
+
"graphics",
|
|
465
|
+
"robotics",
|
|
466
|
+
"genetics",
|
|
467
|
+
"linguistics",
|
|
468
|
+
"means",
|
|
469
|
+
"species",
|
|
470
|
+
"sheep",
|
|
471
|
+
"fish",
|
|
472
|
+
"deer",
|
|
473
|
+
"aircraft",
|
|
474
|
+
"software",
|
|
475
|
+
"hardware",
|
|
476
|
+
"firmware",
|
|
477
|
+
"middleware",
|
|
478
|
+
"access",
|
|
479
|
+
"address",
|
|
480
|
+
"progress",
|
|
481
|
+
"success",
|
|
482
|
+
"process",
|
|
483
|
+
"business",
|
|
484
|
+
"ingress",
|
|
485
|
+
"egress",
|
|
486
|
+
"express",
|
|
487
|
+
"compress",
|
|
488
|
+
"stress",
|
|
489
|
+
"witness",
|
|
490
|
+
"fitness",
|
|
491
|
+
"wellness",
|
|
492
|
+
"readiness",
|
|
493
|
+
"liveness",
|
|
494
|
+
"awareness",
|
|
495
|
+
"class",
|
|
496
|
+
"pass",
|
|
497
|
+
"mass",
|
|
498
|
+
"glass",
|
|
499
|
+
"grass",
|
|
500
|
+
"bypass",
|
|
501
|
+
"compass",
|
|
502
|
+
"kms",
|
|
503
|
+
"eos",
|
|
504
|
+
"nas",
|
|
505
|
+
"ras",
|
|
506
|
+
"sas",
|
|
507
|
+
"das",
|
|
508
|
+
"pas",
|
|
509
|
+
"s3",
|
|
510
|
+
"k8s",
|
|
511
|
+
"kubeadm",
|
|
512
|
+
]);
|
|
513
|
+
|
|
514
|
+
/** Certificate variants that sit in front of the resource noun. */
|
|
515
|
+
const QUALIFIERS = new Set(["acme", "custom"]);
|
|
516
|
+
|
|
517
|
+
export const operationNameKey = (ctx: OperationIdContext): string =>
|
|
518
|
+
`${ctx.method.toUpperCase()} ${ctx.path}`;
|
|
519
|
+
|
|
520
|
+
/** Resolve {@link OperationIdRewrite} against method+path, then operationId. */
|
|
521
|
+
export const resolveOperationName = (
|
|
522
|
+
rewrite: OperationIdRewrite,
|
|
523
|
+
operationId: string,
|
|
524
|
+
ctx: OperationIdContext,
|
|
525
|
+
): string | undefined => {
|
|
526
|
+
if (typeof rewrite === "function") return rewrite(operationId, ctx);
|
|
527
|
+
return rewrite[operationNameKey(ctx)] ?? rewrite[operationId];
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
/** `foo` → `Foo`; all-caps tokens (`CSI`, `API`) keep their casing. */
|
|
531
|
+
const pascalToken = (raw: string): string => {
|
|
532
|
+
const alias = TOKEN_ALIAS[raw.toLowerCase()];
|
|
533
|
+
if (alias !== undefined) return alias;
|
|
534
|
+
return raw.charAt(0).toUpperCase() + raw.slice(1);
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Conservative singular. Only strips a trailing `s` when the word is a
|
|
539
|
+
* regular plural we are confident about; anything ambiguous is returned
|
|
540
|
+
* unchanged, because `getAlia` is worse than `getAliases`.
|
|
541
|
+
*/
|
|
542
|
+
export const singularize = (raw: string): string => {
|
|
543
|
+
const lower = raw.toLowerCase();
|
|
544
|
+
const keepCase = (s: string): string =>
|
|
545
|
+
raw[0] === raw[0]?.toUpperCase()
|
|
546
|
+
? s.charAt(0).toUpperCase() + s.slice(1)
|
|
547
|
+
: s;
|
|
548
|
+
const alias = TOKEN_ALIAS[lower];
|
|
549
|
+
if (alias !== undefined) {
|
|
550
|
+
return alias.endsWith("s") ? alias.slice(0, -1) : alias;
|
|
551
|
+
}
|
|
552
|
+
const irregular = IRREGULAR_SINGULAR[lower];
|
|
553
|
+
if (irregular !== undefined) return keepCase(irregular);
|
|
554
|
+
if (UNCOUNTABLE.has(lower)) return raw;
|
|
555
|
+
// All-caps acronyms (`IPs`, `CIDRs`, `DNS`) — only trim a lowercase `s`
|
|
556
|
+
// after an acronym.
|
|
557
|
+
if (/^[A-Z0-9]+s$/.test(raw)) return raw.slice(0, -1);
|
|
558
|
+
if (raw !== lower && raw.toUpperCase() === raw) return raw;
|
|
559
|
+
if (lower.length <= 3) return raw;
|
|
560
|
+
if (!lower.endsWith("s")) return raw;
|
|
561
|
+
// Vowel + `ys` is a regular plural (`keys`, `days`); consonant + `ys` is not.
|
|
562
|
+
if (/(?:ss|us|is|os|as|[^aeiou]ys)$/.test(lower)) return raw;
|
|
563
|
+
if (lower.endsWith("ies") && lower.length > 4) return `${raw.slice(0, -3)}y`;
|
|
564
|
+
if (/(?:ches|shes|xes|zes|sses)$/.test(lower)) return raw.slice(0, -2);
|
|
565
|
+
if (lower.endsWith("oes") && lower.length > 4) return raw.slice(0, -2);
|
|
566
|
+
// `-ses` after a vowel is usually a regular `-se` noun (`Response`,
|
|
567
|
+
// `Release`, `Database`), not an `-s` + `es` plural.
|
|
568
|
+
if (/[aeiou]ses$/.test(lower)) return raw.slice(0, -1);
|
|
569
|
+
if (lower.endsWith("ses")) return raw.slice(0, -2);
|
|
570
|
+
return raw.slice(0, -1);
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
const lowerFirst = (s: string): string =>
|
|
574
|
+
s.length === 0 ? s : s.charAt(0).toLowerCase() + s.slice(1);
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Split on `_`, `-`, `/` and camel boundaries. Acronym runs stay together
|
|
578
|
+
* (`CSIDriver` → `CSI`, `Driver`; `APIGroup` → `API`, `Group`).
|
|
579
|
+
*/
|
|
580
|
+
const splitIdent = (s: string): string[] => {
|
|
581
|
+
const parts: string[] = [];
|
|
582
|
+
for (const chunk of s.split(/[_/\-.\s]+/).filter(Boolean)) {
|
|
583
|
+
const split = chunk
|
|
584
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1\0$2")
|
|
585
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1\0$2");
|
|
586
|
+
for (const p of split.split("\0")) {
|
|
587
|
+
if (p) parts.push(p);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return parts;
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
const isStrongVerb = (raw: string): boolean =>
|
|
594
|
+
STRONG_VERBS.has(raw.toLowerCase());
|
|
595
|
+
const isTrailingVerb = (raw: string): boolean =>
|
|
596
|
+
TRAILING_VERBS.has(raw.toLowerCase());
|
|
597
|
+
const isWeakLeadingVerb = (raw: string): boolean =>
|
|
598
|
+
WEAK_LEADING_VERBS.has(raw.toLowerCase());
|
|
599
|
+
|
|
600
|
+
const alias = (raw: string): string =>
|
|
601
|
+
VERB_ALIAS[raw.toLowerCase()] ?? raw.toLowerCase();
|
|
602
|
+
|
|
603
|
+
/** `version`-like tokens (`V1`, `v1beta1`, `V2`) are never nouns to singularise. */
|
|
604
|
+
const isVersionToken = (raw: string): boolean => /^[vV]\d/.test(raw);
|
|
605
|
+
|
|
606
|
+
/** Adverbs that sit between resource and verb: `HoldoutsPartialUpdate`. */
|
|
607
|
+
const MODIFIERS = new Set(["partial", "bulk", "batch", "all", "many"]);
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* `verb + Resource + Object`. Only the resource (last noun before the verb)
|
|
611
|
+
* is singularised, and only for non-list verbs (`getApp`, `listApps`,
|
|
612
|
+
* `listMachineEvents`). Parent tokens keep their spelling.
|
|
613
|
+
*/
|
|
614
|
+
const assemble = (
|
|
615
|
+
verb: string,
|
|
616
|
+
nouns: readonly string[],
|
|
617
|
+
tail: readonly string[] = [],
|
|
618
|
+
): string => {
|
|
619
|
+
// `App_Certificates_acme_create`: the qualifier trails the resource in
|
|
620
|
+
// the id but reads better in front of it (`createAppAcmeCertificate`).
|
|
621
|
+
let ordered = [...nouns];
|
|
622
|
+
if (ordered.length >= 2 && QUALIFIERS.has(ordered.at(-1)!.toLowerCase())) {
|
|
623
|
+
const q = ordered.pop()!;
|
|
624
|
+
ordered.splice(ordered.length - 1, 0, q);
|
|
625
|
+
}
|
|
626
|
+
const shaped = ordered.map((p, i) => {
|
|
627
|
+
const isLast = i === ordered.length - 1;
|
|
628
|
+
if (!isLast || isVersionToken(p)) return pascalToken(p);
|
|
629
|
+
if (verb === "list" && tail.length === 0) return pascalToken(p);
|
|
630
|
+
return pascalToken(singularize(p));
|
|
631
|
+
});
|
|
632
|
+
return verb + shaped.join("") + tail.map(pascalToken).join("");
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Distilled SDK names are verbNoun (`listApps`, `getApp`, `createMachine`).
|
|
637
|
+
*
|
|
638
|
+
* - Already verb-first (`listSprites`, `GetObject`, `showContact`,
|
|
639
|
+
* `WatchPodList`) stays, with `show`/`index`/`retrieve` aliased.
|
|
640
|
+
* - Trailing action (`ConfigsList`, `PlansGet`, `ContainerCreate`,
|
|
641
|
+
* `VirtualMachinesStart`, `ReleaseGet`) moves the verb first and
|
|
642
|
+
* singularises the resource for non-list verbs.
|
|
643
|
+
* - go-swagger / autorest `Apps_list`, `App_Certificates_show`,
|
|
644
|
+
* `Machines_list_events`, `ServiceMembers_getMetrics`: the verb heads the
|
|
645
|
+
* first `_` segment after the resource.
|
|
646
|
+
* - Anything else (`AppGetOrCreate`, `VirtualMachines_createOrUpdate`,
|
|
647
|
+
* `DnsRecordsBatch`, `accountById`) is returned unchanged apart from
|
|
648
|
+
* lowerFirst. Use {@link OperationIdRewrite} for those.
|
|
649
|
+
*/
|
|
650
|
+
export const toVerbNoun = (operationId: string): string => {
|
|
651
|
+
const trimmed = operationId.trim();
|
|
652
|
+
if (trimmed === "") return trimmed;
|
|
653
|
+
|
|
654
|
+
const segments = trimmed.split(/[_/]+/).filter(Boolean);
|
|
655
|
+
if (segments.length >= 2) {
|
|
656
|
+
// snake_case verb-first (`get_apps`, `list_all_users`): join.
|
|
657
|
+
const lead = splitIdent(segments[0]!);
|
|
658
|
+
if (lead.length === 1 && isStrongVerb(lead[0]!)) {
|
|
659
|
+
const rest = segments.slice(1).flatMap(splitIdent);
|
|
660
|
+
return alias(lead[0]!) + rest.map(pascalToken).join("");
|
|
661
|
+
}
|
|
662
|
+
// Segments before the verb are the resource by construction, so a
|
|
663
|
+
// verb-looking token inside them (`OpenIdConnectProvider_get`) is a
|
|
664
|
+
// noun. Only the object after the verb can make this a compound.
|
|
665
|
+
const verbSeg = segments.findIndex((seg, i) => {
|
|
666
|
+
if (i === 0) return false;
|
|
667
|
+
const tokens = splitIdent(seg);
|
|
668
|
+
const head = tokens[0] ?? "";
|
|
669
|
+
// `Apps_index` lists; `Apps_indexInfo` is about the index entity.
|
|
670
|
+
if (isIndexVerb(head)) return tokens.length === 1;
|
|
671
|
+
return isTrailingVerb(head);
|
|
672
|
+
});
|
|
673
|
+
if (verbSeg > 0) {
|
|
674
|
+
const head = segments.slice(0, verbSeg).flatMap(splitIdent);
|
|
675
|
+
const [verb, ...objectHere] = splitIdent(segments[verbSeg]!);
|
|
676
|
+
const object = [
|
|
677
|
+
...objectHere,
|
|
678
|
+
...segments.slice(verbSeg + 1).flatMap(splitIdent),
|
|
679
|
+
];
|
|
680
|
+
const compound = object.some(
|
|
681
|
+
(t) => isStrongVerb(t) || /^(or|and)$/i.test(t),
|
|
682
|
+
);
|
|
683
|
+
if (!compound) return assemble(alias(verb!), head, object);
|
|
684
|
+
}
|
|
685
|
+
return lowerFirst(trimmed);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const parts = splitIdent(trimmed);
|
|
689
|
+
// `Index1`, `Index2`: a converter's duplicate-route suffix on a bare id.
|
|
690
|
+
if (parts.length <= 1 || parts.slice(1).every((p) => /^\d+$/.test(p))) {
|
|
691
|
+
return parts.length >= 1 && isTrailingVerb(parts[0]!)
|
|
692
|
+
? alias(parts[0]!) + parts.slice(1).join("")
|
|
693
|
+
: lowerFirst(trimmed);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const first = parts[0]!;
|
|
697
|
+
const last = parts.at(-1)!;
|
|
698
|
+
|
|
699
|
+
// A camel id never uses Rails `index` as its verb: `InfoIndex`,
|
|
700
|
+
// `IndexCreate`, `IndexDocument` all name the index entity.
|
|
701
|
+
const strong = (raw: string) => !isIndexVerb(raw) && isStrongVerb(raw);
|
|
702
|
+
const trailing = (raw: string) => !isIndexVerb(raw) && isTrailingVerb(raw);
|
|
703
|
+
const weakLeading = (raw: string) =>
|
|
704
|
+
!isIndexVerb(raw) && isWeakLeadingVerb(raw);
|
|
705
|
+
|
|
706
|
+
if (strong(first)) {
|
|
707
|
+
return alias(first) + parts.slice(1).map(pascalToken).join("");
|
|
708
|
+
}
|
|
709
|
+
if (parts.some((p) => /^(or|and)$/i.test(p))) return lowerFirst(trimmed);
|
|
710
|
+
|
|
711
|
+
// Swagger tag-prefixed ids whose object repeats the tag (`issueListIssues`,
|
|
712
|
+
// `repoGetRepo`): the tag is redundant, drop it. Other tag-prefixed shapes
|
|
713
|
+
// (`userGetCurrent`, `notifyGetList`) are left for per-package
|
|
714
|
+
// `operationNames`; a middle verb is too often a noun (`environmentPatchCommit`).
|
|
715
|
+
if (
|
|
716
|
+
parts.length >= 3 &&
|
|
717
|
+
!weakLeading(first) &&
|
|
718
|
+
!isVersionToken(first) &&
|
|
719
|
+
strong(parts[1]!) &&
|
|
720
|
+
singularize(parts[2]!).toLowerCase() === singularize(first).toLowerCase() &&
|
|
721
|
+
!parts.slice(2).some(strong)
|
|
722
|
+
) {
|
|
723
|
+
return alias(parts[1]!) + parts.slice(2).map(pascalToken).join("");
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const reorder = weakLeading(first) ? strong(last) : trailing(last);
|
|
727
|
+
if (!reorder) {
|
|
728
|
+
return weakLeading(first)
|
|
729
|
+
? alias(first) + parts.slice(1).map(pascalToken).join("")
|
|
730
|
+
: lowerFirst(trimmed);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const nouns = parts.slice(0, -1);
|
|
734
|
+
if (nouns.slice(1).some(strong)) return lowerFirst(trimmed);
|
|
735
|
+
const modifier = nouns.at(-1);
|
|
736
|
+
if (
|
|
737
|
+
nouns.length > 1 &&
|
|
738
|
+
modifier !== undefined &&
|
|
739
|
+
MODIFIERS.has(modifier.toLowerCase())
|
|
740
|
+
) {
|
|
741
|
+
return assemble(alias(last), nouns.slice(0, -1), [modifier]);
|
|
742
|
+
}
|
|
743
|
+
return assemble(alias(last), nouns);
|
|
744
|
+
};
|
|
745
|
+
|
|
746
|
+
const COMPANION_SUFFIXES = [
|
|
747
|
+
"Request",
|
|
748
|
+
"Response",
|
|
749
|
+
"Input",
|
|
750
|
+
"Output",
|
|
751
|
+
"Error",
|
|
752
|
+
"Result",
|
|
753
|
+
] as const;
|
|
754
|
+
|
|
755
|
+
const remapTargets = (
|
|
756
|
+
node: unknown,
|
|
757
|
+
mapping: ReadonlyMap<string, string>,
|
|
758
|
+
): unknown => {
|
|
759
|
+
if (Array.isArray(node)) {
|
|
760
|
+
return node.map((item) => remapTargets(item, mapping));
|
|
761
|
+
}
|
|
762
|
+
if (node === null || typeof node !== "object") return node;
|
|
763
|
+
const out: Record<string, unknown> = {};
|
|
764
|
+
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
|
|
765
|
+
if (key === "target" && typeof value === "string") {
|
|
766
|
+
out[key] = mapping.get(value) ?? value;
|
|
767
|
+
} else {
|
|
768
|
+
out[key] = remapTargets(value, mapping);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
return out;
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
/** Version prefixes and API roots that carry no meaning in a name. */
|
|
775
|
+
const NOISE_SEGMENTS = new Set(["api", "rest", "v", "public"]);
|
|
776
|
+
const isNoiseSegment = (seg: string): boolean => {
|
|
777
|
+
const lower = seg.toLowerCase();
|
|
778
|
+
return NOISE_SEGMENTS.has(lower) || /^v\d+(\.\d+)*$/.test(lower);
|
|
779
|
+
};
|
|
780
|
+
|
|
781
|
+
const HTTP_METHOD_ID =
|
|
782
|
+
/^(get|post|put|patch|delete|head|options)(?=$|[-_./]|[A-Z0-9])/i;
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Whether an OpenAPI `operationId` carries no information beyond the
|
|
786
|
+
* method and path: absent, or a mechanical restatement of the route such
|
|
787
|
+
* as `get-api-card`, `post_v1_users_id`, `getApiV1AnnotationLayer`. A
|
|
788
|
+
* hand-chosen id that merely starts with the method (`delete-artifact` for
|
|
789
|
+
* `/repos/{owner}/{repo}/actions/artifacts/{id}`) is NOT mechanical — it
|
|
790
|
+
* names a subset of the route on purpose — so the id must cover every
|
|
791
|
+
* literal path segment, and every id token must come from the route.
|
|
792
|
+
*/
|
|
793
|
+
/**
|
|
794
|
+
* A stricter subclass of mechanical ids: nothing in the id was chosen by
|
|
795
|
+
* a person. Either there is no id, or the id is the route spelled out —
|
|
796
|
+
* `postV1AppsByAppIdPromote`, `get_v1_users_id`, `get-api-card`,
|
|
797
|
+
* `getCustomerById`. Such names are derived from the route alone.
|
|
798
|
+
*
|
|
799
|
+
* A `By…` clause counts as verbatim only when it names a whole parameter
|
|
800
|
+
* (`ByAppId` for `{appId}`) or is a bare `ById`. `get_webhook_by_token`
|
|
801
|
+
* for `/webhooks/{webhook_id}/{webhook_token}` is a chosen
|
|
802
|
+
* disambiguation, so its nouns are kept; likewise `deleteScheduledJob`.
|
|
803
|
+
*/
|
|
804
|
+
export const isVerbatimRouteId = (
|
|
805
|
+
operationId: string | undefined,
|
|
806
|
+
ctx: OperationIdContext,
|
|
807
|
+
): boolean => {
|
|
808
|
+
if (!operationId) return true;
|
|
809
|
+
if (!isMechanicalOperationId(operationId, ctx)) return false;
|
|
810
|
+
const id = stripTag(operationId);
|
|
811
|
+
const m = HTTP_METHOD_ID.exec(id);
|
|
812
|
+
const norm = (t: string) => singularize(t.toLowerCase()).toLowerCase();
|
|
813
|
+
const idTokens = splitIdent(id.slice(m ? m[0].length : 0)).map(norm);
|
|
814
|
+
const literal = new Set<string>();
|
|
815
|
+
const params: string[][] = [];
|
|
816
|
+
for (const seg of ctx.path.split(/[?#]/)[0]!.split("/")) {
|
|
817
|
+
for (const mm of seg.matchAll(/\{([^}]+)\}/g)) {
|
|
818
|
+
params.push(splitIdent(mm[1]!).map(norm));
|
|
819
|
+
}
|
|
820
|
+
for (const t of splitIdent(seg.replace(/\{[^}]*\}/g, " ")).map(norm)) {
|
|
821
|
+
literal.add(t);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
const paramTokens = new Set(params.flat());
|
|
825
|
+
const clauses: string[][] = [[]];
|
|
826
|
+
for (const t of idTokens) {
|
|
827
|
+
if (t === "by") clauses.push([]);
|
|
828
|
+
else clauses.at(-1)!.push(t);
|
|
829
|
+
}
|
|
830
|
+
const [head, ...byClauses] = clauses;
|
|
831
|
+
if (!head!.every((t) => literal.has(t) || paramTokens.has(t))) return false;
|
|
832
|
+
// `ByAppIdPromote`: a whole parameter, then route literals.
|
|
833
|
+
return byClauses.every((c) => {
|
|
834
|
+
const lengths = [
|
|
835
|
+
...params
|
|
836
|
+
.filter((p) => p.every((t, i) => t === c[i]))
|
|
837
|
+
.map((p) => p.length),
|
|
838
|
+
...(c[0] === "id" ? [1] : []),
|
|
839
|
+
];
|
|
840
|
+
return lengths.some(
|
|
841
|
+
(n) => n <= c.length && c.slice(n).every((t) => literal.has(t)),
|
|
842
|
+
);
|
|
843
|
+
});
|
|
844
|
+
};
|
|
845
|
+
|
|
846
|
+
/** `activity/get-feeds` → `get-feeds`: a tag prefix is not part of the name. */
|
|
847
|
+
const stripTag = (operationId: string): string =>
|
|
848
|
+
operationId.includes("/")
|
|
849
|
+
? operationId.slice(operationId.lastIndexOf("/") + 1)
|
|
850
|
+
: operationId;
|
|
851
|
+
|
|
852
|
+
export const isMechanicalOperationId = (
|
|
853
|
+
operationId: string | undefined,
|
|
854
|
+
ctx: OperationIdContext,
|
|
855
|
+
): boolean => {
|
|
856
|
+
if (!operationId) return true;
|
|
857
|
+
const id = stripTag(operationId);
|
|
858
|
+
const m = HTTP_METHOD_ID.exec(id);
|
|
859
|
+
if (!m || m[1]!.toLowerCase() !== ctx.method.toLowerCase()) return false;
|
|
860
|
+
const norm = (t: string) => singularize(t.toLowerCase()).toLowerCase();
|
|
861
|
+
const joiners = new Set(["by", "for", "of", "in", "with", "and", "to"]);
|
|
862
|
+
const idTokens = splitIdent(id.slice(m[0].length))
|
|
863
|
+
.map(norm)
|
|
864
|
+
.filter((t) => !joiners.has(t));
|
|
865
|
+
const segments = ctx.path.split(/[?#]/)[0]!.split("/").filter(Boolean);
|
|
866
|
+
// Route tokens in order; literals are required, params and `/api`,
|
|
867
|
+
// `/v2` roots are optional (`getApiV1Foo` and `getFoo` both count).
|
|
868
|
+
const route: Array<{ token: string; required: boolean }> = [];
|
|
869
|
+
for (const seg of segments) {
|
|
870
|
+
const param = /^\{(.*)\}$/.exec(seg);
|
|
871
|
+
const required = param === null && !isNoiseSegment(seg);
|
|
872
|
+
for (const t of splitIdent(param ? param[1]! : seg).map(norm)) {
|
|
873
|
+
route.push({ token: t, required });
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
if (idTokens.length === 0) return !route.some((r) => r.required);
|
|
877
|
+
// The id must read the route left to right, skipping only optional
|
|
878
|
+
// tokens. `delete-package-for-org` on /orgs/{org}/packages/… reads
|
|
879
|
+
// `package, org` — out of order — and stays a hand-written name.
|
|
880
|
+
let i = 0;
|
|
881
|
+
for (const r of route) {
|
|
882
|
+
if (i < idTokens.length && idTokens[i] === r.token) {
|
|
883
|
+
i++;
|
|
884
|
+
} else if (r.required) {
|
|
885
|
+
return false;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
return i === idTokens.length;
|
|
889
|
+
};
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Derive `verbNoun` from `METHOD /path` for operations whose operationId
|
|
893
|
+
* is missing or mechanical:
|
|
894
|
+
*
|
|
895
|
+
* GET /users → listUsers (when the 200 body is a
|
|
896
|
+
* collection; else getUsers)
|
|
897
|
+
* GET /users/{id} → getUser
|
|
898
|
+
* POST /users → createUser
|
|
899
|
+
* POST /users/{id} → updateUser (Stripe-style POST-to-update)
|
|
900
|
+
* PUT /users/{id} → putUser (replace semantics kept)
|
|
901
|
+
* PATCH /users/{id} → updateUser
|
|
902
|
+
* DELETE /users/{id} → deleteUser
|
|
903
|
+
* POST /users/{id}/reset → resetUser (trailing action segment)
|
|
904
|
+
* GET /orgs/{o}/repos → listOrgRepos (parents, singular, keep order)
|
|
905
|
+
*
|
|
906
|
+
* `/api`, `/v1`-style roots are dropped. Path parameters contribute nothing
|
|
907
|
+
* to the name; only literal segments do.
|
|
908
|
+
*/
|
|
909
|
+
export interface PathNamingHints {
|
|
910
|
+
/**
|
|
911
|
+
* Whether the success response is a collection (array body, or an
|
|
912
|
+
* object whose only/primary member is an array). Decides `list` vs
|
|
913
|
+
* `get` for a GET on a route with no trailing parameter.
|
|
914
|
+
*/
|
|
915
|
+
readonly returnsCollection?: boolean;
|
|
916
|
+
/**
|
|
917
|
+
* A mechanical operationId (`get-feeds`, `delete_v1_users_id`) whose
|
|
918
|
+
* noun tokens, after the method, should be used verbatim instead of
|
|
919
|
+
* being re-derived (and re-singularised) from the route.
|
|
920
|
+
*/
|
|
921
|
+
readonly nouns?: string;
|
|
922
|
+
/**
|
|
923
|
+
* `nouns` is the route spelled out (`postV1AppsByAppIdPromote`), so
|
|
924
|
+
* parameter tokens embedded without a `By` (`get_v1_users_id`) and
|
|
925
|
+
* every `By…` clause are dropped, and the resource is singularised
|
|
926
|
+
* when a parameter follows it.
|
|
927
|
+
*/
|
|
928
|
+
readonly verbatim?: boolean;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
export const pathToVerbNoun = (
|
|
932
|
+
ctx: OperationIdContext,
|
|
933
|
+
hints: PathNamingHints = {},
|
|
934
|
+
): string => {
|
|
935
|
+
const method = ctx.method.toLowerCase();
|
|
936
|
+
const segments = ctx.path.split(/[?#]/)[0]!.split("/").filter(Boolean);
|
|
937
|
+
const literal: string[] = [];
|
|
938
|
+
// `{id}`, `:id`, and composite segments such as `{slug}-{id}`.
|
|
939
|
+
const isParam = (seg: string) => /\{[^}]*\}/.test(seg) || seg.startsWith(":");
|
|
940
|
+
let endsWithParam = false;
|
|
941
|
+
for (let i = 0; i < segments.length; i++) {
|
|
942
|
+
const seg = segments[i]!;
|
|
943
|
+
if (isParam(seg)) {
|
|
944
|
+
endsWithParam = i === segments.length - 1;
|
|
945
|
+
continue;
|
|
946
|
+
}
|
|
947
|
+
endsWithParam = false;
|
|
948
|
+
if (isNoiseSegment(seg)) continue;
|
|
949
|
+
literal.push(seg);
|
|
950
|
+
}
|
|
951
|
+
if (literal.length === 0) return lowerFirst(method + "Root");
|
|
952
|
+
|
|
953
|
+
if (hints.nouns !== undefined) {
|
|
954
|
+
// `activity/get-feeds`: a tag prefix before the method is not a noun.
|
|
955
|
+
const id = hints.nouns.includes("/")
|
|
956
|
+
? hints.nouns.slice(hints.nouns.lastIndexOf("/") + 1)
|
|
957
|
+
: hints.nouns;
|
|
958
|
+
const m = HTTP_METHOD_ID.exec(id);
|
|
959
|
+
const rest = m ? id.slice(m[0].length) : id;
|
|
960
|
+
// Route literals, both as written and singularised, so a parameter
|
|
961
|
+
// that echoes a literal (`{team}` under `/teams/`) is not mistaken
|
|
962
|
+
// for a parameter token.
|
|
963
|
+
const literal = new Set(
|
|
964
|
+
segments
|
|
965
|
+
.filter((seg) => !isParam(seg))
|
|
966
|
+
.flatMap((seg) => splitIdent(seg))
|
|
967
|
+
.flatMap((t) => [t.toLowerCase(), singularize(t).toLowerCase()]),
|
|
968
|
+
);
|
|
969
|
+
const routeNoise = new Set(
|
|
970
|
+
segments
|
|
971
|
+
.filter((seg) => !isParam(seg) && isNoiseSegment(seg))
|
|
972
|
+
.flatMap((seg) => splitIdent(seg).map((t) => t.toLowerCase())),
|
|
973
|
+
);
|
|
974
|
+
/** Whole parameter names as token lists (`{appId}` → ["app","id"]). */
|
|
975
|
+
const params = segments
|
|
976
|
+
.flatMap((seg) => [...seg.matchAll(/\{([^}]+)\}/g)])
|
|
977
|
+
.map((mm) => splitIdent(mm[1]!).map((t) => t.toLowerCase()));
|
|
978
|
+
const raw = splitIdent(rest);
|
|
979
|
+
/** Longest whole parameter name starting at raw[at], else 0. */
|
|
980
|
+
const matchParam = (at: number): number => {
|
|
981
|
+
let best = 0;
|
|
982
|
+
for (const prm of params) {
|
|
983
|
+
if (
|
|
984
|
+
prm.length > best &&
|
|
985
|
+
prm.every((t, k) => raw[at + k]?.toLowerCase() === t)
|
|
986
|
+
) {
|
|
987
|
+
best = prm.length;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
if (best === 0 && raw[at]?.toLowerCase() === "id") best = 1;
|
|
991
|
+
return best;
|
|
992
|
+
};
|
|
993
|
+
const tokens: string[] = [];
|
|
994
|
+
/** Index in `tokens` of a noun that a parameter immediately follows. */
|
|
995
|
+
const followedByParam = new Set<number>();
|
|
996
|
+
for (let i = 0; i < raw.length; i++) {
|
|
997
|
+
const t = raw[i]!;
|
|
998
|
+
const lower = t.toLowerCase();
|
|
999
|
+
// `getApiV1Foo` → drop the api/version root the id copied from the
|
|
1000
|
+
// route; a token the route does not have (`…DataAPI`) is kept.
|
|
1001
|
+
if (isNoiseSegment(t) && routeNoise.has(lower)) continue;
|
|
1002
|
+
let n = 0;
|
|
1003
|
+
let skip = 0;
|
|
1004
|
+
if (/^(by|for|of|in|with|and|to)$/.test(lower)) {
|
|
1005
|
+
n = matchParam(i + 1);
|
|
1006
|
+
skip = n + 1;
|
|
1007
|
+
// Non-verbatim ids only drop a TRAILING `ById`; a `By` in the
|
|
1008
|
+
// middle (`getBalanceByAsset`) is part of the chosen name.
|
|
1009
|
+
if (n > 0 && !hints.verbatim && i + skip !== raw.length) n = 0;
|
|
1010
|
+
} else if (hints.verbatim && i > 0) {
|
|
1011
|
+
// `get_v1_users_id`, `GetAccountsAccount`: a whole parameter name
|
|
1012
|
+
// with no `By`. When the parameter echoes the resource it follows
|
|
1013
|
+
// (`/accounts/{account}`, `/teams/{team}`) the previous token has
|
|
1014
|
+
// to be that resource; otherwise (`deleteProjectBranchCustomDomain`
|
|
1015
|
+
// on `/custom-domains/{domain}`) it is the literal.
|
|
1016
|
+
n = matchParam(i);
|
|
1017
|
+
skip = n;
|
|
1018
|
+
const echoes = raw
|
|
1019
|
+
.slice(i, i + n)
|
|
1020
|
+
.some((x) => literal.has(x.toLowerCase()));
|
|
1021
|
+
const prev = tokens.at(-1)?.toLowerCase();
|
|
1022
|
+
const prevIsResource =
|
|
1023
|
+
prev !== undefined &&
|
|
1024
|
+
raw
|
|
1025
|
+
.slice(i, i + n)
|
|
1026
|
+
.some(
|
|
1027
|
+
(x) =>
|
|
1028
|
+
singularize(prev) === singularize(x.toLowerCase()) ||
|
|
1029
|
+
prev === x.toLowerCase(),
|
|
1030
|
+
);
|
|
1031
|
+
if (echoes && !prevIsResource) n = 0;
|
|
1032
|
+
}
|
|
1033
|
+
if (n > 0) {
|
|
1034
|
+
if (tokens.length) followedByParam.add(tokens.length - 1);
|
|
1035
|
+
i += skip - 1;
|
|
1036
|
+
continue;
|
|
1037
|
+
}
|
|
1038
|
+
tokens.push(t);
|
|
1039
|
+
}
|
|
1040
|
+
if (hints.verbatim) {
|
|
1041
|
+
for (const i of followedByParam) tokens[i] = singularize(tokens[i]!);
|
|
1042
|
+
// `PostAccounts` → createAccount: a create makes one member.
|
|
1043
|
+
if (method === "post" && !endsWithParam && tokens.length > 0) {
|
|
1044
|
+
tokens[tokens.length - 1] = singularize(tokens.at(-1)!);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
if (tokens.length > 0) {
|
|
1048
|
+
// A POST that addresses one member (`POST /accounts/{account}`,
|
|
1049
|
+
// Stripe-style) updates it; a POST on a collection creates.
|
|
1050
|
+
const verb =
|
|
1051
|
+
method === "get"
|
|
1052
|
+
? !endsWithParam && hints.returnsCollection === true
|
|
1053
|
+
? "list"
|
|
1054
|
+
: "get"
|
|
1055
|
+
: method === "post"
|
|
1056
|
+
? endsWithParam
|
|
1057
|
+
? "update"
|
|
1058
|
+
: "create"
|
|
1059
|
+
: method === "patch"
|
|
1060
|
+
? "update"
|
|
1061
|
+
: method;
|
|
1062
|
+
return verb + tokens.map(pascalToken).join("");
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
const tokens = literal.map((seg) => splitIdent(seg));
|
|
1067
|
+
const lastTokens = tokens.at(-1)!;
|
|
1068
|
+
// `/papers/index`, `/opensearch/index` address an index resource, not a
|
|
1069
|
+
// Rails `index` action.
|
|
1070
|
+
const lastIsVerb =
|
|
1071
|
+
lastTokens.length > 0 &&
|
|
1072
|
+
!isIndexVerb(lastTokens[0]!) &&
|
|
1073
|
+
(isTrailingVerb(lastTokens[0]!) || isStrongVerb(lastTokens[0]!)) &&
|
|
1074
|
+
!endsWithParam &&
|
|
1075
|
+
method === "post";
|
|
1076
|
+
|
|
1077
|
+
// POST /things/{id}/publish → publishThing; POST /login → login.
|
|
1078
|
+
if (lastIsVerb) {
|
|
1079
|
+
const [verb, ...rest] = lastTokens;
|
|
1080
|
+
const parents = tokens.slice(0, -1);
|
|
1081
|
+
const nouns = parents.map((t, i) =>
|
|
1082
|
+
i === parents.length - 1
|
|
1083
|
+
? t.map((x, j) => (j === t.length - 1 ? singularize(x) : x))
|
|
1084
|
+
: t,
|
|
1085
|
+
);
|
|
1086
|
+
return (
|
|
1087
|
+
alias(verb!) +
|
|
1088
|
+
nouns.flat().map(pascalToken).join("") +
|
|
1089
|
+
rest.map(pascalToken).join("")
|
|
1090
|
+
);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// GET is `list` only when the body is known to be a collection; an
|
|
1094
|
+
// unknown or non-JSON body (`/zen`, `/octocat`) is a plain `get`.
|
|
1095
|
+
const verb =
|
|
1096
|
+
method === "get"
|
|
1097
|
+
? !endsWithParam && hints.returnsCollection === true
|
|
1098
|
+
? "list"
|
|
1099
|
+
: "get"
|
|
1100
|
+
: method === "post"
|
|
1101
|
+
? endsWithParam
|
|
1102
|
+
? "update"
|
|
1103
|
+
: "create"
|
|
1104
|
+
: method === "patch"
|
|
1105
|
+
? "update"
|
|
1106
|
+
: method === "put"
|
|
1107
|
+
? "put"
|
|
1108
|
+
: method === "delete"
|
|
1109
|
+
? "delete"
|
|
1110
|
+
: method;
|
|
1111
|
+
// A GET/DELETE/PATCH/PUT on a collection route (no trailing parameter)
|
|
1112
|
+
// addresses the collection itself — keep its plural. With a trailing
|
|
1113
|
+
// parameter the route addresses one member, and `create` always makes
|
|
1114
|
+
// one — singular.
|
|
1115
|
+
const plural = !endsWithParam && verb !== "create";
|
|
1116
|
+
const shaped = tokens.map((t, i) => {
|
|
1117
|
+
const isResource = i === tokens.length - 1;
|
|
1118
|
+
return t.map((x, j) => {
|
|
1119
|
+
const last = j === t.length - 1;
|
|
1120
|
+
if (!last) return pascalToken(x);
|
|
1121
|
+
// Parents are singular (`Org` in listOrgRepos).
|
|
1122
|
+
if (!isResource || !plural) return pascalToken(singularize(x));
|
|
1123
|
+
return pascalToken(x);
|
|
1124
|
+
});
|
|
1125
|
+
});
|
|
1126
|
+
return verb + shaped.flat().join("");
|
|
1127
|
+
};
|
|
1128
|
+
|
|
1129
|
+
/**
|
|
1130
|
+
* Rename operations (and every shape whose name starts with the operation
|
|
1131
|
+
* name — `FooRequest`, `FooResponse`, `FooRequestBody`, `FooResponseItemsList`)
|
|
1132
|
+
* in a Smithy model to verbNoun PascalCase. Mutates `model.shapes`.
|
|
1133
|
+
* Colliding names keep the original id and are reported.
|
|
1134
|
+
*/
|
|
1135
|
+
export const verbNounSmithyModel = (model: {
|
|
1136
|
+
shapes?: Record<string, any>;
|
|
1137
|
+
}): { renamed: number; collisions: string[] } => {
|
|
1138
|
+
const shapes = model.shapes ?? {};
|
|
1139
|
+
const mapping = new Map<string, string>();
|
|
1140
|
+
const collisions: string[] = [];
|
|
1141
|
+
const taken = new Set(Object.keys(shapes));
|
|
1142
|
+
|
|
1143
|
+
const ops = Object.entries(shapes).filter(
|
|
1144
|
+
([, def]) => def?.type === "operation",
|
|
1145
|
+
);
|
|
1146
|
+
const opLocals = new Set<string>();
|
|
1147
|
+
for (const [id] of ops) {
|
|
1148
|
+
const hash = id.indexOf("#");
|
|
1149
|
+
opLocals.add(hash >= 0 ? id.slice(hash + 1) : id);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
for (const [id] of ops) {
|
|
1153
|
+
const hash = id.indexOf("#");
|
|
1154
|
+
const ns = hash >= 0 ? id.slice(0, hash) : "";
|
|
1155
|
+
const local = hash >= 0 ? id.slice(hash + 1) : id;
|
|
1156
|
+
const camel = toVerbNoun(local);
|
|
1157
|
+
const nextLocal = camel.charAt(0).toUpperCase() + camel.slice(1);
|
|
1158
|
+
if (nextLocal === local) continue;
|
|
1159
|
+
const nextId = ns ? `${ns}#${nextLocal}` : nextLocal;
|
|
1160
|
+
if (taken.has(nextId) && !mapping.has(nextId)) {
|
|
1161
|
+
collisions.push(`${local} → ${nextLocal}`);
|
|
1162
|
+
continue;
|
|
1163
|
+
}
|
|
1164
|
+
if ([...mapping.values()].includes(nextId)) {
|
|
1165
|
+
collisions.push(`${local} → ${nextLocal}`);
|
|
1166
|
+
continue;
|
|
1167
|
+
}
|
|
1168
|
+
mapping.set(id, nextId);
|
|
1169
|
+
taken.add(nextId);
|
|
1170
|
+
|
|
1171
|
+
// Companions: `<Op>Request`, `<Op>Response…`, and anything derived
|
|
1172
|
+
// from them by the converters' `${opName}Request${Member}` naming.
|
|
1173
|
+
// Skip prefixes that are themselves another operation's name
|
|
1174
|
+
// (`Get` vs `GetObject`) — only exact suffix matches count there.
|
|
1175
|
+
for (const candidate of Object.keys(shapes)) {
|
|
1176
|
+
if (candidate === id || mapping.has(candidate)) continue;
|
|
1177
|
+
const cHash = candidate.indexOf("#");
|
|
1178
|
+
const cNs = cHash >= 0 ? candidate.slice(0, cHash) : "";
|
|
1179
|
+
const cLocal = cHash >= 0 ? candidate.slice(cHash + 1) : candidate;
|
|
1180
|
+
if (cNs !== ns || !cLocal.startsWith(local)) continue;
|
|
1181
|
+
const tail = cLocal.slice(local.length);
|
|
1182
|
+
if (tail === "") continue;
|
|
1183
|
+
const suffixed = COMPANION_SUFFIXES.some((s) => tail.startsWith(s));
|
|
1184
|
+
if (!suffixed) continue;
|
|
1185
|
+
// `ListAppsRequest` vs op `List` + `AppsRequest`: require the tail to
|
|
1186
|
+
// start with a companion suffix immediately after the op name.
|
|
1187
|
+
const to = ns ? `${ns}#${nextLocal}${tail}` : `${nextLocal}${tail}`;
|
|
1188
|
+
if (taken.has(to)) continue;
|
|
1189
|
+
mapping.set(candidate, to);
|
|
1190
|
+
taken.add(to);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
if (mapping.size === 0) return { renamed: 0, collisions };
|
|
1195
|
+
|
|
1196
|
+
const nextShapes: Record<string, any> = {};
|
|
1197
|
+
for (const [id, def] of Object.entries(shapes)) {
|
|
1198
|
+
const newId = mapping.get(id) ?? id;
|
|
1199
|
+
nextShapes[newId] = remapTargets(def, mapping);
|
|
1200
|
+
}
|
|
1201
|
+
model.shapes = nextShapes;
|
|
1202
|
+
return {
|
|
1203
|
+
renamed: ops.filter(([opId]) => mapping.has(opId)).length,
|
|
1204
|
+
collisions,
|
|
1205
|
+
};
|
|
1206
|
+
};
|