diff --git a/atlas b/atlas new file mode 100644 index 0000000..d7dd8a3 --- /dev/null +++ b/atlas @@ -0,0 +1,162 @@ + __DIR__ . '/src/Modules', + 'routes_file' => 'routes.php' +]); + +$router = new Router($config); + +// Load routes from a central routes file if it exists, or provide a way to load them +// For this CLI tool, we might need a way to bootstrap the application's router. +// Usually, this would be part of a framework. For Atlas as a library, we provide +// the tool that can be integrated. + +// In a real scenario, the user would point this tool to their router bootstrap file. +// For demonstration, let's assume we have a `bootstrap/router.php` that returns the Router. + +$bootstrapFile = getcwd() . '/bootstrap/router.php'; +if (file_exists($bootstrapFile)) { + $router = require $bootstrapFile; +} + +switch ($command) { + case 'route:list': + $json = in_array('--json', $argv); + $routes = $router->getRoutes(); + $output = []; + foreach ($routes as $route) { + $output[] = $route->toArray(); + } + + if ($json) { + echo json_encode($output, JSON_PRETTY_PRINT) . PHP_EOL; + } else { + printf("%-10s | %-30s | %-20s | %-30s\n", "Method", "Path", "Name", "Handler"); + echo str_repeat("-", 100) . PHP_EOL; + foreach ($output as $r) { + printf("%-10s | %-30s | %-20s | %-30s\n", + $r['method'], + $r['path'], + $r['name'] ?? '', + is_string($r['handler']) ? $r['handler'] : 'Closure' + ); + } + } + break; + + case 'route:test': + $method = $argv[2] ?? 'GET'; + $path = $argv[3] ?? '/'; + $host = 'localhost'; // Default + + // PSR-7 mock request + $uri = new class($path, $host) implements UriInterface { + public function __construct(private $path, private $host) {} + public function getScheme(): string { return 'http'; } + public function getAuthority(): string { return $this->host; } + public function getUserInfo(): string { return ''; } + public function getHost(): string { return $this->host; } + public function getPort(): ?int { return null; } + public function getPath(): string { return $this->path; } + public function getQuery(): string { return ''; } + public function getFragment(): string { return ''; } + public function withScheme($scheme): UriInterface { return $this; } + public function withUserInfo($user, $password = null): UriInterface { return $this; } + public function withHost($host): UriInterface { return $this; } + public function withPort($port): UriInterface { return $this; } + public function withPath($path): UriInterface { return $this; } + public function withQuery($query): UriInterface { return $this; } + public function withFragment($fragment): UriInterface { return $this; } + public function __toString(): string { return "http://{$this->host}{$this->path}"; } + }; + + $request = new class($method, $uri) implements ServerRequestInterface { + public function __construct(private $method, private $uri) {} + public function getProtocolVersion(): string { return '1.1'; } + public function withProtocolVersion($version): ServerRequestInterface { return $this; } + public function getHeaders(): array { return []; } + public function hasHeader($name): bool { return false; } + public function getHeader($name): array { return []; } + public function getHeaderLine($name): string { return ''; } + public function withHeader($name, $value): ServerRequestInterface { return $this; } + public function withAddedHeader($name, $value): ServerRequestInterface { return $this; } + public function withoutHeader($name): ServerRequestInterface { return $this; } + public function getBody(): \Psr\Http\Message\StreamInterface { return $this->createMockStream(); } + public function withBody(\Psr\Http\Message\StreamInterface $body): ServerRequestInterface { return $this; } + public function getRequestTarget(): string { return $this->uri->getPath(); } + public function withRequestTarget($requestTarget): ServerRequestInterface { return $this; } + public function getMethod(): string { return $this->method; } + public function withMethod($method): ServerRequestInterface { return $this; } + public function getUri(): UriInterface { return $this->uri; } + public function withUri(UriInterface $uri, $preserveHost = false): ServerRequestInterface { return $this; } + public function getServerParams(): array { return []; } + public function getCookieParams(): array { return []; } + public function withCookieParams(array $cookies): ServerRequestInterface { return $this; } + public function getQueryParams(): array { return []; } + public function withQueryParams(array $query): ServerRequestInterface { return $this; } + public function getUploadedFiles(): array { return []; } + public function withUploadedFiles(array $uploadedFiles): ServerRequestInterface { return $this; } + public function getParsedBody(): null|array|object { return null; } + public function withParsedBody($data): ServerRequestInterface { return $this; } + public function getAttributes(): array { return []; } + public function getAttribute($name, $default = null): mixed { return $default; } + public function withAttribute($name, $value): ServerRequestInterface { return $this; } + public function withoutAttribute($name): ServerRequestInterface { return $this; } + + private function createMockStream() { + return new class implements \Psr\Http\Message\StreamInterface { + public function __toString(): string { return ''; } + public function close(): void {} + public function detach() { return null; } + public function getSize(): ?int { return 0; } + public function tell(): int { return 0; } + public function eof(): bool { return true; } + public function isSeekable(): bool { return false; } + public function seek($offset, $whence = SEEK_SET): void {} + public function rewind(): void {} + public function isWritable(): bool { return false; } + public function write($string): int { return 0; } + public function isReadable(): bool { return true; } + public function read($length): string { return ''; } + public function getContents(): string { return ''; } + public function getMetadata($key = null) { return $key ? null : []; } + }; + } + }; + + $result = $router->inspect($request); + + if ($result->isFound()) { + echo "Match Found!" . PHP_EOL; + echo "Route: " . $result->getRoute()->getName() . " [" . $result->getRoute()->getMethod() . " " . $result->getRoute()->getPath() . "]" . PHP_EOL; + echo "Parameters: " . json_encode($result->getParameters()) . PHP_EOL; + exit(0); + } else { + echo "No Match Found." . PHP_EOL; + if (in_array('--verbose', $argv)) { + echo "Diagnostics:" . PHP_EOL; + foreach ($result->getDiagnostics()['attempts'] as $attempt) { + echo " - {$attempt['route']}: {$attempt['status']} (Pattern: {$attempt['pattern']})" . PHP_EOL; + } + } + exit(2); + } + break; + + default: + echo "Atlas Routing CLI" . PHP_EOL; + echo "Usage:" . PHP_EOL; + echo " php atlas route:list [--json]" . PHP_EOL; + echo " php atlas route:test [--verbose]" . PHP_EOL; + break; +} diff --git a/src/Router/MatchResult.php b/src/Router/MatchResult.php new file mode 100644 index 0000000..cd1d61f --- /dev/null +++ b/src/Router/MatchResult.php @@ -0,0 +1,46 @@ +found; + } + + public function getRoute(): ?RouteDefinition + { + return $this->route; + } + + public function getParameters(): array + { + return $this->parameters; + } + + public function getDiagnostics(): array + { + return $this->diagnostics; + } + + public function jsonSerialize(): mixed + { + return [ + 'found' => $this->found, + 'route' => $this->route, + 'parameters' => $this->parameters, + 'diagnostics' => $this->diagnostics + ]; + } +} diff --git a/tests/Unit/CliToolTest.php b/tests/Unit/CliToolTest.php new file mode 100644 index 0000000..0bc76db --- /dev/null +++ b/tests/Unit/CliToolTest.php @@ -0,0 +1,96 @@ +atlasPath = realpath(__DIR__ . '/../../atlas'); + $this->bootstrapDir = __DIR__ . '/../../bootstrap'; + $this->bootstrapFile = $this->bootstrapDir . '/router.php'; + + if (!is_dir($this->bootstrapDir)) { + mkdir($this->bootstrapDir); + } + + // Create a bootstrap file for testing + $content = <<<'PHP' + __DIR__ . '/../src/Modules']); +$router = new Router($config); +$router->get('/hello', 'handler', 'hello_route'); +$router->get('/users/{{id}}', 'handler', 'user_detail')->valid('id', 'numeric'); +return $router; +PHP; + file_put_contents($this->bootstrapFile, $content); + } + + protected function tearDown(): void + { + if (file_exists($this->bootstrapFile)) { + unlink($this->bootstrapFile); + } + if (is_dir($this->bootstrapDir)) { + rmdir($this->bootstrapDir); + } + } + + public function testRouteList(): void + { + exec("php {$this->atlasPath} route:list", $output, $returnCode); + + $this->assertSame(0, $returnCode); + $this->assertStringContainsString('hello_route', implode("\n", $output)); + $this->assertStringContainsString('user_detail', implode("\n", $output)); + } + + public function testRouteListJson(): void + { + exec("php {$this->atlasPath} route:list --json", $output, $returnCode); + + $this->assertSame(0, $returnCode); + $json = implode("\n", $output); + $data = json_decode($json, true); + + $this->assertIsArray($data); + $this->assertCount(2, $data); + $this->assertSame('hello_route', $data[0]['name']); + } + + public function testRouteTestSuccess(): void + { + exec("php {$this->atlasPath} route:test GET /hello", $output, $returnCode); + + $this->assertSame(0, $returnCode); + $this->assertStringContainsString('Match Found!', implode("\n", $output)); + $this->assertStringContainsString('hello_route', implode("\n", $output)); + } + + public function testRouteTestFailure(): void + { + exec("php {$this->atlasPath} route:test GET /nonexistent", $output, $returnCode); + + $this->assertSame(2, $returnCode); + $this->assertStringContainsString('No Match Found.', implode("\n", $output)); + } + + public function testRouteTestVerbose(): void + { + exec("php {$this->atlasPath} route:test GET /users/abc --verbose", $output, $returnCode); + + $this->assertSame(2, $returnCode); + $this->assertStringContainsString('No Match Found.', implode("\n", $output)); + $this->assertStringContainsString('Diagnostics:', implode("\n", $output)); + $this->assertStringContainsString('user_detail: mismatch', implode("\n", $output)); + } +} diff --git a/tests/Unit/InspectorApiTest.php b/tests/Unit/InspectorApiTest.php new file mode 100644 index 0000000..233ad59 --- /dev/null +++ b/tests/Unit/InspectorApiTest.php @@ -0,0 +1,95 @@ + ['/path/to/modules']]); + $this->router = new Router($config); + } + + public function testInspectFindsMatch(): void + { + $this->router->get('/users/{{id}}', 'handler', 'user_detail'); + + $uri = $this->createMock(UriInterface::class); + $uri->method('getPath')->willReturn('/users/42'); + $uri->method('getHost')->willReturn('localhost'); + + $request = $this->createMock(ServerRequestInterface::class); + $request->method('getMethod')->willReturn('GET'); + $request->method('getUri')->willReturn($uri); + + $result = $this->router->inspect($request); + + $this->assertInstanceOf(MatchResult::class, $result); + $this->assertTrue($result->isFound()); + $this->assertSame('user_detail', $result->getRoute()->getName()); + $this->assertSame(['id' => '42'], $result->getParameters()); + } + + public function testInspectReturnsDiagnosticsOnMismatch(): void + { + $this->router->get('/users/{{id}}', 'handler', 'user_detail')->valid('id', 'numeric'); + + $uri = $this->createMock(UriInterface::class); + $uri->method('getPath')->willReturn('/users/abc'); + $uri->method('getHost')->willReturn('localhost'); + + $request = $this->createMock(ServerRequestInterface::class); + $request->method('getMethod')->willReturn('GET'); + $request->method('getUri')->willReturn($uri); + + $result = $this->router->inspect($request); + + $this->assertFalse($result->isFound()); + $diagnostics = $result->getDiagnostics(); + $this->assertArrayHasKey('attempts', $diagnostics); + $this->assertCount(1, $diagnostics['attempts']); + $this->assertSame('user_detail', $diagnostics['attempts'][0]['route']); + $this->assertSame('mismatch', $diagnostics['attempts'][0]['status']); + } + + public function testRouteDefinitionIsJsonSerializable(): void + { + $route = $this->router->get('/test', 'handler', 'test_route'); + $json = json_encode($route); + $data = json_decode($json, true); + + $this->assertSame('GET', $data['method']); + $this->assertSame('/test', $data['path']); + $this->assertSame('test_route', $data['name']); + $this->assertSame('handler', $data['handler']); + } + + public function testMatchResultIsJsonSerializable(): void + { + $this->router->get('/test', 'handler', 'test_route'); + + $uri = $this->createMock(UriInterface::class); + $uri->method('getPath')->willReturn('/test'); + $uri->method('getHost')->willReturn('localhost'); + + $request = $this->createMock(ServerRequestInterface::class); + $request->method('getMethod')->willReturn('GET'); + $request->method('getUri')->willReturn($uri); + + $result = $this->router->inspect($request); + $json = json_encode($result); + $data = json_decode($json, true); + + $this->assertTrue($data['found']); + $this->assertSame('test_route', $data['route']['name']); + } +}