mercury_amqp 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/.gitignore +4 -0
- data/.rspec +1 -0
- data/Gemfile +5 -0
- data/README.md +85 -0
- data/Rakefile +9 -0
- data/lib/mercury/cps/methods.rb +23 -0
- data/lib/mercury/cps/seq.rb +23 -0
- data/lib/mercury/cps/seq_with_let.rb +67 -0
- data/lib/mercury/cps.rb +97 -0
- data/lib/mercury/fake/domain.rb +9 -0
- data/lib/mercury/fake/metadata.rb +24 -0
- data/lib/mercury/fake/queue.rb +80 -0
- data/lib/mercury/fake/queued_message.rb +17 -0
- data/lib/mercury/fake/subscriber.rb +13 -0
- data/lib/mercury/fake.rb +104 -0
- data/lib/mercury/mercury.rb +186 -0
- data/lib/mercury/monadic.rb +41 -0
- data/lib/mercury/received_message.rb +30 -0
- data/lib/mercury/sync.rb +20 -0
- data/lib/mercury/test_utils.rb +59 -0
- data/lib/mercury/utils.rb +9 -0
- data/lib/mercury/version.rb +3 -0
- data/lib/mercury/wire_serializer.rb +54 -0
- data/lib/mercury.rb +1 -0
- data/mercury_amqp.gemspec +33 -0
- data/spec/lib/mercury/cps_spec.rb +225 -0
- data/spec/lib/mercury/mercury_spec.rb +87 -0
- data/spec/lib/mercury/monadic_spec.rb +313 -0
- data/spec/lib/mercury/sync_spec.rb +33 -0
- data/spec/lib/mercury/utils_spec.rb +17 -0
- data/spec/lib/mercury/wire_serializer_spec.rb +29 -0
- data/spec/spec_helper.rb +39 -0
- metadata +238 -0
@@ -0,0 +1,186 @@
|
|
1
|
+
require 'amqp'
|
2
|
+
require 'securerandom'
|
3
|
+
require 'mercury/wire_serializer'
|
4
|
+
require 'mercury/received_message'
|
5
|
+
require 'logger'
|
6
|
+
|
7
|
+
class Mercury
|
8
|
+
attr_reader :amqp, :channel, :logger
|
9
|
+
|
10
|
+
def self.open(logger: Logger.new(STDOUT), **kws, &k)
|
11
|
+
@logger = logger
|
12
|
+
new(**kws, &k)
|
13
|
+
nil
|
14
|
+
end
|
15
|
+
|
16
|
+
def close(&k)
|
17
|
+
@amqp.close do
|
18
|
+
k.call
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def initialize(host: 'localhost',
|
23
|
+
port: 5672,
|
24
|
+
vhost: '/',
|
25
|
+
username: 'guest',
|
26
|
+
password: 'guest',
|
27
|
+
&k)
|
28
|
+
AMQP.connect(host: host, port: port, vhost: vhost, username: username, password: password) do |amqp|
|
29
|
+
@amqp = amqp
|
30
|
+
@channel = AMQP::Channel.new(amqp, prefetch: 1) do
|
31
|
+
@channel.confirm_select
|
32
|
+
install_default_error_handler
|
33
|
+
k.call(self)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
private_class_method :new
|
38
|
+
|
39
|
+
def publish(source_name, msg, tag: '', &k)
|
40
|
+
# The amqp gem caches exchange objects, so it's fine to
|
41
|
+
# redeclare the exchange every time we publish.
|
42
|
+
# TODO: wait for publish confirmations (@channel.on_ack)
|
43
|
+
with_source(source_name) do |exchange|
|
44
|
+
exchange.publish(write(msg), **Mercury.publish_opts(tag)) do
|
45
|
+
k.call
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def self.publish_opts(tag)
|
51
|
+
{ routing_key: tag, persistent: true }
|
52
|
+
end
|
53
|
+
|
54
|
+
def start_listener(source_name, handler, tag_filter: '#', &k)
|
55
|
+
with_source(source_name) do |exchange|
|
56
|
+
with_listener_queue(exchange, tag_filter) do |queue|
|
57
|
+
queue.subscribe(ack: false) do |metadata, payload|
|
58
|
+
handler.call(make_received_message(payload, metadata, false))
|
59
|
+
end
|
60
|
+
k.call
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def start_worker(worker_group, source_name, handler, tag_filter: '#', &k)
|
66
|
+
with_source(source_name) do |exchange|
|
67
|
+
with_work_queue(worker_group, exchange, tag_filter) do |queue|
|
68
|
+
queue.subscribe(ack: true) do |metadata, payload|
|
69
|
+
handler.call(make_received_message(payload, metadata, true))
|
70
|
+
end
|
71
|
+
k.call
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
def delete_source(source_name, &k)
|
77
|
+
with_source(source_name) do |exchange|
|
78
|
+
exchange.delete do
|
79
|
+
k.call
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
def delete_work_queue(worker_group, &k)
|
85
|
+
@channel.queue(worker_group, work_queue_opts) do |queue|
|
86
|
+
queue.delete do
|
87
|
+
k.call
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
def source_exists?(source_name, &k)
|
93
|
+
existence_check(k) do |ch, &ret|
|
94
|
+
with_source_no_cache(ch, source_name, passive: true) do
|
95
|
+
ret.call(true)
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
def queue_exists?(queue_name, &k)
|
101
|
+
existence_check(k) do |ch, &ret|
|
102
|
+
ch.queue(queue_name, passive: true) do
|
103
|
+
ret.call(true)
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
private
|
109
|
+
|
110
|
+
def make_received_message(payload, metadata, is_ackable)
|
111
|
+
ReceivedMessage.new(read(payload), metadata, is_ackable: is_ackable)
|
112
|
+
end
|
113
|
+
|
114
|
+
def existence_check(k, &check)
|
115
|
+
AMQP::Channel.new(@amqp) do |ch|
|
116
|
+
ch.on_error do |_, info|
|
117
|
+
if info.reply_code == 404
|
118
|
+
# our request failed because it does not exist
|
119
|
+
k.call(false)
|
120
|
+
else
|
121
|
+
# failed for unknown reason
|
122
|
+
default_error_handler(ch, info)
|
123
|
+
end
|
124
|
+
end
|
125
|
+
check.call(ch) do |result|
|
126
|
+
ch.close do
|
127
|
+
k.call(result)
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
def install_default_error_handler
|
134
|
+
@channel.on_error(&method(:default_error_handler))
|
135
|
+
end
|
136
|
+
|
137
|
+
def default_error_handler(_ch, info)
|
138
|
+
@amqp.close do
|
139
|
+
raise "An error occurred: #{info.reply_code} - #{info.reply_text}"
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
def write(msg)
|
144
|
+
WireSerializer.new.write(msg)
|
145
|
+
end
|
146
|
+
|
147
|
+
def read(bytes)
|
148
|
+
WireSerializer.new.read(bytes)
|
149
|
+
end
|
150
|
+
|
151
|
+
def with_source(source_name, &k)
|
152
|
+
with_source_no_cache(@channel, source_name, Mercury.source_opts) do |exchange|
|
153
|
+
k.call(exchange)
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
def with_source_no_cache(channel, source_name, opts, &k)
|
158
|
+
channel.topic(source_name, opts) do |*args|
|
159
|
+
k.call(*args)
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
def with_work_queue(worker_group, source_exchange, tag_filter, &k)
|
164
|
+
bind_queue(source_exchange, worker_group, tag_filter, work_queue_opts, &k)
|
165
|
+
end
|
166
|
+
|
167
|
+
def self.source_opts
|
168
|
+
{ durable: true, auto_delete: false }
|
169
|
+
end
|
170
|
+
|
171
|
+
def work_queue_opts
|
172
|
+
{ durable: true, auto_delete: false }
|
173
|
+
end
|
174
|
+
|
175
|
+
def with_listener_queue(source_exchange, tag_filter, &k)
|
176
|
+
bind_queue(source_exchange, '', tag_filter, exclusive: true, auto_delete: true, durable: false, &k)
|
177
|
+
end
|
178
|
+
|
179
|
+
def bind_queue(exchange, queue_name, tag_filter, opts, &k)
|
180
|
+
queue = @channel.queue(queue_name, opts)
|
181
|
+
queue.bind(exchange, routing_key: tag_filter) do
|
182
|
+
k.call(queue)
|
183
|
+
end
|
184
|
+
end
|
185
|
+
|
186
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
require 'mercury'
|
2
|
+
require 'mercury/cps'
|
3
|
+
|
4
|
+
class Mercury
|
5
|
+
class Monadic
|
6
|
+
|
7
|
+
def self.open(**kws)
|
8
|
+
Cps.new do |&k|
|
9
|
+
Mercury.open(**kws) do |m|
|
10
|
+
k.call(new(m))
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.wrap(method_name)
|
16
|
+
define_method(method_name) do |*args, **kws, &block|
|
17
|
+
Cps.new do |&k|
|
18
|
+
if @mercury.method(method_name).parameters.map(&:first).include?(:key)
|
19
|
+
@mercury.send(method_name, *[*args, *block], **kws, &k)
|
20
|
+
else
|
21
|
+
@mercury.send(method_name, *[*args, *block], &k)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
wrap(:publish)
|
28
|
+
wrap(:start_listener)
|
29
|
+
wrap(:start_worker)
|
30
|
+
wrap(:delete_source)
|
31
|
+
wrap(:delete_work_queue)
|
32
|
+
wrap(:source_exists?)
|
33
|
+
wrap(:queue_exists?)
|
34
|
+
wrap(:close)
|
35
|
+
|
36
|
+
private
|
37
|
+
def initialize(mercury)
|
38
|
+
@mercury = mercury
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
class Mercury
|
2
|
+
class ReceivedMessage
|
3
|
+
attr_reader :content, :metadata
|
4
|
+
|
5
|
+
def initialize(content, metadata, is_ackable: false)
|
6
|
+
@content = content
|
7
|
+
@metadata = metadata
|
8
|
+
@is_ackable = is_ackable
|
9
|
+
end
|
10
|
+
|
11
|
+
def tag
|
12
|
+
metadata.routing_key
|
13
|
+
end
|
14
|
+
|
15
|
+
def ack
|
16
|
+
@is_ackable or raise 'This message is not ackable'
|
17
|
+
metadata.ack
|
18
|
+
end
|
19
|
+
|
20
|
+
def reject
|
21
|
+
@is_ackable or raise 'This message is not rejectable'
|
22
|
+
metadata.reject(requeue: false)
|
23
|
+
end
|
24
|
+
|
25
|
+
def nack
|
26
|
+
@is_ackable or raise 'This message is not nackable'
|
27
|
+
metadata.reject(requeue: true)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
data/lib/mercury/sync.rb
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
require 'mercury/mercury'
|
2
|
+
require 'bunny'
|
3
|
+
|
4
|
+
class Mercury
|
5
|
+
class Sync
|
6
|
+
class << self
|
7
|
+
def publish(source_name, msg, tag: '', amqp_opts: {})
|
8
|
+
conn = Bunny.new(amqp_opts)
|
9
|
+
conn.start
|
10
|
+
ch = conn.create_channel
|
11
|
+
ch.confirm_select
|
12
|
+
ex = ch.topic(source_name, Mercury.source_opts)
|
13
|
+
ex.publish(WireSerializer.new.write(msg), **Mercury.publish_opts(tag))
|
14
|
+
ch.wait_for_confirms or raise 'failed to confirm publication'
|
15
|
+
ensure
|
16
|
+
conn.close
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
require 'eventmachine'
|
2
|
+
require 'mercury/cps'
|
3
|
+
require 'mercury/monadic'
|
4
|
+
|
5
|
+
class Mercury
|
6
|
+
module TestUtils
|
7
|
+
def em
|
8
|
+
EM.run do
|
9
|
+
EM.add_timer(in_debug_mode? ? 999999 : 3) { raise 'EM spec timed out' }
|
10
|
+
yield
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
def in_debug_mode?
|
15
|
+
ENV['RUBYLIB'] =~ /ruby-debug-ide/ # http://stackoverflow.com/questions/22039807/determine-if-a-program-is-running-in-debug-mode
|
16
|
+
end
|
17
|
+
|
18
|
+
def done
|
19
|
+
EM.stop
|
20
|
+
end
|
21
|
+
|
22
|
+
def em_wait_until(pred, &k)
|
23
|
+
try_again = proc do
|
24
|
+
if pred.call
|
25
|
+
k.call
|
26
|
+
else
|
27
|
+
EM.add_timer(1.0 / 50, try_again)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
try_again.call
|
31
|
+
end
|
32
|
+
|
33
|
+
def wait_until(&pred)
|
34
|
+
cps do |&k|
|
35
|
+
em_wait_until(pred, &k)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def wait_for(seconds)
|
40
|
+
cps do |&k|
|
41
|
+
EM.add_timer(seconds, &k)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def delete_sources_and_queues_cps(source_names, queue_names)
|
46
|
+
# We must create a new mercury. The AMQP gem doesn't let you redeclare
|
47
|
+
# a construct with the same instance you deleted it with.
|
48
|
+
Mercury::Monadic.open.and_then do |m|
|
49
|
+
Cps.inject(amq_filter(source_names)) { |s| m.delete_source(s) }.
|
50
|
+
inject(amq_filter(queue_names)) { |q| m.delete_work_queue(q) }.
|
51
|
+
and_then { m.close }
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def amq_filter(xs)
|
56
|
+
xs.reject{|x| x.start_with?('amq.')}
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
require 'oj'
|
2
|
+
|
3
|
+
class Mercury
|
4
|
+
class WireSerializer
|
5
|
+
# TODO: DRY with hyperion once we know more
|
6
|
+
|
7
|
+
def write(struct_or_hash)
|
8
|
+
write_json(struct_or_hash)
|
9
|
+
end
|
10
|
+
|
11
|
+
def read(bytes)
|
12
|
+
read_json(bytes)
|
13
|
+
end
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
def write_json(obj)
|
18
|
+
if obj.is_a?(String)
|
19
|
+
obj
|
20
|
+
else
|
21
|
+
Oj.dump(hashify(obj), oj_options)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def read_json(bytes)
|
26
|
+
begin
|
27
|
+
Oj.compat_load(bytes, oj_options)
|
28
|
+
rescue Oj::ParseError => e
|
29
|
+
bytes
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def oj_options
|
34
|
+
{
|
35
|
+
mode: :compat,
|
36
|
+
time_format: :xmlschema, # xmlschema == iso8601
|
37
|
+
use_to_json: false,
|
38
|
+
second_precision: 3
|
39
|
+
}
|
40
|
+
end
|
41
|
+
|
42
|
+
def hashify(x)
|
43
|
+
case x
|
44
|
+
when Hash
|
45
|
+
x
|
46
|
+
when Struct
|
47
|
+
x.to_h
|
48
|
+
else
|
49
|
+
raise "Could not convert to hash: #{x.inspect}"
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
end
|
54
|
+
end
|
data/lib/mercury.rb
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require 'mercury/mercury'
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'mercury/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = 'mercury_amqp'
|
8
|
+
spec.version = Mercury::VERSION
|
9
|
+
spec.authors = ['Peter Winton']
|
10
|
+
spec.email = ['wintonpc@gmail.com']
|
11
|
+
spec.summary = 'AMQP-backed messaging layer'
|
12
|
+
spec.description = 'Abstracts common patterns used with AMQP'
|
13
|
+
spec.homepage = 'https://github.com/wintonpc/mercury_amqp'
|
14
|
+
spec.license = 'MIT'
|
15
|
+
|
16
|
+
spec.files = `git ls-files -z`.split("\x0")
|
17
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
18
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
19
|
+
spec.require_paths = ['lib']
|
20
|
+
|
21
|
+
spec.add_development_dependency 'bundler', '~> 1.7'
|
22
|
+
spec.add_development_dependency 'rake', '~> 10.0'
|
23
|
+
spec.add_development_dependency 'yard'
|
24
|
+
spec.add_development_dependency 'rspec', '~> 3.0'
|
25
|
+
spec.add_development_dependency 'json_spec'
|
26
|
+
spec.add_development_dependency 'evented-spec'
|
27
|
+
spec.add_development_dependency 'rspec_junit_formatter'
|
28
|
+
|
29
|
+
spec.add_runtime_dependency 'oj', '~> 2.12'
|
30
|
+
spec.add_runtime_dependency 'amqp', '~> 1.5'
|
31
|
+
spec.add_runtime_dependency 'bunny', '~> 2.1'
|
32
|
+
spec.add_runtime_dependency 'binding_of_caller', '~> 0.7'
|
33
|
+
end
|
@@ -0,0 +1,225 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'mercury/cps'
|
3
|
+
|
4
|
+
describe Cps do
|
5
|
+
include Cps::Methods
|
6
|
+
|
7
|
+
let!(:a) { 123 }
|
8
|
+
|
9
|
+
describe '::lift' do
|
10
|
+
it 'CPS-transforms a non-CPS proc' do
|
11
|
+
expect(Cps.lift{rand}.run).to be_a Numeric
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
describe '::run' do
|
16
|
+
it 'passes the value to the continuation' do
|
17
|
+
expect{|b| lift{a}.run(&b)}.to yield_with_args(a)
|
18
|
+
end
|
19
|
+
it 'returns the return value of the continuation' do
|
20
|
+
expect(lift{a}.run{456}).to eql 456
|
21
|
+
end
|
22
|
+
it 'feeds its arguments into the Cps' do
|
23
|
+
expect{|b| Cps.identity.run(a, &b)}.to yield_with_args(a)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
describe '#and_then' do
|
28
|
+
it 'composes two Cps instances' do
|
29
|
+
expect(lift{a}.and_then{|x| to_string(x)}.run).to eql a.to_s
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
describe '#and_lift' do
|
34
|
+
it 'composes a Cps instance with a normal proc' do
|
35
|
+
expect(lift{a}.and_lift{|x| x.to_s}.run).to eql a.to_s
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
describe '::concurrently' do
|
40
|
+
it 'composes Cps instances concurrently' do
|
41
|
+
em do
|
42
|
+
actions = []
|
43
|
+
started1 = false
|
44
|
+
finished2 = false
|
45
|
+
|
46
|
+
task1 = proc do |n, &k|
|
47
|
+
actions << 'start1'
|
48
|
+
started1 = true
|
49
|
+
em_wait_until(proc{finished2}) do
|
50
|
+
actions << 'finish1'
|
51
|
+
k.call(n.to_s)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
task2 = proc do |n, &k|
|
55
|
+
em_wait_until(proc{started1}) do
|
56
|
+
actions << 'start2'
|
57
|
+
actions << 'finish2'
|
58
|
+
finished2 = true
|
59
|
+
k.call(-1 * n)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
result = nil
|
64
|
+
Cps.concurrently(Cps.new(&task1), Cps.new(&task2)).run(42) { |r| result = r }
|
65
|
+
em_wait_until(proc{result}) do
|
66
|
+
expect(result).to eql [['42'], [-42]]
|
67
|
+
expect(actions).to eql %w(start1 start2 finish2 finish1)
|
68
|
+
done
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
describe '::seq' do
|
75
|
+
it 'binds a sequence of monadic functions' do
|
76
|
+
result = Cps.seq do |th|
|
77
|
+
th.en { to_string(a) }
|
78
|
+
th.en { |v| twice(v) }
|
79
|
+
th.en { |v| reverse(v) }
|
80
|
+
end.run
|
81
|
+
expect(result).to eql '321321'
|
82
|
+
end
|
83
|
+
it 'can be chained' do
|
84
|
+
result = Cps.seq do |th|
|
85
|
+
th.en { to_string(a) }
|
86
|
+
th.en { |v| twice(v) }
|
87
|
+
th.en do |v|
|
88
|
+
Cps.seq do |th|
|
89
|
+
th.en { reverse(v) }
|
90
|
+
th.en { |v| surround(v) }
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end.run
|
94
|
+
expect(result).to eql '*321321*'
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
describe '::seql' do
|
99
|
+
it 'binds a sequence of monadic functions' do
|
100
|
+
result = Cps.seql do
|
101
|
+
let(:v) { to_string(a) }
|
102
|
+
let(:v) { twice(v) }
|
103
|
+
and_then { reverse(v) }
|
104
|
+
end.run
|
105
|
+
expect(result).to eql '321321'
|
106
|
+
end
|
107
|
+
it 'can be chained' do
|
108
|
+
result = Cps.seql do
|
109
|
+
let(:v) { to_string(a) }
|
110
|
+
let(:v) { twice(v) }
|
111
|
+
and_then do
|
112
|
+
Cps.seql do
|
113
|
+
let(:v) { reverse(v) }
|
114
|
+
and_then { surround(v) }
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end.run
|
118
|
+
expect(result).to eql '*321321*'
|
119
|
+
end
|
120
|
+
it 'can be used like seq' do
|
121
|
+
result = Cps.seql do
|
122
|
+
and_then { to_string(a) }
|
123
|
+
and_then { |v| twice(v) }
|
124
|
+
and_then { |v| reverse(v) }
|
125
|
+
end.run
|
126
|
+
expect(result).to eql '321321'
|
127
|
+
end
|
128
|
+
it 'passes a bound value to the next function' do
|
129
|
+
result = Cps.seql do
|
130
|
+
let(:v) { to_string(a) }
|
131
|
+
and_then { |x| lift{x} }
|
132
|
+
end.run
|
133
|
+
expect(result).to eql '123'
|
134
|
+
end
|
135
|
+
it 'block can access outer methods' do
|
136
|
+
Cps.seql do
|
137
|
+
expect(foo).to eql 'foo'
|
138
|
+
end
|
139
|
+
end
|
140
|
+
def foo
|
141
|
+
'foo'
|
142
|
+
end
|
143
|
+
it 'block can access outer variables' do
|
144
|
+
bar = 'bar'
|
145
|
+
Cps.seql do
|
146
|
+
expect(bar).to eql 'bar'
|
147
|
+
end
|
148
|
+
end
|
149
|
+
it 'block cannot access outer instance variables' do
|
150
|
+
@baz = 'baz'
|
151
|
+
Cps.seql do
|
152
|
+
expect(@baz).to eql nil
|
153
|
+
end
|
154
|
+
end
|
155
|
+
it 'block can access outer constants' do
|
156
|
+
Cps.seql do
|
157
|
+
expect(FLIP).to eql 'flip'
|
158
|
+
end
|
159
|
+
end
|
160
|
+
FLIP = 'flip'
|
161
|
+
end
|
162
|
+
|
163
|
+
describe '::identity' do
|
164
|
+
it 'passes its arguments to the continuation' do
|
165
|
+
expect{|b| Cps.identity.run(a, &b)}.to yield_with_args(a)
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
describe '#inject' do
|
170
|
+
it 'creates a Cps chain given an array and a transformation function' do
|
171
|
+
chain = lift{'entity'}.inject(['-ize', '-er']) do |suffix, v|
|
172
|
+
lift { v + suffix }
|
173
|
+
end
|
174
|
+
expect(chain.run).to eql 'entity-ize-er'
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
# verify Cps obeys the monad laws (https://wiki.haskell.org/Monad_laws)
|
179
|
+
# unit ::= Cps::lift
|
180
|
+
# bind ::= Cps#and_then
|
181
|
+
|
182
|
+
it 'obeys the left identity law' do
|
183
|
+
# return a >>= f === f a
|
184
|
+
expect_identical(lift{a}.and_then(&method(:to_string)),
|
185
|
+
to_string(a),
|
186
|
+
'123')
|
187
|
+
end
|
188
|
+
|
189
|
+
it 'obeys the right identity law' do
|
190
|
+
# m >>= return === m
|
191
|
+
m = lift{123}
|
192
|
+
expect_identical(m.and_then{|x| lift{x}},
|
193
|
+
m,
|
194
|
+
123)
|
195
|
+
end
|
196
|
+
|
197
|
+
it 'obeys the associativity law' do
|
198
|
+
# (m >>= f) >>= g === m >>= (\x -> f x >>= g)
|
199
|
+
m = lift{123}
|
200
|
+
expect_identical((m.and_then(&method(:to_string))).and_then(&method(:twice)),
|
201
|
+
m.and_then {|x| to_string(x).and_then(&method(:twice)) },
|
202
|
+
'123123')
|
203
|
+
end
|
204
|
+
|
205
|
+
def expect_identical(lhs, rhs, expected_value)
|
206
|
+
expect{|b| lhs.run(&b)}.to yield_with_args(expected_value)
|
207
|
+
expect{|b| rhs.run(&b)}.to yield_with_args(expected_value)
|
208
|
+
end
|
209
|
+
|
210
|
+
def to_string(x)
|
211
|
+
lift { x.to_s }
|
212
|
+
end
|
213
|
+
|
214
|
+
def twice(x)
|
215
|
+
lift { x * 2 }
|
216
|
+
end
|
217
|
+
|
218
|
+
def reverse(x)
|
219
|
+
lift { x.reverse }
|
220
|
+
end
|
221
|
+
|
222
|
+
def surround(x)
|
223
|
+
lift { "*#{x}*" }
|
224
|
+
end
|
225
|
+
end
|