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.~~
|
||||
* ~~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.
|
||||
|
|
|
|||
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; }
|
||||
};
|
||||
```
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
/** @var array<string,mixed>|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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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