diff --git a/docs/annotations/annotations-reference.md b/docs/annotations/annotations-reference.md index fc579537f..fcc0b1675 100644 --- a/docs/annotations/annotations-reference.md +++ b/docs/annotations/annotations-reference.md @@ -384,6 +384,10 @@ This annotation applies on methods for classes tagged with the `@Provider` annot The resulting field is added to the root Mutation type (defined in configuration at key `overblog_graphql.definitions.schema.mutation`). The class exposing the mutation(s) must be declared as a [service](https://symfony.com/doc/current/service_container.html). +Optional attributes: + +- **targetType** : The GraphQL type to attach the field to. It must be a mutation. (by default, it'll be the root Mutation type of the default schema). + Example: This will add an `updateUserEmail` mutation, with as resolver `@=service('App\Graphql\MutationProvider').updateUserEmail(...)`. @@ -432,7 +436,7 @@ The class exposing the query(ies) must be declared as a [service](https://symfon Optional attributes: -- **targetType** : The GraphQL type to attach the field to (by default, it'll be the root Query type). +- **targetType** : The GraphQL type to attach the field to (by default, it'll be the root Query type of the default schema). Example: @@ -464,9 +468,10 @@ This annotation is used on _class_ to define a GraphQL Type. Optional attributes: - **name** : The GraphQL name of the type (default to the class name without namespace) -- **interfaces** : An array of GraphQL interface this type inherits from +- **interfaces** : An array of GraphQL interface this type inherits from (can be auto-guessed. See interface documentation). - **isRelay** : Set to true to have a Relay compatible type (ie. A `clientMutationId` will be added). -- **builders**: An array of `@FieldsBuilder` annotations +- **builders** : An array of `@FieldsBuilder` annotations +- **isTypeOf** : Is type of resolver for interface implementation ```php */ public $builders = []; + + /** + * Expression to resolve type for interfaces. + * + * @var string + */ + public $isTypeOf; } diff --git a/src/Annotation/Union.php b/src/Annotation/Union.php index 14eab95d3..5a234f3f3 100644 --- a/src/Annotation/Union.php +++ b/src/Annotation/Union.php @@ -22,8 +22,6 @@ final class Union implements Annotation /** * Union types. * - * @required - * * @var array */ public $types; diff --git a/src/Config/Parser/Annotation/GraphClass.php b/src/Config/Parser/Annotation/GraphClass.php new file mode 100644 index 000000000..301b3ab9b --- /dev/null +++ b/src/Config/Parser/Annotation/GraphClass.php @@ -0,0 +1,90 @@ +annotations = $annotationReader->getClassAnnotations($this); + + $reflection = $this; + do { + foreach ($reflection->getProperties() as $property) { + if (isset($this->propertiesExtended[$property->getName()])) { + continue; + } + $this->propertiesExtended[$property->getName()] = $property; + } + } while ($reflection = $reflection->getParentClass()); + } + + /** + * @return ReflectionProperty[] + */ + public function getPropertiesExtended() + { + return $this->propertiesExtended; + } + + /** + * @param ReflectionMethod|ReflectionProperty|null $from + * + * @return array + */ + public function getAnnotations(object $from = null) + { + if (!$from) { + return $this->annotations; + } + + if ($from instanceof ReflectionMethod) { + return self::getAnnotationReader()->getMethodAnnotations($from); + } + + if ($from instanceof ReflectionProperty) { + return self::getAnnotationReader()->getPropertyAnnotations($from); + } + + /** @phpstan-ignore-next-line */ + throw new AnnotationException(sprintf('Unable to retrieve annotations from object of class "%s".', get_class($from))); + } + + private static function getAnnotationReader(): AnnotationReader + { + if (null === self::$annotationReader) { + if (!class_exists(AnnotationReader::class) || + !class_exists(AnnotationRegistry::class)) { + throw new RuntimeException('In order to use graphql annotation, you need to require doctrine annotations'); + } + + AnnotationRegistry::registerLoader('class_exists'); + self::$annotationReader = new AnnotationReader(); + } + + return self::$annotationReader; + } +} diff --git a/src/Config/Parser/AnnotationParser.php b/src/Config/Parser/AnnotationParser.php index dbecd2e12..e2543e050 100644 --- a/src/Config/Parser/AnnotationParser.php +++ b/src/Config/Parser/AnnotationParser.php @@ -4,8 +4,6 @@ namespace Overblog\GraphQLBundle\Config\Parser; -use Doctrine\Common\Annotations\AnnotationReader; -use Doctrine\Common\Annotations\AnnotationRegistry; use Doctrine\ORM\Mapping\Column; use Doctrine\ORM\Mapping\JoinColumn; use Doctrine\ORM\Mapping\ManyToMany; @@ -14,12 +12,14 @@ use Doctrine\ORM\Mapping\OneToOne; use Exception; use Overblog\GraphQLBundle\Annotation as GQL; +use Overblog\GraphQLBundle\Config\Parser\Annotation\GraphClass; use Overblog\GraphQLBundle\Relay\Connection\ConnectionInterface; use Overblog\GraphQLBundle\Relay\Connection\EdgeInterface; -use ReflectionClass; use ReflectionException; use ReflectionMethod; use ReflectionNamedType; +use ReflectionProperty; +use Reflector; use RuntimeException; use SplFileInfo; use Symfony\Component\Config\Resource\FileResource; @@ -29,7 +29,6 @@ use function array_keys; use function array_map; use function array_unshift; -use function class_exists; use function current; use function file_get_contents; use function get_class; @@ -47,11 +46,10 @@ class AnnotationParser implements PreParserInterface { - private static ?AnnotationReader $annotationReader = null; private static array $classesMap = []; private static array $providers = []; private static array $doctrineMapping = []; - private static array $classAnnotationsCache = []; + private static array $graphClassCache = []; private const GQL_SCALAR = 'scalar'; private const GQL_ENUM = 'enum'; @@ -91,8 +89,7 @@ public static function reset(): void { self::$classesMap = []; self::$providers = []; - self::$classAnnotationsCache = []; - self::$annotationReader = null; + self::$graphClassCache = []; } /** @@ -111,17 +108,15 @@ private static function processFile(SplFileInfo $file, ContainerBuilder $contain if (preg_match('#namespace (.+);#', file_get_contents($file->getRealPath()), $matches)) { $className = trim($matches[1]).'\\'.$className; } - [$reflectionEntity, $classAnnotations, $properties, $methods] = self::extractClassAnnotations($className); + $gqlTypes = []; + $graphClass = self::getGraphClass($className); - foreach ($classAnnotations as $classAnnotation) { + foreach ($graphClass->getAnnotations() as $classAnnotation) { $gqlTypes = self::classAnnotationsToGQLConfiguration( - $reflectionEntity, + $graphClass, $classAnnotation, $configs, - $classAnnotations, - $properties, - $methods, $gqlTypes, $preProcess ); @@ -134,12 +129,9 @@ private static function processFile(SplFileInfo $file, ContainerBuilder $contain } private static function classAnnotationsToGQLConfiguration( - ReflectionClass $reflectionEntity, + GraphClass $graphClass, object $classAnnotation, array $configs, - array $classAnnotations, - array $properties, - array $methods, array $gqlTypes, bool $preProcess ): array { @@ -148,19 +140,17 @@ private static function classAnnotationsToGQLConfiguration( switch (true) { case $classAnnotation instanceof GQL\Type: $gqlType = self::GQL_TYPE; - $gqlName = $classAnnotation->name ?: $reflectionEntity->getShortName(); + $gqlName = $classAnnotation->name ?: $graphClass->getShortName(); if (!$preProcess) { - $gqlConfiguration = self::typeAnnotationToGQLConfiguration( - $reflectionEntity, $classAnnotation, $gqlName, $classAnnotations, $properties, $methods, $configs - ); + $gqlConfiguration = self::typeAnnotationToGQLConfiguration($graphClass, $classAnnotation, $gqlName, $configs); if ($classAnnotation instanceof GQL\Relay\Connection) { - if (!$reflectionEntity->implementsInterface(ConnectionInterface::class)) { - throw new InvalidArgumentException(sprintf('The annotation @Connection on class "%s" can only be used on class implementing the ConnectionInterface.', $reflectionEntity->getName())); + if (!$graphClass->implementsInterface(ConnectionInterface::class)) { + throw new InvalidArgumentException(sprintf('The annotation @Connection on class "%s" can only be used on class implementing the ConnectionInterface.', $graphClass->getName())); } if (!($classAnnotation->edge xor $classAnnotation->node)) { - throw new InvalidArgumentException(sprintf('The annotation @Connection on class "%s" is invalid. You must define the "edge" OR the "node" attribute.', $reflectionEntity->getName())); + throw new InvalidArgumentException(sprintf('The annotation @Connection on class "%s" is invalid. You must define the "edge" OR the "node" attribute.', $graphClass->getName())); } $edgeType = $classAnnotation->edge; @@ -185,67 +175,58 @@ private static function classAnnotationsToGQLConfiguration( case $classAnnotation instanceof GQL\Input: $gqlType = self::GQL_INPUT; - $gqlName = $classAnnotation->name ?: self::suffixName($reflectionEntity->getShortName(), 'Input'); + $gqlName = $classAnnotation->name ?: self::suffixName($graphClass->getShortName(), 'Input'); if (!$preProcess) { - $gqlConfiguration = self::inputAnnotationToGQLConfiguration( - $classAnnotation, $classAnnotations, $properties, $reflectionEntity->getNamespaceName() - ); + $gqlConfiguration = self::inputAnnotationToGQLConfiguration($graphClass, $classAnnotation); } break; case $classAnnotation instanceof GQL\Scalar: $gqlType = self::GQL_SCALAR; if (!$preProcess) { - $gqlConfiguration = self::scalarAnnotationToGQLConfiguration( - $reflectionEntity->getName(), $classAnnotation, $classAnnotations - ); + $gqlConfiguration = self::scalarAnnotationToGQLConfiguration($graphClass, $classAnnotation); } break; case $classAnnotation instanceof GQL\Enum: $gqlType = self::GQL_ENUM; if (!$preProcess) { - $gqlConfiguration = self::enumAnnotationToGQLConfiguration( - $classAnnotation, $classAnnotations, $reflectionEntity->getConstants() - ); + $gqlConfiguration = self::enumAnnotationToGQLConfiguration($graphClass, $classAnnotation); } break; case $classAnnotation instanceof GQL\Union: $gqlType = self::GQL_UNION; if (!$preProcess) { - $gqlConfiguration = self::unionAnnotationToGQLConfiguration( - $reflectionEntity->getName(), $classAnnotation, $classAnnotations, $methods - ); + $gqlConfiguration = self::unionAnnotationToGQLConfiguration($graphClass, $classAnnotation); } break; case $classAnnotation instanceof GQL\TypeInterface: $gqlType = self::GQL_INTERFACE; if (!$preProcess) { - $gqlConfiguration = self::typeInterfaceAnnotationToGQLConfiguration( - $classAnnotation, $classAnnotations, $properties, $methods, $reflectionEntity->getNamespaceName() - ); + $gqlConfiguration = self::typeInterfaceAnnotationToGQLConfiguration($graphClass, $classAnnotation); } break; case $classAnnotation instanceof GQL\Provider: if ($preProcess) { - self::$providers[$reflectionEntity->getName()] = ['annotation' => $classAnnotation, 'methods' => $methods, 'annotations' => $classAnnotations]; + self::$providers[] = ['metadata' => $graphClass, 'annotation' => $classAnnotation]; } - break; + + return []; } if (null !== $gqlType) { if (!$gqlName) { - $gqlName = $classAnnotation->name ?: $reflectionEntity->getShortName(); + $gqlName = $classAnnotation->name ?: $graphClass->getShortName(); } if ($preProcess) { if (isset(self::$classesMap[$gqlName])) { throw new InvalidArgumentException(sprintf('The GraphQL type "%s" has already been registered in class "%s"', $gqlName, self::$classesMap[$gqlName]['class'])); } - self::$classesMap[$gqlName] = ['type' => $gqlType, 'class' => $reflectionEntity->getName()]; + self::$classesMap[$gqlName] = ['type' => $gqlType, 'class' => $graphClass->getName()]; } else { $gqlTypes = [$gqlName => $gqlConfiguration] + $gqlTypes; } @@ -257,57 +238,50 @@ private static function classAnnotationsToGQLConfiguration( /** * @throws ReflectionException */ - private static function extractClassAnnotations(string $className): array + private static function getGraphClass(string $className): GraphClass { - if (!isset(self::$classAnnotationsCache[$className])) { - $annotationReader = self::getAnnotationReader(); - $reflectionEntity = new ReflectionClass($className); // @phpstan-ignore-line - $classAnnotations = $annotationReader->getClassAnnotations($reflectionEntity); - - $properties = []; - $reflectionClass = new ReflectionClass($className); // @phpstan-ignore-line - do { - foreach ($reflectionClass->getProperties() as $property) { - if (isset($properties[$property->getName()])) { - continue; - } - $properties[$property->getName()] = ['property' => $property, 'annotations' => $annotationReader->getPropertyAnnotations($property)]; - } - } while ($reflectionClass = $reflectionClass->getParentClass()); - - $methods = []; - foreach ($reflectionEntity->getMethods() as $method) { - $methods[$method->getName()] = ['method' => $method, 'annotations' => $annotationReader->getMethodAnnotations($method)]; - } - - self::$classAnnotationsCache[$className] = [$reflectionEntity, $classAnnotations, $properties, $methods]; - } + self::$graphClassCache[$className] ??= new GraphClass($className); - return self::$classAnnotationsCache[$className]; + return self::$graphClassCache[$className]; } private static function typeAnnotationToGQLConfiguration( - ReflectionClass $reflectionEntity, + GraphClass $graphClass, GQL\Type $classAnnotation, string $gqlName, - array $classAnnotations, - array $properties, - array $methods, array $configs ): array { - $rootQueryType = $configs['definitions']['schema']['default']['query'] ?? null; - $rootMutationType = $configs['definitions']['schema']['default']['mutation'] ?? null; - $isRootQuery = ($rootQueryType && $gqlName === $rootQueryType); - $isRootMutation = ($rootMutationType && $gqlName === $rootMutationType); - $currentValue = ($isRootQuery || $isRootMutation) ? sprintf("service('%s')", self::formatNamespaceForExpression($reflectionEntity->getName())) : 'value'; + $isMutation = $isDefault = $isRoot = false; + if (isset($configs['definitions']['schema'])) { + foreach ($configs['definitions']['schema'] as $schemaName => $schema) { + $schemaQuery = $schema['query'] ?? null; + $schemaMutation = $schema['mutation'] ?? null; + + if ($schemaQuery && $gqlName === $schemaQuery) { + $isRoot = true; + if ('default' == $schemaName) { + $isDefault = true; + } + } elseif ($schemaMutation && $gqlName === $schemaMutation) { + $isMutation = true; + $isRoot = true; + if ('default' == $schemaName) { + $isDefault = true; + } + } + } + } + + $currentValue = $isRoot ? sprintf("service('%s')", self::formatNamespaceForExpression($graphClass->getName())) : 'value'; + + $gqlConfiguration = self::graphQLTypeConfigFromAnnotation($graphClass, $classAnnotation, $currentValue); - $gqlConfiguration = self::graphQLTypeConfigFromAnnotation($classAnnotation, $classAnnotations, $properties, $methods, $reflectionEntity->getNamespaceName(), $currentValue); - $providerFields = self::getGraphQLFieldsFromProviders($reflectionEntity->getNamespaceName(), $isRootMutation ? 'Mutation' : 'Query', $gqlName, ($isRootQuery || $isRootMutation)); - $gqlConfiguration['config']['fields'] = $providerFields + $gqlConfiguration['config']['fields']; + $providerFields = self::getGraphQLFieldsFromProviders($graphClass, $isMutation ? GQL\Mutation::class : GQL\Query::class, $gqlName, $isDefault); + $gqlConfiguration['config']['fields'] = array_merge($gqlConfiguration['config']['fields'], $providerFields); if ($classAnnotation instanceof GQL\Relay\Edge) { - if (!$reflectionEntity->implementsInterface(EdgeInterface::class)) { - throw new InvalidArgumentException(sprintf('The annotation @Edge on class "%s" can only be used on class implementing the EdgeInterface.', $reflectionEntity->getName())); + if (!$graphClass->implementsInterface(EdgeInterface::class)) { + throw new InvalidArgumentException(sprintf('The annotation @Edge on class "%s" can only be used on class implementing the EdgeInterface.', $graphClass->getName())); } if (!isset($gqlConfiguration['config']['builders'])) { $gqlConfiguration['config']['builders'] = []; @@ -318,33 +292,31 @@ private static function typeAnnotationToGQLConfiguration( return $gqlConfiguration; } - private static function getAnnotationReader(): AnnotationReader + private static function graphQLTypeConfigFromAnnotation(GraphClass $graphClass, GQL\Type $typeAnnotation, string $currentValue): array { - if (null === self::$annotationReader) { - if (!class_exists('\\Doctrine\\Common\\Annotations\\AnnotationReader') || - !class_exists('\\Doctrine\\Common\\Annotations\\AnnotationRegistry')) { - throw new RuntimeException('In order to use graphql annotation, you need to require doctrine annotations'); - } - - AnnotationRegistry::registerLoader('class_exists'); - self::$annotationReader = new AnnotationReader(); - } + $typeConfiguration = []; + $fieldsFromProperties = self::getGraphQLTypeFieldsFromAnnotations($graphClass, $graphClass->getPropertiesExtended(), GQL\Field::class, $currentValue); + $fieldsFromMethods = self::getGraphQLTypeFieldsFromAnnotations($graphClass, $graphClass->getMethods(), GQL\Field::class, $currentValue); - return self::$annotationReader; - } + $typeConfiguration['fields'] = array_merge($fieldsFromProperties, $fieldsFromMethods); + $typeConfiguration = self::getDescriptionConfiguration($graphClass->getAnnotations()) + $typeConfiguration; - private static function graphQLTypeConfigFromAnnotation(GQL\Type $typeAnnotation, array $classAnnotations, array $properties, array $methods, string $namespace, string $currentValue): array - { - $typeConfiguration = []; + if (null !== $typeAnnotation->interfaces) { + $typeConfiguration['interfaces'] = $typeAnnotation->interfaces; + } else { + $interfaces = array_keys(self::searchClassesMapBy(function ($gqlType, $configuration) use ($graphClass) { + ['class' => $interfaceClassName] = $configuration; - $fields = self::getGraphQLFieldsFromAnnotations($namespace, $properties, false, false, $currentValue); - $fields = self::getGraphQLFieldsFromAnnotations($namespace, $methods, false, true, $currentValue) + $fields; + $interfaceMetadata = self::getGraphClass($interfaceClassName); + if ($interfaceMetadata->isInterface() && $graphClass->implementsInterface($interfaceMetadata->getName())) { + return true; + } - $typeConfiguration['fields'] = $fields; - $typeConfiguration = self::getDescriptionConfiguration($classAnnotations) + $typeConfiguration; + return $graphClass->isSubclassOf($interfaceClassName); + }, self::GQL_INTERFACE)); - if ($typeAnnotation->interfaces) { - $typeConfiguration['interfaces'] = $typeAnnotation->interfaces; + sort($interfaces); + $typeConfiguration['interfaces'] = $interfaces; } if ($typeAnnotation->resolveField) { @@ -357,12 +329,16 @@ private static function graphQLTypeConfigFromAnnotation(GQL\Type $typeAnnotation }, $typeAnnotation->builders); } - $publicAnnotation = self::getFirstAnnotationMatching($classAnnotations, GQL\IsPublic::class); + if ($typeAnnotation->isTypeOf) { + $typeConfiguration['isTypeOf'] = $typeAnnotation->isTypeOf; + } + + $publicAnnotation = self::getFirstAnnotationMatching($graphClass->getAnnotations(), GQL\IsPublic::class); if ($publicAnnotation) { $typeConfiguration['fieldsDefaultPublic'] = self::formatExpression($publicAnnotation->value); } - $accessAnnotation = self::getFirstAnnotationMatching($classAnnotations, GQL\Access::class); + $accessAnnotation = self::getFirstAnnotationMatching($graphClass->getAnnotations(), GQL\Access::class); if ($accessAnnotation) { $typeConfiguration['fieldsDefaultAccess'] = self::formatExpression($accessAnnotation->value); } @@ -373,15 +349,15 @@ private static function graphQLTypeConfigFromAnnotation(GQL\Type $typeAnnotation /** * Create a GraphQL Interface type configuration from annotations on properties. */ - private static function typeInterfaceAnnotationToGQLConfiguration(GQL\TypeInterface $interfaceAnnotation, array $classAnnotations, array $properties, array $methods, string $namespace): array + private static function typeInterfaceAnnotationToGQLConfiguration(GraphClass $graphClass, GQL\TypeInterface $interfaceAnnotation): array { $interfaceConfiguration = []; - $fields = self::getGraphQLFieldsFromAnnotations($namespace, $properties); - $fields = self::getGraphQLFieldsFromAnnotations($namespace, $methods, false, true) + $fields; + $fieldsFromProperties = self::getGraphQLTypeFieldsFromAnnotations($graphClass, $graphClass->getPropertiesExtended()); + $fieldsFromMethods = self::getGraphQLTypeFieldsFromAnnotations($graphClass, $graphClass->getMethods()); - $interfaceConfiguration['fields'] = $fields; - $interfaceConfiguration = self::getDescriptionConfiguration($classAnnotations) + $interfaceConfiguration; + $interfaceConfiguration['fields'] = array_merge($fieldsFromProperties, $fieldsFromMethods); + $interfaceConfiguration = self::getDescriptionConfiguration($graphClass->getAnnotations()) + $interfaceConfiguration; $interfaceConfiguration['resolveType'] = self::formatExpression($interfaceAnnotation->resolveType); @@ -391,13 +367,11 @@ private static function typeInterfaceAnnotationToGQLConfiguration(GQL\TypeInterf /** * Create a GraphQL Input type configuration from annotations on properties. */ - private static function inputAnnotationToGQLConfiguration(GQL\Input $inputAnnotation, array $classAnnotations, array $properties, string $namespace): array + private static function inputAnnotationToGQLConfiguration(GraphClass $graphClass, GQL\Input $inputAnnotation): array { - $inputConfiguration = []; - $fields = self::getGraphQLFieldsFromAnnotations($namespace, $properties, true); - - $inputConfiguration['fields'] = $fields; - $inputConfiguration = self::getDescriptionConfiguration($classAnnotations) + $inputConfiguration; + $inputConfiguration = array_merge([ + 'fields' => self::getGraphQLInputFieldsFromAnnotations($graphClass, $graphClass->getPropertiesExtended()), + ], self::getDescriptionConfiguration($graphClass->getAnnotations())); return ['type' => $inputAnnotation->isRelay ? 'relay-mutation-input' : 'input-object', 'config' => $inputConfiguration]; } @@ -405,7 +379,7 @@ private static function inputAnnotationToGQLConfiguration(GQL\Input $inputAnnota /** * Get a GraphQL scalar configuration from given scalar annotation. */ - private static function scalarAnnotationToGQLConfiguration(string $className, GQL\Scalar $scalarAnnotation, array $classAnnotations): array + private static function scalarAnnotationToGQLConfiguration(GraphClass $graphClass, GQL\Scalar $scalarAnnotation): array { $scalarConfiguration = []; @@ -413,13 +387,13 @@ private static function scalarAnnotationToGQLConfiguration(string $className, GQ $scalarConfiguration['scalarType'] = self::formatExpression($scalarAnnotation->scalarType); } else { $scalarConfiguration = [ - 'serialize' => [$className, 'serialize'], - 'parseValue' => [$className, 'parseValue'], - 'parseLiteral' => [$className, 'parseLiteral'], + 'serialize' => [$graphClass->getName(), 'serialize'], + 'parseValue' => [$graphClass->getName(), 'parseValue'], + 'parseLiteral' => [$graphClass->getName(), 'parseLiteral'], ]; } - $scalarConfiguration = self::getDescriptionConfiguration($classAnnotations) + $scalarConfiguration; + $scalarConfiguration = self::getDescriptionConfiguration($graphClass->getAnnotations()) + $scalarConfiguration; return ['type' => 'custom-scalar', 'config' => $scalarConfiguration]; } @@ -427,13 +401,13 @@ private static function scalarAnnotationToGQLConfiguration(string $className, GQ /** * Get a GraphQL Enum configuration from given enum annotation. */ - private static function enumAnnotationToGQLConfiguration(GQL\Enum $enumAnnotation, array $classAnnotations, array $constants): array + private static function enumAnnotationToGQLConfiguration(GraphClass $graphClass, GQL\Enum $enumAnnotation): array { $enumValues = $enumAnnotation->values ? $enumAnnotation->values : []; $values = []; - foreach ($constants as $name => $value) { + foreach ($graphClass->getConstants() as $name => $value) { $valueAnnotation = current(array_filter($enumValues, function ($enumValueAnnotation) use ($name) { return $enumValueAnnotation->name == $name; })); @@ -452,7 +426,7 @@ private static function enumAnnotationToGQLConfiguration(GQL\Enum $enumAnnotatio } $enumConfiguration = ['values' => $values]; - $enumConfiguration = self::getDescriptionConfiguration($classAnnotations) + $enumConfiguration; + $enumConfiguration = self::getDescriptionConfiguration($graphClass->getAnnotations()) + $enumConfiguration; return ['type' => 'enum', 'config' => $enumConfiguration]; } @@ -460,18 +434,35 @@ private static function enumAnnotationToGQLConfiguration(GQL\Enum $enumAnnotatio /** * Get a GraphQL Union configuration from given union annotation. */ - private static function unionAnnotationToGQLConfiguration(string $className, GQL\Union $unionAnnotation, array $classAnnotations, array $methods): array + private static function unionAnnotationToGQLConfiguration(GraphClass $graphClass, GQL\Union $unionAnnotation): array { - $unionConfiguration = ['types' => $unionAnnotation->types]; - $unionConfiguration = self::getDescriptionConfiguration($classAnnotations) + $unionConfiguration; + $unionConfiguration = []; + if (null !== $unionAnnotation->types) { + $unionConfiguration['types'] = $unionAnnotation->types; + } else { + $types = array_keys(self::searchClassesMapBy(function ($gqlType, $configuration) use ($graphClass) { + $typeClassName = $configuration['class']; + $typeMetadata = self::getGraphClass($typeClassName); + + if ($graphClass->isInterface() && $typeMetadata->implementsInterface($graphClass->getName())) { + return true; + } + + return $typeMetadata->isSubclassOf($graphClass->getName()); + }, self::GQL_TYPE)); + sort($types); + $unionConfiguration['types'] = $types; + } + + $unionConfiguration = self::getDescriptionConfiguration($graphClass->getAnnotations()) + $unionConfiguration; if ($unionAnnotation->resolveType) { $unionConfiguration['resolveType'] = self::formatExpression($unionAnnotation->resolveType); } else { - if (isset($methods['resolveType'])) { - $method = $methods['resolveType']['method']; + if ($graphClass->hasMethod('resolveType')) { + $method = $graphClass->getMethod('resolveType'); if ($method->isStatic() && $method->isPublic()) { - $unionConfiguration['resolveType'] = self::formatExpression(sprintf("@=call('%s::%s', [service('overblog_graphql.type_resolver'), value], true)", self::formatNamespaceForExpression($className), 'resolveType')); + $unionConfiguration['resolveType'] = self::formatExpression(sprintf("@=call('%s::%s', [service('overblog_graphql.type_resolver'), value], true)", self::formatNamespaceForExpression($graphClass->getName()), 'resolveType')); } else { throw new InvalidArgumentException(sprintf('The "resolveType()" method on class must be static and public. Or you must define a "resolveType" attribute on the @Union annotation.')); } @@ -484,126 +475,158 @@ private static function unionAnnotationToGQLConfiguration(string $className, GQL } /** - * Create GraphQL fields configuration based on annotations. + * @param ReflectionMethod|ReflectionProperty $reflector */ - private static function getGraphQLFieldsFromAnnotations(string $namespace, array $propertiesOrMethods, bool $isInput = false, bool $isMethod = false, string $currentValue = 'value', string $fieldAnnotationName = 'Field'): array + private static function getTypeFieldConfigurationFromReflector(GraphClass $graphClass, Reflector $reflector, string $fieldAnnotationName = GQL\Field::class, string $currentValue = 'value'): array { - $fields = []; - foreach ($propertiesOrMethods as $target => $config) { - $annotations = $config['annotations']; - $method = $isMethod ? $config['method'] : false; + $annotations = $graphClass->getAnnotations($reflector); - $fieldAnnotation = self::getFirstAnnotationMatching($annotations, sprintf('Overblog\GraphQLBundle\Annotation\%s', $fieldAnnotationName)); - $accessAnnotation = self::getFirstAnnotationMatching($annotations, GQL\Access::class); - $publicAnnotation = self::getFirstAnnotationMatching($annotations, GQL\IsPublic::class); + $fieldAnnotation = self::getFirstAnnotationMatching($annotations, $fieldAnnotationName); + $accessAnnotation = self::getFirstAnnotationMatching($annotations, GQL\Access::class); + $publicAnnotation = self::getFirstAnnotationMatching($annotations, GQL\IsPublic::class); - if (!$fieldAnnotation) { - if ($accessAnnotation || $publicAnnotation) { - throw new InvalidArgumentException(sprintf('The annotations "@Access" and/or "@Visible" defined on "%s" are only usable in addition of annotation "@Field"', $target)); - } - continue; + if (!$fieldAnnotation) { + if ($accessAnnotation || $publicAnnotation) { + throw new InvalidArgumentException(sprintf('The annotations "@Access" and/or "@Visible" defined on "%s" are only usable in addition of annotation "@Field"', $reflector->getName())); } - if ($isMethod && !$method->isPublic()) { - throw new InvalidArgumentException(sprintf('The Annotation "@Field" can only be applied to public method. The method "%s" is not public.', $target)); - } + return []; + } - // Ignore field with resolver when the type is an Input - if ($fieldAnnotation->resolve && $isInput) { - continue; - } + if ($reflector instanceof ReflectionMethod && !$reflector->isPublic()) { + throw new InvalidArgumentException(sprintf('The Annotation "@Field" can only be applied to public method. The method "%s" is not public.', $reflector->getName())); + } - $fieldName = $target; - $fieldType = $fieldAnnotation->type; - $fieldConfiguration = []; - if ($fieldType) { - $resolvedType = self::resolveClassFromType($fieldType); - if (null !== $resolvedType && $isInput && !in_array($resolvedType['type'], self::VALID_INPUT_TYPES)) { - throw new InvalidArgumentException(sprintf('The type "%s" on "%s" is a "%s" not valid on an Input @Field. Only Input, Scalar and Enum are allowed.', $fieldType, $target, $resolvedType['type'])); - } + $fieldName = $reflector->getName(); + $fieldType = $fieldAnnotation->type; + $fieldConfiguration = []; + if ($fieldType) { + $fieldConfiguration['type'] = $fieldType; + } - $fieldConfiguration['type'] = $fieldType; + $fieldConfiguration = self::getDescriptionConfiguration($annotations, true) + $fieldConfiguration; + + $args = []; + if (!empty($fieldAnnotation->args)) { + foreach ($fieldAnnotation->args as $arg) { + $args[$arg->name] = ['type' => $arg->type] + + ($arg->description ? ['description' => $arg->description] : []) + + ($arg->default ? ['defaultValue' => $arg->default] : []); } + } elseif ($reflector instanceof ReflectionMethod) { + $args = self::guessArgs($reflector); + } - $fieldConfiguration = self::getDescriptionConfiguration($annotations, true) + $fieldConfiguration; + if (!empty($args)) { + $fieldConfiguration['args'] = $args; + } - if (!$isInput) { - $args = self::getArgs($fieldAnnotation->args, $isMethod && !$fieldAnnotation->argsBuilder ? $method : null); + $fieldName = $fieldAnnotation->name ?: $fieldName; - if (!empty($args)) { - $fieldConfiguration['args'] = $args; + if ($fieldAnnotation->resolve) { + $fieldConfiguration['resolve'] = self::formatExpression($fieldAnnotation->resolve); + } else { + if ($reflector instanceof ReflectionMethod) { + $fieldConfiguration['resolve'] = self::formatExpression(sprintf('call(%s.%s, %s)', $currentValue, $reflector->getName(), self::formatArgsForExpression($args))); + } else { + if ($fieldName !== $reflector->getName() || 'value' !== $currentValue) { + $fieldConfiguration['resolve'] = self::formatExpression(sprintf('%s.%s', $currentValue, $reflector->getName())); } + } + } - $fieldName = $fieldAnnotation->name ?: $fieldName; + if ($fieldAnnotation->argsBuilder) { + if (is_string($fieldAnnotation->argsBuilder)) { + $fieldConfiguration['argsBuilder'] = $fieldAnnotation->argsBuilder; + } elseif (is_array($fieldAnnotation->argsBuilder)) { + list($builder, $builderConfig) = $fieldAnnotation->argsBuilder; + $fieldConfiguration['argsBuilder'] = ['builder' => $builder, 'config' => $builderConfig]; + } else { + throw new InvalidArgumentException(sprintf('The attribute "argsBuilder" on GraphQL annotation "@%s" defined on "%s" must be a string or an array where first index is the builder name and the second is the config.', $fieldAnnotationName, $reflector->getName())); + } + } - if ($fieldAnnotation->resolve) { - $fieldConfiguration['resolve'] = self::formatExpression($fieldAnnotation->resolve); - } else { - if ($isMethod) { - $fieldConfiguration['resolve'] = self::formatExpression(sprintf('call(%s.%s, %s)', $currentValue, $target, self::formatArgsForExpression($args))); - } else { - if ($fieldName !== $target || 'value' !== $currentValue) { - $fieldConfiguration['resolve'] = self::formatExpression(sprintf('%s.%s', $currentValue, $target)); + if ($fieldAnnotation->fieldBuilder) { + if (is_string($fieldAnnotation->fieldBuilder)) { + $fieldConfiguration['builder'] = $fieldAnnotation->fieldBuilder; + } elseif (is_array($fieldAnnotation->fieldBuilder)) { + list($builder, $builderConfig) = $fieldAnnotation->fieldBuilder; + $fieldConfiguration['builder'] = $builder; + $fieldConfiguration['builderConfig'] = $builderConfig ?: []; + } else { + throw new InvalidArgumentException(sprintf('The attribute "argsBuilder" on GraphQL annotation "@%s" defined on "%s" must be a string or an array where first index is the builder name and the second is the config.', $fieldAnnotationName, $reflector->getName())); + } + } else { + if (!$fieldType) { + if ($reflector instanceof ReflectionMethod) { + /** @var ReflectionMethod $reflector */ + if ($reflector->hasReturnType()) { + try { + // @phpstan-ignore-next-line + $fieldConfiguration['type'] = self::resolveGraphQLTypeFromReflectionType($reflector->getReturnType(), self::VALID_OUTPUT_TYPES); + } catch (Exception $e) { + throw new InvalidArgumentException(sprintf('The attribute "type" on GraphQL annotation "@%s" is missing on method "%s" and cannot be auto-guessed from type hint "%s"', $fieldAnnotationName, $reflector->getName(), (string) $reflector->getReturnType())); } - } - } - - if ($fieldAnnotation->argsBuilder) { - if (is_string($fieldAnnotation->argsBuilder)) { - $fieldConfiguration['argsBuilder'] = $fieldAnnotation->argsBuilder; - } elseif (is_array($fieldAnnotation->argsBuilder)) { - list($builder, $builderConfig) = $fieldAnnotation->argsBuilder; - $fieldConfiguration['argsBuilder'] = ['builder' => $builder, 'config' => $builderConfig]; } else { - throw new InvalidArgumentException(sprintf('The attribute "argsBuilder" on GraphQL annotation "@%s" defined on "%s" must be a string or an array where first index is the builder name and the second is the config.', $fieldAnnotationName, $target)); - } - } - - if ($fieldAnnotation->fieldBuilder) { - if (is_string($fieldAnnotation->fieldBuilder)) { - $fieldConfiguration['builder'] = $fieldAnnotation->fieldBuilder; - } elseif (is_array($fieldAnnotation->fieldBuilder)) { - list($builder, $builderConfig) = $fieldAnnotation->fieldBuilder; - $fieldConfiguration['builder'] = $builder; - $fieldConfiguration['builderConfig'] = $builderConfig ?: []; - } else { - throw new InvalidArgumentException(sprintf('The attribute "argsBuilder" on GraphQL annotation "@%s" defined on "%s" must be a string or an array where first index is the builder name and the second is the config.', $fieldAnnotationName, $target)); + throw new InvalidArgumentException(sprintf('The attribute "type" on GraphQL annotation "@%s" is missing on method "%s" and cannot be auto-guessed as there is not return type hint.', $fieldAnnotationName, $reflector->getName())); } } else { - if (!$fieldType) { - if ($isMethod) { - if ($method->hasReturnType()) { - try { - $fieldConfiguration['type'] = self::resolveGraphQLTypeFromReflectionType($method->getReturnType(), self::VALID_OUTPUT_TYPES); - } catch (Exception $e) { - throw new InvalidArgumentException(sprintf('The attribute "type" on GraphQL annotation "@%s" is missing on method "%s" and cannot be auto-guessed from type hint "%s"', $fieldAnnotationName, $target, (string) $method->getReturnType())); - } - } else { - throw new InvalidArgumentException(sprintf('The attribute "type" on GraphQL annotation "@%s" is missing on method "%s" and cannot be auto-guessed as there is not return type hint.', $fieldAnnotationName, $target)); - } - } else { - try { - $fieldConfiguration['type'] = self::guessType($namespace, $annotations); - } catch (Exception $e) { - throw new InvalidArgumentException(sprintf('The attribute "type" on "@%s" defined on "%s" is required and cannot be auto-guessed : %s.', $fieldAnnotationName, $target, $e->getMessage())); - } - } + try { + $fieldConfiguration['type'] = self::guessType($graphClass, $annotations); + } catch (Exception $e) { + throw new InvalidArgumentException(sprintf('The attribute "type" on "@%s" defined on "%s" is required and cannot be auto-guessed : %s.', $fieldAnnotationName, $reflector->getName(), $e->getMessage())); } } + } + } - if ($accessAnnotation) { - $fieldConfiguration['access'] = self::formatExpression($accessAnnotation->value); - } + if ($accessAnnotation) { + $fieldConfiguration['access'] = self::formatExpression($accessAnnotation->value); + } - if ($publicAnnotation) { - $fieldConfiguration['public'] = self::formatExpression($publicAnnotation->value); - } + if ($publicAnnotation) { + $fieldConfiguration['public'] = self::formatExpression($publicAnnotation->value); + } + + if ($fieldAnnotation->complexity) { + $fieldConfiguration['complexity'] = self::formatExpression($fieldAnnotation->complexity); + } + + return [$fieldName => $fieldConfiguration]; + } + + /** + * Create GraphQL input fields configuration based on annotations. + * + * @param ReflectionProperty[] $reflectors + */ + private static function getGraphQLInputFieldsFromAnnotations(GraphClass $graphClass, array $reflectors): array + { + $fields = []; + + foreach ($reflectors as $reflector) { + $annotations = $graphClass->getAnnotations($reflector); + $fieldAnnotation = self::getFirstAnnotationMatching($annotations, GQL\Field::class); + + // Ignore field with resolver when the type is an Input + if ($fieldAnnotation->resolve) { + return []; + } - if ($fieldAnnotation->complexity) { - $fieldConfiguration['complexity'] = self::formatExpression($fieldAnnotation->complexity); + $fieldName = $reflector->getName(); + $fieldType = $fieldAnnotation->type; + $fieldConfiguration = []; + if ($fieldType) { + $resolvedType = self::resolveClassFromType($fieldType); + // We found a type but it is not allowed + if (null !== $resolvedType && !in_array($resolvedType['type'], self::VALID_INPUT_TYPES)) { + throw new InvalidArgumentException(sprintf('The type "%s" on "%s" is a "%s" not valid on an Input @Field. Only Input, Scalar and Enum are allowed.', $fieldType, $reflector->getName(), $resolvedType['type'])); } + + $fieldConfiguration['type'] = $fieldType; } + $fieldConfiguration = array_merge(self::getDescriptionConfiguration($annotations, true), $fieldConfiguration); $fields[$fieldName] = $fieldConfiguration; } @@ -611,45 +634,71 @@ private static function getGraphQLFieldsFromAnnotations(string $namespace, array } /** - * Return fields config from Provider methods. + * Create GraphQL type fields configuration based on annotations. + * + * @param ReflectionProperty[]|ReflectionMethod[] $reflectors */ - private static function getGraphQLFieldsFromProviders(string $namespace, string $annotationName, string $targetType, bool $isRoot = false): array + private static function getGraphQLTypeFieldsFromAnnotations(GraphClass $graphClass, array $reflectors, string $fieldAnnotationName = GQL\Field::class, string $currentValue = 'value'): array { $fields = []; - foreach (self::$providers as $className => $configuration) { - $providerMethods = $configuration['methods']; - $providerAnnotation = $configuration['annotation']; - $providerAnnotations = $configuration['annotations']; - $defaultAccessAnnotation = self::getFirstAnnotationMatching($providerAnnotations, GQL\Access::class); - $defaultIsPublicAnnotation = self::getFirstAnnotationMatching($providerAnnotations, GQL\IsPublic::class); + foreach ($reflectors as $reflector) { + $fields = array_merge($fields, self::getTypeFieldConfigurationFromReflector($graphClass, $reflector, $fieldAnnotationName, $currentValue)); + } + + return $fields; + } + + /** + * Return fields config from Provider methods. + * Loop through configured provider and extract fields targeting the targetType. + */ + private static function getGraphQLFieldsFromProviders(GraphClass $graphClass, string $expectedAnnotation, string $targetType, bool $isDefaultTarget = false): array + { + $fields = []; + foreach (self::$providers as ['metadata' => $providerMetadata, 'annotation' => $providerAnnotation]) { + $defaultAccessAnnotation = self::getFirstAnnotationMatching($providerMetadata->getAnnotations(), GQL\Access::class); + $defaultIsPublicAnnotation = self::getFirstAnnotationMatching($providerMetadata->getAnnotations(), GQL\IsPublic::class); $defaultAccess = $defaultAccessAnnotation ? self::formatExpression($defaultAccessAnnotation->value) : false; $defaultIsPublic = $defaultIsPublicAnnotation ? self::formatExpression($defaultIsPublicAnnotation->value) : false; - $filteredMethods = []; - foreach ($providerMethods as $methodName => $config) { - $annotations = $config['annotations']; + $methods = []; + // First found the methods matching the targeted type + foreach ($providerMetadata->getMethods() as $method) { + $annotations = $providerMetadata->getAnnotations($method); - $annotation = self::getFirstAnnotationMatching($annotations, sprintf('Overblog\\GraphQLBundle\\Annotation\\%s', $annotationName)); + $annotation = self::getFirstAnnotationMatching($annotations, [GQL\Mutation::class, GQL\Query::class]); if (!$annotation) { continue; } - $annotationTarget = 'Query' === $annotationName ? $annotation->targetType : null; - if (!$annotationTarget && $isRoot) { + $annotationTarget = $annotation->targetType; + if (!$annotationTarget && $isDefaultTarget) { $annotationTarget = $targetType; + if (!($annotation instanceof $expectedAnnotation)) { + continue; + } } if ($annotationTarget !== $targetType) { continue; } - $filteredMethods[$methodName] = $config; + if (!($annotation instanceof $expectedAnnotation)) { + if (GQL\Mutation::class == $expectedAnnotation) { + $message = sprintf('The provider "%s" try to add a query field on type "%s" (through @Query on method "%s") but "%s" is a mutation.', $providerMetadata->getName(), $targetType, $method->getName(), $targetType); + } else { + $message = sprintf('The provider "%s" try to add a mutation on type "%s" (through @Mutation on method "%s") but "%s" is not a mutation.', $providerMetadata->getName(), $targetType, $method->getName(), $targetType); + } + + throw new InvalidArgumentException($message); + } + $methods[$method->getName()] = $method; } - $currentValue = sprintf("service('%s')", self::formatNamespaceForExpression($className)); - $providerFields = self::getGraphQLFieldsFromAnnotations($namespace, $filteredMethods, false, true, $currentValue, $annotationName); + $currentValue = sprintf("service('%s')", self::formatNamespaceForExpression($providerMetadata->getName())); + $providerFields = self::getGraphQLTypeFieldsFromAnnotations($graphClass, $methods, $expectedAnnotation, $currentValue); foreach ($providerFields as $fieldName => $fieldConfig) { if ($providerAnnotation->prefix) { $fieldName = sprintf('%s%s', $providerAnnotation->prefix, $fieldName); @@ -691,25 +740,6 @@ private static function getDescriptionConfiguration(array $annotations, bool $wi return $config; } - /** - * Get args config from an array of @Arg annotation or by auto-guessing if a method is provided. - */ - private static function getArgs(?array $args, ReflectionMethod $method = null): array - { - $config = []; - if (!empty($args)) { - foreach ($args as $arg) { - $config[$arg->name] = ['type' => $arg->type] - + ($arg->description ? ['description' => $arg->description] : []) - + ($arg->default ? ['defaultValue' => $arg->default] : []); - } - } elseif ($method) { - $config = self::guessArgs($method); - } - - return $config; - } - /** * Format an array of args to a list of arguments in an expression. */ @@ -772,11 +802,11 @@ private static function suffixName(string $name, string $suffix): string } /** - * Try to guess a field type base on is annotations. + * Try to guess a field type base on his annotations. * * @throws RuntimeException */ - private static function guessType(string $namespace, array $annotations): string + private static function guessType(GraphClass $graphClass, array $annotations): string { $columnAnnotation = self::getFirstAnnotationMatching($annotations, Column::class); if ($columnAnnotation) { @@ -798,7 +828,7 @@ private static function guessType(string $namespace, array $annotations): string $associationAnnotation = self::getFirstAnnotationMatching($annotations, array_keys($associationAnnotations)); if ($associationAnnotation) { - $target = self::fullyQualifiedClassName($associationAnnotation->targetEntity, $namespace); + $target = self::fullyQualifiedClassName($associationAnnotation->targetEntity, $graphClass->getNamespaceName()); $type = self::resolveTypeFromClass($target, ['type']); if ($type) { @@ -939,6 +969,27 @@ private static function resolveClassFromType(string $type) return self::$classesMap[$type] ?? null; } + /** + * Search the classes map for class by predicate. + * + * @return array + */ + private static function searchClassesMapBy(callable $predicate, string $type = null) + { + $classNames = []; + foreach (self::$classesMap as $gqlType => $config) { + if ($type && $config['type'] !== $type) { + continue; + } + + if ($predicate($gqlType, $config)) { + $classNames[$gqlType] = $config; + } + } + + return $classNames; + } + /** * Convert a PHP Builtin type to a GraphQL type. */ diff --git a/src/DependencyInjection/OverblogGraphQLExtension.php b/src/DependencyInjection/OverblogGraphQLExtension.php index 2bbca5f26..092baa225 100644 --- a/src/DependencyInjection/OverblogGraphQLExtension.php +++ b/src/DependencyInjection/OverblogGraphQLExtension.php @@ -31,6 +31,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; @@ -105,7 +106,7 @@ private function registerForAutoconfiguration(ContainerBuilder $container): void private function registerValidatorFactory(ContainerBuilder $container): void { - if (class_exists('Symfony\\Component\\Validator\\Validation')) { + if (class_exists(Validation::class)) { $container->register(ValidatorFactory::class) ->setArguments([ new Reference('validator.validator_factory'), diff --git a/tests/Config/Parser/AnnotationParserTest.php b/tests/Config/Parser/AnnotationParserTest.php index 571953ff2..48455eee1 100644 --- a/tests/Config/Parser/AnnotationParserTest.php +++ b/tests/Config/Parser/AnnotationParserTest.php @@ -22,6 +22,7 @@ class AnnotationParserTest extends TestCase 'definitions' => [ 'schema' => [ 'default' => ['query' => 'RootQuery', 'mutation' => 'RootMutation'], + 'second' => ['query' => 'RootQuery2', 'mutation' => 'RootMutation2'], ], ], 'doctrine' => [ @@ -99,6 +100,7 @@ public function testTypes(): void $this->expect('Droid', 'object', [ 'description' => 'The Droid type', 'interfaces' => ['Character'], + 'isTypeOf' => "@=isTypeOf('App\Entity\Droid')", 'fields' => [ 'name' => ['type' => 'String!', 'description' => 'The name of the character'], 'friends' => ['type' => '[Character]', 'description' => 'The friends of the character', 'resolve' => "@=resolver('App\\\\MyResolver::getFriends')"], @@ -211,6 +213,25 @@ public function testUnion(): void ]); } + public function testUnionAutoguessed(): void + { + $this->expect('Killable', 'union', [ + 'types' => ['Hero', 'Mandalorian', 'Sith'], + 'resolveType' => '@=value.getType()', + ]); + } + + public function testInterfaceAutoguessed(): void + { + $this->expect('Mandalorian', 'object', [ + 'interfaces' => ['Armored', 'Character'], + 'fields' => [ + 'name' => ['type' => 'String!', 'description' => 'The name of the character'], + 'friends' => ['type' => '[Character]', 'description' => 'The friends of the character', 'resolve' => "@=resolver('App\\\\MyResolver::getFriends')"], + ], + ]); + } + public function testScalar(): void { $this->expect('GalaxyCoordinates', 'custom-scalar', [ @@ -248,6 +269,32 @@ public function testProviders(): void ]); } + public function testProvidersMultischema(): void + { + $this->expect('RootQuery2', 'object', [ + 'fields' => [ + 'planet_getPlanetSchema2' => [ + 'type' => 'Planet', + 'resolve' => "@=call(service('Overblog\\\\GraphQLBundle\\\\Tests\\\\Config\\\\Parser\\\\fixtures\\\\annotations\\\\Repository\\\\PlanetRepository').getPlanetSchema2, arguments({}, args))", + 'access' => '@=default_access', + 'public' => '@=default_public', + ], + ], + ]); + + $this->expect('RootMutation2', 'object', [ + 'fields' => [ + 'planet_createPlanetSchema2' => [ + 'type' => 'Planet', + 'args' => ['planetInput' => ['type' => 'PlanetInput!']], + 'resolve' => "@=call(service('Overblog\\\\GraphQLBundle\\\\Tests\\\\Config\\\\Parser\\\\fixtures\\\\annotations\\\\Repository\\\\PlanetRepository').createPlanetSchema2, arguments({planetInput: \"PlanetInput!\"}, args))", + 'access' => '@=default_access', + 'public' => '@=override_public', + ], + ], + ]); + } + public function testFullqualifiedName(): void { $this->assertEquals(self::class, AnnotationParser::fullyQualifiedClassName(self::class, 'Overblog\GraphQLBundle')); @@ -406,4 +453,33 @@ public function testFieldOnPrivateProperty(): void $this->assertMatchesRegularExpression('/The Annotation "@Field" can only be applied to public method/', $e->getPrevious()->getMessage()); } } + + public function testInvalidProviderQueryOnMutation(): void + { + $file = __DIR__.'/fixtures/annotations/Invalid/InvalidProvider.php'; + AnnotationParser::preParse(new SplFileInfo($file), $this->containerBuilder, $this->parserConfig); + + try { + $mutationFile = __DIR__.'/fixtures/annotations/Type/RootMutation2.php'; + AnnotationParser::parse(new SplFileInfo($mutationFile), $this->containerBuilder, $this->parserConfig); + $this->fail('Using @Query targeting mutation type should raise an exception'); + } catch (Exception $e) { + $this->assertInstanceOf(InvalidArgumentException::class, $e); + $this->assertMatchesRegularExpression('/try to add a query field on type "RootMutation2"/', $e->getPrevious()->getMessage()); + } + } + + public function testInvalidProviderMutationOnQuery(): void + { + $file = __DIR__.'/fixtures/annotations/Invalid/InvalidProvider.php'; + AnnotationParser::preParse(new SplFileInfo($file), $this->containerBuilder, $this->parserConfig); + try { + $queryFile = __DIR__.'/fixtures/annotations/Type/RootQuery2.php'; + AnnotationParser::parse(new SplFileInfo($queryFile), $this->containerBuilder, $this->parserConfig); + $this->fail('Using @Mutation targeting query type should raise an exception'); + } catch (Exception $e) { + $this->assertInstanceOf(InvalidArgumentException::class, $e); + $this->assertMatchesRegularExpression('/try to add a mutation on type "RootQuery2"/', $e->getPrevious()->getMessage()); + } + } } diff --git a/tests/Config/Parser/fixtures/annotations/Invalid/InvalidProvider.php b/tests/Config/Parser/fixtures/annotations/Invalid/InvalidProvider.php new file mode 100644 index 000000000..3f0b3206d --- /dev/null +++ b/tests/Config/Parser/fixtures/annotations/Invalid/InvalidProvider.php @@ -0,0 +1,29 @@ +markTestSkipped('Symfony validator component is not installed'); } $this->listener = new ValidationErrorsListener(); diff --git a/tests/ExpressionLanguage/ExpressionFunction/GraphQL/ArgumentsTest.php b/tests/ExpressionLanguage/ExpressionFunction/GraphQL/ArgumentsTest.php index d1093c963..7acc4e207 100644 --- a/tests/ExpressionLanguage/ExpressionFunction/GraphQL/ArgumentsTest.php +++ b/tests/ExpressionLanguage/ExpressionFunction/GraphQL/ArgumentsTest.php @@ -15,6 +15,7 @@ use Overblog\GraphQLBundle\Tests\Transformer\InputType1; use Overblog\GraphQLBundle\Tests\Transformer\InputType2; use Overblog\GraphQLBundle\Transformer\ArgumentsTransformer; +use Symfony\Component\Validator\Validation; use Symfony\Component\Validator\Validator\RecursiveValidator; use function class_exists; use function count; @@ -23,7 +24,7 @@ class ArgumentsTest extends TestCase { public function setUp(): void { - if (!class_exists('Symfony\\Component\\Validator\\Validation')) { + if (!class_exists(Validation::class)) { $this->markTestSkipped('Symfony validator component is not installed'); } parent::setUp(); diff --git a/tests/Functional/Validator/InputValidatorTest.php b/tests/Functional/Validator/InputValidatorTest.php index 68d7493e6..1897ab2db 100644 --- a/tests/Functional/Validator/InputValidatorTest.php +++ b/tests/Functional/Validator/InputValidatorTest.php @@ -5,6 +5,7 @@ namespace Overblog\GraphQLBundle\Tests\Functional\Validator; use Overblog\GraphQLBundle\Tests\Functional\TestCase; +use Symfony\Component\Validator\Validation; use function class_exists; use function json_decode; @@ -13,7 +14,7 @@ class InputValidatorTest extends TestCase protected function setUp(): void { parent::setUp(); - if (!class_exists('Symfony\\Component\\Validator\\Validation')) { + if (!class_exists(Validation::class)) { $this->markTestSkipped('Symfony validator component is not installed'); } static::bootKernel(['test_case' => 'validator']); diff --git a/tests/Transformer/ArgumentsTransformerTest.php b/tests/Transformer/ArgumentsTransformerTest.php index a5ea746cd..71ef0b398 100644 --- a/tests/Transformer/ArgumentsTransformerTest.php +++ b/tests/Transformer/ArgumentsTransformerTest.php @@ -16,6 +16,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Validator\ConstraintViolation; use Symfony\Component\Validator\ConstraintViolationList; +use Symfony\Component\Validator\Validation; use Symfony\Component\Validator\Validator\RecursiveValidator; use function class_exists; use function count; @@ -26,7 +27,7 @@ class ArgumentsTransformerTest extends TestCase protected function setUp(): void { parent::setUp(); - if (!class_exists('Symfony\\Component\\Validator\\Validation')) { + if (!class_exists(Validation::class)) { $this->markTestSkipped('Symfony validator component is not installed'); } } diff --git a/tests/Validator/InputValidatorTest.php b/tests/Validator/InputValidatorTest.php index f409d7684..7869df3e8 100644 --- a/tests/Validator/InputValidatorTest.php +++ b/tests/Validator/InputValidatorTest.php @@ -9,6 +9,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; use Symfony\Component\Validator\ConstraintValidatorFactory; +use Symfony\Component\Validator\Validation; use function class_exists; class InputValidatorTest extends TestCase @@ -16,7 +17,7 @@ class InputValidatorTest extends TestCase public function setUp(): void { parent::setUp(); - if (!class_exists('Symfony\\Component\\Validator\\Validation')) { + if (!class_exists(Validation::class)) { $this->markTestSkipped('Symfony validator component is not installed'); } } diff --git a/tests/Validator/Mapping/MetadataFactoryTest.php b/tests/Validator/Mapping/MetadataFactoryTest.php index 8bcc087da..d334cbbc7 100644 --- a/tests/Validator/Mapping/MetadataFactoryTest.php +++ b/tests/Validator/Mapping/MetadataFactoryTest.php @@ -11,13 +11,14 @@ use PHPUnit\Framework\TestCase; use stdClass; use Symfony\Component\Validator\Exception\NoSuchMetadataException; +use Symfony\Component\Validator\Validation; use function class_exists; class MetadataFactoryTest extends TestCase { public function setUp(): void { - if (!class_exists('Symfony\\Component\\Validator\\Validation')) { + if (!class_exists(Validation::class)) { $this->markTestSkipped('Symfony validator component is not installed'); } parent::setUp();