@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.
@@ -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 scheme the API's own prose
123
- // documents (e.g. `Authorization: OAuth <key>`), else 'Bearer'
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
- 'Bearer'
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 prose showing the Authorization
183
- // header format, e.g. `Authorization: OAuth 89a2...` or
184
- // `-H "Authorization: token <key>"`. The prefix must be a short word on
185
- // the SAME line, followed by something credential-shaped — a long token,
186
- // or a `<key>` / `{token}` / `$KEY` / `YOUR_...`-style placeholder. Bare
187
- // credentials (`Authorization: 89a2...`) and prose coincidences yield no
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
- const m = text.match(
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 == m) {
196
- return null
322
+ if (null != explicit) {
323
+ return explicit[1]
197
324
  }
198
- return m[1]
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(name: string): string {
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
- for (const suffix of CMP_SUFFIXES) {
1083
- if (cleaned.endsWith(suffix)) {
1084
- const parts = cleaned.split('_')
1085
- const suffixParts = suffix.split('_').filter(s => s !== '').length
1086
- cleaned = canonize(parts.slice(0, parts.length - suffixParts).join('_'))
1087
- break
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,