diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 79c4b44..abe1813 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -9,7 +9,7 @@ jobs: fail-fast: true matrix: os: [ubuntu-latest, windows-latest] - php: [8.3, 8.2, 8.1] + php: [8.4, 8.3, 8.2, 8.1] stability: [prefer-lowest, prefer-stable] name: P${{ matrix.php }} - ${{ matrix.stability }} - ${{ matrix.os }} @@ -40,4 +40,4 @@ jobs: uses: codecov/codecov-action@v5 with: file: ./coverage.xml - token: ${{ secrets.CODECOV_TOKEN }} \ No newline at end of file + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/composer.json b/composer.json index 1509517..7123e7e 100644 --- a/composer.json +++ b/composer.json @@ -48,7 +48,12 @@ "scripts": { "test": "vendor/bin/pest", "test-coverage": "vendor/bin/pest --coverage", - "format": "vendor/bin/pint" + "format": "vendor/bin/pint", + "analyse": "vendor/bin/phpstan analyse", + "check": [ + "@analyse", + "@test" + ] }, "config": { "sort-packages": true, diff --git a/examples/advanced-retry.php b/examples/advanced-retry.php index 22e94f5..ce752db 100644 --- a/examples/advanced-retry.php +++ b/examples/advanced-retry.php @@ -1,5 +1,7 @@ withBaseUri('https://jsonplaceholder.typicode.com') ->withRetries( maxRetries: 5, - strategy: new ExponentialBackoffStrategy, + strategy: new ExponentialBackoffStrategy(), condition: RetryCondition::default() - ->onStatusCodes([408, 429, 500, 502, 503, 504]) // Retry on specific status codes + ->when(function (\Throwable $exception, \Farzai\Transport\Retry\RetryContext $context) use ($retryableStatusCodes): bool { + // Retry on specific status codes from HTTP exceptions + if ($exception instanceof \Farzai\Transport\Exceptions\HttpException && $exception->hasResponse()) { + return in_array($exception->getResponse()->getStatusCode(), $retryableStatusCodes, true); + } + + return false; + }) ) ->build(); @@ -83,34 +94,36 @@ echo "4. Retry with Custom Condition Callback\n"; echo str_repeat('-', 50)."\n"; -$transport4 = TransportBuilder::make() - ->withBaseUri('https://jsonplaceholder.typicode.com') - ->withRetries( - maxRetries: 3, - strategy: new ExponentialBackoffStrategy, - condition: RetryCondition::fromCallback( - function (\Throwable $exception, \Farzai\Transport\Retry\RetryContext $context): bool { - echo " Retry attempt {$context->attempt}/{$context->maxAttempts}\n"; - echo " Exception: {$exception->getMessage()}\n"; +$customCondition = (new RetryCondition())->when( + function (\Throwable $exception, \Farzai\Transport\Retry\RetryContext $context): bool { + echo " Retry attempt {$context->attempt}/{$context->maxAttempts}\n"; + echo " Exception: {$exception->getMessage()}\n"; - // Retry only on network errors or 5xx responses - if ($exception instanceof \Farzai\Transport\Exceptions\ServerException) { - echo " Decision: RETRY (Server error)\n\n"; + // Retry only on network errors or 5xx responses + if ($exception instanceof \Farzai\Transport\Exceptions\ServerException) { + echo " Decision: RETRY (Server error)\n\n"; - return true; - } + return true; + } - if ($exception instanceof \Farzai\Transport\Exceptions\NetworkException) { - echo " Decision: RETRY (Network error)\n\n"; + if ($exception instanceof \Farzai\Transport\Exceptions\NetworkException) { + echo " Decision: RETRY (Network error)\n\n"; - return true; - } + return true; + } - echo " Decision: DO NOT RETRY\n\n"; + echo " Decision: DO NOT RETRY\n\n"; - return false; - } - ) + return false; + } +); + +$transport4 = TransportBuilder::make() + ->withBaseUri('https://jsonplaceholder.typicode.com') + ->withRetries( + maxRetries: 3, + strategy: new ExponentialBackoffStrategy(), + condition: $customCondition ) ->build(); diff --git a/examples/basic-usage.php b/examples/basic-usage.php index a2ea7d6..45e3ff2 100644 --- a/examples/basic-usage.php +++ b/examples/basic-usage.php @@ -1,5 +1,7 @@ withBaseUri('https://httpbin.org') @@ -84,7 +86,7 @@ echo str_repeat('-', 50)."\n"; try { - $cookieJar = new CookieJar; + $cookieJar = new CookieJar(); // Manually create and add cookies $sessionCookie = new Cookie( @@ -129,7 +131,7 @@ echo str_repeat('-', 50)."\n"; try { - $cookieJar = new CookieJar; + $cookieJar = new CookieJar(); // Add various cookies $cookieJar->setCookie(new Cookie('cookie1', 'value1', null, 'example.com', '/')); @@ -167,7 +169,7 @@ try { // Create jar and add cookies - $jar1 = new CookieJar; + $jar1 = new CookieJar(); $jar1->setCookie(new Cookie('persistent', 'data', time() + 86400, 'example.com')); $jar1->setCookie(new Cookie('preferences', 'dark_mode=true', time() + 2592000, 'example.com')); @@ -181,7 +183,7 @@ echo "Cookies saved to: {$cookieFile}\n"; // Later... Import cookies - $jar2 = new CookieJar; + $jar2 = new CookieJar(); $imported = json_decode(file_get_contents($cookieFile), true); $jar2->fromArray($imported); @@ -199,7 +201,7 @@ echo str_repeat('-', 50)."\n"; try { - $cookieJar = new CookieJar; + $cookieJar = new CookieJar(); // Add mix of cookies $cookieJar->setCookie(new Cookie('keep', 'value', time() + 3600)); @@ -224,7 +226,7 @@ echo str_repeat('-', 50)."\n"; try { - $cookieJar = new CookieJar; + $cookieJar = new CookieJar(); // Create transport for scraping $scraper = TransportBuilder::make() @@ -262,7 +264,7 @@ try { // Without session persistence (default) - $regularJar = new CookieJar; + $regularJar = new CookieJar(); $regularJar->setCookie(new Cookie('session', 'value')); // Session cookie $regularJar->setCookie(new Cookie('persistent', 'value', time() + 3600)); // Persistent diff --git a/examples/custom-client.php b/examples/custom-client.php index b1fd736..6b50e63 100644 --- a/examples/custom-client.php +++ b/examples/custom-client.php @@ -1,5 +1,7 @@ addField('user_id', '12345') diff --git a/examples/middleware-example.php b/examples/middleware-example.php index 45702bc..865dd7e 100644 --- a/examples/middleware-example.php +++ b/examples/middleware-example.php @@ -1,5 +1,7 @@ withBaseUri('https://jsonplaceholder.typicode.com') ->withoutDefaultMiddlewares() // Disable default logging - ->withMiddleware(new DetailedLoggingMiddleware) + ->withMiddleware(new DetailedLoggingMiddleware()) ->build(); try { @@ -105,7 +108,8 @@ class ApiKeyAuthMiddleware implements MiddlewareInterface public function __construct( private readonly string $apiKey, private readonly string $headerName = 'X-API-Key' - ) {} + ) { + } public function handle(RequestInterface $request, callable $next): ResponseInterface { @@ -138,7 +142,8 @@ class SimpleCacheMiddleware implements MiddlewareInterface public function __construct( private readonly int $ttlSeconds = 60 - ) {} + ) { + } public function handle(RequestInterface $request, callable $next): ResponseInterface { @@ -204,7 +209,8 @@ class RateLimitMiddleware implements MiddlewareInterface public function __construct( private readonly int $maxRequests = 10, private readonly int $perSeconds = 60 - ) {} + ) { + } public function handle(RequestInterface $request, callable $next): ResponseInterface { diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..dcdaeee --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,11 @@ +parameters: + level: 8 + paths: + - src + excludePaths: + - vendor + checkGenericClassInNonGenericObjectType: true + reportUnmatchedIgnoredErrors: false + ignoreErrors: + - + identifier: missingType.iterableValue diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..8d1a3ab --- /dev/null +++ b/pint.json @@ -0,0 +1,21 @@ +{ + "preset": "psr12", + "rules": { + "array_syntax": { + "syntax": "short" + }, + "binary_operator_spaces": { + "default": "single_space" + }, + "blank_line_after_opening_tag": true, + "declare_strict_types": true, + "no_unused_imports": true, + "ordered_imports": { + "sort_algorithm": "alpha" + }, + "single_quote": true, + "trailing_comma_in_multiline": { + "elements": ["arrays"] + } + } +} diff --git a/src/Contracts/ResponseInterface.php b/src/Contracts/ResponseInterface.php index f3fdc3d..a2fa037 100644 --- a/src/Contracts/ResponseInterface.php +++ b/src/Contracts/ResponseInterface.php @@ -57,7 +57,7 @@ public function toArray(): array; * * @throws \Psr\Http\Client\ClientExceptionInterface */ - public function throw(?callable $callback = null); + public function throw(?callable $callback = null): static; /** * Return the psr request. diff --git a/src/Cookie/Cookie.php b/src/Cookie/Cookie.php index ef506e9..ceb06fa 100644 --- a/src/Cookie/Cookie.php +++ b/src/Cookie/Cookie.php @@ -22,7 +22,7 @@ final class Cookie private readonly ?string $domain; - private readonly ?string $path; + private readonly string $path; private readonly bool $secure; @@ -41,6 +41,8 @@ final class Cookie * @param bool $secure Secure flag * @param bool $httpOnly HttpOnly flag * @param string|null $sameSite SameSite attribute (Strict, Lax, None, or null) + * + * @see Cookie::secure() For creating cookies with secure defaults (recommended) */ public function __construct( string $name, @@ -65,6 +67,37 @@ public function __construct( $this->sameSite = $sameSite; } + /** + * Create a cookie with secure defaults. + * + * Uses secure=true, httpOnly=true, sameSite='Lax' for security best practices. + * This is the recommended way to create cookies for security-sensitive applications. + * + * @param string $name Cookie name + * @param string $value Cookie value + * @param int|null $expiresAt Unix timestamp when cookie expires (null = session cookie) + * @param string|null $domain Cookie domain + * @param string $path Cookie path + */ + public static function secure( + string $name, + string $value, + ?int $expiresAt = null, + ?string $domain = null, + string $path = '/', + ): self { + return new self( + name: $name, + value: $value, + expiresAt: $expiresAt, + domain: $domain, + path: $path, + secure: true, + httpOnly: true, + sameSite: 'Lax', + ); + } + /** * Get the cookie name. */ diff --git a/src/Exceptions/HttpException.php b/src/Exceptions/HttpException.php index 0c0ae96..b9f6368 100644 --- a/src/Exceptions/HttpException.php +++ b/src/Exceptions/HttpException.php @@ -36,6 +36,20 @@ */ class HttpException extends RuntimeException implements RequestExceptionInterface { + /** + * Headers that should be redacted from context for security. + * + * @var array + */ + private const SENSITIVE_HEADERS = [ + 'authorization', + 'cookie', + 'set-cookie', + 'x-api-key', + 'x-auth-token', + 'x-csrf-token', + 'proxy-authorization', + ]; /** * Create a new HTTP exception. * @@ -98,6 +112,8 @@ public function getStatusCode(): ?int /** * Get a detailed error context for logging. * + * Sensitive headers (Authorization, Cookie, etc.) are redacted for security. + * * @return array Error context */ public function getContext(): array @@ -107,18 +123,40 @@ public function getContext(): array 'request' => [ 'method' => $this->request->getMethod(), 'uri' => (string) $this->request->getUri(), - 'headers' => $this->request->getHeaders(), + 'headers' => $this->sanitizeHeaders($this->request->getHeaders()), ], ]; - if ($this->hasResponse()) { + $response = $this->response; + if ($response !== null) { $context['response'] = [ - 'status_code' => $this->response->getStatusCode(), - 'reason_phrase' => $this->response->getReasonPhrase(), - 'headers' => $this->response->getHeaders(), + 'status_code' => $response->getStatusCode(), + 'reason_phrase' => $response->getReasonPhrase(), + 'headers' => $this->sanitizeHeaders($response->getHeaders()), ]; } return $context; } + + /** + * Sanitize headers by redacting sensitive values. + * + * @param array> $headers The headers to sanitize + * @return array> The sanitized headers + */ + private function sanitizeHeaders(array $headers): array + { + $sanitized = []; + + foreach ($headers as $name => $values) { + if (in_array(strtolower($name), self::SENSITIVE_HEADERS, true)) { + $sanitized[$name] = ['[REDACTED]']; + } else { + $sanitized[$name] = $values; + } + } + + return $sanitized; + } } diff --git a/src/Exceptions/JsonEncodeException.php b/src/Exceptions/JsonEncodeException.php index fbe8cd9..88114b3 100644 --- a/src/Exceptions/JsonEncodeException.php +++ b/src/Exceptions/JsonEncodeException.php @@ -50,9 +50,9 @@ public function __construct( * @param mixed $value The value that failed to encode * @param int $depth The nesting depth used */ - public static function fromJsonException(\JsonException $exception, mixed $value, int $depth = 512): static + public static function fromJsonException(\JsonException $exception, mixed $value, int $depth = 512): self { - return new static( + return new self( message: sprintf('Failed to encode JSON: %s', $exception->getMessage()), value: $value, jsonErrorCode: $exception->getCode(), diff --git a/src/Exceptions/JsonParseException.php b/src/Exceptions/JsonParseException.php index b6d37ee..bcbfaec 100644 --- a/src/Exceptions/JsonParseException.php +++ b/src/Exceptions/JsonParseException.php @@ -49,9 +49,9 @@ public function __construct( * @param string $jsonString The JSON string that failed to parse * @param int $depth The nesting depth used */ - public static function fromJsonException(\JsonException $exception, string $jsonString, int $depth = 512): static + public static function fromJsonException(\JsonException $exception, string $jsonString, int $depth = 512): self { - return new static( + return new self( message: sprintf('Failed to parse JSON: %s', $exception->getMessage()), jsonString: $jsonString, jsonErrorCode: $exception->getCode(), diff --git a/src/Exceptions/ResponseExceptionFactory.php b/src/Exceptions/ResponseExceptionFactory.php index eab9502..2cd1844 100644 --- a/src/Exceptions/ResponseExceptionFactory.php +++ b/src/Exceptions/ResponseExceptionFactory.php @@ -112,7 +112,7 @@ public static function getErrorMessage(ResponseInterface $response): string $statusCode = $response->statusCode(); // Try to extract error from JSON response - $jsonError = static::extractJsonError($response); + $jsonError = self::extractJsonError($response); if ($jsonError !== null) { return $jsonError; } diff --git a/src/Factory/ClientFactory.php b/src/Factory/ClientFactory.php index 94ae283..ff5ec60 100644 --- a/src/Factory/ClientFactory.php +++ b/src/Factory/ClientFactory.php @@ -62,20 +62,20 @@ final class ClientFactory */ public static function create(?LoggerInterface $logger = null): ClientInterface { - $logger = $logger ?? new NullLogger; + $logger = $logger ?? new NullLogger(); // Try Symfony HTTP Client first (modern, async support, HTTP/2) if (class_exists('Symfony\Component\HttpClient\Psr18Client')) { $logger->debug('ClientFactory: Using Symfony HTTP Client'); - return new \Symfony\Component\HttpClient\Psr18Client; + return new \Symfony\Component\HttpClient\Psr18Client(); } // Try Guzzle HTTP Client (popular, widely used) if (class_exists('GuzzleHttp\Client')) { $logger->debug('ClientFactory: Using Guzzle HTTP Client'); - return new \GuzzleHttp\Client; + return new \GuzzleHttp\Client(); } // Fallback to PSR-18 discovery (will find any installed PSR-18 client) @@ -158,7 +158,7 @@ public static function createSymfony(array $options = []): ClientInterface return new \Symfony\Component\HttpClient\Psr18Client($httpClient); } - return new \Symfony\Component\HttpClient\Psr18Client; + return new \Symfony\Component\HttpClient\Psr18Client(); } /** diff --git a/src/Factory/HttpFactory.php b/src/Factory/HttpFactory.php index 48d4e7a..5df55e2 100644 --- a/src/Factory/HttpFactory.php +++ b/src/Factory/HttpFactory.php @@ -56,7 +56,8 @@ public function __construct( private readonly ?ResponseFactoryInterface $responseFactory = null, private readonly ?UriFactoryInterface $uriFactory = null, private readonly ?StreamFactoryInterface $streamFactory = null - ) {} + ) { + } /** * Get singleton instance with auto-detected factories. @@ -69,7 +70,7 @@ public function __construct( public static function getInstance(): self { if (self::$instance === null) { - self::$instance = new self; + self::$instance = new self(); } return self::$instance; diff --git a/src/Middleware/CookieMiddleware.php b/src/Middleware/CookieMiddleware.php index 2361889..293de54 100644 --- a/src/Middleware/CookieMiddleware.php +++ b/src/Middleware/CookieMiddleware.php @@ -111,7 +111,7 @@ public function getCookieJar(): CookieJar */ public static function create(?CookieJar $cookieJar = null): self { - return new self($cookieJar ?? new CookieJar); + return new self($cookieJar ?? new CookieJar()); } /** diff --git a/src/Middleware/LoggingMiddleware.php b/src/Middleware/LoggingMiddleware.php index 2c2ec66..73c3409 100644 --- a/src/Middleware/LoggingMiddleware.php +++ b/src/Middleware/LoggingMiddleware.php @@ -11,9 +11,25 @@ class LoggingMiddleware implements MiddlewareInterface { + /** + * Headers that should be redacted from logs for security. + * + * @var array + */ + private const SENSITIVE_HEADERS = [ + 'authorization', + 'cookie', + 'set-cookie', + 'x-api-key', + 'x-auth-token', + 'x-csrf-token', + 'proxy-authorization', + ]; + public function __construct( private readonly LoggerInterface $logger - ) {} + ) { + } public function handle(RequestInterface $request, callable $next): ResponseInterface { @@ -23,7 +39,7 @@ public function handle(RequestInterface $request, callable $next): ResponseInter $this->logger->info(sprintf('[REQUEST] %s %s', $method, $uri), [ 'method' => $method, 'uri' => $uri, - 'headers' => $request->getHeaders(), + 'headers' => $this->sanitizeHeaders($request->getHeaders()), ]); try { @@ -47,4 +63,25 @@ public function handle(RequestInterface $request, callable $next): ResponseInter throw $exception; } } + + /** + * Sanitize headers by redacting sensitive values. + * + * @param array> $headers The headers to sanitize + * @return array> The sanitized headers + */ + private function sanitizeHeaders(array $headers): array + { + $sanitized = []; + + foreach ($headers as $name => $values) { + if (in_array(strtolower($name), self::SENSITIVE_HEADERS, true)) { + $sanitized[$name] = ['[REDACTED]']; + } else { + $sanitized[$name] = $values; + } + } + + return $sanitized; + } } diff --git a/src/Middleware/RetryMiddleware.php b/src/Middleware/RetryMiddleware.php index e6661c7..d2bbd8a 100644 --- a/src/Middleware/RetryMiddleware.php +++ b/src/Middleware/RetryMiddleware.php @@ -18,7 +18,8 @@ public function __construct( private readonly int $maxAttempts, private readonly RetryStrategyInterface $strategy, private readonly RetryCondition $condition - ) {} + ) { + } public function handle(RequestInterface $request, callable $next): ResponseInterface { diff --git a/src/Middleware/TimeoutMiddleware.php b/src/Middleware/TimeoutMiddleware.php index 9168daa..c383c16 100644 --- a/src/Middleware/TimeoutMiddleware.php +++ b/src/Middleware/TimeoutMiddleware.php @@ -11,7 +11,8 @@ class TimeoutMiddleware implements MiddlewareInterface { public function __construct( private readonly int $timeoutSeconds - ) {} + ) { + } public function handle(RequestInterface $request, callable $next): ResponseInterface { diff --git a/src/Multipart/Part.php b/src/Multipart/Part.php index 79b844b..3b6d592 100644 --- a/src/Multipart/Part.php +++ b/src/Multipart/Part.php @@ -25,7 +25,7 @@ final class Part * Create a new multipart part. * * @param string $name The field name - * @param StreamInterface|string|resource $contents The content stream or string + * @param StreamInterface|string $contents The content stream or string * @param string|null $filename Optional filename (for file uploads) * @param array $headers Optional custom headers */ diff --git a/src/RequestBuilder.php b/src/RequestBuilder.php index 100e055..d0dc6b5 100644 --- a/src/RequestBuilder.php +++ b/src/RequestBuilder.php @@ -69,9 +69,13 @@ public function uri(UriInterface|string $uri): self * Add a header. * * @param string|array $value + * + * @throws \InvalidArgumentException If header contains invalid characters (CR/LF) */ public function withHeader(string $name, string|array $value): self { + $this->validateHeaderValue($name, $value); + $clone = clone $this; $clone->headers[$name] = $value; @@ -82,9 +86,15 @@ public function withHeader(string $name, string|array $value): self * Add multiple headers. * * @param array> $headers + * + * @throws \InvalidArgumentException If any header contains invalid characters (CR/LF) */ public function withHeaders(array $headers): self { + foreach ($headers as $name => $value) { + $this->validateHeaderValue($name, $value); + } + $clone = clone $this; $clone->headers = array_merge($clone->headers, $headers); @@ -271,7 +281,7 @@ public function send(): ResponseInterface throw new \RuntimeException('No transport instance available. Use Transport::request() or provide transport in constructor.'); } - return $this->transport->sendRequest($this->build()); + return $this->transport->send($this->build()); } // Convenience methods for HTTP verbs @@ -281,7 +291,7 @@ public function send(): ResponseInterface */ public static function get(string|UriInterface $uri): self { - return (new self)->method('GET')->uri($uri); + return (new self())->method('GET')->uri($uri); } /** @@ -289,7 +299,7 @@ public static function get(string|UriInterface $uri): self */ public static function post(string|UriInterface $uri): self { - return (new self)->method('POST')->uri($uri); + return (new self())->method('POST')->uri($uri); } /** @@ -297,7 +307,7 @@ public static function post(string|UriInterface $uri): self */ public static function put(string|UriInterface $uri): self { - return (new self)->method('PUT')->uri($uri); + return (new self())->method('PUT')->uri($uri); } /** @@ -305,7 +315,7 @@ public static function put(string|UriInterface $uri): self */ public static function patch(string|UriInterface $uri): self { - return (new self)->method('PATCH')->uri($uri); + return (new self())->method('PATCH')->uri($uri); } /** @@ -313,7 +323,7 @@ public static function patch(string|UriInterface $uri): self */ public static function delete(string|UriInterface $uri): self { - return (new self)->method('DELETE')->uri($uri); + return (new self())->method('DELETE')->uri($uri); } /** @@ -321,7 +331,7 @@ public static function delete(string|UriInterface $uri): self */ public static function head(string|UriInterface $uri): self { - return (new self)->method('HEAD')->uri($uri); + return (new self())->method('HEAD')->uri($uri); } /** @@ -329,6 +339,35 @@ public static function head(string|UriInterface $uri): self */ public static function options(string|UriInterface $uri): self { - return (new self)->method('OPTIONS')->uri($uri); + return (new self())->method('OPTIONS')->uri($uri); + } + + /** + * Validate header name and value for CRLF injection attacks. + * + * @param string $name The header name + * @param string|array $value The header value(s) + * + * @throws \InvalidArgumentException If header contains CR or LF characters + */ + private function validateHeaderValue(string $name, string|array $value): void + { + // Check header name for CRLF + if (preg_match("/[\r\n]/", $name)) { + throw new \InvalidArgumentException( + "Header name '{$name}' contains invalid characters (CR/LF)" + ); + } + + // Check header values for CRLF + $values = is_array($value) ? $value : [$value]; + + foreach ($values as $v) { + if (is_string($v) && preg_match("/[\r\n]/", $v)) { + throw new \InvalidArgumentException( + "Header '{$name}' value contains invalid characters (CR/LF)" + ); + } + } } } diff --git a/src/Response.php b/src/Response.php index fe55d0a..ceecd6f 100644 --- a/src/Response.php +++ b/src/Response.php @@ -12,7 +12,7 @@ use Psr\Http\Message\ResponseInterface as PsrResponseInterface; use Psr\Http\Message\StreamInterface; -class Response implements ResponseInterface +final class Response implements ResponseInterface { protected mixed $jsonDecoded = null; @@ -152,10 +152,10 @@ public function toArray(): array * * @throws \Psr\Http\Client\ClientExceptionInterface */ - public function throw(?callable $callback = null) + public function throw(?callable $callback = null): static { $callback = $callback ?? function (ResponseInterface $response, ?\Exception $e) { - if (! $this->isSuccessful()) { + if (! $this->isSuccessful() && $e !== null) { throw $e; } diff --git a/src/ResponseBuilder.php b/src/ResponseBuilder.php index 8815e97..95dbf6e 100644 --- a/src/ResponseBuilder.php +++ b/src/ResponseBuilder.php @@ -7,7 +7,7 @@ use Farzai\Transport\Factory\HttpFactory; use Psr\Http\Message\ResponseInterface as PsrResponseInterface; -class ResponseBuilder +final class ResponseBuilder { protected int $statusCode = 200; @@ -16,10 +16,7 @@ class ResponseBuilder */ protected array $headers = []; - /** - * @var mixed - */ - protected $body; + protected mixed $body = null; protected string $version = '1.1'; @@ -58,7 +55,7 @@ public function statusCode(int $statusCode): self /** * Set the response headers. * - * @param array> $headers + * @param array> $headers */ public function withHeaders(array $headers): self { @@ -72,9 +69,9 @@ public function withHeaders(array $headers): self /** * Add a header to the response. * - * @param mixed $value + * @param string|array $value */ - public function withHeader(string $name, $value): self + public function withHeader(string $name, string|array $value): self { if (! isset($this->headers[$name])) { $this->headers[$name] = []; @@ -90,10 +87,8 @@ public function withHeader(string $name, $value): self /** * Set the response body. - * - * @param mixed $body */ - public function withBody($body): self + public function withBody(mixed $body): self { $this->body = $body; diff --git a/src/Retry/ExponentialBackoffStrategy.php b/src/Retry/ExponentialBackoffStrategy.php index 108308c..f711c3f 100644 --- a/src/Retry/ExponentialBackoffStrategy.php +++ b/src/Retry/ExponentialBackoffStrategy.php @@ -17,7 +17,8 @@ public function __construct( private readonly float $multiplier = 2.0, private readonly int $maxDelayMs = 30000, private readonly bool $useJitter = true - ) {} + ) { + } public function getDelay(RetryContext $context): int { diff --git a/src/Retry/FixedDelayStrategy.php b/src/Retry/FixedDelayStrategy.php index 4773cf0..9fdc6e3 100644 --- a/src/Retry/FixedDelayStrategy.php +++ b/src/Retry/FixedDelayStrategy.php @@ -11,7 +11,8 @@ class FixedDelayStrategy implements RetryStrategyInterface */ public function __construct( private readonly int $delayMs = 1000 - ) {} + ) { + } public function getDelay(RetryContext $context): int { diff --git a/src/Retry/RetryCondition.php b/src/Retry/RetryCondition.php index a5bcc35..266814f 100644 --- a/src/Retry/RetryCondition.php +++ b/src/Retry/RetryCondition.php @@ -18,7 +18,7 @@ class RetryCondition */ public static function default(): self { - $condition = new self; + $condition = new self(); $condition->onAnyException(); return $condition; diff --git a/src/Retry/RetryContext.php b/src/Retry/RetryContext.php index 6b8c3ec..71f0088 100644 --- a/src/Retry/RetryContext.php +++ b/src/Retry/RetryContext.php @@ -20,7 +20,8 @@ public function __construct( public readonly ?Throwable $lastException = null, public readonly array $delaysUsed = [], public readonly array $exceptions = [] - ) {} + ) { + } /** * Check if we have retries remaining. diff --git a/src/Serialization/JsonConfig.php b/src/Serialization/JsonConfig.php index fabd0ab..67d39a0 100644 --- a/src/Serialization/JsonConfig.php +++ b/src/Serialization/JsonConfig.php @@ -34,7 +34,7 @@ public function __construct( */ public static function default(): self { - return new self; + return new self(); } /** diff --git a/src/Serialization/JsonSerializer.php b/src/Serialization/JsonSerializer.php index 8c50372..3810c13 100644 --- a/src/Serialization/JsonSerializer.php +++ b/src/Serialization/JsonSerializer.php @@ -31,7 +31,7 @@ final class JsonSerializer implements SerializerInterface * @param JsonConfig $config The configuration for JSON operations */ public function __construct( - private readonly JsonConfig $config = new JsonConfig + private readonly JsonConfig $config = new JsonConfig() ) { // } @@ -49,11 +49,19 @@ public function __construct( public function encode(mixed $data): string { try { - return json_encode( + /** @var int<1, max> $depth */ + $depth = max(1, $this->config->maxDepth); + $result = json_encode( value: $data, flags: $this->config->encodeFlags, - depth: $this->config->maxDepth + depth: $depth ); + + if ($result === false) { + throw new \JsonException(json_last_error_msg(), json_last_error()); + } + + return $result; } catch (\JsonException $e) { throw JsonEncodeException::fromJsonException($e, $data, $this->config->maxDepth); } @@ -79,10 +87,12 @@ public function decode(string $data, ?string $key = null): mixed } try { + /** @var int<1, max> $depth */ + $depth = max(1, $this->config->maxDepth); $decoded = json_decode( json: $data, associative: $this->config->associative, - depth: $this->config->maxDepth, + depth: $depth, flags: $this->config->decodeFlags ); @@ -153,10 +163,56 @@ private function extractValue(mixed $data, string $key): mixed // If data is an object, convert to array for extraction if (is_object($data)) { - return Arr::get(json_decode(json_encode($data), true), $key); + return Arr::get($this->objectToArray($data), $key); } // For scalar values, only return if key is empty return $key === '' ? $data : null; } + + /** + * Recursively convert an object to an associative array. + * + * @param object $object The object to convert + * @return array The converted array + */ + private function objectToArray(object $object): array + { + $result = []; + + foreach (get_object_vars($object) as $key => $value) { + if (is_object($value)) { + $result[$key] = $this->objectToArray($value); + } elseif (is_array($value)) { + $result[$key] = $this->convertArrayValues($value); + } else { + $result[$key] = $value; + } + } + + return $result; + } + + /** + * Recursively convert array values, handling nested objects. + * + * @param array $array The array to convert + * @return array The converted array + */ + private function convertArrayValues(array $array): array + { + $result = []; + + foreach ($array as $key => $value) { + if (is_object($value)) { + $result[$key] = $this->objectToArray($value); + } elseif (is_array($value)) { + $result[$key] = $this->convertArrayValues($value); + } else { + $result[$key] = $value; + } + } + + return $result; + } } diff --git a/src/Serialization/SerializerFactory.php b/src/Serialization/SerializerFactory.php index 4a3f44e..edced92 100644 --- a/src/Serialization/SerializerFactory.php +++ b/src/Serialization/SerializerFactory.php @@ -111,7 +111,7 @@ public static function createFromContentType(string $contentType): SerializerInt if (isset(self::$contentTypeMap[$normalizedType])) { $serializerClass = self::$contentTypeMap[$normalizedType]; - return new $serializerClass; + return new $serializerClass(); } throw new \InvalidArgumentException( diff --git a/src/TransportBuilder.php b/src/TransportBuilder.php index f88f240..40ac771 100644 --- a/src/TransportBuilder.php +++ b/src/TransportBuilder.php @@ -53,7 +53,7 @@ final class TransportBuilder */ public static function make(): static { - return new self; + return new self(); } /** @@ -160,7 +160,7 @@ public function withoutDefaultMiddlewares(): self public function withCookieJar(?CookieJar $cookieJar = null): self { $clone = clone $this; - $clone->cookieJar = $cookieJar ?? new CookieJar; + $clone->cookieJar = $cookieJar ?? new CookieJar(); return $clone; } @@ -199,7 +199,7 @@ public function getLogger(): ?LoggerInterface */ public function build(): Transport { - $logger = $this->logger ?? new NullLogger; + $logger = $this->logger ?? new NullLogger(); // Auto-detect client if not explicitly set // This allows users to use any PSR-18 client without configuration @@ -212,7 +212,7 @@ public function build(): Transport headers: $this->headers, timeout: $this->timeout, maxRetries: $this->maxRetries, - retryStrategy: $this->retryStrategy ?? new ExponentialBackoffStrategy, + retryStrategy: $this->retryStrategy ?? new ExponentialBackoffStrategy(), retryCondition: $this->retryCondition ?? RetryCondition::default(), middlewares: $this->buildMiddlewares($logger) ); @@ -247,7 +247,7 @@ private function buildMiddlewares(LoggerInterface $logger): array if ($this->maxRetries > 0) { $middlewares[] = new RetryMiddleware( maxAttempts: $this->maxRetries, - strategy: $this->retryStrategy ?? new ExponentialBackoffStrategy, + strategy: $this->retryStrategy ?? new ExponentialBackoffStrategy(), condition: $this->retryCondition ?? RetryCondition::default() ); } diff --git a/src/TransportConfig.php b/src/TransportConfig.php index 88f1d58..6598b70 100644 --- a/src/TransportConfig.php +++ b/src/TransportConfig.php @@ -20,13 +20,13 @@ final class TransportConfig */ public function __construct( public readonly ClientInterface $client, - public readonly LoggerInterface $logger = new NullLogger, + public readonly LoggerInterface $logger = new NullLogger(), public readonly string $baseUri = '', public readonly array $headers = [], public readonly int $timeout = 30, public readonly int $maxRetries = 0, - public readonly RetryStrategyInterface $retryStrategy = new ExponentialBackoffStrategy, - public readonly RetryCondition $retryCondition = new RetryCondition, + public readonly RetryStrategyInterface $retryStrategy = new ExponentialBackoffStrategy(), + public readonly RetryCondition $retryCondition = new RetryCondition(), public readonly array $middlewares = [] ) { $this->validate(); diff --git a/tests/Cookie/CookieTest.php b/tests/Cookie/CookieTest.php index 143ce96..8fedcbd 100644 --- a/tests/Cookie/CookieTest.php +++ b/tests/Cookie/CookieTest.php @@ -184,11 +184,41 @@ expect($cookie->matchesDomain('example.com'))->toBeTrue(); expect($cookie->matchesDomain('other.com'))->toBeTrue(); }); + + it('creates secure cookie with Cookie::secure() factory method', function () { + $cookie = Cookie::secure('session', 'abc123'); + + expect($cookie->getName())->toBe('session') + ->and($cookie->getValue())->toBe('abc123') + ->and($cookie->isSecure())->toBeTrue() + ->and($cookie->isHttpOnly())->toBeTrue() + ->and($cookie->getSameSite())->toBe('Lax'); + }); + + it('creates secure cookie with all parameters via secure() factory', function () { + $expiresAt = time() + 3600; + $cookie = Cookie::secure( + 'auth_token', + 'xyz789', + $expiresAt, + 'example.com', + '/api' + ); + + expect($cookie->getName())->toBe('auth_token') + ->and($cookie->getValue())->toBe('xyz789') + ->and($cookie->getExpiresAt())->toBe($expiresAt) + ->and($cookie->getDomain())->toBe('example.com') + ->and($cookie->getPath())->toBe('/api') + ->and($cookie->isSecure())->toBeTrue() + ->and($cookie->isHttpOnly())->toBeTrue() + ->and($cookie->getSameSite())->toBe('Lax'); + }); }); describe('CookieJar', function () { it('stores and retrieves cookies', function () { - $jar = new CookieJar; + $jar = new CookieJar(); $cookie = new Cookie('session', 'abc123', null, 'example.com'); $jar->setCookie($cookie); @@ -199,7 +229,7 @@ }); it('replaces cookie with same name, domain, and path', function () { - $jar = new CookieJar; + $jar = new CookieJar(); $cookie1 = new Cookie('token', 'old', null, 'example.com'); $cookie2 = new Cookie('token', 'new', null, 'example.com'); @@ -212,7 +242,7 @@ }); it('stores multiple cookies with different attributes', function () { - $jar = new CookieJar; + $jar = new CookieJar(); $jar->setCookie(new Cookie('token', 'value1', null, 'example.com', '/')); $jar->setCookie(new Cookie('token', 'value2', null, 'example.com', '/api')); @@ -222,7 +252,7 @@ }); it('gets cookies for URL', function () { - $jar = new CookieJar; + $jar = new CookieJar(); $jar->setCookie(new Cookie('cookie1', 'value1', null, 'example.com', '/')); $jar->setCookie(new Cookie('cookie2', 'value2', null, 'example.com', '/api')); @@ -236,7 +266,7 @@ }); it('respects secure flag in URL matching', function () { - $jar = new CookieJar; + $jar = new CookieJar(); $secureCookie = new Cookie('secure', 'value', null, 'example.com', '/', true); $jar->setCookie($secureCookie); @@ -249,7 +279,7 @@ }); it('removes expired cookies automatically', function () { - $jar = new CookieJar; + $jar = new CookieJar(); $jar->setCookie(new Cookie('expired', 'value', time() - 3600)); $jar->setCookie(new Cookie('valid', 'value', time() + 3600)); @@ -258,7 +288,7 @@ }); it('removes specific cookie', function () { - $jar = new CookieJar; + $jar = new CookieJar(); $jar->setCookie(new Cookie('token', 'value', null, 'example.com')); $jar->removeCookie('token', 'example.com'); @@ -267,7 +297,7 @@ }); it('clears all cookies', function () { - $jar = new CookieJar; + $jar = new CookieJar(); $jar->setCookie(new Cookie('cookie1', 'value1')); $jar->setCookie(new Cookie('cookie2', 'value2')); @@ -279,7 +309,7 @@ }); it('adds cookies from Set-Cookie headers', function () { - $jar = new CookieJar; + $jar = new CookieJar(); $headers = [ 'session_id=abc123; Path=/; HttpOnly', 'token=xyz789; Domain=example.com; Secure', @@ -291,7 +321,7 @@ }); it('generates Cookie header for URL', function () { - $jar = new CookieJar; + $jar = new CookieJar(); $jar->setCookie(new Cookie('session', 'abc123', null, 'example.com')); $jar->setCookie(new Cookie('token', 'xyz789', null, 'example.com')); @@ -304,7 +334,7 @@ }); it('returns null when no cookies match URL', function () { - $jar = new CookieJar; + $jar = new CookieJar(); $jar->setCookie(new Cookie('test', 'value', null, 'example.com')); $header = $jar->getCookieHeaderForUrl('https://other.com/'); @@ -313,13 +343,13 @@ }); it('exports and imports cookies', function () { - $jar1 = new CookieJar; + $jar1 = new CookieJar(); $jar1->setCookie(new Cookie('cookie1', 'value1', null, 'example.com')); $jar1->setCookie(new Cookie('cookie2', 'value2', time() + 3600, 'example.com')); $data = $jar1->toArray(); - $jar2 = new CookieJar; + $jar2 = new CookieJar(); $jar2->fromArray($data); expect($jar2->count())->toBe(2); @@ -336,7 +366,7 @@ }); it('skips invalid cookies when adding from headers', function () { - $jar = new CookieJar; + $jar = new CookieJar(); $headers = [ 'valid=value; Path=/', '', // Empty header @@ -350,7 +380,7 @@ }); it('sorts cookies by path specificity', function () { - $jar = new CookieJar; + $jar = new CookieJar(); $jar->setCookie(new Cookie('root', 'value', null, 'example.com', '/')); $jar->setCookie(new Cookie('api', 'value', null, 'example.com', '/api')); @@ -365,7 +395,7 @@ }); it('handles invalid URL in getCookiesForUrl', function () { - $jar = new CookieJar; + $jar = new CookieJar(); $jar->setCookie(new Cookie('test', 'value', null, 'example.com')); // Test with an invalid URL that would cause parse_url to return false @@ -375,7 +405,7 @@ }); it('getAllCookies can include expired cookies', function () { - $jar = new CookieJar; + $jar = new CookieJar(); $jar->setCookie(new Cookie('valid', 'value', time() + 3600)); $jar->setCookie(new Cookie('expired', 'value', time() - 3600)); diff --git a/tests/Factory/HttpFactoryTest.php b/tests/Factory/HttpFactoryTest.php index 2daa89b..5de27ae 100644 --- a/tests/Factory/HttpFactoryTest.php +++ b/tests/Factory/HttpFactoryTest.php @@ -167,28 +167,28 @@ }); it('auto-detects request factory when not provided', function () { - $factory = new HttpFactory; + $factory = new HttpFactory(); $request = $factory->createRequest('GET', 'https://example.com'); expect($request)->toBeInstanceOf(RequestInterface::class); }); it('auto-detects response factory when not provided', function () { - $factory = new HttpFactory; + $factory = new HttpFactory(); $response = $factory->createResponse(200); expect($response)->toBeInstanceOf(ResponseInterface::class); }); it('auto-detects uri factory when not provided', function () { - $factory = new HttpFactory; + $factory = new HttpFactory(); $uri = $factory->createUri('https://example.com'); expect($uri)->toBeInstanceOf(UriInterface::class); }); it('auto-detects stream factory when not provided', function () { - $factory = new HttpFactory; + $factory = new HttpFactory(); $stream = $factory->createStream('test'); expect($stream)->toBeInstanceOf(StreamInterface::class); diff --git a/tests/HttpClient/MockHttpClient.php b/tests/HttpClient/MockHttpClient.php index 9b10f28..28d6be8 100644 --- a/tests/HttpClient/MockHttpClient.php +++ b/tests/HttpClient/MockHttpClient.php @@ -1,5 +1,7 @@ setCookie(new Cookie('session_id', 'abc123', null, 'example.com', '/')); $middleware = new CookieMiddleware($jar); @@ -24,7 +24,7 @@ }); it('extracts cookies from Set-Cookie response headers', function () { - $jar = new CookieJar; + $jar = new CookieJar(); $middleware = new CookieMiddleware($jar); $request = new Request('GET', 'https://example.com/login'); @@ -41,7 +41,7 @@ }); it('merges with existing Cookie header', function () { - $jar = new CookieJar; + $jar = new CookieJar(); $jar->setCookie(new Cookie('session_id', 'abc123', null, 'example.com', '/')); $middleware = new CookieMiddleware($jar); @@ -60,7 +60,7 @@ }); it('handles requests with no matching cookies', function () { - $jar = new CookieJar; + $jar = new CookieJar(); $jar->setCookie(new Cookie('session', 'value', null, 'other.com', '/')); $middleware = new CookieMiddleware($jar); @@ -74,7 +74,7 @@ }); it('handles responses with no Set-Cookie headers', function () { - $jar = new CookieJar; + $jar = new CookieJar(); $middleware = new CookieMiddleware($jar); $request = new Request('GET', 'https://example.com/api'); @@ -86,7 +86,7 @@ }); it('respects secure flag for HTTPS requests', function () { - $jar = new CookieJar; + $jar = new CookieJar(); $jar->setCookie(new Cookie('secure_token', 'secret', null, 'example.com', '/', true)); $middleware = new CookieMiddleware($jar); @@ -101,7 +101,7 @@ }); it('excludes secure cookies from HTTP requests', function () { - $jar = new CookieJar; + $jar = new CookieJar(); $jar->setCookie(new Cookie('secure_token', 'secret', null, 'example.com', '/', true)); $middleware = new CookieMiddleware($jar); @@ -116,7 +116,7 @@ }); it('handles multiple Set-Cookie headers', function () { - $jar = new CookieJar; + $jar = new CookieJar(); $middleware = new CookieMiddleware($jar); $request = new Request('GET', 'https://example.com/login'); @@ -138,7 +138,7 @@ }); it('returns the cookie jar instance', function () { - $jar = new CookieJar; + $jar = new CookieJar(); $middleware = new CookieMiddleware($jar); expect($middleware->getCookieJar())->toBe($jar); @@ -151,7 +151,7 @@ }); it('can create middleware with custom cookie jar', function () { - $jar = new CookieJar; + $jar = new CookieJar(); $jar->setCookie(new Cookie('existing', 'cookie', null, 'example.com', '/')); $middleware = CookieMiddleware::create($jar); @@ -178,7 +178,7 @@ }); it('handles complete request-response cycle with cookies', function () { - $jar = new CookieJar; + $jar = new CookieJar(); $middleware = new CookieMiddleware($jar); // First request: receive cookie from server @@ -199,7 +199,7 @@ }); it('handles cookie updates from server', function () { - $jar = new CookieJar; + $jar = new CookieJar(); $middleware = new CookieMiddleware($jar); // First request: receive initial cookie @@ -225,7 +225,7 @@ }); it('respects path restrictions', function () { - $jar = new CookieJar; + $jar = new CookieJar(); $jar->setCookie(new Cookie('admin_token', 'secret', null, 'example.com', '/admin')); $middleware = new CookieMiddleware($jar); @@ -248,7 +248,7 @@ }); it('respects domain restrictions', function () { - $jar = new CookieJar; + $jar = new CookieJar(); $jar->setCookie(new Cookie('site_token', 'value', null, 'site.com', '/')); $middleware = new CookieMiddleware($jar); diff --git a/tests/MiddlewareTest.php b/tests/MiddlewareTest.php index 4990730..13316a6 100644 --- a/tests/MiddlewareTest.php +++ b/tests/MiddlewareTest.php @@ -13,9 +13,10 @@ it('can execute middleware in correct order', function () { $order = []; - $middleware1 = new class($order) implements \Farzai\Transport\Middleware\MiddlewareInterface - { - public function __construct(private array &$order) {} + $middleware1 = new class ($order) implements \Farzai\Transport\Middleware\MiddlewareInterface { + public function __construct(private array &$order) + { + } public function handle(\Psr\Http\Message\RequestInterface $request, callable $next): \Psr\Http\Message\ResponseInterface { @@ -27,9 +28,10 @@ public function handle(\Psr\Http\Message\RequestInterface $request, callable $ne } }; - $middleware2 = new class($order) implements \Farzai\Transport\Middleware\MiddlewareInterface - { - public function __construct(private array &$order) {} + $middleware2 = new class ($order) implements \Farzai\Transport\Middleware\MiddlewareInterface { + public function __construct(private array &$order) + { + } public function handle(\Psr\Http\Message\RequestInterface $request, callable $next): \Psr\Http\Message\ResponseInterface { @@ -54,8 +56,7 @@ public function handle(\Psr\Http\Message\RequestInterface $request, callable $ne }); it('can modify request in middleware', function () { - $middleware = new class implements \Farzai\Transport\Middleware\MiddlewareInterface - { + $middleware = new class () implements \Farzai\Transport\Middleware\MiddlewareInterface { public function handle(\Psr\Http\Message\RequestInterface $request, callable $next): \Psr\Http\Message\ResponseInterface { $request = $request->withHeader('X-Modified', 'true'); @@ -75,8 +76,7 @@ public function handle(\Psr\Http\Message\RequestInterface $request, callable $ne }); it('can modify response in middleware', function () { - $middleware = new class implements \Farzai\Transport\Middleware\MiddlewareInterface - { + $middleware = new class () implements \Farzai\Transport\Middleware\MiddlewareInterface { public function handle(\Psr\Http\Message\RequestInterface $request, callable $next): \Psr\Http\Message\ResponseInterface { $response = $next($request); @@ -94,8 +94,7 @@ public function handle(\Psr\Http\Message\RequestInterface $request, callable $ne }); it('can push middleware to stack', function () { - $middleware1 = new class implements \Farzai\Transport\Middleware\MiddlewareInterface - { + $middleware1 = new class () implements \Farzai\Transport\Middleware\MiddlewareInterface { public function handle(\Psr\Http\Message\RequestInterface $request, callable $next): \Psr\Http\Message\ResponseInterface { $request = $request->withHeader('X-First', 'true'); @@ -104,8 +103,7 @@ public function handle(\Psr\Http\Message\RequestInterface $request, callable $ne } }; - $middleware2 = new class implements \Farzai\Transport\Middleware\MiddlewareInterface - { + $middleware2 = new class () implements \Farzai\Transport\Middleware\MiddlewareInterface { public function handle(\Psr\Http\Message\RequestInterface $request, callable $next): \Psr\Http\Message\ResponseInterface { $request = $request->withHeader('X-Second', 'true'); @@ -114,7 +112,7 @@ public function handle(\Psr\Http\Message\RequestInterface $request, callable $ne } }; - $stack = new MiddlewareStack; + $stack = new MiddlewareStack(); $result = $stack->push($middleware1); $stack->push($middleware2); @@ -131,7 +129,7 @@ public function handle(\Psr\Http\Message\RequestInterface $request, callable $ne }); it('can create empty middleware stack', function () { - $stack = new MiddlewareStack; + $stack = new MiddlewareStack(); $request = new Request('GET', 'https://example.com'); $response = $stack->handle($request, fn () => new Response(200)); @@ -175,6 +173,37 @@ public function handle(\Psr\Http\Message\RequestInterface $request, callable $ne expect($e->getMessage())->toBe('Network error'); } }); + + it('sanitizes sensitive headers in logs', function () { + $capturedContext = null; + + $logger = Mockery::mock(LoggerInterface::class); + $logger->shouldReceive('info') + ->once() + ->with(Mockery::pattern('/\[REQUEST\]/'), Mockery::capture($capturedContext)); + $logger->shouldReceive('info') + ->once() + ->with(Mockery::pattern('/\[RESPONSE\]/'), Mockery::type('array')); + + $middleware = new LoggingMiddleware($logger); + + $request = new Request('GET', 'https://example.com', [ + 'Authorization' => 'Bearer secret-token-12345', + 'X-Api-Key' => 'my-api-key', + 'Cookie' => 'session=abc123', + 'X-Custom' => 'visible-value', + ]); + + $middleware->handle($request, fn () => new Response(200)); + + // Verify sensitive headers are redacted + expect($capturedContext['headers']['Authorization'])->toBe(['[REDACTED]']); + expect($capturedContext['headers']['X-Api-Key'])->toBe(['[REDACTED]']); + expect($capturedContext['headers']['Cookie'])->toBe(['[REDACTED]']); + + // Verify non-sensitive headers are preserved + expect($capturedContext['headers']['X-Custom'])->toBe(['visible-value']); + }); }); describe('TimeoutMiddleware', function () { diff --git a/tests/Multipart/MultipartTest.php b/tests/Multipart/MultipartTest.php index c56e9f0..cc631df 100644 --- a/tests/Multipart/MultipartTest.php +++ b/tests/Multipart/MultipartTest.php @@ -134,8 +134,8 @@ describe('MultipartStreamBuilder', function () { it('generates unique boundary', function () { - $builder1 = new MultipartStreamBuilder; - $builder2 = new MultipartStreamBuilder; + $builder1 = new MultipartStreamBuilder(); + $builder2 = new MultipartStreamBuilder(); expect($builder1->getBoundary())->not->toBe($builder2->getBoundary()); }); diff --git a/tests/Pest.php b/tests/Pest.php index 3d9949b..c44bfe6 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,3 +1,5 @@ RequestBuilder::get('/users') + ->withHeader('X-Custom', "value\rEvil")) + ->toThrow(\InvalidArgumentException::class, 'contains invalid characters'); + }); + + it('throws exception for header value with LF character', function () { + expect(fn () => RequestBuilder::get('/users') + ->withHeader('X-Custom', "value\nEvil")) + ->toThrow(\InvalidArgumentException::class, 'contains invalid characters'); + }); + + it('throws exception for header value with CRLF sequence', function () { + expect(fn () => RequestBuilder::get('/users') + ->withHeader('X-Custom', "value\r\nEvil: header")) + ->toThrow(\InvalidArgumentException::class, 'contains invalid characters'); + }); + + it('throws exception for header name with CRLF', function () { + expect(fn () => RequestBuilder::get('/users') + ->withHeader("X-Custom\r\n", 'value')) + ->toThrow(\InvalidArgumentException::class, 'contains invalid characters'); + }); + + it('throws exception for header array value with CRLF', function () { + expect(fn () => RequestBuilder::get('/users') + ->withHeader('X-Custom', ['valid', "invalid\r\nheader"])) + ->toThrow(\InvalidArgumentException::class, 'contains invalid characters'); + }); + + it('throws exception for withHeaders with CRLF', function () { + expect(fn () => RequestBuilder::get('/users') + ->withHeaders(['X-Custom' => "value\r\nEvil: header"])) + ->toThrow(\InvalidArgumentException::class, 'contains invalid characters'); + }); + + it('allows valid header values', function () { + $request = RequestBuilder::get('/users') + ->withHeader('X-Custom', 'valid-value') + ->withHeader('Accept', 'application/json') + ->build(); + + expect($request->getHeaderLine('X-Custom'))->toBe('valid-value') + ->and($request->getHeaderLine('Accept'))->toBe('application/json'); + }); +}); + afterEach(function () { Mockery::close(); }); diff --git a/tests/RetryTest.php b/tests/RetryTest.php index d1b89b7..f052274 100644 --- a/tests/RetryTest.php +++ b/tests/RetryTest.php @@ -91,19 +91,19 @@ $request = new Request('GET', 'https://example.com'); $context = new RetryContext($request, 0, 3); - expect($condition->shouldRetry(new \RuntimeException, $context))->toBeTrue() - ->and($condition->shouldRetry(new \Exception, $context))->toBeTrue(); + expect($condition->shouldRetry(new \RuntimeException(), $context))->toBeTrue() + ->and($condition->shouldRetry(new \Exception(), $context))->toBeTrue(); }); it('can retry on specific exception types', function () { - $condition = new RetryCondition; + $condition = new RetryCondition(); $condition->onExceptions([\RuntimeException::class]); $request = new Request('GET', 'https://example.com'); $context = new RetryContext($request, 0, 3); - expect($condition->shouldRetry(new \RuntimeException, $context))->toBeTrue() - ->and($condition->shouldRetry(new \LogicException, $context))->toBeFalse(); + expect($condition->shouldRetry(new \RuntimeException(), $context))->toBeTrue() + ->and($condition->shouldRetry(new \LogicException(), $context))->toBeFalse(); }); it('does not retry when no retries left', function () { @@ -112,11 +112,11 @@ $request = new Request('GET', 'https://example.com'); $context = new RetryContext($request, 3, 3); // At max attempts - expect($condition->shouldRetry(new \RuntimeException, $context))->toBeFalse(); + expect($condition->shouldRetry(new \RuntimeException(), $context))->toBeFalse(); }); it('can use custom condition callback', function () { - $condition = new RetryCondition; + $condition = new RetryCondition(); $condition->when(function ($exception, $context) { return $exception->getMessage() === 'retryable'; }); @@ -129,7 +129,7 @@ }); it('does not retry when no conditions are set', function () { - $condition = new RetryCondition; // No conditions added + $condition = new RetryCondition(); // No conditions added $request = new Request('GET', 'https://example.com'); $context = new RetryContext($request, 0, 3); @@ -223,7 +223,7 @@ it('does not retry when condition not met', function () { $attempts = 0; $strategy = new FixedDelayStrategy(0); - $condition = new RetryCondition; + $condition = new RetryCondition(); $condition->onExceptions([\LogicException::class]); // Only retry LogicException $middleware = new RetryMiddleware(3, $strategy, $condition); diff --git a/tests/Serialization/JsonSerializerTest.php b/tests/Serialization/JsonSerializerTest.php index df2e193..f026a5c 100644 --- a/tests/Serialization/JsonSerializerTest.php +++ b/tests/Serialization/JsonSerializerTest.php @@ -9,7 +9,7 @@ describe('JsonSerializer', function () { it('encodes simple data to JSON', function () { - $serializer = new JsonSerializer; + $serializer = new JsonSerializer(); $data = ['name' => 'John', 'age' => 30]; $result = $serializer->encode($data); @@ -19,7 +19,7 @@ }); it('decodes JSON string to array', function () { - $serializer = new JsonSerializer; + $serializer = new JsonSerializer(); $json = '{"name":"John","age":30}'; $result = $serializer->decode($json); @@ -28,7 +28,7 @@ }); it('decodes nested JSON using dot notation', function () { - $serializer = new JsonSerializer; + $serializer = new JsonSerializer(); $json = '{"user":{"name":"John","address":{"city":"NYC"}}}'; expect($serializer->decode($json, 'user.name'))->toBe('John') @@ -36,25 +36,25 @@ }); it('returns null for empty string', function () { - $serializer = new JsonSerializer; + $serializer = new JsonSerializer(); expect($serializer->decode(''))->toBeNull(); }); it('throws JsonParseException on invalid JSON', function () { - $serializer = new JsonSerializer; + $serializer = new JsonSerializer(); $serializer->decode('{"invalid": json}'); })->throws(JsonParseException::class); it('decodeOrNull returns null on invalid JSON', function () { - $serializer = new JsonSerializer; + $serializer = new JsonSerializer(); expect($serializer->decodeOrNull('{"invalid": json}'))->toBeNull(); }); it('handles large integers with BIGINT_AS_STRING', function () { - $serializer = new JsonSerializer; + $serializer = new JsonSerializer(); // Use a number larger than PHP_INT_MAX to ensure it's converted to string $json = '{"bigNumber": 99999999999999999999}'; @@ -66,7 +66,7 @@ }); it('uses unescaped slashes by default', function () { - $serializer = new JsonSerializer; + $serializer = new JsonSerializer(); $data = ['url' => 'https://example.com/path']; $result = $serializer->encode($data); @@ -76,7 +76,7 @@ }); it('uses unescaped unicode by default', function () { - $serializer = new JsonSerializer; + $serializer = new JsonSerializer(); $data = ['text' => 'Hello 世界']; $result = $serializer->encode($data); @@ -106,7 +106,7 @@ }); it('returns correct content type', function () { - $serializer = new JsonSerializer; + $serializer = new JsonSerializer(); expect($serializer->getContentType())->toBe('application/json'); }); @@ -119,7 +119,7 @@ }); it('handles array conversion to associative array by default', function () { - $serializer = new JsonSerializer; + $serializer = new JsonSerializer(); $json = '["a","b","c"]'; $result = $serializer->decode($json); @@ -140,7 +140,7 @@ }); it('provides detailed error information in exceptions', function () { - $serializer = new JsonSerializer; + $serializer = new JsonSerializer(); $invalidJson = '{"invalid": }'; try { @@ -156,7 +156,7 @@ }); it('handles encoding errors gracefully', function () { - $serializer = new JsonSerializer; + $serializer = new JsonSerializer(); // Create a resource that cannot be JSON encoded $resource = fopen('php://memory', 'r'); @@ -174,7 +174,7 @@ }); it('handles null values correctly', function () { - $serializer = new JsonSerializer; + $serializer = new JsonSerializer(); $data = ['value' => null]; $encoded = $serializer->encode($data); @@ -184,7 +184,7 @@ }); it('handles empty arrays correctly', function () { - $serializer = new JsonSerializer; + $serializer = new JsonSerializer(); $data = []; $encoded = $serializer->encode($data); @@ -194,7 +194,7 @@ }); it('extracts nested array values with dot notation', function () { - $serializer = new JsonSerializer; + $serializer = new JsonSerializer(); $json = '{"items":[{"id":1,"name":"First"},{"id":2,"name":"Second"}]}'; expect($serializer->decode($json, 'items.0.name'))->toBe('First') @@ -212,7 +212,7 @@ }); it('returns scalar value when key is empty string', function () { - $serializer = new JsonSerializer; + $serializer = new JsonSerializer(); $json = '"just a string"'; $result = $serializer->decode($json, ''); @@ -221,7 +221,7 @@ }); it('returns null for scalar value with non-empty key', function () { - $serializer = new JsonSerializer; + $serializer = new JsonSerializer(); $json = '"just a string"'; $result = $serializer->decode($json, 'some.key'); @@ -230,7 +230,7 @@ }); it('handles integer scalar with key extraction', function () { - $serializer = new JsonSerializer; + $serializer = new JsonSerializer(); $json = '42'; expect($serializer->decode($json, ''))->toBe(42) @@ -238,7 +238,7 @@ }); it('handles boolean scalar with key extraction', function () { - $serializer = new JsonSerializer; + $serializer = new JsonSerializer(); $json = 'true'; expect($serializer->decode($json, ''))->toBeTrue() diff --git a/tests/TestCase.php b/tests/TestCase.php index e9e925e..f702d18 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -1,3 +1,5 @@ new TransportConfig($this->client, middlewares: [$invalidMiddleware])) ->toThrow(InvalidArgumentException::class); @@ -135,7 +135,7 @@ it('withRetries creates new instance with updated retry settings', function () { $config = new TransportConfig($this->client, maxRetries: 0); $newStrategy = new FixedDelayStrategy(2000); - $newCondition = new RetryCondition; + $newCondition = new RetryCondition(); $newConfig = $config->withRetries(5, $newStrategy, $newCondition); @@ -148,7 +148,7 @@ it('withRetries can update max retries only', function () { $originalStrategy = new FixedDelayStrategy(1000); - $originalCondition = new RetryCondition; + $originalCondition = new RetryCondition(); $config = new TransportConfig( $this->client, maxRetries: 3, diff --git a/tests/TransportTest.php b/tests/TransportTest.php index 6b04a2c..ab0ccb9 100644 --- a/tests/TransportTest.php +++ b/tests/TransportTest.php @@ -22,8 +22,8 @@ }); it('can build transport with custom client and logger', function () { - $client = new GuzzleClient; - $logger = new NullLogger; + $client = new GuzzleClient(); + $logger = new NullLogger(); $transport = TransportBuilder::make() ->setClient($client) @@ -79,14 +79,14 @@ }); it('can get configured client', function () { - $client = new GuzzleClient; + $client = new GuzzleClient(); $builder = TransportBuilder::make()->setClient($client); expect($builder->getClient())->toBe($client); }); it('can get configured logger', function () { - $logger = new NullLogger; + $logger = new NullLogger(); $builder = TransportBuilder::make()->setLogger($logger); expect($builder->getLogger())->toBe($logger); @@ -128,7 +128,7 @@ }); it('can enable cookie jar', function () { - $cookieJar = new \Farzai\Transport\Cookie\CookieJar; + $cookieJar = new \Farzai\Transport\Cookie\CookieJar(); $builder = TransportBuilder::make()->withCookieJar($cookieJar); $transport = $builder->build();