@voxgig/apidef 8.3.0 → 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.
package/src/model.ts CHANGED
@@ -232,6 +232,16 @@ type ModelEntity = {
232
232
  id?: {
233
233
  name: string
234
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
235
245
  }
236
246
  relations: ModelEntityRelations
237
247
  }
@@ -32,7 +32,7 @@ import type {
32
32
  const fieldTransform: Transform = async function(
33
33
  ctx: any,
34
34
  ): Promise<TransformResult> {
35
- const { apimodel, def } = ctx
35
+ const { apimodel, def, guide, model } = ctx
36
36
  const kit: KitModel = apimodel.main[KIT]
37
37
 
38
38
  let msg = 'field '
@@ -72,9 +72,101 @@ const fieldTransform: Transform = async function(
72
72
  // Downstream (test generators, fixture builders) gate id-specific code on
73
73
  // this presence so that public read-only APIs without ids don't get
74
74
  // bogus id assertions.
75
+ // COMPOSITE FIRST, because a compound key need not come with an `id`.
76
+ //
77
+ // An entity addressed by `{owner}/{repo}` whose response carries only
78
+ // `owner` and `name` has no field literally named `id`, and its adjacent
79
+ // placeholders are left unrenamed so `addressedById` is false too.
80
+ // Neither branch below then ran, so the entity got NO id descriptor and
81
+ // even an explicit `guide.entity.<name>.id.parts` was silently ignored —
82
+ // while the Go port, which initialises a descriptor unconditionally,
83
+ // emitted the composite. The ports disagreed on exactly the shape this
84
+ // feature exists for.
85
+ const gent = guide?.entity?.[ment.name]
86
+ const composite = compositeId(ment, gent)
87
+
75
88
  const idField = fields.find((f: ModelField) => 'id' === f.name)
76
- if (idField) {
77
- ment.id = { name: 'id', field: 'id' }
89
+
90
+ // A COMPOSITE ID IS A STRING, whatever the API's own `id` field is —
91
+ // AND THE API'S OWN id IS KEPT.
92
+ //
93
+ // github's repo declares `id` as an integer, its global database id,
94
+ // while the composite identity is `owner/repo`. Two facts have to
95
+ // survive: `id` must hold a string, because that is what the joined
96
+ // value is and what every generated type has to store; and the spec's
97
+ // numeric property must not be silently reinterpreted, because a
98
+ // consumer that wants the database id is entitled to it with its own
99
+ // type and format intact.
100
+ //
101
+ // So the API's field MOVES to `<api>_id` rather than being rewritten in
102
+ // place, carrying its type, format and per-op overrides with it, and the
103
+ // entity's `alias.field` map records where it went. Retyping in place
104
+ // (the first attempt) claimed the server's numeric id was a string;
105
+ // leaving it alone made `id.field` name a declaration the runtime value
106
+ // cannot satisfy. Moving it is the only option that lies about neither.
107
+ if (null != composite.parts && null != idField && !scalarStringField(idField)) {
108
+ const idf: any = idField
109
+ const apiname = String((model as any)?.name || 'api')
110
+ const keep = apiname + '_id'
111
+
112
+ if (!fields.some((f: ModelField) => f.name === keep)) {
113
+ // A DEEP COPY, because the move is followed by deletions on the
114
+ // original. A spread shares the `op` object, so clearing the stale
115
+ // per-op `type` off `id` cleared it off the preserved field too —
116
+ // the preservation preserved nothing for exactly the key it was
117
+ // added to keep.
118
+ fields.push(JSON.parse(JSON.stringify({ ...idf, name: keep })) as any)
119
+
120
+ const alias = ((ment as any).alias = (ment as any).alias || {})
121
+ alias.field = alias.field || {}
122
+ alias.field[keep] = 'id'
123
+ }
124
+
125
+ idf.type = '`$STRING`'
126
+ // The facts that described the moved type go with it: `format: int64`
127
+ // beside a string, or a per-op `type` override still saying integer,
128
+ // is a model contradicting itself — and the op override is what a
129
+ // generator reads for that op.
130
+ delete idf.format
131
+ for (const opname of Object.keys(idf.op || {})) {
132
+ delete idf.op[opname].type
133
+ }
134
+
135
+ fields.sort((a: ModelField, b: ModelField) =>
136
+ a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
137
+ }
138
+
139
+ if (null != composite.parts && null == idField) {
140
+ // The FIELD as well as the descriptor, for the reason the branch below
141
+ // documents: a model that declares the descriptor without the field
142
+ // makes the generated type disagree with the generated test.
143
+ fields.push({
144
+ name: 'id',
145
+ type: '`$STRING`',
146
+ req: false,
147
+ } as any)
148
+ fields.sort((a: ModelField, b: ModelField) =>
149
+ a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
150
+ }
151
+
152
+ const singleKey = (composite as any).single
153
+ delete (composite as any).single
154
+
155
+ if (null == idField && null != singleKey && null == composite.parts) {
156
+ // The guide disabled composite; the terminal parameter is the key, and
157
+ // the entity needs the field to carry it for the same reason the
158
+ // composite branch above does.
159
+ fields.push({
160
+ name: 'id',
161
+ type: '`$STRING`',
162
+ req: false,
163
+ } as any)
164
+ fields.sort((a: ModelField, b: ModelField) =>
165
+ a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
166
+ }
167
+
168
+ if (idField || null != composite.parts || null != singleKey) {
169
+ ment.id = { name: 'id', field: 'id', ...composite }
78
170
  }
79
171
  else if (addressedById(ment)) {
80
172
  // The FIELD as well as the descriptor. An entity addressed by id has an
@@ -111,7 +203,7 @@ const fieldTransform: Transform = async function(
111
203
  // with neither a field nor an id param — the read-only public APIs the
112
204
  // rule above was written for — still get no descriptor, so they still
113
205
  // get no id assertions.
114
- ment.id = { name: 'id', field: 'id' }
206
+ ment.id = { name: 'id', field: 'id', ...composite }
115
207
  }
116
208
 
117
209
  msg += ment.name + ' '
@@ -122,6 +214,238 @@ const fieldTransform: Transform = async function(
122
214
 
123
215
 
124
216
 
217
+ // The separator that joins a composite id into one string.
218
+ //
219
+ // A forward slash cannot occur inside a single path segment — a raw `/`
220
+ // would end the segment, and a value that legitimately contains one arrives
221
+ // percent-encoded as `%2F` — so joining on it can never be ambiguous, and
222
+ // splitting on it can never over-split. That is what makes the composite id
223
+ // safe to carry as a single opaque string, which is the property the SDK and
224
+ // Seneca entities are built on.
225
+ const ID_SEP = '/'
226
+
227
+ // The ops that address ONE record, most authoritative first. Only a
228
+ // tie-break: identityParams compares candidates from all of them.
229
+ const ID_OPS = ['load', 'update', 'patch', 'remove']
230
+
231
+
232
+ // The parameters that TOGETHER name one record: the trailing run of
233
+ // ADJACENT variable segments on the addressing route.
234
+ //
235
+ // ADJACENCY IS THE WHOLE TEST, and it is what separates a compound key from
236
+ // ordinary parent/child nesting:
237
+ //
238
+ // /repos/{owner}/{repo} -> owner, repo COMPOSITE
239
+ // /api/planet/{planet_id}/moon/{moon_id} -> moon_id single
240
+ // /repos/{owner}/{repo}/pulls/{pull_number} -> pull_number single
241
+ //
242
+ // A literal segment between two variables names a SUB-COLLECTION, so the
243
+ // earlier variable scopes the later one — `planet_id` says which planet's
244
+ // moons, and `moon_id` alone identifies the moon. Two variables with nothing
245
+ // between them address no sub-collection: neither value names anything on
246
+ // its own, and only the pair identifies a repository.
247
+ //
248
+ // Taking every variable on the path instead was tried first and is wrong on
249
+ // most real specs — it made `moon` (planet_id + moon_id), petstore's `order`,
250
+ // `pet` and `user`, and taxonomy's `domain` and `kingdom` all falsely
251
+ // composite, which the apidef-validate goldens caught immediately. Nested
252
+ // resources are the common shape; compound keys are the exception, and
253
+ // adjacency is the thing that actually distinguishes them.
254
+ //
255
+ // Read from the op that names a single record, never from `list`: a
256
+ // collection route's path params are the entity's parents. A point ending in
257
+ // a literal is a verb ON the record (`.../{number}/merge`) and carries the
258
+ // same variables, so it is a fallback rather than a different answer.
259
+ // Walk back from a point's end, collecting variables until a literal stops
260
+ // the run. That literal is the sub-collection boundary; anything before it
261
+ // scopes this record rather than naming it.
262
+ function trailingVars(point: any): string[] {
263
+ const segs = ((point?.segments || []) as any[]).filter((s: any) => null != s)
264
+ const run: string[] = []
265
+
266
+ for (let i = segs.length - 1; 0 <= i; i--) {
267
+ if (null == segs[i].var) {
268
+ break
269
+ }
270
+ run.unshift(String(segs[i].var))
271
+ }
272
+
273
+ return run
274
+ }
275
+
276
+
277
+ function identityParams(ment: ModelEntity): string[] {
278
+ // EVERY ID-BEARING OP AT ONCE, not the first one that offers a candidate.
279
+ //
280
+ // These four ops all address a single record, so all four describe the
281
+ // same identity — but they do not all carry the same routes. gitlab's
282
+ // `project` has `/api/v4/projects/{id}` under `remove` alone, while its
283
+ // `load` carries only sub-resources like
284
+ // `/api/v4/projects/{id}/uploads/{secret}/{filename}`. Returning on the
285
+ // first op with any candidate therefore made a PROJECT identified by
286
+ // `secret/filename`. The op order is now only a tie-break.
287
+ const cands: any[] = []
288
+
289
+ for (let o = 0; o < ID_OPS.length; o++) {
290
+ const mop = (ment as any).op?.[ID_OPS[o]]
291
+ if (null == mop) {
292
+ continue
293
+ }
294
+
295
+ // Action points are verbs dispatched by `$action`, not addresses.
296
+ for (const pt of (mop.points || [])) {
297
+ if (null != pt?.select?.['$action']) {
298
+ continue
299
+ }
300
+ const run = trailingVars(pt)
301
+ if (0 === run.length) {
302
+ continue
303
+ }
304
+ cands.push({
305
+ run,
306
+ // Segments BEFORE the run: how much parent scope the route needs.
307
+ scope: ((pt.segments || []).length - run.length),
308
+ // DOES THE RUN END IN THE RECORD'S OWN KEY? Then it is the
309
+ // record's address and nothing further is needed.
310
+ //
311
+ // This transform RENAMES that parameter to `id`, so a run ending in
312
+ // it is this port's own statement of what identifies the record —
313
+ // and the composite inference must not contradict it.
314
+ // `/gists/{gist_id}` becomes `/gists/{id}` and is a gist;
315
+ // `/gists/{gist_id}/{sha}` is a REVISION of one, and won on key
316
+ // length alone, so a gist came out keyed `gist_id/sha` while the
317
+ // generated SDK's own load match takes the single parameter. The
318
+ // same contradiction gave cloudsmith's repo and vulnerability
319
+ // compound keys their SDKs never address them by.
320
+ //
321
+ // Deliberately narrow: exactly `id` or an unrenamed `<entity>_id`,
322
+ // never any `*_id`. `actor_type/actor_id` IS a compound key, and a
323
+ // looser test breaks it.
324
+ own: 'id' === run[run.length - 1] ||
325
+ (ment as any).name + '_id' === run[run.length - 1],
326
+ order: o,
327
+ })
328
+ }
329
+ }
330
+
331
+ // WHICH ROUTE IS THE RECORD'S OWN ADDRESS.
332
+ //
333
+ // An entity gathers every route that reads it, and in a large
334
+ // specification most of those are sub-resources. Three earlier rules were
335
+ // measured against the validation corpus, and each is wrong:
336
+ //
337
+ // The FIRST route listed gave github's `repo` the single part
338
+ // `subject_digest`, from
339
+ // `/repos/{owner}/{repo}/attestations/{subject_digest}` — no compound
340
+ // key at all, for the entity this feature exists for. Invisible on a
341
+ // small spec, where the first item route IS the record's own.
342
+ //
343
+ // The SHORTEST route ending in a variable took cloudsmith's
344
+ // `/vulnerabilities/{owner}/` — a LIST of an owner's vulnerabilities —
345
+ // and cut a four-part key down to `owner`, dropping three more
346
+ // composites. Ending in a variable does not make a route an address.
347
+ //
348
+ // The LONGEST trailing run took
349
+ // `/orgs/{org}/teams/{team_slug}/repos/{owner}/{repo}` and made a TEAM
350
+ // identified by `owner/repo`. A deep sub-resource can carry more
351
+ // adjacent variables than the record's own route does.
352
+ //
353
+ // What separates them is PARENT SCOPE: the record's own route is the
354
+ // least-qualified one that names it, and among equally-qualified routes
355
+ // the one carrying the fullest key. `/repos/{owner}/{repo}` is qualified
356
+ // by one segment and the attestations route by four; `/teams/{team_id}`
357
+ // by one and the org-team-repo route by five; cloudsmith's vulnerability
358
+ // routes are all qualified by one, so the fullest of them wins.
359
+ const best = cands.reduce((b: any, c: any) => {
360
+ if (null == b) {
361
+ return c
362
+ }
363
+ if (c.scope !== b.scope) {
364
+ return c.scope < b.scope ? c : b
365
+ }
366
+ if (c.own !== b.own) {
367
+ return c.own ? c : b
368
+ }
369
+ if (c.run.length !== b.run.length) {
370
+ return b.run.length < c.run.length ? c : b
371
+ }
372
+ return c.order < b.order ? c : b
373
+ }, null)
374
+
375
+ return null == best ? [] : best.run
376
+ }
377
+
378
+ // Is this model field declared as a string? A composite id is the parts
379
+ // joined, so the field that holds it has to be one.
380
+ function scalarStringField(f: any): boolean {
381
+ return String(f?.type || '').toUpperCase().includes('STRING')
382
+ }
383
+
384
+
385
+ // WHICH PARAMETER IS THE RECORD'S OWN KEY, among several that looked
386
+ // adjacent. The same shape apidef's id handling recognises everywhere else:
387
+ //
388
+ // 1. one named exactly `id`
389
+ // 2. `<entity>_id` — the entity's own id, however the path spells it
390
+ // 3. any `*_id` — an id by name
391
+ // 4. failing all that, the terminal parameter
392
+ //
393
+ // Position is the LAST resort, not the first.
394
+ function singleKeyOf(ment: ModelEntity, parts: string[]): string | undefined {
395
+ if (0 === parts.length) {
396
+ return undefined
397
+ }
398
+
399
+ return parts.find((p: string) => 'id' === p)
400
+ ?? parts.find((p: string) => p === ment.name + '_id')
401
+ ?? parts.find((p: string) => p.endsWith('_id'))
402
+ ?? parts[parts.length - 1]
403
+ }
404
+
405
+
406
+ // The composite half of the id descriptor, or `{}` for the ordinary case.
407
+ //
408
+ // Emitted ONLY for a genuinely composite id (two or more addressing
409
+ // parameters). A single-parameter entity already round-trips through one
410
+ // `id` and gains nothing from carrying a one-element `parts`, so its
411
+ // descriptor is left exactly as it was — no existing model output moves.
412
+ function compositeId(
413
+ ment: ModelEntity,
414
+ gent?: any,
415
+ ): { parts?: string[], sep?: string } {
416
+ const gid = gent?.id
417
+ const sep = null != gid?.sep && '' !== String(gid.sep) ? String(gid.sep) : ID_SEP
418
+
419
+ // `composite: false` turns the inference off. A boolean rather than an
420
+ // empty `parts`, because aontu resolves an empty list to nothing and the
421
+ // key would arrive absent — indistinguishable from never having been set.
422
+ if (null != gid && false === gid.composite) {
423
+ // DISABLING COMPOSITE MUST NOT DISABLE THE ID. The correction says these
424
+ // adjacent parameters are not a compound key; it does not say the record
425
+ // has no key. Returning a bare `{}` left an entity whose response has no
426
+ // literal `id` with no descriptor at all — the false positive removed and
427
+ // nothing identifying the real key.
428
+ //
429
+ // WHICH of the adjacent parameters is that key is decided by the same
430
+ // id-finding rules apidef uses elsewhere, not by position. Taking the
431
+ // terminal one picked `archive_format` for
432
+ // `/artifacts/{artifact_id}/{archive_format}` — the modifier, precisely
433
+ // the false positive the correction exists to undo.
434
+ return { single: singleKeyOf(ment, identityParams(ment)) } as any
435
+ }
436
+
437
+ if (null != gid && null != gid.parts) {
438
+ const given = (gid.parts as any[])
439
+ .filter((p: any) => null != p && '' !== String(p))
440
+ .map((p: any) => String(p))
441
+ return 1 < given.length ? { parts: given, sep } : {}
442
+ }
443
+
444
+ const parts = identityParams(ment)
445
+ return 1 < parts.length ? { parts, sep } : {}
446
+ }
447
+
448
+
125
449
  // True when any of the entity's own operation points declares an `id`
126
450
  // parameter — i.e. the API addresses this entity by id, whether or not its
127
451
  // response schema declares an id field.
package/src/types.ts CHANGED
@@ -253,6 +253,21 @@ type GuideMetrics = {
253
253
 
254
254
 
255
255
  type GuideEntity = {
256
+ // COMPOSITE IDENTITY CORRECTION, the guide's say over how an entity is
257
+ // addressed. apidef infers a compound key from adjacent path parameters,
258
+ // which is right far more often than not and cannot always be right:
259
+ // `/…/artifacts/{artifact_id}/{archive_format}` reads as composite and is
260
+ // not. Stating it here is the documented correction surface (ADR-002).
261
+ //
262
+ // `parts` names the compound key outright; `composite: false` says these
263
+ // adjacent parameters are not one (the record still has a key); `sep`
264
+ // changes the separator without restating the parts.
265
+ id?: {
266
+ parts?: string[]
267
+ sep?: string
268
+ composite?: boolean
269
+ }
270
+
256
271
  name: string
257
272
  orig: string
258
273
  // `false` drops the entity downstream (transform/entity.ts). Emitted by