diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..53a1bd3 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,29 @@ +name: Run tests + +on: + pull_request: + types: [ opened, synchronize, reopened ] + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + php: [8.1, 8.2, 8.3, 8.4] + name: PHP ${{ matrix.php }} Test + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: zip + + - name: Install Dependencies + run: composer install + + - name: Run tests + run: composer test diff --git a/.gitignore b/.gitignore index d5673e3..fbd1006 100644 --- a/.gitignore +++ b/.gitignore @@ -1,30 +1,6 @@ +/.idea/ /vendor/ -node_modules/ -npm-debug.log -yarn-error.log -# Laravel 4 specific -bootstrap/compiled.php -app/storage/ - -# Laravel 5 & Lumen specific -public/storage -public/hot - -# Laravel 5 & Lumen specific with changed public path -public_html/storage -public_html/hot - -storage/*.key -.env -Homestead.yaml -Homestead.json -/.vagrant .phpunit.result.cache - -/public/build -/storage/pail -.env.backup -.env.production -.phpactor.json -auth.json +composer.lock +Taskfile.yml diff --git a/DEVELOP.md b/DEVELOP.md new file mode 100644 index 0000000..7b250a9 --- /dev/null +++ b/DEVELOP.md @@ -0,0 +1,127 @@ +# Development Guide for `laravel-route-docs` + +This document outlines the local development workflow for building and testing the `laravel-route-docs` package, +as well as integrating it into other Laravel projects before publishing. + +--- + +## Project Setup + + - [Docker](https://www.docker.com/) + - [Taskfile](https://taskfile.dev/) + +### Setup & Install Dependencies + +```bash +task build +``` + +--- + +## Running Tests + +### PHPUnit + +```bash +task test:unit +``` + +Or run tests against all versions of PHP: + +```bash +task test:all +``` + +--- + +## Route Integration Tests with Laravel Kernel + +- Tests extend `Tests\TestCase`, which boots a full Laravel app kernel using Testbench. +- Example route definitions are injected using `defineRoutes()`. + +--- + +## Using This Package in Another Laravel Project (During Development) + +If you're actively developing this package and want to test it **in another Laravel app** without publishing it yet: + +### Option 1: Path Repository (Recommended) + +In your consuming Laravel app's `composer.json`: + +```json +"repositories": [ + { + "type": "path", + "url": "../path-to/laravel-route-docs" + } +], +"require": { + "mikegarde/laravel-route-docs": "*" +} +``` + +Then run: + +```bash +composer update mikegarde/laravel-route-docs +``` + +> Changes in `laravel-route-docs` will be picked up live, no commit/push needed. + +--- + +## Using Inside Docker (Path Setup) + +If you're using Docker and the package is **outside** the app folder, mount both volumes in your `docker-compose.yml`: + +```yaml +services: + php: + volumes: + - ../your-laravel-app:/var/www/html + - ../laravel-route-docs:/packages/laravel-route-docs +``` + +Then in the app's `composer.json`: + +```json +"repositories": [ + { + "type": "path", + "url": "/packages/laravel-route-docs" + } +] +``` + +Run this inside your container: + +```bash +composer require mikegarde/laravel-route-docs:* --prefer-source +# And maybe also: +composer dump-autoload +php artisan ide-helper:generate +``` + +--- + +## Helpers and Polyfills + +To support `base_path()` and `app_path()` in both Laravel and standalone mode: +- Polyfills are provided in `src/helpers.php`. +- Registered in `composer.json` under `autoload.files`. + +--- + +## Useful Commands + +- `php artisan route:docs` — show documented routes +- `php artisan route:docs:validate` — validate attribute/route consistency +- `composer test` — run all tests + +--- + +## Notes + +- The `examples/Http/Controllers` folder contains real attribute usage for demo and test purposes. + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..83679e5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +ARG PHP_VERSION=8.1 +FROM php:${PHP_VERSION}-cli + +# Install dependencies +RUN apt-get update && apt-get install -y \ + git zip unzip curl libzip-dev + +# Install Composer &... +# Can install >100 PHP extensions +# See: https://github.com/mlocati/docker-php-extension-installer +ADD https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/ +RUN chmod +x /usr/local/bin/install-php-extensions && sync +RUN install-php-extensions @composer xdebug + +WORKDIR /app diff --git a/README.md b/README.md index add4478..b80b937 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,62 @@ -# laravel-route-docs +# Laravel Route Docs + +[![Latest Version on Packagist](https://img.shields.io/packagist/v/mikegarde/laravel-route-docs.svg?style=flat-square)](https://packagist.org/packages/mikegarde/laravel-route-docs) +[![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/mikegarde/laravel-route-docs/run-tests.yml?branch=main&label=tests)](https://github.com/mikegarde/laravel-route-docs/actions) +[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE) + A Laravel package that uses PHP attributes to document routes, generate readable route listings, and export OpenAPI or Postman definitions. + +--- + +## Features + +- Document routes directly using PHP attributes +- Validate route documentation in your CI/CD pipeline +- Includes CLI tooling for discovery and inspection + +### TODO: +- Add request parameters +- Add response schemas +- Export route definitions as JSON, OpenAPI, or Postman collections + +--- + +## Installation + +```bash +composer require mikegarde/laravel-route-docs --dev +``` + +## Usage +Annotate your controller methods using custom attributes to describe your API: + +```php +use RouteDocs\Attributes\get; + +class ItemController +{ + #[get('/items', name: 'items.index')] + public function index() + { + return Item::all(); + } +} +``` + +Then run: + +```bash +php artisan route:docs +``` + +You’ll get a structured view of your documented routes. + +## Validate Route Attributes in CI/CD + +You can validate that all routes have correct and complete attribute annotations: + +```bash +php artisan route:docs:validate +``` + +This will return non-zero exit codes on failure, making it CI-friendly. diff --git a/Taskfile.dist.yml b/Taskfile.dist.yml new file mode 100644 index 0000000..7813efb --- /dev/null +++ b/Taskfile.dist.yml @@ -0,0 +1,47 @@ +version: '3' + +vars: + DEFAULT_PHP: '8.1' + +tasks: + default: + silent: true + cmds: + - task --list + - echo "" + - echo -e "\033[34mGet more details by running \033[36mtask --summary [COMMAND]\033[0m" + + build: + vars: + PHP_VERSION: '{{.DEFAULT_PHP}}' + desc: Build the Docker image for PHP development + cmds: + - task: build:{{.PHP_VERSION}} + build:*: + vars: + PHP_VERSION: '{{index .MATCH 0}}' + cmds: + - docker compose down + - rm -f composer.lock + - docker compose build --build-arg PHP_VERSION={{.PHP_VERSION}} + - docker compose up -d + - docker compose exec php composer install --no-interaction --prefer-dist + + test:unit: + desc: Run tests inside the container + cmds: + - docker compose run --rm php composer test + test:all: + desc: Run all tests inside the container + cmds: + - | + VERSIONS=("8.1" "8.2" "8.3" "8.4") + for VERSION in "${VERSIONS[@]}"; do + task build:$VERSION + task test:unit + done + + ssh: + desc: SSH into the PHP container + cmds: + - docker compose exec php bash diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..5825581 --- /dev/null +++ b/composer.json @@ -0,0 +1,64 @@ +{ + "name": "mikegarde/laravel-route-docs", + "description": "A Laravel package that uses PHP attributes to document routes, generate readable route listings, and export OpenAPI or Postman definitions.", + "type": "library", + "license": "GPL-3.0-only", + "authors": [ + { + "name": "Mike Garde", + "email": "you@example.com" + } + ], + "require": { + "php": "^8.1", + "illuminate/support": "^9.0|^10.0|^11.0|^12.0", + "illuminate/console": "^9.0|^10.0|^11.0|^12.0" + }, + "require-dev": { + "phpunit/phpunit": ">=10.5", + "orchestra/testbench": ">=8.18" + }, + "autoload": { + "psr-4": { + "RouteDocs\\": "src/" + }, + "files": [ + "src/helpers.php" + ] + }, + "autoload-dev": { + "psr-4": { + "Examples\\Http\\Controllers\\": "examples/", + "Tests\\": "tests/" + } + }, + "extra": { + "laravel": { + "providers": [ + "RouteDocs\\RouteDocsServiceProvider" + ] + } + }, + "keywords": [ + "laravel", + "routes", + "route docs", + "api docs", + "openapi", + "postman", + "attribute", + "annotation", + "cli", + "developer tools" + ], + "minimum-stability": "stable", + "prefer-stable": true, + "scripts": { + "test": [ + "phpunit" + ], + "post-autoload-dump": [ + "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump" + ] + } +} diff --git a/devops/release.sh b/devops/release.sh new file mode 100644 index 0000000..5cfddbf --- /dev/null +++ b/devops/release.sh @@ -0,0 +1,112 @@ +#!/bin/zsh + +# Variables +STEP=$1 +BRANCH=$(git rev-parse --abbrev-ref HEAD) +RELEASES=$(gh release list --json tagName | jq -r '.[].tagName') + +get_branch_for_tag() { + local tag=$1 + local sha=$(git rev-list -n 1 "$tag") + if git branch -r --contains "$sha" | grep -q "origin/main"; then + echo "main" + elif git branch -r --contains "$sha" | grep -q "origin/develop"; then + echo "develop" + else + echo "other" + fi +} + +# Track latest versions +MAIN_VERSION="0.0.0" +DEVELOP_VERSION="0.0.0" +OTHER_VERSION="0.0.0" + +for tag in $RELEASES; do + branch=$(get_branch_for_tag $tag) + case $branch in + main) + if [ "$(printf '%s\n' "$MAIN_VERSION" "$tag" | sort -V | tail -n1)" = "$tag" ]; then + MAIN_VERSION=$tag + fi + ;; + develop) + if [ "$(printf '%s\n' "$DEVELOP_VERSION" "$tag" | sort -V | tail -n1)" = "$tag" ]; then + DEVELOP_VERSION=$tag + fi + ;; + other) + if [ "$(printf '%s\n' "$OTHER_VERSION" "$tag" | sort -V | tail -n1)" = "$tag" ]; then + OTHER_VERSION=$tag + fi + ;; + esac +done + +# Determine which version to bump +if [ "$BRANCH" = "main" ]; then + VERSION=$MAIN_VERSION +elif [ "$BRANCH" = "develop" ]; then + MAIN_MAJOR=$(echo $MAIN_VERSION | cut -d. -f1) + DEV_MAJOR=$(echo $DEVELOP_VERSION | cut -d. -f1) + if [ "$DEVELOP_VERSION" = "0.0.0" ] || [ "$DEV_MAJOR" = "$MAIN_MAJOR" ]; then + VERSION="$((MAIN_MAJOR+1)).0.0" + else + VERSION=$DEVELOP_VERSION + fi +else + VERSION=$OTHER_VERSION +fi + +# Parse version +MAJOR=$(echo $VERSION | cut -d. -f1) +MINOR=$(echo $VERSION | cut -d. -f2) +PATCH=$(echo $VERSION | cut -d. -f3) + +# Bump version +if [ "$STEP" = "major" ]; then + MAJOR=$((MAJOR+1)) + MINOR=0 + PATCH=0 +elif [ "$STEP" = "minor" ]; then + MINOR=$((MINOR+1)) + PATCH=0 +elif [ "$STEP" = "patch" ]; then + PATCH=$((PATCH+1)) +else + echo "Invalid step: $STEP" + exit 1 +fi +NEW_VERSION="$MAJOR.$MINOR.$PATCH" + +# Build table data +header="$(printf '\033[1;34mBranch\033[0m|\033[1;34mLatest Version\033[0m')" +table_data=$(cat < 'ok']; + } + + #[post('/status/{element}', name: 'status.post')] + public function updateStatus(int $element): array + { + return ['status' => 'updated']; + } + + #[get('/')] + #[get('/home', name: 'home.index')] + #[post('/home', name: 'home.post')] + public function legacyHome(): string + { + // Sometimes you inherit insane legacy code + return 'Welcome to the Example Controller!'; + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..77ff583 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,11 @@ + + + + + ./tests + + + diff --git a/src/Attributes/delete.php b/src/Attributes/delete.php new file mode 100644 index 0000000..efc22f4 --- /dev/null +++ b/src/Attributes/delete.php @@ -0,0 +1,6 @@ +getDocumentedRoutes(); + + $sortKey = $this->option('sort') ?? 'path'; + $sorted = $routes->sortByKey($sortKey); + + $hasErrors = $sorted->hasErrors(); + $headers = $hasErrors + ? ['error', 'method', 'path', 'name', 'class', 'action'] + : ['method', 'path', 'name', 'class', 'action']; + + $this->table($headers, $sorted->toDisplayArray($hasErrors)); + + return Command::SUCCESS; + } +} diff --git a/src/Console/ValidateEndpoints.php b/src/Console/ValidateEndpoints.php new file mode 100644 index 0000000..289935b --- /dev/null +++ b/src/Console/ValidateEndpoints.php @@ -0,0 +1,33 @@ +option('path') ?? null); + $routes = $inspector->getDocumentedRoutes(); + $errors = $routes->onlyErrors(); + + if ($errors->isEmpty()) { + $this->info('✔ All documented routes are correctly registered.'); + + return Command::SUCCESS; + } + + $this->error('✖ Some documented routes are missing from the Laravel route list:'); + $this->table( + ['method', 'path', 'name', 'class', 'method_name'], + $errors->toDisplayArray() + ); + + return Command::FAILURE; + } +} diff --git a/src/RouteDocsServiceProvider.php b/src/RouteDocsServiceProvider.php new file mode 100644 index 0000000..09df4c7 --- /dev/null +++ b/src/RouteDocsServiceProvider.php @@ -0,0 +1,20 @@ +app->runningInConsole()) { + $this->commands([ + ListEndpoints::class, + ValidateEndpoints::class, + ]); + } + } +} diff --git a/src/Support/RouteDocCollection.php b/src/Support/RouteDocCollection.php new file mode 100644 index 0000000..412acef --- /dev/null +++ b/src/Support/RouteDocCollection.php @@ -0,0 +1,33 @@ +sortBy(fn(RouteDocEntry $entry) => $entry->{$key} ?? null)->values()); + } + + public function hasErrors(): bool + { + return $this->contains(fn(RouteDocEntry $entry) => $entry->error); + } + + public function onlyErrors(): self + { + return new static($this->filter(fn(RouteDocEntry $entry) => $entry->error)->values()); + } + + public function toDisplayArray(bool $includeError = false, bool $withColor = true): array + { + return $this->map(function (RouteDocEntry $entry) use ($withColor, $includeError) { + $entry->setOutputColor($withColor); + $row = $entry->toArray(); + + return $includeError ? $row : array_slice($row, 1); // Drop 'error' column if not needed + })->values()->all(); + } +} diff --git a/src/Support/RouteDocEntry.php b/src/Support/RouteDocEntry.php new file mode 100644 index 0000000..bd1f1c7 --- /dev/null +++ b/src/Support/RouteDocEntry.php @@ -0,0 +1,82 @@ +useColor = $useColor; + } + + protected function colorHttpMethod(string $method): string + { + return match (strtoupper($method)) { + 'GET' => 'GET', + 'POST' => 'POST', + 'PUT' => 'PUT', + 'PATCH' => 'PATCH', + 'DELETE' => 'DELETE', + default => "{$method}", + }; + } + + protected function highlightPathVariables(string $path): string + { + return preg_replace('/\{([^}]+)\}/', '{\\1}', $path); + } + + protected function highlightNestedController(string $controller): string + { + $segments = explode('\\', $controller); + + return collect($segments)->map(function ($part, $i) use ($segments) { + $isLast = $i === array_key_last($segments); + + return $isLast + ? $part + : "{$part}" . '/' . ""; + })->implode(''); + } + + public function colorArray(): array + { + return [ + 'error' => $this->error ? 'X' : '', + 'method' => $this->colorHttpMethod($this->method), + 'path' => $this->highlightPathVariables($this->path), + 'name' => $this->name ?? '', + 'class' => $this->highlightNestedController($this->class), + 'action' => $this->action, + ]; + } + + public function rawArray(): array + { + return [ + 'error' => $this->error ? 'X' : '', + 'method' => $this->httpMethod, + 'path' => $this->path, + 'name' => $this->name ?? '', + 'class' => $this->class, + 'action' => $this->action, + ]; + } + + public function toArray(): array + { + return $this->useColor ? $this->colorArray() : $this->rawArray(); + } +} diff --git a/src/Support/RouteDocInspector.php b/src/Support/RouteDocInspector.php new file mode 100644 index 0000000..d3c7ff4 --- /dev/null +++ b/src/Support/RouteDocInspector.php @@ -0,0 +1,134 @@ +controllerPath = $controllerPath ?? base_path('app/Http/Controllers'); + $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(); + $namespace = $this->controllerNamespace(); + + foreach ($this->getPhpFiles($this->controllerPath) as $file) { + $class = $this->getClassFromFile($file); + if (!$class || !class_exists($class)) { + continue; + } + $ref = new ReflectionClass($class); + + foreach ($ref->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + foreach ($method->getAttributes() as $attr) { + $instance = $attr->newInstance(); + + if (!is_subclass_of($instance, HttpMethod::class)) { + continue; + } + + $shortClass = Str::startsWith(Str::lower($class), Str::lower($namespace . '\\')) + ? substr($class, strlen($namespace) + 1) + : $class; + + $error = !$this->routeExists( + name : $instance->name, + path : $instance->path, + method: $instance::method(), + class : $class, + action: $method->getName() + ); + + $entry = new RouteDocEntry( + class : $shortClass, + action: $method->getName(), + method: $instance::method(), + path : $instance->path, + name : $instance->name, + error : $error + ); + + $routes->push($entry); + } + } + + } + + return new RouteDocCollection($routes); + } + + protected function routeExists(?string $name, string $path, string $method, string $class, + string $action): bool + { + $method = strtoupper($method); + $path = $path === '/' ? '/' : ltrim($path, '/'); + + foreach (Route::getRoutes() as $route) { + if ( + $route->uri() === $path && + in_array(strtoupper($method), $route->methods(), true) + ) { + $laravelAction = $route->getAction('controller'); + + if (!str_contains($laravelAction, '@')) { + continue; + } + + [$actualController, $actualMethod] = explode('@', $laravelAction); + + if ( + ltrim($actualController, '\\') === ltrim($class, '\\') && + $actualMethod === $action + ) { + if ($this->requireNameMatch || $name !== null) { + return $route->getName() === $name; + } + + return true; + } + } + } + + return false; + } + + protected function getPhpFiles(string $dir): array + { + return File::allFiles($dir); + } +} diff --git a/src/helpers.php b/src/helpers.php new file mode 100644 index 0000000..f925746 --- /dev/null +++ b/src/helpers.php @@ -0,0 +1,15 @@ +get('/ping', [ExampleController::class, 'ping'])->name('ping.get'); + $router->get('/status', [ExampleController::class, 'status'])->name('status.get'); + $router->post('/status/{element}', [ExampleController::class, 'updateStatus'])->name('status.post'); + $router->get('/', [ExampleController::class, 'legacyHome']); + $router->get('/home', [ExampleController::class, 'legacyHome'])->name('home.index'); + $router->post('/home', [ExampleController::class, 'legacyHome'])->name('home.post'); + } + + public function testAttributeRouteMissingRegisteredRoute() + { + $path = __DIR__ . '/../../examples'; + $inspector = new RouteDocInspector($path); + + $routes = $inspector->getDocumentedRoutes(); + $array = $routes->toArray(); + $errors = $routes->hasErrors(); + + $this->assertNotEmpty($routes, 'No documented routes found.'); + $this->assertIsArray($array); + + $this->assertTrue($errors, 'Expected errors but found none.'); + } +} diff --git a/tests/Feature/LaravelRouteGoodTest.php b/tests/Feature/LaravelRouteGoodTest.php new file mode 100644 index 0000000..23dbfac --- /dev/null +++ b/tests/Feature/LaravelRouteGoodTest.php @@ -0,0 +1,42 @@ +get('/ping', [ExampleController::class, 'ping'])->name('ping.get'); + $router->get('/status', [ExampleController::class, 'status'])->name('status.get'); + $router->post('/status/{element}', [ExampleController::class, 'updateStatus'])->name('status.post'); + $router->get('/', [ExampleController::class, 'legacyHome']); + $router->get('/home', [ExampleController::class, 'legacyHome'])->name('home.index'); + $router->post('/home', [ExampleController::class, 'legacyHome'])->name('home.post'); + + $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'); + } + + public function testAttributeRouteMatchesRegisteredRoute() + { + $path = __DIR__ . '/../../examples'; + $inspector = new RouteDocInspector($path); + + $routes = $inspector->getDocumentedRoutes(); + $array = $routes->toArray(); + $errors = $routes->hasErrors(); + + $this->assertNotEmpty($routes, 'No documented routes found.'); + $this->assertIsArray($array); + + $this->assertFalse($errors, 'Expected no errors but found some.'); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..02bdeb1 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,16 @@ +