diff --git a/Makefile b/Makefile index 04e388edd..55d700aa4 100644 --- a/Makefile +++ b/Makefile @@ -31,6 +31,7 @@ bench: ## Runs benchmarks with phpbench .PHONY: docs docs: ## Generate the class-reference docs php generate-class-reference.php + prettier --write docs/class-reference.md vendor: composer.json composer.lock composer install diff --git a/docs/type-definitions/object-types.md b/docs/type-definitions/object-types.md index e073d2418..a5a98bf34 100644 --- a/docs/type-definitions/object-types.md +++ b/docs/type-definitions/object-types.md @@ -58,14 +58,15 @@ This example uses **inline** style for Object Type definitions, but you can also ## Configuration options -| Option | Type | Notes | -| ------------ | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| name | `string` | **Required.** Unique name of this object type within Schema | -| fields | `array` or `callable` | **Required**. An array describing object fields or callable returning such an array. See [field configuration options](#field-configuration-options) section below for expected structure of each array entry. See also the section on [Circular types](#recurring-and-circular-types) for an explanation of when to use callable for this option. | -| description | `string` | Plain-text description of this type for clients (e.g. used by [GraphiQL](https://github.com/graphql/graphiql) for auto-generated documentation) | -| interfaces | `array` or `callable` | List of interfaces implemented by this type or callable returning such a list. See [Interface Types](interfaces.md) for details. See also the section on [Circular types](#recurring-and-circular-types) for an explanation of when to use callable for this option. | -| isTypeOf | `callable` | **function ($value, $context, [ResolveInfo](../class-reference.md#graphqltypedefinitionresolveinfo) $info): bool**
Expected to return **true** if **$value** qualifies for this type (see section about [Abstract Type Resolution](interfaces.md#interface-role-in-data-fetching) for explanation). | -| resolveField | `callable` | **function ($value, array $args, $context, [ResolveInfo](../class-reference.md#graphqltypedefinitionresolveinfo) $info): mixed**
Given the **$value** of this type, it is expected to return value for a field defined in **$info->fieldName**. A good place to define a type-specific strategy for field resolution. See section on [Data Fetching](../data-fetching.md) for details. | +| Option | Type | Notes | +|--------------|------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| name | `string` | **Required.** Unique name of this object type within Schema | +| fields | `array` or `callable` | **Required**. An array describing object fields or callable returning such an array. See [field configuration options](#field-configuration-options) section below for expected structure of each array entry. See also the section on [Circular types](#recurring-and-circular-types) for an explanation of when to use callable for this option. | +| description | `string` | Plain-text description of this type for clients (e.g. used by [GraphiQL](https://github.com/graphql/graphiql) for auto-generated documentation) | +| interfaces | `array` or `callable` | List of interfaces implemented by this type or callable returning such a list. See [Interface Types](interfaces.md) for details. See also the section on [Circular types](#recurring-and-circular-types) for an explanation of when to use callable for this option. | +| isTypeOf | `callable` | **function ($value, $context, [ResolveInfo](../class-reference.md#graphqltypedefinitionresolveinfo) $info): bool**
Expected to return **true** if **$value** qualifies for this type (see section about [Abstract Type Resolution](interfaces.md#interface-role-in-data-fetching) for explanation). | +| resolveField | `callable` | **function ($value, array $args, $context, [ResolveInfo](../class-reference.md#graphqltypedefinitionresolveinfo) $info): mixed**
Given the **$value** of this type, it is expected to return value for a field defined in **$info->fieldName**. A good place to define a type-specific strategy for field resolution. See section on [Data Fetching](../data-fetching.md) for details. | +| visible | `bool` or `callable` | Defaults to `true`. The given callable receives no arguments and is expected to return a `bool`, it is called once when the field may be accessed. The field is treated as if it were not defined at all when this is `false`. | ### Field configuration options diff --git a/src/Executor/ReferenceExecutor.php b/src/Executor/ReferenceExecutor.php index fa3c63379..2aeed90b7 100644 --- a/src/Executor/ReferenceExecutor.php +++ b/src/Executor/ReferenceExecutor.php @@ -597,7 +597,7 @@ protected function resolveField(ObjectType $parentType, $rootValue, \ArrayObject $fieldName = $fieldNode->name->value; $fieldDef = $this->getFieldDef($exeContext->schema, $parentType, $fieldName); - if ($fieldDef === null) { + if ($fieldDef === null || ! $fieldDef->isVisible()) { return static::$UNDEFINED; } diff --git a/src/Type/Definition/FieldDefinition.php b/src/Type/Definition/FieldDefinition.php index 7b9389b11..c36f76275 100644 --- a/src/Type/Definition/FieldDefinition.php +++ b/src/Type/Definition/FieldDefinition.php @@ -16,12 +16,14 @@ * * @phpstan-type FieldType (Type&OutputType)|callable(): (Type&OutputType) * @phpstan-type ComplexityFn callable(int, array): int + * @phpstan-type VisibilityFn callable(): bool * @phpstan-type FieldDefinitionConfig array{ * name: string, * type: FieldType, * resolve?: FieldResolver|null, * args?: ArgumentListConfig|null, * description?: string|null, + * visible?: VisibilityFn|bool, * deprecationReason?: string|null, * astNode?: FieldDefinitionNode|null, * complexity?: ComplexityFn|null @@ -31,6 +33,7 @@ * resolve?: FieldResolver|null, * args?: ArgumentListConfig|null, * description?: string|null, + * visible?: VisibilityFn|bool, * deprecationReason?: string|null, * astNode?: FieldDefinitionNode|null, * complexity?: ComplexityFn|null @@ -64,6 +67,13 @@ class FieldDefinition public ?string $description; + /** + * @var callable|bool + * + * @phpstan-var VisibilityFn|bool + */ + public $visible; + public ?string $deprecationReason; public ?FieldDefinitionNode $astNode; @@ -94,6 +104,7 @@ public function __construct(array $config) ? Argument::listFromConfig($config['args']) : []; $this->description = $config['description'] ?? null; + $this->visible = $config['visible'] ?? true; $this->deprecationReason = $config['deprecationReason'] ?? null; $this->astNode = $config['astNode'] ?? null; $this->complexityFn = $config['complexity'] ?? null; @@ -181,6 +192,15 @@ public function getType(): Type return $this->type ??= Schema::resolveType($this->config['type']); } + public function isVisible(): bool + { + if (is_bool($this->visible)) { + return $this->visible; + } + + return $this->visible = ($this->visible)(); + } + public function isDeprecated(): bool { return (bool) $this->deprecationReason; diff --git a/src/Type/Definition/HasFieldsType.php b/src/Type/Definition/HasFieldsType.php index bbbfb3270..4bab0bac9 100644 --- a/src/Type/Definition/HasFieldsType.php +++ b/src/Type/Definition/HasFieldsType.php @@ -21,6 +21,15 @@ public function findField(string $name): ?FieldDefinition; public function getFields(): array; /** + * @throws InvariantViolation + * + * @return array + */ + public function getVisibleFields(): array; + + /** + * Get all field names, including only visible fields. + * * @throws InvariantViolation * * @return array diff --git a/src/Type/Definition/HasFieldsTypeImplementation.php b/src/Type/Definition/HasFieldsTypeImplementation.php index d7478f634..023fae17a 100644 --- a/src/Type/Definition/HasFieldsTypeImplementation.php +++ b/src/Type/Definition/HasFieldsTypeImplementation.php @@ -78,11 +78,24 @@ public function getFields(): array return $this->fields; } + public function getVisibleFields(): array + { + return array_filter( + $this->getFields(), + fn (FieldDefinition $fieldDefinition): bool => $fieldDefinition->isVisible() + ); + } + /** @throws InvariantViolation */ public function getFieldNames(): array { $this->initializeFields(); - return \array_keys($this->fields); + $visibleFieldNames = array_map( + fn (FieldDefinition $fieldDefinition): string => $fieldDefinition->getName(), + $this->getVisibleFields() + ); + + return array_values($visibleFieldNames); } } diff --git a/src/Type/Introspection.php b/src/Type/Introspection.php index ab6a39b98..9aaa5341a 100644 --- a/src/Type/Introspection.php +++ b/src/Type/Introspection.php @@ -354,13 +354,12 @@ public static function _type(): ObjectType ], 'resolve' => static function (Type $type, $args): ?array { if ($type instanceof ObjectType || $type instanceof InterfaceType) { - $fields = $type->getFields(); + $fields = $type->getVisibleFields(); if (! ($args['includeDeprecated'] ?? false)) { - $fields = \array_filter( + return \array_filter( $fields, - static fn (FieldDefinition $field): bool => $field->deprecationReason === null - || $field->deprecationReason === '' + static fn (FieldDefinition $field): bool => ! $field->isDeprecated() ); } @@ -397,10 +396,7 @@ public static function _type(): ObjectType if (! ($args['includeDeprecated'] ?? false)) { return \array_filter( $values, - static function (EnumValueDefinition $value): bool { - return $value->deprecationReason === null - || $value->deprecationReason === ''; - } + static fn (EnumValueDefinition $value): bool => ! $value->isDeprecated() ); } @@ -425,8 +421,7 @@ static function (EnumValueDefinition $value): bool { if (! ($args['includeDeprecated'] ?? false)) { return \array_filter( $fields, - static fn (InputObjectField $field): bool => $field->deprecationReason === null - || $field->deprecationReason === '', + static fn (InputObjectField $field): bool => ! $field->isDeprecated(), ); } @@ -521,8 +516,7 @@ public static function _field(): ObjectType if (! ($args['includeDeprecated'] ?? false)) { return \array_filter( $values, - static fn (Argument $value): bool => $value->deprecationReason === null - || $value->deprecationReason === '', + static fn (Argument $value): bool => ! $value->isDeprecated(), ); } @@ -535,8 +529,7 @@ public static function _field(): ObjectType ], 'isDeprecated' => [ 'type' => Type::nonNull(Type::boolean()), - 'resolve' => static fn (FieldDefinition $field): bool => $field->deprecationReason !== null - && $field->deprecationReason !== '', + 'resolve' => static fn (FieldDefinition $field): bool => $field->isDeprecated(), ], 'deprecationReason' => [ 'type' => Type::string(), @@ -593,8 +586,7 @@ public static function _inputValue(): ObjectType 'isDeprecated' => [ 'type' => Type::nonNull(Type::boolean()), /** @param Argument|InputObjectField $inputValue */ - 'resolve' => static fn ($inputValue): bool => $inputValue->deprecationReason !== null - && $inputValue->deprecationReason !== '', + 'resolve' => static fn ($inputValue): bool => $inputValue->isDeprecated(), ], 'deprecationReason' => [ 'type' => Type::string(), @@ -625,8 +617,7 @@ public static function _enumValue(): ObjectType ], 'isDeprecated' => [ 'type' => Type::nonNull(Type::boolean()), - 'resolve' => static fn (EnumValueDefinition $enumValue): bool => $enumValue->deprecationReason !== null - && $enumValue->deprecationReason !== '', + 'resolve' => static fn (EnumValueDefinition $enumValue): bool => $enumValue->isDeprecated(), ], 'deprecationReason' => [ 'type' => Type::string(), diff --git a/src/Validator/Rules/FieldsOnCorrectType.php b/src/Validator/Rules/FieldsOnCorrectType.php index 80ad50c3f..38902dbbc 100644 --- a/src/Validator/Rules/FieldsOnCorrectType.php +++ b/src/Validator/Rules/FieldsOnCorrectType.php @@ -20,7 +20,7 @@ public function getVisitor(QueryValidationContext $context): array return [ NodeKind::FIELD => function (FieldNode $node) use ($context): void { $fieldDef = $context->getFieldDef(); - if ($fieldDef !== null) { + if ($fieldDef !== null && $fieldDef->isVisible()) { return; } diff --git a/tests/Executor/ExecutorSchemaTest.php b/tests/Executor/ExecutorSchemaTest.php index 0fed1d087..2350b58d5 100644 --- a/tests/Executor/ExecutorSchemaTest.php +++ b/tests/Executor/ExecutorSchemaTest.php @@ -28,8 +28,22 @@ public function testExecutesUsingASchema(): void 'name' => 'Image', 'fields' => [ 'url' => ['type' => Type::string()], - 'width' => ['type' => Type::int()], - 'height' => ['type' => Type::int()], + 'width' => [ + 'type' => Type::int(), + 'visible' => fn (): bool => true, + ], + 'height' => [ + 'type' => Type::int(), + 'visible' => true, + ], + 'mimetype' => [ + 'type' => Type::string(), + 'visible' => fn (): bool => false, + ], + 'size' => [ + 'type' => Type::string(), + 'visible' => false, + ], ], ]); @@ -107,7 +121,8 @@ public function testExecutesUsingASchema(): void pic(width: 640, height: 480) { url, width, - height + height, + mimetype }, recentArticle { ...articleFields, @@ -222,6 +237,7 @@ private function article(string $id): array 'url' => "cdn://{$uid}", 'width' => $width, 'height' => $height, + 'mimetype' => 'image/gif', ]; $johnSmith = [ diff --git a/tests/StarWarsSchema.php b/tests/StarWarsSchema.php index ccd67c33e..a2db75671 100644 --- a/tests/StarWarsSchema.php +++ b/tests/StarWarsSchema.php @@ -129,6 +129,11 @@ public static function build(): Schema 'type' => Type::string(), 'description' => 'All secrets about their past.', ], + 'secretName' => [ + 'type' => Type::string(), + 'description' => 'The secret name of the character.', + 'visible' => false, + ], ]; }, 'resolveType' => static function (array $obj) use (&$humanType, &$droidType): ObjectType { diff --git a/tests/StarWarsValidationTest.php b/tests/StarWarsValidationTest.php index 07e7a59d4..3e1a6abf3 100644 --- a/tests/StarWarsValidationTest.php +++ b/tests/StarWarsValidationTest.php @@ -69,6 +69,19 @@ public function testThatNonExistentFieldsAreInvalid(): void self::assertCount(1, $errors); } + public function testThatInvisibleFieldsAreInvalid(): void + { + $query = ' + query HeroSpaceshipQuery { + hero { + secretName + } + } + '; + $errors = $this->validationErrors($query); + self::assertCount(1, $errors); + } + /** @see it('Requires fields on objects') */ public function testRequiresFieldsOnObjects(): void { diff --git a/tests/Type/IntrospectionTest.php b/tests/Type/IntrospectionTest.php index a6402d9dc..c484db3a5 100644 --- a/tests/Type/IntrospectionTest.php +++ b/tests/Type/IntrospectionTest.php @@ -7,6 +7,7 @@ use GraphQL\Language\SourceLocation; use GraphQL\Tests\ErrorHelper; use GraphQL\Type\Definition\EnumType; +use GraphQL\Type\Definition\FieldDefinition; use GraphQL\Type\Definition\InputObjectType; use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\ResolveInfo; @@ -19,6 +20,7 @@ use function Safe\json_encode; +/** @phpstan-import-type VisibilityFn from FieldDefinition */ final class IntrospectionTest extends TestCase { use ArraySubsetAsserts; @@ -1725,4 +1727,59 @@ public function testExecutesAnIntrospectionQueryWithoutCallingGlobalFieldResolve GraphQL::executeQuery($schema, $source, null, null, null, null, $fieldResolver); self::assertEmpty($calledForFields); } + + /** + * @param VisibilityFn|bool $visible + * + * @dataProvider invisibleFieldDataProvider + */ + public function testDoesNotExposeInvisibleFields($visible): void + { + $TestType = new ObjectType([ + 'name' => 'TestType', + 'fields' => [ + 'nonVisible' => [ + 'type' => Type::string(), + 'visible' => $visible, + ], + 'visible' => [ + 'type' => Type::string(), + ], + ], + ]); + + $schema = new Schema(['query' => $TestType]); + $request = ' + { + __type(name: "TestType") { + name + fields { + name + } + } + } + '; + + $expected = [ + 'data' => [ + '__type' => [ + 'name' => 'TestType', + 'fields' => [ + [ + 'name' => 'visible', + ], + ], + ], + ], + ]; + + self::assertSame($expected, GraphQL::executeQuery($schema, $request)->toArray()); + } + + /** @return iterable */ + public static function invisibleFieldDataProvider(): iterable + { + yield [fn (): bool => false]; + yield [false]; + } } diff --git a/tests/Validator/FieldsOnCorrectTypeTest.php b/tests/Validator/FieldsOnCorrectTypeTest.php index 8aef374f2..c0f70f344 100644 --- a/tests/Validator/FieldsOnCorrectTypeTest.php +++ b/tests/Validator/FieldsOnCorrectTypeTest.php @@ -367,4 +367,18 @@ public function testLimitsLotsOfFieldSuggestions(): void ) ); } + + public function testFailsValidationOnInvisibleField(): void + { + $this->expectFailsRule( + new FieldsOnCorrectType(), + <<<'GRAPHQL' + fragment DogWithInvisibleField on Dog { + __typename + secretName + } + GRAPHQL, + [$this->undefinedField('secretName', 'Dog', [], ['nickname'], 3, 3)] + ); + } } diff --git a/tests/Validator/ValidatorTestCase.php b/tests/Validator/ValidatorTestCase.php index 78ca2da5c..3db1c2047 100644 --- a/tests/Validator/ValidatorTestCase.php +++ b/tests/Validator/ValidatorTestCase.php @@ -118,6 +118,10 @@ public static function getTestSchema(): Schema 'type' => Type::boolean(), 'args' => ['x' => ['type' => Type::int()], 'y' => ['type' => Type::int()]], ], + 'secretName' => [ + 'type' => Type::string(), + 'visible' => false, + ], ], 'interfaces' => [$Being, $Pet, $Canine], ]);