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.

Implement primitive type parsers with validation

+640
+95
src/Data/Types/BytesType.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Data\Types; 4 + 5 + use SocialDept\Schema\Data\TypeDefinition; 6 + use SocialDept\Schema\Exceptions\RecordValidationException; 7 + 8 + class BytesType extends TypeDefinition 9 + { 10 + /** 11 + * Minimum byte length. 12 + */ 13 + public readonly ?int $minLength; 14 + 15 + /** 16 + * Maximum byte length. 17 + */ 18 + public readonly ?int $maxLength; 19 + 20 + /** 21 + * Create a new BytesType. 22 + */ 23 + public function __construct( 24 + ?string $description = null, 25 + ?int $minLength = null, 26 + ?int $maxLength = null 27 + ) { 28 + parent::__construct('bytes', $description); 29 + 30 + $this->minLength = $minLength; 31 + $this->maxLength = $maxLength; 32 + } 33 + 34 + /** 35 + * Create from array data. 36 + */ 37 + public static function fromArray(array $data): self 38 + { 39 + return new self( 40 + description: $data['description'] ?? null, 41 + minLength: $data['minLength'] ?? null, 42 + maxLength: $data['maxLength'] ?? null 43 + ); 44 + } 45 + 46 + /** 47 + * Convert to array. 48 + */ 49 + public function toArray(): array 50 + { 51 + $array = ['type' => $this->type]; 52 + 53 + if ($this->description !== null) { 54 + $array['description'] = $this->description; 55 + } 56 + 57 + if ($this->minLength !== null) { 58 + $array['minLength'] = $this->minLength; 59 + } 60 + 61 + if ($this->maxLength !== null) { 62 + $array['maxLength'] = $this->maxLength; 63 + } 64 + 65 + return $array; 66 + } 67 + 68 + /** 69 + * Validate a value against this type definition. 70 + */ 71 + public function validate(mixed $value, string $path = ''): void 72 + { 73 + if (! is_string($value)) { 74 + throw RecordValidationException::invalidType($path, 'bytes (base64 string)', gettype($value)); 75 + } 76 + 77 + // Validate base64 encoding 78 + $decoded = base64_decode($value, true); 79 + 80 + if ($decoded === false || base64_encode($decoded) !== $value) { 81 + throw RecordValidationException::invalidValue($path, 'must be valid base64-encoded data'); 82 + } 83 + 84 + // Length validation on decoded bytes 85 + $length = strlen($decoded); 86 + 87 + if ($this->minLength !== null && $length < $this->minLength) { 88 + throw RecordValidationException::invalidValue($path, "must be at least {$this->minLength} bytes"); 89 + } 90 + 91 + if ($this->maxLength !== null && $length > $this->maxLength) { 92 + throw RecordValidationException::invalidValue($path, "must be at most {$this->maxLength} bytes"); 93 + } 94 + } 95 + }
+66
src/Data/Types/CidLinkType.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Data\Types; 4 + 5 + use SocialDept\Schema\Data\TypeDefinition; 6 + use SocialDept\Schema\Exceptions\RecordValidationException; 7 + 8 + class CidLinkType extends TypeDefinition 9 + { 10 + /** 11 + * Create a new CidLinkType. 12 + */ 13 + public function __construct(?string $description = null) 14 + { 15 + parent::__construct('cid-link', $description); 16 + } 17 + 18 + /** 19 + * Create from array data. 20 + */ 21 + public static function fromArray(array $data): self 22 + { 23 + return new self( 24 + description: $data['description'] ?? null 25 + ); 26 + } 27 + 28 + /** 29 + * Convert to array. 30 + */ 31 + public function toArray(): array 32 + { 33 + $array = ['type' => $this->type]; 34 + 35 + if ($this->description !== null) { 36 + $array['description'] = $this->description; 37 + } 38 + 39 + return $array; 40 + } 41 + 42 + /** 43 + * Validate a value against this type definition. 44 + */ 45 + public function validate(mixed $value, string $path = ''): void 46 + { 47 + if (! is_array($value)) { 48 + throw RecordValidationException::invalidType($path, 'cid-link (object with $link)', gettype($value)); 49 + } 50 + 51 + if (! isset($value['$link'])) { 52 + throw RecordValidationException::invalidValue($path, 'must contain $link property'); 53 + } 54 + 55 + $link = $value['$link']; 56 + 57 + if (! is_string($link)) { 58 + throw RecordValidationException::invalidValue($path, '$link must be a string'); 59 + } 60 + 61 + // Basic CID validation 62 + if (! preg_match('/^[a-zA-Z0-9]+$/', $link)) { 63 + throw RecordValidationException::invalidValue($path, '$link must be a valid CID'); 64 + } 65 + } 66 + }
+51
src/Data/Types/NullType.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Data\Types; 4 + 5 + use SocialDept\Schema\Data\TypeDefinition; 6 + use SocialDept\Schema\Exceptions\RecordValidationException; 7 + 8 + class NullType extends TypeDefinition 9 + { 10 + /** 11 + * Create a new NullType. 12 + */ 13 + public function __construct(?string $description = null) 14 + { 15 + parent::__construct('null', $description); 16 + } 17 + 18 + /** 19 + * Create from array data. 20 + */ 21 + public static function fromArray(array $data): self 22 + { 23 + return new self( 24 + description: $data['description'] ?? null 25 + ); 26 + } 27 + 28 + /** 29 + * Convert to array. 30 + */ 31 + public function toArray(): array 32 + { 33 + $array = ['type' => $this->type]; 34 + 35 + if ($this->description !== null) { 36 + $array['description'] = $this->description; 37 + } 38 + 39 + return $array; 40 + } 41 + 42 + /** 43 + * Validate a value against this type definition. 44 + */ 45 + public function validate(mixed $value, string $path = ''): void 46 + { 47 + if ($value !== null) { 48 + throw RecordValidationException::invalidType($path, 'null', gettype($value)); 49 + } 50 + } 51 + }
+75
src/Parser/PrimitiveParser.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Parser; 4 + 5 + use SocialDept\Schema\Data\TypeDefinition; 6 + use SocialDept\Schema\Data\Types\BooleanType; 7 + use SocialDept\Schema\Data\Types\BytesType; 8 + use SocialDept\Schema\Data\Types\CidLinkType; 9 + use SocialDept\Schema\Data\Types\IntegerType; 10 + use SocialDept\Schema\Data\Types\NullType; 11 + use SocialDept\Schema\Data\Types\StringType; 12 + use SocialDept\Schema\Data\Types\UnknownType; 13 + use SocialDept\Schema\Exceptions\TypeResolutionException; 14 + 15 + class PrimitiveParser 16 + { 17 + /** 18 + * Parse a primitive type definition from array data. 19 + * 20 + * @throws TypeResolutionException 21 + */ 22 + public function parse(array $data): TypeDefinition 23 + { 24 + $type = $data['type'] ?? null; 25 + 26 + if ($type === null) { 27 + throw TypeResolutionException::unknownType('(missing type field)'); 28 + } 29 + 30 + return match ($type) { 31 + 'null' => NullType::fromArray($data), 32 + 'boolean' => BooleanType::fromArray($data), 33 + 'integer' => IntegerType::fromArray($data), 34 + 'string' => StringType::fromArray($data), 35 + 'bytes' => BytesType::fromArray($data), 36 + 'cid-link' => CidLinkType::fromArray($data), 37 + 'unknown' => UnknownType::fromArray($data), 38 + default => throw TypeResolutionException::unknownType($type), 39 + }; 40 + } 41 + 42 + /** 43 + * Check if a type is a primitive type. 44 + */ 45 + public function isPrimitive(string $type): bool 46 + { 47 + return in_array($type, [ 48 + 'null', 49 + 'boolean', 50 + 'integer', 51 + 'string', 52 + 'bytes', 53 + 'cid-link', 54 + 'unknown', 55 + ]); 56 + } 57 + 58 + /** 59 + * Get all supported primitive types. 60 + * 61 + * @return array<string> 62 + */ 63 + public function getSupportedTypes(): array 64 + { 65 + return [ 66 + 'null', 67 + 'boolean', 68 + 'integer', 69 + 'string', 70 + 'bytes', 71 + 'cid-link', 72 + 'unknown', 73 + ]; 74 + } 75 + }
+95
tests/Unit/Data/Types/BytesTypeTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Data\Types; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Schema\Data\Types\BytesType; 7 + use SocialDept\Schema\Exceptions\RecordValidationException; 8 + 9 + class BytesTypeTest extends TestCase 10 + { 11 + public function test_it_creates_from_array(): void 12 + { 13 + $type = BytesType::fromArray([ 14 + 'type' => 'bytes', 15 + 'description' => 'Binary data', 16 + 'minLength' => 1, 17 + 'maxLength' => 100, 18 + ]); 19 + 20 + $this->assertSame('bytes', $type->type); 21 + $this->assertSame('Binary data', $type->description); 22 + $this->assertSame(1, $type->minLength); 23 + $this->assertSame(100, $type->maxLength); 24 + } 25 + 26 + public function test_it_converts_to_array(): void 27 + { 28 + $type = new BytesType( 29 + description: 'Binary data', 30 + minLength: 1, 31 + maxLength: 100 32 + ); 33 + 34 + $array = $type->toArray(); 35 + 36 + $this->assertSame('bytes', $array['type']); 37 + $this->assertSame('Binary data', $array['description']); 38 + $this->assertSame(1, $array['minLength']); 39 + $this->assertSame(100, $array['maxLength']); 40 + } 41 + 42 + public function test_it_validates_bytes_type(): void 43 + { 44 + $type = new BytesType(); 45 + 46 + $this->expectException(RecordValidationException::class); 47 + $this->expectExceptionMessage("Expected type 'bytes (base64 string)' at 'field' but got 'integer'"); 48 + 49 + $type->validate(123, 'field'); 50 + } 51 + 52 + public function test_it_validates_valid_base64(): void 53 + { 54 + $type = new BytesType(); 55 + 56 + $base64 = base64_encode('Hello, World!'); 57 + $type->validate($base64, 'field'); 58 + 59 + $this->assertTrue(true); 60 + } 61 + 62 + public function test_it_rejects_invalid_base64(): void 63 + { 64 + $type = new BytesType(); 65 + 66 + $this->expectException(RecordValidationException::class); 67 + $this->expectExceptionMessage('Invalid value at \'field\': must be valid base64-encoded data'); 68 + 69 + $type->validate('not valid base64!!!', 'field'); 70 + } 71 + 72 + public function test_it_validates_min_length(): void 73 + { 74 + $type = new BytesType(minLength: 10); 75 + 76 + $type->validate(base64_encode('1234567890'), 'field'); 77 + 78 + $this->expectException(RecordValidationException::class); 79 + $this->expectExceptionMessage('Invalid value at \'field\': must be at least 10 bytes'); 80 + 81 + $type->validate(base64_encode('short'), 'field'); 82 + } 83 + 84 + public function test_it_validates_max_length(): void 85 + { 86 + $type = new BytesType(maxLength: 10); 87 + 88 + $type->validate(base64_encode('1234567890'), 'field'); 89 + 90 + $this->expectException(RecordValidationException::class); 91 + $this->expectExceptionMessage('Invalid value at \'field\': must be at most 10 bytes'); 92 + 93 + $type->validate(base64_encode('this is a very long string that exceeds the limit'), 'field'); 94 + } 95 + }
+80
tests/Unit/Data/Types/CidLinkTypeTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Data\Types; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Schema\Data\Types\CidLinkType; 7 + use SocialDept\Schema\Exceptions\RecordValidationException; 8 + 9 + class CidLinkTypeTest extends TestCase 10 + { 11 + public function test_it_creates_from_array(): void 12 + { 13 + $type = CidLinkType::fromArray([ 14 + 'type' => 'cid-link', 15 + 'description' => 'A CID link', 16 + ]); 17 + 18 + $this->assertSame('cid-link', $type->type); 19 + $this->assertSame('A CID link', $type->description); 20 + } 21 + 22 + public function test_it_converts_to_array(): void 23 + { 24 + $type = new CidLinkType(description: 'A CID link'); 25 + 26 + $array = $type->toArray(); 27 + 28 + $this->assertSame('cid-link', $array['type']); 29 + $this->assertSame('A CID link', $array['description']); 30 + } 31 + 32 + public function test_it_validates_cid_link_type(): void 33 + { 34 + $type = new CidLinkType(); 35 + 36 + $this->expectException(RecordValidationException::class); 37 + $this->expectExceptionMessage("Expected type 'cid-link (object with \$link)' at 'field' but got 'string'"); 38 + 39 + $type->validate('not an object', 'field'); 40 + } 41 + 42 + public function test_it_validates_valid_cid_link(): void 43 + { 44 + $type = new CidLinkType(); 45 + 46 + $type->validate(['$link' => 'bafyreihqhqv7h2gfxkj7qxvz7pxqhqvz7h2gfxkj7'], 'field'); 47 + 48 + $this->assertTrue(true); 49 + } 50 + 51 + public function test_it_rejects_missing_link_property(): void 52 + { 53 + $type = new CidLinkType(); 54 + 55 + $this->expectException(RecordValidationException::class); 56 + $this->expectExceptionMessage('Invalid value at \'field\': must contain $link property'); 57 + 58 + $type->validate(['other' => 'value'], 'field'); 59 + } 60 + 61 + public function test_it_rejects_non_string_link(): void 62 + { 63 + $type = new CidLinkType(); 64 + 65 + $this->expectException(RecordValidationException::class); 66 + $this->expectExceptionMessage('Invalid value at \'field\': $link must be a string'); 67 + 68 + $type->validate(['$link' => 123], 'field'); 69 + } 70 + 71 + public function test_it_rejects_invalid_cid_format(): void 72 + { 73 + $type = new CidLinkType(); 74 + 75 + $this->expectException(RecordValidationException::class); 76 + $this->expectExceptionMessage('Invalid value at \'field\': $link must be a valid CID'); 77 + 78 + $type->validate(['$link' => 'invalid cid!'], 'field'); 79 + } 80 + }
+43
tests/Unit/Data/Types/NullTypeTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Data\Types; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Schema\Data\Types\NullType; 7 + use SocialDept\Schema\Exceptions\RecordValidationException; 8 + 9 + class NullTypeTest extends TestCase 10 + { 11 + public function test_it_creates_from_array(): void 12 + { 13 + $type = NullType::fromArray([ 14 + 'type' => 'null', 15 + 'description' => 'A null value', 16 + ]); 17 + 18 + $this->assertSame('null', $type->type); 19 + $this->assertSame('A null value', $type->description); 20 + } 21 + 22 + public function test_it_converts_to_array(): void 23 + { 24 + $type = new NullType(description: 'A null value'); 25 + 26 + $array = $type->toArray(); 27 + 28 + $this->assertSame('null', $array['type']); 29 + $this->assertSame('A null value', $array['description']); 30 + } 31 + 32 + public function test_it_validates_null_type(): void 33 + { 34 + $type = new NullType(); 35 + 36 + $type->validate(null, 'field'); 37 + 38 + $this->expectException(RecordValidationException::class); 39 + $this->expectExceptionMessage("Expected type 'null' at 'field' but got 'string'"); 40 + 41 + $type->validate('not null', 'field'); 42 + } 43 + }
+135
tests/Unit/Parser/PrimitiveParserTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Parser; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Schema\Data\Types\BooleanType; 7 + use SocialDept\Schema\Data\Types\BytesType; 8 + use SocialDept\Schema\Data\Types\CidLinkType; 9 + use SocialDept\Schema\Data\Types\IntegerType; 10 + use SocialDept\Schema\Data\Types\NullType; 11 + use SocialDept\Schema\Data\Types\StringType; 12 + use SocialDept\Schema\Data\Types\UnknownType; 13 + use SocialDept\Schema\Exceptions\TypeResolutionException; 14 + use SocialDept\Schema\Parser\PrimitiveParser; 15 + 16 + class PrimitiveParserTest extends TestCase 17 + { 18 + protected PrimitiveParser $parser; 19 + 20 + protected function setUp(): void 21 + { 22 + parent::setUp(); 23 + 24 + $this->parser = new PrimitiveParser(); 25 + } 26 + 27 + public function test_it_parses_null_type(): void 28 + { 29 + $type = $this->parser->parse(['type' => 'null']); 30 + 31 + $this->assertInstanceOf(NullType::class, $type); 32 + } 33 + 34 + public function test_it_parses_boolean_type(): void 35 + { 36 + $type = $this->parser->parse(['type' => 'boolean']); 37 + 38 + $this->assertInstanceOf(BooleanType::class, $type); 39 + } 40 + 41 + public function test_it_parses_integer_type(): void 42 + { 43 + $type = $this->parser->parse(['type' => 'integer']); 44 + 45 + $this->assertInstanceOf(IntegerType::class, $type); 46 + } 47 + 48 + public function test_it_parses_string_type(): void 49 + { 50 + $type = $this->parser->parse(['type' => 'string']); 51 + 52 + $this->assertInstanceOf(StringType::class, $type); 53 + } 54 + 55 + public function test_it_parses_bytes_type(): void 56 + { 57 + $type = $this->parser->parse(['type' => 'bytes']); 58 + 59 + $this->assertInstanceOf(BytesType::class, $type); 60 + } 61 + 62 + public function test_it_parses_cid_link_type(): void 63 + { 64 + $type = $this->parser->parse(['type' => 'cid-link']); 65 + 66 + $this->assertInstanceOf(CidLinkType::class, $type); 67 + } 68 + 69 + public function test_it_parses_unknown_type(): void 70 + { 71 + $type = $this->parser->parse(['type' => 'unknown']); 72 + 73 + $this->assertInstanceOf(UnknownType::class, $type); 74 + } 75 + 76 + public function test_it_throws_on_missing_type(): void 77 + { 78 + $this->expectException(TypeResolutionException::class); 79 + $this->expectExceptionMessage('Unknown Lexicon type: (missing type field)'); 80 + 81 + $this->parser->parse([]); 82 + } 83 + 84 + public function test_it_throws_on_unknown_type(): void 85 + { 86 + $this->expectException(TypeResolutionException::class); 87 + $this->expectExceptionMessage('Unknown Lexicon type: nonexistent'); 88 + 89 + $this->parser->parse(['type' => 'nonexistent']); 90 + } 91 + 92 + public function test_it_checks_if_type_is_primitive(): void 93 + { 94 + $this->assertTrue($this->parser->isPrimitive('null')); 95 + $this->assertTrue($this->parser->isPrimitive('boolean')); 96 + $this->assertTrue($this->parser->isPrimitive('integer')); 97 + $this->assertTrue($this->parser->isPrimitive('string')); 98 + $this->assertTrue($this->parser->isPrimitive('bytes')); 99 + $this->assertTrue($this->parser->isPrimitive('cid-link')); 100 + $this->assertTrue($this->parser->isPrimitive('unknown')); 101 + 102 + $this->assertFalse($this->parser->isPrimitive('object')); 103 + $this->assertFalse($this->parser->isPrimitive('array')); 104 + $this->assertFalse($this->parser->isPrimitive('ref')); 105 + } 106 + 107 + public function test_it_returns_supported_types(): void 108 + { 109 + $types = $this->parser->getSupportedTypes(); 110 + 111 + $this->assertCount(7, $types); 112 + $this->assertContains('null', $types); 113 + $this->assertContains('boolean', $types); 114 + $this->assertContains('integer', $types); 115 + $this->assertContains('string', $types); 116 + $this->assertContains('bytes', $types); 117 + $this->assertContains('cid-link', $types); 118 + $this->assertContains('unknown', $types); 119 + } 120 + 121 + public function test_it_parses_type_with_properties(): void 122 + { 123 + $type = $this->parser->parse([ 124 + 'type' => 'string', 125 + 'description' => 'A test string', 126 + 'minLength' => 1, 127 + 'maxLength' => 100, 128 + ]); 129 + 130 + $this->assertInstanceOf(StringType::class, $type); 131 + $this->assertSame('A test string', $type->description); 132 + $this->assertSame(1, $type->minLength); 133 + $this->assertSame(100, $type->maxLength); 134 + } 135 + }