thunderoperation 0.2.0

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 (46) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +3 -0
  3. data/config/default.yml +11 -0
  4. data/lib/rubocop/cop/thunderoperation/call_handlers.rb +56 -0
  5. data/lib/rubocop/cop/thunderoperation/def_outside_steps.rb +39 -0
  6. data/lib/rubocop/cop/thunderoperation/flow_required.rb +24 -0
  7. data/lib/rubocop/cop/thunderoperation/missing_steps.rb +42 -0
  8. data/lib/rubocop/cop/thunderoperation/operation_helpers.rb +124 -0
  9. data/lib/rubocop/cop/thunderoperation/operation_section_order.rb +51 -0
  10. data/lib/rubocop/cop/thunderoperation/steps_cannot_call_steps.rb +36 -0
  11. data/lib/rubocop/cop/thunderoperation/steps_order.rb +52 -0
  12. data/lib/rubocop/cop/thunderoperation/unknown_step.rb +61 -0
  13. data/lib/rubocop/cop/thunderoperation/unused_step.rb +29 -0
  14. data/lib/thunderoperation/adapter.rb +23 -0
  15. data/lib/thunderoperation/adapters/framer.rb +64 -0
  16. data/lib/thunderoperation/adapters/interruptable.rb +19 -0
  17. data/lib/thunderoperation/adapters/rich_parameters.rb +42 -0
  18. data/lib/thunderoperation/adapters/validations.rb +49 -0
  19. data/lib/thunderoperation/base.rb +144 -0
  20. data/lib/thunderoperation/clauses/base.rb +11 -0
  21. data/lib/thunderoperation/clauses/transaction.rb +11 -0
  22. data/lib/thunderoperation/clauses.rb +11 -0
  23. data/lib/thunderoperation/concerns/has_adapter.rb +47 -0
  24. data/lib/thunderoperation/config.rb +30 -0
  25. data/lib/thunderoperation/exceptions/trail.rb +27 -0
  26. data/lib/thunderoperation/exceptions.rb +32 -0
  27. data/lib/thunderoperation/flow/builder.rb +31 -0
  28. data/lib/thunderoperation/flow.rb +57 -0
  29. data/lib/thunderoperation/handlers.rb +43 -0
  30. data/lib/thunderoperation/lookup.rb +13 -0
  31. data/lib/thunderoperation/parameters/array_parameter.rb +48 -0
  32. data/lib/thunderoperation/parameters/base.rb +92 -0
  33. data/lib/thunderoperation/parameters/hash_parameter.rb +154 -0
  34. data/lib/thunderoperation/parameters.rb +11 -0
  35. data/lib/thunderoperation/response.rb +62 -0
  36. data/lib/thunderoperation/rubocop.rb +31 -0
  37. data/lib/thunderoperation/steps/base.rb +44 -0
  38. data/lib/thunderoperation/steps/clause.rb +32 -0
  39. data/lib/thunderoperation/steps/method.rb +34 -0
  40. data/lib/thunderoperation/steps/render.rb +22 -0
  41. data/lib/thunderoperation/steps/success.rb +29 -0
  42. data/lib/thunderoperation/steps.rb +33 -0
  43. data/lib/thunderoperation/steps_definition.rb +75 -0
  44. data/lib/thunderoperation/version.rb +5 -0
  45. data/lib/thunderoperation.rb +36 -0
  46. metadata +108 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 45776abcd16980536bbd63b5bb0b5f67c17103b990408ea68673c1ccb3b5fec2
4
+ data.tar.gz: a4965001230ededf0ee43cd9ddcead9be3fc6bad008fd7fac0cc2328be98d5c8
5
+ SHA512:
6
+ metadata.gz: 1c544048645185dbcaf0fc93fe2364e8361a23e7a0cd9850933dcdc5504dff09d5fa026c51f8a4e18f8788bfd00fed687d5a1c90ffe52c4986b9b5f73d554dd6
7
+ data.tar.gz: 9c38b5e95bed8425e73ebfb4dc4b335808849d693ba605f5498e83795dbc13bda60ce68d11c1d1a362eddb156b1f8403317ba143deb2a890a149b14de15313db
data/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # thunderoperation
2
+
3
+ Operations engine: `param`, `flow`, `steps`, `returns` and adapters. Ported from the old thunderbolt (`lib/thunderbolt/operations` at d75314d).
@@ -0,0 +1,11 @@
1
+ # WARN: AI MADE
2
+ # Defaults for every project that loads the thunderoperation cops (require: thunderoperation/rubocop), merged over
3
+ # RuboCop's own defaults; a project's .rubocop.yml still overrides them.
4
+
5
+ # `flow do ... end` and `steps do ... end` hold the whole flow and every step of an operation, so their length is the
6
+ # operation's, not a block's.
7
+ Metrics/BlockLength:
8
+ AllowedMethods:
9
+ - refine # RuboCop's default
10
+ - flow
11
+ - steps
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module ThunderOperation
6
+ # Inside `Operation.call(...) do ... end` only `success { |result| }` and `failure { |result| }` belong,
7
+ # each with a block, each at most once. Anything else runs against the handlers collector, not the operation.
8
+ class CallHandlers < Base
9
+ include OperationHelpers
10
+
11
+ HANDLERS = %i[success failure].freeze
12
+ UNEXPECTED_MSG = 'Only `success` and `failure` handlers belong in a `call` block.'
13
+ BLOCK_MSG = '`%<handler>s` handler needs a block.'
14
+ DUPLICATE_MSG = '`%<handler>s` handler is declared more than once.'
15
+
16
+ def on_block(node)
17
+ return unless call_with_handlers?(node) && inside_operation?(node)
18
+
19
+ seen = []
20
+ block_statements(node).each do |statement|
21
+ handler = handler_name(statement)
22
+ next add_offense(statement, message: UNEXPECTED_MSG) unless handler
23
+ next add_offense(statement, message: format(BLOCK_MSG, handler:)) unless statement.block_type?
24
+
25
+ add_offense(statement, message: format(DUPLICATE_MSG, handler:)) if seen.include?(handler)
26
+ seen << handler
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def call_with_handlers?(node)
33
+ send = node.send_node
34
+ send.method_name == :call && send.receiver && node.arguments.empty?
35
+ end
36
+
37
+ def inside_operation?(node)
38
+ node.each_ancestor(:class).any? { |klass| operation_class?(klass) }
39
+ end
40
+
41
+ def block_statements(node)
42
+ return [] unless node.body
43
+
44
+ node.body.begin_type? ? node.body.children : [node.body]
45
+ end
46
+
47
+ def handler_name(statement)
48
+ send = statement.block_type? ? statement.send_node : statement
49
+ return unless send.send_type? && send.receiver.nil? && HANDLERS.include?(send.method_name)
50
+
51
+ send.method_name
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module ThunderOperation
6
+ # Instance methods of an operation are steps, and steps live in `steps do ... end`. Base classes (no sections) are exempt.
7
+ #
8
+ # # bad
9
+ # def create_product; end
10
+ #
11
+ # # good
12
+ # steps do
13
+ # def create_product; end
14
+ # end
15
+ class DefOutsideSteps < Base
16
+ include OperationHelpers
17
+
18
+ MSG = 'Define `%<method>s` inside `steps do ... end`.'
19
+
20
+ def on_class(node)
21
+ return unless operation_class?(node) && !base_class?(node)
22
+
23
+ body_statements(node).each do |statement|
24
+ next unless statement.def_type?
25
+
26
+ add_offense(statement, message: format(MSG, method: statement.method_name))
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ # A class with no param, flow or steps is a base class (Subscriber, ApplicationOperation): helpers live there.
33
+ def base_class?(node)
34
+ sections(node).empty?
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module ThunderOperation
6
+ # An operation that declares params or steps must declare a `flow`. A class with none of the three is
7
+ # taken as a base class and left alone.
8
+ class FlowRequired < Base
9
+ include OperationHelpers
10
+
11
+ MSG = 'Operation has no `flow do ... end`.'
12
+
13
+ def on_class(node)
14
+ return unless operation_class?(node)
15
+
16
+ names = sections(node).map(&:first)
17
+ return if names.include?(:flow) || !names.intersect?(%i[param steps])
18
+
19
+ add_offense(node.identifier)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ # WARN: AI MADE
4
+ module RuboCop
5
+ module Cop
6
+ module ThunderOperation
7
+ # In the editor only: marks `flow do` when the flow calls steps that `steps do ... end` does not define, so its
8
+ # code action creates all of them at once (each step's line, through UnknownStep, creates only its own). Off
9
+ # outside the editor's language server, so `rubocop -A` does not create the steps twice.
10
+ #
11
+ # flow do # Steps missing from steps do: create_order, notify
12
+ # create_order
13
+ # notify
14
+ # end
15
+ class MissingSteps < Base
16
+ include OperationHelpers
17
+ extend AutoCorrector
18
+
19
+ MSG = 'Steps missing from `steps do ... end`: %<steps>s.'
20
+
21
+ def safe_autocorrect? = false
22
+
23
+ def on_class(node)
24
+ return unless RuboCop::LSP.enabled? && operation_class?(node)
25
+
26
+ steps_block = section(node, :steps)
27
+ flow_block = section(node, :flow)
28
+ return unless steps_block && flow_block&.block_type?
29
+
30
+ defined = step_names(steps_block)
31
+ flow_order = flow_step_names(flow_block)
32
+ missing = flow_order.reject { |name| defined.include?(name) }
33
+ return if missing.empty?
34
+
35
+ add_offense(flow_block.send_node, message: format(MSG, steps: missing.join(', '))) do |corrector|
36
+ add_steps(corrector, steps_block, flow_order, missing, missing)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module ThunderOperation
6
+ # Shared reading of an operation class body: which classes are operations, their sections
7
+ # (`param`, `flow`, `steps`, `returns`), the steps a flow calls and the methods `steps do` defines.
8
+ module OperationHelpers
9
+ # InternalOperation and ExternalOperation: the bases a chunk keeps in app/common.
10
+ OPERATION_PARENTS = %w[
11
+ Operation Operations::Base Operations::Internal Operations::External Operations::Subscriber ThunderOperation::Base
12
+ InternalOperation ExternalOperation
13
+ ].freeze
14
+ SECTIONS = %i[param flow steps returns].freeze
15
+ # Flow calls that are steps of the framework, not methods of the operation.
16
+ FLOW_KEYWORDS = %i[success render].freeze
17
+
18
+ def operation_class?(class_node)
19
+ parent = class_node.parent_class
20
+ return false unless parent&.const_type?
21
+
22
+ name = parent.const_name
23
+ OPERATION_PARENTS.any? { |suffix| name == suffix || name.end_with?("::#{suffix}") }
24
+ end
25
+
26
+ # Top-level statements of the class body.
27
+ def body_statements(class_node)
28
+ body = class_node.body
29
+ return [] unless body
30
+
31
+ body.begin_type? ? body.children : [body]
32
+ end
33
+
34
+ # [[section_name, node], ...] in source order; node is the send or the block wrapping it.
35
+ def sections(class_node)
36
+ body_statements(class_node).filter_map do |statement|
37
+ send = statement.block_type? ? statement.send_node : statement
38
+ next unless send.send_type? && send.receiver.nil? && SECTIONS.include?(send.method_name)
39
+
40
+ [send.method_name, statement]
41
+ end
42
+ end
43
+
44
+ def section(class_node, name)
45
+ sections(class_node).find { |section_name, _| section_name == name }&.last
46
+ end
47
+
48
+ # Names of the methods a `flow do ... end` block calls, including inside clauses.
49
+ def flow_step_names(flow_block)
50
+ return [] unless flow_block&.block_type? && flow_block.body
51
+
52
+ statements = flow_block.body.begin_type? ? flow_block.body.children : [flow_block.body]
53
+ statements.flat_map { |statement| flow_statement_names(statement) }.uniq
54
+ end
55
+
56
+ def flow_statement_names(statement)
57
+ if statement.block_type?
58
+ flow_step_names(statement)
59
+ elsif statement.send_type? && statement.receiver.nil? && !FLOW_KEYWORDS.include?(statement.method_name)
60
+ [statement.method_name]
61
+ else
62
+ []
63
+ end
64
+ end
65
+
66
+ # The `def` nodes directly inside `steps do ... end`.
67
+ def step_defs(steps_block)
68
+ return [] unless steps_block&.block_type? && steps_block.body
69
+
70
+ statements = steps_block.body.begin_type? ? steps_block.body.children : [steps_block.body]
71
+ statements.select(&:def_type?)
72
+ end
73
+
74
+ def step_names(steps_block)
75
+ step_defs(steps_block).map(&:method_name)
76
+ end
77
+
78
+ # WARN: AI MADE
79
+ # Writes an empty `def name` into `steps do`, in flow order (used by UnknownStep and MissingSteps).
80
+ # After the def of the closest earlier step that steps defines, else right after `steps do`. Corrections of one
81
+ # run (rubocop -A) that land on the same point keep the order they were added in, which is the flow order.
82
+ def add_steps(corrector, steps_block, flow_order, missing, names)
83
+ defs = step_defs(steps_block).to_h { |definition| [definition.method_name, definition] }
84
+ indent = ' ' * (steps_block.source_range.column + 2)
85
+
86
+ names.group_by { |name| anchor_for(name, flow_order, defs) }.each do |anchor, group|
87
+ methods = group.map { |name| "#{indent}def #{name}\n#{indent}end" }
88
+
89
+ text =
90
+ if anchor
91
+ methods.map { |method| "\n#{method}\n" }.join
92
+ elsif steps_block.body
93
+ methods.map { |method| "#{method}\n\n" }.join
94
+ else
95
+ first = missing.find { |other| anchor_for(other, flow_order, defs).nil? } == group.first
96
+ methods.each_with_index.map { |method, i| "#{"\n" unless first && i.zero?}#{method}\n" }.join
97
+ end
98
+
99
+ corrector.insert_after(line_start(anchor ? anchor.last_line + 1 : steps_block.loc.begin.line + 1), text)
100
+ end
101
+ end
102
+
103
+ # An empty range at column 0 of the line. Insertions go there because RuboCop's language server (1.90) turns
104
+ # a column into a position measuring the line of the offense, not the line of the edit: past the length of
105
+ # the offense line, the position comes back short (inside `do`). Column 0 is the same on any line.
106
+ def line_start(line)
107
+ buffer = processed_source.buffer
108
+ position = buffer.line_range(line).begin_pos
109
+ Parser::Source::Range.new(buffer, position, position)
110
+ end
111
+
112
+ # The def of the closest step the flow calls before this one and steps defines.
113
+ def anchor_for(name, flow_order, defs)
114
+ flow_order.take(flow_order.index(name)).reverse.filter_map { |earlier| defs[earlier] }.first
115
+ end
116
+
117
+ # Nodes that call `name` with no receiver inside `node`.
118
+ def bare_calls(node, name)
119
+ node.each_descendant(:send).select { |send| send.receiver.nil? && send.method_name == name }
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module ThunderOperation
6
+ # Sections of an operation come in a fixed order: `param`s, `flow`, `steps`, `returns`; each once (params many).
7
+ #
8
+ # # bad
9
+ # flow do ... end
10
+ # param :name
11
+ #
12
+ # # good
13
+ # param :name
14
+ # flow do ... end
15
+ # steps do ... end
16
+ # returns Models::Product
17
+ class OperationSectionOrder < Base
18
+ include OperationHelpers
19
+
20
+ ORDER_MSG = '`%<section>s` must come before `%<previous>s` (order: param, flow, steps, returns).'
21
+ DUPLICATE_MSG = '`%<section>s` is declared more than once.'
22
+
23
+ def on_class(node)
24
+ return unless operation_class?(node)
25
+
26
+ seen = []
27
+ sections(node).each do |name, statement|
28
+ check_order(name, statement, seen)
29
+ check_duplicate(name, statement, seen)
30
+ seen << name
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def check_order(name, statement, seen)
37
+ later = seen.find { |previous| SECTIONS.index(previous) > SECTIONS.index(name) }
38
+ return unless later
39
+
40
+ add_offense(statement, message: format(ORDER_MSG, section: name, previous: later))
41
+ end
42
+
43
+ def check_duplicate(name, statement, seen)
44
+ return if name == :param || !seen.include?(name)
45
+
46
+ add_offense(statement, message: format(DUPLICATE_MSG, section: name))
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module ThunderOperation
6
+ # A step does not call another step; only the flow sequences them.
7
+ #
8
+ # steps do
9
+ # def create_product
10
+ # charge_order # bad
11
+ # end
12
+ # end
13
+ class StepsCannotCallSteps < Base
14
+ include OperationHelpers
15
+
16
+ MSG = 'Step `%<caller>s` must not call step `%<called>s`; only the flow calls steps.'
17
+
18
+ def on_class(node)
19
+ return unless operation_class?(node)
20
+
21
+ steps_block = section(node, :steps)
22
+ return unless steps_block
23
+
24
+ names = step_names(steps_block)
25
+ step_defs(steps_block).each do |definition|
26
+ names.each do |name|
27
+ bare_calls(definition, name).each do |call|
28
+ add_offense(call, message: format(MSG, caller: definition.method_name, called: name))
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module ThunderOperation
6
+ # Methods in `steps do ... end` are defined in the order the flow first calls them.
7
+ #
8
+ # flow do
9
+ # create_product
10
+ # charge_order
11
+ # end
12
+ #
13
+ # steps do
14
+ # def charge_order; end # bad: the flow calls create_product first
15
+ # def create_product; end
16
+ # end
17
+ class StepsOrder < Base
18
+ include OperationHelpers
19
+
20
+ MSG = 'Step `%<step>s` is defined before `%<earlier>s`, but the flow calls it after.'
21
+
22
+ def on_class(node)
23
+ return unless operation_class?(node)
24
+
25
+ steps_block = section(node, :steps)
26
+ flow_block = section(node, :flow)
27
+ return unless steps_block && flow_block
28
+
29
+ check_order(flow_step_names(flow_block), step_defs(steps_block))
30
+ end
31
+
32
+ private
33
+
34
+ # Walks the defs the flow calls; a def whose flow position is lower than one already seen is out of place.
35
+ def check_order(flow_order, defs)
36
+ called = defs.select { |definition| flow_order.include?(definition.method_name) }
37
+ highest = nil
38
+
39
+ called.each do |definition|
40
+ position = flow_order.index(definition.method_name)
41
+
42
+ if highest && position < flow_order.index(highest.method_name)
43
+ add_offense(definition, message: format(MSG, step: highest.method_name, earlier: definition.method_name))
44
+ else
45
+ highest = definition
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module ThunderOperation
6
+ # Every method the flow calls is defined inside `steps do ... end`. Checked only when that block exists.
7
+ #
8
+ # flow do
9
+ # change_order # bad: steps defines charge_order
10
+ # end
11
+ #
12
+ # The correction adds an empty `def` for each missing step, in the order the flow calls them: after the def of
13
+ # the step the flow calls before it, or first in `steps do` when none is defined. It is offered as a code action
14
+ # (quick fix) in the editor and applied by `rubocop -A`; being unsafe, format on save and `rubocop -a` skip it,
15
+ # so a step being typed in the flow does not grow an empty method by itself.
16
+ class UnknownStep < Base
17
+ include OperationHelpers
18
+ extend AutoCorrector
19
+
20
+ MSG = 'Step `%<step>s` is not defined in `steps do ... end`.'
21
+
22
+ def safe_autocorrect? = false
23
+
24
+ def on_class(node)
25
+ return unless operation_class?(node)
26
+
27
+ steps_block = section(node, :steps)
28
+ flow_block = section(node, :flow)
29
+ return unless steps_block && flow_block
30
+
31
+ defined = step_names(steps_block)
32
+ flow_order = flow_step_names(flow_block)
33
+ missing = flow_order.reject { |name| defined.include?(name) }
34
+ offered = []
35
+
36
+ flow_calls(flow_block).each do |call|
37
+ name = call.method_name
38
+ next if defined.include?(name)
39
+
40
+ add_offense(call, message: format(MSG, step: name)) do |corrector|
41
+ # Each line fixes its own step; a step called twice is added by its first call only.
42
+ next if offered.include?(name)
43
+
44
+ offered << name
45
+ add_steps(corrector, steps_block, flow_order, missing, [name])
46
+ end
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def flow_calls(flow_block)
53
+ flow_block.each_descendant(:send).select do |send|
54
+ send.receiver.nil? && !send.block_literal? && !FLOW_KEYWORDS.include?(send.method_name) &&
55
+ flow_step_names(flow_block).include?(send.method_name)
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module ThunderOperation
6
+ # Every method defined inside `steps do ... end` is called by the flow.
7
+ class UnusedStep < Base
8
+ include OperationHelpers
9
+
10
+ MSG = 'Step `%<step>s` is not called by the flow.'
11
+
12
+ def on_class(node)
13
+ return unless operation_class?(node)
14
+
15
+ steps_block = section(node, :steps)
16
+ flow_block = section(node, :flow)
17
+ return unless steps_block && flow_block
18
+
19
+ called = flow_step_names(flow_block)
20
+ step_defs(steps_block).each do |definition|
21
+ next if called.include?(definition.method_name)
22
+
23
+ add_offense(definition, message: format(MSG, step: definition.method_name))
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThunderOperation
4
+ class Adapter
5
+ attr_reader :operation_class
6
+
7
+ def initialize(operation_class)
8
+ @operation_class = operation_class
9
+ end
10
+
11
+ def execute(_params, _operation_instance, _options, &block)
12
+ block.call
13
+ end
14
+
15
+ class << self
16
+ attr_reader :adapted_block
17
+
18
+ def adapted(&block)
19
+ @adapted_block = block
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThunderOperation
4
+ module Adapters
5
+ # WARN: AI MADE
6
+ # Renders the payload through `returns Hash do ... end` (or `Array do items Hash do ... end end`): each field is
7
+ # read from the object returned (a record, a Hash) and coerced to its type; a nested Hash or an association is
8
+ # read the same way, one item at a time for a list. The declaration becomes a ThunderFrame::Frame, built once.
9
+ class Framer < Adapter
10
+ # WARN: AI MADE
11
+ # A map (`keys Date`, `values Hash do ... end`): each value rendered through the values' frame, keys kept.
12
+ MapFrame = Data.define(:values_frame) do
13
+ def render_hash(entity)
14
+ return entity.map { |item| render_hash(item) } if entity.is_a?(Array)
15
+
16
+ entity&.transform_values { |value| values_frame ? values_frame.render_hash(value) : value }
17
+ end
18
+ end
19
+
20
+ def execute(_params, _operation_instance, _options, &block)
21
+ response = block.call
22
+ return response unless response.success? && operation_class.returns_frame
23
+
24
+ response.with(payload: operation_class.returns_frame.render_hash(response.payload))
25
+ end
26
+
27
+ # The frame of a Hash parameter; nil for a return that is not a Hash or a list of Hashes.
28
+ def self.frame_for(owner, parameter)
29
+ parameter = parameter.items if parameter.is_a?(Parameters::ArrayParameter)
30
+ return unless parameter.is_a?(Parameters::HashParameter)
31
+ return MapFrame.new(frame_for(owner, parameter.values)) if parameter.map?
32
+
33
+ ThunderFrame::Frame.new(:Result, owner:).tap { |frame| fill(frame, parameter) }
34
+ end
35
+
36
+ def self.fill(frame, parameter)
37
+ parameter.configuration.each do |name, child|
38
+ options = child.options.slice(:optional, :from, :as, :eager_loads)
39
+ nested = child.is_a?(Parameters::ArrayParameter) ? child.items : child
40
+
41
+ # A Hash with no fields declared, or a map, is a value: rendered as it comes.
42
+ if nested.is_a?(Parameters::HashParameter) && nested.configuration.any?
43
+ frame.association(name, **options) { Framer.fill(self, nested) }
44
+ elsif child.is_a?(Parameters::ArrayParameter)
45
+ raise Exceptions::DeclarationError, "#{frame.label}: #{name} is a list of values; a rendered return " \
46
+ 'takes lists of Hashes only'
47
+ else
48
+ frame.field(name, child.type || String, **options)
49
+ end
50
+ end
51
+ end
52
+
53
+ adapted do
54
+ def self.renders_returns? = true
55
+
56
+ def self.returns_frame
57
+ return @returns_frame if defined?(@returns_frame)
58
+
59
+ @returns_frame = Framer.frame_for(self, returns)
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThunderOperation
4
+ module Adapters
5
+ class Interruptable < Adapter
6
+ def execute(_params, operation_instance, _options, &block)
7
+ block.call
8
+ rescue Exceptions::Interruption => e
9
+ operation_instance._failure(e)
10
+ end
11
+
12
+ adapted do
13
+ def interrupt
14
+ raise Exceptions::Interruption
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end