zero-rails-adapter 0.2.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/LICENSE.txt +21 -0
- data/README.md +344 -0
- data/app/controllers/zero_rails_adapter/mutations_controller.rb +74 -0
- data/config/routes.rb +7 -0
- data/examples/nextjs/README.md +29 -0
- data/examples/nextjs/src/zero/mutators.ts +37 -0
- data/examples/nextjs/src/zero/schema.ts +20 -0
- data/lib/generators/zero_rails_adapter/install/install_generator.rb +15 -0
- data/lib/generators/zero_rails_adapter/install/templates/initializer.rb +52 -0
- data/lib/generators/zero_rails_adapter/mutator/mutator_generator.rb +31 -0
- data/lib/generators/zero_rails_adapter/mutator/templates/mutator.rb +14 -0
- data/lib/generators/zero_rails_adapter/typescript/typescript_generator.rb +18 -0
- data/lib/zero_rails_adapter/configuration.rb +57 -0
- data/lib/zero_rails_adapter/context.rb +19 -0
- data/lib/zero_rails_adapter/crud/dispatcher.rb +121 -0
- data/lib/zero_rails_adapter/engine.rb +7 -0
- data/lib/zero_rails_adapter/errors.rb +50 -0
- data/lib/zero_rails_adapter/identity.rb +9 -0
- data/lib/zero_rails_adapter/mutation.rb +50 -0
- data/lib/zero_rails_adapter/mutator.rb +70 -0
- data/lib/zero_rails_adapter/processor.rb +178 -0
- data/lib/zero_rails_adapter/registry.rb +51 -0
- data/lib/zero_rails_adapter/request.rb +93 -0
- data/lib/zero_rails_adapter/request_verifiers/api_key.rb +21 -0
- data/lib/zero_rails_adapter/storage/zero_schema.rb +95 -0
- data/lib/zero_rails_adapter/type_script/generator.rb +421 -0
- data/lib/zero_rails_adapter/version.rb +5 -0
- data/lib/zero_rails_adapter.rb +50 -0
- metadata +89 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZeroRailsAdapter
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :authenticator, :request_verifier, :authorizer, :logger,
|
|
6
|
+
:transaction_class, :storage_provider, :crud_authorizer,
|
|
7
|
+
:writable_attributes, :generated_attributes, :model_resolver,
|
|
8
|
+
:model_provider
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@authenticator = ->(_request) { Identity.new }
|
|
12
|
+
@request_verifier = ->(_request) { true }
|
|
13
|
+
@authorizer = ->(_context, _mutation) { true }
|
|
14
|
+
@crud_authorizer = ->(_context, _action, _target, _attributes) { true }
|
|
15
|
+
@logger = defined?(Rails) ? Rails.logger : nil
|
|
16
|
+
@transaction_class = ActiveRecord::Base
|
|
17
|
+
@model_provider = -> { default_models }
|
|
18
|
+
@model_resolver = lambda do |resource|
|
|
19
|
+
allowed_models = Array(model_provider.call).select do |model|
|
|
20
|
+
active_record_model?(model)
|
|
21
|
+
end
|
|
22
|
+
candidate = resource.to_s.classify.safe_constantize
|
|
23
|
+
candidate = nil unless allowed_models.include?(candidate)
|
|
24
|
+
candidate ||= allowed_models.find { |model| model.table_name == resource.to_s }
|
|
25
|
+
end
|
|
26
|
+
@writable_attributes = ->(model, _action, _context) { default_writable_attributes(model) }
|
|
27
|
+
@generated_attributes = ->(model, _action) { default_writable_attributes(model) }
|
|
28
|
+
@storage_provider = lambda do |request|
|
|
29
|
+
Storage::ZeroSchema.new(request:, transaction_class:)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def default_models
|
|
36
|
+
application = Rails.application if defined?(Rails) && Rails.respond_to?(:application)
|
|
37
|
+
application&.eager_load!
|
|
38
|
+
ActiveRecord::Base.descendants.select do |model|
|
|
39
|
+
active_record_model?(model) &&
|
|
40
|
+
model.name.present? &&
|
|
41
|
+
!model.name.start_with?("ZeroRailsAdapter::")
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def default_writable_attributes(model)
|
|
46
|
+
model.column_names -
|
|
47
|
+
model.readonly_attributes.to_a -
|
|
48
|
+
[model.inheritance_column, "created_at", "updated_at"]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def active_record_model?(candidate)
|
|
52
|
+
candidate.is_a?(Class) &&
|
|
53
|
+
candidate < ActiveRecord::Base &&
|
|
54
|
+
!candidate.abstract_class?
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZeroRailsAdapter
|
|
4
|
+
class Context
|
|
5
|
+
attr_reader :identity, :request, :rack_request
|
|
6
|
+
|
|
7
|
+
delegate :user_id, :current_user, :claims, to: :identity
|
|
8
|
+
|
|
9
|
+
def initialize(identity:, request: nil, rack_request: nil)
|
|
10
|
+
@identity = identity
|
|
11
|
+
@request = request
|
|
12
|
+
@rack_request = rack_request
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def with(request: @request, rack_request: @rack_request)
|
|
16
|
+
self.class.new(identity:, request:, rack_request:)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZeroRailsAdapter
|
|
4
|
+
module Crud
|
|
5
|
+
class Dispatcher
|
|
6
|
+
ACTIONS = {
|
|
7
|
+
"create" => :create,
|
|
8
|
+
"insert" => :create,
|
|
9
|
+
"update" => :update,
|
|
10
|
+
"destroy" => :destroy,
|
|
11
|
+
"delete" => :destroy
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :context
|
|
15
|
+
|
|
16
|
+
def initialize(context:)
|
|
17
|
+
@context = context
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call(mutation)
|
|
21
|
+
resource, action = parse_name(mutation.name)
|
|
22
|
+
model = resolve_model(resource)
|
|
23
|
+
attributes = normalize_arguments(mutation.argument)
|
|
24
|
+
|
|
25
|
+
case action
|
|
26
|
+
when :create
|
|
27
|
+
create(model, attributes)
|
|
28
|
+
when :update
|
|
29
|
+
update(model, attributes)
|
|
30
|
+
when :destroy
|
|
31
|
+
destroy(model, attributes)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def parse_name(name)
|
|
38
|
+
resource, action_name = name.to_s.split(".", 2)
|
|
39
|
+
action = ACTIONS[action_name]
|
|
40
|
+
unless resource&.match?(/\A[a-zA-Z_][a-zA-Z0-9_]*\z/) && action
|
|
41
|
+
raise UnknownMutatorError, "Could not find mutator #{name}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
[resource, action]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def resolve_model(resource)
|
|
48
|
+
model = ZeroRailsAdapter.configuration.model_resolver.call(resource)
|
|
49
|
+
return model if model.is_a?(Class) && model < ActiveRecord::Base
|
|
50
|
+
|
|
51
|
+
raise UnknownMutatorError, "Could not resolve Active Record model for #{resource}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def normalize_arguments(arguments)
|
|
55
|
+
unless arguments.respond_to?(:to_h)
|
|
56
|
+
raise ValidationError, "CRUD mutation arguments must be an object"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
arguments.to_h.stringify_keys
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def create(model, attributes)
|
|
63
|
+
authorize!(:create, model, attributes)
|
|
64
|
+
model.create!(writable(model, :create, attributes))
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def update(model, attributes)
|
|
69
|
+
record = find_record!(model, attributes)
|
|
70
|
+
authorize!(:update, record, attributes)
|
|
71
|
+
changes = writable(model, :update, attributes).except(*primary_keys(model))
|
|
72
|
+
record.update!(changes)
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def destroy(model, attributes)
|
|
77
|
+
record = find_record!(model, attributes)
|
|
78
|
+
authorize!(:destroy, record, attributes)
|
|
79
|
+
record.destroy!
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def find_record!(model, attributes)
|
|
84
|
+
keys = primary_keys(model)
|
|
85
|
+
values = attributes.slice(*keys)
|
|
86
|
+
missing = keys - values.keys
|
|
87
|
+
if missing.any?
|
|
88
|
+
raise ValidationError.new(
|
|
89
|
+
"Missing primary key attributes: #{missing.join(', ')}",
|
|
90
|
+
details: {"primaryKey" => missing}
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
model.find_by!(values)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def primary_keys(model)
|
|
98
|
+
Array(model.primary_key).map(&:to_s)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def authorize!(action, target, attributes)
|
|
102
|
+
allowed = ZeroRailsAdapter.configuration.crud_authorizer.call(
|
|
103
|
+
context,
|
|
104
|
+
action,
|
|
105
|
+
target,
|
|
106
|
+
attributes
|
|
107
|
+
)
|
|
108
|
+
raise ForbiddenError, "Mutation is not authorized" unless allowed
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def writable(model, action, attributes)
|
|
112
|
+
allowed = ZeroRailsAdapter.configuration.writable_attributes.call(
|
|
113
|
+
model,
|
|
114
|
+
action,
|
|
115
|
+
context
|
|
116
|
+
)
|
|
117
|
+
attributes.slice(*Array(allowed).map(&:to_s))
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZeroRailsAdapter
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
class ApplicationError < Error
|
|
7
|
+
attr_reader :details
|
|
8
|
+
|
|
9
|
+
def initialize(message = nil, details: nil)
|
|
10
|
+
@details = details
|
|
11
|
+
super(message)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class ValidationError < ApplicationError; end
|
|
16
|
+
class ForbiddenError < ApplicationError; end
|
|
17
|
+
class UnauthorizedError < Error; end
|
|
18
|
+
class UnknownMutatorError < ApplicationError; end
|
|
19
|
+
class UnsupportedColumnTypeError < Error; end
|
|
20
|
+
|
|
21
|
+
class ProtocolError < Error
|
|
22
|
+
attr_reader :mutation_ids
|
|
23
|
+
|
|
24
|
+
def initialize(message, mutation_ids: [])
|
|
25
|
+
@mutation_ids = mutation_ids
|
|
26
|
+
super(message)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class ParseError < ProtocolError
|
|
31
|
+
attr_reader :source
|
|
32
|
+
|
|
33
|
+
def initialize(message, mutation_ids: [], source: :body)
|
|
34
|
+
@source = source
|
|
35
|
+
super(message, mutation_ids:)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class UnsupportedPushVersionError < ProtocolError
|
|
40
|
+
attr_reader :version
|
|
41
|
+
|
|
42
|
+
def initialize(version, mutation_ids: [])
|
|
43
|
+
@version = version
|
|
44
|
+
super("Unsupported push version: #{version}", mutation_ids:)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class OutOfOrderMutationError < ProtocolError; end
|
|
49
|
+
class AlreadyProcessedError < Error; end
|
|
50
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZeroRailsAdapter
|
|
4
|
+
Identity = Data.define(:user_id, :current_user, :claims) do
|
|
5
|
+
def initialize(user_id: nil, current_user: nil, claims: {})
|
|
6
|
+
super(user_id: user_id&.to_s, current_user:, claims: claims || {})
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZeroRailsAdapter
|
|
4
|
+
class Mutation
|
|
5
|
+
REQUIRED_KEYS = %w[type id clientID name args timestamp].freeze
|
|
6
|
+
|
|
7
|
+
attr_reader :type, :id, :client_id, :name, :args, :timestamp
|
|
8
|
+
|
|
9
|
+
def self.parse(value)
|
|
10
|
+
unless value.is_a?(Hash)
|
|
11
|
+
raise ParseError, "Mutation must be an object"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
missing = REQUIRED_KEYS.reject { |key| value.key?(key) }
|
|
15
|
+
raise ParseError, "Mutation is missing #{missing.join(', ')}" if missing.any?
|
|
16
|
+
raise ParseError, "Mutation type must be custom" unless value["type"] == "custom"
|
|
17
|
+
raise ParseError, "Mutation id must be a number" unless value["id"].is_a?(Numeric)
|
|
18
|
+
raise ParseError, "Mutation clientID must be a string" unless value["clientID"].is_a?(String)
|
|
19
|
+
raise ParseError, "Mutation name must be a string" unless value["name"].is_a?(String)
|
|
20
|
+
raise ParseError, "Mutation args must be an array" unless value["args"].is_a?(Array)
|
|
21
|
+
raise ParseError, "Mutation timestamp must be a number" unless value["timestamp"].is_a?(Numeric)
|
|
22
|
+
|
|
23
|
+
new(
|
|
24
|
+
type: value["type"],
|
|
25
|
+
id: value["id"],
|
|
26
|
+
client_id: value["clientID"],
|
|
27
|
+
name: value["name"],
|
|
28
|
+
args: value["args"],
|
|
29
|
+
timestamp: value["timestamp"]
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def initialize(type:, id:, client_id:, name:, args:, timestamp:)
|
|
34
|
+
@type = type
|
|
35
|
+
@id = id
|
|
36
|
+
@client_id = client_id
|
|
37
|
+
@name = name
|
|
38
|
+
@args = args
|
|
39
|
+
@timestamp = timestamp
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def argument
|
|
43
|
+
args.first
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def identifier
|
|
47
|
+
{"clientID" => client_id, "id" => id}
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZeroRailsAdapter
|
|
4
|
+
class Mutator
|
|
5
|
+
include ActiveModel::Model
|
|
6
|
+
include ActiveModel::Attributes
|
|
7
|
+
|
|
8
|
+
class_attribute :configured_mutation_name, instance_writer: false
|
|
9
|
+
class_attribute :authorization_callback, instance_writer: false
|
|
10
|
+
|
|
11
|
+
attr_reader :arguments, :context
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def mutation_name(name = nil)
|
|
15
|
+
return configured_mutation_name if name.nil?
|
|
16
|
+
|
|
17
|
+
self.configured_mutation_name = name.to_s
|
|
18
|
+
ZeroRailsAdapter.registry.register(self)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def authorize_with(callable = nil, &block)
|
|
22
|
+
self.authorization_callback = callable || block
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def perform(&block)
|
|
26
|
+
define_method(:perform, &block)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def call(arguments = {}, context:)
|
|
30
|
+
new(arguments, context:).call
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(arguments = {}, context:)
|
|
35
|
+
@arguments = arguments
|
|
36
|
+
@context = context
|
|
37
|
+
attributes = arguments.respond_to?(:to_h) ? arguments.to_h.symbolize_keys : {}
|
|
38
|
+
super(attributes)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def call
|
|
42
|
+
validate_arguments!
|
|
43
|
+
authorize!
|
|
44
|
+
perform
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def perform
|
|
48
|
+
raise NotImplementedError, "#{self.class.name} must implement #perform"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def validate_arguments!
|
|
54
|
+
return if valid?
|
|
55
|
+
|
|
56
|
+
raise ValidationError.new(
|
|
57
|
+
"Mutation arguments are invalid",
|
|
58
|
+
details: errors.to_hash.stringify_keys
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def authorize!
|
|
63
|
+
callback = self.class.authorization_callback
|
|
64
|
+
return true unless callback
|
|
65
|
+
return true if instance_exec(context, &callback)
|
|
66
|
+
|
|
67
|
+
raise ForbiddenError, "Mutation is not authorized"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZeroRailsAdapter
|
|
4
|
+
class Processor
|
|
5
|
+
CLEANUP_MUTATION_NAME = "_zero_cleanupResults"
|
|
6
|
+
|
|
7
|
+
attr_reader :request, :context, :storage
|
|
8
|
+
|
|
9
|
+
def initialize(request:, context:)
|
|
10
|
+
@request = request
|
|
11
|
+
@context = context.with(request:)
|
|
12
|
+
@storage = ZeroRailsAdapter.configuration.storage_provider.call(request)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call
|
|
16
|
+
responses = []
|
|
17
|
+
processed_count = 0
|
|
18
|
+
|
|
19
|
+
request.mutations.each do |mutation|
|
|
20
|
+
if mutation.name == CLEANUP_MUTATION_NAME
|
|
21
|
+
cleanup_results(mutation)
|
|
22
|
+
processed_count += 1
|
|
23
|
+
next
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
responses << ActiveSupport::Notifications.instrument(
|
|
27
|
+
"mutation.zero_rails_adapter",
|
|
28
|
+
name: mutation.name,
|
|
29
|
+
client_id: mutation.client_id,
|
|
30
|
+
mutation_id: mutation.id,
|
|
31
|
+
client_group_id: request.client_group_id,
|
|
32
|
+
app_id: request.app_id,
|
|
33
|
+
schema: request.schema
|
|
34
|
+
) { process_mutation(mutation) }
|
|
35
|
+
processed_count += 1
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
{
|
|
39
|
+
"kind" => "MutateResponse",
|
|
40
|
+
"mutations" => responses,
|
|
41
|
+
"userID" => context.user_id
|
|
42
|
+
}
|
|
43
|
+
rescue OutOfOrderMutationError => error
|
|
44
|
+
push_failed(
|
|
45
|
+
reason: "oooMutation",
|
|
46
|
+
message: error.message,
|
|
47
|
+
mutation_ids: request.mutation_ids.drop(processed_count)
|
|
48
|
+
)
|
|
49
|
+
rescue ActiveRecord::ActiveRecordError => error
|
|
50
|
+
push_failed(
|
|
51
|
+
reason: "database",
|
|
52
|
+
message: error.message,
|
|
53
|
+
mutation_ids: request.mutation_ids.drop(processed_count)
|
|
54
|
+
)
|
|
55
|
+
rescue StandardError => error
|
|
56
|
+
push_failed(
|
|
57
|
+
reason: "internal",
|
|
58
|
+
message: error.message,
|
|
59
|
+
mutation_ids: request.mutation_ids.drop(processed_count)
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def process_mutation(mutation)
|
|
66
|
+
result = nil
|
|
67
|
+
|
|
68
|
+
storage.transaction do
|
|
69
|
+
expected = storage.increment_lmid!(mutation.client_id)
|
|
70
|
+
check_order!(expected, mutation)
|
|
71
|
+
authorized = ZeroRailsAdapter.configuration.authorizer.call(context, mutation)
|
|
72
|
+
raise ForbiddenError, "Mutation is not authorized" unless authorized
|
|
73
|
+
mutator = ZeroRailsAdapter.registry.find(mutation.name)
|
|
74
|
+
result =
|
|
75
|
+
if mutator
|
|
76
|
+
mutator.call(mutation.argument, context:)
|
|
77
|
+
else
|
|
78
|
+
Crud::Dispatcher.new(context:).call(mutation)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
success_response(mutation, result)
|
|
83
|
+
rescue AlreadyProcessedError => error
|
|
84
|
+
already_processed_response(mutation, error)
|
|
85
|
+
rescue OutOfOrderMutationError
|
|
86
|
+
raise
|
|
87
|
+
rescue StandardError => error
|
|
88
|
+
application_error = normalize_application_error(error)
|
|
89
|
+
begin
|
|
90
|
+
persist_failure(mutation, application_error)
|
|
91
|
+
rescue AlreadyProcessedError => concurrent_error
|
|
92
|
+
return already_processed_response(mutation, concurrent_error)
|
|
93
|
+
end
|
|
94
|
+
mutation_response(mutation, application_result(application_error))
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def persist_failure(mutation, error)
|
|
98
|
+
storage.transaction do
|
|
99
|
+
expected = storage.increment_lmid!(mutation.client_id)
|
|
100
|
+
check_order!(expected, mutation)
|
|
101
|
+
storage.write_result(
|
|
102
|
+
client_id: mutation.client_id,
|
|
103
|
+
mutation_id: mutation.id,
|
|
104
|
+
result: application_result(error)
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def check_order!(expected, mutation)
|
|
110
|
+
if mutation.id < expected
|
|
111
|
+
raise AlreadyProcessedError,
|
|
112
|
+
"Ignoring mutation from #{mutation.client_id} with ID #{mutation.id} " \
|
|
113
|
+
"as it was already processed. Expected: #{expected}"
|
|
114
|
+
end
|
|
115
|
+
return if mutation.id == expected
|
|
116
|
+
|
|
117
|
+
raise OutOfOrderMutationError,
|
|
118
|
+
"Client #{mutation.client_id} sent mutation ID #{mutation.id} but expected #{expected}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def success_response(mutation, result)
|
|
122
|
+
payload = {}
|
|
123
|
+
payload["data"] = serializable(result) unless result.nil?
|
|
124
|
+
mutation_response(mutation, payload)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def mutation_response(mutation, result)
|
|
128
|
+
{"id" => mutation.identifier, "result" => result}
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def already_processed_response(mutation, error)
|
|
132
|
+
mutation_response(mutation, {
|
|
133
|
+
"error" => "alreadyProcessed",
|
|
134
|
+
"details" => error.message
|
|
135
|
+
})
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def normalize_application_error(error)
|
|
139
|
+
return error if error.is_a?(ApplicationError)
|
|
140
|
+
|
|
141
|
+
if error.respond_to?(:record) && error.record.respond_to?(:errors)
|
|
142
|
+
return ApplicationError.new(error.message, details: error.record.errors.to_hash)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
ApplicationError.new(error.message)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def application_result(error)
|
|
149
|
+
result = {"error" => "app", "message" => error.message}
|
|
150
|
+
result["details"] = serializable(error.details) unless error.details.nil?
|
|
151
|
+
result
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def serializable(value)
|
|
155
|
+
value.as_json
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def cleanup_results(mutation)
|
|
159
|
+
args = mutation.argument
|
|
160
|
+
return unless args.is_a?(Hash)
|
|
161
|
+
storage.transaction { storage.cleanup(args) }
|
|
162
|
+
rescue StandardError => error
|
|
163
|
+
ZeroRailsAdapter.configuration.logger&.warn(
|
|
164
|
+
"ZeroRailsAdapter cleanup failed: #{error.class}: #{error.message}"
|
|
165
|
+
)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def push_failed(reason:, message:, mutation_ids:)
|
|
169
|
+
{
|
|
170
|
+
"kind" => "PushFailed",
|
|
171
|
+
"origin" => "server",
|
|
172
|
+
"reason" => reason,
|
|
173
|
+
"message" => message,
|
|
174
|
+
"mutationIDs" => mutation_ids
|
|
175
|
+
}
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZeroRailsAdapter
|
|
4
|
+
class Registry
|
|
5
|
+
SEPARATOR = /[|.]/
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@mutators = {}
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def register(mutator_class, as: mutator_class.mutation_name)
|
|
12
|
+
raise ArgumentError, "mutation_name must be configured" if as.to_s.empty?
|
|
13
|
+
|
|
14
|
+
@mutators[canonical(as)] = mutator_class.name.to_s.empty? ? mutator_class : mutator_class.name
|
|
15
|
+
mutator_class
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def fetch(name)
|
|
19
|
+
find(name) || raise(UnknownMutatorError, "Could not find mutator #{name}")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def find(name)
|
|
23
|
+
key = canonical(name)
|
|
24
|
+
mutator = resolve(@mutators[key])
|
|
25
|
+
return mutator if mutator
|
|
26
|
+
|
|
27
|
+
conventional_class_name(name).safe_constantize
|
|
28
|
+
mutator = resolve(@mutators[key])
|
|
29
|
+
return mutator if mutator
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def registered_names
|
|
33
|
+
@mutators.keys.sort
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def canonical(name)
|
|
39
|
+
name.to_s.split(SEPARATOR).join("|")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def conventional_class_name(name)
|
|
43
|
+
parts = name.to_s.split(SEPARATOR)
|
|
44
|
+
"#{parts.map(&:camelize).join('::')}Mutator"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def resolve(entry)
|
|
48
|
+
entry.is_a?(String) ? entry.safe_constantize : entry
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|