@voxgig/apidef 8.10.0 → 8.12.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.
Files changed (79) hide show
  1. package/dist/apidef.d.ts +4 -1
  2. package/dist/apidef.js +12 -34
  3. package/dist/apidef.js.map +1 -1
  4. package/dist/builder/entity/entity.js +0 -33
  5. package/dist/builder/entity/entity.js.map +1 -1
  6. package/dist/builder/entity/info.js +0 -4
  7. package/dist/builder/entity/info.js.map +1 -1
  8. package/dist/builder/flow.d.ts +4 -1
  9. package/dist/builder/flow.js +36 -27
  10. package/dist/builder/flow.js.map +1 -1
  11. package/dist/guide/graphql01.js +0 -55
  12. package/dist/guide/graphql01.js.map +1 -1
  13. package/dist/guide/guide.js +3 -126
  14. package/dist/guide/guide.js.map +1 -1
  15. package/dist/guide/heuristic01.js +40 -181
  16. package/dist/guide/heuristic01.js.map +1 -1
  17. package/dist/parse/graphql.js +0 -25
  18. package/dist/parse/graphql.js.map +1 -1
  19. package/dist/parse.js +51 -56
  20. package/dist/parse.js.map +1 -1
  21. package/dist/resolved.d.ts +23 -0
  22. package/dist/resolved.js +92 -0
  23. package/dist/resolved.js.map +1 -0
  24. package/dist/resolver.js +0 -2
  25. package/dist/resolver.js.map +1 -1
  26. package/dist/transform/args.js +2 -25
  27. package/dist/transform/args.js.map +1 -1
  28. package/dist/transform/casecollide.d.ts +3 -0
  29. package/dist/transform/casecollide.js +67 -0
  30. package/dist/transform/casecollide.js.map +1 -0
  31. package/dist/transform/contract.js +43 -38
  32. package/dist/transform/contract.js.map +1 -1
  33. package/dist/transform/entity.js +0 -56
  34. package/dist/transform/entity.js.map +1 -1
  35. package/dist/transform/field.js +19 -352
  36. package/dist/transform/field.js.map +1 -1
  37. package/dist/transform/flowstep.js +0 -49
  38. package/dist/transform/flowstep.js.map +1 -1
  39. package/dist/transform/graphql.js +0 -36
  40. package/dist/transform/graphql.js.map +1 -1
  41. package/dist/transform/operation.js +0 -23
  42. package/dist/transform/operation.js.map +1 -1
  43. package/dist/transform/top.js +1 -128
  44. package/dist/transform/top.js.map +1 -1
  45. package/dist/transform.js.map +1 -1
  46. package/dist/tsconfig.tsbuildinfo +1 -1
  47. package/dist/types.d.ts +2 -0
  48. package/dist/types.js.map +1 -1
  49. package/dist/utility.d.ts +2 -1
  50. package/dist/utility.js +17 -316
  51. package/dist/utility.js.map +1 -1
  52. package/model/apidef.aon +20 -113
  53. package/model/guide.aon +1 -32
  54. package/package.json +4 -4
  55. package/src/apidef.ts +22 -33
  56. package/src/builder/entity/entity.ts +0 -33
  57. package/src/builder/entity/info.ts +0 -4
  58. package/src/builder/flow.ts +39 -24
  59. package/src/desc.ts +0 -22
  60. package/src/guide/graphql01.ts +0 -75
  61. package/src/guide/guide.ts +5 -127
  62. package/src/guide/heuristic01.ts +47 -182
  63. package/src/model.ts +6 -85
  64. package/src/parse/graphql.ts +0 -25
  65. package/src/parse.ts +61 -56
  66. package/src/resolved.ts +136 -0
  67. package/src/resolver.ts +0 -2
  68. package/src/transform/args.ts +2 -25
  69. package/src/transform/casecollide.ts +80 -0
  70. package/src/transform/contract.ts +44 -34
  71. package/src/transform/entity.ts +0 -56
  72. package/src/transform/field.ts +28 -353
  73. package/src/transform/flowstep.ts +0 -49
  74. package/src/transform/graphql.ts +0 -36
  75. package/src/transform/operation.ts +0 -23
  76. package/src/transform/top.ts +1 -131
  77. package/src/transform.ts +0 -1
  78. package/src/types.ts +4 -13
  79. package/src/utility.ts +18 -314
@@ -6,6 +6,7 @@ import type { TransformResult, Transform } from '../transform'
6
6
 
7
7
  import {
8
8
  validator, canonizeField, inferFieldType, normalizeFieldName, envelopeProp,
9
+ canonizeCmpName,
9
10
  scanUntaggedUnion, firstSentence,
10
11
  } from '../utility'
11
12
 
@@ -49,24 +50,6 @@ const fieldTransform: Transform = async function(
49
50
  const mpoints = mop.points
50
51
 
51
52
  for (let mpoint of mpoints) {
52
- // ACTION POINTS CONTRIBUTE NO FIELDS, as `identityParams` and
53
- // `responseCandidates` below already assume: an action is a VERB
54
- // dispatched by `$action`, so its request body is that verb's
55
- // arguments and its response is that verb's result. Neither says
56
- // anything about what a record of this entity carries.
57
- //
58
- // A custom action lands under `create`, so every point of it was
59
- // harvested along with the plain create's. solar's planet — four
60
- // properties in the spec — came out with ten fields, the extra six
61
- // being `{start, stop}` and `{forbid, why}` from the two action
62
- // bodies and `{ok, state}` from their shared response envelope.
63
- // Those reached the generated `Planet` type, its create and update
64
- // data types, and the per-entity field table in the generated
65
- // reference, none of which a planet has ever carried.
66
- if (null != mpoint?.select?.['$action']) {
67
- continue
68
- }
69
-
70
53
  const opfields = resolveOpFields(ment, mop, mpoint, def)
71
54
 
72
55
  for (let opfield of opfields) {
@@ -86,42 +69,11 @@ const fieldTransform: Transform = async function(
86
69
  return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
87
70
  })
88
71
 
89
- // Mark the entity as having an id only when the spec actually declares one.
90
- // Downstream (test generators, fixture builders) gate id-specific code on
91
- // this presence so that public read-only APIs without ids don't get
92
- // bogus id assertions.
93
- // COMPOSITE FIRST, because a compound key need not come with an `id`.
94
- //
95
- // An entity addressed by `{owner}/{repo}` whose response carries only
96
- // `owner` and `name` has no field literally named `id`, and its adjacent
97
- // placeholders are left unrenamed so `addressedById` is false too.
98
- // Neither branch below then ran, so the entity got NO id descriptor and
99
- // even an explicit `guide.entity.<name>.id.parts` was silently ignored —
100
- // while the Go port, which initialises a descriptor unconditionally,
101
- // emitted the composite. The ports disagreed on exactly the shape this
102
- // feature exists for.
103
72
  const gent = guide?.entity?.[ment.name]
104
73
  const composite = compositeId(ment, gent, def)
105
74
 
106
75
  const idField = fields.find((f: ModelField) => 'id' === f.name)
107
76
 
108
- // A COMPOSITE ID IS A STRING, whatever the API's own `id` field is —
109
- // AND THE API'S OWN id IS KEPT.
110
- //
111
- // github's repo declares `id` as an integer, its global database id,
112
- // while the composite identity is `owner/repo`. Two facts have to
113
- // survive: `id` must hold a string, because that is what the joined
114
- // value is and what every generated type has to store; and the spec's
115
- // numeric property must not be silently reinterpreted, because a
116
- // consumer that wants the database id is entitled to it with its own
117
- // type and format intact.
118
- //
119
- // So the API's field MOVES to `<api>_id` rather than being rewritten in
120
- // place, carrying its type, format and per-op overrides with it, and the
121
- // entity's `alias.field` map records where it went. Retyping in place
122
- // (the first attempt) claimed the server's numeric id was a string;
123
- // leaving it alone made `id.field` name a declaration the runtime value
124
- // cannot satisfy. Moving it is the only option that lies about neither.
125
77
  if (null != composite.parts && null != idField && !scalarStringField(idField)) {
126
78
  const idf: any = idField
127
79
  const apiname = String((model as any)?.name || 'api')
@@ -187,12 +139,6 @@ const fieldTransform: Transform = async function(
187
139
  ment.id = { name: 'id', field: 'id', ...composite }
188
140
  }
189
141
  else if (addressedById(ment)) {
190
- // The FIELD as well as the descriptor. An entity addressed by id has an
191
- // id at runtime — the test fixture seeds one, and the SDK sends it — so
192
- // a model that declares the descriptor without the field makes the
193
- // generated TYPE disagree with the generated TEST: trello's Option,
194
- // Reaction and Sticker compiled to `TS2339: Property 'id' does not
195
- // exist` the moment the test started assigning data.id.
196
142
  fields.push({
197
143
  name: 'id',
198
144
  type: '`$STRING`',
@@ -201,26 +147,6 @@ const fieldTransform: Transform = async function(
201
147
  fields.sort((a: ModelField, b: ModelField) =>
202
148
  a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
203
149
 
204
- // ADDRESSABLE BY ID WITHOUT DECLARING ONE AS A FIELD.
205
- //
206
- // The rule above reads the RESPONSE schema, and plenty of real entities
207
- // are addressed by an id their response never repeats. github's
208
- // private_registry is one: PATCH /orgs/{org}/private-registries/{secret_name}
209
- // renames secret_name to id, so the entity is addressed by id on every
210
- // one of its own routes, while its schema declares only created_at, key,
211
- // name, url and friends.
212
- //
213
- // Downstream that absence is not cosmetic. TestEntity gates
214
- // `data.id = <created>.id` on THIS descriptor, so the generated update
215
- // carried no id at all, the test mock's selector fell back to whatever
216
- // else was in reqdata (org_id), matched no single record, and the flow
217
- // failed with a 404 that named nothing to do with ids.
218
- //
219
- // An entity whose own points take an `id` param IS addressable by id;
220
- // that is the property the downstream generators actually want. Entities
221
- // with neither a field nor an id param — the read-only public APIs the
222
- // rule above was written for — still get no descriptor, so they still
223
- // get no id assertions.
224
150
  ment.id = { name: 'id', field: 'id', ...composite }
225
151
  }
226
152
 
@@ -232,14 +158,6 @@ const fieldTransform: Transform = async function(
232
158
 
233
159
 
234
160
 
235
- // The separator that joins a composite id into one string.
236
- //
237
- // A forward slash cannot occur inside a single path segment — a raw `/`
238
- // would end the segment, and a value that legitimately contains one arrives
239
- // percent-encoded as `%2F` — so joining on it can never be ambiguous, and
240
- // splitting on it can never over-split. That is what makes the composite id
241
- // safe to carry as a single opaque string, which is the property the SDK and
242
- // Seneca entities are built on.
243
161
  const ID_SEP = '/'
244
162
 
245
163
  // Subfields that conventionally carry the identifying value of a nested
@@ -255,36 +173,6 @@ const NESTED_ID_KEYS = ['login', 'slug', 'name', 'key', 'id']
255
173
  const ID_OPS = ['load', 'update', 'patch', 'remove']
256
174
 
257
175
 
258
- // The parameters that TOGETHER name one record: the trailing run of
259
- // ADJACENT variable segments on the addressing route.
260
- //
261
- // ADJACENCY IS THE WHOLE TEST, and it is what separates a compound key from
262
- // ordinary parent/child nesting:
263
- //
264
- // /repos/{owner}/{repo} -> owner, repo COMPOSITE
265
- // /api/planet/{planet_id}/moon/{moon_id} -> moon_id single
266
- // /repos/{owner}/{repo}/pulls/{pull_number} -> pull_number single
267
- //
268
- // A literal segment between two variables names a SUB-COLLECTION, so the
269
- // earlier variable scopes the later one — `planet_id` says which planet's
270
- // moons, and `moon_id` alone identifies the moon. Two variables with nothing
271
- // between them address no sub-collection: neither value names anything on
272
- // its own, and only the pair identifies a repository.
273
- //
274
- // Taking every variable on the path instead was tried first and is wrong on
275
- // most real specs — it made `moon` (planet_id + moon_id), petstore's `order`,
276
- // `pet` and `user`, and taxonomy's `domain` and `kingdom` all falsely
277
- // composite, which the apidef-validate goldens caught immediately. Nested
278
- // resources are the common shape; compound keys are the exception, and
279
- // adjacency is the thing that actually distinguishes them.
280
- //
281
- // Read from the op that names a single record, never from `list`: a
282
- // collection route's path params are the entity's parents. A point ending in
283
- // a literal is a verb ON the record (`.../{number}/merge`) and carries the
284
- // same variables, so it is a fallback rather than a different answer.
285
- // Walk back from a point's end, collecting variables until a literal stops
286
- // the run. That literal is the sub-collection boundary; anything before it
287
- // scopes this record rather than naming it.
288
176
  function trailingVars(point: any): string[] {
289
177
  const segs = ((point?.segments || []) as any[]).filter((s: any) => null != s)
290
178
  const run: string[] = []
@@ -301,15 +189,6 @@ function trailingVars(point: any): string[] {
301
189
 
302
190
 
303
191
  function identityParams(ment: ModelEntity): string[] {
304
- // EVERY ID-BEARING OP AT ONCE, not the first one that offers a candidate.
305
- //
306
- // These four ops all address a single record, so all four describe the
307
- // same identity — but they do not all carry the same routes. gitlab's
308
- // `project` has `/api/v4/projects/{id}` under `remove` alone, while its
309
- // `load` carries only sub-resources like
310
- // `/api/v4/projects/{id}/uploads/{secret}/{filename}`. Returning on the
311
- // first op with any candidate therefore made a PROJECT identified by
312
- // `secret/filename`. The op order is now only a tie-break.
313
192
  const cands: any[] = []
314
193
 
315
194
  for (let o = 0; o < ID_OPS.length; o++) {
@@ -331,22 +210,6 @@ function identityParams(ment: ModelEntity): string[] {
331
210
  run,
332
211
  // Segments BEFORE the run: how much parent scope the route needs.
333
212
  scope: ((pt.segments || []).length - run.length),
334
- // DOES THE RUN END IN THE RECORD'S OWN KEY? Then it is the
335
- // record's address and nothing further is needed.
336
- //
337
- // This transform RENAMES that parameter to `id`, so a run ending in
338
- // it is this port's own statement of what identifies the record —
339
- // and the composite inference must not contradict it.
340
- // `/gists/{gist_id}` becomes `/gists/{id}` and is a gist;
341
- // `/gists/{gist_id}/{sha}` is a REVISION of one, and won on key
342
- // length alone, so a gist came out keyed `gist_id/sha` while the
343
- // generated SDK's own load match takes the single parameter. The
344
- // same contradiction gave cloudsmith's repo and vulnerability
345
- // compound keys their SDKs never address them by.
346
- //
347
- // Deliberately narrow: exactly `id` or an unrenamed `<entity>_id`,
348
- // never any `*_id`. `actor_type/actor_id` IS a compound key, and a
349
- // looser test breaks it.
350
213
  own: 'id' === run[run.length - 1] ||
351
214
  (ment as any).name + '_id' === run[run.length - 1],
352
215
  order: o,
@@ -354,34 +217,6 @@ function identityParams(ment: ModelEntity): string[] {
354
217
  }
355
218
  }
356
219
 
357
- // WHICH ROUTE IS THE RECORD'S OWN ADDRESS.
358
- //
359
- // An entity gathers every route that reads it, and in a large
360
- // specification most of those are sub-resources. Three earlier rules were
361
- // measured against the validation corpus, and each is wrong:
362
- //
363
- // The FIRST route listed gave github's `repo` the single part
364
- // `subject_digest`, from
365
- // `/repos/{owner}/{repo}/attestations/{subject_digest}` — no compound
366
- // key at all, for the entity this feature exists for. Invisible on a
367
- // small spec, where the first item route IS the record's own.
368
- //
369
- // The SHORTEST route ending in a variable took cloudsmith's
370
- // `/vulnerabilities/{owner}/` — a LIST of an owner's vulnerabilities —
371
- // and cut a four-part key down to `owner`, dropping three more
372
- // composites. Ending in a variable does not make a route an address.
373
- //
374
- // The LONGEST trailing run took
375
- // `/orgs/{org}/teams/{team_slug}/repos/{owner}/{repo}` and made a TEAM
376
- // identified by `owner/repo`. A deep sub-resource can carry more
377
- // adjacent variables than the record's own route does.
378
- //
379
- // What separates them is PARENT SCOPE: the record's own route is the
380
- // least-qualified one that names it, and among equally-qualified routes
381
- // the one carrying the fullest key. `/repos/{owner}/{repo}` is qualified
382
- // by one segment and the attestations route by four; `/teams/{team_id}`
383
- // by one and the org-team-repo route by five; cloudsmith's vulnerability
384
- // routes are all qualified by one, so the fullest of them wins.
385
220
  const best = cands.reduce((b: any, c: any) => {
386
221
  if (null == b) {
387
222
  return c
@@ -401,34 +236,6 @@ function identityParams(ment: ModelEntity): string[] {
401
236
  return null == best ? [] : best.run
402
237
  }
403
238
 
404
- // THE PROPERTY MAPS A RESPONSE COULD BE DESCRIBING, best first.
405
- //
406
- // BOTH SPEC DIALECTS. An OpenAPI 3 response carries its schema under
407
- // `content['application/json']`; a SWAGGER 2 response carries it directly as
408
- // `schema`. Reading only the first resolved nothing for every Swagger 2 spec
409
- // in the validation corpus.
410
- //
411
- // JSON ONLY, where there is a choice. An operation may declare several media
412
- // types with different schemas, and field extraction uses the JSON one — so
413
- // picking whichever came first in source order could infer a path from an XML
414
- // or binary schema that the actual JSON record does not have.
415
- //
416
- // `allOf` IS EXPANDED, because a response that composes its entity that way
417
- // has neither `properties` nor `items` of its own. field extraction expands
418
- // it; not doing so here meant the fields were present while the id could not
419
- // be reconstructed.
420
- //
421
- // ONLY THE ENVELOPE IS DESCENDED, via the same `envelopeProp` rule field
422
- // extraction uses. Descending every object-valued property instead treats an
423
- // ordinary nested object as a whole record: for `{ slug, metadata: { tenant } }`
424
- // addressed by `{tenant}/{slug}`, `tenant` resolved to `tenant` rather than
425
- // `metadata.tenant` — a confidently wrong path, which is worse than no
426
- // mapping at all.
427
- //
428
- // ACTION POINTS ARE SKIPPED, as `identityParams` skips them: an action's
429
- // response is a verb's result, not a representation of the entity, so a field
430
- // that happens to appear there says nothing about what a returned record
431
- // carries.
432
239
  function responseCandidates(ment: ModelEntity, def: any): any[] {
433
240
  const out: any[] = []
434
241
  const seen = new Set<any>()
@@ -509,7 +316,6 @@ function responseCandidates(ment: ModelEntity, def: any): any[] {
509
316
  }
510
317
  }
511
318
 
512
- // Swagger 2 puts it here.
513
319
  add(resdef.schema, opname)
514
320
  }
515
321
  }
@@ -519,6 +325,19 @@ function responseCandidates(ment: ModelEntity, def: any): any[] {
519
325
  }
520
326
 
521
327
 
328
+
329
+ function namesEntity(schema: any, ment: ModelEntity): boolean {
330
+ const xref = schema?.['x-ref']
331
+
332
+ if ('string' !== typeof xref) {
333
+ return false
334
+ }
335
+
336
+ const cmp = xref.slice(xref.lastIndexOf('/') + 1)
337
+
338
+ return canonizeCmpName(cmp) === ment.name
339
+ }
340
+
522
341
  // A `$ref` followed one hop, or the schema itself. apidef resolves most refs
523
342
  // before this stage; this covers the ones that survive on a nested property.
524
343
  function resolveRef(schema: any, def: any): any {
@@ -583,12 +402,6 @@ function partAliases(ment: ModelEntity, part: string): string[] {
583
402
  }
584
403
 
585
404
 
586
- // Where one part is carried in a given property map, or null.
587
- //
588
- // The four rules, in order, each a fact the spec states: a scalar property of
589
- // that name; the part naming this entity, resolved to `name`; a scalar
590
- // `<part>_name` / `_login` / `_slug`; or an object property's conventional
591
- // identifying subfield.
592
405
  function resolvePart(
593
406
  ment: ModelEntity,
594
407
  part: string,
@@ -637,52 +450,15 @@ function resolvePart(
637
450
  }
638
451
 
639
452
 
640
- // WHERE EACH COMPOSITE PART'S VALUE LIVES IN A RESPONSE.
641
- //
642
- // The parts are PATH PARAMETER names; a response names its fields whatever it
643
- // likes. Resolving one to the other is what lets an SDK put an id on a record
644
- // the API returned, rather than only address a record whose id it was given.
645
- //
646
- // The rules, in order, and each of them is a fact about the spec rather than
647
- // a guess:
648
- //
649
- // 1. a scalar field of exactly that name -> itself
650
- // 2. the part names this entity, and there is a `name` -> `name`
651
- // (`/repos/{owner}/{repo}` on entity `repo`, whose response calls the
652
- // repository `name`)
653
- // 3. a scalar `<part>_name` / `<part>_login` / `<part>_slug`
654
- // 4. an OBJECT field of that name -> `<part>.<conventional key>`
655
- // (`owner` is a user object; the value is `owner.login`)
656
- //
657
- // A part none of these resolve is left OUT. Downstream then knows the id
658
- // cannot be rebuilt for that entity and can say so, which is better than a
659
- // confidently wrong id on a real record. guide.aon can state it instead.
660
453
  function identityFrom(
661
454
  ment: ModelEntity,
662
455
  parts: string[],
663
456
  def: any,
664
457
  ): Record<string, string> {
665
- // THE RESPONSE SCHEMA IS THE AUTHORITY, not `ment.fields`.
666
- //
667
- // `ment.fields` is merged across load, create, update and list, so a part
668
- // that exists only in a REQUEST BODY appears there too. Resolving against
669
- // it recorded such a part in `from` as though a returned record carried it,
670
- // and a consumer then rebuilt an id from a property the response never
671
- // sends — worse than leaving the part unresolved, which at least says so.
672
- //
673
- // Candidate property maps, in order: the response's own properties, then
674
- // one level into an envelope. A response that wraps the record
675
- // (`{ item: {...} }`, `{ data: [ {...} ] }`) states the record's fields one
676
- // level in, and searching only the wrapper found nothing.
677
458
  const candidates = responseCandidates(ment, def)
678
459
  const out: Record<string, string> = {}
679
460
 
680
461
  for (const part of parts) {
681
- // THE WIRE NAME AS WELL AS THE MODEL NAME. `identityParams` reads the
682
- // RENAMED parameter off the path segments, while a response keeps its own
683
- // casing — so a `tenantKey` renamed to `tenant_key` was looked up under a
684
- // name the response does not use, and the mapping was dropped for every
685
- // camel-cased or depluralized parameter.
686
462
  const aliases = partAliases(ment, part)
687
463
 
688
464
  let found: string | null = null
@@ -710,15 +486,6 @@ function scalarStringField(f: any): boolean {
710
486
  }
711
487
 
712
488
 
713
- // WHICH PARAMETER IS THE RECORD'S OWN KEY, among several that looked
714
- // adjacent. The same shape apidef's id handling recognises everywhere else:
715
- //
716
- // 1. one named exactly `id`
717
- // 2. `<entity>_id` — the entity's own id, however the path spells it
718
- // 3. any `*_id` — an id by name
719
- // 4. failing all that, the terminal parameter
720
- //
721
- // Position is the LAST resort, not the first.
722
489
  function singleKeyOf(ment: ModelEntity, parts: string[]): string | undefined {
723
490
  if (0 === parts.length) {
724
491
  return undefined
@@ -731,12 +498,6 @@ function singleKeyOf(ment: ModelEntity, parts: string[]): string | undefined {
731
498
  }
732
499
 
733
500
 
734
- // The composite half of the id descriptor, or `{}` for the ordinary case.
735
- //
736
- // Emitted ONLY for a genuinely composite id (two or more addressing
737
- // parameters). A single-parameter entity already round-trips through one
738
- // `id` and gains nothing from carrying a one-element `parts`, so its
739
- // descriptor is left exactly as it was — no existing model output moves.
740
501
  function compositeId(
741
502
  ment: ModelEntity,
742
503
  gent?: any,
@@ -749,17 +510,6 @@ function compositeId(
749
510
  // empty `parts`, because aontu resolves an empty list to nothing and the
750
511
  // key would arrive absent — indistinguishable from never having been set.
751
512
  if (null != gid && false === gid.composite) {
752
- // DISABLING COMPOSITE MUST NOT DISABLE THE ID. The correction says these
753
- // adjacent parameters are not a compound key; it does not say the record
754
- // has no key. Returning a bare `{}` left an entity whose response has no
755
- // literal `id` with no descriptor at all — the false positive removed and
756
- // nothing identifying the real key.
757
- //
758
- // WHICH of the adjacent parameters is that key is decided by the same
759
- // id-finding rules apidef uses elsewhere, not by position. Taking the
760
- // terminal one picked `archive_format` for
761
- // `/artifacts/{artifact_id}/{archive_format}` — the modifier, precisely
762
- // the false positive the correction exists to undo.
763
513
  return { single: singleKeyOf(ment, identityParams(ment)) } as any
764
514
  }
765
515
 
@@ -834,19 +584,6 @@ function resolveOpFields(
834
584
  req: !!fielddef.required,
835
585
  op: {},
836
586
  }
837
- // Carry the spec's own words for the field, when it has any.
838
- //
839
- // Every generated per-entity table has a Description column and every cell
840
- // was blank, because nothing ever read the property `description` the spec
841
- // supplies. Trimmed, and only when it is a non-empty string: a whitespace
842
- // or non-string value would put a meaningless cell where an empty one is
843
- // honest.
844
- // ONE LINE, not the whole description. Every generated Readme drops this
845
- // straight into a markdown table cell, where a raw newline ends the row
846
- // and orphans the rest of the table — and specs put bullet lists, fenced
847
- // examples and multi-paragraph notes in `description`. firstSentence is
848
- // the same reduction the API summary uses, so `short` means the same
849
- // thing wherever it appears.
850
587
  const fdesc = (fielddef as any).description
851
588
  if ('string' === typeof fdesc && '' !== fdesc.trim()) {
852
589
  const short = firstSentence(fdesc)
@@ -855,20 +592,6 @@ function resolveOpFields(
855
592
  }
856
593
  }
857
594
 
858
- // SPEC FACTS ABOUT THE FIELD, carried through verbatim.
859
- //
860
- // These four are declared by OpenAPI on the property and were being
861
- // dropped on the floor. `readOnly` is the one that matters most: it is
862
- // the difference between a field a client MAY send and one it may not,
863
- // and nothing else in the model says which — so every generator has been
864
- // putting server-assigned fields into the type a caller fills in.
865
- //
866
- // ONLY WHEN THE SPEC SAYS SO, and for the booleans only when TRUE. Each
867
- // defaults to false in OpenAPI, so an absent key and an explicit `false`
868
- // carry the same information; emitting the false ones would add a key to
869
- // every field of every model and say nothing. Same discipline as
870
- // `short`: absent means "the spec did not say", never "apidef dropped
871
- // it".
872
595
  for (const flag of ['readOnly', 'writeOnly', 'deprecated'] as const) {
873
596
  if (true === (fielddef as any)[flag]) {
874
597
  mfield[flag] = true
@@ -918,7 +641,6 @@ function findGraphqlFieldDefs(
918
641
 
919
642
  const out: SchemaDef[] = []
920
643
 
921
- // Sorted by construction in parse/graphql.ts, so output stays byte-stable.
922
644
  for (const fname of Object.keys(gtype.fields)) {
923
645
  const f = gtype.fields[fname]
924
646
 
@@ -941,17 +663,10 @@ function findGraphqlFieldDefs(
941
663
  // custom scalars left unconstrained.
942
664
  type: 'ENUM' === kind ? 'string' : gqlFieldType(f.type),
943
665
  required: f.reqd,
944
- // GraphQL puts the field's own words on GqlField.desc (see
945
- // parse/graphql.ts). resolveOpFields reads `description`, the OpenAPI
946
- // spelling, so name it that here rather than teaching the reader two.
947
666
  description: f.desc,
948
667
  } as any)
949
668
  }
950
669
  else if (('OBJECT' === kind || 'INTERFACE' === kind) && !f.list) {
951
- // To-one relation. The default fragment selects `team { id }`, so the
952
- // response carries a nested stub object — declare it as such. Naming a
953
- // flat `team_id` here would advertise a field the wire never returns,
954
- // since nothing flattens the response.
955
670
  const idField = ftype.fields?.id
956
671
  if (null != idField) {
957
672
  out.push({
@@ -968,12 +683,6 @@ function findGraphqlFieldDefs(
968
683
  }
969
684
 
970
685
 
971
- // GraphQL named type -> the type names the field typing understands.
972
- //
973
- // Built-ins only: a custom scalar (JSON, JSONObject, Upload, ...) can hold
974
- // any JSON value, so advertising it as a string would misdescribe the data
975
- // and make generated validation reject values the schema accepts. Enums are
976
- // mapped by the caller, which knows they are strings.
977
686
  function gqlFieldType(typeName: string): string | undefined {
978
687
  return 'Int' === typeName ? 'integer' :
979
688
  'Float' === typeName ? 'number' :
@@ -984,20 +693,24 @@ function gqlFieldType(typeName: string): string | undefined {
984
693
 
985
694
 
986
695
  function findFieldDefs(
987
- _ment: ModelEntity,
696
+ ment: ModelEntity,
988
697
  mop: ModelOp,
989
698
  mpoint: ModelPoint,
990
699
  def: any
991
700
  ): SchemaDef[] {
992
701
  if ('graphql' === mpoint.kind) {
993
- return findGraphqlFieldDefs(_ment, mpoint, def)
702
+ return findGraphqlFieldDefs(ment, mpoint, def)
994
703
  }
995
704
 
705
+ // A verb, rather than an address: see the call site in the transform.
706
+ const isAction = null != (mpoint as any)?.select?.['$action']
707
+
996
708
  const fielddefs: SchemaDef[] = []
709
+
997
710
  const pathdef = def.paths[mpoint.orig]
998
711
 
999
712
  const method = mpoint.method.toLowerCase()
1000
- const opdef: any = pathdef[method]
713
+ const opdef: any = (pathdef as any)?.[method]
1001
714
 
1002
715
  if (opdef) {
1003
716
  const responses = opdef.responses
@@ -1009,13 +722,6 @@ function findFieldDefs(
1009
722
  fieldSets = getx(responses, '200 content "application/json" schema') ??
1010
723
  getx(responses, '200 schema')
1011
724
  if ('list' == mop.name) {
1012
- // List responses commonly come in three shapes:
1013
- // 1. direct array — { type: array, items: { ...item } }
1014
- // 2. wrapper object — { properties: { items: [Item], page, ... } }
1015
- // (a single array-of-object property inside an object schema)
1016
- // 3. legacy "list of created items" under 201
1017
- // Resolve to the inner item schema when we can identify one
1018
- // unambiguously; otherwise fall through to the 200 schema as-is.
1019
725
  const unwrapped = unwrapArrayWrapper(fieldSets)
1020
726
  if (unwrapped) {
1021
727
  fieldSets = unwrapped
@@ -1031,14 +737,6 @@ function findFieldDefs(
1031
737
  getx(responses, '201 schema')
1032
738
  }
1033
739
 
1034
- // Single-entity responses get the same treatment the list branch above
1035
- // already gives collections: a body that is only an envelope around the
1036
- // entity — `{item: {...}}` — describes the WRAPPER, not the entity, so
1037
- // its sole property would otherwise be harvested as a field. That is
1038
- // how an entity `todoitem` ended up with a required `item` field of
1039
- // type object, which then appeared in the generated create/update data
1040
- // types. envelopeProp applies the same two rules used to pick the
1041
- // response transform, so the field list and the transform agree.
1042
740
  if ('list' != mop.name) {
1043
741
  const envelope = envelopeProp(fieldSets?.properties, mop.name)
1044
742
  if (null != envelope) {
@@ -1047,11 +745,16 @@ function findFieldDefs(
1047
745
  }
1048
746
  }
1049
747
 
748
+ if (isAction && !namesEntity(fieldSets, ment)) {
749
+ return fielddefs
750
+ }
751
+
1050
752
  // A QUERY (RFC 10008) request body is a filter/query schema, not the
1051
753
  // entity shape, so it must not contribute entity fields. Fields for a
1052
754
  // QUERY op come from its response only. Other methods (POST/PUT/PATCH)
1053
- // carry the entity in the body, so merge as usual.
1054
- if (requestBody && 'query' !== method) {
755
+ // carry the entity in the body, so merge as usual -- except for an
756
+ // action, whose body is the verb's arguments and never the record.
757
+ if (requestBody && 'query' !== method && !isAction) {
1055
758
  fieldSets = [
1056
759
  fieldSets,
1057
760
  getx(requestBody, 'content "application/json" schema') ??
@@ -1149,7 +852,6 @@ function findExampleObject(opdef: any): any {
1149
852
  example = getx(resdef, 'examples "application/json"')
1150
853
  if (null != example && 'object' === typeof example) return unwrapExample(example)
1151
854
 
1152
- // Swagger 2.0: schema.example
1153
855
  example = getx(resdef, 'schema example')
1154
856
  if (null != example && 'object' === typeof example) return unwrapExample(example)
1155
857
 
@@ -1166,16 +868,6 @@ function unwrapExample(example: any): any {
1166
868
  }
1167
869
 
1168
870
 
1169
- // unwrapArrayWrapper inspects a list-response schema and, when it is an
1170
- // object with a single array-of-object-schema property (e.g.
1171
- // { boards: [Board] }, { items: [Foo], page, total, ... }), returns the
1172
- // inner item schema so that field resolution sees the actual entity
1173
- // properties rather than the wrapper's bookkeeping.
1174
- //
1175
- // Returns null if the input is not unambiguously such a wrapper:
1176
- // - schema is already an array → return null (let caller use it directly)
1177
- // - no array-of-object-schema property → return null
1178
- // - more than one array-of-object-schema property → ambiguous, return null
1179
871
  function unwrapArrayWrapper(schema: any): any {
1180
872
  if (null == schema || 'object' !== typeof schema) return null
1181
873
  // Direct list shape — caller can resolve from items directly.
@@ -1227,27 +919,10 @@ function mergeField(
1227
919
  }
1228
920
  }
1229
921
 
1230
- // Field identity is first-writer-wins, but a DESCRIPTION is not part of
1231
- // identity: the op that first names a field is often not the one that
1232
- // documents it (a load response referencing a bare component, a create body
1233
- // referencing the annotated one). Take the first non-empty description in
1234
- // opFieldPrecedence order and keep it — dropping it left a blank cell in
1235
- // every generated table while the spec had the words all along.
1236
922
  if (null == existingField.short && null != newField.short) {
1237
923
  existingField.short = newField.short
1238
924
  }
1239
925
 
1240
- // The spec facts merge the same way, and for the same reason: one schema
1241
- // annotates the field and another references it bare, so taking the first
1242
- // declaration in opFieldPrecedence order is what finds the annotation.
1243
- //
1244
- // THE PRECEDENCE ORDER PUTS `load` FIRST, WHICH IS THE SAFE DIRECTION HERE.
1245
- // A field the response schema marks readOnly and a request body also lists
1246
- // is a self-contradictory spec — OpenAPI says a client must not send a
1247
- // readOnly property at all — and this resolves it by believing the
1248
- // restriction rather than the omission. Marking a writable field readOnly
1249
- // costs a caller one field; the other way round sends a value the server
1250
- // rejects.
1251
926
  for (const flag of ['readOnly', 'writeOnly', 'deprecated', 'format'] as const) {
1252
927
  if (null == existingField[flag] && null != newField[flag]) {
1253
928
  (existingField as any)[flag] = newField[flag]