This commit is contained in:
Your Name
2021-07-26 19:46:18 +02:00
parent e7a49138bb
commit aae17f10a6
818 changed files with 70695 additions and 16408 deletions
@@ -0,0 +1,156 @@
<?php
namespace Nuwave\Lighthouse\Validation;
use GraphQL\Language\AST\FieldDefinitionNode;
use GraphQL\Language\AST\InputValueDefinitionNode;
use GraphQL\Language\AST\ObjectTypeDefinitionNode;
use Nuwave\Lighthouse\Exceptions\DefinitionException;
use Nuwave\Lighthouse\Schema\AST\DocumentAST;
use Nuwave\Lighthouse\Schema\Directives\BaseDirective;
use Nuwave\Lighthouse\Support\Contracts\ArgManipulator;
use Nuwave\Lighthouse\Support\Contracts\ArgumentValidation;
abstract class BaseRulesDirective extends BaseDirective implements ArgumentValidation, ArgManipulator
{
public function rules(): array
{
$rules = $this->directiveArgValue('apply');
// Custom rules may be referenced through their fully qualified class name.
// The Laravel validator expects a class instance to be passed, so we
// resolve any given rule where a corresponding class exists.
foreach ($rules as $key => $rule) {
if (class_exists($rule)) {
$rules[$key] = app($rule);
}
}
return $rules;
}
public function messages(): array
{
$messages = $this->directiveArgValue('messages');
if ($messages === null) {
return [];
}
if (isset($messages[0])) {
/** @var array<string, string> $flattened */
$flattened = [];
/**
* We know this holds true, because it has been validated before.
*
* @var array{rule: string, message: string} $messageMap
*/
foreach ($messages as $messageMap) {
$flattened[$messageMap['rule']] = $messageMap['message'];
}
return $flattened;
}
return $messages;
}
public function attribute(): ?string
{
return $this->directiveArgValue('attribute');
}
public function manipulateArgDefinition(
DocumentAST &$documentAST,
InputValueDefinitionNode &$argDefinition,
FieldDefinitionNode &$parentField,
ObjectTypeDefinitionNode &$parentType
) {
$this->validateRulesArg();
$this->validateMessageArg();
}
protected function validateRulesArg(): void
{
$rules = $this->directiveArgValue('apply');
if (! is_array($rules)) {
$this->invalidApplyArgument($rules);
}
if (count($rules) === 0) {
$this->invalidApplyArgument($rules);
}
foreach ($rules as $rule) {
if (! is_string($rule)) {
$this->invalidApplyArgument($rules);
}
}
}
protected function validateMessageArg(): void
{
$messages = $this->directiveArgValue('messages');
if ($messages === null) {
return;
}
if (! is_array($messages)) {
$this->invalidMessageArgument($messages);
}
if (isset($messages[0])) {
foreach ($messages as $messageMap) {
if (! is_array($messageMap)) {
$this->invalidMessageArgument($messages);
}
$rule = $messageMap['rule'] ?? null;
if (! is_string($rule)) {
$this->invalidMessageArgument($messages);
}
$message = $messageMap['message'] ?? null;
if (! is_string($message)) {
$this->invalidMessageArgument($messages);
}
}
} else {
foreach ($messages as $rule => $message) {
if (! is_string($rule)) {
$this->invalidMessageArgument($messages);
}
if (! is_string($message)) {
$this->invalidMessageArgument($messages);
}
}
}
}
/**
* @param mixed $messages Whatever faulty value was given for messages
* @throws DefinitionException
*/
protected function invalidMessageArgument($messages): void
{
$encoded = \Safe\json_encode($messages);
throw new DefinitionException(
"The `messages` argument of @`{$this->name()}` on `{$this->nodeName()} must be a list of input values with the string keys `rule` and `message`, got: {$encoded}"
);
}
/**
* @param mixed $apply Any invalid value
*
* @throws \Nuwave\Lighthouse\Exceptions\DefinitionException
*/
protected function invalidApplyArgument($apply): void
{
$encoded = \Safe\json_encode($apply);
throw new DefinitionException(
"The `apply` argument of @`{$this->name()}` on `{$this->nodeName()}` has to be a list of strings, got: {$encoded}"
);
}
}
@@ -0,0 +1,53 @@
<?php
namespace Nuwave\Lighthouse\Validation;
use Nuwave\Lighthouse\Support\Contracts\ArgDirective;
class RulesDirective extends BaseRulesDirective implements ArgDirective
{
public static function definition(): string
{
return /** @lang GraphQL */ <<<'GRAPHQL'
"""
Validate an argument using [Laravel validation](https://laravel.com/docs/validation).
"""
directive @rules(
"""
Specify the validation rules to apply to the field.
This can either be a reference to [Laravel's built-in validation rules](https://laravel.com/docs/validation#available-validation-rules),
or the fully qualified class name of a custom validation rule.
Rules that mutate the incoming arguments, such as `exclude_if`, are not supported
by Lighthouse. Use ArgTransformerDirectives or FieldMiddlewareDirectives instead.
"""
apply: [String!]!
"""
Specify a custom attribute name to use in your validation message.
"""
attribute: String
"""
Specify the messages to return if the validators fail.
"""
messages: [RulesMessage!]
) repeatable on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION
"""
Input for the `messages` argument of `@rules`.
"""
input RulesMessage {
"""
Name of the rule, e.g. `"email"`.
"""
rule: String!
"""
Message to display if the rule fails, e.g. `"Must be a valid email"`.
"""
message: String!
}
GRAPHQL;
}
}
@@ -0,0 +1,62 @@
<?php
namespace Nuwave\Lighthouse\Validation;
use Illuminate\Support\Arr;
use Nuwave\Lighthouse\Support\Contracts\ArgDirectiveForArray;
class RulesForArrayDirective extends BaseRulesDirective implements ArgDirectiveForArray
{
public static function definition(): string
{
return /** @lang GraphQL */ <<<'GRAPHQL'
"""
Run validation on an array itself, using [Laravel built-in validation](https://laravel.com/docs/validation).
"""
directive @rulesForArray(
"""
Specify the validation rules to apply to the field.
This can either be a reference to any of Laravel's built-in validation rules: https://laravel.com/docs/validation#available-validation-rules,
or the fully qualified class name of a custom validation rule.
"""
apply: [String!]!
"""
Specify a custom attribute name to use in your validation message.
"""
attribute: String
"""
Specify the messages to return if the validators fail.
"""
messages: [RulesForArrayMessage!]
) repeatable on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION
"""
Input for the `messages` argument of `@rulesForArray`.
"""
input RulesForArrayMessage {
"""
Name of the rule, e.g. `"email"`.
"""
rule: String!
"""
Message to display if the rule fails, e.g. `"Must be a valid email"`.
"""
message: String!
}
GRAPHQL;
}
public function rules(): array
{
$rules = parent::rules();
if (! in_array('array', $rules)) {
$rules = Arr::prepend($rules, 'array');
}
return $rules;
}
}
@@ -0,0 +1,280 @@
<?php
namespace Nuwave\Lighthouse\Validation;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Support\Collection;
use Illuminate\Validation\ValidationRuleParser;
use Nuwave\Lighthouse\Execution\Arguments\ArgumentSet;
use Nuwave\Lighthouse\Execution\Arguments\ListType;
use Nuwave\Lighthouse\Support\Contracts\ArgDirective;
use Nuwave\Lighthouse\Support\Contracts\ArgDirectiveForArray;
use Nuwave\Lighthouse\Support\Contracts\ArgumentSetValidation;
use Nuwave\Lighthouse\Support\Contracts\ArgumentValidation;
use Nuwave\Lighthouse\Support\Traits\HasArgumentValue;
use Nuwave\Lighthouse\Support\Utils;
class RulesGatherer
{
/**
* The gathered rules.
*
* @var array<string, mixed>
*/
public $rules = [];
/**
* The gathered messages.
*
* @var array<string, mixed>
*/
public $messages = [];
/**
* The gathered attributes.
*
* @var array<string, string>
*/
public $attributes = [];
public function __construct(ArgumentSet $argumentSet)
{
$this->gatherRulesRecursively($argumentSet, []);
}
/**
* @param array<int|string> $argumentPath
*/
public function gatherRulesRecursively(ArgumentSet $argumentSet, array $argumentPath): void
{
$this->gatherRulesForArgumentSet($argumentSet, $argumentSet->directives, $argumentPath);
$argumentsWithUndefined = $argumentSet->argumentsWithUndefined();
foreach ($argumentsWithUndefined as $name => $argument) {
$nestedPath = array_merge($argumentPath, [$name]);
$directivesForArray = $argument->directives->filter(
Utils::instanceofMatcher(ArgDirectiveForArray::class)
);
$this->gatherRulesForArgument($argument, $directivesForArray, $nestedPath);
$directivesForArgument = $argument->directives->filter(
Utils::instanceofMatcher(ArgDirective::class)
);
if (
$argument->type instanceof ListType
&& is_array($argument->value)
) {
foreach ($argument->value as $index => $value) {
$this->handleArgumentValue($value, $directivesForArgument, array_merge($nestedPath, [$index]));
}
} else {
$this->handleArgumentValue($argument->value, $directivesForArgument, $nestedPath);
}
}
}
/**
* @param \Illuminate\Support\Collection<\Nuwave\Lighthouse\Support\Contracts\Directive> $directives
* @param array<int|string> $path
*/
public function gatherRulesForArgumentSet(ArgumentSet $argumentSet, Collection $directives, array $path): void
{
foreach ($directives as $directive) {
if ($directive instanceof ArgumentSetValidation) {
if (Utils::classUsesTrait($directive, HasArgumentValue::class)) {
/**
* @psalm-suppress UndefinedDocblockClass
* @var \Nuwave\Lighthouse\Support\Contracts\Directive&\Nuwave\Lighthouse\Support\Contracts\ArgumentSetValidation&\Nuwave\Lighthouse\Support\Traits\HasArgumentValue $directive
*/
// @phpstan-ignore-next-line using trait in typehint
$directive->setArgumentValue($argumentSet);
}
$this->extractValidationForArgumentSet($directive, $path);
}
}
}
/**
* @param mixed $value Any argument value is possible
* @param \Illuminate\Support\Collection<\Nuwave\Lighthouse\Support\Contracts\Directive> $directives
* @param array<int|string> $path
*/
public function gatherRulesForArgument($value, Collection $directives, array $path): void
{
foreach ($directives as $directive) {
if ($directive instanceof ArgumentValidation) {
if (Utils::classUsesTrait($directive, HasArgumentValue::class)) {
/**
* @psalm-suppress UndefinedDocblockClass
* @var \Nuwave\Lighthouse\Support\Contracts\Directive&\Nuwave\Lighthouse\Support\Contracts\ArgumentValidation&\Nuwave\Lighthouse\Support\Traits\HasArgumentValue $directive
*/
// @phpstan-ignore-next-line using trait in typehint
$directive->setArgumentValue($value);
}
$this->extractValidationForArgument($directive, $path);
}
}
}
/**
* @param \Nuwave\Lighthouse\Execution\Arguments\Argument|\Nuwave\Lighthouse\Execution\Arguments\ArgumentSet|mixed $value
* @param \Illuminate\Support\Collection<\Nuwave\Lighthouse\Support\Contracts\Directive> $directives
* @param array<int|string> $path
*/
protected function handleArgumentValue($value, Collection $directives, array $path): void
{
$this->gatherRulesForArgument($value, $directives, $path);
if ($value instanceof ArgumentSet) {
$this->gatherRulesRecursively($value, $path);
}
}
/**
* @param array<int|string> $argumentPath
*/
public function extractValidationForArgumentSet(ArgumentSetValidation $directive, array $argumentPath): void
{
$qualifiedRulesMap = array_map(
function (array $rules) use ($argumentPath): array {
return $this->qualifyArgumentReferences($rules, $argumentPath);
},
$directive->rules()
);
$this->rules = array_merge_recursive(
$this->rules,
$this->wrap($qualifiedRulesMap, $argumentPath)
);
$this->messages += $this->wrap($directive->messages(), $argumentPath);
$this->attributes = array_merge(
$this->attributes,
$this->wrap($directive->attributes(), $argumentPath)
);
}
/**
* @param array<int|string> $argumentPath
*/
public function extractValidationForArgument(ArgumentValidation $directive, array $argumentPath): void
{
$qualifiedRules = $this->qualifyArgumentReferences(
$directive->rules(),
// The last element is the name of the argument the rule is defined upon.
// We want the qualified path to start from the parent level.
array_slice($argumentPath, 0, -1)
);
$this->rules = array_merge_recursive(
$this->rules,
[implode('.', $argumentPath) => $qualifiedRules]
);
$this->messages += $this->wrap($directive->messages(), $argumentPath);
$attribute = $directive->attribute();
if (null !== $attribute) {
$this->attributes = array_merge(
$this->attributes,
[implode('.', $argumentPath) => $attribute]
);
}
}
/**
* @param array<string, mixed> $rulesOrMessages
* @param array<int|string> $path
* @return array<string, mixed>
*/
protected function wrap(array $rulesOrMessages, array $path): array
{
$withPath = [];
foreach ($rulesOrMessages as $key => $value) {
$combinedPath = implode('.', array_merge($path, [$key]));
$withPath[$combinedPath] = $value;
}
return $withPath;
}
/**
* Prepend rule arguments that refer to other arguments with the full path.
*
* This may be necessary to allow certain rules to be reusable when placed
* upon input arguments. For example, `required_with:foo` may be defined
* on an input value that is nested within the arguments under `input.0`.
* It is thus changed to the full reference `required_with:input.0.foo`.
*
* @param array<int, mixed> $rules
* @param array<int|string> $argumentPath
* @return array<int, array<int, mixed>|object>
*/
protected function qualifyArgumentReferences(array $rules, array $argumentPath): array
{
return array_map(
static function ($rule) use ($argumentPath) {
if (is_object($rule)) {
return $rule;
}
/**
* @var array{
* 0: string,
* 1: array<int, mixed>,
* } $parsed
*/
$parsed = ValidationRuleParser::parse($rule);
$name = $parsed[0];
$args = $parsed[1];
// Those rule lists are a subset of https://github.com/illuminate/validation/blob/8079fd53dee983e7c52d1819ae3b98c71a64fbc0/Validator.php#L206-L236
// using the docs to know which ones reference other fields: https://laravel.com/docs/8.x/validation#available-validation-rules
// We do not handle the Exclude* rules, those mutate the input and are not supported.
// Rules where the first argument is a field reference
if (in_array($name, [
'Different',
'Gt',
'Gte',
'Lt',
'Lte',
'RequiredIf',
'RequiredUnless',
'ProhibitedIf',
'ProhibitedUnless',
'Same',
])) {
$args[0] = implode('.', array_merge($argumentPath, [$args[0]]));
}
// Rules where all arguments are field references
if (in_array($name, [
'RequiredWith',
'RequiredWithAll',
'RequiredWithout',
'RequiredWithoutAll',
])) {
$args = array_map(
static function (string $field) use ($argumentPath): string {
return implode('.', array_merge($argumentPath, [$field]));
},
$args
);
}
// Laravel expects the rule to be a flat array of name, arg1, arg2, ...
return array_merge([$name], $args);
},
$rules
);
}
}
@@ -0,0 +1,65 @@
<?php
namespace Nuwave\Lighthouse\Validation;
use Closure;
use GraphQL\Type\Definition\ResolveInfo;
use Illuminate\Contracts\Validation\Factory as ValidationFactory;
use Nuwave\Lighthouse\Exceptions\ValidationException;
use Nuwave\Lighthouse\Schema\Directives\BaseDirective;
use Nuwave\Lighthouse\Schema\Values\FieldValue;
use Nuwave\Lighthouse\Support\Contracts\FieldMiddleware;
use Nuwave\Lighthouse\Support\Contracts\GraphQLContext;
class ValidateDirective extends BaseDirective implements FieldMiddleware
{
/**
* @var \Illuminate\Contracts\Validation\Factory
*/
protected $validationFactory;
public function __construct(ValidationFactory $validationFactory)
{
$this->validationFactory = $validationFactory;
}
public static function definition(): string
{
return /** @lang GraphQL */ <<<'GRAPHQL'
"""
Run validation on a field.
"""
directive @validate on FIELD_DEFINITION
GRAPHQL;
}
public function handleField(FieldValue $fieldValue, Closure $next): FieldValue
{
$resolver = $fieldValue->getResolver();
return $next(
$fieldValue->setResolver(
function ($root, array $args, GraphQLContext $context, ResolveInfo $resolveInfo) use ($resolver) {
$argumentSet = $resolveInfo->argumentSet;
$rulesGatherer = new RulesGatherer($argumentSet);
$validator = $this->validationFactory
->make(
$args,
$rulesGatherer->rules,
$rulesGatherer->messages,
$rulesGatherer->attributes
);
if ($validator->fails()) {
$path = implode('.', $resolveInfo->path);
throw new ValidationException("Validation failed for the field [$path].", $validator);
}
return $resolver($root, $args, $context, $resolveInfo);
}
)
);
}
}
@@ -0,0 +1,20 @@
<?php
namespace Nuwave\Lighthouse\Validation;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Support\ServiceProvider;
use Nuwave\Lighthouse\Events\RegisterDirectiveNamespaces;
class ValidationServiceProvider extends ServiceProvider
{
public function boot(Dispatcher $dispatcher): void
{
$dispatcher->listen(
RegisterDirectiveNamespaces::class,
static function (): string {
return __NAMESPACE__;
}
);
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace Nuwave\Lighthouse\Validation;
use Illuminate\Support\Arr;
use Nuwave\Lighthouse\Execution\Arguments\ArgumentSet;
use Nuwave\Lighthouse\Support\Contracts\ArgumentSetValidation;
abstract class Validator implements ArgumentSetValidation
{
/**
* The slice of incoming arguments to validate.
*
* @var \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet
*/
protected $args;
public function messages(): array
{
return [];
}
public function attributes(): array
{
return [];
}
/**
* Set the slice of args to validate.
*/
public function setArgs(ArgumentSet $args): void
{
$this->args = $args;
}
/**
* Retrieve the value of an argument.
*
* @param string $key The key of the argument, may use dot notation to get nested values.
* @param mixed|null $default Returned in case the argument is not present.
* @return mixed The value of the argument or the default.
*/
protected function arg(string $key, $default = null)
{
return Arr::get(
$this->args->toArray(),
$key,
$default
);
}
}
@@ -0,0 +1,156 @@
<?php
namespace Nuwave\Lighthouse\Validation;
use GraphQL\Language\AST\FieldDefinitionNode;
use GraphQL\Language\AST\InputObjectTypeDefinitionNode;
use GraphQL\Language\AST\Node;
use GraphQL\Language\AST\ObjectTypeDefinitionNode;
use GraphQL\Language\AST\TypeDefinitionNode;
use GraphQL\Language\Parser;
use Nuwave\Lighthouse\Exceptions\DefinitionException;
use Nuwave\Lighthouse\Schema\AST\ASTHelper;
use Nuwave\Lighthouse\Schema\AST\DocumentAST;
use Nuwave\Lighthouse\Schema\Directives\BaseDirective;
use Nuwave\Lighthouse\Support\Contracts\ArgDirective;
use Nuwave\Lighthouse\Support\Contracts\ArgumentSetValidation;
use Nuwave\Lighthouse\Support\Contracts\FieldManipulator;
use Nuwave\Lighthouse\Support\Contracts\TypeManipulator;
use Nuwave\Lighthouse\Support\Traits\HasArgumentValue;
class ValidatorDirective extends BaseDirective implements ArgDirective, ArgumentSetValidation, TypeManipulator, FieldManipulator
{
use HasArgumentValue;
/**
* @var \Nuwave\Lighthouse\Validation\Validator|null
*/
protected $validator;
public static function definition(): string
{
return /** @lang GraphQL */ <<<'GRAPHQL'
"""
Provide validation rules through a PHP class.
"""
directive @validator(
"""
The name of the class to use.
If defined on an input, this defaults to a class called `{$inputName}Validator` in the
default validator namespace. For fields, it uses the name of the parent type
and the field name: `{$parent}{$field}Validator`.
"""
class: String
) repeatable on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION | FIELD_DEFINITION | INPUT_OBJECT
GRAPHQL;
}
public function rules(): array
{
return $this->validator()->rules();
}
public function messages(): array
{
return $this->validator()->messages();
}
public function attributes(): array
{
return $this->validator()->attributes();
}
protected function validator(): Validator
{
if ($this->validator === null) {
/** @var \Nuwave\Lighthouse\Validation\Validator $validator */
$validator = app(
// We precomputed and validated the full class name at schema build time
$this->directiveArgValue('class')
);
// @phpstan-ignore-next-line Since this directive can only be defined on a field or input, this must be ArgumentSet
$validator->setArgs($this->argumentValue);
return $this->validator = $validator;
}
return $this->validator;
}
public function manipulateTypeDefinition(DocumentAST &$documentAST, TypeDefinitionNode &$typeDefinition)
{
if (! $typeDefinition instanceof InputObjectTypeDefinitionNode) {
throw new DefinitionException(
"Can not use @validator on non input type {$typeDefinition->name->value}."
);
}
if ($this->directiveHasArgument('class')) {
$classCandidate = $this->directiveArgValue('class');
} else {
$classCandidate = $typeDefinition->name->value.'Validator';
}
$this->setFullClassnameOnDirective($typeDefinition, $classCandidate);
}
public function manipulateFieldDefinition(
DocumentAST &$documentAST,
FieldDefinitionNode &$fieldDefinition,
ObjectTypeDefinitionNode &$parentType
) {
if ($this->directiveHasArgument('class')) {
$classCandidate = $this->directiveArgValue('class');
} else {
$classCandidate = $parentType->name->value
.'\\'
.ucfirst($fieldDefinition->name->value)
.'Validator';
}
$this->setFullClassnameOnDirective($fieldDefinition, $classCandidate);
}
/**
* Set the full classname of the validator class on the directive.
*
* This allows accessing it straight away when resolving the query.
*
* @param (\GraphQL\Language\AST\TypeDefinitionNode&\GraphQL\Language\AST\Node)|\GraphQL\Language\AST\FieldDefinitionNode $definition
*/
protected function setFullClassnameOnDirective(Node &$definition, string $classCandidate): void
{
$validatorClass = $this->namespaceValidatorClass($classCandidate);
// @phpstan-ignore-next-line The passed in Node types all have the property $directives
foreach ($definition->directives as $directive) {
if ($directive->name->value === $this->name()) {
$directive->arguments = ASTHelper::mergeUniqueNodeList(
$directive->arguments,
[Parser::argument('class: "'.addslashes($validatorClass).'"')],
true
);
}
}
}
/**
* @return class-string<\Nuwave\Lighthouse\Validation\Validator>
*/
protected function namespaceValidatorClass(string $classCandidate): string
{
/**
* @var class-string<\Nuwave\Lighthouse\Validation\Validator> $validatorClassName We know this because of the callback
*/
$validatorClassName = $this->namespaceClassName(
$classCandidate,
(array) config('lighthouse.namespaces.validators'),
function (string $classCandidate): bool {
return is_subclass_of($classCandidate, Validator::class);
}
);
return $validatorClassName;
}
}