From 2916c839f902c4334f859dbc4b331396a04ee7a9 Mon Sep 17 00:00:00 2001 From: Greg MacWilliam Date: Fri, 19 Jul 2024 23:28:27 -0400 Subject: [PATCH] support subscriptions. --- README.md | 6 +- docs/README.md | 1 + docs/subscriptions.md | 206 ++++++++++++++++++ lib/graphql/stitching.rb | 12 + lib/graphql/stitching/client.rb | 14 +- lib/graphql/stitching/composer.rb | 12 +- .../stitching/composer/resolver_config.rb | 2 +- .../stitching/composer/validate_resolvers.rb | 8 +- lib/graphql/stitching/executor.rb | 13 +- lib/graphql/stitching/executor/shaper.rb | 2 +- lib/graphql/stitching/planner.rb | 20 +- lib/graphql/stitching/request.rb | 36 ++- lib/graphql/stitching/resolver/arguments.rb | 4 +- lib/graphql/stitching/resolver/keys.rb | 5 +- lib/graphql/stitching/supergraph.rb | 2 +- lib/graphql/stitching/version.rb | 2 +- test/graphql/stitching/client_test.rb | 28 +-- .../composer/merge_root_objects_test.rb | 13 +- .../executor/shaper_grooming_test.rb | 2 +- .../stitching/planner/plan_resolvers_test.rb | 44 ++++ .../planner/plan_root_operations_test.rb | 36 +++ .../graphql/stitching/request/request_test.rb | 46 +++- 22 files changed, 451 insertions(+), 63 deletions(-) create mode 100644 docs/subscriptions.md diff --git a/README.md b/README.md index 32dabf9a..35ec6c74 100644 --- a/README.md +++ b/README.md @@ -5,16 +5,17 @@ GraphQL stitching composes a single schema from multiple underlying GraphQL reso ![Stitched graph](./docs/images/stitching.png) **Supports:** +- All operation types: query, mutation, and subscription. - Merged object and abstract types. -- Multiple and composite keys per merged type. - 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). - Tested with all minor versions of `graphql-ruby`. **NOT Supported:** - Computed fields (ie: federation-style `@requires`). -- Subscriptions, defer/stream. +- Defer/stream. This Ruby implementation is a sibling to [GraphQL Tools](https://the-guild.dev/graphql/stitching) (JS) and [Bramble](https://movio.github.io/bramble/) (Go), and its capabilities fall somewhere in between them. GraphQL stitching is similar in concept to [Apollo Federation](https://www.apollographql.com/docs/federation/), though more generic. The opportunity here is for a Ruby application to stitch its local schemas together or onto remote sources without requiring an additional proxy service running in another language. If your goal is to build a purely high-throughput federated reverse proxy, consider not using Ruby. @@ -445,6 +446,7 @@ The [Executor](./docs/executor.md) component builds atop the Ruby fiber-based im - [Modeling foreign keys for stitching](./docs/mechanics.md##modeling-foreign-keys-for-stitching) - [Deploying a stitched schema](./docs/mechanics.md#deploying-a-stitched-schema) - [Schema composition merge patterns](./docs/composer.md#merge-patterns) +- [Subscriptions tutorial](./docs/subscriptions.md) - [Field selection routing](./docs/mechanics.md#field-selection-routing) - [Root selection routing](./docs/mechanics.md#root-selection-routing) - [Stitched errors](./docs/mechanics.md#stitched-errors) diff --git a/docs/README.md b/docs/README.md index 54b9664e..23290472 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,4 +15,5 @@ Major components include: Additional topics: - [Stitching mechanics](./mechanics.md) - more about building for stitching and how it operates. +- [Subscriptions](./subscriptions.md) - explore how to stitch realtime event subscriptions. - [Federation entities](./federation_entities.md) - more about Apollo Federation compatibility. diff --git a/docs/subscriptions.md b/docs/subscriptions.md new file mode 100644 index 00000000..916c07d8 --- /dev/null +++ b/docs/subscriptions.md @@ -0,0 +1,206 @@ +## Stitching subscriptions + +Stitching is an interesting prospect for subscriptions because socket-based interactions can be isolated to their own schema/server with very little implementation beyond resolving entity keys. Then, entity data can be stitched onto subscription payloads from other locations. + +### Composing a subscriptions schema + +For simplicity, subscription resolvers should exist together in a single schema (multiple schemas with subscriptions aren't worth the confusion). This subscriptions schema may provide basic entity types that will merge with other locations. For example, here's a bare-bones subscriptions schema: + +```ruby +class SubscriptionSchema < GraphQL::Schema + class Post < GraphQL::Schema::Object + field :id, ID, null: false + end + + class Comment < GraphQL::Schema::Object + field :id, ID, null: false + end + + class CommentAddedToPost < GraphQL::Schema::Subscription + argument :post_id, ID, required: true + field :post, Post, null: false + field :comment, Comment, null: true + + def subscribe(post_id:) + { post: { id: post_id }, comment: nil } + end + + def update(post_id:) + { post: { id: post_id }, comment: object } + end + end + + class SubscriptionType < GraphQL::Schema::Object + field :comment_added_to_post, subscription: CommentAddedToPost + end + + use GraphQL::Subscriptions::ActionCableSubscriptions + subscription SubscriptionType +end +``` + +The above subscriptions schema can compose with other locations, such as the following that provides full entity types: + +```ruby +class EntitiesSchema < GraphQL::Schema + class StitchingResolver < GraphQL::Schema::Directive + graphql_name "stitch" + locations FIELD_DEFINITION + argument :key, String, required: true + argument :arguments, String, required: false + repeatable true + end + + class Comment < GraphQL::Schema::Object + field :id, ID, null: false + field :message, String, null: false + end + + class Post < GraphQL::Schema::Object + field :id, ID, null: false + field :title, String, null: false + field :comments, [Comment, null: false], null: false + end + + class QueryType < GraphQL::Schema::Object + field :posts, [Post, null: true] do + directive StitchingResolver, key: "id" + argument :ids, [ID], required: true + end + + def posts(ids:) + Post.where(id: ids) + end + + field :comments, [Comment, null: true] do + directive StitchingResolver, key: "id" + argument :ids, [ID], required: true + end + + def comments(ids:) + Comment.where(id: ids) + end + end + + query QueryType +end +``` + +These schemas are composed as normal into a stitching client. The subscriptions schema _must be locally-executable_ while the other entity schema(s) may be served from anywhere: + +```ruby +StitchedSchema = GraphQL::Stitching::Client.new(locations: { + subscriptions: { + schema: SubscriptionSchema, # << locally executable! + }, + entities: { + schema: GraphQL::Schema.from_definition(entities_schema_sdl), + executable: GraphQL::Stitching::HttpExecutable.new("http://localhost:3001"), + }, +}) +``` + +### Serving stitched subscriptions + +Once you've stitched a schema with subscriptions, it gets called as part of three workflows: + +1. Controller - handles normal query and mutation requests recieved via HTTP. +2. Channel - handles subscription-create requests recieved through a socket connection. +3. Plugin – handles subscription-update events pushed to the socket connection. + +#### Controller + +A controller will recieve basic query and mutation requests sent over HTTP, including introspection requests. Fulfill these using the stitched schema client. + +```ruby +class GraphqlController < ApplicationController + skip_before_action :verify_authenticity_token + layout false + + def execute + result = StitchedSchema.execute( + params[:query], + context: {}, + variables: params[:variables], + operation_name: params[:operationName], + ) + + render json: result + end +end +``` + +#### Channel + +A channel handles subscription requests initiated via websocket connection. This mostly follows the [GraphQL Ruby documentation example](https://graphql-ruby.org/api-doc/2.3.9/GraphQL/Subscriptions/ActionCableSubscriptions), except that `execute` uses the stitched schema client while `unsubscribed` uses the subscriptions subschema directly: + +```ruby +class GraphqlChannel < ApplicationCable::Channel + def subscribed + @subscription_ids = [] + end + + def execute(params) + result = StitchedSchema.execute( + params["query"], + context: { channel: self }, + variables: params["variables"], + operation_name: params["operationName"] + ) + + payload = { + result: result.to_h, + more: result.subscription?, + } + + if result.context[:subscription_id] + @subscription_ids << result.context[:subscription_id] + end + + transmit(payload) + end + + def unsubscribed + @subscription_ids.each { |sid| + # Go directly through the subscriptions subschema + # when managing/triggering subscriptions: + SubscriptionSchema.subscriptions.delete_subscription(sid) + } + end +end +``` + +What happens behind the scenes here is that stitching filters the `execute` request down to just subscription selections, and passes those through to the subscriptions subschema where they register an event binding. The subscriber response gets stitched while passing back out through the stitching client. + +#### Plugin + +Lastly, update events trigger with the filtered subscriptions selection, and must get stitched before transmitting. The stitching client adds an update handler into request context for this purpose. A small patch to the subscriptions plugin class can call this handler on update event payloads before transmitting them: + +```ruby +class StitchedActionCableSubscriptions < GraphQL::Subscriptions::ActionCableSubscriptions + def execute_update(subscription_id, event, object) + super(subscription_id, event, object).tap do |result| + result.context[:stitch_subscription_update]&.call(result) + end + end +end + +class SubscriptionSchema + # switch the plugin on the subscriptions schema to use the patched class... + use StitchedActionCableSubscriptions +end +``` + +### Triggering subscriptions + +Subscription update events are triggered as normal directly through the subscriptions subschema: + +```ruby +class Comment < ApplicationRecord + after_create :trigger_subscriptions + + def trigger_subscriptions + SubscriptionsSchema.subscriptions.trigger(:comment_added_to_post, { post_id: post_id }, self) + end +end +``` diff --git a/lib/graphql/stitching.rb b/lib/graphql/stitching.rb index e8d66ba3..95c2d43c 100644 --- a/lib/graphql/stitching.rb +++ b/lib/graphql/stitching.rb @@ -4,6 +4,18 @@ module GraphQL module Stitching + # scope name of query operations. + QUERY_OP = "query" + + # scope name of mutation operations. + MUTATION_OP = "mutation" + + # scope name of subscription operations. + SUBSCRIPTION_OP = "subscription" + + # introspection typename field. + TYPENAME = "__typename" + # @api private EMPTY_OBJECT = {}.freeze diff --git a/lib/graphql/stitching/client.rb b/lib/graphql/stitching/client.rb index 0049bb3d..8965b9f0 100644 --- a/lib/graphql/stitching/client.rb +++ b/lib/graphql/stitching/client.rb @@ -33,10 +33,10 @@ def initialize(locations: nil, supergraph: nil, composer: nil) @on_error = nil end - def execute(query:, variables: nil, operation_name: nil, context: nil, validate: true) + def execute(raw_query = nil, query: nil, variables: nil, operation_name: nil, context: nil, validate: true) request = Request.new( @supergraph, - query, + raw_query || query, # << for parity with GraphQL Ruby Schema.execute operation_name: operation_name, variables: variables, context: context, @@ -44,17 +44,17 @@ def execute(query:, variables: nil, operation_name: nil, context: nil, validate: if validate validation_errors = request.validate - return error_result(validation_errors) if validation_errors.any? + return error_result(request, validation_errors) if validation_errors.any? end request.prepare! load_plan(request) request.execute rescue GraphQL::ParseError, GraphQL::ExecutionError => e - error_result([e]) + error_result(request, [e]) rescue StandardError => e custom_message = @on_error.call(request, e) if @on_error - error_result([{ "message" => custom_message || "An unexpected error occured." }]) + error_result(request, [{ "message" => custom_message || "An unexpected error occured." }]) end def on_cache_read(&block) @@ -93,12 +93,12 @@ def load_plan(request) plan end - def error_result(errors) + def error_result(request, errors) public_errors = errors.map! do |e| e.is_a?(Hash) ? e : e.to_h end - { "errors" => public_errors } + GraphQL::Query::Result.new(query: request, values: { "errors" => public_errors }) end end end diff --git a/lib/graphql/stitching/composer.rb b/lib/graphql/stitching/composer.rb index e5817ee2..07e39fb2 100644 --- a/lib/graphql/stitching/composer.rb +++ b/lib/graphql/stitching/composer.rb @@ -40,6 +40,9 @@ class T < GraphQL::Schema::Object # @return [String] name of the Mutation type in the composed schema. attr_reader :mutation_name + # @return [String] name of the Subscription type in the composed schema. + attr_reader :subscription_name + # @api private attr_reader :subgraph_types_by_name_and_location @@ -49,6 +52,7 @@ class T < GraphQL::Schema::Object def initialize( query_name: "Query", mutation_name: "Mutation", + subscription_name: "Subscription", description_merger: nil, deprecation_merger: nil, default_value_merger: nil, @@ -57,6 +61,7 @@ def initialize( ) @query_name = query_name @mutation_name = mutation_name + @subscription_name = subscription_name @description_merger = description_merger || BASIC_VALUE_MERGER @deprecation_merger = deprecation_merger || BASIC_VALUE_MERGER @default_value_merger = default_value_merger || BASIC_VALUE_MERGER @@ -97,7 +102,6 @@ def perform(locations_input) # "Typename" => "location" => subgraph_type @subgraph_types_by_name_and_location = schemas.each_with_object({}) do |(location, schema), memo| raise CompositionError, "Location keys must be strings" unless location.is_a?(String) - raise CompositionError, "The subscription operation is not supported." if schema.subscription introspection_types = schema.introspection_system.types.keys schema.types.each do |type_name, subgraph_type| @@ -107,10 +111,13 @@ def perform(locations_input) raise CompositionError, "Query name \"#{@query_name}\" is used by non-query type in #{location} schema." elsif type_name == @mutation_name && subgraph_type != schema.mutation raise CompositionError, "Mutation name \"#{@mutation_name}\" is used by non-mutation type in #{location} schema." + elsif type_name == @subscription_name && subgraph_type != schema.subscription + raise CompositionError, "Subscription name \"#{@subscription_name}\" is used by non-subscription type in #{location} schema." end type_name = @query_name if subgraph_type == schema.query type_name = @mutation_name if subgraph_type == schema.mutation + type_name = @subscription_name if subgraph_type == schema.subscription @mapped_type_names[subgraph_type.graphql_name] = type_name if subgraph_type.graphql_name != type_name memo[type_name] ||= {} @@ -154,6 +161,7 @@ def perform(locations_input) orphan_types(schema_types.values.select { |t| t.respond_to?(:kind) && t.kind.object? }) query schema_types[builder.query_name] mutation schema_types[builder.mutation_name] + subscription schema_types[builder.subscription_name] directives builder.schema_directives.values own_orphan_types.clear @@ -593,7 +601,7 @@ def extract_resolvers(type_name, types_by_location) # @!scope class # @!visibility private def select_root_field_locations(schema) - [schema.query, schema.mutation].tap(&:compact!).each do |root_type| + [schema.query, schema.mutation, schema.subscription].tap(&:compact!).each do |root_type| root_type.fields.each do |root_field_name, root_field| root_field_locations = @field_map[root_type.graphql_name][root_field_name] next unless root_field_locations.length > 1 diff --git a/lib/graphql/stitching/composer/resolver_config.rb b/lib/graphql/stitching/composer/resolver_config.rb index 89535d04..1ba17d9b 100644 --- a/lib/graphql/stitching/composer/resolver_config.rb +++ b/lib/graphql/stitching/composer/resolver_config.rb @@ -38,7 +38,7 @@ def extract_federation_entities(schema, location) memo[field_path] << new( key: key.to_definition, type_name: entity_type.graphql_name, - arguments: "representations: { #{key_fields.join(", ")}, __typename: $.__typename }", + arguments: "representations: { #{key_fields.join(", ")}, #{TYPENAME}: $.#{TYPENAME} }", ) end end diff --git a/lib/graphql/stitching/composer/validate_resolvers.rb b/lib/graphql/stitching/composer/validate_resolvers.rb index cabccaf5..c91aadfd 100644 --- a/lib/graphql/stitching/composer/validate_resolvers.rb +++ b/lib/graphql/stitching/composer/validate_resolvers.rb @@ -5,10 +5,16 @@ class Composer class ValidateResolvers < BaseValidator def perform(supergraph, composer) + root_types = [ + supergraph.schema.query, + supergraph.schema.mutation, + supergraph.schema.subscription, + ].tap(&:compact!) + supergraph.schema.types.each do |type_name, type| # objects and interfaces that are not the root operation types next unless type.kind.object? || type.kind.interface? - next if supergraph.schema.query == type || supergraph.schema.mutation == type + next if root_types.include?(type) next if type.graphql_name.start_with?("__") # multiple subschemas implement the type diff --git a/lib/graphql/stitching/executor.rb b/lib/graphql/stitching/executor.rb index 7997afb7..c53bb938 100644 --- a/lib/graphql/stitching/executor.rb +++ b/lib/graphql/stitching/executor.rb @@ -27,10 +27,11 @@ class Executor # Builds a new executor. # @param request [Request] the stitching request to execute. # @param nonblocking [Boolean] specifies if the dataloader should use async concurrency. - def initialize(request, nonblocking: false) + def initialize(request, data: {}, errors: [], after: 0, nonblocking: false) @request = request - @data = {} - @errors = [] + @data = data + @errors = errors + @after = after @query_count = 0 @exec_cycles = 0 @dataloader = GraphQL::Dataloader.new(nonblocking: nonblocking) @@ -47,13 +48,13 @@ def perform(raw: false) if @errors.length > 0 result["errors"] = @errors end - - result + + GraphQL::Query::Result.new(query: @request, values: result) end private - def exec!(next_steps = [0]) + def exec!(next_steps = [@after]) if @exec_cycles > @request.plan.ops.length # sanity check... if we've exceeded queue size, then something went wrong. raise StitchingError, "Too many execution requests attempted." diff --git a/lib/graphql/stitching/executor/shaper.rb b/lib/graphql/stitching/executor/shaper.rb index 1a4767d5..c07525fb 100644 --- a/lib/graphql/stitching/executor/shaper.rb +++ b/lib/graphql/stitching/executor/shaper.rb @@ -105,7 +105,7 @@ def introspection_field?(parent_type, node) is_root = parent_type == @root_type case node.name - when "__typename" + when TYPENAME yield(is_root) true when "__schema", "__type" diff --git a/lib/graphql/stitching/planner.rb b/lib/graphql/stitching/planner.rb index 90fc1b56..735e8761 100644 --- a/lib/graphql/stitching/planner.rb +++ b/lib/graphql/stitching/planner.rb @@ -8,9 +8,6 @@ module Stitching # and provides a query plan with sequential execution steps. class Planner SUPERGRAPH_LOCATIONS = [Supergraph::SUPERGRAPH_LOCATION].freeze - TYPENAME = "__typename" - QUERY_OP = "query" - MUTATION_OP = "mutation" ROOT_INDEX = 0 def initialize(request) @@ -128,6 +125,7 @@ def build_root_entrypoints parent_index: ROOT_INDEX, parent_type: parent_type, selections: selections, + operation_type: QUERY_OP, ) end @@ -156,6 +154,22 @@ def build_root_entrypoints ).index end + when SUBSCRIPTION_OP + parent_type = @supergraph.schema.subscription + + each_field_in_scope(parent_type, @request.operation.selections) do |node| + raise StitchingError, "Too many root fields for subscription." unless @steps_by_entrypoint.empty? + + locations = @supergraph.locations_by_type_and_field[parent_type.graphql_name][node.name] || SUPERGRAPH_LOCATIONS + add_step( + location: locations.first, + parent_index: ROOT_INDEX, + parent_type: parent_type, + selections: [node], + operation_type: SUBSCRIPTION_OP, + ) + end + else raise StitchingError, "Invalid operation type." end diff --git a/lib/graphql/stitching/request.rb b/lib/graphql/stitching/request.rb index 1b5a7f89..7a6f0824 100644 --- a/lib/graphql/stitching/request.rb +++ b/lib/graphql/stitching/request.rb @@ -9,7 +9,6 @@ module Stitching # It provides the lifecycle of validating, preparing, # planning, and executing upon these inputs. class Request - SUPPORTED_OPERATIONS = ["query", "mutation"].freeze SKIP_INCLUDE_DIRECTIVE = /@(?:skip|include)/ # @return [Supergraph] supergraph instance that resolves the request. @@ -89,7 +88,6 @@ def operation @operation ||= begin operation_defs = @document.definitions.select do |d| next unless d.is_a?(GraphQL::Language::Nodes::OperationDefinition) - next unless SUPPORTED_OPERATIONS.include?(d.operation_type) @operation_name ? d.name == @operation_name : true end @@ -103,6 +101,21 @@ def operation end end + # @return [Boolean] true if operation type is a query + def query? + operation.operation_type == QUERY_OP + end + + # @return [Boolean] true if operation type is a mutation + def mutation? + operation.operation_type == MUTATION_OP + end + + # @return [Boolean] true if operation type is a subscription + def subscription? + operation.operation_type == SUBSCRIPTION_OP + end + # @return [String] A string of directives applied to the root operation. These are passed through in all subgraph requests. def operation_directives @operation_directives ||= if operation.directives.any? @@ -176,8 +189,27 @@ def plan(new_plan = nil) # @param raw [Boolean] specifies the result should be unshaped without pruning or null bubbling. Useful for debugging. # @return [Hash] the rendered GraphQL response with "data" and "errors" sections. def execute(raw: false) + add_subscription_update_handler if subscription? Executor.new(self).perform(raw: raw) end + + private + + # Adds a handler into context for enriching subscription updates with stitched data + def add_subscription_update_handler + request = self + @context[:stitch_subscription_update] = -> (result) { + stitched_result = Executor.new( + request, + data: result.to_h["data"] || {}, + errors: result.to_h["errors"] || [], + after: request.plan.ops.first.step, + ).perform + + result.to_h.merge!(stitched_result.to_h) + result + } + end end end end diff --git a/lib/graphql/stitching/resolver/arguments.rb b/lib/graphql/stitching/resolver/arguments.rb index f20173b6..7673b12a 100644 --- a/lib/graphql/stitching/resolver/arguments.rb +++ b/lib/graphql/stitching/resolver/arguments.rb @@ -130,8 +130,8 @@ def key? def verify_key(arg, key) key_field = value.reduce(Resolver::KeyField.new("", inner: key)) do |field, ns| - if ns == Resolver::TYPE_NAME - Resolver::KeyField.new(Resolver::TYPE_NAME) + if ns == TYPENAME + Resolver::KeyField.new(TYPENAME) elsif field field.inner.find { _1.name == ns } end diff --git a/lib/graphql/stitching/resolver/keys.rb b/lib/graphql/stitching/resolver/keys.rb index 9f3d469d..e3bf09db 100644 --- a/lib/graphql/stitching/resolver/keys.rb +++ b/lib/graphql/stitching/resolver/keys.rb @@ -3,7 +3,6 @@ module GraphQL::Stitching class Resolver EXPORT_PREFIX = "_export_" - TYPE_NAME = "__typename" class FieldNode # GraphQL Ruby changed the argument assigning Field.alias from @@ -59,8 +58,8 @@ def export_nodes EMPTY_FIELD_SET = KeyFieldSet.new(GraphQL::Stitching::EMPTY_ARRAY) TYPENAME_EXPORT_NODE = FieldNode.build( - field_alias: "#{EXPORT_PREFIX}#{TYPE_NAME}", - field_name: TYPE_NAME, + field_alias: "#{EXPORT_PREFIX}#{TYPENAME}", + field_name: TYPENAME, ) class Key < KeyFieldSet diff --git a/lib/graphql/stitching/supergraph.rb b/lib/graphql/stitching/supergraph.rb index b0fe3a40..d7ea30b8 100644 --- a/lib/graphql/stitching/supergraph.rb +++ b/lib/graphql/stitching/supergraph.rb @@ -103,7 +103,7 @@ def execute_at_location(location, source, variables, request) executable.execute( query: source, variables: variables, - context: request.context.frozen? ? request.context.dup : request.context, + context: request.context.to_h, validate: false, ) elsif executable.respond_to?(:call) diff --git a/lib/graphql/stitching/version.rb b/lib/graphql/stitching/version.rb index 17306c6d..62f77bc9 100644 --- a/lib/graphql/stitching/version.rb +++ b/lib/graphql/stitching/version.rb @@ -2,6 +2,6 @@ module GraphQL module Stitching - VERSION = "1.4.3" + VERSION = "1.5.0" end end diff --git a/test/graphql/stitching/client_test.rb b/test/graphql/stitching/client_test.rb index 91b98a38..12ccfd49 100644 --- a/test/graphql/stitching/client_test.rb +++ b/test/graphql/stitching/client_test.rb @@ -78,7 +78,7 @@ def test_execute_valid_query_via_string operation_name: "MyStore", ) - assert_equal @expected_result, result + assert_equal @expected_result, result.to_h end def test_execute_valid_query_via_ast @@ -90,7 +90,7 @@ def test_execute_valid_query_via_ast operation_name: "MyStore", ) - assert_equal @expected_result, result + assert_equal @expected_result, result.to_h end def test_prepares_requests_before_handling @@ -161,7 +161,7 @@ def test_query_with_operation_name } | - result = @client.execute(query: queries, operation_name: "SecondBest") + result = @client.execute(queries, operation_name: "SecondBest") expected_result = { "data" => { "storefront" => { "id" => "2" } } } assert_equal expected_result, result @@ -179,7 +179,7 @@ def test_returns_error_for_required_operation_name } | - result = @client.execute(query: queries) + result = @client.execute(queries) expected_errors = [ { "message" => "An operation name is required when sending multiple operations." }, @@ -194,7 +194,7 @@ def test_returns_error_for_operation_name_not_found query { storefront(id: "1") { id } } | - result = @client.execute(query: queries, operation_name: "Sfoo") + result = @client.execute(queries, operation_name: "Sfoo") expected_errors = [ { "message" => "Invalid root operation for given name and operation type." }, @@ -209,7 +209,7 @@ def test_returns_graphql_error_for_parser_failures query BestStorefront { sfoo }} | - result = @client.execute(query: queries) + result = @client.execute(queries) expected_locs = [{ "line" => 2, "column" => 36 }] assert_equal 1, result["errors"].length @@ -227,7 +227,7 @@ def test_location_with_executable } }) - result = client.execute(query: "query { storefront(id: \"1\") { id } }") + result = client.execute("query { storefront(id: \"1\") { id } }") assert_equal static_remote_data, result end @@ -244,7 +244,7 @@ def test_query_with_variables } | - result = client.execute(query: query, variables: { "storefrontID" => "1" }) + result = client.execute(query, variables: { "storefrontID" => "1" }) expected_result = { "data" => { "storefront" => { "id" => "1" } } } assert_equal expected_result, result @@ -263,11 +263,11 @@ def test_caching_hooks_store_query_plans @client.on_cache_read { |req| cache[req.digest] } @client.on_cache_write { |req, payload| cache[req.digest] = payload.gsub("price", "name price") } - uncached_result = @client.execute(query: test_query) + uncached_result = @client.execute(test_query) expected_uncached = { "data" => { "product" => { "price" => 699.99 } } } assert_equal expected_uncached, uncached_result - cached_result = @client.execute(query: test_query) + cached_result = @client.execute(test_query) expected_cached = { "data" => { "product" => { "name" => "iPhone", "price" => 699.99 } } } assert_equal expected_cached, cached_result end @@ -292,7 +292,7 @@ def test_caching_hooks_receive_request_context nil end - client.execute(query: %|{ product(upc: "1") { price } }|, context: context) + client.execute(%|{ product(upc: "1") { price } }|, context: context) assert_equal context[:key], read_context assert_equal context[:key], write_context end @@ -304,7 +304,7 @@ def test_invalid_query } }) - result = client.execute(query: "query { invalidSelection }") + result = client.execute("query { invalidSelection }") expected_errors = [{ "message" => "Field 'invalidSelection' doesn't exist on type 'Query'", "path" => ["query", "invalidSelection"], @@ -321,7 +321,7 @@ def test_errors_are_handled_by_default } }) - result = client.execute(query: 'query { invalidSelection }', validate: false) + result = client.execute('query { invalidSelection }', validate: false) expected_errors = [{ "message" => "An unexpected error occured.", @@ -343,7 +343,7 @@ def test_errors_trigger_hooks_that_may_return_a_custom_message end result = client.execute( - query: 'query { invalidSelection }', + query: "query { invalidSelection }", context: { request_id: "R2d2c3P0" }, validate: false ) diff --git a/test/graphql/stitching/composer/merge_root_objects_test.rb b/test/graphql/stitching/composer/merge_root_objects_test.rb index fe2019d5..662cd7b0 100644 --- a/test/graphql/stitching/composer/merge_root_objects_test.rb +++ b/test/graphql/stitching/composer/merge_root_objects_test.rb @@ -5,12 +5,13 @@ describe 'GraphQL::Stitching::Composer, merging root objects' do def test_merges_fields_of_root_scopes - a = "type Query { a:String } type Mutation { a:String }" - b = "type Query { b:String } type Mutation { b:String }" + a = "type Query { a:String } type Mutation { a:String } type Subscription { a:String }" + b = "type Query { b:String } type Mutation { b:String } type Subscription { b:String }" info = compose_definitions({ "a" => a, "b" => b }) assert_equal ["a","b"], info.schema.types["Query"].fields.keys.sort assert_equal ["a","b"], info.schema.types["Mutation"].fields.keys.sort + assert_equal ["a","b"], info.schema.types["Subscription"].fields.keys.sort end def test_merges_fields_of_root_scopes_from_custom_names @@ -37,14 +38,6 @@ def test_merges_fields_of_root_scopes_into_custom_names assert_nil info.schema.get_type("Mutation") end - def test_errors_for_subscription - a = "type Query { a:String } type Mutation { a:String } type Subscription { b:String }" - - assert_error('subscription operation is not supported', CompositionError) do - compose_definitions({ "a" => a }) - end - end - def test_errors_for_query_type_name_conflict a = "type Query { a:String } type Boom { a:String }" diff --git a/test/graphql/stitching/executor/shaper_grooming_test.rb b/test/graphql/stitching/executor/shaper_grooming_test.rb index 7ad355fd..32aef3ba 100644 --- a/test/graphql/stitching/executor/shaper_grooming_test.rb +++ b/test/graphql/stitching/executor/shaper_grooming_test.rb @@ -161,7 +161,7 @@ def test_handles_introspection_types INTROSPECTION_QUERY, ) - raw = schema.execute(query: INTROSPECTION_QUERY).to_h + raw = schema.execute(INTROSPECTION_QUERY).to_h assert GraphQL::Stitching::Executor::Shaper.new(request).perform!(raw) end end diff --git a/test/graphql/stitching/planner/plan_resolvers_test.rb b/test/graphql/stitching/planner/plan_resolvers_test.rb index 49af4dab..7d3db765 100644 --- a/test/graphql/stitching/planner/plan_resolvers_test.rb +++ b/test/graphql/stitching/planner/plan_resolvers_test.rb @@ -291,6 +291,50 @@ def test_expands_selections_for_abstracts_targeting_abstract_locations } end + def test_plans_subscription_resolvers + a = %| + type Apple { id:ID! flavor:String } + type Query { appleA(id:ID!):Apple @stitch(key:"id") } + type Subscription { watchApple: Apple } + | + + b = %| + type Apple { id:ID! color:String } + type Query { appleB(id:ID!):Apple @stitch(key:"id") } + | + + @supergraph = compose_definitions({ "a" => a, "b" => b }) + + plan = GraphQL::Stitching::Request.new( + @supergraph, + %|subscription { watchApple { id flavor color } }|, + ).plan + + assert_equal 2, plan.ops.length + + assert_keys plan.ops[0].as_json, { + after: 0, + location: "a", + operation_type: "subscription", + selections: %|{ watchApple { id flavor _export_id: id _export___typename: __typename } }|, + path: [], + resolver: nil, + } + + assert_keys plan.ops[1].as_json, { + after: plan.ops.first.step, + location: "b", + operation_type: "query", + selections: %|{ color }|, + path: ["watchApple"], + resolver: resolver_version("Apple", { + location: "b", + field: "appleB", + key: "id", + }), + } + end + private def resolver_version(type_name, criteria) diff --git a/test/graphql/stitching/planner/plan_root_operations_test.rb b/test/graphql/stitching/planner/plan_root_operations_test.rb index a010d466..d5b23f24 100644 --- a/test/graphql/stitching/planner/plan_root_operations_test.rb +++ b/test/graphql/stitching/planner/plan_root_operations_test.rb @@ -9,12 +9,14 @@ def setup type Widget { id:ID! } type Query { widget: Widget } type Mutation { makeWidget: Widget } + type Subscription { watchWidget: Widget } | @sprockets_sdl = %| type Sprocket { id:ID! } type Query { sprocket: Sprocket } type Mutation { makeSprocket: Sprocket } + type Subscription { watchSprocket: Sprocket } | @supergraph = compose_definitions({ @@ -104,6 +106,40 @@ def test_plans_mutation_operations_by_serial_location_groups } end + def test_plans_subscription_operations_for_single_field + document = %| + subscription { + watchWidget { id } + } + | + + plan = GraphQL::Stitching::Request.new(@supergraph, document).plan + + assert_equal 1, plan.ops.length + assert_keys plan.ops[0].as_json, { + after: 0, + location: "widgets", + operation_type: "subscription", + selections: %|{ watchWidget { id } }|, + path: [], + if_type: nil, + resolver: nil, + } + end + + def test_raises_for_subscription_operations_with_multiple_fields + document = %| + subscription { + a: watchWidget { id } + b: watchSprocket { id } + } + | + + assert_error "Too many root fields" do + GraphQL::Stitching::Request.new(@supergraph, document).plan + end + end + def test_plans_root_queries_through_fragments document = %| fragment RootAttrs on Query { diff --git a/test/graphql/stitching/request/request_test.rb b/test/graphql/stitching/request/request_test.rb index 5acc76ea..1d74c698 100644 --- a/test/graphql/stitching/request/request_test.rb +++ b/test/graphql/stitching/request/request_test.rb @@ -62,12 +62,6 @@ def test_operation_errors_for_invalid_operation_names end end - def test_operation_errors_for_invalid_operation_types - assert_error "Invalid root operation", GraphQL::ExecutionError do - GraphQL::Stitching::Request.new(@supergraph, "subscription { movie }").operation - end - end - def test_access_operation_directives query = %| query First @inContext(lang: "EN") { widget { id } } @@ -252,4 +246,44 @@ def test_assigning_a_plan_must_be_plan_instance request.plan({}) end end + + def test_builds_request_context + request = GraphQL::Stitching::Request.new(@supergraph, "{ id }") + expected = { request: request } + assert_equal expected, request.context.to_h + + request = GraphQL::Stitching::Request.new(@supergraph, "{ id }", context: { test: true }) + expected = { request: request, test: true } + assert_equal expected, request.context.to_h + end + + def test_identifies_anonymous_query_operations + assert GraphQL::Stitching::Request.new(@supergraph, "{ id }").query? + end + + def test_identifies_query_operations + assert GraphQL::Stitching::Request.new(@supergraph, "query { id }").query? + end + + def test_identifies_mutation_operations + assert GraphQL::Stitching::Request.new(@supergraph, "mutation { id }").mutation? + end + + def test_identifies_subscription_operations + assert GraphQL::Stitching::Request.new(@supergraph, "subscription { id }").subscription? + end + + def test_compatible_with_graphql_result + request = GraphQL::Stitching::Request.new(@supergraph, "query { id }") + result = GraphQL::Query::Result.new(query: request, values: { a: 1, b: 2 }) + + assert result.query? + assert !result.mutation? + assert !result.subscription? + assert_equal request, result.query + assert_equal request.context, result.context + assert_equal [:a, :b], result.keys + assert_equal [1, 2], result.values + assert_equal 1, result[:a] + end end