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 * Expect that one of the processes in json status process list has a field with value that 96 * matches the supplied pattern. 97 * 98 * @param string $fieldName 99 * @param string $pattern 100 * 101 * @return Response 102 */ 103 public function expectJsonBodyPatternForStatusProcessField(string $fieldName, string $pattern) 104 { 105 $rawData = $this->getBody('application/json'); 106 $data = json_decode($rawData, true); 107 if (empty($data['processes']) || !is_array($data['processes'])) { 108 $this->error( 109 "The body data is not a valid status json containing processes field '$rawData'" 110 ); 111 } 112 foreach ($data['processes'] as $process) { 113 if (preg_match('|' . $pattern . '|', $process[$fieldName]) !== false) { 114 return $this; 115 } 116 } 117 118 $this->error( 119 "No field $fieldName matched pattern $pattern for any process in status data '$rawData'" 120 ); 121 122 return $this; 123 } 124 125 /** 126 * @return Response 127 */ 128 public function expectEmptyBody() 129 { 130 return $this->expectBody(''); 131 } 132 133 /** 134 * Expect header in the response. 135 * 136 * @param string $name Header name. 137 * @param string $value Header value. 138 * 139 * @return Response 140 */ 141 public function expectHeader($name, $value): Response 142 { 143 $this->checkHeader($name, $value); 144 145 return $this; 146 } 147 148 /** 149 * Expect error in the response. 150 * 151 * @param string|null $errorMessage Expected error message. 152 * 153 * @return Response 154 */ 155 public function expectError($errorMessage): Response 156 { 157 $errorData = $this->getErrorData(); 158 if ($errorData !== $errorMessage) { 159 $expectedErrorMessage = $errorMessage !== null 160 ? "The expected error message '$errorMessage' is not equal to returned error '$errorData'" 161 : "No error message expected but received '$errorData'"; 162 $this->error($expectedErrorMessage); 163 } 164 165 return $this; 166 } 167 168 /** 169 * Expect response status. 170 * 171 * @param string|null $status Expected status. 172 * 173 * @return Response 174 */ 175 public function expectStatus(string|null $status): Response { 176 $headers = $this->getHeaders(); 177 if (is_null($status) && !isset($headers['status'])) { 178 return $this; 179 } 180 if (!is_null($status) && !isset($headers['status'])) { 181 $this->error('Status is expected but not supplied'); 182 } elseif ($status !== $headers['status']) { 183 $statusMessage = $status === null ? "expected not to be set": "expected to be $status"; 184 $this->error("Status is $statusMessage but the actual value is {$headers['status']}"); 185 } 186 return $this; 187 } 188 189 /** 190 * Expect response status not to be set. 191 * 192 * @return Response 193 */ 194 public function expectNoStatus(): Response { 195 return $this->expectStatus(null); 196 } 197 198 /** 199 * Expect no error in the response. 200 * 201 * @return Response 202 */ 203 public function expectNoError(): Response 204 { 205 return $this->expectError(null); 206 } 207 208 /** 209 * Get response body. 210 * 211 * @param string $contentType Expect body to have specified content type. 212 * 213 * @return string|null 214 */ 215 public function getBody(string $contentType = 'text/html'): ?string 216 { 217 if ($this->checkIfValid() && $this->checkDefaultHeaders($contentType)) { 218 return $this->rawBody; 219 } 220 221 return null; 222 } 223 224 /** 225 * Print raw body. 226 * 227 * @param string $contentType Expect body to have specified content type. 228 */ 229 public function dumpBody(string $contentType = 'text/html') 230 { 231 var_dump($this->getBody($contentType)); 232 } 233 234 /** 235 * Print raw body. 236 * 237 * @param string $contentType Expect body to have specified content type. 238 */ 239 public function printBody(string $contentType = 'text/html') 240 { 241 echo $this->getBody($contentType) . "\n"; 242 } 243 244 /** 245 * Debug response output 246 */ 247 public function debugOutput() 248 { 249 echo ">>> Response\n"; 250 echo "----------------- OUT -----------------\n"; 251 echo $this->data['out_response'] . "\n"; 252 echo "----------------- ERR -----------------\n"; 253 echo $this->data['err_response'] . "\n"; 254 echo "---------------------------------------\n\n"; 255 } 256 257 /** 258 * @return string|null 259 */ 260 public function getErrorData(): ?string 261 { 262 return $this->data['err_response']; 263 } 264 265 /** 266 * Check if the response is valid and if not emit error message 267 * 268 * @return bool 269 */ 270 private function checkIfValid(): bool 271 { 272 if ($this->isValid()) { 273 return true; 274 } 275 276 if ( ! $this->expectInvalid) { 277 $this->error("The response is invalid: $this->rawData"); 278 } 279 280 return false; 281 } 282 283 /** 284 * Check default headers that should be present. 285 * 286 * @param string $contentType 287 * 288 * @return bool 289 */ 290 private function checkDefaultHeaders($contentType): bool 291 { 292 // check default headers 293 return ( 294 ( ! ini_get('expose_php') || $this->checkHeader('X-Powered-By', '|^PHP/8|', true)) && 295 $this->checkHeader('Content-type', '|^' . $contentType . '(;\s?charset=\w+)?|', true) 296 ); 297 } 298 299 /** 300 * Check a specified header. 301 * 302 * @param string $name Header name. 303 * @param string $value Header value. 304 * @param bool $useRegex Whether value is regular expression. 305 * 306 * @return bool 307 */ 308 private function checkHeader(string $name, string $value, $useRegex = false): bool 309 { 310 $lcName = strtolower($name); 311 $headers = $this->getHeaders(); 312 if ( ! isset($headers[$lcName])) { 313 return $this->error("The header $name is not present"); 314 } 315 $header = $headers[$lcName]; 316 317 if ( ! $useRegex) { 318 if ($header === $value) { 319 return true; 320 } 321 322 return $this->error("The header $name value '$header' is not the same as '$value'"); 323 } 324 325 if ( ! preg_match($value, $header)) { 326 return $this->error("The header $name value '$header' does not match RegExp '$value'"); 327 } 328 329 return true; 330 } 331 332 /** 333 * Get all headers. 334 * 335 * @return array|null 336 */ 337 private function getHeaders(): ?array 338 { 339 if ( ! $this->isValid()) { 340 return null; 341 } 342 343 if (is_array($this->headers)) { 344 return $this->headers; 345 } 346 347 $headerRows = explode("\r\n", $this->rawHeaders); 348 $headers = []; 349 foreach ($headerRows as $headerRow) { 350 $colonPosition = strpos($headerRow, ':'); 351 if ($colonPosition === false) { 352 $this->error("Invalid header row (no colon): $headerRow"); 353 } 354 $headers[strtolower(substr($headerRow, 0, $colonPosition))] = trim( 355 substr($headerRow, $colonPosition + 1) 356 ); 357 } 358 359 return ($this->headers = $headers); 360 } 361 362 /** 363 * @return bool 364 */ 365 private function isValid() 366 { 367 if ($this->valid === null) { 368 $this->processData(); 369 } 370 371 return $this->valid; 372 } 373 374 /** 375 * Process data and set validity and raw data 376 */ 377 private function processData() 378 { 379 $this->rawData = $this->data['out_response']; 380 $this->valid = ( 381 ! is_null($this->rawData) && 382 strpos($this->rawData, self::HEADER_SEPARATOR) 383 ); 384 if ($this->valid) { 385 list ($this->rawHeaders, $this->rawBody) = array_map( 386 'trim', 387 explode(self::HEADER_SEPARATOR, $this->rawData) 388 ); 389 } 390 } 391 392 /** 393 * Emit error message 394 * 395 * @param string $message 396 * 397 * @return bool 398 */ 399 private function error($message): bool 400 { 401 echo "ERROR: $message\n"; 402 403 return false; 404 } 405} 406