Laravel AT Protocol Client (alpha & unstable)
3
fork

Configure Feed

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

Implement HTTP client layer and namespace-based client classes

+874
+2
src/AtpClientServiceProvider.php
··· 6 6 use Illuminate\Support\ServiceProvider; 7 7 use SocialDept\AtpClient\Auth\ClientMetadataManager; 8 8 use SocialDept\AtpClient\Auth\DPoPKeyManager; 9 + use SocialDept\AtpClient\Auth\DPoPNonceManager; 9 10 use SocialDept\AtpClient\Auth\OAuthEngine; 10 11 use SocialDept\AtpClient\Auth\TokenRefresher; 11 12 use SocialDept\AtpClient\Client\AtpClient; ··· 40 41 // Register core services 41 42 $this->app->singleton(ClientMetadataManager::class); 42 43 $this->app->singleton(DPoPKeyManager::class); 44 + $this->app->singleton(DPoPNonceManager::class); 43 45 $this->app->singleton(TokenRefresher::class); 44 46 $this->app->singleton(SessionManager::class, function ($app) { 45 47 return new SessionManager(
+64
src/Auth/DPoPNonceManager.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Auth; 4 + 5 + use Illuminate\Support\Facades\Cache; 6 + 7 + class DPoPNonceManager 8 + { 9 + /** 10 + * Get DPoP nonce for PDS endpoint 11 + */ 12 + public function getNonce(string $pdsEndpoint): string 13 + { 14 + $cacheKey = 'dpop_nonce:'.md5($pdsEndpoint); 15 + 16 + // Return cached nonce if available 17 + if ($nonce = Cache::get($cacheKey)) { 18 + return $nonce; 19 + } 20 + 21 + // Fetch new nonce from server 22 + $nonce = $this->fetchNonce($pdsEndpoint); 23 + 24 + // Cache for 5 minutes 25 + Cache::put($cacheKey, $nonce, now()->addMinutes(5)); 26 + 27 + return $nonce; 28 + } 29 + 30 + /** 31 + * Store nonce returned from server response 32 + */ 33 + public function storeNonce(string $pdsEndpoint, string $nonce): void 34 + { 35 + $cacheKey = 'dpop_nonce:'.md5($pdsEndpoint); 36 + Cache::put($cacheKey, $nonce, now()->addMinutes(5)); 37 + } 38 + 39 + /** 40 + * Clear cached nonce (e.g., after nonce error) 41 + */ 42 + public function clearNonce(string $pdsEndpoint): void 43 + { 44 + $cacheKey = 'dpop_nonce:'.md5($pdsEndpoint); 45 + Cache::forget($cacheKey); 46 + } 47 + 48 + /** 49 + * Fetch nonce from PDS server 50 + */ 51 + protected function fetchNonce(string $pdsEndpoint): string 52 + { 53 + // Make a HEAD request to get initial nonce 54 + // The server returns nonce in DPoP-Nonce header 55 + try { 56 + $response = app('http')->head($pdsEndpoint.'/xrpc/_health'); 57 + 58 + return $response->header('DPoP-Nonce') ?? 'fallback-nonce-'.time(); 59 + } catch (\Exception $e) { 60 + // Fallback if health endpoint fails 61 + return 'fallback-nonce-'.time(); 62 + } 63 + } 64 + }
+64
src/Client/AtpClient.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Client; 4 + 5 + use Illuminate\Http\Client\Factory; 6 + use SocialDept\AtpClient\Auth\DPoPNonceManager; 7 + use SocialDept\AtpClient\Session\SessionManager; 8 + 9 + class AtpClient 10 + { 11 + public function __construct( 12 + protected SessionManager $sessions, 13 + protected Factory $http, 14 + protected string $identifier, 15 + ) {} 16 + 17 + /** 18 + * Get Bluesky client (app.bsky.*) 19 + */ 20 + public function bsky(): BskyClient 21 + { 22 + return new BskyClient($this->sessions, $this->http, $this->identifier); 23 + } 24 + 25 + /** 26 + * Get AT Protocol client (com.atproto.*) 27 + */ 28 + public function atproto(): AtprotoClient 29 + { 30 + return new AtprotoClient($this->sessions, $this->http, $this->identifier); 31 + } 32 + 33 + /** 34 + * Get Chat client (chat.bsky.*) 35 + */ 36 + public function chat(): ChatClient 37 + { 38 + return new ChatClient($this->sessions, $this->http, $this->identifier); 39 + } 40 + 41 + /** 42 + * Get Ozone client (tools.ozone.*) 43 + */ 44 + public function ozone(): OzoneClient 45 + { 46 + return new OzoneClient($this->sessions, $this->http, $this->identifier); 47 + } 48 + 49 + /** 50 + * Get the current session identifier 51 + */ 52 + public function getIdentifier(): string 53 + { 54 + return $this->identifier; 55 + } 56 + 57 + /** 58 + * Get the session manager 59 + */ 60 + public function getSessionManager(): SessionManager 61 + { 62 + return $this->sessions; 63 + } 64 + }
+153
src/Client/AtprotoClient.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Client; 4 + 5 + use Illuminate\Http\Client\Factory; 6 + use SocialDept\AtpClient\Auth\DPoPNonceManager; 7 + use SocialDept\AtpClient\Http\HasHttp; 8 + use SocialDept\AtpClient\Http\Response; 9 + use SocialDept\AtpClient\Session\SessionManager; 10 + 11 + class AtprotoClient 12 + { 13 + use HasHttp; 14 + 15 + public function __construct( 16 + SessionManager $sessions, 17 + Factory $http, 18 + string $identifier, 19 + ) { 20 + $this->sessions = $sessions; 21 + $this->http = $http; 22 + $this->identifier = $identifier; 23 + $this->nonceManager = app(DPoPNonceManager::class); 24 + } 25 + 26 + /** 27 + * Create a record 28 + */ 29 + public function createRecord(string $repo, string $collection, array $record, ?string $rkey = null, bool $validate = true, ?string $swapCommit = null): Response 30 + { 31 + return $this->post('com.atproto.repo.createRecord', array_filter(compact('repo', 'collection', 'record', 'rkey', 'validate', 'swapCommit'), fn ($v) => ! is_null($v))); 32 + } 33 + 34 + /** 35 + * Delete a record 36 + */ 37 + public function deleteRecord(string $repo, string $collection, string $rkey, ?string $swapRecord = null, ?string $swapCommit = null): Response 38 + { 39 + return $this->post('com.atproto.repo.deleteRecord', array_filter(compact('repo', 'collection', 'rkey', 'swapRecord', 'swapCommit'), fn ($v) => ! is_null($v))); 40 + } 41 + 42 + /** 43 + * Put (upsert) a record 44 + */ 45 + public function putRecord(string $repo, string $collection, string $rkey, array $record, bool $validate = true, ?string $swapRecord = null, ?string $swapCommit = null): Response 46 + { 47 + return $this->post('com.atproto.repo.putRecord', array_filter(compact('repo', 'collection', 'rkey', 'record', 'validate', 'swapRecord', 'swapCommit'), fn ($v) => ! is_null($v))); 48 + } 49 + 50 + /** 51 + * Get a record 52 + */ 53 + public function getRecord(string $repo, string $collection, string $rkey, ?string $cid = null): Response 54 + { 55 + return $this->get('com.atproto.repo.getRecord', compact('repo', 'collection', 'rkey', 'cid')); 56 + } 57 + 58 + /** 59 + * List records in a collection 60 + */ 61 + public function listRecords(string $repo, string $collection, int $limit = 50, ?string $cursor = null, bool $reverse = false): Response 62 + { 63 + return $this->get('com.atproto.repo.listRecords', compact('repo', 'collection', 'limit', 'cursor', 'reverse')); 64 + } 65 + 66 + /** 67 + * Upload a blob 68 + */ 69 + public function uploadBlob(string $data, string $mimeType): Response 70 + { 71 + return $this->post('com.atproto.repo.uploadBlob', ['blob' => $data, 'mimeType' => $mimeType]); 72 + } 73 + 74 + /** 75 + * Describe the repository 76 + */ 77 + public function describeRepo(string $repo): Response 78 + { 79 + return $this->get('com.atproto.repo.describeRepo', compact('repo')); 80 + } 81 + 82 + /** 83 + * Get current session 84 + */ 85 + public function getSession(): Response 86 + { 87 + return $this->get('com.atproto.server.getSession'); 88 + } 89 + 90 + /** 91 + * Describe server 92 + */ 93 + public function describeServer(): Response 94 + { 95 + return $this->get('com.atproto.server.describeServer'); 96 + } 97 + 98 + /** 99 + * Resolve handle to DID 100 + */ 101 + public function resolveHandle(string $handle): Response 102 + { 103 + return $this->get('com.atproto.identity.resolveHandle', compact('handle')); 104 + } 105 + 106 + /** 107 + * Update handle 108 + */ 109 + public function updateHandle(string $handle): Response 110 + { 111 + return $this->post('com.atproto.identity.updateHandle', compact('handle')); 112 + } 113 + 114 + /** 115 + * Get blob from sync 116 + */ 117 + public function getBlob(string $did, string $cid): Response 118 + { 119 + return $this->get('com.atproto.sync.getBlob', compact('did', 'cid')); 120 + } 121 + 122 + /** 123 + * Get checkout from sync 124 + */ 125 + public function getCheckout(string $did): Response 126 + { 127 + return $this->get('com.atproto.sync.getCheckout', compact('did')); 128 + } 129 + 130 + /** 131 + * Get commit path from sync 132 + */ 133 + public function getCommitPath(string $did, ?string $latest = null, ?string $earliest = null): Response 134 + { 135 + return $this->get('com.atproto.sync.getCommitPath', compact('did', 'latest', 'earliest')); 136 + } 137 + 138 + /** 139 + * Get repo from sync 140 + */ 141 + public function getRepo(string $did, ?string $since = null): Response 142 + { 143 + return $this->get('com.atproto.sync.getRepo', compact('did', 'since')); 144 + } 145 + 146 + /** 147 + * List repos from sync 148 + */ 149 + public function listRepos(int $limit = 500, ?string $cursor = null): Response 150 + { 151 + return $this->get('com.atproto.sync.listRepos', compact('limit', 'cursor')); 152 + } 153 + }
+81
src/Client/BskyClient.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Client; 4 + 5 + use Illuminate\Http\Client\Factory; 6 + use SocialDept\AtpClient\Auth\DPoPNonceManager; 7 + use SocialDept\AtpClient\Http\HasHttp; 8 + use SocialDept\AtpClient\Http\Response; 9 + use SocialDept\AtpClient\Session\SessionManager; 10 + 11 + class BskyClient 12 + { 13 + use HasHttp; 14 + 15 + public function __construct( 16 + SessionManager $sessions, 17 + Factory $http, 18 + string $identifier, 19 + ) { 20 + $this->sessions = $sessions; 21 + $this->http = $http; 22 + $this->identifier = $identifier; 23 + $this->nonceManager = app(DPoPNonceManager::class); 24 + } 25 + 26 + /** 27 + * Get timeline feed 28 + */ 29 + public function getTimeline(int $limit = 50, ?string $cursor = null): Response 30 + { 31 + return $this->get('app.bsky.feed.getTimeline', compact('limit', 'cursor')); 32 + } 33 + 34 + /** 35 + * Get author feed 36 + */ 37 + public function getAuthorFeed(string $actor, int $limit = 50, ?string $cursor = null): Response 38 + { 39 + return $this->get('app.bsky.feed.getAuthorFeed', compact('actor', 'limit', 'cursor')); 40 + } 41 + 42 + /** 43 + * Get post thread 44 + */ 45 + public function getPostThread(string $uri, int $depth = 6): Response 46 + { 47 + return $this->get('app.bsky.feed.getPostThread', compact('uri', 'depth')); 48 + } 49 + 50 + /** 51 + * Get actor profile 52 + */ 53 + public function getProfile(string $actor): Response 54 + { 55 + return $this->get('app.bsky.actor.getProfile', compact('actor')); 56 + } 57 + 58 + /** 59 + * Search posts 60 + */ 61 + public function searchPosts(string $q, int $limit = 25, ?string $cursor = null): Response 62 + { 63 + return $this->get('app.bsky.feed.searchPosts', compact('q', 'limit', 'cursor')); 64 + } 65 + 66 + /** 67 + * Get likes for a post 68 + */ 69 + public function getLikes(string $uri, int $limit = 50, ?string $cursor = null): Response 70 + { 71 + return $this->get('app.bsky.feed.getLikes', compact('uri', 'limit', 'cursor')); 72 + } 73 + 74 + /** 75 + * Get reposts for a post 76 + */ 77 + public function getRepostedBy(string $uri, int $limit = 50, ?string $cursor = null): Response 78 + { 79 + return $this->get('app.bsky.feed.getRepostedBy', compact('uri', 'limit', 'cursor')); 80 + } 81 + }
+145
src/Client/ChatClient.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Client; 4 + 5 + use Illuminate\Http\Client\Factory; 6 + use SocialDept\AtpClient\Auth\DPoPNonceManager; 7 + use SocialDept\AtpClient\Http\HasHttp; 8 + use SocialDept\AtpClient\Http\Response; 9 + use SocialDept\AtpClient\Session\SessionManager; 10 + 11 + class ChatClient 12 + { 13 + use HasHttp; 14 + 15 + public function __construct( 16 + SessionManager $sessions, 17 + Factory $http, 18 + string $identifier, 19 + ) { 20 + $this->sessions = $sessions; 21 + $this->http = $http; 22 + $this->identifier = $identifier; 23 + $this->nonceManager = app(DPoPNonceManager::class); 24 + } 25 + 26 + /** 27 + * Get conversation 28 + */ 29 + public function getConvo(string $convoId): Response 30 + { 31 + return $this->get('chat.bsky.convo.getConvo', compact('convoId')); 32 + } 33 + 34 + /** 35 + * Get conversation for members 36 + */ 37 + public function getConvoForMembers(array $members): Response 38 + { 39 + return $this->get('chat.bsky.convo.getConvoForMembers', compact('members')); 40 + } 41 + 42 + /** 43 + * List conversations 44 + */ 45 + public function listConvos(int $limit = 50, ?string $cursor = null): Response 46 + { 47 + return $this->get('chat.bsky.convo.listConvos', compact('limit', 'cursor')); 48 + } 49 + 50 + /** 51 + * Get messages 52 + */ 53 + public function getMessages(string $convoId, int $limit = 50, ?string $cursor = null): Response 54 + { 55 + return $this->get('chat.bsky.convo.getMessages', compact('convoId', 'limit', 'cursor')); 56 + } 57 + 58 + /** 59 + * Send message 60 + */ 61 + public function sendMessage(string $convoId, array $message): Response 62 + { 63 + return $this->post('chat.bsky.convo.sendMessage', compact('convoId', 'message')); 64 + } 65 + 66 + /** 67 + * Send message batch 68 + */ 69 + public function sendMessageBatch(array $items): Response 70 + { 71 + return $this->post('chat.bsky.convo.sendMessageBatch', compact('items')); 72 + } 73 + 74 + /** 75 + * Delete message 76 + */ 77 + public function deleteMessageForSelf(string $convoId, string $messageId): Response 78 + { 79 + return $this->post('chat.bsky.convo.deleteMessageForSelf', compact('convoId', 'messageId')); 80 + } 81 + 82 + /** 83 + * Update read status 84 + */ 85 + public function updateRead(string $convoId, ?string $messageId = null): Response 86 + { 87 + return $this->post('chat.bsky.convo.updateRead', compact('convoId', 'messageId')); 88 + } 89 + 90 + /** 91 + * Mute conversation 92 + */ 93 + public function muteConvo(string $convoId): Response 94 + { 95 + return $this->post('chat.bsky.convo.muteConvo', compact('convoId')); 96 + } 97 + 98 + /** 99 + * Unmute conversation 100 + */ 101 + public function unmuteConvo(string $convoId): Response 102 + { 103 + return $this->post('chat.bsky.convo.unmuteConvo', compact('convoId')); 104 + } 105 + 106 + /** 107 + * Leave conversation 108 + */ 109 + public function leaveConvo(string $convoId): Response 110 + { 111 + return $this->post('chat.bsky.convo.leaveConvo', compact('convoId')); 112 + } 113 + 114 + /** 115 + * Get log 116 + */ 117 + public function getLog(?string $cursor = null): Response 118 + { 119 + return $this->get('chat.bsky.convo.getLog', compact('cursor')); 120 + } 121 + 122 + /** 123 + * Get actor metadata 124 + */ 125 + public function getActorMetadata(): Response 126 + { 127 + return $this->get('chat.bsky.actor.getActorMetadata'); 128 + } 129 + 130 + /** 131 + * Export account data 132 + */ 133 + public function exportAccountData(): Response 134 + { 135 + return $this->get('chat.bsky.actor.exportAccountData'); 136 + } 137 + 138 + /** 139 + * Delete account 140 + */ 141 + public function deleteAccount(): Response 142 + { 143 + return $this->post('chat.bsky.actor.deleteAccount'); 144 + } 145 + }
+145
src/Client/OzoneClient.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Client; 4 + 5 + use Illuminate\Http\Client\Factory; 6 + use SocialDept\AtpClient\Auth\DPoPNonceManager; 7 + use SocialDept\AtpClient\Http\HasHttp; 8 + use SocialDept\AtpClient\Http\Response; 9 + use SocialDept\AtpClient\Session\SessionManager; 10 + 11 + class OzoneClient 12 + { 13 + use HasHttp; 14 + 15 + public function __construct( 16 + SessionManager $sessions, 17 + Factory $http, 18 + string $identifier, 19 + ) { 20 + $this->sessions = $sessions; 21 + $this->http = $http; 22 + $this->identifier = $identifier; 23 + $this->nonceManager = app(DPoPNonceManager::class); 24 + } 25 + 26 + /** 27 + * Get moderation event 28 + */ 29 + public function getModerationEvent(int $id): Response 30 + { 31 + return $this->get('tools.ozone.moderation.getEvent', compact('id')); 32 + } 33 + 34 + /** 35 + * Get moderation events 36 + */ 37 + public function getModerationEvents(?string $subject = null, ?array $types = null, ?string $createdBy = null, int $limit = 50, ?string $cursor = null): Response 38 + { 39 + return $this->get('tools.ozone.moderation.getEvents', array_filter(compact('subject', 'types', 'createdBy', 'limit', 'cursor'), fn ($v) => ! is_null($v))); 40 + } 41 + 42 + /** 43 + * Get record 44 + */ 45 + public function getRecord(string $uri, ?string $cid = null): Response 46 + { 47 + return $this->get('tools.ozone.moderation.getRecord', compact('uri', 'cid')); 48 + } 49 + 50 + /** 51 + * Get repo 52 + */ 53 + public function getRepo(string $did): Response 54 + { 55 + return $this->get('tools.ozone.moderation.getRepo', compact('did')); 56 + } 57 + 58 + /** 59 + * Query events 60 + */ 61 + public function queryEvents(?array $types = null, ?string $createdBy = null, ?string $subject = null, int $limit = 50, ?string $cursor = null, bool $sortDirection = false): Response 62 + { 63 + return $this->get('tools.ozone.moderation.queryEvents', array_filter(compact('types', 'createdBy', 'subject', 'limit', 'cursor', 'sortDirection'), fn ($v) => ! is_null($v))); 64 + } 65 + 66 + /** 67 + * Query statuses 68 + */ 69 + public function queryStatuses(?string $subject = null, ?array $tags = null, ?string $excludeTags = null, int $limit = 50, ?string $cursor = null): Response 70 + { 71 + return $this->get('tools.ozone.moderation.queryStatuses', array_filter(compact('subject', 'tags', 'excludeTags', 'limit', 'cursor'), fn ($v) => ! is_null($v))); 72 + } 73 + 74 + /** 75 + * Search repos 76 + */ 77 + public function searchRepos(?string $term = null, ?string $invitedBy = null, int $limit = 50, ?string $cursor = null): Response 78 + { 79 + return $this->get('tools.ozone.moderation.searchRepos', array_filter(compact('term', 'invitedBy', 'limit', 'cursor'), fn ($v) => ! is_null($v))); 80 + } 81 + 82 + /** 83 + * Emit moderation event 84 + */ 85 + public function emitEvent(array $event, string $subject, array $subjectBlobCids = [], ?string $createdBy = null): Response 86 + { 87 + return $this->post('tools.ozone.moderation.emitEvent', compact('event', 'subject', 'subjectBlobCids', 'createdBy')); 88 + } 89 + 90 + /** 91 + * Get blob 92 + */ 93 + public function getBlob(string $did, string $cid): Response 94 + { 95 + return $this->get('tools.ozone.server.getBlob', compact('did', 'cid')); 96 + } 97 + 98 + /** 99 + * Get config 100 + */ 101 + public function getConfig(): Response 102 + { 103 + return $this->get('tools.ozone.server.getConfig'); 104 + } 105 + 106 + /** 107 + * Get team member 108 + */ 109 + public function getTeamMember(string $did): Response 110 + { 111 + return $this->get('tools.ozone.team.getMember', compact('did')); 112 + } 113 + 114 + /** 115 + * List team members 116 + */ 117 + public function listTeamMembers(int $limit = 50, ?string $cursor = null): Response 118 + { 119 + return $this->get('tools.ozone.team.listMembers', compact('limit', 'cursor')); 120 + } 121 + 122 + /** 123 + * Add team member 124 + */ 125 + public function addTeamMember(string $did, string $role): Response 126 + { 127 + return $this->post('tools.ozone.team.addMember', compact('did', 'role')); 128 + } 129 + 130 + /** 131 + * Update team member 132 + */ 133 + public function updateTeamMember(string $did, ?bool $disabled = null, ?string $role = null): Response 134 + { 135 + return $this->post('tools.ozone.team.updateMember', array_filter(compact('did', 'disabled', 'role'), fn ($v) => ! is_null($v))); 136 + } 137 + 138 + /** 139 + * Delete team member 140 + */ 141 + public function deleteTeamMember(string $did): Response 142 + { 143 + return $this->post('tools.ozone.team.deleteMember', compact('did')); 144 + } 145 + }
+23
src/Exceptions/ValidationException.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Exceptions; 4 + 5 + class ValidationException extends \Exception 6 + { 7 + public function __construct( 8 + public readonly array $errors, 9 + string $message = 'Response validation failed', 10 + int $code = 0, 11 + ?\Throwable $previous = null 12 + ) { 13 + parent::__construct($message, $code, $previous); 14 + } 15 + 16 + /** 17 + * Get validation errors 18 + */ 19 + public function getErrors(): array 20 + { 21 + return $this->errors; 22 + } 23 + }
+120
src/Http/HasHttp.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Http; 4 + 5 + use Illuminate\Http\Client\Factory; 6 + use Illuminate\Http\Client\Response as LaravelResponse; 7 + use SocialDept\AtpClient\Auth\DPoPNonceManager; 8 + use SocialDept\AtpClient\Exceptions\ValidationException; 9 + use SocialDept\AtpClient\Session\SessionManager; 10 + use SocialDept\Schema\Facades\Schema; 11 + 12 + trait HasHttp 13 + { 14 + protected SessionManager $sessions; 15 + 16 + protected Factory $http; 17 + 18 + protected string $identifier; 19 + 20 + protected DPoPNonceManager $nonceManager; 21 + 22 + /** 23 + * Make XRPC call 24 + */ 25 + protected function call( 26 + string $nsid, 27 + string $method, 28 + ?array $params = null, 29 + ?array $body = null 30 + ): Response { 31 + // Ensure session is valid (auto-refresh) 32 + $session = $this->sessions->ensureValid($this->identifier); 33 + 34 + // Build URL 35 + $url = rtrim($session->pdsEndpoint(), '/').'/xrpc/'.$nsid; 36 + 37 + // Get DPoP nonce 38 + $nonce = $this->nonceManager->getNonce($session->pdsEndpoint()); 39 + 40 + // Create DPoP proof using DPoPKeyManager 41 + $dpopProof = app(\SocialDept\AtpClient\Auth\DPoPKeyManager::class)->createProof( 42 + key: $session->dpopKey(), 43 + method: $method, 44 + url: $url, 45 + nonce: $nonce, 46 + accessToken: $session->accessToken(), 47 + ); 48 + 49 + // Filter null parameters 50 + $params = array_filter($params ?? [], fn ($v) => ! is_null($v)); 51 + 52 + // Build request 53 + $request = $this->http 54 + ->withHeaders([ 55 + 'Authorization' => 'Bearer '.$session->accessToken(), 56 + 'DPoP' => $dpopProof, 57 + ]); 58 + 59 + // Send request 60 + $response = match ($method) { 61 + 'GET' => $request->get($url, $params), 62 + 'POST' => $request->post($url, $body ?? $params), 63 + 'DELETE' => $request->delete($url, $params), 64 + default => throw new \InvalidArgumentException("Unsupported method: {$method}"), 65 + }; 66 + 67 + // Store nonce from response if present 68 + if ($newNonce = $response->header('DPoP-Nonce')) { 69 + $this->nonceManager->storeNonce($session->pdsEndpoint(), $newNonce); 70 + } 71 + 72 + // Validate response if schema exists 73 + if (Schema::exists($nsid)) { 74 + $this->validateResponse($nsid, $response); 75 + } 76 + 77 + return new Response($response); 78 + } 79 + 80 + /** 81 + * Validate response against schema 82 + */ 83 + protected function validateResponse(string $nsid, LaravelResponse $response): void 84 + { 85 + if (! $response->successful()) { 86 + return; // Don't validate error responses 87 + } 88 + 89 + $data = $response->json(); 90 + 91 + if (! Schema::validate($nsid, $data)) { 92 + $errors = Schema::getErrors($nsid, $data); 93 + throw new ValidationException($errors); 94 + } 95 + } 96 + 97 + /** 98 + * Make GET request 99 + */ 100 + protected function get(string $nsid, array $params = []): Response 101 + { 102 + return $this->call($nsid, 'GET', $params); 103 + } 104 + 105 + /** 106 + * Make POST request 107 + */ 108 + protected function post(string $nsid, array $body = []): Response 109 + { 110 + return $this->call($nsid, 'POST', null, $body); 111 + } 112 + 113 + /** 114 + * Make DELETE request 115 + */ 116 + protected function delete(string $nsid, array $params = []): Response 117 + { 118 + return $this->call($nsid, 'DELETE', $params); 119 + } 120 + }
+77
src/Http/Response.php
··· 1 + <?php 2 + 3 + namespace SocialDept\AtpClient\Http; 4 + 5 + use Illuminate\Http\Client\Response as LaravelResponse; 6 + use Illuminate\Support\Collection; 7 + 8 + class Response 9 + { 10 + public function __construct( 11 + protected LaravelResponse $response 12 + ) {} 13 + 14 + /** 15 + * Get JSON response data 16 + */ 17 + public function json(?string $key = null, mixed $default = null): mixed 18 + { 19 + return $this->response->json($key, $default); 20 + } 21 + 22 + /** 23 + * Get response as collection 24 + */ 25 + public function collect(?string $key = null): Collection 26 + { 27 + return $this->response->collect($key); 28 + } 29 + 30 + /** 31 + * Get HTTP status code 32 + */ 33 + public function status(): int 34 + { 35 + return $this->response->status(); 36 + } 37 + 38 + /** 39 + * Check if response was successful 40 + */ 41 + public function successful(): bool 42 + { 43 + return $this->response->successful(); 44 + } 45 + 46 + /** 47 + * Check if response failed 48 + */ 49 + public function failed(): bool 50 + { 51 + return $this->response->failed(); 52 + } 53 + 54 + /** 55 + * Get response body 56 + */ 57 + public function body(): string 58 + { 59 + return $this->response->body(); 60 + } 61 + 62 + /** 63 + * Convert to array 64 + */ 65 + public function toArray(): array 66 + { 67 + return $this->response->json(); 68 + } 69 + 70 + /** 71 + * Get underlying Laravel response 72 + */ 73 + public function getResponse(): LaravelResponse 74 + { 75 + return $this->response; 76 + } 77 + }