From 7cf0773864fbdd1772fea9a5ff9e7ffd3360d7d2 Mon Sep 17 00:00:00 2001 From: shalvah Date: Fri, 8 Jul 2022 22:01:43 +0200 Subject: [PATCH] Implement basic subgroup support --- CHANGELOG.md | 6 ++ camel/Camel.php | 21 ++++- camel/Extraction/ExtractedEndpointData.php | 3 +- camel/Extraction/Metadata.php | 1 + config/scribe.php | 18 +++-- resources/example_custom_endpoint.yaml | 4 +- src/Commands/Upgrade.php | 7 +- .../Strategies/Metadata/GetFromDocBlocks.php | 81 +++++++++++-------- tests/Fixtures/TestGroupController.php | 39 +++++++++ tests/GenerateDocumentation/OutputTest.php | 46 ++++++++--- .../Metadata/GetFromDocBlocksTest.php | 23 ++++++ 11 files changed, 191 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cefd38b..c59ae96a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Removed +# 4.0.0 +### Added +- Support for specifying groups and endpoints order in config file ([29ddcfc](https://github.com/knuckleswtf/scribe/commit/29ddcfcf284a06da0ae6cb399d09ee5cf1f9ffa7))) +- Support for specifying example model sources ([39ff208](https://github.com/knuckleswtf/scribe/commit/39ff208085d68eed4c459768ac5a1120934f021a))) +- Support for subgroups ([39ff208](https://github.com/knuckleswtf/scribe/commit/39ff208085d68eed4c459768ac5a1120934f021a))) + ## 3.33.0 (27 June 2022) ### Added - Include description in Postman collection for formdata body parameters ([10faa500](https://github.com/knuckleswtf/scribe/commit/10faa500e36e02d4efcecf8ad5e1d91ba1c7728d))) diff --git a/camel/Camel.php b/camel/Camel.php index d5591d64..011b27f1 100644 --- a/camel/Camel.php +++ b/camel/Camel.php @@ -165,10 +165,23 @@ public static function groupEndpoints(array $endpoints, array $endpointGroupInde if (empty($endpointGroupIndexes)) { $groupName = data_get($endpointsInGroup[0], 'metadata.groupName'); if ($defaultGroupsOrder && isset($defaultGroupsOrder[$groupName])) { - $endpointsOrder = Utils::getTopLevelItemsFromMixedConfigList($defaultGroupsOrder[$groupName]); + $subGroupOrEndpointsOrder = Utils::getTopLevelItemsFromMixedConfigList($defaultGroupsOrder[$groupName]); $sortedEndpoints = $endpointsInGroup->sortBy( - function (ExtractedEndpointData $e) use ($endpointsOrder) { - $index = array_search($e->httpMethods[0].' '.$e->uri, $endpointsOrder); + function (ExtractedEndpointData $e) use ($defaultGroupsOrder, $subGroupOrEndpointsOrder) { + $endpointIdentifier = $e->httpMethods[0].' /'.$e->uri; + $index = array_search($e->metadata->subgroup, $subGroupOrEndpointsOrder); + + if ($index !== false) { + // This is a subgroup + $endpointsOrderInSubgroup = $defaultGroupsOrder[$e->metadata->groupName][$e->metadata->subgroup] ?? null; + if ($endpointsOrderInSubgroup) { + $indexInSubGroup = array_search($endpointIdentifier, $endpointsOrderInSubgroup); + $index = ($indexInSubGroup === false) ? $index : ($index + ($indexInSubGroup * 0.1)); + } + } else { + // This is an endpoint + $index = array_search($endpointIdentifier, $subGroupOrEndpointsOrder); + } return $index === false ? INF : $index; }, ); @@ -238,4 +251,4 @@ public static function getOrderListComparator(array $order): \Closure return strnatcmp($a, $b); }; } -} \ No newline at end of file +} diff --git a/camel/Extraction/ExtractedEndpointData.php b/camel/Extraction/ExtractedEndpointData.php index 4938fd85..f0484a5f 100644 --- a/camel/Extraction/ExtractedEndpointData.php +++ b/camel/Extraction/ExtractedEndpointData.php @@ -196,11 +196,12 @@ public function normalizeResourceParamName(string $uri, Route $route): string public function forSerialisation() { $copy = $this->except( - // Get rid of all duplicate data + // Get rid of all duplicate data 'cleanQueryParameters', 'cleanUrlParameters', 'fileParameters', 'cleanBodyParameters', // and objects used only in extraction 'route', 'controller', 'method', 'auth', ); + // Remove these, since they're on the parent group object $copy->metadata = $copy->metadata->except('groupName', 'groupDescription', 'beforeGroup', 'afterGroup'); return $copy; diff --git a/camel/Extraction/Metadata.php b/camel/Extraction/Metadata.php index 1cee1284..289b2efc 100644 --- a/camel/Extraction/Metadata.php +++ b/camel/Extraction/Metadata.php @@ -7,6 +7,7 @@ class Metadata extends BaseDTO { public ?string $groupName; + public ?string $subgroup; /** * Name of the group that this group should be placed just before. diff --git a/config/scribe.php b/config/scribe.php index d496872f..e348568b 100644 --- a/config/scribe.php +++ b/config/scribe.php @@ -314,17 +314,23 @@ /* * By default, Scribe will sort groups alphabetically, and endpoints in the order their routes are defined. - * You can customise that by listing the groups and endpoints here in the order you want them. + * You can customise that by listing the groups, subgroups and endpoints here in the order you want them. * - * Any groups or endpoints you don't list here will be added as usual after the ones here. - * If an endpoint is listed under a group it doesn't belong in, it will be ignored. - * Note: omit the initial '/' when writing an endpoint. + * Any groups, subgroups or endpoints you don't list here will be added as usual after the ones here. + * If an endpoint/subgroups is listed under a group it doesn't belong in, it will be ignored. + * Note: you must include the initial '/' when writing an endpoint. */ 'order' => [ // 'This group comes first', // 'This group comes next' => [ - // 'POST this-endpoint-comes-first', - // 'GET this-endpoint-comes-next', + // 'POST /this-endpoint-comes-first', + // 'GET /this-endpoint-comes-next', + // ], + // 'This group comes third' => [ + // 'This subgroup comes first' => [ + // 'GET /this-other-endpoint-comes-first', + // 'GET /this-other-endpoint-comes-next', + // ] // ] ], ], diff --git a/resources/example_custom_endpoint.yaml b/resources/example_custom_endpoint.yaml index 8885d971..4b023521 100644 --- a/resources/example_custom_endpoint.yaml +++ b/resources/example_custom_endpoint.yaml @@ -1,6 +1,7 @@ # To include an endpoint that isn't a part of your Laravel app (or belongs to a vendor package), # you can define it in a custom.*.yaml file, like this one. # Each custom file should contain an array of endpoints. Here's an example: +# See https://scribe.knuckles.wtf/laravel/documenting/custom-endpoints#extra-sorting-groups-in-custom-endpoint-files for more options #- httpMethods: # - POST @@ -8,6 +9,7 @@ # metadata: # groupName: The group the endpoint belongs to. Can be a new group or an existing group. # groupDescription: A description for the group. You don't need to set this for every endpoint; once is enough. +# subgroup: You can add a subgroup, too. # title: Do something # description: 'This endpoint allows you to do something.' # authenticated: false @@ -48,4 +50,4 @@ # hey: # name: hey # description: Who knows? -# type: string # This is optional \ No newline at end of file +# type: string # This is optional diff --git a/src/Commands/Upgrade.php b/src/Commands/Upgrade.php index 133a0d27..7de15249 100644 --- a/src/Commands/Upgrade.php +++ b/src/Commands/Upgrade.php @@ -15,8 +15,11 @@ public function handle(): void { $oldConfig = config('scribe'); $upgrader = Upgrader::ofConfigFile('config/scribe.php', __DIR__ . '/../../config/scribe.php') - ->dontTouch('routes', 'laravel.middleware', 'postman.overrides', 'openapi.overrides') - ->move('interactive', 'try_it_out.enabled'); + ->dontTouch('routes', 'laravel.middleware', 'postman.overrides', 'openapi.overrides', + 'example_languages', 'database_connections_to_transact', 'strategies') + ->move('interactive', 'try_it_out.enabled') + ->move('default_group', 'groups.default') + ->move('faker_seed', 'examples.faker_seed'); $changes = $upgrader->dryRun(); if (empty($changes)) { diff --git a/src/Extracting/Strategies/Metadata/GetFromDocBlocks.php b/src/Extracting/Strategies/Metadata/GetFromDocBlocks.php index ff5df0a8..8fd625cd 100644 --- a/src/Extracting/Strategies/Metadata/GetFromDocBlocks.php +++ b/src/Extracting/Strategies/Metadata/GetFromDocBlocks.php @@ -20,11 +20,12 @@ public function __invoke(ExtractedEndpointData $endpointData, array $routeRules) public function getMetadataFromDocBlock(DocBlock $methodDocBlock, DocBlock $classDocBlock): array { - [$routeGroupName, $routeGroupDescription, $routeTitle] = $this->getRouteGroupDescriptionAndTitle($methodDocBlock, $classDocBlock); + [$routeGroupName, $routeGroupDescription, $routeTitle] = $this->getEndpointGroupDetails($methodDocBlock, $classDocBlock); return [ 'groupName' => $routeGroupName, 'groupDescription' => $routeGroupDescription, + 'subgroup' => $this->getEndpointSubGroup($methodDocBlock, $classDocBlock), 'title' => $routeTitle ?: $methodDocBlock->getShortDescription(), 'description' => $methodDocBlock->getLongDescription()->getContents(), 'authenticated' => $this->getAuthStatusFromDocBlock($methodDocBlock, $classDocBlock), @@ -49,46 +50,41 @@ protected function getAuthStatusFromDocBlock(DocBlock $methodDocBlock, DocBlock } /** - * @param DocBlock $methodDocBlock - * @param DocBlock $controllerDocBlock - * * @return array The route group name, the group description, and the route title */ - protected function getRouteGroupDescriptionAndTitle(DocBlock $methodDocBlock, DocBlock $controllerDocBlock) + protected function getEndpointGroupDetails(DocBlock $methodDocBlock, DocBlock $controllerDocBlock) { - // @group tag on the method overrides that on the controller - if (!empty($methodDocBlock->getTags())) { - foreach ($methodDocBlock->getTags() as $tag) { - if ($tag->getName() === 'group') { - $routeGroupParts = explode("\n", trim($tag->getContent())); - $routeGroupName = array_shift($routeGroupParts); - $routeGroupDescription = trim(implode("\n", $routeGroupParts)); - - // If the route has no title (the methodDocBlock's "short description"), - // we'll assume the routeGroupDescription is actually the title - // Something like this: - // /** - // * Fetch cars. <-- This is route title. - // * @group Cars <-- This is group name. - // * APIs for cars. <-- This is group description (not required). - // **/ - // VS - // /** - // * @group Cars <-- This is group name. - // * Fetch cars. <-- This is route title, NOT group description. - // **/ - - // BTW, this is a spaghetti way of doing this. - // It shall be refactored soon. Deus vult!💪 - if (empty($methodDocBlock->getShortDescription())) { - return [$routeGroupName, '', $routeGroupDescription]; - } - - return [$routeGroupName, $routeGroupDescription, $methodDocBlock->getShortDescription()]; + foreach ($methodDocBlock->getTags() as $tag) { + if ($tag->getName() === 'group') { + $routeGroupParts = explode("\n", trim($tag->getContent())); + $routeGroupName = array_shift($routeGroupParts); + $routeGroupDescription = trim(implode("\n", $routeGroupParts)); + + // If the route has no title (the methodDocBlock's "short description"), + // we'll assume the routeGroupDescription is actually the title + // Something like this: + // /** + // * Fetch cars. <-- This is route title. + // * @group Cars <-- This is group name. + // * APIs for cars. <-- This is group description (not required). + // **/ + // VS + // /** + // * @group Cars <-- This is group name. + // * Fetch cars. <-- This is route title, NOT group description. + // **/ + + // BTW, this is a spaghetti way of doing this. + // It shall be refactored soon. Deus vult!💪 + if (empty($methodDocBlock->getShortDescription())) { + return [$routeGroupName, '', $routeGroupDescription]; } + + return [$routeGroupName, $routeGroupDescription, $methodDocBlock->getShortDescription()]; } } + // Fall back to the controller foreach ($controllerDocBlock->getTags() as $tag) { if ($tag->getName() === 'group') { $routeGroupParts = explode("\n", trim($tag->getContent())); @@ -101,4 +97,21 @@ protected function getRouteGroupDescriptionAndTitle(DocBlock $methodDocBlock, Do return [$this->config->get('groups.default'), '', $methodDocBlock->getShortDescription()]; } + + protected function getEndpointSubGroup(DocBlock $methodDocBlock, DocBlock $controllerDocBlock): ?string + { + foreach ($methodDocBlock->getTags() as $tag) { + if ($tag->getName() === 'subgroup') { + return trim($tag->getContent()); + } + } + + foreach ($controllerDocBlock->getTags() as $tag) { + if ($tag->getName() === 'subgroup') { + return trim($tag->getContent()); + } + } + + return null; + } } diff --git a/tests/Fixtures/TestGroupController.php b/tests/Fixtures/TestGroupController.php index 51658a02..a6847a22 100644 --- a/tests/Fixtures/TestGroupController.php +++ b/tests/Fixtures/TestGroupController.php @@ -40,4 +40,43 @@ public function action2() public function action10() { } + + /** + * @group 13. Group 13 + * @subgroup SG B + */ + public function action13a() + { + } + + /** + * @group 13. Group 13 + * @subgroup SG C + */ + public function action13b() + { + } + + /** + * @group 13. Group 13 + */ + public function action13c() + { + } + + /** + * @group 13. Group 13 + * @subgroup SG B + */ + public function action13d() + { + } + + /** + * @group 13. Group 13 + * @subgroup SG A + */ + public function action13e() + { + } } diff --git a/tests/GenerateDocumentation/OutputTest.php b/tests/GenerateDocumentation/OutputTest.php index 1e26e8e0..3fc98781 100644 --- a/tests/GenerateDocumentation/OutputTest.php +++ b/tests/GenerateDocumentation/OutputTest.php @@ -191,31 +191,57 @@ public function sorts_group_naturally() /** @test */ public function sorts_groups_and_endpoints_in_the_specified_order() { - $order = [ + config(['scribe.groups.order' => [ '10. Group 10', '1. Group 1' => [ - 'GET api/action1b', - 'GET api/action1', + 'GET /api/action1b', + 'GET /api/action1', ], - ]; - config(['scribe.groups.order' => $order]); + '13. Group 13' => [ + 'SG B' => [ + 'POST /api/action13d', + 'GET /api/action13a', + ], + 'SG A', + 'PUT /api/action13c', + 'POST /api/action13b', + ], + ]]); - RouteFacade::get('/api/action1', TestGroupController::class . '@action1'); - RouteFacade::get('/api/action1b', TestGroupController::class . '@action1b'); - RouteFacade::get('/api/action2', TestGroupController::class . '@action2'); - RouteFacade::get('/api/action10', TestGroupController::class . '@action10'); + RouteFacade::get('/api/action1', [TestGroupController::class, 'action1']); + RouteFacade::get('/api/action1b', [TestGroupController::class, 'action1b']); + RouteFacade::get('/api/action2', [TestGroupController::class, 'action2']); + RouteFacade::get('/api/action10', [TestGroupController::class, 'action10']); + RouteFacade::get('/api/action13a', [TestGroupController::class, 'action13a']); + RouteFacade::post('/api/action13b', [TestGroupController::class, 'action13b']); + RouteFacade::put('/api/action13c', [TestGroupController::class, 'action13c']); + RouteFacade::post('/api/action13d', [TestGroupController::class, 'action13d']); + RouteFacade::get('/api/action13e', [TestGroupController::class, 'action13e']); $this->generate(); $this->assertEquals('10. Group 10', Yaml::parseFile('.scribe/endpoints/00.yaml')['name']); $secondGroup = Yaml::parseFile('.scribe/endpoints/01.yaml'); $this->assertEquals('1. Group 1', $secondGroup['name']); - $this->assertEquals('2. Group 2', Yaml::parseFile('.scribe/endpoints/02.yaml')['name']); + $thirdGroup = Yaml::parseFile('.scribe/endpoints/02.yaml'); + $this->assertEquals('13. Group 13', $thirdGroup['name']); + $this->assertEquals('2. Group 2', Yaml::parseFile('.scribe/endpoints/03.yaml')['name']); $this->assertEquals('api/action1b', $secondGroup['endpoints'][0]['uri']); $this->assertEquals('GET', $secondGroup['endpoints'][0]['httpMethods'][0]); $this->assertEquals('api/action1', $secondGroup['endpoints'][1]['uri']); $this->assertEquals('GET', $secondGroup['endpoints'][1]['httpMethods'][0]); + + $this->assertEquals('api/action13d', $thirdGroup['endpoints'][0]['uri']); + $this->assertEquals('POST', $thirdGroup['endpoints'][0]['httpMethods'][0]); + $this->assertEquals('api/action13a', $thirdGroup['endpoints'][1]['uri']); + $this->assertEquals('GET', $thirdGroup['endpoints'][1]['httpMethods'][0]); + $this->assertEquals('api/action13e', $thirdGroup['endpoints'][2]['uri']); + $this->assertEquals('GET', $thirdGroup['endpoints'][2]['httpMethods'][0]); + $this->assertEquals('api/action13c', $thirdGroup['endpoints'][3]['uri']); + $this->assertEquals('PUT', $thirdGroup['endpoints'][3]['httpMethods'][0]); + $this->assertEquals('api/action13b', $thirdGroup['endpoints'][4]['uri']); + $this->assertEquals('POST', $thirdGroup['endpoints'][4]['httpMethods'][0]); } /** @test */ diff --git a/tests/Strategies/Metadata/GetFromDocBlocksTest.php b/tests/Strategies/Metadata/GetFromDocBlocksTest.php index 45076564..e4e676b0 100644 --- a/tests/Strategies/Metadata/GetFromDocBlocksTest.php +++ b/tests/Strategies/Metadata/GetFromDocBlocksTest.php @@ -12,6 +12,26 @@ class GetFromDocBlocksTest extends TestCase { use ArraySubsetAsserts; + /** @test */ + public function can_fetch_metadata_from_method_docblock() + { + $strategy = new GetFromDocBlocks(new DocumentationConfig([])); + $methodDocblock = <<getMetadataFromDocBlock(new DocBlock($methodDocblock), new DocBlock($classDocblock)); + + $this->assertFalse($results['authenticated']); + $this->assertNull($results['subgroup']); + $this->assertSame('Endpoint title.', $results['title']); + $this->assertSame("Endpoint description.\nMultiline.", $results['description']); + } + /** @test */ public function can_fetch_metadata_from_method_and_class() { @@ -32,6 +52,7 @@ public function can_fetch_metadata_from_method_and_class() $results = $strategy->getMetadataFromDocBlock(new DocBlock($methodDocblock), new DocBlock($classDocblock)); $this->assertFalse($results['authenticated']); + $this->assertNull($results['subgroup']); $this->assertSame('Group A', $results['groupName']); $this->assertSame('Group description.', $results['groupDescription']); $this->assertSame('Endpoint title.', $results['title']); @@ -46,12 +67,14 @@ public function can_fetch_metadata_from_method_and_class() $classDocblock = <<getMetadataFromDocBlock(new DocBlock($methodDocblock), new DocBlock($classDocblock)); $this->assertTrue($results['authenticated']); $this->assertSame(null, $results['groupName']); + $this->assertSame('Scheiße', $results['subgroup']); $this->assertSame('', $results['groupDescription']); $this->assertSame('Endpoint title.', $results['title']); $this->assertSame("", $results['description']);