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,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