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