Implement M5 service providers, M6 MVC bases, and URL extension negotiation; update docs and tests

• M5: Add ServiceProviderInterface and ProviderRepository; integrate providers into Kernel (register before container build, boot after); add RouteRegistry with clear(); add default core providers (Routing, Template, ORM, Flags, Testing) and AppServiceProvider; add contracts and default drivers (Template/Eyrie, Orm/Pairity, Flags/Flagpole, Testing/Codeception)
• Routing: allow providers to contribute routes; add ProviderRouteTest
• Config: add config/providers.php; extend config/app.php with driver keys; document env keys
• M6: Introduce MVC bases: Controller, APIController (JSON helpers), ViewController (html + renderView helpers), View (transformData + renderer); add ViewWithDefaultTemplate and default-template flow; adjust method signatures to data-first and delegate template override to View
• HTTP: Add UrlExtensionNegotiationMiddleware (opt-in via URL_EXTENSION_NEGOTIATION, whitelist via URL_EXTENSION_WHITELIST with default json|php|none); wire before ContentNegotiationMiddleware
• Tests: add UrlExtensionNegotiationTest and MvcViewTest; ensure RouteRegistry::clear prevents duplicate routes in tests
• Docs: Update README with M5 provider usage, M6 MVC examples and template selection conventions, and URL extension negotiation; mark M5 complete in MILESTONES; add M12 task to provide XML support and enable xml in whitelist by default
This commit is contained in:
Funky Waddle 2025-12-15 16:08:57 -06:00
parent fd1c9d23df
commit 7d4265d60e
29 changed files with 873 additions and 23 deletions

View file

@ -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.~~ * ~~Map common exceptions to HTTP status codes; include correlation/request IDs in responses/logs.~~
* ~~Acceptance:~~ * ~~Acceptance:~~
* ~~Throwing an exception yields a standardscompliant error response; debug mode shows Whoops page.~~ * ~~Throwing an exception yields a standardscompliant error response; debug mode shows Whoops page.~~
## M5 — Dependency Injection and Service Providers ## ~~M5 — Dependency Injection and Service Providers~~
* Tasks: * ~~Tasks:~~
* Define Service Provider interface and lifecycle (register, boot). * ~~Define Service Provider interface and lifecycle (register, boot).~~
* Module discovery loads providers in order (core → app → module). * ~~Module discovery loads providers in order (core → app → module).~~
* Add examples for registering controllers, services, config, and routes via providers. * ~~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 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`). * ~~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. * ~~Provide “default adapter” Service Providers for the shipped packages and document swap procedure.~~
* Acceptance: * ~~Acceptance:~~
* Providers can contribute bindings and routes; order is deterministic and tested. * ~~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. * ~~Drivers can be switched via `.env`/config without changing controllers/services; example provider route covered by tests.~~
## M6 — MVC: Controllers, Views, Templates ## M6 — MVC: Controllers, Views, Templates
* Tasks: * Tasks:
* Controller base class and conventions (request/response helpers). * 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. * 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). * 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. * 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: * Acceptance:
* Example endpoint validates input, returns 422 with details; paginated listing includes links/meta. * Example endpoint validates input, returns 422 with details; paginated listing includes links/meta.
## M13 — OpenAPI and documentation ## M13 — OpenAPI and documentation

111
README.md
View file

@ -23,20 +23,21 @@ A PHP MVC framework:
* `TEST_PATH` is relative to both project root and each module root. * `TEST_PATH` is relative to both project root and each module root.
* Dependency Injection * Dependency Injection
* Fully Pluggable, but ships with defaults: * Fully Pluggable, but ships with defaults:
* Pluggability model * Pluggability model (M5)
* Core depends on Phred contracts (`Phred\Contracts\*`) and PSRs * Core depends on Phred contracts and PSRs; concrete implementations are provided by Service Providers.
* Concrete implementations are provided by Service Providers. * Providers implement `Phred\Support\Contracts\ServiceProviderInterface` with `register(ContainerBuilder)` and `boot(Container)` methods.
* Swap packages by changing `.env` and enabling a provider. * 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) * Driver keys (examples)
* `ORM_DRIVER=pairity|doctrine` * `ORM_DRIVER=pairity|doctrine`
* `TEMPLATE_DRIVER=eyrie|twig|plates` * `TEMPLATE_DRIVER=eyrie|twig|plates`
* `FLAGS_DRIVER=flagpole|unleash` * `FLAGS_DRIVER=flagpole|unleash`
* `TEST_RUNNER=codeception` * `TEST_RUNNER=codeception`
* Primary contracts * Primary contracts
* `Template\RendererInterface` * `Template\Contracts\RendererInterface`
* `Orm\EntityManagerInterface` (or repositories) * `Orm\Contracts\ConnectionInterface` (or repositories in future milestones)
* `Flags\FeatureFlagClientInterface` * `Flags\Contracts\FeatureFlagClientInterface`
* `Testing\TestRunnerInterface`. * `Testing\Contracts\TestRunnerInterface`.
* Default Plug-ins * Default Plug-ins
* Feature Flags through `getphred/flagpole` * Feature Flags through `getphred/flagpole`
* ORM through `getphred/pairity` (handles migrations, seeds, and db access) * ORM through `getphred/pairity` (handles migrations, seeds, and db access)
@ -50,6 +51,15 @@ A PHP MVC framework:
* Logging through `monolog/monolog` * Logging through `monolog/monolog`
* Config and environment handling through `vlucas/phpdotenv` * Config and environment handling through `vlucas/phpdotenv`
* HTTP client through `guzzlehttp/guzzle` * 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 * CONTROLLERS
* Invokable controllers (Actions), * Invokable controllers (Actions),
* Single router call per controller, * 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 * VIEWS
* Classes for data manipulation/preparation before rendering Templates, * Classes for data manipulation/preparation before rendering Templates,
* `$this->render(<template_name>, <data_array>);` to render a template. * `$this->render(<template_name>, <data_array>);` 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 `"<h1>Hello {{upper}}</h1>"` would produce `<h1>Hello WORLD</h1>`.
* 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 * SERVICES
* for business logic. * for business logic.
* SERVICE PROVIDERS * SERVICE PROVIDERS (M5)
* for dependency injection. * 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 * MIGRATIONS
* for database changes. * for database changes.
* Modular separation, similar to Django apps. * Modular separation, similar to Django apps.

View file

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Phred\Flags\Contracts;
interface FeatureFlagClientInterface
{
public function isEnabled(string $flagKey, array $context = []): bool;
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Phred\Flags;
use Phred\Flags\Contracts\FeatureFlagClientInterface;
final class FlagpoleClient implements FeatureFlagClientInterface
{
public function isEnabled(string $flagKey, array $context = []): bool
{
// default to false in placeholder implementation
return false;
}
}

View file

@ -25,6 +25,7 @@ final class Kernel
public function __construct(?Container $container = null, ?Dispatcher $dispatcher = null) public function __construct(?Container $container = null, ?Dispatcher $dispatcher = null)
{ {
$this->container = $container ?? $this->buildContainer(); $this->container = $container ?? $this->buildContainer();
// Providers may contribute routes during boot; ensure dispatcher is built after container init
$this->dispatcher = $dispatcher ?? $this->buildDispatcher(); $this->dispatcher = $dispatcher ?? $this->buildDispatcher();
} }
@ -48,6 +49,8 @@ final class Kernel
null, null,
filter_var(\Phred\Support\Config::get('API_PROBLEM_DETAILS', 'true'), FILTER_VALIDATE_BOOLEAN) 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\ContentNegotiationMiddleware(),
new Middleware\RoutingMiddleware($this->dispatcher, $psr17), new Middleware\RoutingMiddleware($this->dispatcher, $psr17),
new Middleware\DispatchMiddleware($psr17), new Middleware\DispatchMiddleware($psr17),
@ -59,7 +62,14 @@ final class Kernel
private function buildContainer(): Container private function buildContainer(): Container
{ {
$builder = new ContainerBuilder(); $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([ $builder->addDefinitions([
\Phred\Support\Contracts\ConfigInterface::class => \DI\autowire(\Phred\Support\DefaultConfig::class), \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\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\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),
]); ]);
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 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 // Ensure default demo routes exist for acceptance/demo
$r->addRoute('GET', '/_phred/health', [Controllers\HealthController::class, '__invoke']); $r->addRoute('GET', '/_phred/health', [Controllers\HealthController::class, '__invoke']);
$r->addRoute('GET', '/_phred/format', [Controllers\FormatController::class, '__invoke']); $r->addRoute('GET', '/_phred/format', [Controllers\FormatController::class, '__invoke']);

View file

@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Middleware;
use Phred\Support\Config;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface as Handler;
/**
* Parses a trailing URL extension and hints content negotiation.
*
* - Controlled by env/config `URL_EXTENSION_NEGOTIATION` (bool, default true)
* - Allowed extensions by env/config `URL_EXTENSION_WHITELIST`
* - Pipe-separated list: e.g., "json|php|none" (default: "json|php|none")
* - Behavior:
* - Detects and strips ".ext" at the end of path if ext is whitelisted (except `none` which means no ext)
* - Sets request attribute `phred.format_hint` to ext (json|xml|html) mapping:
* json -> 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,
};
}
}

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Phred\Http\Routing;
use FastRoute\RouteCollector;
use Phred\Http\Router;
/**
* Allows providers to register route callbacks that will be applied
* when the FastRoute dispatcher is built.
*/
final class RouteRegistry
{
/** @var list<callable(RouteCollector, Router):void> */
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);
}
}
}

31
src/Mvc/APIController.php Normal file
View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Phred\Mvc;
use Phred\Http\Contracts\ApiResponseFactoryInterface as Responses;
abstract class APIController extends Controller
{
public function __construct(protected Responses $responses) {}
protected function ok(array $data = []): object
{
return $this->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);
}
}

9
src/Mvc/Controller.php Normal file
View file

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Phred\Mvc;
abstract class Controller
{
// Common utilities for future use can live here.
}

36
src/Mvc/View.php Normal file
View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Phred\Mvc;
use Phred\Template\Contracts\RendererInterface;
abstract class View implements ViewWithDefaultTemplate
{
protected string $template = '';
public function __construct(protected RendererInterface $renderer) {}
/**
* Prepare data for the template. Subclasses may override to massage input.
*/
protected function transformData(array $data): array
{
return $data;
}
/**
* Render using transformed data and either the provided template override or the default template.
*/
public function render(array $data = [], ?string $template = null): string
{
$prepared = $this->transformData($data);
$tpl = $template ?? $this->defaultTemplate();
return $this->renderer->render($tpl, $prepared);
}
public function defaultTemplate(): string
{
return $this->template;
}
}

View file

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Phred\Mvc;
use Nyholm\Psr7\Factory\Psr17Factory;
use Psr\Http\Message\ResponseInterface;
abstract class ViewController extends Controller
{
private Psr17Factory $psr17;
public function __construct()
{
$this->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);
}
}

View file

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Phred\Mvc;
interface ViewWithDefaultTemplate
{
public function defaultTemplate(): string;
}

View file

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Phred\Orm\Contracts;
interface ConnectionInterface
{
public function connect(): void;
public function isConnected(): bool;
}

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Phred\Orm;
use Phred\Orm\Contracts\ConnectionInterface;
final class PairityConnection implements ConnectionInterface
{
private bool $connected = false;
public function connect(): void
{
$this->connected = true;
}
public function isConnected(): bool
{
return $this->connected;
}
}

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Phred\Providers;
use DI\Container;
use DI\ContainerBuilder;
use Nyholm\Psr7\Factory\Psr17Factory;
use Phred\Http\Routing\RouteRegistry;
use Phred\Http\Router;
use Phred\Support\Contracts\ConfigInterface;
use Phred\Support\Contracts\ServiceProviderInterface;
final class AppServiceProvider implements ServiceProviderInterface
{
public function register(ContainerBuilder $builder, ConfigInterface $config): void
{
// Place app-specific bindings here as needed.
}
public function boot(Container $container): void
{
// Demonstrate adding a route from a provider
RouteRegistry::add(static function ($collector, Router $router): void {
$router->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;
});
});
}
}

View file

@ -0,0 +1,27 @@
<?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;
final class FlagsServiceProvider implements ServiceProviderInterface
{
public function register(ContainerBuilder $builder, ConfigInterface $config): void
{
$driver = (string) \Phred\Support\Config::get('app.drivers.flags', 'flagpole');
$impl = match ($driver) {
'flagpole' => \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 {}
}

View file

@ -0,0 +1,27 @@
<?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;
final class OrmServiceProvider implements ServiceProviderInterface
{
public function register(ContainerBuilder $builder, ConfigInterface $config): void
{
$driver = (string) \Phred\Support\Config::get('app.drivers.orm', 'pairity');
$impl = match ($driver) {
'pairity' => \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 {}
}

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Phred\Providers\Core;
use DI\Container;
use DI\ContainerBuilder;
use Phred\Http\Routing\RouteRegistry;
use Phred\Support\Contracts\ConfigInterface;
use Phred\Support\Contracts\ServiceProviderInterface;
final class RoutingServiceProvider implements ServiceProviderInterface
{
public function register(ContainerBuilder $builder, ConfigInterface $config): void
{
// No bindings required; route registry is static helper for now.
}
public function boot(Container $container): void
{
// Core routes can be appended here in future if needed.
// Keeping provider to illustrate ordering and future extension point.
RouteRegistry::add(static function (): void {
// no-op
});
}
}

View file

@ -0,0 +1,27 @@
<?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;
final class TemplateServiceProvider implements ServiceProviderInterface
{
public function register(ContainerBuilder $builder, ConfigInterface $config): void
{
$driver = (string) \Phred\Support\Config::get('app.drivers.template', 'eyrie');
$impl = match ($driver) {
'eyrie' => \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 {}
}

View file

@ -0,0 +1,27 @@
<?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;
final class TestingServiceProvider implements ServiceProviderInterface
{
public function register(ContainerBuilder $builder, ConfigInterface $config): void
{
$driver = (string) \Phred\Support\Config::get('app.drivers.test_runner', 'codeception');
$impl = match ($driver) {
'codeception' => \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 {}
}

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Phred\Support\Contracts;
use DI\Container;
use DI\ContainerBuilder;
/**
* Service providers can register bindings before the container is built
* and perform boot-time work after the container is available.
*/
interface ServiceProviderInterface
{
/**
* Register container definitions/bindings. Called before the container is built.
*/
public function register(ContainerBuilder $builder, ConfigInterface $config): void;
/**
* Boot after the container has been built. Safe to resolve services here.
*/
public function boot(Container $container): void;
}

View file

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Phred\Support;
use DI\Container;
use DI\ContainerBuilder;
use Phred\Support\Contracts\ConfigInterface;
use Phred\Support\Contracts\ServiceProviderInterface;
/**
* Loads and executes service providers in deterministic order.
* Order: core app modules
*/
final class ProviderRepository
{
/** @var list<ServiceProviderInterface> */
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);
}
}
}

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Phred\Template\Contracts;
interface RendererInterface
{
/**
* Render a template with provided data into a string.
* Implementation detail depends on selected driver.
*/
public function render(string $template, array $data = []): string;
}

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Phred\Template;
use Phred\Template\Contracts\RendererInterface;
/**
* Minimal placeholder renderer used as default driver.
*/
final class EyrieRenderer implements RendererInterface
{
public function render(string $template, array $data = []): string
{
// naive replacement for demo purposes
$out = $template;
foreach ($data as $k => $v) {
$out = str_replace('{{' . $k . '}}', (string) $v, $out);
}
return $out;
}
}

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Phred\Testing;
use Phred\Testing\Contracts\TestRunnerInterface;
final class CodeceptionRunner implements TestRunnerInterface
{
public function run(?string $suite = null): int
{
// placeholder implementation always succeeds
return 0;
}
}

View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Phred\Testing\Contracts;
interface TestRunnerInterface
{
/**
* Run tests and return exit code (0 success).
*/
public function run(?string $suite = null): int;
}

32
tests/MvcViewTest.php Normal file
View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Phred\Tests;
use Phred\Mvc\View;
use Phred\Template\EyrieRenderer;
use PHPUnit\Framework\TestCase;
final class MvcViewTest extends TestCase
{
public function testViewTransformsDataAndRenders(): void
{
$renderer = new EyrieRenderer();
$view = new class($renderer) extends View {
protected function transformData(array $data): array
{
$data['name'] = strtoupper($data['name'] ?? '');
return $data;
}
};
// Set default template and render using data-first signature
$ref = new \ReflectionClass($view);
$prop = $ref->getParentClass()->getProperty('template');
$prop->setAccessible(true);
$prop->setValue($view, '<h1>Hello {{name}}</h1>');
$html = $view->render(['name' => 'world']);
$this->assertSame('<h1>Hello WORLD</h1>', $html);
}
}

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Phred\Tests;
use Nyholm\Psr7\ServerRequest;
use PHPUnit\Framework\TestCase;
final class ProviderRouteTest extends TestCase
{
public function testAppProviderRegistersRoute(): void
{
$root = dirname(__DIR__);
/** @var object $app */
$app = require $root . '/bootstrap/app.php';
$this->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']);
}
}

View file

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Phred\Tests;
use Nyholm\Psr7\ServerRequest;
use PHPUnit\Framework\TestCase;
final class UrlExtensionNegotiationTest extends TestCase
{
public function testJsonExtensionForcesJsonNegotiation(): 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.json');
$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);
}
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);
}
}