operations 0.0.1 → 0.6.3

Sign up to get free protection for your applications and to get access to all the features.
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