nulogy_graphql_api 0.4.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +17 -0
- data/.rspec +3 -0
- data/.rubocop +1 -0
- data/.rubocop.directories.yml +11 -0
- data/.rubocop.yml +138 -0
- data/.ruby-version +1 -0
- data/Appraisals +7 -0
- data/CHANGELOG.md +38 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +242 -0
- data/Rakefile +8 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/gemfiles/rails_5.gemfile +9 -0
- data/gemfiles/rails_6.gemfile +9 -0
- data/lib/nulogy_graphql_api.rb +13 -0
- data/lib/nulogy_graphql_api/error_handling.rb +45 -0
- data/lib/nulogy_graphql_api/graphql_error.rb +23 -0
- data/lib/nulogy_graphql_api/graphql_executor.rb +62 -0
- data/lib/nulogy_graphql_api/graphql_response.rb +65 -0
- data/lib/nulogy_graphql_api/railtie.rb +7 -0
- data/lib/nulogy_graphql_api/rspec.rb +4 -0
- data/lib/nulogy_graphql_api/rspec/graphql_helpers.rb +28 -0
- data/lib/nulogy_graphql_api/rspec/graphql_matchers.rb +38 -0
- data/lib/nulogy_graphql_api/transaction_service.rb +44 -0
- data/lib/nulogy_graphql_api/types/user_error_type.rb +12 -0
- data/lib/nulogy_graphql_api/types/uuid.rb +8 -0
- data/lib/nulogy_graphql_api/version.rb +3 -0
- data/lib/tasks/graphql_schema.rake +75 -0
- data/nulogy_graphql_api.gemspec +41 -0
- metadata +211 -0
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "nulogy_graphql_api"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require "irb"
|
14
|
+
IRB.start(__FILE__)
|
data/bin/setup
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
require "graphql"
|
2
|
+
|
3
|
+
require "nulogy_graphql_api/error_handling"
|
4
|
+
require "nulogy_graphql_api/graphql_executor"
|
5
|
+
require "nulogy_graphql_api/graphql_response"
|
6
|
+
require "nulogy_graphql_api/transaction_service"
|
7
|
+
require "nulogy_graphql_api/types/user_error_type"
|
8
|
+
require "nulogy_graphql_api/types/uuid"
|
9
|
+
require "nulogy_graphql_api/version"
|
10
|
+
require "nulogy_graphql_api/railtie"
|
11
|
+
|
12
|
+
module NulogyGraphqlApi
|
13
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
require "nulogy_graphql_api/graphql_error"
|
2
|
+
|
3
|
+
module NulogyGraphqlApi
|
4
|
+
module ErrorHandling
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
included do
|
8
|
+
rescue_from StandardError do |exception|
|
9
|
+
render_error(exception)
|
10
|
+
end
|
11
|
+
|
12
|
+
rescue_from ActiveRecord::RecordNotFound do
|
13
|
+
render_not_found
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def show_detailed_error_information?
|
20
|
+
Rails.application.config.consider_all_requests_local
|
21
|
+
end
|
22
|
+
|
23
|
+
def render_error(exception)
|
24
|
+
error = if show_detailed_error_information?
|
25
|
+
NulogyGraphqlApi::GraphQLError.new(exception.message, backtrace: exception.backtrace)
|
26
|
+
else
|
27
|
+
NulogyGraphqlApi::GraphQLError.new("Something went wrong")
|
28
|
+
end
|
29
|
+
|
30
|
+
render json: error.render, status: :internal_server_error
|
31
|
+
end
|
32
|
+
|
33
|
+
def render_not_found
|
34
|
+
render json: NulogyGraphqlApi::GraphQLError.new("Not Found").render, status: :not_found
|
35
|
+
end
|
36
|
+
|
37
|
+
def render_unauthorized
|
38
|
+
render json: NulogyGraphqlApi::GraphQLError.new("Unauthorized").render, status: :unauthorized
|
39
|
+
end
|
40
|
+
|
41
|
+
def render_timeout
|
42
|
+
render json: NulogyGraphqlApi::GraphQLError.new("Request Timeout").render, status: :request_timeout
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module NulogyGraphqlApi
|
2
|
+
class GraphQLError
|
3
|
+
def initialize(message, backtrace: nil)
|
4
|
+
@message = message
|
5
|
+
@backtrace = backtrace
|
6
|
+
end
|
7
|
+
|
8
|
+
def render
|
9
|
+
{
|
10
|
+
data: {},
|
11
|
+
errors: [{ message: @message }.merge(extensions)]
|
12
|
+
}
|
13
|
+
end
|
14
|
+
|
15
|
+
def extensions
|
16
|
+
if @backtrace
|
17
|
+
{ extensions: { backtrace: @backtrace } }
|
18
|
+
else
|
19
|
+
{}
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
require "action_controller"
|
2
|
+
|
3
|
+
module NulogyGraphqlApi
|
4
|
+
class GraphqlExecutor
|
5
|
+
attr_reader :schema, :transaction_service
|
6
|
+
|
7
|
+
def self.execute(params, context, schema, transaction_service)
|
8
|
+
new(schema, transaction_service).execute(params, context)
|
9
|
+
end
|
10
|
+
|
11
|
+
def execute(params, context)
|
12
|
+
graphql_response(params, context).render_http_response
|
13
|
+
end
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
def initialize(schema, transaction_service)
|
18
|
+
@schema = schema
|
19
|
+
@transaction_service = transaction_service
|
20
|
+
end
|
21
|
+
|
22
|
+
def graphql_response(params, context)
|
23
|
+
execute_graphql(params, context)
|
24
|
+
end
|
25
|
+
|
26
|
+
def execute_graphql(params, context)
|
27
|
+
query = params[:query]
|
28
|
+
variables = ensure_hash(params[:variables])
|
29
|
+
operation_name = params[:operationName]
|
30
|
+
|
31
|
+
transaction_service.execute_in_transaction do |tx|
|
32
|
+
result = GraphqlResponse.new(
|
33
|
+
schema.execute(
|
34
|
+
query,
|
35
|
+
variables: variables,
|
36
|
+
operation_name: operation_name,
|
37
|
+
context: context
|
38
|
+
)
|
39
|
+
)
|
40
|
+
tx.rollback if result.contains_errors?
|
41
|
+
result
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def ensure_hash(ambiguous_param)
|
46
|
+
case ambiguous_param
|
47
|
+
when String
|
48
|
+
if ambiguous_param.present?
|
49
|
+
ensure_hash(JSON.parse(ambiguous_param))
|
50
|
+
else
|
51
|
+
{}
|
52
|
+
end
|
53
|
+
when Hash, ActionController::Parameters
|
54
|
+
ambiguous_param
|
55
|
+
when nil
|
56
|
+
{}
|
57
|
+
else
|
58
|
+
raise ArgumentError, "Unexpected parameter: #{ambiguous_param}"
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
module NulogyGraphqlApi
|
2
|
+
# This class provides a wrapper around the hash returned by GraphQL::Schema#execute.
|
3
|
+
#
|
4
|
+
# The hash is assumed to have String keys rather than Symbol keys because that is what
|
5
|
+
# the Graphql-ruby gem outputs.
|
6
|
+
class GraphqlResponse
|
7
|
+
def initialize(graphql_response_json)
|
8
|
+
@graphql_response_json = graphql_response_json
|
9
|
+
end
|
10
|
+
|
11
|
+
# Detects errors embedded in the GraphQL schema (intended to be shown to end-users):
|
12
|
+
# {
|
13
|
+
# "data": {
|
14
|
+
# "somePayload": {
|
15
|
+
# "errors": [{
|
16
|
+
# "message": "Something went wrong!"
|
17
|
+
# }]
|
18
|
+
# }
|
19
|
+
# }
|
20
|
+
# }
|
21
|
+
def contains_errors_in_data_payload?
|
22
|
+
if @graphql_response_json["data"].present?
|
23
|
+
@graphql_response_json["data"].values.any? do |payload|
|
24
|
+
payload.is_a?(Hash) ? payload["errors"].present? : false
|
25
|
+
end
|
26
|
+
else
|
27
|
+
false
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
# Detects errors in the standard GraphQL error list (intended to be shown to developers and API clients):
|
32
|
+
# {
|
33
|
+
# "errors": [{
|
34
|
+
# "message": "Something went wrong!"
|
35
|
+
# }]
|
36
|
+
# }
|
37
|
+
def contains_errors_in_graphql_errors?
|
38
|
+
@graphql_response_json["errors"].present?
|
39
|
+
end
|
40
|
+
|
41
|
+
def contains_errors?
|
42
|
+
contains_errors_in_graphql_errors? || contains_errors_in_data_payload?
|
43
|
+
end
|
44
|
+
|
45
|
+
def http_response_code
|
46
|
+
if contains_errors_in_graphql_errors?
|
47
|
+
400
|
48
|
+
elsif contains_errors_in_data_payload?
|
49
|
+
# mimic Rails behaviour when there are validation errors. Also enable clients to easily
|
50
|
+
# identify user-facing errors. CPI for instance will retry these to deal with race
|
51
|
+
# conditions.
|
52
|
+
422
|
53
|
+
else
|
54
|
+
200
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def render_http_response
|
59
|
+
{
|
60
|
+
json: @graphql_response_json,
|
61
|
+
status: http_response_code
|
62
|
+
}
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
module NulogyGraphqlApi
|
2
|
+
module GraphqlHelpers
|
3
|
+
def execute_graphql(query, schema, variables: {}, context: {})
|
4
|
+
camelized_variables = variables.deep_transform_keys! { |key| key.to_s.camelize(:lower) } || {}
|
5
|
+
|
6
|
+
response = schema.execute(
|
7
|
+
query,
|
8
|
+
variables: camelized_variables,
|
9
|
+
context: context,
|
10
|
+
operation_name: nil
|
11
|
+
)
|
12
|
+
|
13
|
+
response.to_h.deep_symbolize_keys
|
14
|
+
end
|
15
|
+
|
16
|
+
def request_graphql(url, query, variables: {}, headers: {})
|
17
|
+
params = { query: query, variables: variables }.to_json
|
18
|
+
default_headers = {
|
19
|
+
"CONTENT_TYPE": "application/json",
|
20
|
+
"HTTP_AUTHORIZATION": basic_auth_token(default_user.login)
|
21
|
+
}
|
22
|
+
|
23
|
+
post url, params: params, headers: default_headers.merge(headers)
|
24
|
+
|
25
|
+
JSON.parse(response.body, symbolize_names: true)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
require "rspec/matchers"
|
2
|
+
require "rspec/expectations"
|
3
|
+
|
4
|
+
module NulogyGraphqlApi
|
5
|
+
module GraphqlMatchers
|
6
|
+
RSpec::Matchers.define :have_graphql_data do |expected_data|
|
7
|
+
match do |graphql_response|
|
8
|
+
@expected_response = { data: expected_data }
|
9
|
+
expect(graphql_response).to match(@expected_response)
|
10
|
+
end
|
11
|
+
|
12
|
+
failure_message do |actual_response|
|
13
|
+
<<~MSG
|
14
|
+
expected: #{@expected_response.pretty_inspect}
|
15
|
+
|
16
|
+
got: #{actual_response.pretty_inspect}
|
17
|
+
MSG
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
RSpec::Matchers.define :have_graphql_error do |message|
|
22
|
+
match do |actual_response|
|
23
|
+
expect(actual_response.fetch(:errors, nil)).to contain_exactly(a_hash_including(
|
24
|
+
message: include(message)
|
25
|
+
))
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
RSpec::Matchers.define :have_network_error do |message, error_extensions = {}|
|
30
|
+
match do |actual_response|
|
31
|
+
expect(actual_response).to match({
|
32
|
+
data: {},
|
33
|
+
errors: [{ message: message }.merge(error_extensions)]
|
34
|
+
})
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
require "active_record"
|
2
|
+
|
3
|
+
module NulogyGraphqlApi
|
4
|
+
class TransactionService
|
5
|
+
def execute_in_transaction
|
6
|
+
context = Transaction.new
|
7
|
+
result = nil
|
8
|
+
ActiveRecord::Base.transaction(requires_new: true, joinable: false) do
|
9
|
+
result = yield(context)
|
10
|
+
raise ActiveRecord::Rollback if context.rolledback?
|
11
|
+
end
|
12
|
+
result
|
13
|
+
end
|
14
|
+
|
15
|
+
class Transaction
|
16
|
+
def initialize
|
17
|
+
@rollback = false
|
18
|
+
end
|
19
|
+
|
20
|
+
def rollback
|
21
|
+
@rollback = true
|
22
|
+
end
|
23
|
+
|
24
|
+
def rolledback?
|
25
|
+
@rollback
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
# TODO: Move to the spec folder
|
30
|
+
class Dummy
|
31
|
+
attr_reader :transaction
|
32
|
+
|
33
|
+
def execute_in_transaction
|
34
|
+
@transaction = Transaction.new
|
35
|
+
@was_called = true
|
36
|
+
yield(@transaction)
|
37
|
+
end
|
38
|
+
|
39
|
+
def was_called?
|
40
|
+
@was_called
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
module NulogyGraphqlApi
|
2
|
+
module Types
|
3
|
+
class UserErrorType < ::GraphQL::Schema::Object
|
4
|
+
description "An end-user readable error"
|
5
|
+
|
6
|
+
field :message, String, null: false,
|
7
|
+
description: "A description of the error"
|
8
|
+
field :path, [String], null: false,
|
9
|
+
description: "Which input value this error came from"
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
namespace :nulogy_graphql_api do
|
2
|
+
desc "Generate a schema.graphql file"
|
3
|
+
|
4
|
+
class GraphqlSchemaChangesChecker
|
5
|
+
def check_changes(old_schema, new_schema)
|
6
|
+
compare_result = GraphQL::SchemaComparator.compare(old_schema, new_schema)
|
7
|
+
|
8
|
+
abort "Task aborted!\n #{Rainbow('No schema changes found.').green}" if compare_result.identical?
|
9
|
+
abort "Task aborted!" unless accept_breaking_changes?(compare_result)
|
10
|
+
end
|
11
|
+
|
12
|
+
private
|
13
|
+
|
14
|
+
def accept_breaking_changes?(compare_result)
|
15
|
+
return true if !compare_result.breaking? && compare_result.dangerous_changes.none?
|
16
|
+
|
17
|
+
puts Rainbow("\nThe current GraphQL Schema has breaking or dangerous changes:").yellow
|
18
|
+
|
19
|
+
compare_result.breaking_changes.concat(compare_result.dangerous_changes).each do |change|
|
20
|
+
puts Rainbow("\n\n- #{change.message} #{change.dangerous? ? '(Dangerous)' : '(Breaking)'}").yellow
|
21
|
+
puts Rainbow(" #{change.criticality.reason}").yellow
|
22
|
+
end
|
23
|
+
|
24
|
+
puts "\n\nDo you want to update the schema anyway? [Y/n]"
|
25
|
+
|
26
|
+
STDIN.gets.chomp != "n"
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
class GraphqlSchemaGenerator
|
31
|
+
SCHEMA_FILE_NAME = "schema.graphql"
|
32
|
+
|
33
|
+
def generate_schema(old_schema_file_path, new_schema_file_path)
|
34
|
+
@old_schema_file_path = old_schema_file_path
|
35
|
+
@new_schema_file_path = new_schema_file_path
|
36
|
+
|
37
|
+
generate_schema_file
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
def check_changes(old_schema, new_schema)
|
43
|
+
GraphqlSchemaChangesChecker.new.check_changes(old_schema, new_schema)
|
44
|
+
end
|
45
|
+
|
46
|
+
def new_schema
|
47
|
+
require @new_schema_file_path
|
48
|
+
|
49
|
+
GraphQL::Schema.descendants.first.to_definition
|
50
|
+
end
|
51
|
+
|
52
|
+
def old_schema
|
53
|
+
return nil unless File.exists?(@old_schema_file_path)
|
54
|
+
|
55
|
+
File.read(@old_schema_file_path)
|
56
|
+
end
|
57
|
+
|
58
|
+
def generate_schema_file
|
59
|
+
check_changes(old_schema, new_schema) if old_schema
|
60
|
+
|
61
|
+
write_new_schema_file
|
62
|
+
end
|
63
|
+
|
64
|
+
def write_new_schema_file
|
65
|
+
File.write(@old_schema_file_path, new_schema)
|
66
|
+
puts Rainbow("\nSuccessfully updated #{@old_schema_file_path}").green
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
task :generate_schema, [:old_schema_file_path, :new_schema_file_path] => :environment do |_task, args|
|
71
|
+
abort "new_schema_file_path is required" unless args.key?(:new_schema_file_path)
|
72
|
+
|
73
|
+
GraphqlSchemaGenerator.new.generate_schema(args[:old_schema_file_path], args[:new_schema_file_path])
|
74
|
+
end
|
75
|
+
end
|