Parse and validate AT Protocol Lexicons with DTO generation for Laravel
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add abstract base Data class

+551
+207
src/Data/Data.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Data; 4 + 5 + use Illuminate\Contracts\Support\Arrayable; 6 + use Illuminate\Contracts\Support\Jsonable; 7 + use JsonSerializable; 8 + use Stringable; 9 + 10 + abstract class Data implements Arrayable, Jsonable, JsonSerializable, Stringable 11 + { 12 + /** 13 + * Get the lexicon NSID for this data type. 14 + */ 15 + abstract public static function getLexicon(): string; 16 + 17 + /** 18 + * Convert the data to an array. 19 + */ 20 + public function toArray(): array 21 + { 22 + $result = []; 23 + 24 + foreach (get_object_vars($this) as $property => $value) { 25 + $result[$property] = $this->serializeValue($value); 26 + } 27 + 28 + return $result; 29 + } 30 + 31 + /** 32 + * Convert the data to JSON. 33 + */ 34 + public function toJson($options = 0): string 35 + { 36 + return json_encode($this->jsonSerialize(), $options); 37 + } 38 + 39 + /** 40 + * Convert the data for JSON serialization. 41 + */ 42 + public function jsonSerialize(): mixed 43 + { 44 + return $this->toArray(); 45 + } 46 + 47 + /** 48 + * Convert the data to a string. 49 + */ 50 + public function __toString(): string 51 + { 52 + return $this->toJson(); 53 + } 54 + 55 + /** 56 + * Serialize a value for output. 57 + */ 58 + protected function serializeValue(mixed $value): mixed 59 + { 60 + if ($value instanceof self) { 61 + return $value->toArray(); 62 + } 63 + 64 + if ($value instanceof Arrayable) { 65 + return $value->toArray(); 66 + } 67 + 68 + if (is_array($value)) { 69 + return array_map(fn ($item) => $this->serializeValue($item), $value); 70 + } 71 + 72 + if ($value instanceof \DateTimeInterface) { 73 + return $value->format(\DateTimeInterface::ATOM); 74 + } 75 + 76 + return $value; 77 + } 78 + 79 + /** 80 + * Create an instance from an array. 81 + */ 82 + abstract public static function fromArray(array $data): static; 83 + 84 + /** 85 + * Create an instance from JSON. 86 + */ 87 + public static function fromJson(string $json): static 88 + { 89 + $data = json_decode($json, true); 90 + 91 + if (json_last_error() !== JSON_ERROR_NONE) { 92 + throw new \InvalidArgumentException('Invalid JSON: '.json_last_error_msg()); 93 + } 94 + 95 + return static::fromArray($data); 96 + } 97 + 98 + /** 99 + * Create an instance from an AT Protocol record. 100 + * 101 + * This is an alias for fromArray for semantic clarity 102 + * when working with AT Protocol records. 103 + */ 104 + public static function fromRecord(array $record): static 105 + { 106 + return static::fromArray($record); 107 + } 108 + 109 + /** 110 + * Convert to an AT Protocol record. 111 + * 112 + * This is an alias for toArray for semantic clarity 113 + * when working with AT Protocol records. 114 + */ 115 + public function toRecord(): array 116 + { 117 + return $this->toArray(); 118 + } 119 + 120 + /** 121 + * Check if two data objects are equal. 122 + */ 123 + public function equals(self $other): bool 124 + { 125 + if (! $other instanceof static) { 126 + return false; 127 + } 128 + 129 + return $this->toArray() === $other->toArray(); 130 + } 131 + 132 + /** 133 + * Get a hash of the data. 134 + */ 135 + public function hash(): string 136 + { 137 + return hash('sha256', $this->toJson()); 138 + } 139 + 140 + /** 141 + * Validate the data against its lexicon schema. 142 + */ 143 + public function validate(): bool 144 + { 145 + if (! function_exists('schema_validate')) { 146 + return true; 147 + } 148 + 149 + try { 150 + return schema_validate(static::getLexicon(), $this->toArray()); 151 + } catch (\Throwable) { 152 + return true; 153 + } 154 + } 155 + 156 + /** 157 + * Validate and get errors. 158 + * 159 + * @return array<string, array<string>> 160 + */ 161 + public function validateWithErrors(): array 162 + { 163 + if (! function_exists('SocialDept\Schema\schema')) { 164 + return []; 165 + } 166 + 167 + try { 168 + return schema()->validateWithErrors(static::getLexicon(), $this->toArray()); 169 + } catch (\Throwable) { 170 + return []; 171 + } 172 + } 173 + 174 + /** 175 + * Get a property value dynamically. 176 + */ 177 + public function __get(string $name): mixed 178 + { 179 + if (property_exists($this, $name)) { 180 + return $this->$name; 181 + } 182 + 183 + throw new \InvalidArgumentException("Property {$name} does not exist on ".static::class); 184 + } 185 + 186 + /** 187 + * Check if a property exists. 188 + */ 189 + public function __isset(string $name): bool 190 + { 191 + return property_exists($this, $name); 192 + } 193 + 194 + /** 195 + * Clone the data with modified properties. 196 + */ 197 + public function with(array $properties): static 198 + { 199 + $data = $this->toArray(); 200 + 201 + foreach ($properties as $key => $value) { 202 + $data[$key] = $value; 203 + } 204 + 205 + return static::fromArray($data); 206 + } 207 + }
+344
tests/Unit/Data/DataTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Data; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Schema\Data\Data; 7 + 8 + class DataTest extends TestCase 9 + { 10 + public function test_it_converts_to_array(): void 11 + { 12 + $data = new TestData('John', 30); 13 + 14 + $array = $data->toArray(); 15 + 16 + $this->assertSame([ 17 + 'name' => 'John', 18 + 'age' => 30, 19 + ], $array); 20 + } 21 + 22 + public function test_it_converts_to_json(): void 23 + { 24 + $data = new TestData('John', 30); 25 + 26 + $json = $data->toJson(); 27 + 28 + $this->assertJson($json); 29 + $this->assertSame('{"name":"John","age":30}', $json); 30 + } 31 + 32 + public function test_it_is_json_serializable(): void 33 + { 34 + $data = new TestData('John', 30); 35 + 36 + $json = json_encode($data); 37 + 38 + $this->assertSame('{"name":"John","age":30}', $json); 39 + } 40 + 41 + public function test_it_converts_to_string(): void 42 + { 43 + $data = new TestData('John', 30); 44 + 45 + $string = (string) $data; 46 + 47 + $this->assertSame('{"name":"John","age":30}', $string); 48 + } 49 + 50 + public function test_it_creates_from_array(): void 51 + { 52 + $data = TestData::fromArray([ 53 + 'name' => 'Jane', 54 + 'age' => 25, 55 + ]); 56 + 57 + $this->assertSame('Jane', $data->name); 58 + $this->assertSame(25, $data->age); 59 + } 60 + 61 + public function test_it_creates_from_json(): void 62 + { 63 + $data = TestData::fromJson('{"name":"Bob","age":40}'); 64 + 65 + $this->assertSame('Bob', $data->name); 66 + $this->assertSame(40, $data->age); 67 + } 68 + 69 + public function test_it_throws_on_invalid_json(): void 70 + { 71 + $this->expectException(\InvalidArgumentException::class); 72 + $this->expectExceptionMessage('Invalid JSON'); 73 + 74 + TestData::fromJson('{invalid json}'); 75 + } 76 + 77 + public function test_it_creates_from_record(): void 78 + { 79 + $data = TestData::fromRecord([ 80 + 'name' => 'Alice', 81 + 'age' => 35, 82 + ]); 83 + 84 + $this->assertSame('Alice', $data->name); 85 + $this->assertSame(35, $data->age); 86 + } 87 + 88 + public function test_it_converts_to_record(): void 89 + { 90 + $data = new TestData('John', 30); 91 + 92 + $record = $data->toRecord(); 93 + 94 + $this->assertSame([ 95 + 'name' => 'John', 96 + 'age' => 30, 97 + ], $record); 98 + } 99 + 100 + public function test_it_checks_equality(): void 101 + { 102 + $data1 = new TestData('John', 30); 103 + $data2 = new TestData('John', 30); 104 + $data3 = new TestData('Jane', 25); 105 + 106 + $this->assertTrue($data1->equals($data2)); 107 + $this->assertFalse($data1->equals($data3)); 108 + } 109 + 110 + public function test_it_generates_hash(): void 111 + { 112 + $data = new TestData('John', 30); 113 + 114 + $hash = $data->hash(); 115 + 116 + $this->assertIsString($hash); 117 + $this->assertSame(64, strlen($hash)); // SHA256 produces 64 hex characters 118 + } 119 + 120 + public function test_it_generates_same_hash_for_equal_data(): void 121 + { 122 + $data1 = new TestData('John', 30); 123 + $data2 = new TestData('John', 30); 124 + 125 + $this->assertSame($data1->hash(), $data2->hash()); 126 + } 127 + 128 + public function test_it_generates_different_hash_for_different_data(): void 129 + { 130 + $data1 = new TestData('John', 30); 131 + $data2 = new TestData('Jane', 25); 132 + 133 + $this->assertNotSame($data1->hash(), $data2->hash()); 134 + } 135 + 136 + public function test_it_gets_property_dynamically(): void 137 + { 138 + $data = new TestData('John', 30); 139 + 140 + $this->assertSame('John', $data->__get('name')); 141 + $this->assertSame(30, $data->__get('age')); 142 + } 143 + 144 + public function test_it_throws_on_nonexistent_property(): void 145 + { 146 + $data = new TestData('John', 30); 147 + 148 + $this->expectException(\InvalidArgumentException::class); 149 + $this->expectExceptionMessage('Property nonexistent does not exist'); 150 + 151 + $data->__get('nonexistent'); 152 + } 153 + 154 + public function test_it_checks_if_property_exists(): void 155 + { 156 + $data = new TestData('John', 30); 157 + 158 + $this->assertTrue($data->__isset('name')); 159 + $this->assertTrue($data->__isset('age')); 160 + $this->assertFalse($data->__isset('nonexistent')); 161 + } 162 + 163 + public function test_it_clones_with_modified_properties(): void 164 + { 165 + $data = new TestData('John', 30); 166 + 167 + $modified = $data->with(['age' => 31]); 168 + 169 + $this->assertSame('John', $modified->name); 170 + $this->assertSame(31, $modified->age); 171 + $this->assertNotSame($data, $modified); 172 + $this->assertSame(30, $data->age); // Original unchanged 173 + } 174 + 175 + public function test_it_serializes_nested_data_objects(): void 176 + { 177 + $nested = new TestData('Inner', 20); 178 + $parent = new TestDataWithNested('Outer', $nested); 179 + 180 + $array = $parent->toArray(); 181 + 182 + $this->assertSame([ 183 + 'name' => 'Outer', 184 + 'nested' => [ 185 + 'name' => 'Inner', 186 + 'age' => 20, 187 + ], 188 + ], $array); 189 + } 190 + 191 + public function test_it_serializes_arrays_of_data_objects(): void 192 + { 193 + $items = [ 194 + new TestData('First', 10), 195 + new TestData('Second', 20), 196 + ]; 197 + $collection = new TestDataWithArray('Collection', $items); 198 + 199 + $array = $collection->toArray(); 200 + 201 + $this->assertSame([ 202 + 'name' => 'Collection', 203 + 'items' => [ 204 + ['name' => 'First', 'age' => 10], 205 + ['name' => 'Second', 'age' => 20], 206 + ], 207 + ], $array); 208 + } 209 + 210 + public function test_it_serializes_datetime_objects(): void 211 + { 212 + $date = new \DateTime('2024-01-01T12:00:00Z'); 213 + $data = new TestDataWithDate('Event', $date); 214 + 215 + $array = $data->toArray(); 216 + 217 + $this->assertArrayHasKey('createdAt', $array); 218 + $this->assertIsString($array['createdAt']); 219 + $this->assertStringContainsString('2024-01-01', $array['createdAt']); 220 + } 221 + 222 + public function test_it_returns_lexicon_nsid(): void 223 + { 224 + $lexicon = TestData::getLexicon(); 225 + 226 + $this->assertSame('app.test.data', $lexicon); 227 + } 228 + 229 + public function test_validation_returns_true_when_helper_not_available(): void 230 + { 231 + $data = new TestData('John', 30); 232 + 233 + // Schema helper may not be available in unit tests 234 + $result = $data->validate(); 235 + 236 + $this->assertTrue($result); 237 + } 238 + 239 + public function test_validation_errors_returns_empty_when_helper_not_available(): void 240 + { 241 + $data = new TestData('John', 30); 242 + 243 + // Schema helper may not be available in unit tests 244 + $errors = $data->validateWithErrors(); 245 + 246 + $this->assertIsArray($errors); 247 + } 248 + } 249 + 250 + // Test implementations 251 + 252 + class TestData extends Data 253 + { 254 + public function __construct( 255 + public readonly string $name, 256 + public readonly int $age 257 + ) { 258 + } 259 + 260 + public static function getLexicon(): string 261 + { 262 + return 'app.test.data'; 263 + } 264 + 265 + public static function fromArray(array $data): static 266 + { 267 + return new static( 268 + name: $data['name'], 269 + age: $data['age'] 270 + ); 271 + } 272 + } 273 + 274 + class TestDataWithNested extends Data 275 + { 276 + public function __construct( 277 + public readonly string $name, 278 + public readonly TestData $nested 279 + ) { 280 + } 281 + 282 + public static function getLexicon(): string 283 + { 284 + return 'app.test.nested'; 285 + } 286 + 287 + public static function fromArray(array $data): static 288 + { 289 + return new static( 290 + name: $data['name'], 291 + nested: TestData::fromArray($data['nested']) 292 + ); 293 + } 294 + } 295 + 296 + class TestDataWithArray extends Data 297 + { 298 + /** 299 + * @param array<TestData> $items 300 + */ 301 + public function __construct( 302 + public readonly string $name, 303 + public readonly array $items 304 + ) { 305 + } 306 + 307 + public static function getLexicon(): string 308 + { 309 + return 'app.test.collection'; 310 + } 311 + 312 + public static function fromArray(array $data): static 313 + { 314 + return new static( 315 + name: $data['name'], 316 + items: array_map( 317 + fn ($item) => TestData::fromArray($item), 318 + $data['items'] 319 + ) 320 + ); 321 + } 322 + } 323 + 324 + class TestDataWithDate extends Data 325 + { 326 + public function __construct( 327 + public readonly string $name, 328 + public readonly \DateTimeInterface $createdAt 329 + ) { 330 + } 331 + 332 + public static function getLexicon(): string 333 + { 334 + return 'app.test.dated'; 335 + } 336 + 337 + public static function fromArray(array $data): static 338 + { 339 + return new static( 340 + name: $data['name'], 341 + createdAt: new \DateTime($data['createdAt']) 342 + ); 343 + } 344 + }