From 485594987f0e86eac6c59d0bfeb3d5bfd20c51fd Mon Sep 17 00:00:00 2001 From: Bruno Gaspar Date: Thu, 14 May 2026 20:44:21 +0100 Subject: [PATCH] Add Bruno collection generator Generate a Bruno collection from your API Toolkit routes with `php artisan api-toolkit:bruno`. Scans routes using the existing `RouteScanner`, generates `.bru` files grouped by resource with proper HTTP methods, URL variables, and post-response scripts for auto-capturing resource IDs. Signed-off-by: Bruno Gaspar --- config/api-toolkit.php | 32 +- docs/1.index.md | 3 +- docs/3.advanced/7.bruno.md | 184 +++++ src/ApiToolkitServiceProvider.php | 2 + src/Bruno/BrunoCollectionBuilder.php | 681 ++++++++++++++++++ src/Console/GenerateBrunoCommand.php | 255 +++++++ .../Bruno/BrunoCollectionBuilderTest.php | 211 ++++++ .../Console/GenerateBrunoCommandTest.php | 249 +++++++ 8 files changed, 1615 insertions(+), 2 deletions(-) create mode 100644 docs/3.advanced/7.bruno.md create mode 100644 src/Bruno/BrunoCollectionBuilder.php create mode 100644 src/Console/GenerateBrunoCommand.php create mode 100644 tests/Feature/Bruno/BrunoCollectionBuilderTest.php create mode 100644 tests/Feature/Console/GenerateBrunoCommandTest.php diff --git a/config/api-toolkit.php b/config/api-toolkit.php index 41e3bb1..0077822 100644 --- a/config/api-toolkit.php +++ b/config/api-toolkit.php @@ -47,7 +47,9 @@ | OpenAPI |-------------------------------------------------------------------------- | - | Configuration for the OpenAPI spec generator. + | Configuration for the OpenAPI 3.1 spec generator. + | https://www.openapis.org/ + | | Run: php artisan api-toolkit:openapi | */ @@ -78,4 +80,32 @@ // ], ], + /* + |-------------------------------------------------------------------------- + | Bruno + |-------------------------------------------------------------------------- + | + | Configuration for the Bruno API client collection generator. + | https://www.usebruno.com/ + | + | Run: php artisan api-toolkit:bruno + | + */ + + 'bruno' => [ + 'name' => env('APP_NAME', 'API'), + 'output' => base_path('bruno'), + 'base_url' => '{{host}}', + + // Define multiple collections for versioned APIs. + // When set, the generator creates a separate Bruno collection per entry. + 'collections' => [ + // 'v1' => [ + // 'name' => 'API v1', + // 'prefix' => 'v1', + // 'output' => app_path('V1/Bruno'), + // ], + ], + ], + ]; diff --git a/docs/1.index.md b/docs/1.index.md index 7b8822a..88b98fa 100644 --- a/docs/1.index.md +++ b/docs/1.index.md @@ -17,8 +17,9 @@ API Toolkit is a Laravel package that gives you everything you need to build [JS - **Pagination** - Offset and cursor-based pagination with configurable page sizes - **Error Handling** - Automatic exception rendering in JSON:API format, including validation errors with field pointers - **OpenAPI Generation** - Auto-generate OpenAPI 3.1 specs from your routes and resources +- **Bruno Collection** - Generate Bruno API client collections with pre-filled request bodies - **Testing Utilities** - Chainable assertions purpose-built for JSON:API responses -- **Middleware** - Force JSON:API content type on all responses +- **Middleware** - Force JSON:API content type and ETag caching on responses ## Why API Toolkit? diff --git a/docs/3.advanced/7.bruno.md b/docs/3.advanced/7.bruno.md new file mode 100644 index 0000000..343121f --- /dev/null +++ b/docs/3.advanced/7.bruno.md @@ -0,0 +1,184 @@ +--- +title: Bruno Collection +description: Generate Bruno API client collections from your routes. +--- + +# Bruno Collection + +The toolkit can generate a [Bruno](https://www.usebruno.com/) collection from your API routes. Bruno is a fast, open-source API client that stores collections as plain files, making them easy to version control alongside your code. + +## Generating the Collection + +```bash +php artisan api-toolkit:bruno +``` + +This scans your routes, detects endpoints that use API Toolkit resources, and generates `.bru` files organized by resource. + +### Options + +```bash +# Custom output directory +php artisan api-toolkit:bruno --output=app/V1/Bruno +``` + +## Generated Structure + +``` +bruno/ + bruno.json # Collection metadata + collection.bru # Default bearer auth + environments/ + Local.bru # Host and token variables + Products/ + List.bru # GET /products + Create.bru # POST /products + View.bru # GET /products/{product} + Update.bru # PUT/POST /products/{product} + Delete.bru # DELETE /products/{product} + Orders/ + Items/ # Nested resources get subfolders + List.bru # GET /orders/{order}/items + Create.bru # POST /orders/{order}/items +``` + +### What Gets Generated + +- **Request files** - One `.bru` file per HTTP method per resource, with the correct URL and method +- **URL variables** - Route parameters are converted to Bruno environment variables (e.g. `{{productId}}`) +- **Post-response scripts** - List and Create endpoints automatically capture the resource ID into an environment variable for use in subsequent requests +- **Pre-request scripts** - POST/PUT/PATCH endpoints include a `req.setBody()` script with fields from your FormRequest rules +- **Environment files** - A `Local.bru` file with `host` and `apiToken` variables +- **Secret variables** - All captured resource IDs are registered as `vars:secret` in environment files + +## Pre-Request Body Generation + +The generator reads your FormRequest `rules()` method to build the request body. Field values are determined by: + +1. **`@example` docblocks** - Add above a field rule to set a specific example value +2. **`_id` fields** - Automatically use `bru.getEnvVar()` to reference captured IDs +3. **Rule-based guessing** - `email` rules get `email@example.com`, `integer` gets `0`, `boolean` gets `true` +4. **Fallback** - Empty string `''` + +### Using @example Docblocks + +Add `@example` annotations above your form request rules to provide meaningful example values: + +```php +use BlueBeetle\ApiToolkit\Http\Requests\FormRequest; + +class CreateProductRequest extends FormRequest +{ + public function rules(): array + { + return [ + /** + * @example Widget Pro + */ + 'name' => ['required', 'string', 'min:3'], + /** + * @example PRD-001 + */ + 'code' => ['required', 'string'], + 'category_id' => ['required', 'exists:categories,id'], + /** + * @example 2999 + */ + 'price_in_cents' => ['required', 'integer', 'min:0'], + ]; + } +} +``` + +This generates: + +```javascript +script:pre-request { + req.setBody({ + name: 'Widget Pro', + code: 'PRD-001', + category_id: bru.getEnvVar('categoryId'), + price_in_cents: '2999', + }); +} +``` + +### Nested Fields + +Dot-notation fields in your rules (e.g. `billing.currency_id`) are automatically grouped into nested objects: + +```javascript +req.setBody({ + billing: { + currency_id: bru.getEnvVar('currencyId'), + payment_days: 'immediately', + }, +}); +``` + +## Action Routes + +Routes with action segments like `/restore`, `/archive`, or `/approve` are detected automatically and generate their own `.bru` files: + +``` +Products/ + Restore.bru # POST /products/{product}/restore + Archive.bru # POST /products/{product}/archive +``` + +## Configuration + +In `config/api-toolkit.php`: + +```php +'bruno' => [ + 'name' => env('APP_NAME', 'API'), + 'output' => base_path('bruno'), + 'base_url' => '{{host}}', +], +``` + +| Option | Default | Description | +|---|---|---| +| `name` | `APP_NAME` | Collection name in `bruno.json` | +| `output` | `base_path('bruno')` | Output directory | +| `base_url` | `{{host}}` | Base URL prepended to all request paths | + +## Versioned APIs + +For APIs with multiple versions, define collections per version: + +```php +'bruno' => [ + 'name' => env('APP_NAME', 'API'), + 'base_url' => '{{host}}', + 'collections' => [ + 'v1' => [ + 'name' => 'API v1', + 'prefix' => 'v1', + 'output' => app_path('V1/Bruno'), + ], + 'v2' => [ + 'name' => 'API v2', + 'prefix' => 'v2', + 'output' => app_path('V2/Bruno'), + ], + ], +], +``` + +Each collection gets its own `bruno.json`, `collection.bru`, environment files, and resource folders. The `prefix` matches against the route URL path to filter endpoints per collection. + +```bash +php artisan api-toolkit:bruno # generates all configured collections +``` + +The `--output` flag overrides the multi-collection config and generates a single collection. + +## Regeneration + +Running the command again: + +- **Deletes and recreates** all resource folders (removes stale endpoints) +- **Preserves** `bruno.json`, `collection.bru`, and environment files +- **Updates** the `vars:secret` block in all environment files with current resource IDs diff --git a/src/ApiToolkitServiceProvider.php b/src/ApiToolkitServiceProvider.php index 7b79f5f..a5fb34f 100644 --- a/src/ApiToolkitServiceProvider.php +++ b/src/ApiToolkitServiceProvider.php @@ -4,6 +4,7 @@ namespace BlueBeetle\ApiToolkit; +use BlueBeetle\ApiToolkit\Console\GenerateBrunoCommand; use BlueBeetle\ApiToolkit\Console\GenerateOpenApiCommand; use BlueBeetle\ApiToolkit\Console\MakeResourceCommand; use BlueBeetle\ApiToolkit\Http\Response; @@ -36,6 +37,7 @@ public function boot(): void { if ($this->app->runningInConsole()) { $this->commands([ + GenerateBrunoCommand::class, GenerateOpenApiCommand::class, MakeResourceCommand::class, ]); diff --git a/src/Bruno/BrunoCollectionBuilder.php b/src/Bruno/BrunoCollectionBuilder.php new file mode 100644 index 0000000..8d0ca9e --- /dev/null +++ b/src/Bruno/BrunoCollectionBuilder.php @@ -0,0 +1,681 @@ +schemaBuilder = new SchemaBuilder(); + } + + public function buildCollectionJson(): string + { + return json_encode([ + 'version' => '1', + 'name' => $this->name, + 'type' => 'collection', + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n"; + } + + public function buildCollectionBru(): string + { + return <<<'BRU' + auth { + mode: bearer + } + + auth:bearer { + token: {{apiToken}} + } + + BRU; + } + + /** + * @param list $secretVars + */ + public function buildEnvironmentBru(string $name, string $host, string $token = '', array $secretVars = []): string + { + $bru = <<buildSecretVarsBlock($secretVars); + } + + return $bru."\n"; + } + + /** + * @param list $endpoints + * + * @return list + */ + public function resolveSecretVars(array $endpoints): array + { + $vars = []; + + foreach ($endpoints as $endpoint) { + $resourceName = $this->schemaBuilder->schemaName($endpoint->resourceClass); + $vars[] = Str::camel($resourceName).'Id'; + } + + $vars = array_unique($vars); + sort($vars); + + return array_values($vars); + } + + /** + * @param list $vars + */ + public function buildSecretVarsBlock(array $vars): string + { + $items = implode(",\n", array_map(fn (string $v): string => " {$v}", $vars)); + + return "vars:secret [\n{$items}\n]\n"; + } + + /** + * @param list $endpoints + * + * @return array> Keyed by folder name, then filename => content + */ + public function buildEndpoints(array $endpoints): array + { + $folders = []; + + foreach ($endpoints as $endpoint) { + $folderName = $this->resolveFolderName($endpoint); + + foreach ($endpoint->httpMethods as $httpMethod) { + $method = mb_strtoupper($httpMethod); + $fileName = $this->resolveFileName($endpoint, $method); + $seq = $this->resolveSequence($endpoint, $method); + $content = $this->buildRequestBru($endpoint, $method, $seq); + + $folders[$folderName][$fileName] = $content; + } + } + + return $folders; + } + + private function buildRequestBru(EndpointDefinition $endpoint, string $method, int $seq): string + { + $name = $this->resolveName($endpoint, $method); + $url = $this->resolveUrl($endpoint, $method); + $methodLower = mb_strtolower($method); + + $bru = <<buildPreRequestScript($endpoint, $method); + + if ($preRequest !== null) { + $bru .= "\n\n".$preRequest; + } + + $postResponse = $this->buildPostResponseScript($endpoint, $method); + + if ($postResponse !== null) { + $bru .= "\n\n".$postResponse; + } + + return $bru."\n"; + } + + private function buildPreRequestScript(EndpointDefinition $endpoint, string $method): string | null + { + if (! in_array($method, ['POST', 'PUT', 'PATCH'], true)) { + return null; + } + + if ($endpoint->formRequestClass === null) { + return null; + } + + $fields = $this->extractFieldsFromFormRequest($endpoint->formRequestClass); + + if ($fields === []) { + return null; + } + + $body = $this->buildJsObject($fields, 2); + + return << + */ + private function extractFieldsFromFormRequest(string $formRequestClass): array + { + if (! method_exists($formRequestClass, 'rules')) { + return []; + } + + try { + $request = new $formRequestClass(); + $rules = $request->rules(); + } catch (Throwable) { + // If rules() requires request context (e.g. $this->company()), + // fall back to reading field names via reflection + $rules = $this->extractRuleKeysViaReflection($formRequestClass); + } + + if ($rules === []) { + return []; + } + + $examples = $this->extractExamplesFromSource($formRequestClass); + + // Separate top-level and nested fields + $topLevel = []; + $nested = []; + + foreach ($rules as $field => $fieldRules) { + // Skip wildcard rules like 'items.*' + if (str_contains($field, '*')) { + continue; + } + + $ruleList = is_array($fieldRules) ? $fieldRules : explode('|', $fieldRules); + + if (str_contains($field, '.')) { + $parts = explode('.', $field); + $parent = $parts[0]; + $child = implode('.', array_slice($parts, 1)); + $nested[$parent][$child] = isset($examples[$field]) + ? "'".$examples[$field]."'" + : $this->guessPlaceholder($child, $ruleList); + } else { + $topLevel[$field] = isset($examples[$field]) + ? "'".$examples[$field]."'" + : $this->guessPlaceholder($field, $ruleList); + } + } + + // Replace top-level fields that have nested children with the nested structure + foreach ($nested as $parent => $children) { + $topLevel[$parent] = $children; + } + + return $topLevel; + } + + /** + * Extract @example values from docblocks above rule definitions. + * + * @return array + */ + private function extractExamplesFromSource(string $formRequestClass): array + { + try { + $reflection = new ReflectionMethod($formRequestClass, 'rules'); + $filename = $reflection->getFileName(); + + if ($filename === false || ! file_exists($filename)) { + return []; + } + + $lines = file($filename); + + if ($lines === false) { + return []; + } + + $methodLines = array_slice( + $lines, + $reflection->getStartLine() - 1, + $reflection->getEndLine() - $reflection->getStartLine() + 1, + ); + + $examples = []; + $pendingExample = null; + + foreach ($methodLines as $line) { + $trimmed = mb_trim($line); + + // Capture @example value from docblock + if (preg_match('/@example\s+(.+)$/', $trimmed, $match)) { + $pendingExample = mb_trim($match[1]); + + continue; + } + + // If we have a pending example, attach it to the next field + if ($pendingExample !== null && preg_match("/['\"]([a-zA-Z_][a-zA-Z0-9_.]*)['\"]\\s*=>/", $trimmed, $match)) { + $examples[$match[1]] = $pendingExample; + $pendingExample = null; + + continue; + } + + // Reset pending if we hit a non-comment, non-field line + if ($pendingExample !== null && $trimmed !== '' && ! str_starts_with($trimmed, '*') && ! str_starts_with($trimmed, '//') && ! str_starts_with($trimmed, '/**')) { + $pendingExample = null; + } + } + + return $examples; + } catch (Throwable) { + return []; + } + } + + /** + * @return array> + */ + private function extractRuleKeysViaReflection(string $formRequestClass): array + { + try { + $reflection = new ReflectionMethod($formRequestClass, 'rules'); + $filename = $reflection->getFileName(); + + if ($filename === false || ! file_exists($filename)) { + return []; + } + + $lines = file($filename); + + if ($lines === false) { + return []; + } + + $source = implode('', array_slice( + $lines, + $reflection->getStartLine() - 1, + $reflection->getEndLine() - $reflection->getStartLine() + 1, + )); + + // Extract field names from patterns like 'field_name' => [...] + preg_match_all("/['\"]([a-zA-Z_][a-zA-Z0-9_.]*)['\"]\\s*=>/", $source, $matches); + + $rules = []; + + foreach ($matches[1] as $field) { + $rules[$field] = []; + } + + return $rules; + } catch (Throwable) { + return []; + } + } + + /** + * @param list $rules + */ + private function guessPlaceholder(string $field, array $rules): string + { + foreach ($rules as $rule) { + if (! is_string($rule)) { + continue; + } + + if ($rule === 'integer' || $rule === 'numeric') { + return '0'; + } + + if ($rule === 'boolean') { + return 'true'; + } + + if ($rule === 'email') { + return "'email@example.com'"; + } + } + + if (str_ends_with($field, '_id')) { + $varName = Str::camel(str_replace('_id', '', $field)).'Id'; + + return "bru.getEnvVar('{$varName}')"; + } + + return "''"; + } + + private function buildJsObject(array $fields, int $indent): string + { + if ($fields === []) { + return '{}'; + } + + $pad = str_repeat(' ', $indent); + $lines = []; + + foreach ($fields as $key => $value) { + if (is_array($value)) { + $nested = $this->buildJsObject($value, $indent + 1); + $lines[] = "{$pad}{$key}: {$nested},"; + } else { + $lines[] = "{$pad}{$key}: {$value},"; + } + } + + $innerPad = str_repeat(' ', $indent - 1); + + return "{\n".implode("\n", $lines)."\n{$innerPad}}"; + } + + private function buildPostResponseScript(EndpointDefinition $endpoint, string $method): string | null + { + $varName = $this->resolveVarName($endpoint); + + if ($method === 'GET' && $endpoint->isList) { + return << 0) { + bru.setEnvVar('{$varName}', response.data[0]?.id); + } + } + BRU; + } + + if ($method === 'POST' && ! $this->isActionRoute($endpoint) && ! $this->targetsSpecificResource($endpoint)) { + return <<resolveResourceSegment($endpoint); + } + + private function resolveFileName(EndpointDefinition $endpoint, string $method): string + { + $action = $this->resolveActionSegment($endpoint); + + if ($action !== null) { + return Str::studly($action); + } + + $isPostUpdate = $method === 'POST' && $this->targetsSpecificResource($endpoint); + + return match (true) { + $method === 'GET' && $endpoint->isList => 'List', + $method === 'GET' => 'View', + $isPostUpdate => 'Update', + $method === 'POST' => 'Create', + $method === 'PUT', $method === 'PATCH' => 'Update', + $method === 'DELETE' => 'Delete', + default => $method, + }; + } + + private function resolveName(EndpointDefinition $endpoint, string $method): string + { + $folderName = $this->resolveFolderName($endpoint); + // Use the last segment for naming (e.g. "Clients/Addresses" -> "Addresses") + $lastSegment = str_contains($folderName, '/') ? Str::afterLast($folderName, '/') : $folderName; + $singular = Str::singular($lastSegment); + $plural = $lastSegment; + + $action = $this->resolveActionSegment($endpoint); + + if ($action !== null) { + return Str::headline($action).' '.$singular; + } + + $isPostUpdate = $method === 'POST' && $this->targetsSpecificResource($endpoint); + + return match (true) { + $method === 'GET' && $endpoint->isList => "List {$plural}", + $method === 'GET' => "View {$singular}", + $isPostUpdate => "Update {$singular}", + $method === 'POST' => "Create {$singular}", + $method === 'PUT', $method === 'PATCH' => "Update {$singular}", + $method === 'DELETE' => "Delete {$singular}", + default => "{$method} {$singular}", + }; + } + + private function resolveUrl(EndpointDefinition $endpoint, string $method): string + { + $path = $endpoint->path; + $varName = $this->resolveVarName($endpoint); + + // Strip route binding fields (e.g. {brand:public_id} -> {brand}) + $path = preg_replace('/\{(\w+):[^}]+\}/', '{$1}', $path); + + // Replace route params with the resource-derived env variable name + // e.g. {product} -> {{productId}} (matching the post-response script convention) + $path = preg_replace('/\{(\w+)\}/', '{{'.$varName.'}}', $path); + + // POST (create) should not have path params, but updates and action routes should keep them + $isPostCreate = $method === 'POST' && ! $this->targetsSpecificResource($endpoint) && ! $this->isActionRoute($endpoint); + + if ($isPostCreate && preg_match('/\/\{\{[^}]+\}\}$/', $path)) { + $path = preg_replace('/\/\{\{[^}]+\}\}$/', '', $path); + } + + return $this->baseUrl.$path; + } + + private function resolveSequence(EndpointDefinition $endpoint, string $method): int + { + if ($this->resolveActionSegment($endpoint) !== null) { + return 6; + } + + $isPostUpdate = $method === 'POST' && $this->targetsSpecificResource($endpoint); + + return match (true) { + $method === 'GET' && $endpoint->isList => 1, + $method === 'POST' && ! $isPostUpdate => 2, + $method === 'GET' => 3, + $isPostUpdate, $method === 'PUT', $method === 'PATCH' => 4, + $method === 'DELETE' => 5, + default => 7, + }; + } + + /** + * Extract the resource segment(s) from the path. + * + * /products -> Products + * /products/{product} -> Products + * /products/{product}/restore -> Products + * /orders/{order}/items -> Orders/Items + * /api/v1/shipping-methods -> Shipping Methods + */ + private function resolveResourceSegment(EndpointDefinition $endpoint): string + { + $segments = $this->pathSegments($endpoint); + $skip = ['api', 'api-']; + $action = $this->resolveActionSegment($endpoint); + $resourceParts = []; + + foreach ($segments as $segment) { + if (in_array($segment, $skip, true) || preg_match('/^v\d+$/', $segment)) { + continue; + } + + if (str_starts_with($segment, '{')) { + continue; + } + + // Skip the action segment (e.g. "restore") + if ($action !== null && $segment === $action) { + continue; + } + + $resourceParts[] = Str::headline(Str::plural(str_replace('-', ' ', $segment))); + } + + if ($resourceParts !== []) { + return implode('/', $resourceParts); + } + + // Fallback to resource class name + $resourceName = $this->schemaBuilder->schemaName($endpoint->resourceClass); + + return Str::plural(Str::headline($resourceName)); + } + + /** + * Detect action segments like "restore" at the end of a path. + * + * /products/{product}/restore -> restore + * /products/{product} -> null + * /products -> null + */ + private function resolveActionSegment(EndpointDefinition $endpoint): string | null + { + // List endpoints never have action segments + if ($endpoint->isList) { + return null; + } + + $segments = $this->pathSegments($endpoint); + $last = end($segments); + + if ($last === false || str_starts_with($last, '{') || preg_match('/^v\d+$/', $last)) { + return null; + } + + // A plural last segment after a param is a nested resource (e.g. /addresses), + // not an action. Only singular words are actions (e.g. /restore, /archive). + if (Str::plural($last) === $last) { + return null; + } + + $count = count($segments); + + if ($count < 2) { + return null; + } + + $beforeLast = $segments[$count - 2]; + + if (str_starts_with($beforeLast, '{')) { + return $last; + } + + return null; + } + + /** + * Check if this is an action route (e.g. /restore, /archive) rather than a CRUD route. + */ + private function isActionRoute(EndpointDefinition $endpoint): bool + { + return $this->resolveActionSegment($endpoint) !== null; + } + + /** + * Check if the route targets a specific resource (last meaningful segment is a param). + * + * /products/{product} -> true (targets a specific resource) + * /products -> false (targets the collection) + * /orders/{order}/items -> false (targets a nested collection) + * /orders/{order}/items/{item} -> true (targets a specific nested resource) + */ + private function targetsSpecificResource(EndpointDefinition $endpoint): bool + { + $segments = $this->pathSegments($endpoint); + $last = end($segments); + + if ($last === false) { + return false; + } + + // If the last segment is a param, it targets a specific resource + // If it's an action (like "restore"), check the segment before it + if ($this->isActionRoute($endpoint)) { + $count = count($segments); + + return $count >= 2 && str_starts_with($segments[$count - 2], '{'); + } + + return str_starts_with($last, '{'); + } + + /** + * Resolve the env variable name from the path (uses last resource segment). + * + * /products -> productId + * /shipping-methods -> shippingMethodId + * /orders/{order}/items -> itemId + */ + private function resolveVarName(EndpointDefinition $endpoint): string + { + $segments = $this->pathSegments($endpoint); + $skip = ['api', 'api-']; + $action = $this->resolveActionSegment($endpoint); + $lastResource = null; + + foreach ($segments as $segment) { + if (in_array($segment, $skip, true) || preg_match('/^v\d+$/', $segment) || str_starts_with($segment, '{')) { + continue; + } + + if ($action !== null && $segment === $action) { + continue; + } + + $lastResource = $segment; + } + + if ($lastResource !== null) { + return Str::camel(Str::singular($lastResource)).'Id'; + } + + $resourceName = $this->schemaBuilder->schemaName($endpoint->resourceClass); + + return Str::camel($resourceName).'Id'; + } + + /** + * @return list + */ + private function pathSegments(EndpointDefinition $endpoint): array + { + return array_values(array_filter(explode('/', $endpoint->path))); + } +} diff --git a/src/Console/GenerateBrunoCommand.php b/src/Console/GenerateBrunoCommand.php new file mode 100644 index 0000000..7bf9a73 --- /dev/null +++ b/src/Console/GenerateBrunoCommand.php @@ -0,0 +1,255 @@ +scan(); + + if ($endpoints === []) { + $this->warn('No API Toolkit endpoints found.'); + + return self::SUCCESS; + } + + $this->info(sprintf('Found %d endpoint(s).', count($endpoints))); + $this->newLine(); + + $brunoConfig = $config->get('api-toolkit.bruno', []); + + if ($this->option('output')) { + return $this->generateSingleCollection($endpoints, $brunoConfig); + } + + $collections = $brunoConfig['collections'] ?? []; + + if ($collections !== []) { + return $this->generateMultipleCollections($endpoints, $collections, $brunoConfig); + } + + return $this->generateSingleCollection($endpoints, $brunoConfig); + } + + /** + * @param list $endpoints + * @param array $brunoConfig + */ + private function generateSingleCollection(array $endpoints, array $brunoConfig): int + { + $outputDir = $this->option('output') + ?? $brunoConfig['output'] + ?? base_path('bruno'); + + $collectionName = $brunoConfig['name'] ?? config('app.name', 'API'); + $baseUrl = $brunoConfig['base_url'] ?? '{{host}}'; + + $this->writeCollection($endpoints, $outputDir, $collectionName, $baseUrl); + + return self::SUCCESS; + } + + /** + * @param list $endpoints + * @param array> $collections + * @param array $brunoConfig + */ + private function generateMultipleCollections(array $endpoints, array $collections, array $brunoConfig): int + { + $baseUrl = $brunoConfig['base_url'] ?? '{{host}}'; + + foreach ($collections as $key => $collectionConfig) { + $prefix = $collectionConfig['prefix'] ?? $key; + $outputDir = $collectionConfig['output'] ?? base_path('bruno/'.$key); + $collectionName = $collectionConfig['name'] ?? $brunoConfig['name'] ?? config('app.name', 'API'); + + $filtered = $this->filterByPrefix($endpoints, $prefix); + + if ($filtered === []) { + $this->warn("No endpoints found for prefix [{$prefix}], skipping."); + + continue; + } + + $this->writeCollection($filtered, $outputDir, $collectionName, $baseUrl); + } + + return self::SUCCESS; + } + + /** + * @param list $endpoints + */ + private function writeCollection(array $endpoints, string $outputDir, string $name, string $baseUrl): void + { + $builder = new BrunoCollectionBuilder(name: $name, baseUrl: $baseUrl); + + if (! is_dir($outputDir)) { + mkdir($outputDir, 0755, true); + } + + // Only write config files if they don't exist yet (preserve user customizations) + if (! file_exists($outputDir.'/bruno.json')) { + file_put_contents($outputDir.'/bruno.json', $builder->buildCollectionJson()); + } + + if (! file_exists($outputDir.'/collection.bru')) { + file_put_contents($outputDir.'/collection.bru', $builder->buildCollectionBru()); + } + + $envDir = $outputDir.'/environments'; + + if (! is_dir($envDir)) { + mkdir($envDir, 0755, true); + } + + $secretVars = $builder->resolveSecretVars($endpoints); + + $this->updateEnvironmentFiles($envDir, $builder, $secretVars); + + $folders = $builder->buildEndpoints($endpoints); + + // Clean stale resource folders before regenerating + $this->cleanResourceFolders($outputDir, array_keys($folders)); + + $fileCount = 0; + + foreach ($folders as $folderName => $files) { + $folderPath = $outputDir.'/'.$folderName; + + if (! is_dir($folderPath)) { + mkdir($folderPath, 0755, true); + } + + foreach ($files as $fileName => $content) { + file_put_contents($folderPath.'/'.$fileName.'.bru', $content); + $fileCount++; + } + } + + $this->info("Bruno collection written to {$outputDir} ({$fileCount} requests)"); + } + + /** + * @param list $endpoints + * + * @return list + */ + /** + * @param list $secretVars + */ + private function updateEnvironmentFiles(string $envDir, BrunoCollectionBuilder $builder, array $secretVars): void + { + $localEnv = $envDir.'/Local.bru'; + + if (! file_exists($localEnv)) { + file_put_contents( + $localEnv, + $builder->buildEnvironmentBru('Local', config('app.url', 'http://localhost'), '', $secretVars), + ); + + return; + } + + // Update vars:secret block in all existing environment files + $envFiles = glob($envDir.'/*.bru'); + + if ($envFiles === false) { + return; + } + + $secretBlock = $builder->buildSecretVarsBlock($secretVars); + + foreach ($envFiles as $envFile) { + $content = file_get_contents($envFile); + + if ($content === false) { + continue; + } + + // Replace existing vars:secret block or append it + if (preg_match('/vars:secret\s*\[.*?\]\s*/s', $content)) { + $content = preg_replace('/vars:secret\s*\[.*?\]\s*/s', $secretBlock, $content); + } else { + $content = mb_rtrim($content)."\n".$secretBlock; + } + + file_put_contents($envFile, $content); + } + } + + private function filterByPrefix(array $endpoints, string $prefix): array + { + $prefix = '/'.mb_ltrim($prefix, '/'); + + return array_values( + array_filter($endpoints, fn (EndpointDefinition $e): bool => str_contains($e->path, $prefix)), + ); + } + + /** + * Remove resource folders that are not in the current generation, + * preserving bruno.json, collection.bru, and environments/. + * + * @param list $currentFolders + */ + private function cleanResourceFolders(string $outputDir, array $currentFolders): void + { + if (! is_dir($outputDir)) { + return; + } + + $preserved = ['environments', 'bruno.json', 'collection.bru']; + $entries = scandir($outputDir); + + if ($entries === false) { + return; + } + + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + if (in_array($entry, $preserved, true)) { + continue; + } + + $path = $outputDir.'/'.$entry; + + if (is_dir($path)) { + $this->deleteDirectory($path); + } + } + } + + private function deleteDirectory(string $dir): void + { + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($files as $file) { + $file->isDir() ? rmdir($file->getRealPath()) : unlink($file->getRealPath()); + } + + rmdir($dir); + } +} diff --git a/tests/Feature/Bruno/BrunoCollectionBuilderTest.php b/tests/Feature/Bruno/BrunoCollectionBuilderTest.php new file mode 100644 index 0000000..8fd10d4 --- /dev/null +++ b/tests/Feature/Bruno/BrunoCollectionBuilderTest.php @@ -0,0 +1,211 @@ +buildCollectionJson(), true); + + expect($json['version'])->toBe('1'); + expect($json['name'])->toBe('Test API'); + expect($json['type'])->toBe('collection'); +}); + +it('builds collection bru with bearer auth', function () { + $builder = new BrunoCollectionBuilder(name: 'Test API'); + + $content = $builder->buildCollectionBru(); + + expect($content)->toContain('mode: bearer'); + expect($content)->toContain('token: {{apiToken}}'); +}); + +it('builds environment bru', function () { + $builder = new BrunoCollectionBuilder(name: 'Test API'); + + $content = $builder->buildEnvironmentBru('Local', 'http://localhost', 'sk_test_123'); + + expect($content)->toContain('host: http://localhost'); + expect($content)->toContain('apiToken: sk_test_123'); +}); + +it('builds endpoint files grouped by resource', function () { + $builder = new BrunoCollectionBuilder(name: 'Test API'); + + $endpoints = [ + new EndpointDefinition( + path: '/api/v1/products', + httpMethods: ['GET'], + resourceClass: ProductResource::class, + isList: true, + controllerClass: 'App\Controllers\ProductController', + methodName: 'index', + formRequestClass: null, + routeName: 'api.v1.products.index', + ), + new EndpointDefinition( + path: '/api/v1/products/{product}', + httpMethods: ['GET'], + resourceClass: ProductResource::class, + isList: false, + controllerClass: 'App\Controllers\ProductController', + methodName: 'show', + formRequestClass: null, + routeName: 'api.v1.products.show', + ), + ]; + + $folders = $builder->buildEndpoints($endpoints); + + expect($folders)->toHaveKey('Products'); + expect($folders['Products'])->toHaveKey('List'); + expect($folders['Products'])->toHaveKey('View'); +}); + +it('converts route params to bruno variables', function () { + $builder = new BrunoCollectionBuilder(name: 'Test API'); + + $endpoints = [ + new EndpointDefinition( + path: '/api/v1/products/{product}', + httpMethods: ['GET'], + resourceClass: ProductResource::class, + isList: false, + controllerClass: 'App\Controllers\ProductController', + methodName: 'show', + formRequestClass: null, + routeName: null, + ), + ]; + + $folders = $builder->buildEndpoints($endpoints); + + expect($folders['Products']['View'])->toContain('{{productId}}'); +}); + +it('generates correct method names', function () { + $builder = new BrunoCollectionBuilder(name: 'Test API'); + + $endpoints = [ + new EndpointDefinition( + path: '/api/v1/products', + httpMethods: ['GET', 'POST'], + resourceClass: ProductResource::class, + isList: true, + controllerClass: 'App\Controllers\ProductController', + methodName: 'index', + formRequestClass: null, + routeName: null, + ), + new EndpointDefinition( + path: '/api/v1/products/{product}', + httpMethods: ['PUT', 'DELETE'], + resourceClass: ProductResource::class, + isList: false, + controllerClass: 'App\Controllers\ProductController', + methodName: 'update', + formRequestClass: null, + routeName: null, + ), + ]; + + $folders = $builder->buildEndpoints($endpoints); + + expect($folders['Products'])->toHaveKey('List'); + expect($folders['Products'])->toHaveKey('Create'); + expect($folders['Products'])->toHaveKey('Update'); + expect($folders['Products'])->toHaveKey('Delete'); +}); + +it('adds post-response script for list endpoints', function () { + $builder = new BrunoCollectionBuilder(name: 'Test API'); + + $endpoints = [ + new EndpointDefinition( + path: '/api/v1/products', + httpMethods: ['GET'], + resourceClass: ProductResource::class, + isList: true, + controllerClass: 'App\Controllers\ProductController', + methodName: 'index', + formRequestClass: null, + routeName: null, + ), + ]; + + $folders = $builder->buildEndpoints($endpoints); + + expect($folders['Products']['List'])->toContain('script:post-response'); + expect($folders['Products']['List'])->toContain('productId'); +}); + +it('adds post-response script for create endpoints', function () { + $builder = new BrunoCollectionBuilder(name: 'Test API'); + + $endpoints = [ + new EndpointDefinition( + path: '/api/v1/products', + httpMethods: ['POST'], + resourceClass: ProductResource::class, + isList: false, + controllerClass: 'App\Controllers\ProductController', + methodName: 'store', + formRequestClass: null, + routeName: null, + ), + ]; + + $folders = $builder->buildEndpoints($endpoints); + + expect($folders['Products']['Create'])->toContain('script:post-response'); + expect($folders['Products']['Create'])->toContain('productId'); +}); + +it('does not add post-response script for view endpoints', function () { + $builder = new BrunoCollectionBuilder(name: 'Test API'); + + $endpoints = [ + new EndpointDefinition( + path: '/api/v1/products/{product}', + httpMethods: ['GET'], + resourceClass: ProductResource::class, + isList: false, + controllerClass: 'App\Controllers\ProductController', + methodName: 'show', + formRequestClass: null, + routeName: null, + ), + ]; + + $folders = $builder->buildEndpoints($endpoints); + + expect($folders['Products']['View'])->not->toContain('script:post-response'); +}); + +it('uses custom base url', function () { + $builder = new BrunoCollectionBuilder(name: 'Test API', baseUrl: 'https://api.example.com'); + + $endpoints = [ + new EndpointDefinition( + path: '/api/v1/products', + httpMethods: ['GET'], + resourceClass: ProductResource::class, + isList: true, + controllerClass: 'App\Controllers\ProductController', + methodName: 'index', + formRequestClass: null, + routeName: null, + ), + ]; + + $folders = $builder->buildEndpoints($endpoints); + + expect($folders['Products']['List'])->toContain('https://api.example.com/api/v1/products'); +}); diff --git a/tests/Feature/Console/GenerateBrunoCommandTest.php b/tests/Feature/Console/GenerateBrunoCommandTest.php new file mode 100644 index 0000000..ac36ec4 --- /dev/null +++ b/tests/Feature/Console/GenerateBrunoCommandTest.php @@ -0,0 +1,249 @@ +isDir() ? rmdir($file->getRealPath()) : unlink($file->getRealPath()); + } + + rmdir($dir); + } +}); + +it('warns when no endpoints are found', function () { + $this->artisan('api-toolkit:bruno', ['--output' => base_path('bruno-test')]) + ->expectsOutputToContain('No API Toolkit endpoints found') + ->assertSuccessful() + ; +}); + +it('generates bruno collection files', function () { + registerBrunoRoutes(); + + $this->artisan('api-toolkit:bruno', ['--output' => base_path('bruno-test')]) + ->expectsOutputToContain('endpoint(s)') + ->expectsOutputToContain('Bruno collection written to') + ->assertSuccessful() + ; + + $dir = base_path('bruno-test'); + + expect($dir.'/bruno.json')->toBeFile(); + expect($dir.'/collection.bru')->toBeFile(); + expect($dir.'/environments/Local.bru')->toBeFile(); + + $json = json_decode(file_get_contents($dir.'/bruno.json'), true); + expect($json['version'])->toBe('1'); + expect($json['type'])->toBe('collection'); +}); + +it('generates request files per endpoint', function () { + registerBrunoRoutes(); + + $this->artisan('api-toolkit:bruno', ['--output' => base_path('bruno-test')]) + ->assertSuccessful() + ; + + $dir = base_path('bruno-test'); + + expect($dir.'/Products/List.bru')->toBeFile(); + expect($dir.'/Products/View.bru')->toBeFile(); +}); + +it('generates valid bru file content for list endpoint', function () { + registerBrunoRoutes(); + + $this->artisan('api-toolkit:bruno', ['--output' => base_path('bruno-test')]) + ->assertSuccessful() + ; + + $content = file_get_contents(base_path('bruno-test/Products/List.bru')); + + expect($content)->toContain('meta {'); + expect($content)->toContain('name: List Products'); + expect($content)->toContain('type: http'); + expect($content)->toContain('get {'); + expect($content)->toContain('/api/v1/products'); + expect($content)->toContain('auth: inherit'); +}); + +it('generates valid bru file content for view endpoint', function () { + registerBrunoRoutes(); + + $this->artisan('api-toolkit:bruno', ['--output' => base_path('bruno-test')]) + ->assertSuccessful() + ; + + $content = file_get_contents(base_path('bruno-test/Products/View.bru')); + + expect($content)->toContain('name: View Product'); + expect($content)->toContain('get {'); + expect($content)->toContain('{{productId}}'); +}); + +it('generates collection.bru with bearer auth', function () { + registerBrunoRoutes(); + + $this->artisan('api-toolkit:bruno', ['--output' => base_path('bruno-test')]) + ->assertSuccessful() + ; + + $content = file_get_contents(base_path('bruno-test/collection.bru')); + + expect($content)->toContain('mode: bearer'); + expect($content)->toContain('token: {{apiToken}}'); +}); + +it('generates environment file with host variable', function () { + registerBrunoRoutes(); + + $this->artisan('api-toolkit:bruno', ['--output' => base_path('bruno-test')]) + ->assertSuccessful() + ; + + $content = file_get_contents(base_path('bruno-test/environments/Local.bru')); + + expect($content)->toContain('host:'); + expect($content)->toContain('apiToken:'); +}); + +it('adds post-response script to list endpoints', function () { + registerBrunoRoutes(); + + $this->artisan('api-toolkit:bruno', ['--output' => base_path('bruno-test')]) + ->assertSuccessful() + ; + + $content = file_get_contents(base_path('bruno-test/Products/List.bru')); + + expect($content)->toContain('script:post-response'); + expect($content)->toContain('productId'); +}); + +it('reads config for collection name', function () { + $this->app['config']->set('api-toolkit.bruno.name', 'My Custom API'); + + registerBrunoRoutes(); + + $this->artisan('api-toolkit:bruno', ['--output' => base_path('bruno-test')]) + ->assertSuccessful() + ; + + $json = json_decode(file_get_contents(base_path('bruno-test/bruno.json')), true); + expect($json['name'])->toBe('My Custom API'); +}); + +it('generates multiple collections from config', function () { + $this->app['config']->set('api-toolkit.bruno.collections', [ + 'v1' => [ + 'name' => 'API v1', + 'prefix' => 'v1', + 'output' => base_path('bruno-test/v1'), + ], + 'v2' => [ + 'name' => 'API v2', + 'prefix' => 'v2', + 'output' => base_path('bruno-test/v2'), + ], + ]); + + registerBrunoRoutes(); + registerBrunoV2Routes(); + + $this->artisan('api-toolkit:bruno') + ->expectsOutputToContain('Bruno collection written to') + ->assertSuccessful() + ; + + // V1 collection + expect(base_path('bruno-test/v1/bruno.json'))->toBeFile(); + $v1Json = json_decode(file_get_contents(base_path('bruno-test/v1/bruno.json')), true); + expect($v1Json['name'])->toBe('API v1'); + expect(base_path('bruno-test/v1/Products/List.bru'))->toBeFile(); + + // V2 collection + expect(base_path('bruno-test/v2/bruno.json'))->toBeFile(); + $v2Json = json_decode(file_get_contents(base_path('bruno-test/v2/bruno.json')), true); + expect($v2Json['name'])->toBe('API v2'); + expect(base_path('bruno-test/v2/Products/List.bru'))->toBeFile(); +}); + +it('skips collections with no matching endpoints', function () { + $this->app['config']->set('api-toolkit.bruno.collections', [ + 'v1' => [ + 'name' => 'API v1', + 'prefix' => 'v1', + 'output' => base_path('bruno-test/v1'), + ], + 'v3' => [ + 'name' => 'API v3', + 'prefix' => 'v3', + 'output' => base_path('bruno-test/v3'), + ], + ]); + + registerBrunoRoutes(); + + $this->artisan('api-toolkit:bruno') + ->expectsOutputToContain('No endpoints found for prefix') + ->assertSuccessful() + ; + + expect(base_path('bruno-test/v1/bruno.json'))->toBeFile(); + expect(base_path('bruno-test/v3'))->not->toBeDirectory(); +}); + +it('uses --output flag to override collections config', function () { + $this->app['config']->set('api-toolkit.bruno.collections', [ + 'v1' => [ + 'name' => 'API v1', + 'prefix' => 'v1', + 'output' => base_path('bruno-test/v1'), + ], + ]); + + registerBrunoRoutes(); + + $this->artisan('api-toolkit:bruno', ['--output' => base_path('bruno-test')]) + ->assertSuccessful() + ; + + // Should generate single collection at --output, not per-collection + expect(base_path('bruno-test/bruno.json'))->toBeFile(); + expect(base_path('bruno-test/v1'))->not->toBeDirectory(); +}); + +function registerBrunoRoutes(): void +{ + Route::get('/api/v1/products', [StubListController::class, '__invoke']) + ->name('api.v1.products.index') + ; + + Route::get('/api/v1/products/{product}', [StubShowController::class, '__invoke']) + ->name('api.v1.products.show') + ; +} + +function registerBrunoV2Routes(): void +{ + Route::get('/api/v2/products', [StubListController::class, '__invoke']) + ->name('api.v2.products.index') + ; +}