rackful 0.1.4 → 0.2.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/CHANGES.md +18 -8
- data/RACKFUL.md +46 -34
- data/README.md +11 -5
- data/example/config.ru +19 -25
- data/lib/rackful.rb +11 -2
- data/lib/rackful/httpstatus.rb +250 -0
- data/lib/rackful/middleware.rb +2 -3
- data/lib/rackful/middleware/headerspoofing.rb +49 -0
- data/lib/rackful/middleware/methodoverride.rb +136 -0
- data/lib/rackful/parser.rb +315 -0
- data/lib/rackful/request.rb +103 -53
- data/lib/rackful/resource.rb +158 -221
- data/lib/rackful/serializer.rb +133 -215
- data/lib/rackful/server.rb +76 -86
- data/lib/rackful/uri.rb +150 -0
- data/mkdoc.sh +4 -2
- data/rackful.gemspec +6 -5
- metadata +66 -58
- data/lib/rackful/http_status.rb +0 -285
- data/lib/rackful/middleware/header_spoofing.rb +0 -72
- data/lib/rackful/middleware/method_spoofing.rb +0 -101
- data/lib/rackful/middleware/relative_location.rb +0 -71
- data/lib/rackful/path.rb +0 -179
data/lib/rackful/middleware.rb
CHANGED
@@ -1,3 +1,2 @@
|
|
1
|
-
require 'rackful/middleware/
|
2
|
-
require 'rackful/middleware/
|
3
|
-
require 'rackful/middleware/relative_location.rb'
|
1
|
+
require 'rackful/middleware/headerspoofing.rb'
|
2
|
+
require 'rackful/middleware/methodoverride.rb'
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
# Required for parsing:
|
3
|
+
require 'rackful'
|
4
|
+
|
5
|
+
# Required for running:
|
6
|
+
|
7
|
+
|
8
|
+
# Rack middleware that provides header spoofing.
|
9
|
+
#
|
10
|
+
# If you use this middleware, then clients are allowed to spoof an HTTP header
|
11
|
+
# by specifying a `_http_SOME_HEADER=...` request parameter, for example
|
12
|
+
# `http://example.com/some_resource?_http_DEPTH=infinity`.
|
13
|
+
#
|
14
|
+
# This can be useful if you want to specify certain request headers from within
|
15
|
+
# a normal web browser.
|
16
|
+
#
|
17
|
+
# This middleware won’t work well together with Digest Authentication.
|
18
|
+
# @example Using this middleware
|
19
|
+
# require 'rackful/middleware/header_spoofing'
|
20
|
+
# use Rackful::HeaderSpoofing
|
21
|
+
class Rackful::HeaderSpoofing
|
22
|
+
|
23
|
+
def initialize app
|
24
|
+
@app = app
|
25
|
+
end
|
26
|
+
|
27
|
+
def call env
|
28
|
+
new_query_string = env['QUERY_STRING'].
|
29
|
+
split('&', -1).
|
30
|
+
select {
|
31
|
+
|p|
|
32
|
+
p = p.split('=', 2)
|
33
|
+
if /\A_http_([a-z]+(?:[\-_][a-z]+)*)\z/i === p[0]
|
34
|
+
header_name = p[0].gsub('-', '_').upcase[1..-1]
|
35
|
+
env[header_name] = p[1]
|
36
|
+
false
|
37
|
+
else
|
38
|
+
true
|
39
|
+
end
|
40
|
+
}.
|
41
|
+
join('&')
|
42
|
+
if env['QUERY_STRING'] != new_query_string
|
43
|
+
env['rackful.header_spoofing.QUERY_STRING'] = env['QUERY_STRING']
|
44
|
+
env['QUERY_STRING'] = new_query_string
|
45
|
+
end
|
46
|
+
@app.call env
|
47
|
+
end
|
48
|
+
|
49
|
+
end # Rackful::HeaderSpoofing
|
@@ -0,0 +1,136 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
# Required for parsing:
|
3
|
+
require 'rackful'
|
4
|
+
|
5
|
+
# Required for running:
|
6
|
+
require 'set'
|
7
|
+
|
8
|
+
|
9
|
+
# Middleware that provides method spoofing, like {Rack::MethodOverride}.
|
10
|
+
#
|
11
|
+
# If you use this middleware, then clients are allowed to spoof an HTTP method
|
12
|
+
# by specifying a `_method=...` request parameter, for example
|
13
|
+
# `http://example.com/some_resource?_method=DELETE`.
|
14
|
+
#
|
15
|
+
# This can be useful if you want to perform `PUT` and `DELETE` requests from
|
16
|
+
# within a browser, of when you want to perform a `GET` requests with (too)
|
17
|
+
# many parameters, exceeding the maximum URI length in your client or server.
|
18
|
+
# In that case, you can put the parameters in a `POST` body, like this:
|
19
|
+
#
|
20
|
+
# POST /some_resource HTTP/1.1
|
21
|
+
# Host: example.com
|
22
|
+
# Content-Type: application/x-www-form-urlencoded
|
23
|
+
# Content-Length: 123456789
|
24
|
+
#
|
25
|
+
# param_1=hello¶m_2=world¶m_3=...
|
26
|
+
#
|
27
|
+
# Caveats:
|
28
|
+
#
|
29
|
+
# * this middleware won’t work well together with Digest Authentication.
|
30
|
+
# * When a `POST` request is converted to a `GET` request, the entire request
|
31
|
+
# body is loaded into memory, which creates an attack surface for
|
32
|
+
# DoS-attacks. Hence, the maximum request body size is limited (see
|
33
|
+
# {POST_TO_GET_REQUEST_BODY_MAX_SIZE} and {#initialize}). You should choose this
|
34
|
+
# limit carefully, and/or include this middleware *after* your security
|
35
|
+
# middlewares.
|
36
|
+
#
|
37
|
+
# Improvements over Rack::MethodOverride (v1.5.2):
|
38
|
+
#
|
39
|
+
# * Rack::MethodOverride requires the original method to be `POST`. We allow
|
40
|
+
# the following overrides (`ORIGINAL_METHOD` → `OVERRIDE_WITH`):
|
41
|
+
# * `GET` → `DELETE`, `HEAD` and `OPTIONS`
|
42
|
+
# * `POST` → `GET`, `PATCH` and `PUT`
|
43
|
+
# * Rack::MethodOverride doesn’t touch `env['QUERY_STRING']`. We remove
|
44
|
+
# parameter `_method` if it was handled (but still leave it there if it
|
45
|
+
# wasn’t handled for some reason).
|
46
|
+
# * Rackful::MethodOverride is documented ;-)
|
47
|
+
# @example Using this middleware
|
48
|
+
# require 'rackful/middleware/method_override'
|
49
|
+
# use Rackful::MethodOverride
|
50
|
+
class Rackful::MethodOverride
|
51
|
+
|
52
|
+
METHOD_OVERRIDE_PARAM_KEY = '_method'.freeze
|
53
|
+
|
54
|
+
POST_TO_GET_REQUEST_BODY_MAX_SIZE = 1024 * 1024
|
55
|
+
|
56
|
+
ALLOWED_OVERRIDES = {
|
57
|
+
'GET'.freeze => [ 'DELETE', 'HEAD', 'OPTIONS' ].to_set.freeze,
|
58
|
+
'POST'.freeze => [ 'PATCH', 'PUT' ].to_set.freeze
|
59
|
+
}.freeze
|
60
|
+
|
61
|
+
# Constructor.
|
62
|
+
# @param app [#call]
|
63
|
+
# @param options [Hash{Symbol => Mixed}] Configuration options. The following
|
64
|
+
# options are supported:
|
65
|
+
# * **`:max_size`** the maximum size (in bytes) of the request body of a
|
66
|
+
# `POST` request that is converted to a `GET` request.
|
67
|
+
def initialize( app, options = {} )
|
68
|
+
@app = app
|
69
|
+
@max_size = options[:max_size]
|
70
|
+
end
|
71
|
+
|
72
|
+
|
73
|
+
def call env
|
74
|
+
before_call env
|
75
|
+
@app.call env
|
76
|
+
end
|
77
|
+
|
78
|
+
private
|
79
|
+
|
80
|
+
|
81
|
+
def before_call env
|
82
|
+
return unless ['GET', 'POST'].include? env['REQUEST_METHOD']
|
83
|
+
new_method = nil
|
84
|
+
new_query_string = env['QUERY_STRING'].
|
85
|
+
split('&', -1).
|
86
|
+
select { |p|
|
87
|
+
p = p.split('=', 2)
|
88
|
+
if new_method.nil? && METHOD_OVERRIDE_PARAM_KEY == p[0]
|
89
|
+
new_method = p[1].upcase
|
90
|
+
false
|
91
|
+
else
|
92
|
+
true
|
93
|
+
end
|
94
|
+
}.
|
95
|
+
join('&')
|
96
|
+
if new_method
|
97
|
+
if 'GET' == new_method &&
|
98
|
+
'POST' == env['REQUEST_METHOD']
|
99
|
+
raise HTTP415
|
100
|
+
'application/x-www-form-urlencoded' == env['CONTENT_TYPE'] &&
|
101
|
+
env['CONTENT_LENGTH'].to_i <= @max_size
|
102
|
+
if env.key?('rack.input')
|
103
|
+
new_query_string += '&' unless new_query_string.empty?
|
104
|
+
new_query_string += env['rack.input'].read
|
105
|
+
if env['rack.input'].respond_to?( :rewind )
|
106
|
+
env['rack.input'].rewind
|
107
|
+
env['rackful.method_override.input'] = env['rack.input']
|
108
|
+
end
|
109
|
+
env.delete 'rack.input'
|
110
|
+
end
|
111
|
+
env.delete 'CONTENT_TYPE'
|
112
|
+
env.delete 'CONTENT_LENGTH'
|
113
|
+
update_env( env, new_method, new_query_string )
|
114
|
+
elsif ALLOWED_OVERRIDES[env['REQUEST_METHOD']].include?( new_method )
|
115
|
+
update_env( env, new_method )
|
116
|
+
elsif logger = env['rack.logger']
|
117
|
+
logger.warn('Rackful::MethodOverride') {
|
118
|
+
"Client tried to override request method #{env['REQUEST_METHOD']} with #{new_method} (ignored)."
|
119
|
+
}
|
120
|
+
else
|
121
|
+
STDERR << "warning: Client tried to override request method #{env['REQUEST_METHOD']} with #{new_method} (ignored).\n"
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
|
127
|
+
def update_env env, new_method, new_query_string = nil
|
128
|
+
unless new_query_string.nil?
|
129
|
+
env['rackful.method_override.QUERY_STRING'] = env['QUERY_STRING']
|
130
|
+
env['QUERY_STRING'] = new_query_string
|
131
|
+
end
|
132
|
+
env['rackful.method_override.REQUEST_METHOD'] = env['REQUEST_METHOD']
|
133
|
+
env['REQUEST_METHOD'] = new_method
|
134
|
+
end
|
135
|
+
|
136
|
+
end # Rackful::MethodOverride
|
@@ -0,0 +1,315 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
|
4
|
+
module Rackful
|
5
|
+
|
6
|
+
|
7
|
+
=begin markdown
|
8
|
+
Base class for all parsers.
|
9
|
+
@abstract Subclasses must implement method `#parse`, and define constant
|
10
|
+
{MEDIA_TYPES} as an array of media types this parser accepts.
|
11
|
+
@example Subclassing this class
|
12
|
+
class MyTextParser < Rackful::Parser
|
13
|
+
MEDIA_TYPES = [ 'text/plain' ]
|
14
|
+
def parse
|
15
|
+
# YOUR CODE HERE...
|
16
|
+
end
|
17
|
+
end
|
18
|
+
=end
|
19
|
+
class Parser
|
20
|
+
|
21
|
+
|
22
|
+
=begin markdown
|
23
|
+
An array of media type strings.
|
24
|
+
@!parse MEDIA_TYPES = [ 'example/type1', 'example/type2' ]
|
25
|
+
=end
|
26
|
+
|
27
|
+
|
28
|
+
# @return [Request]
|
29
|
+
attr_reader :request
|
30
|
+
# @return [Resource]
|
31
|
+
attr_reader :resource
|
32
|
+
|
33
|
+
|
34
|
+
=begin markdown
|
35
|
+
@param request [Request]
|
36
|
+
@param resource [Resource]
|
37
|
+
=end
|
38
|
+
def initialize request, resource
|
39
|
+
@request, @resource = request, resource
|
40
|
+
end
|
41
|
+
|
42
|
+
|
43
|
+
end # class Parser
|
44
|
+
|
45
|
+
|
46
|
+
=begin markdown
|
47
|
+
Parent class of all XML-parsing parsers.
|
48
|
+
@abstract
|
49
|
+
@since 0.2.0
|
50
|
+
=end
|
51
|
+
class Parser::DOM < Parser
|
52
|
+
|
53
|
+
|
54
|
+
=begin markdown
|
55
|
+
The media types parsed by this parser.
|
56
|
+
@see Parser
|
57
|
+
=end
|
58
|
+
MEDIA_TYPES = [
|
59
|
+
'text/xml',
|
60
|
+
'application/xml'
|
61
|
+
]
|
62
|
+
|
63
|
+
|
64
|
+
=begin markdown
|
65
|
+
@return [Nokogiri::XML::Document]
|
66
|
+
=end
|
67
|
+
attr_reader :document
|
68
|
+
|
69
|
+
|
70
|
+
=begin markdown
|
71
|
+
@raise [HTTP400BadRequest] if the document is malformed.
|
72
|
+
=end
|
73
|
+
def initialize request, resource
|
74
|
+
super request, resource
|
75
|
+
# TODO Is ISO-8859-1 indeed the default encoding for XML documents? If so,
|
76
|
+
# that fact must be documented and referenced.
|
77
|
+
encoding = self.request.media_type_params['charset'] || 'ISO-8859-1'
|
78
|
+
begin
|
79
|
+
@document = Nokogiri.XML(
|
80
|
+
self.request.env['rack.input'].read,
|
81
|
+
self.request.canonical_uri.to_s,
|
82
|
+
encoding
|
83
|
+
) do |config|
|
84
|
+
config.strict.nonet
|
85
|
+
end
|
86
|
+
rescue
|
87
|
+
raise HTTP400BadRequest, $!.to_s
|
88
|
+
end
|
89
|
+
raise( HTTP400BadRequest, $!.to_s ) unless @document.root
|
90
|
+
end
|
91
|
+
|
92
|
+
|
93
|
+
end # class Parser::DOM
|
94
|
+
|
95
|
+
|
96
|
+
=begin markdown
|
97
|
+
Parses XHTML as generated by {Serializer::XHTML}.
|
98
|
+
=end
|
99
|
+
class Parser::XHTML < Parser::DOM
|
100
|
+
|
101
|
+
|
102
|
+
=begin markdown
|
103
|
+
The media types parsed by this parser.
|
104
|
+
@see Parser
|
105
|
+
=end
|
106
|
+
MEDIA_TYPES = Parser::DOM::MEDIA_TYPES + [
|
107
|
+
'application/xhtml+xml',
|
108
|
+
'text/html'
|
109
|
+
]
|
110
|
+
|
111
|
+
|
112
|
+
=begin markdown
|
113
|
+
@see Parser#parse
|
114
|
+
=end
|
115
|
+
def parse
|
116
|
+
# Try to find the actual content:
|
117
|
+
content = self.document.root.xpath(
|
118
|
+
'//html:div[@id="rackful-content"]',
|
119
|
+
'html' => 'http://www.w3.org/1999/xhtml'
|
120
|
+
)
|
121
|
+
# There must be exactly one element <div id="rackful_content"/> in the document:
|
122
|
+
if content.empty?
|
123
|
+
raise HTTP400BadRequest, 'Couldn’t find div#rackful-content in request body.'
|
124
|
+
end
|
125
|
+
if content.length > 1
|
126
|
+
raise HTTP400BadRequest, 'Multiple instances of div#rackful-content found in request body.'
|
127
|
+
end
|
128
|
+
# Initialize @base_url:
|
129
|
+
base_url = self.document.root.xpath(
|
130
|
+
'/html:html/html:head/html:base',
|
131
|
+
'html' => 'http://www.w3.org/1999/xhtml'
|
132
|
+
)
|
133
|
+
if base_url.empty?
|
134
|
+
@base_url = self.request.canonical_uri.dup
|
135
|
+
else
|
136
|
+
@base_url = URI( base_url.first.attribute('href').text ).normalize
|
137
|
+
if @base_url.relative?
|
138
|
+
@base_url = self.request.canonical_uri + @base_url
|
139
|
+
end
|
140
|
+
end
|
141
|
+
# Parse the f*cking thing:
|
142
|
+
self.parse_recursive content.first
|
143
|
+
end
|
144
|
+
|
145
|
+
|
146
|
+
=begin markdown
|
147
|
+
@api private
|
148
|
+
=end
|
149
|
+
def parse_recursive node
|
150
|
+
|
151
|
+
# A URI:
|
152
|
+
if ( nodelist = node.xpath( 'html:a', 'html' => 'http://www.w3.org/1999/xhtml' ) ).length == 1
|
153
|
+
r = URI( nodelist.first.attribute('href').text )
|
154
|
+
r.relative? ? @base_url + r : r
|
155
|
+
|
156
|
+
# An Object (AKA a Hash)
|
157
|
+
elsif ( nodelist = node.xpath( 'html:dl', 'html' => 'http://www.w3.org/1999/xhtml' ) ).length == 1
|
158
|
+
self.parse_object nodelist.first
|
159
|
+
|
160
|
+
# A list of Objects with identical keys:
|
161
|
+
elsif ( nodelist = node.xpath( 'html:table', 'html' => 'http://www.w3.org/1999/xhtml' ) ).length == 1
|
162
|
+
self.parse_object_list nodelist.first
|
163
|
+
|
164
|
+
# A list of things (AKA an Array):
|
165
|
+
elsif ( nodelist = node.xpath( 'html:ul', 'html' => 'http://www.w3.org/1999/xhtml' ) ).length == 1
|
166
|
+
nodelist.first.xpath(
|
167
|
+
'html:li',
|
168
|
+
'html' => 'http://www.w3.org/1999/xhtml'
|
169
|
+
).collect do |n| self.parse_recursive n end
|
170
|
+
|
171
|
+
# A simple type:
|
172
|
+
elsif type = node.attribute_with_ns( 'type', 'http://www.w3.org/2001/XMLSchema' )
|
173
|
+
prefix, typename = type.text.split(':', 2)
|
174
|
+
unless typename && 'http://www.w3.org/2001/XMLSchema' == node.namespaces["xmlns:#{prefix}"]
|
175
|
+
raise HTTP400BadRequest, "Unknown XML Schema type: #{type}"
|
176
|
+
end
|
177
|
+
self.parse_simple_type node, typename
|
178
|
+
else
|
179
|
+
raise HTTP400BadRequest, 'Can’t parse:<br/>' + Rack::Utils.escape_html(node.to_xml)
|
180
|
+
end
|
181
|
+
end
|
182
|
+
|
183
|
+
|
184
|
+
=begin markdown
|
185
|
+
@api private
|
186
|
+
=end
|
187
|
+
def parse_simple_type node, typename
|
188
|
+
case typename
|
189
|
+
when 'boolean'
|
190
|
+
case node.inner_text.strip
|
191
|
+
when 'true' then true
|
192
|
+
when 'false' then false
|
193
|
+
else nil
|
194
|
+
end
|
195
|
+
when 'integer'
|
196
|
+
node.inner_text.strip.to_i
|
197
|
+
when 'numeric'
|
198
|
+
node.inner_text.strip.to_f
|
199
|
+
when 'dateTime'
|
200
|
+
Time.xmlschema(node.inner_text.strip)
|
201
|
+
when 'base64Binary'
|
202
|
+
Base64.decode64(node.inner_text)
|
203
|
+
when 'string'
|
204
|
+
node.inner_text
|
205
|
+
else
|
206
|
+
raise HTTP400BadRequest, "Unknown XML Schema type: #{type}"
|
207
|
+
end
|
208
|
+
end
|
209
|
+
|
210
|
+
|
211
|
+
=begin markdown
|
212
|
+
@api private
|
213
|
+
=end
|
214
|
+
def parse_object node
|
215
|
+
current_property = nil
|
216
|
+
r = {}
|
217
|
+
node.children.each do |child|
|
218
|
+
if 'dt' == child.name &&
|
219
|
+
'http://www.w3.org/1999/xhtml' == child.namespace.href
|
220
|
+
if current_property
|
221
|
+
raise HTTP400BadRequest, 'Can’t parse:<br/>' + Rack::Utils.escape_html(node.to_xml)
|
222
|
+
end
|
223
|
+
current_property = child.inner_text.strip.split(' ').join('_').to_sym
|
224
|
+
elsif 'dd' == child.name &&
|
225
|
+
'http://www.w3.org/1999/xhtml' == child.namespace.href
|
226
|
+
unless current_property
|
227
|
+
raise HTTP400BadRequest, 'Can’t parse:<br/>' + Rack::Utils.escape_html(node.to_xml)
|
228
|
+
end
|
229
|
+
r[current_property] = self.parse_recursive( child )
|
230
|
+
current_property = nil
|
231
|
+
end
|
232
|
+
end
|
233
|
+
r
|
234
|
+
end
|
235
|
+
|
236
|
+
|
237
|
+
=begin markdown
|
238
|
+
@api private
|
239
|
+
=end
|
240
|
+
def parse_object_list node
|
241
|
+
properties = node.xpath(
|
242
|
+
'html:thead/html:tr/html:th',
|
243
|
+
'html' => 'http://www.w3.org/1999/xhtml'
|
244
|
+
).collect do |th|
|
245
|
+
th.inner_text.strip.split(' ').join('_').to_sym
|
246
|
+
end
|
247
|
+
if properties.empty?
|
248
|
+
raise HTTP400BadRequest, 'Can’t parse:<br/>' + Rack::Utils.escape_html(node.to_xml)
|
249
|
+
end
|
250
|
+
n = properties.length
|
251
|
+
node.xpath(
|
252
|
+
'html:tbody/html:tr',
|
253
|
+
'html' => 'http://www.w3.org/1999/xhtml'
|
254
|
+
).collect do |row|
|
255
|
+
values = row.xpath(
|
256
|
+
'html:td', 'html' => 'http://www.w3.org/1999/xhtml'
|
257
|
+
)
|
258
|
+
unless values.length == n
|
259
|
+
raise HTTP400BadRequest, 'Can’t parse:<br/>' + Rack::Utils.escape_html(row.to_xml)
|
260
|
+
end
|
261
|
+
object = {}
|
262
|
+
Range.new(0,n-1).each do |i|
|
263
|
+
object[properties[i]] = self.parse_recursive( values[i] )
|
264
|
+
end
|
265
|
+
object
|
266
|
+
end
|
267
|
+
end
|
268
|
+
|
269
|
+
|
270
|
+
end # class Parser::XHTML
|
271
|
+
|
272
|
+
|
273
|
+
class Parser::JSON < Parser
|
274
|
+
|
275
|
+
|
276
|
+
MEDIA_TYPES = [
|
277
|
+
'application/json',
|
278
|
+
'application/x-json'
|
279
|
+
]
|
280
|
+
|
281
|
+
|
282
|
+
def parse
|
283
|
+
r = ::JSON.parse(
|
284
|
+
self.request.env['rack.input'].read,
|
285
|
+
:symbolize_names => true
|
286
|
+
)
|
287
|
+
self.recursive_datetime_parser r
|
288
|
+
end
|
289
|
+
|
290
|
+
|
291
|
+
def recursive_datetime_parser p
|
292
|
+
if p.kind_of?(String)
|
293
|
+
begin
|
294
|
+
return Time.xmlschema(p)
|
295
|
+
rescue
|
296
|
+
end
|
297
|
+
elsif p.kind_of?(Hash)
|
298
|
+
p.keys.each do
|
299
|
+
|key|
|
300
|
+
p[key] = self.recursive_datetime_parser( p[key] )
|
301
|
+
end
|
302
|
+
elsif p.kind_of?(Array)
|
303
|
+
(0 ... p.size).each do
|
304
|
+
|i|
|
305
|
+
p[i] = self.recursive_datetime_parser( p[i] )
|
306
|
+
end
|
307
|
+
end
|
308
|
+
p
|
309
|
+
end
|
310
|
+
|
311
|
+
|
312
|
+
end # class Parser::JSON
|
313
|
+
|
314
|
+
|
315
|
+
end # module Rackful
|