Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.67% covered (success)
97.67%
126 / 129
85.71% covered (success)
85.71%
6 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
Parser
97.67% covered (success)
97.67%
126 / 129
85.71% covered (success)
85.71%
6 / 7
48
0.00% covered (danger)
0.00%
0 / 1
 parseHeaders
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
6
 parseDataByContentType
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
17
 parseResponseFromUri
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 parseResponseFromString
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 encodeData
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
7
 decodeData
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
9
 decodeChunkedData
85.00% covered (success)
85.00%
17 / 20
0.00% covered (danger)
0.00%
0 / 1
6.12
1<?php
2/**
3 * Pop PHP Framework (http://www.popphp.org/)
4 *
5 * @link       https://github.com/popphp/popphp-framework
6 * @author     Nick Sagona, III <dev@nolainteractive.com>
7 * @copyright  Copyright (c) 2009-2024 NOLA Interactive, LLC. (http://www.nolainteractive.com)
8 * @license    http://www.popphp.org/license     New BSD License
9 */
10
11/**
12 * @namespace
13 */
14namespace Pop\Http;
15
16use Pop\Mime\Message;
17use Pop\Mime\Part\Header;
18
19/**
20 * HTTP response parser class
21 *
22 * @category   Pop
23 * @package    Pop\Http
24 * @author     Nick Sagona, III <dev@nolainteractive.com>
25 * @copyright  Copyright (c) 2009-2024 NOLA Interactive, LLC. (http://www.nolainteractive.com)
26 * @license    http://www.popphp.org/license     New BSD License
27 * @version    5.0.0
28 */
29class Parser
30{
31
32    /**
33     * Encoding constants
34     * @var string
35     */
36    const BASE64  = 'BASE64';
37    const QUOTED  = 'QUOTED';
38    const URL     = 'URL';
39    const RAW_URL = 'RAW_URL';
40    const GZIP    = 'GZIP';
41    const DEFLATE = 'DEFLATE';
42
43    /**
44     * Parse headers
45     *
46     * @param  mixed $headers
47     * @return array
48     */
49    public static function parseHeaders(mixed $headers): array
50    {
51        $httpVersion   = null;
52        $httpCode      = null;
53        $httpMessage   = null;
54        $headerObjects = [];
55
56        $headers = (is_string($headers)) ?
57            array_map('trim', explode("\n", $headers)) : (array)$headers;
58
59        foreach ($headers as $header) {
60            if (str_contains($header, 'HTTP/')) {
61                $httpVersion = substr($header, 0, strpos($header, ' '));
62                $httpVersion = substr($httpVersion, (strpos($httpVersion, '/') + 1));
63
64                $match = [];
65                preg_match('/\d\d\d/', trim($header), $match);
66
67                if (isset($match[0])) {
68                    $httpCode    = $match[0];
69                    $httpMessage = trim(substr($header, strpos($header, ' ' . $httpCode . ' ') + 5));
70                }
71            } else if (str_contains($header, ':')) {
72                $headerObject = Header::parse($header);
73                $headerObjects[$headerObject->getName()] = $headerObject;
74            }
75        }
76
77        return [
78            'version' => $httpVersion,
79            'code'    => $httpCode,
80            'message' => $httpMessage,
81            'headers' => $headerObjects
82        ];
83    }
84
85    /**
86     * Parse request or response data based on content type
87     *
88     * @param  string  $rawData
89     * @param  ?string $contentType
90     * @param  ?string $encoding
91     * @param  bool    $chunked
92     * @return mixed
93     */
94    public static function parseDataByContentType(
95        string $rawData, ?string $contentType = null, ?string $encoding = null, bool $chunked = false
96    ): mixed
97    {
98        $parsedResult = false;
99
100        if ($contentType !== null) {
101            $contentType = strtolower($contentType);
102        }
103        if ($encoding !== null) {
104            $encoding = strtoupper($encoding);
105        }
106
107        // JSON data
108        if (($contentType !== null) && (str_contains($contentType, 'json'))) {
109            $parsedResult = json_decode(self::decodeData($rawData, $encoding, $chunked), true);
110        // XML data
111        } else if (($contentType !== null) && (str_contains($contentType, 'xml'))) {
112            $rawData = self::decodeData($rawData, $encoding, $chunked);
113            $matches = [];
114            preg_match_all('/<!\[cdata\[(.*?)\]\]>/is', $rawData, $matches);
115
116            foreach ($matches[0] as $match) {
117                $strip = str_replace(
118                    ['<![CDATA[', ']]>', '<', '>'],
119                    ['', '', '&lt;', '&gt;'],
120                    $match
121                );
122                $rawData = str_replace($match, $strip, $rawData);
123            }
124
125            $parsedResult = json_decode(json_encode((array)simplexml_load_string($rawData)), true);
126        // URL-encoded form data
127        } else if (($contentType !== null) && (str_contains($contentType, 'application/x-www-form-urlencoded'))) {
128            $parsedResult = [];
129            parse_str(self::decodeData($rawData, $encoding, $chunked), $parsedResult);
130        // Multipart form data
131        } else if (($contentType !== null) && (str_contains($contentType, 'multipart/form-data'))) {
132            $formContent  = (!str_contains($rawData, 'Content-Type:')) ?
133                'Content-Type: ' . $contentType . "\r\n\r\n" . $rawData : $rawData;
134            $parsedResult = Message::parseForm($formContent);
135        // HTML text or plain text
136        } else if (($contentType !== null) && (str_contains($contentType, 'text/html') || str_contains($contentType, 'text/plain'))) {
137            $parsedResult = $rawData;
138        // Fallback to just the encoding
139        } else if ($encoding !== null) {
140            $parsedResult = self::decodeData($rawData, $encoding, $chunked);
141        }
142
143        return $parsedResult;
144    }
145
146    /**
147     * Parse a response from a URI
148     *
149     * @param  string $uri
150     * @param  string $method
151     * @param  string $mode
152     * @param  array  $options
153     * @param  array  $params
154     * @throws Client\Exception|Exception
155     * @return Server\Response
156     */
157    public static function parseResponseFromUri(
158        string $uri, string $method = 'GET', string $mode = 'r', array $options = [], array $params = []
159    ): Server\Response
160    {
161        $request  = new Client\Request($uri, $method);
162        $handler  = new Client\Handler\Stream($mode, $options, $params);
163        $response = $handler->prepare($request, null, false)->send();
164
165        return new Server\Response([
166            'code'    => $response->getCode(),
167            'headers' => $response->getHeaders(),
168            'body'    => $response->getBody(),
169            'message' => $response->getMessage(),
170            'version' => $response->getVersion()
171        ]);
172    }
173
174    /**
175     * Parse a response from a full response string
176     *
177     * @param  string $responseString
178     * @return Server\Response
179     */
180    public static function parseResponseFromString(string $responseString): Server\Response
181    {
182        $headerString  = substr($responseString, 0, strpos($responseString, "\r\n\r\n"));
183        $bodyString    = substr($responseString, (strpos($responseString, "\r\n\r\n") + 4));
184        $parsedHeaders = self::parseHeaders($headerString);
185
186        // If the body content is encoded, decode the body content
187        if (array_key_exists('Content-Encoding', $parsedHeaders['headers'])) {
188            $encoding = strtoupper($parsedHeaders['headers']['Content-Encoding']);
189            $chunked  = ($parsedHeaders['headers']['Transfer-Encoding'] == 'chunked');
190            $body     = self::decodeData($bodyString, $encoding, $chunked);
191        } else {
192            $body     = $bodyString;
193        }
194
195        return new Server\Response([
196            'code'    => $parsedHeaders['code'],
197            'headers' => $parsedHeaders['headers'],
198            'body'    => $body,
199            'message' => $parsedHeaders['message'],
200            'version' => $parsedHeaders['version']
201        ]);
202    }
203
204    /**
205     * Encode data
206     *
207     * @param  string  $data
208     * @param  ?string $encoding
209     * @return string
210     */
211    public static function encodeData(string $data, ?string $encoding = null): string
212    {
213        switch ($encoding) {
214            case self::BASE64:
215                $data = base64_encode($data);
216                break;
217            case self::QUOTED:
218                $data = quoted_printable_encode($data);
219                break;
220            case self::URL:
221                $data = urlencode($data);
222                break;
223            case self::RAW_URL:
224                $data = rawurlencode($data);
225                break;
226            case self::GZIP:
227                $data = gzencode($data);
228                break;
229            case self::DEFLATE:
230                $data = gzdeflate($data);
231                break;
232        }
233
234        return $data;
235    }
236
237    /**
238     * Decode data
239     *
240     * @param  string  $data
241     * @param  ?string $encoding
242     * @param  bool    $chunked
243     * @return string
244     */
245    public static function decodeData(string $data, ?string $encoding = null, bool $chunked = false): string
246    {
247        if ($chunked) {
248            $data = self::decodeChunkedData($data);
249        }
250
251        switch ($encoding) {
252            case self::BASE64:
253                $data = base64_decode($data);
254                break;
255            case self::QUOTED:
256                $data = quoted_printable_decode($data);
257                break;
258            case self::URL:
259                $data = urldecode($data);
260                break;
261            case self::RAW_URL:
262                $data = rawurldecode($data);
263                break;
264            case self::GZIP:
265                $data = gzinflate(substr($data, 10));
266                break;
267            case self::DEFLATE:
268                $zLib = unpack('n', substr($data, 0, 2));
269                $data = ($zLib[1] % 31 == 0) ? gzuncompress($data) : gzinflate($data);
270                break;
271        }
272
273        return $data;
274    }
275
276    /**
277     * Decode a chunked transfer-encoded data and return the decoded data
278     *
279     * @param  string $data
280     * @return string
281     */
282    public static function decodeChunkedData(string $data): string
283    {
284        $decoded = '';
285
286        while($data != '') {
287            $lfPos = strpos($data, "\012");
288
289            if ($lfPos === false) {
290                $decoded .= $data;
291                break;
292            }
293
294            $chunkHex = trim(substr($data, 0, $lfPos));
295            $scPos    = strpos($chunkHex, ';');
296
297            if ($scPos !== false) {
298                $chunkHex = substr($chunkHex, 0, $scPos);
299            }
300
301            if ($chunkHex == '') {
302                $decoded .= substr($data, 0, $lfPos);
303                $data     = substr($data, $lfPos + 1);
304                continue;
305            }
306
307            $chunkLength = hexdec($chunkHex);
308
309            if ($chunkLength) {
310                $decoded .= substr($data, $lfPos + 1, $chunkLength);
311                $data     = substr($data, $lfPos + 2 + $chunkLength);
312            } else {
313                $data = '';
314            }
315        }
316
317        return $decoded;
318    }
319
320}