Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions elasticgraph-graphql/lib/elastic_graph/graphql/http_endpoint.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,17 @@ def initialize(query_executor:, monotonic_clock:, client_resolver:)
# `max_timeout_in_ms` is not a property of the HTTP request (the
# calling application will determine it instead!) so it is a separate argument.
#
# When a block is given, it is invoked with the GraphQL result hash and must return
# the response body to assign to the {HTTPResponse}. The body may be any value the
# calling adapter accepts — for example, a `String`, or any object responding to
# `each` per the Rack body spec. This lets a host avoid allocating a large JSON
# `String` by streaming the result hash directly into the response output stream.
# Without a block, the body is built via `JSON.generate(result.to_h)` as before.
#
# Note that this method does _not_ convert exceptions to 500 responses. It's up to
# the calling application to do that if it wants to (and to determine how much of the
# exception to return in the HTTP response...).
def process(request, max_timeout_in_ms: nil, start_time_in_ms: @monotonic_clock.now_in_ms)
def process(request, max_timeout_in_ms: nil, start_time_in_ms: @monotonic_clock.now_in_ms, &body_serializer)
client_or_response = @client_resolver.resolve(request)
return client_or_response if client_or_response.is_a?(HTTPResponse)

Expand All @@ -60,7 +67,11 @@ def process(request, max_timeout_in_ms: nil, start_time_in_ms: @monotonic_clock.
start_time_in_ms: start_time_in_ms
)

HTTPResponse.json(200, result.to_h)
if body_serializer
HTTPResponse.new(200, {"Content-Type" => APPLICATION_JSON}, body_serializer.call(result.to_h))
else
HTTPResponse.json(200, result.to_h)
end
end
rescue Errors::RequestExceededDeadlineError
HTTPResponse.error(504, "Search exceeded requested timeout.")
Expand Down Expand Up @@ -209,7 +220,9 @@ def self.normalize_header_name(header)
#
# - status_code: an integer like 200.
# - headers: a hash with string keys and values containing HTTP response headers.
# - body: a string containing the response body.
# - body: the response body. The {.json} and {.error} factories produce a `String` body;
# custom callers may supply any value the calling adapter accepts (for example, a Rack
# body responding to `each`).
HTTPResponse = Data.define(:status_code, :headers, :body) do
# @implements HTTPResponse

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ module ElasticGraph
HTTPRequest,
?max_timeout_in_ms: ::Integer?,
?start_time_in_ms: ::Integer
) -> HTTPResponse
) ?{ (::Hash[::String, untyped]) -> untyped } -> HTTPResponse

private

Expand Down Expand Up @@ -93,21 +93,21 @@ module ElasticGraph
class HTTPResponse
attr_reader status_code: ::Integer
attr_reader headers: ::Hash[::String, ::String]
attr_reader body: ::String
attr_reader body: untyped

def initialize: (
status_code: ::Integer,
headers: ::Hash[::String, ::String],
body: ::String) -> void
body: untyped) -> void

def self.new:
(status_code: ::Integer, headers: ::Hash[::String, ::String], body: ::String) -> instance
| (::Integer, ::Hash[::String, ::String], ::String) -> instance
(status_code: ::Integer, headers: ::Hash[::String, ::String], body: untyped) -> instance
| (::Integer, ::Hash[::String, ::String], untyped) -> instance

def with: (
?status_code: ::Integer,
?headers: ::Hash[::String, ::String],
?body: ::String) -> HTTPResponse
?body: untyped) -> HTTPResponse

def self.json: (::Integer, ::Hash[::String, untyped]) -> HTTPResponse
def self.error: (::Integer, ::String) -> HTTPResponse
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,44 @@ def process(query_params: {}, **options)
expect(response).to eq error_with("No query string was present")
end

describe "with a custom body serializer block" do
it "passes the result hash to the block and uses its return value as the response body" do
captured = nil
custom_body = ::Object.new

response = process(
http_method: :post,
headers: {"Content-Type" => "application/json"},
body: ::JSON.generate("query" => "query { widgets { __typename } }")
) do |result_hash|
captured = result_hash
custom_body
end

expect(response.status_code).to eq(200)
expect(response.headers).to include("Content-Type" => "application/json")
expect(response.body).to be(custom_body)
expect(captured).to eq("data" => {"widgets" => {"__typename" => "WidgetConnection"}})
end

it "does not invoke the block when the request fails before query execution" do
invocations = 0

response = process(
http_method: :post,
headers: {"Content-Type" => "application/json"},
body: "not json"
) do |_result_hash|
invocations += 1
"should not be used"
end

expect(invocations).to eq(0)
expect(response.status_code).to eq(400)
expect(::JSON.parse(response.body)).to eq error_with("Request body is invalid JSON.")
end
end

def process_expecting(status_code, ...)
response = process(...)

Expand All @@ -337,9 +375,9 @@ def process_expecting(status_code, ...)
::JSON.parse(response.body)
end

def process(http_method:, url: "http://foo.test/bar", body: nil, headers: {}, extra_headers: {}, **options)
def process(http_method:, url: "http://foo.test/bar", body: nil, headers: {}, extra_headers: {}, **options, &block)
request = HTTPRequest.new(url: url, http_method: http_method, body: body, headers: headers.merge(extra_headers))
graphql.graphql_http_endpoint.process(request, **options)
graphql.graphql_http_endpoint.process(request, **options, &block)
end

def error_with(message)
Expand Down
23 changes: 19 additions & 4 deletions elasticgraph-rack/lib/elastic_graph/rack/graphql_endpoint.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,22 @@ module Rack
# run ElasticGraph::Rack::GraphQLEndpoint.new(graphql)
class GraphQLEndpoint
# @param graphql [ElasticGraph::GraphQL] ElasticGraph GraphQL instance
def initialize(graphql)
# @param body_serializer [#call, nil] optional callable invoked with the GraphQL result
# hash; its return value is used as the response body. The body may be a `String` or
# any object responding to `each` per the Rack body spec. Use this to bypass the
# default `JSON.generate(result.to_h)` allocation — for example, by streaming the
# result hash directly into the response output stream via a JSON encoder of your
# choice. Errors and non-200 responses still produce a `String` body.
def initialize(graphql, body_serializer: nil)
@logger = graphql.logger
@graphql_http_endpoint = graphql.graphql_http_endpoint
@body_serializer = body_serializer
end

# Responds to a Rack request.
#
# @param env [Hash<String, Object>] Rack env
# @return [Array(Integer, Hash<String, String>, Array<String>)]
# @return [Array(Integer, Hash<String, String>, #each)]
def call(env)
rack_request = ::Rack::Request.new(env)

Expand All @@ -50,9 +57,17 @@ def call(env)
body: rack_request.body&.read
)

response = @graphql_http_endpoint.process(request)
response =
if (body_serializer = @body_serializer)
@graphql_http_endpoint.process(request, &body_serializer)
else
@graphql_http_endpoint.process(request)
end

[response.status_code, response.headers.transform_keys(&:downcase), [response.body]]
body = response.body
rack_body = body.is_a?(::String) ? [body] : body

[response.status_code, response.headers.transform_keys(&:downcase), rack_body]
rescue => e
raise if ENV["RACK_ENV"] == "test"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ module ElasticGraph
module Rack
class GraphQLEndpoint
include ::Rack::_Application
def initialize: (GraphQL) -> void
def initialize: (GraphQL, ?body_serializer: ^(::Hash[::String, untyped]) -> untyped | nil) -> void
@logger: ::Logger
@graphql_http_endpoint: GraphQL::HTTPEndpoint
@body_serializer: ^(::Hash[::String, untyped]) -> untyped | nil
end
end
end
37 changes: 37 additions & 0 deletions elasticgraph-rack/spec/acceptance/graphql_endpoint_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,19 @@
require "elastic_graph/constants"

module ElasticGraph::Rack
# Minimal stand-in for a streaming JSON body. Real callers would write directly
# into a response output stream via their preferred encoder; this verifies only
# that the Rack adapter passes a non-String, `each`-responding body through.
class ChunkedJSONBody
def initialize(hash)
@hash = hash
end

def each
yield ::JSON.generate(@hash)
end
end

RSpec.describe GraphQLEndpoint, :rack_app, :uses_datastore do
let(:graphql) { build_graphql }
let(:app_to_test) { GraphQLEndpoint.new(graphql) }
Expand All @@ -34,6 +47,30 @@ module ElasticGraph::Rack
expect(response.dig("data", "__schema", "types").count).to be > 5
end

context "when configured with a custom body_serializer" do
let(:app_to_test) do
GraphQLEndpoint.new(graphql, body_serializer: lambda do |result_hash|
# Return a Rack-compatible streaming body (responds to `each` yielding String chunks)
# built without round-tripping the result through a single large `JSON.generate` String.
ChunkedJSONBody.new(result_hash)
end)
end

it "uses the custom body and passes its `each`-yielded chunks straight through to Rack" do
response = call_graphql_query(introspection_query)

expect(response.dig("data", "__schema", "types").count).to be > 5
end

it "still produces a String body for error responses" do
post_json "/", "not json"

expect(last_response.status).to eq 400
expect(last_response.body).to be_a(::String)
expect_json_error_including("invalid JSON")
end
end

it "supports executing queries submitted as a GET" do
get "/?query=#{::URI.encode_www_form_component(introspection_query)}"

Expand Down
Loading