GetFromLaravelAPI.php 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. <?php
  2. namespace Knuckles\Scribe\Extracting\Strategies\UrlParameters;
  3. use Illuminate\Database\Eloquent\Model;
  4. use Illuminate\Support\Str;
  5. use Knuckles\Camel\Extraction\ExtractedEndpointData;
  6. use Knuckles\Scribe\Extracting\ParamHelpers;
  7. use Knuckles\Scribe\Extracting\Shared\UrlParamsNormalizer;
  8. use Knuckles\Scribe\Extracting\Strategies\Strategy;
  9. use Knuckles\Scribe\Tools\Utils;
  10. use Throwable;
  11. class GetFromLaravelAPI extends Strategy
  12. {
  13. use ParamHelpers;
  14. public function __invoke(ExtractedEndpointData $endpointData, array $routeRules = []): ?array
  15. {
  16. $parameters = [];
  17. $path = $endpointData->uri;
  18. preg_match_all('/\{(.*?)\}/', $path, $matches);
  19. foreach ($matches[1] as $match) {
  20. $isOptional = Str::endsWith($match, '?');
  21. $name = rtrim($match, '?');
  22. $parameters[$name] = [
  23. 'name' => $name,
  24. 'description' => $this->inferUrlParamDescription($endpointData->uri, $name),
  25. 'required' => !$isOptional,
  26. ];
  27. }
  28. $parameters = $this->inferBetterTypesAndExamplesForEloquentUrlParameters($parameters, $endpointData);
  29. $parameters = $this->inferBetterTypesAndExamplesForEnumUrlParameters($parameters, $endpointData);
  30. $parameters = $this->setTypesAndExamplesForOthers($parameters, $endpointData);
  31. return $parameters;
  32. }
  33. protected function inferUrlParamDescription(string $url, string $paramName): string
  34. {
  35. // If $url is sth like /users/{id}, return "The ID of the user."
  36. // If $url is sth like /anything/{user_id}, return "The ID of the user."
  37. $strategies = collect(["id", "slug"])->map(function ($name) {
  38. $friendlyName = $name === 'id' ? "ID" : $name;
  39. return function ($url, $paramName) use ($name, $friendlyName) {
  40. if ($paramName == $name) {
  41. $thing = $this->getNameOfUrlThing($url, $paramName);
  42. return "The $friendlyName of the $thing.";
  43. } else if (Str::is("*_$name", $paramName)) {
  44. $thing = str_replace(["_", "-"], " ", str_replace("_$name", '', $paramName));
  45. return "The $friendlyName of the $thing.";
  46. }
  47. };
  48. })->toArray();
  49. // If $url is sth like /categories/{category}, return "The category."
  50. $strategies[] = function ($url, $paramName) {
  51. $thing = $this->getNameOfUrlThing($url, $paramName);
  52. if ($thing === $paramName) {
  53. return "The $thing.";
  54. }
  55. };
  56. foreach ($strategies as $strategy) {
  57. if ($inferred = $strategy($url, $paramName)) {
  58. return $inferred;
  59. }
  60. }
  61. return '';
  62. }
  63. protected function inferBetterTypesAndExamplesForEloquentUrlParameters(array $parameters, ExtractedEndpointData $endpointData): array
  64. {
  65. //We'll gather Eloquent model instances that can be linked to a URl parameter
  66. $modelInstances = [];
  67. // First, any bound models
  68. // Eg if route is /users/{id}, and (User $user) model is typehinted on method
  69. // If User model has `id` as an integer, then {id} param should be an integer
  70. $typeHintedEloquentModels = UrlParamsNormalizer::getTypeHintedEloquentModels($endpointData->method);
  71. foreach ($typeHintedEloquentModels as $argumentName => $modelInstance) {
  72. $routeKey = $modelInstance->getRouteKeyName();
  73. // Find the param name. In our normalized URL, argument $user might be param {user}, or {user_id}, or {id},
  74. if (isset($parameters[$argumentName])) {
  75. $paramName = $argumentName;
  76. } else if (isset($parameters["{$argumentName}_$routeKey"])) {
  77. $paramName = "{$argumentName}_$routeKey";
  78. } else if (isset($parameters[$routeKey])) {
  79. $paramName = $routeKey;
  80. } else {
  81. continue;
  82. }
  83. $modelInstances[$paramName] = $modelInstance;
  84. }
  85. // Next, non-Eloquent-bound parameters. They might still be Eloquent models, but model binding wasn't used.
  86. foreach ($parameters as $name => $data) {
  87. if (isset($data['type'])) continue;
  88. // If the url is /things/{id}, try to find a Thing model
  89. $urlThing = $this->getNameOfUrlThing($endpointData->uri, $name);
  90. if ($urlThing && ($modelInstance = $this->findModelFromUrlThing($urlThing))) {
  91. $modelInstances[$name] = $modelInstance;
  92. }
  93. }
  94. // Now infer.
  95. foreach ($modelInstances as $paramName => $modelInstance) {
  96. // If the routeKey is the same as the primary key in the database, use the PK's type.
  97. $routeKey = $modelInstance->getRouteKeyName();
  98. $type = $modelInstance->getKeyName() === $routeKey
  99. ? static::normalizeTypeName($modelInstance->getKeyType()) : 'string';
  100. $parameters[$paramName]['type'] = $type;
  101. try {
  102. $parameters[$paramName]['example'] = $modelInstance::first()->$routeKey ?? null;
  103. } catch (Throwable) {
  104. $parameters[$paramName]['example'] = null;
  105. }
  106. }
  107. return $parameters;
  108. }
  109. protected function inferBetterTypesAndExamplesForEnumUrlParameters(array $parameters, ExtractedEndpointData $endpointData): array
  110. {
  111. $typeHintedEnums = UrlParamsNormalizer::getTypeHintedEnums($endpointData->method);
  112. foreach ($typeHintedEnums as $argumentName => $enum) {
  113. $parameters[$argumentName]['type'] = static::normalizeTypeName($enum->getBackingType());
  114. try {
  115. $parameters[$argumentName]['example'] = $enum->getCases()[0]->getBackingValue();
  116. } catch (Throwable) {
  117. $parameters[$argumentName]['example'] = null;
  118. }
  119. }
  120. return $parameters;
  121. }
  122. protected function setTypesAndExamplesForOthers(array $parameters, ExtractedEndpointData $endpointData): array
  123. {
  124. foreach ($parameters as $name => $parameter) {
  125. if (empty($parameter['type'])) {
  126. $parameters[$name]['type'] = "string";
  127. }
  128. if (($parameter['example'] ?? null) === null) {
  129. // If the user explicitly set a `where()` constraint, use that to refine examples
  130. $parameterRegex = $endpointData->route->wheres[$name] ?? null;
  131. $parameters[$name]['example'] = $parameterRegex
  132. ? $this->castToType($this->getFaker()->regexify($parameterRegex), $parameters[$name]['type'])
  133. : $this->generateDummyValue($parameters[$name]['type'], hints: ['name' => $name]);
  134. }
  135. }
  136. return $parameters;
  137. }
  138. /**
  139. * Given a URL parameter $paramName, extract the "thing" that comes before it. eg::
  140. * - /<whatever>/things/{paramName} -> "thing"
  141. * - animals/cats/{id} -> "cat"
  142. * - users/{user_id}/contracts -> "user"
  143. *
  144. * @param string $url
  145. * @param string $paramName
  146. * @param string|null $alternateParamName A second paramName to try, if the original paramName isn't in the URL.
  147. *
  148. * @return string|null
  149. */
  150. protected function getNameOfUrlThing(string $url, string $paramName, ?string $alternateParamName = null): ?string
  151. {
  152. $parts = explode("/", $url);
  153. if (count($parts) === 1) return null; // URL was "/{thing}"
  154. $paramIndex = array_search("{{$paramName}}", $parts);
  155. if ($paramIndex === false) {
  156. $paramIndex = array_search("{{$alternateParamName}}", $parts);
  157. }
  158. if ($paramIndex === false || $paramIndex === 0) return null;
  159. $things = $parts[$paramIndex - 1];
  160. // Replace underscores/hyphens, so "side_projects" becomes "side project"
  161. return str_replace(["_", "-"], " ", Str::singular($things));
  162. }
  163. /**
  164. * Given a URL "thing", like the "cat" in /cats/{id}, try to locate a Cat model.
  165. *
  166. * @param string $urlThing
  167. *
  168. * @return Model|null
  169. */
  170. protected function findModelFromUrlThing(string $urlThing): ?Model
  171. {
  172. $className = str_replace(['-', '_', ' '], '', Str::title($urlThing));
  173. $rootNamespace = app()->getNamespace();
  174. if (class_exists($class = "{$rootNamespace}Models\\" . $className, autoload: false)
  175. // For the heathens that don't use a Models\ directory
  176. || class_exists($class = $rootNamespace . $className, autoload: false)) {
  177. try {
  178. $instance = new $class;
  179. } catch (\Error) { // It might be an enum or some other non-instantiable class
  180. return null;
  181. }
  182. return $instance instanceof Model ? $instance : null;
  183. }
  184. return null;
  185. }
  186. }