diff --git a/.github/workflows/continuous_integration.yml b/.github/workflows/continuous_integration.yml index 38803d54df..5ec5bc42d6 100644 --- a/.github/workflows/continuous_integration.yml +++ b/.github/workflows/continuous_integration.yml @@ -31,7 +31,7 @@ jobs: - name: "Install PHP with extensions" uses: "shivammathur/setup-php@v2" with: - coverage: "pcov" + coverage: "xdebug" php-version: "${{ matrix.php-version }}" tools: composer:v2 diff --git a/composer.json b/composer.json index db976321fe..03304feb0f 100644 --- a/composer.json +++ b/composer.json @@ -34,7 +34,7 @@ "phpunit/phpunit": "^8.2.4||^9.4", "php-coveralls/php-coveralls": "^2.1", "mouf/picotainer": "^1.1", - "phpstan/phpstan": "^0.12.25", + "phpstan/phpstan": "^0.12.82", "beberlei/porpaginas": "^1.2", "myclabs/php-enum": "^1.6.6", "doctrine/coding-standard": "^8.2", @@ -66,7 +66,7 @@ }, "extra": { "branch-alias": { - "dev-master": "4.1.x-dev" + "dev-master": "4.2.x-dev" } } } diff --git a/docs/annotations_reference.md b/docs/annotations_reference.md index 8a966e6833..3d3eaedcc5 100644 --- a/docs/annotations_reference.md +++ b/docs/annotations_reference.md @@ -55,16 +55,35 @@ name | see below | string | The targeted GraphQL output type. One and only one of "class" and "name" parameter can be passed at the same time. +## @Input annotation + +The `@Input` annotation is used to declare a GraphQL input type. + +**Applies on**: classes. + +Attribute | Compulsory | Type | Definition +---------------|------------|--------|-------- +name | *no* | string | The name of the GraphQL input type generated. If not passed, the name of the class with suffix "Input" is used. If the class ends with "Input", the "Input" suffix is not added. +description | *no* | string | Description of the input type in the documentation. If not passed, PHP doc comment is used. +default | *no* | bool | Defaults to *true* if name is not specified. Whether the annotated PHP class should be mapped by default to this type. +update | *no* | bool | Determines if the the input represents a partial update. When set to *true* all input fields will become optional and won't have default values thus won't be set on resolve if they are not specified in the query/mutation. + + ## @Field annotation The `@Field` annotation is used to declare a GraphQL field. -**Applies on**: methods of classes annotated with `@Type` or `@ExtendType`. +**Applies on**: methods or properties of classes annotated with `@Type`, `@ExtendType` or `@Input`. +When it's applied on private or protected property, public getter or/and setter method is expected in the class accordingly +whether it's used for output type or input type. For example if property name is `foo` then getter should be `getFoo()` or setter should be `setFoo($foo)`. Setter can be omitted if property related to the field is present in the constructor with the same name. -Attribute | Compulsory | Type | Definition ----------------|------------|------|-------- -name | *no* | string | The name of the field. If skipped, the name of the method is used instead. -[outputType](custom_types.md) | *no* | string | Forces the GraphQL output type of a query. +Attribute | Compulsory | Type | Definition +------------------------------|------------|---------------|-------- +name | *no* | string | The name of the field. If skipped, the name of the method is used instead. +for | *no* | string, array | Forces the field to be used only for specific output or input type(s). By default field is used for all possible declared types. +description | *no* | string | Field description displayed in the GraphQL docs. If it's empty PHP doc comment is used instead. +[outputType](custom_types.md) | *no* | string | Forces the GraphQL output type of a query. +[inputType](input_types.md) | *no* | string | Forces the GraphQL input type of a query. ## @SourceField annotation @@ -100,7 +119,7 @@ annotations | *no* | array | A set of annotations that ap The `@Logged` annotation is used to declare a Query/Mutation/Field is only visible to logged users. -**Applies on**: methods annotated with `@Query`, `@Mutation` or `@Field`. +**Applies on**: methods or properties annotated with `@Query`, `@Mutation` or `@Field`. This annotation allows no attributes. @@ -108,7 +127,7 @@ This annotation allows no attributes. The `@Right` annotation is used to declare a Query/Mutation/Field is only visible to users with a specific right. -**Applies on**: methods annotated with `@Query`, `@Mutation` or `@Field`. +**Applies on**: methods or properties annotated with `@Query`, `@Mutation` or `@Field`. Attribute | Compulsory | Type | Definition ---------------|------------|------|-------- @@ -119,7 +138,7 @@ name | *yes* | string | The name of the right. The `@FailWith` annotation is used to declare a default value to return in the user is not authorized to see a specific query / mutation / field (according to the `@Logged` and `@Right` annotations). -**Applies on**: methods annotated with `@Query`, `@Mutation` or `@Field` and one of `@Logged` or `@Right` annotations. +**Applies on**: methods or properties annotated with `@Query`, `@Mutation` or `@Field` and one of `@Logged` or `@Right` annotations. Attribute | Compulsory | Type | Definition ---------------|------------|------|-------- @@ -130,7 +149,7 @@ value | *yes* | mixed | The value to return if the user is not au The `@HideIfUnauthorized` annotation is used to completely hide the query / mutation / field if the user is not authorized to access it (according to the `@Logged` and `@Right` annotations). -**Applies on**: methods annotated with `@Query`, `@Mutation` or `@Field` and one of `@Logged` or `@Right` annotations. +**Applies on**: methods or properties annotated with `@Query`, `@Mutation` or `@Field` and one of `@Logged` or `@Right` annotations. `@HideIfUnauthorized` and `@FailWith` are mutually exclusive. @@ -152,7 +171,7 @@ It is very flexible: it allows you to pass an expression that can contains custo See [the fine grained security page](fine-grained-security.md) for more details. -**Applies on**: methods annotated with `@Query`, `@Mutation` or `@Field`. +**Applies on**: methods or properties annotated with `@Query`, `@Mutation` or `@Field`. Attribute | Compulsory | Type | Definition ---------------|------------|--------|-------- diff --git a/docs/input_types.md b/docs/input_types.md index 41fd64703c..d60ed3c98a 100644 --- a/docs/input_types.md +++ b/docs/input_types.md @@ -95,7 +95,9 @@ 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**. -In order to declare that type, in GraphQLite, we will declare a **Factory**. +There are two ways for declaring that type, in GraphQLite: using **Factory** or annotating the class with `@Input`. + +## Factory A **Factory** is a method that takes in parameter all the fields of the input type and return an object. @@ -136,7 +138,7 @@ class MyFactory and now, you can run query like this: ``` -mutation { +query { getCities(location: { latitude: 45.0, longitude: 0.0, @@ -387,3 +389,165 @@ 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/phpstan.neon b/phpstan.neon index 9e9c137ed5..3d08891dbd 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -3,9 +3,8 @@ parameters: ignoreErrors: - "#PHPDoc tag \\@throws with type Psr\\\\Container\\\\ContainerExceptionInterface is not subtype of Throwable#" #- "#Property TheCodingMachine\\\\GraphQLite\\\\Types\\\\ResolvableInputObjectType::\\$resolve \\(array&callable\\) does not accept array#" - - "#Variable \\$prefetchRefMethod might not be defined.#" #- "#Parameter \\#2 \\$type of class TheCodingMachine\\\\GraphQLite\\\\Parameters\\\\InputTypeParameter constructor expects GraphQL\\\\Type\\\\Definition\\\\InputType&GraphQL\\\\Type\\\\Definition\\\\Type, GraphQL\\\\Type\\\\Definition\\\\InputType\\|GraphQL\\\\Type\\\\Definition\\\\Type given.#" - - "#Parameter .* of class ReflectionMethod constructor expects string, object\\|string given.#" + - "#Parameter .* of class ReflectionMethod constructor expects string(\\|null)?, object\\|string given.#" - message: '#Method TheCodingMachine\\GraphQLite\\Types\\Mutable(Interface|Object)Type::getFields\(\) should return array but returns array\|float\|int#' path: src/Types/MutableTrait.php @@ -34,9 +33,14 @@ parameters: message: '#Property TheCodingMachine\\GraphQLite\\Annotations\\Type::\$class \(class-string\\|null\) does not accept string.#' path: src/Annotations/Type.php - - message: '#Method TheCodingMachine\\GraphQLite\\AnnotationReader::getMethodAnnotations\(\) should return array but returns array.#' + message: '#Method TheCodingMachine\\GraphQLite\\AnnotationReader::(getMethodAnnotations|getPropertyAnnotations)\(\) should return array but returns array.#' + path: src/AnnotationReader.php + - + message: '#Method TheCodingMachine\\GraphQLite\\AnnotationReader::getClassAnnotations\(\) should return array but returns array.#' + path: src/AnnotationReader.php + - + message: '#Parameter \#1 \$annotations of class TheCodingMachine\\GraphQLite\\Annotations\\ParameterAnnotations constructor expects array, array given.#' path: src/AnnotationReader.php - - '#Call to an undefined method GraphQL\\Error\\ClientAware::getMessage\(\)#' #- diff --git a/phpunit.xml.dist b/phpunit.xml.dist index c7b363d680..68da55ad03 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,6 +1,9 @@ - - - - ./tests/ - ./tests/dependencies/ - ./tests/Bootstrap.php - - + + + ./tests/ + ./tests/dependencies/ + ./tests/Bootstrap.php + + - - - src/ - - - - - - + + + src/ + + + + + + diff --git a/src/AnnotationReader.php b/src/AnnotationReader.php index efd8976fd7..e89471c77e 100644 --- a/src/AnnotationReader.php +++ b/src/AnnotationReader.php @@ -11,6 +11,7 @@ use ReflectionClass; use ReflectionMethod; use ReflectionParameter; +use ReflectionProperty; use RuntimeException; use TheCodingMachine\GraphQLite\Annotations\AbstractRequest; use TheCodingMachine\GraphQLite\Annotations\Decorate; @@ -19,6 +20,7 @@ use TheCodingMachine\GraphQLite\Annotations\Exceptions\InvalidParameterException; use TheCodingMachine\GraphQLite\Annotations\ExtendType; use TheCodingMachine\GraphQLite\Annotations\Factory; +use TheCodingMachine\GraphQLite\Annotations\Input; use TheCodingMachine\GraphQLite\Annotations\MiddlewareAnnotationInterface; use TheCodingMachine\GraphQLite\Annotations\MiddlewareAnnotations; use TheCodingMachine\GraphQLite\Annotations\ParameterAnnotationInterface; @@ -68,6 +70,15 @@ class AnnotationReader */ private $mode; + /** @var array */ + private $methodAnnotationCache = []; + + /** @var array> */ + private $methodAnnotationsCache = []; + + /** @var array> */ + private $propertyAnnotationsCache = []; + /** * @param string $mode One of self::LAX_MODE or self::STRICT_MODE * @param string[] $strictNamespaces @@ -101,6 +112,30 @@ public function getTypeAnnotation(ReflectionClass $refClass): ?Type return $type; } + /** + * @param ReflectionClass $refClass + * + * @return Input[] + * + * @throws AnnotationException + * + * @template T of object + */ + public function getInputAnnotations(ReflectionClass $refClass): array + { + try { + /** @var Input[] $inputs */ + $inputs = $this->getClassAnnotations($refClass, Input::class, false); + foreach ($inputs as $input) { + $input->setClass($refClass->getName()); + } + } catch (ClassNotFoundException $e) { + throw ClassNotFoundException::wrapException($e, $refClass->getName()); + } + + return $inputs; + } + /** * @param ReflectionClass $refClass * @@ -241,10 +276,18 @@ static function ($attribute) { }, $parameterAnnotationsPerParameter); } - public function getMiddlewareAnnotations(ReflectionMethod $refMethod): MiddlewareAnnotations + /** + * @param ReflectionMethod|ReflectionProperty $reflection + * + * @throws AnnotationException + */ + public function getMiddlewareAnnotations($reflection): MiddlewareAnnotations { - /** @var MiddlewareAnnotationInterface[] $middlewareAnnotations */ - $middlewareAnnotations = $this->getMethodAnnotations($refMethod, MiddlewareAnnotationInterface::class); + if ($reflection instanceof ReflectionMethod) { + $middlewareAnnotations = $this->getMethodAnnotations($reflection, MiddlewareAnnotationInterface::class); + } else { + $middlewareAnnotations = $this->getPropertyAnnotations($reflection, MiddlewareAnnotationInterface::class); + } return new MiddlewareAnnotations($middlewareAnnotations); } @@ -296,9 +339,6 @@ private function getClassAnnotation(ReflectionClass $refClass, string $annotatio return $type; } - /** @var array */ - private $methodAnnotationCache = []; - /** * Returns a method annotation and handles correctly errors. * @@ -366,8 +406,11 @@ private function isErrorImportant(string $annotationClass, string $docComment, s * @template T of object * @template A of object */ - public function getClassAnnotations(ReflectionClass $refClass, string $annotationClass): array + public function getClassAnnotations(ReflectionClass $refClass, string $annotationClass, bool $inherited = true): array { + /** + * @var array> + */ $toAddAnnotations = []; do { try { @@ -398,7 +441,7 @@ static function ($attribute) { } } $refClass = $refClass->getParentClass(); - } while ($refClass); + } while ($inherited && $refClass); if (! empty($toAddAnnotations)) { return array_merge(...$toAddAnnotations); @@ -407,9 +450,6 @@ static function ($attribute) { return []; } - /** @var array> */ - private $methodAnnotationsCache = []; - /** * Returns the method's annotations. * @@ -462,6 +502,61 @@ static function ($attribute) { return $toAddAnnotations; } + /** + * Returns the property's annotations. + * + * @param class-string $annotationClass + * + * @return array + * + * @throws AnnotationException + * + * @template T of object + */ + public function getPropertyAnnotations(ReflectionProperty $refProperty, string $annotationClass): array + { + $cacheKey = $refProperty->getDeclaringClass()->getName() . '::' . $refProperty->getName() . '_s_' . $annotationClass; + if (isset($this->propertyAnnotationsCache[$cacheKey])) { + /** @var array $annotations */ + $annotations = $this->propertyAnnotationsCache[$cacheKey]; + + return $annotations; + } + + $toAddAnnotations = []; + try { + $allAnnotations = $this->reader->getPropertyAnnotations($refProperty); + $toAddAnnotations = array_filter($allAnnotations, static function ($annotation) use ($annotationClass): bool { + return $annotation instanceof $annotationClass; + }); + if (PHP_MAJOR_VERSION >= 8) { + $attributes = $refProperty->getAttributes(); + $toAddAnnotations = array_merge($toAddAnnotations, array_map( + static function ($attribute) { + return $attribute->newInstance(); + }, + array_filter($attributes, static function ($annotation) use ($annotationClass): bool { + return is_a($annotation->getName(), $annotationClass, true); + }) + )); + } + } catch (AnnotationException $e) { + if ($this->mode === self::STRICT_MODE) { + throw $e; + } + + if ($this->mode === self::LAX_MODE) { + if ($this->isErrorImportant($annotationClass, $refProperty->getDocComment() ?: '', $refProperty->getDeclaringClass()->getName())) { + throw $e; + } + } + } + + $this->propertyAnnotationsCache[$cacheKey] = $toAddAnnotations; + + return $toAddAnnotations; + } + /** * @param ReflectionClass $refClass */ diff --git a/src/Annotations/FailWith.php b/src/Annotations/FailWith.php index fe2aa999c8..bb1aa6f0cc 100644 --- a/src/Annotations/FailWith.php +++ b/src/Annotations/FailWith.php @@ -12,13 +12,13 @@ /** * @Annotation - * @Target({"METHOD", "ANNOTATION"}) + * @Target({"PROPERTY", "METHOD", "ANNOTATION"}) * @Attributes({ * @Attribute("value", type = "mixed"), * @Attribute("mode", type = "string") * }) */ -#[Attribute(Attribute::TARGET_METHOD)] +#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD)] class FailWith implements MiddlewareAnnotationInterface { /** diff --git a/src/Annotations/Field.php b/src/Annotations/Field.php index a3e059858a..fff273401c 100644 --- a/src/Annotations/Field.php +++ b/src/Annotations/Field.php @@ -8,26 +8,52 @@ /** * @Annotation - * @Target({"METHOD"}) + * @Target({"PROPERTY", "METHOD"}) * @Attributes({ * @Attribute("name", type = "string"), * @Attribute("outputType", type = "string"), * @Attribute("prefetchMethod", type = "string"), + * @Attribute("for", type = "string[]"), + * @Attribute("description", type = "string"), + * @Attribute("inputType", type = "string"), * }) */ -#[Attribute(Attribute::TARGET_METHOD)] +#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] class Field extends AbstractRequest { /** @var string|null */ private $prefetchMethod; + /** + * Input/Output type names for which this fields should be applied to. + * + * @var string[]|null + */ + private $for = null; + + /** @var string|null */ + private $description; + + /** @var string|null */ + private $inputType; + /** * @param mixed[] $attributes + * @param string|string[] $for */ - public function __construct(array $attributes = [], ?string $name = null, ?string $outputType = null, ?string $prefetchMethod = null) + public function __construct(array $attributes = [], ?string $name = null, ?string $outputType = null, ?string $prefetchMethod = null, $for = null, ?string $description = null, ?string $inputType = null) { parent::__construct($attributes, $name, $outputType); $this->prefetchMethod = $prefetchMethod ?? $attributes['prefetchMethod'] ?? null; + $this->description = $description ?? $attributes['description'] ?? null; + $this->inputType = $inputType ?? $attributes['inputType'] ?? null; + + $forValue = $for ?? $attributes['for'] ?? null; + if (! $forValue) { + return; + } + + $this->for = (array) $forValue; } /** @@ -37,4 +63,22 @@ public function getPrefetchMethod(): ?string { return $this->prefetchMethod; } + + /** + * @return string[]|null + */ + public function getFor(): ?array + { + return $this->for; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function getInputType(): ?string + { + return $this->inputType; + } } diff --git a/src/Annotations/HideIfUnauthorized.php b/src/Annotations/HideIfUnauthorized.php index 1ef71a2b66..98727c7655 100644 --- a/src/Annotations/HideIfUnauthorized.php +++ b/src/Annotations/HideIfUnauthorized.php @@ -11,9 +11,9 @@ * or has no right associated. * * @Annotation - * @Target({"METHOD", "ANNOTATION"}) + * @Target({"PROPERTY", "METHOD", "ANNOTATION"}) */ -#[Attribute(Attribute::TARGET_METHOD)] +#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD)] class HideIfUnauthorized implements MiddlewareAnnotationInterface { } diff --git a/src/Annotations/Input.php b/src/Annotations/Input.php new file mode 100644 index 0000000000..fed3d465bb --- /dev/null +++ b/src/Annotations/Input.php @@ -0,0 +1,101 @@ +name = $name ?? $attributes['name'] ?? null; + $this->default = $default ?? $attributes['default'] ?? $this->name === null; + $this->description = $description ?? $attributes['description'] ?? null; + $this->update = $update ?? $attributes['update'] ?? false; + } + + /** + * Returns the fully qualified class name of the targeted class. + */ + public function getClass(): string + { + if ($this->class === null) { + throw new RuntimeException('Empty class for @Input annotation. You MUST create the Input annotation object using the GraphQLite AnnotationReader'); + } + + return $this->class; + } + + public function setClass(string $class): void + { + $this->class = $class; + } + + /** + * Returns the GraphQL input name for this type. + */ + public function getName(): ?string + { + return $this->name; + } + + /** + * Returns true if this type should map the targeted class by default. + */ + public function isDefault(): bool + { + return $this->default; + } + + /** + * Returns description about this input type. + */ + public function getDescription(): ?string + { + return $this->description; + } + + /** + * Returns true if this type should behave as update resource. + * Such input type has all fields optional and without default value in the documentation. + */ + public function isUpdate(): bool + { + return $this->update; + } +} diff --git a/src/Annotations/Logged.php b/src/Annotations/Logged.php index 631b98034c..e802dcd29d 100644 --- a/src/Annotations/Logged.php +++ b/src/Annotations/Logged.php @@ -8,9 +8,9 @@ /** * @Annotation - * @Target({"METHOD", "ANNOTATION"}) + * @Target({"PROPERTY", "METHOD", "ANNOTATION"}) */ -#[Attribute(Attribute::TARGET_METHOD)] +#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD)] class Logged implements MiddlewareAnnotationInterface { } diff --git a/src/Annotations/Right.php b/src/Annotations/Right.php index f7e2faabbf..f4562ba423 100644 --- a/src/Annotations/Right.php +++ b/src/Annotations/Right.php @@ -11,12 +11,12 @@ /** * @Annotation - * @Target({"ANNOTATION", "METHOD"}) + * @Target({"PROPERTY", "ANNOTATION", "METHOD"}) * @Attributes({ * @Attribute("name", type = "string"), * }) */ -#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] +#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] class Right implements MiddlewareAnnotationInterface { /** @var string */ diff --git a/src/Annotations/Security.php b/src/Annotations/Security.php index f4c7ae8f2b..59eda63d60 100644 --- a/src/Annotations/Security.php +++ b/src/Annotations/Security.php @@ -16,7 +16,7 @@ /** * @Annotation - * @Target({"ANNOTATION", "METHOD"}) + * @Target({"PROPERTY", "ANNOTATION", "METHOD"}) * @Attributes({ * @Attribute("expression", type = "string"), * @Attribute("failWith", type = "mixed"), @@ -24,7 +24,7 @@ * @Attribute("message", type = "string"), * }) */ -#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] +#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] class Security implements MiddlewareAnnotationInterface { /** @var string */ diff --git a/src/FailedResolvingInputType.php b/src/FailedResolvingInputType.php new file mode 100644 index 0000000000..ce69807244 --- /dev/null +++ b/src/FailedResolvingInputType.php @@ -0,0 +1,27 @@ + QueryField indexed by name. */ - public function getFields(object $controller): array + public function getFields(object $controller, ?string $typeName = null): array { - $fieldAnnotations = $this->getFieldsByAnnotations($controller, Annotations\Field::class, true); + $fieldAnnotations = $this->getFieldsByAnnotations($controller, Annotations\Field::class, true, $typeName); $refClass = new ReflectionClass($controller); @@ -136,6 +149,55 @@ public function getFields(object $controller): array return $fields; } + /** + * @param class-string $className + * + * @return array + * + * @throws AnnotationException + * @throws ReflectionException + */ + public function getInputFields(string $className, string $inputName, bool $isUpdate = false): array + { + $refClass = new ReflectionClass($className); + $reflectors = $refClass->getProperties(); + + $fields = []; + $defaultProperties = $refClass->getDefaultProperties(); + foreach ($reflectors as $reflector) { + + /** @var Annotations\Field[] $annotations */ + $annotations = $this->annotationReader->getPropertyAnnotations($reflector, Annotations\Field::class); + $docBlock = $this->cachedDocBlockFactory->getDocBlock($reflector); + + foreach ($annotations as $annotation) { + $for = $annotation->getFor(); + if ($for && ! in_array($inputName, $for)) { + continue; + } + + $name = $annotation->getName() ?: $reflector->getName(); + + $field = $this->typeMapper->mapInputProperty($reflector, $docBlock, $name, $annotation->getInputType(), $defaultProperties[$reflector->getName()] ?? null, $isUpdate ? true : null); + $description = $annotation->getDescription(); + if ($description) { + $field->setDescription($description); + } + + $fields[$name] = $field; + } + } + + // Make sure @Field annotations applied to parent's private properties are taken into account as well. + $parent = $refClass->getParentClass(); + if ($parent) { + $parentFields = $this->getInputFields($parent->getName(), $inputName, $isUpdate); + $fields = array_merge($fields, array_diff_key($parentFields, $fields)); + } + + return $fields; + } + /** * Track Field annotation in a self targeted type * @@ -143,9 +205,9 @@ public function getFields(object $controller): array * * @return array QueryField indexed by name. */ - public function getSelfFields(string $className): array + public function getSelfFields(string $className, ?string $typeName = null): array { - $fieldAnnotations = $this->getFieldsByAnnotations($className, Annotations\Field::class, false); + $fieldAnnotations = $this->getFieldsByAnnotations($className, Annotations\Field::class, false, $typeName); $refClass = new ReflectionClass($className); @@ -203,18 +265,20 @@ public function getParametersForDecorator(ReflectionMethod $refMethod): array * @param object|class-string $controller The controller instance, or the name of the source class name * @param class-string $annotationName * @param bool $injectSource Whether to inject the source object or not as the first argument. True for @Field (unless @Type has no class attribute), false for @Query and @Mutation + * @param string|null $typeName Type name for which fields should be extracted for. * * @return array * * @throws ReflectionException */ - private function getFieldsByAnnotations($controller, string $annotationName, bool $injectSource): array + private function getFieldsByAnnotations($controller, string $annotationName, bool $injectSource, ?string $typeName = null): array { $refClass = new ReflectionClass($controller); - + /** @var array $queryList */ $queryList = []; - /** @var array $refMethodByFields */ - $refMethodByFields = []; + + /** @var ReflectionMethod[]|ReflectionProperty[] $reflectorByFields */ + $reflectorByFields = []; $oldDeclaringClass = null; $context = null; @@ -227,58 +291,86 @@ private function getFieldsByAnnotations($controller, string $annotationName, boo } } - foreach ($refClass->getMethods(ReflectionMethod::IS_PUBLIC) as $refMethod) { - if ($closestMatchingTypeClass !== null && $closestMatchingTypeClass === $refMethod->getDeclaringClass()->getName()) { + /** @var ReflectionProperty[]|ReflectionMethod[] $reflectors */ + $reflectors = array_merge($refClass->getProperties(), $refClass->getMethods(ReflectionMethod::IS_PUBLIC)); + foreach ($reflectors as $reflector) { + if ($closestMatchingTypeClass !== null && $closestMatchingTypeClass === $reflector->getDeclaringClass()->getName()) { // Optimisation: no need to fetch annotations from parent classes that are ALREADY GraphQL types. // We will merge the fields anyway. - break; + continue; } - // First, let's check the "Query" or "Mutation" or "Field" annotation - $queryAnnotation = $this->annotationReader->getRequestAnnotation($refMethod, $annotationName); + if ($reflector instanceof ReflectionMethod) { + $fields = $this->getFieldsByMethodAnnotations($controller, $refClass, $reflector, $annotationName, $injectSource, $typeName); + } else { + $fields = $this->getFieldsByPropertyAnnotations($controller, $refClass, $reflector, $annotationName, $typeName); + } - if ($queryAnnotation === null) { - continue; + $duplicates = array_intersect_key($reflectorByFields, $fields); + if ($duplicates) { + $name = key($duplicates); + assert(is_string($name)); + throw DuplicateMappingException::createForQuery($refClass->getName(), $name, $reflectorByFields[$name], $reflector); + } + + $reflectorByFields = array_merge( + $reflectorByFields, + array_fill_keys(array_keys($fields), $reflector) + ); + + $queryList = array_merge($queryList, $fields); + } + + return $queryList; + } + + /** + * Gets fields by class method annotations. + * + * @param string|object $controller + * @param class-string $annotationName + * + * @return array + * + * @throws AnnotationException + */ + private function getFieldsByMethodAnnotations($controller, ReflectionClass $refClass, ReflectionMethod $refMethod, string $annotationName, bool $injectSource, ?string $typeName = null): array + { + $fields = []; + + $annotations = $this->annotationReader->getMethodAnnotations($refMethod, $annotationName); + foreach ($annotations as $queryAnnotation) { + $description = null; + + if ($queryAnnotation instanceof Field) { + $for = $queryAnnotation->getFor(); + if ($typeName && $for && ! in_array($typeName, $for)) { + continue; + } + + $description = $queryAnnotation->getDescription(); } $fieldDescriptor = new QueryFieldDescriptor(); $fieldDescriptor->setRefMethod($refMethod); $docBlockObj = $this->cachedDocBlockFactory->getDocBlock($refMethod); - $docBlockComment = $docBlockObj->getSummary() . "\n" . $docBlockObj->getDescription()->render(); - - $deprecated = $docBlockObj->getTagsByName('deprecated'); - if (count($deprecated) >= 1) { - $fieldDescriptor->setDeprecationReason(trim((string) $deprecated[0])); - } + $fieldDescriptor->setDeprecationReason($this->getDeprecationReason($docBlockObj)); $methodName = $refMethod->getName(); $name = $queryAnnotation->getName() ?: $this->namingStrategy->getFieldNameFromMethodName($methodName); + if (! $description) { + $description = $docBlockObj->getSummary() . "\n" . $docBlockObj->getDescription()->render(); + } + $fieldDescriptor->setName($name); - $fieldDescriptor->setComment($docBlockComment); + $fieldDescriptor->setComment($description); - // Get parameters from the prefetchMethod method if any. - $prefetchMethodName = null; - $prefetchArgs = []; - if ($queryAnnotation instanceof Field) { - $prefetchMethodName = $queryAnnotation->getPrefetchMethod(); - if ($prefetchMethodName !== null) { - $fieldDescriptor->setPrefetchMethodName($prefetchMethodName); - try { - $prefetchRefMethod = $refClass->getMethod($prefetchMethodName); - } catch (ReflectionException $e) { - throw InvalidPrefetchMethodRuntimeException::methodNotFound($refMethod, $refClass, $prefetchMethodName, $e); - } - - $prefetchParameters = $prefetchRefMethod->getParameters(); - $firstPrefetchParameter = array_shift($prefetchParameters); - - $prefetchDocBlockObj = $this->cachedDocBlockFactory->getDocBlock($prefetchRefMethod); - - $prefetchArgs = $this->mapParameters($prefetchParameters, $prefetchDocBlockObj); - $fieldDescriptor->setPrefetchParameters($prefetchArgs); - } + [$prefetchMethodName, $prefetchArgs, $prefetchRefMethod] = $this->getPrefetchMethodInfo($refClass, $refMethod, $queryAnnotation); + if ($prefetchMethodName) { + $fieldDescriptor->setPrefetchMethodName($prefetchMethodName); + $fieldDescriptor->setPrefetchParameters($prefetchArgs); } $parameters = $refMethod->getParameters(); @@ -286,7 +378,7 @@ private function getFieldsByAnnotations($controller, string $annotationName, boo $firstParameter = array_shift($parameters); // TODO: check that $first_parameter type is correct. } - if ($prefetchMethodName !== null) { + if ($prefetchMethodName !== null && $prefetchRefMethod !== null) { $secondParameter = array_shift($parameters); if ($secondParameter === null) { throw InvalidPrefetchMethodRuntimeException::prefetchDataIgnored($prefetchRefMethod, $injectSource); @@ -328,27 +420,112 @@ public function handle(QueryFieldDescriptor $fieldDescriptor): ?FieldDefinition } }); - if ($field !== null) { - if (isset($refMethodByFields[$name])) { - throw DuplicateMappingException::createForQuery($refClass->getName(), $name, $refMethodByFields[$name], $refMethod); + if ($field === null) { + continue; + } + + if (isset($fields[$name])) { + throw DuplicateMappingException::createForQueryInOneMethod($name, $refMethod); + } + $fields[$name] = $field; + } + + return $fields; + } + + /** + * Gets fields by class property annotations. + * + * @param string|object $controller + * @param class-string $annotationName + * + * @return array + * + * @throws AnnotationException + */ + private function getFieldsByPropertyAnnotations($controller, ReflectionClass $refClass, ReflectionProperty $refProperty, string $annotationName, ?string $typeName = null): array + { + $fields = []; + + $annotations = $this->annotationReader->getPropertyAnnotations($refProperty, $annotationName); + foreach ($annotations as $queryAnnotation) { + $description = null; + + if ($queryAnnotation instanceof Field) { + $for = $queryAnnotation->getFor(); + if ($typeName && $for && ! in_array($typeName, $for)) { + continue; + } + + $description = $queryAnnotation->getDescription(); + } + + $fieldDescriptor = new QueryFieldDescriptor(); + $fieldDescriptor->setRefProperty($refProperty); + + $docBlock = $this->cachedDocBlockFactory->getDocBlock($refProperty); + $fieldDescriptor->setDeprecationReason($this->getDeprecationReason($docBlock)); + $name = $queryAnnotation->getName() ?: $refProperty->getName(); + + if (! $description) { + $description = $docBlock->getSummary() . PHP_EOL . $docBlock->getDescription()->render(); + + /** @var Var_[] $varTags */ + $varTags = $docBlock->getTagsByName('var'); + $varTag = reset($varTags); + if ($varTag) { + $description .= PHP_EOL . $varTag->getDescription(); } - $queryList[$name] = $field; - $refMethodByFields[$name] = $refMethod; } - /*if ($unauthorized) { - $failWithValue = $failWith->getValue(); - $queryList[] = QueryField::alwaysReturn($fieldDescriptor, $failWithValue); - } elseif ($sourceClassName !== null) { - $fieldDescriptor->setTargetMethodOnSource($methodName); - $queryList[] = QueryField::selfField($fieldDescriptor); + $fieldDescriptor->setName($name); + $fieldDescriptor->setComment($description); + + [$prefetchMethodName, $prefetchArgs] = $this->getPrefetchMethodInfo($refClass, $refProperty, $queryAnnotation); + if ($prefetchMethodName) { + $fieldDescriptor->setPrefetchMethodName($prefetchMethodName); + $fieldDescriptor->setPrefetchParameters($prefetchArgs); + } + + $outputType = $queryAnnotation->getOutputType(); + if ($outputType) { + $type = $this->typeResolver->mapNameToOutputType($outputType); + } else { + $type = $this->typeMapper->mapPropertyType($refProperty, $docBlock, false); + assert($type instanceof OutputType); + } + + $fieldDescriptor->setType($type); + $fieldDescriptor->setInjectSource(false); + + if (is_string($controller)) { + $fieldDescriptor->setTargetPropertyOnSource($refProperty->getName()); } else { - $fieldDescriptor->setCallable([$controller, $methodName]); - $queryList[] = QueryField::externalField($fieldDescriptor); - }*/ + $fieldDescriptor->setCallable(static function () use ($controller, $refProperty) { + return PropertyAccessor::getValue($controller, $refProperty->getName()); + }); + } + + $fieldDescriptor->setMiddlewareAnnotations($this->annotationReader->getMiddlewareAnnotations($refProperty)); + + $field = $this->fieldMiddleware->process($fieldDescriptor, new class implements FieldHandlerInterface { + public function handle(QueryFieldDescriptor $fieldDescriptor): ?FieldDefinition + { + return QueryField::fromFieldDescriptor($fieldDescriptor); + } + }); + + if ($field === null) { + continue; + } + + if (isset($fields[$name])) { + throw DuplicateMappingException::createForQueryInOneProperty($name, $refProperty); + } + $fields[$name] = $field; } - return $queryList; + return $fields; } /** @@ -511,12 +688,8 @@ private function getMethodFromPropertyName(ReflectionClass $reflectionClass, str if ($reflectionClass->hasMethod($propertyName)) { $methodName = $propertyName; } else { - $upperCasePropertyName = ucfirst($propertyName); - if ($reflectionClass->hasMethod('get' . $upperCasePropertyName)) { - $methodName = 'get' . $upperCasePropertyName; - } elseif ($reflectionClass->hasMethod('is' . $upperCasePropertyName)) { - $methodName = 'is' . $upperCasePropertyName; - } else { + $methodName = PropertyAccessor::findGetter($reflectionClass->getName(), $propertyName); + if (! $methodName) { throw FieldNotFoundException::missingField($reflectionClass->getName(), $propertyName); } } @@ -574,4 +747,52 @@ private function mapParameters(array $refParameters, DocBlock $docBlock, ?Source return $args; } + + /** + * Extracts deprecation reason from doc block. + */ + private function getDeprecationReason(DocBlock $docBlockObj): ?string + { + $deprecated = $docBlockObj->getTagsByName('deprecated'); + if (count($deprecated) >= 1) { + return trim((string) $deprecated[0]); + } + + return null; + } + + /** + * Extracts prefetch method info from annotation. + * + * @param ReflectionMethod|ReflectionProperty $reflector + * + * @return array{0: string|null, 1: array, 2: ReflectionMethod|null} + * + * @throws InvalidArgumentException + */ + private function getPrefetchMethodInfo(ReflectionClass $refClass, $reflector, object $annotation): array + { + $prefetchMethodName = null; + $prefetchArgs = []; + $prefetchRefMethod = null; + + if ($annotation instanceof Field) { + $prefetchMethodName = $annotation->getPrefetchMethod(); + if ($prefetchMethodName !== null) { + try { + $prefetchRefMethod = $refClass->getMethod($prefetchMethodName); + } catch (ReflectionException $e) { + throw InvalidPrefetchMethodRuntimeException::methodNotFound($reflector, $refClass, $prefetchMethodName, $e); + } + + $prefetchParameters = $prefetchRefMethod->getParameters(); + array_shift($prefetchParameters); + + $prefetchDocBlockObj = $this->cachedDocBlockFactory->getDocBlock($prefetchRefMethod); + $prefetchArgs = $this->mapParameters($prefetchParameters, $prefetchDocBlockObj); + } + } + + return [$prefetchMethodName, $prefetchArgs, $prefetchRefMethod]; + } } diff --git a/src/Http/WebonyxGraphqlMiddleware.php b/src/Http/WebonyxGraphqlMiddleware.php index 9d55df807f..67f66d63e8 100644 --- a/src/Http/WebonyxGraphqlMiddleware.php +++ b/src/Http/WebonyxGraphqlMiddleware.php @@ -137,7 +137,7 @@ private function decideHttpCode($result): int return $this->httpCodeDecider->decideHttpStatusCode($executionResult); }, $result); - return max($codes); + return (int) max($codes); } // @codeCoverageIgnoreStart diff --git a/src/InputTypeGenerator.php b/src/InputTypeGenerator.php index 0edd15f129..f27033d42d 100644 --- a/src/InputTypeGenerator.php +++ b/src/InputTypeGenerator.php @@ -8,6 +8,7 @@ use Psr\Container\ContainerInterface; use ReflectionFunctionAbstract; use ReflectionMethod; +use TheCodingMachine\GraphQLite\Types\InputType; use TheCodingMachine\GraphQLite\Types\ResolvableMutableInputInterface; use TheCodingMachine\GraphQLite\Types\ResolvableMutableInputObjectType; use Webmozart\Assert\Assert; @@ -20,7 +21,9 @@ class InputTypeGenerator { /** @var array */ - private $cache = []; + private $factoryCache = []; + /** @var array */ + private $inputCache = []; /** @var InputTypeUtils */ private $inputTypeUtils; /** @var FieldsBuilder */ @@ -46,12 +49,24 @@ public function mapFactoryMethod(string $factory, string $methodName, ContainerI [$inputName, $className] = $this->inputTypeUtils->getInputTypeNameAndClassName($method); - if (! isset($this->cache[$inputName])) { + if (! isset($this->factoryCache[$inputName])) { // TODO: add comment argument. - $this->cache[$inputName] = new ResolvableMutableInputObjectType($inputName, $this->fieldsBuilder, $object, $methodName, null, $this->canBeInstantiatedWithoutParameter($method, false)); + $this->factoryCache[$inputName] = new ResolvableMutableInputObjectType($inputName, $this->fieldsBuilder, $object, $methodName, null, $this->canBeInstantiatedWithoutParameter($method, false)); } - return $this->cache[$inputName]; + return $this->factoryCache[$inputName]; + } + + /** + * @param class-string $className + */ + 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); + } + + return $this->inputCache[$inputName]; } public static function canBeInstantiatedWithoutParameter(ReflectionFunctionAbstract $refMethod, bool $skipFirstArgument): bool diff --git a/src/InvalidDocBlockRuntimeException.php b/src/InvalidDocBlockRuntimeException.php index 760c3a9a18..08843abbb3 100644 --- a/src/InvalidDocBlockRuntimeException.php +++ b/src/InvalidDocBlockRuntimeException.php @@ -5,6 +5,7 @@ namespace TheCodingMachine\GraphQLite; use ReflectionMethod; +use ReflectionProperty; class InvalidDocBlockRuntimeException extends GraphQLRuntimeException { @@ -12,4 +13,12 @@ public static function tooManyReturnTags(ReflectionMethod $refMethod): self { throw new self('Method ' . $refMethod->getDeclaringClass()->getName() . '::' . $refMethod->getName() . ' has several @return annotations.'); } + + /** + * Creates an exception for property to have multiple var tags. + */ + public static function tooManyVarTags(ReflectionProperty $refProperty): self + { + throw new self('Property ' . $refProperty->getDeclaringClass()->getName() . '::' . $refProperty->getName() . ' has several @var annotations.'); + } } diff --git a/src/InvalidPrefetchMethodRuntimeException.php b/src/InvalidPrefetchMethodRuntimeException.php index 5deb51e46c..a51f0a318b 100644 --- a/src/InvalidPrefetchMethodRuntimeException.php +++ b/src/InvalidPrefetchMethodRuntimeException.php @@ -7,15 +7,16 @@ use ReflectionClass; use ReflectionException; use ReflectionMethod; +use ReflectionProperty; class InvalidPrefetchMethodRuntimeException extends GraphQLRuntimeException { /** - * @param ReflectionClass $reflectionClass + * @param ReflectionMethod|ReflectionProperty $reflector */ - public static function methodNotFound(ReflectionMethod $annotationMethod, ReflectionClass $reflectionClass, string $methodName, ReflectionException $previous): self + public static function methodNotFound($reflector, ReflectionClass $reflectionClass, string $methodName, ReflectionException $previous): self { - throw new self('The @Field annotation in ' . $annotationMethod->getDeclaringClass()->getName() . '::' . $annotationMethod->getName() . ' specifies a "prefetch method" that could not be found. Unable to find method ' . $reflectionClass->getName() . '::' . $methodName . '.', 0, $previous); + throw new self('The @Field annotation in ' . $reflector->getDeclaringClass()->getName() . '::' . $reflector->getName() . ' specifies a "prefetch method" that could not be found. Unable to find method ' . $reflectionClass->getName() . '::' . $methodName . '.', 0, $previous); } public static function prefetchDataIgnored(ReflectionMethod $annotationMethod, bool $isSecond): self diff --git a/src/Mappers/AbstractTypeMapper.php b/src/Mappers/AbstractTypeMapper.php index 91caf8193f..03d6eec4e2 100644 --- a/src/Mappers/AbstractTypeMapper.php +++ b/src/Mappers/AbstractTypeMapper.php @@ -78,6 +78,8 @@ abstract class AbstractTypeMapper implements TypeMapperInterface private $globTypeMapperCache; /** @var GlobExtendTypeMapperCache */ private $globExtendTypeMapperCache; + /** @var array> */ + private $registeredInputs; public function __construct(string $cachePrefix, TypeGenerator $typeGenerator, InputTypeGenerator $inputTypeGenerator, InputTypeUtils $inputTypeUtils, ContainerInterface $container, AnnotationReader $annotationReader, NamingStrategyInterface $namingStrategy, RecursiveTypeMapperInterface $recursiveTypeMapper, CacheInterface $cache, ?int $globTTL = 2, ?int $mapTTL = null) { @@ -133,7 +135,9 @@ private function buildMap(): GlobTypeMapperCache { $globTypeMapperCache = new GlobTypeMapperCache(); + /** @var array,ReflectionClass> $classes */ $classes = $this->getClassList(); + foreach ($classes as $className => $refClass) { $annotationsCache = $this->mapClassToAnnotationsCache->get($refClass, function () use ($refClass, $className) { $annotationsCache = new GlobAnnotationsCache(); @@ -147,6 +151,18 @@ private function buildMap(): GlobTypeMapperCache $containsAnnotations = true; } + $inputs = $this->annotationReader->getInputAnnotations($refClass); + foreach ($inputs as $input) { + $inputName = $this->namingStrategy->getInputTypeName($className, $input); + if (isset($this->registeredInputs[$inputName])) { + throw DuplicateMappingException::createForTwoInputs($inputName, $this->registeredInputs[$inputName], $refClass->getName()); + } + + $this->registeredInputs[$inputName] = $refClass->getName(); + $annotationsCache->registerInput($inputName, $className, $input); + $containsAnnotations = true; + } + $isAbstract = $refClass->isAbstract(); foreach ($refClass->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { @@ -283,7 +299,11 @@ public function getSupportedClasses(): array */ public function canMapClassToInputType(string $className): bool { - return $this->getMaps()->getFactoryByObjectClass($className) !== null; + if ($this->getMaps()->getFactoryByObjectClass($className) !== null) { + return true; + } + + return $this->getMaps()->getInputByObjectClass($className) !== null; } /** @@ -299,11 +319,17 @@ public function mapClassToInputType(string $className): ResolvableMutableInputIn { $factory = $this->getMaps()->getFactoryByObjectClass($className); - if ($factory === null) { - throw CannotMapTypeException::createForInputType($className); + if ($factory !== null) { + return $this->inputTypeGenerator->mapFactoryMethod($factory[0], $factory[1], $this->container); + } + + $input = $this->getMaps()->getInputByObjectClass($className); + if ($input !== null) { + [$typeName, $description, $isUpdate] = $input; + return $this->inputTypeGenerator->mapInput($className, $typeName, $description, $isUpdate); } - return $this->inputTypeGenerator->mapFactoryMethod($factory[0], $factory[1], $this->container); + throw CannotMapTypeException::createForInputType($className); } /** @@ -329,6 +355,12 @@ public function mapNameToType(string $typeName): Type return $this->inputTypeGenerator->mapFactoryMethod($factory[0], $factory[1], $this->container); } + $input = $this->getMaps()->getInputByGraphQLInputTypeName($typeName); + if ($input !== null) { + [$className, $description, $isUpdate] = $input; + return $this->inputTypeGenerator->mapInput($className, $typeName, $description, $isUpdate); + } + throw CannotMapTypeException::createForName($typeName); } @@ -347,7 +379,11 @@ public function canMapNameToType(string $typeName): bool $factory = $this->getMaps()->getFactoryByGraphQLInputTypeName($typeName); - return $factory !== null; + if ($factory !== null) { + return true; + } + + return $this->getMaps()->getInputByGraphQLInputTypeName($typeName) !== null; } /** diff --git a/src/Mappers/CannotMapTypeException.php b/src/Mappers/CannotMapTypeException.php index 79d74de35f..d887e55e7f 100644 --- a/src/Mappers/CannotMapTypeException.php +++ b/src/Mappers/CannotMapTypeException.php @@ -16,6 +16,7 @@ use phpDocumentor\Reflection\Types\Iterable_; use phpDocumentor\Reflection\Types\Mixed_; use ReflectionMethod; +use ReflectionProperty; use TheCodingMachine\GraphQLite\Annotations\ExtendType; use function array_map; @@ -131,9 +132,10 @@ public static function extendTypeWithBadTargetedClass(string $className, ExtendT } /** - * @param Array_|Iterable_|Mixed_ $type + * @param Array_|Iterable_|Mixed_ $type + * @param ReflectionMethod|ReflectionProperty $reflector */ - public static function createForMissingPhpDoc(PhpDocumentorType $type, ReflectionMethod $refMethod, ?string $argumentName = null): self + public static function createForMissingPhpDoc(PhpDocumentorType $type, $reflector, ?string $argumentName = null): self { $typeStr = ''; if ($type instanceof Array_) { diff --git a/src/Mappers/DuplicateMappingException.php b/src/Mappers/DuplicateMappingException.php index ae60f4656b..989b5945ce 100644 --- a/src/Mappers/DuplicateMappingException.php +++ b/src/Mappers/DuplicateMappingException.php @@ -5,6 +5,7 @@ namespace TheCodingMachine\GraphQLite\Mappers; use ReflectionMethod; +use ReflectionProperty; use RuntimeException; use function sprintf; @@ -31,13 +32,56 @@ public static function createForTypeName(string $type, string $sourceClass1, str throw new self(sprintf("The type '%s' is created by 2 different classes: '%s' and '%s'", $type, $sourceClass1, $sourceClass2)); } - public static function createForQuery(string $sourceClass, string $queryName, ReflectionMethod $method1, ReflectionMethod $method2): self + /** + * @param ReflectionMethod|ReflectionProperty $firstReflector + * @param ReflectionMethod|ReflectionProperty $secondReflector + * + * @return static + */ + public static function createForQuery(string $sourceClass, string $queryName, $firstReflector, $secondReflector): self { - throw new self(sprintf("The query/mutation/field '%s' is declared twice in class '%s'. First in '%s::%s()', second in '%s::%s()'", $queryName, $sourceClass, $method1->getDeclaringClass()->getName(), $method1->getName(), $method2->getDeclaringClass()->getName(), $method2->getName())); + $firstName = sprintf('%s::%s', $firstReflector->getDeclaringClass()->getName(), $firstReflector->getName()); + if ($firstReflector instanceof ReflectionMethod) { + $firstName .= '()'; + } + + $secondName = sprintf('%s::%s', $secondReflector->getDeclaringClass()->getName(), $secondReflector->getName()); + if ($secondReflector instanceof ReflectionMethod) { + $secondName .= '()'; + } + + throw new self(sprintf("The query/mutation/field '%s' is declared twice in class '%s'. First in '%s', second in '%s'", $queryName, $sourceClass, $firstName, $secondName)); } public static function createForQueryInTwoControllers(string $sourceClass1, string $sourceClass2, string $queryName): self { throw new self(sprintf("The query/mutation '%s' is declared twice: in class '%s' and in class '%s'", $queryName, $sourceClass1, $sourceClass2)); } + + public static function createForQueryInOneMethod(string $queryName, ReflectionMethod $method): self + { + throw new self(sprintf("The query/mutation/field '%s' is declared twice in '%s::%s()'", $queryName, $method->getDeclaringClass()->getName(), $method->getName())); + } + + public static function createForQueryInOneProperty(string $queryName, ReflectionProperty $property): self + { + throw new self(sprintf("The query/mutation/field '%s' is declared twice in '%s::%s'", $queryName, $property->getDeclaringClass()->getName(), $property->getName())); + } + + /** + * @return static + */ + public static function createForDefaultInput(string $sourceClass): self + { + throw new self(sprintf("The class '%s' should be mapped to only one GraphQL Input type as default. Two default inputs are declared as default via @Input annotation.", $sourceClass)); + } + + public static function createForTwoInputs(string $typeName, string $firstClass, string $secondClass): self + { + if ($firstClass === $secondClass) { + throw new self(sprintf("The input type '%s' is created 2 times in '%s'", $typeName, $firstClass)); + } + + throw new self(sprintf("The input type '%s' is created by 2 different classes: '%s' and '%s'", $typeName, $firstClass, $secondClass)); + } } diff --git a/src/Mappers/GlobAnnotationsCache.php b/src/Mappers/GlobAnnotationsCache.php index 0dd6e75e88..f8be4cef3a 100644 --- a/src/Mappers/GlobAnnotationsCache.php +++ b/src/Mappers/GlobAnnotationsCache.php @@ -4,6 +4,8 @@ namespace TheCodingMachine\GraphQLite\Mappers; +use TheCodingMachine\GraphQLite\Annotations\Input; + /** * An object containing a description of ALL annotations relevant to GlobTypeMapper for a given class. * @@ -26,6 +28,9 @@ final class GlobAnnotationsCache /** @var array}> An array mapping a decorator method name to an input name / declaring class */ private $decorators = []; + /** @var array, 1: bool, 2: string|null, 3: bool}> An array mapping an input type name to an input name / declaring class */ + private $inputs = []; + /** * @param class-string $className */ @@ -86,4 +91,24 @@ public function getDecorators(): array { return $this->decorators; } + + /** + * Register a new input. + * + * @param class-string $className + */ + public function registerInput(string $name, string $className, Input $input): void + { + $this->inputs[$name] = [$className, $input->isDefault(), $input->getDescription(), $input->isUpdate()]; + } + + /** + * Returns registered inputs. + * + * @return array, 1: bool, 2: string|null, 3: bool}> + */ + public function getInputs(): array + { + return $this->inputs; + } } diff --git a/src/Mappers/GlobTypeMapperCache.php b/src/Mappers/GlobTypeMapperCache.php index af54a9e373..4a0e0ba65e 100644 --- a/src/Mappers/GlobTypeMapperCache.php +++ b/src/Mappers/GlobTypeMapperCache.php @@ -23,6 +23,10 @@ class GlobTypeMapperCache private $mapInputNameToFactory = []; /** @var array> Maps a GraphQL type name to one or many decorators (with the @Decorator annotation) */ private $mapInputNameToDecorator = []; + /** @var array,array{0: string, 1: string|null, 2: bool}> Maps a domain class to the input */ + private $mapClassToInput = []; + /** @var array, 1: string|null, 2: bool}> Maps a GraphQL type name to the input */ + private $mapNameToInput = []; /** * Merges annotations of a given class in the global cache. @@ -65,6 +69,18 @@ public function registerAnnotations(ReflectionClass $refClass, GlobAnnotationsCa $this->mapInputNameToFactory[$inputName] = $refArray; } + foreach ($globAnnotationsCache->getInputs() as $inputName => [$inputClassName, $isDefault, $description, $isUpdate]) { + if ($isDefault) { + if (isset($this->mapClassToInput[$inputClassName])) { + throw DuplicateMappingException::createForDefaultInput($refClass->getName()); + } + + $this->mapClassToInput[$inputClassName] = [$inputName, $description, $isUpdate]; + } + + $this->mapNameToInput[$inputName] = [$inputClassName, $description, $isUpdate]; + } + foreach ($globAnnotationsCache->getDecorators() as $methodName => [$inputName, $declaringClass]) { $this->mapInputNameToDecorator[$inputName][] = [$declaringClass, $methodName]; } @@ -119,4 +135,20 @@ public function getFactoryByObjectClass(string $className): ?array { return $this->mapClassToFactory[$className] ?? null; } + + /** + * @return array{0: class-string, 1: string|null, 2: bool}|null + */ + public function getInputByGraphQLInputTypeName(string $graphqlTypeName): ?array + { + return $this->mapNameToInput[$graphqlTypeName] ?? null; + } + + /** + * @return array{0: string, 1: string|null, 2: bool}|null + */ + public function getInputByObjectClass(string $className): ?array + { + return $this->mapClassToInput[$className] ?? null; + } } diff --git a/src/Mappers/Parameters/TypeHandler.php b/src/Mappers/Parameters/TypeHandler.php index 893cca883f..4e2f3bdec2 100644 --- a/src/Mappers/Parameters/TypeHandler.php +++ b/src/Mappers/Parameters/TypeHandler.php @@ -9,6 +9,7 @@ use GraphQL\Type\Definition\Type as GraphQLType; use phpDocumentor\Reflection\DocBlock; use phpDocumentor\Reflection\DocBlock\Tags\Return_; +use phpDocumentor\Reflection\DocBlock\Tags\Var_; use phpDocumentor\Reflection\Fqsen; use phpDocumentor\Reflection\Type; use phpDocumentor\Reflection\TypeResolver as PhpDocumentorTypeResolver; @@ -24,6 +25,7 @@ use ReflectionMethod; use ReflectionNamedType; use ReflectionParameter; +use ReflectionProperty; use ReflectionType; use TheCodingMachine\GraphQLite\Annotations\HideParameter; use TheCodingMachine\GraphQLite\Annotations\ParameterAnnotations; @@ -34,6 +36,7 @@ use TheCodingMachine\GraphQLite\Mappers\Root\RootTypeMapperInterface; use TheCodingMachine\GraphQLite\Parameters\DefaultValueParameter; use TheCodingMachine\GraphQLite\Parameters\InputTypeParameter; +use TheCodingMachine\GraphQLite\Parameters\InputTypeProperty; use TheCodingMachine\GraphQLite\Parameters\ParameterInterface; use TheCodingMachine\GraphQLite\Types\ArgumentResolver; use TheCodingMachine\GraphQLite\Types\TypeResolver; @@ -43,8 +46,14 @@ use function array_unique; use function assert; use function count; +use function explode; +use function in_array; use function iterator_to_array; +use function method_exists; +use function reset; +use function trim; +use const PHP_EOL; use const SORT_REGULAR; class TypeHandler implements ParameterHandlerInterface @@ -109,6 +118,25 @@ private function getDocBlocReturnType(DocBlock $docBlock, ReflectionMethod $refM return $docBlockReturnType; } + /** + * Gets property type from its dock block. + */ + private function getDocBlockPropertyType(DocBlock $docBlock, ReflectionProperty $refProperty): ?Type + { + /** @var Var_[] $varTags */ + $varTags = $docBlock->getTagsByName('var'); + + if (! $varTags) { + return null; + } + + if (count($varTags) > 1) { + throw InvalidDocBlockRuntimeException::tooManyVarTags($refProperty); + } + + return reset($varTags)->getType(); + } + public function mapParameter(ReflectionParameter $parameter, DocBlock $docBlock, ?Type $paramTagType, ParameterAnnotations $parameterAnnotations): ParameterInterface { $hideParameter = $parameterAnnotations->getAnnotationByType(HideParameter::class); @@ -166,7 +194,98 @@ public function mapParameter(ReflectionParameter $parameter, DocBlock $docBlock, return new InputTypeParameter($parameter->getName(), $type, $hasDefaultValue, $defaultValue, $this->argumentResolver); } - private function mapType(Type $type, ?Type $docBlockType, bool $isNullable, bool $mapToInputType, ReflectionMethod $refMethod, DocBlock $docBlockObj, ?string $argumentName = null): GraphQLType + /** + * Map class property to a GraphQL type. + * + * @return (InputType&GraphQLType)|(OutputType&GraphQLType) + * + * @throws CannotMapTypeException + */ + public function mapPropertyType(ReflectionProperty $refProperty, DocBlock $docBlock, bool $toInput, ?string $argumentName = null, ?bool $isNullable = null): GraphQLType + { + $propertyType = null; + + // getType function on property reflection is available only since PHP 7.4 + if (method_exists($refProperty, 'getType')) { + $propertyType = $refProperty->getType(); + if ($propertyType !== null) { + $phpdocType = $this->reflectionTypeToPhpDocType($propertyType, $refProperty->getDeclaringClass()); + } else { + $phpdocType = new Mixed_(); + } + } else { + $phpdocType = new Mixed_(); + } + + $docBlockPropertyType = $this->getDocBlockPropertyType($docBlock, $refProperty); + + if ($isNullable === null) { + $isNullable = $propertyType ? $propertyType->allowsNull() : false; + } + + return $this->mapType($phpdocType, $docBlockPropertyType, $isNullable, $toInput, $refProperty, $docBlock, $argumentName); + } + + /** + * Maps class property into input property. + * + * @param mixed $defaultValue + * + * @throws CannotMapTypeException + */ + public function mapInputProperty(ReflectionProperty $refProperty, DocBlock $docBlock, ?string $argumentName = null, ?string $inputTypeName = null, $defaultValue = null, ?bool $isNullable = null): InputTypeProperty + { + $docBlockComment = $docBlock->getSummary() . PHP_EOL . $docBlock->getDescription()->render(); + + /** @var Var_[] $varTags */ + $varTags = $docBlock->getTagsByName('var'); + $varTag = reset($varTags); + if ($varTag) { + $docBlockComment .= PHP_EOL . $varTag->getDescription(); + + if ($isNullable === null) { + $varType = $varTag->getType(); + if ($varType !== null) { + $isNullable = in_array('null', explode('|', (string) $varType)); + } + } + } + + if ($isNullable === null) { + $isNullable = false; + // getType function on property reflection is available only since PHP 7.4 + if (method_exists($refProperty, 'getType')) { + $refType = $refProperty->getType(); + if ($refType !== null) { + $isNullable = $refType->allowsNull(); + } + } + } + + if ($inputTypeName) { + $inputType = $this->typeResolver->mapNameToInputType($inputTypeName); + } else { + $inputType = $this->mapPropertyType($refProperty, $docBlock, true, $argumentName, $isNullable); + assert($inputType instanceof InputType && $inputType instanceof GraphQLType); + } + + $hasDefault = $defaultValue !== null || $isNullable; + $fieldName = $argumentName ?? $refProperty->getName(); + + $inputProperty = new InputTypeProperty($refProperty->getName(), $fieldName, $inputType, $hasDefault, $defaultValue, $this->argumentResolver); + $inputProperty->setDescription(trim($docBlockComment)); + + return $inputProperty; + } + + /** + * @param ReflectionMethod|ReflectionProperty $reflector + * + * @return (InputType&GraphQLType)|(OutputType&GraphQLType) + * + * @throws CannotMapTypeException + */ + private function mapType(Type $type, ?Type $docBlockType, bool $isNullable, bool $mapToInputType, $reflector, DocBlock $docBlockObj, ?string $argumentName = null): GraphQLType { $graphQlType = null; if ($isNullable && ! $type instanceof Nullable) { @@ -178,21 +297,21 @@ private function mapType(Type $type, ?Type $docBlockType, bool $isNullable, bool if ($innerType instanceof Array_ || $innerType instanceof Iterable_ || $innerType instanceof Mixed_) { // We need to use the docBlockType if ($docBlockType === null) { - throw CannotMapTypeException::createForMissingPhpDoc($innerType, $refMethod, $argumentName); + throw CannotMapTypeException::createForMissingPhpDoc($innerType, $reflector, $argumentName); } if ($mapToInputType === true) { Assert::notNull($argumentName); - $graphQlType = $this->rootTypeMapper->toGraphQLInputType($docBlockType, null, $argumentName, $refMethod, $docBlockObj); + $graphQlType = $this->rootTypeMapper->toGraphQLInputType($docBlockType, null, $argumentName, $reflector, $docBlockObj); } else { - $graphQlType = $this->rootTypeMapper->toGraphQLOutputType($docBlockType, null, $refMethod, $docBlockObj); + $graphQlType = $this->rootTypeMapper->toGraphQLOutputType($docBlockType, null, $reflector, $docBlockObj); } } else { $completeType = $this->appendTypes($type, $docBlockType); if ($mapToInputType === true) { Assert::notNull($argumentName); - $graphQlType = $this->rootTypeMapper->toGraphQLInputType($completeType, null, $argumentName, $refMethod, $docBlockObj); + $graphQlType = $this->rootTypeMapper->toGraphQLInputType($completeType, null, $argumentName, $reflector, $docBlockObj); } else { - $graphQlType = $this->rootTypeMapper->toGraphQLOutputType($completeType, null, $refMethod, $docBlockObj); + $graphQlType = $this->rootTypeMapper->toGraphQLOutputType($completeType, null, $reflector, $docBlockObj); } } diff --git a/src/Mappers/Proxys/MutableInterfaceTypeAdapter.php b/src/Mappers/Proxys/MutableInterfaceTypeAdapter.php index fbf39b440d..4d5ccc7985 100644 --- a/src/Mappers/Proxys/MutableInterfaceTypeAdapter.php +++ b/src/Mappers/Proxys/MutableInterfaceTypeAdapter.php @@ -27,7 +27,7 @@ */ class MutableInterfaceTypeAdapter extends MutableInterfaceType implements MutableInterface { - /** @use MutableAdapterTrait */ + /** @use MutableAdapterTrait */ use MutableAdapterTrait; public function __construct(InterfaceType $type, ?string $className = null) diff --git a/src/Mappers/Proxys/MutableObjectTypeAdapter.php b/src/Mappers/Proxys/MutableObjectTypeAdapter.php index e1cc2d4311..1878924c10 100644 --- a/src/Mappers/Proxys/MutableObjectTypeAdapter.php +++ b/src/Mappers/Proxys/MutableObjectTypeAdapter.php @@ -28,7 +28,7 @@ */ class MutableObjectTypeAdapter extends MutableObjectType implements MutableInterface { - /** @use MutableAdapterTrait */ + /** @use MutableAdapterTrait */ use MutableAdapterTrait; public function __construct(ObjectType $type, ?string $className = null) diff --git a/src/Mappers/Root/BaseTypeMapper.php b/src/Mappers/Root/BaseTypeMapper.php index 888e62a516..1257f8a627 100644 --- a/src/Mappers/Root/BaseTypeMapper.php +++ b/src/Mappers/Root/BaseTypeMapper.php @@ -24,6 +24,7 @@ use phpDocumentor\Reflection\Types\String_; use Psr\Http\Message\UploadedFileInterface; use ReflectionMethod; +use ReflectionProperty; use TheCodingMachine\GraphQLite\Mappers\CannotMapTypeException; use TheCodingMachine\GraphQLite\Mappers\CannotMapTypeExceptionInterface; use TheCodingMachine\GraphQLite\Mappers\RecursiveTypeMapperInterface; @@ -55,12 +56,13 @@ public function __construct(RootTypeMapperInterface $next, RecursiveTypeMapperIn /** * @param (OutputType&GraphQLType)|null $subType + * @param ReflectionMethod|ReflectionProperty $reflector * * @return OutputType&GraphQLType * * @throws CannotMapTypeExceptionInterface */ - public function toGraphQLOutputType(Type $type, ?OutputType $subType, ReflectionMethod $refMethod, DocBlock $docBlockObj): OutputType + public function toGraphQLOutputType(Type $type, ?OutputType $subType, $reflector, DocBlock $docBlockObj): OutputType { $mappedType = $this->mapBaseType($type); if ($mappedType !== null) { @@ -68,9 +70,9 @@ public function toGraphQLOutputType(Type $type, ?OutputType $subType, Reflection } if ($type instanceof Array_) { - $innerType = $this->topRootTypeMapper->toGraphQLOutputType($type->getValueType(), $subType, $refMethod, $docBlockObj); + $innerType = $this->topRootTypeMapper->toGraphQLOutputType($type->getValueType(), $subType, $reflector, $docBlockObj); /*if ($innerType === null) { - return $this->next->toGraphQLOutputType($type, $subType, $refMethod, $docBlockObj); + return $this->next->toGraphQLOutputType($type, $subType, $reflector, $docBlockObj); }*/ return GraphQLType::listOf($innerType); @@ -81,24 +83,25 @@ public function toGraphQLOutputType(Type $type, ?OutputType $subType, Reflection return $this->recursiveTypeMapper->mapClassToInterfaceOrType($className, $subType); } - return $this->next->toGraphQLOutputType($type, $subType, $refMethod, $docBlockObj); + return $this->next->toGraphQLOutputType($type, $subType, $reflector, $docBlockObj); } /** * @param (InputType&GraphQLType)|null $subType + * @param ReflectionMethod|ReflectionProperty $reflector * * @return InputType&GraphQLType */ - public function toGraphQLInputType(Type $type, ?InputType $subType, string $argumentName, ReflectionMethod $refMethod, DocBlock $docBlockObj): InputType + public function toGraphQLInputType(Type $type, ?InputType $subType, string $argumentName, $reflector, DocBlock $docBlockObj): InputType { $mappedType = $this->mapBaseType($type); if ($mappedType !== null) { return $mappedType; } if ($type instanceof Array_) { - $innerType = $this->topRootTypeMapper->toGraphQLInputType($type->getValueType(), $subType, $argumentName, $refMethod, $docBlockObj); + $innerType = $this->topRootTypeMapper->toGraphQLInputType($type->getValueType(), $subType, $argumentName, $reflector, $docBlockObj); /*if ($innerType === null) { - return $this->next->toGraphQLInputType($type, $subType, $argumentName, $refMethod, $docBlockObj); + return $this->next->toGraphQLInputType($type, $subType, $argumentName, $reflector, $docBlockObj); }*/ return GraphQLType::listOf($innerType); @@ -109,7 +112,7 @@ public function toGraphQLInputType(Type $type, ?InputType $subType, string $argu return $this->recursiveTypeMapper->mapClassToInputType($className); } - return $this->next->toGraphQLInputType($type, $subType, $argumentName, $refMethod, $docBlockObj); + return $this->next->toGraphQLInputType($type, $subType, $argumentName, $reflector, $docBlockObj); } /** diff --git a/src/Mappers/Root/CompoundTypeMapper.php b/src/Mappers/Root/CompoundTypeMapper.php index d494b2258a..cf656708c4 100644 --- a/src/Mappers/Root/CompoundTypeMapper.php +++ b/src/Mappers/Root/CompoundTypeMapper.php @@ -16,6 +16,7 @@ use phpDocumentor\Reflection\Types\Compound; use phpDocumentor\Reflection\Types\Iterable_; use ReflectionMethod; +use ReflectionProperty; use RuntimeException; use TheCodingMachine\GraphQLite\Mappers\CannotMapTypeException; use TheCodingMachine\GraphQLite\Mappers\RecursiveTypeMapperInterface; @@ -54,13 +55,14 @@ public function __construct(RootTypeMapperInterface $next, RootTypeMapperInterfa /** * @param (OutputType&GraphQLType)|null $subType + * @param ReflectionMethod|ReflectionProperty $reflector * * @return OutputType&GraphQLType */ - public function toGraphQLOutputType(Type $type, ?OutputType $subType, ReflectionMethod $refMethod, DocBlock $docBlockObj): OutputType + public function toGraphQLOutputType(Type $type, ?OutputType $subType, $reflector, DocBlock $docBlockObj): OutputType { if (! $type instanceof Compound) { - return $this->next->toGraphQLOutputType($type, $subType, $refMethod, $docBlockObj); + return $this->next->toGraphQLOutputType($type, $subType, $reflector, $docBlockObj); } $filteredDocBlockTypes = iterator_to_array($type); @@ -74,7 +76,7 @@ public function toGraphQLOutputType(Type $type, ?OutputType $subType, Reflection $mustBeIterable = true; continue; } - $unionTypes[] = $this->topRootTypeMapper->toGraphQLOutputType($singleDocBlockType, null, $refMethod, $docBlockObj); + $unionTypes[] = $this->topRootTypeMapper->toGraphQLOutputType($singleDocBlockType, null, $reflector, $docBlockObj); } if ($mustBeIterable && empty($unionTypes)) { @@ -94,13 +96,14 @@ public function toGraphQLOutputType(Type $type, ?OutputType $subType, Reflection /** * @param (InputType&GraphQLType)|null $subType + * @param ReflectionMethod|ReflectionProperty $reflector * * @return InputType&GraphQLType */ - public function toGraphQLInputType(Type $type, ?InputType $subType, string $argumentName, ReflectionMethod $refMethod, DocBlock $docBlockObj): InputType + public function toGraphQLInputType(Type $type, ?InputType $subType, string $argumentName, $reflector, DocBlock $docBlockObj): InputType { if (! $type instanceof Compound) { - return $this->next->toGraphQLInputType($type, $subType, $argumentName, $refMethod, $docBlockObj); + return $this->next->toGraphQLInputType($type, $subType, $argumentName, $reflector, $docBlockObj); } // At this point, the |null has been removed and the |iterable has been removed too. diff --git a/src/Mappers/Root/FinalRootTypeMapper.php b/src/Mappers/Root/FinalRootTypeMapper.php index 6a5ca701a2..3c62cc3ce1 100644 --- a/src/Mappers/Root/FinalRootTypeMapper.php +++ b/src/Mappers/Root/FinalRootTypeMapper.php @@ -11,6 +11,7 @@ use phpDocumentor\Reflection\DocBlock; use phpDocumentor\Reflection\Type; use ReflectionMethod; +use ReflectionProperty; use TheCodingMachine\GraphQLite\Mappers\CannotMapTypeException; use TheCodingMachine\GraphQLite\Mappers\RecursiveTypeMapperInterface; @@ -32,20 +33,22 @@ public function __construct(RecursiveTypeMapperInterface $recursiveTypeMapper) /** * @param (OutputType&GraphQLType)|null $subType + * @param ReflectionMethod|ReflectionProperty $reflector * * @return OutputType&GraphQLType */ - public function toGraphQLOutputType(Type $type, ?OutputType $subType, ReflectionMethod $refMethod, DocBlock $docBlockObj): OutputType + public function toGraphQLOutputType(Type $type, ?OutputType $subType, $reflector, DocBlock $docBlockObj): OutputType { throw CannotMapTypeException::createForPhpDocType($type); } /** * @param (InputType&GraphQLType)|null $subType + * @param ReflectionMethod|ReflectionProperty $reflector * * @return InputType&GraphQLType */ - public function toGraphQLInputType(Type $type, ?InputType $subType, string $argumentName, ReflectionMethod $refMethod, DocBlock $docBlockObj): InputType + public function toGraphQLInputType(Type $type, ?InputType $subType, string $argumentName, $reflector, DocBlock $docBlockObj): InputType { throw CannotMapTypeException::createForPhpDocType($type); } diff --git a/src/Mappers/Root/IteratorTypeMapper.php b/src/Mappers/Root/IteratorTypeMapper.php index 98447df0fa..9fde3004bb 100644 --- a/src/Mappers/Root/IteratorTypeMapper.php +++ b/src/Mappers/Root/IteratorTypeMapper.php @@ -20,6 +20,7 @@ use phpDocumentor\Reflection\Types\Object_; use ReflectionClass; use ReflectionMethod; +use ReflectionProperty; use TheCodingMachine\GraphQLite\Mappers\CannotMapTypeException; use TheCodingMachine\GraphQLite\Mappers\CannotMapTypeExceptionInterface; use Webmozart\Assert\Assert; @@ -47,14 +48,15 @@ public function __construct(RootTypeMapperInterface $next, RootTypeMapperInterfa /** * @param (OutputType&GraphQLType)|null $subType + * @param ReflectionMethod|ReflectionProperty $reflector * * @return OutputType&GraphQLType */ - public function toGraphQLOutputType(Type $type, ?OutputType $subType, ReflectionMethod $refMethod, DocBlock $docBlockObj): OutputType + public function toGraphQLOutputType(Type $type, ?OutputType $subType, $reflector, DocBlock $docBlockObj): OutputType { if (! $type instanceof Compound) { try { - return $this->next->toGraphQLOutputType($type, $subType, $refMethod, $docBlockObj); + return $this->next->toGraphQLOutputType($type, $subType, $reflector, $docBlockObj); } catch (CannotMapTypeException $e) { if ($type instanceof Object_) { /** @@ -71,12 +73,12 @@ public function toGraphQLOutputType(Type $type, ?OutputType $subType, Reflection } } - $result = $this->toGraphQLType($type, function (Type $type, ?OutputType $subType) use ($refMethod, $docBlockObj) { - return $this->topRootTypeMapper->toGraphQLOutputType($type, $subType, $refMethod, $docBlockObj); + $result = $this->toGraphQLType($type, function (Type $type, ?OutputType $subType) use ($reflector, $docBlockObj) { + return $this->topRootTypeMapper->toGraphQLOutputType($type, $subType, $reflector, $docBlockObj); }, true); if ($result === null) { - return $this->next->toGraphQLOutputType($type, $subType, $refMethod, $docBlockObj); + return $this->next->toGraphQLOutputType($type, $subType, $reflector, $docBlockObj); } Assert::isInstanceOf($result, OutputType::class); Assert::notInstanceOf($result, NonNull::class); @@ -86,29 +88,30 @@ public function toGraphQLOutputType(Type $type, ?OutputType $subType, Reflection /** * @param (InputType&GraphQLType)|null $subType + * @param ReflectionMethod|ReflectionProperty $reflector * * @return InputType&GraphQLType */ - public function toGraphQLInputType(Type $type, ?InputType $subType, string $argumentName, ReflectionMethod $refMethod, DocBlock $docBlockObj): InputType + public function toGraphQLInputType(Type $type, ?InputType $subType, string $argumentName, $reflector, DocBlock $docBlockObj): InputType { if (! $type instanceof Compound) { //try { - return $this->next->toGraphQLInputType($type, $subType, $argumentName, $refMethod, $docBlockObj); + return $this->next->toGraphQLInputType($type, $subType, $argumentName, $reflector, $docBlockObj); /*} catch (CannotMapTypeException $e) { $this->throwIterableMissingTypeHintException($e, $type); }*/ } - $result = $this->toGraphQLType($type, function (Type $type, ?InputType $subType) use ($refMethod, $docBlockObj, $argumentName) { - $topType = $this->topRootTypeMapper->toGraphQLInputType($type, $subType, $argumentName, $refMethod, $docBlockObj); + $result = $this->toGraphQLType($type, function (Type $type, ?InputType $subType) use ($reflector, $docBlockObj, $argumentName) { + $topType = $this->topRootTypeMapper->toGraphQLInputType($type, $subType, $argumentName, $reflector, $docBlockObj); if ($topType instanceof NonNull) { $topType = $topType->getWrappedType(); } return $topType; }, false); if ($result === null) { - return $this->next->toGraphQLInputType($type, $subType, $argumentName, $refMethod, $docBlockObj); + return $this->next->toGraphQLInputType($type, $subType, $argumentName, $reflector, $docBlockObj); } Assert::isInstanceOf($result, InputType::class); diff --git a/src/Mappers/Root/MyCLabsEnumTypeMapper.php b/src/Mappers/Root/MyCLabsEnumTypeMapper.php index e2ec3178b8..d7fb79d889 100644 --- a/src/Mappers/Root/MyCLabsEnumTypeMapper.php +++ b/src/Mappers/Root/MyCLabsEnumTypeMapper.php @@ -15,6 +15,7 @@ use phpDocumentor\Reflection\Types\Object_; use ReflectionClass; use ReflectionMethod; +use ReflectionProperty; use Symfony\Contracts\Cache\CacheInterface; use TheCodingMachine\GraphQLite\AnnotationReader; use TheCodingMachine\GraphQLite\Types\MyCLabsEnumType; @@ -55,14 +56,15 @@ public function __construct(RootTypeMapperInterface $next, AnnotationReader $ann /** * @param (OutputType&GraphQLType)|null $subType + * @param ReflectionMethod|ReflectionProperty $reflector * * @return OutputType&GraphQLType */ - public function toGraphQLOutputType(Type $type, ?OutputType $subType, ReflectionMethod $refMethod, DocBlock $docBlockObj): OutputType + public function toGraphQLOutputType(Type $type, ?OutputType $subType, $reflector, DocBlock $docBlockObj): OutputType { $result = $this->map($type); if ($result === null) { - return $this->next->toGraphQLOutputType($type, $subType, $refMethod, $docBlockObj); + return $this->next->toGraphQLOutputType($type, $subType, $reflector, $docBlockObj); } return $result; @@ -70,14 +72,15 @@ public function toGraphQLOutputType(Type $type, ?OutputType $subType, Reflection /** * @param (InputType&GraphQLType)|null $subType + * @param ReflectionMethod|ReflectionProperty $reflector * * @return InputType&GraphQLType */ - public function toGraphQLInputType(Type $type, ?InputType $subType, string $argumentName, ReflectionMethod $refMethod, DocBlock $docBlockObj): InputType + public function toGraphQLInputType(Type $type, ?InputType $subType, string $argumentName, $reflector, DocBlock $docBlockObj): InputType { $result = $this->map($type); if ($result === null) { - return $this->next->toGraphQLInputType($type, $subType, $argumentName, $refMethod, $docBlockObj); + return $this->next->toGraphQLInputType($type, $subType, $argumentName, $reflector, $docBlockObj); } return $result; diff --git a/src/Mappers/Root/NullableTypeMapperAdapter.php b/src/Mappers/Root/NullableTypeMapperAdapter.php index cf9c8cf552..7139e2e84e 100644 --- a/src/Mappers/Root/NullableTypeMapperAdapter.php +++ b/src/Mappers/Root/NullableTypeMapperAdapter.php @@ -16,6 +16,7 @@ use phpDocumentor\Reflection\Types\Null_; use phpDocumentor\Reflection\Types\Nullable; use ReflectionMethod; +use ReflectionProperty; use TheCodingMachine\GraphQLite\Mappers\CannotMapTypeException; use TheCodingMachine\GraphQLite\Types\ResolvableMutableInputObjectType; @@ -41,10 +42,11 @@ public function setNext(RootTypeMapperInterface $next): void /** * @param (OutputType&GraphQLType)|null $subType + * @param ReflectionMethod|ReflectionProperty $reflector * * @return OutputType&GraphQLType */ - public function toGraphQLOutputType(Type $type, ?OutputType $subType, ReflectionMethod $refMethod, DocBlock $docBlockObj): OutputType + public function toGraphQLOutputType(Type $type, ?OutputType $subType, $reflector, DocBlock $docBlockObj): OutputType { // Let's check a "null" value in the docblock $isNullable = $this->isNullable($type); @@ -57,7 +59,7 @@ public function toGraphQLOutputType(Type $type, ?OutputType $subType, Reflection $type = $nonNullableType; } - $graphQlType = $this->next->toGraphQLOutputType($type, $subType, $refMethod, $docBlockObj); + $graphQlType = $this->next->toGraphQLOutputType($type, $subType, $reflector, $docBlockObj); if ($graphQlType instanceof NonNull) { throw CannotMapTypeException::createForNonNullReturnByTypeMapper(); @@ -72,10 +74,11 @@ public function toGraphQLOutputType(Type $type, ?OutputType $subType, Reflection /** * @param (InputType&GraphQLType)|null $subType + * @param ReflectionMethod|ReflectionProperty $reflector * * @return InputType&GraphQLType */ - public function toGraphQLInputType(Type $type, ?InputType $subType, string $argumentName, ReflectionMethod $refMethod, DocBlock $docBlockObj): InputType + public function toGraphQLInputType(Type $type, ?InputType $subType, string $argumentName, $reflector, DocBlock $docBlockObj): InputType { // Let's check a "null" value in the docblock $isNullable = $this->isNullable($type); @@ -88,7 +91,7 @@ public function toGraphQLInputType(Type $type, ?InputType $subType, string $argu $type = $nonNullableType; } - $graphQlType = $this->next->toGraphQLInputType($type, $subType, $argumentName, $refMethod, $docBlockObj); + $graphQlType = $this->next->toGraphQLInputType($type, $subType, $argumentName, $reflector, $docBlockObj); // The type is non nullable if the PHP argument is non nullable // There is an exception: if the PHP argument is non nullable but points to a factory that can called without passing any argument, diff --git a/src/Mappers/Root/RootTypeMapperInterface.php b/src/Mappers/Root/RootTypeMapperInterface.php index 687e68700b..6481058a76 100644 --- a/src/Mappers/Root/RootTypeMapperInterface.php +++ b/src/Mappers/Root/RootTypeMapperInterface.php @@ -11,6 +11,7 @@ use phpDocumentor\Reflection\DocBlock; use phpDocumentor\Reflection\Type; use ReflectionMethod; +use ReflectionProperty; /** * Maps a method return type or argument to a GraphQL Type. @@ -26,17 +27,19 @@ interface RootTypeMapperInterface { /** * @param (OutputType&GraphQLType)|null $subType + * @param ReflectionMethod|ReflectionProperty $reflector * * @return OutputType&GraphQLType */ - public function toGraphQLOutputType(Type $type, ?OutputType $subType, ReflectionMethod $refMethod, DocBlock $docBlockObj): OutputType; + public function toGraphQLOutputType(Type $type, ?OutputType $subType, $reflector, DocBlock $docBlockObj): OutputType; /** * @param (InputType&GraphQLType)|null $subType + * @param ReflectionMethod|ReflectionProperty $reflector * * @return InputType&GraphQLType */ - public function toGraphQLInputType(Type $type, ?InputType $subType, string $argumentName, ReflectionMethod $refMethod, DocBlock $docBlockObj): InputType; + public function toGraphQLInputType(Type $type, ?InputType $subType, string $argumentName, $reflector, DocBlock $docBlockObj): InputType; /** * Returns a GraphQL type by name. diff --git a/src/Middlewares/MagicPropertyResolver.php b/src/Middlewares/MagicPropertyResolver.php index 3804ce6ad9..089febd97c 100644 --- a/src/Middlewares/MagicPropertyResolver.php +++ b/src/Middlewares/MagicPropertyResolver.php @@ -17,7 +17,7 @@ * * @internal */ -class MagicPropertyResolver implements ResolverInterface +class MagicPropertyResolver implements SourceResolverInterface { /** @var string */ private $propertyName; diff --git a/src/Middlewares/SourcePropertyResolver.php b/src/Middlewares/SourcePropertyResolver.php new file mode 100644 index 0000000000..fb33748058 --- /dev/null +++ b/src/Middlewares/SourcePropertyResolver.php @@ -0,0 +1,68 @@ +propertyName = $propertyName; + } + + public function setObject(object $object): void + { + $this->object = $object; + } + + public function getObject(): object + { + Assert::notNull($this->object); + + return $this->object; + } + + /** + * @param mixed $args + * + * @return mixed + */ + public function __invoke(...$args) + { + if ($this->object === null) { + throw new GraphQLRuntimeException('You must call "setObject" on SourceResolver before invoking the object.'); + } + + return PropertyAccessor::getValue($this->object, $this->propertyName, ...$args); + } + + public function toString(): string + { + $class = $this->getObject(); + if (is_object($class)) { + $class = get_class($class); + } + + return $class . '::' . $this->propertyName; + } +} diff --git a/src/Middlewares/SourceResolver.php b/src/Middlewares/SourceResolver.php index 8f2461aeaf..8634341c86 100644 --- a/src/Middlewares/SourceResolver.php +++ b/src/Middlewares/SourceResolver.php @@ -16,7 +16,7 @@ * * @internal */ -class SourceResolver implements ResolverInterface +class SourceResolver implements SourceResolverInterface { /** @var string */ private $methodName; diff --git a/src/Middlewares/SourceResolverInterface.php b/src/Middlewares/SourceResolverInterface.php new file mode 100644 index 0000000000..2ee9a62b8a --- /dev/null +++ b/src/Middlewares/SourceResolverInterface.php @@ -0,0 +1,16 @@ +getName(); + $inputTypeName = $input->getName(); if ($inputTypeName !== null) { return $inputTypeName; } @@ -73,6 +75,10 @@ public function getInputTypeName(string $className, Factory $factory): string $className = substr($className, $prevPos + 1); } + if (substr($className, -5) === 'Input') { + return $className; + } + return $className . 'Input'; } diff --git a/src/NamingStrategyInterface.php b/src/NamingStrategyInterface.php index 21e12e8785..173b15e3c5 100644 --- a/src/NamingStrategyInterface.php +++ b/src/NamingStrategyInterface.php @@ -5,6 +5,7 @@ namespace TheCodingMachine\GraphQLite; use TheCodingMachine\GraphQLite\Annotations\Factory; +use TheCodingMachine\GraphQLite\Annotations\Input; use TheCodingMachine\GraphQLite\Annotations\Type; /** @@ -29,7 +30,13 @@ public function getConcreteNameFromInterfaceName(string $name): string; */ public function getOutputTypeName(string $typeClassName, Type $type): string; - public function getInputTypeName(string $className, Factory $factory): string; + /** + * Returns the GraphQL input object type name based on the type className and the Input annotation. + * + * @param class-string $className + * @param Input|Factory $input + */ + public function getInputTypeName(string $className, $input): string; /** * Returns the name of a GraphQL field from the name of the annotated method. diff --git a/src/Parameters/InputTypeParameter.php b/src/Parameters/InputTypeParameter.php index bf47709ddb..87b18ad26f 100644 --- a/src/Parameters/InputTypeParameter.php +++ b/src/Parameters/InputTypeParameter.php @@ -22,6 +22,8 @@ class InputTypeParameter implements InputTypeParameterInterface private $defaultValue; /** @var ArgumentResolver */ private $argumentResolver; + /** @var string */ + private $description; /** * @param InputType&Type $type @@ -61,6 +63,11 @@ public function resolve(?object $source, array $args, $context, ResolveInfo $inf throw MissingArgumentException::create($this->name); } + public function getName(): string + { + return $this->name; + } + public function getType(): InputType { return $this->type; @@ -78,4 +85,14 @@ public function getDefaultValue() { return $this->defaultValue; } + + public function getDescription(): string + { + return $this->description; + } + + public function setDescription(string $description): void + { + $this->description = $description; + } } diff --git a/src/Parameters/InputTypeProperty.php b/src/Parameters/InputTypeProperty.php new file mode 100644 index 0000000000..7e1750f7d1 --- /dev/null +++ b/src/Parameters/InputTypeProperty.php @@ -0,0 +1,30 @@ +propertyName = $propertyName; + } + + public function getPropertyName(): string + { + return $this->propertyName; + } +} diff --git a/src/QueryField.php b/src/QueryField.php index 0dd8ea6622..5ea61a0a4a 100644 --- a/src/QueryField.php +++ b/src/QueryField.php @@ -14,10 +14,9 @@ use GraphQL\Type\Definition\Type; use TheCodingMachine\GraphQLite\Context\ContextInterface; use TheCodingMachine\GraphQLite\Exceptions\GraphQLAggregateException; -use TheCodingMachine\GraphQLite\Middlewares\MagicPropertyResolver; use TheCodingMachine\GraphQLite\Middlewares\MissingAuthorizationException; use TheCodingMachine\GraphQLite\Middlewares\ResolverInterface; -use TheCodingMachine\GraphQLite\Middlewares\SourceResolver; +use TheCodingMachine\GraphQLite\Middlewares\SourceResolverInterface; use TheCodingMachine\GraphQLite\Parameters\MissingArgumentException; use TheCodingMachine\GraphQLite\Parameters\ParameterInterface; use TheCodingMachine\GraphQLite\Parameters\PrefetchDataParameter; @@ -58,7 +57,7 @@ public function __construct(string $name, OutputType $type, array $arguments, Re } $resolveFn = function ($source, array $args, $context, ResolveInfo $info) use ($arguments, $originalResolver, $resolver) { - if ($originalResolver instanceof SourceResolver || $originalResolver instanceof MagicPropertyResolver) { + if ($originalResolver instanceof SourceResolverInterface) { $originalResolver->setObject($source); } /*if ($resolve !== null) { @@ -106,7 +105,7 @@ public function __construct(string $name, OutputType $type, array $arguments, Re return new Deferred(function () use ($prefetchBuffer, $source, $args, $context, $info, $prefetchArgs, $prefetchMethodName, $arguments, $resolveFn, $originalResolver) { if (! $prefetchBuffer->hasResult($args)) { - if ($originalResolver instanceof SourceResolver || $originalResolver instanceof MagicPropertyResolver) { + if ($originalResolver instanceof SourceResolverInterface) { $originalResolver->setObject($source); } diff --git a/src/QueryFieldDescriptor.php b/src/QueryFieldDescriptor.php index df0a324888..cd45f7cac1 100644 --- a/src/QueryFieldDescriptor.php +++ b/src/QueryFieldDescriptor.php @@ -7,13 +7,17 @@ use GraphQL\Type\Definition\OutputType; use GraphQL\Type\Definition\Type; use ReflectionMethod; +use ReflectionProperty; use TheCodingMachine\GraphQLite\Annotations\MiddlewareAnnotations; use TheCodingMachine\GraphQLite\Middlewares\MagicPropertyResolver; use TheCodingMachine\GraphQLite\Middlewares\ResolverInterface; use TheCodingMachine\GraphQLite\Middlewares\ServiceResolver; +use TheCodingMachine\GraphQLite\Middlewares\SourcePropertyResolver; use TheCodingMachine\GraphQLite\Middlewares\SourceResolver; use TheCodingMachine\GraphQLite\Parameters\ParameterInterface; +use function is_array; + /** * A class that describes a field to be created. * To contains getters and setters to alter the field behaviour. @@ -31,11 +35,13 @@ class QueryFieldDescriptor private $prefetchParameters = []; /** @var string|null */ private $prefetchMethodName; - /** @var (callable&array{0:object, 1:string})|null */ + /** @var callable|null */ private $callable; /** @var string|null */ private $targetMethodOnSource; /** @var string|null */ + private $targetPropertyOnSource; + /** @var string|null */ private $magicProperty; /** * Whether we should inject the source as the first parameter or not. @@ -51,6 +57,8 @@ class QueryFieldDescriptor private $middlewareAnnotations; /** @var ReflectionMethod */ private $refMethod; + /** @var ReflectionProperty */ + private $refProperty; /** @var ResolverInterface */ private $originalResolver; /** @var callable */ @@ -128,8 +136,6 @@ public function setPrefetchMethodName(?string $prefetchMethodName): void * Sets the callable targeting the resolver function if the resolver function is part of a service. * This should not be used in the context of a field middleware. * Use getResolver/setResolver if you want to wrap the resolver in another method. - * - * @param callable&array{0:object, 1:string} $callable */ public function setCallable(callable $callable): void { @@ -138,6 +144,7 @@ public function setCallable(callable $callable): void } $this->callable = $callable; $this->targetMethodOnSource = null; + $this->targetPropertyOnSource = null; $this->magicProperty = null; } @@ -148,6 +155,18 @@ public function setTargetMethodOnSource(string $targetMethodOnSource): void } $this->callable = null; $this->targetMethodOnSource = $targetMethodOnSource; + $this->targetPropertyOnSource = null; + $this->magicProperty = null; + } + + public function setTargetPropertyOnSource(?string $targetPropertyOnSource): void + { + if ($this->originalResolver !== null) { + throw new GraphQLRuntimeException('You cannot modify the target method via setTargetMethodOnSource because it was already used. You can still wrap the callable using getResolver/setResolver'); + } + $this->callable = null; + $this->targetMethodOnSource = null; + $this->targetPropertyOnSource = $targetPropertyOnSource; $this->magicProperty = null; } @@ -158,6 +177,7 @@ public function setMagicProperty(string $magicProperty): void } $this->callable = null; $this->targetMethodOnSource = null; + $this->targetPropertyOnSource = null; $this->magicProperty = $magicProperty; } @@ -211,6 +231,16 @@ public function setRefMethod(ReflectionMethod $refMethod): void $this->refMethod = $refMethod; } + public function getRefProperty(): ReflectionProperty + { + return $this->refProperty; + } + + public function setRefProperty(ReflectionProperty $refProperty): void + { + $this->refProperty = $refProperty; + } + /** * Returns the original callable that will be used to resolve the field. */ @@ -220,10 +250,14 @@ public function getOriginalResolver(): ResolverInterface return $this->originalResolver; } - if ($this->callable !== null) { - $this->originalResolver = new ServiceResolver($this->callable); + if (is_array($this->callable)) { + /** @var callable&array{0:object, 1:string} $callable */ + $callable = $this->callable; + $this->originalResolver = new ServiceResolver($callable); } elseif ($this->targetMethodOnSource !== null) { $this->originalResolver = new SourceResolver($this->targetMethodOnSource); + } elseif ($this->targetPropertyOnSource !== null) { + $this->originalResolver = new SourcePropertyResolver($this->targetPropertyOnSource); } elseif ($this->magicProperty !== null) { $this->originalResolver = new MagicPropertyResolver($this->magicProperty); } else { diff --git a/src/Reflection/CachedDocBlockFactory.php b/src/Reflection/CachedDocBlockFactory.php index c729ab18d4..188aae3067 100644 --- a/src/Reflection/CachedDocBlockFactory.php +++ b/src/Reflection/CachedDocBlockFactory.php @@ -9,11 +9,14 @@ use phpDocumentor\Reflection\Types\Context; use phpDocumentor\Reflection\Types\ContextFactory; use Psr\SimpleCache\CacheInterface; +use Psr\SimpleCache\InvalidArgumentException; use ReflectionClass; use ReflectionMethod; +use ReflectionProperty; use Webmozart\Assert\Assert; use function filemtime; +use function get_class; use function md5; /** @@ -44,15 +47,19 @@ public function __construct(CacheInterface $cache, ?DocBlockFactory $docBlockFac /** * Fetches a DocBlock object from a ReflectionMethod + * + * @param ReflectionMethod|ReflectionProperty $reflector + * + * @throws InvalidArgumentException */ - public function getDocBlock(ReflectionMethod $refMethod): DocBlock + public function getDocBlock($reflector): DocBlock { - $key = 'docblock_' . md5($refMethod->getDeclaringClass()->getName() . '::' . $refMethod->getName()); + $key = 'docblock_' . md5($reflector->getDeclaringClass()->getName() . '::' . $reflector->getName() . '::' . get_class($reflector)); if (isset($this->docBlockArrayCache[$key])) { return $this->docBlockArrayCache[$key]; } - $fileName = $refMethod->getFileName(); + $fileName = $reflector->getDeclaringClass()->getFileName(); Assert::string($fileName); $cacheItem = $this->cache->get($key); @@ -69,7 +76,7 @@ public function getDocBlock(ReflectionMethod $refMethod): DocBlock } } - $docBlock = $this->doGetDocBlock($refMethod); + $docBlock = $this->doGetDocBlock($reflector); $this->cache->set($key, [ 'time' => filemtime($fileName), @@ -80,15 +87,18 @@ public function getDocBlock(ReflectionMethod $refMethod): DocBlock return $docBlock; } - private function doGetDocBlock(ReflectionMethod $refMethod): DocBlock + /** + * @param ReflectionMethod|ReflectionProperty $reflector + */ + private function doGetDocBlock($reflector): DocBlock { - $docComment = $refMethod->getDocComment() ?: '/** */'; + $docComment = $reflector->getDocComment() ?: '/** */'; - $refClass = $refMethod->getDeclaringClass(); + $refClass = $reflector->getDeclaringClass(); $refClassName = $refClass->getName(); if (! isset($this->contextArrayCache[$refClassName])) { - $this->contextArrayCache[$refClassName] = $this->contextFactory->createFromReflector($refMethod); + $this->contextArrayCache[$refClassName] = $this->contextFactory->createFromReflector($reflector); } return $this->docBlockFactory->create($docComment, $this->contextArrayCache[$refClassName]); diff --git a/src/SchemaFactory.php b/src/SchemaFactory.php index f1c6011bb0..220033087e 100644 --- a/src/SchemaFactory.php +++ b/src/SchemaFactory.php @@ -52,6 +52,7 @@ use TheCodingMachine\GraphQLite\Utils\NamespacedCache; use TheCodingMachine\GraphQLite\Utils\Namespaces\NamespaceFactory; +use function apcu_enabled; use function array_map; use function array_reverse; use function class_exists; diff --git a/src/TypeGenerator.php b/src/TypeGenerator.php index d9c85f1712..74907ff936 100644 --- a/src/TypeGenerator.php +++ b/src/TypeGenerator.php @@ -139,7 +139,7 @@ public function extendAnnotatedObject(object $annotatedObject, MutableInterface } */ - $type->addFields(function () use ($annotatedObject) { + $type->addFields(function () use ($annotatedObject, $typeName) { /*$parentClass = get_parent_class($extendTypeAnnotation->getClass()); $parentType = null; if ($parentClass !== false) { @@ -147,7 +147,7 @@ public function extendAnnotatedObject(object $annotatedObject, MutableInterface $parentType = $recursiveTypeMapper->mapClassToType($parentClass, null); } }*/ - return $this->fieldsBuilder->getFields($annotatedObject); + return $this->fieldsBuilder->getFields($annotatedObject, $typeName); /*if ($parentType !== null) { $fields = $parentType->getFields() + $fields; diff --git a/src/Types/InputType.php b/src/Types/InputType.php new file mode 100644 index 0000000000..4189c0b79a --- /dev/null +++ b/src/Types/InputType.php @@ -0,0 +1,150 @@ + */ + private $className; + + /** + * @param class-string $className + */ + public function __construct(string $className, string $inputName, ?string $description, bool $isUpdate, FieldsBuilder $fieldsBuilder) + { + $reflection = new ReflectionClass($className); + if (! $reflection->isInstantiable()) { + throw FailedResolvingInputType::createForNotInstantiableClass($className); + } + + $this->fields = $fieldsBuilder->getInputFields($className, $inputName, $isUpdate); + + $fields = function () use ($isUpdate) { + $fields = []; + foreach ($this->fields as $name => $field) { + $type = $field->getType(); + + if ($isUpdate && $type instanceof NonNull) { + $type = $type->getWrappedType(); + } + + $fields[$name] = [ + 'type' => $type, + 'description' => $field->getDescription(), + ]; + + if (! $field->hasDefaultValue() || $isUpdate) { + continue; + } + + $fields[$name]['defaultValue'] = $field->getDefaultValue(); + } + + return $fields; + }; + + $config = [ + 'name' => $inputName, + 'description' => $description, + 'fields' => $fields, + ]; + + parent::__construct($config); + $this->className = $className; + } + + /** + * @param array $args + * @param mixed $context + */ + public function resolve(?object $source, array $args, $context, ResolveInfo $resolveInfo): object + { + $mappedValues = []; + foreach ($this->fields as $field) { + $name = $field->getName(); + if (! array_key_exists($name, $args)) { + continue; + } + + $mappedValues[$field->getPropertyName()] = $field->resolve($source, $args, $context, $resolveInfo); + } + + $instance = $this->createInstance($mappedValues); + $values = array_diff_key($mappedValues, array_flip($this->getClassConstructParameterNames())); + + foreach ($values as $property => $value) { + PropertyAccessor::setValue($instance, $property, $value); + } + + return $instance; + } + + public function decorate(callable $decorator): void + { + throw FailedResolvingInputType::createForDecorator($this->className); + } + + /** + * Creates an instance of the input class. + * + * @param array $values + */ + private function createInstance(array $values): object + { + $refClass = new ReflectionClass($this->className); + $constructor = $refClass->getConstructor(); + $constructorParameters = $constructor ? $constructor->getParameters() : []; + + $parameters = []; + foreach ($constructorParameters as $parameter) { + $name = $parameter->getName(); + if (! $parameter->isDefaultValueAvailable() && empty($values[$name])) { + throw FailedResolvingInputType::createForMissingConstructorParameter($refClass->getName(), $name); + } + + $parameters[] = $values[$name] ?? $parameter->getDefaultValue(); + } + + return $refClass->newInstanceArgs($parameters); + } + + /** + * @return string[] + */ + private function getClassConstructParameterNames(): array + { + $refClass = new ReflectionClass($this->className); + $constructor = $refClass->getConstructor(); + + if (! $constructor) { + return []; + } + + $names = []; + foreach ($constructor->getParameters() as $parameter) { + $names[] = $parameter->getName(); + } + + return $names; + } +} diff --git a/src/Types/TypeAnnotatedInterfaceType.php b/src/Types/TypeAnnotatedInterfaceType.php index 835fb46e23..d651b76f43 100644 --- a/src/Types/TypeAnnotatedInterfaceType.php +++ b/src/Types/TypeAnnotatedInterfaceType.php @@ -42,7 +42,7 @@ public static function createFromAnnotatedClass(string $typeName, string $classN { return new self($className, [ 'name' => $typeName, - 'fields' => static function () use ($annotatedObject, $className, $fieldsBuilder) { + 'fields' => static function () use ($annotatedObject, $className, $fieldsBuilder, $typeName) { // There is no need for an interface that extends another interface to get all its fields. // Indeed, if the interface is used, the extended interfaces will be used too. Therefore, fetching the fields // and putting them in the child interface is a waste of resources. @@ -65,9 +65,9 @@ public static function createFromAnnotatedClass(string $typeName, string $classN }*/ if ($annotatedObject !== null) { - $fields = $fieldsBuilder->getFields($annotatedObject); + $fields = $fieldsBuilder->getFields($annotatedObject, $typeName); } else { - $fields = $fieldsBuilder->getSelfFields($className); + $fields = $fieldsBuilder->getSelfFields($className, $typeName); } //$fields += $interfaceFields; diff --git a/src/Types/TypeAnnotatedObjectType.php b/src/Types/TypeAnnotatedObjectType.php index 791004f8c3..e1dc286a89 100644 --- a/src/Types/TypeAnnotatedObjectType.php +++ b/src/Types/TypeAnnotatedObjectType.php @@ -31,7 +31,7 @@ public static function createFromAnnotatedClass(string $typeName, string $classN { return new self($className, [ 'name' => $typeName, - 'fields' => static function () use ($annotatedObject, $recursiveTypeMapper, $className, $fieldsBuilder) { + 'fields' => static function () use ($annotatedObject, $recursiveTypeMapper, $className, $fieldsBuilder, $typeName) { $parentClass = get_parent_class($className); $parentType = null; if ($parentClass !== false) { @@ -41,9 +41,9 @@ public static function createFromAnnotatedClass(string $typeName, string $classN } if ($annotatedObject !== null) { - $fields = $fieldsBuilder->getFields($annotatedObject); + $fields = $fieldsBuilder->getFields($annotatedObject, $typeName); } else { - $fields = $fieldsBuilder->getSelfFields($className); + $fields = $fieldsBuilder->getSelfFields($className, $typeName); } if ($parentType !== null) { $finalFields = $parentType->getFields(); diff --git a/src/Utils/AccessPropertyException.php b/src/Utils/AccessPropertyException.php new file mode 100644 index 0000000000..87d2f1fbfc --- /dev/null +++ b/src/Utils/AccessPropertyException.php @@ -0,0 +1,27 @@ +$method(...$args); + } + + if (self::isPublicProperty($class, $propertyName)) { + return $object->$propertyName; + } + + throw AccessPropertyException::createForUnreadableProperty($class, $propertyName); + } + + /** + * @param mixed $value + */ + public static function setValue(object $instance, string $propertyName, $value): void + { + $class = get_class($instance); + + $setter = self::findSetter($class, $propertyName); + if ($setter) { + $instance->$setter($value); + return; + } + + if (self::isPublicProperty($class, $propertyName)) { + $instance->$propertyName = $value; + return; + } + + throw AccessPropertyException::createForUnwritableProperty($class, $propertyName); + } + + private static function isPublicProperty(string $class, string $propertyName): bool + { + if (! property_exists($class, $propertyName)) { + return false; + } + + $reflection = new ReflectionProperty($class, $propertyName); + + return $reflection->isPublic(); + } + + private static function isPublicMethod(string $class, string $methodName): bool + { + if (! method_exists($class, $methodName)) { + return false; + } + + $reflection = new ReflectionMethod($class, $methodName); + + return $reflection->isPublic(); + } + + private static function isValidGetter(string $class, string $methodName): bool + { + $reflection = new ReflectionMethod($class, $methodName); + foreach ($reflection->getParameters() as $parameter) { + if (! $parameter->isDefaultValueAvailable()) { + return false; + } + } + + return true; + } +} diff --git a/tests/Fixtures/DuplicateInputs/Foo.php b/tests/Fixtures/DuplicateInputs/Foo.php new file mode 100644 index 0000000000..8c5acc9356 --- /dev/null +++ b/tests/Fixtures/DuplicateInputs/Foo.php @@ -0,0 +1,12 @@ +foo = $foo; + $this->bar = $bar; + } +} diff --git a/tests/Fixtures/Inputs/InputInterface.php b/tests/Fixtures/Inputs/InputInterface.php new file mode 100644 index 0000000000..744299c5f5 --- /dev/null +++ b/tests/Fixtures/Inputs/InputInterface.php @@ -0,0 +1,12 @@ +foo = $foo; + $this->bar = $bar; + } + + public function setFoo(string $foo): void + { + throw new Exception('This should not be called!'); + } + + public function setBar(int $bar): void + { + throw new Exception('This should not be called!'); + } + + public function getFoo(): string + { + return $this->foo; + } + + public function getBar(): int + { + return $this->bar; + } +} diff --git a/tests/Fixtures/Inputs/TypedFooBar.php b/tests/Fixtures/Inputs/TypedFooBar.php new file mode 100644 index 0000000000..770eb24fd9 --- /dev/null +++ b/tests/Fixtures/Inputs/TypedFooBar.php @@ -0,0 +1,23 @@ +id = $id; + + return $post; + } +} diff --git a/tests/Fixtures/Integration/Models/Article.php b/tests/Fixtures/Integration/Models/Article.php new file mode 100644 index 0000000000..0cf0f3a80d --- /dev/null +++ b/tests/Fixtures/Integration/Models/Article.php @@ -0,0 +1,27 @@ +age; + } + + /** + * @return string + */ + public function getStatus(): string + { + return 'bar'; + } + + /** + * @return string + */ + public function getZipcode(string $foo): string + { + return $this->zipcode; + } + + /** + * @return string + */ + private function getAddress(): string + { + return $this->address; + } + /** * @Field() * @Autowire(for="testService", identifier="testService") diff --git a/tests/Fixtures/Integration/Models/Post.php b/tests/Fixtures/Integration/Models/Post.php new file mode 100644 index 0000000000..31b5270780 --- /dev/null +++ b/tests/Fixtures/Integration/Models/Post.php @@ -0,0 +1,107 @@ +title = $title; + $this->description = 'bar'; + } + + /** + * @return string|null + */ + public function getDescription(): ?string + { + return $this->description; + } + + /** + * @param string|null $description + */ + public function setDescription(?string $description): void + { + $this->description = $description; + } + + /** + * @param string|null $summary + */ + public function setSummary(?string $summary): void + { + $this->summary = $summary; + } + + /** + * @param string $inaccessible + */ + private function setInaccessible(string $inaccessible): void + { + $this->inaccessible = $inaccessible; + } +} diff --git a/tests/Fixtures/NonInstantiableInput/AbstractFoo.php b/tests/Fixtures/NonInstantiableInput/AbstractFoo.php new file mode 100644 index 0000000000..ebf794b6e8 --- /dev/null +++ b/tests/Fixtures/NonInstantiableInput/AbstractFoo.php @@ -0,0 +1,19 @@ +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 { /** @@ -325,7 +340,7 @@ public function testEndToEnd(): void ] ] - ], $result->toArray(Debug::RETHROW_INTERNAL_EXCEPTIONS)['data']); + ], $this->getSuccessResult($result)); // Let's redo this to test cache. $result = GraphQL::executeQuery( @@ -354,7 +369,7 @@ public function testEndToEnd(): void ] ] - ], $result->toArray(Debug::RETHROW_INTERNAL_EXCEPTIONS)['data']); + ], $this->getSuccessResult($result)); } public function testDeprecatedField(): void @@ -400,7 +415,7 @@ public function testDeprecatedField(): void ] ] - ], $result->toArray(Debug::RETHROW_INTERNAL_EXCEPTIONS)['data']); + ], $this->getSuccessResult($result)); // Let's introspect to see if the field is marked as deprecated // in the resulting GraphQL schema @@ -423,7 +438,7 @@ public function testDeprecatedField(): void new Context() ); - $fields = $result->toArray(Debug::RETHROW_INTERNAL_EXCEPTIONS)['data']['__type']['fields']; + $fields = $this->getSuccessResult($result)['__type']['fields']; $deprecatedFields = [ 'deprecatedUppercaseName', 'deprecatedName' @@ -511,7 +526,7 @@ public function testEndToEndInputTypeDate() 'name' => 'Bill', 'birthDate' => '1942-12-24T00:00:00+00:00', ] - ], $result->toArray(Debug::RETHROW_INTERNAL_EXCEPTIONS)['data']); + ], $this->getSuccessResult($result)); } public function testEndToEndInputTypeDateAsParam() @@ -544,7 +559,7 @@ public function testEndToEndInputTypeDateAsParam() 'name' => 'Bill', 'birthDate' => '1942-12-24T00:00:00+00:00', ] - ], $result->toArray(Debug::RETHROW_INTERNAL_EXCEPTIONS)['data']); + ], $this->getSuccessResult($result)); } public function testEndToEndInputType() @@ -590,7 +605,7 @@ public function testEndToEndInputType() ] ] ] - ], $result->toArray(Debug::RETHROW_INTERNAL_EXCEPTIONS)['data']); + ], $this->getSuccessResult($result)); } public function testEndToEndPorpaginas(): void @@ -631,7 +646,7 @@ public function testEndToEndPorpaginas(): void ], 'count' => 2 ] - ], $result->toArray(Debug::RETHROW_INTERNAL_EXCEPTIONS)['data']); + ], $this->getSuccessResult($result)); // Let's redo this to test cache. $result = GraphQL::executeQuery( @@ -650,7 +665,7 @@ public function testEndToEndPorpaginas(): void ], 'count' => 2 ] - ], $result->toArray(Debug::RETHROW_INTERNAL_EXCEPTIONS)['data']); + ], $this->getSuccessResult($result)); // Let's run a query with no limit but an offset $invalidQueryString = ' @@ -708,7 +723,7 @@ public function testEndToEndPorpaginas(): void ], 'count' => 2 ] - ], $result->toArray(Debug::RETHROW_INTERNAL_EXCEPTIONS)['data']); + ], $this->getSuccessResult($result)); } public function testEndToEndPorpaginasOnScalarType(): void @@ -737,7 +752,7 @@ public function testEndToEndPorpaginasOnScalarType(): void 'items' => ['Bill'], 'count' => 2 ] - ], $result->toArray(Debug::RETHROW_INTERNAL_EXCEPTIONS)['data']); + ], $this->getSuccessResult($result)); } /** @@ -800,7 +815,7 @@ public function testEndToEnd2Iterators(): void ], 'count' => 1 ] - ], $result->toArray(Debug::RETHROW_INTERNAL_EXCEPTIONS)['data']); + ], $this->getSuccessResult($result)); } @@ -824,7 +839,7 @@ public function testEndToEndStaticFactories(): void $this->assertSame([ 'echoFilters' => [ "foo", "bar", "12", "42", "62" ] - ], $result->toArray(Debug::RETHROW_INTERNAL_EXCEPTIONS)['data']); + ], $this->getSuccessResult($result)); // Call again to test GlobTypeMapper cache $result = GraphQL::executeQuery( @@ -834,7 +849,7 @@ public function testEndToEndStaticFactories(): void $this->assertSame([ 'echoFilters' => [ "foo", "bar", "12", "42", "62" ] - ], $result->toArray(Debug::RETHROW_INTERNAL_EXCEPTIONS)['data']); + ], $this->getSuccessResult($result)); } public function testNonNullableTypesWithOptionnalFactoryArguments(): void @@ -857,7 +872,7 @@ public function testNonNullableTypesWithOptionnalFactoryArguments(): void $this->assertSame([ 'echoFilters' => [] - ], $result->toArray(Debug::RETHROW_INTERNAL_EXCEPTIONS)['data']); + ], $this->getSuccessResult($result)); } public function testNullableTypesWithOptionnalFactoryArguments(): void @@ -880,7 +895,7 @@ public function testNullableTypesWithOptionnalFactoryArguments(): void $this->assertSame([ 'echoNullableFilters' => null - ], $result->toArray(Debug::RETHROW_INTERNAL_EXCEPTIONS)['data']); + ], $this->getSuccessResult($result)); } public function testEndToEndResolveInfo(): void @@ -903,7 +918,7 @@ public function testEndToEndResolveInfo(): void $this->assertSame([ 'echoResolveInfo' => 'echoResolveInfo' - ], $result->toArray(Debug::RETHROW_INTERNAL_EXCEPTIONS)['data']); + ], $this->getSuccessResult($result)); } public function testEndToEndRightIssues(): void @@ -930,6 +945,22 @@ public function testEndToEndRightIssues(): void $this->assertSame('You need to be logged to access this field', $result->toArray(Debug::RETHROW_UNSAFE_EXCEPTIONS)['errors'][0]['message']); + $queryString = ' + query { + contacts { + name + forLogged + } + } + '; + + $result = GraphQL::executeQuery( + $schema, + $queryString + ); + + $this->assertSame('You need to be logged to access this field', $result->toArray(Debug::RETHROW_UNSAFE_EXCEPTIONS)['errors'][0]['message']); + $queryString = ' query { contacts { @@ -944,6 +975,37 @@ public function testEndToEndRightIssues(): void ); $this->assertSame('You do not have sufficient rights to access this field', $result->toArray(Debug::RETHROW_UNSAFE_EXCEPTIONS)['errors'][0]['message']); + + $queryString = ' + query { + contacts { + withRight + } + } + '; + + $result = GraphQL::executeQuery( + $schema, + $queryString + ); + + $this->assertSame('You do not have sufficient rights to access this field', $result->toArray(Debug::RETHROW_UNSAFE_EXCEPTIONS)['errors'][0]['message']); + + $queryString = ' + query { + contacts { + name + hidden + } + } + '; + + $result = GraphQL::executeQuery( + $schema, + $queryString + ); + + $this->assertSame('Cannot query field "hidden" on type "ContactInterface".', $result->toArray(Debug::RETHROW_UNSAFE_EXCEPTIONS)['errors'][0]['message']); } public function testAutowireService(): void @@ -976,7 +1038,7 @@ public function testAutowireService(): void ] ] - ], $result->toArray(Debug::RETHROW_INTERNAL_EXCEPTIONS)['data']); + ], $this->getSuccessResult($result)); } public function testParameterAnnotationsInSourceField(): void @@ -1009,7 +1071,7 @@ public function testParameterAnnotationsInSourceField(): void ] ] - ], $result->toArray(Debug::RETHROW_INTERNAL_EXCEPTIONS)['data']); + ], $this->getSuccessResult($result)); } public function testEndToEndEnums(): void @@ -1032,7 +1094,7 @@ public function testEndToEndEnums(): void $this->assertSame([ 'echoProductType' => 'NON_FOOD' - ], $result->toArray(Debug::RETHROW_INTERNAL_EXCEPTIONS)['data']); + ], $this->getSuccessResult($result)); } public function testEndToEndEnums2(): void @@ -1055,7 +1117,7 @@ public function testEndToEndEnums2(): void $this->assertSame([ 'echoSomeProductType' => 'FOOD' - ], $result->toArray(Debug::RETHROW_INTERNAL_EXCEPTIONS)['data']); + ], $this->getSuccessResult($result)); } public function testEndToEndEnums3(): void @@ -1085,7 +1147,7 @@ public function testEndToEndEnums3(): void $this->assertSame([ 'echoProductType' => 'NON_FOOD' - ], $result->toArray(Debug::RETHROW_INTERNAL_EXCEPTIONS)['data']); + ], $this->getSuccessResult($result)); } public function testEndToEndDateTime(): void @@ -1108,7 +1170,7 @@ public function testEndToEndDateTime(): void $this->assertSame([ 'echoDate' => '2019-05-05T01:02:03+00:00' - ], $result->toArray(Debug::RETHROW_INTERNAL_EXCEPTIONS)['data']); + ], $this->getSuccessResult($result)); } public function testEndToEndErrorHandlingOfInconstentTypesInArrays(): void @@ -1164,7 +1226,7 @@ public function testEndToEndNonDefaultOutputType(): void 'fullName' => 'JOE', 'phone' => '0123456789' ] - ], $result->toArray(Debug::RETHROW_INTERNAL_EXCEPTIONS)['data']); + ], $this->getSuccessResult($result)); } public function testEndToEndSecurityAnnotation(): void @@ -1187,7 +1249,7 @@ public function testEndToEndSecurityAnnotation(): void $this->assertSame([ 'secretPhrase' => 'you can see this secret only if passed parameter is "foo"' - ], $result->toArray(Debug::RETHROW_INTERNAL_EXCEPTIONS)['data']); + ], $this->getSuccessResult($result)); $queryString = ' query { @@ -1226,7 +1288,7 @@ public function testEndToEndSecurityFailWithAnnotation(): void $this->assertSame([ 'nullableSecretPhrase' => null - ], $result->toArray(Debug::RETHROW_INTERNAL_EXCEPTIONS)['data']); + ], $this->getSuccessResult($result)); // Test with @FailWith annotation $queryString = ' @@ -1242,7 +1304,24 @@ public function testEndToEndSecurityFailWithAnnotation(): void $this->assertSame([ 'nullableSecretPhrase2' => null - ], $result->toArray(Debug::RETHROW_INTERNAL_EXCEPTIONS)['data']); + ], $this->getSuccessResult($result)); + + // Test with @FailWith annotation on property + $queryString = ' + query { + contacts { + failWithNull + } + } + '; + + $result = GraphQL::executeQuery( + $schema, + $queryString + ); + + $data = $this->getSuccessResult($result); + $this->assertSame(null, $data['contacts'][0]['failWithNull']); } public function testEndToEndSecurityWithUser(): void @@ -1392,6 +1471,21 @@ public function testEndToEndSecurityInField(): void ); $this->assertSame('Access denied.', $result->toArray(Debug::RETHROW_UNSAFE_EXCEPTIONS)['errors'][0]['message']); + + $queryString = ' + query { + contacts { + secured + } + } + '; + + $result = GraphQL::executeQuery( + $schema, + $queryString + ); + + $this->assertSame('Access denied.', $result->toArray(Debug::RETHROW_UNSAFE_EXCEPTIONS)['errors'][0]['message']); } public function testEndToEndUnions(){ @@ -1487,7 +1581,7 @@ public function testEndToEndMagicFieldWithPhpType(): void ] ], ] - ], $result->toArray(Debug::RETHROW_INTERNAL_EXCEPTIONS)['data']); + ], $this->getSuccessResult($result)); } public function testEndToEndInjectUser(): void @@ -1571,4 +1665,247 @@ public function testNullableResult(){ $this->assertNull($resultArray['data']['nullableResult']); } + public function testEndToEndFieldAnnotationInProperty(): void + { + /** + * @var Schema $schema + */ + $schema = $this->mainContainer->get(Schema::class); + + $queryString = ' + query { + contacts { + age + nickName + status + address + } + } + '; + + $result = GraphQL::executeQuery( + $schema, + $queryString + ); + + $data = $this->getSuccessResult($result); + + $this->assertSame(42, $data['contacts'][0]['age']); + $this->assertSame('foo', $data['contacts'][0]['nickName']); + $this->assertSame('bar', $data['contacts'][0]['status']); + $this->assertSame('foo', $data['contacts'][0]['address']); + + $queryString = ' + query { + contacts { + private + } + } + '; + + GraphQL::executeQuery( + $schema, + $queryString + ); + + $this->expectException(AccessPropertyException::class); + $this->expectExceptionMessage("Could not get value from property 'TheCodingMachine\GraphQLite\Fixtures\Integration\Models\Contact::private'. Either make the property public or add a public getter for it like 'getPrivate' or 'isPrivate' with no required parameters"); + + $queryString = ' + query { + contacts { + zipcode + } + } + '; + + $result = GraphQL::executeQuery( + $schema, + $queryString + ); + + $this->expectException(AccessPropertyException::class); + $this->expectExceptionMessage("Could not get value from property 'TheCodingMachine\GraphQLite\Fixtures\Integration\Models\Contact::zipcode'. Either make the property public or add a public getter for it like 'getZipcode' or 'isZipcode' with no required parameters"); + $result->toArray(Debug::RETHROW_INTERNAL_EXCEPTIONS); + } + + public function testEndToEndInputAnnotations(): void + { + /** + * @var Schema $schema + */ + $schema = $this->mainContainer->get(Schema::class); + $queryString = ' + mutation { + createPost( + post: { + title: "foo", + publishedAt: "2021-01-24T00:00:00+00:00" + author: { + name: "foo", + birthDate: "1942-12-24T00:00:00+00:00", + relations: [ + { + name: "bar" + } + ] + } + } + ) { + id + title + publishedAt + comment + summary + author { + name + } + } + updatePost( + id: 100, + post: { + title: "bar" + } + ) { + id + title + comment + summary + } + createArticle( + article: { + title: "foo", + comment: "some description", + magazine: "bar", + author: { + name: "foo", + birthDate: "1942-12-24T00:00:00+00:00", + relations: [ + { + name: "bar" + } + ] + } + } + ) { + id + title + comment + summary + magazine + author { + name + } + } + } + '; + + $result = GraphQL::executeQuery( + $schema, + $queryString + ); + + $this->assertSame([ + 'createPost' => [ + 'id' => 1, + 'title' => 'foo', + 'publishedAt' => '2021-01-24T00:00:00+00:00', + 'comment' => 'foo', + 'summary' => 'foo', + 'author' => [ + 'name' => 'foo', + ], + ], + 'updatePost' => [ + 'id' => 100, + 'title' => 'bar', + 'comment' => 'bar', + 'summary' => 'foo', + ], + 'createArticle' => [ + 'id' => 2, + 'title' => 'foo', + 'comment' => 'some description', + 'summary' => 'foo', + 'magazine' => 'bar', + 'author' => [ + 'name' => 'foo', + ], + ], + ], $this->getSuccessResult($result)); + } + + public function testEndToEndInputAnnotationIssues(): void + { + /** + * @var Schema $schema + */ + $schema = $this->mainContainer->get(Schema::class); + $queryString = ' + mutation { + createPost( + post: { + id: 20, + } + ) { + id + } + } + '; + + $result = GraphQL::executeQuery( + $schema, + $queryString + ); + + $this->assertSame('Field PostInput.title of required type String! was not provided.', $result->toArray(Debug::RETHROW_UNSAFE_EXCEPTIONS)['errors'][0]['message']); + $this->assertSame('Field PostInput.publishedAt of required type DateTime! was not provided.', $result->toArray(Debug::RETHROW_UNSAFE_EXCEPTIONS)['errors'][1]['message']); + $this->assertSame('Field "id" is not defined by type PostInput.', $result->toArray(Debug::RETHROW_UNSAFE_EXCEPTIONS)['errors'][2]['message']); + + $queryString = ' + mutation { + createArticle( + article: { + id: 20, + publishedAt: "2021-01-24T00:00:00+00:00" + } + ) { + id + publishedAt + } + } + '; + + $result = GraphQL::executeQuery( + $schema, + $queryString + ); + + $this->assertSame('Field ArticleInput.title of required type String! was not provided.', $result->toArray(Debug::RETHROW_UNSAFE_EXCEPTIONS)['errors'][0]['message']); + $this->assertSame('Field "id" is not defined by type ArticleInput.', $result->toArray(Debug::RETHROW_UNSAFE_EXCEPTIONS)['errors'][1]['message']); + $this->assertSame('Field "publishedAt" is not defined by type ArticleInput.', $result->toArray(Debug::RETHROW_UNSAFE_EXCEPTIONS)['errors'][2]['message']); + + $queryString = ' + mutation { + updatePost( + id: 100, + post: { + title: "foo", + inaccessible: "foo" + } + ) { + id + } + } + '; + + $result = GraphQL::executeQuery( + $schema, + $queryString + ); + + $this->expectException(AccessPropertyException::class); + $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(Debug::RETHROW_INTERNAL_EXCEPTIONS); + } } diff --git a/tests/Mappers/GlobTypeMapperTest.php b/tests/Mappers/GlobTypeMapperTest.php index b483560d00..b9fd62b3d7 100644 --- a/tests/Mappers/GlobTypeMapperTest.php +++ b/tests/Mappers/GlobTypeMapperTest.php @@ -13,11 +13,14 @@ use Test; use TheCodingMachine\GraphQLite\AbstractQueryProviderTest; use TheCodingMachine\GraphQLite\Annotations\Exceptions\ClassNotFoundException; +use TheCodingMachine\GraphQLite\FailedResolvingInputType; use TheCodingMachine\GraphQLite\Fixtures\BadExtendType\BadExtendType; use TheCodingMachine\GraphQLite\Fixtures\BadExtendType2\BadExtendType2; use TheCodingMachine\GraphQLite\Fixtures\InheritedInputTypes\ChildTestFactory; use TheCodingMachine\GraphQLite\Fixtures\Integration\Types\FilterDecorator; use TheCodingMachine\GraphQLite\Fixtures\Mocks\MockResolvableInputObjectType; +use TheCodingMachine\GraphQLite\Fixtures\NonInstantiableInput\AbstractFoo; +use TheCodingMachine\GraphQLite\Fixtures\TestInput; use TheCodingMachine\GraphQLite\Fixtures\TestObject; use TheCodingMachine\GraphQLite\Fixtures\TestType; use TheCodingMachine\GraphQLite\Fixtures\Types\FooExtendType; @@ -81,6 +84,22 @@ public function testGlobTypeMapperDuplicateTypesException(): void $mapper->canMapClassToType(TestType::class); } + public function testGlobTypeMapperDuplicateInputsException(): void + { + $container = new Picotainer([ + TestInput::class => function () { + return new TestInput(); + } + ]); + + $typeGenerator = $this->getTypeGenerator(); + + $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\DuplicateInputs'), $typeGenerator, $this->getInputTypeGenerator(), $this->getInputTypeUtils(), $container, new \TheCodingMachine\GraphQLite\AnnotationReader(new AnnotationReader()), new NamingStrategy(), $this->getTypeMapper(), new Psr16Cache(new NullAdapter())); + + $this->expectException(DuplicateMappingException::class); + $mapper->canMapClassToInputType(TestInput::class); + } + public function testGlobTypeMapperDuplicateInputTypesException(): void { $container = new Picotainer([ @@ -370,4 +389,19 @@ public function testNonInstantiableType(): void $this->expectExceptionMessage('Class "TheCodingMachine\GraphQLite\Fixtures\NonInstantiableType\AbstractFooType" annotated with @Type(class="TheCodingMachine\GraphQLite\Fixtures\TestObject") must be instantiable.'); $mapper->mapClassToType(TestObject::class, null); } + + public function testNonInstantiableInput(): void + { + $container = new Picotainer([]); + + $typeGenerator = $this->getTypeGenerator(); + $inputTypeGenerator = $this->getInputTypeGenerator(); + + $cache = new Psr16Cache(new ArrayAdapter()); + $mapper = new GlobTypeMapper($this->getNamespaceFactory()->createNamespace('TheCodingMachine\GraphQLite\Fixtures\NonInstantiableInput'), $typeGenerator, $inputTypeGenerator, $this->getInputTypeUtils(), $container, new \TheCodingMachine\GraphQLite\AnnotationReader(new AnnotationReader()), new NamingStrategy(), $this->getTypeMapper(), $cache); + + $this->expectException(FailedResolvingInputType::class); + $this->expectExceptionMessage("Class 'TheCodingMachine\GraphQLite\Fixtures\NonInstantiableInput\AbstractFoo' annotated with @Input must be instantiable."); + $mapper->mapClassToInputType(AbstractFoo::class); + } } diff --git a/tests/Mappers/Root/NullableTypeMapperAdapterTest.php b/tests/Mappers/Root/NullableTypeMapperAdapterTest.php index 07d1f42d94..4fc6038963 100644 --- a/tests/Mappers/Root/NullableTypeMapperAdapterTest.php +++ b/tests/Mappers/Root/NullableTypeMapperAdapterTest.php @@ -50,12 +50,12 @@ public function testNonNullableReturnedByWrappedMapper(): void $typeMapper->setNext(new class implements RootTypeMapperInterface { - public function toGraphQLOutputType(Type $type, ?OutputType $subType, ReflectionMethod $refMethod, DocBlock $docBlockObj): OutputType + public function toGraphQLOutputType(Type $type, ?OutputType $subType, $reflector, DocBlock $docBlockObj): OutputType { return new NonNull(new StringType()); } - public function toGraphQLInputType(Type $type, ?InputType $subType, string $argumentName, ReflectionMethod $refMethod, DocBlock $docBlockObj): InputType + public function toGraphQLInputType(Type $type, ?InputType $subType, string $argumentName, $reflector, DocBlock $docBlockObj): InputType { throw new \RuntimeException('Not implemented'); } diff --git a/tests/Mappers/Root/VoidRootTypeMapper.php b/tests/Mappers/Root/VoidRootTypeMapper.php index 366426d3d6..ddaa28a2b3 100644 --- a/tests/Mappers/Root/VoidRootTypeMapper.php +++ b/tests/Mappers/Root/VoidRootTypeMapper.php @@ -11,6 +11,7 @@ use phpDocumentor\Reflection\DocBlock; use phpDocumentor\Reflection\Type; use ReflectionMethod; +use ReflectionProperty; class VoidRootTypeMapper implements RootTypeMapperInterface { @@ -26,22 +27,24 @@ public function __construct(RootTypeMapperInterface $next) /** * @param (OutputType&GraphQLType)|null $subType + * @param ReflectionMethod|ReflectionProperty $reflector * * @return OutputType&GraphQLType */ - public function toGraphQLOutputType(Type $type, ?OutputType $subType, ReflectionMethod $refMethod, DocBlock $docBlockObj): OutputType + public function toGraphQLOutputType(Type $type, ?OutputType $subType, $reflector, DocBlock $docBlockObj): OutputType { - return $this->next->toGraphQLOutputType($type, $subType, $refMethod, $docBlockObj); + return $this->next->toGraphQLOutputType($type, $subType, $reflector, $docBlockObj); } /** * @param (InputType&GraphQLType)|null $subType + * @param ReflectionMethod|ReflectionProperty $reflector * * @return InputType&GraphQLType */ - public function toGraphQLInputType(Type $type, ?InputType $subType, string $argumentName, ReflectionMethod $refMethod, DocBlock $docBlockObj): InputType + public function toGraphQLInputType(Type $type, ?InputType $subType, string $argumentName, $reflector, DocBlock $docBlockObj): InputType { - return $this->next->toGraphQLInputType($type, $subType, $argumentName, $refMethod, $docBlockObj); + return $this->next->toGraphQLInputType($type, $subType, $argumentName, $reflector, $docBlockObj); } /** diff --git a/tests/QueryFieldDescriptorTest.php b/tests/QueryFieldDescriptorTest.php index 880b999bcf..796487406a 100644 --- a/tests/QueryFieldDescriptorTest.php +++ b/tests/QueryFieldDescriptorTest.php @@ -26,6 +26,16 @@ public function testExceptionInSetTargetMethodOnSource(): void $descriptor->setTargetMethodOnSource('test'); } + public function testExceptionInSetTargetPropertyOnSource(): void + { + $descriptor = new QueryFieldDescriptor(); + $descriptor->setTargetPropertyOnSource('test'); + $descriptor->getResolver(); + + $this->expectException(GraphQLRuntimeException::class); + $descriptor->setTargetPropertyOnSource('test'); + } + public function testExceptionInSetMagicProperty(): void { $descriptor = new QueryFieldDescriptor(); diff --git a/tests/Types/InputTypeTest.php b/tests/Types/InputTypeTest.php new file mode 100644 index 0000000000..57174337fe --- /dev/null +++ b/tests/Types/InputTypeTest.php @@ -0,0 +1,149 @@ +getFieldsBuilder()); + $input->freeze(); + + $this->assertEquals('FooBarInput', $input->config['name']); + $this->assertEquals('Test', $input->config['description']); + + $fields = $input->getFields(); + $this->assertCount(2, $fields); + + $this->assertEquals('foo', $fields['foo']->config['name']); + $this->assertEquals('Foo description.', $fields['foo']->config['description']); + $this->assertArrayNotHasKey('defaultValue', $fields['foo']->config); + $this->assertInstanceOf(NonNull::class, $fields['foo']->getType()); + $this->assertInstanceOf(StringType::class, $fields['foo']->getType()->getWrappedType()); + + $this->assertEquals('bar', $fields['bar']->config['name']); + $this->assertEquals('Bar comment.', $fields['bar']->config['description']); + $this->assertEquals('bar', $fields['bar']->config['defaultValue']); + $this->assertNotInstanceOf(NonNull::class, $fields['bar']->getType()); + } + + /** + * @requires PHP >= 7.4 + */ + public function testInputConfiguredCorrectlyWithTypedProperties(): void + { + $input = new InputType(TypedFooBar::class, 'TypedFooBarInput', 'Test', false, $this->getFieldsBuilder()); + $input->freeze(); + + $fields = $input->getFields(); + $this->assertCount(2, $fields); + + $this->assertEquals('foo', $fields['foo']->config['name']); + $this->assertArrayNotHasKey('defaultValue', $fields['foo']->config); + $this->assertInstanceOf(NonNull::class, $fields['foo']->getType()); + $this->assertInstanceOf(StringType::class, $fields['foo']->getType()->getWrappedType()); + + $this->assertEquals('bar', $fields['bar']->config['name']); + $this->assertEquals(10, $fields['bar']->config['defaultValue']); + $this->assertInstanceOf(IntType::class, $fields['bar']->getType()); + } + + public function testUpdateInputConfiguredCorrectly(): void + { + $input = new InputType(FooBar::class, 'FooBarUpdateInput', 'Test', true, $this->getFieldsBuilder()); + $input->freeze(); + + $this->assertEquals('FooBarUpdateInput', $input->config['name']); + $this->assertEquals('Test', $input->config['description']); + + $fields = $input->getFields(); + $this->assertCount(3, $fields); + + $this->assertEquals('foo', $fields['foo']->config['name']); + $this->assertEquals('Foo description.', $fields['foo']->config['description']); + $this->assertArrayNotHasKey('defaultValue', $fields['foo']->config); + + $this->assertEquals('bar', $fields['bar']->config['name']); + $this->assertEquals('Bar comment.', $fields['bar']->config['description']); + $this->assertArrayNotHasKey('defaultValue', $fields['foo']->config); + + $this->assertEquals('timestamp', $fields['timestamp']->config['name']); + $this->assertEquals('', $fields['timestamp']->config['description']); + $this->assertArrayNotHasKey('defaultValue', $fields['timestamp']->config); + } + + public function testPassingInterfaceName(): void + { + $this->expectException(FailedResolvingInputType::class); + $this->expectExceptionMessage("Class 'TheCodingMachine\GraphQLite\Fixtures\Inputs\InputInterface' annotated with @Input must be instantiable."); + + new InputType(InputInterface::class, 'TestInput', null, false, $this->getFieldsBuilder()); + } + + public function testInputCannotBeDecorator(): void + { + $this->expectException(FailedResolvingInputType::class); + $this->expectExceptionMessage("Input type 'TheCodingMachine\GraphQLite\Fixtures\Inputs\FooBar' cannot be a decorator."); + + $input = new InputType(FooBar::class, 'FooBarInput', null, false, $this->getFieldsBuilder()); + $input->decorate(function () {}); + } + + public function testResolvesCorrectlyWithRequiredConstructParam(): void + { + $input = new InputType(FooBar::class, 'FooBarInput', null, false, $this->getFieldsBuilder()); + + $args = ['foo' => 'Foo']; + $resolveInfo = $this->createMock(ResolveInfo::class); + $result = $input->resolve(null, $args, [], $resolveInfo); + + $this->assertSame([ + 'foo' => 'Foo', + 'bar' => 'test', + 'date' => null, + ], (array) $result); + } + + public function testResolvesCorrectlyWithOnlyConstruct(): void + { + $input = new InputType(TestOnlyConstruct::class, 'TestOnlyConstructInput', null, false, $this->getFieldsBuilder()); + + $args = [ + 'foo' => 'Foo', + 'bar' => 200, + ]; + + $resolveInfo = $this->createMock(ResolveInfo::class); + + /** @var TestOnlyConstruct $result */ + $result = $input->resolve(null, $args, [], $resolveInfo); + + $this->assertEquals('Foo', $result->getFoo()); + $this->assertEquals(200, $result->getBar()); + } + + public function testFailsResolvingFieldWithoutRequiredConstructParam(): void + { + $input = new InputType(FooBar::class, 'FooBarInput', null, false, $this->getFieldsBuilder()); + + $args = ['bar' => 'Bar']; + $resolveInfo = $this->createMock(ResolveInfo::class); + + $this->expectException(FailedResolvingInputType::class); + $this->expectExceptionMessage("Parameter 'foo' is missing for class 'TheCodingMachine\GraphQLite\Fixtures\Inputs\FooBar' constructor. It should be mapped as required field."); + + $input->resolve(null, $args, [], $resolveInfo); + } +}