Repository: knuckleswtf/scribe (php)

Issue:
Multi-word names not handled correctly when extracting route key from url param
### Scribe version

4.19.1

### PHP version

8.2.6

### Framework

Laravel

### Framework version

10.11.0

### Scribe config

```ruby
type => "laravel"
```


### What happened?

After running `php artisan scribe:generate` the documentation has wrong url parameter for multi-word paths e.g. `/api/user-address/{user_address}`. Url parameter defaults to `id` but I'm having `uuid` returned from the method `getRouteKeyName()`. I tracked the issue down to `getRouteKeyFromModel(...)` function. It seems the `$paramName` is `user-address` while the array `$typeHintedEloquentModels` has a key named `userAddress` so it can't find it.

https://github.com/knuckleswtf/scribe/blob/3cd295f6b7d235f47350ab6b92c3e4571bca20f5/src/Extracting/Shared/UrlParamsNormalizer.php#L186-L194

### Docs

- [X] I've checked [the docs](https://scribe.knuckles.wtf/laravel), the [troubleshooting guide](https://scribe.knuckles.wtf/laravel/troubleshooting), and [existing issues](https://github.com/knuckleswtf/scribe/issues?q=is%3Aissue+), but I didn't find a solution

Pull request:
camelCase route parameter so multi-word model binding works
Changes multi-word url parameters from snake_case to camelCase so it matches the model name in array. Propably should handle the case changing in some other way, I'll look into it later.

I don't know how to write a test for this, so any help is welcome.

closes #669

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a4e7e962..4ac40b92 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 
 ### Removed
 
+# 4.21.2 (5 June 2023)
+## Fixed
+- Typehint interface in CustomTranslationsLoader for maximum compatibility ([#679](https://github.com/knuckleswtf/scribe/pull/679))
+
+
+# 4.21.1 (3 June 2023)
+## Fixed
+- Load translations on demand to get around false negative for `runningInConsole()` ([963340f2](https://github.com/knuckleswtf/scribe/commit/963340f2bea654bb267286f807bcdf15b699d06e))
+- Correctly set pagination data on collections in `#[ResponseFromApiResource]` ([d53776bee](https://github.com/knuckleswtf/scribe/commit/d53776bee25daff8f0070e49a32b127b4de21c9c))
+
 # 4.21.0 (29 May 2023)
 ## Added
 - API resources: Infer model name from `@mixin` ([f0ed9565](https://github.com/knuckleswtf/scribe/commit/f0ed95653b401b188e76e6ba9057406710f2cc2b))
diff --git a/resources/views/components/field-details.blade.php b/resources/views/components/field-details.blade.php
index 65d6d7be..57200c1c 100644
--- a/resources/views/components/field-details.blade.php
+++ b/resources/views/components/field-details.blade.php
@@ -41,6 +41,7 @@
         </label>
     @elseif($isList)
         <input type="{{ $inputType }}" style="display: none"
+               @if($inputType === 'number')step="any"@endif
                name="{{ $fullName."[0]" }}" @if($class)class="{{ $class }}"@endif
                data-endpoint="{{ $endpointId }}"
                data-component="{{ $component }}">
@@ -50,6 +51,7 @@
                data-component="{{ $component }}">
     @else
         <input type="{{ $inputType }}" style="display: none"
+               @if($inputType === 'number')step="any"@endif
                name="{{ $fullName }}" @if($class)class="{{ $class }}"@endif
                data-endpoint="{{ $endpointId }}"
                value="{!! (isset($example) && (is_string($example) || is_numeric($example))) ? $example : '' !!}"
diff --git a/resources/views/partials/example-requests/bash.md.blade.php b/resources/views/partials/example-requests/bash.md.blade.php
index ad480a77..25945ab4 100644
--- a/resources/views/partials/example-requests/bash.md.blade.php
+++ b/resources/views/partials/example-requests/bash.md.blade.php
@@ -10,10 +10,11 @@ curl --request {{$endpoint->httpMethods[0]}} \
 @endif
 @endforeach
 @endif
-@if($endpoint->hasFiles())
+@if($endpoint->hasFiles() || (isset($endpoint->headers['Content-Type']) && $endpoint->headers['Content-Type'] == 'multipart/form-data' && count($endpoint->cleanBodyParameters)))
 @foreach($endpoint->cleanBodyParameters as $parameter => $value)
 @foreach(u::getParameterNamesAndValuesForFormData($parameter, $value) as $key => $actualValue)
-    --form "{!! "$key=".$actualValue !!}" \
+    --form "{!! "$key=".$actualValue !!}"@if(!($loop->parent->last) || count($endpoint->fileParameters))\
+@endif
 @endforeach
 @endforeach
 @foreach($endpoint->fileParameters as $parameter => $value)
diff --git a/resources/views/partials/example-requests/javascript.md.blade.php b/resources/views/partials/example-requests/javascript.md.blade.php
index 60bbd189..f0755684 100644
--- a/resources/views/partials/example-requests/javascript.md.blade.php
+++ b/resources/views/partials/example-requests/javascript.md.blade.php
@@ -24,7 +24,7 @@ const headers = {
 };
 @endif
 
-@if($endpoint->hasFiles())
+@if($endpoint->hasFiles() || (isset($endpoint->headers['Content-Type']) && $endpoint->headers['Content-Type'] == 'multipart/form-data' && count($endpoint->cleanBodyParameters)))
 const body = new FormData();
 @foreach($endpoint->cleanBodyParameters as $parameter => $value)
 @foreach( u::getParameterNamesAndValuesForFormData($parameter, $value) as $key => $actualValue)
@@ -49,7 +49,7 @@ fetch(url, {
 @if(count($endpoint->headers))
     headers,
 @endif
-@if($endpoint->hasFiles())
+@if($endpoint->hasFiles() || (isset($endpoint->headers['Content-Type']) && $endpoint->headers['Content-Type'] == 'multipart/form-data' && count($endpoint->cleanBodyParameters)))
     body,
 @elseif(count($endpoint->cleanBodyParameters))
 @if ($endpoint->headers['Content-Type'] == 'application/x-www-form-urlencoded')
diff --git a/resources/views/partials/example-requests/php.md.blade.php b/resources/views/partials/example-requests/php.md.blade.php
index f91719c4..ac9637a3 100644
--- a/resources/views/partials/example-requests/php.md.blade.php
+++ b/resources/views/partials/example-requests/php.md.blade.php
@@ -14,7 +14,7 @@ $response = $client->{{ strtolower($endpoint->httpMethods[0]) }}(
 @if(!empty($endpoint->cleanQueryParameters))
         'query' => {!! u::printQueryParamsAsKeyValue($endpoint->cleanQueryParameters, "'", " =>", 12, "[]", 8) !!},
 @endif
-@if($endpoint->hasFiles())
+@if($endpoint->hasFiles() || (isset($endpoint->headers['Content-Type']) && $endpoint->headers['Content-Type'] == 'multipart/form-data' && !empty($endpoint->cleanBodyParameters)))
         'multipart' => [
 @foreach($endpoint->cleanBodyParameters as $parameter => $value)
 @foreach(u::getParameterNamesAndValuesForFormData($parameter, $value) as $key => $actualValue)
@@ -33,7 +33,7 @@ $response = $client->{{ strtolower($endpoint->httpMethods[0]) }}(
 @endforeach
 @endforeach
         ],
-@elseif(!empty($endpoint->cleanBodyParameters))
+@elseif(count($endpoint->cleanBodyParameters))
 @if ($endpoint->headers['Content-Type'] == 'application/x-www-form-urlencoded')
         'form_params' => {!! u::printPhpValue($endpoint->cleanBodyParameters, 8) !!},
 @else
diff --git a/resources/views/partials/example-requests/python.md.blade.php b/resources/views/partials/example-requests/python.md.blade.php
index d3faa646..49200c20 100644
--- a/resources/views/partials/example-requests/python.md.blade.php
+++ b/resources/views/partials/example-requests/python.md.blade.php
@@ -7,15 +7,20 @@ import requests
 import json
 
 url = '{{ rtrim($baseUrl, '/') }}/{{ $endpoint->boundUri }}'
-@if($endpoint->hasFiles())
+@if($endpoint->hasFiles() || (isset($endpoint->headers['Content-Type']) && $endpoint->headers['Content-Type'] == 'multipart/form-data' && count($endpoint->cleanBodyParameters)))
 files = {
+@foreach($endpoint->cleanBodyParameters as $parameter => $value)
+@foreach(u::getParameterNamesAndValuesForFormData($parameter, $value) as $key => $actualValue)
+  '{!! $key !!}': (None, '{!! $actualValue !!}')@if(!($loop->parent->last) || count($endpoint->fileParameters)),
+@endif
+@endforeach
+@endforeach
 @foreach($endpoint->fileParameters as $parameter => $value)
 @foreach(u::getParameterNamesAndValuesForFormData($parameter, $value) as $key => $file)
   '{!! $key !!}': open('{!! $file->path() !!}', 'rb')@if(!($loop->parent->last)),
 @endif
 @endforeach
 @endforeach
-
 }
 @endif
 @if(count($endpoint->cleanBodyParameters))
@@ -38,7 +43,7 @@ headers = {
 $optionalArguments = [];
 if (count($endpoint->headers)) $optionalArguments[] = "headers=headers";
 if (count($endpoint->fileParameters)) $optionalArguments[] = "files=files";
-if (count($endpoint->cleanBodyParameters)) $optionalArguments[] = (count($endpoint->fileParameters) || $endpoint->headers['Content-Type'] == 'application/x-www-form-urlencoded' ? "data=payload" : "json=payload");
+if (count($endpoint->cleanBodyParameters) && $endpoint->headers['Content-Type'] != 'multipart/form-data') $optionalArguments[] = (count($endpoint->fileParameters) || $endpoint->headers['Content-Type'] == 'application/x-www-form-urlencoded' ? "data=payload" : "json=payload");
 if (count($endpoint->cleanQueryParameters)) $optionalArguments[] = "params=params";
 $optionalArguments = implode(', ',$optionalArguments);
 @endphp
diff --git a/src/Attributes/ResponseFromApiResource.php b/src/Attributes/ResponseFromApiResource.php
index 65275ac1..be4086e2 100644
--- a/src/Attributes/ResponseFromApiResource.php
+++ b/src/Attributes/ResponseFromApiResource.php
@@ -3,6 +3,7 @@
 namespace Knuckles\Scribe\Attributes;
 
 use Attribute;
+use Illuminate\Http\Resources\Json\ResourceCollection;
 use Knuckles\Scribe\Extracting\Shared\ApiResourceResponseTools;
 
 #[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)]
@@ -15,7 +16,7 @@ class ResponseFromApiResource
         public ?string $description = '',
 
         /* Mark if this should be used as a collection. Only needed if not using a ResourceCollection. */
-        public bool $collection = false,
+        public ?bool $collection = null,
         public array $factoryStates = [],
         public array $with = [],
 
@@ -34,4 +35,14 @@ class ResponseFromApiResource
 
         return ApiResourceResponseTools::tryToInferApiResourceModel($this->name);
     }
+
+    public function isCollection(): bool
+    {
+        if (!is_null($this->collection)) {
+            return $this->collection;
+        }
+
+        $className = $this->name;
+        return (new $className(new \Illuminate\Http\Resources\MissingValue)) instanceof ResourceCollection;
+    }
 }
diff --git a/src/Extracting/Shared/UrlParamsNormalizer.php b/src/Extracting/Shared/UrlParamsNormalizer.php
index 9607cb28..bfb21b0d 100644
--- a/src/Extracting/Shared/UrlParamsNormalizer.php
+++ b/src/Extracting/Shared/UrlParamsNormalizer.php
@@ -185,6 +185,9 @@ class UrlParamsNormalizer
      */
     protected static function getRouteKeyFromModel(string $paramName, array $typeHintedEloquentModels): ?string
     {
+        // Ensure param name is in camelCase so it matches the argument name (e.g. The '$userAddress' in `function show(BigThing $userAddress`)
+        $paramName = Str::camel($paramName);
+
         if (array_key_exists($paramName, $typeHintedEloquentModels)) {
             $argumentInstance = $typeHintedEloquentModels[$paramName];
             return $argumentInstance->getRouteKeyName();
diff --git a/src/Extracting/Strategies/Responses/UseResponseAttributes.php b/src/Extracting/Strategies/Responses/UseResponseAttributes.php
index 6aa37e24..a53efb46 100644
--- a/src/Extracting/Strategies/Responses/UseResponseAttributes.php
+++ b/src/Extracting/Strategies/Responses/UseResponseAttributes.php
@@ -73,7 +73,7 @@ class UseResponseAttributes extends PhpAttributeStrategy
 
         $this->startDbTransaction();
         $content = ApiResourceResponseTools::fetch(
-            $attributeInstance->name, $attributeInstance->collection, $modelInstantiator,
+            $attributeInstance->name, $attributeInstance->isCollection(), $modelInstantiator,
             $this->endpointData, $pagination, $attributeInstance->additional,
         );
         $this->endDbTransaction();
diff --git a/src/Scribe.php b/src/Scribe.php
index ffbc6fca..4d48b546 100644
--- a/src/Scribe.php
+++ b/src/Scribe.php
@@ -9,7 +9,7 @@ use Symfony\Component\HttpFoundation\Request;
 
 class Scribe
 {
-    public const VERSION = '4.21.0';
+    public const VERSION = '4.21.2';
 
     /**
      * Specify a callback that will be executed just before a response call is made
diff --git a/src/Writing/CustomTranslationsLoader.php b/src/Writing/CustomTranslationsLoader.php
index 4214d488..ea2a9e48 100644
--- a/src/Writing/CustomTranslationsLoader.php
+++ b/src/Writing/CustomTranslationsLoader.php
@@ -4,16 +4,17 @@ namespace Knuckles\Scribe\Writing;
 
 use Illuminate\Translation\FileLoader;
 use Knuckles\Scribe\Tools\Globals;
+use Illuminate\Contracts\Translation\Loader as LoaderContract;
 
 class CustomTranslationsLoader extends FileLoader
 {
-    protected FileLoader $defaultLoader;
+    protected LoaderContract $defaultLoader;
     protected mixed $langPath;
 
     protected ?array $scribeTranslationsCache = null;
     protected ?array $userTranslationsCache = null;
 
-    public function __construct(FileLoader $loader)
+    public function __construct(LoaderContract $loader)
     {
         $this->defaultLoader = $loader;
         $this->files = app('files');
diff --git a/src/Writing/Writer.php b/src/Writing/Writer.php
index efc638f0..c3a03539 100644
--- a/src/Writing/Writer.php
+++ b/src/Writing/Writer.php
@@ -161,7 +161,7 @@ class Writer
         if (!is_dir($this->laravelTypeOutputPath)) {
             mkdir($this->laravelTypeOutputPath, 0777, true);
         }
-        $publicDirectory = app()->get('path.public');
+        $publicDirectory = public_path();
         if (!is_dir($publicDirectory . $this->laravelAssetsPath)) {
             mkdir($publicDirectory . $this->laravelAssetsPath, 0777, true);
         }
@@ -180,8 +180,8 @@ class Writer
         // Rewrite asset links to go through Laravel
         $contents = preg_replace('#href="\.\./docs/css/(.+?)"#', 'href="{{ asset("' . $this->laravelAssetsPath . '/css/$1") }}"', $contents);
         $contents = preg_replace('#src="\.\./docs/(js|images)/(.+?)"#', 'src="{{ asset("' . $this->laravelAssetsPath . '/$1/$2") }}"', $contents);
-        $contents = str_replace('href="../docs/collection.json"', 'href="{{ route("'.$this->docsName.'.postman") }}"', $contents);
-        $contents = str_replace('href="../docs/openapi.yaml"', 'href="{{ route("'.$this->docsName.'.openapi") }}"', $contents);
+        $contents = str_replace('href="../docs/collection.json"', 'href="{{ route("' . $this->docsName . '.postman") }}"', $contents);
+        $contents = str_replace('href="../docs/openapi.yaml"', 'href="{{ route("' . $this->docsName . '.openapi") }}"', $contents);
 
         file_put_contents("$this->laravelTypeOutputPath/index.blade.php", $contents);
     }
@@ -206,9 +206,9 @@ class Writer
             $assetsOutputPath = $outputPath;
         } else {
             $outputPath = rtrim($this->laravelTypeOutputPath, '/') . '/';
-            c::success("Wrote Blade docs to: ". $this->makePathFriendly($outputPath));
+            c::success("Wrote Blade docs to: " . $this->makePathFriendly($outputPath));
             $this->generatedFiles['blade'] = realpath("{$outputPath}index.blade.php");
-            $assetsOutputPath = app()->get('path.public') . $this->laravelAssetsPath . '/';
+            $assetsOutputPath = public_path() . $this->laravelAssetsPath . '/';
             c::success("Wrote Laravel assets to: " . $this->makePathFriendly($assetsOutputPath));
         }
         $this->generatedFiles['assets']['js'] = realpath("{$assetsOutputPath}js");
@@ -228,7 +228,7 @@ class Writer
     {
         if ($this->isStatic) return null;
 
-        return config('view.paths.0', function_exists('base_path') ? base_path("resources/views") : "resources/views")."/$this->docsName";
+        return config('view.paths.0', function_exists('base_path') ? base_path("resources/views") : "resources/views") . "/$this->docsName";
     }
 
     /**
@@ -241,5 +241,4 @@ class Writer
     {
         return str_replace("\\", "/", str_replace(getcwd() . DIRECTORY_SEPARATOR, "", $path));
     }
-
 }


Test patch:
diff --git a/src/Extracting/Strategies/PhpAttributeStrategy.php b/src/Extracting/Strategies/PhpAttributeStrategy.php
index 510cd575..d92a0847 100644
--- a/src/Extracting/Strategies/PhpAttributeStrategy.php
+++ b/src/Extracting/Strategies/PhpAttributeStrategy.php
@@ -41,19 +41,19 @@ abstract class PhpAttributeStrategy extends Strategy
     protected function getAttributes(ReflectionFunctionAbstract $method, ?ReflectionClass $class = null): array
     {
         $attributesOnMethod = collect(static::$attributeNames)
-            ->flatMap(fn(string $name) => $method->getAttributes($name))
+            ->flatMap(fn(string $name) => $method->getAttributes($name, ReflectionAttribute::IS_INSTANCEOF))
             ->map(fn(ReflectionAttribute $a) => $a->newInstance())->all();
 
         // If there's a FormRequest, we check there.
         if ($formRequestClass = $this->getFormRequestReflectionClass($method)) {
             $attributesOnFormRequest = collect(static::$attributeNames)
-                ->flatMap(fn(string $name) => $formRequestClass->getAttributes($name))
+                ->flatMap(fn(string $name) => $formRequestClass->getAttributes($name, ReflectionAttribute::IS_INSTANCEOF))
                 ->map(fn(ReflectionAttribute $a) => $a->newInstance())->all();
         }
 
         if ($class) {
             $attributesOnController = collect(static::$attributeNames)
-                ->flatMap(fn(string $name) => $class->getAttributes($name))
+                ->flatMap(fn(string $name) => $class->getAttributes($name, ReflectionAttribute::IS_INSTANCEOF))
                 ->map(fn(ReflectionAttribute $a) => $a->newInstance())->all();
         }
 
diff --git a/tests/Fixtures/TestController.php b/tests/Fixtures/TestController.php
index 2b8cfd0f..43b0992e 100644
--- a/tests/Fixtures/TestController.php
+++ b/tests/Fixtures/TestController.php
@@ -585,6 +585,11 @@ class TestController extends Controller
     {
         return null;
     }
+    
+    public function withInjectedModelFullParamName(TestPost $testPost)
+    {
+        return null;
+    }
 
     public function withEnumRule(Request $request)
     {
diff --git a/tests/Strategies/Headers/GetFromHeaderAttributeTest.php b/tests/Strategies/Headers/GetFromHeaderAttributeTest.php
index 29146c40..909adbfa 100644
--- a/tests/Strategies/Headers/GetFromHeaderAttributeTest.php
+++ b/tests/Strategies/Headers/GetFromHeaderAttributeTest.php
@@ -2,6 +2,7 @@
 
 namespace Knuckles\Scribe\Tests\Strategies\Headers;
 
+use Attribute;
 use Knuckles\Camel\Extraction\ExtractedEndpointData;
 use Knuckles\Scribe\Attributes\Header;
 use Knuckles\Scribe\Extracting\Strategies\Headers\GetFromHeaderAttribute;
@@ -16,21 +17,39 @@ class GetFromHeaderAttributeTest extends TestCase
 
     /** @test */
     public function can_fetch_from_header_attribute()
+    {
+        $results = $this->getHeaderFromAttribute('methodWithAttributes');
+
+        $this->assertArraySubset([
+            'Api-Version' => 'v1',
+        ], $results);
+        $this->assertArrayHasKey('Some-Custom', $results);
+        $this->assertNotEmpty($results['Some-Custom']);
+    }
+
+    /** @test */
+    public function can_fetch_child_of_header_attribute()
+    {
+        $results = $this->getHeaderFromAttribute('methodWithCustomHeaderAttribute');
+
+        $this->assertArraySubset([
+            'Api-Version' => 'v1',
+        ], $results);
+        $this->assertArrayHasKey('hello', $results);
+        $this->assertEquals('world', $results['hello']);
+    }
+
+    private function getHeaderFromAttribute(string $methodName): array
     {
         $endpoint = new class extends ExtractedEndpointData {
             public function __construct(array $parameters = []) {}
         };
         $endpoint->controller = new ReflectionClass(\Knuckles\Scribe\Tests\Strategies\Headers\HeaderAttributeTestController::class);
-        $endpoint->method = $endpoint->controller->getMethod('methodWithAttributes');
+        $endpoint->method = $endpoint->controller->getMethod($methodName);
 
         $strategy = new GetFromHeaderAttribute(new DocumentationConfig([]));
-        $results = $strategy($endpoint);
 
-        $this->assertArraySubset([
-            'Api-Version' => 'v1',
-        ], $results);
-        $this->assertArrayHasKey('Some-Custom', $results);
-        $this->assertNotEmpty($results['Some-Custom']);
+        return $strategy($endpoint);
     }
 
 }
@@ -43,4 +62,19 @@ class HeaderAttributeTestController
     {
 
     }
+
+    #[CustomHeaderClass()]
+    public function methodWithCustomHeaderAttribute()
+    {
+
+    }
+}
+
+#[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)]
+class CustomHeaderClass extends Header
+{
+    public function __construct()
+    {
+        parent::__construct('hello', 'world');
+    }
 }
diff --git a/tests/Unit/ExtractedEndpointDataTest.php b/tests/Unit/ExtractedEndpointDataTest.php
index b61a3fed..5a6aed96 100644
--- a/tests/Unit/ExtractedEndpointDataTest.php
+++ b/tests/Unit/ExtractedEndpointDataTest.php
@@ -9,6 +9,7 @@ use Knuckles\Scribe\Matching\RouteMatcher;
 use Knuckles\Scribe\Scribe;
 use Knuckles\Scribe\Tests\BaseLaravelTest;
 use Knuckles\Scribe\Tests\Fixtures\TestController;
+use Knuckles\Scribe\Tools\Utils as u;
 
 class ExtractedEndpointDataTest extends BaseLaravelTest
 {
@@ -99,10 +100,20 @@ class ExtractedEndpointDataTest extends BaseLaravelTest
         $this->assertEquals('things/{thing}', $this->originalUri($route));
         $this->assertEquals('things/{thing_slug}', $this->expectedUri($route));
     }
+    
+    /** @test */
+    public function normalizes_url_param_with_eloquent_model_binding()
+    {
+        Route::get("test-posts/{test_post}", [TestController::class, 'withInjectedModelFullParamName']);
+        $route = $this->getRoute(['prefixes' => '*']);
+
+        $this->assertEquals('test-posts/{test_post}', $this->originalUri($route));
+        $this->assertEquals('test-posts/{test_post_slug}', $this->expectedUri($route, withReflectedMethod: true));
+    }
 
-    protected function expectedUri(LaravelRoute $route): string
+    protected function expectedUri(LaravelRoute $route, $withReflectedMethod = false): string
     {
-        return $this->endpoint($route)->uri;
+        return $this->endpoint($route, $withReflectedMethod)->uri;
     }
 
     protected function originalUri(LaravelRoute $route): string
@@ -110,13 +121,21 @@ class ExtractedEndpointDataTest extends BaseLaravelTest
         return $route->uri;
     }
 
-    protected function endpoint(LaravelRoute $route): ExtractedEndpointData
+    protected function endpoint(LaravelRoute $route, $withReflectedMethod = false): ExtractedEndpointData
     {
+        if ($withReflectedMethod) {
+            [$controllerName, $methodName] = u::getRouteClassAndMethodNames($route);
+            $method = u::getReflectedRouteMethod([$controllerName, $methodName]);
+        } else {
+            // We're testing resource routes, and we may not have methods that exist for all of them (show, index, etc).
+            // Just use this dummy so we don't have null
+           $method = new \ReflectionFunction('dump'); 
+        }
         return new ExtractedEndpointData([
             'route' => $route,
             'uri' => $route->uri,
             'httpMethods' => $route->methods,
-            'method' => new \ReflectionFunction('dump'), // Just so we don't have null
+            'method' => $method,
         ]);
     }