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
3 changes: 1 addition & 2 deletions src/Builders/CurlOptionsBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,7 @@ public function build(ClientOptions $options): array
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $options->timeout,
CURLOPT_CONNECTTIMEOUT => $options->connectTimeout,
CURLOPT_FOLLOWLOCATION => $options->followRedirects,
CURLOPT_MAXREDIRS => $options->maxRedirects,
CURLOPT_FOLLOWLOCATION => false, // Handled in User-land PHP
CURLOPT_SSL_VERIFYPEER => $options->verifySSL,
CURLOPT_SSL_VERIFYHOST => $options->verifySSL ? 2 : 0,
CURLOPT_USERAGENT => $options->userAgent,
Expand Down
2 changes: 1 addition & 1 deletion src/Handlers/Curl/SSEHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ private function createSSEConnection(
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);

if (! $headersProcessed && $httpCode > 0 && $trimmedHeader === '') {
if ($httpCode >= 200 && $httpCode < 300) {
if (($httpCode >= 200 && $httpCode < 300) || ($httpCode >= 300 && $httpCode < 400)) {
$parsedHeaders = $this->parseRawHeaders($rawHeaders);

$stream = new Stream();
Expand Down
2 changes: 2 additions & 0 deletions src/Handlers/InterceptorHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@

/**
* Handles the unified interceptor pipeline.
*
* @internal
*/
class InterceptorHandler
{
Expand Down
156 changes: 156 additions & 0 deletions src/Handlers/RedirectHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
<?php

declare(strict_types=1);

namespace Hibla\HttpClient\Handlers;

use Hibla\HttpClient\Exceptions\RequestException;
use Hibla\HttpClient\Interfaces\RequestInterface;
use Hibla\HttpClient\Interfaces\ResponseInterface;
use Hibla\HttpClient\Interfaces\SSEResponseInterface;
use Hibla\HttpClient\Interfaces\StreamingResponseInterface;
use Hibla\HttpClient\Utils\RedirectUriResolver;
use Hibla\HttpClient\Validators\UriValidator;
use Hibla\Promise\Interfaces\PromiseInterface;

use function Hibla\async;
use function Hibla\await;

/**
* Handles HTTP redirects recursively using a non-blocking fiber loop.
*
* This handler wraps the execution pipeline and inspects the resolved responses.
* If a 3xx redirect is detected, it builds a new request, strips sensitive headers
* (if crossing domains), and feeds the request back through the interceptor pipeline.
*
* @internal
*/
final readonly class RedirectHandler
{
/**
* @param array<callable(RequestInterface, callable): mixed> $interceptors
*/
public function __construct(
private InterceptorHandler $interceptorHandler,
private array $interceptors,
private bool $followRedirects,
private int $maxRedirects
) {
}

/**
* Dispatches the request and automatically follows redirects up to the configured limit.
*
* @template TResult
*
* @param RequestInterface $request The initial request to dispatch.
* @param callable(RequestInterface): PromiseInterface<TResult> $executor The transport execution closure.
* @param bool $requireResponse Whether the pipeline must strictly return a ResponseInterface.
* @return PromiseInterface<TResult>
*/
public function dispatch(
RequestInterface $request,
callable $executor,
bool $requireResponse
): PromiseInterface {
/** @var PromiseInterface<TResult>|null $currentPromise */
$currentPromise = null;

/** @var PromiseInterface<TResult> $outerPromise */
$outerPromise = async(function () use ($request, $executor, $requireResponse, &$currentPromise) {
$redirectCount = 0;
$currentRequest = $request;

while (true) {
$currentPromise = $this->interceptorHandler->process(
request: $currentRequest,
interceptors: $this->interceptors,
executor: $executor,
requireResponse: $requireResponse
);

/** @var TResult $response */
$response = await($currentPromise);
$currentPromise = null;

$statusCode = 0;
/** @var string|null $location */
$location = null;

if ($response instanceof ResponseInterface) {
$statusCode = $response->getStatusCode();
$location = $response->getHeaderLine('Location');
} elseif (\is_array($response) && isset($response['status'], $response['headers'])) {
/** @var int $statusCode */
$statusCode = $response['status'];
/** @var mixed $headers */
$headers = $response['headers'];

if (\is_iterable($headers)) {
foreach ($headers as $name => $values) {
if (\is_string($name) && \strtolower($name) === 'location') {
$locVal = \is_array($values) ? ($values[0] ?? '') : $values;
$location = \is_scalar($locVal) ? (string) $locVal : '';

break;
}
}
}
} else {
return $response;
}

// Determine if the response should follow the redirect
if (! $this->followRedirects || $statusCode < 300 || $statusCode >= 400 || $location === null || $location === '') {
return $response;
}

UriValidator::assertNoControlCharacters($location);

if ($redirectCount >= $this->maxRedirects) {
throw new RequestException(
"Will not follow more than {$this->maxRedirects} redirects",
0,
null,
(string) $currentRequest->getUri()
);
}

if ($response instanceof SSEResponseInterface || $response instanceof StreamingResponseInterface) {
$response->close();
} elseif ($response instanceof ResponseInterface) {
$response->getBody()->close();
}

$newUri = RedirectUriResolver::resolve($currentRequest->getUri(), $location);

UriValidator::assertAllowedScheme($newUri);
$isCrossDomain = UriValidator::isCrossDomain($currentRequest->getUri(), $newUri);

$currentRequest = clone $currentRequest;
$currentRequest = $currentRequest->withUri($newUri);

// RFC 7231 Redirect method downgrade handling (e.g. POST to GET)
if ($statusCode === 303 || ($statusCode <= 302 && \in_array(\strtoupper($currentRequest->getMethod()), ['POST', 'PUT', 'DELETE'], true))) {
$currentRequest = $currentRequest->withMethod('GET')->body('');
$currentRequest = $currentRequest->withoutHeader('Content-Type')->withoutHeader('Content-Length');
}

// Security: Strip credentials on cross-origin redirects
if ($isCrossDomain) {
$currentRequest = $currentRequest->withoutHeader('Authorization')->withoutHeader('Cookie');
}

$redirectCount++;
}
});

$outerPromise->onCancel(function () use (&$currentPromise): void {
if ($currentPromise instanceof PromiseInterface && ! $currentPromise->isSettled()) {
$currentPromise->cancelChain();
}
});

return $outerPromise;
}
}
Loading
Loading