GenerateDocumentation.php 12 KB

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