artemis 0.1.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 (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