rackful 0.2.0 → 0.2.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.
- checksums.yaml +4 -4
- data/example/config.ru +11 -10
- data/lib/rackful.rb +9 -5
- data/lib/rackful/global.rb +12 -0
- data/lib/rackful/httpstatus.rb +50 -51
- data/lib/rackful/middleware.rb +5 -0
- data/lib/rackful/middleware/headerspoofing.rb +7 -1
- data/lib/rackful/middleware/methodoverride.rb +8 -2
- data/lib/rackful/parser.rb +80 -101
- data/lib/rackful/request.rb +100 -129
- data/lib/rackful/resource.rb +263 -207
- data/lib/rackful/serializer.rb +46 -17
- data/lib/rackful/server.rb +25 -33
- data/lib/rackful/uri.rb +8 -0
- data/rackful.gemspec +1 -1
- metadata +3 -2
data/lib/rackful/request.rb
CHANGED
@@ -1,76 +1,99 @@
|
|
1
1
|
# encoding: utf-8
|
2
2
|
|
3
|
+
# Required for parsing:
|
4
|
+
require 'rackful/global.rb'
|
5
|
+
|
6
|
+
# Required for running:
|
3
7
|
|
4
8
|
module Rackful
|
5
9
|
|
6
10
|
# Subclass of {Rack::Request}, augmented for Rackful requests.
|
11
|
+
#
|
12
|
+
# This class mixes in module `StatusCodes` for convenience, as explained in the
|
13
|
+
# {StatusCodes StatusCodes documentation}.
|
7
14
|
class Request < Rack::Request
|
8
15
|
|
16
|
+
include StatusCodes
|
9
17
|
|
10
18
|
def initialize *args
|
11
19
|
super( *args )
|
12
20
|
end
|
13
21
|
|
14
22
|
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
=
|
22
|
-
|
23
|
-
|
23
|
+
# Calls the code block passed to the {#initialize constructor}.
|
24
|
+
# @param uri [URI::HTTP, String]
|
25
|
+
# @return [Resource]
|
26
|
+
# @raise [HTTP404NotFound]
|
27
|
+
def resource_at( uri )
|
28
|
+
uri = uri.kind_of?( URI::Generic ) ? uri.dup : URI(uri).normalize
|
29
|
+
uri.query = nil
|
30
|
+
retval = env['rackful.resource_registry'].call( uri )
|
31
|
+
raise HTTP404NotFound unless retval
|
32
|
+
retval
|
24
33
|
end
|
25
34
|
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
=
|
35
|
-
|
36
|
-
|
35
|
+
# The current request’s main resource.
|
36
|
+
#
|
37
|
+
# As a side effect, {#canonical_uri} can be changed.
|
38
|
+
# @return [Resource]
|
39
|
+
# @raise [HTTP404NotFound] from {#resource_at}
|
40
|
+
def resource
|
41
|
+
@rackful_request_resource ||= begin
|
42
|
+
c = URI(url).normalize
|
43
|
+
retval = resource_at(c)
|
44
|
+
c += retval.uri
|
45
|
+
c.query = query_string unless query_string.empty?
|
46
|
+
env['rackful.canonical_uri'] = c
|
47
|
+
retval
|
48
|
+
end
|
37
49
|
end
|
38
50
|
|
39
51
|
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
@return [URI::Generic
|
46
|
-
|
47
|
-
|
48
|
-
env['rackful.canonical_uri'] =
|
49
|
-
uri.kind_of?( URI::Generic ) ? URI(uri) : URI( uri ).normalize
|
50
|
-
uri
|
52
|
+
# Similar to the HTTP/1.1 `Content-Location:` header. Contains the canonical url
|
53
|
+
# of the requested resource, which may differ from {#url}.
|
54
|
+
#
|
55
|
+
# If parameter +full_path+ is provided, than this is used instead of the current
|
56
|
+
# request’s full path (which is the path plus optional query string).
|
57
|
+
# @return [URI::Generic]
|
58
|
+
def canonical_uri
|
59
|
+
env['rackful.canonical_uri'] || URI( self.url ).normalize
|
51
60
|
end
|
52
61
|
|
53
62
|
|
54
|
-
|
55
|
-
|
56
|
-
@
|
57
|
-
@
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
@
|
69
|
-
|
70
|
-
|
63
|
+
# The canonical url of the requested resource. This may differ from {#url}.
|
64
|
+
#
|
65
|
+
# @todo Change URI::Generic into URI::HTTP
|
66
|
+
# @param uri [URI::Generic, String]
|
67
|
+
# @return [URI::Generic, String] `uri`
|
68
|
+
# def canonical_uri=( uri )
|
69
|
+
# env['rackful.canonical_uri'] =
|
70
|
+
# uri.kind_of?( URI::Generic ) ? uri.dup : URI( uri ).normalize
|
71
|
+
# uri
|
72
|
+
# end
|
73
|
+
|
74
|
+
|
75
|
+
# Assert all <tt>If-*</tt> request headers.
|
76
|
+
# @return [void]
|
77
|
+
# @raise [HTTP304NotModified, HTTP400BadRequest, HTTP404NotFound, HTTP412PreconditionFailed]
|
78
|
+
# with the following meanings:
|
79
|
+
#
|
80
|
+
# - `304 Not Modified`
|
81
|
+
# - `400 Bad Request` Couldn't parse one or more <tt>If-*</tt> headers, or a
|
82
|
+
# weak validator comparison was requested for methods other than `GET` or
|
83
|
+
# `HEAD`.
|
84
|
+
# - `404 Not Found`
|
85
|
+
# - `412 Precondition Failed`
|
86
|
+
# @see http://tools.ietf.org/html/rfc2616#section-13.3.3 RFC2616, section 13.3.3
|
87
|
+
# for details about weak and strong validator comparison.
|
88
|
+
# @todo Implement support for the `If-Range:` header.
|
89
|
+
def assert_if_headers
|
71
90
|
#raise HTTP501NotImplemented, 'If-Range: request header is not supported.' \
|
72
91
|
# if env.key? 'HTTP_IF_RANGE'
|
73
|
-
|
92
|
+
begin
|
93
|
+
empty = resource.empty?
|
94
|
+
rescue HTTP404NotFound => e
|
95
|
+
empty = true
|
96
|
+
end
|
74
97
|
etag =
|
75
98
|
if ! empty && resource.respond_to?(:get_etag)
|
76
99
|
resource.get_etag
|
@@ -146,47 +169,6 @@ Assert all <tt>If-*</tt> request headers.
|
|
146
169
|
end
|
147
170
|
|
148
171
|
|
149
|
-
=begin markdown
|
150
|
-
The best media type to represent a resource, given the current HTTP request.
|
151
|
-
@param resource [Resource] the resource to represent
|
152
|
-
@param require_match [Boolean] this flag determines what must happen if the
|
153
|
-
client sent an `Accept:` header, and we cannot serve any of the acceptable
|
154
|
-
media types. **`TRUE`** means that an {HTTP406NotAcceptable} exception is
|
155
|
-
raised. **`FALSE`** means that the content-type with the highest quality is
|
156
|
-
returned.
|
157
|
-
@return [String] content-type
|
158
|
-
@raise [HTTP406NotAcceptable]
|
159
|
-
@api private
|
160
|
-
=end
|
161
|
-
def best_content_type( resource, require_match = true )
|
162
|
-
q_values = self.q_values
|
163
|
-
if q_values.empty?
|
164
|
-
return resource.all_serializers.values.sort_by(&:last).last[0]::CONTENT_TYPES[0]
|
165
|
-
end
|
166
|
-
matches = []
|
167
|
-
q_values.each {
|
168
|
-
|accept_media_type, accept_quality|
|
169
|
-
resource.all_serializers.each_pair {
|
170
|
-
|content_type, v|
|
171
|
-
quality = v[1]
|
172
|
-
media_type = content_type.split(';').first.strip
|
173
|
-
if File.fnmatch( accept_media_type, media_type )
|
174
|
-
matches << [ content_type, accept_quality * quality ]
|
175
|
-
end
|
176
|
-
}
|
177
|
-
}
|
178
|
-
if matches.empty?
|
179
|
-
if require_match
|
180
|
-
raise( HTTP406NotAcceptable, resource.all_serializers.keys() )
|
181
|
-
else
|
182
|
-
return resource.all_serializers.values.sort_by(&:last).
|
183
|
-
last.first::CONTENT_TYPES.first
|
184
|
-
end
|
185
|
-
end
|
186
|
-
matches.sort_by(&:last).last.first
|
187
|
-
end
|
188
|
-
|
189
|
-
|
190
172
|
# Hash of acceptable media types and their qualities.
|
191
173
|
#
|
192
174
|
# This method parses the HTTP/1.1 `Accept:` header. If no acceptable media
|
@@ -212,13 +194,11 @@ The best media type to represent a resource, given the current HTTP request.
|
|
212
194
|
end # def accept
|
213
195
|
|
214
196
|
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
@
|
219
|
-
@see
|
220
|
-
@see #if_none_match
|
221
|
-
=end
|
197
|
+
# @!method if_match()
|
198
|
+
# Parses the HTTP/1.1 `If-Match:` header.
|
199
|
+
# @return [nil, Array<String>]
|
200
|
+
# @see http://tools.ietf.org/html/rfc2616#section-14.24 RFC2616, section 14.24
|
201
|
+
# @see #if_none_match
|
222
202
|
def if_match none = false
|
223
203
|
header = env["HTTP_IF_#{ none ? 'NONE_' : '' }MATCH"]
|
224
204
|
return nil unless header
|
@@ -232,23 +212,19 @@ Parses the HTTP/1.1 `If-Match:` header.
|
|
232
212
|
end
|
233
213
|
|
234
214
|
|
235
|
-
|
236
|
-
|
237
|
-
@
|
238
|
-
@see
|
239
|
-
@see #if_match
|
240
|
-
=end
|
215
|
+
# Parses the HTTP/1.1 `If-None-Match:` header.
|
216
|
+
# @return [nil, Array<String>]
|
217
|
+
# @see http://tools.ietf.org/html/rfc2616#section-14.26 RFC2616, section 14.26
|
218
|
+
# @see #if_match
|
241
219
|
def if_none_match
|
242
220
|
self.if_match true
|
243
221
|
end
|
244
222
|
|
245
223
|
|
246
|
-
|
247
|
-
|
248
|
-
@
|
249
|
-
@see
|
250
|
-
@see #if_unmodified_since
|
251
|
-
=end
|
224
|
+
# @!method if_modified_since()
|
225
|
+
# @return [nil, Time]
|
226
|
+
# @see http://tools.ietf.org/html/rfc2616#section-14.25 RFC2616, section 14.25
|
227
|
+
# @see #if_unmodified_since
|
252
228
|
def if_modified_since unmodified = false
|
253
229
|
header = env["HTTP_IF_#{ unmodified ? 'UN' : '' }MODIFIED_SINCE"]
|
254
230
|
return nil unless header
|
@@ -261,29 +237,25 @@ Parses the HTTP/1.1 `If-None-Match:` header.
|
|
261
237
|
end
|
262
238
|
|
263
239
|
|
264
|
-
|
265
|
-
@
|
266
|
-
@see
|
267
|
-
@see #if_modified_since
|
268
|
-
=end
|
240
|
+
# @return [nil, Time]
|
241
|
+
# @see http://tools.ietf.org/html/rfc2616#section-14.28 RFC2616, section 14.28
|
242
|
+
# @see #if_modified_since
|
269
243
|
def if_unmodified_since
|
270
244
|
self.if_modified_since true
|
271
245
|
end
|
272
246
|
|
273
247
|
|
274
|
-
|
275
|
-
|
276
|
-
@param
|
277
|
-
@
|
278
|
-
|
279
|
-
|
280
|
-
|
281
|
-
|
282
|
-
|
283
|
-
@
|
284
|
-
|
285
|
-
for details about weak and strong validator comparison.
|
286
|
-
=end
|
248
|
+
# Does any of the tags in `etags` match `etag`?
|
249
|
+
# @param etag [#to_s]
|
250
|
+
# @param etags [#to_a]
|
251
|
+
# @example
|
252
|
+
# etag = '"foo"'
|
253
|
+
# etags = [ 'W/"foo"', '"bar"' ]
|
254
|
+
# validate_etag etag, etags
|
255
|
+
# #> true
|
256
|
+
# @return [Boolean]
|
257
|
+
# @see http://tools.ietf.org/html/rfc2616#section-13.3.3 RFC2616 section 13.3.3
|
258
|
+
# for details about weak and strong validator comparison.
|
287
259
|
def validate_etag etag, etags
|
288
260
|
etag = etag.to_s
|
289
261
|
match = etags.to_a.detect do
|
@@ -304,6 +276,5 @@ Does any of the tags in `etags` match `etag`?
|
|
304
276
|
end
|
305
277
|
|
306
278
|
|
307
|
-
end # class Request
|
308
|
-
|
279
|
+
end # class Rackful::Request
|
309
280
|
end # module Rackful
|
data/lib/rackful/resource.rb
CHANGED
@@ -1,21 +1,80 @@
|
|
1
1
|
# encoding: utf-8
|
2
2
|
|
3
|
-
|
3
|
+
# Required for parsing:
|
4
|
+
require 'rackful/global.rb'
|
5
|
+
|
6
|
+
# Required for running:
|
4
7
|
|
5
8
|
module Rackful
|
6
9
|
|
7
10
|
# Abstract superclass for resources served by {Server}.
|
11
|
+
#
|
12
|
+
# This class mixes in module `StatusCodes` for convenience, as explained in the
|
13
|
+
# {StatusCodes StatusCodes documentation}.
|
8
14
|
# @see Server
|
9
15
|
# @todo better documentation
|
10
16
|
# @abstract Realizations must implement…
|
11
|
-
|
12
|
-
|
13
|
-
|
17
|
+
#
|
18
|
+
# @!method do_METHOD( Request, Rack::Response )
|
19
|
+
# HTTP/1.1 method handler.
|
20
|
+
#
|
21
|
+
# To handle certain HTTP/1.1 request methods, resources must implement methods
|
22
|
+
# called `do_<HTTP_METHOD>`.
|
23
|
+
# @example Handling `PATCH` requests
|
24
|
+
# def do_PATCH request, response
|
25
|
+
# response['Content-Type'] = 'text/plain'
|
26
|
+
# response.body = [ 'Hello world!' ]
|
27
|
+
# end
|
28
|
+
# @abstract
|
29
|
+
# @return [void]
|
30
|
+
# @raise [HTTPStatus, RuntimeError]
|
31
|
+
#
|
32
|
+
# @!attribute [r] get_etag
|
33
|
+
# The ETag of this resource.
|
34
|
+
#
|
35
|
+
# If your classes implement this method, then an `ETag:` response
|
36
|
+
# header is generated automatically when appropriate. This allows clients to
|
37
|
+
# perform conditional requests, by sending an `If-Match:` or
|
38
|
+
# `If-None-Match:` request header. These conditions are then asserted
|
39
|
+
# for you automatically.
|
40
|
+
#
|
41
|
+
# Make sure your entity tag is a properly formatted string. In ABNF:
|
42
|
+
#
|
43
|
+
# entity-tag = [ "W/" ] quoted-string
|
44
|
+
# quoted-string = ( <"> *(qdtext | quoted-pair ) <"> )
|
45
|
+
# qdtext = <any TEXT except <">>
|
46
|
+
# quoted-pair = "\" CHAR
|
47
|
+
#
|
48
|
+
# @abstract
|
49
|
+
# @return [String]
|
50
|
+
# @see http://tools.ietf.org/html/rfc2616#section-14.19 RFC2616 section 14.19
|
51
|
+
#
|
52
|
+
# @!attribute [r] get_last_modified
|
53
|
+
# Last modification of this resource.
|
54
|
+
#
|
55
|
+
# If your classes implement this method, then a `Last-Modified:` response
|
56
|
+
# header is generated automatically when appropriate. This allows clients to
|
57
|
+
# perform conditional requests, by sending an `If-Modified-Since:` or
|
58
|
+
# `If-Unmodified-Since:` request header. These conditions are then asserted
|
59
|
+
# for you automatically.
|
60
|
+
# @abstract
|
61
|
+
# @return [Array<(Time, Boolean)>] The timestamp, and a flag indicating if the
|
62
|
+
# timestamp is a strong validator.
|
63
|
+
# @see http://tools.ietf.org/html/rfc2616#section-14.29 RFC2616 section 14.29
|
64
|
+
#
|
65
|
+
# @!method destroy()
|
66
|
+
# @return [Hash, nil] an optional header hash.
|
67
|
+
module Resource
|
68
|
+
|
69
|
+
include StatusCodes
|
70
|
+
|
71
|
+
module ClassMethods
|
14
72
|
|
15
73
|
|
16
74
|
# Meta-programmer method.
|
17
75
|
# @example Have your resource rendered in XML and JSON
|
18
76
|
# class MyResource
|
77
|
+
# include Rackful::Resource
|
19
78
|
# add_serializer MyResource2XML
|
20
79
|
# add_serializer MyResource2JSON, 0.5
|
21
80
|
# end
|
@@ -26,124 +85,124 @@ class Resource
|
|
26
85
|
quality = quality.to_f
|
27
86
|
quality = 1.0 if quality > 1.0
|
28
87
|
quality = 0.0 if quality < 0.0
|
29
|
-
|
30
|
-
serializer
|
31
|
-
|
32
|
-
|
33
|
-
|
88
|
+
sq = [serializer, quality]
|
89
|
+
serializer.content_types.each do
|
90
|
+
|content_type|
|
91
|
+
unless ( old = serializers[content_type] ) and # @formatter:off
|
92
|
+
old[1] > quality # @formatter:on
|
93
|
+
serializers[content_type] = sq
|
94
|
+
end
|
95
|
+
end
|
34
96
|
self
|
35
97
|
end
|
36
98
|
|
37
99
|
|
38
100
|
# Meta-programmer method.
|
101
|
+
#
|
102
|
+
# A parser is an object or a class that implements to the following two methods:
|
103
|
+
#
|
104
|
+
# 1. (Array<String>) media_types()
|
105
|
+
# 2. (void) parse(Request, Resource)
|
106
|
+
#
|
107
|
+
# The first call returns a list of media types this parser can parse, for example:
|
108
|
+
# `[ 'text/html', 'application/xhtml+xml' ]`. The second call parses a request
|
109
|
+
# in the context of a certain resource.
|
39
110
|
# @example Have your resource accept XHTML in `PUT` requests
|
40
111
|
# class MyResource
|
41
112
|
# include Rackful::Resource
|
42
113
|
# add_parser Rackful::Parser::XHTML, :PUT
|
43
114
|
# end
|
44
|
-
# @param parser [
|
115
|
+
# @param parser [#parse, #media_types] an implementation (ie. subclass) of {Parser}
|
45
116
|
# @param method [#to_sym] For example: `:PUT` or `:POST`
|
46
117
|
# @return [self]
|
47
118
|
def add_parser parser, method = :PUT
|
48
119
|
method = method.to_sym
|
49
|
-
|
50
|
-
|
51
|
-
|
120
|
+
parsers[method] ||= []
|
121
|
+
parser.media_types.each do
|
122
|
+
|mt|
|
123
|
+
parsers[method] << [mt, parser]
|
124
|
+
end
|
125
|
+
parsers[method].uniq!
|
52
126
|
self
|
53
127
|
end
|
54
128
|
|
55
129
|
|
56
|
-
# All parsers added to _this_ class. Ie. not including parsers added
|
57
|
-
# to parent classes.
|
58
|
-
# @return [Hash{Symbol => Array<Class>}] A hash of lists of {Parser} classes,
|
59
|
-
# indexed by HTTP method.
|
60
|
-
# @api private
|
61
|
-
def parsers
|
62
|
-
# The single '@' on the following line is on purpose!
|
63
|
-
@rackful_resource_parsers ||= {}
|
64
|
-
end
|
65
|
-
|
66
|
-
|
67
|
-
# All serializers added to _this_ class. Ie. not including serializers added
|
68
|
-
# to parent classes.
|
69
|
-
# @return [Hash{Serializer => Float}]
|
70
|
-
# @api private
|
71
|
-
def serializers
|
72
|
-
# The single '@' on the following line is on purpose!
|
73
|
-
@rackful_resource_serializers ||= {}
|
74
|
-
end
|
75
|
-
|
76
|
-
|
77
130
|
# All parsers for this class, including parsers added to parent classes.
|
78
131
|
# The result of this method is cached, which will interfere with code reloading.
|
79
|
-
# @param method [#to_sym] For example: `:PUT` or `:POST`
|
80
132
|
# @return [Hash{Symbol => Array<Class>}]
|
81
133
|
# @api private
|
82
134
|
def all_parsers
|
83
135
|
# The single '@' on the following line is on purpose!
|
84
136
|
@rackful_resource_all_parsers ||=
|
85
137
|
if self.superclass.respond_to?(:all_parsers)
|
86
|
-
self.
|
138
|
+
self.superclass.all_parsers.merge( parsers ) do
|
87
139
|
|key, oldval, newval|
|
88
140
|
( oldval + newval ).uniq
|
89
141
|
end
|
90
142
|
else
|
91
|
-
|
143
|
+
parsers
|
92
144
|
end
|
93
145
|
end
|
94
146
|
|
95
147
|
|
96
148
|
# All serializers for this class, including those added to parent classes.
|
97
149
|
# The result of this method is cached, which will interfere with code reloading.
|
98
|
-
# @return [Hash{
|
99
|
-
# serializer in the interval [0,1]
|
150
|
+
# @return [Hash{ String( content_type ) => Array( Serializer, Float(quality) ) }]
|
100
151
|
# @api private
|
101
152
|
def all_serializers
|
102
153
|
# The single '@' on the following line is on purpose!
|
103
154
|
@rackful_resource_all_serializers ||=
|
104
155
|
if self.superclass.respond_to?(:all_serializers)
|
105
|
-
self.superclass.all_serializers.merge(
|
156
|
+
self.superclass.all_serializers.merge( serializers ) do
|
106
157
|
|key, oldval, newval|
|
107
158
|
newval[1] >= oldval[1] ? newval : oldval
|
108
159
|
end
|
109
160
|
else
|
110
|
-
|
161
|
+
serializers
|
111
162
|
end
|
112
163
|
end
|
113
164
|
|
165
|
+
private
|
114
166
|
|
115
|
-
|
167
|
+
# All parsers added to _this_ class. Ie. not including parsers added
|
168
|
+
# to parent classes.
|
169
|
+
# @return [Hash{Symbol => Array<Class>}] A hash of lists of {Parser} classes,
|
170
|
+
# indexed by HTTP method.
|
171
|
+
# @api private
|
172
|
+
def parsers
|
173
|
+
# The single '@' on the following line is on purpose!
|
174
|
+
@rackful_resource_parsers ||= {}
|
175
|
+
end
|
116
176
|
|
117
177
|
|
118
|
-
|
119
|
-
|
178
|
+
# All serializers added to _this_ class. Ie. not including serializers added
|
179
|
+
# to parent classes.
|
180
|
+
# @return [Hash{ String( content_type ) => Array( Serializer, Float(quality) ) }]
|
181
|
+
# @api private
|
182
|
+
def serializers
|
183
|
+
# The single '@' on the following line is on purpose!
|
184
|
+
@rackful_resource_serializers ||= {}
|
185
|
+
end
|
120
186
|
|
121
187
|
|
122
|
-
|
123
|
-
@uri = uri.kind_of?( URI::Generic ) ? uri.dup : URI(uri.to_s).normalize
|
124
|
-
#self.uri = uri
|
125
|
-
end
|
188
|
+
end # module ClassMethods
|
126
189
|
|
127
190
|
|
128
|
-
|
129
|
-
|
130
|
-
@
|
131
|
-
|
132
|
-
|
133
|
-
=end
|
134
|
-
def serializer request, content_type = nil
|
135
|
-
content_type ||= request.best_content_type( self, false )
|
136
|
-
self.class.all_serializers[content_type][0].new( request, self, content_type )
|
191
|
+
# This callback includes all methods of {ClassMethods} into all classes that
|
192
|
+
# include {Resource}, to make them available as a tiny DSL.
|
193
|
+
# @api private
|
194
|
+
def self.included(base)
|
195
|
+
base.extend ClassMethods
|
137
196
|
end
|
138
197
|
|
139
198
|
|
140
|
-
|
141
|
-
|
142
|
-
@param
|
143
|
-
@return [Parser, nil] a {Parser}, or nil if the request entity is empty
|
144
|
-
@raise [HTTP415UnsupportedMediaType] if no parser can be found for the request entity
|
145
|
-
|
146
|
-
def
|
199
|
+
# Parse and “execute” the request body.
|
200
|
+
# @param request [Rackful::Request]
|
201
|
+
# @param response [Rack::Response]
|
202
|
+
# @return [Parser, nil] a {Parser}, or nil if the request entity is empty
|
203
|
+
# @raise [HTTP415UnsupportedMediaType] if no parser can be found for the request entity
|
204
|
+
# @api private
|
205
|
+
def parse request, response
|
147
206
|
unless request.content_length ||
|
148
207
|
'chunked' == request.env['HTTP_TRANSFER_ENCODING']
|
149
208
|
raise HTTP411LengthRequired
|
@@ -151,40 +210,25 @@ The best media type for the response body, given the current HTTP request.
|
|
151
210
|
request_media_type = request.media_type.to_s
|
152
211
|
supported_media_types = []
|
153
212
|
all_parsers = self.class.all_parsers[ request.request_method.to_sym ] || []
|
154
|
-
all_parsers.each do |p|
|
155
|
-
|
156
|
-
|
157
|
-
return p.new( request, self )
|
158
|
-
end
|
159
|
-
supported_media_types << parser_media_type
|
213
|
+
all_parsers.each do |mt, p|
|
214
|
+
if File.fnmatch( mt, request_media_type, File::FNM_PATHNAME )
|
215
|
+
return p.parse( request, response, self )
|
160
216
|
end
|
217
|
+
supported_media_types << mt
|
161
218
|
end
|
219
|
+
raise( HTTP405MethodNotAllowed, self.http_methods ) if supported_media_types.empty?
|
162
220
|
raise( HTTP415UnsupportedMediaType, supported_media_types.uniq )
|
163
221
|
end
|
164
222
|
|
165
223
|
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
To handle certain HTTP/1.1 request methods, resources must implement methods
|
171
|
-
called `do_<HTTP_METHOD>`.
|
172
|
-
@example Handling `PATCH` requests
|
173
|
-
def do_PATCH request, response
|
174
|
-
response['Content-Type'] = 'text/plain'
|
175
|
-
response.body = [ 'Hello world!' ]
|
176
|
-
end
|
177
|
-
@abstract
|
178
|
-
@return [void]
|
179
|
-
@raise [HTTPStatus, RuntimeError]
|
180
|
-
=end
|
224
|
+
# The canonical path of this resource.
|
225
|
+
# @return [URI]
|
226
|
+
attr_reader :uri
|
181
227
|
|
182
228
|
|
183
|
-
=
|
184
|
-
|
185
|
-
|
186
|
-
=end
|
187
|
-
attr_reader :uri
|
229
|
+
def uri=( uri )
|
230
|
+
@uri = uri.kind_of?(URI::Generic) ? uri.dup : URI(uri).normalize
|
231
|
+
end
|
188
232
|
|
189
233
|
|
190
234
|
def title
|
@@ -192,16 +236,14 @@ The canonical path of this resource.
|
|
192
236
|
end
|
193
237
|
|
194
238
|
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
@return [Boolean] The default implementation returns `false`.
|
204
|
-
=end
|
239
|
+
# Does this resource _exist_?
|
240
|
+
#
|
241
|
+
# For example, a client can `PUT` to a URL that doesn't refer to a resource
|
242
|
+
# yet. In that case, your {Server#initialize resource registry} can
|
243
|
+
# produce an empty resource to handle the `PUT` request. `HEAD` and `GET`
|
244
|
+
# requests will still yield `404 Not Found`.
|
245
|
+
#
|
246
|
+
# @return [Boolean] The default implementation returns `false`.
|
205
247
|
def empty?
|
206
248
|
false
|
207
249
|
end
|
@@ -212,58 +254,13 @@ requests will still yield `404 Not Found`.
|
|
212
254
|
end
|
213
255
|
|
214
256
|
|
215
|
-
=begin markdown
|
216
|
-
@!attribute [r] get_etag
|
217
|
-
The ETag of this resource.
|
218
|
-
|
219
|
-
If your classes implement this method, then an `ETag:` response
|
220
|
-
header is generated automatically when appropriate. This allows clients to
|
221
|
-
perform conditional requests, by sending an `If-Match:` or
|
222
|
-
`If-None-Match:` request header. These conditions are then asserted
|
223
|
-
for you automatically.
|
224
|
-
|
225
|
-
Make sure your entity tag is a properly formatted string. In ABNF:
|
226
|
-
|
227
|
-
entity-tag = [ "W/" ] quoted-string
|
228
|
-
quoted-string = ( <"> *(qdtext | quoted-pair ) <"> )
|
229
|
-
qdtext = <any TEXT except <">>
|
230
|
-
quoted-pair = "\" CHAR
|
231
|
-
|
232
|
-
@abstract
|
233
|
-
@return [String]
|
234
|
-
@see http://tools.ietf.org/html/rfc2616#section-14.19 RFC2616 section 14.19
|
235
|
-
=end
|
236
|
-
|
237
|
-
|
238
|
-
=begin markdown
|
239
|
-
@!attribute [r] get_last_modified
|
240
|
-
Last modification of this resource.
|
241
257
|
|
242
|
-
If your classes implement this method, then a `Last-Modified:` response
|
243
|
-
header is generated automatically when appropriate. This allows clients to
|
244
|
-
perform conditional requests, by sending an `If-Modified-Since:` or
|
245
|
-
`If-Unmodified-Since:` request header. These conditions are then asserted
|
246
|
-
for you automatically.
|
247
|
-
@abstract
|
248
|
-
@return [Array<(Time, Boolean)>] The timestamp, and a flag indicating if the
|
249
|
-
timestamp is a strong validator.
|
250
|
-
@see http://tools.ietf.org/html/rfc2616#section-14.29 RFC2616 section 14.29
|
251
|
-
=end
|
252
258
|
|
253
|
-
|
254
|
-
|
255
|
-
|
256
|
-
@return [
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
=begin markdown
|
261
|
-
List of all HTTP/1.1 methods implemented by this resource.
|
262
|
-
|
263
|
-
This works by inspecting all the {#do_METHOD} methods this object implements.
|
264
|
-
@return [Array<Symbol>]
|
265
|
-
@api private
|
266
|
-
=end
|
259
|
+
# List of all HTTP/1.1 methods implemented by this resource.
|
260
|
+
#
|
261
|
+
# This works by inspecting all the {#do_METHOD} methods this object implements.
|
262
|
+
# @return [Array<Symbol>]
|
263
|
+
# @api private
|
267
264
|
def http_methods
|
268
265
|
r = []
|
269
266
|
if self.empty?
|
@@ -284,33 +281,29 @@ This works by inspecting all the {#do_METHOD} methods this object implements.
|
|
284
281
|
end
|
285
282
|
|
286
283
|
|
287
|
-
|
288
|
-
|
289
|
-
|
290
|
-
|
291
|
-
|
292
|
-
|
293
|
-
|
294
|
-
|
295
|
-
|
296
|
-
@
|
297
|
-
@raise [HTTP404NotFound] `404 Not Found` if this resource is empty.
|
298
|
-
=end
|
284
|
+
# Handles an OPTIONS request.
|
285
|
+
#
|
286
|
+
# As a courtesy, this module implements a default handler for OPTIONS
|
287
|
+
# requests. It creates an `Allow:` header, listing all implemented HTTP/1.1
|
288
|
+
# methods for this resource. By default, an `HTTP/1.1 204 No Content` is
|
289
|
+
# returned (without an entity body).
|
290
|
+
#
|
291
|
+
# Feel free to override this method at will.
|
292
|
+
# @return [void]
|
293
|
+
# @raise [HTTP404NotFound] `404 Not Found` if this resource is empty.
|
299
294
|
def http_OPTIONS request, response
|
300
295
|
response.status = Rack::Utils.status_code :no_content
|
301
296
|
response.header['Allow'] = self.http_methods.join ', '
|
302
297
|
end
|
303
298
|
|
304
299
|
|
305
|
-
|
306
|
-
|
307
|
-
|
308
|
-
|
309
|
-
|
310
|
-
|
311
|
-
|
312
|
-
@return [self]
|
313
|
-
=end
|
300
|
+
# Handles a HEAD request.
|
301
|
+
#
|
302
|
+
# This default handler for HEAD requests calls {#http\_GET}, and
|
303
|
+
# then strips off the response body.
|
304
|
+
#
|
305
|
+
# Feel free to override this method at will.
|
306
|
+
# @return [self]
|
314
307
|
def http_HEAD request, response
|
315
308
|
self.http_GET request, response
|
316
309
|
response['Content-Length'] =
|
@@ -323,21 +316,18 @@ Feel free to override this method at will.
|
|
323
316
|
end
|
324
317
|
|
325
318
|
|
326
|
-
|
327
|
-
@
|
328
|
-
@param
|
329
|
-
@
|
330
|
-
@
|
331
|
-
@raise [HTTP404NotFound, HTTP405MethodNotAllowed]
|
332
|
-
=end
|
319
|
+
# @api private
|
320
|
+
# @param request [Rackful::Request]
|
321
|
+
# @param response [Rack::Response]
|
322
|
+
# @return [void]
|
323
|
+
# @raise [HTTP404NotFound, HTTP405MethodNotAllowed]
|
333
324
|
def http_GET request, response
|
334
325
|
raise HTTP404NotFound if self.empty?
|
335
326
|
# May throw HTTP406NotAcceptable:
|
336
|
-
|
337
|
-
response['Content-Type'] = content_type
|
327
|
+
serializer = self.serializer( request )
|
328
|
+
response['Content-Type'] = serializer.content_type
|
338
329
|
response.status = Rack::Utils.status_code( :ok )
|
339
330
|
response.headers.merge! self.default_headers
|
340
|
-
serializer = self.serializer( request, content_type )
|
341
331
|
if serializer.respond_to? :headers
|
342
332
|
response.headers.merge!( serializer.headers )
|
343
333
|
end
|
@@ -345,12 +335,47 @@ Feel free to override this method at will.
|
|
345
335
|
end
|
346
336
|
|
347
337
|
|
348
|
-
|
349
|
-
|
350
|
-
@
|
351
|
-
|
352
|
-
|
353
|
-
|
338
|
+
# The best serializer to represent this resource, given the current HTTP request.
|
339
|
+
# @param request [Request] the current request
|
340
|
+
# @param require_match [Boolean] this flag determines what must happen if the
|
341
|
+
# client sent an `Accept:` header, and we cannot serve any of the acceptable
|
342
|
+
# media types. **`TRUE`** means that an {HTTP406NotAcceptable} exception is
|
343
|
+
# raised. **`FALSE`** means that the content-type with the highest quality is
|
344
|
+
# returned.
|
345
|
+
# @return [Serializer]
|
346
|
+
# @raise [HTTP406NotAcceptable]
|
347
|
+
def serializer( request, require_match = true )
|
348
|
+
q_values = request.q_values # Array<Array(type, quality)>
|
349
|
+
default_serializer = # @formatter:off
|
350
|
+
# Hash{ String( content_type ) => Array( Serializer, Float(quality) ) }
|
351
|
+
self.class.all_serializers.
|
352
|
+
# Array< Array( Serializer, Float(quality) ) >
|
353
|
+
values.sort_by(&:last).
|
354
|
+
# Serializer
|
355
|
+
last.first # @formatter:on
|
356
|
+
best_match = [ default_serializer, 0.0, default_serializer.content_types.first ]
|
357
|
+
q_values.each do
|
358
|
+
|accept_media_type, accept_quality|
|
359
|
+
self.class.all_serializers.each_pair do
|
360
|
+
|content_type, sq|
|
361
|
+
media_type = content_type.split(/\s*;\s*/).first
|
362
|
+
if File.fnmatch( accept_media_type, media_type, File::FNM_PATHNAME ) and
|
363
|
+
best_match.nil? || best_match[1] < ( accept_quality * sq[1] )
|
364
|
+
best_match = [ sq[0], sq[1], content_type ]
|
365
|
+
end
|
366
|
+
end
|
367
|
+
end
|
368
|
+
if require_match and best_match[1] <= 0.0
|
369
|
+
raise( HTTP406NotAcceptable, self.class.all_serializers.keys() )
|
370
|
+
end
|
371
|
+
best_match[0].new(request, self, best_match[2])
|
372
|
+
end
|
373
|
+
|
374
|
+
|
375
|
+
# Wrapper around {#do_METHOD #do_GET}
|
376
|
+
# @api private
|
377
|
+
# @return [void]
|
378
|
+
# @raise [HTTP404NotFound, HTTP405MethodNotAllowed]
|
354
379
|
def http_DELETE request, response
|
355
380
|
raise HTTP404NotFound if self.empty?
|
356
381
|
raise HTTP405MethodNotAllowed, self.http_methods unless
|
@@ -362,26 +387,61 @@ Wrapper around {#do_METHOD #do_GET}
|
|
362
387
|
end
|
363
388
|
|
364
389
|
|
365
|
-
|
366
|
-
@
|
367
|
-
@
|
368
|
-
|
369
|
-
|
390
|
+
# @api private
|
391
|
+
# @return [void]
|
392
|
+
# @raise [HTTP404NotFound, HTTP415UnsupportedMediaType, HTTP405MethodNotAllowed] if the
|
393
|
+
# resource doesn’t implement the `PATCH` method or can’t handle the provided
|
394
|
+
# request body media type.
|
395
|
+
def http_PATCH request, response
|
396
|
+
raise HTTP404NotFound if self.empty?
|
397
|
+
response.status = :no_content
|
398
|
+
begin
|
399
|
+
parse(request, response)
|
400
|
+
rescue HTTP405MethodNotAllowed => e
|
401
|
+
raise e unless self.respond_to? :do_PATCH
|
402
|
+
self.do_PATCH( request, response )
|
403
|
+
end
|
404
|
+
response.headers.merge! self.default_headers
|
405
|
+
end
|
406
|
+
|
407
|
+
|
408
|
+
# @api private
|
409
|
+
# @return [void]
|
410
|
+
# @raise [HTTP415UnsupportedMediaType, HTTP405MethodNotAllowed] if the
|
411
|
+
# resource doesn’t implement the `POST` method or can’t handle the provided
|
412
|
+
# request body media type.
|
413
|
+
def http_POST request, response
|
414
|
+
begin
|
415
|
+
parse(request, response)
|
416
|
+
rescue HTTP405MethodNotAllowed => e
|
417
|
+
raise e unless self.respond_to? :do_POST
|
418
|
+
self.do_POST( request, response )
|
419
|
+
end
|
420
|
+
end
|
421
|
+
|
422
|
+
|
423
|
+
# @api private
|
424
|
+
# @return [void]
|
425
|
+
# @raise [HTTP415UnsupportedMediaType, HTTP405MethodNotAllowed] if the
|
426
|
+
# resource doesn’t implement the `PUT` method or can’t handle the provided
|
427
|
+
# request body media type.
|
370
428
|
def http_PUT request, response
|
371
|
-
raise HTTP405MethodNotAllowed, self.http_methods unless self.respond_to? :do_PUT
|
372
429
|
response.status = Rack::Utils.status_code( self.empty? ? :created : :no_content )
|
373
|
-
|
430
|
+
begin
|
431
|
+
parse(request, response)
|
432
|
+
rescue HTTP405MethodNotAllowed => e
|
433
|
+
raise e unless self.respond_to? :do_PUT
|
434
|
+
self.do_PUT( request, response )
|
435
|
+
end
|
374
436
|
response.headers.merge! self.default_headers
|
375
437
|
end
|
376
438
|
|
377
439
|
|
378
|
-
|
379
|
-
|
380
|
-
@
|
381
|
-
@
|
382
|
-
|
383
|
-
the request method.
|
384
|
-
=end
|
440
|
+
# Wrapper around {#do_METHOD}
|
441
|
+
# @api private
|
442
|
+
# @return [void]
|
443
|
+
# @raise [HTTPStatus] `405 Method Not Allowed` if the resource doesn't implement
|
444
|
+
# the request method.
|
385
445
|
def http_method request, response
|
386
446
|
method = request.request_method.to_sym
|
387
447
|
if ! self.respond_to?( :"do_#{method}" )
|
@@ -391,9 +451,7 @@ Wrapper around {#do_METHOD}
|
|
391
451
|
end
|
392
452
|
|
393
453
|
|
394
|
-
|
395
|
-
Adds `ETag:` and `Last-Modified:` response headers.
|
396
|
-
=end
|
454
|
+
# Adds `ETag:` and `Last-Modified:` response headers.
|
397
455
|
def default_headers
|
398
456
|
r = {}
|
399
457
|
r['ETag'] = self.get_etag \
|
@@ -404,7 +462,5 @@ Adds `ETag:` and `Last-Modified:` response headers.
|
|
404
462
|
end
|
405
463
|
|
406
464
|
|
407
|
-
end # module Resource
|
408
|
-
|
409
|
-
|
465
|
+
end # module Rackful::Resource
|
410
466
|
end # module Rackful
|