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
73 changes: 71 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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/):
Expand Down
17 changes: 10 additions & 7 deletions lib/apollo-federation/entities_field.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: why did we remove this TODO? I actually am inclined to leave it because we probably do want to raise a specific error here as opposed to just RuntimeError

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
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions lib/apollo-federation/entity.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +13 to +16
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
# 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.
# The main issue here is the fact that a 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 a 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I don't think this comment is that helpful as it re-iterates the conditional above 🤷🏼‍♀️

else
super(type_defn)
end
end
Comment on lines +13 to +24
Copy link
Contributor

@sethc2 sethc2 Oct 30, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I totally get why we would do this but I was curious if you think we could tweak the implementation to avoid creating a union that is technically invalid according to the GraphQL spec.

I was thinking we keep the requirement that all entity interface implementations are entities, and continue to check that when getting the list of possible types for the entities field, but then drop it from the final list of possible types and then in the entities resolver we use the resolve_type method on an interface and just force that resolution to happen.

Thinking something like this, though I haven't tested to see how well this plays with GraphQL ruby's after_lazy.

      maybe_lazies = grouped_references_with_indices.map do |typename, references_with_indices|
        references = references_with_indices.map(&:first)
        indices = references_with_indices.map(&:last)

        # TODO: Use warden or schema?
        type = context.warden.get_type(typename)
        # pretend we have a has_key_directive method to check if a thing has a key directive on it
        if type.nil? || !(type.kind == GraphQL::TypeKinds::OBJECT || (type.kind == GraphQL::TypeKinds::Interface && has_key_directive(type)))
          raise "The _entities resolver tried to load an entity for type \"#{typename}\"," \
                ' but no entity type of that name was found in the schema'
        end
        
        # ... EXISTING CODE calling resolve reference(s) ...
        
           final_result[i] = context.schema.after_lazy(result) do |resolved_value|
              # force the type to the concrete type
              type_to_set = if type.kind == GraphQL::TypeKinds::Interface
                type_class.resolve_type(resolved_value, context)
              else
                type
              context[resolved_value] = type_to_set
              resolved_value
            end

Do you think something like that would work?

end
end
12 changes: 12 additions & 0 deletions lib/apollo-federation/interface.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
62 changes: 56 additions & 6 deletions lib/apollo-federation/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Loading