@voxgig/sdkgen 4.31.0 → 4.32.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.
Files changed (76) hide show
  1. package/bin/voxgig-sdkgen +1 -1
  2. package/dist/action/package.d.ts +2 -1
  3. package/dist/action/package.js +8 -1
  4. package/dist/action/package.js.map +1 -1
  5. package/dist/helpers/definitionPlan.d.ts +5 -0
  6. package/dist/helpers/definitionPlan.js +44 -7
  7. package/dist/helpers/definitionPlan.js.map +1 -1
  8. package/dist/tsconfig.tsbuildinfo +1 -1
  9. package/package.json +1 -1
  10. package/project/.sdk/tm/c/utility/make_url.c +11 -0
  11. package/project/.sdk/tm/c/utility/prepare_headers.c +53 -3
  12. package/project/.sdk/tm/c/utility/prepare_query.c +4 -1
  13. package/project/.sdk/tm/c/utility/transform_request.c +31 -8
  14. package/project/.sdk/tm/clojure/src/sdk/core.clj +62 -15
  15. package/project/.sdk/tm/cpp/utility/pipeline.hpp +72 -15
  16. package/project/.sdk/tm/csharp/utility/MakeUrl.cs +8 -0
  17. package/project/.sdk/tm/csharp/utility/PrepareHeaders.cs +34 -4
  18. package/project/.sdk/tm/csharp/utility/PrepareQuery.cs +14 -0
  19. package/project/.sdk/tm/csharp/utility/TransformRequest.cs +31 -5
  20. package/project/.sdk/tm/elixir/lib/projectname/utility.ex +86 -24
  21. package/project/.sdk/tm/go/utility/make_url.go +8 -0
  22. package/project/.sdk/tm/go/utility/prepare_headers.go +36 -7
  23. package/project/.sdk/tm/go/utility/prepare_query.go +11 -0
  24. package/project/.sdk/tm/go/utility/transform_request.go +31 -5
  25. package/project/.sdk/tm/java/utility/MakeUrl.java +8 -0
  26. package/project/.sdk/tm/java/utility/PrepareHeaders.java +30 -6
  27. package/project/.sdk/tm/java/utility/PrepareQuery.java +10 -0
  28. package/project/.sdk/tm/java/utility/TransformRequest.java +30 -5
  29. package/project/.sdk/tm/js/src/utility/MakeUrlUtility.js +7 -0
  30. package/project/.sdk/tm/js/src/utility/PrepareHeadersUtility.js +18 -0
  31. package/project/.sdk/tm/js/src/utility/PrepareQueryUtility.js +3 -1
  32. package/project/.sdk/tm/js/src/utility/TransformRequestUtility.js +17 -3
  33. package/project/.sdk/tm/js/test/definition-runner.js +74 -13
  34. package/project/.sdk/tm/kotlin/utility/MakeSpecUrl.kt +8 -0
  35. package/project/.sdk/tm/kotlin/utility/Prepare.kt +34 -4
  36. package/project/.sdk/tm/kotlin/utility/ResultTransform.kt +18 -6
  37. package/project/.sdk/tm/lua/utility/make_url.lua +8 -0
  38. package/project/.sdk/tm/lua/utility/prepare_headers.lua +35 -6
  39. package/project/.sdk/tm/lua/utility/prepare_query.lua +10 -0
  40. package/project/.sdk/tm/lua/utility/transform_request.lua +37 -6
  41. package/project/.sdk/tm/ocaml/sdk_runtime.ml +63 -13
  42. package/project/.sdk/tm/perl/utility/make_url.pm +6 -0
  43. package/project/.sdk/tm/perl/utility/prepare_headers.pm +21 -3
  44. package/project/.sdk/tm/perl/utility/prepare_query.pm +8 -0
  45. package/project/.sdk/tm/perl/utility/transform_request.pm +19 -7
  46. package/project/.sdk/tm/php/utility/MakeUrl.php +7 -0
  47. package/project/.sdk/tm/php/utility/PrepareHeaders.php +33 -4
  48. package/project/.sdk/tm/php/utility/PrepareQuery.php +11 -0
  49. package/project/.sdk/tm/php/utility/TransformRequest.php +28 -5
  50. package/project/.sdk/tm/py/pkg/utility/make_url.py +7 -0
  51. package/project/.sdk/tm/py/pkg/utility/prepare_headers.py +27 -6
  52. package/project/.sdk/tm/py/pkg/utility/prepare_query.py +8 -0
  53. package/project/.sdk/tm/py/pkg/utility/transform_request.py +21 -5
  54. package/project/.sdk/tm/rb/utility/make_url.rb +7 -0
  55. package/project/.sdk/tm/rb/utility/prepare_headers.rb +21 -3
  56. package/project/.sdk/tm/rb/utility/prepare_query.rb +8 -0
  57. package/project/.sdk/tm/rb/utility/transform_request.rb +18 -5
  58. package/project/.sdk/tm/rust/utility/make_url.rs +10 -1
  59. package/project/.sdk/tm/rust/utility/prepare_headers.rs +43 -7
  60. package/project/.sdk/tm/rust/utility/prepare_query.rs +18 -1
  61. package/project/.sdk/tm/rust/utility/transform_request.rs +29 -7
  62. package/project/.sdk/tm/scala/utility/Make.scala +9 -0
  63. package/project/.sdk/tm/scala/utility/Prepare.scala +42 -3
  64. package/project/.sdk/tm/scala/utility/Transform.scala +30 -6
  65. package/project/.sdk/tm/swift/Sources/ProjectNameSDK/utility/Make.swift +7 -0
  66. package/project/.sdk/tm/swift/Sources/ProjectNameSDK/utility/Prepare.swift +28 -2
  67. package/project/.sdk/tm/swift/Sources/ProjectNameSDK/utility/ResultUtil.swift +23 -5
  68. package/project/.sdk/tm/ts/src/utility/MakeUrlUtility.ts +7 -0
  69. package/project/.sdk/tm/ts/src/utility/PrepareHeadersUtility.ts +16 -0
  70. package/project/.sdk/tm/ts/src/utility/PrepareQueryUtility.ts +3 -1
  71. package/project/.sdk/tm/ts/src/utility/TransformRequestUtility.ts +16 -3
  72. package/project/.sdk/tm/ts/test/definition-runner.ts +75 -13
  73. package/project/.sdk/tm/zig/core/utility.zig +80 -20
  74. package/project/sdkgen-package.json +1 -1
  75. package/src/action/package.ts +10 -1
  76. package/src/helpers/definitionPlan.ts +49 -7
@@ -208,6 +208,13 @@ func makeUrlUtil(_ ctx: Context) throws -> String {
208
208
  var url = join(jtp(spec.base, spec.prefix, spec.path, spec.suffix), "/", true)
209
209
  let resmatch = VMap()
210
210
 
211
+ // A route the definition ends with a slash keeps it: a server such as a
212
+ // Django REST one redirects or refuses the route without it.
213
+ if let orig = gp(ctx.point, "orig").asString, orig.hasSuffix("/"), spec.suffix.isEmpty,
214
+ !url.hasSuffix("/") {
215
+ url += "/"
216
+ }
217
+
211
218
  for item in items(.map(spec.params)) {
212
219
  let key = item[0].asString ?? ""
213
220
  let val = item[1]
@@ -39,8 +39,28 @@ func preparePathUtil(_ ctx: Context) -> String {
39
39
  func prepareHeadersUtil(_ ctx: Context) -> VMap {
40
40
  let options = ctx.client!.optionsMap()
41
41
  let headers = gp(options, "headers")
42
- if isNil(headers) { return VMap() }
43
- return clone(headers).asMap ?? VMap()
42
+ let out = isNil(headers) ? VMap() : (clone(headers).asMap ?? VMap())
43
+
44
+ // A header parameter travels as a header, under the name the definition
45
+ // gives it, and only from this call's own arguments. It replaces a default
46
+ // of the same name, whatever its case.
47
+ if let ahl = gpath(ctx.point, "args", "header").asList {
48
+ for hd in ahl.items {
49
+ guard let name = gp(hd, "name").asString, !name.isEmpty else { continue }
50
+ let orig = gp(hd, "orig").asString ?? ""
51
+ let wire = orig.isEmpty ? name : orig
52
+ var val = gp(ctx.reqmatch, name)
53
+ if isNil(val) { val = gp(ctx.reqdata, name) }
54
+ if !isNil(val) {
55
+ let key = wire.lowercased()
56
+ for k in out.entries.keys where k.lowercased() == key {
57
+ _ = out.entries.removeValue(forKey: k)
58
+ }
59
+ out.entries[key] = .string(stringify(val))
60
+ }
61
+ }
62
+ }
63
+ return out
44
64
  }
45
65
 
46
66
  func prepareParamsUtil(_ ctx: Context) -> VMap {
@@ -73,6 +93,12 @@ func prepareQueryUtil(_ ctx: Context) -> VMap {
73
93
  paramnames.append(gp(pd, "name"))
74
94
  }
75
95
  }
96
+ // A header parameter travels in the headers, which prepareHeaders fills.
97
+ if let ahl = gpath(ctx.point, "args", "header").asList {
98
+ for hd in ahl.items {
99
+ paramnames.append(gp(hd, "name"))
100
+ }
101
+ }
76
102
 
77
103
  // A query parameter travels under the name the definition gives it, its
78
104
  // orig, which the model may have renamed for the caller.
@@ -57,9 +57,25 @@ func resultHeadersUtil(_ ctx: Context) -> Result {
57
57
  // `$action` selects the point (see makePointUtil); it is never an API field,
58
58
  // so the body is a copy without it. The caller's map is left untouched.
59
59
  private func stripAction(_ reqdata: Value) -> Value {
60
- guard let src = reqdata.asMap, src.entries["$action"] != nil else { return reqdata }
60
+ return omitKeys(reqdata, ["$action"])
61
+ }
62
+
63
+ // A header argument travels as a header, which prepareHeadersUtil sends, so
64
+ // the body is built from the request data without it.
65
+ private func headerArgNames(_ ctx: Context) -> [String] {
66
+ guard let ahl = gpath(ctx.point, "args", "header").asList else { return [] }
67
+ return ahl.items.compactMap { hd in
68
+ guard let name = gp(hd, "name").asString, !name.isEmpty else { return nil }
69
+ return name
70
+ }
71
+ }
72
+
73
+ private func omitKeys(_ reqdata: Value, _ names: [String]) -> Value {
74
+ guard let src = reqdata.asMap, names.contains(where: { src.entries[$0] != nil }) else {
75
+ return reqdata
76
+ }
61
77
  let body = VMap()
62
- for (key, val) in src.entries where "$action" != key {
78
+ for (key, val) in src.entries where !names.contains(key) {
63
79
  body.entries[key] = val
64
80
  }
65
81
  return .map(body)
@@ -68,11 +84,13 @@ private func stripAction(_ reqdata: Value) -> Value {
68
84
  func transformRequestUtil(_ ctx: Context) -> Value {
69
85
  if let sp = ctx.spec { sp.step = "reqform" }
70
86
 
71
- guard let tfm = gp(ctx.point, "transform").asMap else { return stripAction(.map(ctx.reqdata)) }
87
+ let reqdata = omitKeys(.map(ctx.reqdata), headerArgNames(ctx))
88
+
89
+ guard let tfm = gp(ctx.point, "transform").asMap else { return stripAction(reqdata) }
72
90
  let reqform = gp(tfm, "req")
73
- if isNil(reqform) { return stripAction(.map(ctx.reqdata)) }
91
+ if isNil(reqform) { return stripAction(reqdata) }
74
92
 
75
- return stripAction(transform(.map(vm(("reqdata", .map(ctx.reqdata)))), reqform))
93
+ return stripAction(transform(.map(vm(("reqdata", reqdata))), reqform))
76
94
  }
77
95
 
78
96
  func transformResponseUtil(_ ctx: Context) -> Value {
@@ -26,6 +26,13 @@ function makeUrl(ctx: Context): Error | string {
26
26
  let url = join([spec.base, spec.prefix, spec.path, spec.suffix], '/', true)
27
27
  let resmatch: Record<string, any> = {}
28
28
 
29
+ // A route the definition ends with a slash keeps it: a server such as a
30
+ // Django REST one redirects or refuses the route without it.
31
+ const orig = ctx.point?.orig
32
+ if ('string' === typeof orig && orig.endsWith('/') && !spec.suffix && !url.endsWith('/')) {
33
+ url += '/'
34
+ }
35
+
29
36
  const params = spec.params
30
37
 
31
38
  for (let [key, val] of items(params)) {
@@ -6,6 +6,7 @@ function prepareHeaders(ctx: Context) {
6
6
  const struct = ctx.utility.struct
7
7
  const clone = struct.clone
8
8
  const getprop = struct.getprop
9
+ const stringify = struct.stringify
9
10
 
10
11
  const client = ctx.client
11
12
 
@@ -13,6 +14,21 @@ function prepareHeaders(ctx: Context) {
13
14
 
14
15
  let out = clone(getprop(options, 'headers', {}))
15
16
 
17
+ // A header parameter travels as a header, under the name the definition
18
+ // gives it, and only from this call's own arguments. It replaces a default
19
+ // of the same name, whatever its case.
20
+ for (const h of (ctx.point?.args?.header || [])) {
21
+ if ('string' !== typeof h?.name || '' === h.name) continue
22
+ const val = getprop(ctx.reqmatch, h.name) ?? getprop(ctx.reqdata, h.name)
23
+ if (null != val) {
24
+ const wire = String(h.orig || h.name).toLowerCase()
25
+ for (const key of Object.keys(out)) {
26
+ if (wire === key.toLowerCase()) delete out[key]
27
+ }
28
+ out[wire] = stringify(val)
29
+ }
30
+ }
31
+
16
32
  return out
17
33
  }
18
34
 
@@ -16,8 +16,10 @@ function prepareQuery(ctx: Context) {
16
16
 
17
17
  // A path parameter travels in the path. The generated config lists them as
18
18
  // args.params, which prepareParams reads; params is the older list of names.
19
+ // A header parameter travels in the headers, which prepareHeaders fills.
19
20
  const inpath: string[] = params.concat(
20
- (point.args?.params || []).map((p: any) => p?.name))
21
+ (point.args?.params || []).map((p: any) => p?.name),
22
+ (point.args?.header || []).map((h: any) => h?.name))
21
23
 
22
24
  // A query parameter travels under the name the definition gives it, its
23
25
  // orig, which the model may have renamed for the caller.
@@ -15,7 +15,7 @@ function transformRequest(ctx: Context) {
15
15
  try {
16
16
  const reqform = point.transform.req
17
17
  const reqdata = isfunc(reqform) ? reqform(ctx) : transform({
18
- reqdata: ctx.reqdata
18
+ reqdata: omit(ctx.reqdata, headerArgNames(ctx))
19
19
  }, reqform)
20
20
 
21
21
  return stripAction(reqdata)
@@ -29,17 +29,30 @@ function transformRequest(ctx: Context) {
29
29
 
30
30
 
31
31
  function stripAction(reqdata: any) {
32
+ return omit(reqdata, ['$action'])
33
+ }
34
+
35
+
36
+ // A header argument travels as a header, which prepareHeaders sends, so the
37
+ // body is built from the request data without it.
38
+ function headerArgNames(ctx: Context): string[] {
39
+ return (ctx.point?.args?.header || []).map((h: any) => h?.name)
40
+ .filter((name: any) => 'string' === typeof name && '' !== name)
41
+ }
42
+
43
+
44
+ function omit(reqdata: any, names: string[]) {
32
45
  if (null == reqdata || 'object' !== typeof reqdata || Array.isArray(reqdata)) {
33
46
  return reqdata
34
47
  }
35
48
 
36
- if (!Object.prototype.hasOwnProperty.call(reqdata, '$action')) {
49
+ if (!names.some((name) => Object.prototype.hasOwnProperty.call(reqdata, name))) {
37
50
  return reqdata
38
51
  }
39
52
 
40
53
  const body: Record<string, any> = {}
41
54
  for (const key of Object.keys(reqdata)) {
42
- if ('$action' === key) {
55
+ if (names.includes(key)) {
43
56
  continue
44
57
  }
45
58
  if ('__proto__' === key) {
@@ -16,6 +16,7 @@ type DefinitionPoint = {
16
16
  action?: string
17
17
  args: { name: string, wire: string, value: any }[]
18
18
  select: Record<string, any>
19
+ headers?: { name: string, wire: string, value: any }[]
19
20
  query: string[]
20
21
  auth: Credential[][] | null
21
22
  status: number
@@ -49,6 +50,7 @@ async function runDefinitionPoint(SDK: any, point: DefinitionPoint): Promise<voi
49
50
 
50
51
  const input: any = { ...point.select }
51
52
  for (const arg of point.args) input[arg.name] = arg.value
53
+ for (const h of point.headers || []) input[h.name] = h.value
52
54
  if (null != point.action) input.$action = point.action
53
55
 
54
56
  let result: any
@@ -70,7 +72,9 @@ async function runDefinitionPoint(SDK: any, point: DefinitionPoint): Promise<voi
70
72
  const arg = point.args.find((a) => a.wire === name)
71
73
  return null == arg ? '{' + name + '}' : encodeURIComponent(String(arg.value))
72
74
  })
73
- assert.equal(url.pathname, route, 'route')
75
+ // Read as the request was, so a backslash in the definition's path, as in
76
+ // GitLab's `Packages\(\)`, is the slash the URL parser makes it.
77
+ assert.equal(url.pathname, new URL(BASE + route).pathname, 'route')
74
78
 
75
79
  // Only what the definition declares, so never a path parameter again.
76
80
  const credentialQuery = (point.auth || []).flat()
@@ -87,6 +91,18 @@ async function runDefinitionPoint(SDK: any, point: DefinitionPoint): Promise<voi
87
91
  'credential not sent as the definition declares it: ' + JSON.stringify(point.auth))
88
92
  }
89
93
 
94
+ // A header parameter goes out as a header. The credential check above owns
95
+ // any header the security scheme names, and the SDK sets the content type
96
+ // from the body it sends.
97
+ const credentialHeaders = (point.auth || []).flat()
98
+ .filter((c) => 'header' === c.in).map((c) => c.name.toLowerCase())
99
+ for (const h of point.headers || []) {
100
+ const wire = h.wire.toLowerCase()
101
+ if (credentialHeaders.includes(wire) || 'content-type' === wire) continue
102
+ assert.equal(new Headers(init.headers).get(wire), String(h.value),
103
+ 'header parameter not sent as a header: ' + h.wire)
104
+ }
105
+
90
106
  if (null != error) {
91
107
  throw error
92
108
  }
@@ -105,7 +121,7 @@ async function runDefinitionPoint(SDK: any, point: DefinitionPoint): Promise<voi
105
121
  }
106
122
  }
107
123
  else if ('load' === point.op || 'create' === point.op || 'update' === point.op) {
108
- const record = recordOf(point.sample, point.idField)
124
+ const record = recordOf(point.sample, point.idField, point.entity)
109
125
  if (null != record) {
110
126
  assert.equal(result?.data?.()?.[point.idField], record[point.idField],
111
127
  'the entity does not hold the record the definition example returns')
@@ -145,33 +161,79 @@ function placed(cred: Credential, headers: Headers, url: URL): boolean {
145
161
  }
146
162
 
147
163
 
148
- // The records a list response holds: the body itself, or its one property
149
- // that is a non-empty list of objects. Anything else proves nothing.
164
+ // What an envelope may hold beside what it carries, compared without case,
165
+ // `_` or `-`: status, paging and the page's own metadata, each by its whole
166
+ // name, so a record's homepage or preview is data of its own.
167
+ const ENVELOPE_KEYS = new Set([
168
+ 'success', 'status', 'ok', 'message', 'code', 'error', 'errorcode', 'errormessage',
169
+ 'requestid', 'timestamp', 'took', 'version', 'apiversion', 'object', 'url',
170
+ 'count', 'total', 'totalcount', 'totalhits', 'totalitems', 'totalpages', 'totalresults',
171
+ 'totalrecords', 'totalrowcount', 'totalentries', 'itemcount', 'resultcount', 'rowcount',
172
+ 'page', 'pages', 'pagecount', 'pagenumber', 'pageindex', 'pagesize', 'perpage',
173
+ 'currentpage', 'lastpage', 'limit', 'offset', 'cursor', 'nextcursor', 'prevcursor',
174
+ 'previouscursor', 'next', 'nextpage', 'nexturl', 'nextlink', 'nextpagetoken', 'nexttoken',
175
+ 'pagetoken', 'continuationtoken', 'prev', 'previous', 'prevpage', 'previouspage',
176
+ 'prevurl', 'previousurl', 'prevlink', 'hasmore', 'hasnext', 'hasnextpage', 'hasprevious',
177
+ 'haspreviouspage', 'more', 'meta', 'metadata', 'pagination', 'paging', 'pageinfo', 'links',
178
+ ])
179
+
180
+
181
+ function envelopeKey(key: string): boolean {
182
+ return ENVELOPE_KEYS.has(squash(key))
183
+ }
184
+
185
+
186
+ function ownData(sample: any): boolean {
187
+ return Object.entries(sample).some(([key, value]) =>
188
+ null != value && 'object' !== typeof value && !envelopeKey(key))
189
+ }
190
+
191
+
192
+ function isRecord(value: any): boolean {
193
+ return null != value && 'object' === typeof value && !Array.isArray(value)
194
+ }
195
+
196
+
197
+ // The records a list response holds: the body itself, or the one non-empty
198
+ // list of objects in a page. Anything else, such as a record that happens to
199
+ // hold a list, proves nothing.
150
200
  function recordsOf(sample: any): any[] | null {
151
201
  if (Array.isArray(sample)) {
152
202
  return sample
153
203
  }
154
- if (null == sample || 'object' !== typeof sample) {
204
+ if (!isRecord(sample) || ownData(sample)) {
155
205
  return null
156
206
  }
157
207
  const lists = Object.values(sample).filter((v: any) => Array.isArray(v) && 0 < v.length &&
158
- v.every((x: any) => null != x && 'object' === typeof x && !Array.isArray(x)))
208
+ v.every(isRecord))
159
209
  return 1 === lists.length ? lists[0] as any[] : null
160
210
  }
161
211
 
162
212
 
163
- // The record a single-item response returns: the body, or its one property
164
- // holding an object with the identity field.
165
- function recordOf(sample: any, idField: string): any {
166
- if (null == sample || 'object' !== typeof sample || Array.isArray(sample)) {
213
+ // The record a single-item response returns: the body, or the one object
214
+ // with the identity field that an envelope carries, under the entity's name
215
+ // or beside envelope keys alone. Otherwise the body is the record itself,
216
+ // such as GitHub's check suite preferences beside their repository.
217
+ function recordOf(sample: any, idField: string, entity?: string): any {
218
+ if (!isRecord(sample)) {
167
219
  return null
168
220
  }
169
221
  if (null != sample[idField]) {
170
222
  return sample
171
223
  }
172
- const inner = Object.values(sample).filter((v: any) => null != v && 'object' === typeof v &&
173
- !Array.isArray(v) && null != v[idField])
174
- return 1 === inner.length ? inner[0] : null
224
+ const keys = Object.keys(sample)
225
+ const inner = keys.filter((key) => isRecord(sample[key]) && null != sample[key][idField])
226
+ if (1 !== inner.length) {
227
+ return null
228
+ }
229
+ const named = null != entity && squash(inner[0]) === squash(entity) && !ownData(sample)
230
+ const alone = keys.every((key) => key === inner[0] || envelopeKey(key))
231
+ return named || alone ? sample[inner[0]] : null
232
+ }
233
+
234
+
235
+ function squash(name: string): string {
236
+ return name.toLowerCase().replace(/[_-]/g, '')
175
237
  }
176
238
 
177
239
 
@@ -764,6 +764,15 @@ pub fn make_url_util(ctx: *Context) E![]const u8 {
764
764
  h.vstr(spec.suffix),
765
765
  }), "/", true) catch "";
766
766
 
767
+ // A route the definition ends with a slash keeps it: a server such as a
768
+ // Django REST one redirects or refuses the route without it.
769
+ const orig = h.getp(ctx.point, "orig");
770
+ if (orig == .string and std.mem.endsWith(u8, orig.string, "/") and spec.suffix.len == 0 and
771
+ !std.mem.endsWith(u8, url, "/"))
772
+ {
773
+ url = std.mem.concat(h.A(), u8, &.{ url, "/" }) catch url;
774
+ }
775
+
767
776
  const resmatch = h.omap();
768
777
 
769
778
  if (spec.params == .object) {
@@ -1043,12 +1052,37 @@ pub fn prepare_headers_util(ctx: *Context) Value {
1043
1052
  const options: Value = if (ctx.client) |client| client.options_map() else ctx.options;
1044
1053
 
1045
1054
  const headers = h.getp(options, "headers");
1046
- if (h.is_noval(headers)) return h.omap();
1047
-
1048
- return switch (h.clone(headers)) {
1055
+ const out: Value = if (h.is_noval(headers)) h.omap() else switch (h.clone(headers)) {
1049
1056
  .object => h.clone(headers),
1050
1057
  else => h.omap(),
1051
1058
  };
1059
+
1060
+ // A header parameter travels as a header, under the name the definition
1061
+ // gives it, and only from this call's own arguments. It replaces a default
1062
+ // of the same name, whatever its case.
1063
+ const aheader: Value = h.getpath(&.{ "args", "header" }, ctx.point);
1064
+ if (aheader == .array) {
1065
+ for (aheader.array.data.items) |hd| {
1066
+ const name = h.getp(hd, "name");
1067
+ if (name != .string or name.string.len == 0) continue;
1068
+ const orig = h.getp(hd, "orig");
1069
+ const wire: []const u8 = if (orig == .string and orig.string.len != 0) orig.string else name.string;
1070
+ var val = h.getp(ctx.reqmatch, name.string);
1071
+ if (h.is_noval(val)) val = h.getp(ctx.reqdata, name.string);
1072
+ if (h.is_noval(val)) continue;
1073
+ const key = std.ascii.allocLowerString(h.A(), wire) catch wire;
1074
+ while (true) {
1075
+ var kit = out.object.iterator();
1076
+ const same: ?[]const u8 = while (kit.next()) |kv| {
1077
+ if (std.ascii.eqlIgnoreCase(kv.key_ptr.*, key)) break kv.key_ptr.*;
1078
+ } else null;
1079
+ _ = out.object.fetchOrderedRemove(same orelse break);
1080
+ }
1081
+ h.setp(out, key, h.vstr(h.stringify(val)));
1082
+ }
1083
+ }
1084
+
1085
+ return out;
1052
1086
  }
1053
1087
 
1054
1088
  pub fn prepare_body_util(ctx: *Context) Value {
@@ -1094,6 +1128,16 @@ pub fn prepare_path_util(ctx: *Context) []const u8 {
1094
1128
  return vs.join(h.A(), parts, "/", true) catch "";
1095
1129
  }
1096
1130
 
1131
+ // Whether a list of argument definitions names this key.
1132
+ fn names_key(defs: Value, key: []const u8) bool {
1133
+ if (defs != .array) return false;
1134
+ for (defs.array.data.items) |d| {
1135
+ const name = h.getp(d, "name");
1136
+ if (name == .string and std.mem.eql(u8, name.string, key)) return true;
1137
+ }
1138
+ return false;
1139
+ }
1140
+
1097
1141
  pub fn prepare_query_util(ctx: *Context) Value {
1098
1142
  const point = ctx.point;
1099
1143
  const reqmatch: Value = switch (ctx.reqmatch) {
@@ -1107,6 +1151,8 @@ pub fn prepare_query_util(ctx: *Context) Value {
1107
1151
  // A path parameter travels in the path. The generated config lists them
1108
1152
  // as args.params, which prepare_params reads; params is the older list.
1109
1153
  const aparams: Value = h.getpath(&.{ "args", "params" }, point);
1154
+ // A header parameter travels in the headers, which prepare_headers fills.
1155
+ const aheader: Value = h.getpath(&.{ "args", "header" }, point);
1110
1156
  // A query parameter travels under the name the definition gives it, its
1111
1157
  // orig, which the model may have renamed for the caller.
1112
1158
  const aquery: Value = h.getpath(&.{ "args", "query" }, point);
@@ -1126,15 +1172,7 @@ pub fn prepare_query_util(ctx: *Context) Value {
1126
1172
  }
1127
1173
  }
1128
1174
  }
1129
- if (!contained and aparams == .array) {
1130
- for (aparams.array.data.items) |pd| {
1131
- const name = h.getp(pd, "name");
1132
- if (name == .string and std.mem.eql(u8, name.string, key)) {
1133
- contained = true;
1134
- break;
1135
- }
1136
- }
1137
- }
1175
+ if (!contained) contained = names_key(aparams, key) or names_key(aheader, key);
1138
1176
  var wire: []const u8 = key;
1139
1177
  if (aquery == .array) {
1140
1178
  for (aquery.array.data.items) |qd| {
@@ -1406,13 +1444,33 @@ pub fn result_body_util(ctx: *Context) ?*SdkResult {
1406
1444
  // `$action` selects the point (see make_point_util); it is never an API
1407
1445
  // field, so the body is a copy without it. The caller's map is left untouched.
1408
1446
  fn strip_action(reqdata: Value) Value {
1447
+ return omit_keys(reqdata, h.vnull(), true);
1448
+ }
1449
+
1450
+ // A header argument travels as a header, which prepare_headers_util sends, so
1451
+ // the body is built from the request data without it.
1452
+ fn dropped(key: []const u8, point: Value, action: bool) bool {
1453
+ if (action) return std.mem.eql(u8, key, "$action");
1454
+ const aheader: Value = h.getpath(&.{ "args", "header" }, point);
1455
+ if (aheader != .array) return false;
1456
+ for (aheader.array.data.items) |hd| {
1457
+ const name = h.getp(hd, "name");
1458
+ if (name == .string and std.mem.eql(u8, name.string, key)) return true;
1459
+ }
1460
+ return false;
1461
+ }
1462
+
1463
+ fn omit_keys(reqdata: Value, point: Value, action: bool) Value {
1409
1464
  if (reqdata != .object) return reqdata;
1410
- if (reqdata.object.get("$action") == null) return reqdata;
1411
- const body = h.omap();
1465
+ var found = false;
1412
1466
  var it = reqdata.object.iterator();
1413
- while (it.next()) |kv| {
1467
+ while (it.next()) |kv| found = found or dropped(kv.key_ptr.*, point, action);
1468
+ if (!found) return reqdata;
1469
+ const body = h.omap();
1470
+ var bit = reqdata.object.iterator();
1471
+ while (bit.next()) |kv| {
1414
1472
  const key = kv.key_ptr.*;
1415
- if (!std.mem.eql(u8, key, "$action")) h.setp(body, key, kv.value_ptr.*);
1473
+ if (!dropped(key, point, action)) h.setp(body, key, kv.value_ptr.*);
1416
1474
  }
1417
1475
  return body;
1418
1476
  }
@@ -1423,16 +1481,18 @@ pub fn transform_request_util(ctx: *Context) Value {
1423
1481
 
1424
1482
  if (spec) |sp| sp.step = "reqform";
1425
1483
 
1484
+ const reqdata = omit_keys(ctx.reqdata, point, false);
1485
+
1426
1486
  const transform = h.to_map(h.getp(point, "transform"));
1427
- if (h.is_noval(transform)) return strip_action(ctx.reqdata);
1487
+ if (h.is_noval(transform)) return strip_action(reqdata);
1428
1488
 
1429
1489
  const reqform = h.getp(transform, "req");
1430
- if (h.is_noval(reqform)) return strip_action(ctx.reqdata);
1490
+ if (h.is_noval(reqform)) return strip_action(reqdata);
1431
1491
 
1432
- const store = h.jo(&.{.{ "reqdata", ctx.reqdata }});
1492
+ const store = h.jo(&.{.{ "reqdata", reqdata }});
1433
1493
  // transform now reports collected injection errors beside the value; .out
1434
1494
  // is what it used to return on its own, errors or not.
1435
- const tres = vs.transform(h.A(), store, reqform) catch return strip_action(ctx.reqdata);
1495
+ const tres = vs.transform(h.A(), store, reqform) catch return strip_action(reqdata);
1436
1496
  return strip_action(tres.out);
1437
1497
  }
1438
1498
 
@@ -3,7 +3,7 @@
3
3
  "package": 1
4
4
  },
5
5
  "name": "@voxgig/sdkgen",
6
- "version": "4.31.0",
6
+ "version": "4.32.0",
7
7
  "provides": {
8
8
  "target": [
9
9
  "c",
@@ -737,7 +737,7 @@ async function npmFetch(pkgname: string, actx: ActionContext) {
737
737
  try {
738
738
  const out = await run(
739
739
  'win32' === process.platform ? 'npm.cmd' : 'npm',
740
- ['install', '--save-dev', pkgname + '@latest'],
740
+ npmFetchArgs(pkgname),
741
741
  { cwd, maxBuffer: 64 * 1024 * 1024 })
742
742
 
743
743
  actx.log.debug({
@@ -755,6 +755,14 @@ async function npmFetch(pkgname: string, actx: ActionContext) {
755
755
  }
756
756
 
757
757
 
758
+ // The saved range is a tilde, as the scaffold writes it: a caret would let
759
+ // the next minor float in, and a scaffold contract can change in a minor.
760
+ // An .npmrc `save-exact` outranks the prefix, so it is switched off here.
761
+ function npmFetchArgs(pkgname: string): string[] {
762
+ return ['install', '--save-dev', '--save-exact=false', '--save-prefix=~', pkgname + '@latest']
763
+ }
764
+
765
+
758
766
  function validateFetched(
759
767
  pkgname: string, installed: Installed[], actx: ActionContext,
760
768
  ) {
@@ -938,6 +946,7 @@ export {
938
946
  package_add,
939
947
  package_update,
940
948
  installedFrom,
949
+ npmFetchArgs,
941
950
  resolvePackage,
942
951
  selectItems,
943
952
  parseAliases,