sinja 0.2.0.beta2 → 1.0.0.pre1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,17 @@
1
+ require 'sinatra/base'
2
+
3
+ class MyApp < Sinatra::Base
4
+ get '/raise' do
5
+ raise Sinatra::BadRequest
6
+ end
7
+
8
+ get '/halt' do
9
+ halt 400
10
+ end
11
+
12
+ error Sinatra::BadRequest, 400 do
13
+ body "received error"
14
+ end
15
+
16
+ run!
17
+ end
@@ -1,33 +1,78 @@
1
1
  # frozen_string_literal: true
2
+ require 'active_support/inflector'
3
+ require 'mustermann'
2
4
  require 'sinatra/base'
3
5
  require 'sinatra/namespace'
4
6
 
5
- require 'role_list'
6
7
  require 'sinja/config'
8
+ require 'sinja/errors'
7
9
  require 'sinja/helpers/serializers'
8
10
  require 'sinja/resource'
11
+ require 'sinja/version'
9
12
 
10
13
  module Sinja
11
14
  MIME_TYPE = 'application/vnd.api+json'
12
-
13
- SinjaError = Class.new(StandardError)
14
- ActionHelperError = Class.new(SinjaError)
15
+ ERROR_CODES = [
16
+ BadRequestError,
17
+ ForbiddenError,
18
+ NotFoundError,
19
+ MethodNotAllowedError,
20
+ NotAcceptibleError,
21
+ ConflictError,
22
+ UnsupportedTypeError
23
+ ].map! { |c| [c.new.http_status, c] }.to_h.tap do |h|
24
+ h[422] = UnprocessibleEntityError
25
+ end.freeze
15
26
 
16
27
  def resource(resource_name, konst=nil, &block)
17
28
  abort "Must supply proc constant or block for `resource'" \
18
29
  unless block = (konst if konst.is_a?(Proc)) || block
19
30
 
20
- _sinja.resource_roles[resource_name.to_sym] # trigger default proc
31
+ resource_name = resource_name.to_s
32
+ .pluralize
33
+ .dasherize
34
+ .to_sym
35
+
36
+ # trigger default procs
37
+ _sinja.resource_roles[resource_name]
38
+ _sinja.resource_sideload[resource_name]
39
+
40
+ namespace "/#{resource_name}" do
41
+ define_singleton_method(:_resource_roles) do
42
+ _sinja.resource_roles[resource_name]
43
+ end
44
+
45
+ define_singleton_method(:resource_roles) do
46
+ _resource_roles[:resource]
47
+ end
21
48
 
22
- namespace "/#{resource_name.to_s.tr('_', '-')}" do
23
- define_singleton_method(:can) do |action, roles|
24
- _sinja.resource_roles[resource_name.to_sym].merge!(action=>roles)
49
+ define_singleton_method(:resource_sideload) do
50
+ _sinja.resource_sideload[resource_name]
25
51
  end
26
52
 
27
53
  helpers do
28
54
  define_method(:can?) do |*args|
29
- super(resource_name.to_sym, *args)
55
+ super(resource_name, *args)
30
56
  end
57
+
58
+ define_method(:sanity_check!) do |*args|
59
+ super(resource_name, *args)
60
+ end
61
+
62
+ define_method(:sideload?) do |*args|
63
+ super(resource_name, *args)
64
+ end
65
+ end
66
+
67
+ before %r{/(?<id>[^/]+)(?:/.*)?} do |id|
68
+ self.resource =
69
+ if env.key?('sinja.resource')
70
+ env['sinja.resource']
71
+ elsif respond_to?(:find)
72
+ find(id)
73
+ end
74
+
75
+ raise NotFoundError, "Resource '#{id}' not found" unless resource
31
76
  end
32
77
 
33
78
  register Resource
@@ -36,6 +81,8 @@ module Sinja
36
81
  end
37
82
  end
38
83
 
84
+ alias_method :resources, :resource
85
+
39
86
  def sinja
40
87
  if block_given?
41
88
  yield _sinja
@@ -50,19 +97,20 @@ module Sinja
50
97
  end
51
98
 
52
99
  def self.registered(app)
100
+ app.register Mustermann if Sinatra::VERSION[/^\d+/].to_i < 2
53
101
  app.register Sinatra::Namespace
54
102
 
55
- app.disable :protection, :static
103
+ app.disable :protection, :show_exceptions, :static
56
104
  app.set :_sinja, Sinja::Config.new
57
- app.configure(:development) do |c|
58
- c.set :show_exceptions, :after_handler
59
- end
105
+ app.set :_resource_roles, nil # dummy value overridden in each resource
60
106
 
61
107
  app.set :actions do |*actions|
62
108
  condition do
63
109
  actions.each do |action|
64
- halt 403, 'You are not authorized to perform this action' unless can?(action)
65
- halt 405, 'Action or method not implemented or supported' unless respond_to?(action)
110
+ raise ForbiddenError, 'You are not authorized to perform this action' \
111
+ unless can?(action) || sideload?(action)
112
+ raise MethodNotAllowedError, 'Action or method not implemented or supported' \
113
+ unless respond_to?(action)
66
114
  end
67
115
  true
68
116
  end
@@ -83,9 +131,30 @@ module Sinja
83
131
  app.mime_type :api_json, MIME_TYPE
84
132
 
85
133
  app.helpers Helpers::Serializers do
86
- def can?(resource_name, action)
87
- roles = settings._sinja.resource_roles[resource_name][action]
88
- roles.nil? || roles.empty? || roles === role
134
+ def allow(h={})
135
+ s = Set.new
136
+ h.each do |method, actions|
137
+ s << method if [*actions].all? { |action| respond_to?(action) }
138
+ end
139
+ headers 'Allow'=>s.map(&:upcase).join(',')
140
+ end
141
+
142
+ def attributes
143
+ dedasherize_names(data.fetch(:attributes, {}))
144
+ end
145
+
146
+ if method_defined?(:bad_request?)
147
+ # This screws up our error-handling logic in Sinatra 2.0, so monkeypatch it.
148
+ def bad_request?
149
+ false
150
+ end
151
+ end
152
+
153
+ def can?(resource_name, action, rel_type=nil, rel=nil)
154
+ lookup = settings._sinja.resource_roles[resource_name]
155
+ # TODO: This is... problematic.
156
+ roles = (lookup[rel_type][rel][action] if rel_type && rel) || lookup[:resource][action]
157
+ roles.nil? || roles.empty? || roles === memoized_role
89
158
  end
90
159
 
91
160
  def content?
@@ -93,7 +162,26 @@ module Sinja
93
162
  end
94
163
 
95
164
  def data
96
- @data ||= deserialized_request_body[:data]
165
+ @data ||= {}
166
+ @data[request.path] ||= begin
167
+ deserialize_request_body.fetch(:data)
168
+ rescue NoMethodError, KeyError
169
+ raise BadRequestError, 'Malformed JSON:API request payload'
170
+ end
171
+ end
172
+
173
+ def halt(code, body=nil)
174
+ if exception_class = ERROR_CODES[code]
175
+ raise exception_class, body
176
+ elsif (400...600).include?(code.to_i)
177
+ raise HttpError.new(code.to_i, body)
178
+ else
179
+ super
180
+ end
181
+ end
182
+
183
+ def memoized_role
184
+ @role ||= role
97
185
  end
98
186
 
99
187
  def normalize_params!
@@ -107,34 +195,74 @@ module Sinja
107
195
  }.each { |k, v| params[k] ||= v }
108
196
  end
109
197
 
198
+ def sideload?(resource_name, child)
199
+ return unless sideloaded?
200
+ parent = env.fetch('sinja.passthru', 'unknown').to_sym
201
+ settings._sinja.resource_sideload[resource_name][child]&.
202
+ include?(parent) && can?(parent)
203
+ end
204
+
205
+ def sideloaded?
206
+ env.key?('sinja.passthru')
207
+ end
208
+
110
209
  def role
111
210
  nil
112
211
  end
113
212
 
213
+ def role?(*roles)
214
+ Roles[*roles] === memoized_role
215
+ end
216
+
217
+ def sanity_check!(resource_name, id=nil)
218
+ raise ConflictError, 'Resource type in payload does not match endpoint' \
219
+ if data[:type].to_sym != resource_name
220
+
221
+ raise ConflictError, 'Resource ID in payload does not match endpoint' \
222
+ if id && data[:id].to_s != id.to_s
223
+ end
224
+
114
225
  def transaction
115
226
  yield
116
227
  end
117
228
  end
118
229
 
119
230
  app.before do
120
- halt 406 unless request.preferred_type.entry == MIME_TYPE
121
-
122
- if content?
123
- halt 415 unless request.media_type == MIME_TYPE
124
- halt 415 if request.media_type_params.keys.any? { |k| k != 'charset' }
231
+ unless sideloaded?
232
+ raise NotAcceptibleError unless request.preferred_type.entry == MIME_TYPE
233
+ raise UnsupportedTypeError if content? && (
234
+ request.media_type != MIME_TYPE || request.media_type_params.keys.any? { |k| k != 'charset' }
235
+ )
125
236
  end
126
237
 
127
- content_type :api_json
128
-
129
238
  normalize_params!
239
+
240
+ content_type :api_json
130
241
  end
131
242
 
132
243
  app.after do
133
- body serialized_response_body if response.ok?
244
+ body serialize_response_body if response.ok? || response.created?
245
+ end
246
+
247
+ app.error 400...600 do
248
+ serialize_errors(&settings._sinja.error_logger)
134
249
  end
135
250
 
136
- app.error 400...600, nil do
137
- serialized_error
251
+ app.error StandardError do
252
+ env['sinatra.error'].tap do |e|
253
+ boom =
254
+ if settings._sinja.not_found_exceptions.any? { |c| c === e }
255
+ NotFoundError.new(e.message) unless NotFoundError === e
256
+ elsif settings._sinja.conflict_exceptions.any? { |c| c === e }
257
+ ConflictError.new(e.message) unless ConflictError === e
258
+ elsif settings._sinja.validation_exceptions.any? { |c| c === e }
259
+ UnprocessibleEntityError.new(settings._sinja.validation_formatter.(e)) unless UnprocessibleEntityError === e
260
+ end
261
+
262
+ handle_exception!(boom) if boom # re-throw the re-packaged exception
263
+ end
264
+
265
+ serialize_errors(&settings._sinja.error_logger)
138
266
  end
139
267
  end
140
268
  end
@@ -1,8 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
  require 'forwardable'
3
3
  require 'set'
4
+ require 'sinatra/base'
4
5
 
5
- require 'role_list'
6
+ require 'sinja/resource'
6
7
  require 'sinja/relationship_routes/has_many'
7
8
  require 'sinja/relationship_routes/has_one'
8
9
  require 'sinja/resource_routes'
@@ -27,50 +28,87 @@ module Sinja
27
28
  }.freeze
28
29
 
29
30
  DEFAULT_OPTS = {
30
- :logger_progname=>'sinja',
31
31
  :json_generator=>(Sinatra::Base.development? ? :pretty_generate : :generate),
32
- :json_error_generator=>(Sinatra::Base.development? ? :pretty_generate : :fast_generate)
32
+ :json_error_generator=>(Sinatra::Base.development? ? :pretty_generate : :generate)
33
33
  }.freeze
34
34
 
35
35
  attr_reader \
36
+ :error_logger,
36
37
  :default_roles,
38
+ :default_has_many_roles,
39
+ :default_has_one_roles,
37
40
  :resource_roles,
38
- :conflict_actions,
41
+ :resource_sideload,
39
42
  :conflict_exceptions,
43
+ :not_found_exceptions,
44
+ :validation_exceptions,
45
+ :validation_formatter,
40
46
  :serializer_opts
41
47
 
42
48
  def initialize
43
- @default_roles = RolesConfig.new
44
- @resource_roles = Hash.new { |h, k| h[k] = @default_roles.dup }
49
+ @error_logger = ->(eh) { logger.error('sinja') { eh } }
45
50
 
46
- self.conflict_actions = [
47
- ResourceRoutes::CONFLICT_ACTIONS,
48
- RelationshipRoutes::HasMany::CONFLICT_ACTIONS,
49
- RelationshipRoutes::HasOne::CONFLICT_ACTIONS
50
- ].reduce([], :concat)
51
- self.conflict_exceptions = []
51
+ @default_roles = RolesConfig.new(ResourceRoutes::ACTIONS)
52
+ @default_has_many_roles = RolesConfig.new(RelationshipRoutes::HasMany::ACTIONS)
53
+ @default_has_one_roles = RolesConfig.new(RelationshipRoutes::HasOne::ACTIONS)
54
+
55
+ @resource_roles = Hash.new { |h, k| h[k] = {
56
+ :resource=>@default_roles.dup,
57
+ :has_many=>Hash.new { |rh, rk| rh[rk] = @default_has_many_roles.dup },
58
+ :has_one=>Hash.new { |rh, rk| rh[rk] = @default_has_one_roles.dup }
59
+ }}
60
+
61
+ @resource_sideload = Hash.new do |h, k|
62
+ h[k] = SideloadConfig.new(Resource::SIDELOAD_ACTIONS)
63
+ end
64
+
65
+ @conflict_exceptions = Set.new
66
+ @not_found_exceptions = Set.new
67
+ @validation_exceptions = Set.new
68
+ @validation_formatter = ->{ Array.new }
52
69
 
53
70
  @opts = deep_copy(DEFAULT_OPTS)
54
- self.serializer_opts = {}
71
+ @serializer_opts = deep_copy(DEFAULT_SERIALIZER_OPTS)
55
72
  end
56
73
 
57
- def conflict_actions=(e=[])
58
- @conflict_actions = Set[*e]
74
+ def error_logger=(f)
75
+ fail "Invalid error formatter #{f}" \
76
+ unless f.respond_to?(:call)
77
+
78
+ fail "Can't modify frozen proc" \
79
+ if @error_logger.frozen?
80
+
81
+ @error_logger = f
59
82
  end
60
83
 
61
84
  def conflict_exceptions=(e=[])
62
- @conflict_exceptions = Set[*e]
85
+ @conflict_exceptions.replace(Set[*e])
63
86
  end
64
87
 
65
- def conflict?(action, exception_class)
66
- @conflict_actions.include?(action) &&
67
- @conflict_exceptions.include?(exception_class)
88
+ def not_found_exceptions=(e=[])
89
+ @not_found_exceptions.replace(Set[*e])
90
+ end
91
+
92
+ def validation_exceptions=(e=[])
93
+ @validation_exceptions.replace(Set[*e])
94
+ end
95
+
96
+ def validation_formatter=(f)
97
+ fail "Invalid validation formatter #{f}" \
98
+ unless f.respond_to?(:call)
99
+
100
+ fail "Can't modify frozen proc" \
101
+ if @validation_formatter.frozen?
102
+
103
+ @validation_formatter = f
68
104
  end
69
105
 
70
106
  def_delegator :@default_roles, :merge!, :default_roles=
107
+ def_delegator :@default_has_many_roles, :merge!, :default_has_many_roles=
108
+ def_delegator :@default_has_one_roles, :merge!, :default_has_one_roles=
71
109
 
72
110
  def serializer_opts=(h={})
73
- @serializer_opts = deep_copy(DEFAULT_SERIALIZER_OPTS).merge!(h)
111
+ @serializer_opts.replace(deep_copy(DEFAULT_SERIALIZER_OPTS).merge!(h))
74
112
  end
75
113
 
76
114
  DEFAULT_OPTS.keys.each do |k|
@@ -79,36 +117,63 @@ module Sinja
79
117
  end
80
118
 
81
119
  def freeze
120
+ @error_logger.freeze
121
+
82
122
  @default_roles.freeze
123
+ @default_has_many_roles.freeze
124
+ @default_has_one_roles.freeze
125
+
83
126
  @resource_roles.default_proc = nil
127
+ @resource_roles.values.each do |h|
128
+ h[:resource].freeze
129
+ h[:has_many].default_proc = nil
130
+ deep_freeze(h[:has_many])
131
+ h[:has_one].default_proc = nil
132
+ deep_freeze(h[:has_one])
133
+ end
84
134
  deep_freeze(@resource_roles)
85
- @conflict_actions.freeze
135
+
136
+ @resource_sideload.default_proc = nil
137
+ deep_freeze(@resource_sideload)
138
+
86
139
  @conflict_exceptions.freeze
140
+ @not_found_exceptions.freeze
141
+ @validation_exceptions.freeze
142
+ @validation_formatter.freeze
143
+
87
144
  deep_freeze(@serializer_opts)
145
+
88
146
  @opts.freeze
147
+
89
148
  super
90
149
  end
91
150
  end
92
151
 
152
+ class Roles < Set
153
+ def ===(other)
154
+ self.intersect?(Set === other ? other : Set[*other])
155
+ end
156
+ end
157
+
93
158
  class RolesConfig
94
159
  include ConfigUtils
95
160
  extend Forwardable
96
161
 
97
- def initialize
98
- @data = [
99
- ResourceRoutes::ACTIONS,
100
- RelationshipRoutes::HasMany::ACTIONS,
101
- RelationshipRoutes::HasOne::ACTIONS
102
- ].reduce([], :concat).map { |action| [action, RoleList.new] }.to_h
162
+ def initialize(actions=[])
163
+ @data = actions.map { |action| [action, Roles.new] }.to_h
103
164
  end
104
165
 
105
- def_delegator :@data, :[]
166
+ def_delegators :@data, :[], :dig
167
+
168
+ def ==(other)
169
+ @data == other.instance_variable_get(:@data)
170
+ end
106
171
 
107
172
  def merge!(h={})
108
173
  h.each do |action, roles|
109
174
  abort "Unknown or invalid action helper `#{action}' in configuration" \
110
175
  unless @data.key?(action)
111
- @data[action].replace(RoleList[*roles])
176
+ @data[action].replace(Roles[*roles])
112
177
  end
113
178
  @data
114
179
  end
@@ -123,4 +188,33 @@ module Sinja
123
188
  super
124
189
  end
125
190
  end
191
+
192
+ class SideloadConfig
193
+ include ConfigUtils
194
+ extend Forwardable
195
+
196
+ def initialize(actions=[])
197
+ @data = actions.map { |child| [child, Set.new] }.to_h
198
+ end
199
+
200
+ def_delegators :@data, :[], :dig
201
+
202
+ def ==(other)
203
+ @data == other.instance_variable_get(:@data)
204
+ end
205
+
206
+ def merge!(h={})
207
+ h.each do |child, parents|
208
+ abort "Unknown or invalid action helper `#{child}' in configuration" \
209
+ unless @data.key?(child)
210
+ @data[child].replace(Set[*parents])
211
+ end
212
+ @data
213
+ end
214
+
215
+ def freeze
216
+ deep_freeze(@data)
217
+ super
218
+ end
219
+ end
126
220
  end