@voxgig/apidef 8.2.3 → 8.4.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.
@@ -633,6 +633,15 @@ function ResolveEntityName(spec: TaskSpec) {
633
633
  ment.entname = entname
634
634
  ment.pm = pm
635
635
 
636
+ // Which entity took each path+method, in resolution order. verbOnParent
637
+ // reads the item path's GET owner from here: a t/p/ item path can be
638
+ // split across entities by method (a PUT answering with a one-off
639
+ // acknowledgement is named after it), and the parent of a verb is the
640
+ // entity a read of the item returns.
641
+ work.pathowner = work.pathowner ?? {}
642
+ work.pathowner[pathStr] = work.pathowner[pathStr] ?? {}
643
+ work.pathowner[pathStr][methodName] = entname
644
+
636
645
  debugpath(pathStr, methodName, 'RESOLVE-ENTITY-NAME',
637
646
  formatJSONIC({ entdesc, ment }, { hsepd: 0, $: true, color: true }))
638
647
  }
@@ -737,6 +746,10 @@ function RenameParams(spec: TaskSpec) {
737
746
  oldParam.endsWith('id')
738
747
  || oldParam.endsWith('Id')
739
748
  || canonize(oldParam) === parentName
749
+ // GitHub-style `<parent>_number` keys (pull_number, issue_number):
750
+ // the parent's own key under another name. Only when the param sits
751
+ // under its own entity, so a nested collection keeps its parent key.
752
+ || (oldParam.endsWith('_number') && parentName === entdesc.name)
740
753
 
741
754
  debugpath(pathStr, mdesc.method, 'RENAME-PARAM-PART', parts, partI, partStr, {
742
755
  lastPart,
@@ -960,8 +973,20 @@ function FindActions(spec: TaskSpec) {
960
973
 
961
974
  const cmp = ment.cmp
962
975
 
976
+ // A verb that ResolveEntityName assigned to its parent entity
977
+ // (verbOnParent) is an action whatever the parent literal canonizes to:
978
+ // `/app/installations/{installation_id}/access_tokens` belongs to `app`.
979
+ // Recorded directly rather than through updateAction, whose guard against
980
+ // an entity "already encoding" the verb would drop `archive` on
981
+ // `email_archive` and leave the verb as a plain CRUD point.
982
+ if (null != ment.verb_on_parent) {
983
+ pathdesc.action[lastPartCanon] = pathdesc.action[lastPartCanon] ?? {
984
+ why_action: ['ent', entdesc.name, 'verb-on-parent', lastPart, methodName],
985
+ }
986
+ }
987
+
963
988
  // /api/foo/bar where foo is the entity and bar is the action, no id param
964
- if (
989
+ else if (
965
990
  secondLastPartCanon === cmp
966
991
  || secondLastPartCanon === ment.origcmp
967
992
  || secondLastPartCanon === entname
@@ -1272,9 +1297,17 @@ function entityPathMatch_tpte(
1272
1297
  let ecm = undefined
1273
1298
 
1274
1299
  if (null != ment.cmp) {
1275
- ecm = entityCmpMatch(data, entname, mdesc, why)
1276
- entname = ecm.name
1277
- why.push('has-cmp=' + ecm.orig)
1300
+ const parent = verbOnParent(data, pm, mdesc)
1301
+ if (null != parent) {
1302
+ entname = parent
1303
+ ment.verb_on_parent = getelem(pm, -1)
1304
+ why.push('verb-on-parent=' + parent)
1305
+ }
1306
+ else {
1307
+ ecm = entityCmpMatch(data, entname, mdesc, why)
1308
+ entname = ecm.name
1309
+ why.push('has-cmp=' + ecm.orig)
1310
+ }
1278
1311
  }
1279
1312
 
1280
1313
  else if (probableEntityMethod(data, mdesc, pm, why)) {
@@ -1312,6 +1345,103 @@ function endsWithCmp(data: any, pm: PathMatch) {
1312
1345
  }
1313
1346
 
1314
1347
 
1348
+ // A write on `.../<parent>/{id}/<verb>` is a VERB ON THE PARENT, not an
1349
+ // entity named after its result shape.
1350
+ //
1351
+ // GitHub's `PUT /repos/{owner}/{repo}/pulls/{pull_number}/merge` answers with
1352
+ // a `pull-request-merge-result` component. Naming the method's entity after
1353
+ // that component (the cmp-primary rule) produced a `pull_request_merge_result`
1354
+ // entity with a single `update` op, while the GET on the same path (no
1355
+ // response schema) stayed an action on `pull`: one route split across two
1356
+ // entities by method, and the verb unreachable from the entity it acts on.
1357
+ //
1358
+ // Five signals, together: the method writes (a GET on such a path is a
1359
+ // sub-resource read and keeps the component rule); the response component
1360
+ // occurs nowhere else in the spec (a one-off result, not a resource shape);
1361
+ // that component is not the literal's own collection shape (a create-only
1362
+ // `POST .../{id}/labels` answering with a `label` is a nested collection,
1363
+ // not a verb); the item selector itself (`.../pulls/{pull_number}`) is a
1364
+ // path of the spec, so the trailing literal cannot be a collection of its
1365
+ // own; and nothing extends the path (`.../private-registries/{secret_name}`
1366
+ // makes `private-registries` a collection, whatever its POST answers with).
1367
+ // The verb then joins the parent entity, where FindActions records it as an
1368
+ // action and select stamps `$action` on its points. Returns the parent's
1369
+ // entity name, or null when the rule does not apply.
1370
+ function verbOnParent(
1371
+ data: { def: any, guide: any, work: any },
1372
+ pm: PathMatch,
1373
+ mdesc: any,
1374
+ ): null | string {
1375
+ const method = mdesc.method
1376
+ if ('GET' === method || 'QUERY' === method || 'HEAD' === method || 'OPTIONS' === method) {
1377
+ return null
1378
+ }
1379
+
1380
+ const ment = mdesc.MethodEntity
1381
+ if (1 < (ment.cmpoccur ?? 0)) {
1382
+ return null
1383
+ }
1384
+
1385
+ // A PLURAL literal names a nested collection, whatever it answers with:
1386
+ // `asset_keys` under `{environment_id}` creates an asset key, and
1387
+ // `approvals` under `{merge_request_iid}` is a collection of approvals.
1388
+ // A verb is singular — `merge`, `revoke`, `resend_confirmation` — so the
1389
+ // component rule below is never reached for a plural, which is what keeps
1390
+ // a create-only collection an entity of its own.
1391
+ const lit = snakify(getelem(pm, -1))
1392
+ if ('' === lit || depluralize(lit) !== lit) {
1393
+ return null
1394
+ }
1395
+
1396
+ // A singular literal still names a collection when its response component
1397
+ // is that collection's member shape (`label` answering with `label`, or
1398
+ // with a parent-prefixed `thing_label`); a verb answers with something
1399
+ // else.
1400
+ const verb = canonize(getelem(pm, -1))
1401
+ const cmp = String(ment.cmp ?? '')
1402
+ if ('' === verb || cmp === verb || cmp.endsWith('_' + verb)) {
1403
+ return null
1404
+ }
1405
+
1406
+ const defpaths = data.def?.paths ?? {}
1407
+
1408
+ // Paths compare with parameters normalised: the item path may spell its
1409
+ // key `{id}` where the verb path spells it `{thing_number}`.
1410
+ const normalize = (p: string) => p.replace(/\{[^}]+\}/g, '{}')
1411
+ const itemNorm = normalize(pm.path.replace(/\/[^/]+$/, ''))
1412
+ const prefix = normalize(pm.path) + '/'
1413
+
1414
+ let itemPath: undefined | string = undefined
1415
+ for (const p of Object.keys(defpaths)) {
1416
+ const pn = normalize(p)
1417
+ if (pn === itemNorm) {
1418
+ itemPath = p
1419
+ }
1420
+ // A leaf: no path continues past the verb. Compared on a segment
1421
+ // boundary, so `/merge` is not "extended" by `/merge-async`.
1422
+ else if (pn.startsWith(prefix)) {
1423
+ return null
1424
+ }
1425
+ }
1426
+ if (null == itemPath) {
1427
+ return null
1428
+ }
1429
+
1430
+ // Methods resolve in path order, so the item path's owners are already
1431
+ // known. The parent is the entity a READ of the item returns: a PUT on
1432
+ // the item answering with a one-off acknowledgement is named after that
1433
+ // and must not claim the verb. Fall back to any owner, then the literal.
1434
+ const owners = data.work.pathowner?.[itemPath] ?? {}
1435
+ const parent = owners.GET ?? owners.QUERY ??
1436
+ Object.values(owners).sort()[0] as undefined | string
1437
+ if (null != parent) {
1438
+ return parent
1439
+ }
1440
+
1441
+ return canonize(getelem(pm, -3))
1442
+ }
1443
+
1444
+
1315
1445
  function isOrigCmp(data: any, name: string) {
1316
1446
  return null != data.guide.metrics.count.origcmprefs[name]
1317
1447
  }
package/src/model.ts CHANGED
@@ -84,6 +84,23 @@ type ModelField = {
84
84
  // `description`. Absent when the spec does not describe the property —
85
85
  // generators render an empty cell rather than inventing prose.
86
86
  short?: string
87
+
88
+ // SPEC FACTS ABOUT THE FIELD ITSELF, carried through verbatim from the
89
+ // OpenAPI property. Facts the spec states, not inferences.
90
+ //
91
+ // `readOnly` is the load-bearing one: it is the difference between a field
92
+ // a client MAY send and one it may not, which nothing else in this record
93
+ // expresses. Without it every generator necessarily puts server-assigned
94
+ // fields into the type a caller fills in.
95
+ //
96
+ // The booleans are present ONLY when the spec declares them true — each
97
+ // defaults to false in OpenAPI, so absent and explicit-false mean the same
98
+ // thing and emitting the false ones would change every model for no
99
+ // information. `format` is present only for a non-empty string.
100
+ readOnly?: boolean
101
+ writeOnly?: boolean
102
+ deprecated?: boolean
103
+ format?: string
87
104
  union?: {
88
105
  count: number // how many untagged unions lie beneath the field
89
106
  branches: number // widest branch count among them
@@ -215,6 +232,16 @@ type ModelEntity = {
215
232
  id?: {
216
233
  name: string
217
234
  field: string
235
+ // COMPOSITE IDENTITY. Present only when the API addresses one record by
236
+ // MORE THAN ONE path parameter, so no single parameter is the id.
237
+ // github's repo is the case: GET /repos/{owner}/{repo} needs both, and
238
+ // neither alone names a repository.
239
+ //
240
+ // `parts` are those parameters in path order; `sep` joins them into the
241
+ // one `id` an SDK entity carries. Absent means the ordinary single-key
242
+ // entity, so downstream can branch on presence alone.
243
+ parts?: string[]
244
+ sep?: string
218
245
  }
219
246
  relations: ModelEntityRelations
220
247
  }
@@ -71,7 +71,7 @@ const argsTransform: Transform = async function(
71
71
  argdefs.push(...(opdef?.parameters ?? []))
72
72
  }
73
73
 
74
- resolveArgs(ment, mop, mpoint, argdefs)
74
+ resolveArgs(ctx, ment, mop, mpoint, argdefs)
75
75
  })
76
76
 
77
77
  })
@@ -116,7 +116,10 @@ const ARG_KIND: Record<string, ModelArg["kind"]> = {
116
116
  }
117
117
 
118
118
 
119
- function resolveArgs(ment: ModelEntity, mop: ModelOp, mpoint: ModelPoint, argdefs: ParameterDef[]) {
119
+ function resolveArgs(
120
+ ctx: any,
121
+ ment: ModelEntity, mop: ModelOp, mpoint: ModelPoint, argdefs: ParameterDef[]
122
+ ) {
120
123
  const touchedKeys = new Set<string>()
121
124
 
122
125
  each(argdefs, (argdef: ParameterDef) => {
@@ -124,6 +127,27 @@ function resolveArgs(ment: ModelEntity, mop: ModelOp, mpoint: ModelPoint, argdef
124
127
  // by; the snakified form is the user-friendly runtime identifier.
125
128
  const specName = normalizeFieldName(argdef.name)
126
129
  const orig = depluralize(snakify(specName))
130
+
131
+ // A parameter with no name is not a parameter. This is what a DANGLING
132
+ // `$ref` looks like by the time it reaches here: the reference survives
133
+ // unresolved, `name` and `in` are both absent, and the arg would become
134
+ // a nameless `query` entry that every target then has to render. Ruby
135
+ // cannot: `Struct.new(:"")` raises at load and takes the whole SDK with
136
+ // it. Drop it and say which reference is missing.
137
+ if ('' === orig) {
138
+ const ref = (argdef as any)?.$ref
139
+ ctx?.warn?.({
140
+ note: `Parameter with no name on entity=${ment.name} op=${mop.name}` +
141
+ ` path=${mpoint.orig} is dropped` +
142
+ (null == ref ? '.' : `: \`$ref\` "${ref}" resolves to nothing.`) +
143
+ ' A parameter needs a `name`, or a reference that resolves to one.',
144
+ entity: ment.name,
145
+ path: mpoint.orig,
146
+ op: mop.name,
147
+ })
148
+ return
149
+ }
150
+
127
151
  const kind = ARG_KIND[argdef.in] ?? 'query'
128
152
  // Rename map can be keyed by either the spec original (camelCase) or by
129
153
  // the snakified form depending on which path went through heuristic01.
@@ -241,6 +241,7 @@ function resolvePathList(guideEntity: GuideEntity, def: { paths: Record<string,
241
241
  rename,
242
242
  method: '', // operation collectOps will copy and assign per op
243
243
  op: guidePath.op,
244
+ action: guidePath.action,
244
245
  def: def.paths[orig],
245
246
  }
246
247