diff --git a/.scrutinizer.yml b/.scrutinizer.yml index 8f5365c52..809eb0894 100644 --- a/.scrutinizer.yml +++ b/.scrutinizer.yml @@ -5,3 +5,7 @@ build: tests: override: - true + +filter: + excluded_paths: + - "Tests/" diff --git a/Command/DebugCommand.php b/Command/DebugCommand.php index dad6d837b..3c059ff95 100644 --- a/Command/DebugCommand.php +++ b/Command/DebugCommand.php @@ -2,8 +2,8 @@ namespace Overblog\GraphQLBundle\Command; +use Overblog\GraphQLBundle\Resolver\FluentResolverInterface; use Overblog\GraphQLBundle\Resolver\MutationResolver; -use Overblog\GraphQLBundle\Resolver\ResolverInterface; use Overblog\GraphQLBundle\Resolver\ResolverResolver; use Overblog\GraphQLBundle\Resolver\TypeResolver; use Symfony\Component\Console\Command\Command; @@ -72,7 +72,7 @@ protected function execute(InputInterface $input, OutputInterface $output) foreach ($categories as $category) { $io->title(sprintf('GraphQL %ss Services', ucfirst($category))); - /** @var ResolverInterface $resolver */ + /** @var FluentResolverInterface $resolver */ $resolver = $this->{$category.'Resolver'}; $solutions = $this->retrieveSolutions($resolver); @@ -91,7 +91,7 @@ private function renderTable(array $tableHeaders, array $solutions, SymfonyStyle $io->write("\n\n"); } - private function retrieveSolutions(ResolverInterface $resolver) + private function retrieveSolutions(FluentResolverInterface $resolver) { $data = []; foreach ($resolver->getSolutions() as $alias => $solution) { diff --git a/Config/InterfaceTypeDefinition.php b/Config/InterfaceTypeDefinition.php index 07556383c..ba95dc90c 100644 --- a/Config/InterfaceTypeDefinition.php +++ b/Config/InterfaceTypeDefinition.php @@ -14,7 +14,7 @@ public function getDefinition() $node ->children() ->append($this->nameSection()) - ->append($this->outputFieldsSelection('fields')) + ->append($this->outputFieldsSelection()) ->append($this->resolveTypeSection()) ->append($this->descriptionSection()) ->end(); diff --git a/Config/ObjectTypeDefinition.php b/Config/ObjectTypeDefinition.php index c57aafc5c..19391685b 100644 --- a/Config/ObjectTypeDefinition.php +++ b/Config/ObjectTypeDefinition.php @@ -15,7 +15,7 @@ public function getDefinition() $node ->children() ->append($this->nameSection()) - ->append($this->outputFieldsSelection('fields')) + ->append($this->outputFieldsSelection()) ->append($this->descriptionSection()) ->arrayNode('interfaces') ->prototype('scalar')->info('One of internal or custom interface types.')->end() @@ -32,7 +32,6 @@ public function getDefinition() $this->treatFieldsDefaultAccess($node); $this->treatFieldsDefaultPublic($node); - $this->treatResolveField($node); return $node; } @@ -86,31 +85,4 @@ private function treatFieldsDefaultPublic(ArrayNodeDefinition $node) }) ->end(); } - - /** - * resolveField is set as fields default resolver if not set - * then remove resolveField to keep "access" feature - * TODO(mcg-web) : get a cleaner way to use resolveField combine with "access" feature. - * - * @param ArrayNodeDefinition $node - */ - private function treatResolveField(ArrayNodeDefinition $node) - { - $node->validate() - ->ifTrue(function ($v) { - return array_key_exists('resolveField', $v) && null !== $v['resolveField']; - }) - ->then(function ($v) { - $resolveField = $v['resolveField']; - unset($v['resolveField']); - foreach ($v['fields'] as &$field) { - if (empty($field['resolve'])) { - $field['resolve'] = $resolveField; - } - } - - return $v; - }) - ->end(); - } } diff --git a/Config/Parser/GraphQLParser.php b/Config/Parser/GraphQLParser.php new file mode 100644 index 000000000..b9f6d2dd9 --- /dev/null +++ b/Config/Parser/GraphQLParser.php @@ -0,0 +1,301 @@ + 'object', + NodeKind::INTERFACE_TYPE_DEFINITION => 'interface', + NodeKind::ENUM_TYPE_DEFINITION => 'enum', + NodeKind::UNION_TYPE_DEFINITION => 'union', + NodeKind::INPUT_OBJECT_TYPE_DEFINITION => 'input-object', + NodeKind::SCALAR_TYPE_DEFINITION => 'custom-scalar', + ]; + + /** + * {@inheritdoc} + */ + public static function parse(\SplFileInfo $file, ContainerBuilder $container) + { + $container->addResource(new FileResource($file->getRealPath())); + $content = trim(file_get_contents($file->getPathname())); + $typesConfig = []; + + // allow empty files + if (empty($content)) { + return []; + } + if (!self::$parser) { + self::$parser = new static(); + } + try { + $ast = Parser::parse($content); + } catch (\Exception $e) { + throw new InvalidArgumentException(sprintf('An error occurred while parsing the file "%s".', $file), $e->getCode(), $e); + } + + foreach ($ast->definitions as $typeDef) { + if (isset($typeDef->name) && $typeDef->name instanceof NameNode) { + $typeConfig = self::$parser->typeDefinitionToConfig($typeDef); + $typesConfig[$typeDef->name->value] = $typeConfig; + } else { + self::throwUnsupportedDefinitionNode($typeDef); + } + } + + return $typesConfig; + } + + public static function mustOverrideConfig() + { + throw new \RuntimeException('Config entry must be override with ResolverMap to be used.'); + } + + protected function typeDefinitionToConfig(DefinitionNode $typeDef) + { + if (isset($typeDef->kind)) { + switch ($typeDef->kind) { + case NodeKind::OBJECT_TYPE_DEFINITION: + case NodeKind::INTERFACE_TYPE_DEFINITION: + case NodeKind::INPUT_OBJECT_TYPE_DEFINITION: + case NodeKind::ENUM_TYPE_DEFINITION: + case NodeKind::UNION_TYPE_DEFINITION: + $config = []; + $this->addTypeFields($typeDef, $config); + $this->addDescription($typeDef, $config); + $this->addInterfaces($typeDef, $config); + $this->addTypes($typeDef, $config); + $this->addValues($typeDef, $config); + + return [ + 'type' => self::DEFINITION_TYPE_MAPPING[$typeDef->kind], + 'config' => $config, + ]; + + case NodeKind::SCALAR_TYPE_DEFINITION: + $mustOverride = [__CLASS__, 'mustOverrideConfig']; + $config = [ + 'serialize' => $mustOverride, + 'parseValue' => $mustOverride, + 'parseLiteral' => $mustOverride, + ]; + $this->addDescription($typeDef, $config); + + return [ + 'type' => self::DEFINITION_TYPE_MAPPING[$typeDef->kind], + 'config' => $config, + ]; + break; + + default: + self::throwUnsupportedDefinitionNode($typeDef); + } + } + + self::throwUnsupportedDefinitionNode($typeDef); + } + + private static function throwUnsupportedDefinitionNode(DefinitionNode $typeDef) + { + $path = explode('\\', get_class($typeDef)); + throw new InvalidArgumentException( + sprintf( + '%s definition is not supported right now.', + preg_replace('@DefinitionNode$@', '', array_pop($path)) + ) + ); + } + + /** + * @param DefinitionNode $typeDef + * @param array $config + */ + private function addTypeFields(DefinitionNode $typeDef, array &$config) + { + if (!empty($typeDef->fields)) { + $fields = []; + /** @var FieldDefinitionNode|InputValueDefinitionNode $fieldDef */ + foreach ($typeDef->fields as $fieldDef) { + $fieldName = $fieldDef->name->value; + $fields[$fieldName] = []; + $this->addType($fieldDef, $fields[$fieldName]); + $this->addDescription($fieldDef, $fields[$fieldName]); + $this->addDefaultValue($fieldDef, $fields[$fieldName]); + $this->addFieldArguments($fieldDef, $fields[$fieldName]); + } + $config['fields'] = $fields; + } + } + + /** + * @param Node $fieldDef + * @param array $fieldConf + */ + private function addFieldArguments(Node $fieldDef, array &$fieldConf) + { + if (!empty($fieldDef->arguments)) { + $arguments = []; + foreach ($fieldDef->arguments as $definition) { + $name = $definition->name->value; + $arguments[$name] = []; + $this->addType($definition, $arguments[$name]); + $this->addDescription($definition, $arguments[$name]); + $this->addDefaultValue($definition, $arguments[$name]); + } + $fieldConf['args'] = $arguments; + } + } + + /** + * @param DefinitionNode $typeDef + * @param array $config + */ + private function addInterfaces(DefinitionNode $typeDef, array &$config) + { + if (!empty($typeDef->interfaces)) { + $interfaces = []; + foreach ($typeDef->interfaces as $interface) { + $interfaces[] = $this->astTypeNodeToString($interface); + } + $config['interfaces'] = $interfaces; + } + } + + /** + * @param DefinitionNode $typeDef + * @param array $config + */ + private function addTypes(DefinitionNode $typeDef, array &$config) + { + if (!empty($typeDef->types)) { + $types = []; + foreach ($typeDef->types as $type) { + $types[] = $this->astTypeNodeToString($type); + } + $config['types'] = $types; + } + } + + /** + * @param DefinitionNode $typeDef + * @param array $config + */ + private function addValues(DefinitionNode $typeDef, array &$config) + { + if (!empty($typeDef->values)) { + $values = []; + foreach ($typeDef->values as $value) { + $values[$value->name->value] = ['value' => $value->name->value]; + $this->addDescription($value, $values[$value->name->value]); + } + $config['values'] = $values; + } + } + + /** + * @param Node $definition + * @param array $config + */ + private function addType(Node $definition, array &$config) + { + if (!empty($definition->type)) { + $config['type'] = $this->astTypeNodeToString($definition->type); + } + } + + /** + * @param Node $definition + * @param array $config + */ + private function addDescription(Node $definition, array &$config) + { + if ( + !empty($definition->description) + && $description = $this->cleanAstDescription($definition->description) + ) { + $config['description'] = $description; + } + } + + /** + * @param Node $definition + * @param array $config + */ + private function addDefaultValue(Node $definition, array &$config) + { + if (!empty($definition->defaultValue)) { + $config['defaultValue'] = $this->astValueNodeToConfig($definition->defaultValue); + } + } + + private function astTypeNodeToString(TypeNode $typeNode) + { + $type = ''; + switch ($typeNode->kind) { + case NodeKind::NAMED_TYPE: + $type = $typeNode->name->value; + break; + + case NodeKind::NON_NULL_TYPE: + $type = $this->astTypeNodeToString($typeNode->type).'!'; + break; + + case NodeKind::LIST_TYPE: + $type = '['.$this->astTypeNodeToString($typeNode->type).']'; + break; + } + + return $type; + } + + private function astValueNodeToConfig(ValueNode $valueNode) + { + $config = null; + switch ($valueNode->kind) { + case NodeKind::INT: + case NodeKind::FLOAT: + case NodeKind::STRING: + case NodeKind::BOOLEAN: + case NodeKind::ENUM: + $config = $valueNode->value; + break; + + case NodeKind::LST: + $config = []; + foreach ($valueNode->values as $node) { + $config[] = $this->astValueNodeToConfig($node); + } + break; + + case NodeKind::NULL: + $config = null; + break; + } + + return $config; + } + + private function cleanAstDescription($description) + { + $description = trim($description); + + return empty($description) ? null : $description; + } +} diff --git a/Config/Parser/ParserInterface.php b/Config/Parser/ParserInterface.php index 218844799..586798812 100644 --- a/Config/Parser/ParserInterface.php +++ b/Config/Parser/ParserInterface.php @@ -3,15 +3,14 @@ namespace Overblog\GraphQLBundle\Config\Parser; use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\Finder\SplFileInfo; interface ParserInterface { /** - * @param SplFileInfo $file + * @param \SplFileInfo $file * @param ContainerBuilder $container * * @return array */ - public static function parse(SplFileInfo $file, ContainerBuilder $container); + public static function parse(\SplFileInfo $file, ContainerBuilder $container); } diff --git a/Config/Parser/XmlParser.php b/Config/Parser/XmlParser.php index ab8de9fbf..aedef89bc 100644 --- a/Config/Parser/XmlParser.php +++ b/Config/Parser/XmlParser.php @@ -6,20 +6,16 @@ use Symfony\Component\Config\Util\XmlUtils; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; -use Symfony\Component\Finder\SplFileInfo; class XmlParser implements ParserInterface { /** - * @param SplFileInfo $file - * @param ContainerBuilder $container - * - * @return array + * {@inheritdoc} */ - public static function parse(SplFileInfo $file, ContainerBuilder $container) + public static function parse(\SplFileInfo $file, ContainerBuilder $container) { $typesConfig = []; - + $container->addResource(new FileResource($file->getRealPath())); try { $xml = XmlUtils::loadFile($file->getRealPath()); foreach ($xml->documentElement->childNodes as $node) { @@ -31,7 +27,6 @@ public static function parse(SplFileInfo $file, ContainerBuilder $container) $typesConfig = array_merge($typesConfig, $values); } } - $container->addResource(new FileResource($file->getRealPath())); } catch (\InvalidArgumentException $e) { throw new InvalidArgumentException(sprintf('Unable to parse file "%s".', $file), $e->getCode(), $e); } diff --git a/Config/Parser/YamlParser.php b/Config/Parser/YamlParser.php index 3580ac1bb..7b4f73dcd 100644 --- a/Config/Parser/YamlParser.php +++ b/Config/Parser/YamlParser.php @@ -5,7 +5,6 @@ use Symfony\Component\Config\Resource\FileResource; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; -use Symfony\Component\Finder\SplFileInfo; use Symfony\Component\Yaml\Exception\ParseException; use Symfony\Component\Yaml\Parser; @@ -15,20 +14,17 @@ class YamlParser implements ParserInterface private static $yamlParser; /** - * @param SplFileInfo $file - * @param ContainerBuilder $container - * - * @return array + * {@inheritdoc} */ - public static function parse(SplFileInfo $file, ContainerBuilder $container) + public static function parse(\SplFileInfo $file, ContainerBuilder $container) { if (null === self::$yamlParser) { self::$yamlParser = new Parser(); } + $container->addResource(new FileResource($file->getRealPath())); try { - $typesConfig = self::$yamlParser->parse($file->getContents()); - $container->addResource(new FileResource($file->getRealPath())); + $typesConfig = self::$yamlParser->parse(file_get_contents($file->getPathname())); } catch (ParseException $e) { throw new InvalidArgumentException(sprintf('The file "%s" does not contain valid YAML.', $file), 0, $e); } diff --git a/Config/TypeWithOutputFieldsDefinition.php b/Config/TypeWithOutputFieldsDefinition.php index 355d486f5..e81232d54 100644 --- a/Config/TypeWithOutputFieldsDefinition.php +++ b/Config/TypeWithOutputFieldsDefinition.php @@ -12,7 +12,7 @@ abstract class TypeWithOutputFieldsDefinition extends TypeDefinition * * @return ArrayNodeDefinition|\Symfony\Component\Config\Definition\Builder\NodeDefinition */ - protected function outputFieldsSelection($name) + protected function outputFieldsSelection($name = 'fields') { $builder = new TreeBuilder(); $node = $builder->root($name); diff --git a/Definition/Builder/SchemaBuilder.php b/Definition/Builder/SchemaBuilder.php index 7aee9d25a..e81377aba 100644 --- a/Definition/Builder/SchemaBuilder.php +++ b/Definition/Builder/SchemaBuilder.php @@ -2,47 +2,68 @@ namespace Overblog\GraphQLBundle\Definition\Builder; +use GraphQL\Type\Definition\Type; use GraphQL\Type\Schema; -use Overblog\GraphQLBundle\Resolver\ResolverInterface; +use Overblog\GraphQLBundle\Definition\Type\SchemaDecorator; +use Overblog\GraphQLBundle\Resolver\ResolverMapInterface; +use Overblog\GraphQLBundle\Resolver\ResolverMaps; +use Overblog\GraphQLBundle\Resolver\TypeResolver; class SchemaBuilder { - /** @var ResolverInterface */ + /** @var TypeResolver */ private $typeResolver; + /** @var SchemaDecorator */ + private $decorator; + /** @var bool */ private $enableValidation; - public function __construct(ResolverInterface $typeResolver, $enableValidation = false) + public function __construct(TypeResolver $typeResolver, SchemaDecorator $decorator, $enableValidation = false) { $this->typeResolver = $typeResolver; + $this->decorator = $decorator; $this->enableValidation = $enableValidation; } /** - * @param null|string $queryAlias - * @param null|string $mutationAlias - * @param null|string $subscriptionAlias + * @param null|string $queryAlias + * @param null|string $mutationAlias + * @param null|string $subscriptionAlias + * @param ResolverMapInterface[] $resolverMaps * * @return Schema */ - public function create($queryAlias = null, $mutationAlias = null, $subscriptionAlias = null) + public function create($queryAlias = null, $mutationAlias = null, $subscriptionAlias = null, array $resolverMaps = []) { $query = $this->typeResolver->resolve($queryAlias); $mutation = $this->typeResolver->resolve($mutationAlias); $subscription = $this->typeResolver->resolve($subscriptionAlias); - $schema = new Schema([ - 'query' => $query, - 'mutation' => $mutation, - 'subscription' => $subscription, - 'typeLoader' => [$this->typeResolver, 'resolve'], - 'types' => [$this->typeResolver, 'getSolutions'], - ]); + $schema = new Schema($this->buildSchemaArguments($query, $mutation, $subscription)); + reset($resolverMaps); + $this->decorator->decorate($schema, 1 === count($resolverMaps) ? current($resolverMaps) : new ResolverMaps($resolverMaps)); + if ($this->enableValidation) { $schema->assertValid(); } return $schema; } + + private function buildSchemaArguments(Type $query = null, Type $mutation = null, Type $subscription = null) + { + return [ + 'query' => $query, + 'mutation' => $mutation, + 'subscription' => $subscription, + 'typeLoader' => function ($name) { + return $this->typeResolver->resolve($name); + }, + 'types' => function () { + return $this->typeResolver->getSolutions(); + }, + ]; + } } diff --git a/Definition/ConfigProcessor.php b/Definition/ConfigProcessor.php new file mode 100644 index 000000000..d5489636b --- /dev/null +++ b/Definition/ConfigProcessor.php @@ -0,0 +1,64 @@ +register($configProcessor, $priority); + } + + public function register(ConfigProcessorInterface $configProcessor, $priority = 0) + { + if ($this->isInitialized) { + throw new \LogicException('Registering config processor after calling process() is not supported.'); + } + $this->processors[] = ['processor' => $configProcessor, 'priority' => $priority]; + } + + public function process(LazyConfig $lazyConfig) + { + $this->initialize(); + foreach ($this->orderedProcessors as $processor) { + $lazyConfig = $processor->process($lazyConfig); + } + + return $lazyConfig; + } + + private function initialize() + { + if (!$this->isInitialized) { + // order processors by DESC priority + $processors = $this->processors; + usort($processors, function ($processorA, $processorB) { + if ($processorA['priority'] === $processorB['priority']) { + return 0; + } + + return ($processorA['priority'] < $processorB['priority']) ? 1 : -1; + }); + + $this->orderedProcessors = array_column($processors, 'processor'); + $this->isInitialized = true; + } + } +} diff --git a/Definition/ConfigProcessor/AclConfigProcessor.php b/Definition/ConfigProcessor/AclConfigProcessor.php new file mode 100644 index 000000000..2e9302bcf --- /dev/null +++ b/Definition/ConfigProcessor/AclConfigProcessor.php @@ -0,0 +1,85 @@ +accessResolver = $accessResolver; + $this->defaultResolver = $defaultResolver; + } + + public static function acl(array $fields, AccessResolver $accessResolver, callable $defaultResolver) + { + $deniedAccess = static function () { + throw new UserWarning('Access denied to this field.'); + }; + foreach ($fields as &$field) { + if (isset($field['access']) && true !== $field['access']) { + $accessChecker = $field['access']; + if (false === $accessChecker) { + $field['resolve'] = $deniedAccess; + } elseif (is_callable($accessChecker)) { + $field['resolve'] = function ($value, $args, $context, ResolveInfo $info) use ($field, $accessChecker, $accessResolver, $defaultResolver) { + $resolverCallback = self::findFieldResolver($field, $info, $defaultResolver); + $isMutation = 'mutation' === $info->operation->operation && $info->parentType === $info->schema->getMutationType(); + + return $accessResolver->resolve($accessChecker, $resolverCallback, [$value, new Argument($args), $context, $info], $isMutation); + }; + } + } + } + + return $fields; + } + + public function process(LazyConfig $lazyConfig) + { + $lazyConfig->addPostLoader(function ($config) { + if (isset($config['fields']) && is_callable($config['fields'])) { + $config['fields'] = function () use ($config) { + $fields = $config['fields'](); + + return static::acl($fields, $this->accessResolver, $this->defaultResolver); + }; + } + + return $config; + }); + + return $lazyConfig; + } + + /** + * @param array $field + * @param ResolveInfo $info + * @param callable $defaultResolver + * + * @return callable + */ + private static function findFieldResolver(array $field, ResolveInfo $info, callable $defaultResolver) + { + if (isset($field['resolve'])) { + $resolver = $field['resolve']; + } elseif (isset($info->parentType->config['resolveField'])) { + $resolver = $info->parentType->config['resolveField']; + } else { + $resolver = $defaultResolver; + } + + return $resolver; + } +} diff --git a/Definition/ConfigProcessor/ConfigProcessorInterface.php b/Definition/ConfigProcessor/ConfigProcessorInterface.php new file mode 100644 index 000000000..b2a498505 --- /dev/null +++ b/Definition/ConfigProcessor/ConfigProcessorInterface.php @@ -0,0 +1,15 @@ +addPostLoader(function ($config) { + if (isset($config['fields']) && is_callable($config['fields'])) { + $config['fields'] = function () use ($config) { + $fields = $config['fields'](); + + return static::filter($fields); + }; + } + + return $config; + }); + + return $lazyConfig; + } +} diff --git a/Definition/GlobalVariables.php b/Definition/GlobalVariables.php new file mode 100644 index 000000000..5efe5950d --- /dev/null +++ b/Definition/GlobalVariables.php @@ -0,0 +1,38 @@ +services = $services; + } + + /** + * @param string $name + * + * @return mixed + */ + public function get($name) + { + if (!isset($this->services[$name])) { + throw new \LogicException(sprintf('Global variable %s could not be located. You should define it.', json_encode($name))); + } + + return $this->services[$name]; + } + + /** + * @param string $name + * + * @return bool + */ + public function has($name) + { + return isset($this->services[$name]); + } +} diff --git a/Definition/LazyConfig.php b/Definition/LazyConfig.php new file mode 100644 index 000000000..fa6ed89b9 --- /dev/null +++ b/Definition/LazyConfig.php @@ -0,0 +1,47 @@ +loader = $loader; + $this->globalVariables = $globalVariables ?: new GlobalVariables(); + } + + public static function create(\Closure $loader, GlobalVariables $globalVariables = null) + { + return new self($loader, $globalVariables); + } + + /** + * @return array + */ + public function load() + { + $loader = $this->loader; + $config = $loader($this->globalVariables); + foreach ($this->onPostLoad as $postLoader) { + $config = $postLoader($config); + } + + return $config; + } + + public function addPostLoader(callable $postLoader) + { + $this->onPostLoad[] = $postLoader; + } +} diff --git a/Definition/Type/SchemaDecorator.php b/Definition/Type/SchemaDecorator.php new file mode 100644 index 000000000..66233d389 --- /dev/null +++ b/Definition/Type/SchemaDecorator.php @@ -0,0 +1,179 @@ +covered() as $typeName) { + $type = $schema->getType($typeName); + + if ($type instanceof ObjectType) { + $this->decorateObjectType($type, $resolverMap); + } elseif ($type instanceof InterfaceType || $type instanceof UnionType) { + $this->decorateInterfaceOrUnionType($type, $resolverMap); + } elseif ($type instanceof EnumType) { + $this->decorateEnumType($type, $resolverMap); + } elseif ($type instanceof CustomScalarType) { + $this->decorateCustomScalarType($type, $resolverMap); + } else { + $covered = $resolverMap->covered($type->name); + if (!empty($covered)) { + throw new \InvalidArgumentException( + sprintf( + '"%s".{"%s"} defined in resolverMap, but type is not managed by SchemaDecorator.', + $type->name, + implode('", "', $covered) + ) + ); + } + } + } + } + + private function decorateObjectType(ObjectType $type, ResolverMapInterface $resolverMap) + { + $fieldsResolved = []; + foreach ($resolverMap->covered($type->name) as $fieldName) { + if (ResolverMapInterface::IS_TYPEOF === $fieldName) { + $this->configTypeMapping($type, $resolverMap, ResolverMapInterface::IS_TYPEOF); + } elseif (ResolverMapInterface::RESOLVE_FIELD === $fieldName) { + $resolveFieldFn = self::wrapResolver($resolverMap->resolve($type->name, ResolverMapInterface::RESOLVE_FIELD)); + $type->config[substr(ResolverMapInterface::RESOLVE_FIELD, 2)] = $resolveFieldFn; + $type->resolveFieldFn = $resolveFieldFn; + } else { + $fieldsResolved[] = $fieldName; + } + } + $this->decorateObjectTypeFields($type, $resolverMap, $fieldsResolved); + } + + /** + * @param InterfaceType|UnionType $type + * @param ResolverMapInterface $resolverMap + */ + private function decorateInterfaceOrUnionType($type, ResolverMapInterface $resolverMap) + { + $this->configTypeMapping($type, $resolverMap, ResolverMapInterface::RESOLVE_TYPE); + $covered = $resolverMap->covered($type->name); + if (!empty($covered)) { + $unknownFields = array_diff($covered, [ResolverMapInterface::RESOLVE_TYPE]); + if (!empty($unknownFields)) { + throw new \InvalidArgumentException( + sprintf( + '"%s".{"%s"} defined in resolverMap, but only "%s" is allowed.', + $type->name, + implode('", "', $unknownFields), + ResolverMapInterface::RESOLVE_TYPE + ) + ); + } + } + } + + private function decorateCustomScalarType(CustomScalarType $type, ResolverMapInterface $resolverMap) + { + static $allowedFields = [ResolverMapInterface::SERIALIZE, ResolverMapInterface::PARSE_VALUE, ResolverMapInterface::PARSE_LITERAL]; + + foreach ($allowedFields as $fieldName) { + $this->configTypeMapping($type, $resolverMap, $fieldName); + } + + $unknownFields = array_diff($resolverMap->covered($type->name), $allowedFields); + if (!empty($unknownFields)) { + throw new \InvalidArgumentException( + sprintf( + '"%s".{"%s"} defined in resolverMap, but only "%s" is allowed.', + $type->name, + implode('", "', $unknownFields), + implode('", "', $allowedFields) + ) + ); + } + } + + private function decorateEnumType(EnumType $type, ResolverMapInterface $resolverMaps) + { + $fieldNames = []; + foreach ($type->config['values'] as $key => &$value) { + $fieldName = isset($value['name']) ? $value['name'] : $key; + if ($resolverMaps->isResolvable($type->name, $fieldName)) { + $value['value'] = $resolverMaps->resolve($type->name, $fieldName); + } + $fieldNames[] = $fieldName; + } + $unknownFields = array_diff($resolverMaps->covered($type->name), $fieldNames); + if (!empty($unknownFields)) { + throw new \InvalidArgumentException( + sprintf( + '"%s".{"%s"} defined in resolverMap, was defined in resolvers, but enum is not in schema.', + $type->name, + implode('", "', $unknownFields) + ) + ); + } + } + + private function decorateObjectTypeFields(ObjectType $type, ResolverMapInterface $resolverMap, array $fieldsResolved) + { + $fields = $type->config['fields']; + + $decoratedFields = function () use ($fields, $type, $resolverMap, $fieldsResolved) { + if (is_callable($fields)) { + $fields = $fields(); + } + + $fieldNames = []; + foreach ($fields as $key => &$field) { + $fieldName = isset($field['name']) ? $field['name'] : $key; + + if ($resolverMap->isResolvable($type->name, $fieldName)) { + $field['resolve'] = self::wrapResolver($resolverMap->resolve($type->name, $fieldName)); + } + + $fieldNames[] = $fieldName; + } + + $unknownFields = array_diff($fieldsResolved, $fieldNames); + if (!empty($unknownFields)) { + throw new \InvalidArgumentException( + sprintf('"%s".{"%s"} defined in resolverMap, but not in schema.', $type->name, implode('", "', $unknownFields)) + ); + } + + return $fields; + }; + + $type->config['fields'] = is_callable($fields) ? $decoratedFields : $decoratedFields(); + } + + private function configTypeMapping(Type $type, ResolverMapInterface $resolverMap, $fieldName) + { + if ($resolverMap->isResolvable($type->name, $fieldName)) { + $type->config[substr($fieldName, 2)] = $resolverMap->resolve($type->name, $fieldName); + } + } + + private static function wrapResolver(callable $resolver) + { + return static function () use ($resolver) { + $args = func_get_args(); + if (count($args) > 1) { + $args[1] = new Argument($args[1]); + } + + return call_user_func_array($resolver, $args); + }; + } +} diff --git a/DependencyInjection/Compiler/ConfigTypesPass.php b/DependencyInjection/Compiler/ConfigTypesPass.php index d4c1697ce..6f0b620ed 100644 --- a/DependencyInjection/Compiler/ConfigTypesPass.php +++ b/DependencyInjection/Compiler/ConfigTypesPass.php @@ -2,6 +2,8 @@ namespace Overblog\GraphQLBundle\DependencyInjection\Compiler; +use Overblog\GraphQLBundle\Definition\ConfigProcessor; +use Overblog\GraphQLBundle\Definition\GlobalVariables; use Overblog\GraphQLBundle\Generator\TypeGenerator; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -25,7 +27,7 @@ private function setTypeServiceDefinition(ContainerBuilder $container, $class, a { $definition = $container->setDefinition($class, new Definition($class)); $definition->setPublic(false); - $definition->setArguments([new Reference('service_container')]); + $definition->setArguments([new Reference(ConfigProcessor::class), new Reference(GlobalVariables::class)]); foreach ($aliases as $alias) { $definition->addTag(TypeTaggedServiceMappingPass::TAG_NAME, ['alias' => $alias, 'generated' => true]); } diff --git a/DependencyInjection/Compiler/DefinitionConfigProcessorPass.php b/DependencyInjection/Compiler/DefinitionConfigProcessorPass.php new file mode 100644 index 000000000..24a5c5219 --- /dev/null +++ b/DependencyInjection/Compiler/DefinitionConfigProcessorPass.php @@ -0,0 +1,32 @@ +findDefinition(ConfigProcessor::class); + $taggedServices = $container->findTaggedServiceIds('overblog_graphql.definition_config_processor', true); + + foreach ($taggedServices as $id => $tags) { + foreach ($tags as $attributes) { + $definition->addMethodCall( + 'addConfigProcessor', + [ + new Reference($id), + isset($attributes['priority']) ? $attributes['priority'] : 0, + ] + ); + } + } + } +} diff --git a/DependencyInjection/Compiler/ExpressionFunctionPass.php b/DependencyInjection/Compiler/ExpressionFunctionPass.php index 99d8c5879..0dcc3910d 100644 --- a/DependencyInjection/Compiler/ExpressionFunctionPass.php +++ b/DependencyInjection/Compiler/ExpressionFunctionPass.php @@ -14,7 +14,7 @@ final class ExpressionFunctionPass implements CompilerPassInterface public function process(ContainerBuilder $container) { $definition = $container->findDefinition('overblog_graphql.expression_language'); - $taggedServices = $container->findTaggedServiceIds('overblog_graphql.expression_function'); + $taggedServices = $container->findTaggedServiceIds('overblog_graphql.expression_function', true); foreach ($taggedServices as $id => $tags) { $definition->addMethodCall('addFunction', [new Reference($id)]); diff --git a/DependencyInjection/Compiler/GlobalVariablesPass.php b/DependencyInjection/Compiler/GlobalVariablesPass.php new file mode 100644 index 000000000..a0969d974 --- /dev/null +++ b/DependencyInjection/Compiler/GlobalVariablesPass.php @@ -0,0 +1,44 @@ +findTaggedServiceIds('overblog_graphql.global_variable', true); + $globalVariables = ['container' => new Reference('service_container')]; + $expressionLanguageDefinition = $container->findDefinition('overblog_graphql.expression_language'); + + foreach ($taggedServices as $id => $tags) { + foreach ($tags as $attributes) { + if (empty($attributes['alias']) || !is_string($attributes['alias'])) { + throw new \InvalidArgumentException( + sprintf('Service "%s" tagged "overblog_graphql.global_variable" should have a valid "alias" attribute.', $id) + ); + } + $globalVariables[$attributes['alias']] = new Reference($id); + + $isPublic = isset($attributes['public']) ? (bool) $attributes['public'] : true; + if ($isPublic) { + $expressionLanguageDefinition->addMethodCall( + 'addGlobalName', + [ + sprintf('globalVariables->get(\'%s\')', $attributes['alias']), + $attributes['alias'], + ] + ); + } + } + } + $container->findDefinition(GlobalVariables::class)->setArguments([$globalVariables]); + } +} diff --git a/DependencyInjection/Compiler/TaggedServiceMappingPass.php b/DependencyInjection/Compiler/TaggedServiceMappingPass.php index 31dcea4f0..ee47a440b 100644 --- a/DependencyInjection/Compiler/TaggedServiceMappingPass.php +++ b/DependencyInjection/Compiler/TaggedServiceMappingPass.php @@ -13,7 +13,7 @@ private function getTaggedServiceMapping(ContainerBuilder $container, $tagName) { $serviceMapping = []; - $taggedServices = $container->findTaggedServiceIds($tagName); + $taggedServices = $container->findTaggedServiceIds($tagName, true); $isType = TypeTaggedServiceMappingPass::TAG_NAME === $tagName; foreach ($taggedServices as $id => $tags) { diff --git a/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php index 25bee13e7..847281600 100644 --- a/DependencyInjection/Configuration.php +++ b/DependencyInjection/Configuration.php @@ -94,7 +94,6 @@ private function errorsHandlerSection() ->arrayNode('errors') ->treatNullLike([]) ->prototype('scalar')->end() - ->end() ->end() ->end() ->end(); @@ -197,6 +196,10 @@ private function definitionsSchemaSection() ->scalarNode('query')->defaultNull()->end() ->scalarNode('mutation')->defaultNull()->end() ->scalarNode('subscription')->defaultNull()->end() + ->arrayNode('resolver_maps') + ->defaultValue([]) + ->prototype('scalar')->end() + ->end() ->end() ->end() ->end(); @@ -229,7 +232,6 @@ private function definitionsAutoMappingSection() private function definitionsMappingsSection() { $builder = new TreeBuilder(); - /** @var ArrayNodeDefinition $node */ $node = $builder->root('mappings'); $node ->children() @@ -248,23 +250,30 @@ private function definitionsMappingsSection() ->addDefaultsIfNotSet() ->beforeNormalization() ->ifTrue(function ($v) { - return isset($v['type']) && 'yml' === $v['type']; + return isset($v['type']) && is_string($v['type']); }) ->then(function ($v) { - $v['type'] = 'yaml'; + if ('yml' === $v['type']) { + $v['types'] = ['yaml']; + } else { + $v['types'] = [$v['type']]; + } + unset($v['type']); return $v; }) ->end() ->children() - ->enumNode('type')->values(['yaml', 'xml'])->defaultNull()->end() + ->arrayNode('types') + ->prototype('enum')->values(array_keys(OverblogGraphQLTypesExtension::SUPPORTED_TYPES_EXTENSIONS))->isRequired()->end() + ->end() ->scalarNode('dir')->defaultNull()->end() ->scalarNode('suffix')->defaultValue(OverblogGraphQLTypesExtension::DEFAULT_TYPES_SUFFIX)->end() ->end() ->end() ->end() ->end() - ->end(); + ; return $node; } diff --git a/DependencyInjection/OverblogGraphQLExtension.php b/DependencyInjection/OverblogGraphQLExtension.php index 5c2ac3fb9..822614f28 100644 --- a/DependencyInjection/OverblogGraphQLExtension.php +++ b/DependencyInjection/OverblogGraphQLExtension.php @@ -29,11 +29,7 @@ class OverblogGraphQLExtension extends Extension implements PrependExtensionInte { public function load(array $configs, ContainerBuilder $container) { - $loader = new YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); - $loader->load('services.yml'); - $loader->load('graphql_types.yml'); - $loader->load('expression_language_functions.yml'); - + $this->loadConfigFiles($container); $config = $this->treatConfigs($configs, $container); $this->setBatchingMethod($config, $container); @@ -76,6 +72,15 @@ public function getConfiguration(array $config, ContainerBuilder $container) ); } + private function loadConfigFiles(ContainerBuilder $container) + { + $loader = new YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); + $loader->load('services.yml'); + $loader->load('graphql_types.yml'); + $loader->load('expression_language_functions.yml'); + $loader->load('definition_config_processors.yml'); + } + private function setCompilerCacheWarmer(array $config, ContainerBuilder $container) { if ($config['definitions']['auto_compile']) { @@ -211,7 +216,7 @@ private function setErrorHandler(array $config, ContainerBuilder $container) private function setSchemaBuilderArguments(array $config, ContainerBuilder $container) { $container->getDefinition($this->getAlias().'.schema_builder') - ->replaceArgument(1, $config['definitions']['config_validation']); + ->replaceArgument(2, $config['definitions']['config_validation']); } private function setSchemaArguments(array $config, ContainerBuilder $container) @@ -223,7 +228,14 @@ private function setSchemaArguments(array $config, ContainerBuilder $container) $schemaID = sprintf('%s.schema_%s', $this->getAlias(), $schemaName); $definition = new Definition(Schema::class); $definition->setFactory([new Reference('overblog_graphql.schema_builder'), 'create']); - $definition->setArguments([$schemaConfig['query'], $schemaConfig['mutation'], $schemaConfig['subscription']]); + $definition->setArguments([ + $schemaConfig['query'], + $schemaConfig['mutation'], + $schemaConfig['subscription'], + array_map(function ($id) { + return new Reference($id); + }, $schemaConfig['resolver_maps']), + ]); $definition->setPublic(false); $container->setDefinition($schemaID, $definition); diff --git a/DependencyInjection/OverblogGraphQLTypesExtension.php b/DependencyInjection/OverblogGraphQLTypesExtension.php index 874e4ac10..3c46a047e 100644 --- a/DependencyInjection/OverblogGraphQLTypesExtension.php +++ b/DependencyInjection/OverblogGraphQLTypesExtension.php @@ -2,6 +2,9 @@ namespace Overblog\GraphQLBundle\DependencyInjection; +use Overblog\GraphQLBundle\Config\Parser\GraphQLParser; +use Overblog\GraphQLBundle\Config\Parser\XmlParser; +use Overblog\GraphQLBundle\Config\Parser\YamlParser; use Overblog\GraphQLBundle\OverblogGraphQLBundle; use Symfony\Component\Config\Definition\Exception\ForbiddenOverwriteException; use Symfony\Component\Config\Resource\FileResource; @@ -12,9 +15,13 @@ class OverblogGraphQLTypesExtension extends Extension { - private static $configTypes = ['yaml', 'xml']; + const SUPPORTED_TYPES_EXTENSIONS = ['yaml' => '{yaml,yml}', 'xml' => 'xml', 'graphql' => '{graphql,graphqls}']; - private static $typeExtensions = ['yaml' => '{yaml,yml}', 'xml' => 'xml']; + const PARSERS = [ + 'yaml' => YamlParser::class, + 'xml' => XmlParser::class, + 'graphql' => GraphQLParser::class, + ]; private static $defaultDefaultConfig = [ 'definitions' => [ @@ -48,6 +55,7 @@ public function containerPrependExtensionConfig(array $config, ContainerBuilder $typesMappings = $this->mappingConfig($config, $container); // reset treated files $this->treatedFiles = []; + $typesMappings = call_user_func_array('array_merge', $typesMappings); // treats mappings foreach ($typesMappings as $params) { $this->prependExtensionConfigFromFiles($params['type'], $params['files'], $container); @@ -67,9 +75,7 @@ private function prependExtensionConfigFromFiles($type, $files, ContainerBuilder continue; } - $parserClass = sprintf('Overblog\\GraphQLBundle\\Config\\Parser\\%sParser', ucfirst($type)); - - $typeConfig = call_user_func($parserClass.'::parse', $file, $container); + $typeConfig = call_user_func(self::PARSERS[$type].'::parse', $file, $container); $container->prependExtensionConfig($this->getAlias(), $typeConfig); $this->treatedFiles[$file->getRealPath()] = true; } @@ -97,9 +103,9 @@ private function mappingConfig(array $config, ContainerBuilder $container) $mappingConfig = $config['definitions']['mappings']; $typesMappings = $mappingConfig['types']; - // app only config files (yml or xml) + // app only config files (yml or xml or graphql) if ($mappingConfig['auto_discover']['root_dir'] && $container->hasParameter('kernel.root_dir')) { - $typesMappings[] = ['dir' => $container->getParameter('kernel.root_dir').'/config/graphql', 'type' => null]; + $typesMappings[] = ['dir' => $container->getParameter('kernel.root_dir').'/config/graphql', 'types' => null]; } if ($mappingConfig['auto_discover']['bundles']) { $mappingFromBundles = $this->mappingFromBundles($container); @@ -108,7 +114,7 @@ private function mappingConfig(array $config, ContainerBuilder $container) // enabled only for this bundle $typesMappings[] = [ 'dir' => $this->bundleDir(OverblogGraphQLBundle::class).'/Resources/config/graphql', - 'type' => 'yaml', + 'types' => ['yaml'], ]; } @@ -122,12 +128,9 @@ private function detectFilesFromTypesMappings(array $typesMappings, ContainerBui { return array_filter(array_map( function (array $typeMapping) use ($container) { - $params = $this->detectFilesByType( - $container, - $typeMapping['dir'], - $typeMapping['type'], - isset($typeMapping['suffix']) ? $typeMapping['suffix'] : '' - ); + $suffix = isset($typeMapping['suffix']) ? $typeMapping['suffix'] : ''; + $types = isset($typeMapping['types']) ? $typeMapping['types'] : null; + $params = $this->detectFilesByTypes($container, $typeMapping['dir'], $suffix, $types); return $params; }, @@ -145,13 +148,13 @@ private function mappingFromBundles(ContainerBuilder $container) $bundleDir = $this->bundleDir($class); // only config files (yml or xml) - $typesMappings[] = ['dir' => $bundleDir.'/Resources/config/graphql', 'type' => null]; + $typesMappings[] = ['dir' => $bundleDir.'/Resources/config/graphql', 'types' => null]; } return $typesMappings; } - private function detectFilesByType(ContainerBuilder $container, $path, $type, $suffix) + private function detectFilesByTypes(ContainerBuilder $container, $path, $suffix, array $types = null) { // add the closest existing directory as a resource $resource = $path; @@ -160,25 +163,30 @@ private function detectFilesByType(ContainerBuilder $container, $path, $type, $s } $container->addResource(new FileResource($resource)); - $finder = new Finder(); + $stopOnFirstTypeMatching = empty($types); - $types = null === $type ? self::$configTypes : [$type]; + $types = $stopOnFirstTypeMatching ? array_keys(self::SUPPORTED_TYPES_EXTENSIONS) : $types; + $files = []; foreach ($types as $type) { + $finder = Finder::create(); try { - $finder->files()->in($path)->name('*'.$suffix.'.'.self::$typeExtensions[$type]); + $finder->files()->in($path)->name(sprintf('*%s.%s', $suffix, self::SUPPORTED_TYPES_EXTENSIONS[$type])); } catch (\InvalidArgumentException $e) { continue; } if ($finder->count() > 0) { - return [ + $files[] = [ 'type' => $type, 'files' => $finder, ]; + if ($stopOnFirstTypeMatching) { + break; + } } } - return; + return $files; } private function bundleDir($bundleClass) diff --git a/ExpressionLanguage/ExpressionFunction/DependencyInjection/Parameter.php b/ExpressionLanguage/ExpressionFunction/DependencyInjection/Parameter.php index 4a4a96f66..8a3bae7b4 100644 --- a/ExpressionLanguage/ExpressionFunction/DependencyInjection/Parameter.php +++ b/ExpressionLanguage/ExpressionFunction/DependencyInjection/Parameter.php @@ -11,7 +11,7 @@ public function __construct($name = 'parameter') parent::__construct( $name, function ($value) { - return sprintf('$container->getParameter(%s)', $value); + return sprintf('$globalVariable->get(\'container\')->getParameter(%s)', $value); } ); } diff --git a/ExpressionLanguage/ExpressionFunction/DependencyInjection/Service.php b/ExpressionLanguage/ExpressionFunction/DependencyInjection/Service.php index a49bbac98..19aaa9d34 100644 --- a/ExpressionLanguage/ExpressionFunction/DependencyInjection/Service.php +++ b/ExpressionLanguage/ExpressionFunction/DependencyInjection/Service.php @@ -11,7 +11,7 @@ public function __construct($name = 'service') parent::__construct( $name, function ($value) { - return sprintf('$container->get(%s)', $value); + return sprintf('$globalVariable->get(\'container\')->get(%s)', $value); } ); } diff --git a/ExpressionLanguage/ExpressionFunction/GraphQL/Mutation.php b/ExpressionLanguage/ExpressionFunction/GraphQL/Mutation.php index f824ac7fa..d8c02e3fb 100644 --- a/ExpressionLanguage/ExpressionFunction/GraphQL/Mutation.php +++ b/ExpressionLanguage/ExpressionFunction/GraphQL/Mutation.php @@ -11,7 +11,7 @@ public function __construct($name = 'mutation') parent::__construct( $name, function ($alias, $args = '[]') { - return sprintf('$container->get(\'overblog_graphql.mutation_resolver\')->resolve([%s, %s])', $alias, $args); + return sprintf('$globalVariable->get(\'mutationResolver\')->resolve([%s, %s])', $alias, $args); } ); } diff --git a/ExpressionLanguage/ExpressionFunction/GraphQL/Resolver.php b/ExpressionLanguage/ExpressionFunction/GraphQL/Resolver.php index 52a2bfed7..bd3d53c55 100644 --- a/ExpressionLanguage/ExpressionFunction/GraphQL/Resolver.php +++ b/ExpressionLanguage/ExpressionFunction/GraphQL/Resolver.php @@ -11,7 +11,7 @@ public function __construct($name = 'resolver') parent::__construct( $name, function ($alias, $args = '[]') { - return sprintf('$container->get(\'overblog_graphql.resolver_resolver\')->resolve([%s, %s])', $alias, $args); + return sprintf('$globalVariable->get(\'resolverResolver\')->resolve([%s, %s])', $alias, $args); } ); } diff --git a/ExpressionLanguage/ExpressionFunction/Security/GetUser.php b/ExpressionLanguage/ExpressionFunction/Security/GetUser.php new file mode 100644 index 000000000..4c791e701 --- /dev/null +++ b/ExpressionLanguage/ExpressionFunction/Security/GetUser.php @@ -0,0 +1,18 @@ +get(\'security.authorization_checker\')->isGranted($permission, %s); }, false)', $permissions, $object); + $code = sprintf('array_reduce(%s, function ($isGranted, $permission) use (%s, $object) { return $isGranted || $globalVariable->get(\'container\')->get(\'security.authorization_checker\')->isGranted($permission, %s); }, false)', $permissions, TypeGenerator::USE_FOR_CLOSURES, $object); return $code; } diff --git a/ExpressionLanguage/ExpressionFunction/Security/HasAnyRole.php b/ExpressionLanguage/ExpressionFunction/Security/HasAnyRole.php index 18845c7cb..ec57861d4 100644 --- a/ExpressionLanguage/ExpressionFunction/Security/HasAnyRole.php +++ b/ExpressionLanguage/ExpressionFunction/Security/HasAnyRole.php @@ -3,6 +3,7 @@ namespace Overblog\GraphQLBundle\ExpressionLanguage\ExpressionFunction\Security; use Overblog\GraphQLBundle\ExpressionLanguage\ExpressionFunction; +use Overblog\GraphQLBundle\Generator\TypeGenerator; final class HasAnyRole extends ExpressionFunction { @@ -11,7 +12,7 @@ public function __construct($name = 'hasAnyRole') parent::__construct( $name, function ($roles) { - $code = sprintf('array_reduce(%s, function ($isGranted, $role) use ($container) { return $isGranted || $container->get(\'security.authorization_checker\')->isGranted($role); }, false)', $roles); + $code = sprintf('array_reduce(%s, function ($isGranted, $role) use (%s) { return $isGranted || $globalVariable->get(\'container\')->get(\'security.authorization_checker\')->isGranted($role); }, false)', $roles, TypeGenerator::USE_FOR_CLOSURES); return $code; } diff --git a/ExpressionLanguage/ExpressionFunction/Security/HasPermission.php b/ExpressionLanguage/ExpressionFunction/Security/HasPermission.php index d01a6bd1c..b8819e7e4 100644 --- a/ExpressionLanguage/ExpressionFunction/Security/HasPermission.php +++ b/ExpressionLanguage/ExpressionFunction/Security/HasPermission.php @@ -11,7 +11,7 @@ public function __construct($name = 'hasPermission') parent::__construct( $name, function ($object, $permission) { - $code = sprintf('$container->get(\'security.authorization_checker\')->isGranted(%s, %s)', $permission, $object); + $code = sprintf('$globalVariable->get(\'container\')->get(\'security.authorization_checker\')->isGranted(%s, %s)', $permission, $object); return $code; } diff --git a/ExpressionLanguage/ExpressionFunction/Security/HasRole.php b/ExpressionLanguage/ExpressionFunction/Security/HasRole.php index b04ea52b1..303c032e1 100644 --- a/ExpressionLanguage/ExpressionFunction/Security/HasRole.php +++ b/ExpressionLanguage/ExpressionFunction/Security/HasRole.php @@ -11,7 +11,7 @@ public function __construct($name = 'hasRole') parent::__construct( $name, function ($role) { - return sprintf('$container->get(\'security.authorization_checker\')->isGranted(%s)', $role); + return sprintf('$globalVariable->get(\'container\')->get(\'security.authorization_checker\')->isGranted(%s)', $role); } ); } diff --git a/ExpressionLanguage/ExpressionFunction/Security/Helper.php b/ExpressionLanguage/ExpressionFunction/Security/Helper.php new file mode 100644 index 000000000..74853e2fc --- /dev/null +++ b/ExpressionLanguage/ExpressionFunction/Security/Helper.php @@ -0,0 +1,37 @@ +get('container')->has('security.token_storage')) { + return; + } + + return $globalVariable->get('container')->get('security.token_storage')->getToken(); + } + + public static function getUser(GlobalVariables $globalVariable) + { + if (!$token = self::getToken($globalVariable)) { + return; + } + + $user = $token->getUser(); + if (!is_object($user)) { + return; + } + + return $user; + } +} diff --git a/ExpressionLanguage/ExpressionFunction/Security/IsAnonymous.php b/ExpressionLanguage/ExpressionFunction/Security/IsAnonymous.php index a4874110f..401285e3e 100644 --- a/ExpressionLanguage/ExpressionFunction/Security/IsAnonymous.php +++ b/ExpressionLanguage/ExpressionFunction/Security/IsAnonymous.php @@ -11,7 +11,7 @@ public function __construct($name = 'isAnonymous') parent::__construct( $name, function () { - return '$container->get(\'security.authorization_checker\')->isGranted(\'IS_AUTHENTICATED_ANONYMOUSLY\')'; + return '$globalVariable->get(\'container\')->get(\'security.authorization_checker\')->isGranted(\'IS_AUTHENTICATED_ANONYMOUSLY\')'; } ); } diff --git a/ExpressionLanguage/ExpressionFunction/Security/IsAuthenticated.php b/ExpressionLanguage/ExpressionFunction/Security/IsAuthenticated.php index d222e7949..c47384e89 100644 --- a/ExpressionLanguage/ExpressionFunction/Security/IsAuthenticated.php +++ b/ExpressionLanguage/ExpressionFunction/Security/IsAuthenticated.php @@ -11,7 +11,7 @@ public function __construct($name = 'isAuthenticated') parent::__construct( $name, function () { - return '$container->get(\'security.authorization_checker\')->isGranted(\'IS_AUTHENTICATED_REMEMBERED\') || $container->get(\'security.authorization_checker\')->isGranted(\'IS_AUTHENTICATED_FULLY\')'; + return '$globalVariable->get(\'container\')->get(\'security.authorization_checker\')->isGranted(\'IS_AUTHENTICATED_REMEMBERED\') || $globalVariable->get(\'container\')->get(\'security.authorization_checker\')->isGranted(\'IS_AUTHENTICATED_FULLY\')'; } ); } diff --git a/ExpressionLanguage/ExpressionFunction/Security/IsFullyAuthenticated.php b/ExpressionLanguage/ExpressionFunction/Security/IsFullyAuthenticated.php index 2fb09b4c0..d6615c318 100644 --- a/ExpressionLanguage/ExpressionFunction/Security/IsFullyAuthenticated.php +++ b/ExpressionLanguage/ExpressionFunction/Security/IsFullyAuthenticated.php @@ -11,7 +11,7 @@ public function __construct($name = 'isFullyAuthenticated') parent::__construct( $name, function () { - return '$container->get(\'security.authorization_checker\')->isGranted(\'IS_AUTHENTICATED_FULLY\')'; + return '$globalVariable->get(\'container\')->get(\'security.authorization_checker\')->isGranted(\'IS_AUTHENTICATED_FULLY\')'; } ); } diff --git a/ExpressionLanguage/ExpressionFunction/Security/IsRememberMe.php b/ExpressionLanguage/ExpressionFunction/Security/IsRememberMe.php index ff6fad79a..693b46a18 100644 --- a/ExpressionLanguage/ExpressionFunction/Security/IsRememberMe.php +++ b/ExpressionLanguage/ExpressionFunction/Security/IsRememberMe.php @@ -11,7 +11,7 @@ public function __construct($name = 'isRememberMe') parent::__construct( $name, function () { - return '$container->get(\'security.authorization_checker\')->isGranted(\'IS_AUTHENTICATED_REMEMBERED\')'; + return '$globalVariable->get(\'container\')->get(\'security.authorization_checker\')->isGranted(\'IS_AUTHENTICATED_REMEMBERED\')'; } ); } diff --git a/ExpressionLanguage/ExpressionLanguage.php b/ExpressionLanguage/ExpressionLanguage.php index 26ef21019..dd6c1fd4c 100644 --- a/ExpressionLanguage/ExpressionLanguage.php +++ b/ExpressionLanguage/ExpressionLanguage.php @@ -2,21 +2,23 @@ namespace Overblog\GraphQLBundle\ExpressionLanguage; -use Symfony\Component\DependencyInjection\ContainerAwareTrait; use Symfony\Component\ExpressionLanguage\ExpressionLanguage as BaseExpressionLanguage; class ExpressionLanguage extends BaseExpressionLanguage { - use ContainerAwareTrait; + private $globalNames = []; - public function compile($expression, $names = []) + /** + * @param $index + * @param $name + */ + public function addGlobalName($index, $name) { - $names[] = 'container'; - $names[] = 'request'; - $names[] = 'security.token_storage'; - $names[] = 'token'; - $names[] = 'user'; + $this->globalNames[$index] = $name; + } - return parent::compile($expression, $names); + public function compile($expression, $names = []) + { + return parent::compile($expression, array_merge($names, $this->globalNames)); } } diff --git a/Generator/TypeGenerator.php b/Generator/TypeGenerator.php index 66ef1204b..1b011bb1a 100644 --- a/Generator/TypeGenerator.php +++ b/Generator/TypeGenerator.php @@ -3,23 +3,19 @@ namespace Overblog\GraphQLBundle\Generator; use Composer\Autoload\ClassLoader; -use GraphQL\Type\Definition\ResolveInfo; use Overblog\GraphQLBundle\Config\Processor; use Overblog\GraphQLBundle\Definition\Argument; -use Overblog\GraphQLBundle\Error\UserWarning; use Overblog\GraphQLGenerator\Generator\TypeGenerator as BaseTypeGenerator; use Symfony\Component\Filesystem\Filesystem; class TypeGenerator extends BaseTypeGenerator { - const USE_FOR_CLOSURES = '$container, $request, $user, $token'; + const USE_FOR_CLOSURES = '$globalVariable'; const DEFAULT_CONFIG_PROCESSOR = [Processor::class, 'process']; private $cacheDir; - private $defaultResolver; - private $configProcessor; private $configs; @@ -32,13 +28,11 @@ public function __construct( $classNamespace, array $skeletonDirs, $cacheDir, - callable $defaultResolver, array $configs, $useClassMap = true, callable $configProcessor = null) { $this->setCacheDir($cacheDir); - $this->defaultResolver = $defaultResolver; $this->configProcessor = null === $configProcessor ? static::DEFAULT_CONFIG_PROCESSOR : $configProcessor; $this->configs = $configs; $this->useClassMap = $useClassMap; @@ -75,24 +69,14 @@ protected function generateClassDocBlock(array $value) EOF; } - protected function generateOutputFields(array $config) - { - $outputFieldsCode = sprintf( - 'self::applyPublicFilters(%s)', - $this->processFromArray($config['fields'], 'OutputField') - ); - - return sprintf(static::$closureTemplate, '', $outputFieldsCode); - } - protected function generateClosureUseStatements(array $config) { - return 'use ('.static::USE_FOR_CLOSURES.') '; + return sprintf('use (%s) ', static::USE_FOR_CLOSURES); } protected function resolveTypeCode($alias) { - return sprintf('$container->get(\'%s\')->resolve(%s)', 'overblog_graphql.type_resolver', var_export($alias, true)); + return sprintf('$globalVariable->get(\'typeResolver\')->resolve(%s)', var_export($alias, true)); } protected function generatePublic(array $value) @@ -103,10 +87,6 @@ protected function generatePublic(array $value) $publicCallback = $this->callableCallbackFromArrayValue($value, 'public', '$typeName, $fieldName'); - if ('null' === $publicCallback) { - return $publicCallback; - } - $code = <<<'CODE' function ($fieldName) { $publicCallback = %s; @@ -119,54 +99,17 @@ function ($fieldName) { return $code; } - protected function generateResolve(array $value) + protected function generateAccess(array $value) { - $accessIsSet = $this->arrayKeyExistsAndIsNotNull($value, 'access'); - $fieldOptions = $value; - if (!$this->arrayKeyExistsAndIsNotNull($fieldOptions, 'resolve')) { - $fieldOptions['resolve'] = $this->defaultResolver; + if (!$this->arrayKeyExistsAndIsNotNull($value, 'access')) { + return 'null'; } - $resolveCallback = parent::generateResolve($fieldOptions); - $resolveCallback = ltrim($this->prefixCodeWithSpaces($resolveCallback)); - - if (!$accessIsSet || true === $fieldOptions['access']) { // access granted to this field - if ('null' === $resolveCallback) { - return $resolveCallback; - } - $argumentClass = $this->shortenClassName(Argument::class); - $resolveInfoClass = $this->shortenClassName(ResolveInfo::class); - - $code = <<<'CODE' -function ($value, $args, $context, %s $info) { -$resolverCallback = %s; -return call_user_func_array($resolverCallback, [$value, new %s($args), $context, $info]); -} -CODE; - - return sprintf($code, $resolveInfoClass, $resolveCallback, $argumentClass); - } elseif ($accessIsSet && false === $fieldOptions['access']) { // access deny to this field - $exceptionClass = $this->shortenClassName(UserWarning::class); - - return sprintf('function () { throw new %s(\'Access denied to this field.\'); }', $exceptionClass); - } else { // wrap resolver with access - $accessChecker = $this->callableCallbackFromArrayValue($fieldOptions, 'access', '$value, $args, $context, '.ResolveInfo::class.' $info, $object'); - $resolveInfoClass = $this->shortenClassName(ResolveInfo::class); - $argumentClass = $this->shortenClassName(Argument::class); - - $code = <<<'CODE' -function ($value, $args, $context, %s $info) { -$resolverCallback = %s; -$accessChecker = %s; -$isMutation = $info instanceof ResolveInfo && 'mutation' === $info->operation->operation && $info->parentType === $info->schema->getMutationType(); -return $container->get('overblog_graphql.access_resolver')->resolve($accessChecker, $resolverCallback, [$value, new %s($args), $context, $info], $isMutation); -} -CODE; - - $code = sprintf($code, $resolveInfoClass, $resolveCallback, $accessChecker, $argumentClass); - - return $code; + if (is_bool($value['access'])) { + return $this->varExport($value['access']); } + + return $this->callableCallbackFromArrayValue($value, 'access', '$value, $args, $context, \\GraphQL\\Type\\Definition\\ResolveInfo $info, $object'); } /** diff --git a/OverblogGraphQLBundle.php b/OverblogGraphQLBundle.php index 8dc6124bd..964d897c8 100644 --- a/OverblogGraphQLBundle.php +++ b/OverblogGraphQLBundle.php @@ -6,7 +6,9 @@ use Overblog\GraphQLBundle\DependencyInjection\Compiler\AutoMappingPass; use Overblog\GraphQLBundle\DependencyInjection\Compiler\AutowiringTypesPass; use Overblog\GraphQLBundle\DependencyInjection\Compiler\ConfigTypesPass; +use Overblog\GraphQLBundle\DependencyInjection\Compiler\DefinitionConfigProcessorPass; use Overblog\GraphQLBundle\DependencyInjection\Compiler\ExpressionFunctionPass; +use Overblog\GraphQLBundle\DependencyInjection\Compiler\GlobalVariablesPass; use Overblog\GraphQLBundle\DependencyInjection\Compiler\MutationTaggedServiceMappingTaggedPass; use Overblog\GraphQLBundle\DependencyInjection\Compiler\ResolverTaggedServiceMappingPass; use Overblog\GraphQLBundle\DependencyInjection\Compiler\TypeTaggedServiceMappingPass; @@ -27,12 +29,14 @@ public function build(ContainerBuilder $container) parent::build($container); //ConfigTypesPass and AutoMappingPass must be before TypeTaggedServiceMappingPass + $container->addCompilerPass(new GlobalVariablesPass()); $container->addCompilerPass(new ExpressionFunctionPass()); + $container->addCompilerPass(new DefinitionConfigProcessorPass()); $container->addCompilerPass(new AutoMappingPass()); - $container->addCompilerPass(new ConfigTypesPass(), PassConfig::TYPE_BEFORE_REMOVING); $container->addCompilerPass(new AliasedPass()); $container->addCompilerPass(new AutowiringTypesPass()); + $container->addCompilerPass(new ConfigTypesPass(), PassConfig::TYPE_BEFORE_REMOVING); $container->addCompilerPass(new TypeTaggedServiceMappingPass(), PassConfig::TYPE_BEFORE_REMOVING); $container->addCompilerPass(new ResolverTaggedServiceMappingPass(), PassConfig::TYPE_BEFORE_REMOVING); $container->addCompilerPass(new MutationTaggedServiceMappingTaggedPass(), PassConfig::TYPE_BEFORE_REMOVING); diff --git a/README.md b/README.md index 7e29b42f4..7eee33fcc 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ It also supports batching using libs like [ReactRelayNetworkLayer](https://githu Documentation ------------- +- [Quick start](Resources/doc/definitions/quick-start.md) - [Installation](Resources/doc/index.md) - [Definitions](Resources/doc/definitions/index.md) - [Type System](Resources/doc/definitions/type-system/index.md) @@ -27,6 +28,7 @@ Documentation - [Lists](Resources/doc/definitions/type-system/lists.md) - [Non-Null](Resources/doc/definitions/type-system/non-null.md) - [Type Inheritance](Resources/doc/definitions/type-inheritance.md) + - [GraphQL schema language](Resources/doc/definitions/graphql-schema-language.md) - [Schema](Resources/doc/definitions/schema.md) - [Resolver](Resources/doc/definitions/resolver.md) - [Solving N+1 problem](Resources/doc/definitions/solving-n-plus-1-problem.md) diff --git a/Resolver/AbstractResolver.php b/Resolver/AbstractResolver.php index f78c594a0..0f02652c2 100644 --- a/Resolver/AbstractResolver.php +++ b/Resolver/AbstractResolver.php @@ -2,7 +2,7 @@ namespace Overblog\GraphQLBundle\Resolver; -abstract class AbstractResolver implements ResolverInterface +abstract class AbstractResolver implements FluentResolverInterface { /** @var array */ private $solutions = []; diff --git a/Resolver/ResolverInterface.php b/Resolver/FluentResolverInterface.php similarity index 90% rename from Resolver/ResolverInterface.php rename to Resolver/FluentResolverInterface.php index 02c542b01..958576ecb 100644 --- a/Resolver/ResolverInterface.php +++ b/Resolver/FluentResolverInterface.php @@ -2,7 +2,7 @@ namespace Overblog\GraphQLBundle\Resolver; -interface ResolverInterface +interface FluentResolverInterface { public function resolve($input); diff --git a/Resolver/ResolverMap.php b/Resolver/ResolverMap.php new file mode 100644 index 000000000..a374cbf4f --- /dev/null +++ b/Resolver/ResolverMap.php @@ -0,0 +1,96 @@ +isMapLoaded) { + $this->checkMap($map = $this->map()); + $this->loadedMap = $map; + $this->isMapLoaded = true; + } + + return $this->loadedMap; + } + + /** + * {@inheritdoc} + */ + public function resolve($typeName, $fieldName) + { + $loadedMap = $this->getLoadedMap(); + + if (!$this->isResolvable($typeName, $fieldName)) { + throw new UnresolvableException(sprintf('Field "%s.%s" could not be resolved.', $typeName, $fieldName)); + } + + return $loadedMap[$typeName][$fieldName]; + } + + /** + * {@inheritdoc} + */ + public function isResolvable($typeName, $fieldName) + { + $key = $typeName.'.'.$fieldName; + if (!isset($this->memorized[$key])) { + $loadedMap = $this->getLoadedMap(); + $this->memorized[$key] = isset($loadedMap[$typeName]) && array_key_exists($fieldName, $loadedMap[$typeName]); + } + + return $this->memorized[$key]; + } + + /** + * {@inheritdoc} + */ + public function covered($typeName = null) + { + $loadedMap = $this->getLoadedMap(); + $covered = []; + $resolvers = []; + if (null === $typeName) { + $resolvers = $loadedMap; + } elseif (isset($loadedMap[$typeName])) { + $resolvers = $loadedMap[$typeName]; + } + + foreach ($resolvers as $key => $value) { + $covered[] = $key; + } + + return $covered; + } + + private function checkMap($map) + { + if (!is_array($map) && !($map instanceof \ArrayAccess && $map instanceof \Traversable)) { + throw new \RuntimeException(sprintf( + '%s::map() should return an array or an instance of \ArrayAccess and \Traversable but got "%s".', + get_class($this), + is_object($map) ? get_class($map) : gettype($map) + )); + } + } +} diff --git a/Resolver/ResolverMapInterface.php b/Resolver/ResolverMapInterface.php new file mode 100644 index 000000000..303fc967d --- /dev/null +++ b/Resolver/ResolverMapInterface.php @@ -0,0 +1,49 @@ +resolverMaps = $resolverMaps; + } + + /** + * {@inheritdoc} + */ + public function resolve($typeName, $fieldName) + { + foreach ($this->resolverMaps as $resolverMap) { + if ($resolverMap->isResolvable($typeName, $fieldName)) { + return $resolverMap->resolve($typeName, $fieldName); + } + } + throw new UnresolvableException(sprintf('Field "%s.%s" could not be resolved.', $typeName, $fieldName)); + } + + /** + * {@inheritdoc} + */ + public function isResolvable($typeName, $fieldName) + { + foreach ($this->resolverMaps as $resolverMap) { + if ($resolverMap->isResolvable($typeName, $fieldName)) { + return true; + } + } + + return false; + } + + /** + * {@inheritdoc} + */ + public function covered($typeName = null) + { + $covered = []; + foreach ($this->resolverMaps as $resolverMap) { + $covered = array_merge($covered, $resolverMap->covered($typeName)); + } + $covered = array_unique($covered); + + return $covered; + } + + private static function checkResolverMaps(array $resolverMaps) + { + foreach ($resolverMaps as $resolverMap) { + if (!$resolverMap instanceof ResolverMapInterface) { + throw new \InvalidArgumentException(sprintf( + 'ResolverMap should be instance of "%s" but got "%s".', + ResolverMapInterface::class, + is_object($resolverMap) ? get_class($resolverMap) : gettype($resolverMap) + )); + } + } + } +} diff --git a/Resolver/TypeResolver.php b/Resolver/TypeResolver.php index a53aba891..649808044 100644 --- a/Resolver/TypeResolver.php +++ b/Resolver/TypeResolver.php @@ -37,9 +37,6 @@ public function resolve($alias) return $item->get(); } - /** - * @param string $alias - */ private function string2Type($alias) { if (false !== ($type = $this->wrapTypeIfNeeded($alias))) { diff --git a/Resources/config/definition_config_processors.yml b/Resources/config/definition_config_processors.yml new file mode 100644 index 000000000..db7b0abee --- /dev/null +++ b/Resources/config/definition_config_processors.yml @@ -0,0 +1,15 @@ +services: + Overblog\GraphQLBundle\Definition\ConfigProcessor\PublicFieldsFilterConfigProcessor: + class: Overblog\GraphQLBundle\Definition\ConfigProcessor\PublicFieldsFilterConfigProcessor + public: false + tags: + - { name: overblog_graphql.definition_config_processor, priority: 2048 } + + Overblog\GraphQLBundle\Definition\ConfigProcessor\AclConfigProcessor: + class: Overblog\GraphQLBundle\Definition\ConfigProcessor\AclConfigProcessor + arguments: + - '@overblog_graphql.access_resolver' + - "%overblog_graphql.default_resolver%" + public: false + tags: + - { name: overblog_graphql.definition_config_processor } diff --git a/Resources/config/expression_language_functions.yml b/Resources/config/expression_language_functions.yml index aaf827941..335f98c7e 100644 --- a/Resources/config/expression_language_functions.yml +++ b/Resources/config/expression_language_functions.yml @@ -1,5 +1,11 @@ services: # Authorization + Overblog\GraphQLBundle\ExpressionLanguage\ExpressionFunction\Security\GetUser: + class: Overblog\GraphQLBundle\ExpressionLanguage\ExpressionFunction\Security\GetUser + public: false + tags: + - { name: overblog_graphql.expression_function } + Overblog\GraphQLBundle\ExpressionLanguage\ExpressionFunction\Security\HasAnyPermission: class: Overblog\GraphQLBundle\ExpressionLanguage\ExpressionFunction\Security\HasAnyPermission public: false diff --git a/Resources/config/services.yml b/Resources/config/services.yml index cec626894..a17535dac 100644 --- a/Resources/config/services.yml +++ b/Resources/config/services.yml @@ -33,13 +33,20 @@ services: public: false arguments: - "@overblog_graphql.type_resolver" + - '@Overblog\GraphQLBundle\Definition\Type\SchemaDecorator' - false + Overblog\GraphQLBundle\Definition\Type\SchemaDecorator: + class: Overblog\GraphQLBundle\Definition\Type\SchemaDecorator + public: false + overblog_graphql.type_resolver: class: Overblog\GraphQLBundle\Resolver\TypeResolver public: true arguments: - + tags: + - { name: overblog_graphql.global_variable, alias: typeResolver } Overblog\GraphQLBundle\Resolver\TypeResolver: alias: overblog_graphql.type_resolver @@ -47,6 +54,8 @@ services: overblog_graphql.resolver_resolver: class: Overblog\GraphQLBundle\Resolver\ResolverResolver public: true + tags: + - { name: overblog_graphql.global_variable, alias: resolverResolver, public: false } Overblog\GraphQLBundle\Resolver\ResolverResolver: alias: overblog_graphql.resolver_resolver @@ -54,6 +63,8 @@ services: overblog_graphql.mutation_resolver: class: Overblog\GraphQLBundle\Resolver\MutationResolver public: true + tags: + - { name: overblog_graphql.global_variable, alias: mutationResolver, public: false } Overblog\GraphQLBundle\Resolver\MutationResolver: alias: overblog_graphql.mutation_resolver @@ -72,8 +83,6 @@ services: public: false arguments: - '@overblog_graphql.cache_expression_language_parser' - calls: - - ["setContainer", ["@service_container"]] overblog_graphql.cache_compiler: class: Overblog\GraphQLBundle\Generator\TypeGenerator @@ -82,13 +91,14 @@ services: - "%overblog_graphql.class_namespace%" - ["%overblog_graphql.resources_dir%/skeleton"] - "%overblog_graphql.cache_dir%" - - "%overblog_graphql.default_resolver%" - "%overblog_graphql_types.config%" - "%overblog_graphql.use_classloader_listener%" calls: - - ["addUseStatement", ["Symfony\\Component\\DependencyInjection\\ContainerInterface"]] - - ["addUseStatement", ["Symfony\\Component\\Security\\Core\\Authentication\\Token\\TokenInterface"]] + - ['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"]] Overblog\GraphQLBundle\EventListener\RequestFilesListener: @@ -146,3 +156,13 @@ services: - "@overblog_graphql.cache_compiler" tags: - { name: console.command } + + Overblog\GraphQLBundle\Definition\ConfigProcessor: + class: Overblog\GraphQLBundle\Definition\ConfigProcessor + public: false + + Overblog\GraphQLBundle\Definition\GlobalVariables: + class: Overblog\GraphQLBundle\Definition\GlobalVariables + public: false + arguments: + - [] diff --git a/Resources/doc/definitions/expression-language.md b/Resources/doc/definitions/expression-language.md index 31ef93eaf..f1c918779 100644 --- a/Resources/doc/definitions/expression-language.md +++ b/Resources/doc/definitions/expression-language.md @@ -23,15 +23,14 @@ boolean **isFullyAuthenticated**() | Checks whether the token is fully authentic boolean **isAuthenticated**() | Checks whether the token is not anonymous. | @=isAuthenticated() | boolean **hasPermission**(mixed $var, string $permission) | Checks whether the token has the given permission for the given object (requires the ACL system). | @=hasPermission(object, 'OWNER') | boolean **hasAnyPermission**(mixed $var, array $permissions) | Checks whether the token has any of the given permissions for the given object | @=hasAnyPermission(object, ['OWNER', 'ADMIN']) | +User **getUser**() | Returns the user which is currently in the security token storage. User can be null. | @=getUser() | + **Variables description:** Expression | Description | Scope ---------- | ----------- | -------- -**container** | DI container | global -**request** | Refers to the current request. | Request -**token** | Refers to the token which is currently in the security token storage. Token can be null. | Token -**user** | Refers to the user which is currently in the security token storage. User can be null. | Valid Token +**typeResolver** | the type resolver | global **object** | Refers to the value of the field for which access is being requested. For array `object` will be each item of the array. For Relay connection `object` will be the node of each connection edges. | only available for `config.fields.*.access` with query operation or mutation payload type. **value** | Resolver value | only available in resolve context **args** | Resolver args array | only available in resolve context diff --git a/Resources/doc/definitions/graphql-schema-language.md b/Resources/doc/definitions/graphql-schema-language.md new file mode 100644 index 000000000..6d36c48ca --- /dev/null +++ b/Resources/doc/definitions/graphql-schema-language.md @@ -0,0 +1,46 @@ +GraphQL schema language +======================= + +This section we show how to define schema types using GraphQL schema language. +If you want to learn more about it, you can see +the [official documentation](http://graphql.org/learn/schema/) +or this [cheat sheet](https://github.com/sogko/graphql-shorthand-notation-cheat-sheet). + +#### Usage + +```graphql +# config/graphql/schema.types.graphql + +type Query { + bar: Foo! + baz(id: ID!): Baz +} + +scalar Baz + +interface Foo { + # Description of my is_foo field + is_foo: Boolean +} +type Bar implements Foo { + is_foo: Boolean + user: User! +} + +enum User { + TATA + TITI + TOTO +} +``` + +When using this shorthand syntax, you define your field resolvers (and some more configuration) separately +from the schema. Since the schema already describes all of the fields, arguments, and result types, the only +thing left is a collection of callable that are called to actually execute these fields. +This can be done using [resolver-map](resolver-map.md). + +**Notes:** +- This feature is experimental and could be improve or change in future releases +- Only type definition is allowed right now using this shorthand syntax +- The definition of schema root query or/and mutation should still be done in +[main configuration file](schema.md). diff --git a/Resources/doc/definitions/index.md b/Resources/doc/definitions/index.md index 2bb1a2a21..dbc9e3450 100644 --- a/Resources/doc/definitions/index.md +++ b/Resources/doc/definitions/index.md @@ -3,11 +3,13 @@ Definitions * [Type System](type-system/index.md) * [Type Inheritance](type-inheritance.md) +* [GraphQL schema language](graphql-schema-language.md) * [Schema](schema.md) Go further ---------- +* [Solving N+1 problem](solving-n-plus-1-problem.md) * [Resolver](resolver.md) * [Mutation](mutation.md) * [Relay](relay/index.md) diff --git a/Resources/doc/definitions/quick-start.md b/Resources/doc/definitions/quick-start.md new file mode 100644 index 000000000..85ec7e572 --- /dev/null +++ b/Resources/doc/definitions/quick-start.md @@ -0,0 +1,68 @@ +Quick start +=========== + +1. Install the bundle ([more details](../index.md)) + +```bash +composer require overblog/graphql-bundle +``` + +2. Configure the bundle to accept `graphql` format ([more details](graphql-schema-language.md)) + +```diff +# config/packages/graphql.yaml +overblog_graphql: + definitions: + schema: + query: Query + mappings: + auto_discover: false + types: + - +- type: yaml ++ type: graphql + dir: "%kernel.project_dir%/config/graphql/types" + suffix: ~ +``` + +3. Define schema using [GraphQL schema language](http://graphql.org/learn/schema/) +in files `config/graphql/types/*.graphql` + +4. Define schema Resolvers ([more details](resolver-map.md)) + +```yaml +# config/packages/graphql.yaml +overblog_graphql: + definitions: + schema: + # ... + resolver_maps: + - App\Resolver\MyResolverMap +``` + +```php + [ + self::RESOLVE_FIELD => function ($value, Argument $args, \ArrayObject $context, ResolveInfo $info) { + if ('baz' === $info->fieldName) { + $id = (int) $args['id']; + + return findBaz('baz', $id); + } + + return null; + }, + 'bar' => [Bar::class, 'getBar'], + ], + 'Foo' => [ + self::RESOLVE_TYPE => function ($value) { + return isset($value['user']) ? 'Bar' : null; + }, + ], + // enum internal values + 'User' => [ + 'TATA' => 1, + 'TITI' => 2, + 'TOTO' => 3, + ], + // custom scalar + 'Baz' => [ + self::SERIALIZE => function ($value) { + return sprintf('%s Formatted Baz', $value); + }, + self::PARSE_VALUE => function ($value) { + if (!is_string($value)) { + throw new Error(sprintf('Cannot represent following value as a valid Baz: %s.', Utils::printSafeJson($value))); + } + + return str_replace(' Formatted Baz', '', $value); + }, + self::PARSE_LITERAL => function ($valueNode) { + if (!$valueNode instanceof StringValueNode) { + throw new Error('Query error: Can only parse strings got: '.$valueNode->kind, [$valueNode]); + } + + return str_replace(' Formatted Baz', '', $valueNode->value); + }, + ], + ]; + } +} +``` + +Declare resolverMap to current schema + +```yaml +overblog_graphql: + definitions: + schema: + # ... + # resolver maps services IDs + resolver_maps: + - App\Resolver\MyResolverMap + +services: + App\Resolver\MyResolverMap: ~ +``` + +**Notes:** +- ResolverMap will override **all matching entries** when decorating types. +- ResolverMap does not supports `access` and `query complexity` right now. +- Many resolver map can be set for the same schema. + In this case the first resolverMap in list where `isResolvable` + returns `true` will be use. +- You don’t need to specify resolvers for every type in your schema. + If you don’t specify a resolver, GraphQL falls back to a default one, + which does the following. + +Credits +------- + +This feature was inspired by [Apollo GraphQL tools](https://www.apollographql.com/docs/graphql-tools/resolvers.html). + +Next step [solving N+1 problem](solving-n-plus-1-problem.md) diff --git a/Resources/doc/definitions/resolver.md b/Resources/doc/definitions/resolver.md index 1f4e1c9bf..e17c42835 100644 --- a/Resources/doc/definitions/resolver.md +++ b/Resources/doc/definitions/resolver.md @@ -100,7 +100,7 @@ Resolvers can be define 2 different ways `addition` mutation can be access by using `App\GraphQL\Mutation\CalcMutation::addition` or `add` alias. - You can also define custom dirs using the config: + You can also define custom dirs using the config (Symfony <3.3): ```yaml overblog_graphql: definitions: @@ -122,22 +122,27 @@ Resolvers can be define 2 different ways Here an example of how this can be done with DI `autoconfigure`: ```yaml - App\Mutation\: - resource: '../src/Mutation' - tags: ['overblog_graphql.mutation'] + services: + _instanceof: + GraphQL\Type\Definition\Type: + tags: ['overblog_graphql.type'] + + App\Mutation\: + resource: '../src/Mutation' + tags: ['overblog_graphql.mutation'] - App\Resolver\: - resource: '../src/Resolver' - tags: ['overblog_graphql.resolver'] + App\Resolver\: + resource: '../src/Resolver' + tags: ['overblog_graphql.resolver'] - App\Type\: - resource: '../src/Type' - tags: ['overblog_graphql.type'] + App\Type\: + resource: '../src/Type' ``` **Note:** * When using FQCN in yaml definition, backslash must be correctly quotes, here an example `'@=resolver("App\\GraphQL\\Resolver\\Greetings", [args['name']])'`. + * You can also see the more straight forward way using [resolver map](resolver-map.md) 2. **The service way** diff --git a/Resources/doc/definitions/type-system/index.md b/Resources/doc/definitions/type-system/index.md index 103266cbe..b03f7b7f4 100644 --- a/Resources/doc/definitions/type-system/index.md +++ b/Resources/doc/definitions/type-system/index.md @@ -28,9 +28,12 @@ Types can be define 3 different ways: # auto_discover: false # to disable bundles and root dir auto discover types: - - type: yaml # or xml or null + type: yaml # or xml or graphql null dir: "%kernel.root_dir%/.../mapping" # sub directories are also searched # suffix: .types # use to change default file suffix + - + types: [yaml, graphql] # to include different types from the same dir + dir: "%kernel.root_dir%/.../mapping" ``` 2. **The PHP way** diff --git a/Resources/skeleton/OutputFieldConfig.php.skeleton b/Resources/skeleton/OutputFieldConfig.php.skeleton index 7dec5111e..1dcee2bb8 100644 --- a/Resources/skeleton/OutputFieldConfig.php.skeleton +++ b/Resources/skeleton/OutputFieldConfig.php.skeleton @@ -5,5 +5,7 @@ 'description' => , 'deprecationReason' => , 'complexity' => , +# public and access are custom options managed only by the bundle 'public' => , +'access' => , ], diff --git a/Resources/skeleton/TypeSystem.php.skeleton b/Resources/skeleton/TypeSystem.php.skeleton index 9c12d23a2..e6c0f6f0e 100644 --- a/Resources/skeleton/TypeSystem.php.skeleton +++ b/Resources/skeleton/TypeSystem.php.skeleton @@ -5,36 +5,12 @@ class extends { -public function __construct(ContainerInterface $container) +public function __construct(ConfigProcessor $configProcessor, GlobalVariables $globalVariables = null) { -$request = null; -$token = null; -$user = null; -if ($container->has('request_stack')) { -$request = $container->get('request_stack')->getCurrentRequest(); -} -if ($container->has('security.token_storage')) { -$token = $container->get('security.token_storage')->getToken(); -if ($token instanceof TokenInterface) { -$user = $token->getUser(); -} -} - -parent::__construct(); -} - -private static function applyPublicFilters($fields) -{ -$filtered = []; -foreach ($fields as $fieldName => $field) { -$isPublic = isset($field['public']) ? $field['public'] : true; -if (is_callable($isPublic)) { -$isPublic = call_user_func($isPublic, $fieldName); -} -if ($isPublic) { -$filtered[$fieldName] = $field; -} -} -return $filtered; +$configLoader = function(GlobalVariables $globalVariable) { +return ; +}; +$config = $configProcessor->process(LazyConfig::create($configLoader, $globalVariables))->load(); +parent::__construct($config); } } diff --git a/Tests/Config/Parser/GraphQLParserTest.php b/Tests/Config/Parser/GraphQLParserTest.php new file mode 100644 index 000000000..ab7ee7454 --- /dev/null +++ b/Tests/Config/Parser/GraphQLParserTest.php @@ -0,0 +1,68 @@ +containerBuilder = $this->getMockBuilder(ContainerBuilder::class)->setMethods(['addResource'])->getMock(); + } + + public function testParse() + { + $fileName = __DIR__.'/fixtures/graphql/schema.graphql'; + $expected = include __DIR__.'/fixtures/graphql/schema.php'; + + $this->assertContainerAddFileToResources($fileName); + $config = GraphQLParser::parse(new \SplFileInfo($fileName), $this->containerBuilder); + $this->assertEquals($expected, $config); + } + + public function testParseEmptyFile() + { + $fileName = __DIR__.'/fixtures/graphql/empty.graphql'; + + $this->assertContainerAddFileToResources($fileName); + + $config = GraphQLParser::parse(new \SplFileInfo($fileName), $this->containerBuilder); + $this->assertEquals([], $config); + } + + public function testParseInvalidFile() + { + $fileName = __DIR__.'/fixtures/graphql/invalid.graphql'; + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf('An error occurred while parsing the file "%s"', $fileName)); + GraphQLParser::parse(new \SplFileInfo($fileName), $this->containerBuilder); + } + + public function testParseNotSupportedSchemaDefinition() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Schema definition is not supported right now.'); + GraphQLParser::parse(new \SplFileInfo(__DIR__.'/fixtures/graphql/not-supported-schema-definition.graphql'), $this->containerBuilder); + } + + public function testCustomScalarTypeDefaultFieldValue() + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Config entry must be override with ResolverMap to be used.'); + GraphQLParser::mustOverrideConfig(); + } + + private function assertContainerAddFileToResources($fileName) + { + $this->containerBuilder->expects($this->once()) + ->method('addResource') + ->with($fileName); + } +} diff --git a/Tests/Config/Parser/fixtures/graphql/empty.graphql b/Tests/Config/Parser/fixtures/graphql/empty.graphql new file mode 100644 index 000000000..e69de29bb diff --git a/Tests/Config/Parser/fixtures/graphql/invalid.graphql b/Tests/Config/Parser/fixtures/graphql/invalid.graphql new file mode 100644 index 000000000..c713dcb0c --- /dev/null +++ b/Tests/Config/Parser/fixtures/graphql/invalid.graphql @@ -0,0 +1 @@ +Query {} diff --git a/Tests/Config/Parser/fixtures/graphql/not-supported-schema-definition.graphql b/Tests/Config/Parser/fixtures/graphql/not-supported-schema-definition.graphql new file mode 100644 index 000000000..c3c82a744 --- /dev/null +++ b/Tests/Config/Parser/fixtures/graphql/not-supported-schema-definition.graphql @@ -0,0 +1,4 @@ +schema { + query: Query + mutation: Mutation +} diff --git a/Tests/Config/Parser/fixtures/graphql/schema.graphql b/Tests/Config/Parser/fixtures/graphql/schema.graphql new file mode 100644 index 000000000..3fd725f2d --- /dev/null +++ b/Tests/Config/Parser/fixtures/graphql/schema.graphql @@ -0,0 +1,54 @@ +# Root Query +type Query { + hero( + # Episode list to use to filter + episodes: [Episode!]! = [NEWHOPE, EMPIRE] + ): Character + # search for a droid + droid(id: ID!): Droid +} + +type Starship { + id: ID! + name: String! + length(unit: LengthUnit = METER): Float +} + +enum Episode { + NEWHOPE + EMPIRE + JEDI +} + +interface Character { + id: ID! + name: String! + friends: [Character] + appearsIn: [Episode]! +} + +type Human implements Character { + id: ID! + name: String! + friends: [Character] + appearsIn: [Episode]! + starships: [Starship] + totalCredits: Int +} + +type Droid implements Character { + id: ID! + name: String! + friends: [Character] + appearsIn: [Episode]! + primaryFunction: String +} + +union SearchResult = Human | Droid | Starship + +input ReviewInput { + stars: Int! = 5 + commentary: String = null +} + +scalar Year diff --git a/Tests/Config/Parser/fixtures/graphql/schema.php b/Tests/Config/Parser/fixtures/graphql/schema.php new file mode 100644 index 000000000..b9957c171 --- /dev/null +++ b/Tests/Config/Parser/fixtures/graphql/schema.php @@ -0,0 +1,120 @@ + [ + 'type' => 'object', + 'config' => [ + 'description' => 'Root Query', + 'fields' => [ + 'hero' => [ + 'type' => 'Character', + 'args' => [ + 'episodes' => [ + 'type' => '[Episode!]!', + 'description' => 'Episode list to use to filter', + 'defaultValue' => ['NEWHOPE', 'EMPIRE'], + ], + ], + ], + 'droid' => [ + 'type' => 'Droid', + 'description' => 'search for a droid', + 'args' => [ + 'id' => [ + 'type' => 'ID!', + ], + ], + ], + ], + ], + ], + 'Starship' => [ + 'type' => 'object', + 'config' => [ + 'fields' => [ + 'id' => ['type' => 'ID!'], + 'name' => ['type' => 'String!'], + 'length' => [ + 'type' => 'Float', + 'args' => [ + 'unit' => [ + 'type' => 'LengthUnit', + 'defaultValue' => 'METER', + ], + ], + ], + ], + ], + ], + 'Episode' => [ + 'type' => 'enum', + 'config' => [ + 'values' => [ + 'NEWHOPE' => ['value' => 'NEWHOPE'], + 'EMPIRE' => ['value' => 'EMPIRE'], + 'JEDI' => ['value' => 'JEDI'], + ], + ], + ], + 'Character' => [ + 'type' => 'interface', + 'config' => [ + 'fields' => [ + 'id' => ['type' => 'ID!'], + 'name' => ['type' => 'String!'], + 'friends' => ['type' => '[Character]'], + 'appearsIn' => ['type' => '[Episode]!'], + ], + ], + ], + 'Human' => [ + 'type' => 'object', + 'config' => [ + 'fields' => [ + 'id' => ['type' => 'ID!'], + 'name' => ['type' => 'String!'], + 'friends' => ['type' => '[Character]'], + 'appearsIn' => ['type' => '[Episode]!'], + 'starships' => ['type' => '[Starship]'], + 'totalCredits' => ['type' => 'Int'], + ], + 'interfaces' => ['Character'], + ], + ], + 'Droid' => [ + 'type' => 'object', + 'config' => [ + 'fields' => [ + 'id' => ['type' => 'ID!'], + 'name' => ['type' => 'String!'], + 'friends' => ['type' => '[Character]'], + 'appearsIn' => ['type' => '[Episode]!'], + 'primaryFunction' => ['type' => 'String'], + ], + 'interfaces' => ['Character'], + ], + ], + 'SearchResult' => [ + 'type' => 'union', + 'config' => [ + 'types' => ['Human', 'Droid', 'Starship'], + ], + ], + 'ReviewInput' => [ + 'type' => 'input-object', + 'config' => [ + 'fields' => [ + 'stars' => ['type' => 'Int!', 'defaultValue' => 5], + 'commentary' => ['type' => 'String', 'defaultValue' => null], + ], + ], + ], + 'Year' => [ + 'type' => 'custom-scalar', + 'config' => [ + 'serialize' => [\Overblog\GraphQLBundle\Config\Parser\GraphQLParser::class, 'mustOverrideConfig'], + 'parseValue' => [\Overblog\GraphQLBundle\Config\Parser\GraphQLParser::class, 'mustOverrideConfig'], + 'parseLiteral' => [\Overblog\GraphQLBundle\Config\Parser\GraphQLParser::class, 'mustOverrideConfig'], + ], + ], +]; diff --git a/Tests/DIContainerMockTrait.php b/Tests/DIContainerMockTrait.php index 29cd334db..89408daa1 100644 --- a/Tests/DIContainerMockTrait.php +++ b/Tests/DIContainerMockTrait.php @@ -21,12 +21,11 @@ protected function getDIContainerMock(array $services = [], array $parameters = ->getMock(); $getMethod = $container->expects($this->any())->method('get'); + $hasMethod = $container->expects($this->any())->method('has'); foreach ($services as $id => $service) { - $getMethod - ->with($id) - ->willReturn($service) - ; + $getMethod->with($id)->willReturn($service); + $hasMethod->with($id)->willReturn(true); } $getParameterMethod = $container->expects($this->any())->method('getParameter'); diff --git a/Tests/Definition/ConfigProcessor/NullConfigProcessor.php b/Tests/Definition/ConfigProcessor/NullConfigProcessor.php new file mode 100644 index 000000000..7035a473c --- /dev/null +++ b/Tests/Definition/ConfigProcessor/NullConfigProcessor.php @@ -0,0 +1,17 @@ +addConfigProcessor(new NullConfigProcessor()); + + $configProcessor->process(LazyConfig::create(function () { + return []; + })); + + $configProcessor->addConfigProcessor(new NullConfigProcessor()); + } + + public function testOrderByPriorityDesc() + { + $configProcessor = new ConfigProcessor(); + + $configProcessor->addConfigProcessor($nullConfigProcessor1 = new NullConfigProcessor(), 2); + $configProcessor->addConfigProcessor($nullConfigProcessor2 = new NullConfigProcessor(), 4); + $configProcessor->addConfigProcessor($nullConfigProcessor3 = new NullConfigProcessor(), 256); + $configProcessor->addConfigProcessor($nullConfigProcessor4 = new NullConfigProcessor()); + $configProcessor->addConfigProcessor($nullConfigProcessor5 = new NullConfigProcessor(), 512); + + $configProcessor->process(LazyConfig::create(function () { + return []; + })); + + $getOrderedProcessors = \Closure::bind( + function () { + return $this->orderedProcessors; + }, + $configProcessor, + get_class($configProcessor) + ); + + $processors = $getOrderedProcessors(); + + $this->assertSame( + $processors, + [$nullConfigProcessor5, $nullConfigProcessor3, $nullConfigProcessor2, $nullConfigProcessor1, $nullConfigProcessor4] + ); + } +} diff --git a/Tests/Definition/GlobalVariablesTest.php b/Tests/Definition/GlobalVariablesTest.php new file mode 100644 index 000000000..f64a6e604 --- /dev/null +++ b/Tests/Definition/GlobalVariablesTest.php @@ -0,0 +1,16 @@ +expectException(\LogicException::class); + $this->expectExceptionMessage('Global variable "unknown" could not be located. You should define it.'); + (new GlobalVariables())->get('unknown'); + } +} diff --git a/Tests/Definition/Type/SchemaDecoratorTest.php b/Tests/Definition/Type/SchemaDecoratorTest.php new file mode 100644 index 000000000..2fd791ac1 --- /dev/null +++ b/Tests/Definition/Type/SchemaDecoratorTest.php @@ -0,0 +1,321 @@ +config[$fieldName]; + }; + } + $expected = static function () { + }; + $realFieldName = substr($fieldName, 2); + + $this->decorate( + [$typeWithSpecialField->name => $typeWithSpecialField], + [$typeWithSpecialField->name => [$fieldName => $expected]] + ); + + $actual = $fieldValueRetriever($typeWithSpecialField, $realFieldName); + + if ($strict) { + $this->assertSame($expected, $actual); + } else { + $this->assertNotNull($actual); + $this->assertInstanceOf(\Closure::class, $actual); + } + } + + public function testObjectTypeFieldDecoration() + { + $objectType = new ObjectType([ + 'name' => 'Foo', + 'fields' => function () { + return [ + 'bar' => ['type' => Type::string()], + 'baz' => ['type' => Type::string()], + 'toto' => ['type' => Type::boolean(), 'resolve' => null], + ]; + }, + ]); + $barResolver = static function () { + return 'bar'; + }; + $bazResolver = static function () { + return 'baz'; + }; + + $this->decorate( + [$objectType->name => $objectType], + [$objectType->name => ['bar' => $barResolver, 'baz' => $bazResolver]] + ); + $fields = $objectType->config['fields'](); + + foreach (['bar', 'baz'] as $fieldName) { + $this->assertInstanceOf(\Closure::class, $fields[$fieldName]['resolve']); + $this->assertSame($fieldName, $fields[$fieldName]['resolve']()); + } + + $this->assertNull($fields['toto']['resolve']); + + return $objectType; + } + + public function testWrappedResolver() + { + $objectType = new ObjectType([ + 'name' => 'Foo', + 'fields' => function () { + return [ + 'bar' => ['type' => Type::string()], + ]; + }, + ]); + + $this->decorate( + [$objectType->name => $objectType], + [ + $objectType->name => [ + 'bar' => function ($value, $args) { + return $args; + }, + ], + ] + ); + $expected = ['foo' => 'baz']; + $resolveFn = $objectType->getField('bar')->resolveFn; + /** @var Argument $args */ + $args = $resolveFn(null, $expected); + $this->assertInstanceOf(Argument::class, $args); + $this->assertSame($expected, $args->getRawArguments()); + } + + public function testEnumTypeValuesDecoration() + { + $enumType = new EnumType([ + 'name' => 'Foo', + 'values' => [ + 'BAR' => ['name' => 'BAR', 'value' => 'BAR'], + 'BAZ' => ['name' => 'BAZ', 'value' => 'BAZ'], + 'TOTO' => ['name' => 'TOTO', 'value' => 'TOTO'], + ], + ]); + + $this->decorate( + [$enumType->name => $enumType], + [$enumType->name => ['BAR' => 1, 'BAZ' => 2]] + ); + + $this->assertSame( + [ + 'BAR' => ['name' => 'BAR', 'value' => 1], + 'BAZ' => ['name' => 'BAZ', 'value' => 2], + 'TOTO' => ['name' => 'TOTO', 'value' => 'TOTO'], + ], + $enumType->config['values'] + ); + } + + public function testEnumTypeUnknownField() + { + $enumType = new EnumType([ + 'name' => 'Foo', + 'values' => [ + 'BAR' => ['name' => 'BAR', 'value' => 'BAR'], + ], + ]); + $this->assertDecorateException( + [$enumType->name => $enumType], + [$enumType->name => ['BAZ' => 1]], + \InvalidArgumentException::class, + '"Foo".{"BAZ"} defined in resolverMap, was defined in resolvers, but enum is not in schema.' + ); + } + + public function testUnionTypeUnknownField() + { + $unionType = new UnionType(['name' => 'Foo']); + $this->assertDecorateException( + [$unionType->name => $unionType], + [ + $unionType->name => [ + 'baz' => function () { + }, + ], + ], + \InvalidArgumentException::class, + '"Foo".{"baz"} defined in resolverMap, but only "__resolveType" is allowed.' + ); + } + + public function testInterfaceTypeUnknownField() + { + $interfaceType = new InterfaceType(['name' => 'Foo']); + $this->assertDecorateException( + [$interfaceType->name => $interfaceType], + [ + $interfaceType->name => [ + 'baz' => function () { + }, + ], + ], + \InvalidArgumentException::class, + '"Foo".{"baz"} defined in resolverMap, but only "__resolveType" is allowed.' + ); + } + + public function testCustomScalarTypeUnknownField() + { + $customScalarType = new CustomScalarType(['name' => 'Foo']); + $this->assertDecorateException( + [$customScalarType->name => $customScalarType], + [ + $customScalarType->name => [ + 'baz' => function () { + }, + ], + ], + \InvalidArgumentException::class, + '"Foo".{"baz"} defined in resolverMap, but only "__serialize", "__parseValue", "__parseLiteral" is allowed.' + ); + } + + public function testObjectTypeUnknownField() + { + $objectType = new ObjectType([ + 'name' => 'Foo', + 'fields' => [ + 'bar' => ['type' => Type::string()], + ], + ]); + $this->assertDecorateException( + [$objectType->name => $objectType], + [ + $objectType->name => [ + 'baz' => function () { + }, + ], + ], + \InvalidArgumentException::class, + '"Foo".{"baz"} defined in resolverMap, but not in schema.' + ); + } + + public function testUnSupportedTypeDefineInResolverMapShouldThrowAnException() + { + $this->assertDecorateException( + ['myType' => new InputObjectType(['name' => 'myType'])], + [ + 'myType' => [ + 'foo' => null, + 'bar' => null, + ], + ], + \InvalidArgumentException::class, + '"myType".{"foo", "bar"} defined in resolverMap, but type is not managed by SchemaDecorator.' + ); + } + + public function specialTypeFieldProvider() + { + $objectWithResolveField = new ObjectType(['name' => 'Bar', 'fields' => [], 'resolveField' => null]); + + return [ + // isTypeOf + [ResolverMapInterface::IS_TYPEOF, new ObjectType(['name' => 'Foo', 'fields' => [], 'isTypeOf' => null])], + // resolveField + [ + ResolverMapInterface::RESOLVE_FIELD, + $objectWithResolveField, + function (ObjectType $type) { + return $type->resolveFieldFn; + }, + false, + ], + [ResolverMapInterface::RESOLVE_FIELD, $objectWithResolveField, null, false], + // resolveType + [ResolverMapInterface::RESOLVE_TYPE, new UnionType(['name' => 'Baz', 'resolveType' => null])], + [ResolverMapInterface::RESOLVE_TYPE, new InterfaceType(['name' => 'Baz', 'resolveType' => null])], + // custom scalar + [ResolverMapInterface::SERIALIZE, new CustomScalarType(['name' => 'Custom', 'serialize' => null])], + [ResolverMapInterface::PARSE_VALUE, new CustomScalarType(['name' => 'Custom', 'parseValue' => null])], + [ResolverMapInterface::PARSE_LITERAL, new CustomScalarType(['name' => 'Custom', 'parseLiteral' => null])], + ]; + } + + private function assertDecorateException(array $types, array $map, $exception = null, $exceptionMessage = null) + { + if ($exception) { + $this->expectException($exception); + } + if ($exceptionMessage) { + $this->expectExceptionMessage($exceptionMessage); + } + + $this->decorate($types, $map); + } + + private function decorate(array $types, array $map) + { + (new SchemaDecorator())->decorate($this->createSchemaMock($types), $this->createResolverMapMock($map)); + } + + /** + * @param array $types + * + * @return \PHPUnit\Framework\MockObject\MockObject|Schema + */ + private function createSchemaMock(array $types = []) + { + $schema = $this->getMockBuilder(Schema::class)->disableOriginalConstructor()->setMethods(['getType'])->getMock(); + + $schema->expects($this->any())->method('getType')->willReturnCallback(function ($name) use ($types) { + if (!isset($types[$name])) { + throw new \Exception(sprintf('Type "%s" not found.', $name)); + } + + return $types[$name]; + }); + + return $schema; + } + + /** + * @param array $map + * + * @return \PHPUnit\Framework\MockObject\MockObject|ResolverMap + */ + private function createResolverMapMock(array $map = []) + { + $resolverMap = $this->getMockBuilder(ResolverMap::class)->setMethods(['map'])->getMock(); + $resolverMap->expects($this->any())->method('map')->willReturn($map); + + return $resolverMap; + } +} diff --git a/Tests/DependencyInjection/Compiler/GlobalVariablesPassTest.php b/Tests/DependencyInjection/Compiler/GlobalVariablesPassTest.php new file mode 100644 index 000000000..1311afb59 --- /dev/null +++ b/Tests/DependencyInjection/Compiler/GlobalVariablesPassTest.php @@ -0,0 +1,48 @@ +getMockBuilder(ContainerBuilder::class) + ->setMethods(['findTaggedServiceIds', 'findDefinition']) + ->getMock(); + $container->expects($this->once()) + ->method('findTaggedServiceIds') + ->willReturn([ + 'my-id' => [ + ['alias' => $invalidAlias], + ], + ]); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Service "my-id" tagged "overblog_graphql.global_variable" should have a valid "alias" attribute.'); + + (new GlobalVariablesPass())->process($container); + } + + public function invalidAliasProvider() + { + return [ + [null], + [new \stdClass()], + [[]], + [true], + [false], + [''], + ]; + } +} diff --git a/Tests/DependencyInjection/OverblogGraphQLTypesExtensionTest.php b/Tests/DependencyInjection/OverblogGraphQLTypesExtensionTest.php index 59d260168..f1ea2583e 100644 --- a/Tests/DependencyInjection/OverblogGraphQLTypesExtensionTest.php +++ b/Tests/DependencyInjection/OverblogGraphQLTypesExtensionTest.php @@ -256,7 +256,7 @@ private function getMappingConfig($type) 'mappings' => [ 'types' => [ [ - 'type' => $type, + 'types' => [$type], 'dir' => __DIR__.'/mapping/'.$type, ], ], diff --git a/Tests/ExpressionLanguage/ExpressionFunction/DependencyInjection/ParameterTest.php b/Tests/ExpressionLanguage/ExpressionFunction/DependencyInjection/ParameterTest.php index fa8e7fa9d..6cc94c8e4 100644 --- a/Tests/ExpressionLanguage/ExpressionFunction/DependencyInjection/ParameterTest.php +++ b/Tests/ExpressionLanguage/ExpressionFunction/DependencyInjection/ParameterTest.php @@ -2,6 +2,7 @@ namespace Overblog\GraphQLBundle\Tests\ExpressionLanguage\ExpressionFunction\DependencyInjection; +use Overblog\GraphQLBundle\Definition\GlobalVariables; use Overblog\GraphQLBundle\ExpressionLanguage\ExpressionFunction\DependencyInjection\Parameter; use Overblog\GraphQLBundle\Tests\ExpressionLanguage\TestCase; @@ -18,9 +19,9 @@ protected function getFunctions() */ public function testParameter($name) { - $container = $this->getDIContainerMock([], ['test' => 5]); - $this->expressionLanguage->setContainer($container); - $this->assertEquals(5, eval('return '.$this->expressionLanguage->compile($name.'("test")').';')); + $globalVariable = new GlobalVariables(['container' => $this->getDIContainerMock([], ['test' => 5])]); + $globalVariable->get('container'); + $this->assertSame(5, eval('return '.$this->expressionLanguage->compile($name.'("test")').';')); } public function getNames() diff --git a/Tests/ExpressionLanguage/ExpressionFunction/DependencyInjection/ServiceTest.php b/Tests/ExpressionLanguage/ExpressionFunction/DependencyInjection/ServiceTest.php index 36a6e9e90..20af961a9 100644 --- a/Tests/ExpressionLanguage/ExpressionFunction/DependencyInjection/ServiceTest.php +++ b/Tests/ExpressionLanguage/ExpressionFunction/DependencyInjection/ServiceTest.php @@ -2,6 +2,7 @@ namespace Overblog\GraphQLBundle\Tests\ExpressionLanguage\ExpressionFunction\DependencyInjection; +use Overblog\GraphQLBundle\Definition\GlobalVariables; use Overblog\GraphQLBundle\ExpressionLanguage\ExpressionFunction\DependencyInjection\Service; use Overblog\GraphQLBundle\Tests\ExpressionLanguage\TestCase; @@ -19,9 +20,9 @@ protected function getFunctions() public function testService($name) { $object = new \stdClass(); - $container = $this->getDIContainerMock(['toto' => $object]); - $this->expressionLanguage->setContainer($container); - $this->assertEquals($object, eval('return '.$this->expressionLanguage->compile($name.'("toto")').';')); + $globalVariable = new GlobalVariables(['container' => $this->getDIContainerMock(['toto' => $object])]); + $globalVariable->get('container'); + $this->assertSame($object, eval('return '.$this->expressionLanguage->compile($name.'("toto")').';')); } public function getNames() diff --git a/Tests/ExpressionLanguage/ExpressionFunction/Security/GetUserTest.php b/Tests/ExpressionLanguage/ExpressionFunction/Security/GetUserTest.php new file mode 100644 index 000000000..8dbc40fe7 --- /dev/null +++ b/Tests/ExpressionLanguage/ExpressionFunction/Security/GetUserTest.php @@ -0,0 +1,82 @@ + $this->getDIContainerMock()]); + $globalVariable->has('container'); + $this->assertNull(eval($this->getCompileCode())); + } + + public function testGetUserNoToken() + { + $tokenStorage = $this->getMockBuilder(TokenStorageInterface::class)->getMock(); + $globalVariable = new GlobalVariables(['container' => $this->getDIContainerMock(['security.token_storage' => $tokenStorage])]); + $globalVariable->get('container'); + + $this->getDIContainerMock(['security.token_storage' => $tokenStorage]); + $this->assertNull(eval($this->getCompileCode())); + } + + /** + * @dataProvider getUserProvider + * + * @param $user + * @param $expectedUser + */ + public function testGetUser($user, $expectedUser) + { + $tokenStorage = $this->getMockBuilder(TokenStorageInterface::class)->getMock(); + $token = $this->getMockBuilder(TokenInterface::class)->getMock(); + $globalVariable = new GlobalVariables(['container' => $this->getDIContainerMock(['security.token_storage' => $tokenStorage])]); + $globalVariable->get('container'); + + $token + ->expects($this->once()) + ->method('getUser') + ->will($this->returnValue($user)); + $tokenStorage + ->expects($this->once()) + ->method('getToken') + ->will($this->returnValue($token)); + + $this->assertSame($expectedUser, eval($this->getCompileCode())); + } + + public function getUserProvider() + { + $user = $this->getMockBuilder(UserInterface::class)->getMock(); + $std = new \stdClass(); + $token = $this->getMockBuilder(TokenInterface::class)->getMock(); + + return [ + [$user, $user], + [$std, $std], + [$token, $token], + ['Anon.', null], + [null, null], + [10, null], + [true, null], + ]; + } + + private function getCompileCode() + { + return 'return '.$this->expressionLanguage->compile('getUser()').';'; + } +} diff --git a/Tests/ExpressionLanguage/TestCase.php b/Tests/ExpressionLanguage/TestCase.php index cca2c84c6..24b691e7e 100644 --- a/Tests/ExpressionLanguage/TestCase.php +++ b/Tests/ExpressionLanguage/TestCase.php @@ -2,6 +2,7 @@ namespace Overblog\GraphQLBundle\Tests\ExpressionLanguage; +use Overblog\GraphQLBundle\Definition\GlobalVariables; use Overblog\GraphQLBundle\ExpressionLanguage\ExpressionLanguage; use Overblog\GraphQLBundle\Tests\DIContainerMockTrait; use PHPUnit\Framework\TestCase as BaseTestCase; @@ -18,8 +19,6 @@ abstract class TestCase extends BaseTestCase public function setUp() { $this->expressionLanguage = new ExpressionLanguage(); - $container = $this->getDIContainerMock(); - $this->expressionLanguage->setContainer($container); foreach ($this->getFunctions() as $function) { $this->expressionLanguage->addFunction($function); } @@ -30,12 +29,16 @@ public function setUp() */ abstract protected function getFunctions(); - protected function assertExpressionCompile($expression, $with, array $expressionValues = [], $expects = null, $return = true, $assertMethod = 'assertTrue') + protected function assertExpressionCompile($expression, $with, array $vars = [], $expects = null, $return = true, $assertMethod = 'assertTrue') { - $expressionValues['container'] = $this->getDIContainerMock(['security.authorization_checker' => $this->getAuthorizationCheckerIsGrantedWithExpectation($with, $expects, $return)]); - extract($expressionValues); - - $code = $this->expressionLanguage->compile($expression, array_keys($expressionValues)); + $code = $this->expressionLanguage->compile($expression, array_keys($vars)); + $globalVariable = new GlobalVariables([ + 'container' => $this->getDIContainerMock( + ['security.authorization_checker' => $this->getAuthorizationCheckerIsGrantedWithExpectation($with, $expects, $return)] + ), + ]); + $globalVariable->get('container'); + extract($vars); $this->$assertMethod(eval('return '.$code.';')); } diff --git a/Tests/Functional/App/Resolver/Characters.php b/Tests/Functional/App/Resolver/Characters.php new file mode 100644 index 000000000..4df35291b --- /dev/null +++ b/Tests/Functional/App/Resolver/Characters.php @@ -0,0 +1,128 @@ + [ + 'id' => 1, + 'name' => 'Jon Snow', + 'direwolf' => 7, + 'status' => 1, + 'type' => self::TYPE_HUMAN, + 'dateOfBirth' => 281, + ], + 2 => [ + 'id' => 2, + 'name' => 'Arya', + 'direwolf' => 8, + 'status' => 1, + 'type' => self::TYPE_HUMAN, + 'dateOfBirth' => 287, + ], + 3 => [ + 'id' => 3, + 'name' => 'Bran', + 'direwolf' => 9, + 'status' => 1, + 'type' => self::TYPE_HUMAN, + 'dateOfBirth' => 288, + ], + 4 => [ + 'id' => 4, + 'name' => 'Rickon', + 'direwolf' => 10, + 'status' => 0, + 'type' => self::TYPE_HUMAN, + 'dateOfBirth' => 292, + ], + 5 => [ + 'id' => 5, + 'name' => 'Robb', + 'direwolf' => 11, + 'status' => 0, + 'type' => self::TYPE_HUMAN, + 'dateOfBirth' => 281, + ], + 6 => [ + 'id' => 6, + 'name' => 'Sansa', + 'direwolf' => 12, + 'status' => 1, + 'type' => self::TYPE_HUMAN, + 'dateOfBirth' => 285, + ], + 7 => [ + 'id' => 7, + 'name' => 'Ghost', + 'status' => 1, + 'type' => self::TYPE_DIREWOLF, + ], + 8 => [ + 'id' => 8, + 'name' => 'Nymeria', + 'status' => 1, + 'type' => self::TYPE_DIREWOLF, + ], + 9 => [ + 'id' => 9, + 'name' => 'Summer', + 'status' => 0, + 'type' => self::TYPE_DIREWOLF, + ], + 10 => [ + 'id' => 10, + 'name' => 'Shaggydog', + 'status' => 0, + 'type' => self::TYPE_DIREWOLF, + ], + 11 => [ + 'id' => 11, + 'name' => 'Grey Wind', + 'status' => 0, + 'type' => self::TYPE_DIREWOLF, + ], + 12 => [ + 'id' => 12, + 'name' => 'Lady', + 'status' => 0, + 'type' => self::TYPE_DIREWOLF, + ], + ]; + + public static function getCharacters() + { + return self::$characters; + } + + public static function getHumans() + { + $humans = self::findByType(self::TYPE_HUMAN); + + return $humans; + } + + public static function getDirewolves() + { + return self::findByType(self::TYPE_DIREWOLF); + } + + public static function resurrectZigZag() + { + $zigZag = self::$characters[4]; + $zigZag['status'] = 1; + + return $zigZag; + } + + private static function findByType($type) + { + return array_filter(self::$characters, function ($character) use ($type) { + return $type === $character['type']; + }); + } +} diff --git a/Tests/Functional/App/Resolver/SchemaLanguageMutationResolverMap.php b/Tests/Functional/App/Resolver/SchemaLanguageMutationResolverMap.php new file mode 100644 index 000000000..ad5985a99 --- /dev/null +++ b/Tests/Functional/App/Resolver/SchemaLanguageMutationResolverMap.php @@ -0,0 +1,17 @@ + [ + 'resurrectZigZag' => [Characters::class, 'resurrectZigZag'], + ], + ]; + } +} diff --git a/Tests/Functional/App/Resolver/SchemaLanguageQueryResolverMap.php b/Tests/Functional/App/Resolver/SchemaLanguageQueryResolverMap.php new file mode 100644 index 000000000..6378c696c --- /dev/null +++ b/Tests/Functional/App/Resolver/SchemaLanguageQueryResolverMap.php @@ -0,0 +1,81 @@ + [ + self::RESOLVE_FIELD => function ($value, Argument $args, \ArrayObject $context, ResolveInfo $info) { + if ('character' === $info->fieldName) { + $characters = Characters::getCharacters(); + $id = (int) $args['id']; + if (isset($characters[$id])) { + return $characters[$id]; + } + } + + return null; + }, + 'findHumansByDateOfBirth' => function ($value, Argument $args) { + $years = $args['years']; + + return array_filter(Characters::getHumans(), function ($human) use ($years) { + return in_array($human['dateOfBirth'], $years); + }); + }, + 'humans' => [Characters::class, 'getHumans'], + 'direwolves' => [Characters::class, 'getDirewolves'], + ], + 'Character' => [ + self::RESOLVE_TYPE => function ($value) { + return Characters::TYPE_HUMAN === $value['type'] ? 'Human' : 'Direwolf'; + }, + ], + 'Human' => [ + 'direwolf' => function ($value) { + $direwolves = Characters::getDirewolves(); + if (isset($direwolves[$value['direwolf']])) { + return $direwolves[$value['direwolf']]; + } else { + return null; + } + }, + ], + // enum internal values + 'Status' => [ + 'ALIVE' => 1, + 'DECEASED' => 0, + ], + // custom scalar + 'Year' => [ + self::SERIALIZE => function ($value) { + return sprintf('%s AC', $value); + }, + self::PARSE_VALUE => function ($value) { + if (!is_string($value)) { + throw new Error(sprintf('Cannot represent following value as a valid year: %s.', Utils::printSafeJson($value))); + } + + return (int) str_replace(' AC', '', $value); + }, + self::PARSE_LITERAL => function ($valueNode) { + if (!$valueNode instanceof StringValueNode) { + throw new Error('Query error: Can only parse strings got: '.$valueNode->kind, [$valueNode]); + } + + return (int) str_replace(' AC', '', $valueNode->value); + }, + ], + ]; + } +} diff --git a/Tests/Functional/App/config/schemaLanguage/config.yml b/Tests/Functional/App/config/schemaLanguage/config.yml new file mode 100644 index 000000000..33ebecee9 --- /dev/null +++ b/Tests/Functional/App/config/schemaLanguage/config.yml @@ -0,0 +1,26 @@ +imports: + - { resource: ../config.yml } + +overblog_graphql: + definitions: + class_namespace: "Overblog\\GraphQLBundle\\SchemaLanguage\\__DEFINITIONS__" + schema: + query: Query + mutation: Mutation + resolver_maps: + - Overblog\GraphQLBundle\Tests\Functional\App\Resolver\SchemaLanguageQueryResolverMap + - Overblog\GraphQLBundle\Tests\Functional\App\Resolver\SchemaLanguageMutationResolverMap + mappings: + types: + - + type: graphql + dir: "%kernel.root_dir%/config/schemaLanguage/mapping" + suffix: ~ + +services: + Overblog\GraphQLBundle\Tests\Functional\App\Resolver\SchemaLanguageQueryResolverMap: + class: Overblog\GraphQLBundle\Tests\Functional\App\Resolver\SchemaLanguageQueryResolverMap + public: false + Overblog\GraphQLBundle\Tests\Functional\App\Resolver\SchemaLanguageMutationResolverMap: + class: Overblog\GraphQLBundle\Tests\Functional\App\Resolver\SchemaLanguageMutationResolverMap + public: false diff --git a/Tests/Functional/App/config/schemaLanguage/mapping/mutation.graphql b/Tests/Functional/App/config/schemaLanguage/mapping/mutation.graphql new file mode 100644 index 000000000..a3d48a150 --- /dev/null +++ b/Tests/Functional/App/config/schemaLanguage/mapping/mutation.graphql @@ -0,0 +1,3 @@ +type Mutation { + resurrectZigZag: Human! +} diff --git a/Tests/Functional/App/config/schemaLanguage/mapping/query.graphqls b/Tests/Functional/App/config/schemaLanguage/mapping/query.graphqls new file mode 100644 index 000000000..9df351fa1 --- /dev/null +++ b/Tests/Functional/App/config/schemaLanguage/mapping/query.graphqls @@ -0,0 +1,33 @@ +type Query { + character(id: ID!): Character + findHumansByDateOfBirth(years: [Year!]!): [Human!]! + humans: [Human!]! + direwolves: [Direwolf!]! +} + +scalar Year + +interface Character { + id: ID! + name: String! + status: Status! +} + +type Human implements Character { + id: ID! + name: String! + direwolf: Direwolf! + status: Status! + dateOfBirth: Year! +} + +type Direwolf implements Character { + id: ID! + name: String! + status: Status! +} + +enum Status { + ALIVE + DECEASED +} diff --git a/Tests/Functional/SchemaLanguage/SchemaLanguageTest.php b/Tests/Functional/SchemaLanguage/SchemaLanguageTest.php new file mode 100644 index 000000000..c00500327 --- /dev/null +++ b/Tests/Functional/SchemaLanguage/SchemaLanguageTest.php @@ -0,0 +1,177 @@ + [ + 'humans' => [ + [ + 'id' => 1, + 'name' => 'Jon Snow', + 'direwolf' => ['id' => 7, 'name' => 'Ghost'], + ], + [ + 'id' => 2, + 'name' => 'Arya', + 'direwolf' => ['id' => 8, 'name' => 'Nymeria'], + ], + [ + 'id' => 3, + 'name' => 'Bran', + 'direwolf' => ['id' => 9, 'name' => 'Summer'], + ], + [ + 'id' => 4, + 'name' => 'Rickon', + 'direwolf' => ['id' => 10, 'name' => 'Shaggydog'], + ], + [ + 'id' => 5, + 'name' => 'Robb', + 'direwolf' => ['id' => 11, 'name' => 'Grey Wind'], + ], + [ + 'id' => 6, + 'name' => 'Sansa', + 'direwolf' => ['id' => 12, 'name' => 'Lady'], + ], + ], + ], + ]; + + $this->assertResponse($query, $expected, static::ANONYMOUS_USER, 'schemaLanguage'); + } + + public function testQueryDirewolves() + { + $query = <<<'QUERY' +{ direwolves {name status} } +QUERY; + + $expected = [ + 'data' => [ + 'direwolves' => [ + ['name' => 'Ghost', 'status' => 'ALIVE'], + ['name' => 'Nymeria', 'status' => 'ALIVE'], + ['name' => 'Summer', 'status' => 'DECEASED'], + ['name' => 'Shaggydog', 'status' => 'DECEASED'], + ['name' => 'Grey Wind', 'status' => 'DECEASED'], + ['name' => 'Lady', 'status' => 'DECEASED'], + ], + ], + ]; + + $this->assertResponse($query, $expected, static::ANONYMOUS_USER, 'schemaLanguage'); + } + + public function testQueryACharacter() + { + $query = <<<'QUERY' +{ + character(id: 1) { + name + ...on Human { + dateOfBirth + } + } +} +QUERY; + + $expected = [ + 'data' => [ + 'character' => [ + 'name' => 'Jon Snow', + 'dateOfBirth' => '281 AC', + ], + ], + ]; + + $this->assertResponse($query, $expected, static::ANONYMOUS_USER, 'schemaLanguage'); + } + + public function testQueryHumanByDateOfBirth() + { + $query = <<<'QUERY' +{ + findHumansByDateOfBirth(years: ["281 AC", "288 AC"]) { + name + dateOfBirth + } +} +QUERY; + + $expected = [ + 'data' => [ + 'findHumansByDateOfBirth' => [ + [ + 'name' => 'Jon Snow', + 'dateOfBirth' => '281 AC', + ], + [ + 'name' => 'Bran', + 'dateOfBirth' => '288 AC', + ], + [ + 'name' => 'Robb', + 'dateOfBirth' => '281 AC', + ], + ], + ], + ]; + + $this->assertResponse($query, $expected, static::ANONYMOUS_USER, 'schemaLanguage'); + } + + public function testQueryHumanByDateOfBirthUsingVariables() + { + $query = <<<'QUERY' +query ($years: [Year!]!) { + findHumansByDateOfBirth(years: $years) { + name + dateOfBirth + } +} +QUERY; + + $expected = [ + 'data' => [ + 'findHumansByDateOfBirth' => [ + [ + 'name' => 'Bran', + 'dateOfBirth' => '288 AC', + ], + ], + ], + ]; + + $this->assertResponse($query, $expected, static::ANONYMOUS_USER, 'schemaLanguage', null, ['years' => ['288 AC']]); + } + + public function testMutation() + { + $query = <<<'QUERY' +mutation { resurrectZigZag {name status} } +QUERY; + + $expected = [ + 'data' => [ + 'resurrectZigZag' => [ + 'name' => 'Rickon', + 'status' => 'ALIVE', + ], + ], + ]; + + $this->assertResponse($query, $expected, static::ANONYMOUS_USER, 'schemaLanguage'); + } +} diff --git a/Tests/Functional/TestCase.php b/Tests/Functional/TestCase.php index 183f95034..09ca54e80 100644 --- a/Tests/Functional/TestCase.php +++ b/Tests/Functional/TestCase.php @@ -115,19 +115,19 @@ protected static function createClientAuthenticated($username, $testCase, $passw return $client; } - protected static function assertResponse($query, array $expected, $username, $testCase, $password = self::DEFAULT_PASSWORD) + protected static function assertResponse($query, array $expected, $username, $testCase, $password = self::DEFAULT_PASSWORD, array $variables = null) { $client = self::createClientAuthenticated($username, $testCase, $password); - $result = self::sendRequest($client, $query); + $result = self::sendRequest($client, $query, false, $variables); static::assertEquals($expected, json_decode($result, true), $result); return $client; } - protected static function sendRequest(Client $client, $query, $isDecoded = false) + protected static function sendRequest(Client $client, $query, $isDecoded = false, array $variables = null) { - $client->request('GET', '/', ['query' => $query]); + $client->request('GET', '/', ['query' => $query, 'variables' => json_encode($variables)]); $result = $client->getResponse()->getContent(); return $isDecoded ? json_decode($result, true) : $result; diff --git a/Tests/Resolver/ResolverMapTest.php b/Tests/Resolver/ResolverMapTest.php new file mode 100644 index 000000000..7ed0de874 --- /dev/null +++ b/Tests/Resolver/ResolverMapTest.php @@ -0,0 +1,133 @@ +createResolverMapMock($map); + $resolver = $resolverMap->resolve($typeName, $fieldName); + $this->assertSame($expectedResolver, $resolver); + } + + public function testMapMustBeOverride() + { + /** @var ResolverMap|\PHPUnit_Framework_MockObject_MockObject $resolverMap */ + $resolverMap = $this->getMockBuilder(ResolverMap::class)->setMethods(null)->getMock(); + $this->expectException(\LogicException::class); + $this->expectExceptionMessage(sprintf( + 'You must override the %s::map() method.', + get_class($resolverMap) + )); + + $resolverMap->resolve('Foo', 'bar'); + } + + /** + * @dataProvider invalidMapDataProvider + * + * @param mixed $invalidMap + * @param string $invalidType + */ + public function testInvalidMap($invalidMap, $invalidType) + { + $resolverMap = $this->createResolverMapMock($invalidMap); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage(sprintf( + '%s::map() should return an array or an instance of \ArrayAccess and \Traversable but got "%s".', + get_class($resolverMap), + $invalidType + )); + $resolverMap->resolve('Foo', 'bar'); + } + + public function testUnresolvable() + { + $resolverMap = $this->createResolverMapMock([ + 'Query' => [ + ResolverMap::RESOLVE_FIELD => function () { + }, + ], + ]); + $this->expectException(UnresolvableException::class); + $this->expectExceptionMessage('Field "Foo.bar" could not be resolved.'); + $resolverMap->resolve('Foo', 'bar'); + } + + public function invalidMapDataProvider() + { + return [ + [null, 'NULL'], + [false, 'boolean'], + [true, 'boolean'], + ['baz', 'string'], + [new \stdClass(), 'stdClass'], + ]; + } + + public function validMapDataProvider() + { + $arrayMap = $this->map(); + $objectMap = new \ArrayObject($arrayMap); + + $validMap = []; + + foreach ([$arrayMap, $objectMap] as $map) { + $validMap = array_merge($validMap, [ + [$map, 'Query', ResolverMap::RESOLVE_FIELD, $map['Query'][ResolverMap::RESOLVE_FIELD]], + [$map, 'Query', 'foo', $map['Query']['foo']], + [$map, 'Query', 'bar', $map['Query']['bar']], + [$map, 'Query', 'baz', null], + [$map, 'FooInterface', ResolverMap::RESOLVE_TYPE, $map['FooInterface'][ResolverMap::RESOLVE_TYPE]], + ]); + } + + return $validMap; + } + + /** + * @param mixed $map + * + * @return ResolverMap|\PHPUnit_Framework_MockObject_MockObject + */ + private function createResolverMapMock($map) + { + /** @var ResolverMap|\PHPUnit_Framework_MockObject_MockObject $resolverMap */ + $resolverMap = $this->getMockBuilder(ResolverMap::class)->setMethods(['map'])->getMock(); + $resolverMap->method('map')->willReturn($map); + + return $resolverMap; + } + + private function map() + { + return [ + 'Query' => [ + ResolverMap::RESOLVE_FIELD => function () { + }, + 'foo' => function () { + }, + 'bar' => function () { + }, + 'baz' => null, + ], + 'FooInterface' => [ + ResolverMap::RESOLVE_TYPE => function () { + }, + ], + ]; + } +} diff --git a/Tests/Resolver/ResolverMapsTest.php b/Tests/Resolver/ResolverMapsTest.php new file mode 100644 index 000000000..e730a2682 --- /dev/null +++ b/Tests/Resolver/ResolverMapsTest.php @@ -0,0 +1,47 @@ +expectException(UnresolvableException::class); + $this->expectExceptionMessage('Field "Foo.bar" could not be resolved.'); + $resolverMaps->resolve('Foo', 'bar'); + } + + /** + * @dataProvider invalidResolverMapDataProvider + * + * @param array $resolverMaps + * @param string $type + */ + public function testInvalidResolverMap(array $resolverMaps, $type) + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf( + 'ResolverMap should be instance of "%s" but got "%s".', + ResolverMapInterface::class, + $type + )); + new ResolverMaps($resolverMaps); + } + + public function invalidResolverMapDataProvider() + { + return [ + [[null], 'NULL'], + [[false], 'boolean'], + [[true], 'boolean'], + [['baz'], 'string'], + [[new \stdClass()], 'stdClass'], + ]; + } +} diff --git a/Tests/Resolver/ResolverTest.php b/Tests/Resolver/ResolverTest.php index 3b3c1f18a..c6a92456e 100644 --- a/Tests/Resolver/ResolverTest.php +++ b/Tests/Resolver/ResolverTest.php @@ -22,6 +22,18 @@ public function testDefaultResolveFn($fieldName, $source, $expected) $this->assertEquals($expected, Resolver::defaultResolveFn($source, [], [], $info)); } + public function testSetObjectOrArrayValue() + { + $object = new \stdClass(); + $object->foo = null; + Resolver::setObjectOrArrayValue($object, 'foo', 'bar'); + $this->assertSame($object->foo, 'bar'); + + $data = ['foo' => null]; + Resolver::setObjectOrArrayValue($data, 'foo', 'bar'); + $this->assertSame($data['foo'], 'bar'); + } + public function resolverProvider() { $object = new Toto(); diff --git a/Tests/Resolver/TypeResolverTest.php b/Tests/Resolver/TypeResolverTest.php index 4cc8914f8..ac1e6d07b 100644 --- a/Tests/Resolver/TypeResolverTest.php +++ b/Tests/Resolver/TypeResolverTest.php @@ -27,6 +27,18 @@ public function createObjectType(array $config) return new ObjectType($config); } + /** + * @expectedException \RuntimeException + * @expectedExceptionMessage Type class for alias "type" could not be load. If you are using your own classLoader verify the path and the namespace please. + */ + public function testErrorLoadingType() + { + $this->resolver->addSolution('type', function () { + throw new \Exception('Could not load type.'); + }); + $this->resolver->resolve('type'); + } + /** * @expectedException \Overblog\GraphQLBundle\Resolver\UnsupportedResolverException * @expectedExceptionMessage Resolver "not-supported" must be "GraphQL\Type\Definition\Type" "stdClass" given. diff --git a/UPGRADE-0.11.md b/UPGRADE-0.11.md index 9103d5a8c..5599b1a56 100644 --- a/UPGRADE-0.11.md +++ b/UPGRADE-0.11.md @@ -4,8 +4,9 @@ UPGRADE FROM 0.10 to 0.11 # Table of Contents - [GraphiQL](#graphiql) -- [Errors Handler](#errors-handler) +- [Errors handler](#errors-handler) - [Promise adapter interface](#promise-adapter-interface) +- [Expression language](#expression-language) ### GraphiQL @@ -84,3 +85,47 @@ UPGRADE FROM 0.10 to 0.11 - public function setPromiseAdapter(PromiseAdapter $promiseAdapter = null); + public function setPromiseAdapter(PromiseAdapter $promiseAdapter); ``` + +### Expression language + + * **user** expression variable has been replaced by **getUser** expression function + * **container**, **request** and **token** expression variables has been removed. + `service` or `serv` expression function should be used instead. + + Upgrading your schema configuration: + - Replace `user` by `getUser()`: + ```diff + - resolve: '@=user' + + resolve: '@=getUser()' + ``` + + or + + ```diff + - resolve: '@=resolver('foo', [user])' + + resolve: '@=resolver('foo', [getUser()])' + ``` + - Replace `token` by `serv('security.token_storage')` + ```diff + - resolve: '@=token' + + resolve: '@=serv('security.token_storage')' + ``` + + or + + ```diff + - resolve: '@=resolver('foo', [token])' + + resolve: '@=resolver('foo', [serv('security.token_storage')])' + ``` + - Replace `request` by `serv('request_stack')` + ```diff + - resolve: '@=request' + + resolve: '@=serv('request_stack')' + ``` + + or + + ```diff + - resolve: '@=resolver('foo', [request])' + + resolve: '@=resolver('foo', [serv('request_stack')])' + ``` diff --git a/composer.json b/composer.json index 7fb4d336d..dbc97cdd5 100644 --- a/composer.json +++ b/composer.json @@ -48,7 +48,7 @@ "react/promise": "To use ReactPHP promise adapter" }, "require-dev": { - "phpunit/phpunit": "^5.5 || ^6.0", + "phpunit/phpunit": "^5.7.26 || ^6.0", "psr/log": "^1.0", "react/promise": "^2.5", "sensio/framework-extra-bundle": "^3.0",