Parse and validate AT Protocol Lexicons with DTO generation for Laravel
1<?php
2
3namespace SocialDept\AtpSchema\Validation;
4
5use Illuminate\Support\Traits\Macroable;
6use SocialDept\AtpSchema\Contracts\LexiconValidator as LexiconValidatorContract;
7use SocialDept\AtpSchema\Data\LexiconDocument;
8use SocialDept\AtpSchema\Exceptions\RecordValidationException;
9use SocialDept\AtpSchema\Exceptions\SchemaValidationException;
10use SocialDept\AtpSchema\Parser\SchemaLoader;
11use SocialDept\AtpSchema\Parser\TypeParser;
12
13class Validator implements LexiconValidatorContract
14{
15 use Macroable;
16
17 /**
18 * Validation mode constants.
19 */
20 public const MODE_STRICT = 'strict';
21
22 public const MODE_OPTIMISTIC = 'optimistic';
23
24 public const MODE_LENIENT = 'lenient';
25
26 /**
27 * Schema loader for loading lexicon documents.
28 */
29 protected SchemaLoader $schemaLoader;
30
31 /**
32 * Type parser for parsing and resolving types.
33 */
34 protected TypeParser $typeParser;
35
36 /**
37 * Validation mode.
38 */
39 protected string $mode = self::MODE_STRICT;
40
41 /**
42 * Collected validation errors.
43 *
44 * @var array<string, array<string>>
45 */
46 protected array $errors = [];
47
48 /**
49 * Create a new Validator.
50 */
51 public function __construct(
52 SchemaLoader $schemaLoader,
53 ?TypeParser $typeParser = null
54 ) {
55 $this->schemaLoader = $schemaLoader;
56 $this->typeParser = $typeParser ?? new TypeParser(schemaLoader: $schemaLoader);
57 }
58
59 /**
60 * Validate data against Lexicon schema.
61 */
62 public function validate(array $data, LexiconDocument $schema): bool
63 {
64 $this->errors = [];
65
66 try {
67 $this->validateData($data, $schema);
68
69 return empty($this->errors);
70 } catch (RecordValidationException|SchemaValidationException) {
71 return false;
72 }
73 }
74
75 /**
76 * Validate and return errors.
77 *
78 * @return array<string, array<string>>
79 */
80 public function validateWithErrors(array $data, LexiconDocument $schema): array
81 {
82 $this->errors = [];
83
84 try {
85 $this->validateData($data, $schema);
86
87 return $this->errors;
88 } catch (RecordValidationException $e) {
89 return ['_root' => [$e->getMessage()]];
90 } catch (SchemaValidationException $e) {
91 return ['_schema' => [$e->getMessage()]];
92 }
93 }
94
95 /**
96 * Validate a specific field.
97 */
98 public function validateField(mixed $value, string $field, LexiconDocument $schema): bool
99 {
100 $this->errors = [];
101
102 try {
103 $mainDef = $schema->getMainDefinition();
104
105 if ($mainDef === null) {
106 return false;
107 }
108
109 $properties = $this->extractProperties($mainDef);
110
111 if (! isset($properties[$field])) {
112 if ($this->mode === self::MODE_STRICT) {
113 $this->addError($field, "Field '{$field}' is not defined in schema");
114
115 return false;
116 }
117
118 return true;
119 }
120
121 $this->validateProperty($value, $field, $properties[$field], $schema);
122
123 return empty($this->errors);
124 } catch (RecordValidationException) {
125 return false;
126 }
127 }
128
129 /**
130 * Set validation mode (strict, optimistic, lenient).
131 */
132 public function setMode(string $mode): void
133 {
134 if (! in_array($mode, [self::MODE_STRICT, self::MODE_OPTIMISTIC, self::MODE_LENIENT])) {
135 throw new \InvalidArgumentException("Invalid validation mode: {$mode}");
136 }
137
138 $this->mode = $mode;
139 }
140
141 /**
142 * Get current validation mode.
143 */
144 public function getMode(): string
145 {
146 return $this->mode;
147 }
148
149 /**
150 * Validate data against schema.
151 */
152 protected function validateData(array $data, LexiconDocument $schema): void
153 {
154 $mainDef = $schema->getMainDefinition();
155
156 if ($mainDef === null) {
157 throw SchemaValidationException::invalidStructure(
158 $schema->getNsid(),
159 ['Missing main definition']
160 );
161 }
162
163 $type = $mainDef['type'] ?? null;
164
165 // Only validate if it's a record or object type
166 if ($type !== 'record' && $type !== 'object') {
167 throw SchemaValidationException::invalidStructure(
168 $schema->getNsid(),
169 ['Schema must be a record or object type, got: '.($type ?? 'unknown')]
170 );
171 }
172
173 $properties = $this->extractProperties($mainDef);
174 $required = $this->extractRequired($mainDef);
175
176 // Validate required fields
177 $this->validateRequired($data, $required);
178
179 // Validate defined properties
180 foreach ($properties as $name => $propDef) {
181 if (array_key_exists($name, $data)) {
182 $this->validateProperty($data[$name], $name, $propDef, $schema);
183 }
184 }
185
186 // Check for unknown fields
187 if ($this->mode === self::MODE_STRICT) {
188 $this->validateNoUnknownFields($data, array_keys($properties));
189 }
190 }
191
192 /**
193 * Extract properties from definition.
194 *
195 * @param array<string, mixed> $definition
196 * @return array<string, array<string, mixed>>
197 */
198 protected function extractProperties(array $definition): array
199 {
200 // Handle record type
201 if (isset($definition['record']) && is_array($definition['record'])) {
202 return $definition['record']['properties'] ?? [];
203 }
204
205 // Handle object type
206 if ($definition['type'] === 'object' || isset($definition['properties'])) {
207 return $definition['properties'] ?? [];
208 }
209
210 return [];
211 }
212
213 /**
214 * Extract required fields from definition.
215 *
216 * @param array<string, mixed> $definition
217 * @return array<string>
218 */
219 protected function extractRequired(array $definition): array
220 {
221 // Handle record type
222 if (isset($definition['record']) && is_array($definition['record'])) {
223 return $definition['record']['required'] ?? [];
224 }
225
226 // Handle object type
227 return $definition['required'] ?? [];
228 }
229
230 /**
231 * Validate required fields are present.
232 *
233 * @param array<string> $required
234 */
235 protected function validateRequired(array $data, array $required): void
236 {
237 if ($this->mode === self::MODE_LENIENT) {
238 return;
239 }
240
241 foreach ($required as $field) {
242 if (! array_key_exists($field, $data)) {
243 $this->addError($field, "Required field '{$field}' is missing");
244 }
245 }
246 }
247
248 /**
249 * Validate a single property.
250 *
251 * @param array<string, mixed> $propDef
252 */
253 protected function validateProperty(mixed $value, string $name, array $propDef, LexiconDocument $schema): void
254 {
255 try {
256 $type = $propDef['type'] ?? 'unknown';
257
258 // Basic type validation
259 $this->validateType($value, $type, $name);
260
261 // Constraint validation (skip in lenient mode)
262 if ($this->mode !== self::MODE_LENIENT) {
263 $this->validateConstraints($value, $propDef, $name);
264 }
265
266 // Nested object validation
267 if ($type === 'object' && is_array($value)) {
268 $this->validateNestedObject($value, $propDef, $name, $schema);
269 }
270
271 // Array validation
272 if ($type === 'array' && is_array($value)) {
273 $this->validateArray($value, $propDef, $name, $schema);
274 }
275 } catch (RecordValidationException $e) {
276 $this->addError($name, $e->getMessage());
277 }
278 }
279
280 /**
281 * Validate value type.
282 */
283 protected function validateType(mixed $value, string $expectedType, string $fieldName): void
284 {
285 $actualType = gettype($value);
286
287 $valid = match ($expectedType) {
288 'string' => is_string($value),
289 'integer' => is_int($value),
290 'boolean' => is_bool($value),
291 'number' => is_numeric($value),
292 'object' => is_array($value),
293 'array' => is_array($value),
294 'null' => is_null($value),
295 default => true, // Unknown types pass in optimistic/lenient modes
296 };
297
298 if (! $valid) {
299 $this->addError($fieldName, "Expected type '{$expectedType}', got '{$actualType}'");
300 }
301 }
302
303 /**
304 * Validate field constraints.
305 *
306 * @param array<string, mixed> $propDef
307 */
308 protected function validateConstraints(mixed $value, array $propDef, string $fieldName): void
309 {
310 // String length constraints
311 if (is_string($value)) {
312 if (isset($propDef['maxLength']) && strlen($value) > $propDef['maxLength']) {
313 $this->addError($fieldName, "String exceeds maximum length of {$propDef['maxLength']}");
314 }
315
316 if (isset($propDef['minLength']) && strlen($value) < $propDef['minLength']) {
317 $this->addError($fieldName, "String is shorter than minimum length of {$propDef['minLength']}");
318 }
319
320 if (isset($propDef['maxGraphemes']) && grapheme_strlen($value) > $propDef['maxGraphemes']) {
321 $this->addError($fieldName, "String exceeds maximum graphemes of {$propDef['maxGraphemes']}");
322 }
323
324 if (isset($propDef['minGraphemes']) && grapheme_strlen($value) < $propDef['minGraphemes']) {
325 $this->addError($fieldName, "String has fewer than minimum graphemes of {$propDef['minGraphemes']}");
326 }
327 }
328
329 // Number constraints
330 if (is_numeric($value)) {
331 if (isset($propDef['maximum']) && $value > $propDef['maximum']) {
332 $this->addError($fieldName, "Value exceeds maximum of {$propDef['maximum']}");
333 }
334
335 if (isset($propDef['minimum']) && $value < $propDef['minimum']) {
336 $this->addError($fieldName, "Value is less than minimum of {$propDef['minimum']}");
337 }
338 }
339
340 // Array constraints
341 if (is_array($value)) {
342 $count = count($value);
343
344 if (isset($propDef['maxItems']) && $count > $propDef['maxItems']) {
345 $this->addError($fieldName, "Array exceeds maximum items of {$propDef['maxItems']}");
346 }
347
348 if (isset($propDef['minItems']) && $count < $propDef['minItems']) {
349 $this->addError($fieldName, "Array has fewer than minimum items of {$propDef['minItems']}");
350 }
351 }
352
353 // Enum constraint
354 if (isset($propDef['enum']) && ! in_array($value, $propDef['enum'], true)) {
355 $allowedValues = implode(', ', $propDef['enum']);
356 $this->addError($fieldName, "Value must be one of: {$allowedValues}");
357 }
358
359 // Const constraint
360 if (isset($propDef['const']) && $value !== $propDef['const']) {
361 $expectedValue = json_encode($propDef['const']);
362 $this->addError($fieldName, "Value must be {$expectedValue}");
363 }
364 }
365
366 /**
367 * Validate nested object.
368 *
369 * @param array<string, mixed> $propDef
370 */
371 protected function validateNestedObject(array $value, array $propDef, string $fieldName, LexiconDocument $schema): void
372 {
373 $nestedProperties = $propDef['properties'] ?? [];
374 $nestedRequired = $propDef['required'] ?? [];
375
376 // Create temporary document for nested validation
377 $nestedDoc = new LexiconDocument(
378 lexicon: 1,
379 id: $schema->id,
380 defs: ['main' => [
381 'type' => 'object',
382 'properties' => $nestedProperties,
383 'required' => $nestedRequired,
384 ]],
385 description: null,
386 source: null,
387 raw: []
388 );
389
390 $originalErrors = $this->errors;
391 $this->errors = [];
392
393 $this->validateData($value, $nestedDoc);
394
395 // Prefix nested errors with field name
396 foreach ($this->errors as $nestedField => $messages) {
397 foreach ($messages as $message) {
398 $this->addError("{$fieldName}.{$nestedField}", $message);
399 }
400 }
401
402 $this->errors = array_merge($originalErrors, $this->errors);
403 }
404
405 /**
406 * Validate array items.
407 *
408 * @param array<string, mixed> $propDef
409 */
410 protected function validateArray(array $value, array $propDef, string $fieldName, LexiconDocument $schema): void
411 {
412 if (! isset($propDef['items'])) {
413 return;
414 }
415
416 $itemDef = $propDef['items'];
417
418 foreach ($value as $index => $item) {
419 $itemFieldName = "{$fieldName}[{$index}]";
420 $this->validateProperty($item, $itemFieldName, $itemDef, $schema);
421 }
422 }
423
424 /**
425 * Validate no unknown fields are present.
426 *
427 * @param array<string> $allowedFields
428 */
429 protected function validateNoUnknownFields(array $data, array $allowedFields): void
430 {
431 foreach (array_keys($data) as $field) {
432 if (! in_array($field, $allowedFields)) {
433 $this->addError($field, "Unknown field '{$field}' is not allowed");
434 }
435 }
436 }
437
438 /**
439 * Add a validation error.
440 */
441 protected function addError(string $field, string $message): void
442 {
443 if (! isset($this->errors[$field])) {
444 $this->errors[$field] = [];
445 }
446
447 $this->errors[$field][] = $message;
448 }
449}