GenerateDocumentation.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. <?php
  2. namespace Mpociot\ApiDoc\Commands;
  3. use Mpociot\ApiDoc\Tools\DocumentationConfig;
  4. use ReflectionClass;
  5. use ReflectionException;
  6. use Illuminate\Routing\Route;
  7. use Illuminate\Console\Command;
  8. use Mpociot\Reflection\DocBlock;
  9. use Illuminate\Support\Collection;
  10. use Illuminate\Support\Facades\URL;
  11. use Mpociot\ApiDoc\Tools\Generator;
  12. use Mpociot\ApiDoc\Tools\RouteMatcher;
  13. use Mpociot\Documentarian\Documentarian;
  14. use Mpociot\ApiDoc\Postman\CollectionWriter;
  15. class GenerateDocumentation extends Command
  16. {
  17. /**
  18. * The name and signature of the console command.
  19. *
  20. * @var string
  21. */
  22. protected $signature = 'apidoc:generate
  23. {--force : Force rewriting of existing routes}
  24. ';
  25. /**
  26. * The console command description.
  27. *
  28. * @var string
  29. */
  30. protected $description = 'Generate your API documentation from existing Laravel routes.';
  31. private $routeMatcher;
  32. /**
  33. * @var DocumentationConfig
  34. */
  35. private $docConfig;
  36. public function __construct(RouteMatcher $routeMatcher)
  37. {
  38. parent::__construct();
  39. $this->routeMatcher = $routeMatcher;
  40. }
  41. /**
  42. * Execute the console command.
  43. *
  44. * @return void
  45. */
  46. public function handle()
  47. {
  48. $this->docConfig = new DocumentationConfig(config('apidoc'));
  49. try {
  50. URL::forceRootUrl($this->docConfig->get('base_url'));
  51. } catch (\Exception $e) {
  52. echo "Warning: Couldn't force base url as your version of Lumen doesn't have the forceRootUrl method.\n";
  53. echo "You should probably double check URLs in your generated documentation.\n";
  54. }
  55. $usingDingoRouter = strtolower($this->docConfig->get('router')) == 'dingo';
  56. $routes = $this->docConfig->get('routes');
  57. if ($usingDingoRouter) {
  58. $routes = $this->routeMatcher->getDingoRoutesToBeDocumented($routes);
  59. } else {
  60. $routes = $this->routeMatcher->getLaravelRoutesToBeDocumented($routes);
  61. }
  62. $generator = new Generator($this->docConfig);
  63. $parsedRoutes = $this->processRoutes($generator, $routes);
  64. $parsedRoutes = collect($parsedRoutes)->groupBy('group')
  65. ->sortBy(static function ($group) {
  66. /* @var $group Collection */
  67. return $group->first()['group'];
  68. }, SORT_NATURAL);
  69. $this->writeMarkdown($parsedRoutes);
  70. }
  71. /**
  72. * @param Collection $parsedRoutes
  73. *
  74. * @return void
  75. */
  76. private function writeMarkdown($parsedRoutes)
  77. {
  78. $outputPath = $this->docConfig->get('output');
  79. $targetFile = $outputPath.DIRECTORY_SEPARATOR.'source'.DIRECTORY_SEPARATOR.'index.md';
  80. $compareFile = $outputPath.DIRECTORY_SEPARATOR.'source'.DIRECTORY_SEPARATOR.'.compare.md';
  81. $prependFile = $outputPath.DIRECTORY_SEPARATOR.'source'.DIRECTORY_SEPARATOR.'prepend.md';
  82. $appendFile = $outputPath.DIRECTORY_SEPARATOR.'source'.DIRECTORY_SEPARATOR.'append.md';
  83. $infoText = view('apidoc::partials.info')
  84. ->with('outputPath', ltrim($outputPath, 'public/'))
  85. ->with('showPostmanCollectionButton', $this->shouldGeneratePostmanCollection());
  86. $settings = ['languages' => $this->docConfig->get('example_languages')];
  87. $parsedRouteOutput = $parsedRoutes->map(function ($routeGroup) use ($settings) {
  88. return $routeGroup->map(function ($route) use ($settings) {
  89. if (count($route['cleanBodyParameters']) && ! isset($route['headers']['Content-Type'])) {
  90. $route['headers']['Content-Type'] = 'application/json';
  91. }
  92. $route['output'] = (string) view('apidoc::partials.route')
  93. ->with('route', $route)
  94. ->with('settings', $settings)
  95. ->with('baseUrl', $this->docConfig->get('base_url'))
  96. ->render();
  97. return $route;
  98. });
  99. });
  100. $frontmatter = view('apidoc::partials.frontmatter')
  101. ->with('settings', $settings);
  102. /*
  103. * In case the target file already exists, we should check if the documentation was modified
  104. * and skip the modified parts of the routes.
  105. */
  106. if (file_exists($targetFile) && file_exists($compareFile)) {
  107. $generatedDocumentation = file_get_contents($targetFile);
  108. $compareDocumentation = file_get_contents($compareFile);
  109. if (preg_match('/---(.*)---\\s<!-- START_INFO -->/is', $generatedDocumentation, $generatedFrontmatter)) {
  110. $frontmatter = trim($generatedFrontmatter[1], "\n");
  111. }
  112. $parsedRouteOutput->transform(function ($routeGroup) use ($generatedDocumentation, $compareDocumentation) {
  113. return $routeGroup->transform(function ($route) use ($generatedDocumentation, $compareDocumentation) {
  114. if (preg_match('/<!-- START_'.$route['id'].' -->(.*)<!-- END_'.$route['id'].' -->/is', $generatedDocumentation, $existingRouteDoc)) {
  115. $routeDocumentationChanged = (preg_match('/<!-- START_'.$route['id'].' -->(.*)<!-- END_'.$route['id'].' -->/is', $compareDocumentation, $lastDocWeGeneratedForThisRoute) && $lastDocWeGeneratedForThisRoute[1] !== $existingRouteDoc[1]);
  116. if ($routeDocumentationChanged === false || $this->option('force')) {
  117. if ($routeDocumentationChanged) {
  118. $this->warn('Discarded manual changes for route ['.implode(',', $route['methods']).'] '.$route['uri']);
  119. }
  120. } else {
  121. $this->warn('Skipping modified route ['.implode(',', $route['methods']).'] '.$route['uri']);
  122. $route['modified_output'] = $existingRouteDoc[0];
  123. }
  124. }
  125. return $route;
  126. });
  127. });
  128. }
  129. $prependFileContents = file_exists($prependFile)
  130. ? file_get_contents($prependFile)."\n" : '';
  131. $appendFileContents = file_exists($appendFile)
  132. ? "\n".file_get_contents($appendFile) : '';
  133. $documentarian = new Documentarian();
  134. $markdown = view('apidoc::documentarian')
  135. ->with('writeCompareFile', false)
  136. ->with('frontmatter', $frontmatter)
  137. ->with('infoText', $infoText)
  138. ->with('prependMd', $prependFileContents)
  139. ->with('appendMd', $appendFileContents)
  140. ->with('outputPath', $this->docConfig->get('output'))
  141. ->with('showPostmanCollectionButton', $this->shouldGeneratePostmanCollection())
  142. ->with('parsedRoutes', $parsedRouteOutput);
  143. if (! is_dir($outputPath)) {
  144. $documentarian->create($outputPath);
  145. }
  146. // Write output file
  147. file_put_contents($targetFile, $markdown);
  148. // Write comparable markdown file
  149. $compareMarkdown = view('apidoc::documentarian')
  150. ->with('writeCompareFile', true)
  151. ->with('frontmatter', $frontmatter)
  152. ->with('infoText', $infoText)
  153. ->with('prependMd', $prependFileContents)
  154. ->with('appendMd', $appendFileContents)
  155. ->with('outputPath', $this->docConfig->get('output'))
  156. ->with('showPostmanCollectionButton', $this->shouldGeneratePostmanCollection())
  157. ->with('parsedRoutes', $parsedRouteOutput);
  158. file_put_contents($compareFile, $compareMarkdown);
  159. $this->info('Wrote index.md to: '.$outputPath);
  160. $this->info('Generating API HTML code');
  161. $documentarian->generate($outputPath);
  162. $this->info('Wrote HTML documentation to: '.$outputPath.'/index.html');
  163. if ($this->shouldGeneratePostmanCollection()) {
  164. $this->info('Generating Postman collection');
  165. file_put_contents($outputPath.DIRECTORY_SEPARATOR.'collection.json', $this->generatePostmanCollection($parsedRoutes));
  166. }
  167. if ($logo = $this->docConfig->get('logo')) {
  168. copy(
  169. $logo,
  170. $outputPath.DIRECTORY_SEPARATOR.'images'.DIRECTORY_SEPARATOR.'logo.png'
  171. );
  172. }
  173. }
  174. /**
  175. * @param Generator $generator
  176. * @param array $routes
  177. *
  178. * @return array
  179. */
  180. private function processRoutes(Generator $generator, array $routes)
  181. {
  182. $parsedRoutes = [];
  183. foreach ($routes as $routeItem) {
  184. $route = $routeItem['route'];
  185. /** @var Route $route */
  186. if ($this->isValidRoute($route) && $this->isRouteVisibleForDocumentation($route->getAction()['uses'])) {
  187. $parsedRoutes[] = $generator->processRoute($route, $routeItem['apply']);
  188. $this->info('Processed route: ['.implode(',', $generator->getMethods($route)).'] '.$generator->getUri($route));
  189. } else {
  190. $this->warn('Skipping route: ['.implode(',', $generator->getMethods($route)).'] '.$generator->getUri($route));
  191. }
  192. }
  193. return $parsedRoutes;
  194. }
  195. /**
  196. * @param $route
  197. *
  198. * @return bool
  199. */
  200. private function isValidRoute(Route $route)
  201. {
  202. return ! is_callable($route->getAction()['uses']) && ! is_null($route->getAction()['uses']);
  203. }
  204. /**
  205. * @param $route
  206. *
  207. * @throws ReflectionException
  208. *
  209. * @return bool
  210. */
  211. private function isRouteVisibleForDocumentation($route)
  212. {
  213. list($class, $method) = explode('@', $route);
  214. $reflection = new ReflectionClass($class);
  215. if (! $reflection->hasMethod($method)) {
  216. return false;
  217. }
  218. $comment = $reflection->getMethod($method)->getDocComment();
  219. if ($comment) {
  220. $phpdoc = new DocBlock($comment);
  221. return collect($phpdoc->getTags())
  222. ->filter(function ($tag) use ($route) {
  223. return $tag->getName() === 'hideFromAPIDocumentation';
  224. })
  225. ->isEmpty();
  226. }
  227. return true;
  228. }
  229. /**
  230. * Generate Postman collection JSON file.
  231. *
  232. * @param Collection $routes
  233. *
  234. * @return string
  235. */
  236. private function generatePostmanCollection(Collection $routes)
  237. {
  238. $writer = new CollectionWriter($routes, $this->docConfig->get('base_url'));
  239. return $writer->getCollection();
  240. }
  241. /**
  242. * Checks config if it should generate Postman collection.
  243. *
  244. * @return bool
  245. */
  246. private function shouldGeneratePostmanCollection()
  247. {
  248. return $this->docConfig->get('postman.enabled', is_bool($this->docConfig->get('postman')) ? $this->docConfig->get('postman') : false);
  249. }
  250. }