Router groups, Configs

This commit is contained in:
Funky Waddle 2025-12-14 20:09:06 -06:00
parent 3452ac1e12
commit c691aab9ec
6 changed files with 185 additions and 12 deletions

View file

@ -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.

View file

@ -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'));
```

View file

@ -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));
});
}
} }

View file

@ -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
View 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
View 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);
}
}