From 7e9e32dd9b2f3dec8e7da1b5461933c6fa0b85ec Mon Sep 17 00:00:00 2001 From: Greg MacWilliam Date: Sat, 27 Jun 2026 00:00:29 -0400 Subject: [PATCH 1/2] add working examples. --- example/.gitignore | 3 + example/Gemfile | 9 + example/README.md | 72 ++ example/app.rb | 377 ++++++++++ example/config.ru | 5 + example/lib/example/schema.rb | 122 ++++ example/views/graphiql.erb | 669 ++++++++++++++++++ lib/graphql/breadth/executor.rb | 59 +- lib/graphql/breadth/incremental/context.rb | 23 + .../breadth/executor/incremental_test.rb | 135 +++- 10 files changed, 1406 insertions(+), 68 deletions(-) create mode 100644 example/.gitignore create mode 100644 example/Gemfile create mode 100644 example/README.md create mode 100644 example/app.rb create mode 100644 example/config.ru create mode 100644 example/lib/example/schema.rb create mode 100644 example/views/graphiql.erb diff --git a/example/.gitignore b/example/.gitignore new file mode 100644 index 0000000..d0ccdd0 --- /dev/null +++ b/example/.gitignore @@ -0,0 +1,3 @@ +/.bundle/ +/vendor/bundle +/lib/bundler/man/ diff --git a/example/Gemfile b/example/Gemfile new file mode 100644 index 0000000..5fd89bc --- /dev/null +++ b/example/Gemfile @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +source "https://rubygems.org" + +gem "graphql-breadth", path: ".." + +gem "rack", "~> 3.1" +gem "rackup", "~> 2.2" +gem "puma", "~> 6.0" diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000..61e29a0 --- /dev/null +++ b/example/README.md @@ -0,0 +1,72 @@ +# graphql-breadth Rack example + +This is a small standalone Rack app that serves GraphiQL at `/` and executes GraphQL requests through `GraphQL::Breadth::Executor` at `/graphql`. + +```sh +cd example +bundle install +bundle exec rackup +``` + +Then open `http://localhost:9292`. The root redirects to `/query`. + +The top nav switches between: + +- `/query` for normal JSON query and mutation requests. +- `/defer` for `@defer` over SSE. GraphiQL's response pane shows the current merged result snapshot, while the "SSE Stream" panel shows the raw SSE timeline with receive timing. +- `/subscriptions` for subscriptions over SSE. The "SSE Events" panel fills in a live list of raw events, and the top-right "Send Event" button broadcasts a server event to open subscriptions. + +The stream routes start with GraphiQL's editor maximized so the operation is the main workspace while the right sidebar records each raw SSE payload as it arrives. + +The `Greeting.delayed(seconds:)` field accepts an integer sleep duration. The defer example uses five seconds for the outer deferred field and ten seconds for the nested one so each payload is easy to see. + +## JSON query + +```sh +curl http://localhost:9292/graphql \\ + -H 'Content-Type: application/json' \\ + -d '{"query":"{ hello(name: \"Rack\") { message delayed(seconds: 0) sequence } }"}' +``` + +## JSON mutation + +```sh +curl http://localhost:9292/graphql \\ + -H 'Content-Type: application/json' \\ + -d '{"query":"mutation { echo(message: \"Hello mutation\") }"}' +``` + +## Incremental `@defer` over SSE + +```sh +curl -N http://localhost:9292/graphql \\ + -H 'Accept: text/event-stream' \\ + -H 'Content-Type: application/json' \\ + -d '{"query":"{ hello { message ... @defer(label: \"later\") { delayed(seconds: 5) ... @defer(label: \"later2\") { lazy: delayed(seconds: 10) } } } }"}' +``` + +## Incremental `@defer` over multipart + +```sh +curl -N http://localhost:9292/graphql \\ + -H 'Accept: multipart/mixed' \\ + -H 'Content-Type: application/json' \\ + -d '{"query":"{ hello { message ... @defer(label: \"later\") { delayed(seconds: 5) ... @defer(label: \"later2\") { lazy: delayed(seconds: 10) } } } }"}' +``` + +## Subscription over SSE + +```sh +curl -N http://localhost:9292/graphql \\ + -H 'Accept: text/event-stream' \\ + -H 'Content-Type: application/json' \\ + -d '{"query":"subscription { greetings { message sequence } }"}' +``` + +Then trigger events from another terminal: + +```sh +curl http://localhost:9292/events/greeting \\ + -H 'Content-Type: application/json' \\ + -d '{"name":"SSE"}' +``` diff --git a/example/app.rb b/example/app.rb new file mode 100644 index 0000000..736096f --- /dev/null +++ b/example/app.rb @@ -0,0 +1,377 @@ +# frozen_string_literal: true + +require "erb" +require "json" +require "rack/request" +require "rack/response" +require "thread" + +require_relative "lib/example/schema" + +module Example + class App + GRAPHQL_PATH = "/graphql" + EVENT_PATH = "/events/greeting" + MULTIPART_BOUNDARY = "graphql" + VIEW_ROOT = File.expand_path("views", __dir__) + MODES = { + "query" => { + "path" => "/query", + "label" => "Query/Mutation", + "transport" => "json", + "defaultQuery" => <<~GRAPHQL, + query Hello($name: String) { + hello(name: $name) { + message + sequence + } + serverTime + } + + mutation Echo($message: String!) { + echo(message: $message) + } + GRAPHQL + "variables" => { + "name" => "breadth", + "message" => "Hello from a graphql-breadth mutation", + }, + }, + "defer" => { + "path" => "/defer", + "label" => "Defer", + "transport" => "sse", + "defaultQuery" => <<~GRAPHQL, + query DeferredHello($name: String, $outerDelay: Int, $innerDelay: Int) { + hello(name: $name) { + message + ... @defer(label: "later") { + delayed(seconds: $outerDelay) + ... @defer(label: "later2") { + lazy: delayed(seconds: $innerDelay) + } + } + } + } + GRAPHQL + "variables" => { + "name" => "breadth", + "outerDelay" => 5, + "innerDelay" => 10, + }, + "inspector" => { + "title" => "SSE Stream", + "empty" => "Run the operation to see SSE payloads", + }, + }, + "subscriptions" => { + "path" => "/subscriptions", + "label" => "Subscriptions", + "transport" => "sse", + "defaultQuery" => <<~GRAPHQL, + subscription Greetings { + greetings { + message + sequence + } + } + GRAPHQL + "variables" => {}, + "inspector" => { + "title" => "SSE Events", + "empty" => "Start the subscription, then send an event", + }, + "trigger" => { + "path" => EVENT_PATH, + "label" => "Send Event", + }, + }, + }.freeze + NAV_ITEMS = MODES.map do |id, config| + { + "id" => id, + "path" => config.fetch("path"), + "label" => config.fetch("label"), + } + end.freeze + + class EventBus + def initialize + @mutex = Mutex.new + @sequence = 0 + @subscribers = [] + end + + def subscribe + queue = Queue.new + + @mutex.synchronize do + @subscribers << queue + end + + Enumerator.new do |events| + begin + loop do + events << queue.pop + end + ensure + @mutex.synchronize do + @subscribers.delete(queue) + end + end + end + end + + def publish(name:) + event = nil + subscribers = nil + + @mutex.synchronize do + @sequence += 1 + event = Example::Schema.greeting(name: name, sequence: @sequence) + subscribers = @subscribers.dup + end + + subscribers.each { |queue| queue << event } + + { + "event" => event, + "subscribers" => subscribers.length, + } + end + end + + def initialize(event_bus: EventBus.new) + @event_bus = event_bus + end + + def call(env) + request = Rack::Request.new(env) + + return redirect_response(MODES.fetch("query").fetch("path")) if request.get? && request.path_info == "/" + + if request.get? + mode_id = mode_id_for_path(request.path_info) + return graphiql_response(mode_id) if mode_id + end + + return cors_response if request.options? && [GRAPHQL_PATH, EVENT_PATH].include?(request.path_info) + return event_response(request) if request.post? && request.path_info == EVENT_PATH + return graphql_response(request) if graphql_request?(request) + + json_response({ "errors" => [{ "message" => "Not found" }] }, status: 404) + rescue JSON::ParserError + json_response({ "errors" => [{ "message" => "Request body must be valid JSON" }] }, status: 400) + rescue GraphQL::ParseError => error + json_response({ "errors" => [error.to_h] }, status: 400) + rescue StandardError => error + json_response({ "errors" => [{ "message" => error.message }] }, status: 500) + end + + private + + def graphql_request?(request) + request.path_info == GRAPHQL_PATH && (request.get? || request.post?) + end + + def graphql_response(request) + params = graphql_params(request) + document = GraphQL.parse(params.fetch("query", "")) + validation_errors = Example::Schema::GRAPHQL_SCHEMA.validate(document) + + unless validation_errors.empty? + return json_response({ "errors" => validation_errors.map(&:to_h) }, status: 400) + end + + executor = Example::Schema.executor( + document, + variables: params.fetch("variables", {}), + context: { + request_id: request.get_header("HTTP_X_REQUEST_ID"), + event_bus: @event_bus, + }, + ) + + if executor.subscription? + return subscription_response(executor, request) + end + + if accepts?(request, "text/event-stream") + incremental = executor.incremental_result + return sse_response(each_incremental_payload(incremental)) + end + + if accepts?(request, "multipart/mixed") + incremental = executor.incremental_result + return multipart_response(each_incremental_payload(incremental)) + end + + json_response(executor.result) + end + + def subscription_response(executor, request) + stream = executor.subscribe + + return json_response(stream, status: 400) if stream.is_a?(Hash) + + if accepts?(request, "multipart/mixed") + multipart_response(stream.each) + else + sse_response(stream.each) + end + end + + def graphql_params(request) + raw_params = if request.get? + request.params + elsif request.media_type == "application/graphql" + { "query" => request.body.read } + else + body = request.body.read + body.empty? ? {} : JSON.parse(body) + end + + raw_params = stringify_keys(raw_params) + raw_params["variables"] = parse_variables(raw_params["variables"]) + raw_params + end + + def event_response(request) + params = event_params(request) + name = params.fetch("name", "SSE").to_s + name = "SSE" if name.empty? + + json_response(@event_bus.publish(name: name)) + end + + def event_params(request) + body = request.body.read + body.empty? ? {} : stringify_keys(JSON.parse(body)) + end + + def parse_variables(value) + case value + when nil, "" + {} + when String + JSON.parse(value) + when Hash + value + else + raise JSON::ParserError, "variables must be a JSON object" + end + end + + def stringify_keys(hash) + hash.each_with_object({}) do |(key, value), out| + out[key.to_s] = value + end + end + + def accepts?(request, content_type) + request.get_header("HTTP_ACCEPT").to_s.include?(content_type) + end + + def each_incremental_payload(result) + Enumerator.new do |yielder| + yielder << result.initial_result + result.subsequent_results.each { |payload| yielder << payload } + end + end + + def sse_response(payloads) + body = Enumerator.new do |yielder| + payloads.each do |payload| + yielder << "event: next\n" + yielder << "data: #{JSON.generate(payload)}\n\n" + end + yielder << "event: complete\n" + yielder << "data: {}\n\n" + end + + [ + 200, + cors_headers.merge( + "content-type" => "text/event-stream; charset=utf-8", + "cache-control" => "no-cache", + "x-accel-buffering" => "no", + ), + body, + ] + end + + def multipart_response(payloads) + body = Enumerator.new do |yielder| + payloads.each do |payload| + yielder << "--#{MULTIPART_BOUNDARY}\r\n" + yielder << "Content-Type: application/json; charset=utf-8\r\n\r\n" + yielder << JSON.generate(payload) + yielder << "\r\n" + end + yielder << "--#{MULTIPART_BOUNDARY}--\r\n" + end + + [ + 200, + cors_headers.merge( + "content-type" => "multipart/mixed; boundary=\"#{MULTIPART_BOUNDARY}\"", + ), + body, + ] + end + + def json_response(payload, status: 200) + [ + status, + cors_headers.merge("content-type" => "application/json; charset=utf-8"), + [JSON.pretty_generate(payload)], + ] + end + + def graphiql_response(mode_id) + Rack::Response.new( + render_view( + "graphiql", + current_mode: mode_id, + mode_config: MODES.fetch(mode_id), + nav_items: NAV_ITEMS, + ), + 200, + cors_headers.merge("content-type" => "text/html; charset=utf-8"), + ).finish + end + + def redirect_response(location) + [ + 302, + cors_headers.merge("location" => location), + [], + ] + end + + def cors_response + [204, cors_headers, []] + end + + def cors_headers + { + "access-control-allow-origin" => "*", + "access-control-allow-headers" => "Content-Type, Accept, X-Request-ID", + "access-control-allow-methods" => "GET, POST, OPTIONS", + } + end + + def mode_id_for_path(path) + MODES.find { |_, config| config.fetch("path") == path }&.first + end + + def render_view(name, locals = {}) + template = ERB.new(File.read(File.join(VIEW_ROOT, "#{name}.erb"))) + view_binding = binding + locals.each do |key, value| + view_binding.local_variable_set(key, value) + end + template.result(view_binding) + end + end +end diff --git a/example/config.ru b/example/config.ru new file mode 100644 index 0000000..685ddca --- /dev/null +++ b/example/config.ru @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +require_relative "app" + +run Example::App.new diff --git a/example/lib/example/schema.rb b/example/lib/example/schema.rb new file mode 100644 index 0000000..ab0c72e --- /dev/null +++ b/example/lib/example/schema.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +require "graphql/breadth" +require "time" + +module Example + module Schema + SDL = <<~GRAPHQL + schema { + query: Query + mutation: Mutation + subscription: Subscription + } + + directive @defer(if: Boolean = true, label: String) on FRAGMENT_SPREAD | INLINE_FRAGMENT + + type Query { + hello(name: String = "world"): Greeting! + serverTime: String! + } + + type Mutation { + echo(message: String!): String! + } + + type Subscription { + greetings: Greeting! + } + + type Greeting { + message: String! + delayed(seconds: Int = 5): String! + sequence: Int! + } + GRAPHQL + + GRAPHQL_SCHEMA = GraphQL::Schema.from_definition(SDL) + + class HelloResolver < GraphQL::Breadth::FieldResolver + def resolve(exec_field, _context) + name = exec_field.arguments[:name] || "world" + exec_field.resolve_all(Example::Schema.greeting(name: name, sequence: 1)) + end + end + + class ServerTimeResolver < GraphQL::Breadth::FieldResolver + def resolve(exec_field, _context) + exec_field.resolve_all(Time.now.utc.iso8601) + end + end + + class EchoResolver < GraphQL::Breadth::FieldResolver + def resolve(exec_field, _context) + exec_field.resolve_all(exec_field.arguments.fetch(:message)) + end + end + + class DelayedResolver < GraphQL::Breadth::FieldResolver + def resolve(exec_field, _context) + seconds = exec_field.arguments[:seconds] + seconds = 5 if seconds.nil? + seconds = [seconds.to_i, 0].max + + sleep seconds + exec_field.map_objects { "Delivered after #{seconds} seconds by graphql-breadth @defer." } + end + end + + class GreetingsSubscriptionResolver < GraphQL::Breadth::FieldResolver + def subscribe(_exec_field, context) + event_bus = context[:event_bus] + + raise GraphQL::ExecutionError, "No event bus configured" unless event_bus + + event_bus.subscribe + end + + def resolve(exec_field, _context) + exec_field.map_objects(&:itself) + end + end + + RESOLVERS = { + "Query" => { + "hello" => HelloResolver.new, + "serverTime" => ServerTimeResolver.new, + }, + "Mutation" => { + "echo" => EchoResolver.new, + }, + "Subscription" => { + "greetings" => GreetingsSubscriptionResolver.new, + }, + "Greeting" => { + "message" => GraphQL::Breadth::HashKeyResolver.new("message"), + "delayed" => DelayedResolver.new, + "sequence" => GraphQL::Breadth::HashKeyResolver.new("sequence"), + }, + }.freeze + + module_function + + def executor(document, variables: {}, context: {}) + GraphQL::Breadth::Executor.new( + GRAPHQL_SCHEMA, + document, + resolvers: RESOLVERS, + root_object: {}, + variables: variables, + context: context, + ) + end + + def greeting(name:, sequence:) + { + "message" => "Hello, #{name}!", + "delayed" => "Delivered later by graphql-breadth @defer.", + "sequence" => sequence, + } + end + end +end diff --git a/example/views/graphiql.erb b/example/views/graphiql.erb new file mode 100644 index 0000000..baf72c0 --- /dev/null +++ b/example/views/graphiql.erb @@ -0,0 +1,669 @@ + + + + + + graphql-breadth example + + + + + +
+ + + + + + diff --git a/lib/graphql/breadth/executor.rb b/lib/graphql/breadth/executor.rb index 6d2dec6..3cfcd18 100644 --- a/lib/graphql/breadth/executor.rb +++ b/lib/graphql/breadth/executor.rb @@ -1012,47 +1012,46 @@ def execute_subscription #: (Enumerator::Yielder) -> void def execute_next_incremental_result(yielder) - pending_payloads = [] - incremental_payloads = [] - completed_deliveries = [] - completed_errors_by_delivery = {}.compare_by_identity - loop do ready_scopes = @incremental.ready_scopes break if ready_scopes.empty? + exec_scope = ready_scopes.first + pending_payloads = [] + incremental_payloads = [] + completed_deliveries = [] + completed_errors_by_delivery = {}.compare_by_identity + initial_error_count = @invalidated_results.size - run!(@planner.plan_scopes(ready_scopes)) + run!(@planner.plan_scopes([exec_scope])) has_errors = @invalidated_results.size > initial_error_count - ready_scopes.each do |exec_scope| - deliveries = @incremental.deliveries_for(exec_scope) - deliveries.each do |index, path, deferred_deliveries| - data = exec_scope.results[index] - errors = EMPTY_ARRAY - if has_errors - data, errors = error_result_formatter.format_object(exec_scope.parent_type, exec_scope.selections, data, path) - end - - if data.nil? && !errors.empty? - deferred_deliveries.each { (completed_errors_by_delivery[_1] ||= []).concat(errors) } - else - incremental_payloads << @incremental.incremental_payload(deferred_deliveries, path, data, errors:) - end - completed_deliveries.concat(deferred_deliveries) + deliveries = @incremental.deliveries_for(exec_scope) + deliveries.each do |index, path, deferred_deliveries| + data = exec_scope.results[index] + errors = EMPTY_ARRAY + if has_errors + data, errors = error_result_formatter.format_object(exec_scope.parent_type, exec_scope.selections, data, path) end - exec_scope.executed = true - pending_payloads.concat(@incremental.pending_payloads(@incremental.prepare_pending)) + if data.nil? && !errors.empty? + deferred_deliveries.each { (completed_errors_by_delivery[_1] ||= []).concat(errors) } + else + incremental_payloads << @incremental.incremental_payload(deferred_deliveries, path, data, errors:) + end + completed_deliveries.concat(deferred_deliveries) end - end - completed_payloads = @incremental.completed_payloads(completed_deliveries, errors_by_delivery: completed_errors_by_delivery) - payload = { "hasNext" => false } - payload["pending"] = pending_payloads unless pending_payloads.empty? - payload["incremental"] = incremental_payloads unless incremental_payloads.empty? - payload["completed"] = completed_payloads unless completed_payloads.empty? - yielder << payload + exec_scope.executed = true + pending_payloads.concat(@incremental.pending_payloads(@incremental.prepare_pending)) + + completed_payloads = @incremental.completed_payloads(completed_deliveries, errors_by_delivery: completed_errors_by_delivery) + payload = { "hasNext" => @incremental.has_next? } + payload["pending"] = pending_payloads unless pending_payloads.empty? + payload["incremental"] = incremental_payloads unless incremental_payloads.empty? + payload["completed"] = completed_payloads unless completed_payloads.empty? + yielder << payload + end end #: (?data: Util::NilLike | graphql_result | nil, ?errors: Array[error_hash]) -> graphql_result diff --git a/lib/graphql/breadth/incremental/context.rb b/lib/graphql/breadth/incremental/context.rb index c334b38..8bca378 100644 --- a/lib/graphql/breadth/incremental/context.rb +++ b/lib/graphql/breadth/incremental/context.rb @@ -45,6 +45,11 @@ def deferred? @deferred_scopes.any? end + #: -> bool + def has_next? + @deferred_scopes.any? { _1.announced? && !_1.executed? } + end + #: -> Array[DeferredDelivery] def prepare_pending @deferred_scopes.each do |deferred_scope| @@ -94,6 +99,7 @@ def incremental_payload(deliveries, path, data, errors: EMPTY_ARRAY) def completed_payloads(deliveries, errors_by_delivery: EMPTY_OBJECT) deliveries.uniq.filter_map do |delivery| next if @completed_deliveries[delivery] + next unless delivery_finished?(delivery) @completed_deliveries[delivery] = true @publisher.completed(delivery, errors: errors_by_delivery[delivery] || EMPTY_ARRAY) @@ -148,6 +154,23 @@ def nearest_delivery_for(defer_usage, path) .max_by { _1.path.length } end + #: (DeferredDelivery) -> bool + def delivery_finished?(delivery) + defer_usage = defer_usage_for_delivery(delivery) + return true unless defer_usage + + @deferred_scopes.none? { !_1.executed? && _1.defer_usages.include?(defer_usage) } + end + + #: (DeferredDelivery) -> DeferUsage? + def defer_usage_for_delivery(delivery) + @deliveries_by_usage.each do |defer_usage, deliveries| + return defer_usage if deliveries.include?(delivery) + end + + nil + end + # True when the formatted initial result null-bubbled away the object at `path` # (e.g. a non-null child error nulled a nullable list element). Deferred execution rooted # at such a path must not be announced or delivered: there is no live object to patch. diff --git a/test/graphql/breadth/executor/incremental_test.rb b/test/graphql/breadth/executor/incremental_test.rb index db0497e..c2b5ccc 100644 --- a/test/graphql/breadth/executor/incremental_test.rb +++ b/test/graphql/breadth/executor/incremental_test.rb @@ -452,6 +452,53 @@ def test_incremental_result_deduplicates_nested_defers_on_the_same_object ) end + def test_incremental_result_separately_emits_nested_defers_on_the_same_object + result = build_executor(%|{ + products(first: 1) { + nodes { + id + ... @defer(label: "Outer") { + title + ... @defer(label: "Inner") { + must + } + } + } + } + }|, source: one_product_source).incremental_result + + assert_equal( + { + "data" => { + "products" => { + "nodes" => [{ "id" => "gid://shopify/Product/1" }], + }, + }, + "pending" => [ + { "id" => "0", "path" => ["products", "nodes", 0], "label" => "Outer" }, + { "id" => "1", "path" => ["products", "nodes", 0], "label" => "Inner" }, + ], + "hasNext" => true, + }, + result.initial_result, + ) + assert_equal( + [ + { + "incremental" => [{ "data" => { "title" => "Banana" }, "id" => "0" }], + "completed" => [{ "id" => "0" }], + "hasNext" => true, + }, + { + "incremental" => [{ "data" => { "must" => "yes" }, "id" => "1" }], + "completed" => [{ "id" => "1" }], + "hasNext" => false, + }, + ], + result.subsequent_results.to_a, + ) + end + def test_incremental_result_defers_top_level_fragment result = build_executor(%|{ ... @defer(label: "Top") { @@ -557,14 +604,18 @@ def test_incremental_result_separately_emits_fragments_with_different_labels result.initial_result, ) assert_equal( - [{ - "incremental" => [ - { "data" => { "id" => "gid://shopify/Product/1" }, "id" => "0" }, - { "data" => { "title" => "Banana" }, "id" => "1" }, - ], - "completed" => [{ "id" => "0" }, { "id" => "1" }], - "hasNext" => false, - }], + [ + { + "incremental" => [{ "data" => { "id" => "gid://shopify/Product/1" }, "id" => "0" }], + "completed" => [{ "id" => "0" }], + "hasNext" => true, + }, + { + "incremental" => [{ "data" => { "title" => "Banana" }, "id" => "1" }], + "completed" => [{ "id" => "1" }], + "hasNext" => false, + }, + ], result.subsequent_results.to_a, ) end @@ -595,30 +646,37 @@ def test_incremental_result_uses_sub_path_for_nested_payloads result.initial_result, ) assert_equal( - [{ - "incremental" => [ - { + [ + { + "incremental" => [{ "data" => { "products" => { "nodes" => [{}], }, }, "id" => "0", - }, - { + }], + "hasNext" => true, + }, + { + "incremental" => [{ "data" => { "id" => "gid://shopify/Product/1" }, "id" => "0", "subPath" => ["products", "nodes", 0], - }, - { + }], + "completed" => [{ "id" => "0" }], + "hasNext" => true, + }, + { + "incremental" => [{ "data" => { "title" => "Banana" }, "id" => "1", "subPath" => ["products", "nodes", 0], - }, - ], - "completed" => [{ "id" => "0" }, { "id" => "1" }], - "hasNext" => false, - }], + }], + "completed" => [{ "id" => "1" }], + "hasNext" => false, + }, + ], result.subsequent_results.to_a, ) end @@ -691,14 +749,14 @@ def test_incremental_result_supports_nested_defer result.initial_result, ) assert_equal( - [{ - "pending" => [{ - "id" => "1", - "path" => ["products", "nodes", 0, "variants", "nodes", 0], - "label" => "Inner", - }], - "incremental" => [ - { + [ + { + "pending" => [{ + "id" => "1", + "path" => ["products", "nodes", 0, "variants", "nodes", 0], + "label" => "Inner", + }], + "incremental" => [{ "data" => { "title" => "Banana", "variants" => { @@ -706,15 +764,16 @@ def test_incremental_result_supports_nested_defer }, }, "id" => "0", - }, - { "data" => { "title" => "Small Banana" }, "id" => "1" }, - ], - "completed" => [ - { "id" => "0" }, - { "id" => "1" }, - ], - "hasNext" => false, - }], + }], + "completed" => [{ "id" => "0" }], + "hasNext" => true, + }, + { + "incremental" => [{ "data" => { "title" => "Small Banana" }, "id" => "1" }], + "completed" => [{ "id" => "1" }], + "hasNext" => false, + }, + ], result.subsequent_results.to_a, ) end @@ -908,7 +967,7 @@ def test_ready_deferred_executions_batch_lazy_work result.subsequent_results.to_a assert_equal( - [["Banana", "Apple", "Yellow", "Red"]], + [["Banana", "Apple"], ["Yellow", "Red"]], BatchTrackingLoader.perform_keys, ) end From 998df06e07c9d224c49dfa6d3a3926036b80578b Mon Sep 17 00:00:00 2001 From: Greg MacWilliam Date: Sun, 5 Jul 2026 23:42:44 -0400 Subject: [PATCH 2/2] formalize example. --- example/Gemfile | 1 + example/README.md | 20 ++- example/app.rb | 107 +++++++------- example/lib/example/card_database.rb | 48 +++++++ .../lib/example/loaders/magic_card_rulings.rb | 36 +++++ .../lib/example/loaders/magic_card_sets.rb | 34 +++++ example/lib/example/loaders/magic_cards.rb | 50 +++++++ .../lib/example/loaders/scryfall_helpers.rb | 56 ++++++++ .../example/resolvers/magic_card/rulings.rb | 20 +++ .../lib/example/resolvers/magic_card/set.rb | 20 +++ .../resolvers/mutation/add_another_card.rb | 24 ++++ .../example/resolvers/query/magic_cards.rb | 25 ++++ .../resolvers/subscription/card_added.rb | 36 +++++ example/lib/example/schema.rb | 134 ++++++++---------- example/views/graphiql.erb | 7 +- lib/graphql/breadth/executor.rb | 22 +-- test/graphql/breadth/executor_test.rb | 52 +++++++ test/test_helper.rb | 3 +- 18 files changed, 546 insertions(+), 149 deletions(-) create mode 100644 example/lib/example/card_database.rb create mode 100644 example/lib/example/loaders/magic_card_rulings.rb create mode 100644 example/lib/example/loaders/magic_card_sets.rb create mode 100644 example/lib/example/loaders/magic_cards.rb create mode 100644 example/lib/example/loaders/scryfall_helpers.rb create mode 100644 example/lib/example/resolvers/magic_card/rulings.rb create mode 100644 example/lib/example/resolvers/magic_card/set.rb create mode 100644 example/lib/example/resolvers/mutation/add_another_card.rb create mode 100644 example/lib/example/resolvers/query/magic_cards.rb create mode 100644 example/lib/example/resolvers/subscription/card_added.rb diff --git a/example/Gemfile b/example/Gemfile index 5fd89bc..32c0d90 100644 --- a/example/Gemfile +++ b/example/Gemfile @@ -4,6 +4,7 @@ source "https://rubygems.org" gem "graphql-breadth", path: ".." +gem "async-http" gem "rack", "~> 3.1" gem "rackup", "~> 2.2" gem "puma", "~> 6.0" diff --git a/example/README.md b/example/README.md index 61e29a0..d8cc8c7 100644 --- a/example/README.md +++ b/example/README.md @@ -14,18 +14,16 @@ The top nav switches between: - `/query` for normal JSON query and mutation requests. - `/defer` for `@defer` over SSE. GraphiQL's response pane shows the current merged result snapshot, while the "SSE Stream" panel shows the raw SSE timeline with receive timing. -- `/subscriptions` for subscriptions over SSE. The "SSE Events" panel fills in a live list of raw events, and the top-right "Send Event" button broadcasts a server event to open subscriptions. +- `/subscriptions` for subscriptions over SSE. The "Card Events" panel fills in a live list of raw events, and the top-right "Add Card" button runs the `addAnotherCard` mutation to publish to open subscriptions. The stream routes start with GraphiQL's editor maximized so the operation is the main workspace while the right sidebar records each raw SSE payload as it arrives. -The `Greeting.delayed(seconds:)` field accepts an integer sleep duration. The defer example uses five seconds for the outer deferred field and ten seconds for the nested one so each payload is easy to see. - ## JSON query ```sh curl http://localhost:9292/graphql \\ -H 'Content-Type: application/json' \\ - -d '{"query":"{ hello(name: \"Rack\") { message delayed(seconds: 0) sequence } }"}' + -d '{"query":"{ magicCards { id name imageUri set { code name } } }"}' ``` ## JSON mutation @@ -33,7 +31,7 @@ curl http://localhost:9292/graphql \\ ```sh curl http://localhost:9292/graphql \\ -H 'Content-Type: application/json' \\ - -d '{"query":"mutation { echo(message: \"Hello mutation\") }"}' + -d '{"query":"mutation { addAnotherCard { id name } }"}' ``` ## Incremental `@defer` over SSE @@ -42,7 +40,7 @@ curl http://localhost:9292/graphql \\ curl -N http://localhost:9292/graphql \\ -H 'Accept: text/event-stream' \\ -H 'Content-Type: application/json' \\ - -d '{"query":"{ hello { message ... @defer(label: \"later\") { delayed(seconds: 5) ... @defer(label: \"later2\") { lazy: delayed(seconds: 10) } } } }"}' + -d '{"query":"{ magicCards { id name ... @defer(label: \"rulings\") { rulings { date comment } } } }"}' ``` ## Incremental `@defer` over multipart @@ -51,7 +49,7 @@ curl -N http://localhost:9292/graphql \\ curl -N http://localhost:9292/graphql \\ -H 'Accept: multipart/mixed' \\ -H 'Content-Type: application/json' \\ - -d '{"query":"{ hello { message ... @defer(label: \"later\") { delayed(seconds: 5) ... @defer(label: \"later2\") { lazy: delayed(seconds: 10) } } } }"}' + -d '{"query":"{ magicCards { id name ... @defer(label: \"rulings\") { rulings { date comment } } } }"}' ``` ## Subscription over SSE @@ -60,13 +58,13 @@ curl -N http://localhost:9292/graphql \\ curl -N http://localhost:9292/graphql \\ -H 'Accept: text/event-stream' \\ -H 'Content-Type: application/json' \\ - -d '{"query":"subscription { greetings { message sequence } }"}' + -d '{"query":"subscription { cardAdded { id name } }"}' ``` -Then trigger events from another terminal: +Then add a card from another terminal: ```sh -curl http://localhost:9292/events/greeting \\ +curl http://localhost:9292/graphql \\ -H 'Content-Type: application/json' \\ - -d '{"name":"SSE"}' + -d '{"query":"mutation { addAnotherCard { id name } }"}' ``` diff --git a/example/app.rb b/example/app.rb index 736096f..801a2f1 100644 --- a/example/app.rb +++ b/example/app.rb @@ -11,7 +11,6 @@ module Example class App GRAPHQL_PATH = "/graphql" - EVENT_PATH = "/events/greeting" MULTIPART_BOUNDARY = "graphql" VIEW_ROOT = File.expand_path("views", __dir__) MODES = { @@ -20,45 +19,47 @@ class App "label" => "Query/Mutation", "transport" => "json", "defaultQuery" => <<~GRAPHQL, - query Hello($name: String) { - hello(name: $name) { - message - sequence + query MagicCards { + magicCards { + id + name + imageUri + set { + code + name + } } - serverTime } - mutation Echo($message: String!) { - echo(message: $message) + mutation AddAnotherCard { + addAnotherCard { + id + name + imageUri + } } GRAPHQL - "variables" => { - "name" => "breadth", - "message" => "Hello from a graphql-breadth mutation", - }, + "variables" => {}, }, "defer" => { "path" => "/defer", "label" => "Defer", "transport" => "sse", "defaultQuery" => <<~GRAPHQL, - query DeferredHello($name: String, $outerDelay: Int, $innerDelay: Int) { - hello(name: $name) { - message - ... @defer(label: "later") { - delayed(seconds: $outerDelay) - ... @defer(label: "later2") { - lazy: delayed(seconds: $innerDelay) + query DeferredCardRulings { + magicCards { + id + name + ... @defer(label: "rulings") { + rulings { + date + comment } } } } GRAPHQL - "variables" => { - "name" => "breadth", - "outerDelay" => 5, - "innerDelay" => 10, - }, + "variables" => {}, "inspector" => { "title" => "SSE Stream", "empty" => "Run the operation to see SSE payloads", @@ -69,21 +70,31 @@ class App "label" => "Subscriptions", "transport" => "sse", "defaultQuery" => <<~GRAPHQL, - subscription Greetings { - greetings { - message - sequence + subscription CardAdded { + cardAdded { + id + name } } GRAPHQL "variables" => {}, "inspector" => { - "title" => "SSE Events", - "empty" => "Start the subscription, then send an event", + "title" => "Card Events", + "empty" => "Start the subscription, then add a card", }, "trigger" => { - "path" => EVENT_PATH, - "label" => "Send Event", + "label" => "Add Card", + "graphqlParams" => { + "query" => <<~GRAPHQL, + mutation AddAnotherCard { + addAnotherCard { + id + name + } + } + GRAPHQL + "operationName" => "AddAnotherCard", + }, }, }, }.freeze @@ -98,7 +109,6 @@ class App class EventBus def initialize @mutex = Mutex.new - @sequence = 0 @subscribers = [] end @@ -122,13 +132,12 @@ def subscribe end end - def publish(name:) + def publish(card_id:) event = nil subscribers = nil @mutex.synchronize do - @sequence += 1 - event = Example::Schema.greeting(name: name, sequence: @sequence) + event = { "card_id" => card_id } subscribers = @subscribers.dup end @@ -141,8 +150,9 @@ def publish(name:) end end - def initialize(event_bus: EventBus.new) + def initialize(event_bus: EventBus.new, card_database: Example::Schema.card_database) @event_bus = event_bus + @card_database = card_database end def call(env) @@ -155,8 +165,7 @@ def call(env) return graphiql_response(mode_id) if mode_id end - return cors_response if request.options? && [GRAPHQL_PATH, EVENT_PATH].include?(request.path_info) - return event_response(request) if request.post? && request.path_info == EVENT_PATH + return cors_response if request.options? && request.path_info == GRAPHQL_PATH return graphql_response(request) if graphql_request?(request) json_response({ "errors" => [{ "message" => "Not found" }] }, status: 404) @@ -186,12 +195,18 @@ def graphql_response(request) executor = Example::Schema.executor( document, variables: params.fetch("variables", {}), + root_object: @card_database, + operation_name: params["operationName"], context: { request_id: request.get_header("HTTP_X_REQUEST_ID"), event_bus: @event_bus, }, ) + unless executor.query.selected_operation + return json_response({ "errors" => executor.query.static_errors.map(&:to_h) }, status: 400) + end + if executor.subscription? return subscription_response(executor, request) end @@ -233,22 +248,10 @@ def graphql_params(request) raw_params = stringify_keys(raw_params) raw_params["variables"] = parse_variables(raw_params["variables"]) + raw_params["operationName"] = nil if raw_params["operationName"].to_s.empty? raw_params end - def event_response(request) - params = event_params(request) - name = params.fetch("name", "SSE").to_s - name = "SSE" if name.empty? - - json_response(@event_bus.publish(name: name)) - end - - def event_params(request) - body = request.body.read - body.empty? ? {} : stringify_keys(JSON.parse(body)) - end - def parse_variables(value) case value when nil, "" diff --git a/example/lib/example/card_database.rb b/example/lib/example/card_database.rb new file mode 100644 index 0000000..bfd9f98 --- /dev/null +++ b/example/lib/example/card_database.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Example + class CardDatabase + POSSIBLE_CARD_IDS = [ + "9a879b60-4381-447d-8a5a-8e0b6a1d49ca", + "711d4d54-5520-4de8-9b93-79902ed8e562", + "312a6058-de08-487d-95bd-b3c56807fdd6", + "386ea9eb-abc1-4862-aa2d-8fb808d79490", + ].freeze + + attr_reader :members, :options + + def initialize(card_ids = nil) + card_ids ||= [POSSIBLE_CARD_IDS.sample] + @members = Set.new(card_ids.map(&:to_s)) + @options = Set.new(POSSIBLE_CARD_IDS) - members + end + + def add(card_id) + card_id = card_id.to_s + members.add(card_id) + options.delete(card_id) + self + end + + def remove(card_id) + card_id = card_id.to_s + members.delete(card_id) + options.add(card_id) if POSSIBLE_CARD_IDS.include?(card_id) + self + end + + def add_another_card(context: nil) + raise GraphQL::ExecutionError, "No more cards to add" if options.empty? + + card_id = options.to_a.sample + add(card_id) + event_bus = context && context[:event_bus] + event_bus&.publish(card_id: card_id) + card_id + end + + def card_ids + members.to_a + end + end +end diff --git a/example/lib/example/loaders/magic_card_rulings.rb b/example/lib/example/loaders/magic_card_rulings.rb new file mode 100644 index 0000000..bbf9992 --- /dev/null +++ b/example/lib/example/loaders/magic_card_rulings.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "graphql/breadth" + +require_relative "scryfall_helpers" + +module Example + module Loaders + class MagicCardRulings < GraphQL::Breadth::LazyLoader + include ScryfallHelpers + + async limit: 2, resource: :scryfall_api, timeout: 10 + + def map? + true + end + + def perform_map(card_ids, _context) + async_map(card_ids) do |card_id| + fetch_scryfall_json("/cards/#{card_id}/rulings") + .fetch("data") + .map { normalize_ruling(_1) } + end + end + + private + + def normalize_ruling(ruling) + { + "date" => ruling.fetch("published_at"), + "comment" => ruling.fetch("comment"), + } + end + end + end +end diff --git a/example/lib/example/loaders/magic_card_sets.rb b/example/lib/example/loaders/magic_card_sets.rb new file mode 100644 index 0000000..446d413 --- /dev/null +++ b/example/lib/example/loaders/magic_card_sets.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "graphql/breadth" + +require_relative "scryfall_helpers" + +module Example + module Loaders + class MagicCardSets < GraphQL::Breadth::LazyLoader + include ScryfallHelpers + + async limit: 2, resource: :scryfall_api, timeout: 10 + + def map? + true + end + + def perform_map(set_ids, _context) + async_map(set_ids) { normalize_set(fetch_scryfall_json("/sets/#{_1}")) } + end + + private + + def normalize_set(set) + { + "id" => set.fetch("id"), + "code" => set.fetch("code"), + "name" => set.fetch("name"), + "uri" => set["scryfall_uri"] || set.fetch("uri"), + } + end + end + end +end diff --git a/example/lib/example/loaders/magic_cards.rb b/example/lib/example/loaders/magic_cards.rb new file mode 100644 index 0000000..01e4292 --- /dev/null +++ b/example/lib/example/loaders/magic_cards.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "graphql/breadth" + +require_relative "scryfall_helpers" + +module Example + module Loaders + class MagicCards < GraphQL::Breadth::LazyLoader + include ScryfallHelpers + + async limit: 2, resource: :scryfall_api, timeout: 10 + + def map? + true + end + + def perform_map(keys, _context) + fetch_cards(keys) + end + + private + + def fetch_cards(card_ids) + body = JSON.generate( + "identifiers" => card_ids.map { { "id" => _1 } }, + ) + + fetch_scryfall_json("/cards/collection", method: :post, body: body) + .fetch("data") + .map { normalize_card(_1) } + end + + def normalize_card(card) + { + "id" => card.fetch("id"), + "name" => card.fetch("name"), + "uri" => card["scryfall_uri"] || card.fetch("uri"), + "imageUri" => image_uri_for(card), + "setId" => card.fetch("set_id"), + } + end + + def image_uri_for(card) + image_uris = card["image_uris"] || card.dig("card_faces", 0, "image_uris") + image_uris&.fetch("small", nil) || image_uris&.fetch("normal", nil) + end + end + end +end diff --git a/example/lib/example/loaders/scryfall_helpers.rb b/example/lib/example/loaders/scryfall_helpers.rb new file mode 100644 index 0000000..17f1ce0 --- /dev/null +++ b/example/lib/example/loaders/scryfall_helpers.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require "async" +require "async/http/endpoint" +require "json" +require "protocol/http/request" + +module Example + module Loaders + module ScryfallHelpers + SCRYFALL_API_ROOT = "https://api.scryfall.com" + SCRYFALL_HEADERS = { + "accept" => "application/json", + "user-agent" => "graphql-breadth example", + }.freeze + SCRYFALL_POST_HEADERS = SCRYFALL_HEADERS.merge( + "content-type" => "application/json", + ).freeze + + private + + def fetch_scryfall_json(path, method: :get, body: nil) + Async::Task.current?&.yield + + endpoint = Async::HTTP::Endpoint["#{SCRYFALL_API_ROOT}#{path}"] + stream = endpoint.connect + connection = endpoint.protocol.client(stream) + headers = method == :post ? SCRYFALL_POST_HEADERS : SCRYFALL_HEADERS + request = ::Protocol::HTTP::Request[ + method.to_s.upcase, + endpoint.path, + headers, + body, + scheme: endpoint.scheme, + authority: endpoint.authority, + ] + response = connection.call(request) + payload = response.read + + unless response.success? + raise GraphQL::ExecutionError, "Scryfall request failed with HTTP #{response.status}" + end + + JSON.parse(payload) + ensure + response&.close + + if connection + connection.close + else + stream&.close + end + end + end + end +end diff --git a/example/lib/example/resolvers/magic_card/rulings.rb b/example/lib/example/resolvers/magic_card/rulings.rb new file mode 100644 index 0000000..3ef7936 --- /dev/null +++ b/example/lib/example/resolvers/magic_card/rulings.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "graphql/breadth" + +require_relative "../../loaders/magic_card_rulings" + +module Example + module Resolvers + module MagicCard + class Rulings < GraphQL::Breadth::FieldResolver + def resolve(exec_field, _context) + exec_field.lazy( + loader_class: Example::Loaders::MagicCardRulings, + keys: exec_field.objects.map { _1.fetch("id") }, + ) + end + end + end + end +end diff --git a/example/lib/example/resolvers/magic_card/set.rb b/example/lib/example/resolvers/magic_card/set.rb new file mode 100644 index 0000000..b6a3d8a --- /dev/null +++ b/example/lib/example/resolvers/magic_card/set.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "graphql/breadth" + +require_relative "../../loaders/magic_card_sets" + +module Example + module Resolvers + module MagicCard + class Set < GraphQL::Breadth::FieldResolver + def resolve(exec_field, _context) + exec_field.lazy( + loader_class: Example::Loaders::MagicCardSets, + keys: exec_field.objects.map { _1.fetch("setId") }, + ) + end + end + end + end +end diff --git a/example/lib/example/resolvers/mutation/add_another_card.rb b/example/lib/example/resolvers/mutation/add_another_card.rb new file mode 100644 index 0000000..31fc109 --- /dev/null +++ b/example/lib/example/resolvers/mutation/add_another_card.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "graphql/breadth" + +require_relative "../../loaders/magic_cards" + +module Example + module Resolvers + module Mutation + class AddAnotherCard < GraphQL::Breadth::FieldResolver + def resolve(exec_field, context) + card_id = exec_field.objects.first.add_another_card(context: context) + + exec_field.lazy( + loader_class: Example::Loaders::MagicCards, + keys: [card_id], + ).then do |cards| + exec_field.resolve_all(cards.first) + end + end + end + end + end +end diff --git a/example/lib/example/resolvers/query/magic_cards.rb b/example/lib/example/resolvers/query/magic_cards.rb new file mode 100644 index 0000000..2b961a9 --- /dev/null +++ b/example/lib/example/resolvers/query/magic_cards.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "graphql/breadth" + +require_relative "../../loaders/magic_cards" + +module Example + module Resolvers + module Query + class MagicCards < GraphQL::Breadth::FieldResolver + def resolve(exec_field, _context) + card_ids = exec_field.objects.first.card_ids + return exec_field.resolve_all([]) if card_ids.empty? + + exec_field.lazy( + loader_class: Example::Loaders::MagicCards, + keys: card_ids, + ).then do |results| + exec_field.resolve_all(results) + end + end + end + end + end +end diff --git a/example/lib/example/resolvers/subscription/card_added.rb b/example/lib/example/resolvers/subscription/card_added.rb new file mode 100644 index 0000000..4581a77 --- /dev/null +++ b/example/lib/example/resolvers/subscription/card_added.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "graphql/breadth" + +require_relative "../../loaders/magic_cards" + +module Example + module Resolvers + module Subscription + class CardAdded < GraphQL::Breadth::FieldResolver + def subscribe(_exec_field, context) + event_bus = context[:event_bus] + + raise GraphQL::ExecutionError, "No event bus configured" unless event_bus + + event_bus.subscribe + end + + def resolve(exec_field, _context) + card_ids = exec_field.objects.map { card_id_for(_1) } + + exec_field.lazy( + loader_class: Example::Loaders::MagicCards, + keys: card_ids, + ) + end + + private + + def card_id_for(event) + event.is_a?(Hash) ? event.fetch("card_id") : event + end + end + end + end +end diff --git a/example/lib/example/schema.rb b/example/lib/example/schema.rb index ab0c72e..05aaf56 100644 --- a/example/lib/example/schema.rb +++ b/example/lib/example/schema.rb @@ -1,122 +1,110 @@ # frozen_string_literal: true require "graphql/breadth" -require "time" +require "set" + +GraphQL::Breadth.enable_async! module Example module Schema SDL = <<~GRAPHQL + directive @defer(if: Boolean, label: String) on INLINE_FRAGMENT | FRAGMENT_SPREAD + schema { query: Query mutation: Mutation subscription: Subscription } - directive @defer(if: Boolean = true, label: String) on FRAGMENT_SPREAD | INLINE_FRAGMENT + type MagicCard { + id: ID! + name: String! + uri: String! + imageUri: String! + set: MagicSet + rulings: [MagicCardRuling!] + } + + type MagicSet { + id: ID! + code: String! + name: String! + uri: String! + } + + type MagicCardRuling { + date: String! + comment: String! + } type Query { - hello(name: String = "world"): Greeting! - serverTime: String! + magicCards: [MagicCard!]! } type Mutation { - echo(message: String!): String! + addAnotherCard: MagicCard! } type Subscription { - greetings: Greeting! - } - - type Greeting { - message: String! - delayed(seconds: Int = 5): String! - sequence: Int! + cardAdded: MagicCard! } GRAPHQL GRAPHQL_SCHEMA = GraphQL::Schema.from_definition(SDL) - class HelloResolver < GraphQL::Breadth::FieldResolver - def resolve(exec_field, _context) - name = exec_field.arguments[:name] || "world" - exec_field.resolve_all(Example::Schema.greeting(name: name, sequence: 1)) - end - end - - class ServerTimeResolver < GraphQL::Breadth::FieldResolver - def resolve(exec_field, _context) - exec_field.resolve_all(Time.now.utc.iso8601) - end - end - - class EchoResolver < GraphQL::Breadth::FieldResolver - def resolve(exec_field, _context) - exec_field.resolve_all(exec_field.arguments.fetch(:message)) - end - end - - class DelayedResolver < GraphQL::Breadth::FieldResolver - def resolve(exec_field, _context) - seconds = exec_field.arguments[:seconds] - seconds = 5 if seconds.nil? - seconds = [seconds.to_i, 0].max - - sleep seconds - exec_field.map_objects { "Delivered after #{seconds} seconds by graphql-breadth @defer." } - end - end - - class GreetingsSubscriptionResolver < GraphQL::Breadth::FieldResolver - def subscribe(_exec_field, context) - event_bus = context[:event_bus] - - raise GraphQL::ExecutionError, "No event bus configured" unless event_bus - - event_bus.subscribe - end - - def resolve(exec_field, _context) - exec_field.map_objects(&:itself) - end - end + require_relative "card_database" + require_relative "resolvers/query/magic_cards" + require_relative "resolvers/mutation/add_another_card" + require_relative "resolvers/subscription/card_added" + require_relative "resolvers/magic_card/set" + require_relative "resolvers/magic_card/rulings" RESOLVERS = { "Query" => { - "hello" => HelloResolver.new, - "serverTime" => ServerTimeResolver.new, + "magicCards" => Example::Resolvers::Query::MagicCards.new, }, "Mutation" => { - "echo" => EchoResolver.new, + "addAnotherCard" => Example::Resolvers::Mutation::AddAnotherCard.new, }, "Subscription" => { - "greetings" => GreetingsSubscriptionResolver.new, + "cardAdded" => Example::Resolvers::Subscription::CardAdded.new, }, - "Greeting" => { - "message" => GraphQL::Breadth::HashKeyResolver.new("message"), - "delayed" => DelayedResolver.new, - "sequence" => GraphQL::Breadth::HashKeyResolver.new("sequence"), + "MagicCard" => { + "id" => GraphQL::Breadth::HashKeyResolver.new("id"), + "name" => GraphQL::Breadth::HashKeyResolver.new("name"), + "imageUri" => GraphQL::Breadth::HashKeyResolver.new("imageUri"), + "uri" => GraphQL::Breadth::HashKeyResolver.new("uri"), + "set" => Example::Resolvers::MagicCard::Set.new, + "rulings" => Example::Resolvers::MagicCard::Rulings.new, + }, + "MagicSet" => { + "id" => GraphQL::Breadth::HashKeyResolver.new("id"), + "code" => GraphQL::Breadth::HashKeyResolver.new("code"), + "name" => GraphQL::Breadth::HashKeyResolver.new("name"), + "uri" => GraphQL::Breadth::HashKeyResolver.new("uri"), + }, + "MagicCardRuling" => { + "date" => GraphQL::Breadth::HashKeyResolver.new("date"), + "comment" => GraphQL::Breadth::HashKeyResolver.new("comment"), }, }.freeze module_function - def executor(document, variables: {}, context: {}) + def card_database + @card_database ||= CardDatabase.new + end + + def executor(document, variables: {}, context: {}, root_object: card_database, operation_name: nil) GraphQL::Breadth::Executor.new( GRAPHQL_SCHEMA, document, resolvers: RESOLVERS, - root_object: {}, + root_object: root_object, variables: variables, context: context, + operation_name: operation_name, ) end - - def greeting(name:, sequence:) - { - "message" => "Hello, #{name}!", - "delayed" => "Delivered later by graphql-breadth @defer.", - "sequence" => sequence, - } - end end end diff --git a/example/views/graphiql.erb b/example/views/graphiql.erb index baf72c0..49f8ebc 100644 --- a/example/views/graphiql.erb +++ b/example/views/graphiql.erb @@ -285,18 +285,19 @@ button.textContent = "Sending"; try { - const response = await fetch(modeConfig.trigger.path, { + const response = await fetch("/graphql", { method: "post", headers: { "Accept": "application/json", "Content-Type": "application/json", }, - body: JSON.stringify({ name: "SSE" }), + body: JSON.stringify(modeConfig.trigger.graphqlParams), }); const payload = await response.json(); if (!response.ok) throw new Error(payload.errors?.[0]?.message || "Request failed"); + if (payload.errors?.length) throw new Error(payload.errors[0].message); - button.textContent = `Sent #${payload.event.sequence}`; + button.textContent = "Added"; } catch (error) { button.textContent = "Error"; } finally { diff --git a/lib/graphql/breadth/executor.rb b/lib/graphql/breadth/executor.rb index 3cfcd18..ee1bede 100644 --- a/lib/graphql/breadth/executor.rb +++ b/lib/graphql/breadth/executor.rb @@ -60,14 +60,15 @@ class Executor #| ?root_object: untyped, #| ?variables: Hash[String | Symbol, untyped], #| ?context: Hash[String | Symbol, untyped], + #| ?operation_name: String?, #| ?tracers: Array[Tracer], #| ?authorization: singleton(Authorization), #| ) -> void - def initialize(schema, document, resolvers: EMPTY_OBJECT, root_object: nil, variables: {}, context: {}, tracers: [], authorization: Authorization) + def initialize(schema, document, resolvers: EMPTY_OBJECT, root_object: nil, variables: {}, context: {}, operation_name: nil, tracers: [], authorization: Authorization) @provided_variables = variables.each_with_object({}) { |(key, value), out| out[key.to_s] = value } @schema = schema @resolvers = resolvers - @query = GraphQL::Query.new(schema, document: document, variables: @provided_variables, context: context) # << for schema reference + @query = GraphQL::Query.new(schema, document: document, variables: @provided_variables, context: context, operation_name: operation_name) # << for schema reference @context = @query.context @input = InputFormatter.new(@context) @planner = ExecutionPlanner.new(executor: self, resolvers: @resolvers) @@ -93,17 +94,17 @@ def initialize(schema, document, resolvers: EMPTY_OBJECT, root_object: nil, vari #: -> bool def query? - @query.selected_operation.operation_type == ExecutionPlanner::QUERY_OPERATION + @query.selected_operation&.operation_type == ExecutionPlanner::QUERY_OPERATION end #: -> bool def mutation? - @query.selected_operation.operation_type == ExecutionPlanner::MUTATION_OPERATION + @query.selected_operation&.operation_type == ExecutionPlanner::MUTATION_OPERATION end #: -> bool def subscription? - @query.selected_operation.operation_type == ExecutionPlanner::SUBSCRIPTION_OPERATION + @query.selected_operation&.operation_type == ExecutionPlanner::SUBSCRIPTION_OPERATION end #: -> Hash[String, GraphQL::Language::Nodes::FragmentDefinition] @@ -175,6 +176,7 @@ def execute_subscription_event(object) root_object: object, variables: @provided_variables, context: @context_value, + operation_name: @query.operation_name, tracers: @tracers, authorization: @authorization_class, ).execute @@ -267,14 +269,14 @@ def execute @tracers.each { _1.start(self, @context) } end - # reference selected operation first to trigger AST evaluation - operation = @query.selected_operation - if !@context.errors.empty? # Return any parsing errors return build_result(errors: @context.errors.map(&:to_h)) end + operation = @query.selected_operation + return build_result(errors: @query.static_errors.map(&:to_h)) unless operation + begin @input.coerce_variable_values(operation.variables, @query.provided_variables || EMPTY_OBJECT) rescue InputValidationErrorSet => e @@ -966,12 +968,14 @@ def build_abstract_scopes(exec_field, return_type, next_objects, next_results) #: -> (SubscriptionResponseStream | graphql_result) def execute_subscription @executed = true - operation = @query.selected_operation unless @context.errors.empty? return build_result(errors: @context.errors.map(&:to_h)) end + operation = @query.selected_operation + return build_result(errors: @query.static_errors.map(&:to_h)) unless operation + begin @input.coerce_variable_values(operation.variables, @query.provided_variables || EMPTY_OBJECT) rescue InputValidationErrorSet => input_error diff --git a/test/graphql/breadth/executor_test.rb b/test/graphql/breadth/executor_test.rb index 8be1582..26e8fc1 100644 --- a/test/graphql/breadth/executor_test.rb +++ b/test/graphql/breadth/executor_test.rb @@ -34,6 +34,58 @@ def test_resolves_typename_field assert_equal expected, breadth(document, source).dig("data") end + def test_selects_named_operation + document = %| + query ProductList { + products(first: 1) { + nodes { + title + } + } + } + + query CurrentNode { + node(id: "Product/1") { + __typename + } + } + | + + source = { + "products" => { "nodes" => [{ "title" => "GraphQL T-Shirt" }] }, + "node" => { "__typename__" => "Product" }, + } + + expected = { + "node" => { "__typename" => "Product" }, + } + + assert_equal expected, breadth(document, source, operation_name: "CurrentNode").dig("data") + end + + def test_returns_error_when_operation_name_is_required + document = %| + query ProductList { + products(first: 1) { + nodes { + title + } + } + } + + query CurrentNode { + node(id: "Product/1") { + __typename + } + } + | + + result = breadth(document, {}) + + assert_equal "An operation name is required", result.dig("errors", 0, "message") + refute result.key?("data") + end + def test_mutations_run_serially document = %|mutation { a: writeValue(value: "test1") { diff --git a/test/test_helper.rb b/test/test_helper.rb index 983c09e..d87579a 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -21,7 +21,7 @@ require_relative './fixtures' require_relative './star_wars_fixtures' -def breadth(query, source, variables: {}, context: {}, tracers: [GraphQL::Breadth::Tracer.new]) +def breadth(query, source, variables: {}, context: {}, operation_name: nil, tracers: [GraphQL::Breadth::Tracer.new]) GraphQL::Breadth::Executor.new( SCHEMA, GraphQL.parse(query), @@ -30,6 +30,7 @@ def breadth(query, source, variables: {}, context: {}, tracers: [GraphQL::Breadt tracers: tracers, variables: variables, context: context, + operation_name: operation_name, ).result end