From 4297ed4bb9355f84e6b36090c8a7b47278614b7d Mon Sep 17 00:00:00 2001 From: Marwan Mustafa Fikrat Date: Sat, 13 Apr 2024 21:59:47 +1200 Subject: [PATCH] API Add Directive types Directives are part of the GraphQL specification, this PR adds the Directive type. Follow up PRs would be needed to fully utilise the spec. --- src/Schema/Schema.php | 35 +++- src/Schema/SchemaBuilder.php | 1 + src/Schema/StorableSchema.php | 21 ++- src/Schema/Storage/CodeGenerationStore.php | 23 ++- .../Storage/templates/directive.inc.php | 43 +++++ src/Schema/Storage/templates/registry.inc.php | 24 +++ src/Schema/Type/Directive.php | 154 ++++++++++++++++++ tests/Schema/IntegrationTest.php | 20 +++ tests/Schema/SchemaTest.php | 15 +- tests/Schema/_testDirectives/schema.yml | 14 ++ 10 files changed, 340 insertions(+), 10 deletions(-) create mode 100644 src/Schema/Storage/templates/directive.inc.php create mode 100644 src/Schema/Type/Directive.php create mode 100644 tests/Schema/_testDirectives/schema.yml diff --git a/src/Schema/Schema.php b/src/Schema/Schema.php index 1cb4b9683..9bda84bf7 100644 --- a/src/Schema/Schema.php +++ b/src/Schema/Schema.php @@ -37,6 +37,7 @@ use SilverStripe\ORM\ArrayLib; use Exception; use SilverStripe\Core\Injector\Injector; +use SilverStripe\GraphQL\Schema\Type\Directive; use TypeError; /** @@ -62,6 +63,7 @@ class Schema implements ConfigurationApplier const UNIONS = 'unions'; const ENUMS = 'enums'; const SCALARS = 'scalars'; + const DIRECTIVES = 'directives'; const QUERY_TYPE = 'Query'; const MUTATION_TYPE = 'Mutation'; const ALL = '*'; @@ -108,6 +110,11 @@ class Schema implements ConfigurationApplier */ private array $scalars = []; + /** + * @var Directive[] + */ + private array $directives = []; + private Type $queryType; private Type $mutationType; @@ -151,6 +158,7 @@ public function applyConfig(array $schemaConfig): Schema self::MODELS, self::ENUMS, self::SCALARS, + self::DIRECTIVES, self::SCHEMA_CONFIG, 'execute', self::SOURCE, @@ -166,6 +174,7 @@ public function applyConfig(array $schemaConfig): Schema $models = $schemaConfig[self::MODELS] ?? []; $enums = $schemaConfig[self::ENUMS] ?? []; $scalars = $schemaConfig[self::SCALARS] ?? []; + $directives = $schemaConfig[self::DIRECTIVES] ?? []; $config = $schemaConfig[self::SCHEMA_CONFIG] ?? []; @@ -249,6 +258,13 @@ public function applyConfig(array $schemaConfig): Schema $this->addScalar($scalar); } + static::assertValidConfig($directives); + foreach ($directives as $directiveName => $directiveConfig) { + static::assertValidConfig($directiveConfig, ['name', 'description', 'args', 'locations']); + $directive = Directive::create($directiveName, $directiveConfig); + $this->addDirective($directive); + } + $this->applyProceduralUpdates($config['execute'] ?? []); return $this; @@ -569,7 +585,8 @@ public function createStoreableSchema(): StorableSchema self::ENUMS => $this->getEnums(), self::INTERFACES => $this->getInterfaces(), self::UNIONS => $this->getUnions(), - self::SCALARS => $this->getScalars() + self::SCALARS => $this->getScalars(), + self::DIRECTIVES => $this->getDirectives() ], $this->getConfig() ); @@ -757,6 +774,22 @@ public function removeScalar(string $name): self return $this; } + public function getDirectives(): array + { + return $this->directives; + } + + public function getDirective(string $name): ?Directive + { + return $this->directives[$name] ?? null; + } + + public function addDirective(Directive $directive): self + { + $this->directives[$directive->getName()] = $directive; + return $this; + } + /** * @throws SchemaBuilderException */ diff --git a/src/Schema/SchemaBuilder.php b/src/Schema/SchemaBuilder.php index e090a598c..e99e5fb5c 100644 --- a/src/Schema/SchemaBuilder.php +++ b/src/Schema/SchemaBuilder.php @@ -219,6 +219,7 @@ private static function getSchemaConfigFromSource(string $schemaKey, string $dir Schema::INTERFACES => [], Schema::UNIONS => [], Schema::SCALARS => [], + Schema::DIRECTIVES => [], ]; $finder = new Finder(); diff --git a/src/Schema/StorableSchema.php b/src/Schema/StorableSchema.php index 2565446e4..cd8359629 100644 --- a/src/Schema/StorableSchema.php +++ b/src/Schema/StorableSchema.php @@ -6,6 +6,7 @@ use SilverStripe\Core\Injector\Injectable; use SilverStripe\GraphQL\Schema\Exception\SchemaBuilderException; use SilverStripe\GraphQL\Schema\Interfaces\SchemaValidator; +use SilverStripe\GraphQL\Schema\Type\Directive; use SilverStripe\GraphQL\Schema\Type\Enum; use SilverStripe\GraphQL\Schema\Type\InterfaceType; use SilverStripe\GraphQL\Schema\Type\Scalar; @@ -50,6 +51,11 @@ class StorableSchema implements SchemaValidator */ private array $scalars; + /** + * @var Directive[] + */ + private array $directives; + private SchemaConfig $config; public function __construct(array $config = [], ?SchemaConfig $context = null) @@ -59,6 +65,7 @@ public function __construct(array $config = [], ?SchemaConfig $context = null) $this->interfaces = $config[Schema::INTERFACES] ?? []; $this->unions = $config[Schema::UNIONS] ?? []; $this->scalars = $config[Schema::SCALARS] ?? []; + $this->directives = $config[Schema::DIRECTIVES] ?? []; $this->config = $context ?: SchemaConfig::create(); } @@ -102,6 +109,14 @@ public function getScalars(): array return $this->scalars; } + /** + * @return Directive[] + */ + public function getDirectives(): array + { + return $this->directives; + } + public function getConfig(): SchemaConfig { return $this->config; @@ -137,7 +152,8 @@ public function validate(): void array_keys($this->enums ?? []), array_keys($this->interfaces ?? []), array_keys($this->unions ?? []), - array_keys($this->scalars ?? []) + array_keys($this->scalars ?? []), + array_keys($this->directives ?? []) ); $dupes = []; foreach (array_count_values($allNames ?? []) as $val => $count) { @@ -170,7 +186,8 @@ public function validate(): void $this->enums, $this->interfaces, $this->unions, - $this->scalars + $this->scalars, + $this->directives ); /* @var SchemaValidator $validator */ foreach ($validators as $validator) { diff --git a/src/Schema/Storage/CodeGenerationStore.php b/src/Schema/Storage/CodeGenerationStore.php index c2c274969..820961cd7 100644 --- a/src/Schema/Storage/CodeGenerationStore.php +++ b/src/Schema/Storage/CodeGenerationStore.php @@ -3,6 +3,7 @@ namespace SilverStripe\GraphQL\Schema\Storage; use Exception; +use GraphQL\GraphQL; use GraphQL\Type\Schema as GraphQLSchema; use GraphQL\Type\SchemaConfig as GraphQLSchemaConfig; use Psr\Log\LoggerInterface; @@ -29,6 +30,7 @@ class CodeGenerationStore implements SchemaStorageInterface use Configurable; const TYPE_CLASS_NAME = 'Types'; + const DIRECTIVE_CLASS_NAME = 'Directives'; /** * @config @@ -137,6 +139,8 @@ public function persistSchema(StorableSchema $schema): void 'typeClassName' => self::TYPE_CLASS_NAME, 'namespace' => $this->getNamespace(), 'obfuscator' => $obfuscator, + 'directiveClassName' => self::DIRECTIVE_CLASS_NAME, + 'directives' => $schema->getDirectives() ]; $config = $schema->getConfig()->toArray(); @@ -146,10 +150,10 @@ public function persistSchema(StorableSchema $schema): void $fs->dumpFile( $configFile, 'getEnums(), $schema->getInterfaces(), $schema->getUnions(), - $schema->getScalars() + $schema->getScalars(), + $schema->getDirectives(), ); $encoder = Encoder::create(Path::join($templateDir, 'registry.inc.php'), $allComponents, $globals); $code = $encoder->encode(); @@ -185,13 +190,14 @@ public function persistSchema(StorableSchema $schema): void 'Unions' => 'union.inc.php', 'Enums' => 'enum.inc.php', 'Scalars' => 'scalar.inc.php', + 'Directives' => 'directive.inc.php', ]; $touched = []; $built = []; $total = 0; foreach ($fields as $field => $template) { $method = 'get' . $field; - /* @var Type|InterfaceType|UnionType|Enum $type */ + /* @var Type|InterfaceType|UnionType|Enum|Directive $type */ foreach ($schema->$method() as $type) { $total++; $name = $type->getName(); @@ -311,6 +317,11 @@ function (string $name) use ($registryClass) { $schemaConfig->setTypes($typeObjs); + $directivesClass = $this->getClassName(self::DIRECTIVE_CLASS_NAME); + $directives = call_user_func([$directivesClass, Schema::DIRECTIVES]); + $directives = array_merge(GraphQL::getStandardDirectives(), $directives); + $schemaConfig->setDirectives($directives); + $this->graphqlSchema = new GraphQLSchema($schemaConfig); return $this->graphqlSchema; diff --git a/src/Schema/Storage/templates/directive.inc.php b/src/Schema/Storage/templates/directive.inc.php new file mode 100644 index 000000000..5a3651d98 --- /dev/null +++ b/src/Schema/Storage/templates/directive.inc.php @@ -0,0 +1,43 @@ + + +namespace ; + +use GraphQL\Type\Definition\Directive; + +// @type:getName(); ?> + +class obfuscate($directive->getName()) ?> extends Directive +{ + public function __construct() + { + parent::__construct([ + 'name' => 'getName() ?>', + getDescription())) : ?> + 'description' => 'getDescription()); ?>', + + + 'locations' => [ + getLocations() as $location) : ?> + '', + + ], // locations + getArgs())) : ?> + 'args' => [ + getArgs() as $arg) : ?> + [ + 'name' => 'getName(); ?>', + 'type' => getEncodedType()->encode(); ?>, + getDefaultValue() !== null) : ?> + 'defaultValue' => getDefaultValue(), true); ?>, + + ], // arg + + ], // args + + ]); + } +} diff --git a/src/Schema/Storage/templates/registry.inc.php b/src/Schema/Storage/templates/registry.inc.php index f23f5e6aa..c07a04a9a 100644 --- a/src/Schema/Storage/templates/registry.inc.php +++ b/src/Schema/Storage/templates/registry.inc.php @@ -26,3 +26,27 @@ public static function getName(); ?>() { return static::get(' } + +class extends AbstractTypeRegistry +{ + protected static $types = []; + + protected static function getSourceDirectory(): string + { + return __DIR__; + } + + protected static function getSourceNamespace(): string + { + return __NAMESPACE__; + } + + public static function directives() { + return [ + + static::get('getName(); ?>'), + + ]; + } + +} diff --git a/src/Schema/Type/Directive.php b/src/Schema/Type/Directive.php new file mode 100644 index 000000000..92fb8e3af --- /dev/null +++ b/src/Schema/Type/Directive.php @@ -0,0 +1,154 @@ +setName($directiveName); + $this->applyConfig($config); + } + + public function applyConfig(array $config): void + { + Schema::assertValidConfig($config, [ + 'name', + 'description', + 'locations', + 'args', + ]); + + if (isset($config['name'])) { + $this->setName($config['name']); + } + + if (isset($config['description'])) { + $this->setDescription($config['description']); + } + + $locations = $config['locations'] ?? [DirectiveLocation::FIELD]; + $this->setLocations($locations); + + if (isset($config['args'])) { + $this->setArgs($config['args']); + } + } + + public function getName(): ?string + { + return $this->name; + } + + /** + * @throws SchemaBuilderException + */ + public function setName(string $name): self + { + Schema::assertValidName($name); + $this->name = $name; + return $this; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description) + { + $this->description = $description; + return $this; + } + + public function getLocations(): array + { + return $this->locations; + } + + /** + * @param string $argName + * @param null $config + * @param callable|null $callback + * @return Directive + */ + public function setLocations(array $locations): self + { + $this->locations = $locations; + return $this; + } + + public function getArgs(): array + { + return $this->args; + } + + /** + * @param string $argName + * @param null $config + * @param callable|null $callback + * @return Directive + */ + public function addArg(string $argName, $config, ?callable $callback = null): self + { + $argObj = $config instanceof Argument ? $config : Argument::create($argName, $config); + $this->args[$argObj->getName()] = $argObj; + if ($callback) { + call_user_func_array($callback, [$argObj]); + } + return $this; + } + + /** + * @param array $args + * @return $this + * @throws SchemaBuilderException + */ + public function setArgs(array $args): self + { + Schema::assertValidConfig($args); + foreach ($args as $argName => $config) { + if ($config === false) { + continue; + } + $this->addArg($argName, $config); + } + + return $this; + } + + public function validate(): void + { + } + + public function getSignature(): string + { + return md5(json_encode([ + $this->getName(), + $this->getDescription(), + ]) ?? ''); + } +} diff --git a/tests/Schema/IntegrationTest.php b/tests/Schema/IntegrationTest.php index c3cab1864..a0032c43f 100644 --- a/tests/Schema/IntegrationTest.php +++ b/tests/Schema/IntegrationTest.php @@ -1450,6 +1450,26 @@ public function testCustomFilterFields() $this->assertResult('readOneDataObjectFake.id', $id1, $result); } + public function testDirectives() + { + $dataObject1 = DataObjectFake::create([]); + $dataObject1->write(); + + $schema = $this->createSchema(new TestSchemaBuilder(['_' . __FUNCTION__])); + + $query = <<querySchema($schema, $query); + // The GraphQL server throws errors for unknown directives. + // A simple assertion on the result proves that directive was processed successfully. + $this->assertSuccess($result); + } + public function testHtaccess(): void { FakeProductPage::get()->removeAll(); diff --git a/tests/Schema/SchemaTest.php b/tests/Schema/SchemaTest.php index ca304a4e0..bbc91a624 100644 --- a/tests/Schema/SchemaTest.php +++ b/tests/Schema/SchemaTest.php @@ -15,12 +15,14 @@ use SilverStripe\GraphQL\Schema\Type\InterfaceType; use SilverStripe\GraphQL\Schema\Type\ModelType; use SilverStripe\GraphQL\Schema\Type\Scalar; +use SilverStripe\GraphQL\Schema\Type\Directive; use SilverStripe\GraphQL\Schema\Type\Type; use SilverStripe\GraphQL\Schema\Type\UnionType; use SilverStripe\GraphQL\Tests\Fake\DataObjectFake; use SilverStripe\GraphQL\Tests\Fake\FakePage; use SilverStripe\GraphQL\Tests\Fake\FakeSiteTree; use SilverStripe\GraphQL\Tests\Fake\SubFake\FakePage as SubFakePage; +use GraphQL\Language\DirectiveLocation; class SchemaTest extends SapphireTest { @@ -46,7 +48,7 @@ public function testApplyConfig() { $mock = $this->getMockBuilder(Schema::class) ->setConstructorArgs(['test', $this->createSchemaContext()]) - ->setMethods(['addType', 'addInterface', 'addUnion', 'addModel', 'addEnum', 'addScalar']) + ->setMethods(['addType', 'addInterface', 'addUnion', 'addModel', 'addEnum', 'addScalar', 'addDirective']) ->getMock(); $mock ->expects($this->exactly(3)) @@ -73,6 +75,10 @@ public function testApplyConfig() ->expects($this->once()) ->method('addScalar') ->with($this->isInstanceOf(Scalar::class)); + $mock + ->expects($this->once()) + ->method('addDirective') + ->with($this->isInstanceOf(Directive::class)); $config = $this->getValidConfig(); $mock->applyConfig($config); @@ -362,6 +368,13 @@ private function getValidConfig(): array 'mutations' => [ 'mutation1' => 'testtype3', ], + 'directives' => [ + 'directive1' => [ + 'locations' => [ + DirectiveLocation::FIELD + ] + ] + ] ]; } diff --git a/tests/Schema/_testDirectives/schema.yml b/tests/Schema/_testDirectives/schema.yml new file mode 100644 index 000000000..70511ab8b --- /dev/null +++ b/tests/Schema/_testDirectives/schema.yml @@ -0,0 +1,14 @@ +models: + SilverStripe\GraphQL\Tests\Fake\DataObjectFake: + operations: + readOne: true +directives: + testQueryDirective: + description: 'test description' + args: + if: + type: Boolean + defaultValue: true + locations: [QUERY] + testFieldDirective: + locations: [FIELD]