Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
79 / 79
100.00% covered (success)
100.00%
3 / 3
CRAP
100.00% covered (success)
100.00%
1 / 1
Client
100.00% covered (success)
100.00%
79 / 79
100.00% covered (success)
100.00%
3 / 3
18
100.00% covered (success)
100.00%
1 / 1
 run
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
4
 runMulti
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
8
 parseHeaderLine
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
6
1<?php declare(strict_types=1);
2/*
3 * This file is part of Aplus Framework HTTP Client Library.
4 *
5 * (c) Natan Felles <natanfelles@gmail.com>
6 *
7 * For the full copyright and license information, please view the LICENSE
8 * file that was distributed with this source code.
9 */
10namespace Framework\HTTP\Client;
11
12use CurlHandle;
13use Framework\HTTP\Status;
14use Generator;
15use InvalidArgumentException;
16use RuntimeException;
17
18/**
19 * Class Client.
20 *
21 * @see https://www.php.net/manual/en/function.curl-setopt.php
22 * @see https://curl.se/libcurl/c/curl_easy_setopt.html
23 * @see https://php.watch/articles/php-curl-security-hardening
24 *
25 * @package http-client
26 */
27class Client
28{
29    /**
30     * @var array<mixed>
31     */
32    protected array $parsed = [];
33
34    /**
35     * Run the Request.
36     *
37     * @param Request $request
38     *
39     * @throws InvalidArgumentException for invalid Request Protocol
40     * @throws RuntimeException for curl error
41     *
42     * @return Response
43     */
44    public function run(Request $request) : Response
45    {
46        $handle = \curl_init();
47        $options = $request->getOptions();
48        $options[\CURLOPT_HEADERFUNCTION] = [$this, 'parseHeaderLine'];
49        \curl_setopt_array($handle, $options);
50        $body = \curl_exec($handle);
51        $info = [];
52        if ($request->isGettingResponseInfo()) {
53            $info = (array) \curl_getinfo($handle);
54        }
55        if ($body === false) {
56            throw new RuntimeException(\curl_error($handle), \curl_errno($handle));
57        }
58        \curl_close($handle);
59        if ($body === true) {
60            $body = '';
61        }
62        $objectId = \spl_object_id($handle);
63        $response = new Response(
64            $request,
65            $this->parsed[$objectId]['protocol'],
66            $this->parsed[$objectId]['code'],
67            $this->parsed[$objectId]['reason'],
68            $this->parsed[$objectId]['headers'],
69            $body,
70            $info
71        );
72        unset($this->parsed[$objectId]);
73        return $response;
74    }
75
76    /**
77     * Run multiple HTTP Requests.
78     *
79     * @param Request[] $requests An associative array of Request instances
80     * with ids as keys
81     *
82     * @return Generator<Response> The Requests ids as keys and its respective
83     * Responses as values
84     */
85    public function runMulti(array $requests) : Generator
86    {
87        $multiHandle = \curl_multi_init();
88        $handles = [];
89        foreach ($requests as $id => $request) {
90            $handle = \curl_init();
91            $options = $request->getOptions();
92            $options[\CURLOPT_HEADERFUNCTION] = [$this, 'parseHeaderLine'];
93            \curl_setopt_array($handle, $options);
94            $handles[$id] = $handle;
95            \curl_multi_add_handle($multiHandle, $handle);
96        }
97        do {
98            $status = \curl_multi_exec($multiHandle, $stillRunning);
99            $message = \curl_multi_info_read($multiHandle);
100            if ($message) {
101                foreach ($handles as $id => $handle) {
102                    if ($message['handle'] === $handle) {
103                        $info = [];
104                        if ($requests[$id]->isGettingResponseInfo()) {
105                            $info = (array) \curl_getinfo($handle);
106                        }
107                        $objectId = \spl_object_id($handle);
108                        if ( ! isset($this->parsed[$objectId])) {
109                            unset($handles[$id]);
110                            break;
111                        }
112                        yield $id => new Response(
113                            $requests[$id],
114                            $this->parsed[$objectId]['protocol'],
115                            $this->parsed[$objectId]['code'],
116                            $this->parsed[$objectId]['reason'],
117                            $this->parsed[$objectId]['headers'],
118                            (string) \curl_multi_getcontent($message['handle']),
119                            $info
120                        );
121                        unset($this->parsed[$objectId], $handles[$id]);
122                        break;
123                    }
124                }
125                \curl_multi_remove_handle($multiHandle, $message['handle']);
126            }
127        } while ($stillRunning && $status === \CURLM_OK);
128        \curl_multi_close($multiHandle);
129    }
130
131    /**
132     * Parses Header line.
133     *
134     * @param CurlHandle $curlHandle
135     * @param string $line
136     *
137     * @return int
138     */
139    protected function parseHeaderLine(CurlHandle $curlHandle, string $line) : int
140    {
141        $trimmedLine = \trim($line);
142        $lineLength = \strlen($line);
143        if ($trimmedLine === '') {
144            return $lineLength;
145        }
146        $id = \spl_object_id($curlHandle);
147        if ( ! \str_contains($trimmedLine, ':')) {
148            if (\str_starts_with($trimmedLine, 'HTTP/')) {
149                $parts = \explode(' ', $trimmedLine, 3);
150                $this->parsed[$id]['protocol'] = $parts[0];
151                $this->parsed[$id]['code'] = (int) ($parts[1] ?? 200);
152                $this->parsed[$id]['reason'] = $parts[2]
153                    ?? Status::getReason($this->parsed[$id]['code']);
154            }
155            return $lineLength;
156        }
157        [$name, $value] = \explode(':', $trimmedLine, 2);
158        $name = \trim($name);
159        $value = \trim($value);
160        if ($name !== '' && $value !== '') {
161            $this->parsed[$id]['headers'][\strtolower($name)][] = $value;
162        }
163        return $lineLength;
164    }
165}