doze 0.0.11
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/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
|