Browse Source

Refactor URL params normalization

shalvah 2 years ago
parent
commit
97065d9b65

+ 2 - 130
camel/Extraction/ExtractedEndpointData.php

@@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Model;
 use Illuminate\Routing\Route;
 use Illuminate\Support\Str;
 use Knuckles\Camel\BaseDTO;
+use Knuckles\Scribe\Extracting\UrlParamsNormalizer;
 use Knuckles\Scribe\Tools\Utils as u;
 use ReflectionClass;
 
@@ -87,7 +88,7 @@ class ExtractedEndpointData extends BaseDTO
 
         parent::__construct($parameters);
 
-        $this->uri = $this->normalizeResourceParamName($this->uri, $this->route, $this->getTypeHintedArguments());
+        $this->uri = UrlParamsNormalizer::normalizeParameterNamesInRouteUri($this->route, $this->method);
     }
 
     public static function fromRoute(Route $route, array $extras = []): self
@@ -133,67 +134,6 @@ class ExtractedEndpointData extends BaseDTO
         return $this->httpMethods[0] . str_replace(['/', '?', '{', '}', ':', '\\', '+', '|'], '-', $this->uri);
     }
 
-    public function normalizeResourceParamName(string $uri, Route $route, array $typeHintedArguments): string
-    {
-        $params = [];
-        preg_match_all('#\{(\w+?)}#', $uri, $params);
-
-        $resourceRouteNames = [
-            ".index", ".show", ".update", ".destroy",
-        ];
-
-        if (Str::endsWith($route->action['as'] ?? '', $resourceRouteNames)) {
-            // Note that resource routes can be nested eg users.posts.show
-            $pluralResources = explode('.', $route->action['as']);
-            array_pop($pluralResources);
-
-            $foundResourceParam = false;
-            foreach (array_reverse($pluralResources) as $pluralResource) {
-                $singularResource = Str::singular($pluralResource);
-                $singularResourceParam = str_replace('-', '_', $singularResource);
-
-                $search = [
-                    "{$pluralResource}/{{$singularResourceParam}}",
-                    "{$pluralResource}/{{$singularResource}}",
-                    "{$pluralResource}/{{$singularResourceParam}?}",
-                    "{$pluralResource}/{{$singularResource}?}"
-                ];
-
-                // If there is an inline binding in the route, like /users/{user:uuid}, use that key,
-                // Else, search for a type-hinted variable in the action, whose name matches the route segment name,
-                // If there is such variable (like User $user), call getRouteKeyName() on the model,
-                // Otherwise, use the id
-                $binding = static::getFieldBindingForUrlParam($route, $singularResource, $typeHintedArguments, 'id');
-
-                if (!$foundResourceParam) {
-                    // Only the last resource param should be {id}
-                    $replace = ["$pluralResource/{{$binding}}", "$pluralResource/{{$binding}?}"];
-                    $foundResourceParam = true;
-                } else {
-                    // Earlier ones should be {<param>_id}
-                    $replace = [
-                        "{$pluralResource}/{{$singularResource}_{$binding}}",
-                        "{$pluralResource}/{{$singularResourceParam}_{$binding}}",
-                        "{$pluralResource}/{{$singularResource}_{$binding}?}",
-                        "{$pluralResource}/{{$singularResourceParam}_{$binding}?}"
-                    ];
-                }
-                $uri = str_replace($search, $replace, $uri);
-            }
-        }
-
-        foreach ($params[1] as $param) {
-            // For non-resource parameters, if there's a field binding/type-hinted variable, replace that too:
-            if ($binding = static::getFieldBindingForUrlParam($route, $param, $typeHintedArguments)) {
-                $search = ["{{$param}}", "{{$param}?}"];
-                $replace = ["{{$param}_{$binding}}", "{{$param}_{$binding}?}"];
-                $uri = str_replace($search, $replace, $uri);
-            }
-        }
-
-        return $uri;
-    }
-
     /**
      * Prepare the endpoint data for serialising.
      */
@@ -209,72 +149,4 @@ class ExtractedEndpointData extends BaseDTO
 
         return $copy;
     }
-
-    protected static function instantiateTypedArgument(\ReflectionNamedType $argumentType): ?object
-    {
-        $argumentClassName = $argumentType->getName();
-
-        if (class_exists($argumentClassName)) {
-            return new $argumentClassName;
-        }
-
-        if (interface_exists($argumentClassName)) {
-            return app($argumentClassName);
-        }
-
-        return null;
-    }
-
-    public static function getFieldBindingForUrlParam(
-        Route $route, string $paramName, array $typeHintedArguments = [], string $default = null
-    ): ?string
-    {
-        $binding = null;
-        // Was added in Laravel 7.x
-        if (method_exists($route, 'bindingFieldFor')) {
-            $binding = $route->bindingFieldFor($paramName);
-        }
-
-        // Search for a type-hinted variable whose name matches the route segment name
-        if (is_null($binding) && array_key_exists($paramName, $typeHintedArguments)) {
-            $argumentType = $typeHintedArguments[$paramName]->getType();
-            $argumentInstance = self::instantiateTypedArgument($argumentType);
-            $binding = $argumentInstance instanceof Model ? $argumentInstance->getRouteKeyName() : null;
-        }
-
-        return $binding ?: $default;
-    }
-
-    /**
-     * Return the type-hinted method arguments in the action that have a Model type,
-     * The arguments will be returned as an array of the form: $arguments[<variable_name>] = $argument
-     */
-    protected function getTypeHintedArguments(): array
-    {
-        $arguments = [];
-        if ($this->method) {
-            foreach ($this->method->getParameters() as $argument) {
-                if ($this->argumentHasModelType($argument)) {
-                    $arguments[$argument->getName()] = $argument;
-                }
-            }
-        }
-
-        return $arguments;
-    }
-
-    /**
-     * Determine whether the argument has a Model type
-     */
-    protected function argumentHasModelType(\ReflectionParameter $argument): bool
-    {
-        $argumentType = $argument->getType();
-        if (!($argumentType instanceof \ReflectionNamedType)) {
-            // The argument does not have a type-hint, or is a primitive type (`string`, ..)
-            return false;
-        }
-
-        $argumentInstance = self::instantiateTypedArgument($argumentType);
-        return ($argumentInstance instanceof Model);
-    }
 }

+ 124 - 108
src/Extracting/Strategies/UrlParameters/GetFromLaravelAPI.php

@@ -7,6 +7,7 @@ use Knuckles\Camel\Extraction\ExtractedEndpointData;
 use Illuminate\Support\Str;
 use Knuckles\Scribe\Extracting\ParamHelpers;
 use Knuckles\Scribe\Extracting\Strategies\Strategy;
+use Knuckles\Scribe\Extracting\UrlParamsNormalizer;
 use Knuckles\Scribe\Tools\Utils;
 
 class GetFromLaravelAPI extends Strategy
@@ -15,9 +16,7 @@ class GetFromLaravelAPI extends Strategy
 
     public function __invoke(ExtractedEndpointData $endpointData, array $routeRules): ?array
     {
-        if (Utils::isLumen()) {
-            return null;
-        }
+        if (Utils::isLumen()) return null;
 
         $parameters = [];
 
@@ -25,147 +24,164 @@ class GetFromLaravelAPI extends Strategy
         preg_match_all('/\{(.*?)\}/', $path, $matches);
 
         foreach ($matches[1] as $match) {
-            $optional = Str::endsWith($match, '?');
+            $isOptional = Str::endsWith($match, '?');
             $name = rtrim($match, '?');
 
-            // In case of /users/{user:id}, make the param {user_id}
-            $binding = ExtractedEndpointData::getFieldBindingForUrlParam($endpointData->route, $name);
             $parameters[$name] = [
                 'name' => $name,
-                'description' => $this->inferUrlParamDescription($endpointData->uri, $binding ?: $name, $binding ? $name : null),
-                'required' => !$optional,
+                'description' => $this->inferUrlParamDescription($endpointData->uri, $name),
+                'required' => !$isOptional,
             ];
         }
 
+        $parameters = $this->inferBetterTypesAndExamplesForEloquentUrlParameters($parameters, $endpointData);
 
-        // Infer proper types for any bound models
-        // Eg Suppose route is /users/{user},
-        // and (User $user) model is typehinted on method
-        // If User model has an int primary key, {user} param should be an int
+        $parameters = $this->setTypesAndExamplesForOthers($parameters, $endpointData);
 
-        $methodArguments = $endpointData->method->getParameters();
-        foreach ($methodArguments as $argument) {
-            $argumentType = $argument->getType();
-            // If there's no typehint, continue
-            if (!$argumentType) {
-                continue;
+        return $parameters;
+    }
+
+    protected function inferUrlParamDescription(string $url, string $paramName): string
+    {
+        // If $url is sth like /users/{id}, return "The ID of the user."
+        // If $url is sth like /anything/{user_id}, return "The ID of the user."
+
+        return collect(["id", "slug"])->flatMap(function ($name) use ($url, $paramName) {
+            $friendlyName = $name === 'id' ? "ID" : $name;
+
+            if ($paramName == $name) {
+                $thing = $this->getNameOfUrlThing($url, $paramName);
+                return ["The $friendlyName of the $thing."];
+            } else if (Str::is("*_$name", $paramName)) {
+                $thing = str_replace(["_", "-"], " ", str_replace("_$name", '', $paramName));
+                return ["The $friendlyName of the $thing."];
             }
-            try {
-                $argumentClassName = $argumentType->getName();
-                $argumentInstance = new $argumentClassName;
-                if ($argumentInstance instanceof Model) {
-                    if (isset($parameters[$argument->getName()])) {
-                        $paramName = $argument->getName();
-                    } else if (isset($parameters['id'])) {
-                        $paramName = 'id';
-                    } else {
-                        continue;
-                    }
-
-                    // If a user customized their routeKeyName,
-                    // we can't guarantee that it's the same type as the PK
-                    $typeName = $argumentInstance->getKeyName() === $argumentInstance->getRouteKeyName()
-                        ? $argumentInstance->getKeyType() : 'string';
-                    $type = $this->normalizeTypeName($typeName);
-                    $parameters[$paramName]['type'] = $type;
-
-                    // Try to fetch an example ID from the database
-                    try {
-                        // todo: add some database tests
-                        $example = $argumentInstance::first()->id ?? null;
-                    } catch (\Throwable $e) {
-                        $example = null;
-                    }
-
-                    if ($example === null) {
-                        // If the user explicitly set a `where()` constraint, use that to refine examples
-                        $parameterRegex = $endpointData->route->wheres[$paramName] ?? null;
-                        $example = $parameterRegex
-                            ? $this->castToType($this->getFaker()->regexify($parameterRegex), $type)
-                            : $this->generateDummyValue($type);
-                    }
-                    $parameters[$paramName]['example'] = $example;
-                }
-            } catch (\Throwable $e) {
+            return [];
+        })->first() ?: '';
+    }
+
+    protected function inferBetterTypesAndExamplesForEloquentUrlParameters(array $parameters, ExtractedEndpointData $endpointData): array
+    {
+        //We'll gather Eloquent model instances that can be linked to a URl parameter
+        $modelInstances = [];
+
+        // First, any bound models
+        // Eg if route is /users/{id}, and (User $user) model is typehinted on method
+        // If User model has `id` as an integer, then {id} param should be an integer
+        $typeHintedEloquentModels = UrlParamsNormalizer::getTypeHintedEloquentModels($endpointData->method);
+        foreach ($typeHintedEloquentModels as $argumentName => $modelInstance) {
+            $routeKey = $modelInstance->getRouteKeyName();
+
+            // Find the param name. In our normalized URL, argument $user might be param {user}, or {user_id}, or {id},
+            if (isset($parameters[$argumentName])) {
+                $paramName = $argumentName;
+            } else if (isset($parameters["{$argumentName}_$routeKey"])) {
+                $paramName = "{$argumentName}_$routeKey";
+            } else if (isset($parameters[$routeKey])) {
+                $paramName = $routeKey;
+            } else {
                 continue;
             }
+
+            $modelInstances[$paramName] = $modelInstance;
         }
 
-        // Try to infer correct types for URL parameters.
+        // Next, non-Eloquent-bound parameters. They might still be Eloquent models, but model binding wasn't used.
         foreach ($parameters as $name => $data) {
             if (isset($data['type'])) continue;
 
-            $type = 'string'; // The default type
-
-            // If the url is /things/{id}, try looking for a Thing model ourselves
+            // If the url is /things/{id}, try to find a Thing model
             $urlThing = $this->getNameOfUrlThing($endpointData->uri, $name);
-            if ($urlThing) {
-                $rootNamespace = app()->getNamespace();
-                $className = $this->urlThingToClassName($urlThing);
-                if (class_exists($class = "{$rootNamespace}Models\\" . $className)
-                    // For the heathens that don't use a Models\ directory
-                    || class_exists($class = $rootNamespace . $className)) {
-                    $argumentInstance = new $class;
-                    if ($argumentInstance->getKeyName() === $argumentInstance->getRouteKeyName()) {
-                        $type = $this->normalizeTypeName($argumentInstance->getKeyType());
-                    }
-                }
+            if ($urlThing && ($modelInstance = $this->findModelFromUrlThing($urlThing))) {
+                $modelInstances[$name] = $modelInstance;
             }
-
-            $parameterRegex = $endpointData->route->wheres[$name] ?? null;
-            $example = $parameterRegex
-                ? $this->castToType($this->getFaker()->regexify($parameterRegex), $type)
-                : $this->generateDummyValue($type);
-            $parameters[$name]['example'] =$example;
-            $parameters[$name]['type'] = $type;
         }
 
+        // Now infer.
+        foreach ($modelInstances as $paramName => $modelInstance) {
+            // If the routeKey is the same as the primary key in the database, use the PK's type.
+            $routeKey = $modelInstance->getRouteKeyName();
+            $type = $modelInstance->getKeyName() === $routeKey
+                ? $this->normalizeTypeName($modelInstance->getKeyType()) : 'string';
+
+            $parameters[$paramName]['type'] = $type;
+
+            try {
+                // todo: add some database tests
+                $parameters[$paramName]['example'] = $modelInstance::first()->$routeKey ?? null;
+            } catch (\Throwable $e) {
+                $parameters[$paramName]['example'] = null;
+            }
+
+        }
         return $parameters;
     }
 
-    protected function inferUrlParamDescription(string $url, string $paramName, string $originalBindingName = null): string
+    protected function setTypesAndExamplesForOthers(array $parameters, ExtractedEndpointData $endpointData): array
     {
-        if ($paramName == "id") {
-            // If $url is sth like /users/{id} or /users/{user}, return "The ID of the user."
-            // Make sure to replace underscores, so "side_projects" becomes "side project"
-            $thing = str_replace(["_", "-"], " ",$this->getNameOfUrlThing($url, $paramName, $originalBindingName));
-            return "The ID of the $thing.";
-        } else if (Str::is("*_id", $paramName)) {
-            // If $url is sth like /something/{user_id}, return "The ID of the user."
-            $parts = explode("_", $paramName);
-            return "The ID of the $parts[0].";
-        } else if ($paramName && $originalBindingName) {
-            // A case like /posts/{post:slug} -> The slug of the post
-            return "The $paramName of the $originalBindingName.";
-        }
+        foreach ($parameters as $name => $parameter) {
+            if (empty($parameter['type'])) {
+                $parameters[$name]['type'] = "string";
+            }
 
-        return '';
+            if (($parameter['example'] ?? null) === null) {
+                // If the user explicitly set a `where()` constraint, use that to refine examples
+                $parameterRegex = $endpointData->route->wheres[$name] ?? null;
+                $parameters[$name]['example'] = $parameterRegex
+                    ? $this->castToType($this->getFaker()->regexify($parameterRegex), $parameters[$name]['type'])
+                    : $this->generateDummyValue($parameters[$name]['type']);
+            }
+        }
+        return $parameters;
     }
 
     /**
-     * Extract "thing" in the URL /<whatever>/things/{paramName}
+     * Given a URL parameter $paramName, extract the "thing" that comes before it. eg::
+     * - /<whatever>/things/{paramName} -> "thing"
+     * - animals/cats/{id} -> "cat"
+     * - users/{user_id}/contracts -> "user"
+     *
+     * @param string $url
+     * @param string $paramName
+     * @param string|null $alternateParamName A second paramName to try, if the original paramName isn't in the URL.
+     *
+     * @return string|null
      */
     protected function getNameOfUrlThing(string $url, string $paramName, string $alternateParamName = null): ?string
     {
-        try {
-            $parts = explode("/", $url);
-            $paramIndex = array_search("{{$paramName}}", $parts);
+        $parts = explode("/", $url);
+        $paramIndex = array_search("{{$paramName}}", $parts);
 
-            if ($paramIndex === false) {
-                // Try with the other param name
-                $paramIndex = array_search("{{$alternateParamName}}", $parts);
-            }
-            $things = $parts[$paramIndex - 1];
-            return Str::singular($things);
-        } catch (\Throwable $e) {
-            return null;
+        if ($paramIndex === false) {
+            $paramIndex = array_search("{{$alternateParamName}}", $parts);
         }
+
+        if ($paramIndex === false) return null;
+
+        $things = $parts[$paramIndex - 1];
+        // Replace underscores/hyphens, so "side_projects" becomes "side project"
+        return str_replace(["_", "-"], " ", Str::singular($things));
     }
 
-    protected function urlThingToClassName(string $urlThing): string
+    /**
+     * Given a URL "thing", like the "cat" in /cats/{id}, try to locate a Cat model.
+     *
+     * @param string $urlThing
+     *
+     * @return Model|null
+     */
+    protected function findModelFromUrlThing(string $urlThing): ?Model
     {
-        $className = Str::title($urlThing);
-        $className = str_replace(['-', '_'], '', $className);
-        return $className;
+        $className = str_replace(['-', '_', ' '], '', Str::title($urlThing));
+        $rootNamespace = app()->getNamespace();
+
+        if (class_exists($class = "{$rootNamespace}Models\\" . $className)
+            // For the heathens that don't use a Models\ directory
+            || class_exists($class = $rootNamespace . $className)) {
+            $instance = new $class;
+            return $instance instanceof Model ? $instance : null;
+        }
+
+        return null;
     }
 }

+ 210 - 0
src/Extracting/UrlParamsNormalizer.php

@@ -0,0 +1,210 @@
+<?php
+
+namespace Knuckles\Scribe\Extracting;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Routing\Route;
+use Illuminate\Support\Str;
+use Knuckles\Camel\Extraction\ExtractedEndpointData;
+use ReflectionFunctionAbstract;
+
+/*
+ * See https://laravel.com/docs/9.x/routing#route-model-binding
+ */
+class UrlParamsNormalizer
+{
+    // TODO enum binding https://laravel.com/docs/9.x/routing#implicit-enum-binding
+
+
+    /**
+     * Normalize a URL from Laravel-style to something that's clearer for a non-Laravel user.
+     * For instance, `/posts/{post}` would be clearer as `/posts/{id}`,
+     * and `/users/{user}/posts/{post}` would be clearer as `/users/{user_id}/posts/{id}`
+     *
+     * @param \Illuminate\Routing\Route $route
+     * @param \ReflectionFunctionAbstract $method
+     *
+     * @return string
+     */
+    public static function normalizeParameterNamesInRouteUri(Route $route, ReflectionFunctionAbstract $method): string
+    {
+        $params = [];
+        $uri = $route->uri;
+        preg_match_all('#\{(\w+?)}#', $uri, $params);
+
+        $resourceRouteNames = [".index", ".show", ".update", ".destroy"];
+
+        $typeHintedEloquentModels = self::getTypeHintedEloquentModels($method);
+        $routeName = $route->action['as'] ?? '';
+        if (Str::endsWith($routeName, $resourceRouteNames)) {
+            // Note that resource routes can be nested eg users.posts.show
+            $pluralResources = explode('.', $routeName);
+            array_pop($pluralResources); // Remove the name of the action (eg `show`)
+
+            $alreadyFoundResourceParam = false;
+            foreach (array_reverse($pluralResources) as $pluralResource) {
+                $singularResource = Str::singular($pluralResource);
+                $singularResourceParam = str_replace('-', '_', $singularResource); // URL parameters are often declared with _ in Laravel but - outside
+
+                $urlPatternsToSearchFor = [
+                    "{$pluralResource}/{{$singularResourceParam}}",
+                    "{$pluralResource}/{{$singularResource}}",
+                    "{$pluralResource}/{{$singularResourceParam}?}",
+                    "{$pluralResource}/{{$singularResource}?}",
+                ];
+
+                $binding = self::getRouteKeyForUrlParam(
+                    $route, $singularResource, $typeHintedEloquentModels, 'id'
+                );
+
+                if (!$alreadyFoundResourceParam) {
+                    // This is the first resource param (from the end).
+                    // We set it to `params/{id}` (or whatever field it's bound to)
+                    $replaceWith = ["$pluralResource/{{$binding}}", "$pluralResource/{{$binding}?}"];
+                    $alreadyFoundResourceParam = true;
+                } else {
+                    // Other resource parameters will be `params/{<param>_id}`
+                    $replaceWith = [
+                        "{$pluralResource}/{{$singularResource}_{$binding}}",
+                        "{$pluralResource}/{{$singularResourceParam}_{$binding}}",
+                        "{$pluralResource}/{{$singularResource}_{$binding}?}",
+                        "{$pluralResource}/{{$singularResourceParam}_{$binding}?}",
+                    ];
+                }
+                $uri = str_replace($urlPatternsToSearchFor, $replaceWith, $uri);
+            }
+        }
+
+        foreach ($params[1] as $param) {
+            // For non-resource parameters, if there's a field binding/type-hinted variable, replace that too:
+            if ($binding = self::getRouteKeyForUrlParam($route, $param, $typeHintedEloquentModels)) {
+                $urlPatternsToSearchFor = ["{{$param}}", "{{$param}?}"];
+                $replaceWith = ["{{$param}_{$binding}}", "{{$param}_{$binding}?}"];
+                $uri = str_replace($urlPatternsToSearchFor, $replaceWith, $uri);
+            }
+        }
+
+        return $uri;
+    }
+
+
+    /**
+     * Given a URL that uses Eloquent model binding (for instance `/posts/{post}` -> `public function show(Post
+     * $post)`), we need to figure out the field that Eloquent uses to retrieve the Post object. By default, this would
+     * be `id`, but can be configured in a couple of ways:
+     *
+     * - Inline: `/posts/{post:slug}`
+     * - `class Post { public function getRouteKeyName() { return 'slug'; } }`
+     *
+     * There are other ways, but they're dynamic and beyond our scope.
+     *
+     * @param \Illuminate\Routing\Route $route
+     * @param string $paramName The name of the URL parameter
+     * @param array $typeHintedArguments Arguments to the method that have typehints
+     * @param string|null $default Default field to use
+     *
+     * @return string|null
+     */
+    public static function getRouteKeyForUrlParam(
+        Route $route, string $paramName, array $typeHintedEloquentModels = [], string $default = null
+    ): ?string
+    {
+        if ($binding = self::getInlineRouteKey($route, $paramName)) {
+            return $binding;
+        }
+
+        return self::getRouteKeyFromModel($paramName, $typeHintedEloquentModels) ?: $default;
+    }
+
+    /**
+     * Return the `slug` in /posts/{post:slug}
+     *
+     * @param \Illuminate\Routing\Route $route
+     * @param string $paramName
+     *
+     * @return string|null
+     */
+    protected static function getInlineRouteKey(Route $route, string $paramName): ?string
+    {
+        // Was added in Laravel 7.x
+        if (method_exists($route, 'bindingFieldFor')) {
+            return $route->bindingFieldFor($paramName);
+        }
+        return null;
+    }
+
+    /**
+     * Check if there's a type-hinted argument on the controller method matching the URL param name:
+     * eg /posts/{post} -> public function show(Post $post)
+     * If there is, check if it's an Eloquent model.
+     * If it is, return it's `getRouteKeyName()`.
+     *
+     * @param string $paramName
+     * @param Model[] $typeHintedEloquentModels
+     *
+     * @return string|null
+     */
+    protected static function getRouteKeyFromModel(string $paramName, array $typeHintedEloquentModels): ?string
+    {
+        if (array_key_exists($paramName, $typeHintedEloquentModels)) {
+            $argumentInstance = $typeHintedEloquentModels[$paramName];
+            return $argumentInstance->getRouteKeyName();
+        }
+
+        return null;
+    }
+
+    /**
+     * Return the type-hinted method arguments in the action that are Eloquent models,
+     * The arguments will be returned as an array of the form: [<variable_name> => $instance]
+     */
+    public static function getTypeHintedEloquentModels(ReflectionFunctionAbstract $method): array
+    {
+        $arguments = [];
+        foreach ($method->getParameters() as $argument) {
+            if (($instance = self::instantiateMethodArgument($argument)) && $instance instanceof Model) {
+                $arguments[$argument->getName()] = $instance;
+            }
+        }
+
+        return $arguments;
+    }
+
+    /**
+     * Instantiate an argument on a controller method via its typehint. For instance, $post in:
+     *
+     * public function show(Post $post)
+     *
+     * This method takes in a method argument and returns an instance, or null if it couldn't be instantiated safely.
+     * Cases where instantiation may fail:
+     * - the argument has no type (eg `public function show($postId)`)
+     * - the argument has a primitive type (eg `public function show(string $postId)`)
+     * - the argument is an injected dependency that itself needs other dependencies
+     *   (eg `public function show(PostsManager $manager)`)
+     *
+     * @param \ReflectionParameter $argument
+     *
+     * @return object|null
+     */
+    protected static function instantiateMethodArgument(\ReflectionParameter $argument): ?object
+    {
+        $argumentType = $argument->getType();
+        // No type-hint, or primitive type
+        if (!($argumentType instanceof \ReflectionNamedType)) return null;
+
+        $argumentClassName = $argumentType->getName();
+        if (class_exists($argumentClassName)) {
+            try {
+                return new $argumentClassName;
+            } catch (\Throwable $e) {
+                return null;
+            }
+        }
+
+        if (interface_exists($argumentClassName)) {
+            return app($argumentClassName);
+        }
+
+        return null;
+    }
+}

+ 7 - 6
tests/Strategies/UrlParameters/GetFromLaravelAPITest.php

@@ -6,6 +6,7 @@ use Illuminate\Routing\Route;
 use Illuminate\Routing\Router;
 use Knuckles\Camel\Extraction\ExtractedEndpointData;
 use Knuckles\Scribe\Extracting\Strategies\UrlParameters\GetFromLaravelAPI;
+use Knuckles\Scribe\Extracting\UrlParamsNormalizer;
 use Knuckles\Scribe\Tests\BaseLaravelTest;
 use Knuckles\Scribe\Tests\Fixtures\TestController;
 use Knuckles\Scribe\Tools\DocumentationConfig;
@@ -118,18 +119,18 @@ class GetFromLaravelAPITest extends BaseLaravelTest
 
                 $route = app(Router::class)->addRoute(['GET'], "audio/{audio:slug}", ['uses' => [TestController::class, 'dummy']]);
                 $this->route = $route;
-                $this->uri = $route->uri;
+                $this->uri = UrlParamsNormalizer::normalizeParameterNamesInRouteUri($route, $this->method);
             }
         };
 
         $results = $strategy($endpoint, []);
 
         $this->assertArraySubset([
-            "name" => "audio",
+            "name" => "audio_slug",
             "description" => "The slug of the audio.",
             "required" => true,
             "type" => "string",
-        ], $results['audio']);
+        ], $results['audio_slug']);
 
         $endpoint = new class extends ExtractedEndpointData {
             public function __construct(array $parameters = [])
@@ -138,17 +139,17 @@ class GetFromLaravelAPITest extends BaseLaravelTest
 
                 $route = app(Router::class)->addRoute(['GET'], "users/{user:id}", ['uses' => [TestController::class, 'withInjectedModel']]);
                 $this->route = $route;
-                $this->uri = $route->uri;
+                $this->uri = UrlParamsNormalizer::normalizeParameterNamesInRouteUri($route, $this->method);
             }
         };
 
         $results = $strategy($endpoint, []);
 
         $this->assertArraySubset([
-            "name" => "user",
+            "name" => "user_id",
             "description" => "The ID of the user.",
             "required" => true,
             "type" => "integer",
-        ], $results['user']);
+        ], $results['user_id']);
     }
 }

+ 52 - 46
tests/Unit/ExtractedEndpointDataTest.php

@@ -2,6 +2,7 @@
 
 namespace Knuckles\Scribe\Tests\Unit;
 
+use Illuminate\Routing\Route as LaravelRoute;
 use Illuminate\Support\Facades\Route;
 use Knuckles\Camel\Extraction\ExtractedEndpointData;
 use Knuckles\Scribe\Matching\RouteMatcher;
@@ -11,62 +12,67 @@ use Knuckles\Scribe\Tests\Fixtures\TestController;
 class ExtractedEndpointDataTest extends BaseLaravelTest
 {
     /** @test */
-    public function will_normalize_resource_url_params()
+    public function normalizes_resource_url_params()
     {
-        Route::apiResource('things', TestController::class)
-            ->only('show');
-        $routeRules[0]['match'] = ['prefixes' => '*', 'domains' => '*'];
+        Route::apiResource('things', TestController::class)->only('show');
+        $route = $this->getRoute(['prefixes' => '*']);
 
-        $matcher = new RouteMatcher();
-        $matchedRoutes = $matcher->getRoutes($routeRules);
+        $this->assertEquals('things/{thing}', $this->originalUri($route));
+        $this->assertEquals('things/{id}', $this->expectedUri($route));
 
-        foreach ($matchedRoutes as $matchedRoute) {
-            $route = $matchedRoute->getRoute();
-            $this->assertEquals('things/{thing}', $route->uri);
-            $endpoint = new ExtractedEndpointData([
-                'route' => $route,
-                'uri' => $route->uri,
-                'httpMethods' => $route->methods,
-            ]);
-            $this->assertEquals('things/{id}', $endpoint->uri);
-        }
 
-        Route::apiResource('things.otherthings', TestController::class)
-            ->only( 'destroy');
+        Route::apiResource('things.otherthings', TestController::class)->only('destroy');
+        $route = $this->getRoute(['prefixes' => '*/otherthings/*']);
 
-        $routeRules[0]['match'] = ['prefixes' => '*/otherthings/*', 'domains' => '*'];
-        $matchedRoutes = $matcher->getRoutes($routeRules);
-        foreach ($matchedRoutes as $matchedRoute) {
-            $route = $matchedRoute->getRoute();
-            $this->assertEquals('things/{thing}/otherthings/{otherthing}', $route->uri);
-            $endpoint = new ExtractedEndpointData([
-                'route' => $route,
-                'uri' => $route->uri,
-                'httpMethods' => $route->methods,
-            ]);
-            $this->assertEquals('things/{thing_id}/otherthings/{id}', $endpoint->uri);
-        }
+        $this->assertEquals('things/{thing}/otherthings/{otherthing}', $this->originalUri($route));
+        $this->assertEquals('things/{thing_id}/otherthings/{id}', $this->expectedUri($route));
     }
 
     /** @test */
-    public function will_normalize_resource_url_params_with_hyphens()
+    public function normalizes_resource_url_params_from_underscores_to_hyphens()
     {
-        Route::apiResource('audio-things', TestController::class)
-            ->only('show');
-        $routeRules[0]['match'] = ['prefixes' => '*', 'domains' => '*'];
+        Route::apiResource('audio-things', TestController::class)->only('show');
+        $route = $this->getRoute(['prefixes' => '*']);
 
-        $matcher = new RouteMatcher();
-        $matchedRoutes = $matcher->getRoutes($routeRules);
+        $this->assertEquals('audio-things/{audio_thing}', $this->originalUri($route));
+        $this->assertEquals('audio-things/{id}', $this->expectedUri($route));
+    }
+
+    /** @test */
+    public function normalizes_nonresource_url_params_with_inline_bindings()
+    {
+        Route::get('things/{thing:slug}', [TestController::class, 'show']);
+        $route = $this->getRoute(['prefixes' => '*']);
+
+        $this->assertEquals('things/{thing}', $this->originalUri($route));
+        $this->assertEquals('things/{thing_slug}', $this->expectedUri($route));
+    }
+
+    protected function expectedUri(LaravelRoute $route): string
+    {
+        return $this->endpoint($route)->uri;
+    }
+
+    protected function originalUri(LaravelRoute $route): string
+    {
+        return $route->uri;
+    }
 
-        foreach ($matchedRoutes as $matchedRoute) {
-            $route = $matchedRoute->getRoute();
-            $this->assertEquals('audio-things/{audio_thing}', $route->uri);
-            $endpoint = new ExtractedEndpointData([
-                'route' => $route,
-                'uri' => $route->uri,
-                'httpMethods' => $route->methods,
-            ]);
-            $this->assertEquals('audio-things/{id}', $endpoint->uri);
-        }
+    protected function endpoint(LaravelRoute $route): ExtractedEndpointData
+    {
+        return new ExtractedEndpointData([
+            'route' => $route,
+            'uri' => $route->uri,
+            'httpMethods' => $route->methods,
+            'method' => new \ReflectionFunction('dump'), // Just so we don't have null
+        ]);
+    }
+
+    protected function getRoute(array $matchRules): LaravelRoute
+    {
+        $routeRules[0]['match'] = array_merge($matchRules, ['domains' => '*']);
+        $matchedRoutes = (new RouteMatcher)->getRoutes($routeRules);
+        $this->assertCount(1, $matchedRoutes);
+        return $matchedRoutes[0]->getRoute();
     }
 }