Issues (75)

src/ProxyClient/HttpDispatcher.php (2 issues)

1
<?php
2
3
/*
4
 * This file is part of the FOSHttpCache package.
5
 *
6
 * (c) FriendsOfSymfony <http://friendsofsymfony.github.com/>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11
12
namespace FOS\HttpCache\ProxyClient;
13
14
use FOS\HttpCache\Exception\ExceptionCollection;
15
use FOS\HttpCache\Exception\InvalidArgumentException;
16
use FOS\HttpCache\Exception\InvalidUrlException;
17
use FOS\HttpCache\Exception\MissingHostException;
18
use FOS\HttpCache\Exception\ProxyResponseException;
19
use FOS\HttpCache\Exception\ProxyUnreachableException;
20
use Http\Client\Common\Plugin\ErrorPlugin;
21
use Http\Client\Common\PluginClient;
22
use Http\Client\Exception\HttpException;
23
use Http\Client\Exception\NetworkException;
24
use Http\Client\HttpAsyncClient;
25
use Http\Discovery\HttpAsyncClientDiscovery;
26
use Http\Discovery\UriFactoryDiscovery;
27
use Http\Message\UriFactory;
28
use Http\Promise\Promise;
29
use Psr\Http\Message\RequestInterface;
30
use Psr\Http\Message\UriInterface;
31
32
/**
33
 * Queue and send HTTP requests with a Httplug asynchronous client.
34
 *
35
 * @author David Buchmann <[email protected]>
36
 */
37
class HttpDispatcher implements Dispatcher
38
{
39
    /**
40
     * @var HttpAsyncClient
41
     */
42
    private $httpClient;
43
44
    /**
45
     * @var UriFactory
46
     */
47
    private $uriFactory;
48
49
    /**
50
     * Queued requests.
51
     *
52
     * @var RequestInterface[]
53
     */
54
    private $queue = [];
55
56
    /**
57
     * Caching proxy server host names or IP addresses.
58
     *
59
     * @var UriInterface[]
60
     */
61
    private $servers;
62
63
    /**
64
     * Application host name and optional base URL.
65
     *
66
     * @var UriInterface
67
     */
68
    private $baseUri;
69
70
    /**
71
     * If you specify a custom HTTP client, make sure that it converts HTTP
72
     * errors to exceptions.
73
     *
74
     * If your proxy server IPs can not be statically configured, extend this
75
     * class and overwrite getServers. Be sure to have some caching in
76
     * getServers.
77
     *
78
     * @param string[]             $servers    Caching proxy server hostnames or IP
79
     *                                         addresses, including port if not port 80.
80
     *                                         E.g. ['127.0.0.1:6081']
81
     * @param string               $baseUri    Default application hostname, optionally
82
     *                                         including base URL, for purge and refresh
83
     *                                         requests (optional). This is required if
84
     *                                         you purge and refresh paths instead of
85
     *                                         absolute URLs
86
     * @param HttpAsyncClient|null $httpClient Client capable of sending HTTP requests. If no
87
     *                                         client is supplied, a default one is created
88
     * @param UriFactory|null      $uriFactory Factory for PSR-7 URIs. If not specified, a
89
     *                                         default one is created
90
     */
91 40
    public function __construct(
92
        array $servers,
93
        $baseUri = '',
94
        HttpAsyncClient $httpClient = null,
95
        UriFactory $uriFactory = null
96
    ) {
97 40
        if (!$httpClient) {
98 26
            $httpClient = new PluginClient(
99 26
                HttpAsyncClientDiscovery::find(),
100 26
                [new ErrorPlugin()]
101
            );
102
        }
103 40
        $this->httpClient = $httpClient;
104 40
        $this->uriFactory = $uriFactory ?: UriFactoryDiscovery::find();
105
106 40
        $this->setServers($servers);
107 37
        $this->setBaseUri($baseUri);
108 36
    }
109
110
    /**
111
     * {@inheritdoc}
112
     */
113 35
    public function invalidate(RequestInterface $invalidationRequest, $validateHost = true)
114
    {
115 35
        if ($validateHost && !$this->baseUri && !$invalidationRequest->getUri()->getHost()) {
116 1
            throw MissingHostException::missingHost((string) $invalidationRequest->getUri());
117
        }
118
119 34
        $signature = $this->getRequestSignature($invalidationRequest);
120
121 34
        if (isset($this->queue[$signature])) {
122 1
            return;
123
        }
124
125 34
        $this->queue[$signature] = $invalidationRequest;
126 34
    }
127
128
    /**
129
     * {@inheritdoc}
130
     */
131 35
    public function flush()
132
    {
133 35
        $queue = $this->queue;
134 35
        $this->queue = [];
135
        /** @var Promise[] $promises */
136 35
        $promises = [];
137
138 35
        $exceptions = new ExceptionCollection();
139
140 35
        foreach ($queue as $request) {
141 34
            foreach ($this->fanOut($request) as $proxyRequest) {
142
                try {
143 34
                    $promises[] = $this->httpClient->sendAsyncRequest($proxyRequest);
144 1
                } catch (\Exception $e) {
145 1
                    $exceptions->add(new InvalidArgumentException($e->getMessage(), $e->getCode(), $e));
146
                }
147
            }
148
        }
149
150 35
        foreach ($promises as $promise) {
151
            try {
152 34
                $promise->wait();
153 4
            } catch (HttpException $exception) {
154 2
                $exceptions->add(ProxyResponseException::proxyResponse($exception));
155 2
            } catch (NetworkException $exception) {
156 2
                $exceptions->add(ProxyUnreachableException::proxyUnreachable($exception));
157
            } catch (\Exception $exception) {
158
                // @codeCoverageIgnoreStart
159
                $exceptions->add(new InvalidArgumentException($exception->getMessage(), $exception->getCode(), $exception));
160
                // @codeCoverageIgnoreEnd
161
            }
162
        }
163
164 35
        if (count($exceptions)) {
165 5
            throw $exceptions;
166
        }
167
168 33
        return count($queue);
169
    }
170
171
    /**
172
     * Get the list of servers to send invalidation requests to.
173
     *
174
     * @return UriInterface[]
175
     */
176 34
    protected function getServers()
177
    {
178 34
        return $this->servers;
179
    }
180
181
    /**
182
     * Duplicate a request for each caching server.
183
     *
184
     * @param RequestInterface $request The request to duplicate for each configured server
185
     *
186
     * @return RequestInterface[]
187
     */
188 34
    private function fanOut(RequestInterface $request)
189
    {
190 34
        $requests = [];
191
192 34
        $uri = $request->getUri();
193
194
        // If a base URI is configured, try to make partial invalidation
195
        // requests complete.
196 34
        if ($this->baseUri) {
197 31
            if ($uri->getHost()) {
198
                // Absolute URI: does it already have a scheme?
199 5
                if (!$uri->getScheme() && '' !== $this->baseUri->getScheme()) {
200 5
                    $uri = $uri->withScheme($this->baseUri->getScheme());
201
                }
202
            } else {
203
                // Relative URI
204 26
                if ('' !== $this->baseUri->getHost()) {
205 26
                    $uri = $uri->withHost($this->baseUri->getHost());
206
                }
207
208 26
                if ($this->baseUri->getPort()) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->baseUri->getPort() of type integer|null is loosely compared to true; this is ambiguous if the integer can be 0. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
209 16
                    $uri = $uri->withPort($this->baseUri->getPort());
210
                }
211
212
                // Base path
213 26
                if ('' !== $this->baseUri->getPath()) {
214 1
                    $path = $this->baseUri->getPath().'/'.ltrim($uri->getPath(), '/');
215 1
                    $uri = $uri->withPath($path);
216
                }
217
            }
218
        }
219
220
        // Close connections to make sure invalidation (PURGE/BAN) requests
221
        // will not interfere with content (GET) requests.
222 34
        $request = $request->withUri($uri)->withHeader('Connection', 'Close');
223
224
        // Create a request to each caching proxy server
225 34
        foreach ($this->getServers() as $server) {
226
            $serverUri = $uri
227 34
                ->withScheme($server->getScheme())
228 34
                ->withHost($server->getHost())
229 34
                ->withPort($server->getPort());
230
231 34
            if ($userInfo = $server->getUserInfo()) {
232 1
                $userInfoParts = explode(':', $userInfo, 2);
233
                $serverUri = $serverUri
234 1
                    ->withUserInfo($userInfoParts[0], $userInfoParts[1] ?? null);
235
            }
236
237 34
            $requests[] = $request->withUri($serverUri, true); // Preserve application Host header
238
        }
239
240 34
        return $requests;
241
    }
242
243
    /**
244
     * Set caching proxy server URI objects, validating them.
245
     *
246
     * @param string[] $servers Caching proxy proxy server hostnames or IP
247
     *                          addresses, including port if not port 80.
248
     *                          E.g. ['127.0.0.1:6081']
249
     *
250
     * @throws InvalidUrlException If server is invalid or contains URL
251
     *                             parts other than scheme, host, port
252
     */
253 40
    private function setServers(array $servers)
254
    {
255 40
        $this->servers = [];
256 40
        foreach ($servers as $server) {
257 40
            $this->servers[] = $this->filterUri($server, ['scheme', 'user', 'pass', 'host', 'port']);
258
        }
259 37
    }
260
261
    /**
262
     * Set application base URI that will be prefixed to relative purge and
263
     * refresh requests, and validate it.
264
     *
265
     * @param string $uriString Your application’s base URI
266
     *
267
     * @throws InvalidUrlException If the base URI is not a valid URI
268
     */
269 37
    private function setBaseUri($uriString = null)
270
    {
271 37
        if (!$uriString) {
272 4
            $this->baseUri = null;
273
274 4
            return;
275
        }
276
277 33
        $this->baseUri = $this->filterUri($uriString);
278 32
    }
279
280
    /**
281
     * Filter a URL.
282
     *
283
     * Prefix the URL with "http://" if it has no scheme, then check the URL
284
     * for validity. You can specify what parts of the URL are allowed.
285
     *
286
     * @param string   $uriString
287
     * @param string[] $allowedParts Array of allowed URL parts (optional)
288
     *
289
     * @return UriInterface Filtered URI (with default scheme if there was no scheme)
290
     *
291
     * @throws InvalidUrlException If URL is invalid, the scheme is not http or
292
     *                             contains parts that are not expected
293
     */
294 40
    private function filterUri($uriString, array $allowedParts = [])
295
    {
296 40
        if (!is_string($uriString)) {
0 ignored issues
show
The condition is_string($uriString) is always true.
Loading history...
297 1
            throw new \InvalidArgumentException(sprintf(
298 1
                'URI parameter must be a string, %s given',
299 1
                gettype($uriString)
300
            ));
301
        }
302
303
        // Creating a PSR-7 URI without scheme (with parse_url) results in the
304
        // original hostname to be seen as path. So first add a scheme if none
305
        // is given.
306 40
        if (false === strpos($uriString, '://')) {
307 36
            $uriString = sprintf('%s://%s', 'http', $uriString);
308
        }
309
310
        try {
311 40
            $uri = $this->uriFactory->createUri($uriString);
312 1
        } catch (\InvalidArgumentException $e) {
313 1
            throw InvalidUrlException::invalidUrl($uriString);
314
        }
315
316 39
        if (!$uri->getScheme()) {
317
            throw InvalidUrlException::invalidUrl($uriString, 'empty scheme');
318
        }
319
320 39
        if (count($allowedParts) > 0) {
321 39
            $parts = parse_url((string) $uri);
322 39
            $diff = array_diff(array_keys($parts), $allowedParts);
323 39
            if (count($diff) > 0) {
324 2
                throw InvalidUrlException::invalidUrlParts($uriString, $allowedParts);
325
            }
326
        }
327
328 37
        return $uri;
329
    }
330
331
    /**
332
     * Build a request signature based on the request data. Unique for every different request, identical
333
     * for the same requests.
334
     *
335
     * This signature is used to avoid sending the same invalidation request twice.
336
     *
337
     * @param RequestInterface $request An invalidation request
338
     *
339
     * @return string A signature for this request
340
     */
341 34
    private function getRequestSignature(RequestInterface $request)
342
    {
343 34
        $headers = $request->getHeaders();
344 34
        ksort($headers);
345
346 34
        return sha1($request->getMethod()."\n".$request->getUri()."\n".var_export($headers, true));
347
    }
348
}
349