Parse and validate AT Protocol Lexicons with DTO generation for Laravel
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Create lexicon type system object model with primitive types

+1573 -1
+189
src/Data/LexiconDocument.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Data; 4 + 5 + use SocialDept\Schema\Exceptions\SchemaValidationException; 6 + use SocialDept\Schema\Parser\Nsid; 7 + 8 + class LexiconDocument 9 + { 10 + /** 11 + * Lexicon version. 12 + */ 13 + public readonly int $lexicon; 14 + 15 + /** 16 + * NSID identifier. 17 + */ 18 + public readonly Nsid $id; 19 + 20 + /** 21 + * Schema description. 22 + */ 23 + public readonly ?string $description; 24 + 25 + /** 26 + * Schema definitions. 27 + * 28 + * @var array<string, array> 29 + */ 30 + public readonly array $defs; 31 + 32 + /** 33 + * Raw schema data. 34 + */ 35 + public readonly array $raw; 36 + 37 + /** 38 + * Source of the schema (file path, URL, etc). 39 + */ 40 + public readonly ?string $source; 41 + 42 + /** 43 + * Create a new LexiconDocument. 44 + * 45 + * @param array<string, array> $defs 46 + */ 47 + public function __construct( 48 + int $lexicon, 49 + Nsid $id, 50 + array $defs, 51 + ?string $description = null, 52 + ?string $source = null, 53 + ?array $raw = null 54 + ) { 55 + $this->lexicon = $lexicon; 56 + $this->id = $id; 57 + $this->defs = $defs; 58 + $this->description = $description; 59 + $this->source = $source; 60 + $this->raw = $raw ?? []; 61 + } 62 + 63 + /** 64 + * Create from array data. 65 + */ 66 + public static function fromArray(array $data, ?string $source = null): self 67 + { 68 + if (! isset($data['lexicon'])) { 69 + throw SchemaValidationException::missingField('unknown', 'lexicon'); 70 + } 71 + 72 + if (! isset($data['id'])) { 73 + throw SchemaValidationException::missingField('unknown', 'id'); 74 + } 75 + 76 + if (! isset($data['defs'])) { 77 + throw SchemaValidationException::missingField($data['id'], 'defs'); 78 + } 79 + 80 + $lexicon = (int) $data['lexicon']; 81 + if ($lexicon !== 1) { 82 + throw SchemaValidationException::invalidVersion($data['id'], $lexicon); 83 + } 84 + 85 + return new self( 86 + lexicon: $lexicon, 87 + id: Nsid::parse($data['id']), 88 + defs: $data['defs'], 89 + description: $data['description'] ?? null, 90 + source: $source, 91 + raw: $data 92 + ); 93 + } 94 + 95 + /** 96 + * Get a definition by name. 97 + */ 98 + public function getDefinition(string $name): ?array 99 + { 100 + return $this->defs[$name] ?? null; 101 + } 102 + 103 + /** 104 + * Check if definition exists. 105 + */ 106 + public function hasDefinition(string $name): bool 107 + { 108 + return isset($this->defs[$name]); 109 + } 110 + 111 + /** 112 + * Get the main definition. 113 + */ 114 + public function getMainDefinition(): ?array 115 + { 116 + return $this->getDefinition('main'); 117 + } 118 + 119 + /** 120 + * Get all definition names. 121 + * 122 + * @return array<string> 123 + */ 124 + public function getDefinitionNames(): array 125 + { 126 + return array_keys($this->defs); 127 + } 128 + 129 + /** 130 + * Get NSID as string. 131 + */ 132 + public function getNsid(): string 133 + { 134 + return $this->id->toString(); 135 + } 136 + 137 + /** 138 + * Convert to array. 139 + */ 140 + public function toArray(): array 141 + { 142 + return [ 143 + 'lexicon' => $this->lexicon, 144 + 'id' => $this->id->toString(), 145 + 'description' => $this->description, 146 + 'defs' => $this->defs, 147 + ]; 148 + } 149 + 150 + /** 151 + * Check if this is a record schema. 152 + */ 153 + public function isRecord(): bool 154 + { 155 + $main = $this->getMainDefinition(); 156 + 157 + return $main !== null && ($main['type'] ?? null) === 'record'; 158 + } 159 + 160 + /** 161 + * Check if this is a query schema. 162 + */ 163 + public function isQuery(): bool 164 + { 165 + $main = $this->getMainDefinition(); 166 + 167 + return $main !== null && ($main['type'] ?? null) === 'query'; 168 + } 169 + 170 + /** 171 + * Check if this is a procedure schema. 172 + */ 173 + public function isProcedure(): bool 174 + { 175 + $main = $this->getMainDefinition(); 176 + 177 + return $main !== null && ($main['type'] ?? null) === 'procedure'; 178 + } 179 + 180 + /** 181 + * Check if this is a subscription schema. 182 + */ 183 + public function isSubscription(): bool 184 + { 185 + $main = $this->getMainDefinition(); 186 + 187 + return $main !== null && ($main['type'] ?? null) === 'subscription'; 188 + } 189 + }
+116
src/Data/TypeDefinition.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Data; 4 + 5 + abstract class TypeDefinition 6 + { 7 + /** 8 + * Type identifier (string, number, object, array, etc). 9 + */ 10 + public readonly string $type; 11 + 12 + /** 13 + * Optional description of this type. 14 + */ 15 + public readonly ?string $description; 16 + 17 + /** 18 + * Create a new TypeDefinition. 19 + */ 20 + public function __construct( 21 + string $type, 22 + ?string $description = null 23 + ) { 24 + $this->type = $type; 25 + $this->description = $description; 26 + } 27 + 28 + /** 29 + * Create type definition from array data. 30 + */ 31 + abstract public static function fromArray(array $data): self; 32 + 33 + /** 34 + * Convert type definition to array. 35 + */ 36 + abstract public function toArray(): array; 37 + 38 + /** 39 + * Validate a value against this type definition. 40 + * 41 + * @throws \SocialDept\Schema\Exceptions\RecordValidationException 42 + */ 43 + abstract public function validate(mixed $value, string $path = ''): void; 44 + 45 + /** 46 + * Check if this is a primitive type. 47 + */ 48 + public function isPrimitive(): bool 49 + { 50 + return in_array($this->type, [ 51 + 'null', 52 + 'boolean', 53 + 'integer', 54 + 'string', 55 + 'bytes', 56 + 'cid-link', 57 + 'unknown', 58 + ]); 59 + } 60 + 61 + /** 62 + * Check if this is an object type. 63 + */ 64 + public function isObject(): bool 65 + { 66 + return $this->type === 'object'; 67 + } 68 + 69 + /** 70 + * Check if this is an array type. 71 + */ 72 + public function isArray(): bool 73 + { 74 + return $this->type === 'array'; 75 + } 76 + 77 + /** 78 + * Check if this is a union type. 79 + */ 80 + public function isUnion(): bool 81 + { 82 + return $this->type === 'union'; 83 + } 84 + 85 + /** 86 + * Check if this is a ref type. 87 + */ 88 + public function isRef(): bool 89 + { 90 + return $this->type === 'ref'; 91 + } 92 + 93 + /** 94 + * Check if this is a blob type. 95 + */ 96 + public function isBlob(): bool 97 + { 98 + return $this->type === 'blob'; 99 + } 100 + 101 + /** 102 + * Get the type identifier. 103 + */ 104 + public function getType(): string 105 + { 106 + return $this->type; 107 + } 108 + 109 + /** 110 + * Get the description. 111 + */ 112 + public function getDescription(): ?string 113 + { 114 + return $this->description; 115 + } 116 + }
+72
src/Data/Types/BooleanType.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Data\Types; 4 + 5 + use SocialDept\Schema\Data\TypeDefinition; 6 + use SocialDept\Schema\Exceptions\RecordValidationException; 7 + 8 + class BooleanType extends TypeDefinition 9 + { 10 + /** 11 + * Constant value. 12 + */ 13 + public readonly ?bool $const; 14 + 15 + /** 16 + * Create a new BooleanType. 17 + */ 18 + public function __construct( 19 + ?string $description = null, 20 + ?bool $const = null 21 + ) { 22 + parent::__construct('boolean', $description); 23 + 24 + $this->const = $const; 25 + } 26 + 27 + /** 28 + * Create from array data. 29 + */ 30 + public static function fromArray(array $data): self 31 + { 32 + return new self( 33 + description: $data['description'] ?? null, 34 + const: $data['const'] ?? null 35 + ); 36 + } 37 + 38 + /** 39 + * Convert to array. 40 + */ 41 + public function toArray(): array 42 + { 43 + $array = ['type' => $this->type]; 44 + 45 + if ($this->description !== null) { 46 + $array['description'] = $this->description; 47 + } 48 + 49 + if ($this->const !== null) { 50 + $array['const'] = $this->const; 51 + } 52 + 53 + return $array; 54 + } 55 + 56 + /** 57 + * Validate a value against this type definition. 58 + */ 59 + public function validate(mixed $value, string $path = ''): void 60 + { 61 + if (! is_bool($value)) { 62 + throw RecordValidationException::invalidType($path, 'boolean', gettype($value)); 63 + } 64 + 65 + // Const validation 66 + if ($this->const !== null && $value !== $this->const) { 67 + $expected = $this->const ? 'true' : 'false'; 68 + 69 + throw RecordValidationException::invalidValue($path, "must equal {$expected}"); 70 + } 71 + } 72 + }
+124
src/Data/Types/IntegerType.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Data\Types; 4 + 5 + use SocialDept\Schema\Data\TypeDefinition; 6 + use SocialDept\Schema\Exceptions\RecordValidationException; 7 + 8 + class IntegerType extends TypeDefinition 9 + { 10 + /** 11 + * Minimum value. 12 + */ 13 + public readonly ?int $minimum; 14 + 15 + /** 16 + * Maximum value. 17 + */ 18 + public readonly ?int $maximum; 19 + 20 + /** 21 + * Allowed enum values. 22 + * 23 + * @var array<int>|null 24 + */ 25 + public readonly ?array $enum; 26 + 27 + /** 28 + * Constant value. 29 + */ 30 + public readonly ?int $const; 31 + 32 + /** 33 + * Create a new IntegerType. 34 + * 35 + * @param array<int>|null $enum 36 + */ 37 + public function __construct( 38 + ?string $description = null, 39 + ?int $minimum = null, 40 + ?int $maximum = null, 41 + ?array $enum = null, 42 + ?int $const = null 43 + ) { 44 + parent::__construct('integer', $description); 45 + 46 + $this->minimum = $minimum; 47 + $this->maximum = $maximum; 48 + $this->enum = $enum; 49 + $this->const = $const; 50 + } 51 + 52 + /** 53 + * Create from array data. 54 + */ 55 + public static function fromArray(array $data): self 56 + { 57 + return new self( 58 + description: $data['description'] ?? null, 59 + minimum: $data['minimum'] ?? null, 60 + maximum: $data['maximum'] ?? null, 61 + enum: $data['enum'] ?? null, 62 + const: $data['const'] ?? null 63 + ); 64 + } 65 + 66 + /** 67 + * Convert to array. 68 + */ 69 + public function toArray(): array 70 + { 71 + $array = ['type' => $this->type]; 72 + 73 + if ($this->description !== null) { 74 + $array['description'] = $this->description; 75 + } 76 + 77 + if ($this->minimum !== null) { 78 + $array['minimum'] = $this->minimum; 79 + } 80 + 81 + if ($this->maximum !== null) { 82 + $array['maximum'] = $this->maximum; 83 + } 84 + 85 + if ($this->enum !== null) { 86 + $array['enum'] = $this->enum; 87 + } 88 + 89 + if ($this->const !== null) { 90 + $array['const'] = $this->const; 91 + } 92 + 93 + return $array; 94 + } 95 + 96 + /** 97 + * Validate a value against this type definition. 98 + */ 99 + public function validate(mixed $value, string $path = ''): void 100 + { 101 + if (! is_int($value)) { 102 + throw RecordValidationException::invalidType($path, 'integer', gettype($value)); 103 + } 104 + 105 + // Const validation 106 + if ($this->const !== null && $value !== $this->const) { 107 + throw RecordValidationException::invalidValue($path, "must equal {$this->const}"); 108 + } 109 + 110 + // Enum validation 111 + if ($this->enum !== null && ! in_array($value, $this->enum, true)) { 112 + throw RecordValidationException::invalidValue($path, 'must be one of: '.implode(', ', $this->enum)); 113 + } 114 + 115 + // Range validation 116 + if ($this->minimum !== null && $value < $this->minimum) { 117 + throw RecordValidationException::invalidValue($path, "must be at least {$this->minimum}"); 118 + } 119 + 120 + if ($this->maximum !== null && $value > $this->maximum) { 121 + throw RecordValidationException::invalidValue($path, "must be at most {$this->maximum}"); 122 + } 123 + } 124 + }
+292
src/Data/Types/StringType.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Data\Types; 4 + 5 + use SocialDept\Schema\Data\TypeDefinition; 6 + use SocialDept\Schema\Exceptions\RecordValidationException; 7 + 8 + class StringType extends TypeDefinition 9 + { 10 + /** 11 + * Minimum string length in bytes. 12 + */ 13 + public readonly ?int $minLength; 14 + 15 + /** 16 + * Maximum string length in bytes. 17 + */ 18 + public readonly ?int $maxLength; 19 + 20 + /** 21 + * Minimum string length in graphemes. 22 + */ 23 + public readonly ?int $minGraphemes; 24 + 25 + /** 26 + * Maximum string length in graphemes. 27 + */ 28 + public readonly ?int $maxGraphemes; 29 + 30 + /** 31 + * String format (e.g., datetime, uri, at-uri, did, handle, at-identifier, nsid, cid, language). 32 + */ 33 + public readonly ?string $format; 34 + 35 + /** 36 + * Allowed enum values. 37 + * 38 + * @var array<string>|null 39 + */ 40 + public readonly ?array $enum; 41 + 42 + /** 43 + * Constant value. 44 + */ 45 + public readonly ?string $const; 46 + 47 + /** 48 + * Known values (for documentation/hints, not validation). 49 + * 50 + * @var array<string>|null 51 + */ 52 + public readonly ?array $knownValues; 53 + 54 + /** 55 + * Create a new StringType. 56 + * 57 + * @param array<string>|null $enum 58 + * @param array<string>|null $knownValues 59 + */ 60 + public function __construct( 61 + ?string $description = null, 62 + ?int $minLength = null, 63 + ?int $maxLength = null, 64 + ?int $minGraphemes = null, 65 + ?int $maxGraphemes = null, 66 + ?string $format = null, 67 + ?array $enum = null, 68 + ?string $const = null, 69 + ?array $knownValues = null 70 + ) { 71 + parent::__construct('string', $description); 72 + 73 + $this->minLength = $minLength; 74 + $this->maxLength = $maxLength; 75 + $this->minGraphemes = $minGraphemes; 76 + $this->maxGraphemes = $maxGraphemes; 77 + $this->format = $format; 78 + $this->enum = $enum; 79 + $this->const = $const; 80 + $this->knownValues = $knownValues; 81 + } 82 + 83 + /** 84 + * Create from array data. 85 + */ 86 + public static function fromArray(array $data): self 87 + { 88 + return new self( 89 + description: $data['description'] ?? null, 90 + minLength: $data['minLength'] ?? null, 91 + maxLength: $data['maxLength'] ?? null, 92 + minGraphemes: $data['minGraphemes'] ?? null, 93 + maxGraphemes: $data['maxGraphemes'] ?? null, 94 + format: $data['format'] ?? null, 95 + enum: $data['enum'] ?? null, 96 + const: $data['const'] ?? null, 97 + knownValues: $data['knownValues'] ?? null 98 + ); 99 + } 100 + 101 + /** 102 + * Convert to array. 103 + */ 104 + public function toArray(): array 105 + { 106 + $array = ['type' => $this->type]; 107 + 108 + if ($this->description !== null) { 109 + $array['description'] = $this->description; 110 + } 111 + 112 + if ($this->minLength !== null) { 113 + $array['minLength'] = $this->minLength; 114 + } 115 + 116 + if ($this->maxLength !== null) { 117 + $array['maxLength'] = $this->maxLength; 118 + } 119 + 120 + if ($this->minGraphemes !== null) { 121 + $array['minGraphemes'] = $this->minGraphemes; 122 + } 123 + 124 + if ($this->maxGraphemes !== null) { 125 + $array['maxGraphemes'] = $this->maxGraphemes; 126 + } 127 + 128 + if ($this->format !== null) { 129 + $array['format'] = $this->format; 130 + } 131 + 132 + if ($this->enum !== null) { 133 + $array['enum'] = $this->enum; 134 + } 135 + 136 + if ($this->const !== null) { 137 + $array['const'] = $this->const; 138 + } 139 + 140 + if ($this->knownValues !== null) { 141 + $array['knownValues'] = $this->knownValues; 142 + } 143 + 144 + return $array; 145 + } 146 + 147 + /** 148 + * Validate a value against this type definition. 149 + */ 150 + public function validate(mixed $value, string $path = ''): void 151 + { 152 + if (! is_string($value)) { 153 + throw RecordValidationException::invalidType($path, 'string', gettype($value)); 154 + } 155 + 156 + // Const validation 157 + if ($this->const !== null && $value !== $this->const) { 158 + throw RecordValidationException::invalidValue($path, "must equal '{$this->const}'"); 159 + } 160 + 161 + // Enum validation 162 + if ($this->enum !== null && ! in_array($value, $this->enum, true)) { 163 + throw RecordValidationException::invalidValue($path, 'must be one of: '.implode(', ', $this->enum)); 164 + } 165 + 166 + // Length validation (bytes) 167 + $length = strlen($value); 168 + 169 + if ($this->minLength !== null && $length < $this->minLength) { 170 + throw RecordValidationException::invalidValue($path, "must be at least {$this->minLength} bytes"); 171 + } 172 + 173 + if ($this->maxLength !== null && $length > $this->maxLength) { 174 + throw RecordValidationException::invalidValue($path, "must be at most {$this->maxLength} bytes"); 175 + } 176 + 177 + // Grapheme validation 178 + if ($this->minGraphemes !== null || $this->maxGraphemes !== null) { 179 + $graphemes = grapheme_strlen($value); 180 + 181 + if ($this->minGraphemes !== null && $graphemes < $this->minGraphemes) { 182 + throw RecordValidationException::invalidValue($path, "must be at least {$this->minGraphemes} graphemes"); 183 + } 184 + 185 + if ($this->maxGraphemes !== null && $graphemes > $this->maxGraphemes) { 186 + throw RecordValidationException::invalidValue($path, "must be at most {$this->maxGraphemes} graphemes"); 187 + } 188 + } 189 + 190 + // Format validation 191 + if ($this->format !== null) { 192 + $this->validateFormat($value, $path); 193 + } 194 + } 195 + 196 + /** 197 + * Validate string format. 198 + */ 199 + protected function validateFormat(string $value, string $path): void 200 + { 201 + switch ($this->format) { 202 + case 'datetime': 203 + if (! $this->isValidDatetime($value)) { 204 + throw RecordValidationException::invalidValue($path, 'must be a valid ISO 8601 datetime'); 205 + } 206 + 207 + break; 208 + 209 + case 'uri': 210 + if (! filter_var($value, FILTER_VALIDATE_URL)) { 211 + throw RecordValidationException::invalidValue($path, 'must be a valid URI'); 212 + } 213 + 214 + break; 215 + 216 + case 'at-uri': 217 + if (! str_starts_with($value, 'at://')) { 218 + throw RecordValidationException::invalidValue($path, 'must be a valid AT URI'); 219 + } 220 + 221 + break; 222 + 223 + case 'did': 224 + if (! str_starts_with($value, 'did:')) { 225 + throw RecordValidationException::invalidValue($path, 'must be a valid DID'); 226 + } 227 + 228 + break; 229 + 230 + case 'handle': 231 + if (! $this->isValidHandle($value)) { 232 + throw RecordValidationException::invalidValue($path, 'must be a valid handle'); 233 + } 234 + 235 + break; 236 + 237 + case 'at-identifier': 238 + // Can be either DID or handle 239 + if (! str_starts_with($value, 'did:') && ! $this->isValidHandle($value)) { 240 + throw RecordValidationException::invalidValue($path, 'must be a valid AT identifier (DID or handle)'); 241 + } 242 + 243 + break; 244 + 245 + case 'nsid': 246 + if (! preg_match('/^[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/', $value)) { 247 + throw RecordValidationException::invalidValue($path, 'must be a valid NSID'); 248 + } 249 + 250 + break; 251 + 252 + case 'cid': 253 + // Basic CID validation (starts with proper characters) 254 + if (! preg_match('/^[a-zA-Z0-9]+$/', $value)) { 255 + throw RecordValidationException::invalidValue($path, 'must be a valid CID'); 256 + } 257 + 258 + break; 259 + 260 + case 'language': 261 + // Basic language tag validation (BCP 47) 262 + if (! preg_match('/^[a-z]{2,3}(-[A-Z][a-z]{3})?(-[A-Z]{2})?$/', $value)) { 263 + throw RecordValidationException::invalidValue($path, 'must be a valid language tag'); 264 + } 265 + 266 + break; 267 + } 268 + } 269 + 270 + /** 271 + * Check if value is a valid ISO 8601 datetime. 272 + */ 273 + protected function isValidDatetime(string $value): bool 274 + { 275 + try { 276 + new \DateTimeImmutable($value); 277 + 278 + return true; 279 + } catch (\Exception) { 280 + return false; 281 + } 282 + } 283 + 284 + /** 285 + * Check if value is a valid handle. 286 + */ 287 + protected function isValidHandle(string $value): bool 288 + { 289 + // Basic handle validation: domain-like format 290 + return (bool) preg_match('/^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/', $value); 291 + } 292 + }
+48
src/Data/Types/UnknownType.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Data\Types; 4 + 5 + use SocialDept\Schema\Data\TypeDefinition; 6 + 7 + class UnknownType extends TypeDefinition 8 + { 9 + /** 10 + * Create a new UnknownType. 11 + */ 12 + public function __construct(?string $description = null) 13 + { 14 + parent::__construct('unknown', $description); 15 + } 16 + 17 + /** 18 + * Create from array data. 19 + */ 20 + public static function fromArray(array $data): self 21 + { 22 + return new self( 23 + description: $data['description'] ?? null 24 + ); 25 + } 26 + 27 + /** 28 + * Convert to array. 29 + */ 30 + public function toArray(): array 31 + { 32 + $array = ['type' => $this->type]; 33 + 34 + if ($this->description !== null) { 35 + $array['description'] = $this->description; 36 + } 37 + 38 + return $array; 39 + } 40 + 41 + /** 42 + * Validate a value against this type definition. 43 + */ 44 + public function validate(mixed $value, string $path = ''): void 45 + { 46 + // Unknown type accepts any value 47 + } 48 + }
+22
src/Exceptions/RecordValidationException.php
··· 50 50 ['field' => $field, 'constraint' => $constraint, 'value' => $value] 51 51 ); 52 52 } 53 + 54 + /** 55 + * Create exception for invalid type. 56 + */ 57 + public static function invalidType(string $path, string $expected, string $actual): self 58 + { 59 + return static::withContext( 60 + "Expected type '{$expected}' at '{$path}' but got '{$actual}'", 61 + ['path' => $path, 'expected' => $expected, 'actual' => $actual] 62 + ); 63 + } 64 + 65 + /** 66 + * Create exception for invalid value. 67 + */ 68 + public static function invalidValue(string $path, string $reason): self 69 + { 70 + return static::withContext( 71 + "Invalid value at '{$path}': {$reason}", 72 + ['path' => $path, 'reason' => $reason] 73 + ); 74 + } 53 75 }
+1 -1
src/Exceptions/TypeResolutionException.php
··· 18 18 /** 19 19 * Create exception for unresolvable reference. 20 20 */ 21 - public static function unresolv ableReference(string $ref, string $nsid): self 21 + public static function unresolvableReference(string $ref, string $nsid): self 22 22 { 23 23 return static::withContext( 24 24 "Cannot resolve reference {$ref} in schema {$nsid}",
+237
tests/Unit/Data/LexiconDocumentTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Data; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Schema\Data\LexiconDocument; 7 + use SocialDept\Schema\Exceptions\SchemaValidationException; 8 + use SocialDept\Schema\Parser\Nsid; 9 + 10 + class LexiconDocumentTest extends TestCase 11 + { 12 + public function test_it_creates_from_array(): void 13 + { 14 + $data = [ 15 + 'lexicon' => 1, 16 + 'id' => 'app.bsky.feed.post', 17 + 'description' => 'A post record', 18 + 'defs' => [ 19 + 'main' => [ 20 + 'type' => 'record', 21 + ], 22 + ], 23 + ]; 24 + 25 + $doc = LexiconDocument::fromArray($data, 'test.json'); 26 + 27 + $this->assertSame(1, $doc->lexicon); 28 + $this->assertInstanceOf(Nsid::class, $doc->id); 29 + $this->assertSame('app.bsky.feed.post', $doc->id->toString()); 30 + $this->assertSame('A post record', $doc->description); 31 + $this->assertArrayHasKey('main', $doc->defs); 32 + $this->assertSame('test.json', $doc->source); 33 + $this->assertSame($data, $doc->raw); 34 + } 35 + 36 + public function test_it_throws_on_missing_lexicon(): void 37 + { 38 + $this->expectException(SchemaValidationException::class); 39 + $this->expectExceptionMessage('Required field missing in schema unknown: lexicon'); 40 + 41 + LexiconDocument::fromArray([ 42 + 'id' => 'app.bsky.feed.post', 43 + 'defs' => [], 44 + ]); 45 + } 46 + 47 + public function test_it_throws_on_missing_id(): void 48 + { 49 + $this->expectException(SchemaValidationException::class); 50 + $this->expectExceptionMessage('Required field missing in schema unknown: id'); 51 + 52 + LexiconDocument::fromArray([ 53 + 'lexicon' => 1, 54 + 'defs' => [], 55 + ]); 56 + } 57 + 58 + public function test_it_throws_on_missing_defs(): void 59 + { 60 + $this->expectException(SchemaValidationException::class); 61 + $this->expectExceptionMessage('Required field missing in schema app.bsky.feed.post: defs'); 62 + 63 + LexiconDocument::fromArray([ 64 + 'lexicon' => 1, 65 + 'id' => 'app.bsky.feed.post', 66 + ]); 67 + } 68 + 69 + public function test_it_throws_on_invalid_lexicon_version(): void 70 + { 71 + $this->expectException(SchemaValidationException::class); 72 + $this->expectExceptionMessage('Unsupported lexicon version 2 in schema app.bsky.feed.post'); 73 + 74 + LexiconDocument::fromArray([ 75 + 'lexicon' => 2, 76 + 'id' => 'app.bsky.feed.post', 77 + 'defs' => [], 78 + ]); 79 + } 80 + 81 + public function test_it_gets_definition(): void 82 + { 83 + $doc = LexiconDocument::fromArray([ 84 + 'lexicon' => 1, 85 + 'id' => 'app.bsky.feed.post', 86 + 'defs' => [ 87 + 'main' => ['type' => 'record'], 88 + 'other' => ['type' => 'object'], 89 + ], 90 + ]); 91 + 92 + $this->assertSame(['type' => 'record'], $doc->getDefinition('main')); 93 + $this->assertSame(['type' => 'object'], $doc->getDefinition('other')); 94 + $this->assertNull($doc->getDefinition('nonexistent')); 95 + } 96 + 97 + public function test_it_checks_definition_exists(): void 98 + { 99 + $doc = LexiconDocument::fromArray([ 100 + 'lexicon' => 1, 101 + 'id' => 'app.bsky.feed.post', 102 + 'defs' => [ 103 + 'main' => ['type' => 'record'], 104 + ], 105 + ]); 106 + 107 + $this->assertTrue($doc->hasDefinition('main')); 108 + $this->assertFalse($doc->hasDefinition('nonexistent')); 109 + } 110 + 111 + public function test_it_gets_main_definition(): void 112 + { 113 + $doc = LexiconDocument::fromArray([ 114 + 'lexicon' => 1, 115 + 'id' => 'app.bsky.feed.post', 116 + 'defs' => [ 117 + 'main' => ['type' => 'record'], 118 + ], 119 + ]); 120 + 121 + $this->assertSame(['type' => 'record'], $doc->getMainDefinition()); 122 + } 123 + 124 + public function test_it_gets_definition_names(): void 125 + { 126 + $doc = LexiconDocument::fromArray([ 127 + 'lexicon' => 1, 128 + 'id' => 'app.bsky.feed.post', 129 + 'defs' => [ 130 + 'main' => ['type' => 'record'], 131 + 'other' => ['type' => 'object'], 132 + 'another' => ['type' => 'string'], 133 + ], 134 + ]); 135 + 136 + $names = $doc->getDefinitionNames(); 137 + 138 + $this->assertCount(3, $names); 139 + $this->assertContains('main', $names); 140 + $this->assertContains('other', $names); 141 + $this->assertContains('another', $names); 142 + } 143 + 144 + public function test_it_gets_nsid(): void 145 + { 146 + $doc = LexiconDocument::fromArray([ 147 + 'lexicon' => 1, 148 + 'id' => 'app.bsky.feed.post', 149 + 'defs' => [], 150 + ]); 151 + 152 + $this->assertSame('app.bsky.feed.post', $doc->getNsid()); 153 + } 154 + 155 + public function test_it_converts_to_array(): void 156 + { 157 + $doc = LexiconDocument::fromArray([ 158 + 'lexicon' => 1, 159 + 'id' => 'app.bsky.feed.post', 160 + 'description' => 'A post record', 161 + 'defs' => [ 162 + 'main' => ['type' => 'record'], 163 + ], 164 + ]); 165 + 166 + $array = $doc->toArray(); 167 + 168 + $this->assertSame(1, $array['lexicon']); 169 + $this->assertSame('app.bsky.feed.post', $array['id']); 170 + $this->assertSame('A post record', $array['description']); 171 + $this->assertArrayHasKey('main', $array['defs']); 172 + } 173 + 174 + public function test_it_identifies_record_schema(): void 175 + { 176 + $doc = LexiconDocument::fromArray([ 177 + 'lexicon' => 1, 178 + 'id' => 'app.bsky.feed.post', 179 + 'defs' => [ 180 + 'main' => ['type' => 'record'], 181 + ], 182 + ]); 183 + 184 + $this->assertTrue($doc->isRecord()); 185 + $this->assertFalse($doc->isQuery()); 186 + $this->assertFalse($doc->isProcedure()); 187 + $this->assertFalse($doc->isSubscription()); 188 + } 189 + 190 + public function test_it_identifies_query_schema(): void 191 + { 192 + $doc = LexiconDocument::fromArray([ 193 + 'lexicon' => 1, 194 + 'id' => 'com.atproto.repo.getRecord', 195 + 'defs' => [ 196 + 'main' => ['type' => 'query'], 197 + ], 198 + ]); 199 + 200 + $this->assertTrue($doc->isQuery()); 201 + $this->assertFalse($doc->isRecord()); 202 + $this->assertFalse($doc->isProcedure()); 203 + $this->assertFalse($doc->isSubscription()); 204 + } 205 + 206 + public function test_it_identifies_procedure_schema(): void 207 + { 208 + $doc = LexiconDocument::fromArray([ 209 + 'lexicon' => 1, 210 + 'id' => 'com.atproto.repo.createRecord', 211 + 'defs' => [ 212 + 'main' => ['type' => 'procedure'], 213 + ], 214 + ]); 215 + 216 + $this->assertTrue($doc->isProcedure()); 217 + $this->assertFalse($doc->isRecord()); 218 + $this->assertFalse($doc->isQuery()); 219 + $this->assertFalse($doc->isSubscription()); 220 + } 221 + 222 + public function test_it_identifies_subscription_schema(): void 223 + { 224 + $doc = LexiconDocument::fromArray([ 225 + 'lexicon' => 1, 226 + 'id' => 'com.atproto.sync.subscribeRepos', 227 + 'defs' => [ 228 + 'main' => ['type' => 'subscription'], 229 + ], 230 + ]); 231 + 232 + $this->assertTrue($doc->isSubscription()); 233 + $this->assertFalse($doc->isRecord()); 234 + $this->assertFalse($doc->isQuery()); 235 + $this->assertFalse($doc->isProcedure()); 236 + } 237 + }
+74
tests/Unit/Data/Types/BooleanTypeTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Data\Types; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Schema\Data\Types\BooleanType; 7 + use SocialDept\Schema\Exceptions\RecordValidationException; 8 + 9 + class BooleanTypeTest extends TestCase 10 + { 11 + public function test_it_creates_from_array(): void 12 + { 13 + $type = BooleanType::fromArray([ 14 + 'type' => 'boolean', 15 + 'description' => 'A boolean value', 16 + 'const' => true, 17 + ]); 18 + 19 + $this->assertSame('boolean', $type->type); 20 + $this->assertSame('A boolean value', $type->description); 21 + $this->assertTrue($type->const); 22 + } 23 + 24 + public function test_it_converts_to_array(): void 25 + { 26 + $type = new BooleanType( 27 + description: 'A boolean value', 28 + const: false 29 + ); 30 + 31 + $array = $type->toArray(); 32 + 33 + $this->assertSame('boolean', $array['type']); 34 + $this->assertSame('A boolean value', $array['description']); 35 + $this->assertFalse($array['const']); 36 + } 37 + 38 + public function test_it_validates_boolean_type(): void 39 + { 40 + $type = new BooleanType(); 41 + 42 + $type->validate(true, 'field'); 43 + $type->validate(false, 'field'); 44 + 45 + $this->expectException(RecordValidationException::class); 46 + $this->expectExceptionMessage("Expected type 'boolean' at 'field' but got 'string'"); 47 + 48 + $type->validate('true', 'field'); 49 + } 50 + 51 + public function test_it_validates_const_true(): void 52 + { 53 + $type = new BooleanType(const: true); 54 + 55 + $type->validate(true, 'field'); 56 + 57 + $this->expectException(RecordValidationException::class); 58 + $this->expectExceptionMessage('Invalid value at \'field\': must equal true'); 59 + 60 + $type->validate(false, 'field'); 61 + } 62 + 63 + public function test_it_validates_const_false(): void 64 + { 65 + $type = new BooleanType(const: false); 66 + 67 + $type->validate(false, 'field'); 68 + 69 + $this->expectException(RecordValidationException::class); 70 + $this->expectExceptionMessage('Invalid value at \'field\': must equal false'); 71 + 72 + $type->validate(true, 'field'); 73 + } 74 + }
+106
tests/Unit/Data/Types/IntegerTypeTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Data\Types; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Schema\Data\Types\IntegerType; 7 + use SocialDept\Schema\Exceptions\RecordValidationException; 8 + 9 + class IntegerTypeTest extends TestCase 10 + { 11 + public function test_it_creates_from_array(): void 12 + { 13 + $type = IntegerType::fromArray([ 14 + 'type' => 'integer', 15 + 'description' => 'An integer value', 16 + 'minimum' => 1, 17 + 'maximum' => 100, 18 + 'enum' => [1, 2, 3], 19 + 'const' => 5, 20 + ]); 21 + 22 + $this->assertSame('integer', $type->type); 23 + $this->assertSame('An integer value', $type->description); 24 + $this->assertSame(1, $type->minimum); 25 + $this->assertSame(100, $type->maximum); 26 + $this->assertSame([1, 2, 3], $type->enum); 27 + $this->assertSame(5, $type->const); 28 + } 29 + 30 + public function test_it_converts_to_array(): void 31 + { 32 + $type = new IntegerType( 33 + description: 'An integer value', 34 + minimum: 1, 35 + maximum: 100 36 + ); 37 + 38 + $array = $type->toArray(); 39 + 40 + $this->assertSame('integer', $array['type']); 41 + $this->assertSame('An integer value', $array['description']); 42 + $this->assertSame(1, $array['minimum']); 43 + $this->assertSame(100, $array['maximum']); 44 + } 45 + 46 + public function test_it_validates_integer_type(): void 47 + { 48 + $type = new IntegerType(); 49 + 50 + $this->expectException(RecordValidationException::class); 51 + $this->expectExceptionMessage("Expected type 'integer' at 'field' but got 'string'"); 52 + 53 + $type->validate('123', 'field'); 54 + } 55 + 56 + public function test_it_validates_const(): void 57 + { 58 + $type = new IntegerType(const: 5); 59 + 60 + $type->validate(5, 'field'); 61 + 62 + $this->expectException(RecordValidationException::class); 63 + $this->expectExceptionMessage('Invalid value at \'field\': must equal 5'); 64 + 65 + $type->validate(10, 'field'); 66 + } 67 + 68 + public function test_it_validates_enum(): void 69 + { 70 + $type = new IntegerType(enum: [1, 2, 3]); 71 + 72 + $type->validate(1, 'field'); 73 + $type->validate(2, 'field'); 74 + 75 + $this->expectException(RecordValidationException::class); 76 + $this->expectExceptionMessage('Invalid value at \'field\': must be one of: 1, 2, 3'); 77 + 78 + $type->validate(5, 'field'); 79 + } 80 + 81 + public function test_it_validates_minimum(): void 82 + { 83 + $type = new IntegerType(minimum: 10); 84 + 85 + $type->validate(10, 'field'); 86 + $type->validate(20, 'field'); 87 + 88 + $this->expectException(RecordValidationException::class); 89 + $this->expectExceptionMessage('Invalid value at \'field\': must be at least 10'); 90 + 91 + $type->validate(5, 'field'); 92 + } 93 + 94 + public function test_it_validates_maximum(): void 95 + { 96 + $type = new IntegerType(maximum: 10); 97 + 98 + $type->validate(10, 'field'); 99 + $type->validate(5, 'field'); 100 + 101 + $this->expectException(RecordValidationException::class); 102 + $this->expectExceptionMessage('Invalid value at \'field\': must be at most 10'); 103 + 104 + $type->validate(20, 'field'); 105 + } 106 + }
+248
tests/Unit/Data/Types/StringTypeTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Data\Types; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Schema\Data\Types\StringType; 7 + use SocialDept\Schema\Exceptions\RecordValidationException; 8 + 9 + class StringTypeTest extends TestCase 10 + { 11 + public function test_it_creates_from_array(): void 12 + { 13 + $type = StringType::fromArray([ 14 + 'type' => 'string', 15 + 'description' => 'A string value', 16 + 'minLength' => 1, 17 + 'maxLength' => 100, 18 + 'minGraphemes' => 1, 19 + 'maxGraphemes' => 50, 20 + 'format' => 'datetime', 21 + 'enum' => ['foo', 'bar'], 22 + 'const' => 'foo', 23 + 'knownValues' => ['foo', 'bar', 'baz'], 24 + ]); 25 + 26 + $this->assertSame('string', $type->type); 27 + $this->assertSame('A string value', $type->description); 28 + $this->assertSame(1, $type->minLength); 29 + $this->assertSame(100, $type->maxLength); 30 + $this->assertSame(1, $type->minGraphemes); 31 + $this->assertSame(50, $type->maxGraphemes); 32 + $this->assertSame('datetime', $type->format); 33 + $this->assertSame(['foo', 'bar'], $type->enum); 34 + $this->assertSame('foo', $type->const); 35 + $this->assertSame(['foo', 'bar', 'baz'], $type->knownValues); 36 + } 37 + 38 + public function test_it_converts_to_array(): void 39 + { 40 + $type = new StringType( 41 + description: 'A string value', 42 + minLength: 1, 43 + maxLength: 100, 44 + format: 'datetime' 45 + ); 46 + 47 + $array = $type->toArray(); 48 + 49 + $this->assertSame('string', $array['type']); 50 + $this->assertSame('A string value', $array['description']); 51 + $this->assertSame(1, $array['minLength']); 52 + $this->assertSame(100, $array['maxLength']); 53 + $this->assertSame('datetime', $array['format']); 54 + } 55 + 56 + public function test_it_validates_string_type(): void 57 + { 58 + $type = new StringType(); 59 + 60 + $this->expectException(RecordValidationException::class); 61 + $this->expectExceptionMessage("Expected type 'string' at 'field' but got 'integer'"); 62 + 63 + $type->validate(123, 'field'); 64 + } 65 + 66 + public function test_it_validates_const(): void 67 + { 68 + $type = new StringType(const: 'foo'); 69 + 70 + $type->validate('foo', 'field'); 71 + 72 + $this->expectException(RecordValidationException::class); 73 + $this->expectExceptionMessage("Invalid value at 'field': must equal 'foo'"); 74 + 75 + $type->validate('bar', 'field'); 76 + } 77 + 78 + public function test_it_validates_enum(): void 79 + { 80 + $type = new StringType(enum: ['foo', 'bar']); 81 + 82 + $type->validate('foo', 'field'); 83 + $type->validate('bar', 'field'); 84 + 85 + $this->expectException(RecordValidationException::class); 86 + $this->expectExceptionMessage('Invalid value at \'field\': must be one of: foo, bar'); 87 + 88 + $type->validate('baz', 'field'); 89 + } 90 + 91 + public function test_it_validates_min_length(): void 92 + { 93 + $type = new StringType(minLength: 5); 94 + 95 + $type->validate('hello', 'field'); 96 + 97 + $this->expectException(RecordValidationException::class); 98 + $this->expectExceptionMessage('Invalid value at \'field\': must be at least 5 bytes'); 99 + 100 + $type->validate('hi', 'field'); 101 + } 102 + 103 + public function test_it_validates_max_length(): void 104 + { 105 + $type = new StringType(maxLength: 5); 106 + 107 + $type->validate('hello', 'field'); 108 + 109 + $this->expectException(RecordValidationException::class); 110 + $this->expectExceptionMessage('Invalid value at \'field\': must be at most 5 bytes'); 111 + 112 + $type->validate('hello world', 'field'); 113 + } 114 + 115 + public function test_it_validates_min_graphemes(): void 116 + { 117 + $type = new StringType(minGraphemes: 3); 118 + 119 + $type->validate('abc', 'field'); 120 + 121 + $this->expectException(RecordValidationException::class); 122 + $this->expectExceptionMessage('Invalid value at \'field\': must be at least 3 graphemes'); 123 + 124 + $type->validate('ab', 'field'); 125 + } 126 + 127 + public function test_it_validates_max_graphemes(): void 128 + { 129 + $type = new StringType(maxGraphemes: 3); 130 + 131 + $type->validate('abc', 'field'); 132 + 133 + $this->expectException(RecordValidationException::class); 134 + $this->expectExceptionMessage('Invalid value at \'field\': must be at most 3 graphemes'); 135 + 136 + $type->validate('abcd', 'field'); 137 + } 138 + 139 + public function test_it_validates_datetime_format(): void 140 + { 141 + $type = new StringType(format: 'datetime'); 142 + 143 + $type->validate('2024-01-01T00:00:00Z', 'field'); 144 + 145 + $this->expectException(RecordValidationException::class); 146 + $this->expectExceptionMessage('Invalid value at \'field\': must be a valid ISO 8601 datetime'); 147 + 148 + $type->validate('not a datetime', 'field'); 149 + } 150 + 151 + public function test_it_validates_uri_format(): void 152 + { 153 + $type = new StringType(format: 'uri'); 154 + 155 + $type->validate('https://example.com', 'field'); 156 + 157 + $this->expectException(RecordValidationException::class); 158 + $this->expectExceptionMessage('Invalid value at \'field\': must be a valid URI'); 159 + 160 + $type->validate('not a uri', 'field'); 161 + } 162 + 163 + public function test_it_validates_at_uri_format(): void 164 + { 165 + $type = new StringType(format: 'at-uri'); 166 + 167 + $type->validate('at://did:plc:123/app.bsky.feed.post/123', 'field'); 168 + 169 + $this->expectException(RecordValidationException::class); 170 + $this->expectExceptionMessage('Invalid value at \'field\': must be a valid AT URI'); 171 + 172 + $type->validate('https://example.com', 'field'); 173 + } 174 + 175 + public function test_it_validates_did_format(): void 176 + { 177 + $type = new StringType(format: 'did'); 178 + 179 + $type->validate('did:plc:123abc', 'field'); 180 + 181 + $this->expectException(RecordValidationException::class); 182 + $this->expectExceptionMessage('Invalid value at \'field\': must be a valid DID'); 183 + 184 + $type->validate('not a did', 'field'); 185 + } 186 + 187 + public function test_it_validates_handle_format(): void 188 + { 189 + $type = new StringType(format: 'handle'); 190 + 191 + $type->validate('alice.bsky.social', 'field'); 192 + 193 + $this->expectException(RecordValidationException::class); 194 + $this->expectExceptionMessage('Invalid value at \'field\': must be a valid handle'); 195 + 196 + $type->validate('invalid handle!', 'field'); 197 + } 198 + 199 + public function test_it_validates_at_identifier_format(): void 200 + { 201 + $type = new StringType(format: 'at-identifier'); 202 + 203 + $type->validate('did:plc:123abc', 'field'); 204 + $type->validate('alice.bsky.social', 'field'); 205 + 206 + $this->expectException(RecordValidationException::class); 207 + $this->expectExceptionMessage('Invalid value at \'field\': must be a valid AT identifier (DID or handle)'); 208 + 209 + $type->validate('invalid!', 'field'); 210 + } 211 + 212 + public function test_it_validates_nsid_format(): void 213 + { 214 + $type = new StringType(format: 'nsid'); 215 + 216 + $type->validate('app.bsky.feed.post', 'field'); 217 + 218 + $this->expectException(RecordValidationException::class); 219 + $this->expectExceptionMessage('Invalid value at \'field\': must be a valid NSID'); 220 + 221 + $type->validate('invalid nsid!', 'field'); 222 + } 223 + 224 + public function test_it_validates_cid_format(): void 225 + { 226 + $type = new StringType(format: 'cid'); 227 + 228 + $type->validate('bafyreihqhqv7h2gfxkj7qxvz7pxqhqvz7h2gfxkj7', 'field'); 229 + 230 + $this->expectException(RecordValidationException::class); 231 + $this->expectExceptionMessage('Invalid value at \'field\': must be a valid CID'); 232 + 233 + $type->validate('invalid cid!', 'field'); 234 + } 235 + 236 + public function test_it_validates_language_format(): void 237 + { 238 + $type = new StringType(format: 'language'); 239 + 240 + $type->validate('en', 'field'); 241 + $type->validate('en-US', 'field'); 242 + 243 + $this->expectException(RecordValidationException::class); 244 + $this->expectExceptionMessage('Invalid value at \'field\': must be a valid language tag'); 245 + 246 + $type->validate('invalid', 'field'); 247 + } 248 + }
+44
tests/Unit/Data/Types/UnknownTypeTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Data\Types; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Schema\Data\Types\UnknownType; 7 + 8 + class UnknownTypeTest extends TestCase 9 + { 10 + public function test_it_creates_from_array(): void 11 + { 12 + $type = UnknownType::fromArray([ 13 + 'type' => 'unknown', 14 + 'description' => 'An unknown value', 15 + ]); 16 + 17 + $this->assertSame('unknown', $type->type); 18 + $this->assertSame('An unknown value', $type->description); 19 + } 20 + 21 + public function test_it_converts_to_array(): void 22 + { 23 + $type = new UnknownType(description: 'An unknown value'); 24 + 25 + $array = $type->toArray(); 26 + 27 + $this->assertSame('unknown', $array['type']); 28 + $this->assertSame('An unknown value', $array['description']); 29 + } 30 + 31 + public function test_it_accepts_any_value(): void 32 + { 33 + $type = new UnknownType(); 34 + 35 + // Unknown type should accept any value without throwing 36 + $type->validate('string', 'field'); 37 + $type->validate(123, 'field'); 38 + $type->validate(true, 'field'); 39 + $type->validate(['array'], 'field'); 40 + $type->validate(null, 'field'); 41 + 42 + $this->assertTrue(true); // If we get here, validation passed 43 + } 44 + }