From 1dc3562a1369aa82091980c46fa024f86ea76902 Mon Sep 17 00:00:00 2001 From: Dan Lepadatu Date: Fri, 28 Apr 2023 14:05:13 +0200 Subject: [PATCH 1/4] feat: adds support for Interface Entities Context: In Federation v2.3 there was added support for interfaces to be Entities. There are two ways to consume this new feature. 1. Extend an Interface Entity with the `@interfaceObject` directive, when the Entity is defined by a different subgraph. 2. Implement the Interface Entity with its implementing Type Entitites. There was previos work into getting the first part covered in this gem, but we were still lacking support for the 2nd part. This work address that. The main issue here is the fact that an `Union` in `GraphQL` can't be an `Interface` according to the [spec](https://spec.graphql.org/October2021/#sec-Unions.Type-Validation), but at the same time, according to the Apollo Federation [spec](https://www.apollographql.com/docs/federation/federated-types/interfaces), an interface can be an Entity, and an Entity is an Union. Therefore, we have to extend the validation (`assert_valid_union_member`) for the Entity union to allow `Interfaces` (more exactly `Modules`) as possible types. --- lib/apollo-federation/entities_field.rb | 17 +- lib/apollo-federation/entity.rb | 13 + lib/apollo-federation/schema.rb | 4 +- .../entities_field_interfaces_spec.rb | 505 ++++++++++++++++++ spec/apollo-federation/entities_field_spec.rb | 3 +- spec/apollo-federation/spec_types.rb | 154 ++++++ 6 files changed, 686 insertions(+), 10 deletions(-) create mode 100644 spec/apollo-federation/entities_field_interfaces_spec.rb create mode 100644 spec/apollo-federation/spec_types.rb diff --git a/lib/apollo-federation/entities_field.rb b/lib/apollo-federation/entities_field.rb index b9825dd19..07d5fc787 100644 --- a/lib/apollo-federation/entities_field.rb +++ b/lib/apollo-federation/entities_field.rb @@ -40,13 +40,8 @@ def _entities(representations:) # TODO: Use warden or schema? type = context.warden.get_type(typename) - if type.nil? || type.kind != GraphQL::TypeKinds::OBJECT - # TODO: Raise a specific error class? - raise "The _entities resolver tried to load an entity for type \"#{typename}\"," \ - ' but no object type of that name was found in the schema' - end + check_type_existence!(type, typename) - # TODO: What if the type is an interface? type_class = class_of_type(type) if type_class.underscore_reference_keys @@ -88,8 +83,16 @@ def _entities(representations:) private + def check_type_existence!(type, typename) + return unless type.nil? || (type.kind != GraphQL::TypeKinds::OBJECT && type.kind != GraphQL::TypeKinds::INTERFACE) + + raise "The _entities resolver tried to load an entity for type \"#{typename}\"," \ + ' but no object type of that name was found in the schema' + end + def class_of_type(type) - if defined?(GraphQL::ObjectType) && type.is_a?(GraphQL::ObjectType) + if (defined?(GraphQL::ObjectType) && type.is_a?(GraphQL::ObjectType)) || + (defined?(GraphQL::InterfaceType) && type.is_a?(GraphQL::InterfaceType)) type.metadata[:type_class] else type diff --git a/lib/apollo-federation/entity.rb b/lib/apollo-federation/entity.rb index d065770fe..ece155505 100644 --- a/lib/apollo-federation/entity.rb +++ b/lib/apollo-federation/entity.rb @@ -9,5 +9,18 @@ class Entity < GraphQL::Schema::Union def self.resolve_type(object, context) context[object] end + + # The main issue here is the fact that an union in GraphQL can't be an interface according + # to the [spec](https://spec.graphql.org/October2021/#sec-Unions.Type-Validation), but at + # the same time, according to the Federation spec, an interface can be an Entity, and an Entity + # is an union. Therefore, we have to extend this validation to allow interfaces as possible types. + def self.assert_valid_union_member(type_defn) + if type_defn.is_a?(Module) && + type_defn.included_modules.include?(ApolloFederation::Interface) + # It's an interface entity, defined as a module + else + super(type_defn) + end + end end end diff --git a/lib/apollo-federation/schema.rb b/lib/apollo-federation/schema.rb index ba7e6d24e..2ee6fc57e 100644 --- a/lib/apollo-federation/schema.rb +++ b/lib/apollo-federation/schema.rb @@ -82,8 +82,8 @@ def schema_entities # Walk through all of the types and determine which ones are entities (any type with a # "key" directive) types_schema.send(:non_introspection_types).values.flatten.select do |type| - # TODO: Interfaces can have a key... - type.include?(ApolloFederation::Object) && + # TODO: Find Objects that implement interfaces that are entities. Make sure they are also entities. + (type.include?(ApolloFederation::Object) || type.include?(ApolloFederation::Interface)) && type.federation_directives&.any? { |directive| directive[:name] == 'key' } end end diff --git a/spec/apollo-federation/entities_field_interfaces_spec.rb b/spec/apollo-federation/entities_field_interfaces_spec.rb new file mode 100644 index 000000000..f1d79cc7c --- /dev/null +++ b/spec/apollo-federation/entities_field_interfaces_spec.rb @@ -0,0 +1,505 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'graphql' +require 'apollo-federation/schema' +require 'apollo-federation/field' +require 'apollo-federation/object' +require 'apollo-federation/interface' + +require_relative './spec_types' + +RSpec.describe ApolloFederation::EntitiesField do + shared_examples 'entities field' do + context 'when an interface with the key directive doesn\'t exist' do + it 'does not add the _entities field' do + schema = Class.new(base_schema) do + end + + expect(schema.to_definition).to match_sdl( + <<~GRAPHQL, + type Query { + _service: _Service! + } + + """ + The sdl representing the federated service capabilities. Includes federation + directives, removes federation types, and includes rest of full schema after + schema directives have been applied + """ + type _Service { + sdl: String + } + GRAPHQL + ) + end + end + + context 'when an interface with the key directive exists' do + context 'when a Query object is provided' do + let(:query) do + user_class = SpecTypes::User + Class.new(SpecTypes::BaseObject) do + graphql_name 'Query' + field :user, user_class, null: true + end + end + + let(:schema) do + query_class = query + Class.new(base_schema) do + query query_class + orphan_types SpecTypes::AdminType, SpecTypes::EndUserType + def self.resolve_type(_abstract_type, _obj, _ctx) + raise(GraphQL::RequiredImplementationMissingError) + end + end + end + + it 'sets the Query as the owner to the _entities field' do + expect( + schema.query + .fields['_entities'] + .owner.graphql_name, + ).to eq('Query') + end + + it 'adds an _entities field to the Query object' do + expect(schema.to_definition).to match_sdl( + <<~GRAPHQL, + type Admin implements User { + email: String + id: ID! + } + + type EndUser implements User { + email: String + id: ID! + } + + type Query { + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! + user: User + } + + interface User { + email: String + id: ID! + } + + scalar _Any + + union _Entity = Admin | EndUser | User + + """ + The sdl representing the federated service capabilities. Includes federation + directives, removes federation types, and includes rest of full schema after + schema directives have been applied + """ + type _Service { + sdl: String + } + GRAPHQL + ) + end + end + + context 'when a Query object is not provided' do + let(:mutation) do + # creating a mutation with the User object so it gets included in the schema + user_class = SpecTypes::User + Class.new(SpecTypes::BaseObject) do + graphql_name 'Mutation' + field :user, user_class, null: true + end + end + + let(:schema) do + mutation_class = mutation + Class.new(base_schema) do + orphan_types SpecTypes::AdminType, SpecTypes::EndUserType + mutation mutation_class + + def self.resolve_type(_abstract_type, _obj, _ctx) + raise(GraphQL::RequiredImplementationMissingError) + end + end + end + + it 'creates a Query object and adds an _entities field to it' do + s = schema + expect(s.to_definition).to match_sdl( + <<~GRAPHQL, + type Admin implements User { + email: String + id: ID! + } + + type EndUser implements User { + email: String + id: ID! + } + + type Mutation { + user: User + } + + type Query { + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! + } + + interface User { + email: String + id: ID! + } + + scalar _Any + + union _Entity = Admin | EndUser | User + + """ + The sdl representing the federated service capabilities. Includes federation + directives, removes federation types, and includes rest of full schema after + schema directives have been applied + """ + type _Service { + sdl: String + } + GRAPHQL + ) + end + + describe 'resolver for _entities' do + subject(:entities_result) { execute_query['data']['_entities'] } + + let(:query) do + <<~GRAPHQL + query InterfaceEntityQuery($representations: [_Any!]!) { + _entities(representations: $representations) { + ... on User { + id + email + } + } + } + GRAPHQL + end + + let(:execute_query) do + schema.execute(query, variables: { representations: representations }) + end + let(:errors) { execute_query['errors'] } + + context 'when representations is empty' do + let(:representations) { [] } + + it { is_expected.to match_array [] } + it { expect(errors).to be_nil } + end + + context 'when representations is not empty' do + let(:representations) { [{ __typename: typename, id: id }] } + let(:id) { '10' } + + context 'when typename corresponds to an interface that does not exist in the schema' do + let(:typename) { 'TypeNotInSchema' } + + it 'raises' do + expect { execute_query } + .to raise_error(/The _entities resolver tried to load an entity for type "TypeNotInSchema"/) + end + end + + context 'when typename corresponds to an interface that exists in the schema' do + let(:typename) { SpecTypes::User.graphql_name } + + # Because the Entity is an interface, not having `resolve_references` implemented + # means that we can't know what type to return, so the `resolve_type` on the interface will be called. + # In our test example, we are throwing an error in the `resolve_type` method, so we expect an error. + # In reality, the expected result might differ, depending on the implementation of `resolve_type`. + context 'when the interface does not define a resolve_reference method' do + it 'raises' do + expect { execute_query }.to raise_error(GraphQL::RequiredImplementationMissingError) + end + end + + context 'when we define reference resolvers' do + context 'when we resolve interface entity references' do + let(:typename) { SpecTypes::Product.graphql_name } + let(:query) do + <<~GRAPHQL + query EntitiesQuery($representations: [_Any!]!) { + _entities(representations: $representations) { + ... on Product { + __typename + id + title + } + } + } + GRAPHQL + end + + context 'when the interface defines a resolve_references method' do + # Because we can't add methods to Modules the same we do with classes, + # (as in, we can't create subclasses of a Module), we need to add the method + # to the singleton metrods of the Module and then remove it after the test. + before do + resolve_method_pointer = resolve_method + SpecTypes::Product.define_singleton_method :resolve_references, &resolve_method_pointer + end + + after do + SpecTypes::Product.singleton_class.remove_method :resolve_references + end + + let(:resolve_method) do + lambda do |references, _context| + ids = references.map { |reference| reference[:id] } + products = SpecTypes::PRODUCTS.select { |product| ids.include?(product[:id]) } + + products.map do |product| + if product[:type] == 'Book' + SpecTypes::Book.new(product) + elsif product[:type] == 'Movie' + SpecTypes::Movie.new(product) + end + end + end + end + + let(:mutation) do + product_class = SpecTypes::Product + Class.new(SpecTypes::BaseObject) do + graphql_name 'Mutation' + field :product, product_class, null: true + end + end + + let(:schema) do + mutation_class = mutation + Class.new(base_schema) do + orphan_types SpecTypes::BookType, SpecTypes::MovieType + mutation mutation_class + + def self.resolve_type(_abstract_type, _obj, _ctx) + raise(GraphQL::RequiredImplementationMissingError) + end + end + end + + let(:representations) do + [{ __typename: typename, id: id_1 }, { __typename: typename, id: id_2 }] + end + let(:id_1) { '10' } + let(:id_2) { '30' } + + it { + expect(subject).to match_array [ + { '__typename' => 'Book', 'id' => id_1.to_s, 'title' => 'Dark Matter' }, + { '__typename' => 'Movie', 'id' => id_2.to_s, 'title' => 'The GraphQL Documentary' }, + ] + } + it { expect(errors).to be_nil } + end + + context 'when the interface defines a resolve_reference method' do + let(:mutation) do + product_class = SpecTypes::Product + Class.new(SpecTypes::BaseObject) do + graphql_name 'Mutation' + field :product, product_class, null: true + end + end + + let(:schema) do + mutation_class = mutation + Class.new(base_schema) do + orphan_types SpecTypes::BookType, SpecTypes::MovieType + mutation mutation_class + + def self.resolve_type(_abstract_type, _obj, _ctx) + raise(GraphQL::RequiredImplementationMissingError) + end + end + end + + it { is_expected.to match_array [{ '__typename' => 'Book', 'id' => id, 'title' => 'Dark Matter' }] } + it { expect(errors).to be_nil } + + context 'when resolve_reference returns a lazy object' do + let(:lazy_entity) do + Class.new do + def initialize(callable) + @callable = callable + end + + def load_entity + @callable.call + end + end + end + + let(:resolve_method) do + lazy_entity_class = lazy_entity + + lambda do |reference, _context| + if reference[:id] == '10' + lazy_entity_class.new(-> { SpecTypes::Book.new(SpecTypes::PRODUCTS[0]) }) + end + end + end + + let(:mutation) do + resolve_method_pointer = resolve_method + SpecTypes::Product.define_singleton_method :resolve_reference, &resolve_method_pointer + + product_class = SpecTypes::Product + Class.new(SpecTypes::BaseObject) do + graphql_name 'Mutation' + field :product, product_class, null: true + end + end + + let(:schema) do + lazy_entity_class = lazy_entity + mutation_class = mutation + Class.new(base_schema) do + orphan_types SpecTypes::BookType, SpecTypes::MovieType + lazy_resolve(lazy_entity_class, :load_entity) + mutation mutation_class + + def self.resolve_type(_abstract_type, _obj, _ctx) + raise(GraphQL::RequiredImplementationMissingError) + end + end + end + + it { + expect(subject).to match_array [ + { '__typename' => 'Book', 'id' => id.to_s, 'title' => 'Dark Matter' }, + ] + } + it { expect(errors).to be_nil } + + context 'when lazy object raises an error' do + let(:id1) { '10' } + let(:id2) { '30' } + let(:representations) do + [ + { __typename: typename, id: id1 }, + { __typename: typename, id: id2 }, + ] + end + + let(:resolve_method) do + lazy_entity_class = lazy_entity + + lambda do |reference, _context| + case reference[:id] + when '10' + lazy_entity_class.new(-> { SpecTypes::Book.new(SpecTypes::PRODUCTS[0]) }) + when '30' + lazy_entity_class.new(-> { raise(GraphQL::ExecutionError, 'error') }) + end + end + end + + specify do + expect(execute_query.to_h).to match( + 'data' => { + '_entities' => [ + { '__typename' => 'Book', 'id' => id.to_s, 'title' => 'Dark Matter' }, + nil, + ], + }, + 'errors' => [ + { + 'locations' => [{ 'column' => 3, 'line' => 2 }], + 'message' => 'error', + 'path' => ['_entities', 1], + }, + ], + ) + end + end + end + end + end + + context 'when we resolve implementing types entity references' do + let(:typename) { SpecTypes::BookType.graphql_name } + let(:query) do + <<~GRAPHQL + query EntitiesQuery($representations: [_Any!]!) { + _entities(representations: $representations) { + ... on Book { + __typename + id + title + } + } + } + GRAPHQL + end + + context 'when the type defines a resolve_reference method' do + let(:mutation) do + product_class = SpecTypes::Product + Class.new(SpecTypes::BaseObject) do + graphql_name 'Mutation' + field :product, product_class, null: true + end + end + + let(:schema) do + mutation_class = mutation + Class.new(base_schema) do + orphan_types SpecTypes::BookType, SpecTypes::MovieType + mutation mutation_class + + def self.resolve_type(_abstract_type, _obj, _ctx) + raise(GraphQL::RequiredImplementationMissingError) + end + end + end + + it { is_expected.to match_array [{ '__typename' => 'Book', 'id' => id, 'title' => 'Dark Matter' }] } + it { expect(errors).to be_nil } + end + end + end + end + end + end + end + end + end + + if Gem::Version.new(GraphQL::VERSION) < Gem::Version.new('1.12.0') + context 'with older versions of GraphQL and the interpreter runtime' do + it_behaves_like 'entities field' do + let(:base_schema) do + Class.new(GraphQL::Schema) do + use GraphQL::Execution::Interpreter + use GraphQL::Analysis::AST + + include ApolloFederation::Schema + end + end + end + end + end + + if Gem::Version.new(GraphQL::VERSION) > Gem::Version.new('1.12.0') + it_behaves_like 'entities field' do + let(:base_schema) do + Class.new(GraphQL::Schema) do + include ApolloFederation::Schema + end + end + end + end +end diff --git a/spec/apollo-federation/entities_field_spec.rb b/spec/apollo-federation/entities_field_spec.rb index fdfe8d937..bccf6dc8a 100644 --- a/spec/apollo-federation/entities_field_spec.rb +++ b/spec/apollo-federation/entities_field_spec.rb @@ -5,6 +5,7 @@ require 'apollo-federation/schema' require 'apollo-federation/field' require 'apollo-federation/object' +require 'apollo-federation/interface' RSpec.describe ApolloFederation::EntitiesField do shared_examples 'entities field' do @@ -223,7 +224,7 @@ def self.resolve_type(_abstract_type, _obj, _ctx) let(:typename) { 'TypeNotInSchema' } it 'raises' do - expect(-> { execute_query }).to raise_error( + expect { execute_query }.to raise_error( /The _entities resolver tried to load an entity for type "TypeNotInSchema"/, ) end diff --git a/spec/apollo-federation/spec_types.rb b/spec/apollo-federation/spec_types.rb new file mode 100644 index 000000000..99c2b9167 --- /dev/null +++ b/spec/apollo-federation/spec_types.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +module SpecTypes + PRODUCTS = [ + { + type: 'Book', + id: '10', + title: 'Dark Matter', + pages: 189, + }, + { + type: 'Book', + id: '20', + title: 'Recursion', + pages: 189, + }, + { + type: 'Movie', + id: '30', + title: 'The GraphQL Documentary', + minutes: 120, + }, + { + type: 'Movie', + id: '40', + title: 'Arival', + minutes: 180, + }, + ].freeze + + Book = Struct.new(:id, :type, :title, :pages, keyword_init: true) + Movie = Struct.new(:id, :type, :title, :minutes, keyword_init: true) + + Admin = Struct.new(:id, :type, :email, keyword_init: true) + EndUser = Struct.new(:id, :type, :email, keyword_init: true) + + class BaseField < GraphQL::Schema::Field + include ApolloFederation::Field + end + + class BaseObject < GraphQL::Schema::Object + include ApolloFederation::Object + field_class BaseField + end + + module BaseInterface + include GraphQL::Schema::Interface + include ApolloFederation::Interface + + field_class BaseField + end + + ## Product interface + module Product + include BaseInterface + graphql_name 'Product' + key fields: :id + field :id, ID, null: false + field :title, String, null: true + + definition_methods do + def resolve_type(obj, _ctx) + if obj.is_a?(Book) + BookType + elsif obj.is_a?(Movie) + MovieType + else + raise GraphQL::RequiredImplementationMissingError + end + end + + def resolve_reference(reference, _context) + product = PRODUCTS.find { |p| p[:id] == reference[:id] } + + if product[:type] == 'Book' + Book.new(product) + elsif product[:type] == 'Movie' + Movie.new(product) + end + end + end + end + + class BookType < BaseObject + implements Product + + graphql_name 'Book' + key fields: :id + field :id, ID, null: false + field :title, String, null: true + field :pages, Integer, null: true + + def self.resolve_reference(reference, _context) + book = PRODUCTS.find { |product| product[:id] == reference[:id] } + + Book.new(book) + end + end + + class MovieType < BaseObject + implements Product + + graphql_name 'Movie' + key fields: :id + field :id, ID, null: false + field :title, String, null: true + field :minutes, Integer, null: true + + def self.resolve_reference(reference, _context) + movie = PRODUCTS.find { |product| product[:id] == reference[:id] } + + Movie.new(movie) + end + end + + ## User interface + module User + include BaseInterface + graphql_name 'User' + key fields: :id + field :id, ID, null: false + field :email, String, null: true + + definition_methods do + def resolve_type(obj, _ctx) + if obj.is_a?(Admin) + AdminType + elsif obj.is_a?(EndUser) + EndUserType + else + raise GraphQL::RequiredImplementationMissingError + end + end + end + end + + class AdminType < BaseObject + implements User + + graphql_name 'Admin' + key fields: :id + field :id, ID, null: false + field :email, String, null: true + end + + class EndUserType < BaseObject + implements User + + graphql_name 'EndUser' + key fields: :id + field :id, ID, null: false + field :email, String, null: true + end +end From 3b282f1aa4f51aa97337fa86a951251b1463d034 Mon Sep 17 00:00:00 2001 From: Dan Lepadatu Date: Wed, 6 Sep 2023 13:56:53 +0200 Subject: [PATCH 2/4] fix: adds `underscore_reference_keys` support to interfaces In a previous Pull Request (https://github.com/Gusto/apollo-federation-ruby/pull/248) there was added support for underscoring the keys and while this covered the `Object` class, it didn't also cover the `Interface` module. This commit adds the same `underscore_reference_keys` method to the `Interface` module. --- lib/apollo-federation/interface.rb | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lib/apollo-federation/interface.rb b/lib/apollo-federation/interface.rb index 4a8503bbd..8e6815330 100644 --- a/lib/apollo-federation/interface.rb +++ b/lib/apollo-federation/interface.rb @@ -35,6 +35,18 @@ def key(fields:, camelize: true) ], ) end + + def underscore_reference_keys(value = nil) + if value.nil? + if @underscore_reference_keys.nil? + find_inherited_value(:underscore_reference_keys, false) + else + @underscore_reference_keys + end + else + @underscore_reference_keys = value + end + end end end end From 8d511d6092c9a607938c0ede51c4d133dd95f57d Mon Sep 17 00:00:00 2001 From: Dan Lepadatu Date: Wed, 6 Sep 2023 15:26:31 +0200 Subject: [PATCH 3/4] docs: adds docs for Interface Entities --- README.md | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index dfae06882..23bedfb81 100644 --- a/README.md +++ b/README.md @@ -282,6 +282,20 @@ class Product < BaseObject end ``` +### The `@interfaceObject` directive (Apollo Federation v2.3) + +[Apollo documentation](https://www.apollographql.com/docs/federation/federated-types/federated-directives/#interfaceobject) + +Call `interface_object` within your class definition: + +```ruby +class Product < BaseObject + interface_object + key fields: :id + field :id, ID, null: false +end +``` + ### The `@tag` directive (Apollo Federation v2) [Apollo documentation](https://www.apollographql.com/docs/federation/federated-types/federated-directives/#tag) @@ -327,7 +341,7 @@ Define a `resolve_reference` class method on your object. The method will be pas class User < BaseObject key fields: :user_id field :user_id, ID, null: false - + def self.resolve_reference(reference, context) USERS.find { |user| user[:userId] == reference[:userId] } end @@ -341,7 +355,7 @@ class User < BaseObject key fields: :user_id field :user_id, ID, null: false underscore_reference_keys true - + def self.resolve_reference(reference, context) USERS.find { |user| user[:user_id] == reference[:user_id] } end @@ -358,6 +372,61 @@ class BaseObject < GraphQL::Schema::Object end ``` +### Reference resolvers for Interfaces + +[Apollo documentation](https://www.apollographql.com/docs/federation/federated-types/interfaces/#required-resolvers) + +```ruby + module Product + include BaseInterface + + key fields: :id + field :id, ID, null: false + field :title, String, null: true + + definition_methods do + def resolve_type(obj, _ctx) + if obj.is_a?(Book) + BookType + elsif obj.is_a?(Movie) + MovieType + end + + def resolve_reference(reference, _context) + PRODUCTS.find { |product| product[:id] == reference[:id] } + end + end + end + + class BookType < BaseObject + implements Product + graphql_name 'Book' + + key fields: :id + field :id, ID, null: false + field :title, String, null: true + field :pages, Integer, null: true + + def self.resolve_reference(reference, _context) + BOOKS.find { |book| book[:id] == reference[:id] } + end + end + + class MovieType < BaseObject + implements Product + graphql_name 'Movie' + + key fields: :id + field :id, ID, null: false + field :title, String, null: true + field :minutes, Integer, null: true + + def self.resolve_reference(reference, _context) + MOVIES.find { |movie| movie[:id] == reference[:id] } + end + end +``` + ### Tracing To support [federated tracing](https://www.apollographql.com/docs/apollo-server/federation/metrics/): From adf96d8e0b50462e8ab594424e25eb243e8ddf16 Mon Sep 17 00:00:00 2001 From: David Tapiador Date: Fri, 8 Sep 2023 12:24:34 +0200 Subject: [PATCH 4/4] fix: ensure all interface members are also entities Every entity that implements an interface entity must include all `@keys` from the interface definition. Therefore, we add this extra validation to ensure that. --- lib/apollo-federation/entity.rb | 2 +- lib/apollo-federation/schema.rb | 62 +++++++++++++++++-- .../entities_field_interfaces_spec.rb | 33 ++++++++++ 3 files changed, 90 insertions(+), 7 deletions(-) diff --git a/lib/apollo-federation/entity.rb b/lib/apollo-federation/entity.rb index ece155505..9e6a06283 100644 --- a/lib/apollo-federation/entity.rb +++ b/lib/apollo-federation/entity.rb @@ -16,7 +16,7 @@ def self.resolve_type(object, context) # is an union. Therefore, we have to extend this validation to allow interfaces as possible types. def self.assert_valid_union_member(type_defn) if type_defn.is_a?(Module) && - type_defn.included_modules.include?(ApolloFederation::Interface) + type_defn.included_modules.include?(ApolloFederation::Interface) # It's an interface entity, defined as a module else super(type_defn) diff --git a/lib/apollo-federation/schema.rb b/lib/apollo-federation/schema.rb index 2ee6fc57e..bdb16ce0a 100644 --- a/lib/apollo-federation/schema.rb +++ b/lib/apollo-federation/schema.rb @@ -79,13 +79,59 @@ def schema_entities # infinite recursion types_schema.orphan_types(original_query) - # Walk through all of the types and determine which ones are entities (any type with a - # "key" directive) - types_schema.send(:non_introspection_types).values.flatten.select do |type| - # TODO: Find Objects that implement interfaces that are entities. Make sure they are also entities. - (type.include?(ApolloFederation::Object) || type.include?(ApolloFederation::Interface)) && - type.federation_directives&.any? { |directive| directive[:name] == 'key' } + entities_collection, federation_entities, interface_types_map = collect_entitites(types_schema) + + if federation_entities.any? + entity_names = entities_collection.map(&:graphql_name) + + federation_entities.each do |interface| + members = interface_types_map.fetch(interface.graphql_name, []) + not_entity_members = members.reject { |member| entity_names.include?(member) } + + # If all interface members are entities, it is valid so we add it to the collection + if not_entity_members.empty? + entities_collection << interface + else + raise "Interface #{interface.graphql_name} is not valid. " \ + "Types `#{not_entity_members.join(', ')}` do not have a @key directive. " \ + 'All types that implement an interface with a @key directive must also have a @key directive.' + end + end end + + entities_collection + end + + # Walk through all of the types and interfaces and determine which ones are entities + # (any type with a "key" directive) + # However, for interface entities, don't add them straight away, but first check that + # all implementing types of the interfaces are also entities. + def collect_entitites(types_schema) + federation_entities = [] + interface_types_map = {} + + entities_collection = types_schema.send(:non_introspection_types).values.flatten.select do |type| + # keep track of the interfaces -> type relations. + if type.respond_to?(:implements) + type.implements.each do |interface| + interface_types_map[interface.abstract_type.graphql_name] ||= [] + interface_types_map[interface.abstract_type.graphql_name] << type.graphql_name + end + end + + # Only add Type entities to the collection + # Interface entities will be added later if all implementing types are entities + if type.include?(ApolloFederation::Object) && includes_key_directive?(type) + true + elsif type.include?(ApolloFederation::Interface) && includes_key_directive?(type) + federation_entities << type + false + else + false + end + end + + [entities_collection, federation_entities, interface_types_map] end def federation_query(query_obj) @@ -110,6 +156,10 @@ def federation_query(query_obj) klass.define_service_field klass end + + def includes_key_directive?(type) + type.federation_directives&.any? { |directive| directive[:name] == 'key' } + end end end end diff --git a/spec/apollo-federation/entities_field_interfaces_spec.rb b/spec/apollo-federation/entities_field_interfaces_spec.rb index f1d79cc7c..1effcc856 100644 --- a/spec/apollo-federation/entities_field_interfaces_spec.rb +++ b/spec/apollo-federation/entities_field_interfaces_spec.rb @@ -36,6 +36,39 @@ end context 'when an interface with the key directive exists' do + context "when some of the types implementing the inteface don't have the key directive" do + let(:offending_class) do + end + let(:query) do + user_class = SpecTypes::User + Class.new(SpecTypes::BaseObject) do + graphql_name 'Query' + field :user, user_class, null: true + end + end + + it 'raises an error' do + query_class = query + + offending_class = Class.new(SpecTypes::BaseObject) do + graphql_name 'Manager' + implements SpecTypes::User + + field :id, 'ID', null: false + end + + schema = Class.new(base_schema) do + query query_class + orphan_types SpecTypes::AdminType, offending_class + end + + expect { schema.to_definition }.to raise_error( + 'Interface User is not valid. Types `Manager` do not have a @key directive. ' \ + 'All types that implement an interface with a @key directive must also have a @key directive.', + ) + end + end + context 'when a Query object is provided' do let(:query) do user_class = SpecTypes::User