GenerateDocumentation.php 8.6 KB

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