1<?php 2 3namespace FPM; 4 5class Response 6{ 7 const HEADER_SEPARATOR = "\r\n\r\n"; 8 9 /** 10 * @var array 11 */ 12 private $data; 13 14 /** 15 * @var string 16 */ 17 private $rawData; 18 19 /** 20 * @var string 21 */ 22 private $rawHeaders; 23 24 /** 25 * @var string 26 */ 27 private $rawBody; 28 29 /** 30 * @var array 31 */ 32 private $headers; 33 34 /** 35 * @var bool 36 */ 37 private $valid; 38 39 /** 40 * @var bool 41 */ 42 private $expectInvalid; 43 44 /** 45 * @param string|array|null $data 46 * @param bool $expectInvalid 47 */ 48 public function __construct($data = null, $expectInvalid = false) 49 { 50 if ( ! is_array($data)) { 51 $data = [ 52 'response' => $data, 53 'err_response' => null, 54 'out_response' => $data, 55 ]; 56 } 57 58 $this->data = $data; 59 $this->expectInvalid = $expectInvalid; 60 } 61 62 /** 63 * @param mixed $body 64 * @param string $contentType 65 * 66 * @return Response 67 */ 68 public function expectBody($body, $contentType = 'text/html') 69 { 70 if ($multiLine = is_array($body)) { 71 $body = implode("\n", $body); 72 } 73 74 if ( 75 $this->checkIfValid() && 76 $this->checkDefaultHeaders($contentType) && 77 $body !== $this->rawBody 78 ) { 79 if ($multiLine) { 80 $this->error( 81 "==> The expected body:\n$body\n" . 82 "==> does not match the actual body:\n$this->rawBody" 83 ); 84 } else { 85 $this->error( 86 "The expected body '$body' does not match actual body '$this->rawBody'" 87 ); 88 } 89 } 90 91 return $this; 92 } 93 94 /** 95 * @return Response 96 */ 97 public function expectEmptyBody() 98 { 99 return $this->expectBody(''); 100 } 101 102 /** 103 * Expect header in the response. 104 * 105 * @param string $name Header name. 106 * @param string $value Header value. 107 * 108 * @return Response 109 */ 110 public function expectHeader($name, $value): Response 111 { 112 $this->checkHeader($name, $value); 113 114 return $this; 115 } 116 117 /** 118 * Expect error in the response. 119 * 120 * @param string|null $errorMessage Expected error message. 121 * 122 * @return Response 123 */ 124 public function expectError($errorMessage): Response 125 { 126 $errorData = $this->getErrorData(); 127 if ($errorData !== $errorMessage) { 128 $expectedErrorMessage = $errorMessage !== null 129 ? "The expected error message '$errorMessage' is not equal to returned error '$errorData'" 130 : "No error message expected but received '$errorData'"; 131 $this->error($expectedErrorMessage); 132 } 133 134 return $this; 135 } 136 137 /** 138 * Expect no error in the response. 139 * 140 * @return Response 141 */ 142 public function expectNoError(): Response 143 { 144 return $this->expectError(null); 145 } 146 147 /** 148 * Get response body. 149 * 150 * @param string $contentType Expect body to have specified content type. 151 * 152 * @return string|null 153 */ 154 public function getBody(string $contentType = 'text/html'): ?string 155 { 156 if ($this->checkIfValid() && $this->checkDefaultHeaders($contentType)) { 157 return $this->rawBody; 158 } 159 160 return null; 161 } 162 163 /** 164 * Print raw body. 165 */ 166 public function dumpBody() 167 { 168 var_dump($this->getBody()); 169 } 170 171 /** 172 * Print raw body. 173 */ 174 public function printBody() 175 { 176 echo $this->getBody() . "\n"; 177 } 178 179 /** 180 * Debug response output 181 */ 182 public function debugOutput() 183 { 184 echo ">>> Response\n"; 185 echo "----------------- OUT -----------------\n"; 186 echo $this->data['out_response'] . "\n"; 187 echo "----------------- ERR -----------------\n"; 188 echo $this->data['err_response'] . "\n"; 189 echo "---------------------------------------\n\n"; 190 } 191 192 /** 193 * @return string|null 194 */ 195 public function getErrorData(): ?string 196 { 197 return $this->data['err_response']; 198 } 199 200 /** 201 * Check if the response is valid and if not emit error message 202 * 203 * @return bool 204 */ 205 private function checkIfValid(): bool 206 { 207 if ($this->isValid()) { 208 return true; 209 } 210 211 if ( ! $this->expectInvalid) { 212 $this->error("The response is invalid: $this->rawData"); 213 } 214 215 return false; 216 } 217 218 /** 219 * Check default headers that should be present. 220 * 221 * @param string $contentType 222 * 223 * @return bool 224 */ 225 private function checkDefaultHeaders($contentType): bool 226 { 227 // check default headers 228 return ( 229 ( ! ini_get('expose_php') || $this->checkHeader('X-Powered-By', '|^PHP/8|', true)) && 230 $this->checkHeader('Content-type', '|^' . $contentType . '(;\s?charset=\w+)?|', true) 231 ); 232 } 233 234 /** 235 * Check a specified header. 236 * 237 * @param string $name Header name. 238 * @param string $value Header value. 239 * @param bool $useRegex Whether value is regular expression. 240 * 241 * @return bool 242 */ 243 private function checkHeader(string $name, string $value, $useRegex = false): bool 244 { 245 $lcName = strtolower($name); 246 $headers = $this->getHeaders(); 247 if ( ! isset($headers[$lcName])) { 248 return $this->error("The header $name is not present"); 249 } 250 $header = $headers[$lcName]; 251 252 if ( ! $useRegex) { 253 if ($header === $value) { 254 return true; 255 } 256 257 return $this->error("The header $name value '$header' is not the same as '$value'"); 258 } 259 260 if ( ! preg_match($value, $header)) { 261 return $this->error("The header $name value '$header' does not match RegExp '$value'"); 262 } 263 264 return true; 265 } 266 267 /** 268 * Get all headers. 269 * 270 * @return array|null 271 */ 272 private function getHeaders(): ?array 273 { 274 if ( ! $this->isValid()) { 275 return null; 276 } 277 278 if (is_array($this->headers)) { 279 return $this->headers; 280 } 281 282 $headerRows = explode("\r\n", $this->rawHeaders); 283 $headers = []; 284 foreach ($headerRows as $headerRow) { 285 $colonPosition = strpos($headerRow, ':'); 286 if ($colonPosition === false) { 287 $this->error("Invalid header row (no colon): $headerRow"); 288 } 289 $headers[strtolower(substr($headerRow, 0, $colonPosition))] = trim( 290 substr($headerRow, $colonPosition + 1) 291 ); 292 } 293 294 return ($this->headers = $headers); 295 } 296 297 /** 298 * @return bool 299 */ 300 private function isValid() 301 { 302 if ($this->valid === null) { 303 $this->processData(); 304 } 305 306 return $this->valid; 307 } 308 309 /** 310 * Process data and set validity and raw data 311 */ 312 private function processData() 313 { 314 $this->rawData = $this->data['out_response']; 315 $this->valid = ( 316 ! is_null($this->rawData) && 317 strpos($this->rawData, self::HEADER_SEPARATOR) 318 ); 319 if ($this->valid) { 320 list ($this->rawHeaders, $this->rawBody) = array_map( 321 'trim', 322 explode(self::HEADER_SEPARATOR, $this->rawData) 323 ); 324 } 325 } 326 327 /** 328 * Emit error message 329 * 330 * @param string $message 331 * 332 * @return bool 333 */ 334 private function error($message): bool 335 { 336 echo "ERROR: $message\n"; 337 338 return false; 339 } 340} 341