Extractor.php 24 KB

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