Skip to content

Commit

Permalink
export supergraph as SDL.
Browse files Browse the repository at this point in the history
  • Loading branch information
gmac committed Dec 2, 2023
1 parent 9f08a00 commit 2aaa35f
Show file tree
Hide file tree
Showing 11 changed files with 255 additions and 154 deletions.
18 changes: 7 additions & 11 deletions docs/supergraph.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,25 @@ A `Supergraph` is the singuar representation of a stitched graph. `Supergraph` i

### Export and caching

A Supergraph is designed to be composed, cached, and restored. Calling the `export` method will return an SDL (Schema Definition Language) print of the combined graph schema and a delegation mapping hash. These can be persisted in any raw format that suits your stack:
A Supergraph is designed to be composed, cached, and restored. Calling `to_definition` will return an SDL (Schema Definition Language) print of the combined graph schema with delegation mapping directives. This pre-composed schema can be persisted in any raw format that suits your stack:

```ruby
supergraph_sdl, delegation_map = supergraph.export
supergraph_sdl = supergraph.to_definition

# stash these resources in Redis...
# stash this composed schema in a cache...
$redis.set("cached_supergraph_sdl", supergraph_sdl)
$redis.set("cached_delegation_map", JSON.generate(delegation_map))

# or, write the resources as files and commit them to your repo...
# or, write the composed schema as a file into your repo...
File.write("supergraph/schema.graphql", supergraph_sdl)
File.write("supergraph/delegation_map.json", JSON.generate(delegation_map))
```

To restore a Supergraph, call `from_export` proving the cached SDL string, the parsed JSON delegation mapping, and a hash of executables keyed by their location names:
To restore a Supergraph, call `from_definition` providing the cached SDL string and a hash of executables keyed by their location names:

```ruby
supergraph_sdl = $redis.get("cached_supergraph_sdl")
delegation_map = JSON.parse($redis.get("cached_delegation_map"))

supergraph = GraphQL::Stitching::Supergraph.from_export(
schema: supergraph_sdl,
delegation_map: delegation_map,
supergraph = GraphQL::Stitching::Supergraph.from_definition(
supergraph_sdl,
executables: {
my_remote: GraphQL::Stitching::HttpExecutable.new(url: "http://localhost:3000"),
my_local: MyLocalSchema,
Expand Down
2 changes: 1 addition & 1 deletion lib/graphql/stitching/composer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -541,7 +541,7 @@ def extract_boundaries(type_name, types_by_location)
field: field_candidate.name,
arg: argument_name,
list: boundary_structure.first.list?,
federation: kwargs[:federation],
federation: kwargs[:federation] || false,
)
end
end
Expand Down
2 changes: 1 addition & 1 deletion lib/graphql/stitching/planner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
module GraphQL
module Stitching
class Planner
SUPERGRAPH_LOCATIONS = [Supergraph::LOCATION].freeze
SUPERGRAPH_LOCATIONS = [Supergraph::SUPERGRAPH_LOCATION].freeze
TYPENAME = "__typename"
QUERY_OP = "query"
MUTATION_OP = "mutation"
Expand Down
164 changes: 132 additions & 32 deletions lib/graphql/stitching/supergraph.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,89 @@
module GraphQL
module Stitching
class Supergraph
LOCATION = "__super"
SUPERGRAPH_LOCATION = "__super"

class BoundaryDirective < GraphQL::Schema::Directive
graphql_name "boundary"
locations OBJECT, INTERFACE, UNION
argument :location, String, required: true
argument :key, String, required: true
argument :field, String, required: true
argument :arg, String, required: true
argument :list, Boolean, required: false
argument :federation, Boolean, required: false
repeatable true
end

def self.validate_executable!(location, executable)
return true if executable.is_a?(Class) && executable <= GraphQL::Schema
return true if executable && executable.respond_to?(:call)
raise StitchingError, "Invalid executable provided for location `#{location}`."
class SourceDirective < GraphQL::Schema::Directive
graphql_name "source"
locations FIELD_DEFINITION
argument :location, String, required: true
repeatable true
end

def self.from_export(schema:, delegation_map:, executables:)
schema = GraphQL::Schema.from_definition(schema) if schema.is_a?(String)
class << self
def validate_executable!(location, executable)
return true if executable.is_a?(Class) && executable <= GraphQL::Schema
return true if executable && executable.respond_to?(:call)
raise StitchingError, "Invalid executable provided for location `#{location}`."
end

def from_definition(schema, executables:)
schema = GraphQL::Schema.from_definition(schema) if schema.is_a?(String)
field_map = {}
boundary_map = {}
possible_locations = {}
introspection_types = schema.introspection_system.types.keys

schema.types.each do |type_name, type|
next if introspection_types.include?(type_name)

type.directives.each do |directive|
next unless directive.graphql_name == BoundaryDirective.graphql_name

kwargs = directive.arguments.keyword_arguments
boundary_map[type_name] ||= []
boundary_map[type_name] << Boundary.new(
type_name: type_name,
location: kwargs[:location],
key: kwargs[:key],
field: kwargs[:field],
arg: kwargs[:arg],
list: kwargs[:list] || false,
federation: kwargs[:federation] || false,
)
end

next unless type.kind.fields?

executables = delegation_map["locations"].each_with_object({}) do |location, memo|
executable = executables[location] || executables[location.to_sym]
if validate_executable!(location, executable)
memo[location] = executable
type.fields.each do |field_name, field|
field.directives.each do |d|
next unless d.graphql_name == SourceDirective.graphql_name

location = d.arguments.keyword_arguments[:location]
field_map[type_name] ||= {}
field_map[type_name][field_name] ||= []
field_map[type_name][field_name] << location
possible_locations[location] = true
end
end
end
end

boundaries = delegation_map["boundaries"].map do |k, b|
[k, b.map { Boundary.new(**_1) }]
end
executables = possible_locations.keys.each_with_object({}) do |location, memo|
executable = executables[location] || executables[location.to_sym]
if validate_executable!(location, executable)
memo[location] = executable
end
end

new(
schema: schema,
fields: delegation_map["fields"],
boundaries: boundaries.to_h,
executables: executables,
)
new(
schema: schema,
fields: field_map,
boundaries: boundary_map,
executables: executables,
)
end
end

attr_reader :schema, :boundaries, :locations_by_type_and_field, :executables
Expand All @@ -48,32 +103,77 @@ def initialize(schema:, fields:, boundaries:, executables:)
next unless type.kind.fields?

memo[type_name] = type.fields.keys.each_with_object({}) do |field_name, m|
m[field_name] = [LOCATION]
m[field_name] = [SUPERGRAPH_LOCATION]
end
end.freeze

# validate and normalize executable references
@executables = executables.each_with_object({ LOCATION => @schema }) do |(location, executable), memo|
@executables = executables.each_with_object({ SUPERGRAPH_LOCATION => @schema }) do |(location, executable), memo|
if self.class.validate_executable!(location, executable)
memo[location.to_s] = executable
end
end.freeze
end

def to_definition
if @schema.directives[BoundaryDirective.graphql_name].nil?
@schema.directive(BoundaryDirective)
end
if @schema.directives[SourceDirective.graphql_name].nil?
@schema.directive(SourceDirective)
end

@schema.types.each do |type_name, type|
if boundaries_for_type = @boundaries.dig(type_name)
boundaries_for_type.each do |boundary|
existing = type.directives.find do |d|
kwargs = d.arguments.keyword_arguments
d.graphql_name == BoundaryDirective.graphql_name &&
kwargs[:location] == boundary.location &&
kwargs[:key] == boundary.key &&
kwargs[:field] == boundary.field &&
kwargs[:arg] == boundary.arg &&
kwargs.fetch(:list, false) == boundary.list &&
kwargs.fetch(:federation, false) == boundary.federation
end

type.directive(BoundaryDirective, **{
location: boundary.location,
key: boundary.key,
field: boundary.field,
arg: boundary.arg,
list: boundary.list || nil,
federation: boundary.federation || nil,
}.tap(&:compact!)) if existing.nil?
end
end

next unless type.kind.fields?

type.fields.each do |field_name, field|
locations_for_field = @locations_by_type_and_field.dig(type_name, field_name)
next if locations_for_field.nil?

locations_for_field.each do |location|
existing = field.directives.find do |d|
d.graphql_name == SourceDirective.graphql_name &&
d.arguments.keyword_arguments[:location] == location
end

field.directive(SourceDirective, location: location) if existing.nil?
end
end
end

@schema.to_definition
end

def fields
@locations_by_type_and_field.reject { |k, _v| memoized_introspection_types[k] }
end

def locations
@executables.keys.reject { _1 == LOCATION }
end

def export
return GraphQL::Schema::Printer.print_schema(@schema), {
"locations" => locations,
"fields" => fields,
"boundaries" => @boundaries.map { |k, b| [k, b.map(&:as_json)] }.to_h,
}
@executables.keys.reject { _1 == SUPERGRAPH_LOCATION }
end

def memoized_introspection_types
Expand Down
5 changes: 2 additions & 3 deletions test/graphql/stitching/client_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,8 @@ def test_prepares_requests_before_handling
end

def test_client_builds_with_provided_supergraph
supergraph = GraphQL::Stitching::Supergraph.from_export(
schema: "type Thing { id: String } type Query { thing: Thing }",
delegation_map: { "fields" => {}, "boundaries" => {}, "locations" => ["alpha"] },
supergraph = supergraph_from_schema(
"type Thing { id: String } type Query { thing: Thing }",
executables: {
alpha: Proc.new { true },
}
Expand Down
2 changes: 2 additions & 0 deletions test/graphql/stitching/composer/configuration_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ def test_perform_with_static_boundary_config
key: "id",
arg: "id",
list: false,
federation: false,
),
GraphQL::Stitching::Boundary.new(
location: "bravo",
Expand All @@ -59,6 +60,7 @@ def test_perform_with_static_boundary_config
key: "id",
arg: "key",
list: false,
federation: false,
),
]
}
Expand Down
38 changes: 20 additions & 18 deletions test/graphql/stitching/composer/merge_boundaries_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@

describe 'GraphQL::Stitching::Composer, merging boundary queries' do
def test_creates_boundary_map
a = %{type Test { id: ID!, a: String } type Query { a(id: ID!):Test @stitch(key: "id") }}
b = %{type Test { id: ID!, b: String } type Query { b(ids: [ID!]!):[Test]! @stitch(key: "id") }}
a = %|type Test { id: ID!, a: String } type Query { a(id: ID!):Test @stitch(key: "id") }|
b = %|type Test { id: ID!, b: String } type Query { b(ids: [ID!]!):[Test]! @stitch(key: "id") }|
supergraph = compose_definitions({ "a" => a, "b" => b })

expected_boundaries_map = {
Expand All @@ -16,6 +16,7 @@ def test_creates_boundary_map
field: "a",
arg: "id",
list: false,
federation: false,
type_name: "Test"
),
GraphQL::Stitching::Boundary.new(
Expand All @@ -24,6 +25,7 @@ def test_creates_boundary_map
field: "b",
arg: "ids",
list: true,
federation: false,
type_name: "Test"
),
],
Expand All @@ -36,18 +38,18 @@ def test_merges_boundaries_with_multiple_keys
# repeatable directives don't work before v2.0.15
skip unless minimum_graphql_version?("2.0.15")

a = %{
a = %|
type T { upc:ID! }
type Query { a(upc:ID!):T @stitch(key: "upc") }
}
b = %{
|
b = %|
type T { id:ID! upc:ID! }
type Query { b(id: ID, upc:ID):T @stitch(key: "id:id") @stitch(key: "upc:upc") }
}
c = %{
|
c = %|
type T { id:ID! }
type Query { c(id:ID!):T @stitch(key: "id") }
}
|

supergraph = compose_definitions({ "a" => a, "b" => b, "c" => c })

Expand All @@ -58,21 +60,21 @@ def test_merges_boundaries_with_multiple_keys
end

def test_expands_interface_boundary_accessors_to_relevant_types
a = %{
a = %|
interface Fruit { id:ID! }
type Apple implements Fruit { id:ID! name:String }
type Banana implements Fruit { id:ID! name:String }
type Coconut implements Fruit { id:ID! name:String }
type Query { fruit(id:ID!):Fruit @stitch(key: "id") }
}
b = %{
|
b = %|
type Apple { id:ID! color:String }
type Banana { id:ID! color:String }
type Query {
a(id:ID!):Apple @stitch(key: "id")
b(id:ID!):Banana @stitch(key: "id")
}
}
|

supergraph = compose_definitions({ "a" => a, "b" => b })

Expand All @@ -89,23 +91,23 @@ def test_expands_interface_boundary_accessors_to_relevant_types
end

def test_expands_union_boundary_accessors_to_relevant_types
a = %{
a = %|
type Apple { id:ID! name:String }
type Banana { id:ID! name:String }
union Fruit = Apple | Banana
union Fruit = Apple \| Banana
type Query {
fruit(id:ID!):Fruit @stitch(key: "id")
}
}
b = %{
|
b = %|
type Apple { id:ID! color:String }
type Coconut { id:ID! name:String }
union Fruit = Apple | Coconut
union Fruit = Apple \| Coconut
type Query {
a(id:ID!):Apple @stitch(key: "id")
c(id:ID!):Coconut
}
}
|

supergraph = compose_definitions({ "a" => a, "b" => b })
assert_equal 1, supergraph.boundaries["Fruit"].length
Expand Down
Loading

0 comments on commit 2aaa35f

Please sign in to comment.