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/lib/sinja/errors.rb
ADDED
@@ -0,0 +1,69 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require 'json'
|
3
|
+
|
4
|
+
module Sinja
|
5
|
+
class SinjaError < StandardError
|
6
|
+
end
|
7
|
+
|
8
|
+
class ActionHelperError < SinjaError
|
9
|
+
end
|
10
|
+
|
11
|
+
class HttpError < SinjaError
|
12
|
+
attr_reader :http_status
|
13
|
+
|
14
|
+
def initialize(http_status, message=nil)
|
15
|
+
@http_status = http_status
|
16
|
+
super(message)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
class SideloadError < HttpError
|
21
|
+
attr_reader :error_hashes
|
22
|
+
|
23
|
+
def initialize(http_status, json)
|
24
|
+
@error_hashes = JSON.parse(json, :symbolize_names=>true).fetch(:errors)
|
25
|
+
super(http_status)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
class BadRequestError < HttpError
|
30
|
+
def initialize(*args) super(400, *args) end
|
31
|
+
end
|
32
|
+
|
33
|
+
class ForbiddenError < HttpError
|
34
|
+
def initialize(*args) super(403, *args) end
|
35
|
+
end
|
36
|
+
|
37
|
+
class NotFoundError < HttpError
|
38
|
+
def initialize(*args) super(404, *args) end
|
39
|
+
end
|
40
|
+
|
41
|
+
class MethodNotAllowedError < HttpError
|
42
|
+
def initialize(*args) super(405, *args) end
|
43
|
+
end
|
44
|
+
|
45
|
+
class NotAcceptibleError < HttpError
|
46
|
+
def initialize(*args) super(406, *args) end
|
47
|
+
end
|
48
|
+
|
49
|
+
class ConflictError < HttpError
|
50
|
+
def initialize(*args) super(409, *args) end
|
51
|
+
end
|
52
|
+
|
53
|
+
class UnsupportedTypeError < HttpError
|
54
|
+
def initialize(*args) super(415, *args) end
|
55
|
+
end
|
56
|
+
|
57
|
+
class UnprocessibleEntityError < HttpError
|
58
|
+
attr_reader :tuples
|
59
|
+
|
60
|
+
def initialize(tuples=[])
|
61
|
+
@tuples = [*tuples]
|
62
|
+
|
63
|
+
fail 'Tuples not properly formatted' \
|
64
|
+
unless @tuples.any? && @tuples.all? { |t| Array === t && t.length == 2 }
|
65
|
+
|
66
|
+
super(422)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -19,7 +19,7 @@ module Sinja
|
|
19
19
|
graft do |rio|
|
20
20
|
klass = resource.class.association_reflection(rel).associated_class
|
21
21
|
resource.send("#{rel}=", klass.with_pk!(rio[:id]))
|
22
|
-
resource.save_changes
|
22
|
+
resource.save_changes(validate: !sideloaded?)
|
23
23
|
end
|
24
24
|
|
25
25
|
instance_eval(&block) if block
|
@@ -5,17 +5,26 @@ module Sinja
|
|
5
5
|
module Helpers
|
6
6
|
module Relationships
|
7
7
|
def dispatch_relationship_request(id, path, **opts)
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
8
|
+
path_info = request.path.dup
|
9
|
+
path_info << "/#{id}" unless request.path.end_with?("/#{id}")
|
10
|
+
path_info << "/relationships/#{path}"
|
11
|
+
|
12
|
+
fakenv = env.merge 'PATH_INFO'=>path_info
|
13
|
+
fakenv['REQUEST_METHOD'] = opts[:method].to_s.tap(&:upcase!) if opts[:method]
|
14
|
+
fakenv['rack.input'] = StringIO.new(JSON.fast_generate(opts[:body])) if opts.key?(:body)
|
15
|
+
fakenv['sinja.passthru'] = opts.fetch(:from, :unknown).to_s
|
16
|
+
fakenv['sinja.resource'] = resource if resource
|
17
|
+
|
18
|
+
call(fakenv)
|
12
19
|
end
|
13
20
|
|
14
|
-
def dispatch_relationship_requests!(id, **opts)
|
21
|
+
def dispatch_relationship_requests!(id, methods: {}, **opts)
|
15
22
|
data.fetch(:relationships, {}).each do |path, body|
|
16
|
-
|
23
|
+
method = methods.fetch(settings._resource_roles[:has_one].key?(path.to_sym) ? :has_one : :has_many, :patch)
|
24
|
+
code, _, *json = dispatch_relationship_request(id, path, opts.merge(:body=>body, :method=>method))
|
17
25
|
# TODO: Gather responses and report all errors instead of only first?
|
18
|
-
halt(
|
26
|
+
# `halt' was called (instead of raise); rethrow it as best as possible
|
27
|
+
raise SideloadError.new(code, json) unless (200...300).cover?(code)
|
19
28
|
end
|
20
29
|
end
|
21
30
|
end
|
data/lib/sinja/helpers/sequel.rb
CHANGED
@@ -7,11 +7,14 @@ module Sinja
|
|
7
7
|
include ::Sequel::Inflections
|
8
8
|
|
9
9
|
def self.config(c)
|
10
|
-
c.conflict_exceptions
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
10
|
+
c.conflict_exceptions << ::Sequel::ConstraintViolation
|
11
|
+
c.not_found_exceptions << ::Sequel::NoMatchingRow
|
12
|
+
c.validation_exceptions << ::Sequel::ValidationFailed
|
13
|
+
c.validation_formatter = ->(e) { e.errors.keys.zip(e.errors.full_messages) }
|
14
|
+
end
|
15
|
+
|
16
|
+
def validate!
|
17
|
+
raise ::Sequel::ValidationFailed, resource unless resource.valid?
|
15
18
|
end
|
16
19
|
|
17
20
|
def database
|
@@ -27,13 +30,13 @@ module Sinja
|
|
27
30
|
end
|
28
31
|
|
29
32
|
# <= association, rios, block
|
30
|
-
def add_missing(*args)
|
31
|
-
add_remove(:add, :-, *args)
|
33
|
+
def add_missing(*args, &block)
|
34
|
+
add_remove(:add, :-, *args, &block)
|
32
35
|
end
|
33
36
|
|
34
37
|
# <= association, rios, block
|
35
|
-
def remove_present(*args)
|
36
|
-
add_remove(:remove, :&, *args)
|
38
|
+
def remove_present(*args, &block)
|
39
|
+
add_remove(:remove, :&, *args, &block)
|
37
40
|
end
|
38
41
|
|
39
42
|
private
|
@@ -43,7 +46,8 @@ module Sinja
|
|
43
46
|
transaction do
|
44
47
|
resource.lock!
|
45
48
|
venn(operator, association, rios) do |subresource|
|
46
|
-
resource.send(meth, subresource)
|
49
|
+
resource.send(meth, subresource) \
|
50
|
+
unless block_given? && !yield(subresource)
|
47
51
|
end
|
48
52
|
resource.reload
|
49
53
|
end
|
@@ -51,10 +55,11 @@ module Sinja
|
|
51
55
|
|
52
56
|
def venn(operator, association, rios)
|
53
57
|
dataset = resource.send("#{association}_dataset")
|
58
|
+
klass = dataset.association_reflection.associated_class
|
54
59
|
# does not / will not work with composite primary keys
|
55
60
|
rios.map { |rio| rio[:id].to_i }
|
56
61
|
.send(operator, dataset.select_map(:id))
|
57
|
-
.each { |id| yield
|
62
|
+
.each { |id| yield klass.with_pk!(id) }
|
58
63
|
end
|
59
64
|
end
|
60
65
|
end
|
@@ -1,4 +1,5 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
|
+
require 'active_support/inflector'
|
2
3
|
require 'json'
|
3
4
|
require 'jsonapi-serializers'
|
4
5
|
require 'set'
|
@@ -15,54 +16,82 @@ module Sinja
|
|
15
16
|
end
|
16
17
|
|
17
18
|
private def _dedasherize_names(hash={})
|
18
|
-
return enum_for(__callee__) unless block_given?
|
19
|
+
return enum_for(__callee__, hash) unless block_given?
|
19
20
|
|
20
21
|
hash.each do |k, v|
|
21
22
|
yield dedasherize(k), Hash === v ? dedasherize_names(v) : v
|
22
23
|
end
|
23
24
|
end
|
24
25
|
|
25
|
-
def
|
26
|
+
def deserialize_request_body
|
26
27
|
return {} unless content?
|
27
28
|
|
28
29
|
request.body.rewind
|
29
30
|
JSON.parse(request.body.read, :symbolize_names=>true)
|
30
31
|
rescue JSON::ParserError
|
31
|
-
|
32
|
+
raise BadRequestError, 'Malformed JSON in the request body'
|
32
33
|
end
|
33
34
|
|
34
|
-
def
|
35
|
+
def serialize_response_body
|
35
36
|
JSON.send settings._sinja.json_generator, response.body
|
36
37
|
rescue JSON::GeneratorError
|
37
|
-
|
38
|
+
raise BadRequestError, 'Unserializable entities in the response body'
|
38
39
|
end
|
39
40
|
|
40
|
-
def
|
41
|
-
|
41
|
+
def include_exclude!(options)
|
42
|
+
client, default, excluded =
|
43
|
+
params[:include],
|
44
|
+
options.delete(:include) || [],
|
45
|
+
options.delete(:exclude) || []
|
42
46
|
|
43
|
-
included =
|
44
|
-
|
47
|
+
included = Array === client ? client : client.split(',')
|
48
|
+
if included.empty?
|
49
|
+
included = Array === default ? default : default.split(',')
|
50
|
+
end
|
51
|
+
|
52
|
+
return included if included.empty?
|
45
53
|
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
54
|
+
excluded = Array === excluded ? excluded : excluded.split(',')
|
55
|
+
unless excluded.empty?
|
56
|
+
excluded = Set.new(excluded)
|
57
|
+
included.delete_if do |termstr|
|
58
|
+
terms = termstr.split('.')
|
59
|
+
terms.length.times.any? do |i|
|
60
|
+
excluded.include?(terms.take(i.succ).join('.'))
|
61
|
+
end
|
50
62
|
end
|
63
|
+
|
64
|
+
return included if included.empty?
|
51
65
|
end
|
52
66
|
|
53
|
-
|
67
|
+
return included unless settings._resource_roles
|
68
|
+
|
69
|
+
# Walk the tree and try to exclude based on fetch and pluck permissions
|
70
|
+
included.keep_if do |termstr|
|
71
|
+
# Start cursor at root of current resource
|
72
|
+
roles = settings._resource_roles
|
73
|
+
|
74
|
+
termstr.split('.').all? do |term|
|
75
|
+
break true unless roles
|
76
|
+
|
77
|
+
rel_roles =
|
78
|
+
roles.dig(:has_many, term.to_sym, :fetch) ||
|
79
|
+
roles.dig(:has_one, term.to_sym, :pluck)
|
80
|
+
|
81
|
+
# Move cursor ahead for next iteration (if necessary), avoiding default proc
|
82
|
+
roles = settings._sinja.resource_roles.fetch(term.pluralize.to_sym, nil)
|
83
|
+
|
84
|
+
rel_roles && (rel_roles.empty? || rel_roles === memoized_role)
|
85
|
+
end
|
86
|
+
end
|
54
87
|
end
|
55
88
|
|
56
89
|
def serialize_model(model=nil, options={})
|
57
90
|
options[:is_collection] = false
|
58
|
-
options[:skip_collection_check] = defined?(::Sequel) &&
|
59
|
-
|
60
|
-
# present, and support disabling passthru.
|
61
|
-
options[:include] ||= params[:include] unless params[:include].empty?
|
91
|
+
options[:skip_collection_check] = defined?(::Sequel) && ::Sequel::Model === model
|
92
|
+
options[:include] = include_exclude!(options)
|
62
93
|
options[:fields] ||= params[:fields] unless params[:fields].empty?
|
63
94
|
|
64
|
-
exclude!(options) if options[:include] && options[:exclude]
|
65
|
-
|
66
95
|
::JSONAPI::Serializer.serialize model,
|
67
96
|
settings._sinja.serializer_opts.merge(options)
|
68
97
|
end
|
@@ -79,13 +108,9 @@ module Sinja
|
|
79
108
|
|
80
109
|
def serialize_models(models=[], options={})
|
81
110
|
options[:is_collection] = true
|
82
|
-
|
83
|
-
# present, and support disabling passthru.
|
84
|
-
options[:include] ||= params[:include] unless params[:include].empty?
|
111
|
+
options[:include] = include_exclude!(options)
|
85
112
|
options[:fields] ||= params[:fields] unless params[:fields].empty?
|
86
113
|
|
87
|
-
exclude!(options) if options[:include] && options[:exclude]
|
88
|
-
|
89
114
|
::JSONAPI::Serializer.serialize [*models],
|
90
115
|
settings._sinja.serializer_opts.merge(options)
|
91
116
|
end
|
@@ -100,11 +125,19 @@ module Sinja
|
|
100
125
|
end
|
101
126
|
end
|
102
127
|
|
103
|
-
def serialize_linkage(options={})
|
104
|
-
options =
|
105
|
-
|
106
|
-
|
107
|
-
|
128
|
+
def serialize_linkage(model, rel_path, options={})
|
129
|
+
options[:is_collection] = false
|
130
|
+
options[:skip_collection_check] = defined?(::Sequel) && ::Sequel::Model === model
|
131
|
+
options[:include] = rel_path.to_s
|
132
|
+
|
133
|
+
content = ::JSONAPI::Serializer.serialize model,
|
134
|
+
settings._sinja.serializer_opts.merge(options)
|
135
|
+
|
136
|
+
# TODO: This is extremely wasteful. Refactor JAS to expose the linkage serializer?
|
137
|
+
content['data']['relationships'][rel_path.to_s].tap do |linkage|
|
138
|
+
%w[meta jsonapi].each do |key|
|
139
|
+
linkage[key] = content[key] if content.key?(key)
|
140
|
+
end
|
108
141
|
end
|
109
142
|
end
|
110
143
|
|
@@ -116,35 +149,76 @@ module Sinja
|
|
116
149
|
body updated ? serialize_linkage(options) : serialize_models?([], options)
|
117
150
|
end
|
118
151
|
|
119
|
-
def
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
elsif detail = [*body].first
|
129
|
-
end
|
130
|
-
|
131
|
-
{ :title=>title, :detail=>detail }
|
152
|
+
def error_hash(title: nil, detail: nil, source: nil)
|
153
|
+
[
|
154
|
+
{ id: SecureRandom.uuid }.tap do |hash|
|
155
|
+
hash[:title] = title if title
|
156
|
+
hash[:detail] = detail if detail
|
157
|
+
hash[:status] = status.to_s if status
|
158
|
+
hash[:source] = source if source
|
159
|
+
end
|
160
|
+
]
|
132
161
|
end
|
133
162
|
|
134
|
-
def
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
hash[:source] = source if source
|
163
|
+
def exception_title(e)
|
164
|
+
if e.respond_to?(:title)
|
165
|
+
e.title
|
166
|
+
else
|
167
|
+
e.class.name.split('::').last.split(/(?=[[:upper:]])/).join(' ')
|
140
168
|
end
|
141
169
|
end
|
142
170
|
|
143
|
-
def
|
144
|
-
|
145
|
-
|
171
|
+
def serialize_errors(&block)
|
172
|
+
raise env['sinatra.error'] if env['sinatra.error'] && sideloaded?
|
173
|
+
|
174
|
+
error_hashes =
|
175
|
+
if [*body].any?
|
176
|
+
if [*body].all? { |error| Hash === error }
|
177
|
+
# `halt' with a hash or array of hashes
|
178
|
+
[*body].flat_map { |error| error_hash(error) }
|
179
|
+
elsif not_found?
|
180
|
+
# `not_found' or `halt 404'
|
181
|
+
message = [*body].first.to_s
|
182
|
+
error_hash \
|
183
|
+
:title=>'Not Found Error',
|
184
|
+
:detail=>(message unless message == '<h1>Not Found</h1>')
|
185
|
+
else
|
186
|
+
# `halt'
|
187
|
+
error_hash \
|
188
|
+
:title=>'Unknown Error',
|
189
|
+
:detail=>[*body].first.to_s
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
# Exception already contains formatted errors
|
194
|
+
error_hashes ||= env['sinatra.error'].error_hashes \
|
195
|
+
if env['sinatra.error'].respond_to?(:error_hashes)
|
196
|
+
|
197
|
+
error_hashes ||=
|
198
|
+
case e = env['sinatra.error']
|
199
|
+
when UnprocessibleEntityError
|
200
|
+
e.tuples.flat_map do |attribute, full_message|
|
201
|
+
error_hash \
|
202
|
+
:title=>exception_title(e),
|
203
|
+
:detail=>full_message.to_s,
|
204
|
+
:source=>{
|
205
|
+
:pointer=>(attribute ? "/data/attributes/#{attribute.to_s.dasherize}" : '/data')
|
206
|
+
}
|
207
|
+
end
|
208
|
+
when Exception
|
209
|
+
error_hash \
|
210
|
+
:title=>exception_title(e),
|
211
|
+
:detail=>(e.message.to_s unless e.message == e.class.name)
|
212
|
+
else
|
213
|
+
error_hash \
|
214
|
+
:title=>'Unknown Error'
|
215
|
+
end
|
216
|
+
|
217
|
+
error_hashes.each { |h| instance_exec(h, &block) } if block
|
218
|
+
|
219
|
+
content_type :api_json
|
146
220
|
JSON.send settings._sinja.json_error_generator,
|
147
|
-
::JSONAPI::Serializer.serialize_errors(
|
221
|
+
::JSONAPI::Serializer.serialize_errors(error_hashes)
|
148
222
|
end
|
149
223
|
end
|
150
224
|
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module Sinja
|
3
|
+
class MethodOverride
|
4
|
+
def initialize(app)
|
5
|
+
@app = app
|
6
|
+
end
|
7
|
+
|
8
|
+
def call(env)
|
9
|
+
env['REQUEST_METHOD'] = env['HTTP_X_HTTP_METHOD_OVERRIDE'] if env.key?('HTTP_X_HTTP_METHOD_OVERRIDE') &&
|
10
|
+
env['REQUEST_METHOD'] == 'POST' && env['HTTP_X_HTTP_METHOD_OVERRIDE'].tap(&:upcase!) == 'PATCH'
|
11
|
+
|
12
|
+
@app.call(env)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -3,11 +3,24 @@ module Sinja
|
|
3
3
|
module RelationshipRoutes
|
4
4
|
module HasMany
|
5
5
|
ACTIONS = %i[fetch clear merge subtract].freeze
|
6
|
-
CONFLICT_ACTIONS = %i[merge].freeze
|
7
6
|
|
8
7
|
def self.registered(app)
|
9
8
|
app.def_action_helpers(ACTIONS, app)
|
10
9
|
|
10
|
+
app.head '' do
|
11
|
+
unless relationship_link?
|
12
|
+
allow :get=>:fetch
|
13
|
+
else
|
14
|
+
allow :get=>:show, :patch=>[:clear, :merge], :post=>:merge, :delete=>:subtract
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
app.get '', :actions=>:show do
|
19
|
+
pass unless relationship_link?
|
20
|
+
|
21
|
+
serialize_linkage
|
22
|
+
end
|
23
|
+
|
11
24
|
app.get '', :actions=>:fetch do
|
12
25
|
serialize_models(*fetch)
|
13
26
|
end
|