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