Laravel AT Protocol Client (alpha & unstable)
at dev 5.6 kB view raw
1<?php 2 3namespace SocialDept\AtpClient\Http; 4 5use BackedEnum; 6use Illuminate\Http\Client\Response as LaravelResponse; 7use Illuminate\Support\Facades\Http; 8use InvalidArgumentException; 9use SocialDept\AtpClient\Auth\ScopeChecker; 10use SocialDept\AtpClient\Enums\Scope; 11use SocialDept\AtpClient\Exceptions\AtpResponseException; 12use SocialDept\AtpClient\Exceptions\ValidationException; 13use SocialDept\AtpClient\Session\Session; 14use SocialDept\AtpClient\Session\SessionManager; 15use SocialDept\AtpSchema\Facades\Schema; 16 17trait HasHttp 18{ 19 protected ?SessionManager $sessions = null; 20 21 protected ?string $did = null; 22 23 protected ?DPoPClient $dpopClient = null; 24 25 protected ?ScopeChecker $scopeChecker = null; 26 27 /** 28 * Make XRPC call 29 */ 30 protected function call( 31 string|BackedEnum $endpoint, 32 string $method, 33 ?array $params = null, 34 ?array $body = null 35 ): Response { 36 $endpoint = $endpoint instanceof BackedEnum ? $endpoint->value : $endpoint; 37 $session = $this->sessions->ensureValid($this->did); 38 $url = rtrim($session->pdsEndpoint(), '/').'/xrpc/'.$endpoint; 39 40 $params = array_filter($params ?? [], fn ($v) => ! is_null($v)); 41 42 $request = $this->buildAuthenticatedRequest($session, $url, $method); 43 44 $response = match ($method) { 45 'GET' => $request->get($url, $params), 46 'POST' => $request->post($url, $body ?? $params), 47 'DELETE' => $request->delete($url, $params), 48 default => throw new InvalidArgumentException("Unsupported method: {$method}"), 49 }; 50 51 if ($response->failed() || isset($response->json()['error'])) { 52 throw AtpResponseException::fromResponse($response, $endpoint); 53 } 54 55 if (config('atp-client.schema_validation') && Schema::exists($endpoint)) { 56 $this->validateResponse($endpoint, $response); 57 } 58 59 return new Response($response); 60 } 61 62 /** 63 * Build authenticated request. 64 * 65 * OAuth sessions use DPoP proof with Bearer token. 66 * Legacy sessions use plain Bearer token. 67 */ 68 protected function buildAuthenticatedRequest( 69 Session $session, 70 string $url, 71 string $method 72 ): \Illuminate\Http\Client\PendingRequest { 73 if ($session->isLegacy()) { 74 return Http::withHeader('Authorization', 'Bearer '.$session->accessToken()); 75 } 76 77 return $this->dpopClient->request( 78 pdsEndpoint: $session->pdsEndpoint(), 79 url: $url, 80 method: $method, 81 dpopKey: $session->dpopKey(), 82 accessToken: $session->accessToken(), 83 ); 84 } 85 86 /** 87 * Validate response against schema 88 */ 89 protected function validateResponse(string $endpoint, LaravelResponse $response): void 90 { 91 if (! $response->successful()) { 92 return; 93 } 94 95 $data = $response->json(); 96 97 $errors = Schema::validateWithErrors($endpoint, $data); 98 99 if (! empty($errors)) { 100 throw new ValidationException($errors); 101 } 102 } 103 104 /** 105 * Make GET request 106 */ 107 public function get(string|BackedEnum $endpoint, array $params = []): Response 108 { 109 return $this->call($endpoint, 'GET', $params); 110 } 111 112 /** 113 * Make POST request 114 */ 115 public function post(string|BackedEnum $endpoint, array $body = []): Response 116 { 117 return $this->call($endpoint, 'POST', null, $body); 118 } 119 120 /** 121 * Make DELETE request 122 */ 123 public function delete(string|BackedEnum $endpoint, array $params = []): Response 124 { 125 return $this->call($endpoint, 'DELETE', $params); 126 } 127 128 /** 129 * Make POST request with raw binary body (for blob uploads) 130 */ 131 public function postBlob(string|BackedEnum $endpoint, string $data, string $mimeType): Response 132 { 133 $endpoint = $endpoint instanceof BackedEnum ? $endpoint->value : $endpoint; 134 $session = $this->sessions->ensureValid($this->did); 135 $url = rtrim($session->pdsEndpoint(), '/').'/xrpc/'.$endpoint; 136 137 $response = $this->buildAuthenticatedRequest($session, $url, 'POST') 138 ->withBody($data, $mimeType) 139 ->post($url); 140 141 if ($response->failed() || isset($response->json()['error'])) { 142 throw AtpResponseException::fromResponse($response, $endpoint); 143 } 144 145 return new Response($response); 146 } 147 148 /** 149 * Require specific scopes before making a request. 150 * 151 * Checks if the session has the required scopes. In strict mode, throws 152 * MissingScopeException if scopes are missing. In permissive mode, logs 153 * a warning but allows the request to proceed. 154 * 155 * @param string|Scope ...$scopes The required scopes 156 * 157 * @throws \SocialDept\AtpClient\Exceptions\MissingScopeException 158 */ 159 protected function requireScopes(string|Scope ...$scopes): void 160 { 161 $session = $this->sessions->session($this->did); 162 163 $this->getScopeChecker()->checkOrFail($session, $scopes); 164 } 165 166 /** 167 * Check if the session has a specific scope. 168 */ 169 protected function hasScope(string|Scope $scope): bool 170 { 171 $session = $this->sessions->session($this->did); 172 173 return $this->getScopeChecker()->hasScope($session, $scope); 174 } 175 176 /** 177 * Get the scope checker instance. 178 */ 179 protected function getScopeChecker(): ScopeChecker 180 { 181 if ($this->scopeChecker === null) { 182 $this->scopeChecker = app(ScopeChecker::class); 183 } 184 185 return $this->scopeChecker; 186 } 187}