Parse and validate AT Protocol Lexicons with DTO generation for Laravel
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}