diff --git a/MILESTONES.md b/MILESTONES.md index d5710c6..d0634d3 100644 --- a/MILESTONES.md +++ b/MILESTONES.md @@ -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.~~ * ~~Define route → controller resolution (invokable controllers).~~ * ~~Add minimal app bootstrap (front controller) and DI container wiring (`PHP-DI`).~~ + * ~~Addendum: Route groups (prefix only) via `Router::group()`~~ * ~~Acceptance:~~ * ~~Sample route returning a JSON 200 via controller.~~ * ~~Controllers are invokable (`__invoke(Request)`), one route per controller.~~ -## M2 — Configuration and environment -* Tasks: - * Load `.env` via `vlucas/phpdotenv` and expose `Phred\Support\Config`. - * Define configuration precedence and document keys (e.g., `API_FORMAT`, `APP_ENV`, `APP_DEBUG`). -* Acceptance: - * App reads config from `.env`; unit test demonstrates override behavior. + * ~~Route groups (prefix only) work and are tested.~~ +## ~~M2 — Configuration and environment~~ +* ~~Tasks:~~ + * ~~Load `.env` via `vlucas/phpdotenv` and expose `Phred\Support\Config`.~~ + * ~~Define configuration precedence and document keys (e.g., `API_FORMAT`, `APP_ENV`, `APP_DEBUG`).~~ +* ~~Acceptance:~~ + * ~~App reads config from `.env`; unit test demonstrates override behavior.~~ ## M3 — API formats and content negotiation * Tasks: * Finalize `ContentNegotiationMiddleware` using `.env` and `Accept` header. diff --git a/README.md b/README.md index 286b865..8b6cd99 100644 --- a/README.md +++ b/README.md @@ -123,3 +123,28 @@ return new class extends Command { 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(, )`. + - 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 `.` (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')); +``` diff --git a/src/Http/Router.php b/src/Http/Router.php index ec97a8e..3b268ce 100644 --- a/src/Http/Router.php +++ b/src/Http/Router.php @@ -36,4 +36,17 @@ final class Router { $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)); + }); + } } diff --git a/src/Support/Config.php b/src/Support/Config.php index 6ddae6e..edc5dad 100644 --- a/src/Support/Config.php +++ b/src/Support/Config.php @@ -6,21 +6,23 @@ namespace Phred\Support; final class Config { + /** @var array|null */ + private static ?array $store = null; + /** - * Get a config value from environment variables or return default. - * Accepts both UPPER_CASE and dot.notation keys (the latter is for future file-based config). + * Get configuration value with precedence: + * 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 { - // 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)); - $value = getenv($envKey); if ($value !== false) { return $value; } - - // Fallback to server superglobal if present. if (isset($_SERVER[$envKey])) { return $_SERVER[$envKey]; } @@ -28,6 +30,64 @@ final class Config 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; } + + 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; + } } diff --git a/tests/ConfigTest.php b/tests/ConfigTest.php new file mode 100644 index 0000000..d6e08e6 --- /dev/null +++ b/tests/ConfigTest.php @@ -0,0 +1,40 @@ + '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')); + } +} diff --git a/tests/RouterGroupTest.php b/tests/RouterGroupTest.php new file mode 100644 index 0000000..e4830d9 --- /dev/null +++ b/tests/RouterGroupTest.php @@ -0,0 +1,33 @@ +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); + } +}