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,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
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+ module Sinja
3
+ module Helpers
4
+ module Nested
5
+ def relationship_link?
6
+ !params[:r].nil?
7
+ end
8
+ end
9
+ end
10
+ end
@@ -5,17 +5,26 @@ module Sinja
5
5
  module Helpers
6
6
  module Relationships
7
7
  def dispatch_relationship_request(id, path, **opts)
8
- fake_env = env.merge 'PATH_INFO'=>"/#{id}/relationships/#{path}"
9
- fake_env['REQUEST_METHOD'] = opts[:method].to_s.tap(&:upcase!) if opts[:method]
10
- fake_env['rack.input'] = StringIO.new(JSON.fast_generate(opts[:body])) if opts.key?(:body)
11
- call(fake_env) # TODO: we may need to bypass postprocessing here
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
- response = dispatch_relationship_request(id, path, opts.merge(:body=>body))
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(*response) unless (200...300).cover?(response[0])
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
@@ -7,11 +7,14 @@ module Sinja
7
7
  include ::Sequel::Inflections
8
8
 
9
9
  def self.config(c)
10
- c.conflict_exceptions = [::Sequel::ConstraintViolation]
11
- #c.not_found_exceptions = [::Sequel::RecordNotFound]
12
- #c.validation_exceptions = [::Sequel::ValidationVailed], proc do
13
- # format exception to json:api source.pointer and detail
14
- #end
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 dataset.with_pk!(id) }
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 deserialized_request_body
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
- halt 400, 'Malformed JSON in the request body'
32
+ raise BadRequestError, 'Malformed JSON in the request body'
32
33
  end
33
34
 
34
- def serialized_response_body
35
+ def serialize_response_body
35
36
  JSON.send settings._sinja.json_generator, response.body
36
37
  rescue JSON::GeneratorError
37
- halt 400, 'Unserializable entities in the response body'
38
+ raise BadRequestError, 'Unserializable entities in the response body'
38
39
  end
39
40
 
40
- def exclude!(options)
41
- included, excluded = options.delete(:include), options.delete(:exclude)
41
+ def include_exclude!(options)
42
+ client, default, excluded =
43
+ params[:include],
44
+ options.delete(:include) || [],
45
+ options.delete(:exclude) || []
42
46
 
43
- included = Set.new(included.is_a?(Array) ? included : included.split(','))
44
- excluded = Set.new(excluded.is_a?(Array) ? excluded : excluded.split(','))
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
- included.delete_if do |termstr|
47
- terms = termstr.split('.')
48
- terms.length.times.any? do |i|
49
- excluded.include?(terms.take(i.succ).join('.'))
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
- options[:include] = included.to_a unless included.empty?
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) && model.is_a?(::Sequel::Model)
59
- # TODO: This should allow a default include, take the param value if
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
- # TODO: This should allow a default include, take the param value if
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 = settings._sinja.serializer_opts.merge(options)
105
- linkage.tap do |c|
106
- c[:meta] = options[:meta] if options.key?(:meta)
107
- c[:jsonapi] = options[:jsonapi] if options.key?(:jsonapi)
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 normalized_error
120
- return body if body.is_a?(Hash)
121
-
122
- if not_found? && detail = [*body].first
123
- title = 'Not Found'
124
- detail = nil if detail == '<h1>Not Found</h1>'
125
- elsif env.key?('sinatra.error')
126
- title = 'Unknown Error'
127
- detail = env['sinatra.error'].message
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 error_hash(title: nil, detail: nil, source: nil)
135
- { id: SecureRandom.uuid }.tap do |hash|
136
- hash[:title] = title if title
137
- hash[:detail] = detail if detail
138
- hash[:status] = status.to_s if status
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 serialized_error
144
- hash = error_hash(normalized_error)
145
- logger.error(settings._sinja.logger_progname) { hash }
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([hash])
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