@voxgig/apidef 6.3.6 → 6.3.8

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 (45) hide show
  1. package/dist/apidef.d.ts +2 -2
  2. package/dist/apidef.js +27 -6
  3. package/dist/apidef.js.map +1 -1
  4. package/dist/builder/entity/entity.js.map +1 -1
  5. package/dist/builder/entity/info.js.map +1 -1
  6. package/dist/builder/entity.js.map +1 -1
  7. package/dist/builder/flow/flowHeuristic01.js.map +1 -1
  8. package/dist/builder/flow.js.map +1 -1
  9. package/dist/guide/guide.js +49 -6
  10. package/dist/guide/guide.js.map +1 -1
  11. package/dist/guide/heuristic01.js +99 -89
  12. package/dist/guide/heuristic01.js.map +1 -1
  13. package/dist/parse.js +151 -18
  14. package/dist/parse.js.map +1 -1
  15. package/dist/resolver.js.map +1 -1
  16. package/dist/transform/args.js.map +1 -1
  17. package/dist/transform/clean.js.map +1 -1
  18. package/dist/transform/entity.js.map +1 -1
  19. package/dist/transform/field.js +14 -0
  20. package/dist/transform/field.js.map +1 -1
  21. package/dist/transform/flow.js.map +1 -1
  22. package/dist/transform/flowstep.js.map +1 -1
  23. package/dist/transform/operation.js.map +1 -1
  24. package/dist/transform/select.js.map +1 -1
  25. package/dist/transform/top.d.ts +2 -1
  26. package/dist/transform/top.js +32 -2
  27. package/dist/transform/top.js.map +1 -1
  28. package/dist/transform.d.ts +8 -7
  29. package/dist/transform.js.map +1 -1
  30. package/dist/tsconfig.tsbuildinfo +1 -1
  31. package/dist/types.d.ts +97 -84
  32. package/dist/types.js.map +1 -1
  33. package/dist/utility.d.ts +6 -1
  34. package/dist/utility.js +169 -8
  35. package/dist/utility.js.map +1 -1
  36. package/package.json +8 -8
  37. package/src/apidef.ts +28 -5
  38. package/src/guide/guide.ts +50 -6
  39. package/src/guide/heuristic01.ts +37 -28
  40. package/src/parse.ts +161 -20
  41. package/src/transform/field.ts +16 -1
  42. package/src/transform/top.ts +37 -2
  43. package/src/tsconfig.json +1 -0
  44. package/src/types.ts +5 -0
  45. package/src/utility.ts +186 -8
@@ -7,6 +7,8 @@ import { each } from 'jostraca'
7
7
 
8
8
  import { size, merge, getelem, isempty, items, keysof } from '@voxgig/struct'
9
9
 
10
+ import { isEntityWrapperProp, envelopeProp, closedBodyTransform } from '../utility'
11
+
10
12
 
11
13
  import {
12
14
  ApiDefContext,
@@ -1126,17 +1128,48 @@ function ResolveTransform(spec: TaskSpec) {
1126
1128
  else if (isEntityWrapperProp(resprops[entdesc.name])) {
1127
1129
  transform.res = '`body.' + entdesc.name + '`'
1128
1130
  }
1131
+ else {
1132
+ // The wrapper is often named for the CARDINALITY rather than the
1133
+ // entity — `{item: {...}}` from a load, `{items: [...]}` from a list —
1134
+ // which the entity-name rules above cannot see. Left unwrapped, list()
1135
+ // hands back the envelope where the caller expects an array, and the
1136
+ // envelope key is mistaken for a field of the entity.
1137
+ const envelope = envelopeProp(resprops, opname)
1138
+ if (null != envelope) {
1139
+ transform.res = '`body.' + envelope + '`'
1140
+ }
1141
+ }
1129
1142
  }
1130
1143
 
1131
- const reqprops = getRequestBodySchema(mdesc.requestBody)
1144
+ // The SCHEMA is what closedBodyTransform needs (it reads
1145
+ // additionalProperties); the wrapper-name checks need its PROPERTIES. They
1146
+ // used to share one value and index the schema itself, so
1147
+ // `schema['todoitem']` was always undefined and the entity-name request
1148
+ // envelope was never detected — while the Go port read `.properties` and
1149
+ // did detect it. That divergence was inert only because `req` was never
1150
+ // serialised; now that it is, the two implementations would emit different
1151
+ // request bodies for the same spec.
1152
+ const reqschema = getRequestBodySchema(mdesc.requestBody)
1153
+ const reqprops = reqschema?.properties
1132
1154
  debugpath(pathStr, methodName, 'TRANSFORM-REQ', keysof(reqprops))
1133
- if (reqprops) {
1134
- if (reqprops[entdesc.origname]) {
1155
+ if (reqschema) {
1156
+ if (null != reqprops?.[entdesc.origname]) {
1135
1157
  transform.req = { [entdesc.origname]: '`reqdata`' }
1136
1158
  }
1137
- else if (reqprops[entdesc.name]) {
1159
+ else if (null != reqprops?.[entdesc.name]) {
1138
1160
  transform.req = { [entdesc.name]: '`reqdata`' }
1139
1161
  }
1162
+ else {
1163
+ // A CLOSED body schema names every property the server will accept, so
1164
+ // the body is those properties — not the whole request payload. The
1165
+ // payload also carries the op's PATH params (`id` for
1166
+ // `PUT /item/{id}`), and a closed shape rejects the entire request over
1167
+ // that one extra key: every update came back 400 with `invalid-data`.
1168
+ const body = closedBodyTransform(reqschema)
1169
+ if (null != body) {
1170
+ transform.req = body
1171
+ }
1172
+ }
1140
1173
  }
1141
1174
 
1142
1175
  if (!isempty(transform) && null != op[opname]) {
@@ -1365,30 +1398,6 @@ function getResponseSchema(response: any) {
1365
1398
  }
1366
1399
 
1367
1400
 
1368
- // A response property only "wraps" the entity when it is itself a structured
1369
- // value that could contain the entity: an object, an array, a $ref, or a
1370
- // composed (allOf/oneOf/anyOf) schema. A scalar property (string, integer,
1371
- // number, boolean) that merely shares the entity's name is a field of the
1372
- // entity, not a wrapper, so the response must not be unwrapped down to it.
1373
- function isEntityWrapperProp(propSchema: any): boolean {
1374
- if (null == propSchema || 'object' !== typeof propSchema) {
1375
- return false
1376
- }
1377
- if (null != propSchema.$ref) {
1378
- return true
1379
- }
1380
- if (null != propSchema.properties ||
1381
- null != propSchema.items ||
1382
- null != propSchema.allOf ||
1383
- null != propSchema.oneOf ||
1384
- null != propSchema.anyOf) {
1385
- return true
1386
- }
1387
- const t = propSchema.type
1388
- return 'object' === t || 'array' === t
1389
- }
1390
-
1391
-
1392
1401
  function inferEntityName(
1393
1402
  mdesc: any,
1394
1403
  parts: string[],
package/src/parse.ts CHANGED
@@ -1,14 +1,15 @@
1
1
  /* Copyright (c) 2024-2025 Voxgig, MIT License */
2
2
 
3
- import { Jsonic } from 'jsonic'
4
- import { Yaml } from '@jsonic/yaml'
5
-
6
- import { decircular } from '@voxgig/util'
3
+ import { Jsonic } from '@tabnas/jsonic'
4
+ import { Yaml } from '@tabnas/yaml'
7
5
 
8
6
  import { relativizePath } from './utility'
9
7
 
10
8
 
11
- const yamlParser = Jsonic.make().use(Yaml)
9
+ // NOTE: @tabnas/yaml types its Plugin against @tabnas/parser, while
10
+ // Jsonic.use expects @tabnas/jsonic's own (structurally identical) Plugin
11
+ // type - hence the cast.
12
+ const yamlParser = Jsonic.make().use(Yaml as any)
12
13
 
13
14
  // Matches any line that is not purely a YAML comment or whitespace.
14
15
  const RE_HAS_CONTENT = /^\s*[^#\s]/m
@@ -100,12 +101,80 @@ async function parseOpenAPI(source: any, _meta?: any) {
100
101
  // Single-pass: add x-ref properties and resolve $ref pointers together.
101
102
  addXRefsAndResolve(parsed, parsed)
102
103
 
103
- const def = decircular(parsed)
104
+ const def = decycle(parsed)
104
105
 
105
106
  return def
106
107
  }
107
108
 
108
109
 
110
+ // Break reference cycles so the parsed spec stays JSON-serializable, WITHOUT
111
+ // destroying the structure sharing that $ref inlining deliberately creates.
112
+ //
113
+ // @voxgig/util's decircular() rebuilds the tree — it allocates a fresh object
114
+ // per *visit*, so a component reachable by k distinct paths is copied k times
115
+ // and the result is the tree-expansion of the DAG, size O(fanout^depth). That
116
+ // is catastrophic on exactly the shape real specs have (components reused
117
+ // across nesting levels): a 2.3 KB spec with 12 levels and 3 refs per level
118
+ // expanded to a 172 MB model, and 13 levels exhausted a 2 GB heap.
119
+ //
120
+ // Cycles are real here — inlining a self-referential schema makes the copy's
121
+ // `properties` the same object as the original's, so the copy contains
122
+ // itself — so they still have to be cut. This does it in place: only the edge
123
+ // that closes a cycle is replaced (with decircular's marker string, so the
124
+ // output shape is unchanged), every other node is visited exactly once and
125
+ // left shared. Linear in the number of distinct nodes.
126
+ function decycle(root: any) {
127
+ // Entry path of each node on the current ancestor chain; presence in this
128
+ // map is what identifies a back-edge. Nodes are removed on the way out, so
129
+ // a node reachable twice as a *sibling* is not treated as a cycle.
130
+ const onPath = new Map<any, string[]>()
131
+ // Fully-processed nodes. Revisiting one is legitimate sharing, not a cycle,
132
+ // and must not be walked (or copied) again.
133
+ const done = new WeakSet<any>()
134
+ const path: string[] = []
135
+
136
+ const walk = (node: any) => {
137
+ if (null == node || 'object' !== typeof node) return
138
+ if (done.has(node)) return
139
+
140
+ // The YAML parser hands back null-prototype objects. decircular() used to
141
+ // launder them into plain objects as a side effect of rebuilding the tree;
142
+ // parse()'s result is public, so keep that contract (callers reasonably
143
+ // expect `hasOwnProperty` etc. on a parsed spec) rather than leaking the
144
+ // parser's internal shape now that nothing is rebuilt.
145
+ if (!Array.isArray(node) && null === Object.getPrototypeOf(node)) {
146
+ Object.setPrototypeOf(node, Object.prototype)
147
+ }
148
+
149
+ onPath.set(node, path.slice())
150
+
151
+ const keys = Array.isArray(node) ?
152
+ node.map((_: any, i: number) => i) : Object.keys(node)
153
+
154
+ for (const key of keys as any[]) {
155
+ const val = node[key]
156
+ if (null == val || 'object' !== typeof val) continue
157
+
158
+ const cyclePath = onPath.get(val)
159
+ if (undefined !== cyclePath) {
160
+ node[key] = `[Circular *${cyclePath.join('.')}]`
161
+ continue
162
+ }
163
+
164
+ path.push(String(key))
165
+ walk(val)
166
+ path.pop()
167
+ }
168
+
169
+ onPath.delete(node)
170
+ done.add(node)
171
+ }
172
+
173
+ walk(root)
174
+ return root
175
+ }
176
+
177
+
109
178
  // Single-pass tree walk that:
110
179
  // 1. Preserves original $ref values as x-ref
111
180
  // 2. Resolves $ref JSON pointers in-place
@@ -117,6 +186,20 @@ async function parseOpenAPI(source: any, _meta?: any) {
117
186
  // across every site that referenced the same component. (A deep clone is
118
187
  // deliberately avoided: schemas can be self-referential, which would make
119
188
  // cloning non-terminating.)
189
+ // Keywords sitting beside a `$ref` on the *referring* node. OpenAPI 3.1 and
190
+ // JSON Schema 2020-12 both allow them (`description`, `required`,
191
+ // constraints, ...) and they still apply, so inlining must not drop them.
192
+ // Applied over the resolved target, so the local statement wins.
193
+ function refSiblings(node: any): any {
194
+ const out: any = {}
195
+ for (const k of Object.keys(node)) {
196
+ if ('$ref' === k) continue
197
+ out[k] = node[k]
198
+ }
199
+ return out
200
+ }
201
+
202
+
120
203
  function addXRefsAndResolve(obj: any, root: any, visited?: WeakSet<any>) {
121
204
  if (!obj || typeof obj !== 'object') return
122
205
  if (!visited) visited = new WeakSet()
@@ -131,7 +214,7 @@ function addXRefsAndResolve(obj: any, root: any, visited?: WeakSet<any>) {
131
214
  const xref = item.$ref
132
215
  const resolved = resolvePointer(root, xref)
133
216
  if (resolved !== undefined) {
134
- obj[i] = { ...resolved, 'x-ref': xref }
217
+ obj[i] = { ...resolved, ...refSiblings(item), 'x-ref': xref }
135
218
  addXRefsAndResolve(obj[i], root, visited)
136
219
  } else {
137
220
  item['x-ref'] = xref
@@ -150,7 +233,7 @@ function addXRefsAndResolve(obj: any, root: any, visited?: WeakSet<any>) {
150
233
  const xref = val.$ref
151
234
  const resolved = resolvePointer(root, xref)
152
235
  if (resolved !== undefined) {
153
- obj[key] = { ...resolved, 'x-ref': xref }
236
+ obj[key] = { ...resolved, ...refSiblings(val), 'x-ref': xref }
154
237
  addXRefsAndResolve(obj[key], root, visited)
155
238
  } else {
156
239
  val['x-ref'] = xref
@@ -165,22 +248,80 @@ function addXRefsAndResolve(obj: any, root: any, visited?: WeakSet<any>) {
165
248
  }
166
249
 
167
250
 
168
- // Follow a JSON pointer like "#/components/schemas/Planet"
251
+ // Follow a JSON pointer like "#/components/schemas/Planet".
252
+ //
253
+ // Alias components — `Foo: { $ref: '#/components/schemas/Bar' }` — are a
254
+ // normal OpenAPI idiom, so a pointer can land on another bare $ref node.
255
+ // Follow the chain to its end rather than returning the intermediate: the
256
+ // caller inlines `{ ...resolved }`, and a `$ref` *string* key in that spread
257
+ // is never followed by the object-valued recursion in addXRefsAndResolve, so
258
+ // stopping early yields a schema with no properties and every field is
259
+ // silently dropped. Whether that happened used to depend on whether `paths`
260
+ // or `components` came first in the document, because resolution reads a root
261
+ // the same walk is still mutating.
262
+ //
263
+ // `seen` holds pointer strings (not object identities) so a self- or
264
+ // mutually-referential alias cycle terminates instead of looping forever.
169
265
  function resolvePointer(root: any, ref: string): any {
170
- if (!ref.startsWith('#/')) return undefined
266
+ const seen = new Set<string>()
267
+ // Keywords sitting beside a `$ref` along the chain, outermost first.
268
+ // OpenAPI 3.1 / JSON Schema 2020-12 allow `$ref` to carry siblings
269
+ // (`description`, `required`, constraints, ...) and they still apply, so
270
+ // following the chain must not discard them. Merged onto the final target
271
+ // below, outermost last so the most specific alias wins.
272
+ const siblings: any[] = []
273
+ let current: any = undefined
274
+ let pointer = ref
275
+
276
+ for (; ;) {
277
+ if (!pointer.startsWith('#/')) return undefined
278
+ if (seen.has(pointer)) return undefined
279
+ seen.add(pointer)
280
+
281
+ const parts = pointer
282
+ .substring(2)
283
+ .split('/')
284
+ .map(p => p.replace(/~1/g, '/').replace(/~0/g, '~'))
285
+
286
+ current = root
287
+ for (const part of parts) {
288
+ if (current == null || typeof current !== 'object') return undefined
289
+ current = current[part]
290
+ }
171
291
 
172
- const parts = ref
173
- .substring(2)
174
- .split('/')
175
- .map(p => p.replace(/~1/g, '/').replace(/~0/g, '~'))
292
+ // Landed on another alias: follow it. Anything else is the target.
293
+ if (current != null &&
294
+ 'object' === typeof current &&
295
+ !Array.isArray(current) &&
296
+ 'string' === typeof current.$ref) {
297
+ const sib: any = {}
298
+ let hasSib = false
299
+ for (const k of Object.keys(current)) {
300
+ if ('$ref' === k) continue
301
+ sib[k] = current[k]
302
+ hasSib = true
303
+ }
304
+ if (hasSib) siblings.push(sib)
305
+ pointer = current.$ref
306
+ continue
307
+ }
176
308
 
177
- let current = root
178
- for (const part of parts) {
179
- if (current == null || typeof current !== 'object') return undefined
180
- current = current[part]
181
- }
309
+ if (0 === siblings.length) {
310
+ // No siblings anywhere on the chain: return the target itself, so
311
+ // multiple references keep sharing one object (see the note on
312
+ // addXRefsAndResolve — a fresh copy per site would defeat that).
313
+ return current
314
+ }
182
315
 
183
- return current
316
+ // Siblings present: a merged view is necessarily a new object. Apply
317
+ // deepest-first so the outermost alias's keywords win.
318
+ const merged: any = (null != current && 'object' === typeof current &&
319
+ !Array.isArray(current)) ? { ...current } : {}
320
+ for (let i = siblings.length - 1; 0 <= i; i--) {
321
+ Object.assign(merged, siblings[i])
322
+ }
323
+ return merged
324
+ }
184
325
  }
185
326
 
186
327
 
@@ -4,7 +4,7 @@ import { each, getx } from 'jostraca'
4
4
 
5
5
  import type { TransformResult, Transform } from '../transform'
6
6
 
7
- import { validator, canonize, inferFieldType, normalizeFieldName } from '../utility'
7
+ import { validator, canonize, inferFieldType, normalizeFieldName, envelopeProp } from '../utility'
8
8
 
9
9
  import { KIT } from '../types'
10
10
 
@@ -150,6 +150,21 @@ function findFieldDefs(
150
150
  fieldSets = getx(responses, '201 content "application/json" schema') ??
151
151
  getx(responses, '201 schema')
152
152
  }
153
+
154
+ // Single-entity responses get the same treatment the list branch above
155
+ // already gives collections: a body that is only an envelope around the
156
+ // entity — `{item: {...}}` — describes the WRAPPER, not the entity, so
157
+ // its sole property would otherwise be harvested as a field. That is
158
+ // how an entity `todoitem` ended up with a required `item` field of
159
+ // type object, which then appeared in the generated create/update data
160
+ // types. envelopeProp applies the same two rules used to pick the
161
+ // response transform, so the field list and the transform agree.
162
+ if ('list' != mop.name) {
163
+ const envelope = envelopeProp(fieldSets?.properties, mop.name)
164
+ if (null != envelope) {
165
+ fieldSets = fieldSets.properties[envelope]
166
+ }
167
+ }
153
168
  }
154
169
 
155
170
  // A QUERY (RFC 10008) request body is a filter/query schema, not the
@@ -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,