├── 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 | [](https://github.com/voku/httpful/actions)
2 | [](https://codecov.io/github/voku/httpful?branch=master)
3 | [](https://www.codacy.com/app/voku/httpful)
4 | [](https://packagist.org/packages/voku/httpful)
5 | [](https://packagist.org/packages/voku/httpful)
6 | [](https://packagist.org/packages/voku/httpful)
7 | [](https://www.paypal.me/moelleken)
8 | [](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 |
--------------------------------------------------------------------------------