toast 0.9.5 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,285 @@
1
+ require 'toast/request_helpers'
2
+ require 'toast/http_range'
3
+ require 'link_header'
4
+
5
+ class Toast::PluralAssocRequest
6
+ include Toast::RequestHelpers
7
+ include Toast::Errors
8
+
9
+ def initialize id, config, base_config, auth, request
10
+ @id = id
11
+ @config = config
12
+ @base_config = base_config
13
+ @selected_attributes = request.query_parameters.delete(:toast_select).try(:split,',')
14
+ @uri_params = request.query_parameters
15
+ @base_uri = base_uri(request)
16
+ @verb = request.request_method.downcase
17
+ @auth = auth
18
+ @request = request
19
+ end
20
+
21
+ def respond
22
+ if @verb.in? %w(get post link unlink)
23
+ self.send(@verb)
24
+ else
25
+ response :method_not_allowed,
26
+ headers: {'Allow' => allowed_methods(@config)},
27
+ msg: "method #{@verb.upcase} not supported for association URIs"
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def get
34
+
35
+ if @config.via_get.nil?
36
+ response :method_not_allowed,
37
+ headers: {'Allow' => allowed_methods(@config)},
38
+ msg: "GET not configured"
39
+ else
40
+ begin
41
+
42
+ target_config = get_config(@config.target_model_class)
43
+
44
+ requested_range = Toast::HttpRange.new(@request.env['HTTP_RANGE'])
45
+
46
+ range_start = requested_range.start
47
+ window = if (requested_range.size.nil? || requested_range.size > @config.max_window)
48
+ @config.max_window
49
+ else
50
+ requested_range.size
51
+ end
52
+
53
+ source = @base_config.model_class.find(@id) # may raise ActiveRecord::RecordNotFound
54
+ relation = call_handler(@config.via_get.handler, source, @uri_params) # may raise HandlerError
55
+
56
+ unless relation.is_a? ActiveRecord::Relation and relation.model == @config.target_model_class
57
+ return response :internal_server_error,
58
+ msg: "plural association handler returned `#{relation.class}', expected `ActiveRecord::Relation' (#{@config.target_model_class})"
59
+ end
60
+
61
+ call_allow(@config.via_get.permissions, @auth, source, @uri_params) # may raise NotAllowed, AllowError
62
+
63
+
64
+ # count = relation.count doesn't always work
65
+ # fix problematic select extensions for counting (-> { select(...) })
66
+ # this fails if the where clause depends on the the extended select
67
+ count = relation.count_by_sql relation.to_sql.sub(/SELECT.+FROM/,'SELECT COUNT(*) FROM')
68
+ headers = {"Content-Type" => @config.media_type}
69
+
70
+ if count > 0
71
+ range_end = if (range_start + window - 1) > (count - 1) # behind last
72
+ count - 1
73
+ else
74
+ (range_start + window - 1)
75
+ end
76
+
77
+ headers[ "Content-Range"] = "items=#{range_start}-#{range_end}/#{count}"
78
+ end
79
+
80
+ response :ok,
81
+ headers: headers,
82
+ body: represent(relation.limit(window).offset(range_start), target_config),
83
+ msg: "sent #{count} of #{target_config.model_class}"
84
+
85
+
86
+ rescue ActiveRecord::RecordNotFound
87
+ return response :not_found,
88
+ msg: "#{@config.model_class.name}##{@config.assoc_name} not found"
89
+
90
+ rescue AllowError => error
91
+ return response :internal_server_error,
92
+ msg: "exception raised in allow block: `#{error.orig_error.message}' in #{error.source_location}"
93
+
94
+ rescue BadRequest => error
95
+ response :bad_request, msg: "`#{error.message}' in: #{error.source_location}"
96
+
97
+ rescue HandlerError => error
98
+ return response :internal_server_error,
99
+ msg: "exception raised in via_get handler: `#{error.orig_error.message}' in #{error.source_location}"
100
+ rescue NotAllowed => error
101
+ return response :unauthorized, msg: "not authorized by allow block in: #{error.source_location}"
102
+
103
+ rescue ConfigNotFound => error
104
+ return response :internal_server_error,
105
+ msg: "no API configuration found for model `#{@config.target_model_class.name}'"
106
+
107
+ rescue => error
108
+ return response :internal_server_error,
109
+ msg: "exception raised: #{error} \n#{error.backtrace[0..5].join("\n")}"
110
+ end
111
+ end
112
+ end
113
+
114
+ def post
115
+ if @config.via_post.nil?
116
+ response :method_not_allowed,
117
+ headers: {'Allow' => allowed_methods(@config)},
118
+ msg: "POST not configured"
119
+ else
120
+ begin
121
+ payload = JSON.parse(@request.body.read)
122
+ target_config = get_config(@config.target_model_class)
123
+
124
+ # remove all attributes not in writables from payload
125
+ payload.delete_if do |attr,val|
126
+ unless attr.to_sym.in?(target_config.writables)
127
+ Toast.logger.warn "<POST #{@request.fullpath}> received attribute `#{attr}' is not writable or unknown"
128
+ true
129
+ end
130
+ end
131
+
132
+ source = @config.base_model_class.find(@id)
133
+
134
+ call_allow(@config.via_post.permissions,
135
+ @auth, source, @uri_params)
136
+
137
+ new_instance = call_handler(@config.via_post.handler,
138
+ source, payload, @uri_params)
139
+
140
+ if new_instance.persisted?
141
+ response :created,
142
+ headers: {"Content-Type" => target_config.media_type},
143
+ body: represent(new_instance, target_config ),
144
+ msg: "created #{new_instance.class}##{new_instance.id}"
145
+ else
146
+ message = new_instance.errors.count > 0 ?
147
+ ": " + new_instance.errors.full_messages.join(',') : ''
148
+
149
+ response :conflict,
150
+ msg: "creation of #{new_instance.class} aborted#{message}"
151
+ end
152
+
153
+ rescue ActiveRecord::RecordNotFound
154
+ response :not_found, msg: "#{@config.base_model_class.name}##{@id} not found"
155
+
156
+ rescue JSON::ParserError => error
157
+ return response :internal_server_error, msg: "expect JSON body"
158
+
159
+ rescue AllowError => error
160
+ return response :internal_server_error,
161
+ msg: "exception raised in allow block: `#{error.orig_error.message}' in #{error.source_location}"
162
+
163
+ rescue BadRequest => error
164
+ response :bad_request, msg: "`#{error.message}' in: #{error.source_location}"
165
+
166
+ rescue HandlerError => error
167
+ return response :internal_server_error,
168
+ msg: "exception raised in via_post handler: `#{error.orig_error.message}' in #{error.source_location}"
169
+ rescue NotAllowed => error
170
+ return response :unauthorized,
171
+ msg: "not authorized by allow block in: #{error.source_location}"
172
+
173
+ rescue ConfigNotFound => error
174
+ return response :internal_server_error,
175
+ msg: "no API configuration found for model `#{@config.target_model_class.name}'"
176
+
177
+ rescue => error
178
+ return response :internal_server_error,
179
+ msg: "exception raised: #{error} \n#{error.backtrace[0..5].join("\n")}"
180
+ end
181
+ end
182
+ end
183
+
184
+ def link
185
+ if @config.via_link.nil?
186
+ response :method_not_allowed,
187
+ headers: {'Allow' => allowed_methods(@config)},
188
+ msg: "LINK not configured"
189
+ else
190
+ begin
191
+ source = @base_config.model_class.find(@id)
192
+ link = LinkHeader.parse(@request.headers['Link']).find_link(['rel','related'])
193
+
194
+ if link.nil? or URI(link.href).path.nil?
195
+ return response :bad_request, msg: "Link header missing or invalid"
196
+ end
197
+
198
+ name, target_id = URI(link.href).path.split('/')[1..-1]
199
+ target_model_class = name.singularize.classify.constantize
200
+
201
+ unless is_active_record? target_model_class
202
+ return response :not_found, msg: "target class `#{target_model_class.name}' is not an `ActiveRecord'"
203
+ end
204
+
205
+ target = target_model_class.find(target_id)
206
+
207
+ call_allow(@config.via_link.permissions, @auth, source, @uri_params)
208
+ call_handler(@config.via_link.handler, source, target, @uri_params)
209
+
210
+ response :ok,
211
+ msg: "linked #{target_model_class.name}##{@id} with #{source.class}##{source.id}.#{@config.assoc_name}"
212
+
213
+ rescue ActiveRecord::RecordNotFound => error
214
+ response :not_found, msg: error.message
215
+
216
+ rescue BadRequest => error
217
+ response :bad_request, msg: "`#{error.message}' in: #{error.source_location}"
218
+
219
+ rescue AllowError => error
220
+ return response :internal_server_error,
221
+ msg: "exception raised in allow block: `#{error.orig_error.message}' in #{error.source_location}"
222
+ rescue HandlerError => error
223
+ return response :internal_server_error,
224
+ msg: "exception raised in via_link handler: `#{error.orig_error.message}' in #{error.source_location}"
225
+ rescue NotAllowed => error
226
+ return response :unauthorized, msg: "not authorized by allow block in: #{error.source_location}"
227
+
228
+ rescue => error
229
+ response :internal_server_error,
230
+ msg: "exception from via_link handler #{error.message}"
231
+ end
232
+ end
233
+ end
234
+
235
+ def unlink
236
+ if @config.via_unlink.nil?
237
+ response :method_not_allowed,
238
+ headers: {'Allow' => allowed_methods(@config)},
239
+ msg: "UNLINK not configured"
240
+ else
241
+ begin
242
+ source = @base_config.model_class.find(@id)
243
+ link = LinkHeader.parse(@request.headers['Link']).find_link(['rel','related'])
244
+
245
+ if link.nil? or URI(link.href).nil?
246
+ return response :bad_request, msg: "Link header missing or invalid"
247
+ end
248
+
249
+ name, id = URI(link.href).path.split('/')[1..-1]
250
+ target_model_class = name.singularize.classify.constantize
251
+
252
+ unless is_active_record? target_model_class
253
+ return response :not_found, msg: "target class `#{target_model_class.name}' is not an `ActiveRecord'"
254
+ end
255
+
256
+ call_allow(@config.via_unlink.permissions, @auth, source, @uri_params)
257
+ call_handler(@config.via_unlink.handler, source, target_model_class.find(id), @uri_params)
258
+
259
+ response :ok,
260
+ msg: "unlinked #{target_model_class.name}##{id} from #{source.class}##{source.id}.#{@config.assoc_name}"
261
+
262
+ rescue ActiveRecord::RecordNotFound => error
263
+ response :not_found, msg: error.message
264
+
265
+ rescue AllowError => error
266
+ return response :internal_server_error,
267
+ msg: "exception raised in allow block: `#{error.orig_error.message}' in #{error.source_location}"
268
+
269
+ rescue BadRequest => error
270
+ response :bad_request, msg: "`#{error.message}' in: #{error.source_location}"
271
+
272
+ rescue HandlerError => error
273
+ return response :internal_server_error,
274
+ msg: "exception raised in via_unlink handler: `#{error.orig_error.message}' in #{error.source_location}"
275
+ rescue NotAllowed => error
276
+ return response :unauthorized, msg: "not authorized by allow block in: #{error.source_location}"
277
+
278
+ rescue => error
279
+ response :internal_server_error,
280
+ msg: "exception from via_unlink handler: " + error.message
281
+
282
+ end
283
+ end
284
+ end
285
+ end
@@ -0,0 +1,133 @@
1
+ require 'toast/collection_request'
2
+ require 'toast/canonical_request'
3
+ require 'toast/single_request'
4
+ require 'toast/singular_assoc_request'
5
+ require 'toast/plural_assoc_request'
6
+ require 'toast/errors'
7
+
8
+ class Toast::RackApp
9
+ # NOTE: the RackApp object is shared in threads of concurrent requests
10
+ # (e.g. when using Puma server, but not in Passenger (single-threded, multi-process)).
11
+ # Anyays, don't use any instance vars (@ variables in #call).
12
+ # It causes chaos
13
+
14
+ include Toast::RequestHelpers
15
+
16
+ def call(env)
17
+
18
+ request = ActionDispatch::Request.new(env)
19
+
20
+ Toast.logger.info "[#{Thread.current.object_id}] processing: <#{URI.decode(request.fullpath)}>"
21
+
22
+ # Authentication: respond with 401 on exception or falsy return value:
23
+ begin
24
+ unless (auth = Toast::ConfigDSL::Settings::AuthenticateContext.new.
25
+ instance_exec(request, &Toast.settings.authenticate))
26
+ return response :unauthorized, msg: "authentication failed"
27
+ end
28
+ rescue Toast::Errors::CustomAuthFailure => caf
29
+ return response(caf.response_data[:status] || :unauthorized,
30
+ msg: caf.response_data[:body],
31
+ headers: caf.response_data[:headers])
32
+ rescue => error
33
+ return response :unauthorized, msg: "authentication failed: `#{error.message}'"
34
+ end
35
+
36
+ path = request.path_parameters[:toast_path].split('/')
37
+
38
+ # strip path prefixes
39
+ # get model class
40
+ begin
41
+ if path.first.singularize == path.first # not in plural form?
42
+ raise NameError
43
+ end
44
+ model_class = path.first.singularize.classify.constantize
45
+ raise NameError unless is_active_record?(model_class)
46
+ rescue NameError
47
+ return response :not_found,
48
+ msg: "resource at /#{path.join('/')} not found"
49
+ end
50
+
51
+ # select base configuration
52
+
53
+ ## This is unused. Do we need it at all?
54
+ # preferred_type = Rack::AcceptMediaTypes.new(env['HTTP_ACCEPT']).prefered || "application/json"
55
+
56
+ # the base_config is the configuration for the model corresponding to the first part of the URI path
57
+ begin
58
+ base_config = get_config(model_class)
59
+ rescue Toast::Errors::ConfigNotFound => error
60
+ return response :internal_server_error,
61
+ msg: "no API configuration found for model `#{model_class.name}'"
62
+ end
63
+
64
+ toast_request =
65
+ case path.length
66
+ when 1 # root collection: /apples
67
+
68
+ if base_config.collections[:all].nil?
69
+ return response :not_found, msg: "collection `/#{path[0]}' not configured"
70
+ else
71
+ # root collection
72
+ Toast::CollectionRequest.new( base_config.collections[:all],
73
+ base_config,
74
+ auth,
75
+ request)
76
+ end
77
+ when 2 # canonical, single or collection: /apples/10 , /apples/first, /apples/red_ones
78
+ if path.second =~ /\A\d+\z/
79
+ Toast::CanonicalRequest.new( path.second.to_i,
80
+ base_config,
81
+ auth,
82
+ request )
83
+ else
84
+ if col_config = base_config.collections[path.second.to_sym]
85
+ Toast::CollectionRequest.new(col_config,
86
+ base_config,
87
+ auth,
88
+ request)
89
+
90
+ elsif sin_config = base_config.singles[path.second.to_sym]
91
+ Toast::SingleRequest.new(sin_config,
92
+ base_config,
93
+ auth,
94
+ request)
95
+ else
96
+ return response :not_found,
97
+ msg: "collection or single `#{path.second}' not configured in: #{base_config.source_location}"
98
+ end
99
+ end
100
+
101
+ when 3 # association: /apples/10/tree, /tree/5/apples
102
+
103
+ if assoc_config = base_config.associations[path.third.to_sym]
104
+ if assoc_config.singular
105
+
106
+ Toast::SingularAssocRequest.new( path.second.to_i,
107
+ assoc_config,
108
+ base_config,
109
+ auth,
110
+ request )
111
+
112
+ else
113
+ # process_plural_association assoc_config, path.second.to_i
114
+ Toast::PluralAssocRequest.new( path.second.to_i,
115
+ assoc_config,
116
+ base_config,
117
+ auth,
118
+ request )
119
+ end
120
+ else
121
+ return response :not_found,
122
+ msg: "association `#{model_class.name}##{path.third}' not configured"
123
+ end
124
+
125
+ else
126
+ return response :not_found,
127
+ msg: "#{request.url} not found (invalid path)"
128
+ end
129
+
130
+ toast_request.respond
131
+
132
+ end
133
+ end
@@ -0,0 +1,134 @@
1
+ require 'toast/errors'
2
+
3
+ module Toast::RequestHelpers
4
+
5
+ def get_config model_class
6
+ Toast.expositions.detect do |exp|
7
+ exp.model_class == model_class
8
+ end || raise(Toast::Errors::ConfigNotFound)
9
+ end
10
+
11
+ # this is hard when behind a proxy
12
+ # relies on HTTP_X_FORWARDED* headers
13
+ def base_uri request
14
+ port = ":#{request.port}" unless request.port.in?([80,443])
15
+ path = request.path.sub(request.path_parameters[:toast_path]+'/','')
16
+ (request.protocol + request.host + port.to_s + path).chomp('/')
17
+ end
18
+
19
+ def represent_one record, config
20
+ result = {}
21
+
22
+ (config.readables + config.writables).each do |attr|
23
+ result[attr.to_s] = record.send(attr) if attr_selected?(attr)
24
+ end
25
+
26
+ model_uri = "#{@base_uri}/#{record.class.name.underscore.pluralize}"
27
+ result['self'] = "#{model_uri}/#{record.id}" if attr_selected?('self')
28
+
29
+ # add associations, collections and singles
30
+ config.associations.each do |name, config|
31
+ result[name.to_s] = "#{model_uri}/#{record.id}/#{name}" if attr_selected?(name)
32
+ end
33
+
34
+ config.singles.each do |name, config|
35
+ result[name.to_s] = "#{model_uri}/#{name}" if attr_selected?(name)
36
+ end
37
+
38
+ config.collections.each do |name, config|
39
+ if attr_selected?(name)
40
+ result[name.to_s] = if name == :all
41
+ "#{model_uri}"
42
+ else
43
+ "#{model_uri}/#{name}"
44
+ end
45
+ end
46
+ end
47
+
48
+ result
49
+ end
50
+
51
+ def represent record_or_enum, config
52
+ result = if record_or_enum.is_a? Enumerable
53
+ record_or_enum.map do |record|
54
+ represent_one(record, config)
55
+ end
56
+ else
57
+ represent_one(record_or_enum, config)
58
+ end
59
+
60
+ result.to_json
61
+ end
62
+
63
+ def allowed_methods(config)
64
+ ["DELETE", "GET", "LINK", "PATCH", "POST", "UNLINK"].select{|m|
65
+ !config.send("via_#{m.downcase}").nil?
66
+ }.join(", ")
67
+ end
68
+
69
+ # Builds a Rack conform response tripel. Should be called at the end of the
70
+ # request processing.
71
+ #
72
+ # Params:
73
+ # - status_sym [Symbol] A status code name like :ok, :unauthorized, etc.
74
+ # - headers: [Hash] HTTP headers, defaults to empty Hash
75
+ # - msg: [String] A Message for Toast log file, will be included in the body,
76
+ # if body is no set and app is in non-production modes
77
+ # - body: [String] The repsosne body text, default to nil
78
+ #
79
+ # Return: Rack conform response
80
+ def response status_sym, headers: {}, msg: nil, body: nil
81
+ Toast.logger.info "[#{Thread.current.object_id}] done: #{msg}"
82
+
83
+ unless Rails.env == 'production'
84
+ # put message in body, too, if body is free
85
+ body ||= msg
86
+ end
87
+
88
+ [ Rack::Utils::SYMBOL_TO_STATUS_CODE[status_sym],
89
+ headers,
90
+ [body] ]
91
+ end
92
+
93
+ def call_allow procs, *args
94
+ procs.each do |proc|
95
+ # call all procs, break if proc returns false and raise
96
+ begin
97
+ result = Object.new.instance_exec *args, &proc
98
+ rescue => error
99
+ raise Toast::Errors::AllowError.new(error, proc.source_location.join(':'))
100
+ end
101
+
102
+ if result == false
103
+ raise Toast::Errors::NotAllowed.new(proc.source_location.join(':'))
104
+ end
105
+ end
106
+ end
107
+
108
+ def call_handler proc, *args
109
+ result = nil
110
+
111
+ begin
112
+ context = Object.new
113
+ context.define_singleton_method(:bad_request) do |message|
114
+ raise Toast::Errors::BadRequest.new message, caller.first.sub(/:in.*/,'')
115
+ end
116
+
117
+ result = context.instance_exec *args, &proc
118
+
119
+ rescue Toast::Errors::BadRequest
120
+ raise # re-raise
121
+ rescue => error
122
+ raise Toast::Errors::HandlerError.new(error, error.backtrace.first.sub(/:in.*/,''))
123
+ end
124
+ result
125
+ end
126
+
127
+ def is_active_record? klass
128
+ klass.include? ActiveRecord::Core
129
+ end
130
+
131
+ def attr_selected? name
132
+ (@selected_attributes.nil? or @selected_attributes.include?(name.to_s))
133
+ end
134
+ end
@@ -0,0 +1,66 @@
1
+ require 'toast/request_helpers'
2
+
3
+ class Toast::SingleRequest
4
+ include Toast::RequestHelpers
5
+ include Toast::Errors
6
+
7
+ def initialize config, base_config, auth, request
8
+ @config = config
9
+ @base_config = base_config
10
+ @selected_attributes = request.query_parameters.delete(:toast_select).try(:split,',')
11
+ @uri_params = request.query_parameters
12
+ @base_uri = base_uri(request)
13
+ @verb = request.request_method.downcase
14
+ @auth = auth
15
+ @path = request.path_parameters[:toast_path]#.split('/')
16
+ @request = request
17
+ end
18
+
19
+ def respond
20
+
21
+ if @config.via_get.nil?
22
+ # not declared
23
+ response :method_not_allowed,
24
+ headers: {'Allow' => allowed_methods(@config)},
25
+ msg: "GET not configured"
26
+ else
27
+ begin
28
+ model = call_handler(@config.via_get.handler, @uri_params)
29
+ call_allow(@config.via_get.permissions, @auth, model, @uri_params)
30
+
31
+ case model
32
+ when @base_config.model_class
33
+ response :ok,
34
+ headers: {"Content-Type" => @base_config.media_type},
35
+ body: represent(model, @base_config)
36
+ when nil
37
+ response :not_found, msg: "resource not found at /#{@path}"
38
+ else
39
+ # wrong class/model_class
40
+ response :internal_server_error,
41
+ msg: "single method returned `#{model.class}', expected `#{@base_config.model_class}'"
42
+ end
43
+
44
+ rescue ActiveRecord::RecordNotFound => error
45
+ response :not_found, msg: error.message
46
+
47
+ rescue AllowError => error
48
+ return response :internal_server_error,
49
+ msg: "exception raised in allow block: `#{error.orig_error.message}' in #{error.source_location}"
50
+
51
+ rescue BadRequest => error
52
+ response :bad_request, msg: "`#{error.message}' in: #{error.source_location}"
53
+
54
+ rescue HandlerError => error
55
+ return response :internal_server_error,
56
+ msg: "exception raised in handler: `#{error.orig_error.message}' in #{error.source_location}"
57
+ rescue NotAllowed => error
58
+ return response :unauthorized, msg: "not authorized by allow block in: #{error.source_location}"
59
+
60
+ rescue => error
61
+ return response :internal_server_error,
62
+ msg: "exception raised: #{error} \n#{error.backtrace[0..5].join("\n")}"
63
+ end
64
+ end
65
+ end
66
+ end