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\Http; 6 7use Fetch\Enum\ContentType; 8use Fetch\Enum\Method; 9use Fetch\Exceptions\ClientException; 10use Fetch\Exceptions\NetworkException; 11use Fetch\Exceptions\RequestException; 12use Fetch\Interfaces\ClientHandler as ClientHandlerInterface; 13use Fetch\Interfaces\Response as ResponseInterface; 14use GuzzleHttp\Exception\ConnectException; 15use GuzzleHttp\Exception\RequestException as GuzzleRequestException; 16use InvalidArgumentException; 17use Psr\Http\Client\ClientExceptionInterface; 18use Psr\Http\Client\ClientInterface; 19use Psr\Http\Message\RequestInterface; 20use Psr\Http\Message\ResponseInterface as PsrResponseInterface; 21use Psr\Log\LoggerAwareInterface; 22use Psr\Log\LoggerInterface; 23use Psr\Log\NullLogger; 24use RuntimeException; 25use Throwable; 26 27class Client implements ClientInterface, LoggerAwareInterface 28{ 29 /** 30 * The HTTP client handler. 31 */ 32 protected ClientHandlerInterface $handler; 33 34 /** 35 * The logger instance. 36 */ 37 protected LoggerInterface $logger; 38 39 /** 40 * Client constructor. 41 * 42 * @param ClientHandlerInterface|null $handler The client handler 43 * @param array<string, mixed> $options Default request options 44 * @param LoggerInterface|null $logger PSR-3 logger 45 */ 46 public function __construct( 47 ?ClientHandlerInterface $handler = null, 48 array $options = [], 49 ?LoggerInterface $logger = null 50 ) { 51 $this->handler = $handler ?? new ClientHandler(options: $options); 52 $this->logger = $logger ?? new NullLogger; 53 54 // If handler supports logging, set the logger 55 if (method_exists($this->handler, 'setLogger')) { 56 $this->handler->setLogger($this->logger); 57 } 58 } 59 60 /** 61 * Create a new client with a base URI. 62 * 63 * @param string $baseUri The base URI for all requests 64 * @param array<string, mixed> $options Default request options 65 * @return static New client instance 66 */ 67 public static function createWithBaseUri(string $baseUri, array $options = []): static 68 { 69 $handler = ClientHandler::createWithBaseUri($baseUri); 70 71 if (! empty($options)) { 72 $handler->withOptions($options); 73 } 74 75 return new static($handler); 76 } 77 78 /** 79 * Set a PSR-3 logger. 80 * 81 * @param LoggerInterface $logger PSR-3 logger 82 */ 83 public function setLogger(LoggerInterface $logger): void 84 { 85 $this->logger = $logger; 86 87 // If handler supports logging, set the logger 88 if (method_exists($this->handler, 'setLogger')) { 89 $this->handler->setLogger($logger); 90 } 91 } 92 93 /** 94 * Get the client handler. 95 * 96 * @return ClientHandlerInterface The client handler 97 */ 98 public function getHandler(): ClientHandlerInterface 99 { 100 return $this->handler; 101 } 102 103 /** 104 * Sends a PSR-7 request and returns a PSR-7 response. 105 * 106 * @param RequestInterface $request PSR-7 request 107 * @return PsrResponseInterface PSR-7 response 108 * 109 * @throws ClientExceptionInterface If an error happens while processing the request 110 */ 111 public function sendRequest(RequestInterface $request): PsrResponseInterface 112 { 113 try { 114 $method = $request->getMethod(); 115 $uri = (string) $request->getUri(); 116 $options = $this->extractOptionsFromRequest($request); 117 118 $this->logger->info('Sending PSR-7 request', [ 119 'method' => $method, 120 'uri' => $uri, 121 ]); 122 123 // Use the new sendRequest method instead of request 124 $response = $this->handler->sendRequest($method, $uri, $options); 125 126 // Ensure we return a PSR-7 response 127 if ($response instanceof ResponseInterface) { 128 return $response; 129 } 130 131 // Handle case where a promise was returned (should not happen in sendRequest) 132 throw new RuntimeException('Async operations not supported in sendRequest()'); 133 } catch (ConnectException $e) { 134 $this->logger->error('Network error', [ 135 'message' => $e->getMessage(), 136 'uri' => (string) $request->getUri(), 137 ]); 138 139 throw new NetworkException( 140 'Network error: '.$e->getMessage(), 141 $request, 142 $e 143 ); 144 } catch (GuzzleRequestException $e) { 145 $this->logger->error('Request error', [ 146 'message' => $e->getMessage(), 147 'uri' => (string) $request->getUri(), 148 'code' => $e->getCode(), 149 ]); 150 151 // Return the error response if available 152 if ($e->hasResponse()) { 153 return Response::createFromBase($e->getResponse()); 154 } 155 156 throw new RequestException( 157 'Request error: '.$e->getMessage(), 158 $request, 159 null, 160 $e 161 ); 162 } catch (Throwable $e) { 163 $this->logger->error('Unexpected error', [ 164 'message' => $e->getMessage(), 165 'uri' => (string) $request->getUri(), 166 'type' => get_class($e), 167 ]); 168 169 throw new ClientException( 170 'Unexpected error: '.$e->getMessage(), 171 0, 172 $e 173 ); 174 } 175 } 176 177 /** 178 * Create and send an HTTP request. 179 * 180 * @param string|null $url The URL to fetch 181 * @param array<string, mixed>|null $options Request options 182 * @return ResponseInterface|ClientHandlerInterface Response or handler for method chaining 183 * 184 * @throws RuntimeException If the request fails 185 */ 186 public function fetch(?string $url = null, ?array $options = []): ResponseInterface|ClientHandlerInterface 187 { 188 // If no URL is provided, return the handler for method chaining 189 if (is_null($url)) { 190 return $this->handler; 191 } 192 193 $options = array_merge(ClientHandler::getDefaultOptions(), $options ?? []); 194 195 // Normalize the HTTP method 196 $method = strtoupper($options['method'] ?? Method::GET->value); 197 198 try { 199 $methodEnum = Method::fromString($method); 200 } catch (\ValueError $e) { 201 throw new InvalidArgumentException("Invalid HTTP method: {$method}"); 202 } 203 204 // Process the request body 205 $body = null; 206 $contentType = ContentType::JSON; 207 208 if (isset($options['body'])) { 209 $body = $options['body']; 210 $contentTypeStr = $options['headers']['Content-Type'] ?? ContentType::JSON->value; 211 212 try { 213 $contentType = ContentType::tryFromString($contentTypeStr); 214 } catch (\ValueError $e) { 215 $contentType = $contentTypeStr; 216 } 217 } 218 219 // Handle JSON body specifically 220 if (isset($options['json'])) { 221 $body = $options['json']; 222 $contentType = ContentType::JSON; 223 } 224 225 // Handle base URI if provided 226 if (isset($options['base_uri'])) { 227 $this->handler->baseUri($options['base_uri']); 228 unset($options['base_uri']); 229 } 230 231 $this->logger->info('Sending fetch request', [ 232 'method' => $method, 233 'url' => $url, 234 ]); 235 236 // Send the request using the new unified approach 237 try { 238 $handler = $this->handler->withOptions($options); 239 240 if ($body !== null) { 241 $handler = $handler->withBody($body, $contentType); 242 } 243 244 return $handler->sendRequest($method, $url); 245 } catch (GuzzleRequestException $e) { 246 // Handle Guzzle exceptions - Note: this catch block is incomplete in the original 247 $this->logger->error('Request exception', [ 248 'message' => $e->getMessage(), 249 'code' => $e->getCode(), 250 ]); 251 252 // If the exception has a response, return it 253 if ($e->hasResponse()) { 254 return Response::createFromBase($e->getResponse()); 255 } 256 257 // Otherwise, re-throw 258 throw $e; 259 } 260 } 261 262 /** 263 * Make a GET request. 264 * 265 * @param string $url The URL to fetch 266 * @param array<string, mixed>|null $queryParams Query parameters 267 * @param array<string, mixed>|null $options Request options 268 * @return ResponseInterface The response 269 */ 270 public function get(string $url, ?array $queryParams = null, ?array $options = []): ResponseInterface 271 { 272 $options = $options ?? []; 273 274 if ($queryParams) { 275 $options['query'] = $queryParams; 276 } 277 278 return $this->methodRequest(Method::GET, $url, null, ContentType::JSON, $options); 279 } 280 281 /** 282 * Make a POST request. 283 * 284 * @param string $url The URL to fetch 285 * @param mixed $body Request body 286 * @param string|ContentType $contentType Content type 287 * @param array<string, mixed>|null $options Request options 288 * @return ResponseInterface The response 289 */ 290 public function post( 291 string $url, 292 mixed $body = null, 293 string|ContentType $contentType = ContentType::JSON, 294 ?array $options = [] 295 ): ResponseInterface { 296 return $this->methodRequest(Method::POST, $url, $body, $contentType, $options); 297 } 298 299 /** 300 * Make a PUT request. 301 * 302 * @param string $url The URL to fetch 303 * @param mixed $body Request body 304 * @param string|ContentType $contentType Content type 305 * @param array<string, mixed>|null $options Request options 306 * @return ResponseInterface The response 307 */ 308 public function put( 309 string $url, 310 mixed $body = null, 311 string|ContentType $contentType = ContentType::JSON, 312 ?array $options = [] 313 ): ResponseInterface { 314 return $this->methodRequest(Method::PUT, $url, $body, $contentType, $options); 315 } 316 317 /** 318 * Make a PATCH request. 319 * 320 * @param string $url The URL to fetch 321 * @param mixed $body Request body 322 * @param string|ContentType $contentType Content type 323 * @param array<string, mixed>|null $options Request options 324 * @return ResponseInterface The response 325 */ 326 public function patch( 327 string $url, 328 mixed $body = null, 329 string|ContentType $contentType = ContentType::JSON, 330 ?array $options = [] 331 ): ResponseInterface { 332 return $this->methodRequest(Method::PATCH, $url, $body, $contentType, $options); 333 } 334 335 /** 336 * Make a DELETE request. 337 * 338 * @param string $url The URL to fetch 339 * @param mixed $body Request body 340 * @param string|ContentType $contentType Content type 341 * @param array<string, mixed>|null $options Request options 342 * @return ResponseInterface The response 343 */ 344 public function delete( 345 string $url, 346 mixed $body = null, 347 string|ContentType $contentType = ContentType::JSON, 348 ?array $options = [] 349 ): ResponseInterface { 350 return $this->methodRequest(Method::DELETE, $url, $body, $contentType, $options); 351 } 352 353 /** 354 * Make a HEAD request. 355 * 356 * @param string $url The URL to fetch 357 * @param array<string, mixed>|null $options Request options 358 * @return ResponseInterface The response 359 */ 360 public function head(string $url, ?array $options = []): ResponseInterface 361 { 362 return $this->methodRequest(Method::HEAD, $url, null, ContentType::JSON, $options); 363 } 364 365 /** 366 * Make an OPTIONS request. 367 * 368 * @param string $url The URL to fetch 369 * @param array<string, mixed>|null $options Request options 370 * @return ResponseInterface The response 371 */ 372 public function options(string $url, ?array $options = []): ResponseInterface 373 { 374 return $this->methodRequest(Method::OPTIONS, $url, null, ContentType::JSON, $options); 375 } 376 377 /** 378 * Get the PSR-7 HTTP client. 379 */ 380 public function getHttpClient(): ClientInterface 381 { 382 return $this->handler->getHttpClient(); 383 } 384 385 /** 386 * Make a request with a specific HTTP method. 387 * 388 * @param Method $method The HTTP method 389 * @param string $url The URL to fetch 390 * @param mixed $body Request body 391 * @param string|ContentType $contentType Content type 392 * @param array<string, mixed>|null $options Request options 393 * @return ResponseInterface The response 394 */ 395 protected function methodRequest( 396 Method $method, 397 string $url, 398 mixed $body = null, 399 string|ContentType $contentType = ContentType::JSON, 400 ?array $options = [] 401 ): ResponseInterface { 402 $options = $options ?? []; 403 $options['method'] = $method->value; 404 405 if ($body !== null) { 406 $options['body'] = $body; 407 408 // Use the global normalize_content_type function 409 $normalizedContentType = ContentType::normalizeContentType($contentType); 410 411 if ($normalizedContentType instanceof ContentType) { 412 $options['headers']['Content-Type'] = $normalizedContentType->value; 413 } else { 414 $options['headers']['Content-Type'] = $normalizedContentType; 415 } 416 } 417 418 return $this->fetch($url, $options); 419 } 420 421 /** 422 * Extract options from a PSR-7 request. 423 * 424 * @param RequestInterface $request PSR-7 request 425 * @return array<string, mixed> Request options 426 */ 427 protected function extractOptionsFromRequest(RequestInterface $request): array 428 { 429 $options = []; 430 431 // Add headers 432 $headers = []; 433 foreach ($request->getHeaders() as $name => $values) { 434 $headers[$name] = implode(', ', $values); 435 } 436 437 if (! empty($headers)) { 438 $options['headers'] = $headers; 439 } 440 441 // Add body if present 442 $body = (string) $request->getBody(); 443 if (! empty($body)) { 444 $options['body'] = $body; 445 } 446 447 return $options; 448 } 449}