Parse and validate AT Protocol Lexicons with DTO generation for Laravel
1<?php
2
3namespace SocialDept\AtpSchema\Parser;
4
5use SocialDept\AtpSchema\Exceptions\SchemaException;
6use Stringable;
7
8class 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}