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

Configure Feed

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

Implement NSID parser and validation

+301
+163
src/Parser/Nsid.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Parser; 4 + 5 + use SocialDept\Schema\Exceptions\SchemaException; 6 + use Stringable; 7 + 8 + class Nsid implements Stringable 9 + { 10 + /** 11 + * NSID pattern: authority.name (reversed domain notation) 12 + */ 13 + protected const NSID_REGEX = '/^[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/'; 14 + 15 + /** 16 + * Maximum NSID length 17 + */ 18 + protected const MAX_LENGTH = 317; 19 + 20 + /** 21 + * Minimum NSID segments 22 + */ 23 + protected const MIN_SEGMENTS = 3; 24 + 25 + /** 26 + * Create a new NSID instance. 27 + */ 28 + public function __construct( 29 + protected string $nsid 30 + ) { 31 + $this->validate(); 32 + } 33 + 34 + /** 35 + * Parse NSID from string. 36 + */ 37 + public static function parse(string $nsid): self 38 + { 39 + return new self($nsid); 40 + } 41 + 42 + /** 43 + * Validate NSID format. 44 + */ 45 + protected function validate(): void 46 + { 47 + if (empty($this->nsid)) { 48 + throw SchemaException::withContext('NSID cannot be empty', ['nsid' => $this->nsid]); 49 + } 50 + 51 + if (strlen($this->nsid) > self::MAX_LENGTH) { 52 + throw SchemaException::withContext( 53 + "NSID exceeds maximum length of " . self::MAX_LENGTH . " characters", 54 + ['nsid' => $this->nsid, 'length' => strlen($this->nsid)] 55 + ); 56 + } 57 + 58 + if (! preg_match(self::NSID_REGEX, $this->nsid)) { 59 + throw SchemaException::withContext( 60 + 'Invalid NSID format. Expected reversed domain notation (e.g., app.bsky.feed.post)', 61 + ['nsid' => $this->nsid] 62 + ); 63 + } 64 + 65 + $segments = explode('.', $this->nsid); 66 + if (count($segments) < self::MIN_SEGMENTS) { 67 + throw SchemaException::withContext( 68 + 'NSID must have at least ' . self::MIN_SEGMENTS . ' segments', 69 + ['nsid' => $this->nsid, 'segments' => count($segments)] 70 + ); 71 + } 72 + } 73 + 74 + /** 75 + * Get the authority (all segments except the last). 76 + */ 77 + public function getAuthority(): string 78 + { 79 + $segments = explode('.', $this->nsid); 80 + array_pop($segments); 81 + 82 + return implode('.', $segments); 83 + } 84 + 85 + /** 86 + * Get the name (last segment). 87 + */ 88 + public function getName(): string 89 + { 90 + $segments = explode('.', $this->nsid); 91 + 92 + return end($segments); 93 + } 94 + 95 + /** 96 + * Get all segments. 97 + * 98 + * @return array<string> 99 + */ 100 + public function getSegments(): array 101 + { 102 + return explode('.', $this->nsid); 103 + } 104 + 105 + /** 106 + * Convert to standard domain format (reverse segments). 107 + */ 108 + public function toDomain(): string 109 + { 110 + $segments = $this->getSegments(); 111 + 112 + return implode('.', array_reverse($segments)); 113 + } 114 + 115 + /** 116 + * Get the NSID string. 117 + */ 118 + public function toString(): string 119 + { 120 + return $this->nsid; 121 + } 122 + 123 + /** 124 + * Convert to string. 125 + */ 126 + public function __toString(): string 127 + { 128 + return $this->toString(); 129 + } 130 + 131 + /** 132 + * Check if NSID is valid (static method). 133 + */ 134 + public static function isValid(string $nsid): bool 135 + { 136 + try { 137 + new self($nsid); 138 + 139 + return true; 140 + } catch (SchemaException) { 141 + return false; 142 + } 143 + } 144 + 145 + /** 146 + * Check equality with another NSID. 147 + */ 148 + public function equals(self $other): bool 149 + { 150 + return $this->nsid === $other->nsid; 151 + } 152 + 153 + /** 154 + * Get the authority domain for DNS lookup. 155 + * Returns the authority segments in DNS order (reversed). 156 + */ 157 + public function getAuthorityDomain(): string 158 + { 159 + $authoritySegments = explode('.', $this->getAuthority()); 160 + 161 + return implode('.', array_reverse($authoritySegments)); 162 + } 163 + }
+138
tests/Unit/Parser/NsidTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Parser; 4 + 5 + use PHPUnit\Framework\TestCase; 6 + use SocialDept\Schema\Exceptions\SchemaException; 7 + use SocialDept\Schema\Parser\Nsid; 8 + 9 + class NsidTest extends TestCase 10 + { 11 + public function test_it_parses_valid_nsid(): void 12 + { 13 + $nsid = Nsid::parse('app.bsky.feed.post'); 14 + 15 + $this->assertInstanceOf(Nsid::class, $nsid); 16 + $this->assertSame('app.bsky.feed.post', $nsid->toString()); 17 + } 18 + 19 + public function test_it_extracts_authority(): void 20 + { 21 + $nsid = Nsid::parse('app.bsky.feed.post'); 22 + 23 + $this->assertSame('app.bsky.feed', $nsid->getAuthority()); 24 + } 25 + 26 + public function test_it_extracts_name(): void 27 + { 28 + $nsid = Nsid::parse('app.bsky.feed.post'); 29 + 30 + $this->assertSame('post', $nsid->getName()); 31 + } 32 + 33 + public function test_it_gets_segments(): void 34 + { 35 + $nsid = Nsid::parse('app.bsky.feed.post'); 36 + 37 + $this->assertSame(['app', 'bsky', 'feed', 'post'], $nsid->getSegments()); 38 + } 39 + 40 + public function test_it_converts_to_domain(): void 41 + { 42 + $nsid = Nsid::parse('app.bsky.feed.post'); 43 + 44 + $this->assertSame('post.feed.bsky.app', $nsid->toDomain()); 45 + } 46 + 47 + public function test_it_gets_authority_domain(): void 48 + { 49 + $nsid = Nsid::parse('app.bsky.feed.post'); 50 + 51 + $this->assertSame('feed.bsky.app', $nsid->getAuthorityDomain()); 52 + } 53 + 54 + public function test_it_converts_to_string(): void 55 + { 56 + $nsid = Nsid::parse('app.bsky.feed.post'); 57 + 58 + $this->assertSame('app.bsky.feed.post', (string) $nsid); 59 + } 60 + 61 + public function test_it_validates_nsid_format(): void 62 + { 63 + $this->assertTrue(Nsid::isValid('app.bsky.feed.post')); 64 + $this->assertTrue(Nsid::isValid('com.atproto.repo.getRecord')); 65 + $this->assertTrue(Nsid::isValid('com.example.my-app.action')); 66 + } 67 + 68 + public function test_it_rejects_invalid_nsids(): void 69 + { 70 + $this->assertFalse(Nsid::isValid('')); 71 + $this->assertFalse(Nsid::isValid('invalid')); 72 + $this->assertFalse(Nsid::isValid('no.dots')); 73 + $this->assertFalse(Nsid::isValid('app.bsky')); 74 + $this->assertFalse(Nsid::isValid('.invalid.nsid')); 75 + $this->assertFalse(Nsid::isValid('invalid.nsid.')); 76 + $this->assertFalse(Nsid::isValid('invalid..nsid.test')); 77 + } 78 + 79 + public function test_it_throws_on_empty_nsid(): void 80 + { 81 + $this->expectException(SchemaException::class); 82 + $this->expectExceptionMessage('NSID cannot be empty'); 83 + 84 + Nsid::parse(''); 85 + } 86 + 87 + public function test_it_throws_on_too_few_segments(): void 88 + { 89 + $this->expectException(SchemaException::class); 90 + $this->expectExceptionMessage('NSID must have at least 3 segments'); 91 + 92 + Nsid::parse('app.bsky'); 93 + } 94 + 95 + public function test_it_throws_on_invalid_format(): void 96 + { 97 + $this->expectException(SchemaException::class); 98 + $this->expectExceptionMessage('Invalid NSID format'); 99 + 100 + Nsid::parse('invalid-nsid'); 101 + } 102 + 103 + public function test_it_checks_equality(): void 104 + { 105 + $nsid1 = Nsid::parse('app.bsky.feed.post'); 106 + $nsid2 = Nsid::parse('app.bsky.feed.post'); 107 + $nsid3 = Nsid::parse('app.bsky.feed.like'); 108 + 109 + $this->assertTrue($nsid1->equals($nsid2)); 110 + $this->assertFalse($nsid1->equals($nsid3)); 111 + } 112 + 113 + public function test_it_handles_long_nsids(): void 114 + { 115 + $nsid = Nsid::parse('com.example.very.long.namespace.with.many.segments.action'); 116 + 117 + $this->assertSame('action', $nsid->getName()); 118 + $this->assertSame('com.example.very.long.namespace.with.many.segments', $nsid->getAuthority()); 119 + } 120 + 121 + public function test_it_handles_hyphens(): void 122 + { 123 + $nsid = Nsid::parse('com.my-app.feed.get-posts'); 124 + 125 + $this->assertSame('get-posts', $nsid->getName()); 126 + $this->assertSame('com.my-app.feed', $nsid->getAuthority()); 127 + } 128 + 129 + public function test_it_rejects_nsid_exceeding_max_length(): void 130 + { 131 + $this->expectException(SchemaException::class); 132 + $this->expectExceptionMessage('NSID exceeds maximum length'); 133 + 134 + // Create a string longer than 317 characters 135 + $longNsid = str_repeat('a.', 160) . 'test'; 136 + Nsid::parse($longNsid); 137 + } 138 + }