nulogy_graphql_api 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,8 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+ require "rubocop/rake_task"
4
+
5
+ RuboCop::RakeTask.new
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: [:rubocop, :spec]
@@ -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__)
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,9 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rake", "~> 13.0"
6
+ gem "rspec", "~> 3.9"
7
+ gem "rails", "5.2.4"
8
+
9
+ gemspec :path => "../"
@@ -0,0 +1,9 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rake", "~> 13.0"
6
+ gem "rspec", "~> 3.9"
7
+ gem "rails", "6.0.0"
8
+
9
+ gemspec :path => "../"
@@ -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,7 @@
1
+ module NulogyGraphqlApi
2
+ class Railtie < Rails::Railtie
3
+ rake_tasks do
4
+ load "tasks/graphql_schema.rake"
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,4 @@
1
+ # relative-require all rspec files
2
+ Dir[File.dirname(__FILE__) + "/rspec/*.rb"].each do |file|
3
+ require "nulogy_graphql_api/rspec/" + File.basename(file, File.extname(file))
4
+ 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,8 @@
1
+ module NulogyGraphqlApi
2
+ module Types
3
+ # "Type" is not included in the name as this is a scalar type
4
+ class UUID < GraphQL::Types::String
5
+ description "A scalar type to represent a UUID. The UUID appears in the JSON response as a string."
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,3 @@
1
+ module NulogyGraphqlApi
2
+ VERSION = "0.4.0"
3
+ 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