rails_grip 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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: []
|