friendship ended with social-app. php is my new best friend
1<?php
2
3declare(strict_types=1);
4
5namespace Fetch\Concerns;
6
7use Fetch\Enum\ContentType;
8use Fetch\Enum\Method;
9use Fetch\Http\Response;
10use Fetch\Interfaces\Response as ResponseInterface;
11use GuzzleHttp\Exception\GuzzleException;
12use Matrix\Exceptions\AsyncException;
13use React\Promise\PromiseInterface;
14use RuntimeException;
15
16use function async;
17
18trait PerformsHttpRequests
19{
20 /**
21 * Handles an HTTP request with the given method, URI, and options.
22 *
23 * @param string $method The HTTP method to use
24 * @param string $uri The URI to request
25 * @param array<string, mixed> $options Additional options for the request
26 * @return Response|PromiseInterface Response or promise
27 */
28 public static function handle(
29 string $method,
30 string $uri,
31 array $options = []
32 ): Response|PromiseInterface {
33 $handler = new static;
34 $handler->withOptions($options);
35
36 return $handler->sendRequest($method, $uri);
37 }
38
39 /**
40 * Send a HEAD request.
41 *
42 * @param string $uri The URI to request
43 * @return ResponseInterface|PromiseInterface The response or promise
44 */
45 public function head(string $uri): ResponseInterface|PromiseInterface
46 {
47 return $this->sendRequest(Method::HEAD, $uri);
48 }
49
50 /**
51 * Send a GET request.
52 *
53 * @param string $uri The URI to request
54 * @param array<string, mixed> $queryParams Optional query parameters
55 * @return ResponseInterface|PromiseInterface The response or promise
56 */
57 public function get(string $uri, array $queryParams = []): ResponseInterface|PromiseInterface
58 {
59 $options = [];
60 if (! empty($queryParams)) {
61 $options['query'] = $queryParams;
62 }
63
64 return $this->sendRequest(Method::GET, $uri, $options);
65 }
66
67 /**
68 * Send a POST request.
69 *
70 * @param string $uri The URI to request
71 * @param mixed $body The request body
72 * @param ContentType|string $contentType The content type of the request
73 * @return ResponseInterface|PromiseInterface The response or promise
74 */
75 public function post(
76 string $uri,
77 mixed $body = null,
78 ContentType|string $contentType = ContentType::JSON
79 ): ResponseInterface|PromiseInterface {
80 return $this->sendRequestWithBody(Method::POST, $uri, $body, $contentType);
81 }
82
83 /**
84 * Send a PUT request.
85 *
86 * @param string $uri The URI to request
87 * @param mixed $body The request body
88 * @param ContentType|string $contentType The content type of the request
89 * @return ResponseInterface|PromiseInterface The response or promise
90 */
91 public function put(
92 string $uri,
93 mixed $body = null,
94 ContentType|string $contentType = ContentType::JSON
95 ): ResponseInterface|PromiseInterface {
96 return $this->sendRequestWithBody(Method::PUT, $uri, $body, $contentType);
97 }
98
99 /**
100 * Send a PATCH request.
101 *
102 * @param string $uri The URI to request
103 * @param mixed $body The request body
104 * @param ContentType|string $contentType The content type of the request
105 * @return ResponseInterface|PromiseInterface The response or promise
106 */
107 public function patch(
108 string $uri,
109 mixed $body = null,
110 ContentType|string $contentType = ContentType::JSON
111 ): ResponseInterface|PromiseInterface {
112 return $this->sendRequestWithBody(Method::PATCH, $uri, $body, $contentType);
113 }
114
115 /**
116 * Send a DELETE request.
117 *
118 * @param string $uri The URI to request
119 * @param mixed $body Optional request body
120 * @param ContentType|string $contentType The content type of the request
121 * @return ResponseInterface|PromiseInterface The response or promise
122 */
123 public function delete(
124 string $uri,
125 mixed $body = null,
126 ContentType|string $contentType = ContentType::JSON
127 ): ResponseInterface|PromiseInterface {
128 return $this->sendRequestWithBody(Method::DELETE, $uri, $body, $contentType);
129 }
130
131 /**
132 * Send an OPTIONS request.
133 *
134 * @param string $uri The URI to request
135 * @return ResponseInterface|PromiseInterface The response or promise
136 */
137 public function options(string $uri): ResponseInterface|PromiseInterface
138 {
139 return $this->sendRequest(Method::OPTIONS, $uri);
140 }
141
142 /**
143 * Send an HTTP request.
144 *
145 * @param Method|string $method The HTTP method
146 * @param string $uri The URI to request
147 * @param array<string, mixed> $options Additional options
148 * @return ResponseInterface|PromiseInterface The response or promise
149 */
150 /**
151 * Send an HTTP request.
152 *
153 * @param Method|string $method The HTTP method
154 * @param string $uri The URI to request
155 * @param array<string, mixed> $options Additional options
156 * @return ResponseInterface|PromiseInterface The response or promise
157 */
158 public function sendRequest(
159 Method|string $method,
160 string $uri,
161 array $options = []
162 ): ResponseInterface|PromiseInterface {
163 // Create a new handler with the combined options
164 $handler = clone $this;
165 $handler->withOptions($options);
166
167 // Normalize method to string
168 $methodStr = $method instanceof Method ? $method->value : strtoupper($method);
169
170 // Store URI in handler options
171 $handler->options['uri'] = $uri;
172 $handler->options['method'] = $methodStr;
173
174 // Build the full URI
175 $fullUri = $handler->buildFullUri($uri);
176
177 // Prepare Guzzle options
178 $guzzleOptions = $handler->prepareGuzzleOptions();
179
180 // Start timing for logging
181 $startTime = microtime(true);
182
183 // Log the request if method exists
184 if (method_exists($handler, 'logRequest')) {
185 $handler->logRequest($methodStr, $fullUri, $guzzleOptions);
186 }
187
188 // Send the request (async or sync)
189 if ($handler->isAsync) {
190 return $handler->executeAsyncRequest($methodStr, $fullUri, $guzzleOptions);
191 } else {
192 return $handler->executeSyncRequest($methodStr, $fullUri, $guzzleOptions, $startTime);
193 }
194 }
195
196 /**
197 * Sends an HTTP request with the specified parameters.
198 *
199 * @param string|Method $method HTTP method (e.g., GET, POST)
200 * @param string $uri URI to send the request to
201 * @param mixed $body Request body
202 * @param string|ContentType $contentType Content type of the request
203 * @param array<string, mixed> $options Additional request options
204 * @return Response|PromiseInterface Response or promise
205 */
206 public function request(
207 string|Method $method,
208 string $uri,
209 mixed $body = null,
210 string|ContentType $contentType = ContentType::JSON,
211 array $options = []
212 ): Response|PromiseInterface {
213 // Normalize method to string
214 $methodStr = $method instanceof Method ? $method->value : strtoupper($method);
215
216 // Apply any additional options
217 if (! empty($options)) {
218 $this->withOptions($options);
219 }
220
221 // Configure request body if provided
222 if ($body !== null) {
223 $this->configureRequestBody($body, $contentType);
224 }
225
226 // Send the request using our unified method
227 return $this->sendRequest($methodStr, $uri);
228 }
229
230 /**
231 * Get the effective timeout for the request.
232 *
233 * @return int The timeout in seconds
234 */
235 public function getEffectiveTimeout(): int
236 {
237 // Next check options array
238 if (isset($this->options['timeout']) && is_int($this->options['timeout'])) {
239 return $this->options['timeout'];
240 }
241
242 // First check explicitly set timeout property
243 if (isset($this->timeout) && is_int($this->timeout)) {
244 return $this->timeout;
245 }
246
247 // Fall back to default
248 return self::DEFAULT_TIMEOUT;
249 }
250
251 /**
252 * Send an HTTP request with a body.
253 *
254 * @param Method|string $method The HTTP method
255 * @param string $uri The URI to request
256 * @param mixed $body The request body
257 * @param ContentType|string $contentType The content type
258 * @param array<string, mixed> $options Additional options
259 * @return ResponseInterface|PromiseInterface The response or promise
260 */
261 protected function sendRequestWithBody(
262 Method|string $method,
263 string $uri,
264 mixed $body = null,
265 ContentType|string $contentType = ContentType::JSON,
266 array $options = []
267 ): ResponseInterface|PromiseInterface {
268 // Skip if no body
269 if ($body === null) {
270 return $this->sendRequest($method, $uri, $options);
271 }
272
273 // Create a new handler instance with cloned options
274 $handler = clone $this;
275
276 // Merge options if provided
277 if (! empty($options)) {
278 $handler->withOptions($options);
279 }
280
281 // Configure the request body on the cloned handler
282 $handler->configureRequestBody($body, $contentType);
283
284 // Send the request using the configured handler
285 return $handler->sendRequest($method, $uri);
286 }
287
288 /**
289 * Prepare options for Guzzle.
290 *
291 * @return array<string, mixed> Options ready for Guzzle
292 */
293 protected function prepareGuzzleOptions(): array
294 {
295 $guzzleOptions = [];
296
297 // Standard Guzzle options to include
298 $standardOptions = [
299 'headers', 'json', 'form_params', 'multipart', 'body',
300 'query', 'auth', 'verify', 'proxy', 'cookies', 'allow_redirects',
301 'cert', 'ssl_key', 'stream', 'connect_timeout', 'read_timeout',
302 'debug', 'sink', 'version', 'decode_content',
303 ];
304
305 // Copy standard options if set
306 foreach ($standardOptions as $option) {
307 if (isset($this->options[$option])) {
308 $guzzleOptions[$option] = $this->options[$option];
309 }
310 }
311
312 // Set timeout
313 if (isset($this->timeout)) {
314 $guzzleOptions['timeout'] = $this->timeout;
315 } elseif (isset($this->options['timeout'])) {
316 $guzzleOptions['timeout'] = $this->options['timeout'];
317 } else {
318 $guzzleOptions['timeout'] = $this->getEffectiveTimeout();
319 }
320
321 return $guzzleOptions;
322 }
323
324 /**
325 * Execute a synchronous HTTP request.
326 *
327 * @param string $method The HTTP method
328 * @param string $uri The full URI
329 * @param array<string, mixed> $options The Guzzle options
330 * @param float $startTime The request start time
331 * @return ResponseInterface The response
332 */
333 protected function executeSyncRequest(
334 string $method,
335 string $uri,
336 array $options,
337 float $startTime
338 ): ResponseInterface {
339 return $this->retryRequest(function () use ($method, $uri, $options, $startTime): ResponseInterface {
340 try {
341 // Send the request to Guzzle
342 $psrResponse = $this->getHttpClient()->request($method, $uri, $options);
343
344 // Calculate duration
345 $duration = microtime(true) - $startTime;
346
347 // Create our response object
348 $response = Response::createFromBase($psrResponse);
349
350 // Log response if method exists
351 if (method_exists($this, 'logResponse')) {
352 $this->logResponse($response, $duration);
353 }
354
355 return $response;
356 } catch (GuzzleException $e) {
357 throw new RuntimeException(
358 sprintf(
359 'Request %s %s failed: %s',
360 $method,
361 $uri,
362 $e->getMessage()
363 ),
364 $e->getCode(),
365 $e
366 );
367 }
368 });
369 }
370
371 /**
372 * Execute an asynchronous HTTP request.
373 *
374 * @param string $method The HTTP method
375 * @param string $uri The full URI
376 * @param array<string, mixed> $options The Guzzle options
377 * @return PromiseInterface A promise that resolves with the response
378 */
379 protected function executeAsyncRequest(
380 string $method,
381 string $uri,
382 array $options
383 ): PromiseInterface {
384 return async(function () use ($method, $uri, $options): ResponseInterface {
385 $startTime = microtime(true);
386
387 // Since this is in an async context, we can use try-catch for proper promise rejection
388 try {
389 // Execute the synchronous request inside the async function
390 $response = $this->executeSyncRequest($method, $uri, $options, $startTime);
391
392 return $response;
393 } catch (\Throwable $e) {
394 // Log the error without interfering with promise rejection
395 if (method_exists($this, 'logger') && isset($this->logger)) {
396 $this->logger->error('Async request failed', [
397 'method' => $method,
398 'uri' => $uri,
399 'error' => $e->getMessage(),
400 'exception_class' => get_class($e),
401 ]);
402 }
403
404 // Use withErrorContext to add request information to the error
405 $contextMessage = "Request $method $uri failed";
406
407 // Throw the exception - in the async context, this will properly reject the promise
408 throw new AsyncException(
409 $contextMessage.': '.$e->getMessage(),
410 $e->getCode(),
411 $e // Preserve the original exception as previous
412 );
413 }
414 });
415 }
416}