From 2d574a922221bb575f751276ea5e2097b0ab27ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=2C=20Ren=C3=A9=20Pardon?= Date: Thu, 21 Nov 2019 15:56:17 +0100 Subject: [PATCH] feat (FEDERATION) #911: Initial idea of how we could implement the federation support into lighthouse --- config/config.php | 14 ++ src/Exceptions/FederationException.php | 10 ++ .../Directives/ExtendsDirective.php | 40 +++++ .../Directives/ExternalDirective.php | 35 ++++ src/Federation/Directives/KeyDirective.php | 41 +++++ .../Directives/ProvidesDirective.php | 49 ++++++ .../Directives/RequiresDirective.php | 47 ++++++ src/Federation/FederationServiceProvider.php | 154 ++++++++++++++++++ .../FederationGatewayServiceProvider.php | 10 ++ src/Federation/Resolvers/Entity.php | 15 ++ src/Federation/Resolvers/Service.php | 65 ++++++++ src/Federation/Schema/Types/Scalars/Any.php | 30 ++++ .../Schema/Types/Scalars/FieldSet.php | 30 ++++ .../Federation/FederationSchemaTest.php | 52 ++++++ tests/Unit/Federation/SchemaBuilderTest.php | 100 ++++++++++++ 15 files changed, 692 insertions(+) create mode 100644 src/Exceptions/FederationException.php create mode 100644 src/Federation/Directives/ExtendsDirective.php create mode 100644 src/Federation/Directives/ExternalDirective.php create mode 100644 src/Federation/Directives/KeyDirective.php create mode 100644 src/Federation/Directives/ProvidesDirective.php create mode 100644 src/Federation/Directives/RequiresDirective.php create mode 100644 src/Federation/FederationServiceProvider.php create mode 100644 src/Federation/Gateway/FederationGatewayServiceProvider.php create mode 100644 src/Federation/Resolvers/Entity.php create mode 100644 src/Federation/Resolvers/Service.php create mode 100644 src/Federation/Schema/Types/Scalars/Any.php create mode 100644 src/Federation/Schema/Types/Scalars/FieldSet.php create mode 100644 tests/Integration/Federation/FederationSchemaTest.php create mode 100644 tests/Unit/Federation/SchemaBuilderTest.php diff --git a/config/config.php b/config/config.php index 82f17c2f95..cf8c703504 100644 --- a/config/config.php +++ b/config/config.php @@ -201,6 +201,20 @@ 'transactional_mutations' => true, + /* + |-------------------------------------------------------------------------- + | Apollo federation support + |-------------------------------------------------------------------------- + | + | Possible types are "service" and "gateway". Defaults to + | "service" because you may implement more services than gateways ;) + | + */ + + 'federation' => [ + 'type' => 'service', + ], + /* |-------------------------------------------------------------------------- | GraphQL Subscriptions diff --git a/src/Exceptions/FederationException.php b/src/Exceptions/FederationException.php new file mode 100644 index 0000000000..de28c21a96 --- /dev/null +++ b/src/Exceptions/FederationException.php @@ -0,0 +1,10 @@ +listen( + ManipulateAST::class, + [$this, 'addFederationAdjustments'] + ); + + $dispatcher->listen( + RegisterDirectiveNamespaces::class, + function (RegisterDirectiveNamespaces $registerDirectiveNamespaces): string { + return __NAMESPACE__ . '\\Directives'; + } + ); + } + + /** + * @param \Nuwave\Lighthouse\Events\ManipulateAST $manipulateAST + * + * @return void + */ + public function addFederationAdjustments(ManipulateAST $manipulateAST): void + { + if (config('lighthouse.federation.type') === 'service') { + + $this->addDirectives($manipulateAST); + $this->addScalars($manipulateAST); + $this->addEntityUnion($manipulateAST); + + /** @var \GraphQL\Language\AST\ObjectTypeDefinitionNode $queryType */ + $queryType = $manipulateAST->documentAST->types['Query']; + + $queryType->fields = ASTHelper::mergeNodeList( + $queryType->fields, + [ + PartialParser::fieldDefinition('_entities(representations: [_Any!]!): [_Entity]! @field(resolver: "Nuwave\\\Lighthouse\\\Federation\\\Resolvers\\\Entity@resolve")'), + PartialParser::fieldDefinition('_service: _Service! @field(resolver: "Nuwave\\\Lighthouse\\\Federation\\\Resolvers\\\Service@resolveSdl")'), + ] + ); + + $manipulateAST->documentAST->setTypeDefinition( + PartialParser::objectTypeDefinition(' + type _Service { + sdl: String + } + ') + ); + } + + // TODO add gateway support + } + + /** + * Add federation specific directives to the AST + * + * @param ManipulateAST $manipulateAST + */ + protected function addDirectives(ManipulateAST &$manipulateAST): void + { + $manipulateAST->documentAST->setDirectiveDefinition(PartialParser::directiveDefinition(ExternalDirective::definition())); + $manipulateAST->documentAST->setDirectiveDefinition(PartialParser::directiveDefinition(RequiresDirective::definition())); + $manipulateAST->documentAST->setDirectiveDefinition(PartialParser::directiveDefinition(ProvidesDirective::definition())); + $manipulateAST->documentAST->setDirectiveDefinition(PartialParser::directiveDefinition(KeyDirective::definition())); + $manipulateAST->documentAST->setDirectiveDefinition(PartialParser::directiveDefinition(ExtendsDirective::definition())); + } + + /** + * Add federation specific scalars to the AST + * + * @param ManipulateAST $manipulateAST + * + * @return void + */ + protected function addScalars(ManipulateAST &$manipulateAST): void + { + $manipulateAST->documentAST->setTypeDefinition( + PartialParser::scalarTypeDefinition( + 'scalar _Any @scalar(class: "Nuwave\\\Lighthouse\\\Federation\\\Schema\\\Types\\\Scalars\\\Any")' + ) + ); + + // TODO check if required or if we could also use `String!` instead of the _FieldSet scalar. Apollo federation demo uses String! + $manipulateAST->documentAST->setTypeDefinition( + PartialParser::scalarTypeDefinition( + 'scalar _FieldSet @scalar(class: "Nuwave\\\Lighthouse\\\Federation\\\Schema\\\Types\\\Scalars\\\FieldSet")' + ) + ); + } + + /** + * Retrieve all object types from AST which uses the @key directive (no matter if native or extended type) and + * combine those types into the _Entity union + * + * @param ManipulateAST $manipulateAST + * + * @throws FederationException + */ + protected function addEntityUnion(ManipulateAST &$manipulateAST): void + { + $entities = []; + + // We just care about object types ... but we don't care about global object types ... and we just want the + // types which make use of the @key directive + foreach ($manipulateAST->documentAST->types as $type) { + if (!($type instanceof ObjectTypeDefinitionNode) + || in_array($type->name->value, ['Query', 'Mutation', 'Subscription']) + || (count($type->directives) === 0)) { + continue; + } + + /** @var \GraphQL\Language\AST\DirectiveNode $directive */ + foreach ($type->directives as $directive) { + if ($directive->name->value === 'key') { + $entities[] = $type->name->value; + break; + } + } + } + + if (count($entities) === 0) { + throw new FederationException('There must be at least one type defining the @key directive'); + } + + $manipulateAST->documentAST->setTypeDefinition( + PartialParser::unionTypeDefinition(sprintf('union _Entity = %s', implode(' | ', $entities))) + ); + } +} diff --git a/src/Federation/Gateway/FederationGatewayServiceProvider.php b/src/Federation/Gateway/FederationGatewayServiceProvider.php new file mode 100644 index 0000000000..dccce86409 --- /dev/null +++ b/src/Federation/Gateway/FederationGatewayServiceProvider.php @@ -0,0 +1,10 @@ +schema; + + $queryFields = []; + foreach ($schema->getQueryType()->getFields() as $field) { + if (!in_array($field->name, static::FEDERATION_QUERY_FIELDS)) { + $queryFields[] = $field; + } + } + + $directives = []; + foreach ($schema->getDirectives() as $directive) { + if (!in_array($directive->name, static::FEDERATION_DIRECTIVES)) { + $directives[] = $directive; + } + } + + $types = []; + foreach ($schema->getTypeMap() as $name => $type) { + if ($type instanceof ObjectType) { + $types[] = $type; + } + } + + // $schemaConfig = SchemaConfig::create(); + // $schemaConfig->setQuery($queryFields); + /*$newSchema = new Schema([ + 'query' => $schemaConfig, + 'mutation' => $schema->getMutationType(), + 'subscription' => $schema->getSubscriptionType(), + 'types' => $types, + 'directives' => $directives, + 'typeLoader' => $schema->getConfig()->getTypeLoader(), + ]);*/ + + // TODO the new schema should be printed including the inline (federation) directives required for federation to work. We may need to create our own schema printer for this. + return ['sdl' => SchemaPrinter::doPrint($schema)]; + } +} diff --git a/src/Federation/Schema/Types/Scalars/Any.php b/src/Federation/Schema/Types/Scalars/Any.php new file mode 100644 index 0000000000..f4be7fa9a6 --- /dev/null +++ b/src/Federation/Schema/Types/Scalars/Any.php @@ -0,0 +1,30 @@ +schema = ' + type Foo @key(fields: "id") { + id: ID! @external + foo: String! + } + type Query { + foo: Int! + } + '; + + $this->graphQL(' + { + _service { sdl } + } + ') + ->assertJson([ + 'data' => [ + '_service' => [ + 'sdl' => $this->schema + ], + ], + ]); + } + + public function testFederatedSchemaShouldContainCorrectEntityUnion() + { + // TODO introspect the schema and validate that the _Entity union contains all the types which we defined in the + // schema within this test case + } +} diff --git a/tests/Unit/Federation/SchemaBuilderTest.php b/tests/Unit/Federation/SchemaBuilderTest.php new file mode 100644 index 0000000000..beac363948 --- /dev/null +++ b/tests/Unit/Federation/SchemaBuilderTest.php @@ -0,0 +1,100 @@ +buildSchema(/* @lang GraphQL */ <<<'GQL' +type Foo @key(fields: "id") { + id: ID! + foo: String! +} +type Query { + foo: Int +} +GQL +); + } + + public function testGeneratesValidSchema(): void + { + $schema = $this->buildSchemaWithPlaceholderQuery(' + type Test @key(fields: "foo") { + foo: String! + } + '); + + $this->assertInstanceOf(Schema::class, $schema); + // This would throw if the schema were invalid + $schema->assertValid(); + } + + /** + * At least one type needs to be defined with the @key directive! + * + * We could also just don't add the type definition below if no entities match. So the user is responsible + * by himself to ad the _Entity union. In this case GraphQL itself will throw an exception if the union is missing. + */ + public function testExpectsFederationException(): void + { + $this->expectException(FederationException::class); + + $this->buildSchemaWithPlaceholderQuery(''); + } + + public function testFederatedSchema(): void + { + $schema = $this->buildSchemaWithPlaceholderTypeAndQuery(''); + + // Check if directives are returned within the schema + $this->assertInstanceOf(Directive::class, $schema->getDirective('extends')); + $this->assertInstanceOf(Directive::class, $schema->getDirective('external')); + $this->assertInstanceOf(Directive::class, $schema->getDirective('key')); + $this->assertInstanceOf(Directive::class, $schema->getDirective('provides')); + $this->assertInstanceOf(Directive::class, $schema->getDirective('requires')); + + // Check for required federation types + $this->assertTrue($schema->hasType('_Entity')); + $this->assertTrue($schema->hasType('_Service')); + + // Check for existence of scalars + $this->assertTrue($schema->hasType('_Any')); + $this->assertTrue($schema->hasType('_FieldSet')); + + // Query type should contain federation specific fields + $this->assertTrue($schema->getQueryType()->hasField('_entities')); + $this->assertTrue($schema->getQueryType()->hasField('_service')); + + $entityDef = $schema->getQueryType()->getField('_entities'); + $this->assertEquals(1, count($entityDef->args)); + $this->assertInstanceOf(FieldArgument::class, $entityDef->getArg('representations')); + + $serviceDef = $schema->getQueryType()->getField('_service'); + $this->assertEmpty(count($serviceDef->args)); + } +}