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\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}