update after stop build
All checks were successful
Build, Push and Deploy / build-and-push (push) Successful in 3m36s
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 16s
Build, Push and Deploy / deploy-staging (push) Successful in 42s
Build, Push and Deploy / deploy-production (push) Has been skipped

This commit is contained in:
2026-04-18 16:10:57 +07:00
parent e449146306
commit 1228c52538
260 changed files with 43609 additions and 226 deletions

View File

@@ -0,0 +1,10 @@
<?php
namespace Knuckles\Scribe\GroupedEndpoints;
interface GroupedEndpointsContract
{
public function get(): array;
public function hasEncounteredErrors(): bool;
}

View File

@@ -0,0 +1,50 @@
<?php
namespace Knuckles\Scribe\GroupedEndpoints;
use Knuckles\Scribe\Commands\GenerateDocumentation;
use Knuckles\Scribe\Matching\RouteMatcherInterface;
use Knuckles\Scribe\Tools\PathConfig;
class GroupedEndpointsFactory
{
public function make(
GenerateDocumentation $command,
RouteMatcherInterface $routeMatcher,
PathConfig $paths,
): GroupedEndpointsContract {
if ($command->isForcing()) {
return static::fromApp(
command: $command,
routeMatcher: $routeMatcher,
preserveUserChanges: false,
paths: $paths
);
}
if ($command->shouldExtract()) {
return static::fromApp(
command: $command,
routeMatcher: $routeMatcher,
preserveUserChanges: true,
paths: $paths
);
}
return static::fromCamelDir($paths);
}
public static function fromApp(
GenerateDocumentation $command,
RouteMatcherInterface $routeMatcher,
bool $preserveUserChanges,
PathConfig $paths,
): GroupedEndpointsFromApp {
return new GroupedEndpointsFromApp($command, $routeMatcher, $paths, $preserveUserChanges);
}
public static function fromCamelDir(PathConfig $paths): GroupedEndpointsFromCamelDir
{
return new GroupedEndpointsFromCamelDir($paths);
}
}

View File

@@ -0,0 +1,309 @@
<?php
namespace Knuckles\Scribe\GroupedEndpoints;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Knuckles\Camel\Camel;
use Knuckles\Camel\Extraction\ExtractedEndpointData;
use Knuckles\Camel\Output\OutputEndpointData;
use Knuckles\Scribe\Commands\GenerateDocumentation;
use Knuckles\Scribe\Exceptions\CouldntGetRouteDetails;
use Knuckles\Scribe\Extracting\ApiDetails;
use Knuckles\Scribe\Extracting\Extractor;
use Knuckles\Scribe\Matching\MatchedRoute;
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\PathConfig;
use Knuckles\Scribe\Tools\Utils;
use Knuckles\Scribe\Tools\Utils as u;
use Mpociot\Reflection\DocBlock;
use Mpociot\Reflection\DocBlock\Tag;
use Symfony\Component\Yaml\Yaml;
class GroupedEndpointsFromApp implements GroupedEndpointsContract
{
public static string $camelDir;
public static string $cacheDir;
private DocumentationConfig $docConfig;
private bool $encounteredErrors = false;
public function __construct(
private GenerateDocumentation $command,
private RouteMatcherInterface $routeMatcher,
protected PathConfig $paths,
private bool $preserveUserChanges = true,
) {
$this->docConfig = $command->getDocConfig();
static::$camelDir = Camel::camelDir($this->paths);
static::$cacheDir = Camel::cacheDir($this->paths);
}
public function get(): array
{
$groupedEndpoints = $this->extractEndpointsInfoAndWriteToDisk($this->routeMatcher, $this->preserveUserChanges);
$this->extractAndWriteApiDetailsToDisk();
return $groupedEndpoints;
}
public function hasEncounteredErrors(): bool
{
return $this->encounteredErrors;
}
protected function extractEndpointsInfoAndWriteToDisk(RouteMatcherInterface $routeMatcher, bool $preserveUserChanges): array
{
$latestEndpointsData = [];
$cachedEndpoints = [];
if ($preserveUserChanges && is_dir(static::$camelDir) && is_dir(static::$cacheDir)) {
$latestEndpointsData = Camel::loadEndpointsToFlatPrimitivesArray(static::$camelDir);
$cachedEndpoints = Camel::loadEndpointsToFlatPrimitivesArray(static::$cacheDir);
}
$routes = $routeMatcher->getRoutes($this->docConfig->get('routes', []));
$endpoints = $this->extractEndpointsInfoFromLaravelApp($routes, $cachedEndpoints, $latestEndpointsData);
$groupedEndpoints = collect($endpoints)->groupBy('metadata.groupName')->map(function (Collection $endpointsInGroup) {
return [
'name' => $endpointsInGroup->first(function (ExtractedEndpointData $endpointData) {
return ! empty($endpointData->metadata->groupName);
})->metadata->groupName ?? '',
'description' => $endpointsInGroup->first(function (ExtractedEndpointData $endpointData) {
return ! empty($endpointData->metadata->groupDescription);
})->metadata->groupDescription ?? '',
'endpoints' => $endpointsInGroup->toArray(),
];
})->all();
$this->writeEndpointsToDisk($groupedEndpoints);
return $groupedEndpoints;
}
protected function writeEndpointsToDisk(array $grouped): void
{
Utils::deleteFilesMatching(static::$camelDir, function ($file) {
/** @var array|\League\Flysystem\StorageAttributes $file */
return ! Str::startsWith(basename($file['path']), 'custom.');
});
Utils::deleteDirectoryAndContents(static::$cacheDir);
if (! is_dir(static::$camelDir)) {
mkdir(static::$camelDir, 0o777, true);
}
if (! is_dir(static::$cacheDir)) {
mkdir(static::$cacheDir, 0o777, 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
);
// Format numbers as two digits so they are sorted properly when retrieving later
// (ie "10.yaml" comes after "9.yaml", not after "1.yaml")
$fileName = sprintf('%02d.yaml', $fileNameIndex);
$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 extractAndWriteApiDetailsToDisk(): void
{
$apiDetails = $this->makeApiDetails();
$apiDetails->writeMarkdownFiles();
}
protected function makeApiDetails(): ApiDetails
{
return new ApiDetails($this->paths, $this->docConfig, ! $this->command->option('force'));
}
/**
* Make a new extractor.
*/
protected function makeExtractor(): Extractor
{
return new Extractor($this->docConfig);
}
/**
* @param MatchedRoute[] $matches
*
* @throws \Exception
*/
private function extractEndpointsInfoFromLaravelApp(array $matches, array $cachedEndpoints, array $latestEndpointsData): array
{
$extractor = $this->makeExtractor();
$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 {
// Start buffering warnings so they don't break the task output.
c::startWarningBuffer();
$this->command->outputComponents()->task(
c::getRouteRepresentation($route),
function () use ($extractor, $route, $routeItem, $cachedEndpoints, $latestEndpointsData, &$parsedEndpoints) {
$currentEndpointData = $extractor->processRoute($route, $routeItem->getRules());
// If latest data is different from cached data, merge latest into current.
$currentEndpointData = $this->mergeAnyEndpointDataUpdates($currentEndpointData, $cachedEndpoints, $latestEndpointsData);
$parsedEndpoints[] = $currentEndpointData;
return true;
}
);
// Flush buffered warnings after a task completes.
c::flushWarningBuffer();
} catch (\Exception $exception) {
$this->encounteredErrors = true;
c::flushWarningBuffer(); // Flush warnings even in error.
e::dumpExceptionIfVerbose($exception);
}
}
return $parsedEndpoints;
}
/**
* @param array[] $cachedEndpoints
* @param array[] $latestEndpointsData
* @return ExtractedEndpointData The extracted endpoint data
*/
private function mergeAnyEndpointDataUpdates(ExtractedEndpointData $endpointData, array $cachedEndpoints, array $latestEndpointsData): ExtractedEndpointData
{
// 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;
}
$thisEndpointLatest = Arr::first($latestEndpointsData, function (array $endpoint) use ($endpointData) {
return $endpoint['uri'] === $endpointData->uri && $endpoint['httpMethods'] === $endpointData->httpMethods;
});
if (! $thisEndpointLatest) {
return $endpointData;
}
// 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};
}
return $endpointData;
}
private function isValidRoute(?array $routeControllerAndMethod): bool
{
if (is_array($routeControllerAndMethod)) {
if (count($routeControllerAndMethod) < 2) {
throw CouldntGetRouteDetails::new();
}
[$classOrObject, $method] = $routeControllerAndMethod;
if (u::isInvokableObject($classOrObject)) {
return true;
}
$routeControllerAndMethod = $classOrObject.'@'.$method;
}
return ! is_callable($routeControllerAndMethod) && ! is_null($routeControllerAndMethod);
}
private function doesControllerMethodExist(array $routeControllerAndMethod): bool
{
if (count($routeControllerAndMethod) < 2) {
throw CouldntGetRouteDetails::new();
}
[$class, $method] = $routeControllerAndMethod;
$reflection = new \ReflectionClass($class);
if ($reflection->hasMethod($method)) {
return true;
}
return false;
}
private function isRouteHiddenFromDocumentation(array $routeControllerAndMethod): bool
{
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;
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace Knuckles\Scribe\GroupedEndpoints;
use Knuckles\Camel\Camel;
use Knuckles\Scribe\Tools\PathConfig;
class GroupedEndpointsFromCamelDir implements GroupedEndpointsContract
{
public function __construct(protected PathConfig $paths) {}
public function get(): array
{
if (! is_dir(Camel::camelDir($this->paths))) {
throw new \InvalidArgumentException(
"Can't use --no-extraction because there are no endpoints in the ".Camel::camelDir($this->paths).' directory.'
);
}
return Camel::loadEndpointsIntoGroups(Camel::camelDir($this->paths));
}
public function hasEncounteredErrors(): bool
{
return false;
}
}