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 @@ + + +
+ + +