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 Router, DI Container, Service Provider
milestone_2_core_infrastructure: milestone_2_core_infrastructure:
status: TODO status: COMPLETE
description: >- description: >-
Build the custom routing engine, lightweight dependency injection container, Build the custom routing engine, lightweight dependency injection container,
and service provider pattern that will be used throughout the application. 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 League\Container\Container;
use SiteWeaver\Core\Config; use SiteWeaver\Core\Config;
use SiteWeaver\Core\Router;
$container = new Container(); $container = new Container();
// Register core services
$router = new Router();
$container->add('router', $router);
$container->add('config', function () use ($mergedEnv) { $container->add('config', function () use ($mergedEnv) {
return new Config($mergedEnv); return new Config($mergedEnv);
}); });

View file

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