@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.
- package/LICENSE +202 -0
- package/README.md +20 -0
- package/dist/src/check-sources.d.ts +33 -0
- package/dist/src/check-sources.js +128 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +105 -0
- package/dist/src/derive.d.ts +2 -0
- package/dist/src/derive.js +349 -0
- package/dist/src/gate.d.ts +9 -0
- package/dist/src/gate.js +109 -0
- package/dist/src/grade.d.ts +30 -0
- package/dist/src/grade.js +36 -0
- package/dist/src/index.d.ts +7 -0
- package/dist/src/index.js +11 -0
- package/dist/src/lanes.d.ts +2 -0
- package/dist/src/lanes.js +33 -0
- package/dist/src/p3-rules.d.ts +8 -0
- package/dist/src/p3-rules.js +43 -0
- package/dist/src/protocol-3.d.ts +5 -0
- package/dist/src/protocol-3.js +721 -0
- package/dist/src/published.d.ts +13 -0
- package/dist/src/published.js +36 -0
- package/dist/src/spec-documents.d.ts +12 -0
- package/dist/src/spec-documents.js +61 -0
- package/dist/src/spec-ir-client.d.ts +20 -0
- package/dist/src/spec-ir-client.js +77 -0
- package/dist/src/spec-ir-commands.d.ts +38 -0
- package/dist/src/spec-ir-commands.js +38 -0
- package/dist/src/spec-ir-discovery.d.ts +11 -0
- package/dist/src/spec-ir-discovery.js +106 -0
- package/dist/src/spec-ir-graphql.d.ts +25 -0
- package/dist/src/spec-ir-graphql.js +46 -0
- package/dist/src/spec-ir-lines.d.ts +19 -0
- package/dist/src/spec-ir-lines.js +76 -0
- package/dist/src/spec-ir-proto.d.ts +80 -0
- package/dist/src/spec-ir-proto.js +339 -0
- package/dist/src/spec-ir.d.ts +104 -0
- package/dist/src/spec-ir.js +691 -0
- package/dist/src/spec-patches.d.ts +8 -0
- package/dist/src/spec-patches.js +24 -0
- package/dist/src/spec.d.ts +5 -0
- package/dist/src/spec.js +7 -0
- package/dist/src/types.d.ts +75 -0
- package/dist/src/types.js +4 -0
- package/dist/src/unit.d.ts +5 -0
- package/dist/src/unit.js +14 -0
- package/package.json +71 -0
- package/src/check-sources.ts +109 -0
- package/src/cli.ts +75 -0
- package/src/derive.ts +316 -0
- package/src/gate.ts +95 -0
- package/src/grade.ts +47 -0
- package/src/index.ts +12 -0
- package/src/lanes.ts +29 -0
- package/src/p3-rules.ts +44 -0
- package/src/protocol-3.ts +617 -0
- package/src/published.ts +37 -0
- package/src/spec-documents.ts +58 -0
- package/src/spec-ir-client.ts +86 -0
- package/src/spec-ir-commands.ts +50 -0
- package/src/spec-ir-discovery.ts +104 -0
- package/src/spec-ir-graphql.ts +64 -0
- package/src/spec-ir-lines.ts +76 -0
- package/src/spec-ir-proto.ts +289 -0
- package/src/spec-ir.ts +689 -0
- package/src/spec-patches.ts +23 -0
- package/src/spec.ts +8 -0
- package/src/types.ts +53 -0
- 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
|
+
}
|