GenerateDocumentation.php 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  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\Camel;
  8. use Knuckles\Camel\Output\OutputEndpointData;
  9. use Knuckles\Scribe\Exceptions\GroupNotFound;
  10. use Knuckles\Scribe\GroupedEndpoints\GroupedEndpointsFactory;
  11. use Knuckles\Scribe\Matching\RouteMatcherInterface;
  12. use Knuckles\Scribe\Tools\ConsoleOutputUtils as c;
  13. use Knuckles\Scribe\Tools\DocumentationConfig;
  14. use Knuckles\Scribe\Tools\ErrorHandlingUtils as e;
  15. use Knuckles\Scribe\Tools\Globals;
  16. use Knuckles\Scribe\Writing\Writer;
  17. use Shalvah\Upgrader\Upgrader;
  18. class GenerateDocumentation extends Command
  19. {
  20. protected $signature = "scribe:generate
  21. {--force : Discard any changes you've made to the YAML or Markdown files}
  22. {--no-extraction : Skip extraction of route and API info and just transform the YAML and Markdown files into HTML}
  23. {--no-upgrade-check : Skip checking for config file upgrades. Won't make things faster, but can be helpful if the command is buggy}
  24. {--config=scribe : choose which config file to use}
  25. ";
  26. protected $description = 'Generate API documentation from your Laravel/Dingo routes.';
  27. private DocumentationConfig $docConfig;
  28. private bool $shouldExtract;
  29. private bool $forcing;
  30. protected string $configName;
  31. public function handle(RouteMatcherInterface $routeMatcher, GroupedEndpointsFactory $groupedEndpointsFactory): void
  32. {
  33. $this->bootstrap();
  34. if (!empty($this->docConfig->get("default_group"))) {
  35. $this->warn("It looks like you just upgraded to Scribe v4.");
  36. $this->warn("Please run the upgrade command first: `php artisan scribe:upgrade`.");
  37. exit(1);
  38. }
  39. $groupedEndpointsInstance = $groupedEndpointsFactory->make($this, $routeMatcher, $this->configName);
  40. $userDefinedEndpoints = Camel::loadUserDefinedEndpoints(Camel::camelDir($this->configName));
  41. $groupedEndpoints = $this->mergeUserDefinedEndpoints(
  42. $groupedEndpointsInstance->get(),
  43. $userDefinedEndpoints
  44. );
  45. if (!count($userDefinedEndpoints)) {
  46. // Update the example custom file if there were no custom endpoints
  47. $this->writeExampleCustomEndpoint();
  48. }
  49. $writer = new Writer($this->docConfig, $this->configName);
  50. $writer->writeDocs($groupedEndpoints);
  51. if ($groupedEndpointsInstance->hasEncounteredErrors()) {
  52. c::warn('Generated docs, but encountered some errors while processing routes.');
  53. c::warn('Check the output above for details.');
  54. }
  55. $this->upgradeConfigFileIfNeeded();
  56. $this->sayGoodbye();
  57. }
  58. public function isForcing(): bool
  59. {
  60. return $this->forcing;
  61. }
  62. public function shouldExtract(): bool
  63. {
  64. return $this->shouldExtract;
  65. }
  66. public function getDocConfig(): DocumentationConfig
  67. {
  68. return $this->docConfig;
  69. }
  70. public function bootstrap(): void
  71. {
  72. // The --verbose option is included with all Artisan commands.
  73. Globals::$shouldBeVerbose = $this->option('verbose');
  74. c::bootstrapOutput($this->output);
  75. $this->configName = $this->option('config');
  76. if (!config($this->configName)) {
  77. throw new \InvalidArgumentException("The specified config (config/{$this->configName}.php) doesn't exist.");
  78. }
  79. $this->docConfig = new DocumentationConfig(config($this->configName));
  80. // Force root URL so it works in Postman collection
  81. $baseUrl = $this->docConfig->get('base_url') ?? config('app.url');
  82. URL::forceRootUrl($baseUrl);
  83. $this->forcing = $this->option('force');
  84. $this->shouldExtract = !$this->option('no-extraction');
  85. if ($this->forcing && !$this->shouldExtract) {
  86. throw new \InvalidArgumentException("Can't use --force and --no-extraction together.");
  87. }
  88. // Reset this map (useful for tests)
  89. Camel::$groupFileNames = [];
  90. }
  91. protected function mergeUserDefinedEndpoints(array $groupedEndpoints, array $userDefinedEndpoints): array
  92. {
  93. foreach ($userDefinedEndpoints as $endpoint) {
  94. $indexOfGroupWhereThisEndpointShouldBeAdded = Arr::first(array_keys($groupedEndpoints), function ($key) use ($groupedEndpoints, $endpoint) {
  95. $group = $groupedEndpoints[$key];
  96. return $group['name'] === ($endpoint['metadata']['groupName'] ?? $this->docConfig->get('groups.default', ''));
  97. });
  98. if ($indexOfGroupWhereThisEndpointShouldBeAdded !== null) {
  99. $groupedEndpoints[$indexOfGroupWhereThisEndpointShouldBeAdded]['endpoints'][] = OutputEndpointData::fromExtractedEndpointArray($endpoint);
  100. } else {
  101. $newGroup = [
  102. 'name' => $endpoint['metadata']['groupName'] ?? $this->docConfig->get('groups.default', ''),
  103. 'description' => $endpoint['metadata']['groupDescription'] ?? null,
  104. 'endpoints' => [OutputEndpointData::fromExtractedEndpointArray($endpoint)],
  105. ];
  106. // Place the new group directly before/after an existing group
  107. // if `beforeGroup` or `afterGroup` was set.
  108. $beforeGroupName = $endpoint['metadata']['beforeGroup'] ?? null;
  109. $afterGroupName = $endpoint['metadata']['afterGroup'] ?? null;
  110. if ($beforeGroupName) {
  111. $found = false;
  112. $sortedGroupedEndpoints = [];
  113. foreach ($groupedEndpoints as $group) {
  114. if ($group['name'] === $beforeGroupName) {
  115. $found = true;
  116. $sortedGroupedEndpoints[] = $newGroup;
  117. }
  118. $sortedGroupedEndpoints[] = $group;
  119. }
  120. if (!$found) {
  121. throw GroupNotFound::forTag($beforeGroupName, "beforeGroup:");
  122. }
  123. $groupedEndpoints = $sortedGroupedEndpoints;
  124. } else if ($afterGroupName) {
  125. $found = false;
  126. $sortedGroupedEndpoints = [];
  127. foreach ($groupedEndpoints as $group) {
  128. $sortedGroupedEndpoints[] = $group;
  129. if ($group['name'] === $afterGroupName) {
  130. $found = true;
  131. $sortedGroupedEndpoints[] = $newGroup;
  132. }
  133. }
  134. if (!$found) {
  135. throw GroupNotFound::forTag($afterGroupName, "afterGroup:");
  136. }
  137. $groupedEndpoints = $sortedGroupedEndpoints;
  138. } else {
  139. $groupedEndpoints[] = $newGroup;
  140. }
  141. }
  142. }
  143. return $groupedEndpoints;
  144. }
  145. protected function writeExampleCustomEndpoint(): void
  146. {
  147. // We add an example to guide users in case they need to add a custom endpoint.
  148. copy(__DIR__ . '/../../resources/example_custom_endpoint.yaml', Camel::camelDir($this->configName) . '/custom.0.yaml');
  149. }
  150. protected function upgradeConfigFileIfNeeded(): void
  151. {
  152. if ($this->option('no-upgrade-check')) return;
  153. $this->info("Checking for any pending upgrades to your config file...");
  154. try {
  155. $upgrader = Upgrader::ofConfigFile("config/{$this->configName}.php", __DIR__ . '/../../config/scribe.php')
  156. ->dontTouch(
  157. 'routes', 'example_languages', 'database_connections_to_transact', 'strategies', 'laravel.middleware',
  158. 'postman.overrides', 'openapi.overrides', 'groups'
  159. );
  160. $changes = $upgrader->dryRun();
  161. if (!empty($changes)) {
  162. $this->newLine();
  163. $this->warn("You're using an updated version of Scribe, which added new items to the config file.");
  164. $this->info("Here are the changes:");
  165. foreach ($changes as $change) {
  166. $this->info($change["description"]);
  167. }
  168. if (!$this->input->isInteractive()) {
  169. $this->info("Run `php artisan scribe:upgrade` from an interactive terminal to update your config file automatically.");
  170. $this->info(sprintf("Or see the full changelog at: https://github.com/knuckleswtf/scribe/blob/%s/CHANGELOG.md,", Globals::SCRIBE_VERSION));
  171. return;
  172. }
  173. if ($this->confirm("Let's help you update your config file. Accept changes?")) {
  174. $upgrader->upgrade();
  175. $this->info(sprintf("✔ Updated. See the full changelog: https://github.com/knuckleswtf/scribe/blob/%s/CHANGELOG.md", Globals::SCRIBE_VERSION));
  176. }
  177. }
  178. } catch (\Throwable $e) {
  179. $this->warn("Check failed with error:");
  180. e::dumpExceptionIfVerbose($e);
  181. $this->warn("This did not affect your docs. Please report this issue in the project repo: https://github.com/knuckleswtf/scribe");
  182. }
  183. }
  184. protected function sayGoodbye(): void
  185. {
  186. $message = 'All done. ';
  187. if ($this->docConfig->get('type') == 'laravel') {
  188. if ($this->docConfig->get('laravel.add_routes')) {
  189. $message .= 'Visit your docs at ' . url($this->docConfig->get('laravel.docs_url'));
  190. }
  191. } else if (Str::endsWith(base_path('public'), 'public') && Str::startsWith($this->docConfig->get('static.output_path'), 'public/')) {
  192. $message = 'Visit your docs at ' . url(str_replace('public/', '', $this->docConfig->get('static.output_path')));
  193. }
  194. $this->newLine();
  195. c::success($message);
  196. }
  197. }