1: <?php declare(strict_types=1);
2:
3: namespace Salient\Curler;
4:
5: use Psr\Http\Client\ClientExceptionInterface as PsrClientExceptionInterface;
6: use Psr\Http\Message\RequestInterface as PsrRequestInterface;
7: use Psr\Http\Message\ResponseInterface as PsrResponseInterface;
8: use Psr\Http\Message\StreamInterface as PsrStreamInterface;
9: use Psr\Http\Message\UriInterface as PsrUriInterface;
10: use Salient\Contract\Cache\CacheInterface;
11: use Salient\Contract\Core\Arrayable;
12: use Salient\Contract\Core\Buildable;
13: use Salient\Contract\Core\DateFormatterInterface;
14: use Salient\Contract\Curler\Exception\CurlErrorException as CurlErrorExceptionInterface;
15: use Salient\Contract\Curler\CurlerInterface;
16: use Salient\Contract\Curler\CurlerMiddlewareInterface;
17: use Salient\Contract\Curler\CurlerPageRequestInterface;
18: use Salient\Contract\Curler\CurlerPagerInterface;
19: use Salient\Contract\Http\Exception\StreamEncapsulationException;
20: use Salient\Contract\Http\Message\MultipartStreamInterface;
21: use Salient\Contract\Http\Message\ResponseInterface;
22: use Salient\Contract\Http\CredentialInterface;
23: use Salient\Contract\Http\HeadersInterface;
24: use Salient\Contract\Http\UriInterface;
25: use Salient\Core\Concern\BuildableTrait;
26: use Salient\Core\Concern\ImmutableTrait;
27: use Salient\Core\Facade\Cache;
28: use Salient\Core\Facade\Console;
29: use Salient\Core\Facade\Event;
30: use Salient\Curler\Event\CurlRequestEvent;
31: use Salient\Curler\Event\CurlResponseEvent;
32: use Salient\Curler\Event\ResponseCacheHitEvent;
33: use Salient\Curler\Exception\CurlErrorException;
34: use Salient\Curler\Exception\HttpErrorException;
35: use Salient\Curler\Exception\NetworkException;
36: use Salient\Curler\Exception\RequestException;
37: use Salient\Curler\Exception\TooManyRedirectsException;
38: use Salient\Http\Exception\InvalidHeaderException;
39: use Salient\Http\Message\Request;
40: use Salient\Http\Message\Response;
41: use Salient\Http\Message\Stream;
42: use Salient\Http\HasInnerHeadersTrait;
43: use Salient\Http\Headers;
44: use Salient\Http\HttpUtil;
45: use Salient\Http\Uri;
46: use Salient\Utility\Arr;
47: use Salient\Utility\File;
48: use Salient\Utility\Get;
49: use Salient\Utility\Inflect;
50: use Salient\Utility\Json;
51: use Salient\Utility\Str;
52: use Closure;
53: use CurlHandle;
54: use InvalidArgumentException;
55: use LogicException;
56: use OutOfRangeException;
57: use RuntimeException;
58: use Stringable;
59: use Throwable;
60:
61: /**
62: * An HTTP client optimised for exchanging data with RESTful API endpoints
63: *
64: * @api
65: *
66: * @implements Buildable<CurlerBuilder>
67: */
68: class Curler implements CurlerInterface, Buildable
69: {
70: /** @use BuildableTrait<CurlerBuilder> */
71: use BuildableTrait;
72: use HasInnerHeadersTrait;
73: use ImmutableTrait;
74:
75: /**
76: * Limit input strings to 2MiB
77: *
78: * The underlying limit, `CURL_MAX_INPUT_LENGTH`, is 8MB.
79: */
80: protected const MAX_INPUT_LENGTH = 2 * 1024 ** 2;
81:
82: protected const REQUEST_METHOD_HAS_BODY = [
83: Curler::METHOD_POST => true,
84: Curler::METHOD_PUT => true,
85: Curler::METHOD_PATCH => true,
86: Curler::METHOD_DELETE => true,
87: ];
88:
89: protected Uri $Uri;
90: protected ?CredentialInterface $Credential = null;
91: protected string $CredentialHeaderName;
92: /** @var array<string,true> */
93: protected array $SensitiveHeaders;
94: protected ?string $MediaType = null;
95: protected ?string $UserAgent = null;
96: protected bool $ExpectJson = true;
97: protected bool $PostJson = true;
98: protected ?DateFormatterInterface $DateFormatter = null;
99: /** @var int-mask-of<Curler::DATA_*> */
100: protected int $FormDataFlags = Curler::DATA_PRESERVE_NUMERIC_KEYS | Curler::DATA_PRESERVE_STRING_KEYS;
101: /** @var int-mask-of<\JSON_BIGINT_AS_STRING|\JSON_INVALID_UTF8_IGNORE|\JSON_INVALID_UTF8_SUBSTITUTE|\JSON_OBJECT_AS_ARRAY|\JSON_THROW_ON_ERROR> */
102: protected int $JsonDecodeFlags = \JSON_OBJECT_AS_ARRAY;
103: /** @var array<array{CurlerMiddlewareInterface|Closure(PsrRequestInterface $request, Closure(PsrRequestInterface): ResponseInterface $next, CurlerInterface $curler): PsrResponseInterface,string|null}> */
104: protected array $Middleware = [];
105: protected ?CurlerPagerInterface $Pager = null;
106: protected bool $AlwaysPaginate = false;
107: protected ?CacheInterface $Cache = null;
108: protected ?string $CookiesCacheKey = null;
109: protected bool $CacheResponses = false;
110: protected bool $CachePostResponses = false;
111: /** @var (callable(PsrRequestInterface $request, CurlerInterface $curler): (string[]|string))|null */
112: protected $CacheKeyCallback = null;
113: /** @var int<-1,max> */
114: protected int $CacheLifetime = 3600;
115: protected bool $RefreshCache = false;
116: /** @var int<0,max>|null */
117: protected ?int $Timeout = null;
118: protected bool $FollowRedirects = false;
119: /** @var int<-1,max>|null */
120: protected ?int $MaxRedirects = null;
121: protected bool $RetryAfterTooManyRequests = false;
122: /** @var int<0,max> */
123: protected int $RetryAfterMaxSeconds = 300;
124: protected bool $ThrowHttpErrors = true;
125:
126: // --
127:
128: protected ?PsrRequestInterface $LastRequest = null;
129: protected ?ResponseInterface $LastResponse = null;
130: private ?Curler $WithoutThrowHttpErrors = null;
131: private ?Closure $Closure = null;
132:
133: // --
134:
135: private static string $DefaultUserAgent;
136: /** @var array<string,true> */
137: private static array $UnstableHeaders;
138: /** @var CurlHandle|resource|null */
139: private static $Handle;
140:
141: /**
142: * @api
143: *
144: * @param PsrUriInterface|Stringable|string|null $uri Endpoint URI (cannot have query or fragment components)
145: * @param Arrayable<string,string[]|string>|iterable<string,string[]|string>|null $headers Request headers
146: * @param string[] $sensitiveHeaders Headers treated as sensitive
147: */
148: final public function __construct(
149: $uri = null,
150: $headers = null,
151: array $sensitiveHeaders = Curler::HEADERS_SENSITIVE
152: ) {
153: $this->Uri = $this->filterUri($uri);
154: $this->Headers = $this->filterHeaders($headers);
155: $this->SensitiveHeaders = array_change_key_case(
156: array_fill_keys($sensitiveHeaders, true),
157: );
158: }
159:
160: /**
161: * @internal
162: *
163: * @param PsrUriInterface|Stringable|string|null $uri Endpoint URI (cannot have query or fragment components)
164: * @param Arrayable<string,string[]|string>|iterable<string,string[]|string>|null $headers Request headers
165: * @param CredentialInterface|null $credential Credential applied to request headers
166: * @param string $credentialHeaderName Name of credential header (default: `"Authorization"`)
167: * @param string[] $sensitiveHeaders Headers treated as sensitive (default: {@see Curler::HEADERS_SENSITIVE})
168: * @param string|null $mediaType Media type applied to request headers
169: * @param string|null $userAgent User agent applied to request headers
170: * @param bool $expectJson Explicitly accept JSON-encoded responses and assume responses with no content type contain JSON
171: * @param bool $postJson Use JSON to encode POST/PUT/PATCH/DELETE data
172: * @param DateFormatterInterface|null $dateFormatter Date formatter used to format and parse the endpoint's date and time values
173: * @param int-mask-of<Curler::DATA_*> $formDataFlags Flags used to encode data for query strings and message bodies (default: {@see Curler::DATA_PRESERVE_NUMERIC_KEYS} `|` {@see Curler::DATA_PRESERVE_STRING_KEYS})
174: * @param int-mask-of<\JSON_BIGINT_AS_STRING|\JSON_INVALID_UTF8_IGNORE|\JSON_INVALID_UTF8_SUBSTITUTE|\JSON_OBJECT_AS_ARRAY|\JSON_THROW_ON_ERROR> $jsonDecodeFlags Flags used to decode JSON returned by the endpoint (default: {@see \JSON_OBJECT_AS_ARRAY})
175: * @param array<array{CurlerMiddlewareInterface|Closure(PsrRequestInterface $request, Closure(PsrRequestInterface): ResponseInterface $next, CurlerInterface $curler): PsrResponseInterface,1?:string|null}> $middleware Middleware applied to the request handler stack
176: * @param CurlerPagerInterface|null $pager Pagination handler
177: * @param bool $alwaysPaginate Use the pager to process requests even if no pagination is required
178: * @param CacheInterface|null $cache Cache to use for cookie and response storage instead of the global cache
179: * @param bool $handleCookies Enable cookie handling
180: * @param string|null $cookiesCacheKey Key to cache cookies under (cookie handling is implicitly enabled if given)
181: * @param bool $cacheResponses Cache responses to GET and HEAD requests (HTTP caching headers are ignored; USE RESPONSIBLY)
182: * @param bool $cachePostResponses Cache responses to repeatable POST requests (ignored if GET and HEAD request caching is disabled)
183: * @param (callable(PsrRequestInterface $request, CurlerInterface $curler): (string[]|string))|null $cacheKeyCallback Override values hashed and combined with request method and URI to create response cache keys (headers not in {@see Curler::HEADERS_UNSTABLE} are used by default)
184: * @param int<-1,max> $cacheLifetime Seconds before cached responses expire when caching is enabled (`0` = cache indefinitely; `-1` = do not cache; default: `3600`)
185: * @param bool $refreshCache Replace cached responses even if they haven't expired
186: * @param int<0,max>|null $timeout Connection timeout in seconds (`null` = use underlying default of `300` seconds; default: `null`)
187: * @param bool $followRedirects Follow "Location" headers
188: * @param int<-1,max>|null $maxRedirects Limit the number of "Location" headers followed (`-1` = unlimited; `0` = do not follow redirects; `null` = use underlying default of `30`; default: `null`)
189: * @param bool $retryAfterTooManyRequests Retry throttled requests when the endpoint returns a "Retry-After" header
190: * @param int<0,max> $retryAfterMaxSeconds Limit the delay between request attempts (`0` = unlimited; default: `300`)
191: * @param bool $throwHttpErrors Throw exceptions for HTTP errors
192: * @return static
193: */
194: public static function create(
195: $uri = null,
196: $headers = null,
197: ?CredentialInterface $credential = null,
198: string $credentialHeaderName = Curler::HEADER_AUTHORIZATION,
199: array $sensitiveHeaders = Curler::HEADERS_SENSITIVE,
200: ?string $mediaType = null,
201: ?string $userAgent = null,
202: bool $expectJson = true,
203: bool $postJson = true,
204: ?DateFormatterInterface $dateFormatter = null,
205: int $formDataFlags = Curler::DATA_PRESERVE_NUMERIC_KEYS | Curler::DATA_PRESERVE_STRING_KEYS,
206: int $jsonDecodeFlags = \JSON_OBJECT_AS_ARRAY,
207: array $middleware = [],
208: ?CurlerPagerInterface $pager = null,
209: bool $alwaysPaginate = false,
210: ?CacheInterface $cache = null,
211: bool $handleCookies = false,
212: ?string $cookiesCacheKey = null,
213: bool $cacheResponses = false,
214: bool $cachePostResponses = false,
215: ?callable $cacheKeyCallback = null,
216: int $cacheLifetime = 3600,
217: bool $refreshCache = false,
218: ?int $timeout = null,
219: bool $followRedirects = false,
220: ?int $maxRedirects = null,
221: bool $retryAfterTooManyRequests = false,
222: int $retryAfterMaxSeconds = 300,
223: bool $throwHttpErrors = true
224: ): self {
225: $curler = new static($uri, $headers, $sensitiveHeaders);
226: $curler->Credential = $credential;
227: if ($credential !== null) {
228: $curler->CredentialHeaderName = $credentialHeaderName;
229: }
230: $curler->MediaType = $mediaType;
231: $curler->UserAgent = $userAgent;
232: $curler->ExpectJson = $expectJson;
233: $curler->PostJson = $postJson;
234: $curler->DateFormatter = $dateFormatter;
235: $curler->FormDataFlags = $formDataFlags;
236: $curler->JsonDecodeFlags = $jsonDecodeFlags;
237: foreach ($middleware as $value) {
238: $curler->Middleware[] = [$value[0], $value[1] ?? null];
239: }
240: $curler->Pager = $pager;
241: $curler->AlwaysPaginate = $pager && $alwaysPaginate;
242: $curler->Cache = $cache;
243: $curler->CookiesCacheKey = $handleCookies || $cookiesCacheKey !== null
244: ? self::filterCookiesCacheKey($cookiesCacheKey)
245: : null;
246: $curler->CacheResponses = $cacheResponses;
247: $curler->CachePostResponses = $cachePostResponses;
248: $curler->CacheKeyCallback = $cacheKeyCallback;
249: $curler->CacheLifetime = $cacheLifetime;
250: $curler->RefreshCache = $refreshCache;
251: $curler->Timeout = $timeout;
252: $curler->FollowRedirects = $followRedirects;
253: $curler->MaxRedirects = $maxRedirects;
254: $curler->RetryAfterTooManyRequests = $retryAfterTooManyRequests;
255: $curler->RetryAfterMaxSeconds = $retryAfterMaxSeconds;
256: $curler->ThrowHttpErrors = $throwHttpErrors;
257: return $curler;
258: }
259:
260: private function __clone()
261: {
262: $this->LastRequest = null;
263: $this->LastResponse = null;
264: $this->WithoutThrowHttpErrors = null;
265: $this->Closure = null;
266: }
267:
268: /**
269: * @inheritDoc
270: */
271: public function getUri(): UriInterface
272: {
273: return $this->Uri;
274: }
275:
276: /**
277: * @inheritDoc
278: */
279: public function getLastRequest(): ?PsrRequestInterface
280: {
281: return $this->LastRequest;
282: }
283:
284: /**
285: * @inheritDoc
286: */
287: public function getLastResponse(): ?ResponseInterface
288: {
289: return $this->LastResponse;
290: }
291:
292: /**
293: * @inheritDoc
294: */
295: public function lastResponseIsJson(): bool
296: {
297: if ($this->LastResponse === null) {
298: throw new OutOfRangeException('No response to check');
299: }
300: return $this->responseIsJson($this->LastResponse);
301: }
302:
303: private function responseIsJson(ResponseInterface $response): bool
304: {
305: $headers = $response->getInnerHeaders();
306: if (!$headers->hasHeader(self::HEADER_CONTENT_TYPE)) {
307: return $this->ExpectJson;
308: }
309: $contentType = $headers->getLastHeaderValue(self::HEADER_CONTENT_TYPE);
310: return HttpUtil::mediaTypeIs($contentType, self::TYPE_JSON);
311: }
312:
313: // --
314:
315: /**
316: * @inheritDoc
317: */
318: public function head(?array $query = null): HeadersInterface
319: {
320: return $this->process(self::METHOD_HEAD, $query);
321: }
322:
323: /**
324: * @inheritDoc
325: */
326: public function get(?array $query = null)
327: {
328: return $this->process(self::METHOD_GET, $query);
329: }
330:
331: /**
332: * @inheritDoc
333: */
334: public function post($data = null, ?array $query = null)
335: {
336: return $this->process(self::METHOD_POST, $query, $data);
337: }
338:
339: /**
340: * @inheritDoc
341: */
342: public function put($data = null, ?array $query = null)
343: {
344: return $this->process(self::METHOD_PUT, $query, $data);
345: }
346:
347: /**
348: * @inheritDoc
349: */
350: public function patch($data = null, ?array $query = null)
351: {
352: return $this->process(self::METHOD_PATCH, $query, $data);
353: }
354:
355: /**
356: * @inheritDoc
357: */
358: public function delete($data = null, ?array $query = null)
359: {
360: return $this->process(self::METHOD_DELETE, $query, $data);
361: }
362:
363: /**
364: * @param mixed[]|null $query
365: * @param mixed[]|object|false|null $data
366: * @return ($method is self::METHOD_HEAD ? HeadersInterface : mixed)
367: */
368: private function process(string $method, ?array $query, $data = false)
369: {
370: $request = $this->createRequest($method, $query, $data);
371: $pager = $this->AlwaysPaginate ? $this->Pager : null;
372: if ($pager) {
373: $request = $pager->getFirstRequest($request, $this, $query);
374: if ($request instanceof CurlerPageRequestInterface) {
375: $query = $request->getQuery() ?? $query;
376: $request = $request->getRequest();
377: }
378: }
379: $response = $this->doSendRequest($request);
380: if ($method === self::METHOD_HEAD) {
381: return $response->getInnerHeaders();
382: }
383: $result = $this->getResponseBody($response);
384: if ($pager) {
385: $page = $pager->getPage($result, $request, $response, $this, null, $query);
386: return Arr::unwrap($page->getEntities(), 1);
387: }
388: return $result;
389: }
390:
391: // --
392:
393: /**
394: * @inheritDoc
395: */
396: public function getP(?array $query = null): iterable
397: {
398: return $this->paginate(self::METHOD_GET, $query);
399: }
400:
401: /**
402: * @inheritDoc
403: */
404: public function postP($data = null, ?array $query = null): iterable
405: {
406: return $this->paginate(self::METHOD_POST, $query, $data);
407: }
408:
409: /**
410: * @inheritDoc
411: */
412: public function putP($data = null, ?array $query = null): iterable
413: {
414: return $this->paginate(self::METHOD_PUT, $query, $data);
415: }
416:
417: /**
418: * @inheritDoc
419: */
420: public function patchP($data = null, ?array $query = null): iterable
421: {
422: return $this->paginate(self::METHOD_PATCH, $query, $data);
423: }
424:
425: /**
426: * @inheritDoc
427: */
428: public function deleteP($data = null, ?array $query = null): iterable
429: {
430: return $this->paginate(self::METHOD_DELETE, $query, $data);
431: }
432:
433: /**
434: * @param mixed[]|null $query
435: * @param mixed[]|object|false|null $data
436: * @return iterable<mixed>
437: */
438: private function paginate(string $method, ?array $query, $data = false): iterable
439: {
440: if ($this->Pager === null) {
441: throw new LogicException('No pager');
442: }
443: $pager = $this->Pager;
444: $request = $this->createRequest($method, $query, $data);
445: $request = $pager->getFirstRequest($request, $this, $query);
446: $prev = null;
447: do {
448: if ($request instanceof CurlerPageRequestInterface) {
449: $query = $request->getQuery() ?? $query;
450: $request = $request->getRequest();
451: }
452: $response = $this->doSendRequest($request);
453: $result = $this->getResponseBody($response);
454: $page = $pager->getPage($result, $request, $response, $this, $prev, $query);
455: // Use `yield` instead of `yield from` so entities get unique keys
456: foreach ($page->getEntities() as $entity) {
457: yield $entity;
458: }
459: if (!$page->hasNextRequest()) {
460: return;
461: }
462: $request = $page->getNextRequest();
463: $prev = $page;
464: } while (true);
465: }
466:
467: // --
468:
469: /**
470: * @inheritDoc
471: */
472: public function postR(string $data, string $mediaType, ?array $query = null)
473: {
474: return $this->processRaw(self::METHOD_POST, $data, $mediaType, $query);
475: }
476:
477: /**
478: * @inheritDoc
479: */
480: public function putR(string $data, string $mediaType, ?array $query = null)
481: {
482: return $this->processRaw(self::METHOD_PUT, $data, $mediaType, $query);
483: }
484:
485: /**
486: * @inheritDoc
487: */
488: public function patchR(string $data, string $mediaType, ?array $query = null)
489: {
490: return $this->processRaw(self::METHOD_PATCH, $data, $mediaType, $query);
491: }
492:
493: /**
494: * @inheritDoc
495: */
496: public function deleteR(string $data, string $mediaType, ?array $query = null)
497: {
498: return $this->processRaw(self::METHOD_DELETE, $data, $mediaType, $query);
499: }
500:
501: /**
502: * @param mixed[]|null $query
503: * @return mixed
504: */
505: private function processRaw(string $method, string $data, string $mediaType, ?array $query)
506: {
507: $request = $this->createRequest($method, $query, $data);
508: $request = $request->withHeader(self::HEADER_CONTENT_TYPE, $mediaType);
509: /** @disregard P1006 */
510: $response = $this->doSendRequest($request);
511: return $this->getResponseBody($response);
512: }
513:
514: // --
515:
516: /**
517: * @inheritDoc
518: */
519: public function flushCookies()
520: {
521: if ($this->CookiesCacheKey !== null) {
522: $this->getCacheInstance()->delete($this->CookiesCacheKey);
523: }
524: return $this;
525: }
526:
527: /**
528: * @inheritDoc
529: */
530: public function replaceQuery($value, array $query)
531: {
532: return HttpUtil::replaceQuery(
533: $value,
534: $query,
535: $this->FormDataFlags,
536: $this->DateFormatter,
537: );
538: }
539:
540: // --
541:
542: /**
543: * @inheritDoc
544: */
545: public function getInnerHeaders(): HeadersInterface
546: {
547: $headers = $this->Headers;
548: if ($this->Credential !== null) {
549: $headers = $headers->authorize($this->Credential, $this->CredentialHeaderName);
550: }
551: if ($this->MediaType !== null) {
552: $headers = $headers->set(self::HEADER_CONTENT_TYPE, $this->MediaType);
553: }
554: if ($this->ExpectJson) {
555: $headers = $headers->set(self::HEADER_ACCEPT, self::TYPE_JSON);
556: }
557: if ($this->UserAgent !== '' && (
558: $this->UserAgent !== null
559: || !$headers->hasHeader(self::HEADER_USER_AGENT)
560: )) {
561: $headers = $headers->set(
562: self::HEADER_USER_AGENT,
563: $this->UserAgent ?? $this->getDefaultUserAgent(),
564: );
565: }
566: return $headers;
567: }
568:
569: /**
570: * @inheritDoc
571: */
572: public function getPublicHeaders(): HeadersInterface
573: {
574: $sensitive = $this->SensitiveHeaders;
575: if ($this->Credential !== null) {
576: $sensitive[Str::lower($this->CredentialHeaderName)] = true;
577: }
578: return $this->getInnerHeaders()->exceptIn($sensitive);
579: }
580:
581: /**
582: * @inheritDoc
583: */
584: public function hasCredential(): bool
585: {
586: return $this->Credential !== null;
587: }
588:
589: /**
590: * @inheritDoc
591: */
592: public function isSensitiveHeader(string $name): bool
593: {
594: return isset($this->SensitiveHeaders[Str::lower($name)]);
595: }
596:
597: /**
598: * @inheritDoc
599: */
600: public function getMediaType(): ?string
601: {
602: return $this->MediaType;
603: }
604:
605: /**
606: * @inheritDoc
607: */
608: public function hasUserAgent(): bool
609: {
610: return $this->UserAgent !== null;
611: }
612:
613: /**
614: * @inheritDoc
615: */
616: public function getUserAgent(): string
617: {
618: return $this->UserAgent ?? $this->getDefaultUserAgent();
619: }
620:
621: /**
622: * @inheritDoc
623: */
624: public function expectsJson(): bool
625: {
626: return $this->ExpectJson;
627: }
628:
629: /**
630: * @inheritDoc
631: */
632: public function postsJson(): bool
633: {
634: return $this->PostJson;
635: }
636:
637: /**
638: * @inheritDoc
639: */
640: public function getDateFormatter(): ?DateFormatterInterface
641: {
642: return $this->DateFormatter;
643: }
644:
645: /**
646: * @inheritDoc
647: */
648: public function getFormDataFlags(): int
649: {
650: return $this->FormDataFlags;
651: }
652:
653: /**
654: * @inheritDoc
655: */
656: public function getPager(): ?CurlerPagerInterface
657: {
658: return $this->Pager;
659: }
660:
661: /**
662: * @inheritDoc
663: */
664: public function alwaysPaginates(): bool
665: {
666: return $this->AlwaysPaginate;
667: }
668:
669: /**
670: * @inheritDoc
671: */
672: public function getCache(): ?CacheInterface
673: {
674: return $this->Cache;
675: }
676:
677: /**
678: * @inheritDoc
679: */
680: public function hasCookies(): bool
681: {
682: return $this->CookiesCacheKey !== null;
683: }
684:
685: /**
686: * @inheritDoc
687: */
688: public function hasResponseCache(): bool
689: {
690: return $this->CacheResponses;
691: }
692:
693: /**
694: * @inheritDoc
695: */
696: public function hasPostResponseCache(): bool
697: {
698: return $this->CachePostResponses;
699: }
700:
701: /**
702: * @inheritDoc
703: */
704: public function getCacheLifetime(): int
705: {
706: return $this->CacheLifetime;
707: }
708:
709: /**
710: * @inheritDoc
711: */
712: public function refreshesCache(): bool
713: {
714: return $this->RefreshCache;
715: }
716:
717: /**
718: * @inheritDoc
719: */
720: public function getTimeout(): ?int
721: {
722: return $this->Timeout;
723: }
724:
725: /**
726: * @inheritDoc
727: */
728: public function followsRedirects(): bool
729: {
730: return $this->FollowRedirects;
731: }
732:
733: /**
734: * @inheritDoc
735: */
736: public function getMaxRedirects(): ?int
737: {
738: return $this->MaxRedirects;
739: }
740:
741: /**
742: * @inheritDoc
743: */
744: public function getRetryAfterTooManyRequests(): bool
745: {
746: return $this->RetryAfterTooManyRequests;
747: }
748:
749: /**
750: * @inheritDoc
751: */
752: public function getRetryAfterMaxSeconds(): int
753: {
754: return $this->RetryAfterMaxSeconds;
755: }
756:
757: /**
758: * @inheritDoc
759: */
760: public function throwsHttpErrors(): bool
761: {
762: return $this->ThrowHttpErrors;
763: }
764:
765: // --
766:
767: /**
768: * @inheritDoc
769: */
770: public function withUri($uri)
771: {
772: return (string) $uri === (string) $this->Uri
773: ? $this
774: : $this->with('Uri', $this->filterUri($uri));
775: }
776:
777: /**
778: * @inheritDoc
779: */
780: public function withRequest(PsrRequestInterface $request)
781: {
782: $curler = $this->withUri($request->getUri());
783:
784: $headers = Headers::from($request);
785: $_headers = $this->getInnerHeaders();
786: if (Arr::same($headers->all(), $_headers->all())) {
787: return $curler;
788: }
789:
790: if ($this->Credential !== null) {
791: $header = $headers->getHeader($this->CredentialHeaderName);
792: $_header = $_headers->getHeader($this->CredentialHeaderName);
793: if ($header !== $_header) {
794: $curler = $curler->withCredential(null);
795: }
796: }
797:
798: $mediaType = Arr::last($headers->getHeaderValues(self::HEADER_CONTENT_TYPE));
799: $userAgent = Arr::last($headers->getHeaderValues(self::HEADER_USER_AGENT));
800: $expectJson = Arr::lower($headers->getHeaderValues(self::HEADER_ACCEPT)) === [self::TYPE_JSON];
801: if ($userAgent !== null && $userAgent === $this->getDefaultUserAgent()) {
802: $userAgent = null;
803: }
804: return $curler
805: ->withMediaType($mediaType)
806: ->withUserAgent($userAgent)
807: ->withExpectJson($expectJson)
808: ->with('Headers', $headers);
809: }
810:
811: /**
812: * @inheritDoc
813: */
814: public function withCredential(
815: ?CredentialInterface $credential,
816: string $headerName = Curler::HEADER_AUTHORIZATION
817: ) {
818: return $credential === null
819: ? $this
820: ->with('Credential', null)
821: ->without('CredentialHeaderName')
822: : $this
823: ->with('Credential', $credential)
824: ->with('CredentialHeaderName', $headerName);
825: }
826:
827: /**
828: * @inheritDoc
829: */
830: public function withSensitiveHeader(string $name)
831: {
832: return $this->with(
833: 'SensitiveHeaders',
834: Arr::set($this->SensitiveHeaders, Str::lower($name), true)
835: );
836: }
837:
838: /**
839: * @inheritDoc
840: */
841: public function withoutSensitiveHeader(string $name)
842: {
843: return $this->with(
844: 'SensitiveHeaders',
845: Arr::unset($this->SensitiveHeaders, Str::lower($name))
846: );
847: }
848:
849: /**
850: * @inheritDoc
851: */
852: public function withMediaType(?string $type)
853: {
854: return $this->with('MediaType', $type);
855: }
856:
857: /**
858: * @inheritDoc
859: */
860: public function withUserAgent(?string $userAgent)
861: {
862: return $this->with('UserAgent', $userAgent);
863: }
864:
865: /**
866: * @inheritDoc
867: */
868: public function withExpectJson(bool $expectJson = true)
869: {
870: return $this->with('ExpectJson', $expectJson);
871: }
872:
873: /**
874: * @inheritDoc
875: */
876: public function withPostJson(bool $postJson = true)
877: {
878: return $this->with('PostJson', $postJson);
879: }
880:
881: /**
882: * @inheritDoc
883: */
884: public function withDateFormatter(?DateFormatterInterface $formatter)
885: {
886: return $this->with('DateFormatter', $formatter);
887: }
888:
889: /**
890: * @inheritDoc
891: */
892: public function withFormDataFlags(int $flags)
893: {
894: return $this->with('FormDataFlags', $flags);
895: }
896:
897: /**
898: * @inheritDoc
899: */
900: public function withJsonDecodeFlags(int $flags)
901: {
902: return $this->with('JsonDecodeFlags', $flags);
903: }
904:
905: /**
906: * @inheritDoc
907: */
908: public function withMiddleware($middleware, ?string $name = null)
909: {
910: return $this->with(
911: 'Middleware',
912: Arr::push($this->Middleware, [$middleware, $name])
913: );
914: }
915:
916: /**
917: * @inheritDoc
918: */
919: public function withoutMiddleware($middleware)
920: {
921: $index = is_string($middleware) ? 1 : 0;
922: $values = $this->Middleware;
923: foreach ($values as $key => $value) {
924: if ($middleware === $value[$index]) {
925: unset($values[$key]);
926: }
927: }
928: return $this->with('Middleware', $values);
929: }
930:
931: /**
932: * @inheritDoc
933: */
934: public function withPager(?CurlerPagerInterface $pager, bool $alwaysPaginate = false)
935: {
936: return $this
937: ->with('Pager', $pager)
938: ->with('AlwaysPaginate', $pager && $alwaysPaginate);
939: }
940:
941: /**
942: * @inheritDoc
943: */
944: public function withCache(?CacheInterface $cache = null)
945: {
946: return $this->with('Cache', $cache);
947: }
948:
949: /**
950: * @inheritDoc
951: */
952: public function withCookies(?string $cacheKey = null)
953: {
954: return $this->with('CookiesCacheKey', $this->filterCookiesCacheKey($cacheKey));
955: }
956:
957: /**
958: * @inheritDoc
959: */
960: public function withoutCookies()
961: {
962: return $this->with('CookiesCacheKey', null);
963: }
964:
965: /**
966: * @inheritDoc
967: */
968: public function withResponseCache(bool $cacheResponses = true)
969: {
970: return $this->with('CacheResponses', $cacheResponses);
971: }
972:
973: /**
974: * @inheritDoc
975: */
976: public function withPostResponseCache(bool $cachePostResponses = true)
977: {
978: return $this->with('CachePostResponses', $cachePostResponses);
979: }
980:
981: /**
982: * @inheritDoc
983: */
984: public function withCacheKeyCallback(?callable $callback)
985: {
986: return $this->with('CacheKeyCallback', $callback);
987: }
988:
989: /**
990: * @inheritDoc
991: */
992: public function withCacheLifetime(int $seconds)
993: {
994: return $this->with('CacheLifetime', $seconds);
995: }
996:
997: /**
998: * @inheritDoc
999: */
1000: public function withRefreshCache(bool $refresh = true)
1001: {
1002: return $this->with('RefreshCache', $refresh);
1003: }
1004:
1005: /**
1006: * @inheritDoc
1007: */
1008: public function withTimeout(?int $seconds)
1009: {
1010: return $this->with('Timeout', $seconds);
1011: }
1012:
1013: /**
1014: * @inheritDoc
1015: */
1016: public function withFollowRedirects(bool $follow = true)
1017: {
1018: return $this->with('FollowRedirects', $follow);
1019: }
1020:
1021: /**
1022: * @inheritDoc
1023: */
1024: public function withMaxRedirects(?int $redirects)
1025: {
1026: return $this->with('MaxRedirects', $redirects);
1027: }
1028:
1029: /**
1030: * @inheritDoc
1031: */
1032: public function withRetryAfterTooManyRequests(bool $retry = true)
1033: {
1034: return $this->with('RetryAfterTooManyRequests', $retry);
1035: }
1036:
1037: /**
1038: * @inheritDoc
1039: */
1040: public function withRetryAfterMaxSeconds(int $seconds)
1041: {
1042: return $this->with('RetryAfterMaxSeconds', $seconds);
1043: }
1044:
1045: /**
1046: * @inheritDoc
1047: */
1048: public function withThrowHttpErrors(bool $throw = true)
1049: {
1050: return $this->with('ThrowHttpErrors', $throw);
1051: }
1052:
1053: // --
1054:
1055: /**
1056: * @inheritDoc
1057: */
1058: public function sendRequest(PsrRequestInterface $request): ResponseInterface
1059: {
1060: // PSR-18: "A Client MUST NOT treat a well-formed HTTP request or HTTP
1061: // response as an error condition. For example, response status codes in
1062: // the 400 and 500 range MUST NOT cause an exception and MUST be
1063: // returned to the Calling Library as normal."
1064: $curler = $this->WithoutThrowHttpErrors ??= $this->withThrowHttpErrors(false);
1065: try {
1066: try {
1067: return $curler->doSendRequest($request);
1068: } finally {
1069: $this->LastRequest = $curler->LastRequest;
1070: $this->LastResponse = $curler->LastResponse;
1071: }
1072: } catch (PsrClientExceptionInterface $ex) {
1073: throw $ex;
1074: } catch (CurlErrorExceptionInterface $ex) {
1075: throw $ex->isNetworkError()
1076: ? new NetworkException($ex->getMessage(), $this->LastRequest ?? $request, [], $ex)
1077: : new RequestException($ex->getMessage(), $this->LastRequest ?? $request, [], $ex);
1078: } catch (Throwable $ex) {
1079: throw new RequestException($ex->getMessage(), $this->LastRequest ?? $request, [], $ex);
1080: }
1081: }
1082:
1083: /**
1084: * @phpstan-assert !null $this->LastRequest
1085: * @phpstan-assert !null $this->LastResponse
1086: */
1087: private function doSendRequest(PsrRequestInterface $request): ResponseInterface
1088: {
1089: $this->LastRequest = null;
1090: $this->LastResponse = null;
1091: return $this->Middleware
1092: ? ($this->Closure ??= $this->getClosure())($request)
1093: : $this->getResponse($request);
1094: }
1095:
1096: /**
1097: * @return Closure(PsrRequestInterface): ResponseInterface
1098: */
1099: private function getClosure(): Closure
1100: {
1101: $closure = fn(PsrRequestInterface $request): ResponseInterface =>
1102: $this->getResponse($request);
1103: foreach (array_reverse($this->Middleware) as [$middleware]) {
1104: $closure = $middleware instanceof CurlerMiddlewareInterface
1105: ? fn(PsrRequestInterface $request): ResponseInterface =>
1106: $this->handleHttpResponse($middleware($request, $closure, $this), $request)
1107: : fn(PsrRequestInterface $request): ResponseInterface =>
1108: $this->handleResponse($middleware($request, $closure, $this), $request);
1109: }
1110: return $closure;
1111: }
1112:
1113: private function getResponse(PsrRequestInterface $request): ResponseInterface
1114: {
1115: $uri = $request->getUri()->withFragment('');
1116: $request = $request->withUri($uri);
1117:
1118: $version = (int) ((float) $request->getProtocolVersion() * 10);
1119: $opt[\CURLOPT_HTTP_VERSION] = [
1120: 10 => \CURL_HTTP_VERSION_1_0,
1121: 11 => \CURL_HTTP_VERSION_1_1,
1122: 20 => \CURL_HTTP_VERSION_2_0,
1123: ][$version] ?? \CURL_HTTP_VERSION_NONE;
1124:
1125: $method = $request->getMethod();
1126: $body = $request->getBody();
1127: $opt[\CURLOPT_CUSTOMREQUEST] = $method;
1128: if ($method === self::METHOD_HEAD) {
1129: $opt[\CURLOPT_NOBODY] = true;
1130: $size = 0;
1131: } else {
1132: $size = $body->getSize();
1133: }
1134:
1135: if ($size === null || $size > 0) {
1136: $size ??= HttpUtil::getContentLength($request);
1137: if ($size !== null && $size <= static::MAX_INPUT_LENGTH) {
1138: $body = (string) $body;
1139: $opt[\CURLOPT_POSTFIELDS] = $body;
1140: $request = $request
1141: ->withoutHeader(self::HEADER_CONTENT_LENGTH)
1142: ->withoutHeader(self::HEADER_TRANSFER_ENCODING);
1143: } else {
1144: $opt[\CURLOPT_UPLOAD] = true;
1145: if ($size !== null) {
1146: $opt[\CURLOPT_INFILESIZE] = $size;
1147: $request = $request
1148: ->withoutHeader(self::HEADER_CONTENT_LENGTH);
1149: }
1150: $opt[\CURLOPT_READFUNCTION] =
1151: static function ($handle, $infile, int $length) use ($body): string {
1152: return $body->read($length);
1153: };
1154: if ($body->isSeekable()) {
1155: $body->rewind();
1156: }
1157: }
1158: } elseif (self::REQUEST_METHOD_HAS_BODY[$method] ?? false) {
1159: // [RFC9110], Section 8.6 ("Content-Length"): "A user agent SHOULD
1160: // send Content-Length in a request when the method defines a
1161: // meaning for enclosed content and it is not sending
1162: // Transfer-Encoding. For example, a user agent normally sends
1163: // Content-Length in a POST request even when the value is 0
1164: // (indicating empty content)"
1165: $request = $request->withHeader(self::HEADER_CONTENT_LENGTH, '0');
1166: }
1167:
1168: if ($this->Timeout !== null) {
1169: $opt[\CURLOPT_CONNECTTIMEOUT] = $this->Timeout;
1170: }
1171:
1172: if (!$request->hasHeader(self::HEADER_ACCEPT_ENCODING)) {
1173: // Enable all supported encodings (e.g. gzip, deflate) and set
1174: // `Accept-Encoding` accordingly
1175: $opt[\CURLOPT_ENCODING] = '';
1176: }
1177:
1178: /** @var string|null */
1179: $statusLine = null;
1180: /** @var Headers|null */
1181: $headersIn = null;
1182: $opt[\CURLOPT_HEADERFUNCTION] =
1183: static function ($handle, string $header) use (&$statusLine, &$headersIn): int {
1184: if (substr($header, 0, 5) === 'HTTP/') {
1185: $statusLine = rtrim($header, "\r\n");
1186: $headersIn = new Headers();
1187: return strlen($header);
1188: }
1189: if ($headersIn === null) {
1190: throw new InvalidHeaderException('No status line in HTTP response');
1191: }
1192: $headersIn = $headersIn->addLine($header);
1193: return strlen($header);
1194: };
1195:
1196: /** @var Stream|null */
1197: $bodyIn = null;
1198: $opt[\CURLOPT_WRITEFUNCTION] =
1199: static function ($handle, string $data) use (&$bodyIn): int {
1200: /** @var Stream $bodyIn */
1201: return $bodyIn->write($data);
1202: };
1203:
1204: if (self::$Handle === null) {
1205: $handle = curl_init((string) $uri);
1206: if ($handle === false) {
1207: throw new RuntimeException('curl_init() failed');
1208: }
1209: self::$Handle = $handle;
1210: $resetHandle = false;
1211: } else {
1212: $opt[\CURLOPT_URL] = (string) $uri;
1213: $resetHandle = true;
1214: }
1215:
1216: $request = $this->normaliseRequest($request, $uri);
1217: $headers = Headers::from($request);
1218:
1219: $cacheKey = null;
1220: $transfer = 0;
1221: $redirects = $this->FollowRedirects && $this->MaxRedirects !== 0
1222: ? $this->MaxRedirects ?? 30
1223: : false;
1224: $retrying = false;
1225: do {
1226: if ($cacheKey === null) {
1227: if (
1228: $this->CacheResponses
1229: && ($size === 0 || is_string($body))
1230: && ([
1231: self::METHOD_GET => true,
1232: self::METHOD_HEAD => true,
1233: self::METHOD_POST => $this->CachePostResponses,
1234: ][$method] ?? false)
1235: ) {
1236: $cacheKey = $this->CacheKeyCallback !== null
1237: ? (array) ($this->CacheKeyCallback)($request, $this)
1238: : $headers->exceptIn($this->getUnstableHeaders())->getLines('%s:%s');
1239:
1240: if ($size !== 0 || $method === self::METHOD_POST) {
1241: $cacheKey[] = $size === 0 ? '' : $body;
1242: }
1243:
1244: $cacheUri = $uri->getPath() === ''
1245: ? $uri->withPath('/')
1246: : $uri;
1247: $cacheKey = implode(':', [
1248: self::class,
1249: 'response',
1250: $method,
1251: rawurlencode((string) $cacheUri),
1252: Get::hash(implode("\0", $cacheKey)),
1253: ]);
1254: } else {
1255: $cacheKey = false;
1256: }
1257: }
1258:
1259: if (
1260: $cacheKey !== false
1261: && !$this->RefreshCache
1262: && ($last = $this->getCacheInstance()->getArray($cacheKey)) !== null
1263: ) {
1264: /** @var array{code:int,body:string,headers:array<array{name:string,value:string}>|Headers,reason:string|null,version:string}|array{int,string,Headers,string} $last */
1265: $code = $last['code'] ?? $last[0] ?? 200;
1266: $bodyIn = Stream::fromString($last['body'] ?? $last[3] ?? '');
1267: $lastHeaders = $last['headers'] ?? $last[2] ?? null;
1268: if (is_array($lastHeaders)) {
1269: $lastHeaders = HttpUtil::getNameValuePairs($lastHeaders);
1270: }
1271: $headersIn = Headers::from($lastHeaders ?? []);
1272: $reason = $last['reason'] ?? $last[1] ?? null;
1273: $version = $last['version'] ?? '1.1';
1274: $response = new Response($code, $bodyIn, $headersIn, $reason, $version);
1275: Event::dispatch(new ResponseCacheHitEvent($this, $request, $response));
1276: } else {
1277: if ($transfer) {
1278: if ($size !== 0 && $body instanceof PsrStreamInterface) {
1279: if (!$body->isSeekable()) {
1280: throw new RequestException(
1281: 'Request cannot be sent again (body not seekable)',
1282: $request,
1283: );
1284: }
1285: $body->rewind();
1286: }
1287: $statusLine = null;
1288: $headersIn = null;
1289: }
1290: $bodyIn = new Stream(File::open('php://temp', 'r+'));
1291:
1292: if ($resetHandle || !$transfer) {
1293: if ($resetHandle) {
1294: curl_reset(self::$Handle);
1295: $resetHandle = false;
1296: }
1297: $opt[\CURLOPT_HTTPHEADER] = $headers->getLines('%s: %s', '%s;');
1298: curl_setopt_array(self::$Handle, $opt);
1299:
1300: if ($this->CookiesCacheKey !== null) {
1301: // "If the name is an empty string, no cookies are loaded,
1302: // but cookie handling is still enabled"
1303: curl_setopt(self::$Handle, \CURLOPT_COOKIEFILE, '');
1304: /** @var non-empty-string[] */
1305: $cookies = $this->getCacheInstance()->getArray($this->CookiesCacheKey);
1306: if ($cookies) {
1307: foreach ($cookies as $cookie) {
1308: curl_setopt(self::$Handle, \CURLOPT_COOKIELIST, $cookie);
1309: }
1310: }
1311: }
1312: }
1313:
1314: $transfer++;
1315:
1316: Event::dispatch(new CurlRequestEvent($this, self::$Handle, $request));
1317: $result = curl_exec(self::$Handle);
1318: if ($result === false) {
1319: throw new CurlErrorException(curl_errno(self::$Handle), $request, $this->getCurlInfo());
1320: }
1321:
1322: if (
1323: $statusLine === null
1324: || count($split = explode(' ', $statusLine, 3)) < 2
1325: || ($version = explode('/', $split[0])[1] ?? null) === null
1326: ) {
1327: // @codeCoverageIgnoreStart
1328: throw new InvalidHeaderException(sprintf(
1329: 'HTTP status line invalid or not in response: %s',
1330: rtrim((string) $statusLine, "\r\n"),
1331: ));
1332: // @codeCoverageIgnoreEnd
1333: }
1334:
1335: /** @var Headers $headersIn */
1336: $code = (int) $split[1];
1337: $reason = $split[2] ?? null;
1338: $response = new Response($code, $bodyIn, $headersIn, $reason, $version);
1339: Event::dispatch(new CurlResponseEvent($this, self::$Handle, $request, $response));
1340:
1341: if ($this->CookiesCacheKey !== null) {
1342: $this->getCacheInstance()->set(
1343: $this->CookiesCacheKey,
1344: curl_getinfo(self::$Handle, \CURLINFO_COOKIELIST)
1345: );
1346: }
1347:
1348: if ($cacheKey !== false && $this->CacheLifetime >= 0 && $code < 400) {
1349: $ttl = $this->CacheLifetime === 0
1350: ? null
1351: : $this->CacheLifetime;
1352: $this->getCacheInstance()->set($cacheKey, [
1353: 'code' => $code,
1354: 'body' => (string) $bodyIn,
1355: 'headers' => $headersIn->jsonSerialize(),
1356: 'reason' => $reason,
1357: 'version' => $version,
1358: ], $ttl);
1359: }
1360: }
1361:
1362: if (
1363: $redirects !== false
1364: && $code >= 300
1365: && $code < 400
1366: && ($location = $headersIn->getOnlyHeaderValue(self::HEADER_LOCATION)) !== ''
1367: ) {
1368: if (!$redirects) {
1369: throw new TooManyRedirectsException(sprintf(
1370: 'Redirect limit exceeded: %d',
1371: $this->MaxRedirects,
1372: ), $request, $response);
1373: }
1374: $uri = Uri::from($uri)->follow($location)->withFragment('');
1375: $request = $request->withUri($uri);
1376: // Match cURL's behaviour
1377: if (($code === 301 || $code === 302 || $code === 303) && (
1378: $size !== 0
1379: || (self::REQUEST_METHOD_HAS_BODY[$method] ?? false)
1380: )) {
1381: $method = self::METHOD_GET;
1382: $body = Stream::fromString('');
1383: $request = $request
1384: ->withMethod($method)
1385: ->withBody($body)
1386: ->withoutHeader(self::HEADER_CONTENT_LENGTH)
1387: ->withoutHeader(self::HEADER_TRANSFER_ENCODING);
1388: $size = 0;
1389: $opt[\CURLOPT_CUSTOMREQUEST] = $method;
1390: $opt[\CURLOPT_URL] = (string) $uri;
1391: unset(
1392: $opt[\CURLOPT_POSTFIELDS],
1393: $opt[\CURLOPT_UPLOAD],
1394: $opt[\CURLOPT_INFILESIZE],
1395: $opt[\CURLOPT_READFUNCTION],
1396: );
1397: $resetHandle = true;
1398: } else {
1399: curl_setopt(
1400: self::$Handle,
1401: \CURLOPT_URL,
1402: // @phpstan-ignore argument.type
1403: $opt[\CURLOPT_URL] = (string) $uri,
1404: );
1405: }
1406: $request = $this->normaliseRequest($request, $uri);
1407: $headers = Headers::from($request);
1408: $cacheKey = null;
1409: $redirects--;
1410: $retrying = false;
1411: continue;
1412: }
1413:
1414: if (
1415: !$this->RetryAfterTooManyRequests
1416: || $retrying
1417: || $code !== 429
1418: || ($after = HttpUtil::getRetryAfter($headersIn)) === null
1419: || ($this->RetryAfterMaxSeconds !== 0 && $after > $this->RetryAfterMaxSeconds)
1420: ) {
1421: break;
1422: }
1423:
1424: $after = max(1, $after);
1425: $this->debug(Inflect::format($after, 'Sleeping for {{#}} {{#:second}}'));
1426: sleep($after);
1427: $retrying = true;
1428: } while (true);
1429:
1430: $this->LastRequest = $request;
1431: $this->LastResponse = $response;
1432:
1433: if ($this->ThrowHttpErrors && $code >= 400) {
1434: throw new HttpErrorException($request, $response, $this->getCurlInfo());
1435: }
1436:
1437: return $response;
1438: }
1439:
1440: private function normaliseRequest(
1441: PsrRequestInterface $request,
1442: PsrUriInterface $uri
1443: ): PsrRequestInterface {
1444: // Remove `Host` if its value is redundant
1445: if (($host = $request->getHeaderLine(self::HEADER_HOST)) !== '') {
1446: try {
1447: $host = new Uri("//$host");
1448: } catch (InvalidArgumentException $ex) {
1449: throw new InvalidHeaderException(sprintf(
1450: 'Invalid value for HTTP request header %s: %s',
1451: self::HEADER_HOST,
1452: $host,
1453: ), $ex);
1454: }
1455: $host = $host->withScheme($uri->getScheme())->getAuthority();
1456: if ($host === $uri->withUserInfo('')->getAuthority()) {
1457: $request = $request->withoutHeader(self::HEADER_HOST);
1458: }
1459: }
1460: return $request;
1461: }
1462:
1463: // --
1464:
1465: /**
1466: * @param mixed[]|null $query
1467: * @param mixed[]|object|string|false|null $data
1468: */
1469: private function createRequest(string $method, ?array $query, $data): Request
1470: {
1471: $uri = $this->Uri;
1472: if ($query) {
1473: $uri = $this->replaceQuery($uri, $query);
1474: }
1475: $headers = $this->getInnerHeaders();
1476: $request = new Request($method, $uri, null, $headers);
1477: return $data !== false
1478: ? $this->applyData($request, $data)
1479: : $request;
1480: }
1481:
1482: /**
1483: * @param mixed[]|object|string|null $data
1484: */
1485: private function applyData(Request $request, $data): Request
1486: {
1487: if ($data === null) {
1488: if ($this->PostJson) {
1489: $this->debug(sprintf(
1490: 'JSON message bodies cannot be empty; falling back to %s',
1491: self::TYPE_FORM,
1492: ));
1493: }
1494: return $request->withHeader(self::HEADER_CONTENT_TYPE, self::TYPE_FORM);
1495: }
1496: if (is_string($data)) {
1497: return $request->withBody(Stream::fromString($data));
1498: }
1499: if ($this->PostJson) {
1500: try {
1501: $body = Stream::fromData($data, $this->FormDataFlags, $this->DateFormatter, true);
1502: $mediaType = self::TYPE_JSON;
1503: } catch (StreamEncapsulationException $ex) {
1504: $this->debug(sprintf(
1505: '%s; falling back to %s',
1506: $ex->getMessage(),
1507: self::TYPE_FORM_MULTIPART,
1508: ));
1509: }
1510: }
1511: $body ??= Stream::fromData($data, $this->FormDataFlags, $this->DateFormatter);
1512: $mediaType ??= $body instanceof MultipartStreamInterface
1513: ? HttpUtil::getMultipartMediaType($body)
1514: : self::TYPE_FORM;
1515: return $request
1516: ->withHeader(self::HEADER_CONTENT_TYPE, $mediaType)
1517: ->withBody($body);
1518: }
1519:
1520: /**
1521: * @return mixed
1522: */
1523: private function getResponseBody(ResponseInterface $response)
1524: {
1525: $body = (string) $response->getBody();
1526: return $this->responseIsJson($response)
1527: ? ($body === ''
1528: ? null
1529: : Json::parse($body, $this->JsonDecodeFlags))
1530: : $body;
1531: }
1532:
1533: private function handleResponse(
1534: PsrResponseInterface $response,
1535: PsrRequestInterface $request
1536: ): ResponseInterface {
1537: return $this->handleHttpResponse(
1538: $response instanceof ResponseInterface
1539: ? $response
1540: : Response::fromPsr7($response),
1541: $request,
1542: );
1543: }
1544:
1545: private function handleHttpResponse(
1546: ResponseInterface $response,
1547: PsrRequestInterface $request
1548: ): ResponseInterface {
1549: $this->LastRequest = $request;
1550: $this->LastResponse = $response;
1551: return $response;
1552: }
1553:
1554: /**
1555: * @param PsrUriInterface|Stringable|string|null $uri
1556: */
1557: private function filterUri($uri): Uri
1558: {
1559: if ($uri === null) {
1560: return new Uri();
1561: }
1562: $uri = Uri::from($uri);
1563: $invalid = array_intersect(['query', 'fragment'], array_keys($uri->getComponents()));
1564: if ($invalid) {
1565: throw new InvalidArgumentException(Inflect::format(
1566: $invalid,
1567: 'URI cannot have %s {{#:component}}',
1568: implode(' or ', $invalid),
1569: ));
1570: }
1571: return $uri;
1572: }
1573:
1574: /**
1575: * @param Arrayable<string,string[]|string>|iterable<string,string[]|string>|null $headers
1576: */
1577: private function filterHeaders($headers): HeadersInterface
1578: {
1579: if ($headers instanceof HeadersInterface) {
1580: return $headers;
1581: }
1582: return new Headers($headers ?? []);
1583: }
1584:
1585: private static function filterCookiesCacheKey(?string $cacheKey): string
1586: {
1587: return Arr::implode(':', [self::class, 'cookies', $cacheKey], '');
1588: }
1589:
1590: private function getCacheInstance(): CacheInterface
1591: {
1592: return $this->Cache ?? Cache::getInstance();
1593: }
1594:
1595: private function getDefaultUserAgent(): string
1596: {
1597: return self::$DefaultUserAgent ??= HttpUtil::getProduct();
1598: }
1599:
1600: /**
1601: * @return array<string,true>
1602: */
1603: private function getUnstableHeaders(): array
1604: {
1605: return self::$UnstableHeaders ??= array_fill_keys(self::HEADERS_UNSTABLE, true);
1606: }
1607:
1608: /**
1609: * @return array<string,mixed>
1610: */
1611: private function getCurlInfo(): array
1612: {
1613: if (
1614: self::$Handle === null
1615: || ($info = curl_getinfo(self::$Handle)) === false
1616: ) {
1617: return [];
1618: }
1619: foreach ($info as &$value) {
1620: if (is_string($value)) {
1621: $value = trim($value);
1622: }
1623: }
1624: return $info;
1625: }
1626:
1627: private function debug(string $msg1): void
1628: {
1629: if (Console::isLoaded()) {
1630: Console::debug($msg1);
1631: }
1632: }
1633: }
1634: