friendship ended with social-app. php is my new best friend
at main 12 kB view raw
1<?php 2 3declare(strict_types=1); 4 5namespace Fetch\Http; 6 7use Fetch\Concerns\ConfiguresRequests; 8use Fetch\Concerns\HandlesUris; 9use Fetch\Concerns\ManagesPromises; 10use Fetch\Concerns\ManagesRetries; 11use Fetch\Concerns\PerformsHttpRequests; 12use Fetch\Enum\ContentType; 13use Fetch\Enum\Method; 14use Fetch\Interfaces\ClientHandler as ClientHandlerInterface; 15use GuzzleHttp\Client as GuzzleClient; 16use GuzzleHttp\ClientInterface; 17use GuzzleHttp\RequestOptions; 18use InvalidArgumentException; 19use Psr\Log\LoggerInterface; 20use Psr\Log\NullLogger; 21 22class ClientHandler implements ClientHandlerInterface 23{ 24 use ConfiguresRequests, 25 HandlesUris, 26 ManagesPromises, 27 ManagesRetries, 28 PerformsHttpRequests; 29 30 /** 31 * Default options for the request. 32 * 33 * @var array<string, mixed> 34 */ 35 protected static array $defaultOptions = [ 36 'method' => Method::GET->value, 37 'headers' => [], 38 ]; 39 40 /** 41 * Default timeout for requests in seconds. 42 */ 43 public const DEFAULT_TIMEOUT = 30; 44 45 /** 46 * Default number of retries. 47 */ 48 public const DEFAULT_RETRIES = 1; 49 50 /** 51 * Default delay between retries in milliseconds. 52 */ 53 public const DEFAULT_RETRY_DELAY = 100; 54 55 /** 56 * Whether the request should be asynchronous. 57 */ 58 protected bool $isAsync = false; 59 60 /** 61 * Logger instance. 62 */ 63 protected LoggerInterface $logger; 64 65 /** 66 * ClientHandler constructor. 67 * 68 * @param ClientInterface|null $httpClient The HTTP client 69 * @param array<string, mixed> $options The options for the request 70 * @param int|null $timeout Timeout for the request in seconds 71 * @param int|null $maxRetries Number of retries for the request 72 * @param int|null $retryDelay Delay between retries in milliseconds 73 * @param bool $isAsync Whether the request is asynchronous 74 * @param LoggerInterface|null $logger Logger for request/response details 75 */ 76 public function __construct( 77 protected ?ClientInterface $httpClient = null, 78 protected array $options = [], 79 protected ?int $timeout = null, 80 ?int $maxRetries = null, 81 ?int $retryDelay = null, 82 bool $isAsync = false, 83 ?LoggerInterface $logger = null 84 ) { 85 $this->logger = $logger ?? new NullLogger; 86 $this->isAsync = $isAsync; 87 $this->maxRetries = $maxRetries ?? self::DEFAULT_RETRIES; 88 $this->retryDelay = $retryDelay ?? self::DEFAULT_RETRY_DELAY; 89 90 // Initialize with default options 91 $this->options = array_merge(self::getDefaultOptions(), $this->options); 92 93 // Set the timeout in options 94 if ($this->timeout !== null) { 95 $this->options['timeout'] = $this->timeout; 96 } else { 97 $this->timeout = $this->options['timeout'] ?? self::DEFAULT_TIMEOUT; 98 $this->options['timeout'] = $this->timeout; 99 } 100 } 101 102 /** 103 * Create a new client handler with factory defaults. 104 * 105 * @return static New client handler instance 106 */ 107 public static function create(): static 108 { 109 return new static; 110 } 111 112 /** 113 * Create a client handler with preconfigured base URI. 114 * 115 * @param string $baseUri Base URI for all requests 116 * @return static New client handler instance 117 * 118 * @throws InvalidArgumentException If the base URI is invalid 119 */ 120 public static function createWithBaseUri(string $baseUri): static 121 { 122 $instance = new static; 123 $instance->baseUri($baseUri); 124 125 return $instance; 126 } 127 128 /** 129 * Create a client handler with a custom HTTP client. 130 * 131 * @param ClientInterface $client Custom HTTP client 132 * @return static New client handler instance 133 */ 134 public static function createWithClient(ClientInterface $client): static 135 { 136 return new static(httpClient: $client); 137 } 138 139 /** 140 * Get the default options for the request. 141 * 142 * @return array<string, mixed> Default options 143 */ 144 public static function getDefaultOptions(): array 145 { 146 return array_merge(self::$defaultOptions, [ 147 'timeout' => self::DEFAULT_TIMEOUT, 148 ]); 149 } 150 151 /** 152 * Set the default options for all instances. 153 * 154 * @param array<string, mixed> $options Default options 155 */ 156 public static function setDefaultOptions(array $options): void 157 { 158 self::$defaultOptions = array_merge(self::$defaultOptions, $options); 159 } 160 161 /** 162 * Create a new mock response for testing. 163 * 164 * @param int $statusCode HTTP status code 165 * @param array<string, string|string[]> $headers Response headers 166 * @param string|null $body Response body 167 * @param string $version HTTP protocol version 168 * @param string|null $reason Reason phrase 169 * @return Response Mock response 170 */ 171 public static function createMockResponse( 172 int $statusCode = 200, 173 array $headers = [], 174 ?string $body = null, 175 string $version = '1.1', 176 ?string $reason = null 177 ): Response { 178 return new Response($statusCode, $headers, $body, $version, $reason); 179 } 180 181 /** 182 * Create a JSON response for testing. 183 * 184 * @param array<mixed>|object $data JSON data 185 * @param int $statusCode HTTP status code 186 * @param array<string, string|string[]> $headers Additional headers 187 * @return Response Mock JSON response 188 */ 189 public static function createJsonResponse( 190 array|object $data, 191 int $statusCode = 200, 192 array $headers = [] 193 ): Response { 194 $jsonData = json_encode($data, JSON_PRETTY_PRINT); 195 196 $headers = array_merge( 197 ['Content-Type' => ContentType::JSON->value], 198 $headers 199 ); 200 201 return self::createMockResponse($statusCode, $headers, $jsonData); 202 } 203 204 /** 205 * Get the HTTP client. 206 * 207 * @return ClientInterface The HTTP client 208 */ 209 public function getHttpClient(): ClientInterface 210 { 211 if (! $this->httpClient) { 212 $this->httpClient = new GuzzleClient([ 213 RequestOptions::CONNECT_TIMEOUT => $this->options['timeout'] ?? self::DEFAULT_TIMEOUT, 214 RequestOptions::HTTP_ERRORS => false, // We'll handle HTTP errors ourselves 215 ]); 216 } 217 218 return $this->httpClient; 219 } 220 221 /** 222 * Set the HTTP client. 223 * 224 * @param ClientInterface $client The HTTP client 225 * @return $this 226 */ 227 public function setHttpClient(ClientInterface $client): self 228 { 229 $this->httpClient = $client; 230 231 return $this; 232 } 233 234 /** 235 * Get the current request options. 236 * 237 * @return array<string, mixed> Current options 238 */ 239 public function getOptions(): array 240 { 241 return $this->options; 242 } 243 244 /** 245 * Get the request headers. 246 * 247 * @return array<string, mixed> Current headers 248 */ 249 public function getHeaders(): array 250 { 251 return $this->options['headers'] ?? []; 252 } 253 254 /** 255 * Check if the request has a specific header. 256 * 257 * @param string $header Header name 258 * @return bool Whether the header exists 259 */ 260 public function hasHeader(string $header): bool 261 { 262 return isset($this->options['headers'][$header]); 263 } 264 265 /** 266 * Check if the request has a specific option. 267 * 268 * @param string $option Option name 269 * @return bool Whether the option exists 270 */ 271 public function hasOption(string $option): bool 272 { 273 return isset($this->options[$option]); 274 } 275 276 /** 277 * Get debug information about the request. 278 * 279 * @return array<string, mixed> Debug information 280 */ 281 public function debug(): array 282 { 283 return [ 284 'uri' => $this->getFullUri(), 285 'method' => $this->options['method'] ?? Method::GET->value, 286 'headers' => $this->getHeaders(), 287 'options' => array_diff_key($this->options, ['headers' => true]), 288 'is_async' => $this->isAsync, 289 'timeout' => $this->timeout, 290 'retries' => $this->maxRetries, 291 'retry_delay' => $this->retryDelay, 292 ]; 293 } 294 295 /** 296 * Set the logger instance. 297 * 298 * @param LoggerInterface $logger PSR-3 logger 299 * @return $this 300 */ 301 public function setLogger(LoggerInterface $logger): self 302 { 303 $this->logger = $logger; 304 305 return $this; 306 } 307 308 /** 309 * Clone this client handler with the given options. 310 * 311 * @param array<string, mixed> $options Options to apply to the clone 312 * @return static New client handler instance with the applied options 313 */ 314 public function withClonedOptions(array $options): static 315 { 316 $clone = clone $this; 317 $clone->withOptions($options); 318 319 return $clone; 320 } 321 322 /** 323 * Log a retry attempt. 324 * 325 * @param int $attempt Current attempt number 326 * @param int $maxAttempts Maximum attempts 327 * @param \Throwable $exception The exception that caused the retry 328 */ 329 protected function logRetry(int $attempt, int $maxAttempts, \Throwable $exception): void 330 { 331 $this->logger->info( 332 'Retrying request', 333 [ 334 'attempt' => $attempt, 335 'max_attempts' => $maxAttempts, 336 'uri' => $this->getFullUri(), 337 'method' => $this->options['method'] ?? Method::GET->value, 338 'error' => $exception->getMessage(), 339 'code' => $exception->getCode(), 340 ] 341 ); 342 } 343 344 /** 345 * Log a request. 346 * 347 * @param string $method HTTP method 348 * @param string $uri Request URI 349 * @param array<string, mixed> $options Request options 350 */ 351 protected function logRequest(string $method, string $uri, array $options): void 352 { 353 // Remove potentially sensitive data 354 $sanitizedOptions = $this->sanitizeOptions($options); 355 356 $this->logger->debug( 357 'Sending HTTP request', 358 [ 359 'method' => $method, 360 'uri' => $uri, 361 'options' => $sanitizedOptions, 362 ] 363 ); 364 } 365 366 /** 367 * Log a response. 368 * 369 * @param Response $response HTTP response 370 * @param float $duration Request duration in seconds 371 */ 372 protected function logResponse(Response $response, float $duration): void 373 { 374 $this->logger->debug( 375 'Received HTTP response', 376 [ 377 'status_code' => $response->getStatusCode(), 378 'reason' => $response->getReasonPhrase(), 379 'duration' => round($duration, 3), 380 'content_length' => $this->getResponseContentLength($response), 381 ] 382 ); 383 } 384 385 /** 386 * Get the content length of a response. 387 * 388 * @param Response $response The response 389 * @return int|string The content length 390 */ 391 protected function getResponseContentLength(Response $response): int|string 392 { 393 if ($response->hasHeader('Content-Length')) { 394 return $response->getHeaderLine('Content-Length'); 395 } 396 397 $body = $response->getBody(); 398 $body->rewind(); 399 $content = $body->getContents(); 400 $body->rewind(); 401 402 return strlen($content); 403 } 404 405 /** 406 * Sanitize options for logging. 407 * 408 * @param array<string, mixed> $options The options to sanitize 409 * @return array<string, mixed> Sanitized options 410 */ 411 protected function sanitizeOptions(array $options): array 412 { 413 $sanitizedOptions = $options; 414 415 // Mask authorization headers 416 if (isset($sanitizedOptions['headers']['Authorization'])) { 417 $sanitizedOptions['headers']['Authorization'] = '[REDACTED]'; 418 } 419 420 // Mask auth credentials 421 if (isset($sanitizedOptions['auth'])) { 422 $sanitizedOptions['auth'] = '[REDACTED]'; 423 } 424 425 return $sanitizedOptions; 426 } 427}