@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
@@ -35,8 +35,8 @@ class ProjectNameTestFeature extends ProjectNameBaseFeature
35
35
 
36
36
  $this->client->mode = 'test';
37
37
 
38
- // Ensure entity ids are correct.
39
- \Voxgig\Struct\Struct::walk($entity_data, function ($key, $val, $parent, $path) {
38
+ // Ensure entity ids are correct (walk is by value: keep its result).
39
+ $entity_data = \Voxgig\Struct\Struct::walk($entity_data, function ($key, $val, $parent, $path) {
40
40
  if (count($path) === 2 && is_array($val) && $key !== null) {
41
41
  $val['id'] = $key;
42
42
  }
@@ -103,9 +103,8 @@ class ProjectNameTestFeature extends ProjectNameBaseFeature
103
103
 
104
104
  // PHP-portable equivalent of TS buildArgs+select: a flat-key
105
105
  // filter that matches by exact-equality on each provided key,
106
- // with alias fallback. Empty match matches all entries — load
107
- // with empty match returns the first fixture entry (or last
108
- // create), list returns all entries.
106
+ // with alias fallback. An empty match matches every entry, as an
107
+ // empty `$AND` does in select; a non-empty miss matches nothing.
109
108
  $find_first = function (array $entmap, $match, $alias) {
110
109
  if (!is_array($match) || empty($match)) {
111
110
  foreach ($entmap as $e) {
@@ -158,6 +157,23 @@ class ProjectNameTestFeature extends ProjectNameBaseFeature
158
157
 
159
158
  $alias = is_object($op) ? ($op->alias ?? null) : \Voxgig\Struct\Struct::getprop($op, 'alias');
160
159
 
160
+ // Every op filters through buildArgs, which keeps `id` and the
161
+ // point's required params and drops the rest. find_first/find_all
162
+ // take a flat map, so take the primary key of each $OR clause;
163
+ // they do their own alias fallback.
164
+ $query_match = function ($args) use ($fctx, $op) {
165
+ $flat = [];
166
+ foreach (($this->buildArgs($fctx, $op, $args)['$AND'] ?? []) as $clause) {
167
+ $ors = $clause['$OR'] ?? [];
168
+ if (is_array($ors) && count($ors) > 0 && is_array($ors[0])) {
169
+ foreach ($ors[0] as $lk => $lv) {
170
+ $flat[$lk] = $lv;
171
+ }
172
+ }
173
+ }
174
+ return $flat;
175
+ };
176
+
161
177
  // For single-entity ops (load, remove) with an empty explicit
162
178
  // match, fall back to the id the entity client already knows from a
163
179
  // prior create/load (carried in $fctx->match / $fctx->data). This
@@ -176,7 +192,7 @@ class ProjectNameTestFeature extends ProjectNameBaseFeature
176
192
  };
177
193
 
178
194
  if ($op->name === 'load') {
179
- $ent = $find_first($entmap, $resolve_match($fctx->reqmatch), $alias);
195
+ $ent = $find_first($entmap, $query_match($resolve_match($fctx->reqmatch)), $alias);
180
196
  if ($ent === null) {
181
197
  return $respond(404, null, ['statusText' => 'Not found']);
182
198
  }
@@ -185,37 +201,7 @@ class ProjectNameTestFeature extends ProjectNameBaseFeature
185
201
  return $respond(200, $out);
186
202
 
187
203
  } elseif ($op->name === 'list') {
188
- // FILTER THE MATCH THE WAY THE TS MOCK DOES.
189
- //
190
- // This branch used the whole of reqmatch, while load goes
191
- // through resolve_match and the TS mock runs every op through
192
- // buildArgs — which keeps `id` and the point's REQUIRED params
193
- // and drops the rest. buildArgs is right here in this file and
194
- // its docblock says it mirrors the TS one; list simply did not
195
- // call it.
196
- //
197
- // So a match key that is neither `id` nor a required route
198
- // param matched NOTHING in php and was IGNORED in ts. trello's
199
- // board_star lists on `board_id`, which is not a field of that
200
- // entity (id, pos, member_id, id_board) and not a route param:
201
- // php returned an empty list and BoardStarEntityTest failed on
202
- // an assertion that ts, js and every other port passed.
203
- //
204
- // buildArgs returns a struct query; find_all takes a flat map,
205
- // so take the primary key of each $OR clause. find_all already
206
- // does its own alias fallback, which is what the second $OR
207
- // element carries.
208
- $listargs = $this->buildArgs($fctx, $op, $fctx->reqmatch);
209
- $listmatch = [];
210
- foreach (($listargs['$AND'] ?? []) as $clause) {
211
- $ors = $clause['$OR'] ?? [];
212
- if (is_array($ors) && count($ors) > 0 && is_array($ors[0])) {
213
- foreach ($ors[0] as $lk => $lv) {
214
- $listmatch[$lk] = $lv;
215
- }
216
- }
217
- }
218
- $found = $find_all($entmap, $listmatch, $alias);
204
+ $found = $find_all($entmap, $query_match($fctx->reqmatch), $alias);
219
205
  $cleaned = [];
220
206
  foreach ($found as $e) {
221
207
  if (is_array($e)) unset($e['$KEY']);
@@ -245,14 +231,13 @@ class ProjectNameTestFeature extends ProjectNameBaseFeature
245
231
  if (empty($update_match)) {
246
232
  $update_match = $resolve_match([]);
247
233
  }
248
- $ent = $find_first($entmap, $update_match, $alias);
234
+ $ent = $find_first($entmap, $query_match($update_match), $alias);
249
235
  if ($ent === null) {
236
+ // update miss: 404, never another record
250
237
  return $respond(404, null, ['statusText' => 'Not found']);
251
238
  }
252
239
  if (is_array($fctx->reqdata)) {
253
- foreach ($fctx->reqdata as $k => $v) {
254
- $ent[$k] = $v;
255
- }
240
+ $ent = \Voxgig\Struct\Struct::merge([$ent, $fctx->reqdata]);
256
241
  }
257
242
  $id = is_array($ent) ? ($ent['id'] ?? null) : null;
258
243
  if ($id !== null) {
@@ -264,7 +249,7 @@ class ProjectNameTestFeature extends ProjectNameBaseFeature
264
249
  return $respond(200, $out);
265
250
 
266
251
  } elseif ($op->name === 'remove') {
267
- $ent = $find_first($entmap, $resolve_match($fctx->reqmatch), $alias);
252
+ $ent = $find_first($entmap, $query_match($resolve_match($fctx->reqmatch)), $alias);
268
253
  // Remove only the first matched entity. If nothing matches,
269
254
  // succeed as a no-op rather than erroring.
270
255
  $id = is_array($ent) ? ($ent['id'] ?? null) : null;
@@ -267,6 +267,19 @@ class FeatureCorpusTest extends TestCase
267
267
  $this->assertSame($expect, $actual, $path);
268
268
  }
269
269
 
270
+ // Whether the SDK built the feature at all. The activity record is the
271
+ // wrong probe: most features create theirs on first use, so an idle
272
+ // client has none and every section but the eager ones read as inert.
273
+ private static function present($client, string $name): bool
274
+ {
275
+ foreach ($client->features ?? [] as $f) {
276
+ if (($f->name ?? null) === $name) {
277
+ return true;
278
+ }
279
+ }
280
+ return false;
281
+ }
282
+
270
283
  private static function record($client, string $name)
271
284
  {
272
285
  $prop = '_' . $name;
@@ -326,7 +339,10 @@ class FeatureCorpusTest extends TestCase
326
339
  // Probed by ACTIVATING it: the feature defaults to inactive, so an
327
340
  // idle client never builds it and its absence says nothing.
328
341
  $probe = self::buildClient(['feature' => [['name' => $name, 'active' => true]]]);
329
- if (null === self::record($probe, $name)) {
342
+ if (!self::present($probe, $name)) {
343
+ // The one line every runner prints for an inert section.
344
+ fwrite(STDERR, sprintf(
345
+ "feature.%s: inert (this SDK does not generate the feature)\n", $name));
330
346
  continue;
331
347
  }
332
348
 
@@ -1104,6 +1104,50 @@ class FeatureTest extends TestCase
1104
1104
  $this->assertTrue($res['result']->paging['hasMore']);
1105
1105
  }
1106
1106
 
1107
+ public function test_paging_snake_case_signals_and_ctrl_write_back(): void
1108
+ {
1109
+ $this->needs('paging');
1110
+ $rec = new FtRecorder(function (int $n) {
1111
+ return FtHarness::response(200, 1 === $n
1112
+ ? ['has_more' => true, 'next_cursor' => 'c2']
1113
+ : ['has_more' => false]);
1114
+ });
1115
+ $h = new FtHarness([
1116
+ ['name' => 'paging'],
1117
+ ], $rec->server());
1118
+ $res = $h->op(['op' => 'list', 'path' => '/w', 'ctrl' => ['paging' => []]]);
1119
+ $this->assertTrue($res['result']->paging['hasMore']);
1120
+ $this->assertSame('c2', $res['result']->paging['cursor']);
1121
+ $written = $res['ctx']->ctrl->paging;
1122
+ $this->assertSame('c2', $written['cursor']);
1123
+ $this->assertTrue($written['hasMore']);
1124
+ // Arrays are values here, so the written-back record is passed on by hand.
1125
+ $res = $h->op(['op' => 'list', 'path' => '/w', 'ctrl' => ['paging' => $written]]);
1126
+ $this->assertStringContainsString('cursor=c2', $rec->calls[1]['url']);
1127
+ $this->assertFalse($res['ctx']->ctrl->paging['hasMore']);
1128
+ $this->assertNull($res['ctx']->ctrl->paging['cursor']);
1129
+ }
1130
+
1131
+ public function test_paging_continues_from_written_back_next_page(): void
1132
+ {
1133
+ $this->needs('paging');
1134
+ $rec = new FtRecorder(function (int $n) {
1135
+ return FtHarness::response(200, 1 === $n ? ['next_page' => 2] : [],
1136
+ ['x-page' => (string) $n]);
1137
+ });
1138
+ $h = new FtHarness([
1139
+ ['name' => 'paging'],
1140
+ ], $rec->server());
1141
+ $res = $h->op(['op' => 'list', 'path' => '/w']);
1142
+ $written = $res['ctx']->ctrl->paging;
1143
+ $this->assertEquals(1, $written['page']);
1144
+ $this->assertEquals(2, $written['nextPage']);
1145
+ $this->assertTrue($written['hasMore']);
1146
+ $res = $h->op(['op' => 'list', 'path' => '/w', 'ctrl' => ['paging' => $written]]);
1147
+ $this->assertStringContainsString('page=2', $rec->calls[1]['url']);
1148
+ $this->assertFalse($res['ctx']->ctrl->paging['hasMore']);
1149
+ }
1150
+
1107
1151
  public function test_paging_non_list_op_is_not_paged(): void
1108
1152
  {
1109
1153
  $this->needs('paging');
@@ -12,6 +12,22 @@ class ProjectNameFetcher
12
12
  // Users can override by passing a User-Agent header in fetchdef.
13
13
  public const DEFAULT_USER_AGENT = 'Mozilla/5.0 (compatible; ProjectNameSDK/1.0)';
14
14
 
15
+ // One cURL handle per process, reset between calls rather than closed:
16
+ // curl_reset keeps the connection cache, so the next request to the
17
+ // same host reuses the open connection.
18
+ private static $curl = null;
19
+
20
+ private static function curlHandle(string $fullurl)
21
+ {
22
+ if (self::$curl === null) {
23
+ self::$curl = curl_init();
24
+ } else {
25
+ curl_reset(self::$curl);
26
+ }
27
+ curl_setopt(self::$curl, CURLOPT_URL, $fullurl);
28
+ return self::$curl;
29
+ }
30
+
15
31
  public static function defaultHttpFetch(string $fullurl, array $fetchdef): array
16
32
  {
17
33
  $method_str = strtoupper($fetchdef['method'] ?? 'GET');
@@ -130,7 +146,7 @@ class ProjectNameFetcher
130
146
  ];
131
147
  }
132
148
 
133
- $ch = curl_init($fullurl);
149
+ $ch = self::curlHandle($fullurl);
134
150
  curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
135
151
  curl_setopt($ch, CURLOPT_HEADER, false);
136
152
  curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);
@@ -157,7 +173,6 @@ class ProjectNameFetcher
157
173
  $response_body = curl_exec($ch);
158
174
  if ($response_body === false) {
159
175
  $err = curl_error($ch) ?: 'curl_exec failed';
160
- curl_close($ch);
161
176
  return [
162
177
  [
163
178
  'status' => 0,
@@ -169,7 +184,6 @@ class ProjectNameFetcher
169
184
  $err,
170
185
  ];
171
186
  }
172
- curl_close($ch);
173
187
 
174
188
  $status = 0;
175
189
  $status_text = '';
@@ -22,7 +22,7 @@ class ProjectNamePrepareQuery
22
22
  foreach ($items as $item) {
23
23
  $key = $item[0];
24
24
  $val = $item[1];
25
- if ($val !== null && is_string($key) && !in_array($key, $params, true)) {
25
+ if ($val !== null && is_string($key) && '$action' !== $key && !in_array($key, $params, true)) {
26
26
  $out[$key] = $val;
27
27
  }
28
28
  }
@@ -16,12 +16,24 @@ class ProjectNameTransformRequest
16
16
  }
17
17
  $transform = ProjectNameHelpers::to_map(\Voxgig\Struct\Struct::getprop($point, 'transform'));
18
18
  if (!$transform) {
19
- return $ctx->reqdata;
19
+ return self::strip_action($ctx->reqdata);
20
20
  }
21
21
  $reqform = \Voxgig\Struct\Struct::getprop($transform, 'req');
22
22
  if (!$reqform) {
23
- return $ctx->reqdata;
23
+ return self::strip_action($ctx->reqdata);
24
24
  }
25
- return \Voxgig\Struct\Struct::transform(['reqdata' => $ctx->reqdata], $reqform);
25
+ return self::strip_action(\Voxgig\Struct\Struct::transform(['reqdata' => $ctx->reqdata], $reqform));
26
+ }
27
+
28
+ // `$action` selects the point (see MakePoint); it is never an API field,
29
+ // so the body is a copy without it. Arrays are values, so the unset
30
+ // never reaches the caller's copy.
31
+ private static function strip_action(mixed $reqdata): mixed
32
+ {
33
+ if (!is_array($reqdata) || !array_key_exists('$action', $reqdata)) {
34
+ return $reqdata;
35
+ }
36
+ unset($reqdata['$action']);
37
+ return $reqdata;
26
38
  }
27
39
  }
@@ -53,7 +53,8 @@ class ProjectNameContext:
53
53
  self.ctrl.actor = ctrl_raw["actor"]
54
54
  if isinstance(ctrl_raw.get("paging"), dict):
55
55
  self.ctrl.paging = ctrl_raw["paging"]
56
- elif basectx is not None and basectx.ctrl is not None:
56
+ elif (basectx is not None and basectx.ctrl is not None
57
+ and get_ctx_prop(ctxmap, "opname") is None):
57
58
  self.ctrl = basectx.ctrl
58
59
 
59
60
  # Meta
@@ -12,8 +12,8 @@ from projectname_sdk.feature.base_feature import ProjectNameBaseFeature
12
12
  # stamps page/limit (or a cursor) into the request query; on the way back
13
13
  # (PreResult) it reads the server's pagination signals — a `Link:
14
14
  # rel="next"` header, `X-Page`/`X-Next-Page`/`X-Total-Count` headers, or
15
- # `next`/`cursor`/`nextCursor`/`hasMore` fields in the body — and records
16
- # them on `ctx.result.paging`. A per-call cursor/page supplied via ctrl
15
+ # `next`/`cursor`/`nextCursor`/`hasMore` fields in the body (snake_case forms
16
+ # too) — and records them on `ctx.result.paging` and the caller's ctrl paging. A per-call cursor/page supplied via ctrl
17
17
  # paging takes priority (used by auto-iteration). Parameter names
18
18
  # (`pageParam`/`limitParam`/`cursorParam`), `startPage` and page size
19
19
  # (`limit`) are configurable.
@@ -66,7 +66,11 @@ class ProjectNamePagingFeature(ProjectNameBaseFeature):
66
66
  if paging.get("cursor") is not None:
67
67
  spec.query[cursor_param] = paging["cursor"]
68
68
  elif spec.query.get(page_param) is None:
69
- page = paging.get("page")
69
+ # A record written back by PreResult holds the page just fetched
70
+ # as "page" and the one to fetch as "nextPage", so nextPage wins.
71
+ page = paging.get("nextPage")
72
+ if page is None:
73
+ page = paging.get("page")
70
74
  if page is None:
71
75
  page = self.options.get("startPage") or 1
72
76
  spec.query[page_param] = page
@@ -166,14 +170,25 @@ class ProjectNamePagingFeature(ProjectNameBaseFeature):
166
170
  paging["hasMore"] = more
167
171
  explicit_more = True
168
172
 
169
- # Body-level cursors.
173
+ # Body-level signals; snake_case forms are read first so camelCase wins.
170
174
  if isinstance(body, dict):
171
175
  if body.get("next") is not None:
172
176
  paging["next"] = paging["next"] or body["next"]
177
+ if body.get("next_cursor") is not None:
178
+ paging["cursor"] = body["next_cursor"]
173
179
  if body.get("cursor") is not None:
174
180
  paging["cursor"] = body["cursor"]
175
181
  if body.get("nextCursor") is not None:
176
182
  paging["cursor"] = body["nextCursor"]
183
+ if paging["nextPage"] is None:
184
+ np = body.get("nextPage")
185
+ if np is None:
186
+ np = body.get("next_page")
187
+ if isinstance(np, (int, float, str)) and not isinstance(np, bool):
188
+ paging["nextPage"] = np
189
+ if isinstance(body.get("has_more"), bool):
190
+ paging["hasMore"] = body["has_more"]
191
+ explicit_more = True
177
192
  if isinstance(body.get("hasMore"), bool):
178
193
  paging["hasMore"] = body["hasMore"]
179
194
  explicit_more = True
@@ -191,6 +206,16 @@ class ProjectNamePagingFeature(ProjectNameBaseFeature):
191
206
 
192
207
  result.paging = paging
193
208
 
209
+ ctrl = ctx.ctrl
210
+ if ctrl is not None:
211
+ # The Context shares the caller's paging dict by reference, so
212
+ # refill it in place; a fresh dict on ctrl never reaches the caller.
213
+ if isinstance(getattr(ctrl, "paging", None), dict):
214
+ ctrl.paging.clear()
215
+ ctrl.paging.update(paging)
216
+ else:
217
+ ctrl.paging = paging
218
+
194
219
  self.client._paging = {"last": paging}
195
220
 
196
221
  def _is_list(self, ctx):
@@ -143,18 +143,11 @@ class ProjectNameTestFeature(ProjectNameBaseFeature):
143
143
  args = test_self.build_args(fctx, op, update_match)
144
144
  found = vs.select(entmap, args)
145
145
  ent = vs.getelem(found, 0)
146
- if ent is None and isinstance(entmap, dict):
147
- for e in entmap.values():
148
- if isinstance(e, dict):
149
- ent = e
150
- break
151
146
  if ent is None:
147
+ # update miss: 404, never another record
152
148
  return respond(404, None, {"statusText": "Not found"})
153
- if isinstance(ent, dict):
154
- reqdata = fctx.reqdata
155
- if reqdata is not None:
156
- for k, v in reqdata.items():
157
- ent[k] = v
149
+ if isinstance(ent, dict) and isinstance(fctx.reqdata, dict):
150
+ vs.merge([ent, fctx.reqdata])
158
151
  vs.delprop(ent, "$KEY")
159
152
  out = vs.clone(ent)
160
153
  return respond(200, out)
@@ -260,12 +253,36 @@ class ProjectNameTestFeature(ProjectNameBaseFeature):
260
253
 
261
254
  return netsim_fetcher
262
255
 
256
+ # The entity's own endpoint: a terminal `{param}` marks a record route,
257
+ # and among equals the shallower path wins (the same rule as make_point).
258
+ @staticmethod
259
+ def pick_point(points):
260
+ if not isinstance(points, list) or len(points) == 0:
261
+ return None
262
+
263
+ def terminal(p):
264
+ parts = vs.getprop(p, "parts")
265
+ return isinstance(parts, list) and len(parts) > 0 and \
266
+ isinstance(parts[-1], str) and parts[-1].startswith("{")
267
+
268
+ def depth(p):
269
+ parts = vs.getprop(p, "parts")
270
+ return len(parts) if isinstance(parts, list) else 0
271
+
272
+ point = points[0]
273
+ for cand in points[1:]:
274
+ if terminal(cand) != terminal(point):
275
+ if terminal(cand):
276
+ point = cand
277
+ elif depth(cand) < depth(point):
278
+ point = cand
279
+ return point
280
+
263
281
  def build_args(self, ctx, op, args):
264
282
  opname = op.name
265
283
 
266
- # Get last point from config.
267
284
  points = vs.getpath(ctx.config, "entity." + ctx.entity.get_name() + ".op." + opname + ".points")
268
- point = vs.getelem(points, -1)
285
+ point = self.pick_point(points)
269
286
 
270
287
  # Path AND query: a path-only read misses a query-addressed record
271
288
  # (e.g. GET /result?trace_id=), which has no path param at all.
@@ -2,21 +2,37 @@
2
2
 
3
3
  from __future__ import annotations
4
4
  import json
5
+ import threading
6
+ from http.cookiejar import DefaultCookiePolicy
5
7
  from projectname_sdk.utility.voxgig_struct import voxgig_struct as vs
6
8
 
7
9
 
8
10
  # Default User-Agent — many CDNs (notably Cloudflare) reject requests with
9
- # Python's default urllib UA ("Python-urllib/3.x"), returning 403 before
10
- # the request even reaches the origin. Set a Mozilla-shaped UA so the SDK
11
- # behaves like every other HTTP client by default. Users can still override
12
- # by passing a User-Agent header in fetchdef.
11
+ # a library's default UA, returning 403 before the request even reaches
12
+ # the origin. Set a Mozilla-shaped UA so the SDK behaves like every other
13
+ # HTTP client by default. Users can still override by passing a User-Agent
14
+ # header in fetchdef.
13
15
  _DEFAULT_USER_AGENT = "Mozilla/5.0 (compatible; ProjectNameSDK/1.0)"
14
16
 
15
17
 
16
- def _default_http_fetch(fullurl, fetchdef):
17
- import urllib.request
18
- import urllib.error
18
+ _SESSION = None
19
+ _SESSION_LOCK = threading.Lock()
20
+
21
+
22
+ def _session():
23
+ # Pool connections without retaining response cookies between calls.
24
+ global _SESSION
25
+ if _SESSION is None:
26
+ import requests
27
+ with _SESSION_LOCK:
28
+ if _SESSION is None:
29
+ session = requests.Session()
30
+ session.cookies.set_policy(DefaultCookiePolicy(allowed_domains=[]))
31
+ _SESSION = session
32
+ return _SESSION
33
+
19
34
 
35
+ def _default_http_fetch(fullurl, fetchdef):
20
36
  method = fetchdef.get("method", "GET")
21
37
  body_str = fetchdef.get("body")
22
38
  headers = fetchdef.get("headers", {})
@@ -26,76 +42,58 @@ def _default_http_fetch(fullurl, fetchdef):
26
42
 
27
43
  data = body_str.encode("utf-8") if body_str is not None else None
28
44
 
29
- req = urllib.request.Request(fullurl, data=data, method=method)
45
+ req_headers = {}
30
46
  has_ua = False
31
47
  for k, v in headers.items():
32
48
  if k.lower() == "user-agent":
33
49
  has_ua = True
34
- req.add_header(k, v)
50
+ req_headers[k] = v
35
51
  if not has_ua:
36
- req.add_header("User-Agent", _DEFAULT_USER_AGENT)
52
+ req_headers["User-Agent"] = _DEFAULT_USER_AGENT
37
53
 
38
54
  # Manual redirects: fetchdef["redirect"] == "manual" surfaces a 3xx as
39
- # an ordinary response instead of auto-following it — urllib would
40
- # otherwise replay the request, headers included, against whatever host
41
- # Location names. Set by the station feature's transport middleware
42
- # under an egress hosts policy (mirrors the ts fetch option).
43
- opener = None
44
- if fetchdef.get("redirect") == "manual":
45
- class _NoRedirect(urllib.request.HTTPRedirectHandler):
46
- def redirect_request(self, req, fp, code, msg, headers, newurl):
47
- return None
48
- opener = urllib.request.build_opener(_NoRedirect)
55
+ # an ordinary response instead of auto-following it, which would replay
56
+ # the request, headers included, against whatever host Location names.
57
+ # Set by the station feature's transport middleware under an egress
58
+ # hosts policy (mirrors the ts fetch option).
59
+ kwargs = {"allow_redirects": fetchdef.get("redirect") != "manual"}
60
+
61
+ proxy = fetchdef.get("proxy")
62
+ if isinstance(proxy, str) and proxy:
63
+ kwargs["proxies"] = {"http": proxy, "https": proxy}
64
+
65
+ timeout = fetchdef.get("timeout")
66
+ if isinstance(timeout, (int, float)) and timeout > 0:
67
+ kwargs["timeout"] = timeout
49
68
 
50
69
  try:
51
- resp = opener.open(req) if opener is not None else urllib.request.urlopen(req)
52
- body = resp.read().decode("utf-8")
53
- resp_headers = {}
54
- for k, v in resp.getheaders():
55
- resp_headers[k.lower()] = v
56
-
57
- json_body = None
58
- if len(body) > 0:
59
- try:
60
- json_body = json.loads(body)
61
- except Exception:
62
- pass
63
-
64
- status = resp.getcode()
65
- status_text = "OK" if status < 400 else "Error"
66
-
67
- return {
68
- "status": status,
69
- "statusText": status_text,
70
- "headers": resp_headers,
71
- "json": lambda: json_body,
72
- "body": body,
73
- }, None
74
- except urllib.error.HTTPError as e:
75
- body = e.read().decode("utf-8")
76
- resp_headers = {}
77
- for k, v in e.headers.items():
78
- resp_headers[k.lower()] = v
79
-
80
- json_body = None
81
- if len(body) > 0:
82
- try:
83
- json_body = json.loads(body)
84
- except Exception:
85
- pass
86
-
87
- status_text = "OK" if e.code < 400 else "Error"
88
-
89
- return {
90
- "status": e.code,
91
- "statusText": status_text,
92
- "headers": resp_headers,
93
- "json": lambda: json_body,
94
- "body": body,
95
- }, None
70
+ resp = _session().request(method, fullurl, data=data, headers=req_headers, **kwargs)
71
+ body = resp.content.decode("utf-8")
96
72
  except Exception as e:
97
73
  return None, str(e)
98
74
 
75
+ resp_headers = {}
76
+ for k, v in resp.headers.items():
77
+ resp_headers[k.lower()] = v
78
+
79
+ json_body = None
80
+ if len(body) > 0:
81
+ try:
82
+ json_body = json.loads(body)
83
+ except Exception:
84
+ pass
85
+
86
+ status = resp.status_code
87
+ status_text = resp.reason or ("OK" if status < 400 else "Error")
88
+
89
+ return {
90
+ "status": status,
91
+ "statusText": status_text,
92
+ "headers": resp_headers,
93
+ "json": lambda: json_body,
94
+ "body": body,
95
+ }, None
96
+
99
97
 
100
98
  def fetcher_util(ctx, fullurl, fetchdef):
101
99
  if ctx.client.mode != "live":
@@ -27,7 +27,8 @@ def prepare_query_util(ctx):
27
27
  for item in reqmatch_items:
28
28
  key = item[0]
29
29
  val = item[1]
30
- if val is not None and isinstance(key, str) and not _contains_param(params, key):
30
+ if val is not None and isinstance(key, str) and key != "$action" \
31
+ and not _contains_param(params, key):
31
32
  out[key] = val
32
33
 
33
34
  return out
@@ -5,6 +5,14 @@ from projectname_sdk.utility.voxgig_struct import voxgig_struct as vs
5
5
  from projectname_sdk.core.helpers import to_map
6
6
 
7
7
 
8
+ # `$action` selects the point (see make_point_util); it is never an API field,
9
+ # so the body is a copy without it. The caller's dict is left untouched.
10
+ def _strip_action(reqdata):
11
+ if not isinstance(reqdata, dict) or "$action" not in reqdata:
12
+ return reqdata
13
+ return {k: v for k, v in reqdata.items() if k != "$action"}
14
+
15
+
8
16
  def transform_request_util(ctx):
9
17
  spec = ctx.spec
10
18
  point = ctx.point
@@ -14,14 +22,14 @@ def transform_request_util(ctx):
14
22
 
15
23
  transform = to_map(vs.getprop(point, "transform"))
16
24
  if transform is None:
17
- return ctx.reqdata
25
+ return _strip_action(ctx.reqdata)
18
26
 
19
27
  reqform = vs.getprop(transform, "req")
20
28
  if reqform is None:
21
- return ctx.reqdata
29
+ return _strip_action(ctx.reqdata)
22
30
 
23
31
  reqdata = vs.transform({
24
32
  "reqdata": ctx.reqdata,
25
33
  }, reqform)
26
34
 
27
- return reqdata
35
+ return _strip_action(reqdata)