GenerateDocumentationTest.php 17 KB

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