PluginProbe
404 Solution / 4.2.0
404 Solution v4.2.0
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 / PayloadSchema.php

PayloadSchema.php in 404 Solution 4.2.0, at includes/PayloadSchema.php

257 lines 10.2 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 *
55 * Unknown fields on the payload are reported as `unexpected_field`
56 * violations. The schema is the closed contract: any new field on the
57 * producer must be added to the schema in the same commit.
58 *
59 * Single entry point. `validate(array $schema, array $payload):
60 * array<int, string>` returns a list of human-readable violation strings,
61 * empty when the payload conforms. Callers assert `$violations === []`
62 * in tests, with the list embedded in the failure message so the test
63 * report names the exact field(s) that drifted.
64 */
65 class ABJ_404_Solution_PayloadSchema {
66
67 /**
68 * Validate a payload against a schema.
69 *
70 * @param array<string, array<string, mixed>> $schema FieldSpec map.
71 * @param array<string, mixed> $payload Wire payload to check.
72 * @param string $path Dotted path prefix used in recursive calls
73 * and for error messages. Empty at the top level.
74 * @return array<int, string> Violation messages, empty on success.
75 */
76 public static function validate(array $schema, array $payload, string $path = ''): array {
77 $violations = [];
78
79 foreach ($schema as $field => $spec) {
80 $fieldPath = $path === '' ? (string)$field : $path . '.' . $field;
81 $required = !isset($spec['required']) || $spec['required'] === true;
82
83 if (!array_key_exists($field, $payload)) {
84 if ($required) {
85 $violations[] = sprintf('missing required field: %s', $fieldPath);
86 }
87 continue;
88 }
89
90 $value = $payload[$field];
91 $type = isset($spec['type']) && is_string($spec['type']) ? $spec['type'] : 'mixed';
92
93 $typeViolation = self::checkType($fieldPath, $type, $value);
94 if ($typeViolation !== null) {
95 $violations[] = $typeViolation;
96 continue;
97 }
98
99 if (isset($spec['enum']) && is_array($spec['enum'])) {
100 if (!in_array($value, $spec['enum'], true)) {
101 $violations[] = sprintf(
102 '%s value %s is not in enum [%s]',
103 $fieldPath,
104 self::renderScalar($value),
105 implode(', ', array_map([self::class, 'renderScalar'], $spec['enum']))
106 );
107 continue;
108 }
109 }
110
111 if (self::baseType($type) === 'array' && is_array($value) && isset($spec['item_type'])) {
112 $itemType = (string)$spec['item_type'];
113 foreach ($value as $i => $item) {
114 $v = self::checkType($fieldPath . '[' . $i . ']', $itemType, $item);
115 if ($v !== null) {
116 $violations[] = $v;
117 }
118 }
119 }
120
121 if (self::baseType($type) === 'object' && is_array($value)) {
122 $keyType = isset($spec['key_type']) ? (string)$spec['key_type'] : 'string';
123 $valueType = isset($spec['value_type']) ? (string)$spec['value_type'] : null;
124 foreach ($value as $k => $v) {
125 $kV = self::checkType($fieldPath . '/key', $keyType, $k);
126 if ($kV !== null) {
127 $violations[] = $kV;
128 }
129 if ($valueType !== null) {
130 $vV = self::checkType($fieldPath . '[' . self::renderScalar($k) . ']', $valueType, $v);
131 if ($vV !== null) {
132 $violations[] = $vV;
133 }
134 }
135 }
136 }
137 }
138
139 foreach ($payload as $key => $_value) {
140 if (!array_key_exists($key, $schema)) {
141 $fieldPath = $path === '' ? (string)$key : $path . '.' . $key;
142 $violations[] = sprintf('unexpected_field: %s (not declared in schema)', $fieldPath);
143 }
144 }
145
146 return $violations;
147 }
148
149 /**
150 * Check a single value against a primitive type spec. Returns null
151 * when the value matches, a violation string otherwise.
152 *
153 * @param string $path
154 * @param string $type
155 * @param mixed $value
156 * @return string|null
157 */
158 private static function checkType(string $path, string $type, $value): ?string {
159 $allowNull = self::typeAllowsNull($type);
160 if ($value === null) {
161 return $allowNull ? null : sprintf('%s is null but type %s disallows null', $path, $type);
162 }
163 $base = self::baseType($type);
164 switch ($base) {
165 case 'string':
166 return is_string($value) ? null : self::typeMismatch($path, 'string', $value);
167 case 'int':
168 return is_int($value) ? null : self::typeMismatch($path, 'int', $value);
169 case 'bool':
170 return is_bool($value) ? null : self::typeMismatch($path, 'bool', $value);
171 case 'array':
172 if (!is_array($value)) {
173 return self::typeMismatch($path, 'array', $value);
174 }
175 if (self::isAssoc($value)) {
176 return sprintf('%s expected array (list), got object (associative)', $path);
177 }
178 return null;
179 case 'object':
180 if (!is_array($value)) {
181 return self::typeMismatch($path, 'object', $value);
182 }
183 if ($value !== [] && !self::isAssoc($value)) {
184 return sprintf('%s expected object (associative), got list', $path);
185 }
186 return null;
187 case 'mixed':
188 return null;
189 default:
190 return sprintf('%s schema type %s is unknown to the validator', $path, $type);
191 }
192 }
193
194 private static function typeAllowsNull(string $type): bool {
195 return substr($type, -5) === '|null' || $type === 'mixed';
196 }
197
198 private static function baseType(string $type): string {
199 if (substr($type, -5) === '|null') {
200 return substr($type, 0, -5);
201 }
202 return $type;
203 }
204
205 /**
206 * @param mixed $value
207 */
208 private static function typeMismatch(string $path, string $expected, $value): string {
209 return sprintf('%s expected %s, got %s', $path, $expected, self::describeType($value));
210 }
211
212 /**
213 * @param mixed $value
214 */
215 private static function describeType($value): string {
216 if (is_array($value)) {
217 return self::isAssoc($value) ? 'object' : 'array';
218 }
219 return gettype($value);
220 }
221
222 /**
223 * Render a scalar for error messages. Falls back to gettype() for
224 * non-scalars so the message stays readable even when the value is
225 * an array or object.
226 *
227 * @param mixed $value
228 */
229 private static function renderScalar($value): string {
230 if (is_string($value)) {
231 return "'" . $value . "'";
232 }
233 if (is_int($value) || is_float($value)) {
234 return (string)$value;
235 }
236 if (is_bool($value)) {
237 return $value ? 'true' : 'false';
238 }
239 if ($value === null) {
240 return 'null';
241 }
242 return self::describeType($value);
243 }
244
245 /**
246 * @param array<mixed, mixed> $arr
247 */
248 private static function isAssoc(array $arr): bool {
249 if ($arr === []) {
250 return false;
251 }
252 // array_is_list() is PHP 8.1+; plugin still supports 7.4. Use the
253 // canonical pre-8.1 idiom: a list has int-keyed sequential keys 0..N-1.
254 return array_keys($arr) !== range(0, count($arr) - 1);
255 }
256 }
257