@voxgig/sdkgen 0.44.0 → 1.0.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 (160) hide show
  1. package/bin/voxgig-sdkgen +1 -1
  2. package/dist/cmp/ReadmeEntity.js +9 -153
  3. package/dist/cmp/ReadmeEntity.js.map +1 -1
  4. package/dist/cmp/ReadmeIntro.js +9 -14
  5. package/dist/cmp/ReadmeIntro.js.map +1 -1
  6. package/dist/cmp/ReadmeModel.js +6 -4
  7. package/dist/cmp/ReadmeModel.js.map +1 -1
  8. package/dist/cmp/ReadmeOptions.js +9 -61
  9. package/dist/cmp/ReadmeOptions.js.map +1 -1
  10. package/dist/cmp/ReadmeRef.js +10 -1328
  11. package/dist/cmp/ReadmeRef.js.map +1 -1
  12. package/dist/sdkgen.d.ts +2 -2
  13. package/dist/sdkgen.js +2 -1
  14. package/dist/sdkgen.js.map +1 -1
  15. package/dist/utility.d.ts +2 -1
  16. package/dist/utility.js +9 -0
  17. package/dist/utility.js.map +1 -1
  18. package/package.json +3 -3
  19. package/project/.sdk/src/cmp/go/Config_go.ts +9 -4
  20. package/project/.sdk/src/cmp/go/Entity_go.ts +2 -2
  21. package/project/.sdk/src/cmp/go/Main_go.ts +8 -4
  22. package/project/.sdk/src/cmp/go/Package_go.ts +2 -2
  23. package/project/.sdk/src/cmp/go/ReadmeEntity_go.ts +138 -0
  24. package/project/.sdk/src/cmp/go/ReadmeExplanation_go.ts +2 -2
  25. package/project/.sdk/src/cmp/go/ReadmeHowto_go.ts +8 -5
  26. package/project/.sdk/src/cmp/go/ReadmeInstall_go.ts +2 -2
  27. package/project/.sdk/src/cmp/go/ReadmeIntro_go.ts +18 -0
  28. package/project/.sdk/src/cmp/go/ReadmeModel_go.ts +8 -5
  29. package/project/.sdk/src/cmp/go/ReadmeOptions_go.ts +58 -0
  30. package/project/.sdk/src/cmp/go/ReadmeQuick_go.ts +13 -9
  31. package/project/.sdk/src/cmp/go/ReadmeRef_go.ts +354 -0
  32. package/project/.sdk/src/cmp/go/ReadmeTopQuick_go.ts +8 -6
  33. package/project/.sdk/src/cmp/go/ReadmeTopTest_go.ts +2 -2
  34. package/project/.sdk/src/cmp/go/TestDirect_go.ts +222 -41
  35. package/project/.sdk/src/cmp/go/TestEntity_go.ts +142 -60
  36. package/project/.sdk/src/cmp/go/Test_go.ts +2 -2
  37. package/project/.sdk/src/cmp/go/fragment/Main.fragment.go +21 -4
  38. package/project/.sdk/src/cmp/js/Config_js.ts +18 -0
  39. package/project/.sdk/src/cmp/js/ReadmeEntity_js.ts +138 -0
  40. package/project/.sdk/src/cmp/js/ReadmeHowto_js.ts +11 -6
  41. package/project/.sdk/src/cmp/js/ReadmeIntro_js.ts +18 -0
  42. package/project/.sdk/src/cmp/js/ReadmeModel_js.ts +6 -3
  43. package/project/.sdk/src/cmp/js/ReadmeOptions_js.ts +58 -0
  44. package/project/.sdk/src/cmp/js/ReadmeQuick_js.ts +6 -4
  45. package/project/.sdk/src/cmp/js/ReadmeRef_js.ts +384 -0
  46. package/project/.sdk/src/cmp/js/ReadmeTopQuick_js.ts +6 -4
  47. package/project/.sdk/src/cmp/js/TestDirect_js.ts +23 -12
  48. package/project/.sdk/src/cmp/js/TestEntity_js.ts +107 -74
  49. package/project/.sdk/src/cmp/js/fragment/Config.fragment.js +1 -5
  50. package/project/.sdk/src/cmp/lua/Config_lua.ts +9 -4
  51. package/project/.sdk/src/cmp/lua/Package_lua.ts +9 -2
  52. package/project/.sdk/src/cmp/lua/ReadmeEntity_lua.ts +138 -0
  53. package/project/.sdk/src/cmp/lua/ReadmeHowto_lua.ts +6 -3
  54. package/project/.sdk/src/cmp/lua/ReadmeIntro_lua.ts +18 -0
  55. package/project/.sdk/src/cmp/lua/ReadmeModel_lua.ts +6 -3
  56. package/project/.sdk/src/cmp/lua/ReadmeOptions_lua.ts +58 -0
  57. package/project/.sdk/src/cmp/lua/ReadmeQuick_lua.ts +6 -4
  58. package/project/.sdk/src/cmp/lua/ReadmeRef_lua.ts +360 -0
  59. package/project/.sdk/src/cmp/lua/ReadmeTopQuick_lua.ts +6 -4
  60. package/project/.sdk/src/cmp/lua/TestDirect_lua.ts +172 -29
  61. package/project/.sdk/src/cmp/lua/TestEntity_lua.ts +120 -52
  62. package/project/.sdk/src/cmp/lua/fragment/Main.fragment.lua +20 -4
  63. package/project/.sdk/src/cmp/php/Config_php.ts +10 -8
  64. package/project/.sdk/src/cmp/php/Package_php.ts +7 -1
  65. package/project/.sdk/src/cmp/php/ReadmeEntity_php.ts +138 -0
  66. package/project/.sdk/src/cmp/php/ReadmeHowto_php.ts +6 -3
  67. package/project/.sdk/src/cmp/php/ReadmeIntro_php.ts +18 -0
  68. package/project/.sdk/src/cmp/php/ReadmeModel_php.ts +6 -3
  69. package/project/.sdk/src/cmp/php/ReadmeOptions_php.ts +58 -0
  70. package/project/.sdk/src/cmp/php/ReadmeQuick_php.ts +6 -4
  71. package/project/.sdk/src/cmp/php/ReadmeRef_php.ts +358 -0
  72. package/project/.sdk/src/cmp/php/ReadmeTopQuick_php.ts +6 -4
  73. package/project/.sdk/src/cmp/php/TestDirect_php.ts +171 -28
  74. package/project/.sdk/src/cmp/php/TestEntity_php.ts +126 -55
  75. package/project/.sdk/src/cmp/php/fragment/Main.fragment.php +17 -3
  76. package/project/.sdk/src/cmp/py/Config_py.ts +9 -4
  77. package/project/.sdk/src/cmp/py/Package_py.ts +8 -1
  78. package/project/.sdk/src/cmp/py/ReadmeEntity_py.ts +138 -0
  79. package/project/.sdk/src/cmp/py/ReadmeHowto_py.ts +6 -3
  80. package/project/.sdk/src/cmp/py/ReadmeIntro_py.ts +18 -0
  81. package/project/.sdk/src/cmp/py/ReadmeModel_py.ts +6 -3
  82. package/project/.sdk/src/cmp/py/ReadmeOptions_py.ts +58 -0
  83. package/project/.sdk/src/cmp/py/ReadmeQuick_py.ts +9 -6
  84. package/project/.sdk/src/cmp/py/ReadmeRef_py.ts +356 -0
  85. package/project/.sdk/src/cmp/py/ReadmeTopQuick_py.ts +9 -6
  86. package/project/.sdk/src/cmp/py/TestDirect_py.ts +164 -27
  87. package/project/.sdk/src/cmp/py/TestEntity_py.ts +125 -51
  88. package/project/.sdk/src/cmp/py/fragment/Main.fragment.py +19 -4
  89. package/project/.sdk/src/cmp/rb/Config_rb.ts +9 -4
  90. package/project/.sdk/src/cmp/rb/Package_rb.ts +9 -2
  91. package/project/.sdk/src/cmp/rb/ReadmeEntity_rb.ts +138 -0
  92. package/project/.sdk/src/cmp/rb/ReadmeHowto_rb.ts +6 -3
  93. package/project/.sdk/src/cmp/rb/ReadmeIntro_rb.ts +18 -0
  94. package/project/.sdk/src/cmp/rb/ReadmeModel_rb.ts +6 -3
  95. package/project/.sdk/src/cmp/rb/ReadmeOptions_rb.ts +58 -0
  96. package/project/.sdk/src/cmp/rb/ReadmeQuick_rb.ts +6 -4
  97. package/project/.sdk/src/cmp/rb/ReadmeRef_rb.ts +361 -0
  98. package/project/.sdk/src/cmp/rb/ReadmeTopQuick_rb.ts +6 -4
  99. package/project/.sdk/src/cmp/rb/TestDirect_rb.ts +172 -29
  100. package/project/.sdk/src/cmp/rb/TestEntity_rb.ts +120 -52
  101. package/project/.sdk/src/cmp/rb/fragment/Main.fragment.rb +19 -3
  102. package/project/.sdk/src/cmp/ts/Config_ts.ts +18 -0
  103. package/project/.sdk/src/cmp/ts/Package_ts.ts +1 -1
  104. package/project/.sdk/src/cmp/ts/ReadmeEntity_ts.ts +138 -0
  105. package/project/.sdk/src/cmp/ts/ReadmeHowto_ts.ts +11 -6
  106. package/project/.sdk/src/cmp/ts/ReadmeIntro_ts.ts +18 -0
  107. package/project/.sdk/src/cmp/ts/ReadmeModel_ts.ts +9 -5
  108. package/project/.sdk/src/cmp/ts/ReadmeOptions_ts.ts +58 -0
  109. package/project/.sdk/src/cmp/ts/ReadmeQuick_ts.ts +6 -4
  110. package/project/.sdk/src/cmp/ts/ReadmeRef_ts.ts +384 -0
  111. package/project/.sdk/src/cmp/ts/ReadmeTopQuick_ts.ts +6 -4
  112. package/project/.sdk/src/cmp/ts/TestDirect_ts.ts +213 -42
  113. package/project/.sdk/src/cmp/ts/TestEntity_ts.ts +168 -75
  114. package/project/.sdk/src/cmp/ts/fragment/Config.fragment.ts +1 -5
  115. package/project/.sdk/src/cmp/ts/fragment/Direct.test.fragment.ts +8 -1
  116. package/project/.sdk/src/cmp/ts/fragment/Entity.test.fragment.ts +8 -2
  117. package/project/.sdk/src/cmp/ts/fragment/Main.fragment.ts +21 -1
  118. package/project/.sdk/tm/go/feature/test_feature.go +51 -3
  119. package/project/.sdk/tm/go/test/runner_test.go +106 -6
  120. package/project/.sdk/tm/go/test/sdk-test-control.json +19 -0
  121. package/project/.sdk/tm/go/utility/fetcher.go +10 -0
  122. package/project/.sdk/tm/go/utility/make_url.go +12 -0
  123. package/project/.sdk/tm/go/utility/prepare_auth.go +15 -1
  124. package/project/.sdk/tm/js/src/utility/PrepareAuthUtility.js +7 -1
  125. package/project/.sdk/tm/lua/feature/test_feature.lua +41 -3
  126. package/project/.sdk/tm/lua/test/runner.lua +74 -0
  127. package/project/.sdk/tm/lua/test/sdk-test-control.json +19 -0
  128. package/project/.sdk/tm/lua/utility/fetcher.lua +13 -0
  129. package/project/.sdk/tm/lua/utility/make_url.lua +16 -0
  130. package/project/.sdk/tm/lua/utility/prepare_auth.lua +9 -1
  131. package/project/.sdk/tm/php/feature/TestFeature.php +185 -43
  132. package/project/.sdk/tm/php/test/Runner.php +62 -0
  133. package/project/.sdk/tm/php/test/sdk-test-control.json +19 -0
  134. package/project/.sdk/tm/php/utility/Fetcher.php +132 -9
  135. package/project/.sdk/tm/php/utility/MakeUrl.php +16 -0
  136. package/project/.sdk/tm/php/utility/PrepareAuth.php +11 -1
  137. package/project/.sdk/tm/py/feature/test_feature.py +35 -3
  138. package/project/.sdk/tm/py/test/runner.py +60 -0
  139. package/project/.sdk/tm/py/test/sdk-test-control.json +19 -0
  140. package/project/.sdk/tm/py/utility/fetcher.py +13 -0
  141. package/project/.sdk/tm/py/utility/make_url.py +13 -0
  142. package/project/.sdk/tm/py/utility/prepare_auth.py +10 -1
  143. package/project/.sdk/tm/rb/feature/test_feature.rb +36 -3
  144. package/project/.sdk/tm/rb/test/runner.rb +46 -0
  145. package/project/.sdk/tm/rb/test/sdk-test-control.json +19 -0
  146. package/project/.sdk/tm/rb/utility/fetcher.rb +49 -28
  147. package/project/.sdk/tm/rb/utility/make_url.rb +16 -0
  148. package/project/.sdk/tm/rb/utility/prepare_auth.rb +8 -1
  149. package/project/.sdk/tm/ts/src/utility/MakeUrlUtility.ts +7 -8
  150. package/project/.sdk/tm/ts/src/utility/PrepareAuthUtility.ts +7 -1
  151. package/project/.sdk/tm/ts/test/sdk-test-control.json +19 -0
  152. package/project/.sdk/tm/ts/test/utility.ts +120 -2
  153. package/src/cmp/ReadmeEntity.ts +11 -178
  154. package/src/cmp/ReadmeIntro.ts +11 -25
  155. package/src/cmp/ReadmeModel.ts +7 -5
  156. package/src/cmp/ReadmeOptions.ts +12 -74
  157. package/src/cmp/ReadmeRef.ts +11 -1372
  158. package/src/sdkgen.ts +2 -1
  159. package/src/utility.ts +12 -0
  160. /package/project/.sdk/tm/go/utility/{make_target.go → make_point.go} +0 -0
@@ -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,115 @@ 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
+ }
117
+ }
118
+ if ($ok) $out[] = $e;
119
+ }
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']];
85
137
  }
86
138
  }
87
- if (!$ent) {
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
+ $update_match = [];
166
+ if (is_array($fctx->reqdata)) {
167
+ if (array_key_exists('id', $fctx->reqdata)) {
168
+ $update_match['id'] = $fctx->reqdata['id'];
169
+ }
170
+ $id_alias = is_array($alias) ? ($alias['id'] ?? null) : null;
171
+ if ($id_alias !== null && array_key_exists($id_alias, $fctx->reqdata)) {
172
+ $update_match[$id_alias] = $fctx->reqdata[$id_alias];
111
173
  }
112
174
  }
113
- if (!$ent) {
175
+ $ent = $find_first($entmap, $update_match, $alias);
176
+ if ($ent === null) {
114
177
  return $respond(404, null, ['statusText' => 'Not found']);
115
178
  }
116
179
  if (is_array($fctx->reqdata)) {
@@ -118,33 +181,42 @@ class ProjectNameTestFeature extends ProjectNameBaseFeature
118
181
  $ent[$k] = $v;
119
182
  }
120
183
  }
121
- $entmap[$id] = $ent;
122
- $entity->data[$entname] = $entmap;
184
+ $id = is_array($ent) ? ($ent['id'] ?? null) : null;
185
+ if ($id !== null) {
186
+ $entmap[$id] = $ent;
187
+ $entity->data[$entname] = $entmap;
188
+ }
189
+ if (is_array($ent)) unset($ent['$KEY']);
123
190
  $out = \Voxgig\Struct\Struct::clone($ent);
124
- if (is_array($out)) { unset($out['$KEY']); }
125
- return $respond(200, $out, null);
191
+ return $respond(200, $out);
126
192
 
127
193
  } elseif ($op->name === 'remove') {
128
- $id = $get_id();
129
- if ($id !== null && isset($entmap[$id])) {
194
+ $ent = $find_first($entmap, $resolve_match($fctx->reqmatch), $alias);
195
+ if ($ent === null) {
196
+ return $respond(404, null, ['statusText' => 'Not found']);
197
+ }
198
+ $id = is_array($ent) ? ($ent['id'] ?? null) : null;
199
+ if ($id !== null) {
130
200
  unset($entmap[$id]);
131
201
  $entity->data[$entname] = $entmap;
132
202
  }
133
- return $respond(200, null, null);
203
+ return $respond(200, null);
134
204
 
135
205
  } 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));
206
+ $id = ProjectNameParam::call($fctx, 'id');
207
+ if ($id === null || $id === '__UNDEFINED__') {
208
+ $id = sprintf('%04x%04x%04x%04x',
209
+ random_int(0, 0xFFFF), random_int(0, 0xFFFF),
210
+ random_int(0, 0xFFFF), random_int(0, 0xFFFF));
139
211
  }
140
212
 
141
213
  $ent = is_array($fctx->reqdata) ? $fctx->reqdata : [];
142
214
  $ent['id'] = $id;
143
215
  $entmap[$id] = $ent;
144
216
  $entity->data[$entname] = $entmap;
217
+ if (is_array($ent)) unset($ent['$KEY']);
145
218
  $out = \Voxgig\Struct\Struct::clone($ent);
146
- if (is_array($out)) { unset($out['$KEY']); }
147
- return $respond(200, $out, null);
219
+ return $respond(200, $out);
148
220
 
149
221
  } else {
150
222
  return $respond(404, null, ['statusText' => 'Unknown operation']);
@@ -153,4 +225,74 @@ class ProjectNameTestFeature extends ProjectNameBaseFeature
153
225
 
154
226
  $ctx->utility->fetcher = $test_fetcher;
155
227
  }
228
+
229
+ /**
230
+ * Build a structured `$AND` query from the request match/data dict,
231
+ * matching the TS test feature's buildArgs. Mirrors ts/src/feature/test/TestFeature.ts:158-204.
232
+ *
233
+ * For each key in $args that is 'id' OR a required-param key on the
234
+ * current operation point, emit a `$OR` clause matching the key (and
235
+ * its alias, if any) against the supplied value.
236
+ */
237
+ public function buildArgs(ProjectNameContext $ctx, $op, $args): array
238
+ {
239
+ // If args is empty/missing, return an empty $AND so select() matches
240
+ // every entry — the TS test feature relies on this for empty-match
241
+ // load against fixture entries.
242
+ $keys = is_array($args) ? \Voxgig\Struct\Struct::keysof($args) : [];
243
+ if (empty($keys)) {
244
+ return ['$AND' => []];
245
+ }
246
+
247
+ $opname = is_object($op) ? ($op->name ?? null) : (\Voxgig\Struct\Struct::getprop($op, 'name'));
248
+ $entityName = null;
249
+ if (isset($ctx->entity)) {
250
+ $entityName = is_object($ctx->entity)
251
+ ? ($ctx->entity->name ?? null)
252
+ : (is_array($ctx->entity) ? ($ctx->entity['name'] ?? null) : null);
253
+ }
254
+
255
+ // Resolve required-param names from the op's last point. Defensive:
256
+ // any missing piece falls back to "no required params".
257
+ $reqd_names = [];
258
+ if (is_string($opname) && is_string($entityName) && isset($ctx->config)) {
259
+ $points = \Voxgig\Struct\Struct::getpath(
260
+ ['entity', $entityName, 'op', $opname, 'points'],
261
+ $ctx->config
262
+ );
263
+ $point = \Voxgig\Struct\Struct::getelem($points, -1);
264
+ $params = is_array($point) ? ($point['args']['params'] ?? null) : null;
265
+ if (is_array($params)) {
266
+ foreach ($params as $p) {
267
+ if (is_array($p) && (($p['reqd'] ?? false) === true)) {
268
+ $n = $p['name'] ?? null;
269
+ if ($n !== null) {
270
+ $reqd_names[] = $n;
271
+ }
272
+ }
273
+ }
274
+ }
275
+ }
276
+
277
+ $alias = is_object($op) ? ($op->alias ?? null) : \Voxgig\Struct\Struct::getprop($op, 'alias');
278
+ $qand = [];
279
+
280
+ foreach ($keys as $k) {
281
+ $is_id = ($k === 'id');
282
+ $in_reqd = in_array($k, $reqd_names, true);
283
+ if ($is_id || $in_reqd) {
284
+ $v = ProjectNameParam::call($ctx, $k);
285
+ $ka = \Voxgig\Struct\Struct::getprop($alias, $k);
286
+
287
+ $qor = [[$k => $v]];
288
+ if ($ka !== null) {
289
+ $qor[] = [$ka => $v];
290
+ }
291
+
292
+ $qand[] = ['$OR' => $qor];
293
+ }
294
+ }
295
+
296
+ return ['$AND' => $qand];
297
+ }
156
298
  }
@@ -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
  }
@@ -18,9 +18,19 @@ class ProjectNamePrepareAuth
18
18
 
19
19
  $headers = &$spec->headers;
20
20
  $options = $ctx->client->options_map();
21
+
22
+ // Public APIs that need no auth omit the options.auth block entirely.
23
+ if (!isset($options['auth']) || $options['auth'] === null) {
24
+ unset($headers[self::HEADER_AUTH]);
25
+ return [$spec, null];
26
+ }
27
+
21
28
  $apikey = \Voxgig\Struct\Struct::getprop($options, self::OPTION_APIKEY, self::NOT_FOUND);
22
29
 
23
- if (is_string($apikey) && $apikey === self::NOT_FOUND) {
30
+ if (
31
+ (is_string($apikey) && ($apikey === self::NOT_FOUND || $apikey === ''))
32
+ || $apikey === null
33
+ ) {
24
34
  unset($headers[self::HEADER_AUTH]);
25
35
  } else {
26
36
  $auth_prefix = \Voxgig\Struct\Struct::getpath($options, 'auth.prefix') ?? '';
@@ -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,28 @@ 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
+ # Falls back to first entity when no match found, mirroring
94
+ # the TS mock.
95
+ update_match = {}
96
+ if isinstance(fctx.reqdata, dict):
97
+ if "id" in fctx.reqdata:
98
+ update_match["id"] = fctx.reqdata["id"]
99
+ alias_map = getattr(op, "alias_map", None)
100
+ if alias_map is not None:
101
+ alias_id = vs.getprop(alias_map, "id")
102
+ if alias_id is not None and alias_id in fctx.reqdata:
103
+ update_match[alias_id] = fctx.reqdata[alias_id]
104
+ args = test_self.build_args(fctx, op, update_match)
78
105
  found = vs.select(entmap, args)
79
106
  ent = vs.getelem(found, 0)
107
+ if ent is None and isinstance(entmap, dict):
108
+ for e in entmap.values():
109
+ if isinstance(e, dict):
110
+ ent = e
111
+ break
80
112
  if ent is None:
81
113
  return respond(404, None, {"statusText": "Not found"})
82
114
  if isinstance(ent, dict):
@@ -89,7 +121,7 @@ class ProjectNameTestFeature(ProjectNameBaseFeature):
89
121
  return respond(200, out)
90
122
 
91
123
  elif op.name == "remove":
92
- args = test_self.build_args(fctx, op, fctx.reqmatch)
124
+ args = test_self.build_args(fctx, op, _resolve_match(fctx.reqmatch))
93
125
  found = vs.select(entmap, args)
94
126
  ent = vs.getelem(found, 0)
95
127
  if ent is None: