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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit backupGlobals="false"
bootstrap="vendor/autoload.php"
beStrictAboutTestsThatDoNotTestAnything="false"
colors="true"
processIsolation="false"
Expand Down
257 changes: 257 additions & 0 deletions src/Fetch/Cache/CacheControl.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
<?php

declare(strict_types=1);

namespace Fetch\Cache;

use Psr\Http\Message\ResponseInterface;

/**
* Parses and handles Cache-Control headers according to RFC 7234.
*/
class CacheControl
{
/**
* Parsed Cache-Control directives.
*
* @var array<string, mixed>
*/
private array $directives = [];

/**
* Create a new CacheControl instance.
*
* @param array<string, mixed> $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<string, mixed> $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<string, mixed>
*/
public function getDirectives(): array
{
return $this->directives;
}
}
57 changes: 57 additions & 0 deletions src/Fetch/Cache/CacheInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

declare(strict_types=1);

namespace Fetch\Cache;

/**
* Interface for cache backends used by the HTTP client.
*/
interface CacheInterface
{
/**
* Get a cached response by key.
*
* @param string $key The cache key
* @return CachedResponse|null The cached response, or null if not found
*/
public function get(string $key): ?CachedResponse;

/**
* Store a response in the cache.
*
* @param string $key The cache key
* @param CachedResponse $cachedResponse The response to cache
* @param int|null $ttl Time to live in seconds
* @return bool True if the item was stored, false otherwise
*/
public function set(string $key, CachedResponse $cachedResponse, ?int $ttl = null): bool;

/**
* Delete a cached response by key.
*
* @param string $key The cache key
* @return bool True if the item was deleted, false otherwise
*/
public function delete(string $key): bool;

/**
* Check if a key exists in the cache.
*
* @param string $key The cache key
* @return bool True if the key exists, false otherwise
*/
public function has(string $key): bool;

/**
* Clear all cached responses.
*/
public function clear(): void;

/**
* Remove expired entries from the cache.
*
* @return int The number of entries removed
*/
public function prune(): int;
}
Loading
Loading