diff --git a/MILESTONES.md b/MILESTONES.md index 1180eff..c3c1823 100644 --- a/MILESTONES.md +++ b/MILESTONES.md @@ -39,17 +39,17 @@ Phred supports REST and JSON:API via env setting; batteries-included defaults, s * ~~Map common exceptions to HTTP status codes; include correlation/request IDs in responses/logs.~~ * ~~Acceptance:~~ * ~~Throwing an exception yields a standards‑compliant error response; debug mode shows Whoops page.~~ -## M5 — Dependency Injection and Service Providers -* Tasks: - * Define Service Provider interface and lifecycle (register, boot). - * Module discovery loads providers in order (core → app → module). - * Add examples for registering controllers, services, config, and routes via providers. - * Define contracts: `Phred\Contracts\Template\RendererInterface`, `Phred\Contracts\Orm\*`, `Phred\Contracts\Flags\FeatureFlagClientInterface`, `Phred\Contracts\Testing\TestRunnerInterface` (optional). - * Define config/env keys for driver selection (e.g., `TEMPLATE_DRIVER`, `ORM_DRIVER`, `FLAGS_DRIVER`, `TEST_RUNNER`). - * Provide “default adapter” Service Providers for the shipped packages and document swap procedure. -* Acceptance: - * Providers can contribute bindings and routes; order is deterministic and tested. - * A sample module can switch template/ORM/flags provider by changing `.env` and provider registration, without touching controllers/services. +## ~~M5 — Dependency Injection and Service Providers~~ +* ~~Tasks:~~ + * ~~Define Service Provider interface and lifecycle (register, boot).~~ + * ~~Module discovery loads providers in order (core → app → module).~~ + * ~~Add examples for registering controllers, services, config, and routes via providers.~~ + * ~~Define contracts: `Template\Contracts\RendererInterface`, `Orm\Contracts\*`, `Flags\Contracts\FeatureFlagClientInterface`, `Testing\Contracts\TestRunnerInterface`.~~ + * ~~Define config/env keys for driver selection (e.g., `TEMPLATE_DRIVER`, `ORM_DRIVER`, `FLAGS_DRIVER`, `TEST_RUNNER`).~~ + * ~~Provide “default adapter” Service Providers for the shipped packages and document swap procedure.~~ +* ~~Acceptance:~~ + * ~~Providers can contribute bindings and routes; order is deterministic and tested.~~ + * ~~Drivers can be switched via `.env`/config without changing controllers/services; example provider route covered by tests.~~ ## M6 — MVC: Controllers, Views, Templates * Tasks: * Controller base class and conventions (request/response helpers). @@ -101,6 +101,9 @@ Phred supports REST and JSON:API via env setting; batteries-included defaults, s * REST default: Symfony Serializer normalizers/encoders; document extension points. * Add simple validation layer (pick spec or integrate later if preferred; at minimum, input filtering and error shape alignment with Problem Details). * Pagination helpers (links/meta), REST and JSON:API compatible outputs. + * URL extension negotiation: add XML support + * Provide `XmlResponseFactory` (or encoder) and integrate with negotiation. + * Enable `xml` in `URL_EXTENSION_WHITELIST` by default. * Acceptance: * Example endpoint validates input, returns 422 with details; paginated listing includes links/meta. ## M13 — OpenAPI and documentation diff --git a/README.md b/README.md index 2aea51a..14c479d 100644 --- a/README.md +++ b/README.md @@ -23,20 +23,21 @@ A PHP MVC framework: * `TEST_PATH` is relative to both project root and each module root. * Dependency Injection * Fully Pluggable, but ships with defaults: - * Pluggability model - * Core depends on Phred contracts (`Phred\Contracts\*`) and PSRs - * Concrete implementations are provided by Service Providers. - * Swap packages by changing `.env` and enabling a provider. + * Pluggability model (M5) + * Core depends on Phred contracts and PSRs; concrete implementations are provided by Service Providers. + * Providers implement `Phred\Support\Contracts\ServiceProviderInterface` with `register(ContainerBuilder)` and `boot(Container)` methods. + * Providers are loaded in deterministic order: core → app → modules, configured in `config/providers.php`. + * Swap packages by changing `.env` / `config/app.php` drivers and enabling a provider. * Driver keys (examples) * `ORM_DRIVER=pairity|doctrine` * `TEMPLATE_DRIVER=eyrie|twig|plates` * `FLAGS_DRIVER=flagpole|unleash` * `TEST_RUNNER=codeception` * Primary contracts - * `Template\RendererInterface` - * `Orm\EntityManagerInterface` (or repositories) - * `Flags\FeatureFlagClientInterface` - * `Testing\TestRunnerInterface`. + * `Template\Contracts\RendererInterface` + * `Orm\Contracts\ConnectionInterface` (or repositories in future milestones) + * `Flags\Contracts\FeatureFlagClientInterface` + * `Testing\Contracts\TestRunnerInterface`. * Default Plug-ins * Feature Flags through `getphred/flagpole` * ORM through `getphred/pairity` (handles migrations, seeds, and db access) @@ -50,6 +51,15 @@ A PHP MVC framework: * Logging through `monolog/monolog` * Config and environment handling through `vlucas/phpdotenv` * HTTP client through `guzzlehttp/guzzle` +* URL extension negotiation (optional) + * Opt-in middleware that parses a trailing URL extension and hints content negotiation. + * Enable via env: `URL_EXTENSION_NEGOTIATION=true` (default true) + * Control allowed extensions via: `URL_EXTENSION_WHITELIST="json|php|none"` + * Defaults to `json|php|none`. XML support will be added in M12. + * Examples: + * `/users/1.json` → JSON response + * `/users/1` or `/users/1.php` → HTML (views) by convention + * Extensible: future formats can be added with a factory and whitelisting. * CONTROLLERS * Invokable controllers (Actions), * Single router call per controller, @@ -72,13 +82,94 @@ A PHP MVC framework: } } ``` + * MVC controller bases (M6) + * For API endpoints inside modules, extend `Phred\Mvc\APIController` and use response helpers: + ```php + use Phred\Mvc\APIController; + use Psr\Http\Message\ServerRequestInterface as Request; + + final class UserShowController extends APIController { + public function __invoke(Request $request) { + $user = ['id' => 123, 'name' => 'Ada']; + return $this->ok(['user' => $user]); + } + } + ``` + * For HTML endpoints inside modules, extend `Phred\Mvc\ViewController` and delegate to a module `View`: + ```php + use Phred\Mvc\ViewController; + use Psr\Http\Message\ServerRequestInterface as Request; + + final class HomePageController extends ViewController { + public function __invoke(Request $request, HomePageView $view) { + // domain data (normally from a Service) + $data = ['title' => 'Welcome', 'name' => 'world']; + // Use the view's default template by omitting the template param + return $this->renderView($view, $data); + // Or override template explicitly via 3rd param: + // return $this->renderView($view, $data, 'home'); + } + } + ``` * VIEWS * Classes for data manipulation/preparation before rendering Templates, * `$this->render(, );` to render a template. + * Base class (M6): extend `Phred\Mvc\View` in your module and optionally override `transformData()`: + ```php + use Phred\Mvc\View; + + final class HomePageView extends View { + protected string $template = 'home'; // default template + protected function transformData(array $data): array { + $data['upper'] = strtoupper($data['name'] ?? ''); + return $data; + } + } + ``` + * With the default Eyrie renderer, a simple template string `"

Hello {{upper}}

"` would produce `

Hello WORLD

`. + * Template selection conventions (M6) + * Controllers always call `renderView($view, $data, ?$templateOverride)`: + - Use default template: `renderView($view, $data)` (no third parameter) + - Override template explicitly: `renderView($view, $data, 'template_name')` + * Views own default template selection and presentation logic: + - Declare `protected string $template = 'default_name';` or override `defaultTemplate()` for dynamic selection + - The `View` decides which template to use when the controller does not pass an override + * Rationale: keeps template/presentation decisions in the View layer; controllers only make explicit overrides when necessary (flags, A/B tests, special flows). * SERVICES * for business logic. -* SERVICE PROVIDERS - * for dependency injection. +* SERVICE PROVIDERS (M5) + * for dependency injection and runtime bootstrapping. + * Configure providers in `config/providers.php`: + ```php + return [ + 'core' => [ + Phred\Providers\Core\RoutingServiceProvider::class, + Phred\Providers\Core\TemplateServiceProvider::class, + Phred\Providers\Core\OrmServiceProvider::class, + Phred\Providers\Core\FlagsServiceProvider::class, + Phred\Providers\Core\TestingServiceProvider::class, + ], + 'app' => [ + Phred\Providers\AppServiceProvider::class, + ], + 'modules' => [], + ]; + ``` + * Add routes from a provider using the `RouteRegistry` helper: + ```php + use Phred\Http\Routing\RouteRegistry; + use Phred\Http\Router; + + RouteRegistry::add(static function ($collector, Router $router): void { + $router->get('/hello', fn() => ['message' => 'Hello from a provider']); + }); + ``` + * Select drivers via env or `config/app.php` under `drivers`: + - `TEMPLATE_DRIVER=eyrie` + - `ORM_DRIVER=pairity` + - `FLAGS_DRIVER=flagpole` + - `TEST_RUNNER=codeception` + * Defaults are provided by core providers and can be swapped by changing these keys and adding alternate providers. * MIGRATIONS * for database changes. * Modular separation, similar to Django apps. diff --git a/src/Flags/Contracts/FeatureFlagClientInterface.php b/src/Flags/Contracts/FeatureFlagClientInterface.php new file mode 100644 index 0000000..1758ca0 --- /dev/null +++ b/src/Flags/Contracts/FeatureFlagClientInterface.php @@ -0,0 +1,9 @@ +container = $container ?? $this->buildContainer(); + // Providers may contribute routes during boot; ensure dispatcher is built after container init $this->dispatcher = $dispatcher ?? $this->buildDispatcher(); } @@ -48,6 +49,8 @@ final class Kernel null, filter_var(\Phred\Support\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), @@ -59,7 +62,14 @@ final class Kernel private function buildContainer(): Container { $builder = new ContainerBuilder(); - // Add definitions/bindings here as needed. + + // 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), @@ -69,7 +79,14 @@ final class Kernel \Phred\Http\Responses\RestResponseFactory::class => \DI\autowire(\Phred\Http\Responses\RestResponseFactory::class), \Phred\Http\Responses\JsonApiResponseFactory::class => \DI\autowire(\Phred\Http\Responses\JsonApiResponseFactory::class), ]); - return $builder->build(); + $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 @@ -86,6 +103,9 @@ final class Kernel } } + // 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']); diff --git a/src/Http/Middleware/UrlExtensionNegotiationMiddleware.php b/src/Http/Middleware/UrlExtensionNegotiationMiddleware.php new file mode 100644 index 0000000..605d746 --- /dev/null +++ b/src/Http/Middleware/UrlExtensionNegotiationMiddleware.php @@ -0,0 +1,102 @@ + json + * xml -> xml (not implemented yet; reserved for M12) + * php/none -> html + * - Optionally, sets Accept header mapping for downstream negotiation: + * json -> application/json + * xml -> application/xml (reserved) + * html -> text/html + */ +final class UrlExtensionNegotiationMiddleware implements MiddlewareInterface +{ + public const ATTR_FORMAT_HINT = 'phred.format_hint'; + + public function process(Request $request, Handler $handler): ResponseInterface + { + $enabled = filter_var((string) Config::get('URL_EXTENSION_NEGOTIATION', 'true'), FILTER_VALIDATE_BOOLEAN); + if (!$enabled) { + return $handler->handle($request); + } + + $whitelistRaw = (string) Config::get('URL_EXTENSION_WHITELIST', 'json|php|none'); + $allowed = array_filter(array_map('trim', explode('|', strtolower($whitelistRaw)))); + $allowed = $allowed ?: ['json', 'php', 'none']; + + $uri = $request->getUri(); + $path = $uri->getPath(); + + $ext = null; + if (preg_match('/\.([a-z0-9]+)$/i', $path, $m)) { + $candidate = strtolower($m[1]); + if (in_array($candidate, $allowed, true)) { + $ext = $candidate; + // strip the extension from the path for routing purposes + $path = substr($path, 0, - (strlen($candidate) + 1)); + } + } else { + // no extension → treat as 'none' if allowed + if (in_array('none', $allowed, true)) { + $ext = 'none'; + } + } + + if ($ext !== null) { + $hint = $this->mapToHint($ext); + if ($hint !== null) { + $request = $request->withAttribute(self::ATTR_FORMAT_HINT, $hint); + // Optionally set an Accept header override for downstream negotiation + $accept = $this->mapToAccept($hint); + if ($accept !== null) { + $request = $request->withHeader('Accept', $accept); + } + } + } + + // If we modified the path, update the URI so router matches sans extension + if ($path !== $uri->getPath()) { + $newUri = $uri->withPath($path === '' ? '/' : $path); + $request = $request->withUri($newUri); + } + + return $handler->handle($request); + } + + private function mapToHint(string $ext): ?string + { + return match ($ext) { + 'json' => 'json', + 'xml' => 'xml', // reserved for M12 + 'php', 'none' => 'html', + default => null, + }; + } + + private function mapToAccept(string $hint): ?string + { + return match ($hint) { + 'json' => 'application/json', + 'xml' => 'application/xml', // reserved for M12 + 'html' => 'text/html', + default => null, + }; + } +} diff --git a/src/Http/Routing/RouteRegistry.php b/src/Http/Routing/RouteRegistry.php new file mode 100644 index 0000000..61c4ed2 --- /dev/null +++ b/src/Http/Routing/RouteRegistry.php @@ -0,0 +1,34 @@ + */ + private static array $callbacks = []; + + public static function add(callable $registrar): void + { + self::$callbacks[] = $registrar; + } + + public static function clear(): void + { + self::$callbacks = []; + } + + public static function apply(RouteCollector $collector, Router $router): void + { + foreach (self::$callbacks as $cb) { + $cb($collector, $router); + } + } +} diff --git a/src/Mvc/APIController.php b/src/Mvc/APIController.php new file mode 100644 index 0000000..98844cc --- /dev/null +++ b/src/Mvc/APIController.php @@ -0,0 +1,31 @@ +responses->ok($data); + } + + protected function created(array $data = [], ?string $location = null): object + { + return $this->responses->created($data, $location); + } + + protected function noContent(): object + { + return $this->responses->noContent(); + } + + protected function error(int $status, string $title, ?string $detail = null, array $extra = []): object + { + return $this->responses->error($status, $title, $detail, $extra); + } +} diff --git a/src/Mvc/Controller.php b/src/Mvc/Controller.php new file mode 100644 index 0000000..90ececb --- /dev/null +++ b/src/Mvc/Controller.php @@ -0,0 +1,9 @@ +transformData($data); + $tpl = $template ?? $this->defaultTemplate(); + return $this->renderer->render($tpl, $prepared); + } + + public function defaultTemplate(): string + { + return $this->template; + } +} diff --git a/src/Mvc/ViewController.php b/src/Mvc/ViewController.php new file mode 100644 index 0000000..7cc3b57 --- /dev/null +++ b/src/Mvc/ViewController.php @@ -0,0 +1,42 @@ +psr17 = new Psr17Factory(); + } + + /** + * Return an HTML response with the provided content. + */ + protected function html(string $content, int $status = 200, array $headers = []): ResponseInterface + { + $response = $this->psr17->createResponse($status)->withHeader('Content-Type', 'text/html; charset=utf-8'); + foreach ($headers as $k => $v) { + $response = $response->withHeader((string) $k, (string) $v); + } + $response->getBody()->write($content); + return $response; + } + + /** + * Convenience to render a module View and return an HTML response. + * The `$template` is optional; when omitted (null), the view should use its default template. + */ + protected function renderView(View $view, array $data = [], ?string $template = null, int $status = 200, array $headers = []): ResponseInterface + { + // Delegate template selection to the View; when $template is null, + // the View may use its default template. + $markup = $view->render($data, $template); + return $this->html($markup, $status, $headers); + } +} diff --git a/src/Mvc/ViewWithDefaultTemplate.php b/src/Mvc/ViewWithDefaultTemplate.php new file mode 100644 index 0000000..407a269 --- /dev/null +++ b/src/Mvc/ViewWithDefaultTemplate.php @@ -0,0 +1,9 @@ +connected = true; + } + + public function isConnected(): bool + { + return $this->connected; + } +} diff --git a/src/Providers/AppServiceProvider.php b/src/Providers/AppServiceProvider.php new file mode 100644 index 0000000..04b8bc8 --- /dev/null +++ b/src/Providers/AppServiceProvider.php @@ -0,0 +1,33 @@ +get('/_phred/app', static function () { + $psr17 = new Psr17Factory(); + $res = $psr17->createResponse(200)->withHeader('Content-Type', 'application/json'); + $res->getBody()->write(json_encode(['app' => true], JSON_UNESCAPED_SLASHES)); + return $res; + }); + }); + } +} diff --git a/src/Providers/Core/FlagsServiceProvider.php b/src/Providers/Core/FlagsServiceProvider.php new file mode 100644 index 0000000..95d81ed --- /dev/null +++ b/src/Providers/Core/FlagsServiceProvider.php @@ -0,0 +1,27 @@ + \Phred\Flags\FlagpoleClient::class, + default => \Phred\Flags\FlagpoleClient::class, + }; + + $builder->addDefinitions([ + \Phred\Flags\Contracts\FeatureFlagClientInterface::class => \DI\autowire($impl), + ]); + } + + public function boot(Container $container): void {} +} diff --git a/src/Providers/Core/OrmServiceProvider.php b/src/Providers/Core/OrmServiceProvider.php new file mode 100644 index 0000000..4ffb159 --- /dev/null +++ b/src/Providers/Core/OrmServiceProvider.php @@ -0,0 +1,27 @@ + \Phred\Orm\PairityConnection::class, + default => \Phred\Orm\PairityConnection::class, + }; + + $builder->addDefinitions([ + \Phred\Orm\Contracts\ConnectionInterface::class => \DI\autowire($impl), + ]); + } + + public function boot(Container $container): void {} +} diff --git a/src/Providers/Core/RoutingServiceProvider.php b/src/Providers/Core/RoutingServiceProvider.php new file mode 100644 index 0000000..e73be2d --- /dev/null +++ b/src/Providers/Core/RoutingServiceProvider.php @@ -0,0 +1,27 @@ + \Phred\Template\EyrieRenderer::class, + default => \Phred\Template\EyrieRenderer::class, + }; + + $builder->addDefinitions([ + \Phred\Template\Contracts\RendererInterface::class => \DI\autowire($impl), + ]); + } + + public function boot(Container $container): void {} +} diff --git a/src/Providers/Core/TestingServiceProvider.php b/src/Providers/Core/TestingServiceProvider.php new file mode 100644 index 0000000..1c9523a --- /dev/null +++ b/src/Providers/Core/TestingServiceProvider.php @@ -0,0 +1,27 @@ + \Phred\Testing\CodeceptionRunner::class, + default => \Phred\Testing\CodeceptionRunner::class, + }; + + $builder->addDefinitions([ + \Phred\Testing\Contracts\TestRunnerInterface::class => \DI\autowire($impl), + ]); + } + + public function boot(Container $container): void {} +} diff --git a/src/Support/Contracts/ServiceProviderInterface.php b/src/Support/Contracts/ServiceProviderInterface.php new file mode 100644 index 0000000..a0ea872 --- /dev/null +++ b/src/Support/Contracts/ServiceProviderInterface.php @@ -0,0 +1,24 @@ + */ + private array $providers = []; + + public function __construct(private readonly ConfigInterface $config) + { + } + + public function load(): void + { + $this->providers = []; + $core = (array) Config::get('providers.core', []); + $app = (array) Config::get('providers.app', []); + $modules = (array) Config::get('providers.modules', []); + + foreach ([$core, $app, $modules] as $group) { + foreach ($group as $class) { + if (is_string($class) && class_exists($class)) { + $instance = new $class(); + if ($instance instanceof ServiceProviderInterface) { + $this->providers[] = $instance; + } + } + } + } + } + + public function registerAll(ContainerBuilder $builder): void + { + foreach ($this->providers as $provider) { + $provider->register($builder, $this->config); + } + } + + public function bootAll(Container $container): void + { + foreach ($this->providers as $provider) { + $provider->boot($container); + } + } +} diff --git a/src/Template/Contracts/RendererInterface.php b/src/Template/Contracts/RendererInterface.php new file mode 100644 index 0000000..7cb2250 --- /dev/null +++ b/src/Template/Contracts/RendererInterface.php @@ -0,0 +1,13 @@ + $v) { + $out = str_replace('{{' . $k . '}}', (string) $v, $out); + } + return $out; + } +} diff --git a/src/Testing/CodeceptionRunner.php b/src/Testing/CodeceptionRunner.php new file mode 100644 index 0000000..0b78512 --- /dev/null +++ b/src/Testing/CodeceptionRunner.php @@ -0,0 +1,15 @@ +getParentClass()->getProperty('template'); + $prop->setAccessible(true); + $prop->setValue($view, '

Hello {{name}}

'); + + $html = $view->render(['name' => 'world']); + $this->assertSame('

Hello WORLD

', $html); + } +} diff --git a/tests/ProviderRouteTest.php b/tests/ProviderRouteTest.php new file mode 100644 index 0000000..571219f --- /dev/null +++ b/tests/ProviderRouteTest.php @@ -0,0 +1,28 @@ +assertInstanceOf(\Phred\Http\Kernel::class, $app); + + $request = new ServerRequest('GET', '/_phred/app'); + $response = $app->handle($request); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); + $data = json_decode((string) $response->getBody(), true); + $this->assertIsArray($data); + $this->assertArrayHasKey('app', $data); + $this->assertTrue($data['app']); + } +} diff --git a/tests/UrlExtensionNegotiationTest.php b/tests/UrlExtensionNegotiationTest.php new file mode 100644 index 0000000..a77b210 --- /dev/null +++ b/tests/UrlExtensionNegotiationTest.php @@ -0,0 +1,48 @@ +handle($request); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); + $payload = json_decode((string) $response->getBody(), true); + $this->assertIsArray($payload); + $this->assertSame('rest', $payload['format'] ?? null); + } + + public function testNoExtensionHonorsWhitelistAndDoesNotBreakRouting(): void + { + putenv('URL_EXTENSION_NEGOTIATION=true'); + putenv('URL_EXTENSION_WHITELIST=json|php|none'); + + $root = dirname(__DIR__); + /** @var object $app */ + $app = require $root . '/bootstrap/app.php'; + + $request = new ServerRequest('GET', '/_phred/format'); + $response = $app->handle($request); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); + $payload = json_decode((string) $response->getBody(), true); + $this->assertIsArray($payload); + $this->assertSame('rest', $payload['format'] ?? null); + } +}