@voxgig/sdkgen 0.45.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/bin/voxgig-sdkgen +1 -1
  2. package/package.json +3 -3
  3. package/project/.sdk/src/cmp/go/Entity_go.ts +2 -2
  4. package/project/.sdk/src/cmp/go/Main_go.ts +8 -4
  5. package/project/.sdk/src/cmp/go/Package_go.ts +2 -2
  6. package/project/.sdk/src/cmp/go/ReadmeExplanation_go.ts +2 -2
  7. package/project/.sdk/src/cmp/go/ReadmeHowto_go.ts +2 -2
  8. package/project/.sdk/src/cmp/go/ReadmeInstall_go.ts +2 -2
  9. package/project/.sdk/src/cmp/go/ReadmeModel_go.ts +2 -2
  10. package/project/.sdk/src/cmp/go/ReadmeQuick_go.ts +2 -2
  11. package/project/.sdk/src/cmp/go/ReadmeTopQuick_go.ts +2 -2
  12. package/project/.sdk/src/cmp/go/ReadmeTopTest_go.ts +2 -2
  13. package/project/.sdk/src/cmp/go/TestDirect_go.ts +204 -33
  14. package/project/.sdk/src/cmp/go/TestEntity_go.ts +37 -6
  15. package/project/.sdk/src/cmp/go/Test_go.ts +2 -2
  16. package/project/.sdk/src/cmp/go/fragment/Main.fragment.go +21 -4
  17. package/project/.sdk/src/cmp/lua/Package_lua.ts +9 -2
  18. package/project/.sdk/src/cmp/lua/TestDirect_lua.ts +154 -21
  19. package/project/.sdk/src/cmp/lua/TestEntity_lua.ts +25 -1
  20. package/project/.sdk/src/cmp/lua/fragment/Main.fragment.lua +20 -4
  21. package/project/.sdk/src/cmp/php/Package_php.ts +7 -1
  22. package/project/.sdk/src/cmp/php/TestDirect_php.ts +153 -20
  23. package/project/.sdk/src/cmp/php/TestEntity_php.ts +25 -1
  24. package/project/.sdk/src/cmp/php/fragment/Main.fragment.php +17 -3
  25. package/project/.sdk/src/cmp/py/Package_py.ts +8 -1
  26. package/project/.sdk/src/cmp/py/TestDirect_py.ts +146 -19
  27. package/project/.sdk/src/cmp/py/TestEntity_py.ts +25 -1
  28. package/project/.sdk/src/cmp/py/fragment/Main.fragment.py +19 -4
  29. package/project/.sdk/src/cmp/rb/Package_rb.ts +9 -2
  30. package/project/.sdk/src/cmp/rb/TestDirect_rb.ts +154 -21
  31. package/project/.sdk/src/cmp/rb/TestEntity_rb.ts +25 -1
  32. package/project/.sdk/src/cmp/rb/fragment/Main.fragment.rb +19 -3
  33. package/project/.sdk/src/cmp/ts/Package_ts.ts +1 -1
  34. package/project/.sdk/src/cmp/ts/TestDirect_ts.ts +145 -22
  35. package/project/.sdk/src/cmp/ts/TestEntity_ts.ts +59 -1
  36. package/project/.sdk/src/cmp/ts/fragment/Direct.test.fragment.ts +8 -1
  37. package/project/.sdk/src/cmp/ts/fragment/Entity.test.fragment.ts +8 -2
  38. package/project/.sdk/src/cmp/ts/fragment/Main.fragment.ts +21 -1
  39. package/project/.sdk/tm/go/feature/test_feature.go +56 -3
  40. package/project/.sdk/tm/go/test/runner_test.go +106 -6
  41. package/project/.sdk/tm/go/test/sdk-test-control.json +19 -0
  42. package/project/.sdk/tm/go/utility/fetcher.go +10 -0
  43. package/project/.sdk/tm/go/utility/make_url.go +12 -0
  44. package/project/.sdk/tm/lua/feature/test_feature.lua +46 -3
  45. package/project/.sdk/tm/lua/test/runner.lua +74 -0
  46. package/project/.sdk/tm/lua/test/sdk-test-control.json +19 -0
  47. package/project/.sdk/tm/lua/utility/fetcher.lua +13 -0
  48. package/project/.sdk/tm/lua/utility/make_url.lua +16 -0
  49. package/project/.sdk/tm/php/feature/TestFeature.php +192 -43
  50. package/project/.sdk/tm/php/test/Runner.php +62 -0
  51. package/project/.sdk/tm/php/test/sdk-test-control.json +19 -0
  52. package/project/.sdk/tm/php/utility/Fetcher.php +132 -9
  53. package/project/.sdk/tm/php/utility/MakeUrl.php +16 -0
  54. package/project/.sdk/tm/py/feature/test_feature.py +39 -3
  55. package/project/.sdk/tm/py/test/runner.py +60 -0
  56. package/project/.sdk/tm/py/test/sdk-test-control.json +19 -0
  57. package/project/.sdk/tm/py/utility/fetcher.py +13 -0
  58. package/project/.sdk/tm/py/utility/make_url.py +13 -0
  59. package/project/.sdk/tm/rb/feature/test_feature.rb +37 -3
  60. package/project/.sdk/tm/rb/test/runner.rb +46 -0
  61. package/project/.sdk/tm/rb/test/sdk-test-control.json +19 -0
  62. package/project/.sdk/tm/rb/utility/fetcher.rb +49 -28
  63. package/project/.sdk/tm/rb/utility/make_url.rb +16 -0
  64. package/project/.sdk/tm/ts/test/sdk-test-control.json +19 -0
  65. package/project/.sdk/tm/ts/test/utility.ts +120 -2
@@ -4,6 +4,7 @@ declare(strict_types=1);
4
4
  // ProjectName SDK test feature
5
5
 
6
6
  require_once __DIR__ . '/BaseFeature.php';
7
+ require_once __DIR__ . '/../utility/Param.php';
7
8
 
8
9
  class ProjectNameTestFeature extends ProjectNameBaseFeature
9
10
  {
@@ -45,7 +46,7 @@ class ProjectNameTestFeature extends ProjectNameBaseFeature
45
46
  $entity->data = $entity_data;
46
47
 
47
48
  $test_fetcher = function (ProjectNameContext $fctx, string $_fullurl, array $_fetchdef) use ($entity): array {
48
- $respond = function (int $status, mixed $data, ?array $extra): array {
49
+ $respond = function (int $status, mixed $data, ?array $extra = null): array {
49
50
  $out = [
50
51
  'status' => $status,
51
52
  'statusText' => 'OK',
@@ -64,53 +65,122 @@ class ProjectNameTestFeature extends ProjectNameBaseFeature
64
65
  $entname = $op->entity;
65
66
  $entmap = is_array($entity->data[$entname] ?? null) ? $entity->data[$entname] : [];
66
67
 
67
- // Extract id from context: reqmatch for load/remove, reqdata for update/create.
68
- $get_id = function () use ($fctx) {
69
- $sources = [$fctx->reqmatch, $fctx->reqdata, $fctx->data];
70
- foreach ($sources as $src) {
71
- if (is_array($src) && isset($src['id']) && $src['id'] !== '__UNDEFINED__') {
72
- return $src['id'];
68
+ // PHP-portable equivalent of TS buildArgs+select: a flat-key
69
+ // filter that matches by exact-equality on each provided key,
70
+ // with alias fallback. Empty match matches all entries — load
71
+ // with empty match returns the first fixture entry (or last
72
+ // create), list returns all entries.
73
+ $find_first = function (array $entmap, $match, $alias) {
74
+ if (!is_array($match) || empty($match)) {
75
+ foreach ($entmap as $e) {
76
+ if (is_array($e)) return $e;
77
+ }
78
+ return null;
79
+ }
80
+ foreach ($entmap as $e) {
81
+ if (!is_array($e)) continue;
82
+ $ok = true;
83
+ foreach ($match as $k => $v) {
84
+ if ($v === null || $v === '__UNDEFINED__') continue;
85
+ $ev = $e[$k] ?? null;
86
+ if ($ev !== $v) {
87
+ // Try alias key if any
88
+ $ka = is_array($alias) ? ($alias[$k] ?? null) : null;
89
+ $aliased = ($ka !== null) ? ($e[$ka] ?? null) : null;
90
+ if ($aliased !== $v) {
91
+ $ok = false;
92
+ break;
93
+ }
94
+ }
73
95
  }
96
+ if ($ok) return $e;
74
97
  }
75
98
  return null;
76
99
  };
77
100
 
78
- if ($op->name === 'load') {
79
- $id = $get_id();
80
- $ent = ($id !== null && isset($entmap[$id])) ? $entmap[$id] : null;
81
- if (!$ent) {
82
- // Fallback: search by id field value
83
- foreach ($entmap as $e) {
84
- if (is_array($e) && ($e['id'] ?? null) === $id) { $ent = $e; break; }
101
+ $find_all = function (array $entmap, $match, $alias) use ($find_first) {
102
+ if (!is_array($match) || empty($match)) {
103
+ return array_values(array_filter($entmap, 'is_array'));
104
+ }
105
+ $out = [];
106
+ foreach ($entmap as $e) {
107
+ if (!is_array($e)) continue;
108
+ $ok = true;
109
+ foreach ($match as $k => $v) {
110
+ if ($v === null || $v === '__UNDEFINED__') continue;
111
+ $ev = $e[$k] ?? null;
112
+ if ($ev !== $v) {
113
+ $ka = is_array($alias) ? ($alias[$k] ?? null) : null;
114
+ $aliased = ($ka !== null) ? ($e[$ka] ?? null) : null;
115
+ if ($aliased !== $v) { $ok = false; break; }
116
+ }
85
117
  }
118
+ if ($ok) $out[] = $e;
86
119
  }
87
- if (!$ent) {
120
+ return $out;
121
+ };
122
+
123
+ $alias = is_object($op) ? ($op->alias ?? null) : \Voxgig\Struct\Struct::getprop($op, 'alias');
124
+
125
+ // For single-entity ops (load, remove) with an empty explicit
126
+ // match, fall back to the id the entity client already knows from a
127
+ // prior create/load (carried in $fctx->match / $fctx->data). This
128
+ // mirrors the TS mock where param() resolves the id from that
129
+ // accumulated state — e.g. `create()` then `remove([])` removes the
130
+ // just-created entity, not an arbitrary fixture.
131
+ $resolve_match = function ($explicit) use ($fctx) {
132
+ if (is_array($explicit) && !empty($explicit)) return $explicit;
133
+ foreach ([$fctx->match, $fctx->data] as $src) {
134
+ $arr = is_array($src) ? $src : (is_object($src) ? (array) $src : []);
135
+ if (isset($arr['id']) && $arr['id'] !== null && $arr['id'] !== '__UNDEFINED__') {
136
+ return ['id' => $arr['id']];
137
+ }
138
+ }
139
+ return [];
140
+ };
141
+
142
+ if ($op->name === 'load') {
143
+ $ent = $find_first($entmap, $resolve_match($fctx->reqmatch), $alias);
144
+ if ($ent === null) {
88
145
  return $respond(404, null, ['statusText' => 'Not found']);
89
146
  }
147
+ if (is_array($ent)) unset($ent['$KEY']);
90
148
  $out = \Voxgig\Struct\Struct::clone($ent);
91
- if (is_array($out)) { unset($out['$KEY']); }
92
- return $respond(200, $out, null);
149
+ return $respond(200, $out);
93
150
 
94
151
  } elseif ($op->name === 'list') {
95
- $out = [];
96
- foreach ($entmap as $e) {
97
- if (is_array($e)) {
98
- $copy = $e;
99
- unset($copy['$KEY']);
100
- $out[] = $copy;
101
- }
152
+ $found = $find_all($entmap, $fctx->reqmatch, $alias);
153
+ $cleaned = [];
154
+ foreach ($found as $e) {
155
+ if (is_array($e)) unset($e['$KEY']);
156
+ $cleaned[] = $e;
102
157
  }
103
- return $respond(200, $out, null);
158
+ $out = \Voxgig\Struct\Struct::clone($cleaned);
159
+ return $respond(200, $out);
104
160
 
105
161
  } elseif ($op->name === 'update') {
106
- $id = $get_id();
107
- $ent = ($id !== null && isset($entmap[$id])) ? $entmap[$id] : null;
108
- if (!$ent) {
109
- foreach ($entmap as $e) {
110
- if (is_array($e) && ($e['id'] ?? null) === $id) { $ent = $e; break; }
162
+ // Match the existing entity by id only (or its alias). reqdata
163
+ // also contains the new field values, which would otherwise
164
+ // cause find_first to filter out the entity we want to update.
165
+ // When reqdata has no id, fall back to the id the entity
166
+ // client carries from a prior create/load (in $fctx->match /
167
+ // $fctx->data), mirroring the TS mock where param(ctx,'id')
168
+ // resolves from accumulated state.
169
+ $update_match = [];
170
+ if (is_array($fctx->reqdata)) {
171
+ if (array_key_exists('id', $fctx->reqdata)) {
172
+ $update_match['id'] = $fctx->reqdata['id'];
111
173
  }
174
+ $id_alias = is_array($alias) ? ($alias['id'] ?? null) : null;
175
+ if ($id_alias !== null && array_key_exists($id_alias, $fctx->reqdata)) {
176
+ $update_match[$id_alias] = $fctx->reqdata[$id_alias];
177
+ }
178
+ }
179
+ if (empty($update_match)) {
180
+ $update_match = $resolve_match([]);
112
181
  }
113
- if (!$ent) {
182
+ $ent = $find_first($entmap, $update_match, $alias);
183
+ if ($ent === null) {
114
184
  return $respond(404, null, ['statusText' => 'Not found']);
115
185
  }
116
186
  if (is_array($fctx->reqdata)) {
@@ -118,33 +188,42 @@ class ProjectNameTestFeature extends ProjectNameBaseFeature
118
188
  $ent[$k] = $v;
119
189
  }
120
190
  }
121
- $entmap[$id] = $ent;
122
- $entity->data[$entname] = $entmap;
191
+ $id = is_array($ent) ? ($ent['id'] ?? null) : null;
192
+ if ($id !== null) {
193
+ $entmap[$id] = $ent;
194
+ $entity->data[$entname] = $entmap;
195
+ }
196
+ if (is_array($ent)) unset($ent['$KEY']);
123
197
  $out = \Voxgig\Struct\Struct::clone($ent);
124
- if (is_array($out)) { unset($out['$KEY']); }
125
- return $respond(200, $out, null);
198
+ return $respond(200, $out);
126
199
 
127
200
  } elseif ($op->name === 'remove') {
128
- $id = $get_id();
129
- if ($id !== null && isset($entmap[$id])) {
201
+ $ent = $find_first($entmap, $resolve_match($fctx->reqmatch), $alias);
202
+ if ($ent === null) {
203
+ return $respond(404, null, ['statusText' => 'Not found']);
204
+ }
205
+ $id = is_array($ent) ? ($ent['id'] ?? null) : null;
206
+ if ($id !== null) {
130
207
  unset($entmap[$id]);
131
208
  $entity->data[$entname] = $entmap;
132
209
  }
133
- return $respond(200, null, null);
210
+ return $respond(200, null);
134
211
 
135
212
  } elseif ($op->name === 'create') {
136
- $id = $get_id();
137
- if ($id === null) {
138
- $id = sprintf('%04x%04x%04x%04x', random_int(0, 0xFFFF), random_int(0, 0xFFFF), random_int(0, 0xFFFF), random_int(0, 0xFFFF));
213
+ $id = ProjectNameParam::call($fctx, 'id');
214
+ if ($id === null || $id === '__UNDEFINED__') {
215
+ $id = sprintf('%04x%04x%04x%04x',
216
+ random_int(0, 0xFFFF), random_int(0, 0xFFFF),
217
+ random_int(0, 0xFFFF), random_int(0, 0xFFFF));
139
218
  }
140
219
 
141
220
  $ent = is_array($fctx->reqdata) ? $fctx->reqdata : [];
142
221
  $ent['id'] = $id;
143
222
  $entmap[$id] = $ent;
144
223
  $entity->data[$entname] = $entmap;
224
+ if (is_array($ent)) unset($ent['$KEY']);
145
225
  $out = \Voxgig\Struct\Struct::clone($ent);
146
- if (is_array($out)) { unset($out['$KEY']); }
147
- return $respond(200, $out, null);
226
+ return $respond(200, $out);
148
227
 
149
228
  } else {
150
229
  return $respond(404, null, ['statusText' => 'Unknown operation']);
@@ -153,4 +232,74 @@ class ProjectNameTestFeature extends ProjectNameBaseFeature
153
232
 
154
233
  $ctx->utility->fetcher = $test_fetcher;
155
234
  }
235
+
236
+ /**
237
+ * Build a structured `$AND` query from the request match/data dict,
238
+ * matching the TS test feature's buildArgs. Mirrors ts/src/feature/test/TestFeature.ts:158-204.
239
+ *
240
+ * For each key in $args that is 'id' OR a required-param key on the
241
+ * current operation point, emit a `$OR` clause matching the key (and
242
+ * its alias, if any) against the supplied value.
243
+ */
244
+ public function buildArgs(ProjectNameContext $ctx, $op, $args): array
245
+ {
246
+ // If args is empty/missing, return an empty $AND so select() matches
247
+ // every entry — the TS test feature relies on this for empty-match
248
+ // load against fixture entries.
249
+ $keys = is_array($args) ? \Voxgig\Struct\Struct::keysof($args) : [];
250
+ if (empty($keys)) {
251
+ return ['$AND' => []];
252
+ }
253
+
254
+ $opname = is_object($op) ? ($op->name ?? null) : (\Voxgig\Struct\Struct::getprop($op, 'name'));
255
+ $entityName = null;
256
+ if (isset($ctx->entity)) {
257
+ $entityName = is_object($ctx->entity)
258
+ ? ($ctx->entity->name ?? null)
259
+ : (is_array($ctx->entity) ? ($ctx->entity['name'] ?? null) : null);
260
+ }
261
+
262
+ // Resolve required-param names from the op's last point. Defensive:
263
+ // any missing piece falls back to "no required params".
264
+ $reqd_names = [];
265
+ if (is_string($opname) && is_string($entityName) && isset($ctx->config)) {
266
+ $points = \Voxgig\Struct\Struct::getpath(
267
+ ['entity', $entityName, 'op', $opname, 'points'],
268
+ $ctx->config
269
+ );
270
+ $point = \Voxgig\Struct\Struct::getelem($points, -1);
271
+ $params = is_array($point) ? ($point['args']['params'] ?? null) : null;
272
+ if (is_array($params)) {
273
+ foreach ($params as $p) {
274
+ if (is_array($p) && (($p['reqd'] ?? false) === true)) {
275
+ $n = $p['name'] ?? null;
276
+ if ($n !== null) {
277
+ $reqd_names[] = $n;
278
+ }
279
+ }
280
+ }
281
+ }
282
+ }
283
+
284
+ $alias = is_object($op) ? ($op->alias ?? null) : \Voxgig\Struct\Struct::getprop($op, 'alias');
285
+ $qand = [];
286
+
287
+ foreach ($keys as $k) {
288
+ $is_id = ($k === 'id');
289
+ $in_reqd = in_array($k, $reqd_names, true);
290
+ if ($is_id || $in_reqd) {
291
+ $v = ProjectNameParam::call($ctx, $k);
292
+ $ka = \Voxgig\Struct\Struct::getprop($alias, $k);
293
+
294
+ $qor = [[$k => $v]];
295
+ if ($ka !== null) {
296
+ $qor[] = [$ka => $v];
297
+ }
298
+
299
+ $qand[] = ['$OR' => $qor];
300
+ }
301
+ }
302
+
303
+ return ['$AND' => $qand];
304
+ }
156
305
  }
@@ -86,6 +86,68 @@ class ProjectNameTestRunner
86
86
  }
87
87
  return $out;
88
88
  }
89
+
90
+ private static $test_control = null;
91
+
92
+ /**
93
+ * Load sdk-test-control.json from this test dir; cache. Returns an
94
+ * empty-skip default if the file is missing or invalid.
95
+ */
96
+ public static function load_test_control(): array
97
+ {
98
+ if (self::$test_control !== null) {
99
+ return self::$test_control;
100
+ }
101
+ $ctrl_path = __DIR__ . '/sdk-test-control.json';
102
+ $default = [
103
+ 'version' => 1,
104
+ 'test' => ['skip' => [
105
+ 'live' => ['direct' => [], 'entityOp' => []],
106
+ 'unit' => ['direct' => [], 'entityOp' => []],
107
+ ]],
108
+ ];
109
+ if (!file_exists($ctrl_path)) {
110
+ self::$test_control = $default;
111
+ return self::$test_control;
112
+ }
113
+ $content = file_get_contents($ctrl_path);
114
+ $parsed = json_decode($content, true);
115
+ self::$test_control = is_array($parsed) ? $parsed : $default;
116
+ return self::$test_control;
117
+ }
118
+
119
+ /**
120
+ * Check sdk-test-control.json for a skip entry. Returns [skip, reason].
121
+ */
122
+ public static function is_control_skipped(string $kind, string $name, string $mode): array
123
+ {
124
+ $ctrl = self::load_test_control();
125
+ $skip = $ctrl['test']['skip'][$mode] ?? [];
126
+ $items = $skip[$kind] ?? [];
127
+ foreach ($items as $item) {
128
+ if ($kind === 'direct' && ($item['test'] ?? null) === $name) {
129
+ return [true, $item['reason'] ?? null];
130
+ }
131
+ if ($kind === 'entityOp') {
132
+ $key = ($item['entity'] ?? '') . '.' . ($item['op'] ?? '');
133
+ if ($key === $name) {
134
+ return [true, $item['reason'] ?? null];
135
+ }
136
+ }
137
+ }
138
+ return [false, null];
139
+ }
140
+
141
+ /** Per-test live pacing delay (ms); default 500. */
142
+ public static function live_delay_ms(): int
143
+ {
144
+ $ctrl = self::load_test_control();
145
+ $v = $ctrl['test']['live']['delayMs'] ?? null;
146
+ if (is_int($v) && $v >= 0) {
147
+ return $v;
148
+ }
149
+ return 500;
150
+ }
89
151
  }
90
152
 
91
153
  // Aliases for test convenience.
@@ -0,0 +1,19 @@
1
+ {
2
+ "version": 1,
3
+ "_doc": "Per-SDK test control. Lists tests/operations to skip in unit and live modes; tunes per-test pacing. Edit by hand. Loaded by php test runner.",
4
+ "test": {
5
+ "skip": {
6
+ "live": {
7
+ "direct": [],
8
+ "entityOp": []
9
+ },
10
+ "unit": {
11
+ "direct": [],
12
+ "entityOp": []
13
+ }
14
+ },
15
+ "live": {
16
+ "delayMs": 500
17
+ }
18
+ }
19
+ }
@@ -5,25 +5,47 @@ declare(strict_types=1);
5
5
 
6
6
  class ProjectNameFetcher
7
7
  {
8
+ // Default User-Agent — many CDNs (notably Cloudflare) reject requests
9
+ // with PHP's default UA (which file_get_contents doesn't even set),
10
+ // returning 403 before the request reaches the origin. Set a Mozilla-
11
+ // shaped UA so the SDK behaves like every other HTTP client by default.
12
+ // Users can override by passing a User-Agent header in fetchdef.
13
+ public const DEFAULT_USER_AGENT = 'Mozilla/5.0 (compatible; ProjectNameSDK/1.0)';
14
+
8
15
  public static function defaultHttpFetch(string $fullurl, array $fetchdef): array
9
16
  {
10
- $method_str = $fetchdef['method'] ?? 'GET';
17
+ $method_str = strtoupper($fetchdef['method'] ?? 'GET');
11
18
  $body_str = $fetchdef['body'] ?? null;
12
19
  $headers = $fetchdef['headers'] ?? [];
13
20
 
14
- $opts = [
15
- 'http' => [
16
- 'method' => strtoupper($method_str),
17
- 'ignore_errors' => true,
18
- ],
19
- ];
20
-
21
21
  $header_lines = [];
22
+ $has_ua = false;
22
23
  foreach ($headers as $k => $v) {
23
24
  if (is_string($v)) {
25
+ if (strcasecmp($k, 'user-agent') === 0) {
26
+ $has_ua = true;
27
+ }
24
28
  $header_lines[] = "{$k}: {$v}";
25
29
  }
26
30
  }
31
+ if (!$has_ua) {
32
+ $header_lines[] = 'User-Agent: ' . self::DEFAULT_USER_AGENT;
33
+ }
34
+
35
+ // Prefer cURL when available — its header capture is reliable across
36
+ // PHP versions, while file_get_contents + custom stream wrappers
37
+ // don't propagate $http_response_header for user-defined wrappers
38
+ // in PHP 8.3+.
39
+ if (function_exists('curl_init')) {
40
+ return self::curlFetch($fullurl, $method_str, $body_str, $header_lines);
41
+ }
42
+
43
+ $opts = [
44
+ 'http' => [
45
+ 'method' => $method_str,
46
+ 'ignore_errors' => true,
47
+ ],
48
+ ];
27
49
 
28
50
  if (is_string($body_str)) {
29
51
  $opts['http']['content'] = $body_str;
@@ -78,6 +100,101 @@ class ProjectNameFetcher
78
100
  ];
79
101
  }
80
102
 
103
+ private static function curlFetch(string $fullurl, string $method, $body_str, array $header_lines): array
104
+ {
105
+ // GET-only guard for live tests — set by pub-live-test. We respect
106
+ // it here at the SDK level since cURL bypasses PHP stream wrappers
107
+ // (so the wrapper-based guard wouldn't see this call).
108
+ if (getenv('VOXGIG_GETONLY_GUARD') === 'TRUE' && $method !== 'GET') {
109
+ fwrite(STDERR, "[GET-ONLY GUARD] blocked {$method} {$fullurl}\n");
110
+ return [
111
+ [
112
+ 'status' => 0,
113
+ 'statusText' => 'Blocked',
114
+ 'headers' => [],
115
+ 'json' => function () { return null; },
116
+ 'body' => '',
117
+ ],
118
+ "GET-only guard blocked {$method} {$fullurl}",
119
+ ];
120
+ }
121
+
122
+ $ch = curl_init($fullurl);
123
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
124
+ curl_setopt($ch, CURLOPT_HEADER, false);
125
+ curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);
126
+ curl_setopt($ch, CURLOPT_TIMEOUT, 30);
127
+ curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
128
+ curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
129
+ if (!empty($header_lines)) {
130
+ curl_setopt($ch, CURLOPT_HTTPHEADER, $header_lines);
131
+ }
132
+ if (is_string($body_str)) {
133
+ curl_setopt($ch, CURLOPT_POSTFIELDS, $body_str);
134
+ }
135
+
136
+ $resp_headers = [];
137
+ curl_setopt($ch, CURLOPT_HEADERFUNCTION,
138
+ function ($_ch, $hdr) use (&$resp_headers) {
139
+ $trimmed = trim($hdr);
140
+ if ($trimmed !== '') {
141
+ $resp_headers[] = $trimmed;
142
+ }
143
+ return strlen($hdr);
144
+ });
145
+
146
+ $response_body = curl_exec($ch);
147
+ if ($response_body === false) {
148
+ $err = curl_error($ch) ?: 'curl_exec failed';
149
+ curl_close($ch);
150
+ return [
151
+ [
152
+ 'status' => 0,
153
+ 'statusText' => 'Error',
154
+ 'headers' => [],
155
+ 'json' => function () { return null; },
156
+ 'body' => '',
157
+ ],
158
+ $err,
159
+ ];
160
+ }
161
+ curl_close($ch);
162
+
163
+ $status = 0;
164
+ $status_text = '';
165
+ $resp_kv = [];
166
+ foreach ($resp_headers as $h) {
167
+ if (preg_match('/^HTTP\/\S+\s+(\d+)\s*(.*)$/i', $h, $m)) {
168
+ $status = (int)$m[1];
169
+ $status_text = trim($m[2]);
170
+ } else {
171
+ $parts = explode(':', $h, 2);
172
+ if (count($parts) === 2) {
173
+ $resp_kv[strtolower(trim($parts[0]))] = trim($parts[1]);
174
+ }
175
+ }
176
+ }
177
+
178
+ $json_body = null;
179
+ if ($response_body !== '' && $response_body !== false) {
180
+ $decoded = json_decode($response_body, true);
181
+ if (json_last_error() === JSON_ERROR_NONE) {
182
+ $json_body = $decoded;
183
+ }
184
+ }
185
+
186
+ return [
187
+ [
188
+ 'status' => $status,
189
+ 'statusText' => $status_text,
190
+ 'headers' => $resp_kv,
191
+ 'json' => function () use ($json_body) { return $json_body; },
192
+ 'body' => (string)$response_body,
193
+ ],
194
+ null,
195
+ ];
196
+ }
197
+
81
198
  public static function call(ProjectNameContext $ctx, string $fullurl, array $fetchdef): array
82
199
  {
83
200
  if ($ctx->client->mode !== 'live') {
@@ -93,7 +210,13 @@ class ProjectNameFetcher
93
210
 
94
211
  $sys_fetch = \Voxgig\Struct\Struct::getpath($options, 'system.fetch');
95
212
 
96
- if ($sys_fetch === null) {
213
+ // Treat null OR an empty stdClass/array as "no fetcher provided" and
214
+ // fall through to the default HTTP fetcher. The options builder
215
+ // sometimes materializes `system.fetch` as an empty stdClass even
216
+ // when the user didn't set one — that's a placeholder, not a value.
217
+ $is_empty_obj = ($sys_fetch instanceof \stdClass) && empty(get_object_vars($sys_fetch));
218
+ $is_empty_arr = is_array($sys_fetch) && empty($sys_fetch);
219
+ if ($sys_fetch === null || $is_empty_obj || $is_empty_arr) {
97
220
  return self::defaultHttpFetch($fullurl, $fetchdef);
98
221
  }
99
222
  if (is_callable($sys_fetch)) {
@@ -35,6 +35,22 @@ class ProjectNameMakeUrl
35
35
  }
36
36
  }
37
37
 
38
+ // Append query string from spec.query.
39
+ $qsep = '?';
40
+ $query_items = \Voxgig\Struct\Struct::items($spec->query ?? null);
41
+ if ($query_items) {
42
+ foreach ($query_items as $item) {
43
+ $key = $item[0];
44
+ $val = $item[1];
45
+ if ($val !== null && is_string($key)) {
46
+ $val_str = is_string($val) ? $val : (string)$val;
47
+ $url .= $qsep . \Voxgig\Struct\Struct::escurl($key) . '=' . \Voxgig\Struct\Struct::escurl($val_str);
48
+ $qsep = '&';
49
+ $resmatch[$key] = $val;
50
+ }
51
+ }
52
+ }
53
+
38
54
  $result->resmatch = $resmatch;
39
55
  return [$url, null];
40
56
  }
@@ -52,8 +52,21 @@ class ProjectNameTestFeature(ProjectNameBaseFeature):
52
52
  if not isinstance(entmap, dict):
53
53
  entmap = {}
54
54
 
55
+ # For single-entity ops (load, remove) with an empty explicit
56
+ # match, fall back to the id the entity client already knows from a
57
+ # prior create/load (in fctx.match / fctx.data). Mirrors the TS
58
+ # mock where param() resolves the id from that accumulated state.
59
+ def _resolve_match(explicit):
60
+ if isinstance(explicit, dict) and len(explicit) > 0:
61
+ return explicit
62
+ for src in (getattr(fctx, "match", None), getattr(fctx, "data", None)):
63
+ v = vs.getprop(src, "id") if src is not None else None
64
+ if v is not None and v != "__UNDEFINED__":
65
+ return {"id": v}
66
+ return {}
67
+
55
68
  if op.name == "load":
56
- args = test_self.build_args(fctx, op, fctx.reqmatch)
69
+ args = test_self.build_args(fctx, op, _resolve_match(fctx.reqmatch))
57
70
  found = vs.select(entmap, args)
58
71
  ent = vs.getelem(found, 0)
59
72
  if ent is None:
@@ -74,9 +87,32 @@ class ProjectNameTestFeature(ProjectNameBaseFeature):
74
87
  return respond(200, out)
75
88
 
76
89
  elif op.name == "update":
77
- args = test_self.build_args(fctx, op, fctx.reqdata)
90
+ # Match the existing entity by id only (or its alias). reqdata
91
+ # also contains the new field values, which would otherwise
92
+ # cause select to filter out the entity we want to update.
93
+ # When reqdata has no id, fall back to the id the entity
94
+ # client carries from a prior create/load (in fctx.match /
95
+ # fctx.data), mirroring the TS mock where param(ctx,'id')
96
+ # resolves from accumulated state.
97
+ update_match = {}
98
+ if isinstance(fctx.reqdata, dict):
99
+ if "id" in fctx.reqdata:
100
+ update_match["id"] = fctx.reqdata["id"]
101
+ alias_map = getattr(op, "alias_map", None)
102
+ if alias_map is not None:
103
+ alias_id = vs.getprop(alias_map, "id")
104
+ if alias_id is not None and alias_id in fctx.reqdata:
105
+ update_match[alias_id] = fctx.reqdata[alias_id]
106
+ if not update_match:
107
+ update_match = _resolve_match({})
108
+ args = test_self.build_args(fctx, op, update_match)
78
109
  found = vs.select(entmap, args)
79
110
  ent = vs.getelem(found, 0)
111
+ if ent is None and isinstance(entmap, dict):
112
+ for e in entmap.values():
113
+ if isinstance(e, dict):
114
+ ent = e
115
+ break
80
116
  if ent is None:
81
117
  return respond(404, None, {"statusText": "Not found"})
82
118
  if isinstance(ent, dict):
@@ -89,7 +125,7 @@ class ProjectNameTestFeature(ProjectNameBaseFeature):
89
125
  return respond(200, out)
90
126
 
91
127
  elif op.name == "remove":
92
- args = test_self.build_args(fctx, op, fctx.reqmatch)
128
+ args = test_self.build_args(fctx, op, _resolve_match(fctx.reqmatch))
93
129
  found = vs.select(entmap, args)
94
130
  ent = vs.getelem(found, 0)
95
131
  if ent is None: