GetFromLaravelAPI.php 7.7 KB

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