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: 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 + } } } 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..b81f6a8eae 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).'); @@ -426,6 +449,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); + } } 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..0f190e1f66 100644 --- a/website/docs/input-types.mdx +++ b/website/docs/input-types.mdx @@ -109,7 +109,194 @@ 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). +- 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 + +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 +645,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..ee1a0e93a9 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\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. + +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. 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}; };