Laravel AT Protocol Client (alpha & unstable)
1# Client Extensions 2 3AtpClient provides an extension system that allows you to add custom functionality. You can register domain clients (like `$client->myDomain`) or request clients on existing domains (like `$client->bsky->myFeature`). 4 5## Quick Reference 6 7### Available Methods 8 9| Method | Description | 10|--------|-------------| 11| `AtpClient::extend($name, $callback)` | Register a domain client extension | 12| `AtpClient::extendDomain($domain, $name, $callback)` | Register a request client on an existing domain | 13| `AtpClient::hasExtension($name)` | Check if a domain extension is registered | 14| `AtpClient::hasDomainExtension($domain, $name)` | Check if a request client extension is registered | 15| `AtpClient::flushExtensions()` | Clear all extensions (useful for testing) | 16 17### Extension Types 18 19| Type | Access Pattern | Use Case | 20|------|----------------|----------| 21| Domain Client | `$client->myDomain` | Group related functionality under a namespace | 22| Request Client | `$client->bsky->myFeature` | Add methods to an existing domain | 23 24### Generator Commands 25 26Quickly scaffold extension classes using artisan commands: 27 28```bash 29# Create a domain client extension 30php artisan make:atp-client AnalyticsClient 31 32# Create a request client extension for an existing domain 33php artisan make:atp-request MetricsClient --domain=bsky 34``` 35 36The generated files are placed in configurable directories. You can customize these paths in `config/client.php`: 37 38```php 39'generators' => [ 40 'client_path' => 'app/Services/Clients', 41 'request_path' => 'app/Services/Clients/Requests', 42], 43``` 44 45## Understanding Extensions 46 47Extensions follow a lazy-loading pattern. When you register an extension, the callback is stored but not executed. The extension is only instantiated when first accessed: 48 49```php 50// Registration - callback stored, not executed 51AtpClient::extend('analytics', fn($atp) => new AnalyticsClient($atp)); 52 53// First access - callback executed, instance cached 54$client->analytics->trackEvent('login'); 55 56// Subsequent access - cached instance returned 57$client->analytics->trackEvent('post_created'); 58``` 59 60This ensures extensions don't add overhead unless they're actually used. 61 62## Creating a Domain Client 63 64A domain client adds a new namespace to AtpClient, accessible as a property. 65 66### Step 1: Create Your Client Class 67 68```php 69<?php 70 71namespace App\Atp; 72 73use SocialDept\AtpClient\AtpClient; 74 75class AnalyticsClient 76{ 77 protected AtpClient $atp; 78 79 public function __construct(AtpClient $parent) 80 { 81 $this->atp = $parent; 82 } 83 84 public function trackEvent(string $event, array $properties = []): void 85 { 86 // Your analytics logic here 87 // You have full access to the authenticated client via $this->atp 88 } 89 90 public function getEngagementStats(string $actor): array 91 { 92 $profile = $this->atp->bsky->actor->getProfile($actor); 93 94 return [ 95 'followers' => $profile->followersCount, 96 'following' => $profile->followsCount, 97 'posts' => $profile->postsCount, 98 ]; 99 } 100} 101``` 102 103### Step 2: Register the Extension 104 105In your `AppServiceProvider`: 106 107```php 108<?php 109 110namespace App\Providers; 111 112use App\Atp\AnalyticsClient; 113use Illuminate\Support\ServiceProvider; 114use SocialDept\AtpClient\AtpClient; 115 116class AppServiceProvider extends ServiceProvider 117{ 118 public function boot(): void 119 { 120 AtpClient::extend('analytics', fn(AtpClient $atp) => new AnalyticsClient($atp)); 121 } 122} 123``` 124 125### Step 3: Use Your Extension 126 127```php 128use SocialDept\AtpClient\Facades\Atp; 129 130$client = Atp::as('user.bsky.social'); 131 132$client->analytics->trackEvent('page_view', ['page' => '/feed']); 133 134$stats = $client->analytics->getEngagementStats('someone.bsky.social'); 135``` 136 137## Creating a Request Client 138 139A request client extends an existing domain (like `bsky`, `atproto`, `chat`, or `ozone`). This is useful when you want to add methods that logically belong alongside the built-in functionality. 140 141### Step 1: Create Your Request Client Class 142 143Extend the base `Request` class to get access to the parent AtpClient: 144 145```php 146<?php 147 148namespace App\Atp; 149 150use SocialDept\AtpClient\Client\Requests\Request; 151 152class BskyMetricsClient extends Request 153{ 154 public function getPostEngagement(string $uri): array 155 { 156 $thread = $this->atp->bsky->feed->getPostThread($uri); 157 $post = $thread->thread['post'] ?? null; 158 159 if (! $post) { 160 return []; 161 } 162 163 return [ 164 'likes' => $post['likeCount'] ?? 0, 165 'reposts' => $post['repostCount'] ?? 0, 166 'replies' => $post['replyCount'] ?? 0, 167 'quotes' => $post['quoteCount'] ?? 0, 168 ]; 169 } 170 171 public function getAuthorMetrics(string $actor): array 172 { 173 $feed = $this->atp->bsky->feed->getAuthorFeed($actor, limit: 100); 174 $posts = $feed->feed; 175 176 $totalLikes = 0; 177 $totalReposts = 0; 178 179 foreach ($posts as $item) { 180 $totalLikes += $item['post']['likeCount'] ?? 0; 181 $totalReposts += $item['post']['repostCount'] ?? 0; 182 } 183 184 return [ 185 'posts_analyzed' => count($posts), 186 'total_likes' => $totalLikes, 187 'total_reposts' => $totalReposts, 188 'avg_likes' => count($posts) > 0 ? $totalLikes / count($posts) : 0, 189 ]; 190 } 191} 192``` 193 194### Step 2: Register the Extension 195 196```php 197use App\Atp\BskyMetricsClient; 198use SocialDept\AtpClient\AtpClient; 199 200public function boot(): void 201{ 202 AtpClient::extendDomain('bsky', 'metrics', fn($bsky) => new BskyMetricsClient($bsky)); 203} 204``` 205 206The callback receives the domain client instance (`BskyClient` in this case), which is passed to your request client's constructor. 207 208### Step 3: Use Your Extension 209 210```php 211$client = Atp::as('user.bsky.social'); 212 213$engagement = $client->bsky->metrics->getPostEngagement('at://did:plc:.../app.bsky.feed.post/...'); 214 215$authorMetrics = $client->bsky->metrics->getAuthorMetrics('someone.bsky.social'); 216``` 217 218## Public vs Authenticated Mode 219 220The `AtpClient` class works in both public and authenticated modes. Both `Atp::public()` and `Atp::as()` return the same `AtpClient` class: 221 222```php 223// Public mode - no authentication 224$publicClient = Atp::public('https://public.api.bsky.app'); 225$publicClient->bsky->actor->getProfile('someone.bsky.social'); 226 227// Authenticated mode - with session 228$authClient = Atp::as('did:plc:xxx'); 229$authClient->bsky->actor->getProfile('someone.bsky.social'); 230``` 231 232Extensions registered on `AtpClient` work in both modes. The underlying HTTP layer automatically handles authentication based on whether a session is present. 233 234## Registering Multiple Extensions 235 236You can register multiple extensions in your service provider: 237 238```php 239public function boot(): void 240{ 241 // Domain clients 242 AtpClient::extend('analytics', fn($atp) => new AnalyticsClient($atp)); 243 AtpClient::extend('moderation', fn($atp) => new ModerationClient($atp)); 244 245 // Request clients 246 AtpClient::extendDomain('bsky', 'metrics', fn($bsky) => new BskyMetricsClient($bsky)); 247 AtpClient::extendDomain('bsky', 'lists', fn($bsky) => new BskyListsClient($bsky)); 248 AtpClient::extendDomain('atproto', 'backup', fn($atproto) => new RepoBackupClient($atproto)); 249} 250``` 251 252## Conditional Registration 253 254Register extensions conditionally based on environment or configuration: 255 256```php 257public function boot(): void 258{ 259 if (config('services.analytics.enabled')) { 260 AtpClient::extend('analytics', fn($atp) => new AnalyticsClient($atp)); 261 } 262 263 if (app()->environment('local')) { 264 AtpClient::extend('debug', fn($atp) => new DebugClient($atp)); 265 } 266} 267``` 268 269## Testing Extensions 270 271### Test Isolation 272 273Use `flushExtensions()` to clear registered extensions between tests: 274 275```php 276use SocialDept\AtpClient\AtpClient; 277use PHPUnit\Framework\TestCase; 278 279class MyExtensionTest extends TestCase 280{ 281 protected function setUp(): void 282 { 283 parent::setUp(); 284 AtpClient::flushExtensions(); 285 } 286 287 protected function tearDown(): void 288 { 289 AtpClient::flushExtensions(); 290 parent::tearDown(); 291 } 292 293 public function test_extension_is_registered(): void 294 { 295 AtpClient::extend('test', fn($atp) => new TestClient($atp)); 296 297 $this->assertTrue(AtpClient::hasExtension('test')); 298 } 299} 300``` 301 302### Checking Registration 303 304Use the static methods to verify extensions are registered: 305 306```php 307// Check domain extension 308if (AtpClient::hasExtension('analytics')) { 309 $client->analytics->trackEvent('test'); 310} 311 312// Check request client extension 313if (AtpClient::hasDomainExtension('bsky', 'metrics')) { 314 $metrics = $client->bsky->metrics->getAuthorMetrics($actor); 315} 316``` 317 318## Advanced Patterns 319 320### Accessing the HTTP Client 321 322Domain client extensions can access the underlying HTTP client for custom API calls: 323 324```php 325class CustomApiClient 326{ 327 protected AtpClient $atp; 328 329 public function __construct(AtpClient $parent) 330 { 331 $this->atp = $parent; 332 } 333 334 public function customEndpoint(array $params): array 335 { 336 // Access the authenticated HTTP client 337 $response = $this->atp->client->get('com.example.customEndpoint', $params); 338 339 return $response->json(); 340 } 341 342 public function customProcedure(array $data): array 343 { 344 $response = $this->atp->client->post('com.example.customProcedure', $data); 345 346 return $response->json(); 347 } 348} 349``` 350 351### Using Typed Responses 352 353Return typed response objects for better IDE support: 354 355```php 356use SocialDept\AtpClient\Data\Responses\Response; 357 358class MetricsResponse extends Response 359{ 360 public function __construct( 361 public readonly int $likes, 362 public readonly int $reposts, 363 public readonly int $replies, 364 ) {} 365 366 public static function fromArray(array $data): static 367 { 368 return new static( 369 likes: $data['likes'] ?? 0, 370 reposts: $data['reposts'] ?? 0, 371 replies: $data['replies'] ?? 0, 372 ); 373 } 374} 375 376class BskyMetricsClient extends Request 377{ 378 public function getPostMetrics(string $uri): MetricsResponse 379 { 380 $thread = $this->atp->bsky->feed->getPostThread($uri); 381 $post = $thread->thread['post'] ?? []; 382 383 return MetricsResponse::fromArray([ 384 'likes' => $post['likeCount'] ?? 0, 385 'reposts' => $post['repostCount'] ?? 0, 386 'replies' => $post['replyCount'] ?? 0, 387 ]); 388 } 389} 390``` 391 392### Composing Multiple Clients 393 394Extensions can use other extensions or built-in clients: 395 396```php 397class DashboardClient 398{ 399 protected AtpClient $atp; 400 401 public function __construct(AtpClient $parent) 402 { 403 $this->atp = $parent; 404 } 405 406 public function getOverview(string $actor): array 407 { 408 // Use built-in clients 409 $profile = $this->atp->bsky->actor->getProfile($actor); 410 $feed = $this->atp->bsky->feed->getAuthorFeed($actor, limit: 10); 411 412 // Use other extensions (if registered) 413 $metrics = AtpClient::hasDomainExtension('bsky', 'metrics') 414 ? $this->atp->bsky->metrics->getAuthorMetrics($actor) 415 : null; 416 417 return [ 418 'profile' => $profile, 419 'recent_posts' => $feed->feed, 420 'metrics' => $metrics, 421 ]; 422 } 423} 424``` 425 426### Documenting Scope Requirements 427 428Use the `#[ScopedEndpoint]` and `#[PublicEndpoint]` attributes to document the authentication requirements of your extension methods: 429 430```php 431use SocialDept\AtpClient\Attributes\PublicEndpoint; 432use SocialDept\AtpClient\Attributes\ScopedEndpoint; 433use SocialDept\AtpClient\Client\Requests\Request; 434use SocialDept\AtpClient\Enums\Scope; 435 436class BskyMetricsClient extends Request 437{ 438 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'rpc:app.bsky.feed.getTimeline')] 439 public function getTimelineMetrics(): array 440 { 441 $timeline = $this->atp->bsky->feed->getTimeline(); 442 // Process and return metrics... 443 } 444 445 #[PublicEndpoint] 446 public function getPublicPostMetrics(string $uri): array 447 { 448 $thread = $this->atp->bsky->feed->getPostThread($uri); 449 // Process and return metrics... 450 } 451} 452``` 453 454> **Note:** These attributes currently serve as documentation only. Runtime scope enforcement will be implemented in a future release. Using them correctly now ensures forward compatibility. 455 456Methods with `#[ScopedEndpoint]` indicate they require authentication, while methods with `#[PublicEndpoint]` work without authentication. See [scopes.md](scopes.md) for full documentation on scope handling. 457 458## Available Domains 459 460You can extend these built-in domains with `extendDomain()`: 461 462| Domain | Description | 463|--------|-------------| 464| `bsky` | Bluesky-specific operations (app.bsky.*) | 465| `atproto` | AT Protocol core operations (com.atproto.*) | 466| `chat` | Direct messaging operations (chat.bsky.*) | 467| `ozone` | Moderation tools (tools.ozone.*) |