operations 0.0.1 → 0.6.2

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 +36 -0
  7. data/Appraisals +8 -0
  8. data/CHANGELOG.md +11 -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 +412 -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,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "operations/components/prechecks"
4
+
5
+ # Contains logic to handle idempotency checks.
6
+ #
7
+ # Idempotency checks are used to skip operation execution in
8
+ # certain conditions.
9
+ #
10
+ # An idempotency check returns a Result monad. If it returns
11
+ # a Failure, the operation body is skipped but the operation
12
+ # is considered successful. The value or failure will be merged
13
+ # to the result context in order to enrich it (the failure should
14
+ # contain something that operation body would return normally
15
+ # to mimic a proper operation call result).
16
+ #
17
+ # Component logs the failed check with `error_reporter`.
18
+ class Operations::Components::Idempotency < Operations::Components::Prechecks
19
+ def call(params, context)
20
+ failure, failed_check = process_callables(params, context)
21
+
22
+ if failure
23
+ new_result = result(
24
+ params: params,
25
+ context: context.merge(failure.failure)
26
+ )
27
+
28
+ report_failure(new_result, failed_check)
29
+
30
+ Failure(new_result)
31
+ else
32
+ Success(result(
33
+ params: params,
34
+ context: context
35
+ ))
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def process_callables(params, context)
42
+ failed_check = nil
43
+ failure = nil
44
+
45
+ callable.each do |entry|
46
+ result = entry.call(params, **context)
47
+
48
+ case result
49
+ when Failure
50
+ failed_check = entry
51
+ failure = result
52
+ break
53
+ when Success
54
+ next
55
+ else
56
+ raise "Unrecognized result of an idempotency check. Expected Result monad, got #{result.class}"
57
+ end
58
+ end
59
+
60
+ [failure, failed_check]
61
+ end
62
+
63
+ def report_failure(result, failed_check)
64
+ info_reporter&.call(
65
+ "Idempotency check failed",
66
+ result: result.as_json(include_command: true),
67
+ failed_check: failed_check.inspect
68
+ )
69
+ end
70
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "operations/components/callback"
4
+
5
+ # `on_failure` callbacks are called if a command have failed on a stage
6
+ # other than the operation itself or contract. I.e. on policies/preconditions.
7
+ class Operations::Components::OnFailure < Operations::Components::Callback
8
+ def call(operation_result)
9
+ callback_context = operation_result.context.merge(operation_failure: operation_result.errors.to_h)
10
+ results = callable.map do |entry|
11
+ call_entry(entry, operation_result, **callback_context)
12
+ end
13
+
14
+ maybe_report_failure(:on_failure, operation_result.merge(on_failure: results))
15
+ end
16
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "operations/components/callback"
4
+
5
+ # `on_success` callbacks are called when command was successful and implemented
6
+ # to be executed outside the outermost DB transcation (this is configurable
7
+ # but by default AfterCommitEverywhere gem is used).
8
+ # It there is a wrapping transaction (in cases when command is called inside
9
+ # of another command), the inner command result will have empty `on_success`
10
+ # component (since the callbacks will happen when the wparring command is finished).
11
+ class Operations::Components::OnSuccess < Operations::Components::Callback
12
+ option :after_commit, type: Operations::Types.Interface(:call)
13
+
14
+ def call(operation_result)
15
+ callback_result = after_commit.call do
16
+ call_entries(operation_result)
17
+ end
18
+
19
+ if callback_result.is_a?(Operations::Result)
20
+ callback_result
21
+ else
22
+ operation_result
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def call_entries(operation_result)
29
+ results = callable.map do |entry|
30
+ call_entry(entry, operation_result, **operation_result.context)
31
+ end
32
+
33
+ maybe_report_failure(:on_success, operation_result.merge(on_success: results))
34
+ end
35
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "operations/components/base"
4
+
5
+ # Wraps operation component call to adapt to the further processing.
6
+ class Operations::Components::Operation < Operations::Components::Base
7
+ PARAMS_FIRST_SIGNATURES = [[:params], [:_params], [:_]].freeze
8
+
9
+ def call(params, context)
10
+ arg_names = call_args(callable, types: %i[req opt])
11
+
12
+ operation_result = if PARAMS_FIRST_SIGNATURES.include?(arg_names)
13
+ callable.call(params, **context)
14
+ else
15
+ context_args = context.values_at(*arg_names)
16
+ callable.call(*context_args, **params)
17
+ end
18
+
19
+ extended_result(operation_result, params: params, context: context)
20
+ end
21
+
22
+ private
23
+
24
+ def extended_result(operation_result, params:, context:)
25
+ result = result(params: params, context: context)
26
+
27
+ if operation_result.failure?
28
+ result.merge(errors: errors(normalize_failure(operation_result.failure)))
29
+ elsif operation_result.value!.is_a?(Hash)
30
+ result.merge(context: context.merge(operation_result.value!))
31
+ elsif operation_result.value!.is_a?(Dry::Monads::Unit)
32
+ result
33
+ else
34
+ raise "Unexpected operation body result. Expected Hash, got #{operation_result.value!.class}"
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "operations/components/prechecks"
4
+
5
+ # We are looking for the first policy failure to return because
6
+ # it does not make sense to check for all policy failures. One is
7
+ # more than enough to know that we are not allowed to call the operation.
8
+ #
9
+ # If policy returns `false` then generic `:unauthorized` error
10
+ # code will be used. In case of `Failure` monad - the error code depends
11
+ # on the failure internal value. It can be a String, Symbol or even
12
+ # a Hash containing `:error` key.
13
+ #
14
+ # Successful policies return either `true` or `Success` monad.
15
+ class Operations::Components::Policies < Operations::Components::Prechecks
16
+ def call(params, context)
17
+ first_failure = callable.lazy.filter_map do |entry|
18
+ result_failure(entry.call(**context), entry)
19
+ end.first
20
+
21
+ result(
22
+ params: params,
23
+ context: context,
24
+ errors: errors(normalize_failure([first_failure].compact))
25
+ )
26
+ end
27
+
28
+ private
29
+
30
+ def result_failure(result, entry)
31
+ case result
32
+ when true, Success
33
+ nil
34
+ when Failure
35
+ result.failure
36
+ when false
37
+ :unauthorized
38
+ else
39
+ raise "Unexpected policy result: #{result} for #{entry}"
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "operations/components/base"
4
+
5
+ # Contains common logic for policies and preconditions.
6
+ class Operations::Components::Prechecks < Operations::Components::Base
7
+ param :callable, type: Operations::Types::Array.of(Operations::Types.Interface(:call))
8
+
9
+ def callable?(context)
10
+ (required_context - context.keys).empty?
11
+ end
12
+
13
+ def required_context
14
+ @required_context ||= required_kwargs | context_keys
15
+ end
16
+
17
+ private
18
+
19
+ def context_keys
20
+ keys = callable.flat_map do |entry|
21
+ if entry.respond_to?(:context_key)
22
+ [entry.context_key]
23
+ elsif entry.respond_to?(:context_keys)
24
+ entry.context_keys
25
+ else
26
+ []
27
+ end
28
+ end
29
+
30
+ keys.map(&:to_sym)
31
+ end
32
+
33
+ def required_kwargs
34
+ callable.flat_map do |entry|
35
+ call_args(entry, types: %i[keyreq])
36
+ end.uniq
37
+ end
38
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "operations/components/prechecks"
4
+
5
+ # We check all the precondition failures to return all the codes to
6
+ # the user at once. This provides a better UX, user is able to fix
7
+ # everything at once instead of getting messages one by one. This is
8
+ # similar to the idea of validations.
9
+ #
10
+ # Precondition can return a Symbol - it will be used as an error code.
11
+ # If String is returned - it will be used as a message itself. Please
12
+ # avoid returning string, use i18n instead. Hash with `:error` key
13
+ # will be also treated as a failure ans used accordingly. Also, `Failure`
14
+ # monad gets unwrapped and the value follows the rules above. Also, it is
15
+ # possible to return an array of failures.
16
+ #
17
+ # Successful preconditions returns either nil or an empty array or a
18
+ # `Success` monad.
19
+ class Operations::Components::Preconditions < Operations::Components::Prechecks
20
+ def call(params, context)
21
+ failures = callable.flat_map do |entry|
22
+ results = Array.wrap(entry.call(**context))
23
+ results.filter_map { |result| result_failure(result) }
24
+ end
25
+
26
+ result(
27
+ params: params,
28
+ context: context,
29
+ errors: errors(normalize_failure(failures))
30
+ )
31
+ end
32
+
33
+ private
34
+
35
+ def result_failure(result)
36
+ case result
37
+ when nil, Success
38
+ nil
39
+ when Failure
40
+ result.failure
41
+ else
42
+ result
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A namespace for operation component adapters
4
+ module Operations::Components
5
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-struct"
4
+
5
+ # The framework's configuration shared between all the commands.
6
+ #
7
+ # @see Operations.default_config
8
+ class Operations::Configuration < Dry::Struct
9
+ schema schema.strict
10
+
11
+ attribute :info_reporter?, Operations::Types.Interface(:call).optional
12
+ attribute :error_reporter?, Operations::Types.Interface(:call).optional
13
+ attribute :transaction, Operations::Types.Interface(:call)
14
+ attribute :after_commit, Operations::Types.Interface(:call)
15
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Patching the default messages resolver to append `:code` meta
4
+ # to every message produced.
5
+ class Operations::Contract::MessagesResolver < Dry::Validation::Messages::Resolver
6
+ def call(message:, meta: Dry::Schema::EMPTY_HASH, **rest)
7
+ meta = meta.merge(code: message) if message.is_a?(Symbol)
8
+
9
+ super(message: message, meta: meta, **rest)
10
+ end
11
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Just a base contract with i18n set up and a bunch of useful macro.
4
+ class Operations::Contract < Dry::Validation::Contract
5
+ option :message_resolver, default: -> { Operations::Contract::MessagesResolver.new(messages) }
6
+
7
+ # config.messages.backend = :i18n
8
+ config.messages.top_namespace = "operations"
9
+
10
+ def self.inherited(subclass)
11
+ super
12
+
13
+ return unless subclass.name
14
+
15
+ namespace = subclass.name.underscore.split("/")[0..-2].join("/")
16
+ subclass.config.messages.namespace = namespace
17
+ end
18
+
19
+ def self.prepend_rule(...)
20
+ rule(...)
21
+ rules.unshift(rules.pop)
22
+ end
23
+
24
+ def self.ensure_presence(context_key, field: "#{context_key}_id", optional: false)
25
+ rule do |context:|
26
+ next if context[context_key] || schema_error?(field)
27
+
28
+ if key?(field)
29
+ key(field).failure(:not_found)
30
+ elsif !optional
31
+ key(field).failure(:key?)
32
+ end
33
+ end
34
+ end
35
+
36
+ def call(input, **initial_context)
37
+ super(input, initial_context)
38
+ end
39
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This module helps to follow conventions. Work best with
4
+ # {Operations::Command.build}
5
+ #
6
+ # Unders the hood it defines classes in accordance to the
7
+ # nesting convenience. It is always possible to use this module
8
+ # along with the manually crafted components if necessary.
9
+ #
10
+ # @example
11
+ #
12
+ # class Namespace::OperationName
13
+ # extend Operations::Convenience
14
+ #
15
+ # contract do
16
+ # params { ... }
17
+ # rule(...) { ... }
18
+ # end
19
+ #
20
+ # policy do |current_user, **|
21
+ # current_user.is_a?(Superuser) && ...
22
+ # end
23
+ #
24
+ # def call(context_value1, context_value2, **params)
25
+ # ...
26
+ # end
27
+ # end
28
+ #
29
+ # @see Operations::Command.build
30
+ #
31
+ # Also, if this class is used as a container to cache the command
32
+ # instance under some name, this module will provide a method missing
33
+ # to call the command with `#call!` method using the `#call` method
34
+ # as an interface.
35
+ #
36
+ # @example
37
+ #
38
+ # class Namespace::OperationName
39
+ # extend Operations::Convenience
40
+ #
41
+ # def self.default
42
+ # Operations::Command.new(...)
43
+ # end
44
+ # end
45
+ #
46
+ # # A normall command call
47
+ # Namespace::OperationName.default.call(...)
48
+ # # Raises exception in case of failure
49
+ # Namespace::OperationName.default.call!(...)
50
+ # # Acts exactly the same way as the previous one
51
+ # # but notice where the bang is.
52
+ # Namespace::OperationName.default!.call(...)
53
+ #
54
+ # This is especially convenient when you have a DSL that
55
+ # expects some object responding to `#call` method but you want
56
+ # to raise an exception. In this case you would just pass
57
+ # `Namespace::OperationName.default!` into it.
58
+ #
59
+ module Operations::Convenience
60
+ def self.extended(mod)
61
+ mod.include Dry::Monads[:result]
62
+ mod.include Dry::Monads::Do.for(:call)
63
+ mod.extend Dry::Initializer
64
+ end
65
+
66
+ def method_missing(name, *args, **kwargs, &block)
67
+ name_without_suffix = name.to_s.delete_suffix("!").to_sym
68
+ if name.to_s.end_with?("!") && respond_to?(name_without_suffix)
69
+ public_send(name_without_suffix, *args, **kwargs, &block).method(:call!)
70
+ else
71
+ super
72
+ end
73
+ end
74
+
75
+ def respond_to_missing?(name, *)
76
+ (name.to_s.end_with?("!") && respond_to?(name.to_s.delete_suffix("!").to_sym)) || super
77
+ end
78
+
79
+ def contract(prefix = nil, from: OperationContract, &block)
80
+ contract = Class.new(from)
81
+ contract.config.messages.namespace = name.underscore
82
+ contract.class_eval(&block)
83
+ const_set("#{prefix.to_s.camelize}Contract", contract)
84
+ end
85
+
86
+ %w[policy precondition callback].each do |kind|
87
+ define_method kind do |prefix = nil, from: Object, &block|
88
+ raise ArgumentError.new("Please provide either a superclass or a block for #{kind}") unless from || block
89
+
90
+ klass = Class.new(from)
91
+
92
+ if from == Object
93
+ klass.extend(Dry::Initializer)
94
+ klass.include(Dry::Monads[:result])
95
+ end
96
+
97
+ klass.define_method(:call, &block) if block
98
+
99
+ const_set("#{prefix.to_s.camelize}#{kind.camelize}", klass)
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The main purpose is to infer attribute properties from the
4
+ # related model. We need it to automate form rendering for the
5
+ # legacy UI.
6
+ class Operations::Form::Attribute
7
+ extend Dry::Initializer
8
+ include Dry::Equalizer(:name, :collection, :form, :model_name)
9
+
10
+ param :name, type: Operations::Types::Coercible::Symbol
11
+ option :collection, type: Operations::Types::Bool, optional: true, default: proc { false }
12
+ option :form, type: Operations::Types::Class, optional: true
13
+ option :model_name,
14
+ type: Operations::Types::String | Operations::Types.Instance(Class).constrained(lt: ActiveRecord::Base),
15
+ optional: true
16
+
17
+ def model_type
18
+ @model_type ||= owning_model.type_for_attribute(string_name) if model_name
19
+ end
20
+
21
+ def model_human_name(options = {})
22
+ owning_model.human_attribute_name(string_name, options) if model_name
23
+ end
24
+
25
+ def model_validators
26
+ @model_validators ||= model_name ? owning_model.validators_on(string_name) : []
27
+ end
28
+
29
+ def model_localized_attr_name(locale)
30
+ owning_model.localized_attr_name_for(string_name, locale) if model_name
31
+ end
32
+
33
+ private
34
+
35
+ def owning_model
36
+ @owning_model ||= model_name.is_a?(String) ? model_name.constantize : model_name
37
+ end
38
+
39
+ def string_name
40
+ @string_name ||= name.to_s
41
+ end
42
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Traverses the passed {Dry::Schema::KeyMap} and generates
4
+ # {Operations::Form} classes on the fly. Handles nested structures.
5
+ #
6
+ # @see Operations::Form
7
+ class Operations::Form::Builder
8
+ extend Dry::Initializer
9
+
10
+ NESTED_ATTRIBUTES_SUFFIX = %r{_attributes\z}.freeze
11
+
12
+ option :base_class, Operations::Types::Instance(Class)
13
+
14
+ def build(key_map:, namespace:, class_name:, model_map:)
15
+ return namespace.const_get(class_name) if namespace && class_name && namespace.const_defined?(class_name)
16
+
17
+ traverse(key_map, namespace, class_name, model_map, [])
18
+ end
19
+
20
+ private
21
+
22
+ def traverse(key_map, namespace, class_name, model_map, path)
23
+ form = Class.new(base_class)
24
+ namespace.const_set(class_name, form) if namespace && class_name
25
+
26
+ key_map.each do |key|
27
+ key_path = path + [key.name]
28
+
29
+ case key
30
+ when Dry::Schema::Key::Array
31
+ nested_form = traverse(key.member, form, key.name.to_s.underscore.classify, model_map, key_path)
32
+ form.attribute(key.name, form: nested_form, collection: true, **model_name(key_path, model_map))
33
+ when Dry::Schema::Key::Hash
34
+ traverse_hash(form, key, model_map, path)
35
+ when Dry::Schema::Key
36
+ form.attribute(key.name, **model_name(key_path, model_map))
37
+ else
38
+ raise "Unknown key_map key: #{key.class}"
39
+ end
40
+ end
41
+
42
+ form
43
+ end
44
+
45
+ def traverse_hash(form, hash_key, model_map, path)
46
+ nested_attributes_suffix = hash_key.name.match?(NESTED_ATTRIBUTES_SUFFIX)
47
+ nested_attributes_collection = hash_key.members.all?(Dry::Schema::Key::Hash) &&
48
+ hash_key.members.map(&:members).uniq.size == 1
49
+
50
+ name, members, collection = specify_form_attributes(
51
+ hash_key,
52
+ nested_attributes_suffix,
53
+ nested_attributes_collection
54
+ )
55
+ form.define_method "#{hash_key.name}=", proc { |attributes| attributes } if nested_attributes_suffix
56
+
57
+ key_path = path + [name]
58
+ nested_form = traverse(members, form, name.underscore.camelize, model_map, key_path)
59
+ form.attribute(name, form: nested_form, collection: collection, **model_name(key_path, model_map))
60
+ end
61
+
62
+ def specify_form_attributes(hash_key, nested_attributes_suffix, nested_attributes_collection)
63
+ if nested_attributes_suffix && !nested_attributes_collection
64
+ [hash_key.name.gsub(NESTED_ATTRIBUTES_SUFFIX, ""), hash_key.members, false]
65
+ elsif nested_attributes_suffix && nested_attributes_collection
66
+ [hash_key.name.gsub(NESTED_ATTRIBUTES_SUFFIX, ""), hash_key.members.first.members, true]
67
+ else
68
+ [hash_key.name, hash_key.members, false]
69
+ end
70
+ end
71
+
72
+ def model_name(path, model_map)
73
+ _, model_name = model_map.find do |pathspec, _model|
74
+ path.size == pathspec.size && path.zip(pathspec).all? do |slug, pattern|
75
+ pattern.is_a?(Regexp) ? pattern.match?(slug) : slug == pattern
76
+ end
77
+ end
78
+
79
+ if model_name
80
+ { model_name: model_name }
81
+ else
82
+ {}
83
+ end
84
+ end
85
+ end