Parse and validate AT Protocol Lexicons with DTO generation for Laravel
at main 8.7 kB view raw
1<?php 2 3namespace SocialDept\AtpSchema\Data\Types; 4 5use SocialDept\AtpSchema\Data\TypeDefinition; 6use SocialDept\AtpSchema\Exceptions\RecordValidationException; 7 8class 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}