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 / feedback / PayloadSchema.php

PayloadSchema.php in 404 Solution trunk, at includes/feedback/PayloadSchema.php

263 lines 10.6 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 * Schema-contract validator for JS-to-PHP wire payloads.
9 *
10 * Single source of truth for the *shape* of any payload that crosses a
11 * process boundary (PHP to server HTTP POST, JS to PHP AJAX POST). Each
12 * payload has a `*.schema.php` file under `includes/schema/` that returns an
13 * associative array describing every field; the producer is contract-tested
14 * to match it.
15 *
16 * Why this exists. Commit 4080ffb5 ("fix five schema-mismatch and
17 * reliability bugs") landed five independent bugs in one fix because the
18 * JS payload builder, the PHP builder, and the server endpoint each
19 * encoded the schema independently with no shared spec. Same class of
20 * defect produced 35380dcc and earlier fixes. A single declared schema
21 * plus a producer-side contract test would have caught all five at
22 * unit-test time.
23 *
24 * Why not opis/json-schema or another lib. A hand-rolled spec is smaller
25 * than the JSON-Schema-of-JSON-Schema, has zero new composer deps, and
26 * only needs to encode the contracts that have historically broken
27 * (field name, primitive type, optional vs required, item types inside
28 * arrays/objects). The lib is callable from any context (boot, tests,
29 * future JS via a generator) and degrades gracefully if a field
30 * specifier is malformed.
31 *
32 * Spec grammar. A schema is `array<string, FieldSpec>` where each
33 * FieldSpec is an associative array with these keys:
34 *
35 * - `type` (string, required): one of `string`, `int`, `bool`, `array`,
36 * `object`, `string|null`, `int|null`, `bool|null`, `array|null`,
37 * `object|null`, `mixed`. `object` matches a non-empty associative
38 * array (PHP has no first-class object distinct from assoc-array on
39 * the wire). `mixed` is an escape hatch; use sparingly.
40 * - `required` (bool, default true): whether the key MUST exist on the
41 * payload. Required + null-allowed means the key is present but the
42 * value may be null.
43 * - `item_type` (string, optional): when `type` is `array`, every
44 * element must match this primitive type. Default: no item check.
45 * - `key_type` (string, optional): when `type` is `object`, every key
46 * must match this type (typically `string`). Default: `string`.
47 * - `value_type` (string, optional): when `type` is `object`, every
48 * value must match this primitive type. Default: no value check.
49 * - `enum` (array<scalar>, optional): when present, the value must be
50 * strictly equal to one of the listed scalars. Stricter than `type`
51 * alone.
52 * - `description` (string, optional): human-readable note. Ignored at
53 * runtime; used by docs / future codegen.
54 * - `human_typed` (string, optional): `message` or `contact`, marking a
55 * field a PERSON typed into a form rather than one the plugin filled
56 * in by itself. Ignored at runtime by this validator; read by
57 * scripts/feedback-submission-modes.php, which derives from it which
58 * report types are manually submitted (and therefore worth notifying
59 * a human about) and which values to lead the notification with.
60 *
61 * Unknown fields on the payload are reported as `unexpected_field`
62 * violations. The schema is the closed contract: any new field on the
63 * producer must be added to the schema in the same commit.
64 *
65 * Single entry point. `validate(array $schema, array $payload):
66 * array<int, string>` returns a list of human-readable violation strings,
67 * empty when the payload conforms. Callers assert `$violations === []`
68 * in tests, with the list embedded in the failure message so the test
69 * report names the exact field(s) that drifted.
70 */
71 class ABJ_404_Solution_PayloadSchema {
72
73 /**
74 * Validate a payload against a schema.
75 *
76 * @param array<string, array<string, mixed>> $schema FieldSpec map.
77 * @param array<string, mixed> $payload Wire payload to check.
78 * @param string $path Dotted path prefix used in recursive calls
79 * and for error messages. Empty at the top level.
80 * @return array<int, string> Violation messages, empty on success.
81 */
82 public static function validate(array $schema, array $payload, string $path = ''): array {
83 $violations = [];
84
85 foreach ($schema as $field => $spec) {
86 $fieldPath = $path === '' ? (string)$field : $path . '.' . $field;
87 $required = !isset($spec['required']) || $spec['required'] === true;
88
89 if (!array_key_exists($field, $payload)) {
90 if ($required) {
91 $violations[] = sprintf('missing required field: %s', $fieldPath);
92 }
93 continue;
94 }
95
96 $value = $payload[$field];
97 $type = isset($spec['type']) && is_string($spec['type']) ? $spec['type'] : 'mixed';
98
99 $typeViolation = self::checkType($fieldPath, $type, $value);
100 if ($typeViolation !== null) {
101 $violations[] = $typeViolation;
102 continue;
103 }
104
105 if (isset($spec['enum']) && is_array($spec['enum'])) {
106 if (!in_array($value, $spec['enum'], true)) {
107 $violations[] = sprintf(
108 '%s value %s is not in enum [%s]',
109 $fieldPath,
110 self::renderScalar($value),
111 implode(', ', array_map([self::class, 'renderScalar'], $spec['enum']))
112 );
113 continue;
114 }
115 }
116
117 if (self::baseType($type) === 'array' && is_array($value) && isset($spec['item_type'])) {
118 $itemType = (string)$spec['item_type'];
119 foreach ($value as $i => $item) {
120 $v = self::checkType($fieldPath . '[' . $i . ']', $itemType, $item);
121 if ($v !== null) {
122 $violations[] = $v;
123 }
124 }
125 }
126
127 if (self::baseType($type) === 'object' && is_array($value)) {
128 $keyType = isset($spec['key_type']) ? (string)$spec['key_type'] : 'string';
129 $valueType = isset($spec['value_type']) ? (string)$spec['value_type'] : null;
130 foreach ($value as $k => $v) {
131 $kV = self::checkType($fieldPath . '/key', $keyType, $k);
132 if ($kV !== null) {
133 $violations[] = $kV;
134 }
135 if ($valueType !== null) {
136 $vV = self::checkType($fieldPath . '[' . self::renderScalar($k) . ']', $valueType, $v);
137 if ($vV !== null) {
138 $violations[] = $vV;
139 }
140 }
141 }
142 }
143 }
144
145 foreach ($payload as $key => $_value) {
146 if (!array_key_exists($key, $schema)) {
147 $fieldPath = $path === '' ? (string)$key : $path . '.' . $key;
148 $violations[] = sprintf('unexpected_field: %s (not declared in schema)', $fieldPath);
149 }
150 }
151
152 return $violations;
153 }
154
155 /**
156 * Check a single value against a primitive type spec. Returns null
157 * when the value matches, a violation string otherwise.
158 *
159 * @param string $path
160 * @param string $type
161 * @param mixed $value
162 * @return string|null
163 */
164 private static function checkType(string $path, string $type, $value): ?string {
165 $allowNull = self::typeAllowsNull($type);
166 if ($value === null) {
167 return $allowNull ? null : sprintf('%s is null but type %s disallows null', $path, $type);
168 }
169 $base = self::baseType($type);
170 switch ($base) {
171 case 'string':
172 return is_string($value) ? null : self::typeMismatch($path, 'string', $value);
173 case 'int':
174 return is_int($value) ? null : self::typeMismatch($path, 'int', $value);
175 case 'bool':
176 return is_bool($value) ? null : self::typeMismatch($path, 'bool', $value);
177 case 'array':
178 if (!is_array($value)) {
179 return self::typeMismatch($path, 'array', $value);
180 }
181 if (self::isAssoc($value)) {
182 return sprintf('%s expected array (list), got object (associative)', $path);
183 }
184 return null;
185 case 'object':
186 if (!is_array($value)) {
187 return self::typeMismatch($path, 'object', $value);
188 }
189 if ($value !== [] && !self::isAssoc($value)) {
190 return sprintf('%s expected object (associative), got list', $path);
191 }
192 return null;
193 case 'mixed':
194 return null;
195 default:
196 return sprintf('%s schema type %s is unknown to the validator', $path, $type);
197 }
198 }
199
200 private static function typeAllowsNull(string $type): bool {
201 return substr($type, -5) === '|null' || $type === 'mixed';
202 }
203
204 private static function baseType(string $type): string {
205 if (substr($type, -5) === '|null') {
206 return substr($type, 0, -5);
207 }
208 return $type;
209 }
210
211 /**
212 * @param mixed $value
213 */
214 private static function typeMismatch(string $path, string $expected, $value): string {
215 return sprintf('%s expected %s, got %s', $path, $expected, self::describeType($value));
216 }
217
218 /**
219 * @param mixed $value
220 */
221 private static function describeType($value): string {
222 if (is_array($value)) {
223 return self::isAssoc($value) ? 'object' : 'array';
224 }
225 return gettype($value);
226 }
227
228 /**
229 * Render a scalar for error messages. Falls back to gettype() for
230 * non-scalars so the message stays readable even when the value is
231 * an array or object.
232 *
233 * @param mixed $value
234 */
235 private static function renderScalar($value): string {
236 if (is_string($value)) {
237 return "'" . $value . "'";
238 }
239 if (is_int($value) || is_float($value)) {
240 return (string)$value;
241 }
242 if (is_bool($value)) {
243 return $value ? 'true' : 'false';
244 }
245 if ($value === null) {
246 return 'null';
247 }
248 return self::describeType($value);
249 }
250
251 /**
252 * @param array<mixed, mixed> $arr
253 */
254 private static function isAssoc(array $arr): bool {
255 if ($arr === []) {
256 return false;
257 }
258 // array_is_list() is PHP 8.1+; plugin still supports 7.4. Use the
259 // canonical pre-8.1 idiom: a list has int-keyed sequential keys 0..N-1.
260 return array_keys($arr) !== range(0, count($arr) - 1);
261 }
262 }
263