diff --git a/src/AnnotationReader.php b/src/AnnotationReader.php index c075d6b63..43521ffd7 100644 --- a/src/AnnotationReader.php +++ b/src/AnnotationReader.php @@ -24,12 +24,15 @@ use TheCodingMachine\GraphQLite\Annotations\SourceFieldInterface; use TheCodingMachine\GraphQLite\Annotations\Type; use TheCodingMachine\GraphQLite\Annotations\TypeInterface; +use TheCodingMachine\GraphQLite\Directives\InputObjectTypeDirective; +use TheCodingMachine\GraphQLite\Directives\ObjectTypeDirective; use function array_diff_key; use function array_filter; use function array_key_exists; use function array_map; use function array_merge; +use function array_values; use function assert; use function count; use function get_class; @@ -326,6 +329,34 @@ static function (array $parameterAnnotations): ParameterAnnotations { ); } + /** + * The {@see ObjectTypeDirective}s on a class. + * + * @param ReflectionClass $refClass + * + * @return list + * + * @template T of object + */ + public function getObjectTypeDirectives(ReflectionClass $refClass): array + { + return array_values($this->getClassAnnotations($refClass, ObjectTypeDirective::class, false)); + } + + /** + * The {@see InputObjectTypeDirective}s on a class. + * + * @param ReflectionClass $refClass + * + * @return list + * + * @template T of object + */ + public function getInputObjectTypeDirectives(ReflectionClass $refClass): array + { + return array_values($this->getClassAnnotations($refClass, InputObjectTypeDirective::class, false)); + } + public function getMiddlewareAnnotations(ReflectionMethod|ReflectionProperty $reflection): MiddlewareAnnotations { if ($reflection instanceof ReflectionMethod) { diff --git a/src/Directives/BehavioralFieldDirective.php b/src/Directives/BehavioralFieldDirective.php new file mode 100644 index 000000000..154b1c547 --- /dev/null +++ b/src/Directives/BehavioralFieldDirective.php @@ -0,0 +1,18 @@ +handle($descriptor); + $type->isOneOf = true; + return $type; + } +} diff --git a/src/Directives/DirectiveAstBuilder.php b/src/Directives/DirectiveAstBuilder.php new file mode 100644 index 000000000..84d828ffb --- /dev/null +++ b/src/Directives/DirectiveAstBuilder.php @@ -0,0 +1,118 @@ +directives` on the definition node (FieldDefinitionNode, + * InputValueDefinitionNode, etc.). GraphQLite doesn't otherwise set `astNode`, so this builds just + * enough of the node to carry the directives, with arguments encoded via {@see AST::astFromValue}. + * + * @internal + */ +final class DirectiveAstBuilder +{ + public function __construct(private readonly DirectiveRegistry $registry) + { + } + + /** + * @param list $directives Directive instances applied to this element. + * + * @return list + */ + public function buildDirectiveNodes(array $directives): array + { + $nodes = []; + foreach ($directives as $directive) { + $definition = $this->registry->definitionFor($directive::class); + if ($definition === null) { + continue; + } + $nodes[] = $this->buildDirectiveNode($directive, $definition); + } + return $nodes; + } + + private function buildDirectiveNode(DirectiveInterface $directive, DirectiveDefinition $definition): DirectiveNode + { + $arguments = $this->registry->argumentsFor($directive::class); + $reflection = new ReflectionClass($directive); + + $argumentNodes = []; + foreach ($arguments as $argument) { + if (! $reflection->hasProperty($argument->name)) { + continue; + } + $property = $reflection->getProperty($argument->name); + $value = $property->getValue($directive); + + $argumentNodes[] = new ArgumentNode([ + 'name' => new NameNode(['value' => $argument->name]), + 'value' => AST::astFromValue($value, $argument->type), + ]); + } + + return new DirectiveNode([ + 'name' => new NameNode(['value' => $definition->name]), + 'arguments' => new NodeList($argumentNodes), + ]); + } + + /** @param list $directiveNodes */ + public static function buildFieldDefinitionNode(string $name, array $directiveNodes): FieldDefinitionNode + { + return new FieldDefinitionNode([ + 'name' => new NameNode(['value' => $name]), + 'type' => new NamedTypeNode(['name' => new NameNode(['value' => 'Unknown'])]), + 'arguments' => new NodeList([]), + 'directives' => new NodeList($directiveNodes), + ]); + } + + /** @param list $directiveNodes */ + public static function buildInputValueDefinitionNode(string $name, array $directiveNodes): InputValueDefinitionNode + { + return new InputValueDefinitionNode([ + 'name' => new NameNode(['value' => $name]), + 'type' => new NamedTypeNode(['name' => new NameNode(['value' => 'Unknown'])]), + 'directives' => new NodeList($directiveNodes), + ]); + } + + /** @param list $directiveNodes */ + public static function buildObjectTypeDefinitionNode(string $name, array $directiveNodes): ObjectTypeDefinitionNode + { + return new ObjectTypeDefinitionNode([ + 'name' => new NameNode(['value' => $name]), + 'interfaces' => new NodeList([]), + 'directives' => new NodeList($directiveNodes), + 'fields' => new NodeList([]), + ]); + } + + /** @param list $directiveNodes */ + public static function buildInputObjectTypeDefinitionNode(string $name, array $directiveNodes): InputObjectTypeDefinitionNode + { + return new InputObjectTypeDefinitionNode([ + 'name' => new NameNode(['value' => $name]), + 'directives' => new NodeList($directiveNodes), + 'fields' => new NodeList([]), + ]); + } +} diff --git a/src/Directives/DirectiveDefinition.php b/src/Directives/DirectiveDefinition.php new file mode 100644 index 000000000..51ee64e17 --- /dev/null +++ b/src/Directives/DirectiveDefinition.php @@ -0,0 +1,29 @@ + $locations */ + public function __construct( + public readonly string $name, + public readonly array $locations, + public readonly string|null $description = null, + public readonly bool $builtIn = false, + ) { + } +} diff --git a/src/Directives/DirectiveInterface.php b/src/Directives/DirectiveInterface.php new file mode 100644 index 000000000..39a2e87a6 --- /dev/null +++ b/src/Directives/DirectiveInterface.php @@ -0,0 +1,14 @@ + true, + default => false, + }; + } + + public function isTypeSystem(): bool + { + return ! $this->isExecutable(); + } +} diff --git a/src/Directives/DirectiveReflection.php b/src/Directives/DirectiveReflection.php new file mode 100644 index 000000000..894d2fd2a --- /dev/null +++ b/src/Directives/DirectiveReflection.php @@ -0,0 +1,31 @@ + $reflection */ + public static function attributeFlags(ReflectionClass $reflection): int + { + $attributes = $reflection->getAttributes(Attribute::class); + if ($attributes === []) { + return Attribute::TARGET_ALL; + } + + $args = $attributes[0]->getArguments(); + + return $args[0] ?? $args['flags'] ?? Attribute::TARGET_ALL; + } +} diff --git a/src/Directives/DirectiveRegistry.php b/src/Directives/DirectiveRegistry.php new file mode 100644 index 000000000..1b6420440 --- /dev/null +++ b/src/Directives/DirectiveRegistry.php @@ -0,0 +1,157 @@ +, ResolvedDirective> */ + private array $resolvedByClass = []; + + /** @var array> */ + private array $classByName = []; + + /** + * Names of webonyx-built-in directives. Custom (non-built-in) directives cannot reuse them. + */ + private const RESERVED_NAMES = ['skip', 'include', 'deprecated', 'oneOf']; + + /** + * Attribute classes that bind PHP behavior to webonyx's pre-existing built-in directives. + * Registered after user discovery so a user-provided override (a class with the same name and + * `builtIn: true`) wins. + */ + private const BUILT_IN_ATTRIBUTES = [ + OneOf::class, + ]; + + public function __construct( + private readonly DirectiveClassFinder $classFinder, + ) { + } + + /** Run discovery + validation once. Idempotent. */ + public function discover(): void + { + // User classes first: an override of a built-in (same name, builtIn: true) needs to land + // before our bundled copy registers. + foreach ($this->classFinder->findDirectives() as $directiveClass) { + $this->register($directiveClass); + } + foreach (self::BUILT_IN_ATTRIBUTES as $directiveClass) { + $this->register($directiveClass); + } + } + + /** + * @param class-string $directiveClass + * + * @throws InvalidDirectiveException + */ + private function register(string $directiveClass): void + { + // Registering the same class twice is a no-op; discovery and the built-in list share this + // registry, so duplicates are expected. + if (isset($this->resolvedByClass[$directiveClass])) { + return; + } + + if (! method_exists($directiveClass, 'definition')) { + throw InvalidDirectiveException::noDefinitionMethod($directiveClass); + } + + $definition = $directiveClass::definition(); + assert($definition instanceof DirectiveDefinition); + + DirectiveValidator::validate($directiveClass, $definition); + + if (! $definition->builtIn && in_array($definition->name, self::RESERVED_NAMES, true)) { + throw InvalidDirectiveException::reservedName($definition->name, $directiveClass); + } + + if (array_key_exists($definition->name, $this->classByName)) { + // A name clash is fine when this side is also built-in: the user supplied their own + // implementation and we defer to it. Two custom directives sharing a name is an error. + if ($definition->builtIn) { + return; + } + throw InvalidDirectiveException::duplicateName( + $definition->name, + $this->classByName[$definition->name], + $directiveClass, + ); + } + + $this->resolvedByClass[$directiveClass] = DirectiveResolver::resolve($directiveClass, $definition); + $this->classByName[$definition->name] = $directiveClass; + } + + public function hasAny(): bool + { + foreach ($this->resolvedByClass as $resolved) { + if ($resolved->webonyxDirective !== null) { + return true; + } + } + + return false; + } + + /** @return list */ + public function webonyxDirectives(): array + { + $directives = []; + foreach ($this->resolvedByClass as $resolved) { + if ($resolved->webonyxDirective === null) { + continue; + } + $directives[] = $resolved->webonyxDirective; + } + + return $directives; + } + + /** + * The argument shape for a directive class. + * + * @param class-string $directiveClass + * + * @return list + */ + public function argumentsFor(string $directiveClass): array + { + $resolved = $this->resolvedByClass[$directiveClass] ?? null; + + return $resolved === null ? [] : $resolved->arguments; + } + + /** + * The metadata for a directive class, or null if it isn't registered. + * + * @param class-string $directiveClass + */ + public function definitionFor(string $directiveClass): DirectiveDefinition|null + { + return ($this->resolvedByClass[$directiveClass] ?? null)?->definition; + } +} diff --git a/src/Directives/DirectiveResolver.php b/src/Directives/DirectiveResolver.php new file mode 100644 index 000000000..1e42c9f85 --- /dev/null +++ b/src/Directives/DirectiveResolver.php @@ -0,0 +1,145 @@ + $directiveClass + * + * @throws InvalidDirectiveException when a constructor argument can't map to a GraphQL input type. + */ + public static function resolve(string $directiveClass, DirectiveDefinition $definition): ResolvedDirective + { + $reflection = new ReflectionClass($directiveClass); + $arguments = self::resolveArguments($directiveClass, $reflection); + + // Built-in directives are declared by webonyx, so there's no directive to register for them. + $webonyxDirective = $definition->builtIn + ? null + : self::buildWebonyxDirective($definition, $arguments, self::isRepeatable($reflection)); + + return new ResolvedDirective($definition, $arguments, $webonyxDirective); + } + + /** + * @param class-string $directiveClass + * @param ReflectionClass $reflection + * + * @return list + */ + private static function resolveArguments(string $directiveClass, ReflectionClass $reflection): array + { + $constructor = $reflection->getConstructor(); + if ($constructor === null) { + return []; + } + + $arguments = []; + foreach ($constructor->getParameters() as $parameter) { + $arguments[] = self::resolveArgument($directiveClass, $parameter); + } + + return $arguments; + } + + private static function resolveArgument(string $directiveClass, ReflectionParameter $parameter): ResolvedDirectiveArgument + { + $type = $parameter->getType(); + if (! $type instanceof ReflectionNamedType) { + throw InvalidDirectiveException::unsupportedArgumentType( + $directiveClass, + $parameter->getName(), + 'union/intersection types are not supported', + ); + } + + if ($type->isBuiltin() === false) { + throw InvalidDirectiveException::unsupportedArgumentType( + $directiveClass, + $parameter->getName(), + 'only scalar types (string, int, float, bool) are supported in this release', + ); + } + + $graphQlType = match ($type->getName()) { + 'string' => Type::string(), + 'int' => Type::int(), + 'float' => Type::float(), + 'bool' => Type::boolean(), + default => throw InvalidDirectiveException::unsupportedArgumentType( + $directiveClass, + $parameter->getName(), + 'PHP type "' . $type->getName() . '" cannot be mapped to a GraphQL scalar', + ), + }; + + $nullable = $type->allowsNull(); + $finalType = $nullable ? $graphQlType : new NonNull($graphQlType); + + $hasDefault = $parameter->isDefaultValueAvailable(); + $default = $hasDefault ? $parameter->getDefaultValue() : null; + + return new ResolvedDirectiveArgument( + name: $parameter->getName(), + type: $finalType, + hasDefaultValue: $hasDefault, + defaultValue: $default, + ); + } + + /** @param ReflectionClass $reflection */ + private static function isRepeatable(ReflectionClass $reflection): bool + { + return (DirectiveReflection::attributeFlags($reflection) & Attribute::IS_REPEATABLE) === Attribute::IS_REPEATABLE; + } + + /** @param list $arguments */ + private static function buildWebonyxDirective(DirectiveDefinition $definition, array $arguments, bool $repeatable): WebonyxDirective + { + $argsConfig = []; + foreach ($arguments as $argument) { + $config = ['type' => $argument->type]; + if ($argument->hasDefaultValue) { + $config['defaultValue'] = $argument->defaultValue; + } + if ($argument->description !== null) { + $config['description'] = $argument->description; + } + $argsConfig[$argument->name] = $config; + } + + $config = [ + 'name' => $definition->name, + 'locations' => array_map(static fn (DirectiveLocation $loc) => $loc->value, $definition->locations), + 'isRepeatable' => $repeatable, + 'args' => $argsConfig, + ]; + if ($definition->description !== null) { + $config['description'] = $definition->description; + } + + return new WebonyxDirective($config); + } +} diff --git a/src/Directives/DirectiveValidator.php b/src/Directives/DirectiveValidator.php new file mode 100644 index 000000000..a0588c6b9 --- /dev/null +++ b/src/Directives/DirectiveValidator.php @@ -0,0 +1,100 @@ + $directiveClass + * + * @throws InvalidDirectiveException + */ + public static function validate(string $directiveClass, DirectiveDefinition $definition): void + { + $reflection = new ReflectionClass($directiveClass); + + if ($reflection->getAttributes(Attribute::class) === []) { + throw InvalidDirectiveException::notAttribute($directiveClass); + } + + self::checkPhpTargets($directiveClass, $definition, DirectiveReflection::attributeFlags($reflection)); + self::checkInterfaceAndLocationAgreement($directiveClass, $definition, $reflection); + } + + private static function checkPhpTargets(string $directiveClass, DirectiveDefinition $definition, int $phpFlags): void + { + foreach ($definition->locations as $location) { + foreach (self::requiredPhpTargetsFor($location) as $requiredTarget => $label) { + if (($phpFlags & $requiredTarget) === $requiredTarget) { + continue; + } + + throw InvalidDirectiveException::phpTargetMissingForLocation($directiveClass, $location, $label); + } + } + } + + /** @param ReflectionClass $reflection */ + private static function checkInterfaceAndLocationAgreement(string $directiveClass, DirectiveDefinition $definition, ReflectionClass $reflection): void + { + $locations = $definition->locations; + $interfacePairs = [ + FieldDirective::class => DirectiveLocation::FIELD_DEFINITION, + InputFieldDirective::class => DirectiveLocation::INPUT_FIELD_DEFINITION, + ObjectTypeDirective::class => DirectiveLocation::OBJECT, + InputObjectTypeDirective::class => DirectiveLocation::INPUT_OBJECT, + ]; + + foreach ($interfacePairs as $interface => $expectedLocation) { + $implements = $reflection->implementsInterface($interface); + $declaresLocation = in_array($expectedLocation, $locations, true); + + if ($implements && ! $declaresLocation) { + throw InvalidDirectiveException::interfaceWithoutMatchingLocation($directiveClass, $interface, $locations); + } + + if (! $implements && $declaresLocation) { + throw InvalidDirectiveException::locationWithoutMatchingInterface($directiveClass, $expectedLocation, $interface); + } + } + } + + /** @return array map of Attribute::TARGET_* flag → human-readable label */ + private static function requiredPhpTargetsFor(DirectiveLocation $location): array + { + return match ($location) { + DirectiveLocation::FIELD_DEFINITION => [ + Attribute::TARGET_METHOD => 'TARGET_METHOD', + Attribute::TARGET_PROPERTY => 'TARGET_PROPERTY', + ], + DirectiveLocation::INPUT_FIELD_DEFINITION => [ + Attribute::TARGET_METHOD => 'TARGET_METHOD', + Attribute::TARGET_PROPERTY => 'TARGET_PROPERTY', + Attribute::TARGET_PARAMETER => 'TARGET_PARAMETER', + ], + DirectiveLocation::OBJECT => [Attribute::TARGET_CLASS => 'TARGET_CLASS'], + DirectiveLocation::INPUT_OBJECT => [Attribute::TARGET_CLASS => 'TARGET_CLASS'], + // Other locations don't have apply hooks yet, so there's nothing to enforce. + default => [], + }; + } +} diff --git a/src/Directives/Discovery/DirectiveClassFinder.php b/src/Directives/Discovery/DirectiveClassFinder.php new file mode 100644 index 000000000..d0f9d0958 --- /dev/null +++ b/src/Directives/Discovery/DirectiveClassFinder.php @@ -0,0 +1,74 @@ +>|null */ + private array|null $cache = null; + + public function __construct( + private readonly ClassFinder $classFinder, + private readonly ClassFinderComputedCache $classFinderComputedCache, + ) { + } + + /** @return list> */ + public function findDirectives(): array + { + if ($this->cache !== null) { + return $this->cache; + } + + /** @var list> $result */ + $result = $this->classFinderComputedCache->compute( + $this->classFinder, + 'customDirectives', + static function (ReflectionClass $refClass): GlobDirectivesCache|null { + if ($refClass->isAbstract() || $refClass->isInterface() || $refClass->isEnum()) { + return null; + } + + if (! $refClass->implementsInterface(TypeSystemDirective::class)) { + return null; + } + + /** @var class-string $directiveClass */ + $directiveClass = $refClass->getName(); + return new GlobDirectivesCache($directiveClass); + }, + static fn (array $entries): array => array_reduce( + $entries, + static function (array $carry, GlobDirectivesCache|null $entry): array { + if ($entry === null) { + return $carry; + } + $carry[] = $entry->directiveClass; + return $carry; + }, + [], + ), + ); + + $this->cache = $result; + return $result; + } +} diff --git a/src/Directives/Discovery/GlobDirectivesCache.php b/src/Directives/Discovery/GlobDirectivesCache.php new file mode 100644 index 000000000..d587eaba1 --- /dev/null +++ b/src/Directives/Discovery/GlobDirectivesCache.php @@ -0,0 +1,21 @@ + $directiveClass */ + public function __construct(public readonly string $directiveClass) + { + } +} diff --git a/src/Directives/Exceptions/InvalidDirectiveException.php b/src/Directives/Exceptions/InvalidDirectiveException.php new file mode 100644 index 000000000..9542cfe63 --- /dev/null +++ b/src/Directives/Exceptions/InvalidDirectiveException.php @@ -0,0 +1,101 @@ +value, + $requiredTarget, + )); + } + + /** @param list $locations */ + public static function interfaceWithoutMatchingLocation(string $directiveClass, string $interface, array $locations): self + { + $locationsList = implode(', ', array_map(static fn (DirectiveLocation $l) => $l->value, $locations)); + + return new self(sprintf( + 'Directive "%s" implements %s but its declared locations [%s] do not include the corresponding GraphQL location. ' . + 'Either remove the interface or add the location to DirectiveDefinition::$locations.', + $directiveClass, + $interface, + $locationsList, + )); + } + + public static function locationWithoutMatchingInterface(string $directiveClass, DirectiveLocation $location, string $expectedInterface): self + { + return new self(sprintf( + 'Directive "%s" declares location %s but does not implement %s. ' . + 'Either remove the location or implement the corresponding interface.', + $directiveClass, + $location->value, + $expectedInterface, + )); + } + + public static function notAttribute(string $directiveClass): self + { + return new self(sprintf( + 'Directive "%s" must declare a #[Attribute(...)] declaration so it can be applied to PHP code.', + $directiveClass, + )); + } + + public static function unsupportedArgumentType(string $directiveClass, string $parameterName, string $reason): self + { + return new self(sprintf( + 'Directive "%s" constructor parameter "$%s" cannot be mapped to a GraphQL input type: %s. ' . + 'Supported types are scalars (string, int, float, bool), backed enums, and arrays/lists of those.', + $directiveClass, + $parameterName, + $reason, + )); + } + + public static function duplicateName(string $name, string $existingClass, string $newClass): self + { + return new self(sprintf( + 'Two directives declare the GraphQL name "@%s": %s and %s. Directive names must be unique.', + $name, + $existingClass, + $newClass, + )); + } + + public static function reservedName(string $name, string $directiveClass): self + { + return new self(sprintf( + 'Directive "%s" declares the name "@%s" which is reserved for a webonyx built-in directive (@skip, @include, @deprecated). Pick a different name.', + $directiveClass, + $name, + )); + } + + public static function noDefinitionMethod(string $directiveClass): self + { + return new self(sprintf( + 'Directive "%s" implements DirectiveInterface but does not declare a static `definition(): DirectiveDefinition` method.', + $directiveClass, + )); + } +} diff --git a/src/Directives/FieldDirective.php b/src/Directives/FieldDirective.php new file mode 100644 index 000000000..14126d5f9 --- /dev/null +++ b/src/Directives/FieldDirective.php @@ -0,0 +1,20 @@ + $arguments */ + public function __construct( + public readonly DirectiveDefinition $definition, + public readonly array $arguments, + public readonly WebonyxDirective|null $webonyxDirective, + ) { + } +} diff --git a/src/Directives/ResolvedDirectiveArgument.php b/src/Directives/ResolvedDirectiveArgument.php new file mode 100644 index 000000000..9113e72fa --- /dev/null +++ b/src/Directives/ResolvedDirectiveArgument.php @@ -0,0 +1,29 @@ + $reflectionClass + * @param list $directives + */ + public function __construct( + private readonly ReflectionClass $reflectionClass, + private readonly MutableInputObjectType $type, + private readonly array $directives = [], + ) { + } + + /** @return ReflectionClass */ + public function getReflectionClass(): ReflectionClass + { + return $this->reflectionClass; + } + + public function getType(): MutableInputObjectType + { + return $this->type; + } + + /** @return list */ + public function getDirectives(): array + { + return $this->directives; + } + + /** @param list $directives */ + public function withDirectives(array $directives): self + { + return $this->with(directives: $directives); + } +} diff --git a/src/InputTypeGenerator.php b/src/InputTypeGenerator.php index d2bc24797..80f010b93 100644 --- a/src/InputTypeGenerator.php +++ b/src/InputTypeGenerator.php @@ -6,11 +6,15 @@ use GraphQL\Type\Definition\InputObjectType; use Psr\Container\ContainerInterface; +use ReflectionClass; use ReflectionFunctionAbstract; use ReflectionMethod; +use TheCodingMachine\GraphQLite\Middlewares\InputObjectTypeHandlerInterface; +use TheCodingMachine\GraphQLite\Middlewares\InputObjectTypeMiddlewareInterface; use TheCodingMachine\GraphQLite\Reflection\DocBlock\DocBlockFactory; use TheCodingMachine\GraphQLite\Types\InputType; use TheCodingMachine\GraphQLite\Types\InputTypeValidatorInterface; +use TheCodingMachine\GraphQLite\Types\MutableInputObjectType; use TheCodingMachine\GraphQLite\Types\ResolvableMutableInputInterface; use TheCodingMachine\GraphQLite\Types\ResolvableMutableInputObjectType; use TheCodingMachine\GraphQLite\Utils\DescriptionResolver; @@ -36,6 +40,7 @@ public function __construct( private AnnotationReader|null $annotationReader = null, private DocBlockFactory|null $docBlockFactory = null, private DescriptionResolver $descriptionResolver = new DescriptionResolver(true), + private InputObjectTypeMiddlewareInterface|null $inputObjectTypeMiddleware = null, ) { } @@ -52,7 +57,7 @@ public function mapFactoryMethod(string $factory, string $methodName, ContainerI [$inputName, $className] = $this->inputTypeUtils->getInputTypeNameAndClassName($method); if (! isset($this->factoryCache[$inputName])) { - $this->factoryCache[$inputName] = new ResolvableMutableInputObjectType( + $type = new ResolvableMutableInputObjectType( $inputName, $this->fieldsBuilder, $object, @@ -60,6 +65,12 @@ public function mapFactoryMethod(string $factory, string $methodName, ContainerI $this->resolveFactoryDescription($method), $this->canBeInstantiatedWithoutParameter($method, false), ); + + /** @var class-string $factoryClass */ + $factoryClass = $factory; + $decorated = $this->runInputObjectMiddleware(new ReflectionClass($factoryClass), $type); + assert($decorated instanceof ResolvableMutableInputObjectType); + $this->factoryCache[$inputName] = $decorated; } return $this->factoryCache[$inputName]; @@ -96,7 +107,7 @@ private function resolveFactoryDescription(ReflectionMethod $method): string|nul public function mapInput(string $className, string $inputName, string|null $description, bool $isUpdate): InputType { if (! isset($this->inputCache[$inputName])) { - $this->inputCache[$inputName] = new InputType( + $type = new InputType( $className, $inputName, $description, @@ -104,11 +115,40 @@ public function mapInput(string $className, string $inputName, string|null $desc $this->fieldsBuilder, $this->inputTypeValidator, ); + + $decorated = $this->runInputObjectMiddleware(new ReflectionClass($className), $type); + assert($decorated instanceof InputType); + $this->inputCache[$inputName] = $decorated; } return $this->inputCache[$inputName]; } + /** @param ReflectionClass $refClass */ + private function runInputObjectMiddleware(ReflectionClass $refClass, MutableInputObjectType $type): MutableInputObjectType + { + if ($this->inputObjectTypeMiddleware === null || $this->annotationReader === null) { + return $type; + } + + $directives = $this->annotationReader->getInputObjectTypeDirectives($refClass); + if ($directives === []) { + return $type; + } + + $descriptor = new InputObjectTypeDescriptor($refClass, $type, $directives); + + return $this->inputObjectTypeMiddleware->process( + $descriptor, + new class implements InputObjectTypeHandlerInterface { + public function handle(InputObjectTypeDescriptor $descriptor): MutableInputObjectType + { + return $descriptor->getType(); + } + }, + ); + } + public static function canBeInstantiatedWithoutParameter(ReflectionFunctionAbstract $refMethod, bool $skipFirstArgument): bool { $nbParams = $refMethod->getNumberOfRequiredParameters(); diff --git a/src/Middlewares/DirectiveFieldMiddleware.php b/src/Middlewares/DirectiveFieldMiddleware.php new file mode 100644 index 000000000..cb6f86f60 --- /dev/null +++ b/src/Middlewares/DirectiveFieldMiddleware.php @@ -0,0 +1,74 @@ + $directives */ + $directives = $queryFieldDescriptor + ->getMiddlewareAnnotations() + ->getAnnotationsByType(FieldDirective::class); + + if ($directives === []) { + return $fieldHandler->handle($queryFieldDescriptor); + } + + $behavioralDirectives = array_values(array_filter( + $directives, + static fn (FieldDirective $directive): bool => $directive instanceof BehavioralFieldDirective, + )); + + $handler = $fieldHandler; + foreach (array_reverse($behavioralDirectives) as $directive) { + $handler = new class ($directive, $handler) implements FieldHandlerInterface { + public function __construct( + private readonly BehavioralFieldDirective $directive, + private readonly FieldHandlerInterface $next, + ) { + } + + public function handle(QueryFieldDescriptor $fieldDescriptor): FieldDefinition|null + { + return $this->directive->applyToField($fieldDescriptor, $this->next); + } + }; + } + + $result = $handler->handle($queryFieldDescriptor); + if ($result === null) { + return null; + } + + $result->astNode = DirectiveAstBuilder::buildFieldDefinitionNode( + $result->name, + $this->astBuilder->buildDirectiveNodes($directives), + ); + + return $result; + } +} diff --git a/src/Middlewares/DirectiveInputFieldMiddleware.php b/src/Middlewares/DirectiveInputFieldMiddleware.php new file mode 100644 index 000000000..4ff9217ea --- /dev/null +++ b/src/Middlewares/DirectiveInputFieldMiddleware.php @@ -0,0 +1,79 @@ + $directives */ + $directives = $inputFieldDescriptor + ->getMiddlewareAnnotations() + ->getAnnotationsByType(InputFieldDirective::class); + + if ($directives === []) { + return $inputFieldHandler->handle($inputFieldDescriptor); + } + + $behavioralDirectives = array_values(array_filter( + $directives, + static fn (InputFieldDirective $directive): bool => $directive instanceof BehavioralInputFieldDirective, + )); + + $handler = $inputFieldHandler; + foreach (array_reverse($behavioralDirectives) as $directive) { + $handler = new class ($directive, $handler) implements InputFieldHandlerInterface { + public function __construct( + private readonly BehavioralInputFieldDirective $directive, + private readonly InputFieldHandlerInterface $next, + ) { + } + + public function handle(InputFieldDescriptor $inputFieldDescriptor): InputField|null + { + return $this->directive->applyToInputField($inputFieldDescriptor, $this->next); + } + }; + } + + $result = $handler->handle($inputFieldDescriptor); + if ($result === null) { + return null; + } + + $astNode = DirectiveAstBuilder::buildInputValueDefinitionNode( + $result->name, + $this->astBuilder->buildDirectiveNodes($directives), + ); + + // InputObjectType reconstructs each field from $field->config (see InputType.php:51), + // so mutating only the astNode property would not survive. Update the config too. + $result->astNode = $astNode; + $result->config['astNode'] = $astNode; + + return $result; + } +} diff --git a/src/Middlewares/DirectiveInputObjectTypeMiddleware.php b/src/Middlewares/DirectiveInputObjectTypeMiddleware.php new file mode 100644 index 000000000..c72afb90c --- /dev/null +++ b/src/Middlewares/DirectiveInputObjectTypeMiddleware.php @@ -0,0 +1,67 @@ +getDirectives(); + + if ($directives === []) { + return $next->handle($descriptor); + } + + $behavioralDirectives = array_values(array_filter( + $directives, + static fn ($directive): bool => $directive instanceof BehavioralInputObjectTypeDirective, + )); + + $handler = $next; + foreach (array_reverse($behavioralDirectives) as $directive) { + $handler = new class ($directive, $handler) implements InputObjectTypeHandlerInterface { + public function __construct( + private readonly BehavioralInputObjectTypeDirective $directive, + private readonly InputObjectTypeHandlerInterface $next, + ) { + } + + public function handle(InputObjectTypeDescriptor $descriptor): MutableInputObjectType + { + return $this->directive->applyToInputObjectType($descriptor, $this->next); + } + }; + } + + $type = $handler->handle($descriptor); + + $type->astNode = DirectiveAstBuilder::buildInputObjectTypeDefinitionNode( + $type->name, + $this->astBuilder->buildDirectiveNodes($directives), + ); + + return $type; + } +} diff --git a/src/Middlewares/DirectiveObjectTypeMiddleware.php b/src/Middlewares/DirectiveObjectTypeMiddleware.php new file mode 100644 index 000000000..06bcd8c11 --- /dev/null +++ b/src/Middlewares/DirectiveObjectTypeMiddleware.php @@ -0,0 +1,67 @@ +getDirectives(); + + if ($directives === []) { + return $next->handle($descriptor); + } + + $behavioralDirectives = array_values(array_filter( + $directives, + static fn ($directive): bool => $directive instanceof BehavioralObjectTypeDirective, + )); + + $handler = $next; + foreach (array_reverse($behavioralDirectives) as $directive) { + $handler = new class ($directive, $handler) implements ObjectTypeHandlerInterface { + public function __construct( + private readonly BehavioralObjectTypeDirective $directive, + private readonly ObjectTypeHandlerInterface $next, + ) { + } + + public function handle(ObjectTypeDescriptor $descriptor): MutableObjectType + { + return $this->directive->applyToObjectType($descriptor, $this->next); + } + }; + } + + $type = $handler->handle($descriptor); + + $type->astNode = DirectiveAstBuilder::buildObjectTypeDefinitionNode( + $type->name, + $this->astBuilder->buildDirectiveNodes($directives), + ); + + return $type; + } +} diff --git a/src/Middlewares/InputObjectTypeHandlerInterface.php b/src/Middlewares/InputObjectTypeHandlerInterface.php new file mode 100644 index 000000000..fa8181400 --- /dev/null +++ b/src/Middlewares/InputObjectTypeHandlerInterface.php @@ -0,0 +1,14 @@ +pipeline = new SplQueue(); + } + + public function process(InputObjectTypeDescriptor $descriptor, InputObjectTypeHandlerInterface $next): MutableInputObjectType + { + return (new InputObjectTypeNext($this->pipeline, $next))->handle($descriptor); + } + + public function pipe(InputObjectTypeMiddlewareInterface $middleware): void + { + $this->pipeline->enqueue($middleware); + } +} diff --git a/src/Middlewares/InputObjectTypeNext.php b/src/Middlewares/InputObjectTypeNext.php new file mode 100644 index 000000000..92dab70d9 --- /dev/null +++ b/src/Middlewares/InputObjectTypeNext.php @@ -0,0 +1,35 @@ +queue = clone $queue; + } + + public function handle(InputObjectTypeDescriptor $descriptor): MutableInputObjectType + { + if ($this->queue->isEmpty()) { + return $this->fallbackHandler->handle($descriptor); + } + + $middleware = $this->queue->dequeue(); + assert($middleware instanceof InputObjectTypeMiddlewareInterface); + return $middleware->process($descriptor, $this); + } +} diff --git a/src/Middlewares/ObjectTypeHandlerInterface.php b/src/Middlewares/ObjectTypeHandlerInterface.php new file mode 100644 index 000000000..97dd2a21d --- /dev/null +++ b/src/Middlewares/ObjectTypeHandlerInterface.php @@ -0,0 +1,14 @@ +pipeline = new SplQueue(); + } + + public function process(ObjectTypeDescriptor $descriptor, ObjectTypeHandlerInterface $next): MutableObjectType + { + return (new ObjectTypeNext($this->pipeline, $next))->handle($descriptor); + } + + public function pipe(ObjectTypeMiddlewareInterface $middleware): void + { + $this->pipeline->enqueue($middleware); + } +} diff --git a/src/Middlewares/ObjectTypeNext.php b/src/Middlewares/ObjectTypeNext.php new file mode 100644 index 000000000..923f28bc8 --- /dev/null +++ b/src/Middlewares/ObjectTypeNext.php @@ -0,0 +1,35 @@ +queue = clone $queue; + } + + public function handle(ObjectTypeDescriptor $descriptor): MutableObjectType + { + if ($this->queue->isEmpty()) { + return $this->fallbackHandler->handle($descriptor); + } + + $middleware = $this->queue->dequeue(); + assert($middleware instanceof ObjectTypeMiddlewareInterface); + return $middleware->process($descriptor, $this); + } +} diff --git a/src/ObjectTypeDescriptor.php b/src/ObjectTypeDescriptor.php new file mode 100644 index 000000000..79bdf38ae --- /dev/null +++ b/src/ObjectTypeDescriptor.php @@ -0,0 +1,53 @@ + $reflectionClass + * @param list $directives + */ + public function __construct( + private readonly ReflectionClass $reflectionClass, + private readonly MutableObjectType $type, + private readonly array $directives = [], + ) { + } + + /** @return ReflectionClass */ + public function getReflectionClass(): ReflectionClass + { + return $this->reflectionClass; + } + + public function getType(): MutableObjectType + { + return $this->type; + } + + /** @return list */ + public function getDirectives(): array + { + return $this->directives; + } + + /** @param list $directives */ + public function withDirectives(array $directives): self + { + return $this->with(directives: $directives); + } +} diff --git a/src/Schema.php b/src/Schema.php index f90c7fbbf..3b30354a8 100644 --- a/src/Schema.php +++ b/src/Schema.php @@ -4,6 +4,7 @@ namespace TheCodingMachine\GraphQLite; +use GraphQL\Type\Definition\Directive; use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\Type; use GraphQL\Type\SchemaConfig; @@ -18,12 +19,17 @@ */ class Schema extends \GraphQL\Type\Schema { + /** + * @param array $directives Custom directive definitions to add alongside webonyx's + * standard ones. + */ public function __construct( QueryProviderInterface $queryProvider, RecursiveTypeMapperInterface $recursiveTypeMapper, TypeResolver $typeResolver, RootTypeMapperInterface $rootTypeMapper, SchemaConfig|null $config = null, + array $directives = [], ) { if ($config === null) { $config = SchemaConfig::create(); @@ -113,6 +119,16 @@ public function __construct( return $rootTypeMapper->mapNameToType($name); }); + // Register custom directives so they show up in introspection and SDL. webonyx drops its + // standard directives once this list is set, so include them too: whatever the config + // already had, or webonyx's defaults. + if ($directives !== []) { + $config->setDirectives([ + ...($config->getDirectives() ?? Directive::builtInDirectives()), + ...$directives, + ]); + } + $typeResolver->registerSchema($this); parent::__construct($config); diff --git a/src/SchemaFactory.php b/src/SchemaFactory.php index 20b5e27c1..fecf257c1 100644 --- a/src/SchemaFactory.php +++ b/src/SchemaFactory.php @@ -18,6 +18,9 @@ use TheCodingMachine\GraphQLite\Cache\ClassBoundCache; use TheCodingMachine\GraphQLite\Cache\FilesSnapshot; use TheCodingMachine\GraphQLite\Cache\SnapshotClassBoundCache; +use TheCodingMachine\GraphQLite\Directives\DirectiveAstBuilder; +use TheCodingMachine\GraphQLite\Directives\DirectiveRegistry; +use TheCodingMachine\GraphQLite\Directives\Discovery\DirectiveClassFinder; use TheCodingMachine\GraphQLite\Discovery\Cache\HardClassFinderComputedCache; use TheCodingMachine\GraphQLite\Discovery\Cache\SnapshotClassFinderComputedCache; use TheCodingMachine\GraphQLite\Discovery\ClassFinder; @@ -50,10 +53,16 @@ use TheCodingMachine\GraphQLite\Middlewares\AuthorizationFieldMiddleware; use TheCodingMachine\GraphQLite\Middlewares\AuthorizationInputFieldMiddleware; use TheCodingMachine\GraphQLite\Middlewares\CostFieldMiddleware; +use TheCodingMachine\GraphQLite\Middlewares\DirectiveFieldMiddleware; +use TheCodingMachine\GraphQLite\Middlewares\DirectiveInputFieldMiddleware; +use TheCodingMachine\GraphQLite\Middlewares\DirectiveInputObjectTypeMiddleware; +use TheCodingMachine\GraphQLite\Middlewares\DirectiveObjectTypeMiddleware; use TheCodingMachine\GraphQLite\Middlewares\FieldMiddlewareInterface; use TheCodingMachine\GraphQLite\Middlewares\FieldMiddlewarePipe; use TheCodingMachine\GraphQLite\Middlewares\InputFieldMiddlewareInterface; use TheCodingMachine\GraphQLite\Middlewares\InputFieldMiddlewarePipe; +use TheCodingMachine\GraphQLite\Middlewares\InputObjectTypeMiddlewarePipe; +use TheCodingMachine\GraphQLite\Middlewares\ObjectTypeMiddlewarePipe; use TheCodingMachine\GraphQLite\Middlewares\SecurityFieldMiddleware; use TheCodingMachine\GraphQLite\Middlewares\SecurityInputFieldMiddleware; use TheCodingMachine\GraphQLite\Reflection\DocBlock\CachedDocBlockFactory; @@ -400,6 +409,12 @@ public function createSchema(): Schema $expressionLanguage = $this->expressionLanguage ?: new ExpressionLanguage($symfonyCache); $expressionLanguage->registerProvider(new SecurityExpressionLanguageProvider()); + $directiveRegistry = new DirectiveRegistry( + new DirectiveClassFinder($classFinder, $classFinderComputedCache), + ); + $directiveRegistry->discover(); + $directiveAstBuilder = new DirectiveAstBuilder($directiveRegistry); + $fieldMiddlewarePipe = new FieldMiddlewarePipe(); foreach ($this->fieldMiddlewares as $fieldMiddleware) { $fieldMiddlewarePipe->pipe($fieldMiddleware); @@ -408,6 +423,7 @@ public function createSchema(): Schema $fieldMiddlewarePipe->pipe(new SecurityFieldMiddleware($expressionLanguage, $authenticationService, $authorizationService)); $fieldMiddlewarePipe->pipe(new AuthorizationFieldMiddleware($authenticationService, $authorizationService)); $fieldMiddlewarePipe->pipe(new CostFieldMiddleware()); + $fieldMiddlewarePipe->pipe(new DirectiveFieldMiddleware($directiveAstBuilder)); $inputFieldMiddlewarePipe = new InputFieldMiddlewarePipe(); foreach ($this->inputFieldMiddlewares as $inputFieldMiddleware) { @@ -416,6 +432,13 @@ public function createSchema(): Schema // TODO: add a logger to the SchemaFactory and make use of it everywhere (and most particularly in SecurityInputFieldMiddleware) $inputFieldMiddlewarePipe->pipe(new SecurityInputFieldMiddleware($expressionLanguage, $authenticationService, $authorizationService)); $inputFieldMiddlewarePipe->pipe(new AuthorizationInputFieldMiddleware($authenticationService, $authorizationService)); + $inputFieldMiddlewarePipe->pipe(new DirectiveInputFieldMiddleware($directiveAstBuilder)); + + $objectTypeMiddlewarePipe = new ObjectTypeMiddlewarePipe(); + $objectTypeMiddlewarePipe->pipe(new DirectiveObjectTypeMiddleware($directiveAstBuilder)); + + $inputObjectTypeMiddlewarePipe = new InputObjectTypeMiddlewarePipe(); + $inputObjectTypeMiddlewarePipe->pipe(new DirectiveInputObjectTypeMiddleware($directiveAstBuilder)); $compositeTypeMapper = new CompositeTypeMapper(); $recursiveTypeMapper = new RecursiveTypeMapper($compositeTypeMapper, $namingStrategy, $namespacedCache, $typeRegistry, $annotationReader); @@ -481,9 +504,9 @@ classBoundCache: $classBoundCache, $parameterMiddlewarePipe->pipe(new ContainerParameterHandler($this->container)); $parameterMiddlewarePipe->pipe(new InjectUserParameterHandler($authenticationService)); - $typeGenerator = new TypeGenerator($annotationReader, $namingStrategy, $typeRegistry, $this->container, $recursiveTypeMapper, $fieldsBuilder, $docBlockFactory, $descriptionResolver); + $typeGenerator = new TypeGenerator($annotationReader, $namingStrategy, $typeRegistry, $this->container, $recursiveTypeMapper, $fieldsBuilder, $docBlockFactory, $descriptionResolver, $objectTypeMiddlewarePipe); $inputTypeUtils = new InputTypeUtils($annotationReader, $namingStrategy); - $inputTypeGenerator = new InputTypeGenerator($inputTypeUtils, $fieldsBuilder, $this->inputTypeValidator, $annotationReader, $docBlockFactory, $descriptionResolver); + $inputTypeGenerator = new InputTypeGenerator($inputTypeUtils, $fieldsBuilder, $this->inputTypeValidator, $annotationReader, $docBlockFactory, $descriptionResolver, $inputObjectTypeMiddlewarePipe); if ($this->namespaces) { $compositeTypeMapper->addTypeMapper(new ClassFinderTypeMapper( @@ -558,7 +581,14 @@ classBoundCache: $classBoundCache, $aggregateQueryProvider = new AggregateQueryProvider($queryProviders); - return new Schema($aggregateQueryProvider, $recursiveTypeMapper, $typeResolver, $topRootTypeMapper, $this->schemaConfig); + return new Schema( + $aggregateQueryProvider, + $recursiveTypeMapper, + $typeResolver, + $topRootTypeMapper, + $this->schemaConfig, + $directiveRegistry->webonyxDirectives(), + ); } private function createClassFinder(): ClassFinder diff --git a/src/TypeGenerator.php b/src/TypeGenerator.php index 1220fa679..cc560618e 100644 --- a/src/TypeGenerator.php +++ b/src/TypeGenerator.php @@ -11,6 +11,8 @@ use TheCodingMachine\GraphQLite\Annotations\ExtendType; use TheCodingMachine\GraphQLite\Annotations\Type as TypeAnnotation; use TheCodingMachine\GraphQLite\Mappers\RecursiveTypeMapperInterface; +use TheCodingMachine\GraphQLite\Middlewares\ObjectTypeHandlerInterface; +use TheCodingMachine\GraphQLite\Middlewares\ObjectTypeMiddlewareInterface; use TheCodingMachine\GraphQLite\Reflection\DocBlock\DocBlockFactory; use TheCodingMachine\GraphQLite\Types\MutableInterface; use TheCodingMachine\GraphQLite\Types\MutableInterfaceType; @@ -49,6 +51,7 @@ public function __construct( private FieldsBuilder $fieldsBuilder, private DocBlockFactory|null $docBlockFactory = null, private DescriptionResolver $descriptionResolver = new DescriptionResolver(true), + private ObjectTypeMiddlewareInterface|null $objectTypeMiddleware = null, ) { } @@ -125,6 +128,22 @@ public function mapAnnotatedObject(string $annotatedObjectClassName): MutableInt $type->description = $resolvedDescription; } + if ($type instanceof MutableObjectType && $this->objectTypeMiddleware !== null) { + $directives = $this->annotationReader->getObjectTypeDirectives($refTypeClass); + if ($directives !== []) { + $descriptor = new ObjectTypeDescriptor($refTypeClass, $type, $directives); + $type = $this->objectTypeMiddleware->process( + $descriptor, + new class implements ObjectTypeHandlerInterface { + public function handle(ObjectTypeDescriptor $descriptor): MutableObjectType + { + return $descriptor->getType(); + } + }, + ); + } + } + return $type; } diff --git a/tests/Directives/BuiltInDirectivesTest.php b/tests/Directives/BuiltInDirectivesTest.php new file mode 100644 index 000000000..685ece3f6 --- /dev/null +++ b/tests/Directives/BuiltInDirectivesTest.php @@ -0,0 +1,80 @@ +discover(); + + $this->assertNotNull($registry->definitionFor(OneOf::class)); + } + + public function testBuiltInDirectivesAreNotEmittedAsCustomDirectives(): void + { + $registry = self::buildRegistry([]); + $registry->discover(); + + // @oneOf is a webonyx built-in, so the registry shouldn't add it to the custom list. + $names = array_map(static fn (WebonyxDirective $d) => $d->name, $registry->webonyxDirectives()); + + $this->assertSame([], $names); + $this->assertFalse($registry->hasAny()); + } + + public function testOneOfDefinitionMarksItselfAsBuiltIn(): void + { + $definition = OneOf::definition(); + + $this->assertSame('oneOf', $definition->name); + $this->assertTrue($definition->builtIn); + $this->assertSame([DirectiveLocation::INPUT_OBJECT], $definition->locations); + } + + public function testDiscoveryFindingTheBundledBuiltInClassIsIdempotent(): void + { + // If discovery finds our own built-in class (same FQCN as BUILT_IN_ATTRIBUTES), registering + // it twice shouldn't throw. + $registry = self::buildRegistry([OneOf::class]); + $registry->discover(); + + $this->assertNotNull($registry->definitionFor(OneOf::class)); + } + + public function testUserOverrideOfBuiltInWinsOverBundled(): void + { + // User binds their own class to @oneOf (builtIn: true); ours should defer to it. + $registry = self::buildRegistry([CustomOneOfOverride::class]); + $registry->discover(); + + $this->assertNotNull($registry->definitionFor(CustomOneOfOverride::class)); + $this->assertNull($registry->definitionFor(OneOf::class)); + } + + /** @param list> $classes */ + private static function buildRegistry(array $classes): DirectiveRegistry + { + $finder = new DirectiveClassFinder( + new StaticClassFinder($classes), + new HardClassFinderComputedCache(new Psr16Cache(new ArrayAdapter())), + ); + + return new DirectiveRegistry($finder); + } +} diff --git a/tests/Directives/DirectiveDefinitionTest.php b/tests/Directives/DirectiveDefinitionTest.php new file mode 100644 index 000000000..3fa2000f4 --- /dev/null +++ b/tests/Directives/DirectiveDefinitionTest.php @@ -0,0 +1,33 @@ +assertSame('audit', $definition->name); + $this->assertSame([DirectiveLocation::FIELD_DEFINITION, DirectiveLocation::INPUT_FIELD_DEFINITION], $definition->locations); + $this->assertSame('Audit log marker', $definition->description); + } + + public function testDefaultsDescriptionToNull(): void + { + $definition = new DirectiveDefinition( + name: 'noop', + locations: [DirectiveLocation::OBJECT], + ); + + $this->assertNull($definition->description); + } +} diff --git a/tests/Directives/DirectiveLocationTest.php b/tests/Directives/DirectiveLocationTest.php new file mode 100644 index 000000000..bccc7f239 --- /dev/null +++ b/tests/Directives/DirectiveLocationTest.php @@ -0,0 +1,49 @@ +assertCount(19, DirectiveLocation::cases()); + } + + public function testBackingValuesMatchSpecStrings(): void + { + $this->assertSame('FIELD_DEFINITION', DirectiveLocation::FIELD_DEFINITION->value); + $this->assertSame('OBJECT', DirectiveLocation::OBJECT->value); + $this->assertSame('INPUT_OBJECT', DirectiveLocation::INPUT_OBJECT->value); + $this->assertSame('INPUT_FIELD_DEFINITION', DirectiveLocation::INPUT_FIELD_DEFINITION->value); + } + + public function testIsExecutableClassifiesQueryDocumentLocations(): void + { + $this->assertTrue(DirectiveLocation::QUERY->isExecutable()); + $this->assertTrue(DirectiveLocation::FIELD->isExecutable()); + $this->assertTrue(DirectiveLocation::FRAGMENT_DEFINITION->isExecutable()); + $this->assertTrue(DirectiveLocation::INLINE_FRAGMENT->isExecutable()); + $this->assertTrue(DirectiveLocation::VARIABLE_DEFINITION->isExecutable()); + } + + public function testIsTypeSystemClassifiesSchemaLocations(): void + { + $this->assertTrue(DirectiveLocation::FIELD_DEFINITION->isTypeSystem()); + $this->assertTrue(DirectiveLocation::OBJECT->isTypeSystem()); + $this->assertTrue(DirectiveLocation::INPUT_OBJECT->isTypeSystem()); + $this->assertTrue(DirectiveLocation::INPUT_FIELD_DEFINITION->isTypeSystem()); + $this->assertTrue(DirectiveLocation::ENUM->isTypeSystem()); + $this->assertTrue(DirectiveLocation::SCHEMA->isTypeSystem()); + } + + public function testIsExecutableAndIsTypeSystemArePartitions(): void + { + foreach (DirectiveLocation::cases() as $location) { + $this->assertNotSame($location->isExecutable(), $location->isTypeSystem(), "Location {$location->value} should be executable or type-system, not both."); + } + } +} diff --git a/tests/Directives/DirectiveRegistryTest.php b/tests/Directives/DirectiveRegistryTest.php new file mode 100644 index 000000000..15c0cd0ff --- /dev/null +++ b/tests/Directives/DirectiveRegistryTest.php @@ -0,0 +1,95 @@ +discover(); + + $this->assertTrue($registry->hasAny()); + + $webonyx = $registry->webonyxDirectives(); + $this->assertCount(3, $webonyx); + + $byName = []; + foreach ($webonyx as $directive) { + $byName[$directive->name] = $directive; + } + + $this->assertArrayHasKey('uppercase', $byName); + $this->assertArrayHasKey('audit', $byName); + $this->assertArrayHasKey('versioned', $byName); + + $audit = $byName['audit']; + $this->assertTrue($audit->isRepeatable); + $this->assertSame('Marks a field as needing audit-log treatment.', $audit->description); + $this->assertCount(1, $audit->args); + $this->assertSame('reason', $audit->args[0]->name); + } + + public function testRejectsCustomDirectiveUsingReservedName(): void + { + $registry = self::buildRegistry([ReservedNameDirective::class]); + + $this->expectException(InvalidDirectiveException::class); + $this->expectExceptionMessageMatches('/reserved/'); + + $registry->discover(); + } + + public function testEmptyRegistryReportsNoDirectives(): void + { + $registry = self::buildRegistry([]); + $registry->discover(); + + $this->assertFalse($registry->hasAny()); + $this->assertSame([], $registry->webonyxDirectives()); + } + + public function testWebonyxDirectivesIsolatedFromBuiltins(): void + { + // The built directives shouldn't include webonyx's built-ins; those get merged in at the + // Schema layer, not here. + $registry = self::buildRegistry([UppercaseFieldDirective::class]); + $registry->discover(); + + $names = array_map(static fn (WebonyxDirective $d) => $d->name, $registry->webonyxDirectives()); + $this->assertNotContains('skip', $names); + $this->assertNotContains('include', $names); + $this->assertNotContains('deprecated', $names); + $this->assertNotContains('oneOf', $names); + } + + /** @param list> $classes */ + private static function buildRegistry(array $classes): DirectiveRegistry + { + $finder = new DirectiveClassFinder( + new StaticClassFinder($classes), + new HardClassFinderComputedCache(new Psr16Cache(new ArrayAdapter())), + ); + + return new DirectiveRegistry($finder); + } +} diff --git a/tests/Directives/DirectiveResolverTest.php b/tests/Directives/DirectiveResolverTest.php new file mode 100644 index 000000000..ca380ddc3 --- /dev/null +++ b/tests/Directives/DirectiveResolverTest.php @@ -0,0 +1,55 @@ +assertSame([], $resolved->arguments); + } + + public function testResolvesScalarArgumentWithCorrectTypeAndNullability(): void + { + $resolved = DirectiveResolver::resolve(AuditFieldDirective::class, AuditFieldDirective::definition()); + + $this->assertCount(1, $resolved->arguments); + $this->assertSame('reason', $resolved->arguments[0]->name); + $this->assertInstanceOf(NonNull::class, $resolved->arguments[0]->type); + $this->assertInstanceOf(StringType::class, $resolved->arguments[0]->type->getWrappedType()); + $this->assertFalse($resolved->arguments[0]->hasDefaultValue); + } + + public function testInfersRepeatableFromAttributeFlags(): void + { + $audit = DirectiveResolver::resolve(AuditFieldDirective::class, AuditFieldDirective::definition())->webonyxDirective; + $uppercase = DirectiveResolver::resolve(UppercaseFieldDirective::class, UppercaseFieldDirective::definition())->webonyxDirective; + + $this->assertInstanceOf(WebonyxDirective::class, $audit); + $this->assertTrue($audit->isRepeatable); + + $this->assertInstanceOf(WebonyxDirective::class, $uppercase); + $this->assertFalse($uppercase->isRepeatable); + } + + public function testRejectsUnsupportedArgumentType(): void + { + $this->expectException(InvalidDirectiveException::class); + $this->expectExceptionMessageMatches('/scalar types/'); + + DirectiveResolver::resolve(UnsupportedArgumentTypeDirective::class, UnsupportedArgumentTypeDirective::definition()); + } +} diff --git a/tests/Directives/DirectiveValidatorTest.php b/tests/Directives/DirectiveValidatorTest.php new file mode 100644 index 000000000..e28b6b1ce --- /dev/null +++ b/tests/Directives/DirectiveValidatorTest.php @@ -0,0 +1,29 @@ +expectException(InvalidDirectiveException::class); + $this->expectExceptionMessageMatches('/TARGET_METHOD/'); + + DirectiveValidator::validate(MissingPhpTargetDirective::class, MissingPhpTargetDirective::definition()); + } + + public function testRejectsInterfaceWithoutMatchingLocation(): void + { + $this->expectException(InvalidDirectiveException::class); + $this->expectExceptionMessageMatches('/FieldDirective/'); + + DirectiveValidator::validate(InterfaceWithoutLocationDirective::class, InterfaceWithoutLocationDirective::definition()); + } +} diff --git a/tests/Fixtures/Directives/AuditFieldDirective.php b/tests/Fixtures/Directives/AuditFieldDirective.php new file mode 100644 index 000000000..c2b894cd6 --- /dev/null +++ b/tests/Fixtures/Directives/AuditFieldDirective.php @@ -0,0 +1,27 @@ +handle($descriptor); + $type->isOneOf = true; + return $type; + } +} diff --git a/tests/Fixtures/Directives/Invalid/InterfaceWithoutLocationDirective.php b/tests/Fixtures/Directives/Invalid/InterfaceWithoutLocationDirective.php new file mode 100644 index 000000000..0eb38163c --- /dev/null +++ b/tests/Fixtures/Directives/Invalid/InterfaceWithoutLocationDirective.php @@ -0,0 +1,25 @@ +getResolver(); + $descriptor = $descriptor->withResolver(static function (...$args) use ($resolver): mixed { + $value = $resolver(...$args); + return is_string($value) ? strtoupper($value) : $value; + }); + + return $next->handle($descriptor); + } +} diff --git a/tests/Fixtures/Directives/VersionedInputObjectDirective.php b/tests/Fixtures/Directives/VersionedInputObjectDirective.php new file mode 100644 index 000000000..35c2b3e4b --- /dev/null +++ b/tests/Fixtures/Directives/VersionedInputObjectDirective.php @@ -0,0 +1,30 @@ +sku ?? ('id-' . ($lookup->id ?? 0))); + } + + #[Query] + public function findOneOf(OneOfLookup $lookup): Widget + { + return new Widget($lookup->sku ?? ('id-' . ($lookup->id ?? 0))); + } +} diff --git a/tests/Fixtures/DirectivesIntegration/Directives/AuditDirective.php b/tests/Fixtures/DirectivesIntegration/Directives/AuditDirective.php new file mode 100644 index 000000000..7104860cc --- /dev/null +++ b/tests/Fixtures/DirectivesIntegration/Directives/AuditDirective.php @@ -0,0 +1,27 @@ +getResolver(); + $descriptor = $descriptor->withResolver(static function (...$args) use ($resolver): mixed { + $value = $resolver(...$args); + return is_string($value) ? strtoupper($value) : $value; + }); + + return $next->handle($descriptor); + } +} diff --git a/tests/Fixtures/DirectivesIntegration/Directives/VersionedDirective.php b/tests/Fixtures/DirectivesIntegration/Directives/VersionedDirective.php new file mode 100644 index 000000000..d6ba8affe --- /dev/null +++ b/tests/Fixtures/DirectivesIntegration/Directives/VersionedDirective.php @@ -0,0 +1,31 @@ +label; + } +} diff --git a/tests/Integration/DirectivesEndToEndTest.php b/tests/Integration/DirectivesEndToEndTest.php new file mode 100644 index 000000000..765bd87ee --- /dev/null +++ b/tests/Integration/DirectivesEndToEndTest.php @@ -0,0 +1,116 @@ +directives` for anyone who wants to print them with their own printer. + */ +final class DirectivesEndToEndTest extends TestCase +{ + private function buildSchema(): Schema + { + $cache = new Psr16Cache(new ArrayAdapter()); + $factory = new SchemaFactory($cache, new BasicAutoWiringContainer(new EmptyContainer())); + $factory->prodMode(); + $factory->addNamespace('TheCodingMachine\\GraphQLite\\Fixtures\\DirectivesIntegration'); + + return $factory->createSchema(); + } + + public function testSchemaPrintsDirectiveDefinitions(): void + { + $schema = $this->buildSchema(); + $sdl = SchemaPrinter::doPrint($schema); + + // Definitions: the webonyx printer emits these once the directives are registered on the + // schema (see SchemaFactory's wiring of DirectiveRegistry::webonyxDirectives()). + $this->assertStringContainsString('directive @uppercase on FIELD_DEFINITION', $sdl); + $this->assertStringContainsString('Marks a field for audit-log tracking.', $sdl); + $this->assertStringContainsString('directive @audit(reason: String!) repeatable on FIELD_DEFINITION', $sdl); + $this->assertStringContainsString('directive @tagged(name: String!) on OBJECT', $sdl); + $this->assertStringContainsString('directive @sanitized on INPUT_FIELD_DEFINITION', $sdl); + $this->assertStringContainsString('Marks an input with a schema version for backwards-compat tracking.', $sdl); + $this->assertStringContainsString('directive @versioned(version: Int!) on INPUT_OBJECT', $sdl); + + // No assertions on directive applications here; webonyx's SchemaPrinter doesn't render them. + // See the class docblock. + + // @oneOf is webonyx's built-in: it prints the application from the isOneOf flag, and we + // don't re-declare it in the custom list. + $this->assertStringNotContainsString('directive @oneOf ', $sdl); + $this->assertStringContainsString('input OneOfLookupInput @oneOf', $sdl); + } + + public function testIntrospectionExposesEveryDirective(): void + { + $schema = $this->buildSchema(); + + $result = GraphQL::executeQuery($schema, Introspection::getIntrospectionQuery())->toArray(); + $this->assertArrayNotHasKey('errors', $result); + + $directives = $result['data']['__schema']['directives']; + assert(is_array($directives)); + + $names = []; + foreach ($directives as $directive) { + $names[] = $directive['name']; + } + + // Built-ins remain present alongside custom directives. + $this->assertContains('skip', $names); + $this->assertContains('include', $names); + $this->assertContains('deprecated', $names); + + // Custom directives present. + $this->assertContains('uppercase', $names); + $this->assertContains('audit', $names); + $this->assertContains('tagged', $names); + $this->assertContains('versioned', $names); + $this->assertContains('sanitized', $names); + } + + public function testFieldDirectiveWrapsResolver(): void + { + $schema = $this->buildSchema(); + + $result = GraphQL::executeQuery($schema, '{ tagline }')->toArray(); + $this->assertArrayNotHasKey('errors', $result); + $this->assertSame('HELLO WORLD', $result['data']['tagline']); + } + + public function testInputObjectFieldsResolveWithDirectiveAttached(): void + { + $schema = $this->buildSchema(); + + $result = GraphQL::executeQuery( + $schema, + '{ findWidget(lookup: { sku: "abc" }) { label } }', + )->toArray(); + + $this->assertArrayNotHasKey('errors', $result); + // getLabel() carries @uppercase, so "abc" comes back uppercased. + $this->assertSame('ABC', $result['data']['findWidget']['label']); + } +}