Skip to content

Commit

Permalink
v1.5.1 (#159)
Browse files Browse the repository at this point in the history
  • Loading branch information
gmac committed Sep 19, 2024
1 parent 7b84cdf commit f7861e5
Show file tree
Hide file tree
Showing 29 changed files with 270 additions and 184 deletions.
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ GraphQL stitching composes a single schema from multiple underlying GraphQL reso
- Shared objects, fields, enums, and inputs across locations.
- Multiple and composite type keys.
- Combining local and remote schemas.
- File uploads via [multipart form spec](https://github.com/jaydenseric/graphql-multipart-request-spec).
- [File uploads](./docs/http_executable.md) via multipart forms.
- Tested with all minor versions of `graphql-ruby`.

**NOT Supported:**
Expand All @@ -35,7 +35,7 @@ require "graphql/stitching"

## Usage

The quickest way to start is to use the provided [`Client`](./docs/client.md) component that wraps a stitched graph in an executable workflow (with optional query plan caching hooks):
The [`Client`](./docs/client.md) component builds a stitched graph wrapped in an executable workflow (with optional query plan caching hooks):

```ruby
movies_schema = <<~GRAPHQL
Expand Down Expand Up @@ -75,7 +75,7 @@ result = client.execute(

Schemas provided in [location settings](./docs/composer.md#performing-composition) may be class-based schemas with local resolvers (locally-executable schemas), or schemas built from SDL strings (schema definition language parsed using `GraphQL::Schema.from_definition`) and mapped to remote locations via [executables](#executables).

While the `Client` constructor is an easy quick start, the library also has several discrete components that can be assembled into custom workflows:
While `Client` is sufficient for most usecases, the library offers several discrete components that can be assembled into tailored workflows:

- [Composer](./docs/composer.md) - merges and validates many schemas into one supergraph.
- [Supergraph](./docs/supergraph.md) - manages the combined schema, location routing maps, and executable resources. Can be exported, cached, and rehydrated.
Expand All @@ -88,7 +88,7 @@ While the `Client` constructor is an easy quick start, the library also has seve

![Merging types](./docs/images/merging.png)

To facilitate this merging of types, stitching must know how to cross-reference and fetch each variant of a type from its source location using [resolver queries](#merged-type-resolver-queries). For those in an Apollo ecosystem, there's also _limited_ support for merging types though a [federation `_entities` protocol](./docs/federation_entities.md).
To facilitate this merging of types, stitching must know how to cross-reference and fetch each variant of a type from its source location using [type resolver queries](#merged-type-resolver-queries). For those in an Apollo ecosystem, there's also _limited_ support for merging types though a [federation `_entities` protocol](./docs/federation_entities.md).

### Merged type resolver queries

Expand Down Expand Up @@ -249,7 +249,7 @@ type Query {
}
```

See [resolver arguments](./docs/resolver.md#arguments) for full documentation on shaping input.
See [resolver arguments](./docs/type_resolver.md#arguments) for full documentation on shaping input.

#### Composite type keys

Expand Down Expand Up @@ -458,6 +458,7 @@ This repo includes working examples of stitched schemas running across small Rac

- [Merged types](./examples/merged_types)
- [File uploads](./examples/file_uploads)
- [Subscriptions](./examples/subscriptions)

## Tests

Expand Down
6 changes: 6 additions & 0 deletions docs/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ client.on_cache_write do |request, payload|
end
```

All request digests use SHA2 by default. You can swap in [a faster algorithm](https://github.com/Shopify/blake3-rb) and/or add base scoping by reconfiguring the stitching library:

```ruby
GraphQL::Stitching.digest { |str| Digest::MD5.hexdigest("v2/#{str}") }
```

Note that inlined input data works against caching, so you should _avoid_ these input literals when possible:

```graphql
Expand Down
6 changes: 3 additions & 3 deletions docs/resolver.md → docs/type_resolver.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
## GraphQL::Stitching::Resolver
## GraphQL::Stitching::TypeResolver

A `Resolver` contains all information about a root query used by stitching to fetch location-specific variants of a merged type. Specifically, resolvers manage parsed keys and argument structures.
A `TypeResolver` contains all information about a root query used by stitching to fetch location-specific variants of a merged type. Specifically, resolvers manage parsed keys and argument structures.

### Arguments

Resolvers configure arguments through a template string of [GraphQL argument literal syntax](https://spec.graphql.org/October2021/#sec-Language.Arguments). This allows sending multiple arguments that intermix stitching keys with complex object shapes and other static values.
Type resolvers configure arguments through a template string of [GraphQL argument literal syntax](https://spec.graphql.org/October2021/#sec-Language.Arguments). This allows sending multiple arguments that intermix stitching keys with complex object shapes and other static values.

#### Key insertions

Expand Down
18 changes: 15 additions & 3 deletions lib/graphql/stitching.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,24 @@ class CompositionError < StitchingError; end
class ValidationError < CompositionError; end

class << self
attr_writer :stitch_directive

# Proc used to compute digests; uses SHA2 by default.
# @returns [Proc] proc used to compute digests.
def digest(&block)
if block_given?
@digest = block
else
@digest ||= ->(str) { Digest::SHA2.hexdigest(str) }
end
end

# Name of the directive used to mark type resolvers.
# @returns [String] name of the type resolver directive.
def stitch_directive
@stitch_directive ||= "stitch"
end

attr_writer :stitch_directive

# Names of stitching directives to omit from the composed supergraph.
# @returns [Array<String>] list of stitching directive names.
def stitching_directive_names
Expand All @@ -50,6 +62,6 @@ def stitching_directive_names
require_relative "stitching/plan"
require_relative "stitching/planner"
require_relative "stitching/request"
require_relative "stitching/resolver"
require_relative "stitching/type_resolver"
require_relative "stitching/util"
require_relative "stitching/version"
39 changes: 21 additions & 18 deletions lib/graphql/stitching/composer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

require_relative "composer/base_validator"
require_relative "composer/validate_interfaces"
require_relative "composer/validate_resolvers"
require_relative "composer/resolver_config"
require_relative "composer/validate_type_resolvers"
require_relative "composer/type_resolver_config"

module GraphQL
module Stitching
Expand Down Expand Up @@ -31,7 +31,7 @@ class T < GraphQL::Schema::Object
# @api private
COMPOSITION_VALIDATORS = [
ValidateInterfaces,
ValidateResolvers,
ValidateTypeResolvers,
].freeze

# @return [String] name of the Query type in the composed schema.
Expand Down Expand Up @@ -168,7 +168,7 @@ def perform(locations_input)
end

select_root_field_locations(schema)
expand_abstract_resolvers(schema)
expand_abstract_resolvers(schema, schemas)

supergraph = Supergraph.new(
schema: schema,
Expand Down Expand Up @@ -199,8 +199,8 @@ def prepare_locations_input(locations_input)
raise CompositionError, "The schema for `#{location}` location must be a GraphQL::Schema class."
end

@resolver_configs.merge!(ResolverConfig.extract_directive_assignments(schema, location, input[:stitch]))
@resolver_configs.merge!(ResolverConfig.extract_federation_entities(schema, location))
@resolver_configs.merge!(TypeResolverConfig.extract_directive_assignments(schema, location, input[:stitch]))
@resolver_configs.merge!(TypeResolverConfig.extract_federation_entities(schema, location))

schemas[location.to_s] = schema
executables[location.to_s] = input[:executable] || schema
Expand Down Expand Up @@ -546,13 +546,13 @@ def extract_resolvers(type_name, types_by_location)

subgraph_field.directives.each do |directive|
next unless directive.graphql_name == GraphQL::Stitching.stitch_directive
resolver_configs << ResolverConfig.from_kwargs(directive.arguments.keyword_arguments)
resolver_configs << TypeResolverConfig.from_kwargs(directive.arguments.keyword_arguments)
end

resolver_configs.each do |config|
resolver_type_name = if config.type_name
if !resolver_type.kind.abstract?
raise CompositionError, "Resolver config may only specify a type name for abstract resolvers."
raise CompositionError, "Type resolver config may only specify a type name for abstract resolvers."
elsif !resolver_type.possible_types.find { _1.graphql_name == config.type_name }
raise CompositionError, "Type `#{config.type_name}` is not a possible return type for query `#{field_name}`."
end
Expand All @@ -561,7 +561,7 @@ def extract_resolvers(type_name, types_by_location)
resolver_type.graphql_name
end

key = Resolver.parse_key_with_types(
key = TypeResolver.parse_key_with_types(
config.key,
@subgraph_types_by_name_and_location[resolver_type_name],
)
Expand All @@ -581,11 +581,11 @@ def extract_resolvers(type_name, types_by_location)
"#{argument.graphql_name}: $.#{key.primitive_name}"
end

arguments = Resolver.parse_arguments_with_field(arguments_format, subgraph_field)
arguments = TypeResolver.parse_arguments_with_field(arguments_format, subgraph_field)
arguments.each { _1.verify_key(key) }

@resolver_map[resolver_type_name] ||= []
@resolver_map[resolver_type_name] << Resolver.new(
@resolver_map[resolver_type_name] << TypeResolver.new(
location: location,
type_name: resolver_type_name,
field: subgraph_field.name,
Expand Down Expand Up @@ -620,15 +620,18 @@ def select_root_field_locations(schema)

# @!scope class
# @!visibility private
def expand_abstract_resolvers(schema)
def expand_abstract_resolvers(composed_schema, schemas_by_location)
@resolver_map.keys.each do |type_name|
resolver_type = schema.types[type_name]
next unless resolver_type.kind.abstract?
next unless composed_schema.get_type(type_name).kind.abstract?

expanded_types = Util.expand_abstract_type(schema, resolver_type)
expanded_types.select { @subgraph_types_by_name_and_location[_1.graphql_name].length > 1 }.each do |expanded_type|
@resolver_map[expanded_type.graphql_name] ||= []
@resolver_map[expanded_type.graphql_name].push(*@resolver_map[type_name])
@resolver_map[type_name].each do |resolver|
abstract_type = @subgraph_types_by_name_and_location[type_name][resolver.location]
expanded_types = Util.expand_abstract_type(schemas_by_location[resolver.location], abstract_type)

expanded_types.select { @subgraph_types_by_name_and_location[_1.graphql_name].length > 1 }.each do |impl_type|
@resolver_map[impl_type.graphql_name] ||= []
@resolver_map[impl_type.graphql_name].push(resolver)
end
end
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

module GraphQL::Stitching
class Composer
class ResolverConfig
class TypeResolverConfig
ENTITY_TYPENAME = "_Entity"
ENTITIES_QUERY = "_entities"

Expand Down Expand Up @@ -30,7 +30,7 @@ def extract_federation_entities(schema, location)
entity_type.directives.each do |directive|
next unless directive.graphql_name == "key"

key = Resolver.parse_key(directive.arguments.keyword_arguments.fetch(:fields))
key = TypeResolver.parse_key(directive.arguments.keyword_arguments.fetch(:fields))
key_fields = key.map { "#{_1.name}: $.#{_1.name}" }
field_path = "#{location}._entities"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

module GraphQL::Stitching
class Composer
class ValidateResolvers < BaseValidator
class ValidateTypeResolvers < BaseValidator

def perform(supergraph, composer)
root_types = [
Expand Down
4 changes: 2 additions & 2 deletions lib/graphql/stitching/executor.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# frozen_string_literal: true

require "json"
require_relative "executor/resolver_source"
require_relative "executor/root_source"
require_relative "executor/type_resolver_source"
require_relative "executor/shaper"

module GraphQL
Expand Down Expand Up @@ -66,7 +66,7 @@ def exec!(next_steps = [@after])
.select { next_steps.include?(_1.after) }
.group_by { [_1.location, _1.resolver.nil?] }
.map do |(location, root_source), ops|
source_class = root_source ? RootSource : ResolverSource
source_class = root_source ? RootSource : TypeResolverSource
@dataloader.with(source_class, self, location).request_all(ops)
end

Expand Down
4 changes: 2 additions & 2 deletions lib/graphql/stitching/executor/shaper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ def perform!(raw)
def resolve_object_scope(raw_object, parent_type, selections, typename = nil)
return nil if raw_object.nil?

typename ||= raw_object[Resolver::TYPENAME_EXPORT_NODE.alias]
raw_object.reject! { |key, _v| Resolver.export_key?(key) }
typename ||= raw_object[TypeResolver::TYPENAME_EXPORT_NODE.alias]
raw_object.reject! { |key, _v| TypeResolver.export_key?(key) }

selections.each do |node|
case node
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

module GraphQL::Stitching
class Executor
class ResolverSource < GraphQL::Dataloader::Source
class TypeResolverSource < GraphQL::Dataloader::Source
def initialize(executor, location)
@executor = executor
@location = location
Expand All @@ -17,7 +17,7 @@ def fetch(ops)

if op.if_type
# operations planned around unused fragment conditions should not trigger requests
origin_set.select! { _1[Resolver::TYPENAME_EXPORT_NODE.alias] == op.if_type }
origin_set.select! { _1[TypeResolver::TYPENAME_EXPORT_NODE.alias] == op.if_type }
end

memo[op] = origin_set if origin_set.any?
Expand Down
22 changes: 10 additions & 12 deletions lib/graphql/stitching/planner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ def steps
# A) Group all root selections by their preferred entrypoint locations.
# A.1) Group query fields by location for parallel execution.
# A.2) Partition mutation fields by consecutive location for serial execution.
# A.3) Permit exactly one subscription field.
#
# B) Extract contiguous selections for each entrypoint location.
# B.1) Selections on interface types that do not belong to the interface at the
Expand Down Expand Up @@ -75,7 +76,7 @@ def add_step(
resolver: nil
)
# coalesce repeat parameters into a single entrypoint
entrypoint = String.new("#{parent_index}/#{location}/#{parent_type.graphql_name}/#{resolver&.key&.to_definition}")
entrypoint = String.new("#{parent_index}/#{location}/#{parent_type.graphql_name}/#{resolver&.key&.to_definition}/#")
path.each { entrypoint << "/#{_1}" }

step = @steps_by_entrypoint[entrypoint]
Expand Down Expand Up @@ -107,11 +108,11 @@ def add_step(

# A) Group all root selections by their preferred entrypoint locations.
def build_root_entrypoints
parent_type = @supergraph.schema.root_type_for_operation(@request.operation.operation_type)

case @request.operation.operation_type
when QUERY_OP
# A.1) Group query fields by location for parallel execution.
parent_type = @supergraph.schema.query

selections_by_location = {}
each_field_in_scope(parent_type, @request.operation.selections) do |node|
locations = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name] || SUPERGRAPH_LOCATIONS
Expand All @@ -131,8 +132,6 @@ def build_root_entrypoints

when MUTATION_OP
# A.2) Partition mutation fields by consecutive location for serial execution.
parent_type = @supergraph.schema.mutation

partitions = []
each_field_in_scope(parent_type, @request.operation.selections) do |node|
next_location = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name].first
Expand All @@ -155,8 +154,7 @@ def build_root_entrypoints
end

when SUBSCRIPTION_OP
parent_type = @supergraph.schema.subscription

# A.3) Permit exactly one subscription field.
each_field_in_scope(parent_type, @request.operation.selections) do |node|
raise StitchingError, "Too many root fields for subscription." unless @steps_by_entrypoint.empty?

Expand Down Expand Up @@ -217,8 +215,8 @@ def extract_locale_selections(
input_selections.each do |node|
case node
when GraphQL::Language::Nodes::Field
if node.alias&.start_with?(Resolver::EXPORT_PREFIX)
raise StitchingError, %(Alias "#{node.alias}" is not allowed because "#{Resolver::EXPORT_PREFIX}" is a reserved prefix.)
if node.alias&.start_with?(TypeResolver::EXPORT_PREFIX)
raise StitchingError, %(Alias "#{node.alias}" is not allowed because "#{TypeResolver::EXPORT_PREFIX}" is a reserved prefix.)
elsif node.name == TYPENAME
locale_selections << node
next
Expand Down Expand Up @@ -279,8 +277,8 @@ def extract_locale_selections(

# B.4) Add a `__typename` export to abstracts and types that implement
# fragments so that resolved type information is available during execution.
if requires_typename && !locale_selections.include?(Resolver::TYPENAME_EXPORT_NODE)
locale_selections << Resolver::TYPENAME_EXPORT_NODE
if requires_typename && !locale_selections.include?(TypeResolver::TYPENAME_EXPORT_NODE)
locale_selections << TypeResolver::TYPENAME_EXPORT_NODE
end

if remote_selections
Expand All @@ -296,7 +294,7 @@ def extract_locale_selections(
# E.1) Add the key of each resolver query into the prior location's selection set.
parent_selections.push(*resolver.key.export_nodes) if resolver.key
parent_selections.uniq! do |node|
export_node = node.is_a?(GraphQL::Language::Nodes::Field) && Resolver.export_key?(node.alias)
export_node = node.is_a?(GraphQL::Language::Nodes::Field) && TypeResolver.export_key?(node.alias)
export_node ? node.alias : node.object_id
end

Expand Down
2 changes: 1 addition & 1 deletion lib/graphql/stitching/planner/step.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def initialize(
parent_type:,
index:,
after: nil,
operation_type: "query",
operation_type: QUERY_OP,
selections: [],
variables: {},
path: [],
Expand Down
4 changes: 2 additions & 2 deletions lib/graphql/stitching/request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,12 @@ def normalized_string

# @return [String] a digest of the original document string. Generally faster but less consistent.
def digest
@digest ||= Digest::SHA2.hexdigest(string)
@digest ||= Stitching.digest.call("#{Stitching::VERSION}/#{string}")
end

# @return [String] a digest of the normalized document string. Slower but more consistent.
def normalized_digest
@normalized_digest ||= Digest::SHA2.hexdigest(normalized_string)
@normalized_digest ||= Stitching.digest.call("#{Stitching::VERSION}/#{normalized_string}")
end

# @return [GraphQL::Language::Nodes::OperationDefinition] The selected root operation for the request.
Expand Down
Loading

0 comments on commit f7861e5

Please sign in to comment.