diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 81db123..18a3d13 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,5 +1,6 @@ + */ + private array $directives = []; + + /** + * Create a new CacheControl instance. + * + * @param array $directives The parsed directives + */ + public function __construct(array $directives = []) + { + $this->directives = $directives; + } + + /** + * Parse a Cache-Control header string. + */ + public static function parse(string $cacheControl): self + { + $directives = []; + + foreach (explode(',', $cacheControl) as $directive) { + $directive = trim($directive); + if ($directive === '') { + continue; + } + + $parts = explode('=', $directive, 2); + $name = strtolower(trim($parts[0])); + $value = isset($parts[1]) ? trim($parts[1], '"') : true; + + // Convert numeric values + if (is_string($value) && is_numeric($value)) { + $value = (int) $value; + } + + $directives[$name] = $value; + } + + return new self($directives); + } + + /** + * Parse Cache-Control from a response. + */ + public static function fromResponse(ResponseInterface $response): self + { + return self::parse($response->getHeaderLine('Cache-Control')); + } + + /** + * Build a Cache-Control header string. + * + * @param array $directives The directives to include + */ + public static function build(array $directives): string + { + $parts = []; + + foreach ($directives as $name => $value) { + if ($value === true) { + $parts[] = $name; + } elseif ($value !== false && $value !== null) { + $parts[] = "{$name}={$value}"; + } + } + + return implode(', ', $parts); + } + + /** + * Determine if the response should be cached. + */ + public function shouldCache(ResponseInterface $response, bool $isSharedCache = false): bool + { + // Don't cache if no-store is set + if ($this->hasNoStore()) { + return false; + } + + // Don't cache private responses in shared cache + if ($isSharedCache && $this->isPrivate()) { + return false; + } + + // Check response status code - using RFC 7234 recommended cacheable status codes + // This list matches the default cache_status_codes in ManagesCache trait + $status = $response->getStatusCode(); + $cacheableStatuses = [200, 203, 204, 206, 300, 301, 404, 410]; + + if (! in_array($status, $cacheableStatuses, true)) { + return false; + } + + return true; + } + + /** + * Check if the response requires validation before being served. + */ + public function mustRevalidate(): bool + { + return $this->has('must-revalidate') || $this->has('proxy-revalidate'); + } + + /** + * Check if the response should not be cached. + */ + public function hasNoCache(): bool + { + return $this->has('no-cache'); + } + + /** + * Check if the response should not be stored at all. + */ + public function hasNoStore(): bool + { + return $this->has('no-store'); + } + + /** + * Check if the response is private. + */ + public function isPrivate(): bool + { + return $this->has('private'); + } + + /** + * Check if the response is public. + */ + public function isPublic(): bool + { + return $this->has('public'); + } + + /** + * Get the max-age directive value. + */ + public function getMaxAge(): ?int + { + return $this->getInt('max-age'); + } + + /** + * Get the s-maxage directive value (for shared caches). + */ + public function getSharedMaxAge(): ?int + { + return $this->getInt('s-maxage'); + } + + /** + * Get the stale-while-revalidate directive value. + */ + public function getStaleWhileRevalidate(): ?int + { + return $this->getInt('stale-while-revalidate'); + } + + /** + * Get the stale-if-error directive value. + */ + public function getStaleIfError(): ?int + { + return $this->getInt('stale-if-error'); + } + + /** + * Calculate the TTL for the response. + * + * @return int|null The TTL in seconds, or null if not cacheable + */ + public function getTtl(ResponseInterface $response, bool $isSharedCache = false): ?int + { + // For shared caches, s-maxage takes precedence + if ($isSharedCache) { + $sMaxAge = $this->getSharedMaxAge(); + if ($sMaxAge !== null) { + return $sMaxAge; + } + } + + // Check max-age directive + $maxAge = $this->getMaxAge(); + if ($maxAge !== null) { + return $maxAge; + } + + // Fall back to Expires header + $expires = $response->getHeaderLine('Expires'); + if ($expires !== '') { + $expiresTime = strtotime($expires); + if ($expiresTime !== false) { + return max(0, $expiresTime - time()); + } + } + + return null; + } + + /** + * Check if a directive exists. + */ + public function has(string $directive): bool + { + return isset($this->directives[$directive]); + } + + /** + * Get a directive value. + */ + public function get(string $directive): mixed + { + return $this->directives[$directive] ?? null; + } + + /** + * Get an integer directive value. + */ + public function getInt(string $directive): ?int + { + $value = $this->get($directive); + if ($value === null) { + return null; + } + + return is_int($value) ? $value : (int) $value; + } + + /** + * Get all directives. + * + * @return array + */ + public function getDirectives(): array + { + return $this->directives; + } +} diff --git a/src/Fetch/Cache/CacheInterface.php b/src/Fetch/Cache/CacheInterface.php new file mode 100644 index 0000000..3f917ec --- /dev/null +++ b/src/Fetch/Cache/CacheInterface.php @@ -0,0 +1,57 @@ + + */ + private const DEFAULT_VARY_HEADERS = ['Accept', 'Accept-Encoding', 'Accept-Language']; + + /** + * The prefix for all cache keys. + */ + private string $prefix; + + /** + * Headers to use for cache key variation. + * + * @var array + */ + private array $varyHeaders; + + /** + * Create a new cache key generator. + * + * @param array $varyHeaders Headers to use for cache key variation + */ + public function __construct(string $prefix = 'fetch:', array $varyHeaders = []) + { + $this->prefix = $prefix; + $this->varyHeaders = $varyHeaders ?: self::DEFAULT_VARY_HEADERS; + } + + /** + * Generate a cache key for a request. + * + * @param array $options Request options + */ + public function generate(string $method, string $uri, array $options = []): string + { + $components = [ + 'method' => strtoupper($method), + 'uri' => $this->normalizeUri($uri), + 'headers' => $this->extractVaryHeaders($options), + ]; + + // Include body hash for non-GET/HEAD requests if body is present + if (! in_array(strtoupper($method), ['GET', 'HEAD'], true)) { + $bodyHash = $this->hashBody($options); + if ($bodyHash !== null) { + $components['body_hash'] = $bodyHash; + } + } + + // Include query parameters in the hash + if (isset($options['query']) && is_array($options['query'])) { + $components['query'] = $options['query']; + } + + return $this->prefix.hash('sha256', serialize($components)); + } + + /** + * Generate a cache key with a custom key provided by the user. + */ + public function generateCustom(string $customKey): string + { + return $this->prefix.$customKey; + } + + /** + * Get the vary headers. + * + * @return array + */ + public function getVaryHeaders(): array + { + return $this->varyHeaders; + } + + /** + * Set the vary headers. + * + * @param array $varyHeaders + */ + public function setVaryHeaders(array $varyHeaders): void + { + $this->varyHeaders = $varyHeaders; + } + + /** + * Normalize a URI for consistent cache key generation. + */ + private function normalizeUri(string $uri): string + { + $parsed = parse_url($uri); + if ($parsed === false) { + return $uri; + } + + // Build normalized URI + $normalized = ''; + + if (isset($parsed['scheme'])) { + $normalized .= strtolower($parsed['scheme']).'://'; + } + + if (isset($parsed['host'])) { + $normalized .= strtolower($parsed['host']); + } + + if (isset($parsed['port'])) { + // Only include non-default ports + $defaultPorts = ['http' => 80, 'https' => 443]; + $scheme = $parsed['scheme'] ?? 'http'; + if (! isset($defaultPorts[$scheme]) || $parsed['port'] !== $defaultPorts[$scheme]) { + $normalized .= ':'.$parsed['port']; + } + } + + $normalized .= $parsed['path'] ?? '/'; + + // Sort and normalize query parameters + if (isset($parsed['query'])) { + $normalized .= '?'.$this->normalizeQuery($parsed['query']); + } + + return $normalized; + } + + /** + * Normalize query parameters for consistent cache key generation. + */ + private function normalizeQuery(string $query): string + { + // Parse query string into array of [key, value] pairs, preserving duplicates and order + $pairs = []; + foreach (explode('&', $query) as $part) { + if ($part === '') { + continue; + } + $kv = explode('=', $part, 2); + $key = urldecode($kv[0]); + $value = isset($kv[1]) ? urldecode($kv[1]) : ''; + $pairs[] = [$key, $value]; + } + // Sort pairs by key, then by value, to normalize + usort($pairs, function ($a, $b) { + if ($a[0] === $b[0]) { + return strcmp($a[1], $b[1]); + } + + return strcmp($a[0], $b[0]); + }); + // Rebuild query string + $normalized = []; + foreach ($pairs as [$key, $value]) { + $normalized[] = rawurlencode($key).'='.rawurlencode($value); + } + + return implode('&', $normalized); + } + + /** + * Extract vary headers from request options. + * + * @param array $options + * @return array + */ + private function extractVaryHeaders(array $options): array + { + $varyHeaders = []; + $headers = $options['headers'] ?? []; + + foreach ($this->varyHeaders as $header) { + foreach ($headers as $name => $value) { + if (strcasecmp($name, $header) === 0) { + $varyHeaders[strtolower($header)] = is_array($value) ? implode(', ', $value) : (string) $value; + break; + } + } + } + + return $varyHeaders; + } + + /** + * Hash the request body for cache key generation. + * + * @param array $options + */ + private function hashBody(array $options): ?string + { + if (isset($options['json'])) { + return hash('sha256', json_encode($options['json']) ?: ''); + } + + if (isset($options['body'])) { + $body = $options['body']; + if (is_string($body)) { + return hash('sha256', $body); + } + + return hash('sha256', serialize($body)); + } + + if (isset($options['form_params'])) { + return hash('sha256', http_build_query($options['form_params'])); + } + + return null; + } +} diff --git a/src/Fetch/Cache/CachedResponse.php b/src/Fetch/Cache/CachedResponse.php new file mode 100644 index 0000000..553a317 --- /dev/null +++ b/src/Fetch/Cache/CachedResponse.php @@ -0,0 +1,268 @@ +> $headers Response headers + * @param string $body Response body + * @param int $createdAt Timestamp when the response was cached + * @param int|null $expiresAt Timestamp when the response expires (null = never expires) + * @param string|null $etag ETag value from the response + * @param string|null $lastModified Last-Modified header value + * @param array $metadata Additional metadata + */ + public function __construct( + private readonly int $statusCode, + private readonly array $headers, + private readonly string $body, + private readonly int $createdAt, + private readonly ?int $expiresAt = null, + private readonly ?string $etag = null, + private readonly ?string $lastModified = null, + private readonly array $metadata = [] + ) {} + + /** + * Create a cached response from a PSR-7 response. + */ + public static function fromResponse(ResponseInterface $response, ?int $ttl = null): self + { + $now = time(); + $expiresAt = $ttl !== null ? $now + $ttl : null; + + // Extract ETag + $etag = $response->hasHeader('ETag') + ? $response->getHeaderLine('ETag') + : null; + + // Extract Last-Modified + $lastModified = $response->hasHeader('Last-Modified') + ? $response->getHeaderLine('Last-Modified') + : null; + + return new self( + statusCode: $response->getStatusCode(), + headers: $response->getHeaders(), + body: (string) $response->getBody(), + createdAt: $now, + expiresAt: $expiresAt, + etag: $etag, + lastModified: $lastModified + ); + } + + /** + * Create a cached response from a serialized array. + * + * @param array $data The serialized data + */ + public static function fromArray(array $data): ?self + { + if (! isset($data['status_code'], $data['headers'], $data['body'], $data['created_at'])) { + return null; + } + + return new self( + statusCode: (int) $data['status_code'], + headers: (array) $data['headers'], + body: (string) $data['body'], + createdAt: (int) $data['created_at'], + expiresAt: isset($data['expires_at']) ? (int) $data['expires_at'] : null, + etag: $data['etag'] ?? null, + lastModified: $data['last_modified'] ?? null, + metadata: $data['metadata'] ?? [] + ); + } + + /** + * Get the HTTP status code. + */ + public function getStatusCode(): int + { + return $this->statusCode; + } + + /** + * Get all headers. + * + * @return array> + */ + public function getHeaders(): array + { + return $this->headers; + } + + /** + * Get a specific header. + * + * @return array + */ + public function getHeader(string $name): array + { + // Headers are case-insensitive + foreach ($this->headers as $headerName => $values) { + if (strcasecmp($headerName, $name) === 0) { + return $values; + } + } + + return []; + } + + /** + * Get a header line (comma-separated values). + */ + public function getHeaderLine(string $name): string + { + return implode(', ', $this->getHeader($name)); + } + + /** + * Check if a header exists. + */ + public function hasHeader(string $name): bool + { + foreach (array_keys($this->headers) as $headerName) { + if (strcasecmp($headerName, $name) === 0) { + return true; + } + } + + return false; + } + + /** + * Get the response body. + */ + public function getBody(): string + { + return $this->body; + } + + /** + * Get the timestamp when the response was cached. + */ + public function getCreatedAt(): int + { + return $this->createdAt; + } + + /** + * Get the expiration timestamp. + */ + public function getExpiresAt(): ?int + { + return $this->expiresAt; + } + + /** + * Get the ETag value. + */ + public function getETag(): ?string + { + return $this->etag; + } + + /** + * Get the Last-Modified value. + */ + public function getLastModified(): ?string + { + return $this->lastModified; + } + + /** + * Get additional metadata. + * + * @return array + */ + public function getMetadata(): array + { + return $this->metadata; + } + + /** + * Check if the cached response has expired. + */ + public function isExpired(): bool + { + if ($this->expiresAt === null) { + return false; + } + + return time() > $this->expiresAt; + } + + /** + * Check if the cached response is fresh. + */ + public function isFresh(): bool + { + return ! $this->isExpired(); + } + + /** + * Get the age of the cached response in seconds. + */ + public function getAge(): int + { + return time() - $this->createdAt; + } + + /** + * Get the remaining TTL in seconds. + * + * @return int|null The remaining TTL, or null if no expiration is set + */ + public function getRemainingTtl(): ?int + { + if ($this->expiresAt === null) { + return null; + } + + return max(0, $this->expiresAt - time()); + } + + /** + * Check if the response can be used for stale-while-revalidate. + */ + public function isUsableAsStale(int $maxStale): bool + { + if ($this->expiresAt === null) { + return true; + } + + return time() <= ($this->expiresAt + $maxStale); + } + + /** + * Serialize the cached response for storage. + * + * @return array + */ + public function toArray(): array + { + return [ + 'status_code' => $this->statusCode, + 'headers' => $this->headers, + 'body' => $this->body, + 'created_at' => $this->createdAt, + 'expires_at' => $this->expiresAt, + 'etag' => $this->etag, + 'last_modified' => $this->lastModified, + 'metadata' => $this->metadata, + ]; + } +} diff --git a/src/Fetch/Cache/FileCache.php b/src/Fetch/Cache/FileCache.php new file mode 100644 index 0000000..eb03843 --- /dev/null +++ b/src/Fetch/Cache/FileCache.php @@ -0,0 +1,304 @@ +directory = rtrim($directory, DIRECTORY_SEPARATOR); + $this->defaultTtl = $defaultTtl; + $this->maxSize = $maxSize; + + $this->ensureDirectoryExists(); + } + + /** + * {@inheritdoc} + */ + public function get(string $key): ?CachedResponse + { + $path = $this->getPath($key); + + // Directly attempt to read the file - file_get_contents returns false if file doesn't exist + // This avoids a race condition where file could be deleted between exists check and read + $contents = @file_get_contents($path); + if ($contents === false) { + return null; + } + + $data = json_decode($contents, true); + if ($data === null && json_last_error() !== JSON_ERROR_NONE) { + // Invalid cache file, delete it + @unlink($path); + + return null; + } + + // Reconstruct the cached response + $response = CachedResponse::fromArray($data); + if ($response === null) { + @unlink($path); + + return null; + } + + // Check if expired + if ($response->isExpired()) { + @unlink($path); + + return null; + } + + return $response; + } + + /** + * {@inheritdoc} + */ + public function set(string $key, CachedResponse $response, ?int $ttl = null): bool + { + $this->ensureDirectoryExists(); + + // Probabilistically check cache size and prune if necessary (1 in 20 chance) + if (random_int(1, 20) === 1 && $this->getCacheSize() > $this->maxSize) { + $this->prune(); + } + + $path = $this->getPath($key); + + // If TTL is provided, update the cached response with the correct expiration + // TTL=0 means no expiration (expires_at=null), TTL>0 sets specific expiration + // TTL<0 means already expired, TTL=null means use default + if ($ttl !== null) { + if ($ttl === 0) { + // No expiration + $updatedResponse = CachedResponse::fromArray( + array_merge($response->toArray(), [ + 'expires_at' => null, + ]) + ); + if ($updatedResponse !== null) { + $response = $updatedResponse; + } + } else { + $updatedResponse = CachedResponse::fromArray( + array_merge($response->toArray(), [ + 'expires_at' => time() + $ttl, + ]) + ); + if ($updatedResponse !== null) { + $response = $updatedResponse; + } + } + } elseif ($response->getExpiresAt() === null && $this->defaultTtl > 0) { + $updatedResponse = CachedResponse::fromArray( + array_merge($response->toArray(), [ + 'expires_at' => time() + $this->defaultTtl, + ]) + ); + if ($updatedResponse !== null) { + $response = $updatedResponse; + } + } + + $encoded = json_encode($response->toArray()); + if ($encoded === false) { + throw new RuntimeException('Failed to encode cache data as JSON'); + } + $result = @file_put_contents($path, $encoded, LOCK_EX); + + if ($result === false) { + throw new RuntimeException("Failed to write cache file: {$path}"); + } + + return true; + } + + /** + * {@inheritdoc} + */ + public function delete(string $key): bool + { + $path = $this->getPath($key); + + if (file_exists($path)) { + return @unlink($path); + } + + return false; + } + + /** + * {@inheritdoc} + */ + public function has(string $key): bool + { + return $this->get($key) !== null; + } + + /** + * {@inheritdoc} + */ + public function clear(): void + { + $files = glob($this->directory.DIRECTORY_SEPARATOR.'*'.self::FILE_EXTENSION); + + if ($files === false) { + return; + } + + foreach ($files as $file) { + @unlink($file); + } + } + + /** + * {@inheritdoc} + */ + public function prune(): int + { + $count = 0; + $files = glob($this->directory.DIRECTORY_SEPARATOR.'*'.self::FILE_EXTENSION); + + if ($files === false) { + return 0; + } + + foreach ($files as $file) { + $contents = @file_get_contents($file); + if ($contents === false) { + @unlink($file); + $count++; + + continue; + } + + $data = @json_decode($contents, true); + if ($data === null && json_last_error() !== JSON_ERROR_NONE) { + @unlink($file); + $count++; + + continue; + } + + $response = CachedResponse::fromArray($data); + if ($response === null || $response->isExpired()) { + @unlink($file); + $count++; + } + } + + return $count; + } + + /** + * Get cache statistics. + * + * @return array{directory: string, items: int, size: int, max_size: int, default_ttl: int} + */ + public function getStats(): array + { + $files = glob($this->directory.DIRECTORY_SEPARATOR.'*'.self::FILE_EXTENSION); + + return [ + 'directory' => $this->directory, + 'items' => $files !== false ? count($files) : 0, + 'size' => $this->getCacheSize(), + 'max_size' => $this->maxSize, + 'default_ttl' => $this->defaultTtl, + ]; + } + + /** + * Get the cache directory. + */ + public function getDirectory(): string + { + return $this->directory; + } + + /** + * Get the file path for a cache key. + */ + private function getPath(string $key): string + { + // Hash the key to create a safe filename + $filename = hash('sha256', $key).self::FILE_EXTENSION; + + return $this->directory.DIRECTORY_SEPARATOR.$filename; + } + + /** + * Ensure the cache directory exists. + * + * @throws RuntimeException If the directory cannot be created + */ + private function ensureDirectoryExists(): void + { + if (! is_dir($this->directory)) { + if (! @mkdir($this->directory, 0755, true)) { + throw new RuntimeException("Failed to create cache directory: {$this->directory}"); + } + } + + if (! is_writable($this->directory)) { + throw new RuntimeException("Cache directory is not writable: {$this->directory}"); + } + } + + /** + * Get the current size of the cache in bytes. + */ + private function getCacheSize(): int + { + $size = 0; + $files = glob($this->directory.DIRECTORY_SEPARATOR.'*'.self::FILE_EXTENSION); + + if ($files === false) { + return 0; + } + + foreach ($files as $file) { + $fileSize = @filesize($file); + if ($fileSize !== false) { + $size += $fileSize; + } + } + + return $size; + } +} diff --git a/src/Fetch/Cache/MemoryCache.php b/src/Fetch/Cache/MemoryCache.php new file mode 100644 index 0000000..c633e0a --- /dev/null +++ b/src/Fetch/Cache/MemoryCache.php @@ -0,0 +1,194 @@ + + */ + private array $cache = []; + + /** + * Maximum number of items in the cache. + */ + private int $maxItems; + + /** + * Default TTL in seconds. + */ + private int $defaultTtl; + + /** + * Create a new memory cache instance. + */ + public function __construct(int $maxItems = 1000, int $defaultTtl = 3600) + { + $this->maxItems = $maxItems; + $this->defaultTtl = $defaultTtl; + } + + /** + * {@inheritdoc} + */ + public function get(string $key): ?CachedResponse + { + if (! isset($this->cache[$key])) { + return null; + } + + $entry = $this->cache[$key]; + + // Check if the entry has expired + if ($entry['expires_at'] !== null && time() > $entry['expires_at']) { + unset($this->cache[$key]); + + return null; + } + + return $entry['response']; + } + + /** + * {@inheritdoc} + */ + public function set(string $key, CachedResponse $response, ?int $ttl = null): bool + { + // Ensure we don't exceed max items + if (count($this->cache) >= $this->maxItems && ! isset($this->cache[$key])) { + $this->evictOldest(); + } + + // Determine the effective TTL + // ttl=null means use default, ttl=0 means no expiration, negative means already expired + $effectiveTtl = $ttl ?? $this->defaultTtl; + + // Handle negative TTL (already expired) + if ($effectiveTtl < 0) { + $expiresAt = time() + $effectiveTtl; // Will be in the past + } elseif ($effectiveTtl > 0) { + $expiresAt = time() + $effectiveTtl; + } else { + $expiresAt = null; // TTL of 0 means no expiration + } + + $this->cache[$key] = [ + 'response' => $response, + 'expires_at' => $expiresAt, + ]; + + return true; + } + + /** + * {@inheritdoc} + */ + public function delete(string $key): bool + { + if (isset($this->cache[$key])) { + unset($this->cache[$key]); + + return true; + } + + return false; + } + + /** + * {@inheritdoc} + */ + public function has(string $key): bool + { + if (! isset($this->cache[$key])) { + return false; + } + + $entry = $this->cache[$key]; + + // Check if the entry has expired + if ($entry['expires_at'] !== null && time() > $entry['expires_at']) { + unset($this->cache[$key]); + + return false; + } + + return true; + } + + /** + * {@inheritdoc} + */ + public function clear(): void + { + $this->cache = []; + } + + /** + * {@inheritdoc} + */ + public function prune(): int + { + $now = time(); + $count = 0; + + foreach ($this->cache as $key => $entry) { + if ($entry['expires_at'] !== null && $now > $entry['expires_at']) { + unset($this->cache[$key]); + $count++; + } + } + + return $count; + } + + /** + * Get the number of items in the cache. + */ + public function count(): int + { + return count($this->cache); + } + + /** + * Get cache statistics. + * + * @return array{items: int, max_items: int, default_ttl: int} + */ + public function getStats(): array + { + return [ + 'items' => count($this->cache), + 'max_items' => $this->maxItems, + 'default_ttl' => $this->defaultTtl, + ]; + } + + /** + * Evict the oldest entry from the cache. + */ + private function evictOldest(): void + { + // Find the oldest entry + $oldestKey = null; + $oldestTime = PHP_INT_MAX; + + foreach ($this->cache as $key => $entry) { + $createdAt = $entry['response']->getCreatedAt(); + if ($createdAt < $oldestTime) { + $oldestTime = $createdAt; + $oldestKey = $key; + } + } + + if ($oldestKey !== null) { + unset($this->cache[$oldestKey]); + } + } +} diff --git a/src/Fetch/Concerns/ManagesCache.php b/src/Fetch/Concerns/ManagesCache.php new file mode 100644 index 0000000..1b92975 --- /dev/null +++ b/src/Fetch/Concerns/ManagesCache.php @@ -0,0 +1,341 @@ + + */ + protected array $cacheOptions = [ + 'respect_cache_headers' => true, + 'default_ttl' => 3600, + 'stale_while_revalidate' => 0, + 'stale_if_error' => 0, + 'cache_methods' => ['GET', 'HEAD'], + 'cache_status_codes' => [200, 203, 204, 206, 300, 301, 404, 410], + 'vary_headers' => ['Accept', 'Accept-Encoding', 'Accept-Language'], + 'is_shared_cache' => false, + ]; + + /** + * Enable caching with optional configuration. + * + * @param array $options Cache options + */ + public function withCache(?CacheInterface $cache = null, array $options = []): ClientHandler + { + $this->cache = $cache ?? new MemoryCache; + $this->cacheOptions = array_merge($this->cacheOptions, $options); + + // Initialize the cache key generator with vary headers + $varyHeaders = $this->cacheOptions['vary_headers'] ?? []; + $this->cacheKeyGenerator = new CacheKeyGenerator('fetch:', $varyHeaders); + + return $this; + } + + /** + * Disable caching. + */ + public function withoutCache(): ClientHandler + { + $this->cache = null; + $this->cacheKeyGenerator = null; + + return $this; + } + + /** + * Get the cache instance. + */ + public function getCache(): ?CacheInterface + { + return $this->cache; + } + + /** + * Check if caching is enabled. + */ + public function isCacheEnabled(): bool + { + return $this->cache !== null; + } + + /** + * Check if the request method is cacheable. + */ + protected function isCacheableMethod(string $method): bool + { + $cacheableMethods = $this->cacheOptions['cache_methods'] ?? ['GET', 'HEAD']; + + return in_array(strtoupper($method), $cacheableMethods, true); + } + + /** + * Check if the response status code is cacheable. + */ + protected function isCacheableStatusCode(int $statusCode): bool + { + $cacheableStatusCodes = $this->cacheOptions['cache_status_codes'] ?? [200]; + + return in_array($statusCode, $cacheableStatusCodes, true); + } + + /** + * Generate a cache key for the request. + * + * @param array $options Request options + */ + protected function generateCacheKey(string $method, string $uri, array $options = []): string + { + // Check for custom cache key + $cacheConfig = $options['cache'] ?? []; + if (is_array($cacheConfig) && isset($cacheConfig['key'])) { + return $this->getCacheKeyGenerator()->generateCustom($cacheConfig['key']); + } + + return $this->getCacheKeyGenerator()->generate($method, $uri, $options); + } + + /** + * Get the cache key generator. + */ + protected function getCacheKeyGenerator(): CacheKeyGenerator + { + if ($this->cacheKeyGenerator === null) { + $this->cacheKeyGenerator = new CacheKeyGenerator('fetch:', $this->cacheOptions['vary_headers'] ?? []); + } + + return $this->cacheKeyGenerator; + } + + /** + * Try to get a cached response. + * + * @param array $options Request options + * @return array{response: Response|null, cached: CachedResponse|null, status: string} + */ + protected function getCachedResponse(string $method, string $uri, array $options = []): array + { + if ($this->cache === null || ! $this->isCacheableMethod($method)) { + return ['response' => null, 'cached' => null, 'status' => 'BYPASS']; + } + + // Check for force refresh + $cacheConfig = $options['cache'] ?? []; + if (is_array($cacheConfig) && ($cacheConfig['force_refresh'] ?? false)) { + return ['response' => null, 'cached' => null, 'status' => 'REFRESH']; + } + + $key = $this->generateCacheKey($method, $uri, $options); + $cached = $this->cache->get($key); + + if ($cached === null) { + return ['response' => null, 'cached' => null, 'status' => 'MISS']; + } + + // Check if fresh + if ($cached->isFresh()) { + $response = $this->createResponseFromCached($cached); + $response = $response->withHeader('X-Cache-Status', 'HIT'); + + return ['response' => $response, 'cached' => $cached, 'status' => 'HIT']; + } + + // Check for stale-while-revalidate + $staleWhileRevalidate = $this->cacheOptions['stale_while_revalidate'] ?? 0; + if ($staleWhileRevalidate > 0 && $cached->isUsableAsStale($staleWhileRevalidate)) { + $response = $this->createResponseFromCached($cached); + $response = $response->withHeader('X-Cache-Status', 'STALE'); + + return ['response' => $response, 'cached' => $cached, 'status' => 'STALE']; + } + + return ['response' => null, 'cached' => $cached, 'status' => 'EXPIRED']; + } + + /** + * Store a response in the cache. + * + * @param array $options Request options + */ + protected function cacheResponse(string $method, string $uri, ResponseInterface $response, array $options = []): void + { + if ($this->cache === null || ! $this->isCacheableMethod($method)) { + return; + } + + if (! $this->isCacheableStatusCode($response->getStatusCode())) { + return; + } + + // Check Cache-Control headers + $cacheControl = CacheControl::fromResponse($response); + + if ($this->cacheOptions['respect_cache_headers'] ?? true) { + $isSharedCache = $this->cacheOptions['is_shared_cache'] ?? false; + if (! $cacheControl->shouldCache($response, $isSharedCache)) { + return; + } + } + + // Calculate TTL + $ttl = $this->calculateTtl($response, $cacheControl, $options); + if ($ttl !== null && $ttl <= 0) { + return; + } + + $key = $this->generateCacheKey($method, $uri, $options); + $cachedResponse = CachedResponse::fromResponse($response, null); + + $this->cache->set($key, $cachedResponse, $ttl); + } + + /** + * Calculate the TTL for a response. + * + * @param array $options Request options + */ + protected function calculateTtl(ResponseInterface $response, CacheControl $cacheControl, array $options = []): ?int + { + // Check for per-request TTL + $cacheConfig = $options['cache'] ?? []; + if (is_array($cacheConfig) && isset($cacheConfig['ttl'])) { + return (int) $cacheConfig['ttl']; + } + + // Get TTL from Cache-Control headers + if ($this->cacheOptions['respect_cache_headers'] ?? true) { + $isSharedCache = $this->cacheOptions['is_shared_cache'] ?? false; + $headerTtl = $cacheControl->getTtl($response, $isSharedCache); + if ($headerTtl !== null) { + return $headerTtl; + } + } + + // Fall back to default TTL + return $this->cacheOptions['default_ttl'] ?? 3600; + } + + /** + * Add conditional headers to a request based on cached response. + * + * @param array $options Request options + * @return array Modified options + */ + protected function addConditionalHeaders(array $options, ?CachedResponse $cached): array + { + if ($cached === null) { + return $options; + } + + if (! isset($options['headers'])) { + $options['headers'] = []; + } + + // Add If-None-Match for ETag + $etag = $cached->getETag(); + if ($etag !== null) { + $options['headers']['If-None-Match'] = $etag; + } + + // Add If-Modified-Since for Last-Modified + $lastModified = $cached->getLastModified(); + if ($lastModified !== null) { + $options['headers']['If-Modified-Since'] = $lastModified; + } + + return $options; + } + + /** + * Handle a 304 Not Modified response. + */ + protected function handleNotModified(CachedResponse $cached, ResponseInterface $response): Response + { + // Create a new response with the cached body but potentially updated headers + $headers = $cached->getHeaders(); + + // Update headers from the 304 response + foreach ($response->getHeaders() as $name => $values) { + // Don't copy certain headers from the 304 + if (in_array(strtolower($name), ['content-length', 'content-encoding', 'transfer-encoding'], true)) { + continue; + } + $headers[$name] = $values; + } + + $newResponse = new Response( + $cached->getStatusCode(), + $headers, + $cached->getBody() + ); + + return $newResponse->withHeader('X-Cache-Status', 'REVALIDATED'); + } + + /** + * Create a Response from a CachedResponse. + */ + protected function createResponseFromCached(CachedResponse $cached): Response + { + return new Response( + $cached->getStatusCode(), + $cached->getHeaders(), + $cached->getBody() + ); + } + + /** + * Handle stale-if-error: serve stale response on error. + */ + protected function handleStaleIfError(?CachedResponse $cached): ?Response + { + if ($cached === null) { + return null; + } + + $staleIfError = $this->cacheOptions['stale_if_error'] ?? 0; + if ($staleIfError <= 0) { + return null; + } + + if (! $cached->isUsableAsStale($staleIfError)) { + return null; + } + + $response = $this->createResponseFromCached($cached); + + return $response->withHeader('X-Cache-Status', 'STALE-IF-ERROR'); + } +} diff --git a/src/Fetch/Concerns/ManagesConnectionPool.php b/src/Fetch/Concerns/ManagesConnectionPool.php new file mode 100644 index 0000000..3de5d62 --- /dev/null +++ b/src/Fetch/Concerns/ManagesConnectionPool.php @@ -0,0 +1,275 @@ +getDnsCacheTtl()); + } + } + + /** + * Configure connection pooling for this handler. + * + * @param array|bool $config Pool configuration or boolean to enable/disable + * @return $this + */ + public function withConnectionPool(array|bool $config = true): ClientHandler + { + if (is_bool($config)) { + $this->poolingEnabled = $config; + + return $this; + } + + $this->poolingEnabled = true; + + // Initialize or update the global pool + self::$connectionPool = ConnectionPool::fromArray($config); + + // Always initialize DNS cache with configuration TTL (uses default if not specified) + $poolConfig = self::$connectionPool->getConfig(); + self::$dnsCache = new DnsCache($poolConfig->getDnsCacheTtl()); + + return $this; + } + + /** + * Configure HTTP/2 for this handler. + * + * @param array|bool $config HTTP/2 configuration or boolean to enable/disable + * @return $this + */ + public function withHttp2(array|bool $config = true): ClientHandler + { + if (is_bool($config)) { + $this->http2Config = new Http2Configuration(enabled: $config); + } else { + $this->http2Config = Http2Configuration::fromArray($config); + } + + // Apply HTTP/2 curl options to the handler options + if ($this->http2Config->isEnabled()) { + $curlOptions = $this->http2Config->getCurlOptions(); + if (! empty($curlOptions)) { + $existingCurl = $this->options['curl'] ?? []; + // Use + operator to preserve integer keys (CURL constants) + // and give priority to existing options over defaults + $this->options['curl'] = $existingCurl + $curlOptions; + } + + // Set HTTP version in options + $this->options['version'] = 2.0; + } + + return $this; + } + + /** + * Get the connection pool instance. + * + * @return ConnectionPool|null The pool instance or null if not configured + */ + public function getConnectionPool(): ?ConnectionPool + { + return self::$connectionPool; + } + + /** + * Get the DNS cache instance. + * + * @return DnsCache|null The DNS cache or null if not configured + */ + public function getDnsCache(): ?DnsCache + { + return self::$dnsCache; + } + + /** + * Get the HTTP/2 configuration. + * + * @return Http2Configuration|null The HTTP/2 config or null if not configured + */ + public function getHttp2Config(): ?Http2Configuration + { + return $this->http2Config; + } + + /** + * Check if connection pooling is enabled. + */ + public function isPoolingEnabled(): bool + { + return $this->poolingEnabled && self::$connectionPool !== null && self::$connectionPool->isEnabled(); + } + + /** + * Check if HTTP/2 is enabled. + */ + public function isHttp2Enabled(): bool + { + return $this->http2Config !== null && $this->http2Config->isEnabled(); + } + + /** + * Get connection pool statistics. + * + * @return array + */ + public function getPoolStats(): array + { + if (self::$connectionPool === null) { + return ['enabled' => false]; + } + + return self::$connectionPool->getStats(); + } + + /** + * Get DNS cache statistics. + * + * @return array + */ + public function getDnsCacheStats(): array + { + if (self::$dnsCache === null) { + return ['enabled' => false]; + } + + return array_merge(['enabled' => true], self::$dnsCache->getStats()); + } + + /** + * Clear the DNS cache. + * + * @param string|null $hostname Specific hostname to clear, or null for all + * @return $this + */ + public function clearDnsCache(?string $hostname = null): ClientHandler + { + if (self::$dnsCache !== null) { + self::$dnsCache->clear($hostname); + } + + return $this; + } + + /** + * Close all pooled connections. + * + * @return $this + */ + public function closeAllConnections(): ClientHandler + { + if (self::$connectionPool !== null) { + self::$connectionPool->closeAll(); + } + + return $this; + } + + /** + * Reset the global connection pool and DNS cache. + * + * @return $this + */ + public function resetPool(): ClientHandler + { + if (self::$connectionPool !== null) { + self::$connectionPool->closeAll(); + } + self::$connectionPool = null; + self::$dnsCache = null; + $this->poolingEnabled = false; + + return $this; + } + + /** + * Get cURL options for HTTP/2 support. + * + * @return array + */ + protected function getHttp2CurlOptions(): array + { + if ($this->http2Config === null) { + return []; + } + + return $this->http2Config->getCurlOptions(); + } + + /** + * Resolve a hostname using the DNS cache. + * + * Returns null if DNS cache is not configured or if DNS resolution fails. + * This method silently catches exceptions to allow fallback behavior. + * + * @param string $hostname The hostname to resolve + * @return string|null The resolved IP address, or null if not available + */ + protected function resolveHostname(string $hostname): ?string + { + if (self::$dnsCache === null) { + return null; + } + + try { + return self::$dnsCache->resolveFirst($hostname); + } catch (\Throwable) { + return null; + } + } +} diff --git a/src/Fetch/Concerns/PerformsHttpRequests.php b/src/Fetch/Concerns/PerformsHttpRequests.php index aac842a..187c27d 100644 --- a/src/Fetch/Concerns/PerformsHttpRequests.php +++ b/src/Fetch/Concerns/PerformsHttpRequests.php @@ -13,6 +13,7 @@ use GuzzleHttp\Exception\RequestException as GuzzleRequestException; use GuzzleHttp\Psr7\Request as GuzzleRequest; use Matrix\Exceptions\AsyncException; +use Psr\Http\Message\RequestInterface; use React\Promise\PromiseInterface; use function Matrix\Support\async; @@ -179,6 +180,21 @@ public function sendRequest( } } + // Check for cached response (if ManagesCache trait is available) + // Use handler options which includes cache config, not just guzzle options + $cachedResult = null; + if (method_exists($handler, 'getCachedResponse') && method_exists($handler, 'isCacheEnabled') && $handler->isCacheEnabled()) { + $cachedResult = $handler->getCachedResponse($methodStr, $fullUri, $handler->options); + if ($cachedResult['response'] !== null) { + return $cachedResult['response']; + } + + // Add conditional headers if we have a stale cache entry + if ($cachedResult['cached'] !== null && method_exists($handler, 'addConditionalHeaders')) { + $guzzleOptions = $handler->addConditionalHeaders($guzzleOptions, $cachedResult['cached']); + } + } + // Start timing for logging $startTime = microtime(true); @@ -187,12 +203,152 @@ public function sendRequest( $handler->logRequest($methodStr, $fullUri, $guzzleOptions); } - // Send the request (async or sync) + // Check if middleware is available and should be used + if (method_exists($handler, 'hasMiddleware') && $handler->hasMiddleware()) { + return $handler->executeWithMiddleware($methodStr, $fullUri, $guzzleOptions, $startTime); + } + + // Send the request (async or sync) without middleware if ($handler->isAsync) { return $handler->executeAsyncRequest($methodStr, $fullUri, $guzzleOptions); } else { - return $handler->executeSyncRequest($methodStr, $fullUri, $guzzleOptions, $startTime); + return $this->executeSyncRequestWithCache($methodStr, $fullUri, $guzzleOptions, $startTime, $cachedResult, $handler); + } + } + + /** + * Execute a synchronous request with caching support. + * + * @param string $method The HTTP method + * @param string $uri The full URI + * @param array $options The Guzzle options + * @param float $startTime The request start time + * @param array|null $cachedResult The cached result data + * @param mixed $handler The cloned handler instance with request-specific state + * @return ResponseInterface The response + */ + protected function executeSyncRequestWithCache( + string $method, + string $uri, + array $options, + float $startTime, + ?array $cachedResult, + mixed $handler + ): ResponseInterface { + try { + $response = $handler->executeSyncRequest($method, $uri, $options, $startTime); + + // Handle 304 Not Modified response + if ($response->getStatusCode() === 304 && $cachedResult !== null && isset($cachedResult['cached']) && method_exists($handler, 'handleNotModified')) { + $response = $handler->handleNotModified($cachedResult['cached'], $response); + } + + // Cache the response if caching is enabled + // Use handler options which includes cache config + if (method_exists($handler, 'cacheResponse') && method_exists($handler, 'isCacheEnabled') && $handler->isCacheEnabled()) { + $handler->cacheResponse($method, $uri, $response, $handler->options); + } + + return $response; + } catch (\Throwable $e) { + // Handle stale-if-error: serve stale response on error + if ($cachedResult !== null && isset($cachedResult['cached']) && method_exists($handler, 'handleStaleIfError')) { + $staleResponse = $handler->handleStaleIfError($cachedResult['cached']); + if ($staleResponse !== null) { + return $staleResponse; + } + } + + throw $e; + } + } + + /** + * Execute the request through the middleware pipeline. + * + * @param string $method The HTTP method + * @param string $uri The full URI + * @param array $options The Guzzle options + * @param float $startTime The request start time + * @return ResponseInterface|PromiseInterface The response or promise + */ + protected function executeWithMiddleware( + string $method, + string $uri, + array $options, + float $startTime, + ): ResponseInterface|PromiseInterface { + // Create a PSR-7 request from the current state + $body = null; + if (isset($options['body'])) { + $body = $options['body']; + } elseif (isset($options['json'])) { + $body = json_encode($options['json']); } + + $psrRequest = new GuzzleRequest( + $method, + $uri, + $options['headers'] ?? [], + $body + ); + + // Define the core handler that will be called after all middleware + $coreHandler = function (RequestInterface $request) use ($options, $startTime): ResponseInterface|PromiseInterface { + // Work with a copy to avoid side effects + $modifiedOptions = $options; + + // Extract method and URI from the (potentially modified) request + $method = $request->getMethod(); + $uri = (string) $request->getUri(); + + // Merge any headers from the modified request back into options + $headers = []; + foreach ($request->getHeaders() as $name => $values) { + $headers[$name] = implode(', ', $values); + } + $modifiedOptions['headers'] = array_merge($modifiedOptions['headers'] ?? [], $headers); + + // Handle body from modified request + $body = $request->getBody(); + $bodySize = $body->getSize(); + + // Check if body is readable and potentially has content (size is null or > 0) + if ($body->isReadable() && ($bodySize === null || $bodySize > 0)) { + if ($body->isSeekable()) { + $body->rewind(); + } + $bodyContents = $body->getContents(); + + if (! empty($bodyContents)) { + // Check if it's JSON + $contentType = $request->getHeaderLine('Content-Type'); + if (str_contains($contentType, 'application/json')) { + $decoded = json_decode($bodyContents, true); + if (json_last_error() === JSON_ERROR_NONE) { + $modifiedOptions['json'] = $decoded; + unset($modifiedOptions['body']); + } else { + $modifiedOptions['body'] = $bodyContents; + unset($modifiedOptions['json']); + } + } else { + $modifiedOptions['body'] = $bodyContents; + unset($modifiedOptions['json']); + } + } + } + + // Execute the actual request + if ($this->isAsync) { + return $this->executeAsyncRequest($method, $uri, $modifiedOptions); + } else { + return $this->executeSyncRequest($method, $uri, $modifiedOptions, $startTime); + } + }; + + // Execute through the middleware pipeline + return $this->getMiddlewarePipeline()->handle($psrRequest, $coreHandler); } /** @@ -250,6 +406,53 @@ public function getEffectiveTimeout(): int return self::DEFAULT_TIMEOUT; } + /** + * Execute a synchronous request with caching support. + * + * @param string $method The HTTP method + * @param string $uri The full URI + * @param array $options The Guzzle options + * @param float $startTime The request start time + * @param array|null $cachedResult The cached result data + * @param self $handler The cloned handler instance with request-specific state + * @return ResponseInterface The response + */ + protected function executeSyncRequestWithCache( + string $method, + string $uri, + array $options, + float $startTime, + ?array $cachedResult, + self $handler + ): ResponseInterface { + try { + $response = $handler->executeSyncRequest($method, $uri, $options, $startTime); + + // Handle 304 Not Modified response + if ($response->getStatusCode() === 304 && $cachedResult !== null && isset($cachedResult['cached']) && method_exists($handler, 'handleNotModified')) { + $response = $handler->handleNotModified($cachedResult['cached'], $response); + } + + // Cache the response if caching is enabled + // Use handler options which includes cache config + if (method_exists($handler, 'cacheResponse') && method_exists($handler, 'isCacheEnabled') && $handler->isCacheEnabled()) { + $handler->cacheResponse($method, $uri, $response, $handler->options); + } + + return $response; + } catch (\Throwable $e) { + // Handle stale-if-error: serve stale response on error + if ($cachedResult !== null && isset($cachedResult['cached']) && method_exists($handler, 'handleStaleIfError')) { + $staleResponse = $handler->handleStaleIfError($cachedResult['cached']); + if ($staleResponse !== null) { + return $staleResponse; + } + } + + throw $e; + } + } + /** * Send an HTTP request with a body. * diff --git a/src/Fetch/Concerns/SupportsMiddleware.php b/src/Fetch/Concerns/SupportsMiddleware.php new file mode 100644 index 0000000..639b8f3 --- /dev/null +++ b/src/Fetch/Concerns/SupportsMiddleware.php @@ -0,0 +1,142 @@ + $middleware + * @return $this + */ + public function middleware(array $middleware): ClientHandler + { + $this->middlewarePipeline = new MiddlewarePipeline($middleware); + + return $this; + } + + /** + * Add a single middleware to the pipeline. + * + * @param MiddlewareInterface $middleware The middleware to add + * @param int $priority Higher priority middleware runs first (default: 0) + * @return $this + */ + public function addMiddleware(MiddlewareInterface $middleware, int $priority = 0): ClientHandler + { + $this->getMiddlewarePipeline()->add($middleware, $priority); + + return $this; + } + + /** + * Prepend middleware to run first (with highest priority). + * + * @param MiddlewareInterface $middleware The middleware to prepend + * @return $this + */ + public function prependMiddleware(MiddlewareInterface $middleware): ClientHandler + { + $this->getMiddlewarePipeline()->prepend($middleware); + + return $this; + } + + /** + * Get the middleware pipeline instance. + */ + public function getMiddlewarePipeline(): MiddlewarePipeline + { + if ($this->middlewarePipeline === null) { + $this->middlewarePipeline = new MiddlewarePipeline; + } + + return $this->middlewarePipeline; + } + + /** + * Check if any middleware is registered. + */ + public function hasMiddleware(): bool + { + return $this->middlewarePipeline !== null && ! $this->middlewarePipeline->isEmpty(); + } + + /** + * Clear all middleware from the pipeline. + * + * @return $this + */ + public function clearMiddleware(): ClientHandler + { + if ($this->middlewarePipeline !== null) { + $this->middlewarePipeline->clear(); + } + + return $this; + } + + /** + * Conditionally add middleware. + * + * @param bool $condition The condition to check + * @param callable(static): void $callback Callback that receives $this for adding middleware + * @return $this + */ + public function when(bool $condition, callable $callback): ClientHandler + { + if ($condition) { + $callback($this); + } + + return $this; + } + + /** + * Conditionally add middleware (inverse of when). + * + * @param bool $condition The condition to check + * @param callable(static): void $callback Callback that receives $this for adding middleware + * @return $this + */ + public function unless(bool $condition, callable $callback): ClientHandler + { + return $this->when(! $condition, $callback); + } + + /** + * Clone the middleware pipeline when the handler is cloned. + * + * This ensures that the cloned handler has its own independent middleware + * pipeline instance, preventing modifications from affecting the original. + */ + protected function cloneMiddlewarePipeline(): void + { + if ($this->middlewarePipeline !== null) { + $middleware = $this->middlewarePipeline->getMiddleware(); + $this->middlewarePipeline = new MiddlewarePipeline($middleware); + } + } +} diff --git a/src/Fetch/Http/ClientHandler.php b/src/Fetch/Http/ClientHandler.php index d6dbc82..d137844 100644 --- a/src/Fetch/Http/ClientHandler.php +++ b/src/Fetch/Http/ClientHandler.php @@ -7,10 +7,13 @@ use Fetch\Concerns\ConfiguresRequests; use Fetch\Concerns\HandlesMocking; use Fetch\Concerns\HandlesUris; +use Fetch\Concerns\ManagesConnectionPool; +use Fetch\Concerns\ManagesCache; use Fetch\Concerns\ManagesDebugAndProfiling; use Fetch\Concerns\ManagesPromises; use Fetch\Concerns\ManagesRetries; use Fetch\Concerns\PerformsHttpRequests; +use Fetch\Concerns\SupportsMiddleware; use Fetch\Enum\ContentType; use Fetch\Enum\Method; use Fetch\Interfaces\ClientHandler as ClientHandlerInterface; @@ -29,10 +32,13 @@ class ClientHandler implements ClientHandlerInterface use ConfiguresRequests, HandlesMocking, HandlesUris, + ManagesConnectionPool, + ManagesCache, ManagesDebugAndProfiling, ManagesPromises, ManagesRetries, - PerformsHttpRequests; + PerformsHttpRequests, + SupportsMiddleware; /** * Default options for the request. @@ -326,6 +332,16 @@ public function withClonedOptions(array $options): static return $clone; } + /** + * Handle cloning of the client handler. + * + * Ensures that cloned handlers have independent middleware pipelines. + */ + public function __clone(): void + { + $this->cloneMiddlewarePipeline(); + } + /** * Send the configured request asynchronously based on current options. * diff --git a/src/Fetch/Http/MiddlewarePipeline.php b/src/Fetch/Http/MiddlewarePipeline.php new file mode 100644 index 0000000..f5c9cec --- /dev/null +++ b/src/Fetch/Http/MiddlewarePipeline.php @@ -0,0 +1,180 @@ + + */ + protected array $middleware = []; + + /** + * Create a new middleware pipeline. + * + * @param array $middleware + * + * @throws \InvalidArgumentException If middleware format is invalid + */ + public function __construct(array $middleware = []) + { + foreach ($middleware as $item) { + if ($item instanceof MiddlewareInterface) { + $this->middleware[] = ['middleware' => $item, 'priority' => 0]; + } elseif (is_array($item) && isset($item['middleware']) && $item['middleware'] instanceof MiddlewareInterface) { + $this->middleware[] = [ + 'middleware' => $item['middleware'], + 'priority' => $item['priority'] ?? 0, + ]; + } else { + throw new \InvalidArgumentException( + 'Middleware must be an instance of MiddlewareInterface or an array with "middleware" key containing a MiddlewareInterface instance' + ); + } + } + + $this->sortMiddleware(); + } + + /** + * Add middleware to the pipeline. + * + * @param MiddlewareInterface $middleware The middleware to add + * @param int $priority Higher priority middleware runs first (default: 0) + * @return $this + */ + public function add(MiddlewareInterface $middleware, int $priority = 0): self + { + $this->middleware[] = ['middleware' => $middleware, 'priority' => $priority]; + $this->sortMiddleware(); + + return $this; + } + + /** + * Prepend middleware to run first (with highest priority). + * + * @param MiddlewareInterface $middleware The middleware to prepend + * @return $this + */ + public function prepend(MiddlewareInterface $middleware): self + { + $maxPriority = 0; + foreach ($this->middleware as $item) { + if ($item['priority'] > $maxPriority) { + $maxPriority = $item['priority']; + } + } + + return $this->add($middleware, $maxPriority + 1); + } + + /** + * Process the request through the middleware pipeline. + * + * @param RequestInterface $request The request to process + * @param callable $coreHandler The final handler (typically the HTTP client) + * @return ResponseInterface|PromiseInterface The response or promise + */ + public function handle(RequestInterface $request, callable $coreHandler): ResponseInterface|PromiseInterface + { + if (empty($this->middleware)) { + return $coreHandler($request); + } + + $pipeline = $this->buildPipeline($coreHandler); + + return $pipeline($request); + } + + /** + * Get the current middleware stack. + * + * @return array + */ + public function getMiddleware(): array + { + return $this->middleware; + } + + /** + * Check if the pipeline has any middleware. + */ + public function isEmpty(): bool + { + return empty($this->middleware); + } + + /** + * Get the number of middleware in the pipeline. + */ + public function count(): int + { + return count($this->middleware); + } + + /** + * Clear all middleware from the pipeline. + * + * @return $this + */ + public function clear(): self + { + $this->middleware = []; + + return $this; + } + + /** + * Build the middleware pipeline as a single callable. + * + * @param callable $coreHandler The final handler + * @return callable The composed pipeline + */ + protected function buildPipeline(callable $coreHandler): callable + { + // Build the pipeline from the inside out (reverse order) + // So the first middleware in the array runs first + $pipeline = array_reduce( + array_reverse($this->middleware), + function (callable $carry, array $item): callable { + /** @var MiddlewareInterface $middleware */ + $middleware = $item['middleware']; + + return function (RequestInterface $request) use ($carry, $middleware): ResponseInterface|PromiseInterface { + return $middleware->handle($request, $carry); + }; + }, + $coreHandler + ); + + return $pipeline; + } + + /** + * Sort middleware by priority (highest first). + */ + protected function sortMiddleware(): void + { + usort($this->middleware, function (array $a, array $b): int { + return $b['priority'] <=> $a['priority']; + }); + } +} diff --git a/src/Fetch/Interfaces/ClientHandler.php b/src/Fetch/Interfaces/ClientHandler.php index 142a3d6..324b722 100644 --- a/src/Fetch/Interfaces/ClientHandler.php +++ b/src/Fetch/Interfaces/ClientHandler.php @@ -573,4 +573,172 @@ public function getDebugOptions(): array; * Get the last debug info from the most recent request. */ public function getLastDebugInfo(): ?\Fetch\Support\DebugInfo; + + /** + * Set multiple middleware at once, replacing any existing middleware. + * + * Note: This replaces the entire middleware pipeline. Use addMiddleware() + * to add middleware without removing existing ones. + * + * @param array $middleware + * @return $this + */ + public function middleware(array $middleware): self; + + /** + * Add a single middleware to the pipeline. + * + * @param MiddlewareInterface $middleware The middleware to add + * @param int $priority Higher priority middleware runs first (default: 0) + * @return $this + */ + public function addMiddleware(MiddlewareInterface $middleware, int $priority = 0): self; + + /** + * Prepend middleware to run first (with highest priority). + * + * @param MiddlewareInterface $middleware The middleware to prepend + * @return $this + */ + public function prependMiddleware(MiddlewareInterface $middleware): self; + + /** + * Get the middleware pipeline instance. + */ + public function getMiddlewarePipeline(): \Fetch\Http\MiddlewarePipeline; + + /** + * Check if any middleware is registered. + */ + public function hasMiddleware(): bool; + + /** + * Clear all middleware from the pipeline. + * + * @return $this + */ + public function clearMiddleware(): self; + + /** + * Conditionally add middleware. + * + * @param bool $condition The condition to check + * @param callable(static): void $callback Callback that receives $this for adding middleware + * @return $this + */ + public function when(bool $condition, callable $callback): self; + + /** + * Conditionally add middleware (inverse of when). + * + * @param bool $condition The condition to check + * @param callable(static): void $callback Callback that receives $this for adding middleware + * @return $this + */ + public function unless(bool $condition, callable $callback): self; + + /** + * Configure connection pooling for this handler. + * + * @param array|bool $config Pool configuration or boolean to enable/disable + * @return $this + */ + public function withConnectionPool(array|bool $config = true): self; + + /** + * Configure HTTP/2 for this handler. + * + * @param array|bool $config HTTP/2 configuration or boolean to enable/disable + * @return $this + */ + public function withHttp2(array|bool $config = true): self; + + /** + * Get the connection pool instance. + * + * @return \Fetch\Pool\ConnectionPool|null The pool instance or null if not configured + */ + public function getConnectionPool(): ?\Fetch\Pool\ConnectionPool; + + /** + * Get the DNS cache instance. + * + * @return \Fetch\Pool\DnsCache|null The DNS cache or null if not configured + */ + public function getDnsCache(): ?\Fetch\Pool\DnsCache; + + /** + * Get the HTTP/2 configuration. + * + * @return \Fetch\Pool\Http2Configuration|null The HTTP/2 config or null if not configured + */ + public function getHttp2Config(): ?\Fetch\Pool\Http2Configuration; + + /** + * Check if connection pooling is enabled. + */ + public function isPoolingEnabled(): bool; + + /** + * Check if HTTP/2 is enabled. + */ + public function isHttp2Enabled(): bool; + + /** + * Get connection pool statistics. + * + * @return array + */ + public function getPoolStats(): array; + + /** + * Get DNS cache statistics. + * + * @return array + */ + public function getDnsCacheStats(): array; + + /** + * Clear the DNS cache. + * + * @param string|null $hostname Specific hostname to clear, or null for all + * @return $this + */ + public function clearDnsCache(?string $hostname = null): self; + + /** + * Close all pooled connections. + * + * @return $this + */ + public function closeAllConnections(): self; + + /** + * Reset the global connection pool and DNS cache. + * + * @return $this + */ + public function resetPool(): self; + + /** + * Enable caching with optional configuration. + * + * @param array $options Cache options + */ + public function withCache(?\Fetch\Cache\CacheInterface $cache = null, array $options = []): self; + + /** + * Disable caching. + */ + public function withoutCache(): self; + + /** + * Get the cache instance. + */ + public function getCache(): ?\Fetch\Cache\CacheInterface; + + /** + * Check if caching is enabled. + */ + public function isCacheEnabled(): bool; } diff --git a/src/Fetch/Interfaces/MiddlewareInterface.php b/src/Fetch/Interfaces/MiddlewareInterface.php new file mode 100644 index 0000000..ef4a0cb --- /dev/null +++ b/src/Fetch/Interfaces/MiddlewareInterface.php @@ -0,0 +1,26 @@ + The response or promise + */ + public function handle(RequestInterface $request, callable $next): Response|PromiseInterface; +} diff --git a/src/Fetch/Pool/Connection.php b/src/Fetch/Pool/Connection.php new file mode 100644 index 0000000..c283924 --- /dev/null +++ b/src/Fetch/Pool/Connection.php @@ -0,0 +1,201 @@ +createdAt = microtime(true); + $this->lastUsedAt = $this->createdAt; + } + + /** + * Get the host this connection is for. + */ + public function getHost(): string + { + return $this->host; + } + + /** + * Get the port number. + */ + public function getPort(): int + { + return $this->port; + } + + /** + * Check if SSL is enabled. + */ + public function isSsl(): bool + { + return $this->ssl; + } + + /** + * Get the underlying HTTP client. + */ + public function getClient(): ?ClientInterface + { + return $this->client; + } + + /** + * Set the underlying HTTP client. + * + * @param ClientInterface $client The HTTP client + * @return $this + */ + public function setClient(ClientInterface $client): self + { + $this->client = $client; + + return $this; + } + + /** + * Get the timestamp when this connection was created. + */ + public function getCreatedAt(): float + { + return $this->createdAt; + } + + /** + * Get the timestamp when this connection was last used. + */ + public function getLastUsedAt(): float + { + return $this->lastUsedAt; + } + + /** + * Mark the connection as being used. + * + * @return $this + */ + public function markUsed(): self + { + $this->lastUsedAt = microtime(true); + + return $this; + } + + /** + * Get the number of active requests. + */ + public function getActiveRequestCount(): int + { + return $this->activeRequests; + } + + /** + * Increment the active request count. + * + * @return $this + */ + public function incrementActiveRequests(): self + { + $this->activeRequests++; + + return $this; + } + + /** + * Decrement the active request count. + * + * @return $this + */ + public function decrementActiveRequests(): self + { + if ($this->activeRequests > 0) { + $this->activeRequests--; + } + + return $this; + } + + /** + * Check if the connection is alive and usable. + */ + public function isAlive(): bool + { + return ! $this->closed && $this->client !== null; + } + + /** + * Check if the connection can be reused. + * + * @param int $keepAliveTimeout Keep-alive timeout in seconds + */ + public function isReusable(int $keepAliveTimeout = 30): bool + { + if (! $this->isAlive()) { + return false; + } + + $idleTime = microtime(true) - $this->lastUsedAt; + + return $idleTime < $keepAliveTimeout; + } + + /** + * Close the connection. + */ + public function close(): void + { + $this->closed = true; + $this->client = null; + } + + /** + * Get the connection key (host:port:ssl). + */ + public function getKey(): string + { + $scheme = $this->ssl ? 'https' : 'http'; + + return "{$scheme}://{$this->host}:{$this->port}"; + } +} diff --git a/src/Fetch/Pool/ConnectionPool.php b/src/Fetch/Pool/ConnectionPool.php new file mode 100644 index 0000000..a4f8cc5 --- /dev/null +++ b/src/Fetch/Pool/ConnectionPool.php @@ -0,0 +1,288 @@ + + */ + protected array $pools = []; + + /** + * Active connections indexed by object ID. + * + * @var array + */ + protected array $activeConnections = []; + + /** + * Connection metrics. + * + * @var array + */ + protected array $metrics = [ + 'connections_created' => 0, + 'connections_reused' => 0, + 'total_requests' => 0, + 'total_latency' => 0.0, + ]; + + /** + * Create a new connection pool manager. + * + * @param PoolConfiguration $config Pool configuration + */ + public function __construct( + protected PoolConfiguration $config, + ) {} + + /** + * Create a pool from a configuration array. + * + * @param array $config Configuration array + * @return static New pool instance + */ + public static function fromArray(array $config): static + { + return new static(PoolConfiguration::fromArray($config)); + } + + /** + * Get a connection for the specified host. + * + * @param string $host The host + * @param int $port The port + * @param bool $ssl Whether SSL is enabled + * @return Connection A connection to use + */ + public function getConnection(string $host, int $port = 80, bool $ssl = false): Connection + { + $key = $this->getPoolKey($host, $port, $ssl); + + if (! isset($this->pools[$key])) { + $this->pools[$key] = new HostConnectionPool( + host: $host, + port: $port, + ssl: $ssl, + config: $this->config, + ); + } + + $connection = $this->pools[$key]->borrowConnection(); + $this->activeConnections[spl_object_id($connection)] = $connection; + + // Update metrics + $this->metrics['total_requests']++; + + return $connection; + } + + /** + * Get a connection from a URL. + * + * @param string $url The URL to connect to + * @return Connection A connection to use + */ + public function getConnectionFromUrl(string $url): Connection + { + $parsed = parse_url($url); + + $host = $parsed['host'] ?? 'localhost'; + $ssl = ($parsed['scheme'] ?? 'http') === 'https'; + $defaultPort = $ssl ? 443 : 80; + $port = $parsed['port'] ?? $defaultPort; + + return $this->getConnection($host, $port, $ssl); + } + + /** + * Release a connection back to the pool. + * + * @param Connection $connection The connection to release + */ + public function releaseConnection(Connection $connection): void + { + $id = spl_object_id($connection); + + if (isset($this->activeConnections[$id])) { + unset($this->activeConnections[$id]); + + $key = $connection->getKey(); + $poolKey = $this->normalizePoolKey($key); + + if (isset($this->pools[$poolKey])) { + $this->pools[$poolKey]->returnConnection($connection); + $this->metrics['connections_reused']++; + } else { + $connection->close(); + } + } + } + + /** + * Close a specific connection. + * + * @param Connection $connection The connection to close + */ + public function closeConnection(Connection $connection): void + { + $id = spl_object_id($connection); + + if (isset($this->activeConnections[$id])) { + unset($this->activeConnections[$id]); + } + + $connection->close(); + } + + /** + * Get an HTTP client for the specified URL. + * + * This returns the underlying Guzzle client from the pooled connection. + * + * @param string $url The URL to connect to + * @return ClientInterface|null The HTTP client or null if not available + */ + public function getClientForUrl(string $url): ?ClientInterface + { + $connection = $this->getConnectionFromUrl($url); + + return $connection->getClient(); + } + + /** + * Record connection latency for metrics. + * + * @param string $host The host + * @param int $port The port + * @param float $latency Latency in milliseconds + */ + public function recordLatency(string $host, int $port, float $latency): void + { + $this->metrics['total_latency'] += $latency; + } + + /** + * Get pool statistics. + * + * @return array + */ + public function getStats(): array + { + $stats = [ + 'enabled' => $this->config->isEnabled(), + 'total_pools' => count($this->pools), + 'active_connections' => count($this->activeConnections), + 'connections_created' => $this->metrics['connections_created'], + 'connections_reused' => $this->metrics['connections_reused'], + 'total_requests' => $this->metrics['total_requests'], + 'average_latency' => $this->calculateAverageLatency(), + 'reuse_rate' => $this->calculateReuseRate(), + 'pools' => [], + ]; + + foreach ($this->pools as $key => $pool) { + $stats['pools'][$key] = $pool->getStats(); + } + + return $stats; + } + + /** + * Check if the pool is enabled. + */ + public function isEnabled(): bool + { + return $this->config->isEnabled(); + } + + /** + * Get the pool configuration. + */ + public function getConfig(): PoolConfiguration + { + return $this->config; + } + + /** + * Close all connections in all pools. + */ + public function closeAll(): void + { + foreach ($this->activeConnections as $connection) { + $connection->close(); + } + $this->activeConnections = []; + + foreach ($this->pools as $pool) { + $pool->closeAll(); + } + $this->pools = []; + } + + /** + * Get the pool key for a host:port:ssl combination. + * + * @param string $host The host + * @param int $port The port + * @param bool $ssl Whether SSL is enabled + * @return string The pool key + */ + protected function getPoolKey(string $host, int $port, bool $ssl): string + { + $scheme = $ssl ? 'https' : 'http'; + + return "{$scheme}://{$host}:{$port}"; + } + + /** + * Normalize a connection key to a pool key. + * + * @param string $key The connection key + * @return string The normalized pool key + */ + protected function normalizePoolKey(string $key): string + { + return $key; + } + + /** + * Calculate average latency across all requests. + * + * @return float Average latency in milliseconds + */ + protected function calculateAverageLatency(): float + { + $totalRequests = (int) $this->metrics['total_requests']; + if ($totalRequests === 0) { + return 0.0; + } + + return (float) $this->metrics['total_latency'] / $totalRequests; + } + + /** + * Calculate the connection reuse rate. + * + * @return float Reuse rate (0.0 to 1.0) + */ + protected function calculateReuseRate(): float + { + $totalRequests = (int) $this->metrics['total_requests']; + if ($totalRequests === 0) { + return 0.0; + } + + return (float) $this->metrics['connections_reused'] / $totalRequests; + } +} diff --git a/src/Fetch/Pool/DnsCache.php b/src/Fetch/Pool/DnsCache.php new file mode 100644 index 0000000..daabae3 --- /dev/null +++ b/src/Fetch/Pool/DnsCache.php @@ -0,0 +1,210 @@ +, expires_at: int}> + */ + protected array $cache = []; + + /** + * Create a new DNS cache instance. + * + * @param int $ttl Time-to-live for cached entries in seconds + */ + public function __construct( + protected int $ttl = 300, + ) {} + + /** + * Resolve a hostname to IP addresses. + * + * @param string $hostname The hostname to resolve + * @return array Array of IP addresses + * + * @throws NetworkException If DNS resolution fails + */ + public function resolve(string $hostname): array + { + $cacheKey = $hostname; + + // Check cache first + if (isset($this->cache[$cacheKey]) && ! $this->isExpired($this->cache[$cacheKey])) { + return $this->cache[$cacheKey]['addresses']; + } + + // Perform DNS lookup + $addresses = $this->performDnsLookup($hostname); + + // Cache the result + $this->cache[$cacheKey] = [ + 'addresses' => $addresses, + 'expires_at' => time() + $this->ttl, + ]; + + return $addresses; + } + + /** + * Get the first resolved IP address for a hostname. + * + * @param string $hostname The hostname to resolve + * @return string The first IP address + * + * @throws NetworkException If DNS resolution fails + */ + public function resolveFirst(string $hostname): string + { + $addresses = $this->resolve($hostname); + + return $addresses[0]; + } + + /** + * Clear the cache for a specific hostname or all hostnames. + * + * @param string|null $hostname Hostname to clear, or null for all + */ + public function clear(?string $hostname = null): void + { + if ($hostname === null) { + $this->cache = []; + } else { + unset($this->cache[$hostname]); + } + } + + /** + * Get cache statistics. + * + * @return array + */ + public function getStats(): array + { + $validEntries = 0; + $expiredEntries = 0; + + foreach ($this->cache as $entry) { + if ($this->isExpired($entry)) { + $expiredEntries++; + } else { + $validEntries++; + } + } + + return [ + 'total_entries' => count($this->cache), + 'valid_entries' => $validEntries, + 'expired_entries' => $expiredEntries, + 'ttl' => $this->ttl, + ]; + } + + /** + * Set the TTL for new cache entries. + * + * @param int $ttl Time-to-live in seconds + * @return $this + */ + public function setTtl(int $ttl): self + { + $this->ttl = $ttl; + + return $this; + } + + /** + * Prune expired entries from the cache. + * + * @return int Number of entries removed + */ + public function prune(): int + { + $removed = 0; + + foreach ($this->cache as $key => $entry) { + if ($this->isExpired($entry)) { + unset($this->cache[$key]); + $removed++; + } + } + + return $removed; + } + + /** + * Check if a cache entry is expired. + * + * @param array{addresses: array, expires_at: int} $entry Cache entry + * @return bool Whether the entry is expired + */ + protected function isExpired(array $entry): bool + { + return time() >= $entry['expires_at']; + } + + /** + * Perform actual DNS lookup. + * + * @param string $hostname The hostname to resolve + * @return array Array of IP addresses + * + * @throws NetworkException If DNS resolution fails + */ + protected function performDnsLookup(string $hostname): array + { + $addresses = []; + + // Try IPv4 (A records) + $ipv4 = @dns_get_record($hostname, DNS_A); + if ($ipv4 !== false) { + foreach ($ipv4 as $record) { + if (isset($record['ip'])) { + $addresses[] = $record['ip']; + } + } + } + + // Try IPv6 (AAAA records) + $ipv6 = @dns_get_record($hostname, DNS_AAAA); + if ($ipv6 !== false) { + foreach ($ipv6 as $record) { + if (isset($record['ipv6'])) { + $addresses[] = $record['ipv6']; + } + } + } + + // If no DNS records found, try gethostbyname + if (empty($addresses)) { + $ip = gethostbyname($hostname); + // gethostbyname returns the hostname unchanged if resolution fails + if ($ip !== $hostname) { + $addresses[] = $ip; + } + } + + if (empty($addresses)) { + // Encode hostname for use in URL (handle IDN and special characters) + $safeHost = rawurlencode($hostname); + throw new NetworkException( + "Failed to resolve hostname: {$hostname}", + new Request('GET', "https://{$safeHost}/") + ); + } + + return $addresses; + } +} diff --git a/src/Fetch/Pool/HostConnectionPool.php b/src/Fetch/Pool/HostConnectionPool.php new file mode 100644 index 0000000..c3bbc8e --- /dev/null +++ b/src/Fetch/Pool/HostConnectionPool.php @@ -0,0 +1,220 @@ + + */ + protected SplQueue $availableConnections; + + /** + * Total connections created for this host. + */ + protected int $totalCreated = 0; + + /** + * Total connections borrowed from this pool. + */ + protected int $totalBorrowed = 0; + + /** + * Total connections returned to this pool. + */ + protected int $totalReturned = 0; + + /** + * Create a new host connection pool. + * + * @param string $host The host + * @param int $port The port + * @param bool $ssl Whether SSL is enabled + * @param PoolConfiguration $config Pool configuration + */ + public function __construct( + protected string $host, + protected int $port, + protected bool $ssl, + protected PoolConfiguration $config, + ) { + /** @var SplQueue $queue */ + $queue = new SplQueue; + $this->availableConnections = $queue; + + if ($this->config->isConnectionWarmupEnabled()) { + $this->warmupConnections(); + } + } + + /** + * Borrow a connection from the pool. + * + * @return Connection A connection to use + */ + public function borrowConnection(): Connection + { + $this->totalBorrowed++; + + // Try to get an existing connection from the pool + while (! $this->availableConnections->isEmpty()) { + $connection = $this->availableConnections->dequeue(); + + if ($connection->isReusable($this->config->getKeepAliveTimeout())) { + $connection->markUsed(); + $connection->incrementActiveRequests(); + + return $connection; + } + + // Connection is stale, close it + $connection->close(); + } + + // No available connections, create a new one + return $this->createConnection(); + } + + /** + * Return a connection to the pool. + * + * @param Connection $connection The connection to return + */ + public function returnConnection(Connection $connection): void + { + $this->totalReturned++; + $connection->decrementActiveRequests(); + + // Only keep connections that are still reusable and within limits + if ($connection->isReusable($this->config->getKeepAliveTimeout()) + && $this->availableConnections->count() < $this->config->getMaxIdlePerHost()) { + $this->availableConnections->enqueue($connection); + } else { + $connection->close(); + } + } + + /** + * Get the number of available connections. + */ + public function getAvailableCount(): int + { + return $this->availableConnections->count(); + } + + /** + * Get pool statistics. + * + * @return array + */ + public function getStats(): array + { + return [ + 'host' => $this->host, + 'port' => $this->port, + 'ssl' => $this->ssl, + 'available' => $this->availableConnections->count(), + 'total_created' => $this->totalCreated, + 'total_borrowed' => $this->totalBorrowed, + 'total_returned' => $this->totalReturned, + 'success_rate' => $this->totalBorrowed > 0 + ? $this->totalReturned / $this->totalBorrowed + : 1.0, + ]; + } + + /** + * Close all connections in the pool. + */ + public function closeAll(): void + { + while (! $this->availableConnections->isEmpty()) { + $connection = $this->availableConnections->dequeue(); + $connection->close(); + } + } + + /** + * Create a new connection. + * + * @return Connection The new connection + */ + protected function createConnection(): Connection + { + $this->totalCreated++; + + $connection = new Connection( + host: $this->host, + port: $this->port, + ssl: $this->ssl, + ); + + $client = $this->createHttpClient(); + $connection->setClient($client); + $connection->incrementActiveRequests(); + + return $connection; + } + + /** + * Create an HTTP client for this host. + * + * @return ClientInterface The HTTP client + */ + protected function createHttpClient(): ClientInterface + { + $scheme = $this->ssl ? 'https' : 'http'; + $baseUri = "{$scheme}://{$this->host}:{$this->port}"; + + return new GuzzleClient([ + 'base_uri' => $baseUri, + RequestOptions::CONNECT_TIMEOUT => $this->config->getConnectionTimeout(), + RequestOptions::HTTP_ERRORS => false, + // Configure TCP keep-alive for connection health monitoring + 'curl' => [ + CURLOPT_TCP_KEEPALIVE => 1, + CURLOPT_TCP_KEEPIDLE => $this->config->getKeepAliveTimeout(), + CURLOPT_TCP_KEEPINTVL => self::DEFAULT_TCP_KEEPALIVE_INTERVAL, + ], + ]); + } + + /** + * Pre-warm connections for this host. + */ + protected function warmupConnections(): void + { + $warmupCount = min( + $this->config->getWarmupConnections(), + $this->config->getMaxPerHost() + ); + + for ($i = 0; $i < $warmupCount; $i++) { + try { + $connection = $this->createConnection(); + $connection->decrementActiveRequests(); // Was incremented in createConnection + $this->availableConnections->enqueue($connection); + } catch (\Throwable) { + // Warmup failure is not critical - stop warming up but continue + break; + } + } + } +} diff --git a/src/Fetch/Pool/Http2Configuration.php b/src/Fetch/Pool/Http2Configuration.php new file mode 100644 index 0000000..93b7849 --- /dev/null +++ b/src/Fetch/Pool/Http2Configuration.php @@ -0,0 +1,163 @@ + $config Configuration array + * @return static New configuration instance + */ + public static function fromArray(array $config): static + { + return new static( + enabled: (bool) ($config['enabled'] ?? true), + maxConcurrentStreams: (int) ($config['max_concurrent_streams'] ?? self::DEFAULT_MAX_CONCURRENT_STREAMS), + windowSize: (int) ($config['window_size'] ?? self::DEFAULT_WINDOW_SIZE), + headerTableSize: (int) ($config['header_table_size'] ?? self::DEFAULT_HEADER_TABLE_SIZE), + enableServerPush: (bool) ($config['enable_server_push'] ?? false), + streamPrioritization: (bool) ($config['stream_prioritization'] ?? false), + ); + } + + /** + * Check if HTTP/2 is enabled. + */ + public function isEnabled(): bool + { + return $this->enabled; + } + + /** + * Get maximum concurrent streams. + */ + public function getMaxConcurrentStreams(): int + { + return $this->maxConcurrentStreams; + } + + /** + * Get window size for flow control. + */ + public function getWindowSize(): int + { + return $this->windowSize; + } + + /** + * Get header compression table size. + */ + public function getHeaderTableSize(): int + { + return $this->headerTableSize; + } + + /** + * Check if server push is enabled. + */ + public function isServerPushEnabled(): bool + { + return $this->enableServerPush; + } + + /** + * Check if stream prioritization is enabled. + */ + public function isStreamPrioritizationEnabled(): bool + { + return $this->streamPrioritization; + } + + /** + * Get cURL options for HTTP/2. + * + * @return array + */ + public function getCurlOptions(): array + { + $options = []; + + if ($this->enabled) { + // Use HTTP/2 with automatic fallback + $options[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_2_0; + } + + return $options; + } + + /** + * Get cURL multi options for HTTP/2 multiplexing. + * + * These options should be used with curl_multi_setopt(). + * + * @return array + */ + public function getCurlMultiOptions(): array + { + $options = []; + + if ($this->enabled && defined('CURLPIPE_MULTIPLEX')) { + $options[CURLMOPT_PIPELINING] = CURLPIPE_MULTIPLEX; + } + + return $options; + } + + /** + * Convert configuration to array. + * + * @return array + */ + public function toArray(): array + { + return [ + 'enabled' => $this->enabled, + 'max_concurrent_streams' => $this->maxConcurrentStreams, + 'window_size' => $this->windowSize, + 'header_table_size' => $this->headerTableSize, + 'enable_server_push' => $this->enableServerPush, + 'stream_prioritization' => $this->streamPrioritization, + ]; + } +} diff --git a/src/Fetch/Pool/PoolConfiguration.php b/src/Fetch/Pool/PoolConfiguration.php new file mode 100644 index 0000000..01965ed --- /dev/null +++ b/src/Fetch/Pool/PoolConfiguration.php @@ -0,0 +1,196 @@ + $config Configuration array + * @return static New configuration instance + */ + public static function fromArray(array $config): static + { + return new static( + enabled: (bool) ($config['enabled'] ?? true), + maxConnections: (int) ($config['max_connections'] ?? self::DEFAULT_MAX_CONNECTIONS), + maxPerHost: (int) ($config['max_per_host'] ?? self::DEFAULT_MAX_PER_HOST), + maxIdlePerHost: (int) ($config['max_idle_per_host'] ?? self::DEFAULT_MAX_IDLE_PER_HOST), + keepAliveTimeout: (int) ($config['keep_alive_timeout'] ?? self::DEFAULT_KEEP_ALIVE_TIMEOUT), + connectionTimeout: (int) ($config['connection_timeout'] ?? self::DEFAULT_CONNECTION_TIMEOUT), + strategy: (string) ($config['strategy'] ?? 'least_connections'), + connectionWarmup: (bool) ($config['connection_warmup'] ?? false), + warmupConnections: (int) ($config['warmup_connections'] ?? self::DEFAULT_WARMUP_CONNECTIONS), + dnsCacheTtl: (int) ($config['dns_cache_ttl'] ?? self::DEFAULT_DNS_CACHE_TTL), + ); + } + + /** + * Check if connection pooling is enabled. + */ + public function isEnabled(): bool + { + return $this->enabled; + } + + /** + * Get maximum total connections. + */ + public function getMaxConnections(): int + { + return $this->maxConnections; + } + + /** + * Get maximum connections per host. + */ + public function getMaxPerHost(): int + { + return $this->maxPerHost; + } + + /** + * Get maximum idle connections per host. + */ + public function getMaxIdlePerHost(): int + { + return $this->maxIdlePerHost; + } + + /** + * Get keep-alive timeout in seconds. + */ + public function getKeepAliveTimeout(): int + { + return $this->keepAliveTimeout; + } + + /** + * Get connection timeout in seconds. + */ + public function getConnectionTimeout(): int + { + return $this->connectionTimeout; + } + + /** + * Get connection selection strategy. + */ + public function getStrategy(): string + { + return $this->strategy; + } + + /** + * Check if connection warmup is enabled. + */ + public function isConnectionWarmupEnabled(): bool + { + return $this->connectionWarmup; + } + + /** + * Get number of connections to pre-warm. + */ + public function getWarmupConnections(): int + { + return $this->warmupConnections; + } + + /** + * Get DNS cache TTL in seconds. + */ + public function getDnsCacheTtl(): int + { + return $this->dnsCacheTtl; + } + + /** + * Convert configuration to array. + * + * @return array + */ + public function toArray(): array + { + return [ + 'enabled' => $this->enabled, + 'max_connections' => $this->maxConnections, + 'max_per_host' => $this->maxPerHost, + 'max_idle_per_host' => $this->maxIdlePerHost, + 'keep_alive_timeout' => $this->keepAliveTimeout, + 'connection_timeout' => $this->connectionTimeout, + 'strategy' => $this->strategy, + 'connection_warmup' => $this->connectionWarmup, + 'warmup_connections' => $this->warmupConnections, + 'dns_cache_ttl' => $this->dnsCacheTtl, + ]; + } +} diff --git a/tests/Unit/CacheTest.php b/tests/Unit/CacheTest.php new file mode 100644 index 0000000..5dbc55a --- /dev/null +++ b/tests/Unit/CacheTest.php @@ -0,0 +1,490 @@ +testCacheDir = sys_get_temp_dir().'/fetch-cache-test-'.uniqid(); + } + + protected function tearDown(): void + { + parent::tearDown(); + // Clean up test cache directory + if (is_dir($this->testCacheDir)) { + $files = glob($this->testCacheDir.'/*'); + if ($files) { + foreach ($files as $file) { + @unlink($file); + } + } + @rmdir($this->testCacheDir); + } + } + + // ==================== CacheControl Tests ==================== + + public function test_parse_cache_control_header(): void + { + $cc = CacheControl::parse('max-age=3600, must-revalidate, private'); + + $this->assertEquals(3600, $cc->getMaxAge()); + $this->assertTrue($cc->mustRevalidate()); + $this->assertTrue($cc->isPrivate()); + $this->assertFalse($cc->isPublic()); + } + + public function test_parse_cache_control_with_s_maxage(): void + { + $cc = CacheControl::parse('max-age=3600, s-maxage=7200, public'); + + $this->assertEquals(3600, $cc->getMaxAge()); + $this->assertEquals(7200, $cc->getSharedMaxAge()); + $this->assertTrue($cc->isPublic()); + } + + public function test_parse_cache_control_no_store(): void + { + $cc = CacheControl::parse('no-store, no-cache'); + + $this->assertTrue($cc->hasNoStore()); + $this->assertTrue($cc->hasNoCache()); + } + + public function test_parse_cache_control_stale_directives(): void + { + $cc = CacheControl::parse('max-age=3600, stale-while-revalidate=300, stale-if-error=86400'); + + $this->assertEquals(300, $cc->getStaleWhileRevalidate()); + $this->assertEquals(86400, $cc->getStaleIfError()); + } + + public function test_cache_control_should_cache(): void + { + $cc = CacheControl::parse('max-age=3600'); + $response = new Response(200, [], 'test'); + + $this->assertTrue($cc->shouldCache($response, false)); + $this->assertTrue($cc->shouldCache($response, true)); + } + + public function test_cache_control_should_not_cache_no_store(): void + { + $cc = CacheControl::parse('no-store'); + $response = new Response(200, [], 'test'); + + $this->assertFalse($cc->shouldCache($response, false)); + } + + public function test_cache_control_should_not_cache_private_in_shared(): void + { + $cc = CacheControl::parse('private, max-age=3600'); + $response = new Response(200, [], 'test'); + + $this->assertTrue($cc->shouldCache($response, false)); // Private cache is OK + $this->assertFalse($cc->shouldCache($response, true)); // Shared cache should not cache + } + + public function test_cache_control_get_ttl(): void + { + $cc = CacheControl::parse('max-age=3600'); + $response = new Response(200, [], 'test'); + + $this->assertEquals(3600, $cc->getTtl($response, false)); + } + + public function test_cache_control_get_ttl_shared_uses_s_maxage(): void + { + $cc = CacheControl::parse('max-age=3600, s-maxage=1800'); + $response = new Response(200, [], 'test'); + + $this->assertEquals(1800, $cc->getTtl($response, true)); + $this->assertEquals(3600, $cc->getTtl($response, false)); + } + + public function test_cache_control_build(): void + { + $header = CacheControl::build([ + 'max-age' => 3600, + 'public' => true, + 'must-revalidate' => true, + ]); + + $this->assertStringContainsString('max-age=3600', $header); + $this->assertStringContainsString('public', $header); + $this->assertStringContainsString('must-revalidate', $header); + } + + // ==================== CacheKeyGenerator Tests ==================== + + public function test_generate_cache_key(): void + { + $gen = new CacheKeyGenerator; + + $key1 = $gen->generate('GET', 'https://api.example.com/users'); + $key2 = $gen->generate('GET', 'https://api.example.com/users'); + $key3 = $gen->generate('GET', 'https://api.example.com/posts'); + + $this->assertEquals($key1, $key2); + $this->assertNotEquals($key1, $key3); + } + + public function test_generate_cache_key_different_methods(): void + { + $gen = new CacheKeyGenerator; + + $key1 = $gen->generate('GET', 'https://api.example.com/users'); + $key2 = $gen->generate('POST', 'https://api.example.com/users'); + + $this->assertNotEquals($key1, $key2); + } + + public function test_generate_cache_key_with_query_params(): void + { + $gen = new CacheKeyGenerator; + + $key1 = $gen->generate('GET', 'https://api.example.com/users', ['query' => ['page' => 1]]); + $key2 = $gen->generate('GET', 'https://api.example.com/users', ['query' => ['page' => 2]]); + + $this->assertNotEquals($key1, $key2); + } + + public function test_generate_custom_cache_key(): void + { + $gen = new CacheKeyGenerator; + + $key = $gen->generateCustom('my-custom-key'); + + $this->assertEquals('fetch:my-custom-key', $key); + } + + // ==================== CachedResponse Tests ==================== + + public function test_cached_response_from_response(): void + { + $response = new Response( + 200, + ['Content-Type' => 'application/json', 'ETag' => '"abc123"', 'Last-Modified' => 'Thu, 01 Jan 2020 00:00:00 GMT'], + '{"data": "test"}' + ); + + $cached = CachedResponse::fromResponse($response, 3600); + + $this->assertEquals(200, $cached->getStatusCode()); + $this->assertEquals('{"data": "test"}', $cached->getBody()); + $this->assertEquals('"abc123"', $cached->getETag()); + $this->assertEquals('Thu, 01 Jan 2020 00:00:00 GMT', $cached->getLastModified()); + } + + public function test_cached_response_is_fresh(): void + { + $response = new Response(200, [], 'test'); + $cached = CachedResponse::fromResponse($response, 3600); + + $this->assertTrue($cached->isFresh()); + $this->assertFalse($cached->isExpired()); + } + + public function test_cached_response_is_expired(): void + { + // Create a cached response that expired 1 second ago + $cached = new CachedResponse( + statusCode: 200, + headers: [], + body: 'test', + createdAt: time() - 3601, + expiresAt: time() - 1 + ); + + $this->assertFalse($cached->isFresh()); + $this->assertTrue($cached->isExpired()); + } + + public function test_cached_response_get_age(): void + { + $createdAt = time() - 100; + $cached = new CachedResponse( + statusCode: 200, + headers: [], + body: 'test', + createdAt: $createdAt + ); + + $age = $cached->getAge(); + $this->assertGreaterThanOrEqual(100, $age); + $this->assertLessThanOrEqual(102, $age); // Allow 2 seconds for test execution + } + + public function test_cached_response_serialize_and_deserialize(): void + { + $response = new Response( + 200, + ['Content-Type' => 'application/json', 'ETag' => '"abc123"'], + '{"data": "test"}' + ); + $cached = CachedResponse::fromResponse($response, 3600); + + $data = $cached->toArray(); + $restored = CachedResponse::fromArray($data); + + $this->assertNotNull($restored); + $this->assertEquals($cached->getStatusCode(), $restored->getStatusCode()); + $this->assertEquals($cached->getBody(), $restored->getBody()); + $this->assertEquals($cached->getETag(), $restored->getETag()); + } + + public function test_cached_response_is_usable_as_stale(): void + { + // Create a cached response that expired 30 seconds ago + $cached = new CachedResponse( + statusCode: 200, + headers: [], + body: 'test', + createdAt: time() - 3630, + expiresAt: time() - 30 + ); + + // Should be usable if stale period is 60 seconds + $this->assertTrue($cached->isUsableAsStale(60)); + + // Should not be usable if stale period is 10 seconds + $this->assertFalse($cached->isUsableAsStale(10)); + } + + // ==================== MemoryCache Tests ==================== + + public function test_memory_cache_set_and_get(): void + { + $cache = new MemoryCache; + $response = new Response(200, [], 'test'); + $cached = CachedResponse::fromResponse($response, 3600); + + $cache->set('test-key', $cached); + $retrieved = $cache->get('test-key'); + + $this->assertNotNull($retrieved); + $this->assertEquals($cached->getBody(), $retrieved->getBody()); + } + + public function test_memory_cache_has(): void + { + $cache = new MemoryCache; + $response = new Response(200, [], 'test'); + $cached = CachedResponse::fromResponse($response, 3600); + + $this->assertFalse($cache->has('test-key')); + + $cache->set('test-key', $cached); + $this->assertTrue($cache->has('test-key')); + } + + public function test_memory_cache_delete(): void + { + $cache = new MemoryCache; + $response = new Response(200, [], 'test'); + $cached = CachedResponse::fromResponse($response, 3600); + + $cache->set('test-key', $cached); + $this->assertTrue($cache->has('test-key')); + + $result = $cache->delete('test-key'); + $this->assertTrue($result); + $this->assertFalse($cache->has('test-key')); + } + + public function test_memory_cache_clear(): void + { + $cache = new MemoryCache; + $response = new Response(200, [], 'test'); + + $cache->set('key1', CachedResponse::fromResponse($response, 3600)); + $cache->set('key2', CachedResponse::fromResponse($response, 3600)); + + $this->assertEquals(2, $cache->count()); + + $cache->clear(); + $this->assertEquals(0, $cache->count()); + } + + public function test_memory_cache_max_items(): void + { + $cache = new MemoryCache(maxItems: 2); + $response = new Response(200, [], 'test'); + + $cache->set('key1', CachedResponse::fromResponse($response, 3600)); + $cache->set('key2', CachedResponse::fromResponse($response, 3600)); + $cache->set('key3', CachedResponse::fromResponse($response, 3600)); + + // Should only have 2 items (oldest evicted) + $this->assertEquals(2, $cache->count()); + } + + public function test_memory_cache_expired_items_not_returned(): void + { + $cache = new MemoryCache; + + // Create an already expired cached response with TTL of 0 (immediate expiration) + $cached = new CachedResponse( + statusCode: 200, + headers: [], + body: 'test', + createdAt: time() - 100, + expiresAt: time() - 1 // Already expired + ); + + // Set with a very short TTL (but the cache uses its own TTL calculation) + // To test expired items, we need to directly manipulate the cache internals + // or use the internal expiration check + $cache->set('expired-key', $cached, -1); // TTL of -1 means already expired + $this->assertNull($cache->get('expired-key')); + } + + public function test_memory_cache_prune(): void + { + $cache = new MemoryCache; + + // Add an expired item using TTL of -1 + $expired = new CachedResponse( + statusCode: 200, + headers: [], + body: 'expired', + createdAt: time() - 100, + expiresAt: time() - 1 + ); + $cache->set('expired', $expired, -1); + + // Add a fresh item + $fresh = CachedResponse::fromResponse(new Response(200, [], 'fresh'), 3600); + $cache->set('fresh', $fresh, 3600); + + $pruned = $cache->prune(); + $this->assertEquals(1, $pruned); + $this->assertEquals(1, $cache->count()); + } + + // ==================== FileCache Tests ==================== + + public function test_file_cache_set_and_get(): void + { + $cache = new FileCache($this->testCacheDir); + $response = new Response(200, [], 'test'); + $cached = CachedResponse::fromResponse($response, 3600); + + $cache->set('test-key', $cached); + $retrieved = $cache->get('test-key'); + + $this->assertNotNull($retrieved); + $this->assertEquals($cached->getBody(), $retrieved->getBody()); + } + + public function test_file_cache_has(): void + { + $cache = new FileCache($this->testCacheDir); + $response = new Response(200, [], 'test'); + $cached = CachedResponse::fromResponse($response, 3600); + + $this->assertFalse($cache->has('test-key')); + + $cache->set('test-key', $cached); + $this->assertTrue($cache->has('test-key')); + } + + public function test_file_cache_delete(): void + { + $cache = new FileCache($this->testCacheDir); + $response = new Response(200, [], 'test'); + $cached = CachedResponse::fromResponse($response, 3600); + + $cache->set('test-key', $cached); + $this->assertTrue($cache->has('test-key')); + + $result = $cache->delete('test-key'); + $this->assertTrue($result); + $this->assertFalse($cache->has('test-key')); + } + + public function test_file_cache_clear(): void + { + $cache = new FileCache($this->testCacheDir); + $response = new Response(200, [], 'test'); + + $cache->set('key1', CachedResponse::fromResponse($response, 3600)); + $cache->set('key2', CachedResponse::fromResponse($response, 3600)); + + $stats = $cache->getStats(); + $this->assertEquals(2, $stats['items']); + + $cache->clear(); + $stats = $cache->getStats(); + $this->assertEquals(0, $stats['items']); + } + + public function test_file_cache_expired_items_not_returned(): void + { + $cache = new FileCache($this->testCacheDir); + + // Create an already expired cached response + $cached = new CachedResponse( + statusCode: 200, + headers: [], + body: 'test', + createdAt: time() - 100, + expiresAt: time() - 1 // Already expired + ); + + $cache->set('expired-key', $cached); + $this->assertNull($cache->get('expired-key')); + } + + public function test_file_cache_prune(): void + { + $cache = new FileCache($this->testCacheDir); + + // Add an expired item + $expired = new CachedResponse( + statusCode: 200, + headers: [], + body: 'expired', + createdAt: time() - 100, + expiresAt: time() - 1 + ); + // Directly write to bypass expiration check in set + $key = hash('sha256', 'expired').'.cache'; + file_put_contents($this->testCacheDir.'/'.$key, json_encode($expired->toArray())); + + // Add a fresh item + $fresh = CachedResponse::fromResponse(new Response(200, [], 'fresh'), 3600); + $cache->set('fresh', $fresh); + + $pruned = $cache->prune(); + $this->assertEquals(1, $pruned); + } + + public function test_file_cache_get_stats(): void + { + $cache = new FileCache($this->testCacheDir); + $response = new Response(200, [], 'test'); + + $cache->set('key1', CachedResponse::fromResponse($response, 3600)); + + $stats = $cache->getStats(); + $this->assertEquals($this->testCacheDir, $stats['directory']); + $this->assertEquals(1, $stats['items']); + $this->assertGreaterThan(0, $stats['size']); + } +} diff --git a/tests/Unit/ClientHandlerCacheTest.php b/tests/Unit/ClientHandlerCacheTest.php new file mode 100644 index 0000000..9190dd5 --- /dev/null +++ b/tests/Unit/ClientHandlerCacheTest.php @@ -0,0 +1,341 @@ + $handlerStack]); + + return ClientHandler::createWithClient($client); + } + + public function test_with_cache_enables_caching(): void + { + $handler = ClientHandler::create(); + + $this->assertFalse($handler->isCacheEnabled()); + $this->assertNull($handler->getCache()); + + $handler->withCache(); + + $this->assertTrue($handler->isCacheEnabled()); + $this->assertInstanceOf(MemoryCache::class, $handler->getCache()); + } + + public function test_with_cache_custom_backend(): void + { + $cache = new MemoryCache(maxItems: 50); + $handler = ClientHandler::create(); + + $handler->withCache($cache); + + $this->assertSame($cache, $handler->getCache()); + } + + public function test_without_cache_disables_caching(): void + { + $handler = ClientHandler::create(); + $handler->withCache(); + + $this->assertTrue($handler->isCacheEnabled()); + + $handler->withoutCache(); + + $this->assertFalse($handler->isCacheEnabled()); + $this->assertNull($handler->getCache()); + } + + public function test_caches_get_requests(): void + { + $responses = [ + new GuzzleResponse(200, ['Content-Type' => 'application/json'], '{"data":"first"}'), + new GuzzleResponse(200, ['Content-Type' => 'application/json'], '{"data":"second"}'), + ]; + + $handler = $this->create_handler_with_mock_responses($responses); + $handler->baseUri('https://api.example.com'); + $handler->withCache(); + + // First request - should get first response + $response1 = $handler->get('/users'); + $this->assertEquals('{"data":"first"}', $response1->body()); + + // Second request - should get cached response (first response) + $response2 = $handler->get('/users'); + $this->assertEquals('{"data":"first"}', $response2->body()); + $this->assertEquals('HIT', $response2->getHeaderLine('X-Cache-Status')); + } + + public function test_cache_miss_returns_successful_response(): void + { + $responses = [ + new GuzzleResponse(200, ['Content-Type' => 'application/json'], '{"data":"test"}'), + ]; + + $handler = $this->create_handler_with_mock_responses($responses); + $handler->baseUri('https://api.example.com'); + $handler->withCache(); + + $response = $handler->get('/users'); + + // First request is a cache miss, response should be successful + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('{"data":"test"}', $response->body()); + } + + public function test_does_not_cache_post_requests_by_default(): void + { + $responses = [ + new GuzzleResponse(200, ['Content-Type' => 'application/json'], '{"data":"first"}'), + new GuzzleResponse(200, ['Content-Type' => 'application/json'], '{"data":"second"}'), + ]; + + $handler = $this->create_handler_with_mock_responses($responses); + $handler->baseUri('https://api.example.com'); + $handler->withCache(); + + // First POST request + $response1 = $handler->post('/users', ['name' => 'John']); + $this->assertEquals('{"data":"first"}', $response1->body()); + + // Second POST request - should NOT be cached + $response2 = $handler->post('/users', ['name' => 'John']); + $this->assertEquals('{"data":"second"}', $response2->body()); + } + + public function test_respects_no_store_cache_control(): void + { + $responses = [ + new GuzzleResponse(200, ['Content-Type' => 'application/json', 'Cache-Control' => 'no-store'], '{"data":"first"}'), + new GuzzleResponse(200, ['Content-Type' => 'application/json'], '{"data":"second"}'), + ]; + + $handler = $this->create_handler_with_mock_responses($responses); + $handler->baseUri('https://api.example.com'); + $handler->withCache(); + + // First request - no-store response + $response1 = $handler->get('/users'); + $this->assertEquals('{"data":"first"}', $response1->body()); + + // Second request - should NOT be cached, get fresh response + $response2 = $handler->get('/users'); + $this->assertEquals('{"data":"second"}', $response2->body()); + } + + public function test_respects_custom_ttl_in_options(): void + { + $cache = new MemoryCache; + $responses = [ + new GuzzleResponse(200, ['Content-Type' => 'application/json'], '{"data":"test"}'), + ]; + + $handler = $this->create_handler_with_mock_responses($responses); + $handler->baseUri('https://api.example.com'); + $handler->withCache($cache, ['default_ttl' => 60]); + + $response = $handler->get('/users'); + + // Verify the response was cached + $this->assertTrue($handler->isCacheEnabled()); + } + + public function test_force_refresh_bypasses_cache(): void + { + $responses = [ + new GuzzleResponse(200, ['Content-Type' => 'application/json'], '{"data":"first"}'), + new GuzzleResponse(200, ['Content-Type' => 'application/json'], '{"data":"second"}'), + ]; + + $handler = $this->create_handler_with_mock_responses($responses); + $handler->baseUri('https://api.example.com'); + $handler->withCache(); + + // First request - cache it + $response1 = $handler->get('/users'); + $this->assertEquals('{"data":"first"}', $response1->body()); + + // Second request with force_refresh - should bypass cache + $response2 = $handler->sendRequest('GET', '/users', ['cache' => ['force_refresh' => true]]); + $this->assertEquals('{"data":"second"}', $response2->body()); + } + + public function test_different_query_params_different_cache_keys(): void + { + $responses = [ + new GuzzleResponse(200, ['Content-Type' => 'application/json'], '{"page":1}'), + new GuzzleResponse(200, ['Content-Type' => 'application/json'], '{"page":2}'), + ]; + + $handler = $this->create_handler_with_mock_responses($responses); + $handler->baseUri('https://api.example.com'); + $handler->withCache(); + + // First request - page 1 + $response1 = $handler->get('/users', ['page' => 1]); + $this->assertEquals('{"page":1}', $response1->body()); + + // Second request - page 2 (different cache key) + $response2 = $handler->get('/users', ['page' => 2]); + $this->assertEquals('{"page":2}', $response2->body()); + } + + public function test_handles_etag_conditional_requests(): void + { + $responses = [ + new GuzzleResponse(200, [ + 'Content-Type' => 'application/json', + 'ETag' => '"version1"', + ], '{"data":"original"}'), + new GuzzleResponse(304, [], ''), // Not Modified + ]; + + $handler = $this->create_handler_with_mock_responses($responses); + $handler->baseUri('https://api.example.com'); + $handler->withCache(); + + // First request - stores ETag + $response1 = $handler->get('/users'); + $this->assertEquals('{"data":"original"}', $response1->body()); + $this->assertEquals('"version1"', $response1->getHeaderLine('ETag')); + + // We need to manually expire the cache to trigger conditional request + // For this test, we'll verify the cache contains the ETag + $cache = $handler->getCache(); + $this->assertNotNull($cache); + } + + public function test_does_not_cache_non_cacheable_status_codes(): void + { + // 206 Partial Content is cacheable by default + // Let's configure the cache to NOT cache 206 and test it + $responses = [ + new GuzzleResponse(206, ['Content-Type' => 'application/json'], '{"partial":"data"}'), + new GuzzleResponse(200, ['Content-Type' => 'application/json'], '{"data":"success"}'), + ]; + + $handler = $this->create_handler_with_mock_responses($responses); + $handler->baseUri('https://api.example.com'); + // Configure cache to NOT cache 206 + $handler->withCache(null, ['cache_status_codes' => [200, 203, 204, 300, 301]]); + + // First request - 206 response (not cacheable by our config) + $response1 = $handler->get('/users'); + $this->assertEquals(206, $response1->getStatusCode()); + + // Second request - should get fresh response (not cached) + $response2 = $handler->get('/users'); + $this->assertEquals(200, $response2->getStatusCode()); + $this->assertEquals('{"data":"success"}', $response2->body()); + } + + public function test_cache_with_custom_vary_headers(): void + { + $responses = [ + new GuzzleResponse(200, ['Content-Type' => 'application/json'], '{"lang":"en"}'), + new GuzzleResponse(200, ['Content-Type' => 'application/json'], '{"lang":"fr"}'), + new GuzzleResponse(200, ['Content-Type' => 'application/json'], '{"lang":"en-cached"}'), + ]; + + $handler = $this->create_handler_with_mock_responses($responses); + $handler->baseUri('https://api.example.com'); + $handler->withCache(null, ['vary_headers' => ['Accept-Language']]); + + // Request with English + $response1 = $handler->withHeaders(['Accept-Language' => 'en'])->get('/content'); + $this->assertEquals('{"lang":"en"}', $response1->body()); + + // Request with French - different cache key due to vary header + $handler2 = $this->create_handler_with_mock_responses([ + new GuzzleResponse(200, ['Content-Type' => 'application/json'], '{"lang":"fr"}'), + ]); + $handler2->baseUri('https://api.example.com'); + $handler2->withCache($handler->getCache(), ['vary_headers' => ['Accept-Language']]); + $response2 = $handler2->withHeaders(['Accept-Language' => 'fr'])->get('/content'); + $this->assertEquals('{"lang":"fr"}', $response2->body()); + } + + public function test_cache_respects_max_age_from_response(): void + { + $responses = [ + new GuzzleResponse(200, [ + 'Content-Type' => 'application/json', + 'Cache-Control' => 'max-age=7200', + ], '{"data":"cached"}'), + ]; + + $handler = $this->create_handler_with_mock_responses($responses); + $handler->baseUri('https://api.example.com'); + $handler->withCache(null, ['respect_cache_headers' => true]); + + $response = $handler->get('/users'); + $this->assertEquals('{"data":"cached"}', $response->body()); + } + + public function test_cache_respects_no_store_when_respect_headers_enabled(): void + { + $responses = [ + new GuzzleResponse(200, [ + 'Content-Type' => 'application/json', + 'Cache-Control' => 'no-store', + ], '{"data":"first"}'), + new GuzzleResponse(200, ['Content-Type' => 'application/json'], '{"data":"second"}'), + ]; + + $handler = $this->create_handler_with_mock_responses($responses); + $handler->baseUri('https://api.example.com'); + // With respect_cache_headers=true (default), no-store should be respected + $handler->withCache(null, ['respect_cache_headers' => true]); + + $response1 = $handler->get('/users'); + $this->assertEquals('{"data":"first"}', $response1->body()); + + // Should not be cached due to no-store + $response2 = $handler->get('/users'); + $this->assertEquals('{"data":"second"}', $response2->body()); + } + + public function test_cache_requires_revalidation_with_no_cache_directive(): void + { + // no-cache means the response may be cached but must be revalidated before use + // This is different from no-store which forbids caching entirely + $responses = [ + new GuzzleResponse(200, [ + 'Content-Type' => 'application/json', + 'Cache-Control' => 'no-cache', + 'ETag' => '"abc123"', + ], '{"data":"first"}'), + // Second request should include conditional headers and may get 304 + new GuzzleResponse(304, [], ''), + ]; + + $handler = $this->create_handler_with_mock_responses($responses); + $handler->baseUri('https://api.example.com'); + $handler->withCache(null, ['respect_cache_headers' => true]); + + // First request - gets response with no-cache + $response1 = $handler->get('/users'); + $this->assertEquals('{"data":"first"}', $response1->body()); + $this->assertEquals(200, $response1->getStatusCode()); + + // Second request - should revalidate and use cached response if 304 + $response2 = $handler->get('/users'); + // Response should still be the cached one after revalidation + $this->assertEquals(200, $response2->getStatusCode()); + } +} diff --git a/tests/Unit/ConnectionPoolTest.php b/tests/Unit/ConnectionPoolTest.php new file mode 100644 index 0000000..fece088 --- /dev/null +++ b/tests/Unit/ConnectionPoolTest.php @@ -0,0 +1,364 @@ +assertTrue($config->isEnabled()); + $this->assertEquals(100, $config->getMaxConnections()); + $this->assertEquals(6, $config->getMaxPerHost()); + $this->assertEquals(3, $config->getMaxIdlePerHost()); + $this->assertEquals(30, $config->getKeepAliveTimeout()); + $this->assertEquals(10, $config->getConnectionTimeout()); + $this->assertEquals('least_connections', $config->getStrategy()); + $this->assertFalse($config->isConnectionWarmupEnabled()); + $this->assertEquals(0, $config->getWarmupConnections()); + $this->assertEquals(300, $config->getDnsCacheTtl()); + } + + public function test_pool_configuration_from_array(): void + { + $config = PoolConfiguration::fromArray([ + 'enabled' => true, + 'max_connections' => 50, + 'max_per_host' => 10, + 'max_idle_per_host' => 5, + 'keep_alive_timeout' => 60, + 'connection_timeout' => 15, + 'strategy' => 'round_robin', + 'connection_warmup' => true, + 'warmup_connections' => 2, + 'dns_cache_ttl' => 600, + ]); + + $this->assertTrue($config->isEnabled()); + $this->assertEquals(50, $config->getMaxConnections()); + $this->assertEquals(10, $config->getMaxPerHost()); + $this->assertEquals(5, $config->getMaxIdlePerHost()); + $this->assertEquals(60, $config->getKeepAliveTimeout()); + $this->assertEquals(15, $config->getConnectionTimeout()); + $this->assertEquals('round_robin', $config->getStrategy()); + $this->assertTrue($config->isConnectionWarmupEnabled()); + $this->assertEquals(2, $config->getWarmupConnections()); + $this->assertEquals(600, $config->getDnsCacheTtl()); + } + + public function test_pool_configuration_to_array(): void + { + $config = new PoolConfiguration( + enabled: true, + maxConnections: 200, + maxPerHost: 8, + ); + + $array = $config->toArray(); + + $this->assertTrue($array['enabled']); + $this->assertEquals(200, $array['max_connections']); + $this->assertEquals(8, $array['max_per_host']); + } + + public function test_connection_lifecycle(): void + { + $connection = new Connection( + host: 'example.com', + port: 443, + ssl: true, + ); + + $this->assertEquals('example.com', $connection->getHost()); + $this->assertEquals(443, $connection->getPort()); + $this->assertTrue($connection->isSsl()); + $this->assertEquals('https://example.com:443', $connection->getKey()); + $this->assertEquals(0, $connection->getActiveRequestCount()); + $this->assertFalse($connection->isAlive()); // No client set yet + } + + public function test_connection_active_requests(): void + { + $connection = new Connection('example.com', 80, false); + + $this->assertEquals(0, $connection->getActiveRequestCount()); + + $connection->incrementActiveRequests(); + $this->assertEquals(1, $connection->getActiveRequestCount()); + + $connection->incrementActiveRequests(); + $this->assertEquals(2, $connection->getActiveRequestCount()); + + $connection->decrementActiveRequests(); + $this->assertEquals(1, $connection->getActiveRequestCount()); + + $connection->decrementActiveRequests(); + $connection->decrementActiveRequests(); // Should not go below 0 + $this->assertEquals(0, $connection->getActiveRequestCount()); + } + + public function test_connection_timestamps(): void + { + $before = microtime(true); + $connection = new Connection('example.com', 80, false); + $after = microtime(true); + + $this->assertGreaterThanOrEqual($before, $connection->getCreatedAt()); + $this->assertLessThanOrEqual($after, $connection->getCreatedAt()); + $this->assertEquals($connection->getCreatedAt(), $connection->getLastUsedAt()); + + sleep(1); + $connection->markUsed(); + + $this->assertGreaterThan($connection->getCreatedAt(), $connection->getLastUsedAt()); + } + + public function test_connection_close(): void + { + $connection = new Connection('example.com', 80, false); + $mockClient = $this->createMock(\GuzzleHttp\ClientInterface::class); + $connection->setClient($mockClient); + + $this->assertTrue($connection->isAlive()); + + $connection->close(); + + $this->assertFalse($connection->isAlive()); + $this->assertNull($connection->getClient()); + } + + public function test_connection_pool_get_connection(): void + { + $config = new PoolConfiguration; + $pool = new ConnectionPool($config); + + // Get a connection + $connection = $pool->getConnection('example.com', 443, true); + + $this->assertInstanceOf(Connection::class, $connection); + $this->assertEquals('example.com', $connection->getHost()); + $this->assertEquals(443, $connection->getPort()); + $this->assertTrue($connection->isSsl()); + } + + public function test_connection_pool_from_url(): void + { + $config = new PoolConfiguration; + $pool = new ConnectionPool($config); + + $connection = $pool->getConnectionFromUrl('https://api.example.com:8443/v1/users'); + + $this->assertEquals('api.example.com', $connection->getHost()); + $this->assertEquals(8443, $connection->getPort()); + $this->assertTrue($connection->isSsl()); + } + + public function test_connection_pool_from_url_default_ports(): void + { + $config = new PoolConfiguration; + $pool = new ConnectionPool($config); + + $httpConnection = $pool->getConnectionFromUrl('http://example.com/path'); + $this->assertEquals(80, $httpConnection->getPort()); + $this->assertFalse($httpConnection->isSsl()); + + $httpsConnection = $pool->getConnectionFromUrl('https://example.com/path'); + $this->assertEquals(443, $httpsConnection->getPort()); + $this->assertTrue($httpsConnection->isSsl()); + } + + public function test_connection_pool_release(): void + { + $config = new PoolConfiguration; + $pool = new ConnectionPool($config); + + $connection = $pool->getConnection('example.com', 80, false); + $pool->releaseConnection($connection); + + $stats = $pool->getStats(); + $this->assertEquals(1, $stats['total_requests']); + $this->assertGreaterThanOrEqual(0, $stats['connections_reused']); + } + + public function test_connection_pool_stats(): void + { + $config = new PoolConfiguration; + $pool = new ConnectionPool($config); + + // Initial stats + $stats = $pool->getStats(); + $this->assertTrue($stats['enabled']); + $this->assertEquals(0, $stats['total_pools']); + $this->assertEquals(0, $stats['active_connections']); + + // After getting a connection + $connection = $pool->getConnection('example.com', 80, false); + $stats = $pool->getStats(); + $this->assertEquals(1, $stats['total_pools']); + $this->assertEquals(1, $stats['active_connections']); + $this->assertEquals(1, $stats['total_requests']); + } + + public function test_connection_pool_close_all(): void + { + $config = new PoolConfiguration; + $pool = new ConnectionPool($config); + + // Create some connections + $pool->getConnection('example.com', 80, false); + $pool->getConnection('api.example.com', 443, true); + + $stats = $pool->getStats(); + $this->assertEquals(2, $stats['total_pools']); + $this->assertEquals(2, $stats['active_connections']); + + $pool->closeAll(); + + $stats = $pool->getStats(); + $this->assertEquals(0, $stats['total_pools']); + $this->assertEquals(0, $stats['active_connections']); + } + + public function test_host_connection_pool_stats(): void + { + $config = new PoolConfiguration; + $hostPool = new HostConnectionPool( + host: 'example.com', + port: 443, + ssl: true, + config: $config, + ); + + // Initial stats + $stats = $hostPool->getStats(); + $this->assertEquals('example.com', $stats['host']); + $this->assertEquals(443, $stats['port']); + $this->assertTrue($stats['ssl']); + $this->assertEquals(0, $stats['total_borrowed']); + + // Borrow a connection + $connection = $hostPool->borrowConnection(); + $stats = $hostPool->getStats(); + $this->assertEquals(1, $stats['total_borrowed']); + $this->assertEquals(1, $stats['total_created']); + + // Return the connection + $hostPool->returnConnection($connection); + $stats = $hostPool->getStats(); + $this->assertEquals(1, $stats['total_returned']); + } + + public function test_http2_configuration_defaults(): void + { + $config = new Http2Configuration; + + $this->assertTrue($config->isEnabled()); + $this->assertEquals(100, $config->getMaxConcurrentStreams()); + $this->assertEquals(65535, $config->getWindowSize()); + $this->assertEquals(4096, $config->getHeaderTableSize()); + $this->assertFalse($config->isServerPushEnabled()); + $this->assertFalse($config->isStreamPrioritizationEnabled()); + } + + public function test_http2_configuration_from_array(): void + { + $config = Http2Configuration::fromArray([ + 'enabled' => true, + 'max_concurrent_streams' => 200, + 'window_size' => 131070, + 'enable_server_push' => true, + 'stream_prioritization' => true, + ]); + + $this->assertTrue($config->isEnabled()); + $this->assertEquals(200, $config->getMaxConcurrentStreams()); + $this->assertEquals(131070, $config->getWindowSize()); + $this->assertTrue($config->isServerPushEnabled()); + $this->assertTrue($config->isStreamPrioritizationEnabled()); + } + + public function test_http2_curl_options(): void + { + $config = new Http2Configuration(enabled: true); + $curlOptions = $config->getCurlOptions(); + + $this->assertArrayHasKey(CURLOPT_HTTP_VERSION, $curlOptions); + $this->assertEquals(CURL_HTTP_VERSION_2_0, $curlOptions[CURLOPT_HTTP_VERSION]); + } + + public function test_http2_curl_multi_options(): void + { + $config = new Http2Configuration(enabled: true); + $curlMultiOptions = $config->getCurlMultiOptions(); + + // CURLMOPT_PIPELINING should be in multi options, not regular curl options + if (defined('CURLPIPE_MULTIPLEX')) { + $this->assertArrayHasKey(CURLMOPT_PIPELINING, $curlMultiOptions); + $this->assertEquals(CURLPIPE_MULTIPLEX, $curlMultiOptions[CURLMOPT_PIPELINING]); + } + } + + public function test_http2_disabled_curl_options(): void + { + $config = new Http2Configuration(enabled: false); + $curlOptions = $config->getCurlOptions(); + + $this->assertArrayNotHasKey(CURLOPT_HTTP_VERSION, $curlOptions); + } + + public function test_dns_cache_resolve(): void + { + $cache = new DnsCache(ttl: 300); + + // Mock the DNS lookup by testing with localhost + $addresses = $cache->resolve('localhost'); + + $this->assertNotEmpty($addresses); + } + + public function test_dns_cache_stats(): void + { + $cache = new DnsCache(ttl: 300); + + $stats = $cache->getStats(); + + $this->assertEquals(0, $stats['total_entries']); + $this->assertEquals(300, $stats['ttl']); + } + + public function test_dns_cache_clear(): void + { + $cache = new DnsCache(ttl: 300); + + // Resolve to populate cache + try { + $cache->resolve('localhost'); + } catch (\Throwable) { + // Ignore if DNS fails in test environment + } + + // Clear cache + $cache->clear(); + + $stats = $cache->getStats(); + $this->assertEquals(0, $stats['total_entries']); + } + + public function test_dns_cache_ttl_setter(): void + { + $cache = new DnsCache(ttl: 300); + $cache->setTtl(600); + + $stats = $cache->getStats(); + $this->assertEquals(600, $stats['ttl']); + } +} diff --git a/tests/Unit/ManagesConnectionPoolTest.php b/tests/Unit/ManagesConnectionPoolTest.php new file mode 100644 index 0000000..07cd2c6 --- /dev/null +++ b/tests/Unit/ManagesConnectionPoolTest.php @@ -0,0 +1,206 @@ +resetPool(); + } + + public function test_with_connection_pool_enabled(): void + { + $handler = ClientHandler::create(); + + $result = $handler->withConnectionPool(true); + + $this->assertSame($handler, $result); + // Pool is not fully enabled until configured with array + $this->assertFalse($handler->isPoolingEnabled()); + } + + public function test_with_connection_pool_config(): void + { + $handler = ClientHandler::create(); + + $handler->withConnectionPool([ + 'enabled' => true, + 'max_connections' => 50, + 'max_per_host' => 10, + ]); + + $this->assertTrue($handler->isPoolingEnabled()); + + $pool = $handler->getConnectionPool(); + $this->assertInstanceOf(ConnectionPool::class, $pool); + $this->assertEquals(50, $pool->getConfig()->getMaxConnections()); + $this->assertEquals(10, $pool->getConfig()->getMaxPerHost()); + } + + public function test_with_http2_enabled(): void + { + $handler = ClientHandler::create(); + + $handler->withHttp2(true); + + $this->assertTrue($handler->isHttp2Enabled()); + + $config = $handler->getHttp2Config(); + $this->assertInstanceOf(Http2Configuration::class, $config); + $this->assertTrue($config->isEnabled()); + } + + public function test_with_http2_config(): void + { + $handler = ClientHandler::create(); + + $handler->withHttp2([ + 'enabled' => true, + 'max_concurrent_streams' => 200, + 'enable_server_push' => true, + ]); + + $this->assertTrue($handler->isHttp2Enabled()); + + $config = $handler->getHttp2Config(); + $this->assertEquals(200, $config->getMaxConcurrentStreams()); + $this->assertTrue($config->isServerPushEnabled()); + } + + public function test_with_http2_disabled(): void + { + $handler = ClientHandler::create(); + + $handler->withHttp2(false); + + $this->assertFalse($handler->isHttp2Enabled()); + } + + public function test_get_pool_stats(): void + { + $handler = ClientHandler::create(); + + // Before pooling is configured + $stats = $handler->getPoolStats(); + $this->assertFalse($stats['enabled']); + + // After pooling is configured + $handler->withConnectionPool([ + 'enabled' => true, + 'max_connections' => 100, + ]); + + $stats = $handler->getPoolStats(); + $this->assertTrue($stats['enabled']); + $this->assertEquals(0, $stats['total_pools']); + $this->assertEquals(0, $stats['active_connections']); + } + + public function test_get_dns_cache_stats(): void + { + $handler = ClientHandler::create(); + + // Before DNS cache is configured + $stats = $handler->getDnsCacheStats(); + $this->assertFalse($stats['enabled']); + + // After pooling is configured (DNS cache is initialized too) + $handler->withConnectionPool([ + 'enabled' => true, + 'dns_cache_ttl' => 600, + ]); + + $stats = $handler->getDnsCacheStats(); + $this->assertTrue($stats['enabled']); + $this->assertEquals(600, $stats['ttl']); + } + + public function test_clear_dns_cache(): void + { + $handler = ClientHandler::create(); + $handler->withConnectionPool([ + 'enabled' => true, + 'dns_cache_ttl' => 300, + ]); + + $result = $handler->clearDnsCache(); + + $this->assertSame($handler, $result); + + $stats = $handler->getDnsCacheStats(); + $this->assertEquals(0, $stats['total_entries']); + } + + public function test_close_all_connections(): void + { + $handler = ClientHandler::create(); + $handler->withConnectionPool([ + 'enabled' => true, + ]); + + $result = $handler->closeAllConnections(); + + $this->assertSame($handler, $result); + + $stats = $handler->getPoolStats(); + $this->assertEquals(0, $stats['active_connections']); + } + + public function test_reset_pool(): void + { + $handler = ClientHandler::create(); + $handler->withConnectionPool([ + 'enabled' => true, + ]); + + $this->assertTrue($handler->isPoolingEnabled()); + + $handler->resetPool(); + + $this->assertFalse($handler->isPoolingEnabled()); + $this->assertNull($handler->getConnectionPool()); + $this->assertNull($handler->getDnsCache()); + } + + public function test_http2_adds_curl_options(): void + { + $handler = ClientHandler::create(); + $handler->withHttp2(true); + + $options = $handler->getOptions(); + + $this->assertArrayHasKey('curl', $options); + $this->assertArrayHasKey(CURLOPT_HTTP_VERSION, $options['curl']); + $this->assertEquals(CURL_HTTP_VERSION_2_0, $options['curl'][CURLOPT_HTTP_VERSION]); + $this->assertEquals(2.0, $options['version']); + } + + public function test_chaining_pool_and_http2_config(): void + { + $handler = ClientHandler::create() + ->withConnectionPool([ + 'enabled' => true, + 'max_connections' => 100, + ]) + ->withHttp2([ + 'enabled' => true, + 'max_concurrent_streams' => 50, + ]); + + $this->assertTrue($handler->isPoolingEnabled()); + $this->assertTrue($handler->isHttp2Enabled()); + + $this->assertEquals(100, $handler->getConnectionPool()->getConfig()->getMaxConnections()); + $this->assertEquals(50, $handler->getHttp2Config()->getMaxConcurrentStreams()); + } +} diff --git a/tests/Unit/ManagesDebugAndProfilingTest.php b/tests/Unit/ManagesDebugAndProfilingTest.php index 22452a2..3cca57a 100644 --- a/tests/Unit/ManagesDebugAndProfilingTest.php +++ b/tests/Unit/ManagesDebugAndProfilingTest.php @@ -3,7 +3,6 @@ namespace Tests\Unit; use Fetch\Http\ClientHandler; -use Fetch\Support\DebugInfo; use Fetch\Support\FetchProfiler; use PHPUnit\Framework\TestCase; diff --git a/tests/Unit/MiddlewarePipelineTest.php b/tests/Unit/MiddlewarePipelineTest.php new file mode 100644 index 0000000..613597e --- /dev/null +++ b/tests/Unit/MiddlewarePipelineTest.php @@ -0,0 +1,287 @@ +assertTrue($pipeline->isEmpty()); + $this->assertEquals(0, $pipeline->count()); + } + + public function test_pipeline_can_be_created_with_middleware(): void + { + $middleware = $this->createMockMiddleware(); + $pipeline = new MiddlewarePipeline([$middleware]); + + $this->assertFalse($pipeline->isEmpty()); + $this->assertEquals(1, $pipeline->count()); + } + + public function test_middleware_can_be_added(): void + { + $pipeline = new MiddlewarePipeline; + $middleware = $this->createMockMiddleware(); + + $pipeline->add($middleware); + + $this->assertEquals(1, $pipeline->count()); + } + + public function test_middleware_can_be_prepended(): void + { + $middleware1 = $this->createMockMiddleware(); + $middleware2 = $this->createMockMiddleware(); + + $pipeline = new MiddlewarePipeline([$middleware1]); + $pipeline->prepend($middleware2); + + $stack = $pipeline->getMiddleware(); + + // Prepended middleware should have higher priority and come first + $this->assertSame($middleware2, $stack[0]['middleware']); + $this->assertSame($middleware1, $stack[1]['middleware']); + } + + public function test_middleware_is_sorted_by_priority(): void + { + $lowPriority = $this->createMockMiddleware(); + $highPriority = $this->createMockMiddleware(); + + $pipeline = new MiddlewarePipeline; + $pipeline->add($lowPriority, 1); + $pipeline->add($highPriority, 10); + + $stack = $pipeline->getMiddleware(); + + // Higher priority should come first + $this->assertSame($highPriority, $stack[0]['middleware']); + $this->assertSame($lowPriority, $stack[1]['middleware']); + } + + public function test_handle_executes_core_handler_when_no_middleware(): void + { + $pipeline = new MiddlewarePipeline; + $request = new Request('GET', 'https://example.com'); + $expectedResponse = new Response(200); + + $coreHandler = function (RequestInterface $req) use ($expectedResponse, $request) { + $this->assertSame($request, $req); + + return $expectedResponse; + }; + + $response = $pipeline->handle($request, $coreHandler); + + $this->assertSame($expectedResponse, $response); + } + + public function test_middleware_can_modify_request(): void + { + $middleware = new class implements MiddlewareInterface + { + public function handle(RequestInterface $request, callable $next): Response|PromiseInterface + { + // Add a header to the request + $modifiedRequest = $request->withHeader('X-Custom-Header', 'test-value'); + + return $next($modifiedRequest); + } + }; + + $pipeline = new MiddlewarePipeline([$middleware]); + $request = new Request('GET', 'https://example.com'); + + $receivedRequest = null; + $coreHandler = function (RequestInterface $req) use (&$receivedRequest) { + $receivedRequest = $req; + + return new Response(200); + }; + + $pipeline->handle($request, $coreHandler); + + $this->assertNotNull($receivedRequest); + $this->assertEquals('test-value', $receivedRequest->getHeaderLine('X-Custom-Header')); + } + + public function test_middleware_can_modify_response(): void + { + $middleware = new class implements MiddlewareInterface + { + public function handle(RequestInterface $request, callable $next): Response|PromiseInterface + { + $response = $next($request); + + // Modify the response + return $response->withHeader('X-Response-Header', 'modified'); + } + }; + + $pipeline = new MiddlewarePipeline([$middleware]); + $request = new Request('GET', 'https://example.com'); + + $coreHandler = function (RequestInterface $req) { + return new Response(200); + }; + + $response = $pipeline->handle($request, $coreHandler); + + $this->assertEquals('modified', $response->getHeaderLine('X-Response-Header')); + } + + public function test_middleware_can_short_circuit(): void + { + $shortCircuitMiddleware = new class implements MiddlewareInterface + { + public function handle(RequestInterface $request, callable $next): Response|PromiseInterface + { + // Return early without calling $next + return new Response(401, [], 'Unauthorized'); + } + }; + + $pipeline = new MiddlewarePipeline([$shortCircuitMiddleware]); + $request = new Request('GET', 'https://example.com'); + + $coreHandlerCalled = false; + $coreHandler = function (RequestInterface $req) use (&$coreHandlerCalled) { + $coreHandlerCalled = true; + + return new Response(200); + }; + + $response = $pipeline->handle($request, $coreHandler); + + $this->assertFalse($coreHandlerCalled); + $this->assertEquals(401, $response->getStatusCode()); + } + + public function test_multiple_middleware_executes_in_order(): void + { + $order = []; + + $firstMiddleware = new class($order) implements MiddlewareInterface + { + public function __construct(private array &$order) {} + + public function handle(RequestInterface $request, callable $next): Response|PromiseInterface + { + $this->order[] = 'first-before'; + $response = $next($request); + $this->order[] = 'first-after'; + + return $response; + } + }; + + $secondMiddleware = new class($order) implements MiddlewareInterface + { + public function __construct(private array &$order) {} + + public function handle(RequestInterface $request, callable $next): Response|PromiseInterface + { + $this->order[] = 'second-before'; + $response = $next($request); + $this->order[] = 'second-after'; + + return $response; + } + }; + + $pipeline = new MiddlewarePipeline; + $pipeline->add($firstMiddleware, 10); // Higher priority, runs first + $pipeline->add($secondMiddleware, 5); // Lower priority, runs second + + $request = new Request('GET', 'https://example.com'); + $coreHandler = function (RequestInterface $req) use (&$order) { + $order[] = 'core'; + + return new Response(200); + }; + + $pipeline->handle($request, $coreHandler); + + $this->assertEquals([ + 'first-before', + 'second-before', + 'core', + 'second-after', + 'first-after', + ], $order); + } + + public function test_pipeline_can_be_cleared(): void + { + $middleware = $this->createMockMiddleware(); + $pipeline = new MiddlewarePipeline([$middleware]); + + $pipeline->clear(); + + $this->assertTrue($pipeline->isEmpty()); + $this->assertEquals(0, $pipeline->count()); + } + + public function test_pipeline_accepts_array_with_priority(): void + { + $middleware = $this->createMockMiddleware(); + $pipeline = new MiddlewarePipeline([ + ['middleware' => $middleware, 'priority' => 5], + ]); + + $stack = $pipeline->getMiddleware(); + + $this->assertEquals(5, $stack[0]['priority']); + } + + protected function createMockMiddleware(): MiddlewareInterface + { + return new class implements MiddlewareInterface + { + public function handle(RequestInterface $request, callable $next): Response|PromiseInterface + { + return $next($request); + } + }; + } + + public function test_constructor_throws_exception_for_invalid_middleware(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Middleware must be an instance of MiddlewareInterface'); + + new MiddlewarePipeline(['invalid']); + } + + public function test_constructor_throws_exception_for_array_without_middleware_key(): void + { + $this->expectException(\InvalidArgumentException::class); + + new MiddlewarePipeline([['priority' => 5]]); + } + + public function test_pipeline_accepts_array_with_default_priority(): void + { + $middleware = $this->createMockMiddleware(); + $pipeline = new MiddlewarePipeline([ + ['middleware' => $middleware], + ]); + + $stack = $pipeline->getMiddleware(); + + $this->assertEquals(0, $stack[0]['priority']); + } +} diff --git a/tests/Unit/SupportsMiddlewareTest.php b/tests/Unit/SupportsMiddlewareTest.php new file mode 100644 index 0000000..c4bfdc9 --- /dev/null +++ b/tests/Unit/SupportsMiddlewareTest.php @@ -0,0 +1,207 @@ +handler = new ClientHandler; + } + + public function test_middleware_pipeline_is_lazy_initialized(): void + { + $pipeline = $this->handler->getMiddlewarePipeline(); + + $this->assertInstanceOf(MiddlewarePipeline::class, $pipeline); + } + + public function test_has_middleware_returns_false_when_empty(): void + { + $this->assertFalse($this->handler->hasMiddleware()); + } + + public function test_has_middleware_returns_true_when_middleware_added(): void + { + $middleware = $this->createMockMiddleware(); + $this->handler->addMiddleware($middleware); + + $this->assertTrue($this->handler->hasMiddleware()); + } + + public function test_middleware_can_be_set_with_array(): void + { + $middleware1 = $this->createMockMiddleware(); + $middleware2 = $this->createMockMiddleware(); + + $this->handler->middleware([$middleware1, $middleware2]); + + $this->assertEquals(2, $this->handler->getMiddlewarePipeline()->count()); + } + + public function test_add_middleware_returns_handler_for_chaining(): void + { + $middleware = $this->createMockMiddleware(); + $result = $this->handler->addMiddleware($middleware); + + $this->assertSame($this->handler, $result); + } + + public function test_add_middleware_with_priority(): void + { + $lowPriority = $this->createMockMiddleware(); + $highPriority = $this->createMockMiddleware(); + + $this->handler->addMiddleware($lowPriority, 1); + $this->handler->addMiddleware($highPriority, 10); + + $stack = $this->handler->getMiddlewarePipeline()->getMiddleware(); + + // Higher priority should come first + $this->assertSame($highPriority, $stack[0]['middleware']); + $this->assertSame($lowPriority, $stack[1]['middleware']); + } + + public function test_prepend_middleware(): void + { + $first = $this->createMockMiddleware(); + $prepended = $this->createMockMiddleware(); + + $this->handler->addMiddleware($first); + $this->handler->prependMiddleware($prepended); + + $stack = $this->handler->getMiddlewarePipeline()->getMiddleware(); + + // Prepended should be first + $this->assertSame($prepended, $stack[0]['middleware']); + } + + public function test_clear_middleware(): void + { + $middleware = $this->createMockMiddleware(); + $this->handler->addMiddleware($middleware); + + $this->handler->clearMiddleware(); + + $this->assertFalse($this->handler->hasMiddleware()); + } + + public function test_when_adds_middleware_if_condition_is_true(): void + { + $middleware = $this->createMockMiddleware(); + + $this->handler->when(true, function ($handler) use ($middleware) { + $handler->addMiddleware($middleware); + }); + + $this->assertTrue($this->handler->hasMiddleware()); + } + + public function test_when_does_not_add_middleware_if_condition_is_false(): void + { + $middleware = $this->createMockMiddleware(); + + $this->handler->when(false, function ($handler) use ($middleware) { + $handler->addMiddleware($middleware); + }); + + $this->assertFalse($this->handler->hasMiddleware()); + } + + public function test_unless_adds_middleware_if_condition_is_false(): void + { + $middleware = $this->createMockMiddleware(); + + $this->handler->unless(false, function ($handler) use ($middleware) { + $handler->addMiddleware($middleware); + }); + + $this->assertTrue($this->handler->hasMiddleware()); + } + + public function test_unless_does_not_add_middleware_if_condition_is_true(): void + { + $middleware = $this->createMockMiddleware(); + + $this->handler->unless(true, function ($handler) use ($middleware) { + $handler->addMiddleware($middleware); + }); + + $this->assertFalse($this->handler->hasMiddleware()); + } + + public function test_middleware_method_returns_handler_for_chaining(): void + { + $middleware = $this->createMockMiddleware(); + $result = $this->handler->middleware([$middleware]); + + $this->assertSame($this->handler, $result); + } + + public function test_when_returns_handler_for_chaining(): void + { + $result = $this->handler->when(true, function () {}); + + $this->assertSame($this->handler, $result); + } + + public function test_unless_returns_handler_for_chaining(): void + { + $result = $this->handler->unless(false, function () {}); + + $this->assertSame($this->handler, $result); + } + + public function test_cloned_handler_has_independent_middleware_pipeline(): void + { + $middleware1 = $this->createMockMiddleware(); + $this->handler->addMiddleware($middleware1); + + $cloned = clone $this->handler; + $middleware2 = $this->createMockMiddleware(); + $cloned->addMiddleware($middleware2); + + // Original should only have 1 middleware + $this->assertEquals(1, $this->handler->getMiddlewarePipeline()->count()); + + // Cloned should have 2 middleware + $this->assertEquals(2, $cloned->getMiddlewarePipeline()->count()); + } + + public function test_cloned_handler_without_middleware_remains_independent(): void + { + $cloned = clone $this->handler; + + $middleware = $this->createMockMiddleware(); + $cloned->addMiddleware($middleware); + + // Original should have no middleware + $this->assertFalse($this->handler->hasMiddleware()); + + // Cloned should have middleware + $this->assertTrue($cloned->hasMiddleware()); + } + + protected function createMockMiddleware(): MiddlewareInterface + { + return new class implements MiddlewareInterface + { + public function handle(RequestInterface $request, callable $next): Response|PromiseInterface + { + return $next($request); + } + }; + } +}