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..32c0d90 --- /dev/null +++ b/example/Gemfile @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +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 new file mode 100644 index 0000000..d8cc8c7 --- /dev/null +++ b/example/README.md @@ -0,0 +1,70 @@ +# 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 "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. + +## JSON query + +```sh +curl http://localhost:9292/graphql \\ + -H 'Content-Type: application/json' \\ + -d '{"query":"{ magicCards { id name imageUri set { code name } } }"}' +``` + +## JSON mutation + +```sh +curl http://localhost:9292/graphql \\ + -H 'Content-Type: application/json' \\ + -d '{"query":"mutation { addAnotherCard { id name } }"}' +``` + +## Incremental `@defer` over SSE + +```sh +curl -N http://localhost:9292/graphql \\ + -H 'Accept: text/event-stream' \\ + -H 'Content-Type: application/json' \\ + -d '{"query":"{ magicCards { id name ... @defer(label: \"rulings\") { rulings { date comment } } } }"}' +``` + +## Incremental `@defer` over multipart + +```sh +curl -N http://localhost:9292/graphql \\ + -H 'Accept: multipart/mixed' \\ + -H 'Content-Type: application/json' \\ + -d '{"query":"{ magicCards { id name ... @defer(label: \"rulings\") { rulings { date comment } } } }"}' +``` + +## Subscription over SSE + +```sh +curl -N http://localhost:9292/graphql \\ + -H 'Accept: text/event-stream' \\ + -H 'Content-Type: application/json' \\ + -d '{"query":"subscription { cardAdded { id name } }"}' +``` + +Then add a card from another terminal: + +```sh +curl http://localhost:9292/graphql \\ + -H 'Content-Type: application/json' \\ + -d '{"query":"mutation { addAnotherCard { id name } }"}' +``` diff --git a/example/app.rb b/example/app.rb new file mode 100644 index 0000000..801a2f1 --- /dev/null +++ b/example/app.rb @@ -0,0 +1,380 @@ +# 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" + MULTIPART_BOUNDARY = "graphql" + VIEW_ROOT = File.expand_path("views", __dir__) + 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 + + 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 + + def initialize(event_bus: EventBus.new, card_database: Example::Schema.card_database) + @event_bus = event_bus + @card_database = card_database + 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? && request.path_info == GRAPHQL_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", {}), + 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 + + 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["operationName"] = nil if raw_params["operationName"].to_s.empty? + raw_params + 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/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 new file mode 100644 index 0000000..05aaf56 --- /dev/null +++ b/example/lib/example/schema.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require "graphql/breadth" +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 + } + + 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 { + magicCards: [MagicCard!]! + } + + type Mutation { + addAnotherCard: MagicCard! + } + + type Subscription { + cardAdded: MagicCard! + } + GRAPHQL + + GRAPHQL_SCHEMA = GraphQL::Schema.from_definition(SDL) + + 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" => { + "magicCards" => Example::Resolvers::Query::MagicCards.new, + }, + "Mutation" => { + "addAnotherCard" => Example::Resolvers::Mutation::AddAnotherCard.new, + }, + "Subscription" => { + "cardAdded" => Example::Resolvers::Subscription::CardAdded.new, + }, + "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 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, + variables: variables, + context: context, + operation_name: operation_name, + ) + end + end +end diff --git a/example/views/graphiql.erb b/example/views/graphiql.erb new file mode 100644 index 0000000..49f8ebc --- /dev/null +++ b/example/views/graphiql.erb @@ -0,0 +1,670 @@ + + + + + + graphql-breadth example + + + + + +
+ + + + + + diff --git a/lib/graphql/breadth/executor.rb b/lib/graphql/breadth/executor.rb index 6d2dec6..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 @@ -1012,47 +1016,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 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