Generator.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. <?php
  2. namespace Knuckles\Scribe\Extracting;
  3. use Faker\Factory;
  4. use Illuminate\Http\UploadedFile;
  5. use Illuminate\Routing\Route;
  6. use Illuminate\Support\Arr;
  7. use Illuminate\Support\Str;
  8. use Knuckles\Scribe\Extracting\Strategies\Strategy;
  9. use Knuckles\Scribe\Tools\DocumentationConfig;
  10. use Knuckles\Scribe\Tools\Utils as u;
  11. use ReflectionClass;
  12. use ReflectionFunctionAbstract;
  13. class Generator
  14. {
  15. /**
  16. * @var DocumentationConfig
  17. */
  18. private $config;
  19. /**
  20. * @var Route|null
  21. */
  22. private static $routeBeingProcessed = null;
  23. public function __construct(DocumentationConfig $config = null)
  24. {
  25. // If no config is injected, pull from global
  26. $this->config = $config ?: new DocumentationConfig(config('scribe'));
  27. }
  28. /**
  29. * External interface that allows users to know what route is currently being processed
  30. */
  31. public static function getRouteBeingProcessed(): ?Route
  32. {
  33. return self::$routeBeingProcessed;
  34. }
  35. /**
  36. * @param Route $route
  37. *
  38. * @return mixed
  39. */
  40. public function getUri(Route $route)
  41. {
  42. return $route->uri();
  43. }
  44. /**
  45. * @param Route $route
  46. *
  47. * @return mixed
  48. */
  49. public function getMethods(Route $route)
  50. {
  51. $methods = $route->methods();
  52. // Laravel adds an automatic "HEAD" endpoint for each GET request, so we'll strip that out,
  53. // but not if there's only one method (means it was intentional)
  54. if (count($methods) === 1) {
  55. return $methods;
  56. }
  57. return array_diff($methods, ['HEAD']);
  58. }
  59. /**
  60. * @param \Illuminate\Routing\Route $route
  61. * @param array $routeRules Rules to apply when generating documentation for this route
  62. *
  63. * @throws \ReflectionException
  64. *
  65. * @return array
  66. */
  67. public function processRoute(Route $route, array $routeRules = [])
  68. {
  69. self::$routeBeingProcessed = $route;
  70. [$controllerName, $methodName] = u::getRouteClassAndMethodNames($route);
  71. $controller = new ReflectionClass($controllerName);
  72. $method = u::getReflectedRouteMethod([$controllerName, $methodName]);
  73. $parsedRoute = [
  74. 'id' => md5($this->getUri($route) . ':' . implode($this->getMethods($route))),
  75. 'methods' => $this->getMethods($route),
  76. 'uri' => $this->getUri($route),
  77. ];
  78. $metadata = $this->fetchMetadata($controller, $method, $route, $routeRules, $parsedRoute);
  79. $parsedRoute['metadata'] = $metadata;
  80. $urlParameters = $this->fetchUrlParameters($controller, $method, $route, $routeRules, $parsedRoute);
  81. $parsedRoute['urlParameters'] = $urlParameters;
  82. $parsedRoute['cleanUrlParameters'] = self::cleanParams($urlParameters);
  83. $parsedRoute['boundUri'] = u::getFullUrl($route, $parsedRoute['cleanUrlParameters']);
  84. $parsedRoute = $this->addAuthField($parsedRoute);
  85. $queryParameters = $this->fetchQueryParameters($controller, $method, $route, $routeRules, $parsedRoute);
  86. $parsedRoute['queryParameters'] = $queryParameters;
  87. $parsedRoute['cleanQueryParameters'] = self::cleanParams($queryParameters);
  88. $headers = $this->fetchRequestHeaders($controller, $method, $route, $routeRules, $parsedRoute);
  89. $parsedRoute['headers'] = $headers;
  90. $bodyParameters = $this->fetchBodyParameters($controller, $method, $route, $routeRules, $parsedRoute);
  91. $parsedRoute['bodyParameters'] = $bodyParameters;
  92. $parsedRoute['cleanBodyParameters'] = self::cleanParams($bodyParameters);
  93. if (count($parsedRoute['cleanBodyParameters']) && !isset($parsedRoute['headers']['Content-Type'])) {
  94. // Set content type if the user forgot to set it
  95. $parsedRoute['headers']['Content-Type'] = 'application/json';
  96. }
  97. [$files, $regularParameters] = collect($parsedRoute['cleanBodyParameters'])->partition(function ($example) {
  98. return $example instanceof UploadedFile;
  99. });
  100. if (count($files)) {
  101. $parsedRoute['headers']['Content-Type'] = 'multipart/form-data';
  102. }
  103. $parsedRoute['fileParameters'] = $files->toArray();
  104. $parsedRoute['cleanBodyParameters'] = $regularParameters->toArray();
  105. $responses = $this->fetchResponses($controller, $method, $route, $routeRules, $parsedRoute);
  106. $parsedRoute['responses'] = $responses;
  107. $parsedRoute['showresponse'] = ! empty($responses);
  108. $responseFields = $this->fetchResponseFields($controller, $method, $route, $routeRules, $parsedRoute);
  109. $parsedRoute['responseFields'] = $responseFields;
  110. self::$routeBeingProcessed = null;
  111. return $parsedRoute;
  112. }
  113. protected function fetchMetadata(ReflectionClass $controller, ReflectionFunctionAbstract $method, Route $route, array $rulesToApply, array $context = [])
  114. {
  115. $context['metadata'] = [
  116. 'groupName' => $this->config->get('default_group', ''),
  117. 'groupDescription' => '',
  118. 'title' => '',
  119. 'description' => '',
  120. 'authenticated' => false,
  121. ];
  122. return $this->iterateThroughStrategies('metadata', $context, [$route, $controller, $method, $rulesToApply]);
  123. }
  124. protected function fetchUrlParameters(ReflectionClass $controller, ReflectionFunctionAbstract $method, Route $route, array $rulesToApply, array $context = [])
  125. {
  126. return $this->iterateThroughStrategies('urlParameters', $context, [$route, $controller, $method, $rulesToApply]);
  127. }
  128. protected function fetchQueryParameters(ReflectionClass $controller, ReflectionFunctionAbstract $method, Route $route, array $rulesToApply, array $context = [])
  129. {
  130. return $this->iterateThroughStrategies('queryParameters', $context, [$route, $controller, $method, $rulesToApply]);
  131. }
  132. protected function fetchBodyParameters(ReflectionClass $controller, ReflectionFunctionAbstract $method, Route $route, array $rulesToApply, array $context = [])
  133. {
  134. return $this->iterateThroughStrategies('bodyParameters', $context, [$route, $controller, $method, $rulesToApply]);
  135. }
  136. protected function fetchResponses(ReflectionClass $controller, ReflectionFunctionAbstract $method, Route $route, array $rulesToApply, array $context = [])
  137. {
  138. $responses = $this->iterateThroughStrategies('responses', $context, [$route, $controller, $method, $rulesToApply]);
  139. if (count($responses)) {
  140. return array_filter($responses, function ($response) {
  141. return $response['content'] != null;
  142. });
  143. }
  144. return [];
  145. }
  146. protected function fetchResponseFields(ReflectionClass $controller, ReflectionFunctionAbstract $method, Route $route, array $rulesToApply, array $context = [])
  147. {
  148. return $this->iterateThroughStrategies('responseFields', $context, [$route, $controller, $method, $rulesToApply]);
  149. }
  150. protected function fetchRequestHeaders(ReflectionClass $controller, ReflectionFunctionAbstract $method, Route $route, array $rulesToApply, array $context = [])
  151. {
  152. $headers = $this->iterateThroughStrategies('headers', $context, [$route, $controller, $method, $rulesToApply]);
  153. return array_filter($headers);
  154. }
  155. protected function iterateThroughStrategies(string $stage, array $extractedData, array $arguments)
  156. {
  157. $defaultStrategies = [
  158. 'metadata' => [
  159. \Knuckles\Scribe\Extracting\Strategies\Metadata\GetFromDocBlocks::class,
  160. ],
  161. 'urlParameters' => [
  162. \Knuckles\Scribe\Extracting\Strategies\UrlParameters\GetFromUrlParamTag::class,
  163. ],
  164. 'queryParameters' => [
  165. \Knuckles\Scribe\Extracting\Strategies\QueryParameters\GetFromQueryParamTag::class,
  166. ],
  167. 'headers' => [
  168. \Knuckles\Scribe\Extracting\Strategies\Headers\GetFromRouteRules::class,
  169. \Knuckles\Scribe\Extracting\Strategies\Headers\GetFromHeaderTag::class,
  170. ],
  171. 'bodyParameters' => [
  172. \Knuckles\Scribe\Extracting\Strategies\BodyParameters\GetFromFormRequest::class,
  173. \Knuckles\Scribe\Extracting\Strategies\BodyParameters\GetFromBodyParamTag::class,
  174. ],
  175. 'responses' => [
  176. \Knuckles\Scribe\Extracting\Strategies\Responses\UseTransformerTags::class,
  177. \Knuckles\Scribe\Extracting\Strategies\Responses\UseResponseTag::class,
  178. \Knuckles\Scribe\Extracting\Strategies\Responses\UseResponseFileTag::class,
  179. \Knuckles\Scribe\Extracting\Strategies\Responses\UseApiResourceTags::class,
  180. \Knuckles\Scribe\Extracting\Strategies\Responses\ResponseCalls::class,
  181. ],
  182. 'responseFields' => [
  183. \Knuckles\Scribe\Extracting\Strategies\ResponseFields\GetFromResponseFieldTag::class,
  184. ],
  185. ];
  186. // Use the default strategies for the stage, unless they were explicitly set
  187. $strategies = $this->config->get("strategies.$stage", $defaultStrategies[$stage]);
  188. $extractedData[$stage] = $extractedData[$stage] ?? [];
  189. foreach ($strategies as $strategyClass) {
  190. /** @var Strategy $strategy */
  191. $strategy = new $strategyClass($this->config);
  192. $strategyArgs = $arguments;
  193. $strategyArgs[] = $extractedData;
  194. $results = $strategy(...$strategyArgs);
  195. if (! is_null($results)) {
  196. foreach ($results as $index => $item) {
  197. if ($stage == 'responses') {
  198. // Responses from different strategies are all added, not overwritten
  199. $extractedData[$stage][] = $item;
  200. continue;
  201. }
  202. // We're using a for loop rather than array_merge or +=
  203. // so it does not renumber numeric keys and also allows values to be overwritten
  204. // Don't allow overwriting if an empty value is trying to replace a set one
  205. if (! in_array($extractedData[$stage], [null, ''], true) && in_array($item, [null, ''], true)) {
  206. continue;
  207. } else {
  208. $extractedData[$stage][$index] = $item;
  209. }
  210. }
  211. }
  212. }
  213. return $extractedData[$stage];
  214. }
  215. /**
  216. * This method prepares and simplifies request parameters for use in example requests and response calls.
  217. * It takes in an array with rich details about a parameter eg
  218. * ['age' => [
  219. * 'description' => 'The age',
  220. * 'value' => 12,
  221. * 'required' => false,
  222. * ]
  223. * And transforms them into key-value pairs : ['age' => 12]
  224. * It also filters out parameters which have null values and have 'required' as false.
  225. * It converts all file params that have string examples to actual files (instances of UploadedFile).
  226. * Finally, it adds a '.0' key for each array parameter (eg users.* ->users.0),
  227. * so that the array ends up containing a 1-item array.
  228. *
  229. * @param array $params
  230. *
  231. * @return array
  232. */
  233. public static function cleanParams(array $params): array
  234. {
  235. $cleanParams = [];
  236. // Remove params which have no examples and are optional.
  237. $params = array_filter($params, function ($details) {
  238. return ! (is_null($details['value']) && $details['required'] === false);
  239. });
  240. foreach ($params as $paramName => $details) {
  241. if (($details['type'] ?? '') === 'file' && is_string($details['value'])) {
  242. // Convert any string file examples to instances of UploadedFile
  243. $filePath = $details['value'];
  244. $fileName = basename($filePath);
  245. $details['value'] = new UploadedFile(
  246. $filePath, $fileName, mime_content_type($filePath), 0,false
  247. );
  248. }
  249. self::generateConcreteKeysForArrayParameters(
  250. $paramName,
  251. $details['value'],
  252. $cleanParams
  253. );
  254. }
  255. return $cleanParams;
  256. }
  257. /**
  258. * For each array notation parameter (eg user.*, item.*.name, object.*.*, user[])
  259. * add a key that represents a "concrete" number (eg user.0, item.0.name, object.0.0, user.0 with the same value.
  260. * That way, we always have an array of length 1 for each array key
  261. *
  262. * @param string $paramName
  263. * @param mixed $paramExample
  264. * @param array $cleanParams The array that holds the result
  265. *
  266. * @return void
  267. */
  268. protected static function generateConcreteKeysForArrayParameters($paramName, $paramExample, array &$cleanParams = [])
  269. {
  270. if (Str::contains($paramName, '[')) {
  271. // Replace usages of [] with dot notation
  272. $paramName = str_replace(['][', '[', ']', '..'], ['.', '.', '', '.*.'], $paramName);
  273. }
  274. // Then generate a sample item for the dot notation
  275. Arr::set($cleanParams, str_replace(['.*', '*.'], ['.0','0.'], $paramName), $paramExample);
  276. }
  277. public function addAuthField(array $parsedRoute): array
  278. {
  279. $parsedRoute['auth'] = null;
  280. $isApiAuthed = $this->config->get('auth.enabled', false);
  281. if (!$isApiAuthed || !$parsedRoute['metadata']['authenticated']) {
  282. return $parsedRoute;
  283. }
  284. $strategy = $this->config->get('auth.in');
  285. $parameterName = $this->config->get('auth.name');
  286. $faker = Factory::create();
  287. if ($this->config->get('faker_seed')) {
  288. $faker->seed($this->config->get('faker_seed'));
  289. }
  290. $token = $faker->shuffle('abcdefghkvaZVDPE1864563');
  291. $valueToUse = $this->config->get('auth.use_value');
  292. $valueToDisplay = $this->config->get('auth.placeholder');
  293. switch ($strategy) {
  294. case 'query':
  295. case 'query_or_body':
  296. $parsedRoute['auth'] = "cleanQueryParameters.$parameterName.".($valueToUse ?: $token);
  297. $parsedRoute['queryParameters'][$parameterName] = [
  298. 'name' => $parameterName,
  299. 'value' => $valueToDisplay ?:$token,
  300. 'description' => '',
  301. 'required' => true,
  302. ];
  303. break;
  304. case 'body':
  305. $parsedRoute['auth'] = "cleanBodyParameters.$parameterName.".($valueToUse ?: $token);
  306. $parsedRoute['bodyParameters'][$parameterName] = [
  307. 'name' => $parameterName,
  308. 'type' => 'string',
  309. 'value' => $valueToDisplay ?: $token,
  310. 'description' => '',
  311. 'required' => true,
  312. ];
  313. break;
  314. case 'bearer':
  315. $parsedRoute['auth'] = "headers.Authorization.Bearer ".($valueToUse ?: $token);
  316. $parsedRoute['headers']['Authorization'] = "Bearer ".($valueToDisplay ?: $token);
  317. break;
  318. case 'basic':
  319. $parsedRoute['auth'] = "headers.Authorization.Basic ".($valueToUse ?: base64_encode($token));
  320. $parsedRoute['headers']['Authorization'] = "Basic ".($valueToDisplay ?: base64_encode($token));
  321. break;
  322. case 'header':
  323. $parsedRoute['auth'] = "headers.$parameterName.".($valueToUse ?: $token);
  324. $parsedRoute['headers'][$parameterName] = $valueToDisplay ?: $token;
  325. break;
  326. }
  327. return $parsedRoute;
  328. }
  329. }