friendship ended with social-app. php is my new best friend
at main 14 kB view raw
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}