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