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/): 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..9e6a06283 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/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 diff --git a/lib/apollo-federation/schema.rb b/lib/apollo-federation/schema.rb index ba7e6d24e..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: Interfaces can have a key... - type.include?(ApolloFederation::Object) && - 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 new file mode 100644 index 000000000..1effcc856 --- /dev/null +++ b/spec/apollo-federation/entities_field_interfaces_spec.rb @@ -0,0 +1,538 @@ +# 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 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 + 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