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.
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