Completed
Push — master ( 6258b7...7a53af )
by
unknown
11s
created

GenerateDocumentation::processRoutes()   B

Complexity

Conditions 9
Paths 4

Size

Total Lines 24

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 24
rs 8.0555
c 0
b 0
f 0
cc 9
nc 4
nop 5
1
<?php
2
3
namespace Mpociot\ApiDoc\Commands;
4
5
use ReflectionClass;
6
use Illuminate\Routing\Route;
7
use Illuminate\Console\Command;
8
use Mpociot\Reflection\DocBlock;
9
use Illuminate\Support\Collection;
10
use Mpociot\Documentarian\Documentarian;
11
use Mpociot\ApiDoc\Postman\CollectionWriter;
12
use Mpociot\ApiDoc\Generators\DingoGenerator;
13
use Mpociot\ApiDoc\Generators\LaravelGenerator;
14
use Mpociot\ApiDoc\Generators\AbstractGenerator;
15
use Illuminate\Support\Facades\Route as RouteFacade;
16
17
class GenerateDocumentation extends Command
18
{
19
    /**
20
     * The name and signature of the console command.
21
     *
22
     * @var string
23
     */
24
    protected $signature = 'api:generate
25
                            {--output=public/docs : The output path for the generated documentation}
26
                            {--routeDomain= : The route domain (or domains) to use for generation}
27
                            {--routePrefix= : The route prefix (or prefixes) to use for generation}
28
                            {--routes=* : The route names to use for generation}
29
                            {--middleware= : The middleware to use for generation}
30
                            {--noResponseCalls : Disable API response calls}
31
                            {--noPostmanCollection : Disable Postman collection creation}
32
                            {--useMiddlewares : Use all configured route middlewares}
33
                            {--authProvider=users : The authentication provider to use for API response calls}
34
                            {--authGuard=web : The authentication guard to use for API response calls}
35
                            {--actAsUserId= : The user ID to use for API response calls}
36
                            {--router=laravel : The router to be used (Laravel or Dingo)}
37
                            {--force : Force rewriting of existing routes}
38
                            {--bindings= : Route Model Bindings}
39
                            {--header=* : Custom HTTP headers to add to the example requests. Separate the header name and value with ":"}
40
    ';
41
42
    /**
43
     * The console command description.
44
     *
45
     * @var string
46
     */
47
    protected $description = 'Generate your API documentation from existing Laravel routes.';
48
49
    /**
50
     * Create a new command instance.
51
     *
52
     * @return void
0 ignored issues
show
Comprehensibility Best Practice introduced by
Adding a @return annotation to constructors is generally not recommended as a constructor does not have a meaningful return value.

Adding a @return annotation to a constructor is not recommended, since a constructor does not have a meaningful return value.

Please refer to the PHP core documentation on constructors.

Loading history...
53
     */
54
    public function __construct()
55
    {
56
        parent::__construct();
57
    }
58
59
    /**
60
     * Execute the console command.
61
     *
62
     * @return false|null
63
     */
64
    public function handle()
65
    {
66
        if ($this->option('router') === 'laravel') {
67
            $generator = new LaravelGenerator();
68
        } else {
69
            $generator = new DingoGenerator();
70
        }
71
72
        $allowedRoutes = $this->option('routes');
73
        $routeDomain = $this->option('routeDomain');
74
        $routePrefix = $this->option('routePrefix');
75
        $middleware = $this->option('middleware');
76
77
        $this->setUserToBeImpersonated($this->option('actAsUserId'));
78
79
        if ($routePrefix === null && $routeDomain === null && ! count($allowedRoutes) && $middleware === null) {
80
            $this->error('You must provide either a route prefix, a route domain, a route or a middleware to generate the documentation.');
81
82
            return false;
83
        }
84
85
        $generator->prepareMiddleware($this->option('useMiddlewares'));
0 ignored issues
show
Documentation introduced by
$this->option('useMiddlewares') is of type string|array, but the function expects a boolean.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
86
87
        $routePrefixes = explode(',', $routePrefix ?: '*');
88
        $routeDomains = explode(',', $routeDomain ?: '*');
89
90
        $parsedRoutes = [];
91
92
        if ($this->option('router') === 'laravel') {
93
            foreach ($routeDomains as $routeDomain) {
94
                foreach ($routePrefixes as $routePrefix) {
95
                    $parsedRoutes += $this->processRoutes($generator, $allowedRoutes, $routeDomain, $routePrefix, $middleware);
0 ignored issues
show
Bug introduced by
It seems like $allowedRoutes defined by $this->option('routes') on line 72 can also be of type string; however, Mpociot\ApiDoc\Commands\...tation::processRoutes() does only seem to accept array, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
96
                }
97
            }
98
        } else {
99
            foreach ($routeDomains as $routeDomain) {
100
                foreach ($routePrefixes as $routePrefix) {
101
                    $parsedRoutes += $this->processDingoRoutes($generator, $allowedRoutes, $routeDomain, $routePrefix, $middleware);
0 ignored issues
show
Bug introduced by
The method processDingoRoutes() does not seem to exist on object<Mpociot\ApiDoc\Co...\GenerateDocumentation>.

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
102
                }
103
            }
104
        }
105
        $parsedRoutes = collect($parsedRoutes)->groupBy('resource')->sort(function ($a, $b) {
106
            return strcmp($a->first()['resource'], $b->first()['resource']);
107
        });
108
109
        $this->writeMarkdown($parsedRoutes);
110
    }
111
112
    /**
113
     * @param  Collection $parsedRoutes
114
     *
115
     * @return void
116
     */
117
    private function writeMarkdown($parsedRoutes)
118
    {
119
        $outputPath = $this->option('output');
120
        $targetFile = $outputPath.DIRECTORY_SEPARATOR.'source'.DIRECTORY_SEPARATOR.'index.md';
121
        $compareFile = $outputPath.DIRECTORY_SEPARATOR.'source'.DIRECTORY_SEPARATOR.'.compare.md';
122
123
        $infoText = view('apidoc::partials.info')
124
            ->with('outputPath', ltrim($outputPath, 'public/'))
125
            ->with('showPostmanCollectionButton', ! $this->option('noPostmanCollection'));
126
127
        $parsedRouteOutput = $parsedRoutes->map(function ($routeGroup) {
128
            return $routeGroup->map(function ($route) {
129
                $route['output'] = (string) view('apidoc::partials.route')->with('parsedRoute', $route)->render();
130
131
                return $route;
132
            });
133
        });
134
135
        $frontmatter = view('apidoc::partials.frontmatter');
136
        /*
137
         * In case the target file already exists, we should check if the documentation was modified
138
         * and skip the modified parts of the routes.
139
         */
140
        if (file_exists($targetFile) && file_exists($compareFile)) {
141
            $generatedDocumentation = file_get_contents($targetFile);
142
            $compareDocumentation = file_get_contents($compareFile);
143
144
            if (preg_match('/---(.*)---\\s<!-- START_INFO -->/is', $generatedDocumentation, $generatedFrontmatter)) {
145
                $frontmatter = trim($generatedFrontmatter[1], "\n");
146
            }
147
148
            $parsedRouteOutput->transform(function ($routeGroup) use ($generatedDocumentation, $compareDocumentation) {
149
                return $routeGroup->transform(function ($route) use ($generatedDocumentation, $compareDocumentation) {
150
                    if (preg_match('/<!-- START_'.$route['id'].' -->(.*)<!-- END_'.$route['id'].' -->/is', $generatedDocumentation, $routeMatch)) {
151
                        $routeDocumentationChanged = (preg_match('/<!-- START_'.$route['id'].' -->(.*)<!-- END_'.$route['id'].' -->/is', $compareDocumentation, $compareMatch) && $compareMatch[1] !== $routeMatch[1]);
152
                        if ($routeDocumentationChanged === false || $this->option('force')) {
153
                            if ($routeDocumentationChanged) {
154
                                $this->warn('Discarded manual changes for route ['.implode(',', $route['methods']).'] '.$route['uri']);
155
                            }
156
                        } else {
157
                            $this->warn('Skipping modified route ['.implode(',', $route['methods']).'] '.$route['uri']);
158
                            $route['modified_output'] = $routeMatch[0];
159
                        }
160
                    }
161
162
                    return $route;
163
                });
164
            });
165
        }
166
167
        $documentarian = new Documentarian();
168
169
        $markdown = view('apidoc::documentarian')
170
            ->with('writeCompareFile', false)
171
            ->with('frontmatter', $frontmatter)
172
            ->with('infoText', $infoText)
173
            ->with('outputPath', $this->option('output'))
174
            ->with('showPostmanCollectionButton', ! $this->option('noPostmanCollection'))
175
            ->with('parsedRoutes', $parsedRouteOutput);
176
177
        if (! is_dir($outputPath)) {
178
            $documentarian->create($outputPath);
179
        }
180
181
        // Write output file
182
        file_put_contents($targetFile, $markdown);
183
184
        // Write comparable markdown file
185
        $compareMarkdown = view('apidoc::documentarian')
186
            ->with('writeCompareFile', true)
187
            ->with('frontmatter', $frontmatter)
188
            ->with('infoText', $infoText)
189
            ->with('outputPath', $this->option('output'))
190
            ->with('showPostmanCollectionButton', ! $this->option('noPostmanCollection'))
191
            ->with('parsedRoutes', $parsedRouteOutput);
192
193
        file_put_contents($compareFile, $compareMarkdown);
194
195
        $this->info('Wrote index.md to: '.$outputPath);
196
197
        $this->info('Generating API HTML code');
198
199
        $documentarian->generate($outputPath);
200
201
        $this->info('Wrote HTML documentation to: '.$outputPath.'/index.html');
202
203
        if ($this->option('noPostmanCollection') !== true) {
204
            $this->info('Generating Postman collection');
205
206
            file_put_contents($outputPath.DIRECTORY_SEPARATOR.'collection.json', $this->generatePostmanCollection($parsedRoutes));
207
        }
208
    }
209
210
    /**
211
     * @return array
212
     */
213
    private function getBindings()
214
    {
215
        $bindings = $this->option('bindings');
216
        if (empty($bindings)) {
217
            return [];
218
        }
219
220
        $bindings = explode('|', $bindings);
221
        $resultBindings = [];
222
        foreach ($bindings as $binding) {
223
            list($name, $id) = explode(',', $binding);
224
            $resultBindings[$name] = $id;
225
        }
226
227
        return $resultBindings;
228
    }
229
230
    /**
231
     * @param $actAs
232
     */
233
    private function setUserToBeImpersonated($actAs)
234
    {
235
        if (! empty($actAs)) {
236
            if (version_compare($this->laravel->version(), '5.2.0', '<')) {
237
                $userModel = config('auth.model');
238
                $user = $userModel::find($actAs);
239
                $this->laravel['auth']->setUser($user);
240
            } else {
241
                $provider = $this->option('authProvider');
242
                $userModel = config("auth.providers.$provider.model");
243
                $user = $userModel::find($actAs);
244
                $this->laravel['auth']->guard($this->option('authGuard'))->setUser($user);
245
            }
246
        }
247
    }
248
249
    /**
250
     * @return mixed
251
     */
252
    private function getRoutes()
253
    {
254
        if ($this->option('router') === 'laravel') {
255
            return RouteFacade::getRoutes();
256
        } else {
257
            return app('Dingo\Api\Routing\Router')->getRoutes();
258
        }
259
    }
260
261
    /**
262
     * @param AbstractGenerator  $generator
263
     * @param $allowedRoutes
264
     * @param $routeDomain
265
     * @param $routePrefix
266
     *
267
     * @return array
268
     */
269
    private function processRoutes(AbstractGenerator $generator, array $allowedRoutes, $routeDomain, $routePrefix, $middleware)
270
    {
271
        $withResponse = $this->option('noResponseCalls') == false;
272
        $routes = $this->getRoutes();
273
        $bindings = $this->getBindings();
274
        $parsedRoutes = [];
275
        foreach ($routes as $route) {
276
            /** @var Route $route */
277
            if (in_array($route->getName(), $allowedRoutes)
278
                || (str_is($routeDomain, $generator->getDomain($route))
279
                    && str_is($routePrefix, $generator->getUri($route)))
280
                || in_array($middleware, $route->middleware())
281
               ) {
282
                if ($this->isValidRoute($route) && $this->isRouteVisibleForDocumentation($route->getAction()['uses'])) {
283
                    $parsedRoutes[] = $generator->processRoute($route, $bindings, $this->option('header'), $withResponse && in_array('GET', $generator->getMethods($route)));
0 ignored issues
show
Documentation introduced by
$this->option('header') is of type string, but the function expects a array.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
284
                    $this->info('Processed route: ['.implode(',', $generator->getMethods($route)).'] '.$generator->getUri($route));
285
                } else {
286
                    $this->warn('Skipping route: ['.implode(',', $generator->getMethods($route)).'] '.$generator->getUri($route));
287
                }
288
            }
289
        }
290
291
        return $parsedRoutes;
292
    }
293
294
    /**
295
     * @param $route
296
     *
297
     * @return bool
298
     */
299
    private function isValidRoute(Route $route)
300
    {
301
        return ! is_callable($route->getAction()['uses']) && ! is_null($route->getAction()['uses']);
302
    }
303
304
    /**
305
     * @param $route
306
     *
307
     * @return bool
308
     */
309
    private function isRouteVisibleForDocumentation($route)
310
    {
311
        list($class, $method) = explode('@', $route);
312
        $reflection = new ReflectionClass($class);
313
        $comment = $reflection->getMethod($method)->getDocComment();
314
        if ($comment) {
315
            $phpdoc = new DocBlock($comment);
316
317
            return collect($phpdoc->getTags())
318
                ->filter(function ($tag) use ($route) {
319
                    return $tag->getName() === 'hideFromAPIDocumentation';
320
                })
321
                ->isEmpty();
322
        }
323
324
        return true;
325
    }
326
327
    /**
328
     * Generate Postman collection JSON file.
329
     *
330
     * @param Collection $routes
331
     *
332
     * @return string
333
     */
334
    private function generatePostmanCollection(Collection $routes)
335
    {
336
        $writer = new CollectionWriter($routes);
337
338
        return $writer->getCollection();
339
    }
340
}
341