@voxgig/apidef 5.10.0 → 6.0.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.
@@ -31,6 +31,17 @@ const entityTransform: Transform = async function(
31
31
 
32
32
  let msg = ''
33
33
 
34
+ // Pre-pass: merge collection paths into the entity that owns the
35
+ // per-instance paths. Heuristic01 sometimes assigns "/people" to a
36
+ // separate "*_search" entity (because the response wraps Person in
37
+ // a search/pagination component) while "/people/{id}" and
38
+ // "/people/{id}/anime" land on "person". Result: the person entity has
39
+ // no primary list endpoint, so direct-load tests can't bootstrap an
40
+ // ID. Move "/people" onto person here; this also clears the way for
41
+ // sensible flow generation (one entity, one collection, multiple
42
+ // sub-resources).
43
+ mergeCollectionPaths(guide, ctx.log)
44
+
34
45
  each(guide.entity, (guideEntity: GuideEntity, entname: string) => {
35
46
  ctx.log.debug({ point: 'guide-entity', note: entname })
36
47
 
@@ -41,10 +52,6 @@ const entityTransform: Transform = async function(
41
52
  name: entname,
42
53
  op: {},
43
54
  fields: [],
44
- id: {
45
- name: 'id',
46
- field: 'id',
47
- },
48
55
  relations,
49
56
  }
50
57
 
@@ -57,6 +64,81 @@ const entityTransform: Transform = async function(
57
64
  }
58
65
 
59
66
 
67
+ // Move "/X" paths onto the entity that owns "/X/{id}" or "/X/{id}/sub".
68
+ // Only acts when the path "/X" sits on a different entity than the
69
+ // per-instance paths — leaves correctly-classified APIs alone.
70
+ function mergeCollectionPaths(guide: any, log?: any) {
71
+ const entities = guide.entity as Record<string, any>
72
+
73
+ // First pass: build collectionRoot -> owner-entity-name map.
74
+ // owner is the entity whose name contains "/X/{...}" paths; we prefer
75
+ // the owner whose direct-load path is "/X/{id}" (no further segments)
76
+ // so that nested-resource entities don't claim the root.
77
+ const rootOwners: Record<string, { ename: string, depth: number }> = {}
78
+
79
+ for (const [ename, entity] of Object.entries(entities)) {
80
+ for (const pathStr of Object.keys(entity.path ?? {})) {
81
+ // Match /A/{...} or /A/{...}/...
82
+ const m = pathStr.match(/^\/([^\/{}]+)\/\{[^}]+\}(\/.*)?$/)
83
+ if (!m) continue
84
+ const root = m[1]
85
+ const trailing = m[2] ?? ''
86
+ // Depth = number of segments after the {id} placeholder. Lower
87
+ // depth wins (e.g. "/people/{id}" beats "/people/{id}/anime").
88
+ const depth = trailing === '' ? 0 : trailing.split('/').filter(Boolean).length
89
+
90
+ const cur = rootOwners[root]
91
+ if (!cur || depth < cur.depth) {
92
+ rootOwners[root] = { ename, depth }
93
+ }
94
+ }
95
+ }
96
+
97
+ // Second pass: for each entity with a "/X" path, if X has an owner
98
+ // elsewhere, move the path there.
99
+ for (const [ename, entity] of Object.entries(entities)) {
100
+ if (entity.path == null) continue
101
+ const pathsToMove: string[] = []
102
+
103
+ for (const pathStr of Object.keys(entity.path)) {
104
+ // Match exactly /X (one literal segment, no params).
105
+ const m = pathStr.match(/^\/([^\/{}]+)$/)
106
+ if (!m) continue
107
+ const root = m[1]
108
+ const owner = rootOwners[root]
109
+ if (owner && owner.ename !== ename) {
110
+ pathsToMove.push(pathStr)
111
+ }
112
+ }
113
+
114
+ for (const pathStr of pathsToMove) {
115
+ const owner = rootOwners[pathStr.slice(1)]
116
+ const targetEntity = entities[owner.ename]
117
+ if (targetEntity == null) continue
118
+ targetEntity.path = targetEntity.path ?? {}
119
+ // If the target already has this path (unlikely), leave it alone.
120
+ if (targetEntity.path[pathStr] == null) {
121
+ targetEntity.path[pathStr] = entity.path[pathStr]
122
+ }
123
+ delete entity.path[pathStr]
124
+ log?.debug?.({
125
+ point: 'merge-collection-path',
126
+ path: pathStr,
127
+ from: ename,
128
+ to: owner.ename,
129
+ })
130
+ }
131
+ }
132
+
133
+ // Drop entities that are now empty after the merge.
134
+ for (const ename of Object.keys(entities)) {
135
+ if (entities[ename].path == null || Object.keys(entities[ename].path).length === 0) {
136
+ delete entities[ename]
137
+ }
138
+ }
139
+ }
140
+
141
+
60
142
 
61
143
  function resolvePathList(guideEntity: GuideEntity, def: { paths: Record<string, any> }) {
62
144
  const paths$: PathDesc[] = []
@@ -90,10 +172,18 @@ function resolvePathList(guideEntity: GuideEntity, def: { paths: Record<string,
90
172
 
91
173
 
92
174
  function buildRelations(guideEntity: any, paths$: PathDesc[]) {
175
+ // An ancestor is a literal collection segment (e.g. "rems") followed by
176
+ // a path-param placeholder that names an instance ID. We only collect
177
+ // the literal parts — placeholder parts like "{año}" must be excluded
178
+ // even when they're themselves followed by another placeholder, otherwise
179
+ // downstream code treats `{año}` as an ancestor name and emits broken
180
+ // idmap entries / match keys.
93
181
  let ancestors: any[] = paths$
94
182
  .map(pli => pli.parts
95
183
  .map((p, i) =>
96
- (pli.parts[i + 1]?.[0] === '{' && pli.parts[i + 1] !== '{id}') ? p : null)
184
+ ('{' !== p[0] &&
185
+ pli.parts[i + 1]?.[0] === '{' &&
186
+ pli.parts[i + 1] !== '{id}') ? p : null)
97
187
  .filter(p => null != p))
98
188
  .filter(n => 0 < n.length)
99
189
  .sort((a, b) => a.length - b.length)
@@ -20,7 +20,7 @@ import type {
20
20
  OpName,
21
21
  ModelOp,
22
22
  ModelEntity,
23
- ModelTarget,
23
+ ModelPoint,
24
24
  ModelField,
25
25
  } from '../model'
26
26
 
@@ -43,10 +43,10 @@ const fieldTransform: Transform = async function(
43
43
  for (let opname of opFieldPrecedence) {
44
44
  const mop = ment.op[opname]
45
45
  if (mop) {
46
- const mtargets = mop.points
46
+ const mpoints = mop.points
47
47
 
48
- for (let mtarget of mtargets) {
49
- const opfields = resolveOpFields(ment, mop, mtarget, def)
48
+ for (let mpoint of mpoints) {
49
+ const opfields = resolveOpFields(ment, mop, mpoint, def)
50
50
 
51
51
  for (let opfield of opfields) {
52
52
  if (!seen[opfield.name]) {
@@ -54,7 +54,7 @@ const fieldTransform: Transform = async function(
54
54
  seen[opfield.name] = opfield
55
55
  }
56
56
  else {
57
- mergeField(ment, mop, mtarget, def, seen[opfield.name], opfield)
57
+ mergeField(ment, mop, mpoint, def, seen[opfield.name], opfield)
58
58
  }
59
59
  }
60
60
  }
@@ -65,6 +65,15 @@ const fieldTransform: Transform = async function(
65
65
  return a.name < b.name ? -1 : a.name > b.name ? 1 : 0
66
66
  })
67
67
 
68
+ // Mark the entity as having an id only when the spec actually declares one.
69
+ // Downstream (test generators, fixture builders) gate id-specific code on
70
+ // this presence so that public read-only APIs without ids don't get
71
+ // bogus id assertions.
72
+ const idField = fields.find((f: ModelField) => 'id' === f.name)
73
+ if (idField) {
74
+ ment.id = { name: 'id', field: 'id' }
75
+ }
76
+
68
77
  msg += ment.name + ' '
69
78
  })
70
79
 
@@ -76,11 +85,11 @@ const fieldTransform: Transform = async function(
76
85
  function resolveOpFields(
77
86
  ment: ModelEntity,
78
87
  mop: ModelOp,
79
- mtarget: ModelTarget,
88
+ mpoint: ModelPoint,
80
89
  def: any
81
90
  ): ModelField[] {
82
91
  const mfields: ModelField[] = []
83
- const fielddefs = findFieldDefs(ment, mop, mtarget, def)
92
+ const fielddefs = findFieldDefs(ment, mop, mpoint, def)
84
93
 
85
94
  for (let fielddef of fielddefs) {
86
95
  const fieldname = (fielddef as any).key$ as string
@@ -101,13 +110,13 @@ function resolveOpFields(
101
110
  function findFieldDefs(
102
111
  _ment: ModelEntity,
103
112
  mop: ModelOp,
104
- mtarget: ModelTarget,
113
+ mpoint: ModelPoint,
105
114
  def: any
106
115
  ): SchemaDef[] {
107
116
  const fielddefs: SchemaDef[] = []
108
- const pathdef = def.paths[mtarget.orig]
117
+ const pathdef = def.paths[mpoint.orig]
109
118
 
110
- const method = mtarget.method.toLowerCase()
119
+ const method = mpoint.method.toLowerCase()
111
120
  const opdef: any = pathdef[method]
112
121
 
113
122
  if (opdef) {
@@ -303,7 +312,7 @@ function inferTypeFromValue(value: any): string {
303
312
  function mergeField(
304
313
  ment: ModelEntity,
305
314
  mop: ModelOp,
306
- mtarget: ModelTarget,
315
+ mpoint: ModelPoint,
307
316
  def: any,
308
317
  exisingField: ModelField,
309
318
  newField: ModelField
@@ -19,7 +19,7 @@ import type {
19
19
  OpName,
20
20
  ModelOpMap,
21
21
  ModelOp,
22
- ModelTarget,
22
+ ModelPoint,
23
23
  } from '../model'
24
24
 
25
25
 
@@ -146,7 +146,7 @@ function resolveOp(opname: OpName, gent: GuideEntity): undefined | ModelOp {
146
146
  // rewrote the freshly-renamed `{id}` into `{project_id}` again).
147
147
  const parts = p.parts
148
148
 
149
- const mtarget: ModelTarget = {
149
+ const mpoint: ModelPoint = {
150
150
  orig: p.orig,
151
151
  parts,
152
152
  rename: p.rename,
@@ -158,10 +158,10 @@ function resolveOp(opname: OpName, gent: GuideEntity): undefined | ModelOp {
158
158
  }
159
159
  }
160
160
 
161
- mtarget.transform.req = mtarget.transform.req ?? '`reqdata`'
162
- mtarget.transform.res = mtarget.transform.res ?? '`body`'
161
+ mpoint.transform.req = mpoint.transform.req ?? '`reqdata`'
162
+ mpoint.transform.res = mpoint.transform.res ?? '`body`'
163
163
 
164
- return mtarget
164
+ return mpoint
165
165
  })
166
166
  }
167
167
  }
@@ -20,7 +20,7 @@ import type {
20
20
  OpName,
21
21
  ModelOp,
22
22
  ModelEntity,
23
- ModelTarget,
23
+ ModelPoint,
24
24
  ModelArg,
25
25
  } from '../model'
26
26
 
@@ -36,9 +36,9 @@ const selectTransform: Transform = async function(
36
36
 
37
37
  each(kit.entity, (ment: ModelEntity, _entname: string) => {
38
38
  each(ment.op, (mop: ModelOp, _opname: OpName) => {
39
- each(mop.points, (mtarget: ModelTarget) => {
40
- const pdef: PathDef = def.paths[mtarget.orig]
41
- resolveSelect(guide, ment, mop, mtarget, pdef)
39
+ each(mop.points, (mpoint: ModelPoint) => {
40
+ const pdef: PathDef = def.paths[mpoint.orig]
41
+ resolveSelect(guide, ment, mop, mpoint, pdef)
42
42
  })
43
43
  if (null != mop.points && 0 < mop.points.length) {
44
44
  sortPoints(guide, ment, mop)
@@ -56,11 +56,11 @@ function resolveSelect(
56
56
  guide: Guide,
57
57
  ment: ModelEntity,
58
58
  _mop: ModelOp,
59
- mtarget: ModelTarget,
59
+ mpoint: ModelPoint,
60
60
  _pdef: PathDef
61
61
  ) {
62
- const select: any = mtarget.select
63
- const margs: any = mtarget.args
62
+ const select: any = mpoint.select
63
+ const margs: any = mpoint.args
64
64
 
65
65
  const argkinds = ['params', 'query', 'header', 'cookie']
66
66
 
@@ -75,7 +75,7 @@ function resolveSelect(
75
75
  select.exist.sort()
76
76
 
77
77
  const gent = guide.entity[ment.name]
78
- const gpath = gent.path[mtarget.orig]
78
+ const gpath = gent.path[mpoint.orig]
79
79
 
80
80
  if (gpath.action) {
81
81
  const actname = Object.keys(gpath.action).sort()[0]
@@ -94,12 +94,12 @@ function sortPoints(
94
94
  mop: ModelOp,
95
95
  ) {
96
96
  // Cache joined exist strings to avoid recomputing on every comparison.
97
- const existCache = new Map<ModelTarget, string>()
97
+ const existCache = new Map<ModelPoint, string>()
98
98
  for (const pt of mop.points) {
99
99
  existCache.set(pt, pt.select.exist.join('\t'))
100
100
  }
101
101
 
102
- mop.points.sort((a: ModelTarget, b: ModelTarget) => {
102
+ mop.points.sort((a: ModelPoint, b: ModelPoint) => {
103
103
  // longest exist len first
104
104
  let order = b.select.exist.length - a.select.exist.length
105
105
  if (0 === order) {
@@ -41,8 +41,8 @@ const topTransform = async function(
41
41
  const { apimodel, def } = ctx
42
42
  const kit: KitModel = apimodel.main[KIT]
43
43
 
44
- kit.info = def.info
45
- kit.info.servers = def.servers ?? []
44
+ kit.info = stringifyInfoScalars(def.info ?? {})
45
+ kit.info.servers = stringifyInfoScalars(def.servers ?? [])
46
46
 
47
47
  // Swagger 2.0
48
48
  if (def.host) {
@@ -51,10 +51,45 @@ const topTransform = async function(
51
51
  })
52
52
  }
53
53
 
54
+ // A usable SDK requires a base URL. OpenAPI 3 puts it in `servers[].url`;
55
+ // Swagger 2 derives it from `host` + `basePath`. If neither yields a
56
+ // non-empty url, the generated SDK has no way to issue requests, so fail
57
+ // the apidef model build rather than emit broken code.
58
+ const firstServerUrl: any = kit.info.servers?.[0]?.url
59
+ if (null == firstServerUrl || '' === String(firstServerUrl).trim()) {
60
+ throw new Error(
61
+ 'apidef: no server URL found in API definition (servers[0].url is required).'
62
+ )
63
+ }
64
+
54
65
  return { ok: true, msg: 'top' }
55
66
  }
56
67
 
57
68
 
69
+ // OpenAPI's `info` object (and the `servers` array) declares every scalar
70
+ // leaf as a string. YAML/JSON parsers don't enforce that — `version: 2`
71
+ // without quotes parses as the number 2, `version: true` as a boolean.
72
+ // Apidef's downstream schema (apidef.jsonic) unifies info fields as
73
+ // `string`, so non-string scalars cause an aontu unify failure during
74
+ // model resolution. Normalise scalar leaves to strings here, at the
75
+ // model-build boundary, rather than relax the schema.
76
+ function stringifyInfoScalars(node: any): any {
77
+ if (null == node) return node
78
+ if (Array.isArray(node)) return node.map(stringifyInfoScalars)
79
+ if ('object' === typeof node) {
80
+ const out: Record<string, any> = {}
81
+ for (const [k, v] of Object.entries(node)) {
82
+ out[k] = stringifyInfoScalars(v)
83
+ }
84
+ return out
85
+ }
86
+ if ('number' === typeof node || 'boolean' === typeof node) {
87
+ return String(node)
88
+ }
89
+ return node
90
+ }
91
+
92
+
58
93
  export {
59
94
  topTransform
60
95
  }
package/src/types.ts CHANGED
@@ -117,6 +117,10 @@ type KitModel = {
117
117
 
118
118
  type ApiDefResult = {
119
119
  ok: boolean
120
+ // True when apidef wrote model source files (e.g. entity / flow jsonics).
121
+ // Producer host (voxgig-model) uses this to re-resolve the unified model
122
+ // before downstream actions run, so they see the freshly written sources.
123
+ reload?: boolean
120
124
  start: number
121
125
  end: number
122
126
  steps: string[]
@@ -291,7 +295,7 @@ export type {
291
295
  ModelFieldOp,
292
296
  ModelField,
293
297
  ModelArg,
294
- ModelTarget,
298
+ ModelPoint,
295
299
  ModelOp,
296
300
  ModelEntity,
297
301
  } from './model'