Framework/src/Http/Kernel.php
Funky Waddle cf30f3e41a
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
M12: Serialization/validation utilities and pagination
2025-12-23 17:40:02 -06:00

191 lines
8.8 KiB
PHP

<?php
declare(strict_types=1);
namespace Phred\Http;
use DI\Container;
use DI\ContainerBuilder;
use FastRoute\Dispatcher;
use FastRoute\RouteCollector;
use Nyholm\Psr7\Factory\Psr17Factory;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface as ServerRequest;
use Relay\Relay;
use function FastRoute\simpleDispatcher;
/**
* Core HTTP Kernel builds container, routes, and PSR-15 pipeline and processes requests.
*/
final class Kernel
{
private Container $container;
private Dispatcher $dispatcher;
public function __construct(?Container $container = null, ?Dispatcher $dispatcher = null)
{
$this->container = $container ?? $this->buildContainer();
// Providers may contribute routes during boot; ensure dispatcher is built after container init
$this->dispatcher = $dispatcher ?? $this->buildDispatcher();
}
public function container(): Container
{
return $this->container;
}
public function dispatcher(): Dispatcher
{
return $this->dispatcher;
}
public function handle(ServerRequest $request): ResponseInterface
{
$psr17 = new Psr17Factory();
$config = $this->container->get(\Phred\Support\Contracts\ConfigInterface::class);
// CORS
$corsSettings = new \Neomerx\Cors\Strategies\Settings();
$corsSettings->init(
parse_url((string)getenv('APP_URL'), PHP_URL_SCHEME) ?: 'http',
parse_url((string)getenv('APP_URL'), PHP_URL_HOST) ?: 'localhost',
(int)parse_url((string)getenv('APP_URL'), PHP_URL_PORT) ?: 80
);
$corsSettings->setAllowedOrigins($config->get('cors.origin', ['*']));
$corsSettings->setAllowedMethods($config->get('cors.methods', ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']));
$corsSettings->setAllowedHeaders($config->get('cors.headers.allow', ['Content-Type', 'Accept', 'Authorization', 'X-Requested-With']));
$corsSettings->enableAllOriginsAllowed();
$corsSettings->enableAllMethodsAllowed();
$corsSettings->enableAllHeadersAllowed();
$middleware = [];
if (filter_var($config->get('APP_DEBUG', false), FILTER_VALIDATE_BOOLEAN)) {
$middleware[] = new class extends Middleware\Middleware {
public function process(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Server\RequestHandlerInterface $handler): \Psr\Http\Message\ResponseInterface
{
self::$timings = []; // Reset timings for each request in debug mode
$response = $handler->handle($request);
$timings = self::getTimings();
if (!empty($timings)) {
$encoded = json_encode($timings, JSON_UNESCAPED_SLASHES);
if ($encoded) {
$response = $response->withHeader('X-Phred-Timings', $encoded);
}
}
return $response;
}
};
}
$middleware = array_merge($middleware, [
// Security headers
new Middleware\Security\SecureHeadersMiddleware($config),
// CORS
new \Middlewares\Cors(\Neomerx\Cors\Analyzer::instance($corsSettings)),
new Middleware\ProblemDetailsMiddleware(
filter_var($config->get('APP_DEBUG', 'false'), FILTER_VALIDATE_BOOLEAN),
null,
null,
filter_var($config->get('API_PROBLEM_DETAILS', 'true'), FILTER_VALIDATE_BOOLEAN)
),
// Perform extension-based content negotiation hinting before standard negotiation
new Middleware\UrlExtensionNegotiationMiddleware(),
new Middleware\ContentNegotiationMiddleware(),
new Middleware\RoutingMiddleware($this->dispatcher, $psr17),
new Middleware\DispatchMiddleware($psr17),
]);
$relay = new Relay($middleware);
return $relay->handle($request);
}
private function buildContainer(): Container
{
$builder = new ContainerBuilder();
// Allow service providers to register definitions before defaults
$configAdapter = new \Phred\Support\DefaultConfig();
$providers = new \Phred\Support\ProviderRepository($configAdapter);
$providers->load();
$providers->registerAll($builder);
// Add core definitions/bindings
$builder->addDefinitions([
\Phred\Support\Contracts\ConfigInterface::class => \DI\autowire(\Phred\Support\DefaultConfig::class),
\Phred\Http\Contracts\ErrorFormatNegotiatorInterface::class => \DI\autowire(\Phred\Http\Support\DefaultErrorFormatNegotiator::class),
\Phred\Http\Contracts\RequestIdProviderInterface::class => \DI\autowire(\Phred\Http\Support\DefaultRequestIdProvider::class),
\Phred\Http\Contracts\ExceptionToStatusMapperInterface::class => \DI\autowire(\Phred\Http\Support\DefaultExceptionToStatusMapper::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\JsonApiResponseFactory::class => \DI\autowire(\Phred\Http\Responses\JsonApiResponseFactory::class),
\Phred\Http\Responses\XmlResponseFactory::class => \DI\autowire(\Phred\Http\Responses\XmlResponseFactory::class),
]);
$container = $builder->build();
// Reset provider-registered routes to avoid duplicates across multiple kernel instantiations (e.g., tests)
\Phred\Http\Routing\RouteRegistry::clear();
// Boot providers after container is available
$providers->bootAll($container);
return $container;
}
private function buildDispatcher(): Dispatcher
{
$routesPath = dirname(__DIR__, 2) . '/routes';
$collector = static function (RouteCollector $r) use ($routesPath): void {
// Load user-defined routes if present
$router = new Router($r);
foreach (['web.php', 'api.php'] as $file) {
$path = $routesPath . '/' . $file;
if (is_file($path)) {
/** @noinspection PhpIncludeInspection */
(static function ($router) use ($path) { require $path; })($router);
}
}
// Load module route files under prefixes defined in routes/web.php via RouteGroups includes.
// Additionally, as a convenience, auto-mount modules without explicit includes using folder name as prefix.
$modulesDir = dirname(__DIR__, 2) . '/modules';
if (is_dir($modulesDir)) {
$entries = array_values(array_filter(scandir($modulesDir) ?: [], static fn($e) => $e !== '.' && $e !== '..'));
sort($entries, SORT_STRING);
foreach ($entries as $mod) {
$modRoutes = $modulesDir . '/' . $mod . '/Routes';
if (!is_dir($modRoutes)) {
continue;
}
// Auto-mount only if the module's web.php wasn't already included via RouteGroups in root file.
$autoInclude = function (string $relative, string $prefix) use ($modRoutes, $router): void {
$file = $modRoutes . '/' . $relative;
if (is_file($file)) {
$router->group('/' . strtolower($prefix), static function (Router $r) use ($file): void {
/** @noinspection PhpIncludeInspection */
(static function ($router) use ($file) { require $file; })($r);
});
}
};
$autoInclude('web.php', $mod);
// api.php can be auto-mounted under /api/<module>
$apiFile = $modRoutes . '/api.php';
if (is_file($apiFile)) {
$router->group('/api/' . strtolower($mod), static function (Router $r) use ($apiFile): void {
/** @noinspection PhpIncludeInspection */
(static function ($router) use ($apiFile) { require $apiFile; })($r);
});
}
}
}
// Allow providers to contribute routes
\Phred\Http\Routing\RouteRegistry::apply($r, $router);
// Ensure default demo routes exist for acceptance/demo
$r->addRoute('GET', '/_phred/health', [Controllers\HealthController::class, '__invoke']);
$r->addRoute('GET', '/_phred/format', [Controllers\FormatController::class, '__invoke']);
};
return simpleDispatcher($collector);
}
}