Parse and validate AT Protocol Lexicons with DTO generation for Laravel

Update source files for LexiconDocument return types and improved error handling

+152 -9
src/Console/GenerateCommand.php
··· 16 16 {--output= : Output directory for generated files} 17 17 {--namespace= : Base namespace for generated classes} 18 18 {--force : Overwrite existing files} 19 - {--dry-run : Preview generated code without writing files}'; 19 + {--dry-run : Preview generated code without writing files} 20 + {--with-dependencies : Also generate all referenced lexicons recursively} 21 + {--r|recursive : Alias for --with-dependencies}'; 20 22 21 23 /** 22 24 * The console command description. ··· 24 26 protected $description = 'Generate PHP DTO classes from ATProto Lexicon schemas'; 25 27 26 28 /** 29 + * Track generated NSIDs to avoid duplicates. 30 + */ 31 + protected array $generated = []; 32 + 33 + /** 27 34 * Execute the console command. 28 35 */ 29 36 public function handle(): int ··· 33 40 $namespace = $this->option('namespace') ?? config('schema.lexicons.base_namespace'); 34 41 $force = $this->option('force'); 35 42 $dryRun = $this->option('dry-run'); 43 + $withDependencies = $this->option('with-dependencies') || $this->option('recursive'); 36 44 37 45 $this->info("Generating DTO classes for schema: {$nsid}"); 38 46 ··· 55 63 56 64 if ($dryRun) { 57 65 $this->info('Dry run mode - no files will be written'); 58 - $schema = $loader->load($nsid); 59 - $document = \SocialDept\Schema\Data\LexiconDocument::fromArray($schema); 66 + $document = $loader->load($nsid); 60 67 $code = $generator->preview($document); 61 68 62 69 $this->line(''); ··· 68 75 return self::SUCCESS; 69 76 } 70 77 71 - $files = $generator->generateByNsid($nsid, [ 72 - 'dryRun' => false, 73 - 'overwrite' => $force, 74 - ]); 78 + $allFiles = []; 79 + 80 + if ($withDependencies) { 81 + $this->info('Generating with dependencies...'); 82 + $allFiles = $this->generateWithDependencies($nsid, $loader, $generator, $force); 83 + } else { 84 + $allFiles = $generator->generateByNsid($nsid, [ 85 + 'dryRun' => false, 86 + 'overwrite' => $force, 87 + ]); 88 + } 75 89 76 - $this->info('Generated '.count($files).' file(s):'); 90 + $this->newLine(); 91 + $this->info('Generated '.count($allFiles).' file(s):'); 77 92 78 - foreach ($files as $file) { 93 + foreach ($allFiles as $file) { 79 94 $this->line(" - {$file}"); 80 95 } 81 96 ··· 92 107 93 108 return self::FAILURE; 94 109 } 110 + } 111 + 112 + /** 113 + * Generate schema with all its dependencies recursively. 114 + */ 115 + protected function generateWithDependencies( 116 + string $nsid, 117 + SchemaLoader $loader, 118 + DTOGenerator $generator, 119 + bool $force 120 + ): array { 121 + // Skip if already generated 122 + if (in_array($nsid, $this->generated)) { 123 + return []; 124 + } 125 + 126 + $this->line(" → Loading schema: {$nsid}"); 127 + 128 + try { 129 + $schema = $loader->load($nsid); 130 + } catch (\Exception $e) { 131 + $this->warn(" ⚠ Could not load {$nsid}: ".$e->getMessage()); 132 + 133 + return []; 134 + } 135 + 136 + // Extract all referenced NSIDs from this schema 137 + $dependencies = $this->extractDependencies($schema); 138 + 139 + $allFiles = []; 140 + 141 + // Generate dependencies first 142 + foreach ($dependencies as $depNsid) { 143 + $depFiles = $this->generateWithDependencies($depNsid, $loader, $generator, $force); 144 + $allFiles = array_merge($allFiles, $depFiles); 145 + } 146 + 147 + // Mark as generated before generating to prevent circular references 148 + $this->generated[] = $nsid; 149 + 150 + // Generate current schema 151 + try { 152 + $files = $generator->generateByNsid($nsid, [ 153 + 'dryRun' => false, 154 + 'overwrite' => $force, 155 + ]); 156 + $allFiles = array_merge($allFiles, $files); 157 + } catch (\Exception $e) { 158 + $this->warn(" ⚠ Could not generate {$nsid}: ".$e->getMessage()); 159 + } 160 + 161 + return $allFiles; 162 + } 163 + 164 + /** 165 + * Extract all NSID dependencies from a schema. 166 + */ 167 + protected function extractDependencies(\SocialDept\Schema\Data\LexiconDocument $schema): array 168 + { 169 + $dependencies = []; 170 + $currentNsid = $schema->getNsid(); 171 + 172 + // Walk through all definitions 173 + foreach ($schema->defs as $def) { 174 + $dependencies = array_merge($dependencies, $this->extractRefsFromDefinition($def)); 175 + } 176 + 177 + // Filter out refs that are definitions within the same schema 178 + // (refs that start with the current NSID followed by a dot) 179 + $dependencies = array_filter($dependencies, function ($ref) use ($currentNsid) { 180 + return ! str_starts_with($ref, $currentNsid.'.'); 181 + }); 182 + 183 + return array_unique($dependencies); 184 + } 185 + 186 + /** 187 + * Recursively extract refs from a definition. 188 + */ 189 + protected function extractRefsFromDefinition(array $definition): array 190 + { 191 + $refs = []; 192 + 193 + // Handle direct ref 194 + if (isset($definition['ref'])) { 195 + $ref = $definition['ref']; 196 + // Skip local references (starting with #) 197 + if (! str_starts_with($ref, '#')) { 198 + // Extract NSID part (before fragment) 199 + if (str_contains($ref, '#')) { 200 + $ref = explode('#', $ref)[0]; 201 + } 202 + $refs[] = $ref; 203 + } 204 + } 205 + 206 + // Handle union refs 207 + if (isset($definition['refs']) && is_array($definition['refs'])) { 208 + foreach ($definition['refs'] as $ref) { 209 + // Skip local references 210 + if (! str_starts_with($ref, '#')) { 211 + // Extract NSID part 212 + if (str_contains($ref, '#')) { 213 + $ref = explode('#', $ref)[0]; 214 + } 215 + $refs[] = $ref; 216 + } 217 + } 218 + } 219 + 220 + // Recursively check properties 221 + if (isset($definition['properties']) && is_array($definition['properties'])) { 222 + foreach ($definition['properties'] as $propDef) { 223 + $refs = array_merge($refs, $this->extractRefsFromDefinition($propDef)); 224 + } 225 + } 226 + 227 + // Recursively check record 228 + if (isset($definition['record']) && is_array($definition['record'])) { 229 + $refs = array_merge($refs, $this->extractRefsFromDefinition($definition['record'])); 230 + } 231 + 232 + // Recursively check array items 233 + if (isset($definition['items']) && is_array($definition['items'])) { 234 + $refs = array_merge($refs, $this->extractRefsFromDefinition($definition['items'])); 235 + } 236 + 237 + return $refs; 95 238 } 96 239 }
+2 -5
src/Console/ListCommand.php
··· 3 3 namespace SocialDept\Schema\Console; 4 4 5 5 use Illuminate\Console\Command; 6 - use SocialDept\Schema\Data\LexiconDocument; 7 6 use SocialDept\Schema\Parser\SchemaLoader; 8 7 9 8 class ListCommand extends Command ··· 55 54 56 55 foreach ($schemas as $nsid) { 57 56 try { 58 - $schema = $loader->load($nsid); 59 - $document = LexiconDocument::fromArray($schema); 57 + $document = $loader->load($nsid); 60 58 61 59 $schemaType = 'unknown'; 62 60 if ($document->isRecord()) { ··· 173 171 { 174 172 return array_filter($schemas, function ($nsid) use ($type, $loader) { 175 173 try { 176 - $schema = $loader->load($nsid); 177 - $document = LexiconDocument::fromArray($schema); 174 + $document = $loader->load($nsid); 178 175 179 176 return match ($type) { 180 177 'record' => $document->isRecord(),
+1 -3
src/Console/ValidateCommand.php
··· 3 3 namespace SocialDept\Schema\Console; 4 4 5 5 use Illuminate\Console\Command; 6 - use SocialDept\Schema\Data\LexiconDocument; 7 6 use SocialDept\Schema\Parser\SchemaLoader; 8 7 use SocialDept\Schema\Validation\LexiconValidator; 9 8 ··· 64 63 65 64 $this->info("Validating data against schema: {$nsid}"); 66 65 67 - $schema = $loader->load($nsid); 68 - $document = LexiconDocument::fromArray($schema); 66 + $document = $loader->load($nsid); 69 67 70 68 $errors = $validator->validateWithErrors($data, $document); 71 69
+21
src/Contracts/DiscriminatedUnion.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Contracts; 4 + 5 + /** 6 + * Contract for Data classes that can participate in AT Protocol discriminated unions. 7 + * 8 + * Union types in AT Protocol use the $type field to discriminate between 9 + * different variants. This interface marks classes that can be used as 10 + * union variants and provides access to their discriminator value. 11 + */ 12 + interface DiscriminatedUnion 13 + { 14 + /** 15 + * Get the lexicon NSID that identifies this union variant. 16 + * 17 + * This value is used as the $type discriminator in AT Protocol records 18 + * to identify which specific type a union contains. 19 + */ 20 + public static function getDiscriminator(): string; 21 + }
+25 -6
src/Data/Data.php
··· 5 5 use Illuminate\Contracts\Support\Arrayable; 6 6 use Illuminate\Contracts\Support\Jsonable; 7 7 use JsonSerializable; 8 + use SocialDept\Schema\Contracts\DiscriminatedUnion; 8 9 use Stringable; 9 10 10 - abstract class Data implements Arrayable, Jsonable, JsonSerializable, Stringable 11 + abstract class Data implements Arrayable, DiscriminatedUnion, Jsonable, JsonSerializable, Stringable 11 12 { 12 13 /** 13 14 * Get the lexicon NSID for this data type. ··· 15 16 abstract public static function getLexicon(): string; 16 17 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 + /** 18 29 * Convert the data to an array. 19 30 */ 20 31 public function toArray(): array ··· 22 33 $result = []; 23 34 24 35 foreach (get_object_vars($this) as $property => $value) { 25 - $result[$property] = $this->serializeValue($value); 36 + // Skip null values to exclude optional fields that aren't set 37 + if ($value !== null) { 38 + $result[$property] = $this->serializeValue($value); 39 + } 26 40 } 27 41 28 - return $result; 42 + return array_filter($result); 29 43 } 30 44 31 45 /** ··· 57 71 */ 58 72 protected function serializeValue(mixed $value): mixed 59 73 { 74 + // Union variants must include $type for discrimination 60 75 if ($value instanceof self) { 61 - return $value->toArray(); 76 + return $value->toRecord(); 62 77 } 63 78 64 79 if ($value instanceof Arrayable) { 65 80 return $value->toArray(); 66 81 } 67 82 83 + // Preserve arrays with $type (open union data) 68 84 if (is_array($value)) { 69 85 return array_map(fn ($item) => $this->serializeValue($item), $value); 70 86 } ··· 103 119 */ 104 120 public static function fromRecord(array $record): static 105 121 { 106 - return static::fromArray($record); 122 + return static::fromArray($record['value'] ?? $record); 107 123 } 108 124 109 125 /** ··· 114 130 */ 115 131 public function toRecord(): array 116 132 { 117 - return $this->toArray(); 133 + return [ 134 + ...$this->toArray(), 135 + '$type' => $this->getLexicon(), 136 + ]; 118 137 } 119 138 120 139 /**
+1 -1
src/Exceptions/BlobException.php
··· 32 32 public static function invalidMimeType(string $mimeType, array $accepted): self 33 33 { 34 34 return static::withContext( 35 - "Invalid MIME type {$mimeType}. Accepted: " . implode(', ', $accepted), 35 + "Invalid MIME type {$mimeType}. Accepted: ".implode(', ', $accepted), 36 36 ['mimeType' => $mimeType, 'accepted' => $accepted] 37 37 ); 38 38 }
+1 -1
src/Exceptions/SchemaValidationException.php
··· 9 9 */ 10 10 public static function invalidStructure(string $nsid, array $errors): self 11 11 { 12 - $message = "Schema validation failed for {$nsid}:\n" . implode("\n", $errors); 12 + $message = "Schema validation failed for {$nsid}:\n".implode("\n", $errors); 13 13 14 14 return static::withContext($message, [ 15 15 'nsid' => $nsid,
+5 -4
src/Facades/Schema.php
··· 6 6 use SocialDept\Schema\Data\LexiconDocument; 7 7 8 8 /** 9 - * @method static array load(string $nsid) 9 + * @method static LexiconDocument load(string $nsid) 10 + * @method static LexiconDocument|null find(string $nsid) 10 11 * @method static bool exists(string $nsid) 11 - * @method static LexiconDocument parse(string $nsid) 12 + * @method static array all() 13 + * @method static void clearCache(?string $nsid = null) 14 + * @method static string generate(string $nsid, ?string $outputPath = null) 12 15 * @method static bool validate(string $nsid, array $data) 13 16 * @method static array validateWithErrors(string $nsid, array $data) 14 - * @method static string generate(string $nsid, array $options = []) 15 - * @method static void clearCache(?string $nsid = null) 16 17 * 17 18 * @see \SocialDept\Schema\SchemaManager 18 19 */
+1 -1
src/Parser/ComplexTypeParser.php
··· 27 27 */ 28 28 public function __construct(?PrimitiveParser $primitiveParser = null) 29 29 { 30 - $this->primitiveParser = $primitiveParser ?? new PrimitiveParser(); 30 + $this->primitiveParser = $primitiveParser ?? new PrimitiveParser; 31 31 } 32 32 33 33 /**
+105
src/Parser/DefaultLexiconParser.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Parser; 4 + 5 + use SocialDept\Schema\Contracts\LexiconParser; 6 + use SocialDept\Schema\Data\LexiconDocument; 7 + use SocialDept\Schema\Exceptions\SchemaParseException; 8 + 9 + class DefaultLexiconParser implements LexiconParser 10 + { 11 + /** 12 + * Parse raw Lexicon JSON into structured objects. 13 + */ 14 + public function parse(string $json): LexiconDocument 15 + { 16 + $data = json_decode($json, true); 17 + 18 + if (json_last_error() !== JSON_ERROR_NONE) { 19 + throw SchemaParseException::invalidJson('unknown', json_last_error_msg()); 20 + } 21 + 22 + if (! is_array($data)) { 23 + throw SchemaParseException::malformed('unknown', 'Schema must be a JSON object'); 24 + } 25 + 26 + return $this->parseArray($data); 27 + } 28 + 29 + /** 30 + * Parse Lexicon from array data. 31 + */ 32 + public function parseArray(array $data): LexiconDocument 33 + { 34 + return LexiconDocument::fromArray($data); 35 + } 36 + 37 + /** 38 + * Validate Lexicon schema structure. 39 + */ 40 + public function validate(array $data): bool 41 + { 42 + try { 43 + // Required fields 44 + if (! isset($data['lexicon'])) { 45 + return false; 46 + } 47 + 48 + if (! isset($data['id'])) { 49 + return false; 50 + } 51 + 52 + if (! isset($data['defs'])) { 53 + return false; 54 + } 55 + 56 + // Validate lexicon version 57 + $lexicon = (int) $data['lexicon']; 58 + if ($lexicon !== 1) { 59 + return false; 60 + } 61 + 62 + // Validate NSID format 63 + Nsid::parse($data['id']); 64 + 65 + // Validate defs is an object/array 66 + if (! is_array($data['defs'])) { 67 + return false; 68 + } 69 + 70 + return true; 71 + } catch (\Exception) { 72 + return false; 73 + } 74 + } 75 + 76 + /** 77 + * Resolve $ref references to other schemas. 78 + */ 79 + public function resolveReference(string $ref, LexiconDocument $context): mixed 80 + { 81 + // Local reference (starting with #) 82 + if (str_starts_with($ref, '#')) { 83 + $defName = substr($ref, 1); 84 + 85 + return $context->getDefinition($defName); 86 + } 87 + 88 + // External reference with fragment (e.g., com.atproto.label.defs#selfLabels) 89 + if (str_contains($ref, '#')) { 90 + [$nsid, $defName] = explode('#', $ref, 2); 91 + 92 + // Return the ref as-is - external refs need schema loading which should be handled by caller 93 + return [ 94 + 'type' => 'ref', 95 + 'ref' => $ref, 96 + ]; 97 + } 98 + 99 + // Full NSID reference - return as ref definition 100 + return [ 101 + 'type' => 'ref', 102 + 'ref' => $ref, 103 + ]; 104 + } 105 + }
+206
src/Parser/DnsLexiconResolver.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Parser; 4 + 5 + use Illuminate\Support\Facades\Http; 6 + use SocialDept\Schema\Contracts\LexiconParser; 7 + use SocialDept\Schema\Contracts\LexiconResolver; 8 + use SocialDept\Schema\Data\LexiconDocument; 9 + use SocialDept\Schema\Exceptions\SchemaNotFoundException; 10 + 11 + class DnsLexiconResolver implements LexiconResolver 12 + { 13 + /** 14 + * Whether DNS resolution is enabled. 15 + */ 16 + protected bool $enabled; 17 + 18 + /** 19 + * HTTP timeout in seconds. 20 + */ 21 + protected int $httpTimeout; 22 + 23 + /** 24 + * Lexicon parser instance. 25 + */ 26 + protected LexiconParser $parser; 27 + 28 + /** 29 + * Whether the atp-resolver package is available. 30 + */ 31 + protected bool $hasResolver; 32 + 33 + /** 34 + * Whether we've shown the resolver warning. 35 + */ 36 + protected static bool $resolverWarningShown = false; 37 + 38 + /** 39 + * Create a new DnsLexiconResolver. 40 + */ 41 + public function __construct( 42 + bool $enabled = true, 43 + int $httpTimeout = 10, 44 + ?LexiconParser $parser = null 45 + ) { 46 + $this->enabled = $enabled; 47 + $this->httpTimeout = $httpTimeout; 48 + $this->parser = $parser ?? new DefaultLexiconParser; 49 + $this->hasResolver = class_exists('SocialDept\\Resolver\\Resolver'); 50 + } 51 + 52 + /** 53 + * Resolve NSID to Lexicon schema via DNS and XRPC. 54 + */ 55 + public function resolve(string $nsid): LexiconDocument 56 + { 57 + if (! $this->enabled) { 58 + throw SchemaNotFoundException::forNsid($nsid); 59 + } 60 + 61 + if (! $this->hasResolver) { 62 + $this->showResolverWarning(); 63 + throw SchemaNotFoundException::forNsid($nsid); 64 + } 65 + 66 + try { 67 + $nsidParsed = Nsid::parse($nsid); 68 + 69 + // Step 1: Query DNS TXT record for DID 70 + $did = $this->lookupDns($nsidParsed->getAuthority()); 71 + if ($did === null) { 72 + throw SchemaNotFoundException::forNsid($nsid); 73 + } 74 + 75 + // Step 2: Resolve DID to PDS endpoint 76 + $pdsUrl = $this->resolvePdsEndpoint($did); 77 + if ($pdsUrl === null) { 78 + throw SchemaNotFoundException::forNsid($nsid); 79 + } 80 + 81 + // Step 3: Fetch lexicon schema from repository 82 + $schema = $this->retrieveSchema($pdsUrl, $did, $nsid); 83 + 84 + return $this->parser->parseArray($schema); 85 + } catch (SchemaNotFoundException $e) { 86 + throw $e; 87 + } catch (\Exception $e) { 88 + throw SchemaNotFoundException::forNsid($nsid); 89 + } 90 + } 91 + 92 + /** 93 + * Perform DNS TXT lookup for _lexicon.{authority}. 94 + */ 95 + public function lookupDns(string $authority): ?string 96 + { 97 + // Convert authority to domain (e.g., pub.leaflet -> leaflet.pub) 98 + $parts = explode('.', $authority); 99 + $domain = implode('.', array_reverse($parts)); 100 + 101 + // Query DNS TXT record at _lexicon.<domain> 102 + $hostname = "_lexicon.{$domain}"; 103 + 104 + try { 105 + $records = dns_get_record($hostname, DNS_TXT); 106 + 107 + if ($records === false || empty($records)) { 108 + return null; 109 + } 110 + 111 + // Look for TXT record with did= prefix 112 + foreach ($records as $record) { 113 + if (isset($record['txt']) && str_starts_with($record['txt'], 'did=')) { 114 + return substr($record['txt'], 4); // Remove 'did=' prefix 115 + } 116 + } 117 + } catch (\Exception $e) { 118 + // DNS query failed 119 + return null; 120 + } 121 + 122 + return null; 123 + } 124 + 125 + /** 126 + * Retrieve schema via XRPC from PDS. 127 + */ 128 + public function retrieveSchema(string $pdsEndpoint, string $did, string $nsid): array 129 + { 130 + try { 131 + // Construct XRPC call to com.atproto.repo.getRecord 132 + $response = Http::timeout($this->httpTimeout) 133 + ->get("{$pdsEndpoint}/xrpc/com.atproto.repo.getRecord", [ 134 + 'repo' => $did, 135 + 'collection' => 'com.atproto.lexicon.schema', 136 + 'rkey' => $nsid, 137 + ]); 138 + 139 + if ($response->successful()) { 140 + $data = $response->json(); 141 + 142 + // Extract the lexicon schema from the record value 143 + if (isset($data['value']) && is_array($data['value']) && isset($data['value']['lexicon'])) { 144 + return $data['value']; 145 + } 146 + } 147 + } catch (\Exception $e) { 148 + throw SchemaNotFoundException::forNsid($nsid); 149 + } 150 + 151 + throw SchemaNotFoundException::forNsid($nsid); 152 + } 153 + 154 + /** 155 + * Check if DNS resolution is enabled. 156 + */ 157 + public function isEnabled(): bool 158 + { 159 + return $this->enabled; 160 + } 161 + 162 + /** 163 + * Resolve DID to PDS endpoint using atp-resolver. 164 + */ 165 + protected function resolvePdsEndpoint(string $did): ?string 166 + { 167 + if (! $this->hasResolver) { 168 + return null; 169 + } 170 + 171 + try { 172 + // Get resolver from Laravel container if available 173 + if (function_exists('app') && app()->has(\SocialDept\Resolver\Resolver::class)) { 174 + $resolver = app(\SocialDept\Resolver\Resolver::class); 175 + } else { 176 + // Can't instantiate without dependencies 177 + return null; 178 + } 179 + 180 + // Use the resolvePds method which handles DID resolution and PDS extraction 181 + return $resolver->resolvePds($did); 182 + } catch (\Exception $e) { 183 + return null; 184 + } 185 + } 186 + 187 + /** 188 + * Show warning about missing atp-resolver package. 189 + */ 190 + protected function showResolverWarning(): void 191 + { 192 + if (self::$resolverWarningShown) { 193 + return; 194 + } 195 + 196 + if (function_exists('logger')) { 197 + logger()->warning( 198 + 'DNS-based lexicon resolution requires the socialdept/atp-resolver package. '. 199 + 'Install it with: composer require socialdept/atp-resolver '. 200 + 'Falling back to local lexicon sources only.' 201 + ); 202 + } 203 + 204 + self::$resolverWarningShown = true; 205 + } 206 + }
+58
src/Parser/InMemoryLexiconRegistry.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Parser; 4 + 5 + use SocialDept\Schema\Contracts\LexiconRegistry; 6 + use SocialDept\Schema\Data\LexiconDocument; 7 + 8 + class InMemoryLexiconRegistry implements LexiconRegistry 9 + { 10 + /** 11 + * Registered lexicon documents. 12 + * 13 + * @var array<string, LexiconDocument> 14 + */ 15 + protected array $documents = []; 16 + 17 + /** 18 + * Register a lexicon document. 19 + */ 20 + public function register(LexiconDocument $document): void 21 + { 22 + $this->documents[$document->getNsid()] = $document; 23 + } 24 + 25 + /** 26 + * Get a lexicon document by NSID. 27 + */ 28 + public function get(string $nsid): ?LexiconDocument 29 + { 30 + return $this->documents[$nsid] ?? null; 31 + } 32 + 33 + /** 34 + * Check if a lexicon document exists. 35 + */ 36 + public function has(string $nsid): bool 37 + { 38 + return isset($this->documents[$nsid]); 39 + } 40 + 41 + /** 42 + * Get all registered lexicon documents. 43 + * 44 + * @return array<string, LexiconDocument> 45 + */ 46 + public function all(): array 47 + { 48 + return $this->documents; 49 + } 50 + 51 + /** 52 + * Clear all registered lexicon documents. 53 + */ 54 + public function clear(): void 55 + { 56 + $this->documents = []; 57 + } 58 + }
+2 -2
src/Parser/Nsid.php
··· 50 50 51 51 if (strlen($this->nsid) > self::MAX_LENGTH) { 52 52 throw SchemaException::withContext( 53 - "NSID exceeds maximum length of " . self::MAX_LENGTH . " characters", 53 + 'NSID exceeds maximum length of '.self::MAX_LENGTH.' characters', 54 54 ['nsid' => $this->nsid, 'length' => strlen($this->nsid)] 55 55 ); 56 56 } ··· 65 65 $segments = explode('.', $this->nsid); 66 66 if (count($segments) < self::MIN_SEGMENTS) { 67 67 throw SchemaException::withContext( 68 - 'NSID must have at least ' . self::MIN_SEGMENTS . ' segments', 68 + 'NSID must have at least '.self::MIN_SEGMENTS.' segments', 69 69 ['nsid' => $this->nsid, 'segments' => count($segments)] 70 70 ); 71 71 }
+1 -1
src/Parser/TypeParser.php
··· 45 45 ?ComplexTypeParser $complexParser = null, 46 46 ?SchemaLoader $schemaLoader = null 47 47 ) { 48 - $this->primitiveParser = $primitiveParser ?? new PrimitiveParser(); 48 + $this->primitiveParser = $primitiveParser ?? new PrimitiveParser; 49 49 $this->complexParser = $complexParser ?? new ComplexTypeParser($this->primitiveParser); 50 50 $this->schemaLoader = $schemaLoader; 51 51 }
+20 -12
src/SchemaManager.php
··· 40 40 /** 41 41 * Load a schema by NSID. 42 42 */ 43 - public function load(string $nsid): array 43 + public function load(string $nsid): LexiconDocument 44 44 { 45 45 return $this->loader->load($nsid); 46 46 } 47 47 48 48 /** 49 - * Check if a schema exists. 49 + * Find a schema by NSID (nullable). 50 50 */ 51 - public function exists(string $nsid): bool 51 + public function find(string $nsid): ?LexiconDocument 52 52 { 53 - return $this->loader->exists($nsid); 53 + return $this->loader->find($nsid); 54 54 } 55 55 56 56 /** 57 - * Parse a schema into a LexiconDocument. 57 + * Get all available schemas. 58 + * 59 + * @return array<string> 58 60 */ 59 - public function parse(string $nsid): LexiconDocument 61 + public function all(): array 60 62 { 61 - $schema = $this->loader->load($nsid); 63 + return $this->loader->all(); 64 + } 62 65 63 - return LexiconDocument::fromArray($schema); 66 + /** 67 + * Check if a schema exists. 68 + */ 69 + public function exists(string $nsid): bool 70 + { 71 + return $this->loader->exists($nsid); 64 72 } 65 73 66 74 /** ··· 68 76 */ 69 77 public function validate(string $nsid, array $data): bool 70 78 { 71 - $document = $this->parse($nsid); 79 + $document = $this->load($nsid); 72 80 73 81 return $this->validator->validate($data, $document); 74 82 } ··· 80 88 */ 81 89 public function validateWithErrors(string $nsid, array $data): array 82 90 { 83 - $document = $this->parse($nsid); 91 + $document = $this->load($nsid); 84 92 85 93 return $this->validator->validateWithErrors($data, $document); 86 94 } ··· 88 96 /** 89 97 * Generate DTO code from a schema. 90 98 */ 91 - public function generate(string $nsid, array $options = []): string 99 + public function generate(string $nsid, ?string $outputPath = null): string 92 100 { 93 101 if ($this->generator === null) { 94 102 throw new \RuntimeException('Generator not available'); 95 103 } 96 104 97 - $document = $this->parse($nsid); 105 + $document = $this->load($nsid); 98 106 99 107 return $this->generator->generate($document); 100 108 }
+51 -7
src/SchemaServiceProvider.php
··· 46 46 ); 47 47 }); 48 48 49 + // Register UnionResolver 50 + $this->app->singleton(Services\UnionResolver::class); 51 + 52 + // Register ExtensionManager 53 + $this->app->singleton(Support\ExtensionManager::class); 54 + 55 + // Register DefaultLexiconParser 56 + $this->app->singleton(Parser\DefaultLexiconParser::class); 57 + 58 + // Register InMemoryLexiconRegistry 59 + $this->app->singleton(Parser\InMemoryLexiconRegistry::class); 60 + 61 + // Register DnsLexiconResolver 62 + $this->app->singleton(Parser\DnsLexiconResolver::class, function ($app) { 63 + return new Parser\DnsLexiconResolver( 64 + enabled: config('schema.dns_resolution.enabled', true), 65 + httpTimeout: config('schema.http.timeout', 10), 66 + parser: $app->make(Parser\DefaultLexiconParser::class) 67 + ); 68 + }); 69 + 70 + // Register DefaultBlobHandler 71 + $this->app->singleton(Support\DefaultBlobHandler::class, function ($app) { 72 + return new Support\DefaultBlobHandler( 73 + disk: config('schema.blobs.disk'), 74 + path: config('schema.blobs.path', 'blobs') 75 + ); 76 + }); 77 + 78 + // Bind BlobHandler contract to DefaultBlobHandler 79 + $this->app->bind(Contracts\BlobHandler::class, Support\DefaultBlobHandler::class); 80 + 81 + // Bind LexiconParser contract to DefaultLexiconParser 82 + $this->app->bind(Contracts\LexiconParser::class, Parser\DefaultLexiconParser::class); 83 + 84 + // Bind LexiconRegistry contract to InMemoryLexiconRegistry 85 + $this->app->bind(Contracts\LexiconRegistry::class, Parser\InMemoryLexiconRegistry::class); 86 + 87 + // Bind LexiconResolver contract to DnsLexiconResolver 88 + $this->app->bind(Contracts\LexiconResolver::class, Parser\DnsLexiconResolver::class); 89 + 90 + // Bind SchemaRepository contract to SchemaLoader 91 + $this->app->bind(Contracts\SchemaRepository::class, Parser\SchemaLoader::class); 92 + 49 93 // Register DTOGenerator 50 94 $this->app->singleton(Generator\DTOGenerator::class, function ($app) { 51 95 return new Generator\DTOGenerator( ··· 88 132 89 133 // Register AT Protocol validation rules 90 134 $validator->extend('nsid', function ($attribute, $value) { 91 - $rule = new Validation\Rules\Nsid(); 135 + $rule = new Validation\Rules\Nsid; 92 136 $failed = false; 93 137 $rule->validate($attribute, $value, function () use (&$failed) { 94 138 $failed = true; ··· 98 142 }, 'The :attribute is not a valid NSID.'); 99 143 100 144 $validator->extend('did', function ($attribute, $value) { 101 - $rule = new Validation\Rules\Did(); 145 + $rule = new Validation\Rules\Did; 102 146 $failed = false; 103 147 $rule->validate($attribute, $value, function () use (&$failed) { 104 148 $failed = true; ··· 108 152 }, 'The :attribute is not a valid DID.'); 109 153 110 154 $validator->extend('handle', function ($attribute, $value) { 111 - $rule = new Validation\Rules\Handle(); 155 + $rule = new Validation\Rules\Handle; 112 156 $failed = false; 113 157 $rule->validate($attribute, $value, function () use (&$failed) { 114 158 $failed = true; ··· 118 162 }, 'The :attribute is not a valid handle.'); 119 163 120 164 $validator->extend('at_uri', function ($attribute, $value) { 121 - $rule = new Validation\Rules\AtUri(); 165 + $rule = new Validation\Rules\AtUri; 122 166 $failed = false; 123 167 $rule->validate($attribute, $value, function () use (&$failed) { 124 168 $failed = true; ··· 128 172 }, 'The :attribute is not a valid AT URI.'); 129 173 130 174 $validator->extend('at_datetime', function ($attribute, $value) { 131 - $rule = new Validation\Rules\AtDatetime(); 175 + $rule = new Validation\Rules\AtDatetime; 132 176 $failed = false; 133 177 $rule->validate($attribute, $value, function () use (&$failed) { 134 178 $failed = true; ··· 138 182 }, 'The :attribute is not a valid AT Protocol datetime.'); 139 183 140 184 $validator->extend('cid', function ($attribute, $value) { 141 - $rule = new Validation\Rules\Cid(); 185 + $rule = new Validation\Rules\Cid; 142 186 $failed = false; 143 187 $rule->validate($attribute, $value, function () use (&$failed) { 144 188 $failed = true; ··· 174 218 }, 'The :attribute must be at least :min_graphemes graphemes.'); 175 219 176 220 $validator->extend('language', function ($attribute, $value) { 177 - $rule = new Validation\Rules\Language(); 221 + $rule = new Validation\Rules\Language; 178 222 $failed = false; 179 223 $rule->validate($attribute, $value, function () use (&$failed) { 180 224 $failed = true;
+1
src/Services/BlobHandler.php
··· 12 12 class BlobHandler 13 13 { 14 14 use Macroable; 15 + 15 16 /** 16 17 * Storage disk name. 17 18 */
+1
src/Services/ModelMapper.php
··· 9 9 class ModelMapper 10 10 { 11 11 use Macroable; 12 + 12 13 /** 13 14 * Registered transformers. 14 15 *
+2 -2
src/Services/UnionResolver.php
··· 10 10 class UnionResolver 11 11 { 12 12 use Macroable; 13 + 13 14 /** 14 15 * Create a new UnionResolver. 15 16 */ 16 17 public function __construct( 17 18 protected ?LexiconRegistry $registry = null 18 - ) { 19 - } 19 + ) {} 20 20 21 21 /** 22 22 * Resolve union type from data.
+148
src/Support/DefaultBlobHandler.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Support; 4 + 5 + use Illuminate\Http\UploadedFile; 6 + use Illuminate\Support\Facades\Storage; 7 + use SocialDept\Schema\Contracts\BlobHandler; 8 + use SocialDept\Schema\Data\BlobReference; 9 + 10 + class DefaultBlobHandler implements BlobHandler 11 + { 12 + /** 13 + * Storage disk to use. 14 + */ 15 + protected string $disk; 16 + 17 + /** 18 + * Storage path prefix. 19 + */ 20 + protected string $path; 21 + 22 + /** 23 + * Create a new DefaultBlobHandler. 24 + */ 25 + public function __construct( 26 + ?string $disk = null, 27 + string $path = 'blobs' 28 + ) { 29 + $this->disk = $disk ?? config('filesystems.default', 'local'); 30 + $this->path = $path; 31 + } 32 + 33 + /** 34 + * Upload blob and create reference. 35 + */ 36 + public function upload(UploadedFile $file): BlobReference 37 + { 38 + $hash = hash_file('sha256', $file->getPathname()); 39 + $mimeType = $file->getMimeType() ?? 'application/octet-stream'; 40 + $size = $file->getSize(); 41 + 42 + // Store with hash as filename to enable deduplication 43 + $storagePath = $this->path.'/'.$hash; 44 + Storage::disk($this->disk)->put($storagePath, file_get_contents($file->getPathname())); 45 + 46 + return new BlobReference( 47 + cid: $hash, // Using hash as CID for simplicity 48 + mimeType: $mimeType, 49 + size: $size 50 + ); 51 + } 52 + 53 + /** 54 + * Upload blob from path. 55 + */ 56 + public function uploadFromPath(string $path): BlobReference 57 + { 58 + $hash = hash_file('sha256', $path); 59 + $mimeType = mime_content_type($path) ?: 'application/octet-stream'; 60 + $size = filesize($path); 61 + 62 + // Store with hash as filename 63 + $storagePath = $this->path.'/'.$hash; 64 + Storage::disk($this->disk)->put($storagePath, file_get_contents($path)); 65 + 66 + return new BlobReference( 67 + cid: $hash, 68 + mimeType: $mimeType, 69 + size: $size 70 + ); 71 + } 72 + 73 + /** 74 + * Upload blob from content. 75 + */ 76 + public function uploadFromContent(string $content, string $mimeType): BlobReference 77 + { 78 + $hash = hash('sha256', $content); 79 + $size = strlen($content); 80 + 81 + // Store with hash as filename 82 + $storagePath = $this->path.'/'.$hash; 83 + Storage::disk($this->disk)->put($storagePath, $content); 84 + 85 + return new BlobReference( 86 + cid: $hash, 87 + mimeType: $mimeType, 88 + size: $size 89 + ); 90 + } 91 + 92 + /** 93 + * Download blob content. 94 + */ 95 + public function download(BlobReference $blob): string 96 + { 97 + $storagePath = $this->path.'/'.$blob->cid; 98 + 99 + if (! Storage::disk($this->disk)->exists($storagePath)) { 100 + throw new \RuntimeException("Blob not found: {$blob->cid}"); 101 + } 102 + 103 + return Storage::disk($this->disk)->get($storagePath); 104 + } 105 + 106 + /** 107 + * Generate signed URL for blob. 108 + */ 109 + public function url(BlobReference $blob): string 110 + { 111 + $storagePath = $this->path.'/'.$blob->cid; 112 + 113 + // Try to generate a temporary URL if the disk supports it 114 + try { 115 + return Storage::disk($this->disk)->temporaryUrl( 116 + $storagePath, 117 + now()->addHour() 118 + ); 119 + } catch (\RuntimeException $e) { 120 + // Fallback to regular URL for disks that don't support temporary URLs 121 + return Storage::disk($this->disk)->url($storagePath); 122 + } 123 + } 124 + 125 + /** 126 + * Check if blob exists in storage. 127 + */ 128 + public function exists(BlobReference $blob): bool 129 + { 130 + $storagePath = $this->path.'/'.$blob->cid; 131 + 132 + return Storage::disk($this->disk)->exists($storagePath); 133 + } 134 + 135 + /** 136 + * Delete blob from storage. 137 + */ 138 + public function delete(BlobReference $blob): bool 139 + { 140 + $storagePath = $this->path.'/'.$blob->cid; 141 + 142 + if (! Storage::disk($this->disk)->exists($storagePath)) { 143 + return false; 144 + } 145 + 146 + return Storage::disk($this->disk)->delete($storagePath); 147 + } 148 + }
+100
src/Support/UnionHelper.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Support; 4 + 5 + use InvalidArgumentException; 6 + use SocialDept\Schema\Contracts\DiscriminatedUnion; 7 + use SocialDept\Schema\Data\Data; 8 + 9 + /** 10 + * Helper for resolving discriminated unions based on $type field. 11 + * 12 + * This class uses the DiscriminatedUnion interface to build type maps 13 + * and resolve union data to the correct variant class. 14 + */ 15 + class UnionHelper 16 + { 17 + /** 18 + * Resolve a closed union to the correct variant class. 19 + * 20 + * @param array $data The union data containing a $type field 21 + * @param array<class-string<Data>> $variants Array of possible variant class names 22 + * @return Data The resolved variant instance 23 + * 24 + * @throws InvalidArgumentException If $type is missing or unknown 25 + */ 26 + public static function resolveClosedUnion(array $data, array $variants): Data 27 + { 28 + // Validate $type field exists 29 + if (! isset($data['$type'])) { 30 + throw new InvalidArgumentException( 31 + 'Closed union data must contain a $type field for discrimination' 32 + ); 33 + } 34 + 35 + $type = $data['$type']; 36 + 37 + // Build type map using DiscriminatedUnion interface 38 + $typeMap = static::buildTypeMap($variants); 39 + 40 + // Check if type is known 41 + if (! isset($typeMap[$type])) { 42 + $knownTypes = implode(', ', array_keys($typeMap)); 43 + throw new InvalidArgumentException( 44 + "Unknown union type '{$type}'. Expected one of: {$knownTypes}" 45 + ); 46 + } 47 + 48 + // Resolve to correct variant class 49 + $class = $typeMap[$type]; 50 + 51 + return $class::fromArray($data); 52 + } 53 + 54 + /** 55 + * Validate an open union has $type field. 56 + * 57 + * Open unions pass data through as-is but must have $type for future discrimination. 58 + * 59 + * @param array $data The union data 60 + * @return array The validated union data 61 + * 62 + * @throws InvalidArgumentException If $type is missing 63 + */ 64 + public static function validateOpenUnion(array $data): array 65 + { 66 + if (! isset($data['$type'])) { 67 + throw new InvalidArgumentException( 68 + 'Open union data must contain a $type field for future discrimination' 69 + ); 70 + } 71 + 72 + return $data; 73 + } 74 + 75 + /** 76 + * Build a type map from variant classes using DiscriminatedUnion interface. 77 + * 78 + * @param array<class-string<Data>> $variants Array of variant class names 79 + * @return array<string, class-string<Data>> Map of discriminator => class name 80 + */ 81 + protected static function buildTypeMap(array $variants): array 82 + { 83 + $typeMap = []; 84 + 85 + foreach ($variants as $class) { 86 + // Ensure class implements DiscriminatedUnion 87 + if (! is_subclass_of($class, DiscriminatedUnion::class)) { 88 + throw new InvalidArgumentException( 89 + "Variant class {$class} must implement DiscriminatedUnion interface" 90 + ); 91 + } 92 + 93 + // Get discriminator from the class 94 + $discriminator = $class::getDiscriminator(); 95 + $typeMap[$discriminator] = $class; 96 + } 97 + 98 + return $typeMap; 99 + } 100 + }
+1 -2
src/Validation/LexiconValidator.php
··· 115 115 */ 116 116 public function validateByNsid(string $nsid, array $record): void 117 117 { 118 - $schema = $this->schemaLoader->load($nsid); 119 - $document = LexiconDocument::fromArray($schema); 118 + $document = $this->schemaLoader->load($nsid); 120 119 121 120 $this->validateRecord($document, $record); 122 121 }
+2 -2
src/Validation/Rules/AtUri.php
··· 49 49 $authority = $parts[0]; 50 50 51 51 // Validate authority (DID or handle) 52 - $didRule = new Did(); 53 - $handleRule = new Handle(); 52 + $didRule = new Did; 53 + $handleRule = new Handle; 54 54 55 55 $isValidDid = true; 56 56 $isValidHandle = true;
+5 -5
src/Validation/TypeValidators/ArrayValidator.php
··· 75 75 $type = $definition['type'] ?? null; 76 76 77 77 $validator = match ($type) { 78 - 'string' => new StringValidator(), 79 - 'integer' => new IntegerValidator(), 80 - 'boolean' => new BooleanValidator(), 81 - 'object' => new ObjectValidator(), 82 - 'array' => new ArrayValidator(), 78 + 'string' => new StringValidator, 79 + 'integer' => new IntegerValidator, 80 + 'boolean' => new BooleanValidator, 81 + 'object' => new ObjectValidator, 82 + 'array' => new ArrayValidator, 83 83 default => null, 84 84 }; 85 85
+5 -5
src/Validation/TypeValidators/ObjectValidator.php
··· 48 48 $type = $definition['type'] ?? null; 49 49 50 50 $validator = match ($type) { 51 - 'string' => new StringValidator(), 52 - 'integer' => new IntegerValidator(), 53 - 'boolean' => new BooleanValidator(), 54 - 'object' => new ObjectValidator(), 55 - 'array' => new ArrayValidator(), 51 + 'string' => new StringValidator, 52 + 'integer' => new IntegerValidator, 53 + 'boolean' => new BooleanValidator, 54 + 'object' => new ObjectValidator, 55 + 'array' => new ArrayValidator, 56 56 default => null, 57 57 }; 58 58
+19 -21
src/Validation/TypeValidators/UnionValidator.php
··· 3 3 namespace SocialDept\Schema\Validation\TypeValidators; 4 4 5 5 use SocialDept\Schema\Exceptions\RecordValidationException; 6 + use SocialDept\Schema\Services\UnionResolver; 6 7 7 8 class UnionValidator 8 9 { 10 + /** 11 + * Create a new UnionValidator. 12 + */ 13 + public function __construct( 14 + protected ?UnionResolver $resolver = null 15 + ) { 16 + $this->resolver = $resolver ?? new UnionResolver; 17 + } 18 + 9 19 /** 10 20 * Validate a union value against constraints. 11 21 * ··· 23 33 $closed = $definition['closed'] ?? false; 24 34 25 35 if ($closed) { 26 - $this->validateDiscriminatedUnion($value, $refs, $path); 36 + $this->validateDiscriminatedUnion($value, $refs, $path, $definition); 27 37 } else { 28 38 $this->validateOpenUnion($value, $refs, $path); 29 39 } ··· 33 43 * Validate discriminated (closed) union. 34 44 * 35 45 * @param array<string> $refs 46 + * @param array<string, mixed> $definition 36 47 */ 37 - protected function validateDiscriminatedUnion(mixed $value, array $refs, string $path): void 48 + protected function validateDiscriminatedUnion(mixed $value, array $refs, string $path, array $definition): void 38 49 { 39 - if (! is_array($value)) { 40 - throw RecordValidationException::invalidType($path, 'object', gettype($value)); 41 - } 42 - 43 - // Check for $type discriminator 44 - if (! isset($value['$type'])) { 45 - throw RecordValidationException::invalidValue( 46 - $path, 47 - 'Discriminated union must have $type field' 48 - ); 49 - } 50 - 51 - $type = $value['$type']; 52 - 53 - // Validate that $type is one of the allowed refs 54 - if (! in_array($type, $refs, true)) { 55 - $allowed = implode(', ', $refs); 56 - 50 + // Delegate validation to UnionResolver which handles all the logic 51 + try { 52 + $this->resolver->resolve($value, $definition); 53 + } catch (RecordValidationException $e) { 54 + // Re-throw with path context 57 55 throw RecordValidationException::invalidValue( 58 56 $path, 59 - "Union type '{$type}' not allowed. Must be one of: {$allowed}" 57 + $e->getMessage() 60 58 ); 61 59 } 62 60 }
+2 -1
src/Validation/Validator.php
··· 13 13 class Validator implements LexiconValidatorContract 14 14 { 15 15 use Macroable; 16 + 16 17 /** 17 18 * Validation mode constants. 18 19 */ ··· 165 166 if ($type !== 'record' && $type !== 'object') { 166 167 throw SchemaValidationException::invalidStructure( 167 168 $schema->getNsid(), 168 - ['Schema must be a record or object type, got: ' . ($type ?? 'unknown')] 169 + ['Schema must be a record or object type, got: '.($type ?? 'unknown')] 169 170 ); 170 171 } 171 172
+21 -12
src/helpers.php
··· 7 7 /** 8 8 * Get the SchemaManager instance or load a schema. 9 9 * 10 - * @param string|null $nsid 11 - * @return \SocialDept\Schema\SchemaManager|array 10 + * @return \SocialDept\Schema\SchemaManager|LexiconDocument 12 11 */ 13 12 function schema(?string $nsid = null) 14 13 { ··· 20 19 } 21 20 } 22 21 23 - if (! function_exists('schema_validate')) { 22 + if (! function_exists('schema_find')) { 23 + /** 24 + * Find a schema by NSID (nullable version). 25 + */ 26 + function schema_find(string $nsid): ?LexiconDocument 27 + { 28 + return Schema::find($nsid); 29 + } 30 + } 31 + 32 + if (! function_exists('schema_exists')) { 24 33 /** 25 - * Validate data against a schema. 34 + * Check if a schema exists. 26 35 */ 27 - function schema_validate(string $nsid, array $data): bool 36 + function schema_exists(string $nsid): bool 28 37 { 29 - return Schema::validate($nsid, $data); 38 + return Schema::exists($nsid); 30 39 } 31 40 } 32 41 33 - if (! function_exists('schema_parse')) { 42 + if (! function_exists('schema_validate')) { 34 43 /** 35 - * Parse a schema into a LexiconDocument. 44 + * Validate data against a schema. 36 45 */ 37 - function schema_parse(string $nsid): LexiconDocument 46 + function schema_validate(string $nsid, array $data): bool 38 47 { 39 - return Schema::parse($nsid); 48 + return Schema::validate($nsid, $data); 40 49 } 41 50 } 42 51 ··· 44 53 /** 45 54 * Generate DTO code from a schema. 46 55 */ 47 - function schema_generate(string $nsid, array $options = []): string 56 + function schema_generate(string $nsid, ?string $outputPath = null): string 48 57 { 49 - return Schema::generate($nsid, $options); 58 + return Schema::generate($nsid, $outputPath); 50 59 } 51 60 }