Maintain local ⭤ remote in sync with automatic AT Protocol parity for Laravel (alpha & unstable)
at dev 5.6 kB view raw
1<?php 2 3namespace SocialDept\AtpParity\Support; 4 5use Illuminate\Database\Eloquent\Model; 6use SocialDept\AtpClient\AtpClient; 7use SocialDept\AtpClient\Data\Responses\Atproto\Repo\GetRecordResponse; 8use SocialDept\AtpClient\Facades\Atp; 9use SocialDept\AtpParity\MapperRegistry; 10use SocialDept\AtpResolver\Facades\Resolver; 11use SocialDept\AtpSchema\Data\Data; 12 13/** 14 * Helper for integrating atp-parity with atp-client. 15 * 16 * Provides convenient methods for fetching records from the ATP network 17 * and converting them to typed DTOs or Eloquent models. 18 */ 19class RecordHelper 20{ 21 /** 22 * Cache of clients by PDS endpoint. 23 * 24 * @var array<string, AtpClient> 25 */ 26 protected array $clients = []; 27 28 public function __construct( 29 protected MapperRegistry $registry 30 ) {} 31 32 /** 33 * Get or create a client for a PDS endpoint. 34 */ 35 protected function clientFor(string $pdsEndpoint): AtpClient 36 { 37 return $this->clients[$pdsEndpoint] ??= Atp::public($pdsEndpoint); 38 } 39 40 /** 41 * Resolve the PDS endpoint for a DID or handle. 42 */ 43 protected function resolvePds(string $actor): ?string 44 { 45 return Resolver::resolvePds($actor); 46 } 47 48 /** 49 * Convert a GetRecordResponse to a typed record DTO. 50 * 51 * @template T of Data 52 * 53 * @param class-string<T>|null $recordClass Explicit record class, or null to auto-detect from mapper 54 * @return T|array The typed record, or raw array if no mapper found and no class specified 55 */ 56 public function hydrateRecord(GetRecordResponse $response, ?string $recordClass = null): mixed 57 { 58 if ($recordClass) { 59 return $recordClass::fromArray($response->value); 60 } 61 62 $collection = $this->extractCollection($response->uri); 63 $mapper = $this->registry->forLexicon($collection); 64 65 if (! $mapper) { 66 return $response->value; 67 } 68 69 $recordClass = $mapper->recordClass(); 70 71 return $recordClass::fromArray($response->value); 72 } 73 74 /** 75 * Fetch a record from the ATP network by URI and return as typed DTO. 76 * 77 * @template T of Data 78 * 79 * @param class-string<T>|null $recordClass 80 * @return T|array|null 81 */ 82 public function fetch(string $uri, ?string $recordClass = null): mixed 83 { 84 $parts = $this->parseUri($uri); 85 86 if (! $parts) { 87 return null; 88 } 89 90 $pdsEndpoint = $this->resolvePds($parts['repo']); 91 92 if (! $pdsEndpoint) { 93 return null; 94 } 95 96 $response = $this->clientFor($pdsEndpoint)->atproto->repo->getRecord( 97 $parts['repo'], 98 $parts['collection'], 99 $parts['rkey'] 100 ); 101 102 return $this->hydrateRecord($response, $recordClass); 103 } 104 105 /** 106 * Fetch a record by URI and convert directly to an Eloquent model. 107 * 108 * @template TModel of Model 109 * 110 * @return TModel|null 111 */ 112 public function fetchAsModel(string $uri): ?Model 113 { 114 $parts = $this->parseUri($uri); 115 116 if (! $parts) { 117 return null; 118 } 119 120 $mapper = $this->registry->forLexicon($parts['collection']); 121 122 if (! $mapper) { 123 return null; 124 } 125 126 $pdsEndpoint = $this->resolvePds($parts['repo']); 127 128 if (! $pdsEndpoint) { 129 return null; 130 } 131 132 $response = $this->clientFor($pdsEndpoint)->atproto->repo->getRecord( 133 $parts['repo'], 134 $parts['collection'], 135 $parts['rkey'] 136 ); 137 138 $recordClass = $mapper->recordClass(); 139 $record = $recordClass::fromArray($response->value); 140 141 return $mapper->toModel($record, [ 142 'uri' => $response->uri, 143 'cid' => $response->cid, 144 ]); 145 } 146 147 /** 148 * Fetch a record by URI and upsert to the database. 149 * 150 * @template TModel of Model 151 * 152 * @return TModel|null 153 */ 154 public function sync(string $uri): ?Model 155 { 156 $parts = $this->parseUri($uri); 157 158 if (! $parts) { 159 return null; 160 } 161 162 $mapper = $this->registry->forLexicon($parts['collection']); 163 164 if (! $mapper) { 165 return null; 166 } 167 168 $pdsEndpoint = $this->resolvePds($parts['repo']); 169 170 if (! $pdsEndpoint) { 171 return null; 172 } 173 174 $response = $this->clientFor($pdsEndpoint)->atproto->repo->getRecord( 175 $parts['repo'], 176 $parts['collection'], 177 $parts['rkey'] 178 ); 179 180 $recordClass = $mapper->recordClass(); 181 $record = $recordClass::fromArray($response->value); 182 183 return $mapper->upsert($record, [ 184 'uri' => $response->uri, 185 'cid' => $response->cid, 186 ]); 187 } 188 189 /** 190 * Parse an AT Protocol URI into its components. 191 * 192 * @return array{repo: string, collection: string, rkey: string}|null 193 */ 194 protected function parseUri(string $uri): ?array 195 { 196 // at://did:plc:xxx/app.bsky.feed.post/rkey 197 if (! preg_match('#^at://([^/]+)/([^/]+)/([^/]+)$#', $uri, $matches)) { 198 return null; 199 } 200 201 return [ 202 'repo' => $matches[1], 203 'collection' => $matches[2], 204 'rkey' => $matches[3], 205 ]; 206 } 207 208 /** 209 * Extract collection from AT Protocol URI. 210 */ 211 protected function extractCollection(string $uri): string 212 { 213 // at://did:plc:xxx/app.bsky.feed.post/rkey 214 if (preg_match('#^at://[^/]+/([^/]+)/#', $uri, $matches)) { 215 return $matches[1]; 216 } 217 218 return ''; 219 } 220}