@voxgig/sdkgen 4.23.0 → 4.24.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 (138) hide show
  1. package/bin/voxgig-sdkgen +18 -2
  2. package/dist/action/doctor.d.ts +1 -1
  3. package/dist/action/doctor.js +12 -10
  4. package/dist/action/doctor.js.map +1 -1
  5. package/dist/action/edition.js +4 -0
  6. package/dist/action/edition.js.map +1 -1
  7. package/dist/action/feature.js +5 -1
  8. package/dist/action/feature.js.map +1 -1
  9. package/dist/action/remove.d.ts +16 -0
  10. package/dist/action/remove.js +345 -0
  11. package/dist/action/remove.js.map +1 -0
  12. package/dist/action/target.js +6 -1
  13. package/dist/action/target.js.map +1 -1
  14. package/dist/tsconfig.tsbuildinfo +1 -1
  15. package/package.json +1 -1
  16. package/project/.sdk/tm/c/core/context.c +3 -1
  17. package/project/.sdk/tm/c/feature/paging.c +37 -3
  18. package/project/.sdk/tm/c/feature/test.c +51 -27
  19. package/project/.sdk/tm/c/utility/prepare_query.c +2 -1
  20. package/project/.sdk/tm/c/utility/transform_request.c +25 -3
  21. package/project/.sdk/tm/clojure/src/sdk/core.clj +21 -7
  22. package/project/.sdk/tm/clojure/src/sdk/features.clj +68 -18
  23. package/project/.sdk/tm/cpp/core/types.hpp +1 -1
  24. package/project/.sdk/tm/cpp/feature/paging.hpp +36 -3
  25. package/project/.sdk/tm/cpp/feature/test.hpp +41 -13
  26. package/project/.sdk/tm/cpp/utility/pipeline.hpp +16 -4
  27. package/project/.sdk/tm/csharp/core/Context.cs +1 -1
  28. package/project/.sdk/tm/csharp/feature/PagingFeature.cs +58 -4
  29. package/project/.sdk/tm/csharp/feature/TestFeature.cs +13 -21
  30. package/project/.sdk/tm/csharp/utility/Fetcher.cs +19 -12
  31. package/project/.sdk/tm/csharp/utility/PrepareQuery.cs +1 -1
  32. package/project/.sdk/tm/csharp/utility/TransformRequest.cs +23 -3
  33. package/project/.sdk/tm/elixir/lib/projectname/context.ex +1 -1
  34. package/project/.sdk/tm/elixir/lib/projectname/feature/paging.ex +52 -10
  35. package/project/.sdk/tm/elixir/lib/projectname/feature/test.ex +47 -17
  36. package/project/.sdk/tm/elixir/lib/projectname/utility.ex +30 -9
  37. package/project/.sdk/tm/go/core/context.go +13 -13
  38. package/project/.sdk/tm/go/feature/paging_feature.go +40 -2
  39. package/project/.sdk/tm/go/feature/test_feature.go +3 -15
  40. package/project/.sdk/tm/go/test/feature_corpus_test.go +1 -0
  41. package/project/.sdk/tm/go/test/feature_test.go +54 -0
  42. package/project/.sdk/tm/go/utility/fetcher.go +41 -20
  43. package/project/.sdk/tm/go/utility/prepare_query.go +1 -1
  44. package/project/.sdk/tm/go/utility/transform_request.go +22 -3
  45. package/project/.sdk/tm/java/core/Context.java +2 -1
  46. package/project/.sdk/tm/java/feature/PagingFeature.java +46 -4
  47. package/project/.sdk/tm/java/feature/TestFeature.java +10 -13
  48. package/project/.sdk/tm/java/test/FeatureCorpusTest.java +3 -0
  49. package/project/.sdk/tm/java/test/FeatureTest.java +42 -0
  50. package/project/.sdk/tm/java/utility/Fetcher.java +36 -28
  51. package/project/.sdk/tm/java/utility/PrepareQuery.java +1 -1
  52. package/project/.sdk/tm/java/utility/TransformRequest.java +18 -3
  53. package/project/.sdk/tm/js/src/Context.js +7 -2
  54. package/project/.sdk/tm/js/src/feature/paging/PagingFeature.js +24 -6
  55. package/project/.sdk/tm/js/src/feature/test/TestFeature.js +7 -5
  56. package/project/.sdk/tm/js/src/utility/PrepareQueryUtility.js +1 -1
  57. package/project/.sdk/tm/js/test/feature/Corpus.test.js +4 -0
  58. package/project/.sdk/tm/kotlin/core/Context.kt +1 -1
  59. package/project/.sdk/tm/kotlin/feature/PagingFeature.kt +36 -4
  60. package/project/.sdk/tm/kotlin/feature/TestFeature.kt +11 -14
  61. package/project/.sdk/tm/kotlin/utility/Fetcher.kt +33 -24
  62. package/project/.sdk/tm/kotlin/utility/Prepare.kt +1 -1
  63. package/project/.sdk/tm/kotlin/utility/ResultTransform.kt +18 -3
  64. package/project/.sdk/tm/lua/core/context.lua +8 -1
  65. package/project/.sdk/tm/lua/core/control.lua +3 -0
  66. package/project/.sdk/tm/lua/feature/paging_feature.lua +44 -5
  67. package/project/.sdk/tm/lua/feature/test_feature.lua +32 -14
  68. package/project/.sdk/tm/lua/utility/prepare_query.lua +2 -1
  69. package/project/.sdk/tm/lua/utility/transform_request.lua +19 -3
  70. package/project/.sdk/tm/ocaml/sdk_features.ml +72 -21
  71. package/project/.sdk/tm/ocaml/sdk_runtime.ml +23 -8
  72. package/project/.sdk/tm/perl/core/context.pm +2 -1
  73. package/project/.sdk/tm/perl/feature/paging_feature.pm +31 -5
  74. package/project/.sdk/tm/perl/feature/test_feature.pm +46 -16
  75. package/project/.sdk/tm/perl/feature/timeout_feature.pm +17 -13
  76. package/project/.sdk/tm/perl/t/feature_corpus.t +14 -1
  77. package/project/.sdk/tm/perl/utility/fetcher.pm +15 -1
  78. package/project/.sdk/tm/perl/utility/prepare_query.pm +1 -0
  79. package/project/.sdk/tm/perl/utility/transform_request.pm +13 -3
  80. package/project/.sdk/tm/php/core/Context.php +5 -1
  81. package/project/.sdk/tm/php/core/Control.php +5 -0
  82. package/project/.sdk/tm/php/feature/PagingFeature.php +26 -5
  83. package/project/.sdk/tm/php/feature/TestFeature.php +27 -42
  84. package/project/.sdk/tm/php/test/FeatureCorpusTest.php +17 -1
  85. package/project/.sdk/tm/php/test/FeatureTest.php +44 -0
  86. package/project/.sdk/tm/php/utility/Fetcher.php +17 -3
  87. package/project/.sdk/tm/php/utility/PrepareQuery.php +1 -1
  88. package/project/.sdk/tm/php/utility/TransformRequest.php +15 -3
  89. package/project/.sdk/tm/py/pkg/core/context.py +2 -1
  90. package/project/.sdk/tm/py/pkg/feature/paging_feature.py +29 -4
  91. package/project/.sdk/tm/py/pkg/feature/test_feature.py +29 -12
  92. package/project/.sdk/tm/py/pkg/utility/fetcher.py +63 -65
  93. package/project/.sdk/tm/py/pkg/utility/prepare_query.py +2 -1
  94. package/project/.sdk/tm/py/pkg/utility/transform_request.py +11 -3
  95. package/project/.sdk/tm/py/test/test_feature.py +27 -0
  96. package/project/.sdk/tm/py/test/test_feature_corpus.py +11 -1
  97. package/project/.sdk/tm/rb/core/context.rb +1 -1
  98. package/project/.sdk/tm/rb/feature/paging_feature.rb +27 -5
  99. package/project/.sdk/tm/rb/feature/test_feature.rb +33 -7
  100. package/project/.sdk/tm/rb/test/feature_corpus_test.rb +12 -1
  101. package/project/.sdk/tm/rb/test/feature_test.rb +38 -0
  102. package/project/.sdk/tm/rb/utility/fetcher.rb +49 -3
  103. package/project/.sdk/tm/rb/utility/prepare_query.rb +1 -1
  104. package/project/.sdk/tm/rb/utility/transform_request.rb +10 -3
  105. package/project/.sdk/tm/rust/core/context.rs +2 -6
  106. package/project/.sdk/tm/rust/feature/paging.rs +44 -3
  107. package/project/.sdk/tm/rust/feature/secrets.rs +1 -1
  108. package/project/.sdk/tm/rust/feature/test.rs +66 -33
  109. package/project/.sdk/tm/rust/utility/fetcher.rs +41 -14
  110. package/project/.sdk/tm/rust/utility/prepare_query.rs +1 -1
  111. package/project/.sdk/tm/rust/utility/transform_request.rs +31 -5
  112. package/project/.sdk/tm/scala/core/Context.scala +2 -1
  113. package/project/.sdk/tm/scala/feature/PagingFeature.scala +41 -4
  114. package/project/.sdk/tm/scala/feature/TestFeature.scala +11 -12
  115. package/project/.sdk/tm/scala/utility/Fetcher.scala +34 -13
  116. package/project/.sdk/tm/scala/utility/Prepare.scala +1 -1
  117. package/project/.sdk/tm/scala/utility/Transform.scala +19 -3
  118. package/project/.sdk/tm/swift/Sources/ProjectNameSDK/core/Context.swift +1 -1
  119. package/project/.sdk/tm/swift/Sources/ProjectNameSDK/feature/PagingFeature.swift +31 -4
  120. package/project/.sdk/tm/swift/Sources/ProjectNameSDK/feature/TestFeature.swift +12 -9
  121. package/project/.sdk/tm/swift/Sources/ProjectNameSDK/utility/Fetcher.swift +22 -3
  122. package/project/.sdk/tm/swift/Sources/ProjectNameSDK/utility/Prepare.swift +1 -1
  123. package/project/.sdk/tm/swift/Sources/ProjectNameSDK/utility/ResultUtil.swift +14 -3
  124. package/project/.sdk/tm/ts/src/Context.ts +7 -2
  125. package/project/.sdk/tm/ts/src/feature/paging/PagingFeature.ts +19 -3
  126. package/project/.sdk/tm/ts/src/feature/test/TestFeature.ts +3 -0
  127. package/project/.sdk/tm/ts/src/utility/PrepareQueryUtility.ts +1 -1
  128. package/project/.sdk/tm/ts/test/feature/Corpus.test.ts +1 -0
  129. package/project/.sdk/tm/zig/core/context.zig +1 -1
  130. package/project/.sdk/tm/zig/core/utility.zig +19 -5
  131. package/project/.sdk/tm/zig/feature/paging.zig +34 -4
  132. package/project/.sdk/tm/zig/feature/test.zig +54 -26
  133. package/project/sdkgen-package.json +1 -1
  134. package/src/action/doctor.ts +12 -9
  135. package/src/action/edition.ts +8 -0
  136. package/src/action/feature.ts +7 -1
  137. package/src/action/remove.ts +492 -0
  138. package/src/action/target.ts +8 -1
@@ -724,6 +724,33 @@ class TestPagingFeature:
724
724
  assert res["result"].paging["cursor"] == "abc"
725
725
  assert res["result"].paging["hasMore"] is True
726
726
 
727
+ def test_snake_case_signals_and_ctrl_write_back(self):
728
+ server, calls = recording_server(lambda n, _fd: make_response(
729
+ 200, {"has_more": True, "next_cursor": "c2"} if n == 1
730
+ else {"has_more": False}))
731
+ h = make_client([{"name": "paging"}], server=server)
732
+ pg = {}
733
+ ctrl = {"paging": pg}
734
+ res = h.op(op="list", path="/w", ctrl=ctrl)
735
+ assert res["result"].paging["hasMore"] is True
736
+ assert res["result"].paging["cursor"] == "c2"
737
+ assert pg["cursor"] == "c2" and pg["hasMore"] is True
738
+ h.op(op="list", path="/w", ctrl=ctrl)
739
+ assert re.search(r"[?&]cursor=c2(&|$)", calls[1]["url"])
740
+ assert pg["hasMore"] is False and pg["cursor"] is None
741
+
742
+ def test_continues_from_written_back_next_page(self):
743
+ server, calls = recording_server(lambda n, _fd: make_response(
744
+ 200, {"next_page": 2} if n == 1 else {}, {"x-page": str(n)}))
745
+ h = make_client([{"name": "paging"}], server=server)
746
+ pg = {}
747
+ ctrl = {"paging": pg}
748
+ h.op(op="list", path="/w", ctrl=ctrl)
749
+ assert pg["page"] == 1 and pg["nextPage"] == 2 and pg["hasMore"] is True
750
+ h.op(op="list", path="/w", ctrl=ctrl)
751
+ assert re.search(r"[?&]page=2(&|$)", calls[1]["url"])
752
+ assert pg["hasMore"] is False
753
+
727
754
  def test_non_list_op_is_not_paged(self):
728
755
  server, calls = recording_server()
729
756
  h = make_client([{"name": "paging"}], server=server)
@@ -240,6 +240,14 @@ def _subset(actual, expect, path):
240
240
  "{}: got {!r}, want {!r}".format(path, actual, expect)
241
241
 
242
242
 
243
+ # Whether the SDK built the feature at all. The activity record is the
244
+ # wrong probe: most features create theirs on first use, so an idle client
245
+ # has none and every section but the eager ones read as inert.
246
+ def _present(client, name):
247
+ return any(getattr(f, "name", None) == name
248
+ for f in (getattr(client, "features", None) or []))
249
+
250
+
243
251
  def _record(client, name):
244
252
  return getattr(client, "_" + name, None)
245
253
 
@@ -280,7 +288,9 @@ class TestFeatureCorpus:
280
288
  # Probed by ACTIVATING it: the feature defaults to inactive, so an
281
289
  # idle client never builds it and its absence says nothing.
282
290
  probe = _client({"feature": [{"name": name, "active": True}]})
283
- if _record(probe, name) is None:
291
+ if not _present(probe, name):
292
+ # The one line every runner prints for an inert section.
293
+ print("feature.{}: inert (this SDK does not generate the feature)".format(name))
284
294
  pytest.skip("this SDK was generated without the {} feature".format(name))
285
295
 
286
296
  ops = _usable_ops(2)
@@ -30,7 +30,7 @@ class ProjectNameContext
30
30
  @ctrl.explain = ctrl_raw["explain"] if ctrl_raw["explain"].is_a?(Hash)
31
31
  @ctrl.actor = ctrl_raw["actor"] if ctrl_raw.key?("actor")
32
32
  @ctrl.paging = ctrl_raw["paging"] if ctrl_raw["paging"].is_a?(Hash)
33
- elsif basectx&.ctrl
33
+ elsif basectx&.ctrl && ProjectNameHelpers.get_ctx_prop(ctxmap, "opname").nil?
34
34
  @ctrl = basectx.ctrl
35
35
  end
36
36
 
@@ -4,8 +4,8 @@
4
4
  # stamps page/limit (or a cursor) into the request query; on the way back
5
5
  # (PreResult) it reads the server's pagination signals - a Link rel="next"
6
6
  # header, X-Page/X-Next-Page/X-Total-Count headers, or next/cursor/
7
- # nextCursor/hasMore fields in the body - and records them on
8
- # result.paging. A per-call ctrl paging value (page or cursor) takes
7
+ # nextCursor/hasMore fields in the body (snake_case forms too) - and records
8
+ # them on result.paging and the caller's ctrl paging. A per-call ctrl paging value (page or cursor) takes
9
9
  # priority. Parameter names ("pageParam", "limitParam", "cursorParam"),
10
10
  # "startPage" (default 1) and page size ("limit") are configurable.
11
11
 
@@ -56,8 +56,10 @@ class ProjectNamePagingFeature < ProjectNameBaseFeature
56
56
  if !paging["cursor"].nil?
57
57
  spec.query[cursor_param] = paging["cursor"]
58
58
  elsif spec.query[page_param].nil?
59
- spec.query[page_param] =
60
- paging["page"].nil? ? (@options["startPage"] || 1) : paging["page"]
59
+ # A record written back by PreResult holds the page just fetched as
60
+ # "page" and the one to fetch as "nextPage", so nextPage wins.
61
+ page = paging["nextPage"].nil? ? paging["page"] : paging["nextPage"]
62
+ spec.query[page_param] = page.nil? ? (@options["startPage"] || 1) : page
61
63
  end
62
64
 
63
65
  if !@options["limit"].nil? && spec.query[limit_param].nil?
@@ -158,11 +160,20 @@ class ProjectNamePagingFeature < ProjectNameBaseFeature
158
160
  paging["next"] = m[1] if m
159
161
  end
160
162
 
161
- # Body-level cursors.
163
+ # Body-level signals; snake_case forms are read first so camelCase wins.
162
164
  if body.is_a?(Hash)
163
165
  paging["next"] = paging["next"] || body["next"] unless body["next"].nil?
166
+ paging["cursor"] = body["next_cursor"] unless body["next_cursor"].nil?
164
167
  paging["cursor"] = body["cursor"] unless body["cursor"].nil?
165
168
  paging["cursor"] = body["nextCursor"] unless body["nextCursor"].nil?
169
+ if paging["nextPage"].nil?
170
+ np = body["nextPage"].nil? ? body["next_page"] : body["nextPage"]
171
+ paging["nextPage"] = np if np.is_a?(Numeric) || np.is_a?(String)
172
+ end
173
+ if body["has_more"] == true || body["has_more"] == false
174
+ paging["hasMore"] = body["has_more"]
175
+ explicit_more = true
176
+ end
166
177
  if body["hasMore"] == true || body["hasMore"] == false
167
178
  paging["hasMore"] = body["hasMore"]
168
179
  explicit_more = true
@@ -181,6 +192,17 @@ class ProjectNamePagingFeature < ProjectNameBaseFeature
181
192
 
182
193
  result.paging = paging
183
194
 
195
+ if ctx.ctrl && ctx.ctrl.respond_to?(:paging=)
196
+ # The Context shares the caller's paging hash by reference, so refill
197
+ # it in place; a fresh hash on ctrl never reaches the caller.
198
+ if ctx.ctrl.paging.is_a?(Hash)
199
+ ctx.ctrl.paging.clear
200
+ ctx.ctrl.paging.merge!(paging)
201
+ else
202
+ ctx.ctrl.paging = paging
203
+ end
204
+ end
205
+
184
206
  @client.instance_variable_set(:@_paging, { "last" => paging })
185
207
  end
186
208
 
@@ -127,12 +127,10 @@ class ProjectNameTestFeature < ProjectNameBaseFeature
127
127
  args = test_self.build_args(fctx, op, update_match)
128
128
  found = VoxgigStruct.select(entmap, args)
129
129
  ent = VoxgigStruct.getelem(found, 0)
130
- if ent.nil? && entmap.is_a?(Hash) && !entmap.empty?
131
- ent = entmap.values.find { |e| e.is_a?(Hash) }
132
- end
130
+ # update miss: 404, never another record
133
131
  return respond.call(404, nil, { "statusText" => "Not found" }) unless ent
134
- if ent.is_a?(Hash) && fctx.reqdata
135
- fctx.reqdata.each { |k, v| ent[k] = v }
132
+ if ent.is_a?(Hash) && fctx.reqdata.is_a?(Hash)
133
+ VoxgigStruct.merge([ent, fctx.reqdata])
136
134
  end
137
135
  VoxgigStruct.delprop(ent, "$KEY")
138
136
  out = VoxgigStruct.clone(ent)
@@ -237,14 +235,42 @@ class ProjectNameTestFeature < ProjectNameBaseFeature
237
235
  }
238
236
  end
239
237
 
238
+ # The entity's own endpoint: a terminal `{param}` marks a record route,
239
+ # and among equals the shallower path wins (the same rule as make_point).
240
+ def pick_point(points)
241
+ return nil unless points.is_a?(Array) && !points.empty?
242
+ terminal = ->(p) {
243
+ parts = VoxgigStruct.getprop(p, "parts")
244
+ parts.is_a?(Array) && !parts.empty? && parts[-1].is_a?(String) && parts[-1].start_with?("{")
245
+ }
246
+ depth = ->(p) {
247
+ parts = VoxgigStruct.getprop(p, "parts")
248
+ parts.is_a?(Array) ? parts.length : 0
249
+ }
250
+ point = points[0]
251
+ points[1..].each do |cand|
252
+ if terminal.call(cand) != terminal.call(point)
253
+ point = cand if terminal.call(cand)
254
+ elsif depth.call(cand) < depth.call(point)
255
+ point = cand
256
+ end
257
+ end
258
+ point
259
+ end
260
+
240
261
  def build_args(ctx, op, args)
241
262
  opname = op.name
242
263
  points = VoxgigStruct.getpath(ctx.config, "entity.#{ctx.entity.get_name}.op.#{opname}.points")
243
- point = VoxgigStruct.getelem(points, -1)
264
+ point = pick_point(points)
244
265
 
266
+ # Path AND query: a path-only read misses a query-addressed record
267
+ # (e.g. GET /result?trace_id=), which has no path param at all.
245
268
  params_path = VoxgigStruct.getpath(point, "args.params")
246
269
  reqd_params = VoxgigStruct.select(params_path, { "reqd" => true })
247
- reqd = VoxgigStruct.transform(reqd_params, ["`$EACH`", "", "`$KEY.name`"])
270
+ query_path = VoxgigStruct.getpath(point, "args.query")
271
+ reqd_query = VoxgigStruct.select(query_path, { "reqd" => true })
272
+ reqd = (VoxgigStruct.transform(reqd_params, ["`$EACH`", "", "`$KEY.name`"]) || []) +
273
+ (VoxgigStruct.transform(reqd_query, ["`$EACH`", "", "`$KEY.name`"]) || [])
248
274
 
249
275
  qand = []
250
276
  q = { "`$AND`" => qand }
@@ -200,6 +200,13 @@ class FeatureCorpusTest < Minitest::Test
200
200
  assert_equal expect, actual, path
201
201
  end
202
202
 
203
+ # Whether the SDK built the feature at all. The activity record is the
204
+ # wrong probe: most features create theirs on first use, so an idle
205
+ # client has none and every section but the eager ones read as inert.
206
+ def present?(client, name)
207
+ (client.features || []).any? { |f| f.respond_to?(:name) && f.name == name }
208
+ end
209
+
203
210
  def record(client, name)
204
211
  # A corpus key is any manifest item name, so it may carry characters
205
212
  # (foo-bar, foo.2) that cannot form a Ruby instance-variable name. The
@@ -246,7 +253,11 @@ class FeatureCorpusTest < Minitest::Test
246
253
  # Probed by ACTIVATING it: the feature defaults to inactive, so an idle
247
254
  # client never builds it and its absence says nothing.
248
255
  probe = build_client({ "feature" => [{ "name" => name, "active" => true }] })
249
- next if record(probe, name).nil?
256
+ unless present?(probe, name)
257
+ # The one line every runner prints for an inert section.
258
+ puts "feature.#{name}: inert (this SDK does not generate the feature)"
259
+ next
260
+ end
250
261
 
251
262
  ops = usable_ops(2)
252
263
  by_key = ops.each_with_object({}) { |o, h| h[o["key"]] = o }
@@ -911,6 +911,44 @@ class FeatureTest < Minitest::Test
911
911
  assert_equal true, res["result"].paging["hasMore"]
912
912
  end
913
913
 
914
+ def test_paging_snake_case_signals_and_ctrl_write_back
915
+ skip_unless_feature("paging")
916
+ server, calls = H.recording_server { |n, _fd|
917
+ body = 1 == n ? { "has_more" => true, "next_cursor" => "c2" } : { "has_more" => false }
918
+ [H.make_response(200, body), nil]
919
+ }
920
+ h = harness([fspec("paging")], server: server)
921
+ pg = {}
922
+ ctrl = { "paging" => pg }
923
+ res = h.op(opname: "list", path: "/w", ctrl: ctrl)
924
+ assert_equal true, res["result"].paging["hasMore"]
925
+ assert_equal "c2", res["result"].paging["cursor"]
926
+ assert_equal "c2", pg["cursor"], "record written back into ctrl"
927
+ assert_equal true, pg["hasMore"]
928
+ h.op(opname: "list", path: "/w", ctrl: ctrl)
929
+ assert_match(/[?&]cursor=c2(&|\z)/, calls[1]["url"])
930
+ assert_equal false, pg["hasMore"]
931
+ assert_nil pg["cursor"]
932
+ end
933
+
934
+ def test_paging_continues_from_written_back_next_page
935
+ skip_unless_feature("paging")
936
+ server, calls = H.recording_server { |n, _fd|
937
+ body = 1 == n ? { "next_page" => 2 } : {}
938
+ [H.make_response(200, body, "x-page" => n.to_s), nil]
939
+ }
940
+ h = harness([fspec("paging")], server: server)
941
+ pg = {}
942
+ ctrl = { "paging" => pg }
943
+ h.op(opname: "list", path: "/w", ctrl: ctrl)
944
+ assert_equal 1, pg["page"]
945
+ assert_equal 2, pg["nextPage"]
946
+ assert_equal true, pg["hasMore"]
947
+ h.op(opname: "list", path: "/w", ctrl: ctrl)
948
+ assert_match(/[?&]page=2(&|\z)/, calls[1]["url"])
949
+ assert_equal false, pg["hasMore"]
950
+ end
951
+
914
952
  def test_paging_non_list_op_is_not_paged
915
953
  skip_unless_feature("paging")
916
954
  server, calls = H.recording_server
@@ -5,6 +5,42 @@ require 'uri'
5
5
  require 'json'
6
6
 
7
7
  module ProjectNameUtilities
8
+ # Started Net::HTTP connections keyed by scheme, host, port and proxy,
9
+ # checked out for one request and returned. A Net::HTTP serves one
10
+ # request at a time, so the pool holds idle ones rather than sharing one.
11
+ HTTP_POOL = Hash.new { |h, k| h[k] = [] }
12
+ HTTP_POOL_LOCK = Mutex.new
13
+ HTTP_POOL_IDLE_MAX = 4
14
+
15
+ def self.http_checkout(key, uri, proxy)
16
+ http = HTTP_POOL_LOCK.synchronize { HTTP_POOL[key].pop }
17
+ return http if http && http.started?
18
+
19
+ if proxy
20
+ purl = URI.parse(proxy)
21
+ http = Net::HTTP.new(uri.host, uri.port, purl.host, purl.port, purl.user, purl.password)
22
+ else
23
+ http = Net::HTTP.new(uri.host, uri.port)
24
+ end
25
+ http.use_ssl = (uri.scheme == "https")
26
+ http.start
27
+ end
28
+
29
+ def self.http_checkin(key, http)
30
+ kept = HTTP_POOL_LOCK.synchronize do
31
+ HTTP_POOL[key].size < HTTP_POOL_IDLE_MAX && HTTP_POOL[key].push(http)
32
+ end
33
+ http.finish unless kept
34
+ rescue IOError
35
+ nil
36
+ end
37
+
38
+ def self.http_discard(http)
39
+ http.finish if http.started?
40
+ rescue IOError
41
+ nil
42
+ end
43
+
8
44
  DefaultHttpFetch = ->(fullurl, fetchdef) {
9
45
  method_str = fetchdef["method"] || "GET"
10
46
  body_str = fetchdef["body"]
@@ -12,8 +48,9 @@ module ProjectNameUtilities
12
48
 
13
49
  begin
14
50
  uri = URI.parse(fullurl)
15
- http = Net::HTTP.new(uri.host, uri.port)
16
- http.use_ssl = (uri.scheme == "https")
51
+ proxy = fetchdef["proxy"]
52
+ proxy = nil unless proxy.is_a?(String) && !proxy.empty?
53
+ key = [uri.scheme, uri.host, uri.port, proxy]
17
54
 
18
55
  klass = case method_str.upcase
19
56
  when "POST" then Net::HTTP::Post
@@ -35,7 +72,16 @@ module ProjectNameUtilities
35
72
  request['User-Agent'] = 'Mozilla/5.0 (compatible; ProjectNameSDK/1.0)' unless has_ua
36
73
  request.body = body_str if body_str.is_a?(String)
37
74
 
38
- resp = http.request(request)
75
+ http = ProjectNameUtilities.http_checkout(key, uri, proxy)
76
+ begin
77
+ # Net::HTTP retries idempotent requests; an EOF can follow a committed POST.
78
+ resp = http.request(request)
79
+ rescue StandardError
80
+ ProjectNameUtilities.http_discard(http)
81
+ raise
82
+ end
83
+ ProjectNameUtilities.http_checkin(key, http)
84
+
39
85
  resp_headers = {}
40
86
  resp.each_header { |k, v| resp_headers[k.downcase] = v }
41
87
 
@@ -14,7 +14,7 @@ module ProjectNameUtilities
14
14
  if items
15
15
  items.each do |item|
16
16
  key, val = item[0], item[1]
17
- out[key] = val if val && key.is_a?(String) && !params.include?(key)
17
+ out[key] = val if val && key.is_a?(String) && key != "$action" && !params.include?(key)
18
18
  end
19
19
  end
20
20
  out
@@ -2,14 +2,21 @@
2
2
  require_relative 'struct/voxgig_struct'
3
3
  require_relative '../core/helpers'
4
4
  module ProjectNameUtilities
5
+ # `$action` selects the point (see MakePoint); it is never an API field, so
6
+ # the body is a copy without it. The caller's hash is left untouched.
7
+ def self.strip_action(reqdata)
8
+ return reqdata unless reqdata.is_a?(Hash) && reqdata.key?("$action")
9
+ reqdata.reject { |k, _| k == "$action" }
10
+ end
11
+
5
12
  TransformRequest = ->(ctx) {
6
13
  spec = ctx.spec
7
14
  point = ctx.point
8
15
  spec.step = "reqform" if spec
9
16
  transform = ProjectNameHelpers.to_map(VoxgigStruct.getprop(point, "transform"))
10
- return ctx.reqdata unless transform
17
+ return ProjectNameUtilities.strip_action(ctx.reqdata) unless transform
11
18
  reqform = VoxgigStruct.getprop(transform, "req")
12
- return ctx.reqdata unless reqform
13
- VoxgigStruct.transform({ "reqdata" => ctx.reqdata }, reqform)
19
+ return ProjectNameUtilities.strip_action(ctx.reqdata) unless reqform
20
+ ProjectNameUtilities.strip_action(VoxgigStruct.transform({ "reqdata" => ctx.reqdata }, reqform))
14
21
  }
15
22
  end
@@ -1,4 +1,3 @@
1
-
2
1
  use std::cell::RefCell;
3
2
  use std::collections::HashMap;
4
3
  use std::rc::Rc;
@@ -101,7 +100,7 @@ impl Context {
101
100
  Rc::new(RefCell::new(c))
102
101
  } else if let Some(co) = ctxspec.ctrl_obj {
103
102
  co
104
- } else if let Some(b) = basectx {
103
+ } else if let Some(b) = basectx.filter(|_| ctxspec.opname.is_none()) {
105
104
  b.ctrl.borrow().clone()
106
105
  } else {
107
106
  Rc::new(RefCell::new(Control::new()))
@@ -249,10 +248,7 @@ impl Context {
249
248
  return Rc::new(Operation::new(&Value::empty_map()));
250
249
  }
251
250
 
252
- let opcfg = getpath(
253
- &["entity", &entname, "op", opname],
254
- &self.config.borrow(),
255
- );
251
+ let opcfg = getpath(&["entity", &entname, "op", opname], &self.config.borrow());
256
252
 
257
253
  let input = if opname == "update" || opname == "create" {
258
254
  "data"
@@ -7,6 +7,7 @@ use crate::core::spec::Spec;
7
7
  use crate::core::helpers::{get_bool, getp, getpath, setp};
8
8
  use crate::core::types::Feature;
9
9
  use crate::feature::support::*;
10
+ use crate::utility::voxgigstruct::ordered_map::OrderedMap;
10
11
  use crate::utility::voxgigstruct::Value;
11
12
 
12
13
  pub struct PagingFeature {
@@ -193,7 +194,12 @@ impl Feature for PagingFeature {
193
194
  if !cursor.is_noval() && !cursor.is_null() {
194
195
  setp(&query, &cursor_param, cursor);
195
196
  } else if getp(&query, &page_param).is_noval() {
196
- let page = getp(&paging, "page");
197
+ // A record written back by pre_result holds the page just fetched
198
+ // as "page" and the one to fetch as "nextPage", so nextPage wins.
199
+ let mut page = getp(&paging, "nextPage");
200
+ if page.is_noval() || page.is_null() {
201
+ page = getp(&paging, "page");
202
+ }
197
203
  if !page.is_noval() && !page.is_null() {
198
204
  setp(&query, &page_param, page);
199
205
  } else {
@@ -281,12 +287,16 @@ impl Feature for PagingFeature {
281
287
  }
282
288
  }
283
289
 
284
- // Body-level cursors.
290
+ // Body-level signals; snake_case forms are read first so camelCase wins.
285
291
  if let Value::Map(_) = body {
286
292
  let next = getp(&body, "next");
287
293
  if !next.is_noval() && !next.is_null() && getp(&paging, "next").is_noval() {
288
294
  setp(&paging, "next", next);
289
295
  }
296
+ let snake_cursor = getp(&body, "next_cursor");
297
+ if !snake_cursor.is_noval() && !snake_cursor.is_null() {
298
+ setp(&paging, "cursor", snake_cursor);
299
+ }
290
300
  let cursor = getp(&body, "cursor");
291
301
  if !cursor.is_noval() && !cursor.is_null() {
292
302
  setp(&paging, "cursor", cursor);
@@ -295,6 +305,19 @@ impl Feature for PagingFeature {
295
305
  if !next_cursor.is_noval() && !next_cursor.is_null() {
296
306
  setp(&paging, "cursor", next_cursor);
297
307
  }
308
+ if getp(&paging, "nextPage").is_noval() {
309
+ let mut np = getp(&body, "nextPage");
310
+ if np.is_noval() || np.is_null() {
311
+ np = getp(&body, "next_page");
312
+ }
313
+ if matches!(np, Value::Num(_) | Value::Str(_)) {
314
+ setp(&paging, "nextPage", np);
315
+ }
316
+ }
317
+ if let Some(has_more) = get_bool(&body, "has_more") {
318
+ setp(&paging, "hasMore", Value::Bool(has_more));
319
+ explicit_more = true;
320
+ }
298
321
  if let Some(has_more) = get_bool(&body, "hasMore") {
299
322
  setp(&paging, "hasMore", Value::Bool(has_more));
300
323
  explicit_more = true;
@@ -316,6 +339,24 @@ impl Feature for PagingFeature {
316
339
  }
317
340
 
318
341
  result.borrow_mut().paging = paging.clone();
319
- self.last = paging;
342
+ self.last = paging.clone();
343
+
344
+ // Value::Map is Rc-shared, so the caller's map is refilled in place;
345
+ // a fresh map on ctrl never reaches the caller.
346
+ let ctrl = ctx.ctrl.borrow().clone();
347
+ let shared = ctrl.borrow().paging.clone();
348
+ if let Value::Map(m) = &shared {
349
+ let entries: Vec<(String, Value)> = match &paging {
350
+ Value::Map(pm) => pm.borrow().iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
351
+ _ => Vec::new(),
352
+ };
353
+ let mut mm = m.borrow_mut();
354
+ *mm = OrderedMap::new();
355
+ for (k, v) in entries {
356
+ mm.insert(k, v);
357
+ }
358
+ } else {
359
+ ctrl.borrow_mut().paging = paging;
360
+ }
320
361
  }
321
362
  }
@@ -754,7 +754,7 @@ fn raw_exchange_fetch(fullurl: &str, fetchdef: &Value) -> Result<Value, ProjectN
754
754
  .filter(|m| !m.is_empty())
755
755
  .unwrap_or_else(|| "POST".to_string());
756
756
 
757
- let agent = ureq::AgentBuilder::new().build();
757
+ let agent = crate::utility::fetcher::default_agent();
758
758
  let mut req = agent.request(&method, fullurl);
759
759
 
760
760
  if let Value::Map(m) = getp(fetchdef, "headers") {
@@ -80,6 +80,61 @@ fn resolve_match(ctx: &Rc<Context>, explicit: &Value) -> Value {
80
80
  Value::empty_map()
81
81
  }
82
82
 
83
+ fn point_terminal(p: &Value) -> bool {
84
+ match getp(p, "parts") {
85
+ Value::List(l) => match l.borrow().last() {
86
+ Some(Value::Str(s)) => s.starts_with('{'),
87
+ _ => false,
88
+ },
89
+ _ => false,
90
+ }
91
+ }
92
+
93
+ fn point_depth(p: &Value) -> usize {
94
+ match getp(p, "parts") {
95
+ Value::List(l) => l.borrow().len(),
96
+ _ => 0,
97
+ }
98
+ }
99
+
100
+ // The entity's own endpoint: a terminal `{param}` marks a record route, and
101
+ // among equals the shallower path wins (the same rule as make_point).
102
+ fn pick_point(points: &Value) -> Value {
103
+ let list: Vec<Value> = match points {
104
+ Value::List(l) => l.borrow().clone(),
105
+ _ => return Value::Noval,
106
+ };
107
+ let mut point = match list.first() {
108
+ Some(p) => p.clone(),
109
+ None => return Value::Noval,
110
+ };
111
+ for cand in list.iter().skip(1) {
112
+ if point_terminal(cand) != point_terminal(&point) {
113
+ if point_terminal(cand) {
114
+ point = cand.clone();
115
+ }
116
+ } else if point_depth(cand) < point_depth(&point) {
117
+ point = cand.clone();
118
+ }
119
+ }
120
+ point
121
+ }
122
+
123
+ fn reqd_names(point: &Value, kind: &str) -> Value {
124
+ let args_path = crate::core::helpers::getpath(&["args", kind], point);
125
+ let reqd_args = vs::select(&args_path, &jo(vec![("reqd", Value::Bool(true))]));
126
+ vs::transform(
127
+ &reqd_args,
128
+ &ja(vec![
129
+ Value::str("`$EACH`"),
130
+ Value::str(""),
131
+ Value::str("`$KEY.name`"),
132
+ ]),
133
+ None,
134
+ )
135
+ .unwrap_or_else(|_| Value::empty_list())
136
+ }
137
+
83
138
  fn build_args(ctx: &Rc<Context>, args: &Value) -> Value {
84
139
  let op = ctx.op.borrow().clone();
85
140
  let opname = op.name.clone();
@@ -88,26 +143,16 @@ fn build_args(ctx: &Rc<Context>, args: &Value) -> Value {
88
143
  None => op.entity.clone(),
89
144
  };
90
145
 
91
- // Get last point from config.
92
146
  let points = crate::core::helpers::getpath(
93
147
  &["entity", &entname, "op", &opname, "points"],
94
148
  &ctx.config.borrow(),
95
149
  );
96
- let point = vs::get_elem(&points, &Value::Num(-1.0), Value::Noval);
150
+ let point = pick_point(&points);
97
151
 
98
- // Get required params.
99
- let params_path = crate::core::helpers::getpath(&["args", "params"], &point);
100
- let reqd_params = vs::select(&params_path, &jo(vec![("reqd", Value::Bool(true))]));
101
- let reqd = vs::transform(
102
- &reqd_params,
103
- &ja(vec![
104
- Value::str("`$EACH`"),
105
- Value::str(""),
106
- Value::str("`$KEY.name`"),
107
- ]),
108
- None,
109
- )
110
- .unwrap_or_else(|_| Value::empty_list());
152
+ // Path AND query: a path-only read misses a query-addressed record
153
+ // (e.g. GET /result?trace_id=), which has no path param at all.
154
+ let reqd_params = reqd_names(&point, "params");
155
+ let reqd_query = reqd_names(&point, "query");
111
156
 
112
157
  let qand = Value::empty_list();
113
158
  let q = jo(vec![("`$AND`", qand.clone())]);
@@ -116,8 +161,8 @@ fn build_args(ctx: &Rc<Context>, args: &Value) -> Value {
116
161
  let keys = vs::keysof_vec(args);
117
162
  for key in keys {
118
163
  let is_id = key == "id";
119
- let selected = vs::select(&reqd, &Value::str(key.clone()));
120
- let is_reqd = !vs::is_empty(&selected);
164
+ let is_reqd = !vs::is_empty(&vs::select(&reqd_params, &Value::str(key.clone())))
165
+ || !vs::is_empty(&vs::select(&reqd_query, &Value::str(key.clone())));
121
166
 
122
167
  if is_id || is_reqd {
123
168
  let v = ctx.util().param(ctx, &Value::str(key.clone()));
@@ -215,28 +260,16 @@ fn test_fetch(
215
260
  };
216
261
  let args = build_args(ctx, &update_match);
217
262
  let found = vs::select(&entmap, &args);
218
- let mut ent = vs::get_elem(&found, &Value::Num(0.0), Value::Noval);
219
- if (ent.is_noval() || ent.is_null()) && vs::size(&entmap) > 0 {
220
- // Fall back to any entity in the fixture.
221
- for (_k, e) in vs::items_vec(&entmap) {
222
- if let Value::Map(_) = e {
223
- ent = e;
224
- break;
225
- }
226
- }
227
- }
263
+ let ent = vs::get_elem(&found, &Value::Num(0.0), Value::Noval);
228
264
  if ent.is_noval() || ent.is_null() {
265
+ // update miss: 404, never another record
229
266
  return Ok(respond(ctx, 404,
230
267
  Value::Noval,
231
268
  vec![("statusText", Value::str("Not found"))],
232
269
  ));
233
270
  }
234
- if let Value::Map(_) = &ent {
235
- if let Value::Map(rm) = &reqdata {
236
- for (k, v) in rm.borrow().iter() {
237
- setp(&ent, k, v.clone());
238
- }
239
- }
271
+ if let (Value::Map(_), Value::Map(_)) = (&ent, &reqdata) {
272
+ vs::merge(&Value::list(vec![ent.clone(), reqdata.clone()]), None);
240
273
  }
241
274
  vs::del_prop(ent.clone(), &Value::str("$KEY"));
242
275
  Ok(respond(ctx, 200, vs::clone(&ent), vec![]))