From cf30f3e41a31a8188c236cedea9802d1a06987bf Mon Sep 17 00:00:00 2001 From: Funky Waddle Date: Tue, 23 Dec 2025 17:40:02 -0600 Subject: [PATCH] M12: Serialization/validation utilities and pagination --- bin/scripts/generate_toc.php | 86 ++++++++++++ .../ErrorFormatNegotiatorInterface.php | 2 +- src/Http/Controllers/HealthController.php | 11 +- src/Http/Kernel.php | 1 + .../ContentNegotiationMiddleware.php | 21 ++- src/Http/Middleware/DispatchMiddleware.php | 10 +- src/Http/Middleware/Middleware.php | 8 ++ .../UrlExtensionNegotiationMiddleware.php | 4 +- src/Http/Middleware/ValidationMiddleware.php | 29 ++++ .../DelegatingApiResponseFactory.php | 9 +- src/Http/Responses/XmlResponseFactory.php | 64 +++++++++ .../Support/DefaultErrorFormatNegotiator.php | 8 +- src/Http/Support/Paginator.php | 84 +++++++++++ src/Providers/Core/HttpServiceProvider.php | 39 ++++++ src/Providers/Core/LoggingServiceProvider.php | 45 ++++++ .../Core/SerializationServiceProvider.php | 34 +++++ src/Providers/Core/StorageServiceProvider.php | 52 ++++++- src/Support/Cache/FileCache.php | 110 +++++++++++++++ src/Support/Http/CircuitBreakerMiddleware.php | 131 ++++++++++++++++++ src/Support/Storage/StorageManager.php | 38 +++++ src/commands/create_module.php | 1 + tests/ContentNegotiationTest.php | 6 + tests/Feature/M12FeaturesTest.php | 90 ++++++++++++ tests/Feature/M12OpportunityRadarTest.php | 62 +++++++++ tests/Feature/NewOpportunityRadarTest.php | 81 +++++++++++ 25 files changed, 997 insertions(+), 29 deletions(-) create mode 100644 bin/scripts/generate_toc.php create mode 100644 src/Http/Middleware/ValidationMiddleware.php create mode 100644 src/Http/Responses/XmlResponseFactory.php create mode 100644 src/Http/Support/Paginator.php create mode 100644 src/Providers/Core/SerializationServiceProvider.php create mode 100644 src/Support/Cache/FileCache.php create mode 100644 src/Support/Http/CircuitBreakerMiddleware.php create mode 100644 src/Support/Storage/StorageManager.php create mode 100644 tests/Feature/M12FeaturesTest.php create mode 100644 tests/Feature/M12OpportunityRadarTest.php create mode 100644 tests/Feature/NewOpportunityRadarTest.php diff --git a/bin/scripts/generate_toc.php b/bin/scripts/generate_toc.php new file mode 100644 index 0000000..b415c55 --- /dev/null +++ b/bin/scripts/generate_toc.php @@ -0,0 +1,86 @@ + 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'); diff --git a/src/Http/Contracts/ErrorFormatNegotiatorInterface.php b/src/Http/Contracts/ErrorFormatNegotiatorInterface.php index 5c749eb..bd5addd 100644 --- a/src/Http/Contracts/ErrorFormatNegotiatorInterface.php +++ b/src/Http/Contracts/ErrorFormatNegotiatorInterface.php @@ -9,7 +9,7 @@ interface ErrorFormatNegotiatorInterface { /** * 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; diff --git a/src/Http/Controllers/HealthController.php b/src/Http/Controllers/HealthController.php index 5cbfcff..b6236f5 100644 --- a/src/Http/Controllers/HealthController.php +++ b/src/Http/Controllers/HealthController.php @@ -3,20 +3,19 @@ declare(strict_types=1); namespace Phred\Http\Controllers; -use Nyholm\Psr7\Factory\Psr17Factory; +use Phred\Http\Contracts\ApiResponseFactoryInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface as Request; final class HealthController { + public function __construct(private ApiResponseFactoryInterface $factory) {} + public function __invoke(Request $request): ResponseInterface { - $psr17 = new Psr17Factory(); - $res = $psr17->createResponse(200)->withHeader('Content-Type', 'application/json'); - $res->getBody()->write(json_encode([ + return $this->factory->ok([ 'ok' => true, 'framework' => 'Phred', - ], JSON_UNESCAPED_SLASHES)); - return $res; + ]); } } diff --git a/src/Http/Kernel.php b/src/Http/Kernel.php index 9b8cf1c..193d71d 100644 --- a/src/Http/Kernel.php +++ b/src/Http/Kernel.php @@ -118,6 +118,7 @@ final class Kernel \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\JsonApiResponseFactory::class => \DI\autowire(\Phred\Http\Responses\JsonApiResponseFactory::class), + \Phred\Http\Responses\XmlResponseFactory::class => \DI\autowire(\Phred\Http\Responses\XmlResponseFactory::class), ]); $container = $builder->build(); diff --git a/src/Http/Middleware/ContentNegotiationMiddleware.php b/src/Http/Middleware/ContentNegotiationMiddleware.php index 47413dd..ea8404d 100644 --- a/src/Http/Middleware/ContentNegotiationMiddleware.php +++ b/src/Http/Middleware/ContentNegotiationMiddleware.php @@ -19,20 +19,27 @@ class ContentNegotiationMiddleware extends Middleware private readonly ?ConfigInterface $config = 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 { $format = $this->profileSelf(function () use ($request) { $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 - $neg = $this->negotiator ?? new DefaultErrorFormatNegotiator(); - if ($neg->apiFormat($request) === 'jsonapi') { - $format = 'jsonapi'; + // Allow Accept header to override + $accept = $request->getHeaderLine('Accept'); + if (str_contains($accept, 'application/vnd.api+json')) { + 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)); diff --git a/src/Http/Middleware/DispatchMiddleware.php b/src/Http/Middleware/DispatchMiddleware.php index 5872f32..a24b49f 100644 --- a/src/Http/Middleware/DispatchMiddleware.php +++ b/src/Http/Middleware/DispatchMiddleware.php @@ -71,9 +71,13 @@ final class DispatchMiddleware implements MiddlewareInterface { $format = (string) ($request->getAttribute(ContentNegotiationMiddleware::ATTR_API_FORMAT, 'rest')); $builder = new ContainerBuilder(); - $definition = $format === 'jsonapi' - ? \DI\autowire(\Phred\Http\Responses\JsonApiResponseFactory::class) - : \DI\autowire(\Phred\Http\Responses\RestResponseFactory::class); + + $definition = match ($format) { + '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([ \Phred\Http\Contracts\ApiResponseFactoryInterface::class => $definition, ]); diff --git a/src/Http/Middleware/Middleware.php b/src/Http/Middleware/Middleware.php index af4a4fb..dfed0c2 100644 --- a/src/Http/Middleware/Middleware.php +++ b/src/Http/Middleware/Middleware.php @@ -52,6 +52,14 @@ abstract class Middleware implements MiddlewareInterface 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 { $response = new \Nyholm\Psr7\Response($status, ['Content-Type' => 'application/json']); diff --git a/src/Http/Middleware/UrlExtensionNegotiationMiddleware.php b/src/Http/Middleware/UrlExtensionNegotiationMiddleware.php index 4ade12c..1cdf3f4 100644 --- a/src/Http/Middleware/UrlExtensionNegotiationMiddleware.php +++ b/src/Http/Middleware/UrlExtensionNegotiationMiddleware.php @@ -37,9 +37,9 @@ final class UrlExtensionNegotiationMiddleware implements MiddlewareInterface 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 = $allowed ?: ['json', 'php', 'none']; + $allowed = $allowed ?: ['json', 'xml', 'php', 'none']; $uri = $request->getUri(); $path = $uri->getPath(); diff --git a/src/Http/Middleware/ValidationMiddleware.php b/src/Http/Middleware/ValidationMiddleware.php new file mode 100644 index 0000000..a1a7146 --- /dev/null +++ b/src/Http/Middleware/ValidationMiddleware.php @@ -0,0 +1,29 @@ +validate($request); + + if (!empty($errors)) { + return $this->json([ + 'errors' => $errors + ], 422); + } + + return $handler->handle($request); + } + + /** + * @return array List of validation errors + */ + abstract protected function validate(ServerRequestInterface $request): array; +} diff --git a/src/Http/Responses/DelegatingApiResponseFactory.php b/src/Http/Responses/DelegatingApiResponseFactory.php index 2e37cb9..4e2da08 100644 --- a/src/Http/Responses/DelegatingApiResponseFactory.php +++ b/src/Http/Responses/DelegatingApiResponseFactory.php @@ -17,7 +17,8 @@ final class DelegatingApiResponseFactory implements ApiResponseFactoryInterface { public function __construct( private RestResponseFactory $rest, - private JsonApiResponseFactory $jsonapi + private JsonApiResponseFactory $jsonapi, + private XmlResponseFactory $xml ) {} public function ok(array $data = []): ResponseInterface @@ -49,6 +50,10 @@ final class DelegatingApiResponseFactory implements ApiResponseFactoryInterface { $req = RequestContext::get(); $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, + }; } } diff --git a/src/Http/Responses/XmlResponseFactory.php b/src/Http/Responses/XmlResponseFactory.php new file mode 100644 index 0000000..af218ba --- /dev/null +++ b/src/Http/Responses/XmlResponseFactory.php @@ -0,0 +1,64 @@ +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; + } +} diff --git a/src/Http/Support/DefaultErrorFormatNegotiator.php b/src/Http/Support/DefaultErrorFormatNegotiator.php index 0690e23..f7bdf33 100644 --- a/src/Http/Support/DefaultErrorFormatNegotiator.php +++ b/src/Http/Support/DefaultErrorFormatNegotiator.php @@ -11,7 +11,13 @@ final class DefaultErrorFormatNegotiator implements ErrorFormatNegotiatorInterfa public function apiFormat(ServerRequest $request): string { $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 diff --git a/src/Http/Support/Paginator.php b/src/Http/Support/Paginator.php new file mode 100644 index 0000000..2fa8b37 --- /dev/null +++ b/src/Http/Support/Paginator.php @@ -0,0 +1,84 @@ + $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; + } +} diff --git a/src/Providers/Core/HttpServiceProvider.php b/src/Providers/Core/HttpServiceProvider.php index 1488795..624ac51 100644 --- a/src/Providers/Core/HttpServiceProvider.php +++ b/src/Providers/Core/HttpServiceProvider.php @@ -9,6 +9,10 @@ use GuzzleHttp\Client; use GuzzleHttp\HandlerStack; use GuzzleHttp\Middleware; 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\ServiceProviderInterface; use Psr\Http\Client\ClientInterface; @@ -19,6 +23,10 @@ final class HttpServiceProvider implements ServiceProviderInterface public function register(ContainerBuilder $builder, ConfigInterface $config): void { $builder->addDefinitions([ + CacheInterface::class => function (ConfigInterface $config) { + $cacheDir = getcwd() . '/storage/cache'; + return new FileCache($cacheDir); + }, ClientInterface::class => function (ConfigInterface $config, Container $c) { $options = $config->get('http.client', [ 'timeout' => 5.0, @@ -27,6 +35,29 @@ final class HttpServiceProvider implements ServiceProviderInterface $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 if ($config->get('http.middleware.log', false)) { 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; return new Client($options); diff --git a/src/Providers/Core/LoggingServiceProvider.php b/src/Providers/Core/LoggingServiceProvider.php index bfe627c..f7b8e51 100644 --- a/src/Providers/Core/LoggingServiceProvider.php +++ b/src/Providers/Core/LoggingServiceProvider.php @@ -9,6 +9,7 @@ use Monolog\Handler\ErrorLogHandler; use Monolog\Handler\RotatingFileHandler; use Monolog\Handler\StreamHandler; use Monolog\Handler\SyslogHandler; +use Monolog\Handler\SlackWebhookHandler; use Monolog\Logger; use Monolog\LogRecord; use Monolog\Processor\MemoryUsageProcessor; @@ -95,9 +96,53 @@ final class LoggingServiceProvider implements ServiceProviderInterface case 'errorlog': $logger->pushHandler(new ErrorLogHandler(ErrorLogHandler::OPERATING_SYSTEM, $channelConfig['level'] ?? 'debug')); 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 { if (!is_dir($path)) { diff --git a/src/Providers/Core/SerializationServiceProvider.php b/src/Providers/Core/SerializationServiceProvider.php new file mode 100644 index 0000000..9e19fa4 --- /dev/null +++ b/src/Providers/Core/SerializationServiceProvider.php @@ -0,0 +1,34 @@ +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 {} +} diff --git a/src/Providers/Core/StorageServiceProvider.php b/src/Providers/Core/StorageServiceProvider.php index 2942fa3..e8bb58d 100644 --- a/src/Providers/Core/StorageServiceProvider.php +++ b/src/Providers/Core/StorageServiceProvider.php @@ -8,6 +8,9 @@ use DI\ContainerBuilder; use League\Flysystem\Filesystem; use League\Flysystem\FilesystemOperator; 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\ServiceProviderInterface; @@ -16,8 +19,15 @@ final class StorageServiceProvider implements ServiceProviderInterface public function register(ContainerBuilder $builder, ConfigInterface $config): void { $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), - Filesystem::class => function (ConfigInterface $config) { + Filesystem::class => function (Container $c) { + $config = $c->get(ConfigInterface::class); $default = $config->get('storage.default', 'local'); $diskConfig = $config->get("storage.disks.$default"); @@ -27,17 +37,45 @@ final class StorageServiceProvider implements ServiceProviderInterface $driver = $diskConfig['driver'] ?? 'local'; - if ($driver !== 'local') { - throw new \RuntimeException("Unsupported storage driver [$driver]. Only 'local' is supported currently."); - } - - $root = $diskConfig['root']; - $adapter = new LocalFilesystemAdapter($root); + $adapter = match ($driver) { + 'local' => new LocalFilesystemAdapter($diskConfig['root']), + 's3' => $this->createS3Adapter($diskConfig), + default => throw new \RuntimeException("Unsupported storage driver [$driver]."), + }; 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 {} } diff --git a/src/Support/Cache/FileCache.php b/src/Support/Cache/FileCache.php new file mode 100644 index 0000000..69f5e42 --- /dev/null +++ b/src/Support/Cache/FileCache.php @@ -0,0 +1,110 @@ +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'; + } +} diff --git a/src/Support/Http/CircuitBreakerMiddleware.php b/src/Support/Http/CircuitBreakerMiddleware.php new file mode 100644 index 0000000..bd6a2d4 --- /dev/null +++ b/src/Support/Http/CircuitBreakerMiddleware.php @@ -0,0 +1,131 @@ +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 = []; + } + } +} diff --git a/src/Support/Storage/StorageManager.php b/src/Support/Storage/StorageManager.php new file mode 100644 index 0000000..42ef966 --- /dev/null +++ b/src/Support/Storage/StorageManager.php @@ -0,0 +1,38 @@ + */ + 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, '/'); + } +} diff --git a/src/commands/create_module.php b/src/commands/create_module.php index 08b18d9..22e4be7 100644 --- a/src/commands/create_module.php +++ b/src/commands/create_module.php @@ -65,6 +65,7 @@ return new class extends Command { if ($updateComposer) { $this->updateComposerPsr4($output, $root, $name, !$noDump); } + $output->writeln("\nFull documentation available at: https://getphred.com"); return 0; } diff --git a/tests/ContentNegotiationTest.php b/tests/ContentNegotiationTest.php index 51ccf63..79a5c76 100644 --- a/tests/ContentNegotiationTest.php +++ b/tests/ContentNegotiationTest.php @@ -10,6 +10,12 @@ use PHPUnit\Framework\TestCase; final class ContentNegotiationTest extends TestCase { + protected function tearDown(): void + { + putenv('API_FORMAT'); + \Phred\Support\Config::clear(); + } + private function kernel(): Kernel { return new Kernel(); diff --git a/tests/Feature/M12FeaturesTest.php b/tests/Feature/M12FeaturesTest.php new file mode 100644 index 0000000..4f82820 --- /dev/null +++ b/tests/Feature/M12FeaturesTest.php @@ -0,0 +1,90 @@ +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('assertStringContainsString('1', $body); + $this->assertStringContainsString('Phred', $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']); + } +} diff --git a/tests/Feature/M12OpportunityRadarTest.php b/tests/Feature/M12OpportunityRadarTest.php new file mode 100644 index 0000000..b5cef18 --- /dev/null +++ b/tests/Feature/M12OpportunityRadarTest.php @@ -0,0 +1,62 @@ +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); + } +} diff --git a/tests/Feature/NewOpportunityRadarTest.php b/tests/Feature/NewOpportunityRadarTest.php new file mode 100644 index 0000000..49147c0 --- /dev/null +++ b/tests/Feature/NewOpportunityRadarTest.php @@ -0,0 +1,81 @@ +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); + } +}