├── src └── Httpful │ ├── Exception │ ├── CsvParseException.php │ ├── JsonParseException.php │ ├── ResponseException.php │ ├── XmlParseException.php │ ├── ResponseHeaderException.php │ ├── RequestException.php │ ├── ClientErrorException.php │ └── NetworkErrorException.php │ ├── Encoding.php │ ├── Handlers │ ├── MimeHandlerInterface.php │ ├── AbstractMimeHandler.php │ ├── FormMimeHandler.php │ ├── HtmlMimeHandler.php │ ├── DefaultMimeHandler.php │ ├── JsonMimeHandler.php │ ├── CsvMimeHandler.php │ └── XmlMimeHandler.php │ ├── Proxy.php │ ├── ClientPromise.php │ ├── Mime.php │ ├── Setup.php │ ├── Factory.php │ ├── ServerRequest.php │ ├── Curl │ ├── MultiCurlPromise.php │ └── MultiCurl.php │ ├── UploadedFile.php │ ├── Http.php │ ├── Client.php │ ├── ClientMulti.php │ ├── UriResolver.php │ ├── Headers.php │ ├── Stream.php │ ├── Response.php │ └── Uri.php ├── LICENSE.txt ├── composer.json ├── README.md └── CHANGELOG.md /src/Httpful/Exception/CsvParseException.php: -------------------------------------------------------------------------------- 1 | stripBom($body); 22 | if (empty($body)) { 23 | return null; 24 | } 25 | 26 | if (\voku\helper\UTF8::is_utf8($body) === false) { 27 | $body = \voku\helper\UTF8::to_utf8($body); 28 | } 29 | 30 | return HtmlDomParser::str_get_html($body); 31 | } 32 | 33 | /** 34 | * @param mixed $payload 35 | * 36 | * @return string 37 | */ 38 | public function serialize($payload) 39 | { 40 | return (string) $payload; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Nate Good 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /src/Httpful/Exception/RequestException.php: -------------------------------------------------------------------------------- 1 | request = $request; 30 | } 31 | 32 | /** 33 | * Returns the request. 34 | * 35 | * The request object MAY be a different object from the one passed to ClientInterface::sendRequest() 36 | * 37 | * @return RequestInterface 38 | */ 39 | public function getRequest(): RequestInterface 40 | { 41 | return $this->request; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Httpful/ClientPromise.php: -------------------------------------------------------------------------------- 1 | curlMulti = (new Request())->initMulti(); 19 | } 20 | 21 | /** 22 | * @return MultiCurlPromise 23 | */ 24 | public function getPromise(): MultiCurlPromise 25 | { 26 | return new MultiCurlPromise($this->curlMulti); 27 | } 28 | 29 | /** 30 | * Sends a PSR-7 request in an asynchronous way. 31 | * 32 | * Exceptions related to processing the request are available from the returned Promise. 33 | * 34 | * @param RequestInterface $request 35 | * 36 | * @return \Http\Promise\Promise resolves a PSR-7 Response or fails with an Http\Client\Exception 37 | */ 38 | public function sendAsyncRequest(RequestInterface $request) 39 | { 40 | $this->add_request($request); 41 | 42 | return $this->getPromise(); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Httpful/Handlers/DefaultMimeHandler.php: -------------------------------------------------------------------------------- 1 | init($args); 22 | } 23 | 24 | /** 25 | * @param array $args 26 | * 27 | * @return void 28 | */ 29 | public function init(array $args) 30 | { 31 | } 32 | 33 | /** 34 | * @param mixed $body 35 | * 36 | * @return mixed 37 | */ 38 | public function parse($body) 39 | { 40 | return $body; 41 | } 42 | 43 | /** 44 | * @param mixed $payload 45 | * 46 | * @return mixed 47 | */ 48 | public function serialize($payload) 49 | { 50 | if ( 51 | \is_array($payload) 52 | || 53 | $payload instanceof \Serializable 54 | ) { 55 | $payload = \serialize($payload); 56 | } 57 | 58 | return $payload; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Httpful/Handlers/JsonMimeHandler.php: -------------------------------------------------------------------------------- 1 | decode_as_array = (bool) ($args['decode_as_array']); 28 | } else { 29 | $this->decode_as_array = true; 30 | } 31 | } 32 | 33 | /** 34 | * @param string $body 35 | * 36 | * @return mixed|null 37 | */ 38 | public function parse($body) 39 | { 40 | $body = $this->stripBom($body); 41 | if (empty($body)) { 42 | return null; 43 | } 44 | 45 | $parsed = \json_decode($body, $this->decode_as_array); 46 | if ($parsed === null && \strtolower($body) !== 'null') { 47 | throw new JsonParseException('Unable to parse response as JSON: ' . \json_last_error_msg() . ' | "' . \print_r($body, true) . '"'); 48 | } 49 | 50 | return $parsed; 51 | } 52 | 53 | /** 54 | * @param mixed $payload 55 | * 56 | * @return false|string 57 | */ 58 | public function serialize($payload) 59 | { 60 | return \json_encode($payload); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "voku/httpful", 3 | "description": "A Readable, Chainable, REST friendly, PHP HTTP Client", 4 | "keywords": [ 5 | "http", 6 | "curl", 7 | "rest", 8 | "restful", 9 | "api", 10 | "requests" 11 | ], 12 | "homepage": "https://github.com/voku/httpful", 13 | "license": "MIT", 14 | "authors": [ 15 | { 16 | "name": "Nate Good", 17 | "email": "me@nategood.com", 18 | "homepage": "http://nategood.com" 19 | }, 20 | { 21 | "name": "Lars Moelleken", 22 | "email": "lars@moelleken.org", 23 | "homepage": "https://moelleken.org/" 24 | } 25 | ], 26 | "require": { 27 | "php": ">=7.4", 28 | "ext-curl": "*", 29 | "ext-dom": "*", 30 | "ext-fileinfo": "*", 31 | "ext-json": "*", 32 | "ext-simplexml": "*", 33 | "ext-xmlwriter": "*", 34 | "php-http/httplug": "2.4.* || 2.3.* || 2.2.* || 2.1.*", 35 | "php-http/promise": "1.1.* || 1.0.*", 36 | "psr/http-client": "1.0.*", 37 | "psr/http-factory": "1.0.*", 38 | "psr/http-message": "2.0.* || 1.1.* || 1.0.*", 39 | "psr/log": "1.1.* || 2.0.* || 3.0.*", 40 | "voku/portable-utf8": "~6.0", 41 | "voku/simple_html_dom": "~4.7" 42 | }, 43 | "require-dev": { 44 | "phpunit/phpunit": "~6.0 || ~7.0 || ~9.0" 45 | }, 46 | "provide": { 47 | "php-http/async-client-implementation": "1.0", 48 | "php-http/client-implementation": "1.0", 49 | "psr/http-client-implementation": "1.0", 50 | "psr/http-factory-implementation": "1.0" 51 | }, 52 | "autoload": { 53 | "psr-0": { 54 | "Httpful": "src/" 55 | } 56 | }, 57 | "autoload-dev": { 58 | "psr-4": { 59 | "Httpful\\tests\\": "tests/Httpful/" 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Httpful/Handlers/CsvMimeHandler.php: -------------------------------------------------------------------------------- 1 | 33 | */ 34 | private static $mimes = [ 35 | 'json' => self::JSON, 36 | 'xml' => self::XML, 37 | 'form' => self::FORM, 38 | 'plain' => self::PLAIN, 39 | 'text' => self::PLAIN, 40 | 'upload' => self::UPLOAD, 41 | 'html' => self::HTML, 42 | 'xhtml' => self::XHTML, 43 | 'js' => self::JS, 44 | 'javascript' => self::JS, 45 | 'yaml' => self::YAML, 46 | 'csv' => self::CSV, 47 | ]; 48 | 49 | /** 50 | * Get the full Mime Type name from a "short name". 51 | * Returns the short if no mapping was found. 52 | * 53 | * @param string $short_name common name for mime type (e.g. json) 54 | * 55 | * @return string full mime type (e.g. application/json) 56 | */ 57 | public static function getFullMime($short_name): string 58 | { 59 | if (\array_key_exists($short_name, self::$mimes)) { 60 | return self::$mimes[$short_name]; 61 | } 62 | 63 | return $short_name; 64 | } 65 | 66 | /** 67 | * @param string $short_name 68 | * 69 | * @return bool 70 | */ 71 | public static function supportsMimeType($short_name): bool 72 | { 73 | return \array_key_exists($short_name, self::$mimes); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Httpful/Exception/ClientErrorException.php: -------------------------------------------------------------------------------- 1 | curl_object = $curl_object; 35 | 36 | parent::__construct($message, $code, $previous); 37 | } 38 | 39 | /** 40 | * @return int 41 | */ 42 | public function getCurlErrorNumber(): int 43 | { 44 | return $this->curlErrorNumber; 45 | } 46 | 47 | /** 48 | * @return string 49 | */ 50 | public function getCurlErrorString(): string 51 | { 52 | return $this->curlErrorString; 53 | } 54 | 55 | /** 56 | * @return \Httpful\Curl\Curl|null 57 | */ 58 | public function getCurlObject() 59 | { 60 | return $this->curl_object; 61 | } 62 | 63 | /** 64 | * @param int $curlErrorNumber 65 | * 66 | * @return static 67 | */ 68 | public function setCurlErrorNumber($curlErrorNumber) 69 | { 70 | $this->curlErrorNumber = $curlErrorNumber; 71 | 72 | return $this; 73 | } 74 | 75 | /** 76 | * @param string $curlErrorString 77 | * 78 | * @return static 79 | */ 80 | public function setCurlErrorString($curlErrorString) 81 | { 82 | $this->curlErrorString = $curlErrorString; 83 | 84 | return $this; 85 | } 86 | 87 | /** 88 | * @return bool 89 | */ 90 | public function wasTimeout(): bool 91 | { 92 | return $this->code === \CURLE_OPERATION_TIMEOUTED; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Httpful/Exception/NetworkErrorException.php: -------------------------------------------------------------------------------- 1 | curl_object = $curl_object; 49 | $this->request = $request; 50 | 51 | parent::__construct($message, $code, $previous); 52 | } 53 | 54 | /** 55 | * @return int 56 | */ 57 | public function getCurlErrorNumber(): int 58 | { 59 | return $this->curlErrorNumber; 60 | } 61 | 62 | /** 63 | * @return string 64 | */ 65 | public function getCurlErrorString(): string 66 | { 67 | return $this->curlErrorString; 68 | } 69 | 70 | /** 71 | * @return \Httpful\Curl\Curl|null 72 | */ 73 | public function getCurlObject() 74 | { 75 | return $this->curl_object; 76 | } 77 | 78 | /** 79 | * Returns the request. 80 | * 81 | * The request object MAY be a different object from the one passed to ClientInterface::sendRequest() 82 | * 83 | * @return RequestInterface 84 | */ 85 | public function getRequest(): RequestInterface 86 | { 87 | return $this->request ?? new Request(); 88 | } 89 | 90 | /** 91 | * @param int $curlErrorNumber 92 | * 93 | * @return static 94 | */ 95 | public function setCurlErrorNumber($curlErrorNumber) 96 | { 97 | $this->curlErrorNumber = $curlErrorNumber; 98 | 99 | return $this; 100 | } 101 | 102 | /** 103 | * @param string $curlErrorString 104 | * 105 | * @return static 106 | */ 107 | public function setCurlErrorString($curlErrorString) 108 | { 109 | $this->curlErrorString = $curlErrorString; 110 | 111 | return $this; 112 | } 113 | 114 | /** 115 | * @return bool 116 | */ 117 | public function wasTimeout(): bool 118 | { 119 | return $this->code === \CURLE_OPERATION_TIMEOUTED; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/Httpful/Setup.php: -------------------------------------------------------------------------------- 1 | new CsvMimeHandler(), 71 | Mime::FORM => new FormMimeHandler(), 72 | Mime::HTML => new HtmlMimeHandler(), 73 | Mime::JS => new DefaultMimeHandler(), 74 | Mime::JSON => new JsonMimeHandler(['decode_as_array' => true]), 75 | Mime::PLAIN => new DefaultMimeHandler(), 76 | Mime::XHTML => new HtmlMimeHandler(), 77 | Mime::XML => new XmlMimeHandler(), 78 | Mime::YAML => new DefaultMimeHandler(), 79 | ]; 80 | 81 | foreach ($handlers as $mime => $handler) { 82 | // Don't overwrite if the handler has already been registered. 83 | if (self::hasParserRegistered($mime)) { 84 | continue; 85 | } 86 | 87 | self::registerMimeHandler($mime, $handler); 88 | } 89 | 90 | self::$mime_registered = true; 91 | } 92 | 93 | /** 94 | * @param callable|LoggerInterface|null $error_handler 95 | * 96 | * @return void 97 | */ 98 | public static function registerGlobalErrorHandler($error_handler = null) 99 | { 100 | if ( 101 | !$error_handler instanceof LoggerInterface 102 | && 103 | !\is_callable($error_handler) 104 | ) { 105 | throw new \InvalidArgumentException('Only callable or LoggerInterface are allowed as global error callback.'); 106 | } 107 | 108 | self::$global_error_handler = $error_handler; 109 | } 110 | 111 | /** 112 | * @param MimeHandlerInterface $global_mime_handler 113 | * 114 | * @return void 115 | */ 116 | public static function registerGlobalMimeHandler(MimeHandlerInterface $global_mime_handler) 117 | { 118 | self::$global_mime_handler = $global_mime_handler; 119 | } 120 | 121 | /** 122 | * @param string $mimeType 123 | * @param MimeHandlerInterface $handler 124 | * 125 | * @return void 126 | */ 127 | public static function registerMimeHandler($mimeType, MimeHandlerInterface $handler) 128 | { 129 | self::$mime_registrar[$mimeType] = $handler; 130 | } 131 | 132 | /** 133 | * @return MimeHandlerInterface 134 | */ 135 | public static function reset(): MimeHandlerInterface 136 | { 137 | self::$mime_registrar = []; 138 | self::$mime_registered = false; 139 | self::$global_error_handler = null; 140 | self::$global_mime_handler = null; 141 | 142 | self::initMimeHandlers(); 143 | 144 | return self::setupGlobalMimeType(); 145 | } 146 | 147 | /** 148 | * @param string $mimeType 149 | * 150 | * @return MimeHandlerInterface 151 | */ 152 | public static function setupGlobalMimeType($mimeType = null): MimeHandlerInterface 153 | { 154 | self::initMimeHandlers(); 155 | 156 | if (isset(self::$mime_registrar[$mimeType])) { 157 | return self::$mime_registrar[$mimeType]; 158 | } 159 | 160 | if (empty(self::$global_mime_handler)) { 161 | self::$global_mime_handler = new DefaultMimeHandler(); 162 | } 163 | 164 | return self::$global_mime_handler; 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/Httpful/Factory.php: -------------------------------------------------------------------------------- 1 | withUriFromString($uri); 37 | 38 | if (is_array($body)) { 39 | $return = $return->withBodyFromArray($body); 40 | } else { 41 | $return = $return->withBodyFromString($body); 42 | } 43 | 44 | return $return; 45 | } 46 | 47 | /** 48 | * @param int $code 49 | * @param string|null $reasonPhrase 50 | * 51 | * @return Response 52 | */ 53 | public function createResponse(int $code = 200, string $reasonPhrase = null): ResponseInterface 54 | { 55 | return (new Response())->withStatus($code, $reasonPhrase); 56 | } 57 | 58 | /** 59 | * @param string $method 60 | * @param string $uri 61 | * @param array $serverParams 62 | * @param string|null $mime 63 | * @param string $body 64 | * 65 | * @return ServerRequest 66 | */ 67 | public function createServerRequest(string $method, $uri, array $serverParams = [], $mime = null, string $body = ''): ServerRequestInterface 68 | { 69 | return (new ServerRequest($method, $mime, null, $serverParams)) 70 | ->withUriFromString($uri) 71 | ->withBodyFromString($body); 72 | } 73 | 74 | /** 75 | * @param string $content 76 | * 77 | * @return StreamInterface 78 | */ 79 | public function createStream(string $content = ''): StreamInterface 80 | { 81 | return Stream::createNotNull($content); 82 | } 83 | 84 | /** 85 | * @param string $filename 86 | * @param string $mode 87 | * 88 | * @return StreamInterface 89 | */ 90 | public function createStreamFromFile(string $filename, string $mode = 'rb'): StreamInterface 91 | { 92 | /** @noinspection PhpUsageOfSilenceOperatorInspection */ 93 | $resource = @\fopen($filename, $mode); 94 | if ($resource === false) { 95 | if ($mode === '' || \in_array($mode[0], ['r', 'w', 'a', 'x', 'c'], true) === false) { 96 | throw new \InvalidArgumentException('The mode ' . $mode . ' is invalid.'); 97 | } 98 | 99 | throw new \RuntimeException('The file ' . $filename . ' cannot be opened.'); 100 | } 101 | 102 | return Stream::createNotNull($resource); 103 | } 104 | 105 | /** 106 | * @param resource|StreamInterface|string $resource 107 | * 108 | * @return StreamInterface 109 | */ 110 | public function createStreamFromResource($resource): StreamInterface 111 | { 112 | return Stream::createNotNull($resource); 113 | } 114 | 115 | /** 116 | * @param StreamInterface $stream 117 | * @param int|null $size 118 | * @param int $error 119 | * @param string|null $clientFilename 120 | * @param string|null $clientMediaType 121 | * 122 | * @return UploadedFileInterface 123 | */ 124 | public function createUploadedFile( 125 | StreamInterface $stream, 126 | int $size = null, 127 | int $error = \UPLOAD_ERR_OK, 128 | string $clientFilename = null, 129 | string $clientMediaType = null 130 | ): UploadedFileInterface { 131 | if ($size === null) { 132 | $size = (int) $stream->getSize(); 133 | } 134 | 135 | return new UploadedFile( 136 | $stream, 137 | $size, 138 | $error, 139 | $clientFilename, 140 | $clientMediaType 141 | ); 142 | } 143 | 144 | /** 145 | * @param string $uri 146 | * 147 | * @return UriInterface 148 | */ 149 | public function createUri(string $uri = ''): UriInterface 150 | { 151 | return new Uri($uri); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/Httpful/ServerRequest.php: -------------------------------------------------------------------------------- 1 | serverParams = $serverParams; 55 | 56 | parent::__construct($method, $mime, $template); 57 | } 58 | 59 | /** 60 | * @param string $name 61 | * @param mixed $default 62 | * 63 | * @return mixed|null 64 | */ 65 | public function getAttribute($name, $default = null) 66 | { 67 | if (\array_key_exists($name, $this->attributes) === false) { 68 | return $default; 69 | } 70 | 71 | return $this->attributes[$name]; 72 | } 73 | 74 | /** 75 | * @return array 76 | */ 77 | public function getAttributes(): array 78 | { 79 | return $this->attributes; 80 | } 81 | 82 | /** 83 | * @return array 84 | */ 85 | public function getCookieParams(): array 86 | { 87 | return $this->cookieParams; 88 | } 89 | 90 | /** 91 | * @return array|object|null 92 | */ 93 | public function getParsedBody() 94 | { 95 | return $this->parsedBody; 96 | } 97 | 98 | /** 99 | * @return array 100 | */ 101 | public function getQueryParams(): array 102 | { 103 | return $this->queryParams; 104 | } 105 | 106 | /** 107 | * @return array 108 | */ 109 | public function getServerParams(): array 110 | { 111 | return $this->serverParams; 112 | } 113 | 114 | /** 115 | * @return array 116 | */ 117 | public function getUploadedFiles(): array 118 | { 119 | return $this->uploadedFiles; 120 | } 121 | 122 | /** 123 | * @param string $name 124 | * @param mixed $value 125 | * 126 | * @return static 127 | */ 128 | public function withAttribute($name, $value): self 129 | { 130 | $new = clone $this; 131 | $new->attributes[$name] = $value; 132 | 133 | return $new; 134 | } 135 | 136 | /** 137 | * @param array $cookies 138 | * 139 | * @return ServerRequest|ServerRequestInterface 140 | */ 141 | public function withCookieParams(array $cookies): ServerRequestInterface 142 | { 143 | $new = clone $this; 144 | $new->cookieParams = $cookies; 145 | 146 | return $new; 147 | } 148 | 149 | /** 150 | * @param array|object|null $data 151 | * 152 | * @return ServerRequest|ServerRequestInterface 153 | */ 154 | public function withParsedBody($data): ServerRequestInterface 155 | { 156 | if ( 157 | !\is_array($data) 158 | && 159 | !\is_object($data) 160 | && 161 | $data !== null 162 | ) { 163 | throw new \InvalidArgumentException('First parameter to withParsedBody MUST be object, array or null'); 164 | } 165 | 166 | $new = clone $this; 167 | $new->parsedBody = $data; 168 | 169 | return $new; 170 | } 171 | 172 | /** 173 | * @param array $query 174 | * 175 | * @return ServerRequestInterface|static 176 | */ 177 | public function withQueryParams(array $query): ServerRequestInterface 178 | { 179 | $new = clone $this; 180 | $new->queryParams = $query; 181 | 182 | return $new; 183 | } 184 | 185 | /** 186 | * @param array $uploadedFiles 187 | * 188 | * @return ServerRequestInterface|static 189 | */ 190 | public function withUploadedFiles(array $uploadedFiles): ServerRequestInterface 191 | { 192 | $new = clone $this; 193 | $new->uploadedFiles = $uploadedFiles; 194 | 195 | return $new; 196 | } 197 | 198 | /** 199 | * @param string $name 200 | * 201 | * @return static 202 | */ 203 | public function withoutAttribute($name): self 204 | { 205 | if (\array_key_exists($name, $this->attributes) === false) { 206 | return $this; 207 | } 208 | 209 | $new = clone $this; 210 | unset($new->attributes[$name]); 211 | 212 | return $new; 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /src/Httpful/Curl/MultiCurlPromise.php: -------------------------------------------------------------------------------- 1 | clientMulti = $clientMulti; 37 | $this->state = Promise::PENDING; 38 | } 39 | 40 | /** 41 | * Add behavior for when the promise is resolved or rejected. 42 | * 43 | * If you do not care about one of the cases, you can set the corresponding callable to null 44 | * The callback will be called when the response or exception arrived and never more than once. 45 | * 46 | * @param callable $onComplete Called when a response will be available 47 | * @param callable $onRejected Called when an error happens. 48 | * 49 | * You must always return the Response in the interface or throw an Exception 50 | * 51 | * @return Promise Always returns a new promise which is resolved with value of the executed 52 | * callback (onFulfilled / onRejected) 53 | */ 54 | public function then(callable $onComplete = null, callable $onRejected = null) 55 | { 56 | if ($onComplete) { 57 | $this->clientMulti->complete( 58 | static function (Curl $instance) use ($onComplete) { 59 | if ($instance->request instanceof \Httpful\Request) { 60 | $response = $instance->request->_buildResponse($instance->rawResponse, $instance); 61 | } else { 62 | $response = $instance->rawResponse; 63 | } 64 | 65 | $onComplete( 66 | $response, 67 | $instance->request, 68 | $instance 69 | ); 70 | } 71 | ); 72 | } 73 | 74 | if ($onRejected) { 75 | $this->clientMulti->error( 76 | static function (Curl $instance) use ($onRejected) { 77 | if ($instance->request instanceof \Httpful\Request) { 78 | $response = $instance->request->_buildResponse($instance->rawResponse, $instance); 79 | } else { 80 | $response = $instance->rawResponse; 81 | } 82 | 83 | $onRejected( 84 | $response, 85 | $instance->request, 86 | $instance 87 | ); 88 | } 89 | ); 90 | } 91 | 92 | return new self($this->clientMulti); 93 | } 94 | 95 | /** 96 | * Get the state of the promise, one of PENDING, FULFILLED or REJECTED. 97 | * 98 | * @return string 99 | */ 100 | public function getState() 101 | { 102 | return $this->state; 103 | } 104 | 105 | /** 106 | * Wait for the promise to be fulfilled or rejected. 107 | * 108 | * When this method returns, the request has been resolved and the appropriate callable has terminated. 109 | * 110 | * When called with the unwrap option 111 | * 112 | * @param bool $unwrap Whether to return resolved value / throw reason or not 113 | * 114 | * @return MultiCurl|null Resolved value, null if $unwrap is set to false 115 | */ 116 | public function wait($unwrap = true) 117 | { 118 | if ($unwrap) { 119 | $this->clientMulti->start(); 120 | $this->state = Promise::FULFILLED; 121 | 122 | return $this->clientMulti; 123 | } 124 | 125 | try { 126 | $this->clientMulti->start(); 127 | $this->state = Promise::FULFILLED; 128 | } catch (\ErrorException $e) { 129 | $this->_error((string) $e); 130 | } 131 | 132 | return null; 133 | } 134 | 135 | /** 136 | * @param string $error 137 | * 138 | * @return void 139 | */ 140 | private function _error($error) 141 | { 142 | $this->state = Promise::REJECTED; 143 | 144 | // global error handling 145 | 146 | $global_error_handler = \Httpful\Setup::getGlobalErrorHandler(); 147 | if ($global_error_handler) { 148 | if ($global_error_handler instanceof \Psr\Log\LoggerInterface) { 149 | // PSR-3 https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-3-logger-interface.md 150 | $global_error_handler->error($error); 151 | } elseif (\is_callable($global_error_handler)) { 152 | // error callback 153 | /** @noinspection VariableFunctionsUsageInspection */ 154 | \call_user_func($global_error_handler, $error); 155 | } 156 | } 157 | 158 | // local error handling 159 | 160 | /** @noinspection ForgottenDebugOutputInspection */ 161 | \error_log($error); 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://github.com/voku/httpful/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/voku/httpful/actions) 2 | [![codecov.io](https://codecov.io/github/voku/httpful/coverage.svg?branch=master)](https://codecov.io/github/voku/httpful?branch=master) 3 | [![Codacy Badge](https://api.codacy.com/project/badge/Grade/5882e37a6cd24f6c9d1cf70a08064146)](https://www.codacy.com/app/voku/httpful) 4 | [![Latest Stable Version](https://poser.pugx.org/voku/httpful/v/stable)](https://packagist.org/packages/voku/httpful) 5 | [![Total Downloads](https://poser.pugx.org/voku/httpful/downloads)](https://packagist.org/packages/voku/httpful) 6 | [![License](https://poser.pugx.org/voku/httpful/license)](https://packagist.org/packages/voku/httpful) 7 | [![Donate to this project using Paypal](https://img.shields.io/badge/paypal-donate-yellow.svg)](https://www.paypal.me/moelleken) 8 | [![Donate to this project using Patreon](https://img.shields.io/badge/patreon-donate-yellow.svg)](https://www.patreon.com/voku) 9 | 10 | # 📯 Httpful 11 | 12 | Forked some years ago from [nategood/httpful](https://github.com/nategood/httpful) + added support for parallel request and implemented many PSR Interfaces: A Chainable, REST Friendly Wrapper for cURL with many "PSR-HTTP" implemented interfaces. 13 | 14 | Features 15 | 16 | - Readable HTTP Method Support (GET, PUT, POST, DELETE, HEAD, PATCH and OPTIONS) 17 | - Custom Headers 18 | - Automatic "Smart" Parsing 19 | - Automatic Payload Serialization 20 | - Basic Auth 21 | - Client Side Certificate Auth (SSL) 22 | - Request "Download" 23 | - Request "Templates" 24 | - Parallel Request (via curl_multi) 25 | - PSR-3: Logger Interface 26 | - PSR-7: HTTP Message Interface 27 | - PSR-17: HTTP Factory Interface 28 | - PSR-18: HTTP Client Interface 29 | 30 | # Examples 31 | 32 | ```php 33 | getBody()->name . ' joined GitHub on ' . date('M jS Y', strtotime($response->getBody()->created_at)) . "\n"; 41 | ``` 42 | 43 | ```php 44 | withAddedHeader('X-Foo-Header', 'Just as a demo') 51 | ->expectsJson() 52 | ->send(); 53 | 54 | $result = $response->getRawBody(); 55 | 56 | echo $result['name'] . ' joined GitHub on ' . \date('M jS Y', \strtotime($result['created_at'])) . "\n"; 57 | ``` 58 | 59 | ```php 60 | withUriFromString('https://postman-echo.com/basic-auth') 74 | ->withBasicAuth('postman', 'password'); 75 | 76 | $multi->add_request($request); 77 | // $multi->add_request(...); // add more calls here 78 | 79 | $multi->start(); 80 | 81 | // DEBUG 82 | //print_r($results); 83 | ``` 84 | 85 | # Installation 86 | 87 | ```shell 88 | composer require voku/httpful 89 | ``` 90 | 91 | ## Handlers 92 | 93 | We can override the default parser configuration options be registering 94 | a parser with different configuration options for a particular mime type 95 | 96 | Example: setting a namespace for the XMLHandler parser 97 | ```php 98 | $conf = ['namespace' => 'http://example.com']; 99 | \Httpful\Setup::registerMimeHandler(\Httpful\Mime::XML, new \Httpful\Handlers\XmlMimeHandler($conf)); 100 | ``` 101 | 102 | --- 103 | 104 | Handlers are simple classes that are used to parse response bodies and serialize request payloads. All Handlers must implement the `MimeHandlerInterface` interface and implement two methods: `serialize($payload)` and `parse($response)`. Let's build a very basic Handler to register for the `text/csv` mime type. 105 | 106 | ```php 107 | namespace = $conf['namespace'] ?? ''; 32 | $this->libxml_opts = $conf['libxml_opts'] ?? 0; 33 | } 34 | 35 | /** 36 | * @param string $body 37 | * 38 | * @return \SimpleXMLElement|null 39 | */ 40 | public function parse($body) 41 | { 42 | $body = $this->stripBom($body); 43 | if (empty($body)) { 44 | return null; 45 | } 46 | 47 | $parsed = \simplexml_load_string($body, \SimpleXMLElement::class, $this->libxml_opts, $this->namespace); 48 | if ($parsed === false) { 49 | throw new XmlParseException('Unable to parse response as XML: ' . $body); 50 | } 51 | 52 | return $parsed; 53 | } 54 | 55 | /** @noinspection PhpMissingParentCallCommonInspection */ 56 | 57 | /** 58 | * @param mixed $payload 59 | * 60 | * @return false|string 61 | */ 62 | public function serialize($payload) 63 | { 64 | /** @noinspection PhpUnusedLocalVariableInspection */ 65 | list($_, $dom) = $this->_future_serializeAsXml($payload); 66 | 67 | /* @var \DOMDocument $dom */ 68 | 69 | return $dom->saveXML(); 70 | } 71 | 72 | /** 73 | * @param mixed $payload 74 | * 75 | * @return string 76 | */ 77 | public function serialize_clean($payload): string 78 | { 79 | $xml = new \XMLWriter(); 80 | $xml->openMemory(); 81 | $xml->startDocument('1.0', 'UTF-8'); 82 | $this->serialize_node($xml, $payload); 83 | 84 | return $xml->outputMemory(true); 85 | } 86 | 87 | /** 88 | * @param \XMLWriter $xmlw 89 | * @param mixed $node to serialize 90 | * 91 | * @return void 92 | */ 93 | public function serialize_node(&$xmlw, $node) 94 | { 95 | if (!\is_array($node)) { 96 | $xmlw->text($node); 97 | } else { 98 | foreach ($node as $k => $v) { 99 | $xmlw->startElement($k); 100 | $this->serialize_node($xmlw, $v); 101 | $xmlw->endElement(); 102 | } 103 | } 104 | } 105 | 106 | /** 107 | * @param mixed $value 108 | * @param \DOMElement $parent 109 | * @param \DOMDocument $dom 110 | * 111 | * @return array 112 | */ 113 | private function _future_serializeArrayAsXml(&$value, \DOMElement $parent, \DOMDocument $dom): array 114 | { 115 | foreach ($value as $k => &$v) { 116 | $n = $k; 117 | if (\is_numeric($k)) { 118 | $n = "child-{$n}"; 119 | } 120 | 121 | $el = $dom->createElement($n); 122 | $parent->appendChild($el); 123 | $this->_future_serializeAsXml($v, $el, $dom); 124 | } 125 | 126 | return [$parent, $dom]; 127 | } 128 | 129 | /** 130 | * @param mixed $value 131 | * @param \DOMElement|null $node 132 | * @param \DOMDocument|null $dom 133 | * 134 | * @return array 135 | */ 136 | private function _future_serializeAsXml(&$value, \DOMElement $node = null, \DOMDocument $dom = null): array 137 | { 138 | if (!$dom) { 139 | $dom = new \DOMDocument(); 140 | } 141 | 142 | if (!$node) { 143 | if (!\is_object($value)) { 144 | $node = $dom->createElement('response'); 145 | $dom->appendChild($node); 146 | } else { 147 | $node = $dom; // is it correct, that we use the "dom" as "node"? 148 | } 149 | } 150 | 151 | if (\is_object($value)) { 152 | $objNode = $dom->createElement(\get_class($value)); 153 | $node->appendChild($objNode); 154 | $this->_future_serializeObjectAsXml($value, $objNode, $dom); 155 | } elseif (\is_array($value)) { 156 | $arrNode = $dom->createElement('array'); 157 | $node->appendChild($arrNode); 158 | $this->_future_serializeArrayAsXml($value, $arrNode, $dom); 159 | } elseif ((bool) $value === $value) { 160 | $node->appendChild($dom->createTextNode($value ? 'TRUE' : 'FALSE')); 161 | } else { 162 | $node->appendChild($dom->createTextNode($value)); 163 | } 164 | 165 | return [$node, $dom]; 166 | } 167 | 168 | /** 169 | * @param mixed $value 170 | * @param \DOMElement $parent 171 | * @param \DOMDocument $dom 172 | * 173 | * @return array 174 | */ 175 | private function _future_serializeObjectAsXml(&$value, \DOMElement $parent, \DOMDocument $dom): array 176 | { 177 | $refl = new \ReflectionObject($value); 178 | foreach ($refl->getProperties() as $pr) { 179 | if (!$pr->isPrivate()) { 180 | $el = $dom->createElement($pr->getName()); 181 | $parent->appendChild($el); 182 | $value = $pr->getValue($value); 183 | $this->_future_serializeAsXml($value, $el, $dom); 184 | } 185 | } 186 | 187 | return [$parent, $dom]; 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/Httpful/UploadedFile.php: -------------------------------------------------------------------------------- 1 | 1, 17 | \UPLOAD_ERR_INI_SIZE => 1, 18 | \UPLOAD_ERR_FORM_SIZE => 1, 19 | \UPLOAD_ERR_PARTIAL => 1, 20 | \UPLOAD_ERR_NO_FILE => 1, 21 | \UPLOAD_ERR_NO_TMP_DIR => 1, 22 | \UPLOAD_ERR_CANT_WRITE => 1, 23 | \UPLOAD_ERR_EXTENSION => 1, 24 | ]; 25 | 26 | /** 27 | * @var string|null 28 | */ 29 | private $clientFilename; 30 | 31 | /** 32 | * @var string|null 33 | */ 34 | private $clientMediaType; 35 | 36 | /** 37 | * @var int 38 | */ 39 | private $error; 40 | 41 | /** 42 | * @var string|null 43 | */ 44 | private $file; 45 | 46 | /** 47 | * @var bool 48 | */ 49 | private $moved = false; 50 | 51 | /** 52 | * @var int 53 | */ 54 | private $size; 55 | 56 | /** 57 | * @var StreamInterface|null 58 | */ 59 | private $stream; 60 | 61 | /** 62 | * @param resource|StreamInterface|string $streamOrFile 63 | * @param int $size 64 | * @param int $errorStatus 65 | * @param string|null $clientFilename 66 | * @param string|null $clientMediaType 67 | */ 68 | public function __construct( 69 | $streamOrFile, 70 | $size, 71 | $errorStatus, 72 | $clientFilename = null, 73 | $clientMediaType = null 74 | ) { 75 | if ( 76 | \is_int($errorStatus) === false 77 | || 78 | !isset(self::ERRORS[$errorStatus]) 79 | ) { 80 | throw new \InvalidArgumentException('Upload file error status must be an integer value and one of the "UPLOAD_ERR_*" constants.'); 81 | } 82 | 83 | if (\is_int($size) === false) { 84 | throw new \InvalidArgumentException('Upload file size must be an integer'); 85 | } 86 | 87 | if ( 88 | $clientFilename !== null 89 | && 90 | !\is_string($clientFilename) 91 | ) { 92 | throw new \InvalidArgumentException('Upload file client filename must be a string or null'); 93 | } 94 | 95 | if ( 96 | $clientMediaType !== null 97 | && 98 | !\is_string($clientMediaType) 99 | ) { 100 | throw new \InvalidArgumentException('Upload file client media type must be a string or null'); 101 | } 102 | 103 | $this->error = $errorStatus; 104 | $this->size = $size; 105 | $this->clientFilename = $clientFilename; 106 | $this->clientMediaType = $clientMediaType; 107 | 108 | if ($this->error === \UPLOAD_ERR_OK) { 109 | // Depending on the value set file or stream variable. 110 | if (\is_string($streamOrFile)) { 111 | $this->file = $streamOrFile; 112 | } elseif (\is_resource($streamOrFile)) { 113 | $this->stream = Stream::create($streamOrFile); 114 | } elseif ($streamOrFile instanceof StreamInterface) { 115 | $this->stream = $streamOrFile; 116 | } else { 117 | throw new \InvalidArgumentException('Invalid stream or file provided for UploadedFile'); 118 | } 119 | } 120 | } 121 | 122 | /** 123 | * @return string|null 124 | */ 125 | public function getClientFilename(): ?string 126 | { 127 | return $this->clientFilename; 128 | } 129 | 130 | /** 131 | * @return string|null 132 | */ 133 | public function getClientMediaType(): ?string 134 | { 135 | return $this->clientMediaType; 136 | } 137 | 138 | /** 139 | * @return int 140 | */ 141 | public function getError(): int 142 | { 143 | return $this->error; 144 | } 145 | 146 | /** 147 | * @return int 148 | */ 149 | public function getSize(): int 150 | { 151 | return $this->size; 152 | } 153 | 154 | /** 155 | * @return StreamInterface 156 | */ 157 | public function getStream(): StreamInterface 158 | { 159 | $this->_validateActive(); 160 | 161 | if ($this->stream instanceof StreamInterface) { 162 | return $this->stream; 163 | } 164 | 165 | if ($this->file !== null) { 166 | $resource = \fopen($this->file, 'rb'); 167 | } else { 168 | $resource = ''; 169 | } 170 | 171 | return Stream::createNotNull($resource); 172 | } 173 | 174 | /** 175 | * @param string $targetPath 176 | * 177 | * @return void 178 | */ 179 | public function moveTo($targetPath): void 180 | { 181 | $this->_validateActive(); 182 | 183 | if ( 184 | !\is_string($targetPath) 185 | || 186 | $targetPath === '' 187 | ) { 188 | throw new \InvalidArgumentException('Invalid path provided for move operation; must be a non-empty string'); 189 | } 190 | 191 | if ($this->file !== null) { 192 | $this->moved = 'cli' === \PHP_SAPI ? \rename($this->file, $targetPath) : \move_uploaded_file($this->file, $targetPath); 193 | } else { 194 | $stream = $this->getStream(); 195 | if ($stream->isSeekable()) { 196 | $stream->rewind(); 197 | } 198 | 199 | // Copy the contents of a stream into another stream until end-of-file. 200 | $dest = Stream::createNotNull(\fopen($targetPath, 'wb')); 201 | while (!$stream->eof()) { 202 | if (!$dest->write($stream->read(1048576))) { 203 | break; 204 | } 205 | } 206 | 207 | $this->moved = true; 208 | } 209 | 210 | if ($this->moved === false) { 211 | throw new \RuntimeException(\sprintf('Uploaded file could not be moved to %s', $targetPath)); 212 | } 213 | } 214 | 215 | /** 216 | * @throws \RuntimeException if is moved or not ok 217 | * 218 | * @return void 219 | */ 220 | private function _validateActive() 221 | { 222 | if ($this->error !== \UPLOAD_ERR_OK) { 223 | throw new \RuntimeException('Cannot retrieve stream due to upload error'); 224 | } 225 | 226 | if ($this->moved) { 227 | throw new \RuntimeException('Cannot retrieve stream after it has already been moved'); 228 | } 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /src/Httpful/Http.php: -------------------------------------------------------------------------------- 1 | 'Continue', 223 | 101 => 'Switching Protocols', 224 | 102 => 'Processing', 225 | 200 => 'OK', 226 | 201 => 'Created', 227 | 202 => 'Accepted', 228 | 203 => 'Non-Authoritative Information', 229 | 204 => 'No Content', 230 | 205 => 'Reset Content', 231 | 206 => 'Partial Content', 232 | 207 => 'Multi-Status', 233 | 300 => 'Multiple Choices', 234 | 301 => 'Moved Permanently', 235 | 302 => 'Found', 236 | 303 => 'See Other', 237 | 304 => 'Not Modified', 238 | 305 => 'Use Proxy', 239 | 306 => 'Switch Proxy', 240 | 307 => 'Temporary Redirect', 241 | 400 => 'Bad Request', 242 | 401 => 'Unauthorized', 243 | 402 => 'Payment Required', 244 | 403 => 'Forbidden', 245 | 404 => 'Not Found', 246 | 405 => 'Method Not Allowed', 247 | 406 => 'Not Acceptable', 248 | 407 => 'Proxy Authentication Required', 249 | 408 => 'Request Timeout', 250 | 409 => 'Conflict', 251 | 410 => 'Gone', 252 | 411 => 'Length Required', 253 | 412 => 'Precondition Failed', 254 | 413 => 'Request Entity Too Large', 255 | 414 => 'Request-URI Too Long', 256 | 415 => 'Unsupported Media Type', 257 | 416 => 'Requested Range Not Satisfiable', 258 | 417 => 'Expectation Failed', 259 | 418 => 'I\'m a teapot', 260 | 422 => 'Unprocessable Entity', 261 | 423 => 'Locked', 262 | 424 => 'Failed Dependency', 263 | 425 => 'Unordered Collection', 264 | 426 => 'Upgrade Required', 265 | 429 => 'Too Many Requests', 266 | 449 => 'Retry With', 267 | 450 => 'Blocked by Windows Parental Controls', 268 | 500 => 'Internal Server Error', 269 | 501 => 'Not Implemented', 270 | 502 => 'Bad Gateway', 271 | 503 => 'Service Unavailable', 272 | 504 => 'Gateway Timeout', 273 | 505 => 'HTTP Version Not Supported', 274 | 506 => 'Variant Also Negotiates', 275 | 507 => 'Insufficient Storage', 276 | 509 => 'Bandwidth Limit Exceeded', 277 | 510 => 'Not Extended', 278 | ]; 279 | } 280 | } 281 | -------------------------------------------------------------------------------- /src/Httpful/Client.php: -------------------------------------------------------------------------------- 1 | send(); 23 | } 24 | 25 | /** 26 | * @param string $uri 27 | * @param array|null $params 28 | * @param string $mime 29 | * 30 | * @return Request 31 | */ 32 | public static function delete_request(string $uri, array $params = null, string $mime = Mime::JSON): Request 33 | { 34 | return Request::delete($uri, $params, $mime); 35 | } 36 | 37 | /** 38 | * @param string $uri 39 | * @param string $file_path 40 | * @param float|int $timeout 41 | * 42 | * @return Response 43 | */ 44 | public static function download(string $uri, $file_path, $timeout = 0): Response 45 | { 46 | $request = Request::download($uri, $file_path); 47 | 48 | if ($timeout > 0) { 49 | $request->withTimeout($timeout) 50 | ->withConnectionTimeoutInSeconds($timeout / 10); 51 | } 52 | 53 | return $request->send(); 54 | } 55 | 56 | /** 57 | * @param string $uri 58 | * @param array|null $params 59 | * @param string|null $mime 60 | * 61 | * @return Response 62 | */ 63 | public static function get(string $uri, array $params = null, $mime = Mime::PLAIN): Response 64 | { 65 | return self::get_request($uri, $params, $mime)->send(); 66 | } 67 | 68 | /** 69 | * @param string $uri 70 | * @param array|null $param 71 | * 72 | * @return \voku\helper\HtmlDomParser|null 73 | */ 74 | public static function get_dom(string $uri, array $param = null) 75 | { 76 | return self::get_request($uri, $param, Mime::HTML)->send()->getRawBody(); 77 | } 78 | 79 | /** 80 | * @param string $uri 81 | * @param array|null $param 82 | * 83 | * @return array 84 | */ 85 | public static function get_form(string $uri, array $param = null): array 86 | { 87 | return self::get_request($uri, $param, Mime::FORM)->send()->getRawBody(); 88 | } 89 | 90 | /** 91 | * @param string $uri 92 | * @param array|null $param 93 | * 94 | * @return mixed 95 | */ 96 | public static function get_json(string $uri, array $param = null) 97 | { 98 | return self::get_request($uri, $param, Mime::JSON)->send()->getRawBody(); 99 | } 100 | 101 | /** 102 | * @param string $uri 103 | * @param array|null $param 104 | * @param string|null $mime 105 | * 106 | * @return Request 107 | */ 108 | public static function get_request(string $uri, array $param = null, $mime = Mime::PLAIN): Request 109 | { 110 | return Request::get($uri, $param, $mime)->followRedirects(); 111 | } 112 | 113 | /** 114 | * @param string $uri 115 | * @param array|null $param 116 | * 117 | * @return \SimpleXMLElement|null 118 | */ 119 | public static function get_xml(string $uri, array $param = null) 120 | { 121 | return self::get_request($uri, $param, Mime::XML)->send()->getRawBody(); 122 | } 123 | 124 | /** 125 | * @param string $uri 126 | * 127 | * @return Response 128 | */ 129 | public static function head(string $uri): Response 130 | { 131 | return self::head_request($uri)->send(); 132 | } 133 | 134 | /** 135 | * @param string $uri 136 | * 137 | * @return Request 138 | */ 139 | public static function head_request(string $uri): Request 140 | { 141 | return Request::head($uri)->followRedirects(); 142 | } 143 | 144 | /** 145 | * @param string $uri 146 | * 147 | * @return Response 148 | */ 149 | public static function options(string $uri): Response 150 | { 151 | return self::options_request($uri)->send(); 152 | } 153 | 154 | /** 155 | * @param string $uri 156 | * 157 | * @return Request 158 | */ 159 | public static function options_request(string $uri): Request 160 | { 161 | return Request::options($uri); 162 | } 163 | 164 | /** 165 | * @param string $uri 166 | * @param mixed|null $payload 167 | * @param string $mime 168 | * 169 | * @return Response 170 | */ 171 | public static function patch(string $uri, $payload = null, string $mime = Mime::PLAIN): Response 172 | { 173 | return self::patch_request($uri, $payload, $mime)->send(); 174 | } 175 | 176 | /** 177 | * @param string $uri 178 | * @param mixed|null $payload 179 | * @param string $mime 180 | * 181 | * @return Request 182 | */ 183 | public static function patch_request(string $uri, $payload = null, string $mime = Mime::PLAIN): Request 184 | { 185 | return Request::patch($uri, $payload, $mime); 186 | } 187 | 188 | /** 189 | * @param string $uri 190 | * @param mixed|null $payload 191 | * @param string $mime 192 | * 193 | * @return Response 194 | */ 195 | public static function post(string $uri, $payload = null, string $mime = Mime::PLAIN): Response 196 | { 197 | return self::post_request($uri, $payload, $mime)->send(); 198 | } 199 | 200 | /** 201 | * @param string $uri 202 | * @param mixed|null $payload 203 | * 204 | * @return \voku\helper\HtmlDomParser|null 205 | */ 206 | public static function post_dom(string $uri, $payload = null) 207 | { 208 | return self::post_request($uri, $payload, Mime::HTML)->send()->getRawBody(); 209 | } 210 | 211 | /** 212 | * @param string $uri 213 | * @param mixed|null $payload 214 | * 215 | * @return array 216 | */ 217 | public static function post_form(string $uri, $payload = null): array 218 | { 219 | return self::post_request($uri, $payload, Mime::FORM)->send()->getRawBody(); 220 | } 221 | 222 | /** 223 | * @param string $uri 224 | * @param mixed|null $payload 225 | * 226 | * @return mixed 227 | */ 228 | public static function post_json(string $uri, $payload = null) 229 | { 230 | return self::post_request($uri, $payload, Mime::JSON)->send()->getRawBody(); 231 | } 232 | 233 | /** 234 | * @param string $uri 235 | * @param mixed|null $payload 236 | * @param string $mime 237 | * 238 | * @return Request 239 | */ 240 | public static function post_request(string $uri, $payload = null, string $mime = Mime::PLAIN): Request 241 | { 242 | return Request::post($uri, $payload, $mime)->followRedirects(); 243 | } 244 | 245 | /** 246 | * @param string $uri 247 | * @param mixed|null $payload 248 | * 249 | * @return \SimpleXMLElement|null 250 | */ 251 | public static function post_xml(string $uri, $payload = null) 252 | { 253 | return self::post_request($uri, $payload, Mime::XML)->send()->getRawBody(); 254 | } 255 | 256 | /** 257 | * @param string $uri 258 | * @param mixed|null $payload 259 | * @param string $mime 260 | * 261 | * @return Response 262 | */ 263 | public static function put(string $uri, $payload = null, string $mime = Mime::PLAIN): Response 264 | { 265 | return self::put_request($uri, $payload, $mime)->send(); 266 | } 267 | 268 | /** 269 | * @param string $uri 270 | * @param mixed|null $payload 271 | * @param string $mime 272 | * 273 | * @return Request 274 | */ 275 | public static function put_request(string $uri, $payload = null, string $mime = Mime::JSON): Request 276 | { 277 | return Request::put($uri, $payload, $mime); 278 | } 279 | 280 | /** 281 | * @param Request|RequestInterface $request 282 | * 283 | * @return Response|ResponseInterface 284 | */ 285 | public function sendRequest(RequestInterface $request): ResponseInterface 286 | { 287 | if (!$request instanceof Request) { 288 | /** @noinspection PhpSillyAssignmentInspection - helper for PhpStorm */ 289 | /** @var RequestInterface $request */ 290 | $request = $request; 291 | 292 | /** @var Request $requestNew */ 293 | $requestNew = Request::{$request->getMethod()}($request->getUri()); 294 | $requestNew->withHeaders($request->getHeaders()); 295 | $requestNew->withProtocolVersion($request->getProtocolVersion()); 296 | $requestNew->withBody($request->getBody()); 297 | $requestNew->withRequestTarget($request->getRequestTarget()); 298 | 299 | $request = $requestNew; 300 | } 301 | 302 | return $request->send(); 303 | } 304 | } 305 | -------------------------------------------------------------------------------- /src/Httpful/ClientMulti.php: -------------------------------------------------------------------------------- 1 | curlMulti = (new Request()) 24 | ->initMulti($onSuccessCallback, $onCompleteCallback); 25 | } 26 | 27 | /** 28 | * @return void 29 | */ 30 | public function start() 31 | { 32 | $this->curlMulti->start(); 33 | } 34 | 35 | /** 36 | * @param string $uri 37 | * @param array|null $params 38 | * @param string $mime 39 | * 40 | * @return $this 41 | */ 42 | public function add_delete(string $uri, array $params = null, string $mime = Mime::JSON) 43 | { 44 | $request = Request::delete($uri, $params, $mime); 45 | $curl = $request->_curlPrep()->_curl(); 46 | 47 | if ($curl) { 48 | $curl->request = $request; 49 | $this->curlMulti->addCurl($curl); 50 | } 51 | 52 | return $this; 53 | } 54 | 55 | /** 56 | * @param string $uri 57 | * @param string $file_path 58 | * 59 | * @return $this 60 | */ 61 | public function add_download(string $uri, $file_path) 62 | { 63 | $request = Request::download($uri, $file_path); 64 | $curl = $request->_curlPrep()->_curl(); 65 | 66 | if ($curl) { 67 | $curl->request = $request; 68 | $this->curlMulti->addCurl($curl); 69 | } 70 | 71 | return $this; 72 | } 73 | 74 | /** 75 | * @param string $uri 76 | * @param array|null $params 77 | * @param string|null $mime 78 | * 79 | * @return $this 80 | */ 81 | public function add_html(string $uri, array $params = null, $mime = Mime::HTML) 82 | { 83 | $request = Request::get($uri, $params, $mime)->followRedirects(); 84 | $curl = $request->_curlPrep()->_curl(); 85 | 86 | if ($curl) { 87 | $curl->request = $request; 88 | $this->curlMulti->addCurl($curl); 89 | } 90 | 91 | return $this; 92 | } 93 | 94 | /** 95 | * @param string $uri 96 | * @param array|null $params 97 | * @param string|null $mime 98 | * 99 | * @return $this 100 | */ 101 | public function add_get(string $uri, array $params = null, $mime = Mime::PLAIN) 102 | { 103 | $request = Request::get($uri, $params, $mime)->followRedirects(); 104 | $curl = $request->_curlPrep()->_curl(); 105 | 106 | if ($curl) { 107 | $curl->request = $request; 108 | $this->curlMulti->addCurl($curl); 109 | } 110 | 111 | return $this; 112 | } 113 | 114 | /** 115 | * @param string $uri 116 | * @param array|null $params 117 | * 118 | * @return $this 119 | */ 120 | public function add_get_dom(string $uri, array $params = null) 121 | { 122 | $request = Request::get($uri, $params, Mime::HTML)->followRedirects(); 123 | $curl = $request->_curlPrep()->_curl(); 124 | 125 | if ($curl) { 126 | $curl->request = $request; 127 | $this->curlMulti->addCurl($curl); 128 | } 129 | 130 | return $this; 131 | } 132 | 133 | /** 134 | * @param string $uri 135 | * @param array|null $params 136 | * 137 | * @return $this 138 | */ 139 | public function add_get_form(string $uri, array $params = null) 140 | { 141 | $request = Request::get($uri, $params, Mime::FORM)->followRedirects(); 142 | $curl = $request->_curlPrep()->_curl(); 143 | 144 | if ($curl) { 145 | $curl->request = $request; 146 | $this->curlMulti->addCurl($curl); 147 | } 148 | 149 | return $this; 150 | } 151 | 152 | /** 153 | * @param string $uri 154 | * @param array|null $params 155 | * 156 | * @return $this 157 | */ 158 | public function add_get_json(string $uri, array $params = null) 159 | { 160 | $request = Request::get($uri, $params, Mime::JSON)->followRedirects(); 161 | $curl = $request->_curlPrep()->_curl(); 162 | 163 | if ($curl) { 164 | $curl->request = $request; 165 | $this->curlMulti->addCurl($curl); 166 | } 167 | 168 | return $this; 169 | } 170 | 171 | /** 172 | * @param string $uri 173 | * @param array|null $params 174 | * 175 | * @return $this 176 | */ 177 | public function get_xml(string $uri, array $params = null) 178 | { 179 | $request = Request::get($uri, $params, Mime::XML)->followRedirects(); 180 | $curl = $request->_curlPrep()->_curl(); 181 | 182 | if ($curl) { 183 | $curl->request = $request; 184 | $this->curlMulti->addCurl($curl); 185 | } 186 | 187 | return $this; 188 | } 189 | 190 | /** 191 | * @param string $uri 192 | * 193 | * @return $this 194 | */ 195 | public function add_head(string $uri) 196 | { 197 | $request = Request::head($uri)->followRedirects(); 198 | $curl = $request->_curlPrep()->_curl(); 199 | 200 | if ($curl) { 201 | $curl->request = $request; 202 | $this->curlMulti->addCurl($curl); 203 | } 204 | 205 | return $this; 206 | } 207 | 208 | /** 209 | * @param string $uri 210 | * 211 | * @return $this 212 | */ 213 | public function add_options(string $uri) 214 | { 215 | $request = Request::options($uri); 216 | $curl = $request->_curlPrep()->_curl(); 217 | 218 | if ($curl) { 219 | $curl->request = $request; 220 | $this->curlMulti->addCurl($curl); 221 | } 222 | 223 | return $this; 224 | } 225 | 226 | /** 227 | * @param string $uri 228 | * @param mixed|null $payload 229 | * @param string $mime 230 | * 231 | * @return $this 232 | */ 233 | public function add_patch(string $uri, $payload = null, string $mime = Mime::PLAIN) 234 | { 235 | $request = Request::patch($uri, $payload, $mime); 236 | $curl = $request->_curlPrep()->_curl(); 237 | 238 | if ($curl) { 239 | $curl->request = $request; 240 | $this->curlMulti->addCurl($curl); 241 | } 242 | 243 | return $this; 244 | } 245 | 246 | /** 247 | * @param string $uri 248 | * @param mixed|null $payload 249 | * @param string $mime 250 | * 251 | * @return $this 252 | */ 253 | public function add_post(string $uri, $payload = null, string $mime = Mime::PLAIN) 254 | { 255 | $request = Request::post($uri, $payload, $mime)->followRedirects(); 256 | $curl = $request->_curlPrep()->_curl(); 257 | 258 | if ($curl) { 259 | $curl->request = $request; 260 | $this->curlMulti->addCurl($curl); 261 | } 262 | 263 | return $this; 264 | } 265 | 266 | /** 267 | * @param string $uri 268 | * @param mixed|null $payload 269 | * 270 | * @return $this 271 | */ 272 | public function add_post_dom(string $uri, $payload = null) 273 | { 274 | $request = Request::post($uri, $payload, Mime::HTML)->followRedirects(); 275 | $curl = $request->_curlPrep()->_curl(); 276 | 277 | if ($curl) { 278 | $curl->request = $request; 279 | $this->curlMulti->addCurl($curl); 280 | } 281 | 282 | return $this; 283 | } 284 | 285 | /** 286 | * @param string $uri 287 | * @param mixed|null $payload 288 | * 289 | * @return $this 290 | */ 291 | public function add_post_form(string $uri, $payload = null) 292 | { 293 | $request = Request::post($uri, $payload, Mime::FORM)->followRedirects(); 294 | $curl = $request->_curlPrep()->_curl(); 295 | 296 | if ($curl) { 297 | $curl->request = $request; 298 | $this->curlMulti->addCurl($curl); 299 | } 300 | 301 | return $this; 302 | } 303 | 304 | /** 305 | * @param string $uri 306 | * @param mixed|null $payload 307 | * 308 | * @return $this 309 | */ 310 | public function add_post_json(string $uri, $payload = null) 311 | { 312 | $request = Request::post($uri, $payload, Mime::JSON)->followRedirects(); 313 | $curl = $request->_curlPrep()->_curl(); 314 | 315 | if ($curl) { 316 | $curl->request = $request; 317 | $this->curlMulti->addCurl($curl); 318 | } 319 | 320 | return $this; 321 | } 322 | 323 | /** 324 | * @param string $uri 325 | * @param mixed|null $payload 326 | * 327 | * @return $this 328 | */ 329 | public function add_post_xml(string $uri, $payload = null) 330 | { 331 | $request = Request::post($uri, $payload, Mime::XML)->followRedirects(); 332 | $curl = $request->_curlPrep()->_curl(); 333 | 334 | if ($curl) { 335 | $curl->request = $request; 336 | $this->curlMulti->addCurl($curl); 337 | } 338 | 339 | return $this; 340 | } 341 | 342 | /** 343 | * @param string $uri 344 | * @param mixed|null $payload 345 | * @param string $mime 346 | * 347 | * @return $this 348 | */ 349 | public function add_put(string $uri, $payload = null, string $mime = Mime::PLAIN) 350 | { 351 | $request = Request::put($uri, $payload, $mime); 352 | $curl = $request->_curlPrep()->_curl(); 353 | 354 | if ($curl) { 355 | $curl->request = $request; 356 | $this->curlMulti->addCurl($curl); 357 | } 358 | 359 | return $this; 360 | } 361 | 362 | /** 363 | * @param Request|RequestInterface $request 364 | * 365 | * @return $this 366 | */ 367 | public function add_request(RequestInterface $request) 368 | { 369 | if (!$request instanceof Request) { 370 | /** @noinspection PhpSillyAssignmentInspection - helper for PhpStorm */ 371 | /** @var RequestInterface $request */ 372 | $request = $request; 373 | 374 | /** @var Request $requestNew */ 375 | $requestNew = Request::{$request->getMethod()}($request->getUri()); 376 | $requestNew->withHeaders($request->getHeaders()); 377 | $requestNew->withProtocolVersion($request->getProtocolVersion()); 378 | $requestNew->withBody($request->getBody()); 379 | $requestNew->withRequestTarget($request->getRequestTarget()); 380 | 381 | $request = $requestNew; 382 | } 383 | 384 | $curl = $request->_curlPrep()->_curl(); 385 | 386 | if ($curl) { 387 | $curl->request = $request; 388 | $this->curlMulti->addCurl($curl); 389 | } 390 | 391 | return $this; 392 | } 393 | } 394 | -------------------------------------------------------------------------------- /src/Httpful/UriResolver.php: -------------------------------------------------------------------------------- 1 | getScheme() !== '' 75 | && 76 | ( 77 | $base->getScheme() !== $target->getScheme() 78 | || 79 | ($target->getAuthority() === '' && $base->getAuthority() !== '') 80 | ) 81 | ) { 82 | return $target; 83 | } 84 | 85 | if (Uri::isRelativePathReference($target)) { 86 | // As the target is already highly relative we return it as-is. It would be possible to resolve 87 | // the target with `$target = self::resolve($base, $target);` and then try make it more relative 88 | // by removing a duplicate query. But let's not do that automatically. 89 | return $target; 90 | } 91 | 92 | $authority = $target->getAuthority(); 93 | if ( 94 | $authority !== '' 95 | && 96 | $base->getAuthority() !== $authority 97 | ) { 98 | return $target->withScheme(''); 99 | } 100 | 101 | // We must remove the path before removing the authority because if the path starts with two slashes, the URI 102 | // would turn invalid. And we also cannot set a relative path before removing the authority, as that is also 103 | // invalid. 104 | $emptyPathUri = $target->withScheme('')->withPath('')->withUserInfo('')->withPort(null)->withHost(''); 105 | 106 | if ($base->getPath() !== $target->getPath()) { 107 | return $emptyPathUri->withPath(self::getRelativePath($base, $target)); 108 | } 109 | 110 | if ($base->getQuery() === $target->getQuery()) { 111 | // Only the target fragment is left. And it must be returned even if base and target fragment are the same. 112 | return $emptyPathUri->withQuery(''); 113 | } 114 | 115 | // If the base URI has a query but the target has none, we cannot return an empty path reference as it would 116 | // inherit the base query component when resolving. 117 | if ($target->getQuery() === '') { 118 | $segments = \explode('/', $target->getPath()); 119 | $lastSegment = \end($segments); 120 | 121 | return $emptyPathUri->withPath($lastSegment === '' ? './' : $lastSegment); 122 | } 123 | 124 | return $emptyPathUri; 125 | } 126 | 127 | /** 128 | * Removes dot segments from a path and returns the new path. 129 | * 130 | * @param string $path 131 | * 132 | * @return string 133 | * 134 | * @see http://tools.ietf.org/html/rfc3986#section-5.2.4 135 | */ 136 | public static function removeDotSegments($path): string 137 | { 138 | if ($path === '' || $path === '/') { 139 | return $path; 140 | } 141 | 142 | $results = []; 143 | $segments = \explode('/', $path); 144 | $segment = ''; 145 | foreach ($segments as $segment) { 146 | if ($segment === '..') { 147 | \array_pop($results); 148 | } elseif ($segment !== '.') { 149 | $results[] = $segment; 150 | } 151 | } 152 | 153 | $newPath = \implode('/', $results); 154 | 155 | if ( 156 | $path[0] === '/' 157 | && 158 | ( 159 | !isset($newPath[0]) 160 | || 161 | $newPath[0] !== '/' 162 | ) 163 | ) { 164 | // Re-add the leading slash if necessary for cases like "/.." 165 | $newPath = '/' . $newPath; 166 | } elseif ( 167 | $newPath !== '' 168 | && 169 | ( 170 | $segment 171 | && 172 | ( 173 | $segment === '.' 174 | || 175 | $segment === '..' 176 | ) 177 | ) 178 | ) { 179 | // Add the trailing slash if necessary 180 | // If newPath is not empty, then $segment must be set and is the last segment from the foreach 181 | $newPath .= '/'; 182 | } 183 | 184 | return $newPath; 185 | } 186 | 187 | /** 188 | * Converts the relative URI into a new URI that is resolved against the base URI. 189 | * 190 | * @param UriInterface $base Base URI 191 | * @param UriInterface $rel Relative URI 192 | * 193 | * @return UriInterface 194 | * 195 | * @see http://tools.ietf.org/html/rfc3986#section-5.2 196 | */ 197 | public static function resolve(UriInterface $base, UriInterface $rel): UriInterface 198 | { 199 | if ((string) $rel === '') { 200 | // we can simply return the same base URI instance for this same-document reference 201 | return $base; 202 | } 203 | 204 | if ($rel->getScheme() !== '') { 205 | return $rel->withPath(self::removeDotSegments($rel->getPath())); 206 | } 207 | 208 | if ($rel->getAuthority() !== '') { 209 | $targetAuthority = $rel->getAuthority(); 210 | $targetPath = self::removeDotSegments($rel->getPath()); 211 | $targetQuery = $rel->getQuery(); 212 | } else { 213 | $targetAuthority = $base->getAuthority(); 214 | if ($rel->getPath() === '') { 215 | $targetPath = $base->getPath(); 216 | $targetQuery = $rel->getQuery() !== '' ? $rel->getQuery() : $base->getQuery(); 217 | } else { 218 | if ($rel->getPath()[0] === '/') { 219 | $targetPath = $rel->getPath(); 220 | } elseif ($targetAuthority !== '' && $base->getPath() === '') { 221 | $targetPath = '/' . $rel->getPath(); 222 | } else { 223 | $lastSlashPos = \strrpos($base->getPath(), '/'); 224 | if ($lastSlashPos === false) { 225 | $targetPath = $rel->getPath(); 226 | } else { 227 | $targetPath = \substr($base->getPath(), 0, $lastSlashPos + 1) . $rel->getPath(); 228 | } 229 | } 230 | $targetPath = self::removeDotSegments($targetPath); 231 | $targetQuery = $rel->getQuery(); 232 | } 233 | } 234 | 235 | return new Uri( 236 | Uri::composeComponents( 237 | $base->getScheme(), 238 | $targetAuthority, 239 | $targetPath, 240 | $targetQuery, 241 | $rel->getFragment() 242 | ) 243 | ); 244 | } 245 | 246 | private static function getRelativePath(UriInterface $base, UriInterface $target): string 247 | { 248 | $sourceSegments = \explode('/', $base->getPath()); 249 | $targetSegments = \explode('/', $target->getPath()); 250 | 251 | \array_pop($sourceSegments); 252 | 253 | $targetLastSegment = \array_pop($targetSegments); 254 | foreach ($sourceSegments as $i => $segment) { 255 | if (isset($targetSegments[$i]) && $segment === $targetSegments[$i]) { 256 | unset($sourceSegments[$i], $targetSegments[$i]); 257 | } else { 258 | break; 259 | } 260 | } 261 | 262 | $targetSegments[] = $targetLastSegment; 263 | 264 | $relativePath = \str_repeat('../', \count($sourceSegments)) . \implode('/', $targetSegments); 265 | 266 | // A reference to am empty last segment or an empty first sub-segment must be prefixed with "./". 267 | // This also applies to a segment with a colon character (e.g., "file:colon") that cannot be used 268 | // as the first segment of a relative-path reference, as it would be mistaken for a scheme name. 269 | /* @phpstan-ignore-next-line | FP? */ 270 | if ($relativePath === '' || \strpos(\explode('/', $relativePath, 2)[0], ':') !== false) { 271 | $relativePath = "./{$relativePath}"; 272 | } elseif ($relativePath[0] === '/') { 273 | if ($base->getAuthority() !== '' && $base->getPath() === '') { 274 | // In this case an extra slash is added by resolve() automatically. So we must not add one here. 275 | $relativePath = ".{$relativePath}"; 276 | } else { 277 | $relativePath = "./{$relativePath}"; 278 | } 279 | } 280 | 281 | return $relativePath; 282 | } 283 | } 284 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 3.0.1 (2023-07-22) 4 | 5 | - "composer.json" -> provide "psr/http-factory-implementation" 6 | 7 | ## 3.0.0 (2023-07-20) 8 | 9 | - minimal PHP version 7.4 10 | - allow to use "psr/http-message" 2.0.* 11 | - allow to use "psr/log" 2.0.* || 3.0.* 12 | 13 | breaking change: 14 | - "Response->hasBody()" was fixed, now it will return `false` for an empty body 15 | - "Request->getUri()" now always returns an `UriInterface` , if we need the old behaviors, use can use "Request->getUriOrNull()" 16 | - "Stream->getContents()" now always returns a `string`, if we need the old behaviors, use can use "Stream->getContentsUnserialized()" 17 | - "psr/http-message" v2 has return types, so you need to use them too, if you extend one of this classes 18 | 19 | ## 2.4.9 (2023-07-15) 20 | 21 | - use "ReturnTypeWillChange" to ignore return type changes from PHP >= 8.1 22 | 23 | ## 2.4.8 (2023-07-14) 24 | 25 | - update dependencies "httplug / http-message" 26 | 27 | ## 2.4.7 (2021-12-08) 28 | 29 | - update "portable-utf8" 30 | 31 | ## 2.4.6 (2021-10-15) 32 | 33 | - fix file upload 34 | 35 | ## 2.4.5 (2021-09-14) 36 | 37 | - "XmlMimeHandler" -> show the broken xml 38 | 39 | ## 2.4.4 (2021-09-09) 40 | 41 | - fixes for phpdoc only 42 | 43 | ## 2.4.3 (2021-04-07) 44 | 45 | - fix for old PHP versions 46 | - use Github Actions 47 | 48 | ## 2.4.2 (2020-11-18) 49 | 50 | - update vendor stuff + fix tests 51 | 52 | ## 2.4.1 (2020-05-04) 53 | 54 | - "Client->download()" -> added timeout parameter 55 | - "HtmlMimeHandler" -> fix non UTF-8 string input 56 | - "Response" -> fix Header-Parsing for empty responses 57 | 58 | ## 2.4.0 (2020-03-06) 59 | 60 | - add "Request->withPort(int $port)" 61 | - fix "Request Body Not Preserved" #7 62 | 63 | ## 2.3.2 (2020-02-29) 64 | 65 | - "ClientMulti" -> add "add_html()" 66 | 67 | ## 2.3.1 (2020-02-29) 68 | 69 | - merge upstream fixes from https://github.com/php-curl-class/php-curl-class/ 70 | 71 | ## 2.3.0 (2020-01-28) 72 | 73 | - optimize "RequestInterface"-integration 74 | 75 | ## 2.2.0 (2020-01-28) 76 | 77 | - add "ClientPromise" (\Http\Client\HttpAsyncClient) 78 | 79 | ## 2.1.0 (2019-12-19) 80 | 81 | - return $this for many methods from "Curl" & "MultiCurl" 82 | - optimize the speed of "MultiCurl" 83 | - use phpstan (0.12) + add more phpdocs 84 | 85 | ## 2.0.0 (2019-11-15) 86 | 87 | - add $params for "GET" / "DELETE" requests 88 | - free some more memory 89 | - more helpfully exception messages 90 | - fixes callbacks for "ClientMulti" 91 | 92 | ## 1.0.0 (2019-11-13) 93 | 94 | - fix all bugs reported by phpstan 95 | - clean-up dependencies 96 | - fix async support for POST data 97 | 98 | ## 0.10.0 (2019-11-12) 99 | 100 | - add support for async requests via CurlMulti 101 | 102 | ## 0.9.0 (2019-07-16) 103 | 104 | - add new header functions + many tests 105 | 106 | ## 0.8.0 (2019-07-06) 107 | 108 | - fix implementation of PSR standards + many tests 109 | 110 | ## 0.7.1 (2019-05-01) 111 | 112 | - fix "addHeaders()" 113 | 114 | ## 0.7.0 (2019-04-30) 115 | 116 | - fix return types of "Handlers" 117 | - add more helper functions for "Client" (with auto-completion via phpdoc) 118 | 119 | ## 0.6.0 (2019-04-30) 120 | 121 | - make more properties private && classes final v2 122 | - fix array usage with "Stream" 123 | - move "Request->init" into the "__constructor" 124 | - rename some internal classes + methods 125 | 126 | ## 0.5.0 (2019-04-29) 127 | 128 | - FEATURE Add "PSR-3" logging 129 | - FEATURE Add "PSR-18" HTTP Client - "\Httpful\Client" 130 | - FEATURE Add "PSR-7" - RequestInterface && ResponseInterface 131 | - fix issues reported by phpstan (level 7) 132 | - make properties private && classes final v1 133 | 134 | ## 0.4.x 135 | 136 | - update vendor 137 | - fix return types 138 | 139 | ## 0.3.x 140 | 141 | - drop support for < PHP7 142 | - use return types 143 | 144 | ## 0.2.x 145 | 146 | - "Add convenience methods for appending parameters to query string." [PR #65](https://github.com/nategood/httpful/pull/65) 147 | - "Give more information to the Exception object to enable better error handling" [PR #117](https://github.com/nategood/httpful/pull/117) 148 | - "Solves issue #170: HTTP Header parsing is inconsistent" [PR #182](https://github.com/nategood/httpful/pull/182) 149 | - "added support for http_proxy environment variable" [PR #183](https://github.com/nategood/httpful/pull/183) 150 | - "Fix for frameworks that use object proxies" + fixes phpdoc [PR #205](https://github.com/nategood/httpful/pull/205) 151 | - "ConnectionErrorException cURLError" [PR #207](https://github.com/nategood/httpful/pull/208) 152 | - "Added explicit support for expectsXXX" [PR #210](https://github.com/nategood/httpful/pull/210) 153 | - "Add connection timeout" [PR #215](https://github.com/nategood/httpful/pull/215) 154 | - use "portable-utf8" [voku](https://github.com/voku/httpful/commit/3b4b36bd65bdecd0dafaa7ace336ac9f629a0e5a) 155 | - fixed code-style / added php-docs / added "alias"-methods ... [voku](https://github.com/voku/httpful/commit/3b82723609d5decc6521b94d336f090bc9d764e3) 156 | 157 | ## 0.2.20 158 | 159 | - MINOR Move Response building logic into separate function [PR #193](https://github.com/nategood/httpful/pull/193) 160 | 161 | ## 0.2.19 162 | 163 | - FEATURE Before send hook [PR #164](https://github.com/nategood/httpful/pull/164) 164 | - MINOR More descriptive connection exceptions [PR #166](https://github.com/nategood/httpful/pull/166) 165 | 166 | ## 0.2.18 167 | 168 | - FIX [PR #149](https://github.com/nategood/httpful/pull/149) 169 | - FIX [PR #150](https://github.com/nategood/httpful/pull/150) 170 | - FIX [PR #156](https://github.com/nategood/httpful/pull/156) 171 | 172 | ## 0.2.17 173 | 174 | - FEATURE [PR #144](https://github.com/nategood/httpful/pull/144) Adds additional parameter to the Response class to specify additional meta data about the request/response (e.g. number of redirect). 175 | 176 | ## 0.2.16 177 | 178 | - FEATURE Added support for whenError to define a custom callback to be fired upon error. Useful for logging or overriding the default error_log behavior. 179 | 180 | ## 0.2.15 181 | 182 | - FEATURE [I #131](https://github.com/nategood/httpful/pull/131) Support for SOCKS proxy 183 | 184 | ## 0.2.14 185 | 186 | - FEATURE [I #138](https://github.com/nategood/httpful/pull/138) Added alternative option for XML request construction. In the next major release this will likely supplant the older version. 187 | 188 | ## 0.2.13 189 | 190 | - REFACTOR [I #121](https://github.com/nategood/httpful/pull/121) Throw more descriptive exception on curl errors 191 | - REFACTOR [I #122](https://github.com/nategood/httpful/issues/122) Better proxy scrubbing in Request 192 | - REFACTOR [I #119](https://github.com/nategood/httpful/issues/119) Better document the mimeType param on Request::body 193 | - Misc code and test cleanup 194 | 195 | ## 0.2.12 196 | 197 | - REFACTOR [I #123](https://github.com/nategood/httpful/pull/123) Support new curl file upload method 198 | - FEATURE [I #118](https://github.com/nategood/httpful/pull/118) 5.4 HTTP Test Server 199 | - FIX [I #109](https://github.com/nategood/httpful/pull/109) Typo 200 | - FIX [I #103](https://github.com/nategood/httpful/pull/103) Handle also CURLOPT_SSL_VERIFYHOST for strictSsl mode 201 | 202 | ## 0.2.11 203 | 204 | - FIX [I #99](https://github.com/nategood/httpful/pull/99) Prevent hanging on HEAD requests 205 | 206 | ## 0.2.10 207 | 208 | - FIX [I #93](https://github.com/nategood/httpful/pull/86) Fixes edge case where content-length would be set incorrectly 209 | 210 | ## 0.2.9 211 | 212 | - FEATURE [I #89](https://github.com/nategood/httpful/pull/89) multipart/form-data support (a.k.a. file uploads)! Thanks @dtelaroli! 213 | 214 | ## 0.2.8 215 | 216 | - FIX Notice fix for Pull Request 86 217 | 218 | ## 0.2.7 219 | 220 | - FIX [I #86](https://github.com/nategood/httpful/pull/86) Remove Connection Established header when using a proxy 221 | 222 | ## 0.2.6 223 | 224 | - FIX [I #85](https://github.com/nategood/httpful/issues/85) Empty Content Length issue resolved 225 | 226 | ## 0.2.5 227 | 228 | - FEATURE [I #80](https://github.com/nategood/httpful/issues/80) [I #81](https://github.com/nategood/httpful/issues/81) Proxy support added with `useProxy` method. 229 | 230 | ## 0.2.4 231 | 232 | - FEATURE [I #77](https://github.com/nategood/httpful/issues/77) Convenience method for setting a timeout (seconds) `$req->timeoutIn(10);` 233 | - FIX [I #75](https://github.com/nategood/httpful/issues/75) [I #78](https://github.com/nategood/httpful/issues/78) Bug with checking if digest auth is being used. 234 | 235 | ## 0.2.3 236 | 237 | - FIX Overriding default Mime Handlers 238 | - FIX [PR #73](https://github.com/nategood/httpful/pull/73) Parsing http status codes 239 | 240 | ## 0.2.2 241 | 242 | - FEATURE Add support for parsing JSON responses as associative arrays instead of objects 243 | - FEATURE Better support for setting constructor arguments on Mime Handlers 244 | 245 | ## 0.2.1 246 | 247 | - FEATURE [PR #72](https://github.com/nategood/httpful/pull/72) Allow support for custom Accept header 248 | 249 | ## 0.2.0 250 | 251 | - REFACTOR [PR #49](https://github.com/nategood/httpful/pull/49) Broke headers out into their own class 252 | - REFACTOR [PR #54](https://github.com/nategood/httpful/pull/54) Added more specific Exceptions 253 | - FIX [PR #58](https://github.com/nategood/httpful/pull/58) Fixes throwing an error on an empty xml response 254 | - FEATURE [PR #57](https://github.com/nategood/httpful/pull/57) Adds support for digest authentication 255 | 256 | ## 0.1.6 257 | 258 | - Ability to set the number of max redirects via overloading `followRedirects(int max_redirects)` 259 | - Standards Compliant fix to `Accepts` header 260 | - Bug fix for bootstrap process when installed via Composer 261 | 262 | ## 0.1.5 263 | 264 | - Use `DIRECTORY_SEPARATOR` constant [PR #33](https://github.com/nategood/httpful/pull/32) 265 | - [PR #35](https://github.com/nategood/httpful/pull/35) 266 | - Added the raw\_headers property reference to response. 267 | - Compose request header and added raw\_header to Request object. 268 | - Fixed response has errors and added more comments for clarity. 269 | - Fixed header parsing to allow the minimum (status line only) and also cater for the actual CRLF ended headers as per RFC2616. 270 | - Added the perfect test Accept: header for all Acceptable scenarios see @b78e9e82cd9614fbe137c01bde9439c4e16ca323 for details. 271 | - Added default User-Agent header 272 | - `User-Agent: Httpful/0.1.5` + curl version + server software + PHP version 273 | - To bypass this "default" operation simply add a User-Agent to the request headers even a blank User-Agent is sufficient and more than simple enough to produce me thinks. 274 | - Completed test units for additions. 275 | - Added phpunit coverage reporting and helped phpunit auto locate the tests a bit easier. 276 | 277 | ## 0.1.4 278 | 279 | - Add support for CSV Handling [PR #32](https://github.com/nategood/httpful/pull/32) 280 | 281 | ## 0.1.3 282 | 283 | - Handle empty responses in JsonParser and XmlParser 284 | 285 | ## 0.1.2 286 | 287 | - Added support for setting XMLHandler configuration options 288 | - Added examples for overriding XmlHandler and registering a custom parser 289 | - Removed the httpful.php download (deprecated in favor of httpful.phar) 290 | 291 | ## 0.1.1 292 | 293 | - Bug fix serialization default case and phpunit tests 294 | 295 | ## 0.1.0 296 | 297 | - Added Support for Registering Mime Handlers 298 | - Created AbstractMimeHandler type that all Mime Handlers must extend 299 | - Pulled out the parsing/serializing logic from the Request/Response classes into their own MimeHandler classes 300 | - Added ability to register new mime handlers for mime types 301 | 302 | -------------------------------------------------------------------------------- /src/Httpful/Headers.php: -------------------------------------------------------------------------------- 1 | $value) { 48 | if (!\is_array($value)) { 49 | $value = [$value]; 50 | } 51 | 52 | $this->forceSet($key, $value); 53 | } 54 | } 55 | } 56 | 57 | /** 58 | * @see https://secure.php.net/manual/en/countable.count.php 59 | * 60 | * @return int the number of elements stored in the array 61 | */ 62 | #[\ReturnTypeWillChange] 63 | public function count() 64 | { 65 | return (int) \count($this->data); 66 | } 67 | 68 | /** 69 | * @see https://secure.php.net/manual/en/iterator.current.php 70 | * 71 | * @return mixed data at the current position 72 | */ 73 | #[\ReturnTypeWillChange] 74 | public function current() 75 | { 76 | return \current($this->data); 77 | } 78 | 79 | /** 80 | * @see https://secure.php.net/manual/en/iterator.key.php 81 | * 82 | * @return mixed case-sensitive key at current position 83 | */ 84 | #[\ReturnTypeWillChange] 85 | public function key() 86 | { 87 | $key = \key($this->data); 88 | 89 | return $this->keys[$key] ?? $key; 90 | } 91 | 92 | /** 93 | * @see https://secure.php.net/manual/en/iterator.next.php 94 | * 95 | * @return void 96 | */ 97 | #[\ReturnTypeWillChange] 98 | public function next() 99 | { 100 | \next($this->data); 101 | } 102 | 103 | /** 104 | * @see https://secure.php.net/manual/en/iterator.rewind.php 105 | * 106 | * @return void 107 | */ 108 | #[\ReturnTypeWillChange] 109 | public function rewind() 110 | { 111 | \reset($this->data); 112 | } 113 | 114 | /** 115 | * @see https://secure.php.net/manual/en/iterator.valid.php 116 | * 117 | * @return bool if the current position is valid 118 | */ 119 | #[\ReturnTypeWillChange] 120 | public function valid() 121 | { 122 | return \key($this->data) !== null; 123 | } 124 | 125 | /** 126 | * @param string $offset the offset to store the data at (case-insensitive) 127 | * @param mixed $value the data to store at the specified offset 128 | * 129 | * @return void 130 | */ 131 | public function forceSet($offset, $value) 132 | { 133 | $value = $this->_validateAndTrimHeader($offset, $value); 134 | 135 | $this->offsetSetForce($offset, $value); 136 | } 137 | 138 | /** 139 | * @param string $offset 140 | * 141 | * @return void 142 | */ 143 | public function forceUnset($offset) 144 | { 145 | $this->offsetUnsetForce($offset); 146 | } 147 | 148 | /** 149 | * @param string $string 150 | * 151 | * @return Headers 152 | */ 153 | public static function fromString($string): self 154 | { 155 | // init 156 | $parsed_headers = []; 157 | 158 | $headers = \preg_split("/[\r\n]+/", $string, -1, \PREG_SPLIT_NO_EMPTY); 159 | if ($headers === false) { 160 | return new self($parsed_headers); 161 | } 162 | 163 | $headersCount = \count($headers); 164 | for ($i = 1; $i < $headersCount; ++$i) { 165 | $header = $headers[$i]; 166 | 167 | if (\strpos($header, ':') === false) { 168 | continue; 169 | } 170 | 171 | list($key, $raw_value) = \explode(':', $header, 2); 172 | $key = \trim($key); 173 | $value = \trim($raw_value); 174 | 175 | if (\array_key_exists($key, $parsed_headers)) { 176 | $parsed_headers[$key][] = $value; 177 | } else { 178 | $parsed_headers[$key][] = $value; 179 | } 180 | } 181 | 182 | return new self($parsed_headers); 183 | } 184 | 185 | /** 186 | * Checks if the offset exists in data storage. The index is looked up with 187 | * the lowercase version of the provided offset. 188 | * 189 | * @see https://secure.php.net/manual/en/arrayaccess.offsetexists.php 190 | * 191 | * @param string $offset Offset to check 192 | * 193 | * @return bool if the offset exists 194 | */ 195 | #[\ReturnTypeWillChange] 196 | public function offsetExists($offset) 197 | { 198 | return (bool) \array_key_exists(\strtolower($offset), $this->data); 199 | } 200 | 201 | /** 202 | * Return the stored data at the provided offset. The offset is converted to 203 | * lowercase and the lookup is done on the data store directly. 204 | * 205 | * @see https://secure.php.net/manual/en/arrayaccess.offsetget.php 206 | * 207 | * @param string $offset offset to lookup 208 | * 209 | * @return mixed the data stored at the offset 210 | */ 211 | #[\ReturnTypeWillChange] 212 | public function offsetGet($offset) 213 | { 214 | $offsetLower = \strtolower($offset); 215 | 216 | return $this->data[$offsetLower] ?? null; 217 | } 218 | 219 | /** 220 | * @param string $offset 221 | * @param string $value 222 | * 223 | * @throws ResponseHeaderException 224 | * 225 | * @return void 226 | */ 227 | #[\ReturnTypeWillChange] 228 | public function offsetSet($offset, $value) 229 | { 230 | throw new ResponseHeaderException('Headers are read-only.'); 231 | } 232 | 233 | /** 234 | * @param string $offset 235 | * 236 | * @throws ResponseHeaderException 237 | * 238 | * @return void 239 | */ 240 | #[\ReturnTypeWillChange] 241 | public function offsetUnset($offset) 242 | { 243 | throw new ResponseHeaderException('Headers are read-only.'); 244 | } 245 | 246 | /** 247 | * @return array 248 | */ 249 | public function toArray(): array 250 | { 251 | // init 252 | $return = []; 253 | 254 | $that = clone $this; 255 | 256 | foreach ($that as $key => $value) { 257 | if (\is_array($value)) { 258 | foreach ($value as $keyInner => $valueInner) { 259 | $value[$keyInner] = \trim($valueInner, " \t"); 260 | } 261 | } 262 | 263 | $return[$key] = $value; 264 | } 265 | 266 | return $return; 267 | } 268 | 269 | /** 270 | * Make sure the header complies with RFC 7230. 271 | * 272 | * Header names must be a non-empty string consisting of token characters. 273 | * 274 | * Header values must be strings consisting of visible characters with all optional 275 | * leading and trailing whitespace stripped. This method will always strip such 276 | * optional whitespace. Note that the method does not allow folding whitespace within 277 | * the values as this was deprecated for almost all instances by the RFC. 278 | * 279 | * header-field = field-name ":" OWS field-value OWS 280 | * field-name = 1*( "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / "^" 281 | * / "_" / "`" / "|" / "~" / %x30-39 / ( %x41-5A / %x61-7A ) ) 282 | * OWS = *( SP / HTAB ) 283 | * field-value = *( ( %x21-7E / %x80-FF ) [ 1*( SP / HTAB ) ( %x21-7E / %x80-FF ) ] ) 284 | * 285 | * @see https://tools.ietf.org/html/rfc7230#section-3.2.4 286 | * 287 | * @param mixed $header 288 | * @param mixed $values 289 | * 290 | * @return string[] 291 | */ 292 | private function _validateAndTrimHeader($header, $values): array 293 | { 294 | if ( 295 | !\is_string($header) 296 | || 297 | \preg_match("@^[!#$%&'*+.^_`|~0-9A-Za-z-]+$@", $header) !== 1 298 | ) { 299 | throw new \InvalidArgumentException('Header name must be an RFC 7230 compatible string: ' . \print_r($header, true)); 300 | } 301 | 302 | if (!\is_array($values)) { 303 | // This is simple, just one value. 304 | if ( 305 | (!\is_numeric($values) && !\is_string($values)) 306 | || 307 | \preg_match("@^[ \t\x21-\x7E\x80-\xFF]*$@", (string) $values) !== 1 308 | ) { 309 | throw new \InvalidArgumentException('Header values must be RFC 7230 compatible strings: ' . \print_r($header, true)); 310 | } 311 | 312 | return [\trim((string) $values, " \t")]; 313 | } 314 | 315 | if (empty($values)) { 316 | throw new \InvalidArgumentException('Header values must be a string or an array of strings, empty array given.'); 317 | } 318 | 319 | // Assert Non empty array 320 | $returnValues = []; 321 | foreach ($values as $v) { 322 | if ( 323 | (!\is_numeric($v) && !\is_string($v)) 324 | || 325 | \preg_match("@^[ \t\x21-\x7E\x80-\xFF]*$@", (string) $v) !== 1 326 | ) { 327 | throw new \InvalidArgumentException('Header values must be RFC 7230 compatible strings: ' . \print_r($v, true)); 328 | } 329 | 330 | $returnValues[] = \trim((string) $v, " \t"); 331 | } 332 | 333 | return $returnValues; 334 | } 335 | 336 | /** 337 | * Set data at a specified offset. Converts the offset to lowercase, and 338 | * stores the case-sensitive offset and the data at the lowercase indexes in 339 | * $this->keys and @this->data. 340 | * 341 | * @see https://secure.php.net/manual/en/arrayaccess.offsetset.php 342 | * 343 | * @param string|null $offset the offset to store the data at (case-insensitive) 344 | * @param mixed $value the data to store at the specified offset 345 | * 346 | * @return void 347 | */ 348 | private function offsetSetForce($offset, $value) 349 | { 350 | if ($offset === null) { 351 | $this->data[] = $value; 352 | } else { 353 | $offsetlower = \strtolower($offset); 354 | $this->data[$offsetlower] = $value; 355 | $this->keys[$offsetlower] = $offset; 356 | } 357 | } 358 | 359 | /** 360 | * Unsets the specified offset. Converts the provided offset to lowercase, 361 | * and unsets the case-sensitive key, as well as the stored data. 362 | * 363 | * @see https://secure.php.net/manual/en/arrayaccess.offsetunset.php 364 | * 365 | * @param string $offset the offset to unset 366 | * 367 | * @return void 368 | */ 369 | private function offsetUnsetForce($offset) 370 | { 371 | $offsetLower = \strtolower($offset); 372 | 373 | unset($this->data[$offsetLower], $this->keys[$offsetLower]); 374 | } 375 | } 376 | -------------------------------------------------------------------------------- /src/Httpful/Curl/MultiCurl.php: -------------------------------------------------------------------------------- 1 | multiCurl = \curl_multi_init(); 73 | } 74 | 75 | public function __destruct() 76 | { 77 | $this->close(); 78 | } 79 | 80 | /** 81 | * Add a Curl instance to the handle queue. 82 | * 83 | * @param Curl $curl 84 | * 85 | * @return $this 86 | */ 87 | public function addCurl(Curl $curl) 88 | { 89 | $this->queueHandle($curl); 90 | 91 | return $this; 92 | } 93 | 94 | /** 95 | * @param callable $callback 96 | * 97 | * @return $this 98 | */ 99 | public function beforeSend($callback) 100 | { 101 | $this->beforeSendCallback = $callback; 102 | 103 | return $this; 104 | } 105 | 106 | /** 107 | * @return void 108 | */ 109 | public function close() 110 | { 111 | foreach ($this->curls as $curl) { 112 | $curl->close(); 113 | } 114 | 115 | if ( 116 | \is_resource($this->multiCurl) 117 | || 118 | (class_exists('CurlMultiHandle') && $this->multiCurl instanceof \CurlMultiHandle) 119 | ) { 120 | \curl_multi_close($this->multiCurl); 121 | } 122 | } 123 | 124 | /** 125 | * @param callable $callback 126 | * 127 | * @return $this; 128 | */ 129 | public function complete($callback) 130 | { 131 | $this->completeCallback = $callback; 132 | 133 | return $this; 134 | } 135 | 136 | /** 137 | * @param callable $callback 138 | * 139 | * @return $this 140 | */ 141 | public function error($callback) 142 | { 143 | $this->errorCallback = $callback; 144 | 145 | return $this; 146 | } 147 | 148 | /** 149 | * @param Curl $curl 150 | * @param callable|string $mixed_filename 151 | * 152 | * @return Curl 153 | */ 154 | public function addDownload(Curl $curl, $mixed_filename) 155 | { 156 | $this->queueHandle($curl); 157 | 158 | // Use tmpfile() or php://temp to avoid "Too many open files" error. 159 | if (\is_callable($mixed_filename)) { 160 | $curl->downloadCompleteCallback = $mixed_filename; 161 | $curl->downloadFileName = null; 162 | $curl->fileHandle = \tmpfile(); 163 | } else { 164 | $filename = $mixed_filename; 165 | 166 | // Use a temporary file when downloading. Not using a temporary file can cause an error when an existing 167 | // file has already fully completed downloading and a new download is started with the same destination save 168 | // path. The download request will include header "Range: bytes=$filesize-" which is syntactically valid, 169 | // but unsatisfiable. 170 | $download_filename = $filename . '.pccdownload'; 171 | $curl->downloadFileName = $download_filename; 172 | 173 | // Attempt to resume download only when a temporary download file exists and is not empty. 174 | if (\is_file($download_filename) && $filesize = \filesize($download_filename)) { 175 | $first_byte_position = $filesize; 176 | $range = $first_byte_position . '-'; 177 | $curl->setRange($range); 178 | $curl->fileHandle = \fopen($download_filename, 'ab'); 179 | 180 | // Move the downloaded temporary file to the destination save path. 181 | $curl->downloadCompleteCallback = static function ($instance, $fh) use ($download_filename, $filename) { 182 | // Close the open file handle before renaming the file. 183 | if (\is_resource($fh)) { 184 | \fclose($fh); 185 | } 186 | 187 | \rename($download_filename, $filename); 188 | }; 189 | } else { 190 | $curl->fileHandle = \fopen('php://temp', 'wb'); 191 | $curl->downloadCompleteCallback = static function ($instance, $fh) use ($filename) { 192 | \file_put_contents($filename, \stream_get_contents($fh)); 193 | }; 194 | } 195 | } 196 | 197 | if ($curl->fileHandle === false) { 198 | throw new \Httpful\Exception\ClientErrorException('Unable to write to file:' . $curl->downloadFileName); 199 | } 200 | 201 | $curl->setFile($curl->fileHandle); 202 | $curl->setOpt(\CURLOPT_CUSTOMREQUEST, 'GET'); 203 | $curl->setOpt(\CURLOPT_HTTPGET, true); 204 | 205 | return $curl; 206 | } 207 | 208 | /** 209 | * @param int $concurrency 210 | * 211 | * @return $this 212 | */ 213 | public function setConcurrency($concurrency) 214 | { 215 | $this->concurrency = $concurrency; 216 | 217 | return $this; 218 | } 219 | 220 | /** 221 | * @param string $key 222 | * @param mixed $value 223 | * 224 | * @return $this 225 | */ 226 | public function setCookie($key, $value) 227 | { 228 | $this->cookies[$key] = $value; 229 | 230 | return $this; 231 | } 232 | 233 | /** 234 | * @param array $cookies 235 | * 236 | * @return $this 237 | */ 238 | public function setCookies($cookies) 239 | { 240 | foreach ($cookies as $key => $value) { 241 | $this->cookies[$key] = $value; 242 | } 243 | 244 | return $this; 245 | } 246 | 247 | /** 248 | * Number of retries to attempt or decider callable. 249 | * 250 | * When using a number of retries to attempt, the maximum number of attempts 251 | * for the request is $maximum_number_of_retries + 1. 252 | * 253 | * When using a callable decider, the request will be retried until the 254 | * function returns a value which evaluates to false. 255 | * 256 | * @param callable|int $mixed 257 | * 258 | * @return $this 259 | */ 260 | public function setRetry($mixed) 261 | { 262 | $this->retry = $mixed; 263 | 264 | return $this; 265 | } 266 | 267 | /** 268 | * @return $this|null 269 | */ 270 | public function start() 271 | { 272 | if ($this->isStarted) { 273 | return null; 274 | } 275 | 276 | $this->isStarted = true; 277 | 278 | $concurrency = $this->concurrency; 279 | if ($concurrency > \count($this->curls)) { 280 | $concurrency = \count($this->curls); 281 | } 282 | 283 | for ($i = 0; $i < $concurrency; ++$i) { 284 | $curlOrNull = \array_shift($this->curls); 285 | if ($curlOrNull !== null) { 286 | $this->initHandle($curlOrNull); 287 | } 288 | } 289 | 290 | $active = null; 291 | do { 292 | // Wait for activity on any curl_multi connection when curl_multi_select (libcurl) fails to correctly block. 293 | // https://bugs.php.net/bug.php?id=63411 294 | if ($active && \curl_multi_select($this->multiCurl) === -1) { 295 | \usleep(250); 296 | } 297 | 298 | \curl_multi_exec($this->multiCurl, $active); 299 | 300 | while (!(($info_array = \curl_multi_info_read($this->multiCurl)) === false)) { 301 | if ($info_array['msg'] === \CURLMSG_DONE) { 302 | foreach ($this->activeCurls as $key => $curl) { 303 | $curlRes = $curl->getCurl(); 304 | if ($curlRes === false) { 305 | continue; 306 | } 307 | 308 | if ($curlRes === $info_array['handle']) { 309 | // Set the error code for multi handles using the "result" key in the array returned by 310 | // curl_multi_info_read(). Using curl_errno() on a multi handle will incorrectly return 0 311 | // for errors. 312 | $curl->curlErrorCode = $info_array['result']; 313 | $curl->exec($curlRes); 314 | 315 | if ($curl->attemptRetry()) { 316 | // Remove completed handle before adding again in order to retry request. 317 | \curl_multi_remove_handle($this->multiCurl, $curlRes); 318 | 319 | $curlm_error_code = \curl_multi_add_handle($this->multiCurl, $curlRes); 320 | if ($curlm_error_code !== \CURLM_OK) { 321 | throw new \ErrorException( 322 | 'cURL multi add handle error: ' . \curl_multi_strerror($curlm_error_code) 323 | ); 324 | } 325 | } else { 326 | $curl->execDone(); 327 | 328 | // Remove completed instance from active curls. 329 | unset($this->activeCurls[$key]); 330 | 331 | // Start new requests before removing the handle of the completed one. 332 | while (\count($this->curls) >= 1 && \count($this->activeCurls) < $this->concurrency) { 333 | $curlOrNull = \array_shift($this->curls); 334 | if ($curlOrNull !== null) { 335 | $this->initHandle($curlOrNull); 336 | } 337 | } 338 | \curl_multi_remove_handle($this->multiCurl, $curlRes); 339 | 340 | // Clean up completed instance. 341 | $curl->close(); 342 | } 343 | 344 | break; 345 | } 346 | } 347 | } 348 | } 349 | 350 | if (!$active) { 351 | $active = \count($this->activeCurls); 352 | } 353 | } while ($active > 0); 354 | 355 | $this->isStarted = false; 356 | 357 | return $this; 358 | } 359 | 360 | /** 361 | * @param callable $callback 362 | * 363 | * @return $this 364 | */ 365 | public function success($callback) 366 | { 367 | $this->successCallback = $callback; 368 | 369 | return $this; 370 | } 371 | 372 | /** 373 | * @return resource|\CurlMultiHandle 374 | */ 375 | public function getMultiCurl() 376 | { 377 | return $this->multiCurl; 378 | } 379 | 380 | /** 381 | * @param Curl $curl 382 | * 383 | * @throws \ErrorException 384 | * 385 | * @return void 386 | */ 387 | private function initHandle($curl) 388 | { 389 | // Set callbacks if not already individually set. 390 | 391 | if ($curl->beforeSendCallback === null) { 392 | $curl->beforeSend($this->beforeSendCallback); 393 | } 394 | 395 | if ($curl->successCallback === null) { 396 | $curl->success($this->successCallback); 397 | } 398 | 399 | if ($curl->errorCallback === null) { 400 | $curl->error($this->errorCallback); 401 | } 402 | 403 | if ($curl->completeCallback === null) { 404 | $curl->complete($this->completeCallback); 405 | } 406 | 407 | $curl->setRetry($this->retry); 408 | $curl->setCookies($this->cookies); 409 | 410 | $curlRes = $curl->getCurl(); 411 | if ($curlRes === false) { 412 | throw new \ErrorException('cURL multi add handle error from curl: curl === false'); 413 | } 414 | 415 | $curlm_error_code = \curl_multi_add_handle($this->multiCurl, $curlRes); 416 | if ($curlm_error_code !== \CURLM_OK) { 417 | throw new \ErrorException('cURL multi add handle error: ' . \curl_multi_strerror($curlm_error_code)); 418 | } 419 | 420 | $this->activeCurls[$curl->getId()] = $curl; 421 | $curl->call($curl->beforeSendCallback); 422 | } 423 | 424 | /** 425 | * @param Curl $curl 426 | * 427 | * @return void 428 | */ 429 | private function queueHandle($curl) 430 | { 431 | // Use sequential ids to allow for ordered post processing. 432 | ++$this->nextCurlId; 433 | $curl->setId($this->nextCurlId); 434 | $curl->setChildOfMultiCurl(true); 435 | $this->curls[$this->nextCurlId] = $curl; 436 | } 437 | } 438 | -------------------------------------------------------------------------------- /src/Httpful/Stream.php: -------------------------------------------------------------------------------- 1 | > Hash of readable and writable stream types 24 | */ 25 | const READ_WRITE_HASH = [ 26 | 'read' => [ 27 | 'r' => true, 28 | 'w+' => true, 29 | 'r+' => true, 30 | 'x+' => true, 31 | 'c+' => true, 32 | 'rb' => true, 33 | 'w+b' => true, 34 | 'r+b' => true, 35 | 'x+b' => true, 36 | 'c+b' => true, 37 | 'rt' => true, 38 | 'w+t' => true, 39 | 'r+t' => true, 40 | 'x+t' => true, 41 | 'c+t' => true, 42 | 'a+' => true, 43 | ], 44 | 'write' => [ 45 | 'w' => true, 46 | 'w+' => true, 47 | 'rw' => true, 48 | 'r+' => true, 49 | 'x+' => true, 50 | 'c+' => true, 51 | 'wb' => true, 52 | 'w+b' => true, 53 | 'r+b' => true, 54 | 'x+b' => true, 55 | 'c+b' => true, 56 | 'w+t' => true, 57 | 'r+t' => true, 58 | 'x+t' => true, 59 | 'c+t' => true, 60 | 'a' => true, 61 | 'a+' => true, 62 | ], 63 | ]; 64 | 65 | /** 66 | * @var string 67 | */ 68 | const WRITABLE_MODES = '/a|w|r\+|rb\+|rw|x|c/'; 69 | 70 | /** 71 | * @var resource|null 72 | */ 73 | private $stream; 74 | 75 | /** 76 | * @var int|null 77 | */ 78 | private $size; 79 | 80 | /** 81 | * @var bool 82 | */ 83 | private $seekable; 84 | 85 | /** 86 | * @var bool 87 | */ 88 | private $readable; 89 | 90 | /** 91 | * @var bool 92 | */ 93 | private $writable; 94 | 95 | /** 96 | * @var string|null 97 | */ 98 | private $uri; 99 | 100 | /** 101 | * @var array 102 | */ 103 | private $customMetadata; 104 | 105 | /** 106 | * @var bool 107 | */ 108 | private $serialized; 109 | 110 | /** 111 | * This constructor accepts an associative array of options. 112 | * 113 | * - size: (int) If a read stream would otherwise have an indeterminate 114 | * size, but the size is known due to foreknowledge, then you can 115 | * provide that size, in bytes. 116 | * - metadata: (array) Any additional metadata to return when the metadata 117 | * of the stream is accessed. 118 | * 119 | * @param resource $stream stream resource to wrap 120 | * @param array $options associative array of options 121 | * 122 | * @throws \InvalidArgumentException if the stream is not a stream resource 123 | */ 124 | public function __construct($stream, $options = []) 125 | { 126 | if (!\is_resource($stream)) { 127 | throw new \InvalidArgumentException('Stream must be a resource'); 128 | } 129 | 130 | if (isset($options['size'])) { 131 | $this->size = (int) $options['size']; 132 | } 133 | 134 | $this->customMetadata = $options['metadata'] ?? []; 135 | 136 | $this->serialized = $options['serialized'] ?? false; 137 | 138 | $this->stream = $stream; 139 | $meta = \stream_get_meta_data($this->stream); 140 | $this->seekable = (bool) $meta['seekable']; 141 | $this->readable = (bool) \preg_match(self::READABLE_MODES, $meta['mode']); 142 | $this->writable = (bool) \preg_match(self::WRITABLE_MODES, $meta['mode']); 143 | $this->uri = $this->getMetadata('uri'); 144 | } 145 | 146 | /** 147 | * Closes the stream when the destructed 148 | */ 149 | public function __destruct() 150 | { 151 | $this->close(); 152 | } 153 | 154 | /** 155 | * @return string 156 | */ 157 | public function __toString(): string 158 | { 159 | try { 160 | $this->seek(0); 161 | 162 | if ($this->stream === null) { 163 | return ''; 164 | } 165 | 166 | return (string) \stream_get_contents($this->stream); 167 | } catch (\Exception $e) { 168 | return ''; 169 | } 170 | } 171 | 172 | public function close(): void 173 | { 174 | if (isset($this->stream)) { 175 | if (\is_resource($this->stream)) { 176 | \fclose($this->stream); 177 | } 178 | 179 | /** @noinspection UnusedFunctionResultInspection */ 180 | $this->detach(); 181 | } 182 | } 183 | 184 | /** 185 | * @return resource|null 186 | */ 187 | public function detach() 188 | { 189 | if (!isset($this->stream)) { 190 | return null; 191 | } 192 | 193 | $result = $this->stream; 194 | $this->stream = null; 195 | $this->size = null; 196 | $this->uri = null; 197 | $this->readable = false; 198 | $this->writable = false; 199 | $this->seekable = false; 200 | 201 | return $result; 202 | } 203 | 204 | /** 205 | * @return bool 206 | */ 207 | public function eof(): bool 208 | { 209 | if (!isset($this->stream)) { 210 | throw new \RuntimeException('Stream is detached'); 211 | } 212 | 213 | return \feof($this->stream); 214 | } 215 | 216 | /** 217 | * @return mixed 218 | */ 219 | public function getContentsUnserialized() 220 | { 221 | $contents = $this->getContents(); 222 | 223 | if ($this->serialized) { 224 | /** @noinspection UnserializeExploitsInspection */ 225 | $contents = \unserialize($contents, []); 226 | } 227 | 228 | return $contents; 229 | } 230 | 231 | public function getContents(): string 232 | { 233 | if (!isset($this->stream)) { 234 | throw new \RuntimeException('Stream is detached'); 235 | } 236 | 237 | $contents = \stream_get_contents($this->stream); 238 | if ($contents === false) { 239 | throw new \RuntimeException('Unable to read stream contents'); 240 | } 241 | 242 | return $contents; 243 | } 244 | 245 | /** 246 | * @param string|null $key 247 | * 248 | * @return array|mixed|null 249 | */ 250 | public function getMetadata($key = null) 251 | { 252 | if (!isset($this->stream)) { 253 | return $key ? null : []; 254 | } 255 | 256 | if (!$key) { 257 | /** @noinspection AdditionOperationOnArraysInspection */ 258 | return $this->customMetadata + \stream_get_meta_data($this->stream); 259 | } 260 | 261 | if (isset($this->customMetadata[$key])) { 262 | return $this->customMetadata[$key]; 263 | } 264 | 265 | $meta = \stream_get_meta_data($this->stream); 266 | 267 | return $meta[$key] ?? null; 268 | } 269 | 270 | /** 271 | * @return int|null 272 | */ 273 | public function getSize(): ?int 274 | { 275 | if ($this->size !== null) { 276 | return $this->size; 277 | } 278 | 279 | if (!isset($this->stream)) { 280 | return null; 281 | } 282 | 283 | // Clear the stat cache if the stream has a URI 284 | if ($this->uri) { 285 | \clearstatcache(true, $this->uri); 286 | } 287 | 288 | $stats = \fstat($this->stream); 289 | if ($stats !== false) { 290 | $this->size = $stats['size']; 291 | 292 | return $this->size; 293 | } 294 | 295 | return null; 296 | } 297 | 298 | /** 299 | * @return bool 300 | */ 301 | public function isReadable(): bool 302 | { 303 | return $this->readable; 304 | } 305 | 306 | /** 307 | * @return bool 308 | */ 309 | public function isSeekable(): bool 310 | { 311 | return $this->seekable; 312 | } 313 | 314 | /** 315 | * @return bool 316 | */ 317 | public function isWritable(): bool 318 | { 319 | return $this->writable; 320 | } 321 | 322 | /** 323 | * @param int $length 324 | * 325 | * @return string 326 | */ 327 | public function read($length): string 328 | { 329 | if (!isset($this->stream)) { 330 | throw new \RuntimeException('Stream is detached'); 331 | } 332 | 333 | if (!$this->readable) { 334 | throw new \RuntimeException('Cannot read from non-readable stream'); 335 | } 336 | 337 | if ($length < 0) { 338 | throw new \RuntimeException('Length parameter cannot be negative'); 339 | } 340 | 341 | if ($length === 0) { 342 | return ''; 343 | } 344 | 345 | $string = \fread($this->stream, $length); 346 | if ($string === false) { 347 | throw new \RuntimeException('Unable to read from stream'); 348 | } 349 | 350 | return $string; 351 | } 352 | 353 | /** 354 | * @return void 355 | */ 356 | public function rewind(): void 357 | { 358 | $this->seek(0); 359 | } 360 | 361 | /** 362 | * @param int $offset 363 | * @param int $whence 364 | * 365 | * @return void 366 | */ 367 | public function seek($offset, $whence = \SEEK_SET): void 368 | { 369 | $whence = (int) $whence; 370 | 371 | if (!isset($this->stream)) { 372 | throw new \RuntimeException('Stream is detached'); 373 | } 374 | if (!$this->seekable) { 375 | throw new \RuntimeException('Stream is not seekable'); 376 | } 377 | if (\fseek($this->stream, $offset, $whence) === -1) { 378 | throw new \RuntimeException( 379 | 'Unable to seek to stream position ' 380 | . $offset . ' with whence ' . \var_export($whence, true) 381 | ); 382 | } 383 | } 384 | 385 | /** 386 | * @return int 387 | */ 388 | public function tell(): int 389 | { 390 | if (!isset($this->stream)) { 391 | throw new \RuntimeException('Stream is detached'); 392 | } 393 | 394 | $result = \ftell($this->stream); 395 | if ($result === false) { 396 | throw new \RuntimeException('Unable to determine stream position'); 397 | } 398 | 399 | return $result; 400 | } 401 | 402 | /** 403 | * @param string $string 404 | * 405 | * @return int 406 | */ 407 | public function write($string): int 408 | { 409 | if (!isset($this->stream)) { 410 | throw new \RuntimeException('Stream is detached'); 411 | } 412 | if (!$this->writable) { 413 | throw new \RuntimeException('Cannot write to a non-writable stream'); 414 | } 415 | 416 | // We can't know the size after writing anything 417 | $this->size = null; 418 | $result = \fwrite($this->stream, $string); 419 | 420 | if ($result === false) { 421 | throw new \RuntimeException('Unable to write to stream'); 422 | } 423 | 424 | return $result; 425 | } 426 | 427 | /** 428 | * Creates a new PSR-7 stream. 429 | * 430 | * @param mixed $body 431 | * 432 | * @return StreamInterface|null 433 | */ 434 | public static function create($body = '') 435 | { 436 | if ($body instanceof StreamInterface) { 437 | return $body; 438 | } 439 | 440 | if ($body === null) { 441 | $body = ''; 442 | $serialized = false; 443 | } elseif (\is_numeric($body)) { 444 | $body = (string) $body; 445 | $serialized = UTF8::is_serialized($body); 446 | } elseif ( 447 | \is_array($body) 448 | || 449 | $body instanceof \Serializable 450 | ) { 451 | $body = \serialize($body); 452 | $serialized = true; 453 | } else { 454 | $serialized = false; 455 | } 456 | 457 | if (\is_string($body)) { 458 | $resource = \fopen('php://temp', 'rwb+'); 459 | if ($resource !== false) { 460 | \fwrite($resource, $body); 461 | $body = $resource; 462 | } 463 | } 464 | 465 | if (\is_resource($body)) { 466 | $new = new static($body); 467 | if ($new->stream === null) { 468 | return null; 469 | } 470 | 471 | $meta = \stream_get_meta_data($new->stream); 472 | $new->serialized = $serialized; 473 | $new->seekable = $meta['seekable']; 474 | $new->readable = isset(self::READ_WRITE_HASH['read'][$meta['mode']]); 475 | $new->writable = isset(self::READ_WRITE_HASH['write'][$meta['mode']]); 476 | $new->uri = $new->getMetadata('uri'); 477 | 478 | return $new; 479 | } 480 | 481 | return null; 482 | } 483 | 484 | /** 485 | * @param mixed $body 486 | * 487 | * @return StreamInterface 488 | */ 489 | public static function createNotNull($body = ''): StreamInterface 490 | { 491 | $stream = static::create($body); 492 | if ($stream === null) { 493 | $stream = static::create(); 494 | } 495 | 496 | \assert($stream instanceof self); 497 | 498 | return $stream; 499 | } 500 | } 501 | -------------------------------------------------------------------------------- /src/Httpful/Response.php: -------------------------------------------------------------------------------- 1 | e.g. [protocol_version] = '1.1'

88 | */ 89 | public function __construct( 90 | $body = null, 91 | $headers = null, 92 | RequestInterface $request = null, 93 | array $meta_data = [] 94 | ) { 95 | if (!($body instanceof Stream)) { 96 | $this->raw_body = $body; 97 | $body = Stream::create($body); 98 | } 99 | 100 | $this->request = $request; 101 | $this->raw_headers = $headers; 102 | $this->meta_data = $meta_data; 103 | 104 | if (!isset($this->meta_data['protocol_version'])) { 105 | $this->meta_data['protocol_version'] = '1.1'; 106 | } 107 | 108 | if ( 109 | \is_string($headers) 110 | && 111 | $headers !== '' 112 | ) { 113 | $this->code = $this->_getResponseCodeFromHeaderString($headers); 114 | $this->reason = Http::reason($this->code); 115 | $this->headers = Headers::fromString($headers); 116 | } elseif ( 117 | \is_array($headers) 118 | && 119 | \count($headers) > 0 120 | ) { 121 | $this->code = 200; 122 | $this->reason = Http::reason($this->code); 123 | $this->headers = new Headers($headers); 124 | } else { 125 | $this->code = 200; 126 | $this->reason = Http::reason($this->code); 127 | $this->headers = new Headers(); 128 | } 129 | 130 | $this->_interpretHeaders(); 131 | 132 | $bodyParsed = $this->_parse($body); 133 | $this->body = Stream::createNotNull($bodyParsed); 134 | $this->raw_body = $bodyParsed; 135 | } 136 | 137 | /** 138 | * @return void 139 | */ 140 | public function __clone() 141 | { 142 | $this->headers = clone $this->headers; 143 | } 144 | 145 | /** 146 | * @return string 147 | */ 148 | public function __toString() 149 | { 150 | if ( 151 | $this->body->getSize() > 0 152 | && 153 | !( 154 | $this->raw_body 155 | && 156 | UTF8::is_serialized((string) $this->body) 157 | ) 158 | ) { 159 | return (string) $this->body; 160 | } 161 | 162 | if (\is_string($this->raw_body)) { 163 | return (string) $this->raw_body; 164 | } 165 | 166 | return (string) \json_encode($this->raw_body); 167 | } 168 | 169 | /** 170 | * @param string $headers 171 | * 172 | * @throws ResponseException if we are unable to parse response code from HTTP response 173 | * 174 | * @return int 175 | * 176 | * @internal 177 | */ 178 | public function _getResponseCodeFromHeaderString($headers): int 179 | { 180 | // If there was a redirect, we will get headers from one then one request, 181 | // but will are only interested in the last request. 182 | $headersTmp = \explode("\r\n\r\n", $headers); 183 | $headersTmpCount = \count($headersTmp); 184 | if ($headersTmpCount >= 2) { 185 | $headers = $headersTmp[$headersTmpCount - 2]; 186 | } 187 | 188 | $end = \strpos($headers, "\r\n"); 189 | if ($end === false) { 190 | $end = \strlen($headers); 191 | } 192 | 193 | $parts = \explode(' ', \substr($headers, 0, $end)); 194 | 195 | if ( 196 | \count($parts) < 2 197 | || 198 | !\is_numeric($parts[1]) 199 | ) { 200 | throw new ResponseException('Unable to parse response code from HTTP response due to malformed response: "' . \print_r($headers, true) . '"'); 201 | } 202 | 203 | return (int) $parts[1]; 204 | } 205 | 206 | /** 207 | * @return StreamInterface 208 | */ 209 | public function getBody(): StreamInterface 210 | { 211 | return $this->body; 212 | } 213 | 214 | /** 215 | * Retrieves a message header value by the given case-insensitive name. 216 | * 217 | * This method returns an array of all the header values of the given 218 | * case-insensitive header name. 219 | * 220 | * If the header does not appear in the message, this method MUST return an 221 | * empty array. 222 | * 223 | * @param string $name case-insensitive header field name 224 | * 225 | * @return string[] An array of string values as provided for the given 226 | * header. If the header does not appear in the message, this method MUST 227 | * return an empty array. 228 | */ 229 | public function getHeader($name): array 230 | { 231 | if ($this->headers->offsetExists($name)) { 232 | $value = $this->headers->offsetGet($name); 233 | 234 | if (!\is_array($value)) { 235 | return [\trim($value, " \t")]; 236 | } 237 | 238 | foreach ($value as $keyInner => $valueInner) { 239 | $value[$keyInner] = \trim($valueInner, " \t"); 240 | } 241 | 242 | return $value; 243 | } 244 | 245 | return []; 246 | } 247 | 248 | /** 249 | * Retrieves a comma-separated string of the values for a single header. 250 | * 251 | * This method returns all of the header values of the given 252 | * case-insensitive header name as a string concatenated together using 253 | * a comma. 254 | * 255 | * NOTE: Not all header values may be appropriately represented using 256 | * comma concatenation. For such headers, use getHeader() instead 257 | * and supply your own delimiter when concatenating. 258 | * 259 | * If the header does not appear in the message, this method MUST return 260 | * an empty string. 261 | * 262 | * @param string $name case-insensitive header field name 263 | * 264 | * @return string A string of values as provided for the given header 265 | * concatenated together using a comma. If the header does not appear in 266 | * the message, this method MUST return an empty string. 267 | */ 268 | public function getHeaderLine($name): string 269 | { 270 | return \implode(', ', $this->getHeader($name)); 271 | } 272 | 273 | /** 274 | * @return array 275 | */ 276 | public function getHeaders(): array 277 | { 278 | return $this->headers->toArray(); 279 | } 280 | 281 | /** 282 | * Retrieves the HTTP protocol version as a string. 283 | * 284 | * The string MUST contain only the HTTP version number (e.g., "1.1", "1.0"). 285 | * 286 | * @return string HTTP protocol version 287 | */ 288 | public function getProtocolVersion(): string 289 | { 290 | if (isset($this->meta_data['protocol_version'])) { 291 | return (string) $this->meta_data['protocol_version']; 292 | } 293 | 294 | return '1.1'; 295 | } 296 | 297 | /** 298 | * Gets the response reason phrase associated with the status code. 299 | * 300 | * Because a reason phrase is not a required element in a response 301 | * status line, the reason phrase value MAY be null. Implementations MAY 302 | * choose to return the default RFC 7231 recommended reason phrase (or those 303 | * listed in the IANA HTTP Status Code Registry) for the response's 304 | * status code. 305 | * 306 | * @see http://tools.ietf.org/html/rfc7231#section-6 307 | * @see http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml 308 | * 309 | * @return string reason phrase; must return an empty string if none present 310 | */ 311 | public function getReasonPhrase(): string 312 | { 313 | return $this->reason; 314 | } 315 | 316 | /** 317 | * Gets the response status code. 318 | * 319 | * The status code is a 3-digit integer result code of the server's attempt 320 | * to understand and satisfy the request. 321 | * 322 | * @return int status code 323 | */ 324 | public function getStatusCode(): int 325 | { 326 | return $this->code; 327 | } 328 | 329 | /** 330 | * Checks if a header exists by the given case-insensitive name. 331 | * 332 | * @param string $name case-insensitive header field name 333 | * 334 | * @return bool Returns true if any header names match the given header 335 | * name using a case-insensitive string comparison. Returns false if 336 | * no matching header name is found in the message. 337 | */ 338 | public function hasHeader($name): bool 339 | { 340 | return $this->headers->offsetExists($name); 341 | } 342 | 343 | /** 344 | * Return an instance with the specified header appended with the given value. 345 | * 346 | * Existing values for the specified header will be maintained. The new 347 | * value(s) will be appended to the existing list. If the header did not 348 | * exist previously, it will be added. 349 | * 350 | * This method MUST be implemented in such a way as to retain the 351 | * immutability of the message, and MUST return an instance that has the 352 | * new header and/or value. 353 | * 354 | * @param string $name case-insensitive header field name to add 355 | * @param string|string[] $value header value(s) 356 | * 357 | * @throws \InvalidArgumentException for invalid header names or values 358 | * 359 | * @return static 360 | */ 361 | public function withAddedHeader($name, $value): \Psr\Http\Message\MessageInterface 362 | { 363 | $new = clone $this; 364 | 365 | if (!\is_array($value)) { 366 | $value = [$value]; 367 | } 368 | 369 | if ($new->headers->offsetExists($name)) { 370 | $new->headers->forceSet($name, \array_merge_recursive($new->headers->offsetGet($name), $value)); 371 | } else { 372 | $new->headers->forceSet($name, $value); 373 | } 374 | 375 | return $new; 376 | } 377 | 378 | /** 379 | * Return an instance with the specified message body. 380 | * 381 | * The body MUST be a StreamInterface object. 382 | * 383 | * This method MUST be implemented in such a way as to retain the 384 | * immutability of the message, and MUST return a new instance that has the 385 | * new body stream. 386 | * 387 | * @param StreamInterface $body body 388 | * 389 | * @throws \InvalidArgumentException when the body is not valid 390 | * 391 | * @return static 392 | */ 393 | public function withBody(StreamInterface $body): \Psr\Http\Message\MessageInterface 394 | { 395 | $new = clone $this; 396 | 397 | $new->body = $body; 398 | 399 | return $new; 400 | } 401 | 402 | /** 403 | * Return an instance with the provided value replacing the specified header. 404 | * 405 | * While header names are case-insensitive, the casing of the header will 406 | * be preserved by this function, and returned from getHeaders(). 407 | * 408 | * This method MUST be implemented in such a way as to retain the 409 | * immutability of the message, and MUST return an instance that has the 410 | * new and/or updated header and value. 411 | * 412 | * @param string $name case-insensitive header field name 413 | * @param string|string[] $value header value(s) 414 | * 415 | * @throws \InvalidArgumentException for invalid header names or values 416 | * 417 | * @return static 418 | */ 419 | public function withHeader($name, $value): \Psr\Http\Message\MessageInterface 420 | { 421 | $new = clone $this; 422 | 423 | if (!\is_array($value)) { 424 | $value = [$value]; 425 | } 426 | 427 | $new->headers->forceSet($name, $value); 428 | 429 | return $new; 430 | } 431 | 432 | /** 433 | * Return an instance with the specified HTTP protocol version. 434 | * 435 | * The version string MUST contain only the HTTP version number (e.g., 436 | * "1.1", "1.0"). 437 | * 438 | * This method MUST be implemented in such a way as to retain the 439 | * immutability of the message, and MUST return an instance that has the 440 | * new protocol version. 441 | * 442 | * @param string $version HTTP protocol version 443 | * 444 | * @return static 445 | */ 446 | public function withProtocolVersion($version): \Psr\Http\Message\MessageInterface 447 | { 448 | $new = clone $this; 449 | 450 | $new->meta_data['protocol_version'] = $version; 451 | 452 | return $new; 453 | } 454 | 455 | /** 456 | * Return an instance with the specified status code and, optionally, reason phrase. 457 | * 458 | * If no reason phrase is specified, implementations MAY choose to default 459 | * to the RFC 7231 or IANA recommended reason phrase for the response's 460 | * status code. 461 | * 462 | * This method MUST be implemented in such a way as to retain the 463 | * immutability of the message, and MUST return an instance that has the 464 | * updated status and reason phrase. 465 | * 466 | * @see http://tools.ietf.org/html/rfc7231#section-6 467 | * @see http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml 468 | * 469 | * @param int $code the 3-digit integer result code to set 470 | * @param string $reasonPhrase the reason phrase to use with the 471 | * provided status code; if none is provided, implementations MAY 472 | * use the defaults as suggested in the HTTP specification 473 | * 474 | * @throws \InvalidArgumentException for invalid status code arguments 475 | * 476 | * @return static 477 | */ 478 | public function withStatus($code, $reasonPhrase = null): ResponseInterface 479 | { 480 | $new = clone $this; 481 | 482 | $new->code = (int) $code; 483 | 484 | if (Http::responseCodeExists($new->code)) { 485 | $new->reason = Http::reason($new->code); 486 | } else { 487 | $new->reason = ''; 488 | } 489 | 490 | if ($reasonPhrase !== null) { 491 | $new->reason = $reasonPhrase; 492 | } 493 | 494 | return $new; 495 | } 496 | 497 | /** 498 | * Return an instance without the specified header. 499 | * 500 | * Header resolution MUST be done without case-sensitivity. 501 | * 502 | * This method MUST be implemented in such a way as to retain the 503 | * immutability of the message, and MUST return an instance that removes 504 | * the named header. 505 | * 506 | * @param string $name case-insensitive header field name to remove 507 | * 508 | * @return static 509 | */ 510 | public function withoutHeader($name): \Psr\Http\Message\MessageInterface 511 | { 512 | $new = clone $this; 513 | 514 | $new->headers->forceUnset($name); 515 | 516 | return $new; 517 | } 518 | 519 | /** 520 | * @return string 521 | */ 522 | public function getCharset(): string 523 | { 524 | return $this->charset; 525 | } 526 | 527 | /** 528 | * @return string 529 | */ 530 | public function getContentType(): string 531 | { 532 | return $this->content_type; 533 | } 534 | 535 | /** 536 | * @return Headers 537 | */ 538 | public function getHeadersObject(): Headers 539 | { 540 | return $this->headers; 541 | } 542 | 543 | /** 544 | * @return array 545 | */ 546 | public function getMetaData(): array 547 | { 548 | return $this->meta_data; 549 | } 550 | 551 | /** 552 | * @return string 553 | */ 554 | public function getParentType(): string 555 | { 556 | return $this->parent_type; 557 | } 558 | 559 | /** 560 | * @return mixed 561 | */ 562 | public function getRawBody() 563 | { 564 | return $this->raw_body; 565 | } 566 | 567 | /** 568 | * @return string 569 | */ 570 | public function getRawHeaders(): string 571 | { 572 | return $this->raw_headers; 573 | } 574 | 575 | public function hasBody(): bool 576 | { 577 | return $this->body->getSize() > 0; 578 | } 579 | 580 | /** 581 | * Status Code Definitions. 582 | * 583 | * Informational 1xx 584 | * Successful 2xx 585 | * Redirection 3xx 586 | * Client Error 4xx 587 | * Server Error 5xx 588 | * 589 | * http://pretty-rfc.herokuapp.com/RFC2616#status.codes 590 | * 591 | * @return bool Did we receive a 4xx or 5xx? 592 | */ 593 | public function hasErrors(): bool 594 | { 595 | return $this->code >= 400; 596 | } 597 | 598 | /** 599 | * @return bool 600 | */ 601 | public function isMimePersonal(): bool 602 | { 603 | return $this->is_mime_personal; 604 | } 605 | 606 | /** 607 | * @return bool 608 | */ 609 | public function isMimeVendorSpecific(): bool 610 | { 611 | return $this->is_mime_vendor_specific; 612 | } 613 | 614 | /** 615 | * @param string[] $header 616 | * 617 | * @return static 618 | */ 619 | public function withHeaders(array $header) 620 | { 621 | $new = clone $this; 622 | 623 | foreach ($header as $name => $value) { 624 | $new = $new->withHeader($name, $value); 625 | } 626 | 627 | return $new; 628 | } 629 | 630 | /** 631 | * After we've parse the headers, let's clean things 632 | * up a bit and treat some headers specially 633 | * 634 | * @return void 635 | */ 636 | private function _interpretHeaders() 637 | { 638 | // Parse the Content-Type and charset 639 | $content_type = $this->headers['Content-Type'] ?? []; 640 | foreach ($content_type as $content_type_inner) { 641 | $content_type = \array_merge(\explode(';', $content_type_inner)); 642 | } 643 | 644 | $this->content_type = $content_type[0] ?? ''; 645 | if ( 646 | \count($content_type) === 2 647 | && 648 | \strpos($content_type[1], '=') !== false 649 | ) { 650 | /** @noinspection PhpUnusedLocalVariableInspection */ 651 | list($nill, $this->charset) = \explode('=', $content_type[1]); 652 | } 653 | 654 | // fallback 655 | if (!$this->charset) { 656 | $this->charset = 'utf-8'; 657 | } 658 | 659 | // check for vendor & personal type 660 | if (\strpos($this->content_type, '/') !== false) { 661 | /** @noinspection PhpUnusedLocalVariableInspection */ 662 | list($type, $sub_type) = \explode('/', $this->content_type); 663 | $this->is_mime_vendor_specific = \strpos($sub_type, 'vnd.') === 0; 664 | $this->is_mime_personal = \strpos($sub_type, 'prs.') === 0; 665 | } 666 | 667 | $this->parent_type = $this->content_type; 668 | if (\strpos($this->content_type, '+') !== false) { 669 | /** @noinspection PhpUnusedLocalVariableInspection */ 670 | list($vendor, $this->parent_type) = \explode('+', $this->content_type, 2); 671 | $this->parent_type = Mime::getFullMime($this->parent_type); 672 | } 673 | } 674 | 675 | /** 676 | * Parse the response into a clean data structure 677 | * (most often an associative array) based on the expected 678 | * Mime type. 679 | * 680 | * @param StreamInterface|null $body Http response body 681 | * 682 | * @return mixed the response parse accordingly 683 | */ 684 | private function _parse($body) 685 | { 686 | // If the user decided to forgo the automatic smart parsing, short circuit. 687 | if ( 688 | $this->request instanceof Request 689 | && 690 | !$this->request->isAutoParse() 691 | ) { 692 | return $body; 693 | } 694 | 695 | // If provided, use custom parsing callback. 696 | if ( 697 | $this->request instanceof Request 698 | && 699 | $this->request->hasParseCallback() 700 | ) { 701 | return \call_user_func($this->request->getParseCallback(), $body); 702 | } 703 | 704 | // Decide how to parse the body of the response in the following order: 705 | // 706 | // 1. If provided, use the mime type specifically set as part of the `Request` 707 | // 2. If a MimeHandler is registered for the content type, use it 708 | // 3. If provided, use the "parent type" of the mime type from the response 709 | // 4. Default to the content-type provided in the response 710 | if ($this->request instanceof Request) { 711 | $parse_with = $this->request->getExpectedType(); 712 | } 713 | 714 | if (empty($parse_with)) { 715 | if (Setup::hasParserRegistered($this->content_type)) { 716 | $parse_with = $this->content_type; 717 | } else { 718 | $parse_with = $this->parent_type; 719 | } 720 | } 721 | 722 | return Setup::setupGlobalMimeType($parse_with)->parse((string) $body); 723 | } 724 | } 725 | -------------------------------------------------------------------------------- /src/Httpful/Uri.php: -------------------------------------------------------------------------------- 1 | 80, 24 | 'https' => 443, 25 | 'ftp' => 21, 26 | 'gopher' => 70, 27 | 'nntp' => 119, 28 | 'news' => 119, 29 | 'telnet' => 23, 30 | 'tn3270' => 23, 31 | 'imap' => 143, 32 | 'pop' => 110, 33 | 'ldap' => 389, 34 | ]; 35 | 36 | /** 37 | * @var string 38 | */ 39 | private static $charUnreserved = 'a-zA-Z0-9_\-\.~'; 40 | 41 | /** 42 | * @var string 43 | */ 44 | private static $charSubDelims = '!\$&\'\(\)\*\+,;='; 45 | 46 | /** 47 | * @var array 48 | */ 49 | private static $replaceQuery = [ 50 | '=' => '%3D', 51 | '&' => '%26', 52 | ]; 53 | 54 | /** 55 | * @var string uri scheme 56 | */ 57 | private $scheme = ''; 58 | 59 | /** 60 | * @var string uri user info 61 | */ 62 | private $userInfo = ''; 63 | 64 | /** 65 | * @var string uri host 66 | */ 67 | private $host = ''; 68 | 69 | /** 70 | * @var int|null uri port 71 | */ 72 | private $port; 73 | 74 | /** 75 | * @var string uri path 76 | */ 77 | private $path = ''; 78 | 79 | /** 80 | * @var string uri query string 81 | */ 82 | private $query = ''; 83 | 84 | /** 85 | * @var string uri fragment 86 | */ 87 | private $fragment = ''; 88 | 89 | /** 90 | * @param string $uri URI to parse 91 | */ 92 | public function __construct($uri = '') 93 | { 94 | // weak type check to also accept null until we can add scalar type hints 95 | if ($uri !== '') { 96 | $parts = \parse_url($uri); 97 | 98 | if ($parts === false) { 99 | throw new \InvalidArgumentException("Unable to parse URI: {$uri}"); 100 | } 101 | 102 | $this->_applyParts($parts); 103 | } 104 | } 105 | 106 | /** 107 | * @return string 108 | */ 109 | public function __toString(): string 110 | { 111 | return self::composeComponents( 112 | $this->scheme, 113 | $this->getAuthority(), 114 | $this->path, 115 | $this->query, 116 | $this->fragment 117 | ); 118 | } 119 | 120 | /** 121 | * @return string 122 | */ 123 | public function getAuthority(): string 124 | { 125 | if ($this->host === '') { 126 | return ''; 127 | } 128 | 129 | $authority = $this->host; 130 | if ($this->userInfo !== '') { 131 | $authority = $this->userInfo . '@' . $authority; 132 | } 133 | 134 | if ($this->port !== null) { 135 | $authority .= ':' . $this->port; 136 | } 137 | 138 | return $authority; 139 | } 140 | 141 | /** 142 | * @return string 143 | */ 144 | public function getFragment(): string 145 | { 146 | return $this->fragment; 147 | } 148 | 149 | /** 150 | * @return string 151 | */ 152 | public function getHost(): string 153 | { 154 | return $this->host; 155 | } 156 | 157 | /** 158 | * @return string 159 | */ 160 | public function getPath(): string 161 | { 162 | return $this->path; 163 | } 164 | 165 | /** 166 | * @return int|null 167 | */ 168 | public function getPort(): ?int 169 | { 170 | return $this->port; 171 | } 172 | 173 | /** 174 | * @return string 175 | */ 176 | public function getQuery(): string 177 | { 178 | return $this->query; 179 | } 180 | 181 | /** 182 | * @return string 183 | */ 184 | public function getScheme(): string 185 | { 186 | return $this->scheme; 187 | } 188 | 189 | /** 190 | * @return string 191 | */ 192 | public function getUserInfo(): string 193 | { 194 | return $this->userInfo; 195 | } 196 | 197 | /** 198 | * @param string $fragment 199 | * 200 | * @return $this|Uri|UriInterface 201 | */ 202 | public function withFragment($fragment): UriInterface 203 | { 204 | $fragment = $this->_filterQueryAndFragment($fragment); 205 | 206 | if ($this->fragment === $fragment) { 207 | return $this; 208 | } 209 | 210 | $new = clone $this; 211 | $new->fragment = $fragment; 212 | 213 | return $new; 214 | } 215 | 216 | /** 217 | * @param string $host 218 | * 219 | * @return $this|Uri|UriInterface 220 | */ 221 | public function withHost($host): UriInterface 222 | { 223 | $host = $this->_filterHost($host); 224 | 225 | if ($this->host === $host) { 226 | return $this; 227 | } 228 | 229 | $new = clone $this; 230 | $new->host = $host; 231 | $new->_validateState(); 232 | 233 | return $new; 234 | } 235 | 236 | /** 237 | * @param string $path 238 | * 239 | * @return $this|Uri|UriInterface 240 | */ 241 | public function withPath($path): UriInterface 242 | { 243 | $path = $this->_filterPath($path); 244 | 245 | if ($this->path === $path) { 246 | return $this; 247 | } 248 | 249 | $new = clone $this; 250 | $new->path = $path; 251 | $new->_validateState(); 252 | 253 | return $new; 254 | } 255 | 256 | /** 257 | * @param int|null $port 258 | * 259 | * @return $this|Uri|UriInterface 260 | */ 261 | public function withPort($port): UriInterface 262 | { 263 | $port = $this->_filterPort($port); 264 | 265 | if ($this->port === $port) { 266 | return $this; 267 | } 268 | 269 | $new = clone $this; 270 | $new->port = $port; 271 | $new->_removeDefaultPort(); 272 | $new->_validateState(); 273 | 274 | return $new; 275 | } 276 | 277 | /** 278 | * @param string $query 279 | * 280 | * @return $this|Uri|UriInterface 281 | */ 282 | public function withQuery($query): UriInterface 283 | { 284 | $query = $this->_filterQueryAndFragment($query); 285 | 286 | if ($this->query === $query) { 287 | return $this; 288 | } 289 | 290 | $new = clone $this; 291 | $new->query = $query; 292 | 293 | return $new; 294 | } 295 | 296 | /** 297 | * @param string $scheme 298 | * 299 | * @return $this|Uri|UriInterface 300 | */ 301 | public function withScheme($scheme): UriInterface 302 | { 303 | $scheme = $this->_filterScheme($scheme); 304 | 305 | if ($this->scheme === $scheme) { 306 | return $this; 307 | } 308 | 309 | $new = clone $this; 310 | $new->scheme = $scheme; 311 | $new->_removeDefaultPort(); 312 | $new->_validateState(); 313 | 314 | return $new; 315 | } 316 | 317 | /** 318 | * @param string $user 319 | * @param string|null $password 320 | * 321 | * @return $this|Uri|UriInterface 322 | */ 323 | public function withUserInfo($user, $password = null): UriInterface 324 | { 325 | $info = $this->_filterUserInfoComponent($user); 326 | if ($password !== null) { 327 | $info .= ':' . $this->_filterUserInfoComponent($password); 328 | } 329 | 330 | if ($this->userInfo === $info) { 331 | return $this; 332 | } 333 | 334 | $new = clone $this; 335 | $new->userInfo = $info; 336 | $new->_validateState(); 337 | 338 | return $new; 339 | } 340 | 341 | /** 342 | * Composes a URI reference string from its various components. 343 | * 344 | * Usually this method does not need to be called manually but instead is used indirectly via 345 | * `Psr\Http\Message\UriInterface::__toString`. 346 | * 347 | * PSR-7 UriInterface treats an empty component the same as a missing component as 348 | * getQuery(), getFragment() etc. always return a string. This explains the slight 349 | * difference to RFC 3986 Section 5.3. 350 | * 351 | * Another adjustment is that the authority separator is added even when the authority is missing/empty 352 | * for the "file" scheme. This is because PHP stream functions like `file_get_contents` only work with 353 | * `file:///myfile` but not with `file:/myfile` although they are equivalent according to RFC 3986. But 354 | * `file:///` is the more common syntax for the file scheme anyway (Chrome for example redirects to 355 | * that format). 356 | * 357 | * @param string $scheme 358 | * @param string $authority 359 | * @param string $path 360 | * @param string $query 361 | * @param string $fragment 362 | * 363 | * @return string 364 | * 365 | * @see https://tools.ietf.org/html/rfc3986#section-5.3 366 | */ 367 | public static function composeComponents($scheme, $authority, $path, $query, $fragment): string 368 | { 369 | // init 370 | $uri = ''; 371 | 372 | // weak type checks to also accept null until we can add scalar type hints 373 | if ($scheme !== '') { 374 | $uri .= $scheme . ':'; 375 | } 376 | 377 | if ($authority !== '' || $scheme === 'file') { 378 | $uri .= '//' . $authority; 379 | } 380 | 381 | $uri .= $path; 382 | 383 | if ($query !== '') { 384 | $uri .= '?' . $query; 385 | } 386 | 387 | if ($fragment !== '') { 388 | $uri .= '#' . $fragment; 389 | } 390 | 391 | return $uri; 392 | } 393 | 394 | /** 395 | * Creates a URI from a hash of `parse_url` components. 396 | * 397 | * @param array $parts 398 | * 399 | * @throws \InvalidArgumentException if the components do not form a valid URI 400 | * 401 | * @return UriInterface 402 | * 403 | * @see http://php.net/manual/en/function.parse-url.php 404 | */ 405 | public static function fromParts(array $parts): UriInterface 406 | { 407 | $uri = new self(); 408 | $uri->_applyParts($parts); 409 | $uri->_validateState(); 410 | 411 | return $uri; 412 | } 413 | 414 | /** 415 | * Whether the URI is absolute, i.e. it has a scheme. 416 | * 417 | * An instance of UriInterface can either be an absolute URI or a relative reference. This method returns true 418 | * if it is the former. An absolute URI has a scheme. A relative reference is used to express a URI relative 419 | * to another URI, the base URI. Relative references can be divided into several forms: 420 | * - network-path references, e.g. '//example.com/path' 421 | * - absolute-path references, e.g. '/path' 422 | * - relative-path references, e.g. 'subpath' 423 | * 424 | * @param UriInterface $uri 425 | * 426 | * @return bool 427 | * 428 | * @see Uri::isNetworkPathReference 429 | * @see Uri::isAbsolutePathReference 430 | * @see Uri::isRelativePathReference 431 | * @see https://tools.ietf.org/html/rfc3986#section-4 432 | */ 433 | public static function isAbsolute(UriInterface $uri): bool 434 | { 435 | return $uri->getScheme() !== ''; 436 | } 437 | 438 | /** 439 | * Whether the URI is a absolute-path reference. 440 | * 441 | * A relative reference that begins with a single slash character is termed an absolute-path reference. 442 | * 443 | * @param UriInterface $uri 444 | * 445 | * @return bool 446 | * 447 | * @see https://tools.ietf.org/html/rfc3986#section-4.2 448 | */ 449 | public static function isAbsolutePathReference(UriInterface $uri): bool 450 | { 451 | return $uri->getScheme() === '' 452 | && 453 | $uri->getAuthority() === '' 454 | && 455 | isset($uri->getPath()[0]) 456 | && 457 | $uri->getPath()[0] === '/'; 458 | } 459 | 460 | /** 461 | * Whether the URI has the default port of the current scheme. 462 | * 463 | * `Psr\Http\Message\UriInterface::getPort` may return null or the standard port. This method can be used 464 | * independently of the implementation. 465 | * 466 | * @param UriInterface $uri 467 | * 468 | * @return bool 469 | */ 470 | public static function isDefaultPort(UriInterface $uri): bool 471 | { 472 | return $uri->getPort() === null 473 | || 474 | ( 475 | isset(self::$defaultPorts[$uri->getScheme()]) 476 | && 477 | $uri->getPort() === self::$defaultPorts[$uri->getScheme()] 478 | ); 479 | } 480 | 481 | /** 482 | * Whether the URI is a network-path reference. 483 | * 484 | * A relative reference that begins with two slash characters is termed an network-path reference. 485 | * 486 | * @param UriInterface $uri 487 | * 488 | * @return bool 489 | * 490 | * @see https://tools.ietf.org/html/rfc3986#section-4.2 491 | */ 492 | public static function isNetworkPathReference(UriInterface $uri): bool 493 | { 494 | return $uri->getScheme() === '' && $uri->getAuthority() !== ''; 495 | } 496 | 497 | /** 498 | * Whether the URI is a relative-path reference. 499 | * 500 | * A relative reference that does not begin with a slash character is termed a relative-path reference. 501 | * 502 | * @param UriInterface $uri 503 | * 504 | * @return bool 505 | * 506 | * @see https://tools.ietf.org/html/rfc3986#section-4.2 507 | */ 508 | public static function isRelativePathReference(UriInterface $uri): bool 509 | { 510 | return $uri->getScheme() === '' 511 | && 512 | $uri->getAuthority() === '' 513 | && 514 | (!isset($uri->getPath()[0]) || $uri->getPath()[0] !== '/'); 515 | } 516 | 517 | /** 518 | * Whether the URI is a same-document reference. 519 | * 520 | * A same-document reference refers to a URI that is, aside from its fragment 521 | * component, identical to the base URI. When no base URI is given, only an empty 522 | * URI reference (apart from its fragment) is considered a same-document reference. 523 | * 524 | * @param UriInterface $uri The URI to check 525 | * @param UriInterface|null $base An optional base URI to compare against 526 | * 527 | * @return bool 528 | * 529 | * @see https://tools.ietf.org/html/rfc3986#section-4.4 530 | */ 531 | public static function isSameDocumentReference(UriInterface $uri, UriInterface $base = null): bool 532 | { 533 | if ($base !== null) { 534 | $uri = UriResolver::resolve($base, $uri); 535 | 536 | return ($uri->getScheme() === $base->getScheme()) 537 | && 538 | ($uri->getAuthority() === $base->getAuthority()) 539 | && 540 | ($uri->getPath() === $base->getPath()) 541 | && 542 | ($uri->getQuery() === $base->getQuery()); 543 | } 544 | 545 | return $uri->getScheme() === '' && $uri->getAuthority() === '' && $uri->getPath() === '' && $uri->getQuery() === ''; 546 | } 547 | 548 | /** 549 | * Creates a new URI with a specific query string value. 550 | * 551 | * Any existing query string values that exactly match the provided key are 552 | * removed and replaced with the given key value pair. 553 | * 554 | * A value of null will set the query string key without a value, e.g. "key" 555 | * instead of "key=value". 556 | * 557 | * @param UriInterface $uri URI to use as a base 558 | * @param string $key key to set 559 | * @param string|null $value Value to set 560 | * 561 | * @return UriInterface 562 | */ 563 | public static function withQueryValue(UriInterface $uri, $key, $value): UriInterface 564 | { 565 | $result = self::_getFilteredQueryString($uri, [$key]); 566 | 567 | $result[] = self::_generateQueryString($key, $value); 568 | 569 | /** @noinspection ImplodeMissUseInspection */ 570 | return $uri->withQuery(\implode('&', $result)); 571 | } 572 | 573 | /** 574 | * Creates a new URI with multiple specific query string values. 575 | * 576 | * It has the same behavior as withQueryValue() but for an associative array of key => value. 577 | * 578 | * @param UriInterface $uri URI to use as a base 579 | * @param array $keyValueArray Associative array of key and values 580 | * 581 | * @return UriInterface 582 | */ 583 | public static function withQueryValues(UriInterface $uri, array $keyValueArray): UriInterface 584 | { 585 | $result = self::_getFilteredQueryString($uri, \array_keys($keyValueArray)); 586 | 587 | foreach ($keyValueArray as $key => $value) { 588 | $result[] = self::_generateQueryString($key, $value); 589 | } 590 | 591 | /** @noinspection ImplodeMissUseInspection */ 592 | return $uri->withQuery(\implode('&', $result)); 593 | } 594 | 595 | /** 596 | * Creates a new URI with a specific query string value removed. 597 | * 598 | * Any existing query string values that exactly match the provided key are 599 | * removed. 600 | * 601 | * @param uriInterface $uri URI to use as a base 602 | * @param string $key query string key to remove 603 | * 604 | * @return UriInterface 605 | */ 606 | public static function withoutQueryValue(UriInterface $uri, $key): UriInterface 607 | { 608 | $result = self::_getFilteredQueryString($uri, [$key]); 609 | 610 | /** @noinspection ImplodeMissUseInspection */ 611 | return $uri->withQuery(\implode('&', $result)); 612 | } 613 | 614 | /** 615 | * Apply parse_url parts to a URI. 616 | * 617 | * @param array $parts array of parse_url parts to apply 618 | * 619 | * @return void 620 | */ 621 | private function _applyParts(array $parts) 622 | { 623 | $this->scheme = isset($parts['scheme']) 624 | ? $this->_filterScheme($parts['scheme']) 625 | : ''; 626 | $this->userInfo = isset($parts['user']) 627 | ? $this->_filterUserInfoComponent($parts['user']) 628 | : ''; 629 | $this->host = isset($parts['host']) 630 | ? $this->_filterHost($parts['host']) 631 | : ''; 632 | $this->port = isset($parts['port']) 633 | ? $this->_filterPort($parts['port']) 634 | : null; 635 | $this->path = isset($parts['path']) 636 | ? $this->_filterPath($parts['path']) 637 | : ''; 638 | $this->query = isset($parts['query']) 639 | ? $this->_filterQueryAndFragment($parts['query']) 640 | : ''; 641 | $this->fragment = isset($parts['fragment']) 642 | ? $this->_filterQueryAndFragment($parts['fragment']) 643 | : ''; 644 | if (isset($parts['pass'])) { 645 | $this->userInfo .= ':' . $this->_filterUserInfoComponent($parts['pass']); 646 | } 647 | 648 | $this->_removeDefaultPort(); 649 | } 650 | 651 | /** 652 | * @param string $host 653 | * 654 | * @throws \InvalidArgumentException if the host is invalid 655 | * 656 | * @return string 657 | */ 658 | private function _filterHost($host): string 659 | { 660 | if (!\is_string($host)) { 661 | throw new \InvalidArgumentException('Host must be a string'); 662 | } 663 | 664 | return \strtolower($host); 665 | } 666 | 667 | /** 668 | * Filters the path of a URI 669 | * 670 | * @param string $path 671 | * 672 | * @throws \InvalidArgumentException if the path is invalid 673 | * 674 | * @return string 675 | */ 676 | private function _filterPath($path): string 677 | { 678 | if (!\is_string($path)) { 679 | throw new \InvalidArgumentException('Path must be a string'); 680 | } 681 | 682 | return (string) \preg_replace_callback( 683 | '/(?:[^' . self::$charUnreserved . self::$charSubDelims . '%:@\/]++|%(?![A-Fa-f0-9]{2}))/', 684 | [$this, '_rawurlencodeMatchZero'], 685 | $path 686 | ); 687 | } 688 | 689 | /** 690 | * @param int|null $port 691 | * 692 | * @throws \InvalidArgumentException if the port is invalid 693 | * 694 | * @return int|null 695 | */ 696 | private function _filterPort($port) 697 | { 698 | if ($port === null) { 699 | return null; 700 | } 701 | 702 | $port = (int) $port; 703 | if ($port < 1 || $port > 0xffff) { 704 | throw new \InvalidArgumentException( 705 | \sprintf('Invalid port: %d. Must be between 1 and 65535', $port) 706 | ); 707 | } 708 | 709 | return $port; 710 | } 711 | 712 | /** 713 | * Filters the query string or fragment of a URI. 714 | * 715 | * @param string $str 716 | * 717 | * @throws \InvalidArgumentException if the query or fragment is invalid 718 | * 719 | * @return string 720 | */ 721 | private function _filterQueryAndFragment($str): string 722 | { 723 | if (!\is_string($str)) { 724 | throw new \InvalidArgumentException('Query and fragment must be a string'); 725 | } 726 | 727 | return (string) \preg_replace_callback( 728 | '/(?:[^' . self::$charUnreserved . self::$charSubDelims . '%:@\/\?]++|%(?![A-Fa-f0-9]{2}))/', 729 | [$this, '_rawurlencodeMatchZero'], 730 | $str 731 | ); 732 | } 733 | 734 | /** 735 | * @param string $scheme 736 | * 737 | * @throws \InvalidArgumentException if the scheme is invalid 738 | * 739 | * @return string 740 | */ 741 | private function _filterScheme($scheme): string 742 | { 743 | if (!\is_string($scheme)) { 744 | throw new \InvalidArgumentException('Scheme must be a string'); 745 | } 746 | 747 | return \strtolower($scheme); 748 | } 749 | 750 | /** 751 | * @param string $component 752 | * 753 | * @throws \InvalidArgumentException if the user info is invalid 754 | * 755 | * @return string 756 | */ 757 | private function _filterUserInfoComponent($component): string 758 | { 759 | if (!\is_string($component)) { 760 | throw new \InvalidArgumentException('User info must be a string'); 761 | } 762 | 763 | return (string) \preg_replace_callback( 764 | '/(?:[^%' . self::$charUnreserved . self::$charSubDelims . ']+|%(?![A-Fa-f0-9]{2}))/', 765 | [$this, '_rawurlencodeMatchZero'], 766 | $component 767 | ); 768 | } 769 | 770 | /** 771 | * @param string $key 772 | * @param string|null $value 773 | * 774 | * @return string 775 | */ 776 | private static function _generateQueryString($key, $value): string 777 | { 778 | // Query string separators ("=", "&") within the key or value need to be encoded 779 | // (while preventing double-encoding) before setting the query string. All other 780 | // chars that need percent-encoding will be encoded by withQuery(). 781 | $queryString = \strtr($key, self::$replaceQuery); 782 | 783 | if ($value !== null) { 784 | $queryString .= '=' . \strtr($value, self::$replaceQuery); 785 | } 786 | 787 | return $queryString; 788 | } 789 | 790 | /** 791 | * @param UriInterface $uri 792 | * @param string[] $keys 793 | * 794 | * @return array 795 | */ 796 | private static function _getFilteredQueryString(UriInterface $uri, array $keys): array 797 | { 798 | $current = $uri->getQuery(); 799 | 800 | if ($current === '') { 801 | return []; 802 | } 803 | 804 | $decodedKeys = \array_map('rawurldecode', $keys); 805 | 806 | return \array_filter( 807 | \explode('&', $current), 808 | static function ($part) use ($decodedKeys) { 809 | return !\in_array(\rawurldecode(\explode('=', $part, 2)[0]), $decodedKeys, true); 810 | } 811 | ); 812 | } 813 | 814 | /** 815 | * @param string[] $match 816 | * 817 | * @return string 818 | */ 819 | private function _rawurlencodeMatchZero(array $match): string 820 | { 821 | return \rawurlencode($match[0]); 822 | } 823 | 824 | /** 825 | * @return void 826 | */ 827 | private function _removeDefaultPort() 828 | { 829 | if ($this->port !== null && self::isDefaultPort($this)) { 830 | $this->port = null; 831 | } 832 | } 833 | 834 | /** 835 | * @return void 836 | */ 837 | private function _validateState() 838 | { 839 | if ($this->host === '' && ($this->scheme === 'http' || $this->scheme === 'https')) { 840 | $this->host = self::HTTP_DEFAULT_HOST; 841 | } 842 | 843 | if ($this->getAuthority() === '') { 844 | if (\strpos($this->path, '//') === 0) { 845 | throw new \InvalidArgumentException('The path of a URI without an authority must not start with two slashes "//"'); 846 | } 847 | if ($this->scheme === '' && \strpos(\explode('/', $this->path, 2)[0], ':') !== false) { 848 | throw new \InvalidArgumentException('A relative URI must not have a path beginning with a segment containing a colon'); 849 | } 850 | } elseif (isset($this->path[0]) && $this->path[0] !== '/') { 851 | /** @noinspection PhpUsageOfSilenceOperatorInspection */ 852 | @\trigger_error( 853 | 'The path of a URI with an authority must start with a slash "/" or be empty. Automagically fixing the URI ' . 854 | 'by adding a leading slash to the path is deprecated since version 1.4 and will throw an exception instead.', 855 | \E_USER_DEPRECATED 856 | ); 857 | $this->path = '/' . $this->path; 858 | } 859 | } 860 | } 861 | --------------------------------------------------------------------------------