@voxgig/apidef 8.0.3 → 8.2.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.
@@ -3,7 +3,9 @@ import { join } from '@voxgig/struct'
3
3
 
4
4
  import { KIT } from '../types'
5
5
 
6
- import { firstSentence } from '../utility'
6
+ import {
7
+ firstSentence, authExchangeOp, specSecuredByDefault, sortedEntries,
8
+ } from '../utility'
7
9
 
8
10
  import type { TransformResult } from '../transform'
9
11
 
@@ -95,6 +97,17 @@ const topTransform = async function(
95
97
  const security = resolveSecurity(def)
96
98
  if (null != security) {
97
99
  kit.info.security = security
100
+
101
+ // Record the ACCESS-TOKEN EXCHANGE, when the spec describes one. The
102
+ // endpoint that issues credentials is not a resource and does not
103
+ // become an entity (the guide deactivates it — see
104
+ // guide/heuristic01.ts and ADR-002); these facts are how it survives
105
+ // into the model instead, and they are exactly what sdkgen's
106
+ // `secrets` feature needs to drive the exchange.
107
+ const exchange = findAuthExchange(def)
108
+ if (null != exchange) {
109
+ kit.info.security.exchange = exchange
110
+ }
98
111
  }
99
112
  }
100
113
 
@@ -361,6 +374,50 @@ function resolveSecurity(def: any): Record<string, string> | null {
361
374
  }
362
375
 
363
376
 
377
+ // Find the spec's access-token exchange and describe it as model facts:
378
+ // where it lives, and the field names it sends and answers with. Returns
379
+ // null when the spec describes no exchange, which is the common case.
380
+ //
381
+ // `path` is RELATIVE to the server URL, with no leading slash, because the
382
+ // server URL already carries whatever account or tenant segment the API
383
+ // templates into it — an absolute path would drop that segment. sdkgen's
384
+ // secrets feature resolves it against `options.base` for the same reason.
385
+ //
386
+ // Only the FIRST exchange is recorded. A spec describing two token
387
+ // endpoints is describing two auth schemes, which is a bigger thing than a
388
+ // field on info.security and is not guessed at here.
389
+ function findAuthExchange(def: any): Record<string, string> | null {
390
+ const secured = specSecuredByDefault(def)
391
+ if (!secured) {
392
+ return null
393
+ }
394
+
395
+ for (const [pathStr, pdef] of sortedEntries(def.paths ?? {})) {
396
+ for (const [methodName, mdef] of sortedEntries(pdef as any)) {
397
+ const found = authExchangeOp(
398
+ { ...(mdef as any), method: methodName.toUpperCase() }, secured)
399
+
400
+ if (null != found) {
401
+ const out: Record<string, string> = {
402
+ path: String(pathStr).replace(/^\/+/, ''),
403
+ method: methodName.toUpperCase(),
404
+ response: found.response,
405
+ }
406
+ // Only when the heuristic actually recognised the credential field.
407
+ // Absent, sdkgen keeps its own documented default rather than
408
+ // carrying a guess that reads like a fact.
409
+ if (null != found.request) {
410
+ out.request = found.request
411
+ }
412
+ return out
413
+ }
414
+ }
415
+ }
416
+
417
+ return null
418
+ }
419
+
420
+
364
421
  // Extract the credential prefix from a securityScheme's / info prose.
365
422
  // Three signals, in confidence order:
366
423
  // 1. An explicit `Authorization: <prefix> <cred>` line (any prefix word)
package/src/types.ts CHANGED
@@ -255,6 +255,13 @@ type GuideMetrics = {
255
255
  type GuideEntity = {
256
256
  name: string
257
257
  orig: string
258
+ // `false` drops the entity downstream (transform/entity.ts). Emitted by
259
+ // the heuristic for an access-token exchange, and editable in guide.aon —
260
+ // which is the ONLY correction surface (ADR-002).
261
+ active?: boolean
262
+ // Why the heuristic deactivated it, so guide.aon reads as a record of a
263
+ // decision rather than an unexplained `active: false`.
264
+ why_inactive?: string
258
265
  // GraphQL guides key operations by schema root field instead of path;
259
266
  // the two branches are mutually exclusive per guide.
260
267
  field?: Record<string, GuidePath>
package/src/utility.ts CHANGED
@@ -1243,6 +1243,131 @@ function guideActive(node: any): boolean {
1243
1243
  }
1244
1244
 
1245
1245
 
1246
+ // An API's ACCESS-TOKEN EXCHANGE is not a resource, and must not become an
1247
+ // entity. The shape apidef looks for is the one every such endpoint has:
1248
+ //
1249
+ // 1. The spec as a whole is SECURED (a top-level `security` requirement).
1250
+ // Without that, a per-operation `security: []` clears nothing and
1251
+ // carries no signal at all.
1252
+ // 2. The operation clears that requirement with its own `security: []` —
1253
+ // it is the one call a client can make before it holds a credential,
1254
+ // because it is what issues them.
1255
+ // 3. It is a POST. A credential exchange writes; a GET that happens to
1256
+ // return a field called `token` is far likelier to be a resource.
1257
+ // 4. Its success response carries a TOKEN-shaped field.
1258
+ //
1259
+ // All four together, or it is a resource. There is deliberately no vendor
1260
+ // extension and no overlay to say otherwise (ADR-002): a spec apidef does
1261
+ // not control cannot be annotated anyway, and a heuristic that can be
1262
+ // corrected in guide.aon needs no second correction surface.
1263
+ //
1264
+ // Returns the field names the exchange uses, which is what sdkgen's
1265
+ // `secrets` feature needs to drive it, or null when this is a resource.
1266
+ const AUTH_TOKEN_FIELDS = [
1267
+ 'access_token', 'accessToken', 'access-token',
1268
+ 'id_token', 'idToken',
1269
+ 'token', 'jwt',
1270
+ ]
1271
+
1272
+ // What the exchange SENDS. Optional: an operation answering with an access
1273
+ // token is an exchange whether or not apidef recognises the credential it
1274
+ // was bought with, and sdkgen carries its own default for the field name.
1275
+ const AUTH_CREDENTIAL_FIELDS = [
1276
+ 'refresh_token', 'refreshToken', 'refresh-token',
1277
+ 'client_secret', 'clientSecret',
1278
+ 'assertion', 'grant_type', 'grantType',
1279
+ 'api_key', 'apiKey', 'apikey',
1280
+ 'password', 'code',
1281
+ ]
1282
+
1283
+ function authExchangeOp(
1284
+ op: any,
1285
+ specSecured: boolean
1286
+ ): { request: string | null, response: string } | null {
1287
+ if (true !== specSecured) {
1288
+ return null
1289
+ }
1290
+
1291
+ // An empty ARRAY, specifically. `security` absent means "inherit the
1292
+ // global requirement"; `security: []` means "no credential needed here".
1293
+ if (!Array.isArray(op?.security) || 0 !== op.security.length) {
1294
+ return null
1295
+ }
1296
+
1297
+ if ('POST' !== String(op?.method ?? '').toUpperCase()) {
1298
+ return null
1299
+ }
1300
+
1301
+ const response = firstFieldMatch(
1302
+ schemaProps(successResponseSchema(op?.responses)), AUTH_TOKEN_FIELDS)
1303
+
1304
+ if (null == response) {
1305
+ return null
1306
+ }
1307
+
1308
+ const request = firstFieldMatch(
1309
+ schemaProps(requestBodySchema(op?.requestBody)), AUTH_CREDENTIAL_FIELDS)
1310
+
1311
+ return { request, response }
1312
+ }
1313
+
1314
+
1315
+ // Does the spec require a credential by default? Only a non-empty top-level
1316
+ // `security` makes a per-operation `security: []` meaningful.
1317
+ function specSecuredByDefault(def: any): boolean {
1318
+ return Array.isArray(def?.security) && 0 < def.security.length
1319
+ }
1320
+
1321
+
1322
+ // The 2xx body schema, OpenAPI 3 (`content`) or Swagger 2 (`schema`).
1323
+ function successResponseSchema(responses: any): any {
1324
+ const res = responses?.['200'] ?? responses?.[200] ??
1325
+ responses?.['201'] ?? responses?.[201]
1326
+ if (null == res) {
1327
+ return null
1328
+ }
1329
+ return res.content?.['application/json']?.schema ?? res.schema ?? null
1330
+ }
1331
+
1332
+
1333
+ function requestBodySchema(requestBody: any): any {
1334
+ if (null == requestBody) {
1335
+ return null
1336
+ }
1337
+ return requestBody.content?.['application/json']?.schema ?? null
1338
+ }
1339
+
1340
+
1341
+ function schemaProps(schema: any): string[] {
1342
+ const props = schema?.properties
1343
+ if (null == props || 'object' !== typeof props) {
1344
+ return []
1345
+ }
1346
+ return Object.keys(props)
1347
+ }
1348
+
1349
+
1350
+ // First name in `names` that the schema declares, compared case-insensitively
1351
+ // so `Access_Token` matches `access_token`. Ordered by the CANDIDATE list, not
1352
+ // by declaration order, so `access_token` wins over a sibling `token`.
1353
+ function firstFieldMatch(props: string[], names: string[]): string | null {
1354
+ const lower = new Map<string, string>()
1355
+ for (const p of props) {
1356
+ const k = p.toLowerCase()
1357
+ if (!lower.has(k)) {
1358
+ lower.set(k, p)
1359
+ }
1360
+ }
1361
+ for (const name of names) {
1362
+ const hit = lower.get(name.toLowerCase())
1363
+ if (null != hit) {
1364
+ return hit
1365
+ }
1366
+ }
1367
+ return null
1368
+ }
1369
+
1370
+
1246
1371
  function cleanComponentName(
1247
1372
  name: string,
1248
1373
  isKnownCmp?: (canonizedRemainder: string) => boolean
@@ -1826,6 +1951,8 @@ export {
1826
1951
  transliterate,
1827
1952
  cleanComponentName,
1828
1953
  guideActive,
1954
+ authExchangeOp,
1955
+ specSecuredByDefault,
1829
1956
  ensureMinEntityName,
1830
1957
  inferFieldType,
1831
1958
  normalizeFieldName,