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