Generator.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  1. <?php
  2. namespace Knuckles\Scribe\Extracting;
  3. use Faker\Factory;
  4. use Illuminate\Http\Testing\File;
  5. use Illuminate\Http\UploadedFile;
  6. use Illuminate\Routing\Route;
  7. use Illuminate\Support\Arr;
  8. use Illuminate\Support\Str;
  9. use Knuckles\Scribe\Extracting\Strategies\Strategy;
  10. use Knuckles\Scribe\Tools\DocumentationConfig;
  11. use Knuckles\Scribe\Tools\Utils as u;
  12. use ReflectionClass;
  13. use ReflectionFunctionAbstract;
  14. class Generator
  15. {
  16. /**
  17. * @var DocumentationConfig
  18. */
  19. private $config;
  20. /**
  21. * @var Route|null
  22. */
  23. private static $routeBeingProcessed = null;
  24. public function __construct(DocumentationConfig $config = null)
  25. {
  26. // If no config is injected, pull from global
  27. $this->config = $config ?: new DocumentationConfig(config('scribe'));
  28. }
  29. /**
  30. * External interface that allows users to know what route is currently being processed
  31. */
  32. public static function getRouteBeingProcessed(): ?Route
  33. {
  34. return self::$routeBeingProcessed;
  35. }
  36. /**
  37. * @param Route $route
  38. *
  39. * @return mixed
  40. */
  41. public function getUri(Route $route)
  42. {
  43. return $route->uri();
  44. }
  45. /**
  46. * @param Route $route
  47. *
  48. * @return mixed
  49. */
  50. public function getMethods(Route $route)
  51. {
  52. $methods = $route->methods();
  53. // Laravel adds an automatic "HEAD" endpoint for each GET request, so we'll strip that out,
  54. // but not if there's only one method (means it was intentional)
  55. if (count($methods) === 1) {
  56. return $methods;
  57. }
  58. return array_diff($methods, ['HEAD']);
  59. }
  60. /**
  61. * @param \Illuminate\Routing\Route $route
  62. * @param array $routeRules Rules to apply when generating documentation for this route
  63. *
  64. * @return array
  65. * @throws \ReflectionException
  66. *
  67. */
  68. public function processRoute(Route $route, array $routeRules = [])
  69. {
  70. self::$routeBeingProcessed = $route;
  71. [$controllerName, $methodName] = u::getRouteClassAndMethodNames($route);
  72. $controller = new ReflectionClass($controllerName);
  73. $method = u::getReflectedRouteMethod([$controllerName, $methodName]);
  74. $parsedRoute = [
  75. 'id' => md5($this->getUri($route) . ':' . implode($this->getMethods($route))),
  76. 'methods' => $this->getMethods($route),
  77. 'uri' => $this->getUri($route),
  78. ];
  79. $metadata = $this->fetchMetadata($controller, $method, $route, $routeRules, $parsedRoute);
  80. $parsedRoute['metadata'] = $metadata;
  81. $urlParameters = $this->fetchUrlParameters($controller, $method, $route, $routeRules, $parsedRoute);
  82. $parsedRoute['urlParameters'] = $urlParameters;
  83. $parsedRoute['cleanUrlParameters'] = self::cleanParams($urlParameters);
  84. $parsedRoute['boundUri'] = u::getUrlWithBoundParameters($route, $parsedRoute['cleanUrlParameters']);
  85. $parsedRoute = $this->addAuthField($parsedRoute);
  86. $queryParameters = $this->fetchQueryParameters($controller, $method, $route, $routeRules, $parsedRoute);
  87. $parsedRoute['queryParameters'] = $queryParameters;
  88. $parsedRoute['cleanQueryParameters'] = self::cleanParams($queryParameters);
  89. $headers = $this->fetchRequestHeaders($controller, $method, $route, $routeRules, $parsedRoute);
  90. $parsedRoute['headers'] = $headers;
  91. $bodyParameters = $this->fetchBodyParameters($controller, $method, $route, $routeRules, $parsedRoute);
  92. $parsedRoute['bodyParameters'] = $bodyParameters;
  93. $parsedRoute['cleanBodyParameters'] = self::cleanParams($bodyParameters);
  94. if (count($parsedRoute['cleanBodyParameters']) && !isset($parsedRoute['headers']['Content-Type'])) {
  95. // Set content type if the user forgot to set it
  96. $parsedRoute['headers']['Content-Type'] = 'application/json';
  97. }
  98. [$files, $regularParameters] = collect($parsedRoute['cleanBodyParameters'])->partition(function ($example) {
  99. return $example instanceof UploadedFile
  100. || (is_array($example) && !empty($example[0]) && $example[0] instanceof UploadedFile);
  101. });
  102. if (count($files)) {
  103. $parsedRoute['headers']['Content-Type'] = 'multipart/form-data';
  104. }
  105. $parsedRoute['fileParameters'] = $files->toArray();
  106. $parsedRoute['cleanBodyParameters'] = $regularParameters->toArray();
  107. $responses = $this->fetchResponses($controller, $method, $route, $routeRules, $parsedRoute);
  108. $parsedRoute['responses'] = $responses;
  109. $parsedRoute['showresponse'] = !empty($responses);
  110. $responseFields = $this->fetchResponseFields($controller, $method, $route, $routeRules, $parsedRoute);
  111. $parsedRoute['responseFields'] = $responseFields;
  112. $parsedRoute['nestedBodyParameters'] = self::nestArrayAndObjectFields($parsedRoute['bodyParameters']);
  113. self::$routeBeingProcessed = null;
  114. return $parsedRoute;
  115. }
  116. protected function fetchMetadata(ReflectionClass $controller, ReflectionFunctionAbstract $method, Route $route, array $rulesToApply, array $context = [])
  117. {
  118. $context['metadata'] = [
  119. 'groupName' => $this->config->get('default_group', ''),
  120. 'groupDescription' => '',
  121. 'title' => '',
  122. 'description' => '',
  123. 'authenticated' => false,
  124. ];
  125. return $this->iterateThroughStrategies('metadata', $context, [$route, $controller, $method, $rulesToApply]);
  126. }
  127. protected function fetchUrlParameters(ReflectionClass $controller, ReflectionFunctionAbstract $method, Route $route, array $rulesToApply, array $context = [])
  128. {
  129. return $this->iterateThroughStrategies('urlParameters', $context, [$route, $controller, $method, $rulesToApply]);
  130. }
  131. protected function fetchQueryParameters(ReflectionClass $controller, ReflectionFunctionAbstract $method, Route $route, array $rulesToApply, array $context = [])
  132. {
  133. return $this->iterateThroughStrategies('queryParameters', $context, [$route, $controller, $method, $rulesToApply]);
  134. }
  135. protected function fetchBodyParameters(ReflectionClass $controller, ReflectionFunctionAbstract $method, Route $route, array $rulesToApply, array $context = [])
  136. {
  137. return $this->iterateThroughStrategies('bodyParameters', $context, [$route, $controller, $method, $rulesToApply]);
  138. }
  139. protected function fetchResponses(ReflectionClass $controller, ReflectionFunctionAbstract $method, Route $route, array $rulesToApply, array $context = [])
  140. {
  141. $responses = $this->iterateThroughStrategies('responses', $context, [$route, $controller, $method, $rulesToApply]);
  142. if (count($responses)) {
  143. return array_filter($responses, function ($response) {
  144. return $response['content'] != null;
  145. });
  146. }
  147. return [];
  148. }
  149. protected function fetchResponseFields(ReflectionClass $controller, ReflectionFunctionAbstract $method, Route $route, array $rulesToApply, array $context = [])
  150. {
  151. return $this->iterateThroughStrategies('responseFields', $context, [$route, $controller, $method, $rulesToApply]);
  152. }
  153. protected function fetchRequestHeaders(ReflectionClass $controller, ReflectionFunctionAbstract $method, Route $route, array $rulesToApply, array $context = [])
  154. {
  155. $headers = $this->iterateThroughStrategies('headers', $context, [$route, $controller, $method, $rulesToApply]);
  156. return array_filter($headers);
  157. }
  158. protected function iterateThroughStrategies(string $stage, array $extractedData, array $arguments)
  159. {
  160. $defaultStrategies = [
  161. 'metadata' => [
  162. \Knuckles\Scribe\Extracting\Strategies\Metadata\GetFromDocBlocks::class,
  163. ],
  164. 'urlParameters' => [
  165. \Knuckles\Scribe\Extracting\Strategies\UrlParameters\GetFromLaravelAPI::class,
  166. \Knuckles\Scribe\Extracting\Strategies\UrlParameters\GetFromLumenAPI::class,
  167. \Knuckles\Scribe\Extracting\Strategies\UrlParameters\GetFromUrlParamTag::class,
  168. ],
  169. 'queryParameters' => [
  170. \Knuckles\Scribe\Extracting\Strategies\QueryParameters\GetFromQueryParamTag::class,
  171. ],
  172. 'headers' => [
  173. \Knuckles\Scribe\Extracting\Strategies\Headers\GetFromRouteRules::class,
  174. \Knuckles\Scribe\Extracting\Strategies\Headers\GetFromHeaderTag::class,
  175. ],
  176. 'bodyParameters' => [
  177. \Knuckles\Scribe\Extracting\Strategies\BodyParameters\GetFromFormRequest::class,
  178. \Knuckles\Scribe\Extracting\Strategies\BodyParameters\GetFromBodyParamTag::class,
  179. ],
  180. 'responses' => [
  181. \Knuckles\Scribe\Extracting\Strategies\Responses\UseTransformerTags::class,
  182. \Knuckles\Scribe\Extracting\Strategies\Responses\UseResponseTag::class,
  183. \Knuckles\Scribe\Extracting\Strategies\Responses\UseResponseFileTag::class,
  184. \Knuckles\Scribe\Extracting\Strategies\Responses\UseApiResourceTags::class,
  185. \Knuckles\Scribe\Extracting\Strategies\Responses\ResponseCalls::class,
  186. ],
  187. 'responseFields' => [
  188. \Knuckles\Scribe\Extracting\Strategies\ResponseFields\GetFromResponseFieldTag::class,
  189. ],
  190. ];
  191. // Use the default strategies for the stage, unless they were explicitly set
  192. $strategies = $this->config->get("strategies.$stage", $defaultStrategies[$stage]);
  193. $extractedData[$stage] = $extractedData[$stage] ?? [];
  194. foreach ($strategies as $strategyClass) {
  195. /** @var Strategy $strategy */
  196. $strategy = new $strategyClass($this->config);
  197. $strategyArgs = $arguments;
  198. $strategyArgs[] = $extractedData;
  199. $results = $strategy(...$strategyArgs);
  200. if (!is_null($results)) {
  201. foreach ($results as $index => $item) {
  202. if ($stage == 'responses') {
  203. // Responses from different strategies are all added, not overwritten
  204. $extractedData[$stage][] = $item;
  205. continue;
  206. }
  207. // We're using a for loop rather than array_merge or +=
  208. // so it does not renumber numeric keys and also allows values to be overwritten
  209. // Don't allow overwriting if an empty value is trying to replace a set one
  210. if (!in_array($extractedData[$stage], [null, ''], true) && in_array($item, [null, ''], true)) {
  211. continue;
  212. } else {
  213. $extractedData[$stage][$index] = $item;
  214. }
  215. }
  216. }
  217. }
  218. return $extractedData[$stage];
  219. }
  220. /**
  221. * This method prepares and simplifies request parameters for use in example requests and response calls.
  222. * It takes in an array with rich details about a parameter eg
  223. * ['age' => [
  224. * 'description' => 'The age',
  225. * 'value' => 12,
  226. * 'required' => false,
  227. * ]]
  228. * And transforms them into key-example pairs : ['age' => 12]
  229. * It also filters out parameters which have null values and have 'required' as false.
  230. * It converts all file params that have string examples to actual files (instances of UploadedFile).
  231. *
  232. * @param array $parameters
  233. *
  234. * @return array
  235. */
  236. public static function cleanParams(array $parameters): array
  237. {
  238. $cleanParameters = [];
  239. foreach ($parameters as $paramName => $details) {
  240. // Remove params which have no examples and are optional.
  241. if (is_null($details['value']) && $details['required'] === false) {
  242. continue;
  243. }
  244. if (($details['type'] ?? '') === 'file' && is_string($details['value'])) {
  245. $details['value'] = self::convertStringValueToUploadedFileInstance($details['value']);
  246. }
  247. if (Str::contains($paramName, '.')) { // Object field (or array of objects)
  248. self::setObject($cleanParameters, $paramName, $details['value'], $parameters, ($details['required'] ?? false));
  249. } else {
  250. $cleanParameters[$paramName] = $details['value'];
  251. }
  252. }
  253. return $cleanParameters;
  254. }
  255. public static function setObject(array &$results, string $path, $value, array $source, bool $isRequired)
  256. {
  257. $parts = explode('.', $path);
  258. array_pop($parts); // Get rid of the field name
  259. $baseName = join('.', $parts);
  260. // For array fields, the type should be indicated in the source object by now;
  261. // eg test.items[] would actually be described as name: test.items, type: object[]
  262. // So we get rid of that ending []
  263. // For other fields (eg test.items[].name), it remains as-is
  264. $baseNameInOriginalParams = $baseName;
  265. while (Str::endsWith($baseNameInOriginalParams, '[]')) {
  266. $baseNameInOriginalParams = substr($baseNameInOriginalParams, 0, -2);
  267. }
  268. if (Arr::has($source, $baseNameInOriginalParams)) {
  269. $parentData = Arr::get($source, $baseNameInOriginalParams);
  270. // Path we use for data_set
  271. $dotPath = str_replace('[]', '.0', $path);
  272. if ($parentData['type'] === 'object') {
  273. if (!Arr::has($results, $dotPath)) {
  274. Arr::set($results, $dotPath, $value);
  275. }
  276. } else if ($parentData['type'] === 'object[]') {
  277. if (!Arr::has($results, $dotPath)) {
  278. Arr::set($results, $dotPath, $value);
  279. }
  280. // If there's a second item in the array, set for that too.
  281. if ($value !== null && Arr::has($results, Str::replaceLast('[]', '.1', $baseName))) {
  282. // If value is optional, flip a coin on whether to set or not
  283. if ($isRequired || array_rand([true, false], 1)) {
  284. Arr::set($results, Str::replaceLast('.0', '.1', $dotPath), $value);
  285. }
  286. }
  287. }
  288. }
  289. }
  290. public function addAuthField(array $parsedRoute): array
  291. {
  292. $parsedRoute['auth'] = null;
  293. $isApiAuthed = $this->config->get('auth.enabled', false);
  294. if (!$isApiAuthed || !$parsedRoute['metadata']['authenticated']) {
  295. return $parsedRoute;
  296. }
  297. $strategy = $this->config->get('auth.in');
  298. $parameterName = $this->config->get('auth.name');
  299. $faker = Factory::create();
  300. if ($this->config->get('faker_seed')) {
  301. $faker->seed($this->config->get('faker_seed'));
  302. }
  303. $token = $faker->shuffle('abcdefghkvaZVDPE1864563');
  304. $valueToUse = $this->config->get('auth.use_value');
  305. $valueToDisplay = $this->config->get('auth.placeholder');
  306. switch ($strategy) {
  307. case 'query':
  308. case 'query_or_body':
  309. $parsedRoute['auth'] = "cleanQueryParameters.$parameterName." . ($valueToUse ?: $token);
  310. $parsedRoute['queryParameters'][$parameterName] = [
  311. 'name' => $parameterName,
  312. 'type' => 'string',
  313. 'value' => $valueToDisplay ?: $token,
  314. 'description' => 'Authentication key.',
  315. 'required' => true,
  316. ];
  317. break;
  318. case 'body':
  319. $parsedRoute['auth'] = "cleanBodyParameters.$parameterName." . ($valueToUse ?: $token);
  320. $parsedRoute['bodyParameters'][$parameterName] = [
  321. 'name' => $parameterName,
  322. 'type' => 'string',
  323. 'value' => $valueToDisplay ?: $token,
  324. 'description' => 'Authentication key.',
  325. 'required' => true,
  326. ];
  327. break;
  328. case 'bearer':
  329. $parsedRoute['auth'] = "headers.Authorization.Bearer " . ($valueToUse ?: $token);
  330. $parsedRoute['headers']['Authorization'] = "Bearer " . ($valueToDisplay ?: $token);
  331. break;
  332. case 'basic':
  333. $parsedRoute['auth'] = "headers.Authorization.Basic " . ($valueToUse ?: base64_encode($token));
  334. $parsedRoute['headers']['Authorization'] = "Basic " . ($valueToDisplay ?: base64_encode($token));
  335. break;
  336. case 'header':
  337. $parsedRoute['auth'] = "headers.$parameterName." . ($valueToUse ?: $token);
  338. $parsedRoute['headers'][$parameterName] = $valueToDisplay ?: $token;
  339. break;
  340. }
  341. return $parsedRoute;
  342. }
  343. protected static function convertStringValueToUploadedFileInstance(string $filePath): UploadedFile
  344. {
  345. $fileName = basename($filePath);
  346. return new File($fileName, fopen($filePath, 'r'));
  347. }
  348. /**
  349. * Transform body parameters such that object fields have a `fields` property containing a list of all subfields
  350. * Subfields will be removed from the main parameter map
  351. * For instance, if $parameters is ['dad' => [], 'dad.cars' => [], 'dad.age' => []],
  352. * normalise this into ['dad' => [..., '__fields' => ['dad.cars' => [], 'dad.age' => []]]
  353. */
  354. public static function nestArrayAndObjectFields(array $parameters)
  355. {
  356. $finalParameters = [];
  357. foreach ($parameters as $name => $parameter) {
  358. if (Str::contains($name, '.')) { // Likely an object field
  359. // Get the various pieces of the name
  360. $parts = explode('.', $name);
  361. $fieldName = array_pop($parts);
  362. $baseName = join('.__fields.', $parts);
  363. // For subfields, the type is indicated in the source object
  364. // eg test.items[].more and test.items.more would both have parent field with name `items` and containing __fields => more
  365. // The difference would be in the parent field's `type` property (object[] vs object)
  366. // So we can get rid of all [] to get the parent name
  367. $dotPathToParent = str_replace('[]', '', $baseName);
  368. $dotPath = $dotPathToParent . '.__fields.' . $fieldName;
  369. Arr::set($finalParameters, $dotPath, $parameter);
  370. } else { // A regular field, not a subfield of anything
  371. $parameter['__fields'] = [];
  372. $finalParameters[$name] = $parameter;
  373. }
  374. }
  375. return $finalParameters;
  376. }
  377. }