From efa27c3adbf5236717bf38f7046f81cd57950678 Mon Sep 17 00:00:00 2001 From: Jacob Thomason Date: Thu, 13 Jan 2022 18:52:55 -0500 Subject: [PATCH 01/12] Due to typing that exists in numerous files, PHP 7.4 is required --- composer.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 880145d16b..34436fc37c 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,7 @@ } ], "require": { - "php": ">=7.2", + "php": ">=7.4", "ext-json": "*", "doctrine/annotations": "^1.13", "composer/package-versions-deprecated": "^1.8", @@ -67,5 +67,12 @@ "branch-alias": { "dev-master": "5.0.x-dev" } + }, + "config": { + "allow-plugins": { + "composer/package-versions-deprecated": true, + "dealerdirect/phpcodesniffer-composer-installer": true, + "phpstan/extension-installer": true + } } } From 9411f6bf237c62b2efa6b655d8def066ad58a574 Mon Sep 17 00:00:00 2001 From: Jacob Thomason Date: Thu, 13 Jan 2022 18:58:44 -0500 Subject: [PATCH 02/12] PHPUnit 9.3 introduced configuration changes - updated config and required version --- composer.json | 2 +- phpunit.xml.dist | 55 ++++++++++++++++++++++++------------------------ 2 files changed, 29 insertions(+), 28 deletions(-) diff --git a/composer.json b/composer.json index 34436fc37c..2e17b0d89a 100644 --- a/composer.json +++ b/composer.json @@ -40,7 +40,7 @@ "phpstan/extension-installer": "^1.1", "phpstan/phpstan": "^0.12.94", "phpstan/phpstan-webmozart-assert": "^0.12.15", - "phpunit/phpunit": "^8.5.19||^9.5.8", + "phpunit/phpunit": "^9.3", "thecodingmachine/phpstan-strict-rules": "^0.12.1" }, "suggest": { diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 489d69899f..fd78cc5c7e 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,32 +1,33 @@ - - - ./tests/ - ./tests/Bootstrap.php - - - - - - src/ - - - - - - + + + src/ + + + + + + + + + + ./tests/ + ./tests/Bootstrap.php + + + From c15d012372bf8e7c162db71092ec2c9d75fec1fd Mon Sep 17 00:00:00 2001 From: Jacob Thomason Date: Thu, 13 Jan 2022 19:12:18 -0500 Subject: [PATCH 03/12] Implemented an InputTypeValidatorInterface --- src/FactoryContext.php | 10 ++ src/InputTypeGenerator.php | 16 ++- src/SchemaFactory.php | 113 +++++++++++------- src/Types/InputType.php | 19 ++- src/Types/InputTypeValidatorInterface.php | 26 ++++ tests/FactoryContextTest.php | 6 +- tests/Fixtures/Inputs/ValidationException.php | 16 +++ tests/Fixtures/Inputs/Validator.php | 33 +++++ tests/Integration/EndToEndTest.php | 49 +++++++- 9 files changed, 235 insertions(+), 53 deletions(-) create mode 100644 src/Types/InputTypeValidatorInterface.php create mode 100644 tests/Fixtures/Inputs/ValidationException.php create mode 100644 tests/Fixtures/Inputs/Validator.php diff --git a/src/FactoryContext.php b/src/FactoryContext.php index 8235fe8a5a..857bad7348 100644 --- a/src/FactoryContext.php +++ b/src/FactoryContext.php @@ -7,6 +7,7 @@ use Psr\Container\ContainerInterface; use Psr\SimpleCache\CacheInterface; use TheCodingMachine\GraphQLite\Mappers\RecursiveTypeMapperInterface; +use TheCodingMachine\GraphQLite\Types\InputTypeValidatorInterface; use TheCodingMachine\GraphQLite\Types\TypeResolver; /** @@ -36,6 +37,8 @@ final class FactoryContext private $container; /** @var CacheInterface */ private $cache; + /** @var InputTypeValidatorInterface|null */ + private $inputTypeValidator; /** @var int|null */ private $globTTL; /** @var int|null */ @@ -52,6 +55,7 @@ public function __construct( RecursiveTypeMapperInterface $recursiveTypeMapper, ContainerInterface $container, CacheInterface $cache, + ?InputTypeValidatorInterface $inputTypeValidator, ?int $globTTL, ?int $mapTTL = null ) { @@ -65,6 +69,7 @@ public function __construct( $this->recursiveTypeMapper = $recursiveTypeMapper; $this->container = $container; $this->cache = $cache; + $this->inputTypeValidator = $inputTypeValidator; $this->globTTL = $globTTL; $this->mapTTL = $mapTTL; } @@ -119,6 +124,11 @@ public function getCache(): CacheInterface return $this->cache; } + public function getInputTypeValidator(): ?InputTypeValidatorInterface + { + return $this->inputTypeValidator; + } + public function getGlobTTL(): ?int { return $this->globTTL; diff --git a/src/InputTypeGenerator.php b/src/InputTypeGenerator.php index f27033d42d..5f5d93358e 100644 --- a/src/InputTypeGenerator.php +++ b/src/InputTypeGenerator.php @@ -9,6 +9,7 @@ use ReflectionFunctionAbstract; use ReflectionMethod; use TheCodingMachine\GraphQLite\Types\InputType; +use TheCodingMachine\GraphQLite\Types\InputTypeValidatorInterface; use TheCodingMachine\GraphQLite\Types\ResolvableMutableInputInterface; use TheCodingMachine\GraphQLite\Types\ResolvableMutableInputObjectType; use Webmozart\Assert\Assert; @@ -28,13 +29,17 @@ class InputTypeGenerator private $inputTypeUtils; /** @var FieldsBuilder */ private $fieldsBuilder; + /** @var InputTypeValidatorInterface|null */ + private $inputTypeValidator; public function __construct( InputTypeUtils $inputTypeUtils, - FieldsBuilder $fieldsBuilder + FieldsBuilder $fieldsBuilder, + ?InputTypeValidatorInterface $inputTypeValidator = null ) { $this->inputTypeUtils = $inputTypeUtils; $this->fieldsBuilder = $fieldsBuilder; + $this->inputTypeValidator = $inputTypeValidator; } public function mapFactoryMethod(string $factory, string $methodName, ContainerInterface $container): ResolvableMutableInputObjectType @@ -63,7 +68,14 @@ public function mapFactoryMethod(string $factory, string $methodName, ContainerI public function mapInput(string $className, string $inputName, ?string $description, bool $isUpdate): InputType { if (! isset($this->inputCache[$inputName])) { - $this->inputCache[$inputName] = new InputType($className, $inputName, $description, $isUpdate, $this->fieldsBuilder); + $this->inputCache[$inputName] = new InputType( + $className, + $inputName, + $description, + $isUpdate, + $this->fieldsBuilder, + $this->inputTypeValidator + ); } return $this->inputCache[$inputName]; diff --git a/src/SchemaFactory.php b/src/SchemaFactory.php index b80e066fe8..934d982b86 100644 --- a/src/SchemaFactory.php +++ b/src/SchemaFactory.php @@ -47,6 +47,7 @@ use TheCodingMachine\GraphQLite\Security\FailAuthorizationService; use TheCodingMachine\GraphQLite\Security\SecurityExpressionLanguageProvider; use TheCodingMachine\GraphQLite\Types\ArgumentResolver; +use TheCodingMachine\GraphQLite\Types\InputTypeValidatorInterface; use TheCodingMachine\GraphQLite\Types\TypeResolver; use TheCodingMachine\GraphQLite\Utils\NamespacedCache; use TheCodingMachine\GraphQLite\Utils\Namespaces\NamespaceFactory; @@ -63,48 +64,61 @@ */ class SchemaFactory { + public const GLOB_CACHE_SECONDS = 2; + /** @var string[] */ - private $controllerNamespaces = []; + private array $controllerNamespaces = []; + /** @var string[] */ - private $typeNamespaces = []; + private array $typeNamespaces = []; + /** @var QueryProviderInterface[] */ - private $queryProviders = []; + private array $queryProviders = []; + /** @var QueryProviderFactoryInterface[] */ - private $queryProviderFactories = []; + private array $queryProviderFactories = []; + /** @var RootTypeMapperFactoryInterface[] */ - private $rootTypeMapperFactories = []; + private array $rootTypeMapperFactories = []; + /** @var TypeMapperInterface[] */ - private $typeMappers = []; + private array $typeMappers = []; + /** @var TypeMapperFactoryInterface[] */ - private $typeMapperFactories = []; + private array $typeMapperFactories = []; + /** @var ParameterMiddlewareInterface[] */ - private $parameterMiddlewares = []; - /** @var Reader */ - private $doctrineAnnotationReader; - /** @var AuthenticationServiceInterface|null */ - private $authenticationService; - /** @var AuthorizationServiceInterface|null */ - private $authorizationService; - /** @var CacheInterface */ - private $cache; - /** @var NamingStrategyInterface|null */ - private $namingStrategy; - /** @var ContainerInterface */ - private $container; - /** @var ClassNameMapper */ - private $classNameMapper; - /** @var SchemaConfig */ - private $schemaConfig; - /** @var int|null */ - private $globTTL = self::GLOB_CACHE_SECONDS; + private array $parameterMiddlewares = []; + + private ?Reader $doctrineAnnotationReader = null; + + private ?AuthenticationServiceInterface $authenticationService = null; + + private ?AuthorizationServiceInterface $authorizationService = null; + + private ?InputTypeValidatorInterface $inputTypeValidator = null; + + private CacheInterface $cache; + + private ?NamingStrategyInterface $namingStrategy = null; + + private ContainerInterface $container; + + private ?ClassNameMapper $classNameMapper = null; + + private ?SchemaConfig $schemaConfig = null; + + private ?int $globTTL = self::GLOB_CACHE_SECONDS; + /** @var array */ - private $fieldMiddlewares = []; - /** @var ExpressionLanguage|null */ - private $expressionLanguage; - /** @var string */ - private $cacheNamespace; + private array $fieldMiddlewares = []; + + private ?ExpressionLanguage $expressionLanguage = null; + + private string $cacheNamespace; + public function __construct(CacheInterface $cache, ContainerInterface $container) { @@ -229,6 +243,14 @@ public function setAuthorizationService(AuthorizationServiceInterface $authoriza return $this; } + public function setInputTypeValidator(?InputTypeValidatorInterface $inputTypeValidator): self + { + $this->inputTypeValidator = $inputTypeValidator; + + return $this; + } + + public function setNamingStrategy(NamingStrategyInterface $namingStrategy): self { $this->namingStrategy = $namingStrategy; @@ -305,20 +327,21 @@ public function setExpressionLanguage(ExpressionLanguage $expressionLanguage): s public function createSchema(): Schema { - $symfonyCache = new Psr16Adapter($this->cache, $this->cacheNamespace); - $annotationReader = new AnnotationReader($this->getDoctrineAnnotationReader($symfonyCache), AnnotationReader::LAX_MODE); - $authenticationService = $this->authenticationService ?: new FailAuthenticationService(); - $authorizationService = $this->authorizationService ?: new FailAuthorizationService(); - $typeResolver = new TypeResolver(); - $namespacedCache = new NamespacedCache($this->cache); - $cachedDocBlockFactory = new CachedDocBlockFactory($namespacedCache); - $namingStrategy = $this->namingStrategy ?: new NamingStrategy(); - $typeRegistry = new TypeRegistry(); + $symfonyCache = new Psr16Adapter($this->cache, $this->cacheNamespace); + $annotationReader = new AnnotationReader($this->getDoctrineAnnotationReader($symfonyCache), AnnotationReader::LAX_MODE); + $authenticationService = $this->authenticationService ?: new FailAuthenticationService(); + $authorizationService = $this->authorizationService ?: new FailAuthorizationService(); + $typeResolver = new TypeResolver(); + $namespacedCache = new NamespacedCache($this->cache); + $cachedDocBlockFactory = new CachedDocBlockFactory($namespacedCache); + $namingStrategy = $this->namingStrategy ?: new NamingStrategy(); + $typeRegistry = new TypeRegistry(); $namespaceFactory = new NamespaceFactory($namespacedCache, $this->classNameMapper, $this->globTTL); - $nsList = array_map(static function (string $namespace) use ($namespaceFactory) { - return $namespaceFactory->createNamespace($namespace); - }, $this->typeNamespaces); + $nsList = array_map( + static fn (string $namespace) => $namespaceFactory->createNamespace($namespace), + $this->typeNamespaces + ); $expressionLanguage = $this->expressionLanguage ?: new ExpressionLanguage($symfonyCache); $expressionLanguage->registerProvider(new SecurityExpressionLanguageProvider()); @@ -389,7 +412,7 @@ public function createSchema(): Schema $typeGenerator = new TypeGenerator($annotationReader, $namingStrategy, $typeRegistry, $this->container, $recursiveTypeMapper, $fieldsBuilder); $inputTypeUtils = new InputTypeUtils($annotationReader, $namingStrategy); - $inputTypeGenerator = new InputTypeGenerator($inputTypeUtils, $fieldsBuilder); + $inputTypeGenerator = new InputTypeGenerator($inputTypeUtils, $fieldsBuilder, $this->inputTypeValidator); if (empty($this->typeNamespaces) && empty($this->typeMappers)) { throw new GraphQLRuntimeException('Cannot create schema: no namespace for types found (You must call the SchemaFactory::addTypeNamespace() at least once).'); @@ -406,6 +429,7 @@ public function createSchema(): Schema $namingStrategy, $recursiveTypeMapper, $namespacedCache, + $this->inputTypeValidator, $this->globTTL )); } @@ -426,6 +450,7 @@ public function createSchema(): Schema $recursiveTypeMapper, $this->container, $namespacedCache, + $this->inputTypeValidator, $this->globTTL ); } diff --git a/src/Types/InputType.php b/src/Types/InputType.php index 4189c0b79a..108a6b2e63 100644 --- a/src/Types/InputType.php +++ b/src/Types/InputType.php @@ -22,16 +22,24 @@ class InputType extends MutableInputObjectType implements ResolvableMutableInputInterface { /** @var InputTypeProperty[] */ - private $fields; + private array $fields; /** @var class-string */ private $className; + private ?InputTypeValidatorInterface $inputTypeValidator; + /** * @param class-string $className */ - public function __construct(string $className, string $inputName, ?string $description, bool $isUpdate, FieldsBuilder $fieldsBuilder) - { + public function __construct( + string $className, + string $inputName, + ?string $description, + bool $isUpdate, + FieldsBuilder $fieldsBuilder, + ?InputTypeValidatorInterface $inputTypeValidator = null + ) { $reflection = new ReflectionClass($className); if (! $reflection->isInstantiable()) { throw FailedResolvingInputType::createForNotInstantiableClass($className); @@ -71,6 +79,7 @@ public function __construct(string $className, string $inputName, ?string $descr parent::__construct($config); $this->className = $className; + $this->inputTypeValidator = $inputTypeValidator; } /** @@ -96,6 +105,10 @@ public function resolve(?object $source, array $args, $context, ResolveInfo $res PropertyAccessor::setValue($instance, $property, $value); } + if ($this->inputTypeValidator && $this->inputTypeValidator->isEnabled()) { + $this->inputTypeValidator->validate($instance); + } + return $instance; } diff --git a/src/Types/InputTypeValidatorInterface.php b/src/Types/InputTypeValidatorInterface.php new file mode 100644 index 0000000000..7ee5b9308f --- /dev/null +++ b/src/Types/InputTypeValidatorInterface.php @@ -0,0 +1,26 @@ + + */ +interface InputTypeValidatorInterface +{ + /** + * Checks to see if the Validator is currently enabled. + */ + public function isEnabled(): bool; + + /** + * Performs the validation of the InputType. + * + * @param object $input The input type object to validate + */ + public function validate(object $input): void; +} diff --git a/tests/FactoryContextTest.php b/tests/FactoryContextTest.php index 50744eae73..c6baf1022e 100644 --- a/tests/FactoryContextTest.php +++ b/tests/FactoryContextTest.php @@ -2,11 +2,10 @@ namespace TheCodingMachine\GraphQLite; -use PHPUnit\Framework\TestCase; use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\Cache\Psr16Cache; -use Symfony\Component\Cache\Simple\ArrayCache; use TheCodingMachine\GraphQLite\Containers\EmptyContainer; +use TheCodingMachine\GraphQLite\Fixtures\Inputs\Validator; class FactoryContextTest extends AbstractQueryProviderTest { @@ -17,6 +16,7 @@ public function testContext(): void $namingStrategy = new NamingStrategy(); $container = new EmptyContainer(); $arrayCache = new Psr16Cache(new ArrayAdapter()); + $validator = new Validator(); $context = new FactoryContext( $this->getAnnotationReader(), @@ -29,6 +29,7 @@ public function testContext(): void $this->getTypeMapper(), $container, $arrayCache, + $validator, self::GLOB_TTL_SECONDS ); @@ -42,6 +43,7 @@ public function testContext(): void $this->assertSame($this->getTypeMapper(), $context->getRecursiveTypeMapper()); $this->assertSame($container, $context->getContainer()); $this->assertSame($arrayCache, $context->getCache()); + $this->assertSame($validator, $context->getInputTypeValidator()); $this->assertSame(self::GLOB_TTL_SECONDS, $context->getGlobTTL()); $this->assertNull($context->getMapTTL()); } diff --git a/tests/Fixtures/Inputs/ValidationException.php b/tests/Fixtures/Inputs/ValidationException.php new file mode 100644 index 0000000000..c02e6ab929 --- /dev/null +++ b/tests/Fixtures/Inputs/ValidationException.php @@ -0,0 +1,16 @@ + + */ +class ValidationException extends GraphQLException +{ +} diff --git a/tests/Fixtures/Inputs/Validator.php b/tests/Fixtures/Inputs/Validator.php new file mode 100644 index 0000000000..1e9b9978d2 --- /dev/null +++ b/tests/Fixtures/Inputs/Validator.php @@ -0,0 +1,33 @@ + + */ +class Validator implements InputTypeValidatorInterface +{ + + private bool $isEnabled; + + public function __construct(bool $isEnabled = true) + { + $this->isEnabled = $isEnabled; + } + + public function isEnabled(): bool + { + return $this->isEnabled; + } + + public function validate(object $input): void + { + throw new ValidationException('Validation failed'); + } +} diff --git a/tests/Integration/EndToEndTest.php b/tests/Integration/EndToEndTest.php index a1b0fbe50a..3b43c05b38 100644 --- a/tests/Integration/EndToEndTest.php +++ b/tests/Integration/EndToEndTest.php @@ -51,6 +51,8 @@ use TheCodingMachine\GraphQLite\QueryProviderInterface; use TheCodingMachine\GraphQLite\Containers\BasicAutoWiringContainer; use TheCodingMachine\GraphQLite\Containers\EmptyContainer; +use TheCodingMachine\GraphQLite\Fixtures\Inputs\ValidationException; +use TheCodingMachine\GraphQLite\Fixtures\Inputs\Validator; use TheCodingMachine\GraphQLite\Reflection\CachedDocBlockFactory; use TheCodingMachine\GraphQLite\Schema; use TheCodingMachine\GraphQLite\SchemaFactory; @@ -773,14 +775,13 @@ public function testEndToEnd2Iterators(): void } count } - products { items { name price unauthorized } - count + count } } '; @@ -1904,4 +1905,48 @@ public function testEndToEndInputAnnotationIssues(): void $this->expectExceptionMessage("Could not set value for property 'TheCodingMachine\GraphQLite\Fixtures\Integration\Models\Post::inaccessible'. Either make the property public or add a public setter for it like this: 'setInaccessible'"); $result->toArray(DebugFlag::RETHROW_INTERNAL_EXCEPTIONS); } + + public function testEndToEndInputTypeValidation(): void + { + $validator = new Validator(); + + $container = $this->createContainer([ + InputTypeGenerator::class => function (ContainerInterface $container) use ($validator) { + return new InputTypeGenerator( + $container->get(InputTypeUtils::class), + $container->get(FieldsBuilder::class), + $validator + ); + }, + ]); + + $arrayAdapter = new ArrayAdapter(); + $arrayAdapter->setLogger(new ExceptionLogger()); + $schemaFactory = new SchemaFactory(new Psr16Cache($arrayAdapter), new BasicAutoWiringContainer(new EmptyContainer())); + $schemaFactory->addControllerNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Controllers'); + $schemaFactory->addTypeNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Models'); + $schemaFactory->addTypeNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\Integration\\Types'); + $schemaFactory->setAuthenticationService($container->get(AuthenticationServiceInterface::class)); + $schemaFactory->setAuthorizationService($container->get(AuthorizationServiceInterface::class)); + $schemaFactory->setInputTypeValidator($validator); + + $schema = $schemaFactory->createSchema(); + + // Test any mutation, we just need a trigger an InputType to be resolved + $queryString = ' + mutation { + createArticle( + article: { + title: "Old Man and the Sea" + } + ) { + title + } + } + '; + + $this->expectException(ValidationException::class); + $result = GraphQL::executeQuery($schema, $queryString); + $result->toArray(DebugFlag::RETHROW_INTERNAL_EXCEPTIONS); + } } From 59cbae44949ff43ffcf235a8c2376663a2b0a7c9 Mon Sep 17 00:00:00 2001 From: Jacob Thomason Date: Thu, 13 Jan 2022 19:15:21 -0500 Subject: [PATCH 04/12] Removed assignment of InputTypeValidator to GlobTypeMapper --- src/SchemaFactory.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/SchemaFactory.php b/src/SchemaFactory.php index 934d982b86..b81f6a8eae 100644 --- a/src/SchemaFactory.php +++ b/src/SchemaFactory.php @@ -429,7 +429,6 @@ public function createSchema(): Schema $namingStrategy, $recursiveTypeMapper, $namespacedCache, - $this->inputTypeValidator, $this->globTTL )); } From dde4f9c5df2d4114a90eae7cfd96be513739a7e3 Mon Sep 17 00:00:00 2001 From: Jacob Thomason Date: Fri, 14 Jan 2022 01:33:24 -0500 Subject: [PATCH 05/12] Upgraded Docusaurus version --- website/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/package.json b/website/package.json index 7ad804e1f9..7ff552123a 100644 --- a/website/package.json +++ b/website/package.json @@ -9,8 +9,8 @@ "devDependencies": { }, "dependencies": { - "@docusaurus/core": "2.0.0-beta.8", - "@docusaurus/preset-classic": "2.0.0-beta.8", + "@docusaurus/core": "2.0.0-beta.14", + "@docusaurus/preset-classic": "2.0.0-beta.14", "clsx": "^1.1.1", "mdx-mermaid": "^1.1.0", "mermaid": "^8.12.0", From 1fca504f4a682685042d0a25c5fa5f19582c05c3 Mon Sep 17 00:00:00 2001 From: Jacob Thomason Date: Fri, 14 Jan 2022 01:59:55 -0500 Subject: [PATCH 06/12] Added and updated documentation --- website/docs/CHANGELOG.md | 2 +- website/docs/input-types.mdx | 372 ++++++++++++++++++----------------- website/docs/validation.mdx | 80 ++++++-- 3 files changed, 254 insertions(+), 200 deletions(-) diff --git a/website/docs/CHANGELOG.md b/website/docs/CHANGELOG.md index 3fcc2cc8db..2e98e0ac9d 100644 --- a/website/docs/CHANGELOG.md +++ b/website/docs/CHANGELOG.md @@ -43,7 +43,7 @@ public function toGraphQLInputType(Type $type, ?InputType $subType, string $argu #### New features: -- [@Input](annotations-reference.md#input-annotation) annotation is introduced as an alternative to `@Factory`. Now GraphQL input type can be created in the same manner as `@Type` in combination with `@Field` - [example](input-types.mdx#input-annotation). +- [@Input](annotations-reference.md#input-annotation) annotation is introduced as an alternative to `@Factory`. Now GraphQL input type can be created in the same manner as `@Type` in combination with `@Field` - [example](input-types.mdx#input-attribute). - New attributes has been added to [@Field](annotations-reference.md#field-annotation) annotation: `for`, `inputType` and `description`. - The following annotations now can be applied to class properties directly: `@Field`, `@Logged`, `@Right`, `@FailWith`, `@HideIfUnauthorized` and `@Security`. diff --git a/website/docs/input-types.mdx b/website/docs/input-types.mdx index 3695eaf5d2..7c95f60feb 100644 --- a/website/docs/input-types.mdx +++ b/website/docs/input-types.mdx @@ -109,7 +109,193 @@ You are running into this error because GraphQLite does not know how to handle t In GraphQL, an object passed in parameter of a query or mutation (or any field) is called an **Input Type**. -There are two ways for declaring that type, in GraphQLite: using **Factory** or annotating the class with `@Input`. +There are two ways for declaring that type, in GraphQLite: using the [`#[Input]` attribute](input-attribute) or a [Factory method](factory). + +## #\[Input\] Attribute + +Using the `#[Input]` attribute, we can transform the `Location` class, in the example above, into an input type. Just add the `#[Field]` attribute to the corresponding properties: + + + + +```php +#[Input] +class Location +{ + + #[Field] + private float $latitude; + + #[Field] + private float $longitude; + + public function __construct(float $latitude, float $longitude) + { + $this->latitude = $latitude; + $this->longitude = $longitude; + } + + public function getLatitude(): float + { + return $this->latitude; + } + + public function getLongitude(): float + { + return $this->longitude; + } +} +``` + + + + +```php +/** + * @Input + */ +class Location +{ + + /** + * @Field + * @var float + */ + private $latitude; + + /** + * @Field + * @var float + */ + private $longitude; + + public function __construct(float $latitude, float $longitude) + { + $this->latitude = $latitude; + $this->longitude = $longitude; + } + + public function getLatitude(): float + { + return $this->latitude; + } + + public function getLongitude(): float + { + return $this->longitude; + } +} +``` + + + + +Now if you call the `getCities` query, from the controller in the first example, the `Location` object will be automatically instantiated with the user provided, `latitude` / `longitude` properties, and passed to the controller as a parameter. + +There are some important things to notice: + +- The `@Field` annotation is recognized only on properties for Input Type, not methods. +- There are 3 ways for fields to be resolved: + - Via constructor if corresponding properties are mentioned as parameters with the same names - exactly as in the example above. + - If properties are public, they will be just set without any additional effort - no constructor required. + - For private or protected properties implemented, a public setter is required (if they are not set via the constructor). For example `setLatitude(float $latitude)`. + - For validation of these Input Types, see the [Custom InputType Validation section](validation#custom-inputtype-validation). + +### Multiple Input Types from the same class + +Simple usage of the `@Input` annotation on a class creates a GraphQL input named by class name + "Input" suffix if a class name does not end with it already. Ex. `LocationInput` for `Location` class. + +You can add multiple `@Input` annotations to the same class, give them different names and link different fields. +Consider the following example: + + + + +```php +#[Input(name: 'CreateUserInput', default: true)] +#[Input(name: 'UpdateUserInput', update: true)] +class UserInput +{ + + #[Field] + public string $username; + + #[Field(for: 'CreateUserInput')] + public string $email; + + #[Field(for: 'CreateUserInput', inputType: 'String!')] + #[Field(for: 'UpdateUserInput', inputType: 'String')] + public string $password; + + #[Field] + public ?int $age; +} +``` + + + + +```php +/** + * @Input(name="CreateUserInput", default=true) + * @Input(name="UpdateUserInput", update=true) + */ +class UserInput +{ + + /** + * @Field() + * @var string + */ + public $username; + + /** + * @Field(for="CreateUserInput") + * @var string + */ + public string $email; + + /** + * @Field(for="CreateUserInput", inputType="String!") + * @Field(for="UpdateUserInput", inputType="String") + * @var string|null + */ + public $password; + + /** + * @Field() + * @var int|null + */ + public $age; +} +``` + + + + +There are 2 input types added to the `UserInput` class: `CreateUserInput` and `UpdateUserInput`. A few notes: +- `CreateUserInput` input will be used by default for this class. +- Field `username` is created for both input types, and it is required because the property type is not nullable. +- Field `email` will appear only for `CreateUserInput` input. +- Field `password` will appear for both. For `CreateUserInput` it'll be the required field and for `UpdateUserInput` optional. +- Field `age` is optional for both input types. + +Note that `update: true` argument for `UpdateUserInput`. It should be used when input type is used for a partial update, +It makes all fields optional and removes all default values from thus prevents setting default values via setters or directly to public properties. +In example above if you use the class as `UpdateUserInput` and set only `username` the other ones will be ignored. +In PHP 7 they will be set to `null`, while in PHP 8 they will be in not initialized state - this can be used as a trick +to check if user actually passed a value for a certain field. ## Factory @@ -458,187 +644,3 @@ public function getProductById(string $id, bool $lazyLoad = true): Product With the `@HideParameter` annotation, you can choose to remove from the GraphQL schema any argument. To be able to hide an argument, the argument must have a default value. - -## @Input Annotation - -Let's transform `Location` class into an input type by adding `@Input` annotation to it and `@Field` annotation to corresponding properties: - - - - -```php -#[Input] -class Location -{ - - #[Field] - private float $latitude; - - #[Field] - private float $longitude; - - public function __construct(float $latitude, float $longitude) - { - $this->latitude = $latitude; - $this->longitude = $longitude; - } - - public function getLatitude(): float - { - return $this->latitude; - } - - public function getLongitude(): float - { - return $this->longitude; - } -} -``` - - - - -```php -/** - * @Input - */ -class Location -{ - - /** - * @Field - * @var float - */ - private $latitude; - - /** - * @Field - * @var float - */ - private $longitude; - - public function __construct(float $latitude, float $longitude) - { - $this->latitude = $latitude; - $this->longitude = $longitude; - } - - public function getLatitude(): float - { - return $this->latitude; - } - - public function getLongitude(): float - { - return $this->longitude; - } -} -``` - - - - -Now if you call `getCities()` query you can pass the location input in the same way as with factories. -The `Location` object will be automatically instantiated with provided `latitude` / `longitude` and passed to the controller as a parameter. - -There are some important things to notice: - -- `@Field` annotation is recognized only on properties for Input Type. -- There are 3 ways for fields to be resolved: - - Via constructor if corresponding properties are mentioned as parameters with the same names - exactly as in the example above. - - If properties are public, they will be just set without any additional effort. - - For private or protected properties implemented public setter is required (if they are not set via constructor). For example `setLatitude(float $latitude)`. - -### Multiple input types per one class - -Simple usage of `@Input` annotation on a class creates an GraphQl input named by class name + "Input" suffix if a class name does not end with it already. -You can add multiple `@Input` annotations to the same class, give them different names and link different fields. -Consider the following example: - - - - -```php -#[Input(name: 'CreateUserInput', default: true)] -#[Input(name: 'UpdateUserInput', update: true)] -class UserInput -{ - - #[Field] - public string $username; - - #[Field(for: 'CreateUserInput')] - public string $email; - - #[Field(for: 'CreateUserInput', inputType: 'String!')] - #[Field(for: 'UpdateUserInput', inputType: 'String')] - public string $password; - - #[Field] - public ?int $age; -} -``` - - - - -```php -/** - * @Input(name="CreateUserInput", default=true) - * @Input(name="UpdateUserInput", update=true) - */ -class UserInput -{ - - /** - * @Field() - * @var string - */ - public $username; - - /** - * @Field(for="CreateUserInput") - * @var string - */ - public string $email; - - /** - * @Field(for="CreateUserInput", inputType="String!") - * @Field(for="UpdateUserInput", inputType="String") - * @var string|null - */ - public $password; - - /** - * @Field() - * @var int|null - */ - public $age; -} -``` - - - - -There are 2 input types created for just one class: `CreateUserInput` and `UpdateUserInput`. A few notes: -- `CreateUserInput` input will be used by default for this class. -- Field `username` is created for both input types, and it is required because the property type is not nullable. -- Field `email` will appear only for `CreateUserInput` input. -- Field `password` will appear for both. For `CreateUserInput` it'll be the required field and for `UpdateUserInput` optional. -- Field `age` is optional for both input types. - -Note that `update: true` argument for `UpdateUserInput`. It should be used when input type is used for a partial update, -It makes all fields optional and removes all default values from thus prevents setting default values via setters or directly to public properties. -In example above if you use the class as `UpdateUserInput` and set only `username` the other ones will be ignored. -In PHP 7 they will be set to `null`, while in PHP 8 they will be in not initialized state - this can be used as a trick -to check if user actually passed a value for a certain field. diff --git a/website/docs/validation.mdx b/website/docs/validation.mdx index c747164f42..8a675c1da9 100644 --- a/website/docs/validation.mdx +++ b/website/docs/validation.mdx @@ -39,7 +39,8 @@ using the `Validator` object. values={[ {label: 'PHP 8', value: 'php8'}, {label: 'PHP 7', value: 'php7'}, - ]}> + ]} +> ```php title="UserController.php" @@ -117,7 +118,8 @@ Validation rules are added directly to the object in the domain model: values={[ {label: 'PHP 8', value: 'php8'}, {label: 'PHP 7', value: 'php7'}, - ]}> + ]} +> ```php title="User.php" @@ -183,16 +185,16 @@ If a validation fails, GraphQLite will return the failed validations in the "err ```json { - "errors": [ - { - "message": "The email '\"foo@thisdomaindoesnotexistatall.com\"' is not a valid email.", - "extensions": { - "code": "bf447c1c-0266-4e10-9c6c-573df282e413", - "field": "email", - "category": "Validate" - } - } - ] + "errors": [ + { + "message": "The email '\"foo@thisdomaindoesnotexistatall.com\"' is not a valid email.", + "extensions": { + "code": "bf447c1c-0266-4e10-9c6c-573df282e413", + "field": "email", + "category": "Validate" + } + } + ] } ``` @@ -204,9 +206,11 @@ the last chapter. It is a best practice to put your validation layer as close as If the data entered by the user is **not** mapped to an object, you can directly annotate your query, mutation, factory... -
You generally don't want to do this. It is a best practice to put your validation constraints +
+ You generally don't want to do this. It is a best practice to put your validation constraints on your domain objects. Only use this technique if you want to validate user input and user input will not be stored -in a domain object.
+in a domain object. +
Use the `@Assertion` annotation to validate directly the user input. @@ -233,3 +237,51 @@ You can also pass an array to the `constraint` parameter: ```
Heads up! The "@Assertion" annotation is only available as a Doctrine annotations. You cannot use it as a PHP 8 attributes
+ +## Custom InputType Validation + +GraphQLite also supports a fully custom validation implementation for all input types defined with an `@Input` annotation or PHP8 `#[Input]` attribute. This offers a way to validate input types before they're available as a method parameter of your query and mutation controllers. This way, when you're using your query or mutation controllers, you can feel confident that your input type objects have already been validated. + +
+

It's important to note that this validation implementation does not validate input types created with a factory. If you are creating an input type with a factory, or using primitive parameters in your query/mutation controllers, you should be sure to validate these independently. This is strictly for input type objects.

+ +

You can use one of the framework validation libraries listed above or implement your own validation for these cases. If you're using input type objects for most all of your query and mutation controllers, then there is little additional validation concerns with regards to user input. There are many reasons why you should consider defaulting to an InputType object, as opposed to individual arguments, for your queries and mutations. This is just one additional perk.

+
+ +To get started with validation on input types defined by an `@Input` annotation, you'll first need to register your validator with the `SchemaFactory`. + +```php +$factory = new SchemaFactory($cache, $this->container); +$factory->addControllerNamespace('App\\Controllers'); +$factory->addTypeNamespace('App'); +// Register your validator +$factory->setInputTypeValidator($this->container->get('your_validator')); +$factory->createSchema(); +``` + +Your input type validator must implement the `TheCodingMachine\GraphQLite\Types\InputTypeValidatorInterface`, as shown below: + +```php +interface InputTypeValidatorInterface +{ + /** + * Checks to see if the Validator is currently enabled. + */ + public function isEnabled(): bool; + + /** + * Performs the validation of the InputType. + * + * @param object $input The input type object to validate + */ + public function validate(object $input): void; +} +``` + +The interface is quite simple. Handle all of your own validation logic in the `validate` method. For example, you might use Symfony's annotation based validation in addition to some other custom validation logic. It's really up to you on how you wish to handle your own validation. The `validate` method will receive the input type object populated with the user input. + +You'll notice that the `validate` method has a `void` return. The purpose here is to encourage you to throw an Exception or handle validation output however you best see fit. GraphQLite does it's best to stay out of your way and doesn't make attempts to handle validation output. You can, however, throw an instance of `TheCodingMachine\GraphQLite\Exceptions\GraphQLAggregateException` or `TheCodingMachine\GraphQLite\Exceptions\GraphQLAggregateException` as usual (see [Error Handling](error-handling) for more details). + +Also available is the `isEnabled` method. This method is checked before executing validation on an InputType being resolved. You can work out your own logic to selectively enable or disable validation through this method. In most cases, you can simply return `true` to keep it always enabled. + +And that's it, now, anytime an input type is resolved, the validator will be executed on that input type immediately after it has been hydrated with user input. From 99b8f0153f979700955073a0112932c06d41d33f Mon Sep 17 00:00:00 2001 From: Jacob Thomason Date: Fri, 14 Jan 2022 02:20:06 -0500 Subject: [PATCH 07/12] Remove PHP 7.2 and 7.3 from CI workflows --- .github/workflows/continuous_integration.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index 8dd971d5a6..6317e94e62 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -18,7 +18,7 @@ jobs: strategy: matrix: install-args: ['', '--prefer-lowest'] - php-version: ['7.2', '7.3', '7.4', '8.0'] + php-version: ['7.4', '8.0'] fail-fast: false steps: From 8763682f5e717cbf5c4e02bfc85cb5678c490aad Mon Sep 17 00:00:00 2001 From: Jacob Thomason Date: Fri, 14 Jan 2022 02:25:33 -0500 Subject: [PATCH 08/12] Revert "PHPUnit 9.3 introduced configuration changes - updated config and required version" This reverts commit 9411f6bf237c62b2efa6b655d8def066ad58a574. --- composer.json | 2 +- phpunit.xml.dist | 55 ++++++++++++++++++++++++------------------------ 2 files changed, 28 insertions(+), 29 deletions(-) diff --git a/composer.json b/composer.json index 2e17b0d89a..34436fc37c 100644 --- a/composer.json +++ b/composer.json @@ -40,7 +40,7 @@ "phpstan/extension-installer": "^1.1", "phpstan/phpstan": "^0.12.94", "phpstan/phpstan-webmozart-assert": "^0.12.15", - "phpunit/phpunit": "^9.3", + "phpunit/phpunit": "^8.5.19||^9.5.8", "thecodingmachine/phpstan-strict-rules": "^0.12.1" }, "suggest": { diff --git a/phpunit.xml.dist b/phpunit.xml.dist index fd78cc5c7e..489d69899f 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,33 +1,32 @@ - - - src/ - - - - - - - - - - ./tests/ - ./tests/Bootstrap.php - - - + + + ./tests/ + ./tests/Bootstrap.php + + + + + + src/ + + + + + + From 19d66eb81166115648f0f3be483c8da0a4701b27 Mon Sep 17 00:00:00 2001 From: Jacob Thomason Date: Fri, 14 Jan 2022 02:55:57 -0500 Subject: [PATCH 09/12] Revert Docusaurus version --- website/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/package.json b/website/package.json index 7ff552123a..7ad804e1f9 100644 --- a/website/package.json +++ b/website/package.json @@ -9,8 +9,8 @@ "devDependencies": { }, "dependencies": { - "@docusaurus/core": "2.0.0-beta.14", - "@docusaurus/preset-classic": "2.0.0-beta.14", + "@docusaurus/core": "2.0.0-beta.8", + "@docusaurus/preset-classic": "2.0.0-beta.8", "clsx": "^1.1.1", "mdx-mermaid": "^1.1.0", "mermaid": "^8.12.0", From a532bc94f0f7bfa66d8bb96c7212db28c33347bc Mon Sep 17 00:00:00 2001 From: Jacob Thomason Date: Fri, 14 Jan 2022 03:40:58 -0500 Subject: [PATCH 10/12] Upgraded Docusaurus: https://github.com/facebook/docusaurus/issues/6337 --- website/package.json | 4 ++-- website/src/pages/index.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/website/package.json b/website/package.json index 7ad804e1f9..7ff552123a 100644 --- a/website/package.json +++ b/website/package.json @@ -9,8 +9,8 @@ "devDependencies": { }, "dependencies": { - "@docusaurus/core": "2.0.0-beta.8", - "@docusaurus/preset-classic": "2.0.0-beta.8", + "@docusaurus/core": "2.0.0-beta.14", + "@docusaurus/preset-classic": "2.0.0-beta.14", "clsx": "^1.1.1", "mdx-mermaid": "^1.1.0", "mermaid": "^8.12.0", diff --git a/website/src/pages/index.js b/website/src/pages/index.js index c555e223a9..fffbc7c2d3 100644 --- a/website/src/pages/index.js +++ b/website/src/pages/index.js @@ -5,8 +5,8 @@ export default () => { if (typeof window !== "undefined") { window.location.href = "/docs"; } - + // Rendering the Layout helps keep the page from jumping, it takes a minute for the // location to change - return ; + return {null}; }; From 75a1a1265b50550508b8a52ba449b0e581c290ed Mon Sep 17 00:00:00 2001 From: Jacob Thomason Date: Fri, 14 Jan 2022 15:18:23 -0500 Subject: [PATCH 11/12] Improved documentation --- website/docs/input-types.mdx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/website/docs/input-types.mdx b/website/docs/input-types.mdx index 7c95f60feb..0f190e1f66 100644 --- a/website/docs/input-types.mdx +++ b/website/docs/input-types.mdx @@ -204,7 +204,8 @@ There are some important things to notice: - Via constructor if corresponding properties are mentioned as parameters with the same names - exactly as in the example above. - If properties are public, they will be just set without any additional effort - no constructor required. - For private or protected properties implemented, a public setter is required (if they are not set via the constructor). For example `setLatitude(float $latitude)`. - - For validation of these Input Types, see the [Custom InputType Validation section](validation#custom-inputtype-validation). +- For validation of these Input Types, see the [Custom InputType Validation section](validation#custom-inputtype-validation). +- We advise using the `#[Input]` attribute on DTO style input type objects and not directly on your model objects. Using it on your model objects can cause coupling in undesirable ways. ### Multiple Input Types from the same class From c9fe25440bc8af659bdb413baab4aa657b917f7f Mon Sep 17 00:00:00 2001 From: Jacob Thomason Date: Sat, 12 Feb 2022 21:07:36 -0500 Subject: [PATCH 12/12] Duplicate class name typo --- website/docs/validation.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/docs/validation.mdx b/website/docs/validation.mdx index 8a675c1da9..ee1a0e93a9 100644 --- a/website/docs/validation.mdx +++ b/website/docs/validation.mdx @@ -280,7 +280,7 @@ interface InputTypeValidatorInterface The interface is quite simple. Handle all of your own validation logic in the `validate` method. For example, you might use Symfony's annotation based validation in addition to some other custom validation logic. It's really up to you on how you wish to handle your own validation. The `validate` method will receive the input type object populated with the user input. -You'll notice that the `validate` method has a `void` return. The purpose here is to encourage you to throw an Exception or handle validation output however you best see fit. GraphQLite does it's best to stay out of your way and doesn't make attempts to handle validation output. You can, however, throw an instance of `TheCodingMachine\GraphQLite\Exceptions\GraphQLAggregateException` or `TheCodingMachine\GraphQLite\Exceptions\GraphQLAggregateException` as usual (see [Error Handling](error-handling) for more details). +You'll notice that the `validate` method has a `void` return. The purpose here is to encourage you to throw an Exception or handle validation output however you best see fit. GraphQLite does it's best to stay out of your way and doesn't make attempts to handle validation output. You can, however, throw an instance of `TheCodingMachine\GraphQLite\Exceptions\GraphQLException` or `TheCodingMachine\GraphQLite\Exceptions\GraphQLAggregateException` as usual (see [Error Handling](error-handling) for more details). Also available is the `isEnabled` method. This method is checked before executing validation on an InputType being resolved. You can work out your own logic to selectively enable or disable validation through this method. In most cases, you can simply return `true` to keep it always enabled.