webmachine 0.1.0 → 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.
- data/Gemfile +11 -3
- data/README.md +55 -27
- data/lib/webmachine/adapters/mongrel.rb +84 -0
- data/lib/webmachine/adapters/webrick.rb +12 -3
- data/lib/webmachine/adapters.rb +1 -7
- data/lib/webmachine/configuration.rb +30 -0
- data/lib/webmachine/decision/conneg.rb +7 -72
- data/lib/webmachine/decision/flow.rb +13 -11
- data/lib/webmachine/decision/fsm.rb +1 -9
- data/lib/webmachine/decision/helpers.rb +27 -7
- data/lib/webmachine/errors.rb +1 -0
- data/lib/webmachine/headers.rb +12 -3
- data/lib/webmachine/locale/en.yml +2 -2
- data/lib/webmachine/media_type.rb +117 -0
- data/lib/webmachine/resource/callbacks.rb +9 -0
- data/lib/webmachine/streaming.rb +3 -3
- data/lib/webmachine/version.rb +1 -1
- data/lib/webmachine.rb +3 -1
- data/pkg/webmachine-0.1.0/Gemfile +16 -0
- data/pkg/webmachine-0.1.0/Guardfile +11 -0
- data/pkg/webmachine-0.1.0/README.md +90 -0
- data/pkg/webmachine-0.1.0/Rakefile +31 -0
- data/pkg/webmachine-0.1.0/examples/webrick.rb +19 -0
- data/pkg/webmachine-0.1.0/lib/webmachine/adapters/webrick.rb +74 -0
- data/pkg/webmachine-0.1.0/lib/webmachine/adapters.rb +15 -0
- data/pkg/webmachine-0.1.0/lib/webmachine/decision/conneg.rb +304 -0
- data/pkg/webmachine-0.1.0/lib/webmachine/decision/flow.rb +502 -0
- data/pkg/webmachine-0.1.0/lib/webmachine/decision/fsm.rb +79 -0
- data/pkg/webmachine-0.1.0/lib/webmachine/decision/helpers.rb +80 -0
- data/pkg/webmachine-0.1.0/lib/webmachine/decision.rb +12 -0
- data/pkg/webmachine-0.1.0/lib/webmachine/dispatcher/route.rb +85 -0
- data/pkg/webmachine-0.1.0/lib/webmachine/dispatcher.rb +40 -0
- data/pkg/webmachine-0.1.0/lib/webmachine/errors.rb +37 -0
- data/pkg/webmachine-0.1.0/lib/webmachine/headers.rb +16 -0
- data/pkg/webmachine-0.1.0/lib/webmachine/locale/en.yml +28 -0
- data/pkg/webmachine-0.1.0/lib/webmachine/request.rb +56 -0
- data/pkg/webmachine-0.1.0/lib/webmachine/resource/callbacks.rb +362 -0
- data/pkg/webmachine-0.1.0/lib/webmachine/resource/encodings.rb +36 -0
- data/pkg/webmachine-0.1.0/lib/webmachine/resource.rb +48 -0
- data/pkg/webmachine-0.1.0/lib/webmachine/response.rb +49 -0
- data/pkg/webmachine-0.1.0/lib/webmachine/streaming.rb +27 -0
- data/pkg/webmachine-0.1.0/lib/webmachine/translation.rb +11 -0
- data/pkg/webmachine-0.1.0/lib/webmachine/version.rb +4 -0
- data/pkg/webmachine-0.1.0/lib/webmachine.rb +19 -0
- data/pkg/webmachine-0.1.0/spec/spec_helper.rb +13 -0
- data/pkg/webmachine-0.1.0/spec/tests.org +57 -0
- data/pkg/webmachine-0.1.0/spec/webmachine/decision/conneg_spec.rb +152 -0
- data/pkg/webmachine-0.1.0/spec/webmachine/decision/flow_spec.rb +1030 -0
- data/pkg/webmachine-0.1.0/spec/webmachine/dispatcher/route_spec.rb +109 -0
- data/pkg/webmachine-0.1.0/spec/webmachine/dispatcher_spec.rb +34 -0
- data/pkg/webmachine-0.1.0/spec/webmachine/headers_spec.rb +19 -0
- data/pkg/webmachine-0.1.0/spec/webmachine/request_spec.rb +24 -0
- data/pkg/webmachine-0.1.0/webmachine.gemspec +44 -0
- data/pkg/webmachine-0.1.0.gem +0 -0
- data/spec/webmachine/configuration_spec.rb +27 -0
- data/spec/webmachine/decision/conneg_spec.rb +18 -11
- data/spec/webmachine/decision/flow_spec.rb +2 -0
- data/spec/webmachine/decision/helpers_spec.rb +105 -0
- data/spec/webmachine/errors_spec.rb +13 -0
- data/spec/webmachine/headers_spec.rb +2 -1
- data/spec/webmachine/media_type_spec.rb +78 -0
- data/webmachine.gemspec +4 -1
- metadata +69 -11
@@ -0,0 +1,79 @@
|
|
1
|
+
require 'webmachine/decision/helpers'
|
2
|
+
require 'webmachine/decision/fsm'
|
3
|
+
require 'webmachine/translation'
|
4
|
+
|
5
|
+
module Webmachine
|
6
|
+
module Decision
|
7
|
+
# Implements the finite-state machine described by the Webmachine
|
8
|
+
# sequence diagram.
|
9
|
+
class FSM
|
10
|
+
include Flow
|
11
|
+
include Helpers
|
12
|
+
include Translation
|
13
|
+
|
14
|
+
attr_reader :resource, :request, :response, :metadata
|
15
|
+
|
16
|
+
def initialize(resource, request, response)
|
17
|
+
@resource, @request, @response = resource, request, response
|
18
|
+
@metadata = {}
|
19
|
+
end
|
20
|
+
|
21
|
+
# Processes the request, iteratively invoking the decision methods in {Flow}.
|
22
|
+
def run
|
23
|
+
begin
|
24
|
+
state = Flow::START
|
25
|
+
loop do
|
26
|
+
response.trace << state
|
27
|
+
result = send(state)
|
28
|
+
case result
|
29
|
+
when Fixnum # Response code
|
30
|
+
respond(result)
|
31
|
+
break
|
32
|
+
when Symbol # Next state
|
33
|
+
state = result
|
34
|
+
else # You bwoke it
|
35
|
+
raise InvalidResource, t('fsm_broke', :state => state, :result => result.inspect)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
rescue MalformedRequest => malformed
|
39
|
+
Webmachine.render_error(400, request, response, :message => malformed.message)
|
40
|
+
respond(400)
|
41
|
+
rescue => e # Handle all exceptions without crashing the server
|
42
|
+
error_response(e, state)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
private
|
47
|
+
def respond(code, headers={})
|
48
|
+
response.headers.merge!(headers)
|
49
|
+
end_time = Time.now
|
50
|
+
case code
|
51
|
+
when 404
|
52
|
+
Webmachine.render_error(code, request, response)
|
53
|
+
when 304
|
54
|
+
response.headers.delete('Content-Type')
|
55
|
+
if etag = resource.generate_etag
|
56
|
+
response.headers['ETag'] = ensure_quoted_header(etag)
|
57
|
+
end
|
58
|
+
if expires = resource.expires
|
59
|
+
response.headers['Expires'] = expires.httpdate
|
60
|
+
end
|
61
|
+
if modified = resource.last_modified
|
62
|
+
response.headers['Last-Modified'] = modified.httpdate
|
63
|
+
end
|
64
|
+
end
|
65
|
+
response.code = code
|
66
|
+
resource.finish_request
|
67
|
+
# TODO: add logging/tracing
|
68
|
+
end
|
69
|
+
|
70
|
+
# Renders a 500 error by capturing the exception information.
|
71
|
+
def error_response(exception, state)
|
72
|
+
response.error = [exception.message, exception.backtrace].flatten.join("\n ")
|
73
|
+
response.end_state = state
|
74
|
+
Webmachine.render_error(500, request, response)
|
75
|
+
respond(500)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
require 'webmachine/streaming'
|
2
|
+
module Webmachine
|
3
|
+
module Decision
|
4
|
+
# Methods that assist the Decision {Flow}.
|
5
|
+
module Helpers
|
6
|
+
QUOTED = /^"(.*)"$/
|
7
|
+
|
8
|
+
# Determines if the response has a body/entity set.
|
9
|
+
def has_response_body?
|
10
|
+
!response.body.nil? && !response.body.empty?
|
11
|
+
end
|
12
|
+
|
13
|
+
# If the response body exists, encode it.
|
14
|
+
# @see #encode_body
|
15
|
+
def encode_body_if_set
|
16
|
+
encode_body if has_response_body?
|
17
|
+
end
|
18
|
+
|
19
|
+
# Encodes the body in the selected charset and encoding.
|
20
|
+
def encode_body
|
21
|
+
body = response.body
|
22
|
+
chosen_charset = metadata['Charset']
|
23
|
+
chosen_encoding = metadata['Content-Encoding']
|
24
|
+
charsetter = resource.charsets_provided && resource.charsets_provided.find {|c,_| c == chosen_charset }.last || :charset_nop
|
25
|
+
encoder = resource.encodings_provided[chosen_encoding]
|
26
|
+
response.body = case body
|
27
|
+
when String # 1.8 treats Strings as Enumerable
|
28
|
+
resource.send(encoder, resource.send(charsetter, body))
|
29
|
+
when Enumerable
|
30
|
+
EnumerableEncoder.new(resource, encoder, charsetter, body)
|
31
|
+
when body.respond_to?(:call)
|
32
|
+
CallableEncoder.new(resource, encoder, charsetter, body)
|
33
|
+
else
|
34
|
+
resource.send(encoder, resource.send(charsetter, body))
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
# Ensures that a header is quoted (like ETag)
|
39
|
+
def ensure_quoted_header(value)
|
40
|
+
if value =~ QUOTED
|
41
|
+
value
|
42
|
+
else
|
43
|
+
'"' << value << '"'
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
# Unquotes request headers (like ETag)
|
48
|
+
def unquote_header(value)
|
49
|
+
if value =~ QUOTED
|
50
|
+
$1
|
51
|
+
else
|
52
|
+
value
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
# Assists in receiving request bodies
|
57
|
+
def accept_helper
|
58
|
+
content_type = request.content_type || 'application/octet-stream'
|
59
|
+
mt = Conneg::MediaType.parse(content_type)
|
60
|
+
metadata['mediaparams'] = mt.params
|
61
|
+
acceptable = resource.content_types_accepted.find {|ct, _| mt.type_matches?(Conneg::MediaType.parse(ct)) }
|
62
|
+
if acceptable
|
63
|
+
resource.send(acceptable.last)
|
64
|
+
else
|
65
|
+
415
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
# Computes the entries for the 'Vary' response header
|
70
|
+
def variances
|
71
|
+
resource.variances.tap do |v|
|
72
|
+
v.unshift "Accept-Language" if resource.languages_provided.size > 1
|
73
|
+
v.unshift "Accept-Charset" if resource.charsets_provided && resource.charsets_provided.size > 1
|
74
|
+
v.unshift "Accept-Encoding" if resource.encodings_provided.size > 1
|
75
|
+
v.unshift "Accept" if resource.content_types_provided.size > 1
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
require 'webmachine/decision/helpers'
|
2
|
+
require 'webmachine/decision/conneg'
|
3
|
+
require 'webmachine/decision/flow'
|
4
|
+
require 'webmachine/decision/fsm'
|
5
|
+
|
6
|
+
module Webmachine
|
7
|
+
# This module encapsulates the logic related to delivering the
|
8
|
+
# proper HTTP response, given the constraints of the {Request} and
|
9
|
+
# the {Resource} which is being processed.
|
10
|
+
module Decision
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
require 'webmachine/resource'
|
2
|
+
require 'webmachine/translation'
|
3
|
+
|
4
|
+
module Webmachine
|
5
|
+
module Dispatcher
|
6
|
+
# Pairs URIs with {Resource} classes in the {Dispatcher}. To
|
7
|
+
# create routes, use {Dispatcher#add_route}.
|
8
|
+
class Route
|
9
|
+
# @return [Class] the resource this route will dispatch to, a
|
10
|
+
# subclass of {Resource}
|
11
|
+
attr_reader :resource
|
12
|
+
|
13
|
+
# When used in a path specification, will match all remaining
|
14
|
+
# segments
|
15
|
+
MATCH_ALL = '*'.freeze
|
16
|
+
|
17
|
+
# Creates a new Route that will associate a pattern to a
|
18
|
+
# {Resource}.
|
19
|
+
# @param [Array<String|Symbol>] path_spec a list of path
|
20
|
+
# segments (String) and identifiers (Symbol) to bind.
|
21
|
+
# Strings will be simply matched for equality. Symbols in
|
22
|
+
# the path spec will be extracted into {Request#path_info} for use
|
23
|
+
# inside your {Resource}. The special segment {MATCH_ALL} will match
|
24
|
+
# all remaining segments.
|
25
|
+
# @param [Class] resource the {Resource} to dispatch to
|
26
|
+
# @param [Hash] bindings additional information to add to
|
27
|
+
# {Request#path_info} when this route matches
|
28
|
+
# @see Dispatcher#add_route
|
29
|
+
def initialize(path_spec, resource, bindings={})
|
30
|
+
@path_spec, @resource, @bindings = path_spec, resource, bindings
|
31
|
+
raise ArgumentError, t('not_resource_class', :class => resource.name) unless resource < Resource
|
32
|
+
end
|
33
|
+
|
34
|
+
# Determines whether the given request matches this route and
|
35
|
+
# should be dispatched to the {#resource}.
|
36
|
+
# @param [Reqeust] request the request object
|
37
|
+
def match?(request)
|
38
|
+
tokens = request.uri.path.match(/^\/(.*)/)[1].split('/')
|
39
|
+
bind(tokens, {})
|
40
|
+
end
|
41
|
+
|
42
|
+
# Decorates the request with information about the dispatch
|
43
|
+
# route, including path bindings.
|
44
|
+
# @param [Request] request the request object
|
45
|
+
def apply(request)
|
46
|
+
request.disp_path = request.uri.path.match(/^\/(.*)/)[1]
|
47
|
+
request.path_info = @bindings.dup
|
48
|
+
tokens = request.disp_path.split('/')
|
49
|
+
depth, trailing = bind(tokens, request.path_info)
|
50
|
+
request.path_tokens = trailing || []
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
# Attempts to match the path spec against the path tokens, while
|
55
|
+
# accumulating variable bindings.
|
56
|
+
# @param [Array<String>] tokens the list of path segments
|
57
|
+
# @param [Hash] bindings where path bindings will be stored
|
58
|
+
# @return [Fixnum, Array<Fixnum, Array>, false] either the depth
|
59
|
+
# that the path matched at, the depth and tokens matched by
|
60
|
+
# {MATCH_ALL}, or false if it didn't match.
|
61
|
+
def bind(tokens, bindings)
|
62
|
+
depth = 0
|
63
|
+
spec = @path_spec
|
64
|
+
loop do
|
65
|
+
case
|
66
|
+
when spec.empty? && tokens.empty?
|
67
|
+
return depth
|
68
|
+
when spec == [MATCH_ALL]
|
69
|
+
return [depth, tokens]
|
70
|
+
when tokens.empty?
|
71
|
+
return false
|
72
|
+
when Symbol === spec.first
|
73
|
+
bindings[spec.first] = tokens.first
|
74
|
+
when spec.first == tokens.first
|
75
|
+
else
|
76
|
+
return false
|
77
|
+
end
|
78
|
+
spec = spec[1..-1]
|
79
|
+
tokens = tokens[1..-1]
|
80
|
+
depth += 1
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
require 'webmachine/decision'
|
2
|
+
require 'webmachine/dispatcher/route'
|
3
|
+
|
4
|
+
module Webmachine
|
5
|
+
# Handles dispatching incoming requests to the proper registered
|
6
|
+
# resources and initializing the decision logic.
|
7
|
+
module Dispatcher
|
8
|
+
extend self
|
9
|
+
@routes = []
|
10
|
+
|
11
|
+
# Adds a route to the dispatch list. Routes will be matched in the
|
12
|
+
# order they are added.
|
13
|
+
# @see Route#new
|
14
|
+
def add_route(*args)
|
15
|
+
@routes << Route.new(*args)
|
16
|
+
end
|
17
|
+
|
18
|
+
# Dispatches a request to the appropriate {Resource} in the
|
19
|
+
# dispatch list. If a matching resource is not found, a "404 Not
|
20
|
+
# Found" will be rendered.
|
21
|
+
# @param [Request] request the request object
|
22
|
+
# @param [Response] response the response object
|
23
|
+
def dispatch(request, response)
|
24
|
+
route = @routes.find {|r| r.match?(request) }
|
25
|
+
if route
|
26
|
+
resource = route.resource.new(request, response)
|
27
|
+
route.apply(request)
|
28
|
+
Webmachine::Decision::FSM.new(resource, request, response).run
|
29
|
+
else
|
30
|
+
Webmachine.render_error(404, request, response)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
# Resets, removing all routes. Useful for testing or reloading the
|
35
|
+
# application.
|
36
|
+
def reset
|
37
|
+
@routes = []
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
require 'webmachine/translation'
|
2
|
+
require 'webmachine/version'
|
3
|
+
|
4
|
+
module Webmachine
|
5
|
+
extend Translation
|
6
|
+
|
7
|
+
# Renders a standard error message body for the response. The
|
8
|
+
# standard messages are defined in localization files.
|
9
|
+
# @param [Fixnum] code the response status code
|
10
|
+
# @param [Request] req the request object
|
11
|
+
# @param [Response] req the response object
|
12
|
+
# @param [Hash] options keys to override the defaults when rendering
|
13
|
+
# the response body
|
14
|
+
def self.render_error(code, req, res, options={})
|
15
|
+
unless res.body
|
16
|
+
title, message = t(["errors.#{code}.title", "errors.#{code}.message"],
|
17
|
+
{ :method => req.method,
|
18
|
+
:error => res.error}.merge(options))
|
19
|
+
res.body = t("errors.standard_body",
|
20
|
+
{:title => title,
|
21
|
+
:message => message,
|
22
|
+
:version => Webmachine::SERVER_STRING}.merge(options))
|
23
|
+
res.headers['Content-Type'] = "text/html"
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
# Superclass of all errors generated by Webmachine.
|
28
|
+
class Error < ::StandardError; end
|
29
|
+
|
30
|
+
# Raised when the resource violates specific constraints on its API.
|
31
|
+
class InvalidResource < Error; end
|
32
|
+
|
33
|
+
# Raised when the client has submitted an invalid request, e.g. in
|
34
|
+
# the case where a request header is improperly formed. Raising this
|
35
|
+
# exception will result in a 400 response.
|
36
|
+
class MalformedRequest < Error; end
|
37
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
module Webmachine
|
2
|
+
# Case-insensitive Hash of request headers
|
3
|
+
class Headers < ::Hash
|
4
|
+
def [](key)
|
5
|
+
super key.to_s.downcase
|
6
|
+
end
|
7
|
+
|
8
|
+
def []=(key,value)
|
9
|
+
super key.to_s.downcase, value
|
10
|
+
end
|
11
|
+
|
12
|
+
def grep(pattern)
|
13
|
+
self.class[select { |k,_| pattern === k }]
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
en:
|
2
|
+
webmachine:
|
3
|
+
errors:
|
4
|
+
standard_body: "<!DOCTYPE html><html>
|
5
|
+
<head><title>%{title}</title></head>
|
6
|
+
<body><h1>%{title}</h1><p>%{message}</p>
|
7
|
+
<address>%{version} server</address></body></html>"
|
8
|
+
"400":
|
9
|
+
title: 400 Malformed Request
|
10
|
+
message: The request was malformed and could not be processed.
|
11
|
+
"404":
|
12
|
+
title: 404 Not Found
|
13
|
+
message: The requested document was not found on this server.
|
14
|
+
"500":
|
15
|
+
title: 500 Internal Server Error
|
16
|
+
message: "The server encountered an error while processing this request: <pre>%{error}</pre>"
|
17
|
+
"501":
|
18
|
+
title: 501 Not Implemented
|
19
|
+
message: "The server does not support the %{method} method."
|
20
|
+
"503":
|
21
|
+
title: 503 Service Unavailable
|
22
|
+
message: The server is currently unable to handl the request due to a temporary overloading or maintenance of the server.
|
23
|
+
create_path_nil: "post_is_create? returned true but create_path is nil! Define the create_path method in %{class}"
|
24
|
+
do_redirect: "Response had do_redirect but no Location header."
|
25
|
+
fsm_broke: "Decision FSM returned an unexpected value %{result} from decision %{state}."
|
26
|
+
invalid_media_type: "Invalid media type specified in Accept header: %{type}"
|
27
|
+
not_resource_class: "%{class} is not a subclass of Webmachine::Resource"
|
28
|
+
process_post_invalid: "process_post returned %{result}"
|
@@ -0,0 +1,56 @@
|
|
1
|
+
require 'forwardable'
|
2
|
+
|
3
|
+
module Webmachine
|
4
|
+
# This represents a single HTTP request sent from a client.
|
5
|
+
class Request
|
6
|
+
extend Forwardable
|
7
|
+
attr_reader :method, :uri, :headers, :body
|
8
|
+
attr_accessor :disp_path, :path_info, :path_tokens
|
9
|
+
|
10
|
+
def initialize(meth, uri, headers, body)
|
11
|
+
@method, @uri, @headers, @body = meth, uri, headers, body
|
12
|
+
end
|
13
|
+
|
14
|
+
def_delegators :headers, :[]
|
15
|
+
|
16
|
+
# @private
|
17
|
+
def method_missing(m, *args)
|
18
|
+
if m.to_s =~ /^(?:[a-z0-9])+(?:_[a-z0-9]+)*$/i
|
19
|
+
# Access headers more easily as underscored methods.
|
20
|
+
self[m.to_s.tr('_', '-')]
|
21
|
+
else
|
22
|
+
super
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# Whether the request body is present.
|
27
|
+
def has_body?
|
28
|
+
!(body.nil? || body.empty?)
|
29
|
+
end
|
30
|
+
|
31
|
+
# The root URI for the request, ignoring path and query. This is
|
32
|
+
# useful for calculating relative paths to resources.
|
33
|
+
# @return [URI]
|
34
|
+
def base_uri
|
35
|
+
@base_uri ||= uri.dup.tap do |u|
|
36
|
+
u.path = "/"
|
37
|
+
u.query = nil
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
# Returns a hash of query parameters (they come after the ? in the
|
42
|
+
# URI). Note that this does NOT work in the same way as Rails,
|
43
|
+
# i.e. it does not support nested arrays and hashes.
|
44
|
+
# @return [Hash] query parameters
|
45
|
+
def query
|
46
|
+
unless @query
|
47
|
+
@query = {}
|
48
|
+
uri.query.split(/&/).each do |kv|
|
49
|
+
k, v = URI.unescape(kv).split(/=/)
|
50
|
+
@query[k] = v if k && v
|
51
|
+
end
|
52
|
+
end
|
53
|
+
@query
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|