Skip to content

Commit

Permalink
Merge pull request #263 from ajcastro/refactor
Browse files Browse the repository at this point in the history
Separate the concern of getting groupedEndpoints by creating different strategies
  • Loading branch information
shalvah authored Jul 5, 2021
2 parents d0a6980 + 980a88b commit 30e4850
Show file tree
Hide file tree
Showing 5 changed files with 359 additions and 241 deletions.
258 changes: 17 additions & 241 deletions src/Commands/GenerateDocumentation.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,19 @@
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
use Knuckles\Camel\Camel;
use Knuckles\Camel\Extraction\ExtractedEndpointData;
use Knuckles\Camel\Output\OutputEndpointData;
use Knuckles\Camel\Camel;
use Knuckles\Scribe\Extracting\Extractor;
use Knuckles\Scribe\Extracting\ApiDetails;
use Knuckles\Scribe\Matching\MatchedRoute;
use Knuckles\Scribe\Extracting\Extractor;
use Knuckles\Scribe\GroupedEndpoints\GroupedEndpointsFactory;
use Knuckles\Scribe\Matching\RouteMatcherInterface;
use Knuckles\Scribe\Tools\ConsoleOutputUtils as c;
use Knuckles\Scribe\Tools\DocumentationConfig;
use Knuckles\Scribe\Tools\ErrorHandlingUtils as e;
use Knuckles\Scribe\Tools\Globals;
use Knuckles\Scribe\Tools\Utils;
use Knuckles\Scribe\Tools\Utils as u;
use Knuckles\Scribe\Writing\Writer;
use Mpociot\Reflection\DocBlock;
use Mpociot\Reflection\DocBlock\Tag;
use ReflectionClass;
use Symfony\Component\Yaml\Yaml;

class GenerateDocumentation extends Command
Expand All @@ -42,191 +38,40 @@ class GenerateDocumentation extends Command
private bool $shouldExtract;

private bool $forcing;
private bool $encounteredErrors = false;
private array $endpointGroupIndexes = [];

public function handle(RouteMatcherInterface $routeMatcher): void
public function handle(RouteMatcherInterface $routeMatcher, GroupedEndpointsFactory $groupedEndpointsFactory): void
{
$this->bootstrap();

if ($this->forcing) {
$groupedEndpoints = $this->extractEndpointsInfoAndWriteToDisk($routeMatcher, false);
$this->extractAndWriteApiDetailsToDisk();
} else if ($this->shouldExtract) {
$groupedEndpoints = $this->extractEndpointsInfoAndWriteToDisk($routeMatcher, true);
$this->extractAndWriteApiDetailsToDisk();
} else {
if (!is_dir(static::$camelDir)) {
throw new \InvalidArgumentException("Can't use --no-extraction because there are no endpoints in the " . static::$camelDir . " directory.");
}
$groupedEndpoints = Camel::loadEndpointsIntoGroups(static::$camelDir);
}
$groupedEndpointsInstance = $groupedEndpointsFactory->make($this, $routeMatcher);

$groupedEndpoints = $this->mergeUserDefinedEndpoints(
$groupedEndpointsInstance->get(),
Camel::loadUserDefinedEndpoints(static::$camelDir)
);

$userDefinedEndpoints = Camel::loadUserDefinedEndpoints(static::$camelDir);
$groupedEndpoints = $this->mergeUserDefinedEndpoints($groupedEndpoints, $userDefinedEndpoints);
$writer = new Writer($this->docConfig);
$writer->writeDocs($groupedEndpoints);

if ($this->encounteredErrors) {
if ($groupedEndpointsInstance->hasEncounteredErrors()) {
c::warn('Generated docs, but encountered some errors while processing routes.');
c::warn('Check the output above for details.');
}
}

/**
* @param MatchedRoute[] $matches
* @param array $cachedEndpoints
* @param array $latestEndpointsData
* @param array[] $groups
*
* @return array
* @throws \Exception
*/
private function extractEndpointsInfoFromLaravelApp(array $matches, array $cachedEndpoints, array $latestEndpointsData, array $groups): array
{
$generator = new Extractor($this->docConfig);
$parsedEndpoints = [];

foreach ($matches as $routeItem) {
$route = $routeItem->getRoute();

$routeControllerAndMethod = u::getRouteClassAndMethodNames($route);
if (!$this->isValidRoute($routeControllerAndMethod)) {
c::warn('Skipping invalid route: ' . c::getRouteRepresentation($route));
continue;
}

if (!$this->doesControllerMethodExist($routeControllerAndMethod)) {
c::warn('Skipping route: ' . c::getRouteRepresentation($route) . ' - Controller method does not exist.');
continue;
}

if ($this->isRouteHiddenFromDocumentation($routeControllerAndMethod)) {
c::warn('Skipping route: ' . c::getRouteRepresentation($route) . ': @hideFromAPIDocumentation was specified.');
continue;
}

try {
c::info('Processing route: ' . c::getRouteRepresentation($route));
$currentEndpointData = $generator->processRoute($route, $routeItem->getRules());
// If latest data is different from cached data, merge latest into current
[$currentEndpointData, $index] = $this->mergeAnyEndpointDataUpdates($currentEndpointData, $cachedEndpoints, $latestEndpointsData, $groups);

// We need to preserve order of endpoints, in case user did custom sorting
$parsedEndpoints[] = $currentEndpointData;
if ($index !== null) {
$this->endpointGroupIndexes[$currentEndpointData->endpointId()] = $index;
}
c::success('Processed route: ' . c::getRouteRepresentation($route));
} catch (\Exception $exception) {
$this->encounteredErrors = true;
c::error('Failed processing route: ' . c::getRouteRepresentation($route) . ' - Exception encountered.');
e::dumpExceptionIfVerbose($exception);
}
}

return $parsedEndpoints;
}

/**
* @param ExtractedEndpointData $endpointData
* @param array[] $cachedEndpoints
* @param array[] $latestEndpointsData
* @param array[] $groups
*
* @return array The extracted endpoint data and the endpoint's index in the group file
*/
private function mergeAnyEndpointDataUpdates(ExtractedEndpointData $endpointData, array $cachedEndpoints, array $latestEndpointsData, array $groups): array
{
// First, find the corresponding endpoint in cached and latest
$thisEndpointCached = Arr::first($cachedEndpoints, function (array $endpoint) use ($endpointData) {
return $endpoint['uri'] === $endpointData->uri && $endpoint['httpMethods'] === $endpointData->httpMethods;
});
if (!$thisEndpointCached) {
return [$endpointData, null];
}

$thisEndpointLatest = Arr::first($latestEndpointsData, function (array $endpoint) use ($endpointData) {
return $endpoint['uri'] === $endpointData->uri && $endpoint['httpMethods'] == $endpointData->httpMethods;
});
if (!$thisEndpointLatest) {
return [$endpointData, null];
}

// Then compare cached and latest to see what sections changed.
$properties = [
'metadata',
'headers',
'urlParameters',
'queryParameters',
'bodyParameters',
'responses',
'responseFields',
];

$changed = [];
foreach ($properties as $property) {
if ($thisEndpointCached[$property] != $thisEndpointLatest[$property]) {
$changed[] = $property;
}
}

// Finally, merge any changed sections.
$thisEndpointLatest = OutputEndpointData::create($thisEndpointLatest);
foreach ($changed as $property) {
$endpointData->$property = $thisEndpointLatest->$property;
}
$index = Camel::getEndpointIndexInGroup($groups, $thisEndpointLatest);

return [$endpointData, $index];
}

private function isValidRoute(array $routeControllerAndMethod = null): bool
public function isForcing(): bool
{
if (is_array($routeControllerAndMethod)) {
[$classOrObject, $method] = $routeControllerAndMethod;
if (u::isInvokableObject($classOrObject)) {
return true;
}
$routeControllerAndMethod = $classOrObject . '@' . $method;
}

return !is_callable($routeControllerAndMethod) && !is_null($routeControllerAndMethod);
return $this->forcing;
}

private function doesControllerMethodExist(array $routeControllerAndMethod): bool
public function shouldExtract(): bool
{
[$class, $method] = $routeControllerAndMethod;
$reflection = new ReflectionClass($class);

if ($reflection->hasMethod($method)) {
return true;
}

return false;
return $this->shouldExtract;
}

private function isRouteHiddenFromDocumentation(array $routeControllerAndMethod): bool
public function getDocConfig()
{
if (!($class = $routeControllerAndMethod[0]) instanceof \Closure) {
$classDocBlock = new DocBlock((new ReflectionClass($class))->getDocComment() ?: '');
$shouldIgnoreClass = collect($classDocBlock->getTags())
->filter(function (Tag $tag) {
return Str::lower($tag->getName()) === 'hidefromapidocumentation';
})->isNotEmpty();

if ($shouldIgnoreClass) {
return true;
}
}

$methodDocBlock = new DocBlock(u::getReflectedRouteMethod($routeControllerAndMethod)->getDocComment() ?: '');
$shouldIgnoreMethod = collect($methodDocBlock->getTags())
->filter(function (Tag $tag) {
return Str::lower($tag->getName()) === 'hidefromapidocumentation';
})->isNotEmpty();

return $shouldIgnoreMethod;
return $this->docConfig;
}

public function bootstrap(): void
Expand All @@ -252,40 +97,6 @@ public function bootstrap(): void
Camel::$groupFileNames = [];
}

protected function writeEndpointsToDisk(array $grouped): void
{
Utils::deleteFilesMatching(static::$camelDir, function (array $file) {
return !Str::startsWith($file['basename'], 'custom.');
});
Utils::deleteDirectoryAndContents(static::$cacheDir);

if (!is_dir(static::$camelDir)) {
mkdir(static::$camelDir, 0777, true);
}

if (!is_dir(static::$cacheDir)) {
mkdir(static::$cacheDir, 0777, true);
}

$fileNameIndex = 0;
foreach ($grouped as $group) {
$yaml = Yaml::dump(
$group, 20, 2,
Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE | Yaml::DUMP_OBJECT_AS_MAP | Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK
);
if (count(Camel::$groupFileNames) == count($grouped)
&& isset(Camel::$groupFileNames[$group['name']])) {
$fileName = Camel::$groupFileNames[$group['name']];
} else {
$fileName = "$fileNameIndex.yaml";
$fileNameIndex++;
}

file_put_contents(static::$camelDir . "/$fileName", $yaml);
file_put_contents(static::$cacheDir . "/$fileName", "## Autogenerated by Scribe. DO NOT MODIFY.\n\n" . $yaml);
}
}

protected function mergeUserDefinedEndpoints(array $groupedEndpoints, array $userDefinedEndpoints): array
{
foreach ($userDefinedEndpoints as $endpoint) {
Expand All @@ -307,39 +118,4 @@ protected function mergeUserDefinedEndpoints(array $groupedEndpoints, array $use

return $groupedEndpoints;
}

protected function extractEndpointsInfoAndWriteToDisk(RouteMatcherInterface $routeMatcher, bool $preserveUserChanges): array
{
$latestEndpointsData = [];
$cachedEndpoints = [];
$groups = [];

if ($preserveUserChanges && is_dir(static::$camelDir) && is_dir(static::$cacheDir)) {
$latestEndpointsData = Camel::loadEndpointsToFlatPrimitivesArray(static::$camelDir);
$cachedEndpoints = Camel::loadEndpointsToFlatPrimitivesArray(static::$cacheDir, true);
$groups = Camel::loadEndpointsIntoGroups(static::$camelDir);
}

$routes = $routeMatcher->getRoutes($this->docConfig->get('routes'), $this->docConfig->get('router'));
$endpoints = $this->extractEndpointsInfoFromLaravelApp($routes, $cachedEndpoints, $latestEndpointsData, $groups);
$groupedEndpoints = Camel::groupEndpoints($endpoints, $this->endpointGroupIndexes);
$this->writeEndpointsToDisk($groupedEndpoints);
$this->writeExampleCustomEndpoint();
$groupedEndpoints = Camel::prepareGroupedEndpointsForOutput($groupedEndpoints);
return $groupedEndpoints;
}

protected function writeExampleCustomEndpoint(): void
{
// We add an example to guide users in case they need to add a custom endpoint.
if (!file_exists(static::$camelDir . '/custom.0.yaml')) {
copy(__DIR__ . '/../../resources/example_custom_endpoint.yaml', static::$camelDir . '/custom.0.yaml');
}
}

protected function extractAndWriteApiDetailsToDisk(): void
{
$apiDetails = new ApiDetails($this->docConfig, !$this->option('force'));
$apiDetails->writeMarkdownFiles();
}
}
10 changes: 10 additions & 0 deletions src/GroupedEndpoints/GroupedEndpointsContract.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace Knuckles\Scribe\GroupedEndpoints;

interface GroupedEndpointsContract
{
public function get(): array;

public function hasEncounteredErrors(): bool;
}
23 changes: 23 additions & 0 deletions src/GroupedEndpoints/GroupedEndpointsFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

namespace Knuckles\Scribe\GroupedEndpoints;

use Knuckles\Camel\Camel;
use Knuckles\Scribe\Commands\GenerateDocumentation;
use Knuckles\Scribe\Matching\RouteMatcherInterface;

class GroupedEndpointsFactory
{
public static function make(GenerateDocumentation $command, RouteMatcherInterface $routeMatcher): GroupedEndpointsContract
{
if ($command->isForcing()) {
return new GroupedEndpointsFromApp($command, $routeMatcher, false);
}

if ($command->shouldExtract()) {
return new GroupedEndpointsFromApp($command, $routeMatcher, true);
}

return new GroupedEndpointsFromCamelDir;
}
}
Loading

0 comments on commit 30e4850

Please sign in to comment.