GenerateDocumentation.php 12 KB

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