sinja 0.2.0.beta2 → 1.0.0.pre1

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