GenerateDocumentationTest.php 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488
  1. <?php
  2. namespace Knuckles\Scribe\Tests;
  3. use Illuminate\Support\Facades\Route as RouteFacade;
  4. use Knuckles\Scribe\Tests\Fixtures\TestController;
  5. use Knuckles\Scribe\Tests\Fixtures\TestGroupController;
  6. use Knuckles\Scribe\Tests\Fixtures\TestIgnoreThisController;
  7. use Knuckles\Scribe\Tests\Fixtures\TestPartialResourceController;
  8. use Knuckles\Scribe\Tests\Fixtures\TestResourceController;
  9. use Knuckles\Scribe\Tests\Fixtures\TestUser;
  10. use Knuckles\Scribe\Tools\Utils;
  11. use Symfony\Component\DomCrawler\Crawler;
  12. use Symfony\Component\Yaml\Yaml;
  13. class GenerateDocumentationTest extends BaseLaravelTest
  14. {
  15. use TestHelpers;
  16. protected function setUp(): void
  17. {
  18. parent::setUp();
  19. config(['scribe.database_connections_to_transact' => []]);
  20. $factory = app(\Illuminate\Database\Eloquent\Factory::class);
  21. $factory->define(TestUser::class, function () {
  22. return [
  23. 'id' => 4,
  24. 'first_name' => 'Tested',
  25. 'last_name' => 'Again',
  26. 'email' => 'a@b.com',
  27. ];
  28. });
  29. }
  30. public function tearDown(): void
  31. {
  32. Utils::deleteDirectoryAndContents('public/docs');
  33. Utils::deleteDirectoryAndContents('.scribe');
  34. }
  35. /** @test */
  36. public function can_process_traditional_laravel_route_syntax()
  37. {
  38. RouteFacade::get('/api/test', [TestController::class, 'withEndpointDescription']);
  39. config(['scribe.routes.0.match.prefixes' => ['api/*']]);
  40. $output = $this->artisan('scribe:generate');
  41. $this->assertStringContainsString('Processed route: [GET] api/test', $output);
  42. }
  43. /** @test */
  44. public function can_process_traditional_laravel_head_routes()
  45. {
  46. RouteFacade::addRoute('HEAD', '/api/test', [TestController::class, 'withEndpointDescription']);
  47. config(['scribe.routes.0.match.prefixes' => ['api/*']]);
  48. $output = $this->artisan('scribe:generate');
  49. $this->assertStringContainsString('Processed route: [HEAD] api/test', $output);
  50. }
  51. /**
  52. * @test
  53. * @see https://github.com/knuckleswtf/scribe/issues/53
  54. */
  55. public function can_process_closure_routes()
  56. {
  57. RouteFacade::get('/api/closure', function () {
  58. return 'hi';
  59. });
  60. config(['scribe.routes.0.match.prefixes' => ['api/*']]);
  61. $output = $this->artisan('scribe:generate');
  62. $this->assertStringContainsString('Processed route: [GET] api/closure', $output);
  63. }
  64. /**
  65. * @group dingo
  66. * @test
  67. */
  68. public function can_process_routes_on_dingo()
  69. {
  70. $api = app(\Dingo\Api\Routing\Router::class);
  71. $api->version('v1', function ($api) {
  72. $api->get('/closure', function () {
  73. return 'foo';
  74. });
  75. $api->get('/test', [TestController::class, 'withEndpointDescription']);
  76. });
  77. config(['scribe.routes.0.match.prefixes' => ['*']]);
  78. config(['scribe.routes.0.match.versions' => ['v1']]);
  79. $output = $this->artisan('scribe:generate');
  80. $this->assertStringContainsString('Processed route: [GET] closure', $output);
  81. $this->assertStringContainsString('Processed route: [GET] test', $output);
  82. }
  83. /** @test */
  84. public function can_process_callable_tuple_syntax()
  85. {
  86. RouteFacade::get('/api/array/test', [TestController::class, 'withEndpointDescription']);
  87. config(['scribe.routes.0.match.prefixes' => ['api/*']]);
  88. $output = $this->artisan('scribe:generate');
  89. $this->assertStringContainsString('Processed route: [GET] api/array/test', $output);
  90. }
  91. /** @test */
  92. public function can_skip_methods_and_classes_with_hidefromapidocumentation_tag()
  93. {
  94. RouteFacade::get('/api/skip', [TestController::class, 'skip']);
  95. RouteFacade::get('/api/skipClass', TestIgnoreThisController::class . '@dummy');
  96. RouteFacade::get('/api/test', [TestController::class, 'withEndpointDescription']);
  97. config(['scribe.routes.0.match.prefixes' => ['api/*']]);
  98. $output = $this->artisan('scribe:generate');
  99. $this->assertStringContainsString('Skipping route: [GET] api/skip', $output);
  100. $this->assertStringContainsString('Skipping route: [GET] api/skipClass', $output);
  101. $this->assertStringContainsString('Processed route: [GET] api/test', $output);
  102. }
  103. /** @test */
  104. public function can_skip_nonexistent_response_files()
  105. {
  106. RouteFacade::get('/api/non-existent', [TestController::class, 'withNonExistentResponseFile']);
  107. config(['scribe.routes.0.match.prefixes' => ['api/*']]);
  108. $output = $this->artisan('scribe:generate');
  109. $this->assertStringContainsString('@responseFile i-do-not-exist.json does not exist', $output);
  110. }
  111. /** @test */
  112. public function can_parse_resource_routes()
  113. {
  114. RouteFacade::resource('/api/users', TestResourceController::class)
  115. ->only(['index', 'store']);
  116. config(['scribe.routes.0.match.prefixes' => ['api/*']]);
  117. config([
  118. 'scribe.routes.0.apply.headers' => [
  119. 'Accept' => 'application/json',
  120. ],
  121. ]);
  122. $output = $this->artisan('scribe:generate');
  123. $this->assertStringContainsString('Processed route: [GET] api/users', $output);
  124. $this->assertStringContainsString('Processed route: [POST] api/users', $output);
  125. $this->assertStringNotContainsString('Processed route: [PUT,PATCH] api/users/{user}', $output);
  126. $this->assertStringNotContainsString('Processed route: [DELETE] api/users/{user}', $output);
  127. RouteFacade::apiResource('/api/users', TestResourceController::class)
  128. ->only(['index', 'store']);
  129. $output = $this->artisan('scribe:generate');
  130. $this->assertStringContainsString('Processed route: [GET] api/users', $output);
  131. $this->assertStringContainsString('Processed route: [POST] api/users', $output);
  132. $this->assertStringNotContainsString('Processed route: [PUT,PATCH] api/users/{user}', $output);
  133. $this->assertStringNotContainsString('Processed route: [DELETE] api/users/{user}', $output);
  134. }
  135. /** @test */
  136. public function supports_partial_resource_controller()
  137. {
  138. RouteFacade::resource('/api/users', TestPartialResourceController::class);
  139. config(['scribe.routes.0.match.prefixes' => ['api/*']]);
  140. $output = $this->artisan('scribe:generate');
  141. $this->assertStringContainsString('Processed route: [GET] api/users', $output);
  142. $this->assertStringContainsString('Processed route: [PUT,PATCH] api/users/{user}', $output);
  143. }
  144. /** @test */
  145. public function generated_postman_collection_file_is_correct()
  146. {
  147. RouteFacade::post('/api/withBodyParametersAsArray', [TestController::class, 'withBodyParametersAsArray']);
  148. RouteFacade::post('/api/withFormDataParams', [TestController::class, 'withFormDataParams']);
  149. RouteFacade::post('/api/withBodyParameters', [TestController::class, 'withBodyParameters']);
  150. RouteFacade::get('/api/withQueryParameters', [TestController::class, 'withQueryParameters']);
  151. RouteFacade::get('/api/withAuthTag', [TestController::class, 'withAuthenticatedTag']);
  152. RouteFacade::get('/api/echoesUrlParameters/{param}/{param2}/{param3?}/{param4?}', [TestController::class, 'echoesUrlParameters']);
  153. // We want to have the same values for params each time
  154. config(['scribe.faker_seed' => 1234]);
  155. config(['scribe.title' => 'GREAT API!']);
  156. config(['scribe.auth.enabled' => true]);
  157. config(['scribe.routes.0.match.prefixes' => ['api/*']]);
  158. config(['scribe.postman.overrides' => [
  159. 'info.version' => '3.9.9',
  160. ]]);
  161. config([
  162. 'scribe.routes.0.apply.headers' => [
  163. 'Custom-Header' => 'NotSoCustom',
  164. ],
  165. ]);
  166. config(['scribe.postman.enabled' => true]);
  167. config(['scribe.openapi.enabled' => false]);
  168. $this->artisan('scribe:generate');
  169. $generatedCollection = json_decode(file_get_contents(__DIR__ . '/../public/docs/collection.json'), true);
  170. // The Postman ID varies from call to call; erase it to make the test data reproducible.
  171. $generatedCollection['info']['_postman_id'] = '';
  172. $fixtureCollection = json_decode(file_get_contents(__DIR__ . '/Fixtures/collection.json'), true);
  173. $this->assertEquals($fixtureCollection, $generatedCollection);
  174. }
  175. /** @test */
  176. public function generated_openapi_spec_file_is_correct()
  177. {
  178. RouteFacade::post('/api/withBodyParametersAsArray', [TestController::class, 'withBodyParametersAsArray']);
  179. RouteFacade::post('/api/withFormDataParams', [TestController::class, 'withFormDataParams']);
  180. RouteFacade::get('/api/withResponseTag', [TestController::class, 'withResponseTag']);
  181. RouteFacade::get('/api/withQueryParameters', [TestController::class, 'withQueryParameters']);
  182. RouteFacade::get('/api/withAuthTag', [TestController::class, 'withAuthenticatedTag']);
  183. RouteFacade::get('/api/echoesUrlParameters/{param}/{param2}/{param3?}/{param4?}', [TestController::class, 'echoesUrlParameters']);
  184. // We want to have the same values for params each time
  185. config(['scribe.faker_seed' => 1234]);
  186. config(['scribe.postman.enabled' => false]);
  187. config(['scribe.openapi.enabled' => true]);
  188. config(['scribe.openapi.overrides' => [
  189. 'info.version' => '3.9.9',
  190. ]]);
  191. config(['scribe.routes.0.match.prefixes' => ['api/*']]);
  192. config([
  193. 'scribe.routes.0.apply.headers' => [
  194. 'Custom-Header' => 'NotSoCustom',
  195. ],
  196. ]);
  197. $this->artisan('scribe:generate');
  198. $generatedSpec = Yaml::parseFile(__DIR__ . '/../public/docs/openapi.yaml');
  199. $fixtureSpec = Yaml::parseFile(__DIR__ . '/Fixtures/openapi.yaml');
  200. $this->assertEquals($fixtureSpec, $generatedSpec);
  201. }
  202. /** @test */
  203. public function can_append_custom_http_headers()
  204. {
  205. RouteFacade::get('/api/headers', [TestController::class, 'checkCustomHeaders']);
  206. config(['scribe.routes.0.match.prefixes' => ['api/*']]);
  207. config([
  208. 'scribe.routes.0.apply.headers' => [
  209. 'Authorization' => 'customAuthToken',
  210. 'Custom-Header' => 'NotSoCustom',
  211. ],
  212. ]);
  213. $this->artisan('scribe:generate');
  214. $endpointDetails = Yaml::parseFile(__DIR__ . '/../.scribe/endpoints/0.yaml')['endpoints'][0];
  215. $this->assertEquals("customAuthToken", $endpointDetails['headers']["Authorization"]);
  216. $this->assertEquals("NotSoCustom", $endpointDetails['headers']["Custom-Header"]);
  217. }
  218. /** @test */
  219. public function can_parse_utf8_response()
  220. {
  221. RouteFacade::get('/api/utf8', [TestController::class, 'withUtf8ResponseTag']);
  222. config(['scribe.routes.0.match.prefixes' => ['api/*']]);
  223. $this->artisan('scribe:generate');
  224. $generatedHtml = file_get_contents('public/docs/index.html');
  225. $this->assertStringContainsString('Лорем ипсум долор сит амет', $generatedHtml);
  226. }
  227. /** @test */
  228. public function sorts_group_naturally()
  229. {
  230. RouteFacade::get('/api/action1', TestGroupController::class . '@action1');
  231. RouteFacade::get('/api/action1b', TestGroupController::class . '@action1b');
  232. RouteFacade::get('/api/action2', TestGroupController::class . '@action2');
  233. RouteFacade::get('/api/action10', TestGroupController::class . '@action10');
  234. config(['scribe.routes.0.match.prefixes' => ['api/*']]);
  235. $this->artisan('scribe:generate');
  236. $this->assertFileExists(__DIR__ . '/../.scribe/endpoints/0.yaml');
  237. $this->assertFileExists(__DIR__ . '/../.scribe/endpoints/1.yaml');
  238. $this->assertFileExists(__DIR__ . '/../.scribe/endpoints/2.yaml');
  239. $this->assertEquals('1. Group 1', Yaml::parseFile(__DIR__ . '/../.scribe/endpoints/0.yaml')['name']);
  240. $this->assertEquals('2. Group 2', Yaml::parseFile(__DIR__ . '/../.scribe/endpoints/1.yaml')['name']);
  241. $this->assertEquals('10. Group 10', Yaml::parseFile(__DIR__ . '/../.scribe/endpoints/2.yaml')['name']);
  242. }
  243. /** @test */
  244. public function can_customise_static_output_path()
  245. {
  246. RouteFacade::get('/api/action1', TestGroupController::class . '@action1');
  247. config(['scribe.routes.0.match.prefixes' => ['*']]);
  248. config(['scribe.static.output_path' => 'static/docs']);
  249. $this->artisan('scribe:generate');
  250. $this->assertFileExists('static/docs/index.html');
  251. Utils::deleteDirectoryAndContents('static/');
  252. }
  253. /** @test */
  254. public function will_not_overwrite_manually_modified_content_unless_force_flag_is_set()
  255. {
  256. RouteFacade::get('/api/action1', [TestGroupController::class, 'action1']);
  257. RouteFacade::get('/api/action1b', [TestGroupController::class, 'action1b']);
  258. config(['scribe.routes.0.match.prefixes' => ['api/*']]);
  259. $this->artisan('scribe:generate');
  260. $authFilePath = '.scribe/auth.md';
  261. $group1FilePath = '.scribe/endpoints/0.yaml';
  262. $group = Yaml::parseFile($group1FilePath);
  263. $this->assertEquals('api/action1', $group['endpoints'][0]['uri']);
  264. $this->assertEquals([], $group['endpoints'][0]['urlParameters']);
  265. $extraParam = [
  266. 'name' => 'a_param',
  267. 'description' => 'A URL param.',
  268. 'required' => true,
  269. 'example' => 6,
  270. 'type' => 'integer',
  271. ];
  272. $group['endpoints'][0]['urlParameters']['a_param'] = $extraParam;
  273. file_put_contents($group1FilePath, Yaml::dump(
  274. $group, 20, 2,
  275. Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE | Yaml::DUMP_OBJECT_AS_MAP
  276. ));
  277. file_put_contents($authFilePath, 'Some other useful stuff.', FILE_APPEND);
  278. $this->artisan('scribe:generate');
  279. $group = Yaml::parseFile($group1FilePath);
  280. $this->assertEquals('api/action1', $group['endpoints'][0]['uri']);
  281. $this->assertEquals(['a_param' => $extraParam], $group['endpoints'][0]['urlParameters']);
  282. $this->assertStringContainsString('Some other useful stuff.', file_get_contents($authFilePath));
  283. $this->artisan('scribe:generate', ['--force' => true]);
  284. $group = Yaml::parseFile($group1FilePath);
  285. $this->assertEquals('api/action1', $group['endpoints'][0]['uri']);
  286. $this->assertEquals([], $group['endpoints'][0]['urlParameters']);
  287. $this->assertStringNotContainsString('Some other useful stuff.', file_get_contents($authFilePath));
  288. }
  289. /** @test */
  290. public function generates_correct_url_params_from_resource_routes_and_field_bindings()
  291. {
  292. if (version_compare($this->app->version(), '7.0.0', '<')) {
  293. $this->markTestSkipped("Laravel < 7.x doesn't support field binding syntax.");
  294. return;
  295. }
  296. RouteFacade::prefix('providers/{provider:slug}')->group(function () {
  297. RouteFacade::resource('users.addresses', TestPartialResourceController::class)->parameters([
  298. 'addresses' => 'address:uuid',
  299. ]);
  300. });
  301. config(['scribe.routes.0.match.prefixes' => ['*']]);
  302. config(['scribe.openapi.enabled' => false]);
  303. config(['scribe.postman.enabled' => false]);
  304. $this->artisan('scribe:generate');
  305. $groupA = Yaml::parseFile('.scribe/endpoints/0.yaml');
  306. $this->assertEquals('providers/{provider_slug}/users/{user_id}/addresses', $groupA['endpoints'][0]['uri']);
  307. $groupB = Yaml::parseFile('.scribe/endpoints/1.yaml');
  308. $this->assertEquals('providers/{provider_slug}/users/{user_id}/addresses/{uuid}', $groupB['endpoints'][0]['uri']);
  309. }
  310. /** @test */
  311. public function will_not_extract_if_noExtraction_flag_is_set()
  312. {
  313. config(['scribe.routes.0.exclude' => ['*']]);
  314. config(['scribe.openapi.enabled' => false]);
  315. config(['scribe.postman.enabled' => false]);
  316. Utils::copyDirectory(__DIR__.'/Fixtures/.scribe', '.scribe');
  317. $output = $this->artisan('scribe:generate', ['--no-extraction' => true]);
  318. $this->assertStringNotContainsString("Processing route", $output);
  319. $crawler = new Crawler(file_get_contents('public/docs/index.html'));
  320. [$intro, $auth] = $crawler->filter('h1 + p')->getIterator();
  321. $this->assertEquals('Heyaa introduction!👋', trim($intro->firstChild->textContent));
  322. $this->assertEquals('This is just a test.', trim($auth->firstChild->textContent));
  323. $endpoints = $crawler->filter('h1')->getNode(2);
  324. $this->assertEquals('General', trim($endpoints->textContent));
  325. $expectedEndpoint = $crawler->filter('h2');
  326. $this->assertCount(1, $expectedEndpoint);
  327. $this->assertEquals("Healthcheck", $expectedEndpoint->text());
  328. }
  329. /** @test */
  330. public function merges_user_defined_endpoints()
  331. {
  332. RouteFacade::get('/api/action1', [TestGroupController::class, 'action1']);
  333. RouteFacade::get('/api/action2', [TestGroupController::class, 'action2']);
  334. config(['scribe.routes.0.match.prefixes' => ['api/*']]);
  335. config(['scribe.openapi.enabled' => false]);
  336. config(['scribe.postman.enabled' => false]);
  337. if (!is_dir('.scribe/endpoints'))
  338. mkdir('.scribe/endpoints', 0777, true);
  339. copy(__DIR__ . '/Fixtures/custom.0.yaml', '.scribe/endpoints/custom.0.yaml');
  340. $this->artisan('scribe:generate');
  341. $crawler = new Crawler(file_get_contents('public/docs/index.html'));
  342. $headings = $crawler->filter('h1')->getIterator();
  343. // There should only be four headings — intro, auth and two groups
  344. $this->assertCount(4, $headings);
  345. [$_, $_, $group1, $group2] = $headings;
  346. $this->assertEquals('1. Group 1', trim($group1->textContent));
  347. $this->assertEquals('2. Group 2', trim($group2->textContent));
  348. $expectedEndpoints = $crawler->filter('h2');
  349. $this->assertEquals(3, $expectedEndpoints->count());
  350. // Enforce the order of the endpoints
  351. // Ideally, we should also check the groups they're under
  352. $this->assertEquals("Some endpoint.", $expectedEndpoints->getNode(0)->textContent);
  353. $this->assertEquals("User defined", $expectedEndpoints->getNode(1)->textContent);
  354. $this->assertEquals("GET api/action2", $expectedEndpoints->getNode(2)->textContent);
  355. }
  356. /** @test */
  357. public function respects_endpoints_and_group_sort_order()
  358. {
  359. RouteFacade::get('/api/action1', [TestGroupController::class, 'action1']);
  360. RouteFacade::get('/api/action1b', [TestGroupController::class, 'action1b']);
  361. RouteFacade::get('/api/action2', [TestGroupController::class, 'action2']);
  362. config(['scribe.routes.0.match.prefixes' => ['api/*']]);
  363. config(['scribe.openapi.enabled' => false]);
  364. config(['scribe.postman.enabled' => false]);
  365. $this->artisan('scribe:generate');
  366. // First: verify the current order of the groups and endpoints
  367. $crawler = new Crawler(file_get_contents('public/docs/index.html'));
  368. $h1s = $crawler->filter('h1');
  369. $this->assertEquals('1. Group 1', trim($h1s->getNode(2)->textContent));
  370. $this->assertEquals('2. Group 2', trim($h1s->getNode(3)->textContent));
  371. $expectedEndpoints = $crawler->filter('h2');
  372. $this->assertEquals("Some endpoint.", $expectedEndpoints->getNode(0)->textContent);
  373. $this->assertEquals("Another endpoint.", $expectedEndpoints->getNode(1)->textContent);
  374. $this->assertEquals("GET api/action2", $expectedEndpoints->getNode(2)->textContent);
  375. // Now swap the endpoints
  376. $group = Yaml::parseFile('.scribe/endpoints/0.yaml');
  377. $this->assertEquals('api/action1', $group['endpoints'][0]['uri']);
  378. $this->assertEquals('api/action1b', $group['endpoints'][1]['uri']);
  379. $action1 = $group['endpoints'][0];
  380. $group['endpoints'][0] = $group['endpoints'][1];
  381. $group['endpoints'][1] = $action1;
  382. file_put_contents('.scribe/endpoints/0.yaml', Yaml::dump(
  383. $group, 20, 2,
  384. Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE | Yaml::DUMP_OBJECT_AS_MAP
  385. ));
  386. // And then the groups
  387. rename('.scribe/endpoints/0.yaml', '.scribe/endpoints/temp.yaml');
  388. rename('.scribe/endpoints/1.yaml', '.scribe/endpoints/0.yaml');
  389. rename('.scribe/endpoints/temp.yaml', '.scribe/endpoints/1.yaml');
  390. $this->artisan('scribe:generate');
  391. $crawler = new Crawler(file_get_contents('public/docs/index.html'));
  392. $h1s = $crawler->filter('h1');
  393. $this->assertEquals('2. Group 2', trim($h1s->getNode(2)->textContent));
  394. $this->assertEquals('1. Group 1', trim($h1s->getNode(3)->textContent));
  395. $expectedEndpoints = $crawler->filter('h2');
  396. $this->assertEquals("GET api/action2", $expectedEndpoints->getNode(0)->textContent);
  397. $this->assertEquals("Another endpoint.", $expectedEndpoints->getNode(1)->textContent);
  398. $this->assertEquals("Some endpoint.", $expectedEndpoints->getNode(2)->textContent);
  399. }
  400. }