Completed
Pull Request — master (#312)
by David
10:58
created

HttpDispatcher::setBaseUri()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

Changes 0
Metric Value
dl 0
loc 10
ccs 0
cts 0
cp 0
rs 9.4285
c 0
b 0
f 0
cc 2
eloc 5
nc 2
nop 1
crap 6
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\RequestException;
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
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
     * @param string[]             $servers    Caching proxy server hostnames or IP
75
     *                                         addresses, including port if not port 80.
76
     *                                         E.g. ['127.0.0.1:6081']
77
     * @param string               $baseUri    Default application hostname, optionally
78
     *                                         including base URL, for purge and refresh
79
     *                                         requests (optional). This is required if
80
     *                                         you purge and refresh paths instead of
81
     *                                         absolute URLs
82 38
     * @param HttpAsyncClient|null $httpClient Client capable of sending HTTP requests. If no
83
     *                                         client is supplied, a default one is created
84
     * @param UriFactory|null      $uriFactory Factory for PSR-7 URIs. If not specified, a
85
     *                                         default one is created
86
     */
87
    public function __construct(
88 38
        array $servers,
89 38
        $baseUri = '',
90
        HttpAsyncClient $httpClient = null,
91 38
        UriFactory $uriFactory = null
92 35
    ) {
93 34
        if (!$httpClient) {
94
            $httpClient = new PluginClient(
95
                HttpAsyncClientDiscovery::find(),
96
                [new ErrorPlugin()]
97
            );
98
        }
99
        $this->httpClient = $httpClient;
100 32
        $this->uriFactory = $uriFactory ?: UriFactoryDiscovery::find();
101
102 32
        $this->setServers($servers);
103 1
        $this->setBaseUri($baseUri);
104
    }
105
106 31
    /**
107
     * Queue invalidation request.
108 31
     *
109 1
     * @param RequestInterface $invalidationRequest
110
     */
111
    public function invalidate(RequestInterface $invalidationRequest)
112 31
    {
113 31
        if (!$this->baseUri && !$invalidationRequest->getUri()->getHost()) {
114
            throw MissingHostException::missingHost((string) $invalidationRequest->getUri());
115
        }
116
117
        $signature = $this->getRequestSignature($invalidationRequest);
118
119
        if (isset($this->queue[$signature])) {
120
            return;
121
        }
122 32
123
        $this->queue[$signature] = $invalidationRequest;
124 32
    }
125 32
126
    /**
127 32
     * Send all pending invalidation requests and make sure the requests have terminated and gather exceptions.
128
     *
129 32
     * @return int The number of cache invalidations performed per caching server
130
     *
131 32
     * @throws ExceptionCollection If any errors occurred during flush
132 31
     */
133
    public function flush()
134 31
    {
135 31
        $queue = $this->queue;
136 1
        $this->queue = [];
137
        /** @var Promise[] $promises */
138 31
        $promises = [];
139 32
140
        $exceptions = new ExceptionCollection();
141 32
142
        foreach ($queue as $request) {
143 31
            foreach ($this->fanOut($request) as $proxyRequest) {
144 31
                try {
145 1
                    $promises[] = $this->httpClient->sendAsyncRequest($proxyRequest);
146 2
                } catch (\Exception $e) {
147 1
                    $exceptions->add(new InvalidArgumentException($e));
148 1
                }
149
            }
150
        }
151
152
        foreach ($promises as $promise) {
153 32
            try {
154
                $promise->wait();
155 32
            } catch (HttpException $exception) {
156 3
                $exceptions->add(ProxyResponseException::proxyResponse($exception));
157
            } catch (RequestException $exception) {
158
                $exceptions->add(ProxyUnreachableException::proxyUnreachable($exception));
159 32
            } catch (\Exception $exception) {
160
                // @codeCoverageIgnoreStart
161
                $exceptions->add(new InvalidArgumentException($exception));
162
                // @codeCoverageIgnoreEnd
163
            }
164
        }
165
166
        if (count($exceptions)) {
167
            throw $exceptions;
168
        }
169 31
170
        return count($queue);
171 31
    }
172
173 31
    /**
174
     * Duplicate a request for each caching server.
175
     *
176
     * @param RequestInterface $request The request to duplicate for each configured server
177 31
     *
178 31
     * @return RequestInterface[]
179
     */
180 5
    private function fanOut(RequestInterface $request)
181 1
    {
182 1
        $requests = [];
183 5
184
        $uri = $request->getUri();
185 26
186 26
        // If a base URI is configured, try to make partial invalidation
187 26
        // requests complete.
188
        if ($this->baseUri) {
189 26
            if ($uri->getHost()) {
190 17
                // Absolute URI: does it already have a scheme?
191 17
                if (!$uri->getScheme() && $this->baseUri->getScheme() !== '') {
192
                    $uri = $uri->withScheme($this->baseUri->getScheme());
193
                }
194 26
            } else {
195 1
                // Relative URI
196 1
                if ($this->baseUri->getHost() !== '') {
197 1
                    $uri = $uri->withHost($this->baseUri->getHost());
198
                }
199 31
200
                if ($this->baseUri->getPort()) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->baseUri->getPort() of type null|integer is loosely compared to true; this is ambiguous if the integer can be zero. 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...
201
                    $uri = $uri->withPort($this->baseUri->getPort());
202
                }
203 31
204
                // Base path
205
                if ($this->baseUri->getPath() !== '') {
206 31
                    $path = $this->baseUri->getPath().'/'.ltrim($uri->getPath(), '/');
207 31
                    $uri = $uri->withPath($path);
208
                }
209 31
            }
210 31
        }
211 31
212
        // Close connections to make sure invalidation (PURGE/BAN) requests
213 31
        // will not interfere with content (GET) requests.
214 31
        $request = $request->withUri($uri)->withHeader('Connection', 'Close');
215
216 31
        // Create a request to each caching proxy server
217
        foreach ($this->servers as $server) {
218
            $requests[] = $request->withUri(
219
                $uri
220
                    ->withScheme($server->getScheme())
221
                    ->withHost($server->getHost())
222
                    ->withPort($server->getPort()),
223
                true    // Preserve application Host header
224
            );
225
        }
226
227
        return $requests;
228
    }
229 38
230
    /**
231 38
     * Set caching proxy server URI objects, validating them.
232 38
     *
233 38
     * @param string[] $servers Caching proxy proxy server hostnames or IP
234 35
     *                          addresses, including port if not port 80.
235 35
     *                          E.g. ['127.0.0.1:6081']
236
     *
237
     * @throws InvalidUrlException If server is invalid or contains URL
238
     *                             parts other than scheme, host, port
239
     */
240
    private function setServers(array $servers)
241
    {
242
        $this->servers = [];
243
        foreach ($servers as $server) {
244
            $this->servers[] = $this->filterUri($server, ['scheme', 'host', 'port']);
245 35
        }
246
    }
247 35
248 1
    /**
249
     * Set application base URI that will be prefixed to relative purge and
250 1
     * refresh requests, and validate it.
251
     *
252
     * @param string $uriString Your application’s base URI
253 34
     *
254 33
     * @throws InvalidUrlException If the base URI is not a valid URI
255
     */
256
    private function setBaseUri($uriString = null)
257
    {
258
        if (!$uriString) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $uriString of type string|null is loosely compared to false; this is ambiguous if the string can be empty. 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 string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
259
            $this->baseUri = null;
260
261
            return;
262
        }
263
264
        $this->baseUri = $this->filterUri($uriString);
265
    }
266
267
    /**
268
     * Filter a URL.
269
     *
270 38
     * Prefix the URL with "http://" if it has no scheme, then check the URL
271
     * for validity. You can specify what parts of the URL are allowed.
272 38
     *
273 1
     * @param string   $uriString
274 1
     * @param string[] $allowedParts Array of allowed URL parts (optional)
275 1
     *
276 1
     * @return UriInterface Filtered URI (with default scheme if there was no scheme)
277
     *
278
     * @throws InvalidUrlException If URL is invalid, the scheme is not http or
279
     *                             contains parts that are not expected
280
     */
281
    private function filterUri($uriString, array $allowedParts = [])
282 38
    {
283 35
        if (!is_string($uriString)) {
284 35
            throw new \InvalidArgumentException(sprintf(
285
                'URI parameter must be a string, %s given',
286
                gettype($uriString)
287 38
            ));
288 38
        }
289 1
290
        // Creating a PSR-7 URI without scheme (with parse_url) results in the
291
        // original hostname to be seen as path. So first add a scheme if none
292 37
        // is given.
293 1
        if (false === strpos($uriString, '://')) {
294
            $uriString = sprintf('%s://%s', 'http', $uriString);
295
        }
296 36
297 36
        try {
298 36
            $uri = $this->uriFactory->createUri($uriString);
299 36
        } catch (\InvalidArgumentException $e) {
300 1
            throw InvalidUrlException::invalidUrl($uriString);
301
        }
302 35
303
        if (!$uri->getScheme()) {
304 35
            throw InvalidUrlException::invalidUrl($uriString, 'empty scheme');
305
        }
306
307
        if (count($allowedParts) > 0) {
308
            $parts = parse_url((string) $uri);
309
            $diff = array_diff(array_keys($parts), $allowedParts);
310
            if (count($diff) > 0) {
311
                throw InvalidUrlException::invalidUrlParts($uriString, $allowedParts);
312
            }
313
        }
314
315
        return $uri;
316
    }
317 31
318
    /**
319 31
     * Build a request signature based on the request data. Unique for every different request, identical
320 31
     * for the same requests.
321
     *
322 31
     * This signature is used to avoid sending the same invalidation request twice.
323
     *
324
     * @param RequestInterface $request An invalidation request
325
     *
326
     * @return string A signature for this request
327
     */
328
    private function getRequestSignature(RequestInterface $request)
329
    {
330
        $headers = $request->getHeaders();
331
        ksort($headers);
332
333
        return sha1($request->getMethod()."\n".$request->getUri()."\n".var_export($headers, true));
334
    }
335
}
336