1<?php 2 3declare(strict_types=1); 4 5namespace GuzzleHttp\Psr7; 6 7use Psr\Http\Message\MessageInterface; 8use Psr\Http\Message\RequestInterface; 9use Psr\Http\Message\ResponseInterface; 10 11final 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} 247