diff --git a/src/Annotations/Cost.php b/src/Annotations/Cost.php new file mode 100644 index 0000000000..8e09f4de18 --- /dev/null +++ b/src/Annotations/Cost.php @@ -0,0 +1,23 @@ + + * @param class-string $className + * + * @return array + * + * @template TAnnotation of MiddlewareAnnotationInterface */ public function getAnnotationsByType(string $className): array { @@ -32,6 +36,12 @@ public function getAnnotationsByType(string $className): array /** * Returns at most 1 annotation of the $className type. + * + * @param class-string $className + * + * @return TAnnotation|null + * + * @template TAnnotation of MiddlewareAnnotationInterface */ public function getAnnotationByType(string $className): MiddlewareAnnotationInterface|null { diff --git a/src/Http/Psr15GraphQLMiddlewareBuilder.php b/src/Http/Psr15GraphQLMiddlewareBuilder.php index 0ee4565771..76bb4d6de8 100644 --- a/src/Http/Psr15GraphQLMiddlewareBuilder.php +++ b/src/Http/Psr15GraphQLMiddlewareBuilder.php @@ -8,6 +8,9 @@ use GraphQL\Error\DebugFlag; use GraphQL\Server\ServerConfig; use GraphQL\Type\Schema; +use GraphQL\Validator\DocumentValidator; +use GraphQL\Validator\Rules\QueryComplexity; +use GraphQL\Validator\Rules\ValidationRule; use Laminas\Diactoros\ResponseFactory; use Laminas\Diactoros\StreamFactory; use Psr\Http\Message\ResponseFactoryInterface; @@ -21,6 +24,7 @@ use TheCodingMachine\GraphQLite\Server\PersistedQuery\NotSupportedPersistedQueryLoader; use function class_exists; +use function is_callable; /** * A factory generating a PSR-15 middleware tailored for GraphQLite. @@ -38,6 +42,9 @@ class Psr15GraphQLMiddlewareBuilder private HttpCodeDeciderInterface $httpCodeDecider; + /** @var ValidationRule[] */ + private array $addedValidationRules = []; + public function __construct(Schema $schema) { $this->config = new ServerConfig(); @@ -97,6 +104,18 @@ public function useAutomaticPersistedQueries(CacheInterface $cache, DateInterval return $this; } + public function limitQueryComplexity(int $complexity): self + { + return $this->addValidationRule(new QueryComplexity($complexity)); + } + + public function addValidationRule(ValidationRule $rule): self + { + $this->addedValidationRules[] = $rule; + + return $this; + } + public function createMiddleware(): MiddlewareInterface { if ($this->responseFactory === null && ! class_exists(ResponseFactory::class)) { @@ -109,6 +128,21 @@ public function createMiddleware(): MiddlewareInterface } $this->streamFactory = $this->streamFactory ?: new StreamFactory(); + // If getValidationRules() is null in the config, DocumentValidator will default to DocumentValidator::allRules(). + // So if we only added given rule, all of the default rules would not be validated, so we must also provide them. + $originalValidationRules = $this->config->getValidationRules() ?? DocumentValidator::allRules(); + + $this->config->setValidationRules(function (...$args) use ($originalValidationRules) { + if (is_callable($originalValidationRules)) { + $originalValidationRules = $originalValidationRules(...$args); + } + + return [ + ...$originalValidationRules, + ...$this->addedValidationRules, + ]; + }); + return new WebonyxGraphqlMiddleware($this->config, $this->responseFactory, $this->streamFactory, $this->httpCodeDecider, $this->url); } } diff --git a/src/Middlewares/AuthorizationFieldMiddleware.php b/src/Middlewares/AuthorizationFieldMiddleware.php index e1e8acde06..6e2a7cdc1e 100644 --- a/src/Middlewares/AuthorizationFieldMiddleware.php +++ b/src/Middlewares/AuthorizationFieldMiddleware.php @@ -34,9 +34,7 @@ public function process(QueryFieldDescriptor $queryFieldDescriptor, FieldHandler $annotations = $queryFieldDescriptor->getMiddlewareAnnotations(); $loggedAnnotation = $annotations->getAnnotationByType(Logged::class); - assert($loggedAnnotation === null || $loggedAnnotation instanceof Logged); $rightAnnotation = $annotations->getAnnotationByType(Right::class); - assert($rightAnnotation === null || $rightAnnotation instanceof Right); // Avoid wrapping resolver callback when no annotations are specified. if (! $loggedAnnotation && ! $rightAnnotation) { @@ -44,9 +42,7 @@ public function process(QueryFieldDescriptor $queryFieldDescriptor, FieldHandler } $failWith = $annotations->getAnnotationByType(FailWith::class); - assert($failWith === null || $failWith instanceof FailWith); $hideIfUnauthorized = $annotations->getAnnotationByType(HideIfUnauthorized::class); - assert($hideIfUnauthorized instanceof HideIfUnauthorized || $hideIfUnauthorized === null); if ($failWith !== null && $hideIfUnauthorized !== null) { throw IncompatibleAnnotationsException::cannotUseFailWithAndHide(); diff --git a/src/Middlewares/AuthorizationInputFieldMiddleware.php b/src/Middlewares/AuthorizationInputFieldMiddleware.php index 3e78b87960..670bd973d6 100644 --- a/src/Middlewares/AuthorizationInputFieldMiddleware.php +++ b/src/Middlewares/AuthorizationInputFieldMiddleware.php @@ -32,9 +32,7 @@ public function process(InputFieldDescriptor $inputFieldDescriptor, InputFieldHa $annotations = $inputFieldDescriptor->getMiddlewareAnnotations(); $loggedAnnotation = $annotations->getAnnotationByType(Logged::class); - assert($loggedAnnotation === null || $loggedAnnotation instanceof Logged); $rightAnnotation = $annotations->getAnnotationByType(Right::class); - assert($rightAnnotation === null || $rightAnnotation instanceof Right); // Avoid wrapping resolver callback when no annotations are specified. if (! $loggedAnnotation && ! $rightAnnotation) { @@ -42,7 +40,6 @@ public function process(InputFieldDescriptor $inputFieldDescriptor, InputFieldHa } $hideIfUnauthorized = $annotations->getAnnotationByType(HideIfUnauthorized::class); - assert($hideIfUnauthorized instanceof HideIfUnauthorized || $hideIfUnauthorized === null); if ($hideIfUnauthorized !== null && ! $this->isAuthorized($loggedAnnotation, $rightAnnotation)) { return null; diff --git a/src/Middlewares/CostFieldMiddleware.php b/src/Middlewares/CostFieldMiddleware.php new file mode 100644 index 0000000000..56daa62328 --- /dev/null +++ b/src/Middlewares/CostFieldMiddleware.php @@ -0,0 +1,73 @@ +getMiddlewareAnnotations()->getAnnotationByType(Cost::class); + + if (! $costAttribute) { + return $fieldHandler->handle($queryFieldDescriptor); + } + + $field = $fieldHandler->handle( + $queryFieldDescriptor->withAddedCommentLines($this->buildQueryComment($costAttribute)), + ); + + if (! $field) { + return $field; + } + + $field->complexityFn = static function (int $childrenComplexity, array $fieldArguments) use ($costAttribute): int { + if (! $costAttribute->multipliers) { + return $costAttribute->complexity + $childrenComplexity; + } + + $cost = $costAttribute->complexity + $childrenComplexity; + $needsDefaultMultiplier = true; + + foreach ($costAttribute->multipliers as $multiplier) { + $value = $fieldArguments[$multiplier] ?? null; + + if (! is_int($value)) { + continue; + } + + $cost *= $value; + $needsDefaultMultiplier = false; + } + + if ($needsDefaultMultiplier && $costAttribute->defaultMultiplier !== null) { + $cost *= $costAttribute->defaultMultiplier; + } + + return $cost; + }; + + return $field; + } + + private function buildQueryComment(Cost $costAttribute): string + { + return 'Cost: ' . + implode(', ', [ + 'complexity = ' . $costAttribute->complexity, + 'multipliers = [' . implode(', ', $costAttribute->multipliers) . ']', + 'defaultMultiplier = ' . ($costAttribute->defaultMultiplier ?? 'null'), + ]); + } +} diff --git a/src/Middlewares/SecurityFieldMiddleware.php b/src/Middlewares/SecurityFieldMiddleware.php index d0884bd418..c3182dd962 100644 --- a/src/Middlewares/SecurityFieldMiddleware.php +++ b/src/Middlewares/SecurityFieldMiddleware.php @@ -42,7 +42,6 @@ public function process(QueryFieldDescriptor $queryFieldDescriptor, FieldHandler } $failWith = $annotations->getAnnotationByType(FailWith::class); - assert($failWith instanceof FailWith || $failWith === null); // If the failWith value is null and the return type is non nullable, we must set it to nullable. $makeReturnTypeNullable = false; diff --git a/src/QueryFieldDescriptor.php b/src/QueryFieldDescriptor.php index 5efa1de983..e12b88a2ee 100644 --- a/src/QueryFieldDescriptor.php +++ b/src/QueryFieldDescriptor.php @@ -174,6 +174,15 @@ public function withComment(string|null $comment): self return $this->with(comment: $comment); } + public function withAddedCommentLines(string $comment): self + { + if (! $this->comment) { + return $this->withComment($comment); + } + + return $this->withComment($this->comment . "\n" . $comment); + } + public function getDeprecationReason(): string|null { return $this->deprecationReason; diff --git a/src/SchemaFactory.php b/src/SchemaFactory.php index b612ee745e..11dcdf71f7 100644 --- a/src/SchemaFactory.php +++ b/src/SchemaFactory.php @@ -41,6 +41,7 @@ use TheCodingMachine\GraphQLite\Mappers\TypeMapperInterface; use TheCodingMachine\GraphQLite\Middlewares\AuthorizationFieldMiddleware; use TheCodingMachine\GraphQLite\Middlewares\AuthorizationInputFieldMiddleware; +use TheCodingMachine\GraphQLite\Middlewares\CostFieldMiddleware; use TheCodingMachine\GraphQLite\Middlewares\FieldMiddlewareInterface; use TheCodingMachine\GraphQLite\Middlewares\FieldMiddlewarePipe; use TheCodingMachine\GraphQLite\Middlewares\InputFieldMiddlewareInterface; @@ -211,9 +212,7 @@ public function addParameterMiddleware(ParameterMiddlewareInterface $parameterMi return $this; } - /** - * @deprecated Use PHP8 Attributes instead - */ + /** @deprecated Use PHP8 Attributes instead */ public function setDoctrineAnnotationReader(Reader $annotationReader): self { $this->doctrineAnnotationReader = $annotationReader; @@ -349,7 +348,7 @@ public function createSchema(): Schema $namespaceFactory = new NamespaceFactory($namespacedCache, $this->classNameMapper, $this->globTTL); $nsList = array_map( - static fn(string $namespace) => $namespaceFactory->createNamespace($namespace), + static fn (string $namespace) => $namespaceFactory->createNamespace($namespace), $this->typeNamespaces, ); @@ -363,6 +362,7 @@ public function createSchema(): Schema // TODO: add a logger to the SchemaFactory and make use of it everywhere (and most particularly in SecurityFieldMiddleware) $fieldMiddlewarePipe->pipe(new SecurityFieldMiddleware($expressionLanguage, $authenticationService, $authorizationService)); $fieldMiddlewarePipe->pipe(new AuthorizationFieldMiddleware($authenticationService, $authorizationService)); + $fieldMiddlewarePipe->pipe(new CostFieldMiddleware()); $inputFieldMiddlewarePipe = new InputFieldMiddlewarePipe(); foreach ($this->inputFieldMiddlewares as $inputFieldMiddleware) { @@ -390,7 +390,7 @@ public function createSchema(): Schema $rootTypeMapper = new MyCLabsEnumTypeMapper($rootTypeMapper, $annotationReader, $symfonyCache, $nsList); } - if (!empty($this->rootTypeMapperFactories)) { + if (! empty($this->rootTypeMapperFactories)) { $rootSchemaFactoryContext = new RootTypeMapperFactoryContext( $annotationReader, $typeResolver, @@ -458,7 +458,7 @@ public function createSchema(): Schema )); } - if (!empty($this->typeMapperFactories) || !empty($this->queryProviderFactories)) { + if (! empty($this->typeMapperFactories) || ! empty($this->queryProviderFactories)) { $context = new FactoryContext( $annotationReader, $typeResolver, diff --git a/tests/Fixtures/Integration/Controllers/ArticleController.php b/tests/Fixtures/Integration/Controllers/ArticleController.php index f682ca585b..ca2c32eff4 100644 --- a/tests/Fixtures/Integration/Controllers/ArticleController.php +++ b/tests/Fixtures/Integration/Controllers/ArticleController.php @@ -2,11 +2,27 @@ namespace TheCodingMachine\GraphQLite\Fixtures\Integration\Controllers; +use TheCodingMachine\GraphQLite\Annotations\Cost; use TheCodingMachine\GraphQLite\Annotations\Mutation; +use TheCodingMachine\GraphQLite\Annotations\Query; use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\Article; +use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\Contact; +use TheCodingMachine\GraphQLite\Fixtures\Integration\Models\User; class ArticleController { + /** + * @return Article[] + */ + #[Query] + #[Cost(complexity: 5, multipliers: ['take'], defaultMultiplier: 500)] + public function articles(?int $take = 10): array + { + return [ + new Article('Title'), + ]; + } + /** * @Mutation() diff --git a/tests/Fixtures/Integration/Models/Post.php b/tests/Fixtures/Integration/Models/Post.php index 8b611202d2..ae91b13ae0 100644 --- a/tests/Fixtures/Integration/Models/Post.php +++ b/tests/Fixtures/Integration/Models/Post.php @@ -3,6 +3,7 @@ namespace TheCodingMachine\GraphQLite\Fixtures\Integration\Models; use DateTimeInterface; +use TheCodingMachine\GraphQLite\Annotations\Cost; use TheCodingMachine\GraphQLite\Annotations\Field; use TheCodingMachine\GraphQLite\Annotations\Input; use TheCodingMachine\GraphQLite\Annotations\Type; @@ -38,6 +39,7 @@ class Post * @Field(name="comment") * @var string|null */ + #[Cost(complexity: 5)] private $description = 'foo'; /** @@ -50,6 +52,7 @@ class Post * @Field() * @var Contact|null */ + #[Cost(complexity: 3)] public $author = null; /** diff --git a/tests/Integration/EndToEndTest.php b/tests/Integration/EndToEndTest.php index 93d6f7f41f..2e2e4ed847 100644 --- a/tests/Integration/EndToEndTest.php +++ b/tests/Integration/EndToEndTest.php @@ -57,6 +57,7 @@ use TheCodingMachine\GraphQLite\Mappers\TypeMapperInterface; use TheCodingMachine\GraphQLite\Middlewares\AuthorizationFieldMiddleware; use TheCodingMachine\GraphQLite\Middlewares\AuthorizationInputFieldMiddleware; +use TheCodingMachine\GraphQLite\Middlewares\CostFieldMiddleware; use TheCodingMachine\GraphQLite\Middlewares\FieldMiddlewareInterface; use TheCodingMachine\GraphQLite\Middlewares\FieldMiddlewarePipe; use TheCodingMachine\GraphQLite\Middlewares\InputFieldMiddlewareInterface; @@ -95,311 +96,8 @@ use const JSON_PRETTY_PRINT; -class EndToEndTest extends TestCase +class EndToEndTest extends IntegrationTestCase { - private ContainerInterface $mainContainer; - - public function setUp(): void - { - $this->mainContainer = $this->createContainer(); - } - - /** @param array $overloadedServices */ - public function createContainer(array $overloadedServices = []): ContainerInterface - { - $services = [ - Schema::class => static function (ContainerInterface $container) { - return new Schema($container->get(QueryProviderInterface::class), $container->get(RecursiveTypeMapperInterface::class), $container->get(TypeResolver::class), $container->get(RootTypeMapperInterface::class)); - }, - QueryProviderInterface::class => static function (ContainerInterface $container) { - $queryProvider = new GlobControllerQueryProvider( - 'TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Controllers', - $container->get(FieldsBuilder::class), - $container->get(BasicAutoWiringContainer::class), - $container->get(AnnotationReader::class), - new Psr16Cache(new ArrayAdapter()), - ); - - if (interface_exists(UnitEnum::class)) { - $queryProvider = new AggregateQueryProvider([ - $queryProvider, - new GlobControllerQueryProvider( - 'TheCodingMachine\\GraphQLite\\Fixtures81\\Integration\\Controllers', - $container->get(FieldsBuilder::class), - $container->get(BasicAutoWiringContainer::class), - $container->get(AnnotationReader::class), - new Psr16Cache(new ArrayAdapter()), - ), - ]); - } - return $queryProvider; - }, - FieldsBuilder::class => static function (ContainerInterface $container) { - $parameterMiddlewarePipe = $container->get(ParameterMiddlewareInterface::class); - $fieldsBuilder = new FieldsBuilder( - $container->get(AnnotationReader::class), - $container->get(RecursiveTypeMapperInterface::class), - $container->get(ArgumentResolver::class), - $container->get(TypeResolver::class), - $container->get(CachedDocBlockFactory::class), - $container->get(NamingStrategyInterface::class), - $container->get(RootTypeMapperInterface::class), - $parameterMiddlewarePipe, - $container->get(FieldMiddlewareInterface::class), - $container->get(InputFieldMiddlewareInterface::class), - ); - $parameterizedCallableResolver = new ParameterizedCallableResolver($fieldsBuilder, $container); - - $parameterMiddlewarePipe->pipe(new PrefetchParameterMiddleware($parameterizedCallableResolver)); - - return $fieldsBuilder; - }, - FieldMiddlewareInterface::class => static function (ContainerInterface $container) { - $pipe = new FieldMiddlewarePipe(); - $pipe->pipe($container->get(AuthorizationFieldMiddleware::class)); - $pipe->pipe($container->get(SecurityFieldMiddleware::class)); - return $pipe; - }, - InputFieldMiddlewareInterface::class => static function (ContainerInterface $container) { - $pipe = new InputFieldMiddlewarePipe(); - $pipe->pipe($container->get(AuthorizationInputFieldMiddleware::class)); - $pipe->pipe($container->get(SecurityInputFieldMiddleware::class)); - return $pipe; - }, - AuthorizationInputFieldMiddleware::class => static function (ContainerInterface $container) { - return new AuthorizationInputFieldMiddleware( - $container->get(AuthenticationServiceInterface::class), - $container->get(AuthorizationServiceInterface::class), - ); - }, - SecurityInputFieldMiddleware::class => static function (ContainerInterface $container) { - return new SecurityInputFieldMiddleware( - new ExpressionLanguage(new Psr16Adapter(new Psr16Cache(new ArrayAdapter())), [new SecurityExpressionLanguageProvider()]), - $container->get(AuthenticationServiceInterface::class), - $container->get(AuthorizationServiceInterface::class), - ); - }, - AuthorizationFieldMiddleware::class => static function (ContainerInterface $container) { - return new AuthorizationFieldMiddleware( - $container->get(AuthenticationServiceInterface::class), - $container->get(AuthorizationServiceInterface::class), - ); - }, - SecurityFieldMiddleware::class => static function (ContainerInterface $container) { - return new SecurityFieldMiddleware( - new ExpressionLanguage(new Psr16Adapter(new Psr16Cache(new ArrayAdapter())), [new SecurityExpressionLanguageProvider()]), - $container->get(AuthenticationServiceInterface::class), - $container->get(AuthorizationServiceInterface::class), - ); - }, - ArgumentResolver::class => static function (ContainerInterface $container) { - return new ArgumentResolver(); - }, - TypeResolver::class => static function (ContainerInterface $container) { - return new TypeResolver(); - }, - BasicAutoWiringContainer::class => static function (ContainerInterface $container) { - return new BasicAutoWiringContainer(new EmptyContainer()); - }, - AuthorizationServiceInterface::class => static function (ContainerInterface $container) { - return new VoidAuthorizationService(); - }, - AuthenticationServiceInterface::class => static function (ContainerInterface $container) { - return new VoidAuthenticationService(); - }, - RecursiveTypeMapperInterface::class => static function (ContainerInterface $container) { - $arrayAdapter = new ArrayAdapter(); - $arrayAdapter->setLogger(new ExceptionLogger()); - return new RecursiveTypeMapper( - $container->get(TypeMapperInterface::class), - $container->get(NamingStrategyInterface::class), - new Psr16Cache($arrayAdapter), - $container->get(TypeRegistry::class), - $container->get(AnnotationReader::class), - ); - }, - TypeMapperInterface::class => static function (ContainerInterface $container) { - return new CompositeTypeMapper(); - }, - NamespaceFactory::class => static function (ContainerInterface $container) { - $arrayAdapter = new ArrayAdapter(); - $arrayAdapter->setLogger(new ExceptionLogger()); - return new NamespaceFactory(new Psr16Cache($arrayAdapter)); - }, - GlobTypeMapper::class => static function (ContainerInterface $container) { - $arrayAdapter = new ArrayAdapter(); - $arrayAdapter->setLogger(new ExceptionLogger()); - return new GlobTypeMapper( - $container->get(NamespaceFactory::class)->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Types'), - $container->get(TypeGenerator::class), - $container->get(InputTypeGenerator::class), - $container->get(InputTypeUtils::class), - $container->get(BasicAutoWiringContainer::class), - $container->get(AnnotationReader::class), - $container->get(NamingStrategyInterface::class), - $container->get(RecursiveTypeMapperInterface::class), - new Psr16Cache($arrayAdapter), - ); - }, - // We use a second type mapper here so we can target the Models dir - GlobTypeMapper::class . '2' => static function (ContainerInterface $container) { - $arrayAdapter = new ArrayAdapter(); - $arrayAdapter->setLogger(new ExceptionLogger()); - return new GlobTypeMapper( - $container->get(NamespaceFactory::class)->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Models'), - $container->get(TypeGenerator::class), - $container->get(InputTypeGenerator::class), - $container->get(InputTypeUtils::class), - $container->get(BasicAutoWiringContainer::class), - $container->get(AnnotationReader::class), - $container->get(NamingStrategyInterface::class), - $container->get(RecursiveTypeMapperInterface::class), - new Psr16Cache($arrayAdapter), - ); - }, - PorpaginasTypeMapper::class => static function (ContainerInterface $container) { - return new PorpaginasTypeMapper($container->get(RecursiveTypeMapperInterface::class)); - }, - EnumTypeMapper::class => static function (ContainerInterface $container) { - return new EnumTypeMapper( - $container->get(RootTypeMapperInterface::class), - $container->get(AnnotationReader::class), - new ArrayAdapter(), - [ - $container->get(NamespaceFactory::class) - ->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures81\\Integration\\Models'), - ], - ); - }, - TypeGenerator::class => static function (ContainerInterface $container) { - return new TypeGenerator( - $container->get(AnnotationReader::class), - $container->get(NamingStrategyInterface::class), - $container->get(TypeRegistry::class), - $container->get(BasicAutoWiringContainer::class), - $container->get(RecursiveTypeMapperInterface::class), - $container->get(FieldsBuilder::class), - ); - }, - TypeRegistry::class => static function () { - return new TypeRegistry(); - }, - InputTypeGenerator::class => static function (ContainerInterface $container) { - return new InputTypeGenerator( - $container->get(InputTypeUtils::class), - $container->get(FieldsBuilder::class), - ); - }, - InputTypeUtils::class => static function (ContainerInterface $container) { - return new InputTypeUtils( - $container->get(AnnotationReader::class), - $container->get(NamingStrategyInterface::class), - ); - }, - AnnotationReader::class => static function (ContainerInterface $container) { - return new AnnotationReader(new DoctrineAnnotationReader()); - }, - NamingStrategyInterface::class => static function () { - return new NamingStrategy(); - }, - CachedDocBlockFactory::class => static function () { - $arrayAdapter = new ArrayAdapter(); - $arrayAdapter->setLogger(new ExceptionLogger()); - return new CachedDocBlockFactory(new Psr16Cache($arrayAdapter)); - }, - RootTypeMapperInterface::class => static function (ContainerInterface $container) { - return new VoidTypeMapper( - new NullableTypeMapperAdapter( - $container->get('topRootTypeMapper') - ) - ); - }, - 'topRootTypeMapper' => static function () { - return new LastDelegatingTypeMapper(); - }, - 'rootTypeMapper' => static function (ContainerInterface $container) { - // These are in reverse order of execution - $errorRootTypeMapper = new FinalRootTypeMapper($container->get(RecursiveTypeMapperInterface::class)); - $rootTypeMapper = new BaseTypeMapper($errorRootTypeMapper, $container->get(RecursiveTypeMapperInterface::class), $container->get(RootTypeMapperInterface::class)); - $rootTypeMapper = new MyCLabsEnumTypeMapper($rootTypeMapper, $container->get(AnnotationReader::class), new ArrayAdapter(), [$container->get(NamespaceFactory::class)->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Models')]); - if (interface_exists(UnitEnum::class)) { - $rootTypeMapper = new EnumTypeMapper($rootTypeMapper, $container->get(AnnotationReader::class), new ArrayAdapter(), [$container->get(NamespaceFactory::class)->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures81\\Integration\\Models')]); - } - $rootTypeMapper = new CompoundTypeMapper($rootTypeMapper, $container->get(RootTypeMapperInterface::class), $container->get(NamingStrategyInterface::class), $container->get(TypeRegistry::class), $container->get(RecursiveTypeMapperInterface::class)); - $rootTypeMapper = new IteratorTypeMapper($rootTypeMapper, $container->get(RootTypeMapperInterface::class)); - return $rootTypeMapper; - }, - ContainerParameterHandler::class => static function (ContainerInterface $container) { - return new ContainerParameterHandler($container, true, true); - }, - InjectUserParameterHandler::class => static function (ContainerInterface $container) { - return new InjectUserParameterHandler($container->get(AuthenticationServiceInterface::class)); - }, - 'testService' => static function () { - return 'foo'; - }, - stdClass::class => static function () { - // Empty test service for autowiring - return new stdClass(); - }, - ParameterMiddlewareInterface::class => static function (ContainerInterface $container) { - $parameterMiddlewarePipe = new ParameterMiddlewarePipe(); - $parameterMiddlewarePipe->pipe(new ResolveInfoParameterHandler()); - $parameterMiddlewarePipe->pipe($container->get(ContainerParameterHandler::class)); - $parameterMiddlewarePipe->pipe($container->get(InjectUserParameterHandler::class)); - - return $parameterMiddlewarePipe; - }, - ]; - - if (interface_exists(UnitEnum::class)) { - // Register another instance of GlobTypeMapper to process our PHP 8.1 enums and/or other - // 8.1 supported features. - $services[GlobTypeMapper::class . '3'] = static function (ContainerInterface $container) { - $arrayAdapter = new ArrayAdapter(); - $arrayAdapter->setLogger(new ExceptionLogger()); - return new GlobTypeMapper( - $container->get(NamespaceFactory::class)->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures81\\Integration\\Models'), - $container->get(TypeGenerator::class), - $container->get(InputTypeGenerator::class), - $container->get(InputTypeUtils::class), - $container->get(BasicAutoWiringContainer::class), - $container->get(AnnotationReader::class), - $container->get(NamingStrategyInterface::class), - $container->get(RecursiveTypeMapperInterface::class), - new Psr16Cache($arrayAdapter), - ); - }; - } - - $container = new LazyContainer($overloadedServices + $services); - $container->get(TypeResolver::class)->registerSchema($container->get(Schema::class)); - $container->get(TypeMapperInterface::class)->addTypeMapper($container->get(GlobTypeMapper::class)); - $container->get(TypeMapperInterface::class)->addTypeMapper($container->get(GlobTypeMapper::class . '2')); - if (interface_exists(UnitEnum::class)) { - $container->get(TypeMapperInterface::class)->addTypeMapper($container->get(GlobTypeMapper::class . '3')); - } - $container->get(TypeMapperInterface::class)->addTypeMapper($container->get(PorpaginasTypeMapper::class)); - - $container->get('topRootTypeMapper')->setNext($container->get('rootTypeMapper')); - /*$container->get(CompositeRootTypeMapper::class)->addRootTypeMapper(new CompoundTypeMapper($container->get(RootTypeMapperInterface::class), $container->get(TypeRegistry::class), $container->get(RecursiveTypeMapperInterface::class))); - $container->get(CompositeRootTypeMapper::class)->addRootTypeMapper(new IteratorTypeMapper($container->get(RootTypeMapperInterface::class), $container->get(TypeRegistry::class), $container->get(RecursiveTypeMapperInterface::class))); - $container->get(CompositeRootTypeMapper::class)->addRootTypeMapper(new IteratorTypeMapper($container->get(RootTypeMapperInterface::class), $container->get(TypeRegistry::class), $container->get(RecursiveTypeMapperInterface::class))); - $container->get(CompositeRootTypeMapper::class)->addRootTypeMapper(new MyCLabsEnumTypeMapper()); - $container->get(CompositeRootTypeMapper::class)->addRootTypeMapper(new BaseTypeMapper($container->get(RecursiveTypeMapperInterface::class), $container->get(RootTypeMapperInterface::class))); -*/ - return $container; - } - - private function getSuccessResult(ExecutionResult $result, int $debugFlag = DebugFlag::RETHROW_INTERNAL_EXCEPTIONS): mixed - { - $array = $result->toArray($debugFlag); - if (isset($array['errors']) || !isset($array['data'])) { - $this->fail('Expected a successful answer. Got ' . json_encode($array, JSON_PRETTY_PRINT)); - } - return $array['data']; - } - public function testEndToEnd(): void { $schema = $this->mainContainer->get(Schema::class); diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php new file mode 100644 index 0000000000..e9d843347a --- /dev/null +++ b/tests/Integration/IntegrationTestCase.php @@ -0,0 +1,393 @@ +mainContainer = $this->createContainer(); + } + + /** @param array $overloadedServices */ + public function createContainer(array $overloadedServices = []): ContainerInterface + { + $services = [ + Schema::class => static function (ContainerInterface $container) { + return new Schema($container->get(QueryProviderInterface::class), $container->get(RecursiveTypeMapperInterface::class), $container->get(TypeResolver::class), $container->get(RootTypeMapperInterface::class)); + }, + QueryProviderInterface::class => static function (ContainerInterface $container) { + $queryProvider = new GlobControllerQueryProvider( + 'TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Controllers', + $container->get(FieldsBuilder::class), + $container->get(BasicAutoWiringContainer::class), + $container->get(AnnotationReader::class), + new Psr16Cache(new ArrayAdapter()), + ); + + if (interface_exists(UnitEnum::class)) { + $queryProvider = new AggregateQueryProvider([ + $queryProvider, + new GlobControllerQueryProvider( + 'TheCodingMachine\\GraphQLite\\Fixtures81\\Integration\\Controllers', + $container->get(FieldsBuilder::class), + $container->get(BasicAutoWiringContainer::class), + $container->get(AnnotationReader::class), + new Psr16Cache(new ArrayAdapter()), + ), + ]); + } + return $queryProvider; + }, + FieldsBuilder::class => static function (ContainerInterface $container) { + $parameterMiddlewarePipe = $container->get(ParameterMiddlewareInterface::class); + $fieldsBuilder = new FieldsBuilder( + $container->get(AnnotationReader::class), + $container->get(RecursiveTypeMapperInterface::class), + $container->get(ArgumentResolver::class), + $container->get(TypeResolver::class), + $container->get(CachedDocBlockFactory::class), + $container->get(NamingStrategyInterface::class), + $container->get(RootTypeMapperInterface::class), + $parameterMiddlewarePipe, + $container->get(FieldMiddlewareInterface::class), + $container->get(InputFieldMiddlewareInterface::class), + ); + $parameterizedCallableResolver = new ParameterizedCallableResolver($fieldsBuilder, $container); + + $parameterMiddlewarePipe->pipe(new PrefetchParameterMiddleware($parameterizedCallableResolver)); + + return $fieldsBuilder; + }, + FieldMiddlewareInterface::class => static function (ContainerInterface $container) { + $pipe = new FieldMiddlewarePipe(); + $pipe->pipe($container->get(AuthorizationFieldMiddleware::class)); + $pipe->pipe($container->get(SecurityFieldMiddleware::class)); + $pipe->pipe($container->get(CostFieldMiddleware::class)); + return $pipe; + }, + InputFieldMiddlewareInterface::class => static function (ContainerInterface $container) { + $pipe = new InputFieldMiddlewarePipe(); + $pipe->pipe($container->get(AuthorizationInputFieldMiddleware::class)); + $pipe->pipe($container->get(SecurityInputFieldMiddleware::class)); + return $pipe; + }, + AuthorizationInputFieldMiddleware::class => static function (ContainerInterface $container) { + return new AuthorizationInputFieldMiddleware( + $container->get(AuthenticationServiceInterface::class), + $container->get(AuthorizationServiceInterface::class), + ); + }, + SecurityInputFieldMiddleware::class => static function (ContainerInterface $container) { + return new SecurityInputFieldMiddleware( + new ExpressionLanguage(new Psr16Adapter(new Psr16Cache(new ArrayAdapter())), [new SecurityExpressionLanguageProvider()]), + $container->get(AuthenticationServiceInterface::class), + $container->get(AuthorizationServiceInterface::class), + ); + }, + AuthorizationFieldMiddleware::class => static function (ContainerInterface $container) { + return new AuthorizationFieldMiddleware( + $container->get(AuthenticationServiceInterface::class), + $container->get(AuthorizationServiceInterface::class), + ); + }, + SecurityFieldMiddleware::class => static function (ContainerInterface $container) { + return new SecurityFieldMiddleware( + new ExpressionLanguage(new Psr16Adapter(new Psr16Cache(new ArrayAdapter())), [new SecurityExpressionLanguageProvider()]), + $container->get(AuthenticationServiceInterface::class), + $container->get(AuthorizationServiceInterface::class), + ); + }, + CostFieldMiddleware::class => fn () => new CostFieldMiddleware(), + ArgumentResolver::class => static function (ContainerInterface $container) { + return new ArgumentResolver(); + }, + TypeResolver::class => static function (ContainerInterface $container) { + return new TypeResolver(); + }, + BasicAutoWiringContainer::class => static function (ContainerInterface $container) { + return new BasicAutoWiringContainer(new EmptyContainer()); + }, + AuthorizationServiceInterface::class => static function (ContainerInterface $container) { + return new VoidAuthorizationService(); + }, + AuthenticationServiceInterface::class => static function (ContainerInterface $container) { + return new VoidAuthenticationService(); + }, + RecursiveTypeMapperInterface::class => static function (ContainerInterface $container) { + $arrayAdapter = new ArrayAdapter(); + $arrayAdapter->setLogger(new ExceptionLogger()); + return new RecursiveTypeMapper( + $container->get(TypeMapperInterface::class), + $container->get(NamingStrategyInterface::class), + new Psr16Cache($arrayAdapter), + $container->get(TypeRegistry::class), + $container->get(AnnotationReader::class), + ); + }, + TypeMapperInterface::class => static function (ContainerInterface $container) { + return new CompositeTypeMapper(); + }, + NamespaceFactory::class => static function (ContainerInterface $container) { + $arrayAdapter = new ArrayAdapter(); + $arrayAdapter->setLogger(new ExceptionLogger()); + return new NamespaceFactory(new Psr16Cache($arrayAdapter)); + }, + GlobTypeMapper::class => static function (ContainerInterface $container) { + $arrayAdapter = new ArrayAdapter(); + $arrayAdapter->setLogger(new ExceptionLogger()); + return new GlobTypeMapper( + $container->get(NamespaceFactory::class)->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Types'), + $container->get(TypeGenerator::class), + $container->get(InputTypeGenerator::class), + $container->get(InputTypeUtils::class), + $container->get(BasicAutoWiringContainer::class), + $container->get(AnnotationReader::class), + $container->get(NamingStrategyInterface::class), + $container->get(RecursiveTypeMapperInterface::class), + new Psr16Cache($arrayAdapter), + ); + }, + // We use a second type mapper here so we can target the Models dir + GlobTypeMapper::class . '2' => static function (ContainerInterface $container) { + $arrayAdapter = new ArrayAdapter(); + $arrayAdapter->setLogger(new ExceptionLogger()); + return new GlobTypeMapper( + $container->get(NamespaceFactory::class)->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Models'), + $container->get(TypeGenerator::class), + $container->get(InputTypeGenerator::class), + $container->get(InputTypeUtils::class), + $container->get(BasicAutoWiringContainer::class), + $container->get(AnnotationReader::class), + $container->get(NamingStrategyInterface::class), + $container->get(RecursiveTypeMapperInterface::class), + new Psr16Cache($arrayAdapter), + ); + }, + PorpaginasTypeMapper::class => static function (ContainerInterface $container) { + return new PorpaginasTypeMapper($container->get(RecursiveTypeMapperInterface::class)); + }, + EnumTypeMapper::class => static function (ContainerInterface $container) { + return new EnumTypeMapper( + $container->get(RootTypeMapperInterface::class), + $container->get(AnnotationReader::class), + new ArrayAdapter(), + [ + $container->get(NamespaceFactory::class) + ->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures81\\Integration\\Models'), + ], + ); + }, + TypeGenerator::class => static function (ContainerInterface $container) { + return new TypeGenerator( + $container->get(AnnotationReader::class), + $container->get(NamingStrategyInterface::class), + $container->get(TypeRegistry::class), + $container->get(BasicAutoWiringContainer::class), + $container->get(RecursiveTypeMapperInterface::class), + $container->get(FieldsBuilder::class), + ); + }, + TypeRegistry::class => static function () { + return new TypeRegistry(); + }, + InputTypeGenerator::class => static function (ContainerInterface $container) { + return new InputTypeGenerator( + $container->get(InputTypeUtils::class), + $container->get(FieldsBuilder::class), + ); + }, + InputTypeUtils::class => static function (ContainerInterface $container) { + return new InputTypeUtils( + $container->get(AnnotationReader::class), + $container->get(NamingStrategyInterface::class), + ); + }, + AnnotationReader::class => static function (ContainerInterface $container) { + return new AnnotationReader(new DoctrineAnnotationReader()); + }, + NamingStrategyInterface::class => static function () { + return new NamingStrategy(); + }, + CachedDocBlockFactory::class => static function () { + $arrayAdapter = new ArrayAdapter(); + $arrayAdapter->setLogger(new ExceptionLogger()); + return new CachedDocBlockFactory(new Psr16Cache($arrayAdapter)); + }, + RootTypeMapperInterface::class => static function (ContainerInterface $container) { + return new VoidTypeMapper( + new NullableTypeMapperAdapter( + $container->get('topRootTypeMapper') + ) + ); + }, + 'topRootTypeMapper' => static function () { + return new LastDelegatingTypeMapper(); + }, + 'rootTypeMapper' => static function (ContainerInterface $container) { + // These are in reverse order of execution + $errorRootTypeMapper = new FinalRootTypeMapper($container->get(RecursiveTypeMapperInterface::class)); + $rootTypeMapper = new BaseTypeMapper($errorRootTypeMapper, $container->get(RecursiveTypeMapperInterface::class), $container->get(RootTypeMapperInterface::class)); + $rootTypeMapper = new MyCLabsEnumTypeMapper($rootTypeMapper, $container->get(AnnotationReader::class), new ArrayAdapter(), [ $container->get(NamespaceFactory::class)->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Models') ]); + if (interface_exists(UnitEnum::class)) { + $rootTypeMapper = new EnumTypeMapper($rootTypeMapper, $container->get(AnnotationReader::class), new ArrayAdapter(), [ $container->get(NamespaceFactory::class)->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures81\\Integration\\Models') ]); + } + $rootTypeMapper = new CompoundTypeMapper($rootTypeMapper, $container->get(RootTypeMapperInterface::class), $container->get(NamingStrategyInterface::class), $container->get(TypeRegistry::class), $container->get(RecursiveTypeMapperInterface::class)); + $rootTypeMapper = new IteratorTypeMapper($rootTypeMapper, $container->get(RootTypeMapperInterface::class)); + return $rootTypeMapper; + }, + ContainerParameterHandler::class => static function (ContainerInterface $container) { + return new ContainerParameterHandler($container, true, true); + }, + InjectUserParameterHandler::class => static function (ContainerInterface $container) { + return new InjectUserParameterHandler($container->get(AuthenticationServiceInterface::class)); + }, + 'testService' => static function () { + return 'foo'; + }, + stdClass::class => static function () { + // Empty test service for autowiring + return new stdClass(); + }, + ParameterMiddlewareInterface::class => static function (ContainerInterface $container) { + $parameterMiddlewarePipe = new ParameterMiddlewarePipe(); + $parameterMiddlewarePipe->pipe(new ResolveInfoParameterHandler()); + $parameterMiddlewarePipe->pipe($container->get(ContainerParameterHandler::class)); + $parameterMiddlewarePipe->pipe($container->get(InjectUserParameterHandler::class)); + + return $parameterMiddlewarePipe; + }, + ]; + + if (interface_exists(UnitEnum::class)) { + // Register another instance of GlobTypeMapper to process our PHP 8.1 enums and/or other + // 8.1 supported features. + $services[GlobTypeMapper::class . '3'] = static function (ContainerInterface $container) { + $arrayAdapter = new ArrayAdapter(); + $arrayAdapter->setLogger(new ExceptionLogger()); + return new GlobTypeMapper( + $container->get(NamespaceFactory::class)->createNamespace('TheCodingMachine\\GraphQLite\\Fixtures81\\Integration\\Models'), + $container->get(TypeGenerator::class), + $container->get(InputTypeGenerator::class), + $container->get(InputTypeUtils::class), + $container->get(BasicAutoWiringContainer::class), + $container->get(AnnotationReader::class), + $container->get(NamingStrategyInterface::class), + $container->get(RecursiveTypeMapperInterface::class), + new Psr16Cache($arrayAdapter), + ); + }; + } + + $container = new LazyContainer($overloadedServices + $services); + $container->get(TypeResolver::class)->registerSchema($container->get(Schema::class)); + $container->get(TypeMapperInterface::class)->addTypeMapper($container->get(GlobTypeMapper::class)); + $container->get(TypeMapperInterface::class)->addTypeMapper($container->get(GlobTypeMapper::class . '2')); + if (interface_exists(UnitEnum::class)) { + $container->get(TypeMapperInterface::class)->addTypeMapper($container->get(GlobTypeMapper::class . '3')); + } + $container->get(TypeMapperInterface::class)->addTypeMapper($container->get(PorpaginasTypeMapper::class)); + + $container->get('topRootTypeMapper')->setNext($container->get('rootTypeMapper')); + /*$container->get(CompositeRootTypeMapper::class)->addRootTypeMapper(new CompoundTypeMapper($container->get(RootTypeMapperInterface::class), $container->get(TypeRegistry::class), $container->get(RecursiveTypeMapperInterface::class))); + $container->get(CompositeRootTypeMapper::class)->addRootTypeMapper(new IteratorTypeMapper($container->get(RootTypeMapperInterface::class), $container->get(TypeRegistry::class), $container->get(RecursiveTypeMapperInterface::class))); + $container->get(CompositeRootTypeMapper::class)->addRootTypeMapper(new IteratorTypeMapper($container->get(RootTypeMapperInterface::class), $container->get(TypeRegistry::class), $container->get(RecursiveTypeMapperInterface::class))); + $container->get(CompositeRootTypeMapper::class)->addRootTypeMapper(new MyCLabsEnumTypeMapper()); + $container->get(CompositeRootTypeMapper::class)->addRootTypeMapper(new BaseTypeMapper($container->get(RecursiveTypeMapperInterface::class), $container->get(RootTypeMapperInterface::class))); +*/ + return $container; + } + + protected function getSuccessResult(ExecutionResult $result, int $debugFlag = DebugFlag::RETHROW_INTERNAL_EXCEPTIONS): mixed + { + $array = $result->toArray($debugFlag); + if (isset($array['errors']) || ! isset($array['data'])) { + $this->fail('Expected a successful answer. Got ' . json_encode($array, JSON_PRETTY_PRINT)); + } + return $array['data']; + } +} \ No newline at end of file diff --git a/tests/Integration/QueryComplexityTest.php b/tests/Integration/QueryComplexityTest.php new file mode 100644 index 0000000000..c3af9d04f8 --- /dev/null +++ b/tests/Integration/QueryComplexityTest.php @@ -0,0 +1,201 @@ +mainContainer->get(Schema::class); + assert($schema instanceof Schema); + + $result = GraphQL::executeQuery( + $schema, + <<<'GRAPHQL' + query { + articles { + title + } + } + GRAPHQL, + validationRules: [ + ...DocumentValidator::allRules(), + new QueryComplexity(60), + ] + ); + + $this->assertSame([ + ['title' => 'Title'] + ], $this->getSuccessResult($result)['articles']); + } + + public function testExceedsAllowedQueryComplexity(): void + { + $schema = $this->mainContainer->get(Schema::class); + assert($schema instanceof Schema); + + $result = GraphQL::executeQuery( + $schema, + <<<'GRAPHQL' + query { + articles { + title + } + } + GRAPHQL, + validationRules: [ + ...DocumentValidator::allRules(), + new QueryComplexity(5), + ] + ); + + $this->assertSame('Max query complexity should be 5 but got 60.', $result->toArray(DebugFlag::RETHROW_UNSAFE_EXCEPTIONS)['errors'][0]['message']); + } + + /** + * @dataProvider calculatesCorrectQueryCostProvider + */ + public function testCalculatesCorrectQueryCost(int $expectedCost, string $query): void + { + $schema = $this->mainContainer->get(Schema::class); + assert($schema instanceof Schema); + + $result = GraphQL::executeQuery( + $schema, + $query, + validationRules: [ + ...DocumentValidator::allRules(), + new QueryComplexity(1), + ] + ); + + $this->assertSame('Max query complexity should be 1 but got ' . $expectedCost . '.', $result->toArray(DebugFlag::RETHROW_UNSAFE_EXCEPTIONS)['errors'][0]['message']); + } + + public static function calculatesCorrectQueryCostProvider(): iterable + { + yield [ + 60, // 10 articles * (5 in controller + 1 for title) + <<<'GRAPHQL' + query { + articles { + title + } + } + GRAPHQL, + ]; + + yield [ + 110, // 10 articles * (5 in controller + 1 for title + 5 for description) + <<<'GRAPHQL' + query { + articles { + title + comment + } + } + GRAPHQL, + ]; + + yield [ + 100, // 10 articles * (5 in controller + 1 for title + 3 for author + 1 for author name) + <<<'GRAPHQL' + query { + articles { + title + author { + name + } + } + } + GRAPHQL, + ]; + + yield [ + 18, // 3 articles * (5 in controller + 1 for title) + <<<'GRAPHQL' + query { + articles(take: 3) { + title + } + } + GRAPHQL, + ]; + + yield [ + 3000, // 500 articles default multiplier * (5 in controller + 1 for title) + <<<'GRAPHQL' + query { + articles(take: null) { + title + } + } + GRAPHQL, + ]; + } + + /** + * @dataProvider reportsQueryCostInIntrospectionProvider + */ + public function testReportsQueryCostInIntrospection(string|null $expectedDescription, string $typeName, string $fieldName): void + { + $schema = $this->mainContainer->get(Schema::class); + assert($schema instanceof Schema); + + $result = GraphQL::executeQuery( + $schema, + <<<'GRAPHQL' + query fieldDescription($type: String!) { + __type(name: $type) { + fields { + name + description + } + } + } + GRAPHQL, + variableValues: ['type' => $typeName], + ); + + $data = $result->toArray(DebugFlag::RETHROW_UNSAFE_EXCEPTIONS); + + $fieldsByName = array_filter($data['data']['__type']['fields'], fn (array $field) => $field['name'] === $fieldName); + $fieldByName = reset($fieldsByName); + + self::assertNotNull($fieldByName); + self::assertSame($expectedDescription, $fieldByName['description']); + } + + public static function reportsQueryCostInIntrospectionProvider(): iterable + { + yield [ + 'Cost: complexity = 5, multipliers = [take], defaultMultiplier = 500', + 'Query', + 'articles', + ]; + + yield [ + null, + 'Post', + 'title', + ]; + + yield [ + 'Cost: complexity = 5, multipliers = [], defaultMultiplier = null', + 'Post', + 'comment', + ]; + + yield [ + 'Cost: complexity = 3, multipliers = [], defaultMultiplier = null', + 'Post', + 'author', + ]; + } +} \ No newline at end of file diff --git a/tests/Mappers/Parameters/PrefetchParameterMiddlewareTest.php b/tests/Mappers/Parameters/PrefetchParameterMiddlewareTest.php index ea749d6de8..bc40294dbd 100644 --- a/tests/Mappers/Parameters/PrefetchParameterMiddlewareTest.php +++ b/tests/Mappers/Parameters/PrefetchParameterMiddlewareTest.php @@ -51,6 +51,7 @@ public function testMapsToPrefetchDataParameter(): void { $parameterizedCallableResolver = $this->createMock(ParameterizedCallableResolver::class); $parameterizedCallableResolver + ->expects($this->once()) ->method('resolve') ->with('dummy', new IsEqual(new ReflectionClass(self::class)), 1) ->willReturn([ @@ -83,6 +84,7 @@ public function testRethrowsInvalidCallableAsInvalidPrefetchException(): void $parameterizedCallableResolver = $this->createMock(ParameterizedCallableResolver::class); $parameterizedCallableResolver + ->expects($this->once()) ->method('resolve') ->with([TestType::class, 'notExists'], new IsEqual(new ReflectionClass(self::class)), 1) ->willThrowException(InvalidCallableRuntimeException::methodNotFound(TestType::class, 'notExists')); diff --git a/tests/Middlewares/CostFieldMiddlewareTest.php b/tests/Middlewares/CostFieldMiddlewareTest.php new file mode 100644 index 0000000000..863fbdb89e --- /dev/null +++ b/tests/Middlewares/CostFieldMiddlewareTest.php @@ -0,0 +1,163 @@ +stubField(); + + $result = (new CostFieldMiddleware())->process($this->stubDescriptor([]), $this->stubFieldHandler($field)); + + self::assertSame($field, $result); + } + + public function testIgnoresNullField(): void + { + $result = (new CostFieldMiddleware())->process($this->stubDescriptor([]), $this->stubFieldHandler(null)); + + self::assertNull($result); + } + + /** + * @dataProvider setsComplexityFunctionProvider + */ + public function testSetsComplexityFunction(int $expectedComplexity, Cost $cost): void + { + $field = $this->stubField(); + + $result = (new CostFieldMiddleware())->process($this->stubDescriptor([$cost]), $this->stubFieldHandler($field)); + + self::assertNotNull($result->complexityFn); + + $resultComplexity = ($result->complexityFn)(8, [ + 'take' => 10, + 'null' => null, + ]); + + self::assertSame($expectedComplexity, $resultComplexity); + } + + public static function setsComplexityFunctionProvider(): iterable + { + yield 'default 1 + children 8 #1' => [9, new Cost()]; + yield 'default 1 + children 8 #2' => [9, new Cost(defaultMultiplier: 100)]; + yield 'default 1 + children 8 #3' => [9, new Cost(multipliers: ['null'])]; + yield 'default 1 + children 8 #4' => [9, new Cost(multipliers: ['missing'])]; + + yield 'set 3 + children 8 #1' => [11, new Cost(complexity: 3)]; + yield 'set 3 + children 8 #2' => [11, new Cost(complexity: 3, defaultMultiplier: 100)]; + yield 'set 3 + children 8 #3' => [11, new Cost(complexity: 3, multipliers: ['null'])]; + yield 'set 3 + children 8 #4' => [11, new Cost(complexity: 3, multipliers: ['missing'])]; + + yield 'take 10 * (default 1 + children 8) #1' => [90, new Cost(multipliers: ['take'])]; + yield 'take 10 * (default 1 + children 8) #2' => [90, new Cost(multipliers: ['take'], defaultMultiplier: 100)]; + yield 'take 10 * (default 1 + children 8) #3' => [90, new Cost(multipliers: ['take', 'null'])]; + yield 'take 10 * (default 1 + children 8) #4' => [90, new Cost(multipliers: ['take', 'null'], defaultMultiplier: 100)]; + + yield 'take 10 * (set 3 + children 8) #1' => [110, new Cost(complexity: 3, multipliers: ['take'])]; + yield 'take 10 * (set 3 + children 8) #2' => [110, new Cost(complexity: 3, multipliers: ['take'], defaultMultiplier: 100)]; + yield 'take 10 * (set 3 + children 8) #3' => [110, new Cost(complexity: 3, multipliers: ['take', 'null'])]; + yield 'take 10 * (set 3 + children 8) #4' => [110, new Cost(complexity: 3, multipliers: ['take', 'null'], defaultMultiplier: 100)]; + + yield 'default multiplier 100 * (default 1 + children 8) #1' => [900, new Cost(multipliers: ['null'], defaultMultiplier: 100)]; + yield 'default multiplier 100 * (default 1 + children 8) #2' => [900, new Cost(multipliers: ['missing'], defaultMultiplier: 100)]; + yield 'default multiplier 100 * (default 1 + children 8) #3' => [900, new Cost(multipliers: ['null', 'missing'], defaultMultiplier: 100)]; + + } + + /** + * @dataProvider addsCostInDescriptionProvider + */ + public function testAddsCostInDescription(string $expectedDescription, Cost $cost): void + { + if (Version::series() === '8.5') { + $this->markTestSkipped('Broken on PHPUnit 8.'); + } + + $queryFieldDescriptor = $this->createMock(QueryFieldDescriptor::class); + $queryFieldDescriptor->method('getMiddlewareAnnotations') + ->willReturn(new MiddlewareAnnotations([$cost])); + $queryFieldDescriptor->expects($this->once()) + ->method('withAddedCommentLines') + ->with($expectedDescription) + ->willReturnSelf(); + + (new CostFieldMiddleware())->process($queryFieldDescriptor, $this->stubFieldHandler(null)); + } + + public static function addsCostInDescriptionProvider(): iterable + { + yield [ + 'Cost: complexity = 1, multipliers = [], defaultMultiplier = null', + new Cost(), + ]; + + yield [ + 'Cost: complexity = 5, multipliers = [take], defaultMultiplier = 500', + new Cost(complexity: 5, multipliers: ['take'], defaultMultiplier: 500) + ]; + + yield [ + 'Cost: complexity = 5, multipliers = [take, null], defaultMultiplier = null', + new Cost(complexity: 5, multipliers: ['take', 'null'], defaultMultiplier: null) + ]; + } + + /** + * @param MiddlewareAnnotationInterface[] $annotations + */ + private function stubDescriptor(array $annotations): QueryFieldDescriptor + { + $descriptor = new QueryFieldDescriptor( + name: 'foo', + type: Type::string(), + middlewareAnnotations: new MiddlewareAnnotations($annotations), + ); + $descriptor = $descriptor->withResolver(fn () => self::fail('Should not be called.')); + + return $descriptor; + } + + private function stubFieldHandler(FieldDefinition|null $field): FieldHandlerInterface + { + return new class ($field) implements FieldHandlerInterface { + public function __construct(private readonly FieldDefinition|null $field) + { + } + + public function handle(QueryFieldDescriptor $fieldDescriptor): FieldDefinition|null + { + return $this->field; + } + }; + } + + private function stubField(): FieldDefinition + { + return new FieldDefinition([ + 'name' => 'test', + 'resolve' => function () {}, + ]); + } +} diff --git a/tests/ParameterizedCallableResolverTest.php b/tests/ParameterizedCallableResolverTest.php index dbe8eeb086..f1643fb99e 100644 --- a/tests/ParameterizedCallableResolverTest.php +++ b/tests/ParameterizedCallableResolverTest.php @@ -59,7 +59,8 @@ public function testResolveReturnsCallableAndParametersFromContainer(): void ->willReturn($expectedParameters); $container = $this->createMock(ContainerInterface::class); - $container->method('get') + $container->expects($this->once()) + ->method('get') ->with(FooExtendType::class) ->willReturn(new FooExtendType()); diff --git a/tests/QueryFieldDescriptorTest.php b/tests/QueryFieldDescriptorTest.php index 7e623df62f..fa42c373da 100644 --- a/tests/QueryFieldDescriptorTest.php +++ b/tests/QueryFieldDescriptorTest.php @@ -69,4 +69,25 @@ public function testExceptionInGetOriginalResolver(): void $this->expectException(GraphQLRuntimeException::class); $descriptor->getOriginalResolver(); } + + /** + * @dataProvider withAddedCommentLineProvider + */ + public function testWithAddedCommentLine(string $expected, string|null $previous, string $added): void + { + $descriptor = (new QueryFieldDescriptor( + 'test', + Type::string(), + comment: $previous, + ))->withAddedCommentLines($added); + + self::assertSame($expected, $descriptor->getComment()); + } + + public static function withAddedCommentLineProvider(): iterable + { + yield ['', null, '']; + yield ['Asd', null, 'Asd']; + yield ["Some comment\nAsd", 'Some comment', 'Asd']; + } } diff --git a/website/docs/annotations-reference.md b/website/docs/annotations-reference.md index 334b2864d9..0a1c872661 100644 --- a/website/docs/annotations-reference.md +++ b/website/docs/annotations-reference.md @@ -251,6 +251,16 @@ Attribute | Compulsory | Type | Definition ---------------|------------|------|-------- *for* | *yes* | string | The name of the PHP parameter to hide +## @Cost + +Sets complexity and multipliers on fields for [automatic query complexity](operation-complexity.md#static-request-analysis). + +Attribute | Compulsory | Type | Definition +--------------------|------------|-----------------|----------------------------------------------------------------- +*complexity* | *no* | int | Complexity for that field +*multipliers* | *no* | array\ | Names of fields by value of which complexity will be multiplied +*defaultMultiplier* | *no* | int | Default multiplier value if all multipliers are missing/null + ## @Validate
This annotation is only available in the GraphQLite Laravel package
diff --git a/website/docs/operation-complexity.md b/website/docs/operation-complexity.md new file mode 100644 index 0000000000..bf946721ee --- /dev/null +++ b/website/docs/operation-complexity.md @@ -0,0 +1,223 @@ +--- +id: operation-complexity +title: Operation complexity +sidebar_label: Operation complexity +--- + +At some point you may find yourself receiving queries with an insane amount of requested +fields or items, all at once. Usually, it's not a good thing, so you may want to somehow +limit the amount of requests or their individual complexity. + +## Query depth + +The simplest way to limit complexity is to limit the max query depth. `webonyx/graphql-php`, +which GraphQLite relies on, [has this built in](https://webonyx.github.io/graphql-php/security/#limiting-query-depth). +To use it, you may use `addValidationRule` when building your PSR15 middleware: + +```php +$builder = new Psr15GraphQLMiddlewareBuilder($schema); + +$builder->addValidationRule(new \GraphQL\Validator\Rules\QueryDepth(7)); +``` + +Although this works for simple cases, this doesn't prevent requesting an excessive amount +of fields on the depth of under 7, nor does it prevent requesting too many nodes in paginated lists. +This is where automatic query complexity comes to save us. + +## Static request analysis + +The operation complexity analyzer is a useful tool to make your API secure. The operation +complexity analyzer assigns by default every field a complexity of `1`. The complexity of +all fields in one of the operations of a GraphQL request is not allowed to be greater +than the maximum permitted operation complexity. + +This sounds fairly simple at first, but the more you think about this, the more you +wonder if that is so. Does every field have the same complexity? + +In a data graph, not every field is the same. We have fields that fetch data that are +more expensive than fields that just complete already resolved data. + +```graphql +type Query { + books(take: Int = 10): [Book] +} + +type Book { + title + author: Author +} + +type Author { + name +} +``` + +In the above example executing the `books` field on the `Query` type might go to the +database and fetch the `Book`. This means that the cost of the `books` field is +probably higher than the cost of the `title` field. The cost of the title field +might be the impact on the memory and to the transport. For `title`, the default +cost of `1` os OK. But for `books`, we might want to go with a higher cost of `10` +since we are getting a list of books from our database. + +Moreover, we have the field `author` on the book, which might go to the database +as well to fetch the `Author` object. Since we are only fetching a single item here, +we might want to apply a cost of `5` to this field. + +```php +class Controller { + /** + * @return Book[] + */ + #[Query] + #[Cost(complexity: 10)] + public function books(int $take = 10): array {} +} + +#[Type] +class Book { + #[Field] + public string $title; + + #[Field] + #[Cost(complexity: 5)] + public Author $author; +} + +#[Type] +class Author { + #[Field] + public string $name; +} +``` + +If we run the following query against our data graph, we will come up with the cost of `11`. + +```graphql +query { + books { + title + } +} +``` + +When drilling in further, a cost of `17` occurs. + +```graphql +query { + books { + title + author { + name + } + } +} +``` + +This kind of analysis is entirely static and could just be done by inspecting the +query syntax tree. The impact on the overall execution performance is very low. +But with this static approach, we do have a very rough idea of the performance. +Is it correct to apply always a cost of `10` even though we might get one or one +hundred books back? + +## Full request analysis + +The operation complexity analyzer can also take arguments into account when analyzing operation complexity. + +If we look at our data graph, we can see that the `books` field actually has an argument +that defines how many books are returned. The `take` argument, in this case, specifies +the maximum books that the field will return. + +When measuring the field\`s impact, we can take the argument `take` into account as a +multiplier of our cost. This means we might want to lower the cost to `5` since now we +get a more fine-grained cost calculation by multiplying the complexity +of the field with the `take` argument. + +```php +class Controller { + /** + * @return Book[] + */ + #[Query] + #[Cost(complexity: 5, multipliers: ['take'], defaultMultiplier: 200)] + public function books(?int $take = 10): array {} +} + +#[Type] +class Book { + #[Field] + public string $title; + + #[Field] + #[Cost(complexity: 5)] + public Author $author; +} + +#[Type] +class Author { + #[Field] + public string $name; +} +``` + +With the multiplier in place, we now get a cost of `60` for the request since the multiplier +is applied to the books field and the child fields' cost. If multiple multipliers are specified, +the cost will be multiplied by each of the fields. + +Cost calculation: `10 * (5 + 1)` + +```graphql +query { + books { + title + } +} +``` + +When drilling in further, the cost will go up to `240` since we are now pulling twice as much books and also their authors. + +Cost calculation: `20 * (5 + 1 + 5 + 1)` + +```graphql +query { + books(take: 20) { + title + author { + name + } + } +} +``` + +Notice the nullable `$take` parameter. This might come in handy if `take: null` means "get all items", +but that would also mean that the overall complexity would only be `1 + 5 + 1 + 5 + 1 = 11`, +when in fact that would be a very costly query to execute. + +If all of the multiplier fields are either `null` or missing (and don't have default values), +`defaultMultiplier` is used: + +Cost calculation: `200 * (5 + 1 + 5 + 1)` + +```graphql +query { + books(take: null) { + title + author { + name + } + } +} +``` + +## Setup + +As with query depth, automatic query complexity is configured through PSR15 middleware: + +```php +$builder = new Psr15GraphQLMiddlewareBuilder($schema); + +// Total query cost cannot exceed 1000 points +$builder->limitQueryComplexity(1000); +``` + +Beware that introspection queries would also be limited in complexity. A full introspection +query sits at around `107` points, so we recommend a minimum of `150` for query complexity limit. diff --git a/website/sidebars.json b/website/sidebars.json index 10bea7aae7..68ec666d7f 100755 --- a/website/sidebars.json +++ b/website/sidebars.json @@ -25,7 +25,8 @@ "Security": [ "authentication-authorization", "fine-grained-security", - "implementing-security" + "implementing-security", + "operation-complexity" ], "Performance": [ "query-plan",