elasticgraph-apollo 0.18.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|