Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 68 additions & 12 deletions src/Directives/DirectiveRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,43 +9,99 @@
use TheCodingMachine\GraphQLite\AnnotationReader;
use TheCodingMachine\GraphQLite\Directives\BuiltIn\Deprecated;
use TheCodingMachine\GraphQLite\Directives\BuiltIn\OneOf;
use TheCodingMachine\GraphQLite\Directives\Discovery\DirectiveClassFinder;
use TheCodingMachine\GraphQLite\Directives\Exceptions\InvalidDirectiveException;

use function array_key_exists;
use function in_array;

/**
* Holds the directives known to a schema. In this layer that's the built-ins that bind PHP behavior
* to directives webonyx already declares (`@oneOf`, `@deprecated`); a later layer adds user-defined
* directives on top.
*
* The dispatcher middlewares query it at apply time for a directive's argument shape, which the AST
* builder needs to encode each arg as a GraphQL value.
* Discovers, validates and holds a schema's directives: the user-defined ones plus the built-ins
* (`@oneOf`, `@deprecated`). Middlewares query it at apply time for a directive's argument shape.
*/
final class DirectiveRegistry
{
/** @var array<class-string<DirectiveInterface>, ResolvedDirective> */
private array $resolvedByClass = [];

/** Attribute classes that bind PHP behavior to webonyx's built-in directives. */
/** @var array<string, class-string<DirectiveInterface>> */
private array $classByName = [];

/** Attributes we bind to webonyx's built-ins; a custom directive reuses these names only via `builtIn: true`. */
private const BUILT_IN_ATTRIBUTES = [
OneOf::class,
Deprecated::class,
];

/** webonyx directives we don't bind; no custom directive may take these names. */
private const RESERVED_WEBONYX_NAMES = [
WebonyxDirective::SKIP_NAME,
WebonyxDirective::INCLUDE_NAME,
WebonyxDirective::SPECIFIED_BY_NAME,
];

public function __construct(
private readonly AnnotationReader $annotationReader,
private readonly DirectiveClassFinder $classFinder,
) {
}

/** Resolve the built-in directives once. Idempotent. */
/** Idempotent. User directives first, so a `builtIn: true` override lands before the bundled copy. */
public function discover(): void
{
foreach ($this->classFinder->findDirectives() as $directiveClass) {
$this->register($directiveClass);
}
foreach (self::BUILT_IN_ATTRIBUTES as $directiveClass) {
/** @var class-string<TypeSystemDirective> $directiveClass */
if (isset($this->resolvedByClass[$directiveClass])) {
continue;
$this->register($directiveClass);
}
}

/** @param class-string<TypeSystemDirective> $directiveClass */
private function register(string $directiveClass): void
{
// Discovery and the built-in list overlap, so a repeat is expected.
if (isset($this->resolvedByClass[$directiveClass])) {
return;
}

$definition = $directiveClass::definition();

DirectiveValidator::validate($directiveClass, $definition);

if (! $definition->builtIn && in_array($definition->name, self::RESERVED_WEBONYX_NAMES, true)) {
throw InvalidDirectiveException::reservedName($definition->name, $directiveClass);
}

if (! $definition->builtIn && $this->isReservedBuiltInName($definition->name)) {
throw InvalidDirectiveException::reservedName($definition->name, $directiveClass);
}

if (array_key_exists($definition->name, $this->classByName)) {
// A builtIn override defers to the already-registered user class; two customs is an error.
if ($definition->builtIn) {
return;
}
throw InvalidDirectiveException::duplicateName(
$definition->name,
$this->classByName[$definition->name],
$directiveClass,
);
}

$this->resolvedByClass[$directiveClass] = DirectiveResolver::resolve($directiveClass, $directiveClass::definition());
$this->resolvedByClass[$directiveClass] = DirectiveResolver::resolve($directiveClass, $definition);
$this->classByName[$definition->name] = $directiveClass;
}

private function isReservedBuiltInName(string $name): bool
{
foreach (self::BUILT_IN_ATTRIBUTES as $builtInClass) {
if ($builtInClass::definition()->name === $name) {
return true;
}
}

return false;
}

public function hasAny(): bool
Expand Down
28 changes: 19 additions & 9 deletions src/Directives/DirectiveValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,37 @@

namespace TheCodingMachine\GraphQLite\Directives;

use Attribute;
use ReflectionClass;
use TheCodingMachine\GraphQLite\Directives\Exceptions\InvalidDirectiveException;
use TheCodingMachine\GraphQLite\Directives\Validation\AttributeTargetValidator;
use TheCodingMachine\GraphQLite\Directives\Validation\InterfaceLocationValidator;

use function in_array;
use function is_a;

/**
* Validates that directives are applied where they're allowed. PHP's `#[Attribute]` targets can't
* tell a `#[Type]` class from an `#[Input]` class (both are `TARGET_CLASS`), so a class-level
* directive like `#[OneOf]` could be placed on the wrong kind of class. This reports that instead
* of letting the interface-based collectors drop the misplaced directive silently.
* Validates a directive at discovery time ({@see self::validate()}, delegating target and
* interface/location checks) and its placement at apply time ({@see self::assertDirectivesUsableAt()}).
*
* @internal
*/
final class DirectiveValidator
{
/**
* @param ReflectionClass<object> $refClass
*
* @throws InvalidDirectiveException when a directive on $refClass isn't allowed at $location.
*/
/** @param class-string<TypeSystemDirective> $directiveClass */
public static function validate(string $directiveClass, DirectiveDefinition $definition): void
{
$reflection = new ReflectionClass($directiveClass);

if ($reflection->getAttributes(Attribute::class) === []) {
throw InvalidDirectiveException::notAttribute($directiveClass);
}

AttributeTargetValidator::validate($directiveClass, $definition, DirectiveReflection::attributeFlags($reflection));
InterfaceLocationValidator::validate($directiveClass, $definition, $reflection);
}

/** @param ReflectionClass<object> $refClass */
public static function assertDirectivesUsableAt(ReflectionClass $refClass, DirectiveLocation $location): void
{
foreach ($refClass->getAttributes() as $attribute) {
Expand Down
71 changes: 71 additions & 0 deletions src/Directives/Discovery/DirectiveClassFinder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

declare(strict_types=1);

namespace TheCodingMachine\GraphQLite\Directives\Discovery;

use ReflectionClass;
use TheCodingMachine\GraphQLite\Directives\TypeSystemDirective;
use TheCodingMachine\GraphQLite\Discovery\Cache\ClassFinderComputedCache;
use TheCodingMachine\GraphQLite\Discovery\ClassFinder;

use function array_reduce;

/**
* Finds classes in the configured namespaces that implement {@see TypeSystemDirective}, cached the
* same way as {@see \TheCodingMachine\GraphQLite\Mappers\ClassFinderTypeMapper}.
*
* @internal
*/
final class DirectiveClassFinder
{
/** @var list<class-string<TypeSystemDirective>>|null */
private array|null $cache = null;

public function __construct(
private readonly ClassFinder $classFinder,
private readonly ClassFinderComputedCache $classFinderComputedCache,
) {
}

/** @return list<class-string<TypeSystemDirective>> */
public function findDirectives(): array
{
if ($this->cache !== null) {
return $this->cache;
}

/** @var list<class-string<TypeSystemDirective>> $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<TypeSystemDirective> $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;
}
}
20 changes: 20 additions & 0 deletions src/Directives/Discovery/GlobDirectivesCache.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace TheCodingMachine\GraphQLite\Directives\Discovery;

use TheCodingMachine\GraphQLite\Directives\TypeSystemDirective;

/**
* Cache entry for a directive class {@see DirectiveClassFinder} found.
*
* @internal
*/
final class GlobDirectivesCache
{
/** @param class-string<TypeSystemDirective> $directiveClass */
public function __construct(public readonly string $directiveClass)
{
}
}
10 changes: 1 addition & 9 deletions src/Directives/Exceptions/InvalidDirectiveException.php
Original file line number Diff line number Diff line change
Expand Up @@ -103,17 +103,9 @@ public static function duplicateName(string $name, string $existingClass, string
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.',
'Directive "%s" declares the name "@%s", which is reserved for a built-in directive. Pick a different name, or set builtIn: true on its definition to intentionally override the built-in.',
$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,
));
}
}
51 changes: 51 additions & 0 deletions src/Directives/Validation/AttributeTargetValidator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

namespace TheCodingMachine\GraphQLite\Directives\Validation;

use Attribute;
use TheCodingMachine\GraphQLite\Directives\DirectiveDefinition;
use TheCodingMachine\GraphQLite\Directives\DirectiveLocation;
use TheCodingMachine\GraphQLite\Directives\Exceptions\InvalidDirectiveException;

/**
* Checks a directive's `#[Attribute(...)]` targets cover every GraphQL location it declares.
*
* @internal
*/
final class AttributeTargetValidator
{
public static function validate(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);
}
}
}

/** @return array<int, string> required TARGET_* flag => 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'],
// No apply hooks for the other locations yet.
default => [],
};
}
}
51 changes: 51 additions & 0 deletions src/Directives/Validation/InterfaceLocationValidator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

namespace TheCodingMachine\GraphQLite\Directives\Validation;

use ReflectionClass;
use TheCodingMachine\GraphQLite\Directives\DirectiveDefinition;
use TheCodingMachine\GraphQLite\Directives\DirectiveLocation;
use TheCodingMachine\GraphQLite\Directives\Exceptions\InvalidDirectiveException;
use TheCodingMachine\GraphQLite\Directives\FieldDirective;
use TheCodingMachine\GraphQLite\Directives\InputFieldDirective;
use TheCodingMachine\GraphQLite\Directives\InputObjectTypeDirective;
use TheCodingMachine\GraphQLite\Directives\ObjectTypeDirective;
use TheCodingMachine\GraphQLite\Directives\TypeSystemDirective;

use function in_array;

/**
* Checks a directive's family interfaces and declared locations agree (FieldDirective and
* FIELD_DEFINITION imply each other, likewise for the input-field, object and input-object families).
*
* @internal
*/
final class InterfaceLocationValidator
{
/** @param ReflectionClass<TypeSystemDirective> $reflection */
public static function validate(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);
}
}
}
}
6 changes: 5 additions & 1 deletion src/SchemaFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
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;
Expand Down Expand Up @@ -408,7 +409,10 @@ public function createSchema(): Schema
$expressionLanguage = $this->expressionLanguage ?: new ExpressionLanguage($symfonyCache);
$expressionLanguage->registerProvider(new SecurityExpressionLanguageProvider());

$directiveRegistry = new DirectiveRegistry($annotationReader);
$directiveRegistry = new DirectiveRegistry(
$annotationReader,
new DirectiveClassFinder($classFinder, $classFinderComputedCache),
);
$directiveRegistry->discover();

$directiveAstBuilder = new DirectiveAstBuilder($directiveRegistry);
Expand Down
Loading
Loading