elasticgraph-apollo 0.18.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +63 -0
  4. data/apollo_tests_implementation/Dockerfile +66 -0
  5. data/apollo_tests_implementation/Gemfile +27 -0
  6. data/apollo_tests_implementation/Rakefile +22 -0
  7. data/apollo_tests_implementation/config/products_schema.rb +173 -0
  8. data/apollo_tests_implementation/config/settings.yaml +34 -0
  9. data/apollo_tests_implementation/config.ru +122 -0
  10. data/apollo_tests_implementation/docker-compose.yaml +18 -0
  11. data/apollo_tests_implementation/lib/test_implementation_extension.rb +58 -0
  12. data/apollo_tests_implementation/wait_for_datastore.sh +17 -0
  13. data/elasticgraph-apollo.gemspec +27 -0
  14. data/lib/elastic_graph/apollo/graphql/engine_extension.rb +52 -0
  15. data/lib/elastic_graph/apollo/graphql/entities_field_resolver.rb +305 -0
  16. data/lib/elastic_graph/apollo/graphql/http_endpoint_extension.rb +45 -0
  17. data/lib/elastic_graph/apollo/graphql/service_field_resolver.rb +30 -0
  18. data/lib/elastic_graph/apollo/schema_definition/api_extension.rb +385 -0
  19. data/lib/elastic_graph/apollo/schema_definition/apollo_directives.rb +119 -0
  20. data/lib/elastic_graph/apollo/schema_definition/argument_extension.rb +20 -0
  21. data/lib/elastic_graph/apollo/schema_definition/entity_type_extension.rb +30 -0
  22. data/lib/elastic_graph/apollo/schema_definition/enum_type_extension.rb +23 -0
  23. data/lib/elastic_graph/apollo/schema_definition/enum_value_extension.rb +20 -0
  24. data/lib/elastic_graph/apollo/schema_definition/factory_extension.rb +104 -0
  25. data/lib/elastic_graph/apollo/schema_definition/field_extension.rb +59 -0
  26. data/lib/elastic_graph/apollo/schema_definition/graphql_sdl_enumerator_extension.rb +69 -0
  27. data/lib/elastic_graph/apollo/schema_definition/input_type_extension.rb +20 -0
  28. data/lib/elastic_graph/apollo/schema_definition/interface_type_extension.rb +25 -0
  29. data/lib/elastic_graph/apollo/schema_definition/object_type_extension.rb +28 -0
  30. data/lib/elastic_graph/apollo/schema_definition/scalar_type_extension.rb +23 -0
  31. data/lib/elastic_graph/apollo/schema_definition/state_extension.rb +23 -0
  32. data/lib/elastic_graph/apollo/schema_definition/union_type_extension.rb +20 -0
  33. data/script/boot_eg_apollo_implementation +22 -0
  34. data/script/export_docker_env_vars.sh +15 -0
  35. data/script/test_compatibility +54 -0
  36. 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