Milestone 2

This commit is contained in:
Funky Waddle 2026-09-10 14:17:19 -05:00
parent 059d3d2f12
commit 350d7f6351
7 changed files with 200 additions and 3 deletions

View file

@ -60,7 +60,7 @@ milestones:
# Milestone 2: Core Infrastructure Router, DI Container, Service Provider
milestone_2_core_infrastructure:
status: TODO
status: COMPLETE
description: >-
Build the custom routing engine, lightweight dependency injection container,
and service provider pattern that will be used throughout the application.

View file

@ -10,7 +10,12 @@ $mergedEnv = $_ENV + $_SERVER;
use League\Container\Container;
use SiteWeaver\Core\Config;
use SiteWeaver\Core\Router;
$container = new Container();
// Register core services
$router = new Router();
$container->add('router', $router);
$container->add('config', function () use ($mergedEnv) {
return new Config($mergedEnv);
});

View file

@ -15,7 +15,7 @@ class Config
/** @var array<string, mixed> */
private array $values = [];
public function __construct(array $env = null)
public function __construct(?array $env = null)
{
// Use provided env array or fallback to $_ENV
$this->values = $env ?? $_ENV;
@ -74,4 +74,4 @@ class Config
return $this->values;
}
}
?>

103
src/Site/Core/Router.php Normal file
View file

@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace SiteWeaver\Core;
/**
* Very small custom router.
*/
class Router
{
/** @var array<int, array{method:string,path:string,handler:callable,middleware?:array<callable>}> */
private array $routes = [];
/** @var array<string,string> mapping of route name to path pattern */
private array $namedRoutes = [];
public function get(string $path, callable $handler, array $middleware = [], ?string $name = null): void
{
$this->addRoute('GET', $path, $handler, $middleware, $name);
}
public function post(string $path, callable $handler, array $middleware = [], ?string $name = null): void
{
$this->addRoute('POST', $path, $handler, $middleware, $name);
}
public function put(string $path, callable $handler, array $middleware = [], ?string $name = null): void
{
$this->addRoute('PUT', $path, $handler, $middleware, $name);
}
public function delete(string $path, callable $handler, array $middleware = [], ?string $name = null): void
{
$this->addRoute('DELETE', $path, $handler, $middleware, $name);
}
private function addRoute(
string $method,
string $path,
callable $handler,
array $middleware,
?string $name = null
): void {
$this->routes[] = [
'method' => strtoupper($method),
'path' => $path,
'handler' => $handler,
'middleware' => $middleware,
];
if ($name !== null) {
$this->namedRoutes[$name] = $path;
}
}
/**
* Dispatch a request.
*
* @return array{handler:callable,middleware:array<callable>,params:array<string,string>}|null
*/
public function dispatch(string $method, string $uri): ?array
{
foreach ($this->routes as $route) {
if (strtoupper($method) !== strtoupper($route['method'])) {
continue;
}
// Build regex for path pattern
$pattern = preg_replace('#\{([^}]+)\}#', '(?P<$1>[^/]+)', $route['path']);
$pattern = '#^' . $pattern . '$#';
if (preg_match($pattern, $uri, $matches)) {
// filter named groups
$params = array_filter(
$matches,
fn ($k) => is_string($k),
ARRAY_FILTER_USE_KEY
);
return [
'handler' => $route['handler'],
'middleware' => $route['middleware'] ?? [],
'params' => $params,
];
}
}
return null;
}
/**
* Generate a URL for a named route.
*/
public function url(string $name, array $params = []): string
{
if (!isset($this->namedRoutes[$name])) {
throw new \InvalidArgumentException("Route name {$name} not defined");
}
$path = $this->namedRoutes[$name];
foreach ($params as $key => $value) {
// replace both required and optional placeholders
$path = str_replace('{' . $key . '}', (string)$value, $path);
$path = str_replace('{?' . $key . '}', (string)$value, $path);
}
return $path;
}
}

View file

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace SiteWeaver\Core;
use League\Container\Container;
/**
* Base class for all service providers.
*/
abstract class ServiceProvider
{
protected Container $container;
public function __construct(Container $container)
{
$this->container = $container;
}
/**
* Register bindings in the container.
*/
abstract public function register(): void;
/**
* Optional boot method called after all providers are registered.
*/
public function boot(): void {}
}

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Tests\unit\Core;
use SiteWeaver\Core\Router;
use PHPUnit\Framework\TestCase;
final class RouterTest extends TestCase
{
public function testRouteRegistrationAndDispatch(): void
{
$router = new Router();
$router->get('/users/{id}', fn($params) => 'handler', [], 'user_show');
$result = $router->dispatch('GET', '/users/42');
$this->assertNotNull($result);
$handler = $result['handler'];
$this->assertIsCallable($handler);
$this->assertSame('handler', ($handler)(['id' => '42']));
$this->assertEquals(['id' => '42'], $result['params']);
}
public function testUrlGeneration(): void
{
$router = new Router();
$router->get('/posts/{slug}', fn() => null, [], 'post_show');
$url = $router->url('post_show', ['slug' => 'hello-world']);
$this->assertSame('/posts/hello-world', $url);
}
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Tests\unit\Core;
use SiteWeaver\Core\ServiceProvider;
use League\Container\Container;
use PHPUnit\Framework\TestCase;
final class DummyProvider extends ServiceProvider {
public function register(): void {
$this->container->add('dummy', fn() => 'value');
}
}
final class ServiceProviderTest extends TestCase
{
public function testRegisterAddsService(): void
{
$container = new Container();
$provider = new DummyProvider($container);
$provider->register();
$this->assertSame('value', $container->get('dummy'));
}
}