feat: implement milestone 12 - tooling & inspector API
This commit is contained in:
parent
31d4dd56b4
commit
2ad368e4bd
162
atlas
Normal file
162
atlas
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
<?php
|
||||
|
||||
require_once __DIR__ . '/vendor/autoload.php';
|
||||
|
||||
use Atlas\Router\Router;
|
||||
use Atlas\Config\Config;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Message\UriInterface;
|
||||
|
||||
$command = $argv[1] ?? 'help';
|
||||
|
||||
$config = new Config([
|
||||
'modules_path' => __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 <METHOD> <PATH> [--verbose]" . PHP_EOL;
|
||||
break;
|
||||
}
|
||||
46
src/Router/MatchResult.php
Normal file
46
src/Router/MatchResult.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace Atlas\Router;
|
||||
|
||||
/**
|
||||
* Represents the result of a route matching operation.
|
||||
*/
|
||||
class MatchResult implements \JsonSerializable
|
||||
{
|
||||
public function __construct(
|
||||
private readonly bool $found,
|
||||
private readonly RouteDefinition|null $route = null,
|
||||
private readonly array $parameters = [],
|
||||
private readonly array $diagnostics = []
|
||||
) {}
|
||||
|
||||
public function isFound(): bool
|
||||
{
|
||||
return $this->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
|
||||
];
|
||||
}
|
||||
}
|
||||
96
tests/Unit/CliToolTest.php
Normal file
96
tests/Unit/CliToolTest.php
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
<?php
|
||||
|
||||
namespace Atlas\Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class CliToolTest extends TestCase
|
||||
{
|
||||
private string $atlasPath;
|
||||
private string $bootstrapDir;
|
||||
private string $bootstrapFile;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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'
|
||||
<?php
|
||||
use Atlas\Router\Router;
|
||||
use Atlas\Config\Config;
|
||||
|
||||
$config = new Config(['modules_path' => __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));
|
||||
}
|
||||
}
|
||||
95
tests/Unit/InspectorApiTest.php
Normal file
95
tests/Unit/InspectorApiTest.php
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
<?php
|
||||
|
||||
namespace Atlas\Tests\Unit;
|
||||
|
||||
use Atlas\Router\Router;
|
||||
use Atlas\Config\Config;
|
||||
use Atlas\Router\MatchResult;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Message\UriInterface;
|
||||
|
||||
class InspectorApiTest extends TestCase
|
||||
{
|
||||
private Router $router;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$config = new Config(['modules_path' => ['/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']);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue