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,136 @@
<?php
namespace Knuckles\Scribe\Extracting\Shared;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Http\Resources\Json\ResourceCollection;
use Illuminate\Pagination\CursorPaginator;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Arr;
use Knuckles\Camel\Extraction\ExtractedEndpointData;
use Knuckles\Scribe\Tools\ErrorHandlingUtils as e;
use Knuckles\Scribe\Tools\Utils;
use Mpociot\Reflection\DocBlock;
use Mpociot\Reflection\DocBlock\Tag;
class ApiResourceResponseTools
{
public static function fetch(
string $apiResourceClass,
bool $isCollection,
?callable $modelInstantiator,
ExtractedEndpointData $endpointData,
array $pagination,
array $additionalData,
) {
$resource = static::getApiResourceOrCollectionInstance(
$apiResourceClass,
$isCollection,
$modelInstantiator,
$pagination,
$additionalData
);
$response = static::callApiResourceAndGetResponse($resource, $endpointData);
return $response->getContent();
}
public static function callApiResourceAndGetResponse(JsonResource $resource, ExtractedEndpointData $endpointData): JsonResponse
{
$uri = Utils::getUrlWithBoundParameters($endpointData->route->uri(), $endpointData->cleanUrlParameters);
$method = $endpointData->route->methods()[0];
$request = Request::create($uri, $method);
$request->headers->add(['Accept' => 'application/json']);
// Set the route properly, so it works for users who have code that checks for the route.
$request->setRouteResolver(fn () => $endpointData->route);
$previousBoundRequest = app('request');
app()->bind('request', fn () => $request);
$response = $resource->toResponse($request);
app()->bind('request', fn () => $previousBoundRequest);
return $response;
}
public static function getApiResourceOrCollectionInstance(
string $apiResourceClass,
bool $isCollection,
?callable $modelInstantiator,
array $paginationStrategy = [],
array $additionalData = [],
): JsonResource {
// If the API Resource uses an empty $resource (e.g. an empty array), the $modelInstantiator will be null
// See https://github.com/knuckleswtf/scribe/issues/652
$modelInstance = is_callable($modelInstantiator) ? $modelInstantiator() : [];
try {
$resource = new $apiResourceClass($modelInstance);
} catch (\Exception) {
// If it is a ResourceCollection class, it might throw an error
// when trying to instantiate with something other than a collection
$resource = new $apiResourceClass(collect([$modelInstance]));
}
if ($isCollection) {
// Collections can either use the regular JsonResource class (via `::collection()`,
// or a ResourceCollection (via `new`)
// See https://laravel.com/docs/5.8/eloquent-resources
$models = [$modelInstance, $modelInstantiator()];
// Pagination can be in two forms:
// [15] : means ::paginate(15)
// [15, 'simple'] : means ::simplePaginate(15)
if (count($paginationStrategy) === 1) {
$perPage = $paginationStrategy[0];
$paginator = new LengthAwarePaginator(
// For some reason, the LengthAware paginator needs only first page items to work correctly
collect($models)->slice(0, $perPage),
count($models),
$perPage
);
$list = $paginator;
} elseif (count($paginationStrategy) === 2 && $paginationStrategy[1] === 'simple') {
$perPage = $paginationStrategy[0];
$paginator = new Paginator($models, $perPage);
$list = $paginator;
} elseif (count($paginationStrategy) === 2 && $paginationStrategy[1] === 'cursor') {
$perPage = $paginationStrategy[0];
$paginator = new CursorPaginator($models, $perPage);
$list = $paginator;
} else {
$list = collect($models);
}
/** @var JsonResource $resource */
$resource = $resource instanceof ResourceCollection
? new $apiResourceClass($list) : $apiResourceClass::collection($list);
}
return $resource->additional($additionalData);
}
/**
* Check if the ApiResource class has an `@mixin` docblock, and fetch the model from there.
*/
public static function tryToInferApiResourceModel(string $apiResourceClass): ?string
{
$class = new \ReflectionClass($apiResourceClass);
$docBlock = new DocBlock($class->getDocComment() ?: '');
/** @var null|Tag $mixinTag */
$mixinTag = Arr::first(Utils::filterDocBlockTags($docBlock->getTags(), 'mixin'));
if (empty($mixinTag) || empty($modelClass = mb_trim($mixinTag->getContent()))) {
return null;
}
if (class_exists($modelClass)) {
return $modelClass;
}
return null;
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace Knuckles\Scribe\Extracting\Shared;
use Knuckles\Camel\Extraction\ExtractedEndpointData;
use Knuckles\Camel\Extraction\Response;
use Knuckles\Scribe\Extracting\ParamHelpers;
class ResponseFieldTools
{
use ParamHelpers;
public static function inferTypeOfResponseField(array $data, ExtractedEndpointData $endpointData): string
{
if (! empty($data['type'])) {
return self::normalizeTypeName($data['type']);
}
// Try to get a type from first 2xx response
$validResponse = collect($endpointData->responses)->first(
fn (Response $r) => $r->status >= 200 && $r->status < 300
);
if ($validResponse && ($validResponseContent = json_decode($validResponse->content, true))) {
$nonexistent = new \stdClass;
$value = $validResponseContent[$data['name']]
?? $validResponseContent['data'][$data['name']] // Maybe it's a Laravel ApiResource
?? $validResponseContent[0][$data['name']] // Maybe it's a list
?? $validResponseContent['data'][0][$data['name']] // Maybe an Api Resource Collection?
?? $nonexistent;
if ($value !== $nonexistent) {
return self::normalizeTypeName(gettype($value), $value);
}
}
return '';
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace Knuckles\Scribe\Extracting\Shared;
class ResponseFileTools
{
public static function getResponseContents($filePath, array|string|null $merge): string
{
$content = self::getFileContents($filePath);
if (empty($merge)) {
return $content;
}
if (is_string($merge)) {
$json = str_replace("'", '"', $merge);
return json_encode(array_merge(json_decode($content, true), json_decode($json, true)));
}
return json_encode(array_merge(json_decode($content, true), $merge));
}
protected static function getFileContents($filePath): string
{
if (! file_exists($filePath)) {
// Try Laravel storage folder
if (! file_exists(storage_path($filePath))) {
throw new \InvalidArgumentException("@responseFile {$filePath} does not exist");
}
$filePath = storage_path($filePath);
}
return file_get_contents($filePath, true);
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace Knuckles\Scribe\Extracting\Shared;
use Illuminate\Pagination\LengthAwarePaginator;
use League\Fractal\Manager;
use League\Fractal\Resource\Collection;
use League\Fractal\Resource\Item;
class TransformerResponseTools
{
public static function fetch(string $transformerClass, bool $isCollection, $modelInstantiator, array $pagination = [], ?string $resourceKey = null, ?string $serializer = null)
{
$fractal = new Manager;
if (! is_null($serializer)) {
$fractal->setSerializer(app($serializer));
}
$modelInstance = $modelInstantiator();
if ($isCollection) {
$models = [$modelInstance, $modelInstantiator()];
$resource = new Collection($models, new $transformerClass);
['adapter' => $paginatorAdapter, 'perPage' => $perPage] = $pagination;
if ($paginatorAdapter) {
$total = count($models);
// Need to pass only the first page to both adapter and paginator, otherwise they will display ebverything
$firstPage = collect($models)->slice(0, $perPage);
$resource = new Collection($firstPage, new $transformerClass, $resourceKey);
$paginator = new LengthAwarePaginator($firstPage, $total, $perPage);
$resource->setPaginator(new $paginatorAdapter($paginator));
}
} else {
$resource = (new Item($modelInstance, new $transformerClass, $resourceKey));
}
return response($fractal->createData($resource)->toJson())->getContent();
}
}

View File

@@ -0,0 +1,226 @@
<?php
namespace Knuckles\Scribe\Extracting\Shared;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Routing\Route;
use Illuminate\Support\Str;
// See https://laravel.com/docs/9.x/routing#route-model-binding
class UrlParamsNormalizer
{
/**
* Normalize a URL from Laravel-style to something that's clearer for a non-Laravel user.
* For instance, `/posts/{post}` would be clearer as `/posts/{id}`,
* and `/users/{user}/posts/{post}` would be clearer as `/users/{user_id}/posts/{id}`.
*/
public static function normalizeParameterNamesInRouteUri(Route $route, \ReflectionFunctionAbstract $method): string
{
$params = [];
$uri = $route->uri;
preg_match_all('#\{(\w+?)}#', $uri, $params);
$resourceRouteNames = ['.index', '.show', '.update', '.destroy', '.store'];
$typeHintedEloquentModels = self::getTypeHintedEloquentModels($method);
$routeName = $route->action['as'] ?? '';
if (Str::endsWith($routeName, $resourceRouteNames)) {
// Note that resource routes can be nested eg users.posts.show
$pluralResources = explode('.', $routeName);
array_pop($pluralResources); // Remove the name of the action (eg `show`)
$alreadyFoundResourceParam = false;
foreach (array_reverse($pluralResources) as $pluralResource) {
$singularResource = Str::singular($pluralResource);
// Laravel turns hyphens in parameters to underscores
// (`cool-things/{cool-thing}` to `cool-things/{cool_thing_id}`)
// so we do the same
$singularResourceParam = str_replace('-', '_', $singularResource);
$urlPatternsToSearchFor = [
"{$pluralResource}/{{$singularResourceParam}}",
"{$pluralResource}/{{$singularResource}}",
"{$pluralResource}/{{$singularResourceParam}?}",
"{$pluralResource}/{{$singularResource}?}",
];
$binding = self::getRouteKeyForUrlParam(
$route,
$singularResource,
$typeHintedEloquentModels,
'id'
);
if (! $alreadyFoundResourceParam) {
// This is the first resource param (from the end).
// We set it to `params/{id}` (or whatever field it's bound to)
$replaceWith = [
"{$pluralResource}/{{$binding}}",
"{$pluralResource}/{{$binding}}",
"{$pluralResource}/{{$binding}?}",
"{$pluralResource}/{{$binding}?}",
];
$alreadyFoundResourceParam = true;
} else {
// Other resource parameters will be `params/{<param>_id}`
$replaceWith = [
"{$pluralResource}/{{$singularResourceParam}_{$binding}}",
"{$pluralResource}/{{$singularResource}_{$binding}}",
"{$pluralResource}/{{$singularResourceParam}_{$binding}?}",
"{$pluralResource}/{{$singularResource}_{$binding}?}",
];
}
$uri = str_replace($urlPatternsToSearchFor, $replaceWith, $uri);
}
}
foreach ($params[1] as $param) {
// For non-resource parameters, if there's a field binding/type-hinted variable, replace that too:
if ($binding = self::getRouteKeyForUrlParam($route, $param, $typeHintedEloquentModels)) {
$urlPatternsToSearchFor = ["{{$param}}", "{{$param}?}"];
$replaceWith = ["{{$param}_{$binding}}", "{{$param}_{$binding}?}"];
$uri = str_replace($urlPatternsToSearchFor, $replaceWith, $uri);
}
}
return $uri;
}
/**
* Return the type-hinted method arguments in the action that are Eloquent models,
* The arguments will be returned as an array of the form: [<variable_name> => $instance].
*/
public static function getTypeHintedEloquentModels(\ReflectionFunctionAbstract $method): array
{
$arguments = [];
foreach ($method->getParameters() as $argument) {
if (($instance = self::instantiateMethodArgument($argument)) && $instance instanceof Model) {
$arguments[$argument->getName()] = $instance;
}
}
return $arguments;
}
/**
* Return the type-hinted method arguments in the action that are enums,
* The arguments will be returned as an array of the form: [<variable_name> => $instance].
*/
public static function getTypeHintedEnums(\ReflectionFunctionAbstract $method): array
{
$arguments = [];
foreach ($method->getParameters() as $argument) {
$argumentType = $argument->getType();
if (! $argumentType instanceof \ReflectionNamedType) {
continue;
}
try {
$reflectionEnum = new \ReflectionEnum($argumentType->getName());
$arguments[$argument->getName()] = $reflectionEnum;
} catch (\ReflectionException) {
continue;
}
}
return $arguments;
}
/**
* Given a URL that uses Eloquent model binding (for instance `/posts/{post}` -> `public function show(Post
* $post)`), we need to figure out the field that Eloquent uses to retrieve the Post object. By default, this would
* be `id`, but can be configured in a couple of ways:
*
* - Inline: `/posts/{post:slug}`
* - `class Post { public function getRouteKeyName() { return 'slug'; } }`
*
* There are other ways, but they're dynamic and beyond our scope.
*
* @param string $paramName The name of the URL parameter
* @param array<string, Model> $typeHintedEloquentModels
* @param null|string $default Default field to use
*/
protected static function getRouteKeyForUrlParam(
Route $route,
string $paramName,
array $typeHintedEloquentModels = [],
?string $default = null,
): ?string {
if ($binding = self::getInlineRouteKey($route, $paramName)) {
return $binding;
}
return self::getRouteKeyFromModel($paramName, $typeHintedEloquentModels) ?: $default;
}
/**
* Return the `slug` in /posts/{post:slug}.
*/
protected static function getInlineRouteKey(Route $route, string $paramName): ?string
{
return $route->bindingFieldFor($paramName);
}
/**
* Check if there's a type-hinted argument on the controller method matching the URL param name:
* eg /posts/{post} -> public function show(Post $post)
* If there is, check if it's an Eloquent model.
* If it is, return it's `getRouteKeyName()`.
*
* @param Model[] $typeHintedEloquentModels
*/
protected static function getRouteKeyFromModel(string $paramName, array $typeHintedEloquentModels): ?string
{
// Ensure param name is in camelCase so it matches the argument name (e.g. The '$userAddress' in `function show(BigThing $userAddress`)
$paramName = Str::camel($paramName);
if (array_key_exists($paramName, $typeHintedEloquentModels)) {
$argumentInstance = $typeHintedEloquentModels[$paramName];
return $argumentInstance->getRouteKeyName();
}
return null;
}
/**
* Instantiate an argument on a controller method via its typehint. For instance, $post in:
*
* public function show(Post $post)
*
* This method takes in a method argument and returns an instance, or null if it couldn't be instantiated safely.
* Cases where instantiation may fail:
* - the argument has no type (eg `public function show($postId)`)
* - the argument has a primitive type (eg `public function show(string $postId)`)
* - the argument is an injected dependency that itself needs other dependencies
* (eg `public function show(PostsManager $manager)`)
*/
protected static function instantiateMethodArgument(\ReflectionParameter $argument): ?object
{
$argumentType = $argument->getType();
// No type-hint, or primitive type
if (! $argumentType instanceof \ReflectionNamedType) {
return null;
}
$argumentClassName = $argumentType->getName();
if (class_exists($argumentClassName)) {
try {
return new $argumentClassName;
} catch (\Throwable $e) {
return null;
}
}
if (interface_exists($argumentClassName)) {
try {
return app($argumentClassName);
} catch (\Throwable $e) {
return null;
}
}
return null;
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace Knuckles\Scribe\Extracting\Shared\ValidationRulesFinders;
use PhpParser\Node;
/**
* This class looks for
* $anyVariable = $request->validate(...);
* or just
* $request->validate(...);.
*
* Also supports `$req` instead of `$request`
* Also supports `->validateWithBag('', ...)`
*/
class RequestValidate
{
public static function find(Node $node)
{
if (! $node instanceof Node\Stmt\Expression) {
return;
}
$expr = $node->expr;
if ($expr instanceof Node\Expr\Assign) {
$expr = $expr->expr; // If it's an assignment, get the expression on the RHS
}
if (
$expr instanceof Node\Expr\MethodCall
&& $expr->var instanceof Node\Expr\Variable
&& in_array($expr->var->name, ['request', 'req'])
) {
if ($expr->name->name === 'validate') {
return $expr->args[0]->value;
}
if ($expr->name->name === 'validateWithBag') {
return $expr->args[1]->value;
}
}
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace Knuckles\Scribe\Extracting\Shared\ValidationRulesFinders;
use Illuminate\Support\Facades\Request;
use PhpParser\Node;
/**
* This class looks for
* $anyVariable = Request::validate(...);
* or just
* Request::validate(...);.
*
* Also supports `->validateWithBag('', ...)`
*/
class RequestValidateFacade
{
public static function find(Node $node)
{
if (! $node instanceof Node\Stmt\Expression) {
return;
}
$expr = $node->expr;
if ($expr instanceof Node\Expr\Assign) {
$expr = $expr->expr; // If it's an assignment, get the expression on the RHS
}
if (
$expr instanceof Node\Expr\StaticCall
&& $expr->class instanceof Node\Name
&& in_array($expr->class->name, ['Request', Request::class])
) {
if ($expr->name->name === 'validate') {
return $expr->args[0]->value;
}
if ($expr->name->name === 'validateWithBag') {
return $expr->args[1]->value;
}
}
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace Knuckles\Scribe\Extracting\Shared\ValidationRulesFinders;
use PhpParser\Node;
/**
* This class looks for
* $anyVariable = $this->validate($request, ...);
* or just
* $this->validate($request, ...);.
*
* Also supports `$req` instead of `$request`
*/
class ThisValidate
{
public static function find(Node $node)
{
if (! $node instanceof Node\Stmt\Expression) {
return;
}
$expr = $node->expr;
if ($expr instanceof Node\Expr\Assign) {
$expr = $expr->expr; // If it's an assignment, get the expression on the RHS
}
if (
$expr instanceof Node\Expr\MethodCall
&& $expr->var instanceof Node\Expr\Variable
&& $expr->var->name === 'this'
) {
if ($expr->name->name === 'validate') {
return $expr->args[1]->value;
}
}
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace Knuckles\Scribe\Extracting\Shared\ValidationRulesFinders;
use PhpParser\Node;
use PhpParser\NodeFinder;
/**
* This class looks for
* $validator = Validator::make($request, ...)
* Validator::make($request, ...)->validate().
*
* The variable names (`$validator` and `$request`) don't matter.
*/
class ValidatorMake
{
public static function find(Node $node)
{
// Make sure it's an assignment
if (! $node instanceof Node\Stmt\Expression) {
return;
}
$validatorNode = (new NodeFinder)->findFirst($node, function ($node): bool {
return $node instanceof Node\Expr\StaticCall
&& ! empty($node->class->name)
&& str_ends_with($node->class->name, 'Validator')
&& $node->name->name === 'make';
});
if ($validatorNode instanceof Node\Expr\StaticCall) {
return $validatorNode->args[1]->value;
}
}
}