@volter/twin-standard 1.0.0

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 (69) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +20 -0
  3. package/dist/src/check-sources.d.ts +33 -0
  4. package/dist/src/check-sources.js +128 -0
  5. package/dist/src/cli.d.ts +2 -0
  6. package/dist/src/cli.js +105 -0
  7. package/dist/src/derive.d.ts +2 -0
  8. package/dist/src/derive.js +349 -0
  9. package/dist/src/gate.d.ts +9 -0
  10. package/dist/src/gate.js +109 -0
  11. package/dist/src/grade.d.ts +30 -0
  12. package/dist/src/grade.js +36 -0
  13. package/dist/src/index.d.ts +7 -0
  14. package/dist/src/index.js +11 -0
  15. package/dist/src/lanes.d.ts +2 -0
  16. package/dist/src/lanes.js +33 -0
  17. package/dist/src/p3-rules.d.ts +8 -0
  18. package/dist/src/p3-rules.js +43 -0
  19. package/dist/src/protocol-3.d.ts +5 -0
  20. package/dist/src/protocol-3.js +721 -0
  21. package/dist/src/published.d.ts +13 -0
  22. package/dist/src/published.js +36 -0
  23. package/dist/src/spec-documents.d.ts +12 -0
  24. package/dist/src/spec-documents.js +61 -0
  25. package/dist/src/spec-ir-client.d.ts +20 -0
  26. package/dist/src/spec-ir-client.js +77 -0
  27. package/dist/src/spec-ir-commands.d.ts +38 -0
  28. package/dist/src/spec-ir-commands.js +38 -0
  29. package/dist/src/spec-ir-discovery.d.ts +11 -0
  30. package/dist/src/spec-ir-discovery.js +106 -0
  31. package/dist/src/spec-ir-graphql.d.ts +25 -0
  32. package/dist/src/spec-ir-graphql.js +46 -0
  33. package/dist/src/spec-ir-lines.d.ts +19 -0
  34. package/dist/src/spec-ir-lines.js +76 -0
  35. package/dist/src/spec-ir-proto.d.ts +80 -0
  36. package/dist/src/spec-ir-proto.js +339 -0
  37. package/dist/src/spec-ir.d.ts +104 -0
  38. package/dist/src/spec-ir.js +691 -0
  39. package/dist/src/spec-patches.d.ts +8 -0
  40. package/dist/src/spec-patches.js +24 -0
  41. package/dist/src/spec.d.ts +5 -0
  42. package/dist/src/spec.js +7 -0
  43. package/dist/src/types.d.ts +75 -0
  44. package/dist/src/types.js +4 -0
  45. package/dist/src/unit.d.ts +5 -0
  46. package/dist/src/unit.js +14 -0
  47. package/package.json +71 -0
  48. package/src/check-sources.ts +109 -0
  49. package/src/cli.ts +75 -0
  50. package/src/derive.ts +316 -0
  51. package/src/gate.ts +95 -0
  52. package/src/grade.ts +47 -0
  53. package/src/index.ts +12 -0
  54. package/src/lanes.ts +29 -0
  55. package/src/p3-rules.ts +44 -0
  56. package/src/protocol-3.ts +617 -0
  57. package/src/published.ts +37 -0
  58. package/src/spec-documents.ts +58 -0
  59. package/src/spec-ir-client.ts +86 -0
  60. package/src/spec-ir-commands.ts +50 -0
  61. package/src/spec-ir-discovery.ts +104 -0
  62. package/src/spec-ir-graphql.ts +64 -0
  63. package/src/spec-ir-lines.ts +76 -0
  64. package/src/spec-ir-proto.ts +289 -0
  65. package/src/spec-ir.ts +689 -0
  66. package/src/spec-patches.ts +23 -0
  67. package/src/spec.ts +8 -0
  68. package/src/types.ts +53 -0
  69. package/src/unit.ts +15 -0
package/src/spec-ir.ts ADDED
@@ -0,0 +1,689 @@
1
+ // THE SPEC IR — one representation of a vendor's published surface, beneath every spec format, that
2
+ // the derived-pack generator reads (docs/contributing/architecture.md, "Protocol 3"). A front end
3
+ // turns a spec into the IR; the generator only ever sees the IR. `fromOpenAPI` is the first front end, and `fromSmithy`
4
+ // reads an AWS Smithy JSON model
5
+ // (OpenAPI 3.x; Stripe's vendor extensions `x-resourceId` and `x-expandableFields` are read where
6
+ // present). Pure and deterministic: the same document always yields the same IR, byte for byte.
7
+
8
+ type Json = any;
9
+
10
+ export type OperationClass = 'create' | 'retrieve' | 'list' | 'update' | 'delete' | 'action' | 'computed' | 'non-resource';
11
+ export const CRUD_CLASSES: ReadonlySet<OperationClass> = new Set(['create', 'retrieve', 'list', 'update', 'delete']);
12
+
13
+ /** A parameter or body field: its name, type and whether it is required, and the numeric bounds the spec states
14
+ * (`minimum`, `maximum`), which a strict body (the manifest's `body.strict`, `ctx.fields`) refuses outside of. */
15
+ export type IrParam = { name: string; type: string; required: boolean; minimum?: number; maximum?: number };
16
+
17
+ /** A schema's stated numeric bounds, when it states any. */
18
+ function boundsOf(doc: Json, schema: Json): { minimum?: number; maximum?: number } {
19
+ const s = deref(doc, schema);
20
+ return { ...(typeof s?.minimum === 'number' ? { minimum: s.minimum } : {}), ...(typeof s?.maximum === 'number' ? { maximum: s.maximum } : {}) };
21
+ }
22
+
23
+ export type IrOperation = {
24
+ /** The spec's operationId, or the `method_path` slug when the spec has none. */
25
+ id: string;
26
+ method: string;
27
+ path: string;
28
+ /** Query values that tell this operation from another at the same method and path (a spec key
29
+ * such as `/responses?beta=true`); `null`: the key present with any value (S3's UploadPart, `uploadId`). */
30
+ discriminator?: Record<string, string | null>;
31
+ /** request headers that name the operation (AWS JSON's X-Amz-Target), for a wire whose operations share a path;
32
+ * `null`: the header present with any value (S3's CopyObject, `x-amz-copy-source`) */
33
+ headers?: Record<string, string | null>;
34
+ pathParams: string[];
35
+ /** The spec's own grouping of the operation, where it has one (a client-derived spec: the client library that makes
36
+ * the call), which names the family file its handler is written in; absent, the family is the path's (the grade's
37
+ * interim rule) */
38
+ family?: string;
39
+ /** The path the operation's own server puts before it, when its path item or operation names a server of its own
40
+ * (OpenAPI's path-level `servers`: Upstash's /auditlogs at https://api.upstash.com, beside the document's /v2) */
41
+ basePath?: string;
42
+ query: IrParam[];
43
+ /** Top-level body fields; `bodyEncoding` says how the vendor reads them. */
44
+ body: IrParam[];
45
+ bodyEncoding: 'json' | 'form' | 'multipart' | 'xml' | 'none';
46
+ /** A form-encoded operation's number and boolean fields, by bracket path (formScalarsOf). */
47
+ scalars?: Record<string, 'integer' | 'number' | 'boolean'>;
48
+ successStatus: number;
49
+ /** the success is documented with no content at all (a 200 with nothing in it) */
50
+ emptySuccess?: true;
51
+ /** The success answer may be a server-sent event stream (`text/event-stream`). */
52
+ streams?: boolean;
53
+ /** The resource the success answer is (or lists), when it is one: the first of a union, whose
54
+ * other members are `alternatives` (a deleted form, or the kinds of a polymorphic answer); `key`
55
+ * when the answer is an envelope holding it under that property (`{ ok, channel: {...} }`). */
56
+ /** `extends`: fields the answer adds to its resource (GitHub's ruleset-version-with-state: allOf ruleset-version and `state`). */
57
+ answers?: { resource: string; list: boolean; alternatives?: string[]; key?: string; extends?: string[] };
58
+ /** The credential scopes any one of which lets a caller make this call, from the spec's security. */
59
+ scopes?: string[];
60
+ /** The security schemes any one of which the call is made with (the spec's security, each requirement's scheme
61
+ * names); absent when the call takes none. `anonymous` when a requirement is empty (`{}`: the call may also go
62
+ * without). */
63
+ credentials?: string[];
64
+ /** The resource the operation acts on: the one at its path. */
65
+ resource?: string;
66
+ class: OperationClass;
67
+ };
68
+
69
+ export type IrStateCandidate = { field: string; values: string[] };
70
+
71
+ export type IrResource = {
72
+ name: string;
73
+ /** The component schema that defines it. */
74
+ schema: string;
75
+ /** `discriminator`: a union's field each alternative gives one value (which alternative it is). */
76
+ fields: Array<{ name: string; type: string; nullable: boolean; required?: boolean; enum?: string[]; discriminator?: boolean }>;
77
+ /** Top-level enum fields with more than one value. The manifest rules on each. */
78
+ stateCandidates: IrStateCandidate[];
79
+ /** Fields holding another resource's id that a caller may ask to receive embedded. */
80
+ expandable: string[];
81
+ };
82
+
83
+ export type SpecIR = {
84
+ format: 'openapi' | 'smithy' | 'proto' | 'client';
85
+ version: string;
86
+ /** The path the vendor's server URL puts before every operation path (`/v1` for OpenAI), when any. */
87
+ basePath?: string;
88
+ /** The path parameters whose values span segments: Smithy's greedy labels (`{Key+}`), which the path keeps as the label. */
89
+ spanning?: string[];
90
+ resources: IrResource[];
91
+ operations: IrOperation[];
92
+ };
93
+
94
+ // every operation an OpenAPI path item may hold (a HEAD is as published as a GET: Vercel's /v8/artifacts/{hash})
95
+ const METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace'] as const;
96
+
97
+ function deref(doc: Json, node: Json, depth = 0): Json {
98
+ if (!node || typeof node !== 'object' || depth > 8) return node;
99
+ if (typeof node.$ref === 'string' && node.$ref.startsWith('#/')) {
100
+ const target = node.$ref.slice(2).split('/').reduce((acc: Json, k: string) => (acc ? acc[k] : undefined), doc);
101
+ return deref(doc, target, depth + 1);
102
+ }
103
+ return node;
104
+ }
105
+
106
+ /** The fields a schema requires, its `allOf` members' included; a union requires what every alternative does. */
107
+ function requiredOf(doc: Json, schema: Json, depth = 0): Set<string> {
108
+ const s = deref(doc, schema);
109
+ if (!s || typeof s !== 'object' || depth > 8) return new Set();
110
+ const out = new Set<string>(Array.isArray(s.required) ? s.required.map(String) : []);
111
+ for (const part of Array.isArray(s.allOf) ? s.allOf : []) for (const f of requiredOf(doc, part, depth + 1)) out.add(f);
112
+ const alts = [...(Array.isArray(s.oneOf) ? s.oneOf : []), ...(Array.isArray(s.anyOf) ? s.anyOf : [])].map((a: Json) => requiredOf(doc, a, depth + 1));
113
+ if (alts.length) for (const f of alts[0]!) if (alts.every((a) => a.has(f))) out.add(f);
114
+ return out;
115
+ }
116
+
117
+ /** A schema's properties, with `allOf` members merged (how composed schemas are written). */
118
+ function propsOf(doc: Json, schema: Json, depth = 0): Record<string, Json> {
119
+ const s = deref(doc, schema);
120
+ if (!s || typeof s !== 'object' || depth > 8) return {};
121
+ const merged: Record<string, Json> = {};
122
+ for (const part of Array.isArray(s.allOf) ? s.allOf : []) Object.assign(merged, propsOf(doc, part, depth + 1));
123
+ return { ...merged, ...(s.properties ?? {}) };
124
+ }
125
+
126
+ const DISCRIMINATOR = 'x-twin-discriminator';
127
+
128
+ /** The fields a resource can answer: its properties and, for a union (Slack's conversation is a oneOf
129
+ * of its kinds), every alternative's, since any of them may be what the vendor sends. */
130
+ function fieldsOf(doc: Json, schema: Json, depth = 0): Record<string, Json> {
131
+ const s = deref(doc, schema);
132
+ if (!s || typeof s !== 'object' || depth > 8) return {};
133
+ const merged: Record<string, Json> = {};
134
+ for (const alt of [...(Array.isArray(s.oneOf) ? s.oneOf : []), ...(Array.isArray(s.anyOf) ? s.anyOf : [])]) {
135
+ for (const [k, v] of Object.entries(fieldsOf(doc, alt, depth + 1))) {
136
+ if (!(k in merged)) { merged[k] = v; continue; }
137
+ // a field several alternatives list values for (an input message's role, an output message's) takes them all
138
+ const had = deref(doc, merged[k]);
139
+ const more = deref(doc, v);
140
+ // (one value per alternative is the union's discriminator, Stripe's `object` or OpenAI's item `type`: which
141
+ // alternative this is, never a state)
142
+ if (Array.isArray(had?.enum) && Array.isArray(more?.enum)) {
143
+ const discriminates = (had.enum.length === 1 || had[DISCRIMINATOR] === true) && more.enum.length === 1;
144
+ merged[k] = { ...had, enum: [...new Set([...had.enum, ...more.enum])], ...(discriminates ? { [DISCRIMINATOR]: true } : {}) };
145
+ }
146
+ }
147
+ }
148
+ for (const part of Array.isArray(s.allOf) ? s.allOf : []) Object.assign(merged, fieldsOf(doc, part, depth + 1));
149
+ return { ...merged, ...(s.properties ?? {}) };
150
+ }
151
+
152
+ // An object a spec writes inline where it answers a resource (hoistInline): its name, as a $ref would give one.
153
+ const inlineNames = new WeakMap<object, string>();
154
+ const refName = (node: Json): string | undefined =>
155
+ typeof node?.$ref === 'string' ? node.$ref.split('/').pop() : node && typeof node === 'object' ? inlineNames.get(node) : undefined;
156
+
157
+ /** A spec that writes every schema in place (Tremendous: no $ref anywhere under paths, its components unused) names no
158
+ * resource. Each object an answer holds under one key (`{ order: {...} }`, `{ orders: [...] }`) with an `id` of its own is
159
+ * the resource that key names, singular (`order`): every inline copy of it is read under that name, and the item GET's
160
+ * copy (else a list's, else the first seen) is its schema. Each operation keeps its own copy for what it answers. A spec
161
+ * that references components anywhere under its paths is read as it always was. */
162
+ function hoistInline(doc: Json): Json {
163
+ if (!doc?.paths || JSON.stringify(doc.paths).includes('"$ref"')) return doc;
164
+ const out = structuredClone(doc);
165
+ const singular = (key: string): string => (/ies$/.test(key) ? `${key.slice(0, -3)}y` : /(ses|xes)$/.test(key) ? key.slice(0, -2) : /s$/.test(key) ? key.slice(0, -1) : key);
166
+ const idBearing = (n: Json): boolean => !!n && typeof n === 'object' && !!n.properties?.id;
167
+ const chosen = new Map<string, { node: Json; rank: number }>();
168
+ for (const [path, item] of Object.entries(out.paths as Record<string, Json>)) {
169
+ for (const [method, op] of Object.entries(item ?? {})) {
170
+ if (!op || typeof op !== 'object' || !(op as Json).responses) continue;
171
+ const code = Object.keys((op as Json).responses).filter((c) => /^2\d\d$/.test(c)).sort()[0];
172
+ const schema = code ? (op as Json).responses[code]?.content?.['application/json']?.schema : undefined;
173
+ const props = schema?.properties;
174
+ if (!props || schema.properties.id) continue;
175
+ const held = Object.entries(props as Record<string, Json>).flatMap(([key, p]) =>
176
+ idBearing(p) ? [{ key, node: p, list: false }] : p?.type === 'array' && idBearing(p.items) ? [{ key: singular(key), node: p.items, list: true }] : []);
177
+ if (held.length !== 1) continue;
178
+ const { key, node, list } = held[0]!;
179
+ inlineNames.set(node, key);
180
+ const rank = method === 'get' && !list && /\}$/.test(path) ? 0 : method === 'get' ? 1 : 2;
181
+ const had = chosen.get(key);
182
+ if (!had || rank < had.rank) chosen.set(key, { node, rank });
183
+ }
184
+ }
185
+ if (!chosen.size) return doc;
186
+ out.components = { ...(out.components ?? {}), schemas: { ...(out.components?.schemas ?? {}) } };
187
+ for (const [key, { node }] of chosen) out.components.schemas[key] = node;
188
+ return out;
189
+ }
190
+
191
+ function typeOf(doc: Json, schema: Json): string {
192
+ const s = deref(doc, schema);
193
+ if (!s || typeof s !== 'object') return 'any';
194
+ if (refName(schema)) return refName(schema)!;
195
+ if (s.anyOf || s.oneOf) return 'union';
196
+ return Array.isArray(s.type) ? s.type.join('|') : (s.type ?? (s.properties ? 'object' : 'any'));
197
+ }
198
+
199
+ export function slug(method: string, path: string): string {
200
+ return `${method}_${path}`.replace(/[{}]/g, '').replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_+|_+$/g, '').toLowerCase();
201
+ }
202
+
203
+ /** The resources a schema can be: one for a reference to a resource, several for a union of them
204
+ * (`anyOf: [customer, deleted_customer]`, `[card, bank_account, source]`), in the spec's order. */
205
+ function resourcesOfSchema(doc: Json, schema: Json, resources: Map<string, string>): string[] {
206
+ const name = refName(schema);
207
+ if (name && resources.has(name)) return [resources.get(name)!];
208
+ const s = deref(doc, schema);
209
+ for (const k of ['anyOf', 'oneOf', 'allOf'] as const) {
210
+ if (Array.isArray(s?.[k])) return [...new Set(s[k].flatMap((alt: Json) => resourcesOfSchema(doc, alt, resources)))] as string[];
211
+ }
212
+ return [];
213
+ }
214
+
215
+ /** Whether a property of an envelope is its metadata, not what it holds: a list of messages (Cloudflare's `errors` and
216
+ * `messages`: `code`, `message`) or a paging block (`result_info`: `count`, `page`, `per_page`, `total_count`; cursors). */
217
+ const MESSAGE_FIELDS = new Set(['code', 'message', 'documentation_url', 'source', 'pointer']);
218
+ const PAGING_FIELDS = new Set(['count', 'page', 'per_page', 'total_count', 'total_pages', 'cursor', 'cursors', 'next', 'previous', 'next_cursor', 'before', 'after', 'has_more']);
219
+ function envelopeMeta(doc: Json, node: Json): boolean {
220
+ const d = deref(doc, node);
221
+ const target = d?.type === 'array' ? d.items : node;
222
+ const keys = Object.keys(propsOf(doc, target));
223
+ return keys.length > 0 && (keys.every((k) => MESSAGE_FIELDS.has(k)) || keys.every((k) => PAGING_FIELDS.has(k)));
224
+ }
225
+
226
+ function successOf(doc: Json, op: Json, resources: Map<string, string>): { status: number; answers?: IrOperation['answers']; empty?: true } {
227
+ const codes = Object.keys(op.responses ?? {}).filter((c) => /^2\d\d$/.test(c)).sort();
228
+ if (!codes.length) return { status: 200 };
229
+ const status = Number(codes[0]);
230
+ const r = deref(doc, op.responses[codes[0]]);
231
+ // a success documented with no content at all answers no body, whatever its status (a 200 with nothing in it)
232
+ if (status !== 204 && (!r?.content || Object.keys(r.content).length === 0)) return { status, empty: true };
233
+ const schema = r?.content?.['application/json']?.schema;
234
+ if (!schema) return { status };
235
+ const answer = (found: string[], list: boolean, key?: string): IrOperation['answers'] => ({ resource: found[0]!, list, ...(found.length > 1 ? { alternatives: found.slice(1) } : {}), ...(key ? { key } : {}) });
236
+ const direct = resourcesOfSchema(doc, schema, resources);
237
+ if (direct.length) {
238
+ // a named schema composed over the resource answers the resource's fields and its own
239
+ const composed = !resources.has(refName(schema) ?? '') && Array.isArray(deref(doc, schema)?.allOf);
240
+ const extra = composed ? Object.keys(deref(doc, schema).allOf.filter((part: Json) => !resourcesOfSchema(doc, part, resources).length).reduce((all: Record<string, Json>, part: Json) => Object.assign(all, propsOf(doc, part)), {} as Record<string, Json>)) : [];
241
+ return { status, answers: { ...answer(direct, false)!, ...(extra.length ? { extends: extra.sort() } : {}) } };
242
+ }
243
+ const s = deref(doc, schema);
244
+ // A list: a bare array of a resource, or an envelope whose `data` is one (Stripe's list object).
245
+ const items = s?.type === 'array' ? s.items : s?.properties?.data?.type === 'array' ? s.properties.data.items : undefined;
246
+ const listed = items ? resourcesOfSchema(doc, items, resources) : [];
247
+ if (listed.length) return { status, answers: answer(listed, true) };
248
+ // An envelope that holds exactly one resource, or one list of them, under a named property, beside
249
+ // at most a few scalars (`ok`, a cursor). An object with an `id` of its own, or with other objects
250
+ // in it, is not an envelope: it is something that embeds another (a Pages site, a branch).
251
+ const own = propsOf(doc, schema);
252
+ if (own.id) return { status };
253
+ // objects and arrays beside the held one: a paging block is fine, a record's own sub-objects are not
254
+ const objectish = Object.values(own).filter((p) => { const d = deref(doc, p); return d && typeof d === 'object' && (d.type === 'object' || d.type === 'array' || !!d.properties || !!d.allOf) && !envelopeMeta(doc, p); });
255
+ if (objectish.length > 3 || Object.keys(own).length > 6) return { status };
256
+ const held = Object.entries(propsOf(doc, schema)).filter(([, p]) => !envelopeMeta(doc, p)).flatMap(([key, p]) => {
257
+ const one = resourcesOfSchema(doc, p, resources);
258
+ if (one.length) return [{ key, found: one, list: false, node: p }];
259
+ const d = deref(doc, p);
260
+ const many = d?.type === 'array' ? resourcesOfSchema(doc, d.items, resources) : [];
261
+ return many.length ? [{ key, found: many, list: true, node: d.items }] : [];
262
+ });
263
+ if (held.length !== 1) return { status };
264
+ const h = held[0]!;
265
+ // an operation's own inline copy of a resource (hoistInline) that answers fields the resource's schema lacks
266
+ // (Tremendous's generate-reward-link: { id, link }) answers them beside the resource's
267
+ const name = h.node && typeof h.node === 'object' ? inlineNames.get(h.node) : undefined;
268
+ const named = name ? doc?.components?.schemas?.[name] : undefined;
269
+ const extra = named && named !== h.node ? Object.keys(propsOf(doc, h.node)).filter((f) => !(f in propsOf(doc, named))).sort() : [];
270
+ return { status, answers: { ...answer(h.found, h.list, h.key)!, ...(extra.length ? { extends: extra } : {}) } };
271
+ }
272
+
273
+ function paramsOf(doc: Json, op: Json, pathItem: Json): { pathParams: string[]; query: IrParam[] } {
274
+ const all = [...(pathItem.parameters ?? []), ...(op.parameters ?? [])].map((p: Json) => deref(doc, p));
275
+ const pathParams = all.filter((p: Json) => p.in === 'path').map((p: Json) => String(p.name));
276
+ const query = all
277
+ .filter((p: Json) => p.in === 'query')
278
+ .map((p: Json) => ({ name: String(p.name), type: typeOf(doc, p.schema), required: p.required === true, ...boundsOf(doc, p.schema) }))
279
+ .sort((a: IrParam, b: IrParam) => a.name.localeCompare(b.name));
280
+ return { pathParams, query };
281
+ }
282
+
283
+ function bodyOf(doc: Json, op: Json): { body: IrParam[]; bodyEncoding: IrOperation['bodyEncoding'] } {
284
+ const content = deref(doc, op.requestBody)?.content ?? {};
285
+ const mediaOf = { json: 'application/json', form: 'application/x-www-form-urlencoded', multipart: 'multipart/form-data' } as const;
286
+ const encoding = content[mediaOf.json] ? 'json' : content[mediaOf.form] ? 'form' : content[mediaOf.multipart] ? 'multipart' : 'none';
287
+ if (encoding === 'none') return { body: [], bodyEncoding: 'none' };
288
+ const schema = deref(doc, content[mediaOf[encoding]].schema);
289
+ const required = new Set<string>(schema?.required ?? []);
290
+ const body = Object.entries(schema?.properties ?? {})
291
+ .map(([name, p]) => ({ name, type: typeOf(doc, p), required: required.has(name), ...boundsOf(doc, p) }))
292
+ .sort((a, b) => a.name.localeCompare(b.name));
293
+ return { body, bodyEncoding: encoding };
294
+ }
295
+
296
+ /** A form-encoded operation's fields whose value is a number or a boolean, by bracket path: a form carries only text, so
297
+ * the spec's type is what says `unit_amount=2400` is a number and `metadata[order]=007` or `name=2024` is text. A path
298
+ * segment is a property name, `[]` for an array's item, or `*` for a map's key (`additionalProperties`). A field some
299
+ * alternative of whose union takes free text is text; `""`, the value Stripe's unsettable fields take, stays text
300
+ * wherever it is sent. */
301
+ function formScalarsOf(doc: Json, op: Json, pathItem: Json, encoding: IrOperation['bodyEncoding']): Record<string, 'integer' | 'number' | 'boolean'> | undefined {
302
+ if (encoding !== 'form') return undefined;
303
+ const out: Record<string, 'integer' | 'number' | 'boolean'> = {};
304
+ const visit = (schema: Json, at: string[], depth: number): void => {
305
+ const s = deref(doc, schema);
306
+ if (!s || typeof s !== 'object' || depth > 12) return;
307
+ const alts = [...(Array.isArray(s.anyOf) ? s.anyOf : []), ...(Array.isArray(s.oneOf) ? s.oneOf : [])].map((a: Json) => deref(doc, a));
308
+ if (alts.length) {
309
+ const scalar = alts.map((a: Json) => a?.type).find((t: unknown) => t === 'integer' || t === 'number' || t === 'boolean') as 'integer' | 'number' | 'boolean' | undefined;
310
+ const freeText = alts.some((a: Json) => a?.type === 'string' && !Array.isArray(a.enum));
311
+ if (scalar && !freeText && at.length) out[at.join('.')] = scalar;
312
+ for (const a of alts) if (a?.type !== 'integer' && a?.type !== 'number' && a?.type !== 'boolean') visit(a, at, depth + 1);
313
+ return;
314
+ }
315
+ if ((s.type === 'integer' || s.type === 'number' || s.type === 'boolean') && at.length) { out[at.join('.')] = s.type; return; }
316
+ if (s.type === 'array') { visit(s.items, [...at, '[]'], depth + 1); return; }
317
+ for (const [name, p] of Object.entries(propsOf(doc, s))) visit(p, [...at, name], depth + 1);
318
+ if (s.additionalProperties && typeof s.additionalProperties === 'object') visit(s.additionalProperties, [...at, '*'], depth + 1);
319
+ };
320
+ for (const p of [...(pathItem.parameters ?? []), ...(op.parameters ?? [])].map((q: Json) => deref(doc, q))) if (p?.in === 'query') visit(p.schema, [String(p.name)], 0);
321
+ const body = deref(doc, deref(doc, op.requestBody)?.content?.['application/x-www-form-urlencoded']?.schema);
322
+ for (const [name, p] of Object.entries(propsOf(doc, body))) visit(p, [name], 0);
323
+ return Object.keys(out).length ? Object.fromEntries(Object.entries(out).sort(([a], [b]) => a.localeCompare(b))) : undefined;
324
+ }
325
+
326
+ /** The class of one operation, from its method, its path and what it answers. An operation this cannot place is `action`, never CRUD. */
327
+ /** A path and its discriminator as one key: `/responses/{id}` and `/responses/{id}?beta=true` are different reads. */
328
+ // a null value (the key present with any value) keys as the bare name
329
+ const keyOf = (path: string, discriminator?: Record<string, string | null>): string => (discriminator ? `${path}?${new URLSearchParams(Object.entries(discriminator).map(([k, v]) => [k, v ?? '']))}` : path);
330
+
331
+ /** An RPC method's class from its verb (`conversations.list`, `chat.postMessage`): the conventions
332
+ * RPC-style vendors share. An unfamiliar verb is an action. */
333
+ function rpcClass(verb: string, answersList: boolean): OperationClass {
334
+ const v = verb.toLowerCase();
335
+ if (answersList || /^(list|history|replies|members)$/.test(v)) return 'list';
336
+ if (/^(info|get|lookup|read)/.test(v)) return 'retrieve';
337
+ if (/^(create|add|open|post|schedule|upload)/.test(v)) return 'create';
338
+ if (/^(update|edit|rename|set)/.test(v)) return 'update';
339
+ if (/^(delete|remove)/.test(v)) return 'delete';
340
+ return 'action';
341
+ }
342
+
343
+ function classify(o: Omit<IrOperation, 'class' | 'resource'>, itemGets: Map<string, string>, listGets: Map<string, string>, singletons: Set<string>, settingOf: Map<string, string> = new Map()): { class: OperationClass; resource?: string } {
344
+ const segs = o.path.split('/').filter(Boolean);
345
+ const last = segs[segs.length - 1] ?? '';
346
+ if (segs.length === 1 && last.includes('.')) {
347
+ const verb = last.slice(last.lastIndexOf('.') + 1);
348
+ let cls = rpcClass(verb, o.answers?.list === true);
349
+ // a method the vendor documents as a GET is a read, whatever its verb (`auth.test`, `search.messages`)
350
+ if (o.method === 'get' && !['retrieve', 'list'].includes(cls)) cls = o.answers?.list ? 'list' : o.answers ? 'retrieve' : 'computed';
351
+ return { class: cls, ...(o.answers ? { resource: o.answers.resource } : {}) };
352
+ }
353
+ const lastIsParam = last.startsWith('{');
354
+ const here = itemGets.get(keyOf(o.path, o.discriminator));
355
+ const parentPath = '/' + segs.slice(0, -1).join('/');
356
+ const parent = itemGets.get(keyOf(parentPath, o.discriminator));
357
+ const answered = o.answers?.resource;
358
+ const setting = settingOf.get(keyOf(o.path, o.discriminator));
359
+ if (o.method === 'get') {
360
+ if (setting) return { class: 'retrieve', resource: setting };
361
+ if (last === 'search') return { class: 'computed', resource: answered };
362
+ if (o.answers?.list) return { class: 'list', resource: answered };
363
+ if (answered && lastIsParam) return { class: 'retrieve', resource: answered };
364
+ if (answered && here === answered) return { class: 'retrieve', resource: answered };
365
+ return { class: 'computed', resource: answered ?? parent };
366
+ }
367
+ if (o.method === 'delete') return lastIsParam || here ? { class: 'delete', resource: here ?? answered } : { class: 'action', resource: parent ?? answered };
368
+ if (lastIsParam && answered && here === answered) return { class: 'update', resource: answered };
369
+ if (o.method === 'post' && !lastIsParam && answered && [...itemGets].some(([k, r]) => r === answered && k.startsWith(`${o.path}/{`) && k.split('?')[0]!.split('/').length === segs.length + 2)) {
370
+ return { class: 'create', resource: answered };
371
+ }
372
+ // a POST to a collection answering one of its items creates it, even when the item is read back
373
+ // elsewhere (GitHub reads an issue comment at /issues/comments/{id}, not under its issue)
374
+ if (o.method === 'post' && answered && listGets.get(keyOf(o.path, o.discriminator)) === answered) return { class: 'create', resource: answered };
375
+ // a setting read and written at one path (an id-less object whose identity is its path)
376
+ if (singletons.has(keyOf(o.path, o.discriminator)) && (o.method === 'put' || o.method === 'patch')) return { class: 'update', ...(setting ?? answered ? { resource: (setting ?? answered)! } : {}) };
377
+ if (singletons.has(keyOf(o.path, o.discriminator)) && o.method === 'delete') return { class: 'delete', ...(setting ?? answered ? { resource: (setting ?? answered)! } : {}) };
378
+ if (!answered && !parent) return { class: 'non-resource' };
379
+ return { class: 'action', resource: parent ?? answered };
380
+ }
381
+
382
+ export function fromOpenAPI(spec: Json): SpecIR {
383
+ const doc = hoistInline(spec);
384
+ const schemas: Record<string, Json> = doc?.components?.schemas ?? {};
385
+ // A resource is a schema the vendor declares one (`x-resourceId`), or, lacking any declaration,
386
+ // a schema with an `id` that some GET answers or lists: something a caller can read back.
387
+ const declared = Object.entries(schemas).filter(([, s]) => typeof s?.['x-resourceId'] === 'string');
388
+ const resourceBySchema = new Map<string, string>(declared.map(([n, s]) => [n, String(s['x-resourceId'])]));
389
+ if (!declared.length) {
390
+ // what a GET reads back: an id-bearing schema it answers directly, the items of a list it
391
+ // answers, or the one object an envelope it answers holds (`{ ok, channel }`); identity need
392
+ // not be an `id` (a Slack message is its channel and `ts`)
393
+ // a map (an object whose keys are the caller's data: `additionalProperties` and no properties of its own, as
394
+ // currencyapi's `RatesMap` keyed by currency code) is not a thing a caller reads back: its keys are values, not fields
395
+ const pureMap = (d: Json): boolean => !!d.additionalProperties && typeof d.additionalProperties === 'object' && !d.properties && !d.allOf && !d.anyOf && !d.oneOf;
396
+ const objectLike = (node: Json): boolean => {
397
+ const d = deref(doc, node);
398
+ return !!d && typeof d === 'object' && !pureMap(d) && (d.type === 'object' || !!d.properties || !!d.allOf || !!d.anyOf || !!d.oneOf);
399
+ };
400
+ const named = (node: Json): string[] => {
401
+ const name = refName(node);
402
+ if (name && schemas[name] && objectLike(node)) return [name];
403
+ const d = deref(doc, node);
404
+ for (const k of ['anyOf', 'oneOf'] as const) if (Array.isArray(d?.[k])) return d[k].flatMap(named);
405
+ // an inline object that is one named schema (`{ type: object, allOf: [$ref] }`: Cloudflare's custom hostname)
406
+ if (Array.isArray(d?.allOf) && d.allOf.length === 1) return named(d.allOf[0]);
407
+ return [];
408
+ };
409
+ const readBack = (schema: Json): string[] => {
410
+ const name = refName(schema);
411
+ if (name && schemas[name] && propsOf(doc, schema).id) return [name];
412
+ const d = deref(doc, schema);
413
+ if (d?.type === 'array') return named(d.items);
414
+ const props = propsOf(doc, schema);
415
+ if (props.data && deref(doc, props.data)?.type === 'array') return named(deref(doc, props.data).items);
416
+ const held = Object.values(props).filter((p) => !envelopeMeta(doc, p)).map((p) => (deref(doc, p)?.type === 'array' ? named(deref(doc, p).items) : named(p))).filter((n) => n.length);
417
+ return held.length === 1 ? held[0]! : [];
418
+ };
419
+ for (const item of Object.values(doc.paths ?? {})) {
420
+ const get = (item as Json)?.get;
421
+ const code = Object.keys(get?.responses ?? {}).filter((c) => /^2\d\d$/.test(c)).sort()[0];
422
+ const schema = code ? deref(doc, get.responses[code])?.content?.['application/json']?.schema : undefined;
423
+ for (const r of schema ? readBack(schema) : []) resourceBySchema.set(r, r);
424
+ }
425
+ }
426
+
427
+ const server = String(doc?.servers?.[0]?.url ?? '');
428
+ const docBase = /^https?:\/\//.test(server) ? new URL(server).pathname.replace(/\/+$/, '') : '';
429
+ const raw: Array<Omit<IrOperation, 'class' | 'resource'>> = [];
430
+ for (const [key, item] of Object.entries(doc.paths ?? {}).sort(([a], [b]) => a.localeCompare(b))) {
431
+ const [path, query] = key.split('?') as [string, string | undefined];
432
+ const discriminator = query ? Object.fromEntries(new URLSearchParams(query)) : undefined;
433
+ for (const method of METHODS) {
434
+ const op = (item as Json)[method];
435
+ if (!op) continue;
436
+ const success = successOf(doc, op, resourceBySchema);
437
+ const streams = Object.entries(op.responses ?? {}).some(([code, r]) => /^2\d\d$/.test(code) && deref(doc, r)?.content?.['text/event-stream']);
438
+ const security = (op.security ?? doc.security ?? []) as Json[];
439
+ const scopes = [...new Set(security.flatMap((req: Json) => Object.values(req ?? {}).flat() as string[]))].sort();
440
+ const credentials = [...new Set(security.flatMap((req: Json) => { const names = Object.keys(req ?? {}); return names.length ? names : ['anonymous']; }))].sort();
441
+ const body = bodyOf(doc, op);
442
+ const scalars = formScalarsOf(doc, op, item, body.bodyEncoding);
443
+ const ownServer = String((op.servers ?? (item as Json).servers)?.[0]?.url ?? '');
444
+ const ownBase = /^https?:\/\//.test(ownServer) ? new URL(ownServer).pathname.replace(/\/+$/, '') : undefined;
445
+ raw.push({
446
+ id: typeof op.operationId === 'string' && op.operationId ? op.operationId : slug(method, key),
447
+ method,
448
+ path,
449
+ ...(discriminator ? { discriminator } : {}),
450
+ ...paramsOf(doc, op, item),
451
+ ...(ownBase !== undefined && ownBase !== docBase ? { basePath: ownBase } : {}),
452
+ ...body,
453
+ ...(scalars ? { scalars } : {}),
454
+ successStatus: success.status,
455
+ ...(success.empty ? { emptySuccess: true as const } : {}),
456
+ ...(streams ? { streams: true } : {}),
457
+ ...(scopes.length ? { scopes } : {}),
458
+ ...(credentials.length && !(credentials.length === 1 && credentials[0] === 'anonymous') ? { credentials } : {}),
459
+ ...(success.answers ? { answers: success.answers } : {}),
460
+ });
461
+ }
462
+ }
463
+ // Paths whose GET retrieves one resource: what a write at that path, or under it, acts on.
464
+ const itemGets = new Map<string, string>();
465
+ for (const o of raw) if (o.method === 'get' && o.answers && !o.answers.list) itemGets.set(keyOf(o.path, o.discriminator), o.answers.resource);
466
+ const listGets = new Map<string, string>();
467
+ for (const o of raw) if (o.method === 'get' && o.answers?.list) listGets.set(keyOf(o.path, o.discriminator), o.answers.resource);
468
+ const singletons = new Set<string>();
469
+ for (const o of raw) if (o.method === 'get' && !o.answers && !o.path.split('/').at(-1)!.startsWith('{') && o.successStatus === 200) singletons.add(keyOf(o.path, o.discriminator));
470
+ // a setting's resource: the named schema its GET answers (`AuthConfigResponse`), which a pack may declare to have the
471
+ // core read it (its defaults until first written) and write it (a merge that creates it), keyed by the path's parent
472
+ const settingOf = new Map<string, string>();
473
+ for (const [key, item] of Object.entries(doc.paths ?? {})) {
474
+ const get = (item as Json).get;
475
+ const [path, query] = key.split('?') as [string, string | undefined];
476
+ const k = keyOf(path, query ? Object.fromEntries(new URLSearchParams(query)) : undefined);
477
+ if (!get || !singletons.has(k)) continue;
478
+ const schema = deref(doc, get.responses?.['200'])?.content?.['application/json']?.schema;
479
+ const name = schema ? refName(schema) : undefined;
480
+ if (name && deref(doc, schema)?.type !== 'array') settingOf.set(k, name);
481
+ }
482
+ let operations: IrOperation[] = raw.map((o) => {
483
+ const c = classify(o, itemGets, listGets, singletons, settingOf);
484
+ return { ...o, class: c.class, ...(c.resource ? { resource: c.resource } : {}) };
485
+ });
486
+ // An RPC method that answers no resource acts on its family's: the one the family's retrieve reads
487
+ // (`conversations.archive` acts on what `conversations.info` answers).
488
+ const familyOf = (path: string): string | undefined => (/^\/[^/]+\.[^/]+$/.test(path) ? path.slice(1, path.lastIndexOf('.')) : undefined);
489
+ const familyResource = new Map<string, string>();
490
+ for (const o of operations) if (o.class === 'retrieve' && o.resource && familyOf(o.path)) familyResource.set(familyOf(o.path)!, o.resource);
491
+ operations = operations.map((o) => (o.resource || !familyOf(o.path) || !familyResource.has(familyOf(o.path)!) ? o : { ...o, resource: familyResource.get(familyOf(o.path)!)! }));
492
+
493
+ const resources: IrResource[] = [...resourceBySchema]
494
+ .map(([schemaName, name]) => {
495
+ const s = deref(doc, schemas[schemaName]);
496
+ const required = requiredOf(doc, schemas[schemaName]);
497
+ const fields = Object.entries(fieldsOf(doc, schemas[schemaName]))
498
+ .map(([f, p]: [string, Json]) => {
499
+ const d = deref(doc, p);
500
+ return { name: f, type: typeOf(doc, p), nullable: d?.nullable === true, ...(required.has(f) ? { required: true } : {}), ...(Array.isArray(d?.enum) ? { enum: d.enum.map(String) } : {}), ...(d?.[DISCRIMINATOR] ? { discriminator: true } : {}) };
501
+ })
502
+ .sort((a, b) => a.name.localeCompare(b.name));
503
+ return {
504
+ name,
505
+ schema: schemaName,
506
+ fields,
507
+ // an enum with several values, and the fields a lifecycle usually lives in though the spec types
508
+ // them plainly (a string `status`, a boolean `merged`): the manifest rules on every one
509
+ stateCandidates: fields
510
+ .filter((f) => !f.discriminator)
511
+ .filter((f) => (f.enum && f.enum.length > 1) || (f.type === 'string' && /^(status|state|conclusion)$/.test(f.name)) || (f.type === 'boolean' && /^(merged|draft|locked|archived|closed|captured|paid|refunded|disabled|active|livemode_off|is_[a-z_]+)$/.test(f.name)))
512
+ .map((f) => ({ field: f.name, values: f.enum ? [...f.enum].sort() : f.type === 'boolean' ? ['false', 'true'] : [] })),
513
+ expandable: Array.isArray(s?.['x-expandableFields']) ? [...s['x-expandableFields']].map(String).sort() : [],
514
+ };
515
+ })
516
+ .sort((a, b) => a.name.localeCompare(b.name) || a.schema.localeCompare(b.schema));
517
+
518
+ const basePath = docBase;
519
+ return { format: 'openapi', version: String(doc?.info?.version ?? ''), ...(basePath ? { basePath } : {}), resources, operations };
520
+ }
521
+
522
+ /** A Swagger 2.0 document as the OpenAPI 3 shape `fromOpenAPI` reads: definitions become component
523
+ * schemas, formData and body parameters a request body, response schemas JSON content, and
524
+ * host + basePath the server. Nothing vendor-specific. */
525
+ export function fromSwagger2(doc: Json): SpecIR {
526
+ const rewrite = (node: Json): Json => {
527
+ if (Array.isArray(node)) return node.map(rewrite);
528
+ if (!node || typeof node !== 'object') return node;
529
+ const out: Json = {};
530
+ for (const [k, v] of Object.entries(node)) out[k] = k === '$ref' && typeof v === 'string' ? v.replace('#/definitions/', '#/components/schemas/') : rewrite(v);
531
+ return out;
532
+ };
533
+ const paths: Json = {};
534
+ for (const [key, item] of Object.entries(doc.paths ?? {})) {
535
+ const next: Json = {};
536
+ for (const method of METHODS) {
537
+ const op = (item as Json)[method];
538
+ if (!op) continue;
539
+ const params = [...((item as Json).parameters ?? []), ...(op.parameters ?? [])];
540
+ const form = params.filter((p: Json) => p.in === 'formData');
541
+ const body = params.find((p: Json) => p.in === 'body');
542
+ const consumes: string[] = op.consumes ?? doc.consumes ?? [];
543
+ const formType = consumes.includes('multipart/form-data') ? 'multipart/form-data' : 'application/x-www-form-urlencoded';
544
+ const requestBody = body
545
+ ? { content: { 'application/json': { schema: body.schema } } }
546
+ : form.length
547
+ ? { content: { [formType]: { schema: { type: 'object', required: form.filter((p: Json) => p.required).map((p: Json) => p.name), properties: Object.fromEntries(form.map((p: Json) => [p.name, { type: p.type ?? 'string' }])) } } } }
548
+ : undefined;
549
+ next[method] = {
550
+ ...op,
551
+ parameters: params.filter((p: Json) => p.in === 'path' || p.in === 'query').map((p: Json) => ({ name: p.name, in: p.in, required: p.required, schema: { type: p.type ?? 'string' } })),
552
+ ...(requestBody ? { requestBody } : {}),
553
+ responses: Object.fromEntries(Object.entries(op.responses ?? {}).map(([code, r]: [string, Json]) => [code, { description: r?.description ?? '', ...(r?.schema ? { content: { 'application/json': { schema: r.schema } } } : {}) }])),
554
+ };
555
+ }
556
+ paths[key] = next;
557
+ }
558
+ const scheme = (doc.schemes ?? ['https'])[0];
559
+ return fromOpenAPI(
560
+ rewrite({
561
+ openapi: '3.0.0',
562
+ info: doc.info,
563
+ ...(doc.host ? { servers: [{ url: `${scheme}://${doc.host}${doc.basePath ?? ''}` }] } : {}),
564
+ ...(doc.security ? { security: doc.security } : {}),
565
+ components: { schemas: doc.definitions ?? {} },
566
+ paths,
567
+ }),
568
+ );
569
+ }
570
+
571
+ // ── Smithy (AWS) ─────────────────────────────────────────────────────────────────────────────
572
+ // An AWS service's Smithy JSON model: its operations, each named on the wire by `X-Amz-Target: <prefix>.<Operation>`
573
+ // (awsJson1_0 / awsJson1_1, every operation a POST to `/`) or by its `smithy.api#http` trait (restJson1, restXml). A model
574
+ // with no resource shapes (Secrets Manager) answers each operation with its output structure, which is the resource
575
+ // that operation answers: its members are the fields, `smithy.api#required` marks the required ones, and an enum
576
+ // shape's values are the field's values. Timestamps are numbers on AWS JSON (epoch seconds).
577
+
578
+ const short = (id: string): string => id.split('#').pop()!;
579
+
580
+ /** An AWS service's Smithy JSON model as the IR (`service` names the service shape when the model holds several). */
581
+ export function fromSmithy(model: Json, service?: string): SpecIR {
582
+ const shapes: Record<string, Json> = model.shapes ?? {};
583
+ const serviceId = service ?? Object.keys(shapes).find((k) => shapes[k].type === 'service')!;
584
+ const svc = shapes[serviceId];
585
+ const traits = svc.traits ?? {};
586
+ const json = Boolean(traits['aws.protocols#awsJson1_0'] || traits['aws.protocols#awsJson1_1']);
587
+ // AWS JSON names an operation by the service shape's own name (secretsmanager.CreateSecret, DynamoDB_20120810.PutItem)
588
+ const targetPrefix = short(serviceId);
589
+ const typeOfShape = (target: string): string => {
590
+ const s = shapes[target];
591
+ if (!s) return short(target).toLowerCase();
592
+ if (s.type === 'structure' || s.type === 'union') return short(target);
593
+ if (s.type === 'timestamp') return json ? 'number' : 'string';
594
+ if (s.type === 'enum' || s.type === 'intEnum') return 'string';
595
+ return s.type;
596
+ };
597
+ const enumOf = (target: string): string[] | undefined => {
598
+ const s = shapes[target];
599
+ if (!s) return undefined;
600
+ if (s.type === 'enum') return Object.entries(s.members ?? {}).map(([k, m]: [string, Json]) => String(m.traits?.['smithy.api#enumValue'] ?? k));
601
+ if (Array.isArray(s.traits?.['smithy.api#enum'])) return s.traits['smithy.api#enum'].map((e: Json) => String(e.value));
602
+ return undefined;
603
+ };
604
+ const resources = new Map<string, IrResource>();
605
+ const xmlWire = Boolean(traits['aws.protocols#restXml']);
606
+ const resourceOf = (target: string): string => {
607
+ const name = short(target);
608
+ if (resources.has(name)) return name;
609
+ const s = shapes[target] ?? {};
610
+ // on restXml an output's members bound to headers or the status are not in its body, and an element is named by its
611
+ // xmlName where it has one (what SHAPE sees of the answer)
612
+ const inBody = ([, m]: [string, Json]): boolean => !xmlWire || !Object.keys(m.traits ?? {}).some((t) => /^smithy\.api#http(Header|PrefixHeaders|ResponseCode)$/.test(t));
613
+ const fields = (Object.entries(s.members ?? {}) as Array<[string, Json]>).filter(inBody).map(([f, m]: [string, Json]) => {
614
+ const values = enumOf(m.target);
615
+ return { name: xmlWire ? String(m.traits?.['smithy.api#xmlName'] ?? f) : f, type: typeOfShape(m.target), nullable: !m.traits?.['smithy.api#required'], ...(m.traits?.['smithy.api#required'] ? { required: true } : {}), ...(values ? { enum: values } : {}) };
616
+ }).sort((a, b) => a.name.localeCompare(b.name));
617
+ const stateCandidates = fields.filter((f) => f.enum && f.enum.length > 1).map((f) => ({ field: f.name, values: [...f.enum!].sort() }));
618
+ resources.set(name, { name, schema: name, fields, stateCandidates, expandable: [] });
619
+ return name;
620
+ };
621
+ const classOf = (name: string): OperationClass => {
622
+ if (/^Create/.test(name)) return 'create';
623
+ if (/^(Get|Describe)/.test(name)) return 'retrieve';
624
+ if (/^List/.test(name)) return 'list';
625
+ if (/^(Update|Put)/.test(name)) return 'update';
626
+ if (/^Delete/.test(name)) return 'delete';
627
+ return 'action';
628
+ };
629
+ // an HTTP-bound protocol (restJson1, restXml) binds each input member to the wire by a trait: a path label, a query
630
+ // key, a header, or the payload (smithy.io/2.0/spec/http-bindings); what no trait binds is the body. The IR carries
631
+ // the labels, the query and the body; a header-bound member is the handler's to read.
632
+ // what an operation's success answer is: its output structure, or on restXml the structure its payload member holds
633
+ // (GetBucketCors answers its CORSConfiguration); a payload of bytes (GetObject's Body) is no resource
634
+ const answerOf = (op: Json): string | undefined => {
635
+ if (!op.output) return undefined;
636
+ const payload = Object.values((shapes[op.output.target]?.members ?? {}) as Record<string, Json>).find((m) => m.traits?.['smithy.api#httpPayload'] !== undefined);
637
+ if (!xmlWire || !payload) return resourceOf(op.output.target);
638
+ return shapes[payload.target]?.type === 'structure' ? resourceOf(payload.target) : undefined;
639
+ };
640
+ const BOUND = /^smithy\.api#http(Label|Query|QueryParams|Header|PrefixHeaders|ResponseCode)$/;
641
+ const operations: IrOperation[] = (svc.operations ?? []).map((o: Json) => o.target).sort().map((target: string) => {
642
+ const op = shapes[target];
643
+ const name = short(target);
644
+ const http = op.traits?.['smithy.api#http'];
645
+ const input = op.input ? shapes[op.input.target] : undefined;
646
+ const members = Object.entries(input?.members ?? {}) as Array<[string, Json]>;
647
+ const param = ([f, m]: [string, Json]): IrParam => ({ name: f, type: typeOfShape(m.target), required: Boolean(m.traits?.['smithy.api#required']) });
648
+ // a uri's query literals (`/{Bucket}?cors`) must be carried by a request to match it, and tell operations at one
649
+ // path apart; a greedy label (`{Key+}`) is the label, the surface's `spanning` naming it
650
+ const [uriPath, uriQuery] = String(http?.uri ?? '/').split('?');
651
+ const discriminator = !json && uriQuery ? Object.fromEntries(uriQuery.split('&').map((kv) => { const [k, v = ''] = kv.split('='); return [k!, v]; })) : undefined;
652
+ const payload = members.find(([, m]) => m.traits?.['smithy.api#httpPayload'] !== undefined);
653
+ return {
654
+ id: name,
655
+ method: json ? 'post' : String(http?.method ?? 'POST').toLowerCase(),
656
+ path: json ? '/' : uriPath!.replace(/\{([^}+]+)\+\}/g, '{$1}'),
657
+ ...(discriminator ? { discriminator } : {}),
658
+ ...(json ? { headers: { 'x-amz-target': `${targetPrefix}.${name}` } } : {}),
659
+ pathParams: json ? [] : [...uriPath!.matchAll(/\{([^}+]+)\+?\}/g)].map((m) => m[1]!),
660
+ query: json ? [] : members.filter(([, m]) => m.traits?.['smithy.api#httpQuery'] !== undefined).map(([f, m]) => ({ ...param([f, m]), name: String(m.traits['smithy.api#httpQuery']) })),
661
+ body: json ? members.map(param) : payload ? [param(payload)] : members.filter(([, m]) => !Object.keys(m.traits ?? {}).some((t) => BOUND.test(t))).map(param),
662
+ bodyEncoding: xmlWire ? ('xml' as const) : ('json' as const),
663
+ successStatus: json ? 200 : Number(http?.code ?? 200),
664
+ ...(answerOf(op) ? { answers: { resource: answerOf(op)!, list: false } } : {}),
665
+ class: classOf(name),
666
+ };
667
+ });
668
+ // operations at one method, path and query literals are told apart, as the service tells them, by what each requires
669
+ // the request to carry: a required query key or header present (S3: CopyObject's `x-amz-copy-source`, UploadPart's
670
+ // `partNumber` and `uploadId`); an operation that requires none is the one a request carrying none of them reaches
671
+ const at = new Map<string, IrOperation[]>();
672
+ for (const o of operations) { const k = `${o.method} ${o.path} ${JSON.stringify(o.discriminator ?? {})}`; at.set(k, [...(at.get(k) ?? []), o]); }
673
+ for (const group of at.values()) {
674
+ if (json || group.length < 2) continue;
675
+ for (const o of group) {
676
+ const input = shapes[shapes[`${serviceId.split('#')[0]}#${o.id}`]?.input?.target ?? ''];
677
+ for (const m of Object.values((input?.members ?? {}) as Record<string, Json>)) {
678
+ if (!m.traits?.['smithy.api#required']) continue;
679
+ const q = m.traits['smithy.api#httpQuery'];
680
+ const h = m.traits['smithy.api#httpHeader'];
681
+ if (q !== undefined) o.discriminator = { ...(o.discriminator ?? {}), [String(q)]: o.discriminator?.[String(q)] ?? null };
682
+ if (h !== undefined) o.headers = { ...(o.headers ?? {}), [String(h).toLowerCase()]: null };
683
+ }
684
+ }
685
+ }
686
+ // "a label suffixed with the + qualifier that can be used to match more than one path segment" (smithy.io/2.0/spec/http-bindings.html, Greedy labels)
687
+ const spanning = [...new Set((svc.operations ?? []).flatMap((o: Json) => [...String(shapes[o.target]?.traits?.['smithy.api#http']?.uri ?? '').matchAll(/\{([^}+]+)\+\}/g)].map((m) => m[1]!)))].sort() as string[];
688
+ return { format: 'smithy', version: String(svc.version ?? ''), ...(spanning.length ? { spanning } : {}), resources: [...resources.values()].sort((a, b) => a.name.localeCompare(b.name)), operations };
689
+ }