Parse and validate AT Protocol Lexicons with DTO generation for Laravel
at main 14 kB view raw
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}