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