diff --git a/packages/http/README.md b/packages/http/README.md index a826c21..5ffa6c6 100644 --- a/packages/http/README.md +++ b/packages/http/README.md @@ -81,6 +81,14 @@ The package does not bundle a concrete PSR-7 implementation. Applications or lat Phase 4.6 closes the reviewed Phase 4 HTTP package foundation. HTML and JSON error rendering, problem-details DTOs, content negotiation, debug pages, automatic health routes, SAPI request creation, concrete response transmission, process termination/recycle adapters, runtime adapters, trace propagation and OpenTelemetry propagation remain deferred to later reviewed slices. +Phase 6.4C adds `Evolve\Http\Routing\Console\RouteListCommand` as a caller-registerable console command adapter for route inspection. The command receives an explicit `RouteCollection` through constructor injection and uses the existing Core Console API, keeping the dependency direction as HTTP -> Core. + +`route:list` renders only configured route methods and paths. It preserves `RouteCollection` order, method order, method case and route path text exactly, and it does not invoke or expose route handlers. Empty collections write `No routes are configured.` to normal output. + +The adapter accepts no arguments or options. Unsupported input writes `The route:list command does not accept arguments or options.` to error output and returns exit code `2` without rendering routes. + +The command is not automatically registered in `packages/core/bin/evolve`, and this package does not provide application bootstrapping or automatic route discovery for it. Application-owned or skeleton CLI composition remains deferred. + ## Publication Status EvolvePHP 2 is pre-release. This package is not yet independently published, and the current canonical source is the EvolvePHP monorepo: diff --git a/packages/http/src/Routing/Console/RouteListCommand.php b/packages/http/src/Routing/Console/RouteListCommand.php new file mode 100644 index 0000000..cbc8cd0 --- /dev/null +++ b/packages/http/src/Routing/Console/RouteListCommand.php @@ -0,0 +1,49 @@ +tokens() !== []) { + $output->writeError('The route:list command does not accept arguments or options.'); + + return new CommandResult(2); + } + + $routes = $this->routes->all(); + + if ($routes === []) { + $output->write('No routes are configured.'); + + return new CommandResult(0); + } + + foreach ($routes as $route) { + $output->write(implode('|', $route->methods()) . ' ' . $route->path()); + } + + return new CommandResult(0); + } +} diff --git a/packages/http/tests/Unit/Routing/Console/RouteListCommandTest.php b/packages/http/tests/Unit/Routing/Console/RouteListCommandTest.php new file mode 100644 index 0000000..0e0b3eb --- /dev/null +++ b/packages/http/tests/Unit/Routing/Console/RouteListCommandTest.php @@ -0,0 +1,141 @@ +command([]); + + self::assertTrue((new \ReflectionClass(RouteListCommand::class))->isFinal()); + self::assertTrue((new \ReflectionClass(RouteListCommand::class))->isReadOnly()); + self::assertSame('route:list', $command->name()); + self::assertSame('List configured HTTP routes.', $command->description()); + } + + public function testEmptyCollectionEmitsMessageAndReturnsSuccess(): void + { + $output = new RecordingCommandOutput(); + $result = $this->command([])->execute(new CommandInput([]), $output); + + self::assertSame(0, $result->exitCode()); + self::assertSame(['No routes are configured.'], $output->normal); + self::assertSame([], $output->error); + } + + public function testRoutesAreRenderedWithExactMethodsPathsAndOrder(): void + { + $output = new RecordingCommandOutput(); + $firstHandler = $this->handler(); + $secondHandler = $this->handler(); + $thirdHandler = $this->handler(); + $command = $this->command([ + new Route(['GET'], '/users', $firstHandler), + new Route(['post', 'PATCH'], '/mixed-case', $secondHandler), + new Route(['DELETE'], '/users/{id}', $thirdHandler), + ]); + + $result = $command->execute(new CommandInput([]), $output); + + self::assertSame(0, $result->exitCode()); + self::assertSame( + [ + 'GET /users', + 'post|PATCH /mixed-case', + 'DELETE /users/{id}', + ], + $output->normal, + ); + self::assertSame([], $output->error); + self::assertSame(0, $firstHandler->calls); + self::assertSame(0, $secondHandler->calls); + self::assertSame(0, $thirdHandler->calls); + } + + public function testUnsupportedArgumentReturnsUsageErrorOnly(): void + { + $output = new RecordingCommandOutput(); + $handler = $this->handler(); + $result = $this->command([new Route(['GET'], '/users', $handler)]) + ->execute(new CommandInput(['--json']), $output); + + self::assertSame(2, $result->exitCode()); + self::assertSame([], $output->normal); + self::assertSame(['The route:list command does not accept arguments or options.'], $output->error); + self::assertSame(0, $handler->calls); + } + + public function testMultipleUnsupportedTokensAreRejectedIdentically(): void + { + $output = new RecordingCommandOutput(); + $result = $this->command([new Route(['GET'], '/users', $this->handler())]) + ->execute(new CommandInput(['--verbose', '/users']), $output); + + self::assertSame(2, $result->exitCode()); + self::assertSame([], $output->normal); + self::assertSame(['The route:list command does not accept arguments or options.'], $output->error); + } + + /** + * @param list $routes + */ + private function command(array $routes): RouteListCommand + { + return new RouteListCommand(new RouteCollection($routes)); + } + + private function handler(): RecordingRouteListHandler + { + return new RecordingRouteListHandler($this->createStub(ResponseInterface::class)); + } +} + +final class RecordingCommandOutput implements CommandOutput +{ + /** + * @var list + */ + public array $normal = []; + + /** + * @var list + */ + public array $error = []; + + public function write(string $message): void + { + $this->normal[] = $message; + } + + public function writeError(string $message): void + { + $this->error[] = $message; + } +} + +final class RecordingRouteListHandler implements RequestHandlerInterface +{ + public int $calls = 0; + + public function __construct(private readonly ResponseInterface $response) {} + + public function handle(ServerRequestInterface $request): ResponseInterface + { + $this->calls++; + + return $this->response; + } +} diff --git a/tests/Architecture/EvolvePhp2PackageSkeletonTest.php b/tests/Architecture/EvolvePhp2PackageSkeletonTest.php index 053ccf5..8a9d0d3 100644 --- a/tests/Architecture/EvolvePhp2PackageSkeletonTest.php +++ b/tests/Architecture/EvolvePhp2PackageSkeletonTest.php @@ -854,6 +854,7 @@ private function acceptedPackageSourceInventories() 'Middleware/MiddlewarePipeline.php', 'Response/ExecutionOutcomeResponseResolver.php', 'Response/ResponseEmitter.php', + 'Routing/Console/RouteListCommand.php', 'Routing/Internal/RoutePattern.php', 'Routing/Route.php', 'Routing/RouteCollection.php',