Repository: knuckleswtf/scribe (php)

Issue:
HEAD Routes break generation
- [x] **I confirm that I have read and attempted the tips in the [Troubleshooting Guide](https://scribe.readthedocs.io/en/latest/troubleshooting.html).**

**What happened?**
1. Using the out-of-the-box configuration with minor alterations
2. When we run `php artisan scribe:generate`
3. We get an error during generation of the example-requests

**Screenshots and stack traces:**

```
In 3666cec63a5bc7f130fc7f39cdbc92fbb87d6d5c.php line 2:

  Undefined offset: 0 (View: /web/html/vendor/knuckleswtf/scribe/resources/views/partials/example-requests/bash.blade.php) (View:
   /web/html/vendor/knuckleswtf/scribe/resources/views/partials/example-requests/bash.blade.php)
```

**TL;SR;** Already found the cause of the bug! It just needs fixing :) [Click Here](#additional-info) or scroll to Additional Info

**My environment:**
 - PHP version: 7.3.14
 - Framework (Laravel/Lumen) and version: Lumen v6.3.5 (74d61ebf4f022a874bcea560054a2a2050d0e75d)
 - Scribe Version: 1.1.0 (42d034f6267518c05050ccd18da2989bdfce35d3)

**My Scribe config (minus the comments):**
```php
return [
    'static' => [
        'output_path' => 'public/docs',
    ],
    'laravel' => [
        'add_routes' => true,
        'docs_url' => '/docs',
        'middleware' => [],
    ],
    'auth' => [
        'enabled' => true,
        'in' => 'bearer',
        'name' => 'bearer',
        'use_value' => null,
        'extra_info' => 'JWT Tokens are App Specific.',
    ],
    'intro_text' => 'api intro',
    'example_languages' => [
        'bash',
        'javascript',
        'php'
    ],
    'base_url' => null,
    'title' => 'XEDI API Documentation',
    'postman' => [
        'enabled' => true,
        'base_url' => null,
        'description' => null,
        'auth' => null,
    ],
    'default_group' => 'Misc',
    'logo' =>  null,
    'router' => 'laravel',
    'routes' => [
        [
            'match' => [
                'domains' => ['*'],
                'prefixes' => ['*'],
                'versions' => ['v1'],
            ],
            'include' => [],
            'exclude' => [
                'status.*', 'version'
            ],
            'apply' => [
                'headers' => [
                    'Content-Type' => 'application/json',
                    'Accept' => 'application/json',
                    'Authorization' => 'Bearer {token}',
                ],
                'response_calls' => [
                    'methods' => ['GET', 'POST', 'PUT'],
                    'config' => [
                        'app.env' => 'localhost',
                    ],
                    'cookies' => [],
                    'queryParams' => [],
                    'bodyParams' => [],
                    'fileParams' => [],
                ],
            ],
        ],
    ],
    'fractal' => [
        'serializer' => null,
    ],
    'faker_seed' => null,
    'strategies' => [
        'metadata' => [
            \Knuckles\Scribe\Extracting\Strategies\Metadata\GetFromDocBlocks::class,
        ],
        'urlParameters' => [
            \Knuckles\Scribe\Extracting\Strategies\UrlParameters\GetFromUrlParamTag::class,
        ],
        'queryParameters' => [
            \Knuckles\Scribe\Extracting\Strategies\QueryParameters\GetFromQueryParamTag::class,
        ],
        'headers' => [
            \Knuckles\Scribe\Extracting\Strategies\Headers\GetFromRouteRules::class,
            \Knuckles\Scribe\Extracting\Strategies\Headers\GetFromHeaderTag::class,
        ],
        'bodyParameters' => [
            \Knuckles\Scribe\Extracting\Strategies\BodyParameters\GetFromFormRequest::class,
            \Knuckles\Scribe\Extracting\Strategies\BodyParameters\GetFromBodyParamTag::class,
        ],
        'responses' => [
            \Knuckles\Scribe\Extracting\Strategies\Responses\UseTransformerTags::class,
            \Knuckles\Scribe\Extracting\Strategies\Responses\UseResponseTag::class,
            \Knuckles\Scribe\Extracting\Strategies\Responses\UseResponseFileTag::class,
            \Knuckles\Scribe\Extracting\Strategies\Responses\UseApiResourceTags::class,
            \Knuckles\Scribe\Extracting\Strategies\Responses\ResponseCalls::class,
        ],
        'responseFields' => [
            \Knuckles\Scribe\Extracting\Strategies\ResponseFields\GetFromResponseFieldTag::class,
        ],
    ],
    'routeMatcher' => \Knuckles\Scribe\Matching\RouteMatcher::class,
];

```
#### Additional info:

* Debugging of the partial that throws the error reveals a route with an empty methods array.
* Debugging of the RouteMatcher reveals that all routes have entries in the methods array.
* Placing a `try/catch` around the render call responsible for triggering the error reveals the route without the method. It's a `HEAD` route. The method has been lost somewhere.
* Tracing the call stack back to the `GenerateCommand` leads us to Line 118 and `$generator->processRoute()` as the point where the method is "lost".
* On stepping into the `Knuckles\Scribe\Extracting\Generator` source code, the `getMethods` method is observed.

```php
    /**
     * @param Route $route
     *
     * @return mixed
     */
    public function getMethods(Route $route)
    {
        return array_diff($route->methods(), ['HEAD']);
    }
```

Because `HEAD` is the only method in the methods array, it is striped out. We are using the `HEAD` verb in our API in accordance to its semantic purpose. 

**Proposed Fixes**

1. Remove the use of `array_diff`. The addition of the `HEAD` verb is Laravel's doing and therefore they should fix that.
1. Add a defensive check to make sure that there is always one value in the methods array.

Thoughts?

TIA

Pull request:
[#53] Fixed issue where purposeful head requests aren't rendered
> **Please read the [contribution guidelines](https://scribe.readthedocs.io/en/latest/contributing.html), especially the section on making a pull request, before creating a PR.** Otherwise, your PR may be turned down.

When a route that uses the `HEAD` verb exclusively is encountered, the `Writer` causes an `Undefined Offset` exception to be thrown as `HEAD` verbs are filtered out of the list of methods for an endpoint.

This solution makes an exception for `HEAD` where it is the **only** method specified by the developer. This is so that the original functionality is retained.

As per my comments on #53, I think that filtering out `HEAD` obfuscates what is actually happening; and Laravel should probably provide guidance/configurability for what appears to be a widely undesired behaviour; therefore making this functionality surplus to requirement.

/fixes #53 

Patch:
diff --git a/src/Extracting/Generator.php b/src/Extracting/Generator.php
index 2671f2d8..d10ba756 100644
--- a/src/Extracting/Generator.php
+++ b/src/Extracting/Generator.php
@@ -43,7 +43,15 @@ class Generator
      */
     public function getMethods(Route $route)
     {
-        return array_diff($route->methods(), ['HEAD']);
+        $methods = $route->methods();
+
+        // Laravel adds an automatic "HEAD" endpoint for each GET request, so we'll strip that out,
+        // but not if there's only one method (means it was intentional)
+        if (count($methods) === 1) {
+            return $methods;
+        }
+
+        return array_diff($methods, ['HEAD']);
     }
 
     /**
diff --git a/src/Tools/ConsoleOutputUtils.php b/src/Tools/ConsoleOutputUtils.php
index 56b1daa3..65e7d4b1 100644
--- a/src/Tools/ConsoleOutputUtils.php
+++ b/src/Tools/ConsoleOutputUtils.php
@@ -61,7 +61,12 @@ class ConsoleOutputUtils
      */
     public static function getRouteRepresentation(Route $route): string
     {
-        $routeMethods = implode(',', array_diff($route->methods(), ['HEAD']));
+        $methods = $route->methods();
+        if (count($methods) > 1) {
+            $methods = array_diff($route->methods(), ['HEAD']);
+        }
+
+        $routeMethods = implode(',', $methods);
         $routePath = $route->uri();
         return "[$routeMethods] $routePath";
     }


Test patch:
diff --git a/tests/GenerateDocumentationTest.php b/tests/GenerateDocumentationTest.php
index 6bd805cf..a3a9a632 100644
--- a/tests/GenerateDocumentationTest.php
+++ b/tests/GenerateDocumentationTest.php
@@ -70,6 +70,20 @@ class GenerateDocumentationTest extends TestCase
     }
 
     /** @test */
+    public function can_process_traditional_laravel_head_routes()
+    {
+        RouteFacade::addRoute('HEAD', '/api/test', TestController::class . '@withEndpointDescription');
+
+        config(['scribe.routes.0.match.prefixes' => ['api/*']]);
+        $output = $this->artisan('scribe:generate');
+
+        $this->assertStringContainsString('Processed route: [HEAD] api/test', $output);
+    }
+
+    /**
+     * @test
+     * @see https://github.com/knuckleswtf/scribe/issues/53
+     */
     public function can_process_closure_routes()
     {
         RouteFacade::get('/api/closure', function () {