mercury_amqp 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|