Browse Source

Merge pull request #570 from mpociot/plugin-arch

Switch to plugin architecture
Shalvah 5 years ago
parent
commit
f98914ac7b

+ 18 - 0
config/apidoc.php

@@ -179,6 +179,24 @@ return [
         ],
     ],
 
+    'strategies' => [
+        'metadata' => [
+            \Mpociot\ApiDoc\Strategies\Metadata\GetFromDocBlocks::class,
+        ],
+        'bodyParameters' => [
+            \Mpociot\ApiDoc\Strategies\BodyParameters\GetFromBodyParamTag::class,
+        ],
+        'queryParameters' => [
+            \Mpociot\ApiDoc\Strategies\QueryParameters\GetFromQueryParamTag::class,
+        ],
+        'responses' => [
+            \Mpociot\ApiDoc\Strategies\Responses\UseResponseTag::class,
+            \Mpociot\ApiDoc\Strategies\Responses\UseResponseFileTag::class,
+            \Mpociot\ApiDoc\Strategies\Responses\UseTransformerTags::class,
+            \Mpociot\ApiDoc\Strategies\Responses\ResponseCalls::class,
+        ],
+    ],
+
     /*
      * Custom logo path. The logo will be copied from this location
      * during the generate process. Set this to false to use the default logo.

+ 1 - 0
docs/index.md

@@ -9,6 +9,7 @@ Automatically generate your API documentation from your existing Laravel/Lumen/[
 * [Configuration](config.md)
 * [Generating Documentation](generating-documentation.md)
 * [Documenting Your API](documenting.md)
+* [Extending functionality with plugins](plugins.md)
 * [Internal Architecture](architecture.md)
 
 ## Installation

+ 140 - 0
docs/plugins.md

@@ -0,0 +1,140 @@
+# Extending functionality with plugins
+You can use plugins to alter how the Generator fetches data about your routes. For instance, suppose all your routes have a body parameter `organizationId`, and you don't want to annotate this with `@queryParam` on each method. You can create a plugin that adds this to all your body parameters. Let's see how to do this.
+
+## The stages of route processing
+Route processing is performed in four stages:
+- metadata (this covers route `title`, route `description`, route `groupName`, route `groupDescription`, and authentication status (`authenticated`))
+- bodyParameters
+- queryParameters
+- responses
+
+For each stage, the Generator attempts one or more configured strategies to fetch data. The Generator will call of the strategies configured, progressively combining their results together before to produce the final output of that stage.
+
+## Strategies
+To create a strategy, create a class that extends `\Mpociot\ApiDoc\Strategies\Strategy`.
+
+The `__invoke` method of the strategy is where you perform your actions and return data. It receives the following arguments:
+- the route (instance of `\Illuminate\Routing\Route`)
+- the controller class handling the route (`\ReflectionClass`)
+- the controller method (`\ReflectionMethod $method`)
+ - the rules specified in the apidoc.php config file for the group this route belongs to, under the `apply` section (array)
+ - the context. This contains all data for the route that has been parsed thus far in the previous stages. This means, by the `responses` stage, the context will contain the following keys: `metadata`, `bodyParameters` and `queryParameters`.
+ 
+ Here's what your strategy in our example would look like:
+ 
+ ```php
+<?php
+
+use Illuminate\Routing\Route;
+use Mpociot\ApiDoc\Strategies\Strategy;
+
+class AddOrganizationIdBodyParameter extends Strategy
+{
+    public function __invoke(Route $route, \ReflectionClass $controller, \ReflectionMethod $method, array $routeRules, array $context = [])
+    {
+        return [
+            'organizationId' => [
+                'type' => 'integer',
+                'description' => 'The ID of the organization', 
+                'required' => true, 
+                'value' => 2,
+            ]
+        ];
+    }
+}
+```
+
+The last thing to do is to register the strategy. Strategies are registered in a `strategies` key in the `apidoc.php` file. Here's what the file looks like by default:
+
+```php
+...
+    'strategies' => [
+        'metadata' => [
+            \Mpociot\ApiDoc\Strategies\Metadata\GetFromDocBlocks::class,
+        ],
+        'bodyParameters' => [
+            \Mpociot\ApiDoc\Strategies\BodyParameters\GetFromBodyParamTag::class,
+        ],
+        'queryParameters' => [
+            \Mpociot\ApiDoc\Strategies\QueryParameters\GetFromQueryParamTag::class,
+        ],
+        'responses' => [
+            \Mpociot\ApiDoc\Strategies\Responses\UseResponseTag::class,
+            \Mpociot\ApiDoc\Strategies\Responses\UseResponseFileTag::class,
+            \Mpociot\ApiDoc\Strategies\Responses\UseTransformerTags::class,
+            \Mpociot\ApiDoc\Strategies\Responses\ResponseCalls::class,
+        ],
+    ],
+...
+```
+
+You can add, replace or remove strategies from here. In our case, we're adding our bodyParameter strategy:
+
+```php
+
+        'bodyParameters' => [
+            \Mpociot\ApiDoc\Strategies\BodyParameters\GetFromBodyParamTag::class,
+            AddOrganizationIdBodyParameter::class,
+        ],
+```
+
+And we're done. Now, when we run `php artisan docs:generate`, all our routes will have this bodyParameter added.
+
+
+We could go further and modify our strategy so it doesn't add this parameter if the route is a GET route or is authenticated:
+
+```php
+public function __invoke(Route $route, \ReflectionClass $controller, \ReflectionMethod $method, array $routeRules, array $context = [])
+{
+    if (in_array('GET', $route->methods()) {
+        return null;
+    }
+
+    if ($context['metadata']['authenticated']) {
+        return null;
+    }
+
+    return [
+        'organizationId' => [
+            'type' => 'integer',
+            'description' => 'The ID of the organization', 
+            'required' => true, 
+            'value' => 2,
+        ]
+    ];
+}
+```
+
+The strategy class also has access to the current apidoc configuration via its `config` property. For instance, you can retrieve the default group with `$this->config->get('default_group')`.
+
+Yopu are also provided with the instance pproperty `stage`, which is set to the name of the currently executing stage.
+
+
+## Utilities
+You have access to a number of tools when developing strategies. They include:
+
+- The `RouteDocBlocker` class (in the `\Mpociot\ApiDoc\Tools` namespace) has a single public static method, `getDocBlocksFromRoute(Route $route)`. It allows you to retrieve the docblocks for a given route. It returns an array of with two keys: `method` and `class` containing the docblocks for the method and controller handling the route respectively. Both are instances of `\Mpociot\Reflection\DocBlock`.
+
+- The `ParamsHelper` trait (in the `\Mpociot\ApiDoc\Tools` namespace) can be included in your strategies. It contains a number of useful methods for working with parameters, including type casting and generating dummy values.
+
+## API
+Each strategy class must implement the __invoke method with the parameters as described above. This method must return the needed data for the intended stage, or `null` to indicate failure.
+- In the `metadata` stage, strategies should return an array. These are the expected keys (you may omit some, or all):
+
+```
+'groupName'
+'groupDescription'
+'title'
+'description'
+'authenticated' // boolean
+```
+
+- In the `bodyParameters` and `queryParameters` stages, you can return an array with arbitrary keys. These keys will serve as the names of your parameters. Array keys can be indicated with Laravel's dot notation. The value of each key should be an array with the following keys:
+
+```
+'type', // Only used in bodyParameters
+'description', 
+'required', // boolean
+'value', // An example value for the parameter
+```
+- In the `responses` stage, your strategy should return an array containing the responses for different status codes. Each key in the array should be a HTTP status code, and each value should be a string containing the response.

+ 2 - 2
src/Commands/GenerateDocumentation.php

@@ -247,7 +247,7 @@ class GenerateDocumentation extends Command
      */
     private function isValidRoute(Route $route)
     {
-        $action = Utils::getRouteActionUses($route->getAction());
+        $action = Utils::getRouteClassAndMethodNames($route->getAction());
         if (is_array($action)) {
             $action = implode('@', $action);
         }
@@ -264,7 +264,7 @@ class GenerateDocumentation extends Command
      */
     private function isRouteVisibleForDocumentation(array $action)
     {
-        list($class, $method) = Utils::getRouteActionUses($action);
+        list($class, $method) = Utils::getRouteClassAndMethodNames($action);
         $reflection = new ReflectionClass($class);
 
         if (! $reflection->hasMethod($method)) {

+ 90 - 0
src/Strategies/BodyParameters/GetFromBodyParamTag.php

@@ -0,0 +1,90 @@
+<?php
+
+namespace Mpociot\ApiDoc\Strategies\BodyParameters;
+
+use ReflectionClass;
+use ReflectionMethod;
+use Illuminate\Routing\Route;
+use Mpociot\Reflection\DocBlock;
+use Mpociot\Reflection\DocBlock\Tag;
+use Mpociot\ApiDoc\Strategies\Strategy;
+use Mpociot\ApiDoc\Tools\RouteDocBlocker;
+use Dingo\Api\Http\FormRequest as DingoFormRequest;
+use Mpociot\ApiDoc\Tools\Traits\DocBlockParamHelpers;
+use Illuminate\Foundation\Http\FormRequest as LaravelFormRequest;
+
+class GetFromBodyParamTag extends Strategy
+{
+    use DocBlockParamHelpers;
+
+    public function __invoke(Route $route, ReflectionClass $controller, ReflectionMethod $method, array $routeRules, array $context = [])
+    {
+        foreach ($method->getParameters() as $param) {
+            $paramType = $param->getType();
+            if ($paramType === null) {
+                continue;
+            }
+
+            $parameterClassName = version_compare(phpversion(), '7.1.0', '<')
+                ? $paramType->__toString()
+                : $paramType->getName();
+
+            try {
+                $parameterClass = new ReflectionClass($parameterClassName);
+            } catch (\ReflectionException $e) {
+                continue;
+            }
+
+            // If there's a FormRequest, we check there for @bodyParam tags.
+            if (class_exists(LaravelFormRequest::class) && $parameterClass->isSubclassOf(LaravelFormRequest::class)
+                || class_exists(DingoFormRequest::class) && $parameterClass->isSubclassOf(DingoFormRequest::class)) {
+                $formRequestDocBlock = new DocBlock($parameterClass->getDocComment());
+                $bodyParametersFromDocBlock = $this->getBodyParametersFromDocBlock($formRequestDocBlock->getTags());
+
+                if (count($bodyParametersFromDocBlock)) {
+                    return $bodyParametersFromDocBlock;
+                }
+            }
+        }
+
+        $methodDocBlock = RouteDocBlocker::getDocBlocksFromRoute($route)['method'];
+
+        return $this->getBodyParametersFromDocBlock($methodDocBlock->getTags());
+    }
+
+    private function getBodyParametersFromDocBlock($tags)
+    {
+        $parameters = collect($tags)
+            ->filter(function ($tag) {
+                return $tag instanceof Tag && $tag->getName() === 'bodyParam';
+            })
+            ->mapWithKeys(function ($tag) {
+                preg_match('/(.+?)\s+(.+?)\s+(required\s+)?(.*)/', $tag->getContent(), $content);
+                $content = preg_replace('/\s?No-example.?/', '', $content);
+                if (empty($content)) {
+                    // this means only name and type were supplied
+                    list($name, $type) = preg_split('/\s+/', $tag->getContent());
+                    $required = false;
+                    $description = '';
+                } else {
+                    list($_, $name, $type, $required, $description) = $content;
+                    $description = trim($description);
+                    if ($description == 'required' && empty(trim($required))) {
+                        $required = $description;
+                        $description = '';
+                    }
+                    $required = trim($required) == 'required' ? true : false;
+                }
+
+                $type = $this->normalizeParameterType($type);
+                list($description, $example) = $this->parseParamDescription($description, $type);
+                $value = is_null($example) && ! $this->shouldExcludeExample($tag)
+                    ? $this->generateDummyValue($type)
+                    : $example;
+
+                return [$name => compact('type', 'description', 'required', 'value')];
+            })->toArray();
+
+        return $parameters;
+    }
+}

+ 100 - 0
src/Strategies/Metadata/GetFromDocBlocks.php

@@ -0,0 +1,100 @@
+<?php
+
+namespace Mpociot\ApiDoc\Strategies\Metadata;
+
+use ReflectionClass;
+use ReflectionMethod;
+use Illuminate\Routing\Route;
+use Mpociot\Reflection\DocBlock;
+use Mpociot\Reflection\DocBlock\Tag;
+use Mpociot\ApiDoc\Strategies\Strategy;
+use Mpociot\ApiDoc\Tools\RouteDocBlocker;
+
+class GetFromDocBlocks extends Strategy
+{
+    public function __invoke(Route $route, ReflectionClass $controller, ReflectionMethod $method, array $routeRules, array $context = [])
+    {
+        $docBlocks = RouteDocBlocker::getDocBlocksFromRoute($route);
+        /** @var DocBlock $methodDocBlock */
+        $methodDocBlock = $docBlocks['method'];
+
+        list($routeGroupName, $routeGroupDescription, $routeTitle) = $this->getRouteGroupDescriptionAndTitle($methodDocBlock, $docBlocks['class']);
+
+        return [
+                'groupName' => $routeGroupName,
+                'groupDescription' => $routeGroupDescription,
+                'title' => $routeTitle ?: $methodDocBlock->getShortDescription(),
+                'description' => $methodDocBlock->getLongDescription()->getContents(),
+                'authenticated' => $this->getAuthStatusFromDocBlock($methodDocBlock->getTags()),
+        ];
+    }
+
+    /**
+     * @param array $tags Tags in the method doc block
+     *
+     * @return bool
+     */
+    protected function getAuthStatusFromDocBlock(array $tags)
+    {
+        $authTag = collect($tags)
+            ->first(function ($tag) {
+                return $tag instanceof Tag && strtolower($tag->getName()) === 'authenticated';
+            });
+
+        return (bool) $authTag;
+    }
+
+    /**
+     * @param DocBlock $methodDocBlock
+     * @param DocBlock $controllerDocBlock
+     *
+     * @return array The route group name, the group description, ad the route title
+     */
+    protected function getRouteGroupDescriptionAndTitle(DocBlock $methodDocBlock, DocBlock $controllerDocBlock)
+    {
+        // @group tag on the method overrides that on the controller
+        if (! empty($methodDocBlock->getTags())) {
+            foreach ($methodDocBlock->getTags() as $tag) {
+                if ($tag->getName() === 'group') {
+                    $routeGroupParts = explode("\n", trim($tag->getContent()));
+                    $routeGroupName = array_shift($routeGroupParts);
+                    $routeGroupDescription = trim(implode("\n", $routeGroupParts));
+
+                    // If the route has no title (the methodDocBlock's "short description"),
+                    // we'll assume the routeGroupDescription is actually the title
+                    // Something like this:
+                    // /**
+                    //   * Fetch cars. <-- This is route title.
+                    //   * @group Cars <-- This is group name.
+                    //   * APIs for cars. <-- This is group description (not required).
+                    //   **/
+                    // VS
+                    // /**
+                    //   * @group Cars <-- This is group name.
+                    //   * Fetch cars. <-- This is route title, NOT group description.
+                    //   **/
+
+                    // BTW, this is a spaghetti way of doing this.
+                    // It shall be refactored soon. Deus vult!💪
+                    if (empty($methodDocBlock->getShortDescription())) {
+                        return [$routeGroupName, '', $routeGroupDescription];
+                    }
+
+                    return [$routeGroupName, $routeGroupDescription, $methodDocBlock->getShortDescription()];
+                }
+            }
+        }
+
+        foreach ($controllerDocBlock->getTags() as $tag) {
+            if ($tag->getName() === 'group') {
+                $routeGroupParts = explode("\n", trim($tag->getContent()));
+                $routeGroupName = array_shift($routeGroupParts);
+                $routeGroupDescription = implode("\n", $routeGroupParts);
+
+                return [$routeGroupName, $routeGroupDescription, $methodDocBlock->getShortDescription()];
+            }
+        }
+
+        return [$this->config->get('default_group'), '', $methodDocBlock->getShortDescription()];
+    }
+}

+ 92 - 0
src/Strategies/QueryParameters/GetFromQueryParamTag.php

@@ -0,0 +1,92 @@
+<?php
+
+namespace Mpociot\ApiDoc\Strategies\QueryParameters;
+
+use ReflectionClass;
+use ReflectionMethod;
+use Illuminate\Support\Str;
+use Illuminate\Routing\Route;
+use Mpociot\Reflection\DocBlock;
+use Mpociot\Reflection\DocBlock\Tag;
+use Mpociot\ApiDoc\Strategies\Strategy;
+use Mpociot\ApiDoc\Tools\RouteDocBlocker;
+use Dingo\Api\Http\FormRequest as DingoFormRequest;
+use Mpociot\ApiDoc\Tools\Traits\DocBlockParamHelpers;
+use Illuminate\Foundation\Http\FormRequest as LaravelFormRequest;
+
+class GetFromQueryParamTag extends Strategy
+{
+    use DocBlockParamHelpers;
+
+    public function __invoke(Route $route, ReflectionClass $controller, ReflectionMethod $method, array $routeRules, array $context = [])
+    {
+        foreach ($method->getParameters() as $param) {
+            $paramType = $param->getType();
+            if ($paramType === null) {
+                continue;
+            }
+
+            $parameterClassName = version_compare(phpversion(), '7.1.0', '<')
+                ? $paramType->__toString()
+                : $paramType->getName();
+
+            try {
+                $parameterClass = new ReflectionClass($parameterClassName);
+            } catch (\ReflectionException $e) {
+                continue;
+            }
+
+            // If there's a FormRequest, we check there for @queryParam tags.
+            if (class_exists(LaravelFormRequest::class) && $parameterClass->isSubclassOf(LaravelFormRequest::class)
+                || class_exists(DingoFormRequest::class) && $parameterClass->isSubclassOf(DingoFormRequest::class)) {
+                $formRequestDocBlock = new DocBlock($parameterClass->getDocComment());
+                $queryParametersFromDocBlock = $this->getqueryParametersFromDocBlock($formRequestDocBlock->getTags());
+
+                if (count($queryParametersFromDocBlock)) {
+                    return $queryParametersFromDocBlock;
+                }
+            }
+        }
+
+        $methodDocBlock = RouteDocBlocker::getDocBlocksFromRoute($route)['method'];
+
+        return $this->getqueryParametersFromDocBlock($methodDocBlock->getTags());
+    }
+
+    private function getQueryParametersFromDocBlock($tags)
+    {
+        $parameters = collect($tags)
+            ->filter(function ($tag) {
+                return $tag instanceof Tag && $tag->getName() === 'queryParam';
+            })
+            ->mapWithKeys(function ($tag) {
+                preg_match('/(.+?)\s+(required\s+)?(.*)/', $tag->getContent(), $content);
+                $content = preg_replace('/\s?No-example.?/', '', $content);
+                if (empty($content)) {
+                    // this means only name was supplied
+                    list($name) = preg_split('/\s+/', $tag->getContent());
+                    $required = false;
+                    $description = '';
+                } else {
+                    list($_, $name, $required, $description) = $content;
+                    $description = trim($description);
+                    if ($description == 'required' && empty(trim($required))) {
+                        $required = $description;
+                        $description = '';
+                    }
+                    $required = trim($required) == 'required' ? true : false;
+                }
+
+                list($description, $value) = $this->parseParamDescription($description, 'string');
+                if (is_null($value) && ! $this->shouldExcludeExample($tag)) {
+                    $value = Str::contains($description, ['number', 'count', 'page'])
+                        ? $this->generateDummyValue('integer')
+                        : $this->generateDummyValue('string');
+                }
+
+                return [$name => compact('description', 'required', 'value')];
+            })->toArray();
+
+        return $parameters;
+    }
+}

+ 22 - 14
src/Tools/ResponseStrategies/ResponseCallStrategy.php → src/Strategies/Responses/ResponseCalls.php

@@ -1,6 +1,6 @@
 <?php
 
-namespace Mpociot\ApiDoc\Tools\ResponseStrategies;
+namespace Mpociot\ApiDoc\Strategies\Responses;
 
 use Dingo\Api\Dispatcher;
 use Illuminate\Support\Str;
@@ -9,34 +9,41 @@ use Illuminate\Http\Response;
 use Illuminate\Routing\Route;
 use Mpociot\ApiDoc\Tools\Flags;
 use Mpociot\ApiDoc\Tools\Utils;
+use Mpociot\ApiDoc\Strategies\Strategy;
 use Mpociot\ApiDoc\Tools\Traits\ParamHelpers;
 
 /**
  * Make a call to the route and retrieve its response.
  */
-class ResponseCallStrategy
+class ResponseCalls extends Strategy
 {
     use ParamHelpers;
 
     /**
      * @param Route $route
-     * @param array $tags
-     * @param array $routeProps
+     * @param \ReflectionClass $controller
+     * @param \ReflectionMethod $method
+     * @param array $routeRules
+     * @param array $context
      *
      * @return array|null
      */
-    public function __invoke(Route $route, array $tags, array $routeProps)
+    public function __invoke(Route $route, \ReflectionClass $controller, \ReflectionMethod $method, array $routeRules, array $context = [])
     {
-        $rulesToApply = $routeProps['rules']['response_calls'] ?? [];
-        if (! $this->shouldMakeApiCall($route, $rulesToApply)) {
+        $rulesToApply = $routeRules['response_calls'] ?? [];
+        if (! $this->shouldMakeApiCall($route, $rulesToApply, $context)) {
             return null;
         }
 
         $this->configureEnvironment($rulesToApply);
-        $request = $this->prepareRequest($route, $rulesToApply, $routeProps['body'], $routeProps['query']);
+
+        // Mix in parsed parameters with manually specified parameters.
+        $bodyParameters = array_merge($context['cleanBodyParameters'], $rulesToApply['body'] ?? []);
+        $queryParameters = array_merge($context['cleanQueryParameters'], $rulesToApply['query'] ?? []);
+        $request = $this->prepareRequest($route, $rulesToApply, $bodyParameters, $queryParameters);
 
         try {
-            $response = [$this->makeApiCall($request)];
+            $response = [200 => $this->makeApiCall($request)->getContent()];
         } catch (\Exception $e) {
             echo 'Exception thrown during response call for ['.implode(',', $route->methods)."] {$route->uri}.\n";
             if (Flags::$shouldBeVerbose) {
@@ -81,10 +88,6 @@ class ResponseCallStrategy
         $request = Request::create($uri, $method, [], $cookies, [], $this->transformHeadersToServerVars($rulesToApply['headers'] ?? []));
         $request = $this->addHeaders($request, $route, $rulesToApply['headers'] ?? []);
 
-        // Mix in parsed parameters with manually specified parameters.
-        $queryParams = collect($this->cleanParams($queryParams))->merge($rulesToApply['query'] ?? [])->toArray();
-        $bodyParams = collect($this->cleanParams($bodyParams))->merge($rulesToApply['body'] ?? [])->toArray();
-
         $request = $this->addQueryParameters($request, $queryParams);
         $request = $this->addBodyParameters($request, $bodyParams);
 
@@ -295,13 +298,18 @@ class ResponseCallStrategy
      *
      * @return bool
      */
-    private function shouldMakeApiCall(Route $route, array $rulesToApply): bool
+    private function shouldMakeApiCall(Route $route, array $rulesToApply, array $context): bool
     {
         $allowedMethods = $rulesToApply['methods'] ?? [];
         if (empty($allowedMethods)) {
             return false;
         }
 
+        if (! empty($context['responses'])) {
+            // Don't attempt a response call if there are already responses
+            return false;
+        }
+
         if (is_string($allowedMethods) && $allowedMethods == '*') {
             return true;
         }

+ 22 - 9
src/Tools/ResponseStrategies/ResponseFileStrategy.php → src/Strategies/Responses/UseResponseFileTag.php

@@ -1,26 +1,36 @@
 <?php
 
-namespace Mpociot\ApiDoc\Tools\ResponseStrategies;
+namespace Mpociot\ApiDoc\Strategies\Responses;
 
 use Illuminate\Routing\Route;
-use Illuminate\Http\JsonResponse;
+use Mpociot\Reflection\DocBlock;
 use Mpociot\Reflection\DocBlock\Tag;
+use Mpociot\ApiDoc\Strategies\Strategy;
+use Mpociot\ApiDoc\Tools\RouteDocBlocker;
 
 /**
  * Get a response from from a file in the docblock ( @responseFile ).
  */
-class ResponseFileStrategy
+class UseResponseFileTag extends Strategy
 {
     /**
      * @param Route $route
-     * @param array $tags
-     * @param array $routeProps
+     * @param \ReflectionClass $controller
+     * @param \ReflectionMethod $method
+     * @param array $routeRules
+     * @param array $context
+     *
+     * @throws \Exception
      *
      * @return array|null
      */
-    public function __invoke(Route $route, array $tags, array $routeProps)
+    public function __invoke(Route $route, \ReflectionClass $controller, \ReflectionMethod $method, array $routeRules, array $context = [])
     {
-        return $this->getFileResponses($tags);
+        $docBlocks = RouteDocBlocker::getDocBlocksFromRoute($route);
+        /** @var DocBlock $methodDocBlock */
+        $methodDocBlock = $docBlocks['method'];
+
+        return $this->getFileResponses($methodDocBlock->getTags());
     }
 
     /**
@@ -43,14 +53,17 @@ class ResponseFileStrategy
             return null;
         }
 
-        return array_map(function (Tag $responseFileTag) {
+        $responses = array_map(function (Tag $responseFileTag) {
             preg_match('/^(\d{3})?\s?([\S]*[\s]*?)(\{.*\})?$/', $responseFileTag->getContent(), $result);
             $status = $result[1] ?: 200;
             $content = $result[2] ? file_get_contents(storage_path(trim($result[2])), true) : '{}';
             $json = ! empty($result[3]) ? str_replace("'", '"', $result[3]) : '{}';
             $merged = array_merge(json_decode($content, true), json_decode($json, true));
 
-            return new JsonResponse($merged, (int) $status);
+            return [json_encode($merged), (int) $status];
         }, $responseFileTags);
+
+        // Convert responses to [200 => 'response', 401 => 'response']
+        return collect($responses)->pluck('0', '1')->toArray();
     }
 }

+ 67 - 0
src/Strategies/Responses/UseResponseTag.php

@@ -0,0 +1,67 @@
+<?php
+
+namespace Mpociot\ApiDoc\Strategies\Responses;
+
+use Illuminate\Routing\Route;
+use Mpociot\Reflection\DocBlock;
+use Mpociot\Reflection\DocBlock\Tag;
+use Mpociot\ApiDoc\Strategies\Strategy;
+use Mpociot\ApiDoc\Tools\RouteDocBlocker;
+
+/**
+ * Get a response from the docblock ( @response ).
+ */
+class UseResponseTag extends Strategy
+{
+    /**
+     * @param Route $route
+     * @param \ReflectionClass $controller
+     * @param \ReflectionMethod $method
+     * @param array $routeRules
+     * @param array $context
+     *
+     * @throws \Exception
+     *
+     * @return array|null
+     */
+    public function __invoke(Route $route, \ReflectionClass $controller, \ReflectionMethod $method, array $routeRules, array $context = [])
+    {
+        $docBlocks = RouteDocBlocker::getDocBlocksFromRoute($route);
+        /** @var DocBlock $methodDocBlock */
+        $methodDocBlock = $docBlocks['method'];
+
+        return $this->getDocBlockResponses($methodDocBlock->getTags());
+    }
+
+    /**
+     * Get the response from the docblock if available.
+     *
+     * @param array $tags
+     *
+     * @return array|null
+     */
+    protected function getDocBlockResponses(array $tags)
+    {
+        $responseTags = array_values(
+            array_filter($tags, function ($tag) {
+                return $tag instanceof Tag && strtolower($tag->getName()) === 'response';
+            })
+        );
+
+        if (empty($responseTags)) {
+            return null;
+        }
+
+        $responses = array_map(function (Tag $responseTag) {
+            preg_match('/^(\d{3})?\s?([\s\S]*)$/', $responseTag->getContent(), $result);
+
+            $status = $result[1] ?: 200;
+            $content = $result[2] ?: '{}';
+
+            return [$content, (int) $status];
+        }, $responseTags);
+
+        // Convert responses to [200 => 'response', 401 => 'response']
+        return collect($responses)->pluck('0', '1')->toArray();
+    }
+}

+ 18 - 7
src/Tools/ResponseStrategies/TransformerTagsStrategy.php → src/Strategies/Responses/UseTransformerTags.php

@@ -1,6 +1,6 @@
 <?php
 
-namespace Mpociot\ApiDoc\Tools\ResponseStrategies;
+namespace Mpociot\ApiDoc\Strategies\Responses;
 
 use ReflectionClass;
 use ReflectionMethod;
@@ -8,25 +8,36 @@ use Illuminate\Support\Arr;
 use League\Fractal\Manager;
 use Illuminate\Routing\Route;
 use Mpociot\ApiDoc\Tools\Flags;
+use Mpociot\Reflection\DocBlock;
 use League\Fractal\Resource\Item;
 use Mpociot\Reflection\DocBlock\Tag;
 use League\Fractal\Resource\Collection;
+use Mpociot\ApiDoc\Strategies\Strategy;
+use Mpociot\ApiDoc\Tools\RouteDocBlocker;
 
 /**
  * Parse a transformer response from the docblock ( @transformer || @transformercollection ).
  */
-class TransformerTagsStrategy
+class UseTransformerTags extends Strategy
 {
     /**
      * @param Route $route
-     * @param array $tags
-     * @param array $routeProps
+     * @param ReflectionClass $controller
+     * @param ReflectionMethod $method
+     * @param array $rulesToApply
+     * @param array $context
+     *
+     * @throws \Exception
      *
      * @return array|null
      */
-    public function __invoke(Route $route, array $tags, array $routeProps)
+    public function __invoke(Route $route, \ReflectionClass $controller, \ReflectionMethod $method, array $rulesToApply, array $context = [])
     {
-        return $this->getTransformerResponse($tags);
+        $docBlocks = RouteDocBlocker::getDocBlocksFromRoute($route);
+        /** @var DocBlock $methodDocBlock */
+        $methodDocBlock = $docBlocks['method'];
+
+        return $this->getTransformerResponse($methodDocBlock->getTags());
     }
 
     /**
@@ -57,7 +68,7 @@ class TransformerTagsStrategy
                 ? new Collection([$modelInstance, $modelInstance], new $transformer)
                 : new Item($modelInstance, new $transformer);
 
-            return [response($fractal->createData($resource)->toJson())];
+            return [200 => response($fractal->createData($resource)->toJson())->getContent()];
         } catch (\Exception $e) {
             return null;
         }

+ 40 - 0
src/Strategies/Strategy.php

@@ -0,0 +1,40 @@
+<?php
+
+namespace Mpociot\ApiDoc\Strategies;
+
+use ReflectionClass;
+use ReflectionMethod;
+use Illuminate\Routing\Route;
+use Mpociot\ApiDoc\Tools\DocumentationConfig;
+
+abstract class Strategy
+{
+    /**
+     * @var DocumentationConfig The apidoc config
+     */
+    protected $config;
+
+    /**
+     * @var string The current stage of route processing
+     */
+    protected $stage;
+
+    public function __construct(string $stage, DocumentationConfig $config)
+    {
+        $this->stage = $stage;
+        $this->config = $config;
+    }
+
+    /**
+     * @param Route $route
+     * @param ReflectionClass $controller
+     * @param ReflectionMethod $method
+     * @param array $routeRules Array of rules for the ruleset which this route belongs to.
+     * @param array $context Results from the previous stages
+     *
+     * @throws \Exception
+     *
+     * @return array
+     */
+    abstract public function __invoke(Route $route, ReflectionClass $controller, ReflectionMethod $method, array $routeRules, array $context = []);
+}

+ 88 - 337
src/Tools/Generator.php

@@ -2,19 +2,14 @@
 
 namespace Mpociot\ApiDoc\Tools;
 
-use Faker\Factory;
 use ReflectionClass;
 use ReflectionMethod;
+use Illuminate\Support\Arr;
 use Illuminate\Support\Str;
 use Illuminate\Routing\Route;
-use Mpociot\Reflection\DocBlock;
-use Mpociot\Reflection\DocBlock\Tag;
-use Mpociot\ApiDoc\Tools\Traits\ParamHelpers;
 
 class Generator
 {
-    use ParamHelpers;
-
     /**
      * @var DocumentationConfig
      */
@@ -54,389 +49,145 @@ class Generator
      */
     public function processRoute(Route $route, array $rulesToApply = [])
     {
-        list($class, $method) = Utils::getRouteActionUses($route->getAction());
-        $controller = new ReflectionClass($class);
-        $method = $controller->getMethod($method);
-
-        $docBlock = $this->parseDocBlock($method);
-        list($routeGroupName, $routeGroupDescription, $routeTitle) = $this->getRouteGroup($controller, $docBlock);
-        $bodyParameters = $this->getBodyParameters($method, $docBlock['tags']);
-        $queryParameters = $this->getQueryParameters($method, $docBlock['tags']);
-        $content = ResponseResolver::getResponse($route, $docBlock['tags'], [
-            'rules' => $rulesToApply,
-            'body' => $bodyParameters,
-            'query' => $queryParameters,
-        ]);
+        list($controllerName, $methodName) = Utils::getRouteClassAndMethodNames($route->getAction());
+        $controller = new ReflectionClass($controllerName);
+        $method = $controller->getMethod($methodName);
 
         $parsedRoute = [
             'id' => md5($this->getUri($route).':'.implode($this->getMethods($route))),
-            'groupName' => $routeGroupName,
-            'groupDescription' => $routeGroupDescription,
-            'title' => $routeTitle ?: $docBlock['short'],
-            'description' => $docBlock['long'],
             'methods' => $this->getMethods($route),
             'uri' => $this->getUri($route),
             'boundUri' => Utils::getFullUrl($route, $rulesToApply['bindings'] ?? ($rulesToApply['response_calls']['bindings'] ?? [])),
-            'queryParameters' => $queryParameters,
-            'bodyParameters' => $bodyParameters,
-            'cleanBodyParameters' => $this->cleanParams($bodyParameters),
-            'cleanQueryParameters' => $this->cleanParams($queryParameters),
-            'authenticated' => $this->getAuthStatusFromDocBlock($docBlock['tags']),
-            'response' => $content,
-            'showresponse' => ! empty($content),
         ];
-        $parsedRoute['headers'] = $rulesToApply['headers'] ?? [];
+        $metadata = $this->fetchMetadata($controller, $method, $route, $rulesToApply, $parsedRoute);
+        $parsedRoute['metadata'] = $metadata;
+        $bodyParameters = $this->fetchBodyParameters($controller, $method, $route, $rulesToApply, $parsedRoute);
+        $parsedRoute['bodyParameters'] = $bodyParameters;
+        $parsedRoute['cleanBodyParameters'] = $this->cleanParams($bodyParameters);
 
-        return $parsedRoute;
-    }
+        $queryParameters = $this->fetchQueryParameters($controller, $method, $route, $rulesToApply, $parsedRoute);
+        $parsedRoute['queryParameters'] = $queryParameters;
+        $parsedRoute['cleanQueryParameters'] = $this->cleanParams($queryParameters);
 
-    protected function getBodyParameters(ReflectionMethod $method, array $tags)
-    {
-        foreach ($method->getParameters() as $param) {
-            $paramType = $param->getType();
-            if ($paramType === null) {
-                continue;
-            }
-
-            $parameterClassName = version_compare(phpversion(), '7.1.0', '<')
-                ? $paramType->__toString()
-                : $paramType->getName();
-
-            try {
-                $parameterClass = new ReflectionClass($parameterClassName);
-            } catch (\ReflectionException $e) {
-                continue;
-            }
-
-            if (class_exists('\Illuminate\Foundation\Http\FormRequest') && $parameterClass->isSubclassOf(\Illuminate\Foundation\Http\FormRequest::class) || class_exists('\Dingo\Api\Http\FormRequest') && $parameterClass->isSubclassOf(\Dingo\Api\Http\FormRequest::class)) {
-                $formRequestDocBlock = new DocBlock($parameterClass->getDocComment());
-                $bodyParametersFromDocBlock = $this->getBodyParametersFromDocBlock($formRequestDocBlock->getTags());
-
-                if (count($bodyParametersFromDocBlock)) {
-                    return $bodyParametersFromDocBlock;
-                }
-            }
-        }
-
-        return $this->getBodyParametersFromDocBlock($tags);
-    }
-
-    /**
-     * @param array $tags
-     *
-     * @return array
-     */
-    protected function getBodyParametersFromDocBlock(array $tags)
-    {
-        $parameters = collect($tags)
-            ->filter(function ($tag) {
-                return $tag instanceof Tag && $tag->getName() === 'bodyParam';
-            })
-            ->mapWithKeys(function ($tag) {
-                preg_match('/(.+?)\s+(.+?)\s+(required\s+)?(.*)/', $tag->getContent(), $content);
-                $content = preg_replace('/\s?No-example.?/', '', $content);
-                if (empty($content)) {
-                    // this means only name and type were supplied
-                    list($name, $type) = preg_split('/\s+/', $tag->getContent());
-                    $required = false;
-                    $description = '';
-                } else {
-                    list($_, $name, $type, $required, $description) = $content;
-                    $description = trim($description);
-                    if ($description == 'required' && empty(trim($required))) {
-                        $required = $description;
-                        $description = '';
-                    }
-                    $required = trim($required) == 'required' ? true : false;
-                }
+        $responses = $this->fetchResponses($controller, $method, $route, $rulesToApply, $parsedRoute);
+        $parsedRoute['response'] = $responses;
+        $parsedRoute['showresponse'] = ! empty($responses);
 
-                $type = $this->normalizeParameterType($type);
-                list($description, $example) = $this->parseDescription($description, $type);
-                $value = is_null($example) && ! $this->shouldExcludeExample($tag) ? $this->generateDummyValue($type) : $example;
+        $parsedRoute['headers'] = $rulesToApply['headers'] ?? [];
 
-                return [$name => compact('type', 'description', 'required', 'value')];
-            })->toArray();
+        $parsedRoute += $metadata;
 
-        return $parameters;
+        return $parsedRoute;
     }
 
-    /**
-     * @param ReflectionMethod $method
-     * @param array $tags
-     *
-     * @return array
-     */
-    protected function getQueryParameters(ReflectionMethod $method, array $tags)
+    protected function fetchMetadata(ReflectionClass $controller, ReflectionMethod $method, Route $route, array $rulesToApply, array $context = [])
     {
-        foreach ($method->getParameters() as $param) {
-            $paramType = $param->getType();
-            if ($paramType === null) {
-                continue;
-            }
-
-            $parameterClassName = version_compare(phpversion(), '7.1.0', '<')
-                ? $paramType->__toString()
-                : $paramType->getName();
-
-            try {
-                $parameterClass = new ReflectionClass($parameterClassName);
-            } catch (\ReflectionException $e) {
-                continue;
-            }
-
-            if (class_exists('\Illuminate\Foundation\Http\FormRequest') && $parameterClass->isSubclassOf(\Illuminate\Foundation\Http\FormRequest::class) || class_exists('\Dingo\Api\Http\FormRequest') && $parameterClass->isSubclassOf(\Dingo\Api\Http\FormRequest::class)) {
-                $formRequestDocBlock = new DocBlock($parameterClass->getDocComment());
-                $queryParametersFromDocBlock = $this->getQueryParametersFromDocBlock($formRequestDocBlock->getTags());
-
-                if (count($queryParametersFromDocBlock)) {
-                    return $queryParametersFromDocBlock;
-                }
-            }
-        }
+        $context['metadata'] = [
+            'groupName' => $this->config->get('default_group', ''),
+            'groupDescription' => '',
+            'title' => '',
+            'description' => '',
+            'authenticated' => false,
+        ];
 
-        return $this->getQueryParametersFromDocBlock($tags);
+        return $this->iterateThroughStrategies('metadata', $context, [$route, $controller, $method, $rulesToApply]);
     }
 
-    /**
-     * @param array $tags
-     *
-     * @return array
-     */
-    protected function getQueryParametersFromDocBlock(array $tags)
+    protected function fetchBodyParameters(ReflectionClass $controller, ReflectionMethod $method, Route $route, array $rulesToApply, array $context = [])
     {
-        $parameters = collect($tags)
-            ->filter(function ($tag) {
-                return $tag instanceof Tag && $tag->getName() === 'queryParam';
-            })
-            ->mapWithKeys(function ($tag) {
-                preg_match('/(.+?)\s+(required\s+)?(.*)/', $tag->getContent(), $content);
-                $content = preg_replace('/\s?No-example.?/', '', $content);
-                if (empty($content)) {
-                    // this means only name was supplied
-                    list($name) = preg_split('/\s+/', $tag->getContent());
-                    $required = false;
-                    $description = '';
-                } else {
-                    list($_, $name, $required, $description) = $content;
-                    $description = trim($description);
-                    if ($description == 'required' && empty(trim($required))) {
-                        $required = $description;
-                        $description = '';
-                    }
-                    $required = trim($required) == 'required' ? true : false;
-                }
-
-                list($description, $value) = $this->parseDescription($description, 'string');
-                if (is_null($value) && ! $this->shouldExcludeExample($tag)) {
-                    $value = Str::contains($description, ['number', 'count', 'page'])
-                        ? $this->generateDummyValue('integer')
-                        : $this->generateDummyValue('string');
-                }
-
-                return [$name => compact('description', 'required', 'value')];
-            })->toArray();
-
-        return $parameters;
+        return $this->iterateThroughStrategies('bodyParameters', $context, [$route, $controller, $method, $rulesToApply]);
     }
 
-    /**
-     * @param array $tags
-     *
-     * @return bool
-     */
-    protected function getAuthStatusFromDocBlock(array $tags)
+    protected function fetchQueryParameters(ReflectionClass $controller, ReflectionMethod $method, Route $route, array $rulesToApply, array $context = [])
     {
-        $authTag = collect($tags)
-            ->first(function ($tag) {
-                return $tag instanceof Tag && strtolower($tag->getName()) === 'authenticated';
-            });
-
-        return (bool) $authTag;
+        return $this->iterateThroughStrategies('queryParameters', $context, [$route, $controller, $method, $rulesToApply]);
     }
 
-    /**
-     * @param ReflectionMethod $method
-     *
-     * @return array
-     */
-    protected function parseDocBlock(ReflectionMethod $method)
+    protected function fetchResponses(ReflectionClass $controller, ReflectionMethod $method, Route $route, array $rulesToApply, array $context = [])
     {
-        $comment = $method->getDocComment();
-        $phpdoc = new DocBlock($comment);
+        $responses = $this->iterateThroughStrategies('responses', $context, [$route, $controller, $method, $rulesToApply]);
+        if (count($responses)) {
+            return collect($responses)->map(function (string $response, int $status) {
+                return [
+                    'status' => $status ?: 200,
+                    'content' => $response,
+                ];
+            })->values()->toArray();
+        }
 
-        return [
-            'short' => $phpdoc->getShortDescription(),
-            'long' => $phpdoc->getLongDescription()->getContents(),
-            'tags' => $phpdoc->getTags(),
-        ];
+        return null;
     }
 
-    /**
-     * @param ReflectionClass $controller
-     * @param array $methodDocBlock
-     *
-     * @return array The route group name, the group description, ad the route title
-     */
-    protected function getRouteGroup(ReflectionClass $controller, array $methodDocBlock)
+    protected function iterateThroughStrategies(string $stage, array $context, array $arguments)
     {
-        // @group tag on the method overrides that on the controller
-        if (! empty($methodDocBlock['tags'])) {
-            foreach ($methodDocBlock['tags'] as $tag) {
-                if ($tag->getName() === 'group') {
-                    $routeGroupParts = explode("\n", trim($tag->getContent()));
-                    $routeGroupName = array_shift($routeGroupParts);
-                    $routeGroupDescription = trim(implode("\n", $routeGroupParts));
-
-                    // If the route has no title (aka "short"),
-                    // we'll assume the routeGroupDescription is actually the title
-                    // Something like this:
-                    // /**
-                    //   * Fetch cars. <-- This is route title.
-                    //   * @group Cars <-- This is group name.
-                    //   * APIs for cars. <-- This is group description (not required).
-                    //   **/
-                    // VS
-                    // /**
-                    //   * @group Cars <-- This is group name.
-                    //   * Fetch cars. <-- This is route title, NOT group description.
-                    //   **/
+        $strategies = $this->config->get("strategies.$stage", []);
+        $context[$stage] = $context[$stage] ?? [];
+        foreach ($strategies as $strategyClass) {
+            $strategy = new $strategyClass($stage, $this->config);
+            $arguments[] = $context;
+            $results = $strategy(...$arguments);
+            if (! is_null($results)) {
+                foreach ($results as $index => $item) {
+                    // Using a for loop rather than array_merge or +=
+                    // so it does not renumber numeric keys
+                    // and also allows values to be overwritten
 
-                    // BTW, this is a spaghetti way of doing this.
-                    // It shall be refactored soon. Deus vult!💪
-                    if (empty($methodDocBlock['short'])) {
-                        return [$routeGroupName, '', $routeGroupDescription];
+                    // Don't allow overwriting if an empty value is trying to replace a set one
+                    if (! in_array($context[$stage], [null, ''], true) && in_array($item, [null, ''], true)) {
+                        continue;
+                    } else {
+                        $context[$stage][$index] = $item;
                     }
-
-                    return [$routeGroupName, $routeGroupDescription, $methodDocBlock['short']];
-                }
-            }
-        }
-
-        $docBlockComment = $controller->getDocComment();
-        if ($docBlockComment) {
-            $phpdoc = new DocBlock($docBlockComment);
-            foreach ($phpdoc->getTags() as $tag) {
-                if ($tag->getName() === 'group') {
-                    $routeGroupParts = explode("\n", trim($tag->getContent()));
-                    $routeGroupName = array_shift($routeGroupParts);
-                    $routeGroupDescription = implode("\n", $routeGroupParts);
-
-                    return [$routeGroupName, $routeGroupDescription, $methodDocBlock['short']];
                 }
             }
         }
 
-        return [$this->config->get(('default_group')), '', $methodDocBlock['short']];
-    }
-
-    private function normalizeParameterType($type)
-    {
-        $typeMap = [
-            'int' => 'integer',
-            'bool' => 'boolean',
-            'double' => 'float',
-        ];
-
-        return $type ? ($typeMap[$type] ?? $type) : 'string';
-    }
-
-    private function generateDummyValue(string $type)
-    {
-        $faker = Factory::create();
-        if ($this->config->get('faker_seed')) {
-            $faker->seed($this->config->get('faker_seed'));
-        }
-        $fakeFactories = [
-            'integer' => function () use ($faker) {
-                return $faker->numberBetween(1, 20);
-            },
-            'number' => function () use ($faker) {
-                return $faker->randomFloat();
-            },
-            'float' => function () use ($faker) {
-                return $faker->randomFloat();
-            },
-            'boolean' => function () use ($faker) {
-                return $faker->boolean();
-            },
-            'string' => function () use ($faker) {
-                return $faker->word;
-            },
-            'array' => function () {
-                return [];
-            },
-            'object' => function () {
-                return new \stdClass;
-            },
-        ];
-
-        $fakeFactory = $fakeFactories[$type] ?? $fakeFactories['string'];
-
-        return $fakeFactory();
+        return $context[$stage];
     }
 
     /**
-     * Allows users to specify an example for the parameter by writing 'Example: the-example',
-     * to be used in example requests and response calls.
+     * Create samples at index 0 for array parameters.
+     * Also filter out parameters which were excluded from having examples.
      *
-     * @param string $description
-     * @param string $type The type of the parameter. Used to cast the example provided, if any.
+     * @param array $params
      *
-     * @return array The description and included example.
+     * @return array
      */
-    private function parseDescription(string $description, string $type)
+    protected function cleanParams(array $params)
     {
-        $example = null;
-        if (preg_match('/(.*)\s+Example:\s*(.*)\s*/', $description, $content)) {
-            $description = $content[1];
+        $values = [];
 
-            // examples are parsed as strings by default, we need to cast them properly
-            $example = $this->castToType($content[2], $type);
-        }
+        // Remove params which have no examples.
+        $params = array_filter($params, function ($details) {
+            return ! is_null($details['value']);
+        });
 
-        return [$description, $example];
-    }
+        foreach ($params as $paramName => $details) {
+            $this->generateConcreteSampleForArrayKeys(
+                $paramName, $details['value'], $values
+            );
+        }
 
-    /**
-     * Allows users to specify that we shouldn't generate an example for the parameter
-     * by writing 'No-example'.
-     *
-     * @param Tag $tag
-     *
-     * @return bool Whether no example should be generated
-     */
-    private function shouldExcludeExample(Tag $tag)
-    {
-        return strpos($tag->getContent(), ' No-example') !== false;
+        return $values;
     }
 
     /**
-     * Cast a value from a string to a specified type.
+     * For each array notation parameter (eg user.*, item.*.name, object.*.*, user[])
+     * generate concrete sample (user.0, item.0.name, object.0.0, user.0) with example as value.
      *
-     * @param string $value
-     * @param string $type
+     * @param string $paramName
+     * @param mixed $paramExample
+     * @param array $values The array that holds the result
      *
-     * @return mixed
+     * @return void
      */
-    private function castToType(string $value, string $type)
+    protected function generateConcreteSampleForArrayKeys($paramName, $paramExample, array &$values = [])
     {
-        $casts = [
-            'integer' => 'intval',
-            'number' => 'floatval',
-            'float' => 'floatval',
-            'boolean' => 'boolval',
-        ];
-
-        // First, we handle booleans. We can't use a regular cast,
-        //because PHP considers string 'false' as true.
-        if ($value == 'false' && $type == 'boolean') {
-            return false;
+        if (Str::contains($paramName, '[')) {
+            // Replace usages of [] with dot notation
+            $paramName = str_replace(['][', '[', ']', '..'], ['.', '.', '', '.*.'], $paramName);
         }
-
-        if (isset($casts[$type])) {
-            return $casts[$type]($value);
-        }
-
-        return $value;
+        // Then generate a sample item for the dot notation
+        Arr::set($values, str_replace('.*', '.0', $paramName), $paramExample);
     }
 }

+ 0 - 80
src/Tools/ResponseResolver.php

@@ -1,80 +0,0 @@
-<?php
-
-namespace Mpociot\ApiDoc\Tools;
-
-use Illuminate\Routing\Route;
-use Symfony\Component\HttpFoundation\Response;
-use Mpociot\ApiDoc\Tools\ResponseStrategies\ResponseTagStrategy;
-use Mpociot\ApiDoc\Tools\ResponseStrategies\ResponseCallStrategy;
-use Mpociot\ApiDoc\Tools\ResponseStrategies\ResponseFileStrategy;
-use Mpociot\ApiDoc\Tools\ResponseStrategies\TransformerTagsStrategy;
-
-class ResponseResolver
-{
-    /**
-     * @var array
-     */
-    public static $strategies = [
-        ResponseTagStrategy::class,
-        TransformerTagsStrategy::class,
-        ResponseFileStrategy::class,
-        ResponseCallStrategy::class,
-    ];
-
-    /**
-     * @var Route
-     */
-    private $route;
-
-    /**
-     * @param Route $route
-     */
-    public function __construct(Route $route)
-    {
-        $this->route = $route;
-    }
-
-    /**
-     * @param array $tags
-     * @param array $routeProps
-     *
-     * @return array|null
-     */
-    private function resolve(array $tags, array $routeProps)
-    {
-        foreach (static::$strategies as $strategy) {
-            $strategy = new $strategy();
-
-            /** @var Response[]|null $response */
-            $responses = $strategy($this->route, $tags, $routeProps);
-
-            if (! is_null($responses)) {
-                return array_map(function (Response $response) {
-                    return ['status' => $response->getStatusCode(), 'content' => $this->getResponseContent($response)];
-                }, $responses);
-            }
-        }
-    }
-
-    /**
-     * @param Route $route
-     * @param array $tags
-     * @param array $routeProps
-     *
-     * @return array
-     */
-    public static function getResponse(Route $route, array $tags, array $routeProps)
-    {
-        return (new static($route))->resolve($tags, $routeProps);
-    }
-
-    /**
-     * @param Response $response
-     *
-     * @return string
-     */
-    private function getResponseContent(Response $response)
-    {
-        return $response->getContent() ?: '';
-    }
-}

+ 0 - 54
src/Tools/ResponseStrategies/ResponseTagStrategy.php

@@ -1,54 +0,0 @@
-<?php
-
-namespace Mpociot\ApiDoc\Tools\ResponseStrategies;
-
-use Illuminate\Routing\Route;
-use Illuminate\Http\JsonResponse;
-use Mpociot\Reflection\DocBlock\Tag;
-
-/**
- * Get a response from the docblock ( @response ).
- */
-class ResponseTagStrategy
-{
-    /**
-     * @param Route $route
-     * @param array $tags
-     * @param array $routeProps
-     *
-     * @return array|null
-     */
-    public function __invoke(Route $route, array $tags, array $routeProps)
-    {
-        return $this->getDocBlockResponses($tags);
-    }
-
-    /**
-     * Get the response from the docblock if available.
-     *
-     * @param array $tags
-     *
-     * @return array|null
-     */
-    protected function getDocBlockResponses(array $tags)
-    {
-        $responseTags = array_values(
-            array_filter($tags, function ($tag) {
-                return $tag instanceof Tag && strtolower($tag->getName()) === 'response';
-            })
-        );
-
-        if (empty($responseTags)) {
-            return null;
-        }
-
-        return array_map(function (Tag $responseTag) {
-            preg_match('/^(\d{3})?\s?([\s\S]*)$/', $responseTag->getContent(), $result);
-
-            $status = $result[1] ?: 200;
-            $content = $result[2] ?: '{}';
-
-            return new JsonResponse(json_decode($content, true), (int) $status);
-        }, $responseTags);
-    }
-}

+ 64 - 0
src/Tools/RouteDocBlocker.php

@@ -0,0 +1,64 @@
+<?php
+
+namespace Mpociot\ApiDoc\Tools;
+
+use ReflectionClass;
+use Illuminate\Routing\Route;
+use Mpociot\Reflection\DocBlock;
+
+class RouteDocBlocker
+{
+    protected static $docBlocks = [];
+
+    /**
+     * @param Route $route
+     *
+     * @throws \ReflectionException
+     *
+     * @return array<string, DocBlock> Method and class docblocks
+     */
+    public static function getDocBlocksFromRoute(Route $route): array
+    {
+        list($className, $methodName) = Utils::getRouteClassAndMethodNames($route);
+        $docBlocks = self::getCachedDocBlock($route, $className, $methodName);
+        if ($docBlocks) {
+            return $docBlocks;
+        }
+
+        $class = new ReflectionClass($className);
+
+        if (! $class->hasMethod($methodName)) {
+            throw new \Exception("Error while fetching docblock for route: Class $className does not contain method $methodName");
+        }
+
+        $docBlocks = [
+            'method' => new DocBlock($class->getMethod($methodName)->getDocComment() ?: ''),
+            'class' => new DocBlock($class->getDocComment() ?: ''),
+        ];
+        self::cacheDocBlocks($route, $className, $methodName, $docBlocks);
+
+        return $docBlocks;
+    }
+
+    protected static function getCachedDocBlock(Route $route, string $className, string $methodName)
+    {
+        $routeId = self::getRouteCacheId($route, $className, $methodName);
+
+        return self::$docBlocks[$routeId] ?? null;
+    }
+
+    protected static function cacheDocBlocks(Route $route, string $className, string $methodName, array $docBlocks)
+    {
+        $routeId = self::getRouteCacheId($route, $className, $methodName);
+        self::$docBlocks[$routeId] = $docBlocks;
+    }
+
+    private static function getRouteCacheId(Route $route, string $className, string $methodName): string
+    {
+        return $route->uri()
+            .':'
+            .implode(array_diff($route->methods(), ['HEAD']))
+            .$className
+            .$methodName;
+    }
+}

+ 45 - 0
src/Tools/Traits/DocBlockParamHelpers.php

@@ -0,0 +1,45 @@
+<?php
+
+namespace Mpociot\ApiDoc\Tools\Traits;
+
+use Mpociot\Reflection\DocBlock\Tag;
+
+trait DocBlockParamHelpers
+{
+    use ParamHelpers;
+
+    /**
+     * Allows users to specify that we shouldn't generate an example for the parameter
+     * by writing 'No-example'.
+     *
+     * @param Tag $tag
+     *
+     * @return bool Whether no example should be generated
+     */
+    protected function shouldExcludeExample(Tag $tag)
+    {
+        return strpos($tag->getContent(), ' No-example') !== false;
+    }
+
+    /**
+     * Allows users to specify an example for the parameter by writing 'Example: the-example',
+     * to be used in example requests and response calls.
+     *
+     * @param string $description
+     * @param string $type The type of the parameter. Used to cast the example provided, if any.
+     *
+     * @return array The description and included example.
+     */
+    protected function parseParamDescription(string $description, string $type)
+    {
+        $example = null;
+        if (preg_match('/(.*)\s+Example:\s*(.*)\s*/', $description, $content)) {
+            $description = $content[1];
+
+            // examples are parsed as strings by default, we need to cast them properly
+            $example = $this->castToType($content[2], $type);
+        }
+
+        return [$description, $example];
+    }
+}

+ 68 - 23
src/Tools/Traits/ParamHelpers.php

@@ -2,46 +2,91 @@
 
 namespace Mpociot\ApiDoc\Tools\Traits;
 
-use Illuminate\Support\Arr;
-use Illuminate\Support\Str;
+use Faker\Factory;
 
 trait ParamHelpers
 {
+    protected function generateDummyValue(string $type)
+    {
+        $faker = Factory::create();
+        if ($this->config->get('faker_seed')) {
+            $faker->seed($this->config->get('faker_seed'));
+        }
+        $fakeFactories = [
+            'integer' => function () use ($faker) {
+                return $faker->numberBetween(1, 20);
+            },
+            'number' => function () use ($faker) {
+                return $faker->randomFloat();
+            },
+            'float' => function () use ($faker) {
+                return $faker->randomFloat();
+            },
+            'boolean' => function () use ($faker) {
+                return $faker->boolean();
+            },
+            'string' => function () use ($faker) {
+                return $faker->word;
+            },
+            'array' => function () {
+                return [];
+            },
+            'object' => function () {
+                return new \stdClass;
+            },
+        ];
+
+        $fakeFactory = $fakeFactories[$type] ?? $fakeFactories['string'];
+
+        return $fakeFactory();
+    }
+
     /**
-     * Create proper arrays from dot-noted parameter names. Also filter out parameters which were excluded from having examples.
+     * Cast a value from a string to a specified type.
      *
-     * @param array $params
+     * @param string $value
+     * @param string $type
      *
-     * @return array
+     * @return mixed
      */
-    protected function cleanParams(array $params)
+    protected function castToType(string $value, string $type)
     {
-        $values = [];
-        $params = array_filter($params, function ($details) {
-            return ! is_null($details['value']);
-        });
+        $casts = [
+            'integer' => 'intval',
+            'number' => 'floatval',
+            'float' => 'floatval',
+            'boolean' => 'boolval',
+        ];
+
+        // First, we handle booleans. We can't use a regular cast,
+        //because PHP considers string 'false' as true.
+        if ($value == 'false' && $type == 'boolean') {
+            return false;
+        }
 
-        foreach ($params as $name => $details) {
-            $this->cleanValueFrom($name, $details['value'], $values);
+        if (isset($casts[$type])) {
+            return $casts[$type]($value);
         }
 
-        return $values;
+        return $value;
     }
 
     /**
-     * Converts dot notation names to arrays and sets the value at the right depth.
+     * Normalizes the stated "type" of a parameter (eg "int", "integer", "double")
+     * to a number of standard types (integer, boolean, float).
      *
-     * @param string $name
-     * @param mixed $value
-     * @param array $values The array that holds the result
+     * @param string $type
      *
-     * @return void
+     * @return mixed|string
      */
-    protected function cleanValueFrom($name, $value, array &$values = [])
+    protected function normalizeParameterType(string $type)
     {
-        if (Str::contains($name, '[')) {
-            $name = str_replace(['][', '[', ']', '..'], ['.', '.', '', '.*.'], $name);
-        }
-        Arr::set($values, str_replace('.*', '.0', $name), $value);
+        $typeMap = [
+            'int' => 'integer',
+            'bool' => 'boolean',
+            'double' => 'float',
+        ];
+
+        return $type ? ($typeMap[$type] ?? $type) : 'string';
     }
 }

+ 4 - 2
src/Tools/Utils.php

@@ -19,12 +19,14 @@ class Utils
     }
 
     /**
-     * @param array $action
+     * @param array|Route $routeOrAction
      *
      * @return array|null
      */
-    public static function getRouteActionUses(array $action)
+    public static function getRouteClassAndMethodNames($routeOrAction)
     {
+        $action = $routeOrAction instanceof Route ? $routeOrAction->getAction() : $routeOrAction;
+
         if ($action['uses'] !== null) {
             if (is_array($action['uses'])) {
                 return $action['uses'];

+ 8 - 3
tests/Fixtures/TestController.php

@@ -30,7 +30,7 @@ class TestController extends Controller
      */
     public function withGroupOverride()
     {
-        return '';
+        return 'Group B, baby!';
     }
 
     /**
@@ -66,6 +66,8 @@ class TestController extends Controller
     }
 
     /**
+     * Endpoint with body parameters.
+     *
      * @bodyParam user_id int required The id of the user. Example: 9
      * @bodyParam room_id string The id of the room.
      * @bodyParam forever boolean Whether to ban the user forever. Example: false
@@ -152,6 +154,7 @@ class TestController extends Controller
             'color' => strtolower($fruit->color),
             'weight' => $fruit->weight.' kg',
             'delicious' => $fruit->delicious,
+            'responseCall' => true,
         ];
     }
 
@@ -203,7 +206,8 @@ class TestController extends Controller
      *   "name": "banana",
      *   "color": "red",
      *   "weight": "1 kg",
-     *   "delicious": true
+     *   "delicious": true,
+     *   "responseTag": true
      * }
      */
     public function withResponseTag()
@@ -227,7 +231,8 @@ class TestController extends Controller
      *   "name": "banana",
      *   "color": "red",
      *   "weight": "1 kg",
-     *   "delicious": true
+     *   "delicious": true,
+     *   "multipleResponseTagsAndStatusCodes": true
      * }
      * @response 401 {
      *   "message": "Unauthorized"

+ 2 - 2
tests/Fixtures/TestResourceController.php

@@ -64,7 +64,7 @@ class TestResourceController extends Controller
     public function show($id)
     {
         return [
-            'show_resource' => $id,
+            'show_resource' => true,
         ];
     }
 
@@ -82,7 +82,7 @@ class TestResourceController extends Controller
     public function edit($id)
     {
         return [
-            'edit_resource' => $id,
+            'edit_resource' => true,
         ];
     }
 

+ 203 - 6
tests/Fixtures/collection.json

@@ -14,12 +14,20 @@
                 {
                     "name": "Example title.",
                     "request": {
-                        "url": "http:\/\/localhost\/api\/test",
+                        "url": "http:\/\/localhost\/api\/withDescription",
                         "method": "GET",
                         "header": [
+                            {
+                                "key": "Authorization",
+                                "value": "customAuthToken"
+                            },
+                            {
+                                "key": "Custom-Header",
+                                "value": "NotSoCustom"
+                            },
                             {
                                 "key": "Accept",
-                                "value": "application/json"
+                                "value": "application\/json"
                             }
                         ],
                         "body": {
@@ -31,14 +39,203 @@
                     }
                 },
                 {
-                    "name": "http:\/\/localhost\/api\/responseTag",
+                    "name": "http:\/\/localhost\/api\/withResponseTag",
+                    "request": {
+                        "url": "http:\/\/localhost\/api\/withResponseTag",
+                        "method": "GET",
+                        "header": [
+                            {
+                                "key": "Authorization",
+                                "value": "customAuthToken"
+                            },
+                            {
+                                "key": "Custom-Header",
+                                "value": "NotSoCustom"
+                            },
+                            {
+                                "key": "Accept",
+                                "value": "application\/json"
+                            }
+                        ],
+                        "body": {
+                            "mode": "formdata",
+                            "formdata": []
+                        },
+                        "description": "",
+                        "response": []
+                    }
+                },
+                {
+                    "name": "Endpoint with body parameters.",
                     "request": {
-                        "url": "http:\/\/localhost\/api\/responseTag",
+                        "url": "http:\/\/localhost\/api\/withBodyParameters",
+                        "method": "GET",
+                        "header": [
+                            {
+                                "key": "Authorization",
+                                "value": "customAuthToken"
+                            },
+                            {
+                                "key": "Custom-Header",
+                                "value": "NotSoCustom"
+                            },
+                            {
+                                "key": "Accept",
+                                "value": "application\/json"
+                            }
+                        ],
+                        "body": {
+                            "mode": "formdata",
+                            "formdata": [
+                                {
+                                    "key": "user_id",
+                                    "value": 9,
+                                    "type": "text",
+                                    "enabled": true
+                                },
+                                {
+                                    "key": "room_id",
+                                    "value": "consequatur",
+                                    "type": "text",
+                                    "enabled": true
+                                },
+                                {
+                                    "key": "forever",
+                                    "value": false,
+                                    "type": "text",
+                                    "enabled": true
+                                },
+                                {
+                                    "key": "another_one",
+                                    "value": 11613.31890586,
+                                    "type": "text",
+                                    "enabled": true
+                                },
+                                {
+                                    "key": "yet_another_param",
+                                    "value": {},
+                                    "type": "text",
+                                    "enabled": true
+                                },
+                                {
+                                    "key": "even_more_param",
+                                    "value": [],
+                                    "type": "text",
+                                    "enabled": true
+                                },
+                                {
+                                    "key": "book.name",
+                                    "value": "consequatur",
+                                    "type": "text",
+                                    "enabled": true
+                                },
+                                {
+                                    "key": "book.author_id",
+                                    "value": 17,
+                                    "type": "text",
+                                    "enabled": true
+                                },
+                                {
+                                    "key": "book[pages_count]",
+                                    "value": 17,
+                                    "type": "text",
+                                    "enabled": true
+                                },
+                                {
+                                    "key": "ids.*",
+                                    "value": 17,
+                                    "type": "text",
+                                    "enabled": true
+                                },
+                                {
+                                    "key": "users.*.first_name",
+                                    "value": "John",
+                                    "type": "text",
+                                    "enabled": true
+                                },
+                                {
+                                    "key": "users.*.last_name",
+                                    "value": "Doe",
+                                    "type": "text",
+                                    "enabled": true
+                                }
+                            ]
+                        },
+                        "description": "",
+                        "response": []
+                    }
+                },
+                {
+                    "name": "http:\/\/localhost\/api\/withQueryParameters",
+                    "request": {
+                        "url": "http:\/\/localhost\/api\/withQueryParameters?location_id=consequatur&user_id=me&page=4&filters=consequatur&url_encoded=%2B+%5B%5D%26%3D",
+                        "method": "GET",
+                        "header": [
+                            {
+                                "key": "Authorization",
+                                "value": "customAuthToken"
+                            },
+                            {
+                                "key": "Custom-Header",
+                                "value": "NotSoCustom"
+                            },
+                            {
+                                "key": "Accept",
+                                "value": "application\/json"
+                            }
+                        ],
+                        "body": {
+                            "mode": "formdata",
+                            "formdata": []
+                        },
+                        "description": "",
+                        "response": []
+                    }
+                },
+                {
+                    "name": "http:\/\/localhost\/api\/withAuthTag",
+                    "request": {
+                        "url": "http:\/\/localhost\/api\/withAuthTag",
+                        "method": "GET",
+                        "header": [
+                            {
+                                "key": "Authorization",
+                                "value": "customAuthToken"
+                            },
+                            {
+                                "key": "Custom-Header",
+                                "value": "NotSoCustom"
+                            },
+                            {
+                                "key": "Accept",
+                                "value": "application\/json"
+                            }
+                        ],
+                        "body": {
+                            "mode": "formdata",
+                            "formdata": []
+                        },
+                        "description": "",
+                        "response": []
+                    }
+                },
+                {
+                    "name": "http:\/\/localhost\/api\/withMultipleResponseTagsAndStatusCode",
+                    "request": {
+                        "url": "http:\/\/localhost\/api\/withMultipleResponseTagsAndStatusCode",
                         "method": "POST",
                         "header": [
+                            {
+                                "key": "Authorization",
+                                "value": "customAuthToken"
+                            },
+                            {
+                                "key": "Custom-Header",
+                                "value": "NotSoCustom"
+                            },
                             {
                                 "key": "Accept",
-                                "value": "application/json"
+                                "value": "application\/json"
                             }
                         ],
                         "body": {
@@ -52,4 +249,4 @@
             ]
         }
     ]
-}
+}

+ 2 - 2
tests/Fixtures/collection_with_body_parameters.json

@@ -12,7 +12,7 @@
             "description": "",
             "item": [
                 {
-                    "name": "http://localhost/api/withBodyParameters",
+                    "name": "Endpoint with body parameters.",
                     "request": {
                         "url": "http:\/\/localhost\/api\/withBodyParameters",
                         "method": "GET",
@@ -106,4 +106,4 @@
             ]
         }
     ]
-}
+}

+ 63 - 6
tests/Fixtures/index.md

@@ -56,7 +56,7 @@ fetch(url, {
 ```
 
 
-> Example response (200):
+> Example response:
 
 ```json
 null
@@ -105,7 +105,8 @@ fetch(url, {
     "name": "banana",
     "color": "red",
     "weight": "1 kg",
-    "delicious": true
+    "delicious": true,
+    "responseTag": true
 }
 ```
 
@@ -116,7 +117,8 @@ fetch(url, {
 <!-- END_9cedd363be06f5512f9e844b100fcc9d -->
 
 <!-- START_a25cb3b490fa579d7d77b386bbb7ec03 -->
-## api/withBodyParameters
+## Endpoint with body parameters.
+
 > Example request:
 
 ```bash
@@ -171,7 +173,7 @@ fetch(url, {
 ```
 
 
-> Example response (200):
+> Example response:
 
 ```json
 null
@@ -237,7 +239,7 @@ fetch(url, {
 ```
 
 
-> Example response (200):
+> Example response:
 
 ```json
 null
@@ -288,7 +290,7 @@ fetch(url, {
 ```
 
 
-> Example response (200):
+> Example response:
 
 ```json
 null
@@ -300,4 +302,59 @@ null
 
 <!-- END_5c08cc4d72b6e5830f6814c64086e197 -->
 
+<!-- START_55f321056bfc0de7269ac70e24eb84be -->
+## api/withMultipleResponseTagsAndStatusCode
+> Example request:
+
+```bash
+curl -X POST "http://localhost/api/withMultipleResponseTagsAndStatusCode" \
+    -H "Authorization: customAuthToken" \
+    -H "Custom-Header: NotSoCustom"
+```
+
+```javascript
+const url = new URL("http://localhost/api/withMultipleResponseTagsAndStatusCode");
+
+let headers = {
+    "Authorization": "customAuthToken",
+    "Custom-Header": "NotSoCustom",
+    "Accept": "application/json",
+    "Content-Type": "application/json",
+}
+
+fetch(url, {
+    method: "POST",
+    headers: headers,
+})
+    .then(response => response.json())
+    .then(json => console.log(json));
+```
+
+
+> Example response (200):
+
+```json
+{
+    "id": 4,
+    "name": "banana",
+    "color": "red",
+    "weight": "1 kg",
+    "delicious": true,
+    "multipleResponseTagsAndStatusCodes": true
+}
+```
+> Example response (401):
+
+```json
+{
+    "message": "Unauthorized"
+}
+```
+
+### HTTP Request
+`POST api/withMultipleResponseTagsAndStatusCode`
+
+
+<!-- END_55f321056bfc0de7269ac70e24eb84be -->
+
 

+ 16 - 2
tests/GenerateDocumentationTest.php

@@ -176,6 +176,7 @@ class GenerateDocumentationTest extends TestCase
         RouteFacade::get('/api/withBodyParameters', TestController::class.'@withBodyParameters');
         RouteFacade::get('/api/withQueryParameters', TestController::class.'@withQueryParameters');
         RouteFacade::get('/api/withAuthTag', TestController::class.'@withAuthenticatedTag');
+        RouteFacade::post('/api/withMultipleResponseTagsAndStatusCode', [TestController::class, 'withMultipleResponseTagsAndStatusCode']);
 
         // We want to have the same values for params each time
         config(['apidoc.faker_seed' => 1234]);
@@ -220,10 +221,23 @@ class GenerateDocumentationTest extends TestCase
     /** @test */
     public function generated_postman_collection_file_is_correct()
     {
-        RouteFacade::get('/api/test', TestController::class.'@withEndpointDescription');
-        RouteFacade::post('/api/responseTag', TestController::class.'@withResponseTag');
+        RouteFacade::get('/api/withDescription', TestController::class.'@withEndpointDescription');
+        RouteFacade::get('/api/withResponseTag', TestController::class.'@withResponseTag');
+        RouteFacade::get('/api/withBodyParameters', TestController::class.'@withBodyParameters');
+        RouteFacade::get('/api/withQueryParameters', TestController::class.'@withQueryParameters');
+        RouteFacade::get('/api/withAuthTag', TestController::class.'@withAuthenticatedTag');
+        RouteFacade::post('/api/withMultipleResponseTagsAndStatusCode', [TestController::class, 'withMultipleResponseTagsAndStatusCode']);
 
+        // We want to have the same values for params each time
+        config(['apidoc.faker_seed' => 1234]);
         config(['apidoc.routes.0.match.prefixes' => ['api/*']]);
+        config([
+            'apidoc.routes.0.apply.headers' => [
+                'Authorization' => 'customAuthToken',
+                'Custom-Header' => 'NotSoCustom',
+            ],
+        ]);
+
         $this->artisan('apidoc:generate');
 
         $generatedCollection = json_decode(file_get_contents(__DIR__.'/../public/docs/collection.json'), true);

+ 6 - 6
tests/Unit/DingoGeneratorTest.php

@@ -23,25 +23,25 @@ class DingoGeneratorTest extends GeneratorTestCase
         config(['apidoc.router' => 'dingo']);
     }
 
-    public function createRoute(string $httpMethod, string $path, string $controllerMethod, $register = false)
+    public function createRoute(string $httpMethod, string $path, string $controllerMethod, $register = false, $class = TestController::class)
     {
         $route = null;
         /** @var Router $api */
         $api = app(Router::class);
-        $api->version('v1', function (Router $api) use ($controllerMethod, $path, $httpMethod, &$route) {
-            $route = $api->$httpMethod($path, TestController::class."@$controllerMethod");
+        $api->version('v1', function (Router $api) use ($class, $controllerMethod, $path, $httpMethod, &$route) {
+            $route = $api->$httpMethod($path, $class."@$controllerMethod");
         });
 
         return $route;
     }
 
-    public function createRouteUsesArray(string $httpMethod, string $path, string $controllerMethod, $register = false)
+    public function createRouteUsesArray(string $httpMethod, string $path, string $controllerMethod, $register = false, $class = TestController::class)
     {
         $route = null;
         /** @var Router $api */
         $api = app(Router::class);
-        $api->version('v1', function (Router $api) use ($controllerMethod, $path, $httpMethod, &$route) {
-            $route = $api->$httpMethod($path, [TestController::class, $controllerMethod]);
+        $api->version('v1', function (Router $api) use ($class, $controllerMethod, $path, $httpMethod, &$route) {
+            $route = $api->$httpMethod($path, [$class, $controllerMethod]);
         });
 
         return $route;

+ 262 - 0
tests/Unit/GeneratorPluginSystemTestCase.php

@@ -0,0 +1,262 @@
+<?php
+
+namespace Mpociot\ApiDoc\Tests\Unit;
+
+use ReflectionClass;
+use ReflectionMethod;
+use Illuminate\Routing\Route;
+use Mpociot\ApiDoc\Tools\Generator;
+use Mpociot\ApiDoc\Strategies\Strategy;
+use Mpociot\ApiDoc\Tools\DocumentationConfig;
+use Mpociot\ApiDoc\Tests\Fixtures\TestController;
+use Mpociot\ApiDoc\ApiDocGeneratorServiceProvider;
+
+class GeneratorPluginSystemTestCase extends LaravelGeneratorTest
+{
+    /**
+     * @var \Mpociot\ApiDoc\Tools\Generator
+     */
+    protected $generator;
+
+    protected function getPackageProviders($app)
+    {
+        return [
+            ApiDocGeneratorServiceProvider::class,
+        ];
+    }
+
+    /** @test */
+    public function only_specified_strategies_are_loaded()
+    {
+        $config = [
+            'strategies' => [
+                'metadata' => [EmptyStrategy1::class],
+                'bodyParameters' => [
+                    EmptyStrategy1::class,
+                    EmptyStrategy2::class,
+                ],
+                'responses' => [EmptyStrategy1::class],
+            ],
+        ];
+        $route = $this->createRoute('GET', '/api/test', 'dummy', true, TestController::class);
+        $generator = new Generator(new DocumentationConfig($config));
+        $generator->processRoute($route);
+
+        // Probably not the best way to do this, but 🤷‍♂️
+        $this->assertTrue(EmptyStrategy1::$called['metadata']);
+
+        $this->assertTrue(EmptyStrategy1::$called['bodyParameters']);
+        $this->assertTrue(EmptyStrategy2::$called['bodyParameters']);
+
+        $this->assertArrayNotHasKey('queryParameters', EmptyStrategy1::$called);
+
+        $this->assertTrue(EmptyStrategy1::$called['responses']);
+    }
+
+    /** @test */
+    public function combines_responses_from_different_strategies()
+    {
+        $config = [
+            'strategies' => [
+                'responses' => [DummyResponseStrategy200::class, DummyResponseStrategy400::class],
+            ],
+        ];
+        $route = $this->createRoute('GET', '/api/test', 'dummy', true, TestController::class);
+        $generator = new Generator(new DocumentationConfig($config));
+        $parsed = $generator->processRoute($route);
+
+        $this->assertTrue($parsed['showresponse']);
+        $this->assertCount(2, $parsed['response']);
+        $first = array_shift($parsed['response']);
+        $this->assertTrue(is_array($first));
+        $this->assertEquals(200, $first['status']);
+        $this->assertEquals('dummy', $first['content']);
+        $second = array_shift($parsed['response']);
+        $this->assertTrue(is_array($second));
+        $this->assertEquals(400, $second['status']);
+        $this->assertEquals('dummy2', $second['content']);
+    }
+
+    // This is a generalized test, as opposed to the one above for responses only
+
+    /** @test */
+    public function combines_results_from_different_strategies_in_same_stage()
+    {
+        $config = [
+            'strategies' => [
+                'metadata' => [PartialDummyMetadataStrategy1::class, PartialDummyMetadataStrategy2::class],
+            ],
+        ];
+        $route = $this->createRoute('GET', '/api/test', 'dummy', true, TestController::class);
+        $generator = new Generator(new DocumentationConfig($config));
+        $parsed = $generator->processRoute($route);
+
+        $expectedMetadata = [
+            'groupName' => 'dummy',
+            'groupDescription' => 'dummy',
+            'title' => 'dummy',
+            'description' => 'dummy',
+            'authenticated' => false,
+        ];
+        $this->assertArraySubset($expectedMetadata, $parsed['metadata']); // Forwards-compatibility
+        $this->assertArraySubset($expectedMetadata, $parsed); // Backwards-compatibility
+    }
+
+    /** @test */
+    public function missing_metadata_is_filled_in()
+    {
+        $config = [
+            'strategies' => [
+                'metadata' => [PartialDummyMetadataStrategy2::class],
+            ],
+        ];
+        $route = $this->createRoute('GET', '/api/test', 'dummy', true, TestController::class);
+        $generator = new Generator(new DocumentationConfig($config));
+        $parsed = $generator->processRoute($route);
+
+        $expectedMetadata = [
+            'groupName' => '',
+            'groupDescription' => 'dummy',
+            'title' => '',
+            'description' => 'dummy',
+            'authenticated' => false,
+        ];
+        $this->assertArraySubset($expectedMetadata, $parsed['metadata']); // Forwards-compatibility
+        $this->assertArraySubset($expectedMetadata, $parsed); // Backwards-compatibility
+    }
+
+    /** @test */
+    public function overwrites_results_from_previous_strategies_in_same_stage()
+    {
+        $config = [
+            'strategies' => [
+                'responses' => [DummyResponseStrategy200::class, StillDummyResponseStrategyAlso200::class],
+            ],
+        ];
+        $route = $this->createRoute('GET', '/api/test', 'dummy', true, TestController::class);
+        $generator = new Generator(new DocumentationConfig($config));
+        $parsed = $generator->processRoute($route);
+
+        $this->assertTrue($parsed['showresponse']);
+        $this->assertCount(1, $parsed['response']);
+        $first = array_shift($parsed['response']);
+        $this->assertTrue(is_array($first));
+        $this->assertEquals(200, $first['status']);
+        $this->assertEquals('stilldummy', $first['content']);
+
+        $config = [
+            'strategies' => [
+                'metadata' => [NotDummyMetadataStrategy::class, PartialDummyMetadataStrategy1::class],
+            ],
+        ];
+        $route = $this->createRoute('GET', '/api/test', 'dummy', true, TestController::class);
+        $generator = new Generator(new DocumentationConfig($config));
+        $parsed = $generator->processRoute($route);
+
+        $expectedMetadata = [
+            'groupName' => 'dummy',
+            'groupDescription' => 'notdummy',
+            'title' => 'dummy',
+            'description' => 'dummy',
+            'authenticated' => false,
+        ];
+        $this->assertArraySubset($expectedMetadata, $parsed['metadata']); // Forwards-compatibility
+        $this->assertArraySubset($expectedMetadata, $parsed); // Backwards-compatibility
+    }
+
+    public function dataResources()
+    {
+        return [
+            [
+                null,
+                '{"data":{"id":1,"description":"Welcome on this test versions","name":"TestName"}}',
+            ],
+            [
+                'League\Fractal\Serializer\JsonApiSerializer',
+                '{"data":{"type":null,"id":"1","attributes":{"description":"Welcome on this test versions","name":"TestName"}}}',
+            ],
+        ];
+    }
+}
+
+class EmptyStrategy1 extends Strategy
+{
+    public static $called = [];
+
+    public function __invoke(Route $route, ReflectionClass $controller, ReflectionMethod $method, array $routeRules, array $context = [])
+    {
+        static::$called[$this->stage] = true;
+    }
+}
+
+class EmptyStrategy2 extends Strategy
+{
+    public static $called = [];
+
+    public function __invoke(Route $route, ReflectionClass $controller, ReflectionMethod $method, array $routeRules, array $context = [])
+    {
+        static::$called[$this->stage] = true;
+    }
+}
+
+class NotDummyMetadataStrategy extends Strategy
+{
+    public function __invoke(Route $route, ReflectionClass $controller, ReflectionMethod $method, array $routeRules, array $context = [])
+    {
+        return [
+            'groupName' => 'notdummy',
+            'groupDescription' => 'notdummy',
+            'title' => 'notdummy',
+            'description' => 'notdummy',
+            'authenticated' => true,
+        ];
+    }
+}
+
+class PartialDummyMetadataStrategy1 extends Strategy
+{
+    public function __invoke(Route $route, ReflectionClass $controller, ReflectionMethod $method, array $routeRules, array $context = [])
+    {
+        return [
+            'groupName' => 'dummy',
+            'title' => 'dummy',
+            'description' => 'dummy',
+            'authenticated' => false,
+        ];
+    }
+}
+
+class PartialDummyMetadataStrategy2 extends Strategy
+{
+    public function __invoke(Route $route, ReflectionClass $controller, ReflectionMethod $method, array $routeRules, array $context = [])
+    {
+        return [
+            'description' => 'dummy',
+            'groupDescription' => 'dummy',
+        ];
+    }
+}
+
+class DummyResponseStrategy200 extends Strategy
+{
+    public function __invoke(Route $route, ReflectionClass $controller, ReflectionMethod $method, array $routeRules, array $context = [])
+    {
+        return [200 => 'dummy'];
+    }
+}
+
+class StillDummyResponseStrategyAlso200 extends Strategy
+{
+    public function __invoke(Route $route, ReflectionClass $controller, ReflectionMethod $method, array $routeRules, array $context = [])
+    {
+        return [200 => 'stilldummy'];
+    }
+}
+
+class DummyResponseStrategy400 extends Strategy
+{
+    public function __invoke(Route $route, ReflectionClass $controller, ReflectionMethod $method, array $routeRules, array $context = [])
+    {
+        return [400 => 'dummy2'];
+    }
+}

+ 55 - 6
tests/Unit/GeneratorTestCase.php

@@ -6,7 +6,9 @@ use Illuminate\Support\Arr;
 use Orchestra\Testbench\TestCase;
 use Mpociot\ApiDoc\Tools\Generator;
 use Mpociot\ApiDoc\Tools\DocumentationConfig;
+use Mpociot\ApiDoc\Tests\Fixtures\TestController;
 use Mpociot\ApiDoc\ApiDocGeneratorServiceProvider;
+use Mpociot\ApiDoc\Tests\Fixtures\TestResourceController;
 
 abstract class GeneratorTestCase extends TestCase
 {
@@ -14,6 +16,27 @@ abstract class GeneratorTestCase extends TestCase
      * @var \Mpociot\ApiDoc\Tools\Generator
      */
     protected $generator;
+    private $config = [
+        'strategies' => [
+            'metadata' => [
+                \Mpociot\ApiDoc\Strategies\Metadata\GetFromDocBlocks::class,
+            ],
+            'bodyParameters' => [
+                \Mpociot\ApiDoc\Strategies\BodyParameters\GetFromBodyParamTag::class,
+            ],
+            'queryParameters' => [
+                \Mpociot\ApiDoc\Strategies\QueryParameters\GetFromQueryParamTag::class,
+            ],
+            'responses' => [
+                \Mpociot\ApiDoc\Strategies\Responses\UseResponseTag::class,
+                \Mpociot\ApiDoc\Strategies\Responses\UseResponseFileTag::class,
+                \Mpociot\ApiDoc\Strategies\Responses\UseTransformerTags::class,
+                \Mpociot\ApiDoc\Strategies\Responses\ResponseCalls::class,
+            ],
+        ],
+        'default_group' => 'general',
+
+    ];
 
     protected function getPackageProviders($app)
     {
@@ -29,7 +52,7 @@ abstract class GeneratorTestCase extends TestCase
     {
         parent::setUp();
 
-        $this->generator = new Generator();
+        $this->generator = new Generator(new DocumentationConfig($this->config));
     }
 
     /** @test */
@@ -394,8 +417,8 @@ abstract class GeneratorTestCase extends TestCase
         $this->assertTrue(is_array($response));
         $this->assertEquals(200, $response['status']);
         $this->assertSame(
-            $response['content'],
-            $expected
+            $expected,
+            $response['content']
         );
     }
 
@@ -683,7 +706,7 @@ abstract class GeneratorTestCase extends TestCase
         // Examples should have different values
         $this->assertNotEquals(count($results), 1);
 
-        $generator = new Generator(new DocumentationConfig(['faker_seed' => 12345]));
+        $generator = new Generator(new DocumentationConfig($this->config + ['faker_seed' => 12345]));
         $results = [];
         $results[$generator->processRoute($route)['cleanBodyParameters'][$paramName]] = true;
         $results[$generator->processRoute($route)['cleanBodyParameters'][$paramName]] = true;
@@ -743,9 +766,35 @@ abstract class GeneratorTestCase extends TestCase
         $this->assertSame("This will be the long description.\nIt can also be multiple lines long.", $parsed['description']);
     }
 
-    abstract public function createRoute(string $httpMethod, string $path, string $controllerMethod, $register = false);
+    /** @test */
+    public function combines_responses_from_different_strategies()
+    {
+        $route = $this->createRoute('GET', '/api/indexResource', 'index', true, TestResourceController::class);
+        $rules = [
+            'response_calls' => [
+                'methods' => ['*'],
+                'headers' => [
+                    'Accept' => 'application/json',
+                ],
+            ],
+        ];
+
+        $parsed = $this->generator->processRoute($route, $rules);
+
+        $this->assertTrue(is_array($parsed));
+        $this->assertArrayHasKey('showresponse', $parsed);
+        $this->assertTrue($parsed['showresponse']);
+        $this->assertSame(1, count($parsed['response']));
+        $this->assertTrue(is_array($parsed['response'][0]));
+        $this->assertEquals(200, $parsed['response'][0]['status']);
+        $this->assertArraySubset([
+            'index_resource' => true,
+        ], json_decode($parsed['response'][0]['content'], true));
+    }
+
+    abstract public function createRoute(string $httpMethod, string $path, string $controllerMethod, $register = false, $class = TestController::class);
 
-    abstract public function createRouteUsesArray(string $httpMethod, string $path, string $controllerMethod, $register = false);
+    abstract public function createRouteUsesArray(string $httpMethod, string $path, string $controllerMethod, $register = false, $class = TestController::class);
 
     public function dataResources()
     {

+ 6 - 6
tests/Unit/LaravelGeneratorTest.php

@@ -16,21 +16,21 @@ class LaravelGeneratorTest extends GeneratorTestCase
         ];
     }
 
-    public function createRoute(string $httpMethod, string $path, string $controllerMethod, $register = false)
+    public function createRoute(string $httpMethod, string $path, string $controllerMethod, $register = false, $class = TestController::class)
     {
         if ($register) {
-            return RouteFacade::{$httpMethod}($path, TestController::class."@$controllerMethod");
+            return RouteFacade::{$httpMethod}($path, $class."@$controllerMethod");
         } else {
-            return new Route([$httpMethod], $path, ['uses' => TestController::class."@$controllerMethod"]);
+            return new Route([$httpMethod], $path, ['uses' => $class."@$controllerMethod"]);
         }
     }
 
-    public function createRouteUsesArray(string $httpMethod, string $path, string $controllerMethod, $register = false)
+    public function createRouteUsesArray(string $httpMethod, string $path, string $controllerMethod, $register = false, $class = TestController::class)
     {
         if ($register) {
-            return RouteFacade::{$httpMethod}($path, TestController::class."@$controllerMethod");
+            return RouteFacade::{$httpMethod}($path, [$class."$controllerMethod"]);
         } else {
-            return new Route([$httpMethod], $path, ['uses' => [TestController::class, $controllerMethod]]);
+            return new Route([$httpMethod], $path, ['uses' => [$class, $controllerMethod]]);
         }
     }
 }