瀏覽代碼

Reimplement response calls (#364)

shalvah 6 年之前
父節點
當前提交
8c62c542b0

+ 56 - 0
config/apidoc.php

@@ -84,6 +84,62 @@ return [
                     // 'Authorization' => 'Bearer: {token}',
                     // 'Api-Version' => 'v2',
                 ],
+
+                /*
+                 * If no @response or @transformer declaratons are found for the route,
+                 * we'll try to get a sample response by attempting an API call.
+                 * Configure the settings for the API call here,
+                 */
+                'response_calls' => [
+                    /*
+                     * What HTTP methods (GET, POST, etc) should API calls be made for. List the methods here
+                     * or use '*' to mean all methods. Set to false to disable API calls.
+                     */
+                    'methods' => ['*'],
+
+                    /*
+                     * For URLs which have parameters (/users/{user}, /orders/{id?}),
+                     * specify what values the parameters should be replaced with.
+                     * Note that you must specify the full parameter, including curly brackets and question marks if any.
+                     */
+                    'bindings' => [
+                        // '{user}' => 1
+                    ],
+
+                    /*
+                     * Environment variables which should be set for the API call.
+                     */
+                    'env' => [
+                        'APP_ENV' => 'documentation',
+                        'APP_DEBUG' => false,
+                        // 'env_var' => 'value',
+                    ],
+
+                    /*
+                     * Headers which should be sent with the API call.
+                     */
+                    'headers' => [
+                        'Content-Type' => 'application/json',
+                        'Accept' => 'application/json',
+                        // 'key' => 'value',
+                    ],
+
+
+                    /*
+                     * Query parameters which should be sent with the API call.
+                     */
+                    'query' => [
+                        // 'key' => 'value',
+                    ],
+
+
+                    /*
+                     * Body parameters which should be sent with the API call.
+                     */
+                    'body' => [
+                        // 'key' => 'value',
+                    ],
+                ],
             ],
         ],
     ],

+ 2 - 91
src/Generators/AbstractGenerator.php

@@ -3,16 +3,12 @@
 namespace Mpociot\ApiDoc\Generators;
 
 use Faker\Factory;
-use Mpociot\ApiDoc\Tools\ResponseResolver;
 use ReflectionClass;
-use Illuminate\Support\Str;
-use League\Fractal\Manager;
+use ReflectionMethod;
 use Illuminate\Routing\Route;
 use Mpociot\Reflection\DocBlock;
-use League\Fractal\Resource\Item;
 use Mpociot\Reflection\DocBlock\Tag;
-use League\Fractal\Resource\Collection;
-use ReflectionMethod;
+use Mpociot\ApiDoc\Tools\ResponseResolver;
 
 abstract class AbstractGenerator
 {
@@ -141,51 +137,6 @@ abstract class AbstractGenerator
         return (bool) $authTag;
     }
 
-    /**
-     * @param  $route
-     * @param  $bindings
-     * @param  $headers
-     *
-     * @return \Illuminate\Http\Response
-     */
-    protected function getRouteResponse($route, $bindings, $headers = [])
-    {
-        $uri = $this->addRouteModelBindings($route, $bindings);
-
-        $methods = $this->getMethods($route);
-
-        // Split headers into key - value pairs
-        $headers = collect($headers)->map(function ($value) {
-            $split = explode(':', $value); // explode to get key + values
-            $key = array_shift($split); // extract the key and keep the values in the array
-            $value = implode(':', $split); // implode values into string again
-
-            return [trim($key) => trim($value)];
-        })->collapse()->toArray();
-
-        //Changes url with parameters like /users/{user} to /users/1
-        $uri = preg_replace('/{(.*?)}/', 1, $uri); // 1 is the default value for route parameters
-
-        return $this->callRoute(array_shift($methods), $uri, [], [], [], $headers);
-    }
-
-    /**
-     * @param $route
-     * @param array $bindings
-     *
-     * @return mixed
-     */
-    protected function addRouteModelBindings($route, $bindings)
-    {
-        $uri = $this->getUri($route);
-        foreach ($bindings as $model => $id) {
-            $uri = str_replace('{'.$model.'}', $id, $uri);
-            $uri = str_replace('{'.$model.'?}', $id, $uri);
-        }
-
-        return $uri;
-    }
-
     /**
      * @param ReflectionMethod $method
      *
@@ -236,46 +187,6 @@ abstract class AbstractGenerator
         return 'general';
     }
 
-    /**
-     * Call the given URI and return the Response.
-     *
-     * @param  string  $method
-     * @param  string  $uri
-     * @param  array  $parameters
-     * @param  array  $cookies
-     * @param  array  $files
-     * @param  array  $server
-     * @param  string  $content
-     *
-     * @return \Illuminate\Http\Response
-     */
-    abstract public function callRoute($method, $uri, $parameters = [], $cookies = [], $files = [], $server = [], $content = null);
-
-    /**
-     * Transform headers array to array of $_SERVER vars with HTTP_* format.
-     *
-     * @param  array  $headers
-     *
-     * @return array
-     */
-    protected function transformHeadersToServerVars(array $headers)
-    {
-        $server = [];
-        $prefix = 'HTTP_';
-
-        foreach ($headers as $name => $value) {
-            $name = strtr(strtoupper($name), '-', '_');
-
-            if (! Str::startsWith($name, $prefix) && $name !== 'CONTENT_TYPE') {
-                $name = $prefix.$name;
-            }
-
-            $server[$name] = $value;
-        }
-
-        return $server;
-    }
-
     private function normalizeParameterType($type)
     {
         $typeMap = [

+ 5 - 11
src/Tools/ResponseResolver.php

@@ -3,13 +3,16 @@
 namespace Mpociot\ApiDoc\Tools;
 
 use Illuminate\Routing\Route;
+use Mpociot\ApiDoc\Tools\ResponseStrategies\ResponseTagStrategy;
+use Mpociot\ApiDoc\Tools\ResponseStrategies\ResponseCallStrategy;
+use Mpociot\ApiDoc\Tools\ResponseStrategies\TransformerTagsStrategy;
 
 class ResponseResolver
 {
     public static $strategies = [
         ResponseTagStrategy::class,
         TransformerTagsStrategy::class,
-   //     ResponseCallStrategy::class,
+        ResponseCallStrategy::class,
     ];
 
     /**
@@ -46,15 +49,6 @@ class ResponseResolver
      */
     private function getResponseContent($response)
     {
-        if (empty($response)) {
-            return '';
-        }
-        if ($response->headers->get('Content-Type') === 'application/json') {
-            $content = json_decode($response->getContent(), JSON_PRETTY_PRINT);
-        } else {
-            $content = $response->getContent();
-        }
-
-        return $content;
+        return $response ? $response->getContent() : '';
     }
 }

+ 245 - 0
src/Tools/ResponseStrategies/ResponseCallStrategy.php

@@ -0,0 +1,245 @@
+<?php
+
+namespace Mpociot\ApiDoc\Tools\ResponseStrategies;
+
+use Dingo\Api\Dispatcher;
+use Illuminate\Http\Request;
+use Illuminate\Http\Response;
+use Illuminate\Routing\Route;
+
+/**
+ * Make a call to the route and retrieve its response
+ */
+class ResponseCallStrategy
+{
+    public function __invoke(Route $route, array $tags, array $rulesToApply)
+    {
+        $rulesToApply = $rulesToApply['response_calls'] ?? [];
+        if (! $this->shouldMakeApiCall($route, $rulesToApply)) {
+            return;
+        }
+
+        $this->configureEnvironment($rulesToApply);
+        $request = $this->prepareRequest($route, $rulesToApply);
+        try {
+            $response = $this->makeApiCall($request);
+        } catch (\Exception $e) {
+            $response = null;
+        } finally {
+            $this->finish();
+        }
+
+        return $response;
+    }
+
+    private function configureEnvironment(array $rulesToApply)
+    {
+        $this->enableDbTransactions();
+        $this->setEnvironmentVariables($rulesToApply['env'] ?? []);
+    }
+
+    private function prepareRequest(Route $route, array $rulesToApply)
+    {
+        $uri = $this->replaceUrlParameterBindings($route, $rulesToApply['bindings'] ?? []);
+        $routeMethods = $this->getMethods($route);
+        $method = array_shift($routeMethods);
+        $request = Request::create($uri, $method, [], [], [], $this->transformHeadersToServerVars($rulesToApply['headers'] ?? []));
+        $request = $this->addHeaders($request, $route, $rulesToApply['headers'] ?? []);
+        $request = $this->addQueryParameters($request, $rulesToApply['query'] ?? []);
+        $request = $this->addBodyParameters($request, $rulesToApply['body'] ?? []);
+
+        return $request;
+    }
+
+    /**
+     * Transform parameters in URLs into real values (/users/{user} -> /users/2).
+     * Uses bindings specified by caller, otherwise just uses '1'
+     *
+     * @param Route $route
+     * @param array $bindings
+     *
+     * @return mixed
+     */
+    protected function replaceUrlParameterBindings(Route $route, $bindings)
+    {
+        $uri = $route->uri();
+        foreach ($bindings as $parameter => $binding) {
+            $uri = str_replace($parameter, $binding, $uri);
+        }
+        // Replace any unbound parameters with '1'
+        $uri = preg_replace('/{(.*?)}/', 1, $uri);
+
+        return $uri;
+    }
+
+    private function setEnvironmentVariables(array $env)
+    {
+        foreach ($env as $name => $value) {
+            putenv("$name=$value");
+
+            $_ENV[$name] = $value;
+            $_SERVER[$name] = $value;
+        }
+    }
+
+    private function enableDbTransactions()
+    {
+        try {
+            app('db')->beginTransaction();
+        } catch (\Exception $e) {
+
+        }
+    }
+
+    private function disableDbTransactions()
+    {
+        try {
+            app('db')->rollBack();
+        } catch (\Exception $e) {
+
+        }
+    }
+
+    private function finish()
+    {
+        $this->disableDbTransactions();
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function callDingoRoute(Request $request)
+    {
+        /** @var Dispatcher $dispatcher */
+        $dispatcher = app(\Dingo\Api\Dispatcher::class);
+
+        foreach ($request->headers as $header => $value) {
+            $dispatcher->header($header, $value);
+        }
+
+        // set domain and body parameters
+        $dispatcher->on($request->header('SERVER_NAME'))
+            ->with($request->request->all());
+
+        // set URL and query parameters
+        $uri = $request->getRequestUri();
+        $query = $request->getQueryString();
+        if (!empty($query)) {
+            $uri .= "?$query";
+        }
+        $response = call_user_func_array([$dispatcher, strtolower($request->method())], [$uri]);
+
+        // the response from the Dingo dispatcher is the 'raw' response from the controller,
+        // so we have to ensure it's JSON first
+        if (! $response instanceof Response) {
+            $response = response()->json($response);
+        }
+
+        return $response;
+    }
+
+    public function getMethods(Route $route)
+    {
+        return array_diff($route->methods(), ['HEAD']);
+    }
+
+    private function addHeaders(Request $request, Route $route, $headers)
+    {
+        // set the proper domain
+        if ($route->getDomain()) {
+            $request->server->add([
+                'HTTP_HOST' => $route->getDomain(),
+                'SERVER_NAME' => $route->getDomain(),
+            ]);
+        }
+
+        $headers = collect($headers);
+
+        if (($headers->get('Accept') ?: $headers->get('accept')) === 'application/json') {
+            $request->setRequestFormat('json');
+        }
+
+        return $request;
+    }
+
+    private function addQueryParameters(Request $request, array $query)
+    {
+        $request->query->add($query);
+        $request->server->add(['QUERY_STRING' => http_build_query($query)]);
+
+        return $request;
+    }
+
+    private function addBodyParameters(Request $request, array $body)
+    {
+        $request->request->add($body);
+
+        return $request;
+    }
+
+    private function makeApiCall(Request $request)
+    {
+        if (config('apidoc.router') == 'dingo') {
+            $response = $this->callDingoRoute($request);
+        } else {
+            $response = $this->callLaravelRoute($request);
+        }
+
+        return $response;
+    }
+
+    /**
+     * @param $request
+     *
+     * @return \Symfony\Component\HttpFoundation\Response
+     */
+    private function callLaravelRoute($request): \Symfony\Component\HttpFoundation\Response
+    {
+        $kernel = app(\Illuminate\Contracts\Http\Kernel::class);
+        $response = $kernel->handle($request);
+        $kernel->terminate($request, $response);
+
+        return $response;
+    }
+
+    private function shouldMakeApiCall(Route $route, array $rulesToApply): bool
+    {
+        $allowedMethods = $rulesToApply['methods'] ?? [];
+        if (empty($allowedMethods)) {
+            return false;
+        }
+
+        if (array_search('*', $allowedMethods) !== false) {
+            return true;
+        }
+
+        $routeMethods = $this->getMethods($route);
+        if (in_array(array_shift($routeMethods), $allowedMethods)) {
+            return true;
+        }
+
+        return false;
+    }
+
+    /**
+     * Transform headers array to array of $_SERVER vars with HTTP_* format.
+     *
+     * @param  array  $headers
+     *
+     * @return array
+     */
+    protected function transformHeadersToServerVars(array $headers)
+    {
+        $server = [];
+        $prefix = 'HTTP_';
+        foreach ($headers as $name => $value) {
+            $name = strtr(strtoupper($name), '-', '_');
+            if (! starts_with($name, $prefix) && $name !== 'CONTENT_TYPE') {
+                $name = $prefix.$name;
+            }
+            $server[$name] = $value;
+        }
+
+        return $server;
+    }
+}

+ 4 - 10
src/Tools/ResponseTagStrategy.php → src/Tools/ResponseStrategies/ResponseTagStrategy.php

@@ -1,12 +1,6 @@
 <?php
-/**
- * Created by shalvah
- * Date: 15-Oct-18
- * Time: 15:07
- */
-
-namespace Mpociot\ApiDoc\Tools;
 
+namespace Mpociot\ApiDoc\Tools\ResponseStrategies;
 
 use Illuminate\Routing\Route;
 use Mpociot\Reflection\DocBlock\Tag;
@@ -31,14 +25,14 @@ class ResponseTagStrategy
     protected function getDocBlockResponse(array $tags)
     {
         $responseTags = array_filter($tags, function ($tag) {
-            return $tag instanceof Tag && \strtolower($tag->getName()) == 'response';
+            return $tag instanceof Tag && strtolower($tag->getName()) == 'response';
         });
         if (empty($responseTags)) {
             return;
         }
-        $responseTag = \array_first($responseTags);
+        $responseTag = array_first($responseTags);
 
-        return \response()->json($responseTag->getContent());
+        return response()->json(json_decode($responseTag->getContent(), true));
     }
 
 }

+ 11 - 17
src/Tools/TransformerTagsStrategy.php → src/Tools/ResponseStrategies/TransformerTagsStrategy.php

@@ -1,12 +1,6 @@
 <?php
-/**
- * Created by shalvah
- * Date: 15-Oct-18
- * Time: 15:07
- */
-
-namespace Mpociot\ApiDoc\Tools;
 
+namespace Mpociot\ApiDoc\Tools\ResponseStrategies;
 
 use ReflectionClass;
 use ReflectionMethod;
@@ -51,7 +45,6 @@ class TransformerTagsStrategy
 
             return response($fractal->createData($resource)->toJson());
         } catch (\Exception $e) {
-
             return;
         }
     }
@@ -88,6 +81,7 @@ class TransformerTagsStrategy
                 $type = (string) $parameter->getType();
             }
         }
+
         return $type;
 
     }
@@ -99,26 +93,25 @@ class TransformerTagsStrategy
      */
     protected function instantiateTransformerModel(string $type)
     {
-        // our fallback
-        $modelInstance = new $type;
-
         try {
             // try Eloquent model factory
-            $modelInstance = factory($type)->make();
+            return factory($type)->make();
         } catch (\Exception $e) {
-            if ($modelInstance instanceof \Illuminate\Database\Eloquent\Model) {
+            $instance = new $type;
+            if ($instance instanceof \Illuminate\Database\Eloquent\Model) {
                 try {
                     // we can't use a factory but can try to get one from the database
-                    $newDemoData = $type::first();
-                    if ($newDemoData) {
-                        $modelInstance = $newDemoData;
+                    $firstInstance = $type::first();
+                    if ($firstInstance) {
+                        return $firstInstance;
                     }
                 } catch (\Exception $e) {
                     // okay, we'll stick with `new`
                 }
             }
         }
-        return $modelInstance;
+
+        return $instance;
     }
 
     /**
@@ -131,6 +124,7 @@ class TransformerTagsStrategy
         $transFormerTags = array_filter($tags, function ($tag) {
             return ($tag instanceof Tag) && in_array(strtolower($tag->getName()), ['transformer', 'transformercollection']);
         });
+
         return array_first($transFormerTags);
     }
 }

+ 22 - 11
tests/Fixtures/TestController.php

@@ -61,19 +61,30 @@ class TestController extends Controller
 
     public function shouldFetchRouteResponse()
     {
-        $fixture = new \stdClass();
-        $fixture->id = 1;
-        $fixture->name = 'banana';
-        $fixture->color = 'red';
-        $fixture->weight = 300;
-        $fixture->delicious = 1;
+        $fruit = new \stdClass();
+        $fruit->id = 4;
+        $fruit->name = ' banana  ';
+        $fruit->color = 'RED';
+        $fruit->weight = 1;
+        $fruit->delicious = true;
 
         return [
-            'id' => (int) $fixture->id,
-            'name' => ucfirst($fixture->name),
-            'color' => ucfirst($fixture->color),
-            'weight' => $fixture->weight.' grams',
-            'delicious' => (bool) $fixture->delicious,
+            'id' => (int) $fruit->id,
+            'name' => trim($fruit->name),
+            'color' => strtolower($fruit->color),
+            'weight' => $fruit->weight.' kg',
+            'delicious' => $fruit->delicious,
+        ];
+    }
+
+    public function shouldFetchRouteResponseWithEchoedSettings($id)
+    {
+        return [
+            '{id}' => $id,
+            'APP_ENV' => getenv('APP_ENV'),
+            'header' => request()->header('header'),
+            'queryParam' => request()->query('queryParam'),
+            'bodyParam' => request()->get('bodyParam'),
         ];
     }
 

+ 1 - 9
tests/Fixtures/TestResourceController.php

@@ -48,9 +48,7 @@ class TestResourceController extends Controller
      */
     public function store(Request $request)
     {
-        return [
-            'store_resource' => true,
-        ];
+        return;
     }
 
     /**
@@ -99,9 +97,6 @@ class TestResourceController extends Controller
      */
     public function update(Request $request, $id)
     {
-        return [
-            'update_resource' => $id,
-        ];
     }
 
     /**
@@ -113,8 +108,5 @@ class TestResourceController extends Controller
      */
     public function destroy($id)
     {
-        return [
-            'destroy_resource' => $id,
-        ];
     }
 }

+ 4 - 2
tests/Unit/DingoGeneratorTest.php

@@ -12,8 +12,8 @@ class DingoGeneratorTest extends GeneratorTestCase
     protected function getPackageProviders($app)
     {
         return [
-            \Dingo\Api\Provider\LaravelServiceProvider::class,
             ApiDocGeneratorServiceProvider::class,
+            \Dingo\Api\Provider\LaravelServiceProvider::class,
         ];
     }
 
@@ -22,9 +22,11 @@ class DingoGeneratorTest extends GeneratorTestCase
         parent::setUp();
 
         $this->generator = new DingoGenerator();
+        config(['apidoc.router' => 'dingo']);
+
     }
 
-    public function createRoute(string $httpMethod, string $path, string $controllerMethod)
+    public function createRoute(string $httpMethod, string $path, string $controllerMethod, $register = false)
     {
         $route = null;
         /** @var Router $api */

+ 71 - 5
tests/Unit/GeneratorTestCase.php

@@ -26,8 +26,6 @@ abstract class GeneratorTestCase extends TestCase
     public function setUp()
     {
         parent::setUp();
-
-        $this->generator = new LaravelGenerator();
     }
 
     /** @test */
@@ -140,13 +138,13 @@ abstract class GeneratorTestCase extends TestCase
         $this->assertTrue(is_array($parsed));
         $this->assertArrayHasKey('showresponse', $parsed);
         $this->assertTrue($parsed['showresponse']);
-        $this->assertJsonStringEqualsJsonString(json_encode([
+        $this->assertArraySubset([
             'id' => 4,
             'name' => 'banana',
             'color' => 'red',
             'weight' => '1 kg',
             'delicious' => true,
-        ]), $parsed['response']);
+        ], json_decode($parsed['response'], true));
     }
 
     /** @test */
@@ -207,5 +205,73 @@ abstract class GeneratorTestCase extends TestCase
         );
     }
 
-    abstract public function createRoute(string $httpMethod, string $path, string $controllerMethod);
+    /** @test */
+    public function can_call_route_and_generate_response()
+    {
+        $route = $this->createRoute('PUT', '/shouldFetchRouteResponse', 'shouldFetchRouteResponse', true);
+
+        $rules = [
+            'response_calls' => [
+                'methods' => ['*'],
+                'headers' => [
+                    'Content-Type' => 'application/json',
+                    'Accept' => 'application/json',
+                ],
+            ],
+        ];
+        $parsed = $this->generator->processRoute($route, $rules);
+
+        $this->assertTrue(is_array($parsed));
+        $this->assertArrayHasKey('showresponse', $parsed);
+        $this->assertTrue($parsed['showresponse']);
+        $this->assertArraySubset([
+            'id' => 4,
+            'name' => 'banana',
+            'color' => 'red',
+            'weight' => '1 kg',
+            'delicious' => true,
+        ], json_decode($parsed['response'], true));
+    }
+
+    /** @test */
+    public function uses_configured_settings_when_calling_route()
+    {
+        $route = $this->createRoute('PUT', '/echo/{id}', 'shouldFetchRouteResponseWithEchoedSettings', true);
+
+        $rules = [
+            'response_calls' => [
+                'methods' => ['*'],
+                'headers' => [
+                    'Content-Type' => 'application/json',
+                    'Accept' => 'application/json',
+                    'header' => 'value',
+                ],
+                'bindings' => [
+                    '{id}' => 3,
+                ],
+                'env' => [
+                    'APP_ENV' => 'documentation',
+                ],
+                'query' => [
+                    'queryParam' => 'queryValue',
+                ],
+                'body' => [
+                    'bodyParam' => 'bodyValue',
+                ]
+            ],
+        ];
+        $parsed = $this->generator->processRoute($route, $rules);
+
+        $this->assertTrue(is_array($parsed));
+        $this->assertArrayHasKey('showresponse', $parsed);
+        $this->assertTrue($parsed['showresponse']);
+        $response = json_decode($parsed['response'], true);
+        $this->assertEquals(3, $response['{id}']);
+        $this->assertEquals('documentation', $response['APP_ENV']);
+        $this->assertEquals('queryValue', $response['queryParam']);
+        $this->assertEquals('bodyValue', $response['bodyParam']);
+        $this->assertEquals('value', $response['header']);
+    }
+
+    abstract public function createRoute(string $httpMethod, string $path, string $controllerMethod, $register = false);
 }

+ 7 - 2
tests/Unit/LaravelGeneratorTest.php

@@ -3,6 +3,7 @@
 namespace Mpociot\ApiDoc\Tests\Unit;
 
 use Illuminate\Routing\Route;
+use Illuminate\Support\Facades\Route as RouteFacade;
 use Mpociot\ApiDoc\Generators\LaravelGenerator;
 use Mpociot\ApiDoc\Tests\Fixtures\TestController;
 use Mpociot\ApiDoc\ApiDocGeneratorServiceProvider;
@@ -23,8 +24,12 @@ class LaravelGeneratorTest extends GeneratorTestCase
         $this->generator = new LaravelGenerator();
     }
 
-    public function createRoute(string $httpMethod, string $path, string $controllerMethod)
+    public function createRoute(string $httpMethod, string $path, string $controllerMethod, $register = false)
     {
-        return new Route([$httpMethod], $path, ['uses' => TestController::class."@$controllerMethod"]);
+        if ($register) {
+            return RouteFacade::{$httpMethod}($path, TestController::class . "@$controllerMethod");
+        } else {
+            return new Route([$httpMethod], $path, ['uses' => TestController::class . "@$controllerMethod"]);
+        }
     }
 }