elasticgraph-apollo 0.18.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +63 -0
- data/apollo_tests_implementation/Dockerfile +66 -0
- data/apollo_tests_implementation/Gemfile +27 -0
- data/apollo_tests_implementation/Rakefile +22 -0
- data/apollo_tests_implementation/config/products_schema.rb +173 -0
- data/apollo_tests_implementation/config/settings.yaml +34 -0
- data/apollo_tests_implementation/config.ru +122 -0
- data/apollo_tests_implementation/docker-compose.yaml +18 -0
- data/apollo_tests_implementation/lib/test_implementation_extension.rb +58 -0
- data/apollo_tests_implementation/wait_for_datastore.sh +17 -0
- data/elasticgraph-apollo.gemspec +27 -0
- data/lib/elastic_graph/apollo/graphql/engine_extension.rb +52 -0
- data/lib/elastic_graph/apollo/graphql/entities_field_resolver.rb +305 -0
- data/lib/elastic_graph/apollo/graphql/http_endpoint_extension.rb +45 -0
- data/lib/elastic_graph/apollo/graphql/service_field_resolver.rb +30 -0
- data/lib/elastic_graph/apollo/schema_definition/api_extension.rb +385 -0
- data/lib/elastic_graph/apollo/schema_definition/apollo_directives.rb +119 -0
- data/lib/elastic_graph/apollo/schema_definition/argument_extension.rb +20 -0
- data/lib/elastic_graph/apollo/schema_definition/entity_type_extension.rb +30 -0
- data/lib/elastic_graph/apollo/schema_definition/enum_type_extension.rb +23 -0
- data/lib/elastic_graph/apollo/schema_definition/enum_value_extension.rb +20 -0
- data/lib/elastic_graph/apollo/schema_definition/factory_extension.rb +104 -0
- data/lib/elastic_graph/apollo/schema_definition/field_extension.rb +59 -0
- data/lib/elastic_graph/apollo/schema_definition/graphql_sdl_enumerator_extension.rb +69 -0
- data/lib/elastic_graph/apollo/schema_definition/input_type_extension.rb +20 -0
- data/lib/elastic_graph/apollo/schema_definition/interface_type_extension.rb +25 -0
- data/lib/elastic_graph/apollo/schema_definition/object_type_extension.rb +28 -0
- data/lib/elastic_graph/apollo/schema_definition/scalar_type_extension.rb +23 -0
- data/lib/elastic_graph/apollo/schema_definition/state_extension.rb +23 -0
- data/lib/elastic_graph/apollo/schema_definition/union_type_extension.rb +20 -0
- data/script/boot_eg_apollo_implementation +22 -0
- data/script/export_docker_env_vars.sh +15 -0
- data/script/test_compatibility +54 -0
- metadata +472 -0
@@ -0,0 +1,52 @@
|
|
1
|
+
# Copyright 2024 Block, Inc.
|
2
|
+
#
|
3
|
+
# Use of this source code is governed by an MIT-style
|
4
|
+
# license that can be found in the LICENSE file or at
|
5
|
+
# https://opensource.org/licenses/MIT.
|
6
|
+
#
|
7
|
+
# frozen_string_literal: true
|
8
|
+
|
9
|
+
module ElasticGraph
|
10
|
+
module Apollo
|
11
|
+
module GraphQL
|
12
|
+
# ElasticGraph application extension module designed to hook into the ElasticGraph
|
13
|
+
# GraphQL engine in order to support Apollo-specific fields.
|
14
|
+
module EngineExtension
|
15
|
+
def graphql_resolvers
|
16
|
+
@graphql_resolvers ||= begin
|
17
|
+
require "elastic_graph/apollo/graphql/entities_field_resolver"
|
18
|
+
require "elastic_graph/apollo/graphql/service_field_resolver"
|
19
|
+
|
20
|
+
[
|
21
|
+
EntitiesFieldResolver.new(
|
22
|
+
datastore_query_builder: datastore_query_builder,
|
23
|
+
schema_element_names: runtime_metadata.schema_element_names
|
24
|
+
),
|
25
|
+
ServiceFieldResolver.new
|
26
|
+
] + super
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def graphql_gem_plugins
|
31
|
+
@graphql_gem_plugins ||= begin
|
32
|
+
require "apollo-federation/tracing/proto"
|
33
|
+
require "apollo-federation/tracing/node_map"
|
34
|
+
require "apollo-federation/tracing/tracer"
|
35
|
+
require "apollo-federation/tracing"
|
36
|
+
|
37
|
+
# @type var options: ::Hash[::Symbol, untyped]
|
38
|
+
options = {}
|
39
|
+
super.merge(ApolloFederation::Tracing => options)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def graphql_http_endpoint
|
44
|
+
@graphql_http_endpoint ||= super.tap do |endpoint|
|
45
|
+
require "elastic_graph/apollo/graphql/http_endpoint_extension"
|
46
|
+
endpoint.extend HTTPEndpointExtension
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,305 @@
|
|
1
|
+
# Copyright 2024 Block, Inc.
|
2
|
+
#
|
3
|
+
# Use of this source code is governed by an MIT-style
|
4
|
+
# license that can be found in the LICENSE file or at
|
5
|
+
# https://opensource.org/licenses/MIT.
|
6
|
+
#
|
7
|
+
# frozen_string_literal: true
|
8
|
+
|
9
|
+
require "elastic_graph/error"
|
10
|
+
require "elastic_graph/graphql/query_adapter/requested_fields"
|
11
|
+
require "elastic_graph/graphql/resolvers/query_source"
|
12
|
+
|
13
|
+
module ElasticGraph
|
14
|
+
module Apollo
|
15
|
+
module GraphQL
|
16
|
+
# GraphQL resolver for the Apollo `Query._entities` field. For details on this field, see:
|
17
|
+
#
|
18
|
+
# https://www.apollographql.com/docs/federation/subgraph-spec/#resolve-requests-for-entities
|
19
|
+
class EntitiesFieldResolver
|
20
|
+
def initialize(datastore_query_builder:, schema_element_names:)
|
21
|
+
@datastore_query_builder = datastore_query_builder
|
22
|
+
@schema_element_names = schema_element_names
|
23
|
+
end
|
24
|
+
|
25
|
+
def can_resolve?(field:, object:)
|
26
|
+
field.parent_type.name == :Query && field.name == :_entities
|
27
|
+
end
|
28
|
+
|
29
|
+
def resolve(field:, object:, args:, context:, lookahead:)
|
30
|
+
schema = context.fetch(:elastic_graph_schema)
|
31
|
+
|
32
|
+
representations = args.fetch("representations").map.with_index do |rep, index|
|
33
|
+
try_parse_representation(rep, schema) do |error_description|
|
34
|
+
context.add_error(::GraphQL::ExecutionError.new("Representation at index #{index} #{error_description}."))
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
representations_by_adapter = representations.group_by { |rep| rep&.adapter }
|
39
|
+
|
40
|
+
# The query attributes that are based on the requested subfields are the same across all representations,
|
41
|
+
# so we build the hash of those attributes once here.
|
42
|
+
query_attributes = ElasticGraph::GraphQL::QueryAdapter::RequestedFields
|
43
|
+
.new(schema)
|
44
|
+
.query_attributes_for(field: field, lookahead: lookahead)
|
45
|
+
.merge(monotonic_clock_deadline: context[:monotonic_clock_deadline])
|
46
|
+
|
47
|
+
# Build a separate query per adapter instance since each adapter instance is capable of building
|
48
|
+
# a single query that handles all representations assigned to it.
|
49
|
+
query_by_adapter = representations_by_adapter.to_h do |adapter, reps|
|
50
|
+
query = build_query(adapter, reps, query_attributes) if adapter
|
51
|
+
[adapter, query]
|
52
|
+
end
|
53
|
+
|
54
|
+
responses_by_query = ElasticGraph::GraphQL::Resolvers::QuerySource.execute_many(query_by_adapter.values.compact, for_context: context)
|
55
|
+
indexed_search_hits_by_adapter = query_by_adapter.to_h do |adapter, query|
|
56
|
+
indexed_search_hits = query ? adapter.index_search_hits(responses_by_query.fetch(query)) : {} # : ::Hash[::String, ElasticGraph::GraphQL::DatastoreResponse::Document]
|
57
|
+
[adapter, indexed_search_hits]
|
58
|
+
end
|
59
|
+
|
60
|
+
representations.map.with_index do |representation, index|
|
61
|
+
next unless (adapter = representation&.adapter)
|
62
|
+
|
63
|
+
indexed_search_hits = indexed_search_hits_by_adapter.fetch(adapter)
|
64
|
+
adapter.identify_matching_hit(indexed_search_hits, representation, context: context, index: index)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
private
|
69
|
+
|
70
|
+
# Builds a datastore query for the given specific representation.
|
71
|
+
def build_query(adapter, representations, query_attributes)
|
72
|
+
return nil unless adapter.indexed?
|
73
|
+
|
74
|
+
type = adapter.type
|
75
|
+
query = @datastore_query_builder.new_query(search_index_definitions: type.search_index_definitions, **query_attributes)
|
76
|
+
adapter.customize_query(query, representations)
|
77
|
+
end
|
78
|
+
|
79
|
+
# Helper method that parses an `_Any` representation of an entity into a `Representation`
|
80
|
+
# object that contains the GraphQL `type` and a query `filter`.
|
81
|
+
#
|
82
|
+
# Based on whether or not this is successful, one of two things will happen:
|
83
|
+
#
|
84
|
+
# - If we can't parse it, an error description will be yielded and `nil` will be return
|
85
|
+
# (to indicate we couldn't parse it).
|
86
|
+
# - If we can parse it, the representation will be returned (and nothing will be yielded).
|
87
|
+
def try_parse_representation(representation, schema)
|
88
|
+
notify_error = proc do |msg|
|
89
|
+
yield msg.to_s
|
90
|
+
return nil # returns `nil` from the `try_parse_representation` method.
|
91
|
+
end
|
92
|
+
|
93
|
+
unless representation.is_a?(::Hash)
|
94
|
+
notify_error.call("is not a JSON object")
|
95
|
+
end
|
96
|
+
|
97
|
+
unless (typename = representation["__typename"])
|
98
|
+
notify_error.call("lacks a `__typename`")
|
99
|
+
end
|
100
|
+
|
101
|
+
type = begin
|
102
|
+
schema.type_named(typename)
|
103
|
+
rescue ElasticGraph::NotFoundError
|
104
|
+
notify_error.call("has an unrecognized `__typename`: #{typename}")
|
105
|
+
end
|
106
|
+
|
107
|
+
if (fields = representation.except("__typename")).empty?
|
108
|
+
notify_error.call("has only a `__typename` field")
|
109
|
+
end
|
110
|
+
|
111
|
+
if !type.indexed_document?
|
112
|
+
RepresentationWithoutIndex.new(
|
113
|
+
type: type,
|
114
|
+
representation_hash: representation
|
115
|
+
)
|
116
|
+
elsif (id = fields["id"])
|
117
|
+
RepresentationWithId.new(
|
118
|
+
type: type,
|
119
|
+
id: id,
|
120
|
+
other_fields: translate_field_names(fields.except("id"), type),
|
121
|
+
schema_element_names: @schema_element_names
|
122
|
+
)
|
123
|
+
else
|
124
|
+
RepresentationWithoutId.new(
|
125
|
+
type: type,
|
126
|
+
fields: translate_field_names(fields, type),
|
127
|
+
schema_element_names: @schema_element_names
|
128
|
+
)
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
def translate_field_names(fields_hash, type)
|
133
|
+
fields_hash.to_h do |public_field_name, value|
|
134
|
+
field = type.field_named(public_field_name)
|
135
|
+
field_name = field.name_in_index.to_s
|
136
|
+
|
137
|
+
case value
|
138
|
+
when ::Hash
|
139
|
+
[field_name, translate_field_names(value, field.type.unwrap_fully)]
|
140
|
+
else
|
141
|
+
# TODO: Add support for array cases (e.g. when value is an array of hashes).
|
142
|
+
[field_name, value]
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
# A simple value object containing a parsed form of an `_Any` representation when there's an `id` field.
|
148
|
+
class RepresentationWithId < ::Data.define(:type, :id, :other_fields, :schema_element_names, :adapter)
|
149
|
+
def initialize(type:, id:, other_fields:, schema_element_names:)
|
150
|
+
super(
|
151
|
+
type: type, id: id, other_fields: other_fields, schema_element_names: schema_element_names,
|
152
|
+
# All `RepresentationWithId` instances with the same `type` can be handled by the same adapter,
|
153
|
+
# since we can combine them into a single query filtering on `id`.
|
154
|
+
adapter: Adapter.new(type, schema_element_names)
|
155
|
+
)
|
156
|
+
end
|
157
|
+
|
158
|
+
Adapter = ::Data.define(:type, :schema_element_names) do
|
159
|
+
# @implements Adapter
|
160
|
+
|
161
|
+
def customize_query(query, representations)
|
162
|
+
# Given a set of representations, builds a filter that will match all of them (and only them).
|
163
|
+
all_ids = representations.map(&:id).reject { |id| id.is_a?(::Array) or id.is_a?(::Hash) }
|
164
|
+
filter = {"id" => {schema_element_names.equal_to_any_of => all_ids}}
|
165
|
+
|
166
|
+
query.merge_with(
|
167
|
+
document_pagination: {first: representations.length},
|
168
|
+
requested_fields: additional_requested_fields_for(representations),
|
169
|
+
filter: filter
|
170
|
+
)
|
171
|
+
end
|
172
|
+
|
173
|
+
# Given a query response, indexes the search hits for easy `O(1)` retrieval by `identify_matching_hit`.
|
174
|
+
# This allows us to provide `O(N)` complexity in our resolver instead of `O(N^2)`.
|
175
|
+
def index_search_hits(response)
|
176
|
+
response.to_h { |hit| [hit.id, hit] }
|
177
|
+
end
|
178
|
+
|
179
|
+
# Given some indexed search hits and a representation, identifies the search hit that matches the representation.
|
180
|
+
def identify_matching_hit(indexed_search_hits, representation, context:, index:)
|
181
|
+
hit = indexed_search_hits[representation.id]
|
182
|
+
hit if hit && match?(representation.other_fields, hit.payload)
|
183
|
+
end
|
184
|
+
|
185
|
+
def indexed?
|
186
|
+
true
|
187
|
+
end
|
188
|
+
|
189
|
+
private
|
190
|
+
|
191
|
+
def additional_requested_fields_for(representations)
|
192
|
+
representations.flat_map do |representation|
|
193
|
+
fields_in(representation.other_fields)
|
194
|
+
end
|
195
|
+
end
|
196
|
+
|
197
|
+
def fields_in(hash)
|
198
|
+
hash.flat_map do |field_name, value|
|
199
|
+
case value
|
200
|
+
when ::Hash
|
201
|
+
fields_in(value).map do |sub_field_name|
|
202
|
+
"#{field_name}.#{sub_field_name}"
|
203
|
+
end
|
204
|
+
else
|
205
|
+
# TODO: Add support for array cases.
|
206
|
+
[field_name]
|
207
|
+
end
|
208
|
+
end
|
209
|
+
end
|
210
|
+
|
211
|
+
def match?(expected, actual)
|
212
|
+
expected.all? do |key, value|
|
213
|
+
case value
|
214
|
+
when ::Hash
|
215
|
+
match?(value, actual[key])
|
216
|
+
when ::Array
|
217
|
+
# TODO: Add support for array filtering, instead of ignoring it.
|
218
|
+
true
|
219
|
+
else
|
220
|
+
value == actual[key]
|
221
|
+
end
|
222
|
+
end
|
223
|
+
end
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
# A simple value object containing a parsed form of an `_Any` representation when there's no `id` field.
|
228
|
+
class RepresentationWithoutId < ::Data.define(:type, :fields, :schema_element_names)
|
229
|
+
# @dynamic type
|
230
|
+
|
231
|
+
# Each `RepresentationWithoutId` instance needs to be handled by a separate adapter. We can't
|
232
|
+
# safely combine representations into a single datastore query, so we want each to handled
|
233
|
+
# by a separate adapter instance. So, we use the representation itself as the adapter.
|
234
|
+
def adapter
|
235
|
+
self
|
236
|
+
end
|
237
|
+
|
238
|
+
def customize_query(query, representations)
|
239
|
+
query.merge_with(
|
240
|
+
# In the case of representations which don't query Id, we ask for 2 documents so that
|
241
|
+
# if something weird is going on and it matches more than 1, we can detect that and return an error.
|
242
|
+
document_pagination: {first: 2},
|
243
|
+
filter: build_filter_for_hash(fields)
|
244
|
+
)
|
245
|
+
end
|
246
|
+
|
247
|
+
def index_search_hits(response)
|
248
|
+
{"search_hits" => response.to_a}
|
249
|
+
end
|
250
|
+
|
251
|
+
def identify_matching_hit(indexed_search_hits, representation, context:, index:)
|
252
|
+
search_hits = indexed_search_hits.fetch("search_hits")
|
253
|
+
if search_hits.size > 1
|
254
|
+
context.add_error(::GraphQL::ExecutionError.new("Representation at index #{index} matches more than one entity."))
|
255
|
+
nil
|
256
|
+
else
|
257
|
+
search_hits.first
|
258
|
+
end
|
259
|
+
end
|
260
|
+
|
261
|
+
def indexed?
|
262
|
+
true
|
263
|
+
end
|
264
|
+
|
265
|
+
private
|
266
|
+
|
267
|
+
def build_filter_for_hash(fields)
|
268
|
+
# We must exclude `Array` values because we'll get an exception from the datastore if we allow it here.
|
269
|
+
# Filtering it out just means that the representation will not match an entity.
|
270
|
+
fields.reject { |key, value| value.is_a?(::Array) }.transform_values do |value|
|
271
|
+
if value.is_a?(::Hash)
|
272
|
+
build_filter_for_hash(value)
|
273
|
+
else
|
274
|
+
{schema_element_names.equal_to_any_of => [value]}
|
275
|
+
end
|
276
|
+
end
|
277
|
+
end
|
278
|
+
end
|
279
|
+
|
280
|
+
class RepresentationWithoutIndex < ::Data.define(:type, :representation_hash)
|
281
|
+
# @dynamic type
|
282
|
+
def adapter
|
283
|
+
self
|
284
|
+
end
|
285
|
+
|
286
|
+
def customize_query(query, representations)
|
287
|
+
nil
|
288
|
+
end
|
289
|
+
|
290
|
+
def index_search_hits(response)
|
291
|
+
nil
|
292
|
+
end
|
293
|
+
|
294
|
+
def identify_matching_hit(indexed_search_hits, representation, context:, index:)
|
295
|
+
representation.representation_hash
|
296
|
+
end
|
297
|
+
|
298
|
+
def indexed?
|
299
|
+
false
|
300
|
+
end
|
301
|
+
end
|
302
|
+
end
|
303
|
+
end
|
304
|
+
end
|
305
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# Copyright 2024 Block, Inc.
|
2
|
+
#
|
3
|
+
# Use of this source code is governed by an MIT-style
|
4
|
+
# license that can be found in the LICENSE file or at
|
5
|
+
# https://opensource.org/licenses/MIT.
|
6
|
+
#
|
7
|
+
# frozen_string_literal: true
|
8
|
+
|
9
|
+
require "apollo-federation/tracing"
|
10
|
+
|
11
|
+
module ElasticGraph
|
12
|
+
module Apollo
|
13
|
+
module GraphQL
|
14
|
+
# This extension is designed to hook `ElasticGraph::GraphQL::HTTPEndpoint` in order
|
15
|
+
# to provide Apollo Federated Tracing:
|
16
|
+
#
|
17
|
+
# https://www.apollographql.com/docs/federation/metrics/
|
18
|
+
#
|
19
|
+
# Luckily, the apollo-federation gem supports this--we just need to:
|
20
|
+
#
|
21
|
+
# 1. Use the `ApolloFederation::Tracing` plugin (implemented via `EngineExtension#graphql_gem_plugins`).
|
22
|
+
# 2. Conditionally pass `tracing_enabled: true` into in `context`.
|
23
|
+
#
|
24
|
+
# This extension handles the latter requirement. For more info, see:
|
25
|
+
# https://github.com/Gusto/apollo-federation-ruby#tracing
|
26
|
+
module HTTPEndpointExtension
|
27
|
+
def with_context(request)
|
28
|
+
# Steep has an error here for some reason:
|
29
|
+
# UnexpectedError: undefined method `selector' for #<Parser::Source::Map::Keyword:0x0000000131979b18>
|
30
|
+
__skip__ = super(request) do |context|
|
31
|
+
# `ApolloFederation::Tracing.should_add_traces` expects the header to be in SCREAMING_SNAKE_CASE with an HTTP_ prefix:
|
32
|
+
# https://github.com/Gusto/apollo-federation-ruby/blob/v3.8.4/lib/apollo-federation/tracing.rb#L5
|
33
|
+
normalized_headers = request.headers.transform_keys { |key| "HTTP_#{key.upcase.tr("-", "_")}" }
|
34
|
+
|
35
|
+
if ApolloFederation::Tracing.should_add_traces(normalized_headers)
|
36
|
+
context = context.merge(tracing_enabled: true)
|
37
|
+
end
|
38
|
+
|
39
|
+
yield context
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# Copyright 2024 Block, Inc.
|
2
|
+
#
|
3
|
+
# Use of this source code is governed by an MIT-style
|
4
|
+
# license that can be found in the LICENSE file or at
|
5
|
+
# https://opensource.org/licenses/MIT.
|
6
|
+
#
|
7
|
+
# frozen_string_literal: true
|
8
|
+
|
9
|
+
module ElasticGraph
|
10
|
+
module Apollo
|
11
|
+
module GraphQL
|
12
|
+
# GraphQL resolver for the Apollo `Query._service` field.
|
13
|
+
class ServiceFieldResolver
|
14
|
+
def can_resolve?(field:, object:)
|
15
|
+
field.parent_type.name == :Query && field.name == :_service
|
16
|
+
end
|
17
|
+
|
18
|
+
def resolve(field:, object:, args:, context:, lookahead:)
|
19
|
+
{"sdl" => service_sdl(context.fetch(:elastic_graph_schema).graphql_schema)}
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def service_sdl(graphql_schema)
|
25
|
+
::GraphQL::Schema::Printer.print_schema(graphql_schema)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|