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,161 @@
1
+ require 'toast/request_helpers'
2
+ require 'toast/http_range'
3
+
4
+ class Toast::CollectionRequest
5
+ include Toast::RequestHelpers
6
+ include Toast::Errors
7
+
8
+ def initialize config, base_config, auth, request
9
+ @config = config
10
+ @base_config = base_config
11
+ @base_uri = base_uri(request)
12
+ @verb = request.request_method.downcase
13
+ @requested_range = Toast::HttpRange.new(request.env['HTTP_RANGE'])
14
+ @selected_attributes = request.query_parameters.delete(:toast_select).try(:split,',')
15
+ @uri_params = request.query_parameters
16
+ @auth = auth
17
+ @request = request
18
+ end
19
+
20
+ def respond
21
+ if @verb.in? %w(get post)
22
+ self.send(@verb)
23
+ else
24
+ response :method_not_allowed,
25
+ headers: {'Allow' => allowed_methods(@base_config)},
26
+ msg: "method #{@verb.upcase} not supported for collection URIs"
27
+ end
28
+ end
29
+
30
+ private
31
+ def get
32
+ if @config.via_get.nil?
33
+ # not declared
34
+ response :method_not_allowed,
35
+ headers: {'Allow' => allowed_methods(@config)},
36
+ msg: "GET not configured"
37
+ else
38
+ begin
39
+
40
+ range_start = @requested_range.start
41
+ window = if (@requested_range.size.nil? || @requested_range.size > @config.max_window)
42
+ @config.max_window
43
+ else
44
+ @requested_range.size
45
+ end
46
+
47
+ relation = call_handler(@config.via_get.handler, @uri_params)
48
+ call_allow(@config.via_get.permissions,
49
+ @auth, relation, @uri_params)
50
+
51
+ if relation.is_a?(ActiveRecord::Relation) and
52
+ relation.model == @config.base_model_class
53
+
54
+ # count = relation.count doesn't always work
55
+ # fix problematic select extensions for counting (-> { select(...) })
56
+ # this fails if the where clause depends on the the extended select
57
+ count = relation.count_by_sql relation.to_sql.sub(/SELECT.+FROM/,'SELECT COUNT(*) FROM')
58
+ headers = {"Content-Type" => @config.media_type}
59
+
60
+ if count > 0
61
+ range_end = if (range_start + window - 1) > (count - 1) # behind last
62
+ count - 1
63
+ else
64
+ (range_start + window - 1)
65
+ end
66
+
67
+ headers[ "Content-Range"] = "items=#{range_start}-#{range_end}/#{count}"
68
+ end
69
+
70
+ response :ok,
71
+ headers: headers,
72
+ body: represent(relation.limit(window).offset(range_start), @base_config),
73
+ msg: "sent #{count} of #{@config.mode_class}"
74
+
75
+ else
76
+ # wrong class/model_class
77
+ response :internal_server_error,
78
+ msg: "collection method returned #{relation.class}, expected ActiveRecord::Relation of #{@config.base_model_class}"
79
+ end
80
+
81
+
82
+ rescue NotAllowed => error
83
+ return response :unauthorized,
84
+ msg: "not authorized by allow block in: #{error.source_location}"
85
+
86
+ rescue BadRequest => error
87
+ response :bad_request, msg: "`#{error.message}' in: #{error.source_location}"
88
+
89
+ rescue HandlerError => error
90
+ return response :internal_server_error,
91
+ msg: "exception raised in via_get handler: `#{error.orig_error.message}' in #{error.source_location}"
92
+ rescue AllowError => error
93
+ return response :internal_server_error,
94
+ msg: "exception raised in allow block: `#{error.orig_error.message}' in #{error.source_location}"
95
+ rescue => error
96
+ response :internal_server_error,
97
+ msg: "exception from via_get handler in: #{error.backtrace.first.sub(Rails.root.to_s+'/', '')}: #{error.message}"
98
+ end
99
+ end
100
+ end
101
+
102
+ def post
103
+ if @config.via_post.nil?
104
+ # not declared
105
+ response :method_not_allowed,
106
+ headers: {'Allow' => allowed_methods(@config)},
107
+ msg: "POST not configured"
108
+ else
109
+ begin
110
+ payload = JSON.parse(@request.body.read)
111
+
112
+ call_allow(@config.via_post.permissions,
113
+ @auth, nil, @uri_params)
114
+
115
+ # remove all attributes not in writables from payload
116
+ payload.delete_if do |attr,val|
117
+ unless attr.to_sym.in?(@base_config.writables)
118
+ Toast.logger.warn "<POST #{@request.fullpath}> received attribute `#{attr}' is not writable or unknown"
119
+ true
120
+ end
121
+ end
122
+
123
+ new_instance = call_handler(@config.via_post.handler, payload, @uri_params)
124
+
125
+ if new_instance.persisted?
126
+ response :created,
127
+ headers: {"Content-Type" => @config.media_type},
128
+ msg: "created #{new_instance.class}##{new_instance.id}",
129
+ body: represent(new_instance, @base_config)
130
+ else
131
+ message = new_instance.errors.count > 0 ?
132
+ ": " + new_instance.errors.full_messages.join(',') : ''
133
+
134
+ response :conflict,
135
+ msg: "creation of #{new_instance.class} aborted#{message}"
136
+ end
137
+
138
+ rescue JSON::ParserError => error
139
+ return response :internal_server_error, msg: "expect JSON body"
140
+
141
+ rescue NotAllowed => error
142
+ return response :unauthorized,
143
+ msg: "not authorized by allow block in: #{error.source_location}"
144
+
145
+ rescue BadRequest => error
146
+ response :bad_request, msg: "`#{error.message}' in: #{error.source_location}"
147
+
148
+ rescue HandlerError => error
149
+ return response :internal_server_error,
150
+ msg: "exception raised in via_post handler: `#{error.orig_error.message}' in #{error.source_location}"
151
+ rescue AllowError => error
152
+ return response :internal_server_error,
153
+ msg: "exception raised in allow block: `#{error.orig_error.message}' in #{error.source_location}"
154
+ rescue => error
155
+ response :internal_server_error,
156
+ msg: "exception from via_post handler in: #{error.backtrace.first.sub(Rails.root.to_s+'/', '')}: #{error.message}"
157
+
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,72 @@
1
+ require 'toast/config_dsl/via_verb.rb'
2
+ require 'toast/config_dsl/default_handlers.rb'
3
+
4
+ class Toast::ConfigDSL::Association
5
+ include Toast::ConfigDSL::Common
6
+ include Toast::ConfigDSL::DefaultHandlers
7
+
8
+ def via_get &block
9
+ stack_push 'via_get' do
10
+ @config_data.via_get =
11
+ OpenStruct.new(permissions: [],
12
+ handler: (@config_data.singular ?
13
+ singular_assoc_get_handler(@config_data.assoc_name) :
14
+ plural_assoc_get_handler(@config_data.assoc_name)))
15
+
16
+ Toast::ConfigDSL::ViaVerb.new(@config_data.via_get).instance_eval &block
17
+
18
+
19
+ end
20
+ end
21
+
22
+ def via_post &block
23
+ stack_push 'via_post' do
24
+ if @config_data.singular
25
+ raise_config_error "`via_post' is not allowed for singular associations"
26
+ end
27
+
28
+ @config_data.via_post = OpenStruct.new(permissions: [],
29
+ handler: plural_assoc_post_handler(@config_data.assoc_name))
30
+
31
+ Toast::ConfigDSL::ViaVerb.new(@config_data.via_post).instance_eval &block
32
+
33
+
34
+ end
35
+ end
36
+
37
+ def via_link &block
38
+ stack_push 'via_link' do
39
+ @config_data.via_link = OpenStruct.new(permissions: [],
40
+ handler: (@config_data.singular ?
41
+ singular_assoc_link_handler(@config_data.assoc_name) :
42
+ plural_assoc_link_handler(@config_data.assoc_name)))
43
+
44
+ Toast::ConfigDSL::ViaVerb.new(@config_data.via_link).instance_eval &block
45
+
46
+ end
47
+ end
48
+
49
+ def via_unlink &block
50
+ stack_push 'via_unlink' do
51
+ @config_data.via_unlink = OpenStruct.new(permissions: [],
52
+ handler: (@config_data.singular ?
53
+ singular_assoc_unlink_handler(@config_data.assoc_name) :
54
+ plural_assoc_unlink_handler(@config_data.assoc_name)))
55
+
56
+ Toast::ConfigDSL::ViaVerb.new(@config_data.via_unlink).instance_eval &block
57
+
58
+ end
59
+ end
60
+
61
+ def max_window size
62
+ stack_push 'max_window' do
63
+ if size.is_a?(Fixnum) and size > 0
64
+ @config_data.max_window = size
65
+ elsif size == :unlimited
66
+ @config_data.max_window = 10**6 # yes that's inifinity
67
+ else
68
+ raise_config_error 'max_window must a positive integer or :unlimited'
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,50 @@
1
+ require 'toast/config_dsl/expose'
2
+
3
+ class Toast::ConfigDSL::Base
4
+ include Toast::ConfigDSL::Common
5
+
6
+ def expose model_class, as: 'application/json', under: '', &block
7
+
8
+ stack_push "expose(#{model_class})" do
9
+
10
+ begin
11
+ unless model_class.new.is_a?(ActiveRecord::Base)
12
+ raise_config_error 'Directive requires an ActiveRecord::Base descendant.'
13
+ end
14
+ rescue ActiveRecord::StatementInvalid => error
15
+ # may be raised when tables are not setup yet during database setup
16
+ raise_config_error error.message
17
+ end
18
+
19
+ unless block_given?
20
+ raise_config_error 'Block expected.'
21
+ end
22
+
23
+ config_data = OpenStruct.new
24
+
25
+ config_data.instance_eval do
26
+ self.source_location = block.source_location.first
27
+
28
+ self.model_class = model_class
29
+ self.media_type = as
30
+ self.url_path_prefix = under.split('/').delete_if(&:blank?)
31
+
32
+ # defaults
33
+ self.readables = []
34
+ self.writables = []
35
+ self.collections = {}
36
+ self.singles = {}
37
+ self.associations = {}
38
+ end
39
+
40
+ if Toast.expositions.detect{|exp| exp.model_class == config_data.model_class}
41
+ raise_config_error "Model class #{exp.model_class} has already another configuration."
42
+ end
43
+
44
+ Toast.expositions << config_data
45
+
46
+ # evaluate expose block
47
+ Toast::ConfigDSL::Expose.new(config_data).instance_eval &block
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,45 @@
1
+ require 'toast/config_dsl/via_verb.rb'
2
+ require 'toast/config_dsl/default_handlers.rb'
3
+
4
+ class Toast::ConfigDSL::Collection
5
+ include Toast::ConfigDSL::Common
6
+ include Toast::ConfigDSL::DefaultHandlers
7
+
8
+ def via_get &block
9
+ stack_push 'via_get' do
10
+ @config_data.via_get =
11
+ OpenStruct.new(permissions: [],
12
+ handler: collection_get_handler(@config_data.base_model_class,
13
+ @config_data.collection_name))
14
+
15
+ Toast::ConfigDSL::ViaVerb.new(@config_data.via_get).instance_eval &block
16
+
17
+ end
18
+ end
19
+
20
+ def via_post &block
21
+ stack_push 'via_post' do
22
+ unless @config_data.collection_name == :all
23
+ raise_config_error "POST is supported for the `all' collection only"
24
+ end
25
+
26
+ @config_data.via_post = OpenStruct.new(permissions: [],
27
+ handler: collection_post_handler(@config_data.base_model_class))
28
+
29
+ Toast::ConfigDSL::ViaVerb.new(@config_data.via_post).instance_eval &block
30
+
31
+ end
32
+ end
33
+
34
+ def max_window size
35
+ stack_push 'max_window' do
36
+ if size.is_a?(Integer) and size > 0
37
+ @config_data.max_window = size
38
+ elsif size == :unlimited
39
+ @config_data.max_window = 10**6 # yes that's inifinity
40
+ else
41
+ raise_config_error 'max_window must a positive integer or :unlimited'
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,38 @@
1
+ module Toast::ConfigDSL::Common
2
+ def initialize config_data=nil
3
+ @config_data = config_data
4
+ end
5
+
6
+ def method_missing method, *args
7
+ raise_config_error "Unknown directive: `#{method}'"
8
+ end
9
+
10
+ def check_symbol_list list
11
+ unless list.is_a?(Array) and list.all?{|x| x.is_a? Symbol}
12
+ raise_config_error "Directive requires a list of symbols.\n"+
13
+ " #{list.map{|x| x.inspect}.join(', ')} ?"
14
+ end
15
+ end
16
+
17
+ def raise_config_error message=''
18
+ match = caller.grep(/#{Toast::ConfigDSL.cfg_name}/).first
19
+
20
+ file_line = if match.nil?
21
+ Toast::ConfigDSL.cfg_name
22
+ else
23
+ match.split(':in').first
24
+ end
25
+
26
+ message += "\n directive: /#{Toast::ConfigDSL.stack.join('/')}"
27
+ message += "\n in file : #{file_line}"
28
+
29
+ Toast.raise_config_error message
30
+ end
31
+
32
+ # ... to not forget to pop use:
33
+ def stack_push level, &block
34
+ Toast::ConfigDSL.stack << level
35
+ yield
36
+ Toast::ConfigDSL.stack.pop
37
+ end
38
+ end
@@ -0,0 +1,83 @@
1
+ module Toast::ConfigDSL::DefaultHandlers
2
+ def plural_assoc_get_handler assoc_name
3
+ lambda do |source, uri_params|
4
+ source.send(assoc_name)
5
+ end
6
+ end
7
+
8
+ def singular_assoc_get_handler assoc_name
9
+ lambda do |source, uri_params|
10
+ source.send(assoc_name)
11
+ end
12
+ end
13
+
14
+ def single_get_handler model_class, single_name
15
+ lambda do |uri_params|
16
+ model_class.send(single_name)
17
+ end
18
+ end
19
+
20
+ def collection_get_handler model_class, coll_name
21
+ lambda do |uri_params|
22
+ model_class.send(coll_name)
23
+ end
24
+ end
25
+
26
+ def canonical_get_handler
27
+ lambda do |model, uri_params|
28
+ model
29
+ end
30
+ end
31
+
32
+ def canonical_patch_handler
33
+ lambda do |model, payload, uri_params|
34
+ model.update payload
35
+ end
36
+ end
37
+
38
+ def collection_post_handler model_class
39
+ lambda do |payload, uri_params|
40
+ model_class.create payload
41
+ end
42
+ end
43
+
44
+ def plural_assoc_post_handler assoc_name
45
+ lambda do |source, payload, uri_params|
46
+ source.send(assoc_name).create payload
47
+ end
48
+ end
49
+
50
+ def canonical_delete_handler
51
+ lambda do |model, uri_params|
52
+ model.destroy
53
+ end
54
+ end
55
+
56
+ def singular_assoc_link_handler assoc_name
57
+ lambda do |source, target, uri_params|
58
+ source.send("#{assoc_name}=", target)
59
+ source.save
60
+ end
61
+ end
62
+
63
+ def plural_assoc_link_handler assoc_name
64
+ lambda do |source, target, uri_params|
65
+ source.send(assoc_name) << target
66
+ end
67
+ end
68
+
69
+ def singular_assoc_unlink_handler assoc_name
70
+ lambda do |source, target, uri_params|
71
+ if source.send(assoc_name) == target
72
+ source.send("#{assoc_name}=", nil)
73
+ source.save
74
+ end
75
+ end
76
+ end
77
+
78
+ def plural_assoc_unlink_handler name
79
+ lambda do |source, target, uri_params|
80
+ source.send(name).delete(target)
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,176 @@
1
+ require 'toast/config_dsl/association'
2
+ require 'toast/config_dsl/collection'
3
+ require 'toast/config_dsl/single'
4
+
5
+ # context for expose{} blocks
6
+ class Toast::ConfigDSL::Expose
7
+ include Toast::ConfigDSL::Common
8
+ include Toast::ConfigDSL::DefaultHandlers
9
+
10
+ # directives
11
+ def writables *attributes
12
+ stack_push "writables(#{attributes.map(&:inspect).join(',')})" do
13
+
14
+ check_symbol_list attributes
15
+
16
+ model_class = @config_data.model_class
17
+
18
+ attributes.each do |attr|
19
+
20
+ model = model_class.new
21
+ setter = (attr.to_s + '=').to_sym
22
+
23
+ unless (model.respond_to?(setter) and model.method(setter).arity == 1)
24
+ raise_config_error "Exposed attribute setter not found: `#{model_class.name}##{attr}='. Typo?"
25
+ end
26
+
27
+ unless (model.respond_to?(attr) and model.method(attr).arity.in?([-1,0]))
28
+ raise_config_error "Exposed attribute getter not found `#{model_class.name}##{attr}'. Typo?"
29
+ end
30
+
31
+ @config_data.writables << attr
32
+ end
33
+ end
34
+ end
35
+
36
+ def readables *attributes
37
+ stack_push "readables(#{attributes.map(&:inspect).join(',')})" do
38
+
39
+ check_symbol_list attributes
40
+
41
+ model_class = @config_data.model_class
42
+
43
+ attributes.each do |attr|
44
+ model = model_class.new
45
+
46
+ unless (model.respond_to?(attr) and model.method(attr).arity.in?([-1,0]))
47
+ raise_config_error "Exposed attribute getter not found `#{model_class.name}##{attr}'. Typo?"
48
+ end
49
+
50
+ @config_data.readables << attr
51
+ end
52
+ end
53
+ end
54
+
55
+ def via_get &block
56
+ stack_push 'via_get' do
57
+ @config_data.via_get =
58
+ OpenStruct.new(permissions: [],
59
+ handler: canonical_get_handler)
60
+
61
+ Toast::ConfigDSL::ViaVerb.new(@config_data.via_get).instance_eval &block
62
+
63
+ end
64
+ end
65
+
66
+ def via_patch &block
67
+ stack_push 'via_patch' do
68
+ @config_data.via_patch =
69
+ OpenStruct.new(permissions: [],
70
+ handler: canonical_patch_handler)
71
+
72
+ Toast::ConfigDSL::ViaVerb.new(@config_data.via_patch).instance_eval &block
73
+
74
+ end
75
+ end
76
+
77
+ def via_delete &block
78
+ stack_push 'via_delete' do
79
+ @config_data.via_delete =
80
+ OpenStruct.new(permissions: [],
81
+ handler: canonical_delete_handler)
82
+
83
+ Toast::ConfigDSL::ViaVerb.new(@config_data.via_delete).instance_eval &block
84
+
85
+ end
86
+ end
87
+
88
+
89
+ def collection name, as: 'application/json', &block
90
+ stack_push "collection(#{name.inspect})" do
91
+ model_class = @config_data.model_class
92
+
93
+ unless block_given?
94
+ raise_config_error 'Block expected.'
95
+ end
96
+
97
+ unless name.is_a?(Symbol)
98
+ raise_config_error "collection name expected as Symbol"
99
+ end
100
+
101
+ unless model_class.respond_to?(name)
102
+ raise_config_error "`#{name}' must be a callable class method."
103
+ end
104
+
105
+ @config_data.collections[name] =
106
+ OpenStruct.new(base_model_class: model_class,
107
+ collection_name: name,
108
+ media_type: as,
109
+ max_window: Toast.settings.max_window)
110
+
111
+ Toast::ConfigDSL::Collection.new(@config_data.collections[name]).
112
+ instance_eval(&block)
113
+
114
+ end
115
+ end
116
+
117
+ def single name, &block
118
+ stack_push "single(#{name.inspect})" do
119
+
120
+ model_class = @config_data.model_class
121
+
122
+ unless model_class.respond_to?(name)
123
+ raise_config_error "`#{name}' must be a callable class method."
124
+ end
125
+
126
+ unless block_given?
127
+ raise_config_error 'Block expected.'
128
+ end
129
+
130
+ @config_data.singles[name] = OpenStruct.new(name: name, model_class: model_class)
131
+
132
+ Toast::ConfigDSL::Single.new(@config_data.singles[name]).
133
+ instance_eval(&block)
134
+ end
135
+ end
136
+
137
+ def association name, as: 'application/json', &block
138
+ stack_push "association(#{name.inspect})" do
139
+
140
+
141
+ model_class = @config_data.model_class
142
+
143
+ unless name.is_a?(Symbol)
144
+ raise_config_error "association name expected as Symbol"
145
+ end
146
+
147
+ unless model_class.reflections[name.to_s].
148
+ try(:macro).in?([:has_many,
149
+ :has_one,
150
+ :belongs_to,
151
+ :has_and_belongs_to_many])
152
+ raise_config_error 'Association expected'
153
+ end
154
+
155
+ unless block_given?
156
+ raise_config_error 'Block expected.'
157
+ end
158
+
159
+ target_model_class = model_class.reflections[name.to_s].klass
160
+ macro = model_class.reflections[name.to_s].macro
161
+ singular = macro.in? [:belongs_to, :has_one]
162
+
163
+ @config_data.associations[name] =
164
+ OpenStruct.new(base_model_class: model_class,
165
+ target_model_class: target_model_class,
166
+ assoc_name: name,
167
+ media_type: as,
168
+ macro: macro,
169
+ singular: singular,
170
+ max_window: singular ? nil : Toast.settings.max_window)
171
+
172
+ Toast::ConfigDSL::Association.new(@config_data.associations[name]).
173
+ instance_eval(&block)
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,35 @@
1
+ require 'toast/errors'
2
+
3
+ class Toast::ConfigDSL::Settings
4
+ include Toast::ConfigDSL::Common
5
+
6
+ class AuthenticateContext
7
+ def fail_with hash
8
+ raise Toast::Errors::CustomAuthFailure.new(hash)
9
+ end
10
+ end
11
+
12
+ def toast_settings &block
13
+ stack_push 'toast_settings' do
14
+ self.instance_eval &block
15
+ end
16
+ end
17
+
18
+ def max_window size
19
+ if size.is_a?(Integer) and size > 0
20
+ Toast.settings.max_window = size
21
+ elsif size == :unlimited
22
+ Toast.settings.max_window = 10**6 # yes that's inifinity
23
+ else
24
+ raise_config_error 'max_window must a positive integer or :unlimited'
25
+ end
26
+ end
27
+
28
+ def link_unlink_via_post boolean
29
+ Toast.settings.link_unlink_via_post = boolean
30
+ end
31
+
32
+ def authenticate &block
33
+ Toast.settings.authenticate = block
34
+ end
35
+ end
@@ -0,0 +1,15 @@
1
+ class Toast::ConfigDSL::Single
2
+ include Toast::ConfigDSL::Common
3
+ include Toast::ConfigDSL::DefaultHandlers
4
+
5
+ def via_get &block
6
+ stack_push 'via_get' do
7
+
8
+ @config_data.via_get =
9
+ OpenStruct.new(permissions: [],
10
+ handler: single_get_handler(@config_data.model_class, @config_data.name))
11
+
12
+ Toast::ConfigDSL::ViaVerb.new(@config_data.via_get).instance_eval &block
13
+ end
14
+ end
15
+ end