Laravel AT Protocol Client (alpha & unstable)
at dev 8.4 kB view raw
1<?php 2 3namespace SocialDept\AtpClient\Client\Requests\Atproto; 4 5use BackedEnum; 6use Illuminate\Http\UploadedFile; 7use InvalidArgumentException; 8use SocialDept\AtpClient\Attributes\PublicEndpoint; 9use SocialDept\AtpClient\Attributes\ScopedEndpoint; 10use SocialDept\AtpClient\Auth\ScopeChecker; 11use SocialDept\AtpClient\Client\Requests\Request; 12use SocialDept\AtpClient\Data\Responses\Atproto\Repo\CreateRecordResponse; 13use SocialDept\AtpClient\Data\Responses\Atproto\Repo\DeleteRecordResponse; 14use SocialDept\AtpClient\Data\Responses\Atproto\Repo\DescribeRepoResponse; 15use SocialDept\AtpClient\Data\Responses\Atproto\Repo\GetRecordResponse; 16use SocialDept\AtpClient\Data\Responses\Atproto\Repo\ListRecordsResponse; 17use SocialDept\AtpClient\Data\Responses\Atproto\Repo\PutRecordResponse; 18use SocialDept\AtpClient\Enums\Nsid\AtprotoRepo; 19use SocialDept\AtpClient\Enums\Scope; 20use SocialDept\AtpSchema\Data\BlobReference; 21use SplFileInfo; 22use Throwable; 23 24class RepoRequestClient extends Request 25{ 26 /** 27 * Create a record 28 * 29 * @requires transition:generic OR repo:[collection]?action=create 30 * 31 * @see https://docs.bsky.app/docs/api/com-atproto-repo-create-record 32 */ 33 #[ScopedEndpoint(Scope::TransitionGeneric, description: 'Create records in repository')] 34 public function createRecord( 35 string|BackedEnum $collection, 36 array $record, 37 ?string $rkey = null, 38 bool $validate = true, 39 ?string $swapCommit = null 40 ): CreateRecordResponse { 41 $repo = $this->atp->client->session()->did(); 42 $collection = $collection instanceof BackedEnum ? $collection->value : $collection; 43 $this->checkCollectionScope($collection, 'create'); 44 45 $response = $this->atp->client->post( 46 endpoint: AtprotoRepo::CreateRecord, 47 body: array_filter( 48 compact('repo', 'collection', 'record', 'rkey', 'validate', 'swapCommit'), 49 fn ($v) => ! is_null($v) 50 ) 51 ); 52 53 return CreateRecordResponse::fromArray($response->json()); 54 } 55 56 /** 57 * Delete a record 58 * 59 * @requires transition:generic OR repo:[collection]?action=delete 60 * 61 * @see https://docs.bsky.app/docs/api/com-atproto-repo-delete-record 62 */ 63 #[ScopedEndpoint(Scope::TransitionGeneric, description: 'Delete records from repository')] 64 public function deleteRecord( 65 string|BackedEnum $collection, 66 string $rkey, 67 ?string $swapRecord = null, 68 ?string $swapCommit = null 69 ): DeleteRecordResponse { 70 $repo = $this->atp->client->session()->did(); 71 $collection = $collection instanceof BackedEnum ? $collection->value : $collection; 72 $this->checkCollectionScope($collection, 'delete'); 73 74 $response = $this->atp->client->post( 75 endpoint: AtprotoRepo::DeleteRecord, 76 body: array_filter( 77 compact('repo', 'collection', 'rkey', 'swapRecord', 'swapCommit'), 78 fn ($v) => ! is_null($v) 79 ) 80 ); 81 82 return DeleteRecordResponse::fromArray($response->json()); 83 } 84 85 /** 86 * Put (upsert) a record 87 * 88 * @requires transition:generic OR repo:[collection]?action=update 89 * 90 * @see https://docs.bsky.app/docs/api/com-atproto-repo-put-record 91 */ 92 #[ScopedEndpoint(Scope::TransitionGeneric, description: 'Update records in repository')] 93 public function putRecord( 94 string|BackedEnum $collection, 95 string $rkey, 96 array $record, 97 bool $validate = true, 98 ?string $swapRecord = null, 99 ?string $swapCommit = null 100 ): PutRecordResponse { 101 $repo = $this->atp->client->session()->did(); 102 $collection = $collection instanceof BackedEnum ? $collection->value : $collection; 103 $this->checkCollectionScope($collection, 'update'); 104 105 $response = $this->atp->client->post( 106 endpoint: AtprotoRepo::PutRecord, 107 body: array_filter( 108 compact('repo', 'collection', 'rkey', 'record', 'validate', 'swapRecord', 'swapCommit'), 109 fn ($v) => ! is_null($v) 110 ) 111 ); 112 113 return PutRecordResponse::fromArray($response->json()); 114 } 115 116 /** 117 * Get a record 118 * 119 * @see https://docs.bsky.app/docs/api/com-atproto-repo-get-record 120 */ 121 #[PublicEndpoint] 122 public function getRecord( 123 string $repo, 124 string|BackedEnum $collection, 125 string $rkey, 126 ?string $cid = null 127 ): GetRecordResponse { 128 $collection = $collection instanceof BackedEnum ? $collection->value : $collection; 129 $response = $this->atp->client->get( 130 endpoint: AtprotoRepo::GetRecord, 131 params: compact('repo', 'collection', 'rkey', 'cid') 132 ); 133 134 return GetRecordResponse::fromArray($response->json()); 135 } 136 137 /** 138 * List records in a collection 139 * 140 * @see https://docs.bsky.app/docs/api/com-atproto-repo-list-records 141 */ 142 #[PublicEndpoint] 143 public function listRecords( 144 string $repo, 145 string|BackedEnum $collection, 146 int $limit = 50, 147 ?string $cursor = null, 148 bool $reverse = false 149 ): ListRecordsResponse { 150 $collection = $collection instanceof BackedEnum ? $collection->value : $collection; 151 $response = $this->atp->client->get( 152 endpoint: AtprotoRepo::ListRecords, 153 params: compact('repo', 'collection', 'limit', 'cursor', 'reverse') 154 ); 155 156 return ListRecordsResponse::fromArray($response->json()); 157 } 158 159 /** 160 * Upload a new blob, to be referenced from a repository record 161 * 162 * The blob will be deleted if it is not referenced within a time window. 163 * 164 * @requires transition:generic (blob:*\/*\) 165 * 166 * @param UploadedFile|SplFileInfo|string $file The file to upload 167 * @param string|null $mimeType MIME type (required for string input, auto-detected for file objects) 168 * 169 * @throws InvalidArgumentException|Throwable When $file is a string and $mimeType is not provided 170 * 171 * @see https://docs.bsky.app/docs/api/com-atproto-repo-upload-blob 172 */ 173 #[ScopedEndpoint(Scope::TransitionGeneric, granular: 'blob:*/*')] 174 public function uploadBlob(UploadedFile|SplFileInfo|string $file, ?string $mimeType = null): BlobReference 175 { 176 // Handle different input types 177 if ($file instanceof UploadedFile) { 178 $data = $file->getContent(); 179 $mimeType ??= $file->getMimeType(); 180 } elseif ($file instanceof SplFileInfo) { 181 $data = file_get_contents($file->getRealPath()); 182 $mimeType ??= mime_content_type($file->getRealPath()) ?: 'application/octet-stream'; 183 } else { 184 throw_if($mimeType === null, new InvalidArgumentException('The $mimeType parameter is required when $file is a string.')); 185 $data = $file; 186 } 187 188 $response = $this->atp->client->postBlob( 189 endpoint: AtprotoRepo::UploadBlob, 190 data: $data, 191 mimeType: $mimeType 192 ); 193 194 return BlobReference::fromArray($response->json()['blob']); 195 } 196 197 /** 198 * Describe the repository 199 * 200 * @see https://docs.bsky.app/docs/api/com-atproto-repo-describe-repo 201 */ 202 #[PublicEndpoint] 203 public function describeRepo(string $repo): DescribeRepoResponse 204 { 205 $response = $this->atp->client->get( 206 endpoint: AtprotoRepo::DescribeRepo, 207 params: compact('repo') 208 ); 209 210 return DescribeRepoResponse::fromArray($response->json()); 211 } 212 213 /** 214 * Check if the session has repo access for a specific collection and action. 215 * 216 * This check is in addition to the transition:generic scope check. 217 * Users need either transition:generic OR the specific repo scope. 218 */ 219 protected function checkCollectionScope(string $collection, string $action): void 220 { 221 $session = $this->atp->client->session(); 222 $checker = app(ScopeChecker::class); 223 224 // If user has transition:generic, they have broad access 225 if ($checker->hasScope($session, Scope::TransitionGeneric)) { 226 return; 227 } 228 229 // Otherwise, check for specific repo scope 230 $checker->checkRepoScopeOrFail($session, $collection, $action); 231 } 232}