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