Parse and validate AT Protocol Lexicons with DTO generation for Laravel
1<?php
2
3namespace SocialDept\AtpSchema\Validation;
4
5use SocialDept\AtpSchema\Contracts\LexiconValidator as LexiconValidatorContract;
6use SocialDept\AtpSchema\Data\LexiconDocument;
7use SocialDept\AtpSchema\Exceptions\RecordValidationException;
8use SocialDept\AtpSchema\Exceptions\SchemaValidationException;
9use SocialDept\AtpSchema\Parser\SchemaLoader;
10use SocialDept\AtpSchema\Parser\TypeParser;
11
12class LexiconValidator implements LexiconValidatorContract
13{
14 /**
15 * Schema loader for loading lexicon documents.
16 */
17 protected SchemaLoader $schemaLoader;
18
19 /**
20 * Type parser for parsing and resolving types.
21 */
22 protected TypeParser $typeParser;
23
24 /**
25 * Validation mode.
26 */
27 protected string $mode = 'strict';
28
29 /**
30 * Create a new LexiconValidator.
31 */
32 public function __construct(
33 SchemaLoader $schemaLoader,
34 ?TypeParser $typeParser = null
35 ) {
36 $this->schemaLoader = $schemaLoader;
37 $this->typeParser = $typeParser ?? new TypeParser(schemaLoader: $schemaLoader);
38 }
39
40 /**
41 * Validate data against Lexicon schema.
42 */
43 public function validate(array $data, LexiconDocument $schema): bool
44 {
45 try {
46 $this->validateRecord($schema, $data);
47
48 return true;
49 } catch (RecordValidationException|SchemaValidationException) {
50 return false;
51 }
52 }
53
54 /**
55 * Validate and return errors.
56 *
57 * @return array<string, array<string>>
58 */
59 public function validateWithErrors(array $data, LexiconDocument $schema): array
60 {
61 try {
62 $this->validateRecord($schema, $data);
63
64 return [];
65 } catch (RecordValidationException $e) {
66 return ['record' => [$e->getMessage()]];
67 } catch (SchemaValidationException $e) {
68 return ['schema' => [$e->getMessage()]];
69 }
70 }
71
72 /**
73 * Validate a specific field.
74 */
75 public function validateField(mixed $value, string $field, LexiconDocument $schema): bool
76 {
77 try {
78 $mainDef = $schema->getMainDefinition();
79
80 if ($mainDef === null) {
81 return false;
82 }
83
84 $recordSchema = $mainDef['record'] ?? null;
85
86 if ($recordSchema === null || ! is_array($recordSchema)) {
87 return false;
88 }
89
90 $properties = $recordSchema['properties'] ?? [];
91
92 if (! isset($properties[$field])) {
93 return false;
94 }
95
96 $fieldType = $this->typeParser->parse($properties[$field], $schema);
97 $fieldType->validate($value, $field);
98
99 return true;
100 } catch (RecordValidationException) {
101 return false;
102 }
103 }
104
105 /**
106 * Set validation mode (strict, optimistic, lenient).
107 */
108 public function setMode(string $mode): void
109 {
110 $this->mode = $mode;
111 }
112
113 /**
114 * Validate a record by NSID string.
115 */
116 public function validateByNsid(string $nsid, array $record): void
117 {
118 $document = $this->schemaLoader->load($nsid);
119
120 $this->validateRecord($document, $record);
121 }
122
123 /**
124 * Validate a record against a lexicon document.
125 */
126 public function validateRecord(LexiconDocument $document, array $record): void
127 {
128 if (! $document->isRecord()) {
129 throw SchemaValidationException::invalidStructure(
130 $document->getNsid(),
131 ['Schema is not a record type']
132 );
133 }
134
135 $mainDef = $document->getMainDefinition();
136
137 if ($mainDef === null) {
138 throw SchemaValidationException::invalidStructure(
139 $document->getNsid(),
140 ['Missing main definition']
141 );
142 }
143
144 // Get the record schema
145 $recordSchema = $mainDef['record'] ?? null;
146
147 if ($recordSchema === null || ! is_array($recordSchema)) {
148 throw SchemaValidationException::invalidStructure(
149 $document->getNsid(),
150 ['Invalid record schema']
151 );
152 }
153
154 // Parse and validate the record type
155 $recordType = $this->typeParser->parse($recordSchema, $document);
156 $recordType->validate($record, '$');
157 }
158
159 /**
160 * Validate a query against its lexicon schema.
161 */
162 public function validateQuery(LexiconDocument $document, array $params): void
163 {
164 if (! $document->isQuery()) {
165 throw SchemaValidationException::invalidStructure(
166 $document->getNsid(),
167 ['Schema is not a query type']
168 );
169 }
170
171 $mainDef = $document->getMainDefinition();
172
173 if ($mainDef === null) {
174 throw SchemaValidationException::invalidStructure(
175 $document->getNsid(),
176 ['Missing main definition']
177 );
178 }
179
180 // Get the parameters schema
181 $paramsSchema = $mainDef['parameters'] ?? null;
182
183 if ($paramsSchema !== null && is_array($paramsSchema)) {
184 $paramsType = $this->typeParser->parse($paramsSchema, $document);
185 $paramsType->validate($params, '$');
186 }
187 }
188
189 /**
190 * Validate a procedure against its lexicon schema.
191 */
192 public function validateProcedure(LexiconDocument $document, array $input): void
193 {
194 if (! $document->isProcedure()) {
195 throw SchemaValidationException::invalidStructure(
196 $document->getNsid(),
197 ['Schema is not a procedure type']
198 );
199 }
200
201 $mainDef = $document->getMainDefinition();
202
203 if ($mainDef === null) {
204 throw SchemaValidationException::invalidStructure(
205 $document->getNsid(),
206 ['Missing main definition']
207 );
208 }
209
210 // Get the input schema
211 $inputSchema = $mainDef['input'] ?? null;
212
213 if ($inputSchema !== null && is_array($inputSchema)) {
214 $inputType = $this->typeParser->parse($inputSchema, $document);
215 $inputType->validate($input, '$');
216 }
217 }
218
219 /**
220 * Check if a record is valid.
221 */
222 public function isValid(string $nsid, array $record): bool
223 {
224 try {
225 $this->validate($nsid, $record);
226
227 return true;
228 } catch (RecordValidationException) {
229 return false;
230 }
231 }
232
233 /**
234 * Get validation errors for a record.
235 *
236 * @return array<string>
237 */
238 public function getErrors(string $nsid, array $record): array
239 {
240 try {
241 $this->validate($nsid, $record);
242
243 return [];
244 } catch (RecordValidationException $e) {
245 return [$e->getMessage()];
246 }
247 }
248}