Parse and validate AT Protocol Lexicons with DTO generation for Laravel
1<?php
2
3namespace SocialDept\AtpSchema\Data;
4
5use Illuminate\Contracts\Support\Arrayable;
6use Illuminate\Contracts\Support\Jsonable;
7use JsonSerializable;
8use SocialDept\AtpSchema\Contracts\DiscriminatedUnion;
9use Stringable;
10
11abstract class Data implements Arrayable, DiscriminatedUnion, Jsonable, JsonSerializable, Stringable
12{
13 /**
14 * Get the lexicon NSID for this data type.
15 */
16 abstract public static function getLexicon(): string;
17
18 /**
19 * Get the lexicon NSID that identifies this union variant.
20 *
21 * This is an alias for getLexicon() to satisfy the DiscriminatedUnion contract.
22 */
23 public static function getDiscriminator(): string
24 {
25 return static::getLexicon();
26 }
27
28 /**
29 * Convert the data to an array.
30 */
31 public function toArray(): array
32 {
33 $result = [];
34
35 foreach (get_object_vars($this) as $property => $value) {
36 // Skip null values to exclude optional fields that aren't set
37 if ($value !== null) {
38 $result[$property] = $this->serializeValue($value);
39 }
40 }
41
42 return array_filter($result);
43 }
44
45 /**
46 * Convert the data to JSON.
47 */
48 public function toJson($options = 0): string
49 {
50 return json_encode($this->jsonSerialize(), $options);
51 }
52
53 /**
54 * Convert the data for JSON serialization.
55 */
56 public function jsonSerialize(): mixed
57 {
58 return $this->toArray();
59 }
60
61 /**
62 * Convert the data to a string.
63 */
64 public function __toString(): string
65 {
66 return $this->toJson();
67 }
68
69 /**
70 * Serialize a value for output.
71 */
72 protected function serializeValue(mixed $value): mixed
73 {
74 // Union variants must include $type for discrimination
75 if ($value instanceof self) {
76 return $value->toRecord();
77 }
78
79 if ($value instanceof Arrayable) {
80 return $value->toArray();
81 }
82
83 // Preserve arrays with $type (open union data)
84 if (is_array($value)) {
85 return array_map(fn ($item) => $this->serializeValue($item), $value);
86 }
87
88 if ($value instanceof \DateTimeInterface) {
89 return $value->format(\DateTimeInterface::ATOM);
90 }
91
92 return $value;
93 }
94
95 /**
96 * Create an instance from an array.
97 */
98 abstract public static function fromArray(array $data): static;
99
100 /**
101 * Create an instance from JSON.
102 */
103 public static function fromJson(string $json): static
104 {
105 $data = json_decode($json, true);
106
107 if (json_last_error() !== JSON_ERROR_NONE) {
108 throw new \InvalidArgumentException('Invalid JSON: '.json_last_error_msg());
109 }
110
111 return static::fromArray($data);
112 }
113
114 /**
115 * Create an instance from an AT Protocol record.
116 *
117 * This is an alias for fromArray for semantic clarity
118 * when working with AT Protocol records.
119 */
120 public static function fromRecord(array $record): static
121 {
122 return static::fromArray($record['value'] ?? $record);
123 }
124
125 /**
126 * Convert to an AT Protocol record.
127 *
128 * This is an alias for toArray for semantic clarity
129 * when working with AT Protocol records.
130 */
131 public function toRecord(): array
132 {
133 return [
134 ...$this->toArray(),
135 '$type' => $this->getLexicon(),
136 ];
137 }
138
139 /**
140 * Check if two data objects are equal.
141 */
142 public function equals(self $other): bool
143 {
144 if (! $other instanceof static) {
145 return false;
146 }
147
148 return $this->toArray() === $other->toArray();
149 }
150
151 /**
152 * Get a hash of the data.
153 */
154 public function hash(): string
155 {
156 return hash('sha256', $this->toJson());
157 }
158
159 /**
160 * Validate the data against its lexicon schema.
161 */
162 public function validate(): bool
163 {
164 if (! function_exists('schema_validate')) {
165 return true;
166 }
167
168 try {
169 return schema_validate(static::getLexicon(), $this->toArray());
170 } catch (\Throwable) {
171 return true;
172 }
173 }
174
175 /**
176 * Validate and get errors.
177 *
178 * @return array<string, array<string>>
179 */
180 public function validateWithErrors(): array
181 {
182 if (! function_exists('SocialDept\AtpSchema\schema')) {
183 return [];
184 }
185
186 try {
187 return schema()->validateWithErrors(static::getLexicon(), $this->toArray());
188 } catch (\Throwable) {
189 return [];
190 }
191 }
192
193 /**
194 * Get a property value dynamically.
195 */
196 public function __get(string $name): mixed
197 {
198 if (property_exists($this, $name)) {
199 return $this->$name;
200 }
201
202 throw new \InvalidArgumentException("Property {$name} does not exist on ".static::class);
203 }
204
205 /**
206 * Check if a property exists.
207 */
208 public function __isset(string $name): bool
209 {
210 return property_exists($this, $name);
211 }
212
213 /**
214 * Clone the data with modified properties.
215 */
216 public function with(array $properties): static
217 {
218 $data = $this->toArray();
219
220 foreach ($properties as $key => $value) {
221 $data[$key] = $value;
222 }
223
224 return static::fromArray($data);
225 }
226}