jsonapionify 0.9.0 → 0.9.1

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.
Files changed (84) hide show
  1. checksums.yaml +13 -5
  2. data/.rubocop.yml +1 -0
  3. data/.ruby-version +1 -1
  4. data/.travis.yml +8 -0
  5. data/README.md +85 -3
  6. data/Rakefile +14 -0
  7. data/jsonapionify.gemspec +3 -0
  8. data/lib/jsonapionify/api/action.rb +84 -121
  9. data/lib/jsonapionify/api/attribute.rb +97 -20
  10. data/lib/jsonapionify/api/base/class_methods.rb +5 -4
  11. data/lib/jsonapionify/api/base/delegation.rb +20 -4
  12. data/lib/jsonapionify/api/base/doc_helper.rb +3 -3
  13. data/lib/jsonapionify/api/base/reloader.rb +1 -1
  14. data/lib/jsonapionify/api/base/resource_definitions.rb +28 -15
  15. data/lib/jsonapionify/api/base.rb +6 -0
  16. data/lib/jsonapionify/api/context.rb +18 -5
  17. data/lib/jsonapionify/api/context_delegate.rb +24 -7
  18. data/lib/jsonapionify/api/errors.rb +2 -0
  19. data/lib/jsonapionify/api/errors_object.rb +6 -5
  20. data/lib/jsonapionify/api/relationship/blocks.rb +1 -1
  21. data/lib/jsonapionify/api/relationship/many.rb +35 -11
  22. data/lib/jsonapionify/api/relationship/one.rb +17 -7
  23. data/lib/jsonapionify/api/relationship.rb +20 -6
  24. data/lib/jsonapionify/api/resource/builders.rb +81 -30
  25. data/lib/jsonapionify/api/resource/caching.rb +28 -0
  26. data/lib/jsonapionify/api/resource/caller.rb +61 -0
  27. data/lib/jsonapionify/api/resource/class_methods.rb +6 -2
  28. data/lib/jsonapionify/api/resource/defaults/actions.rb +47 -0
  29. data/lib/jsonapionify/api/resource/defaults/errors.rb +61 -15
  30. data/lib/jsonapionify/api/resource/defaults/hooks.rb +68 -0
  31. data/lib/jsonapionify/api/resource/defaults/options.rb +16 -28
  32. data/lib/jsonapionify/api/resource/defaults/params.rb +3 -0
  33. data/lib/jsonapionify/api/resource/defaults/request_contexts.rb +80 -32
  34. data/lib/jsonapionify/api/resource/defaults/response_contexts.rb +13 -6
  35. data/lib/jsonapionify/api/resource/defaults.rb +1 -1
  36. data/lib/jsonapionify/api/resource/definitions/actions.rb +81 -55
  37. data/lib/jsonapionify/api/resource/definitions/attributes.rb +46 -10
  38. data/lib/jsonapionify/api/resource/definitions/contexts.rb +6 -2
  39. data/lib/jsonapionify/api/resource/definitions/helpers.rb +1 -1
  40. data/lib/jsonapionify/api/resource/definitions/pagination.rb +47 -56
  41. data/lib/jsonapionify/api/resource/definitions/params.rb +11 -15
  42. data/lib/jsonapionify/api/resource/definitions/relationships.rb +43 -7
  43. data/lib/jsonapionify/api/resource/definitions/request_headers.rb +6 -3
  44. data/lib/jsonapionify/api/resource/definitions/response_headers.rb +1 -1
  45. data/lib/jsonapionify/api/resource/definitions/scopes.rb +5 -5
  46. data/lib/jsonapionify/api/resource/definitions/sorting.rb +12 -11
  47. data/lib/jsonapionify/api/resource/definitions.rb +1 -1
  48. data/lib/jsonapionify/api/resource/error_handling.rb +92 -20
  49. data/lib/jsonapionify/api/resource/exec.rb +11 -0
  50. data/lib/jsonapionify/api/resource/includer.rb +89 -1
  51. data/lib/jsonapionify/api/resource.rb +55 -8
  52. data/lib/jsonapionify/api/response.rb +43 -14
  53. data/lib/jsonapionify/api/server/media_type.rb +36 -0
  54. data/lib/jsonapionify/api/server/request.rb +25 -11
  55. data/lib/jsonapionify/api/server.rb +8 -4
  56. data/lib/jsonapionify/api/sort_field.rb +18 -0
  57. data/lib/jsonapionify/api/sort_field_set.rb +1 -1
  58. data/lib/jsonapionify/api/test_helper.rb +46 -0
  59. data/lib/jsonapionify/documentation/template.erb +2 -2
  60. data/lib/jsonapionify/documentation.rb +10 -0
  61. data/lib/jsonapionify/structure/collections/base.rb +10 -3
  62. data/lib/jsonapionify/structure/helpers/object_defaults.rb +5 -10
  63. data/lib/jsonapionify/structure/maps/relationships.rb +4 -0
  64. data/lib/jsonapionify/structure/objects/attributes.rb +4 -0
  65. data/lib/jsonapionify/structure/objects/base.rb +22 -9
  66. data/lib/jsonapionify/structure/objects/error.rb +2 -0
  67. data/lib/jsonapionify/structure/objects/jsonapi.rb +1 -0
  68. data/lib/jsonapionify/structure/objects/link.rb +1 -0
  69. data/lib/jsonapionify/structure/objects/relationship.rb +2 -0
  70. data/lib/jsonapionify/structure/objects/resource.rb +2 -0
  71. data/lib/jsonapionify/structure/objects/resource_identifier.rb +12 -4
  72. data/lib/jsonapionify/structure/objects/top_level.rb +4 -2
  73. data/lib/jsonapionify/types/array_type.rb +16 -11
  74. data/lib/jsonapionify/types/boolean_type.rb +9 -4
  75. data/lib/jsonapionify/types/date_string_type.rb +7 -10
  76. data/lib/jsonapionify/types/float_type.rb +13 -0
  77. data/lib/jsonapionify/types/integer_type.rb +12 -0
  78. data/lib/jsonapionify/types/object_type.rb +7 -2
  79. data/lib/jsonapionify/types/string_type.rb +12 -0
  80. data/lib/jsonapionify/types/time_string_type.rb +8 -10
  81. data/lib/jsonapionify/types.rb +43 -5
  82. data/lib/jsonapionify/version.rb +1 -1
  83. data/lib/jsonapionify.rb +36 -1
  84. metadata +121 -74
@@ -3,10 +3,10 @@ module JSONAPIonify::Api
3
3
 
4
4
  def scope(&block)
5
5
  define_singleton_method(:current_scope) do
6
- Object.new.instance_eval(&block)
6
+ instance_exec(OpenStruct.new, &block)
7
7
  end
8
- context :scope do
9
- self.class.current_scope
8
+ context :scope do |context|
9
+ instance_exec(context, &block)
10
10
  end
11
11
  end
12
12
 
@@ -16,7 +16,7 @@ module JSONAPIonify::Api
16
16
  define_singleton_method(:find_instance) do |id|
17
17
  instance_exec(current_scope, id, OpenStruct.new, &block)
18
18
  end
19
- context :instance do |context|
19
+ context :instance, persisted: true do |context|
20
20
  instance_exec(context.scope, context.id, context, &block)
21
21
  end
22
22
  end
@@ -31,7 +31,7 @@ module JSONAPIonify::Api
31
31
  define_singleton_method(:build_instance) do
32
32
  Object.new.instance_exec(current_scope, &block)
33
33
  end
34
- context :new_instance do |context|
34
+ context :new_instance, persisted: true, readonly: true do |context|
35
35
  Object.new.instance_exec(context.scope, context, &block)
36
36
  end
37
37
  end
@@ -10,28 +10,32 @@ module JSONAPIonify::Api
10
10
  delegate :sort_fields_from_sort_string, to: :class
11
11
 
12
12
  # Define Contexts
13
- context :sorted_collection do |context|
14
- _, block = sorting_strategies.to_a.reverse.to_h.find do |mod, _|
15
- Object.const_defined?(mod, false) && context.collection.class <= Object.const_get(mod, false)
13
+ context :sorted_collection, readonly: true do |context|
14
+ if context.root_request?
15
+ _, block = sorting_strategies.to_a.reverse.to_h.find do |mod, _|
16
+ Object.const_defined?(mod, false) && context.collection.class <= Object.const_get(mod, false)
17
+ end
18
+ context.reset(:sort_params)
19
+ instance_exec(context.collection, context.sort_params, context, &block)
20
+ else
21
+ context.collection
16
22
  end
17
- context.reset(:sort_params)
18
- instance_exec(context.collection, context.sort_params, context, &block)
19
23
  end
20
24
 
21
- context(:sort_params, readonly: true) do |context|
25
+ context(:sort_params, readonly: true, persisted: true) do |context|
22
26
  sort_fields_from_sort_string(context.params['sort']).tap do |fields|
23
27
  should_error = false
24
28
  fields.each do |field|
25
29
  unless self.class.field_valid?(field.name) || field.name == id_attribute
26
30
  should_error = true
27
- type = self.class.type
31
+ type = self.class.type
28
32
  error :sort_parameter_invalid do
29
33
  detail "resource `#{type}` does not have field: #{field.name}"
30
34
  end
31
35
  next
32
36
  end
33
37
  end
34
- raise Errors::RequestError if should_error
38
+ halt if should_error
35
39
  end
36
40
  end
37
41
 
@@ -47,9 +51,6 @@ module JSONAPIonify::Api
47
51
  collection.reorder(fields.to_hash).order(self.class.id_attribute)
48
52
  end
49
53
 
50
- # Configure the default sort
51
- default_sort 'id'
52
-
53
54
  end
54
55
  end
55
56
 
@@ -11,4 +11,4 @@ module JSONAPIonify::Api
11
11
  end
12
12
  end
13
13
  end
14
- end
14
+ end
@@ -1,12 +1,27 @@
1
+ require 'unstrict_proc'
2
+
1
3
  module JSONAPIonify::Api
2
4
  module Resource::ErrorHandling
3
5
  extend ActiveSupport::Concern
6
+ using UnstrictProc
4
7
 
5
8
  included do
6
9
  include ActiveSupport::Rescuable
7
- context(:errors, readonly: true) do
10
+ context(:errors, readonly: true, persisted: true) do
8
11
  ErrorsObject.new
9
12
  end
13
+ register_exception Exception, error: :internal_server_error do |exception|
14
+ if JSONAPIonify.verbose_errors
15
+ detail exception.message
16
+ meta[:error_class] = exception.class.name
17
+ end
18
+ end
19
+ register_exception Errors::RequestError, error: :internal_server_error do |exception|
20
+ if JSONAPIonify.verbose_errors
21
+ detail exception.message
22
+ meta[:error_class] = exception.class.name
23
+ end
24
+ end
10
25
  end
11
26
 
12
27
  module ClassMethods
@@ -15,11 +30,12 @@ module JSONAPIonify::Api
15
30
  self.error_definitions = self.error_definitions.merge name.to_sym => block
16
31
  end
17
32
 
18
- def rescue_from(*klasses, error:, &block)
19
- super(*klasses) do |exception|
33
+ def register_exception(*klasses, error:, &block)
34
+ block ||= proc {}
35
+ rescue_from(*klasses) do |exception, context|
20
36
  errors.evaluate(
21
37
  error_block: lookup_error(error),
22
- runtime_block: block || proc {},
38
+ runtime_block: proc { instance_exec exception, context, &block },
23
39
  backtrace: exception.backtrace
24
40
  )
25
41
  end
@@ -37,6 +53,35 @@ module JSONAPIonify::Api
37
53
  @error_definitions
38
54
  end
39
55
  end
56
+
57
+ def sorted_rescue_handlers
58
+ handlers = self.rescue_handlers.dup
59
+ # logic to find invalid order
60
+ out_of_order_class = lambda do |klasses|
61
+ klass_index = nil
62
+ parent_index = nil
63
+ sort_class = klasses.find do |klass|
64
+ klass_index = klasses.find_index { |k| k == klass }
65
+ parent_index = klasses[0..klass_index].find_index { |k| k < klass }
66
+ end
67
+ sort_class ? [klass_index, parent_index] : nil
68
+ end
69
+
70
+ # Map handler classes
71
+ klasses = handlers.map do |klass_name, _|
72
+ klass = self.class.const_get(klass_name) rescue nil
73
+ klass ||= klass_name.constantize rescue nil
74
+ klass
75
+ end
76
+
77
+ # Loop until things are ordered
78
+ while (result = out_of_order_class[klasses])
79
+ klass_index, parent_index = result
80
+ handler = klasses.delete_at klass_index
81
+ handlers = [*handlers[0..parent_index-1], handler, *handlers[parent_index..-1]]
82
+ end
83
+ handlers.reverse
84
+ end
40
85
  end
41
86
 
42
87
  def error(name, *args, &block)
@@ -49,7 +94,7 @@ module JSONAPIonify::Api
49
94
 
50
95
  def error_now(name, *args, &block)
51
96
  error(name, *args, &block)
52
- raise Errors::RequestError
97
+ halt
53
98
  end
54
99
 
55
100
  def set_errors(collection)
@@ -60,6 +105,12 @@ module JSONAPIonify::Api
60
105
  errors.meta
61
106
  end
62
107
 
108
+ def halt
109
+ error = Errors::RequestError.new
110
+ error.set_backtrace caller
111
+ raise error
112
+ end
113
+
63
114
  private
64
115
 
65
116
  def lookup_error(name)
@@ -68,22 +119,43 @@ module JSONAPIonify::Api
68
119
  end
69
120
  end
70
121
 
71
- def rescued_response(exception)
72
- rescue_with_handler(exception) || begin
73
- run_callbacks(:exception, exception)
74
- errors.evaluate(
75
- error_block: lookup_error(:internal_server_error),
76
- runtime_block: proc {
77
- unless ENV['RACK_ENV'] == 'production'
78
- detail exception.message
79
- meta[:error_class] = exception.class.name
80
- end
81
- },
82
- backtrace: exception.backtrace
83
- )
122
+ def handler_for_rescue(exception)
123
+ _, rescuer = self.class.sorted_rescue_handlers.find do |klass_name, _|
124
+ klass = self.class.const_get(klass_name) rescue nil
125
+ klass ||= klass_name.constantize rescue nil
126
+ exception.is_a?(klass) if klass
127
+ end
128
+
129
+ case rescuer
130
+ when Symbol
131
+ method(rescuer)
132
+ when Proc
133
+ rescuer
134
+ end
135
+ end
136
+
137
+ def invoke_rescue_handler(handler, exception, context, respond_proc)
138
+ status, headers, body =
139
+ instance_exec exception, context, respond_proc, &handler.unstrict
140
+ if status.is_a?(Fixnum) && headers.is_a?(Hash) && body.respond_to?(:each)
141
+ [status, headers, body]
142
+ else
143
+ error_response
144
+ end
145
+ end
146
+
147
+ # Tries to rescue the exception by looking up and calling a registered handler.
148
+ def rescue_with_handler(exception, context, respond_proc)
149
+ if (handler = handler_for_rescue(exception))
150
+ invoke_rescue_handler(handler, exception, context, respond_proc)
84
151
  end
85
- ensure
86
- return error_response
152
+ end
153
+
154
+ def rescued_response(exception, context, respond_proc)
155
+ rescue_with_handler(exception, context, respond_proc)
156
+ rescue Exception => ex
157
+ handler = handler_for_rescue(Exception.new)
158
+ invoke_rescue_handler(handler, ex, context, respond_proc)
87
159
  end
88
160
 
89
161
  def error_response
@@ -0,0 +1,11 @@
1
+ module JSONAPIonify::Api
2
+ module Resource::Exec
3
+ def halt
4
+ # Don't Halt
5
+ end
6
+
7
+ def exec(&block)
8
+ instance_exec @__context, &block
9
+ end
10
+ end
11
+ end
@@ -1,9 +1,97 @@
1
+ require 'concurrent'
2
+
1
3
  module JSONAPIonify::Api
2
4
  module Resource::Includer
3
5
  extend ActiveSupport::Concern
4
6
 
5
7
  included do
6
- param :include
8
+ before :list, :create, :read, :update do |context|
9
+ supports_includes = context.root_request? && context.includes.present?
10
+ is_active_record = defined?(ActiveRecord) && context.scope.respond_to?(:<) && context.scope < ActiveRecord::Base
11
+ if supports_includes && is_active_record
12
+ valid_includes = context.includes.select do |k, v|
13
+ context.scope._reflect_on_association(k)
14
+ end.to_h
15
+ context.scope = context.scope.includes valid_includes
16
+ end
17
+ end
18
+
19
+ after :commit_list do |context|
20
+ if context.includes.present?
21
+ included =
22
+ Concurrent::Array.new(context.response_collection).map do |instance|
23
+ fetch_included(context, owner: instance)
24
+ end.reduce(:+)
25
+ append_included(context, included)
26
+ end
27
+ end
28
+
29
+ after :commit_create, :commit_read, :commit_update do |context|
30
+ if context.includes.present?
31
+ included = fetch_included(
32
+ context, owner: context.instance
33
+ )
34
+ append_included(context, included)
35
+ end
36
+ end
37
+
38
+ context :root_request?, readonly: true do
39
+ true
40
+ end
41
+
42
+ context :includes do |context|
43
+ Resource::Includer.includes_to_hashes context.params['include']
44
+ end
45
+ end
46
+
47
+ def append_included(context, included)
48
+ if included.present?
49
+ context.response_object[:included] = included
50
+ context.response_object[:included].tap(&:uniq!).reject! do |r|
51
+ Array.wrap(context.response_object[:included]).include?(r) ||
52
+ Array.wrap(context.response_object[:data]).include?(r)
53
+ end
54
+ end
55
+ end
56
+
57
+ def fetch_included(context, **overrides)
58
+ collection = JSONAPIonify::Structure::Collections::IncludedResources.new
59
+ context.includes.each_with_object(collection) do |(name, _),|
60
+ res = self.class.relationship(name)
61
+ if res.rel.includable?
62
+ overrides = overrides.merge includes: context.includes[name],
63
+ errors: context.errors,
64
+ root_request?: false
65
+ *, body =
66
+ case res.rel
67
+ when Relationship::One
68
+ res.call_action(:read, context.request, **overrides)
69
+ when Relationship::Many
70
+ res.call_action(:list, context.request, **overrides)
71
+ end
72
+ collection.concat expand_body(body)
73
+ else
74
+ error :relationship_not_includable, res.rel.name
75
+ end
76
+ end.uniq
77
+ end
78
+
79
+ def self.includes_to_hashes(path)
80
+ path.to_s.split(',').each_with_object({}) do |path, obj|
81
+ rel, *sub_path = path.split('.')
82
+ obj[rel] = includes_to_hashes(sub_path.join('.'))
83
+ end
84
+ end
85
+
86
+ def expand_body(body)
87
+ case body
88
+ when Rack::BodyProxy
89
+ json = JSONAPIonify.parse(body.body.join)
90
+ Array.wrap(json[:data]) + (json[:included] || [])
91
+ when Array
92
+ json = JSONAPIonify.parse(body.join)
93
+ Array.wrap(json[:data]) + (json[:included] || [])
94
+ end
7
95
  end
8
96
 
9
97
  end
@@ -15,31 +15,78 @@ module JSONAPIonify::Api
15
15
  include Builders
16
16
  include Defaults
17
17
 
18
+ delegate :type, :attributes, :relationships, to: :class
19
+
18
20
  def self.inherited(subclass)
19
21
  super(subclass)
20
22
  subclass.class_eval do
21
- context(:api, readonly: true) { self.class.api }
22
- context(:resource, readonly: true) { self }
23
+ context(:api, readonly: true, persisted: true) { self.class.api }
24
+ context(:resource, readonly: true, persisted: true) { self }
23
25
  end
24
26
  end
25
27
 
28
+ def self.cache_key(**options)
29
+ api.cache_key(
30
+ **options,
31
+ resource: name
32
+ )
33
+ end
34
+
26
35
  def self.example_id_generator(&block)
27
- define_singleton_method :generate_id, &block
36
+ index = 0
37
+ define_singleton_method(:generate_id) do
38
+ instance_exec index += 1, &block
39
+ end
40
+ context :example_id do
41
+ self.class.generate_id
42
+ end
28
43
  end
29
44
 
30
- def self.example_instance(index=1)
31
- id = generate_id(index)
45
+ def self.example_instance_for_action(action, context)
46
+ id = generate_id
32
47
  OpenStruct.new.tap do |instance|
33
- instance.send "#{id_attribute}=", (id).to_s
34
- attributes.select(&:read?).each do |attribute|
35
- instance.send "#{attribute.name}=", attribute.example(id, index)
48
+ instance.send "#{id_attribute}=", id.to_s
49
+ actionable_attributes = attributes.select do |attr|
50
+ attr.supports_read_for_action?(action, context)
51
+ end
52
+ actionable_attributes.each do |attribute|
53
+ instance.send "#{attribute.name}=", attribute.example(id)
36
54
  end
37
55
  end
38
56
  end
39
57
 
40
58
  example_id_generator { |val| val }
41
59
 
60
+ attr_reader :errors, :action, :response_headers
61
+
62
+ def initialize(
63
+ request:,
64
+ context_definitions: self.class.context_definitions,
65
+ commit: true,
66
+ callbacks: true,
67
+ context_overrides: {},
68
+ cacheable: true,
69
+ action: nil
70
+ )
71
+ context_overrides[:action_name] = action.name if action
72
+ @__context = ContextDelegate.new(
73
+ request,
74
+ self,
75
+ context_definitions,
76
+ context_overrides
77
+ )
78
+ @errors = @__context.errors
79
+ @action = action
80
+ @response_headers = @__context.response_headers
81
+ @callbacks = action ? callbacks : false
82
+ @cache_options = {}
83
+ extend Caller if commit && action
84
+ extend Exec unless action
85
+ extend Caching if cacheable
86
+ end
87
+
42
88
  def action_name
89
+ action&.name
43
90
  end
44
91
 
45
92
  end
@@ -1,12 +1,20 @@
1
1
  module JSONAPIonify::Api
2
2
  class Response
3
- attr_reader :action, :accept, :response_block, :status
3
+ attr_reader :action, :accept, :response_block, :status,
4
+ :matcher, :content_type
4
5
 
5
- def initialize(action, accept: nil, status: nil, &block)
6
+ def initialize(action, accept: 'application/vnd.api+json', content_type: nil, status: nil, match: nil, cacheable: true, &block)
6
7
  @action = action
7
8
  @response_block = block || proc {}
8
- @accept = accept || 'application/vnd.api+json'
9
+ @accept = accept unless match
10
+ @content_type = content_type || (@accept == '*/*' ? nil : @accept)
11
+ @matcher = match || proc {}
9
12
  @status = status || 200
13
+ @cacheable = cacheable
14
+ end
15
+
16
+ def cacheable
17
+ action.cacheable && @cacheable
10
18
  end
11
19
 
12
20
  def ==(other)
@@ -16,12 +24,6 @@ module JSONAPIonify::Api
16
24
  end
17
25
  end
18
26
 
19
- def accept?(request)
20
- request.accept.any? do |accept|
21
- @accept == accept || accept == '*/*' || self.accept == '*/*'
22
- end
23
- end
24
-
25
27
  def documentation_object
26
28
  OpenStruct.new(
27
29
  accept: accept,
@@ -30,18 +32,45 @@ module JSONAPIonify::Api
30
32
  )
31
33
  end
32
34
 
33
- def call(instance, context)
35
+ def call(instance, context, status: nil)
36
+ status ||= self.status
34
37
  response = self
35
38
  instance.instance_eval do
36
39
  body = instance_exec(context, &response.response_block)
37
40
  Rack::Response.new.tap do |rack_response|
38
- rack_response.status = response.status
39
- response_headers.each { |k, v| rack_response.headers[k.split('-').map(&:capitalize).join('-')] = v }
40
- rack_response.headers['Content-Type'] = response.accept unless response.accept == '*/*'
41
- rack_response.write(body) unless body.nil?
41
+ rack_response.status = status
42
+ response_headers.each do |k, v|
43
+ rack_response.headers[k.split('-').map(&:capitalize).join('-')] = v
44
+ end
45
+ rack_response.headers['Content-Type'] =
46
+ case response.content_type
47
+ when nil
48
+ raise(Errors::MissingContentType, 'missing content type')
49
+ when Proc
50
+ response.content_type.call(context)
51
+ else
52
+ response.content_type
53
+ end
54
+ if body.respond_to?(:each)
55
+ rack_response.body = body
56
+ elsif !body.nil?
57
+ rack_response.write(body)
58
+ end
42
59
  end.finish
43
60
  end
44
61
  end
45
62
 
63
+ def accept_with_header?(context)
64
+ context.request.accept.any? do |accept|
65
+ self.accept == accept ||
66
+ (self.accept == '*/*' && !context.request.extension) ||
67
+ (accept == '*/*' && !context.request.extension)
68
+ end
69
+ end
70
+
71
+ def accept_with_matcher?(context)
72
+ !!matcher.call(context)
73
+ end
74
+
46
75
  end
47
76
  end
@@ -0,0 +1,36 @@
1
+ module JSONAPIonify::Api
2
+ class Server::MediaType
3
+ SPLIT_PATTERN = %r{\s*[;,]\s*}
4
+
5
+ class << self
6
+ # The media type (type/subtype) portion of the CONTENT_TYPE header
7
+ # without any media type parameters. e.g., when CONTENT_TYPE is
8
+ # "text/plain;charset=utf-8", the media-type is "text/plain".
9
+ #
10
+ # For more information on the use of media types in HTTP, see:
11
+ # http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.7
12
+ def type(content_type)
13
+ return nil unless content_type
14
+ content_type.split(SPLIT_PATTERN, 2).first.downcase
15
+ end
16
+
17
+ # The media type parameters provided in CONTENT_TYPE as a Hash, or
18
+ # an empty Hash if no CONTENT_TYPE or media-type parameters were
19
+ # provided. e.g., when the CONTENT_TYPE is "text/plain;charset=utf-8",
20
+ # this method responds with the following Hash:
21
+ # { 'charset' => 'utf-8' }
22
+ def params(content_type)
23
+ return {} if content_type.nil?
24
+ Hash[*content_type.split(SPLIT_PATTERN)[1..-1].
25
+ collect { |s| s.split('=', 2) }.
26
+ map { |k, v| [k.downcase, strip_doublequotes(v)] }.flatten]
27
+ end
28
+
29
+ private
30
+
31
+ def strip_doublequotes(str)
32
+ (str[0] == ?" && str[-1] == ?") ? str[1..-2] : str
33
+ end
34
+ end
35
+ end
36
+ end
@@ -1,6 +1,7 @@
1
1
  require 'rack/request'
2
2
  require 'rack/utils'
3
3
  require 'rack/mock'
4
+ require 'mime-types'
4
5
 
5
6
  module JSONAPIonify::Api
6
7
  class Server::Request < Rack::Request
@@ -15,7 +16,7 @@ module JSONAPIonify::Api
15
16
  end.each_with_object({}) do |(name, value), hash|
16
17
  hash[name[5..-1].gsub('_', '-').downcase] = value
17
18
  end
18
- env_headers['content-type'] = content_type
19
+ env_headers['content-type'] = content_type if content_type
19
20
  Rack::Utils::HeaderHash.new(env_headers)
20
21
  end
21
22
 
@@ -44,23 +45,36 @@ module JSONAPIonify::Api
44
45
  body.rewind
45
46
  end
46
47
 
48
+ def extension
49
+ ext = File.extname(path)
50
+ return nil unless ext
51
+ ext = ext[0] == '.' ? ext[1..-1] : ext
52
+ ext.to_s.empty? ? nil : ext
53
+ end
54
+
47
55
  def content_type
48
56
  super.try(:split, ';').try(:first)
49
57
  end
50
58
 
51
- def accept
52
- accepts = (headers['accept'] || '*/*').split(',')
53
- accepts.to_a.sort_by! do |accept|
54
- _, *media_type_params = accept.split(';')
55
- rqf = media_type_params.find { |mtp| mtp.start_with? 'q=' }
56
- -(rqf ? rqf[2..-1].to_f : 1.0)
57
- end.map do |accept|
58
- mime, *media_type_params = accept.split(';')
59
- media_type_params.reject! { |mtp| mtp.start_with? 'q=' }
60
- [mime, *media_type_params].join(';')
59
+ def accept_params
60
+ @accept_params ||= begin
61
+ ext_mime = MIME::Types.type_for(path)[0]&.content_type
62
+ accepts = (headers['accept'] || ext_mime || '*/*').split(',')
63
+ types = [ext_mime].compact | accepts
64
+ types.each_with_object({}) do |type, list|
65
+ list[Server::MediaType.type(type)] = Server::MediaType.params(type)
66
+ end
61
67
  end
62
68
  end
63
69
 
70
+ def jsonapi_params
71
+ accept_params['application/vnd.api+json'] || {}
72
+ end
73
+
74
+ def accept
75
+ @accept ||= accept_params.keys
76
+ end
77
+
64
78
  def authorizations
65
79
  parts = headers['authorization'].to_s.split(' ')
66
80
  parts.length == 2 ? Rack::Utils::HeaderHash.new([parts].to_h) : {}
@@ -23,17 +23,21 @@ module JSONAPIonify::Api
23
23
  @api = api
24
24
  @request = Request.new(env)
25
25
  request.path_info.split('/').tap(&:shift).tap do |parts|
26
+ parts[-1] = File.basename(parts[-1], File.extname(parts[-1])) if parts[-1]
26
27
  @resource, @id, @relationship, @relationship_name, *@more = parts
27
- request.env['jsonapionify.resource_name'] = @resource if @resource
28
- request.env['jsonapionify.resource'] = resource if @resource
29
- request.env['jsonapionify.id'] = @id if @id
30
28
  end
31
29
  end
32
30
 
33
31
  def response
32
+ request.env['jsonapionify.resource_name'] = @resource if @resource
33
+ request.env['jsonapionify.resource'] = resource if @resource
34
+ request.env['jsonapionify.id'] = @id if @id
34
35
  @resource ? resource.process(request) : api_index
35
36
  rescue Errors::ResourceNotFound
36
- api.http_error(:not_found, request)
37
+ resource = @resource
38
+ api.http_error(:not_found, request) do
39
+ detail "Resource not found: #{resource}"
40
+ end
37
41
  end
38
42
 
39
43
  private