diff --git a/src/Definition/GraphQLServices.php b/src/Definition/GraphQLServices.php index 6abfd0694..85aa194e9 100644 --- a/src/Definition/GraphQLServices.php +++ b/src/Definition/GraphQLServices.php @@ -4,11 +4,13 @@ namespace Overblog\GraphQLBundle\Definition; +use GraphQL\Type\Definition\ResolveInfo; use GraphQL\Type\Definition\Type; use LogicException; use Overblog\GraphQLBundle\Resolver\MutationResolver; use Overblog\GraphQLBundle\Resolver\QueryResolver; use Overblog\GraphQLBundle\Resolver\TypeResolver; +use Overblog\GraphQLBundle\Validator\InputValidator; /** * Container for special services to be passed to all generated types. @@ -81,4 +83,17 @@ public function getType(string $typeName): ?Type { return $this->types->resolve($typeName); } + + /** + * Creates an instance of InputValidator + * + * @param mixed $value + * @param mixed $context + */ + public function createInputValidator($value, ArgumentInterface $args, $context, ResolveInfo $info): InputValidator + { + return $this->services['input_validator_factory']->create( + new ResolverArgs($value, $args, $context, $info) + ); + } } diff --git a/src/Definition/ResolverArgs.php b/src/Definition/ResolverArgs.php new file mode 100644 index 000000000..6cb4b9687 --- /dev/null +++ b/src/Definition/ResolverArgs.php @@ -0,0 +1,29 @@ +value = $value; + $this->args = $args; + $this->context = $context; + $this->info = $info; + } +} diff --git a/src/DependencyInjection/Compiler/ConfigParserPass.php b/src/DependencyInjection/Compiler/ConfigParserPass.php index 6579a71cd..2750b5baa 100644 --- a/src/DependencyInjection/Compiler/ConfigParserPass.php +++ b/src/DependencyInjection/Compiler/ConfigParserPass.php @@ -177,6 +177,7 @@ private function mappingConfig(array $config, ContainerBuilder $container): arra // app only config files (yml or xml or graphql) if ($mappingConfig['auto_discover']['root_dir'] && $container->hasParameter('kernel.root_dir')) { + // @phpstan-ignore-next-line $typesMappings[] = ['dir' => $container->getParameter('kernel.root_dir').'/config/graphql', 'types' => null]; } if ($mappingConfig['auto_discover']['bundles']) { @@ -212,10 +213,12 @@ function (array $typeMapping) use ($container) { private function mappingFromBundles(ContainerBuilder $container): array { $typesMappings = []; + + /** @var array $bundles */ $bundles = $container->getParameter('kernel.bundles'); // auto detect from bundle - foreach ($bundles as $name => $class) { + foreach ($bundles as $class) { // skip this bundle if (OverblogGraphQLBundle::class === $class) { continue; diff --git a/src/DependencyInjection/Compiler/ResolverMapTaggedServiceMappingPass.php b/src/DependencyInjection/Compiler/ResolverMapTaggedServiceMappingPass.php index 44ffcfa7c..49423b042 100644 --- a/src/DependencyInjection/Compiler/ResolverMapTaggedServiceMappingPass.php +++ b/src/DependencyInjection/Compiler/ResolverMapTaggedServiceMappingPass.php @@ -25,9 +25,11 @@ final class ResolverMapTaggedServiceMappingPass implements CompilerPassInterface public function process(ContainerBuilder $container): void { $resolverMapsSortedBySchema = []; - $resolverMapsBySchemas = $container->getParameter('overblog_graphql.resolver_maps'); $typeDecoratorListenerDefinition = $container->getDefinition(TypeDecoratorListener::class); + /** @var array $resolverMapsBySchemas */ + $resolverMapsBySchemas = $container->getParameter('overblog_graphql.resolver_maps'); + foreach ($container->findTaggedServiceIds(self::SERVICE_TAG, true) as $serviceId => $tags) { foreach ($tags as $tag) { if (!isset($tag['schema'])) { diff --git a/src/DependencyInjection/OverblogGraphQLExtension.php b/src/DependencyInjection/OverblogGraphQLExtension.php index 1d391e814..29b64a35b 100644 --- a/src/DependencyInjection/OverblogGraphQLExtension.php +++ b/src/DependencyInjection/OverblogGraphQLExtension.php @@ -23,7 +23,6 @@ use Overblog\GraphQLBundle\EventListener\ErrorHandlerListener; use Overblog\GraphQLBundle\EventListener\ErrorLoggerListener; use Overblog\GraphQLBundle\Request\Executor; -use Overblog\GraphQLBundle\Validator\ValidatorFactory; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -31,9 +30,7 @@ use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpKernel\DependencyInjection\Extension; -use Symfony\Component\Validator\Validation; use function array_fill_keys; -use function class_exists; use function realpath; use function sprintf; @@ -59,21 +56,22 @@ public function load(array $configs, ContainerBuilder $container): void $this->setCompilerCacheWarmer($config, $container); $this->registerForAutoconfiguration($container); $this->setDefaultFieldResolver($config, $container); - $this->registerValidatorFactory($container); $container->setParameter($this->getAlias().'.config', $config); $container->setParameter($this->getAlias().'.resources_dir', realpath(__DIR__.'/../Resources')); } - public function getAlias() + public function getAlias(): string { return Configuration::NAME; } - public function getConfiguration(array $config, ContainerBuilder $container) + public function getConfiguration(array $config, ContainerBuilder $container): Configuration { return new Configuration( + // @phpstan-ignore-next-line $container->getParameter('kernel.debug'), + // @phpstan-ignore-next-line $container->hasParameter('kernel.cache_dir') ? $container->getParameter('kernel.cache_dir') : null ); } @@ -104,24 +102,6 @@ private function registerForAutoconfiguration(ContainerBuilder $container): void ->addTag('overblog_graphql.type'); } - private function registerValidatorFactory(ContainerBuilder $container): void - { - if (class_exists(Validation::class)) { - $container->register(ValidatorFactory::class) - ->setArguments([ - new Reference('validator.validator_factory'), - new Reference('translator.default', $container::NULL_ON_INVALID_REFERENCE), - ]) - ->addTag( - 'overblog_graphql.service', - [ - 'alias' => 'validatorFactory', - 'public' => false, - ] - ); - } - } - private function setDefaultFieldResolver(array $config, ContainerBuilder $container): void { $container->setAlias($this->getAlias().'.default_field_resolver', $config['definitions']['default_field_resolver']); @@ -295,7 +275,7 @@ private function setServicesAliases(array $config, ContainerBuilder $container): /** * Returns a list of custom exceptions mapped to error/warning classes. * - * @param array $exceptionConfig + * @param array> $exceptionConfig * * @return array Custom exception map, [exception => UserError/UserWarning] */ diff --git a/src/Generator/TypeBuilder.php b/src/Generator/TypeBuilder.php index 2f3e24b5f..3704a0556 100644 --- a/src/Generator/TypeBuilder.php +++ b/src/Generator/TypeBuilder.php @@ -16,7 +16,6 @@ use Murtukov\PHPCodeGenerator\Closure; use Murtukov\PHPCodeGenerator\Config; use Murtukov\PHPCodeGenerator\ConverterInterface; -use Murtukov\PHPCodeGenerator\Exception\UnrecognizedValueTypeException; use Murtukov\PHPCodeGenerator\GeneratorInterface; use Murtukov\PHPCodeGenerator\Instance; use Murtukov\PHPCodeGenerator\Literal; @@ -31,10 +30,7 @@ use Overblog\GraphQLBundle\Generator\Converter\ExpressionConverter; use Overblog\GraphQLBundle\Generator\Exception\GeneratorException; use Overblog\GraphQLBundle\Validator\InputValidator; -use function array_filter; -use function array_intersect; use function array_map; -use function array_replace_recursive; use function class_exists; use function count; use function explode; @@ -46,12 +42,10 @@ use function ltrim; use function reset; use function rtrim; -use function str_split; use function strpos; use function strrchr; use function strtolower; use function substr; -use function trim; /** * Service that exposes a single method `build` called for each GraphQL @@ -84,6 +78,7 @@ class TypeBuilder protected string $namespace; protected array $config; protected string $type; + protected string $currentField; protected string $gqlServices = '$'.TypeGenerator::GRAPHQL_SERVICES; public function __construct(ExpressionConverter $expressionConverter, string $namespace) @@ -112,7 +107,6 @@ public function __construct(ExpressionConverter $expressionConverter, string $na * } $config * * @throws GeneratorException - * @throws UnrecognizedValueTypeException */ public function build(array $config, string $type): PhpFile { @@ -290,7 +284,6 @@ protected function wrapTypeRecursive($typeNode, bool &$isReference) * ] * * @throws GeneratorException - * @throws UnrecognizedValueTypeException */ protected function buildConfig(array $config): Collection { @@ -358,7 +351,7 @@ protected function buildConfig(array $config): Collection } } - return $configLoader; // @phpstan-ignore-line + return $configLoader; } /** @@ -422,8 +415,8 @@ protected function buildScalarCallback($callback, string $fieldName) * Render example (with validation): * * function ($value, $args, $context, $info) use ($services) { - * $validator = {@see buildValidatorInstance} - * return $services->mutation("create_post", $validator); + * $validator = $services->createInputValidator(...func_get_args()); + * return $services->mutation("create_post", $validator]); * } * * Render example (with validation, but errors are injected into the user-defined resolver): @@ -431,7 +424,7 @@ protected function buildScalarCallback($callback, string $fieldName) * * function ($value, $args, $context, $info) use ($services) { * $errors = new ResolveErrors(); - * $validator = {@see buildValidatorInstance} + * $validator = $services->createInputValidator(...func_get_args()); * * $errors->setValidationErrors($validator->validate(null, false)) * @@ -441,36 +434,36 @@ protected function buildScalarCallback($callback, string $fieldName) * @param mixed $resolve * * @throws GeneratorException - * @throws UnrecognizedValueTypeException * * @return GeneratorInterface|string */ - protected function buildResolve($resolve, ?array $validationConfig = null) + protected function buildResolve($resolve, ?array $groups = null) { if (is_callable($resolve) && is_array($resolve)) { return Collection::numeric($resolve); } + // TODO: before creating an input validator, check if any validation rules are defined if (EL::isStringWithTrigger($resolve)) { $closure = Closure::new() ->addArguments('value', 'args', 'context', 'info') ->bindVar(TypeGenerator::GRAPHQL_SERVICES); - $injectErrors = EL::expressionContainsVar('errors', $resolve); + $injectValidator = EL::expressionContainsVar('validator', $resolve); - if ($injectErrors) { - $closure->append('$errors = ', Instance::new(ResolveErrors::class)); - } + if ($this->configContainsValidation()) { + $injectErrors = EL::expressionContainsVar('errors', $resolve); - $injectValidator = EL::expressionContainsVar('validator', $resolve); + if ($injectErrors) { + $closure->append('$errors = ', Instance::new(ResolveErrors::class)); + } - if (null !== $validationConfig) { - $closure->append('$validator = ', $this->buildValidatorInstance($validationConfig)); + $closure->append('$validator = ', "$this->gqlServices->createInputValidator(...func_get_args())"); // If auto-validation on or errors are injected if (!$injectValidator || $injectErrors) { - if (!empty($validationConfig['validationGroups'])) { - $validationGroups = Collection::numeric($validationConfig['validationGroups']); + if (!empty($groups)) { + $validationGroups = Collection::numeric($groups); } else { $validationGroups = 'null'; } @@ -485,12 +478,8 @@ protected function buildResolve($resolve, ?array $validationConfig = null) $closure->emptyLine(); } - } elseif (true === $injectValidator) { - throw new GeneratorException( - 'Unable to inject an instance of the InputValidator. No validation constraints provided. '. - 'Please remove the "validator" argument from the list of dependencies of your resolver '. - 'or provide validation configs.' - ); + } elseif ($injectValidator) { + throw new GeneratorException('Unable to inject an instance of the InputValidator. No validation constraints provided. Please remove the "validator" argument from the list of dependencies of your resolver or provide validation configs.'); } $closure->append('return ', $this->expressionConverter->convert($resolve)); @@ -502,35 +491,23 @@ protected function buildResolve($resolve, ?array $validationConfig = null) } /** - * Render example: - * - * new InputValidator( - * \func_get_args(), - * $services->get('container')->get('validator'), - * $services->get('validatorFactory'), - * {@see buildProperties}, - * {@see buildValidationRules}, - * ) - * - * @throws GeneratorException + * Checks if given config contains any validation rules. */ - protected function buildValidatorInstance(array $mapping): Instance + private function configContainsValidation(): bool { - $validator = Instance::new(InputValidator::class) - ->setMultiline() - ->addArgument(new Literal('\\func_get_args()')) - ->addArgument("$this->gqlServices->get('container')->get('validator')") - ->addArgument("$this->gqlServices->get('validatorFactory')"); - - if (!empty($mapping['properties'])) { - $validator->addArgument($this->buildProperties($mapping['properties'])); + $fieldConfig = $this->config['fields'][$this->currentField]; + + if (!empty($fieldConfig['validation'])) { + return true; } - if (!empty($mapping['class'])) { - $validator->addArgument($this->buildValidationRules($mapping['class'])); + foreach ($fieldConfig['args'] ?? [] as $argConfig) { + if (!empty($argConfig['validation'])) { + return true; + } } - return $validator; + return false; } /** @@ -538,7 +515,9 @@ protected function buildValidatorInstance(array $mapping): Instance * * [ * 'link' => {@see normalizeLink} - * 'cascade' => {@see buildCascade}, + * 'cascade' => [ + * 'groups' => ['my_group'], + * ], * 'constraints' => {@see buildConstraints} * ] * @@ -552,7 +531,7 @@ protected function buildValidatorInstance(array $mapping): Instance * * @throws GeneratorException */ - protected function buildValidationRules(array $config): Collection + protected function buildValidationRules(array $config): GeneratorInterface { // Convert to object for better readability $c = (object) $config; @@ -569,26 +548,32 @@ protected function buildValidationRules(array $config): Collection } } - if (!empty($c->cascade)) { - $array->addItem('cascade', $this->buildCascade($c->cascade)); + if (isset($c->cascade)) { + // If there are only constarainst, use short syntax + if (empty($c->cascade['groups'])) { + $this->file->addUse(InputValidator::class); + + return Literal::new('InputValidator::CASCADE'); + } + $array->addItem('cascade', $c->cascade['groups']); } if (!empty($c->constraints)) { - // If there are only constarainst, dont use additional nesting + // If there are only constarainst, use short syntax if (0 === $array->count()) { return $this->buildConstraints($c->constraints); } $array->addItem('constraints', $this->buildConstraints($c->constraints)); } - return $array; // @phpstan-ignore-line + return $array; } /** - * Builds a numeric multiline array with Symfony Constraint instances. - * The array is used by {@see InputValidator} during requests. + * Builds a closure or a numeric multiline array with Symfony Constraint + * instances. The array is used by {@see InputValidator} during requests. * - * Render example: + * Render example (array): * * [ * new NotNull(), @@ -599,9 +584,22 @@ protected function buildValidationRules(array $config): Collection * ... * ] * + * Render example (in a closure): + * + * fn() => [ + * new NotNull(), + * new Length([ + * 'min' => 5, + * 'max' => 10 + * ]), + * ... + * ] + * * @throws GeneratorException + * + * @return ArrowFunction|Collection */ - protected function buildConstraints(array $constraints = []): Collection + protected function buildConstraints(array $constraints = [], bool $inClosure = true) { $result = Collection::numeric()->setMultiline(); @@ -628,8 +626,8 @@ protected function buildConstraints(array $constraints = []): Collection if (is_array($args)) { if (isset($args[0]) && is_array($args[0])) { - // Another instance? - $instance->addArgument($this->buildConstraints($args)); + // Nested instance + $instance->addArgument($this->buildConstraints($args, false)); } else { // Numeric or Assoc array? $instance->addArgument(isset($args[0]) ? $args : Collection::assoc($args)); @@ -641,79 +639,11 @@ protected function buildConstraints(array $constraints = []): Collection $result->push($instance); } - return $result; // @phpstan-ignore-line - } - - /** - * Builds an assoc multiline array with a predefined shape. The array - * is used by {@see InputValidator} during requests. - * - * Possible keys are: 'groups', 'isCollection' and 'referenceType'. - * - * Render example: - * - * [ - * 'groups' => ['my_group'], - * 'isCollection' => true, - * 'referenceType' => $services->getType('Article') - * ] - * - * @param array{ - * referenceType: string, - * groups: array, - * isCollection: bool - * } $cascade - * - * @throws GeneratorException - */ - protected function buildCascade(array $cascade): Collection - { - $c = (object) $cascade; - - /** - * todo: remove this type-hint after fixing return type in the php generator - * - * @var Collection $array - */ - $array = Collection::assoc()->addIfNotEmpty('groups', $c->groups); - - if (isset($c->isCollection)) { - $array->addItem('isCollection', $c->isCollection); - } - - if (isset($c->referenceType)) { - $type = trim($c->referenceType, '[]!'); - - if (in_array($type, static::BUILT_IN_TYPES)) { - throw new GeneratorException('Cascade validation cannot be applied to built-in types.'); - } - - $array->addItem('referenceType', "$this->gqlServices->getType('$c->referenceType')"); - } - - return $array; // @phpstan-ignore-line - } - - /** - * Render example: - * - * [ - * 'firstName' => {@see buildValidationRules}, - * 'lastName' => {@see buildValidationRules}, - * ... - * ] - * - * @throws GeneratorException - */ - protected function buildProperties(array $properties): Collection - { - $array = Collection::assoc(); - - foreach ($properties as $name => $props) { - $array->addItem($name, $this->buildValidationRules($props)); + if ($inClosure) { + return ArrowFunction::new($result); } - return $array; // @phpstan-ignore-line + return $result; // @phpstan-ignore-line } /** @@ -745,12 +675,13 @@ protected function buildProperties(array $properties): Collection * @internal * * @throws GeneratorException - * @throws UnrecognizedValueTypeException * * @return GeneratorInterface|Collection|string */ - public function buildField(array $fieldConfig /*, $fieldname */) + public function buildField(array $fieldConfig, string $fieldname) { + $this->currentField = $fieldname; + // Convert to object for better readability $c = (object) $fieldConfig; @@ -764,8 +695,10 @@ public function buildField(array $fieldConfig /*, $fieldname */) // only for object types if (isset($c->resolve)) { - $validationConfig = $this->restructureObjectValidationConfig($fieldConfig); - $field->addItem('resolve', $this->buildResolve($c->resolve, $validationConfig)); + if (isset($c->validation)) { + $field->addItem('validation', $this->buildValidationRules($c->validation)); + } + $field->addItem('resolve', $this->buildResolve($c->resolve, $fieldConfig['validationGroups'] ?? null)); } if (isset($c->deprecationReason)) { @@ -797,12 +730,6 @@ public function buildField(array $fieldConfig /*, $fieldname */) } if ('input-object' === $this->type && isset($c->validation)) { - // restructure validation config - if (!empty($c->validation['cascade'])) { - $c->validation['cascade']['isCollection'] = $this->isCollectionType($c->type); - $c->validation['cascade']['referenceType'] = trim($c->type, '[]!'); - } - $field->addItem('validation', $this->buildValidationRules($c->validation)); } @@ -811,21 +738,24 @@ public function buildField(array $fieldConfig /*, $fieldname */) /** * Render example: - * - * [ - * 'name' => 'username', - * 'type' => {@see buildType}, - * 'description' => 'Some fancy description.', - * 'defaultValue' => 'admin', - * ] - * - * @internal + * + * [ + * 'name' => 'username', + * 'type' => {@see buildType}, + * 'description' => 'Some fancy description.', + * 'defaultValue' => 'admin', + * ] + * * * @param array{ * type: string, * description?: string, * defaultValue?: string * } $argConfig + * + * @internal + * + * @throws GeneratorException */ public function buildArg(array $argConfig, string $argName): Collection { @@ -844,7 +774,15 @@ public function buildArg(array $argConfig, string $argName): Collection $arg->addIfNotEmpty('defaultValue', $c->defaultValue); } - return $arg; // @phpstan-ignore-line + if (!empty($c->validation)) { + if (in_array($c->type, self::BUILT_IN_TYPES) && isset($c->validation['cascade'])) { + throw new GeneratorException('Cascade validation cannot be applied to built-in types.'); + } + + $arg->addIfNotEmpty('validation', $this->buildValidationRules($c->validation)); + } + + return $arg; } /** @@ -975,61 +913,6 @@ protected function buildResolveType($resolveType) return $resolveType; } - // TODO (murtukov): rework this method to use builders - protected function restructureObjectValidationConfig(array $fieldConfig): ?array - { - $properties = []; - - foreach ($fieldConfig['args'] ?? [] as $name => $arg) { - if (empty($arg['validation'])) { - continue; - } - - $properties[$name] = $arg['validation']; - - if (empty($arg['validation']['cascade'])) { - continue; - } - - $properties[$name]['cascade']['isCollection'] = $this->isCollectionType($arg['type']); - $properties[$name]['cascade']['referenceType'] = trim($arg['type'], '[]!'); - } - - // Merge class and field constraints - $classValidation = $this->config['validation'] ?? []; - - if (!empty($fieldConfig['validation'])) { - $classValidation = array_replace_recursive($classValidation, $fieldConfig['validation']); - } - - $mapping = []; - - if (!empty($properties)) { - $mapping['properties'] = $properties; - } - - // class - if (!empty($classValidation)) { - $mapping['class'] = $classValidation; - } - - // validationGroups - if (!empty($fieldConfig['validationGroups'])) { - $mapping['validationGroups'] = $fieldConfig['validationGroups']; - } - - if (empty($classValidation) && !array_filter($properties)) { - return null; - } else { - return $mapping; - } - } - - protected function isCollectionType(string $type): bool - { - return 2 === count(array_intersect(['[', ']'], str_split($type))); - } - /** * Creates and array from a formatted string. * diff --git a/src/Resources/config/services.yaml b/src/Resources/config/services.yaml index db17309c1..a91aaf436 100644 --- a/src/Resources/config/services.yaml +++ b/src/Resources/config/services.yaml @@ -108,3 +108,11 @@ services: arguments: - '@Overblog\GraphQLBundle\Generator\Converter\ExpressionConverter' - '%overblog_graphql.class_namespace%' + + Overblog\GraphQLBundle\Validator\InputValidatorFactory: + arguments: + - '@?validator.validator_factory' + - '@?validator' + - '@?translator.default' + tags: + - { name: overblog_graphql.service, alias: input_validator_factory, public: false } diff --git a/src/Validator/InputValidator.php b/src/Validator/InputValidator.php index b5f7afe64..1508d61f9 100644 --- a/src/Validator/InputValidator.php +++ b/src/Validator/InputValidator.php @@ -4,82 +4,61 @@ namespace Overblog\GraphQLBundle\Validator; +use Closure; use GraphQL\Type\Definition\InputObjectType; +use GraphQL\Type\Definition\ListOfType; +use GraphQL\Type\Definition\NonNull; use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\ResolveInfo; -use Overblog\GraphQLBundle\Definition\ArgumentInterface; +use GraphQL\Type\Definition\Type; +use Overblog\GraphQLBundle\Definition\ResolverArgs; +use Overblog\GraphQLBundle\Definition\Type\GeneratedTypeInterface; use Overblog\GraphQLBundle\Validator\Exception\ArgumentsValidationException; use Overblog\GraphQLBundle\Validator\Mapping\MetadataFactory; use Overblog\GraphQLBundle\Validator\Mapping\ObjectMetadata; -use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraints\GroupSequence; use Symfony\Component\Validator\Constraints\Valid; +use Symfony\Component\Validator\ConstraintValidatorFactoryInterface; use Symfony\Component\Validator\ConstraintViolationListInterface; use Symfony\Component\Validator\Mapping\ClassMetadataInterface; use Symfony\Component\Validator\Mapping\GetterMetadata; use Symfony\Component\Validator\Mapping\PropertyMetadata; +use Symfony\Component\Validator\Validation; use Symfony\Component\Validator\Validator\ValidatorInterface; +use Symfony\Contracts\Translation\TranslatorInterface; use function in_array; class InputValidator { private const TYPE_PROPERTY = 'property'; private const TYPE_GETTER = 'getter'; + public const CASCADE = 'cascade'; - private array $resolverArgs; - private array $propertiesMapping; - private array $classMapping; - private ValidatorInterface $validator; + private ResolverArgs $resolverArgs; + private ValidatorInterface $defaultValidator; private MetadataFactory $metadataFactory; private ResolveInfo $info; - private ValidatorFactory $validatorFactory; + private ConstraintValidatorFactoryInterface $constraintValidatorFactory; + private ?TranslatorInterface $defaultTranslator; /** @var ClassMetadataInterface[] */ private array $cachedMetadata = []; - /** - * InputValidator constructor. - */ public function __construct( - array $resolverArgs, - ?ValidatorInterface $validator, - ValidatorFactory $factory, - array $propertiesMapping = [], - array $classMapping = [] + ResolverArgs $resolverArgs, + ValidatorInterface $validator, + ConstraintValidatorFactoryInterface $constraintValidatorFactory, + ?TranslatorInterface $translator ) { - if (null === $validator) { - throw new ServiceNotFoundException( - "The 'validator' service is not found. To use the 'InputValidator' you need to install the - Symfony Validator Component first. See: 'https://symfony.com/doc/current/validation.html'" - ); - } - - $this->resolverArgs = $this->mapResolverArgs(...$resolverArgs); - $this->info = $this->resolverArgs['info']; - $this->propertiesMapping = $propertiesMapping; - $this->classMapping = $classMapping; - $this->validator = $validator; - $this->validatorFactory = $factory; + $this->resolverArgs = $resolverArgs; + $this->info = $this->resolverArgs->info; + $this->defaultValidator = $validator; + $this->constraintValidatorFactory = $constraintValidatorFactory; + $this->defaultTranslator = $translator; $this->metadataFactory = new MetadataFactory(); } - /** - * Converts a numeric array of resolver args to an associative one. - * - * @param mixed $value - * @param mixed $context - */ - private function mapResolverArgs($value, ArgumentInterface $args, $context, ResolveInfo $info): array - { - return [ - 'value' => $value, - 'args' => $args, - 'context' => $context, - 'info' => $info, - ]; - } - /** * @param string|array|null $groups * @@ -87,18 +66,25 @@ private function mapResolverArgs($value, ArgumentInterface $args, $context, Reso */ public function validate($groups = null, bool $throw = true): ?ConstraintViolationListInterface { - $rootObject = new ValidationNode($this->info->parentType, $this->info->fieldName, null, $this->resolverArgs); + $rootNode = new ValidationNode( + $this->info->parentType, + $this->info->fieldName, + null, + $this->resolverArgs + ); + + $classMapping = $this->mergeClassValidation(); $this->buildValidationTree( - $rootObject, - $this->propertiesMapping, - $this->classMapping, - $this->resolverArgs['args']->getArrayCopy() + $rootNode, + $this->info->fieldDefinition->config['args'], + $classMapping, + $this->resolverArgs->args->getArrayCopy() ); - $validator = $this->validatorFactory->createValidator($this->metadataFactory); + $validator = $this->createValidator($this->metadataFactory); - $errors = $validator->validate($rootObject, null, $groups); + $errors = $validator->validate($rootNode, null, $groups); if ($throw && $errors->count() > 0) { throw new ArgumentsValidationException($errors); @@ -107,51 +93,85 @@ public function validate($groups = null, bool $throw = true): ?ConstraintViolati } } + private function mergeClassValidation(): array + { + $common = static::normalizeConfig($this->info->parentType->config['validation'] ?? []); + $specific = static::normalizeConfig($this->info->fieldDefinition->config['validation'] ?? []); + + return array_filter([ + 'link' => $specific['link'] ?? $common['link'] ?? null, + 'constraints' => [ + ...($common['constraints'] ?? []), + ...($specific['constraints'] ?? []), + ], + ]); + } + + private function createValidator(MetadataFactory $metadataFactory): ValidatorInterface + { + $builder = Validation::createValidatorBuilder() + ->setMetadataFactory($metadataFactory) + ->setConstraintValidatorFactory($this->constraintValidatorFactory); + + if (null !== $this->defaultTranslator) { + // @phpstan-ignore-next-line (only for Symfony 4.4) + $builder + ->setTranslator($this->defaultTranslator) + ->setTranslationDomain('validators'); + } + + return $builder->getValidator(); + } + /** * Creates a composition of ValidationNode objects from args * and simultaneously applies to them validation constraints. */ - protected function buildValidationTree(ValidationNode $rootObject, array $propertiesMapping, array $classMapping, array $args): ValidationNode + protected function buildValidationTree(ValidationNode $rootObject, array $fields, array $classValidation, array $inputData): ValidationNode { $metadata = new ObjectMetadata($rootObject); - if (!empty($classMapping)) { - $this->applyClassConstraints($metadata, $classMapping); + if (!empty($classValidation)) { + $this->applyClassValidation($metadata, $classValidation); } - foreach ($propertiesMapping as $property => $params) { - if (!empty($params['cascade']) && isset($args[$property])) { - $options = $params['cascade']; + foreach ($fields as $name => $arg) { + $property = $arg['name'] ?? $name; + $config = static::normalizeConfig($arg['validation'] ?? []); + + if (isset($config['cascade']) && isset($inputData[$property])) { + $groups = $config['cascade']; + $argType = $this->unclosure($arg['type']); /** @var ObjectType|InputObjectType $type */ - $type = $options['referenceType']; + $type = Type::getNamedType($argType); - if ($options['isCollection']) { - $rootObject->$property = $this->createCollectionNode($args[$property], $type, $rootObject); + if (static::isListOfType($argType)) { + $rootObject->$property = $this->createCollectionNode($inputData[$property], $type, $rootObject); } else { - $rootObject->$property = $this->createObjectNode($args[$property], $type, $rootObject); + $rootObject->$property = $this->createObjectNode($inputData[$property], $type, $rootObject); } $valid = new Valid(); - if (!empty($options['groups'])) { - $valid->groups = $options['groups']; + if (!empty($groups)) { + $valid->groups = $groups; } $metadata->addPropertyConstraint($property, $valid); } else { - $rootObject->$property = $args[$property] ?? null; + $rootObject->$property = $inputData[$property] ?? null; } - $this->restructureShortForm($params); + $config = static::normalizeConfig($config); - foreach ($params ?? [] as $key => $value) { + foreach ($config as $key => $value) { switch ($key) { case 'link': [$fqcn, $property, $type] = $value; if (!in_array($fqcn, $this->cachedMetadata)) { - $this->cachedMetadata[$fqcn] = $this->validator->getMetadataFor($fqcn); + $this->cachedMetadata[$fqcn] = $this->defaultValidator->getMetadataFor($fqcn); } // Get metadata from the property and it's getters @@ -187,6 +207,18 @@ protected function buildValidationTree(ValidationNode $rootObject, array $proper return $rootObject; } + /** + * @param GeneratedTypeInterface|ListOfType|NonNull $type + */ + private static function isListOfType($type): bool + { + if ($type instanceof ListOfType || ($type instanceof NonNull && $type->getOfType() instanceof ListOfType)) { + return true; + } + + return false; + } + /** * @param ObjectType|InputObjectType $type */ @@ -206,33 +238,28 @@ private function createCollectionNode(array $values, $type, ValidationNode $pare */ private function createObjectNode(array $value, $type, ValidationNode $parent): ValidationNode { - $classMapping = $type->config['validation'] ?? []; - $propertiesMapping = []; - - foreach ($type->getFields() as $fieldName => $inputField) { - $propertiesMapping[$fieldName] = $inputField->config['validation'] ?? []; - } + $classValidation = static::normalizeConfig($type->config['validation'] ?? []); return $this->buildValidationTree( new ValidationNode($type, null, $parent, $this->resolverArgs), - $propertiesMapping, - $classMapping, + self::unclosure($type->config['fields']), + $classValidation, $value ); } - private function applyClassConstraints(ObjectMetadata $metadata, array $rules): void + private function applyClassValidation(ObjectMetadata $metadata, array $rules): void { - $this->restructureShortForm($rules); + $rules = static::normalizeConfig($rules); foreach ($rules as $key => $value) { switch ($key) { case 'link': - $linkedMetadata = $this->validator->getMetadataFor($value); + $linkedMetadata = $this->defaultValidator->getMetadataFor($value); $metadata->addConstraints($linkedMetadata->getConstraints()); break; case 'constraints': - foreach ($value as $constraint) { + foreach ($this->unclosure($value) as $constraint) { if ($constraint instanceof Constraint) { $metadata->addConstraint($constraint); } elseif ($constraint instanceof GroupSequence) { @@ -244,11 +271,41 @@ private function applyClassConstraints(ObjectMetadata $metadata, array $rules): } } - private function restructureShortForm(array &$rules): void + /** + * Restructures short forms into the full form array and + * unwraps constraints in closures. + * + * @param mixed $config + */ + public static function normalizeConfig($config): array { - if (isset($rules[0])) { - $rules = ['constraints' => $rules]; + if ($config instanceof Closure) { + return ['constraints' => $config()]; + } + + if (self::CASCADE === $config) { + return ['cascade' => []]; } + + if (isset($config['constraints']) && $config['constraints'] instanceof Closure) { + $config['constraints'] = $config['constraints'](); + } + + return $config; + } + + /** + * @param mixed $value + * + * @return mixed + */ + private function unclosure($value) + { + if ($value instanceof Closure) { + return $value(); + } + + return $value; } /** diff --git a/src/Validator/InputValidatorFactory.php b/src/Validator/InputValidatorFactory.php new file mode 100644 index 000000000..30afaffe0 --- /dev/null +++ b/src/Validator/InputValidatorFactory.php @@ -0,0 +1,45 @@ +defaultValidator = $validator; + $this->defaultTranslator = $translator; + $this->constraintValidatorFactory = $constraintValidatorFactory; + } + + public function create(ResolverArgs $args): InputValidator + { + if (null === $this->defaultValidator) { + throw new ServiceNotFoundException("The 'validator' service is not found. To use the 'InputValidator' you need to install the Symfony Validator Component first. See: 'https://symfony.com/doc/current/validation.html'"); + } + + return new InputValidator( + $args, + $this->defaultValidator, + $this->constraintValidatorFactory, + $this->defaultTranslator + ); + } +} diff --git a/src/Validator/ValidationNode.php b/src/Validator/ValidationNode.php index c1e7f1f53..4925b65de 100644 --- a/src/Validator/ValidationNode.php +++ b/src/Validator/ValidationNode.php @@ -9,6 +9,7 @@ use GraphQL\Type\Definition\ResolveInfo; use GraphQL\Type\Definition\Type; use Overblog\GraphQLBundle\Definition\Argument; +use Overblog\GraphQLBundle\Definition\ResolverArgs; use function in_array; /** @@ -41,10 +42,14 @@ class ValidationNode /** * Arguments of the resolver, where the current validation is being executed. */ - private array $__resolverArgs; - - public function __construct(Type $type, string $field = null, ?ValidationNode $parent = null, array $resolverArgs = []) - { + private ?ResolverArgs $__resolverArgs; + + public function __construct( + Type $type, + string $field = null, + ?ValidationNode $parent = null, + ?ResolverArgs $resolverArgs = null + ) { $this->__type = $type; $this->__fieldName = $field; $this->__resolverArgs = $resolverArgs; @@ -121,7 +126,7 @@ public function findParent(string $name): ?ValidationNode public function getResolverArg(string $name) { if (in_array($name, self::KNOWN_VAR_NAMES)) { - return $this->__resolverArgs[$name]; + return $this->__resolverArgs->$name; } return null; diff --git a/src/Validator/ValidatorFactory.php b/src/Validator/ValidatorFactory.php deleted file mode 100644 index 3b483c541..000000000 --- a/src/Validator/ValidatorFactory.php +++ /dev/null @@ -1,39 +0,0 @@ -defaultTranslator = $translator; - $this->constraintValidatorFactory = $constraintValidatorFactory; - } - - public function createValidator(MetadataFactory $metadataFactory): ValidatorInterface - { - $builder = Validation::createValidatorBuilder() - ->setMetadataFactory($metadataFactory) - ->setConstraintValidatorFactory($this->constraintValidatorFactory); - - if (null !== $this->defaultTranslator) { - // @phpstan-ignore-next-line (only for Symfony 4.4) - $builder - ->setTranslator($this->defaultTranslator) - ->setTranslationDomain('validators'); - } - - return $builder->getValidator(); - } -} diff --git a/tests/ExpressionLanguage/TestCase.php b/tests/ExpressionLanguage/TestCase.php index 1014fecbd..40658f237 100644 --- a/tests/ExpressionLanguage/TestCase.php +++ b/tests/ExpressionLanguage/TestCase.php @@ -101,8 +101,7 @@ private function getCoreSecurityMock(): CoreSecurity return $this->getMockBuilder(CoreSecurity::class) ->disableOriginalConstructor() ->setMethods(['isGranted']) - ->getMock() - ; + ->getMock(); } protected function createGraphQLServices(array $services = []): GraphQLServices diff --git a/tests/Functional/App/IsolatedResolver/EchoQuery.php b/tests/Functional/App/IsolatedResolver/EchoQuery.php index 250ca43ec..6a7b1d6fc 100644 --- a/tests/Functional/App/IsolatedResolver/EchoQuery.php +++ b/tests/Functional/App/IsolatedResolver/EchoQuery.php @@ -14,6 +14,7 @@ final class EchoQuery implements QueryInterface, ContainerAwareInterface public function display(string $message): string { + // @phpstan-ignore-next-line return $this->container->getParameter('echo.prefix').$message; } } diff --git a/tests/Functional/App/config/cascadeOnScalars/mapping/Mutation.types.yml b/tests/Functional/App/config/cascadeOnScalars/mapping/Mutation.types.yml index d60b2364e..7001457ad 100644 --- a/tests/Functional/App/config/cascadeOnScalars/mapping/Mutation.types.yml +++ b/tests/Functional/App/config/cascadeOnScalars/mapping/Mutation.types.yml @@ -4,7 +4,7 @@ Mutation: fields: cascadeOnScalar: type: Boolean - resolve: '@=mut("mutation_mock", [args, validator])' + resolve: '@=mut("mutation_mock", args, validator)' args: test: type: "String" diff --git a/tests/Functional/App/config/public/mapping/public.types.yml b/tests/Functional/App/config/public/mapping/public.types.yml index d2cd16abe..2eca0507f 100644 --- a/tests/Functional/App/config/public/mapping/public.types.yml +++ b/tests/Functional/App/config/public/mapping/public.types.yml @@ -15,6 +15,7 @@ ObjectWithPrivateField: type: String other: type: String + public: "@=typeName == 'nonsense'" privateData: type: String public: "@=service('security.authorization_checker').isGranted('ROLE_ADMIN')" diff --git a/tests/Functional/App/config/queryComplexity/mapping/connectionWithComplexity.types.yml b/tests/Functional/App/config/queryComplexity/mapping/connectionWithComplexity.types.yml index 097165010..8607fa723 100644 --- a/tests/Functional/App/config/queryComplexity/mapping/connectionWithComplexity.types.yml +++ b/tests/Functional/App/config/queryComplexity/mapping/connectionWithComplexity.types.yml @@ -17,6 +17,11 @@ User: type: friendConnection argsBuilder: "Relay::Connection" resolve: '@=query("friends", value, args)' + noFriend: + complexity: "@=15 + 20 + childrenComplexity" + type: friendConnection + argsBuilder: "Relay::Connection" + resolve: '@=query("friends", value, args)' friendConnection: type: relay-connection diff --git a/tests/Functional/App/config/validator/mapping/Mutation.types.yml b/tests/Functional/App/config/validator/mapping/Mutation.types.yml index bff02536f..988965c36 100644 --- a/tests/Functional/App/config/validator/mapping/Mutation.types.yml +++ b/tests/Functional/App/config/validator/mapping/Mutation.types.yml @@ -1,6 +1,11 @@ Mutation: type: object config: + validation: # Applied to all fields + - Callback: [Overblog\GraphQLBundle\Tests\Functional\Validator\StaticValidator, alwaysTrue] + - Expression: + expression: this.getFieldName() == this.getFieldName() + message: "parent" fields: noValidation: complexity: 1 @@ -14,6 +19,10 @@ Mutation: validation: ~ simpleValidation: + validation: # Applied to all fields + - Expression: + expression: this.getFieldName() == this.getFieldName() + message: "child" type: Boolean resolve: "@=mut('mutation_mock', args, validator)" args: diff --git a/tests/Functional/Generator/TypeGeneratorTest.php b/tests/Functional/Generator/TypeGeneratorTest.php index 3039c9dcf..39665ca48 100644 --- a/tests/Functional/Generator/TypeGeneratorTest.php +++ b/tests/Functional/Generator/TypeGeneratorTest.php @@ -89,11 +89,7 @@ public function testNonExistentConstraintThrowsException(): void public function testInjectValidatorWithoutConstraintsThrowsException(): void { $this->expectException(GeneratorException::class); - $this->expectExceptionMessage( - 'Unable to inject an instance of the InputValidator. No validation constraints provided. '. - 'Please remove the "validator" argument from the list of dependencies of your resolver '. - 'or provide validation configs.' - ); + $this->expectExceptionMessage('Unable to inject an instance of the InputValidator. No validation constraints provided. Please remove the "validator" argument from the list of dependencies of your resolver or provide validation configs.'); parent::setUp(); static::bootKernel(['test_case' => 'validatorWithoutConstraints']); diff --git a/tests/Functional/Validator/StaticValidator.php b/tests/Functional/Validator/StaticValidator.php index f4a91614b..7b224a99f 100644 --- a/tests/Functional/Validator/StaticValidator.php +++ b/tests/Functional/Validator/StaticValidator.php @@ -36,4 +36,9 @@ public static function validateClass($object, ExecutionContextInterface $context $context->buildViolation('Class is invalid'); } } + + public static function alwaysTrue(): bool + { + return true; + } } diff --git a/tests/Validator/InputValidatorTest.php b/tests/Validator/InputValidatorTest.php index 7869df3e8..cc1654ed5 100644 --- a/tests/Validator/InputValidatorTest.php +++ b/tests/Validator/InputValidatorTest.php @@ -4,11 +4,13 @@ namespace Overblog\GraphQLBundle\Tests\Validator; -use Overblog\GraphQLBundle\Validator\InputValidator; -use Overblog\GraphQLBundle\Validator\ValidatorFactory; +use ArrayObject; +use GraphQL\Type\Definition\ResolveInfo; +use Overblog\GraphQLBundle\Definition\Argument; +use Overblog\GraphQLBundle\Definition\ResolverArgs; +use Overblog\GraphQLBundle\Validator\InputValidatorFactory; use PHPUnit\Framework\TestCase; use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; -use Symfony\Component\Validator\ConstraintValidatorFactory; use Symfony\Component\Validator\Validation; use function class_exists; @@ -24,10 +26,15 @@ public function setUp(): void public function testNoDefaultValidatorException(): void { - $factory = new ValidatorFactory(new ConstraintValidatorFactory(), null); - $this->expectException(ServiceNotFoundException::class); - new InputValidator([], null, $factory, []); + $factory = new InputValidatorFactory(null, null, null); + + $factory->create(new ResolverArgs( + true, + new Argument(), + new ArrayObject(), + $this->getMockBuilder(ResolveInfo::class)->disableOriginalConstructor()->getMock(), + )); } } diff --git a/tests/Validator/ValidationNodeTest.php b/tests/Validator/ValidationNodeTest.php index 04459522c..337d1dd3a 100644 --- a/tests/Validator/ValidationNodeTest.php +++ b/tests/Validator/ValidationNodeTest.php @@ -8,6 +8,7 @@ use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\ResolveInfo; use Overblog\GraphQLBundle\Definition\Argument; +use Overblog\GraphQLBundle\Definition\ResolverArgs; use Overblog\GraphQLBundle\Validator\ValidationNode; use PHPUnit\Framework\TestCase; @@ -39,13 +40,13 @@ public function testValidationNode(): void $this->assertSame($childType, $childNode->getType()); } - private function createResolveArgs(): array + private function createResolveArgs(): ResolverArgs { - return [ - 'value' => true, - 'args' => new Argument(), - 'context' => new ArrayObject(), - 'info' => $this->getMockBuilder(ResolveInfo::class)->disableOriginalConstructor()->getMock(), - ]; + return new ResolverArgs( + true, + new Argument(), + new ArrayObject(), + $this->getMockBuilder(ResolveInfo::class)->disableOriginalConstructor()->getMock(), + ); } }