diff --git a/.gitignore b/.gitignore index ff69cd6..ce33fd0 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ .phpunit.result.cache composer.lock Taskfile.yml +.DS_Store diff --git a/artisan b/artisan new file mode 100644 index 0000000..2646443 --- /dev/null +++ b/artisan @@ -0,0 +1,36 @@ +#!/usr/bin/env php +configure([]); + +// Create the Laravel app instance +$app = $appFactory->create(); + +// We need to register the service provider manually +$app->register(RouteDocs\RouteDocsServiceProvider::class); + +Route::middleware('web') + ->group('examples/routes.php'); + +// Do it +$app->make(Kernel::class)->handle( + $input = new Symfony\Component\Console\Input\ArgvInput(), + new Symfony\Component\Console\Output\ConsoleOutput() +); diff --git a/composer.json b/composer.json index 749b4db..b115a91 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ }, "autoload-dev": { "psr-4": { - "Examples\\Http\\Controllers\\": "examples/", + "Examples\\Http\\": "examples/", "Tests\\": "tests/" } }, diff --git a/examples/BookingController.php b/examples/BookingController.php deleted file mode 100644 index c0fac0c..0000000 --- a/examples/BookingController.php +++ /dev/null @@ -1,40 +0,0 @@ -getDocumentedRoutes(); + parent::__construct(); + $this->inspector = $inspector; + } + public function handle(): int + { $sortKey = $this->option('sort') ?? 'path'; - $sorted = $routes->sortByKey($sortKey); + $path = $this->option('path'); + + if ($path) { + $this->inspector = new RouteDocInspector($path); + } + + $routes = $this->inspector->getDocumentedRoutes(); + $sorted = $routes->sortByKey($sortKey); + + if ($this->option('json')) { + $this->line($sorted->toJson()); + + return Command::SUCCESS; + } $hasErrors = $sorted->hasErrors(); $headers = $hasErrors diff --git a/src/Console/ValidateEndpoints.php b/src/Console/ValidateEndpoints.php index 1374ad0..58dd447 100644 --- a/src/Console/ValidateEndpoints.php +++ b/src/Console/ValidateEndpoints.php @@ -10,16 +10,16 @@ class ValidateEndpoints extends Command protected $signature = 'route:docs:validate {--path= : Path to controller directory}'; protected $description = 'Validate route attribute usage across controllers.'; + protected RouteDocInspector $inspector; + public function __construct(RouteDocInspector $inspector) { - // This allows both CLI flexibility and test mocking parent::__construct(); $this->inspector = $inspector; } public function handle(): int { - // If --path is provided, re-instantiate inspector with path if ($path = $this->option('path')) { $this->inspector = new RouteDocInspector($path); } diff --git a/src/Support/RouteDocEntry.php b/src/Support/RouteDocEntry.php index 43590ca..4640ac2 100644 --- a/src/Support/RouteDocEntry.php +++ b/src/Support/RouteDocEntry.php @@ -4,6 +4,7 @@ class RouteDocEntry { + protected array $errorContext = []; protected bool $useColor = false; public function __construct( @@ -13,6 +14,12 @@ public function __construct( public string $path, public ?string $name = null, public bool $error = false, + public array $params = [ + 'path' => [], + 'query' => [], + 'form' => [], + ], + public array $middlewares = [], ) { } @@ -21,6 +28,22 @@ public function setOutputColor(bool $useColor = true): void $this->useColor = $useColor; } + public function setError(array $context = []): void + { + $this->error = true; + $this->errorContext = $context; + } + + public function hasError(): bool + { + return $this->error; + } + + public function getErrors(): array + { + return $this->errorContext; + } + protected function colorHttpMethod(string $method): string { return match (strtoupper($method)) { diff --git a/src/Support/RouteDocInspector.php b/src/Support/RouteDocInspector.php index 810d7f6..4f47e5e 100644 --- a/src/Support/RouteDocInspector.php +++ b/src/Support/RouteDocInspector.php @@ -8,7 +8,10 @@ use Illuminate\Support\Str; use ReflectionClass; use ReflectionMethod; +use RouteDocs\Attributes\formParam; use RouteDocs\Attributes\HttpMethod; +use RouteDocs\Attributes\param; +use RouteDocs\Attributes\queryParam; class RouteDocInspector { @@ -21,28 +24,6 @@ public function __construct(?string $controllerPath = null, bool $requireNameMat $this->requireNameMatch = $requireNameMatch; } - protected function controllerNamespace(): string - { - $basePath = base_path() . '/'; - $relativePath = Str::after($this->controllerPath, $basePath); - $trimmedPath = trim($relativePath, '/'); - $namespace = str_replace('/', '\\', $trimmedPath); - $default = 'App\\Http\\Controllers'; - - return $namespace ?: $default; - } - - protected function getClassFromFile(string $file): ?string - { - $src = file_get_contents($file); - if (preg_match('/namespace\s+([^;]+);/', $src, $nsMatch) && - preg_match('/class\s+([^\s{]+)/', $src, $classMatch)) { - return trim($nsMatch[1]) . '\\' . trim($classMatch[1]); - } - - return null; - } - public function getDocumentedRoutes(): RouteDocCollection { $routes = collect(); @@ -67,7 +48,7 @@ public function getDocumentedRoutes(): RouteDocCollection ? substr($class, strlen($namespace) + 1) : $class; - $error = !$this->routeExists( + $validRoute = $this->routeExists( name : $instance->name, path : $instance->path, method: $instance::method(), @@ -76,21 +57,24 @@ class : $class, ); $rules = [ - 'path' => 'required|string|regex:/^\//', - 'method' => 'required|string|in:GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS', - 'name' => 'nullable|string|regex:/^[a-zA-Z0-9_.-]+$/', + 'path' => 'required|string|regex:/^\//', + 'method' => 'required|string|in:GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS', + 'name' => 'nullable|string|regex:/^[a-zA-Z0-9_.-]+$/', + 'validRoute' => 'accepted', // aka true + ]; + $messages = [ + 'validRoute.accepted' => 'The route is invalid.', ]; $validator = Validator::make( [ - 'path' => $instance->path, - 'method' => $instance::method(), - 'name' => $instance->name, + 'path' => $instance->path, + 'method' => $instance::method(), + 'name' => $instance->name, + 'validRoute' => $validRoute, ], - $rules + $rules, + $messages ); - if ($validator->fails()) { - $error = true; - } $entry = new RouteDocEntry( class : $shortClass, @@ -98,9 +82,14 @@ class : $shortClass, method: $instance::method(), path : $instance->path, name : $instance->name, - error : $error ); + if ($validator->fails()) { + $entry->setError($validator->errors()->all()); + } + + $entry->params = $this->gatherParams($method); + $routes->push($entry); } } @@ -145,7 +134,56 @@ protected function routeExists(?string $name, string $path, string $method, stri return false; } - protected function getPhpFiles(string $dir): array + protected static function gatherParams(ReflectionMethod $method): array + { + $paramGroups = [ + 'path' => [], + 'query' => [], + 'form' => [], + ]; + + foreach ($method->getAttributes() as $attr) { + $instance = $attr->newInstance(); + + if (!$instance instanceof Param) { + continue; + } + + $paramGroups[ $instance->type ][] = [ + 'key' => $instance->key, + 'cast' => $instance->cast, + 'required' => $instance->required, + 'description' => $instance->description, + 'example' => $instance->example, + ]; + } + + return $paramGroups; + } + + private function controllerNamespace(): string + { + $basePath = base_path() . '/'; + $relativePath = Str::after($this->controllerPath, $basePath); + $trimmedPath = trim($relativePath, '/'); + $namespace = str_replace('/', '\\', $trimmedPath); + $default = 'App\\Http\\Controllers'; + + return $namespace ?: $default; + } + + private function getClassFromFile(string $file): ?string + { + $src = file_get_contents($file); + if (preg_match('/namespace\s+([^;]+);/', $src, $nsMatch) && + preg_match('/class\s+([^\s{]+)/', $src, $classMatch)) { + return trim($nsMatch[1]) . '\\' . trim($classMatch[1]); + } + + return null; + } + + private function getPhpFiles(string $dir): array { return File::allFiles($dir); } diff --git a/tests/Feature/CommandsTest.php b/tests/Feature/CommandsTest.php index 58851af..acb20ea 100644 --- a/tests/Feature/CommandsTest.php +++ b/tests/Feature/CommandsTest.php @@ -45,7 +45,7 @@ public function testListEndpointsWithErrors() $output = Artisan::output(); $this->assertEquals(0, $result); - $this->assertStringNotContainsString('error', $output); + $this->assertStringContainsString('error', $output); $this->assertStringContainsString('method', $output); $this->assertStringContainsString('path', $output); $this->assertStringContainsString('name', $output); @@ -80,4 +80,44 @@ public function testValidateEndpointsSuccess() $this->assertEquals(0, $result); $this->assertStringContainsString('All documented routes are correctly registered', $output); } + + public function testListOutputJsonFormat() + { + $class = 'A'; + $action = 'foo'; + $method = 'GET'; + $path = '/a'; + $name = 'a'; + $error = false; + + $entry = new RouteDocEntry($class, $action, $method, $path, $name, $error); + $collection = new RouteDocCollection([$entry]); + $this->mockInspectorWithRoutes($collection); + + $result = Artisan::call('route:docs', ['--json' => true]); + $output = Artisan::output(); + + $this->assertEquals(0, $result); + $this->assertJson($output); + + $data = json_decode($output, true); + $this->assertIsArray($data); + $this->assertCount(1, $data); + + $entry = $data[0]; + + $this->assertArrayHasKey('class', $entry); + $this->assertArrayHasKey('action', $entry); + $this->assertArrayHasKey('method', $entry); + $this->assertArrayHasKey('path', $entry); + $this->assertArrayHasKey('name', $entry); + $this->assertArrayHasKey('error', $entry); + + $this->assertEquals($class, $entry['class']); + $this->assertEquals($action, $entry['action']); + $this->assertEquals($method, $entry['method']); + $this->assertEquals($path, $entry['path']); + $this->assertEquals($name, $entry['name']); + $this->assertEquals($error, $entry['error']); + } } diff --git a/tests/Feature/DetectParam.php b/tests/Feature/DetectParam.php new file mode 100644 index 0000000..1ff66ff --- /dev/null +++ b/tests/Feature/DetectParam.php @@ -0,0 +1,42 @@ +getByName(null); // Assuming it's the only one + $this->assertNotNull($route); + + $entry = RouteDocInspector::getDocumentedRoutes($route); + $this->assertNotNull($entry); + + $this->assertEquals(QueryParamController::class, $entry->controller); + $this->assertEquals('index', $entry->method); + $this->assertEquals('/query-param-test', $entry->uri); + + $this->assertIsArray($entry->queryParams); + $this->assertCount(1, $entry->queryParams); + + $param = $entry->queryParams[0]; + $this->assertEquals('status', $param['key']); + $this->assertEquals('bool', $param['cast']); + $this->assertTrue($param['required']); + $this->assertEquals('Filter by status', $param['description']); + } +} diff --git a/tests/Feature/LaravelRouteGoodTest.php b/tests/Feature/LaravelRouteGoodTest.php index 23dbfac..c4207c6 100644 --- a/tests/Feature/LaravelRouteGoodTest.php +++ b/tests/Feature/LaravelRouteGoodTest.php @@ -4,8 +4,11 @@ use Examples\Http\Controllers\BookingController; use Examples\Http\Controllers\ExampleController; +use Examples\Http\Controllers\PostApiController; +use Examples\Http\Controllers\PostController; use RouteDocs\Support\RouteDocInspector; use Tests\TestCase; +use function Symfony\Component\String\s; class LaravelRouteGoodTest extends TestCase { @@ -21,8 +24,10 @@ protected function defineRoutes($router): void $router->get('/bookings', [BookingController::class, 'index'])->name('bookings.index'); $router->post('/bookings', [BookingController::class, 'store'])->name('bookings.store'); $router->get('/bookings/{id}', [BookingController::class, 'show'])->name('bookings.show'); - $router->post('/bookings/{id}/cancel', [BookingController::class, 'cancel'])->name('bookings.cancel'); - $router->get('/bookings/stats/daily', [BookingController::class, 'dailyStats'])->name('bookings.stats.daily'); + $router->delete('/bookings/{id}/cancel', [BookingController::class, 'cancel'])->name('bookings.cancel'); + $router->get('/bookings/stats/{frequency}', [BookingController::class, 'stats'])->name('bookings.stats'); + $router->resource('/posts', PostController::class); + $router->apiResource('/api/posts', PostApiController::class); } public function testAttributeRouteMatchesRegisteredRoute() @@ -37,6 +42,19 @@ public function testAttributeRouteMatchesRegisteredRoute() $this->assertNotEmpty($routes, 'No documented routes found.'); $this->assertIsArray($array); + // We don't want this and will give us a passing test next, but let's help the user out + foreach ($routes as $route) { + if ($route->hasError()) { + $msg = 'Error found in route: %s %s - %s'; + $method = $route->method; + $path = $route->path; + $context = implode(', ', $route->getErrors()); + $this->fail(sprintf($msg, $method, $path, $context)); + } + $this->assertFalse($route->hasError()); + $this->assertEmpty($route->getErrors()); + } + $this->assertFalse($errors, 'Expected no errors but found some.'); } } diff --git a/tests/Unit/RouteDocCollectionTest.php b/tests/Unit/RouteDocCollectionTest.php index b006d5e..5a483db 100644 --- a/tests/Unit/RouteDocCollectionTest.php +++ b/tests/Unit/RouteDocCollectionTest.php @@ -1,5 +1,7 @@ setOutputColor(true); @@ -55,12 +57,12 @@ class: 'App\\Http\\Controllers\\UserController', public function testSetOutputColorSwitchesBackToRaw() { $entry = new RouteDocEntry( - class: 'App\\Controller', + class : 'App\\Controller', action: 'edit', method: 'PATCH', - path: '/edit/{item}', - name: null, - error: false + path : '/edit/{item}', + name : null, + error : false ); $entry->setOutputColor(true); $this->assertStringContainsString('PATCH', $entry->toArray()['method']);