Router groups, Configs
This commit is contained in:
parent
3452ac1e12
commit
c691aab9ec
|
|
@ -14,15 +14,17 @@ Phred supports REST and JSON:API via env setting; batteries-included defaults, s
|
||||||
* ~~Integrate `nikic/fast-route` with a RouteCollector and dispatcher.~~
|
* ~~Integrate `nikic/fast-route` with a RouteCollector and dispatcher.~~
|
||||||
* ~~Define route → controller resolution (invokable controllers).~~
|
* ~~Define route → controller resolution (invokable controllers).~~
|
||||||
* ~~Add minimal app bootstrap (front controller) and DI container wiring (`PHP-DI`).~~
|
* ~~Add minimal app bootstrap (front controller) and DI container wiring (`PHP-DI`).~~
|
||||||
|
* ~~Addendum: Route groups (prefix only) via `Router::group()`~~
|
||||||
* ~~Acceptance:~~
|
* ~~Acceptance:~~
|
||||||
* ~~Sample route returning a JSON 200 via controller.~~
|
* ~~Sample route returning a JSON 200 via controller.~~
|
||||||
* ~~Controllers are invokable (`__invoke(Request)`), one route per controller.~~
|
* ~~Controllers are invokable (`__invoke(Request)`), one route per controller.~~
|
||||||
## M2 — Configuration and environment
|
* ~~Route groups (prefix only) work and are tested.~~
|
||||||
* Tasks:
|
## ~~M2 — Configuration and environment~~
|
||||||
* Load `.env` via `vlucas/phpdotenv` and expose `Phred\Support\Config`.
|
* ~~Tasks:~~
|
||||||
* Define configuration precedence and document keys (e.g., `API_FORMAT`, `APP_ENV`, `APP_DEBUG`).
|
* ~~Load `.env` via `vlucas/phpdotenv` and expose `Phred\Support\Config`.~~
|
||||||
* Acceptance:
|
* ~~Define configuration precedence and document keys (e.g., `API_FORMAT`, `APP_ENV`, `APP_DEBUG`).~~
|
||||||
* App reads config from `.env`; unit test demonstrates override behavior.
|
* ~~Acceptance:~~
|
||||||
|
* ~~App reads config from `.env`; unit test demonstrates override behavior.~~
|
||||||
## M3 — API formats and content negotiation
|
## M3 — API formats and content negotiation
|
||||||
* Tasks:
|
* Tasks:
|
||||||
* Finalize `ContentNegotiationMiddleware` using `.env` and `Accept` header.
|
* Finalize `ContentNegotiationMiddleware` using `.env` and `Accept` header.
|
||||||
|
|
|
||||||
25
README.md
25
README.md
|
|
@ -123,3 +123,28 @@ return new class extends Command {
|
||||||
public function handle(Input $in, Output $out): int { $out->writeln('Hello!'); return 0; }
|
public function handle(Input $in, Output $out): int { $out->writeln('Hello!'); return 0; }
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Configuration and environment
|
||||||
|
|
||||||
|
- Phred uses `vlucas/phpdotenv` to load a `.env` file from your project root (loaded in `bootstrap/app.php`).
|
||||||
|
- Access configuration anywhere via `Phred\Support\Config::get(<key>, <default>)`.
|
||||||
|
- Precedence: environment variables > config files > provided default.
|
||||||
|
- Keys may be provided in either UPPER_SNAKE (e.g., `APP_ENV`) or dot.notation (e.g., `app.env`).
|
||||||
|
- Config files live under `config/*.php` and return arrays; dot keys are addressed as `<file>.<path>` (e.g., `app.timezone`).
|
||||||
|
|
||||||
|
Common keys
|
||||||
|
|
||||||
|
- `APP_ENV` (default from config/app.php: `local`)
|
||||||
|
- `APP_DEBUG` (`true`/`false`)
|
||||||
|
- `APP_TIMEZONE` (default `UTC`)
|
||||||
|
- `API_FORMAT` (`rest` | `jsonapi`; default `rest`)
|
||||||
|
|
||||||
|
Examples
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Phred\Support\Config;
|
||||||
|
|
||||||
|
$env = Config::get('APP_ENV', 'local'); // reads from env, then config/app.php, else 'local'
|
||||||
|
$tz = Config::get('app.timezone', 'UTC'); // reads nested key from config files
|
||||||
|
$fmt = strtolower(Config::get('API_FORMAT', 'rest'));
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -36,4 +36,17 @@ final class Router
|
||||||
{
|
{
|
||||||
$this->collector->addRoute('DELETE', $path, $handler);
|
$this->collector->addRoute('DELETE', $path, $handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group routes under a common path prefix.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* $router->group('/api', function (Router $r) { $r->get('/health', Handler::class); });
|
||||||
|
*/
|
||||||
|
public function group(string $prefix, callable $routes): void
|
||||||
|
{
|
||||||
|
$this->collector->addGroup($prefix, function (RouteCollector $rc) use ($routes): void {
|
||||||
|
$routes(new Router($rc));
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,21 +6,23 @@ namespace Phred\Support;
|
||||||
|
|
||||||
final class Config
|
final class Config
|
||||||
{
|
{
|
||||||
|
/** @var array<string,mixed>|null */
|
||||||
|
private static ?array $store = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a config value from environment variables or return default.
|
* Get configuration value with precedence:
|
||||||
* Accepts both UPPER_CASE and dot.notation keys (the latter is for future file-based config).
|
* 1) Environment variables (UPPER_CASE or dot.notation translated)
|
||||||
|
* 2) Loaded config files from config/*.php, accessible via dot.notation (e.g., app.env)
|
||||||
|
* 3) Provided $default
|
||||||
*/
|
*/
|
||||||
public static function get(string $key, mixed $default = null): mixed
|
public static function get(string $key, mixed $default = null): mixed
|
||||||
{
|
{
|
||||||
// Support dot.notation by converting to uppercase with underscores for env lookup.
|
// 1) Environment lookup (supports dot.notation by converting to UPPER_SNAKE)
|
||||||
$envKey = strtoupper(str_replace('.', '_', $key));
|
$envKey = strtoupper(str_replace('.', '_', $key));
|
||||||
|
|
||||||
$value = getenv($envKey);
|
$value = getenv($envKey);
|
||||||
if ($value !== false) {
|
if ($value !== false) {
|
||||||
return $value;
|
return $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to server superglobal if present.
|
|
||||||
if (isset($_SERVER[$envKey])) {
|
if (isset($_SERVER[$envKey])) {
|
||||||
return $_SERVER[$envKey];
|
return $_SERVER[$envKey];
|
||||||
}
|
}
|
||||||
|
|
@ -28,6 +30,64 @@ final class Config
|
||||||
return $_ENV[$envKey];
|
return $_ENV[$envKey];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2) Config files (lazy load once)
|
||||||
|
self::ensureLoaded();
|
||||||
|
if (self::$store) {
|
||||||
|
$fromStore = self::getFromStore($key);
|
||||||
|
if ($fromStore !== null) {
|
||||||
|
return $fromStore;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Default
|
||||||
return $default;
|
return $default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function ensureLoaded(): void
|
||||||
|
{
|
||||||
|
if (self::$store !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self::$store = [];
|
||||||
|
$root = getcwd();
|
||||||
|
$configDir = $root . DIRECTORY_SEPARATOR . 'config';
|
||||||
|
if (!is_dir($configDir)) {
|
||||||
|
return; // no config directory; keep empty store
|
||||||
|
}
|
||||||
|
foreach (glob($configDir . '/*.php') ?: [] as $file) {
|
||||||
|
$key = basename($file, '.php');
|
||||||
|
try {
|
||||||
|
$data = require $file;
|
||||||
|
if (is_array($data)) {
|
||||||
|
self::$store[$key] = $data;
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// ignore malformed config files to avoid breaking runtime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function getFromStore(string $key): mixed
|
||||||
|
{
|
||||||
|
// dot.notation: first segment is file key, remaining traverse array
|
||||||
|
if (str_contains($key, '.')) {
|
||||||
|
$parts = explode('.', $key);
|
||||||
|
$rootKey = array_shift($parts);
|
||||||
|
if ($rootKey === null || !isset(self::$store[$rootKey])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$cursor = self::$store[$rootKey];
|
||||||
|
foreach ($parts as $p) {
|
||||||
|
if (is_array($cursor) && array_key_exists($p, $cursor)) {
|
||||||
|
$cursor = $cursor[$p];
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// non-dotted: try exact file key
|
||||||
|
return self::$store[$key] ?? null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
40
tests/ConfigTest.php
Normal file
40
tests/ConfigTest.php
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Phred\Tests;
|
||||||
|
|
||||||
|
use Phred\Support\Config;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ConfigTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
// Clean up env changes
|
||||||
|
putenv('APP_ENV');
|
||||||
|
unset($_ENV['APP_ENV'], $_SERVER['APP_ENV']);
|
||||||
|
putenv('APP_DEBUG');
|
||||||
|
unset($_ENV['APP_DEBUG'], $_SERVER['APP_DEBUG']);
|
||||||
|
putenv('API_FORMAT');
|
||||||
|
unset($_ENV['API_FORMAT'], $_SERVER['API_FORMAT']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEnvOverridesConfigFile(): void
|
||||||
|
{
|
||||||
|
// config/app.php sets 'env' => 'local' by default; ensure ENV wins
|
||||||
|
putenv('APP_ENV=production');
|
||||||
|
$this->assertSame('production', Config::get('APP_ENV'));
|
||||||
|
$this->assertSame('production', Config::get('app.env'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReadsFromConfigFileWhenEnvMissing(): void
|
||||||
|
{
|
||||||
|
// From config/app.php
|
||||||
|
$this->assertSame('UTC', Config::get('app.timezone'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReturnsDefaultWhenNotFound(): void
|
||||||
|
{
|
||||||
|
$this->assertSame('fallback', Config::get('nonexistent.key', 'fallback'));
|
||||||
|
}
|
||||||
|
}
|
||||||
33
tests/RouterGroupTest.php
Normal file
33
tests/RouterGroupTest.php
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Phred\Tests;
|
||||||
|
|
||||||
|
use FastRoute\Dispatcher;
|
||||||
|
use FastRoute\RouteCollector;
|
||||||
|
use Nyholm\Psr7\Factory\Psr17Factory;
|
||||||
|
use Nyholm\Psr7Server\ServerRequestCreator;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Phred\Http\Router;
|
||||||
|
use function FastRoute\simpleDispatcher;
|
||||||
|
|
||||||
|
final class RouterGroupTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testGroupPrefixesRoutes(): void
|
||||||
|
{
|
||||||
|
$dispatcher = simpleDispatcher(function (RouteCollector $rc): void {
|
||||||
|
$router = new Router($rc);
|
||||||
|
$router->group('/api', function (Router $r): void {
|
||||||
|
$r->get('/health', [\Phred\Http\Controllers\HealthController::class, '__invoke']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$psr17 = new Psr17Factory();
|
||||||
|
$creator = new ServerRequestCreator($psr17, $psr17, $psr17, $psr17);
|
||||||
|
$req = $creator->fromGlobals()->withMethod('GET')->withUri($psr17->createUri('/api/health'));
|
||||||
|
|
||||||
|
$routeInfo = $dispatcher->dispatch($req->getMethod(), $req->getUri()->getPath());
|
||||||
|
$this->assertSame(Dispatcher::FOUND, $routeInfo[0] ?? null, 'Route in group should be found with prefixed path');
|
||||||
|
$this->assertIsArray($routeInfo[1] ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue