diff --git a/README.md b/README.md index f6bf7df..1be07cd 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,7 @@ return [ /* * The routes for which documentation should be generated. - * Each group contains rules defining which routes should be included ('match', 'include' and 'exclude' sections) + * Each group contains rules defining what routes should be included ('match', 'include' and 'exclude' sections) * and rules which should be applied to them ('apply' section). */ 'routes' => [ @@ -631,6 +631,100 @@ If you are referring to the environment setting as shown above, then you should APP_URL=http://yourapp.app ``` +## Documenting Complex Responses with @responseResource + +The `@responseResource` annotation allows you to easily document complex response structures using Laravel API Resources. This feature streamlines the process of generating comprehensive API documentation for nested and complex data structures, including automatic generation of example responses. + +### Usage + +To use the `@responseResource` annotation, add it to your controller method's PHPDoc block: + +```php +/** + * @responseResource App\Http\Resources\OrderResource + */ +public function show($id) +{ + return new OrderResource(Order::findOrFail($id)); +} +``` + +You can also specify a status code: + +```php +/** + * @responseResource 201 App\Http\Resources\OrderResource + */ +public function store(Request $request) +{ + $order = Order::create($request->all()); + return new OrderResource($order); +} +``` + +### Documenting the Resource Class + +In your API Resource class, use the following tags in the class-level DocBlock to provide metadata about the resource: + +- `@resourceName`: Specifies a custom name for the resource in the documentation. +- `@resourceDescription`: Provides a description of the resource. +- `@resourceStatus`: Sets a default HTTP status code for the resource. + +Example: + +```php +/** + * @resourceName Order + * @resourceDescription Represents an order in the system + * @resourceStatus 200 + */ +class OrderResource extends JsonResource +{ + public function toArray($request) + { + return [ + /** + * @responseParam id integer required The ID of the order. Example: 1 + */ + 'id' => $this->id, + /** + * @responseParam status string required The status of the order. Enum: [pending, processing, shipped, delivered]. Example: processing + */ + 'status' => $this->status, + /** + * @responseParam items array required The items in the order. + */ + 'items' => $this->items->map(function ($item) { + return [ + /** + * @responseParam id integer required The ID of the item. Example: 101 + */ + 'id' => $item->id, + /** + * @responseParam name string required The name of the item. Example: Ergonomic Keyboard + */ + 'name' => $item->name, + /** + * @responseParam price float required The price of the item. Example: 129.99 + */ + 'price' => $item->price, + ]; + }), + ]; + } +} +``` + +Use `@responseParam` annotations within the `toArray` method to document individual fields of the resource. You can specify the following for each field: + +- Type (e.g., integer, string, array) +- Whether it's required +- Description +- Example value +- Enum values (if applicable) + +The `@responseResource` annotation automatically parses your API Resource class to generate a detailed schema of your response structure, including nested relationships and complex data types. Additionally, it automatically generates an example response based on the provided example values or default values for each field type. + ## Further modification The info file in the view folder can be further modified to add introductions and further documentation. @@ -652,6 +746,122 @@ php artisan idoc:custom {config?} ### How to Use +1. **Create a Custom Configuration File:** + + Create a custom configuration file in the `config` directory. The file should follow the naming convention `idoc.{config}.php`, where `{config}` is the name you will use when running the command. + + Example for `config/idoc.ecommerce.php`: + ``` + // config/idoc.ecommerce.php + return [ + 'title' => 'E-commerce API Documentation', + 'version' => '1.0.0', + 'description' => 'API documentation for e-commerce.', + 'terms_of_service' => 'https://example.com/terms', + 'contact' => [ + 'name' => 'E-commerce API Support', + 'email' => 'support@example.com', + 'url' => 'https://example.com', + ], + 'license' => [ + 'name' => 'MIT', + 'url' => 'https://opensource.org/licenses/MIT', + ], + 'output' => '/docs/ecommerce', // Ensure this path is unique + 'hide_download_button' => false, + 'external_description' => route('ecommerce-doc-description'), + 'routes' => [ + [ + 'match' => [ + 'domains' => ['*'], + 'prefixes' => ['api/ecommerce/*'], + 'versions' => ['v1'], + ], + 'include' => [], + 'exclude' => [], + 'apply' => [ + 'headers' => [ + 'Authorization' => 'Bearer {token}', + ], + 'response_calls' => [ + 'methods' => ['*'], + 'bindings' => [], + 'env' => [ + 'APP_ENV' => 'documentation', + 'APP_DEBUG' => false, + ], + 'headers' => [ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ], + 'query' => [], + 'body' => [], + 'without_middleware' => [], + ], + ], + ], + ], + ]; + ``` + +2. **Run the Command:** + + Run the command with the name of your custom configuration file (without the `.php` extension). + + Example: + ``` + php artisan idoc:custom ecommerce + ``` + + If the custom configuration file exists, it will be loaded and merged with the default configuration. The command will then generate the API documentation using the merged configuration. + +3. **Check the Output:** + + The generated documentation will be saved to the path specified in the `output` configuration option of your custom configuration file. Ensure that the output path is unique for each custom documentation to avoid conflicts. This is relative to the public directory. + + - E-commerce API documentation: `/docs/ecommerce`, will save the open-api spec file to `public/docs/ecommerce/openapi.json` and the documentation to `public/docs/ecommerce/index.html`. + - User Management API documentation: `/docs/user` will save the open-api spec file to `public/docs/user/openapi.json` and the documentation to `public/docs/user/index.html`. + +By using the custom configuration generator, you can easily manage and generate multiple sets of API documentation for different applications within the same Laravel application. This approach allows you to maintain separate configurations and documentation outputs for each API, ensuring clarity and organization. + +### Managing Multiple API Documentation Sets + +The custom configuration generator can also help you manage multiple sets of API documentation for different applications within the same Laravel application. This is particularly useful if you have different API sets for different applications or modules. + +#### Example Scenario + +Suppose you have a Laravel application that serves multiple APIs for different applications, such as a user management API, and an e-commerce API. You can create separate configuration files for each API and use the custom configuration generator to generate the documentation accordingly. + +1. **Create Configuration Files:** + + - `config/idoc.ecommerce.php` + - `config/idoc.user.php` + +2. **Run the Command for Each API:** + + ``` + php artisan idoc:custom ecommerce + php artisan idoc:custom user + ``` + + This will generate the API documentation for each application using the respective configuration file. + +3. **Check the Output:** + + The generated documentation will be saved to the paths specified in the `output` configuration options of your custom configuration files. Ensure that each output path is unique to avoid conflicts. This is relative to the public directory. + + - E-commerce API documentation: `/docs/ecommerce`, will save the open-api spec file to `public/docs/ecommerce/openapi.json` and the documentation to `public/docs/ecommerce/index.html`. + - User Management API documentation: `/docs/user` will save the open-api spec file to `public/docs/user/openapi.json` and the documentation to `public/docs/user/index.html`. + +By using the custom configuration generator, you can easily manage and generate multiple sets of API documentation for different applications within the same Laravel application. This approach allows you to maintain separate configurations and documentation outputs for each API, ensuring clarity and organization. + +### Defining Custom Documentation Routes + +To serve the generated documentation for each custom configuration, you need to define routes in your `routes/web.php` or a similar routes file. This ensures that each set of documentation is accessible via a unique URL. + +Example for `idoc.ecommerce.php` configuration: + +``` 1. **Create a Custom Configuration File:** Create a custom configuration file in the `config` directory. The file should follow the naming convention `idoc.{config}.php`, where `{config}` is the name you will use when running the command. diff --git a/src/idoc/IDocCustomConfigGeneratorCommand.php b/src/idoc/IDocCustomConfigGeneratorCommand.php index b94f7eb..bd8f617 100644 --- a/src/idoc/IDocCustomConfigGeneratorCommand.php +++ b/src/idoc/IDocCustomConfigGeneratorCommand.php @@ -10,7 +10,7 @@ class IDocCustomConfigGeneratorCommand extends Command { // Command signature and description - protected $signature = 'idoc:custom {config?}'; + protected $signature = 'idoc:custom {config?} {--force}'; protected $description = 'Generate API documentation for custom configuration'; /** @@ -47,8 +47,11 @@ public function handle() $this->info('No configuration provided, using default iDoc configuration.'); } - // Execute the 'idoc:generate' Artisan command - Artisan::call('idoc:generate'); + // Check if the --force option is provided + $force = $this->option('force') ? ['--force' => true] : []; + + // Execute the 'idoc:generate' Artisan command with the --force option if provided + Artisan::call('idoc:generate', $force); // Get the output of the Artisan command $output = Artisan::output(); @@ -59,4 +62,4 @@ public function handle() // Inform the user that the iDoc command has been executed $this->info('iDoc command has been executed.'); } -} +} \ No newline at end of file diff --git a/src/idoc/IDocGenerator.php b/src/idoc/IDocGenerator.php index b935489..8a0f0f2 100644 --- a/src/idoc/IDocGenerator.php +++ b/src/idoc/IDocGenerator.php @@ -7,6 +7,7 @@ use Mpociot\Reflection\DocBlock; use Mpociot\Reflection\DocBlock\Tag; use OVAC\IDoc\Tools\ResponseResolver; +use OVAC\IDoc\Tools\SchemaParser; use OVAC\IDoc\Tools\Traits\ParamHelpers; use ReflectionClass; use ReflectionMethod; @@ -15,6 +16,13 @@ class IDocGenerator { use ParamHelpers; + protected $schemaParser; + + public function __construct() + { + $this->schemaParser = new SchemaParser(); + } + /** * Get the URI of the given route. * @@ -72,6 +80,9 @@ public function processRoute(Route $route, array $rulesToApply = []) 'query' => $queryParameters, ]); + // Extract schema documentation + $schemas = $this->schemaParser->getSchemaDocumentation($docBlock['tags']); + $parsedRoute = [ 'id' => md5($this->getUri($route) . ':' . implode($this->getMethods($route))), 'group' => $routeGroup, @@ -85,6 +96,7 @@ public function processRoute(Route $route, array $rulesToApply = []) 'authenticated' => $authenticated = $this->getAuthStatusFromDocBlock($docBlock['tags']), 'response' => $content, 'showresponse' => !empty($content), + 'schemas' => $schemas, ]; if (!$authenticated && array_key_exists('Authorization', ($rulesToApply['headers'] ?? []))) { @@ -328,6 +340,9 @@ private function generateDummyValue(string $type) 'object' => function () { return '{}'; }, + 'json' => function () { + return '{}'; + }, ]; $fake = $fakes[$type] ?? $fakes['string']; @@ -383,4 +398,4 @@ private function castToType(string $value, string $type) return $value; } -} +} \ No newline at end of file diff --git a/src/idoc/IDocGeneratorCommand.php b/src/idoc/IDocGeneratorCommand.php index 1eac5e2..1a6f262 100644 --- a/src/idoc/IDocGeneratorCommand.php +++ b/src/idoc/IDocGeneratorCommand.php @@ -13,7 +13,7 @@ /** * This custom generator will parse and generate a beautiful - * interractive documentation with openAPI schema. + * interactive documentation with openAPI schema. */ class IDocGeneratorCommand extends Command { @@ -31,7 +31,7 @@ class IDocGeneratorCommand extends Command * * @var string */ - protected $description = 'Generate interractive api documentation.'; + protected $description = 'Generate interactive api documentation.'; private $routeMatcher; @@ -324,8 +324,52 @@ function ($parameter) { ->toArray() ), - 'responses' => collect($route['response'])->mapWithKeys(function($item) { - return [ + 'responses' => collect($route['response'])->mapWithKeys(function($item) use ($route) { + + $schemas = collect($route['schemas'])->values()->mapWithKeys(function($schema){ + $requiredFields = []; + return [ + $schema['statusCode'] => [ + 'description' => $schema['name'], + 'content' => [ + 'application/json' => [ + 'schema' => [ + 'title' => $schema['name'], + 'type' => 'object', + 'description' => $schema['description'], + 'example' => $schema['example'], + 'properties' => collect($schema['properties']) + ->map(function($item, $key) use (&$requiredFields) { + + if($item['required']) $requiredFields[] = $key; + + $property = [ + 'type' => $item['type'], + 'description' => $item['description'], + 'example' => $item['example'] + ]; + + if ($item['type'] === 'object' && isset($item['properties'])) { + $property = $this->processNestedProperties($item['properties'], $property); + } + + if ($item['type'] === 'array' && isset($item['items'])) { + $property = $this->processArrayItems($item['items'], $property); + } + + return $property; + }) + ->toArray(), + 'required' => $requiredFields, + ] + ] + + ] + ] + ]; + }); + + return $schemas->toArray() + [ (int) $item['status'] => [ 'description' => in_array($item['status'], range(200,299)) ? 'success' : 'error', 'content' => [ @@ -336,7 +380,7 @@ function ($parameter) { ] ] ] - ] + ] ]; })->all(), @@ -345,7 +389,7 @@ function ($parameter) { 'lang' => $name, 'source' => view('idoc::languages.' . $lang, compact('route'))->render(), ]; - })->values()->toArray(), + })->values(), ] ), ]; @@ -416,46 +460,6 @@ function ($parameter) { 'default' => $schema['value'], ]; }); - - return ["PM{$route['paymentMethod']->id}" => ['type' => 'object'] - - + ( - count($required = $bodyParameters - ->values() - ->where('required', true) - ->pluck('name')) - ? ['required' => $required] - : [] - ) - - + ( - count($properties = $bodyParameters - ->values() - ->filter() - ->mapWithKeys(function ($parameter) { - return [ - $parameter['name'] => [ - 'type' => $parameter['type'], - 'example' => $parameter['default'], - 'description' => $parameter['description'], - ], - ]; - })) - ? ['properties' => $properties] - : [] - ) - - + ( - count($properties = $bodyParameters - ->values() - ->filter() - ->mapWithKeys(function ($parameter) { - return [$parameter['name'] => $parameter['default']]; - })) - ? ['example' => $properties] - : [] - ) - ]; }); })->filter(), ], @@ -469,4 +473,79 @@ function ($parameter) { return json_encode($collection); } -} + + /** + * Process nested properties for an object type. + * + * @param array $properties The nested properties to process + * @param array $property The parent property to update + * @return array The updated property with processed nested properties + */ + private function processNestedProperties(array $properties, array $property): array + { + $nestedRequiredFields = []; + $property['properties'] = collect($properties) + ->map(function($nestedItem, $nestedKey) use (&$nestedRequiredFields) { + if($nestedItem['required']) $nestedRequiredFields[] = $nestedKey; + $processedItem = [ + 'type' => $nestedItem['type'], + 'description' => $nestedItem['description'], + 'example' => $nestedItem['example'] + ]; + + // Recursive call for nested objects + if ($nestedItem['type'] === 'object' && isset($nestedItem['properties'])) { + $processedItem = $this->processNestedProperties($nestedItem['properties'], $processedItem); + } + + // Recursive call for nested arrays + if ($nestedItem['type'] === 'array' && isset($nestedItem['items'])) { + $processedItem = $this->processArrayItems($nestedItem['items'], $processedItem); + } + + return $processedItem; + }) + ->toArray(); + $property['required'] = $nestedRequiredFields; + return $property; + } + + /** + * Process items for an array type. + * + * @param array $items The array items to process + * @param array $property The parent property to update + * @return array The updated property with processed array items + */ + private function processArrayItems(array $items, array $property): array + { + $nestedRequiredFields = []; + $property['items'] = [ + 'type' => 'object', + 'properties' => collect($items) + ->map(function($nestedItem, $nestedKey) use (&$nestedRequiredFields) { + if($nestedItem['required']) $nestedRequiredFields[] = $nestedKey; + $processedItem = [ + 'type' => $nestedItem['type'], + 'description' => $nestedItem['description'], + 'example' => $nestedItem['example'] + ]; + + // Recursive call for nested objects + if ($nestedItem['type'] === 'object' && isset($nestedItem['properties'])) { + $processedItem = $this->processNestedProperties($nestedItem['properties'], $processedItem); + } + + // Recursive call for nested arrays + if ($nestedItem['type'] === 'array' && isset($nestedItem['items'])) { + $processedItem = $this->processArrayItems($nestedItem['items'], $processedItem); + } + + return $processedItem; + }) + ->toArray(), + ]; + $property['required'] = $nestedRequiredFields; + return $property; + } +} \ No newline at end of file diff --git a/src/idoc/Tools/SchemaParser.php b/src/idoc/Tools/SchemaParser.php new file mode 100644 index 0000000..5c5d74e --- /dev/null +++ b/src/idoc/Tools/SchemaParser.php @@ -0,0 +1,239 @@ +key() < $endLine) { + $line = $file->current(); + $file->next(); + + // Handle multiline comments + if (preg_match('/\*\s+@responseParam\s+(.+?)\s+(.+?)\s+(required\s+)?(.*)/', $line, $matches) || preg_match('/\/\/\s+@responseParam\s+(.+?)\s+(.+?)\s+(required\s+)?(.*)/', $line, $matches)) { + $multilineComment = $line; + while ($file->key() < $endLine && !preg_match('/\*\//', $line)) { + $line = $file->current(); + $file->next(); + $multilineComment .= ' ' . trim($line); + } + $line = $multilineComment; + } + + // Match @responseParam annotations + if (preg_match('/\*\s+@responseParam\s+(.+?)\s+(.+?)\s+(required\s+)?(.*)/', $line, $matches)) { + $name = $matches[1]; + $type = $matches[2]; + $required = !empty($matches[3]); + $description = trim($matches[4], " */"); + + // Extract example from description + $example = null; + if (preg_match('/Example:\s*(.*)/', $description, $exampleMatches)) { + $example = $exampleMatches[1]; + $description = trim(str_replace($exampleMatches[0], '', $description)); + } + + // Extract enum values from description + $enum = null; + if (preg_match('/Enum:\s*\[(.*)\]/', $description, $enumMatches)) { + $enum = array_map('trim', explode(',', $enumMatches[1])); + $description = trim(str_replace($enumMatches[0], '', $description)); + } + + // Add the parsed field to the current schema + $currentSchema[$name] = [ + 'type' => $type, + 'description' => $description, + 'required' => $required, + 'example' => $example, + 'enum' => $enum, + ]; + + // Handle nested schemas for array, object, or json types + if ($type === 'array') { + $nestedSchemas[] = &$currentSchema; + $currentSchema[$name]['items'] = []; + $currentSchema = &$currentSchema[$name]['items']; + } elseif (in_array($type, ['object', 'json'])) { + $nestedSchemas[] = &$currentSchema; + $currentSchema[$name]['properties'] = []; + $currentSchema = &$currentSchema[$name]['properties']; + } + } elseif (preg_match('/\s*\]\s*,?\s*$/', $line) || preg_match('/\s*\}\s*,?\s*$/', $line)) { + // Handle the end of a nested schema + if (!empty($nestedSchemas)) { + $currentSchema = &$nestedSchemas[count($nestedSchemas) - 1]; + array_pop($nestedSchemas); + } + } + } + + return $schema; + } + + /** + * Extract schema documentation from the given doc block tags. + * + * This method processes an array of doc block tags to extract schema documentation + * for response resources. + * + * @param array $tags The array of doc block tags to process. + * @return array The extracted schema documentation. + */ + public function getSchemaDocumentation(array $tags) + { + $schemas = []; + + foreach ($tags as $tag) { + if ($tag instanceof Tag && in_array($tag->getName(), ['responseResource'])) { + $content = $tag->getContent(); + preg_match('/(\d+)?\s*(.*)/', $content, $matches); + $statusCode = $matches[1]; + $resourceClass = $matches[2]; + + if (!class_exists($resourceClass)) { + throw new \Exception( + "Error in @responseResource annotation: Class '{$resourceClass}' does not exist.\n\n" . + "Please ensure you've provided the fully qualified class name, including the namespace.\n" . + "Example: @responseResource App\\Http\\Resources\\UserResource\n\n" . + "If you're using a collection resource, make sure to use the correct class name.\n" . + "Example: @responseResource 200 App\\Http\\Resources\\UserCollection\n\n" . + "Check your controller method's PHPDoc block and verify the class name and namespace." + ); + } + + $reflectionClass = new ReflectionClass($resourceClass); + $classDocBlock = $this->parseClassDocBlock($reflectionClass); + $method = $reflectionClass->getMethod('toArray'); + $fileName = $reflectionClass->getFileName(); + $startLine = $method->getStartLine(); + $endLine = $method->getEndLine(); + + $file = new SplFileObject($fileName); + $file->seek($startLine - 1); + + $schema = $this->parseSchema($file, $endLine); + + $schemas[] = [ + 'name' => $classDocBlock['resourceName'] ?? $reflectionClass->getShortName(), + 'statusCode' => $classDocBlock['resourceStatus'] ?? $statusCode ?: '200', + 'description' => $classDocBlock['resourceDescription'] ?? '', + 'properties' => $schema, + 'example' => $this->generateExampleResponse($schema), + ]; + } + } + + return $schemas; + } + + /** + * Parse the class doc block to extract resource name, description, and status. + * + * This method reads the doc block of a given class and extracts the resource name, + * description, and status from the @resourceName, @resourceDescription, and @resourceStatus + * annotations. + * + * @param ReflectionClass $reflectionClass The reflection class to parse. + * @return array An associative array containing the resource name, description, and status. + */ + protected function parseClassDocBlock(ReflectionClass $reflectionClass) + { + $docBlock = $reflectionClass->getDocComment(); + $phpdoc = new DocBlock($docBlock); + + $resourceName = null; + $resourceDescription = null; + $resourceStatus = null; + + foreach ($phpdoc->getTags() as $tag) { + if ($tag->getName() === 'resourceName') { + $resourceName = $tag->getContent(); + } elseif ($tag->getName() === 'resourceDescription') { + $resourceDescription = $tag->getContent(); + } elseif ($tag->getName() === 'resourceStatus') { + $resourceStatus = $tag->getContent(); + } + } + + return [ + 'resourceName' => $resourceName, + 'resourceDescription' => $resourceDescription, + 'resourceStatus' => $resourceStatus, + ]; + } + + /** + * Generate an example response from the given schema. + * + * @param array $schema + * @return array + */ + protected function generateExampleResponse(array $schema) + { + $response = []; + + foreach ($schema as $name => $property) { + if ($property['type'] === 'array') { + $response[$name] = [$this->generateExampleResponse($property['items'] ?? [])]; + } elseif (in_array($property['type'], ['object', 'json'])) { + $response[$name] = $this->generateExampleResponse($property['properties'] ?? []); + } else { + $response[$name] = $property['example'] ?? $this->generateDummyValue($property['type']); + } + } + + return $response; + } + + /** + * Generate a dummy value for the given parameter type. + * + * @param string $type + * @return mixed + */ + protected function generateDummyValue(string $type) + { + switch ($type) { + case 'integer': + return 1; + case 'float': + case 'double': + return 1.0; + case 'boolean': + return true; + case 'string': + return 'example'; + case 'array': + return []; + case 'object': + case 'json': + return new \stdClass(); + default: + return null; + } + } +} \ No newline at end of file