Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions src/Httpful/Response.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Comment on lines +714 to +716
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The debugInfo() method currently leaks sensitive credentials by including raw header values for Authorization, Cookie, and Proxy-Authorization. Although the pull request description shows these being masked (e.g., Authorization: ******), the implementation does not actually perform redaction. For security reasons, sensitive headers should be redacted in debug output to prevent accidental exposure in logs.

            foreach ($this->request->getHeaders() as $name => $values) {
                $headerValue = \implode(', ', (array) $values);
                if (\in_array(\strtolower($name), ['authorization', 'cookie', 'proxy-authorization'], true)) {
                    $headerValue = '******';
                }
                $lines[] = $name . ': ' . $headerValue;
            }


$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);
}
Comment on lines +734 to +736
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

Response headers such as Set-Cookie can contain sensitive session information and should be redacted in the debug output to prevent leakage in logs.

        foreach ($this->headers->toArray() as $name => $values) {
            $headerValue = \implode(', ', (array) $values);
            if (\strtolower($name) === 'set-cookie') {
                $headerValue = '******';
            }
            $lines[] = $name . ': ' . $headerValue;
        }


$lines[] = '';
$lines[] = '--- Response Body ---';
$lines[] = (string) $this->body;

return \implode("\n", $lines);
}

/**
* @return bool
*/
Expand Down
169 changes: 169 additions & 0 deletions tests/Httpful/ResponseTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Loading