graphql-remote_loader 0.0.2 → 0.0.3
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 +4 -4
- data/Gemfile.lock +2 -2
- data/README.md +65 -0
- data/lib/graphql/remote_loader/loader.rb +14 -2
- data/lib/graphql/remote_loader/query_merger.rb +79 -251
- data/lib/graphql/remote_loader/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: b4e6ea2888b09a78e64eeec68a7905e5932c0aa2
|
4
|
+
data.tar.gz: 93ff402aaff2686821e835ecf1200b894f00c61a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 103d053506f99d3b285122a1eef0bf5dbb6cd685978a0cd78a316644211eb97a6bd6ccc1a0e18374b922011e94a7e54278b03650e147aa47b765f048179a3a18
|
7
|
+
data.tar.gz: f22cc0abee65eaa738c640abff3dce91fa7fd17f58523d4abde0094f1000c4d7f7a05e80fe8608172a95dc51383f8db0ad28a469ff660199433c1449235cb1e7
|
data/Gemfile.lock
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
graphql-remote_loader (0.0.
|
4
|
+
graphql-remote_loader (0.0.2)
|
5
5
|
graphql (~> 1.6)
|
6
6
|
graphql-batch (~> 0.3)
|
7
7
|
|
@@ -46,7 +46,7 @@ DEPENDENCIES
|
|
46
46
|
graphql-remote_loader!
|
47
47
|
pry-byebug (~> 3.4)
|
48
48
|
rake (~> 10.0)
|
49
|
-
rspec
|
49
|
+
rspec (~> 3.6)
|
50
50
|
|
51
51
|
BUNDLED WITH
|
52
52
|
1.15.4
|
data/README.md
ADDED
@@ -0,0 +1,65 @@
|
|
1
|
+
# GraphQL Remote Loader
|
2
|
+
Performant, batched GraphQL queries from within the resolvers of a [`graphql-ruby`](https://github.com/rmosolgo/graphql-ruby) API.
|
3
|
+
|
4
|
+
## Example
|
5
|
+
|
6
|
+
```ruby
|
7
|
+
field :repositoryUrl, !types.String do
|
8
|
+
description "The repository URL"
|
9
|
+
|
10
|
+
resolve ->(obj, args, ctx) do
|
11
|
+
query = <<-GQL
|
12
|
+
node(id: "#{obj.global_relay_id}"){
|
13
|
+
... on Repository {
|
14
|
+
url
|
15
|
+
}
|
16
|
+
}
|
17
|
+
GQL
|
18
|
+
|
19
|
+
GitHubLoader.load(query).then do |results|
|
20
|
+
results["node"]["url"]
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
```
|
25
|
+
|
26
|
+
## Description
|
27
|
+
`graphql-remote_loader` allows for querying GraphQL APIs from within resolvers of a [`graphql-ruby`](https://github.com/rmosolgo/graphql-ruby) API.
|
28
|
+
|
29
|
+
This can be used to create GraphQL APIs that depend on data from other GraphQL APIs, either remote or local.
|
30
|
+
|
31
|
+
A promise-based resolution strategy from Shopify's [`graphql-batch`](https://github.com/Shopify/graphql-batch) is used to batch all requested data into a single GraphQL query. Promises are fulfilled with only the data they requested.
|
32
|
+
|
33
|
+
You can think of it as a lightweight version of schema-stitching.
|
34
|
+
|
35
|
+
## How to use
|
36
|
+
First, you'll need to install the gem. Either do `gem install graphql-remote_loader` or add this to your Gemfile:
|
37
|
+
|
38
|
+
```
|
39
|
+
gem "graphql-remote_loader"
|
40
|
+
```
|
41
|
+
|
42
|
+
The gem provides a base loader `GraphQL::RemoteLoader::Loader` which does most of the heavy lifting. In order to remain client-agnostic, there's an unimplemented no-op that queries the external GraphQL API.
|
43
|
+
|
44
|
+
To use, create a new class that inherits from `GraphQL::RemoteLoader::Loader` and define `def query(query_string)`. The method takes a query String as input. The expected output is a response `Hash`, or some object that responds to `#to_h`.
|
45
|
+
|
46
|
+
Example:
|
47
|
+
|
48
|
+
```ruby
|
49
|
+
require "graphql/remote_loader"
|
50
|
+
|
51
|
+
module MyApp
|
52
|
+
class GitHubLoader < GraphQL::RemoteLoader::Loader
|
53
|
+
def query(query_string)
|
54
|
+
parsed_query = MyApp::Client.parse(query_string)
|
55
|
+
MyApp.query(parsed_query)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
```
|
60
|
+
|
61
|
+
This example uses [`graphql-client`](https://github.com/github/graphql-client). Any client, or even just plain `cURL`/`HTTP` can be used.
|
62
|
+
|
63
|
+
## Current State
|
64
|
+
|
65
|
+
This project is very much WIP. Some TODOs are listed in the issues. Bugs and feature requests should be added as issues.
|
@@ -19,6 +19,10 @@ module GraphQL
|
|
19
19
|
self.for.load([query, prime])
|
20
20
|
end
|
21
21
|
|
22
|
+
def self.reset_index
|
23
|
+
@index = nil
|
24
|
+
end
|
25
|
+
|
22
26
|
# Given a query string, return a response JSON
|
23
27
|
def query(query_string)
|
24
28
|
raise NotImplementedError,
|
@@ -41,7 +45,7 @@ module GraphQL
|
|
41
45
|
filtered_results = {}
|
42
46
|
|
43
47
|
# Select field keys on the results hash
|
44
|
-
fields = obj.keys.select { |k| k.match
|
48
|
+
fields = obj.keys.select { |k| k.match /\Ap[0-9]+.*[^?]\z/ }
|
45
49
|
|
46
50
|
# Filter methods that were not requested in this sub-query
|
47
51
|
fields = fields.select do |field|
|
@@ -56,11 +60,19 @@ module GraphQL
|
|
56
60
|
method_value = obj[method]
|
57
61
|
filtered_value = filter_keys_on_response(method_value, prime)
|
58
62
|
|
59
|
-
filtered_results[method_name
|
63
|
+
filtered_results[underscore(method_name)] = filtered_value
|
60
64
|
end
|
61
65
|
|
62
66
|
filtered_results
|
63
67
|
end
|
68
|
+
|
69
|
+
def underscore(str)
|
70
|
+
str.gsub(/::/, '/').
|
71
|
+
gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2').
|
72
|
+
gsub(/([a-z\d])([A-Z])/,'\1_\2').
|
73
|
+
tr("-", "_").
|
74
|
+
downcase
|
75
|
+
end
|
64
76
|
end
|
65
77
|
end
|
66
78
|
end
|
@@ -1,299 +1,127 @@
|
|
1
1
|
module GraphQL
|
2
2
|
module RemoteLoader
|
3
|
-
|
4
3
|
# Given a list of queries and their prime UIDs, generate the merged and labeled
|
5
4
|
# GraphQL query to be sent off to the remote backend.
|
6
5
|
class QueryMerger
|
7
6
|
class << self
|
8
7
|
def merge(queries_and_primes)
|
9
|
-
|
10
|
-
|
11
|
-
queries_and_primes.each do |query, prime|
|
12
|
-
parsed_query = [parse(query)]
|
13
|
-
attach_primes!(parsed_query, prime)
|
14
|
-
merge_query(parsed_query, new_ast)
|
15
|
-
end
|
16
|
-
|
17
|
-
query_string_from_ast(new_ast)
|
18
|
-
end
|
8
|
+
parsed_queries = queries_and_primes.map do |query, prime|
|
9
|
+
parsed_query = parse(query)
|
19
10
|
|
20
|
-
|
21
|
-
|
22
|
-
def merge_query(query, ast)
|
23
|
-
query.each do |query_field|
|
24
|
-
matching_field = ast.find do |ast_field|
|
25
|
-
query_field[:field] == ast_field[:field] &&
|
26
|
-
query_field[:arguments] == ast_field[:arguments]
|
11
|
+
parsed_query.definitions.each do |definition|
|
12
|
+
attach_primes!(definition.children, prime)
|
27
13
|
end
|
28
14
|
|
29
|
-
|
30
|
-
matching_field[:primes] *= query_field[:primes]
|
31
|
-
merge_query(query_field[:body], matching_field[:body])
|
32
|
-
else
|
33
|
-
ast << query_field
|
34
|
-
end
|
15
|
+
parsed_query
|
35
16
|
end
|
36
|
-
end
|
37
17
|
|
38
|
-
|
39
|
-
query.each do |field|
|
40
|
-
field[:primes] = prime
|
41
|
-
attach_primes!(field[:body], prime)
|
42
|
-
end
|
18
|
+
merge_parsed_queries(parsed_queries).to_query_string
|
43
19
|
end
|
44
20
|
|
45
|
-
|
46
|
-
ast_node_strings = ast.map do |ast_node|
|
47
|
-
query_string_for_node(ast_node)
|
48
|
-
end.join(" ")
|
49
|
-
ast_node_strings
|
50
|
-
end
|
51
|
-
|
52
|
-
def query_string_for_node(node)
|
53
|
-
field = node[:field]
|
54
|
-
|
55
|
-
# TODO: This won't work for fields named `query`. Fix once
|
56
|
-
# I move to Roberts non-jank parser
|
57
|
-
unless field.include?("...") || field == "query"
|
58
|
-
# assign preix if not a spread
|
59
|
-
field = "p#{node[:primes]}#{field}: #{field}"
|
60
|
-
end
|
21
|
+
private
|
61
22
|
|
62
|
-
|
63
|
-
|
23
|
+
def merge_parsed_queries(parsed_queries)
|
24
|
+
merged_query = parsed_queries.pop
|
64
25
|
|
65
|
-
|
66
|
-
|
26
|
+
parsed_queries.each do |query|
|
27
|
+
merge_query_recursive(query.definitions[0], merged_query.definitions[0])
|
28
|
+
merge_fragment_definitions(query, merged_query)
|
67
29
|
|
68
|
-
arg_string = unless args.empty?
|
69
|
-
"(#{args.join(",")})"
|
70
|
-
else
|
71
|
-
""
|
72
30
|
end
|
73
31
|
|
74
|
-
|
75
|
-
|
76
|
-
query_string_for_node(node)
|
77
|
-
end
|
78
|
-
|
79
|
-
"{ #{str.join(" ")} }"
|
32
|
+
merged_query.definitions.each do |definition|
|
33
|
+
apply_aliases!(definition.selections)
|
80
34
|
end
|
81
35
|
|
82
|
-
|
36
|
+
merged_query
|
83
37
|
end
|
84
38
|
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
class QueryAST
|
91
|
-
class ParseException < Exception; end
|
92
|
-
|
93
|
-
class << self
|
94
|
-
def build(tokenizer)
|
95
|
-
result = build_node(tokenizer)
|
39
|
+
# merges a_query's fragment definitions into b_query
|
40
|
+
def merge_fragment_definitions(a_query, b_query)
|
41
|
+
a_query.definitions[1..-1].each do |a_definition|
|
42
|
+
matching_fragment_definition = b_query.definitions.find do |b_definition|
|
43
|
+
a_definition.name == b_definition.name
|
96
44
|
end
|
97
45
|
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
body: build_body(tokenizer)
|
103
|
-
}
|
104
|
-
end
|
105
|
-
|
106
|
-
def get_field(tokenizer)
|
107
|
-
token = tokenizer.pop
|
108
|
-
|
109
|
-
if token[:type] == :spread
|
110
|
-
validate_token_type(tokenizer.pop, :on)
|
111
|
-
spread_type = tokenizer.pop[:string]
|
112
|
-
|
113
|
-
return "... on #{spread_type}"
|
114
|
-
end
|
115
|
-
|
116
|
-
validate_token_type(token, :field)
|
117
|
-
|
118
|
-
throw_parse_exception(token, "field") unless (token[:type] == :field)
|
119
|
-
|
120
|
-
token[:string]
|
46
|
+
if matching_fragment_definition
|
47
|
+
merge_query_recursive(a_definition, matching_fragment_definition)
|
48
|
+
else
|
49
|
+
b_query.definitions << a_definition
|
121
50
|
end
|
51
|
+
end
|
52
|
+
end
|
122
53
|
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
token = tokenizer.pop
|
138
|
-
value = case token[:type]
|
139
|
-
when :string
|
140
|
-
token[:string][1..-2] # remove ""
|
141
|
-
when :int
|
142
|
-
Integer(token[:string])
|
143
|
-
when :float
|
144
|
-
Float(token[:string])
|
145
|
-
when :true
|
146
|
-
true
|
147
|
-
when :false
|
148
|
-
false
|
149
|
-
else
|
150
|
-
raise ParseException, "Expected string, int, float, or boolean, got #{token[:string]}"
|
151
|
-
end
|
152
|
-
|
153
|
-
args[key.to_sym] = value
|
154
|
-
|
155
|
-
if tokenizer.peek[:type] == :comma
|
156
|
-
tokenizer.pop
|
157
|
-
elsif tokenizer.peek[:type] == :right_paren
|
158
|
-
tokenizer.pop
|
159
|
-
break
|
160
|
-
else
|
161
|
-
raise ParseException, "Expected comma or right paren, got #{tokenizer[:string]}"
|
162
|
-
end
|
54
|
+
# merges a_query into b_query
|
55
|
+
def merge_query_recursive(a_query, b_query)
|
56
|
+
exempt_node_types = [
|
57
|
+
GraphQL::Language::Nodes::InlineFragment,
|
58
|
+
GraphQL::Language::Nodes::FragmentSpread
|
59
|
+
]
|
60
|
+
|
61
|
+
a_query.selections.each do |a_query_selection|
|
62
|
+
matching_field = b_query.selections.find do |b_query_selection|
|
63
|
+
same_name = a_query_selection.name == b_query_selection.name
|
64
|
+
same_args = if exempt_node_types.any? { |type| b_query_selection.is_a?(type) }
|
65
|
+
true
|
66
|
+
else
|
67
|
+
arguments_equal?(a_query_selection, b_query_selection)
|
163
68
|
end
|
164
69
|
|
165
|
-
|
70
|
+
same_name && same_args
|
166
71
|
end
|
167
72
|
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
# remove "{"
|
173
|
-
tokenizer.pop
|
174
|
-
|
175
|
-
while true
|
176
|
-
body << build_node(tokenizer)
|
177
|
-
break if (tokenizer.peek[:type] == :right_brace)
|
178
|
-
end
|
179
|
-
|
180
|
-
# remove "}"
|
181
|
-
tokenizer.pop
|
182
|
-
|
183
|
-
body
|
184
|
-
end
|
73
|
+
if matching_field
|
74
|
+
new_prime = matching_field.instance_variable_get(:@prime) *
|
75
|
+
a_query_selection.instance_variable_get(:@prime)
|
185
76
|
|
186
|
-
|
187
|
-
unless
|
188
|
-
|
189
|
-
|
77
|
+
matching_field.instance_variable_set(:@prime, new_prime)
|
78
|
+
merge_query_recursive(a_query_selection, matching_field) unless exempt_node_types.any? { |type| matching_field.is_a?(type) }
|
79
|
+
else
|
80
|
+
b_query.selections << a_query_selection
|
190
81
|
end
|
191
82
|
end
|
192
83
|
end
|
193
84
|
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
# Get next token, without removing from stream
|
201
|
-
def peek
|
202
|
-
@next_token
|
203
|
-
end
|
204
|
-
|
205
|
-
# Pop next token
|
206
|
-
def pop
|
207
|
-
token = @next_token
|
208
|
-
set_next_token if token
|
209
|
-
|
210
|
-
token
|
211
|
-
end
|
212
|
-
|
213
|
-
private
|
214
|
-
|
215
|
-
# Read @query to set @next_token
|
216
|
-
def set_next_token
|
217
|
-
next_token_string = extract_token_string_from_query
|
218
|
-
@next_token = tokenize_string(next_token_string)
|
219
|
-
end
|
220
|
-
|
221
|
-
def extract_token_string_from_query
|
222
|
-
trim_leading_whitespace
|
223
|
-
|
224
|
-
token_string = ""
|
225
|
-
while true
|
226
|
-
next_char = @query.first || break
|
85
|
+
# Are two lists of arguments equal?
|
86
|
+
def arguments_equal?(a, b)
|
87
|
+
# Return true if both don't have args.
|
88
|
+
# Return false if only one doesn't have args
|
89
|
+
return true unless a.respond_to?(:arguments) && b.respond_to?(:arguments)
|
90
|
+
return false unless a.respond_to?(:arguments) || b.respond_to?(:arguments)
|
227
91
|
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
end
|
232
|
-
|
233
|
-
break
|
234
|
-
else
|
235
|
-
if !is_whitespace?(@query.first)
|
236
|
-
token_string << @query.shift
|
237
|
-
else
|
238
|
-
break
|
239
|
-
end
|
240
|
-
end
|
241
|
-
end
|
92
|
+
a.arguments.map { |arg| {name: arg.name, value: arg.value}.to_s }.sort ==
|
93
|
+
b.arguments.map { |arg| {name: arg.name, value: arg.value}.to_s }.sort
|
94
|
+
end
|
242
95
|
|
243
|
-
|
96
|
+
def attach_primes!(query_fields, prime)
|
97
|
+
query_fields.each do |field|
|
98
|
+
field.instance_variable_set(:@prime, prime)
|
99
|
+
attach_primes!(field.children, prime)
|
244
100
|
end
|
101
|
+
end
|
245
102
|
|
246
|
-
|
247
|
-
|
248
|
-
|
249
|
-
|
250
|
-
|
251
|
-
:right_brace
|
252
|
-
when '('
|
253
|
-
:left_paren
|
254
|
-
when ')'
|
255
|
-
:right_paren
|
256
|
-
when ','
|
257
|
-
:comma
|
258
|
-
when /\A[[:digit:]]+\z/
|
259
|
-
:int
|
260
|
-
when /\A[[:digit:]]+\.[[:digit:]]+\z/
|
261
|
-
:float
|
262
|
-
when 'true'
|
263
|
-
:true
|
264
|
-
when 'false'
|
265
|
-
:false
|
266
|
-
when 'on'
|
267
|
-
:on
|
268
|
-
when /\A".*"\z/
|
269
|
-
:string
|
270
|
-
when /\A.+:\z/
|
271
|
-
:key
|
272
|
-
when ''
|
273
|
-
next_token_string = "END OF STREAM"
|
274
|
-
:empty
|
275
|
-
when '...'
|
276
|
-
:spread
|
277
|
-
else
|
278
|
-
:field
|
279
|
-
end
|
280
|
-
|
281
|
-
{ type: token, string: next_token_string }
|
282
|
-
end
|
103
|
+
def apply_aliases!(query_selections)
|
104
|
+
exempt_node_types = [
|
105
|
+
GraphQL::Language::Nodes::InlineFragment,
|
106
|
+
GraphQL::Language::Nodes::FragmentSpread
|
107
|
+
]
|
283
108
|
|
284
|
-
|
285
|
-
|
286
|
-
|
109
|
+
query_selections.each do |selection|
|
110
|
+
unless exempt_node_types.any? { |type| selection.is_a? type }
|
111
|
+
prime_factor = selection.instance_variable_get(:@prime)
|
112
|
+
selection.alias = "p#{prime_factor}#{selection.name}"
|
287
113
|
end
|
288
|
-
end
|
289
114
|
|
290
|
-
|
291
|
-
|
115
|
+
# Some nodes don't have selections (e.g. fragment spreads)
|
116
|
+
apply_aliases!(selection.selections) if selection.respond_to? :selections
|
292
117
|
end
|
118
|
+
end
|
293
119
|
|
294
|
-
|
295
|
-
|
296
|
-
|
120
|
+
# Allows "foo" or "query { foo }"
|
121
|
+
def parse(query)
|
122
|
+
GraphQL.parse(query)
|
123
|
+
rescue GraphQL::ParseError
|
124
|
+
GraphQL.parse("query { #{query} }")
|
297
125
|
end
|
298
126
|
end
|
299
127
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: graphql-remote_loader
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
4
|
+
version: 0.0.3
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Nathaniel Woodthorpe
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2018-06-
|
11
|
+
date: 2018-06-13 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: graphql
|
@@ -106,6 +106,7 @@ files:
|
|
106
106
|
- ".gitignore"
|
107
107
|
- Gemfile
|
108
108
|
- Gemfile.lock
|
109
|
+
- README.md
|
109
110
|
- graphql-remote_loader.gemspec
|
110
111
|
- lib/graphql/remote_loader.rb
|
111
112
|
- lib/graphql/remote_loader/loader.rb
|