Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.06% covered (success)
97.06%
165 / 170
70.00% covered (success)
70.00%
7 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Parser
97.06% covered (success)
97.06%
165 / 170
70.00% covered (success)
70.00%
7 / 10
58
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
 parseMediaType
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
4
 parseDataByContentType
96.00% covered (success)
96.00%
24 / 25
0.00% covered (danger)
0.00%
0 / 1
16
 parseXml
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
4
 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
86.36% covered (success)
86.36%
19 / 22
0.00% covered (danger)
0.00%
0 / 1
6.09
 isOfficeDocument
88.89% covered (success)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <dev@noladev.com>
8 * @copyright  Copyright (c) 2009-2027 NOLA Interactive, LLC.
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Http;
16
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@noladev.com>
25 * @copyright  Copyright (c) 2009-2027 NOLA Interactive, LLC.
26 * @license    https://www.popphp.org/license     New BSD License
27 * @version    6.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 a Content-Type header value into its structured parts
87     *
88     * @param  string $contentType
89     * @return array
90     */
91    public static function parseMediaType(string $contentType): array
92    {
93        $parts    = explode(';', $contentType);
94        $fullType = strtolower(trim(array_shift($parts)));
95        $params   = [];
96
97        foreach ($parts as $part) {
98            if (str_contains($part, '=')) {
99                [$key, $value] = array_map('trim', explode('=', $part, 2));
100                $params[strtolower($key)] = trim($value, '"');
101            }
102        }
103
104        [$type, $subtypeAndSuffix] = array_pad(explode('/', $fullType, 2), 2, '');
105        $suffix   = null;
106        $subtype  = $subtypeAndSuffix;
107
108        if (str_contains($subtypeAndSuffix, '+')) {
109            [$subtype, $suffix] = explode('+', $subtypeAndSuffix, 2);
110        }
111
112        return [
113            'type'    => $type,
114            'subtype' => $subtype,
115            'suffix'  => $suffix,
116            'params'  => $params,
117        ];
118    }
119
120    /**
121     * Parse request or response data based on content type
122     *
123     * @param  string  $rawData
124     * @param  ?string $contentType
125     * @param  ?string $encoding
126     * @param  bool    $chunked
127     * @throws Exception
128     * @return mixed
129     */
130    public static function parseDataByContentType(
131        string $rawData, ?string $contentType = null, ?string $encoding = null, bool $chunked = false
132    ): mixed
133    {
134        if ($encoding !== null) {
135            $encoding = strtoupper($encoding);
136        }
137
138        if ($contentType === null) {
139            return self::decodeData($rawData, $encoding, $chunked);
140        }
141
142        $media   = self::parseMediaType($contentType);
143        $rawData = self::decodeData($rawData, $encoding, $chunked);
144
145        if (isset($media['params']['charset']) && (strtoupper($media['params']['charset']) !== 'UTF-8')) {
146            $converted = @mb_convert_encoding($rawData, 'UTF-8', $media['params']['charset']);
147            if ($converted !== false) {
148                $rawData = $converted;
149            }
150        }
151
152        // JSON: application/json, or a structured +json suffix (e.g. application/vnd.api+json)
153        if (($media['subtype'] === 'json') || ($media['suffix'] === 'json')) {
154            return json_decode($rawData, true);
155        }
156
157        // Generic XML only: application/xml or text/xml specifically - NOT a
158        // +xml structured suffix (e.g. application/xhtml+xml, application/rdf+xml),
159        // and not an office document that merely contains "xml" in its name.
160        if ((($media['subtype'] === 'xml') && ($media['suffix'] === null)) &&
161            !self::isOfficeDocument($contentType)) {
162            return self::parseXml($rawData);
163        }
164
165        if (($media['type'] === 'application') && ($media['subtype'] === 'x-www-form-urlencoded')) {
166            $parsedResult = [];
167            parse_str($rawData, $parsedResult);
168            return $parsedResult;
169        }
170
171        if (($media['type'] === 'multipart') && ($media['subtype'] === 'form-data')) {
172            $boundary = $media['params']['boundary'] ?? null;
173            if ($boundary === null) {
174                throw new Exception('Error: The multipart/form-data content type is missing its boundary parameter.');
175            }
176            return \Pop\Http\Body\Multipart::parse($rawData, $boundary);
177        }
178
179        return $rawData;
180    }
181
182    /**
183     * Parse an XML string into an array, throwing on malformed input instead
184     * of silently returning a nonsense value.
185     *
186     * @param  string $rawData
187     * @throws Exception
188     * @return array
189     */
190    protected static function parseXml(string $rawData): array
191    {
192        $matches = [];
193        preg_match_all('/<!\[cdata\[(.*?)\]\]>/is', $rawData, $matches);
194
195        foreach ($matches[0] as $match) {
196            $strip = str_replace(
197                ['<![CDATA[', ']]>', '<', '>'],
198                ['', '', '&lt;', '&gt;'],
199                $match
200            );
201            $rawData = str_replace($match, $strip, $rawData);
202        }
203
204        $previous = libxml_use_internal_errors(true);
205        libxml_clear_errors();
206        $xml = simplexml_load_string($rawData);
207        $errors = libxml_get_errors();
208        libxml_use_internal_errors($previous);
209
210        if ($xml === false) {
211            $message = !empty($errors) ? trim($errors[0]->message) : 'unknown XML parse error';
212            throw new Exception('Error: Unable to parse XML content - ' . $message);
213        }
214
215        return json_decode(json_encode((array)$xml), true);
216    }
217
218    /**
219     * Parse a response from a URI
220     *
221     * @param  string $uri
222     * @param  string $method
223     * @param  string $mode
224     * @param  array  $options
225     * @param  array  $params
226     * @throws Client\Exception|Exception
227     * @return Server\Response
228     */
229    public static function parseResponseFromUri(
230        string $uri, string $method = 'GET', string $mode = 'r', array $options = [], array $params = []
231    ): Server\Response
232    {
233        $request  = new Client\Request($uri, $method);
234        $handler  = new Client\Handler\Stream($mode, $options, $params);
235        $response = $handler->prepare($request, null, false)->send();
236
237        return new Server\Response([
238            'code'    => $response->getCode(),
239            'headers' => $response->getHeaderObjects(),
240            'body'    => $response->getBody(),
241            'message' => $response->getMessage(),
242            'version' => $response->getVersion()
243        ]);
244    }
245
246    /**
247     * Parse a response from a full response string
248     *
249     * @param  string $responseString
250     * @return Server\Response
251     */
252    public static function parseResponseFromString(string $responseString): Server\Response
253    {
254        $headerString  = substr($responseString, 0, strpos($responseString, "\r\n\r\n"));
255        $bodyString    = substr($responseString, (strpos($responseString, "\r\n\r\n") + 4));
256        $parsedHeaders = self::parseHeaders($headerString);
257
258        // If the body content is encoded, decode the body content
259        if (array_key_exists('Content-Encoding', $parsedHeaders['headers'])) {
260            $encoding = strtoupper((string)$parsedHeaders['headers']['Content-Encoding']->getValueAsString());
261            $chunked  = ($parsedHeaders['headers']['Transfer-Encoding'] == 'chunked');
262            $body     = self::decodeData($bodyString, $encoding, $chunked);
263        } else {
264            $body     = $bodyString;
265        }
266
267        return new Server\Response([
268            'code'    => (int)$parsedHeaders['code'],
269            'headers' => $parsedHeaders['headers'],
270            'body'    => $body,
271            'message' => $parsedHeaders['message'],
272            'version' => $parsedHeaders['version']
273        ]);
274    }
275
276    /**
277     * Encode data
278     *
279     * @param  string  $data
280     * @param  ?string $encoding
281     * @return string
282     */
283    public static function encodeData(string $data, ?string $encoding = null): string
284    {
285        switch ($encoding) {
286            case self::BASE64:
287                $data = base64_encode($data);
288                break;
289            case self::QUOTED:
290                $data = quoted_printable_encode($data);
291                break;
292            case self::URL:
293                $data = urlencode($data);
294                break;
295            case self::RAW_URL:
296                $data = rawurlencode($data);
297                break;
298            case self::GZIP:
299                $data = gzencode($data);
300                break;
301            case self::DEFLATE:
302                $data = gzdeflate($data);
303                break;
304        }
305
306        return $data;
307    }
308
309    /**
310     * Decode data
311     *
312     * @param  string  $data
313     * @param  ?string $encoding
314     * @param  bool    $chunked
315     * @return string
316     */
317    public static function decodeData(string $data, ?string $encoding = null, bool $chunked = false): string
318    {
319        if ($chunked) {
320            $data = self::decodeChunkedData($data);
321        }
322
323        switch ($encoding) {
324            case self::BASE64:
325                $data = base64_decode($data);
326                break;
327            case self::QUOTED:
328                $data = quoted_printable_decode($data);
329                break;
330            case self::URL:
331                $data = urldecode($data);
332                break;
333            case self::RAW_URL:
334                $data = rawurldecode($data);
335                break;
336            case self::GZIP:
337                $data = gzdecode($data);
338                break;
339            case self::DEFLATE:
340                $zLib = unpack('n', substr($data, 0, 2));
341                $data = ($zLib[1] % 31 == 0) ? gzuncompress($data) : gzinflate($data);
342                break;
343        }
344
345        return $data;
346    }
347
348    /**
349     * Decode a chunked transfer-encoded data and return the decoded data
350     *
351     * @param  string $data
352     * @return string
353     */
354    public static function decodeChunkedData(string $data): string
355    {
356        $decoded = '';
357        $offset  = 0;
358        $length  = strlen($data);
359
360        // Tracks an offset into the original $data instead of reassigning $data = substr($data, ...)
361        // every iteration - the latter copies the entire remaining tail of the buffer on each chunk,
362        // making decode time O(N*k) for k chunks instead of linear in the total data length.
363        while ($offset < $length) {
364            $lfPos = strpos($data, "\012", $offset);
365
366            if ($lfPos === false) {
367                $decoded .= substr($data, $offset);
368                break;
369            }
370
371            $chunkHex = trim(substr($data, $offset, $lfPos - $offset));
372            $scPos    = strpos($chunkHex, ';');
373
374            if ($scPos !== false) {
375                $chunkHex = substr($chunkHex, 0, $scPos);
376            }
377
378            if ($chunkHex == '') {
379                $decoded .= substr($data, $offset, $lfPos - $offset);
380                $offset   = $lfPos + 1;
381                continue;
382            }
383
384            $chunkLength = hexdec($chunkHex);
385
386            if ($chunkLength) {
387                $decoded .= substr($data, $lfPos + 1, $chunkLength);
388                $offset   = $lfPos + 2 + $chunkLength;
389            } else {
390                break;
391            }
392        }
393
394        return $decoded;
395    }
396
397    /**
398     * Determine if the content-type is that of an office document
399     *
400     * @param  string $contentType
401     * @return bool
402     */
403    public static function isOfficeDocument(string $contentType): bool
404    {
405        $keywords = [
406            'open', 'office', 'ms-', 'word', 'excel', 'powerpoint', 'document',
407            'presentation', 'sheet', 'template', 'slideshow', 'addin'
408        ];
409        $contentType = strtolower($contentType);
410
411        foreach ($keywords as $keyword) {
412            if (str_contains($contentType, $keyword)) {
413                return true;
414            }
415        }
416
417        return false;
418    }
419
420}