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.
Files changed (30) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +344 -0
  4. data/app/controllers/zero_rails_adapter/mutations_controller.rb +74 -0
  5. data/config/routes.rb +7 -0
  6. data/examples/nextjs/README.md +29 -0
  7. data/examples/nextjs/src/zero/mutators.ts +37 -0
  8. data/examples/nextjs/src/zero/schema.ts +20 -0
  9. data/lib/generators/zero_rails_adapter/install/install_generator.rb +15 -0
  10. data/lib/generators/zero_rails_adapter/install/templates/initializer.rb +52 -0
  11. data/lib/generators/zero_rails_adapter/mutator/mutator_generator.rb +31 -0
  12. data/lib/generators/zero_rails_adapter/mutator/templates/mutator.rb +14 -0
  13. data/lib/generators/zero_rails_adapter/typescript/typescript_generator.rb +18 -0
  14. data/lib/zero_rails_adapter/configuration.rb +57 -0
  15. data/lib/zero_rails_adapter/context.rb +19 -0
  16. data/lib/zero_rails_adapter/crud/dispatcher.rb +121 -0
  17. data/lib/zero_rails_adapter/engine.rb +7 -0
  18. data/lib/zero_rails_adapter/errors.rb +50 -0
  19. data/lib/zero_rails_adapter/identity.rb +9 -0
  20. data/lib/zero_rails_adapter/mutation.rb +50 -0
  21. data/lib/zero_rails_adapter/mutator.rb +70 -0
  22. data/lib/zero_rails_adapter/processor.rb +178 -0
  23. data/lib/zero_rails_adapter/registry.rb +51 -0
  24. data/lib/zero_rails_adapter/request.rb +93 -0
  25. data/lib/zero_rails_adapter/request_verifiers/api_key.rb +21 -0
  26. data/lib/zero_rails_adapter/storage/zero_schema.rb +95 -0
  27. data/lib/zero_rails_adapter/type_script/generator.rb +421 -0
  28. data/lib/zero_rails_adapter/version.rb +5 -0
  29. data/lib/zero_rails_adapter.rb +50 -0
  30. 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZeroRailsAdapter
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace ZeroRailsAdapter
6
+ end
7
+ 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