UrlParamsNormalizer.php 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. <?php
  2. namespace Knuckles\Scribe\Extracting;
  3. use Illuminate\Database\Eloquent\Model;
  4. use Illuminate\Routing\Route;
  5. use Illuminate\Support\Str;
  6. use Knuckles\Camel\Extraction\ExtractedEndpointData;
  7. use ReflectionEnum;
  8. use ReflectionException;
  9. use ReflectionFunctionAbstract;
  10. /*
  11. * See https://laravel.com/docs/9.x/routing#route-model-binding
  12. */
  13. class UrlParamsNormalizer
  14. {
  15. /**
  16. * Normalize a URL from Laravel-style to something that's clearer for a non-Laravel user.
  17. * For instance, `/posts/{post}` would be clearer as `/posts/{id}`,
  18. * and `/users/{user}/posts/{post}` would be clearer as `/users/{user_id}/posts/{id}`
  19. *
  20. * @param \Illuminate\Routing\Route $route
  21. * @param \ReflectionFunctionAbstract $method
  22. *
  23. * @return string
  24. */
  25. public static function normalizeParameterNamesInRouteUri(Route $route, ReflectionFunctionAbstract $method): string
  26. {
  27. $params = [];
  28. $uri = $route->uri;
  29. preg_match_all('#\{(\w+?)}#', $uri, $params);
  30. $resourceRouteNames = [".index", ".show", ".update", ".destroy"];
  31. $typeHintedEloquentModels = self::getTypeHintedEloquentModels($method);
  32. $routeName = $route->action['as'] ?? '';
  33. if (Str::endsWith($routeName, $resourceRouteNames)) {
  34. // Note that resource routes can be nested eg users.posts.show
  35. $pluralResources = explode('.', $routeName);
  36. array_pop($pluralResources); // Remove the name of the action (eg `show`)
  37. $alreadyFoundResourceParam = false;
  38. foreach (array_reverse($pluralResources) as $pluralResource) {
  39. $singularResource = Str::singular($pluralResource);
  40. $singularResourceParam = str_replace('-', '_', $singularResource); // URL parameters are often declared with _ in Laravel but - outside
  41. $urlPatternsToSearchFor = [
  42. "{$pluralResource}/{{$singularResourceParam}}",
  43. "{$pluralResource}/{{$singularResource}}",
  44. "{$pluralResource}/{{$singularResourceParam}?}",
  45. "{$pluralResource}/{{$singularResource}?}",
  46. ];
  47. $binding = self::getRouteKeyForUrlParam(
  48. $route, $singularResource, $typeHintedEloquentModels, 'id'
  49. );
  50. if (!$alreadyFoundResourceParam) {
  51. // This is the first resource param (from the end).
  52. // We set it to `params/{id}` (or whatever field it's bound to)
  53. $replaceWith = ["$pluralResource/{{$binding}}", "$pluralResource/{{$binding}?}"];
  54. $alreadyFoundResourceParam = true;
  55. } else {
  56. // Other resource parameters will be `params/{<param>_id}`
  57. $replaceWith = [
  58. "{$pluralResource}/{{$singularResource}_{$binding}}",
  59. "{$pluralResource}/{{$singularResourceParam}_{$binding}}",
  60. "{$pluralResource}/{{$singularResource}_{$binding}?}",
  61. "{$pluralResource}/{{$singularResourceParam}_{$binding}?}",
  62. ];
  63. }
  64. $uri = str_replace($urlPatternsToSearchFor, $replaceWith, $uri);
  65. }
  66. }
  67. foreach ($params[1] as $param) {
  68. // For non-resource parameters, if there's a field binding/type-hinted variable, replace that too:
  69. if ($binding = self::getRouteKeyForUrlParam($route, $param, $typeHintedEloquentModels)) {
  70. $urlPatternsToSearchFor = ["{{$param}}", "{{$param}?}"];
  71. $replaceWith = ["{{$param}_{$binding}}", "{{$param}_{$binding}?}"];
  72. $uri = str_replace($urlPatternsToSearchFor, $replaceWith, $uri);
  73. }
  74. }
  75. return $uri;
  76. }
  77. /**
  78. * Return the type-hinted method arguments in the action that are Eloquent models,
  79. * The arguments will be returned as an array of the form: [<variable_name> => $instance]
  80. */
  81. public static function getTypeHintedEloquentModels(ReflectionFunctionAbstract $method): array
  82. {
  83. $arguments = [];
  84. foreach ($method->getParameters() as $argument) {
  85. if (($instance = self::instantiateMethodArgument($argument)) && $instance instanceof Model) {
  86. $arguments[$argument->getName()] = $instance;
  87. }
  88. }
  89. return $arguments;
  90. }
  91. /**
  92. * Return the type-hinted method arguments in the action that are enums,
  93. * The arguments will be returned as an array of the form: [<variable_name> => $instance]
  94. */
  95. public static function getTypeHintedEnums(ReflectionFunctionAbstract $method): array
  96. {
  97. $arguments = [];
  98. foreach ($method->getParameters() as $argument) {
  99. $argumentType = $argument->getType();
  100. if (!($argumentType instanceof \ReflectionNamedType)) continue;
  101. try {
  102. $reflectionEnum = new ReflectionEnum($argumentType->getName());
  103. $arguments[$argument->getName()] = $reflectionEnum;
  104. } catch (ReflectionException) {
  105. continue;
  106. }
  107. }
  108. return $arguments;
  109. }
  110. /**
  111. * Given a URL that uses Eloquent model binding (for instance `/posts/{post}` -> `public function show(Post
  112. * $post)`), we need to figure out the field that Eloquent uses to retrieve the Post object. By default, this would
  113. * be `id`, but can be configured in a couple of ways:
  114. *
  115. * - Inline: `/posts/{post:slug}`
  116. * - `class Post { public function getRouteKeyName() { return 'slug'; } }`
  117. *
  118. * There are other ways, but they're dynamic and beyond our scope.
  119. *
  120. * @param \Illuminate\Routing\Route $route
  121. * @param string $paramName The name of the URL parameter
  122. * @param array<string, Model> $typeHintedEloquentModels
  123. * @param string|null $default Default field to use
  124. *
  125. * @return string|null
  126. */
  127. protected static function getRouteKeyForUrlParam(
  128. Route $route, string $paramName, array $typeHintedEloquentModels = [], string $default = null
  129. ): ?string
  130. {
  131. if ($binding = self::getInlineRouteKey($route, $paramName)) {
  132. return $binding;
  133. }
  134. return self::getRouteKeyFromModel($paramName, $typeHintedEloquentModels) ?: $default;
  135. }
  136. /**
  137. * Return the `slug` in /posts/{post:slug}
  138. *
  139. * @param \Illuminate\Routing\Route $route
  140. * @param string $paramName
  141. *
  142. * @return string|null
  143. */
  144. protected static function getInlineRouteKey(Route $route, string $paramName): ?string
  145. {
  146. // Was added in Laravel 7.x
  147. if (method_exists($route, 'bindingFieldFor')) {
  148. return $route->bindingFieldFor($paramName);
  149. }
  150. return null;
  151. }
  152. /**
  153. * Check if there's a type-hinted argument on the controller method matching the URL param name:
  154. * eg /posts/{post} -> public function show(Post $post)
  155. * If there is, check if it's an Eloquent model.
  156. * If it is, return it's `getRouteKeyName()`.
  157. *
  158. * @param string $paramName
  159. * @param Model[] $typeHintedEloquentModels
  160. *
  161. * @return string|null
  162. */
  163. protected static function getRouteKeyFromModel(string $paramName, array $typeHintedEloquentModels): ?string
  164. {
  165. if (array_key_exists($paramName, $typeHintedEloquentModels)) {
  166. $argumentInstance = $typeHintedEloquentModels[$paramName];
  167. return $argumentInstance->getRouteKeyName();
  168. }
  169. return null;
  170. }
  171. /**
  172. * Instantiate an argument on a controller method via its typehint. For instance, $post in:
  173. *
  174. * public function show(Post $post)
  175. *
  176. * This method takes in a method argument and returns an instance, or null if it couldn't be instantiated safely.
  177. * Cases where instantiation may fail:
  178. * - the argument has no type (eg `public function show($postId)`)
  179. * - the argument has a primitive type (eg `public function show(string $postId)`)
  180. * - the argument is an injected dependency that itself needs other dependencies
  181. * (eg `public function show(PostsManager $manager)`)
  182. *
  183. * @param \ReflectionParameter $argument
  184. *
  185. * @return object|null
  186. */
  187. protected static function instantiateMethodArgument(\ReflectionParameter $argument): ?object
  188. {
  189. $argumentType = $argument->getType();
  190. // No type-hint, or primitive type
  191. if (!($argumentType instanceof \ReflectionNamedType)) return null;
  192. $argumentClassName = $argumentType->getName();
  193. if (class_exists($argumentClassName)) {
  194. try {
  195. return new $argumentClassName;
  196. } catch (\Throwable $e) {
  197. return null;
  198. }
  199. }
  200. if (interface_exists($argumentClassName)) {
  201. return app($argumentClassName);
  202. }
  203. return null;
  204. }
  205. }