friendship ended with social-app. php is my new best friend
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}