diff --git a/composer.json b/composer.json index 86c012028..67747c5f4 100644 --- a/composer.json +++ b/composer.json @@ -65,6 +65,7 @@ "symfony/process": "^3.4 || ^4.0", "symfony/security-bundle": "^3.4 || ^4.0", "symfony/templating": "^3.4 || ^4.0", + "symfony/validator": "^3.4 || ^4.0", "symfony/web-profiler-bundle": "^3.4 || ^4.0", "symfony/yaml": "^3.4 || ^4.0" }, diff --git a/docs/annotations/annotations-reference.md b/docs/annotations/annotations-reference.md index b9f0b2980..e0cbf6efc 100644 --- a/docs/annotations/annotations-reference.md +++ b/docs/annotations/annotations-reference.md @@ -8,7 +8,7 @@ In the following reference examples, the `use Overblog\GraphQLBundle\Annotation - For example, `@GQL\Access("isAuthenticated()")` will be converted to `['access' => '@=isAuthenticated()']`. -- You can use multiple type annotations on the same class. For example, if you need your class to be a Graphql Type AND a Graphql InputType, you just need to add the two annotations. Incompatible annotations or properties for a specified Type will simply be ignored. +- You can use multiple type annotations on the same class. For example, if you need your class to be a Graphql Type AND a Graphql Input, you just need to add the two annotations. Incompatible annotations or properties for a specified Type will simply be ignored. In the following example, both the type `Coordinates` and the input type `CoordinatesInput` will be generated. As fields on input type don't support resolvers, the field `elevation` will simply be ignored to generate the input type (it will only have two fields: `latitude` and `longitude`). @@ -18,7 +18,7 @@ As fields on input type don't support resolvers, the field `elevation` will simp /** * @GQL\Type - * @GQL\InputType + * @GQL\Input */ class Coordinates { /** @@ -57,7 +57,7 @@ class Coordinates { @FieldBuilder -@InputType +@Input @IsPublic @@ -174,7 +174,7 @@ class Hero { ## @Description -This annotation is used in conjonction with one of `@Enum`, `@Field`, `@InputType`, `@Scalar`, `@Type`, `@TypeInterface`, `@Union` to set a description for the GraphQL object. +This annotation is used in conjonction with one of `@Enum`, `@Field`, `@Input`, `@Scalar`, `@Type`, `@TypeInterface`, `@Union` to set a description for the GraphQL object. Example @@ -205,6 +205,8 @@ Optional attributes: - **name** : The GraphQL name of the enum (default to the class name without namespace) - **values** : An array of `@EnumValue`to define description or deprecated reason of enum values +The class will also be used by the `Input Builder` service when an `Enum` is encoutered in a Mutation or Query Input. A property accessor will try to populate a property name `value`. + Example: ```php @@ -223,10 +225,14 @@ class Planet const TATOUINE = "2"; const HOTH = "3"; const BESPIN = "4"; + + public $value; } ?> ``` +In the example above, if a query or mutation has this Enum as an argument, the value will be an instanceof the class with the enum value as the `value` property. (see [The Input Builder documentation](input-builder.md)). + ## @EnumValue This annotation is used in the `values` attribute on the `@Enum` annotation to add a description or deprecation reason on his value. See `@Enum` example above. @@ -308,7 +314,7 @@ class Hero { ?> ``` -## @InputType +## @Input This annotation is used on a _class_ to define an input type. An Input type is pretty much the same as an input except: @@ -320,6 +326,8 @@ Optional attributes: - **name** : The GraphQL name of the input field (default to classnameInput ) - **isRelay** : Set to true if you want your input to be relay compatible (ie. An extra field `clientMutationId` will be added to the input) +The corresponding class will also be used by the `Input Builder` service. A instance of the corresponding class will be use as the `input` value if it is an argument of a query or mutation. (see [The Input Builder documentation](input-builder.md)). + ## @IsPublic Added on a _class_ in conjonction with `@Type` or `@TypeInterface`, this annotation will define the defaut to set if fields are public or not. diff --git a/docs/annotations/index.md b/docs/annotations/index.md index a3a1c6fe7..0e6abe360 100644 --- a/docs/annotations/index.md +++ b/docs/annotations/index.md @@ -3,11 +3,15 @@ ## Annotations reference - [Annotations reference](annotations-reference.md) +## Input populating & validation +- [The Input Builder](input-builder.md) + ## Annotations & type inheritance As PHP classes naturally support inheritances (and so is the annotation reader), it doesn't make sense to allow classes to use the "inherits" option. The type will inherits the annotations declared on parent classes properties and methods. The annotation on the class itself will not be herited. + ## Annotations, Root Query & Root Mutation If you define your Root Query, or Root Mutation as a class with annotations, it will allow you to define methods directly on the class itself to be expose as GraphQL. @@ -48,6 +52,21 @@ The type can be auto-guess from : - `@ORM\ManyToOne`, `@ORM\OneToOne` The generated type will also use the `@ORM\JoinColumn` annotation and his `nullable` attribute to generate either `Type` or `Type!` - `@ORM\ManyToMany`, `@ORM\OneToMany` The generated type will always be not null, like `[Type]!` as you're supposed to initialize corresponding properties with an ArrayCollection +You can also provide your own doctrine / GraphQL types mappings in the bundle configuration. +For example: + + +```yaml (graphql.yaml) +overblog_graphql: + ... + doctrine: + types_mapping: + text[]: "[String]" + datetime: DateTime # If you have registered this custom scalar + +``` + + ### @Field type auto-guessing when applied on a method with a return type hint The type of a `@Field` annotation can be auto-guessed if it applies on a method with a return type hint. @@ -104,3 +123,11 @@ As PHP type hinting doesn't support "array of instances of class", we cannot rel In these case, you'll need to declare your types or arguments type manually. For example, in PHP, a signature like this : `public function getArrayOfStrings(): string[] {}` is invalid. + + + + + + + + diff --git a/docs/annotations/input-builder.md b/docs/annotations/input-builder.md new file mode 100644 index 000000000..7c937e8ce --- /dev/null +++ b/docs/annotations/input-builder.md @@ -0,0 +1,102 @@ +# The Input Builder service + +When using annotation, as we use classes to describe our GraphQL objects, it is also possible to create and populate classes instances using GraphQL data. +If a class is used to describe a GraphQL Input, this same class can be instanciated to hold the corresponding GraphQL Input data. +This is where the `Input Builder` comes into play. Knowing the matching between GraphQL types and PHP classes, the service is able to instanciate a PHP classes and populate it with data based on the corresponding GraphQL type. +To invoke the Input Builder, we use the `input` expression function in our resolvers. + +## the `input` function in expression language + +The `input` function take two parameter, a GraphQL type (in GraphQL notation, eventually with the "[]" and "!") and a variable containing data. +This function will use the `Input Builder` service to create an instance of the PHP class matching the GraphQL type if it has one and using a property accessor, it will populate the instance, and will use the `validator` service to validate it. +The transformation is done recursively. If an Input include another Input as field, it will also be populated the same way. + +For example: + +```php +namespace App\GraphQL\Input; + +use Overblog\GraphQLBundle\Annotation as GQL; +use Symfony\Component\Validator\Constraints as Assert; + +/** + * @GQL\Input + */ +class UserRegisterInput { + /** + * @GQL\Field(type="String!") + * @Assert\NotBlank + * @Assert\Length(min = 2, max = 50) + */ + public $username; + + /** + * @GQL\Field(type="String!") + * @Assert\NotBlank + * @Assert\Email + */ + public $email; + + /** + * @GQL\Field(type="String!") + * @Assert\NotBlank + * @Assert\Length( + * min = 5, + * minMessage="The password must be at least 5 characters long." + * ) + */ + public $password; + + /** + * @GQL\Field(type="Int!") + * @Assert\NotBlank + * @Assert\GreaterThan(18) + */ + public $age; +} + +.... + +/** + * @GQL\Provider + */ +class UserRepository { + /** + * @GQL\Mutation + */ + public function createUser(UserRegisterInput $input) : User { + // Use the validated $input here + $user = new User(); + $user->setUsername($input->username); + $user->setPassword($input->password); + ... + } +} +``` + +When this Input is used in a mutation, the Symfony service `overblog_graphql.input_builder` is called in order to transform the received array of data into a `UserRegisterInput` instance using a property accessor. +Then the `validator` service is used to validate this instance against the configured constraints. +The mutation received the valid instance. + +In the above example, everything is auto-guessed and a Provider is used. But this would be the same as : + +```php +/** + * @GQL\Type + */ +class RootMutation { + /** + * @GQL\Field( + * type="User", + * args={ + * @GQL\Arg(name="input", type="UserRegisterInput") + * }, + * resolve="@=service('UserRepository').createUser(input(arg['input']))" + * ) + */ + public $createUser; +} +``` + +So, the resolver (the `createUser` method) will receive an instance of the class `UserRegisterInput` instead of an array of data. + diff --git a/docs/definitions/expression-language.md b/docs/definitions/expression-language.md index b6feb35bb..2b6b16e33 100644 --- a/docs/definitions/expression-language.md +++ b/docs/definitions/expression-language.md @@ -12,6 +12,7 @@ All definition config entries can use expression language but it must be explici | boolean **isTypeOf**(string $className) | Verified if `value` is instance of className | @=isTypeOf('AppBundle\\User\\User') | | mixed **resolver**(string $alias, array $args = []) | call the method on the tagged service "overblog_graphql.resolver" with args | @=resolver('blog_by_id', [value['blogID']] | res | | mixed **mutation**(string $alias, array $args = []) | call the method on the tagged service "overblog_graphql.mutation" with args | @=mutation('remove_post_from_community', [value]) | mut | +| mixed **input**(string $type, mixed $data) | Transform and validate an input using the `Input Builder` service. (see [The Input Builder ](input-builder.md)) | @=service('my_service').method(input(value)) | | string **globalId**(string\|int id, string $typeName = null) | Relay node globalId | @=globalId(15, 'User') | | array **fromGlobalId**(string $globalId) | Relay node fromGlobalId | @=fromGlobalId('QmxvZzox') | | object **newObject**(string $className, array $args = []) | Instantiation $className object with $args | @=newObject('AppBundle\\User\\User', ['John', 15]) | diff --git a/src/Annotation/InputType.php b/src/Annotation/Input.php similarity index 88% rename from src/Annotation/InputType.php rename to src/Annotation/Input.php index a893eac90..16ba0f073 100644 --- a/src/Annotation/InputType.php +++ b/src/Annotation/Input.php @@ -10,7 +10,7 @@ * @Annotation * @Target("CLASS") */ -final class InputType implements Annotation +final class Input implements Annotation { /** * Type name. diff --git a/src/Annotation/Operation.php b/src/Annotation/Operation.php index 2d9f0a01c..64b7aee36 100644 --- a/src/Annotation/Operation.php +++ b/src/Annotation/Operation.php @@ -17,8 +17,6 @@ abstract class Operation implements Annotation /** * Operation Type. * - * @required - * * @var string */ public $type; diff --git a/src/Config/Parser/AnnotationParser.php b/src/Config/Parser/AnnotationParser.php index b55eb24ca..f2e5c3075 100644 --- a/src/Config/Parser/AnnotationParser.php +++ b/src/Config/Parser/AnnotationParser.php @@ -13,9 +13,12 @@ class AnnotationParser implements PreParserInterface { + public const CLASSESMAP_CONTAINER_PARAMETER = 'overblog_graphql_types.classes_map'; + private static $annotationReader = null; private static $classesMap = []; private static $providers = []; + private static $doctrineMapping = []; /** * {@inheritdoc} @@ -23,9 +26,9 @@ class AnnotationParser implements PreParserInterface * @throws \ReflectionException * @throws InvalidArgumentException */ - public static function parse(\SplFileInfo $file, ContainerBuilder $container, array $configs = []): array + public static function preParse(\SplFileInfo $file, ContainerBuilder $container, array $configs = []): void { - return self::proccessFile($file, $container, $configs); + self::proccessFile($file, $container, $configs, true); } /** @@ -34,9 +37,9 @@ public static function parse(\SplFileInfo $file, ContainerBuilder $container, ar * @throws \ReflectionException * @throws InvalidArgumentException */ - public static function preParse(\SplFileInfo $file, ContainerBuilder $container, array $configs = []): void + public static function parse(\SplFileInfo $file, ContainerBuilder $container, array $configs = []): array { - self::proccessFile($file, $container, $configs, true); + return self::proccessFile($file, $container, $configs); } /** @@ -61,10 +64,17 @@ public static function clear(): void */ public static function proccessFile(\SplFileInfo $file, ContainerBuilder $container, array $configs, bool $resolveClassMap = false): array { + self::$doctrineMapping = $configs['doctrine']['types_mapping']; + $rootQueryType = $configs['definitions']['schema']['default']['query'] ?? false; $rootMutationType = $configs['definitions']['schema']['default']['mutation'] ?? false; $container->addResource(new FileResource($file->getRealPath())); + + if (!$resolveClassMap && !$container->hasParameter(self::CLASSESMAP_CONTAINER_PARAMETER)) { + $container->setParameter(self::CLASSESMAP_CONTAINER_PARAMETER, self::$classesMap); + } + try { $fileContent = \file_get_contents($file->getRealPath()); @@ -113,11 +123,11 @@ public static function proccessFile(\SplFileInfo $file, ContainerBuilder $contai } } break; - case $classAnnotation instanceof GQL\InputType: + case $classAnnotation instanceof GQL\Input: $gqlType = 'input'; $gqlName = $classAnnotation->name ?: self::suffixName($shortClassName, 'Input'); if (!$resolveClassMap) { - $gqlConfiguration = self::getGraphqlInputType($classAnnotation, $classAnnotations, $properties, $namespace); + $gqlConfiguration = self::getGraphqlInput($classAnnotation, $classAnnotations, $properties, $namespace); } break; case $classAnnotation instanceof GQL\Scalar: @@ -267,14 +277,14 @@ private static function getGraphqlInterface(GQL\TypeInterface $interfaceAnnotati /** * Create a GraphQL Input type configuration from annotations on properties. * - * @param string $shortClassName - * @param GQL\InputType $inputAnnotation - * @param array $properties - * @param string $namespace + * @param string $shortClassName + * @param GQL\Input $inputAnnotation + * @param array $properties + * @param string $namespace * * @return array */ - private static function getGraphqlInputType(GQL\InputType $inputAnnotation, array $classAnnotations, array $properties, string $namespace) + private static function getGraphqlInput(GQL\Input $inputAnnotation, array $classAnnotations, array $properties, string $namespace) { $inputConfiguration = []; $fields = self::getGraphqlFieldsFromAnnotations($namespace, $properties, true); @@ -418,6 +428,13 @@ private static function getGraphqlFieldsFromAnnotations(string $namespace, array $fieldType = $fieldAnnotation->type; $fieldConfiguration = []; if ($fieldType) { + $resolvedType = self::resolveClassFromType($fieldType); + if ($resolvedType) { + if ($isInput && !\in_array($resolvedType['type'], ['input', 'scalar', 'enum'])) { + 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'])); + } + } + $fieldConfiguration['type'] = $fieldType; } @@ -480,7 +497,7 @@ private static function getGraphqlFieldsFromAnnotations(string $namespace, array try { $fieldConfiguration['type'] = self::guessType($namespace, $annotations); } catch (\Exception $e) { - throw new InvalidArgumentException(\sprintf('The attribute "type" on "@Field" defined on "%s" cannot be auto-guessed : %s.', $target, $e->getMessage())); + throw new InvalidArgumentException(\sprintf('The attribute "type" on "@Field" defined on "%s" is required and cannot be auto-guessed : %s.', $target, $e->getMessage())); } } } @@ -545,14 +562,14 @@ private static function getGraphqlFieldsFromProvider(string $className, array $m $resolve = \sprintf("@=service('%s').%s(%s)", self::formatNamespaceForExpression($className), $methodName, self::formatArgsForExpression($args)); - return [ - $name => [ - 'type' => $type, - 'args' => $args, - 'resolve' => $resolve, - ], + $fields[$name] = [ + 'type' => $type, + 'args' => $args, + 'resolve' => $resolve, ]; } + + return $fields; } /** @@ -612,11 +629,27 @@ private static function getArgs(array $args = null, \ReflectionMethod $method = */ private static function formatArgsForExpression(array $args) { - $resolveArgs = \array_map(function ($a) { - return \sprintf("args['%s']", $a); - }, \array_keys($args)); + $resolvedArgs = []; + foreach ($args as $name => $config) { + $cleanedType = \str_replace(['[', ']', '!'], '', $config['type']); + $definition = self::resolveClassFromType($cleanedType); + $defaultFormat = \sprintf("args['%s']", $name); + if (!$definition) { + $resolvedArgs[] = $defaultFormat; + } else { + switch ($definition['type']) { + case 'input': + case 'enum': + $resolvedArgs[] = \sprintf("input('%s', args['%s'])", $config['type'], $name); + break; + default: + $resolvedArgs[] = $defaultFormat; + break; + } + } + } - return \implode(', ', $resolveArgs); + return \implode(', ', $resolvedArgs); } /** @@ -678,7 +711,7 @@ private static function formatExpression(string $expression) */ private static function suffixName(string $name, string $suffix) { - return \substr($name, \strlen($suffix)) === $suffix ? $name : \sprintf('%s%s', $name, $suffix); + return \substr($name, -\strlen($suffix)) === $suffix ? $name : \sprintf('%s%s', $name, $suffix); } /** @@ -761,6 +794,10 @@ public static function fullyQualifiedClassName(string $className, string $namesp */ private static function resolveTypeFromDoctrineType(string $doctrineType) { + if (isset(self::$doctrineMapping[$doctrineType])) { + return self::$doctrineMapping[$doctrineType]; + } + switch ($doctrineType) { case 'integer': case 'smallint': @@ -846,7 +883,7 @@ private static function resolveGraphqlTypeFromReflectionType(\ReflectionType $ty } /** - * Resolve a Graphql Type from a class name. + * Resolve a GraphQL Type from a class name. * * @param string $className * @param string $wantedType @@ -866,6 +903,18 @@ private static function resolveTypeFromClass(string $className, string $wantedTy return false; } + /** + * Resolve a PHP class from a GraphQL type. + * + * @param string $type + * + * @return string|false + */ + private static function resolveClassFromType(string $type) + { + return self::$classesMap[$type] ?? false; + } + /** * Convert a PHP Builtin type to a GraphQL type. * @@ -882,8 +931,8 @@ private static function resolveTypeFromPhpType(string $phpType) case 'integer': case 'int': return 'Int'; - case 'double': case 'float': + case 'double': return 'Float'; case 'string': return 'String'; diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 75b2c0e1c..aa3eb1f6f 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -49,6 +49,7 @@ public function getConfigTreeBuilder() ->append($this->errorsHandlerSection()) ->append($this->servicesSection()) ->append($this->securitySection()) + ->append($this->doctrineSection()) ->end(); return $treeBuilder; @@ -260,6 +261,23 @@ private function definitionsMappingsSection() return $node; } + private function doctrineSection() + { + $builder = new TreeBuilder(); + $node = $builder->root('doctrine'); + $node + ->addDefaultsIfNotSet() + ->children() + ->arrayNode('types_mapping') + ->defaultValue([]) + ->prototype('scalar')->end() + ->end() + ->end() + ; + + return $node; + } + /** * @param string $name * diff --git a/src/ExpressionLanguage/ExpressionFunction/GraphQL/Input.php b/src/ExpressionLanguage/ExpressionFunction/GraphQL/Input.php new file mode 100644 index 000000000..9404d0866 --- /dev/null +++ b/src/ExpressionLanguage/ExpressionFunction/GraphQL/Input.php @@ -0,0 +1,20 @@ +get(\'container\')->get(\'overblog_graphql.input_builder\')->getInstanceAndValidate(%s, %s, $info)', $type, $data); + } + ); + } +} diff --git a/src/Resources/config/expression_language_functions.yml b/src/Resources/config/expression_language_functions.yml index 335f98c7e..130948be5 100644 --- a/src/Resources/config/expression_language_functions.yml +++ b/src/Resources/config/expression_language_functions.yml @@ -91,6 +91,12 @@ services: tags: - { name: overblog_graphql.expression_function } + Overblog\GraphQLBundle\ExpressionLanguage\ExpressionFunction\GraphQL\Input: + class: Overblog\GraphQLBundle\ExpressionLanguage\ExpressionFunction\GraphQL\Input + public: false + tags: + - { name: overblog_graphql.expression_function } + # relay Overblog\GraphQLBundle\ExpressionLanguage\ExpressionFunction\GraphQL\Relay\FromGlobalID: class: Overblog\GraphQLBundle\ExpressionLanguage\ExpressionFunction\GraphQL\Relay\FromGlobalID diff --git a/src/Resources/config/services.yml b/src/Resources/config/services.yml index 768cec7b1..6e758ebf0 100644 --- a/src/Resources/config/services.yml +++ b/src/Resources/config/services.yml @@ -46,10 +46,17 @@ services: class: Overblog\GraphQLBundle\Resolver\TypeResolver public: true calls: - - ['setDispatcher', ['@event_dispatcher']] + - ["setDispatcher", ["@event_dispatcher"]] tags: - { name: overblog_graphql.global_variable, alias: typeResolver } + overblog_graphql.input_builder: + class: Overblog\GraphQLBundle\Transformer\InputBuilder + public: true + arguments: + - "@validator" + - "%overblog_graphql_types.classes_map%" + Overblog\GraphQLBundle\Resolver\TypeResolver: alias: overblog_graphql.type_resolver @@ -84,7 +91,7 @@ services: class: Overblog\GraphQLBundle\ExpressionLanguage\ExpressionLanguage public: false arguments: - - '@?overblog_graphql.cache_expression_language_parser' + - "@?overblog_graphql.cache_expression_language_parser" overblog_graphql.cache_compiler: class: Overblog\GraphQLBundle\Generator\TypeGenerator @@ -98,9 +105,9 @@ services: - ~ - "%kernel.cache_dir%" calls: - - ['addUseStatement', ['Overblog\GraphQLBundle\Definition\ConfigProcessor']] - - ['addUseStatement', ['Overblog\GraphQLBundle\Definition\LazyConfig']] - - ['addUseStatement', ['Overblog\GraphQLBundle\Definition\GlobalVariables']] + - ["addUseStatement", ['Overblog\GraphQLBundle\Definition\ConfigProcessor']] + - ["addUseStatement", ['Overblog\GraphQLBundle\Definition\LazyConfig']] + - ["addUseStatement", ['Overblog\GraphQLBundle\Definition\GlobalVariables']] - ["addImplement", ["Overblog\\GraphQLBundle\\Definition\\Type\\GeneratedTypeInterface"]] - ["setExpressionLanguage", ["@overblog_graphql.expression_language"]] @@ -139,7 +146,7 @@ services: Overblog\GraphQLBundle\Controller\GraphController: public: true - alias: 'overblog_graphql.controller.graphql' + alias: "overblog_graphql.controller.graphql" overblog_graphql.command.dump_schema: class: Overblog\GraphQLBundle\Command\GraphQLDumpSchemaCommand @@ -148,7 +155,7 @@ services: - "%kernel.root_dir%" - "@overblog_graphql.request_executor" tags: - - { name: console.command, command: graphql:dump-schema, alias: 'graph:dump-schema' } + - { name: console.command, command: graphql:dump-schema, alias: "graph:dump-schema" } overblog_graphql.command.debug: class: Overblog\GraphQLBundle\Command\DebugCommand @@ -158,7 +165,7 @@ services: - "@overblog_graphql.mutation_resolver" - "@overblog_graphql.resolver_resolver" tags: - - { name: console.command, command: graphql:debug, alias: 'debug:graphql' } + - { name: console.command, command: graphql:debug, alias: "debug:graphql" } overblog_graphql.command.compile: class: Overblog\GraphQLBundle\Command\CompileCommand @@ -172,7 +179,7 @@ services: class: Overblog\GraphQLBundle\Command\ValidateCommand public: true arguments: - - '@overblog_graphql.request_executor' + - "@overblog_graphql.request_executor" tags: - { name: console.command, command: graphql:validate } @@ -187,4 +194,4 @@ services: arguments: - [] - GraphQL\Executor\Promise\PromiseAdapter: '@overblog_graphql.promise_adapter' + GraphQL\Executor\Promise\PromiseAdapter: "@overblog_graphql.promise_adapter" diff --git a/src/Transformer/InputBuilder.php b/src/Transformer/InputBuilder.php new file mode 100644 index 000000000..7f2b69998 --- /dev/null +++ b/src/Transformer/InputBuilder.php @@ -0,0 +1,146 @@ +validator = $validator; + $this->accessor = PropertyAccess::createPropertyAccessor(); + $this->classesMap = $classesMap; + } + + /** + * Get the PHP class for a given type. + * + * @param string $type + * + * @return object|false + */ + private function getTypeClassInstance(string $type) + { + $classname = isset($this->classesMap[$type]) ? $this->classesMap[$type]['class'] : false; + + return $classname ? new $classname() : false; + } + + /** + * Extract given type from Resolve Info. + * + * @param string $type + * @param ResolveInfo $info + * + * @return Type + */ + private function getType(string $type, ResolveInfo $info): Type + { + return $info->schema->getType($type); + } + + /** + * Populate an object based on type with given data. + * + * @param Type $type + * @param mixed $data + * @param bool $multiple + * @param ResolveInfo $info + * + * @return mixed + */ + private function populateObject(Type $type, $data, $multiple = false, ResolveInfo $info) + { + if ($multiple) { + return \array_map(function ($data) use ($type, $info) { + return $this->populateObject($type, $data, false, $info); + }, $data); + } + + if ($type instanceof EnumType) { + $instance = $this->getTypeClassInstance($type->name); + if ($instance) { + $this->accessor->setValue($instance, 'value', $data); + + return $instance; + } else { + return $data; + } + } elseif ($type instanceof InputType) { + $instance = $this->getTypeClassInstance($type->name); + if (!$instance) { + return $data; + } + + $fields = $type->getFields(); + + foreach ($fields as $name => $field) { + $fieldData = $this->accessor->getValue($data, \sprintf('[%s]', $name)); + + if ($field->getType() instanceof ListOfType) { + $fieldValue = $this->populateObject($field->getType()->getWrappedType(), $fieldData, true, $info); + } else { + $fieldValue = $this->populateObject($field->getType(), $fieldData, false, $info); + } + + $this->accessor->setValue($instance, $name, $fieldValue); + } + + return $instance; + } else { + return $data; + } + } + + /** + * Given a GraphQL type and an array of data, populate corresponding object recursively + * using annoted classes. + * + * @param string $argType + * @param mixed $data + * @param ResolveInfo $info + * + * @return mixed + */ + public function getInstanceAndValidate(string $argType, $data, ResolveInfo $info) + { + $isRequired = '!' === $argType[\strlen($argType) - 1]; + $isMultiple = '[' === $argType[0]; + $endIndex = ($isRequired ? 1 : 0) + ($isMultiple ? 1 : 0); + $type = \substr($argType, $isMultiple ? 1 : 0, $endIndex > 0 ? -$endIndex : \strlen($argType)); + + $result = $this->populateObject($this->getType($type, $info), $data, $isMultiple, $info); + + $errors = $this->validator->validate($result); + if (\count($errors) > 0) { + throw new \Exception((string) $errors); + } else { + return $result; + } + } +} diff --git a/tests/Config/Parser/AnnotationParserTest.php b/tests/Config/Parser/AnnotationParserTest.php index 0a457d3aa..d5a43c455 100644 --- a/tests/Config/Parser/AnnotationParserTest.php +++ b/tests/Config/Parser/AnnotationParserTest.php @@ -14,7 +14,18 @@ public function setUp(): void { parent::setup(); - $configs = ['definitions' => ['schema' => ['default' => ['query' => 'RootQuery', 'mutation' => 'RootMutation']]]]; + $configs = [ + 'definitions' => [ + 'schema' => [ + 'default' => ['query' => 'RootQuery', 'mutation' => 'RootMutation'], + ], + ], + 'doctrine' => [ + 'types_mapping' => [ + 'text[]' => '[String]', + ], + ], + ]; $files = []; $rii = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator(__DIR__.'/fixtures/annotations/')); @@ -182,7 +193,7 @@ public function testProviders(): void 'createPlanet' => [ 'type' => 'Planet', 'args' => ['planetInput' => ['type' => 'PlanetInput!']], - 'resolve' => "@=service('Overblog\\\\GraphQLBundle\\\\Tests\\\\Config\\\\Parser\\\\fixtures\\\\annotations\\\\Repository\\\\PlanetRepository').createPlanet(args['planetInput'])", + 'resolve' => "@=service('Overblog\\\\GraphQLBundle\\\\Tests\\\\Config\\\\Parser\\\\fixtures\\\\annotations\\\\Repository\\\\PlanetRepository').createPlanet(input('PlanetInput!', args['planetInput']))", ], ], ]); @@ -199,13 +210,13 @@ public function testDoctrineGuessing(): void 'crystal' => ['type' => 'Crystal!'], 'battles' => ['type' => '[Battle]!'], 'currentHolder' => ['type' => 'Hero'], + 'tags' => ['type' => '[String]!'], ], ]); } public function testArgsAndReturnGuessing(): void { - // public function getCasualties(int $areaId, string $raceId, int $dayStart = null, int $dayEnd = null, string $nameStartingWith = '', string $planetId = null) : int $this->expect('Battle', 'object', [ 'fields' => [ 'planet' => ['type' => 'Planet'], @@ -224,56 +235,4 @@ public function testArgsAndReturnGuessing(): void ], ]); } - - /* - public function testTypeGuessing() : void - { - $file1 = __DIR__ . '/fixtures/Entity/GraphQL/GuessType/Autoguess.php'; - $file2 = __DIR__ . '/fixtures/Entity/GraphQL/GuessType/Autoguess2.php'; - AnnotationParser::preParse(new \SplFileInfo($file1), $this->containerBuilder); - AnnotationParser::preParse(new \SplFileInfo($file2), $this->containerBuilder); - - $config = AnnotationParser::parse(new \SplFileInfo($file1), $this->containerBuilder); - - $expected = ['Autoguess' => ['type' => 'object', 'config' => [ - 'fields' => [ - 'field1' => ['type' => 'String!'], - 'field2' => ['type' => 'Int'], - 'field3' => ['type' => '[CustomAutoguessType]!'], - 'field4' => ['type' => 'CustomAutoguessType!'], - 'field5' => ['type' => 'CustomAutoguessType!'], - 'field6' => ['type' => '[CustomAutoguessType]!'], - 'field7' => ['type' => 'CustomAutoguessType'], - ], - ]]]; - - $this->assertEquals($expected, self::cleanConfig($config)); - } - - public function testArgsGuessing() : void - { - $file1 = __DIR__ . '/fixtures/Entity/GraphQL/GuessArgs/AutoguessArgs.php'; - //$file2 = __DIR__ . '/fixtures/Entity/GraphQL/GuessType/Autoguess2.php'; - AnnotationParser::preParse(new \SplFileInfo($file1), $this->containerBuilder); - //AnnotationParser::preParse(new \SplFileInfo($file2), $this->containerBuilder); - - $config = AnnotationParser::parse(new \SplFileInfo($file1), $this->containerBuilder); - - $expected = ['AutoguessArgs' => ['type' => 'object', 'config' => [ - 'fields' => [ - 'myMethod' => [ - 'type' => 'String', - 'args' => [ - 'p1' => [], - 'p2' => [], - 'p3' => [], - 'p4' => [], - 'p5' => [] - ] - ] - ], - ]]]; - - $this->assertEquals($expected, self::cleanConfig($config)); - }*/ } diff --git a/tests/Config/Parser/fixtures/annotations/Input/Planet.php b/tests/Config/Parser/fixtures/annotations/Input/Planet.php index ebed64c61..d9cc16708 100644 --- a/tests/Config/Parser/fixtures/annotations/Input/Planet.php +++ b/tests/Config/Parser/fixtures/annotations/Input/Planet.php @@ -7,7 +7,7 @@ use Overblog\GraphQLBundle\Annotation as GQL; /** - * @GQL\InputType + * @GQL\Input * @GQL\Description("Planet Input type description") */ class Planet diff --git a/tests/Config/Parser/fixtures/annotations/Type/Lightsaber.php b/tests/Config/Parser/fixtures/annotations/Type/Lightsaber.php index efe7deca9..c28246bb5 100644 --- a/tests/Config/Parser/fixtures/annotations/Type/Lightsaber.php +++ b/tests/Config/Parser/fixtures/annotations/Type/Lightsaber.php @@ -55,4 +55,10 @@ class Lightsaber * @ORM\JoinColumn(nullable=true) */ protected $currentHolder; + + /** + * @GQL\Field + * @ORM\Column(type="text[]") + */ + protected $tags; } diff --git a/tests/Transformer/Enum1.php b/tests/Transformer/Enum1.php new file mode 100644 index 000000000..efd9ec6b4 --- /dev/null +++ b/tests/Transformer/Enum1.php @@ -0,0 +1,10 @@ +createMock(\Symfony\Component\Validator\Validator\RecursiveValidator::class); + $validator->method('validate')->willReturn([]); + + return new InputBuilder($validator, $classesMap); + } + + public function getResolveInfo($types): ResolveInfo + { + $info = new ResolveInfo([]); + $info->schema = new Schema(['types' => $types]); + + return $info; + } + + public function testPopulating(): void + { + $t1 = new InputObjectType([ + 'name' => 'InputType1', + 'fields' => [ + 'field1' => Type::string(), + 'field2' => Type::int(), + 'field3' => Type::boolean(), + ], + ]); + + $t3 = new EnumType([ + 'name' => 'Enum1', + 'values' => ['op1' => 1, 'op2' => 2, 'op3' => 3], + ]); + + $t2 = new InputObjectType([ + 'name' => 'InputType2', + 'fields' => [ + 'field1' => Type::listOf($t1), + 'field2' => $t3, + ], + ]); + + $types = [$t1, $t2, $t3]; + + $builder = $this->getBuilder([ + 'InputType1' => ['type' => 'input', 'class' => 'Overblog\GraphQLBundle\Tests\Transformer\InputType1'], + 'InputType2' => ['type' => 'input', 'class' => 'Overblog\GraphQLBundle\Tests\Transformer\InputType2'], + ]); + + $info = $this->getResolveInfo($types); + + $data = [ + 'field1' => 'hello', + 'field2' => 12, + 'field3' => true, + ]; + + $res = $builder->getInstanceAndValidate('InputType1', $data, $info); + + $this->assertInstanceOf(InputType1::class, $res); + $this->assertEquals($res->field1, $data['field1']); + $this->assertEquals($res->field2, $data['field2']); + $this->assertEquals($res->field3, $data['field3']); + + $data = [ + 'field1' => [ + ['field1' => 'hello2', 'field2' => 2, 'field3' => false], + ['field1' => 'world2'], + ], + 'field2' => 3, + ]; + + $res2 = $builder->getInstanceAndValidate('InputType2', $data, $info); + + $this->assertInstanceOf(InputType2::class, $res2); + $this->assertTrue(\is_array($res2->field1)); + $this->assertArrayHasKey(0, $res2->field1); + $this->assertArrayHasKey(1, $res2->field1); + $this->assertInstanceOf(InputType1::class, $res2->field1[0]); + $this->assertInstanceOf(InputType1::class, $res2->field1[1]); + + $res3 = $builder->getInstanceAndValidate('Enum1', 2, $info); + + $this->assertEquals(2, $res3); + + $builder = $this->getBuilder([ + 'InputType1' => ['type' => 'input', 'class' => 'Overblog\GraphQLBundle\Tests\Transformer\InputType1'], + 'InputType2' => ['type' => 'input', 'class' => 'Overblog\GraphQLBundle\Tests\Transformer\InputType2'], + 'Enum1' => ['type' => 'enum', 'class' => 'Overblog\GraphQLBundle\Tests\Transformer\Enum1'], + ]); + + $res4 = $builder->getInstanceAndValidate('Enum1', 2, $info); + $this->assertInstanceOf(Enum1::class, $res4); + $this->assertEquals(2, $res4->value); + } +} diff --git a/tests/Transformer/InputType1.php b/tests/Transformer/InputType1.php new file mode 100644 index 000000000..ade280ab9 --- /dev/null +++ b/tests/Transformer/InputType1.php @@ -0,0 +1,12 @@ +