@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
@@ -164,24 +164,12 @@ func (f *TestFeature) Init(ctx *core.Context, options map[string]any) {
164
164
  args := self.buildArgs(ctx, op, updateMatch)
165
165
  found := vs.Select(entmap, args)
166
166
  ent := vs.GetElem(found, 0)
167
- if ent == nil && entmap != nil {
168
- for _, e := range entmap {
169
- if _, ok := e.(map[string]any); ok {
170
- ent = e
171
- break
172
- }
173
- }
174
- }
175
167
  if ent == nil {
168
+ // update miss: 404, never another record
176
169
  return respond(404, nil, map[string]any{"statusText": "Not found"}), nil
177
170
  }
178
- if entm, ok := ent.(map[string]any); ok {
179
- reqdata := ctx.Reqdata
180
- if reqdata != nil {
181
- for k, v := range reqdata {
182
- entm[k] = v
183
- }
184
- }
171
+ if _, ok := ent.(map[string]any); ok && ctx.Reqdata != nil {
172
+ vs.Merge([]any{ent, ctx.Reqdata})
185
173
  }
186
174
  vs.DelProp(ent, "$KEY")
187
175
  out := vs.Clone(ent)
@@ -428,6 +428,7 @@ func TestFeatureCorpus(t *testing.T) {
428
428
  "feature": []any{map[string]any{"name": name, "active": true}},
429
429
  })
430
430
  if fcRecord(probe, name) == nil {
431
+ t.Logf("feature.%s: inert (this SDK does not generate the feature)", name)
431
432
  t.Skipf("this SDK was generated without the %s feature", name)
432
433
  }
433
434
 
@@ -5,6 +5,7 @@ import (
5
5
  "os"
6
6
  "reflect"
7
7
  "regexp"
8
+ "strconv"
8
9
  "strings"
9
10
  "testing"
10
11
  "time"
@@ -1042,6 +1043,59 @@ func TestFeaturePaging(t *testing.T) {
1042
1043
  }
1043
1044
  })
1044
1045
 
1046
+ t.Run("snake-case-signals-and-ctrl-write-back", func(t *testing.T) {
1047
+ rec := &fhRecorder{reply: func(n int, _ map[string]any) (any, error) {
1048
+ if n == 1 {
1049
+ return fhResponse(200, map[string]any{"has_more": true, "next_cursor": "c2"}, nil), nil
1050
+ }
1051
+ return fhResponse(200, map[string]any{"has_more": false}, nil), nil
1052
+ }}
1053
+ h := fhMake(rec.fetch, fhF(feat.NewPagingFeature(), nil))
1054
+ pg := map[string]any{}
1055
+ ctrl := map[string]any{"paging": pg}
1056
+ res := h.op(fhOpSpec{op: "list", path: "/w", ctrl: ctrl})
1057
+ if res.result.Paging["hasMore"] != true {
1058
+ t.Errorf("expected has_more read, got %v", res.result.Paging["hasMore"])
1059
+ }
1060
+ if res.result.Paging["cursor"] != "c2" {
1061
+ t.Errorf("expected next_cursor read, got %v", res.result.Paging["cursor"])
1062
+ }
1063
+ if pg["cursor"] != "c2" || pg["hasMore"] != true {
1064
+ t.Errorf("expected record written back into ctrl, got %v", pg)
1065
+ }
1066
+ h.op(fhOpSpec{op: "list", path: "/w", ctrl: ctrl})
1067
+ if !strings.Contains(rec.url(1), "cursor=c2") {
1068
+ t.Errorf("expected written-back cursor sent, got %s", rec.url(1))
1069
+ }
1070
+ if pg["hasMore"] != false || pg["cursor"] != nil {
1071
+ t.Errorf("expected ctrl to carry the last record, got %v", pg)
1072
+ }
1073
+ })
1074
+
1075
+ t.Run("continues-from-written-back-next-page", func(t *testing.T) {
1076
+ rec := &fhRecorder{reply: func(n int, _ map[string]any) (any, error) {
1077
+ body := map[string]any{}
1078
+ if n == 1 {
1079
+ body["next_page"] = 2
1080
+ }
1081
+ return fhResponse(200, body, map[string]any{"x-page": strconv.Itoa(n)}), nil
1082
+ }}
1083
+ h := fhMake(rec.fetch, fhF(feat.NewPagingFeature(), nil))
1084
+ pg := map[string]any{}
1085
+ ctrl := map[string]any{"paging": pg}
1086
+ h.op(fhOpSpec{op: "list", path: "/w", ctrl: ctrl})
1087
+ if pg["page"] != 1 || pg["nextPage"] != 2 || pg["hasMore"] != true {
1088
+ t.Errorf("expected next_page read and written back, got %v", pg)
1089
+ }
1090
+ h.op(fhOpSpec{op: "list", path: "/w", ctrl: ctrl})
1091
+ if !strings.Contains(rec.url(1), "page=2") {
1092
+ t.Errorf("expected written-back next page sent, got %s", rec.url(1))
1093
+ }
1094
+ if pg["hasMore"] != false {
1095
+ t.Errorf("expected no more pages, got %v", pg)
1096
+ }
1097
+ })
1098
+
1045
1099
  t.Run("non-list-not-paged", func(t *testing.T) {
1046
1100
  rec := &fhRecorder{}
1047
1101
  h := fhMake(rec.fetch, fhF(feat.NewPagingFeature(), nil))
@@ -6,12 +6,52 @@ import (
6
6
  "net/http"
7
7
  "net/url"
8
8
  "strings"
9
+ "sync"
9
10
 
10
11
  vs "github.com/voxgig/struct"
11
12
 
12
13
  "GOMODULE/core"
13
14
  )
14
15
 
16
+ // Proxied clients keyed by proxy URL. A client per request would open a
17
+ // fresh connection every call; a cached one pools like http.DefaultClient.
18
+ var proxyClients sync.Map
19
+
20
+ func clientFor(fetchdef map[string]any) *http.Client {
21
+ client := http.DefaultClient
22
+
23
+ if proxy, ok := fetchdef["proxy"].(string); ok && proxy != "" {
24
+ if cached, ok := proxyClients.Load(proxy); ok {
25
+ client = cached.(*http.Client)
26
+ } else if proxyURL, perr := url.Parse(proxy); perr == nil {
27
+ // http.DefaultTransport is a package variable the host program can
28
+ // replace, and an unchecked assertion on it panics the SDK inside
29
+ // whatever called it. Clone the real one where it is there, and
30
+ // build a plain transport where it is not.
31
+ var transport *http.Transport
32
+ if def, ok := http.DefaultTransport.(*http.Transport); ok {
33
+ transport = def.Clone()
34
+ } else {
35
+ transport = &http.Transport{}
36
+ }
37
+ transport.Proxy = http.ProxyURL(proxyURL)
38
+ cached, _ := proxyClients.LoadOrStore(proxy, &http.Client{Transport: transport})
39
+ client = cached.(*http.Client)
40
+ }
41
+ }
42
+
43
+ // A shallow copy shares the pooled Transport; only CheckRedirect differs.
44
+ if redirect, ok := fetchdef["redirect"].(string); ok && redirect == "manual" {
45
+ manual := *client
46
+ manual.CheckRedirect = func(req *http.Request, via []*http.Request) error {
47
+ return http.ErrUseLastResponse
48
+ }
49
+ client = &manual
50
+ }
51
+
52
+ return client
53
+ }
54
+
15
55
  func defaultHTTPFetch(fullurl string, fetchdef map[string]any) (map[string]any, error) {
16
56
  method, _ := fetchdef["method"].(string)
17
57
  if method == "" {
@@ -46,26 +86,7 @@ func defaultHTTPFetch(fullurl string, fetchdef map[string]any) (map[string]any,
46
86
  req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; ProjectNameSDK/1.0)")
47
87
  }
48
88
 
49
- // Honour a proxy annotation on the fetch definition (set by the proxy
50
- // feature): route the request through an http.Transport with Proxy set.
51
- client := http.DefaultClient
52
- if proxy, ok := fetchdef["proxy"].(string); ok && proxy != "" {
53
- if proxyURL, perr := url.Parse(proxy); perr == nil {
54
- client = &http.Client{
55
- Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)},
56
- }
57
- }
58
- }
59
-
60
- if redirect, ok := fetchdef["redirect"].(string); ok && redirect == "manual" {
61
- manual := *client
62
- manual.CheckRedirect = func(req *http.Request, via []*http.Request) error {
63
- return http.ErrUseLastResponse
64
- }
65
- client = &manual
66
- }
67
-
68
- resp, err := client.Do(req)
89
+ resp, err := clientFor(fetchdef).Do(req)
69
90
  if err != nil {
70
91
  return nil, err
71
92
  }
@@ -29,7 +29,7 @@ func prepareQueryUtil(ctx *core.Context) map[string]any {
29
29
  for _, item := range vs.Items(reqmatch) {
30
30
  key, _ := item[0].(string)
31
31
  val := item[1]
32
- if val != nil && !containsStr(params, key) {
32
+ if val != nil && key != "$action" && !containsStr(params, key) {
33
33
  out[key] = val
34
34
  }
35
35
  }
@@ -16,12 +16,12 @@ func transformRequestUtil(ctx *core.Context) any {
16
16
 
17
17
  transform := core.ToMapAny(vs.GetProp(point, "transform"))
18
18
  if transform == nil {
19
- return ctx.Reqdata
19
+ return stripAction(ctx.Reqdata)
20
20
  }
21
21
 
22
22
  reqform := vs.GetProp(transform, "req")
23
23
  if reqform == nil {
24
- return ctx.Reqdata
24
+ return stripAction(ctx.Reqdata)
25
25
  }
26
26
 
27
27
  reqdata, terr := vs.Transform(map[string]any{
@@ -36,5 +36,24 @@ func transformRequestUtil(ctx *core.Context) any {
36
36
  return terr
37
37
  }
38
38
 
39
- return reqdata
39
+ return stripAction(reqdata)
40
+ }
41
+
42
+ // `$action` selects the point (see makePointUtil); it is never an API field,
43
+ // so the body is a copy without it. The caller's map is left untouched.
44
+ func stripAction(reqdata any) any {
45
+ src, ok := reqdata.(map[string]any)
46
+ if !ok {
47
+ return reqdata
48
+ }
49
+ if _, has := src["$action"]; !has {
50
+ return reqdata
51
+ }
52
+ body := make(map[string]any, len(src))
53
+ for k, v := range src {
54
+ if k != "$action" {
55
+ body[k] = v
56
+ }
57
+ }
58
+ return body
40
59
  }
@@ -82,7 +82,8 @@ public class Context {
82
82
  this.ctrl = (Control) cv;
83
83
  }
84
84
  }
85
- else if (basectx != null && basectx.ctrl != null) {
85
+ else if (basectx != null && basectx.ctrl != null
86
+ && Helpers.getCtxProp(ctxmap, "opname") == null) {
86
87
  this.ctrl = basectx.ctrl;
87
88
  }
88
89
 
@@ -18,8 +18,8 @@ import JAVAPACKAGE.utility.struct.Struct;
18
18
  // stamps page/limit (or a cursor) into the request query; on the way back
19
19
  // (PreResult) it reads the server's pagination signals — a `Link:
20
20
  // rel="next"` header, `X-Page`/`X-Next-Page`/`X-Total-Count` headers, or
21
- // `next`/`cursor`/`nextCursor`/`hasMore` fields in the body — and records
22
- // them on `ctx.result.paging`. A per-call cursor/page from ctrl takes
21
+ // `next`/`cursor`/`nextCursor`/`hasMore` fields in the body (snake_case
22
+ // forms too) — and records them on `ctx.result.paging` and `ctrl.paging`. A per-call cursor/page from ctrl takes
23
23
  // priority (used by auto-iteration). Parameter names (`pageParam`,
24
24
  // `limitParam`, `cursorParam`), the page size (`limit`) and the start page
25
25
  // (`startPage`, default 1) are configurable.
@@ -82,7 +82,12 @@ public class PagingFeature extends BaseFeature {
82
82
  spec.query.put(cursorParam, cursor);
83
83
  }
84
84
  else if (spec.query.get(pageParam) == null) {
85
- Object page = paging.get("page");
85
+ // A record written back by preResult holds the page just fetched as
86
+ // "page" and the one to fetch as "nextPage", so nextPage wins.
87
+ Object page = paging.get("nextPage");
88
+ if (page == null) {
89
+ page = paging.get("page");
90
+ }
86
91
  if (page != null) {
87
92
  spec.query.put(pageParam, page);
88
93
  }
@@ -206,18 +211,37 @@ public class PagingFeature extends BaseFeature {
206
211
  }
207
212
  }
208
213
 
209
- // Body-level cursors.
214
+ // Body-level signals; snake_case forms are read first so camelCase wins.
210
215
  if (body instanceof Map) {
211
216
  Map<String, Object> bm = (Map<String, Object>) body;
212
217
  if (bm.get("next") != null && paging.get("next") == null) {
213
218
  paging.put("next", bm.get("next"));
214
219
  }
220
+ if (bm.get("next_cursor") != null) {
221
+ paging.put("cursor", bm.get("next_cursor"));
222
+ }
215
223
  if (bm.get("cursor") != null) {
216
224
  paging.put("cursor", bm.get("cursor"));
217
225
  }
218
226
  if (bm.get("nextCursor") != null) {
219
227
  paging.put("cursor", bm.get("nextCursor"));
220
228
  }
229
+ if (paging.get("nextPage") == null) {
230
+ Object np = bm.get("nextPage");
231
+ if (np == null) {
232
+ np = bm.get("next_page");
233
+ }
234
+ if (np instanceof Number) {
235
+ paging.put("nextPage", ((Number) np).intValue());
236
+ }
237
+ else if (np instanceof String) {
238
+ paging.put("nextPage", np);
239
+ }
240
+ }
241
+ if (bm.get("has_more") instanceof Boolean) {
242
+ paging.put("hasMore", bm.get("has_more"));
243
+ explicitMore = true;
244
+ }
221
245
  if (bm.get("hasMore") instanceof Boolean) {
222
246
  paging.put("hasMore", bm.get("hasMore"));
223
247
  explicitMore = true;
@@ -237,6 +261,24 @@ public class PagingFeature extends BaseFeature {
237
261
 
238
262
  result.paging = paging;
239
263
  this.last = paging;
264
+
265
+ if (ctx.ctrl != null) {
266
+ // The Context shares the caller's paging map by reference, so refill
267
+ // it in place; an immutable map falls back to replacing the reference.
268
+ Map<String, Object> shared = ctx.ctrl.paging;
269
+ if (shared == null) {
270
+ ctx.ctrl.paging = paging;
271
+ }
272
+ else {
273
+ try {
274
+ shared.clear();
275
+ shared.putAll(paging);
276
+ }
277
+ catch (UnsupportedOperationException e) {
278
+ ctx.ctrl.paging = paging;
279
+ }
280
+ }
281
+ }
240
282
  }
241
283
 
242
284
  private boolean isList(Context ctx) {
@@ -228,19 +228,12 @@ public class TestFeature extends BaseFeature {
228
228
  Object args = buildArgs(ctx, op, updateMatch);
229
229
  List<Object> found = Struct.select(entmap, args);
230
230
  Object ent = Struct.getelem(found, 0);
231
- if (ent == null && entmap != null) {
232
- for (Object e : entmap.values()) {
233
- if (e instanceof Map) {
234
- ent = e;
235
- break;
236
- }
237
- }
238
- }
239
231
  if (ent == null) {
232
+ // update miss: 404, never another record
240
233
  return respond(ctx, 404, null, extra("statusText", "Not found"));
241
234
  }
242
235
  if (ent instanceof Map && ctx.reqdata != null) {
243
- ((Map<String, Object>) ent).putAll(ctx.reqdata);
236
+ Struct.merge(Struct.jt(ent, ctx.reqdata));
244
237
  }
245
238
  Struct.delprop(ent, "$KEY");
246
239
  Object out = Struct.clone(ent);
@@ -374,10 +367,14 @@ public class TestFeature extends BaseFeature {
374
367
  }
375
368
  }
376
369
 
377
- // Get required params.
378
- Object paramsPath = Struct.getpath(point, List.of("args", "params"));
379
- Object reqdParams = Struct.select(paramsPath, Struct.jm("reqd", true));
380
- Object reqd = Struct.transform(reqdParams,
370
+ // Path AND query: a path-only read misses a query-addressed record
371
+ // (e.g. GET /result?trace_id=), which has no path param at all.
372
+ List<Object> reqdArgs = Struct.jt();
373
+ for (String kind : new String[] { "params", "query" }) {
374
+ Object argsPath = Struct.getpath(point, List.of("args", kind));
375
+ reqdArgs.addAll(Struct.select(argsPath, Struct.jm("reqd", true)));
376
+ }
377
+ Object reqd = Struct.transform(reqdArgs,
381
378
  Struct.jt("`$EACH`", "", "`$KEY.name`"));
382
379
 
383
380
  List<Object> qand = Struct.jt();
@@ -405,6 +405,9 @@ public class FeatureCorpusTest {
405
405
  ProjectNameSDK probe = buildClient(Map.of("feature",
406
406
  List.of(Map.of("name", name, "active", true))));
407
407
  if (null == record(probe, name)) {
408
+ // The one line every runner prints for an inert section.
409
+ System.err.println(String.format(
410
+ "feature.%s: inert (this SDK does not generate the feature)", name));
408
411
  continue;
409
412
  }
410
413
 
@@ -867,6 +867,48 @@ public class FeatureTest {
867
867
  assertEquals(true, res.result.paging.get("hasMore"), "expected hasMore");
868
868
  }
869
869
 
870
+ @Test
871
+ public void paging_snakeCaseSignalsAndCtrlWriteBack() {
872
+ assumeFeatures("paging");
873
+ FhRecorder rec = new FhRecorder();
874
+ rec.reply = (n, fetchdef) -> 1 == n
875
+ ? fhResponse(200, fhMap("has_more", true, "next_cursor", "c2"), null)
876
+ : fhResponse(200, fhMap("has_more", false), null);
877
+ FhHarness h = fhMake(rec::fetch, fhF(new PagingFeature(), null));
878
+ Map<String, Object> pg = new LinkedHashMap<>();
879
+ Map<String, Object> ctrl = fhMap("paging", pg);
880
+ FhOpResult res = h.op(fhOp("list").path("/w").ctrl(ctrl));
881
+ assertEquals(true, res.result.paging.get("hasMore"), "expected has_more read");
882
+ assertEquals("c2", res.result.paging.get("cursor"), "expected next_cursor read");
883
+ assertEquals("c2", pg.get("cursor"), "expected record written back into ctrl");
884
+ assertEquals(true, pg.get("hasMore"), "expected record written back into ctrl");
885
+ h.op(fhOp("list").path("/w").ctrl(ctrl));
886
+ assertTrue(rec.url(1).contains("cursor=c2"),
887
+ "expected written-back cursor sent: " + rec.url(1));
888
+ assertEquals(false, pg.get("hasMore"), "expected ctrl to carry the last record");
889
+ assertNull(pg.get("cursor"), "expected ctrl to carry the last record");
890
+ }
891
+
892
+ @Test
893
+ public void paging_continuesFromWrittenBackNextPage() {
894
+ assumeFeatures("paging");
895
+ FhRecorder rec = new FhRecorder();
896
+ rec.reply = (n, fetchdef) -> fhResponse(200,
897
+ 1 == n ? fhMap("next_page", 2) : fhMap(),
898
+ fhMap("x-page", String.valueOf(n)));
899
+ FhHarness h = fhMake(rec::fetch, fhF(new PagingFeature(), null));
900
+ Map<String, Object> pg = new LinkedHashMap<>();
901
+ Map<String, Object> ctrl = fhMap("paging", pg);
902
+ h.op(fhOp("list").path("/w").ctrl(ctrl));
903
+ assertEquals(1, pg.get("page"), "expected x-page written back");
904
+ assertEquals(2, pg.get("nextPage"), "expected next_page written back");
905
+ assertEquals(true, pg.get("hasMore"), "expected hasMore inferred from next_page");
906
+ h.op(fhOp("list").path("/w").ctrl(ctrl));
907
+ assertTrue(rec.url(1).contains("page=2"),
908
+ "expected written-back next page sent: " + rec.url(1));
909
+ assertEquals(false, pg.get("hasMore"), "expected no more pages");
910
+ }
911
+
870
912
  @Test
871
913
  public void paging_nonListNotPaged() {
872
914
  assumeFeatures("paging");
@@ -9,6 +9,7 @@ import java.net.http.HttpResponse;
9
9
  import java.util.LinkedHashMap;
10
10
  import java.util.List;
11
11
  import java.util.Map;
12
+ import java.util.concurrent.ConcurrentHashMap;
12
13
  import java.util.function.BiFunction;
13
14
  import java.util.function.Supplier;
14
15
 
@@ -20,6 +21,40 @@ final class Fetcher {
20
21
 
21
22
  private Fetcher() {}
22
23
 
24
+ // Clients keyed by redirect policy and proxy. Each HttpClient owns a
25
+ // connection pool and a selector thread, so one per request reused
26
+ // nothing; one per key pools across calls.
27
+ private static final ConcurrentHashMap<String, HttpClient> CLIENTS =
28
+ new ConcurrentHashMap<>();
29
+
30
+ private static HttpClient clientFor(Map<String, Object> fetchdef) {
31
+ // "manual" (set by the station feature's middleware under a hosts
32
+ // policy) returns a 3xx like any other response: an automatic follow
33
+ // would carry injected credentials to a host no policy approved.
34
+ final HttpClient.Redirect redirectPolicy =
35
+ "manual".equals(fetchdef.get("redirect"))
36
+ ? HttpClient.Redirect.NEVER
37
+ : HttpClient.Redirect.NORMAL;
38
+
39
+ Object praw = fetchdef.get("proxy");
40
+ final String proxy = praw instanceof String ? (String) praw : "";
41
+
42
+ return CLIENTS.computeIfAbsent(redirectPolicy + "|" + proxy, key -> {
43
+ HttpClient.Builder clientb = HttpClient.newBuilder().followRedirects(redirectPolicy);
44
+ if (!"".equals(proxy)) {
45
+ try {
46
+ URI proxyUri = URI.create(proxy);
47
+ int port = proxyUri.getPort() < 0 ? 80 : proxyUri.getPort();
48
+ clientb.proxy(ProxySelector.of(new InetSocketAddress(proxyUri.getHost(), port)));
49
+ }
50
+ catch (RuntimeException e) {
51
+ // Unparseable proxy target: direct connection, as the go transport.
52
+ }
53
+ }
54
+ return clientb.build();
55
+ });
56
+ }
57
+
23
58
  static Map<String, Object> defaultHttpFetch(String fullurl, Map<String, Object> fetchdef) {
24
59
  String method = fetchdef.get("method") instanceof String
25
60
  ? (String) fetchdef.get("method") : "";
@@ -59,36 +94,9 @@ final class Fetcher {
59
94
  reqb.setHeader("User-Agent", "Mozilla/5.0 (compatible; ProjectNameSDK/1.0)");
60
95
  }
61
96
 
62
- // Honour a redirect annotation on the fetch definition (set by the
63
- // station feature's middleware under a hosts policy): "manual" returns
64
- // a 3xx like any other response instead of following it — the ts
65
- // donor's fetch honours the same key natively, and an automatic follow
66
- // would carry injected credentials to a host no policy approved.
67
- HttpClient.Redirect redirectPolicy =
68
- "manual".equals(fetchdef.get("redirect"))
69
- ? HttpClient.Redirect.NEVER
70
- : HttpClient.Redirect.NORMAL;
71
-
72
- // Honour a proxy annotation on the fetch definition (set by the proxy
73
- // feature): route the request through a proxied HttpClient.
74
- HttpClient.Builder clientb = HttpClient.newBuilder()
75
- .followRedirects(redirectPolicy);
76
- Object proxy = fetchdef.get("proxy");
77
- if (proxy instanceof String && !"".equals(proxy)) {
78
- try {
79
- URI proxyUri = URI.create((String) proxy);
80
- int port = proxyUri.getPort() < 0 ? 80 : proxyUri.getPort();
81
- clientb.proxy(ProxySelector.of(new InetSocketAddress(proxyUri.getHost(), port)));
82
- }
83
- catch (RuntimeException e) {
84
- // Unparseable proxy target: fall through to a direct connection,
85
- // mirroring the go transport (bad proxy URL is ignored).
86
- }
87
- }
88
-
89
97
  HttpResponse<String> resp;
90
98
  try {
91
- resp = clientb.build().send(reqb.build(), HttpResponse.BodyHandlers.ofString());
99
+ resp = clientFor(fetchdef).send(reqb.build(), HttpResponse.BodyHandlers.ofString());
92
100
  }
93
101
  catch (Exception e) {
94
102
  throw new RuntimeException("fetch: " + e.getMessage(), e);
@@ -35,7 +35,7 @@ final class PrepareQuery {
35
35
  for (List<Object> item : Struct.items(reqmatch)) {
36
36
  String key = item.get(0) instanceof String ? (String) item.get(0) : "";
37
37
  Object val = item.get(1);
38
- if (val != null && !containsStr(params, key)) {
38
+ if (val != null && !"$action".equals(key) && !containsStr(params, key)) {
39
39
  out.put(key, val);
40
40
  }
41
41
  }
@@ -19,17 +19,32 @@ final class TransformRequest {
19
19
  Map<String, Object> transform =
20
20
  Helpers.toMapAny(Struct.getprop(ctx.point, "transform"));
21
21
  if (transform == null) {
22
- return ctx.reqdata;
22
+ return stripAction(ctx.reqdata);
23
23
  }
24
24
 
25
25
  Object reqform = Struct.getprop(transform, "req", null);
26
26
  if (reqform == null) {
27
- return ctx.reqdata;
27
+ return stripAction(ctx.reqdata);
28
28
  }
29
29
 
30
30
  Map<String, Object> data = new LinkedHashMap<>();
31
31
  data.put("reqdata", ctx.reqdata);
32
32
 
33
- return Struct.transform(data, reqform);
33
+ return stripAction(Struct.transform(data, reqform));
34
+ }
35
+
36
+ // `$action` selects the point (see MakePoint); it is never an API field, so
37
+ // the body is a copy without it. The caller's map is left untouched.
38
+ private static Object stripAction(Object reqdata) {
39
+ if (!(reqdata instanceof Map) || !((Map<?, ?>) reqdata).containsKey("$action")) {
40
+ return reqdata;
41
+ }
42
+ Map<String, Object> body = new LinkedHashMap<>();
43
+ for (Map.Entry<?, ?> e : ((Map<?, ?>) reqdata).entrySet()) {
44
+ if (!"$action".equals(e.getKey())) {
45
+ body.put(String.valueOf(e.getKey()), e.getValue());
46
+ }
47
+ }
48
+ return body;
34
49
  }
35
50
  }
@@ -57,7 +57,13 @@ class Context {
57
57
  this.client = getprop(ctxmap, 'client', getprop(basectx, 'client'))
58
58
  this.utility = getprop(ctxmap, 'utility', getprop(basectx, 'utility'))
59
59
 
60
- this.ctrl = getprop(ctxmap, 'ctrl', getprop(basectx, 'ctrl', this.ctrl))
60
+ // An operation gets its OWN control unless the caller passed one: a
61
+ // paging cursor left on the client's control would otherwise be picked
62
+ // up by the next, unrelated call. A context with no opname is not an
63
+ // operation, so it still shares (an entity's, with the client's).
64
+ const opname = getprop(ctxmap, 'opname')
65
+ const basectrl = null == opname ? getprop(basectx, 'ctrl', this.ctrl) : this.ctrl
66
+ this.ctrl = getprop(ctxmap, 'ctrl', basectrl)
61
67
  this.meta = getprop(ctxmap, 'meta', getprop(basectx, 'meta', this.meta))
62
68
 
63
69
  this.config = getprop(ctxmap, 'config', getprop(basectx, 'config'))
@@ -78,7 +84,6 @@ class Context {
78
84
  this.result = getprop(ctxmap, 'result', getprop(basectx, 'result'))
79
85
  this.response = getprop(ctxmap, 'response', getprop(basectx, 'response'))
80
86
 
81
- const opname = getprop(ctxmap, 'opname')
82
87
  this.op = this.resolveOp(opname)
83
88
  }
84
89
 
@@ -7,8 +7,9 @@ const { BaseFeature } = require('../base/BaseFeature')
7
7
  // stamps page/limit (or a cursor) into the request query; on the way back
8
8
  // (PreResult) it reads the server's pagination signals — a `Link:
9
9
  // rel="next"` header, `X-Next-Page`/`X-Total-Count` headers, or `next`/
10
- // `cursor`/`hasMore` fields in the body — and records them on
11
- // `ctx.result.paging`. Generated SDKs build auto-iteration on top of this
10
+ // `cursor`/`hasMore` fields in the body (snake_case forms too) — and
11
+ // records them on `ctx.result.paging` and the caller's `ctrl.paging`.
12
+ // Generated SDKs build auto-iteration on top of this
12
13
  // (advance the cursor/page and re-issue the list call until `hasMore` is
13
14
  // false). Parameter names and page size are configurable.
14
15
  class PagingFeature extends BaseFeature {
@@ -28,7 +29,7 @@ class PagingFeature extends BaseFeature {
28
29
 
29
30
 
30
31
  PreRequest(ctx) {
31
- if (!this._isList(ctx)) {
32
+ if (!this.active || !this._isList(ctx)) {
32
33
  return
33
34
  }
34
35
  const spec = ctx.spec
@@ -58,7 +59,10 @@ class PagingFeature extends BaseFeature {
58
59
  spec.query[cursorParam] = paging.cursor
59
60
  }
60
61
  else if (null == spec.query[pageParam]) {
61
- spec.query[pageParam] = null != paging.page ? paging.page : (this._options.startPage || 1)
62
+ // A record written back by PreResult holds the page just fetched as
63
+ // `page` and the one to fetch as `nextPage`, so nextPage wins.
64
+ spec.query[pageParam] = null != paging.nextPage ? paging.nextPage :
65
+ null != paging.page ? paging.page : (this._options.startPage || 1)
62
66
  }
63
67
 
64
68
  if (null != this._options.limit && null == spec.query[limitParam]) {
@@ -68,7 +72,7 @@ class PagingFeature extends BaseFeature {
68
72
 
69
73
 
70
74
  PreResult(ctx) {
71
- if (!this._isList(ctx)) {
75
+ if (!this.active || !this._isList(ctx)) {
72
76
  return
73
77
  }
74
78
  const result = ctx.result
@@ -124,11 +128,20 @@ class PagingFeature extends BaseFeature {
124
128
  }
125
129
  }
126
130
 
127
- // Body-level cursors.
131
+ // Body-level signals; snake_case forms are read first so camelCase wins.
128
132
  if (body && 'object' === typeof body) {
129
133
  if (null != body.next) { paging.next = paging.next || body.next }
134
+ if (null != body.next_cursor) { paging.cursor = body.next_cursor }
130
135
  if (null != body.cursor) { paging.cursor = body.cursor }
131
136
  if (null != body.nextCursor) { paging.cursor = body.nextCursor }
137
+ if (null == paging.nextPage) {
138
+ const np = null != body.nextPage ? body.nextPage : body.next_page
139
+ if ('number' === typeof np || 'string' === typeof np) { paging.nextPage = np }
140
+ }
141
+ if ('boolean' === typeof body.has_more) {
142
+ paging.hasMore = body.has_more
143
+ explicitMore = true
144
+ }
132
145
  if ('boolean' === typeof body.hasMore) {
133
146
  paging.hasMore = body.hasMore
134
147
  explicitMore = true
@@ -147,6 +160,11 @@ class PagingFeature extends BaseFeature {
147
160
 
148
161
  result.paging = paging
149
162
 
163
+ // ctx.ctrl is the caller's own object, so the record reaches them.
164
+ if (null != ctx.ctrl) {
165
+ ctx.ctrl.paging = paging
166
+ }
167
+
150
168
  const client = this._client
151
169
  client._paging = { last: paging }
152
170
  }