toast 0.9.5 → 1.0.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,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