@kirigami/php-prepros 1.1.0 → 1.6.0

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.
@@ -0,0 +1,550 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ /**
6
+ * SchemaValidator
7
+ *
8
+ * A pure PHP JSON Schema validator (Draft 7 style, similar to Ajv),
9
+ * with no external dependencies.
10
+ *
11
+ * Usage example:
12
+ *
13
+ * $schema = [
14
+ * 'type' => 'object',
15
+ * 'required' => ['name', 'age'],
16
+ * 'properties' => [
17
+ * 'name' => ['type' => 'string', 'minLength' => 1],
18
+ * 'age' => ['type' => 'integer', 'minimum' => 0],
19
+ * 'tags' => ['type' => 'array', 'items' => ['type' => 'string']],
20
+ * ],
21
+ * 'additionalProperties' => false,
22
+ * ];
23
+ *
24
+ * $validator = new SchemaValidator($schema);
25
+ *
26
+ * if (!$validator->isValid(['name' => 'Alice', 'age' => 30])) {
27
+ * foreach ($validator->getErrors() as $error) {
28
+ * echo $error, PHP_EOL;
29
+ * }
30
+ * }
31
+ */
32
+ final class SCHEMA
33
+ {
34
+ /** @var array<string,mixed> Schema used as the entry point of isValid(). */
35
+ private array $rootSchema;
36
+
37
+ /** @var array<string,mixed> Root document used to resolve local $ref pointers. */
38
+ private array $refRoot;
39
+
40
+ /** @var string[] */
41
+ private array $errors = [];
42
+
43
+ /**
44
+ * @param array<string,mixed> $schema The root schema (JSON Schema format).
45
+ */
46
+ public function __construct(array $schema)
47
+ {
48
+ $this->rootSchema = $schema;
49
+ $this->refRoot = $schema;
50
+ }
51
+
52
+ /**
53
+ * Validates data. Returns true/false, like Ajv's `validate()`.
54
+ * Errors are available via getErrors().
55
+ *
56
+ * @param mixed $data
57
+ */
58
+ public function isValid(mixed $data): bool
59
+ {
60
+ $this->errors = [];
61
+ $this->validateAgainst($data, $this->rootSchema, '');
62
+
63
+ return count($this->errors) === 0;
64
+ }
65
+
66
+ /**
67
+ * Alias closer to Ajv's semantics (validate($data)).
68
+ */
69
+ public function validate(mixed $data): bool
70
+ {
71
+ return $this->isValid($data);
72
+ }
73
+
74
+ /**
75
+ * @return string[] List of error messages (path: message).
76
+ */
77
+ public function getErrors(): array
78
+ {
79
+ return $this->errors;
80
+ }
81
+
82
+ private function addError(string $path, string $message): void
83
+ {
84
+ $p = $path === '' ? '(root)' : $path;
85
+ $this->errors[] = "{$p}: {$message}";
86
+ }
87
+
88
+ /**
89
+ * @param mixed $data
90
+ * @param array<string,mixed> $schema
91
+ */
92
+ private function validateAgainst(mixed $data, array $schema, string $path): void
93
+ {
94
+ // Local $ref (#/definitions/xxx or #/$defs/xxx)
95
+ if (isset($schema['$ref'])) {
96
+ $resolved = $this->resolveRef((string) $schema['$ref']);
97
+ if ($resolved === null) {
98
+ $this->addError($path, "reference not found: {$schema['$ref']}");
99
+ return;
100
+ }
101
+ $this->validateAgainst($data, $resolved, $path);
102
+ return;
103
+ }
104
+
105
+ if (isset($schema['const'])) {
106
+ if (!$this->looseEquals($data, $schema['const'])) {
107
+ $this->addError($path, 'does not match the expected const value');
108
+ }
109
+ }
110
+
111
+ if (isset($schema['enum']) && is_array($schema['enum'])) {
112
+ $found = false;
113
+ foreach ($schema['enum'] as $candidate) {
114
+ if ($this->looseEquals($data, $candidate)) {
115
+ $found = true;
116
+ break;
117
+ }
118
+ }
119
+ if (!$found) {
120
+ $this->addError($path, 'is not one of the allowed enum values');
121
+ }
122
+ }
123
+
124
+ if (isset($schema['type'])) {
125
+ $this->validateType($data, $schema['type'], $path);
126
+ }
127
+
128
+ $this->validateCombinators($data, $schema, $path);
129
+
130
+ $type = $this->detectType($data);
131
+
132
+ switch ($type) {
133
+ case 'string':
134
+ $this->validateString($data, $schema, $path);
135
+ break;
136
+ case 'integer':
137
+ case 'number':
138
+ $this->validateNumber($data, $schema, $path);
139
+ break;
140
+ case 'array':
141
+ $this->validateArray($data, $schema, $path);
142
+ break;
143
+ case 'object':
144
+ $this->validateObject($data, $schema, $path);
145
+ break;
146
+ }
147
+ }
148
+
149
+ /**
150
+ * @param array<string,mixed> $schema
151
+ */
152
+ private function validateCombinators(mixed $data, array $schema, string $path): void
153
+ {
154
+ if (isset($schema['allOf']) && is_array($schema['allOf'])) {
155
+ foreach ($schema['allOf'] as $sub) {
156
+ $this->validateAgainst($data, $sub, $path);
157
+ }
158
+ }
159
+
160
+ if (isset($schema['anyOf']) && is_array($schema['anyOf'])) {
161
+ $ok = false;
162
+ foreach ($schema['anyOf'] as $sub) {
163
+ if ($this->isSubSchemaValid($data, $sub)) {
164
+ $ok = true;
165
+ break;
166
+ }
167
+ }
168
+ if (!$ok) {
169
+ $this->addError($path, 'does not satisfy any of the anyOf schemas');
170
+ }
171
+ }
172
+
173
+ if (isset($schema['oneOf']) && is_array($schema['oneOf'])) {
174
+ $matches = 0;
175
+ foreach ($schema['oneOf'] as $sub) {
176
+ if ($this->isSubSchemaValid($data, $sub)) {
177
+ $matches++;
178
+ }
179
+ }
180
+ if ($matches !== 1) {
181
+ $this->addError($path, "must satisfy exactly one oneOf schema ({$matches} match(es))");
182
+ }
183
+ }
184
+
185
+ if (isset($schema['not'])) {
186
+ if ($this->isSubSchemaValid($data, $schema['not'])) {
187
+ $this->addError($path, 'must not satisfy the "not" schema');
188
+ }
189
+ }
190
+ }
191
+
192
+ /**
193
+ * @param array<string,mixed> $schema
194
+ */
195
+ private function isSubSchemaValid(mixed $data, array $schema): bool
196
+ {
197
+ $sub = new self($schema);
198
+ // $ref pointers inside the sub-schema must keep resolving against
199
+ // the original root document, not against the sub-schema itself.
200
+ $sub->refRoot = $this->refRoot;
201
+ return $sub->isValid($data);
202
+ }
203
+
204
+ private function validateType(mixed $data, mixed $expected, string $path): void
205
+ {
206
+ $expectedTypes = is_array($expected) ? $expected : [$expected];
207
+ $actual = $this->detectType($data);
208
+
209
+ foreach ($expectedTypes as $t) {
210
+ if ($t === 'integer' && $actual === 'number' && is_float($data) && floor($data) === $data) {
211
+ return;
212
+ }
213
+ if ($t === $actual) {
214
+ return;
215
+ }
216
+ if ($t === 'number' && $actual === 'integer') {
217
+ return;
218
+ }
219
+ }
220
+
221
+ $expectedStr = implode('|', $expectedTypes);
222
+ $this->addError($path, "invalid type: expected {$expectedStr}, got {$actual}");
223
+ }
224
+
225
+ private function detectType(mixed $data): string
226
+ {
227
+ return match (true) {
228
+ $data === null => 'null',
229
+ is_bool($data) => 'boolean',
230
+ is_int($data) => 'integer',
231
+ is_float($data) => (floor($data) === $data ? 'integer' : 'number'),
232
+ is_string($data) => 'string',
233
+ // A JSON array always decodes to a PHP list array, whether or not
234
+ // json_decode() was called with associative=true.
235
+ is_array($data) && $this->isList($data) => 'array',
236
+ // A JSON object decodes to either an associative array
237
+ // (json_decode(..., true)) or a stdClass (json_decode(..., false)).
238
+ is_array($data) || is_object($data) => 'object',
239
+ default => 'unknown',
240
+ };
241
+ }
242
+
243
+ /**
244
+ * True for plain PHP list arrays (sequential integer keys from 0).
245
+ * Objects (stdClass or associative arrays) are never lists.
246
+ */
247
+ private function isList(mixed $arr): bool
248
+ {
249
+ if (!is_array($arr)) {
250
+ return false;
251
+ }
252
+ if ($arr === []) {
253
+ return true;
254
+ }
255
+ return array_keys($arr) === range(0, count($arr) - 1);
256
+ }
257
+
258
+ /**
259
+ * True when $data behaves like a JSON object: either a stdClass
260
+ * (json_decode(..., false)) or a non-list associative array
261
+ * (json_decode(..., true)).
262
+ */
263
+ private function isObjectLike(mixed $data): bool
264
+ {
265
+ return is_object($data) || (is_array($data) && !$this->isList($data));
266
+ }
267
+
268
+ /**
269
+ * @return string[] Property names of an object-like value, in order.
270
+ */
271
+ private function objectKeys(mixed $data): array
272
+ {
273
+ if (is_object($data)) {
274
+ return array_keys(get_object_vars($data));
275
+ }
276
+ if (is_array($data)) {
277
+ return array_map('strval', array_keys($data));
278
+ }
279
+ return [];
280
+ }
281
+
282
+ private function objectHas(mixed $data, string $key): bool
283
+ {
284
+ if (is_object($data)) {
285
+ return property_exists($data, $key);
286
+ }
287
+ if (is_array($data)) {
288
+ return array_key_exists($key, $data);
289
+ }
290
+ return false;
291
+ }
292
+
293
+ private function objectGet(mixed $data, string $key): mixed
294
+ {
295
+ if (is_object($data)) {
296
+ return $data->{$key};
297
+ }
298
+ return $data[$key];
299
+ }
300
+
301
+ /**
302
+ * @param array<string,mixed> $schema
303
+ */
304
+ private function validateString(mixed $data, array $schema, string $path): void
305
+ {
306
+ if (!is_string($data)) {
307
+ return;
308
+ }
309
+
310
+ $len = function_exists('mb_strlen') ? mb_strlen($data) : strlen($data);
311
+
312
+ if (isset($schema['minLength']) && $len < (int) $schema['minLength']) {
313
+ $this->addError($path, "minimum length {$schema['minLength']} not met ({$len})");
314
+ }
315
+
316
+ if (isset($schema['maxLength']) && $len > (int) $schema['maxLength']) {
317
+ $this->addError($path, "maximum length {$schema['maxLength']} exceeded ({$len})");
318
+ }
319
+
320
+ if (isset($schema['pattern'])) {
321
+ $pattern = '/' . str_replace('/', '\/', (string) $schema['pattern']) . '/u';
322
+ if (@preg_match($pattern, $data) !== 1) {
323
+ $this->addError($path, "does not match pattern {$schema['pattern']}");
324
+ }
325
+ }
326
+
327
+ if (isset($schema['format'])) {
328
+ $this->validateFormat($data, (string) $schema['format'], $path);
329
+ }
330
+ }
331
+
332
+ private function validateFormat(string $data, string $format, string $path): void
333
+ {
334
+ $ok = match ($format) {
335
+ 'email' => (bool) filter_var($data, FILTER_VALIDATE_EMAIL),
336
+ 'uri', 'url' => (bool) filter_var($data, FILTER_VALIDATE_URL),
337
+ 'date' => (bool) preg_match('/^\d{4}-\d{2}-\d{2}$/', $data)
338
+ && checkdate((int) substr($data, 5, 2), (int) substr($data, 8, 2), (int) substr($data, 0, 4)),
339
+ 'date-time' => (bool) preg_match(
340
+ '/^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/',
341
+ $data
342
+ ),
343
+ 'ipv4' => (bool) filter_var($data, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4),
344
+ 'ipv6' => (bool) filter_var($data, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6),
345
+ 'uuid' => (bool) preg_match(
346
+ '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i',
347
+ $data
348
+ ),
349
+ default => true, // Unknown format: does not block, same as Ajv in non-strict mode.
350
+ };
351
+
352
+ if (!$ok) {
353
+ $this->addError($path, "invalid format, expected '{$format}'");
354
+ }
355
+ }
356
+
357
+ /**
358
+ * @param array<string,mixed> $schema
359
+ */
360
+ private function validateNumber(mixed $data, array $schema, string $path): void
361
+ {
362
+ if (!is_int($data) && !is_float($data)) {
363
+ return;
364
+ }
365
+
366
+ if (isset($schema['minimum']) && $data < $schema['minimum']) {
367
+ $this->addError($path, "must be >= {$schema['minimum']}");
368
+ }
369
+
370
+ if (isset($schema['maximum']) && $data > $schema['maximum']) {
371
+ $this->addError($path, "must be <= {$schema['maximum']}");
372
+ }
373
+
374
+ if (isset($schema['exclusiveMinimum']) && $data <= $schema['exclusiveMinimum']) {
375
+ $this->addError($path, "must be > {$schema['exclusiveMinimum']}");
376
+ }
377
+
378
+ if (isset($schema['exclusiveMaximum']) && $data >= $schema['exclusiveMaximum']) {
379
+ $this->addError($path, "must be < {$schema['exclusiveMaximum']}");
380
+ }
381
+
382
+ if (isset($schema['multipleOf'])) {
383
+ $factor = (float) $schema['multipleOf'];
384
+ if ($factor > 0) {
385
+ $div = $data / $factor;
386
+ if (abs($div - round($div)) > 1e-9) {
387
+ $this->addError($path, "must be a multiple of {$schema['multipleOf']}");
388
+ }
389
+ }
390
+ }
391
+ }
392
+
393
+ /**
394
+ * @param array<string,mixed> $schema
395
+ */
396
+ private function validateArray(mixed $data, array $schema, string $path): void
397
+ {
398
+ if (!is_array($data) || !$this->isList($data)) {
399
+ return;
400
+ }
401
+
402
+ $count = count($data);
403
+
404
+ if (isset($schema['minItems']) && $count < (int) $schema['minItems']) {
405
+ $this->addError($path, "must contain at least {$schema['minItems']} item(s)");
406
+ }
407
+
408
+ if (isset($schema['maxItems']) && $count > (int) $schema['maxItems']) {
409
+ $this->addError($path, "must contain at most {$schema['maxItems']} item(s)");
410
+ }
411
+
412
+ if (($schema['uniqueItems'] ?? false) === true) {
413
+ $seen = [];
414
+ foreach ($data as $item) {
415
+ $key = json_encode($item, JSON_THROW_ON_ERROR);
416
+ if (isset($seen[$key])) {
417
+ $this->addError($path, 'items must be unique');
418
+ break;
419
+ }
420
+ $seen[$key] = true;
421
+ }
422
+ }
423
+
424
+ if (isset($schema['items'])) {
425
+ $items = $schema['items'];
426
+
427
+ if ($this->isList($items) && array_is_list($items)) {
428
+ // Tuple mode: items is a list of positional schemas.
429
+ foreach ($data as $i => $item) {
430
+ if (isset($items[$i])) {
431
+ $this->validateAgainst($item, $items[$i], "{$path}[{$i}]");
432
+ } elseif (isset($schema['additionalItems']) && $schema['additionalItems'] === false) {
433
+ $this->addError("{$path}[{$i}]", 'additional item not allowed');
434
+ }
435
+ }
436
+ } else {
437
+ // Standard mode: a single schema applied to every item.
438
+ foreach ($data as $i => $item) {
439
+ $this->validateAgainst($item, $items, "{$path}[{$i}]");
440
+ }
441
+ }
442
+ }
443
+ }
444
+
445
+ /**
446
+ * @param array<string,mixed> $schema
447
+ */
448
+ private function validateObject(mixed $data, array $schema, string $path): void
449
+ {
450
+ // Accepts a JSON object regardless of how it was decoded:
451
+ // associative array (json_decode(..., true)) or stdClass (json_decode(..., false)).
452
+ if (!$this->isObjectLike($data)) {
453
+ return;
454
+ }
455
+
456
+ $keys = $this->objectKeys($data);
457
+
458
+ // required
459
+ if (isset($schema['required']) && is_array($schema['required'])) {
460
+ foreach ($schema['required'] as $requiredKey) {
461
+ if (!$this->objectHas($data, (string) $requiredKey)) {
462
+ $this->addError($path, "missing required property: '{$requiredKey}'");
463
+ }
464
+ }
465
+ }
466
+
467
+ $properties = $schema['properties'] ?? [];
468
+ $patternProperties = $schema['patternProperties'] ?? [];
469
+ $additional = $schema['additionalProperties'] ?? true;
470
+
471
+ foreach ($keys as $key) {
472
+ $value = $this->objectGet($data, $key);
473
+ $childPath = $path === '' ? $key : "{$path}.{$key}";
474
+ $matched = false;
475
+
476
+ if (isset($properties[$key])) {
477
+ $matched = true;
478
+ $this->validateAgainst($value, $properties[$key], $childPath);
479
+ }
480
+
481
+ foreach ($patternProperties as $pattern => $subSchema) {
482
+ $regex = '/' . str_replace('/', '\/', (string) $pattern) . '/u';
483
+ if (@preg_match($regex, $key) === 1) {
484
+ $matched = true;
485
+ $this->validateAgainst($value, $subSchema, $childPath);
486
+ }
487
+ }
488
+
489
+ if (!$matched) {
490
+ if ($additional === false) {
491
+ $this->addError($childPath, 'additional property not allowed');
492
+ } elseif (is_array($additional)) {
493
+ $this->validateAgainst($value, $additional, $childPath);
494
+ }
495
+ }
496
+ }
497
+
498
+ $count = count($keys);
499
+
500
+ if (isset($schema['minProperties']) && $count < (int) $schema['minProperties']) {
501
+ $this->addError($path, "must have at least {$schema['minProperties']} propert(y/ies)");
502
+ }
503
+
504
+ if (isset($schema['maxProperties']) && $count > (int) $schema['maxProperties']) {
505
+ $this->addError($path, "must have at most {$schema['maxProperties']} propert(y/ies)");
506
+ }
507
+ }
508
+
509
+ /**
510
+ * Resolves a local reference such as "#/definitions/foo" or "#/$defs/foo".
511
+ *
512
+ * @return array<string,mixed>|null
513
+ */
514
+ private function resolveRef(string $ref): ?array
515
+ {
516
+ if (!str_starts_with($ref, '#/')) {
517
+ return null; // External references are not supported.
518
+ }
519
+
520
+ $segments = explode('/', substr($ref, 2));
521
+ $current = $this->refRoot;
522
+
523
+ foreach ($segments as $segment) {
524
+ $segment = str_replace(['~1', '~0'], ['/', '~'], $segment);
525
+ if (!is_array($current) || !array_key_exists($segment, $current)) {
526
+ return null;
527
+ }
528
+ $current = $current[$segment];
529
+ }
530
+
531
+ return is_array($current) ? $current : null;
532
+ }
533
+
534
+ private function looseEquals(mixed $a, mixed $b): bool
535
+ {
536
+ $aStructured = is_array($a) || is_object($a);
537
+ $bStructured = is_array($b) || is_object($b);
538
+
539
+ if ($aStructured && $bStructured) {
540
+ try {
541
+ // Compares structural (JSON) equality, so an associative array
542
+ // and a stdClass with the same content are considered equal.
543
+ return json_encode($a, JSON_THROW_ON_ERROR) === json_encode($b, JSON_THROW_ON_ERROR);
544
+ } catch (\JsonException) {
545
+ return false;
546
+ }
547
+ }
548
+ return $a === $b;
549
+ }
550
+ }