operations 0.0.1 → 0.6.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +33 -0
- data/.gitignore +4 -0
- data/.rspec +0 -2
- data/.rubocop.yml +21 -0
- data/.rubocop_todo.yml +36 -0
- data/Appraisals +8 -0
- data/CHANGELOG.md +11 -0
- data/Gemfile +8 -2
- data/README.md +910 -5
- data/Rakefile +3 -1
- data/gemfiles/rails.5.2.gemfile +14 -0
- data/gemfiles/rails.6.0.gemfile +14 -0
- data/gemfiles/rails.6.1.gemfile +14 -0
- data/gemfiles/rails.7.0.gemfile +14 -0
- data/gemfiles/rails.7.1.gemfile +14 -0
- data/lib/operations/command.rb +412 -0
- data/lib/operations/components/base.rb +79 -0
- data/lib/operations/components/callback.rb +55 -0
- data/lib/operations/components/contract.rb +20 -0
- data/lib/operations/components/idempotency.rb +70 -0
- data/lib/operations/components/on_failure.rb +16 -0
- data/lib/operations/components/on_success.rb +35 -0
- data/lib/operations/components/operation.rb +37 -0
- data/lib/operations/components/policies.rb +42 -0
- data/lib/operations/components/prechecks.rb +38 -0
- data/lib/operations/components/preconditions.rb +45 -0
- data/lib/operations/components.rb +5 -0
- data/lib/operations/configuration.rb +15 -0
- data/lib/operations/contract/messages_resolver.rb +11 -0
- data/lib/operations/contract.rb +39 -0
- data/lib/operations/convenience.rb +102 -0
- data/lib/operations/form/attribute.rb +42 -0
- data/lib/operations/form/builder.rb +85 -0
- data/lib/operations/form.rb +194 -0
- data/lib/operations/result.rb +122 -0
- data/lib/operations/test_helpers.rb +71 -0
- data/lib/operations/types.rb +6 -0
- data/lib/operations/version.rb +3 -1
- data/lib/operations.rb +42 -2
- data/operations.gemspec +20 -4
- metadata +164 -9
- data/.travis.yml +0 -6
@@ -0,0 +1,194 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# This class implements Rails form object compatibility layer
|
4
|
+
# It is possible to configure form object attributes automatically
|
5
|
+
# basing on Dry Schema user {Operations::Form::Builder}
|
6
|
+
# @example
|
7
|
+
#
|
8
|
+
# class AuthorForm < Operations::Form
|
9
|
+
# attribute :name
|
10
|
+
# end
|
11
|
+
#
|
12
|
+
# class PostForm < Operations::Form
|
13
|
+
# attribute :title
|
14
|
+
# attribute :tags, collection: true
|
15
|
+
# attribute :author, form: AuthorForm
|
16
|
+
# end
|
17
|
+
#
|
18
|
+
# PostForm.new({ tags: ["foobar"], author: { name: "Batman" } })
|
19
|
+
# # => #<PostForm attributes={:title=>nil, :tags=>["foobar"], :author=>#<AuthorForm attributes={:name=>"Batman"}>}>
|
20
|
+
#
|
21
|
+
# @see Operations::Form::Builder
|
22
|
+
class Operations::Form
|
23
|
+
extend Dry::Initializer
|
24
|
+
include Dry::Equalizer(:attributes, :errors)
|
25
|
+
|
26
|
+
param :data,
|
27
|
+
type: Operations::Types::Hash.map(Operations::Types::Symbol, Operations::Types::Any),
|
28
|
+
default: proc { {} },
|
29
|
+
reader: :private
|
30
|
+
option :messages,
|
31
|
+
type: Operations::Types::Hash.map(
|
32
|
+
Operations::Types::Nil | Operations::Types::Coercible::Symbol,
|
33
|
+
Operations::Types::Any
|
34
|
+
),
|
35
|
+
default: proc { {} },
|
36
|
+
reader: :private
|
37
|
+
|
38
|
+
class_attribute :attributes, instance_accessor: false, default: {}
|
39
|
+
|
40
|
+
def self.attribute(name, **options)
|
41
|
+
attribute = Operations::Form::Attribute.new(name, **options)
|
42
|
+
|
43
|
+
self.attributes = attributes.merge(
|
44
|
+
attribute.name => attribute
|
45
|
+
)
|
46
|
+
end
|
47
|
+
|
48
|
+
def self.human_attribute_name(name, options = {})
|
49
|
+
if attributes[name.to_sym]
|
50
|
+
attributes[name.to_sym].model_human_name(options)
|
51
|
+
else
|
52
|
+
name.to_s.humanize
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def self.validators_on(name)
|
57
|
+
attributes[name.to_sym]&.model_validators || []
|
58
|
+
end
|
59
|
+
|
60
|
+
def type_for_attribute(name)
|
61
|
+
self.class.attributes[name.to_sym].model_type
|
62
|
+
end
|
63
|
+
|
64
|
+
def localized_attr_name_for(name, locale)
|
65
|
+
self.class.attributes[name.to_sym].model_localized_attr_name(locale)
|
66
|
+
end
|
67
|
+
|
68
|
+
def has_attribute?(name) # rubocop:disable Naming/PredicateName
|
69
|
+
self.class.attributes.key?(name.to_sym)
|
70
|
+
end
|
71
|
+
|
72
|
+
def attributes
|
73
|
+
self.class.attributes.keys.to_h do |name|
|
74
|
+
[name, read_attribute(name)]
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def assigned_attributes
|
79
|
+
(self.class.attributes.keys & data.keys).to_h do |name|
|
80
|
+
[name, read_attribute(name)]
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
def method_missing(name, *)
|
85
|
+
read_attribute(name)
|
86
|
+
end
|
87
|
+
|
88
|
+
def respond_to_missing?(name, *)
|
89
|
+
self.class.attributes.key?(name)
|
90
|
+
end
|
91
|
+
|
92
|
+
def model_name
|
93
|
+
ActiveModel::Name.new(self.class)
|
94
|
+
end
|
95
|
+
|
96
|
+
# This should return false if we want to use POST.
|
97
|
+
# Now it is going to generate PATCH form.
|
98
|
+
def persisted?
|
99
|
+
true
|
100
|
+
end
|
101
|
+
|
102
|
+
# Probably can be always nil, it is used in automated URL derival.
|
103
|
+
# We can make it work later but it will require additional concepts.
|
104
|
+
def to_key
|
105
|
+
nil
|
106
|
+
end
|
107
|
+
|
108
|
+
def errors
|
109
|
+
@errors ||= ActiveModel::Errors.new(self).tap do |errors|
|
110
|
+
self.class.attributes.each do |name, attribute|
|
111
|
+
add_messages(errors, name, messages[name])
|
112
|
+
add_messages_to_collection(errors, name, messages[name]) if attribute.collection
|
113
|
+
end
|
114
|
+
|
115
|
+
add_messages(errors, :base, messages[nil])
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
def valid?
|
120
|
+
errors.empty?
|
121
|
+
end
|
122
|
+
|
123
|
+
def read_attribute(name)
|
124
|
+
cached_attribute(name) do |value, attribute|
|
125
|
+
if attribute.collection && attribute.form
|
126
|
+
wrap_collection([name], value, attribute.form)
|
127
|
+
elsif attribute.form
|
128
|
+
wrap_object([name], value, attribute.form)
|
129
|
+
elsif attribute.collection
|
130
|
+
value.nil? ? [] : value
|
131
|
+
else
|
132
|
+
value
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
private
|
138
|
+
|
139
|
+
def add_messages(errors, key, messages)
|
140
|
+
return unless messages.is_a?(Array)
|
141
|
+
|
142
|
+
messages.each do |message|
|
143
|
+
message = message[:text] if message.is_a?(Hash) && message.key?(:text)
|
144
|
+
errors.add(key, message)
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
def add_messages_to_collection(errors, key, messages)
|
149
|
+
return unless messages.is_a?(Hash)
|
150
|
+
|
151
|
+
read_attribute(key).size.times do |i|
|
152
|
+
add_messages(errors, "#{key}[#{i}]", messages[i])
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
def cached_attribute(name)
|
157
|
+
name = name.to_sym
|
158
|
+
return unless self.class.attributes.key?(name)
|
159
|
+
|
160
|
+
nested_name = "#{name}_attributes".to_sym
|
161
|
+
value = data.key?(nested_name) ? data[nested_name] : data[name]
|
162
|
+
|
163
|
+
(@attributes_cache ||= {})[name] ||= yield(value, self.class.attributes[name])
|
164
|
+
end
|
165
|
+
|
166
|
+
def wrap_collection(path, collection, form)
|
167
|
+
collection = [] if collection.nil?
|
168
|
+
|
169
|
+
case collection
|
170
|
+
when Hash
|
171
|
+
collection.values.map.with_index do |data, i|
|
172
|
+
wrap_object(path + [i], data, form)
|
173
|
+
end
|
174
|
+
when Array
|
175
|
+
collection.map.with_index do |data, i|
|
176
|
+
wrap_object(path + [i], data, form)
|
177
|
+
end
|
178
|
+
else
|
179
|
+
collection
|
180
|
+
end
|
181
|
+
end
|
182
|
+
|
183
|
+
def wrap_object(path, data, form)
|
184
|
+
data = {} if data.nil?
|
185
|
+
|
186
|
+
if data.is_a?(Hash)
|
187
|
+
nested_messages = messages.dig(*path)
|
188
|
+
nested_messages = {} unless nested_messages.is_a?(Hash)
|
189
|
+
form.new(data, messages: nested_messages)
|
190
|
+
else
|
191
|
+
data
|
192
|
+
end
|
193
|
+
end
|
194
|
+
end
|
@@ -0,0 +1,122 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# This is a the result of the operation. Considered a failure if contains
|
4
|
+
# any errors. Contains all the execution artifacts such as params and context
|
5
|
+
# (the initial one merged with the result of contract and operation routine
|
6
|
+
# execution).
|
7
|
+
# Also able to spawn a form object basing on the operation params and errors.
|
8
|
+
class Operations::Result
|
9
|
+
include Dry::Monads[:result]
|
10
|
+
include Dry::Equalizer(:operation, :component, :params, :context, :on_success, :errors)
|
11
|
+
extend Dry::Initializer
|
12
|
+
|
13
|
+
option :operation, type: Operations::Types::Instance(Operations::Command), optional: true
|
14
|
+
option :component, type: Operations::Types::Symbol.enum(*Operations::Command::COMPONENTS)
|
15
|
+
option :params, type: Operations::Types::Hash.map(Operations::Types::Symbol, Operations::Types::Any)
|
16
|
+
option :context, type: Operations::Types::Hash.map(Operations::Types::Symbol, Operations::Types::Any)
|
17
|
+
option :on_success, type: Operations::Types::Array.of(Operations::Types::Any), default: proc { [] }
|
18
|
+
option :on_failure, type: Operations::Types::Array.of(Operations::Types::Any), default: proc { [] }
|
19
|
+
option :errors, type: Operations::Types.Interface(:call) | Operations::Types::Instance(Dry::Validation::MessageSet),
|
20
|
+
default: proc { Dry::Validation::MessageSet.new([]).freeze }
|
21
|
+
|
22
|
+
# Instantiates a new result with the given fields updated
|
23
|
+
def merge(**changes)
|
24
|
+
self.class.new(**self.class.dry_initializer.attributes(self), **changes)
|
25
|
+
end
|
26
|
+
|
27
|
+
def errors(**options)
|
28
|
+
if @errors.respond_to?(:call)
|
29
|
+
@errors.call(**options)
|
30
|
+
else
|
31
|
+
options.empty? ? @errors : @errors.with([], options).freeze
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def success?
|
36
|
+
errors.empty?
|
37
|
+
end
|
38
|
+
alias_method :callable?, :success?
|
39
|
+
|
40
|
+
def failure?
|
41
|
+
!success?
|
42
|
+
end
|
43
|
+
|
44
|
+
# Checks if ANY of the passed precondition or policy codes have failed
|
45
|
+
# If nothing is passed - checks that ANY precondition or policy have failed
|
46
|
+
def failed_precheck?(*error_codes)
|
47
|
+
failure? &&
|
48
|
+
%i[policies preconditions].include?(component) &&
|
49
|
+
(error_codes.blank? || errors_with_code?(*error_codes))
|
50
|
+
end
|
51
|
+
alias_method :failed_prechecks?, :failed_precheck?
|
52
|
+
|
53
|
+
# Checks if ANY of the passed policy codes have failed
|
54
|
+
# If nothing is passed - checks that ANY policy have failed
|
55
|
+
def failed_policy?(*error_codes)
|
56
|
+
component == :policies && failed_precheck?(*error_codes)
|
57
|
+
end
|
58
|
+
alias_method :failed_policies?, :failed_policy?
|
59
|
+
|
60
|
+
# Checks if ANY of the passed precondition codes have failed
|
61
|
+
# If nothing is passed - checks that ANY precondition have failed
|
62
|
+
def failed_precondition?(*error_codes)
|
63
|
+
component == :preconditions && failed_precheck?(*error_codes)
|
64
|
+
end
|
65
|
+
alias_method :failed_preconditions?, :failed_precondition?
|
66
|
+
|
67
|
+
def to_monad
|
68
|
+
success? ? Success(self) : Failure(self)
|
69
|
+
end
|
70
|
+
|
71
|
+
# A form object that can be used for rendering forms with `form_for`,
|
72
|
+
# `simple_form` and other view helpers.
|
73
|
+
def form
|
74
|
+
@form ||= operation.form_class.new(
|
75
|
+
operation.form_hydrator.call(operation.form_class, params, **context),
|
76
|
+
messages: errors.to_h
|
77
|
+
)
|
78
|
+
end
|
79
|
+
|
80
|
+
def pretty_print(pp)
|
81
|
+
attributes = self.class.dry_initializer.attributes(self)
|
82
|
+
|
83
|
+
pp.object_group(self) do
|
84
|
+
pp.seplist(attributes.keys, -> { pp.text "," }) do |name|
|
85
|
+
pp.breakable " "
|
86
|
+
pp.group(1) do
|
87
|
+
pp.text name.to_s
|
88
|
+
pp.text " = "
|
89
|
+
pp.pp send(name)
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
def as_json(*, include_command: false, **)
|
96
|
+
hash = {
|
97
|
+
component: component,
|
98
|
+
params: params,
|
99
|
+
context: context_as_json,
|
100
|
+
on_success: on_success.as_json,
|
101
|
+
on_failure: on_failure.as_json,
|
102
|
+
errors: errors(full: true).to_h
|
103
|
+
}
|
104
|
+
hash[:command] = operation.as_json if include_command
|
105
|
+
hash
|
106
|
+
end
|
107
|
+
|
108
|
+
private
|
109
|
+
|
110
|
+
def errors_with_code?(name, *names)
|
111
|
+
names = [name] + names
|
112
|
+
(errors.map { |error| error.meta[:code] } & names).present?
|
113
|
+
end
|
114
|
+
|
115
|
+
def context_as_json
|
116
|
+
context.transform_values do |context_value|
|
117
|
+
next context_value.class.name unless context_value.respond_to?(:id)
|
118
|
+
|
119
|
+
[context_value.class.name, context_value.id].join("#")
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# This module contains helpers for simplifaction of testing
|
4
|
+
# of operation-related concerns.
|
5
|
+
#
|
6
|
+
# @example
|
7
|
+
# require "operations/test_helpers"
|
8
|
+
#
|
9
|
+
# module RailsHelper
|
10
|
+
# config.include Operations::TestHelpers
|
11
|
+
# end
|
12
|
+
module Operations::TestHelpers
|
13
|
+
def self.empty_contract
|
14
|
+
@empty_contract ||= OperationContract.build { schema { nil } }
|
15
|
+
end
|
16
|
+
|
17
|
+
# Used to test messages generated by preconditions
|
18
|
+
# If one calls `precondition.call` intests and it returns `Failure`
|
19
|
+
# there is no way to check is a proper text message will be rendered.
|
20
|
+
#
|
21
|
+
# @example
|
22
|
+
# subject(:precondition) { described_class::Precondition.new }
|
23
|
+
# let(:contract) { described_class::Contract.new }
|
24
|
+
#
|
25
|
+
# describe "#call" do
|
26
|
+
# subject(:errors) { precondition_errors(precondition, contract, **context) }
|
27
|
+
#
|
28
|
+
# let(:context) { { entity: entity } }
|
29
|
+
#
|
30
|
+
# context "when entity is pokable" do
|
31
|
+
# let(:entity) { build_stubbed(:entity) }
|
32
|
+
#
|
33
|
+
# it { is_expected.to be_empty }
|
34
|
+
# end
|
35
|
+
#
|
36
|
+
# context "when entity is not pokable" do
|
37
|
+
# let(:entity) { build_stubbed(:entity) }
|
38
|
+
#
|
39
|
+
# specify do
|
40
|
+
# expect(errors).to eq({
|
41
|
+
# nil => [{ code: :some_failure, text: "Unable to poke entity" }]
|
42
|
+
# })
|
43
|
+
# end
|
44
|
+
# end
|
45
|
+
# end
|
46
|
+
#
|
47
|
+
# We need to pass contract since preconditions are using the
|
48
|
+
# contract's message rendering and we want to ensure that the
|
49
|
+
# translation is placed correctly in the scope of the operation.
|
50
|
+
def precondition_errors(precondition, contract = Operations::TestHelpers.empty_contract, **context)
|
51
|
+
component = Operations::Components::Preconditions.new(
|
52
|
+
[precondition],
|
53
|
+
message_resolver: contract.message_resolver
|
54
|
+
)
|
55
|
+
result = component.call({}, context)
|
56
|
+
result.errors.to_h
|
57
|
+
end
|
58
|
+
|
59
|
+
# Works exactly the same way as {#precondition_errors} but
|
60
|
+
# for policies.
|
61
|
+
#
|
62
|
+
# @see #precondition_errors
|
63
|
+
def policy_errors(policy, contract = Operations::TestHelpers.empty_contract, **context)
|
64
|
+
component = Operations::Components::Policies.new(
|
65
|
+
[policy],
|
66
|
+
message_resolver: contract.message_resolver
|
67
|
+
)
|
68
|
+
result = component.call({}, context)
|
69
|
+
result.errors.to_h
|
70
|
+
end
|
71
|
+
end
|
data/lib/operations/version.rb
CHANGED
data/lib/operations.rb
CHANGED
@@ -1,6 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "dry-monads"
|
4
|
+
require "dry/monads/do"
|
5
|
+
require "dry-validation"
|
6
|
+
require "active_support/core_ext/array/wrap"
|
7
|
+
require "active_support/core_ext/class/attribute"
|
8
|
+
require "active_support/core_ext/module/delegation"
|
9
|
+
require "active_support/inflector/inflections"
|
10
|
+
require "active_model/naming"
|
11
|
+
require "after_commit_everywhere"
|
1
12
|
require "operations/version"
|
13
|
+
require "operations/types"
|
14
|
+
require "operations/configuration"
|
15
|
+
require "operations/contract"
|
16
|
+
require "operations/contract/messages_resolver"
|
17
|
+
require "operations/convenience"
|
18
|
+
require "operations/command"
|
19
|
+
require "operations/result"
|
20
|
+
require "operations/form"
|
21
|
+
require "operations/form/attribute"
|
22
|
+
require "operations/form/builder"
|
2
23
|
|
24
|
+
# The root gem module
|
3
25
|
module Operations
|
4
|
-
class Error < StandardError
|
5
|
-
|
26
|
+
class Error < StandardError
|
27
|
+
end
|
28
|
+
|
29
|
+
DEFAULT_ERROR_REPORTER = ->(message, payload) { Sentry.capture_message(message, extra: payload) }
|
30
|
+
DEFAULT_TRANSACTION = ->(&block) { ActiveRecord::Base.transaction(requires_new: true, &block) }
|
31
|
+
DEFAULT_AFTER_COMMIT = ->(&block) { AfterCommitEverywhere.after_commit(&block) }
|
32
|
+
|
33
|
+
class << self
|
34
|
+
attr_reader :default_config
|
35
|
+
|
36
|
+
def configure(configuration = nil, **options)
|
37
|
+
@default_config = (configuration || Configuration).new(**options)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
configure(
|
42
|
+
error_reporter: DEFAULT_ERROR_REPORTER,
|
43
|
+
transaction: DEFAULT_TRANSACTION,
|
44
|
+
after_commit: DEFAULT_AFTER_COMMIT
|
45
|
+
)
|
6
46
|
end
|
data/operations.gemspec
CHANGED
@@ -1,4 +1,6 @@
|
|
1
|
-
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "lib/operations/version"
|
2
4
|
|
3
5
|
Gem::Specification.new do |spec|
|
4
6
|
spec.name = "operations"
|
@@ -10,7 +12,7 @@ Gem::Specification.new do |spec|
|
|
10
12
|
spec.description = "Operations framework"
|
11
13
|
spec.homepage = "https://github.com/BookingSync/operations"
|
12
14
|
spec.license = "MIT"
|
13
|
-
spec.required_ruby_version = Gem::Requirement.new(">= 2.
|
15
|
+
spec.required_ruby_version = Gem::Requirement.new(">= 2.7.0")
|
14
16
|
|
15
17
|
spec.metadata["homepage_uri"] = spec.homepage
|
16
18
|
spec.metadata["source_code_uri"] = "https://github.com/BookingSync/operations"
|
@@ -18,10 +20,24 @@ Gem::Specification.new do |spec|
|
|
18
20
|
|
19
21
|
# Specify which files should be added to the gem when it is released.
|
20
22
|
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
21
|
-
spec.files
|
22
|
-
|
23
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
24
|
+
%x(git ls-files -z).split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
23
25
|
end
|
24
26
|
spec.bindir = "exe"
|
25
27
|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
26
28
|
spec.require_paths = ["lib"]
|
29
|
+
|
30
|
+
spec.add_development_dependency "appraisal"
|
31
|
+
spec.add_development_dependency "database_cleaner-active_record"
|
32
|
+
spec.add_development_dependency "sqlite3"
|
33
|
+
|
34
|
+
spec.add_runtime_dependency "activerecord", ">= 5.2.0"
|
35
|
+
spec.add_runtime_dependency "activesupport", ">= 5.2.0"
|
36
|
+
spec.add_runtime_dependency "after_commit_everywhere"
|
37
|
+
spec.add_runtime_dependency "dry-monads"
|
38
|
+
spec.add_runtime_dependency "dry-struct"
|
39
|
+
spec.add_runtime_dependency "dry-validation"
|
40
|
+
spec.metadata = {
|
41
|
+
"rubygems_mfa_required" => "true"
|
42
|
+
}
|
27
43
|
end
|