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