ExtractedEndpointData.php 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. <?php
  2. namespace Knuckles\Camel\Extraction;
  3. use Illuminate\Database\Eloquent\Model;
  4. use Illuminate\Routing\Route;
  5. use Illuminate\Support\Str;
  6. use Knuckles\Camel\BaseDTO;
  7. use Knuckles\Scribe\Tools\Utils as u;
  8. use ReflectionClass;
  9. class ExtractedEndpointData extends BaseDTO
  10. {
  11. /**
  12. * @var array<string>
  13. */
  14. public array $httpMethods;
  15. public string $uri;
  16. public Metadata $metadata;
  17. /**
  18. * @var array<string,string>
  19. */
  20. public array $headers = [];
  21. /**
  22. * @var array<string,\Knuckles\Camel\Extraction\Parameter>
  23. */
  24. public array $urlParameters = [];
  25. /**
  26. * @var array<string,mixed>
  27. */
  28. public array $cleanUrlParameters = [];
  29. /**
  30. * @var array<string,\Knuckles\Camel\Extraction\Parameter>
  31. */
  32. public array $queryParameters = [];
  33. /**
  34. * @var array<string,mixed>
  35. */
  36. public array $cleanQueryParameters = [];
  37. /**
  38. * @var array<string,\Knuckles\Camel\Extraction\Parameter>
  39. */
  40. public array $bodyParameters = [];
  41. /**
  42. * @var array<string,mixed>
  43. */
  44. public array $cleanBodyParameters = [];
  45. /**
  46. * @var array<string,\Illuminate\Http\UploadedFile|array>
  47. */
  48. public array $fileParameters = [];
  49. public ResponseCollection $responses;
  50. /**
  51. * @var array<string,\Knuckles\Camel\Extraction\ResponseField>
  52. */
  53. public array $responseFields = [];
  54. /**
  55. * Authentication info for this endpoint. In the form [{where}, {name}, {sample}]
  56. * Example: ["queryParameters", "api_key", "njiuyiw97865rfyvgfvb1"]
  57. */
  58. public array $auth = [];
  59. public ?ReflectionClass $controller;
  60. public ?\ReflectionFunctionAbstract $method;
  61. public ?Route $route;
  62. public function __construct(array $parameters = [])
  63. {
  64. $parameters['metadata'] = $parameters['metadata'] ?? new Metadata([]);
  65. $parameters['responses'] = $parameters['responses'] ?? new ResponseCollection([]);
  66. parent::__construct($parameters);
  67. $this->uri = $this->normalizeResourceParamName($this->uri, $this->route, $this->getTypeHintedArguments());
  68. }
  69. public static function fromRoute(Route $route, array $extras = []): self
  70. {
  71. $httpMethods = self::getMethods($route);
  72. $uri = $route->uri();
  73. [$controllerName, $methodName] = u::getRouteClassAndMethodNames($route);
  74. $controller = new ReflectionClass($controllerName);
  75. $method = u::getReflectedRouteMethod([$controllerName, $methodName]);
  76. $data = compact('httpMethods', 'uri', 'controller', 'method', 'route');
  77. $data = array_merge($data, $extras);
  78. return new ExtractedEndpointData($data);
  79. }
  80. /**
  81. * @param Route $route
  82. *
  83. * @return array<string>
  84. */
  85. public static function getMethods(Route $route): array
  86. {
  87. $methods = $route->methods();
  88. // Laravel adds an automatic "HEAD" endpoint for each GET request, so we'll strip that out,
  89. // but not if there's only one method (means it was intentional)
  90. if (count($methods) === 1) {
  91. return $methods;
  92. }
  93. return array_diff($methods, ['HEAD']);
  94. }
  95. public function name()
  96. {
  97. return sprintf("[%s] {$this->route->uri}.", implode(',', $this->route->methods));
  98. }
  99. public function endpointId()
  100. {
  101. return $this->httpMethods[0] . str_replace(['/', '?', '{', '}', ':', '\\', '+', '|'], '-', $this->uri);
  102. }
  103. public function normalizeResourceParamName(string $uri, Route $route, array $typeHintedArguments): string
  104. {
  105. $params = [];
  106. preg_match_all('#\{(\w+?)}#', $uri, $params);
  107. $resourceRouteNames = [
  108. ".index", ".show", ".update", ".destroy",
  109. ];
  110. if (Str::endsWith($route->action['as'] ?? '', $resourceRouteNames)) {
  111. // Note that resource routes can be nested eg users.posts.show
  112. $pluralResources = explode('.', $route->action['as']);
  113. array_pop($pluralResources);
  114. $foundResourceParam = false;
  115. foreach (array_reverse($pluralResources) as $pluralResource) {
  116. $singularResource = Str::singular($pluralResource);
  117. $singularResourceParam = str_replace('-', '_', $singularResource);
  118. $search = [
  119. "{$pluralResource}/{{$singularResourceParam}}",
  120. "{$pluralResource}/{{$singularResource}}",
  121. "{$pluralResource}/{{$singularResourceParam}?}",
  122. "{$pluralResource}/{{$singularResource}?}"
  123. ];
  124. // If there is an inline binding in the route, like /users/{user:uuid}, use that key,
  125. // Else, search for a type-hinted variable in the action, whose name matches the route segment name,
  126. // If there is such variable (like User $user), call getRouteKeyName() on the model,
  127. // Otherwise, use the id
  128. $binding = static::getFieldBindingForUrlParam($route, $singularResource, $typeHintedArguments, 'id');
  129. if (!$foundResourceParam) {
  130. // Only the last resource param should be {id}
  131. $replace = ["$pluralResource/{{$binding}}", "$pluralResource/{{$binding}?}"];
  132. $foundResourceParam = true;
  133. } else {
  134. // Earlier ones should be {<param>_id}
  135. $replace = [
  136. "{$pluralResource}/{{$singularResource}_{$binding}}",
  137. "{$pluralResource}/{{$singularResourceParam}_{$binding}}",
  138. "{$pluralResource}/{{$singularResource}_{$binding}?}",
  139. "{$pluralResource}/{{$singularResourceParam}_{$binding}?}"
  140. ];
  141. }
  142. $uri = str_replace($search, $replace, $uri);
  143. }
  144. }
  145. foreach ($params[1] as $param) {
  146. // For non-resource parameters, if there's a field binding/type-hinted variable, replace that too:
  147. if ($binding = static::getFieldBindingForUrlParam($route, $param, $typeHintedArguments)) {
  148. $search = ["{{$param}}", "{{$param}?}"];
  149. $replace = ["{{$param}_{$binding}}", "{{$param}_{$binding}?}"];
  150. $uri = str_replace($search, $replace, $uri);
  151. }
  152. }
  153. return $uri;
  154. }
  155. /**
  156. * Prepare the endpoint data for serialising.
  157. */
  158. public function forSerialisation()
  159. {
  160. $copy = $this->except(
  161. // Get rid of all duplicate data
  162. 'cleanQueryParameters', 'cleanUrlParameters', 'fileParameters', 'cleanBodyParameters',
  163. // and objects used only in extraction
  164. 'route', 'controller', 'method', 'auth',
  165. );
  166. // Remove these, since they're on the parent group object
  167. $copy->metadata = $copy->metadata->except('groupName', 'groupDescription', 'beforeGroup', 'afterGroup');
  168. return $copy;
  169. }
  170. protected static function instantiateTypedArgument(\ReflectionNamedType $argumentType): ?object
  171. {
  172. $argumentClassName = $argumentType->getName();
  173. if (class_exists($argumentClassName)) {
  174. return new $argumentClassName;
  175. }
  176. if (interface_exists($argumentClassName)) {
  177. return app($argumentClassName);
  178. }
  179. return null;
  180. }
  181. public static function getFieldBindingForUrlParam(
  182. Route $route, string $paramName, array $typeHintedArguments = [], string $default = null
  183. ): ?string
  184. {
  185. $binding = null;
  186. // Was added in Laravel 7.x
  187. if (method_exists($route, 'bindingFieldFor')) {
  188. $binding = $route->bindingFieldFor($paramName);
  189. }
  190. // Search for a type-hinted variable whose name matches the route segment name
  191. if (is_null($binding) && array_key_exists($paramName, $typeHintedArguments)) {
  192. $argumentType = $typeHintedArguments[$paramName]->getType();
  193. $argumentInstance = self::instantiateTypedArgument($argumentType);
  194. $binding = $argumentInstance instanceof Model ? $argumentInstance->getRouteKeyName() : null;
  195. }
  196. return $binding ?: $default;
  197. }
  198. /**
  199. * Return the type-hinted method arguments in the action that have a Model type,
  200. * The arguments will be returned as an array of the form: $arguments[<variable_name>] = $argument
  201. */
  202. protected function getTypeHintedArguments(): array
  203. {
  204. $arguments = [];
  205. if ($this->method) {
  206. foreach ($this->method->getParameters() as $argument) {
  207. if ($this->argumentHasModelType($argument)) {
  208. $arguments[$argument->getName()] = $argument;
  209. }
  210. }
  211. }
  212. return $arguments;
  213. }
  214. /**
  215. * Determine whether the argument has a Model type
  216. */
  217. protected function argumentHasModelType(\ReflectionParameter $argument): bool
  218. {
  219. $argumentType = $argument->getType();
  220. if (!($argumentType instanceof \ReflectionNamedType)) {
  221. // The argument does not have a type-hint, or is a primitive type (`string`, ..)
  222. return false;
  223. }
  224. $argumentInstance = self::instantiateTypedArgument($argumentType);
  225. return ($argumentInstance instanceof Model);
  226. }
  227. }