@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.
- package/dist/apidef.d.ts +1 -1
- package/dist/apidef.js +10 -0
- package/dist/apidef.js.map +1 -1
- package/dist/model.d.ts +49 -17
- package/dist/transform/args.js +39 -8
- package/dist/transform/args.js.map +1 -1
- package/dist/transform/entity.js +89 -5
- package/dist/transform/entity.js.map +1 -1
- package/dist/transform/field.js +18 -10
- package/dist/transform/field.js.map +1 -1
- package/dist/transform/operation.js +4 -4
- package/dist/transform/operation.js.map +1 -1
- package/dist/transform/select.js +7 -7
- package/dist/transform/select.js.map +1 -1
- package/dist/transform/top.js +34 -2
- package/dist/transform/top.js.map +1 -1
- package/dist/types.d.ts +2 -1
- package/dist/types.js.map +1 -1
- package/model/apidef.jsonic +40 -4
- package/package.json +2 -2
- package/src/apidef.ts +19 -1
- package/src/builder/flow/flowHeuristic01.ts +2 -2
- package/src/model.ts +104 -27
- package/src/transform/args.ts +43 -10
- package/src/transform/entity.ts +95 -5
- package/src/transform/field.ts +20 -11
- package/src/transform/operation.ts +5 -5
- package/src/transform/select.ts +10 -10
- package/src/transform/top.ts +37 -2
- package/src/types.ts +5 -1
package/src/transform/entity.ts
CHANGED
|
@@ -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
|
-
(
|
|
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)
|
package/src/transform/field.ts
CHANGED
|
@@ -20,7 +20,7 @@ import type {
|
|
|
20
20
|
OpName,
|
|
21
21
|
ModelOp,
|
|
22
22
|
ModelEntity,
|
|
23
|
-
|
|
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
|
|
46
|
+
const mpoints = mop.points
|
|
47
47
|
|
|
48
|
-
for (let
|
|
49
|
-
const opfields = resolveOpFields(ment, mop,
|
|
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,
|
|
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
|
-
|
|
88
|
+
mpoint: ModelPoint,
|
|
80
89
|
def: any
|
|
81
90
|
): ModelField[] {
|
|
82
91
|
const mfields: ModelField[] = []
|
|
83
|
-
const fielddefs = findFieldDefs(ment, mop,
|
|
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
|
-
|
|
113
|
+
mpoint: ModelPoint,
|
|
105
114
|
def: any
|
|
106
115
|
): SchemaDef[] {
|
|
107
116
|
const fielddefs: SchemaDef[] = []
|
|
108
|
-
const pathdef = def.paths[
|
|
117
|
+
const pathdef = def.paths[mpoint.orig]
|
|
109
118
|
|
|
110
|
-
const method =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
162
|
-
|
|
161
|
+
mpoint.transform.req = mpoint.transform.req ?? '`reqdata`'
|
|
162
|
+
mpoint.transform.res = mpoint.transform.res ?? '`body`'
|
|
163
163
|
|
|
164
|
-
return
|
|
164
|
+
return mpoint
|
|
165
165
|
})
|
|
166
166
|
}
|
|
167
167
|
}
|
package/src/transform/select.ts
CHANGED
|
@@ -20,7 +20,7 @@ import type {
|
|
|
20
20
|
OpName,
|
|
21
21
|
ModelOp,
|
|
22
22
|
ModelEntity,
|
|
23
|
-
|
|
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, (
|
|
40
|
-
const pdef: PathDef = def.paths[
|
|
41
|
-
resolveSelect(guide, ment, mop,
|
|
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
|
-
|
|
59
|
+
mpoint: ModelPoint,
|
|
60
60
|
_pdef: PathDef
|
|
61
61
|
) {
|
|
62
|
-
const select: any =
|
|
63
|
-
const margs: any =
|
|
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[
|
|
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<
|
|
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:
|
|
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) {
|
package/src/transform/top.ts
CHANGED
|
@@ -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
|
-
|
|
298
|
+
ModelPoint,
|
|
295
299
|
ModelOp,
|
|
296
300
|
ModelEntity,
|
|
297
301
|
} from './model'
|