Laravel AT Protocol Client (alpha & unstable)
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}