Passed
Push — master ( ac6eac...e2836d )
by Joel
05:14
created

Client::box()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 20
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
eloc 12
nc 2
nop 3
dl 0
loc 20
rs 9.8666
c 0
b 0
f 0
ccs 13
cts 13
cp 1
crap 2
1
<?php
2
3
namespace RedirectionIO\Client\Sdk;
4
5
use Psr\Log\LoggerInterface;
6
use Psr\Log\NullLogger;
7
use RedirectionIO\Client\Sdk\Exception\AgentNotFoundException;
8
use RedirectionIO\Client\Sdk\Exception\BadConfigurationException;
9
use RedirectionIO\Client\Sdk\Exception\ExceptionInterface;
10
use RedirectionIO\Client\Sdk\HttpMessage\RedirectResponse;
0 ignored issues
show
Bug introduced by
The type RedirectionIO\Client\Sdk...essage\RedirectResponse was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
11
use RedirectionIO\Client\Sdk\HttpMessage\Request;
12
use RedirectionIO\Client\Sdk\HttpMessage\Response;
13
14
class Client
15
{
16
    private $connections;
17
    private $timeout;
18
    private $debug;
19
    private $logger;
20
    private $currentConnection;
21
    private $currentConnectionName;
22
23
    /**
24
     * @param int  $timeout
25
     * @param bool $debug
26
     */
27 13
    public function __construct(array $connections, $timeout = 10000, $debug = false, LoggerInterface $logger = null)
28
    {
29 13
        if (!$connections) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $connections of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
30 1
            throw new BadConfigurationException('At least one connection is required.');
31
        }
32
33 13
        foreach ($connections as $name => $connection) {
34 13
            $this->connections[$name] = [
35 13
                'remote_socket' => $connection,
36 13
                'retries' => 2,
37
            ];
38 13
        }
39
40 13
        $this->timeout = $timeout;
41 13
        $this->debug = $debug;
42 13
        $this->logger = $logger ?: new NullLogger();
43 13
    }
44
45 9
    public function findRedirect(Request $request)
46
    {
47
        $requestContext = [
48 9
            'host' => $request->getHost(),
49 9
            'request_uri' => $request->getPath(),
50 9
            'user_agent' => $request->getUserAgent(),
51 9
            'referer' => $request->getReferer(),
52 9
            'scheme' => $request->getScheme(),
53 9
            'use_json' => true,
54 9
        ];
55
56
        try {
57 9
            $agentResponse = $this->request('GET', $requestContext);
58 9
        } catch (ExceptionInterface $exception) {
59 3
            if ($this->debug) {
60 1
                throw $exception;
61
            }
62
63 2
            return null;
64
        }
65
66 7
        if (0 === strlen($agentResponse)) {
67 1
            return null;
68
        }
69
70 6
        $json = json_decode($agentResponse, true);
71
72 6
        if (null === $json) {
73
            if ($this->debug) {
74
                throw new \ErrorException(sprintf('Impossible to decode the JSON (%s). Content: "%s"', json_last_error_msg(), $agentResponse));
75
            }
76
77
            return null;
78
        }
79
80 6
        $ruleId = null;
81 6
        $location = null;
82
83 6
        if (isset($json['matched_rule'], $json['matched_rule']['id'])) {
84
            $ruleId = $json['matched_rule']['id'];
85
        }
86
87 6
        if (isset($json['location'])) {
88 6
            $location = $json['location'];
89 6
        }
90
91 6
        return new Response((int) $json['status_code'], $ruleId, $location);
92
    }
93
94 3
    public function log(Request $request, Response $response)
95
    {
96
        $responseContext = [
97 3
            'status_code' => $response->getStatusCode(),
98 3
            'host' => $request->getHost(),
99 3
            'request_uri' => $request->getPath(),
100 3
            'user_agent' => $request->getUserAgent(),
101 3
            'referer' => $request->getReferer(),
102 3
            'scheme' => $request->getScheme(),
103 3
            'use_json' => true,
104 3
        ];
105
106 3
        if ($response->getLocation()) {
107
            $responseContext['target'] = $response->getLocation();
108
        }
109
110 3
        if ($response->getRuleId()) {
111
            $responseContext['rule_id'] = $response->getRuleId();
112
        }
113
114
        try {
115 3
            return (bool) $this->request('LOG', $responseContext);
116 2
        } catch (ExceptionInterface $exception) {
117 2
            if ($this->debug) {
118 2
                throw $exception;
119
            }
120
121 1
            return false;
122
        }
123
    }
124
125 12
    private function request($command, $context)
126
    {
127 12
        $connection = $this->getConnection();
128
129 8
        $content = $command.' '.json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)."\n";
130 8
        $sent = $this->box('doSend', false, [$connection, $content]);
131
132
        // if the pipe is broken, `fwrite` will throw a Notice
133 8
        if (false === $sent) {
134
            $this->logger->debug('Impossible to send content to the connection.', [
135
                'options' => $this->connections[$this->currentConnectionName],
136
            ]);
137
138
            --$this->connections[$this->currentConnectionName]['retries'];
139
            $this->currentConnection = null;
140
141
            return $this->request($command, $context);
142
        }
143
144 8
        $received = $this->box('doGet', false, [$connection]);
145
146
        // false: the persistent connection is stale
147 8
        if (false === $received) {
148 1
            $this->logger->debug('Impossible to get content from the connection.', [
149 1
                'options' => $this->connections[$this->currentConnectionName],
150 1
            ]);
151
152 1
            --$this->connections[$this->currentConnectionName]['retries'];
153 1
            $this->currentConnection = null;
154
155 1
            return $this->request($command, $context);
156
        }
157
158 8
        return trim($received);
159
    }
160
161 12
    private function getConnection()
162
    {
163 12
        if (null !== $this->currentConnection) {
164 2
            return $this->currentConnection;
165
        }
166
167 12
        foreach ($this->connections as $name => $connection) {
168 12
            if ($connection['retries'] <= 0) {
169 1
                continue;
170
            }
171
172 12
            $this->logger->debug('New connection chosen. Trying to connect.', [
173 12
                'connection' => $connection,
174 12
                'name' => $name,
175 12
            ]);
176
177 12
            $connection = $this->box('doConnect', false, [$connection]);
178
179 12
            if (false === $connection) {
180 6
                $this->logger->debug('Impossible to connect to the connection.', [
181 6
                    'connection' => $connection,
182 6
                    'name' => $name,
183 6
                ]);
184
185 6
                $this->connections[$name]['retries'] = 0;
186
187 6
                continue;
188
            }
189
190 8
            $this->logger->debug('New connection approved.', [
191 8
                'connection' => $connection,
192 8
                'name' => $name,
193 8
            ]);
194
195 8
            stream_set_timeout($connection, 0, $this->timeout);
196
197 8
            $this->currentConnection = $connection;
198 8
            $this->currentConnectionName = $name;
199
200 8
            return $connection;
201 5
        }
202
203 5
        $this->logger->error('Can not find an agent.', [
204 5
            'connections_options' => $this->connections,
205 5
        ]);
206
207 5
        throw new AgentNotFoundException();
208
    }
209
210 12
    private function doConnect($options)
211
    {
212 12
        return stream_socket_client(
213 12
            $options['remote_socket'],
214 12
            $errNo,
215 12
            $errMsg,
216 12
            1, // This value is not used but it should not be 0
217 12
            STREAM_CLIENT_CONNECT | STREAM_CLIENT_PERSISTENT
218 12
        );
219
    }
220
221 8
    private function doSend($connection, $content)
222
    {
223 8
        return $this->fwrite($connection, $content);
224
    }
225
226 8
    private function doGet($connection)
227
    {
228 8
        return fgets($connection);
229
    }
230
231 12
    private function box($method, $defaultReturnValue = null, array $args = [])
232
    {
233 12
        set_error_handler(__CLASS__.'::handleInternalError');
234
235
        try {
236 12
            $returnValue = call_user_func_array([$this, $method], $args);
237 12
        } catch (\ErrorException $exception) {
238 6
            $returnValue = $defaultReturnValue;
239
240 6
            $this->logger->warning('Impossible to execute a boxed call.', [
241 6
                'method' => $method,
242 6
                'default_return_value' => $defaultReturnValue,
243 6
                'args' => $args,
244 6
                'exception' => $exception,
245 6
            ]);
246
        }
247
248 12
        restore_error_handler();
249
250 12
        return $returnValue;
251
    }
252
253 6
    private static function handleInternalError($type, $message, $file, $line)
254
    {
255 6
        throw new \ErrorException($message, 0, $type, $file, $line);
256
    }
257
258
    /**
259
     * Replace fwrite behavior as API is broken in PHP.
260
     *
261
     * @see https://secure.phabricator.com/rPHU69490c53c9c2ef2002bc2dd4cecfe9a4b080b497
262
     *
263
     * @param resource $stream The stream resource
264
     * @param string   $bytes  Bytes written in the stream
265
     *
266
     * @return bool|int false if pipe is broken, number of bytes written otherwise
267
     */
268 8
    private function fwrite($stream, $bytes)
269
    {
270 8
        if (!strlen($bytes)) {
271
            return 0;
272
        }
273 8
        $result = @fwrite($stream, $bytes);
274 8
        if (0 !== $result) {
275
            // In cases where some bytes are witten (`$result > 0`) or
276
            // an error occurs (`$result === false`), the behavior of fwrite() is
277
            // correct. We can return the value as-is.
278 8
            return $result;
279
        }
280
        // If we make it here, we performed a 0-length write. Try to distinguish
281
        // between EAGAIN and EPIPE. To do this, we're going to `stream_select()`
282
        // the stream, write to it again if PHP claims that it's writable, and
283
        // consider the pipe broken if the write fails.
284
        $read = [];
285
        $write = [$stream];
286
        $except = [];
287
        @stream_select($read, $write, $except, 0);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for stream_select(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

287
        /** @scrutinizer ignore-unhandled */ @stream_select($read, $write, $except, 0);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
288
        if (!$write) {
289
            // The stream isn't writable, so we conclude that it probably really is
290
            // blocked and the underlying error was EAGAIN. Return 0 to indicate that
291
            // no data could be written yet.
292
            return 0;
293
        }
294
        // If we make it here, PHP **just** claimed that this stream is writable, so
295
        // perform a write. If the write also fails, conclude that these failures are
296
        // EPIPE or some other permanent failure.
297
        $result = @fwrite($stream, $bytes);
298
        if (0 !== $result) {
299
            // The write worked or failed explicitly. This value is fine to return.
300
            return $result;
301
        }
302
        // We performed a 0-length write, were told that the stream was writable, and
303
        // then immediately performed another 0-length write. Conclude that the pipe
304
        // is broken and return `false`.
305
        return false;
306
    }
307
}
308