graphql-remote_loader 0.0.2 → 0.0.3

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 994f881ffc8571f96fbe6c969cba79f030b6bbb6
4
- data.tar.gz: 4bdf0b71e51d6e9acc7611e46197615165cd33cb
3
+ metadata.gz: b4e6ea2888b09a78e64eeec68a7905e5932c0aa2
4
+ data.tar.gz: 93ff402aaff2686821e835ecf1200b894f00c61a
5
5
  SHA512:
6
- metadata.gz: 3e2caaa027c826d9a116c892792f65f915f850a8d324b118809806def027e1f3eecf6aa37748a6bac23b276bad54606519a80c9127121bc1ebeefb917c86677d
7
- data.tar.gz: 9225082887eb70fc5e42eea716df857b928ab0b6ba6bef9150d064785520005fa40c3bb0cfed373963e2634ba6c3903fcebf6af931f1982191e355a136f3ec88
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.1)
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? /\Ap[0-9]+.*[^?]\z/ }
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.underscore] = filtered_value
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
- new_ast = []
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
- private
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
- if matching_field
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
- def attach_primes!(query, prime)
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
- def query_string_from_ast(ast)
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
- args = node[:arguments].map do |arg, value|
63
- value = "\"#{value}\"" if value.is_a? String
23
+ def merge_parsed_queries(parsed_queries)
24
+ merged_query = parsed_queries.pop
64
25
 
65
- "#{arg}: #{value}"
66
- end
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
- body_string = unless node[:body].empty?
75
- str = node[:body].map do |node|
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
- "#{field}#{arg_string} #{body_string}".strip
36
+ merged_query
83
37
  end
84
38
 
85
- def parse(query)
86
- tokenizer = QueryTokenizer.new("query { #{query} }")
87
- QueryAST.build(tokenizer)
88
- end
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
- def build_node(tokenizer)
99
- {
100
- field: get_field(tokenizer),
101
- arguments: get_args(tokenizer),
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
- def get_args(tokenizer)
124
- args = {}
125
- return args unless (tokenizer.peek[:type] == :left_paren)
126
-
127
- # remove "("
128
- tokenizer.pop
129
-
130
- while true
131
- token = tokenizer.pop
132
- validate_token_type(token, :key)
133
-
134
- # Remove :
135
- key = token[:string].sub(":", "")
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
- args
70
+ same_name && same_args
166
71
  end
167
72
 
168
- def build_body(tokenizer)
169
- body = []
170
- return body unless (tokenizer.peek[:type] == :left_brace)
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
- def validate_token_type(token, expected_type)
187
- unless token[:type] == expected_type
188
- raise ParseException, "Expected #{expected_type.to_s} token, got #{token[:string]}"
189
- end
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
- class QueryTokenizer
195
- def initialize(query)
196
- @query = query.chars
197
- set_next_token
198
- end
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
- if is_brace_or_bracket_or_comma?(next_char)
229
- if token_string.length == 0
230
- token_string << @query.shift
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
- token_string
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
- def tokenize_string(next_token_string)
247
- token = case next_token_string
248
- when '{'
249
- :left_brace
250
- when '}'
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
- def trim_leading_whitespace
285
- while is_whitespace?(@query.first)
286
- @query.shift
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
- def is_whitespace?(char)
291
- char == "\n" || char == " "
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
- def is_brace_or_bracket_or_comma?(char)
295
- char == "{" || char == "}" || char == "(" || char == ")" || char == ","
296
- end
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
@@ -1,5 +1,5 @@
1
1
  module GraphQL
2
2
  module RemoteLoader
3
- VERSION = "0.0.2"
3
+ VERSION = "0.0.3"
4
4
  end
5
5
  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.2
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-09 00:00:00.000000000 Z
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