protobuf 1.0.1 → 1.1.0.beta0
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +3 -0
- data/.yardopts +5 -0
- data/Gemfile.lock +25 -10
- data/bin/rpc_server +38 -33
- data/lib/protobuf.rb +22 -3
- data/lib/protobuf/common/logger.rb +6 -8
- data/lib/protobuf/compiler/visitors.rb +8 -9
- data/lib/protobuf/descriptor/descriptor_builder.rb +6 -6
- data/lib/protobuf/ext/eventmachine.rb +2 -4
- data/lib/protobuf/message/message.rb +1 -3
- data/lib/protobuf/rpc/buffer.rb +6 -6
- data/lib/protobuf/rpc/client.rb +59 -21
- data/lib/protobuf/rpc/connector.rb +10 -9
- data/lib/protobuf/rpc/connectors/base.rb +23 -8
- data/lib/protobuf/rpc/connectors/common.rb +155 -0
- data/lib/protobuf/rpc/connectors/em_client.rb +23 -192
- data/lib/protobuf/rpc/connectors/eventmachine.rb +36 -44
- data/lib/protobuf/rpc/connectors/socket.rb +58 -1
- data/lib/protobuf/rpc/error.rb +6 -14
- data/lib/protobuf/rpc/server.rb +72 -99
- data/lib/protobuf/rpc/servers/evented_runner.rb +32 -0
- data/lib/protobuf/rpc/servers/evented_server.rb +29 -0
- data/lib/protobuf/rpc/servers/socket_runner.rb +17 -0
- data/lib/protobuf/rpc/servers/socket_server.rb +145 -0
- data/lib/protobuf/rpc/service.rb +50 -51
- data/lib/protobuf/rpc/stat.rb +2 -2
- data/lib/protobuf/version.rb +1 -1
- data/protobuf.gemspec +9 -4
- data/spec/helper/all.rb +1 -7
- data/spec/helper/server.rb +45 -5
- data/spec/helper/silent_constants.rb +40 -0
- data/spec/proto/test_service.rb +0 -1
- data/spec/proto/test_service_impl.rb +4 -3
- data/spec/spec_helper.rb +19 -6
- data/spec/unit/enum_spec.rb +4 -4
- data/spec/unit/rpc/client_spec.rb +32 -42
- data/spec/unit/rpc/connector_spec.rb +11 -16
- data/spec/unit/rpc/connectors/base_spec.rb +14 -3
- data/spec/unit/rpc/connectors/common_spec.rb +132 -0
- data/spec/unit/rpc/connectors/{eventmachine/client_spec.rb → eventmachine_client_spec.rb} +0 -0
- data/spec/unit/rpc/connectors/socket_spec.rb +49 -0
- data/spec/unit/rpc/servers/evented_server_spec.rb +18 -0
- data/spec/unit/rpc/servers/socket_server_spec.rb +57 -0
- metadata +86 -16
- data/spec/unit/rpc/server_spec.rb +0 -27
@@ -0,0 +1,29 @@
|
|
1
|
+
require 'protobuf/rpc/server'
|
2
|
+
|
3
|
+
module Protobuf
|
4
|
+
module Rpc
|
5
|
+
class EventedServer < EventMachine::Connection
|
6
|
+
include Protobuf::Rpc::Server
|
7
|
+
include Protobuf::Logger::LogMethods
|
8
|
+
|
9
|
+
# Initialize a new read buffer for storing client request info
|
10
|
+
def post_init
|
11
|
+
log_debug '[server] Post init, new read buffer created'
|
12
|
+
|
13
|
+
@stats = Protobuf::Rpc::Stat.new(:SERVER, true)
|
14
|
+
@stats.client = Socket.unpack_sockaddr_in(get_peername)
|
15
|
+
|
16
|
+
@buffer = Protobuf::Rpc::Buffer.new(:read)
|
17
|
+
@did_respond = false
|
18
|
+
end
|
19
|
+
|
20
|
+
# Receive a chunk of data, potentially flushed to handle_client
|
21
|
+
def receive_data(data)
|
22
|
+
log_debug '[server] receive_data: %s' % data
|
23
|
+
@buffer << data
|
24
|
+
handle_client if @buffer.flushed?
|
25
|
+
end
|
26
|
+
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
module Protobuf
|
2
|
+
module Rpc
|
3
|
+
class SocketRunner
|
4
|
+
|
5
|
+
def self.stop
|
6
|
+
Protobuf::Rpc::SocketServer.stop
|
7
|
+
Protobuf::Logger.info 'Shutdown complete'
|
8
|
+
end
|
9
|
+
|
10
|
+
def self.run(server)
|
11
|
+
Protobuf::Logger.info "SocketServer Running"
|
12
|
+
Protobuf::Rpc::SocketServer.run(server.host, server.port, server.backlog, server.threshold) if !Protobuf::Rpc::SocketServer.running?
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,145 @@
|
|
1
|
+
require 'protobuf/rpc/server'
|
2
|
+
|
3
|
+
module Protobuf
|
4
|
+
module Rpc
|
5
|
+
class SocketServer
|
6
|
+
include Protobuf::Rpc::Server
|
7
|
+
include Protobuf::Logger::LogMethods
|
8
|
+
|
9
|
+
class << self
|
10
|
+
|
11
|
+
def cleanup?
|
12
|
+
# every 10 connections run a cleanup routine after closing the response
|
13
|
+
@threads.size > (@thread_threshold - 1) && (@threads.size % @thread_threshold) == 0
|
14
|
+
end
|
15
|
+
|
16
|
+
def cleanup_threads
|
17
|
+
log_debug "[#{log_signature}] Thread cleanup - #{@threads.size} - start"
|
18
|
+
|
19
|
+
@threads = @threads.select do |t|
|
20
|
+
if t[:thread].alive?
|
21
|
+
true
|
22
|
+
else
|
23
|
+
t[:thread].join
|
24
|
+
@working.delete(t[:socket])
|
25
|
+
false
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
log_debug "[#{log_signature}] Thread cleanup - #{@threads.size} - complete"
|
30
|
+
end
|
31
|
+
|
32
|
+
def log_signature
|
33
|
+
@log_signature ||= "server-#{self}"
|
34
|
+
end
|
35
|
+
|
36
|
+
def new_worker(socket)
|
37
|
+
Thread.new(socket) do |sock|
|
38
|
+
Protobuf::Rpc::SocketServer::Worker.new(sock) do |s|
|
39
|
+
s.close
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
# TODO make listen/backlog part of config
|
45
|
+
def run(host = "127.0.0.1", port = 9399, backlog = 100, thread_threshold = 100)
|
46
|
+
log_debug "[#{log_signature}] Run"
|
47
|
+
@running = true
|
48
|
+
@threads = []
|
49
|
+
@thread_threshold = thread_threshold
|
50
|
+
@server = TCPServer.new(host, port)
|
51
|
+
@server.listen(backlog)
|
52
|
+
@working = []
|
53
|
+
@listen_fds = [@server]
|
54
|
+
|
55
|
+
while running?
|
56
|
+
log_debug "[#{log_signature}] Waiting for connections"
|
57
|
+
|
58
|
+
if ready_cnxns = IO.select(@listen_fds, [], [], 20)
|
59
|
+
cnxns = ready_cnxns.first
|
60
|
+
cnxns.each do |client|
|
61
|
+
case
|
62
|
+
when !running? then
|
63
|
+
# no-op
|
64
|
+
when client == @server then
|
65
|
+
log_debug "[#{log_signature}] Accepted new connection"
|
66
|
+
client, sockaddr = @server.accept
|
67
|
+
@listen_fds << client
|
68
|
+
else
|
69
|
+
if !@working.include?(client)
|
70
|
+
@working << @listen_fds.delete(client)
|
71
|
+
log_debug "[#{log_signature}] Working"
|
72
|
+
@threads << { :thread => new_worker(client),
|
73
|
+
:socket => client }
|
74
|
+
|
75
|
+
cleanup_threads if cleanup?
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
else
|
80
|
+
# Run a cleanup if select times out while waiting
|
81
|
+
cleanup_threads if @threads.size > 1
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
rescue
|
86
|
+
# Closing the server causes the loop to raise an exception here
|
87
|
+
raise if running?
|
88
|
+
end
|
89
|
+
|
90
|
+
def running?
|
91
|
+
@running
|
92
|
+
end
|
93
|
+
|
94
|
+
def stop
|
95
|
+
@running = false
|
96
|
+
@server.close
|
97
|
+
end
|
98
|
+
|
99
|
+
end
|
100
|
+
|
101
|
+
class Worker
|
102
|
+
include Protobuf::Rpc::Server
|
103
|
+
include Protobuf::Logger::LogMethods
|
104
|
+
|
105
|
+
def initialize(sock, &complete_cb)
|
106
|
+
@did_response = false
|
107
|
+
@socket = sock
|
108
|
+
@request = Protobuf::Socketrpc::Request.new
|
109
|
+
@response = Protobuf::Socketrpc::Response.new
|
110
|
+
@buffer = Protobuf::Rpc::Buffer.new(:read)
|
111
|
+
@stats = Protobuf::Rpc::Stat.new(:SERVER, true)
|
112
|
+
@complete_cb = complete_cb
|
113
|
+
log_debug "[#{log_signature}] Post init, new read buffer created"
|
114
|
+
|
115
|
+
@stats.client = Socket.unpack_sockaddr_in(@socket.getpeername)
|
116
|
+
@buffer << read_data
|
117
|
+
log_debug "[#{log_signature}] handling request"
|
118
|
+
handle_client if @buffer.flushed?
|
119
|
+
end
|
120
|
+
|
121
|
+
def log_signature
|
122
|
+
@log_signature ||= "server-#{self.class}-#{object_id}"
|
123
|
+
end
|
124
|
+
|
125
|
+
def read_data
|
126
|
+
size_io = StringIO.new
|
127
|
+
|
128
|
+
while((size_reader = @socket.getc) != "-")
|
129
|
+
size_io << size_reader
|
130
|
+
end
|
131
|
+
str_size_io = size_io.string
|
132
|
+
|
133
|
+
"#{str_size_io}-#{@socket.read(str_size_io.to_i)}"
|
134
|
+
end
|
135
|
+
|
136
|
+
def send_data(data)
|
137
|
+
log_debug "[#{log_signature}] sending data : %s" % data
|
138
|
+
@socket.write(data)
|
139
|
+
@socket.flush
|
140
|
+
@complete_cb.call(@socket)
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
data/lib/protobuf/rpc/service.rb
CHANGED
@@ -4,10 +4,9 @@ require 'protobuf/rpc/error'
|
|
4
4
|
|
5
5
|
module Protobuf
|
6
6
|
module Rpc
|
7
|
-
|
8
7
|
# Object to encapsulate the request/response types for a given service method
|
9
8
|
#
|
10
|
-
RpcMethod = Struct.new
|
9
|
+
RpcMethod = Struct.new("RpcMethod", :service, :method, :request_type, :response_type)
|
11
10
|
|
12
11
|
class Service
|
13
12
|
include Protobuf::Logger::LogMethods
|
@@ -27,34 +26,32 @@ module Protobuf
|
|
27
26
|
|
28
27
|
# You MUST add the method name to this list if you are adding
|
29
28
|
# instance methods below, otherwise stuff will definitely break
|
30
|
-
NON_RPC_METHODS = %w( rpcs call_rpc on_rpc_failed rpc_failed request response method_missing async_responder on_send_response send_response
|
29
|
+
NON_RPC_METHODS = %w( rpcs call_rpc on_rpc_failed rpc_failed request response method_missing async_responder on_send_response send_response log_signature )
|
31
30
|
|
32
31
|
# Override methods being added to the class
|
33
32
|
# If the method isn't already a private instance method, or it doesn't start with rpc_,
|
34
33
|
# or it isn't in the reserved method list (NON_RPC_METHODS),
|
35
34
|
# We want to remap the method such that we can wrap it in before and after behavior,
|
36
35
|
# most notably calling call_rpc against the method. See call_rpc for more info.
|
37
|
-
def method_added
|
36
|
+
def method_added(old)
|
38
37
|
new_method = :"rpc_#{old}"
|
39
38
|
return if private_instance_methods.include?(new_method) or old =~ /^rpc_/ or NON_RPC_METHODS.include?(old.to_s)
|
40
39
|
|
41
40
|
alias_method new_method, old
|
42
41
|
private new_method
|
43
42
|
|
44
|
-
|
45
|
-
|
46
|
-
call_rpc old.to_sym, pb_request
|
47
|
-
end
|
48
|
-
rescue ArgumentError => e
|
49
|
-
# Wrap a known issue where an instance method was defined in the class without
|
50
|
-
# it being ignored with NON_RPC_METHODS.
|
51
|
-
raise ArgumentError, "#{e.message} (Note: This could mean that you need to add the method #{old} to the NON_RPC_METHODS list)"
|
43
|
+
define_method(old) do |pb_request|
|
44
|
+
call_rpc(old.to_sym, pb_request)
|
52
45
|
end
|
46
|
+
rescue ArgumentError => e
|
47
|
+
# Wrap a known issue where an instance method was defined in the class without
|
48
|
+
# it being ignored with NON_RPC_METHODS.
|
49
|
+
raise ArgumentError, "#{e.message} (Note: This could mean that you need to add the method #{old} to the NON_RPC_METHODS list)"
|
53
50
|
end
|
54
51
|
|
55
52
|
# Generated service classes should call this method on themselves to add rpc methods
|
56
53
|
# to the stack with a given request and response type
|
57
|
-
def rpc
|
54
|
+
def rpc(method, request_type, response_type)
|
58
55
|
rpcs[self] ||= {}
|
59
56
|
rpcs[self][method] = RpcMethod.new self, method, request_type, response_type
|
60
57
|
end
|
@@ -68,7 +65,7 @@ module Protobuf
|
|
68
65
|
# See Client#initialize and ClientConnection::DEFAULT_OPTIONS
|
69
66
|
# for all available options.
|
70
67
|
#
|
71
|
-
def client
|
68
|
+
def client(options={})
|
72
69
|
configure
|
73
70
|
Client.new({
|
74
71
|
:service => self,
|
@@ -83,17 +80,17 @@ module Protobuf
|
|
83
80
|
# so that any Clients using the Service.client sugar
|
84
81
|
# will not have to configure the location each time.
|
85
82
|
#
|
86
|
-
def configure
|
83
|
+
def configure(config={})
|
87
84
|
locations[self] ||= {}
|
88
|
-
locations[self][:host] = config[:host] if config.key?
|
89
|
-
locations[self][:port] = config[:port] if config.key?
|
85
|
+
locations[self][:host] = config[:host] if config.key?(:host)
|
86
|
+
locations[self][:port] = config[:port] if config.key?(:port)
|
90
87
|
end
|
91
88
|
|
92
89
|
# Shorthand call to configure, passing a string formatted as hostname:port
|
93
90
|
# e.g. 127.0.0.1:9933
|
94
91
|
# e.g. localhost:0
|
95
92
|
#
|
96
|
-
def located_at
|
93
|
+
def located_at(location)
|
97
94
|
return if location.nil? or location.downcase.strip !~ /[a-z0-9.]+:\d+/
|
98
95
|
host, port = location.downcase.strip.split ':'
|
99
96
|
configure :host => host, :port => port.to_i
|
@@ -117,6 +114,10 @@ module Protobuf
|
|
117
114
|
end
|
118
115
|
|
119
116
|
end
|
117
|
+
|
118
|
+
def log_signature
|
119
|
+
@log_signature ||= "service-#{self.class}"
|
120
|
+
end
|
120
121
|
|
121
122
|
# If a method comes through that hasn't been found, and it
|
122
123
|
# is defined in the rpcs method list, we know that the rpc
|
@@ -129,7 +130,7 @@ module Protobuf
|
|
129
130
|
log_error exc.message
|
130
131
|
raise exc
|
131
132
|
else
|
132
|
-
log_error
|
133
|
+
log_error "-------------- [#{log_signature}] %s#%s not rpc method, passing to super" % [self.class.name, m.to_s]
|
133
134
|
super m, params
|
134
135
|
end
|
135
136
|
end
|
@@ -141,7 +142,7 @@ module Protobuf
|
|
141
142
|
|
142
143
|
# Callback register for the server when a service
|
143
144
|
# method calls rpc_failed. Called by Service#rpc_failed.
|
144
|
-
def on_rpc_failed
|
145
|
+
def on_rpc_failed(&rpc_failure_cb)
|
145
146
|
@rpc_failure_cb = rpc_failure_cb
|
146
147
|
end
|
147
148
|
|
@@ -149,14 +150,11 @@ module Protobuf
|
|
149
150
|
# NOTE: This shortcuts the @async_responder paradigm. There is
|
150
151
|
# not any way to get around this currently (and I'm not sure you should want to).
|
151
152
|
#
|
152
|
-
def rpc_failed
|
153
|
-
|
154
|
-
|
155
|
-
log_error exc.message
|
156
|
-
raise exc
|
157
|
-
end
|
153
|
+
def rpc_failed(message="RPC Failed while executing service method #{@current_method}")
|
154
|
+
error_message = 'Unable to invoke rpc_failed, no failure callback is setup.'
|
155
|
+
log_and_raise_error(error_message) if @rpc_failure_cb.nil?
|
158
156
|
error = message.is_a?(String) ? RpcFailed.new(message) : message
|
159
|
-
log_warn
|
157
|
+
log_warn "[#{log_signature}] RPC Failed: %s" % error.message
|
160
158
|
@rpc_failure_cb.call(error)
|
161
159
|
end
|
162
160
|
|
@@ -164,7 +162,7 @@ module Protobuf
|
|
164
162
|
# when it is appropriate to generate a response to the client.
|
165
163
|
# Used in conjunciton with Service#send_response.
|
166
164
|
#
|
167
|
-
def on_send_response
|
165
|
+
def on_send_response(&responder)
|
168
166
|
@responder = responder
|
169
167
|
end
|
170
168
|
|
@@ -175,15 +173,17 @@ module Protobuf
|
|
175
173
|
# will timeout since no data will be sent.
|
176
174
|
#
|
177
175
|
def send_response
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
raise exc
|
182
|
-
end
|
183
|
-
@responder.call @response
|
176
|
+
error_message = "Unable to send response, responder is nil. It appears you aren't inside of an RPC request/response cycle."
|
177
|
+
log_and_raise_error(error_message) if @responder.nil?
|
178
|
+
@responder.call(@response)
|
184
179
|
end
|
185
180
|
|
186
181
|
private
|
182
|
+
|
183
|
+
def log_and_raise_error(error_message)
|
184
|
+
log_error(error_message)
|
185
|
+
raise error_message
|
186
|
+
end
|
187
187
|
|
188
188
|
# Call the rpc method that was previously privatized.
|
189
189
|
# call_rpc allows us to wrap the normal method call with
|
@@ -202,38 +202,36 @@ module Protobuf
|
|
202
202
|
# by calling self.send_response without any arguments. The rpc
|
203
203
|
# server is setup to handle synchronous and asynchronous responses.
|
204
204
|
#
|
205
|
-
def call_rpc
|
205
|
+
def call_rpc(method, pb_request)
|
206
206
|
@current_method = method
|
207
207
|
|
208
208
|
# Allows the service to set whether or not
|
209
209
|
# it would like to asynchronously respond to the connected client(s)
|
210
210
|
@async_responder = false
|
211
211
|
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
end
|
222
|
-
|
212
|
+
# Setup the request
|
213
|
+
@request = rpcs[method].request_type.new
|
214
|
+
@request.parse_from_string(pb_request.request_proto)
|
215
|
+
rescue
|
216
|
+
exc = BadRequestProto.new 'Unable to parse request: %s' % $!.message
|
217
|
+
log_error exc.message
|
218
|
+
log_error $!.backtrace.join("\n")
|
219
|
+
raise exc
|
220
|
+
else # when no Exception was thrown
|
223
221
|
# Setup the response
|
224
222
|
@response = rpcs[method].response_type.new
|
225
223
|
|
226
|
-
log_debug
|
224
|
+
log_debug "[#{log_signature}] calling service method %s#%s" % [self.class, method]
|
227
225
|
# Call the aliased rpc method (e.g. :rpc_find for :find)
|
228
226
|
__send__("rpc_#{method}".to_sym)
|
229
|
-
log_debug
|
227
|
+
log_debug "[#{log_signature}] completed service method %s#%s" % [self.class, method]
|
230
228
|
|
231
229
|
# Pass the populated response back to the server
|
232
230
|
# Note this will only get called if the rpc method didn't explode (by design)
|
233
231
|
if @async_responder
|
234
|
-
log_debug
|
232
|
+
log_debug "[#{log_signature}] async request, not sending response (yet)"
|
235
233
|
else
|
236
|
-
log_debug
|
234
|
+
log_debug "[#{log_signature}] trigger server send_response"
|
237
235
|
send_response
|
238
236
|
end
|
239
237
|
end
|
@@ -241,4 +239,5 @@ module Protobuf
|
|
241
239
|
end
|
242
240
|
|
243
241
|
end
|
244
|
-
|
242
|
+
|
243
|
+
end
|
data/lib/protobuf/rpc/stat.rb
CHANGED
@@ -51,12 +51,12 @@ module Protobuf
|
|
51
51
|
end
|
52
52
|
|
53
53
|
def log_stats
|
54
|
-
Protobuf::Logger.info
|
54
|
+
Protobuf::Logger.info(self.to_s)
|
55
55
|
end
|
56
56
|
|
57
57
|
def to_s
|
58
58
|
[
|
59
|
-
@type == :SERVER ?
|
59
|
+
@type == :SERVER ? "[SRV-#{self.class}]" : "[CLT-#{self.class}]",
|
60
60
|
rpc,
|
61
61
|
elapsed_time,
|
62
62
|
sizes,
|
data/lib/protobuf/version.rb
CHANGED
data/protobuf.gemspec
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
#
|
1
|
+
# encoding: UTF-8
|
2
2
|
$:.push File.expand_path("./lib", File.dirname(__FILE__))
|
3
3
|
require "protobuf/version"
|
4
4
|
|
@@ -8,7 +8,7 @@ Gem::Specification.new do |s|
|
|
8
8
|
s.date = %q{2011-12-07}
|
9
9
|
|
10
10
|
s.authors = ['BJ Neilsen', 'Brandon Dewitt']
|
11
|
-
s.email = ["bj.neilsen@gmail.com", "brandonsdewitt@gmail.com"]
|
11
|
+
s.email = ["bj.neilsen@gmail.com", "brandonsdewitt+protobuf@gmail.com"]
|
12
12
|
s.homepage = %q{https://github.com/localshred/protobuf}
|
13
13
|
s.summary = 'Ruby implementation for Protocol Buffers. Works with other protobuf rpc implementations (e.g. Java, Python, C++).'
|
14
14
|
s.description = s.summary + "\n\nThis gem has diverged from https://github.com/macks/ruby-protobuf. All credit for serialization and rprotoc work most certainly goes to the original authors. All RPC implementation code (client/server/service) was written and is maintained by this author. Attempts to reconcile the original codebase with the current RPC implementation went unsuccessful."
|
@@ -19,7 +19,12 @@ Gem::Specification.new do |s|
|
|
19
19
|
s.require_paths = ["lib"]
|
20
20
|
|
21
21
|
s.add_dependency 'eventmachine', '~> 0.12.10'
|
22
|
-
|
22
|
+
s.add_dependency 'eventually', '~> 0.1.0'
|
23
|
+
s.add_dependency 'json_pure', '~> 1.6.4'
|
24
|
+
|
23
25
|
s.add_development_dependency 'rake', '~> 0.8.7'
|
24
|
-
s.add_development_dependency 'rspec', '~> 2.
|
26
|
+
s.add_development_dependency 'rspec', '~> 2.8.0'
|
27
|
+
s.add_development_dependency 'yard', '~> 0.7.4'
|
28
|
+
s.add_development_dependency 'redcarpet', '~> 1.17.2'
|
29
|
+
s.add_development_dependency 'simplecov', '~> 0.5.4'
|
25
30
|
end
|