src/Bundle/CoreBundle/GraphQL/SchemaManager.php
<?php
namespace UniteCMS\CoreBundle\GraphQL;
use GraphQL\Error\ClientAware;
use GraphQL\Error\Error;
use GraphQL\Error\SyntaxError;
use GraphQL\Language\AST\FragmentDefinitionNode;
use GraphQL\Language\AST\InterfaceTypeDefinitionNode;
use GraphQL\Language\AST\OperationDefinitionNode;
use GraphQL\Language\AST\ScalarTypeDefinitionNode;
use GraphQL\Language\AST\UnionTypeDefinitionNode;
use GraphQL\Language\Printer;
use GraphQL\Server\RequestError;
use GraphQL\Utils\AST;
use InvalidArgumentException;
use Psr\Log\LoggerInterface;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Throwable;
use UniteCMS\CoreBundle\ContentType\ContentType;
use UniteCMS\CoreBundle\ContentType\ContentTypeManager;
use UniteCMS\CoreBundle\Domain\DomainManager;
use UniteCMS\CoreBundle\Exception\ConstraintViolationsException;
use UniteCMS\CoreBundle\GraphQL\Resolver\Field\FieldResolverInterface;
use UniteCMS\CoreBundle\GraphQL\Resolver\Scalar\ScalarResolverInterface;
use UniteCMS\CoreBundle\GraphQL\Resolver\Type\TypeResolverInterface;
use UniteCMS\CoreBundle\GraphQL\Schema\Extender\SchemaExtenderInterface;
use UniteCMS\CoreBundle\GraphQL\Schema\Modifier\SchemaModifierInterface;
use GraphQL\Executor\ExecutionResult;
use GraphQL\GraphQL;
use GraphQL\Language\AST\DocumentNode;
use GraphQL\Language\AST\ObjectTypeDefinitionNode;
use GraphQL\Language\Parser;
use GraphQL\Server\Helper;
use GraphQL\Server\ServerConfig;
use GraphQL\Server\StandardServer;
use GraphQL\Type\Definition\InterfaceType;
use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Definition\ResolveInfo;
use GraphQL\Type\Schema;
use GraphQL\Utils\BuildSchema;
use GraphQL\Utils\SchemaExtender;
use GraphQL\Utils\SchemaPrinter;
use Symfony\Component\HttpFoundation\Request;
use UniteCMS\CoreBundle\GraphQL\Schema\Provider\SchemaProviderInterface;
use UniteCMS\CoreBundle\ContentType\UserType;
use UniteCMS\CoreBundle\Security\User\UserInterface;
class SchemaManager
{
const UNITE_CMS_ROOT_SCHEMA = __DIR__ . '/../Resources/GraphQL/Schema/root-schema.graphql';
// From https://github.com/graphql/graphql-js/blob/master/src/utilities/assertValidName.js
const GRAPHQL_NAME_REGEX = '/^[_a-zA-Z][_a-zA-Z0-9]*$/';
/**
* @var CacheInterface $uniteCmsCoreGraphqlSchemaCache
*/
protected $uniteCmsCoreGraphqlSchemaCache;
/**
* @var DomainManager $domainManager
*/
protected $domainManager;
/**
* @var Security $security
*/
protected $security;
/**
* @var ValidatorInterface $validator
*/
protected $validator;
/**
* @var LoggerInterface $logger
*/
protected $logger;
/**
* @var DocumentNode
*/
protected $baseSchemaDefinition = null;
/**
* @var Schema
*/
protected $cacheableBaseSchema = null;
/**
* @var DocumentNode
*/
protected $cacheableSchema = null;
/**
* @var Schema
*/
protected $executableSchema = null;
/**
* @var SchemaProviderInterface[]
*/
protected $providers = [];
/**
* @var SchemaExtenderInterface[]
*/
protected $beforeTypeExtenders = [];
/**
* @var SchemaExtenderInterface[]
*/
protected $afterTypeExtenders = [];
/**
* @var SchemaModifierInterface[]
*/
protected $modifiers = [];
/**
* @var FieldResolverInterface[]
*/
protected $fieldResolvers = [];
/**
* @var TypeResolverInterface[]
*/
protected $typeResolvers = [];
/**
* @var ScalarResolverInterface[]
*/
protected $scalarResolvers = [];
public function __construct(DomainManager $domainManager, Security $security, ValidatorInterface $validator, LoggerInterface $logger, CacheInterface $uniteCmsCoreGraphqlSchemaCache)
{
$this->uniteCmsCoreGraphqlSchemaCache = $uniteCmsCoreGraphqlSchemaCache;
$this->domainManager = $domainManager;
$this->security = $security;
$this->validator = $validator;
$this->logger = $logger;
}
/**
* @param SchemaProviderInterface $provider
* @return $this
*/
public function registerProvider(SchemaProviderInterface $provider) : self {
if(!in_array($provider, $this->providers)) {
$this->providers[] = $provider;
}
return $this;
}
/**
* @param SchemaExtenderInterface $extender
* @param string $position
*
* @return $this
*/
public function registerExtender(SchemaExtenderInterface $extender, string $position = SchemaExtenderInterface::EXTENDER_AFTER) : self {
switch ($position) {
case SchemaExtenderInterface::EXTENDER_BEFORE:
$this->beforeTypeExtenders[] = $extender;
break;
case SchemaExtenderInterface::EXTENDER_AFTER:
$this->afterTypeExtenders[] = $extender;
break;
}
return $this;
}
/**
* @param SchemaModifierInterface $modifier
* @return $this
*/
public function registerModifier(SchemaModifierInterface $modifier) : self {
if(!in_array($modifier, $this->modifiers)) {
$this->modifiers[] = $modifier;
}
return $this;
}
/**
* @param FieldResolverInterface $resolver
* @return $this
*/
public function registerFieldResolver(FieldResolverInterface $resolver) : self {
if(!in_array($resolver, $this->fieldResolvers)) {
$this->fieldResolvers[] = $resolver;
}
return $this;
}
/**
* @param TypeResolverInterface $resolver
* @return $this
*/
public function registerTypeResolver(TypeResolverInterface $resolver) : self {
if(!in_array($resolver, $this->typeResolvers)) {
$this->typeResolvers[] = $resolver;
}
return $this;
}
/**
* @param ScalarResolverInterface $resolver
* @return $this
*/
public function registerScalarResolver(ScalarResolverInterface $resolver) : self {
if(!in_array($resolver, $this->scalarResolvers)) {
$this->scalarResolvers[] = $resolver;
}
return $this;
}
/**
* @param array $resolvers
* @param array $typeConfig
* @param $typeDefinitionNode
*
* @return array
*/
protected function getSupportedResolvers(array $resolvers, array $typeConfig, $typeDefinitionNode) : array{
$supportedResolvers = [];
foreach ($resolvers as $resolver) {
if ($resolver->supports($typeConfig['name'], $typeDefinitionNode)) {
$supportedResolvers[] = $resolver;
}
}
return $supportedResolvers;
}
/**
* @param array $typeConfig
* @param $typeDefinitionNode
*
* @return mixed
*/
protected function decorateObjectType(array $typeConfig, $typeDefinitionNode) {
$resolvers = $this->getSupportedResolvers($this->fieldResolvers, $typeConfig, $typeDefinitionNode);
if(count($resolvers) === 1) {
$typeConfig['resolveField'] = [$resolvers[0], 'resolve'];
} elseif(count($resolvers) > 1) {
$typeConfig['resolveField'] = function($value, $args, $context, ResolveInfo $info) use ($resolvers) {
foreach($resolvers as $resolver) {
$result = $resolver->resolve($value, $args, $context, $info);
if($result !== null) {
return $result;
}
}
return null;
};
}
return $typeConfig;
}
/**
* @param $typeConfig
* @param $typeDefinitionNode
*
* @return array
*/
protected function decorateAbstractType(array $typeConfig, $typeDefinitionNode) {
$resolvers = $this->getSupportedResolvers($this->typeResolvers, $typeConfig, $typeDefinitionNode);
if(count($resolvers) === 1) {
$typeConfig['resolveType'] = [$resolvers[0], 'resolve'];
} elseif(count($resolvers) > 1) {
$typeConfig['resolveType'] = function($value, $context, ResolveInfo $info) use ($resolvers) {
foreach($resolvers as $resolver) {
$result = $resolver->resolve($value, $context, $info);
if($result !== null) {
return $result;
}
}
return null;
};
}
return $typeConfig;
}
/**
* @param array $typeConfig
* @param $typeDefinitionNode
*
* @return array
*/
protected function decorateScalarType(array $typeConfig, $typeDefinitionNode) {
$resolvers = $this->getSupportedResolvers($this->scalarResolvers, $typeConfig, $typeDefinitionNode);
if(count($resolvers) === 1) {
$typeConfig['serialize'] = [$resolvers[0], 'serialize'];
$typeConfig['parseValue'] = [$resolvers[0], 'parseValue'];
$typeConfig['parseLiteral'] = [$resolvers[0], 'parseLiteral'];
} elseif(count($resolvers) > 1) {
$typeConfig['serialize'] = function($value) use ($resolvers) {
foreach($resolvers as $resolver) {
$result = $resolver->serialize($value);
if($result !== null) {
return $result;
}
}
return null;
};
$typeConfig['parseValue'] = function($value) use ($resolvers) {
foreach($resolvers as $resolver) {
$result = $resolver->parseValue($value);
if($result !== null) {
return $result;
}
}
return null;
};
$typeConfig['parseLiteral'] = function($valueNode, array $variables = null) use ($resolvers) {
foreach($resolvers as $resolver) {
$result = $resolver->parseLiteral($valueNode, $variables);
if($result !== null) {
return $result;
}
}
return null;
};
}
return $typeConfig;
}
/**
* @param bool $byUser
* @return string
*/
protected function cacheKey($byUser = true) : string {
$key = 'unite_cms_core_schema';
$key .= '__domain_' . $this->domainManager->current()->getId();
if($byUser) {
$user = $this->security->getUser();
$user_hash = [];
if($user) {
$user_hash[] = $user->getUsername();
$user_hash[] = $user->getRoles();
if ($user instanceof UserInterface) {
$user_hash[] = $user->isFullyAuthenticated();
$user_hash[] = $user->getType();
$user_hash[] = $user->getUpdated()->getTimestamp();
}
$key .= '__u_'.md5(serialize($user_hash));
} else {
$key .= '__u_anon';
}
} else {
$key .= '__u_ignore';
}
return $key;
}
/**
* @param bool $forceFresh
*
* @return Schema
* @throws SyntaxError
* @throws \Psr\Cache\InvalidArgumentException
*/
public function buildBaseSchema(bool $forceFresh = false) : Schema {
// If the schema is already in memory, use it from there.
if(!$forceFresh && $this->cacheableBaseSchema) {
return $this->cacheableBaseSchema;
}
// If the schema is not in memory load it from cache or generate it and save it to the cache
$ast = $this->uniteCmsCoreGraphqlSchemaCache->get($this->cacheKey(false), function() {
// Init with graphQL schema.
$schemaDefinition = '';
foreach ($this->providers as $provider) {
$schemaDefinition .= $provider->extend() . "\n";
}
$schemaDefinition .= join("\n", $this->domainManager->current()->getCompleteSchema());
$ast = Parser::parse($schemaDefinition);
return AST::toArray($ast);
}, $forceFresh ? INF : 0);
$this->baseSchemaDefinition = AST::fromArray($ast);
$this->cacheableBaseSchema = BuildSchema::build($this->baseSchemaDefinition);
return $this->cacheableBaseSchema;
}
/**
* @param bool $forceFresh
*
* @param ExecutionContext|null $context
* @return DocumentNode
* @throws Error
* @throws SyntaxError
*/
public function buildCacheableSchema(bool $forceFresh = false, ?ExecutionContext $context = null) : DocumentNode {
$context = $context ?? new ExecutionContext();
// If the schema is already in memory, use it from there.
if(!$forceFresh && $this->cacheableSchema) {
return $this->cacheableSchema;
}
// Build base schema from domain and providers.
$schema = $this->buildBaseSchema($forceFresh);
// Get current content type manager.
$contentTypeManager = $this->domainManager->current()->getContentTypeManager();
// If the schema is not in memory load it from cache or generate it and save it to the cache
list($ast, $types) = $this->uniteCmsCoreGraphqlSchemaCache->get($this->cacheKey(true), function() use ($context, $schema, $contentTypeManager) {
// Execute before type extenders.
foreach($this->beforeTypeExtenders as $extender) {
if($extension = $extender->extend($schema, $context)) {
$parameters = $this->domainManager->getGlobalParameters() + $this->domainManager->current()->getParameters();
$extension = Util::replaceSchemaParameters($extension, $parameters);
$schema = SchemaExtender::extend($schema, Parser::parse($extension));
}
}
// Generate content types based on schema and validate it.
$contentTypeManager = $this->generateContentTypes($schema, $contentTypeManager);
$violations = $this->validator->validate($contentTypeManager);
if(count($violations) > 0) {
throw new ConstraintViolationsException($violations);
}
// Execute after type extenders.
foreach($this->afterTypeExtenders as $extender) {
if($extension = $extender->extend($schema, $context)) {
$parameters = $this->domainManager->getGlobalParameters() + $this->domainManager->current()->getParameters();
$extension = Util::replaceSchemaParameters($extension, $parameters);
$schema = SchemaExtender::extend($schema, Parser::parse($extension));
}
}
$cacheableSchema = Parser::parse(SchemaPrinter::doPrint($schema));
// Execute schema modifiers after schema was built.
foreach($this->modifiers as $modifier) {
$modifier->modify($cacheableSchema, $schema, $context);
}
return [
AST::toArray($cacheableSchema),
$contentTypeManager->toArray(),
];
}, $forceFresh ? INF : 0);
// Get schema from cached ast
$this->cacheableSchema = AST::fromArray($ast);
// Get Content Type Manager form cached types
$contentTypeManager->fromArray($types);
return $this->cacheableSchema;
}
/**
* @param bool $forceFresh
*
* @param ExecutionContext|null $context
* @return Schema
* @throws Error
* @throws SyntaxError
*/
public function buildExecutableSchema(bool $forceFresh = false, ?ExecutionContext $context = null) : Schema {
$context = $context ?? new ExecutionContext();
if(!$forceFresh && $this->executableSchema) {
return $this->executableSchema;
}
$this->executableSchema = BuildSchema::build($this->buildCacheableSchema($forceFresh, $context), function(array $typeConfig, $typeDefinitionNode) {
// Resolve GraphQL objects.
if($typeDefinitionNode instanceof ObjectTypeDefinitionNode) {
$typeConfig = $this->decorateObjectType($typeConfig, $typeDefinitionNode);
}
// Resolve GraphQL union and interface types.
else if ($typeDefinitionNode instanceof UnionTypeDefinitionNode || $typeDefinitionNode instanceof InterfaceTypeDefinitionNode) {
$typeConfig = $this->decorateAbstractType($typeConfig, $typeDefinitionNode);
}
// Resolve GraphQL scalars.
else if($typeDefinitionNode instanceof ScalarTypeDefinitionNode) {
$typeConfig = $this->decorateScalarType($typeConfig, $typeDefinitionNode);
}
return $typeConfig;
});
return $this->executableSchema;
}
/**
* @param string $query
* @param array $args
* @param null|ExecutionContext $context
*
* @param bool $forceFresh
*
* @return ExecutionResult
* @throws Error
* @throws SyntaxError
*/
public function execute(string $query, array $args = [], ?ExecutionContext $context = null, bool $forceFresh = false) : ExecutionResult {
$context = $context ?? new ExecutionContext();
$schema = $this->buildExecutableSchema($forceFresh, $context);
return GraphQL::executeQuery($schema, $query, null, $context, $args)
->setErrorFormatter([ErrorFormatter::class, 'createFromException'])->setErrorsHandler([$this, 'handleErrors']);
}
/**
* @param string $name
* @param array $fragments
* @param array $args
* @param null $context
* @param bool $forceFresh
*
* @return ExecutionResult
* @throws Error
* @throws SyntaxError
*/
public function executeOperation(string $name, array $fragments = [], array $args = [], $context = null, bool $forceFresh = false) : ExecutionResult {
$fragmentsQuery = '';
$query = '';
$this->buildBaseSchema($forceFresh);
foreach($this->baseSchemaDefinition->definitions as $definition) {
if($definition instanceof OperationDefinitionNode && $definition->name->value === $name) {
$query .= Printer::doPrint($definition);
}
if($definition instanceof FragmentDefinitionNode && in_array($definition->name->value, $fragments)) {
$fragmentsQuery .= Printer::doPrint($definition);
}
}
if($query) {
return $this->execute($fragmentsQuery . $query, $args, $context, $forceFresh);
}
throw new InvalidArgumentException(sprintf('Operation with name "%s" was not found in your schema.', $name));
}
/**
* @param Request $request
* @param bool $debug
* @param null|ExecutionContext $context
*
* @param bool $forceFresh
*
* @return ExecutionResult
* @throws Error
* @throws SyntaxError
* @throws RequestError
*/
public function executeRequest(Request $request, bool $debug = false, ?ExecutionContext $context = null, bool $forceFresh = false) : ExecutionResult {
$context = $context ?? new ExecutionContext();
$server = new StandardServer(ServerConfig::create()
->setSchema($this->buildExecutableSchema($forceFresh))
->setQueryBatching(true)
->setDebug($debug)
->setContext($context)
);
$serverHelper = new Helper();
return $server->executeRequest(
$serverHelper->parseRequestParams(
$request->getMethod(),
json_decode($request->getContent(), true),
$request->request->all()
)
)->setErrorFormatter([ErrorFormatter::class, 'createFromException'])->setErrorsHandler([$this, 'handleErrors']);
}
/**
* @return DocumentNode
* @throws SyntaxError
*/
public function getBaseSchemaDefinition() : DocumentNode {
$this->buildBaseSchema();
return $this->baseSchemaDefinition;
}
/**
* Creates or updates the current domain's ContentTypeManager and returns it.
*
* @param Schema $schema
* @param ContentTypeManager $contentTypeManager
* @return ContentTypeManager
* @throws Error
*/
protected function generateContentTypes(Schema $schema, ContentTypeManager $contentTypeManager) : ContentTypeManager {
/**
* @var InterfaceType $uniteContent
*/
$uniteContent = $schema->getType('UniteContent');
/**
* @var InterfaceType $uniteSingleContent
*/
$uniteSingleContent = $schema->getType('UniteSingleContent');
/**
* @var InterfaceType $uniteContentEmbed
*/
$uniteContentEmbed = $schema->getType('UniteEmbeddedContent');
/**
* @var InterfaceType $uniteUser
*/
$uniteUser = $schema->getType('UniteUser');
// Fill content type manager from graphql objects.
foreach($schema->getTypeMap() as $key => $type) {
if($type instanceof ObjectType) {
// Register content type in content type manager.
if($type->implementsInterface($uniteContent)){
$contentTypeManager->registerContentType(ContentType::fromObjectType($type, $this->domainManager->getIsAdminExpression()));
}
// Register single content type in content type manager.
if($type->implementsInterface($uniteSingleContent)){
$contentTypeManager->registerSingleContentType(ContentType::fromObjectType($type, $this->domainManager->getIsAdminExpression()));
}
// Register embedded content type in content type manager.
if($type->implementsInterface($uniteContentEmbed)){
$contentTypeManager->registerEmbeddedContentType(ContentType::fromObjectType($type, $this->domainManager->getIsAdminExpression()));
}
// Register user content type in content type manager.
if($type->implementsInterface($uniteUser)){
$contentTypeManager->registerUserType(UserType::fromObjectType($type, $this->domainManager->getIsAdminExpression()));
}
}
}
return $contentTypeManager;
}
/**
* @param array $errors
* @param callable $formatter
*
* @return array
*/
public function handleErrors(array $errors, callable $formatter) {
// All errors that cannot be shown to the user should be logged somewhere.
foreach($errors as $error) {
if($error instanceof Throwable) {
if (!$error instanceof ClientAware || !$error->isClientSafe()) {
$this->logger->error($error->getMessage(), ['e' => $error]);
}
}
}
return array_map($formatter, $errors);
}
}