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.
@@ -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