Completed
Pull Request — master (#203)
by Manash
04:33 queued 02:15
created

GenerateDocumentation::handle()   C

Complexity

Conditions 8
Paths 10

Size

Total Lines 41
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 0 Features 1
Metric Value
c 3
b 0
f 1
dl 0
loc 41
rs 5.3846
cc 8
eloc 24
nc 10
nop 0
1
<?php
2
3
namespace Mpociot\ApiDoc\Commands;
4
5
use ReflectionClass;
6
use Illuminate\Console\Command;
7
use Mpociot\Reflection\DocBlock;
8
use Illuminate\Support\Collection;
9
use Illuminate\Support\Facades\Route;
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
16
class GenerateDocumentation extends Command
17
{
18
    /**
19
     * The name and signature of the console command.
20
     *
21
     * @var string
22
     */
23
    protected $signature = 'api:generate 
24
                            {--output=public/docs : The output path for the generated documentation}
25
                            {--routePrefix= : The route prefix to use for generation}
26
                            {--routes=* : The route names to use for generation}
27
                            {--middleware= : The middleware to use for generation}
28
                            {--noResponseCalls : Disable API response calls}
29
                            {--noPostmanCollection : Disable Postman collection creation}
30
                            {--useMiddlewares : Use all configured route middlewares}
31
                            {--actAsUserId= : The user ID to use for API response calls}
32
                            {--router=laravel : The router to be used (Laravel or Dingo)}
33
                            {--force : Force rewriting of existing routes}
34
                            {--bindings= : Route Model Bindings}
35
                            {--header=* : Custom HTTP headers to add to the example requests. Separate the header name and value with ":"}
36
    ';
37
38
    /**
39
     * The console command description.
40
     *
41
     * @var string
42
     */
43
    protected $description = 'Generate your API documentation from existing Laravel routes.';
44
45
    /**
46
     * Create a new command instance.
47
     *
48
     * @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...
49
     */
50
    public function __construct()
51
    {
52
        parent::__construct();
53
    }
54
55
    /**
56
     * Execute the console command.
57
     *
58
     * @return false|null
59
     */
60
    public function handle()
61
    {
62
        if ($this->option('router') === 'laravel') {
63
            $generator = new LaravelGenerator();
64
        } else {
65
            $generator = new DingoGenerator();
66
        }
67
68
        $allowedRoutes = $this->option('routes');
69
        $routePrefix = $this->option('routePrefix');
70
        $middleware = $this->option('middleware');
71
72
        $this->setUserToBeImpersonated($this->option('actAsUserId'));
73
74
        if ($routePrefix === null && ! count($allowedRoutes) && $middleware === null) {
75
            $this->error('You must provide either a route prefix or a route or a middleware to generate the documentation.');
76
77
            return false;
78
        }
79
80
        $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...
81
82
        $routePrefixes = explode(',', $routePrefix);
83
84
        $parsedRoutes = [];
85
86
        if ($this->option('router') === 'laravel') {
87
            foreach ($routePrefixes as $routePrefix) {
88
                $parsedRoutes += $this->processLaravelRoutes($generator, $allowedRoutes, $routePrefix, $middleware);
89
            }
90
        } else {
91
            foreach ($routePrefixes as $routePrefix) {
92
                $parsedRoutes += $this->processDingoRoutes($generator, $allowedRoutes, $routePrefix, $middleware);
93
            }
94
        }
95
        $parsedRoutes = collect($parsedRoutes)->groupBy('resource')->sort(function ($a, $b) {
96
            return strcmp($a->first()['resource'], $b->first()['resource']);
97
        });
98
99
        $this->writeMarkdown($parsedRoutes);
100
    }
101
102
    /**
103
     * @param  Collection $parsedRoutes
104
     *
105
     * @return void
106
     */
107
    private function writeMarkdown($parsedRoutes)
108
    {
109
        $outputPath = $this->option('output');
110
        $targetFile = $outputPath.DIRECTORY_SEPARATOR.'source'.DIRECTORY_SEPARATOR.'index.md';
111
        $compareFile = $outputPath.DIRECTORY_SEPARATOR.'source'.DIRECTORY_SEPARATOR.'.compare.md';
112
113
        $infoText = view('apidoc::partials.info')
114
            ->with('outputPath', ltrim($outputPath, 'public/'))
115
            ->with('showPostmanCollectionButton', ! $this->option('noPostmanCollection'));
116
117
        $parsedRouteOutput = $parsedRoutes->map(function ($routeGroup) {
118
            return $routeGroup->map(function ($route) {
119
                $route['output'] = (string) view('apidoc::partials.route')->with('parsedRoute', $route)->render();
120
121
                return $route;
122
            });
123
        });
124
125
        $frontmatter = view('apidoc::partials.frontmatter');
126
        /*
127
         * In case the target file already exists, we should check if the documentation was modified
128
         * and skip the modified parts of the routes.
129
         */
130
        if (file_exists($targetFile) && file_exists($compareFile)) {
131
            $generatedDocumentation = file_get_contents($targetFile);
132
            $compareDocumentation = file_get_contents($compareFile);
133
134
            if (preg_match('/<!-- START_INFO -->(.*)<!-- END_INFO -->/is', $generatedDocumentation, $generatedInfoText)) {
135
                $infoText = trim($generatedInfoText[1], "\n");
136
            }
137
138
            if (preg_match('/---(.*)---\\s<!-- START_INFO -->/is', $generatedDocumentation, $generatedFrontmatter)) {
139
                $frontmatter = trim($generatedFrontmatter[1], "\n");
140
            }
141
142
            $parsedRouteOutput->transform(function ($routeGroup) use ($generatedDocumentation, $compareDocumentation) {
143
                return $routeGroup->transform(function ($route) use ($generatedDocumentation, $compareDocumentation) {
144
                    if (preg_match('/<!-- START_'.$route['id'].' -->(.*)<!-- END_'.$route['id'].' -->/is', $generatedDocumentation, $routeMatch)) {
145
                        $routeDocumentationChanged = (preg_match('/<!-- START_'.$route['id'].' -->(.*)<!-- END_'.$route['id'].' -->/is', $compareDocumentation, $compareMatch) && $compareMatch[1] !== $routeMatch[1]);
146
                        if ($routeDocumentationChanged === false || $this->option('force')) {
147
                            if ($routeDocumentationChanged) {
148
                                $this->warn('Discarded manual changes for route ['.implode(',', $route['methods']).'] '.$route['uri']);
149
                            }
150
                        } else {
151
                            $this->warn('Skipping modified route ['.implode(',', $route['methods']).'] '.$route['uri']);
152
                            $route['modified_output'] = $routeMatch[0];
153
                        }
154
                    }
155
156
                    return $route;
157
                });
158
            });
159
        }
160
161
        $documentarian = new Documentarian();
162
163
        $markdown = view('apidoc::documentarian')
164
            ->with('writeCompareFile', false)
165
            ->with('frontmatter', $frontmatter)
166
            ->with('infoText', $infoText)
167
            ->with('outputPath', $this->option('output'))
168
            ->with('showPostmanCollectionButton', ! $this->option('noPostmanCollection'))
169
            ->with('parsedRoutes', $parsedRouteOutput);
170
171
        if (! is_dir($outputPath)) {
172
            $documentarian->create($outputPath);
173
        }
174
175
        // Write output file
176
        file_put_contents($targetFile, $markdown);
177
178
        // Write comparable markdown file
179
        $compareMarkdown = view('apidoc::documentarian')
180
            ->with('writeCompareFile', true)
181
            ->with('frontmatter', $frontmatter)
182
            ->with('infoText', $infoText)
183
            ->with('outputPath', $this->option('output'))
184
            ->with('showPostmanCollectionButton', ! $this->option('noPostmanCollection'))
185
            ->with('parsedRoutes', $parsedRouteOutput);
186
187
        file_put_contents($compareFile, $compareMarkdown);
188
189
        $this->info('Wrote index.md to: '.$outputPath);
190
191
        $this->info('Generating API HTML code');
192
193
        $documentarian->generate($outputPath);
194
195
        $this->info('Wrote HTML documentation to: '.$outputPath.'/public/index.html');
196
197
        if ($this->option('noPostmanCollection') !== true) {
198
            $this->info('Generating Postman collection');
199
200
            file_put_contents($outputPath.DIRECTORY_SEPARATOR.'collection.json', $this->generatePostmanCollection($parsedRoutes));
201
        }
202
    }
203
204
    /**
205
     * @return array
206
     */
207
    private function getBindings()
208
    {
209
        $bindings = $this->option('bindings');
210
        if (empty($bindings)) {
211
            return [];
212
        }
213
        $bindings = explode('|', $bindings);
214
        $resultBindings = [];
215
        foreach ($bindings as $binding) {
216
            list($name, $id) = explode(',', $binding);
217
            $resultBindings[$name] = $id;
218
        }
219
220
        return $resultBindings;
221
    }
222
223
    /**
224
     * @param $actAs
225
     */
226
    private function setUserToBeImpersonated($actAs)
227
    {
228
        if (! empty($actAs)) {
229
            if (version_compare($this->laravel->version(), '5.2.0', '<')) {
230
                $userModel = config('auth.model');
231
                $user = $userModel::find((int) $actAs);
232
                $this->laravel['auth']->setUser($user);
233
            } else {
234
                $userModel = config('auth.providers.users.model');
235
                $user = $userModel::find((int) $actAs);
236
                $this->laravel['auth']->guard()->setUser($user);
237
            }
238
        }
239
    }
240
241
    /**
242
     * @return mixed
243
     */
244
    private function getRoutes()
245
    {
246
        if ($this->option('router') === 'laravel') {
247
            return Route::getRoutes();
248
        } else {
249
            return app('Dingo\Api\Routing\Router')->getRoutes()[$this->option('routePrefix')];
250
        }
251
    }
252
253
    /**
254
     * @param AbstractGenerator  $generator
255
     * @param $allowedRoutes
256
     * @param $routePrefix
257
     *
258
     * @return array
259
     */
260
    private function processLaravelRoutes(AbstractGenerator $generator, $allowedRoutes, $routePrefix, $middleware)
261
    {
262
        $withResponse = $this->option('noResponseCalls') === false;
263
        $routes = $this->getRoutes();
264
        $bindings = $this->getBindings();
265
        $parsedRoutes = [];
266
        foreach ($routes as $route) {
267
            if (in_array($route->getName(), $allowedRoutes) || str_is($routePrefix, $generator->getUri($route)) || in_array($middleware, $route->middleware())) {
268
                if ($this->isValidRoute($route) && $this->isRouteVisibleForDocumentation($route->getAction()['uses'])) {
269
                    $parsedRoutes[] = $generator->processRoute($route, $bindings, $this->option('header'), $withResponse);
0 ignored issues
show
Documentation introduced by
$this->option('header') 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...
Unused Code introduced by
The call to AbstractGenerator::processRoute() has too many arguments starting with $withResponse.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
270
                    $this->info('Processed route: ['.implode(',', $generator->getMethods($route)).'] '.$generator->getUri($route));
271
                } else {
272
                    $this->warn('Skipping route: ['.implode(',', $generator->getMethods($route)).'] '.$generator->getUri($route));
273
                }
274
            }
275
        }
276
277
        return $parsedRoutes;
278
    }
279
280
    /**
281
     * @param AbstractGenerator $generator
282
     * @param $allowedRoutes
283
     * @param $routePrefix
284
     *
285
     * @return array
286
     */
287
    private function processDingoRoutes(AbstractGenerator $generator, $allowedRoutes, $routePrefix, $middleware)
288
    {
289
        $withResponse = $this->option('noResponseCalls') === false;
290
        $routes = $this->getRoutes();
291
        $bindings = $this->getBindings();
292
        $parsedRoutes = [];
293
        foreach ($routes as $route) {
294
            if (empty($allowedRoutes) || in_array($route->getName(), $allowedRoutes) || str_is($routePrefix, $route->uri()) || in_array($middleware, $route->middleware())) {
295
                if ($this->isValidRoute($route) && $this->isRouteVisibleForDocumentation($route->getAction()['uses'])) {
296
                    $parsedRoutes[] = $generator->processRoute($route, $bindings, $this->option('header'), $withResponse);
0 ignored issues
show
Documentation introduced by
$this->option('header') 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...
Unused Code introduced by
The call to AbstractGenerator::processRoute() has too many arguments starting with $withResponse.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
297
                    $this->info('Processed route: ['.implode(',', $route->getMethods()).'] '.$route->uri());
298
                } else {
299
                    $this->warn('Skipping route: ['.implode(',', $route->getMethods()).'] '.$route->uri());
300
                }
301
            }
302
        }
303
304
        return $parsedRoutes;
305
    }
306
307
    /**
308
     * @param $route
309
     *
310
     * @return bool
311
     */
312
    private function isValidRoute($route)
313
    {
314
        return ! is_callable($route->getAction()['uses']) && ! is_null($route->getAction()['uses']);
315
    }
316
317
    /**
318
     * @param $route
319
     *
320
     * @return bool
321
     */
322
    private function isRouteVisibleForDocumentation($route)
323
    {
324
        list($class, $method) = explode('@', $route);
325
        $reflection = new ReflectionClass($class);
326
        $comment = $reflection->getMethod($method)->getDocComment();
327
        if ($comment) {
328
            $phpdoc = new DocBlock($comment);
329
330
            return collect($phpdoc->getTags())
331
                ->filter(function ($tag) use ($route) {
332
                    return $tag->getName() === 'hideFromAPIDocumentation';
333
                })
334
                ->isEmpty();
335
        }
336
337
        return true;
338
    }
339
340
    /**
341
     * Generate Postman collection JSON file.
342
     *
343
     * @param Collection $routes
344
     *
345
     * @return string
346
     */
347
    private function generatePostmanCollection(Collection $routes)
348
    {
349
        $writer = new CollectionWriter($routes);
350
351
        return $writer->getCollection();
352
    }
353
}
354