@voxgig/apidef 5.7.0 → 5.8.2

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.
@@ -71,9 +71,16 @@ function resolveArgs(ment: ModelEntity, mop: ModelOp, mtarget: ModelTarget, argd
71
71
  const touchedKeys = new Set<string>()
72
72
 
73
73
  each(argdefs, (argdef: ParameterDef) => {
74
- const orig = depluralize(snakify(normalizeFieldName(argdef.name)))
74
+ // Spec name as written (e.g. `dataType`) is what the rename map is keyed
75
+ // by; the snakified form is the user-friendly runtime identifier.
76
+ const specName = normalizeFieldName(argdef.name)
77
+ const orig = depluralize(snakify(specName))
75
78
  const kind = ARG_KIND[argdef.in] ?? 'query'
76
- const name = mtarget.rename[kind]?.[orig] ?? orig
79
+ // Rename map can be keyed by either the spec original (camelCase) or by
80
+ // the snakified form depending on which path went through heuristic01.
81
+ // Try both before falling through to `orig`.
82
+ const renameMap = mtarget.rename[kind]
83
+ const name = renameMap?.[specName] ?? renameMap?.[orig] ?? orig
77
84
  const marg: ModelArg = {
78
85
  name,
79
86
  orig,
@@ -1,6 +1,6 @@
1
1
 
2
2
 
3
- import { each } from 'jostraca'
3
+ import { each, snakify } from 'jostraca'
4
4
 
5
5
  import type { TransformResult, Transform } from '../transform'
6
6
 
@@ -21,6 +21,11 @@ import type {
21
21
  ModelEntity,
22
22
  } from '../model'
23
23
 
24
+ import {
25
+ depluralize,
26
+ normalizeFieldName,
27
+ } from '../utility'
28
+
24
29
 
25
30
 
26
31
  const entityTransform: Transform = async function(
@@ -67,7 +72,24 @@ function resolvePathList(guideEntity: GuideEntity, def: { paths: Record<string,
67
72
 
68
73
  each(rename.param, (param: any) => {
69
74
  const pI = parts.indexOf('{' + param.key$ + '}')
70
- parts[pI] = '{' + param.val$ + '}'
75
+ if (pI >= 0) parts[pI] = '{' + param.val$ + '}'
76
+ })
77
+
78
+ // Implicit snake_case normalization for any path placeholder that wasn't
79
+ // explicitly renamed. apidef's args transform snake-cases param names
80
+ // (e.g. spec `platformKey` → param.name `platform_key`); without
81
+ // normalizing the placeholder to match, runtime URL substitution by
82
+ // param.name fails to fill `{platformKey}`.
83
+ rename.param = (rename.param as any) ?? {}
84
+ parts.forEach((part: string, i: number) => {
85
+ const m = part.match(/^\{(.+)\}$/)
86
+ if (!m) return
87
+ const placeholder = m[1]
88
+ const snake = depluralize(snakify(normalizeFieldName(placeholder)))
89
+ if (snake !== placeholder && (rename.param as any)[placeholder] === undefined) {
90
+ ; (rename.param as any)[placeholder] = snake
91
+ parts[i] = '{' + snake + '}'
92
+ }
71
93
  })
72
94
 
73
95
  const pathdesc: PathDesc = {
@@ -119,9 +119,23 @@ function findFieldDefs(
119
119
  if (responses) {
120
120
  fieldSets = getx(responses, '200 content "application/json" schema') ??
121
121
  getx(responses, '200 schema')
122
- if ('get' === method && 'list' == mop.name) {
123
- fieldSets = getx(responses, '201 content "application/json" schema items') ??
124
- getx(responses, '201 schema items')
122
+ if ('list' == mop.name) {
123
+ // List responses commonly come in three shapes:
124
+ // 1. direct array — { type: array, items: { ...item } }
125
+ // 2. wrapper object — { properties: { items: [Item], page, ... } }
126
+ // (a single array-of-object property inside an object schema)
127
+ // 3. legacy "list of created items" under 201
128
+ // Resolve to the inner item schema when we can identify one
129
+ // unambiguously; otherwise fall through to the 200 schema as-is.
130
+ const unwrapped = unwrapArrayWrapper(fieldSets)
131
+ if (unwrapped) {
132
+ fieldSets = unwrapped
133
+ }
134
+ else {
135
+ const fromCreated = getx(responses, '201 content "application/json" schema items') ??
136
+ getx(responses, '201 schema items')
137
+ if (fromCreated) fieldSets = fromCreated
138
+ }
125
139
  }
126
140
  else if ('put' === method && null == fieldSets) {
127
141
  fieldSets = getx(responses, '201 content "application/json" schema') ??
@@ -178,7 +192,7 @@ function inferFieldsFromExamples(opdef: any): SchemaDef[] {
178
192
  }
179
193
 
180
194
  const fielddefs: SchemaDef[] = []
181
- for (const [key, value] of Object.entries(example)) {
195
+ for (const [key, value] of Object.entries(example).sort(([a],[b]) => a < b ? -1 : a > b ? 1 : 0)) {
182
196
  const fielddef: any = {
183
197
  key$: key,
184
198
  type: inferTypeFromValue(value),
@@ -237,6 +251,42 @@ function unwrapExample(example: any): any {
237
251
  }
238
252
 
239
253
 
254
+ // unwrapArrayWrapper inspects a list-response schema and, when it is an
255
+ // object with a single array-of-object-schema property (e.g.
256
+ // { boards: [Board] }, { items: [Foo], page, total, ... }), returns the
257
+ // inner item schema so that field resolution sees the actual entity
258
+ // properties rather than the wrapper's bookkeeping.
259
+ //
260
+ // Returns null if the input is not unambiguously such a wrapper:
261
+ // - schema is already an array → return null (let caller use it directly)
262
+ // - no array-of-object-schema property → return null
263
+ // - more than one array-of-object-schema property → ambiguous, return null
264
+ function unwrapArrayWrapper(schema: any): any {
265
+ if (null == schema || 'object' !== typeof schema) return null
266
+ // Direct list shape — caller can resolve from items directly.
267
+ if (schema.type === 'array' && schema.items) {
268
+ const items = schema.items
269
+ if (items && (items.properties || Array.isArray(items.allOf))) {
270
+ return items
271
+ }
272
+ return null
273
+ }
274
+ if (null == schema.properties || 'object' !== typeof schema.properties) return null
275
+ let resolved: any = null
276
+ for (const key of Object.keys(schema.properties)) {
277
+ const prop = schema.properties[key]
278
+ if (null == prop || 'object' !== typeof prop) continue
279
+ if (prop.type !== 'array' || null == prop.items) continue
280
+ const items = prop.items
281
+ if (null == items || 'object' !== typeof items) continue
282
+ if (!items.properties && !Array.isArray(items.allOf)) continue
283
+ if (resolved != null) return null // ambiguous: multiple array-of-object props
284
+ resolved = items
285
+ }
286
+ return resolved
287
+ }
288
+
289
+
240
290
  function inferTypeFromValue(value: any): string {
241
291
  if (null == value) return 'string'
242
292
  if ('boolean' === typeof value) return 'boolean'
@@ -1,6 +1,6 @@
1
1
 
2
2
 
3
- import { each } from 'jostraca'
3
+ import { each, camelify, lcf } from 'jostraca'
4
4
 
5
5
  import type { TransformResult, Transform } from '../transform'
6
6
 
@@ -10,6 +10,43 @@ import {
10
10
  nom,
11
11
  } from '../utility'
12
12
 
13
+
14
+ // Detect a path-parameter that is in fact the entity's own id, after URL
15
+ // renaming. Three ways an identity can show up:
16
+ // 1. The param literally has name 'id' (the common case for e.g. /things/{id}).
17
+ // 2. The param's lower-camelCase name appears in `point.rename.param` mapping
18
+ // to 'id' — e.g. `{connectionId: 'id'}` for `/companies/{company_id}/connections/{id}`.
19
+ // In this case the param's own name is `connection_id` (apidef snake-cased
20
+ // it), which doesn't equal 'id' but the placeholder in `parts` is `{id}`.
21
+ // 3. Positional convention: for singleton ops (load/update/remove), the
22
+ // LAST `{X}` placeholder in the path is the entity's own id. Catches
23
+ // cases where the entity name and path placeholder differ in spelling
24
+ // (e.g. entity `enviroment` vs path `/environments/{environment_id}`)
25
+ // and apidef therefore didn't synthesize a rename-to-id.
26
+ //
27
+ // Without this helper, the flow generator double-counts the entity's id —
28
+ // emitting it as both `srcdatavar.id` AND a separate body field — which the
29
+ // in-memory test mock then requires to match a non-existent field on the
30
+ // stored entity.
31
+ function isEntityIdParam(point: any, param: any, opname?: string): boolean {
32
+ if ('id' === param?.name) return true
33
+ const renameMap = point?.rename?.param
34
+ if (renameMap && param?.name) {
35
+ const camel = lcf(camelify(param.name))
36
+ if ('id' === renameMap[camel]) return true
37
+ }
38
+ if ('update' === opname || 'load' === opname || 'remove' === opname) {
39
+ const parts: any[] = point?.parts || []
40
+ let last: string | null = null
41
+ for (const p of parts) {
42
+ const m = String(p).match(/^\{(.+)\}$/)
43
+ if (m) last = m[1]
44
+ }
45
+ if (last && last === param?.name) return true
46
+ }
47
+ return false
48
+ }
49
+
13
50
  import { KIT } from '../types'
14
51
 
15
52
  import type { KitModel } from '../types'
@@ -116,6 +153,24 @@ function newFlowStep(opname: OpName, args: Record<string, any>): ModelEntityFlow
116
153
  }
117
154
 
118
155
 
156
+ // Reverse-lookup: given a point with rename.param like {spaceId: 'id'} or
157
+ // {space_id: 'id'}, return the snake_case ORIGINAL name (e.g. 'space_id') of
158
+ // any param whose URL placeholder is now `{id}`. Returns null when no
159
+ // rename-to-id is recorded — the literal `id` then represents the entity's
160
+ // own id and createStep should skip it.
161
+ function originalSnakeNameOfRenamedId(point: any): string | null {
162
+ const renameMap = point?.rename?.param || {}
163
+ for (const [src, dst] of Object.entries(renameMap)) {
164
+ if ('id' === dst) {
165
+ const srcStr = String(src)
166
+ // Already snake_case? Use as-is. Otherwise convert to snake form.
167
+ return srcStr.includes('_') ? srcStr : (srcStr.replace(/[A-Z]/g, m => '_' + m.toLowerCase()).replace(/^_/, ''))
168
+ }
169
+ }
170
+ return null
171
+ }
172
+
173
+
119
174
  const createStep: MakeFlowStep = (
120
175
  opmap: any,
121
176
  flow: ModelEntityFlow,
@@ -128,17 +183,59 @@ const createStep: MakeFlowStep = (
128
183
  const step = newFlowStep('create', args)
129
184
 
130
185
  each(point.args.params, (param: any) => {
131
- // id should not be here in the first place
132
- if ('id' !== param.name) {
133
- step.match[param.name] = args.input?.[param.name] ?? param.name.replace(/_id/, '') + '01'
186
+ if ('id' === param.name) {
187
+ // For CREATE, `id` in the path is NOT the entity's own id (entity is
188
+ // being created here — its id doesn't exist yet). It's some parent's
189
+ // id renamed by apidef's path normalization (e.g. `space_id` → `id`
190
+ // in `/spaces/{id}/space_memberships` for SpaceMembership). Recover
191
+ // the original snake_case name so the test seeds the parent's id
192
+ // into both the created entity's data AND the URL.
193
+ const origName = originalSnakeNameOfRenamedId(point)
194
+ if (origName) {
195
+ step.match[origName] = args.input?.[origName] ?? origName.replace(/_id/, '') + '01'
196
+ }
197
+ // If there's no rename-from, this is genuinely the entity's id — skip
198
+ // (the create call generates it).
199
+ return
134
200
  }
201
+ step.match[param.name] = args.input?.[param.name] ?? param.name.replace(/_id/, '') + '01'
135
202
  })
136
203
 
204
+ // Also seed any path-param fields required by other ops (typically LIST
205
+ // through a sibling parent path), so the in-memory test mock can find
206
+ // the just-created entity when a later step queries by those fields.
207
+ // Without this, a metric created at /pages/{page_id}/metrics/data lacks
208
+ // the page_access_user_id field required by
209
+ // /pages/{page_id}/page_access_users/{page_access_user_id}/metrics LIST.
210
+ seedRelatedOpParams(opmap, point, step)
211
+
137
212
  flow.step.push(step)
138
213
  }
139
214
  }
140
215
 
141
216
 
217
+ function seedRelatedOpParams(opmap: any, createPoint: any, step: ModelEntityFlowStep) {
218
+ const otherOps = ['list', 'load', 'update', 'remove']
219
+ for (const opname of otherOps) {
220
+ const op = opmap[opname]
221
+ if (!op?.points) continue
222
+ for (const point of op.points) {
223
+ const params: any[] = point?.args?.params || []
224
+ for (const param of params) {
225
+ if (!param?.name) continue
226
+ if (isEntityIdParam(point, param, opname as any)) continue
227
+ if (step.match[param.name] !== undefined) continue
228
+ // For renamed-from-id params on CREATE's chosen point we'd already
229
+ // have set the snake-case origin; don't double-write.
230
+ if ('id' === param.name) continue
231
+ step.match[param.name] =
232
+ param.name.replace(/_id/, '') + '01'
233
+ }
234
+ }
235
+ }
236
+ }
237
+
238
+
142
239
  const listStep: MakeFlowStep = (
143
240
  opmap: any,
144
241
  flow: ModelEntityFlow,
@@ -151,6 +248,17 @@ const listStep: MakeFlowStep = (
151
248
  const step = newFlowStep('list', args)
152
249
 
153
250
  each(point.args.params, (param: any) => {
251
+ if ('id' === param.name) {
252
+ // For LIST, `id` in the path is a parent's id renamed by apidef
253
+ // (LIST doesn't address a single entity by id). Recover the original
254
+ // snake_case name so test code references a real idmap entry rather
255
+ // than landing on the bogus `id01` default.
256
+ const origName = originalSnakeNameOfRenamedId(point)
257
+ if (origName) {
258
+ step.match[origName] = args.input?.[origName] ?? origName.replace(/_id/, '') + '01'
259
+ }
260
+ return
261
+ }
154
262
  step.match[param.name] = args.input?.[param.name] ?? param.name.replace(/_id/, '') + '01'
155
263
  })
156
264
 
@@ -171,12 +279,12 @@ const updateStep: MakeFlowStep = (
171
279
  const step = newFlowStep('update', args)
172
280
 
173
281
  each(point.args.params, (param: any) => {
174
- if ('id' === param.name) {
175
- step.data.id = args.input?.id ?? ent.name + '01'
176
- }
177
- else {
178
- step.data[param.name] = args.input?.[param.name] ?? param.name.replace(/_id/, '') + '01'
282
+ if (isEntityIdParam(point, param, 'update')) {
283
+ // Entity's own id — supplied at test time via the loaded/created
284
+ // entity's id field, not as a separate body parameter. Skip.
285
+ return
179
286
  }
287
+ step.data[param.name] = args.input?.[param.name] ?? param.name.replace(/_id/, '') + '01'
180
288
  })
181
289
 
182
290
  flow.step.push(step)
@@ -196,7 +304,7 @@ const loadStep: MakeFlowStep = (
196
304
  const step = newFlowStep('load', args)
197
305
 
198
306
  each(point.args.params, (param: any) => {
199
- if ('id' === param.name) {
307
+ if (isEntityIdParam(point, param, 'load')) {
200
308
  step.match.id = args.input?.id ?? ent.name + '01'
201
309
  }
202
310
  else {
@@ -221,7 +329,7 @@ const removeStep: MakeFlowStep = (
221
329
  const step = newFlowStep('remove', args)
222
330
 
223
331
  each(point.args.params, (param: any) => {
224
- if ('id' === param.name) {
332
+ if (isEntityIdParam(point, param, 'remove')) {
225
333
  step.match.id = args.input?.id ?? ent.name + '01'
226
334
  }
227
335
  else {
@@ -137,7 +137,14 @@ function resolveOp(opname: OpName, gent: GuideEntity): undefined | ModelOp {
137
137
  mop = {
138
138
  name: opname,
139
139
  points: opdesc.paths.map((p: PathDesc) => {
140
- const parts = applyRename(p)
140
+ // Renames already applied by entity.ts resolvePathList — re-applying
141
+ // here corrupted paths for any spec where rename map maps an old
142
+ // name to a value that another rename maps to a different new name
143
+ // (e.g. gitlab `/groups/{id}/badges/{badge_id}` with rename
144
+ // `{badge_id: 'id', id: 'project_id'}` ended up as
145
+ // `/groups/{project_id}/badges/{project_id}` — the second pass
146
+ // rewrote the freshly-renamed `{id}` into `{project_id}` again).
147
+ const parts = p.parts
141
148
 
142
149
  const mtarget: ModelTarget = {
143
150
  orig: p.orig,
@@ -162,12 +169,6 @@ function resolveOp(opname: OpName, gent: GuideEntity): undefined | ModelOp {
162
169
  }
163
170
 
164
171
 
165
- function applyRename(pathdesc: PathDesc): string[] {
166
- const prn: Record<string, string> = pathdesc.rename?.param ?? {}
167
- return pathdesc.parts.map(p => '{' === p[0] ? (prn[p.substring(1, p.length - 1)] ?? p) : p)
168
- }
169
-
170
-
171
172
 
172
173
 
173
174
  export {
@@ -78,7 +78,7 @@ function resolveSelect(
78
78
  const gpath = gent.path[mtarget.orig]
79
79
 
80
80
  if (gpath.action) {
81
- const actname = Object.keys(gpath.action)[0]
81
+ const actname = Object.keys(gpath.action).sort()[0]
82
82
 
83
83
  if (null != actname) {
84
84
  select.$action = actname
package/src/utility.ts CHANGED
@@ -24,6 +24,11 @@ import type {
24
24
 
25
25
  const KONSOLE_LOG = console['log']
26
26
 
27
+ // Sorted iteration helpers — ensures deterministic key order matching Go.
28
+ const sortedKeys = (obj: any): string[] => Object.keys(obj ?? {}).sort()
29
+ const sortedEntries = (obj: any): [string, any][] =>
30
+ Object.entries(obj ?? {}).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
31
+
27
32
  // Pre-compiled regex patterns for formatJsonSrc to avoid recompilation per call.
28
33
  const RE_JSON_KEY = /"([a-zA-Z_][a-zA-Z_0-9]*)": /g
29
34
  const RE_JSON_TRAILING_BRACE = /},/g
@@ -1163,5 +1168,7 @@ export {
1163
1168
  warnOnError,
1164
1169
  relativizePath,
1165
1170
  getModelPath,
1171
+ sortedKeys,
1172
+ sortedEntries,
1166
1173
 
1167
1174
  }