GenerateDocumentation.php 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. <?php
  2. namespace Knuckles\Scribe\Commands;
  3. use Illuminate\Console\Command;
  4. use Illuminate\Support\Arr;
  5. use Illuminate\Support\Facades\URL;
  6. use Illuminate\Support\Str;
  7. use Knuckles\Camel\Extraction\EndpointData as ExtractedEndpointData;
  8. use Knuckles\Camel\Output\EndpointData;
  9. use Knuckles\Camel\Output\EndpointData as OutputEndpointData;
  10. use Knuckles\Camel\Output\Group;
  11. use Knuckles\Camel\Camel;
  12. use Knuckles\Scribe\Extracting\Extractor;
  13. use Knuckles\Scribe\Matching\MatchedRoute;
  14. use Knuckles\Scribe\Matching\RouteMatcherInterface;
  15. use Knuckles\Scribe\Tools\ConsoleOutputUtils as c;
  16. use Knuckles\Scribe\Tools\DocumentationConfig;
  17. use Knuckles\Scribe\Tools\ErrorHandlingUtils as e;
  18. use Knuckles\Scribe\Tools\Globals;
  19. use Knuckles\Scribe\Tools\Utils;
  20. use Knuckles\Scribe\Tools\Utils as u;
  21. use Knuckles\Scribe\Writing\Writer;
  22. use Mpociot\Reflection\DocBlock;
  23. use Mpociot\Reflection\DocBlock\Tag;
  24. use ReflectionClass;
  25. use Symfony\Component\Yaml\Yaml;
  26. class GenerateDocumentation extends Command
  27. {
  28. protected $signature = "scribe:generate
  29. {--force : Discard any changes you've made to the Markdown files}
  30. {--no-extraction : Skip extraction of route info and just transform the Markdown files}
  31. ";
  32. protected $description = 'Generate API documentation from your Laravel/Dingo routes.';
  33. /**
  34. * @var DocumentationConfig
  35. */
  36. private $docConfig;
  37. public static $camelDir = ".scribe/endpoints";
  38. public static $cacheDir = ".scribe/endpoints.cache";
  39. /**
  40. * @var bool
  41. */
  42. private $shouldExtract;
  43. /**
  44. * @var bool
  45. */
  46. private $forcing;
  47. public function handle(RouteMatcherInterface $routeMatcher): void
  48. {
  49. $this->bootstrap();
  50. if ($this->forcing) {
  51. $routes = $routeMatcher->getRoutes($this->docConfig->get('routes'), $this->docConfig->get('router'));
  52. $endpoints = $this->extractEndpointsInfo($routes);
  53. $groupedEndpoints = Camel::groupEndpoints($endpoints);
  54. $this->writeEndpointsToDisk($groupedEndpoints);
  55. } else if ($this->shouldExtract) {
  56. $latestEndpointsData = [];
  57. $cachedEndpoints = [];
  58. if (is_dir(static::$camelDir) && is_dir(static::$cacheDir)) {
  59. $latestEndpointsData = Camel::loadEndpointsToFlatPrimitivesArray(static::$camelDir);
  60. $cachedEndpoints = Camel::loadEndpointsToFlatPrimitivesArray(static::$cacheDir);
  61. }
  62. $routes = $routeMatcher->getRoutes($this->docConfig->get('routes'), $this->docConfig->get('router'));
  63. $endpoints = $this->extractEndpointsInfo($routes, $cachedEndpoints, $latestEndpointsData);
  64. $groupedEndpoints = Camel::groupEndpoints($endpoints);
  65. $this->writeEndpointsToDisk($groupedEndpoints);
  66. } else {
  67. if (!is_dir(static::$camelDir)) {
  68. throw new \Exception("Can't use --no-extraction because there are no endpoints in the {static::$camelDir} directory.");
  69. }
  70. $groupedEndpoints = Camel::loadEndpointsIntoGroups(static::$camelDir);
  71. }
  72. $writer = new Writer($this->docConfig, $this->forcing);
  73. $writer->writeDocs($groupedEndpoints);
  74. }
  75. /**
  76. * @param MatchedRoute[] $matches
  77. * @param array $cachedEndpoints
  78. * @param array $latestEndpointsData
  79. *
  80. * @return array
  81. */
  82. private function extractEndpointsInfo(array $matches, array $cachedEndpoints = [], array $latestEndpointsData = []): array
  83. {
  84. $generator = new Extractor($this->docConfig);
  85. $parsedRoutes = [];
  86. foreach ($matches as $routeItem) {
  87. $route = $routeItem->getRoute();
  88. $routeControllerAndMethod = u::getRouteClassAndMethodNames($route);
  89. if (!$this->isValidRoute($routeControllerAndMethod)) {
  90. c::warn('Skipping invalid route: ' . c::getRouteRepresentation($route));
  91. continue;
  92. }
  93. if (!$this->doesControllerMethodExist($routeControllerAndMethod)) {
  94. c::warn('Skipping route: ' . c::getRouteRepresentation($route) . ' - Controller method does not exist.');
  95. continue;
  96. }
  97. if ($this->isRouteHiddenFromDocumentation($routeControllerAndMethod)) {
  98. c::warn('Skipping route: ' . c::getRouteRepresentation($route) . ': @hideFromAPIDocumentation was specified.');
  99. continue;
  100. }
  101. try {
  102. c::info('Processing route: ' . c::getRouteRepresentation($route));
  103. $currentEndpointData = $generator->processRoute($route, $routeItem->getRules());
  104. // If latest data is different from cached data, merge latest into current
  105. $currentEndpointData = $this->mergeAnyEndpointDataUpdates($currentEndpointData, $cachedEndpoints, $latestEndpointsData);
  106. $parsedRoutes[] = $currentEndpointData;
  107. c::success('Processed route: ' . c::getRouteRepresentation($route));
  108. } catch (\Exception $exception) {
  109. c::error('Failed processing route: ' . c::getRouteRepresentation($route) . ' - Exception encountered.');
  110. e::dumpExceptionIfVerbose($exception);
  111. }
  112. }
  113. return $parsedRoutes;
  114. }
  115. private function mergeAnyEndpointDataUpdates(ExtractedEndpointData $endpointData, array $cachedEndpoints, array $latestEndpointsData): ExtractedEndpointData
  116. {
  117. // First, find the corresponding endpoint in cached and latest
  118. $thisEndpointCached = Arr::first($cachedEndpoints, function ($endpoint) use ($endpointData) {
  119. return $endpoint['uri'] === $endpointData->uri && $endpoint['methods'] === $endpointData->methods;
  120. });
  121. if (!$thisEndpointCached) {
  122. return $endpointData;
  123. }
  124. $thisEndpointLatest = Arr::first($latestEndpointsData, function ($endpoint) use ($endpointData) {
  125. return $endpoint['uri'] === $endpointData->uri && $endpoint['methods'] == $endpointData->methods;
  126. });
  127. if (!$thisEndpointLatest) {
  128. return $endpointData;
  129. }
  130. // Then compare cached and latest to see what sections changed.
  131. $properties = [
  132. 'metadata',
  133. 'headers',
  134. 'urlParameters',
  135. 'queryParameters',
  136. 'bodyParameters',
  137. 'responses',
  138. 'responseFields',
  139. 'auth',
  140. ];
  141. $changed = [];
  142. foreach ($properties as $property) {
  143. if ($thisEndpointCached[$property] != $thisEndpointLatest[$property]) {
  144. $changed[] = $property;
  145. }
  146. }
  147. // Finally, merge any changed sections.
  148. foreach ($changed as $property) {
  149. $thisEndpointLatest = OutputEndpointData::create($thisEndpointLatest);
  150. $endpointData->$property = $thisEndpointLatest->$property;
  151. }
  152. return $endpointData;
  153. }
  154. private function isValidRoute(array $routeControllerAndMethod = null): bool
  155. {
  156. if (is_array($routeControllerAndMethod)) {
  157. [$classOrObject, $method] = $routeControllerAndMethod;
  158. if (u::isInvokableObject($classOrObject)) {
  159. return true;
  160. }
  161. $routeControllerAndMethod = $classOrObject . '@' . $method;
  162. }
  163. return !is_callable($routeControllerAndMethod) && !is_null($routeControllerAndMethod);
  164. }
  165. private function doesControllerMethodExist(array $routeControllerAndMethod): bool
  166. {
  167. [$class, $method] = $routeControllerAndMethod;
  168. $reflection = new ReflectionClass($class);
  169. if ($reflection->hasMethod($method)) {
  170. return true;
  171. }
  172. return false;
  173. }
  174. private function isRouteHiddenFromDocumentation(array $routeControllerAndMethod): bool
  175. {
  176. if (!($class = $routeControllerAndMethod[0]) instanceof \Closure) {
  177. $classDocBlock = new DocBlock((new ReflectionClass($class))->getDocComment() ?: '');
  178. $shouldIgnoreClass = collect($classDocBlock->getTags())
  179. ->filter(function (Tag $tag) {
  180. return Str::lower($tag->getName()) === 'hidefromapidocumentation';
  181. })->isNotEmpty();
  182. if ($shouldIgnoreClass) {
  183. return true;
  184. }
  185. }
  186. $methodDocBlock = new DocBlock(u::getReflectedRouteMethod($routeControllerAndMethod)->getDocComment() ?: '');
  187. $shouldIgnoreMethod = collect($methodDocBlock->getTags())
  188. ->filter(function (Tag $tag) {
  189. return Str::lower($tag->getName()) === 'hidefromapidocumentation';
  190. })->isNotEmpty();
  191. return $shouldIgnoreMethod;
  192. }
  193. public function bootstrap(): void
  194. {
  195. // The --verbose option is included with all Artisan commands.
  196. Globals::$shouldBeVerbose = $this->option('verbose');
  197. c::bootstrapOutput($this->output);
  198. $this->docConfig = new DocumentationConfig(config('scribe'));
  199. // Force root URL so it works in Postman collection
  200. $baseUrl = $this->docConfig->get('base_url') ?? config('app.url');
  201. URL::forceRootUrl($baseUrl);
  202. $this->forcing = $this->option('force');
  203. $this->shouldExtract = !$this->option('no-extraction');
  204. if ($this->forcing && !$this->shouldExtract) {
  205. throw new \Exception("Can't use --force and --no-extraction together.");
  206. }
  207. }
  208. protected function writeEndpointsToDisk(array $grouped): void
  209. {
  210. Utils::deleteDirectoryAndContents(static::$camelDir);
  211. Utils::deleteDirectoryAndContents(static::$cacheDir);
  212. if (!is_dir(static::$camelDir)) {
  213. mkdir(static::$camelDir, 0777, true);
  214. }
  215. if (!is_dir(static::$cacheDir)) {
  216. mkdir(static::$cacheDir, 0777, true);
  217. }
  218. $i = 0;
  219. foreach ($grouped as $group) {
  220. $group['endpoints'] = array_map(function (EndpointData $endpoint) {
  221. return $endpoint->toArray();
  222. }, $group['endpoints']);
  223. $yaml = Yaml::dump(
  224. $group, 10, 2,
  225. Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE | Yaml::DUMP_OBJECT_AS_MAP | Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK
  226. );
  227. file_put_contents(static::$camelDir."/$i.yaml", $yaml);
  228. file_put_contents(static::$cacheDir."/$i.yaml", "## Autogenerated by Scribe. DO NOT MODIFY.\n\n" . $yaml);
  229. $i++;
  230. }
  231. }
  232. }