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,19 @@
1
+ module Artemis
2
+ class Error < StandardError
3
+ end
4
+
5
+ class EndpointNotFound < Error
6
+ end
7
+
8
+ class ConfigurationError < Error
9
+ end
10
+
11
+ class GraphQLFileNotFound < Error
12
+ end
13
+
14
+ class GraphQLError < Error
15
+ end
16
+
17
+ class GraphQLServerError < GraphQLError
18
+ end
19
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/hash/deep_merge'
4
+ require 'active_support/core_ext/hash/keys'
5
+ require 'active_support/core_ext/object/blank'
6
+ require 'active_support/core_ext/string/inflections'
7
+ require 'graphql/client'
8
+
9
+ require 'artemis/adapters'
10
+ require 'artemis/exceptions'
11
+
12
+ module Artemis
13
+ class GraphQLEndpoint
14
+ # Hash object that holds references to adapter instances.
15
+ ENDPOINT_INSTANCES = {}
16
+
17
+ private_constant :ENDPOINT_INSTANCES
18
+
19
+ class << self
20
+ ##
21
+ # Provides an endpoint instance specified in the +configuration+. If the endpoint is not found in
22
+ # +ENDPOINT_INSTANCES+, it'll raise an exception.
23
+ def lookup(service_name)
24
+ ENDPOINT_INSTANCES[service_name.to_s.underscore] || raise(Artemis::EndpointNotFound, "Service `#{service_name}' not registered.")
25
+ end
26
+
27
+ def register!(service_name, configurations)
28
+ ENDPOINT_INSTANCES[service_name.to_s.underscore] = new(service_name.to_s, configurations.symbolize_keys)
29
+ end
30
+ end
31
+
32
+ attr_reader :name, :url, :adapter, :timeout, :schema_path, :pool_size
33
+
34
+ def initialize(name, url: , adapter: :net_http, timeout: 30, schema_path: nil, pool_size: 5)
35
+ @name, @url, @adapter, @timeout, @schema_path, @pool_size = name.to_s, url, adapter, timeout, schema_path, pool_size
36
+
37
+ @mutex_for_schema = Mutex.new
38
+ @mutex_for_connection = Mutex.new
39
+ end
40
+
41
+ def schema
42
+ @schema || @mutex_for_schema.synchronize do
43
+ @schema ||= ::GraphQL::Client.load_schema(schema_path.presence || connection)
44
+ end
45
+ end
46
+ alias load_schema! schema
47
+
48
+ def connection
49
+ @connection || @mutex_for_connection.synchronize do
50
+ @connection ||= ::Artemis::Adapters.lookup(adapter).new(url, service_name: name, timeout: timeout, pool_size: pool_size)
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,50 @@
1
+ require 'active_support/file_update_checker'
2
+
3
+ module Artemis
4
+ class Railtie < ::Rails::Railtie #:nodoc:
5
+ initializer 'graphql.client.attach_log_subscriber' do
6
+ if !defined?(GraphQL::Client::LogSubscriber)
7
+ require "graphql/client/log_subscriber"
8
+ GraphQL::Client::LogSubscriber.attach_to :graphql
9
+ end
10
+ end
11
+
12
+ initializer 'graphql.client.set_query_paths' do |app|
13
+ app.paths.add "app/operations"
14
+
15
+ Artemis::Client.query_paths = app.paths["app/operations"].existent
16
+ end
17
+
18
+ initializer 'graphql.client.set_reloader', after: 'graphql.client.set_query_paths' do |app|
19
+ files_to_watch = Artemis::Client.query_paths.map {|path| [path, ["graphql"]] }.to_h
20
+
21
+ app.reloaders << ActiveSupport::FileUpdateChecker.new([], files_to_watch) do
22
+ endpoint_names = app.config_for(:graphql).keys
23
+ endpoint_names.each do |endpoint_name|
24
+ Artemis::Client.query_paths.each do |path|
25
+ FileUtils.touch("#{path}/#{endpoint_name}.rb")
26
+ end
27
+ end
28
+ end
29
+ end
30
+
31
+ initializer 'graphql.client.load_config' do |app|
32
+ # TODO: Remove the +rescue+ call
33
+ (app.config_for(:graphql) rescue {}).each do |endpoint_name, options|
34
+ Artemis::GraphQLEndpoint.register!(endpoint_name, { 'schema_path' => app.root.join("vendor/graphql/schema/#{endpoint_name}.json").to_s }.merge(options))
35
+ end
36
+ end
37
+
38
+ initializer 'graphql.client.preload', after: 'graphql.client.load_config' do |app|
39
+ if app.config.eager_load
40
+ app.config_for(:graphql).keys.each do |endpoint_name|
41
+ endpoint_name.to_s.camelize.constantize.preload!
42
+ end
43
+ end
44
+ end
45
+
46
+ rake_tasks do
47
+ load "tasks/artemis.rake"
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,3 @@
1
+ module Artemis
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,11 @@
1
+ Description:
2
+ Generates a client stub and config files and downloads the schema from the given endpoint.
3
+
4
+ Example:
5
+ rails generate install Artsy https://metaphysics-production.artsy.net
6
+
7
+ This will create:
8
+ app/operations/artsy.rb
9
+ app/operations/artsy/.gitkeep
10
+ config/graphql.yml
11
+ vendor/graphql/schema/artsy.json
@@ -0,0 +1,59 @@
1
+ require 'graphql/client'
2
+ require 'graphql/client/http'
3
+
4
+ class Artemis::InstallGenerator < Rails::Generators::NamedBase
5
+ source_root File.expand_path('../templates', __FILE__)
6
+
7
+ argument :endpoint_url, type: :string, banner: "The endpoint URL for a GraphQL service"
8
+
9
+ class_option :authorization, type: :string, default: nil, aliases: "-A"
10
+
11
+ def generate_client
12
+ template "client.rb", client_file_name
13
+ create_file query_dir_gitkeep, ""
14
+ end
15
+
16
+ def generate_config
17
+ in_root do
18
+ if behavior == :invoke && !File.exist?(config_file_name)
19
+ template "graphql.yml", config_file_name
20
+ end
21
+ end
22
+ end
23
+
24
+ def download_schema
25
+ say " downloading GraphQL schema from #{endpoint_url}..."
26
+
27
+ if options['authorization'].present?
28
+ rake "graphql:schema:update SERVICE=#{file_name} AUTHORIZATION='#{options['authorization']}'"
29
+ else
30
+ rake "graphql:schema:update SERVICE=#{file_name}"
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def file_name # :doc:
37
+ @_file_name ||= super.underscore
38
+ end
39
+
40
+ def client_file_name
41
+ if mountable_engine?
42
+ "app/operations/#{namespaced_path}/#{file_name}.rb"
43
+ else
44
+ "app/operations/#{file_name}.rb"
45
+ end
46
+ end
47
+
48
+ def query_dir_gitkeep
49
+ if mountable_engine?
50
+ "app/operations/#{namespaced_path}/#{file_name}/.gitkeep"
51
+ else
52
+ "app/operations/#{file_name}/.gitkeep"
53
+ end
54
+ end
55
+
56
+ def config_file_name
57
+ "config/graphql.yml"
58
+ end
59
+ end
@@ -0,0 +1,10 @@
1
+ <% module_namespacing do -%>
2
+ class <%= class_name %> < Artemis::Client
3
+ # If an access token needs to be assigned to every request:
4
+ # self.default_context = {
5
+ # headers: {
6
+ # Authorization: "token ..."
7
+ # }
8
+ # }
9
+ end
10
+ <% end %>
@@ -0,0 +1,19 @@
1
+ default: &default
2
+ adapter: :net_http
3
+ timeout: 10
4
+ pool_size: 25
5
+
6
+ development:
7
+ <%= file_name %>:
8
+ <<: *default
9
+ url: <%= endpoint_url %>
10
+
11
+ test:
12
+ <%= file_name %>:
13
+ <<: *default
14
+ url: <%= endpoint_url %>
15
+
16
+ production:
17
+ <%= file_name %>:
18
+ <<: *default
19
+ url: <%= endpoint_url %>
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ require 'active_support/core_ext/string/inflections'
6
+ require 'graphql/client'
7
+
8
+ namespace :graphql do
9
+ namespace :schema do
10
+ desc "Downloads and saves the GraphQL schema (options: SERVICE=service_name AUTHORIZATION='token ...')"
11
+ task update: :environment do
12
+ service = if ENV['SERVICE']
13
+ ENV['SERVICE']
14
+ else
15
+ services = Rails.application.config_for(:graphql).keys
16
+
17
+ if services.size == 1
18
+ services.first
19
+ else
20
+ raise "Please specify a service name (available services: #{services.join(", ")}): rake graphql:schema:update SERVICE=service"
21
+ end
22
+ end
23
+
24
+ headers = ENV['AUTHORIZATION'] ? { Authorization: ENV['AUTHORIZATION'] } : {}
25
+ service_class = service.camelize.constantize
26
+ schema_path = service_class.endpoint.schema_path
27
+ schema = service_class.connection
28
+ .execute(
29
+ document: GraphQL::Client::IntrospectionDocument,
30
+ operation_name: "IntrospectionQuery",
31
+ variables: {},
32
+ context: { headers: headers }
33
+ ).to_h
34
+
35
+ if schema['errors'].nil? || schema['errors'].empty?
36
+ FileUtils.mkdir_p(File.dirname(schema_path))
37
+ File.open(schema_path, 'w') do |file|
38
+ file.write(JSON.pretty_generate(schema))
39
+ end
40
+
41
+ puts "saved schema to: #{schema_path.gsub("#{Dir.pwd}/", '')}"
42
+ else
43
+ raise "received error from server: #{schema}\n\n"
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,106 @@
1
+ require 'json'
2
+ require 'rack'
3
+
4
+ describe 'Adapters' do
5
+ FakeServer = ->(env) {
6
+ case env['PATH_INFO']
7
+ when '/slow_server'
8
+ sleep 2.1
9
+
10
+ [200, {}, ['{}']]
11
+ when '/500'
12
+ [500, {}, ['Server error']]
13
+ else
14
+ body = {
15
+ data: {
16
+ body: JSON.parse(env['rack.input'].read),
17
+ headers: env.select {|key, val| key.start_with?('HTTP_') }
18
+ .collect {|key, val| [key.gsub(/^HTTP_/, ''), val.downcase] }
19
+ .to_h,
20
+ },
21
+ errors: [],
22
+ extensions: {}
23
+ }.to_json
24
+
25
+ [200, {}, [body]]
26
+ end
27
+ }
28
+
29
+ before :all do
30
+ Artemis::Adapters::AbstractAdapter.send(:attr_writer, :uri, :timeout)
31
+
32
+ @server_thread = Thread.new do
33
+ Rack::Handler::WEBrick.run(FakeServer, Port: 8000, Logger: WEBrick::Log.new('/dev/null'), AccessLog: [])
34
+ end
35
+
36
+ loop do
37
+ begin
38
+ TCPSocket.open('localhost', 8000)
39
+ break
40
+ rescue Errno::ECONNREFUSED
41
+ # no-op
42
+ end
43
+ end
44
+ end
45
+
46
+ after :all do
47
+ Rack::Handler::WEBrick.shutdown
48
+ @server_thread.terminate
49
+ end
50
+
51
+ shared_examples 'an adapter' do
52
+ describe '#execute' do
53
+ it 'makes an actual HTTP request' do
54
+ response = adapter.execute(
55
+ document: GraphQL::Client::IntrospectionDocument,
56
+ operation_name: 'IntrospectionQuery',
57
+ variables: { id: 'yayoi-kusama' },
58
+ context: { user_id: 1 }
59
+ )
60
+
61
+ expect(response['data']['body']['query']).to eq(GraphQL::Client::IntrospectionDocument.to_query_string)
62
+ expect(response['data']['body']['variables']).to eq('id' => 'yayoi-kusama')
63
+ expect(response['data']['body']['operationName']).to eq('IntrospectionQuery')
64
+ expect(response['errors']).to eq([])
65
+ expect(response['extensions']).to eq({})
66
+ end
67
+
68
+ it 'raises an error when it receives a server error' do
69
+ adapter.uri = URI.parse('http://localhost:8000/500')
70
+
71
+ expect do
72
+ adapter.execute(document: GraphQL::Client::IntrospectionDocument, operation_name: 'IntrospectionQuery')
73
+ end.to raise_error(Artemis::GraphQLServerError, "Received server error status 500: Server error")
74
+ end
75
+
76
+ it 'allows for overriding timeout' do
77
+ adapter.uri = URI.parse('http://localhost:8000/slow_server')
78
+
79
+ expect do
80
+ adapter.execute(document: GraphQL::Client::IntrospectionDocument, operation_name: 'IntrospectionQuery')
81
+ end.to raise_error(timeout_error)
82
+ end
83
+ end
84
+ end
85
+
86
+ describe Artemis::Adapters::NetHttpAdapter do
87
+ let(:adapter) { Artemis::Adapters::NetHttpAdapter.new('http://localhost:8000', service_name: nil, timeout: 2, pool_size: 5) }
88
+ let(:timeout_error) { Net::ReadTimeout }
89
+
90
+ it_behaves_like 'an adapter'
91
+ end
92
+
93
+ describe Artemis::Adapters::NetHttpPersistentAdapter do
94
+ let(:adapter) { Artemis::Adapters::NetHttpPersistentAdapter.new('http://localhost:8000', service_name: nil, timeout: 2, pool_size: 5) }
95
+ let(:timeout_error) { Net::HTTP::Persistent::Error }
96
+
97
+ it_behaves_like 'an adapter'
98
+ end
99
+
100
+ describe Artemis::Adapters::CurbAdapter do
101
+ let(:adapter) { Artemis::Adapters::CurbAdapter.new('http://localhost:8000', service_name: nil, timeout: 2, pool_size: 5) }
102
+ let(:timeout_error) { Curl::Err::TimeoutError }
103
+
104
+ it_behaves_like 'an adapter'
105
+ end
106
+ end
@@ -0,0 +1,146 @@
1
+ describe "#{GraphQL::Client} Autoloading" do
2
+ describe ".load_constant" do
3
+ it "loads the specified constant if there is a matching graphql file" do
4
+ Metaphysics.send(:remove_const, :Artist) if Metaphysics.constants.include?(:Artist)
5
+
6
+ Metaphysics.load_constant(:Artist)
7
+
8
+ expect(defined?(Metaphysics::Artist)).to eq('constant')
9
+ end
10
+
11
+ it "does nothing and returns nil if there is no matching file" do
12
+ expect(Metaphysics.load_constant(:DoesNotExist)).to be_nil
13
+ end
14
+ end
15
+
16
+ describe ".preload!" do
17
+ it "preloads all the graphQL files in the query paths" do
18
+ %i(Artist Artwork ArtistFragment)
19
+ .select {|const_name| Metaphysics.constants.include?(const_name) }
20
+ .each {|const_name| Metaphysics.send(:remove_const, const_name) }
21
+
22
+ Metaphysics.preload!
23
+
24
+ expect(defined?(Metaphysics::Artist)).to eq('constant')
25
+ expect(defined?(Metaphysics::Artwork)).to eq('constant')
26
+ end
27
+ end
28
+
29
+ it "dynamically loads the matching GraphQL query and sets it to a constant" do
30
+ Metaphysics.send(:remove_const, :Artist) if Metaphysics.constants.include?(:Artist)
31
+
32
+ query = Metaphysics::Artist
33
+
34
+ expect(query.document.to_query_string).to eq(<<~GRAPHQL.strip)
35
+ query Metaphysics__Artist($id: String!) {
36
+ artist(id: $id) {
37
+ name
38
+ bio
39
+ birthday
40
+ }
41
+ }
42
+ GRAPHQL
43
+ end
44
+
45
+ it "dynamically loads the matching GraphQL fragment and sets it to a constant" do
46
+ Metaphysics.send(:remove_const, :ArtistFragment) if Metaphysics.constants.include?(:ArtistFragment)
47
+
48
+ query = Metaphysics::ArtistFragment
49
+
50
+ expect(query.document.to_query_string).to eq(<<~GRAPHQL.strip)
51
+ fragment Metaphysics__ArtistFragment on Artist {
52
+ hometown
53
+ deathday
54
+ }
55
+ GRAPHQL
56
+ end
57
+
58
+ it "correctly loads the matching GraphQL query even when the top-level constant with the same name exists" do
59
+ # In Ruby <= 2.4 top-level constants can be looked up through a namespace, which turned out to be a bad practice.
60
+ # This has been removed in 2.5, but in earlier versions still suffer from this behaviour.
61
+ Metaphysics.send(:remove_const, :Artist) if Metaphysics.constants.include?(:Artist)
62
+ Object.send(:remove_const, :Artist) if Object.constants.include?(:Artist)
63
+
64
+ begin
65
+ Object.send(:const_set, :Artist, 1)
66
+
67
+ Metaphysics.artist
68
+ ensure
69
+ Object.send(:remove_const, :Artist)
70
+ end
71
+
72
+ query = Metaphysics::Artist
73
+
74
+ expect(query.document.to_query_string).to eq(<<~GRAPHQL.strip)
75
+ query Metaphysics__Artist($id: String!) {
76
+ artist(id: $id) {
77
+ name
78
+ bio
79
+ birthday
80
+ }
81
+ }
82
+ GRAPHQL
83
+ end
84
+
85
+ it "raises an exception when the path was resolved but the file does not exist" do
86
+ begin
87
+ Metaphysics.graphql_file_paths << "metaphysics/removed.graphql"
88
+
89
+ expect { Metaphysics::Removed }.to raise_error(Errno::ENOENT)
90
+ ensure
91
+ Metaphysics.graphql_file_paths.delete("metaphysics/removed.graphql")
92
+ end
93
+ end
94
+
95
+ it "raises an NameError when there is no graphql file that matches the const name" do
96
+ expect { Metaphysics::DoesNotExist }.to raise_error(NameError)
97
+ end
98
+
99
+ xit "defines the query method when the matching class method gets called for the first time" do
100
+ Metaphysics.undef_method(:artwork) if Metaphysics.public_instance_methods.include?(:artwork)
101
+
102
+ Metaphysics.artwork
103
+
104
+ expect(Metaphysics.public_instance_methods).to include(:artwork)
105
+ end
106
+
107
+ it "raises an NameError when there is no graphql file that matches the class method name" do
108
+ expect { Metaphysics.does_not_exist }.to raise_error(NameError)
109
+ end
110
+
111
+ it "raises an NameError when the class method name matches a fragment name" do
112
+ expect { Metaphysics.artist_fragment }.to raise_error(NameError)
113
+ end
114
+
115
+ it "responds to a class method that has a matching graphQL file" do
116
+ expect(Metaphysics).to respond_to(:artwork)
117
+ end
118
+
119
+ it "does not respond to class methods that do not have a matching graphQL file" do
120
+ expect(Metaphysics).not_to respond_to(:does_not_exist)
121
+ end
122
+
123
+ xit "defines the query method when the matching instance method gets called for the first time" do
124
+ Metaphysics.undef_method(:artwork) if Metaphysics.public_instance_methods.include?(:artwork)
125
+
126
+ Metaphysics.new.artwork
127
+
128
+ expect(Metaphysics.public_instance_methods).to include(:artwork)
129
+ end
130
+
131
+ it "raises an NameError when there is no graphql file that matches the instance method name" do
132
+ expect { Metaphysics.new.does_not_exist }.to raise_error(NameError)
133
+ end
134
+
135
+ it "raises an NameError when the instance method name matches a fragment name" do
136
+ expect { Metaphysics.new.artist_fragment }.to raise_error(NameError)
137
+ end
138
+
139
+ it "responds to the method that has a matching graphQL file" do
140
+ expect(Metaphysics.new).to respond_to(:artwork)
141
+ end
142
+
143
+ it "does not respond to methods that do not have a matching graphQL file" do
144
+ expect(Metaphysics.new).not_to respond_to(:does_not_exist)
145
+ end
146
+ end