Extractor.php 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508
  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\StaticData;
  16. use Knuckles\Scribe\Tools\ConsoleOutputUtils as c;
  17. use Knuckles\Scribe\Tools\DocumentationConfig;
  18. use Knuckles\Scribe\Tools\RoutePatternMatcher;
  19. class Extractor
  20. {
  21. private DocumentationConfig $config;
  22. use ParamHelpers;
  23. private static ?Route $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. * @param array $routeRules Rules to apply when generating documentation for this route. Deprecated. Use strategy config instead.
  39. *
  40. * @return ExtractedEndpointData
  41. *
  42. */
  43. public function processRoute(Route $route, array $routeRules = []): ExtractedEndpointData
  44. {
  45. self::$routeBeingProcessed = $route;
  46. $endpointData = ExtractedEndpointData::fromRoute($route);
  47. $inheritedDocsOverrides = [];
  48. if ($endpointData->controller->hasMethod('inheritedDocsOverrides')) {
  49. $inheritedDocsOverrides = call_user_func([$endpointData->controller->getName(), 'inheritedDocsOverrides']);
  50. $inheritedDocsOverrides = $inheritedDocsOverrides[$endpointData->method->getName()] ?? [];
  51. }
  52. $this->fetchMetadata($endpointData, $routeRules);
  53. $this->mergeInheritedMethodsData('metadata', $endpointData, $inheritedDocsOverrides);
  54. $this->fetchUrlParameters($endpointData, $routeRules);
  55. $this->mergeInheritedMethodsData('urlParameters', $endpointData, $inheritedDocsOverrides);
  56. $endpointData->cleanUrlParameters = self::cleanParams($endpointData->urlParameters);
  57. $this->addAuthField($endpointData);
  58. $this->fetchQueryParameters($endpointData, $routeRules);
  59. $this->mergeInheritedMethodsData('queryParameters', $endpointData, $inheritedDocsOverrides);
  60. $endpointData->cleanQueryParameters = self::cleanParams($endpointData->queryParameters);
  61. $this->fetchRequestHeaders($endpointData, $routeRules);
  62. $this->mergeInheritedMethodsData('headers', $endpointData, $inheritedDocsOverrides);
  63. $this->fetchBodyParameters($endpointData, $routeRules);
  64. $endpointData->cleanBodyParameters = self::cleanParams($endpointData->bodyParameters);
  65. $this->mergeInheritedMethodsData('bodyParameters', $endpointData, $inheritedDocsOverrides);
  66. if (count($endpointData->cleanBodyParameters) && !isset($endpointData->headers['Content-Type'])) {
  67. // Set content type if the user forgot to set it
  68. $endpointData->headers['Content-Type'] = 'application/json';
  69. }
  70. // We need to do all this so response calls can work correctly,
  71. // even though they're only needed for output
  72. // Note that this
  73. [$files, $regularParameters] = OutputEndpointData::splitIntoFileAndRegularParameters($endpointData->cleanBodyParameters);
  74. if (count($files)) {
  75. $endpointData->headers['Content-Type'] = 'multipart/form-data';
  76. }
  77. $endpointData->fileParameters = $files;
  78. $endpointData->cleanBodyParameters = $regularParameters;
  79. $this->fetchResponses($endpointData, $routeRules);
  80. $this->mergeInheritedMethodsData('responses', $endpointData, $inheritedDocsOverrides);
  81. $this->fetchResponseFields($endpointData, $routeRules);
  82. $this->mergeInheritedMethodsData('responseFields', $endpointData, $inheritedDocsOverrides);
  83. self::$routeBeingProcessed = null;
  84. return $endpointData;
  85. }
  86. protected function fetchMetadata(ExtractedEndpointData $endpointData, array $rulesToApply): void
  87. {
  88. $endpointData->metadata = new Metadata([
  89. 'groupName' => $this->config->get('groups.default', ''),
  90. "authenticated" => $this->config->get("auth.default", false),
  91. ]);
  92. $this->iterateThroughStrategies('metadata', $endpointData, $rulesToApply, function ($results) use ($endpointData) {
  93. foreach ($results as $key => $item) {
  94. $hadPreviousValue = !is_null($endpointData->metadata->$key);
  95. $noNewValueSet = is_null($item) || $item === "";
  96. if ($hadPreviousValue && $noNewValueSet) {
  97. continue;
  98. }
  99. $endpointData->metadata->$key = $item;
  100. }
  101. });
  102. }
  103. protected function fetchUrlParameters(ExtractedEndpointData $endpointData, array $rulesToApply): void
  104. {
  105. $this->iterateThroughStrategies('urlParameters', $endpointData, $rulesToApply, function ($results) use ($endpointData) {
  106. foreach ($results as $key => $item) {
  107. if (empty($item['name'])) {
  108. $item['name'] = $key;
  109. }
  110. $endpointData->urlParameters[$key] = Parameter::create($item, $endpointData->urlParameters[$key] ?? []);
  111. }
  112. });
  113. }
  114. protected function fetchQueryParameters(ExtractedEndpointData $endpointData, array $rulesToApply): void
  115. {
  116. $this->iterateThroughStrategies('queryParameters', $endpointData, $rulesToApply, function ($results) use ($endpointData) {
  117. foreach ($results as $key => $item) {
  118. if (empty($item['name'])) {
  119. $item['name'] = $key;
  120. }
  121. $endpointData->queryParameters[$key] = Parameter::create($item, $endpointData->queryParameters[$key] ?? []);
  122. }
  123. });
  124. }
  125. protected function fetchBodyParameters(ExtractedEndpointData $endpointData, array $rulesToApply): void
  126. {
  127. $this->iterateThroughStrategies('bodyParameters', $endpointData, $rulesToApply, function ($results) use ($endpointData) {
  128. foreach ($results as $key => $item) {
  129. if (empty($item['name'])) {
  130. $item['name'] = $key;
  131. }
  132. $endpointData->bodyParameters[$key] = Parameter::create($item, $endpointData->bodyParameters[$key] ?? []);
  133. }
  134. });
  135. }
  136. protected function fetchResponses(ExtractedEndpointData $endpointData, array $rulesToApply): void
  137. {
  138. $this->iterateThroughStrategies('responses', $endpointData, $rulesToApply, function ($results) use ($endpointData) {
  139. // Responses from different strategies are all added, not overwritten
  140. $endpointData->responses->concat($results);
  141. });
  142. // Ensure 200 responses come first
  143. $endpointData->responses = new ResponseCollection($endpointData->responses->sortBy('status')->values());
  144. }
  145. protected function fetchResponseFields(ExtractedEndpointData $endpointData, array $rulesToApply): void
  146. {
  147. $this->iterateThroughStrategies('responseFields', $endpointData, $rulesToApply, function ($results) use ($endpointData) {
  148. foreach ($results as $key => $item) {
  149. $endpointData->responseFields[$key] = Parameter::create($item, $endpointData->responseFields[$key] ?? []);
  150. }
  151. });
  152. }
  153. protected function fetchRequestHeaders(ExtractedEndpointData $endpointData, array $rulesToApply): void
  154. {
  155. $this->iterateThroughStrategies('headers', $endpointData, $rulesToApply, function ($results) use ($endpointData) {
  156. foreach ($results as $key => $item) {
  157. if ($item) {
  158. $endpointData->headers[$key] = $item;
  159. }
  160. }
  161. });
  162. }
  163. /**
  164. * Iterate through all defined strategies for this stage.
  165. * A strategy may return an array of attributes
  166. * to be added to that stage data, or it may modify the stage data directly.
  167. *
  168. * @param string $stage
  169. * @param ExtractedEndpointData $endpointData
  170. * @param array $rulesToApply Deprecated. Use strategy config instead.
  171. * @param callable $handler Function to run after each strategy returns its results (an array).
  172. *
  173. */
  174. protected function iterateThroughStrategies(
  175. string $stage, ExtractedEndpointData $endpointData, array $rulesToApply, callable $handler
  176. ): void
  177. {
  178. $strategies = $this->config->get("strategies.$stage", []);
  179. foreach ($strategies as $strategyClassOrTuple) {
  180. if (is_array($strategyClassOrTuple)) {
  181. [$strategyClass, &$settings] = $strategyClassOrTuple;
  182. if ($strategyClass == 'override') {
  183. c::warn("The 'override' strategy was renamed to 'static_data', and will stop working in the future. Please replace 'override' in your config file with 'static_data'.");
  184. $strategyClass = 'static_data';
  185. }
  186. if ($strategyClass == 'static_data') {
  187. $strategyClass = StaticData::class;
  188. // Static data can be short: ['static_data', ['key' => 'value']],
  189. // or extended ['static_data', ['data' => ['key' => 'value'], 'only' => ['GET *'], 'except' => []]],
  190. $settingsFormat = array_key_exists('data', $settings) ? 'extended' : 'short';
  191. $settings = match($settingsFormat) {
  192. 'extended' => $settings,
  193. 'short' => ['data' => $settings],
  194. };
  195. }
  196. } else {
  197. $strategyClass = $strategyClassOrTuple;
  198. $settings = [];
  199. }
  200. $routesToExclude = Arr::wrap($settings["except"] ?? []);
  201. $routesToInclude = Arr::wrap($settings["only"] ?? []);
  202. if ($this->shouldSkipRoute($endpointData->route, $routesToExclude, $routesToInclude)) {
  203. continue;
  204. }
  205. $strategy = new $strategyClass($this->config);
  206. $results = $strategy($endpointData, $settings);
  207. if (is_array($results)) {
  208. $handler($results);
  209. }
  210. }
  211. }
  212. public function shouldSkipRoute($route, array $routesToExclude, array $routesToInclude): bool
  213. {
  214. if (!empty($routesToExclude) && RoutePatternMatcher::matches($route, $routesToExclude)) {
  215. return true;
  216. } elseif (!empty($routesToInclude) && !RoutePatternMatcher::matches($route, $routesToInclude)) {
  217. return true;
  218. }
  219. return false;
  220. }
  221. /**
  222. * This method prepares and simplifies request parameters for use in example requests and response calls.
  223. * It takes in an array with rich details about a parameter eg
  224. * ['age' => new Parameter([
  225. * 'description' => 'The age',
  226. * 'example' => 12,
  227. * 'required' => false,
  228. * ])]
  229. * And transforms them into key-example pairs : ['age' => 12]
  230. * It also filters out parameters which have null values and have 'required' as false.
  231. * It converts all file params that have string examples to actual files (instances of UploadedFile).
  232. *
  233. * @param array<string,Parameter> $parameters
  234. *
  235. * @return array
  236. */
  237. public static function cleanParams(array $parameters): array
  238. {
  239. $cleanParameters = [];
  240. /**
  241. * @var string $paramName
  242. * @var Parameter $details
  243. */
  244. foreach ($parameters as $paramName => $details) {
  245. // Remove params which have no intentional examples and are optional.
  246. if (!$details->exampleWasSpecified) {
  247. if (is_null($details->example) && $details->required === false) {
  248. continue;
  249. }
  250. }
  251. if ($details->type === 'file') {
  252. if (is_string($details->example)) {
  253. $details->example = self::convertStringValueToUploadedFileInstance($details->example);
  254. } else if (is_null($details->example)) {
  255. $details->example = (new self)->generateDummyValue($details->type);
  256. }
  257. }
  258. if (Str::startsWith($paramName, '[].')) { // Entire body is an array
  259. if (empty($parameters["[]"])) { // Make sure there's a parent
  260. $cleanParameters["[]"] = [[], []];
  261. $parameters["[]"] = new Parameter([
  262. "name" => "[]",
  263. "type" => "object[]",
  264. "description" => "",
  265. "required" => true,
  266. "example" => [$paramName => $details->example],
  267. ]);
  268. }
  269. }
  270. if (Str::contains($paramName, '.')) { // Object field (or array of objects)
  271. self::setObject($cleanParameters, $paramName, $details->example, $parameters, $details->required);
  272. } else {
  273. $cleanParameters[$paramName] = $details->example;
  274. }
  275. }
  276. // Finally, if the body is an array, flatten it.
  277. if (isset($cleanParameters['[]'])) {
  278. $cleanParameters = $cleanParameters['[]'];
  279. }
  280. return $cleanParameters;
  281. }
  282. public static function setObject(array &$results, string $path, $value, array $source, bool $isRequired)
  283. {
  284. $parts = explode('.', $path);
  285. array_pop($parts); // Get rid of the field name
  286. $baseName = join('.', $parts);
  287. // For array fields, the type should be indicated in the source object by now;
  288. // eg test.items[] would actually be described as name: test.items, type: object[]
  289. // So we get rid of that ending []
  290. // For other fields (eg test.items[].name), it remains as-is
  291. $baseNameInOriginalParams = $baseName;
  292. while (Str::endsWith($baseNameInOriginalParams, '[]')) {
  293. $baseNameInOriginalParams = substr($baseNameInOriginalParams, 0, -2);
  294. }
  295. // When the body is an array, param names will be "[].paramname",
  296. // so $baseNameInOriginalParams here will be empty
  297. if (Str::startsWith($path, '[].')) {
  298. $baseNameInOriginalParams = '[]';
  299. }
  300. if (Arr::has($source, $baseNameInOriginalParams)) {
  301. /** @var Parameter $parentData */
  302. $parentData = Arr::get($source, $baseNameInOriginalParams);
  303. // Path we use for data_set
  304. $dotPath = str_replace('[]', '.0', $path);
  305. // Don't overwrite parent if there's already data there
  306. if ($parentData->type === 'object') {
  307. $parentPath = explode('.', $dotPath);
  308. $property = array_pop($parentPath);
  309. $parentPath = implode('.', $parentPath);
  310. $exampleFromParent = Arr::get($results, $dotPath) ?? $parentData->example[$property] ?? null;
  311. if (empty($exampleFromParent)) {
  312. Arr::set($results, $dotPath, $value);
  313. }
  314. } else if ($parentData->type === 'object[]') {
  315. // When the body is an array, param names will be "[].paramname", so dot paths won't work correctly with "[]"
  316. if (Str::startsWith($path, '[].')) {
  317. $valueDotPath = substr($dotPath, 3); // Remove initial '.0.'
  318. if (isset($results['[]'][0]) && !Arr::has($results['[]'][0], $valueDotPath)) {
  319. Arr::set($results['[]'][0], $valueDotPath, $value);
  320. }
  321. } else {
  322. $parentPath = explode('.', $dotPath);
  323. $index = (int)array_pop($parentPath);
  324. $parentPath = implode('.', $parentPath);
  325. $exampleFromParent = Arr::get($results, $dotPath) ?? $parentData->example[$index] ?? null;
  326. if (empty($exampleFromParent)) {
  327. Arr::set($results, $dotPath, $value);
  328. }
  329. }
  330. }
  331. }
  332. }
  333. public function addAuthField(ExtractedEndpointData $endpointData): void
  334. {
  335. $isApiAuthed = $this->config->get('auth.enabled', false);
  336. if (!$isApiAuthed || !$endpointData->metadata->authenticated) {
  337. return;
  338. }
  339. $strategy = $this->config->get('auth.in');
  340. $parameterName = $this->config->get('auth.name');
  341. $faker = Factory::create();
  342. if ($seed = $this->config->get('examples.faker_seed')) {
  343. $faker->seed($seed);
  344. }
  345. $token = $faker->shuffleString('abcdefghkvaZVDPE1864563');
  346. $valueToUse = $this->config->get('auth.use_value');
  347. $valueToDisplay = $this->config->get('auth.placeholder');
  348. switch ($strategy) {
  349. case 'query':
  350. case 'query_or_body':
  351. $endpointData->auth = ["queryParameters", $parameterName, $valueToUse ?: $token];
  352. $endpointData->queryParameters[$parameterName] = new Parameter([
  353. 'name' => $parameterName,
  354. 'type' => 'string',
  355. 'example' => $valueToDisplay ?: $token,
  356. 'description' => 'Authentication key.',
  357. 'required' => true,
  358. ]);
  359. return;
  360. case 'body':
  361. $endpointData->auth = ["bodyParameters", $parameterName, $valueToUse ?: $token];
  362. $endpointData->bodyParameters[$parameterName] = new Parameter([
  363. 'name' => $parameterName,
  364. 'type' => 'string',
  365. 'example' => $valueToDisplay ?: $token,
  366. 'description' => 'Authentication key.',
  367. 'required' => true,
  368. ]);
  369. return;
  370. case 'bearer':
  371. $endpointData->auth = ["headers", "Authorization", "Bearer " . ($valueToUse ?: $token)];
  372. $endpointData->headers['Authorization'] = "Bearer " . ($valueToDisplay ?: $token);
  373. return;
  374. case 'basic':
  375. $endpointData->auth = ["headers", "Authorization", "Basic " . ($valueToUse ?: base64_encode($token))];
  376. $endpointData->headers['Authorization'] = "Basic " . ($valueToDisplay ?: base64_encode($token));
  377. return;
  378. case 'header':
  379. $endpointData->auth = ["headers", $parameterName, $valueToUse ?: $token];
  380. $endpointData->headers[$parameterName] = $valueToDisplay ?: $token;
  381. return;
  382. }
  383. }
  384. protected static function convertStringValueToUploadedFileInstance(string $filePath): UploadedFile
  385. {
  386. $fileName = basename($filePath);
  387. return new File($fileName, fopen($filePath, 'r'));
  388. }
  389. protected function mergeInheritedMethodsData(string $stage, ExtractedEndpointData $endpointData, array $inheritedDocsOverrides = []): void
  390. {
  391. $overrides = $inheritedDocsOverrides[$stage] ?? [];
  392. $normalizeParamData = fn($data, $key) => array_merge($data, ["name" => $key]);
  393. if (is_array($overrides)) {
  394. foreach ($overrides as $key => $item) {
  395. switch ($stage) {
  396. case "responses":
  397. $endpointData->responses->concat($overrides);
  398. $endpointData->responses->sortBy('status');
  399. break;
  400. case "urlParameters":
  401. case "bodyParameters":
  402. case "queryParameters":
  403. $endpointData->$stage[$key] = Parameter::make($normalizeParamData($item, $key));
  404. break;
  405. case "responseFields":
  406. $endpointData->$stage[$key] = ResponseField::make($normalizeParamData($item, $key));
  407. break;
  408. default:
  409. $endpointData->$stage[$key] = $item;
  410. }
  411. }
  412. } else if (is_callable($overrides)) {
  413. $results = $overrides($endpointData);
  414. $endpointData->$stage = match ($stage) {
  415. "responses" => ResponseCollection::make($results),
  416. "urlParameters", "bodyParameters", "queryParameters" => collect($results)->map(fn($param, $name) => Parameter::make($normalizeParamData($param, $name)))->all(),
  417. "responseFields" => collect($results)->map(fn($field, $name) => ResponseField::make($normalizeParamData($field, $name)))->all(),
  418. default => $results,
  419. };
  420. }
  421. }
  422. public static function transformOldRouteRulesIntoNewSettings($stage, $rulesToApply, $strategyName, $strategySettings = [])
  423. {
  424. if ($stage == 'responses' && Str::is('*ResponseCalls*', $strategyName) &&!empty($rulesToApply['response_calls'])) {
  425. // Transform `methods` to `only`
  426. $strategySettings = [];
  427. if (isset($rulesToApply['response_calls']['methods'])) {
  428. if(empty($rulesToApply['response_calls']['methods'])) {
  429. // This means all routes are forbidden
  430. $strategySettings['except'] = ["*"];
  431. } else {
  432. $strategySettings['only'] = array_map(
  433. fn($method) => "$method *",
  434. $rulesToApply['response_calls']['methods']
  435. );
  436. }
  437. }
  438. // Copy all other keys over as is
  439. foreach (array_keys($rulesToApply['response_calls']) as $key) {
  440. if ($key == 'methods') continue;
  441. $strategySettings[$key] = $rulesToApply['response_calls'][$key];
  442. }
  443. }
  444. return $strategySettings;
  445. }
  446. }