feat: extract CDN providers from app/Plug/Cdn

Bunny CDN (Purge, Stats), CdnManager, domain contracts (HasStats, Purgeable)
with Core\Plug\Cdn namespace alignment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-03-09 17:29:13 +00:00
parent 2de9e3dcb6
commit fb78c05c32
6 changed files with 500 additions and 0 deletions

17
composer.json Normal file
View file

@ -0,0 +1,17 @@
{
"name": "core/php-plug-cdn",
"description": "CDN provider integrations for the Plug framework",
"type": "library",
"license": "EUPL-1.2",
"require": {
"php": "^8.2",
"core/php": "^1.0"
},
"autoload": {
"psr-4": {
"Core\\Plug\\Cdn\\": "src/"
}
},
"minimum-stability": "dev",
"prefer-stable": true
}

166
src/Bunny/Purge.php Normal file
View file

@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace Core\Plug\Cdn\Bunny;
use Core\Plug\Cdn\Contract\Purgeable;
use Core\Plug\Concern\BuildsResponse;
use Core\Plug\Concern\UsesHttp;
use Core\Plug\Response;
use Illuminate\Support\Facades\Log;
/**
* Bunny CDN cache purging operations.
*
* Supports:
* - Single URL purge
* - Bulk URL purge
* - Full cache purge
* - Tag-based purge (for workspace isolation)
*/
class Purge implements Purgeable
{
use BuildsResponse;
use UsesHttp;
protected string $apiKey;
protected string $pullZoneId;
protected string $baseUrl = 'https://api.bunny.net';
public function __construct(?string $apiKey = null, ?string $pullZoneId = null)
{
$this->apiKey = $apiKey ?? config('cdn.bunny.api_key') ?? '';
$this->pullZoneId = $pullZoneId ?? config('cdn.bunny.pull_zone_id') ?? '';
}
/**
* Check if the service is configured.
*/
public function isConfigured(): bool
{
return ! empty($this->apiKey) && ! empty($this->pullZoneId);
}
/**
* Purge a single URL from cache.
*/
public function url(string $url): Response
{
return $this->urls([$url]);
}
/**
* Purge multiple URLs from cache.
*
* @param array<string> $urls
*/
public function urls(array $urls): Response
{
if (! $this->isConfigured()) {
return $this->error('Bunny CDN not configured');
}
try {
$failed = [];
foreach ($urls as $url) {
$response = $this->http()
->withHeaders(['AccessKey' => $this->apiKey])
->post("{$this->baseUrl}/purge", ['url' => $url]);
if (! $response->successful()) {
$failed[] = $url;
Log::error('Bunny CDN: Purge failed', [
'url' => $url,
'status' => $response->status(),
'body' => $response->body(),
]);
}
}
if (! empty($failed)) {
return $this->error('Some URLs failed to purge', [
'failed' => $failed,
'total' => count($urls),
'failed_count' => count($failed),
]);
}
return $this->ok([
'purged' => count($urls),
'urls' => $urls,
]);
} catch (\Exception $e) {
Log::error('Bunny CDN: Purge exception', ['error' => $e->getMessage()]);
return $this->error($e->getMessage());
}
}
/**
* Purge entire pull zone cache.
*/
public function all(): Response
{
if (! $this->isConfigured()) {
return $this->error('Bunny CDN not configured');
}
try {
$response = $this->http()
->withHeaders(['AccessKey' => $this->apiKey])
->post("{$this->baseUrl}/pullzone/{$this->pullZoneId}/purgeCache");
return $this->fromHttp($response, fn () => ['purged' => 'all']);
} catch (\Exception $e) {
Log::error('Bunny CDN: PurgeAll exception', ['error' => $e->getMessage()]);
return $this->error($e->getMessage());
}
}
/**
* Purge by cache tag.
*
* Useful for workspace isolation - tag content with workspace ID.
*/
public function tag(string $tag): Response
{
if (! $this->isConfigured()) {
return $this->error('Bunny CDN not configured');
}
try {
$response = $this->http()
->withHeaders(['AccessKey' => $this->apiKey])
->post("{$this->baseUrl}/pullzone/{$this->pullZoneId}/purgeCache", [
'CacheTag' => $tag,
]);
return $this->fromHttp($response, fn () => [
'purged' => 'tag',
'tag' => $tag,
]);
} catch (\Exception $e) {
Log::error('Bunny CDN: PurgeByTag exception', [
'tag' => $tag,
'error' => $e->getMessage(),
]);
return $this->error($e->getMessage());
}
}
/**
* Purge all cached content for a workspace.
*
* Convenience method using tag-based purging.
*/
public function workspace(string $workspaceUuid): Response
{
return $this->tag("workspace-{$workspaceUuid}");
}
}

109
src/Bunny/Stats.php Normal file
View file

@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace Core\Plug\Cdn\Bunny;
use Core\Plug\Cdn\Contract\HasStats;
use Core\Plug\Concern\BuildsResponse;
use Core\Plug\Concern\UsesHttp;
use Core\Plug\Response;
use Illuminate\Support\Facades\Log;
/**
* Bunny CDN statistics and bandwidth reporting.
*
* Provides:
* - Traffic statistics
* - Bandwidth usage
* - Cache hit rates
* - Origin traffic
*/
class Stats implements HasStats
{
use BuildsResponse;
use UsesHttp;
protected string $apiKey;
protected string $pullZoneId;
protected string $baseUrl = 'https://api.bunny.net';
public function __construct(?string $apiKey = null, ?string $pullZoneId = null)
{
$this->apiKey = $apiKey ?? config('cdn.bunny.api_key') ?? '';
$this->pullZoneId = $pullZoneId ?? config('cdn.bunny.pull_zone_id') ?? '';
}
/**
* Check if the service is configured.
*/
public function isConfigured(): bool
{
return ! empty($this->apiKey) && ! empty($this->pullZoneId);
}
/**
* Get CDN statistics for date range.
*
* @param string|null $from Start date (ISO 8601)
* @param string|null $to End date (ISO 8601)
*/
public function stats(?string $from = null, ?string $to = null): Response
{
if (! $this->isConfigured()) {
return $this->error('Bunny CDN not configured');
}
try {
$params = ['pullZone' => $this->pullZoneId];
if ($from) {
$params['dateFrom'] = $from;
}
if ($to) {
$params['dateTo'] = $to;
}
$response = $this->http()
->withHeaders(['AccessKey' => $this->apiKey])
->get("{$this->baseUrl}/statistics", $params);
return $this->fromHttp($response, fn (array $data) => [
'total_bandwidth' => $data['TotalBandwidthUsed'] ?? 0,
'cache_hit_rate' => $data['CacheHitRate'] ?? 0,
'origin_traffic' => $data['TotalOriginTraffic'] ?? 0,
'requests_served' => $data['TotalRequestsServed'] ?? 0,
'error_4xx' => $data['Error4xxCount'] ?? 0,
'error_5xx' => $data['Error5xxCount'] ?? 0,
'raw' => $data,
]);
} catch (\Exception $e) {
Log::error('Bunny CDN: Stats exception', ['error' => $e->getMessage()]);
return $this->error($e->getMessage());
}
}
/**
* Get bandwidth usage for date range.
*
* @param string|null $from Start date (ISO 8601)
* @param string|null $to End date (ISO 8601)
*/
public function bandwidth(?string $from = null, ?string $to = null): Response
{
$statsResponse = $this->stats($from, $to);
if ($statsResponse->hasError()) {
return $statsResponse;
}
return $this->ok([
'total_bandwidth' => $statsResponse->get('total_bandwidth', 0),
'cached_bandwidth' => $statsResponse->get('cache_hit_rate', 0),
'origin_bandwidth' => $statsResponse->get('origin_traffic', 0),
]);
}
}

144
src/CdnManager.php Normal file
View file

@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace Core\Plug\Cdn;
use Core\Plug\Cdn\Contract\HasStats;
use Core\Plug\Cdn\Contract\Purgeable;
use InvalidArgumentException;
/**
* CDN Manager - Factory for CDN provider operations.
*
* Resolves CDN providers from config and provides type-safe access to operations.
*
* @example
* $cdn = app(CdnManager::class);
*
* // Get operations
* $purger = $cdn->purge(); // Returns Purgeable for default driver
* $stats = $cdn->stats(); // Returns HasStats for default driver
*
* // Specific driver
* $purger = $cdn->driver('bunny')->purge();
*/
class CdnManager
{
protected string $defaultDriver;
/**
* @var array<string, array{purge: class-string<Purgeable>, stats: class-string<HasStats>}>
*/
protected array $drivers = [
'bunny' => [
'purge' => Bunny\Purge::class,
'stats' => Bunny\Stats::class,
],
];
public function __construct()
{
$this->defaultDriver = config('cdn.driver', 'bunny');
}
/**
* Get the default driver name.
*/
public function getDefaultDriver(): string
{
return $this->defaultDriver;
}
/**
* Set the default driver.
*/
public function setDefaultDriver(string $driver): self
{
$this->defaultDriver = $driver;
return $this;
}
/**
* Get a driver instance.
*/
public function driver(?string $driver = null): self
{
if ($driver !== null) {
$this->defaultDriver = $driver;
}
return $this;
}
/**
* Get the purge operation for the current driver.
*/
public function purge(): Purgeable
{
return $this->resolve('purge');
}
/**
* Get the stats operation for the current driver.
*/
public function stats(): HasStats
{
return $this->resolve('stats');
}
/**
* Check if a driver is registered.
*/
public function hasDriver(string $driver): bool
{
return isset($this->drivers[$driver]);
}
/**
* Get all registered driver names.
*
* @return array<string>
*/
public function drivers(): array
{
return array_keys($this->drivers);
}
/**
* Register a custom driver.
*
* @param array{purge?: class-string<Purgeable>, stats?: class-string<HasStats>} $operations
*/
public function extend(string $driver, array $operations): self
{
$this->drivers[$driver] = array_merge($this->drivers[$driver] ?? [], $operations);
return $this;
}
/**
* Resolve an operation class for the current driver.
*
* @template T
*
* @return T
*/
protected function resolve(string $operation): object
{
if (! isset($this->drivers[$this->defaultDriver])) {
throw new InvalidArgumentException("CDN driver [{$this->defaultDriver}] not registered.");
}
if (! isset($this->drivers[$this->defaultDriver][$operation])) {
throw new InvalidArgumentException(
"Operation [{$operation}] not available for CDN driver [{$this->defaultDriver}]."
);
}
$class = $this->drivers[$this->defaultDriver][$operation];
return new $class;
}
}

29
src/Contract/HasStats.php Normal file
View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Core\Plug\Cdn\Contract;
use Core\Plug\Response;
/**
* CDN statistics and bandwidth reporting.
*/
interface HasStats
{
/**
* Get CDN statistics for date range.
*
* @param string|null $from Start date (ISO 8601)
* @param string|null $to End date (ISO 8601)
*/
public function stats(?string $from = null, ?string $to = null): Response;
/**
* Get bandwidth usage for date range.
*
* @param string|null $from Start date (ISO 8601)
* @param string|null $to End date (ISO 8601)
*/
public function bandwidth(?string $from = null, ?string $to = null): Response;
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Core\Plug\Cdn\Contract;
use Core\Plug\Response;
/**
* CDN cache purging operations.
*/
interface Purgeable
{
/**
* Purge a single URL from cache.
*/
public function url(string $url): Response;
/**
* Purge multiple URLs from cache.
*
* @param array<string> $urls
*/
public function urls(array $urls): Response;
/**
* Purge entire cache.
*/
public function all(): Response;
/**
* Purge by cache tag.
*/
public function tag(string $tag): Response;
}