@voxgig/apidef 6.3.2 → 6.3.6
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/dist/guide/heuristic01.js +13 -5
- package/dist/guide/heuristic01.js.map +1 -1
- package/dist/transform/flowstep.js +9 -2
- package/dist/transform/flowstep.js.map +1 -1
- package/dist/transform/top.d.ts +4 -1
- package/dist/transform/top.js +153 -14
- package/dist/transform/top.js.map +1 -1
- package/dist/utility.d.ts +4 -2
- package/dist/utility.js +76 -7
- package/dist/utility.js.map +1 -1
- package/model/apidef.aontu +14 -0
- package/package.json +1 -1
- package/src/guide/heuristic01.ts +15 -5
- package/src/transform/flowstep.ts +9 -2
- package/src/transform/top.ts +170 -14
- package/src/utility.ts +85 -7
package/src/transform/top.ts
CHANGED
|
@@ -105,10 +105,124 @@ const topTransform = async function(
|
|
|
105
105
|
)
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
// A short "what this API is" blurb and a canonical website link, for doc
|
|
109
|
+
// generators. Only set when derivable, so downstream can gate on them.
|
|
110
|
+
const summary = resolveSummary(def)
|
|
111
|
+
if (null != summary) {
|
|
112
|
+
kit.info.summary = summary
|
|
113
|
+
}
|
|
114
|
+
const website = resolveWebsite(def, kit.info.servers as any[])
|
|
115
|
+
if (null != website) {
|
|
116
|
+
kit.info.website = website
|
|
117
|
+
}
|
|
118
|
+
|
|
108
119
|
return { ok: true, msg: 'top' }
|
|
109
120
|
}
|
|
110
121
|
|
|
111
122
|
|
|
123
|
+
// A short one-line description of the API's purpose: the spec's
|
|
124
|
+
// `info.summary` (OpenAPI 3.1) when present, else the first prose sentence
|
|
125
|
+
// of `info.description` with leading markdown headings/blank lines stripped
|
|
126
|
+
// and the length capped. Returns undefined when no usable prose exists
|
|
127
|
+
// (e.g. GitLab, whose top-level description is empty).
|
|
128
|
+
function resolveSummary(def: any): string | undefined {
|
|
129
|
+
const info = def?.info ?? {}
|
|
130
|
+
|
|
131
|
+
const explicit = 'string' === typeof info.summary ? info.summary.trim() : ''
|
|
132
|
+
if ('' !== explicit) {
|
|
133
|
+
return firstSentence(explicit)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const desc = 'string' === typeof info.description ? info.description : ''
|
|
137
|
+
if ('' === desc.trim()) {
|
|
138
|
+
return undefined
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const lines = desc.split('\n')
|
|
142
|
+
let i = 0
|
|
143
|
+
// Skip leading blank lines, ATX headings (`# ...`) and setext underlines.
|
|
144
|
+
while (i < lines.length &&
|
|
145
|
+
('' === lines[i].trim() ||
|
|
146
|
+
/^\s*#{1,6}\s/.test(lines[i]) ||
|
|
147
|
+
/^\s*(-{2,}|={2,})\s*$/.test(lines[i]))) {
|
|
148
|
+
i++
|
|
149
|
+
}
|
|
150
|
+
// Take the first paragraph (up to the next blank line or heading).
|
|
151
|
+
const para: string[] = []
|
|
152
|
+
while (i < lines.length &&
|
|
153
|
+
'' !== lines[i].trim() &&
|
|
154
|
+
!/^\s*#{1,6}\s/.test(lines[i])) {
|
|
155
|
+
para.push(lines[i].trim())
|
|
156
|
+
i++
|
|
157
|
+
}
|
|
158
|
+
const paragraph = para.join(' ').trim()
|
|
159
|
+
return '' === paragraph ? undefined : firstSentence(paragraph)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
// The first sentence of `text` (up to a `.`/`!`/`?` followed by whitespace
|
|
164
|
+
// or end), whitespace-collapsed and length-capped with an ellipsis.
|
|
165
|
+
function firstSentence(text: string): string {
|
|
166
|
+
const collapsed = text.replace(/\s+/g, ' ').trim()
|
|
167
|
+
const m = collapsed.match(/^(.+?[.!?])(\s|$)/)
|
|
168
|
+
let out = m ? m[1] : collapsed
|
|
169
|
+
const MAX = 240
|
|
170
|
+
if (out.length > MAX) {
|
|
171
|
+
out = out.slice(0, MAX - 1).trimEnd() + '…'
|
|
172
|
+
}
|
|
173
|
+
return out
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
// A canonical link back to the API's own website, in priority order:
|
|
178
|
+
// 1. externalDocs.url (the spec's explicit external link)
|
|
179
|
+
// 2. info['x-logo'].href (redoc homepage link)
|
|
180
|
+
// 3. homepage from the server (strip an api./developer./docs. subdomain)
|
|
181
|
+
// 4. info.contact.url
|
|
182
|
+
// 5. info.termsOfService
|
|
183
|
+
function resolveWebsite(def: any, servers: any[]): string | undefined {
|
|
184
|
+
const info = def?.info ?? {}
|
|
185
|
+
|
|
186
|
+
const ext = def?.externalDocs?.url
|
|
187
|
+
if (isHttpUrl(ext)) return ext.trim()
|
|
188
|
+
|
|
189
|
+
const logoHref = info['x-logo']?.href
|
|
190
|
+
if (isHttpUrl(logoHref)) return logoHref.trim()
|
|
191
|
+
|
|
192
|
+
const home = homepageFromServer(servers?.[0]?.url)
|
|
193
|
+
if (null != home) return home
|
|
194
|
+
|
|
195
|
+
if (isHttpUrl(info.contact?.url)) return info.contact.url.trim()
|
|
196
|
+
if (isHttpUrl(info.termsOfService)) return info.termsOfService.trim()
|
|
197
|
+
|
|
198
|
+
return undefined
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
// Derive a homepage from an API server URL by dropping the path and an
|
|
203
|
+
// `api.` / `developer.` / `docs.` / `www.` service subdomain — e.g.
|
|
204
|
+
// `https://api.thesmsworks.co.uk/v1` -> `https://thesmsworks.co.uk`.
|
|
205
|
+
function homepageFromServer(url: any): string | undefined {
|
|
206
|
+
if ('string' !== typeof url || '' === url.trim()) return undefined
|
|
207
|
+
try {
|
|
208
|
+
const u = new URL(url.includes('://') ? url : 'https://' + url)
|
|
209
|
+
let host = u.hostname
|
|
210
|
+
if ('' === host || !host.includes('.')) return undefined
|
|
211
|
+
host = host.replace(
|
|
212
|
+
/^(api|api-[a-z0-9]+|apis|developer|developers|docs?|www)\./i, '')
|
|
213
|
+
return u.protocol + '//' + host
|
|
214
|
+
}
|
|
215
|
+
catch (_e) {
|
|
216
|
+
return undefined
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
function isHttpUrl(v: any): boolean {
|
|
222
|
+
return 'string' === typeof v && /^https?:\/\//i.test(v.trim())
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
|
|
112
226
|
// Describe the spec's PRIMARY security scheme as model facts
|
|
113
227
|
// (info.security): scheme key, type, where the credential goes (in/name),
|
|
114
228
|
// and the value prefix for Authorization-header credentials. The primary
|
|
@@ -119,8 +233,13 @@ const topTransform = async function(
|
|
|
119
233
|
// Prefix rules:
|
|
120
234
|
// http basic/bearer -> 'Basic' / 'Bearer'
|
|
121
235
|
// oauth2 / openIdConnect -> 'Bearer' (access token in Authorization)
|
|
122
|
-
// apiKey in an Authorization header -> the
|
|
123
|
-
// documents (e.g. `Authorization: OAuth <key>`), else '
|
|
236
|
+
// apiKey in an Authorization header -> the prefix the API's own prose
|
|
237
|
+
// documents (e.g. `Authorization: OAuth <key>`), else '' (raw). An
|
|
238
|
+
// `apiKey` scheme means "send the credential as-is" — a `Bearer`/etc.
|
|
239
|
+
// prefix is only implied by an `http`+`bearer` scheme or explicit
|
|
240
|
+
// prose, so absent evidence the key goes in raw (e.g. The SMS Works'
|
|
241
|
+
// `Authorization: <jwt>`). A user override is available via
|
|
242
|
+
// config.auth.prefix.
|
|
124
243
|
// apiKey in any other header/query/cookie -> '' (raw credential)
|
|
125
244
|
function resolveSecurity(def: any): Record<string, string> | null {
|
|
126
245
|
const schemes: Record<string, any> =
|
|
@@ -167,10 +286,12 @@ function resolveSecurity(def: any): Record<string, string> | null {
|
|
|
167
286
|
else if ('apikey' === type) {
|
|
168
287
|
if ('header' === String(out.in).toLowerCase() &&
|
|
169
288
|
'authorization' === String(out.name).toLowerCase()) {
|
|
289
|
+
// Only adopt a prefix the API's prose actually documents; otherwise
|
|
290
|
+
// the apiKey goes in raw (no assumed 'Bearer').
|
|
170
291
|
out.prefix =
|
|
171
292
|
findAuthPrefix(scheme.description) ??
|
|
172
293
|
findAuthPrefix(def.info?.description) ??
|
|
173
|
-
'
|
|
294
|
+
''
|
|
174
295
|
}
|
|
175
296
|
// else: raw credential in a named header/query/cookie — no prefix.
|
|
176
297
|
}
|
|
@@ -179,23 +300,55 @@ function resolveSecurity(def: any): Record<string, string> | null {
|
|
|
179
300
|
}
|
|
180
301
|
|
|
181
302
|
|
|
182
|
-
// Extract the credential prefix from
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
// match.
|
|
303
|
+
// Extract the credential prefix from a securityScheme's / info prose.
|
|
304
|
+
// Three signals, in confidence order:
|
|
305
|
+
// 1. An explicit `Authorization: <prefix> <cred>` line (any prefix word)
|
|
306
|
+
// — e.g. Statuspage's `Authorization: OAuth 89a2...`. The prefix must
|
|
307
|
+
// be a short word followed by something credential-shaped (a long
|
|
308
|
+
// token, or a `<key>` / `{token}` / `$KEY` / `YOUR_...` placeholder),
|
|
309
|
+
// so a bare `Authorization: 89a2...` doesn't match.
|
|
310
|
+
// 2. A KNOWN scheme word (Bearer/OAuth/Token/Basic) shown as an example
|
|
311
|
+
// prefix — `Example: Bearer eyJ...` (NoFrixion's shape).
|
|
312
|
+
// 3. A KNOWN scheme word named as the scheme — `the Bearer scheme`,
|
|
313
|
+
// `Bearer authentication`.
|
|
314
|
+
// Returns null when nothing indicates a prefix (an apiKey then goes in raw).
|
|
189
315
|
function findAuthPrefix(text: unknown): string | null {
|
|
190
316
|
if ('string' !== typeof text || '' === text) {
|
|
191
317
|
return null
|
|
192
318
|
}
|
|
193
|
-
|
|
319
|
+
|
|
320
|
+
const explicit = text.match(
|
|
194
321
|
/Authorization:[ \t]*([A-Za-z][A-Za-z0-9._-]{0,14})[ \t]+(?:<[^>\n]+>|\{[^}\n]+\}|\$[A-Za-z_][A-Za-z0-9_]*|[Yy][Oo][Uu][Rr][A-Za-z0-9_-]*|[A-Za-z0-9._~+/=-]{8,})/)
|
|
195
|
-
if (null
|
|
196
|
-
return
|
|
322
|
+
if (null != explicit) {
|
|
323
|
+
return explicit[1]
|
|
197
324
|
}
|
|
198
|
-
|
|
325
|
+
|
|
326
|
+
// A known scheme word as an example prefix, then a credential-shaped tail.
|
|
327
|
+
const example = text.match(
|
|
328
|
+
/(?:example|e\.g\.)[:\s][^\n]{0,20}?\b(Bearer|OAuth2?|Token|Basic)\b[ \t]+(?:<[^>\n]+>|\{[^}\n]+\}|[A-Za-z0-9._~+/=-]{6,})/i)
|
|
329
|
+
if (null != example) {
|
|
330
|
+
return canonAuthScheme(example[1])
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// A known scheme word named as the auth scheme.
|
|
334
|
+
const named = text.match(
|
|
335
|
+
/\b(Bearer|OAuth2?|Token|Basic)\b[ \t]+(?:scheme|authentication|auth\b|credentials?)/i)
|
|
336
|
+
if (null != named) {
|
|
337
|
+
return canonAuthScheme(named[1])
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return null
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
// Canonical casing for a known scheme word (Bearer/OAuth/Token/Basic).
|
|
345
|
+
function canonAuthScheme(word: string): string {
|
|
346
|
+
const w = word.toLowerCase()
|
|
347
|
+
if (w.startsWith('oauth')) return 'OAuth'
|
|
348
|
+
if ('bearer' === w) return 'Bearer'
|
|
349
|
+
if ('token' === w) return 'Token'
|
|
350
|
+
if ('basic' === w) return 'Basic'
|
|
351
|
+
return word
|
|
199
352
|
}
|
|
200
353
|
|
|
201
354
|
|
|
@@ -264,6 +417,9 @@ function stringifyInfoScalars(node: any): any {
|
|
|
264
417
|
export {
|
|
265
418
|
topTransform,
|
|
266
419
|
resolveSecurity,
|
|
420
|
+
resolveSummary,
|
|
421
|
+
resolveWebsite,
|
|
422
|
+
homepageFromServer,
|
|
267
423
|
findAuthPrefix,
|
|
268
424
|
}
|
|
269
425
|
|
package/src/utility.ts
CHANGED
|
@@ -951,6 +951,34 @@ function canonize(s: string) {
|
|
|
951
951
|
}
|
|
952
952
|
|
|
953
953
|
|
|
954
|
+
// Namespace-qualified schema names (ASP.NET / Java style:
|
|
955
|
+
// "NoFrixion.MoneyMoov.Models.PaymentRequests.MerchantPayment",
|
|
956
|
+
// "com.example.api.Payment") describe the type by their LAST dotted
|
|
957
|
+
// segment; the namespace prefix is packaging noise. Reduce to the last
|
|
958
|
+
// meaningful segment — skipping version-ish ("v2", "10") or too-short
|
|
959
|
+
// tails — so entity names derive from the type, not the namespace.
|
|
960
|
+
function stripSchemaNamespace(name: string): string {
|
|
961
|
+
if (null == name || !name.includes('.')) return name
|
|
962
|
+
const segs = name.split('.')
|
|
963
|
+
for (let i = segs.length - 1; i >= 0; i--) {
|
|
964
|
+
const seg = segs[i]
|
|
965
|
+
if (seg.length >= 3 && !/^v?\d+$/i.test(seg)) {
|
|
966
|
+
return seg
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
return name
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
// Canonical form of an OpenAPI component schema name, for use as an
|
|
974
|
+
// entity-name candidate and as the frequency-metric key. Must be applied
|
|
975
|
+
// uniformly wherever schema refs are counted or resolved (MeasureRef,
|
|
976
|
+
// ResolveEntityComponent, findcmps) so the metric keys stay consistent.
|
|
977
|
+
function canonizeCmpName(orig: string): string {
|
|
978
|
+
return canonize(stripSchemaNamespace(orig))
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
|
|
954
982
|
// Sanitize a raw slug into a clean kebab-case string suitable for
|
|
955
983
|
// conversion to a valid JS identifier (via camelify/snakify/etc).
|
|
956
984
|
function sanitizeSlug(s: string): string {
|
|
@@ -1062,8 +1090,21 @@ function ensureMinEntityName(
|
|
|
1062
1090
|
}
|
|
1063
1091
|
|
|
1064
1092
|
if (padded !== name && null != existing[padded]) {
|
|
1093
|
+
// The name was modified (truncated/sanitized) and collides with an
|
|
1094
|
+
// existing entity. Only a collision between DIFFERENT origins needs a
|
|
1095
|
+
// numeric suffix — the same original name re-encountered (e.g. the same
|
|
1096
|
+
// long schema referenced by several methods on one path) must reuse the
|
|
1097
|
+
// existing entity so its ops merge instead of minting phantom
|
|
1098
|
+
// "<entity>2/3/4" entities. Entities record their pre-truncation name
|
|
1099
|
+
// as `longname`; entries without one keep the old always-suffix rule.
|
|
1100
|
+
if (existing[padded].longname === name) {
|
|
1101
|
+
return padded
|
|
1102
|
+
}
|
|
1065
1103
|
let i = 2
|
|
1066
1104
|
while (null != existing[padded + i]) {
|
|
1105
|
+
if (existing[padded + i].longname === name) {
|
|
1106
|
+
return padded + i
|
|
1107
|
+
}
|
|
1067
1108
|
i++
|
|
1068
1109
|
}
|
|
1069
1110
|
padded = padded + i
|
|
@@ -1073,18 +1114,53 @@ function ensureMinEntityName(
|
|
|
1073
1114
|
}
|
|
1074
1115
|
|
|
1075
1116
|
|
|
1117
|
+
// Unconditional suffixes: framework noise, always stripped.
|
|
1076
1118
|
const CMP_SUFFIXES = ['_rest_controller', '_controller', '_response', '_request']
|
|
1119
|
+
|
|
1120
|
+
// Guarded suffixes: pagination wrappers ('_page_response', '_page') and
|
|
1121
|
+
// op-reply wrappers ('_create_response', '_update_response') fold wrapper
|
|
1122
|
+
// schemas (BeneficiaryPageResponse, MerchantTokenPage,
|
|
1123
|
+
// BeneficiariesCreateResponse, ...) into their base entity — but ONLY when
|
|
1124
|
+
// the remainder is itself a known component schema (the wrapper
|
|
1125
|
+
// convention). Without that guard a real noun gets mangled: an API whose
|
|
1126
|
+
// resource IS a page (LandingPage entity at /landing-pages) must keep
|
|
1127
|
+
// 'landing_page', not become 'landing'. Order matters: longer first, since
|
|
1128
|
+
// '_page_response'/'_create_response' also end with '_response'. Bare
|
|
1129
|
+
// '_create'/'_update' are never stripped: too likely part of a real noun.
|
|
1130
|
+
const CMP_GUARDED_SUFFIXES = ['_create_response', '_update_response', '_page_response', '_page']
|
|
1131
|
+
|
|
1077
1132
|
const CMP_PREFIXES = ['get_', 'post_', 'put_', 'delete_', 'patch_']
|
|
1078
1133
|
|
|
1079
|
-
function cleanComponentName(
|
|
1134
|
+
function cleanComponentName(
|
|
1135
|
+
name: string,
|
|
1136
|
+
isKnownCmp?: (canonizedRemainder: string) => boolean
|
|
1137
|
+
): string {
|
|
1080
1138
|
let cleaned = name
|
|
1139
|
+
let stripped = false
|
|
1140
|
+
|
|
1141
|
+
if (null != isKnownCmp) {
|
|
1142
|
+
for (const suffix of CMP_GUARDED_SUFFIXES) {
|
|
1143
|
+
if (cleaned.endsWith(suffix)) {
|
|
1144
|
+
const parts = cleaned.split('_')
|
|
1145
|
+
const suffixParts = suffix.split('_').filter(s => s !== '').length
|
|
1146
|
+
const remainder = canonize(parts.slice(0, parts.length - suffixParts).join('_'))
|
|
1147
|
+
if (remainder.length >= 3 && isKnownCmp(remainder)) {
|
|
1148
|
+
cleaned = remainder
|
|
1149
|
+
stripped = true
|
|
1150
|
+
}
|
|
1151
|
+
break
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1081
1155
|
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1156
|
+
if (!stripped) {
|
|
1157
|
+
for (const suffix of CMP_SUFFIXES) {
|
|
1158
|
+
if (cleaned.endsWith(suffix)) {
|
|
1159
|
+
const parts = cleaned.split('_')
|
|
1160
|
+
const suffixParts = suffix.split('_').filter(s => s !== '').length
|
|
1161
|
+
cleaned = canonize(parts.slice(0, parts.length - suffixParts).join('_'))
|
|
1162
|
+
break
|
|
1163
|
+
}
|
|
1088
1164
|
}
|
|
1089
1165
|
}
|
|
1090
1166
|
|
|
@@ -1315,6 +1391,8 @@ export {
|
|
|
1315
1391
|
formatJSONIC,
|
|
1316
1392
|
validator,
|
|
1317
1393
|
canonize,
|
|
1394
|
+
canonizeCmpName,
|
|
1395
|
+
stripSchemaNamespace,
|
|
1318
1396
|
sanitizeSlug,
|
|
1319
1397
|
slugToPascalCase,
|
|
1320
1398
|
transliterate,
|