@voxgig/sdkgen 4.31.1 → 4.32.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 (76) hide show
  1. package/bin/voxgig-sdkgen +1 -1
  2. package/dist/helpers/definitionPlan.d.ts +5 -0
  3. package/dist/helpers/definitionPlan.js +44 -7
  4. package/dist/helpers/definitionPlan.js.map +1 -1
  5. package/dist/tsconfig.tsbuildinfo +1 -1
  6. package/package.json +1 -1
  7. package/project/.sdk/tm/c/utility/make_url.c +11 -0
  8. package/project/.sdk/tm/c/utility/prepare_headers.c +53 -3
  9. package/project/.sdk/tm/c/utility/prepare_query.c +4 -1
  10. package/project/.sdk/tm/c/utility/transform_request.c +31 -8
  11. package/project/.sdk/tm/clojure/src/sdk/core.clj +62 -15
  12. package/project/.sdk/tm/cpp/utility/pipeline.hpp +72 -15
  13. package/project/.sdk/tm/csharp/utility/MakeUrl.cs +8 -0
  14. package/project/.sdk/tm/csharp/utility/PrepareHeaders.cs +34 -4
  15. package/project/.sdk/tm/csharp/utility/PrepareQuery.cs +14 -0
  16. package/project/.sdk/tm/csharp/utility/TransformRequest.cs +31 -5
  17. package/project/.sdk/tm/elixir/lib/projectname/utility.ex +86 -24
  18. package/project/.sdk/tm/go/utility/make_url.go +8 -0
  19. package/project/.sdk/tm/go/utility/prepare_headers.go +36 -7
  20. package/project/.sdk/tm/go/utility/prepare_query.go +11 -0
  21. package/project/.sdk/tm/go/utility/transform_request.go +31 -5
  22. package/project/.sdk/tm/java/utility/MakeUrl.java +8 -0
  23. package/project/.sdk/tm/java/utility/PrepareHeaders.java +30 -6
  24. package/project/.sdk/tm/java/utility/PrepareQuery.java +10 -0
  25. package/project/.sdk/tm/java/utility/TransformRequest.java +30 -5
  26. package/project/.sdk/tm/js/src/Response.js +14 -1
  27. package/project/.sdk/tm/js/src/utility/MakeUrlUtility.js +7 -0
  28. package/project/.sdk/tm/js/src/utility/PrepareHeadersUtility.js +18 -0
  29. package/project/.sdk/tm/js/src/utility/PrepareQueryUtility.js +3 -1
  30. package/project/.sdk/tm/js/src/utility/TransformRequestUtility.js +17 -3
  31. package/project/.sdk/tm/js/test/definition-runner.js +74 -13
  32. package/project/.sdk/tm/js/test/utility/PrimaryUtility.test.js +25 -0
  33. package/project/.sdk/tm/kotlin/utility/MakeSpecUrl.kt +8 -0
  34. package/project/.sdk/tm/kotlin/utility/Prepare.kt +34 -4
  35. package/project/.sdk/tm/kotlin/utility/ResultTransform.kt +18 -6
  36. package/project/.sdk/tm/lua/utility/make_url.lua +8 -0
  37. package/project/.sdk/tm/lua/utility/prepare_headers.lua +35 -6
  38. package/project/.sdk/tm/lua/utility/prepare_query.lua +10 -0
  39. package/project/.sdk/tm/lua/utility/transform_request.lua +37 -6
  40. package/project/.sdk/tm/ocaml/sdk_runtime.ml +63 -13
  41. package/project/.sdk/tm/perl/utility/make_url.pm +6 -0
  42. package/project/.sdk/tm/perl/utility/prepare_headers.pm +21 -3
  43. package/project/.sdk/tm/perl/utility/prepare_query.pm +8 -0
  44. package/project/.sdk/tm/perl/utility/transform_request.pm +19 -7
  45. package/project/.sdk/tm/php/utility/MakeUrl.php +7 -0
  46. package/project/.sdk/tm/php/utility/PrepareHeaders.php +33 -4
  47. package/project/.sdk/tm/php/utility/PrepareQuery.php +11 -0
  48. package/project/.sdk/tm/php/utility/TransformRequest.php +28 -5
  49. package/project/.sdk/tm/py/pkg/utility/make_url.py +7 -0
  50. package/project/.sdk/tm/py/pkg/utility/prepare_headers.py +27 -6
  51. package/project/.sdk/tm/py/pkg/utility/prepare_query.py +8 -0
  52. package/project/.sdk/tm/py/pkg/utility/transform_request.py +21 -5
  53. package/project/.sdk/tm/rb/utility/make_url.rb +7 -0
  54. package/project/.sdk/tm/rb/utility/prepare_headers.rb +21 -3
  55. package/project/.sdk/tm/rb/utility/prepare_query.rb +8 -0
  56. package/project/.sdk/tm/rb/utility/transform_request.rb +18 -5
  57. package/project/.sdk/tm/rust/utility/make_url.rs +10 -1
  58. package/project/.sdk/tm/rust/utility/prepare_headers.rs +43 -7
  59. package/project/.sdk/tm/rust/utility/prepare_query.rs +18 -1
  60. package/project/.sdk/tm/rust/utility/transform_request.rs +29 -7
  61. package/project/.sdk/tm/scala/utility/Make.scala +9 -0
  62. package/project/.sdk/tm/scala/utility/Prepare.scala +42 -3
  63. package/project/.sdk/tm/scala/utility/Transform.scala +30 -6
  64. package/project/.sdk/tm/swift/Sources/ProjectNameSDK/utility/Make.swift +7 -0
  65. package/project/.sdk/tm/swift/Sources/ProjectNameSDK/utility/Prepare.swift +28 -2
  66. package/project/.sdk/tm/swift/Sources/ProjectNameSDK/utility/ResultUtil.swift +23 -5
  67. package/project/.sdk/tm/ts/src/Response.ts +14 -1
  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/ts/test/utility/PrimaryUtility.test.ts +25 -0
  74. package/project/.sdk/tm/zig/core/utility.zig +80 -20
  75. package/project/sdkgen-package.json +1 -1
  76. package/src/helpers/definitionPlan.ts +49 -7
@@ -80,6 +80,15 @@ object PrepareQuery {
80
80
  }
81
81
  case _ =>
82
82
  }
83
+ // A header parameter travels in the headers, which prepareHeaders fills.
84
+ Struct.getpath(point, java.util.List.of("args", "header")) match {
85
+ case l: JList[_] =>
86
+ val hit = l.iterator()
87
+ while (hit.hasNext) {
88
+ Struct.getprop(hit.next(), "name") match { case s: String => params.add(s); case _ => }
89
+ }
90
+ case _ =>
91
+ }
83
92
  }
84
93
 
85
94
  // A query parameter travels under the name the definition gives it, its
@@ -124,9 +133,39 @@ object PrepareHeaders {
124
133
  def prepareHeaders(ctx: Context): JMap[String, Object] = {
125
134
  val options = ctx.client.optionsMap()
126
135
  val headers = Struct.getprop(options, "headers")
127
- if (headers == null) return new LinkedHashMap[String, Object]()
128
- val out = Helpers.toMapAny(Struct.clone(headers))
129
- if (out != null) out else new LinkedHashMap[String, Object]()
136
+ val cloned = if (headers == null) null else Helpers.toMapAny(Struct.clone(headers))
137
+ val out: JMap[String, Object] = if (cloned != null) cloned else new LinkedHashMap[String, Object]()
138
+
139
+ // A header parameter travels as a header, under the name the definition
140
+ // gives it, and only from this call's own arguments. It replaces a default
141
+ // of the same name, whatever its case.
142
+ if (ctx.point != null) {
143
+ Struct.getpath(ctx.point, java.util.List.of("args", "header")) match {
144
+ case l: JList[_] =>
145
+ val hit = l.iterator()
146
+ while (hit.hasNext) {
147
+ val hd = hit.next()
148
+ Struct.getprop(hd, "name") match {
149
+ case name: String if name.nonEmpty =>
150
+ val wire = Struct.getprop(hd, "orig") match {
151
+ case o: String if o.nonEmpty => o
152
+ case _ => name
153
+ }
154
+ var v: Object = if (ctx.reqmatch == null) null else Struct.getprop(ctx.reqmatch, name, null)
155
+ if (v == null && ctx.reqdata != null) v = Struct.getprop(ctx.reqdata, name, null)
156
+ if (v != null) {
157
+ val key = wire.toLowerCase(java.util.Locale.ROOT)
158
+ out.keySet().removeIf(k => k != null && k.toLowerCase(java.util.Locale.ROOT) == key)
159
+ out.put(key, Struct.stringify(v))
160
+ }
161
+ case _ =>
162
+ }
163
+ }
164
+ case _ =>
165
+ }
166
+ }
167
+
168
+ out
130
169
  }
131
170
  }
132
171
 
@@ -8,28 +8,52 @@ object TransformRequest {
8
8
  def transformRequest(ctx: Context): Object = {
9
9
  if (ctx.spec != null) ctx.spec.step = "reqform"
10
10
 
11
+ val reqdata = omit(ctx.reqdata, headerArgNames(ctx))
12
+
11
13
  val transform = Helpers.toMapAny(Struct.getprop(ctx.point, "transform"))
12
- if (transform == null) return stripAction(ctx.reqdata)
14
+ if (transform == null) return stripAction(reqdata)
13
15
 
14
16
  val reqform = Struct.getprop(transform, "req", null)
15
- if (reqform == null) return stripAction(ctx.reqdata)
17
+ if (reqform == null) return stripAction(reqdata)
16
18
 
17
19
  val data = new LinkedHashMap[String, Object]()
18
- data.put("reqdata", ctx.reqdata)
20
+ data.put("reqdata", reqdata)
19
21
 
20
22
  stripAction(Struct.transform(data, reqform))
21
23
  }
22
24
 
23
25
  // `$action` selects the point (see MakePoint); it is never an API field, so
24
26
  // the body is a copy without it. The caller's map is left untouched.
25
- private def stripAction(reqdata: Object): Object = {
27
+ private def stripAction(reqdata: Object): Object = omit(reqdata, Seq("$action"))
28
+
29
+ // A header argument travels as a header, which PrepareHeaders sends, so the
30
+ // body is built from the request data without it.
31
+ private def headerArgNames(ctx: Context): Seq[String] = {
32
+ if (ctx.point == null) return Seq.empty
33
+ Struct.getpath(ctx.point, java.util.List.of("args", "header")) match {
34
+ case l: java.util.List[_] =>
35
+ val names = scala.collection.mutable.ArrayBuffer[String]()
36
+ val hit = l.iterator()
37
+ while (hit.hasNext) {
38
+ val hd = hit.next()
39
+ Struct.getprop(hd, "name") match {
40
+ case name: String if name.nonEmpty => names += name
41
+ case _ =>
42
+ }
43
+ }
44
+ names.toSeq
45
+ case _ => Seq.empty
46
+ }
47
+ }
48
+
49
+ private def omit(reqdata: Object, names: Seq[String]): Object = {
26
50
  reqdata match {
27
- case src: JMap[_, _] if src.containsKey("$action") =>
51
+ case src: JMap[_, _] if names.exists(n => src.containsKey(n)) =>
28
52
  val body = new LinkedHashMap[String, Object]()
29
53
  val it = src.asInstanceOf[JMap[String, Object]].entrySet().iterator()
30
54
  while (it.hasNext) {
31
55
  val e = it.next()
32
- if ("$action" != e.getKey) body.put(e.getKey, e.getValue)
56
+ if (!names.contains(e.getKey)) body.put(e.getKey, e.getValue)
33
57
  }
34
58
  body
35
59
  case _ => reqdata
@@ -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 {
@@ -14,13 +14,26 @@ class Response {
14
14
  this.status = getprop(resmap, 'status', -1)
15
15
  this.statusText = getprop(resmap, 'statusText', '')
16
16
  this.headers = getprop(resmap, 'headers')
17
- this.json = resmap.json ? resmap.json.bind(resmap) : async () => undefined
17
+ this.json = readJson(resmap)
18
18
  this.body = getprop(resmap, 'body')
19
19
  this.err = getprop(resmap, 'err')
20
20
  }
21
21
  }
22
22
 
23
23
 
24
+ // An empty body, such as the one an accepted delete answers with, is no
25
+ // body: parsing it as JSON would throw after the call has succeeded.
26
+ function readJson(resmap: Record<string, any>): Function {
27
+ if ('function' === typeof resmap.text) {
28
+ return async () => {
29
+ const text = await resmap.text()
30
+ return '' === text.trim() ? undefined : JSON.parse(text)
31
+ }
32
+ }
33
+ return resmap.json ? resmap.json.bind(resmap) : async () => undefined
34
+ }
35
+
36
+
24
37
  export {
25
38
  Response,
26
39
  }
@@ -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
 
@@ -259,6 +259,31 @@ describe('PrimaryUtility', async () => {
259
259
  })
260
260
 
261
261
 
262
+ // An accepted delete answers 202 with an empty body, which is no body.
263
+ test('resbody-empty', async () => {
264
+ const read = async (res: any) => {
265
+ const reqClient = new (SDK as any)({
266
+ base: 'http://localhost:8080',
267
+ system: { fetch: async () => res },
268
+ })
269
+ const reqUtility = reqClient.utility()
270
+ const ctx = reqUtility.makeContext({
271
+ opname: 'remove',
272
+ spec: {
273
+ alias: {}, base: 'http://localhost/', headers: {}, method: 'DELETE',
274
+ params: {}, path: '/p0', prefix: '', query: {}, suffix: '',
275
+ },
276
+ }, reqClient._rootctx)
277
+ ctx.client = reqClient
278
+ await reqUtility.makeRequest(ctx)
279
+ return reqUtility.resultBody(ctx)
280
+ }
281
+
282
+ equal((await read(new Response('', { status: 202 }))).body, undefined)
283
+ deepStrictEqual((await read(new Response('{"a":1}', { status: 200 }))).body, { a: 1 })
284
+ })
285
+
286
+
262
287
  test('request-basic', async () => {
263
288
  const mockFetch = async (url: string, init: any) => ({
264
289
  status: 200,