nulogy_graphql_api 0.4.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.
- 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
|