Parcourir la source

Expanding Postman collection support

Along with resolving the `PostmanCollectionWriter` class with the application container, this PR extends support for Postman collections with the following features:

- Properly handle url parameters using the prefixed colon syntax (opposed to the Laravel-esque `{param}` syntax)
- Allow configuring the auth section of Postman collection config so your collection can indicate how to auth across all endpoints
- Resolve query params from url params if the URL param is not visible in the route path
- Add some documentation that was available but not exported in the Postman collection

The PostmanCollectionWriter has been significantly refactored and cleaned up, and a full suite of tests have been added for it.
Guy Marriott il y a 5 ans
Parent
commit
6770e678d0

+ 6 - 0
config/apidoc.php

@@ -41,6 +41,12 @@ return [
          * The description for the exported Postman collection.
          */
         'description' => null,
+
+        /*
+         * The "Auth" section that should appear in the postman collection. See the schema docs for more information:
+         * https://schema.getpostman.com/json/collection/v2.0.0/docs/index.html
+         */
+        'auth' => null,
     ],
 
     /*

+ 132 - 40
src/Writing/PostmanCollectionWriter.php

@@ -19,6 +19,16 @@ class PostmanCollectionWriter
      */
     private $baseUrl;
 
+    /**
+     * @var string
+     */
+    private $protocol;
+
+    /**
+     * @var array|null
+     */
+    private $auth;
+
     /**
      * CollectionWriter constructor.
      *
@@ -27,16 +37,13 @@ class PostmanCollectionWriter
     public function __construct(Collection $routeGroups, $baseUrl)
     {
         $this->routeGroups = $routeGroups;
-        $this->baseUrl = $baseUrl;
+        $this->protocol = Str::startsWith($baseUrl, 'https') ? 'https' : 'http';
+        $this->baseUrl = URL::formatRoot('', $baseUrl);
+        $this->auth = config('apidoc.postman.auth');
     }
 
     public function getCollection()
     {
-        URL::forceRootUrl($this->baseUrl);
-        if (Str::startsWith($this->baseUrl, 'https://')) {
-            URL::forceScheme('https');
-        }
-
         $collection = [
             'variables' => [],
             'info' => [
@@ -45,46 +52,131 @@ class PostmanCollectionWriter
                 'description' => config('apidoc.postman.description') ?: '',
                 'schema' => 'https://schema.getpostman.com/json/collection/v2.0.0/collection.json',
             ],
-            'item' => $this->routeGroups->map(function ($routes, $groupName) {
+            'item' => $this->routeGroups->map(function (Collection $routes, $groupName) {
                 return [
                     'name' => $groupName,
-                    'description' => '',
-                    'item' => $routes->map(function ($route) {
-                        $mode = 'raw';
-
-                        return [
-                            'name' => $route['metadata']['title'] != '' ? $route['metadata']['title'] : url($route['uri']),
-                            'request' => [
-                                'url' => url($route['uri']).(collect($route['queryParameters'])->isEmpty()
-                                        ? ''
-                                        : ('?'.implode('&', collect($route['queryParameters'])->map(function ($parameter, $key) {
-                                            return urlencode($key).'='.urlencode($parameter['value'] ?? '');
-                                        })->all()))),
-                                'method' => $route['methods'][0],
-                                'header' => collect($route['headers'])
-                                    ->union([
-                                        'Accept' => 'application/json',
-                                    ])
-                                    ->map(function ($value, $header) {
-                                        return [
-                                            'key' => $header,
-                                            'value' => $value,
-                                        ];
-                                    })
-                                    ->values()->all(),
-                                'body' => [
-                                    'mode' => $mode,
-                                    $mode => json_encode($route['cleanBodyParameters'], JSON_PRETTY_PRINT),
-                                ],
-                                'description' => $route['metadata']['description'],
-                                'response' => [],
-                            ],
-                        ];
-                    })->toArray(),
+                    'description' => $routes->first()['metadata']['groupDescription'],
+                    'item' => $routes->map(\Closure::fromCallable([$this, 'generateEndpointItem']))->toArray(),
                 ];
             })->values()->toArray(),
         ];
 
+
+        if (!empty($this->auth)) {
+            $collection['auth'] = $this->auth;
+        }
+
         return json_encode($collection, JSON_PRETTY_PRINT);
     }
+
+    protected function generateEndpointItem($route)
+    {
+        $mode = 'raw';
+
+        $method = $route['methods'][0];
+        return [
+            'name' => $route['metadata']['title'] != '' ? $route['metadata']['title'] : $route['uri'],
+            'request' => [
+                'url' => $this->makeUrlData($route),
+                'method' => $method,
+                'header' => $this->resolveHeadersForRoute($route),
+                'body' => [
+                    'mode' => $mode,
+                    $mode => json_encode($route['cleanBodyParameters'], JSON_PRETTY_PRINT),
+                ],
+                'description' => $route['metadata']['description'] ?? null,
+                'response' => [],
+            ],
+        ];
+    }
+
+    protected function resolveHeadersForRoute($route)
+    {
+        $headers = collect($route['headers']);
+
+        // Exclude authentication headers if they're handled by Postman auth
+        $authHeader = $this->getAuthHeader();
+        if (!empty($authHeader)) {
+            $headers = $headers->except($authHeader);
+        }
+
+        return $headers
+            ->union([
+                'Accept' => 'application/json',
+            ])
+            ->map(function ($value, $header) {
+                return [
+                    'key' => $header,
+                    'value' => $value,
+                ];
+            })
+            ->values()
+            ->all();
+    }
+
+    protected function makeUrlData($route)
+    {
+        [$urlParams, $queryParams] = collect($route['urlParameters'])->partition(function($_, $key) use ($route) {
+            return Str::contains($route['uri'], '{' . $key . '}');
+        });
+
+        /** @var Collection $queryParams */
+        $base = [
+            'protocol' => $this->protocol,
+            'host' => $this->baseUrl,
+            // Substitute laravel/symfony query params ({example}) to Postman style, prefixed with a colon
+            'path' => preg_replace_callback('/\/{(\w+)\??}(?=\/|$)/', function ($matches) {
+                return '/:' . $matches[1];
+            }, $route['uri']),
+            'query' => $queryParams->union($route['queryParameters'])->map(function ($parameter, $key) {
+                return [
+                    'key' => $key,
+                    'value' => $parameter['value'],
+                    'description' => $parameter['description'],
+                    // Default query params to disabled if they aren't required and have empty values
+                    'disabled' => !$parameter['required'] && empty($parameter['value']),
+                ];
+            })->values()->toArray(),
+        ];
+
+        // If there aren't any url parameters described then return what we've got
+        /** @var $urlParams Collection */
+        if ($urlParams->isEmpty()) {
+            return $base;
+        }
+
+        $base['variable'] = $urlParams->map(function ($parameter, $key) {
+            return [
+                'id' => $key,
+                'key' => $key,
+                'value' => $parameter['value'],
+                'description' => $parameter['description'],
+            ];
+        })->values()->toArray();
+
+        return $base;
+    }
+
+    protected function getAuthHeader()
+    {
+        $auth = $this->auth;
+        if (empty($auth) || !is_string($auth['type'] ?? null)) {
+            return null;
+        }
+
+        switch ($auth['type']) {
+            case 'bearer':
+                return 'Authorization';
+            case 'apikey':
+                $spec = $auth['apikey'];
+
+                if (isset($spec['in']) && $spec['in'] !== 'header') {
+                    return null;
+                }
+
+                return $spec['key'];
+            default:
+                return null;
+        }
+    }
 }

+ 5 - 1
src/Writing/Writer.php

@@ -223,7 +223,11 @@ class Writer
      */
     public function generatePostmanCollection(Collection $routes)
     {
-        $writer = new PostmanCollectionWriter($routes, $this->baseUrl);
+        /** @var PostmanCollectionWriter $writer */
+        $writer = app()->makeWith(
+            PostmanCollectionWriter::class,
+            ['routeGroups' => $routes, 'baseUrl' => $this->baseUrl]
+        );
 
         return $writer->getCollection();
     }

+ 320 - 0
tests/Unit/PostmanCollectionWriterTest.php

@@ -0,0 +1,320 @@
+<?php
+
+namespace Mpociot\ApiDoc\Tests\Unit;
+
+use Illuminate\Support\Collection;
+use Mpociot\ApiDoc\Writing\PostmanCollectionWriter;
+use Orchestra\Testbench\TestCase;
+
+class PostmanCollectionWriterTest extends TestCase
+{
+    public function testNameIsPresentInCollection()
+    {
+        \Config::set('apidoc.postman', [
+            'name' => 'Test collection',
+        ]);
+
+        $writer = new PostmanCollectionWriter(new Collection(), '');
+        $collection = $writer->getCollection();
+
+        $this->assertSame('Test collection', json_decode($collection)->info->name);
+    }
+
+    public function testFallbackCollectionNameIsUsed()
+    {
+        \Config::set('app.name', 'Fake App');
+
+        $writer = new PostmanCollectionWriter(new Collection(), '');
+        $collection = $writer->getCollection();
+
+        $this->assertSame('Fake App API', json_decode($collection)->info->name);
+    }
+
+    public function testDescriptionIsPresentInCollection()
+    {
+        \Config::set('apidoc.postman', [
+            'description' => 'A fake description',
+        ]);
+
+        $writer = new PostmanCollectionWriter(new Collection(), '');
+        $collection = $writer->getCollection();
+
+        $this->assertSame('A fake description', json_decode($collection)->info->description);
+    }
+
+    public function testAuthIsNotIncludedWhenNull()
+    {
+        $writer = new PostmanCollectionWriter(new Collection(), '');
+        $collection = $writer->getCollection();
+
+        $this->assertArrayNotHasKey('auth', json_decode($collection, true));
+    }
+
+    public function testAuthIsIncludedVerbatim()
+    {
+        $auth = [
+            'type' => 'test',
+            'test' => ['a' => 1],
+        ];
+        \Config::set('apidoc.postman', [
+            'auth' => $auth,
+        ]);
+
+        $writer = new PostmanCollectionWriter(new Collection(), '');
+        $collection = $writer->getCollection();
+
+        $this->assertSame($auth, json_decode($collection, true)['auth']);
+    }
+
+    public function testEndpointIsParsed()
+    {
+        $route = $this->fakeRoute('some/path');
+
+        // Ensure method is set correctly for assertion later
+        $route['methods'] = ['GET'];
+
+        $collection = $this->fakeCollection([$route], 'Group');
+
+        $writer = new PostmanCollectionWriter($collection, 'fake.localhost');
+        $collection = json_decode($writer->getCollection(), true);
+
+        $this->assertSame('Group', data_get($collection, 'item.0.name'), 'Group name exists');
+
+        $item = data_get($collection, 'item.0.item.0');
+        $this->assertSame('some/path', $item['name'], 'Name defaults to path');
+        $this->assertSame('http', data_get($item, 'request.url.protocol'), 'Protocol defaults to http');
+        $this->assertSame('fake.localhost', data_get($item, 'request.url.host'), 'Host uses what\'s given');
+        $this->assertSame('some/path', data_get($item, 'request.url.path'), 'Path is set correctly');
+        $this->assertEmpty(data_get($item, 'request.url.query'), 'Query parameters are empty');
+        $this->assertSame('GET', data_get($item, 'request.method'), 'Method is correctly resolved');
+        $this->assertContains([
+            'key' => 'Accept',
+            'value' => 'application/json',
+        ], data_get($item, 'request.header'), 'JSON Accept header is added');
+    }
+
+    public function testHttpsProtocolIsDetected()
+    {
+        $collection = $this->fakeCollection([$this->fakeRoute('fake')]);
+        $writer = new PostmanCollectionWriter($collection, 'https://fake.localhost');
+        $collection = json_decode($writer->getCollection(), true);
+
+        $this->assertSame('https', data_get($collection, 'item.0.item.0.request.url.protocol'));
+    }
+
+    public function testHeadersArePulledFromRoute()
+    {
+        $route = $this->fakeRoute('some/path');
+
+        $route['headers'] = ['X-Fake' => 'Test'];
+
+        $collection = $this->fakeCollection([$route], 'Group');
+        $writer = new PostmanCollectionWriter($collection, 'fake.localhost');
+        $collection = json_decode($writer->getCollection(), true);
+
+        $this->assertContains([
+            'key' => 'X-Fake',
+            'value' => 'Test',
+        ], data_get($collection, 'item.0.item.0.request.header'));
+    }
+
+    public function testUrlParametersAreConverted()
+    {
+        $collection = $this->fakeCollection([$this->fakeRoute('fake/{param}')]);
+        $writer = new PostmanCollectionWriter($collection, 'fake.localhost');
+        $collection = json_decode($writer->getCollection(), true);
+
+        $item = data_get($collection, 'item.0.item.0');
+        $this->assertSame('fake/{param}', $item['name'], 'Name defaults to path');
+        $this->assertSame('fake/:param', data_get($item, 'request.url.path'), 'Path is converted');
+    }
+
+    public function testUrlParamsResolveTheirDocumentation()
+    {
+        $fakeRoute = $this->fakeRoute('fake/{param}');
+
+        $fakeRoute['urlParameters'] = ['param' => [
+            'description' => 'A test description for the test param',
+            'required' => true,
+            'value' => 'foobar',
+        ]];
+
+        $collection = $this->fakeCollection([$fakeRoute]);
+        $writer = new PostmanCollectionWriter($collection, 'fake.localhost');
+        $collection = json_decode($writer->getCollection(), true);
+
+        $variableData = data_get($collection, 'item.0.item.0.request.url.variable');
+
+        $this->assertCount(1, $variableData);
+        $this->assertSame([
+            'id' => 'param',
+            'key' => 'param',
+            'value' => 'foobar',
+            'description' => 'A test description for the test param',
+        ], $variableData[0]);
+    }
+
+    public function testQueryParametersAreDocumented()
+    {
+        $fakeRoute = $this->fakeRoute('fake/path');
+
+        $fakeRoute['queryParameters'] = ['limit' => [
+            'description' => 'A fake limit for my fake endpoint',
+            'required' => false,
+            'value' => 5,
+        ]];
+
+        $collection = $this->fakeCollection([$fakeRoute]);
+        $writer = new PostmanCollectionWriter($collection, 'fake.localhost');
+        $collection = json_decode($writer->getCollection(), true);
+
+        $variableData = data_get($collection, 'item.0.item.0.request.url.query');
+
+        $this->assertCount(1, $variableData);
+        $this->assertSame([
+            'key' => 'limit',
+            'value' => 5,
+            'description' => 'A fake limit for my fake endpoint',
+            'disabled' => false,
+        ], $variableData[0]);
+    }
+
+    public function testUrlParametersAreResolvedAsQueryParametersIfMissingFromPath()
+    {
+        $fakeRoute = $this->fakeRoute('fake/path');
+
+        $fakeRoute['urlParameters'] = ['limit' => [
+            'description' => 'A fake limit for my fake endpoint',
+            'required' => false,
+            'value' => 5,
+        ]];
+
+        $collection = $this->fakeCollection([$fakeRoute]);
+        $writer = new PostmanCollectionWriter($collection, 'fake.localhost');
+        $collection = json_decode($writer->getCollection(), true);
+
+        $variableData = data_get($collection, 'item.0.item.0.request.url.query');
+
+        $this->assertCount(1, $variableData);
+        $this->assertSame([
+            'key' => 'limit',
+            'value' => 5,
+            'description' => 'A fake limit for my fake endpoint',
+            'disabled' => false,
+        ], $variableData[0]);
+    }
+
+    public function testQueryParametersAreDisabledWithNoValueWhenNotRequired()
+    {
+        $fakeRoute = $this->fakeRoute('fake/path');
+        $fakeRoute['urlParameters'] = [
+            'required' => [
+                'description' => 'A required param with a null value',
+                'required' => true,
+                'value' => null,
+            ],
+            'not_required' => [
+                'description' => 'A not required param with a null value',
+                'required' => false,
+                'value' => null,
+            ]
+        ];
+
+        $collection = $this->fakeCollection([$fakeRoute]);
+        $writer = new PostmanCollectionWriter($collection, 'fake.localhost');
+        $collection = json_decode($writer->getCollection(), true);
+
+        $variableData = data_get($collection, 'item.0.item.0.request.url.query');
+
+        $this->assertCount(2, $variableData);
+        $this->assertContains([
+            'key' => 'required',
+            'value' => null,
+            'description' => 'A required param with a null value',
+            'disabled' => false,
+        ], $variableData);
+        $this->assertContains([
+            'key' => 'not_required',
+            'value' => null,
+            'description' => 'A not required param with a null value',
+            'disabled' => true,
+        ], $variableData);
+    }
+
+    /**
+     * @dataProvider provideAuthConfigHeaderData
+     */
+    public function testAuthAutoExcludesHeaderDefinitions(array $authConfig, array $expectedRemovedHeaders)
+    {
+        \Config::set('apidoc.postman', [
+            'auth' => $authConfig,
+        ]);
+
+        $route = $this->fakeRoute('some/path');
+        $route['headers'] = $expectedRemovedHeaders;
+        $collection = $this->fakeCollection([$route], 'Group');
+        $writer = new PostmanCollectionWriter($collection, 'fake.localhost');
+        $collection = json_decode($writer->getCollection(), true);
+
+        foreach ($expectedRemovedHeaders as $key => $value) {
+            $this->assertNotContains(compact('key', 'value'), data_get($collection, 'item.0.item.0.request.header'));
+        }
+    }
+
+    public function provideAuthConfigHeaderData()
+    {
+        yield [
+            ['type' => 'bearer', 'bearer' => ['token' => 'Test']],
+            ['Authorization' => 'Bearer Test']
+        ];
+
+        yield [
+            ['type' => 'apikey', 'apikey' => ['value' => 'Test', 'key' => 'X-Authorization']],
+            ['X-Authorization' => 'Test']
+        ];
+    }
+
+    public function testApiKeyAuthIsIgnoredIfExplicitlyNotInHeader()
+    {
+        \Config::set('apidoc.postman', [
+            'auth' => ['type' => 'apikey', 'apikey' => [
+                'value' => 'Test',
+                'key' => 'X-Authorization',
+                'in' => 'notheader'
+            ]],
+        ]);
+
+        $route = $this->fakeRoute('some/path');
+        $route['headers'] = ['X-Authorization' => 'Test'];
+        $collection = $this->fakeCollection([$route], 'Group');
+        $writer = new PostmanCollectionWriter($collection, 'fake.localhost');
+        $collection = json_decode($writer->getCollection(), true);
+
+        $this->assertContains([
+            'key' => 'X-Authorization',
+            'value' => 'Test'
+        ], data_get($collection, 'item.0.item.0.request.header'));
+
+    }
+
+    protected function fakeRoute($path, $title = '')
+    {
+        return [
+            'uri' => $path,
+            'methods' => ['GET'],
+            'headers' => [],
+            'metadata' => [
+                'groupDescription' => '',
+                'title' => $title,
+            ],
+            'queryParameters' => [],
+            'urlParameters' => [],
+            'cleanBodyParameters' => [],
+        ];
+    }
+
+    protected function fakeCollection(array $routes, $groupName = 'Group')
+    {
+        return collect([$groupName => collect($routes)]);
+    }
+}