1<?php
2
3namespace GuzzleHttp\Psr7;
4
5use Psr\Http\Message\MessageInterface;
6use Psr\Http\Message\RequestInterface;
7use Psr\Http\Message\ResponseInterface;
8
9final class Message
10{
11    /**
12     * Returns the string representation of an HTTP message.
13     *
14     * @param MessageInterface $message Message to convert to a string.
15     *
16     * @return string
17     */
18    public static function toString(MessageInterface $message)
19    {
20        if ($message instanceof RequestInterface) {
21            $msg = trim($message->getMethod() . ' '
22                    . $message->getRequestTarget())
23                . ' HTTP/' . $message->getProtocolVersion();
24            if (!$message->hasHeader('host')) {
25                $msg .= "\r\nHost: " . $message->getUri()->getHost();
26            }
27        } elseif ($message instanceof ResponseInterface) {
28            $msg = 'HTTP/' . $message->getProtocolVersion() . ' '
29                . $message->getStatusCode() . ' '
30                . $message->getReasonPhrase();
31        } else {
32            throw new \InvalidArgumentException('Unknown message type');
33        }
34
35        foreach ($message->getHeaders() as $name => $values) {
36            if (strtolower($name) === 'set-cookie') {
37                foreach ($values as $value) {
38                    $msg .= "\r\n{$name}: " . $value;
39                }
40            } else {
41                $msg .= "\r\n{$name}: " . implode(', ', $values);
42            }
43        }
44
45        return "{$msg}\r\n\r\n" . $message->getBody();
46    }
47
48    /**
49     * Get a short summary of the message body.
50     *
51     * Will return `null` if the response is not printable.
52     *
53     * @param MessageInterface $message    The message to get the body summary
54     * @param int              $truncateAt The maximum allowed size of the summary
55     *
56     * @return string|null
57     */
58    public static function bodySummary(MessageInterface $message, $truncateAt = 120)
59    {
60        $body = $message->getBody();
61
62        if (!$body->isSeekable() || !$body->isReadable()) {
63            return null;
64        }
65
66        $size = $body->getSize();
67
68        if ($size === 0) {
69            return null;
70        }
71
72        $summary = $body->read($truncateAt);
73        $body->rewind();
74
75        if ($size > $truncateAt) {
76            $summary .= ' (truncated...)';
77        }
78
79        // Matches any printable character, including unicode characters:
80        // letters, marks, numbers, punctuation, spacing, and separators.
81        if (preg_match('/[^\pL\pM\pN\pP\pS\pZ\n\r\t]/u', $summary)) {
82            return null;
83        }
84
85        return $summary;
86    }
87
88    /**
89     * Attempts to rewind a message body and throws an exception on failure.
90     *
91     * The body of the message will only be rewound if a call to `tell()`
92     * returns a value other than `0`.
93     *
94     * @param MessageInterface $message Message to rewind
95     *
96     * @throws \RuntimeException
97     */
98    public static function rewindBody(MessageInterface $message)
99    {
100        $body = $message->getBody();
101
102        if ($body->tell()) {
103            $body->rewind();
104        }
105    }
106
107    /**
108     * Parses an HTTP message into an associative array.
109     *
110     * The array contains the "start-line" key containing the start line of
111     * the message, "headers" key containing an associative array of header
112     * array values, and a "body" key containing the body of the message.
113     *
114     * @param string $message HTTP request or response to parse.
115     *
116     * @return array
117     */
118    public static function parseMessage($message)
119    {
120        if (!$message) {
121            throw new \InvalidArgumentException('Invalid message');
122        }
123
124        $message = ltrim($message, "\r\n");
125
126        $messageParts = preg_split("/\r?\n\r?\n/", $message, 2);
127
128        if ($messageParts === false || count($messageParts) !== 2) {
129            throw new \InvalidArgumentException('Invalid message: Missing header delimiter');
130        }
131
132        list($rawHeaders, $body) = $messageParts;
133        $rawHeaders .= "\r\n"; // Put back the delimiter we split previously
134        $headerParts = preg_split("/\r?\n/", $rawHeaders, 2);
135
136        if ($headerParts === false || count($headerParts) !== 2) {
137            throw new \InvalidArgumentException('Invalid message: Missing status line');
138        }
139
140        list($startLine, $rawHeaders) = $headerParts;
141
142        if (preg_match("/(?:^HTTP\/|^[A-Z]+ \S+ HTTP\/)(\d+(?:\.\d+)?)/i", $startLine, $matches) && $matches[1] === '1.0') {
143            // Header folding is deprecated for HTTP/1.1, but allowed in HTTP/1.0
144            $rawHeaders = preg_replace(Rfc7230::HEADER_FOLD_REGEX, ' ', $rawHeaders);
145        }
146
147        /** @var array[] $headerLines */
148        $count = preg_match_all(Rfc7230::HEADER_REGEX, $rawHeaders, $headerLines, PREG_SET_ORDER);
149
150        // If these aren't the same, then one line didn't match and there's an invalid header.
151        if ($count !== substr_count($rawHeaders, "\n")) {
152            // Folding is deprecated, see https://tools.ietf.org/html/rfc7230#section-3.2.4
153            if (preg_match(Rfc7230::HEADER_FOLD_REGEX, $rawHeaders)) {
154                throw new \InvalidArgumentException('Invalid header syntax: Obsolete line folding');
155            }
156
157            throw new \InvalidArgumentException('Invalid header syntax');
158        }
159
160        $headers = [];
161
162        foreach ($headerLines as $headerLine) {
163            $headers[$headerLine[1]][] = $headerLine[2];
164        }
165
166        return [
167            'start-line' => $startLine,
168            'headers' => $headers,
169            'body' => $body,
170        ];
171    }
172
173    /**
174     * Constructs a URI for an HTTP request message.
175     *
176     * @param string $path    Path from the start-line
177     * @param array  $headers Array of headers (each value an array).
178     *
179     * @return string
180     */
181    public static function parseRequestUri($path, array $headers)
182    {
183        $hostKey = array_filter(array_keys($headers), function ($k) {
184            return strtolower($k) === 'host';
185        });
186
187        // If no host is found, then a full URI cannot be constructed.
188        if (!$hostKey) {
189            return $path;
190        }
191
192        $host = $headers[reset($hostKey)][0];
193        $scheme = substr($host, -4) === ':443' ? 'https' : 'http';
194
195        return $scheme . '://' . $host . '/' . ltrim($path, '/');
196    }
197
198    /**
199     * Parses a request message string into a request object.
200     *
201     * @param string $message Request message string.
202     *
203     * @return Request
204     */
205    public static function parseRequest($message)
206    {
207        $data = self::parseMessage($message);
208        $matches = [];
209        if (!preg_match('/^[\S]+\s+([a-zA-Z]+:\/\/|\/).*/', $data['start-line'], $matches)) {
210            throw new \InvalidArgumentException('Invalid request string');
211        }
212        $parts = explode(' ', $data['start-line'], 3);
213        $version = isset($parts[2]) ? explode('/', $parts[2])[1] : '1.1';
214
215        $request = new Request(
216            $parts[0],
217            $matches[1] === '/' ? self::parseRequestUri($parts[1], $data['headers']) : $parts[1],
218            $data['headers'],
219            $data['body'],
220            $version
221        );
222
223        return $matches[1] === '/' ? $request : $request->withRequestTarget($parts[1]);
224    }
225
226    /**
227     * Parses a response message string into a response object.
228     *
229     * @param string $message Response message string.
230     *
231     * @return Response
232     */
233    public static function parseResponse($message)
234    {
235        $data = self::parseMessage($message);
236        // According to https://tools.ietf.org/html/rfc7230#section-3.1.2 the space
237        // between status-code and reason-phrase is required. But browsers accept
238        // responses without space and reason as well.
239        if (!preg_match('/^HTTP\/.* [0-9]{3}( .*|$)/', $data['start-line'])) {
240            throw new \InvalidArgumentException('Invalid response string: ' . $data['start-line']);
241        }
242        $parts = explode(' ', $data['start-line'], 3);
243
244        return new Response(
245            (int) $parts[1],
246            $data['headers'],
247            $data['body'],
248            explode('/', $parts[0])[1],
249            isset($parts[2]) ? $parts[2] : null
250        );
251    }
252}
253