@voxgig/apidef 6.3.7 → 6.3.9

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 (48) hide show
  1. package/dist/apidef.d.ts +3 -2
  2. package/dist/apidef.js +35 -5
  3. package/dist/apidef.js.map +1 -1
  4. package/dist/builder/entity/entity.d.ts +2 -1
  5. package/dist/builder/entity/entity.js +63 -0
  6. package/dist/builder/entity/entity.js.map +1 -1
  7. package/dist/builder/entity/info.js.map +1 -1
  8. package/dist/builder/entity.js.map +1 -1
  9. package/dist/builder/flow/flowHeuristic01.js.map +1 -1
  10. package/dist/builder/flow.js.map +1 -1
  11. package/dist/guide/guide.js +49 -6
  12. package/dist/guide/guide.js.map +1 -1
  13. package/dist/guide/heuristic01.js +99 -89
  14. package/dist/guide/heuristic01.js.map +1 -1
  15. package/dist/parse.js +151 -18
  16. package/dist/parse.js.map +1 -1
  17. package/dist/resolver.js.map +1 -1
  18. package/dist/transform/args.js.map +1 -1
  19. package/dist/transform/clean.js.map +1 -1
  20. package/dist/transform/entity.js.map +1 -1
  21. package/dist/transform/field.js +14 -0
  22. package/dist/transform/field.js.map +1 -1
  23. package/dist/transform/flow.js.map +1 -1
  24. package/dist/transform/flowstep.js.map +1 -1
  25. package/dist/transform/operation.js.map +1 -1
  26. package/dist/transform/select.js.map +1 -1
  27. package/dist/transform/top.d.ts +2 -1
  28. package/dist/transform/top.js +32 -2
  29. package/dist/transform/top.js.map +1 -1
  30. package/dist/transform.d.ts +8 -7
  31. package/dist/transform.js.map +1 -1
  32. package/dist/tsconfig.tsbuildinfo +1 -1
  33. package/dist/types.d.ts +97 -84
  34. package/dist/types.js.map +1 -1
  35. package/dist/utility.d.ts +6 -1
  36. package/dist/utility.js +205 -16
  37. package/dist/utility.js.map +1 -1
  38. package/package.json +8 -8
  39. package/src/apidef.ts +38 -4
  40. package/src/builder/entity/entity.ts +70 -1
  41. package/src/guide/guide.ts +50 -6
  42. package/src/guide/heuristic01.ts +37 -28
  43. package/src/parse.ts +161 -20
  44. package/src/transform/field.ts +16 -1
  45. package/src/transform/top.ts +37 -2
  46. package/src/tsconfig.json +1 -0
  47. package/src/types.ts +5 -0
  48. package/src/utility.ts +226 -16
@@ -44,6 +44,13 @@ const topTransform = async function(
44
44
  kit.info = stringifyInfoScalars(def.info ?? {})
45
45
  kit.info.servers = stringifyInfoScalars(def.servers ?? [])
46
46
 
47
+ // Guarantee at least one sentence of API description. Many specs (e.g. the
48
+ // readme.io-hosted Bluefin APIs) ship a placeholder `info.description` of
49
+ // "." — letterless, useless prose. When the description is empty or has no
50
+ // letters, synthesise a sentence from the title so the api-info.aontu (and
51
+ // the docs generated from it) never carry an empty/degenerate description.
52
+ kit.info.description = ensureDescription(kit.info)
53
+
47
54
  // Public APIs that declare NO authentication (no security schemes, no
48
55
  // top-level `security`, and no per-operation `security`) get an explicit
49
56
  // no-auth signal in the model. Downstream sdkgen reads it via
@@ -120,6 +127,31 @@ const topTransform = async function(
120
127
  }
121
128
 
122
129
 
130
+ // True when the text carries at least one alphabetic character — i.e. it is
131
+ // real prose rather than a placeholder like "." / "---" / whitespace.
132
+ function hasLetters(text: string): boolean {
133
+ return /[a-zA-Z]/.test(text)
134
+ }
135
+
136
+
137
+ // A non-empty, at-least-one-sentence description for the API. Keeps the spec's
138
+ // own `info.description` when it is real prose; otherwise synthesises a sentence
139
+ // from the title (falling back to a generic sentence when even that is
140
+ // missing). Never returns an empty or letterless string.
141
+ function ensureDescription(info: any): string {
142
+ const current = 'string' === typeof info.description ? info.description.trim() : ''
143
+ if ('' !== current && hasLetters(current)) {
144
+ return info.description
145
+ }
146
+ const title = 'string' === typeof info.title ? info.title.trim() : ''
147
+ if ('' === title || !hasLetters(title)) {
148
+ return 'Client SDK for this API.'
149
+ }
150
+ // Avoid a redundant "… Api API." when the title already names itself an API.
151
+ return 'The ' + title + (/\bapi\b/i.test(title) ? '' : ' API') + '.'
152
+ }
153
+
154
+
123
155
  // A short one-line description of the API's purpose: the spec's
124
156
  // `info.summary` (OpenAPI 3.1) when present, else the first prose sentence
125
157
  // of `info.description` with leading markdown headings/blank lines stripped
@@ -129,12 +161,14 @@ function resolveSummary(def: any): string | undefined {
129
161
  const info = def?.info ?? {}
130
162
 
131
163
  const explicit = 'string' === typeof info.summary ? info.summary.trim() : ''
132
- if ('' !== explicit) {
164
+ if ('' !== explicit && hasLetters(explicit)) {
133
165
  return firstSentence(explicit)
134
166
  }
135
167
 
136
168
  const desc = 'string' === typeof info.description ? info.description : ''
137
- if ('' === desc.trim()) {
169
+ // Treat letterless prose (a bare "." placeholder, "---", …) as no summary
170
+ // rather than surfacing it verbatim.
171
+ if ('' === desc.trim() || !hasLetters(desc)) {
138
172
  return undefined
139
173
  }
140
174
 
@@ -418,6 +452,7 @@ export {
418
452
  topTransform,
419
453
  resolveSecurity,
420
454
  resolveSummary,
455
+ ensureDescription,
421
456
  resolveWebsite,
422
457
  homepageFromServer,
423
458
  findAuthPrefix,
package/src/tsconfig.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "compilerOptions": {
3
3
  "esModuleInterop": true,
4
+ "composite": true,
4
5
  "module": "nodenext",
5
6
  "noEmitOnError": true,
6
7
  "outDir":"../dist",
package/src/types.ts CHANGED
@@ -160,6 +160,11 @@ type Metrics = {
160
160
 
161
161
  type ApiDefContext = {
162
162
  fs: any,
163
+ // True only when the caller supplied ApiDefOptions.fs (e.g. memfs), as
164
+ // opposed to the node:fs default. aontu/@tabnas/multisource treats the
165
+ // presence of an injected fs as "paths are POSIX", so forwarding the real
166
+ // node:fs breaks include resolution on Windows. See guide/guide.ts.
167
+ fsInjected: boolean,
163
168
  log: any,
164
169
  spec: any,
165
170
  opts: any,
package/src/utility.ts CHANGED
@@ -6,7 +6,7 @@ import { snakify, camelify, kebabify, each } from 'jostraca'
6
6
  import { decircular } from '@voxgig/util'
7
7
 
8
8
  import {
9
- slice, merge, inject, clone, isnode, walk, transform, select,
9
+ slice, merge, inject, clone, isnode, walk, transform, select, keysof,
10
10
  Injection,
11
11
  M_VAL,
12
12
  M_KEYPRE,
@@ -134,7 +134,12 @@ function formatJsonSrc(jsonsrc: string) {
134
134
  //
135
135
  // Keys are lowercase; depluralize() does a case-insensitive lookup
136
136
  // and reapplies the caller's casing on the way out.
137
- const IRREGULARS: Record<string, string> = {
137
+ //
138
+ // Null-prototype: these tables are indexed by spec-derived names, so a
139
+ // schema or path segment called `constructor` / `__proto__` / `toString`
140
+ // would otherwise resolve to the inherited Object member and be returned
141
+ // as a "match" — crashing matchCase() on a function. See NULL_PROTO_NOTE.
142
+ const IRREGULARS: Record<string, string> = Object.assign(Object.create(null), {
138
143
  'analytics': 'analytics',
139
144
  'analyses': 'analysis',
140
145
  'appendices': 'appendix',
@@ -198,7 +203,18 @@ const IRREGULARS: Record<string, string> = {
198
203
  'vertices': 'vertex',
199
204
  'women': 'woman',
200
205
  'yes': 'yes',
201
- }
206
+ })
207
+
208
+ // NULL_PROTO_NOTE: every plain-object lookup table in this module whose keys
209
+ // come from an API spec is built with a null prototype
210
+ // (`Object.assign(Object.create(null), {...})`). Without it, `TABLE[name]`
211
+ // inherits from Object.prototype, so `TABLE['constructor']` yields the Object
212
+ // constructor (truthy, a function) and `TABLE['__proto__']` yields
213
+ // Object.prototype (truthy, an object). Both then flow into code expecting a
214
+ // string. A spec with `components.schemas.Constructor` is enough to reach
215
+ // this: canonize -> depluralize -> matchCase -> `.toLowerCase is not a
216
+ // function`, failing the whole build at the guide stage. Keep new tables
217
+ // null-prototype, or use a Map (CANONIZE_CACHE already does).
202
218
 
203
219
  // Sorted longest-first so the most specific IRREGULARS suffix wins.
204
220
  // Without this, 'women' would be shadowed by 'men' (3 < 5) under
@@ -235,12 +251,13 @@ function matchCase(source: string, target: string): string {
235
251
  // inherit the override without signature churn. apidef is
236
252
  // single-model-per-process; if that ever changes, switch this to a
237
253
  // per-context map.
238
- let CUSTOM_PLURALS: Record<string, string> = {}
254
+ // Null-prototype: see NULL_PROTO_NOTE above.
255
+ let CUSTOM_PLURALS: Record<string, string> = Object.create(null)
239
256
  let CUSTOM_PLURAL_KEYS: string[] = []
240
257
 
241
258
 
242
259
  function setCustomPlurals(plurals: Record<string, string> | undefined | null) {
243
- CUSTOM_PLURALS = {}
260
+ CUSTOM_PLURALS = Object.create(null)
244
261
  if (plurals) {
245
262
  for (const k of Object.keys(plurals)) {
246
263
  // Skip null/undefined values so a partially-typed model entry
@@ -752,7 +769,7 @@ function formatJSONIC(
752
769
  }
753
770
 
754
771
  return renderJSONIC(val, hsepd, showd, useColor, maxlines, exclude, c,
755
- renderPrimitive, renderComment)
772
+ renderPrimitive, renderComment, false)
756
773
  }
757
774
 
758
775
 
@@ -766,6 +783,12 @@ function renderJSONIC(
766
783
  c: (color: any, text: string) => string,
767
784
  renderPrimitive: (v: any) => string,
768
785
  renderComment: (c: any) => string | null,
786
+ // True once the value has already been through decircular. `seen` never
787
+ // forgets a node, so a merely REPEATED reference (a shared node in a DAG,
788
+ // not a cycle) also lands in the fallback below. If decircular leaves such
789
+ // a repeat in place, retrying forever overflows the stack — which is what
790
+ // formatting a deep validation error used to do. Retry at most once.
791
+ decircularized: boolean,
769
792
  ): string {
770
793
 
771
794
  const space = ' '
@@ -782,6 +805,10 @@ function renderJSONIC(
782
805
  kind: 'close'
783
806
  token: '}' | ']'
784
807
  indentLevel: number
808
+ // The container this frame closes. `seen` tracks the ANCESTOR PATH, so
809
+ // the node is removed again on the way out — otherwise a merely repeated
810
+ // (shared) node is indistinguishable from a cycle.
811
+ node?: any
785
812
  }
786
813
 
787
814
  const seen = new WeakSet()
@@ -807,6 +834,9 @@ function renderJSONIC(
807
834
  top -= 1
808
835
 
809
836
  if (frame.kind === 'close') {
837
+ if (undefined !== frame.node) {
838
+ seen.delete(frame.node)
839
+ }
810
840
  const indent = space.repeat(frame.indentLevel)
811
841
  const hsep = 0 < frame.indentLevel && frame.indentLevel <= hsepd
812
842
  lines.push(`${indent}${c('bracket', frame.token)}${hsep ? '\n' : ''}`)
@@ -826,10 +856,29 @@ function renderJSONIC(
826
856
  continue
827
857
  }
828
858
 
829
- // Circular reference detected — fall back to decircular
859
+ // Repeated reference — fall back to decircular, but only once.
830
860
  if (seen.has(v)) {
831
- return renderJSONIC(decircular(val), hsepd, showd, useColor, maxlines, exclude, c,
832
- renderPrimitive, renderComment)
861
+ if (!decircularized) {
862
+ // decircular recurses per level, so a deep value can overflow the
863
+ // stack inside it. This whole path usually runs while formatting an
864
+ // error, so degrade to the marker instead of throwing over it.
865
+ let flat: any
866
+ try {
867
+ flat = decircular(val)
868
+ }
869
+ catch (err: any) {
870
+ flat = undefined
871
+ }
872
+ if (undefined !== flat) {
873
+ return renderJSONIC(flat, hsepd, showd, useColor, maxlines, exclude, c,
874
+ renderPrimitive, renderComment, true)
875
+ }
876
+ }
877
+ // decircular did not resolve it, so render a marker rather than
878
+ // recursing again. Better a placeholder than a crash while
879
+ // formatting an error the user actually needs to read.
880
+ lines.push(`${linePrefix}${c('string', '"[Circular]"')}${commentSuffix}`)
881
+ continue
833
882
  }
834
883
  seen.add(v)
835
884
 
@@ -837,13 +886,13 @@ function renderJSONIC(
837
886
  const arr = v as any[]
838
887
  if (arr.length === 0) {
839
888
  lines.push(`${linePrefix}${c('bracket', '[')}${commentSuffix}`)
840
- stack[++top] = { kind: 'close', token: ']', indentLevel }
889
+ stack[++top] = { kind: 'close', token: ']', indentLevel, node: v }
841
890
  continue
842
891
  }
843
892
 
844
893
  // opening line
845
894
  lines.push(`${linePrefix}${c('bracket', '[')}${commentSuffix}`)
846
- stack[++top] = { kind: 'close', token: ']', indentLevel }
895
+ stack[++top] = { kind: 'close', token: ']', indentLevel, node: v }
847
896
 
848
897
  // children (reverse push)
849
898
  const childPrefix = space.repeat(indentLevel + 1)
@@ -872,13 +921,13 @@ function renderJSONIC(
872
921
 
873
922
  if (printableKeys.length === 0) {
874
923
  lines.push(`${linePrefix}${c('bracket', '{')}${commentSuffix}`)
875
- stack[++top] = { kind: 'close', token: '}', indentLevel }
924
+ stack[++top] = { kind: 'close', token: '}', indentLevel, node: v }
876
925
  continue
877
926
  }
878
927
 
879
928
  // opening line
880
929
  lines.push(`${linePrefix}${c('bracket', '{')}${commentSuffix}`)
881
- stack[++top] = { kind: 'close', token: '}', indentLevel }
930
+ stack[++top] = { kind: 'close', token: '}', indentLevel, node: v }
882
931
 
883
932
  const nextIndentStr = space.repeat(indentLevel + 1)
884
933
  for (let i = printableKeys.length - 1; i >= 0; i--) {
@@ -905,7 +954,17 @@ function renderJSONIC(
905
954
  }
906
955
 
907
956
 
908
- const VALID_CANON: Record<string, string> = {
957
+ // Canonical type-sentinel vocabulary. VALID_CANON maps an OpenAPI type NAME
958
+ // to its `$SENTINEL` form; CANON_ONE is the union sentinel produced by
959
+ // `validator` for a multi-type (`['`$ONE`', [member, ...]]`). Both are part
960
+ // of the public API so downstream consumers (e.g. @voxgig/sdkgen's
961
+ // sentinel -> language-type table) can verify they cover the full set
962
+ // instead of hand-syncing against this file.
963
+ //
964
+ // Null-prototype (see NULL_PROTO_NOTE): `type` values come from the spec, so
965
+ // a schema declaring `type: constructor` would otherwise return the Object
966
+ // constructor here rather than falling through to the 'Any' default.
967
+ const VALID_CANON: Record<string, string> = Object.assign(Object.create(null), {
909
968
  'string': '`$STRING`',
910
969
  'number': '`$NUMBER`',
911
970
  'integer': '`$INTEGER`',
@@ -914,7 +973,9 @@ const VALID_CANON: Record<string, string> = {
914
973
  'array': '`$ARRAY`',
915
974
  'object': '`$OBJECT`',
916
975
  'any': '`$ANY`',
917
- }
976
+ })
977
+
978
+ const CANON_ONE = '`$ONE`'
918
979
 
919
980
 
920
981
  function validator(torig: undefined | string | string[]): any {
@@ -924,7 +985,7 @@ function validator(torig: undefined | string | string[]): any {
924
985
  return canon
925
986
  }
926
987
  else if (Array.isArray(torig)) {
927
- return ['`$ONE`', torig.map((t: string) => validator(t))]
988
+ return [CANON_ONE, torig.map((t: string) => validator(t))]
928
989
  }
929
990
  else {
930
991
  return '`$ANY`'
@@ -1376,6 +1437,150 @@ export type {
1376
1437
  PathMatch
1377
1438
  }
1378
1439
 
1440
+
1441
+ // A response property only "wraps" the entity when it is itself a structured
1442
+ // value that could contain the entity: an object, an array, a $ref, or a
1443
+ // composed (allOf/oneOf/anyOf) schema. A scalar property (string, integer,
1444
+ // number, boolean) that merely shares the entity's name is a field of the
1445
+ // entity, not a wrapper, so the response must not be unwrapped down to it.
1446
+ function isEntityWrapperProp(propSchema: any): boolean {
1447
+ if (null == propSchema || 'object' !== typeof propSchema) {
1448
+ return false
1449
+ }
1450
+ if (null != propSchema.$ref) {
1451
+ return true
1452
+ }
1453
+ if (null != propSchema.properties ||
1454
+ null != propSchema.items ||
1455
+ null != propSchema.allOf ||
1456
+ null != propSchema.oneOf ||
1457
+ null != propSchema.anyOf) {
1458
+ return true
1459
+ }
1460
+ const t = propSchema.type
1461
+ return 'object' === t || 'array' === t
1462
+ }
1463
+
1464
+
1465
+ // A response body that is nothing but a single wrapper property is an
1466
+ // ENVELOPE around the result: `{item: {...}}`, `{data: {...}}`,
1467
+ // `{items: [...]}`, `{results: [...]}`. Return that property's name so the
1468
+ // caller can unwrap to it, or null when the body is the result itself.
1469
+ //
1470
+ // Two conditions keep this from firing on a response that IS the entity:
1471
+ //
1472
+ // 1. EXACTLY ONE property. A body with siblings is a structure in its own
1473
+ // right, not a wrapper — `{ok, id}` from a delete, or any paged
1474
+ // `{results, next}`, must be handed over whole.
1475
+ // 2. The property's SHAPE matches the operation's cardinality. A `list`
1476
+ // unwraps only to an array, every other op only to a non-array. So a
1477
+ // single-entity op facing `{items: [...]}` is left alone rather than
1478
+ // silently yielding a list, and vice versa.
1479
+ //
1480
+ // A one-field entity whose sole field is itself structured can still be
1481
+ // unwrapped wrongly; that is the residual cost of the spec not saying which
1482
+ // it means. Naming the wrapper after the entity remains the unambiguous
1483
+ // signal, and is still checked first.
1484
+ function envelopeProp(resprops: any, opname: string): string | null {
1485
+ const keys = keysof(resprops)
1486
+ if (1 !== keys.length) {
1487
+ return null
1488
+ }
1489
+
1490
+ const key = keys[0]
1491
+ const prop = resprops[key]
1492
+ if (!isEntityWrapperProp(prop)) {
1493
+ return null
1494
+ }
1495
+
1496
+ const islist = propIsList(prop)
1497
+ if (null == islist || islist !== ('list' === opname)) {
1498
+ return null
1499
+ }
1500
+
1501
+ return key
1502
+ }
1503
+
1504
+
1505
+ // Is this schema a collection? null when the schema does not say.
1506
+ //
1507
+ // `isEntityWrapperProp` accepts a composed schema (allOf/oneOf/anyOf) as
1508
+ // structured, but a composed schema carries no outer `type` or `items` — so
1509
+ // reading those alone silently called it a non-list. A `list` then kept its
1510
+ // envelope, and worse, a single-entity op unwrapped to an array-valued
1511
+ // property. Composed branches are inspected instead, and unanimity required:
1512
+ // a union that is an array in one branch and an object in another does not
1513
+ // say what the caller will get, and an envelope is not worth guessing at.
1514
+ function propIsList(schema: any): boolean | null {
1515
+ if (null == schema || 'object' !== typeof schema) {
1516
+ return null
1517
+ }
1518
+
1519
+ const branches = schema.allOf ?? schema.oneOf ?? schema.anyOf
1520
+ if (Array.isArray(branches)) {
1521
+ if (0 === branches.length) {
1522
+ return null
1523
+ }
1524
+ const first = propIsList(branches[0])
1525
+ if (null == first) {
1526
+ return null
1527
+ }
1528
+ for (const branch of branches) {
1529
+ if (propIsList(branch) !== first) {
1530
+ return null
1531
+ }
1532
+ }
1533
+ return first
1534
+ }
1535
+
1536
+ return 'array' === schema.type || null != schema.items
1537
+ }
1538
+
1539
+
1540
+ // The request BODY a closed schema permits, as a transform mapping.
1541
+ //
1542
+ // `additionalProperties: false` is the spec saying the server rejects any
1543
+ // property it did not declare. When a body says that, sending the caller's
1544
+ // whole request payload is wrong: an op's payload also carries its PATH
1545
+ // params (`id` for `PUT /item/{id}`), and a closed shape 400s the entire
1546
+ // request over that one extra key. Restricting the body to the declared
1547
+ // properties is then not a heuristic — it is what the spec asked for.
1548
+ //
1549
+ // Returns null for an open or property-less schema, where `reqdata` (send
1550
+ // everything) remains the right default: an open body accepts extras, and
1551
+ // with no declared properties there is nothing to restrict to.
1552
+ //
1553
+ // The KEY is the property's wire name — that is what goes on the wire and
1554
+ // what the server matches against. The SOURCE is read by the field's
1555
+ // CANONICAL name, because that is the only name the caller ever sees:
1556
+ // findFieldDefs runs every property through `canonize(normalizeFieldName())`,
1557
+ // so a spec property `UserName` reaches the generated request type as
1558
+ // `user_name`. Reading `reqdata.UserName` would find nothing and send
1559
+ // undefined.
1560
+ function closedBodyTransform(schema: any): Record<string, string> | null {
1561
+ if (null == schema || 'object' !== typeof schema) {
1562
+ return null
1563
+ }
1564
+ if (false !== schema.additionalProperties) {
1565
+ return null
1566
+ }
1567
+
1568
+ const names = keysof(schema.properties)
1569
+ if (0 === names.length) {
1570
+ return null
1571
+ }
1572
+
1573
+ // Null prototype: a spec is free to declare a property called `__proto__`,
1574
+ // and on an ordinary object that assignment sets the prototype instead of
1575
+ // creating an own property — the mapping would vanish, and with it the
1576
+ // whole body restriction if it were the only one.
1577
+ const out: Record<string, string> = Object.create(null)
1578
+ for (const name of names) {
1579
+ out[name] = '`reqdata.' + canonize(normalizeFieldName(name)) + '`'
1580
+ }
1581
+ return out
1582
+ }
1583
+
1379
1584
  export {
1380
1585
  nom,
1381
1586
  getdlog,
@@ -1390,6 +1595,8 @@ export {
1390
1595
  makeWarner,
1391
1596
  formatJSONIC,
1392
1597
  validator,
1598
+ VALID_CANON,
1599
+ CANON_ONE,
1393
1600
  canonize,
1394
1601
  canonizeCmpName,
1395
1602
  stripSchemaNamespace,
@@ -1408,5 +1615,8 @@ export {
1408
1615
  getModelPath,
1409
1616
  sortedKeys,
1410
1617
  sortedEntries,
1618
+ isEntityWrapperProp,
1619
+ envelopeProp,
1620
+ closedBodyTransform,
1411
1621
 
1412
1622
  }