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 object array union ref and blob type parsers with validation

+1511
+127
src/Data/Types/ArrayType.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 ArrayType extends TypeDefinition 9 + { 10 + /** 11 + * Type of array items. 12 + */ 13 + public readonly ?TypeDefinition $items; 14 + 15 + /** 16 + * Minimum array length. 17 + */ 18 + public readonly ?int $minLength; 19 + 20 + /** 21 + * Maximum array length. 22 + */ 23 + public readonly ?int $maxLength; 24 + 25 + /** 26 + * Create a new ArrayType. 27 + */ 28 + public function __construct( 29 + ?TypeDefinition $items = null, 30 + ?int $minLength = null, 31 + ?int $maxLength = null, 32 + ?string $description = null 33 + ) { 34 + parent::__construct('array', $description); 35 + 36 + $this->items = $items; 37 + $this->minLength = $minLength; 38 + $this->maxLength = $maxLength; 39 + } 40 + 41 + /** 42 + * Create from array data. 43 + */ 44 + public static function fromArray(array $data): self 45 + { 46 + // Items will be parsed by TypeParser, this is just a placeholder 47 + return new self( 48 + items: null, 49 + minLength: $data['minLength'] ?? null, 50 + maxLength: $data['maxLength'] ?? null, 51 + description: $data['description'] ?? null 52 + ); 53 + } 54 + 55 + /** 56 + * Convert to array. 57 + */ 58 + public function toArray(): array 59 + { 60 + $array = ['type' => $this->type]; 61 + 62 + if ($this->description !== null) { 63 + $array['description'] = $this->description; 64 + } 65 + 66 + if ($this->items !== null) { 67 + $array['items'] = $this->items->toArray(); 68 + } 69 + 70 + if ($this->minLength !== null) { 71 + $array['minLength'] = $this->minLength; 72 + } 73 + 74 + if ($this->maxLength !== null) { 75 + $array['maxLength'] = $this->maxLength; 76 + } 77 + 78 + return $array; 79 + } 80 + 81 + /** 82 + * Validate a value against this type definition. 83 + */ 84 + public function validate(mixed $value, string $path = ''): void 85 + { 86 + if (! is_array($value)) { 87 + throw RecordValidationException::invalidType($path, 'array', gettype($value)); 88 + } 89 + 90 + // Check if it's a sequential array 91 + if (! array_is_list($value)) { 92 + throw RecordValidationException::invalidValue($path, 'must be a sequential array'); 93 + } 94 + 95 + $length = count($value); 96 + 97 + // Validate length 98 + if ($this->minLength !== null && $length < $this->minLength) { 99 + throw RecordValidationException::invalidValue($path, "must have at least {$this->minLength} items"); 100 + } 101 + 102 + if ($this->maxLength !== null && $length > $this->maxLength) { 103 + throw RecordValidationException::invalidValue($path, "must have at most {$this->maxLength} items"); 104 + } 105 + 106 + // Validate items 107 + if ($this->items !== null) { 108 + foreach ($value as $index => $item) { 109 + $itemPath = "{$path}[{$index}]"; 110 + $this->items->validate($item, $itemPath); 111 + } 112 + } 113 + } 114 + 115 + /** 116 + * Set items type after construction. 117 + */ 118 + public function withItems(TypeDefinition $items): self 119 + { 120 + return new self( 121 + items: $items, 122 + minLength: $this->minLength, 123 + maxLength: $this->maxLength, 124 + description: $this->description 125 + ); 126 + } 127 + }
+152
src/Data/Types/BlobType.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 BlobType extends TypeDefinition 9 + { 10 + /** 11 + * Accepted MIME types. 12 + * 13 + * @var array<string>|null 14 + */ 15 + public readonly ?array $accept; 16 + 17 + /** 18 + * Maximum blob size in bytes. 19 + */ 20 + public readonly ?int $maxSize; 21 + 22 + /** 23 + * Create a new BlobType. 24 + * 25 + * @param array<string>|null $accept 26 + */ 27 + public function __construct( 28 + ?array $accept = null, 29 + ?int $maxSize = null, 30 + ?string $description = null 31 + ) { 32 + parent::__construct('blob', $description); 33 + 34 + $this->accept = $accept; 35 + $this->maxSize = $maxSize; 36 + } 37 + 38 + /** 39 + * Create from array data. 40 + */ 41 + public static function fromArray(array $data): self 42 + { 43 + return new self( 44 + accept: $data['accept'] ?? null, 45 + maxSize: $data['maxSize'] ?? null, 46 + description: $data['description'] ?? null 47 + ); 48 + } 49 + 50 + /** 51 + * Convert to array. 52 + */ 53 + public function toArray(): array 54 + { 55 + $array = ['type' => $this->type]; 56 + 57 + if ($this->description !== null) { 58 + $array['description'] = $this->description; 59 + } 60 + 61 + if ($this->accept !== null) { 62 + $array['accept'] = $this->accept; 63 + } 64 + 65 + if ($this->maxSize !== null) { 66 + $array['maxSize'] = $this->maxSize; 67 + } 68 + 69 + return $array; 70 + } 71 + 72 + /** 73 + * Validate a value against this type definition. 74 + */ 75 + public function validate(mixed $value, string $path = ''): void 76 + { 77 + if (! is_array($value)) { 78 + throw RecordValidationException::invalidType($path, 'blob (object)', gettype($value)); 79 + } 80 + 81 + // Blob must have $type property 82 + if (! isset($value['$type']) || $value['$type'] !== 'blob') { 83 + throw RecordValidationException::invalidValue($path, 'must have $type property set to "blob"'); 84 + } 85 + 86 + // Blob must have ref (CID reference) 87 + if (! isset($value['ref'])) { 88 + throw RecordValidationException::invalidValue($path, 'must have ref property'); 89 + } 90 + 91 + // Blob must have mimeType 92 + if (! isset($value['mimeType'])) { 93 + throw RecordValidationException::invalidValue($path, 'must have mimeType property'); 94 + } 95 + 96 + // Blob must have size 97 + if (! isset($value['size'])) { 98 + throw RecordValidationException::invalidValue($path, 'must have size property'); 99 + } 100 + 101 + // Validate MIME type if accept is specified 102 + if ($this->accept !== null) { 103 + $mimeType = $value['mimeType']; 104 + 105 + if (! $this->isMimeTypeAccepted($mimeType)) { 106 + $accepted = implode(', ', $this->accept); 107 + 108 + throw RecordValidationException::invalidValue($path, "MIME type must be one of: {$accepted}"); 109 + } 110 + } 111 + 112 + // Validate size if maxSize is specified 113 + if ($this->maxSize !== null) { 114 + $size = $value['size']; 115 + 116 + if (! is_int($size)) { 117 + throw RecordValidationException::invalidValue($path, 'size must be an integer'); 118 + } 119 + 120 + if ($size > $this->maxSize) { 121 + throw RecordValidationException::invalidValue($path, "size must not exceed {$this->maxSize} bytes"); 122 + } 123 + } 124 + } 125 + 126 + /** 127 + * Check if a MIME type is accepted. 128 + */ 129 + protected function isMimeTypeAccepted(string $mimeType): bool 130 + { 131 + if ($this->accept === null) { 132 + return true; 133 + } 134 + 135 + foreach ($this->accept as $accepted) { 136 + // Exact match 137 + if ($accepted === $mimeType) { 138 + return true; 139 + } 140 + 141 + // Wildcard match (e.g., image/*) 142 + if (str_ends_with($accepted, '/*')) { 143 + $prefix = substr($accepted, 0, -1); 144 + if (str_starts_with($mimeType, $prefix)) { 145 + return true; 146 + } 147 + } 148 + } 149 + 150 + return false; 151 + } 152 + }
+139
src/Data/Types/ObjectType.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 ObjectType extends TypeDefinition 9 + { 10 + /** 11 + * Object properties. 12 + * 13 + * @var array<string, TypeDefinition> 14 + */ 15 + public readonly array $properties; 16 + 17 + /** 18 + * Required property names. 19 + * 20 + * @var array<string> 21 + */ 22 + public readonly array $required; 23 + 24 + /** 25 + * Whether nullable properties are allowed. 26 + */ 27 + public readonly bool $nullable; 28 + 29 + /** 30 + * Create a new ObjectType. 31 + * 32 + * @param array<string, TypeDefinition> $properties 33 + * @param array<string> $required 34 + */ 35 + public function __construct( 36 + array $properties = [], 37 + array $required = [], 38 + bool $nullable = false, 39 + ?string $description = null 40 + ) { 41 + parent::__construct('object', $description); 42 + 43 + $this->properties = $properties; 44 + $this->required = $required; 45 + $this->nullable = $nullable; 46 + } 47 + 48 + /** 49 + * Create from array data. 50 + */ 51 + public static function fromArray(array $data): self 52 + { 53 + // Properties will be parsed by TypeParser, this is just a placeholder 54 + return new self( 55 + properties: [], 56 + required: $data['required'] ?? [], 57 + nullable: $data['nullable'] ?? false, 58 + description: $data['description'] ?? null 59 + ); 60 + } 61 + 62 + /** 63 + * Convert to array. 64 + */ 65 + public function toArray(): array 66 + { 67 + $array = ['type' => $this->type]; 68 + 69 + if ($this->description !== null) { 70 + $array['description'] = $this->description; 71 + } 72 + 73 + if (! empty($this->properties)) { 74 + $array['properties'] = array_map( 75 + fn (TypeDefinition $type) => $type->toArray(), 76 + $this->properties 77 + ); 78 + } 79 + 80 + if (! empty($this->required)) { 81 + $array['required'] = $this->required; 82 + } 83 + 84 + if ($this->nullable) { 85 + $array['nullable'] = $this->nullable; 86 + } 87 + 88 + return $array; 89 + } 90 + 91 + /** 92 + * Validate a value against this type definition. 93 + */ 94 + public function validate(mixed $value, string $path = ''): void 95 + { 96 + if (! is_array($value)) { 97 + throw RecordValidationException::invalidType($path, 'object', gettype($value)); 98 + } 99 + 100 + // Validate required properties 101 + foreach ($this->required as $requiredKey) { 102 + if (! array_key_exists($requiredKey, $value)) { 103 + throw RecordValidationException::invalidValue($path, "missing required property '{$requiredKey}'"); 104 + } 105 + } 106 + 107 + // Validate each property 108 + foreach ($this->properties as $key => $propertyType) { 109 + if (! array_key_exists($key, $value)) { 110 + continue; 111 + } 112 + 113 + $propertyPath = $path ? "{$path}.{$key}" : $key; 114 + $propertyValue = $value[$key]; 115 + 116 + // Handle nullable 117 + if ($propertyValue === null && $this->nullable) { 118 + continue; 119 + } 120 + 121 + $propertyType->validate($propertyValue, $propertyPath); 122 + } 123 + } 124 + 125 + /** 126 + * Set properties after construction. 127 + * 128 + * @param array<string, TypeDefinition> $properties 129 + */ 130 + public function withProperties(array $properties): self 131 + { 132 + return new self( 133 + properties: $properties, 134 + required: $this->required, 135 + nullable: $this->nullable, 136 + description: $this->description 137 + ); 138 + } 139 + }
+95
src/Data/Types/RefType.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Data\Types; 4 + 5 + use SocialDept\Schema\Data\TypeDefinition; 6 + 7 + class RefType extends TypeDefinition 8 + { 9 + /** 10 + * Reference to another type (NSID or local #def). 11 + */ 12 + public readonly string $ref; 13 + 14 + /** 15 + * Create a new RefType. 16 + */ 17 + public function __construct( 18 + string $ref, 19 + ?string $description = null 20 + ) { 21 + parent::__construct('ref', $description); 22 + 23 + $this->ref = $ref; 24 + } 25 + 26 + /** 27 + * Create from array data. 28 + */ 29 + public static function fromArray(array $data): self 30 + { 31 + if (! isset($data['ref'])) { 32 + throw new \InvalidArgumentException('RefType requires a ref property'); 33 + } 34 + 35 + return new self( 36 + ref: $data['ref'], 37 + description: $data['description'] ?? null 38 + ); 39 + } 40 + 41 + /** 42 + * Convert to array. 43 + */ 44 + public function toArray(): array 45 + { 46 + $array = [ 47 + 'type' => $this->type, 48 + 'ref' => $this->ref, 49 + ]; 50 + 51 + if ($this->description !== null) { 52 + $array['description'] = $this->description; 53 + } 54 + 55 + return $array; 56 + } 57 + 58 + /** 59 + * Validate a value against this type definition. 60 + */ 61 + public function validate(mixed $value, string $path = ''): void 62 + { 63 + // Ref validation requires resolving the reference to its actual type 64 + // This would be handled by a higher-level validator with schema repository access 65 + // For now, we just accept any value 66 + } 67 + 68 + /** 69 + * Check if this is a local reference (starts with #). 70 + */ 71 + public function isLocal(): bool 72 + { 73 + return str_starts_with($this->ref, '#'); 74 + } 75 + 76 + /** 77 + * Check if this is an external reference (contains a dot). 78 + */ 79 + public function isExternal(): bool 80 + { 81 + return str_contains($this->ref, '.') && ! $this->isLocal(); 82 + } 83 + 84 + /** 85 + * Get the definition name from a local reference. 86 + */ 87 + public function getLocalDefinition(): ?string 88 + { 89 + if (! $this->isLocal()) { 90 + return null; 91 + } 92 + 93 + return substr($this->ref, 1); 94 + } 95 + }
+102
src/Data/Types/UnionType.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 UnionType extends TypeDefinition 9 + { 10 + /** 11 + * Possible types (refs). 12 + * 13 + * @var array<string> 14 + */ 15 + public readonly array $refs; 16 + 17 + /** 18 + * Whether this is a closed union (only listed refs allowed). 19 + */ 20 + public readonly bool $closed; 21 + 22 + /** 23 + * Create a new UnionType. 24 + * 25 + * @param array<string> $refs 26 + */ 27 + public function __construct( 28 + array $refs = [], 29 + bool $closed = false, 30 + ?string $description = null 31 + ) { 32 + parent::__construct('union', $description); 33 + 34 + $this->refs = $refs; 35 + $this->closed = $closed; 36 + } 37 + 38 + /** 39 + * Create from array data. 40 + */ 41 + public static function fromArray(array $data): self 42 + { 43 + return new self( 44 + refs: $data['refs'] ?? [], 45 + closed: $data['closed'] ?? false, 46 + description: $data['description'] ?? null 47 + ); 48 + } 49 + 50 + /** 51 + * Convert to array. 52 + */ 53 + public function toArray(): array 54 + { 55 + $array = ['type' => $this->type]; 56 + 57 + if ($this->description !== null) { 58 + $array['description'] = $this->description; 59 + } 60 + 61 + if (! empty($this->refs)) { 62 + $array['refs'] = $this->refs; 63 + } 64 + 65 + if ($this->closed) { 66 + $array['closed'] = $this->closed; 67 + } 68 + 69 + return $array; 70 + } 71 + 72 + /** 73 + * Validate a value against this type definition. 74 + */ 75 + public function validate(mixed $value, string $path = ''): void 76 + { 77 + if (! is_array($value)) { 78 + throw RecordValidationException::invalidType($path, 'union (object with $type)', gettype($value)); 79 + } 80 + 81 + // Union types must have a $type discriminator 82 + if (! isset($value['$type'])) { 83 + throw RecordValidationException::invalidValue($path, 'must contain $type property'); 84 + } 85 + 86 + $typeRef = $value['$type']; 87 + 88 + if (! is_string($typeRef)) { 89 + throw RecordValidationException::invalidValue($path, '$type must be a string'); 90 + } 91 + 92 + // If closed, validate the type is in refs 93 + if ($this->closed && ! in_array($typeRef, $this->refs, true)) { 94 + $allowed = implode(', ', $this->refs); 95 + 96 + throw RecordValidationException::invalidValue($path, "type must be one of: {$allowed}"); 97 + } 98 + 99 + // Note: Actual validation of the referenced type would happen 100 + // in a higher-level validator that has access to the schema repository 101 + } 102 + }
+137
src/Parser/ComplexTypeParser.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Parser; 4 + 5 + use SocialDept\Schema\Data\TypeDefinition; 6 + use SocialDept\Schema\Data\Types\ArrayType; 7 + use SocialDept\Schema\Data\Types\BlobType; 8 + use SocialDept\Schema\Data\Types\ObjectType; 9 + use SocialDept\Schema\Data\Types\RefType; 10 + use SocialDept\Schema\Data\Types\UnionType; 11 + use SocialDept\Schema\Exceptions\TypeResolutionException; 12 + 13 + class ComplexTypeParser 14 + { 15 + /** 16 + * Primitive parser for nested types. 17 + */ 18 + protected PrimitiveParser $primitiveParser; 19 + 20 + /** 21 + * Create a new ComplexTypeParser. 22 + */ 23 + public function __construct(?PrimitiveParser $primitiveParser = null) 24 + { 25 + $this->primitiveParser = $primitiveParser ?? new PrimitiveParser(); 26 + } 27 + 28 + /** 29 + * Parse a complex type definition from array data. 30 + * 31 + * @throws TypeResolutionException 32 + */ 33 + public function parse(array $data): TypeDefinition 34 + { 35 + $type = $data['type'] ?? null; 36 + 37 + if ($type === null) { 38 + throw TypeResolutionException::unknownType('(missing type field)'); 39 + } 40 + 41 + return match ($type) { 42 + 'object' => $this->parseObject($data), 43 + 'array' => $this->parseArray($data), 44 + 'union' => UnionType::fromArray($data), 45 + 'ref' => RefType::fromArray($data), 46 + 'blob' => BlobType::fromArray($data), 47 + default => throw TypeResolutionException::unknownType($type), 48 + }; 49 + } 50 + 51 + /** 52 + * Parse an object type with nested properties. 53 + */ 54 + protected function parseObject(array $data): ObjectType 55 + { 56 + $object = ObjectType::fromArray($data); 57 + 58 + // Parse properties if present 59 + if (isset($data['properties']) && is_array($data['properties'])) { 60 + $properties = []; 61 + 62 + foreach ($data['properties'] as $key => $propertyData) { 63 + $properties[$key] = $this->parseNestedType($propertyData); 64 + } 65 + 66 + $object = $object->withProperties($properties); 67 + } 68 + 69 + return $object; 70 + } 71 + 72 + /** 73 + * Parse an array type with nested items. 74 + */ 75 + protected function parseArray(array $data): ArrayType 76 + { 77 + $array = ArrayType::fromArray($data); 78 + 79 + // Parse items if present 80 + if (isset($data['items']) && is_array($data['items'])) { 81 + $items = $this->parseNestedType($data['items']); 82 + $array = $array->withItems($items); 83 + } 84 + 85 + return $array; 86 + } 87 + 88 + /** 89 + * Parse a nested type definition (can be primitive or complex). 90 + */ 91 + protected function parseNestedType(array $data): TypeDefinition 92 + { 93 + $type = $data['type'] ?? null; 94 + 95 + if ($type === null) { 96 + throw TypeResolutionException::unknownType('(missing type field)'); 97 + } 98 + 99 + // Try primitive types first 100 + if ($this->primitiveParser->isPrimitive($type)) { 101 + return $this->primitiveParser->parse($data); 102 + } 103 + 104 + // Try complex types 105 + return $this->parse($data); 106 + } 107 + 108 + /** 109 + * Check if a type is a complex type. 110 + */ 111 + public function isComplex(string $type): bool 112 + { 113 + return in_array($type, [ 114 + 'object', 115 + 'array', 116 + 'union', 117 + 'ref', 118 + 'blob', 119 + ]); 120 + } 121 + 122 + /** 123 + * Get all supported complex types. 124 + * 125 + * @return array<string> 126 + */ 127 + public function getSupportedTypes(): array 128 + { 129 + return [ 130 + 'object', 131 + 'array', 132 + 'union', 133 + 'ref', 134 + 'blob', 135 + ]; 136 + } 137 + }
+113
tests/Unit/Data/Types/ArrayTypeTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Data\Types; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Schema\Data\Types\ArrayType; 7 + use SocialDept\Schema\Data\Types\StringType; 8 + use SocialDept\Schema\Exceptions\RecordValidationException; 9 + 10 + class ArrayTypeTest extends TestCase 11 + { 12 + public function test_it_creates_from_array(): void 13 + { 14 + $type = ArrayType::fromArray([ 15 + 'type' => 'array', 16 + 'description' => 'An array', 17 + 'minLength' => 1, 18 + 'maxLength' => 10, 19 + ]); 20 + 21 + $this->assertSame('array', $type->type); 22 + $this->assertSame('An array', $type->description); 23 + $this->assertSame(1, $type->minLength); 24 + $this->assertSame(10, $type->maxLength); 25 + } 26 + 27 + public function test_it_converts_to_array(): void 28 + { 29 + $type = new ArrayType( 30 + items: new StringType(), 31 + minLength: 1, 32 + maxLength: 10, 33 + description: 'An array' 34 + ); 35 + 36 + $array = $type->toArray(); 37 + 38 + $this->assertSame('array', $array['type']); 39 + $this->assertSame('An array', $array['description']); 40 + $this->assertArrayHasKey('items', $array); 41 + $this->assertSame(1, $array['minLength']); 42 + $this->assertSame(10, $array['maxLength']); 43 + } 44 + 45 + public function test_it_validates_array_type(): void 46 + { 47 + $type = new ArrayType(); 48 + 49 + $this->expectException(RecordValidationException::class); 50 + $this->expectExceptionMessage("Expected type 'array' at 'field' but got 'string'"); 51 + 52 + $type->validate('not an array', 'field'); 53 + } 54 + 55 + public function test_it_validates_sequential_array(): void 56 + { 57 + $type = new ArrayType(); 58 + 59 + $type->validate(['a', 'b', 'c'], 'field'); 60 + 61 + $this->expectException(RecordValidationException::class); 62 + $this->expectExceptionMessage('Invalid value at \'field\': must be a sequential array'); 63 + 64 + $type->validate(['key' => 'value'], 'field'); 65 + } 66 + 67 + public function test_it_validates_min_length(): void 68 + { 69 + $type = new ArrayType(minLength: 2); 70 + 71 + $type->validate(['a', 'b'], 'field'); 72 + 73 + $this->expectException(RecordValidationException::class); 74 + $this->expectExceptionMessage('Invalid value at \'field\': must have at least 2 items'); 75 + 76 + $type->validate(['a'], 'field'); 77 + } 78 + 79 + public function test_it_validates_max_length(): void 80 + { 81 + $type = new ArrayType(maxLength: 2); 82 + 83 + $type->validate(['a', 'b'], 'field'); 84 + 85 + $this->expectException(RecordValidationException::class); 86 + $this->expectExceptionMessage('Invalid value at \'field\': must have at most 2 items'); 87 + 88 + $type->validate(['a', 'b', 'c'], 'field'); 89 + } 90 + 91 + public function test_it_validates_item_types(): void 92 + { 93 + $type = new ArrayType(items: new StringType()); 94 + 95 + $type->validate(['a', 'b', 'c'], 'field'); 96 + 97 + $this->expectException(RecordValidationException::class); 98 + $this->expectExceptionMessage("Expected type 'string' at 'field[1]' but got 'integer'"); 99 + 100 + $type->validate(['a', 123, 'c'], 'field'); 101 + } 102 + 103 + public function test_with_items_returns_new_instance(): void 104 + { 105 + $type = new ArrayType(minLength: 1); 106 + 107 + $newType = $type->withItems(new StringType()); 108 + 109 + $this->assertNotSame($type, $newType); 110 + $this->assertNull($type->items); 111 + $this->assertInstanceOf(StringType::class, $newType->items); 112 + } 113 + }
+192
tests/Unit/Data/Types/BlobTypeTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Data\Types; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Schema\Data\Types\BlobType; 7 + use SocialDept\Schema\Exceptions\RecordValidationException; 8 + 9 + class BlobTypeTest extends TestCase 10 + { 11 + public function test_it_creates_from_array(): void 12 + { 13 + $type = BlobType::fromArray([ 14 + 'type' => 'blob', 15 + 'description' => 'A blob', 16 + 'accept' => ['image/png', 'image/jpeg'], 17 + 'maxSize' => 1000000, 18 + ]); 19 + 20 + $this->assertSame('blob', $type->type); 21 + $this->assertSame('A blob', $type->description); 22 + $this->assertSame(['image/png', 'image/jpeg'], $type->accept); 23 + $this->assertSame(1000000, $type->maxSize); 24 + } 25 + 26 + public function test_it_converts_to_array(): void 27 + { 28 + $type = new BlobType( 29 + accept: ['image/png'], 30 + maxSize: 1000000, 31 + description: 'A blob' 32 + ); 33 + 34 + $array = $type->toArray(); 35 + 36 + $this->assertSame('blob', $array['type']); 37 + $this->assertSame('A blob', $array['description']); 38 + $this->assertSame(['image/png'], $array['accept']); 39 + $this->assertSame(1000000, $array['maxSize']); 40 + } 41 + 42 + public function test_it_validates_blob_type(): void 43 + { 44 + $type = new BlobType(); 45 + 46 + $this->expectException(RecordValidationException::class); 47 + $this->expectExceptionMessage("Expected type 'blob (object)' at 'field' but got 'string'"); 48 + 49 + $type->validate('not an object', 'field'); 50 + } 51 + 52 + public function test_it_validates_type_property(): void 53 + { 54 + $type = new BlobType(); 55 + 56 + $this->expectException(RecordValidationException::class); 57 + $this->expectExceptionMessage('Invalid value at \'field\': must have $type property set to "blob"'); 58 + 59 + $type->validate(['ref' => 'cid123'], 'field'); 60 + } 61 + 62 + public function test_it_validates_ref_property(): void 63 + { 64 + $type = new BlobType(); 65 + 66 + $this->expectException(RecordValidationException::class); 67 + $this->expectExceptionMessage('Invalid value at \'field\': must have ref property'); 68 + 69 + $type->validate(['$type' => 'blob'], 'field'); 70 + } 71 + 72 + public function test_it_validates_mime_type_property(): void 73 + { 74 + $type = new BlobType(); 75 + 76 + $this->expectException(RecordValidationException::class); 77 + $this->expectExceptionMessage('Invalid value at \'field\': must have mimeType property'); 78 + 79 + $type->validate(['$type' => 'blob', 'ref' => 'cid123'], 'field'); 80 + } 81 + 82 + public function test_it_validates_size_property(): void 83 + { 84 + $type = new BlobType(); 85 + 86 + $this->expectException(RecordValidationException::class); 87 + $this->expectExceptionMessage('Invalid value at \'field\': must have size property'); 88 + 89 + $type->validate(['$type' => 'blob', 'ref' => 'cid123', 'mimeType' => 'image/png'], 'field'); 90 + } 91 + 92 + public function test_it_validates_valid_blob(): void 93 + { 94 + $type = new BlobType(); 95 + 96 + $type->validate([ 97 + '$type' => 'blob', 98 + 'ref' => 'cid123', 99 + 'mimeType' => 'image/png', 100 + 'size' => 12345, 101 + ], 'field'); 102 + 103 + $this->assertTrue(true); 104 + } 105 + 106 + public function test_it_validates_accepted_mime_types(): void 107 + { 108 + $type = new BlobType(accept: ['image/png', 'image/jpeg']); 109 + 110 + $type->validate([ 111 + '$type' => 'blob', 112 + 'ref' => 'cid123', 113 + 'mimeType' => 'image/png', 114 + 'size' => 12345, 115 + ], 'field'); 116 + 117 + $this->expectException(RecordValidationException::class); 118 + $this->expectExceptionMessage('Invalid value at \'field\': MIME type must be one of: image/png, image/jpeg'); 119 + 120 + $type->validate([ 121 + '$type' => 'blob', 122 + 'ref' => 'cid123', 123 + 'mimeType' => 'image/gif', 124 + 'size' => 12345, 125 + ], 'field'); 126 + } 127 + 128 + public function test_it_validates_wildcard_mime_types(): void 129 + { 130 + $type = new BlobType(accept: ['image/*']); 131 + 132 + $type->validate([ 133 + '$type' => 'blob', 134 + 'ref' => 'cid123', 135 + 'mimeType' => 'image/png', 136 + 'size' => 12345, 137 + ], 'field'); 138 + 139 + $type->validate([ 140 + '$type' => 'blob', 141 + 'ref' => 'cid123', 142 + 'mimeType' => 'image/jpeg', 143 + 'size' => 12345, 144 + ], 'field'); 145 + 146 + $this->expectException(RecordValidationException::class); 147 + 148 + $type->validate([ 149 + '$type' => 'blob', 150 + 'ref' => 'cid123', 151 + 'mimeType' => 'video/mp4', 152 + 'size' => 12345, 153 + ], 'field'); 154 + } 155 + 156 + public function test_it_validates_max_size(): void 157 + { 158 + $type = new BlobType(maxSize: 10000); 159 + 160 + $type->validate([ 161 + '$type' => 'blob', 162 + 'ref' => 'cid123', 163 + 'mimeType' => 'image/png', 164 + 'size' => 10000, 165 + ], 'field'); 166 + 167 + $this->expectException(RecordValidationException::class); 168 + $this->expectExceptionMessage('Invalid value at \'field\': size must not exceed 10000 bytes'); 169 + 170 + $type->validate([ 171 + '$type' => 'blob', 172 + 'ref' => 'cid123', 173 + 'mimeType' => 'image/png', 174 + 'size' => 10001, 175 + ], 'field'); 176 + } 177 + 178 + public function test_it_validates_size_is_integer(): void 179 + { 180 + $type = new BlobType(maxSize: 10000); 181 + 182 + $this->expectException(RecordValidationException::class); 183 + $this->expectExceptionMessage('Invalid value at \'field\': size must be an integer'); 184 + 185 + $type->validate([ 186 + '$type' => 'blob', 187 + 'ref' => 'cid123', 188 + 'mimeType' => 'image/png', 189 + 'size' => '12345', 190 + ], 'field'); 191 + } 192 + }
+118
tests/Unit/Data/Types/ObjectTypeTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Data\Types; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Schema\Data\Types\ObjectType; 7 + use SocialDept\Schema\Data\Types\StringType; 8 + use SocialDept\Schema\Exceptions\RecordValidationException; 9 + 10 + class ObjectTypeTest extends TestCase 11 + { 12 + public function test_it_creates_from_array(): void 13 + { 14 + $type = ObjectType::fromArray([ 15 + 'type' => 'object', 16 + 'description' => 'An object', 17 + 'required' => ['name'], 18 + 'nullable' => true, 19 + ]); 20 + 21 + $this->assertSame('object', $type->type); 22 + $this->assertSame('An object', $type->description); 23 + $this->assertSame(['name'], $type->required); 24 + $this->assertTrue($type->nullable); 25 + } 26 + 27 + public function test_it_converts_to_array(): void 28 + { 29 + $type = new ObjectType( 30 + properties: ['name' => new StringType()], 31 + required: ['name'], 32 + nullable: true, 33 + description: 'An object' 34 + ); 35 + 36 + $array = $type->toArray(); 37 + 38 + $this->assertSame('object', $array['type']); 39 + $this->assertSame('An object', $array['description']); 40 + $this->assertArrayHasKey('properties', $array); 41 + $this->assertSame(['name'], $array['required']); 42 + $this->assertTrue($array['nullable']); 43 + } 44 + 45 + public function test_it_validates_object_type(): void 46 + { 47 + $type = new ObjectType(); 48 + 49 + $this->expectException(RecordValidationException::class); 50 + $this->expectExceptionMessage("Expected type 'object' at 'field' but got 'string'"); 51 + 52 + $type->validate('not an object', 'field'); 53 + } 54 + 55 + public function test_it_validates_required_properties(): void 56 + { 57 + $type = new ObjectType( 58 + properties: ['name' => new StringType()], 59 + required: ['name'] 60 + ); 61 + 62 + $type->validate(['name' => 'John'], 'field'); 63 + 64 + $this->expectException(RecordValidationException::class); 65 + $this->expectExceptionMessage("Invalid value at 'field': missing required property 'name'"); 66 + 67 + $type->validate([], 'field'); 68 + } 69 + 70 + public function test_it_validates_property_types(): void 71 + { 72 + $type = new ObjectType( 73 + properties: ['name' => new StringType()] 74 + ); 75 + 76 + $type->validate(['name' => 'John'], 'field'); 77 + 78 + $this->expectException(RecordValidationException::class); 79 + $this->expectExceptionMessage("Expected type 'string' at 'field.name' but got 'integer'"); 80 + 81 + $type->validate(['name' => 123], 'field'); 82 + } 83 + 84 + public function test_it_allows_nullable_properties(): void 85 + { 86 + $type = new ObjectType( 87 + properties: ['name' => new StringType()], 88 + nullable: true 89 + ); 90 + 91 + $type->validate(['name' => null], 'field'); 92 + 93 + $this->assertTrue(true); 94 + } 95 + 96 + public function test_it_allows_additional_properties(): void 97 + { 98 + $type = new ObjectType( 99 + properties: ['name' => new StringType()] 100 + ); 101 + 102 + // Additional properties should be allowed 103 + $type->validate(['name' => 'John', 'age' => 30], 'field'); 104 + 105 + $this->assertTrue(true); 106 + } 107 + 108 + public function test_with_properties_returns_new_instance(): void 109 + { 110 + $type = new ObjectType(required: ['name']); 111 + 112 + $newType = $type->withProperties(['name' => new StringType()]); 113 + 114 + $this->assertNotSame($type, $newType); 115 + $this->assertEmpty($type->properties); 116 + $this->assertCount(1, $newType->properties); 117 + } 118 + }
+86
tests/Unit/Data/Types/RefTypeTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Data\Types; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Schema\Data\Types\RefType; 7 + 8 + class RefTypeTest extends TestCase 9 + { 10 + public function test_it_creates_from_array(): void 11 + { 12 + $type = RefType::fromArray([ 13 + 'type' => 'ref', 14 + 'ref' => '#main', 15 + 'description' => 'A reference', 16 + ]); 17 + 18 + $this->assertSame('ref', $type->type); 19 + $this->assertSame('#main', $type->ref); 20 + $this->assertSame('A reference', $type->description); 21 + } 22 + 23 + public function test_it_throws_on_missing_ref(): void 24 + { 25 + $this->expectException(\InvalidArgumentException::class); 26 + $this->expectExceptionMessage('RefType requires a ref property'); 27 + 28 + RefType::fromArray(['type' => 'ref']); 29 + } 30 + 31 + public function test_it_converts_to_array(): void 32 + { 33 + $type = new RefType( 34 + ref: '#main', 35 + description: 'A reference' 36 + ); 37 + 38 + $array = $type->toArray(); 39 + 40 + $this->assertSame('ref', $array['type']); 41 + $this->assertSame('#main', $array['ref']); 42 + $this->assertSame('A reference', $array['description']); 43 + } 44 + 45 + public function test_it_identifies_local_reference(): void 46 + { 47 + $type = new RefType('#main'); 48 + 49 + $this->assertTrue($type->isLocal()); 50 + $this->assertFalse($type->isExternal()); 51 + } 52 + 53 + public function test_it_identifies_external_reference(): void 54 + { 55 + $type = new RefType('com.atproto.repo.strongRef'); 56 + 57 + $this->assertFalse($type->isLocal()); 58 + $this->assertTrue($type->isExternal()); 59 + } 60 + 61 + public function test_it_gets_local_definition_name(): void 62 + { 63 + $type = new RefType('#main'); 64 + 65 + $this->assertSame('main', $type->getLocalDefinition()); 66 + } 67 + 68 + public function test_it_returns_null_for_external_definition(): void 69 + { 70 + $type = new RefType('com.atproto.repo.strongRef'); 71 + 72 + $this->assertNull($type->getLocalDefinition()); 73 + } 74 + 75 + public function test_it_validates_any_value(): void 76 + { 77 + $type = new RefType('#main'); 78 + 79 + // Ref validation is deferred to higher-level validator 80 + $type->validate(['any' => 'value'], 'field'); 81 + $type->validate('string', 'field'); 82 + $type->validate(123, 'field'); 83 + 84 + $this->assertTrue(true); 85 + } 86 + }
+98
tests/Unit/Data/Types/UnionTypeTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Data\Types; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Schema\Data\Types\UnionType; 7 + use SocialDept\Schema\Exceptions\RecordValidationException; 8 + 9 + class UnionTypeTest extends TestCase 10 + { 11 + public function test_it_creates_from_array(): void 12 + { 13 + $type = UnionType::fromArray([ 14 + 'type' => 'union', 15 + 'description' => 'A union', 16 + 'refs' => ['#typeA', '#typeB'], 17 + 'closed' => true, 18 + ]); 19 + 20 + $this->assertSame('union', $type->type); 21 + $this->assertSame('A union', $type->description); 22 + $this->assertSame(['#typeA', '#typeB'], $type->refs); 23 + $this->assertTrue($type->closed); 24 + } 25 + 26 + public function test_it_converts_to_array(): void 27 + { 28 + $type = new UnionType( 29 + refs: ['#typeA', '#typeB'], 30 + closed: true, 31 + description: 'A union' 32 + ); 33 + 34 + $array = $type->toArray(); 35 + 36 + $this->assertSame('union', $array['type']); 37 + $this->assertSame('A union', $array['description']); 38 + $this->assertSame(['#typeA', '#typeB'], $array['refs']); 39 + $this->assertTrue($array['closed']); 40 + } 41 + 42 + public function test_it_validates_union_type(): void 43 + { 44 + $type = new UnionType(); 45 + 46 + $this->expectException(RecordValidationException::class); 47 + $this->expectExceptionMessage("Expected type 'union (object with \$type)' at 'field' but got 'string'"); 48 + 49 + $type->validate('not an object', 'field'); 50 + } 51 + 52 + public function test_it_validates_type_discriminator(): void 53 + { 54 + $type = new UnionType(); 55 + 56 + $this->expectException(RecordValidationException::class); 57 + $this->expectExceptionMessage('Invalid value at \'field\': must contain $type property'); 58 + 59 + $type->validate(['data' => 'value'], 'field'); 60 + } 61 + 62 + public function test_it_validates_type_discriminator_is_string(): void 63 + { 64 + $type = new UnionType(); 65 + 66 + $this->expectException(RecordValidationException::class); 67 + $this->expectExceptionMessage('Invalid value at \'field\': $type must be a string'); 68 + 69 + $type->validate(['$type' => 123], 'field'); 70 + } 71 + 72 + public function test_it_validates_closed_union(): void 73 + { 74 + $type = new UnionType( 75 + refs: ['#typeA', '#typeB'], 76 + closed: true 77 + ); 78 + 79 + $type->validate(['$type' => '#typeA'], 'field'); 80 + 81 + $this->expectException(RecordValidationException::class); 82 + $this->expectExceptionMessage('Invalid value at \'field\': type must be one of: #typeA, #typeB'); 83 + 84 + $type->validate(['$type' => '#typeC'], 'field'); 85 + } 86 + 87 + public function test_it_allows_any_type_in_open_union(): void 88 + { 89 + $type = new UnionType( 90 + refs: ['#typeA', '#typeB'], 91 + closed: false 92 + ); 93 + 94 + $type->validate(['$type' => '#typeC'], 'field'); 95 + 96 + $this->assertTrue(true); 97 + } 98 + }
+152
tests/Unit/Parser/ComplexTypeParserTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Parser; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Schema\Data\Types\ArrayType; 7 + use SocialDept\Schema\Data\Types\BlobType; 8 + use SocialDept\Schema\Data\Types\ObjectType; 9 + use SocialDept\Schema\Data\Types\RefType; 10 + use SocialDept\Schema\Data\Types\StringType; 11 + use SocialDept\Schema\Data\Types\UnionType; 12 + use SocialDept\Schema\Exceptions\TypeResolutionException; 13 + use SocialDept\Schema\Parser\ComplexTypeParser; 14 + 15 + class ComplexTypeParserTest extends TestCase 16 + { 17 + protected ComplexTypeParser $parser; 18 + 19 + protected function setUp(): void 20 + { 21 + parent::setUp(); 22 + 23 + $this->parser = new ComplexTypeParser(); 24 + } 25 + 26 + public function test_it_parses_object_type(): void 27 + { 28 + $type = $this->parser->parse(['type' => 'object']); 29 + 30 + $this->assertInstanceOf(ObjectType::class, $type); 31 + } 32 + 33 + public function test_it_parses_object_with_properties(): void 34 + { 35 + $type = $this->parser->parse([ 36 + 'type' => 'object', 37 + 'properties' => [ 38 + 'name' => ['type' => 'string'], 39 + 'age' => ['type' => 'integer'], 40 + ], 41 + ]); 42 + 43 + $this->assertInstanceOf(ObjectType::class, $type); 44 + $this->assertCount(2, $type->properties); 45 + $this->assertInstanceOf(StringType::class, $type->properties['name']); 46 + } 47 + 48 + public function test_it_parses_array_type(): void 49 + { 50 + $type = $this->parser->parse(['type' => 'array']); 51 + 52 + $this->assertInstanceOf(ArrayType::class, $type); 53 + } 54 + 55 + public function test_it_parses_array_with_items(): void 56 + { 57 + $type = $this->parser->parse([ 58 + 'type' => 'array', 59 + 'items' => ['type' => 'string'], 60 + ]); 61 + 62 + $this->assertInstanceOf(ArrayType::class, $type); 63 + $this->assertInstanceOf(StringType::class, $type->items); 64 + } 65 + 66 + public function test_it_parses_nested_complex_types(): void 67 + { 68 + $type = $this->parser->parse([ 69 + 'type' => 'object', 70 + 'properties' => [ 71 + 'items' => [ 72 + 'type' => 'array', 73 + 'items' => ['type' => 'string'], 74 + ], 75 + ], 76 + ]); 77 + 78 + $this->assertInstanceOf(ObjectType::class, $type); 79 + $this->assertInstanceOf(ArrayType::class, $type->properties['items']); 80 + $this->assertInstanceOf(StringType::class, $type->properties['items']->items); 81 + } 82 + 83 + public function test_it_parses_union_type(): void 84 + { 85 + $type = $this->parser->parse([ 86 + 'type' => 'union', 87 + 'refs' => ['#typeA', '#typeB'], 88 + ]); 89 + 90 + $this->assertInstanceOf(UnionType::class, $type); 91 + } 92 + 93 + public function test_it_parses_ref_type(): void 94 + { 95 + $type = $this->parser->parse([ 96 + 'type' => 'ref', 97 + 'ref' => '#main', 98 + ]); 99 + 100 + $this->assertInstanceOf(RefType::class, $type); 101 + } 102 + 103 + public function test_it_parses_blob_type(): void 104 + { 105 + $type = $this->parser->parse([ 106 + 'type' => 'blob', 107 + 'accept' => ['image/*'], 108 + ]); 109 + 110 + $this->assertInstanceOf(BlobType::class, $type); 111 + } 112 + 113 + public function test_it_throws_on_missing_type(): void 114 + { 115 + $this->expectException(TypeResolutionException::class); 116 + $this->expectExceptionMessage('Unknown Lexicon type: (missing type field)'); 117 + 118 + $this->parser->parse([]); 119 + } 120 + 121 + public function test_it_throws_on_unknown_type(): void 122 + { 123 + $this->expectException(TypeResolutionException::class); 124 + $this->expectExceptionMessage('Unknown Lexicon type: nonexistent'); 125 + 126 + $this->parser->parse(['type' => 'nonexistent']); 127 + } 128 + 129 + public function test_it_checks_if_type_is_complex(): void 130 + { 131 + $this->assertTrue($this->parser->isComplex('object')); 132 + $this->assertTrue($this->parser->isComplex('array')); 133 + $this->assertTrue($this->parser->isComplex('union')); 134 + $this->assertTrue($this->parser->isComplex('ref')); 135 + $this->assertTrue($this->parser->isComplex('blob')); 136 + 137 + $this->assertFalse($this->parser->isComplex('string')); 138 + $this->assertFalse($this->parser->isComplex('integer')); 139 + } 140 + 141 + public function test_it_returns_supported_types(): void 142 + { 143 + $types = $this->parser->getSupportedTypes(); 144 + 145 + $this->assertCount(5, $types); 146 + $this->assertContains('object', $types); 147 + $this->assertContains('array', $types); 148 + $this->assertContains('union', $types); 149 + $this->assertContains('ref', $types); 150 + $this->assertContains('blob', $types); 151 + } 152 + }