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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +33 -0
  3. data/.gitignore +4 -0
  4. data/.rspec +0 -2
  5. data/.rubocop.yml +21 -0
  6. data/.rubocop_todo.yml +30 -0
  7. data/Appraisals +8 -0
  8. data/CHANGELOG.md +17 -0
  9. data/Gemfile +8 -2
  10. data/README.md +910 -5
  11. data/Rakefile +3 -1
  12. data/gemfiles/rails.5.2.gemfile +14 -0
  13. data/gemfiles/rails.6.0.gemfile +14 -0
  14. data/gemfiles/rails.6.1.gemfile +14 -0
  15. data/gemfiles/rails.7.0.gemfile +14 -0
  16. data/gemfiles/rails.7.1.gemfile +14 -0
  17. data/lib/operations/command.rb +413 -0
  18. data/lib/operations/components/base.rb +79 -0
  19. data/lib/operations/components/callback.rb +55 -0
  20. data/lib/operations/components/contract.rb +20 -0
  21. data/lib/operations/components/idempotency.rb +70 -0
  22. data/lib/operations/components/on_failure.rb +16 -0
  23. data/lib/operations/components/on_success.rb +35 -0
  24. data/lib/operations/components/operation.rb +37 -0
  25. data/lib/operations/components/policies.rb +42 -0
  26. data/lib/operations/components/prechecks.rb +38 -0
  27. data/lib/operations/components/preconditions.rb +45 -0
  28. data/lib/operations/components.rb +5 -0
  29. data/lib/operations/configuration.rb +15 -0
  30. data/lib/operations/contract/messages_resolver.rb +11 -0
  31. data/lib/operations/contract.rb +39 -0
  32. data/lib/operations/convenience.rb +102 -0
  33. data/lib/operations/form/attribute.rb +42 -0
  34. data/lib/operations/form/builder.rb +85 -0
  35. data/lib/operations/form.rb +194 -0
  36. data/lib/operations/result.rb +122 -0
  37. data/lib/operations/test_helpers.rb +71 -0
  38. data/lib/operations/types.rb +6 -0
  39. data/lib/operations/version.rb +3 -1
  40. data/lib/operations.rb +42 -2
  41. data/operations.gemspec +20 -4
  42. metadata +164 -9
  43. 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
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Gem types root module
4
+ module Operations::Types
5
+ include Dry::Types()
6
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Operations
2
- VERSION = "0.0.1"
4
+ VERSION = "0.6.3"
3
5
  end
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; end
5
- # Your code goes here...
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
- require_relative 'lib/operations/version'
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.3.0")
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 = Dir.chdir(File.expand_path('..', __FILE__)) do
22
- `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
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