From 764e7ff3028d8033a1b24c2fd50e2015fd0ae7b2 Mon Sep 17 00:00:00 2001 From: Mike Garde Date: Mon, 9 Jun 2025 00:34:59 -0500 Subject: [PATCH 1/5] Basic Working Version --- .gitignore | 29 +------ composer.json | 55 ++++++++++++ src/Attributes/delete.php | 6 ++ src/Attributes/get.php | 6 ++ src/Attributes/httpMethod.php | 17 ++++ src/Attributes/patch.php | 6 ++ src/Attributes/post.php | 6 ++ src/Attributes/put.php | 6 ++ src/Console/ListEndpoints.php | 30 +++++++ src/Console/ValidateEndpoints.php | 33 ++++++++ src/RouteDocsServiceProvider.php | 20 +++++ src/Support/RouteDocCollection.php | 33 ++++++++ src/Support/RouteDocEntry.php | 82 ++++++++++++++++++ src/Support/RouteDocInspector.php | 131 +++++++++++++++++++++++++++++ src/helpers.php | 15 ++++ 15 files changed, 448 insertions(+), 27 deletions(-) create mode 100644 composer.json create mode 100644 src/Attributes/delete.php create mode 100644 src/Attributes/get.php create mode 100644 src/Attributes/httpMethod.php create mode 100644 src/Attributes/patch.php create mode 100644 src/Attributes/post.php create mode 100644 src/Attributes/put.php create mode 100644 src/Console/ListEndpoints.php create mode 100644 src/Console/ValidateEndpoints.php create mode 100644 src/RouteDocsServiceProvider.php create mode 100644 src/Support/RouteDocCollection.php create mode 100644 src/Support/RouteDocEntry.php create mode 100644 src/Support/RouteDocInspector.php create mode 100644 src/helpers.php diff --git a/.gitignore b/.gitignore index d5673e3..30ea24f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,30 +1,5 @@ +/.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 diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..aa4b71c --- /dev/null +++ b/composer.json @@ -0,0 +1,55 @@ +{ + "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/" + } + }, + "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 +} 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..5333f01 --- /dev/null +++ b/src/Support/RouteDocInspector.php @@ -0,0 +1,131 @@ +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; + } + + public function getDocumentedRoutes(): RouteDocCollection + { + $routes = collect(); + $seen = []; + + foreach ($this->getPhpFiles($this->controllerPath) as $file) { + require_once $file; + + foreach (get_declared_classes() as $class) { + $namespace = $this->controllerNamespace(); + if (!Str::startsWith(Str::lower($class), Str::lower($namespace . '\\'))) { + continue; + } + if (in_array($class, $seen, true)) { + continue; + } + $seen[] = $class; + + $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 + { + $action = strtoupper($action); + $path = $path === '/' ? '/' : ltrim($path, '/'); + + foreach (Route::getRoutes() as $route) { + if ( + $route->uri() === $path && + in_array(strtoupper($action), $route->methods(), true) + ) { + $action = $route->getAction('controller'); + + if (!str_contains($action, '@')) { + continue; + } + + [$actualController, $actualMethod] = explode('@', $action); + + 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 @@ + Date: Mon, 9 Jun 2025 13:35:46 -0500 Subject: [PATCH 2/5] Tests & Examples - Added LaravelRouteBadTest to test missing registered routes - Added LaravelRouteGoodTest to test matching registered routes - Created TestCase for test setup with necessary package providers - Smarter loading of classes --- composer.json | 13 +++- examples/BookingController.php | 40 ++++++++++ examples/ExampleController.php | 36 +++++++++ phpunit.xml | 11 +++ src/Support/RouteDocInspector.php | 103 +++++++++++++------------ tests/Feature/LaravelRouteBadTest.php | 36 +++++++++ tests/Feature/LaravelRouteGoodTest.php | 42 ++++++++++ tests/TestCase.php | 16 ++++ 8 files changed, 245 insertions(+), 52 deletions(-) create mode 100644 examples/BookingController.php create mode 100644 examples/ExampleController.php create mode 100644 phpunit.xml create mode 100644 tests/Feature/LaravelRouteBadTest.php create mode 100644 tests/Feature/LaravelRouteGoodTest.php create mode 100644 tests/TestCase.php diff --git a/composer.json b/composer.json index aa4b71c..5825581 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,8 @@ }, "autoload-dev": { "psr-4": { - "Examples\\Http\\Controllers\\": "examples/" + "Examples\\Http\\Controllers\\": "examples/", + "Tests\\": "tests/" } }, "extra": { @@ -51,5 +52,13 @@ "developer tools" ], "minimum-stability": "stable", - "prefer-stable": true + "prefer-stable": true, + "scripts": { + "test": [ + "phpunit" + ], + "post-autoload-dump": [ + "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump" + ] + } } diff --git a/examples/BookingController.php b/examples/BookingController.php new file mode 100644 index 0000000..c0fac0c --- /dev/null +++ b/examples/BookingController.php @@ -0,0 +1,40 @@ + '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/Support/RouteDocInspector.php b/src/Support/RouteDocInspector.php index 5333f01..d3c7ff4 100644 --- a/src/Support/RouteDocInspector.php +++ b/src/Support/RouteDocInspector.php @@ -31,59 +31,62 @@ protected function controllerNamespace(): string 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(); - $seen = []; + $routes = collect(); + $namespace = $this->controllerNamespace(); foreach ($this->getPhpFiles($this->controllerPath) as $file) { - require_once $file; + $class = $this->getClassFromFile($file); + if (!$class || !class_exists($class)) { + continue; + } + $ref = new ReflectionClass($class); - foreach (get_declared_classes() as $class) { - $namespace = $this->controllerNamespace(); - if (!Str::startsWith(Str::lower($class), Str::lower($namespace . '\\'))) { - continue; - } - if (in_array($class, $seen, true)) { - continue; - } - $seen[] = $class; - - $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); + 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); @@ -92,21 +95,21 @@ class : $shortClass, protected function routeExists(?string $name, string $path, string $method, string $class, string $action): bool { - $action = strtoupper($action); + $method = strtoupper($method); $path = $path === '/' ? '/' : ltrim($path, '/'); foreach (Route::getRoutes() as $route) { if ( $route->uri() === $path && - in_array(strtoupper($action), $route->methods(), true) + in_array(strtoupper($method), $route->methods(), true) ) { - $action = $route->getAction('controller'); + $laravelAction = $route->getAction('controller'); - if (!str_contains($action, '@')) { + if (!str_contains($laravelAction, '@')) { continue; } - [$actualController, $actualMethod] = explode('@', $action); + [$actualController, $actualMethod] = explode('@', $laravelAction); if ( ltrim($actualController, '\\') === ltrim($class, '\\') && diff --git a/tests/Feature/LaravelRouteBadTest.php b/tests/Feature/LaravelRouteBadTest.php new file mode 100644 index 0000000..f1668ab --- /dev/null +++ b/tests/Feature/LaravelRouteBadTest.php @@ -0,0 +1,36 @@ +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 @@ + Date: Mon, 9 Jun 2025 13:36:33 -0500 Subject: [PATCH 3/5] Add Docker-related configurations and tasks for PHP development - Defined Taskfile.dist.yml to manage Docker image building tasks - Included docker-compose.yml for PHP container setup - Added test and SSH tasks for Docker-based testing and interaction --- .gitignore | 1 + Dockerfile | 15 +++++++++++++++ Taskfile.dist.yml | 47 ++++++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 11 +++++++++++ 4 files changed, 74 insertions(+) create mode 100644 Dockerfile create mode 100644 Taskfile.dist.yml create mode 100644 docker-compose.yml diff --git a/.gitignore b/.gitignore index 30ea24f..fbd1006 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ .phpunit.result.cache composer.lock +Taskfile.yml 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/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/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fe6848b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +services: + php: + build: + context: . + dockerfile: Dockerfile + args: + PHP_VERSION: 8.1 + volumes: + - .:/app + working_dir: /app + command: ["tail", "-f", "/dev/null"] From 1567e5e628090ae814987b64d4650e4c12e5b5ec Mon Sep 17 00:00:00 2001 From: Mike Garde Date: Mon, 9 Jun 2025 13:48:03 -0500 Subject: [PATCH 4/5] Implement automated release process on GitHub with versioning - Added GitHub workflow for running tests on pull requests - Included `release.sh` script for automated version management - Implemented the process of creating GitHub releases with generated notes --- .github/workflows/tests.yml | 29 +++++++++ devops/release.sh | 119 ++++++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 .github/workflows/tests.yml create mode 100644 devops/release.sh 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/devops/release.sh b/devops/release.sh new file mode 100644 index 0000000..a38df27 --- /dev/null +++ b/devops/release.sh @@ -0,0 +1,119 @@ +#!/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 < Date: Mon, 9 Jun 2025 14:17:15 -0500 Subject: [PATCH 5/5] Documentation Documentation & release fix --- DEVELOP.md | 127 ++++++++++++++++++++++++++++++++++++++++++++++ README.md | 62 +++++++++++++++++++++- devops/release.sh | 9 +--- 3 files changed, 189 insertions(+), 9 deletions(-) create mode 100644 DEVELOP.md 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/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/devops/release.sh b/devops/release.sh index a38df27..5cfddbf 100644 --- a/devops/release.sh +++ b/devops/release.sh @@ -107,13 +107,6 @@ if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then exit 1 fi -# Do it -HASH=$(git rev-parse --short HEAD) -DATE=$(date +"%Y%m.%d.%H%M") -BRANCH_FN=$(echo $BRANCH | tr -d '/') -ZIP_FILE="devops/${NEW_VERSION}-$BRANCH_FN-$DATE-$HASH.zip" -sh devops/build.sh "$ZIP_FILE" - -gh release create "$NEW_VERSION" --generate-notes --target $BRANCH $ZIP_FILE +gh release create "$NEW_VERSION" --generate-notes --target $BRANCH exit 0