Extractor.php 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  1. <?php
  2. namespace Knuckles\Scribe\Extracting;
  3. use Knuckles\Camel\Extraction\ExtractedEndpointData;
  4. use Knuckles\Camel\Extraction\Metadata;
  5. use Knuckles\Camel\Extraction\Parameter;
  6. use Faker\Factory;
  7. use Illuminate\Http\Testing\File;
  8. use Illuminate\Http\UploadedFile;
  9. use Illuminate\Routing\Route;
  10. use Illuminate\Support\Arr;
  11. use Illuminate\Support\Str;
  12. use Knuckles\Camel\Extraction\ResponseField;
  13. use Knuckles\Camel\Output\OutputEndpointData;
  14. use Knuckles\Scribe\Extracting\Strategies\Strategy;
  15. use Knuckles\Scribe\Tools\DocumentationConfig;
  16. class Extractor
  17. {
  18. private DocumentationConfig $config;
  19. use ParamHelpers;
  20. private static ?Route $routeBeingProcessed = null;
  21. private static array $defaultStrategies = [
  22. 'metadata' => [
  23. \Knuckles\Scribe\Extracting\Strategies\Metadata\GetFromDocBlocks::class,
  24. ],
  25. 'urlParameters' => [
  26. \Knuckles\Scribe\Extracting\Strategies\UrlParameters\GetFromLaravelAPI::class,
  27. \Knuckles\Scribe\Extracting\Strategies\UrlParameters\GetFromLumenAPI::class,
  28. \Knuckles\Scribe\Extracting\Strategies\UrlParameters\GetFromUrlParamTag::class,
  29. ],
  30. 'queryParameters' => [
  31. \Knuckles\Scribe\Extracting\Strategies\QueryParameters\GetFromQueryParamTag::class,
  32. ],
  33. 'headers' => [
  34. \Knuckles\Scribe\Extracting\Strategies\Headers\GetFromRouteRules::class,
  35. \Knuckles\Scribe\Extracting\Strategies\Headers\GetFromHeaderTag::class,
  36. ],
  37. 'bodyParameters' => [
  38. \Knuckles\Scribe\Extracting\Strategies\BodyParameters\GetFromFormRequest::class,
  39. \Knuckles\Scribe\Extracting\Strategies\BodyParameters\GetFromInlineValidator::class,
  40. \Knuckles\Scribe\Extracting\Strategies\BodyParameters\GetFromBodyParamTag::class,
  41. ],
  42. 'responses' => [
  43. \Knuckles\Scribe\Extracting\Strategies\Responses\UseTransformerTags::class,
  44. \Knuckles\Scribe\Extracting\Strategies\Responses\UseResponseTag::class,
  45. \Knuckles\Scribe\Extracting\Strategies\Responses\UseResponseFileTag::class,
  46. \Knuckles\Scribe\Extracting\Strategies\Responses\UseApiResourceTags::class,
  47. \Knuckles\Scribe\Extracting\Strategies\Responses\ResponseCalls::class,
  48. ],
  49. 'responseFields' => [
  50. \Knuckles\Scribe\Extracting\Strategies\ResponseFields\GetFromResponseFieldTag::class,
  51. ],
  52. ];
  53. public function __construct(DocumentationConfig $config = null)
  54. {
  55. // If no config is injected, pull from global
  56. $this->config = $config ?: new DocumentationConfig(config('scribe'));
  57. }
  58. /**
  59. * External interface that allows users to know what route is currently being processed
  60. */
  61. public static function getRouteBeingProcessed(): ?Route
  62. {
  63. return self::$routeBeingProcessed;
  64. }
  65. /**
  66. * @param Route $route
  67. * @param array $routeRules Rules to apply when generating documentation for this route
  68. *
  69. * @return ExtractedEndpointData
  70. * @throws \ReflectionException
  71. *
  72. */
  73. public function processRoute(Route $route, array $routeRules = []): ExtractedEndpointData
  74. {
  75. self::$routeBeingProcessed = $route;
  76. $endpointData = ExtractedEndpointData::fromRoute($route);
  77. $this->fetchMetadata($endpointData, $routeRules);
  78. $this->fetchUrlParameters($endpointData, $routeRules);
  79. $endpointData->cleanUrlParameters = self::cleanParams($endpointData->urlParameters);
  80. $this->addAuthField($endpointData);
  81. $this->fetchQueryParameters($endpointData, $routeRules);
  82. $endpointData->cleanQueryParameters = self::cleanParams($endpointData->queryParameters);
  83. $this->fetchRequestHeaders($endpointData, $routeRules);
  84. $this->fetchBodyParameters($endpointData, $routeRules);
  85. $endpointData->cleanBodyParameters = self::cleanParams($endpointData->bodyParameters);
  86. if (count($endpointData->cleanBodyParameters) && !isset($endpointData->headers['Content-Type'])) {
  87. // Set content type if the user forgot to set it
  88. $endpointData->headers['Content-Type'] = 'application/json';
  89. }
  90. // We need to do all this so response calls can work correctly,
  91. // even though they're only needed for output
  92. // Note that this
  93. [$files, $regularParameters] = OutputEndpointData::getFileParameters($endpointData->cleanBodyParameters);
  94. if (count($files)) {
  95. $endpointData->headers['Content-Type'] = 'multipart/form-data';
  96. }
  97. $endpointData->fileParameters = $files;
  98. $endpointData->cleanBodyParameters = $regularParameters;
  99. $this->fetchResponses($endpointData, $routeRules);
  100. $this->fetchResponseFields($endpointData, $routeRules);
  101. self::$routeBeingProcessed = null;
  102. return $endpointData;
  103. }
  104. protected function fetchMetadata(ExtractedEndpointData $endpointData, array $rulesToApply): void
  105. {
  106. $endpointData->metadata = new Metadata([
  107. 'groupName' => $this->config->get('default_group', ''),
  108. ]);
  109. $this->iterateThroughStrategies('metadata', $endpointData, $rulesToApply, function ($results) use ($endpointData) {
  110. foreach ($results as $key => $item) {
  111. $endpointData->metadata->$key = $item;
  112. }
  113. });
  114. }
  115. protected function fetchUrlParameters(ExtractedEndpointData $endpointData, array $rulesToApply): void
  116. {
  117. $this->iterateThroughStrategies('urlParameters', $endpointData, $rulesToApply, function ($results) use ($endpointData) {
  118. foreach ($results as $key => $item) {
  119. if (empty($item['name'])) {
  120. $item['name'] = $key;
  121. }
  122. $endpointData->urlParameters[$key] = Parameter::create($item);
  123. }
  124. });
  125. }
  126. protected function fetchQueryParameters(ExtractedEndpointData $endpointData, array $rulesToApply): void
  127. {
  128. $this->iterateThroughStrategies('queryParameters', $endpointData, $rulesToApply, function ($results) use ($endpointData) {
  129. foreach ($results as $key => $item) {
  130. if (empty($item['name'])) {
  131. $item['name'] = $key;
  132. }
  133. $endpointData->queryParameters[$key] = Parameter::create($item);
  134. }
  135. });
  136. }
  137. protected function fetchBodyParameters(ExtractedEndpointData $endpointData, array $rulesToApply): void
  138. {
  139. if (in_array('GET', $endpointData->httpMethods)) {
  140. return;
  141. }
  142. $this->iterateThroughStrategies('bodyParameters', $endpointData, $rulesToApply, function ($results) use ($endpointData) {
  143. foreach ($results as $key => $item) {
  144. if (empty($item['name'])) {
  145. $item['name'] = $key;
  146. }
  147. $endpointData->bodyParameters[$key] = Parameter::create($item);
  148. }
  149. });
  150. }
  151. protected function fetchResponses(ExtractedEndpointData $endpointData, array $rulesToApply): void
  152. {
  153. $this->iterateThroughStrategies('responses', $endpointData, $rulesToApply, function ($results) use ($endpointData) {
  154. // Responses from different strategies are all added, not overwritten
  155. $endpointData->responses->concat($results);
  156. });
  157. // Ensure 200 responses come first
  158. $endpointData->responses->sortBy('status');
  159. }
  160. protected function fetchResponseFields(ExtractedEndpointData $endpointData, array $rulesToApply): void
  161. {
  162. $this->iterateThroughStrategies('responseFields', $endpointData, $rulesToApply, function ($results) use ($endpointData) {
  163. foreach ($results as $key => $item) {
  164. $endpointData->responseFields[$key] = ResponseField::create($item);
  165. }
  166. });
  167. }
  168. protected function fetchRequestHeaders(ExtractedEndpointData $endpointData, array $rulesToApply): void
  169. {
  170. $this->iterateThroughStrategies('headers', $endpointData, $rulesToApply, function ($results) use ($endpointData) {
  171. foreach ($results as $key => $item) {
  172. if ($item) {
  173. $endpointData->headers[$key] = $item;
  174. }
  175. }
  176. });
  177. }
  178. /**
  179. * Iterate through all defined strategies for this stage.
  180. * A strategy may return an array of attributes
  181. * to be added to that stage data, or it may modify the stage data directly.
  182. *
  183. * @param string $stage
  184. * @param ExtractedEndpointData $endpointData
  185. * @param array $rulesToApply
  186. * @param callable $handler Function to run after each strategy returns its results (an array).
  187. *
  188. */
  189. protected function iterateThroughStrategies(string $stage, ExtractedEndpointData $endpointData, array $rulesToApply, callable $handler): void
  190. {
  191. $strategies = $this->config->get("strategies.$stage", self::$defaultStrategies[$stage]);
  192. foreach ($strategies as $strategyClass) {
  193. /** @var Strategy $strategy */
  194. $strategy = new $strategyClass($this->config);
  195. $results = $strategy($endpointData, $rulesToApply);
  196. if (is_array($results)) {
  197. $handler($results);
  198. }
  199. }
  200. }
  201. /**
  202. * This method prepares and simplifies request parameters for use in example requests and response calls.
  203. * It takes in an array with rich details about a parameter eg
  204. * ['age' => new Parameter([
  205. * 'description' => 'The age',
  206. * 'example' => 12,
  207. * 'required' => false,
  208. * ])]
  209. * And transforms them into key-example pairs : ['age' => 12]
  210. * It also filters out parameters which have null values and have 'required' as false.
  211. * It converts all file params that have string examples to actual files (instances of UploadedFile).
  212. *
  213. * @param array<string,Parameter> $parameters
  214. *
  215. * @return array
  216. */
  217. public static function cleanParams(array $parameters): array
  218. {
  219. $cleanParameters = [];
  220. /**
  221. * @var string $paramName
  222. * @var Parameter $details
  223. */
  224. foreach ($parameters as $paramName => $details) {
  225. // Remove params which have no examples and are optional.
  226. if (is_null($details->example) && $details->required === false) {
  227. continue;
  228. }
  229. if ($details->type === 'file') {
  230. if (is_string($details->example)) {
  231. $details->example = self::convertStringValueToUploadedFileInstance($details->example);
  232. } else if (is_null($details->example)) {
  233. $details->example = (new self)->generateDummyValue($details->type);
  234. }
  235. }
  236. if (Str::startsWith($paramName, '[].')) { // Entire body is an array
  237. if (empty($parameters["[]"])) { // Make sure there's a parent
  238. $cleanParameters["[]"] = [[], []];
  239. $parameters["[]"] = new Parameter([
  240. "name" => "[]",
  241. "type" => "object[]",
  242. "description" => "",
  243. "required" => true,
  244. "example" => [$paramName => $details->example],
  245. ]);
  246. }
  247. }
  248. if (Str::contains($paramName, '.')) { // Object field (or array of objects)
  249. self::setObject($cleanParameters, $paramName, $details->example, $parameters, $details->required);
  250. } else {
  251. $cleanParameters[$paramName] = $details->example;
  252. }
  253. }
  254. // Finally, if the body is an array, flatten it.
  255. if (isset($cleanParameters['[]'])) {
  256. $cleanParameters = $cleanParameters['[]'];
  257. }
  258. return $cleanParameters;
  259. }
  260. public static function setObject(array &$results, string $path, $value, array $source, bool $isRequired)
  261. {
  262. $parts = explode('.', $path);
  263. array_pop($parts); // Get rid of the field name
  264. $baseName = join('.', $parts);
  265. // For array fields, the type should be indicated in the source object by now;
  266. // eg test.items[] would actually be described as name: test.items, type: object[]
  267. // So we get rid of that ending []
  268. // For other fields (eg test.items[].name), it remains as-is
  269. $baseNameInOriginalParams = $baseName;
  270. while (Str::endsWith($baseNameInOriginalParams, '[]')) {
  271. $baseNameInOriginalParams = substr($baseNameInOriginalParams, 0, -2);
  272. }
  273. // When the body is an array, param names will be "[].paramname",
  274. // so $baseNameInOriginalParams here will be empty
  275. if (Str::startsWith($path, '[].')) {
  276. $baseNameInOriginalParams = '[]';
  277. }
  278. if (Arr::has($source, $baseNameInOriginalParams)) {
  279. /** @var Parameter $parentData */
  280. $parentData = Arr::get($source, $baseNameInOriginalParams);
  281. // Path we use for data_set
  282. $dotPath = str_replace('[]', '.0', $path);
  283. if ($parentData->type === 'object') {
  284. if (!Arr::has($results, $dotPath)) {
  285. Arr::set($results, $dotPath, $value);
  286. }
  287. } else if ($parentData->type === 'object[]') {
  288. // When the body is an array, param names will be "[].paramname", so dot paths won't work correctly with "[]"
  289. if (Str::startsWith($path, '[].')) {
  290. $valueDotPath = substr($dotPath, 3); // Remove initial '.0.'
  291. if (isset($results['[]'][0]) && !Arr::has($results['[]'][0], $valueDotPath)) {
  292. Arr::set($results['[]'][0], $valueDotPath, $value);
  293. }
  294. // If there's a second item in the array, set for that too.
  295. if ($value !== null && isset($results['[]'][1])) {
  296. // If value is optional, flip a coin on whether to set or not
  297. if ($isRequired || array_rand([true, false], 1)) {
  298. Arr::set($results['[]'][1], $valueDotPath, $value);
  299. }
  300. }
  301. } else {
  302. if (!Arr::has($results, $dotPath)) {
  303. Arr::set($results, $dotPath, $value);
  304. }
  305. // If there's a second item in the array, set for that too.
  306. if ($value !== null && Arr::has($results, Str::replaceLast('[]', '.1', $baseName))) {
  307. // If value is optional, flip a coin on whether to set or not
  308. if ($isRequired || array_rand([true, false], 1)) {
  309. Arr::set($results, Str::replaceLast('.0', '.1', $dotPath), $value);
  310. }
  311. }
  312. }
  313. }
  314. }
  315. }
  316. public function addAuthField(ExtractedEndpointData $endpointData): void
  317. {
  318. $isApiAuthed = $this->config->get('auth.enabled', false);
  319. if (!$isApiAuthed || !$endpointData->metadata->authenticated) {
  320. return;
  321. }
  322. $strategy = $this->config->get('auth.in');
  323. $parameterName = $this->config->get('auth.name');
  324. $faker = Factory::create();
  325. if ($this->config->get('faker_seed')) {
  326. $faker->seed($this->config->get('faker_seed'));
  327. }
  328. $token = $faker->shuffle('abcdefghkvaZVDPE1864563');
  329. $valueToUse = $this->config->get('auth.use_value');
  330. $valueToDisplay = $this->config->get('auth.placeholder');
  331. switch ($strategy) {
  332. case 'query':
  333. case 'query_or_body':
  334. $endpointData->auth = ["queryParameters", $parameterName, $valueToUse ?: $token];
  335. $endpointData->queryParameters[$parameterName] = new Parameter([
  336. 'name' => $parameterName,
  337. 'type' => 'string',
  338. 'example' => $valueToDisplay ?: $token,
  339. 'description' => 'Authentication key.',
  340. 'required' => true,
  341. ]);
  342. return;
  343. case 'body':
  344. $endpointData->auth = ["bodyParameters", $parameterName, $valueToUse ?: $token];
  345. $endpointData->bodyParameters[$parameterName] = new Parameter([
  346. 'name' => $parameterName,
  347. 'type' => 'string',
  348. 'example' => $valueToDisplay ?: $token,
  349. 'description' => 'Authentication key.',
  350. 'required' => true,
  351. ]);
  352. return;
  353. case 'bearer':
  354. $endpointData->auth = ["headers", "Authorization", "Bearer " . ($valueToUse ?: $token)];
  355. $endpointData->headers['Authorization'] = "Bearer " . ($valueToDisplay ?: $token);
  356. return;
  357. case 'basic':
  358. $endpointData->auth = ["headers", "Authorization", "Basic " . ($valueToUse ?: base64_encode($token))];
  359. $endpointData->headers['Authorization'] = "Basic " . ($valueToDisplay ?: base64_encode($token));
  360. return;
  361. case 'header':
  362. $endpointData->auth = ["headers", $parameterName, $valueToUse ?: $token];
  363. $endpointData->headers[$parameterName] = $valueToDisplay ?: $token;
  364. return;
  365. }
  366. }
  367. protected static function convertStringValueToUploadedFileInstance(string $filePath): UploadedFile
  368. {
  369. $fileName = basename($filePath);
  370. return new File($fileName, fopen($filePath, 'r'));
  371. }
  372. /**
  373. * Transform body parameters such that object fields have a `fields` property containing a list of all subfields
  374. * Subfields will be removed from the main parameter map
  375. * For instance, if $parameters is ['dad' => [], 'dad.cars' => [], 'dad.age' => []],
  376. * normalise this into ['dad' => [..., '__fields' => ['dad.cars' => [], 'dad.age' => []]]
  377. *
  378. * @param array $parameters
  379. *
  380. * @return array
  381. */
  382. public static function nestArrayAndObjectFields(array $parameters, array $cleanParameters = []): array
  383. {
  384. // First, we'll make sure all object fields have parent fields properly set
  385. $normalisedParameters = [];
  386. foreach ($parameters as $name => $parameter) {
  387. if (Str::contains($name, '.')) {
  388. // Get the various pieces of the name
  389. $parts = explode('.', $name);
  390. $fieldName = array_pop($parts);
  391. // If the user didn't add a parent field, we'll helpfully add it for them
  392. $parentName = rtrim(join('.', $parts), '[]');
  393. // When the body is an array, param names will be "[].paramname",
  394. // so $parentName is empty. Let's fix that.
  395. if (empty($parentName)) {
  396. $parentName = '[]';
  397. }
  398. if (empty($normalisedParameters[$parentName])) {
  399. $normalisedParameters[$parentName] = new Parameter([
  400. "name" => $parentName,
  401. "type" => $parentName === '[]' ? "object[]" : "object",
  402. "description" => "",
  403. "required" => true,
  404. "example" => [$fieldName => $parameter->example],
  405. ]);
  406. }
  407. }
  408. $normalisedParameters[$name] = $parameter;
  409. }
  410. $finalParameters = [];
  411. foreach ($normalisedParameters as $name => $parameter) {
  412. $parameter = $parameter->toArray();
  413. if (Str::contains($name, '.')) { // An object field
  414. // Get the various pieces of the name
  415. $parts = explode('.', $name);
  416. $fieldName = array_pop($parts);
  417. $baseName = join('.__fields.', $parts);
  418. // For subfields, the type is indicated in the source object
  419. // eg test.items[].more and test.items.more would both have parent field with name `items` and containing __fields => more
  420. // The difference would be in the parent field's `type` property (object[] vs object)
  421. // So we can get rid of all [] to get the parent name
  422. $dotPathToParent = str_replace('[]', '', $baseName);
  423. // When the body is an array, param names will be "[].paramname",
  424. // so $parts is ['[]']
  425. if ($parts[0] == '[]') {
  426. $dotPathToParent = '[]'.$dotPathToParent;
  427. }
  428. $dotPath = $dotPathToParent . '.__fields.' . $fieldName;
  429. Arr::set($finalParameters, $dotPath, $parameter);
  430. } else { // A regular field, not a subfield of anything
  431. // Note: we're assuming any subfields of this field are listed *after* it,
  432. // and will set __fields correctly when we iterate over them
  433. // Hence why we create a new "normalisedParameters" array above and push the parent to that first
  434. $parameter['__fields'] = [];
  435. $finalParameters[$name] = $parameter;
  436. }
  437. }
  438. // Finally, if the body is an array, remove any other items.
  439. if (isset($finalParameters['[]'])) {
  440. $finalParameters = ["[]" => $finalParameters['[]']];
  441. // At this point, the examples are likely [[], []],
  442. // but have been correctly set in clean parameters, so let's update them
  443. if ($finalParameters["[]"]["example"][0] == [] && !empty($cleanParameters)) {
  444. $finalParameters["[]"]["example"] = $cleanParameters;
  445. }
  446. }
  447. return $finalParameters;
  448. }
  449. }