diff --git a/src/Httpful/Response.php b/src/Httpful/Response.php index 7197f40..5263a79 100644 --- a/src/Httpful/Response.php +++ b/src/Httpful/Response.php @@ -595,6 +595,153 @@ public function hasErrors(): bool return $this->code >= 400; } + /** + * @return bool Did we receive a 1xx Informational response? + */ + public function isInformational(): bool + { + return $this->code >= 100 && $this->code < 200; + } + + /** + * @return bool Did we receive a 2xx Successful response? + */ + public function isSuccess(): bool + { + return $this->code >= 200 && $this->code < 300; + } + + /** + * @return bool Did we receive a 3xx Redirection response? + */ + public function isRedirect(): bool + { + return $this->code >= 300 && $this->code < 400; + } + + /** + * @return bool Did we receive a 4xx Client Error response? + */ + public function isClientError(): bool + { + return $this->code >= 400 && $this->code < 500; + } + + /** + * @return bool Did we receive a 5xx Server Error response? + */ + public function isServerError(): bool + { + return $this->code >= 500 && $this->code < 600; + } + + /** + * Returns a human-readable hint / explanation for the current HTTP status code. + * + * Useful when displaying or logging error information without having to + * hard-code status-code ranges in calling code. + * + * @return string + */ + public function getErrorMessage(): string + { + if ($this->isSuccess()) { + return 'Request was successful (' . $this->code . ' ' . $this->reason . ').'; + } + + if ($this->isInformational()) { + return 'Informational response (' . $this->code . ' ' . $this->reason . '): the server acknowledged the request.'; + } + + if ($this->isRedirect()) { + return 'Redirect response (' . $this->code . ' ' . $this->reason . '): the resource has moved. Check the Location header.'; + } + + if ($this->isClientError()) { + $hints = [ + 400 => 'Bad Request: the server could not understand the request due to invalid syntax.', + 401 => 'Unauthorized: authentication is required and has failed or not been provided.', + 403 => 'Forbidden: you do not have permission to access this resource.', + 404 => 'Not Found: the requested resource could not be found on the server.', + 405 => 'Method Not Allowed: the HTTP method used is not supported for this endpoint.', + 408 => 'Request Timeout: the server timed out waiting for the request.', + 409 => 'Conflict: the request conflicts with the current state of the resource.', + 410 => 'Gone: the resource has been permanently removed.', + 422 => 'Unprocessable Entity: the request was well-formed but contains semantic errors.', + 429 => 'Too Many Requests: you have exceeded the rate limit. Try again later.', + ]; + + $hint = $hints[$this->code] ?? 'Client Error: the request could not be fulfilled due to a client-side problem. Check your request parameters, headers and authentication.'; + + return $hint . ' (' . $this->code . ' ' . $this->reason . ')'; + } + + if ($this->isServerError()) { + $hints = [ + 500 => 'Internal Server Error: the server encountered an unexpected error. This is a server-side problem.', + 501 => 'Not Implemented: the server does not support the functionality required to fulfil the request.', + 502 => 'Bad Gateway: the server received an invalid response from an upstream server.', + 503 => 'Service Unavailable: the server is temporarily unable to handle the request (overloaded or under maintenance).', + 504 => 'Gateway Timeout: the upstream server did not respond in time.', + ]; + + $hint = $hints[$this->code] ?? 'Server Error: the server failed to fulfil the request. This is a server-side problem and is not caused by your request.'; + + return $hint . ' (' . $this->code . ' ' . $this->reason . ')'; + } + + return 'Unknown response status (' . $this->code . ' ' . $this->reason . ').'; + } + + /** + * Returns a human-readable debug summary that includes the original + * request (method, full URL with query params, request headers, request + * body) as well as the response (status, hint, response headers, response + * body). Useful for logging, debugging, or building descriptive exception + * messages. + * + * @return string + */ + public function debugInfo(): string + { + $lines = []; + + // ---- Request section (available when the response was produced by a Request) ---- + if ($this->request !== null) { + $lines[] = '--- Request ---'; + $lines[] = $this->request->getMethod() . ' ' . (string) $this->request->getUri(); + + foreach ($this->request->getHeaders() as $name => $values) { + $lines[] = $name . ': ' . \implode(', ', (array) $values); + } + + $requestBody = (string) $this->request->getBody(); + if ($requestBody !== '') { + $lines[] = ''; + $lines[] = $requestBody; + } + + $lines[] = ''; + } + + // ---- Response section ---- + $lines[] = '=== Response Debug Info ==='; + $lines[] = 'Status : ' . $this->code . ' ' . $this->reason; + $lines[] = 'Hint : ' . $this->getErrorMessage(); + $lines[] = ''; + $lines[] = '--- Response Headers ---'; + + foreach ($this->headers->toArray() as $name => $values) { + $lines[] = $name . ': ' . \implode(', ', (array) $values); + } + + $lines[] = ''; + $lines[] = '--- Response Body ---'; + $lines[] = (string) $this->body; + + return \implode("\n", $lines); + } + /** * @return bool */ diff --git a/tests/Httpful/ResponseTest.php b/tests/Httpful/ResponseTest.php index 1ccb578..c216f0f 100644 --- a/tests/Httpful/ResponseTest.php +++ b/tests/Httpful/ResponseTest.php @@ -262,4 +262,173 @@ public function testHeaderValuesAreTrimmed($r) static::assertSame('Foo', $r->getHeaderLine('OWS')); static::assertSame(['Foo'], $r->getHeader('OWS')); } + + // ----------------------------------------------------------------------- + // Status-range helpers + // ----------------------------------------------------------------------- + + public function testIsInformational() + { + $r = (new Response())->withStatus(100); + static::assertTrue($r->isInformational()); + static::assertFalse($r->isSuccess()); + static::assertFalse($r->isRedirect()); + static::assertFalse($r->isClientError()); + static::assertFalse($r->isServerError()); + static::assertFalse($r->hasErrors()); + } + + public function testIsSuccess() + { + foreach ([200, 201, 204] as $code) { + $r = (new Response())->withStatus($code); + static::assertTrue($r->isSuccess(), "Expected isSuccess() for {$code}"); + static::assertFalse($r->isInformational()); + static::assertFalse($r->isRedirect()); + static::assertFalse($r->isClientError()); + static::assertFalse($r->isServerError()); + static::assertFalse($r->hasErrors()); + } + } + + public function testIsRedirect() + { + foreach ([301, 302, 307] as $code) { + $r = (new Response())->withStatus($code); + static::assertTrue($r->isRedirect(), "Expected isRedirect() for {$code}"); + static::assertFalse($r->isSuccess()); + static::assertFalse($r->isClientError()); + static::assertFalse($r->isServerError()); + static::assertFalse($r->hasErrors()); + } + } + + public function testIsClientError() + { + foreach ([400, 401, 403, 404, 422, 429] as $code) { + $r = (new Response())->withStatus($code); + static::assertTrue($r->isClientError(), "Expected isClientError() for {$code}"); + static::assertTrue($r->hasErrors(), "Expected hasErrors() for {$code}"); + static::assertFalse($r->isServerError()); + static::assertFalse($r->isSuccess()); + } + } + + public function testIsServerError() + { + foreach ([500, 502, 503, 504] as $code) { + $r = (new Response())->withStatus($code); + static::assertTrue($r->isServerError(), "Expected isServerError() for {$code}"); + static::assertTrue($r->hasErrors(), "Expected hasErrors() for {$code}"); + static::assertFalse($r->isClientError()); + static::assertFalse($r->isSuccess()); + } + } + + // ----------------------------------------------------------------------- + // getErrorMessage() + // ----------------------------------------------------------------------- + + public function testGetErrorMessageForSuccess() + { + $r = (new Response())->withStatus(200); + static::assertStringContainsString('successful', \strtolower($r->getErrorMessage())); + static::assertStringContainsString('200', $r->getErrorMessage()); + } + + public function testGetErrorMessageForClientError() + { + $r = (new Response())->withStatus(404); + $msg = $r->getErrorMessage(); + static::assertStringContainsString('404', $msg); + static::assertStringContainsString('Not Found', $msg); + } + + public function testGetErrorMessageForServerError() + { + $r = (new Response())->withStatus(500); + $msg = $r->getErrorMessage(); + static::assertStringContainsString('500', $msg); + static::assertStringContainsString('Internal Server Error', $msg); + static::assertStringContainsString('server', \strtolower($msg)); + } + + public function testGetErrorMessageForRedirect() + { + $r = (new Response())->withStatus(301); + $msg = $r->getErrorMessage(); + static::assertStringContainsString('301', $msg); + static::assertStringContainsString('Redirect', $msg); + } + + public function testGetErrorMessageFor429() + { + $r = (new Response())->withStatus(429); + $msg = $r->getErrorMessage(); + static::assertStringContainsString('429', $msg); + static::assertStringContainsString('rate limit', \strtolower($msg)); + } + + public function testGetErrorMessageFor502() + { + $r = (new Response())->withStatus(502); + $msg = $r->getErrorMessage(); + static::assertStringContainsString('502', $msg); + static::assertStringContainsString('Bad Gateway', $msg); + } + + // ----------------------------------------------------------------------- + // debugInfo() + // ----------------------------------------------------------------------- + + public function testDebugInfoContainsStatusAndBody() + { + $r = new Response('hello world', "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\n"); + $debug = $r->debugInfo(); + static::assertStringContainsString('200', $debug); + static::assertStringContainsString('Content-Type', $debug); + static::assertStringContainsString('hello world', $debug); + static::assertStringContainsString('Response Headers', $debug); + static::assertStringContainsString('Response Body', $debug); + } + + public function testDebugInfoContainsHint() + { + $r = (new Response())->withStatus(503); + $debug = $r->debugInfo(); + static::assertStringContainsString('503', $debug); + static::assertStringContainsString('Hint', $debug); + } + + public function testDebugInfoWithRequestShowsMethodAndUrl() + { + $request = \Httpful\Request::get('https://example.com/api/users?page=2'); + $r = new Response('{"items":[]}', "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n", $request); + $debug = $r->debugInfo(); + + // Request section must be present + static::assertStringContainsString('--- Request ---', $debug); + static::assertStringContainsString('GET', $debug); + static::assertStringContainsString('https://example.com/api/users', $debug); + static::assertStringContainsString('page=2', $debug); + } + + public function testDebugInfoWithRequestShowsRequestHeaders() + { + $request = \Httpful\Request::get('https://example.com/ping') + ->withHeader('Accept', 'application/json'); + $r = new Response('ok', "HTTP/1.1 200 OK\r\n\r\n", $request); + $debug = $r->debugInfo(); + + static::assertStringContainsString('Accept', $debug); + static::assertStringContainsString('application/json', $debug); + } + + public function testDebugInfoWithoutRequestHasNoRequestSection() + { + $r = new Response('ok', "HTTP/1.1 200 OK\r\n\r\n"); + $debug = $r->debugInfo(); + + static::assertStringNotContainsString('--- Request ---', $debug); + } }