From de097dc81dd93311df39f1fb70162099f0dd55ba 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 541d6db..36a0ce5 100644 --- a/lib/graphql/breadth/executor.rb +++ b/lib/graphql/breadth/executor.rb @@ -1007,47 +1007,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 b7bde62631de2daba37ab23393a4dc82ef0381bc 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 | 2 + example/README.md | 22 +-- example/app.rb | 175 ++---------------- example/lib/example/card_store.rb | 36 ++++ example/lib/example/event_bus.rb | 49 +++++ example/lib/example/graphiql_view_data.rb | 119 ++++++++++++ .../lib/example/loaders/magic_card_rulings.rb | 41 ++++ .../lib/example/loaders/magic_card_sets.rb | 39 ++++ example/lib/example/loaders/magic_cards.rb | 40 ++++ .../lib/example/loaders/random_magic_card.rb | 34 ++++ .../lib/example/loaders/scryfall_helpers.rb | 86 +++++++++ .../example/resolvers/magic_card/rulings.rb | 20 ++ .../lib/example/resolvers/magic_card/set.rb | 20 ++ .../resolvers/mutation/add_another_card.rb | 25 +++ .../example/resolvers/query/magic_cards.rb | 25 +++ .../resolvers/subscription/card_added.rb | 36 ++++ example/lib/example/schema.rb | 134 ++++++-------- example/views/graphiql.erb | 17 +- graphql-breadth.gemspec | 2 +- lib/graphql/breadth/executor.rb | 3 + 20 files changed, 678 insertions(+), 247 deletions(-) create mode 100644 example/lib/example/card_store.rb create mode 100644 example/lib/example/event_bus.rb create mode 100644 example/lib/example/graphiql_view_data.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/random_magic_card.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..e831535 100644 --- a/example/Gemfile +++ b/example/Gemfile @@ -4,6 +4,8 @@ source "https://rubygems.org" gem "graphql-breadth", path: ".." +gem "async-http" +gem "async-limiter" gem "rack", "~> 3.1" gem "rackup", "~> 2.2" gem "puma", "~> 6.0" diff --git a/example/README.md b/example/README.md index 61e29a0..3bbbe24 100644 --- a/example/README.md +++ b/example/README.md @@ -1,6 +1,6 @@ # 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`. +This is a small standalone Rack app that serves GraphiQL at `/` and executes GraphQL requests through `GraphQL::Breadth::Executor` at `/graphql`. It uses the [Scryfall API](https://scryfall.com/docs/api) as a remote backend. ```sh cd example @@ -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..944541c 100644 --- a/example/app.rb +++ b/example/app.rb @@ -4,159 +4,33 @@ require "json" require "rack/request" require "rack/response" -require "thread" +require_relative "lib/example/event_bus" +require_relative "lib/example/graphiql_view_data" 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) + def initialize(event_bus: EventBus.new, card_store: Example::Schema.card_store) @event_bus = event_bus + @card_store = card_store end def call(env) request = Rack::Request.new(env) - return redirect_response(MODES.fetch("query").fetch("path")) if request.get? && request.path_info == "/" + return redirect_response(GraphiQLViewData.default_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 + view_data = GraphiQLViewData.for_path(request.path_info) + return graphiql_response(view_data) if view_data 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 +60,18 @@ def graphql_response(request) executor = Example::Schema.executor( document, variables: params.fetch("variables", {}), + root_object: @card_store, + 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 +113,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, "" @@ -328,14 +196,9 @@ def json_response(payload, status: 200) ] end - def graphiql_response(mode_id) + def graphiql_response(view_data) Rack::Response.new( - render_view( - "graphiql", - current_mode: mode_id, - mode_config: MODES.fetch(mode_id), - nav_items: NAV_ITEMS, - ), + render_view("graphiql", view_data), 200, cors_headers.merge("content-type" => "text/html; charset=utf-8"), ).finish @@ -361,10 +224,6 @@ def cors_headers } 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 diff --git a/example/lib/example/card_store.rb b/example/lib/example/card_store.rb new file mode 100644 index 0000000..33bbacd --- /dev/null +++ b/example/lib/example/card_store.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "set" + +module Example + class CardStore + INITIAL_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 + + def initialize + @members = Set.new([INITIAL_CARD_IDS.sample]) + end + + def add(card_id) + card_id = card_id.to_s + members.add(card_id) + self + end + + def remove(card_id) + card_id = card_id.to_s + members.delete(card_id) + self + end + + def card_ids + members.to_a + end + end +end diff --git a/example/lib/example/event_bus.rb b/example/lib/example/event_bus.rb new file mode 100644 index 0000000..44bc24b --- /dev/null +++ b/example/lib/example/event_bus.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "thread" + +module Example + class EventBus + def initialize + @mutex = Mutex.new + @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(card_id:) + event = nil + subscribers = nil + + @mutex.synchronize do + event = { "card_id" => card_id } + subscribers = @subscribers.dup + end + + subscribers.each { |queue| queue << event } + + { + "event" => event, + "subscribers" => subscribers.length, + } + end + end +end diff --git a/example/lib/example/graphiql_view_data.rb b/example/lib/example/graphiql_view_data.rb new file mode 100644 index 0000000..83c55d5 --- /dev/null +++ b/example/lib/example/graphiql_view_data.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +module Example + module GraphiQLViewData + DEFAULT_MODE = "query" + MODES = { + "query" => { + "path" => "/query", + "label" => "Query/Mutation", + "transport" => "json", + "defaultQuery" => <<~GRAPHQL, + query MagicCards { + magicCards { + id + name + imageUri + set { + code + name + } + } + } + + mutation AddAnotherCard { + addAnotherCard { + id + name + imageUri + } + } + GRAPHQL + "variables" => {}, + }, + "defer" => { + "path" => "/defer", + "label" => "Defer", + "transport" => "sse", + "defaultQuery" => <<~GRAPHQL, + query DeferredCardRulings { + magicCards { + id + name + ... @defer(label: "rulings") { + rulings { + date + comment + } + } + } + } + GRAPHQL + "variables" => {}, + "inspector" => { + "title" => "SSE Stream", + "empty" => "Run the operation to see SSE payloads", + }, + }, + "subscriptions" => { + "path" => "/subscriptions", + "label" => "Subscriptions", + "transport" => "sse", + "defaultQuery" => <<~GRAPHQL, + subscription CardAdded { + cardAdded { + id + name + } + } + GRAPHQL + "variables" => {}, + "inspector" => { + "title" => "Card Events", + "empty" => "Start the subscription, then add a card", + }, + "trigger" => { + "label" => "Add Card", + "graphqlParams" => { + "query" => <<~GRAPHQL, + mutation AddAnotherCard { + addAnotherCard { + id + name + } + } + GRAPHQL + "operationName" => "AddAnotherCard", + }, + }, + }, + }.freeze + + NAV_ITEMS = MODES.map do |id, config| + { + "id" => id, + "path" => config.fetch("path"), + "label" => config.fetch("label"), + } + end.freeze + + MODE_IDS_BY_PATH = MODES.to_h { |id, config| [config.fetch("path"), id] }.freeze + + module_function + + def default_path + MODES.fetch(DEFAULT_MODE).fetch("path") + end + + def for_path(path) + mode_id = MODE_IDS_BY_PATH[path] + return unless mode_id + + { + current_mode: mode_id, + mode_config: MODES.fetch(mode_id), + nav_items: NAV_ITEMS, + } + 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..c288752 --- /dev/null +++ b/example/lib/example/loaders/magic_card_rulings.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "graphql/breadth" + +require_relative "scryfall_helpers" + +module Example + module Loaders + class MagicCardRulings < GraphQL::Breadth::LazyLoader + include ScryfallHelpers + + async( + limit: 10, + resource: :scryfall_api, + throttle: ScryfallHelpers::SCRYFALL_10_RPS, + 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..27bf381 --- /dev/null +++ b/example/lib/example/loaders/magic_card_sets.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "graphql/breadth" + +require_relative "scryfall_helpers" + +module Example + module Loaders + class MagicCardSets < GraphQL::Breadth::LazyLoader + include ScryfallHelpers + + async( + limit: 10, + resource: :scryfall_api, + throttle: ScryfallHelpers::SCRYFALL_10_RPS, + 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..40df778 --- /dev/null +++ b/example/lib/example/loaders/magic_cards.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "graphql/breadth" + +require_relative "scryfall_helpers" + +module Example + module Loaders + class MagicCards < GraphQL::Breadth::LazyLoader + include ScryfallHelpers + + async( + limit: 10, + resource: :scryfall_api, + throttle: ScryfallHelpers::SCRYFALL_2_RPS, + 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 + end + end +end diff --git a/example/lib/example/loaders/random_magic_card.rb b/example/lib/example/loaders/random_magic_card.rb new file mode 100644 index 0000000..f9b3573 --- /dev/null +++ b/example/lib/example/loaders/random_magic_card.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "graphql/breadth" + +require_relative "scryfall_helpers" + +module Example + module Loaders + class RandomMagicCard < GraphQL::Breadth::LazyLoader + include ScryfallHelpers + + async( + limit: 10, + resource: :scryfall_api, + throttle: ScryfallHelpers::SCRYFALL_2_RPS, + timeout: 10, + ) + + def map? + true + end + + def resolve_one? + true + end + + def perform_map(requests, _context) + async_map(requests) do + normalize_card(fetch_scryfall_json("/cards/random")) + end + 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..c6365a2 --- /dev/null +++ b/example/lib/example/loaders/scryfall_helpers.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require "async" +require "async/http/endpoint" +require "async/limiter" +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 + SCRYFALL_2_RPS = Async::Limiter::Generic.new( + timing: Async::Limiter::Timing::SlidingWindow.new( + 1, + Async::Limiter::Timing::Burst::Greedy, + 2, + ), + ) + SCRYFALL_10_RPS = Async::Limiter::Generic.new( + timing: Async::Limiter::Timing::SlidingWindow.new( + 1, + Async::Limiter::Timing::Burst::Greedy, + 10, + ), + ) + + 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 + + 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/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..ba4115c --- /dev/null +++ b/example/lib/example/resolvers/mutation/add_another_card.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "graphql/breadth" + +require_relative "../../loaders/random_magic_card" + +module Example + module Resolvers + module Mutation + class AddAnotherCard < GraphQL::Breadth::FieldResolver + def resolve(exec_field, context) + exec_field.lazy( + loader_class: Example::Loaders::RandomMagicCard, + keys: [Object.new], + ).then do |card| + card_id = card.fetch("id") + exec_field.objects.first.add(card_id) + context[:event_bus]&.publish(card_id: card_id) + exec_field.resolve_all(card) + 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..b772c9c 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_store" + 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_store + @card_store ||= CardStore.new + end + + def executor(document, variables: {}, context: {}, root_object: card_store, 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..d8e3e80 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 { @@ -316,6 +317,10 @@ }, []); const graphQLFetcher = React.useCallback((graphQLParams, fetcherOptions = {}) => { + if (isIntrospectionRequest(graphQLParams)) { + return fetchGraphQL(graphQLParams, fetcherOptions.headers); + } + startedAt.current = performance.now(); setStreamEvents([]); setStatus("running"); @@ -377,6 +382,12 @@ ); } + function isIntrospectionRequest(graphQLParams) { + const query = graphQLParams?.query; + return graphQLParams?.operationName === "IntrospectionQuery" + || (typeof query === "string" && /\b__(?:schema|type)\b/.test(query)); + } + function createGraphiQLStorage(defaults) { const store = new Map(); diff --git a/graphql-breadth.gemspec b/graphql-breadth.gemspec index 3427479..b2a2cac 100644 --- a/graphql-breadth.gemspec +++ b/graphql-breadth.gemspec @@ -11,7 +11,7 @@ Gem::Specification.new do |spec| spec.homepage = 'https://github.com/gmac/graphql-breadth' spec.license = 'MIT' - spec.required_ruby_version = '>= 3.2.0' + spec.required_ruby_version = '>= 3.3.0' spec.metadata = { 'homepage_uri' => 'https://github.com/gmac/graphql-breadth', diff --git a/lib/graphql/breadth/executor.rb b/lib/graphql/breadth/executor.rb index 36a0ce5..d9ca51a 100644 --- a/lib/graphql/breadth/executor.rb +++ b/lib/graphql/breadth/executor.rb @@ -967,6 +967,9 @@ def execute_subscription operation = @query.selected_operation return build_result(errors: @query.static_errors.map(&:to_h)) unless operation + 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