M12: Serialization/validation utilities and pagination
Some checks failed
CI / PHP ${{ matrix.php }} (8.1) (push) Has been cancelled
CI / PHP ${{ matrix.php }} (8.2) (push) Has been cancelled
CI / PHP ${{ matrix.php }} (8.3) (push) Has been cancelled

This commit is contained in:
Funky Waddle 2025-12-23 17:40:02 -06:00
parent e0f34f6360
commit cf30f3e41a
25 changed files with 997 additions and 29 deletions

View file

@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
/**
* Automatically generates a Table of Contents and injects breadcrumbs for Markdown files.
*/
function generateToc(string $filePath, string $name): void
{
if (!is_file($filePath)) {
echo "$name not found.\n";
return;
}
$content = file_get_contents($filePath);
$lines = explode("\n", $content);
$inToc = false;
$headers = [];
$bodyLines = [];
foreach ($lines as $line) {
if (trim($line) === '## Table of Contents') {
$inToc = true;
continue;
}
// We assume the TOC ends at the next header or double newline
if ($inToc && (str_starts_with($line, '## ') || (str_contains($content, 'This document outlines') && str_starts_with($line, 'This document outlines')))) {
$inToc = false;
}
if (!$inToc) {
if (preg_match('/^(##+) (.*)/', $line, $matches)) {
$level = strlen($matches[1]) - 1; // ## is level 1 in TOC
if ($level > 0) {
$anchor = strtolower(trim($matches[2]));
$anchor = str_replace('~~', '', $anchor);
$anchor = preg_replace('/[^a-z0-9]+/', '-', $anchor);
$anchor = trim($anchor, '-');
$headers[] = [
'level' => $level,
'title' => trim($matches[2]),
'anchor' => $anchor
];
}
// Add "Back to Top" breadcrumb before level 2 headers, except for the first one or if already present
if ($level === 1 && !empty($bodyLines)) {
$lastLine = end($bodyLines);
if ($lastLine !== '' && !str_contains($lastLine, '[↑ Back to Top]')) {
$bodyLines[] = '';
$bodyLines[] = '[↑ Back to Top](#table-of-contents)';
}
}
}
$bodyLines[] = $line;
}
}
// Generate TOC text
$tocText = "## Table of Contents\n";
foreach ($headers as $header) {
if ($header['title'] === 'Table of Contents') continue;
$indent = str_repeat(' ', $header['level'] - 1);
$tocText .= "{$indent}- [{$header['title']}](#{$header['anchor']})\n";
}
// Reconstruct file
$finalLines = [];
$tocInserted = false;
foreach ($bodyLines as $line) {
if (!$tocInserted && (str_starts_with($line, '## ') || (str_contains($content, 'This document outlines') && str_starts_with($line, 'This document outlines')))) {
$finalLines[] = $tocText;
$tocInserted = true;
}
$finalLines[] = $line;
}
file_put_contents($filePath, implode("\n", $finalLines));
echo "$name TOC and breadcrumbs regenerated successfully.\n";
}
$root = __DIR__ . '/../..';
generateToc($root . '/SPECS.md', 'SPECS.md');
generateToc($root . '/MILESTONES.md', 'MILESTONES.md');

View file

@ -9,7 +9,7 @@ interface ErrorFormatNegotiatorInterface
{ {
/** /**
* Determine desired API format based on the request (e.g., Accept header). * Determine desired API format based on the request (e.g., Accept header).
* Should return 'rest' or 'jsonapi'. * Should return 'rest', 'jsonapi', or 'xml'.
*/ */
public function apiFormat(ServerRequestInterface $request): string; public function apiFormat(ServerRequestInterface $request): string;

View file

@ -3,20 +3,19 @@ declare(strict_types=1);
namespace Phred\Http\Controllers; namespace Phred\Http\Controllers;
use Nyholm\Psr7\Factory\Psr17Factory; use Phred\Http\Contracts\ApiResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
final class HealthController final class HealthController
{ {
public function __construct(private ApiResponseFactoryInterface $factory) {}
public function __invoke(Request $request): ResponseInterface public function __invoke(Request $request): ResponseInterface
{ {
$psr17 = new Psr17Factory(); return $this->factory->ok([
$res = $psr17->createResponse(200)->withHeader('Content-Type', 'application/json');
$res->getBody()->write(json_encode([
'ok' => true, 'ok' => true,
'framework' => 'Phred', 'framework' => 'Phred',
], JSON_UNESCAPED_SLASHES)); ]);
return $res;
} }
} }

View file

@ -118,6 +118,7 @@ final class Kernel
\Phred\Http\Contracts\ApiResponseFactoryInterface::class => \DI\autowire(\Phred\Http\Responses\DelegatingApiResponseFactory::class), \Phred\Http\Contracts\ApiResponseFactoryInterface::class => \DI\autowire(\Phred\Http\Responses\DelegatingApiResponseFactory::class),
\Phred\Http\Responses\RestResponseFactory::class => \DI\autowire(\Phred\Http\Responses\RestResponseFactory::class), \Phred\Http\Responses\RestResponseFactory::class => \DI\autowire(\Phred\Http\Responses\RestResponseFactory::class),
\Phred\Http\Responses\JsonApiResponseFactory::class => \DI\autowire(\Phred\Http\Responses\JsonApiResponseFactory::class), \Phred\Http\Responses\JsonApiResponseFactory::class => \DI\autowire(\Phred\Http\Responses\JsonApiResponseFactory::class),
\Phred\Http\Responses\XmlResponseFactory::class => \DI\autowire(\Phred\Http\Responses\XmlResponseFactory::class),
]); ]);
$container = $builder->build(); $container = $builder->build();

View file

@ -19,20 +19,27 @@ class ContentNegotiationMiddleware extends Middleware
private readonly ?ConfigInterface $config = null, private readonly ?ConfigInterface $config = null,
private readonly ?ErrorFormatNegotiatorInterface $negotiator = null, private readonly ?ErrorFormatNegotiatorInterface $negotiator = null,
) {} ) {}
public const ATTR_API_FORMAT = 'phred.api_format'; // 'rest' | 'jsonapi' public const ATTR_API_FORMAT = 'phred.api_format'; // 'rest' | 'jsonapi' | 'xml'
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{ {
$format = $this->profileSelf(function () use ($request) { $format = $this->profileSelf(function () use ($request) {
$cfg = $this->config ?? new DefaultConfig(); $cfg = $this->config ?? new DefaultConfig();
$format = strtolower((string) $cfg->get('API_FORMAT', $cfg->get('api.format', 'rest'))); $defaultFormat = strtolower((string) $cfg->get('api_format', 'rest'));
// Optional: allow Accept header to override when JSON:API is explicitly requested // Allow Accept header to override
$neg = $this->negotiator ?? new DefaultErrorFormatNegotiator(); $accept = $request->getHeaderLine('Accept');
if ($neg->apiFormat($request) === 'jsonapi') { if (str_contains($accept, 'application/vnd.api+json')) {
$format = 'jsonapi'; return 'jsonapi';
} }
return $format; if (str_contains($accept, 'application/xml') || str_contains($accept, 'text/xml')) {
return 'xml';
}
if (str_contains($accept, 'application/json') || str_contains($accept, 'application/problem+json')) {
return 'rest';
}
return $defaultFormat;
}); });
return $handler->handle($request->withAttribute(self::ATTR_API_FORMAT, $format)); return $handler->handle($request->withAttribute(self::ATTR_API_FORMAT, $format));

View file

@ -71,9 +71,13 @@ final class DispatchMiddleware implements MiddlewareInterface
{ {
$format = (string) ($request->getAttribute(ContentNegotiationMiddleware::ATTR_API_FORMAT, 'rest')); $format = (string) ($request->getAttribute(ContentNegotiationMiddleware::ATTR_API_FORMAT, 'rest'));
$builder = new ContainerBuilder(); $builder = new ContainerBuilder();
$definition = $format === 'jsonapi'
? \DI\autowire(\Phred\Http\Responses\JsonApiResponseFactory::class) $definition = match ($format) {
: \DI\autowire(\Phred\Http\Responses\RestResponseFactory::class); 'jsonapi' => \DI\autowire(\Phred\Http\Responses\JsonApiResponseFactory::class),
'xml' => \DI\autowire(\Phred\Http\Responses\XmlResponseFactory::class),
default => \DI\autowire(\Phred\Http\Responses\RestResponseFactory::class),
};
$builder->addDefinitions([ $builder->addDefinitions([
\Phred\Http\Contracts\ApiResponseFactoryInterface::class => $definition, \Phred\Http\Contracts\ApiResponseFactoryInterface::class => $definition,
]); ]);

View file

@ -52,6 +52,14 @@ abstract class Middleware implements MiddlewareInterface
return self::$timings; return self::$timings;
} }
/**
* Record a timing manually.
*/
public static function recordTiming(string $key, float $duration): void
{
self::$timings[$key] = (self::$timings[$key] ?? 0) + $duration;
}
protected function json(array $data, int $status = 200): ResponseInterface protected function json(array $data, int $status = 200): ResponseInterface
{ {
$response = new \Nyholm\Psr7\Response($status, ['Content-Type' => 'application/json']); $response = new \Nyholm\Psr7\Response($status, ['Content-Type' => 'application/json']);

View file

@ -37,9 +37,9 @@ final class UrlExtensionNegotiationMiddleware implements MiddlewareInterface
return $handler->handle($request); return $handler->handle($request);
} }
$whitelistRaw = (string) Config::get('URL_EXTENSION_WHITELIST', 'json|php|none'); $whitelistRaw = (string) Config::get('URL_EXTENSION_WHITELIST', 'json|xml|php|none');
$allowed = array_filter(array_map('trim', explode('|', strtolower($whitelistRaw)))); $allowed = array_filter(array_map('trim', explode('|', strtolower($whitelistRaw))));
$allowed = $allowed ?: ['json', 'php', 'none']; $allowed = $allowed ?: ['json', 'xml', 'php', 'none'];
$uri = $request->getUri(); $uri = $request->getUri();
$path = $uri->getPath(); $path = $uri->getPath();

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Middleware;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
abstract class ValidationMiddleware extends Middleware
{
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$errors = $this->validate($request);
if (!empty($errors)) {
return $this->json([
'errors' => $errors
], 422);
}
return $handler->handle($request);
}
/**
* @return array<string, mixed> List of validation errors
*/
abstract protected function validate(ServerRequestInterface $request): array;
}

View file

@ -17,7 +17,8 @@ final class DelegatingApiResponseFactory implements ApiResponseFactoryInterface
{ {
public function __construct( public function __construct(
private RestResponseFactory $rest, private RestResponseFactory $rest,
private JsonApiResponseFactory $jsonapi private JsonApiResponseFactory $jsonapi,
private XmlResponseFactory $xml
) {} ) {}
public function ok(array $data = []): ResponseInterface public function ok(array $data = []): ResponseInterface
@ -49,6 +50,10 @@ final class DelegatingApiResponseFactory implements ApiResponseFactoryInterface
{ {
$req = RequestContext::get(); $req = RequestContext::get();
$format = $req?->getAttribute(Negotiation::ATTR_API_FORMAT) ?? 'rest'; $format = $req?->getAttribute(Negotiation::ATTR_API_FORMAT) ?? 'rest';
return $format === 'jsonapi' ? $this->jsonapi : $this->rest; return match ($format) {
'jsonapi' => $this->jsonapi,
'xml' => $this->xml,
default => $this->rest,
};
} }
} }

View file

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Responses;
use Nyholm\Psr7\Factory\Psr17Factory;
use Phred\Http\Contracts\ApiResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Symfony\Component\Serializer\Encoder\XmlEncoder;
use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer;
use Symfony\Component\Serializer\Serializer;
final class XmlResponseFactory implements ApiResponseFactoryInterface
{
private Serializer $serializer;
public function __construct(private Psr17Factory $psr17 = new Psr17Factory())
{
$this->serializer = new Serializer([new ArrayDenormalizer()], [new XmlEncoder()]);
}
public function ok(array $data = []): ResponseInterface
{
return $this->xml($data, 200);
}
public function created(array $data = [], ?string $location = null): ResponseInterface
{
$res = $this->xml($data, 201);
if ($location) {
$res = $res->withHeader('Location', $location);
}
return $res;
}
public function noContent(): ResponseInterface
{
return $this->psr17->createResponse(204);
}
public function error(int $status, string $title, ?string $detail = null, array $extra = []): ResponseInterface
{
$payload = array_merge([
'title' => $title,
'status' => $status,
], $detail !== null ? ['detail' => $detail] : [], $extra);
return $this->xml(['error' => $payload], $status);
}
public function fromArray(array $payload, int $status = 200): ResponseInterface
{
return $this->xml($payload, $status);
}
private function xml(array $data, int $status): ResponseInterface
{
$xml = $this->serializer->serialize($data, 'xml');
$res = $this->psr17->createResponse($status)
->withHeader('Content-Type', 'application/xml');
$res->getBody()->write($xml);
return $res;
}
}

View file

@ -11,7 +11,13 @@ final class DefaultErrorFormatNegotiator implements ErrorFormatNegotiatorInterfa
public function apiFormat(ServerRequest $request): string public function apiFormat(ServerRequest $request): string
{ {
$accept = $request->getHeaderLine('Accept'); $accept = $request->getHeaderLine('Accept');
return str_contains($accept, 'application/vnd.api+json') ? 'jsonapi' : 'rest'; if (str_contains($accept, 'application/vnd.api+json')) {
return 'jsonapi';
}
if (str_contains($accept, 'application/xml') || str_contains($accept, 'text/xml')) {
return 'xml';
}
return 'rest';
} }
public function wantsHtml(ServerRequest $request): bool public function wantsHtml(ServerRequest $request): bool

View file

@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Support;
/**
* Helper to build pagination links and metadata.
*/
final class Paginator
{
/**
* @param array<mixed> $items
*/
public function __construct(
private array $items,
private int $total,
private int $perPage,
private int $currentPage,
private string $baseUrl
) {}
public function toArray(): array
{
$lastPage = (int) ceil($this->total / $this->perPage);
return [
'data' => $this->items,
'meta' => [
'total' => $this->total,
'per_page' => $this->perPage,
'current_page' => $this->currentPage,
'last_page' => $lastPage,
'from' => ($this->currentPage - 1) * $this->perPage + 1,
'to' => min($this->currentPage * $this->perPage, $this->total),
],
'links' => [
'first' => $this->getUrl(1),
'last' => $this->getUrl($lastPage),
'prev' => $this->currentPage > 1 ? $this->getUrl($this->currentPage - 1) : null,
'next' => $this->currentPage < $lastPage ? $this->getUrl($this->currentPage + 1) : null,
'self' => $this->getUrl($this->currentPage),
]
];
}
public function toJsonApi(): array
{
$lastPage = (int) ceil($this->total / $this->perPage);
return [
'data' => $this->items,
'meta' => [
'total' => $this->total,
'page' => [
'size' => $this->perPage,
'total' => $lastPage,
]
],
'links' => [
'first' => $this->getUrl(1),
'last' => $this->getUrl($lastPage),
'prev' => $this->currentPage > 1 ? $this->getUrl($this->currentPage - 1) : null,
'next' => $this->currentPage < $lastPage ? $this->getUrl($this->currentPage + 1) : null,
'self' => $this->getUrl($this->currentPage),
]
];
}
private function getUrl(int $page): string
{
$url = parse_url($this->baseUrl);
$query = [];
if (isset($url['query'])) {
parse_str($url['query'], $query);
}
$query['page'] = $page;
$query['per_page'] = $this->perPage;
$queryString = http_build_query($query);
return ($url['path'] ?? '/') . '?' . $queryString;
}
}

View file

@ -9,6 +9,10 @@ use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack; use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware; use GuzzleHttp\Middleware;
use GuzzleHttp\MessageFormatter; use GuzzleHttp\MessageFormatter;
use Phred\Support\Http\CircuitBreakerMiddleware;
use Phred\Http\Middleware\Middleware as PhredMiddleware;
use Psr\SimpleCache\CacheInterface;
use Phred\Support\Cache\FileCache;
use Phred\Support\Contracts\ConfigInterface; use Phred\Support\Contracts\ConfigInterface;
use Phred\Support\Contracts\ServiceProviderInterface; use Phred\Support\Contracts\ServiceProviderInterface;
use Psr\Http\Client\ClientInterface; use Psr\Http\Client\ClientInterface;
@ -19,6 +23,10 @@ final class HttpServiceProvider implements ServiceProviderInterface
public function register(ContainerBuilder $builder, ConfigInterface $config): void public function register(ContainerBuilder $builder, ConfigInterface $config): void
{ {
$builder->addDefinitions([ $builder->addDefinitions([
CacheInterface::class => function (ConfigInterface $config) {
$cacheDir = getcwd() . '/storage/cache';
return new FileCache($cacheDir);
},
ClientInterface::class => function (ConfigInterface $config, Container $c) { ClientInterface::class => function (ConfigInterface $config, Container $c) {
$options = $config->get('http.client', [ $options = $config->get('http.client', [
'timeout' => 5.0, 'timeout' => 5.0,
@ -27,6 +35,29 @@ final class HttpServiceProvider implements ServiceProviderInterface
$stack = HandlerStack::create(); $stack = HandlerStack::create();
// Profiling middleware
$stack->push(function (callable $handler) {
return function (\Psr\Http\Message\RequestInterface $request, array $options) use ($handler) {
$start = microtime(true);
return $handler($request, $options)->then(
function ($response) use ($start, $request) {
$duration = microtime(true) - $start;
$host = $request->getUri()->getHost();
$key = "HTTP: " . $host;
PhredMiddleware::recordTiming($key, $duration);
return $response;
},
function ($reason) use ($start, $request) {
$duration = microtime(true) - $start;
$host = $request->getUri()->getHost();
$key = "HTTP: " . $host;
PhredMiddleware::recordTiming($key, $duration);
return \GuzzleHttp\Promise\Create::rejectionFor($reason);
}
);
};
}, 'profiler');
// Logging middleware // Logging middleware
if ($config->get('http.middleware.log', false)) { if ($config->get('http.middleware.log', false)) {
try { try {
@ -57,6 +88,14 @@ final class HttpServiceProvider implements ServiceProviderInterface
})); }));
} }
// Circuit Breaker middleware
if ($config->get('http.middleware.circuit_breaker.enabled', false)) {
$threshold = $config->get('http.middleware.circuit_breaker.threshold', 5);
$timeout = $config->get('http.middleware.circuit_breaker.timeout', 30.0);
$cache = $c->get(CacheInterface::class);
$stack->push(new CircuitBreakerMiddleware($threshold, (float) $timeout, $cache));
}
$options['handler'] = $stack; $options['handler'] = $stack;
return new Client($options); return new Client($options);

View file

@ -9,6 +9,7 @@ use Monolog\Handler\ErrorLogHandler;
use Monolog\Handler\RotatingFileHandler; use Monolog\Handler\RotatingFileHandler;
use Monolog\Handler\StreamHandler; use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogHandler; use Monolog\Handler\SyslogHandler;
use Monolog\Handler\SlackWebhookHandler;
use Monolog\Logger; use Monolog\Logger;
use Monolog\LogRecord; use Monolog\LogRecord;
use Monolog\Processor\MemoryUsageProcessor; use Monolog\Processor\MemoryUsageProcessor;
@ -95,9 +96,53 @@ final class LoggingServiceProvider implements ServiceProviderInterface
case 'errorlog': case 'errorlog':
$logger->pushHandler(new ErrorLogHandler(ErrorLogHandler::OPERATING_SYSTEM, $channelConfig['level'] ?? 'debug')); $logger->pushHandler(new ErrorLogHandler(ErrorLogHandler::OPERATING_SYSTEM, $channelConfig['level'] ?? 'debug'));
break; break;
case 'slack':
$this->createSlackHandler($logger, $channelConfig);
break;
case 'sentry':
$this->createSentryHandler($logger, $channelConfig);
break;
} }
} }
private function createSlackHandler(Logger $logger, array $config): void
{
if (!class_exists(SlackWebhookHandler::class)) {
// Silently skip if Monolog Slack handler is missing (usually bundled with Monolog 2/3)
return;
}
if (empty($config['url'])) {
return;
}
$logger->pushHandler(new SlackWebhookHandler(
$config['url'],
$config['channel'] ?? null,
$config['username'] ?? 'Phred Log Bot',
true,
null,
false,
true,
$config['level'] ?? 'critical'
));
}
private function createSentryHandler(Logger $logger, array $config): void
{
// Using sentry/sentry-monolog if available
if (!class_exists(\Sentry\Monolog\Handler::class) || !class_exists(\Sentry\SentrySdk::class)) {
return;
}
if (empty($config['dsn'])) {
return;
}
\Sentry\init(['dsn' => $config['dsn']]);
$logger->pushHandler(new \Sentry\Monolog\Handler(\Sentry\SentrySdk::getCurrentHub(), $config['level'] ?? 'error'));
}
private function ensureDir(string $path): void private function ensureDir(string $path): void
{ {
if (!is_dir($path)) { if (!is_dir($path)) {

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Phred\Providers\Core;
use DI\Container;
use DI\ContainerBuilder;
use Phred\Support\Contracts\ConfigInterface;
use Phred\Support\Contracts\ServiceProviderInterface;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Encoder\XmlEncoder;
use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Serializer\SerializerInterface;
final class SerializationServiceProvider implements ServiceProviderInterface
{
public function register(ContainerBuilder $builder, ConfigInterface $config): void
{
$builder->addDefinitions([
SerializerInterface::class => function () {
$encoders = [new XmlEncoder(), new JsonEncoder()];
// Avoid using ObjectNormalizer if symfony/property-access is missing
// Or use it only if available. For now, let's use ArrayDenormalizer which is safer
$normalizers = [new ArrayDenormalizer()];
return new Serializer($normalizers, $encoders);
},
]);
}
public function boot(Container $container): void {}
}

View file

@ -8,6 +8,9 @@ use DI\ContainerBuilder;
use League\Flysystem\Filesystem; use League\Flysystem\Filesystem;
use League\Flysystem\FilesystemOperator; use League\Flysystem\FilesystemOperator;
use League\Flysystem\Local\LocalFilesystemAdapter; use League\Flysystem\Local\LocalFilesystemAdapter;
use League\Flysystem\AwsS3V3\AwsS3V3Adapter;
use Aws\S3\S3Client;
use Phred\Support\Storage\StorageManager;
use Phred\Support\Contracts\ConfigInterface; use Phred\Support\Contracts\ConfigInterface;
use Phred\Support\Contracts\ServiceProviderInterface; use Phred\Support\Contracts\ServiceProviderInterface;
@ -16,8 +19,15 @@ final class StorageServiceProvider implements ServiceProviderInterface
public function register(ContainerBuilder $builder, ConfigInterface $config): void public function register(ContainerBuilder $builder, ConfigInterface $config): void
{ {
$builder->addDefinitions([ $builder->addDefinitions([
StorageManager::class => function (Container $c) {
$config = $c->get(ConfigInterface::class);
$storageConfig = $config->get('storage');
$defaultFilesystem = $c->get(FilesystemOperator::class);
return new StorageManager($defaultFilesystem, $storageConfig);
},
FilesystemOperator::class => \DI\get(Filesystem::class), FilesystemOperator::class => \DI\get(Filesystem::class),
Filesystem::class => function (ConfigInterface $config) { Filesystem::class => function (Container $c) {
$config = $c->get(ConfigInterface::class);
$default = $config->get('storage.default', 'local'); $default = $config->get('storage.default', 'local');
$diskConfig = $config->get("storage.disks.$default"); $diskConfig = $config->get("storage.disks.$default");
@ -27,17 +37,45 @@ final class StorageServiceProvider implements ServiceProviderInterface
$driver = $diskConfig['driver'] ?? 'local'; $driver = $diskConfig['driver'] ?? 'local';
if ($driver !== 'local') { $adapter = match ($driver) {
throw new \RuntimeException("Unsupported storage driver [$driver]. Only 'local' is supported currently."); 'local' => new LocalFilesystemAdapter($diskConfig['root']),
} 's3' => $this->createS3Adapter($diskConfig),
default => throw new \RuntimeException("Unsupported storage driver [$driver]."),
$root = $diskConfig['root']; };
$adapter = new LocalFilesystemAdapter($root);
return new Filesystem($adapter); return new Filesystem($adapter);
}, },
]); ]);
} }
private function createS3Adapter(array $config): AwsS3V3Adapter
{
if (!class_exists(S3Client::class)) {
throw new \RuntimeException("AWS SDK not found. Did you install aws/aws-sdk-php?");
}
if (!class_exists(AwsS3V3Adapter::class)) {
throw new \RuntimeException("Flysystem S3 adapter not found. Did you install league/flysystem-aws-s3-v3?");
}
$clientConfig = [
'credentials' => [
'key' => $config['key'],
'secret' => $config['secret'],
],
'region' => $config['region'],
'version' => 'latest',
];
if (!empty($config['endpoint'])) {
$clientConfig['endpoint'] = $config['endpoint'];
$clientConfig['use_path_style_endpoint'] = $config['use_path_style_endpoint'] ?? false;
}
$client = new S3Client($clientConfig);
return new AwsS3V3Adapter($client, $config['bucket']);
}
public function boot(Container $container): void {} public function boot(Container $container): void {}
} }

View file

@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace Phred\Support\Cache;
use Psr\SimpleCache\CacheInterface;
final class FileCache implements CacheInterface
{
public function __construct(private readonly string $directory)
{
if (!is_dir($this->directory)) {
@mkdir($this->directory, 0777, true);
}
}
public function get(string $key, mixed $default = null): mixed
{
$file = $this->getFilePath($key);
if (!file_exists($file)) {
return $default;
}
$content = file_get_contents($file);
if ($content === false) {
return $default;
}
$data = unserialize($content);
if ($data['expires'] !== 0 && $data['expires'] < time()) {
@unlink($file);
return $default;
}
return $data['value'];
}
public function set(string $key, mixed $value, \DateInterval|int|null $ttl = null): bool
{
$expires = 0;
if ($ttl !== null) {
if ($ttl instanceof \DateInterval) {
$expires = (new \DateTime())->add($ttl)->getTimestamp();
} else {
$expires = time() + $ttl;
}
}
$data = [
'expires' => $expires,
'value' => $value,
];
return file_put_contents($this->getFilePath($key), serialize($data)) !== false;
}
public function delete(string $key): bool
{
$file = $this->getFilePath($key);
if (file_exists($file)) {
return @unlink($file);
}
return true;
}
public function clear(): bool
{
foreach (glob($this->directory . '/*') as $file) {
if (is_file($file)) {
@unlink($file);
}
}
return true;
}
public function getMultiple(iterable $keys, mixed $default = null): iterable
{
$result = [];
foreach ($keys as $key) {
$result[$key] = $this->get($key, $default);
}
return $result;
}
public function setMultiple(iterable $values, \DateInterval|int|null $ttl = null): bool
{
foreach ($values as $key => $value) {
$this->set($key, $value, $ttl);
}
return true;
}
public function deleteMultiple(iterable $keys): bool
{
foreach ($keys as $key) {
$this->delete($key);
}
return true;
}
public function has(string $key): bool
{
return $this->get($key, $this) !== $this;
}
private function getFilePath(string $key): string
{
return $this->directory . '/' . md5($key) . '.cache';
}
}

View file

@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace Phred\Support\Http;
use GuzzleHttp\Promise\Create;
use Psr\Http\Message\RequestInterface;
use Psr\SimpleCache\CacheInterface;
/**
* A circuit breaker middleware for Guzzle with optional PSR-16 persistence.
*/
final class CircuitBreakerMiddleware
{
private static array $localFailures = [];
private static array $localLastFailureTime = [];
private static array $localIsOpen = [];
public function __construct(
private readonly int $threshold = 5,
private readonly float $timeout = 30.0,
private readonly ?CacheInterface $cache = null
) {}
public function __invoke(callable $handler): callable
{
return function (RequestInterface $request, array $options) use ($handler) {
$host = $request->getUri()->getHost();
if ($this->isCircuitOpen($host)) {
return Create::rejectionFor(
new \RuntimeException("Circuit breaker is open for host: $host")
);
}
return $handler($request, $options)->then(
function ($response) use ($host) {
if ($response instanceof \Psr\Http\Message\ResponseInterface && $response->getStatusCode() >= 500) {
$this->reportFailure($host);
} else {
$this->reportSuccess($host);
}
return $response;
},
function ($reason) use ($host) {
$this->reportFailure($host);
return Create::rejectionFor($reason);
}
);
};
}
private function isCircuitOpen(string $host): bool
{
$state = $this->getState($host);
if (!$state['isOpen']) {
return false;
}
if ((microtime(true) - $state['lastFailureTime']) > $this->timeout) {
// Half-open state in a real CB, here we just try again
$this->reportSuccess($host);
return false;
}
return true;
}
private function reportSuccess(string $host): void
{
$this->saveState($host, [
'failures' => 0,
'lastFailureTime' => 0,
'isOpen' => false,
]);
}
private function reportFailure(string $host): void
{
$state = $this->getState($host);
$state['failures']++;
$state['lastFailureTime'] = microtime(true);
if ($state['failures'] >= $this->threshold) {
$state['isOpen'] = true;
}
$this->saveState($host, $state);
}
private function getState(string $host): array
{
if ($this->cache) {
return $this->cache->get("cb.$host", [
'failures' => 0,
'lastFailureTime' => 0,
'isOpen' => false,
]);
}
return [
'failures' => self::$localFailures[$host] ?? 0,
'lastFailureTime' => self::$localLastFailureTime[$host] ?? 0,
'isOpen' => self::$localIsOpen[$host] ?? false,
];
}
private function saveState(string $host, array $state): void
{
if ($this->cache) {
$this->cache->set("cb.$host", $state, (int)$this->timeout * 2);
return;
}
self::$localFailures[$host] = $state['failures'];
self::$localLastFailureTime[$host] = $state['lastFailureTime'];
self::$localIsOpen[$host] = $state['isOpen'];
}
public static function clear(string $host = null): void
{
if ($host) {
unset(self::$localFailures[$host], self::$localLastFailureTime[$host], self::$localIsOpen[$host]);
} else {
self::$localFailures = [];
self::$localLastFailureTime = [];
self::$localIsOpen = [];
}
}
}

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Phred\Support\Storage;
use League\Flysystem\FilesystemOperator;
final class StorageManager
{
/** @var array<string, FilesystemOperator> */
private array $disks = [];
public function __construct(
private readonly FilesystemOperator $defaultDisk,
private readonly array $config = []
) {}
public function disk(?string $name = null): FilesystemOperator
{
if ($name === null) {
return $this->defaultDisk;
}
return $this->disks[$name] ?? $this->defaultDisk;
}
public function url(string $path, ?string $disk = null): string
{
$diskName = $disk ?? 'local';
$diskConfig = $this->config['disks'][$diskName] ?? null;
if (!$diskConfig || empty($diskConfig['url'])) {
return $path;
}
return rtrim($diskConfig['url'], '/') . '/' . ltrim($path, '/');
}
}

View file

@ -65,6 +65,7 @@ return new class extends Command {
if ($updateComposer) { if ($updateComposer) {
$this->updateComposerPsr4($output, $root, $name, !$noDump); $this->updateComposerPsr4($output, $root, $name, !$noDump);
} }
$output->writeln("\n<info>Full documentation available at:</info> https://getphred.com");
return 0; return 0;
} }

View file

@ -10,6 +10,12 @@ use PHPUnit\Framework\TestCase;
final class ContentNegotiationTest extends TestCase final class ContentNegotiationTest extends TestCase
{ {
protected function tearDown(): void
{
putenv('API_FORMAT');
\Phred\Support\Config::clear();
}
private function kernel(): Kernel private function kernel(): Kernel
{ {
return new Kernel(); return new Kernel();

View file

@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace Phred\Tests\Feature;
use PHPUnit\Framework\TestCase;
use Phred\Http\Kernel;
use Nyholm\Psr7\ServerRequest;
use Phred\Http\Support\Paginator;
use Symfony\Component\Serializer\SerializerInterface;
class M12FeaturesTest extends TestCase
{
public function test_serialization_service_is_bound(): void
{
$kernel = new Kernel();
$serializer = $kernel->container()->get(SerializerInterface::class);
$this->assertInstanceOf(SerializerInterface::class, $serializer);
$data = ['name' => 'John'];
$json = $serializer->serialize($data, 'json');
$this->assertEquals('{"name":"John"}', $json);
}
public function test_paginator_rest(): void
{
$items = [['id' => 1], ['id' => 2]];
$paginator = new Paginator($items, 10, 2, 1, 'http://localhost/api/posts');
$data = $paginator->toArray();
$this->assertArrayHasKey('data', $data);
$this->assertArrayHasKey('meta', $data);
$this->assertArrayHasKey('links', $data);
$this->assertEquals(10, $data['meta']['total']);
$this->assertEquals('/api/posts?page=1&per_page=2', $data['links']['self']);
}
public function test_xml_support(): void
{
$kernel = new Kernel();
$request = (new ServerRequest('GET', '/_phred/health'))
->withHeader('Accept', 'application/xml');
$response = $kernel->handle($request);
$this->assertEquals('application/xml', $response->getHeaderLine('Content-Type'));
$body = (string) $response->getBody();
$this->assertStringContainsString('<?xml', $body);
$this->assertStringContainsString('<ok>1</ok>', $body);
$this->assertStringContainsString('<framework>Phred</framework>', $body);
}
public function test_url_extension_xml(): void
{
$kernel = new Kernel();
$request = new ServerRequest('GET', '/_phred/health.xml');
$response = $kernel->handle($request);
$this->assertEquals('application/xml', $response->getHeaderLine('Content-Type'));
}
public function test_validation_middleware(): void
{
$middleware = new class extends \Phred\Http\Middleware\ValidationMiddleware {
protected function validate(\Psr\Http\Message\ServerRequestInterface $request): array
{
$body = $request->getParsedBody();
$errors = [];
if (empty($body['name'])) {
$errors['name'] = 'Name is required';
}
return $errors;
}
};
$request = new ServerRequest('POST', '/test');
$handler = new class implements \Psr\Http\Server\RequestHandlerInterface {
public function handle(\Psr\Http\Message\ServerRequestInterface $request): \Psr\Http\Message\ResponseInterface
{
return new \Nyholm\Psr7\Response(200);
}
};
$response = $middleware->process($request, $handler);
$this->assertEquals(422, $response->getStatusCode());
$data = json_decode((string) $response->getBody(), true);
$this->assertEquals('Name is required', $data['errors']['name']);
}
}

View file

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Phred\Tests\Feature;
use PHPUnit\Framework\TestCase;
use Phred\Http\Kernel;
use Nyholm\Psr7\ServerRequest;
use Phred\Support\Storage\StorageManager;
use Psr\SimpleCache\CacheInterface;
use Psr\Http\Client\ClientInterface;
use GuzzleHttp\Psr7\Request;
class M12OpportunityRadarTest extends TestCase
{
private object $app;
protected function setUp(): void
{
$root = dirname(__DIR__, 2);
$this->app = require $root . '/bootstrap/app.php';
}
public function test_storage_url_generation(): void
{
$manager = $this->app->container()->get(StorageManager::class);
$this->assertInstanceOf(StorageManager::class, $manager);
// Test with public disk url
$url = $manager->url('avatars/user.jpg', 'public');
$this->assertStringContainsString('/storage/avatars/user.jpg', $url);
}
public function test_cache_service_is_bound(): void
{
$cache = $this->app->container()->get(CacheInterface::class);
$this->assertInstanceOf(CacheInterface::class, $cache);
$cache->set('radar_test', 'working', 10);
$this->assertEquals('working', $cache->get('radar_test'));
$cache->delete('radar_test');
}
public function test_http_client_profiling(): void
{
putenv('APP_DEBUG=true');
\Phred\Support\Config::clear();
\Phred\Http\Middleware\Middleware::recordTiming('Warmup', 0.0);
$kernel = new Kernel();
$client = $kernel->container()->get(ClientInterface::class);
try {
$client->sendRequest(new Request('GET', 'http://localhost:1'));
} catch (\Throwable) {
// expected to fail
}
$timings = \Phred\Http\Middleware\Middleware::getTimings();
$this->assertArrayHasKey('HTTP: localhost', $timings);
}
}

View file

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace Phred\Tests\Feature;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use GuzzleHttp\Psr7\Request;
use Phred\Support\Http\CircuitBreakerMiddleware;
use Phred\Providers\Core\StorageServiceProvider;
use PHPUnit\Framework\TestCase;
final class NewOpportunityRadarTest extends TestCase
{
private object $app;
protected function setUp(): void
{
$root = dirname(__DIR__, 2);
$this->app = require $root . '/bootstrap/app.php';
CircuitBreakerMiddleware::clear();
}
public function testCircuitBreakerOpensAfterFailures(): void
{
$mock = new MockHandler([
new Response(500),
new Response(500),
new Response(200), // Won't be reached if threshold is 2
]);
$stack = HandlerStack::create($mock);
// Threshold = 2, Timeout = 60
$stack->push(new CircuitBreakerMiddleware(2, 60.0));
$client = new Client(['handler' => $stack]);
// First failure
$p1 = $client->getAsync('http://example.com');
try { $p1->wait(); } catch (\Throwable) {}
// Second failure -> should open circuit
$p2 = $client->getAsync('http://example.com');
try { $p2->wait(); } catch (\Throwable) {}
// Third call should be rejected by CB immediately
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Circuit breaker is open for host: example.com');
$client->get('http://example.com');
}
public function testS3AdapterResolutionThrowsWhenMissingDependencies(): void
{
$config = $this->createMock(\Phred\Support\Contracts\ConfigInterface::class);
$config->method('get')->willReturnMap([
['storage.default', 'local', 's3'],
['storage.disks.s3', null, [
'driver' => 's3',
'key' => 'key',
'secret' => 'secret',
'region' => 'us-east-1',
'bucket' => 'test',
]],
]);
$provider = new \Phred\Providers\Core\StorageServiceProvider();
$builder = new \DI\ContainerBuilder();
$builder->addDefinitions([
\Phred\Support\Contracts\ConfigInterface::class => $config,
]);
$provider->register($builder, $config);
$container = $builder->build();
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('AWS SDK not found');
$container->get(\League\Flysystem\FilesystemOperator::class);
}
}