GenerateDocumentation.php 9.5 KB

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