Parse and validate AT Protocol Lexicons with DTO generation for Laravel
1<?php
2
3namespace SocialDept\AtpSchema\Services;
4
5use Illuminate\Support\Traits\Macroable;
6use SocialDept\AtpSchema\Contracts\LexiconRegistry;
7use SocialDept\AtpSchema\Data\LexiconDocument;
8use SocialDept\AtpSchema\Exceptions\RecordValidationException;
9
10class UnionResolver
11{
12 use Macroable;
13
14 /**
15 * Create a new UnionResolver.
16 */
17 public function __construct(
18 protected ?LexiconRegistry $registry = null
19 ) {
20 }
21
22 /**
23 * Resolve union type from data.
24 *
25 * Returns the NSID of the matched type for discriminated unions,
26 * or null for open unions.
27 */
28 public function resolve(mixed $data, array $unionDef): ?string
29 {
30 // Check if this is a closed/discriminated union
31 $closed = $unionDef['closed'] ?? false;
32
33 if ($closed) {
34 return $this->resolveDiscriminated($data, $unionDef);
35 }
36
37 return null;
38 }
39
40 /**
41 * Resolve discriminated union type.
42 */
43 protected function resolveDiscriminated(mixed $data, array $unionDef): string
44 {
45 if (! is_array($data)) {
46 throw RecordValidationException::invalidType('union', 'object', gettype($data));
47 }
48
49 if (! isset($data['$type'])) {
50 throw RecordValidationException::invalidValue('union', 'Missing required $type field');
51 }
52
53 $type = $data['$type'];
54 $refs = $unionDef['refs'] ?? [];
55
56 if (! in_array($type, $refs, true)) {
57 throw RecordValidationException::invalidValue(
58 'union',
59 "Type '{$type}' not in union. Allowed: ".implode(', ', $refs)
60 );
61 }
62
63 return $type;
64 }
65
66 /**
67 * Check if data matches a specific union type.
68 */
69 public function matches(mixed $data, string $expectedType, array $unionDef): bool
70 {
71 try {
72 $resolvedType = $this->resolve($data, $unionDef);
73
74 if ($resolvedType === null) {
75 // Open union - can't determine type
76 return false;
77 }
78
79 return $resolvedType === $expectedType;
80 } catch (RecordValidationException) {
81 return false;
82 }
83 }
84
85 /**
86 * Get the definition for the resolved type.
87 */
88 public function getTypeDefinition(mixed $data, array $unionDef): ?LexiconDocument
89 {
90 if ($this->registry === null) {
91 return null;
92 }
93
94 $type = $this->resolve($data, $unionDef);
95
96 if ($type === null) {
97 return null;
98 }
99
100 return $this->registry->get($type);
101 }
102
103 /**
104 * Validate that data is a valid discriminated union.
105 */
106 public function validateDiscriminated(mixed $data, array $refs): void
107 {
108 if (! is_array($data)) {
109 throw RecordValidationException::invalidType('union', 'object', gettype($data));
110 }
111
112 if (! isset($data['$type'])) {
113 throw RecordValidationException::invalidValue('union', 'Missing required $type field');
114 }
115
116 $type = $data['$type'];
117
118 if (! in_array($type, $refs, true)) {
119 throw RecordValidationException::invalidValue(
120 'union',
121 "Type '{$type}' not in union. Allowed: ".implode(', ', $refs)
122 );
123 }
124 }
125
126 /**
127 * Extract type from discriminated union data.
128 */
129 public function extractType(mixed $data): ?string
130 {
131 if (! is_array($data)) {
132 return null;
133 }
134
135 return $data['$type'] ?? null;
136 }
137
138 /**
139 * Create discriminated union data.
140 */
141 public function createDiscriminated(string $type, array $data): array
142 {
143 return [...$data, '$type' => $type];
144 }
145
146 /**
147 * Check if union definition is closed/discriminated.
148 */
149 public function isClosed(array $unionDef): bool
150 {
151 return $unionDef['closed'] ?? false;
152 }
153
154 /**
155 * Get all possible types from union definition.
156 *
157 * @return array<string>
158 */
159 public function getTypes(array $unionDef): array
160 {
161 return $unionDef['refs'] ?? [];
162 }
163
164 /**
165 * Set the lexicon registry.
166 */
167 public function setRegistry(LexiconRegistry $registry): self
168 {
169 $this->registry = $registry;
170
171 return $this;
172 }
173}