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:
parent
fd1c9d23df
commit
7d4265d60e
|
|
@ -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
|
||||
|
|
|
|||
111
README.md
111
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(<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
|
||||
* 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.
|
||||
|
|
|
|||
9
src/Flags/Contracts/FeatureFlagClientInterface.php
Normal file
9
src/Flags/Contracts/FeatureFlagClientInterface.php
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Flags\Contracts;
|
||||
|
||||
interface FeatureFlagClientInterface
|
||||
{
|
||||
public function isEnabled(string $flagKey, array $context = []): bool;
|
||||
}
|
||||
15
src/Flags/FlagpoleClient.php
Normal file
15
src/Flags/FlagpoleClient.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ final class Kernel
|
|||
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();
|
||||
}
|
||||
|
||||
|
|
@ -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']);
|
||||
|
|
|
|||
102
src/Http/Middleware/UrlExtensionNegotiationMiddleware.php
Normal file
102
src/Http/Middleware/UrlExtensionNegotiationMiddleware.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
34
src/Http/Routing/RouteRegistry.php
Normal file
34
src/Http/Routing/RouteRegistry.php
Normal 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
31
src/Mvc/APIController.php
Normal 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
9
src/Mvc/Controller.php
Normal 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
36
src/Mvc/View.php
Normal 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;
|
||||
}
|
||||
}
|
||||
42
src/Mvc/ViewController.php
Normal file
42
src/Mvc/ViewController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
9
src/Mvc/ViewWithDefaultTemplate.php
Normal file
9
src/Mvc/ViewWithDefaultTemplate.php
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Mvc;
|
||||
|
||||
interface ViewWithDefaultTemplate
|
||||
{
|
||||
public function defaultTemplate(): string;
|
||||
}
|
||||
10
src/Orm/Contracts/ConnectionInterface.php
Normal file
10
src/Orm/Contracts/ConnectionInterface.php
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Orm\Contracts;
|
||||
|
||||
interface ConnectionInterface
|
||||
{
|
||||
public function connect(): void;
|
||||
public function isConnected(): bool;
|
||||
}
|
||||
21
src/Orm/PairityConnection.php
Normal file
21
src/Orm/PairityConnection.php
Normal 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;
|
||||
}
|
||||
}
|
||||
33
src/Providers/AppServiceProvider.php
Normal file
33
src/Providers/AppServiceProvider.php
Normal 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;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
27
src/Providers/Core/FlagsServiceProvider.php
Normal file
27
src/Providers/Core/FlagsServiceProvider.php
Normal 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 {}
|
||||
}
|
||||
27
src/Providers/Core/OrmServiceProvider.php
Normal file
27
src/Providers/Core/OrmServiceProvider.php
Normal 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 {}
|
||||
}
|
||||
27
src/Providers/Core/RoutingServiceProvider.php
Normal file
27
src/Providers/Core/RoutingServiceProvider.php
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
27
src/Providers/Core/TemplateServiceProvider.php
Normal file
27
src/Providers/Core/TemplateServiceProvider.php
Normal 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 {}
|
||||
}
|
||||
27
src/Providers/Core/TestingServiceProvider.php
Normal file
27
src/Providers/Core/TestingServiceProvider.php
Normal 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 {}
|
||||
}
|
||||
24
src/Support/Contracts/ServiceProviderInterface.php
Normal file
24
src/Support/Contracts/ServiceProviderInterface.php
Normal 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;
|
||||
}
|
||||
56
src/Support/ProviderRepository.php
Normal file
56
src/Support/ProviderRepository.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/Template/Contracts/RendererInterface.php
Normal file
13
src/Template/Contracts/RendererInterface.php
Normal 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;
|
||||
}
|
||||
22
src/Template/EyrieRenderer.php
Normal file
22
src/Template/EyrieRenderer.php
Normal 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;
|
||||
}
|
||||
}
|
||||
15
src/Testing/CodeceptionRunner.php
Normal file
15
src/Testing/CodeceptionRunner.php
Normal 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;
|
||||
}
|
||||
}
|
||||
12
src/Testing/Contracts/TestRunnerInterface.php
Normal file
12
src/Testing/Contracts/TestRunnerInterface.php
Normal 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
32
tests/MvcViewTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
28
tests/ProviderRouteTest.php
Normal file
28
tests/ProviderRouteTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
48
tests/UrlExtensionNegotiationTest.php
Normal file
48
tests/UrlExtensionNegotiationTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue