Explorar o código

Improve plugin API

shalvah %!s(int64=5) %!d(string=hai) anos
pai
achega
c3cc56177f

+ 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

+ 108 - 0
docs/plugins.md

@@ -0,0 +1,108 @@
+# 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 group name, route group description, and authentication status)
+- 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.
+ 
+ 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\GetFromDocBlocks::class,
+        ],
+        'queryParameters' => [
+            \Mpociot\ApiDoc\Strategies\QueryParameters\GetFromDocBlocks::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\GetFromDocBlocks::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 deafult group with `$this->config->get('default_group')`.

+ 8 - 3
src/Strategies/Responses/ResponseCalls.php

@@ -30,7 +30,7 @@ class ResponseCalls extends Strategy
     public function __invoke(Route $route, \ReflectionClass $controller, \ReflectionMethod $method, array $routeRules, array $context = [])
     {
         $rulesToApply = $routeRules['response_calls'] ?? [];
-        if (! $this->shouldMakeApiCall($route, $rulesToApply)) {
+        if (! $this->shouldMakeApiCall($route, $rulesToApply, $context)) {
             return null;
         }
 
@@ -42,7 +42,7 @@ class ResponseCalls extends Strategy
         $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) {
@@ -297,13 +297,18 @@ class ResponseCalls extends Strategy
      *
      * @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;
         }

+ 5 - 2
src/Strategies/Responses/UseResponseFileTag.php

@@ -52,14 +52,17 @@ class UseResponseFileTag extends Strategy
             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();
     }
 }

+ 5 - 2
src/Strategies/Responses/UseResponseTag.php

@@ -51,13 +51,16 @@ class UseResponseTag extends Strategy
             return null;
         }
 
-        return array_map(function (Tag $responseTag) {
+        $responses = 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);
+            return [$content, (int) $status];
         }, $responseTags);
+
+        // Convert responses to [200 => 'response', 401 => 'response']
+        return collect($responses)->pluck(0, 1)->toArray();
     }
 }

+ 1 - 1
src/Strategies/Responses/UseTransformerTags.php

@@ -65,7 +65,7 @@ class UseTransformerTags extends Strategy
                 ? 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;
         }

+ 46 - 26
src/Tools/Generator.php

@@ -6,9 +6,7 @@ use Faker\Factory;
 use ReflectionClass;
 use ReflectionMethod;
 use Illuminate\Routing\Route;
-use Mpociot\Reflection\DocBlock;
 use Mpociot\ApiDoc\Tools\Traits\ParamHelpers;
-use Symfony\Component\HttpFoundation\Response;
 
 class Generator
 {
@@ -58,13 +56,13 @@ class Generator
         $method = $controller->getMethod($methodName);
 
         $parsedRoute = [
-            'id' => md5($this->getUri($route).':'.implode($this->getMethods($route))),
+            'id' => md5($this->getUri($route) . ':' . implode($this->getMethods($route))),
             'methods' => $this->getMethods($route),
             'uri' => $this->getUri($route),
             'boundUri' => Utils::getFullUrl($route, $rulesToApply['bindings'] ?? ($rulesToApply['response_calls']['bindings'] ?? [])),
         ];
-        $metadata = $this->fetchMetadata($controller, $method, $route, $rulesToApply);
-        $parsedRoute += $metadata;
+        $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);
@@ -75,54 +73,76 @@ class Generator
 
         $responses = $this->fetchResponses($controller, $method, $route, $rulesToApply, $parsedRoute);
         $parsedRoute['response'] = $responses;
-        $parsedRoute['showresponse'] = ! empty($responses);
+        $parsedRoute['showresponse'] = !empty($responses);
 
         $parsedRoute['headers'] = $rulesToApply['headers'] ?? [];
 
+        // Currently too lazy to tinker with Blade files; change this later
+        unset($parsedRoute['metadata']);
+        $parsedRoute += $metadata;
+
         return $parsedRoute;
     }
 
-    protected function fetchMetadata(ReflectionClass $controller, ReflectionMethod $method, Route $route, array $rulesToApply)
+    protected function fetchMetadata(ReflectionClass $controller, ReflectionMethod $method, Route $route, array $rulesToApply, array $context = [])
     {
-        return $this->iterateThroughStrategies('metadata', [$route, $controller, $method, $rulesToApply]);
+        $context['metadata'] = [
+            'groupName' => $this->config->get('default_group'),
+            'groupDescription' => '',
+            'title' => '',
+            'description' => '',
+            'authenticated' => false,
+        ];
+        return $this->iterateThroughStrategies('metadata', $context, [$route, $controller, $method, $rulesToApply]);
     }
 
     protected function fetchBodyParameters(ReflectionClass $controller, ReflectionMethod $method, Route $route, array $rulesToApply, array $context = [])
     {
-        return $this->iterateThroughStrategies('bodyParameters', [$route, $controller, $method, $rulesToApply]);
+        return $this->iterateThroughStrategies('bodyParameters', $context, [$route, $controller, $method, $rulesToApply]);
     }
 
     protected function fetchQueryParameters(ReflectionClass $controller, ReflectionMethod $method, Route $route, array $rulesToApply, array $context = [])
     {
-        return $this->iterateThroughStrategies('queryParameters', [$route, $controller, $method, $rulesToApply]);
+        return $this->iterateThroughStrategies('queryParameters', $context, [$route, $controller, $method, $rulesToApply]);
     }
 
     protected function fetchResponses(ReflectionClass $controller, ReflectionMethod $method, Route $route, array $rulesToApply, array $context = [])
     {
-        $responses = $this->iterateThroughStrategies('responses', [$route, $controller, $method, $rulesToApply, $context]);
+        $responses = $this->iterateThroughStrategies('responses', $context, [$route, $controller, $method, $rulesToApply]);
         if (count($responses)) {
-            return array_map(function (Response $response) {
+            return collect($responses)->map(function (string $response, int $status) {
                 return [
-                    'status' => $response->getStatusCode(),
-                    'content' => $response->getContent()
+                    'status' => $status ?: 200,
+                    'content' => $response,
                 ];
-            }, $responses);
+            })->values()->toArray();
         }
         return null;
     }
 
-    protected function iterateThroughStrategies(string $key, array $arguments)
+    protected function iterateThroughStrategies(string $key, array $context, array $arguments)
     {
-    $strategies = $this->config->get("strategies.$key", []);
-    $results = [];
-
-    foreach ($strategies as $strategyClass) {
-        $strategy = new $strategyClass($this->config);
-        $results = $strategy(...$arguments);
-        if (! is_null($results)) {
-            break;
+        $strategies = $this->config->get("strategies.$key", []);
+        $context[$key] = $context[$key] ?? [];
+        foreach ($strategies as $strategyClass) {
+            $strategy = new $strategyClass($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
+
+                    // Don't allow overwriting if an empty value is trying to replace a set one
+                    if (! in_array($context[$key], [null, ''], true) && in_array($item, [null, ''], true)) {
+                        continue;
+                    } else {
+                        $context[$key][$index] = $item;
+                    }
+                }
+            }
         }
+        return $context[$key];
     }
-    return is_null($results) ? [] : $results;
-}
 }

+ 4 - 4
tests/Fixtures/index.md

@@ -56,7 +56,7 @@ fetch(url, {
 ```
 
 
-> Example response (200):
+> Example response:
 
 ```json
 null
@@ -173,7 +173,7 @@ fetch(url, {
 ```
 
 
-> Example response (200):
+> Example response:
 
 ```json
 null
@@ -239,7 +239,7 @@ fetch(url, {
 ```
 
 
-> Example response (200):
+> Example response:
 
 ```json
 null
@@ -290,7 +290,7 @@ fetch(url, {
 ```
 
 
-> Example response (200):
+> Example response:
 
 ```json
 null

+ 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;

+ 32 - 4
tests/Unit/GeneratorTestCase.php

@@ -5,7 +5,9 @@ namespace Mpociot\ApiDoc\Tests\Unit;
 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
 {
@@ -414,8 +416,8 @@ abstract class GeneratorTestCase extends TestCase
         $this->assertTrue(is_array($response));
         $this->assertEquals(200, $response['status']);
         $this->assertSame(
-            $response['content'],
-            $expected
+            $expected,
+            $response['content']
         );
     }
 
@@ -763,9 +765,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]]);
         }
     }
 }