@voxgig/apidef 8.2.3 → 8.3.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.
@@ -71,7 +71,7 @@ const argsTransform: Transform = async function(
71
71
  argdefs.push(...(opdef?.parameters ?? []))
72
72
  }
73
73
 
74
- resolveArgs(ment, mop, mpoint, argdefs)
74
+ resolveArgs(ctx, ment, mop, mpoint, argdefs)
75
75
  })
76
76
 
77
77
  })
@@ -116,7 +116,10 @@ const ARG_KIND: Record<string, ModelArg["kind"]> = {
116
116
  }
117
117
 
118
118
 
119
- function resolveArgs(ment: ModelEntity, mop: ModelOp, mpoint: ModelPoint, argdefs: ParameterDef[]) {
119
+ function resolveArgs(
120
+ ctx: any,
121
+ ment: ModelEntity, mop: ModelOp, mpoint: ModelPoint, argdefs: ParameterDef[]
122
+ ) {
120
123
  const touchedKeys = new Set<string>()
121
124
 
122
125
  each(argdefs, (argdef: ParameterDef) => {
@@ -124,6 +127,27 @@ function resolveArgs(ment: ModelEntity, mop: ModelOp, mpoint: ModelPoint, argdef
124
127
  // by; the snakified form is the user-friendly runtime identifier.
125
128
  const specName = normalizeFieldName(argdef.name)
126
129
  const orig = depluralize(snakify(specName))
130
+
131
+ // A parameter with no name is not a parameter. This is what a DANGLING
132
+ // `$ref` looks like by the time it reaches here: the reference survives
133
+ // unresolved, `name` and `in` are both absent, and the arg would become
134
+ // a nameless `query` entry that every target then has to render. Ruby
135
+ // cannot: `Struct.new(:"")` raises at load and takes the whole SDK with
136
+ // it. Drop it and say which reference is missing.
137
+ if ('' === orig) {
138
+ const ref = (argdef as any)?.$ref
139
+ ctx?.warn?.({
140
+ note: `Parameter with no name on entity=${ment.name} op=${mop.name}` +
141
+ ` path=${mpoint.orig} is dropped` +
142
+ (null == ref ? '.' : `: \`$ref\` "${ref}" resolves to nothing.`) +
143
+ ' A parameter needs a `name`, or a reference that resolves to one.',
144
+ entity: ment.name,
145
+ path: mpoint.orig,
146
+ op: mop.name,
147
+ })
148
+ return
149
+ }
150
+
127
151
  const kind = ARG_KIND[argdef.in] ?? 'query'
128
152
  // Rename map can be keyed by either the spec original (camelCase) or by
129
153
  // the snakified form depending on which path went through heuristic01.
@@ -241,6 +241,7 @@ function resolvePathList(guideEntity: GuideEntity, def: { paths: Record<string,
241
241
  rename,
242
242
  method: '', // operation collectOps will copy and assign per op
243
243
  op: guidePath.op,
244
+ action: guidePath.action,
244
245
  def: def.paths[orig],
245
246
  }
246
247
 
@@ -182,6 +182,34 @@ function resolveOpFields(
182
182
  }
183
183
  }
184
184
 
185
+ // SPEC FACTS ABOUT THE FIELD, carried through verbatim.
186
+ //
187
+ // These four are declared by OpenAPI on the property and were being
188
+ // dropped on the floor. `readOnly` is the one that matters most: it is
189
+ // the difference between a field a client MAY send and one it may not,
190
+ // and nothing else in the model says which — so every generator has been
191
+ // putting server-assigned fields into the type a caller fills in.
192
+ //
193
+ // ONLY WHEN THE SPEC SAYS SO, and for the booleans only when TRUE. Each
194
+ // defaults to false in OpenAPI, so an absent key and an explicit `false`
195
+ // carry the same information; emitting the false ones would add a key to
196
+ // every field of every model and say nothing. Same discipline as
197
+ // `short`: absent means "the spec did not say", never "apidef dropped
198
+ // it".
199
+ for (const flag of ['readOnly', 'writeOnly', 'deprecated'] as const) {
200
+ if (true === (fielddef as any)[flag]) {
201
+ mfield[flag] = true
202
+ }
203
+ }
204
+
205
+ // `format` is an open vocabulary — OpenAPI defines a handful and lets a
206
+ // spec coin its own — so it is carried as the string it is rather than
207
+ // interpreted here. `password` is the one a generator acts on today.
208
+ const ffmt = (fielddef as any).format
209
+ if ('string' === typeof ffmt && '' !== ffmt.trim()) {
210
+ mfield.format = ffmt.trim()
211
+ }
212
+
185
213
  // Record an untagged union under this field. The field is already typed
186
214
  // openly ($ANY/$ARRAY/$OBJECT) because there is nothing to narrow it to;
187
215
  // this says WHY, so the generated docs can explain the open type instead
@@ -536,6 +564,23 @@ function mergeField(
536
564
  existingField.short = newField.short
537
565
  }
538
566
 
567
+ // The spec facts merge the same way, and for the same reason: one schema
568
+ // annotates the field and another references it bare, so taking the first
569
+ // declaration in opFieldPrecedence order is what finds the annotation.
570
+ //
571
+ // THE PRECEDENCE ORDER PUTS `load` FIRST, WHICH IS THE SAFE DIRECTION HERE.
572
+ // A field the response schema marks readOnly and a request body also lists
573
+ // is a self-contradictory spec — OpenAPI says a client must not send a
574
+ // readOnly property at all — and this resolves it by believing the
575
+ // restriction rather than the omission. Marking a writable field readOnly
576
+ // costs a caller one field; the other way round sends a value the server
577
+ // rejects.
578
+ for (const flag of ['readOnly', 'writeOnly', 'deprecated', 'format'] as const) {
579
+ if (null == existingField[flag] && null != newField[flag]) {
580
+ (existingField as any)[flag] = newField[flag]
581
+ }
582
+ }
583
+
539
584
  return existingField
540
585
  }
541
586
 
@@ -25,6 +25,18 @@ import type {
25
25
 
26
26
 
27
27
 
28
+ // The op names the transform resolves. Anything else under a guide path's
29
+ // `op` map is dropped, and an unknown name (a verb such as `merge`, or a
30
+ // typo) is dropped WITH A WARNING: guide.aon is the only correction surface
31
+ // (ADR-002), so a correction that vanishes silently defeats it. A non-CRUD
32
+ // verb is declared as `action: <verb>: {}` beside a CRUD op on the same path.
33
+ const RESOLVED_OPS = ['load', 'list', 'create', 'update', 'remove', 'patch']
34
+
35
+ // Emitted by the heuristic for HEAD and OPTIONS methods; no SDK operation
36
+ // exists for them yet, so they are skipped without a warning.
37
+ const IGNORED_OPS = ['head', 'options', 'OPTIONS']
38
+
39
+
28
40
  const operationTransform: Transform = async function(
29
41
  ctx: any,
30
42
  ): Promise<TransformResult> {
@@ -36,7 +48,7 @@ const operationTransform: Transform = async function(
36
48
  each(guide.entity, (gent: GuideEntity, entname: string) => {
37
49
  if (!guideActive(gent)) return
38
50
 
39
- collectOps(gent)
51
+ collectOps(ctx, gent)
40
52
 
41
53
  const opm: ModelOpMap = {
42
54
  load: undefined,
@@ -63,7 +75,7 @@ const operationTransform: Transform = async function(
63
75
  }
64
76
 
65
77
 
66
- function collectOps(gent: GuideEntity) {
78
+ function collectOps(ctx: any, gent: GuideEntity) {
67
79
  ; (gent as any).opm$ = (gent as any).opm$ ?? {}
68
80
  each((gent as any).paths$, (pathdesc: PathDesc) => {
69
81
  each(pathdesc.op, (gop: GuidePathOp, opname: OpName) => {
@@ -72,6 +84,20 @@ function collectOps(gent: GuideEntity) {
72
84
  return
73
85
  }
74
86
 
87
+ if (!RESOLVED_OPS.includes(opname)) {
88
+ if (!IGNORED_OPS.includes(opname)) {
89
+ ctx.warn?.({
90
+ note: `Unknown op "${opname}" on entity=${gent.name} path=${pathdesc.orig}` +
91
+ ` is dropped: only ${RESOLVED_OPS.join('/')} are resolved.` +
92
+ ` Declare a verb as \`action: ${opname}: {}\` beside a CRUD op on that path.`,
93
+ entity: gent.name,
94
+ path: pathdesc.orig,
95
+ op: opname,
96
+ })
97
+ }
98
+ return
99
+ }
100
+
75
101
  ; (gent as any).opm$[opname] = (gent as any).opm$[opname] ?? { paths: [] }
76
102
 
77
103
  const oppathdesc: PathDesc = {
@@ -80,6 +106,7 @@ function collectOps(gent: GuideEntity) {
80
106
  rename: pathdesc.rename,
81
107
  method: gop.method as any,
82
108
  op: gop as any,
109
+ action: pathdesc.action,
83
110
  def: pathdesc.def,
84
111
  }
85
112
 
@@ -126,7 +153,18 @@ function resolvePatch(opm: ModelOpMap, gent: GuideEntity): undefined | ModelOp {
126
153
  const opdesc = resolveOp('patch', gent)
127
154
 
128
155
  // If patch is actually update, make it update!
129
- if (null != opdesc && null == opm.update) {
156
+ //
157
+ // That holds when there is no PUT update at all, and equally when every
158
+ // PUT update point is an ACTION: a verb such as GitHub's `merge` borrows
159
+ // the update slot (actions have no slot of their own) but is not the
160
+ // entity's update. Leaving PATCH as `patch` there made the real update
161
+ // unreachable, since no target emits a `patch` method, and routed a plain
162
+ // update() to the verb. The action points join the promoted PATCH, and
163
+ // `$action` selects them at call time.
164
+ if (null != opdesc && (null == opm.update || onlyActionPaths(gent, 'update'))) {
165
+ if (null != opm.update) {
166
+ opdesc.points.push(...opm.update.points)
167
+ }
130
168
  opm.update = opdesc
131
169
  opm.update.name = 'update'
132
170
  }
@@ -138,6 +176,14 @@ function resolvePatch(opm: ModelOpMap, gent: GuideEntity): undefined | ModelOp {
138
176
  }
139
177
 
140
178
 
179
+ // True when every path collected under the op carries a guide action.
180
+ function onlyActionPaths(gent: GuideEntity, opname: OpName): boolean {
181
+ const paths: PathDesc[] = (gent as any).opm$?.[opname]?.paths ?? []
182
+ return 0 < paths.length &&
183
+ paths.every((p: PathDesc) => 0 < Object.keys(p.action ?? {}).length)
184
+ }
185
+
186
+
141
187
  function resolveOp(opname: OpName, gent: GuideEntity): undefined | ModelOp {
142
188
  let mop: undefined | ModelOp = undefined
143
189
  let opdesc = (gent as any).opm$[opname]
package/src/utility.ts CHANGED
@@ -1070,6 +1070,37 @@ function canonizeCmpName(orig: string): string {
1070
1070
  }
1071
1071
 
1072
1072
 
1073
+ const FIRST_LETTER_RE = /[a-zA-Z]/
1074
+
1075
+
1076
+ // No target language permits an identifier that starts with a digit, so a
1077
+ // name derived from one — a `3dsSession` schema, a `/2fa` path segment, a
1078
+ // `_3DSecure` GraphQL type — is prefixed with an `n`.
1079
+ //
1080
+ // The prefix takes the case of the name it guards: lower for `3ds_session`,
1081
+ // upper for `3DSecure`. That keeps the result inside whatever casing
1082
+ // convention the caller was already working in, so a later PascalCase or
1083
+ // camelCase conversion has nothing to undo. A name with no letter in it at
1084
+ // all (`404`) takes the lower-case prefix.
1085
+ //
1086
+ // This is the ONE place the rule lives. Entity names reach it through
1087
+ // `ensureMinEntityName` and the GraphQL guide's `entityName`; project slugs
1088
+ // through `sanitizeSlug`.
1089
+ //
1090
+ // FIELD names deliberately do NOT come here. A field name is a WIRE
1091
+ // identifier and renaming it makes the SDK read a key the server never sends
1092
+ // — the mistake `canonizeField` exists to document. Targets escape those at
1093
+ // the point of emission instead.
1094
+ function prefixLeadingDigit(s: string): string {
1095
+ if (null == s || '' === s) return s
1096
+ const first = s.charCodeAt(0)
1097
+ if (first < 48 || first > 57) return s
1098
+ const letter = s.match(FIRST_LETTER_RE)
1099
+ const upper = null != letter && letter[0] >= 'A' && letter[0] <= 'Z'
1100
+ return (upper ? 'N' : 'n') + s
1101
+ }
1102
+
1103
+
1073
1104
  // Sanitize a raw slug into a clean kebab-case string suitable for
1074
1105
  // conversion to a valid JS identifier (via camelify/snakify/etc).
1075
1106
  function sanitizeSlug(s: string): string {
@@ -1098,12 +1129,7 @@ function sanitizeSlug(s: string): string {
1098
1129
 
1099
1130
  if (!out) return 'unknown'
1100
1131
 
1101
- // Ensure the slug does not start with a digit (invalid for JS identifiers)
1102
- if (/^\d/.test(out)) {
1103
- out = 'n' + out
1104
- }
1105
-
1106
- return out
1132
+ return prefixLeadingDigit(out)
1107
1133
  }
1108
1134
 
1109
1135
 
@@ -1172,9 +1198,7 @@ function ensureMinEntityName(
1172
1198
  padded = truncated || parts[0].substring(0, MAX_ENTITY_NAME_LEN)
1173
1199
  }
1174
1200
 
1175
- if (padded.length > 0 && padded[0] >= '0' && padded[0] <= '9') {
1176
- padded = 'n' + padded
1177
- }
1201
+ padded = prefixLeadingDigit(padded)
1178
1202
  if (padded.length < MIN_ENTITY_NAME_LEN) {
1179
1203
  const padding = 'nt'.substring(0, MIN_ENTITY_NAME_LEN - padded.length)
1180
1204
  padded = padded + padding
@@ -1956,6 +1980,7 @@ export {
1956
1980
  ensureMinEntityName,
1957
1981
  inferFieldType,
1958
1982
  normalizeFieldName,
1983
+ prefixLeadingDigit,
1959
1984
  debugpath,
1960
1985
  findPathsWithPrefix,
1961
1986
  writeFileSyncWarn,