easyop 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +41 -0
- data/README.md +1089 -0
- data/lib/easyop/configuration.rb +31 -0
- data/lib/easyop/ctx.rb +187 -0
- data/lib/easyop/flow.rb +94 -0
- data/lib/easyop/flow_builder.rb +80 -0
- data/lib/easyop/hooks.rb +108 -0
- data/lib/easyop/operation.rb +115 -0
- data/lib/easyop/plugins/async.rb +98 -0
- data/lib/easyop/plugins/base.rb +27 -0
- data/lib/easyop/plugins/instrumentation.rb +74 -0
- data/lib/easyop/plugins/recording.rb +115 -0
- data/lib/easyop/plugins/transactional.rb +69 -0
- data/lib/easyop/rescuable.rb +68 -0
- data/lib/easyop/schema.rb +168 -0
- data/lib/easyop/skip.rb +22 -0
- data/lib/easyop/version.rb +3 -0
- data/lib/easyop.rb +41 -0
- metadata +94 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Easyop
|
|
4
|
+
module Plugins
|
|
5
|
+
# Enables async execution via ActiveJob.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# class Newsletter::SendBroadcast < ApplicationOperation
|
|
9
|
+
# plugin Easyop::Plugins::Async, queue: "broadcasts"
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# # Enqueue immediately:
|
|
13
|
+
# Newsletter::SendBroadcast.call_async(subject: "Hello", body: "World")
|
|
14
|
+
#
|
|
15
|
+
# # With scheduling options:
|
|
16
|
+
# Newsletter::SendBroadcast.call_async(attrs, wait: 10.minutes)
|
|
17
|
+
# Newsletter::SendBroadcast.call_async(attrs, wait_until: Date.tomorrow.noon)
|
|
18
|
+
# Newsletter::SendBroadcast.call_async(attrs, queue: "low_priority")
|
|
19
|
+
#
|
|
20
|
+
# ActiveRecord objects are serialized by (class, id) and re-fetched in the job.
|
|
21
|
+
# Only serializable values (String, Integer, Float, Boolean, nil, Hash, Array,
|
|
22
|
+
# or ActiveRecord::Base) should be passed.
|
|
23
|
+
module Async
|
|
24
|
+
def self.install(base, queue: "default", **_options)
|
|
25
|
+
base.extend(ClassMethods)
|
|
26
|
+
base.instance_variable_set(:@_async_default_queue, queue)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
module ClassMethods
|
|
30
|
+
# Enqueue the operation as a background job.
|
|
31
|
+
#
|
|
32
|
+
# MyOp.call_async(email: "x@y.com")
|
|
33
|
+
# MyOp.call_async(email: "x@y.com", wait: 5.minutes, queue: "low")
|
|
34
|
+
def call_async(attrs = {}, wait: nil, wait_until: nil, queue: nil, **extra_attrs)
|
|
35
|
+
merged_attrs = attrs.merge(extra_attrs)
|
|
36
|
+
_async_ensure_active_job!
|
|
37
|
+
job = Easyop::Plugins::Async.job_class
|
|
38
|
+
job = job.set(queue: queue || _async_default_queue)
|
|
39
|
+
job = job.set(wait: wait) if wait
|
|
40
|
+
job = job.set(wait_until: wait_until) if wait_until
|
|
41
|
+
job.perform_later(name, _async_serialize(merged_attrs))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def _async_default_queue
|
|
45
|
+
@_async_default_queue ||
|
|
46
|
+
(superclass.respond_to?(:_async_default_queue) ? superclass._async_default_queue : "default")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def _async_ensure_active_job!
|
|
52
|
+
return if defined?(ActiveJob::Base)
|
|
53
|
+
raise LoadError, "ActiveJob is required for async operations."
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Serialize attrs for GlobalID / JSON storage.
|
|
57
|
+
# AR objects → { "__ar_class" => "User", "__ar_id" => 42 }
|
|
58
|
+
def _async_serialize(attrs)
|
|
59
|
+
attrs.each_with_object({}) do |(k, v), h|
|
|
60
|
+
h[k.to_s] = if defined?(ActiveRecord::Base) && v.is_a?(ActiveRecord::Base)
|
|
61
|
+
{ "__ar_class" => v.class.name, "__ar_id" => v.id }
|
|
62
|
+
else
|
|
63
|
+
v
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The ActiveJob that deserializes and runs the operation.
|
|
70
|
+
# Defined lazily so this file can be required before ActiveJob loads.
|
|
71
|
+
def self.job_class
|
|
72
|
+
@job_class ||= begin
|
|
73
|
+
raise LoadError, "ActiveJob is required for Easyop::Plugins::Async" unless defined?(ActiveJob::Base)
|
|
74
|
+
|
|
75
|
+
klass = Class.new(ActiveJob::Base) do
|
|
76
|
+
queue_as :default
|
|
77
|
+
|
|
78
|
+
def perform(operation_class, attrs)
|
|
79
|
+
op_klass = operation_class.constantize
|
|
80
|
+
deserialized = attrs.each_with_object({}) do |(k, v), h|
|
|
81
|
+
h[k.to_sym] = if v.is_a?(Hash) && v["__ar_class"]
|
|
82
|
+
v["__ar_class"].constantize.find(v["__ar_id"])
|
|
83
|
+
else
|
|
84
|
+
v
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
op_klass.call(deserialized)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Give the anonymous class a constant name for serialization
|
|
92
|
+
Easyop::Plugins::Async.const_set(:Job, klass) unless const_defined?(:Job)
|
|
93
|
+
klass
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Easyop
|
|
2
|
+
module Plugins
|
|
3
|
+
# Abstract base for EasyOp plugins.
|
|
4
|
+
#
|
|
5
|
+
# A plugin is any object responding to `.install(base_class, **options)`.
|
|
6
|
+
# Inherit from Base for convenience and documentation:
|
|
7
|
+
#
|
|
8
|
+
# module MyPlugin < Easyop::Plugins::Base
|
|
9
|
+
# def self.install(base, **options)
|
|
10
|
+
# base.prepend(RunWrapper)
|
|
11
|
+
# base.extend(ClassMethods)
|
|
12
|
+
# end
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# Plugins are activated via the `plugin` DSL on operation classes:
|
|
16
|
+
#
|
|
17
|
+
# class ApplicationOperation
|
|
18
|
+
# include Easyop::Operation
|
|
19
|
+
# plugin MyPlugin, option: :value
|
|
20
|
+
# end
|
|
21
|
+
class Base
|
|
22
|
+
def self.install(_base, **_options)
|
|
23
|
+
raise NotImplementedError, "#{name}.install(base, **options) must be implemented"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Easyop
|
|
4
|
+
module Plugins
|
|
5
|
+
# Instruments every operation call via ActiveSupport::Notifications.
|
|
6
|
+
#
|
|
7
|
+
# Install on ApplicationOperation (propagates to all subclasses):
|
|
8
|
+
#
|
|
9
|
+
# class ApplicationOperation
|
|
10
|
+
# include Easyop::Operation
|
|
11
|
+
# plugin Easyop::Plugins::Instrumentation
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# Event: "easyop.operation.call"
|
|
15
|
+
# Payload keys:
|
|
16
|
+
# :operation — String class name, e.g. "Users::Register"
|
|
17
|
+
# :success — Boolean
|
|
18
|
+
# :error — String | nil (ctx.error on failure)
|
|
19
|
+
# :duration — Float ms
|
|
20
|
+
# :ctx — The Easyop::Ctx object (read-only reference)
|
|
21
|
+
#
|
|
22
|
+
# Subscribe manually:
|
|
23
|
+
# ActiveSupport::Notifications.subscribe("easyop.operation.call") do |event|
|
|
24
|
+
# Rails.logger.info "[EasyOp] #{event.payload[:operation]} — #{event.payload[:success] ? 'ok' : 'FAILED'}"
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# Or use the built-in log subscriber:
|
|
28
|
+
# Easyop::Plugins::Instrumentation.attach_log_subscriber
|
|
29
|
+
module Instrumentation
|
|
30
|
+
EVENT = "easyop.operation.call"
|
|
31
|
+
|
|
32
|
+
def self.install(base, **_options)
|
|
33
|
+
base.prepend(RunWrapper)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Attach a default subscriber that logs to Rails.logger (or stdout).
|
|
37
|
+
# Call once in an initializer: Easyop::Plugins::Instrumentation.attach_log_subscriber
|
|
38
|
+
def self.attach_log_subscriber
|
|
39
|
+
ActiveSupport::Notifications.subscribe(EVENT) do |*args|
|
|
40
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
41
|
+
p = event.payload
|
|
42
|
+
next if p[:operation].nil?
|
|
43
|
+
|
|
44
|
+
status = p[:success] ? "ok" : "FAILED"
|
|
45
|
+
ms = event.duration.round(1)
|
|
46
|
+
line = "[EasyOp] #{p[:operation]} #{status} (#{ms}ms)"
|
|
47
|
+
line += " — #{p[:error]}" if p[:error]
|
|
48
|
+
|
|
49
|
+
logger = defined?(Rails) && Rails.respond_to?(:logger) ? Rails.logger : Logger.new($stdout)
|
|
50
|
+
if p[:success]
|
|
51
|
+
logger.info line
|
|
52
|
+
else
|
|
53
|
+
logger.warn line
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
module RunWrapper
|
|
59
|
+
def _easyop_run(ctx, raise_on_failure:)
|
|
60
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
61
|
+
payload = { operation: self.class.name, success: nil, error: nil, duration: nil, ctx: ctx }
|
|
62
|
+
|
|
63
|
+
ActiveSupport::Notifications.instrument(EVENT, payload) do
|
|
64
|
+
super.tap do
|
|
65
|
+
payload[:success] = ctx.success?
|
|
66
|
+
payload[:error] = ctx.error if ctx.failure?
|
|
67
|
+
payload[:duration] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(2)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Easyop
|
|
4
|
+
module Plugins
|
|
5
|
+
# Records operation executions to a database model.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# class ApplicationOperation
|
|
9
|
+
# include Easyop::Operation
|
|
10
|
+
# plugin Easyop::Plugins::Recording, model: OperationLog
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# Required model columns (create with the generator migration):
|
|
14
|
+
# operation_name :string, null: false
|
|
15
|
+
# success :boolean, null: false
|
|
16
|
+
# error_message :string
|
|
17
|
+
# params_data :text # stored as JSON
|
|
18
|
+
# duration_ms :float
|
|
19
|
+
# performed_at :datetime, null: false
|
|
20
|
+
#
|
|
21
|
+
# Opt out per operation class:
|
|
22
|
+
# class MyOp < ApplicationOperation
|
|
23
|
+
# recording false
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# Options:
|
|
27
|
+
# model: (required) ActiveRecord class
|
|
28
|
+
# record_params: true pass false to skip params serialization
|
|
29
|
+
module Recording
|
|
30
|
+
# Sensitive keys scrubbed from params_data before persisting.
|
|
31
|
+
SCRUBBED_KEYS = %i[password password_confirmation token secret api_key].freeze
|
|
32
|
+
|
|
33
|
+
def self.install(base, model:, record_params: true, **_options)
|
|
34
|
+
base.extend(ClassMethods)
|
|
35
|
+
base.prepend(RunWrapper)
|
|
36
|
+
base.instance_variable_set(:@_recording_model, model)
|
|
37
|
+
base.instance_variable_set(:@_recording_record_params, record_params)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
module ClassMethods
|
|
41
|
+
# Disable recording for this class: `recording false`
|
|
42
|
+
def recording(enabled)
|
|
43
|
+
@_recording_enabled = enabled
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def _recording_enabled?
|
|
47
|
+
return @_recording_enabled if instance_variable_defined?(:@_recording_enabled)
|
|
48
|
+
superclass.respond_to?(:_recording_enabled?) ? superclass._recording_enabled? : true
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def _recording_model
|
|
52
|
+
@_recording_model ||
|
|
53
|
+
(superclass.respond_to?(:_recording_model) ? superclass._recording_model : nil)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def _recording_record_params?
|
|
57
|
+
if instance_variable_defined?(:@_recording_record_params)
|
|
58
|
+
@_recording_record_params
|
|
59
|
+
elsif superclass.respond_to?(:_recording_record_params?)
|
|
60
|
+
superclass._recording_record_params?
|
|
61
|
+
else
|
|
62
|
+
true
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
module RunWrapper
|
|
68
|
+
def _easyop_run(ctx, raise_on_failure:)
|
|
69
|
+
return super unless self.class._recording_enabled?
|
|
70
|
+
return super unless (model = self.class._recording_model)
|
|
71
|
+
return super unless self.class.name # skip anonymous classes
|
|
72
|
+
|
|
73
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
74
|
+
super.tap do
|
|
75
|
+
ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(2)
|
|
76
|
+
_recording_persist!(ctx, model, ms)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def _recording_persist!(ctx, model, duration_ms)
|
|
83
|
+
attrs = {
|
|
84
|
+
operation_name: self.class.name,
|
|
85
|
+
success: ctx.success?,
|
|
86
|
+
error_message: ctx.error,
|
|
87
|
+
performed_at: Time.current,
|
|
88
|
+
duration_ms: duration_ms
|
|
89
|
+
}
|
|
90
|
+
attrs[:params_data] = _recording_safe_params(ctx) if self.class._recording_record_params?
|
|
91
|
+
|
|
92
|
+
# Only write columns the model actually has
|
|
93
|
+
safe = attrs.select { |k, _| model.column_names.include?(k.to_s) }
|
|
94
|
+
model.create!(safe)
|
|
95
|
+
rescue => e
|
|
96
|
+
_recording_warn(e)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def _recording_safe_params(ctx)
|
|
100
|
+
ctx.to_h
|
|
101
|
+
.except(*SCRUBBED_KEYS)
|
|
102
|
+
.transform_values { |v| v.is_a?(ActiveRecord::Base) ? { id: v.id, class: v.class.name } : v }
|
|
103
|
+
.to_json
|
|
104
|
+
rescue
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def _recording_warn(err)
|
|
109
|
+
return unless defined?(Rails) && Rails.respond_to?(:logger)
|
|
110
|
+
Rails.logger.warn "[EasyOp::Recording] Failed to record #{self.class.name}: #{err.message}"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
module Easyop
|
|
2
|
+
module Plugins
|
|
3
|
+
# Wraps the entire operation (including before/after hooks) in a database
|
|
4
|
+
# transaction. On ctx.fail! or any unhandled exception the transaction rolls back.
|
|
5
|
+
#
|
|
6
|
+
# Supports ActiveRecord and Sequel out of the box.
|
|
7
|
+
#
|
|
8
|
+
# Usage — include style (classic):
|
|
9
|
+
# class TransferFunds
|
|
10
|
+
# include Easyop::Operation
|
|
11
|
+
# include Easyop::Plugins::Transactional
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# Usage — plugin DSL (recommended, works with ApplicationOperation):
|
|
15
|
+
# class ApplicationOperation
|
|
16
|
+
# include Easyop::Operation
|
|
17
|
+
# plugin Easyop::Plugins::Transactional
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# Or opt in per operation:
|
|
21
|
+
# class TransferFunds < ApplicationOperation
|
|
22
|
+
# plugin Easyop::Plugins::Transactional
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# To opt out when the parent has it:
|
|
26
|
+
# class ReadOnlyOp < ApplicationOperation
|
|
27
|
+
# transactional false
|
|
28
|
+
# end
|
|
29
|
+
module Transactional
|
|
30
|
+
# Support the `plugin` DSL: `plugin Easyop::Plugins::Transactional`
|
|
31
|
+
def self.install(base, **_options)
|
|
32
|
+
base.include(self)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.included(base)
|
|
36
|
+
base.extend(ClassMethods)
|
|
37
|
+
base.around(:_transactional_wrap)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
module ClassMethods
|
|
41
|
+
# Opt out of transaction wrapping: `transactional false`
|
|
42
|
+
def transactional(enabled)
|
|
43
|
+
@_transactional_enabled = enabled
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def _transactional_enabled?
|
|
47
|
+
return @_transactional_enabled if instance_variable_defined?(:@_transactional_enabled)
|
|
48
|
+
superclass.respond_to?(:_transactional_enabled?) ? superclass._transactional_enabled? : true
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def _transactional_wrap
|
|
55
|
+
return yield unless self.class._transactional_enabled?
|
|
56
|
+
|
|
57
|
+
db = if defined?(ActiveRecord::Base)
|
|
58
|
+
ActiveRecord::Base
|
|
59
|
+
elsif defined?(Sequel::Model)
|
|
60
|
+
Sequel::Model.db
|
|
61
|
+
else
|
|
62
|
+
raise "Easyop::Plugins::Transactional requires ActiveRecord or Sequel"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
db.transaction { yield }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
module Easyop
|
|
2
|
+
# Lightweight rescue_from DSL, modelled after ActiveSupport::Rescuable.
|
|
3
|
+
# Works without ActiveSupport and can be used standalone.
|
|
4
|
+
#
|
|
5
|
+
# Usage:
|
|
6
|
+
# rescue_from SomeError, with: :handle_it
|
|
7
|
+
# rescue_from OtherError, AnotherError do |e|
|
|
8
|
+
# ctx.fail!(error: e.message)
|
|
9
|
+
# end
|
|
10
|
+
#
|
|
11
|
+
# Handlers are checked in definition order (first match wins).
|
|
12
|
+
# Subclasses inherit their parent's handlers.
|
|
13
|
+
module Rescuable
|
|
14
|
+
def self.included(base)
|
|
15
|
+
base.extend(ClassMethods)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
module ClassMethods
|
|
19
|
+
# Register a handler for one or more exception classes.
|
|
20
|
+
# Pass `with: :method_name` or a block.
|
|
21
|
+
def rescue_from(*klasses, with: nil, &block)
|
|
22
|
+
raise ArgumentError, "Provide `with:` or a block" unless with || block_given?
|
|
23
|
+
|
|
24
|
+
handler = with || block
|
|
25
|
+
klasses.each do |klass|
|
|
26
|
+
_rescue_handlers << [klass, handler]
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Own handlers defined directly on this class (not inherited).
|
|
31
|
+
def _rescue_handlers
|
|
32
|
+
@_rescue_handlers ||= []
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Full ordered list: own handlers first, then ancestors' (child wins).
|
|
36
|
+
def _all_rescue_handlers
|
|
37
|
+
parent = superclass
|
|
38
|
+
parent_handlers = parent.respond_to?(:_all_rescue_handlers) ? parent._all_rescue_handlers : []
|
|
39
|
+
_rescue_handlers + parent_handlers
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Attempt to handle `exception` with a registered handler.
|
|
44
|
+
# Returns true if handled, false if no matching handler found.
|
|
45
|
+
def rescue_with_handler(exception)
|
|
46
|
+
handler = handler_for_rescue(exception)
|
|
47
|
+
return false unless handler
|
|
48
|
+
|
|
49
|
+
case handler
|
|
50
|
+
when Symbol then send(handler, exception)
|
|
51
|
+
when Proc then instance_exec(exception, &handler)
|
|
52
|
+
end
|
|
53
|
+
true
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def handler_for_rescue(exception)
|
|
59
|
+
self.class._all_rescue_handlers.each do |klass, handler|
|
|
60
|
+
klass_const = klass.is_a?(String) ? Object.const_get(klass) : klass
|
|
61
|
+
return handler if exception.is_a?(klass_const)
|
|
62
|
+
rescue NameError
|
|
63
|
+
next # constant not loaded yet — skip
|
|
64
|
+
end
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
module Easyop
|
|
2
|
+
# Optional typed schema DSL for Operation inputs and outputs.
|
|
3
|
+
#
|
|
4
|
+
# When included (automatically when you use `params` or `result` in an
|
|
5
|
+
# Operation), it adds before/after hooks that validate ctx against the
|
|
6
|
+
# declared schema using the configured type adapter.
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
# params do
|
|
10
|
+
# required :email, String
|
|
11
|
+
# required :amount, Integer
|
|
12
|
+
# optional :note, String
|
|
13
|
+
# optional :retry, :boolean, default: false
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# result do
|
|
17
|
+
# required :record, ActiveRecord::Base
|
|
18
|
+
# optional :token, String
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# Type symbols (:boolean, :string, :integer, :float) are mapped to native
|
|
22
|
+
# Ruby classes. Pass an actual class (String, User, etc.) for strict is_a?
|
|
23
|
+
# checking. Type validation only happens if Easyop.config.type_adapter != :none.
|
|
24
|
+
module Schema
|
|
25
|
+
def self.included(base)
|
|
26
|
+
base.extend(ClassMethods)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
module ClassMethods
|
|
30
|
+
# Declare input schema. Validated before `call` runs.
|
|
31
|
+
def params(&block)
|
|
32
|
+
schema = FieldSchema.new
|
|
33
|
+
schema.instance_eval(&block)
|
|
34
|
+
@_param_schema = schema
|
|
35
|
+
|
|
36
|
+
# Register as a before hook (prepend so it runs first)
|
|
37
|
+
_before_hooks.unshift(:_validate_params!)
|
|
38
|
+
end
|
|
39
|
+
alias inputs params
|
|
40
|
+
|
|
41
|
+
# Declare output schema. Validated after `call` returns (not in ensure).
|
|
42
|
+
def result(&block)
|
|
43
|
+
schema = FieldSchema.new
|
|
44
|
+
schema.instance_eval(&block)
|
|
45
|
+
@_result_schema = schema
|
|
46
|
+
|
|
47
|
+
_after_hooks.push(:_validate_result!)
|
|
48
|
+
end
|
|
49
|
+
alias outputs result
|
|
50
|
+
|
|
51
|
+
def _param_schema
|
|
52
|
+
@_param_schema
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def _result_schema
|
|
56
|
+
@_result_schema
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# ── Instance validation methods ───────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
def _validate_params!
|
|
63
|
+
schema = self.class._param_schema
|
|
64
|
+
return unless schema
|
|
65
|
+
schema.validate!(ctx, phase: :params)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def _validate_result!
|
|
69
|
+
schema = self.class._result_schema
|
|
70
|
+
return unless schema && ctx.success?
|
|
71
|
+
schema.validate!(ctx, phase: :result)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Describes a set of typed fields (used for both params and result schemas).
|
|
76
|
+
class FieldSchema
|
|
77
|
+
TYPE_MAP = {
|
|
78
|
+
string: String,
|
|
79
|
+
integer: Integer,
|
|
80
|
+
float: Float,
|
|
81
|
+
boolean: [TrueClass, FalseClass],
|
|
82
|
+
symbol: Symbol,
|
|
83
|
+
any: BasicObject,
|
|
84
|
+
}.freeze
|
|
85
|
+
|
|
86
|
+
Field = Struct.new(:name, :type, :required, :default, :has_default, keyword_init: true)
|
|
87
|
+
|
|
88
|
+
def initialize
|
|
89
|
+
@fields = []
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def required(name, type = nil, **opts)
|
|
93
|
+
add_field(name, type, required: true, **opts)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def optional(name, type = nil, **opts)
|
|
97
|
+
add_field(name, type, required: false, **opts)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def fields
|
|
101
|
+
@fields.dup
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Validate ctx against this schema.
|
|
105
|
+
# Raises Ctx::Failure on hard errors; emits warnings otherwise
|
|
106
|
+
# depending on Easyop.config.strict_types.
|
|
107
|
+
def validate!(ctx, phase: :params)
|
|
108
|
+
@fields.each do |field|
|
|
109
|
+
val = ctx[field.name]
|
|
110
|
+
|
|
111
|
+
# Apply default if not set
|
|
112
|
+
if val.nil? && field.has_default
|
|
113
|
+
ctx[field.name] = field.default.respond_to?(:call) ? field.default.call : field.default
|
|
114
|
+
val = ctx[field.name]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Required check
|
|
118
|
+
if field.required && val.nil?
|
|
119
|
+
ctx.fail!(
|
|
120
|
+
error: "Missing required #{phase} field: #{field.name}",
|
|
121
|
+
errors: ctx.errors.merge(field.name => "is required")
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Type check (skip if nil and optional)
|
|
126
|
+
next if val.nil?
|
|
127
|
+
next if field.type.nil?
|
|
128
|
+
|
|
129
|
+
type_check!(ctx, field, val, phase)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def add_field(name, type, required:, default: :__no_default__, **_rest)
|
|
136
|
+
resolved = resolve_type(type)
|
|
137
|
+
@fields << Field.new(
|
|
138
|
+
name: name.to_sym,
|
|
139
|
+
type: resolved,
|
|
140
|
+
required: required,
|
|
141
|
+
default: default == :__no_default__ ? nil : default,
|
|
142
|
+
has_default: default != :__no_default__
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def resolve_type(type)
|
|
147
|
+
return nil if type.nil?
|
|
148
|
+
return type if type.is_a?(Class) || type.is_a?(Array)
|
|
149
|
+
|
|
150
|
+
TYPE_MAP[type.to_sym] || (raise ArgumentError, "Unknown type shorthand: #{type.inspect}")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def type_check!(ctx, field, val, phase)
|
|
154
|
+
types = Array(field.type)
|
|
155
|
+
valid = types.any? { |t| t == BasicObject || val.is_a?(t) }
|
|
156
|
+
return if valid
|
|
157
|
+
|
|
158
|
+
msg = "Type mismatch in #{phase} field :#{field.name} — " \
|
|
159
|
+
"expected #{types.map(&:name).join(" | ")}, got #{val.class}"
|
|
160
|
+
|
|
161
|
+
if Easyop.config.strict_types
|
|
162
|
+
ctx.fail!(error: msg, errors: ctx.errors.merge(field.name => msg))
|
|
163
|
+
else
|
|
164
|
+
warn "[Easyop] #{msg}"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
data/lib/easyop/skip.rb
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module Easyop
|
|
2
|
+
module Skip
|
|
3
|
+
def self.included(base)
|
|
4
|
+
base.extend(ClassMethods)
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
module ClassMethods
|
|
8
|
+
def skip_if(&block)
|
|
9
|
+
@_skip_predicate = block
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def _skip_predicate
|
|
13
|
+
@_skip_predicate
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Returns true if this step should be skipped for the given ctx.
|
|
17
|
+
def skip?(ctx)
|
|
18
|
+
@_skip_predicate ? @_skip_predicate.call(ctx) : false
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
data/lib/easyop.rb
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
require_relative "easyop/version"
|
|
2
|
+
require_relative "easyop/configuration"
|
|
3
|
+
require_relative "easyop/ctx"
|
|
4
|
+
require_relative "easyop/hooks"
|
|
5
|
+
require_relative "easyop/rescuable"
|
|
6
|
+
require_relative "easyop/skip"
|
|
7
|
+
require_relative "easyop/schema"
|
|
8
|
+
require_relative "easyop/operation"
|
|
9
|
+
require_relative "easyop/flow_builder"
|
|
10
|
+
require_relative "easyop/flow"
|
|
11
|
+
|
|
12
|
+
# Optional plugins — not auto-required
|
|
13
|
+
# require_relative "easyop/plugins/transactional"
|
|
14
|
+
|
|
15
|
+
# Optional plugins — require explicitly or via Bundler:
|
|
16
|
+
# require "easyop/plugins/base"
|
|
17
|
+
# require "easyop/plugins/instrumentation"
|
|
18
|
+
# require "easyop/plugins/recording"
|
|
19
|
+
# require "easyop/plugins/async"
|
|
20
|
+
|
|
21
|
+
module Easyop
|
|
22
|
+
# Convenience: inherit from this instead of including Easyop::Operation
|
|
23
|
+
# when you want a common base class for all your operations.
|
|
24
|
+
#
|
|
25
|
+
# class ApplicationOperation
|
|
26
|
+
# include Easyop::Operation
|
|
27
|
+
#
|
|
28
|
+
# rescue_from StandardError, with: :handle_unexpected
|
|
29
|
+
#
|
|
30
|
+
# private
|
|
31
|
+
#
|
|
32
|
+
# def handle_unexpected(e)
|
|
33
|
+
# Sentry.capture_exception(e)
|
|
34
|
+
# ctx.fail!(error: "An unexpected error occurred")
|
|
35
|
+
# end
|
|
36
|
+
# end
|
|
37
|
+
#
|
|
38
|
+
# class MyOp < ApplicationOperation
|
|
39
|
+
# ...
|
|
40
|
+
# end
|
|
41
|
+
end
|