lolitra 0.2.0 → 0.2.1
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.
- data/README.md +31 -4
- data/lib/lolitra/handler_base.rb +61 -97
- data/lib/lolitra/rabbitmq_bus.rb +235 -0
- data/lib/lolitra/version.rb +1 -1
- metadata +4 -3
data/README.md
CHANGED
@@ -4,7 +4,7 @@ Amqp and Faye event bus
|
|
4
4
|
|
5
5
|
## Installation
|
6
6
|
|
7
|
-
Add this line to your
|
7
|
+
Add this line to your Gemfile:
|
8
8
|
|
9
9
|
gem 'lolitra'
|
10
10
|
|
@@ -20,14 +20,13 @@ Or install it yourself as:
|
|
20
20
|
|
21
21
|
Create an event bus on initializers
|
22
22
|
|
23
|
-
Lolitra::
|
23
|
+
Lolitra::RabbitmqBus.new(
|
24
24
|
:exchange => "exchangetest",
|
25
|
-
:queue_prefix => "my_prefix_",
|
26
25
|
:host => "127.0.0.1",
|
27
26
|
:port => 5672,
|
28
27
|
:user => "guest",
|
29
28
|
:pass => "guest",
|
30
|
-
:pull_subscribers => [DevicesHandler
|
29
|
+
:pull_subscribers => [DevicesHandler]
|
31
30
|
)
|
32
31
|
|
33
32
|
Create messages
|
@@ -68,6 +67,34 @@ Create a message handler
|
|
68
67
|
|
69
68
|
end
|
70
69
|
|
70
|
+
*Rabbitmq*
|
71
|
+
Lolitra generates a deadletter exchange and queues to handle dead letters and will be aware about connections issues, reconnecting on every failure.
|
72
|
+
|
73
|
+
*Deadletter manual handling*
|
74
|
+
With lolitra you can recover dead letter message with irb or rails console.
|
75
|
+
|
76
|
+
```
|
77
|
+
Lolitra::subscribers
|
78
|
+
```
|
79
|
+
will return the available handlers
|
80
|
+
|
81
|
+
```
|
82
|
+
Lolitra::process_deadletters(DevicesHandler)
|
83
|
+
```
|
84
|
+
will process all dead letter from DevicesHandler until found an exception
|
85
|
+
Fail recover deadletter will reenqueue to dead letter queue
|
86
|
+
|
87
|
+
```
|
88
|
+
Lolitra::remove_next_deadletter(DeviceHandler)
|
89
|
+
```
|
90
|
+
Will remove next deadletter without doing anything
|
91
|
+
|
92
|
+
```
|
93
|
+
Lolitra::purge_deadletters(DeviceHandler)
|
94
|
+
```
|
95
|
+
Will remove all deadletters without doing anything
|
96
|
+
|
97
|
+
|
71
98
|
## Contributing
|
72
99
|
|
73
100
|
1. Fork it
|
data/lib/lolitra/handler_base.rb
CHANGED
@@ -3,6 +3,8 @@ require 'log4r'
|
|
3
3
|
require 'amqp'
|
4
4
|
require 'amqp/utilities/event_loop_helper'
|
5
5
|
require 'json'
|
6
|
+
require 'fileutils'
|
7
|
+
require_relative 'rabbitmq_bus'
|
6
8
|
|
7
9
|
module Lolitra
|
8
10
|
include Log4r
|
@@ -27,6 +29,32 @@ module Lolitra
|
|
27
29
|
Lolitra::MessageHandlerManager.publish(message)
|
28
30
|
end
|
29
31
|
|
32
|
+
def self.unsubscribe(handler_class, &block)
|
33
|
+
Lolitra::MessageHandlerManager.unsubscribe(handler_class, &block)
|
34
|
+
end
|
35
|
+
|
36
|
+
def self.disconnect(&block)
|
37
|
+
Lolitra::MessageHandlerManager.disconnect(&block)
|
38
|
+
end
|
39
|
+
|
40
|
+
def self.subscribers
|
41
|
+
Lolitra::MessageHandlerManager.instance.subscribers.collect do |subscriber|
|
42
|
+
subscriber.name
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def self.process_deadletters(subscriber)
|
47
|
+
Lolitra::MessageHandlerManager.instance.process_deadletters(subscriber)
|
48
|
+
end
|
49
|
+
|
50
|
+
def self.remove_next_deadletter(subscriber)
|
51
|
+
Lolitra::MessageHandlerManager.instance.remove_next_deadletter(subscriber)
|
52
|
+
end
|
53
|
+
|
54
|
+
def self.purge_deadletters(subscriber)
|
55
|
+
Lolitra::MessageHandlerManager.instance.purge_deadletters(subscriber)
|
56
|
+
end
|
57
|
+
|
30
58
|
module MessageHandler
|
31
59
|
module Helpers
|
32
60
|
def self.underscore(arg)
|
@@ -65,11 +93,7 @@ module Lolitra
|
|
65
93
|
end
|
66
94
|
|
67
95
|
def handle(message)
|
68
|
-
|
69
|
-
get_handler(message).handle(message)
|
70
|
-
rescue => e
|
71
|
-
Lolitra::log_exception(e)
|
72
|
-
end
|
96
|
+
get_handler(message).handle(message)
|
73
97
|
end
|
74
98
|
|
75
99
|
def publish(message)
|
@@ -142,6 +166,7 @@ module Lolitra
|
|
142
166
|
include Singleton
|
143
167
|
|
144
168
|
attr_accessor :bus
|
169
|
+
attr_accessor :subscribers
|
145
170
|
|
146
171
|
def self.bus=(new_bus)
|
147
172
|
instance.bus = new_bus
|
@@ -155,20 +180,36 @@ module Lolitra
|
|
155
180
|
instance.register_subscriber(handler_class)
|
156
181
|
end
|
157
182
|
|
183
|
+
def subscribers
|
184
|
+
@subscribers ||= []
|
185
|
+
end
|
186
|
+
|
187
|
+
def process_deadletters(handler_class)
|
188
|
+
bus.process_deadletters(handler_class)
|
189
|
+
end
|
190
|
+
|
191
|
+
def purge_deadletters(handler_class)
|
192
|
+
bus.purge_deadletters(handler_class)
|
193
|
+
end
|
194
|
+
|
195
|
+
def remove_next_deadletter(handler_class)
|
196
|
+
bus.remove_next_deadletter(handler_class)
|
197
|
+
end
|
198
|
+
|
158
199
|
def register_subscriber(handler_class)
|
200
|
+
subscribers << handler_class
|
159
201
|
handler_class.handle_messages.each do |message_class|
|
160
202
|
bus.subscribe(message_class, handler_class)
|
161
203
|
end
|
162
204
|
end
|
163
205
|
|
164
|
-
def self.
|
165
|
-
instance.
|
206
|
+
def self.unsubscribe(handler_class, &block)
|
207
|
+
instance.unsubscribe(handler_class, &block)
|
166
208
|
end
|
167
|
-
|
168
|
-
def
|
169
|
-
|
170
|
-
|
171
|
-
end
|
209
|
+
|
210
|
+
def unsubscribe(handler_class, &block)
|
211
|
+
Lolitra::logger.info("Unsubscribing #{handler_class}")
|
212
|
+
bus.unsubscribe(handler_class, &block)
|
172
213
|
end
|
173
214
|
|
174
215
|
def self.publish(message)
|
@@ -180,6 +221,14 @@ module Lolitra
|
|
180
221
|
Lolitra::logger.debug("#{message.marshall}")
|
181
222
|
bus.publish(message)
|
182
223
|
end
|
224
|
+
|
225
|
+
def self.disconnect(&block)
|
226
|
+
instance.disconnect(&block)
|
227
|
+
end
|
228
|
+
|
229
|
+
def disconnect(&block)
|
230
|
+
bus.disconnect(&block)
|
231
|
+
end
|
183
232
|
end
|
184
233
|
|
185
234
|
module Message
|
@@ -241,89 +290,4 @@ module Lolitra
|
|
241
290
|
@socketClient.publish(message.class.message_key, message.marshall)
|
242
291
|
end
|
243
292
|
end
|
244
|
-
|
245
|
-
class AmqpBus
|
246
|
-
attr_accessor :queue_prefix
|
247
|
-
attr_accessor :connection
|
248
|
-
attr_accessor :exchange
|
249
|
-
|
250
|
-
def initialize(hash = {})
|
251
|
-
Lolitra::MessageHandlerManager.bus = self
|
252
|
-
|
253
|
-
@channels = {}
|
254
|
-
@params = hash.reject { |key, value| !value }
|
255
|
-
raise "no :exchange specified" unless hash[:exchange]
|
256
|
-
|
257
|
-
self.queue_prefix = hash[:queue_prefix]||""
|
258
|
-
AMQP::Utilities::EventLoopHelper.run do
|
259
|
-
self.connection = AMQP.start(@params) do |connection|
|
260
|
-
channel = create_channel(connection) do |channel|
|
261
|
-
begin
|
262
|
-
self.exchange = channel.topic(@params[:exchange], :durable => true)
|
263
|
-
|
264
|
-
@params[:pull_subscribers].each do |handler|
|
265
|
-
Lolitra::MessageHandlerManager.register_pull_subscriber(handler)
|
266
|
-
end
|
267
|
-
rescue => e
|
268
|
-
Lolitra::logger.debug("error")
|
269
|
-
Lolitra::log_exception(e)
|
270
|
-
end
|
271
|
-
end
|
272
|
-
end
|
273
|
-
end
|
274
|
-
end
|
275
|
-
|
276
|
-
def subscribe(message_class, handler_class)
|
277
|
-
create_queue(message_class, handler_class, {:exclusive => true, :durable => false}, "")
|
278
|
-
end
|
279
|
-
|
280
|
-
def pull_subscribe(message_class, handler_class)
|
281
|
-
create_queue(message_class, handler_class, {:durable => true})
|
282
|
-
end
|
283
|
-
|
284
|
-
def publish(message)
|
285
|
-
#TODO: if exchange channel is closed doesn't log anything
|
286
|
-
self.exchange.publish(message.marshall, :routing_key => message.class.message_key, :timestamp => Time.now.to_i)
|
287
|
-
end
|
288
|
-
|
289
|
-
private
|
290
|
-
def create_channel(connection, &block)
|
291
|
-
channel = AMQP::Channel.new(connection) do
|
292
|
-
channel.on_error do |channel, close|
|
293
|
-
Lolitra::logger.error("Channel error: #{channel}")
|
294
|
-
Lolitra::logger.error(close)
|
295
|
-
end
|
296
|
-
block.call(channel)
|
297
|
-
end
|
298
|
-
channel
|
299
|
-
end
|
300
|
-
|
301
|
-
def create_queue(message_class, handler_class, options)
|
302
|
-
begin
|
303
|
-
queue_name = queue_prefix + MessageHandler::Helpers.underscore(handler_class.name)
|
304
|
-
|
305
|
-
create_channel(self.connection) do |channel|
|
306
|
-
channel.queue(queue_name, options).bind(self.exchange, :routing_key => message_class.message_key)
|
307
|
-
channel.close
|
308
|
-
end
|
309
|
-
|
310
|
-
if !@channels[queue_name] #Only one subscriber by queue_name
|
311
|
-
@channels[queue_name] = create_channel(self.connection) do |channel|
|
312
|
-
channel.prefetch(1).queue(queue_name, options).subscribe do |info, payload|
|
313
|
-
begin
|
314
|
-
Lolitra::logger.debug("Message recived: #{info.routing_key}")
|
315
|
-
Lolitra::logger.debug("#{payload}")
|
316
|
-
message_class_tmp = handler_class.handlers[info.routing_key][0]
|
317
|
-
handler_class.handle(message_class_tmp.unmarshall(payload))
|
318
|
-
rescue => e
|
319
|
-
Lolitra::log_exception(e)
|
320
|
-
end
|
321
|
-
end
|
322
|
-
end
|
323
|
-
end
|
324
|
-
rescue => e
|
325
|
-
Lolitra::log_exception(e)
|
326
|
-
end
|
327
|
-
end
|
328
|
-
end
|
329
293
|
end
|
@@ -0,0 +1,235 @@
|
|
1
|
+
module Lolitra
|
2
|
+
class RabbitmqBus
|
3
|
+
attr_accessor :connection
|
4
|
+
attr_accessor :exchange
|
5
|
+
attr_accessor :exchange_dead_letter
|
6
|
+
attr_accessor :options
|
7
|
+
attr_accessor :subscribers
|
8
|
+
|
9
|
+
SUBSCRIBE_OPTIONS = {:durable => true}
|
10
|
+
|
11
|
+
def initialize(hash = {})
|
12
|
+
Lolitra::MessageHandlerManager.bus = self
|
13
|
+
|
14
|
+
self.options = {
|
15
|
+
:queue_prefix => "",
|
16
|
+
:queue_suffix => "",
|
17
|
+
:exchange_dead_suffix => ".dead",
|
18
|
+
:exchange_dead_params => {},
|
19
|
+
:queue_params => {},
|
20
|
+
:queue_dead_suffix => ".dead",
|
21
|
+
:queue_dead_params => {},
|
22
|
+
:no_consume => false,
|
23
|
+
}.merge(hash.delete(:options) || {})
|
24
|
+
|
25
|
+
self.options[:queue_params][:arguments] = {} unless self.options[:queue_params][:arguments]
|
26
|
+
|
27
|
+
self.options[:queue_params][:arguments] = {
|
28
|
+
"x-dead-letter-exchange" => "#{hash[:exchange]}#{@options[:exchange_dead_suffix]}"
|
29
|
+
}.merge(self.options[:queue_params][:arguments])
|
30
|
+
|
31
|
+
@channels = {}
|
32
|
+
@params = hash.reject { |key, value| !value }
|
33
|
+
raise "no :exchange specified" unless hash[:exchange]
|
34
|
+
|
35
|
+
AMQP::Utilities::EventLoopHelper.run do
|
36
|
+
self.connection = AMQP.start(@params) do |connection|
|
37
|
+
Lolitra::logger.info("Connected to rabbitmq.")
|
38
|
+
channel = create_channel(connection) do |channel|
|
39
|
+
begin
|
40
|
+
self.exchange = channel.topic(@params[:exchange], :durable => true)
|
41
|
+
self.exchange_dead_letter = channel.topic("#{@params[:exchange]}#{@options[:exchange_dead_suffix]}", :durable => true)
|
42
|
+
|
43
|
+
@params[:subscribers].each do |handler|
|
44
|
+
Lolitra::MessageHandlerManager.register_subscriber(handler)
|
45
|
+
end
|
46
|
+
rescue => e
|
47
|
+
Lolitra::log_exception(e)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
self.connection.on_tcp_connection_loss do |connection, settings|
|
52
|
+
# reconnect in 10 seconds, without enforcement
|
53
|
+
Lolitra::logger.info("Connection loss. Trying to reconnect in 10 secs if needed.")
|
54
|
+
connection.reconnect(false, 10)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def disconnect(&block)
|
60
|
+
self.connection.close(&block)
|
61
|
+
end
|
62
|
+
|
63
|
+
def subscribe(message_class, handler_class)
|
64
|
+
create_queue(message_class, handler_class, SUBSCRIBE_OPTIONS)
|
65
|
+
end
|
66
|
+
|
67
|
+
def publish(message)
|
68
|
+
#TODO: if exchange channel is closed doesn't log anything
|
69
|
+
self.exchange.publish(message.marshall, :routing_key => message.class.message_key, :timestamp => Time.now.to_i)
|
70
|
+
end
|
71
|
+
|
72
|
+
def unsubscribe(handler_class, &block)
|
73
|
+
queue_name = generate_queue_name(handler_class)
|
74
|
+
begin
|
75
|
+
create_channel(self.connection) do |channel|
|
76
|
+
queue = channel.queue(queue_name, SUBSCRIBE_OPTIONS) do |queue|
|
77
|
+
begin
|
78
|
+
queue.delete
|
79
|
+
block.call(handler_class, true)
|
80
|
+
rescue => e
|
81
|
+
Lolitra::log_exception(e)
|
82
|
+
block.call(handler_class, false)
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
rescue => e
|
87
|
+
Lolitra::log_exception(e)
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def process_deadletters(handler_class)
|
92
|
+
queue_name_dead = generate_queue_name_dead(handler_class)
|
93
|
+
options = SUBSCRIBE_OPTIONS
|
94
|
+
create_channel(self.connection) do |channel|
|
95
|
+
begin
|
96
|
+
channel.queue(queue_name_dead, options.merge(@options[:queue_dead_params])) do |queue|
|
97
|
+
recursive_pop(channel, queue, handler_class)
|
98
|
+
end
|
99
|
+
rescue => e
|
100
|
+
Lolitra::log_exception(e)
|
101
|
+
end
|
102
|
+
end
|
103
|
+
true
|
104
|
+
end
|
105
|
+
|
106
|
+
def purge_deadletters(handler_class)
|
107
|
+
queue_name_dead = generate_queue_name_dead(handler_class)
|
108
|
+
options = SUBSCRIBE_OPTIONS
|
109
|
+
create_channel(self.connection) do |channel|
|
110
|
+
begin
|
111
|
+
channel.queue(queue_name_dead, options.merge(@options[:queue_dead_params])) do |queue|
|
112
|
+
purge_queue(queue)
|
113
|
+
end
|
114
|
+
rescue => e
|
115
|
+
Lolitra::log_exception(e)
|
116
|
+
end
|
117
|
+
end
|
118
|
+
true
|
119
|
+
end
|
120
|
+
|
121
|
+
def remove_next_deadletter(handler_class)
|
122
|
+
queue_name_dead = generate_queue_name_dead(handler_class)
|
123
|
+
options = SUBSCRIBE_OPTIONS
|
124
|
+
create_channel(self.connection) do |channel|
|
125
|
+
begin
|
126
|
+
channel.queue(queue_name_dead, options.merge(@options[:queue_dead_params])) do |queue|
|
127
|
+
queue.pop
|
128
|
+
end
|
129
|
+
rescue => e
|
130
|
+
Lolitra::log_exception(e)
|
131
|
+
end
|
132
|
+
end
|
133
|
+
true
|
134
|
+
end
|
135
|
+
|
136
|
+
private
|
137
|
+
def purge_queue(queue)
|
138
|
+
queue.pop do |info, payload|
|
139
|
+
if (payload)
|
140
|
+
purge_queue(queue)
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
def recursive_pop(channel, queue, handler_class)
|
146
|
+
queue.pop(:ack => true) do |info, payload|
|
147
|
+
if payload
|
148
|
+
Lolitra::logger.info("Routing key: #{info.routing_key}")
|
149
|
+
Lolitra::logger.info("Payload: #{payload}")
|
150
|
+
begin
|
151
|
+
message_class_tmp = handler_class.handlers[info.routing_key][0]
|
152
|
+
handler_class.handle(message_class_tmp.unmarshall(payload))
|
153
|
+
info.ack
|
154
|
+
recursive_pop(channel, queue, handler_class)
|
155
|
+
rescue => e
|
156
|
+
channel.reject(info.delivery_tag, true)
|
157
|
+
Lolitra::log_exception(e)
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
def publish_payload(routing_key, payload)
|
164
|
+
self.exchange.publish(payload, :routing_key => routing_key, :timestamp => Time.now.to_i)
|
165
|
+
end
|
166
|
+
|
167
|
+
def create_channel(connection, &block)
|
168
|
+
channel = AMQP::Channel.new(connection, :auto_recovery => true) do
|
169
|
+
channel.on_error do |channel, close|
|
170
|
+
Lolitra::logger.error("Channel error: #{channel}")
|
171
|
+
Lolitra::logger.error(close)
|
172
|
+
end
|
173
|
+
block.call(channel)
|
174
|
+
end
|
175
|
+
channel
|
176
|
+
end
|
177
|
+
|
178
|
+
def generate_queue_name(handler_class)
|
179
|
+
"#{@options[:queue_prefix]}#{MessageHandler::Helpers.underscore(handler_class.name)}#{@options[:queue_suffix]}"
|
180
|
+
end
|
181
|
+
|
182
|
+
def generate_queue_name_dead(handler_class)
|
183
|
+
"#{generate_queue_name(handler_class)}#{@options[:queue_dead_suffix]}"
|
184
|
+
end
|
185
|
+
|
186
|
+
def create_queue(message_class, handler_class, options)
|
187
|
+
begin
|
188
|
+
queue_name = generate_queue_name(handler_class)
|
189
|
+
queue_name_dead = generate_queue_name_dead(handler_class)
|
190
|
+
|
191
|
+
create_channel(self.connection) do |channel|
|
192
|
+
begin
|
193
|
+
channel.queue(queue_name, options.merge(@options[:queue_params])).bind(self.exchange, :routing_key => message_class.message_key)
|
194
|
+
channel.queue(queue_name_dead, options.merge(@options[:queue_dead_params])).bind(self.exchange_dead_letter, :routing_key => message_class.message_key)
|
195
|
+
channel.close
|
196
|
+
rescue => e
|
197
|
+
Lolitra::log_exception(e)
|
198
|
+
end
|
199
|
+
end
|
200
|
+
|
201
|
+
if !@options[:no_consume] && !@channels[queue_name] #Only one subscriber by queue_name
|
202
|
+
@channels[queue_name] = subscribe_to_messages(queue_name, options, handler_class)
|
203
|
+
end
|
204
|
+
rescue => e
|
205
|
+
Lolitra::log_exception(e)
|
206
|
+
end
|
207
|
+
end
|
208
|
+
|
209
|
+
def subscribe_to_messages(queue_name, options, handler_class)
|
210
|
+
create_channel(self.connection) do |channel|
|
211
|
+
channel.prefetch(1).queue(queue_name, options.merge(@options[:queue_params])).subscribe(:ack => true) do |info, payload|
|
212
|
+
begin
|
213
|
+
Lolitra::logger.debug("Message recived: #{info.routing_key}")
|
214
|
+
Lolitra::logger.debug("#{payload}")
|
215
|
+
message_class_tmp = handler_class.handlers[info.routing_key][0]
|
216
|
+
handler_class.handle(message_class_tmp.unmarshall(payload))
|
217
|
+
info.ack
|
218
|
+
rescue => e
|
219
|
+
channel.reject(info.delivery_tag, false)
|
220
|
+
Lolitra::log_exception(e)
|
221
|
+
mark_deadletter
|
222
|
+
end
|
223
|
+
end
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
def remove_mark_deadletter
|
228
|
+
FileUtils.rm("#{Dir.pwd}/tmp/deadletter")
|
229
|
+
end
|
230
|
+
|
231
|
+
def mark_deadletter
|
232
|
+
FileUtils.touch("#{Dir.pwd}/tmp/deadletter")
|
233
|
+
end
|
234
|
+
end
|
235
|
+
end
|
data/lib/lolitra/version.rb
CHANGED
metadata
CHANGED
@@ -5,8 +5,8 @@ version: !ruby/object:Gem::Version
|
|
5
5
|
segments:
|
6
6
|
- 0
|
7
7
|
- 2
|
8
|
-
-
|
9
|
-
version: 0.2.
|
8
|
+
- 1
|
9
|
+
version: 0.2.1
|
10
10
|
platform: ruby
|
11
11
|
authors:
|
12
12
|
- Hugo Freire
|
@@ -14,7 +14,7 @@ autorequire:
|
|
14
14
|
bindir: bin
|
15
15
|
cert_chain: []
|
16
16
|
|
17
|
-
date: 2015-02-
|
17
|
+
date: 2015-02-26 00:00:00 +01:00
|
18
18
|
default_executable:
|
19
19
|
dependencies:
|
20
20
|
- !ruby/object:Gem::Dependency
|
@@ -102,6 +102,7 @@ files:
|
|
102
102
|
- Rakefile
|
103
103
|
- lib/lolitra.rb
|
104
104
|
- lib/lolitra/handler_base.rb
|
105
|
+
- lib/lolitra/rabbitmq_bus.rb
|
105
106
|
- lib/lolitra/version.rb
|
106
107
|
- lolitra.gemspec
|
107
108
|
- spec/lolitra_spec.rb
|