@voxgig/apidef 8.4.0 → 8.5.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/dist/model.d.ts +1 -0
- package/dist/transform/field.js +277 -4
- package/dist/transform/field.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types.d.ts +1 -0
- package/dist/types.js.map +1 -1
- package/model/apidef.aon +9 -0
- package/model/guide.aon +4 -0
- package/package.json +1 -1
- package/src/model.ts +13 -0
- package/src/transform/field.ts +335 -4
- package/src/types.ts +4 -0
package/src/transform/field.ts
CHANGED
|
@@ -83,7 +83,7 @@ const fieldTransform: Transform = async function(
|
|
|
83
83
|
// emitted the composite. The ports disagreed on exactly the shape this
|
|
84
84
|
// feature exists for.
|
|
85
85
|
const gent = guide?.entity?.[ment.name]
|
|
86
|
-
const composite = compositeId(ment, gent)
|
|
86
|
+
const composite = compositeId(ment, gent, def)
|
|
87
87
|
|
|
88
88
|
const idField = fields.find((f: ModelField) => 'id' === f.name)
|
|
89
89
|
|
|
@@ -224,6 +224,14 @@ const fieldTransform: Transform = async function(
|
|
|
224
224
|
// Seneca entities are built on.
|
|
225
225
|
const ID_SEP = '/'
|
|
226
226
|
|
|
227
|
+
// Subfields that conventionally carry the identifying value of a nested
|
|
228
|
+
// object, in preference order. github's repo `owner` is a user object whose
|
|
229
|
+
// identifier is `login`; other specs use `name`, `slug` or `key`. `id` is
|
|
230
|
+
// last: it is the most common name and the least likely to be the value a
|
|
231
|
+
// PATH parameter takes (a path that wanted an id would say so).
|
|
232
|
+
const NESTED_ID_KEYS = ['login', 'slug', 'name', 'key', 'id']
|
|
233
|
+
|
|
234
|
+
|
|
227
235
|
// The ops that address ONE record, most authoritative first. Only a
|
|
228
236
|
// tie-break: identityParams compares candidates from all of them.
|
|
229
237
|
const ID_OPS = ['load', 'update', 'patch', 'remove']
|
|
@@ -375,6 +383,308 @@ function identityParams(ment: ModelEntity): string[] {
|
|
|
375
383
|
return null == best ? [] : best.run
|
|
376
384
|
}
|
|
377
385
|
|
|
386
|
+
// THE PROPERTY MAPS A RESPONSE COULD BE DESCRIBING, best first.
|
|
387
|
+
//
|
|
388
|
+
// BOTH SPEC DIALECTS. An OpenAPI 3 response carries its schema under
|
|
389
|
+
// `content['application/json']`; a SWAGGER 2 response carries it directly as
|
|
390
|
+
// `schema`. Reading only the first resolved nothing for every Swagger 2 spec
|
|
391
|
+
// in the validation corpus.
|
|
392
|
+
//
|
|
393
|
+
// JSON ONLY, where there is a choice. An operation may declare several media
|
|
394
|
+
// types with different schemas, and field extraction uses the JSON one — so
|
|
395
|
+
// picking whichever came first in source order could infer a path from an XML
|
|
396
|
+
// or binary schema that the actual JSON record does not have.
|
|
397
|
+
//
|
|
398
|
+
// `allOf` IS EXPANDED, because a response that composes its entity that way
|
|
399
|
+
// has neither `properties` nor `items` of its own. field extraction expands
|
|
400
|
+
// it; not doing so here meant the fields were present while the id could not
|
|
401
|
+
// be reconstructed.
|
|
402
|
+
//
|
|
403
|
+
// ONLY THE ENVELOPE IS DESCENDED, via the same `envelopeProp` rule field
|
|
404
|
+
// extraction uses. Descending every object-valued property instead treats an
|
|
405
|
+
// ordinary nested object as a whole record: for `{ slug, metadata: { tenant } }`
|
|
406
|
+
// addressed by `{tenant}/{slug}`, `tenant` resolved to `tenant` rather than
|
|
407
|
+
// `metadata.tenant` — a confidently wrong path, which is worse than no
|
|
408
|
+
// mapping at all.
|
|
409
|
+
//
|
|
410
|
+
// ACTION POINTS ARE SKIPPED, as `identityParams` skips them: an action's
|
|
411
|
+
// response is a verb's result, not a representation of the entity, so a field
|
|
412
|
+
// that happens to appear there says nothing about what a returned record
|
|
413
|
+
// carries.
|
|
414
|
+
function responseCandidates(ment: ModelEntity, def: any): any[] {
|
|
415
|
+
const out: any[] = []
|
|
416
|
+
const seen = new Set<any>()
|
|
417
|
+
|
|
418
|
+
// Every property map this schema describes, expanding allOf.
|
|
419
|
+
const propsOf = (schema: any): any[] => {
|
|
420
|
+
const node = resolveRef(schema, def)
|
|
421
|
+
if (null == node || seen.has(node)) {
|
|
422
|
+
return []
|
|
423
|
+
}
|
|
424
|
+
seen.add(node)
|
|
425
|
+
|
|
426
|
+
if (Array.isArray(node.allOf)) {
|
|
427
|
+
return node.allOf.flatMap((member: any) => propsOf(member))
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (null != node.properties) {
|
|
431
|
+
return [node.properties]
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// A bare array response: the record is the item.
|
|
435
|
+
const items = resolveRef(node.items, def)
|
|
436
|
+
if (null != items) {
|
|
437
|
+
return propsOf(items)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return []
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const add = (schema: any, opname: string) => {
|
|
444
|
+
for (const props of propsOf(schema)) {
|
|
445
|
+
out.push(props)
|
|
446
|
+
|
|
447
|
+
// One level in, but ONLY through the envelope property.
|
|
448
|
+
const envelope = envelopeProp(props, opname)
|
|
449
|
+
if (null == envelope) {
|
|
450
|
+
continue
|
|
451
|
+
}
|
|
452
|
+
const inner = resolveRef(props[envelope], def)
|
|
453
|
+
if (null == inner) {
|
|
454
|
+
continue
|
|
455
|
+
}
|
|
456
|
+
for (const innerProps of propsOf(inner)) {
|
|
457
|
+
out.push(innerProps)
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
for (const opname of ['load', 'list', 'update', 'create']) {
|
|
463
|
+
const mop = (ment as any).op?.[opname]
|
|
464
|
+
|
|
465
|
+
for (const mpoint of (mop?.points || [])) {
|
|
466
|
+
// An action point's response is not the entity.
|
|
467
|
+
if (null != mpoint?.select?.['$action']) {
|
|
468
|
+
continue
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const path = (def?.paths || {})[mpoint?.orig]
|
|
472
|
+
const method = String(mpoint?.method || '').toLowerCase()
|
|
473
|
+
const responses = path?.[method]?.responses || {}
|
|
474
|
+
|
|
475
|
+
for (const code of Object.keys(responses)) {
|
|
476
|
+
if (!/^2/.test(code)) {
|
|
477
|
+
continue
|
|
478
|
+
}
|
|
479
|
+
const resdef = responses[code] || {}
|
|
480
|
+
|
|
481
|
+
const content = resdef.content || {}
|
|
482
|
+
const ctypes = Object.keys(content)
|
|
483
|
+
// Prefer JSON; fall back to whatever single type is offered.
|
|
484
|
+
const json = ctypes.find((c: string) => c.includes('json'))
|
|
485
|
+
if (null != json) {
|
|
486
|
+
add(content[json]?.schema, opname)
|
|
487
|
+
}
|
|
488
|
+
else {
|
|
489
|
+
for (const ctype of ctypes) {
|
|
490
|
+
add(content[ctype]?.schema, opname)
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Swagger 2 puts it here.
|
|
495
|
+
add(resdef.schema, opname)
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
return out
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
// A `$ref` followed one hop, or the schema itself. apidef resolves most refs
|
|
505
|
+
// before this stage; this covers the ones that survive on a nested property.
|
|
506
|
+
function resolveRef(schema: any, def: any): any {
|
|
507
|
+
if (null == schema) {
|
|
508
|
+
return null
|
|
509
|
+
}
|
|
510
|
+
const ref = schema.$ref
|
|
511
|
+
if ('string' !== typeof ref || !ref.startsWith('#/')) {
|
|
512
|
+
return schema
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
let node: any = def
|
|
516
|
+
for (const seg of ref.slice(2).split('/')) {
|
|
517
|
+
node = node?.[seg]
|
|
518
|
+
if (null == node) {
|
|
519
|
+
return null
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return node
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
// The conventional identifying subfield of a property map, or null.
|
|
527
|
+
function conventionalIdKey(props: any): string | null {
|
|
528
|
+
for (const key of NESTED_ID_KEYS) {
|
|
529
|
+
const p = props[key]
|
|
530
|
+
if (null == p) {
|
|
531
|
+
continue
|
|
532
|
+
}
|
|
533
|
+
const t = String(p.type || '')
|
|
534
|
+
if ('object' !== t && 'array' !== t) {
|
|
535
|
+
return key
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return null
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
// Every name a part might be carried under: the model's name for it, plus the
|
|
544
|
+
// original wire names of any path parameter that was renamed to it.
|
|
545
|
+
function partAliases(ment: ModelEntity, part: string): string[] {
|
|
546
|
+
const names = new Set<string>([part])
|
|
547
|
+
|
|
548
|
+
each((ment as any).op, (mop: any) => {
|
|
549
|
+
each(mop?.points, (mpoint: any) => {
|
|
550
|
+
const rename = mpoint?.rename?.param || {}
|
|
551
|
+
for (const orig of Object.keys(rename)) {
|
|
552
|
+
if (String(rename[orig]) === part) {
|
|
553
|
+
names.add(orig)
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
for (const arg of (mpoint?.args?.params || [])) {
|
|
557
|
+
if (null != arg && arg.name === part && null != arg.orig) {
|
|
558
|
+
names.add(String(arg.orig))
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
})
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
return [...names]
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
// Where one part is carried in a given property map, or null.
|
|
569
|
+
//
|
|
570
|
+
// The four rules, in order, each a fact the spec states: a scalar property of
|
|
571
|
+
// that name; the part naming this entity, resolved to `name`; a scalar
|
|
572
|
+
// `<part>_name` / `_login` / `_slug`; or an object property's conventional
|
|
573
|
+
// identifying subfield.
|
|
574
|
+
function resolvePart(
|
|
575
|
+
ment: ModelEntity,
|
|
576
|
+
part: string,
|
|
577
|
+
aliases: string[],
|
|
578
|
+
props: any,
|
|
579
|
+
def: any,
|
|
580
|
+
): string | null {
|
|
581
|
+
if (null == props) {
|
|
582
|
+
return null
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const prop = (name: string) => resolveRef(props[name], def)
|
|
586
|
+
const scalar = (p: any) =>
|
|
587
|
+
null != p && 'object' !== String(p.type) && 'array' !== String(p.type) &&
|
|
588
|
+
null == p.properties && null == p.items
|
|
589
|
+
|
|
590
|
+
for (const name of aliases) {
|
|
591
|
+
if (scalar(prop(name))) {
|
|
592
|
+
return name
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (part === ment.name && scalar(prop('name'))) {
|
|
597
|
+
return 'name'
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
for (const name of aliases) {
|
|
601
|
+
for (const suffix of ['_name', '_login', '_slug']) {
|
|
602
|
+
if (scalar(prop(name + suffix))) {
|
|
603
|
+
return name + suffix
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
for (const name of aliases) {
|
|
609
|
+
const nested = prop(name)
|
|
610
|
+
if (null != nested?.properties) {
|
|
611
|
+
const sub = conventionalIdKey(nested.properties)
|
|
612
|
+
if (null != sub) {
|
|
613
|
+
return name + '.' + sub
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
return null
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
// WHERE EACH COMPOSITE PART'S VALUE LIVES IN A RESPONSE.
|
|
623
|
+
//
|
|
624
|
+
// The parts are PATH PARAMETER names; a response names its fields whatever it
|
|
625
|
+
// likes. Resolving one to the other is what lets an SDK put an id on a record
|
|
626
|
+
// the API returned, rather than only address a record whose id it was given.
|
|
627
|
+
//
|
|
628
|
+
// The rules, in order, and each of them is a fact about the spec rather than
|
|
629
|
+
// a guess:
|
|
630
|
+
//
|
|
631
|
+
// 1. a scalar field of exactly that name -> itself
|
|
632
|
+
// 2. the part names this entity, and there is a `name` -> `name`
|
|
633
|
+
// (`/repos/{owner}/{repo}` on entity `repo`, whose response calls the
|
|
634
|
+
// repository `name`)
|
|
635
|
+
// 3. a scalar `<part>_name` / `<part>_login` / `<part>_slug`
|
|
636
|
+
// 4. an OBJECT field of that name -> `<part>.<conventional key>`
|
|
637
|
+
// (`owner` is a user object; the value is `owner.login`)
|
|
638
|
+
//
|
|
639
|
+
// A part none of these resolve is left OUT. Downstream then knows the id
|
|
640
|
+
// cannot be rebuilt for that entity and can say so, which is better than a
|
|
641
|
+
// confidently wrong id on a real record. guide.aon can state it instead.
|
|
642
|
+
function identityFrom(
|
|
643
|
+
ment: ModelEntity,
|
|
644
|
+
parts: string[],
|
|
645
|
+
def: any,
|
|
646
|
+
): Record<string, string> {
|
|
647
|
+
// THE RESPONSE SCHEMA IS THE AUTHORITY, not `ment.fields`.
|
|
648
|
+
//
|
|
649
|
+
// `ment.fields` is merged across load, create, update and list, so a part
|
|
650
|
+
// that exists only in a REQUEST BODY appears there too. Resolving against
|
|
651
|
+
// it recorded such a part in `from` as though a returned record carried it,
|
|
652
|
+
// and a consumer then rebuilt an id from a property the response never
|
|
653
|
+
// sends — worse than leaving the part unresolved, which at least says so.
|
|
654
|
+
//
|
|
655
|
+
// Candidate property maps, in order: the response's own properties, then
|
|
656
|
+
// one level into an envelope. A response that wraps the record
|
|
657
|
+
// (`{ item: {...} }`, `{ data: [ {...} ] }`) states the record's fields one
|
|
658
|
+
// level in, and searching only the wrapper found nothing.
|
|
659
|
+
const candidates = responseCandidates(ment, def)
|
|
660
|
+
const out: Record<string, string> = {}
|
|
661
|
+
|
|
662
|
+
for (const part of parts) {
|
|
663
|
+
// THE WIRE NAME AS WELL AS THE MODEL NAME. `identityParams` reads the
|
|
664
|
+
// RENAMED parameter off the path segments, while a response keeps its own
|
|
665
|
+
// casing — so a `tenantKey` renamed to `tenant_key` was looked up under a
|
|
666
|
+
// name the response does not use, and the mapping was dropped for every
|
|
667
|
+
// camel-cased or depluralized parameter.
|
|
668
|
+
const aliases = partAliases(ment, part)
|
|
669
|
+
|
|
670
|
+
let found: string | null = null
|
|
671
|
+
|
|
672
|
+
for (const props of candidates) {
|
|
673
|
+
found = resolvePart(ment, part, aliases, props, def)
|
|
674
|
+
if (null != found) {
|
|
675
|
+
break
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (null != found) {
|
|
680
|
+
out[part] = found
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
return out
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
|
|
378
688
|
// Is this model field declared as a string? A composite id is the parts
|
|
379
689
|
// joined, so the field that holds it has to be one.
|
|
380
690
|
function scalarStringField(f: any): boolean {
|
|
@@ -412,7 +722,8 @@ function singleKeyOf(ment: ModelEntity, parts: string[]): string | undefined {
|
|
|
412
722
|
function compositeId(
|
|
413
723
|
ment: ModelEntity,
|
|
414
724
|
gent?: any,
|
|
415
|
-
|
|
725
|
+
def?: any,
|
|
726
|
+
): { parts?: string[], sep?: string, from?: Record<string, string> } {
|
|
416
727
|
const gid = gent?.id
|
|
417
728
|
const sep = null != gid?.sep && '' !== String(gid.sep) ? String(gid.sep) : ID_SEP
|
|
418
729
|
|
|
@@ -434,15 +745,35 @@ function compositeId(
|
|
|
434
745
|
return { single: singleKeyOf(ment, identityParams(ment)) } as any
|
|
435
746
|
}
|
|
436
747
|
|
|
748
|
+
// `from` STATED IN guide.aon WINS PER PART, so a spec can correct one
|
|
749
|
+
// mapping without restating the others — which matters because the
|
|
750
|
+
// heuristic gets most of them right and the odd one wrong.
|
|
751
|
+
const withFrom = (parts: string[], usesep: string) => {
|
|
752
|
+
const derived = identityFrom(ment, parts, def)
|
|
753
|
+
const stated = null != gid?.from && 'object' === typeof gid.from ? gid.from : {}
|
|
754
|
+
const from: Record<string, string> = {}
|
|
755
|
+
|
|
756
|
+
for (const part of parts) {
|
|
757
|
+
const say = (stated as any)[part]
|
|
758
|
+
const use = null != say && '' !== String(say) ? String(say) : derived[part]
|
|
759
|
+
if (null != use) {
|
|
760
|
+
from[part] = use
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
return 0 === Object.keys(from).length ?
|
|
765
|
+
{ parts, sep: usesep } : { parts, sep: usesep, from }
|
|
766
|
+
}
|
|
767
|
+
|
|
437
768
|
if (null != gid && null != gid.parts) {
|
|
438
769
|
const given = (gid.parts as any[])
|
|
439
770
|
.filter((p: any) => null != p && '' !== String(p))
|
|
440
771
|
.map((p: any) => String(p))
|
|
441
|
-
return 1 < given.length ?
|
|
772
|
+
return 1 < given.length ? withFrom(given, sep) : {}
|
|
442
773
|
}
|
|
443
774
|
|
|
444
775
|
const parts = identityParams(ment)
|
|
445
|
-
return 1 < parts.length ?
|
|
776
|
+
return 1 < parts.length ? withFrom(parts, sep) : {}
|
|
446
777
|
}
|
|
447
778
|
|
|
448
779
|
|
package/src/types.ts
CHANGED
|
@@ -266,6 +266,10 @@ type GuideEntity = {
|
|
|
266
266
|
parts?: string[]
|
|
267
267
|
sep?: string
|
|
268
268
|
composite?: boolean
|
|
269
|
+
// Corrects WHERE A PART LIVES in a response, per part, so a spec can fix
|
|
270
|
+
// one mapping without restating the others — the heuristic gets most of
|
|
271
|
+
// them right and the odd one wrong.
|
|
272
|
+
from?: Record<string, string>
|
|
269
273
|
}
|
|
270
274
|
|
|
271
275
|
name: string
|