rackful 0.0.2 → 0.1.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.
@@ -0,0 +1,288 @@
1
+ # Required for parsing:
2
+ require 'rackful_resource.rb'
3
+ require 'rackful_serializer.rb'
4
+
5
+ # Required for running
6
+ require 'rexml/rexml'
7
+
8
+
9
+ module Rackful
10
+
11
+ =begin markdown
12
+ Exception which represents an HTTP Status response.
13
+ @since 0.1.0
14
+ @abstract
15
+ =end
16
+ class HTTPStatus < RuntimeError
17
+
18
+
19
+ include Resource
20
+ attr_reader :status, :headers, :to_rackful
21
+
22
+
23
+ =begin markdown
24
+ @param message [String] XHTML
25
+ @param status [Symbol, Integer] e.g. `404` or `:not_found`
26
+ @param headers [Hash] HTTP response headers
27
+ =end
28
+ def initialize status, message = nil, info = {}
29
+ self.path = Request.current.path
30
+ @status = status_code status
31
+ raise "Wrong status: #{status}" if 0 == @status
32
+ message ||= ''
33
+ @headers = {}
34
+ @to_rackful = {}
35
+ info.each do
36
+ |k, v|
37
+ if k.kind_of? Symbol then @to_rackful[k] = v
38
+ else @headers[k] = v end
39
+ end
40
+ @to_rackful = nil if @to_rackful.empty?
41
+ begin
42
+ REXML::Document.new \
43
+ '<?xml version="1.0" encoding="UTF-8" ?>' +
44
+ "<div>#{message}</div>"
45
+ rescue
46
+ message = Rack::Utils.escape_html(message)
47
+ end
48
+ super message
49
+ if 500 <= @status
50
+ errors = Request.current.env['rack.errors']
51
+ errors.puts self.inspect
52
+ errors.puts "Headers: #{@headers.inspect}"
53
+ errors.puts "Info: #{@to_rackful.inspect}"
54
+ end
55
+ end
56
+
57
+
58
+ class XHTML < ::Rackful::XHTML
59
+
60
+
61
+ HTTP_STATUS_CODES = Rack::Utils::HTTP_STATUS_CODES
62
+
63
+
64
+ def header
65
+ <<EOS
66
+ <title>#{self.resource.status} #{HTTP_STATUS_CODES[self.resource.status]}</title>
67
+ </head><body>
68
+ <h1>HTTP/1.1 #{self.resource.status} #{HTTP_STATUS_CODES[self.resource.status]}</h1>
69
+ <div id="rackful_description">#{self.resource.message}</div>
70
+ EOS
71
+ end
72
+
73
+
74
+ def headers
75
+ self.resource.headers
76
+ end
77
+
78
+
79
+ end # class HTTPStatus::XHTML
80
+
81
+
82
+ add_serializer XHTML, 1.0
83
+
84
+
85
+ end # class Rackful::HTTPStatus
86
+
87
+
88
+ =begin markdown
89
+ @abstract Base class for HTTP status codes with only a simple text message, or
90
+ no message at all.
91
+ @since 0.1.0
92
+ =end
93
+ class HTTPSimpleStatus < HTTPStatus
94
+
95
+ def initialize message = nil
96
+ /HTTP(\d\d\d)\w+\z/ === self.class.to_s
97
+ status = $1.to_i
98
+ super( status, message )
99
+ end
100
+
101
+ end
102
+
103
+
104
+ # @since 0.1.0
105
+ class HTTP201Created < HTTPStatus
106
+
107
+ def initialize locations
108
+ locations = [ locations ] unless locations.kind_of? Array
109
+ locations = locations.collect { |l| l.to_path }
110
+ rf = Request.current.resource_factory
111
+ if locations.size > 1
112
+ locations = locations.collect {
113
+ |l|
114
+ resource = rf[l]
115
+ { :location => l }.merge( resource.default_headers )
116
+ rf.uncache l if rf.respond_to? :uncache
117
+ }
118
+ super(
119
+ 201, 'New resources were created:', :locations => locations
120
+ )
121
+ else
122
+ location = locations[0]
123
+ resource = rf[location]
124
+ super(
125
+ 201, 'A new resource was created:', {
126
+ :location => location,
127
+ 'Location' => location
128
+ }.merge( resource.default_headers )
129
+ )
130
+ end
131
+ end
132
+
133
+ end # class HTTP201Created
134
+
135
+
136
+ # @since 0.1.0
137
+ class HTTP202Accepted < HTTPStatus
138
+
139
+ def initialize location = nil
140
+ if location
141
+ super(
142
+ 202, '', {
143
+ :'Job status location:' => Path.new(locations),
144
+ 'Location' => locations
145
+ }
146
+ )
147
+ else
148
+ super 202
149
+ end
150
+ end
151
+
152
+ end # class HTTP202Accepted
153
+
154
+
155
+ # @since 0.1.0
156
+ class HTTP301MovedPermanently < HTTPStatus
157
+
158
+ def initialize location
159
+ super(
160
+ 301, '', {
161
+ :'New location:' => Path.new(location),
162
+ 'Location' => location
163
+ }
164
+ )
165
+ end
166
+
167
+ end
168
+
169
+
170
+ # @since 0.1.0
171
+ class HTTP303SeeOther < HTTPStatus
172
+
173
+ def initialize location
174
+ super(
175
+ 303, '', {
176
+ :'See:' => Path.new(location),
177
+ 'Location' => location
178
+ }
179
+ )
180
+ end
181
+
182
+ end
183
+
184
+
185
+ # @since 0.1.0
186
+ class HTTP304NotModified < HTTPStatus
187
+
188
+ def initialize location
189
+ super( 304, nil, self.resource.default_headers )
190
+ end
191
+
192
+ end
193
+
194
+
195
+ # @since 0.1.0
196
+ class HTTP307TemporaryRedirect < HTTPStatus
197
+
198
+ def initialize location
199
+ super(
200
+ 301, '', {
201
+ :'Current location:' => Path.new(location),
202
+ 'Location' => location
203
+ }
204
+ )
205
+ end
206
+
207
+ end
208
+
209
+
210
+ # @since 0.1.0
211
+ class HTTP400BadRequest < HTTPSimpleStatus; end
212
+
213
+ # @since 0.1.0
214
+ class HTTP403Forbidden < HTTPSimpleStatus; end
215
+
216
+ # @since 0.1.0
217
+ class HTTP404NotFound < HTTPSimpleStatus; end
218
+
219
+
220
+ # @since 0.1.0
221
+ class HTTP405MethodNotAllowed < HTTPStatus
222
+
223
+ def initialize methods
224
+ super 405, '', 'Allow' => methods.join(', '),
225
+ :'Allowed methods:' => methods
226
+ end
227
+
228
+ end
229
+
230
+
231
+ # @since 0.1.0
232
+ class HTTP406NotAcceptable < HTTPStatus
233
+
234
+ def initialize content_types
235
+ super 406, '',
236
+ :'Available content-type(s):' => content_types
237
+ end
238
+
239
+ end
240
+
241
+
242
+ # @since 0.1.0
243
+ class HTTP409Conflict < HTTPSimpleStatus; end
244
+
245
+ # @since 0.1.0
246
+ class HTTP410Gone < HTTPSimpleStatus; end
247
+
248
+ # @since 0.1.0
249
+ class HTTP411LengthRequired < HTTPSimpleStatus; end
250
+
251
+
252
+ # @since 0.1.0
253
+ class HTTP412PreconditionFailed < HTTPStatus
254
+
255
+ def initialize header = nil
256
+ info =
257
+ if header
258
+ { header.to_sym => Request.current.env[ 'HTTP_' + header.gsub('-', '_').upcase ] }
259
+ else
260
+ {}
261
+ end
262
+ super 412, 'Failed precondition:', info
263
+ end
264
+
265
+ end
266
+
267
+
268
+ # @since 0.1.0
269
+ class HTTP415UnsupportedMediaType < HTTPStatus
270
+
271
+ def initialize media_types
272
+ super 405, '',
273
+ :'Supported media-type(s):' => media_types
274
+ end
275
+
276
+ end
277
+
278
+
279
+ # @since 0.1.0
280
+ class HTTP422UnprocessableEntity < HTTPSimpleStatus; end
281
+
282
+ # @since 0.1.0
283
+ class HTTP501NotImplemented < HTTPSimpleStatus; end
284
+
285
+ # @since 0.1.0
286
+ class HTTP503ServiceUnavailable < HTTPSimpleStatus; end
287
+
288
+ end # module Rackful
@@ -0,0 +1,112 @@
1
+ # Required for parsing:
2
+
3
+ # Required for running:
4
+ require 'rack/utils'
5
+
6
+
7
+ # A String monkeypatch
8
+ # @private
9
+ class String
10
+
11
+ def to_path; Rackful::Path.new(self); end
12
+
13
+ end
14
+
15
+
16
+ module Rackful
17
+
18
+
19
+ # Relative URI (a path)
20
+ class Path < String
21
+
22
+ def slashify
23
+ r = self.dup
24
+ r << '/' if '/' != r[-1,1]
25
+ r
26
+ end
27
+
28
+ def slashify!
29
+ if '/' != self[-1,1]
30
+ self << '/'
31
+ else
32
+ nil
33
+ end
34
+ end
35
+
36
+ def unslashify
37
+ r = self.dup
38
+ r = r.chomp( '/' ) if '/' == r[-1,1]
39
+ r
40
+ end
41
+
42
+ def unslashify!
43
+ if '/' == self[-1,1]
44
+ self.chomp! '/'
45
+ else
46
+ nil
47
+ end
48
+ end
49
+
50
+ # An alias for Rack::Utils.unescape
51
+ def unescape( encoding = Encoding::UTF_8 ); Rack::Utils.unescape(self, encoding); end
52
+
53
+ end # class Path
54
+
55
+
56
+ end # module Rackful
57
+
58
+ =begin comment
59
+ # Monkeypatch to this stdlib class.
60
+ class URI::Generic
61
+
62
+ # @see http://www.w3.org/TR/html401/struct/links.html#adef-rel the HTML `rel` attribute.
63
+ attr_accessor :rel
64
+
65
+ # @see http://www.w3.org/TR/html401/struct/links.html#adef-rev the HTML `rev` attribute.
66
+ attr_accessor :rev
67
+
68
+ def to_xhtml base_path, encoding = Encoding::UTF_8
69
+ retval = "<a href=\"#{self.route_from(base_path)}\"".encode encoding
70
+ retval << " rel=\"#{self.rel}\"" if self.rel
71
+ retval << " rev=\"#{self.rev}\"" if self.rev
72
+ retval << '>'
73
+ if self.relative? && ! self.query && ! self.fragment
74
+ retval << Rack::Utils.escape_html(
75
+ Rack::Utils.unescape( self.route_from(base_path).to_s, encoding )
76
+ )
77
+ else
78
+ retval << self.to_s
79
+ end
80
+ retval << '</a>'
81
+ retval
82
+ end
83
+
84
+ # @return [URI::Generic]
85
+ def slashify
86
+ r = self.dup
87
+ r.path = r.path.unslashify
88
+ r
89
+ end
90
+
91
+ # @return [self, nil]
92
+ def slashify!
93
+ self.path.slashify! && self
94
+ end
95
+
96
+ # @return [URI::Generic]
97
+ def unslashify
98
+ r = self.dup
99
+ r.path = r.path.unslashify
100
+ r
101
+ end
102
+
103
+ # @return [self, nil]
104
+ def unslashify!
105
+ self.path.unslashify! && self
106
+ end
107
+
108
+ end # class ::URI::Generic
109
+ =end comment
110
+
111
+
112
+
@@ -0,0 +1,268 @@
1
+ # Required for parsing:
2
+ require 'rack'
3
+
4
+ # Required for running:
5
+
6
+
7
+ module Rackful
8
+
9
+ =begin markdown
10
+ Subclass of {Rack::Request}, augmented for Rackful requests.
11
+ @since 0.0.1
12
+ =end
13
+ class Request < Rack::Request
14
+
15
+
16
+ =begin markdown
17
+ The resource factory for the current request.
18
+ @return [#[]]
19
+ @see Server#initialize
20
+ @since 0.0.1
21
+ =end
22
+ def resource_factory; self.env['rackful.resource_factory']; end
23
+ def base_path
24
+ self.env['rackful.base_path'] ||= begin
25
+ r = self.content_path.dup
26
+ r[%r{[^/]*\z}] = ''
27
+ r
28
+ end
29
+ end
30
+ =begin markdown
31
+ Similar to the HTTP/1.1 `Content-Location:` header. Contains the canonical path
32
+ to the requested resource, which may differ from {#path}
33
+ @return [Path]
34
+ @since 0.1.0
35
+ =end
36
+ def content_path; self.env['rackful.content_path'] ||= self.path; end
37
+ =begin markdown
38
+ Set by {Rackful::Server#call!}
39
+ @return [Path]
40
+ @since 0.1.0
41
+ =end
42
+ def content_path= bp; self.env['rackful.content_path'] = bp.to_path; end
43
+ =begin markdown
44
+ @return [Path]
45
+ @since 0.1.0
46
+ =end
47
+ def path; super.to_path; end
48
+
49
+
50
+ def initialize resource_factory, *args
51
+ super( *args )
52
+ self.env['rackful.resource_factory'] = resource_factory
53
+ end
54
+
55
+
56
+ =begin markdown
57
+ The request currently being processed in the current thread.
58
+
59
+ In a multi-threaded server, multiple requests can be handled at one time.
60
+ This method returns the request object, created (and registered) by
61
+ {Server#call!}
62
+ @return [Request]
63
+ @since 0.0.1
64
+ =end
65
+ def self.current
66
+ Thread.current[:rackful_request]
67
+ end
68
+
69
+
70
+ =begin markdown
71
+ Assert all <tt>If-*</tt> request headers.
72
+ @return [void]
73
+ @raise [HTTP304NotModified, HTTP400BadRequest, HTTP404NotFound, HTTP412PreconditionFailed]
74
+ with the following meanings:
75
+
76
+ - `304 Not Modified`
77
+ - `400 Bad Request` Couldn't parse one or more <tt>If-*</tt> headers, or a
78
+ weak validator comparison was requested for methods other than `GET` or
79
+ `HEAD`.
80
+ - `404 Not Found`
81
+ - `412 Precondition Failed`
82
+ @see http://tools.ietf.org/html/rfc2616#section-13.3.3 RFC2616, section 13.3.3
83
+ for details about weak and strong validator comparison.
84
+ @todo Implement support for the `If-Range:` header.
85
+ @since 0.0.1
86
+ =end
87
+ def assert_if_headers resource
88
+ #raise HTTP501NotImplemented, 'If-Range: request header is not supported.' \
89
+ # if @env.key? 'HTTP_IF_RANGE'
90
+ empty = resource.empty?
91
+ etag =
92
+ if ! empty && resource.respond_to?(:get_etag)
93
+ resource.get_etag
94
+ else
95
+ nil
96
+ end
97
+ last_modified =
98
+ if ! empty && resource.respond_to?(:get_last_modified)
99
+ resource.get_last_modified
100
+ else
101
+ nil
102
+ end
103
+ cond = {
104
+ :match => self.if_match,
105
+ :none_match => self.if_none_match,
106
+ :modified_since => self.if_modified_since,
107
+ :unmodified_since => self.if_unmodified_since
108
+ }
109
+ allow_weak = ['GET', 'HEAD'].include? self.request_method
110
+ if empty
111
+ if cond[:match]
112
+ raise HTTP412PreconditionFailed, 'If-Match'
113
+ elsif cond[:unmodified_since]
114
+ raise HTTP412PreconditionFailed, 'If-Unmodified-Since'
115
+ elsif cond[:modified_since]
116
+ raise HTTP404NotFound
117
+ end
118
+ else
119
+ if cond[:none_match] && self.validate_etag( etag, cond[:none_match] )
120
+ raise HTTP412PreconditionFailed, 'If-None-Match'
121
+ elsif cond[:match] && ! self.validate_etag( etag, cond[:match] )
122
+ raise HTTP412PreconditionFailed, 'If-Match'
123
+ elsif cond[:unmodified_since]
124
+ if ! last_modified || cond[:unmodified_since] < last_modified[0]
125
+ raise HTTP412PreconditionFailed, 'If-Unmodified-Since'
126
+ elsif last_modified && ! last_modified[1] && ! allow_weak &&
127
+ cond[:unmodified_since] == last_modified[0]
128
+ raise HTTP412PreconditionFailed, 'If-Unmodified-Since'
129
+ end
130
+ elsif cond[:modified_since]
131
+ if ! last_modified || cond[:modified_since] >= last_modified[0]
132
+ raise HTTP304NotModified
133
+ elsif last_modified && ! last_modified[1] && !allow_weak &&
134
+ cond[:modified_since] == last_modified[0]
135
+ raise HTTP412PreconditionFailed, 'If-Modified-Since'
136
+ end
137
+ end
138
+ end
139
+ end
140
+
141
+
142
+ =begin markdown
143
+ Hash of acceptable media types and their qualities.
144
+
145
+ This method parses the HTTP/1.1 `Accept:` header. If no acceptable media
146
+ types are provided, an empty Hash is returned.
147
+ @return [Hash{media_type => quality}]
148
+ @since 0.0.1
149
+ =end
150
+ def accept
151
+ @env['rackful.accept'] ||= begin
152
+ Hash[
153
+ @env['HTTP_ACCEPT'].to_s.split(',').collect do
154
+ |entry|
155
+ type, *options = entry.delete(' ').split(';')
156
+ quality = 1
157
+ options.each { |e|
158
+ quality = e[2..-1].to_f if e.start_with? 'q='
159
+ }
160
+ [type, quality]
161
+ end
162
+ ]
163
+ rescue
164
+ {}
165
+ end
166
+ end # def accept
167
+
168
+
169
+ =begin markdown
170
+ @!method if_match()
171
+ Parses the HTTP/1.1 `If-Match:` header.
172
+ @return [nil, Array<String>]
173
+ @see http://tools.ietf.org/html/rfc2616#section-14.24 RFC2616, section 14.24
174
+ @see #if_none_match
175
+ @since 0.0.1
176
+ =end
177
+ def if_match none = false
178
+ header = @env["HTTP_IF_#{ none ? 'NONE_' : '' }MATCH"]
179
+ return nil unless header
180
+ envkey = "rackful.if_#{ none ? 'none_' : '' }match"
181
+ if %r{\A\s*\*\s*\z} === header
182
+ return [ '*' ]
183
+ elsif %r{\A(\s*(W/)?"([^"\\]|\\.)*"\s*,)+\z}m === ( header + ',' )
184
+ return header.scan( %r{(?:W/)?"(?:[^"\\]|\\.)*"}m )
185
+ end
186
+ raise HTTP400BadRequest, "Couldn't parse If-#{ none ? 'None-' : '' }Match: #{header}"
187
+ end
188
+
189
+
190
+ =begin markdown
191
+ Parses the HTTP/1.1 `If-None-Match:` header.
192
+ @return [nil, Array<String>]
193
+ @see http://tools.ietf.org/html/rfc2616#section-14.26 RFC2616, section 14.26
194
+ @see #if_match
195
+ @since 0.0.1
196
+ =end
197
+ def if_none_match
198
+ self.if_match true
199
+ end
200
+
201
+
202
+ =begin markdown
203
+ @!method if_modified_since()
204
+ @return [nil, Time]
205
+ @see http://tools.ietf.org/html/rfc2616#section-14.25 RFC2616, section 14.25
206
+ @see #if_unmodified_since
207
+ @since 0.0.1
208
+ =end
209
+ def if_modified_since unmodified = false
210
+ header = @env["HTTP_IF_#{ unmodified ? 'UN' : '' }MODIFIED_SINCE"]
211
+ return nil unless header
212
+ begin
213
+ header = Time.httpdate( header )
214
+ rescue ArgumentError
215
+ raise HTTP400BadRequest, "Couldn't parse If-#{ unmodified ? 'Unmodified' : 'Modified' }-Since: #{header}"
216
+ end
217
+ header
218
+ end
219
+
220
+
221
+ =begin markdown
222
+ @return [nil, Time]
223
+ @see http://tools.ietf.org/html/rfc2616#section-14.28 RFC2616, section 14.28
224
+ @see #if_modified_since
225
+ @since 0.0.1
226
+ =end
227
+ def if_unmodified_since
228
+ self.if_modified_since true
229
+ end
230
+
231
+
232
+ =begin markdown
233
+ Does any of the tags in `etags` match `etag`?
234
+ @param etag [#to_s]
235
+ @param etags [#to_a]
236
+ @example
237
+ etag = '"foo"'
238
+ etags = [ 'W/"foo"', '"bar"' ]
239
+ validate_etag etag, etags
240
+ #> true
241
+ @return [Boolean]
242
+ @see http://tools.ietf.org/html/rfc2616#section-13.3.3 RFC2616 section 13.3.3
243
+ for details about weak and strong validator comparison.
244
+ @since 0.0.1
245
+ =end
246
+ def validate_etag etag, etags
247
+ etag = etag.to_s
248
+ match = etags.to_a.detect do
249
+ |tag|
250
+ tag = tag.to_s
251
+ tag == '*' or
252
+ tag == etag or
253
+ 'W/' + tag == etag or
254
+ 'W/' + etag == tag
255
+ end
256
+ if match and
257
+ '*' != match and
258
+ 'W/' == etag[0,2] || 'W/' == match[0,2] and
259
+ ! [ 'HEAD', 'GET' ].include? self.request_method
260
+ raise HTTP400BadRequest, "Weak validators are only allowed for GET and HEAD requests."
261
+ end
262
+ !!match
263
+ end
264
+
265
+
266
+ end # class Request
267
+
268
+ end # module Rackful