Przeglądaj źródła

Remove outdated sort methods support; move sorting to output stage

shalvah 2 lat temu
rodzic
commit
527fdc2067

+ 50 - 85
camel/Camel.php

@@ -14,27 +14,12 @@ use Symfony\Component\Yaml\Yaml;
 
 class Camel
 {
-    /**
-     * Mapping of group names to their generated file names. Helps us respect user reordering.
-     * @var array<string, string>
-     */
-    public static array $groupFileNames = [];
-
-    /**
-     * @deprecated Use the cacheDir() method instead
-     */
-    public static string $cacheDir = ".scribe/endpoints.cache";
-    /**
-     * @deprecated Use the camelDir() method instead
-     */
-    public static string $camelDir = ".scribe/endpoints";
-
-    public static function cacheDir(string $docsName = 'scribe')
+    public static function cacheDir(string $docsName = 'scribe'): string
     {
         return ".$docsName/endpoints.cache";
     }
 
-    public static function camelDir(string $docsName = 'scribe')
+    public static function camelDir(string $docsName = 'scribe'): string
     {
         return ".$docsName/endpoints";
     }
@@ -44,44 +29,43 @@ class Camel
      *
      * @param string $folder
      *
-     * @return array[]
+     * @return array[] Each array is a group with keys including `name` and `endpoints`.
      */
     public static function loadEndpointsIntoGroups(string $folder): array
     {
         $groups = [];
-        self::loadEndpointsFromCamelFiles($folder, function ($group) use (&$groups) {
-            $group['endpoints'] = array_map(function (array $endpoint) {
-                return OutputEndpointData::fromExtractedEndpointArray($endpoint);
-            }, $group['endpoints']);
-            $groups[] = $group;
+        self::loadEndpointsFromCamelFiles($folder, function (array $group) use (&$groups) {
+            $groups[$group['name']] = $group;
         });
         return $groups;
     }
 
     /**
      * Load endpoints from the Camel files into a flat list of endpoint arrays.
+     * Useful when we don't care about groups, but simply want to compare endpoints contents
+     * to see if anything changed.
      *
      * @param string $folder
      *
-     * @return array[]
+     * @return array[] List of endpoint arrays.
      */
-    public static function loadEndpointsToFlatPrimitivesArray(string $folder, bool $isFromCache = false): array
+    public static function loadEndpointsToFlatPrimitivesArray(string $folder): array
     {
         $endpoints = [];
-        self::loadEndpointsFromCamelFiles($folder, function ($group) use (&$endpoints) {
+        self::loadEndpointsFromCamelFiles($folder, function (array $group) use (&$endpoints) {
             foreach ($group['endpoints'] as $endpoint) {
                 $endpoints[] = $endpoint;
             }
-        }, !$isFromCache);
+        });
         return $endpoints;
     }
 
-    public static function loadEndpointsFromCamelFiles(string $folder, callable $callback, bool $storeGroupFilePaths = true)
+    public static function loadEndpointsFromCamelFiles(string $folder, callable $callback): void
     {
         $contents = Utils::listDirectoryContents($folder);
 
         foreach ($contents as $object) {
-            // Flysystem v1 had items as arrays; v2 has objects.
+            // todo Flysystem v1 had items as arrays; v2 has objects.
             // v2 allows ArrayAccess, but when we drop v1 support (Laravel <9), we should switch to methods
             if (
                 $object['type'] == 'file'
@@ -89,10 +73,6 @@ class Camel
                 && !Str::startsWith(basename($object['path']), 'custom.')
             ) {
                 $group = Yaml::parseFile($object['path']);
-                if ($storeGroupFilePaths) {
-                    $filePathParts = explode('/', $object['path']);
-                    self::$groupFileNames[$group['name']] = end($filePathParts);
-                }
                 $callback($group);
             }
         }
@@ -128,48 +108,30 @@ class Camel
         }));
     }
 
-    public static function getEndpointIndexInGroup(array $groups, OutputEndpointData $endpoint): ?int
-    {
-        foreach ($groups as $group) {
-            foreach ($group['endpoints'] as $index => $endpointInGroup) {
-                if ($endpointInGroup->endpointId() === $endpoint->endpointId()) {
-                    return $index;
-                }
-            }
-        }
-
-        return null;
-    }
-
     /**
-     * @param array[] $endpoints
-     * @param array $defaultGroupsOrder The order for groups that users specified in their config file.
+     * @param array[] $groupedEndpoints
+     * @param array $configFileOrder The order for groups that users specified in their config file.
      *
      * @return array[]
      */
-    public static function groupEndpoints(array $endpoints, array $defaultGroupsOrder = []): array
+    public static function sortByConfigFileOrder(array $groupedEndpoints, array $configFileOrder): array
     {
-        $groupedEndpoints = collect($endpoints)->groupBy('metadata.groupName');
-
-        if ($defaultGroupsOrder) {
-            // Sort groups based on config file order
-            $groupsOrder = Utils::getTopLevelItemsFromMixedConfigList($defaultGroupsOrder);
-            $groupedEndpoints = $groupedEndpoints->sortKeysUsing(self::getOrderListComparator($groupsOrder));
-        } else {
-            $groupedEndpoints = $groupedEndpoints->sortKeys(SORT_NATURAL);
+        if (empty($configFileOrder)) {
+            ksort($groupedEndpoints, SORT_NATURAL);
+            return $groupedEndpoints;
         }
 
-        return $groupedEndpoints->map(function (Collection $endpointsInGroup) use ($defaultGroupsOrder) {
-            /** @var Collection<(int|string),ExtractedEndpointData> $endpointsInGroup */
-            $sortedEndpoints = $endpointsInGroup;
+        // First, sort groups
+        $groupsOrder = Utils::getTopLevelItemsFromMixedConfigList($configFileOrder);
+        $groupedEndpoints = collect($groupedEndpoints)->sortKeysUsing(self::getOrderListComparator($groupsOrder));
 
-            $groupName = data_get($endpointsInGroup[0], 'metadata.groupName');
+        return $groupedEndpoints->map(function (array $group, string $groupName) use ($configFileOrder) {
+            $sortedEndpoints = collect($group['endpoints']);
 
-            // Sort endpoints in group based on config file order
-            if ($defaultGroupsOrder && isset($defaultGroupsOrder[$groupName])) {
-                $subGroupOrEndpointsOrder = Utils::getTopLevelItemsFromMixedConfigList($defaultGroupsOrder[$groupName]);
-                $sortedEndpoints = $endpointsInGroup->sortBy(
-                    function (ExtractedEndpointData $e) use ($defaultGroupsOrder, $subGroupOrEndpointsOrder) {
+            if ($configFileOrder && isset($configFileOrder[$groupName])) {
+                $subGroupOrEndpointsOrder = Utils::getTopLevelItemsFromMixedConfigList($configFileOrder[$groupName]);
+                $sortedEndpoints = $sortedEndpoints->sortBy(
+                    function (OutputEndpointData $e) use ($configFileOrder, $subGroupOrEndpointsOrder) {
                         $endpointIdentifier = $e->httpMethods[0] . ' /' . $e->uri;
 
                         // First, check if there's an ordering specified for the endpoint's subgroup
@@ -177,7 +139,7 @@ class Camel
 
                         if ($index !== false) {
                             // There's a subgroup order; check if there's an endpoints order within that
-                            $endpointsOrderInSubgroup = $defaultGroupsOrder[$e->metadata->groupName][$e->metadata->subgroup] ?? null;
+                            $endpointsOrderInSubgroup = $configFileOrder[$e->metadata->groupName][$e->metadata->subgroup] ?? null;
                             if ($endpointsOrderInSubgroup) {
                                 $indexInSubGroup = array_search($endpointIdentifier, $endpointsOrderInSubgroup);
                                 $index = ($indexInSubGroup === false) ? $index : ($index + ($indexInSubGroup * 0.1));
@@ -192,37 +154,39 @@ class Camel
             }
 
             return [
-                'name' => Arr::first($endpointsInGroup, function (ExtractedEndpointData $endpointData) {
-                        return !empty($endpointData->metadata->groupName);
-                    })->metadata->groupName ?? '',
-                'description' => Arr::first($endpointsInGroup, function (ExtractedEndpointData $endpointData) {
-                        return !empty($endpointData->metadata->groupDescription);
-                    })->metadata->groupDescription ?? '',
-                'endpoints' => $sortedEndpoints->map(
-                    fn(ExtractedEndpointData $endpointData) => $endpointData->forSerialisation()->toArray()
-                )->values()->all(),
+                'name' => $groupName,
+                'description' => $group['description'],
+                'endpoints' => $sortedEndpoints->all(),
             ];
         })->values()->all();
     }
 
-    public static function prepareGroupedEndpointsForOutput(array $groupedEndpoints): array
+    /**
+     * Prepare endpoints to be turned into HTML.
+     * Map them into OutputEndpointData DTOs, and sort them by the specified order in the config file.
+     *
+     * @param array<string,array[]> $groupedEndpoints
+     *
+     * @return array
+     */
+    public static function prepareGroupedEndpointsForOutput(array $groupedEndpoints, array $configFileOrder = []): array
     {
         $groups = array_map(function (array $group) {
             return [
                 'name' => $group['name'],
                 'description' => $group['description'],
-                'fileName' => self::$groupFileNames[$group['name']] ?? null,
-                'endpoints' => array_map(function (array $endpoint) {
-                    return OutputEndpointData::fromExtractedEndpointArray($endpoint);
-                }, $group['endpoints']),
+                'endpoints' => array_map(
+                    fn(array $endpoint) => OutputEndpointData::fromExtractedEndpointArray($endpoint), $group['endpoints']
+                ),
             ];
         }, $groupedEndpoints);
-        return array_values(Arr::sort($groups, 'fileName'));
+        return Camel::sortByConfigFileOrder($groups, $configFileOrder);
     }
 
     /**
      * Given an $order list like ['first', 'second', ...], return a compare function that can be used to sort
-     * a list of strings based on the $order list. Any strings not in the list are sorted with natural sort.
+     * a list of strings based on the order of items in $order.
+     * Any strings not in the list are sorted with natural sort.
      *
      * @param array $order
      */
@@ -232,16 +196,17 @@ class Camel
             $indexOfA = array_search($a, $order);
             $indexOfB = array_search($b, $order);
 
+            // If both are in the $order list, compare them normally based on their position in the list
             if ($indexOfA !== false && $indexOfB !== false) {
                 return $indexOfA <=> $indexOfB;
             }
 
-            // If only the first is in the default order, then it must come before the second.
+            // If only A is in the $order list, then it must come before B.
             if ($indexOfA !== false) {
                 return -1;
             }
 
-            // If only the second is in the default order, then first must come after it.
+            // If only B is in the $order list, then it must come before A.
             if ($indexOfB !== false) {
                 return 1;
             }

+ 1 - 1
camel/Extraction/ExtractedEndpointData.php

@@ -144,7 +144,7 @@ class ExtractedEndpointData extends BaseDTO
             'route', 'controller', 'method', 'auth',
         );
         // Remove these, since they're on the parent group object
-        $copy->metadata = $copy->metadata->except('groupName', 'groupDescription', 'beforeGroup', 'afterGroup');
+        $copy->metadata = $copy->metadata->except('groupName', 'groupDescription');
 
         return $copy;
     }

+ 0 - 12
camel/Extraction/Metadata.php

@@ -13,16 +13,4 @@ class Metadata extends BaseDTO
     public ?string $title;
     public ?string $description;
     public bool $authenticated = false;
-
-    /**
-     * Name of the group that this group should be placed just before.
-     * Only used in custom endpoints, if the endpoint's `groupName` doesn't already exist.
-     */
-    public ?string $beforeGroup;
-
-    /**
-     * Name of the group that this group should be placed just after.
-     * Only used in custom endpoints, if the endpoint's `groupName` doesn't already exist.
-     */
-    public ?string $afterGroup;
 }

+ 10 - 48
src/Commands/GenerateDocumentation.php

@@ -48,13 +48,15 @@ class GenerateDocumentation extends Command
             exit(1);
         }
 
+        // Extraction stage - extract endpoint info either from app or existing Camel files (previously extracted data)
         $groupedEndpointsInstance = $groupedEndpointsFactory->make($this, $routeMatcher, $this->configName);
-
+        $extractedEndpoints = $groupedEndpointsInstance->get();
         $userDefinedEndpoints = Camel::loadUserDefinedEndpoints(Camel::camelDir($this->configName));
-        $groupedEndpoints = $this->mergeUserDefinedEndpoints(
-            $groupedEndpointsInstance->get(),
-            $userDefinedEndpoints
-        );
+        $groupedEndpoints = $this->mergeUserDefinedEndpoints($extractedEndpoints, $userDefinedEndpoints);
+
+        // Output stage
+        $configFileOrder = $this->docConfig->get('groups.order', []);
+        $groupedEndpoints = Camel::prepareGroupedEndpointsForOutput($groupedEndpoints, $configFileOrder);
 
         if (!count($userDefinedEndpoints)) {
             // Update the example custom file if there were no custom endpoints
@@ -113,9 +115,6 @@ class GenerateDocumentation extends Command
         if ($this->forcing && !$this->shouldExtract) {
             throw new \InvalidArgumentException("Can't use --force and --no-extraction together.");
         }
-
-        // Reset this map (useful for tests)
-        Camel::$groupFileNames = [];
     }
 
     protected function mergeUserDefinedEndpoints(array $groupedEndpoints, array $userDefinedEndpoints): array
@@ -127,52 +126,15 @@ class GenerateDocumentation extends Command
             });
 
             if ($indexOfGroupWhereThisEndpointShouldBeAdded !== null) {
-                $groupedEndpoints[$indexOfGroupWhereThisEndpointShouldBeAdded]['endpoints'][] = OutputEndpointData::fromExtractedEndpointArray($endpoint);
+                $groupedEndpoints[$indexOfGroupWhereThisEndpointShouldBeAdded]['endpoints'][] = $endpoint;
             } else {
                 $newGroup = [
                     'name' => $endpoint['metadata']['groupName'] ?? $this->docConfig->get('groups.default', ''),
                     'description' => $endpoint['metadata']['groupDescription'] ?? null,
-                    'endpoints' => [OutputEndpointData::fromExtractedEndpointArray($endpoint)],
+                    'endpoints' => [$endpoint],
                 ];
 
-                // Place the new group directly before/after an existing group
-                // if `beforeGroup` or `afterGroup` was set.
-                $beforeGroupName = $endpoint['metadata']['beforeGroup'] ?? null;
-                $afterGroupName = $endpoint['metadata']['afterGroup'] ?? null;
-
-                if ($beforeGroupName) {
-                    $found = false;
-                    $sortedGroupedEndpoints = [];
-                    foreach ($groupedEndpoints as $group) {
-                        if ($group['name'] === $beforeGroupName) {
-                            $found = true;
-                            $sortedGroupedEndpoints[] = $newGroup;
-                        }
-                        $sortedGroupedEndpoints[] = $group;
-                    }
-
-                    if (!$found) {
-                        throw GroupNotFound::forTag($beforeGroupName, "beforeGroup:");
-                    }
-                    $groupedEndpoints = $sortedGroupedEndpoints;
-                } else if ($afterGroupName) {
-                    $found = false;
-                    $sortedGroupedEndpoints = [];
-                    foreach ($groupedEndpoints as $group) {
-                        $sortedGroupedEndpoints[] = $group;
-                        if ($group['name'] === $afterGroupName) {
-                            $found = true;
-                            $sortedGroupedEndpoints[] = $newGroup;
-                        }
-                    }
-
-                    if (!$found) {
-                        throw GroupNotFound::forTag($afterGroupName, "afterGroup:");
-                    }
-                    $groupedEndpoints = $sortedGroupedEndpoints;
-                } else {
-                    $groupedEndpoints[] = $newGroup;
-                }
+                $groupedEndpoints[$newGroup['name']] = $newGroup;
             }
         }
 

+ 22 - 30
src/GroupedEndpoints/GroupedEndpointsFromApp.php

@@ -3,6 +3,7 @@
 namespace Knuckles\Scribe\GroupedEndpoints;
 
 use Illuminate\Support\Arr;
+use Illuminate\Support\Collection;
 use Illuminate\Support\Str;
 use Knuckles\Camel\Camel;
 use Knuckles\Camel\Extraction\ExtractedEndpointData;
@@ -24,26 +25,18 @@ use Symfony\Component\Yaml\Yaml;
 
 class GroupedEndpointsFromApp implements GroupedEndpointsContract
 {
-    protected string $docsName;
-    private GenerateDocumentation $command;
-    private RouteMatcherInterface $routeMatcher;
     private DocumentationConfig $docConfig;
-    private bool $preserveUserChanges = true;
     private bool $encounteredErrors = false;
 
     public static string $camelDir;
     public static string $cacheDir;
 
     public function __construct(
-        GenerateDocumentation $command, RouteMatcherInterface $routeMatcher,
-        bool $preserveUserChanges, string $docsName = 'scribe'
+        private GenerateDocumentation $command, private RouteMatcherInterface $routeMatcher,
+        private bool $preserveUserChanges = true, protected string $docsName = 'scribe'
     )
     {
-        $this->command = $command;
-        $this->routeMatcher = $routeMatcher;
         $this->docConfig = $command->getDocConfig();
-        $this->preserveUserChanges = $preserveUserChanges;
-        $this->docsName = $docsName;
 
         static::$camelDir = Camel::camelDir($this->docsName);
         static::$cacheDir = Camel::cacheDir($this->docsName);
@@ -69,17 +62,25 @@ class GroupedEndpointsFromApp implements GroupedEndpointsContract
 
         if ($preserveUserChanges && is_dir(static::$camelDir) && is_dir(static::$cacheDir)) {
             $latestEndpointsData = Camel::loadEndpointsToFlatPrimitivesArray(static::$camelDir);
-            $cachedEndpoints = Camel::loadEndpointsToFlatPrimitivesArray(static::$cacheDir, true);
+            $cachedEndpoints = Camel::loadEndpointsToFlatPrimitivesArray(static::$cacheDir);
         }
 
         $routes = $routeMatcher->getRoutes($this->docConfig->get('routes', []), $this->docConfig->get('router'));
         $endpoints = $this->extractEndpointsInfoFromLaravelApp($routes, $cachedEndpoints, $latestEndpointsData);
 
-        $groupsOrder = $this->docConfig->get('groups.order', []);
-        $groupedEndpoints = Camel::groupEndpoints($endpoints, $groupsOrder);
+        $groupedEndpoints = collect($endpoints)->groupBy('metadata.groupName')->map(function (Collection $endpointsInGroup) {
+            return [
+                'name' => $endpointsInGroup->first(function (ExtractedEndpointData $endpointData) {
+                        return !empty($endpointData->metadata->groupName);
+                    })->metadata->groupName ?? '',
+                'description' => $endpointsInGroup->first(function (ExtractedEndpointData $endpointData) {
+                        return !empty($endpointData->metadata->groupDescription);
+                    })->metadata->groupDescription ?? '',
+                'endpoints' => $endpointsInGroup->toArray(),
+            ];
+        })->all();
+        $this->writeEndpointsToDisk($groupedEndpoints);
 
-        $this->writeEndpointsToDisk($groupedEndpoints, $groupsOrder);
-        $groupedEndpoints = Camel::prepareGroupedEndpointsForOutput($groupedEndpoints);
         return $groupedEndpoints;
     }
 
@@ -183,7 +184,7 @@ class GroupedEndpointsFromApp implements GroupedEndpointsContract
         return $endpointData;
     }
 
-    protected function writeEndpointsToDisk(array $grouped, array $groupsOrder): void
+    protected function writeEndpointsToDisk(array $grouped): void
     {
         Utils::deleteFilesMatching(static::$camelDir, function ($file) {
             /** @var $file array|\League\Flysystem\StorageAttributes */
@@ -205,23 +206,14 @@ class GroupedEndpointsFromApp implements GroupedEndpointsContract
                 $group, 20, 2,
                 Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE | Yaml::DUMP_OBJECT_AS_MAP | Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK
             );
-            $wasGroupOrderedInConfigFile = isset($groupsOrder[$group['name']]) || in_array($group['name'], $groupsOrder);
-
-            if (count(Camel::$groupFileNames) == count($grouped)
-                && isset(Camel::$groupFileNames[$group['name']])
-                && !$wasGroupOrderedInConfigFile
-            ) {
-                $fileName = Camel::$groupFileNames[$group['name']];
-            } else {
-                // Format numbers as two digits so they are sorted properly when retrieving later
-                // (ie "10.yaml" comes after "9.yaml", not after "1.yaml")
-                $fileName = sprintf("%02d.yaml", $fileNameIndex);
-                $fileNameIndex++;
-            }
+
+            // Format numbers as two digits so they are sorted properly when retrieving later
+            // (ie "10.yaml" comes after "9.yaml", not after "1.yaml")
+            $fileName = sprintf("%02d.yaml", $fileNameIndex);
+            $fileNameIndex++;
 
             file_put_contents(static::$camelDir . "/$fileName", $yaml);
             file_put_contents(static::$cacheDir . "/$fileName", "## Autogenerated by Scribe. DO NOT MODIFY.\n\n" . $yaml);
-            Camel::$groupFileNames[$group['name']] = $fileName;
         }
     }
 

+ 2 - 2
src/Writing/OpenAPISpecWriter.php

@@ -58,12 +58,12 @@ class OpenAPISpecWriter
                 ],
             ],
             'paths' => $this->generatePathsSpec($groupedEndpoints),
-            'tags' => array_map(function (array $group) {
+            'tags' => array_values(array_map(function (array $group) {
                 return [
                     'name' => $group['name'],
                     'description' => $group['description'],
                 ];
-            }, $groupedEndpoints),
+            }, $groupedEndpoints)),
         ], $this->generateSecurityPartialSpec());
     }
 

+ 2 - 2
src/Writing/PostmanCollectionWriter.php

@@ -50,13 +50,13 @@ class PostmanCollectionWriter
                 'description' => $this->config->get('description', ''),
                 'schema' => "https://schema.getpostman.com/json/collection/v" . self::VERSION . "/collection.json",
             ],
-            'item' => array_map(function (array $group) {
+            'item' => array_values(array_map(function (array $group) {
                 return [
                     'name' => $group['name'],
                     'description' => $group['description'],
                     'item' => array_map(\Closure::fromCallable([$this, 'generateEndpointItem']), $group['endpoints']),
                 ];
-            }, $groupedEndpoints),
+            }, $groupedEndpoints)),
             'auth' => $this->generateAuthObject(),
         ];
 

+ 0 - 9
tests/Fixtures/TestGroupController.php

@@ -9,20 +9,11 @@ namespace Knuckles\Scribe\Tests\Fixtures;
  */
 class TestGroupController
 {
-    /**
-     * Some endpoint.
-     *
-     * By default, this is in Group 1.
-     */
     public function action1()
     {
     }
 
     /**
-     * Another endpoint.
-     *
-     * Here we specify a group. This is also in Group 1.
-     *
      * @group 1. Group 1
      */
     public function action1b()

+ 4 - 6
tests/Fixtures/custom.0.yaml

@@ -39,10 +39,9 @@
 
 - httpMethods:
     - GET
-  uri: withBeforeGroup
+  uri: group5
   metadata:
     groupName: '5. Group 5'
-    beforeGroup: '2. Group 2'
     title:
     description:
     authenticated: false
@@ -54,10 +53,9 @@
   responseFields: {}
 - httpMethods:
     - GET
-  uri: withAfterGroup
+  uri: group4
   metadata:
     groupName: '4. Group 4'
-    afterGroup: '5. Group 5'
     title:
     description:
     authenticated: false
@@ -70,7 +68,7 @@
 
 - httpMethods:
     - GET
-  uri: belongingToAnEarlierBeforeGroup
+  uri: alsoGroup5
   metadata:
     groupName: '5. Group 5'
     title:
@@ -81,4 +79,4 @@
   queryParameters: {}
   bodyParameters: {}
   responses: {}
-  responseFields: {}
+  responseFields: {}

+ 96 - 69
tests/GenerateDocumentation/OutputTest.php

@@ -5,6 +5,7 @@ namespace Knuckles\Scribe\Tests\GenerateDocumentation;
 use Illuminate\Support\Facades\Route as RouteFacade;
 use Illuminate\Support\Facades\Storage;
 use Illuminate\Support\Facades\View;
+use Illuminate\Support\Str;
 use Knuckles\Scribe\Tests\BaseLaravelTest;
 use Knuckles\Scribe\Tests\Fixtures\TestController;
 use Knuckles\Scribe\Tests\Fixtures\TestGroupController;
@@ -266,18 +267,24 @@ class OutputTest extends BaseLaravelTest
     }
 
     /** @test */
-    public function sorts_group_naturally()
+    public function sorts_group_naturally_if_no_order_specified()
     {
-        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']);
 
         $this->generate();
 
-        $this->assertEquals('1. Group 1', Yaml::parseFile('.scribe/endpoints/00.yaml')['name']);
-        $this->assertEquals('2. Group 2', Yaml::parseFile('.scribe/endpoints/01.yaml')['name']);
-        $this->assertEquals('10. Group 10', Yaml::parseFile('.scribe/endpoints/02.yaml')['name']);
+        $crawler = new Crawler(file_get_contents($this->htmlOutputPath()));
+        $headings = $crawler->filter('h1')->getIterator();
+        $this->assertCount(5, $headings); // intro, auth, three groups
+        [$_, $_, $firstGroup, $secondGroup, $thirdGroup] = $headings;
+
+        $this->assertEquals('1. Group 1', $firstGroup->textContent);
+        $this->assertEquals('2. Group 2', $secondGroup->textContent);
+        $this->assertEquals('10. Group 10', $thirdGroup->textContent);
+
     }
 
     /** @test */
@@ -296,7 +303,6 @@ class OutputTest extends BaseLaravelTest
                 ],
                 'SG A',
                 'PUT /api/action13c',
-                'POST /api/action13b',
             ],
         ]]);
 
@@ -312,28 +318,82 @@ class OutputTest extends BaseLaravelTest
 
         $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']);
-        $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]);
+        $crawler = new Crawler(file_get_contents($this->htmlOutputPath()));
+        $headings = $crawler->filter('h1')->getIterator();
+        $this->assertCount(6, $headings); // intro, auth, four groups
+        [$_, $_, $firstGroup, $secondGroup, $thirdGroup, $fourthGroup] = $headings;
+
+        $this->assertEquals('10. Group 10', $firstGroup->textContent);
+        $this->assertEquals('1. Group 1', $secondGroup->textContent);
+        $this->assertEquals('13. Group 13', $thirdGroup->textContent);
+        $this->assertEquals('2. Group 2', $fourthGroup->textContent);
+
+        $firstGroupEndpointsAndSubgroups = $crawler->filter('h2[id^="'.Str::slug($firstGroup->textContent).'"]');
+        $this->assertEquals(1, $firstGroupEndpointsAndSubgroups->count());
+        $this->assertEquals("GET api/action10", $firstGroupEndpointsAndSubgroups->getNode(0)->textContent);
+
+        $secondGroupEndpointsAndSubgroups = $crawler->filter('h2[id^="'.Str::slug($secondGroup->textContent).'"]');
+        $this->assertEquals(2, $secondGroupEndpointsAndSubgroups->count());
+        $this->assertEquals("GET api/action1b", $secondGroupEndpointsAndSubgroups->getNode(0)->textContent);
+        $this->assertEquals("GET api/action1", $secondGroupEndpointsAndSubgroups->getNode(1)->textContent);
+
+        $thirdGroupEndpointsAndSubgroups = $crawler->filter('h2[id^="'.Str::slug($thirdGroup->textContent).'"]');
+        $this->assertEquals(8, $thirdGroupEndpointsAndSubgroups->count());
+        $this->assertEquals("SG B", $thirdGroupEndpointsAndSubgroups->getNode(0)->textContent);
+        $this->assertEquals("POST api/action13d", $thirdGroupEndpointsAndSubgroups->getNode(1)->textContent);
+        $this->assertEquals("GET api/action13a", $thirdGroupEndpointsAndSubgroups->getNode(2)->textContent);
+        $this->assertEquals("SG A", $thirdGroupEndpointsAndSubgroups->getNode(3)->textContent);
+        $this->assertEquals("GET api/action13e", $thirdGroupEndpointsAndSubgroups->getNode(4)->textContent);
+        $this->assertEquals("PUT api/action13c", $thirdGroupEndpointsAndSubgroups->getNode(5)->textContent);
+        $this->assertEquals("SG C", $thirdGroupEndpointsAndSubgroups->getNode(6)->textContent);
+        $this->assertEquals("POST api/action13b", $thirdGroupEndpointsAndSubgroups->getNode(7)->textContent);
+    }
+
+    /** @test */
+    public function merges_and_correctly_sorts_user_defined_endpoints()
+    {
+        RouteFacade::get('/api/action1', [TestGroupController::class, 'action1']);
+        RouteFacade::get('/api/action2', [TestGroupController::class, 'action2']);
+        config(['scribe.routes.0.apply.response_calls.methods' => []]);
+        config(['scribe.groups.order' => [
+            '1. Group 1',
+            '5. Group 5',
+            '4. Group 4',
+            '2. Group 2',
+        ]]);
+
+        if (!is_dir('.scribe/endpoints')) mkdir('.scribe/endpoints', 0777, true);
+        copy(__DIR__ . '/../Fixtures/custom.0.yaml', '.scribe/endpoints/custom.0.yaml');
+
+        $this->generate();
+
+        $crawler = new Crawler(file_get_contents($this->htmlOutputPath()));
+        $headings = $crawler->filter('h1')->getIterator();
+        $this->assertCount(6, $headings); // intro, auth, four groups
+        [$_, $_, $firstGroup, $secondGroup, $thirdGroup, $fourthGroup] = $headings;
+
+        $this->assertEquals('1. Group 1', $firstGroup->textContent);
+        $this->assertEquals('5. Group 5', $secondGroup->textContent);
+        $this->assertEquals('4. Group 4', $thirdGroup->textContent);
+        $this->assertEquals('2. Group 2', $fourthGroup->textContent);
+
+        $firstGroupEndpointsAndSubgroups = $crawler->filter('h2[id^="'.Str::slug($firstGroup->textContent).'"]');
+        $this->assertEquals(2, $firstGroupEndpointsAndSubgroups->count());
+        $this->assertEquals("GET api/action1", $firstGroupEndpointsAndSubgroups->getNode(0)->textContent);
+        $this->assertEquals("User defined", $firstGroupEndpointsAndSubgroups->getNode(1)->textContent);
+
+        $secondGroupEndpointsAndSubgroups = $crawler->filter('h2[id^="'.Str::slug($secondGroup->textContent).'"]');
+        $this->assertEquals(2, $secondGroupEndpointsAndSubgroups->count());
+        $this->assertEquals("GET group5", $secondGroupEndpointsAndSubgroups->getNode(0)->textContent);
+        $this->assertEquals("GET alsoGroup5", $secondGroupEndpointsAndSubgroups->getNode(1)->textContent);
+
+        $thirdGroupEndpointsAndSubgroups = $crawler->filter('h2[id^="'.Str::slug($thirdGroup->textContent).'"]');
+        $this->assertEquals(1, $thirdGroupEndpointsAndSubgroups->count());
+        $this->assertEquals("GET group4", $thirdGroupEndpointsAndSubgroups->getNode(0)->textContent);
+
+        $fourthGroupEndpointsAndSubgroups = $crawler->filter('h2[id^="'.Str::slug($fourthGroup->textContent).'"]');
+        $this->assertEquals(1, $fourthGroupEndpointsAndSubgroups->count());
+        $this->assertEquals("GET api/action2", $fourthGroupEndpointsAndSubgroups->getNode(0)->textContent);
     }
 
     /** @test */
@@ -346,9 +406,9 @@ class OutputTest extends BaseLaravelTest
         $this->generate();
 
         $authFilePath = '.scribe/auth.md';
-        $group1FilePath = '.scribe/endpoints/00.yaml';
+        $firstGroupFilePath = '.scribe/endpoints/00.yaml';
 
-        $group = Yaml::parseFile($group1FilePath);
+        $group = Yaml::parseFile($firstGroupFilePath);
         $this->assertEquals('api/action1', $group['endpoints'][0]['uri']);
         $this->assertEquals([], $group['endpoints'][0]['urlParameters']);
         $extraParam = [
@@ -360,7 +420,7 @@ class OutputTest extends BaseLaravelTest
             'custom' => [],
         ];
         $group['endpoints'][0]['urlParameters']['a_param'] = $extraParam;
-        file_put_contents($group1FilePath, Yaml::dump(
+        file_put_contents($firstGroupFilePath, Yaml::dump(
             $group, 20, 2,
             Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE | Yaml::DUMP_OBJECT_AS_MAP
         ));
@@ -368,14 +428,14 @@ class OutputTest extends BaseLaravelTest
 
         $this->generate();
 
-        $group = Yaml::parseFile($group1FilePath);
+        $group = Yaml::parseFile($firstGroupFilePath);
         $this->assertEquals('api/action1', $group['endpoints'][0]['uri']);
         $this->assertEquals(['a_param' => $extraParam], $group['endpoints'][0]['urlParameters']);
         $this->assertStringContainsString('Some other useful stuff.', file_get_contents($authFilePath));
 
         $this->generate(['--force' => true]);
 
-        $group = Yaml::parseFile($group1FilePath);
+        $group = Yaml::parseFile($firstGroupFilePath);
         $this->assertEquals('api/action1', $group['endpoints'][0]['uri']);
         $this->assertEquals([], $group['endpoints'][0]['urlParameters']);
         $this->assertStringNotContainsString('Some other useful stuff.', file_get_contents($authFilePath));
@@ -468,39 +528,6 @@ class OutputTest extends BaseLaravelTest
         $this->assertEquals("Healthcheck", $expectedEndpoint->text());
     }
 
-    /** @test */
-    public function merges_and_correctly_sorts_user_defined_endpoints()
-    {
-        RouteFacade::get('/api/action1', [TestGroupController::class, 'action1']);
-        RouteFacade::get('/api/action2', [TestGroupController::class, 'action2']);
-        config(['scribe.routes.0.apply.response_calls.methods' => []]);
-        if (!is_dir('.scribe/endpoints'))
-            mkdir('.scribe/endpoints', 0777, true);
-        copy(__DIR__ . '/../Fixtures/custom.0.yaml', '.scribe/endpoints/custom.0.yaml');
-
-        $this->generate();
-
-        $crawler = new Crawler(file_get_contents($this->htmlOutputPath()));
-        $headings = $crawler->filter('h1')->getIterator();
-        // There should only be six headings — intro, auth and four groups
-        $this->assertCount(6, $headings);
-        [$_, $_, $group1, $group2, $group3, $group4] = $headings;
-        $this->assertEquals('1. Group 1', trim($group1->textContent));
-        $this->assertEquals('5. Group 5', trim($group2->textContent));
-        $this->assertEquals('4. Group 4', trim($group3->textContent));
-        $this->assertEquals('2. Group 2', trim($group4->textContent));
-        $expectedEndpoints = $crawler->filter('h2');
-        $this->assertEquals(6, $expectedEndpoints->count());
-        // Enforce the order of the endpoints
-        // Ideally, we should also check the groups they're under
-        $this->assertEquals("Some endpoint.", $expectedEndpoints->getNode(0)->textContent);
-        $this->assertEquals("User defined", $expectedEndpoints->getNode(1)->textContent);
-        $this->assertEquals("GET withBeforeGroup", $expectedEndpoints->getNode(2)->textContent);
-        $this->assertEquals("GET belongingToAnEarlierBeforeGroup", $expectedEndpoints->getNode(3)->textContent);
-        $this->assertEquals("GET withAfterGroup", $expectedEndpoints->getNode(4)->textContent);
-        $this->assertEquals("GET api/action2", $expectedEndpoints->getNode(5)->textContent);
-    }
-
     /** @test */
     public function will_auto_set_content_type_to_multipart_if_file_params_are_present()
     {