PluginProbe
404 Solution / trunk
404 Solution vtrunk
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / matching / EngineProfileSaveRequest.php

EngineProfileSaveRequest.php in 404 Solution trunk, at includes/matching/EngineProfileSaveRequest.php

295 lines 9.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Typed value object for the `wp_ajax_abj404_engine_profiles_save` request
9 * payload (the $_POST that drives Ajax_EngineProfiles::handleSave).
10 *
11 * Boundary normalizer (task: type-pressure at module boundaries).
12 *
13 * The handler used to inline its own shape probing:
14 *
15 * $id = isset($_POST['id']) ? absint($_POST['id']) : 0;
16 * $name = isset($_POST['name']) ? sanitize_text_field(wp_unslash((string)$_POST['name'])) : '';
17 * $urlPattern = isset($_POST['url_pattern']) ? wp_unslash((string)$_POST['url_pattern']) : '';
18 * $isRegex = isset($_POST['is_regex']) ? (int)(bool)$_POST['is_regex'] : 0;
19 * $engines = isset($_POST['enabled_engines']) ? wp_unslash((string)$_POST['enabled_engines']) : '[]';
20 * $priority = isset($_POST['priority']) ? (int)$_POST['priority'] : 0;
21 * $status = isset($_POST['status']) ? (int)(bool)$_POST['status'] : 1;
22 *
23 * Six fields, six casts, two sanitisers, all inline. The normalizer pulls
24 * the contract into one place so:
25 *
26 * - field rules cannot drift between the handler and any future caller
27 * (e.g. a WP-CLI command that wants to reuse the save path);
28 * - malformed-input tests live at the VO, not scattered through the
29 * handler's success path;
30 * - PHPStan sees a typed `getName(): string` etc. at the call sites,
31 * not `mixed` from $_POST.
32 *
33 * Schema (after normalization):
34 *
35 * - id : int >= 0 (0 means "insert new", any positive int means "update")
36 * - name : string (sanitize_text_field; '' when absent or non-scalar)
37 * - urlPattern : string (wp_unslash; raw payload, regex tested upstream)
38 * - isRegex : int (0 or 1; non-scalar treated as 0)
39 * - enabledEngines : string (JSON wire string; '[]' when absent / non-scalar)
40 * - priority : int (0 when absent or non-numeric)
41 * - status : int (0 or 1; default 1 = enabled)
42 *
43 * Construct via `fromPost($_POST)`. The VO accepts a raw payload array so
44 * tests can build a fixture without round-tripping through PHP superglobals.
45 */
46 final class ABJ_404_Solution_EngineProfileSaveRequest {
47
48 /** @var int */
49 private $id;
50
51 /** @var string */
52 private $name;
53
54 /** @var string */
55 private $urlPattern;
56
57 /** @var int */
58 private $isRegex;
59
60 /** @var string */
61 private $enabledEngines;
62
63 /** @var int */
64 private $priority;
65
66 /** @var int */
67 private $status;
68
69 private function __construct(
70 int $id,
71 string $name,
72 string $urlPattern,
73 int $isRegex,
74 string $enabledEngines,
75 int $priority,
76 int $status
77 ) {
78 $this->id = $id;
79 $this->name = $name;
80 $this->urlPattern = $urlPattern;
81 $this->isRegex = $isRegex;
82 $this->enabledEngines = $enabledEngines;
83 $this->priority = $priority;
84 $this->status = $status;
85 }
86
87 /**
88 * Normalize a $_POST payload (or any associative array of the same
89 * shape) into a typed VO. Always returns a VO; missing or malformed
90 * fields are filled with their documented defaults so callers can rely
91 * on the typed accessors without further null checks.
92 *
93 * Validation of business rules (name non-empty, regex parses) is the
94 * caller's job: the VO is the type boundary, not the policy boundary.
95 *
96 * @param array<mixed, mixed>|null $post
97 */
98 public static function fromPost($post): self {
99 $payload = is_array($post) ? $post : array();
100
101 $id = self::coerceAbsInt($payload, 'id');
102 $name = self::coerceSanitizedText($payload, 'name');
103 $urlPattern = self::coerceUnslashedString($payload, 'url_pattern');
104 $isRegex = self::coerceBoolInt($payload, 'is_regex', 0);
105 $enabledEngines = self::coerceEnabledEnginesJson($payload);
106 $priority = self::coerceInt($payload, 'priority');
107 $status = self::coerceBoolInt($payload, 'status', 1);
108
109 return new self($id, $name, $urlPattern, $isRegex, $enabledEngines, $priority, $status);
110 }
111
112 public function getId(): int {
113 return $this->id;
114 }
115
116 public function getName(): string {
117 return $this->name;
118 }
119
120 public function getUrlPattern(): string {
121 return $this->urlPattern;
122 }
123
124 public function isRegex(): bool {
125 return $this->isRegex === 1;
126 }
127
128 public function getIsRegexInt(): int {
129 return $this->isRegex;
130 }
131
132 public function getEnabledEnginesJson(): string {
133 return $this->enabledEngines;
134 }
135
136 public function getPriority(): int {
137 return $this->priority;
138 }
139
140 public function getStatusInt(): int {
141 return $this->status;
142 }
143
144 /**
145 * True iff the required business fields are populated. The handler
146 * uses this to decide whether to early-return with a 400.
147 */
148 public function hasRequiredFields(): bool {
149 return trim($this->name) !== '' && trim($this->urlPattern) !== '';
150 }
151
152 /**
153 * The exact payload shape `EngineProfileResolver::saveProfile()`
154 * expects. The VO is the single source of truth for the wire format
155 * between the handler and the persistence layer.
156 *
157 * @return array{id: int, name: string, url_pattern: string, is_regex: int, enabled_engines: string, priority: int, status: int}
158 */
159 public function toResolverPayload(): array {
160 return array(
161 'id' => $this->id,
162 'name' => $this->name,
163 'url_pattern' => $this->urlPattern,
164 'is_regex' => $this->isRegex,
165 'enabled_engines' => $this->enabledEngines,
166 'priority' => $this->priority,
167 'status' => $this->status,
168 );
169 }
170
171 /**
172 * The enabled-engines payload is a JSON string on the wire. Absent or
173 * malformed (non-scalar) inputs fall back to the documented default
174 * '[]' so the resolver always receives a parseable string.
175 *
176 * @param array<mixed, mixed> $payload
177 */
178 private static function coerceEnabledEnginesJson(array $payload): string {
179 if (!isset($payload['enabled_engines'])) {
180 return '[]';
181 }
182 $v = $payload['enabled_engines'];
183 if (!is_scalar($v)) {
184 return '[]';
185 }
186 $s = (string)$v;
187 if (function_exists('wp_unslash')) {
188 $u = wp_unslash($s);
189 $s = is_string($u) ? $u : $s;
190 }
191 return $s === '' ? '[]' : $s;
192 }
193
194 /**
195 * @param array<mixed, mixed> $raw
196 */
197 private static function coerceAbsInt(array $raw, string $key): int {
198 if (!isset($raw[$key])) {
199 return 0;
200 }
201 $v = $raw[$key];
202 if (is_int($v)) {
203 return $v < 0 ? -$v : $v;
204 }
205 if (is_float($v)) {
206 return abs((int)$v);
207 }
208 if (is_string($v) && is_numeric($v)) {
209 return abs((int)$v);
210 }
211 if (is_string($v) && function_exists('absint')) {
212 return (int)absint($v);
213 }
214 return 0;
215 }
216
217 /**
218 * @param array<mixed, mixed> $raw
219 */
220 private static function coerceInt(array $raw, string $key): int {
221 if (!isset($raw[$key])) {
222 return 0;
223 }
224 $v = $raw[$key];
225 if (is_int($v)) {
226 return $v;
227 }
228 if (is_float($v)) {
229 return (int)$v;
230 }
231 if (is_string($v) && is_numeric($v)) {
232 return (int)$v;
233 }
234 if (is_bool($v)) {
235 return $v ? 1 : 0;
236 }
237 return 0;
238 }
239
240 /**
241 * Returns 0 or 1, never anything else. `$default` is what to return
242 * when the key is absent (status defaults to 1 = enabled; is_regex
243 * defaults to 0 = literal pattern).
244 *
245 * @param array<mixed, mixed> $raw
246 */
247 private static function coerceBoolInt(array $raw, string $key, int $default): int {
248 if (!isset($raw[$key])) {
249 return $default;
250 }
251 $v = $raw[$key];
252 if (!is_scalar($v)) {
253 return 0;
254 }
255 return (int)(bool)$v;
256 }
257
258 /**
259 * Strings flow through wp_unslash to undo WP's magic-quotes pretence,
260 * then sanitize_text_field for length/control-char hardening. Both
261 * functions exist on real WP; in tests we degrade to identity.
262 *
263 * @param array<mixed, mixed> $raw
264 */
265 private static function coerceSanitizedText(array $raw, string $key): string {
266 $s = self::coerceUnslashedString($raw, $key);
267 if (function_exists('sanitize_text_field')) {
268 return (string)sanitize_text_field($s);
269 }
270 return $s;
271 }
272
273 /**
274 * Pulls a string out of the raw payload, applying wp_unslash if
275 * available, and returns '' for missing / non-scalar values.
276 *
277 * @param array<mixed, mixed> $raw
278 */
279 private static function coerceUnslashedString(array $raw, string $key): string {
280 if (!isset($raw[$key])) {
281 return '';
282 }
283 $v = $raw[$key];
284 if (!is_scalar($v)) {
285 return '';
286 }
287 $s = (string)$v;
288 if (function_exists('wp_unslash')) {
289 $u = wp_unslash($s);
290 return is_string($u) ? $u : $s;
291 }
292 return $s;
293 }
294 }
295