rails_grip 1.0.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/lib/gripmiddleware.rb +193 -0
- data/lib/nonwebsocketrequesterror.rb +12 -0
- data/lib/rails_grip.rb +92 -0
- data/lib/railssettings.rb +36 -0
- data/lib/railtie.rb +12 -0
- data/lib/websocketcontext.rb +107 -0
- metadata +63 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 543fe29bbb33ab3fa68744043d958b0ff417561f
|
4
|
+
data.tar.gz: 2a4261dbe4d4fe36b271fde6391e05a8cadd9e48
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 0549e500baf1100baaec263cf4725d486af5cee8dae9ecff8d83ddfa6747d72c63795ca37777f60f20c10134cf50efa637f0890658af22ccc64ff0a3f1e06b57
|
7
|
+
data.tar.gz: 2c365e32cd0e2b06e9410a4941f7bbea5e9bdbde563e93db0c9658ef28b144708d71134f79bb9ee12b05c9716a19d3eb23c4fea1f44ecfbf337664c84fb9c92d
|
@@ -0,0 +1,193 @@
|
|
1
|
+
# gripmiddleware.rb
|
2
|
+
# ~~~~~~~~~
|
3
|
+
# This module implements the GripMiddleware class.
|
4
|
+
# :authors: Konstantin Bokarius.
|
5
|
+
# :copyright: (c) 2015 by Fanout, Inc.
|
6
|
+
# :license: MIT, see LICENSE for more details.
|
7
|
+
|
8
|
+
require 'set'
|
9
|
+
require 'gripcontrol'
|
10
|
+
require_relative 'websocketcontext.rb'
|
11
|
+
require_relative 'nonwebsocketrequesterror.rb'
|
12
|
+
|
13
|
+
class GripMiddleware
|
14
|
+
def initialize(app)
|
15
|
+
@app = app
|
16
|
+
end
|
17
|
+
|
18
|
+
# TODO: Add a mechanism to set to websocket-only.
|
19
|
+
def call(env)
|
20
|
+
env['grip_proxied'] = false
|
21
|
+
env['grip_wscontext'] = nil
|
22
|
+
grip_signed = false
|
23
|
+
grip_proxies = RailsSettings.get_grip_proxies
|
24
|
+
if env.key?('HTTP_GRIP_SIG') and !grip_proxies.nil?
|
25
|
+
grip_proxies.each do |entry|
|
26
|
+
if GripControl.validate_sig(env['HTTP_GRIP_SIG'], entry['key'])
|
27
|
+
grip_signed = true
|
28
|
+
break
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
content_type = nil
|
33
|
+
if env.key?('CONTENT_TYPE')
|
34
|
+
content_type = env['CONTENT_TYPE']
|
35
|
+
at = content_type.index(';')
|
36
|
+
if !at.nil?
|
37
|
+
content_type = content_type[0..at-1]
|
38
|
+
end
|
39
|
+
end
|
40
|
+
accept_types = nil
|
41
|
+
if env.key?('HTTP_ACCEPT')
|
42
|
+
accept_types = env['HTTP_ACCEPT']
|
43
|
+
tmp = accept_types.split(',')
|
44
|
+
accept_types = []
|
45
|
+
tmp.each do |s|
|
46
|
+
accept_types.push(s.strip)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
wscontext = nil
|
50
|
+
if env['REQUEST_METHOD'] == 'POST' and ((content_type ==
|
51
|
+
'application/websocket-events') or (!accept_types.nil? and
|
52
|
+
accept_types.include?('application/websocket-events')))
|
53
|
+
cid = nil
|
54
|
+
if env.key?('HTTP_CONNECTION_ID')
|
55
|
+
cid = env['HTTP_CONNECTION_ID']
|
56
|
+
end
|
57
|
+
meta = {}
|
58
|
+
env.each do |k, v|
|
59
|
+
if k.start_with?('HTTP_META_')
|
60
|
+
meta[convert_header_name(k[10..-1])] = v
|
61
|
+
end
|
62
|
+
end
|
63
|
+
events = nil
|
64
|
+
begin
|
65
|
+
events = GripControl.decode_websocket_events(env["rack.input"].read)
|
66
|
+
rescue
|
67
|
+
return [ 400, {}, ["Error parsing WebSocket events.\n"]]
|
68
|
+
end
|
69
|
+
wscontext = WebSocketContext.new(cid, meta, events)
|
70
|
+
end
|
71
|
+
env['grip_proxied'] = grip_signed
|
72
|
+
env['grip_wscontext'] = wscontext
|
73
|
+
begin
|
74
|
+
status, headers, response = @app.call(env)
|
75
|
+
rescue NonWebSocketRequestError => e
|
76
|
+
return [400, {}, [e.message + "\n"]]
|
77
|
+
end
|
78
|
+
if !env['grip_wscontext'].nil? and status == 200
|
79
|
+
wscontext = env['grip_wscontext']
|
80
|
+
meta_remove = Set.new
|
81
|
+
wscontext.orig_meta.each do |k, v|
|
82
|
+
found = false
|
83
|
+
wscontext.meta.each do |nk, nv|
|
84
|
+
if nk.downcase == k
|
85
|
+
found = true
|
86
|
+
break
|
87
|
+
end
|
88
|
+
end
|
89
|
+
if !found
|
90
|
+
meta_remove.add(k)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
meta_set = {}
|
94
|
+
wscontext.meta.each do |k, v|
|
95
|
+
lname = k.downcase
|
96
|
+
need_set = true
|
97
|
+
wscontext.orig_meta.each do |ok, ov|
|
98
|
+
if lname == ok and v == ov
|
99
|
+
need_set = false
|
100
|
+
break
|
101
|
+
end
|
102
|
+
end
|
103
|
+
if need_set
|
104
|
+
meta_set[lname] = v
|
105
|
+
end
|
106
|
+
end
|
107
|
+
events = []
|
108
|
+
if wscontext.accepted
|
109
|
+
events.push(WebSocketEvent.new('OPEN'))
|
110
|
+
end
|
111
|
+
events.push(*wscontext.out_events)
|
112
|
+
if wscontext.closed
|
113
|
+
events.push(WebSocketEvent.new('CLOSE',
|
114
|
+
[wscontext.out_close_code].pack('S_')))
|
115
|
+
end
|
116
|
+
if response.respond_to?(:content_type)
|
117
|
+
response.body = GripControl.encode_websocket_events(events)
|
118
|
+
response.content_type = 'application/websocket-events'
|
119
|
+
else
|
120
|
+
response = [GripControl.encode_websocket_events(events)]
|
121
|
+
end
|
122
|
+
headers['Content-Type'] = 'application/websocket-events'
|
123
|
+
if wscontext.accepted
|
124
|
+
headers['Sec-WebSocket-Extensions'] = 'grip'
|
125
|
+
end
|
126
|
+
meta_remove.each do |k, v|
|
127
|
+
headers['Set-Meta-' + k] = ''
|
128
|
+
end
|
129
|
+
meta_set.each do |k, v|
|
130
|
+
headers['Set-Meta-' + k] = v
|
131
|
+
end
|
132
|
+
elsif !env['grip_hold'].nil?
|
133
|
+
if !env['grip_proxied'] and RailsSettings.get_grip_proxy_required
|
134
|
+
return [ 501, {}, ["Not implemented.\n"]]
|
135
|
+
end
|
136
|
+
channels = env['grip_channels']
|
137
|
+
prefix = RailsSettings.get_prefix
|
138
|
+
if prefix != ''
|
139
|
+
channels.each do |channel|
|
140
|
+
channel.name = prefix + channel.name
|
141
|
+
end
|
142
|
+
end
|
143
|
+
if status == 304
|
144
|
+
iheaders = headers.clone
|
145
|
+
if !iheaders.key?('Location') and response.respond_to?(:location) and
|
146
|
+
!response.location.nil?
|
147
|
+
iheaders['Location'] = response.location
|
148
|
+
end
|
149
|
+
if response.respond_to?(:body)
|
150
|
+
orig_body = response.body
|
151
|
+
else
|
152
|
+
orig_body = response.to_s
|
153
|
+
end
|
154
|
+
iresponse = Response.new(status, nil, iheaders, orig_body)
|
155
|
+
timeout = nil
|
156
|
+
if !env['grip_timeout'].nil?
|
157
|
+
timeout = env['grip_timeout']
|
158
|
+
end
|
159
|
+
if response.respond_to?(:content_type)
|
160
|
+
response.body = GripControl.create_hold(env['grip_hold'],
|
161
|
+
channels, iresponse, timeout)
|
162
|
+
response.content_type = 'application/grip-instruct'
|
163
|
+
else
|
164
|
+
response = [GripControl.create_hold(env['grip_hold'],
|
165
|
+
channels, iresponse, timeout)]
|
166
|
+
end
|
167
|
+
headers = {'Content-Type' => 'application/grip-instruct'}
|
168
|
+
else
|
169
|
+
headers['Grip-Hold'] = env['grip_hold']
|
170
|
+
headers['Grip-Channel'] = GripControl.create_grip_channel_header(
|
171
|
+
channels)
|
172
|
+
if !env['grip_timeout'].nil?
|
173
|
+
headers['Grip-Timeout'] = env['grip_timeout'].to_s
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
177
|
+
return [status, headers, response]
|
178
|
+
end
|
179
|
+
|
180
|
+
private
|
181
|
+
|
182
|
+
def convert_header_name(name)
|
183
|
+
out = ''
|
184
|
+
name.each_char do |c|
|
185
|
+
if c == '_'
|
186
|
+
out += '-'
|
187
|
+
else
|
188
|
+
out += c.downcase
|
189
|
+
end
|
190
|
+
end
|
191
|
+
return out
|
192
|
+
end
|
193
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
# nonwebsocketrequesterror.rb
|
2
|
+
# ~~~~~~~~~
|
3
|
+
# This module implements the NonWebSocketRequestError class.
|
4
|
+
# :authors: Konstantin Bokarius.
|
5
|
+
# :copyright: (c) 2015 by Fanout, Inc.
|
6
|
+
# :license: MIT, see LICENSE for more details.
|
7
|
+
|
8
|
+
class NonWebSocketRequestError < StandardError
|
9
|
+
def message
|
10
|
+
"This endpoint only allows WebSocket requests."
|
11
|
+
end
|
12
|
+
end
|
data/lib/rails_grip.rb
ADDED
@@ -0,0 +1,92 @@
|
|
1
|
+
# rails_grip.rb
|
2
|
+
# ~~~~~~~~~
|
3
|
+
# This module implements the RailsGrip class.
|
4
|
+
# :authors: Konstantin Bokarius.
|
5
|
+
# :copyright: (c) 2015 by Fanout, Inc.
|
6
|
+
# :license: MIT, see LICENSE for more details.
|
7
|
+
|
8
|
+
require 'pubcontrol'
|
9
|
+
require 'gripcontrol'
|
10
|
+
|
11
|
+
require_relative 'railssettings.rb'
|
12
|
+
require_relative 'gripmiddleware.rb'
|
13
|
+
|
14
|
+
class RailsGrip
|
15
|
+
def self.publish(channel, formats, id=nil, prev_id=nil)
|
16
|
+
pub = RailsGrip.get_pubcontrol
|
17
|
+
pub.publish(RailsSettings.get_prefix + channel, Item.new(
|
18
|
+
formats, id,prev_id))
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.publish_async(channel, formats, id=nil, prev_id=nil, callback=nil)
|
22
|
+
pub = RailsGrip.get_pubcontrol
|
23
|
+
pub.publish_async(RailsSettings.get_prefix + channel,
|
24
|
+
Item.new(formats, id, prev_id), callback)
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.set_hold_longpoll(request, channels, timeout=nil)
|
28
|
+
request.env['grip_hold'] = 'response'
|
29
|
+
request.env['grip_channels'] = RailsGrip.convert_channels(channels)
|
30
|
+
request.env['grip_timeout'] = timeout
|
31
|
+
end
|
32
|
+
|
33
|
+
def self.set_hold_stream(request, channels)
|
34
|
+
request.env['grip_hold'] = 'stream'
|
35
|
+
request.env['grip_channels'] = RailsGrip.convert_channels(channels)
|
36
|
+
end
|
37
|
+
|
38
|
+
def self.is_grip_proxied(request)
|
39
|
+
if request.env.key?('grip_proxied')
|
40
|
+
return request.env['grip_proxied']
|
41
|
+
end
|
42
|
+
return false
|
43
|
+
end
|
44
|
+
|
45
|
+
def self.get_wscontext(request)
|
46
|
+
if request.env.key?('grip_wscontext')
|
47
|
+
return request.env['grip_wscontext']
|
48
|
+
end
|
49
|
+
return nil
|
50
|
+
end
|
51
|
+
|
52
|
+
def self.verify_is_websocket(request)
|
53
|
+
if !RailsGrip.get_wscontext(request)
|
54
|
+
raise NonWebSocketRequestError
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
private
|
59
|
+
|
60
|
+
def self.get_pubcontrol
|
61
|
+
if Thread.current['pubcontrol'].nil?
|
62
|
+
pub = GripPubControl.new
|
63
|
+
grip_proxies = RailsSettings.get_grip_proxies
|
64
|
+
publish_servers = RailsSettings.get_publish_servers
|
65
|
+
if !grip_proxies.nil?
|
66
|
+
pub.apply_grip_config(grip_proxies)
|
67
|
+
end
|
68
|
+
if !publish_servers.nil?
|
69
|
+
pub.apply_config(publish_servers)
|
70
|
+
end
|
71
|
+
at_exit { pub.finish }
|
72
|
+
Thread.current['pubcontrol'] = pub
|
73
|
+
end
|
74
|
+
return Thread.current['pubcontrol']
|
75
|
+
end
|
76
|
+
|
77
|
+
def self.convert_channels(channels)
|
78
|
+
if channels.is_a?(Channel) or channels.is_a?(String)
|
79
|
+
channels = [channels]
|
80
|
+
end
|
81
|
+
out = []
|
82
|
+
channels.each do |channel|
|
83
|
+
if channel.is_a?(String)
|
84
|
+
channel = Channel.new(channel)
|
85
|
+
end
|
86
|
+
out.push(channel)
|
87
|
+
end
|
88
|
+
return out
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
require_relative 'railtie.rb' if defined? Rails::Railtie
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# railssettings.rb
|
2
|
+
# ~~~~~~~~~
|
3
|
+
# This module implements the RailsSettings class.
|
4
|
+
# :authors: Konstantin Bokarius.
|
5
|
+
# :copyright: (c) 2015 by Fanout, Inc.
|
6
|
+
# :license: MIT, see LICENSE for more details.
|
7
|
+
|
8
|
+
class RailsSettings
|
9
|
+
def self.get_prefix
|
10
|
+
if Rails.application.config.respond_to?(:grip_prefix)
|
11
|
+
return Rails.application.config.grip_prefix
|
12
|
+
end
|
13
|
+
return ''
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.get_grip_proxies
|
17
|
+
if Rails.application.config.respond_to?(:grip_proxies)
|
18
|
+
return Rails.application.config.grip_proxies
|
19
|
+
end
|
20
|
+
return nil
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.get_publish_servers
|
24
|
+
if Rails.application.config.respond_to?(:publish_servers)
|
25
|
+
return Rails.application.config.publish_servers
|
26
|
+
end
|
27
|
+
return nil
|
28
|
+
end
|
29
|
+
|
30
|
+
def self.get_grip_proxy_required
|
31
|
+
if Rails.application.config.respond_to?(:grip_proxy_required)
|
32
|
+
return Rails.application.config.grip_proxy_required
|
33
|
+
end
|
34
|
+
return false
|
35
|
+
end
|
36
|
+
end
|
data/lib/railtie.rb
ADDED
@@ -0,0 +1,12 @@
|
|
1
|
+
# railtie.rb
|
2
|
+
# ~~~~~~~~~
|
3
|
+
# This module implements the Railtie class.
|
4
|
+
# :authors: Konstantin Bokarius.
|
5
|
+
# :copyright: (c) 2015 by Fanout, Inc.
|
6
|
+
# :license: MIT, see LICENSE for more details.
|
7
|
+
|
8
|
+
class Railtie < Rails::Railtie
|
9
|
+
initializer "rails_grip.configure_rails_initialization" do
|
10
|
+
Rails.application.middleware.use GripMiddleware
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,107 @@
|
|
1
|
+
# websocketcontext.rb
|
2
|
+
# ~~~~~~~~~
|
3
|
+
# This module implements the WebSocketContext class.
|
4
|
+
# :authors: Konstantin Bokarius.
|
5
|
+
# :copyright: (c) 2015 by Fanout, Inc.
|
6
|
+
# :license: MIT, see LICENSE for more details.
|
7
|
+
|
8
|
+
require 'gripcontrol'
|
9
|
+
|
10
|
+
class WebSocketContext
|
11
|
+
attr_accessor :orig_meta
|
12
|
+
attr_accessor :meta
|
13
|
+
attr_accessor :accepted
|
14
|
+
attr_accessor :out_events
|
15
|
+
attr_accessor :closed
|
16
|
+
attr_accessor :out_close_code
|
17
|
+
attr_accessor :close_code
|
18
|
+
|
19
|
+
def initialize(id, meta, in_events)
|
20
|
+
@id = id
|
21
|
+
@in_events = in_events
|
22
|
+
@read_index = 0
|
23
|
+
@accepted = false
|
24
|
+
@close_code = nil
|
25
|
+
@closed = false
|
26
|
+
@out_close_code = nil
|
27
|
+
@out_events = []
|
28
|
+
@orig_meta = meta
|
29
|
+
@meta = Marshal.load(Marshal.dump(meta))
|
30
|
+
end
|
31
|
+
|
32
|
+
def is_opening
|
33
|
+
return (!@in_events.nil? and @in_events.length > 0 and
|
34
|
+
@in_events[0].type == 'OPEN')
|
35
|
+
end
|
36
|
+
|
37
|
+
def accept
|
38
|
+
@accepted = true
|
39
|
+
end
|
40
|
+
|
41
|
+
def close(code=nil)
|
42
|
+
@closed = true
|
43
|
+
if !code.nil?
|
44
|
+
@out_close_code = code
|
45
|
+
else
|
46
|
+
@out_close_code = 0
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def can_recv
|
51
|
+
for n in @read_index..@in_events.length-1 do
|
52
|
+
if ['TEXT', 'BINARY', 'CLOSE', 'DISCONNECT'].include?(
|
53
|
+
@in_events[n].type)
|
54
|
+
return true
|
55
|
+
end
|
56
|
+
end
|
57
|
+
return false
|
58
|
+
end
|
59
|
+
|
60
|
+
def recv
|
61
|
+
e = nil
|
62
|
+
while e.nil? and @read_index < @in_events.length do
|
63
|
+
if ['TEXT', 'BINARY', 'CLOSE', 'DISCONNECT'].include?(
|
64
|
+
@in_events[@read_index].type)
|
65
|
+
e = @in_events[@read_index]
|
66
|
+
elsif @in_events[@read_index].type == 'PING'
|
67
|
+
@out_events.push(WebSocketEvent.new('PONG'))
|
68
|
+
end
|
69
|
+
@read_index += 1
|
70
|
+
end
|
71
|
+
if e.nil?
|
72
|
+
raise 'read from empty buffer'
|
73
|
+
end
|
74
|
+
if e.type == 'TEXT' or e.type == 'BINARY'
|
75
|
+
return e.content
|
76
|
+
elsif e.type == 'CLOSE'
|
77
|
+
if !e.content.nil? and e.content.length == 2
|
78
|
+
@close_code = e.content.unpack('S_')[0]
|
79
|
+
end
|
80
|
+
return nil
|
81
|
+
else
|
82
|
+
raise 'client disconnected unexpectedly'
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def send(message)
|
87
|
+
@out_events.push(WebSocketEvent.new('TEXT', 'm:' + message))
|
88
|
+
end
|
89
|
+
|
90
|
+
def send_control(message)
|
91
|
+
@out_events.push(WebSocketEvent.new('TEXT', 'c:' + message))
|
92
|
+
end
|
93
|
+
|
94
|
+
def subscribe(channel)
|
95
|
+
send_control(GripControl.websocket_control_message(
|
96
|
+
RailsSettings.get_prefix + 'subscribe', {'channel' => channel}))
|
97
|
+
end
|
98
|
+
|
99
|
+
def unsubscribe(channel)
|
100
|
+
send_control(GripControl.websocket_control_message(
|
101
|
+
RailsSettings.get_prefix + 'unsubscribe', {'channel' => channel}))
|
102
|
+
end
|
103
|
+
|
104
|
+
def detach(channel)
|
105
|
+
send_control(GripControl.websocket_control_message('detach'))
|
106
|
+
end
|
107
|
+
end
|
metadata
ADDED
@@ -0,0 +1,63 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: rails_grip
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Konstantin Bokarius
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2015-02-07 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: gripcontrol
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '1'
|
27
|
+
description: A Ruby on Rails convenience library for working with GRIP proxies.
|
28
|
+
email: bokarius@comcast.net
|
29
|
+
executables: []
|
30
|
+
extensions: []
|
31
|
+
extra_rdoc_files: []
|
32
|
+
files:
|
33
|
+
- lib/gripmiddleware.rb
|
34
|
+
- lib/nonwebsocketrequesterror.rb
|
35
|
+
- lib/rails_grip.rb
|
36
|
+
- lib/railssettings.rb
|
37
|
+
- lib/railtie.rb
|
38
|
+
- lib/websocketcontext.rb
|
39
|
+
homepage: https://github.com/fanout/rails-grip
|
40
|
+
licenses:
|
41
|
+
- MIT
|
42
|
+
metadata: {}
|
43
|
+
post_install_message:
|
44
|
+
rdoc_options: []
|
45
|
+
require_paths:
|
46
|
+
- lib
|
47
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
48
|
+
requirements:
|
49
|
+
- - ">="
|
50
|
+
- !ruby/object:Gem::Version
|
51
|
+
version: 1.9.0
|
52
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
53
|
+
requirements:
|
54
|
+
- - ">="
|
55
|
+
- !ruby/object:Gem::Version
|
56
|
+
version: '0'
|
57
|
+
requirements: []
|
58
|
+
rubyforge_project:
|
59
|
+
rubygems_version: 2.2.2
|
60
|
+
signing_key:
|
61
|
+
specification_version: 4
|
62
|
+
summary: GRIP library for Ruby on Rails
|
63
|
+
test_files: []
|