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.
- checksums.yaml +4 -4
- data/.gitignore +2 -1
- data/.travis.yml +12 -2
- data/Gemfile +2 -0
- data/README.md +526 -99
- data/Rakefile +7 -3
- data/demo-app/Gemfile +3 -0
- data/demo-app/app.rb +39 -0
- data/demo-app/base.rb +9 -0
- data/demo-app/boot.rb +4 -0
- data/demo-app/classes/author.rb +88 -0
- data/demo-app/classes/comment.rb +91 -0
- data/demo-app/classes/post.rb +118 -0
- data/demo-app/classes/tag.rb +70 -0
- data/demo-app/database.rb +12 -0
- data/demo-app/test.rb +17 -0
- data/lib/sinja.rb +157 -29
- data/lib/sinja/config.rb +123 -29
- data/lib/sinja/errors.rb +69 -0
- data/lib/sinja/{relationship_routes → extensions}/sequel.rb +1 -1
- data/lib/sinja/helpers/nested.rb +10 -0
- data/lib/sinja/helpers/relationships.rb +16 -7
- data/lib/sinja/helpers/sequel.rb +16 -11
- data/lib/sinja/helpers/serializers.rb +127 -53
- data/lib/sinja/method_override.rb +15 -0
- data/lib/sinja/relationship_routes/has_many.rb +14 -1
- data/lib/sinja/relationship_routes/has_one.rb +14 -1
- data/lib/sinja/resource.rb +40 -59
- data/lib/sinja/resource_routes.rb +46 -26
- data/lib/sinja/version.rb +2 -2
- data/sinja.gemspec +18 -7
- metadata +137 -25
- data/lib/role_list.rb +0 -8
data/demo-app/test.rb
ADDED
data/lib/sinja.rb
CHANGED
@@ -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
|
-
|
14
|
-
|
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
|
-
|
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
|
-
|
23
|
-
|
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
|
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.
|
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
|
-
|
65
|
-
|
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
|
87
|
-
|
88
|
-
|
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 ||=
|
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
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
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
|
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
|
137
|
-
|
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
|
data/lib/sinja/config.rb
CHANGED
@@ -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 '
|
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 : :
|
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
|
-
:
|
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
|
-
@
|
44
|
-
@resource_roles = Hash.new { |h, k| h[k] = @default_roles.dup }
|
49
|
+
@error_logger = ->(eh) { logger.error('sinja') { eh } }
|
45
50
|
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
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
|
-
|
71
|
+
@serializer_opts = deep_copy(DEFAULT_SERIALIZER_OPTS)
|
55
72
|
end
|
56
73
|
|
57
|
-
def
|
58
|
-
|
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
|
85
|
+
@conflict_exceptions.replace(Set[*e])
|
63
86
|
end
|
64
87
|
|
65
|
-
def
|
66
|
-
@
|
67
|
-
|
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
|
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
|
-
|
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
|
-
|
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(
|
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
|