artemis 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +10 -0
  3. data/.rspec +2 -0
  4. data/.travis.yml +36 -0
  5. data/Appraisals +32 -0
  6. data/CODE_OF_CONDUCT.md +74 -0
  7. data/Gemfile +10 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +181 -0
  10. data/Rakefile +14 -0
  11. data/artemis.gemspec +28 -0
  12. data/bin/console +14 -0
  13. data/bin/setup +8 -0
  14. data/gemfiles/.bundle/config +2 -0
  15. data/gemfiles/rails_42.gemfile +12 -0
  16. data/gemfiles/rails_50.gemfile +11 -0
  17. data/gemfiles/rails_51.gemfile +11 -0
  18. data/gemfiles/rails_52.gemfile +11 -0
  19. data/gemfiles/rails_edge.gemfile +14 -0
  20. data/lib/artemis.rb +3 -0
  21. data/lib/artemis/adapters.rb +25 -0
  22. data/lib/artemis/adapters/abstract_adapter.rb +46 -0
  23. data/lib/artemis/adapters/curb_adapter.rb +56 -0
  24. data/lib/artemis/adapters/net_http_adapter.rb +51 -0
  25. data/lib/artemis/adapters/net_http_persistent_adapter.rb +46 -0
  26. data/lib/artemis/adapters/test_adapter.rb +29 -0
  27. data/lib/artemis/client.rb +245 -0
  28. data/lib/artemis/exceptions.rb +19 -0
  29. data/lib/artemis/graphql_endpoint.rb +54 -0
  30. data/lib/artemis/railtie.rb +50 -0
  31. data/lib/artemis/version.rb +3 -0
  32. data/lib/generators/artemis/USAGE +11 -0
  33. data/lib/generators/artemis/install_generator.rb +59 -0
  34. data/lib/generators/artemis/templates/.gitkeep +0 -0
  35. data/lib/generators/artemis/templates/client.rb +10 -0
  36. data/lib/generators/artemis/templates/graphql.yml +19 -0
  37. data/lib/tasks/artemis.rake +47 -0
  38. data/spec/adapters_spec.rb +106 -0
  39. data/spec/autoloading_spec.rb +146 -0
  40. data/spec/callbacks_spec.rb +46 -0
  41. data/spec/client_spec.rb +161 -0
  42. data/spec/endpoint_spec.rb +45 -0
  43. data/spec/fixtures/metaphysics.rb +2 -0
  44. data/spec/fixtures/metaphysics/_artist_fragment.graphql +4 -0
  45. data/spec/fixtures/metaphysics/artist.graphql +7 -0
  46. data/spec/fixtures/metaphysics/artists.graphql +8 -0
  47. data/spec/fixtures/metaphysics/artwork.graphql +8 -0
  48. data/spec/fixtures/metaphysics/schema.json +49811 -0
  49. data/spec/spec_helper.rb +48 -0
  50. metadata +219 -0
@@ -0,0 +1,2 @@
1
+ ---
2
+ BUNDLE_RETRY: "1"
@@ -0,0 +1,12 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "pry"
6
+ gem "pry-byebug", platforms: :mri
7
+ gem "rails", "~> 4.2"
8
+ gem "railties", "~> 4.2"
9
+ gem "activesupport", "~> 4.2"
10
+ gem "minitest", "5.10.3"
11
+
12
+ gemspec path: "../"
@@ -0,0 +1,11 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "pry"
6
+ gem "pry-byebug", platforms: :mri
7
+ gem "rails", "~> 5.0"
8
+ gem "railties", "~> 5.0"
9
+ gem "activesupport", "~> 5.0"
10
+
11
+ gemspec path: "../"
@@ -0,0 +1,11 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "pry"
6
+ gem "pry-byebug", platforms: :mri
7
+ gem "rails", "~> 5.1"
8
+ gem "railties", "~> 5.1"
9
+ gem "activesupport", "~> 5.1"
10
+
11
+ gemspec path: "../"
@@ -0,0 +1,11 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "pry"
6
+ gem "pry-byebug", platforms: :mri
7
+ gem "rails", "~> 5.2"
8
+ gem "railties", "~> 5.2"
9
+ gem "activesupport", "~> 5.2"
10
+
11
+ gemspec path: "../"
@@ -0,0 +1,14 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ git "git://github.com/rails/rails.git" do
6
+ gem "rails"
7
+ gem "railties"
8
+ gem "activesupport"
9
+ end
10
+
11
+ gem "pry"
12
+ gem "pry-byebug", platforms: :mri
13
+
14
+ gemspec path: "../"
@@ -0,0 +1,3 @@
1
+ require "artemis/version"
2
+ require "artemis/client"
3
+ require "artemis/railtie" if defined?(Rails)
@@ -0,0 +1,25 @@
1
+ # frozen-string-literal: true
2
+
3
+ require 'active_support/dependencies/autoload'
4
+
5
+ module Artemis
6
+ module Adapters
7
+ extend ActiveSupport::Autoload
8
+
9
+ autoload :CurbAdapter
10
+ autoload :NetHttpAdapter
11
+ autoload :NetHttpPersistentAdapter
12
+ autoload :TestAdapter
13
+
14
+ class << self
15
+ ##
16
+ # Returns the constant for the specified adapter name.
17
+ #
18
+ # Artemis::Adapters.lookup(:net_http)
19
+ # # => Artemis::Adapters::NetHttpAdapter
20
+ def lookup(name)
21
+ const_get("#{name.to_s.camelize}Adapter")
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'graphql/client/http'
4
+
5
+ module Artemis
6
+ module Adapters
7
+ class AbstractAdapter < ::GraphQL::Client::HTTP
8
+ attr_reader :service_name, :timeout, :pool_size
9
+
10
+ EMPTY_HEADERS = {}.freeze
11
+
12
+ def initialize(uri, service_name: , timeout: , pool_size: )
13
+ super(uri) # Do not pass in the block to avoid getting #headers and #connection overridden.
14
+
15
+ @service_name = service_name.to_s
16
+ @timeout = timeout
17
+ @pool_size = pool_size
18
+ end
19
+
20
+ # Public: Extension point for subclasses to set custom request headers.
21
+ #
22
+ # Returns Hash of String header names and values.
23
+ def headers(_context)
24
+ _context[:headers] || EMPTY_HEADERS
25
+ end
26
+
27
+ # Public: Make an HTTP request for GraphQL query.
28
+ #
29
+ # A subclass that inherits from +AbstractAdapter+ can override this method if it needs more flexibility for how
30
+ # it makes a request.
31
+ #
32
+ # For more details, see +GraphQL::Client::HTTP#execute+.
33
+ def execute(*)
34
+ super
35
+ end
36
+
37
+ # Public: Extension point for subclasses to customize the Net:HTTP client
38
+ #
39
+ # A subclass that inherits from +AbstractAdapter+ should returns a Net::HTTP object or an object that responds
40
+ # to +request+ that is given a Net::HTTP request object.
41
+ def connection
42
+ raise "AbstractAdapter is an abstract class that can not be instantiated!"
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'delegate'
4
+ require 'json'
5
+
6
+ require 'curb'
7
+
8
+ require 'artemis/adapters/abstract_adapter'
9
+ require 'artemis/exceptions'
10
+
11
+ module Artemis
12
+ module Adapters
13
+ class CurbAdapter < AbstractAdapter
14
+ attr_reader :multi
15
+
16
+ def initialize(uri, service_name: , timeout: , pool_size: )
17
+ super
18
+
19
+ @multi = Curl::Multi.new
20
+ @multi.pipeline = Curl::CURLPIPE_MULTIPLEX if defined?(Curl::CURLPIPE_MULTIPLEX)
21
+ end
22
+
23
+ def execute(document:, operation_name: nil, variables: {}, context: {})
24
+ easy = Curl::Easy.new(uri.to_s)
25
+
26
+ body = {}
27
+ body["query"] = document.to_query_string
28
+ body["variables"] = variables if variables.any?
29
+ body["operationName"] = operation_name if operation_name
30
+
31
+ easy.timeout = timeout
32
+ easy.multi = multi
33
+ easy.headers = headers(context) || {}
34
+ easy.post_body = JSON.generate(body)
35
+
36
+ if defined?(Curl::CURLPIPE_MULTIPLEX)
37
+ # This ensures libcurl waits for the connection to reveal if it is
38
+ # possible to pipeline/multiplex on before it continues.
39
+ easy.setopt(Curl::CURLOPT_PIPEWAIT, 1)
40
+ easy.version = Curl::HTTP_2_0
41
+ end
42
+
43
+ easy.http_post
44
+
45
+ case easy.response_code
46
+ when 200, 400
47
+ JSON.parse(easy.body)
48
+ when 500..599
49
+ raise Artemis::GraphQLServerError, "Received server error status #{easy.response_code}: #{easy.body}"
50
+ else
51
+ { "errors" => [{ "message" => "#{easy.response_code} #{easy.body}" }] }
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'net/http'
5
+
6
+ require 'artemis/adapters/abstract_adapter'
7
+ require 'artemis/exceptions'
8
+
9
+ module Artemis
10
+ module Adapters
11
+ class NetHttpAdapter < AbstractAdapter
12
+ # Makes an HTTP request for GraphQL query.
13
+ def execute(document:, operation_name: nil, variables: {}, context: {})
14
+ request = Net::HTTP::Post.new(uri.request_uri)
15
+
16
+ request.basic_auth(uri.user, uri.password) if uri.user || uri.password
17
+
18
+ request["Accept"] = "application/json"
19
+ request["Content-Type"] = "application/json"
20
+ headers(context).each { |name, value| request[name] = value }
21
+
22
+ body = {}
23
+ body["query"] = document.to_query_string
24
+ body["variables"] = variables if variables.any?
25
+ body["operationName"] = operation_name if operation_name
26
+ request.body = JSON.generate(body)
27
+
28
+ response = connection.request(request)
29
+
30
+ case response.code.to_i
31
+ when 200, 400
32
+ JSON.parse(response.body)
33
+ when 500..599
34
+ raise Artemis::GraphQLServerError, "Received server error status #{response.code}: #{response.body}"
35
+ else
36
+ { "errors" => [{ "message" => "#{response.code} #{response.message}" }] }
37
+ end
38
+ end
39
+
40
+ # Returns a fresh Net::HTTP object that creates a new connection.
41
+ def connection
42
+ Net::HTTP.new(uri.host, uri.port).tap do |client|
43
+ client.use_ssl = uri.scheme == "https"
44
+ client.open_timeout = timeout
45
+ client.read_timeout = timeout
46
+ client.write_timeout = timeout if client.respond_to?(:write_timeout=)
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'delegate'
4
+
5
+ require 'net/http/persistent'
6
+
7
+ require 'artemis/adapters/net_http_adapter'
8
+
9
+ module Artemis
10
+ module Adapters
11
+ class NetHttpPersistentAdapter < NetHttpAdapter
12
+ attr_reader :_connection, :raw_connection
13
+
14
+ def initialize(uri, service_name: , timeout: , pool_size: )
15
+ super
16
+
17
+ @raw_connection = Net::HTTP::Persistent.new(name: service_name, pool_size: pool_size)
18
+ @raw_connection.open_timeout = timeout
19
+ @raw_connection.read_timeout = timeout
20
+
21
+ @_connection = ConnectionWrapper.new(@raw_connection, uri)
22
+ end
23
+
24
+ # Public: Extension point for subclasses to customize the Net:HTTP client
25
+ #
26
+ # Returns a Net::HTTP object
27
+ def connection
28
+ _connection
29
+ end
30
+
31
+ class ConnectionWrapper < SimpleDelegator
32
+ def initialize(obj, url)
33
+ super(obj)
34
+
35
+ @url = url
36
+ end
37
+
38
+ def request(req)
39
+ __getobj__.request(@url, req)
40
+ end
41
+ end
42
+
43
+ private_constant :ConnectionWrapper
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/module/attribute_accessors'
4
+
5
+ module Artemis
6
+ module Adapters
7
+ class TestAdapter
8
+ cattr_accessor :requests
9
+ self.requests = []
10
+
11
+ Request = Struct.new(:document, :operation_name, :variables, :context)
12
+
13
+ private_constant :Request
14
+
15
+ def initialize(*)
16
+ end
17
+
18
+ def execute(**arguments)
19
+ self.requests << Request.new(*arguments.values_at(:document, :operation_name, :variables, :context))
20
+
21
+ {
22
+ 'data' => { 'test' => 'data' },
23
+ 'errors' => [],
24
+ 'extensions' => {}
25
+ }
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'delegate'
4
+
5
+ require 'active_support/configurable'
6
+ require 'active_support/core_ext/hash/deep_merge'
7
+ require 'active_support/core_ext/string/inflections'
8
+
9
+ require 'artemis/graphql_endpoint'
10
+ require 'artemis/exceptions'
11
+
12
+ module Artemis
13
+ class Client
14
+ include ActiveSupport::Configurable
15
+
16
+ # The paths in which the Artemis client looks for files that have the +.graphql+ extension.
17
+ # In a rails app, this value will be set to +["app/operations"]+ by Artemis' +Artemis::Railtie+.
18
+ config.query_paths = []
19
+
20
+ # Default context that is appended to every GraphQL request for the client.
21
+ config.default_context = {}
22
+
23
+ # List of before callbacks that get invoked in every +execute+ call.
24
+ #
25
+ # @api private
26
+ config.before_callbacks = []
27
+
28
+ # List of after callbacks that get invoked in every +execute+ call.
29
+ #
30
+ # @api private
31
+ config.after_callbacks = []
32
+
33
+ # Returns a plain +GraphQL::Client+ object. For more details please refer to the official documentation for
34
+ # {the +graphql-client+ gem}[https://github.com/github/graphql-client].
35
+ attr_reader :client
36
+
37
+ # Creates a new instance of the GraphQL client for the service.
38
+ #
39
+ # # app/operations/github/user.graphql
40
+ # query($id: String!) {
41
+ # user(login: $id) {
42
+ # name
43
+ # }
44
+ # }
45
+ #
46
+ # # app/operations/github.rb
47
+ # class GitHub < Artemis::Client
48
+ # end
49
+ #
50
+ # github = GitHub.new
51
+ # github.user(id: 'yuki24').data.user.name # => "Yuki Nishijima"
52
+ #
53
+ # github = GitHub.new(context: { headers: { Authorization: "bearer ..." } })
54
+ # github.user(id: 'yuki24').data.user.name # => "Yuki Nishijima"
55
+ #
56
+ def initialize(context = {})
57
+ @client = self.class.instantiate_client(context)
58
+ end
59
+
60
+ class << self
61
+ delegate :query_paths, :default_context, :query_paths=, :default_context=, to: :config
62
+
63
+ alias with_context new
64
+
65
+ def endpoint
66
+ Artemis::GraphQLEndpoint.lookup(name)
67
+ end
68
+
69
+ def instantiate_client(context = {})
70
+ ::GraphQL::Client.new(schema: endpoint.schema, execute: connection(context))
71
+ end
72
+
73
+ # Defines a callback that will get called right before the
74
+ # client's execute method is executed.
75
+ #
76
+ # class GitHub < Artemis::Client
77
+ #
78
+ # before_execute do |document, operation_name, variables, context|
79
+ # Analytics.log(operation_name, variables, context[:user_id])
80
+ # end
81
+ #
82
+ # ...
83
+ # end
84
+ #
85
+ def before_execute(&block)
86
+ config.before_callbacks << block
87
+ end
88
+
89
+ # Defines a callback that will get called right after the
90
+ # client's execute method has finished.
91
+ #
92
+ # class GitHub < Artemis::Client
93
+ #
94
+ # after_execute do |data, errors, extensions|
95
+ # if errors.present?
96
+ # Rails.logger.error(errors.to_json)
97
+ # end
98
+ # end
99
+ #
100
+ # ...
101
+ # end
102
+ #
103
+ def after_execute(&block)
104
+ config.after_callbacks << block
105
+ end
106
+
107
+ def resolve_graphql_file_path(filename, fragment: false)
108
+ namespace = name.underscore
109
+ filename = filename.to_s.underscore
110
+
111
+ graphql_file_paths.detect do |path|
112
+ path.end_with?("#{namespace}/#{filename}.graphql") ||
113
+ (fragment && filename.end_with?('fragment') && path.end_with?("#{namespace}/_#{filename}.graphql"))
114
+ end
115
+ end
116
+
117
+ def graphql_file_paths
118
+ @graphql_file_paths ||= query_paths.flat_map {|path| Dir["#{path}/#{name.underscore}/*.graphql"] }
119
+ end
120
+
121
+ def preload!
122
+ graphql_file_paths.each do |path|
123
+ load_constant(File.basename(path, File.extname(path)).camelize)
124
+ end
125
+ end
126
+
127
+ def load_constant(const_name)
128
+ graphql_file = resolve_graphql_file_path(const_name.to_s.underscore, fragment: true)
129
+
130
+ if graphql_file
131
+ graphql = File.open(graphql_file).read
132
+ ast = instantiate_client.parse(graphql)
133
+
134
+ const_set(const_name, ast)
135
+ end
136
+ end
137
+ alias load_query load_constant
138
+
139
+ # @api private
140
+ def connection(context = {})
141
+ Executor.new(endpoint.connection, callbacks, default_context.deep_merge(context))
142
+ end
143
+
144
+ private
145
+
146
+ # @api private
147
+ def const_missing(const_name)
148
+ load_constant(const_name) || super
149
+ end
150
+
151
+ # @api private
152
+ def method_missing(method_name, *arguments, &block)
153
+ if resolve_graphql_file_path(method_name)
154
+ new(default_context).public_send(method_name, *arguments, &block)
155
+ else
156
+ super
157
+ end
158
+ end
159
+
160
+ # @api private
161
+ def respond_to_missing?(method_name, *_, &block)
162
+ resolve_graphql_file_path(method_name) || super
163
+ end
164
+
165
+ Callbacks = Struct.new(:before_callbacks, :after_callbacks)
166
+
167
+ private_constant :Callbacks
168
+
169
+ # @api private
170
+ def callbacks
171
+ Callbacks.new(config.before_callbacks, config.after_callbacks)
172
+ end
173
+ end
174
+
175
+ private
176
+
177
+ # @api private
178
+ def method_missing(method_name, context: {}, **arguments)
179
+ if self.class.resolve_graphql_file_path(method_name)
180
+ const_name = method_name.to_s.camelize
181
+
182
+ # This check will be unnecessary once we drop support for Ruby 2.4 and earlier
183
+ if !self.class.const_get(const_name).is_a?(GraphQL::Client::OperationDefinition)
184
+ self.class.load_constant(const_name)
185
+ end
186
+
187
+ client.query(
188
+ self.class.const_get(const_name),
189
+ variables: arguments.deep_transform_keys {|key| key.to_s.camelize(:lower) },
190
+ context: context
191
+ )
192
+ else
193
+ super
194
+ end
195
+ end
196
+
197
+ # @api private
198
+ def respond_to_missing?(method_name, *_, &block)
199
+ self.class.resolve_graphql_file_path(method_name) || super
200
+ end
201
+
202
+ # @api private
203
+ def compile_query_method!(method_name)
204
+ const_name = method_name.to_s.camelize
205
+
206
+ self.class.send(:class_eval, <<-RUBY, __FILE__, __LINE__ + 1)
207
+ def #{method_name}(context: {}, **arguments)
208
+ client.query(
209
+ self.class::#{const_name},
210
+ variables: arguments.deep_transform_keys {|key| key.to_s.camelize(:lower) },
211
+ context: context
212
+ )
213
+ end
214
+ RUBY
215
+ end
216
+ end
217
+
218
+ # @api private
219
+ class Executor < SimpleDelegator
220
+ def initialize(connection, callbacks, default_context)
221
+ super(connection)
222
+
223
+ @callbacks = callbacks
224
+ @default_context = default_context
225
+ end
226
+
227
+ def execute(document:, operation_name: nil, variables: {}, context: {})
228
+ _context = @default_context.deep_merge(context)
229
+
230
+ @callbacks.before_callbacks.each do |callback|
231
+ callback.call(document, operation_name, variables, _context)
232
+ end
233
+
234
+ response = __getobj__.execute(document: document, operation_name: operation_name, variables: variables, context: _context)
235
+
236
+ @callbacks.after_callbacks.each do |callback|
237
+ callback.call(response['data'], response['errors'], response['extensions'])
238
+ end
239
+
240
+ response
241
+ end
242
+ end
243
+
244
+ private_constant :Executor
245
+ end