operations 0.0.1 → 0.6.3
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 +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 +30 -0
- data/Appraisals +8 -0
- data/CHANGELOG.md +17 -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 +413 -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"
|
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
|