GroupedEndpointsFromApp.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. <?php
  2. namespace Knuckles\Scribe\GroupedEndpoints;
  3. use Illuminate\Support\Arr;
  4. use Illuminate\Support\Str;
  5. use Knuckles\Camel\Camel;
  6. use Knuckles\Camel\Extraction\ExtractedEndpointData;
  7. use Knuckles\Camel\Output\OutputEndpointData;
  8. use Knuckles\Scribe\Commands\GenerateDocumentation;
  9. use Knuckles\Scribe\Extracting\ApiDetails;
  10. use Knuckles\Scribe\Extracting\Extractor;
  11. use Knuckles\Scribe\Matching\MatchedRoute;
  12. use Knuckles\Scribe\Matching\RouteMatcherInterface;
  13. use Knuckles\Scribe\Tools\ConsoleOutputUtils as c;
  14. use Knuckles\Scribe\Tools\DocumentationConfig;
  15. use Knuckles\Scribe\Tools\ErrorHandlingUtils as e;
  16. use Knuckles\Scribe\Tools\Utils as u;
  17. use Knuckles\Scribe\Tools\Utils;
  18. use Mpociot\Reflection\DocBlock;
  19. use Mpociot\Reflection\DocBlock\Tag;
  20. use ReflectionClass;
  21. use Symfony\Component\Yaml\Yaml;
  22. class GroupedEndpointsFromApp implements GroupedEndpointsContract
  23. {
  24. protected string $docsName;
  25. private GenerateDocumentation $command;
  26. private RouteMatcherInterface $routeMatcher;
  27. private DocumentationConfig $docConfig;
  28. private bool $preserveUserChanges = true;
  29. private bool $encounteredErrors = false;
  30. public static string $camelDir;
  31. public static string $cacheDir;
  32. private array $endpointGroupIndexes = [];
  33. public function __construct(
  34. GenerateDocumentation $command, RouteMatcherInterface $routeMatcher,
  35. bool $preserveUserChanges, string $docsName = 'scribe'
  36. )
  37. {
  38. $this->command = $command;
  39. $this->routeMatcher = $routeMatcher;
  40. $this->docConfig = $command->getDocConfig();
  41. $this->preserveUserChanges = $preserveUserChanges;
  42. $this->docsName = $docsName;
  43. static::$camelDir = Camel::camelDir($this->docsName);
  44. static::$cacheDir = Camel::cacheDir($this->docsName);
  45. }
  46. public function get(): array
  47. {
  48. $groupedEndpoints = $this->extractEndpointsInfoAndWriteToDisk($this->routeMatcher, $this->preserveUserChanges);
  49. $this->extractAndWriteApiDetailsToDisk();
  50. return $groupedEndpoints;
  51. }
  52. public function hasEncounteredErrors(): bool
  53. {
  54. return $this->encounteredErrors;
  55. }
  56. protected function extractEndpointsInfoAndWriteToDisk(RouteMatcherInterface $routeMatcher, bool $preserveUserChanges): array
  57. {
  58. $latestEndpointsData = [];
  59. $cachedEndpoints = [];
  60. $groups = [];
  61. if ($preserveUserChanges && is_dir(static::$camelDir) && is_dir(static::$cacheDir)) {
  62. $latestEndpointsData = Camel::loadEndpointsToFlatPrimitivesArray(static::$camelDir);
  63. $cachedEndpoints = Camel::loadEndpointsToFlatPrimitivesArray(static::$cacheDir, true);
  64. $groups = Camel::loadEndpointsIntoGroups(static::$camelDir);
  65. }
  66. $routes = $routeMatcher->getRoutes($this->docConfig->get('routes'), $this->docConfig->get('router'));
  67. $endpoints = $this->extractEndpointsInfoFromLaravelApp($routes, $cachedEndpoints, $latestEndpointsData, $groups);
  68. $groupedEndpoints = Camel::groupEndpoints($endpoints, $this->endpointGroupIndexes);
  69. $this->writeEndpointsToDisk($groupedEndpoints);
  70. $groupedEndpoints = Camel::prepareGroupedEndpointsForOutput($groupedEndpoints);
  71. return $groupedEndpoints;
  72. }
  73. /**
  74. * @param MatchedRoute[] $matches
  75. * @param array $cachedEndpoints
  76. * @param array $latestEndpointsData
  77. * @param array[] $groups
  78. *
  79. * @return array
  80. * @throws \Exception
  81. */
  82. private function extractEndpointsInfoFromLaravelApp(array $matches, array $cachedEndpoints, array $latestEndpointsData, array $groups): array
  83. {
  84. $generator = $this->makeExtractor();
  85. $parsedEndpoints = [];
  86. foreach ($matches as $routeItem) {
  87. $route = $routeItem->getRoute();
  88. $routeControllerAndMethod = u::getRouteClassAndMethodNames($route);
  89. if (!$this->isValidRoute($routeControllerAndMethod)) {
  90. c::warn('Skipping invalid route: ' . c::getRouteRepresentation($route));
  91. continue;
  92. }
  93. if (!$this->doesControllerMethodExist($routeControllerAndMethod)) {
  94. c::warn('Skipping route: ' . c::getRouteRepresentation($route) . ' - Controller method does not exist.');
  95. continue;
  96. }
  97. if ($this->isRouteHiddenFromDocumentation($routeControllerAndMethod)) {
  98. c::warn('Skipping route: ' . c::getRouteRepresentation($route) . ': @hideFromAPIDocumentation was specified.');
  99. continue;
  100. }
  101. try {
  102. c::info('Processing route: ' . c::getRouteRepresentation($route));
  103. $currentEndpointData = $generator->processRoute($route, $routeItem->getRules());
  104. // If latest data is different from cached data, merge latest into current
  105. [$currentEndpointData, $index] = $this->mergeAnyEndpointDataUpdates($currentEndpointData, $cachedEndpoints, $latestEndpointsData, $groups);
  106. // We need to preserve order of endpoints, in case user did custom sorting
  107. $parsedEndpoints[] = $currentEndpointData;
  108. if ($index !== null) {
  109. $this->endpointGroupIndexes[$currentEndpointData->endpointId()] = $index;
  110. }
  111. c::success('Processed route: ' . c::getRouteRepresentation($route));
  112. } catch (\Exception $exception) {
  113. $this->encounteredErrors = true;
  114. c::error('Failed processing route: ' . c::getRouteRepresentation($route) . ' - Exception encountered.');
  115. e::dumpExceptionIfVerbose($exception);
  116. }
  117. }
  118. return $parsedEndpoints;
  119. }
  120. /**
  121. * @param ExtractedEndpointData $endpointData
  122. * @param array[] $cachedEndpoints
  123. * @param array[] $latestEndpointsData
  124. * @param array[] $groups
  125. *
  126. * @return array The extracted endpoint data and the endpoint's index in the group file
  127. */
  128. private function mergeAnyEndpointDataUpdates(ExtractedEndpointData $endpointData, array $cachedEndpoints, array $latestEndpointsData, array $groups): array
  129. {
  130. // First, find the corresponding endpoint in cached and latest
  131. $thisEndpointCached = Arr::first($cachedEndpoints, function (array $endpoint) use ($endpointData) {
  132. return $endpoint['uri'] === $endpointData->uri && $endpoint['httpMethods'] === $endpointData->httpMethods;
  133. });
  134. if (!$thisEndpointCached) {
  135. return [$endpointData, null];
  136. }
  137. $thisEndpointLatest = Arr::first($latestEndpointsData, function (array $endpoint) use ($endpointData) {
  138. return $endpoint['uri'] === $endpointData->uri && $endpoint['httpMethods'] == $endpointData->httpMethods;
  139. });
  140. if (!$thisEndpointLatest) {
  141. return [$endpointData, null];
  142. }
  143. // Then compare cached and latest to see what sections changed.
  144. $properties = [
  145. 'metadata',
  146. 'headers',
  147. 'urlParameters',
  148. 'queryParameters',
  149. 'bodyParameters',
  150. 'responses',
  151. 'responseFields',
  152. ];
  153. $changed = [];
  154. foreach ($properties as $property) {
  155. if ($thisEndpointCached[$property] != $thisEndpointLatest[$property]) {
  156. $changed[] = $property;
  157. }
  158. }
  159. // Finally, merge any changed sections.
  160. $thisEndpointLatest = OutputEndpointData::create($thisEndpointLatest);
  161. foreach ($changed as $property) {
  162. $endpointData->$property = $thisEndpointLatest->$property;
  163. }
  164. $index = Camel::getEndpointIndexInGroup($groups, $thisEndpointLatest);
  165. return [$endpointData, $index];
  166. }
  167. protected function writeEndpointsToDisk(array $grouped): void
  168. {
  169. Utils::deleteFilesMatching(static::$camelDir, function ($file) {
  170. /** @var $file array|\League\Flysystem\StorageAttributes */
  171. return !Str::startsWith(basename($file['path']), 'custom.');
  172. });
  173. Utils::deleteDirectoryAndContents(static::$cacheDir);
  174. if (!is_dir(static::$camelDir)) {
  175. mkdir(static::$camelDir, 0777, true);
  176. }
  177. if (!is_dir(static::$cacheDir)) {
  178. mkdir(static::$cacheDir, 0777, true);
  179. }
  180. $fileNameIndex = 0;
  181. foreach ($grouped as $group) {
  182. $yaml = Yaml::dump(
  183. $group, 20, 2,
  184. Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE | Yaml::DUMP_OBJECT_AS_MAP | Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK
  185. );
  186. if (count(Camel::$groupFileNames) == count($grouped)
  187. && isset(Camel::$groupFileNames[$group['name']])) {
  188. $fileName = Camel::$groupFileNames[$group['name']];
  189. } else {
  190. // Format numbers as two digits so they are sorted properly when retrieving later
  191. // (ie "10.yaml" comes after "9.yaml", not after "1.yaml")
  192. $fileName = sprintf("%02d.yaml", $fileNameIndex);
  193. $fileNameIndex++;
  194. }
  195. file_put_contents(static::$camelDir . "/$fileName", $yaml);
  196. file_put_contents(static::$cacheDir . "/$fileName", "## Autogenerated by Scribe. DO NOT MODIFY.\n\n" . $yaml);
  197. }
  198. }
  199. private function isValidRoute(array $routeControllerAndMethod = null): bool
  200. {
  201. if (is_array($routeControllerAndMethod)) {
  202. [$classOrObject, $method] = $routeControllerAndMethod;
  203. if (u::isInvokableObject($classOrObject)) {
  204. return true;
  205. }
  206. $routeControllerAndMethod = $classOrObject . '@' . $method;
  207. }
  208. return !is_callable($routeControllerAndMethod) && !is_null($routeControllerAndMethod);
  209. }
  210. private function doesControllerMethodExist(array $routeControllerAndMethod): bool
  211. {
  212. [$class, $method] = $routeControllerAndMethod;
  213. $reflection = new ReflectionClass($class);
  214. if ($reflection->hasMethod($method)) {
  215. return true;
  216. }
  217. return false;
  218. }
  219. private function isRouteHiddenFromDocumentation(array $routeControllerAndMethod): bool
  220. {
  221. if (!($class = $routeControllerAndMethod[0]) instanceof \Closure) {
  222. $classDocBlock = new DocBlock((new ReflectionClass($class))->getDocComment() ?: '');
  223. $shouldIgnoreClass = collect($classDocBlock->getTags())
  224. ->filter(function (Tag $tag) {
  225. return Str::lower($tag->getName()) === 'hidefromapidocumentation';
  226. })->isNotEmpty();
  227. if ($shouldIgnoreClass) {
  228. return true;
  229. }
  230. }
  231. $methodDocBlock = new DocBlock(u::getReflectedRouteMethod($routeControllerAndMethod)->getDocComment() ?: '');
  232. $shouldIgnoreMethod = collect($methodDocBlock->getTags())
  233. ->filter(function (Tag $tag) {
  234. return Str::lower($tag->getName()) === 'hidefromapidocumentation';
  235. })->isNotEmpty();
  236. return $shouldIgnoreMethod;
  237. }
  238. protected function extractAndWriteApiDetailsToDisk(): void
  239. {
  240. $apiDetails = $this->makeApiDetails();
  241. $apiDetails->writeMarkdownFiles();
  242. }
  243. protected function makeApiDetails(): ApiDetails
  244. {
  245. return new ApiDetails($this->docConfig, !$this->command->option('force'), $this->docsName);
  246. }
  247. /**
  248. * Make a new extractor.
  249. *
  250. * @return Extractor
  251. */
  252. protected function makeExtractor(): Extractor
  253. {
  254. return new Extractor($this->docConfig);
  255. }
  256. }