active_operation 0.1.1 → 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.
- checksums.yaml +4 -4
- data/README.md +14 -0
- data/lib/active_operation.rb +7 -3
- data/lib/active_operation/base.rb +30 -10
- data/lib/active_operation/matcher.rb +3 -0
- data/lib/active_operation/matcher/execution.rb +166 -0
- data/lib/active_operation/matcher/utilize_operation.rb +90 -0
- data/lib/active_operation/pipeline.rb +94 -0
- data/lib/active_operation/version.rb +1 -1
- data/lib/generators/active_operation/install/USAGE +2 -0
- data/lib/generators/active_operation/install/install_generator.rb +14 -0
- data/lib/generators/active_operation/operation/USAGE +8 -0
- data/lib/generators/active_operation/operation/operation_generator.rb +14 -0
- data/support/templates/application_operation.rb +2 -0
- data/support/templates/operation.rb +17 -0
- metadata +13 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: eead73f1ad502fdd329a6cd5ae6acc20306472df
|
4
|
+
data.tar.gz: c4adb13fbb34e04cf4895175babc9d458ff8c6bc
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 32c999e6bd87a939c985ea696250cf00de302fe0b7749edb07339381e1a543c4baaa54bc10b3101b0215f34f9955e9bf13d555ac581a693d276f029015ad0d82
|
7
|
+
data.tar.gz: ac35417f23a2a4321e11869b3c95f8a0120da18cf4b71cbae7ad22494d3eb04b8638f2d119cdaa960cb25c84de908bc63a9a3fb4b9eba736e0b385ecd8afb6fe
|
data/README.md
CHANGED
@@ -26,6 +26,20 @@ Or install it yourself as:
|
|
26
26
|
$ gem install active_operation
|
27
27
|
```
|
28
28
|
|
29
|
+
### Rails
|
30
|
+
|
31
|
+
We recommend running the install generator to initialize a base operation:
|
32
|
+
|
33
|
+
```
|
34
|
+
rails g active_operation:install
|
35
|
+
```
|
36
|
+
|
37
|
+
You can also generate new operations using:
|
38
|
+
|
39
|
+
```
|
40
|
+
rails g active_operation:operation Signup
|
41
|
+
```
|
42
|
+
|
29
43
|
## Usage
|
30
44
|
|
31
45
|
To define an operation, create a new class and inherit from `ActiveOperation::Base`.
|
data/lib/active_operation.rb
CHANGED
@@ -1,4 +1,5 @@
|
|
1
1
|
require "active_support/callbacks"
|
2
|
+
require "delegate"
|
2
3
|
require "smart_properties"
|
3
4
|
|
4
5
|
module ActiveOperation
|
@@ -6,6 +7,9 @@ module ActiveOperation
|
|
6
7
|
class AlreadyCompletedError < Error; end
|
7
8
|
end
|
8
9
|
|
9
|
-
|
10
|
-
|
11
|
-
|
10
|
+
require_relative "active_operation/version"
|
11
|
+
require_relative "active_operation/input"
|
12
|
+
require_relative "active_operation/base"
|
13
|
+
require_relative "active_operation/pipeline"
|
14
|
+
|
15
|
+
require_relative "active_operation/matcher" if defined?(::RSpec)
|
@@ -1,3 +1,5 @@
|
|
1
|
+
require 'active_support/callbacks'
|
2
|
+
|
1
3
|
class ActiveOperation::Base
|
2
4
|
include SmartProperties
|
3
5
|
include ActiveSupport::Callbacks
|
@@ -14,14 +16,24 @@ class ActiveOperation::Base
|
|
14
16
|
define_callbacks :halted, scope: [:name]
|
15
17
|
|
16
18
|
class << self
|
17
|
-
def
|
19
|
+
def perform(*args)
|
18
20
|
new(*args).call
|
19
21
|
end
|
20
22
|
|
23
|
+
def call(*args)
|
24
|
+
perform(*args)
|
25
|
+
end
|
26
|
+
|
21
27
|
def inputs
|
22
28
|
[]
|
23
29
|
end
|
24
30
|
|
31
|
+
def to_proc
|
32
|
+
->(*args) {
|
33
|
+
perform(*args)
|
34
|
+
}
|
35
|
+
end
|
36
|
+
|
25
37
|
protected
|
26
38
|
|
27
39
|
def input(name, type: :positional, **configuration)
|
@@ -70,6 +82,8 @@ class ActiveOperation::Base
|
|
70
82
|
end
|
71
83
|
|
72
84
|
def inherited(subclass)
|
85
|
+
super
|
86
|
+
|
73
87
|
subclass.define_singleton_method(:inputs) do
|
74
88
|
superclass.inputs + Array(@inputs)
|
75
89
|
end
|
@@ -82,19 +96,21 @@ class ActiveOperation::Base
|
|
82
96
|
end
|
83
97
|
end
|
84
98
|
|
85
|
-
def initialize(*
|
86
|
-
|
99
|
+
def initialize(*args)
|
100
|
+
arity = self.class.inputs.count(&:positional?)
|
101
|
+
arguments = args.shift(arity)
|
102
|
+
attributes = args.last.kind_of?(Hash) ? args.pop : {}
|
103
|
+
|
104
|
+
raise ArgumentError, "wrong number of arguments #{arguments.length + args.length} for #{arity}" unless args.empty?
|
87
105
|
|
88
|
-
|
106
|
+
self.class.inputs.select(&:positional?).each_with_index do |input, index|
|
107
|
+
attributes[input.name] = arguments[index]
|
108
|
+
end
|
89
109
|
|
90
|
-
super(
|
91
|
-
keyword_arguments.merge(
|
92
|
-
expected_positional_arguments.zip(positional_arguments).map { |input, value| [input.name, value] }.to_h
|
93
|
-
)
|
94
|
-
)
|
110
|
+
super(attributes)
|
95
111
|
end
|
96
112
|
|
97
|
-
def
|
113
|
+
def perform
|
98
114
|
run_callbacks :execute do
|
99
115
|
catch(:abort) do
|
100
116
|
next if completed?
|
@@ -114,6 +130,10 @@ class ActiveOperation::Base
|
|
114
130
|
raise
|
115
131
|
end
|
116
132
|
|
133
|
+
def call
|
134
|
+
perform
|
135
|
+
end
|
136
|
+
|
117
137
|
def output
|
118
138
|
call unless self.completed?
|
119
139
|
@output
|
@@ -0,0 +1,166 @@
|
|
1
|
+
module ActiveOperation
|
2
|
+
module Matcher
|
3
|
+
module Execution
|
4
|
+
class Base
|
5
|
+
def and_return(result)
|
6
|
+
@result = result
|
7
|
+
self
|
8
|
+
end
|
9
|
+
|
10
|
+
def when_initialized_with(*input)
|
11
|
+
@input = input
|
12
|
+
self
|
13
|
+
end
|
14
|
+
|
15
|
+
def failure_message
|
16
|
+
raise NotImplementedError, "Expected #{self.class} to implement ##{__callee__}"
|
17
|
+
end
|
18
|
+
|
19
|
+
def failure_message_when_negated
|
20
|
+
raise NotImplementedError, "Expected #{self.class} to implement ##{__callee__}"
|
21
|
+
end
|
22
|
+
alias negative_failure_message failure_message_when_negated
|
23
|
+
|
24
|
+
protected
|
25
|
+
|
26
|
+
attr_reader :operation
|
27
|
+
attr_reader :message
|
28
|
+
attr_reader :result
|
29
|
+
attr_reader :input
|
30
|
+
|
31
|
+
def operation=(operation)
|
32
|
+
operation = operation.new(*input) if operation.kind_of?(Class)
|
33
|
+
operation.perform
|
34
|
+
@operation = operation
|
35
|
+
end
|
36
|
+
|
37
|
+
def succeeded?
|
38
|
+
operation.succeeded?
|
39
|
+
end
|
40
|
+
|
41
|
+
def halted?
|
42
|
+
operation.halted?
|
43
|
+
end
|
44
|
+
|
45
|
+
def result_as_expected?
|
46
|
+
return true unless result
|
47
|
+
operation.output == result
|
48
|
+
end
|
49
|
+
|
50
|
+
def message_as_expected?
|
51
|
+
return true unless message
|
52
|
+
operation.message == message
|
53
|
+
end
|
54
|
+
|
55
|
+
def input_as_text
|
56
|
+
humanize(*input)
|
57
|
+
end
|
58
|
+
|
59
|
+
def result_as_text
|
60
|
+
humanize(result)
|
61
|
+
end
|
62
|
+
|
63
|
+
private
|
64
|
+
|
65
|
+
def humanize(*args)
|
66
|
+
args = args.map(&:inspect)
|
67
|
+
last_element = args.pop
|
68
|
+
args.length > 0 ? [args.join(", "), last_element].join(" and ") : last_element
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
class SucceedToPerform < Base
|
73
|
+
def matches?(operation)
|
74
|
+
self.operation = operation
|
75
|
+
succeeded? && result_as_expected?
|
76
|
+
end
|
77
|
+
|
78
|
+
def description
|
79
|
+
description = "succeed to perform"
|
80
|
+
description += " when initialized with custom input (#{input_as_text})" if input
|
81
|
+
description += " and return the expected result (#{result_as_text})" if result
|
82
|
+
description
|
83
|
+
end
|
84
|
+
|
85
|
+
def failure_message
|
86
|
+
"the operation failed to perform for the following reason(s):\n#{failure_reasons}"
|
87
|
+
end
|
88
|
+
|
89
|
+
def failure_message_when_negated
|
90
|
+
"the operation succeeded unexpectedly"
|
91
|
+
end
|
92
|
+
|
93
|
+
private
|
94
|
+
|
95
|
+
def failure_reasons
|
96
|
+
reasons = []
|
97
|
+
reasons << "it did not succeed at all" unless succeeded?
|
98
|
+
unless result_as_expected?
|
99
|
+
reasons << [
|
100
|
+
"it did not return the expected result",
|
101
|
+
"Expected: #{result.inspect}",
|
102
|
+
"Got: #{operation.result.inspect}"
|
103
|
+
].join("\n\t ")
|
104
|
+
end
|
105
|
+
reasons.map { |r| "\t- #{r}" }.join("\n")
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
class HaltWhilePerforming < Base
|
110
|
+
def matches?(operation)
|
111
|
+
self.operation = operation
|
112
|
+
halted? && result_as_expected? && message_as_expected?
|
113
|
+
end
|
114
|
+
|
115
|
+
def because(message)
|
116
|
+
@message = message
|
117
|
+
self
|
118
|
+
end
|
119
|
+
|
120
|
+
def description
|
121
|
+
description = "halt while performing"
|
122
|
+
description += " because #{message}" if message
|
123
|
+
description += " when initialized with custom input (#{input_as_text})" if input
|
124
|
+
description += " and return the expected result (#{result_as_text})" if result
|
125
|
+
description
|
126
|
+
end
|
127
|
+
|
128
|
+
def failure_message
|
129
|
+
"the operation did not halt while performing for the following reason(s):\n#{failure_reasons}"
|
130
|
+
end
|
131
|
+
|
132
|
+
def failure_message_when_negated
|
133
|
+
"the operation was halted unexpectedly"
|
134
|
+
end
|
135
|
+
|
136
|
+
protected
|
137
|
+
|
138
|
+
def failure_reasons
|
139
|
+
reasons = []
|
140
|
+
reasons << "it did not halt at all" unless halted?
|
141
|
+
reasons << "its message was not as expected" unless message_as_expected?
|
142
|
+
unless result_as_expected?
|
143
|
+
reasons << [
|
144
|
+
"it did not return the expected result",
|
145
|
+
"Expected: #{result.inspect}",
|
146
|
+
"Got: #{operation.result.inspect}"
|
147
|
+
].join("\n\t ")
|
148
|
+
end
|
149
|
+
reasons.map { |r| "\t- #{r}" }.join("\n")
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
def succeed_to_perform
|
154
|
+
SucceedToPerform.new
|
155
|
+
end
|
156
|
+
|
157
|
+
def halt_while_performing
|
158
|
+
HaltWhilePerforming.new
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
RSpec.configure do |config|
|
165
|
+
config.include ActiveOperation::Matcher::Execution
|
166
|
+
end
|
@@ -0,0 +1,90 @@
|
|
1
|
+
module ActiveOperation
|
2
|
+
module Matcher
|
3
|
+
module UtilizeOperation
|
4
|
+
|
5
|
+
class DummyOperation
|
6
|
+
def initialize(*args)
|
7
|
+
end
|
8
|
+
|
9
|
+
def halted?
|
10
|
+
false
|
11
|
+
end
|
12
|
+
|
13
|
+
def succeeded?
|
14
|
+
true
|
15
|
+
end
|
16
|
+
|
17
|
+
def output
|
18
|
+
Object.new
|
19
|
+
end
|
20
|
+
|
21
|
+
def perform
|
22
|
+
output
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
class Matcher
|
27
|
+
include RSpec::Mocks::ExampleMethods
|
28
|
+
|
29
|
+
attr_reader :composite_operations
|
30
|
+
attr_reader :tested_operation
|
31
|
+
attr_reader :tested_instance
|
32
|
+
|
33
|
+
def initialize(*composite_operations)
|
34
|
+
@composite_operations = composite_operations.flatten
|
35
|
+
end
|
36
|
+
|
37
|
+
def matches?(class_or_instance)
|
38
|
+
if class_or_instance.is_a?(Class)
|
39
|
+
@tested_operation = class_or_instance
|
40
|
+
@tested_instance = class_or_instance.new
|
41
|
+
else
|
42
|
+
@tested_operation = class_or_instance.class
|
43
|
+
@tested_instance = class_or_instance
|
44
|
+
end
|
45
|
+
|
46
|
+
allow(Base).to receive(:new).and_return(DummyOperation.new)
|
47
|
+
composite_operations.each do |composite_operation|
|
48
|
+
dummy_operation = DummyOperation.new
|
49
|
+
expect(dummy_operation).to receive(:perform).and_call_original
|
50
|
+
expect(composite_operation).to receive(:new).and_return(dummy_operation)
|
51
|
+
end
|
52
|
+
allow(tested_instance).to receive(:prepare).and_return(true)
|
53
|
+
allow(tested_instance).to receive(:finalize).and_return(true)
|
54
|
+
tested_instance.perform
|
55
|
+
|
56
|
+
tested_operation.operations == composite_operations
|
57
|
+
end
|
58
|
+
|
59
|
+
def description
|
60
|
+
"utilize the following operations: #{composite_operations.map(&:to_s).join(', ')}"
|
61
|
+
end
|
62
|
+
|
63
|
+
def failure_message
|
64
|
+
expected_but_not_used = composite_operations - tested_operation.operations
|
65
|
+
used_but_not_exptected = tested_operation.operations - composite_operations
|
66
|
+
message = ["Unexpected operation utilization:"]
|
67
|
+
message << "Expected: #{expected_but_not_used.join(', ')}" unless expected_but_not_used.empty?
|
68
|
+
message << "Not expected: #{used_but_not_exptected.join(', ')}" unless used_but_not_exptected.empty?
|
69
|
+
message.join("\n\t")
|
70
|
+
end
|
71
|
+
|
72
|
+
def failure_message_when_negated
|
73
|
+
"Unexpected operation utilization"
|
74
|
+
end
|
75
|
+
alias negative_failure_message failure_message_when_negated
|
76
|
+
|
77
|
+
end
|
78
|
+
|
79
|
+
def utilize_operation(*args)
|
80
|
+
Matcher.new(*args)
|
81
|
+
end
|
82
|
+
alias utilize_operations utilize_operation
|
83
|
+
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
RSpec.configure do |config|
|
89
|
+
config.include ActiveOperation::Matcher::UtilizeOperation
|
90
|
+
end
|
@@ -0,0 +1,94 @@
|
|
1
|
+
module ActiveOperation
|
2
|
+
class Pipeline < Base
|
3
|
+
class OperationFactory < SimpleDelegator
|
4
|
+
def initialize(operation_class, options = {})
|
5
|
+
super(operation_class)
|
6
|
+
@_options = options
|
7
|
+
end
|
8
|
+
|
9
|
+
def new(context, *input)
|
10
|
+
keyword_input_names = inputs.select(&:keyword?).map(&:name)
|
11
|
+
positional_args = input.shift(inputs.count(&:positional?))
|
12
|
+
|
13
|
+
attributes_from_input = input.last.kind_of?(Hash) ? input.pop.slice(*keyword_input_names) : {}
|
14
|
+
attributes_from_input.delete_if { |_, value| value.nil? }
|
15
|
+
|
16
|
+
attributes_from_pipeline = Array(@_options).each_with_object({}) do |(key, value), result|
|
17
|
+
result[key] = value.kind_of?(Proc) ? context.instance_exec(&value) : value
|
18
|
+
end
|
19
|
+
|
20
|
+
__getobj__.new *positional_args, attributes_from_input.merge(attributes_from_pipeline)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
class << self
|
25
|
+
def operations
|
26
|
+
[]
|
27
|
+
end
|
28
|
+
|
29
|
+
def use(operation, options = {})
|
30
|
+
if operations.empty?
|
31
|
+
inputs = operation.inputs
|
32
|
+
|
33
|
+
inputs.each do |input|
|
34
|
+
input input.name, type: input.type
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
(@operations ||= []) << OperationFactory.new(operation, options)
|
39
|
+
end
|
40
|
+
|
41
|
+
def compose(*operations, &block)
|
42
|
+
raise ArgumentError, "Expects either an array of operations or a block with configuration instructions" unless !!block ^ !operations.empty?
|
43
|
+
|
44
|
+
if block
|
45
|
+
Class.new(self, &block)
|
46
|
+
else
|
47
|
+
Class.new(self) do
|
48
|
+
operations.each do |operation|
|
49
|
+
use operation
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
protected
|
56
|
+
|
57
|
+
def inherited(subclass)
|
58
|
+
super
|
59
|
+
|
60
|
+
subclass.define_singleton_method(:operations) do
|
61
|
+
superclass.operations + Array(@operations)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
protected
|
67
|
+
|
68
|
+
def execute
|
69
|
+
values = ->(input) { self[input.name] }
|
70
|
+
|
71
|
+
positional_arguments = self.class.inputs.select(&:positional?).map(&values)
|
72
|
+
keyword_arguments = self.class.inputs.select(&:keyword?).each_with_object({}) do |input, kwargs|
|
73
|
+
kwargs[input.name] = values.call(input)
|
74
|
+
end
|
75
|
+
arguments = positional_arguments.push(keyword_arguments)
|
76
|
+
|
77
|
+
self.class.operations.inject(arguments) do |data, operation|
|
78
|
+
operation = if data.respond_to?(:to_ary)
|
79
|
+
operation.new(self, *data)
|
80
|
+
else
|
81
|
+
operation.new(self, data)
|
82
|
+
end
|
83
|
+
|
84
|
+
operation.perform
|
85
|
+
|
86
|
+
output = operation.output
|
87
|
+
|
88
|
+
halt output if operation.halted?
|
89
|
+
|
90
|
+
output
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
require 'rails/generators/base'
|
2
|
+
require 'rails/generators/active_record'
|
3
|
+
|
4
|
+
module ActiveOperation
|
5
|
+
module Generators
|
6
|
+
class InstallGenerator < Rails::Generators::Base
|
7
|
+
source_root File.expand_path('../../../../../support/templates', __FILE__)
|
8
|
+
|
9
|
+
def create_application_operation
|
10
|
+
template 'application_operation.rb', 'app/operations/application_operation.rb'
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
require 'rails/generators/base'
|
2
|
+
require 'rails/generators/active_record'
|
3
|
+
|
4
|
+
module ActiveOperation
|
5
|
+
module Generators
|
6
|
+
class OperationGenerator < Rails::Generators::NamedBase
|
7
|
+
source_root File.expand_path('../../../../../support/templates', __FILE__)
|
8
|
+
|
9
|
+
def create_operation
|
10
|
+
template 'operation.rb', File.join('app/operations', class_path, "#{file_name}.rb")
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
class <%= name %> < ApplicationOperation
|
2
|
+
# input :email, accepts: String, type: :keyword, required: true
|
3
|
+
# input :password, accepts: String, type: :keyword, required: true
|
4
|
+
#
|
5
|
+
# before do
|
6
|
+
# user = User.find_by(email: email)
|
7
|
+
# halt user unless user.nil?
|
8
|
+
# end
|
9
|
+
#
|
10
|
+
# def execute
|
11
|
+
# User.create!(email: email, password: password)
|
12
|
+
# end
|
13
|
+
#
|
14
|
+
# succeeded do
|
15
|
+
# Email::SendWelcomeMail.perform(output)
|
16
|
+
# end
|
17
|
+
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: active_operation
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Konstantin Tennhard
|
@@ -9,7 +9,7 @@ authors:
|
|
9
9
|
autorequire:
|
10
10
|
bindir: exe
|
11
11
|
cert_chain: []
|
12
|
-
date:
|
12
|
+
date: 2017-06-05 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: activesupport
|
@@ -116,7 +116,17 @@ files:
|
|
116
116
|
- lib/active_operation.rb
|
117
117
|
- lib/active_operation/base.rb
|
118
118
|
- lib/active_operation/input.rb
|
119
|
+
- lib/active_operation/matcher.rb
|
120
|
+
- lib/active_operation/matcher/execution.rb
|
121
|
+
- lib/active_operation/matcher/utilize_operation.rb
|
122
|
+
- lib/active_operation/pipeline.rb
|
119
123
|
- lib/active_operation/version.rb
|
124
|
+
- lib/generators/active_operation/install/USAGE
|
125
|
+
- lib/generators/active_operation/install/install_generator.rb
|
126
|
+
- lib/generators/active_operation/operation/USAGE
|
127
|
+
- lib/generators/active_operation/operation/operation_generator.rb
|
128
|
+
- support/templates/application_operation.rb
|
129
|
+
- support/templates/operation.rb
|
120
130
|
homepage: https://gitub.com/t6d/active_operation
|
121
131
|
licenses:
|
122
132
|
- MIT
|
@@ -137,7 +147,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
137
147
|
version: '0'
|
138
148
|
requirements: []
|
139
149
|
rubyforge_project:
|
140
|
-
rubygems_version: 2.
|
150
|
+
rubygems_version: 2.6.11
|
141
151
|
signing_key:
|
142
152
|
specification_version: 4
|
143
153
|
summary: ActiveOperation is a micro-framework for modelling business processes.
|