sanger-jsonapi-resources 0.1.0

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 (39) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +22 -0
  3. data/README.md +53 -0
  4. data/lib/generators/jsonapi/USAGE +13 -0
  5. data/lib/generators/jsonapi/controller_generator.rb +14 -0
  6. data/lib/generators/jsonapi/resource_generator.rb +14 -0
  7. data/lib/generators/jsonapi/templates/jsonapi_controller.rb +4 -0
  8. data/lib/generators/jsonapi/templates/jsonapi_resource.rb +4 -0
  9. data/lib/jsonapi/acts_as_resource_controller.rb +320 -0
  10. data/lib/jsonapi/cached_resource_fragment.rb +127 -0
  11. data/lib/jsonapi/callbacks.rb +51 -0
  12. data/lib/jsonapi/compiled_json.rb +36 -0
  13. data/lib/jsonapi/configuration.rb +258 -0
  14. data/lib/jsonapi/error.rb +47 -0
  15. data/lib/jsonapi/error_codes.rb +60 -0
  16. data/lib/jsonapi/exceptions.rb +563 -0
  17. data/lib/jsonapi/formatter.rb +169 -0
  18. data/lib/jsonapi/include_directives.rb +100 -0
  19. data/lib/jsonapi/link_builder.rb +152 -0
  20. data/lib/jsonapi/mime_types.rb +41 -0
  21. data/lib/jsonapi/naive_cache.rb +30 -0
  22. data/lib/jsonapi/operation.rb +24 -0
  23. data/lib/jsonapi/operation_dispatcher.rb +88 -0
  24. data/lib/jsonapi/operation_result.rb +65 -0
  25. data/lib/jsonapi/operation_results.rb +35 -0
  26. data/lib/jsonapi/paginator.rb +209 -0
  27. data/lib/jsonapi/processor.rb +328 -0
  28. data/lib/jsonapi/relationship.rb +94 -0
  29. data/lib/jsonapi/relationship_builder.rb +167 -0
  30. data/lib/jsonapi/request_parser.rb +678 -0
  31. data/lib/jsonapi/resource.rb +1255 -0
  32. data/lib/jsonapi/resource_controller.rb +5 -0
  33. data/lib/jsonapi/resource_controller_metal.rb +16 -0
  34. data/lib/jsonapi/resource_serializer.rb +531 -0
  35. data/lib/jsonapi/resources/version.rb +5 -0
  36. data/lib/jsonapi/response_document.rb +135 -0
  37. data/lib/jsonapi/routing_ext.rb +262 -0
  38. data/lib/jsonapi-resources.rb +27 -0
  39. metadata +223 -0
@@ -0,0 +1,169 @@
1
+ module JSONAPI
2
+ class Formatter
3
+ class << self
4
+ def format(arg)
5
+ arg.to_s
6
+ end
7
+
8
+ def unformat(arg)
9
+ arg
10
+ end
11
+
12
+ def cached
13
+ return FormatterWrapperCache.new(self)
14
+ end
15
+
16
+ def uncached
17
+ return self
18
+ end
19
+
20
+ def formatter_for(format)
21
+ "#{format.to_s.camelize}Formatter".safe_constantize
22
+ end
23
+ end
24
+ end
25
+
26
+ class KeyFormatter < Formatter
27
+ class << self
28
+ def format(key)
29
+ super
30
+ end
31
+
32
+ def unformat(formatted_key)
33
+ super
34
+ end
35
+ end
36
+ end
37
+
38
+ class RouteFormatter < Formatter
39
+ class << self
40
+ def format(route)
41
+ super
42
+ end
43
+
44
+ def unformat(formatted_route)
45
+ super
46
+ end
47
+ end
48
+ end
49
+
50
+ class ValueFormatter < Formatter
51
+ class << self
52
+ def format(raw_value)
53
+ super(raw_value)
54
+ end
55
+
56
+ def unformat(value)
57
+ super(value)
58
+ end
59
+
60
+ def value_formatter_for(type)
61
+ "#{type.to_s.camelize}ValueFormatter".safe_constantize
62
+ end
63
+ end
64
+ end
65
+
66
+ # Warning: Not thread-safe. Wrap in ThreadLocalVar as needed.
67
+ class FormatterWrapperCache
68
+ attr_reader :formatter_klass
69
+
70
+ def initialize(formatter_klass)
71
+ @formatter_klass = formatter_klass
72
+ @format_cache = NaiveCache.new{|arg| formatter_klass.format(arg) }
73
+ @unformat_cache = NaiveCache.new{|arg| formatter_klass.unformat(arg) }
74
+ end
75
+
76
+ def format(arg)
77
+ @format_cache.get(arg)
78
+ end
79
+
80
+ def unformat(arg)
81
+ @unformat_cache.get(arg)
82
+ end
83
+
84
+ def cached
85
+ self
86
+ end
87
+
88
+ def uncached
89
+ return @formatter_klass
90
+ end
91
+ end
92
+ end
93
+
94
+ class UnderscoredKeyFormatter < JSONAPI::KeyFormatter
95
+ end
96
+
97
+ class CamelizedKeyFormatter < JSONAPI::KeyFormatter
98
+ class << self
99
+ def format(key)
100
+ super.camelize(:lower)
101
+ end
102
+
103
+ def unformat(formatted_key)
104
+ formatted_key.to_s.underscore
105
+ end
106
+ end
107
+ end
108
+
109
+ class DasherizedKeyFormatter < JSONAPI::KeyFormatter
110
+ class << self
111
+ def format(key)
112
+ super.underscore.dasherize
113
+ end
114
+
115
+ def unformat(formatted_key)
116
+ formatted_key.to_s.underscore
117
+ end
118
+ end
119
+ end
120
+
121
+ class DefaultValueFormatter < JSONAPI::ValueFormatter
122
+ class << self
123
+ def format(raw_value)
124
+ case raw_value
125
+ when Date, Time, DateTime, ActiveSupport::TimeWithZone, BigDecimal
126
+ # Use the as_json methods added to various base classes by ActiveSupport
127
+ return raw_value.as_json
128
+ else
129
+ return raw_value
130
+ end
131
+ end
132
+ end
133
+ end
134
+
135
+ class IdValueFormatter < JSONAPI::ValueFormatter
136
+ class << self
137
+ def format(raw_value)
138
+ return if raw_value.nil?
139
+ raw_value.to_s
140
+ end
141
+ end
142
+ end
143
+
144
+ class UnderscoredRouteFormatter < JSONAPI::RouteFormatter
145
+ end
146
+
147
+ class CamelizedRouteFormatter < JSONAPI::RouteFormatter
148
+ class << self
149
+ def format(route)
150
+ super.camelize(:lower)
151
+ end
152
+
153
+ def unformat(formatted_route)
154
+ formatted_route.to_s.underscore
155
+ end
156
+ end
157
+ end
158
+
159
+ class DasherizedRouteFormatter < JSONAPI::RouteFormatter
160
+ class << self
161
+ def format(route)
162
+ super.dasherize
163
+ end
164
+
165
+ def unformat(formatted_route)
166
+ formatted_route.to_s.underscore
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,100 @@
1
+ module JSONAPI
2
+ class IncludeDirectives
3
+ # Construct an IncludeDirectives Hash from an array of dot separated include strings.
4
+ # For example ['posts.comments.tags']
5
+ # will transform into =>
6
+ # {
7
+ # posts:{
8
+ # include:true,
9
+ # include_related:{
10
+ # comments:{
11
+ # include:true,
12
+ # include_related:{
13
+ # tags:{
14
+ # include:true
15
+ # }
16
+ # }
17
+ # }
18
+ # }
19
+ # }
20
+ # }
21
+
22
+ def initialize(resource_klass, includes_array, force_eager_load: false)
23
+ @resource_klass = resource_klass
24
+ @force_eager_load = force_eager_load
25
+ @include_directives_hash = { include_related: {} }
26
+ includes_array.each do |include|
27
+ parse_include(include)
28
+ end
29
+ end
30
+
31
+ def include_directives
32
+ @include_directives_hash
33
+ end
34
+
35
+ def model_includes
36
+ get_includes(@include_directives_hash)
37
+ end
38
+
39
+ def paths
40
+ delve_paths(get_includes(@include_directives_hash, false))
41
+ end
42
+
43
+ private
44
+
45
+ def get_related(current_path)
46
+ current = @include_directives_hash
47
+ current_resource_klass = @resource_klass
48
+ current_path.split('.').each do |fragment|
49
+ fragment = fragment.to_sym
50
+
51
+ if current_resource_klass
52
+ current_relationship = current_resource_klass._relationships[fragment]
53
+ current_resource_klass = current_relationship.try(:resource_klass)
54
+ else
55
+ warn "[RELATIONSHIP NOT FOUND] Relationship could not be found for #{current_path}."
56
+ end
57
+
58
+ include_in_join = @force_eager_load || !current_relationship || current_relationship.eager_load_on_include
59
+
60
+ current[:include_related][fragment] ||= { include: false, include_related: {}, include_in_join: include_in_join }
61
+ current = current[:include_related][fragment]
62
+ end
63
+ current
64
+ end
65
+
66
+ def get_includes(directive, only_joined_includes = true)
67
+ ir = directive[:include_related]
68
+ ir = ir.select { |k,v| v[:include_in_join] } if only_joined_includes
69
+
70
+ ir.map do |name, sub_directive|
71
+ sub = get_includes(sub_directive, only_joined_includes)
72
+ sub.any? ? { name => sub } : name
73
+ end
74
+ end
75
+
76
+ def parse_include(include)
77
+ parts = include.split('.')
78
+ local_path = ''
79
+
80
+ parts.each do |name|
81
+ local_path += local_path.length > 0 ? ".#{name}" : name
82
+ related = get_related(local_path)
83
+ related[:include] = true
84
+ end
85
+ end
86
+
87
+ def delve_paths(obj)
88
+ case obj
89
+ when Array
90
+ obj.map{|elem| delve_paths(elem)}.flatten(1)
91
+ when Hash
92
+ obj.map{|k,v| [[k]] + delve_paths(v).map{|path| [k] + path } }.flatten(1)
93
+ when Symbol, String
94
+ [[obj]]
95
+ else
96
+ raise "delve_paths cannot descend into #{obj.class.name}"
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,152 @@
1
+ module JSONAPI
2
+ class LinkBuilder
3
+ attr_reader :base_url,
4
+ :primary_resource_klass,
5
+ :route_formatter,
6
+ :engine_name
7
+
8
+ def initialize(config = {})
9
+ @base_url = config[:base_url]
10
+ @primary_resource_klass = config[:primary_resource_klass]
11
+ @route_formatter = config[:route_formatter]
12
+ @engine_name = build_engine_name
13
+
14
+ # Warning: These make LinkBuilder non-thread-safe. That's not a problem with the
15
+ # request-specific way it's currently used, though.
16
+ @resources_path_cache = JSONAPI::NaiveCache.new do |source_klass|
17
+ formatted_module_path_from_class(source_klass) + format_route(source_klass._type.to_s)
18
+ end
19
+ end
20
+
21
+ def engine?
22
+ !!@engine_name
23
+ end
24
+
25
+ def primary_resources_url
26
+ if engine?
27
+ engine_primary_resources_url
28
+ else
29
+ regular_primary_resources_url
30
+ end
31
+ end
32
+
33
+ def query_link(query_params)
34
+ "#{ primary_resources_url }?#{ query_params.to_query }"
35
+ end
36
+
37
+ def relationships_related_link(source, relationship, query_params = {})
38
+ url = "#{ self_link(source) }/#{ route_for_relationship(relationship) }"
39
+ url = "#{ url }?#{ query_params.to_query }" if query_params.present?
40
+ url
41
+ end
42
+
43
+ def relationships_self_link(source, relationship)
44
+ "#{ self_link(source) }/relationships/#{ route_for_relationship(relationship) }"
45
+ end
46
+
47
+ def self_link(source)
48
+ if engine?
49
+ engine_resource_url(source)
50
+ else
51
+ regular_resource_url(source)
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def build_engine_name
58
+ scopes = module_scopes_from_class(primary_resource_klass)
59
+
60
+ begin
61
+ unless scopes.empty?
62
+ "#{ scopes.first.to_s.camelize }::Engine".safe_constantize
63
+ end
64
+ rescue LoadError => e
65
+ nil
66
+ end
67
+ end
68
+
69
+ def engine_path_from_resource_class(klass)
70
+ path_name = engine_resources_path_name_from_class(klass)
71
+ engine_name.routes.url_helpers.public_send(path_name)
72
+ end
73
+
74
+ def engine_primary_resources_path
75
+ engine_path_from_resource_class(primary_resource_klass)
76
+ end
77
+
78
+ def engine_primary_resources_url
79
+ "#{ base_url }#{ engine_primary_resources_path }"
80
+ end
81
+
82
+ def engine_resource_path(source)
83
+ resource_path_name = engine_resource_path_name_from_source(source)
84
+ engine_name.routes.url_helpers.public_send(resource_path_name, source.id)
85
+ end
86
+
87
+ def engine_resource_path_name_from_source(source)
88
+ scopes = module_scopes_from_class(source.class)[1..-1]
89
+ base_path_name = scopes.map { |scope| scope.underscore }.join("_")
90
+ end_path_name = source.class._type.to_s.singularize
91
+ [base_path_name, end_path_name, "path"].reject(&:blank?).join("_")
92
+ end
93
+
94
+ def engine_resource_url(source)
95
+ "#{ base_url }#{ engine_resource_path(source) }"
96
+ end
97
+
98
+ def engine_resources_path_name_from_class(klass)
99
+ scopes = module_scopes_from_class(klass)[1..-1]
100
+ base_path_name = scopes.map { |scope| scope.underscore }.join("_")
101
+ end_path_name = klass._type.to_s
102
+
103
+ if base_path_name.blank?
104
+ "#{ end_path_name }_path"
105
+ else
106
+ "#{ base_path_name }_#{ end_path_name }_path"
107
+ end
108
+ end
109
+
110
+ def format_route(route)
111
+ route_formatter.format(route)
112
+ end
113
+
114
+ def formatted_module_path_from_class(klass)
115
+ scopes = module_scopes_from_class(klass)
116
+
117
+ unless scopes.empty?
118
+ "/#{ scopes.map{ |scope| format_route(scope.to_s.underscore) }.join('/') }/"
119
+ else
120
+ "/"
121
+ end
122
+ end
123
+
124
+ def module_scopes_from_class(klass)
125
+ klass.name.to_s.split("::")[0...-1]
126
+ end
127
+
128
+ def regular_resources_path(source_klass)
129
+ @resources_path_cache.get(source_klass)
130
+ end
131
+
132
+ def regular_primary_resources_path
133
+ regular_resources_path(primary_resource_klass)
134
+ end
135
+
136
+ def regular_primary_resources_url
137
+ "#{ base_url }#{ regular_primary_resources_path }"
138
+ end
139
+
140
+ def regular_resource_path(source)
141
+ "#{regular_resources_path(source.class)}/#{source.id}"
142
+ end
143
+
144
+ def regular_resource_url(source)
145
+ "#{ base_url }#{ regular_resource_path(source) }"
146
+ end
147
+
148
+ def route_for_relationship(relationship)
149
+ format_route(relationship.name)
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,41 @@
1
+ require 'json'
2
+
3
+ module JSONAPI
4
+ MEDIA_TYPE = 'application/vnd.api+json'
5
+
6
+ module MimeTypes
7
+ def self.install
8
+ Mime::Type.register JSONAPI::MEDIA_TYPE, :api_json
9
+
10
+ # :nocov:
11
+ if Rails::VERSION::MAJOR >= 5
12
+ parsers = ActionDispatch::Request.parameter_parsers.merge(
13
+ Mime::Type.lookup(JSONAPI::MEDIA_TYPE).symbol => parser
14
+ )
15
+ ActionDispatch::Request.parameter_parsers = parsers
16
+ else
17
+ ActionDispatch::ParamsParser::DEFAULT_PARSERS[Mime::Type.lookup(JSONAPI::MEDIA_TYPE)] = parser
18
+ end
19
+ # :nocov:
20
+ end
21
+
22
+ def self.parser
23
+ lambda do |body|
24
+ begin
25
+ data = JSON.parse(body)
26
+ if data.is_a?(Hash)
27
+ data.with_indifferent_access
28
+ else
29
+ fail JSONAPI::Exceptions::InvalidRequestFormat.new
30
+ end
31
+ rescue JSON::ParserError => e
32
+ { _parser_exception: JSONAPI::Exceptions::BadRequest.new(e.to_s) }
33
+ rescue => e
34
+ { _parser_exception: e }
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+ MimeTypes.install
41
+ end
@@ -0,0 +1,30 @@
1
+ module JSONAPI
2
+
3
+ # Cache which memoizes the given block.
4
+ #
5
+ # It's "naive" because it clears the least-recently-inserted cache entry
6
+ # rather than the least-recently-used. This makes lookups faster but cache
7
+ # misses more frequent after cleanups. Therefore you the best time to use
8
+ # this cache is when you expect only a small number of unique lookup keys, so
9
+ # that the cache never has to clear.
10
+ #
11
+ # Also, it's not thread safe (although jsonapi-resources is careful to only
12
+ # use it in a thread safe way).
13
+ class NaiveCache
14
+ def initialize(cap = 10000, &calculator)
15
+ @cap = cap
16
+ @data = {}
17
+ @calculator = calculator
18
+ end
19
+
20
+ def get(key)
21
+ found = true
22
+ value = @data.fetch(key) { found = false }
23
+ return value if found
24
+ value = @calculator.call(key)
25
+ @data[key] = value
26
+ @data.shift if @data.length > @cap
27
+ return value
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,24 @@
1
+ module JSONAPI
2
+ class Operation
3
+ attr_reader :resource_klass, :operation_type, :options
4
+
5
+ def initialize(operation_type, resource_klass, options)
6
+ @operation_type = operation_type
7
+ @resource_klass = resource_klass
8
+ @options = options
9
+ end
10
+
11
+ def transactional?
12
+ JSONAPI::Processor._processor_from_resource_type(resource_klass).transactional_operation_type?(operation_type)
13
+ end
14
+
15
+ def process
16
+ processor.process
17
+ end
18
+
19
+ private
20
+ def processor
21
+ JSONAPI::Processor.processor_instance_for(resource_klass, operation_type, options)
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,88 @@
1
+ module JSONAPI
2
+ class OperationDispatcher
3
+
4
+ def initialize(transaction: lambda { |&block| block.yield },
5
+ rollback: lambda { },
6
+ server_error_callbacks: [])
7
+
8
+ @transaction = transaction
9
+ @rollback = rollback
10
+ @server_error_callbacks = server_error_callbacks
11
+ end
12
+
13
+ def process(operations)
14
+ results = JSONAPI::OperationResults.new
15
+
16
+ # Use transactions if more than one operation and if one of the operations can be transactional
17
+ # Even if transactional transactions won't be used unless the derived OperationsProcessor supports them.
18
+ transactional = false
19
+
20
+ operations.each do |operation|
21
+ transactional |= operation.transactional?
22
+ end if JSONAPI.configuration.allow_transactions
23
+
24
+ transaction(transactional) do
25
+ # Links and meta data global to the set of operations
26
+ operations_meta = {}
27
+ operations_links = {}
28
+ operations.each do |operation|
29
+ results.add_result(process_operation(operation))
30
+ rollback(transactional) if results.has_errors?
31
+ end
32
+ results.meta = operations_meta
33
+ results.links = operations_links
34
+ end
35
+ results
36
+ end
37
+
38
+ private
39
+
40
+ def transaction(transactional)
41
+ if transactional
42
+ @transaction.call do
43
+ yield
44
+ end
45
+ else
46
+ yield
47
+ end
48
+ end
49
+
50
+ def rollback(transactional)
51
+ if transactional
52
+ @rollback.call
53
+ end
54
+ end
55
+
56
+ def process_operation(operation)
57
+ with_default_handling do
58
+ operation.process
59
+ end
60
+ end
61
+
62
+ def with_default_handling(&block)
63
+ block.yield
64
+ rescue => e
65
+ if JSONAPI.configuration.exception_class_whitelisted?(e)
66
+ raise e
67
+ else
68
+ @server_error_callbacks.each { |callback|
69
+ safe_run_callback(callback, e)
70
+ }
71
+
72
+ internal_server_error = JSONAPI::Exceptions::InternalServerError.new(e)
73
+ Rails.logger.error { "Internal Server Error: #{e.message} #{e.backtrace.join("\n")}" }
74
+ return JSONAPI::ErrorsOperationResult.new(internal_server_error.errors[0].code, internal_server_error.errors)
75
+ end
76
+ end
77
+
78
+ def safe_run_callback(callback, error)
79
+ begin
80
+ callback.call(error)
81
+ rescue => e
82
+ Rails.logger.error { "Error in error handling callback: #{e.message} #{e.backtrace.join("\n")}" }
83
+ internal_server_error = JSONAPI::Exceptions::InternalServerError.new(e)
84
+ return JSONAPI::ErrorsOperationResult.new(internal_server_error.errors[0].code, internal_server_error.errors)
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,65 @@
1
+ module JSONAPI
2
+ class OperationResult
3
+ attr_accessor :code
4
+ attr_accessor :meta
5
+ attr_accessor :links
6
+ attr_accessor :options
7
+
8
+ def initialize(code, options = {})
9
+ @code = code
10
+ @options = options
11
+ @meta = options.fetch(:meta, {})
12
+ @links = options.fetch(:links, {})
13
+ end
14
+ end
15
+
16
+ class ErrorsOperationResult < OperationResult
17
+ attr_accessor :errors
18
+
19
+ def initialize(code, errors, options = {})
20
+ @errors = errors
21
+ super(code, options)
22
+ end
23
+ end
24
+
25
+ class ResourceOperationResult < OperationResult
26
+ attr_accessor :resource
27
+
28
+ def initialize(code, resource, options = {})
29
+ @resource = resource
30
+ super(code, options)
31
+ end
32
+ end
33
+
34
+ class ResourcesOperationResult < OperationResult
35
+ attr_accessor :resources, :pagination_params, :record_count, :page_count
36
+
37
+ def initialize(code, resources, options = {})
38
+ @resources = resources
39
+ @pagination_params = options.fetch(:pagination_params, {})
40
+ @record_count = options[:record_count]
41
+ @page_count = options[:page_count]
42
+ super(code, options)
43
+ end
44
+ end
45
+
46
+ class RelatedResourcesOperationResult < ResourcesOperationResult
47
+ attr_accessor :source_resource, :_type
48
+
49
+ def initialize(code, source_resource, type, resources, options = {})
50
+ @source_resource = source_resource
51
+ @_type = type
52
+ super(code, resources, options)
53
+ end
54
+ end
55
+
56
+ class LinksObjectOperationResult < OperationResult
57
+ attr_accessor :parent_resource, :relationship
58
+
59
+ def initialize(code, parent_resource, relationship, options = {})
60
+ @parent_resource = parent_resource
61
+ @relationship = relationship
62
+ super(code, options)
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,35 @@
1
+ module JSONAPI
2
+ class OperationResults
3
+ attr_accessor :results
4
+ attr_accessor :meta
5
+ attr_accessor :links
6
+
7
+ def initialize
8
+ @results = []
9
+ @has_errors = false
10
+ @meta = {}
11
+ @links = {}
12
+ end
13
+
14
+ def add_result(result)
15
+ @has_errors = true if result.is_a?(JSONAPI::ErrorsOperationResult)
16
+ @results.push(result)
17
+ end
18
+
19
+ def has_errors?
20
+ @has_errors
21
+ end
22
+
23
+ def all_errors
24
+ errors = []
25
+ if @has_errors
26
+ @results.each do |result|
27
+ if result.is_a?(JSONAPI::ErrorsOperationResult)
28
+ errors.concat(result.errors)
29
+ end
30
+ end
31
+ end
32
+ errors
33
+ end
34
+ end
35
+ end