doze 0.0.11
Sign up to get free protection for your applications and to get access to all the features.
- data/README +6 -0
- data/lib/doze/application.rb +92 -0
- data/lib/doze/collection/object.rb +14 -0
- data/lib/doze/entity.rb +62 -0
- data/lib/doze/error.rb +75 -0
- data/lib/doze/media_type.rb +135 -0
- data/lib/doze/negotiator.rb +107 -0
- data/lib/doze/request.rb +119 -0
- data/lib/doze/resource/error.rb +21 -0
- data/lib/doze/resource/proxy.rb +81 -0
- data/lib/doze/resource.rb +193 -0
- data/lib/doze/responder/error.rb +34 -0
- data/lib/doze/responder/main.rb +41 -0
- data/lib/doze/responder/resource.rb +262 -0
- data/lib/doze/responder.rb +58 -0
- data/lib/doze/response.rb +78 -0
- data/lib/doze/router/anchored_route_set.rb +68 -0
- data/lib/doze/router/route.rb +88 -0
- data/lib/doze/router/route_set.rb +34 -0
- data/lib/doze/router.rb +100 -0
- data/lib/doze/serialization/entity.rb +34 -0
- data/lib/doze/serialization/form_data_helpers.rb +40 -0
- data/lib/doze/serialization/html.rb +116 -0
- data/lib/doze/serialization/json.rb +29 -0
- data/lib/doze/serialization/multipart_form_data.rb +162 -0
- data/lib/doze/serialization/resource.rb +30 -0
- data/lib/doze/serialization/resource_proxy.rb +14 -0
- data/lib/doze/serialization/www_form_encoded.rb +42 -0
- data/lib/doze/serialization/yaml.rb +25 -0
- data/lib/doze/uri_template.rb +220 -0
- data/lib/doze/utils.rb +53 -0
- data/lib/doze/version.rb +3 -0
- data/lib/doze.rb +5 -0
- data/test/functional/auth_test.rb +69 -0
- data/test/functional/base.rb +159 -0
- data/test/functional/cache_header_test.rb +76 -0
- data/test/functional/direct_response_test.rb +16 -0
- data/test/functional/error_handling_test.rb +131 -0
- data/test/functional/get_and_conneg_test.rb +182 -0
- data/test/functional/media_type_extensions_test.rb +102 -0
- data/test/functional/media_type_test.rb +40 -0
- data/test/functional/method_support_test.rb +49 -0
- data/test/functional/non_get_method_test.rb +173 -0
- data/test/functional/precondition_test.rb +84 -0
- data/test/functional/raw_path_info_test.rb +69 -0
- data/test/functional/resource_representation_test.rb +14 -0
- data/test/functional/router_test.rb +196 -0
- data/test/functional/serialization_test.rb +142 -0
- data/test/functional/uri_template_test.rb +51 -0
- metadata +221 -0
@@ -0,0 +1,81 @@
|
|
1
|
+
# A proxy wrapper for a resource instance, which allows you to proxy through to its resource functionality
|
2
|
+
# while potentially overriding some of it with context-sensitive behaviour.
|
3
|
+
# Unlike the proxies in the stdlib's 'delegate' library, this will satisfy is_a?(Doze::Resource) for the proxy instance.
|
4
|
+
# Also note it only proxies the Resource interface, and doesn't do any method_missing magic to proxy additional methods
|
5
|
+
# on the underlying instance. If you need this you can access the target of the proxy with #target, or use 'try',
|
6
|
+
# which has been overridden sensibly.
|
7
|
+
#
|
8
|
+
# Also note the proxy is designed to do the sensible default with a nil target, however do make sure it's doing what
|
9
|
+
# you want it to do if using with a nil target
|
10
|
+
require 'doze/resource'
|
11
|
+
class Doze::Resource::Proxy
|
12
|
+
include Doze::Resource
|
13
|
+
|
14
|
+
def initialize(uri, target)
|
15
|
+
@uri = uri
|
16
|
+
@target = target
|
17
|
+
end
|
18
|
+
|
19
|
+
attr_reader :target
|
20
|
+
|
21
|
+
# Methods based around use try / respond_to? need special care when proxying:
|
22
|
+
|
23
|
+
def try(method, *args, &block)
|
24
|
+
if respond_to?(method)
|
25
|
+
send(method, *args, &block)
|
26
|
+
elsif target.respond_to?(method)
|
27
|
+
target && target.send(method, *args, &block)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def supports_method?(method)
|
32
|
+
supports_method = "supports_#{method}?"
|
33
|
+
if respond_to?(supports_method)
|
34
|
+
send(supports_method)
|
35
|
+
else
|
36
|
+
target && target.supports_method?(method)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def other_method(method_name, entity=nil)
|
41
|
+
if respond_to?(method_name)
|
42
|
+
send(method_name, entity)
|
43
|
+
else
|
44
|
+
target && target.other_method(method_name, entity)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def accepts_method_with_media_type?(resource_method, entity)
|
49
|
+
method_name = "accepts_#{resource_method}_with_media_type?"
|
50
|
+
if respond_to?(method_name)
|
51
|
+
send(method_name, entity)
|
52
|
+
else
|
53
|
+
target && target.accepts_method_with_media_type?(resource_method, entity)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
# proxying post is a bit fiddly due to the (in retrospect perhaps a bit unadvised) convenience support for
|
58
|
+
# different method arities for post.
|
59
|
+
# maybe move to post_with_session(entity, session) vs post(entity) if this causes any more pain.
|
60
|
+
def post(entity, session)
|
61
|
+
t = target or return
|
62
|
+
if t.method(:post).arity.abs > 1
|
63
|
+
t.post(entity, session)
|
64
|
+
else
|
65
|
+
t.post(entity)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
# Some methods which should return something other than nil by default for an empty target:
|
70
|
+
|
71
|
+
def authorize(user, method)
|
72
|
+
target ? target.authorize(user, method) : true
|
73
|
+
end
|
74
|
+
|
75
|
+
# Other methods which we can proxy generically
|
76
|
+
|
77
|
+
proxied_methods = Doze::Resource.public_instance_methods(true) - ['uri', 'uri_object'] - self.public_instance_methods(false)
|
78
|
+
proxied_methods.each do |method|
|
79
|
+
module_eval("def #{method}(*args, &block); t = target and t.__send__(:#{method}, *args, &block); end", __FILE__, __LINE__)
|
80
|
+
end
|
81
|
+
end
|
@@ -0,0 +1,193 @@
|
|
1
|
+
module Doze::Resource
|
2
|
+
|
3
|
+
# URIs and identity
|
4
|
+
|
5
|
+
# You would typically set @uri in your constructor; resources don't have to have a URI, but certain parts of the framework require a URI
|
6
|
+
# in order to create links to the object for Location headers, links etc.
|
7
|
+
# Also see Router (and note that a Resource can also act as a Router for its subresources)
|
8
|
+
|
9
|
+
# The URI path of this resource.
|
10
|
+
# #uri= may be used by propagate_static_routes when a resource which is also a router, is statically routed to.
|
11
|
+
# you can private :uri= if you don't want it writeable, however.
|
12
|
+
attr_accessor :uri
|
13
|
+
|
14
|
+
# Wraps up the URI path of this resource as a URI::Generic
|
15
|
+
def uri_object
|
16
|
+
uri && URI::Generic.build(:path => uri)
|
17
|
+
end
|
18
|
+
|
19
|
+
|
20
|
+
# Authorization / Authentication:
|
21
|
+
#
|
22
|
+
# Return true or false to allow or deny the given action on this resource for the given user (if user is nil, for the unauthenticated user).
|
23
|
+
# If you deny an action by the unauthenticated user, this will be taken as a requirement for authentication. So eg if authentication is all you require,
|
24
|
+
# you could just return !user.nil?
|
25
|
+
#
|
26
|
+
# method will be one of:
|
27
|
+
# * get, put, post, delete, or some other recognized_method where we supports_method?
|
28
|
+
#
|
29
|
+
# user will be the authenticated user, or nil if there is no authenticated user. The exact nature of the user object will depend on the middleware used
|
30
|
+
# to do authentication.
|
31
|
+
def authorize(user, method)
|
32
|
+
true
|
33
|
+
end
|
34
|
+
|
35
|
+
# You can return false here to make a resource instance act like it doesn't exist.
|
36
|
+
#
|
37
|
+
# You could use this if it's more convenient to have a router create an instance representing a potentially-extant resource,
|
38
|
+
# and to have the actual existence test run on the instance.
|
39
|
+
#
|
40
|
+
# Or you can use it if you want to support certain non-get methods on a resource, but appear non-existent in response to get requests.
|
41
|
+
def exists?
|
42
|
+
true
|
43
|
+
end
|
44
|
+
|
45
|
+
# A convenience which some libraries add to Kernel
|
46
|
+
def try(method, *args, &block)
|
47
|
+
send(method, *args, &block) if respond_to?(method)
|
48
|
+
end
|
49
|
+
|
50
|
+
def supports_method?(method)
|
51
|
+
try("supports_#{method}?")
|
52
|
+
end
|
53
|
+
|
54
|
+
def supports_get?
|
55
|
+
true
|
56
|
+
end
|
57
|
+
|
58
|
+
|
59
|
+
# Called to obtain one or more representations of the resource.
|
60
|
+
#
|
61
|
+
# Should be safe, that is, not have any side-effects visible to the caller. This also implies idempotency (which is weaker).
|
62
|
+
#
|
63
|
+
# You may return either:
|
64
|
+
#
|
65
|
+
# * A single entity representation, in the form of an instance of Doze::Entity
|
66
|
+
#
|
67
|
+
# * An array of multiple entity representations, instances of Doze::Entity with different media_types and/or languages.
|
68
|
+
# Content negotiation may be used to select an appropriate entity from the list.
|
69
|
+
# NB: if you return multiple entities we recommend using 'lazy' Doze::Entity instances constructed with a block -
|
70
|
+
# this way the entity data will not need to be generated for entities which aren't selected by content negotiation.
|
71
|
+
#
|
72
|
+
# * A resource representation, in the form of a Doze::Resource with a URI
|
73
|
+
# This would correspond to a redirect in HTTP.
|
74
|
+
#
|
75
|
+
# If you wish to indicate that the resource is missing, return false from exists?
|
76
|
+
def get
|
77
|
+
nil
|
78
|
+
end
|
79
|
+
|
80
|
+
# Called to update the entirity of the this resource to the resource represented by the given representation entity.
|
81
|
+
# entity will be a new entity representation whose media_type has been okayed by accepts_put_with_media_type?
|
82
|
+
#
|
83
|
+
# Should be idempotent; Subsequent to a successful put, the following should hold:
|
84
|
+
# * get should return the updated entity representation (or an alternative representation with the same resource-level semantics)
|
85
|
+
# * parent.resolve_subresource(additional_identifier_components) should return a resource for which the same holds.
|
86
|
+
#
|
87
|
+
# Need not return anything; success is assumed unless an error is raised. (or: should we have this return true/false?)
|
88
|
+
def put(entity)
|
89
|
+
nil
|
90
|
+
end
|
91
|
+
|
92
|
+
# Called to delete this resource.
|
93
|
+
#
|
94
|
+
# Should be idempotent. Subsequent to a successful delete, the following should hold:
|
95
|
+
# * exists? should return false, or get should return nil, or both
|
96
|
+
# * parent.resolve_subresource(additional_identifier_components) should return nil, or return a resource which "doesn't exist" in the same sense as above.
|
97
|
+
#
|
98
|
+
# Need not return anything; success is assumed unless an error is raised. (or: should we have this return true/false?)
|
99
|
+
def delete_resource
|
100
|
+
nil
|
101
|
+
end
|
102
|
+
|
103
|
+
# Intended to be called in order to:
|
104
|
+
# * Allocate an identifier for a new subresource, and create this subresource, based on the representation entity if given.
|
105
|
+
# (Note: use put_on_subresource instead if you know the desired identifier)
|
106
|
+
# * Create a new interal resource which isn't exposed to the caller (eg, log some data)
|
107
|
+
#
|
108
|
+
# May also be used for legacy reasons for some other wider purposes:
|
109
|
+
# * To annotate, append to or otherwise modify the current resource based on instructions contained in the given representation entity,
|
110
|
+
# potentially returning an anonymous resource describing the results
|
111
|
+
# * Otherwise process instructions contained in the given representation entity, returning an anonymous resource describing the results
|
112
|
+
# Note: these uses are a bit of a catch-all, not very REST-ful, and discouraged, see other_method.
|
113
|
+
#
|
114
|
+
# entity will be a entity whose media_type has been okayed by accepts_post_with_media_type?
|
115
|
+
#
|
116
|
+
# Does not need to be idempotent or safe, and should not be assumed to be.
|
117
|
+
#
|
118
|
+
# May return:
|
119
|
+
# * A Doze::Resource with identifier_components, which will be taken as a newly-created resource.
|
120
|
+
# This is what we're recommending as the primary intended semantics for post.
|
121
|
+
# * nil to indicate success without exposing a resulting resource
|
122
|
+
# * A Doze::Resource without identifier_components, which will be taken to be a returned description of the results of some arbitrary operation performed
|
123
|
+
# (see discouragement above)
|
124
|
+
# * A Doze::Entity, which will be taken to be a returned description of the results of some arbitrary operation performed
|
125
|
+
# (this one even more discouraged, but there if you need a quick way to make an arbitrary fixed response)
|
126
|
+
def post(entity)
|
127
|
+
nil
|
128
|
+
end
|
129
|
+
|
130
|
+
# This allows you to accept methods other than the standard get/put/post/delete from HTTP. When the resource is exposed over a protocol (or requested by a client)
|
131
|
+
# which doesn't support custom methods, some form of tunnelling will be used, typically ontop of POST.
|
132
|
+
#
|
133
|
+
# Semantics are up to you, but should not, and will not, be assumed to be idempotent or safe by any generic middleware.
|
134
|
+
#
|
135
|
+
# When should you consider using a custom method? as I understand it, the REST philosophy is that this is valid and to be encouraged if and only if:
|
136
|
+
# * Your proposed method has clearly-defined semantics which have the potential to apply generically to a wide variety of resources and media types
|
137
|
+
# * It doesn't overlap with the semantics of existing methods in a confusing or redundant way (exception: it may be used to replace legacy uses of post
|
138
|
+
# as described; see eg patch)
|
139
|
+
# * Requests with the method can safely be forwarded around by middleware which don't know anything about their semantics
|
140
|
+
# * You've given due consideration to the alternative: rephrasing the problem in terms of standard methods acting on a different resource structure.
|
141
|
+
# And you concluded that this would be significantly less elegant / would add excessive conceptual (or implementation) overhead
|
142
|
+
# * Ideally, some attempt has been made to standardize it
|
143
|
+
# The proposed 'patch' method (for selective update of a resource by a diff-like media type) is a good example of something which meets these criterea.
|
144
|
+
# http://greenbytes.de/tech/webdav/draft-dusseault-http-patch-11.html
|
145
|
+
#
|
146
|
+
# entity will be nil, or an entity whose media_type has been okayed by accepts_method_with_media_type?(method_name, ..)
|
147
|
+
#
|
148
|
+
# Return options are the same as for post, as are their interpretations, with the exception that a resource returned will not be assumed to be newly-created.
|
149
|
+
# (we're taking the stance that you should be using post or put for creation of new resources).
|
150
|
+
#
|
151
|
+
# By default it'll call a ruby method of the same name to call, as already happens for post/put/delete.
|
152
|
+
def other_method(method_name, entity=nil)
|
153
|
+
try(method_name, entity)
|
154
|
+
end
|
155
|
+
|
156
|
+
# Called to determine whether the request entity is of a suitable media type for the method in question (eg for a put or a post).
|
157
|
+
# The entity itself is passed in. If you return false, the method will never be called; if true then the method may be called with the entity.
|
158
|
+
def accepts_method_with_media_type?(resource_method, entity)
|
159
|
+
try("accepts_#{resource_method}_with_media_type?", entity)
|
160
|
+
end
|
161
|
+
|
162
|
+
|
163
|
+
|
164
|
+
|
165
|
+
# Caching and modification-related metadata
|
166
|
+
|
167
|
+
# May return a Time object inidicating the last modification date, or nil if not known
|
168
|
+
def last_modified
|
169
|
+
nil
|
170
|
+
end
|
171
|
+
|
172
|
+
# nil = no explicit policy, false = explicitly no caching, true = caching yes please
|
173
|
+
def cacheable?
|
174
|
+
nil
|
175
|
+
end
|
176
|
+
|
177
|
+
# false here (when cacheable? is true) implies that private caching only is desired.
|
178
|
+
def publicly_cacheable?
|
179
|
+
cacheable?
|
180
|
+
end
|
181
|
+
|
182
|
+
# Integer seconds, or nil for no explicitly-specified expiry period. Will only be checked if cacheable? is true.
|
183
|
+
# todo: a means to specify the equivalent of 'must-revalidate'
|
184
|
+
def cache_expiry_period
|
185
|
+
nil
|
186
|
+
end
|
187
|
+
|
188
|
+
# You can override public_cache_expiry_period to specify a longer or shorter period for public caches.
|
189
|
+
# Otherwise assumed same as general cache_expiry_period.
|
190
|
+
def public_cache_expiry_period
|
191
|
+
nil
|
192
|
+
end
|
193
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
class Doze::Responder::Error < Doze::Responder
|
2
|
+
|
3
|
+
def initialize(app, request, error)
|
4
|
+
super(app, request)
|
5
|
+
@error = error
|
6
|
+
end
|
7
|
+
|
8
|
+
# We use a resource class to represent for errors to enable content type negotiation for them.
|
9
|
+
# The class used is configurable but defaults to Doze::Resource::Error
|
10
|
+
def response
|
11
|
+
response = Doze::Response.new
|
12
|
+
if @app.config[:error_resource_class]
|
13
|
+
extras = {:error => @error}
|
14
|
+
|
15
|
+
if @app.config[:expose_exception_details] && @error.http_status >= 500
|
16
|
+
extras[:backtrace] = @error.backtrace
|
17
|
+
end
|
18
|
+
|
19
|
+
resource = @app.config[:error_resource_class].new(@error.http_status, @error.message, extras)
|
20
|
+
# we can't have it going STATUS_NOT_ACCEPTABLE in the middle of trying to return an error resource, so :ignore_unacceptable_accepts
|
21
|
+
responder = Doze::Responder::Resource.new(@app, @request, resource, :ignore_unacceptable_accepts => true)
|
22
|
+
entity = responder.get_preferred_representation(response)
|
23
|
+
response.entity = entity
|
24
|
+
else
|
25
|
+
response.headers['Content-Type'] = 'text/plain'
|
26
|
+
response.body = @error.message
|
27
|
+
end
|
28
|
+
|
29
|
+
response.head_only = true if @request.head?
|
30
|
+
response.status = @error.http_status
|
31
|
+
response.headers.merge!(@error.headers) if @error.headers
|
32
|
+
response
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
class Doze::Responder::Main < Doze::Responder
|
2
|
+
|
3
|
+
def response
|
4
|
+
resource = nil
|
5
|
+
route_to = @app.root
|
6
|
+
remaining_path = @request.routing_path
|
7
|
+
remaining_path = nil if remaining_path.empty? || remaining_path == '/'
|
8
|
+
session = @request.session
|
9
|
+
base_uri = ''
|
10
|
+
|
11
|
+
# main routing loop - results in either a final Resource which has been routed to, or nil
|
12
|
+
# todo: maybe something recursive would be a bit more readable here
|
13
|
+
while true
|
14
|
+
if remaining_path && route_to.is_a?(Doze::Router)
|
15
|
+
# Bail early with a 401 or 403 if the router refuses to authorize further routing
|
16
|
+
return auth_failed_response unless route_to.authorize_routing(@request.session)
|
17
|
+
route_to, base_uri, remaining_path = route_to.perform_routing(remaining_path, session, base_uri)
|
18
|
+
elsif !remaining_path && route_to.is_a?(Doze::Resource)
|
19
|
+
resource = route_to
|
20
|
+
break
|
21
|
+
else
|
22
|
+
break
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
if resource
|
27
|
+
Doze::Responder::Resource.new(@app, @request, resource).response
|
28
|
+
elsif @request.options?
|
29
|
+
if @request.raw_path_info == '*'
|
30
|
+
# Special response for "OPTIONS *" as in HTTP spec:
|
31
|
+
rec_methods = (@app.config[:recognized_methods] + [:head, :options]).join(', ').upcase
|
32
|
+
Doze::Response.new(STATUS_NO_CONTENT, 'Allow' => rec_methods)
|
33
|
+
else
|
34
|
+
# Special OPTIONS response for non-existent resource:
|
35
|
+
Doze::Response.new(STATUS_NO_CONTENT, 'Allow' => 'OPTIONS')
|
36
|
+
end
|
37
|
+
else
|
38
|
+
error_response(STATUS_NOT_FOUND)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,262 @@
|
|
1
|
+
class Doze::Responder::Resource < Doze::Responder
|
2
|
+
include Doze::Utils
|
3
|
+
|
4
|
+
attr_reader :resource, :options
|
5
|
+
|
6
|
+
def initialize(app, request, resource, options={})
|
7
|
+
super(app, request)
|
8
|
+
@resource = resource
|
9
|
+
@options = options
|
10
|
+
end
|
11
|
+
|
12
|
+
|
13
|
+
# Basic handling of method support and OPTIONS
|
14
|
+
|
15
|
+
def response
|
16
|
+
if @request.options?
|
17
|
+
Doze::Response.new(STATUS_NO_CONTENT, allow_header)
|
18
|
+
elsif !@resource.supports_method?(recognized_method)
|
19
|
+
error_response(STATUS_METHOD_NOT_ALLOWED, nil, allow_header)
|
20
|
+
else
|
21
|
+
response_to_supported_method
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def allow_header
|
26
|
+
methods = @app.config[:recognized_methods].select {|m| @resource.supports_method?(m)}
|
27
|
+
# We support OPTIONS for free, and HEAD for free if GET is supported
|
28
|
+
methods << :head if methods.include?(:get)
|
29
|
+
methods << :options
|
30
|
+
{'Allow' => methods.map {|method| method.to_s.upcase}.join(', ')}
|
31
|
+
end
|
32
|
+
|
33
|
+
|
34
|
+
|
35
|
+
# Handling for supported methods
|
36
|
+
|
37
|
+
def response_to_supported_method
|
38
|
+
fail = authorization_fail_response(recognized_method) and return fail
|
39
|
+
|
40
|
+
exists = @resource.exists?
|
41
|
+
|
42
|
+
if @request.get_or_head?
|
43
|
+
return error_response(STATUS_NOT_FOUND) unless exists
|
44
|
+
response = resource_preconditions_fail_response || make_representation_of_resource_response
|
45
|
+
response.head_only = @request.head?
|
46
|
+
response
|
47
|
+
else
|
48
|
+
entity = @request.entity
|
49
|
+
if entity && recognized_method != :delete
|
50
|
+
fail = request_entity_media_type_fail_response(recognized_method, entity) and return fail
|
51
|
+
end
|
52
|
+
|
53
|
+
# Where the resource supports GET, we can use this to support preconditions (If-Match etc)
|
54
|
+
# on PUT/DELETE/POST operations based on the content that GET would return.
|
55
|
+
if exists && @resource.supports_get?
|
56
|
+
fail = resource_preconditions_fail_response and return fail
|
57
|
+
fail = entity_preconditions_fail_response and return fail
|
58
|
+
end
|
59
|
+
|
60
|
+
perform_non_get_action(entity, exists)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def perform_non_get_action(entity, existed_before)
|
65
|
+
case recognized_method
|
66
|
+
when :post
|
67
|
+
# Slightly hacky but for now we allow the session to be passed as an extra arg to post actions
|
68
|
+
# where the method has sufficient arity.
|
69
|
+
#
|
70
|
+
# This is only supported for POST at present; prefered approach (especially for GET/PUT/DELETE)
|
71
|
+
# is to use a session-specific route and construct the resource with the session context.
|
72
|
+
# Also, this should not be used for authorization logic - use the authorize method for that.
|
73
|
+
result = if @resource.method(:post).arity.abs > 1
|
74
|
+
@resource.post(entity, @request.session)
|
75
|
+
else
|
76
|
+
@resource.post(entity)
|
77
|
+
end
|
78
|
+
|
79
|
+
# 201 created is the default interpretation of a new resource with an identifier resulting from a post.
|
80
|
+
make_post_result_response(result)
|
81
|
+
|
82
|
+
when :put
|
83
|
+
if entity
|
84
|
+
result = @resource.put(entity)
|
85
|
+
Doze::Response.new_empty(existed_before ? STATUS_NO_CONTENT : STATUS_CREATED)
|
86
|
+
else
|
87
|
+
error_response(STATUS_BAD_REQUEST, "expected request body for PUT")
|
88
|
+
end
|
89
|
+
|
90
|
+
when :delete
|
91
|
+
result = @resource.delete_resource if existed_before
|
92
|
+
Doze::Response.new_empty
|
93
|
+
|
94
|
+
else
|
95
|
+
result = @resource.other_method(recognized_method, entity)
|
96
|
+
# For now we streat this pretty much as a POST
|
97
|
+
make_post_result_response(result)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
|
102
|
+
|
103
|
+
|
104
|
+
|
105
|
+
# Precondition checkers
|
106
|
+
|
107
|
+
def request_entity_media_type_fail_response(resource_method, entity)
|
108
|
+
unless @resource.accepts_method_with_media_type?(resource_method, entity)
|
109
|
+
error_response(STATUS_UNSUPPORTED_MEDIA_TYPE)
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
def authorization_fail_response(action)
|
114
|
+
auth_failed_response unless @resource.authorize(@request.session, action)
|
115
|
+
end
|
116
|
+
|
117
|
+
def resource_preconditions_fail_response
|
118
|
+
last_modified = @resource.last_modified or return
|
119
|
+
if_modified_since = @request.env['HTTP_IF_MODIFIED_SINCE']
|
120
|
+
if_unmodified_since = @request.env['HTTP_IF_UNMODIFIED_SINCE']
|
121
|
+
|
122
|
+
if (if_unmodified_since && last_modified > Time.httpdate(if_unmodified_since))
|
123
|
+
# although technically an HTTP error response, we don't use error_response (and an error resource)
|
124
|
+
# to send STATUS_PRECONDITION_FAILED, since the precondition check was something the client specifically
|
125
|
+
# requested, so we assume they don't need a special error resource to make sense of it.
|
126
|
+
Doze::Response.new(STATUS_PRECONDITION_FAILED, 'Last-Modified' => last_modified.httpdate)
|
127
|
+
elsif (if_modified_since && last_modified <= Time.httpdate(if_modified_since))
|
128
|
+
if request.get_or_head?
|
129
|
+
Doze::Response.new(STATUS_NOT_MODIFIED, 'Last-Modified' => last_modified.httpdate)
|
130
|
+
else
|
131
|
+
Doze::Response.new(STATUS_PRECONDITION_FAILED, 'Last-Modified' => last_modified.httpdate)
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
# Etag-based precondition checks. These are specific to the response entity that would be returned
|
137
|
+
# from a GET.
|
138
|
+
#
|
139
|
+
# Note: the default implementation of entity.etag just generates the entity body and hashes it,
|
140
|
+
# but you could return entities which lazily know their Etag without the body needing to be generated,
|
141
|
+
# and this code will take advantage of that
|
142
|
+
def entity_preconditions_fail_response(entity=nil)
|
143
|
+
if_match = @request.env['HTTP_IF_MATCH']
|
144
|
+
if_none_match = @request.env['HTTP_IF_NONE_MATCH']
|
145
|
+
return unless if_match || if_none_match
|
146
|
+
|
147
|
+
entity ||= get_preferred_representation
|
148
|
+
return unless entity.is_a?(Doze::Entity)
|
149
|
+
|
150
|
+
etag = entity.etag
|
151
|
+
|
152
|
+
# etag membership test is kinda crude at present, really we should parse the separate quoted etags out.
|
153
|
+
if (if_match && if_match != '*' && !(etag && if_match.include?(quote(etag))))
|
154
|
+
Doze::Response.new(STATUS_PRECONDITION_FAILED, 'Etag' => quote(etag))
|
155
|
+
elsif (if_none_match && (if_none_match == '*' || (etag && if_none_match.include?(quote(etag)))))
|
156
|
+
if @request.get_or_head?
|
157
|
+
Doze::Response.new(STATUS_NOT_MODIFIED, 'Etag' => quote(etag))
|
158
|
+
else
|
159
|
+
Doze::Response.new(STATUS_PRECONDITION_FAILED, 'Etag' => quote(etag))
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
|
165
|
+
|
166
|
+
|
167
|
+
# Response handling helpers
|
168
|
+
|
169
|
+
def get_preferred_representation(response=nil)
|
170
|
+
representation = @resource.get
|
171
|
+
if representation.is_a?(Array)
|
172
|
+
negotiator = @request.negotiator(@options[:ignore_unacceptable_accepts])
|
173
|
+
|
174
|
+
if response
|
175
|
+
# If the available representation entities differ by media type, add a Vary: Accept. similarly for language.
|
176
|
+
response.add_header_values('Vary', 'Accept') if not_all_equal?(representation.map {|e| e.media_type})
|
177
|
+
response.add_header_values('Vary', 'Accept-Language') if not_all_equal?(representation.map {|e| e.language})
|
178
|
+
end
|
179
|
+
|
180
|
+
# If we fail to find the requested media type when requested via a file extension (/foo.jpeg) the appropriate HTTP status
|
181
|
+
# is 404; at the HTTP level this is effectively a separate media-type-specific version of the resource at its own uri,
|
182
|
+
# which doesn't exist due to the particular media-type-specific version of the resource not being available.
|
183
|
+
# If we fail due to an Accept header not matching anything, of course the appropriate status is STATUS_NOT_ACCEPTABLE
|
184
|
+
negotiator.choose_entity(representation) or raise_error(@request.extension ? STATUS_NOT_FOUND : STATUS_NOT_ACCEPTABLE)
|
185
|
+
else
|
186
|
+
representation
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
def not_all_equal?(collection)
|
191
|
+
first = collection.first
|
192
|
+
collection.any? {|x| x != first}
|
193
|
+
end
|
194
|
+
|
195
|
+
def add_caching_headers(response)
|
196
|
+
# resource-level caching metadata headers
|
197
|
+
last_modified = @resource.last_modified and response.headers['Last-Modified'] = last_modified.httpdate
|
198
|
+
case @resource.cacheable?
|
199
|
+
when true
|
200
|
+
expiry_period = @resource.cache_expiry_period
|
201
|
+
if @resource.publicly_cacheable?
|
202
|
+
cache_control = 'public'
|
203
|
+
if expiry_period
|
204
|
+
cache_control << ", max-age=#{expiry_period}"
|
205
|
+
public_expiry_period = @resource.public_cache_expiry_period
|
206
|
+
cache_control << ", s-maxage=#{public_expiry_period}" if public_expiry_period
|
207
|
+
end
|
208
|
+
else
|
209
|
+
cache_control = 'private'
|
210
|
+
cache_control << ", max-age=#{expiry_period}" if expiry_period
|
211
|
+
end
|
212
|
+
response.headers['Expires'] = (Time.now + expiry_period).httpdate if expiry_period
|
213
|
+
response.headers['Cache-Control'] = cache_control
|
214
|
+
when false
|
215
|
+
response.headers['Expires'] = 'Thu, 01 Jan 1970 00:00:00 GMT' # Beginning of time woop woop
|
216
|
+
response.headers['Cache-Control'] = 'no-cache, max-age=0'
|
217
|
+
end
|
218
|
+
end
|
219
|
+
|
220
|
+
def make_representation_of_resource_response(include_location_with_status=nil)
|
221
|
+
response = Doze::Response.new
|
222
|
+
representation = get_preferred_representation(response)
|
223
|
+
|
224
|
+
case representation
|
225
|
+
when Doze::Resource
|
226
|
+
raise 'Resource representation must have a uri' unless representation.uri
|
227
|
+
response.set_redirect(representation, @request)
|
228
|
+
add_caching_headers(response)
|
229
|
+
response
|
230
|
+
when Doze::Entity
|
231
|
+
# preconditions on the representation only apply to the content that would be served up by a GET
|
232
|
+
fail_response = @request.get_or_head? && entity_preconditions_fail_response(representation)
|
233
|
+
response = fail_response || begin
|
234
|
+
response.entity = representation
|
235
|
+
response
|
236
|
+
end
|
237
|
+
add_caching_headers(response)
|
238
|
+
|
239
|
+
if include_location_with_status && @resource.uri
|
240
|
+
response.set_location(@resource, @request)
|
241
|
+
response.status = include_location_with_status
|
242
|
+
end
|
243
|
+
|
244
|
+
response
|
245
|
+
when Doze::Response
|
246
|
+
representation
|
247
|
+
end
|
248
|
+
end
|
249
|
+
|
250
|
+
def make_post_result_response(result)
|
251
|
+
case result
|
252
|
+
when Doze::Resource
|
253
|
+
Doze::Responder::Resource.new(@app, @request, result, @options).make_representation_of_resource_response(STATUS_CREATED)
|
254
|
+
when Doze::Entity
|
255
|
+
Doze::Response.new_from_entity(result)
|
256
|
+
when Doze::Response
|
257
|
+
result
|
258
|
+
when nil
|
259
|
+
Doze::Response.new_empty
|
260
|
+
end
|
261
|
+
end
|
262
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
class Doze::Responder
|
2
|
+
include Doze::Utils
|
3
|
+
|
4
|
+
attr_reader :response, :request
|
5
|
+
|
6
|
+
def initialize(app, request)
|
7
|
+
@app = app
|
8
|
+
@request = request
|
9
|
+
end
|
10
|
+
|
11
|
+
def recognized_method
|
12
|
+
@recognized_method ||= begin
|
13
|
+
method = @request.normalized_request_method
|
14
|
+
([:options] + @app.config[:recognized_methods]).find {|m| m.to_s == method} or raise_error(STATUS_NOT_IMPLEMENTED)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
# for use within #response
|
19
|
+
def raise_error(status=STATUS_INTERNAL_SERVER_ERROR, message=nil, headers={})
|
20
|
+
raise Doze::Error.new(status, message, headers)
|
21
|
+
end
|
22
|
+
|
23
|
+
def error_response(status=STATUS_INTERNAL_SERVER_ERROR, message=nil, headers={}, backtrace=nil)
|
24
|
+
error_response_from_error(Doze::Error.new(status, message, headers, backtrace))
|
25
|
+
end
|
26
|
+
|
27
|
+
def error_response_from_error(error)
|
28
|
+
Doze::Responder::Error.new(@app, @request, error).response
|
29
|
+
end
|
30
|
+
|
31
|
+
def call
|
32
|
+
begin
|
33
|
+
response().finish
|
34
|
+
rescue Doze::Error => error
|
35
|
+
error_response_from_error(error).finish
|
36
|
+
rescue => exception
|
37
|
+
raise unless @app.config[:catch_application_errors]
|
38
|
+
lines = ["#{exception.class}: #{exception.message}", *exception.backtrace].join("\n")
|
39
|
+
@app.logger << lines
|
40
|
+
if @app.config[:expose_exception_details]
|
41
|
+
error_response(STATUS_INTERNAL_SERVER_ERROR, exception.message, {}, exception.backtrace).finish
|
42
|
+
else
|
43
|
+
error_response.finish
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def response
|
49
|
+
raise NotImplementedError
|
50
|
+
end
|
51
|
+
|
52
|
+
def auth_failed_response
|
53
|
+
error_response(@request.session_authenticated? ?
|
54
|
+
STATUS_FORBIDDEN : # this one, 403, really means 'unauthorized', ie
|
55
|
+
STATUS_UNAUTHORIZED # http status code 401 called 'unauthorized' but really used to mean 'unauthenticated'
|
56
|
+
)
|
57
|
+
end
|
58
|
+
end
|