diff --git a/elasticgraph-graphql/lib/elastic_graph/graphql/http_endpoint.rb b/elasticgraph-graphql/lib/elastic_graph/graphql/http_endpoint.rb index 509fc1842..310ac3fa9 100644 --- a/elasticgraph-graphql/lib/elastic_graph/graphql/http_endpoint.rb +++ b/elasticgraph-graphql/lib/elastic_graph/graphql/http_endpoint.rb @@ -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) @@ -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.") @@ -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 diff --git a/elasticgraph-graphql/sig/elastic_graph/graphql/http_endpoint.rbs b/elasticgraph-graphql/sig/elastic_graph/graphql/http_endpoint.rbs index c839d9514..1f9be5392 100644 --- a/elasticgraph-graphql/sig/elastic_graph/graphql/http_endpoint.rbs +++ b/elasticgraph-graphql/sig/elastic_graph/graphql/http_endpoint.rbs @@ -14,7 +14,7 @@ module ElasticGraph HTTPRequest, ?max_timeout_in_ms: ::Integer?, ?start_time_in_ms: ::Integer - ) -> HTTPResponse + ) ?{ (::Hash[::String, untyped]) -> untyped } -> HTTPResponse private @@ -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 diff --git a/elasticgraph-graphql/spec/unit/elastic_graph/graphql/http_endpoint_spec.rb b/elasticgraph-graphql/spec/unit/elastic_graph/graphql/http_endpoint_spec.rb index 7c80f21c9..ddf94f239 100644 --- a/elasticgraph-graphql/spec/unit/elastic_graph/graphql/http_endpoint_spec.rb +++ b/elasticgraph-graphql/spec/unit/elastic_graph/graphql/http_endpoint_spec.rb @@ -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(...) @@ -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) diff --git a/elasticgraph-rack/lib/elastic_graph/rack/graphql_endpoint.rb b/elasticgraph-rack/lib/elastic_graph/rack/graphql_endpoint.rb index f3a283988..54bccfdac 100644 --- a/elasticgraph-rack/lib/elastic_graph/rack/graphql_endpoint.rb +++ b/elasticgraph-rack/lib/elastic_graph/rack/graphql_endpoint.rb @@ -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] Rack env - # @return [Array(Integer, Hash, Array)] + # @return [Array(Integer, Hash, #each)] def call(env) rack_request = ::Rack::Request.new(env) @@ -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" diff --git a/elasticgraph-rack/sig/elastic_graph/rack/graphql_endpoint.rbs b/elasticgraph-rack/sig/elastic_graph/rack/graphql_endpoint.rbs index bf9c195f9..f57e39ea7 100644 --- a/elasticgraph-rack/sig/elastic_graph/rack/graphql_endpoint.rbs +++ b/elasticgraph-rack/sig/elastic_graph/rack/graphql_endpoint.rbs @@ -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 \ No newline at end of file diff --git a/elasticgraph-rack/spec/acceptance/graphql_endpoint_spec.rb b/elasticgraph-rack/spec/acceptance/graphql_endpoint_spec.rb index 966cae4b8..ae00fddfd 100644 --- a/elasticgraph-rack/spec/acceptance/graphql_endpoint_spec.rb +++ b/elasticgraph-rack/spec/acceptance/graphql_endpoint_spec.rb @@ -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) } @@ -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)}"