@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.
- package/dist/apidef.d.ts +3 -2
- package/dist/apidef.js +35 -5
- package/dist/apidef.js.map +1 -1
- package/dist/builder/entity/entity.d.ts +2 -1
- package/dist/builder/entity/entity.js +63 -0
- package/dist/builder/entity/entity.js.map +1 -1
- package/dist/builder/entity/info.js.map +1 -1
- package/dist/builder/entity.js.map +1 -1
- package/dist/builder/flow/flowHeuristic01.js.map +1 -1
- package/dist/builder/flow.js.map +1 -1
- package/dist/guide/guide.js +49 -6
- package/dist/guide/guide.js.map +1 -1
- package/dist/guide/heuristic01.js +99 -89
- package/dist/guide/heuristic01.js.map +1 -1
- package/dist/parse.js +151 -18
- package/dist/parse.js.map +1 -1
- package/dist/resolver.js.map +1 -1
- package/dist/transform/args.js.map +1 -1
- package/dist/transform/clean.js.map +1 -1
- package/dist/transform/entity.js.map +1 -1
- package/dist/transform/field.js +14 -0
- package/dist/transform/field.js.map +1 -1
- package/dist/transform/flow.js.map +1 -1
- package/dist/transform/flowstep.js.map +1 -1
- package/dist/transform/operation.js.map +1 -1
- package/dist/transform/select.js.map +1 -1
- package/dist/transform/top.d.ts +2 -1
- package/dist/transform/top.js +32 -2
- package/dist/transform/top.js.map +1 -1
- package/dist/transform.d.ts +8 -7
- package/dist/transform.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types.d.ts +97 -84
- package/dist/types.js.map +1 -1
- package/dist/utility.d.ts +6 -1
- package/dist/utility.js +205 -16
- package/dist/utility.js.map +1 -1
- package/package.json +8 -8
- package/src/apidef.ts +38 -4
- package/src/builder/entity/entity.ts +70 -1
- package/src/guide/guide.ts +50 -6
- package/src/guide/heuristic01.ts +37 -28
- package/src/parse.ts +161 -20
- package/src/transform/field.ts +16 -1
- package/src/transform/top.ts +37 -2
- package/src/tsconfig.json +1 -0
- package/src/types.ts +5 -0
- package/src/utility.ts +226 -16
package/src/apidef.ts
CHANGED
|
@@ -61,6 +61,8 @@ import {
|
|
|
61
61
|
writeFileSyncWarn,
|
|
62
62
|
relativizePath,
|
|
63
63
|
getModelPath,
|
|
64
|
+
VALID_CANON,
|
|
65
|
+
CANON_ONE,
|
|
64
66
|
} from './utility'
|
|
65
67
|
|
|
66
68
|
|
|
@@ -75,6 +77,7 @@ import { flowstepTransform } from './transform/flowstep'
|
|
|
75
77
|
import { cleanTransform } from './transform/clean'
|
|
76
78
|
|
|
77
79
|
import { makeEntityBuilder } from './builder/entity'
|
|
80
|
+
import { gcEntityFiles } from './builder/entity/entity'
|
|
78
81
|
import { makeFlowBuilder } from './builder/flow'
|
|
79
82
|
|
|
80
83
|
// Log non-fatal wierdness.
|
|
@@ -144,6 +147,7 @@ function ApiDef(opts: ApiDefOptions) {
|
|
|
144
147
|
// TODO: Validate spec
|
|
145
148
|
ctx = {
|
|
146
149
|
fs,
|
|
150
|
+
fsInjected: null != opts.fs,
|
|
147
151
|
log,
|
|
148
152
|
spec,
|
|
149
153
|
opts,
|
|
@@ -199,7 +203,7 @@ function ApiDef(opts: ApiDefOptions) {
|
|
|
199
203
|
|
|
200
204
|
// Step: guide (derive).
|
|
201
205
|
if (!ctrl.step.guide) {
|
|
202
|
-
return { ok: false, steps, start, end: Date.now(), ctrl }
|
|
206
|
+
return { ok: false, steps, start, end: Date.now(), ctrl, ctx }
|
|
203
207
|
}
|
|
204
208
|
|
|
205
209
|
const guideModel = await buildGuide(ctx)
|
|
@@ -214,8 +218,15 @@ function ApiDef(opts: ApiDefOptions) {
|
|
|
214
218
|
|
|
215
219
|
|
|
216
220
|
// Step: transformers (transform spec and guide into core structures).
|
|
221
|
+
// Early stops return the model built so far: `ctrl.step.generate = false`
|
|
222
|
+
// is the documented way to build the model in memory without writing
|
|
223
|
+
// files (see AGENTS.md), which requires `apimodel` in the result. The Go
|
|
224
|
+
// port already returns it from every early return.
|
|
217
225
|
if (!ctrl.step.transformers) {
|
|
218
|
-
return {
|
|
226
|
+
return {
|
|
227
|
+
ok: true, steps, start, end: Date.now(), ctrl,
|
|
228
|
+
guide: ctx.guide, apimodel: ctx.apimodel, ctx
|
|
229
|
+
}
|
|
219
230
|
}
|
|
220
231
|
|
|
221
232
|
await topTransform(ctx)
|
|
@@ -232,7 +243,10 @@ function ApiDef(opts: ApiDefOptions) {
|
|
|
232
243
|
|
|
233
244
|
// Step: builders (build generated sub models).
|
|
234
245
|
if (!ctrl.step.builders) {
|
|
235
|
-
return {
|
|
246
|
+
return {
|
|
247
|
+
ok: true, steps, start, end: Date.now(), ctrl,
|
|
248
|
+
guide: ctx.guide, apimodel: ctx.apimodel, ctx
|
|
249
|
+
}
|
|
236
250
|
}
|
|
237
251
|
|
|
238
252
|
const builders = [
|
|
@@ -247,7 +261,10 @@ function ApiDef(opts: ApiDefOptions) {
|
|
|
247
261
|
|
|
248
262
|
// Step: generate (generate model files).
|
|
249
263
|
if (!ctrl.step.generate) {
|
|
250
|
-
return {
|
|
264
|
+
return {
|
|
265
|
+
ok: true, steps, start, end: Date.now(), ctrl,
|
|
266
|
+
guide: ctx.guide, apimodel: ctx.apimodel, ctx
|
|
267
|
+
}
|
|
251
268
|
}
|
|
252
269
|
|
|
253
270
|
const jostraca = Jostraca({
|
|
@@ -285,6 +302,20 @@ function ApiDef(opts: ApiDefOptions) {
|
|
|
285
302
|
|
|
286
303
|
steps.push('generate')
|
|
287
304
|
|
|
305
|
+
// Garbage-collect entity model files no longer derived from the def.
|
|
306
|
+
// The builders only ever WRITE: a spec change that removes or renames a
|
|
307
|
+
// derived entity used to leave the old <name>.aontu behind forever.
|
|
308
|
+
// Runs after generate so the current set is on disk; guarded so only
|
|
309
|
+
// apidef-generated files under this build's outprefix are touched.
|
|
310
|
+
try {
|
|
311
|
+
const kitEntity = (ctx.apimodel?.main as any)?.[KIT]?.entity || {}
|
|
312
|
+
gcEntityFiles(fs, log, opts.folder as string, opts.outprefix,
|
|
313
|
+
Object.keys(kitEntity))
|
|
314
|
+
}
|
|
315
|
+
catch (err: any) {
|
|
316
|
+
log.warn({ point: 'entity-gc-failed', err, note: String(err?.message) })
|
|
317
|
+
}
|
|
318
|
+
|
|
288
319
|
const hasWarnings = 0 < warn.history.length
|
|
289
320
|
const endnote =
|
|
290
321
|
hasWarnings ? `PARTIAL BUILD! There were ${warn.history.length} warnings (see above).` :
|
|
@@ -450,6 +481,7 @@ export type {
|
|
|
450
481
|
export {
|
|
451
482
|
KIT,
|
|
452
483
|
ApiDef,
|
|
484
|
+
gcEntityFiles,
|
|
453
485
|
parse,
|
|
454
486
|
formatJSONIC,
|
|
455
487
|
depluralize,
|
|
@@ -457,4 +489,6 @@ export {
|
|
|
457
489
|
slugToPascalCase,
|
|
458
490
|
getModelPath,
|
|
459
491
|
nom,
|
|
492
|
+
VALID_CANON,
|
|
493
|
+
CANON_ONE,
|
|
460
494
|
}
|
|
@@ -66,6 +66,74 @@ function resolveEntity(
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
|
|
69
|
+
// Garbage-collect orphaned entity model files.
|
|
70
|
+
//
|
|
71
|
+
// The builder above EMITS one <outprefix><name>.aontu per derived entity but
|
|
72
|
+
// never removes anything, so an entity that disappears from the def — a spec
|
|
73
|
+
// rename, a dropped path, a schema rename that changes the derived entity
|
|
74
|
+
// name — leaves its old file behind on every regen. The orphan is not in the
|
|
75
|
+
// regenerated entity-index barrel, so it is silently dead weight at best; at
|
|
76
|
+
// worst a later hand-include resurrects a stale surface.
|
|
77
|
+
//
|
|
78
|
+
// Deletion is guarded three ways, so nothing a user could own is touched:
|
|
79
|
+
// 1. only `<outprefix>*.aontu` files inside the entity folder are candidates
|
|
80
|
+
// (a different outprefix belongs to a different def sharing the folder);
|
|
81
|
+
// 2. the current entity set and the index barrel are always kept;
|
|
82
|
+
// 3. the file must START with the generated header (`# Entity: `) — a file
|
|
83
|
+
// apidef did not write is left alone.
|
|
84
|
+
//
|
|
85
|
+
// GC failure must never fail a build: errors are logged and swallowed.
|
|
86
|
+
function gcEntityFiles(
|
|
87
|
+
fs: any,
|
|
88
|
+
log: any,
|
|
89
|
+
modelFolder: string,
|
|
90
|
+
outprefix: string | undefined,
|
|
91
|
+
entityNames: string[],
|
|
92
|
+
): string[] {
|
|
93
|
+
const removed: string[] = []
|
|
94
|
+
const prefix = null == outprefix ? '' : outprefix
|
|
95
|
+
const entityFolder = Path.join(modelFolder, 'entity')
|
|
96
|
+
|
|
97
|
+
const keep = new Set<string>(
|
|
98
|
+
entityNames.map((name) => prefix + name + '.aontu'))
|
|
99
|
+
keep.add(prefix + 'entity-index.aontu')
|
|
100
|
+
|
|
101
|
+
let entries: string[] = []
|
|
102
|
+
try {
|
|
103
|
+
entries = fs.readdirSync(entityFolder)
|
|
104
|
+
}
|
|
105
|
+
catch (_err: any) {
|
|
106
|
+
return removed // no entity folder yet — nothing to collect
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const entry of entries) {
|
|
110
|
+
if (!entry.endsWith('.aontu')) { continue }
|
|
111
|
+
if (!entry.startsWith(prefix)) { continue }
|
|
112
|
+
if (keep.has(entry)) { continue }
|
|
113
|
+
|
|
114
|
+
const file = Path.join(entityFolder, entry)
|
|
115
|
+
try {
|
|
116
|
+
const head = String(fs.readFileSync(file)).slice(0, 64)
|
|
117
|
+
if (!head.startsWith('# Entity: ')) { continue }
|
|
118
|
+
fs.unlinkSync(file)
|
|
119
|
+
removed.push(entry)
|
|
120
|
+
log?.info?.({
|
|
121
|
+
point: 'entity-gc', file: entry,
|
|
122
|
+
note: `removed orphaned entity model file ${entry} (no longer derived from the def)`,
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
catch (err: any) {
|
|
126
|
+
log?.warn?.({
|
|
127
|
+
point: 'entity-gc-failed', file: entry, err,
|
|
128
|
+
note: `could not gc ${entry}: ${err?.message}`,
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return removed
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
69
137
|
function fieldAliases(_entity: any): string {
|
|
70
138
|
// Field aliasing (mapping e.g. a `<name>_id` field onto the canonical
|
|
71
139
|
// `id`) is not currently implemented. The original heuristic referenced
|
|
@@ -81,5 +149,6 @@ function fieldAliases(_entity: any): string {
|
|
|
81
149
|
|
|
82
150
|
|
|
83
151
|
export {
|
|
84
|
-
resolveEntity
|
|
152
|
+
resolveEntity,
|
|
153
|
+
gcEntityFiles,
|
|
85
154
|
}
|
package/src/guide/guide.ts
CHANGED
|
@@ -88,7 +88,36 @@ async function buildGuide(ctx: ApiDefContext): Promise<any> {
|
|
|
88
88
|
errs,
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
|
|
91
|
+
// Only forward a *genuinely injected* fs.
|
|
92
|
+
//
|
|
93
|
+
// aontu resolves `@`-includes through @tabnas/multisource, which does:
|
|
94
|
+
// const P = null != ctx.meta?.fs ? Path.posix : Path
|
|
95
|
+
// i.e. it switches to POSIX path semantics whenever an fs is present, on
|
|
96
|
+
// the assumption that an injected fs is memfs keyed by POSIX paths.
|
|
97
|
+
//
|
|
98
|
+
// apidef defaults ctx.fs to the real node:fs (`opts.fs || Fs`), so
|
|
99
|
+
// forwarding it unconditionally made multisource parse *Windows* paths
|
|
100
|
+
// with Path.posix. 'D:\...\guide\x-guide.aontu' contains no '/', so the
|
|
101
|
+
// include base resolved to '' and sibling includes were looked up against
|
|
102
|
+
// the cwd instead of the guide folder — every build failed on Windows
|
|
103
|
+
// with `source not found: <prefix>base-guide.aontu`. Linux and macOS were
|
|
104
|
+
// unaffected because there Path and Path.posix are the same module.
|
|
105
|
+
//
|
|
106
|
+
// Callers that supply a real memfs (e.g. apidef-validate) still get it,
|
|
107
|
+
// and still get the POSIX semantics they need.
|
|
108
|
+
//
|
|
109
|
+
// Uses the explicit ctx.fsInjected flag rather than `Fs !== ctx.fs`:
|
|
110
|
+
// esModuleInterop compiles `import * as Fs` to __importStar(), which
|
|
111
|
+
// builds a fresh wrapper per module, so identity comparison across
|
|
112
|
+
// modules is always false.
|
|
113
|
+
if (ctx.fsInjected) {
|
|
114
|
+
opts.fs = ctx.fs
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Record what was actually handed to aontu, not what we intended to hand
|
|
118
|
+
// it, so the regression test fails if this block is ever changed back to
|
|
119
|
+
// an unconditional assignment.
|
|
120
|
+
ctx.work.guideAontuFs = undefined !== opts.fs
|
|
92
121
|
|
|
93
122
|
const guideModel = aontu.generate(src, opts)
|
|
94
123
|
|
|
@@ -212,15 +241,30 @@ async function buildBaseGuide(ctx: ApiDefContext) {
|
|
|
212
241
|
items(path.op).map(([opname, op]: [string, GuidePathOp]) => {
|
|
213
242
|
guideBlocks.push(` op: ${opname}: method: *${op.method}` +
|
|
214
243
|
sw(0 < op.why_op.length ? ' # ' + op.why_op : ''))
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
// op.transform.res — emitting `transform: res: *undefined` whenever
|
|
218
|
-
//
|
|
219
|
-
//
|
|
244
|
+
// Each transform is emitted only when set, and each on its own terms.
|
|
245
|
+
// (An earlier req-GUARDED block pushed a second res line built from
|
|
246
|
+
// op.transform.res — emitting `transform: res: *undefined` whenever a
|
|
247
|
+
// request was wrapped but the response was not. Hence the separate
|
|
248
|
+
// null checks below rather than one shared guard.)
|
|
220
249
|
if (null != op.transform.res) {
|
|
221
250
|
guideBlocks.push(
|
|
222
251
|
` op: ${opname}: transform: res: *${qt(op.transform.res)}|top`)
|
|
223
252
|
}
|
|
253
|
+
// The req transform is a MAP of body property -> source expression
|
|
254
|
+
// (see closedBodyTransform), so it takes one line per property. THE
|
|
255
|
+
// SERIALISED GUIDE IS WHAT THE TRANSFORM STEP READS: a transform not
|
|
256
|
+
// written here never reaches the model, which is why restricting a
|
|
257
|
+
// closed request body had no effect until this existed. Only the map
|
|
258
|
+
// form is representable as aontu paths; a scalar req is left alone.
|
|
259
|
+
const reqmap: any = op.transform.req
|
|
260
|
+
if (null != reqmap && 'object' === typeof reqmap) {
|
|
261
|
+
items(reqmap).map(([bodykey, source]: [string, any]) => {
|
|
262
|
+
if ('string' === typeof source) {
|
|
263
|
+
guideBlocks.push(` op: ${opname}: transform: req: ` +
|
|
264
|
+
`${qs(bodykey)}: *${qt(source)}|top`)
|
|
265
|
+
}
|
|
266
|
+
})
|
|
267
|
+
}
|
|
224
268
|
})
|
|
225
269
|
|
|
226
270
|
guideBlocks.push(` }`)
|
package/src/guide/heuristic01.ts
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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 '@
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
|
package/src/transform/field.ts
CHANGED
|
@@ -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
|