GenerateDocumentation.php 12 KB

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