@voxgig/apidef 6.5.1 → 7.0.1

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 (52) hide show
  1. package/dist/apidef.js +39 -2
  2. package/dist/apidef.js.map +1 -1
  3. package/dist/guide/graphql01.d.ts +39 -0
  4. package/dist/guide/graphql01.js +331 -0
  5. package/dist/guide/graphql01.js.map +1 -0
  6. package/dist/guide/guide.js +70 -10
  7. package/dist/guide/guide.js.map +1 -1
  8. package/dist/guide/heuristic01.js +1 -0
  9. package/dist/guide/heuristic01.js.map +1 -1
  10. package/dist/model.d.ts +22 -1
  11. package/dist/parse/graphql.d.ts +46 -0
  12. package/dist/parse/graphql.js +205 -0
  13. package/dist/parse/graphql.js.map +1 -0
  14. package/dist/parse.js +19 -0
  15. package/dist/parse.js.map +1 -1
  16. package/dist/transform/args.js +49 -4
  17. package/dist/transform/args.js.map +1 -1
  18. package/dist/transform/entity.js +51 -3
  19. package/dist/transform/entity.js.map +1 -1
  20. package/dist/transform/field.js +71 -1
  21. package/dist/transform/field.js.map +1 -1
  22. package/dist/transform/graphql.d.ts +6 -0
  23. package/dist/transform/graphql.js +227 -0
  24. package/dist/transform/graphql.js.map +1 -0
  25. package/dist/transform/select.js +17 -2
  26. package/dist/transform/select.js.map +1 -1
  27. package/dist/transform/top.js +31 -2
  28. package/dist/transform/top.js.map +1 -1
  29. package/dist/tsconfig.tsbuildinfo +1 -1
  30. package/dist/types.d.ts +16 -1
  31. package/dist/types.js.map +1 -1
  32. package/dist/utility.d.ts +2 -1
  33. package/dist/utility.js +52 -4
  34. package/dist/utility.js.map +1 -1
  35. package/model/apidef.aontu +23 -0
  36. package/model/guide.aontu +22 -0
  37. package/package.json +11 -4
  38. package/src/apidef.ts +44 -2
  39. package/src/guide/graphql01.ts +516 -0
  40. package/src/guide/guide.ts +90 -12
  41. package/src/guide/heuristic01.ts +1 -0
  42. package/src/model.ts +46 -1
  43. package/src/parse/graphql.ts +302 -0
  44. package/src/parse.ts +23 -0
  45. package/src/transform/args.ts +54 -5
  46. package/src/transform/entity.ts +63 -3
  47. package/src/transform/field.ts +89 -2
  48. package/src/transform/graphql.ts +311 -0
  49. package/src/transform/select.ts +19 -2
  50. package/src/transform/top.ts +31 -2
  51. package/src/types.ts +40 -0
  52. package/src/utility.ts +54 -4
@@ -0,0 +1,311 @@
1
+ /* Copyright (c) 2024-2026 Voxgig, MIT License */
2
+
3
+ // Render the GraphQL wire data onto each point: the complete operation
4
+ // document, its variable bindings, the response unwrap path, and (for list
5
+ // ops) the pagination descriptor.
6
+ //
7
+ // Documents are computed HERE, once, and stored in the model as strings.
8
+ // The alternative — shipping structured selection data and assembling query
9
+ // text inside every generated SDK — would mean one query assembler per
10
+ // language target, all of which must stay semantically identical. One
11
+ // renderer in apidef is the whole reason GraphQL support stays affordable
12
+ // across the target matrix.
13
+ //
14
+ // Documents are rendered SINGLE-LINE with sorted selection fields, so the
15
+ // emitted model is byte-stable and schema drift shows up in model diffs.
16
+
17
+ import { each } from 'jostraca'
18
+
19
+ import type { TransformResult, Transform } from '../transform'
20
+
21
+ import { KIT } from '../types'
22
+
23
+ import type { KitModel, GuidePath } from '../types'
24
+
25
+ import type {
26
+ OpName,
27
+ ModelOp,
28
+ ModelEntity,
29
+ ModelPoint,
30
+ ModelGraphqlVar,
31
+ } from '../model'
32
+
33
+ import { deriveRetShape } from '../guide/graphql01'
34
+
35
+
36
+ // Fields the default fragment never selects on a to-one relation: the stub
37
+ // carries the id only, so the caller loads the related entity through its
38
+ // own entity op.
39
+ const REL_STUB = '{ id }'
40
+
41
+
42
+ function pascal(s: string): string {
43
+ return s.replace(/(^|[_-])([a-z])/g, (_m, _p, c) => c.toUpperCase())
44
+ }
45
+
46
+
47
+ // Build the selection set for an entity type: every non-deprecated scalar,
48
+ // skipping fields that require arguments (they cannot appear in a fixed
49
+ // fragment without binding those arguments), plus an id stub per to-one
50
+ // relation. Sorted — byte-stability.
51
+ function selectionFields(typeName: string, def: any): string[] {
52
+ const gtype = def.types?.[typeName]
53
+ if (null == gtype) {
54
+ return []
55
+ }
56
+
57
+ const out: string[] = []
58
+
59
+ for (const fname of Object.keys(gtype.fields)) {
60
+ const f = gtype.fields[fname]
61
+
62
+ if (f.deprecated) {
63
+ continue
64
+ }
65
+ if (f.args.some((a: any) => a.reqd)) {
66
+ continue
67
+ }
68
+
69
+ const ftype = def.types?.[f.type]
70
+ const kind = ftype?.kind
71
+
72
+ if ('SCALAR' === kind || 'ENUM' === kind) {
73
+ out.push(fname)
74
+ }
75
+ else if (('OBJECT' === kind || 'INTERFACE' === kind) && !f.list) {
76
+ if (null != ftype.fields?.id) {
77
+ out.push(fname + ' ' + REL_STUB)
78
+ }
79
+ }
80
+ }
81
+
82
+ return out.sort()
83
+ }
84
+
85
+
86
+ // Scalar fields of a payload type, for payloads that carry no entity. Used
87
+ // when there is nothing to spread a fragment on, so the operation still has
88
+ // a valid selection set.
89
+ function payloadScalarFields(typeName: string, def: any): string[] {
90
+ const gtype = def.types?.[typeName]
91
+ if (null == gtype) {
92
+ return []
93
+ }
94
+
95
+ const out: string[] = []
96
+
97
+ for (const fname of Object.keys(gtype.fields)) {
98
+ const f = gtype.fields[fname]
99
+ const kind = def.types?.[f.type]?.kind
100
+
101
+ if (!f.deprecated && !f.args.some((a: any) => a.reqd) &&
102
+ ('SCALAR' === kind || 'ENUM' === kind)) {
103
+ out.push(fname)
104
+ }
105
+ }
106
+
107
+ return out.sort()
108
+ }
109
+
110
+
111
+ // Variable bindings for a root field: one per argument. `from` is the op
112
+ // argument the value is read from; for the input-object argument that is the
113
+ // request data itself.
114
+ function buildVars(fielddef: any, def: any): ModelGraphqlVar[] {
115
+ const args = fielddef?.args ?? []
116
+
117
+ // The whole-request-body binding is the SINGLE-entity-input convention
118
+ // (issueCreate(input: IssueCreateInput!)). A field taking several input
119
+ // objects — items(filter: Filter, orderBy: OrderBy) — must bind each from
120
+ // its own argument, or they all receive the same value and at least one
121
+ // fails input validation.
122
+ const inputs = args.filter((a: any) => {
123
+ const atype = def.types?.[a.type]
124
+ return null != atype && 'INPUT_OBJECT' === atype.kind
125
+ })
126
+ const soleInput = 1 === inputs.length ? inputs[0].name : undefined
127
+
128
+ return args.map((arg: any) => {
129
+ const v: any = {
130
+ name: arg.name,
131
+ from: arg.name === soleInput ? '' : arg.name,
132
+ gqltype: arg.gqltype,
133
+ }
134
+ if (undefined !== arg.deflt) {
135
+ v.deflt = arg.deflt
136
+ }
137
+ return v
138
+ })
139
+ }
140
+
141
+
142
+ // `issue(id: $id, first: $first)` — argument list wired to variables.
143
+ function argList(vars: ModelGraphqlVar[]): string {
144
+ return 0 === vars.length ? '' :
145
+ '(' + vars.map((v) => v.name + ': $' + v.name).join(', ') + ')'
146
+ }
147
+
148
+
149
+ // `($id: String!, $first: Int = 100)` — the operation's variable
150
+ // declarations. A schema default is carried through: without it a non-null
151
+ // argument that the schema makes omittable (`first: Int! = 100`) would fail
152
+ // variable coercion when the caller leaves it out.
153
+ function varDecl(vars: ModelGraphqlVar[]): string {
154
+ return 0 === vars.length ? '' :
155
+ '(' + vars.map((v) => '$' + v.name + ': ' + v.gqltype +
156
+ (undefined === (v as any).deflt ? '' :
157
+ ' = ' + JSON.stringify((v as any).deflt))).join(', ') + ')'
158
+ }
159
+
160
+
161
+ // Render one operation document, single-line.
162
+ function renderDoc(
163
+ opname: string,
164
+ optype: string,
165
+ field: string,
166
+ vars: ModelGraphqlVar[],
167
+ selection: string,
168
+ fragName: string,
169
+ fragType: string,
170
+ fragFields: string[]
171
+ ): string {
172
+ const doc =
173
+ optype + ' ' + opname + varDecl(vars) +
174
+ ' { ' + field + argList(vars) + ' ' + selection + ' }' +
175
+ (0 < fragFields.length ?
176
+ ' fragment ' + fragName + ' on ' + fragType +
177
+ ' { ' + fragFields.join(' ') + ' }' : '')
178
+
179
+ // Collapse any accidental double spacing so the string is canonical.
180
+ return doc.replace(/\s+/g, ' ').trim()
181
+ }
182
+
183
+
184
+ const graphqlTransform: Transform = async function(
185
+ ctx: any,
186
+ ): Promise<TransformResult> {
187
+ const { apimodel, def, guide } = ctx
188
+
189
+ if (true !== def?.graphql) {
190
+ return { ok: true, msg: 'graphql (skipped: not a graphql def)' }
191
+ }
192
+
193
+ const kit: KitModel = apimodel.main[KIT]
194
+
195
+ let msg = 'graphql '
196
+
197
+ each(kit.entity, (ment: ModelEntity, entname: string) => {
198
+ const gent = guide.entity[entname]
199
+
200
+ each(ment.op, (mop: ModelOp, opname: OpName) => {
201
+ each(mop.points, (mpoint: ModelPoint) => {
202
+ const rootfield = mpoint.orig
203
+
204
+ const gfield: GuidePath | undefined = (gent as any)?.field?.[rootfield]
205
+ const optype = (gfield?.op?.[opname] as any)?.optype ?? 'query'
206
+
207
+ const fielddef = 'mutation' === optype ?
208
+ def.mutation?.[rootfield] : def.query?.[rootfield]
209
+
210
+ if (null == fielddef) {
211
+ return
212
+ }
213
+
214
+ const ret = deriveRetShape(fielddef, def.types ?? {})
215
+ const entityType = ret.entity ?? ''
216
+
217
+ const fragFields = selectionFields(entityType, def)
218
+ const fragName = pascal(entname) + 'Fields'
219
+ const fragSpread = 0 < fragFields.length ? '{ ...' + fragName + ' }' : '{ id }'
220
+
221
+ const vars = buildVars(fielddef, def)
222
+
223
+ // Selection shape and response unwrap both follow the return kind.
224
+ let selection = fragSpread
225
+ let respath = 'body.data.' + rootfield
226
+
227
+ if ('connection' === ret.kind) {
228
+ const nodes = ret.nodes ?? 'nodes'
229
+ selection = 'nodes' === nodes ?
230
+ '{ nodes ' + fragSpread + ' pageInfo { endCursor hasNextPage } }' :
231
+ '{ edges { node ' + fragSpread + ' } pageInfo { endCursor hasNextPage } }'
232
+ respath = 'body.data.' + rootfield + '.' + nodes
233
+ }
234
+ else if ('list' === ret.kind) {
235
+ selection = fragSpread
236
+ }
237
+ else if ('payload' === ret.kind && null == ret.entity) {
238
+ // Entity-less payload: Linear's DeletePayload is entityId +
239
+ // success + lastSyncId and nothing else. The classifier admits
240
+ // these (the entity comes from the field name), so the renderer
241
+ // must not fall through to the default `{ id }` spread — the
242
+ // payload HAS no id, and the server rejects the whole document.
243
+ // Select the payload's own scalars and unwrap to the payload.
244
+ const own = payloadScalarFields(fielddef.type, def)
245
+ selection = '{ ' + (0 < own.length ? own.join(' ') : '__typename') + ' }'
246
+ respath = 'body.data.' + rootfield
247
+ }
248
+ else if ('payload' === ret.kind && null != ret.unwrap) {
249
+ // Mutation payload wrapper: select the entity inside it (plus the
250
+ // conventional success flag when present) and unwrap on the way
251
+ // back, so create/update return the entity exactly as REST does.
252
+ const payloadType = def.types?.[fielddef.type]
253
+ const hasSuccess = null != payloadType?.fields?.success
254
+ selection = '{ ' + ret.unwrap + ' ' + fragSpread +
255
+ (hasSuccess ? ' success' : '') + ' }'
256
+ respath = 'body.data.' + rootfield + '.' + ret.unwrap
257
+ }
258
+
259
+ // Distinct operation name per point. The action comes from the GUIDE,
260
+ // not from mpoint.select: selectTransform runs after this stage, so
261
+ // $action is not set yet, and without the suffix every action point
262
+ // on an op would ship the same operation name (three PlanetUpdates),
263
+ // which is what server logs, tracing and APM key on.
264
+ const actionName = Object.keys((gfield as any)?.action ?? {})[0]
265
+ const docname = pascal(entname) + pascal(opname) +
266
+ (null != actionName ? pascal(actionName) : '')
267
+
268
+ // GraphQL points ride the HTTP machinery: POST to the single
269
+ // endpoint, no path parts. The document carries everything else.
270
+ mpoint.kind = 'graphql'
271
+ mpoint.method = 'POST'
272
+ mpoint.parts = []
273
+
274
+ mpoint.graphql = {
275
+ optype: optype as 'query' | 'mutation',
276
+ field: rootfield,
277
+ doc: renderDoc(docname, optype, rootfield, vars, selection,
278
+ fragName, entityType, fragFields),
279
+ vars,
280
+ }
281
+
282
+ // Carried for the field transform, which derives entity fields from
283
+ // the same object type. The `$` suffix makes cleanTransform strip it
284
+ // from the emitted model — it is pipeline state, not wire data.
285
+ ;(mpoint.graphql as any).entityType$ = entityType
286
+
287
+ if ('connection' === ret.kind) {
288
+ mpoint.graphql.page = {
289
+ style: 'relay',
290
+ nodes: ret.nodes ?? 'nodes',
291
+ cursor: 'pageInfo.endCursor',
292
+ more: 'pageInfo.hasNextPage',
293
+ }
294
+ }
295
+
296
+ mpoint.transform.res = '`' + respath + '`'
297
+ })
298
+ })
299
+
300
+ msg += ment.name + ' '
301
+ })
302
+
303
+ return { ok: true, msg }
304
+ }
305
+
306
+
307
+ export {
308
+ graphqlTransform,
309
+ selectionFields,
310
+ renderDoc,
311
+ }
@@ -37,7 +37,9 @@ const selectTransform: Transform = async function(
37
37
  each(kit.entity, (ment: ModelEntity, _entname: string) => {
38
38
  each(ment.op, (mop: ModelOp, _opname: OpName) => {
39
39
  each(mop.points, (mpoint: ModelPoint) => {
40
- const pdef: PathDef = def.paths[mpoint.orig]
40
+ // GraphQL defs have no `paths`; the lookup is only passed through to
41
+ // an unused parameter, so skip it rather than dereference undefined.
42
+ const pdef: PathDef = def.paths?.[mpoint.orig]
41
43
  resolveSelect(guide, ment, mop, mpoint, pdef)
42
44
  })
43
45
  if (null != mop.points && 0 < mop.points.length) {
@@ -64,8 +66,18 @@ function resolveSelect(
64
66
 
65
67
  const argkinds = ['params', 'query', 'header', 'cookie']
66
68
 
69
+ // `exist` names values that must be PRESENT for this point to be chosen.
70
+ // A GraphQL root field exposes its optional arguments (relay's first /
71
+ // after, filters) as params, and requiring those for selection would make
72
+ // list() unusable without supplying every pagination argument. Only
73
+ // required arguments identify a point.
74
+ const reqdonly = 'graphql' === (mpoint as any).kind
75
+
67
76
  argkinds.map((kind: string) => {
68
77
  each(margs[kind], (marg: ModelArg) => {
78
+ if (reqdonly && !marg.reqd) {
79
+ return
80
+ }
69
81
  if (!select.exist.includes(marg.name)) {
70
82
  select.exist.push(marg.name)
71
83
  }
@@ -75,7 +87,12 @@ function resolveSelect(
75
87
  select.exist.sort()
76
88
 
77
89
  const gent = guide.entity[ment.name]
78
- const gpath = gent.path[mpoint.orig]
90
+ // REST guides key entries by path, GraphQL guides by root field.
91
+ const gpath = gent.path?.[mpoint.orig] ?? (gent as any).field?.[mpoint.orig]
92
+
93
+ if (null == gpath) {
94
+ return
95
+ }
79
96
 
80
97
  if (gpath.action) {
81
98
  const actname = Object.keys(gpath.action).sort()[0]
@@ -59,7 +59,31 @@ const topTransform = async function(
59
59
  // spec DOES declare auth we leave `auth` unset so the SDK's own config
60
60
  // (main.kit.config.auth) governs. Set AFTER stringifyInfoScalars so the
61
61
  // value stays a real boolean rather than the string "false".
62
- if (!specDeclaresAuth(def)) {
62
+ if (true === def.graphql) {
63
+ // A GraphQL schema NEVER declares HTTP auth, so specDeclaresAuth would
64
+ // report every secured GraphQL API (Linear included) as public and
65
+ // suppress all generated auth code. Take the explicit build option
66
+ // instead: only an option that actively says "public" emits the no-auth
67
+ // signal; silence leaves auth unset so the SDK's own config governs.
68
+ const authopt = ctx.opts?.auth
69
+ if (null != authopt) {
70
+ if (false === authopt.active) {
71
+ kit.info.auth = false
72
+ }
73
+ else {
74
+ kit.info.security = {
75
+ scheme: authopt.scheme ?? 'apikey',
76
+ type: authopt.type ?? 'apiKey',
77
+ in: authopt.in ?? 'header',
78
+ name: authopt.name ?? 'Authorization',
79
+ // '' means a raw credential with no prefix (Linear's style);
80
+ // prepareAuth in generated SDKs already honours that.
81
+ prefix: authopt.prefix ?? '',
82
+ }
83
+ }
84
+ }
85
+ }
86
+ else if (!specDeclaresAuth(def)) {
63
87
  kit.info.auth = false
64
88
  }
65
89
  else {
@@ -105,10 +129,15 @@ const topTransform = async function(
105
129
  // Swagger 2 derives it from `host` + `basePath`. If neither yields a
106
130
  // non-empty url, the generated SDK has no way to issue requests, so fail
107
131
  // the apidef model build rather than emit broken code.
132
+ // (For GraphQL the parser already synthesised servers[0] from the required
133
+ // `endpoint` build option, so this check passes on the same terms.)
108
134
  const firstServerUrl: any = kit.info.servers?.[0]?.url
109
135
  if (null == firstServerUrl || '' === String(firstServerUrl).trim()) {
110
136
  throw new Error(
111
- 'apidef: no server URL found in API definition (servers[0].url is required).'
137
+ true === def.graphql ?
138
+ 'apidef: no endpoint given for GraphQL schema' +
139
+ ' (the endpoint build option is required).' :
140
+ 'apidef: no server URL found in API definition (servers[0].url is required).'
112
141
  )
113
142
  }
114
143
 
package/src/types.ts CHANGED
@@ -33,11 +33,41 @@ type ApiDefOptions = {
33
33
  meta?: Record<string, any>
34
34
  outprefix?: string
35
35
  strategy?: string
36
+
37
+ // Input format. Defaults to 'OpenAPI'; sniffed from the def file name
38
+ // when not given (see resolveKind in apidef.ts).
39
+ kind?: DefKind
40
+
41
+ // GraphQL-only build inputs. A GraphQL schema carries neither a
42
+ // deployment URL nor an HTTP auth declaration, so both must be supplied
43
+ // out of band (see docs/design/graphql-ingestion.md).
44
+ endpoint?: string
45
+ auth?: ApiDefAuthOption
46
+
36
47
  why?: {
37
48
  show?: boolean
38
49
  }
39
50
  }
40
51
 
52
+
53
+ // Input definition format.
54
+ type DefKind = 'OpenAPI' | 'GraphQL'
55
+
56
+
57
+ // Auth descriptor for schema formats that cannot declare their own. When
58
+ // omitted for a GraphQL build, no auth signal is emitted either way, so the
59
+ // SDK's own config governs; set `active: false` to state a public API
60
+ // explicitly (which suppresses generated auth code, as an OpenAPI spec with
61
+ // no security schemes does).
62
+ type ApiDefAuthOption = {
63
+ active?: boolean
64
+ scheme?: string
65
+ type?: string
66
+ in?: string
67
+ name?: string
68
+ prefix?: string
69
+ }
70
+
41
71
  const ControlShape = Shape({
42
72
  step: {
43
73
  parse: true,
@@ -207,6 +237,8 @@ type GuideControl = {}
207
237
  type GuideMetrics = {
208
238
  count: {
209
239
  path: number
240
+ // Schema root fields classified (GraphQL guides; 0 for OpenAPI).
241
+ field: number
210
242
  method: number
211
243
  entity: number
212
244
  tag: number
@@ -223,6 +255,9 @@ type GuideMetrics = {
223
255
  type GuideEntity = {
224
256
  name: string
225
257
  orig: string
258
+ // GraphQL guides key operations by schema root field instead of path;
259
+ // the two branches are mutually exclusive per guide.
260
+ field?: Record<string, GuidePath>
226
261
  path: Record<string, GuidePath>
227
262
  }
228
263
 
@@ -247,6 +282,9 @@ type GuideRenameParam = {
247
282
 
248
283
  type GuidePathOp = {
249
284
  method: string
285
+ // GraphQL root-field ops carry the operation type instead of relying on
286
+ // an HTTP verb (points still synthesize method 'POST').
287
+ optype?: string
250
288
  why_op: string[]
251
289
  transform: {
252
290
  req: any
@@ -280,6 +318,8 @@ export type {
280
318
  Log,
281
319
  FsUtil,
282
320
  ApiDefOptions,
321
+ DefKind,
322
+ ApiDefAuthOption,
283
323
  ApiDefResult,
284
324
  Control,
285
325
  Model,
package/src/utility.ts CHANGED
@@ -1012,6 +1012,36 @@ function canonize(s: string) {
1012
1012
  }
1013
1013
 
1014
1014
 
1015
+ // Canonicalise a FIELD name — which is a WIRE identifier, not a type name.
1016
+ //
1017
+ // `canonize` is right for entity/type names: it snakifies and depluralizes so
1018
+ // `Users` and `user-items` converge on `user` / `user_item`. Applied to a
1019
+ // field it is actively WRONG, because the name has to match the JSON the
1020
+ // server actually sends:
1021
+ //
1022
+ // modelType -> canonize -> model_type (server sends modelType)
1023
+ // items -> canonize -> item (server sends items)
1024
+ //
1025
+ // Nothing maps back: the model's `alias.field` map is emitted empty and no
1026
+ // generator consumes it, so the wire name is simply lost. Across the fleet's
1027
+ // specs that renamed 23% of all fields (146 repos) and depluralized another
1028
+ // 13% — every one of those SDKs reading a key the server never sends.
1029
+ //
1030
+ // So: keep the transliteration and identifier sanitisation that stop a name
1031
+ // being unusable in a target language, and drop the snakify/depluralize that
1032
+ // change what the name MEANS. Case and plurality are preserved verbatim.
1033
+ const CANONIZE_FIELD_CACHE = new Map<string, string>()
1034
+
1035
+ function canonizeField(s: string) {
1036
+ if (null == s || '' === s) return ''
1037
+ const cached = CANONIZE_FIELD_CACHE.get(s)
1038
+ if (undefined !== cached) return cached
1039
+ const out = transliterate(s).replace(/[^a-zA-Z_0-9]/g, '')
1040
+ CANONIZE_FIELD_CACHE.set(s, out)
1041
+ return out
1042
+ }
1043
+
1044
+
1015
1045
  // Namespace-qualified schema names (ASP.NET / Java style:
1016
1046
  // "NoFrixion.MoneyMoov.Models.PaymentRequests.MerchantPayment",
1017
1047
  // "com.example.api.Payment") describe the type by their LAST dotted
@@ -1551,16 +1581,35 @@ function isEntityWrapperProp(propSchema: any): boolean {
1551
1581
  // signal, and is still checked first.
1552
1582
  function envelopeProp(resprops: any, opname: string): string | null {
1553
1583
  const keys = keysof(resprops)
1554
- if (1 !== keys.length) {
1584
+ if (0 === keys.length) {
1555
1585
  return null
1556
1586
  }
1557
1587
 
1558
- const key = keys[0]
1559
- const prop = resprops[key]
1560
- if (!isEntityWrapperProp(prop)) {
1588
+ // Exactly one STRUCTURED property, with any siblings being scalars.
1589
+ //
1590
+ // The original rule demanded exactly one property full stop, which missed
1591
+ // the single most common envelope shape in the wild:
1592
+ //
1593
+ // { "success": true, "data": [ ... ] }
1594
+ // { "status": "ok", "result": { ... } }
1595
+ //
1596
+ // A boolean/string status flag beside the payload is metadata, not a
1597
+ // sibling of equal standing, so the body is still an envelope. UniVec's
1598
+ // /v1/models returns exactly this and every generated SDK — TypeScript, Go,
1599
+ // Python alike — returned an empty list against an API plainly serving
1600
+ // data.
1601
+ //
1602
+ // Scalar-only siblings keep the guard meaningful: `{ok, id}` from a delete
1603
+ // has no structured member and is still handed over whole, and a body with
1604
+ // TWO structured members is a composite we must not guess at.
1605
+ const structured = keys.filter((k: string) => isEntityWrapperProp(resprops[k]))
1606
+ if (1 !== structured.length) {
1561
1607
  return null
1562
1608
  }
1563
1609
 
1610
+ const key = structured[0]
1611
+ const prop = resprops[key]
1612
+
1564
1613
  const islist = propIsList(prop)
1565
1614
  if (null == islist || islist !== ('list' === opname)) {
1566
1615
  return null
@@ -1666,6 +1715,7 @@ export {
1666
1715
  VALID_CANON,
1667
1716
  CANON_ONE,
1668
1717
  canonize,
1718
+ canonizeField,
1669
1719
  canonizeCmpName,
1670
1720
  stripSchemaNamespace,
1671
1721
  sanitizeSlug,