diff --git a/src/Console/GenerateCommand.php b/src/Console/GenerateCommand.php index 8a8eb8f..538d695 100644 --- a/src/Console/GenerateCommand.php +++ b/src/Console/GenerateCommand.php @@ -16,13 +16,20 @@ class GenerateCommand extends Command {--output= : Output directory for generated files} {--namespace= : Base namespace for generated classes} {--force : Overwrite existing files} - {--dry-run : Preview generated code without writing files}'; + {--dry-run : Preview generated code without writing files} + {--with-dependencies : Also generate all referenced lexicons recursively} + {--r|recursive : Alias for --with-dependencies}'; /** * The console command description. */ protected $description = 'Generate PHP DTO classes from ATProto Lexicon schemas'; + /** + * Track generated NSIDs to avoid duplicates. + */ + protected array $generated = []; + /** * Execute the console command. */ @@ -33,6 +40,7 @@ class GenerateCommand extends Command $namespace = $this->option('namespace') ?? config('schema.lexicons.base_namespace'); $force = $this->option('force'); $dryRun = $this->option('dry-run'); + $withDependencies = $this->option('with-dependencies') || $this->option('recursive'); $this->info("Generating DTO classes for schema: {$nsid}"); @@ -55,8 +63,7 @@ class GenerateCommand extends Command if ($dryRun) { $this->info('Dry run mode - no files will be written'); - $schema = $loader->load($nsid); - $document = \SocialDept\Schema\Data\LexiconDocument::fromArray($schema); + $document = $loader->load($nsid); $code = $generator->preview($document); $this->line(''); @@ -68,14 +75,22 @@ class GenerateCommand extends Command return self::SUCCESS; } - $files = $generator->generateByNsid($nsid, [ - 'dryRun' => false, - 'overwrite' => $force, - ]); + $allFiles = []; + + if ($withDependencies) { + $this->info('Generating with dependencies...'); + $allFiles = $this->generateWithDependencies($nsid, $loader, $generator, $force); + } else { + $allFiles = $generator->generateByNsid($nsid, [ + 'dryRun' => false, + 'overwrite' => $force, + ]); + } - $this->info('Generated '.count($files).' file(s):'); + $this->newLine(); + $this->info('Generated '.count($allFiles).' file(s):'); - foreach ($files as $file) { + foreach ($allFiles as $file) { $this->line(" - {$file}"); } @@ -93,4 +108,132 @@ class GenerateCommand extends Command return self::FAILURE; } } + + /** + * Generate schema with all its dependencies recursively. + */ + protected function generateWithDependencies( + string $nsid, + SchemaLoader $loader, + DTOGenerator $generator, + bool $force + ): array { + // Skip if already generated + if (in_array($nsid, $this->generated)) { + return []; + } + + $this->line(" → Loading schema: {$nsid}"); + + try { + $schema = $loader->load($nsid); + } catch (\Exception $e) { + $this->warn(" ⚠ Could not load {$nsid}: ".$e->getMessage()); + + return []; + } + + // Extract all referenced NSIDs from this schema + $dependencies = $this->extractDependencies($schema); + + $allFiles = []; + + // Generate dependencies first + foreach ($dependencies as $depNsid) { + $depFiles = $this->generateWithDependencies($depNsid, $loader, $generator, $force); + $allFiles = array_merge($allFiles, $depFiles); + } + + // Mark as generated before generating to prevent circular references + $this->generated[] = $nsid; + + // Generate current schema + try { + $files = $generator->generateByNsid($nsid, [ + 'dryRun' => false, + 'overwrite' => $force, + ]); + $allFiles = array_merge($allFiles, $files); + } catch (\Exception $e) { + $this->warn(" ⚠ Could not generate {$nsid}: ".$e->getMessage()); + } + + return $allFiles; + } + + /** + * Extract all NSID dependencies from a schema. + */ + protected function extractDependencies(\SocialDept\Schema\Data\LexiconDocument $schema): array + { + $dependencies = []; + $currentNsid = $schema->getNsid(); + + // Walk through all definitions + foreach ($schema->defs as $def) { + $dependencies = array_merge($dependencies, $this->extractRefsFromDefinition($def)); + } + + // Filter out refs that are definitions within the same schema + // (refs that start with the current NSID followed by a dot) + $dependencies = array_filter($dependencies, function ($ref) use ($currentNsid) { + return ! str_starts_with($ref, $currentNsid.'.'); + }); + + return array_unique($dependencies); + } + + /** + * Recursively extract refs from a definition. + */ + protected function extractRefsFromDefinition(array $definition): array + { + $refs = []; + + // Handle direct ref + if (isset($definition['ref'])) { + $ref = $definition['ref']; + // Skip local references (starting with #) + if (! str_starts_with($ref, '#')) { + // Extract NSID part (before fragment) + if (str_contains($ref, '#')) { + $ref = explode('#', $ref)[0]; + } + $refs[] = $ref; + } + } + + // Handle union refs + if (isset($definition['refs']) && is_array($definition['refs'])) { + foreach ($definition['refs'] as $ref) { + // Skip local references + if (! str_starts_with($ref, '#')) { + // Extract NSID part + if (str_contains($ref, '#')) { + $ref = explode('#', $ref)[0]; + } + $refs[] = $ref; + } + } + } + + // Recursively check properties + if (isset($definition['properties']) && is_array($definition['properties'])) { + foreach ($definition['properties'] as $propDef) { + $refs = array_merge($refs, $this->extractRefsFromDefinition($propDef)); + } + } + + // Recursively check record + if (isset($definition['record']) && is_array($definition['record'])) { + $refs = array_merge($refs, $this->extractRefsFromDefinition($definition['record'])); + } + + // Recursively check array items + if (isset($definition['items']) && is_array($definition['items'])) { + $refs = array_merge($refs, $this->extractRefsFromDefinition($definition['items'])); + } + + return $refs; + } } diff --git a/src/Console/ListCommand.php b/src/Console/ListCommand.php index 3961709..27b6ec9 100644 --- a/src/Console/ListCommand.php +++ b/src/Console/ListCommand.php @@ -3,7 +3,6 @@ namespace SocialDept\Schema\Console; use Illuminate\Console\Command; -use SocialDept\Schema\Data\LexiconDocument; use SocialDept\Schema\Parser\SchemaLoader; class ListCommand extends Command @@ -55,8 +54,7 @@ class ListCommand extends Command foreach ($schemas as $nsid) { try { - $schema = $loader->load($nsid); - $document = LexiconDocument::fromArray($schema); + $document = $loader->load($nsid); $schemaType = 'unknown'; if ($document->isRecord()) { @@ -173,8 +171,7 @@ class ListCommand extends Command { return array_filter($schemas, function ($nsid) use ($type, $loader) { try { - $schema = $loader->load($nsid); - $document = LexiconDocument::fromArray($schema); + $document = $loader->load($nsid); return match ($type) { 'record' => $document->isRecord(), diff --git a/src/Console/ValidateCommand.php b/src/Console/ValidateCommand.php index 44ba0a4..0e5ec8e 100644 --- a/src/Console/ValidateCommand.php +++ b/src/Console/ValidateCommand.php @@ -3,7 +3,6 @@ namespace SocialDept\Schema\Console; use Illuminate\Console\Command; -use SocialDept\Schema\Data\LexiconDocument; use SocialDept\Schema\Parser\SchemaLoader; use SocialDept\Schema\Validation\LexiconValidator; @@ -64,8 +63,7 @@ class ValidateCommand extends Command $this->info("Validating data against schema: {$nsid}"); - $schema = $loader->load($nsid); - $document = LexiconDocument::fromArray($schema); + $document = $loader->load($nsid); $errors = $validator->validateWithErrors($data, $document); diff --git a/src/Contracts/DiscriminatedUnion.php b/src/Contracts/DiscriminatedUnion.php new file mode 100644 index 0000000..dcc81e2 --- /dev/null +++ b/src/Contracts/DiscriminatedUnion.php @@ -0,0 +1,21 @@ + $value) { - $result[$property] = $this->serializeValue($value); + // Skip null values to exclude optional fields that aren't set + if ($value !== null) { + $result[$property] = $this->serializeValue($value); + } } - return $result; + return array_filter($result); } /** @@ -57,14 +71,16 @@ abstract class Data implements Arrayable, Jsonable, JsonSerializable, Stringable */ protected function serializeValue(mixed $value): mixed { + // Union variants must include $type for discrimination if ($value instanceof self) { - return $value->toArray(); + return $value->toRecord(); } if ($value instanceof Arrayable) { return $value->toArray(); } + // Preserve arrays with $type (open union data) if (is_array($value)) { return array_map(fn ($item) => $this->serializeValue($item), $value); } @@ -103,7 +119,7 @@ abstract class Data implements Arrayable, Jsonable, JsonSerializable, Stringable */ public static function fromRecord(array $record): static { - return static::fromArray($record); + return static::fromArray($record['value'] ?? $record); } /** @@ -114,7 +130,10 @@ abstract class Data implements Arrayable, Jsonable, JsonSerializable, Stringable */ public function toRecord(): array { - return $this->toArray(); + return [ + ...$this->toArray(), + '$type' => $this->getLexicon(), + ]; } /** diff --git a/src/Exceptions/BlobException.php b/src/Exceptions/BlobException.php index c335df4..3456e3f 100644 --- a/src/Exceptions/BlobException.php +++ b/src/Exceptions/BlobException.php @@ -32,7 +32,7 @@ class BlobException extends SchemaException public static function invalidMimeType(string $mimeType, array $accepted): self { return static::withContext( - "Invalid MIME type {$mimeType}. Accepted: " . implode(', ', $accepted), + "Invalid MIME type {$mimeType}. Accepted: ".implode(', ', $accepted), ['mimeType' => $mimeType, 'accepted' => $accepted] ); } diff --git a/src/Exceptions/SchemaValidationException.php b/src/Exceptions/SchemaValidationException.php index edab598..214da13 100644 --- a/src/Exceptions/SchemaValidationException.php +++ b/src/Exceptions/SchemaValidationException.php @@ -9,7 +9,7 @@ class SchemaValidationException extends SchemaException */ public static function invalidStructure(string $nsid, array $errors): self { - $message = "Schema validation failed for {$nsid}:\n" . implode("\n", $errors); + $message = "Schema validation failed for {$nsid}:\n".implode("\n", $errors); return static::withContext($message, [ 'nsid' => $nsid, diff --git a/src/Facades/Schema.php b/src/Facades/Schema.php index c99ff2a..2850c64 100644 --- a/src/Facades/Schema.php +++ b/src/Facades/Schema.php @@ -6,13 +6,14 @@ use Illuminate\Support\Facades\Facade; use SocialDept\Schema\Data\LexiconDocument; /** - * @method static array load(string $nsid) + * @method static LexiconDocument load(string $nsid) + * @method static LexiconDocument|null find(string $nsid) * @method static bool exists(string $nsid) - * @method static LexiconDocument parse(string $nsid) + * @method static array all() + * @method static void clearCache(?string $nsid = null) + * @method static string generate(string $nsid, ?string $outputPath = null) * @method static bool validate(string $nsid, array $data) * @method static array validateWithErrors(string $nsid, array $data) - * @method static string generate(string $nsid, array $options = []) - * @method static void clearCache(?string $nsid = null) * * @see \SocialDept\Schema\SchemaManager */ diff --git a/src/Parser/ComplexTypeParser.php b/src/Parser/ComplexTypeParser.php index c7b90da..057e139 100644 --- a/src/Parser/ComplexTypeParser.php +++ b/src/Parser/ComplexTypeParser.php @@ -27,7 +27,7 @@ class ComplexTypeParser */ public function __construct(?PrimitiveParser $primitiveParser = null) { - $this->primitiveParser = $primitiveParser ?? new PrimitiveParser(); + $this->primitiveParser = $primitiveParser ?? new PrimitiveParser; } /** diff --git a/src/Parser/DefaultLexiconParser.php b/src/Parser/DefaultLexiconParser.php new file mode 100644 index 0000000..5700bb0 --- /dev/null +++ b/src/Parser/DefaultLexiconParser.php @@ -0,0 +1,105 @@ +parseArray($data); + } + + /** + * Parse Lexicon from array data. + */ + public function parseArray(array $data): LexiconDocument + { + return LexiconDocument::fromArray($data); + } + + /** + * Validate Lexicon schema structure. + */ + public function validate(array $data): bool + { + try { + // Required fields + if (! isset($data['lexicon'])) { + return false; + } + + if (! isset($data['id'])) { + return false; + } + + if (! isset($data['defs'])) { + return false; + } + + // Validate lexicon version + $lexicon = (int) $data['lexicon']; + if ($lexicon !== 1) { + return false; + } + + // Validate NSID format + Nsid::parse($data['id']); + + // Validate defs is an object/array + if (! is_array($data['defs'])) { + return false; + } + + return true; + } catch (\Exception) { + return false; + } + } + + /** + * Resolve $ref references to other schemas. + */ + public function resolveReference(string $ref, LexiconDocument $context): mixed + { + // Local reference (starting with #) + if (str_starts_with($ref, '#')) { + $defName = substr($ref, 1); + + return $context->getDefinition($defName); + } + + // External reference with fragment (e.g., com.atproto.label.defs#selfLabels) + if (str_contains($ref, '#')) { + [$nsid, $defName] = explode('#', $ref, 2); + + // Return the ref as-is - external refs need schema loading which should be handled by caller + return [ + 'type' => 'ref', + 'ref' => $ref, + ]; + } + + // Full NSID reference - return as ref definition + return [ + 'type' => 'ref', + 'ref' => $ref, + ]; + } +} diff --git a/src/Parser/DnsLexiconResolver.php b/src/Parser/DnsLexiconResolver.php new file mode 100644 index 0000000..c86aea3 --- /dev/null +++ b/src/Parser/DnsLexiconResolver.php @@ -0,0 +1,206 @@ +enabled = $enabled; + $this->httpTimeout = $httpTimeout; + $this->parser = $parser ?? new DefaultLexiconParser; + $this->hasResolver = class_exists('SocialDept\\Resolver\\Resolver'); + } + + /** + * Resolve NSID to Lexicon schema via DNS and XRPC. + */ + public function resolve(string $nsid): LexiconDocument + { + if (! $this->enabled) { + throw SchemaNotFoundException::forNsid($nsid); + } + + if (! $this->hasResolver) { + $this->showResolverWarning(); + throw SchemaNotFoundException::forNsid($nsid); + } + + try { + $nsidParsed = Nsid::parse($nsid); + + // Step 1: Query DNS TXT record for DID + $did = $this->lookupDns($nsidParsed->getAuthority()); + if ($did === null) { + throw SchemaNotFoundException::forNsid($nsid); + } + + // Step 2: Resolve DID to PDS endpoint + $pdsUrl = $this->resolvePdsEndpoint($did); + if ($pdsUrl === null) { + throw SchemaNotFoundException::forNsid($nsid); + } + + // Step 3: Fetch lexicon schema from repository + $schema = $this->retrieveSchema($pdsUrl, $did, $nsid); + + return $this->parser->parseArray($schema); + } catch (SchemaNotFoundException $e) { + throw $e; + } catch (\Exception $e) { + throw SchemaNotFoundException::forNsid($nsid); + } + } + + /** + * Perform DNS TXT lookup for _lexicon.{authority}. + */ + public function lookupDns(string $authority): ?string + { + // Convert authority to domain (e.g., pub.leaflet -> leaflet.pub) + $parts = explode('.', $authority); + $domain = implode('.', array_reverse($parts)); + + // Query DNS TXT record at _lexicon. + $hostname = "_lexicon.{$domain}"; + + try { + $records = dns_get_record($hostname, DNS_TXT); + + if ($records === false || empty($records)) { + return null; + } + + // Look for TXT record with did= prefix + foreach ($records as $record) { + if (isset($record['txt']) && str_starts_with($record['txt'], 'did=')) { + return substr($record['txt'], 4); // Remove 'did=' prefix + } + } + } catch (\Exception $e) { + // DNS query failed + return null; + } + + return null; + } + + /** + * Retrieve schema via XRPC from PDS. + */ + public function retrieveSchema(string $pdsEndpoint, string $did, string $nsid): array + { + try { + // Construct XRPC call to com.atproto.repo.getRecord + $response = Http::timeout($this->httpTimeout) + ->get("{$pdsEndpoint}/xrpc/com.atproto.repo.getRecord", [ + 'repo' => $did, + 'collection' => 'com.atproto.lexicon.schema', + 'rkey' => $nsid, + ]); + + if ($response->successful()) { + $data = $response->json(); + + // Extract the lexicon schema from the record value + if (isset($data['value']) && is_array($data['value']) && isset($data['value']['lexicon'])) { + return $data['value']; + } + } + } catch (\Exception $e) { + throw SchemaNotFoundException::forNsid($nsid); + } + + throw SchemaNotFoundException::forNsid($nsid); + } + + /** + * Check if DNS resolution is enabled. + */ + public function isEnabled(): bool + { + return $this->enabled; + } + + /** + * Resolve DID to PDS endpoint using atp-resolver. + */ + protected function resolvePdsEndpoint(string $did): ?string + { + if (! $this->hasResolver) { + return null; + } + + try { + // Get resolver from Laravel container if available + if (function_exists('app') && app()->has(\SocialDept\Resolver\Resolver::class)) { + $resolver = app(\SocialDept\Resolver\Resolver::class); + } else { + // Can't instantiate without dependencies + return null; + } + + // Use the resolvePds method which handles DID resolution and PDS extraction + return $resolver->resolvePds($did); + } catch (\Exception $e) { + return null; + } + } + + /** + * Show warning about missing atp-resolver package. + */ + protected function showResolverWarning(): void + { + if (self::$resolverWarningShown) { + return; + } + + if (function_exists('logger')) { + logger()->warning( + 'DNS-based lexicon resolution requires the socialdept/atp-resolver package. '. + 'Install it with: composer require socialdept/atp-resolver '. + 'Falling back to local lexicon sources only.' + ); + } + + self::$resolverWarningShown = true; + } +} diff --git a/src/Parser/InMemoryLexiconRegistry.php b/src/Parser/InMemoryLexiconRegistry.php new file mode 100644 index 0000000..1bb6772 --- /dev/null +++ b/src/Parser/InMemoryLexiconRegistry.php @@ -0,0 +1,58 @@ + + */ + protected array $documents = []; + + /** + * Register a lexicon document. + */ + public function register(LexiconDocument $document): void + { + $this->documents[$document->getNsid()] = $document; + } + + /** + * Get a lexicon document by NSID. + */ + public function get(string $nsid): ?LexiconDocument + { + return $this->documents[$nsid] ?? null; + } + + /** + * Check if a lexicon document exists. + */ + public function has(string $nsid): bool + { + return isset($this->documents[$nsid]); + } + + /** + * Get all registered lexicon documents. + * + * @return array + */ + public function all(): array + { + return $this->documents; + } + + /** + * Clear all registered lexicon documents. + */ + public function clear(): void + { + $this->documents = []; + } +} diff --git a/src/Parser/Nsid.php b/src/Parser/Nsid.php index fa210c5..9e33419 100644 --- a/src/Parser/Nsid.php +++ b/src/Parser/Nsid.php @@ -50,7 +50,7 @@ class Nsid implements Stringable if (strlen($this->nsid) > self::MAX_LENGTH) { throw SchemaException::withContext( - "NSID exceeds maximum length of " . self::MAX_LENGTH . " characters", + 'NSID exceeds maximum length of '.self::MAX_LENGTH.' characters', ['nsid' => $this->nsid, 'length' => strlen($this->nsid)] ); } @@ -65,7 +65,7 @@ class Nsid implements Stringable $segments = explode('.', $this->nsid); if (count($segments) < self::MIN_SEGMENTS) { throw SchemaException::withContext( - 'NSID must have at least ' . self::MIN_SEGMENTS . ' segments', + 'NSID must have at least '.self::MIN_SEGMENTS.' segments', ['nsid' => $this->nsid, 'segments' => count($segments)] ); } diff --git a/src/Parser/TypeParser.php b/src/Parser/TypeParser.php index 5524471..0d667cb 100644 --- a/src/Parser/TypeParser.php +++ b/src/Parser/TypeParser.php @@ -45,7 +45,7 @@ class TypeParser ?ComplexTypeParser $complexParser = null, ?SchemaLoader $schemaLoader = null ) { - $this->primitiveParser = $primitiveParser ?? new PrimitiveParser(); + $this->primitiveParser = $primitiveParser ?? new PrimitiveParser; $this->complexParser = $complexParser ?? new ComplexTypeParser($this->primitiveParser); $this->schemaLoader = $schemaLoader; } diff --git a/src/SchemaManager.php b/src/SchemaManager.php index dacd1a4..af558c9 100644 --- a/src/SchemaManager.php +++ b/src/SchemaManager.php @@ -40,27 +40,35 @@ class SchemaManager /** * Load a schema by NSID. */ - public function load(string $nsid): array + public function load(string $nsid): LexiconDocument { return $this->loader->load($nsid); } /** - * Check if a schema exists. + * Find a schema by NSID (nullable). */ - public function exists(string $nsid): bool + public function find(string $nsid): ?LexiconDocument { - return $this->loader->exists($nsid); + return $this->loader->find($nsid); } /** - * Parse a schema into a LexiconDocument. + * Get all available schemas. + * + * @return array */ - public function parse(string $nsid): LexiconDocument + public function all(): array { - $schema = $this->loader->load($nsid); + return $this->loader->all(); + } - return LexiconDocument::fromArray($schema); + /** + * Check if a schema exists. + */ + public function exists(string $nsid): bool + { + return $this->loader->exists($nsid); } /** @@ -68,7 +76,7 @@ class SchemaManager */ public function validate(string $nsid, array $data): bool { - $document = $this->parse($nsid); + $document = $this->load($nsid); return $this->validator->validate($data, $document); } @@ -80,7 +88,7 @@ class SchemaManager */ public function validateWithErrors(string $nsid, array $data): array { - $document = $this->parse($nsid); + $document = $this->load($nsid); return $this->validator->validateWithErrors($data, $document); } @@ -88,13 +96,13 @@ class SchemaManager /** * Generate DTO code from a schema. */ - public function generate(string $nsid, array $options = []): string + public function generate(string $nsid, ?string $outputPath = null): string { if ($this->generator === null) { throw new \RuntimeException('Generator not available'); } - $document = $this->parse($nsid); + $document = $this->load($nsid); return $this->generator->generate($document); } diff --git a/src/SchemaServiceProvider.php b/src/SchemaServiceProvider.php index 9f801ab..4dcf504 100644 --- a/src/SchemaServiceProvider.php +++ b/src/SchemaServiceProvider.php @@ -46,6 +46,50 @@ class SchemaServiceProvider extends ServiceProvider ); }); + // Register UnionResolver + $this->app->singleton(Services\UnionResolver::class); + + // Register ExtensionManager + $this->app->singleton(Support\ExtensionManager::class); + + // Register DefaultLexiconParser + $this->app->singleton(Parser\DefaultLexiconParser::class); + + // Register InMemoryLexiconRegistry + $this->app->singleton(Parser\InMemoryLexiconRegistry::class); + + // Register DnsLexiconResolver + $this->app->singleton(Parser\DnsLexiconResolver::class, function ($app) { + return new Parser\DnsLexiconResolver( + enabled: config('schema.dns_resolution.enabled', true), + httpTimeout: config('schema.http.timeout', 10), + parser: $app->make(Parser\DefaultLexiconParser::class) + ); + }); + + // Register DefaultBlobHandler + $this->app->singleton(Support\DefaultBlobHandler::class, function ($app) { + return new Support\DefaultBlobHandler( + disk: config('schema.blobs.disk'), + path: config('schema.blobs.path', 'blobs') + ); + }); + + // Bind BlobHandler contract to DefaultBlobHandler + $this->app->bind(Contracts\BlobHandler::class, Support\DefaultBlobHandler::class); + + // Bind LexiconParser contract to DefaultLexiconParser + $this->app->bind(Contracts\LexiconParser::class, Parser\DefaultLexiconParser::class); + + // Bind LexiconRegistry contract to InMemoryLexiconRegistry + $this->app->bind(Contracts\LexiconRegistry::class, Parser\InMemoryLexiconRegistry::class); + + // Bind LexiconResolver contract to DnsLexiconResolver + $this->app->bind(Contracts\LexiconResolver::class, Parser\DnsLexiconResolver::class); + + // Bind SchemaRepository contract to SchemaLoader + $this->app->bind(Contracts\SchemaRepository::class, Parser\SchemaLoader::class); + // Register DTOGenerator $this->app->singleton(Generator\DTOGenerator::class, function ($app) { return new Generator\DTOGenerator( @@ -88,7 +132,7 @@ class SchemaServiceProvider extends ServiceProvider // Register AT Protocol validation rules $validator->extend('nsid', function ($attribute, $value) { - $rule = new Validation\Rules\Nsid(); + $rule = new Validation\Rules\Nsid; $failed = false; $rule->validate($attribute, $value, function () use (&$failed) { $failed = true; @@ -98,7 +142,7 @@ class SchemaServiceProvider extends ServiceProvider }, 'The :attribute is not a valid NSID.'); $validator->extend('did', function ($attribute, $value) { - $rule = new Validation\Rules\Did(); + $rule = new Validation\Rules\Did; $failed = false; $rule->validate($attribute, $value, function () use (&$failed) { $failed = true; @@ -108,7 +152,7 @@ class SchemaServiceProvider extends ServiceProvider }, 'The :attribute is not a valid DID.'); $validator->extend('handle', function ($attribute, $value) { - $rule = new Validation\Rules\Handle(); + $rule = new Validation\Rules\Handle; $failed = false; $rule->validate($attribute, $value, function () use (&$failed) { $failed = true; @@ -118,7 +162,7 @@ class SchemaServiceProvider extends ServiceProvider }, 'The :attribute is not a valid handle.'); $validator->extend('at_uri', function ($attribute, $value) { - $rule = new Validation\Rules\AtUri(); + $rule = new Validation\Rules\AtUri; $failed = false; $rule->validate($attribute, $value, function () use (&$failed) { $failed = true; @@ -128,7 +172,7 @@ class SchemaServiceProvider extends ServiceProvider }, 'The :attribute is not a valid AT URI.'); $validator->extend('at_datetime', function ($attribute, $value) { - $rule = new Validation\Rules\AtDatetime(); + $rule = new Validation\Rules\AtDatetime; $failed = false; $rule->validate($attribute, $value, function () use (&$failed) { $failed = true; @@ -138,7 +182,7 @@ class SchemaServiceProvider extends ServiceProvider }, 'The :attribute is not a valid AT Protocol datetime.'); $validator->extend('cid', function ($attribute, $value) { - $rule = new Validation\Rules\Cid(); + $rule = new Validation\Rules\Cid; $failed = false; $rule->validate($attribute, $value, function () use (&$failed) { $failed = true; @@ -174,7 +218,7 @@ class SchemaServiceProvider extends ServiceProvider }, 'The :attribute must be at least :min_graphemes graphemes.'); $validator->extend('language', function ($attribute, $value) { - $rule = new Validation\Rules\Language(); + $rule = new Validation\Rules\Language; $failed = false; $rule->validate($attribute, $value, function () use (&$failed) { $failed = true; diff --git a/src/Services/BlobHandler.php b/src/Services/BlobHandler.php index e462fd6..23e3431 100644 --- a/src/Services/BlobHandler.php +++ b/src/Services/BlobHandler.php @@ -12,6 +12,7 @@ use SocialDept\Schema\Exceptions\RecordValidationException; class BlobHandler { use Macroable; + /** * Storage disk name. */ diff --git a/src/Services/ModelMapper.php b/src/Services/ModelMapper.php index 9970e89..796edf2 100644 --- a/src/Services/ModelMapper.php +++ b/src/Services/ModelMapper.php @@ -9,6 +9,7 @@ use SocialDept\Schema\Exceptions\SchemaException; class ModelMapper { use Macroable; + /** * Registered transformers. * diff --git a/src/Services/UnionResolver.php b/src/Services/UnionResolver.php index 2899519..36d95a1 100644 --- a/src/Services/UnionResolver.php +++ b/src/Services/UnionResolver.php @@ -10,13 +10,13 @@ use SocialDept\Schema\Exceptions\RecordValidationException; class UnionResolver { use Macroable; + /** * Create a new UnionResolver. */ public function __construct( protected ?LexiconRegistry $registry = null - ) { - } + ) {} /** * Resolve union type from data. diff --git a/src/Support/DefaultBlobHandler.php b/src/Support/DefaultBlobHandler.php new file mode 100644 index 0000000..f36ee28 --- /dev/null +++ b/src/Support/DefaultBlobHandler.php @@ -0,0 +1,148 @@ +disk = $disk ?? config('filesystems.default', 'local'); + $this->path = $path; + } + + /** + * Upload blob and create reference. + */ + public function upload(UploadedFile $file): BlobReference + { + $hash = hash_file('sha256', $file->getPathname()); + $mimeType = $file->getMimeType() ?? 'application/octet-stream'; + $size = $file->getSize(); + + // Store with hash as filename to enable deduplication + $storagePath = $this->path.'/'.$hash; + Storage::disk($this->disk)->put($storagePath, file_get_contents($file->getPathname())); + + return new BlobReference( + cid: $hash, // Using hash as CID for simplicity + mimeType: $mimeType, + size: $size + ); + } + + /** + * Upload blob from path. + */ + public function uploadFromPath(string $path): BlobReference + { + $hash = hash_file('sha256', $path); + $mimeType = mime_content_type($path) ?: 'application/octet-stream'; + $size = filesize($path); + + // Store with hash as filename + $storagePath = $this->path.'/'.$hash; + Storage::disk($this->disk)->put($storagePath, file_get_contents($path)); + + return new BlobReference( + cid: $hash, + mimeType: $mimeType, + size: $size + ); + } + + /** + * Upload blob from content. + */ + public function uploadFromContent(string $content, string $mimeType): BlobReference + { + $hash = hash('sha256', $content); + $size = strlen($content); + + // Store with hash as filename + $storagePath = $this->path.'/'.$hash; + Storage::disk($this->disk)->put($storagePath, $content); + + return new BlobReference( + cid: $hash, + mimeType: $mimeType, + size: $size + ); + } + + /** + * Download blob content. + */ + public function download(BlobReference $blob): string + { + $storagePath = $this->path.'/'.$blob->cid; + + if (! Storage::disk($this->disk)->exists($storagePath)) { + throw new \RuntimeException("Blob not found: {$blob->cid}"); + } + + return Storage::disk($this->disk)->get($storagePath); + } + + /** + * Generate signed URL for blob. + */ + public function url(BlobReference $blob): string + { + $storagePath = $this->path.'/'.$blob->cid; + + // Try to generate a temporary URL if the disk supports it + try { + return Storage::disk($this->disk)->temporaryUrl( + $storagePath, + now()->addHour() + ); + } catch (\RuntimeException $e) { + // Fallback to regular URL for disks that don't support temporary URLs + return Storage::disk($this->disk)->url($storagePath); + } + } + + /** + * Check if blob exists in storage. + */ + public function exists(BlobReference $blob): bool + { + $storagePath = $this->path.'/'.$blob->cid; + + return Storage::disk($this->disk)->exists($storagePath); + } + + /** + * Delete blob from storage. + */ + public function delete(BlobReference $blob): bool + { + $storagePath = $this->path.'/'.$blob->cid; + + if (! Storage::disk($this->disk)->exists($storagePath)) { + return false; + } + + return Storage::disk($this->disk)->delete($storagePath); + } +} diff --git a/src/Support/UnionHelper.php b/src/Support/UnionHelper.php new file mode 100644 index 0000000..75f3a81 --- /dev/null +++ b/src/Support/UnionHelper.php @@ -0,0 +1,100 @@ +> $variants Array of possible variant class names + * @return Data The resolved variant instance + * + * @throws InvalidArgumentException If $type is missing or unknown + */ + public static function resolveClosedUnion(array $data, array $variants): Data + { + // Validate $type field exists + if (! isset($data['$type'])) { + throw new InvalidArgumentException( + 'Closed union data must contain a $type field for discrimination' + ); + } + + $type = $data['$type']; + + // Build type map using DiscriminatedUnion interface + $typeMap = static::buildTypeMap($variants); + + // Check if type is known + if (! isset($typeMap[$type])) { + $knownTypes = implode(', ', array_keys($typeMap)); + throw new InvalidArgumentException( + "Unknown union type '{$type}'. Expected one of: {$knownTypes}" + ); + } + + // Resolve to correct variant class + $class = $typeMap[$type]; + + return $class::fromArray($data); + } + + /** + * Validate an open union has $type field. + * + * Open unions pass data through as-is but must have $type for future discrimination. + * + * @param array $data The union data + * @return array The validated union data + * + * @throws InvalidArgumentException If $type is missing + */ + public static function validateOpenUnion(array $data): array + { + if (! isset($data['$type'])) { + throw new InvalidArgumentException( + 'Open union data must contain a $type field for future discrimination' + ); + } + + return $data; + } + + /** + * Build a type map from variant classes using DiscriminatedUnion interface. + * + * @param array> $variants Array of variant class names + * @return array> Map of discriminator => class name + */ + protected static function buildTypeMap(array $variants): array + { + $typeMap = []; + + foreach ($variants as $class) { + // Ensure class implements DiscriminatedUnion + if (! is_subclass_of($class, DiscriminatedUnion::class)) { + throw new InvalidArgumentException( + "Variant class {$class} must implement DiscriminatedUnion interface" + ); + } + + // Get discriminator from the class + $discriminator = $class::getDiscriminator(); + $typeMap[$discriminator] = $class; + } + + return $typeMap; + } +} diff --git a/src/Validation/LexiconValidator.php b/src/Validation/LexiconValidator.php index 2a0067d..45f4c0e 100644 --- a/src/Validation/LexiconValidator.php +++ b/src/Validation/LexiconValidator.php @@ -115,8 +115,7 @@ class LexiconValidator implements LexiconValidatorContract */ public function validateByNsid(string $nsid, array $record): void { - $schema = $this->schemaLoader->load($nsid); - $document = LexiconDocument::fromArray($schema); + $document = $this->schemaLoader->load($nsid); $this->validateRecord($document, $record); } diff --git a/src/Validation/Rules/AtUri.php b/src/Validation/Rules/AtUri.php index a691279..7918b4a 100644 --- a/src/Validation/Rules/AtUri.php +++ b/src/Validation/Rules/AtUri.php @@ -49,8 +49,8 @@ class AtUri implements ValidationRule $authority = $parts[0]; // Validate authority (DID or handle) - $didRule = new Did(); - $handleRule = new Handle(); + $didRule = new Did; + $handleRule = new Handle; $isValidDid = true; $isValidHandle = true; diff --git a/src/Validation/TypeValidators/ArrayValidator.php b/src/Validation/TypeValidators/ArrayValidator.php index 635f04c..ce5bb85 100644 --- a/src/Validation/TypeValidators/ArrayValidator.php +++ b/src/Validation/TypeValidators/ArrayValidator.php @@ -75,11 +75,11 @@ class ArrayValidator $type = $definition['type'] ?? null; $validator = match ($type) { - 'string' => new StringValidator(), - 'integer' => new IntegerValidator(), - 'boolean' => new BooleanValidator(), - 'object' => new ObjectValidator(), - 'array' => new ArrayValidator(), + 'string' => new StringValidator, + 'integer' => new IntegerValidator, + 'boolean' => new BooleanValidator, + 'object' => new ObjectValidator, + 'array' => new ArrayValidator, default => null, }; diff --git a/src/Validation/TypeValidators/ObjectValidator.php b/src/Validation/TypeValidators/ObjectValidator.php index ee4fdff..57312f3 100644 --- a/src/Validation/TypeValidators/ObjectValidator.php +++ b/src/Validation/TypeValidators/ObjectValidator.php @@ -48,11 +48,11 @@ class ObjectValidator $type = $definition['type'] ?? null; $validator = match ($type) { - 'string' => new StringValidator(), - 'integer' => new IntegerValidator(), - 'boolean' => new BooleanValidator(), - 'object' => new ObjectValidator(), - 'array' => new ArrayValidator(), + 'string' => new StringValidator, + 'integer' => new IntegerValidator, + 'boolean' => new BooleanValidator, + 'object' => new ObjectValidator, + 'array' => new ArrayValidator, default => null, }; diff --git a/src/Validation/TypeValidators/UnionValidator.php b/src/Validation/TypeValidators/UnionValidator.php index 41c799c..a763382 100644 --- a/src/Validation/TypeValidators/UnionValidator.php +++ b/src/Validation/TypeValidators/UnionValidator.php @@ -3,9 +3,19 @@ namespace SocialDept\Schema\Validation\TypeValidators; use SocialDept\Schema\Exceptions\RecordValidationException; +use SocialDept\Schema\Services\UnionResolver; class UnionValidator { + /** + * Create a new UnionValidator. + */ + public function __construct( + protected ?UnionResolver $resolver = null + ) { + $this->resolver = $resolver ?? new UnionResolver; + } + /** * Validate a union value against constraints. * @@ -23,7 +33,7 @@ class UnionValidator $closed = $definition['closed'] ?? false; if ($closed) { - $this->validateDiscriminatedUnion($value, $refs, $path); + $this->validateDiscriminatedUnion($value, $refs, $path, $definition); } else { $this->validateOpenUnion($value, $refs, $path); } @@ -33,30 +43,18 @@ class UnionValidator * Validate discriminated (closed) union. * * @param array $refs + * @param array $definition */ - protected function validateDiscriminatedUnion(mixed $value, array $refs, string $path): void + protected function validateDiscriminatedUnion(mixed $value, array $refs, string $path, array $definition): void { - if (! is_array($value)) { - throw RecordValidationException::invalidType($path, 'object', gettype($value)); - } - - // Check for $type discriminator - if (! isset($value['$type'])) { - throw RecordValidationException::invalidValue( - $path, - 'Discriminated union must have $type field' - ); - } - - $type = $value['$type']; - - // Validate that $type is one of the allowed refs - if (! in_array($type, $refs, true)) { - $allowed = implode(', ', $refs); - + // Delegate validation to UnionResolver which handles all the logic + try { + $this->resolver->resolve($value, $definition); + } catch (RecordValidationException $e) { + // Re-throw with path context throw RecordValidationException::invalidValue( $path, - "Union type '{$type}' not allowed. Must be one of: {$allowed}" + $e->getMessage() ); } } diff --git a/src/Validation/Validator.php b/src/Validation/Validator.php index b3370e1..aab38cb 100644 --- a/src/Validation/Validator.php +++ b/src/Validation/Validator.php @@ -13,6 +13,7 @@ use SocialDept\Schema\Parser\TypeParser; class Validator implements LexiconValidatorContract { use Macroable; + /** * Validation mode constants. */ @@ -165,7 +166,7 @@ class Validator implements LexiconValidatorContract if ($type !== 'record' && $type !== 'object') { throw SchemaValidationException::invalidStructure( $schema->getNsid(), - ['Schema must be a record or object type, got: ' . ($type ?? 'unknown')] + ['Schema must be a record or object type, got: '.($type ?? 'unknown')] ); } diff --git a/src/helpers.php b/src/helpers.php index a9c5b70..cb03577 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -7,8 +7,7 @@ if (! function_exists('schema')) { /** * Get the SchemaManager instance or load a schema. * - * @param string|null $nsid - * @return \SocialDept\Schema\SchemaManager|array + * @return \SocialDept\Schema\SchemaManager|LexiconDocument */ function schema(?string $nsid = null) { @@ -20,23 +19,33 @@ if (! function_exists('schema')) { } } -if (! function_exists('schema_validate')) { +if (! function_exists('schema_find')) { /** - * Validate data against a schema. + * Find a schema by NSID (nullable version). */ - function schema_validate(string $nsid, array $data): bool + function schema_find(string $nsid): ?LexiconDocument { - return Schema::validate($nsid, $data); + return Schema::find($nsid); + } +} + +if (! function_exists('schema_exists')) { + /** + * Check if a schema exists. + */ + function schema_exists(string $nsid): bool + { + return Schema::exists($nsid); } } -if (! function_exists('schema_parse')) { +if (! function_exists('schema_validate')) { /** - * Parse a schema into a LexiconDocument. + * Validate data against a schema. */ - function schema_parse(string $nsid): LexiconDocument + function schema_validate(string $nsid, array $data): bool { - return Schema::parse($nsid); + return Schema::validate($nsid, $data); } } @@ -44,8 +53,8 @@ if (! function_exists('schema_generate')) { /** * Generate DTO code from a schema. */ - function schema_generate(string $nsid, array $options = []): string + function schema_generate(string $nsid, ?string $outputPath = null): string { - return Schema::generate($nsid, $options); + return Schema::generate($nsid, $outputPath); } }