diff --git a/MILESTONES.yaml b/MILESTONES.yaml index d775ba2..1bf2e19 100644 --- a/MILESTONES.yaml +++ b/MILESTONES.yaml @@ -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. diff --git a/config/bootstrap.php b/config/bootstrap.php index 4743d66..c7f0b01 100644 --- a/config/bootstrap.php +++ b/config/bootstrap.php @@ -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); }); diff --git a/src/Site/Core/Config.php b/src/Site/Core/Config.php index 716eac2..6cf9c83 100644 --- a/src/Site/Core/Config.php +++ b/src/Site/Core/Config.php @@ -15,7 +15,7 @@ class Config /** @var array */ 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; } } -?> + diff --git a/src/Site/Core/Router.php b/src/Site/Core/Router.php new file mode 100644 index 0000000..5aa4364 --- /dev/null +++ b/src/Site/Core/Router.php @@ -0,0 +1,103 @@ +}> */ + private array $routes = []; + + /** @var array 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,params:array}|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; + } +} + diff --git a/src/Site/Core/ServiceProvider.php b/src/Site/Core/ServiceProvider.php new file mode 100644 index 0000000..8fbb55c --- /dev/null +++ b/src/Site/Core/ServiceProvider.php @@ -0,0 +1,30 @@ +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 {} +} + diff --git a/tests/unit/Core/RouterTest.php b/tests/unit/Core/RouterTest.php new file mode 100644 index 0000000..5f97528 --- /dev/null +++ b/tests/unit/Core/RouterTest.php @@ -0,0 +1,33 @@ +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); + } +} + diff --git a/tests/unit/Core/ServiceProviderTest.php b/tests/unit/Core/ServiceProviderTest.php new file mode 100644 index 0000000..f399302 --- /dev/null +++ b/tests/unit/Core/ServiceProviderTest.php @@ -0,0 +1,26 @@ +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')); + } +}