fix(graphql): fix format exceptions (#1049)

This commit is contained in:
Kyrch
2026-01-07 18:38:51 -03:00
committed by GitHub
parent 10ae7b865f
commit b7d10d81e8
20 changed files with 172 additions and 108 deletions
+12 -2
View File
@@ -8,7 +8,6 @@ use App\Concerns\Actions\GraphQL\ConstrainsEagerLoads;
use App\Concerns\Actions\GraphQL\PaginatesModels;
use App\Concerns\Actions\GraphQL\SortsModels;
use App\Enums\GraphQL\SortType;
use App\Exceptions\GraphQL\ClientValidationException;
use App\GraphQL\Argument\SortArgument;
use App\GraphQL\Criteria\Sort\RelationSortCriteria;
use App\GraphQL\Criteria\Sort\SortCriteria;
@@ -22,6 +21,7 @@ use Illuminate\Contracts\Pagination\Paginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Validator;
class IndexAction
{
@@ -57,7 +57,17 @@ class IndexAction
$page = Arr::get($args, 'page', 1);
$maxCount = Config::get('graphql.pagination_values.max_count');
throw_if($maxCount !== null && $first > $maxCount, ClientValidationException::class, "Maximum first value is {$maxCount}. Got {$first}. Fetch in smaller chuncks.");
Validator::make(['first' => $first], [
'first' => [
'required', 'integer', 'min:1',
function ($attribute, $value, $fail) use ($maxCount, $first): void {
if ($maxCount !== null && $value > $maxCount) {
$fail("You may request at most {$maxCount} items. Got {$first}. Fetch in smaller chuncks.");
}
},
],
])->validate();
$searchBuilder->withPagination($first, $page);
@@ -27,42 +27,37 @@ trait ConstrainsEagerLoads
{
$resolveInfo = new CustomResolveInfo($resolveInfo);
$fields = Arr::get($resolveInfo->getFieldSelectionWithAliases(100), "{$fieldName}.{$fieldName}.selectionSet")
$selection = Arr::get($resolveInfo->getFieldSelectionWithAliases(100), "{$fieldName}.{$fieldName}.selectionSet")
?? $resolveInfo->getFieldSelectionWithAliases(100);
$this->processEagerLoadForType($query, $fields, $type);
$this->processEagerLoadForType($query, $selection, $type);
}
/**
* Process recursively the relations available for the given type.
*/
private function processEagerLoadForType(Builder $builder, array $fields, BaseType|BaseUnion $type): void
private function processEagerLoadForType(Builder $builder, array $selection, BaseType|BaseUnion $type): void
{
$eagerLoadRelations = [];
/** @var array<int, Relation> $relations */
$relations = collect($type->relations())
->filter(fn (Relation $relation) => Arr::has($fields, $relation->getName()))
->filter(fn (Relation $relation) => Arr::has($selection, $relation->getName()))
->all();
foreach ($relations as $relation) {
$name = $relation->getName();
$path = $relation->getRelationName();
$relationSelection = Arr::get($fields, "{$name}.{$name}");
$relationArgs = Arr::get($relationSelection, 'args');
$relationSelection = Arr::get($selection, "{$name}.{$name}");
$relationType = $relation->getBaseType();
$eagerLoadRelations[$path] = function (EloquentRelation $eloquentRelation) use ($relationSelection, $relationArgs, $relationType, $relation): void {
if ($eloquentRelation instanceof MorphTo) {
$this->processMorphToRelation($relationSelection, $relationType, $eloquentRelation);
} elseif ($relationType instanceof BaseUnion) {
$this->processUnion($relationSelection, $eloquentRelation, $relationType);
} else {
$this->processGenericRelation($eloquentRelation, $relationArgs, $relationSelection, $relationType, $relation);
}
$eagerLoadRelations[$relation->getRelationName()] = function (EloquentRelation $eloquentRelation) use ($relationSelection, $relationType, $relation): void {
match (true) {
$eloquentRelation instanceof MorphTo => $this->processMorphToRelation($relationSelection, $relationType, $eloquentRelation),
$relationType instanceof BaseUnion => $this->processUnion($relationSelection, $relationType, $eloquentRelation),
default => $this->processGenericRelation($relationSelection, $relationType, $eloquentRelation, $relation),
};
};
}
@@ -84,7 +79,7 @@ trait ConstrainsEagerLoads
$morphConstrains = [];
foreach ($types as $type) {
$typeSelection = Arr::get($unions, "{$type->getName()}.selectionSet");
$typeSelection = Arr::get($unions, "{$type->getName()}.selectionSet", []);
$morphConstrains[$type->model()] = function (Builder $query) use ($typeSelection, $type): void {
$this->processEagerLoadForType($query, $typeSelection, $type);
@@ -97,10 +92,8 @@ trait ConstrainsEagerLoads
/**
* Process a union relation by applying the eager loads for each type in the union.
*/
private function processUnion(array $selection, EloquentRelation $relation, BaseUnion $union): void
private function processUnion(array $selection, BaseUnion $union, EloquentRelation $relation): void
{
$builder = $relation->getQuery();
$unions = Arr::get($selection, 'selectionSet.data.data.unions', []);
$types = collect($union->baseTypes())
@@ -110,17 +103,19 @@ trait ConstrainsEagerLoads
foreach ($types as $type) {
$typeSelection = Arr::get($unions, "{$type->getName()}.selectionSet", []);
$this->processEagerLoadForType($builder, $typeSelection, $type);
$this->processEagerLoadForType($relation->getQuery(), $typeSelection, $type);
}
}
/**
* Process a generic relation by applying filters, sorting and eager loads.
*/
private function processGenericRelation(EloquentRelation $relation, array $args, array $selection, BaseType $type, Relation $graphqlRelation): void
private function processGenericRelation(array $selection, BaseType $type, EloquentRelation $relation, Relation $graphqlRelation): void
{
$builder = $relation->getQuery();
$args = Arr::get($selection, 'args');
$this->filter($builder, $args, $type);
$this->sort($builder, $args, $type, $relation, $graphqlRelation);
@@ -4,11 +4,11 @@ declare(strict_types=1);
namespace App\Concerns\Actions\GraphQL;
use App\Exceptions\GraphQL\ClientValidationException;
use Illuminate\Contracts\Pagination\Paginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Validator;
trait PaginatesModels
{
@@ -18,7 +18,17 @@ trait PaginatesModels
$page = Arr::get($args, 'page');
$maxCount = Config::get('graphql.pagination_values.max_count');
throw_if($maxCount !== null && $first > $maxCount, ClientValidationException::class, "Maximum first value is {$maxCount}. Got {$first}. Fetch in smaller chuncks.");
Validator::make(['first' => $first], [
'first' => [
'required', 'integer', 'min:1',
function ($attribute, $value, $fail) use ($maxCount, $first): void {
if ($maxCount !== null && $value > $maxCount) {
$fail("You may request at most {$maxCount} items. Got {$first}. Fetch in smaller chuncks.");
}
},
],
])->validate();
return $builder->paginate($first, page: $page);
}
@@ -44,7 +44,7 @@ class DatabaseSyncCommand extends BaseCommand
}
if (! $this->option('drop')) {
Schema::withoutForeignKeyConstraints(function () {
Schema::withoutForeignKeyConstraints(function (): void {
foreach ([
...DumpDocumentAction::allowedTables(),
...DumpWikiAction::allowedTables(),
@@ -1,18 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\GraphQL;
use GraphQL\Error\Error;
/**
* Thrown when client arguments are missing or wrong.
*/
class ClientValidationException extends Error
{
public function isClientSafe(): bool
{
return true;
}
}
+1 -9
View File
@@ -11,8 +11,6 @@ use App\GraphQL\Schema\Mutations\BaseMutation;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Rebing\GraphQL\Error\ValidationError;
/**
* @template TModel of \Illuminate\Database\Eloquent\Model
@@ -37,8 +35,6 @@ abstract class BaseController
*
* @param class-string<BaseMutation> $mutation
* @return array<string, mixed>
*
* @throws ValidationError
*/
public function validated(array $args, string $mutation): array
{
@@ -46,11 +42,7 @@ abstract class BaseController
$validator = Validator::make($args, $mutationInstance->rulesForValidation($args));
try {
$validated = $validator->validated();
} catch (ValidationException $e) {
throw new ValidationError($e->getMessage(), $validator);
}
$validated = $validator->validated();
return [
...$validated,
@@ -5,12 +5,12 @@ declare(strict_types=1);
namespace App\GraphQL\Controllers\User;
use App\Contracts\Models\Likeable;
use App\Exceptions\GraphQL\ClientValidationException;
use App\GraphQL\Controllers\BaseController;
use App\GraphQL\Schema\Mutations\Models\User\LikeMutation;
use App\GraphQL\Schema\Mutations\Models\User\UnlikeMutation;
use App\Models\User\Like;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Auth;
/**
@@ -23,41 +23,27 @@ class LikeController extends BaseController
/**
* @param array<string, mixed> $args
*
* @throws ClientValidationException
*/
public function store($root, array $args): Model&Likeable
public function store($root, array $args): Model
{
$validated = $this->validated($args, LikeMutation::class);
foreach ($validated as $likeable) {
if ($likeable instanceof Model && $likeable instanceof Likeable) {
$likeable->like(Auth::user());
/** @var Model&Likeable $likeable */
$likeable = Arr::first($validated);
return $likeable;
}
}
throw new ClientValidationException('One resource is required to like.');
return $likeable->like(Auth::user());
}
/**
* @param array<string, mixed> $args
*
* @throws ClientValidationException
*/
public function destroy($root, array $args): Model&Likeable
public function destroy($root, array $args): Model
{
$validated = $this->validated($args, UnlikeMutation::class);
foreach ($validated as $likeable) {
if ($likeable instanceof Model && $likeable instanceof Likeable) {
$likeable->unlike(Auth::user());
/** @var Model&Likeable $likeable */
$likeable = Arr::first($validated);
return $likeable;
}
}
throw new ClientValidationException('One resource is required to unlike.');
return $likeable->unlike(Auth::user());
}
}
@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\GraphQL\Controllers\Wiki\Anime;
use App\Actions\GraphQL\IndexAction;
use App\Exceptions\GraphQL\ClientValidationException;
use App\GraphQL\Controllers\BaseController;
use App\GraphQL\Schema\Fields\Wiki\Anime\AnimeYear\AnimeYearSeason\AnimeYearSeasonSeasonField;
use App\GraphQL\Schema\Fields\Wiki\Anime\AnimeYear\AnimeYearSeasonField;
@@ -19,6 +18,7 @@ use Illuminate\Contracts\Pagination\Paginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Validator;
/**
* @extends BaseController<Anime>
@@ -35,11 +35,20 @@ class AnimeYearsController extends BaseController
$fieldSelection = $resolveInfo->getFieldSelection(1);
// Restrict 'animes' field to a unique year.
throw_if(
($year === null || count($year) > 1) && (Arr::get($fieldSelection, 'season.anime') || Arr::get($fieldSelection, 'seasons.anime')),
ClientValidationException::class,
"Please provide a unique 'year' argument to query the animes field."
);
Validator::make(
[
'year' => $year,
],
[
'year' => [
function ($attribute, $value, $fail) use ($fieldSelection): void {
if (($value === null || count($value) > 1) && (Arr::get($fieldSelection, 'season.anime') || Arr::get($fieldSelection, 'seasons.anime'))) {
$fail('Exactly one year is required when requesting anime.');
}
},
],
]
)->validate();
return Anime::query()
->distinct([Anime::ATTRIBUTE_YEAR, Anime::ATTRIBUTE_SEASON])
+4 -10
View File
@@ -5,12 +5,10 @@ declare(strict_types=1);
namespace App\GraphQL\Filter;
use App\Enums\Http\Api\Filter\ComparisonOperator;
use App\Exceptions\GraphQL\ClientValidationException;
use App\GraphQL\Argument\Argument;
use App\GraphQL\Argument\FilterArgument;
use GraphQL\Type\Definition\Type;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
abstract class Filter
{
@@ -63,14 +61,10 @@ abstract class Filter
protected function validateFilterValues(array $filterValues): void
{
foreach ($filterValues as $filterValue) {
try {
Validator::make(
[$this->fieldName => $filterValue],
[$this->fieldName => $this->getRules()],
)->validate();
} catch (ValidationException $e) {
throw new ClientValidationException($e->getMessage());
}
Validator::make(
[$this->fieldName => $filterValue],
[$this->fieldName => $this->getRules()],
)->validate();
}
}
+50
View File
@@ -6,11 +6,18 @@ namespace App\GraphQL\Handler;
use Error as PhpError;
use Exception;
use GraphQL\Error\DebugFlag;
use GraphQL\Error\Error as GraphQLError;
use GraphQL\Error\FormattedError;
use GraphQL\Server\RequestError;
use Illuminate\Contracts\Debug\ExceptionHandler;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\Config;
use Illuminate\Validation\ValidationException;
use Rebing\GraphQL\Error\AuthorizationError;
use Rebing\GraphQL\Error\ProvidesErrorCategory;
use Rebing\GraphQL\Error\ValidationError;
use Throwable;
class ErrorHandler
{
@@ -51,4 +58,47 @@ class ErrorHandler
return array_map($formatter, $errors);
}
// /**
// * @return array<string,mixed>
// * @see ExecutionResult::setErrorFormatter
// */
public static function formatError(GraphQLError $e): array
{
$debug = Config::get('app.debug') ? (DebugFlag::INCLUDE_DEBUG_MESSAGE | DebugFlag::INCLUDE_TRACE) : DebugFlag::NONE;
$formatter = FormattedError::prepareFormatter(null, $debug);
$error = $formatter($e);
$previous = $e->getPrevious();
if ($previous instanceof Throwable) {
if ($previous instanceof ModelNotFoundException) {
$error['message'] = $previous->getMessage();
$error['extensions'] = [
'category' => 'model_not_found',
'model' => $previous->getModel(),
];
}
if ($previous instanceof ValidationException) {
$error['message'] = 'validation';
$error['extensions'] = [
'category' => 'validation',
'validation' => $previous->validator->errors()->getMessages(),
];
}
if ($previous instanceof ValidationError) {
$error['extensions']['validation'] = $previous->getValidatorMessages()->getMessages();
}
if ($previous instanceof ProvidesErrorCategory) {
$error['extensions']['category'] = $previous->getCategory();
}
} elseif ($e instanceof ProvidesErrorCategory) {
$error['extensions']['category'] = $e->getCategory();
}
return $error;
}
}
@@ -48,6 +48,9 @@ class LikeAnimeThemeEntryField extends Field implements BindableField, Creatable
{
return [
Str::of('prohibits:')->append(LikeController::ATTRIBUTE_PLAYLIST)->__toString(),
'required_without_all:'.implode(',', [
LikeController::ATTRIBUTE_PLAYLIST,
]),
];
}
@@ -58,6 +61,9 @@ class LikeAnimeThemeEntryField extends Field implements BindableField, Creatable
{
return [
Str::of('prohibits:')->append(LikeController::ATTRIBUTE_PLAYLIST)->__toString(),
'required_without_all:'.implode(',', [
LikeController::ATTRIBUTE_PLAYLIST,
]),
];
}
}
@@ -48,6 +48,9 @@ class LikePlaylistField extends Field implements BindableField, CreatableField,
{
return [
Str::of('prohibits:')->append(LikeController::ATTRIBUTE_ENTRY)->__toString(),
'required_without_all:'.implode(',', [
LikeController::ATTRIBUTE_ENTRY,
]),
];
}
@@ -58,6 +61,9 @@ class LikePlaylistField extends Field implements BindableField, CreatableField,
{
return [
Str::of('prohibits:')->append(LikeController::ATTRIBUTE_ENTRY)->__toString(),
'required_without_all:'.implode(',', [
LikeController::ATTRIBUTE_ENTRY,
]),
];
}
}
@@ -10,10 +10,11 @@ use App\GraphQL\Controllers\User\LikeController;
use App\GraphQL\Schema\Fields\Field;
use App\GraphQL\Schema\Mutations\BaseMutation;
use App\GraphQL\Schema\Types\User\LikeType;
use App\GraphQL\Schema\Unions\LikedUnion;
use App\Models\User\Like;
use GraphQL\Type\Definition\ResolveInfo;
use GraphQL\Type\Definition\Type;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Gate;
class LikeMutation extends BaseMutation
{
@@ -27,6 +28,11 @@ class LikeMutation extends BaseMutation
return 'Like a model';
}
public function authorize($root, array $args, $ctx, ?ResolveInfo $resolveInfo = null, $selectFields = null): bool
{
return ($this->response = Gate::inspect('create', [Like::class, ...[]]))->allowed();
}
/**
* Get the arguments for the like mutation.
*
@@ -56,9 +62,9 @@ class LikeMutation extends BaseMutation
/**
* The base return type of the mutation.
*/
public function baseType(): LikedUnion
public function baseType(): LikeType
{
return new LikedUnion();
return new LikeType();
}
public function type(): Type
@@ -10,10 +10,11 @@ use App\GraphQL\Controllers\User\LikeController;
use App\GraphQL\Schema\Fields\Field;
use App\GraphQL\Schema\Mutations\BaseMutation;
use App\GraphQL\Schema\Types\User\LikeType;
use App\GraphQL\Schema\Unions\LikedUnion;
use App\Models\User\Like;
use GraphQL\Type\Definition\ResolveInfo;
use GraphQL\Type\Definition\Type;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Gate;
class UnlikeMutation extends BaseMutation
{
@@ -27,6 +28,11 @@ class UnlikeMutation extends BaseMutation
return 'Unlike a model';
}
public function authorize($root, array $args, $ctx, ?ResolveInfo $resolveInfo = null, $selectFields = null): bool
{
return ($this->response = Gate::inspect('delete', [Like::class, ...[]]))->allowed();
}
/**
* Get the arguments for the unlike mutation.
*
@@ -56,9 +62,9 @@ class UnlikeMutation extends BaseMutation
/**
* The base return type of the mutation.
*/
public function baseType(): LikedUnion
public function baseType(): LikeType
{
return new LikedUnion();
return new LikeType();
}
public function type(): Type
@@ -8,8 +8,8 @@ use App\GraphQL\Argument\Argument;
use App\GraphQL\Schema\Queries\BaseQuery;
use App\GraphQL\Schema\Types\Admin\FeaturedThemeType;
use App\Models\Admin\FeaturedTheme;
use Closure;
use GraphQL\Type\Definition\ResolveInfo;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Date;
class CurrentFeaturedThemeQuery extends BaseQuery
@@ -48,7 +48,7 @@ class CurrentFeaturedThemeQuery extends BaseQuery
* @param array<string, mixed> $args
* @return FeaturedTheme|null
*/
public function resolve($root, array $args, $context, ResolveInfo $resolveInfo, Closure $getSelectFields)
public function resolve($root, array $args, $context, ResolveInfo $resolveInfo): ?Model
{
$builder = FeaturedTheme::query();
@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\GraphQL\Schema\Queries\Wiki;
use App\Enums\Models\Wiki\ResourceSite;
use App\Exceptions\GraphQL\ClientValidationException;
use App\GraphQL\Argument\Argument;
use App\GraphQL\Schema\Queries\BaseQuery;
use App\GraphQL\Schema\Types\Wiki\AnimeType;
@@ -18,6 +17,8 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rules\Enum;
use Rebing\GraphQL\Support\Facades\GraphQL;
class FindAnimeByExternalSiteQuery extends BaseQuery
@@ -71,16 +72,18 @@ class FindAnimeByExternalSiteQuery extends BaseQuery
*/
public function resolve($root, array $args, $context, ResolveInfo $resolveInfo)
{
Validator::make($args, [
self::ATTRIBUTE_SITE => ['required', new Enum(ResourceSite::class)],
self::ATTRIBUTE_ID => ['required_without:link', 'max:100'],
self::ATTRIBUTE_LINK => ['required_without:id'],
])->validate();
$builder = Anime::query();
$site = Arr::get($args, self::ATTRIBUTE_SITE);
$externalId = Arr::get($args, self::ATTRIBUTE_ID);
$link = Arr::get($args, self::ATTRIBUTE_LINK);
throw_if(is_null($externalId) && is_null($link), ClientValidationException::class, 'At least "id" or "link" is required.');
throw_if($externalId !== null && count($externalId) > 100, ClientValidationException::class, 'The "Id" parameter cannot contain more than 100 integer values.');
$builder->whereRelation(Anime::RELATION_RESOURCES, function (Builder $query) use ($site, $externalId, $link): void {
$query->where(ExternalResource::ATTRIBUTE_SITE, $site);
+1 -1
View File
@@ -128,7 +128,7 @@ abstract class Relation
/** @var Collection $collection */
$collection = $root->{$this->getRelationName()};
$first = Arr::get($args, 'first');
$first = max(1, Arr::integer($args, 'first'));
$page = Arr::get($args, 'page');
return new LengthAwarePaginator(
+2
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\GraphQL\Schema\Types;
use App\Contracts\GraphQL\Fields\DeprecatedField;
use App\Contracts\GraphQL\Fields\DisplayableField;
use App\GraphQL\Schema\Fields\Field;
use App\GraphQL\Schema\Relations\Relation;
use Illuminate\Support\Str;
@@ -77,6 +78,7 @@ abstract class BaseType extends RebingType
]);
$fields = collect($this->fieldClasses())
->filter(fn (Field $field): bool => $field instanceof DisplayableField && $field->canBeDisplayed())
->mapWithKeys(fn (Field $field): array => [
$field->getName() => [
'type' => $field->type(),
+7
View File
@@ -12,6 +12,13 @@ use Illuminate\Auth\Access\Response;
class LikePolicy extends BasePolicy
{
public function create(User $user): Response
{
return $user->can(CrudPermission::DELETE->format(Like::class))
? Response::allow()
: Response::deny();
}
public function delete(User $user, mixed $value): Response
{
return $user->can(CrudPermission::DELETE->format(Like::class))
+1 -1
View File
@@ -89,7 +89,7 @@ return [
// 'message' => '',
// 'locations' => []
// ]
'error_formatter' => [Rebing\GraphQL\GraphQL::class, 'formatError'],
'error_formatter' => [App\GraphQL\Handler\ErrorHandler::class, 'formatError'],
/*
* Custom Error Handling