operations 0.0.1 → 0.6.2

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 +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