gnarly 0.0.1
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/lib/gnarly/application.rb +105 -0
- data/lib/gnarly/mimeparse.rb +216 -0
- data/lib/gnarly/request.rb +45 -0
- data/lib/gnarly/url.rb +53 -0
- data/lib/gnarly.rb +11 -0
- metadata +66 -0
@@ -0,0 +1,105 @@
|
|
1
|
+
require 'gnarly/url.rb'
|
2
|
+
require 'gnarly/request.rb'
|
3
|
+
require 'gnarly/mimeparse.rb'
|
4
|
+
|
5
|
+
module Gnarly
|
6
|
+
|
7
|
+
class Application
|
8
|
+
|
9
|
+
def initialize()
|
10
|
+
@urls = []
|
11
|
+
end
|
12
|
+
|
13
|
+
def url(url_mask, &block)
|
14
|
+
url = Url.new(url_mask)
|
15
|
+
url.instance_eval &block
|
16
|
+
@urls << url
|
17
|
+
end
|
18
|
+
|
19
|
+
def environment()
|
20
|
+
@environment
|
21
|
+
end
|
22
|
+
|
23
|
+
def request()
|
24
|
+
@request
|
25
|
+
end
|
26
|
+
|
27
|
+
def ok(body, mime=nil)
|
28
|
+
headers = mime ? {"Content-type" => mime} : {}
|
29
|
+
[200, headers, [body]]
|
30
|
+
end
|
31
|
+
|
32
|
+
def not_found(body="Not Found", mime="text/plain")
|
33
|
+
[404, {"Content-type" => mime}, [body]]
|
34
|
+
end
|
35
|
+
|
36
|
+
def method_not_allowed(allow, body="Method Not Allowed", mime="text/plain")
|
37
|
+
[405,
|
38
|
+
{"Content-type" => mime, "Allow" => allow},
|
39
|
+
[body]]
|
40
|
+
end
|
41
|
+
|
42
|
+
def not_acceptable(acceptable=nil, body=nil, mime="text/plain")
|
43
|
+
body = "" unless body
|
44
|
+
[406,
|
45
|
+
{"Content-type" => mime},
|
46
|
+
[body]]
|
47
|
+
end
|
48
|
+
|
49
|
+
def internal_server_error(body="Internal Server Error", mime="text/plain")
|
50
|
+
[500, {"Content-type" => mime}, [body]]
|
51
|
+
end
|
52
|
+
|
53
|
+
def provides(*mimes, &block)
|
54
|
+
mime = MIMEParse.best_match mimes, request.accept
|
55
|
+
if mime
|
56
|
+
response = block.call mime if block
|
57
|
+
if response.is_a? Array and response.size == 3
|
58
|
+
status, headers, body = response
|
59
|
+
unless headers.key? "Content-type"
|
60
|
+
charset = body_charset(body)
|
61
|
+
headers["Content-type"] =
|
62
|
+
charset ? "#{mime}; charset=#{charset}" : mime
|
63
|
+
end
|
64
|
+
response
|
65
|
+
else
|
66
|
+
name, line = @responder.source_location
|
67
|
+
msg = "Bad return type for block in #{filename} line #{line}"
|
68
|
+
internal_server_error msg
|
69
|
+
end
|
70
|
+
else
|
71
|
+
not_acceptable
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def call(env)
|
76
|
+
@request = Request.new env
|
77
|
+
@environment = env
|
78
|
+
path = env["PATH_INFO"]
|
79
|
+
if url = @urls.detect { |u| u.match? path }
|
80
|
+
method = env["REQUEST_METHOD"]
|
81
|
+
@responder = url.responder method
|
82
|
+
if @responder
|
83
|
+
instance_exec *url.parameters, &@responder
|
84
|
+
else
|
85
|
+
method_not_allowed url.allow
|
86
|
+
end
|
87
|
+
else
|
88
|
+
not_found
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
private
|
93
|
+
|
94
|
+
def body_charset(body)
|
95
|
+
charset = nil
|
96
|
+
body.each do |s|
|
97
|
+
charset = s.encoding.to_s
|
98
|
+
break
|
99
|
+
end
|
100
|
+
charset
|
101
|
+
end
|
102
|
+
|
103
|
+
end
|
104
|
+
|
105
|
+
end
|
@@ -0,0 +1,216 @@
|
|
1
|
+
# mimeparse.rb
|
2
|
+
#
|
3
|
+
# This module provides basic functions for handling mime-types. It can
|
4
|
+
# handle matching mime-types against a list of media-ranges. See section
|
5
|
+
# 14.1 of the HTTP specification [RFC 2616] for a complete explanation.
|
6
|
+
#
|
7
|
+
# http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.1
|
8
|
+
#
|
9
|
+
# ---------
|
10
|
+
#
|
11
|
+
# This is a port of Joe Gregario's mimeparse.py, which can be found at
|
12
|
+
# <http://code.google.com/p/mimeparse/>.
|
13
|
+
#
|
14
|
+
# ported from version 0.1.2
|
15
|
+
#
|
16
|
+
# Comments are mostly excerpted from the original.
|
17
|
+
|
18
|
+
module MIMEParse
|
19
|
+
module_function
|
20
|
+
|
21
|
+
# Carves up a mime-type and returns an Array of the
|
22
|
+
# [type, subtype, params] where "params" is a Hash of all
|
23
|
+
# the parameters for the media range.
|
24
|
+
#
|
25
|
+
# For example, the media range "application/xhtml;q=0.5" would
|
26
|
+
# get parsed into:
|
27
|
+
#
|
28
|
+
# ["application", "xhtml", { "q" => "0.5" }]
|
29
|
+
def parse_mime_type(mime_type)
|
30
|
+
parts = mime_type.split(";")
|
31
|
+
|
32
|
+
params = {}
|
33
|
+
|
34
|
+
parts[1..-1].map do |param|
|
35
|
+
k,v = param.split("=").map { |s| s.strip }
|
36
|
+
params[k] = v
|
37
|
+
end
|
38
|
+
|
39
|
+
full_type = parts[0].strip
|
40
|
+
# Java URLConnection class sends an Accept header that includes a single "*"
|
41
|
+
# Turn it into a legal wildcard.
|
42
|
+
full_type = "*/*" if full_type == "*"
|
43
|
+
type, subtype = full_type.split("/")
|
44
|
+
raise "malformed mime type" unless subtype
|
45
|
+
|
46
|
+
[type.strip, subtype.strip, params]
|
47
|
+
end
|
48
|
+
|
49
|
+
# Carves up a media range and returns an Array of the
|
50
|
+
# [type, subtype, params] where "params" is a Hash of all
|
51
|
+
# the parameters for the media range.
|
52
|
+
#
|
53
|
+
# For example, the media range "application/*;q=0.5" would
|
54
|
+
# get parsed into:
|
55
|
+
#
|
56
|
+
# ["application", "*", { "q", "0.5" }]
|
57
|
+
#
|
58
|
+
# In addition this function also guarantees that there
|
59
|
+
# is a value for "q" in the params dictionary, filling it
|
60
|
+
# in with a proper default if necessary.
|
61
|
+
def parse_media_range(range)
|
62
|
+
type, subtype, params = parse_mime_type(range)
|
63
|
+
unless params.has_key?("q") and params["q"] and params["q"].to_f and params["q"].to_f <= 1 and params["q"].to_f >= 0
|
64
|
+
params["q"] = "1"
|
65
|
+
end
|
66
|
+
|
67
|
+
[type, subtype, params]
|
68
|
+
end
|
69
|
+
|
70
|
+
# Find the best match for a given mime-type against a list of
|
71
|
+
# media_ranges that have already been parsed by #parse_media_range
|
72
|
+
#
|
73
|
+
# Returns the fitness and the "q" quality parameter of the best match,
|
74
|
+
# or [-1, 0] if no match was found. Just as for #quality_parsed,
|
75
|
+
# "parsed_ranges" must be an Enumerable of parsed media ranges.
|
76
|
+
def fitness_and_quality_parsed(mime_type, parsed_ranges)
|
77
|
+
best_fitness = -1
|
78
|
+
best_fit_q = 0
|
79
|
+
target_type, target_subtype, target_params = parse_media_range(mime_type)
|
80
|
+
|
81
|
+
parsed_ranges.each do |type,subtype,params|
|
82
|
+
if (type == target_type or type == "*" or target_type == "*") and
|
83
|
+
(subtype == target_subtype or subtype == "*" or target_subtype == "*")
|
84
|
+
param_matches = target_params.find_all { |k,v| k != "q" and params.has_key?(k) and v == params[k] }.length
|
85
|
+
|
86
|
+
fitness = (type == target_type) ? 100 : 0
|
87
|
+
fitness += (subtype == target_subtype) ? 10 : 0
|
88
|
+
fitness += param_matches
|
89
|
+
|
90
|
+
if fitness > best_fitness
|
91
|
+
best_fitness = fitness
|
92
|
+
best_fit_q = params["q"]
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
[best_fitness, best_fit_q.to_f]
|
98
|
+
end
|
99
|
+
|
100
|
+
# Find the best match for a given mime-type against a list of
|
101
|
+
# media_ranges that have already been parsed by #parse_media_range
|
102
|
+
#
|
103
|
+
# Returns the "q" quality parameter of the best match, 0 if no match
|
104
|
+
# was found. This function behaves the same as #quality except that
|
105
|
+
# "parsed_ranges" must be an Enumerable of parsed media ranges.
|
106
|
+
def quality_parsed(mime_type, parsed_ranges)
|
107
|
+
fitness_and_quality_parsed(mime_type, parsed_ranges)[1]
|
108
|
+
end
|
109
|
+
|
110
|
+
# Returns the quality "q" of a mime_type when compared against
|
111
|
+
# the media-ranges in ranges. For example:
|
112
|
+
#
|
113
|
+
# irb> quality("text/html", "text/*;q=0.3, text/html;q=0.7, text/html;level=1, text/html;level=2;q=0.4, */*;q=0.5")
|
114
|
+
# => 0.7
|
115
|
+
def quality(mime_type, ranges)
|
116
|
+
parsed_ranges = ranges.split(",").map { |r| parse_media_range(r) }
|
117
|
+
quality_parsed(mime_type, parsed_ranges)
|
118
|
+
end
|
119
|
+
|
120
|
+
# Takes a list of supported mime-types and finds the best match
|
121
|
+
# for all the media-ranges listed in header. The value of header
|
122
|
+
# must be a string that conforms to the format of the HTTP Accept:
|
123
|
+
# header. The value of supported is an Enumerable of mime-types
|
124
|
+
#
|
125
|
+
# irb> best_match(["application/xbel+xml", "text/xml"], "text/*;q=0.5,*/*; q=0.1")
|
126
|
+
# => "text/xml"
|
127
|
+
def best_match(supported, header)
|
128
|
+
parsed_header = header.split(",").map { |r| parse_media_range(r) }
|
129
|
+
|
130
|
+
weighted_matches = supported.map do |mime_type|
|
131
|
+
[fitness_and_quality_parsed(mime_type, parsed_header), mime_type]
|
132
|
+
end
|
133
|
+
|
134
|
+
weighted_matches.sort!
|
135
|
+
|
136
|
+
weighted_matches.last[0][1].zero? ? nil : weighted_matches.last[1]
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
if __FILE__ == $0
|
141
|
+
require "test/unit"
|
142
|
+
|
143
|
+
class TestMimeParsing < Test::Unit::TestCase
|
144
|
+
include MIMEParse
|
145
|
+
|
146
|
+
def test_parse_media_range
|
147
|
+
assert_equal [ "application", "xml", { "q" => "1" } ],
|
148
|
+
parse_media_range("application/xml;q=1")
|
149
|
+
|
150
|
+
assert_equal [ "application", "xml", { "q" => "1" } ],
|
151
|
+
parse_media_range("application/xml")
|
152
|
+
|
153
|
+
assert_equal [ "application", "xml", { "q" => "1" } ],
|
154
|
+
parse_media_range("application/xml;q=")
|
155
|
+
|
156
|
+
assert_equal [ "application", "xml", { "q" => "1", "b" => "other" } ],
|
157
|
+
parse_media_range("application/xml ; q=1;b=other")
|
158
|
+
|
159
|
+
assert_equal [ "application", "xml", { "q" => "1", "b" => "other" } ],
|
160
|
+
parse_media_range("application/xml ; q=2;b=other")
|
161
|
+
|
162
|
+
# Java URLConnection class sends an Accept header that includes a single "*"
|
163
|
+
assert_equal [ "*", "*", { "q" => ".2" } ],
|
164
|
+
parse_media_range(" *; q=.2")
|
165
|
+
end
|
166
|
+
|
167
|
+
def test_rfc_2616_example
|
168
|
+
accept = "text/*;q=0.3, text/html;q=0.7, text/html;level=1, text/html;level=2;q=0.4, */*;q=0.5"
|
169
|
+
|
170
|
+
assert_equal 1, quality("text/html;level=1", accept)
|
171
|
+
assert_equal 0.7, quality("text/html", accept)
|
172
|
+
assert_equal 0.3, quality("text/plain", accept)
|
173
|
+
assert_equal 0.5, quality("image/jpeg", accept)
|
174
|
+
assert_equal 0.4, quality("text/html;level=2", accept)
|
175
|
+
assert_equal 0.7, quality("text/html;level=3", accept)
|
176
|
+
end
|
177
|
+
|
178
|
+
def test_best_match
|
179
|
+
@supported_mime_types = [ "application/xbel+xml", "application/xml" ]
|
180
|
+
|
181
|
+
# direct match
|
182
|
+
assert_best_match "application/xbel+xml", "application/xbel+xml"
|
183
|
+
# direct match with a q parameter
|
184
|
+
assert_best_match "application/xbel+xml", "application/xbel+xml; q=1"
|
185
|
+
# direct match of our second choice with a q parameter
|
186
|
+
assert_best_match "application/xml", "application/xml; q=1"
|
187
|
+
# match using a subtype wildcard
|
188
|
+
assert_best_match "application/xml", "application/*; q=1"
|
189
|
+
# match using a type wildcard
|
190
|
+
assert_best_match "application/xml", "*/*"
|
191
|
+
|
192
|
+
@supported_mime_types = [ "application/xbel+xml", "text/xml" ]
|
193
|
+
# match using a type versus a lower weighted subtype
|
194
|
+
assert_best_match "text/xml", "text/*;q=0.5,*/*;q=0.1"
|
195
|
+
# fail to match anything
|
196
|
+
assert_best_match nil, "text/html,application/atom+xml; q=0.9"
|
197
|
+
# common AJAX scenario
|
198
|
+
@supported_mime_types = [ "application/json", "text/html" ]
|
199
|
+
assert_best_match "application/json", "application/json, text/javascript, */*"
|
200
|
+
# verify fitness sorting
|
201
|
+
assert_best_match "application/json", "application/json, text/html;q=0.9"
|
202
|
+
end
|
203
|
+
|
204
|
+
def test_support_wildcards
|
205
|
+
@supported_mime_types = ['image/*', 'application/xml']
|
206
|
+
# match using a type wildcard
|
207
|
+
assert_best_match 'image/*', 'image/png'
|
208
|
+
# match using a wildcard for both requested and supported
|
209
|
+
assert_best_match 'image/*', 'image/*'
|
210
|
+
end
|
211
|
+
|
212
|
+
def assert_best_match(expected, header)
|
213
|
+
assert_equal(expected, best_match(@supported_mime_types, header))
|
214
|
+
end
|
215
|
+
end
|
216
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
|
2
|
+
module Gnarly
|
3
|
+
|
4
|
+
class Request
|
5
|
+
|
6
|
+
def initialize(env)
|
7
|
+
@environment = env
|
8
|
+
parse_content_type
|
9
|
+
end
|
10
|
+
|
11
|
+
def accept()
|
12
|
+
@accept ||= @environment["HTTP_ACCEPT"]
|
13
|
+
end
|
14
|
+
|
15
|
+
def content_type()
|
16
|
+
@content_type
|
17
|
+
end
|
18
|
+
|
19
|
+
def charset()
|
20
|
+
@charset
|
21
|
+
end
|
22
|
+
|
23
|
+
def body()
|
24
|
+
unless @body
|
25
|
+
input = @environment["rack.input"]
|
26
|
+
@body = input.read
|
27
|
+
input.rewind
|
28
|
+
@body.force_encoding charset if charset
|
29
|
+
end
|
30
|
+
@body
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def parse_content_type()
|
36
|
+
if header = @environment["CONTENT_TYPE"]
|
37
|
+
header =~ /^([^;\s]+)(\s*;\s*charset=(\S+)\s*)$/
|
38
|
+
@content_type = $1
|
39
|
+
@charset = $3
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
end
|
44
|
+
|
45
|
+
end
|
data/lib/gnarly/url.rb
ADDED
@@ -0,0 +1,53 @@
|
|
1
|
+
|
2
|
+
module Gnarly
|
3
|
+
|
4
|
+
class Url
|
5
|
+
|
6
|
+
def initialize(url_mask)
|
7
|
+
@url_mask = url_mask
|
8
|
+
@responders = {}
|
9
|
+
@allow = []
|
10
|
+
end
|
11
|
+
|
12
|
+
def match?(path)
|
13
|
+
@match = @url_mask.match path
|
14
|
+
@match != nil
|
15
|
+
end
|
16
|
+
|
17
|
+
def parameters()
|
18
|
+
a = @match.to_a
|
19
|
+
a.shift
|
20
|
+
a
|
21
|
+
end
|
22
|
+
|
23
|
+
def responder(method)
|
24
|
+
@responders[method]
|
25
|
+
end
|
26
|
+
|
27
|
+
def allow()
|
28
|
+
@allow.join " "
|
29
|
+
end
|
30
|
+
|
31
|
+
def get(&block)
|
32
|
+
@responders["GET"] = block
|
33
|
+
@allow << "GET"
|
34
|
+
end
|
35
|
+
|
36
|
+
def put(&block)
|
37
|
+
@responders["PUT"] = block
|
38
|
+
@allow << "PUT"
|
39
|
+
end
|
40
|
+
|
41
|
+
def post(&block)
|
42
|
+
@responders["POST"] = block
|
43
|
+
@allow << "POST"
|
44
|
+
end
|
45
|
+
|
46
|
+
def delete(&block)
|
47
|
+
@responders["DELETE"] = block
|
48
|
+
@allow << "DELETE"
|
49
|
+
end
|
50
|
+
|
51
|
+
end
|
52
|
+
|
53
|
+
end
|
data/lib/gnarly.rb
ADDED
metadata
ADDED
@@ -0,0 +1,66 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: gnarly
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
prerelease: false
|
5
|
+
segments:
|
6
|
+
- 0
|
7
|
+
- 0
|
8
|
+
- 1
|
9
|
+
version: 0.0.1
|
10
|
+
platform: ruby
|
11
|
+
authors:
|
12
|
+
- Kim Dalsgaard
|
13
|
+
autorequire:
|
14
|
+
bindir: bin
|
15
|
+
cert_chain: []
|
16
|
+
|
17
|
+
date: 2010-02-24 00:00:00 +01:00
|
18
|
+
default_executable:
|
19
|
+
dependencies: []
|
20
|
+
|
21
|
+
description: Super simple DSL style web framework on top of Rack. Makes real HTTP'ish REST services easy.
|
22
|
+
email: kim@kimdalsgaard.com
|
23
|
+
executables: []
|
24
|
+
|
25
|
+
extensions: []
|
26
|
+
|
27
|
+
extra_rdoc_files: []
|
28
|
+
|
29
|
+
files:
|
30
|
+
- lib/gnarly/application.rb
|
31
|
+
- lib/gnarly/mimeparse.rb
|
32
|
+
- lib/gnarly/request.rb
|
33
|
+
- lib/gnarly/url.rb
|
34
|
+
- lib/gnarly.rb
|
35
|
+
has_rdoc: true
|
36
|
+
homepage:
|
37
|
+
licenses: []
|
38
|
+
|
39
|
+
post_install_message:
|
40
|
+
rdoc_options: []
|
41
|
+
|
42
|
+
require_paths:
|
43
|
+
- lib
|
44
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
45
|
+
requirements:
|
46
|
+
- - ">="
|
47
|
+
- !ruby/object:Gem::Version
|
48
|
+
segments:
|
49
|
+
- 0
|
50
|
+
version: "0"
|
51
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
52
|
+
requirements:
|
53
|
+
- - ">="
|
54
|
+
- !ruby/object:Gem::Version
|
55
|
+
segments:
|
56
|
+
- 0
|
57
|
+
version: "0"
|
58
|
+
requirements: []
|
59
|
+
|
60
|
+
rubyforge_project:
|
61
|
+
rubygems_version: 1.3.6
|
62
|
+
signing_key:
|
63
|
+
specification_version: 3
|
64
|
+
summary: Super simple web framework on top of Rack
|
65
|
+
test_files: []
|
66
|
+
|