easy-jsonapi 1.0.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 (91) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/publish-gem.yml +60 -0
  3. data/.github/workflows/rake.yml +35 -0
  4. data/.rspec +3 -0
  5. data/.ruby-version +1 -0
  6. data/CHANGELOG.md +5 -0
  7. data/CODE_OF_CONDUCT.md +74 -0
  8. data/Gemfile +5 -0
  9. data/Gemfile.lock +106 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +209 -0
  12. data/Rakefile +20 -0
  13. data/UsingTheRequestObject.md +74 -0
  14. data/UsingUserConfigurations.md +95 -0
  15. data/bin/bundle +114 -0
  16. data/bin/console +15 -0
  17. data/bin/htmldiff +29 -0
  18. data/bin/kramdown +29 -0
  19. data/bin/ldiff +29 -0
  20. data/bin/license_finder +29 -0
  21. data/bin/license_finder_pip.py +29 -0
  22. data/bin/maruku +29 -0
  23. data/bin/marutex +29 -0
  24. data/bin/nokogiri +29 -0
  25. data/bin/racc +29 -0
  26. data/bin/rackup +29 -0
  27. data/bin/rake +29 -0
  28. data/bin/redcarpet +29 -0
  29. data/bin/reverse_markdown +29 -0
  30. data/bin/rspec +29 -0
  31. data/bin/rubocop +29 -0
  32. data/bin/ruby-parse +29 -0
  33. data/bin/ruby-rewrite +29 -0
  34. data/bin/setup +8 -0
  35. data/bin/solargraph +29 -0
  36. data/bin/thor +29 -0
  37. data/bin/tilt +29 -0
  38. data/bin/yard +29 -0
  39. data/bin/yardoc +29 -0
  40. data/bin/yri +29 -0
  41. data/easy-jsonapi.gemspec +39 -0
  42. data/lib/easy/jsonapi.rb +12 -0
  43. data/lib/easy/jsonapi/collection.rb +144 -0
  44. data/lib/easy/jsonapi/config_manager.rb +144 -0
  45. data/lib/easy/jsonapi/config_manager/config.rb +49 -0
  46. data/lib/easy/jsonapi/document.rb +71 -0
  47. data/lib/easy/jsonapi/document/error.rb +48 -0
  48. data/lib/easy/jsonapi/document/error/error_member.rb +15 -0
  49. data/lib/easy/jsonapi/document/jsonapi.rb +26 -0
  50. data/lib/easy/jsonapi/document/jsonapi/jsonapi_member.rb +15 -0
  51. data/lib/easy/jsonapi/document/links.rb +36 -0
  52. data/lib/easy/jsonapi/document/links/link.rb +15 -0
  53. data/lib/easy/jsonapi/document/meta.rb +26 -0
  54. data/lib/easy/jsonapi/document/meta/meta_member.rb +14 -0
  55. data/lib/easy/jsonapi/document/resource.rb +56 -0
  56. data/lib/easy/jsonapi/document/resource/attributes.rb +37 -0
  57. data/lib/easy/jsonapi/document/resource/attributes/attribute.rb +29 -0
  58. data/lib/easy/jsonapi/document/resource/relationships.rb +40 -0
  59. data/lib/easy/jsonapi/document/resource/relationships/relationship.rb +50 -0
  60. data/lib/easy/jsonapi/document/resource_id.rb +28 -0
  61. data/lib/easy/jsonapi/exceptions.rb +27 -0
  62. data/lib/easy/jsonapi/exceptions/document_exceptions.rb +619 -0
  63. data/lib/easy/jsonapi/exceptions/headers_exceptions.rb +156 -0
  64. data/lib/easy/jsonapi/exceptions/naming_exceptions.rb +36 -0
  65. data/lib/easy/jsonapi/exceptions/query_params_exceptions.rb +67 -0
  66. data/lib/easy/jsonapi/exceptions/user_defined_exceptions.rb +253 -0
  67. data/lib/easy/jsonapi/field.rb +43 -0
  68. data/lib/easy/jsonapi/header_collection.rb +38 -0
  69. data/lib/easy/jsonapi/header_collection/header.rb +11 -0
  70. data/lib/easy/jsonapi/item.rb +88 -0
  71. data/lib/easy/jsonapi/middleware.rb +158 -0
  72. data/lib/easy/jsonapi/name_value_pair.rb +72 -0
  73. data/lib/easy/jsonapi/name_value_pair_collection.rb +78 -0
  74. data/lib/easy/jsonapi/parser.rb +38 -0
  75. data/lib/easy/jsonapi/parser/document_parser.rb +196 -0
  76. data/lib/easy/jsonapi/parser/headers_parser.rb +33 -0
  77. data/lib/easy/jsonapi/parser/rack_req_params_parser.rb +117 -0
  78. data/lib/easy/jsonapi/request.rb +40 -0
  79. data/lib/easy/jsonapi/request/query_param_collection.rb +56 -0
  80. data/lib/easy/jsonapi/request/query_param_collection/fields_param.rb +32 -0
  81. data/lib/easy/jsonapi/request/query_param_collection/fields_param/fieldset.rb +34 -0
  82. data/lib/easy/jsonapi/request/query_param_collection/filter_param.rb +28 -0
  83. data/lib/easy/jsonapi/request/query_param_collection/filter_param/filter.rb +34 -0
  84. data/lib/easy/jsonapi/request/query_param_collection/include_param.rb +119 -0
  85. data/lib/easy/jsonapi/request/query_param_collection/page_param.rb +55 -0
  86. data/lib/easy/jsonapi/request/query_param_collection/query_param.rb +47 -0
  87. data/lib/easy/jsonapi/request/query_param_collection/sort_param.rb +25 -0
  88. data/lib/easy/jsonapi/response.rb +22 -0
  89. data/lib/easy/jsonapi/utility.rb +158 -0
  90. data/lib/easy/jsonapi/version.rb +8 -0
  91. metadata +248 -0
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'easy/jsonapi/name_value_pair_collection'
4
+ require 'easy/jsonapi/document/resource/relationships/relationship'
5
+ require 'easy/jsonapi/utility'
6
+
7
+ module JSONAPI
8
+ class Document
9
+ class Resource
10
+ # A JSONAPI resource's relationships
11
+ class Relationships < JSONAPI::NameValuePairCollection
12
+
13
+ # @param rels_obj_arr [Array<JSONAPI::Document::Resource::Relationships::Relationship]
14
+ # The collection of relationships to initialize the collection with
15
+ def initialize(rels_obj_arr = [])
16
+ super(rels_obj_arr, item_type: JSONAPI::Document::Resource::Relationships::Relationship)
17
+ end
18
+
19
+ # Add a jsonapi member to the collection
20
+ # @param relationship [JSONAPI::Document::Resource::Relationships::Relationship] The member to add
21
+ def add(relationship)
22
+ super(relationship, &:name)
23
+ end
24
+
25
+ # The jsonapi hash representation of a resource's relationships
26
+ # @return [Hash] A resource's relationships
27
+ def to_h
28
+ to_return = {}
29
+ each do |rel|
30
+ to_return[rel.name.to_sym] = {}
31
+ JSONAPI::Utility.to_h_member(to_return[rel.name.to_sym], rel.links, :links)
32
+ JSONAPI::Utility.to_h_member(to_return[rel.name.to_sym], rel.data, :data)
33
+ JSONAPI::Utility.to_h_member(to_return[rel.name.to_sym], rel.meta, :meta)
34
+ end
35
+ to_return
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'easy/jsonapi/utility'
4
+ require 'easy/jsonapi/document/resource/relationships'
5
+
6
+ module JSONAPI
7
+ class Document
8
+ class Resource
9
+ class Relationships < JSONAPI::NameValuePairCollection
10
+ # The relationships of a resource
11
+ class Relationship
12
+ attr_accessor :links, :data, :meta
13
+ attr_reader :name
14
+
15
+ # @param rels_member_hash [Hash] The hash of relationship members
16
+ def initialize(rels_member_hash)
17
+ unless rels_member_hash.is_a? Hash
18
+ raise 'Must initialize a ' \
19
+ 'JSONAPI::Document::Resource::Relationships::Relationship with a Hash'
20
+ end
21
+ # TODO: Knowing whether a relationship is to-one or to-many can assist in validating
22
+ # compliance and cross checking a document.
23
+ @name = rels_member_hash[:name].to_s
24
+ @links = rels_member_hash[:links]
25
+ @data = rels_member_hash[:data]
26
+ @meta = rels_member_hash[:meta]
27
+ end
28
+
29
+ # @return [String] A JSON parseable representation of a relationship
30
+ def to_s
31
+ "\"#{@name}\": { " \
32
+ "#{JSONAPI::Utility.member_to_s('links', @links, first_member: true)}" \
33
+ "#{JSONAPI::Utility.member_to_s('data', @data)}" \
34
+ "#{JSONAPI::Utility.member_to_s('meta', @meta)}" \
35
+ ' }' \
36
+ end
37
+
38
+ # Hash representation of a relationship
39
+ def to_h
40
+ { @name.to_sym => {
41
+ links: @links.to_h,
42
+ data: @data.to_h,
43
+ meta: @meta.to_h
44
+ } }
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JSONAPI
4
+ class Document
5
+ # A jsonapi resource identifier
6
+ class ResourceId
7
+
8
+ attr_accessor :type, :id
9
+
10
+ # @param type [String | Symbol] The type of the resource identifier
11
+ # @param id [String | Symbol] The id of the resource identifier
12
+ def initialize(type:, id:)
13
+ @type = type.to_s
14
+ @id = id.to_s
15
+ end
16
+
17
+ # Represents ResourceId as a JSON parsable string
18
+ def to_s
19
+ "{ \"type\": \"#{@type}\", \"id\": \"#{@id}\" }"
20
+ end
21
+
22
+ # Represents ResourceID as a jsonapi hash
23
+ def to_h
24
+ { type: @type, id: @id }
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'easy/jsonapi/exceptions/document_exceptions'
4
+ require 'easy/jsonapi/exceptions/headers_exceptions'
5
+ require 'easy/jsonapi/exceptions/naming_exceptions'
6
+ require 'easy/jsonapi/exceptions/query_params_exceptions'
7
+
8
+ module JSONAPI
9
+ # Namespace for the gem's Exceptions
10
+ module Exceptions
11
+ # Validates that the Query Parameters comply with the JSONAPI specification
12
+ module QueryParamsExceptions
13
+ end
14
+
15
+ # Validates that Headers comply with the JSONAPI specification
16
+ module HeadersExceptions
17
+ end
18
+
19
+ # Validates that the request or response document complies with the JSONAPI specification
20
+ module DocumentExceptions
21
+ end
22
+
23
+ # Checking for JSONAPI naming rules compliance
24
+ module NamingExceptions
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,619 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'easy/jsonapi/exceptions/naming_exceptions'
4
+ require 'easy/jsonapi/exceptions/user_defined_exceptions'
5
+
6
+ # TODO: Review document exceptions against jsonapi spec
7
+ # TODO: PATCH -- Updating To One Relationships -- The patch request must contain a top level member
8
+ # named data containing either a ResourceID or null
9
+ # ^ To check, create #relationships_link? and make check based on that
10
+
11
+
12
+ module JSONAPI
13
+ module Exceptions
14
+
15
+ # Validates that the request or response document complies with the JSONAPI specification
16
+ module DocumentExceptions
17
+
18
+ # A jsonapi document MUST contain at least one of the following top-level members
19
+ REQUIRED_TOP_LEVEL_KEYS = %i[data errors meta].freeze
20
+
21
+ # Top level links objects MAY contain the following members
22
+ LINKS_KEYS = %i[self related first prev next last].freeze
23
+
24
+ # Pagination member names in a links object
25
+ PAGINATION_LINKS = %i[first prev next last].freeze
26
+
27
+ # Each member of a links object is a link. A link MUST be represented as either
28
+ LINK_KEYS = %i[href meta].freeze
29
+
30
+ # A resource object MUST contain at least id and type (unless a post resource)
31
+ # In addition, a resource object MAY contain these top-level members.
32
+ RESOURCE_KEYS = %i[type id attributes relationships links meta].freeze
33
+
34
+ # A relationships object MUST contain one of the following:
35
+ RELATIONSHIP_KEYS = %i[data links meta].freeze
36
+
37
+ # A relationship that is to-one or to-many must conatin at least one of the following.
38
+ # A to-many relationship can also contain the addition 'pagination' key.
39
+ TO_ONE_RELATIONSHIP_LINK_KEYS = %i[self related].freeze
40
+
41
+ # Every resource object MUST contain an id member and a type member.
42
+ RESOURCE_IDENTIFIER_KEYS = %i[type id].freeze
43
+
44
+ # A more specific standard error to raise when an exception is found
45
+ class InvalidDocument < StandardError
46
+ attr_accessor :status_code
47
+
48
+ # Init w a status code, so that it can be accessed when rescuing an exception
49
+ def initialize(status_code)
50
+ @status_code = status_code
51
+ super
52
+ end
53
+ end
54
+
55
+ # Checks a request document against the JSON:API spec to see if it complies
56
+ # @param document [String | Hash] The jsonapi document included with the http request
57
+ # @param opts [Hash] Includes path, http_method, sparse_fieldsets
58
+ # @raise InvalidDocument if any part of the spec is not observed
59
+ def self.check_compliance(document, config_manager = nil, opts = {})
60
+ document = Oj.load(document, symbol_keys: true) if document.is_a? String
61
+ ensure!(!document.nil?, 'A document cannot be nil')
62
+
63
+ check_essentials(document, opts[:http_method])
64
+ check_members(document, opts[:http_method], opts[:path], opts[:sparse_fieldsets])
65
+ check_for_matching_types(document, opts[:http_method], opts[:path])
66
+ check_member_names(document)
67
+
68
+ usr_opts = { http_method: opts[:http_method], path: opts[:path] }
69
+ err = JSONAPI::Exceptions::UserDefinedExceptions.check_user_document_requirements(document, config_manager, usr_opts)
70
+ raise err unless err.nil?
71
+
72
+ nil
73
+ end
74
+
75
+ # Make helper methods private
76
+ class << self
77
+
78
+ # Checks the essentials of a jsonapi document. It is
79
+ # used by #check_compliance and JSONAPI::Document's #initialize method
80
+ # @param (see #check_compliance)
81
+ def check_essentials(document, http_method)
82
+ ensure!(document.is_a?(Hash),
83
+ 'A JSON object MUST be at the root of every JSON API request ' \
84
+ 'and response containing data')
85
+ check_top_level(document, http_method)
86
+ end
87
+
88
+ # **********************************
89
+ # * CHECK TOP LEVEL *
90
+ # **********************************
91
+
92
+ # Checks if there are any errors in the top level hash
93
+ # @param (see *check_compliance)
94
+ # @raise (see check_compliance)
95
+ def check_top_level(document, http_method)
96
+ ensure!(!(document.keys & REQUIRED_TOP_LEVEL_KEYS).empty?,
97
+ 'A document MUST contain at least one of the following ' \
98
+ "top-level members: #{REQUIRED_TOP_LEVEL_KEYS}")
99
+
100
+ if document.key? :data
101
+ ensure!(!document.key?(:errors),
102
+ 'The members data and errors MUST NOT coexist in the same document')
103
+ else
104
+ ensure!(!document.key?(:included),
105
+ 'If a document does not contain a top-level data key, the included ' \
106
+ 'member MUST NOT be present either')
107
+ ensure!(http_method.nil?,
108
+ 'The request MUST include a single resource object as primary data, ' \
109
+ 'unless it is a PATCH request clearing a relationship using a relationship link')
110
+ end
111
+ end
112
+
113
+ # **********************************
114
+ # * CHECK TOP LEVEL MEMBERS *
115
+ # **********************************
116
+
117
+ # Checks if any errors exist in the jsonapi document members
118
+ # @param http_method [String] The http verb
119
+ # @param sparse_fieldsets [TrueClass | FalseClass | Nilclass]
120
+ # @raise (see #check_compliance)
121
+ def check_members(document, http_method, path, sparse_fieldsets)
122
+ check_individual_members(document, http_method, path)
123
+ check_full_linkage(document, http_method) unless sparse_fieldsets && http_method.nil?
124
+ end
125
+
126
+ # Checks individual members of the jsonapi document for errors
127
+ # @param (see #check_compliance)
128
+ # @raise (see #check_complaince)
129
+ def check_individual_members(document, http_method, path)
130
+ check_data(document[:data], http_method, path) if document.key? :data
131
+ check_included(document[:included]) if document.key? :included
132
+ check_meta(document[:meta]) if document.key? :meta
133
+ check_errors(document[:errors]) if document.key? :errors
134
+ check_jsonapi(document[:jsonapi]) if document.key? :jsonapi
135
+ check_links(document[:links]) if document.key? :links
136
+ end
137
+
138
+ # -- TOP LEVEL - PRIMARY DATA
139
+
140
+ # @param data [Hash | Array<Hash>] A resource or array or resources
141
+ # @param (see #check_compliance)
142
+ # @param (see #check_compliance)
143
+ # @raise (see #check_compliance)
144
+ def check_data(data, http_method, path)
145
+ ensure!(data.is_a?(Hash) || http_method.nil? || clearing_relationship_link?(data, http_method, path),
146
+ 'The request MUST include a single resource object as primary data, ' \
147
+ 'unless it is a PATCH request clearing a relationship using a relationship link')
148
+ case data
149
+ when Hash
150
+ check_resource(data, http_method)
151
+ when Array
152
+ data.each { |res| check_resource(res, http_method) }
153
+ else
154
+ ensure!(data.nil?,
155
+ 'Primary data must be either nil, an object or an array')
156
+ end
157
+ end
158
+
159
+ # @param resource [Hash] The jsonapi resource object
160
+ # @param (see #check_compliance)
161
+ # @raise (see #check_compliance)
162
+ def check_resource(resource, http_method = nil)
163
+ if http_method == 'POST'
164
+ ensure!(resource[:type],
165
+ 'The resource object (for a post request) MUST contain at least a type member')
166
+ else
167
+ ensure!((resource[:type] && resource[:id]),
168
+ 'Every resource object MUST contain an id member and a type member')
169
+ end
170
+ ensure!(resource[:type].instance_of?(String),
171
+ 'The value of the resource type member MUST be string')
172
+ if resource[:id]
173
+ ensure!(resource[:id].instance_of?(String),
174
+ 'The value of the resource id member MUST be string')
175
+ end
176
+ # Check for sharing a common namespace is in #check_resource_members
177
+ ensure!(JSONAPI::Exceptions::NamingExceptions.check_member_constraints(resource[:type]).nil?,
178
+ 'The values of type members MUST adhere to the same constraints as member names')
179
+
180
+ check_resource_members(resource)
181
+ end
182
+
183
+ # Checks whether the resource members conform to the spec
184
+ # @param (see #check_resource)
185
+ # @raise (see #check_compliance)
186
+ def check_resource_members(resource)
187
+ check_attributes(resource[:attributes]) if resource.key? :attributes
188
+ check_relationships(resource[:relationships]) if resource.key? :relationships
189
+ check_meta(resource[:meta]) if resource.key? :meta
190
+ check_links(resource[:links]) if resource.key? :links
191
+ ensure!(shares_common_namespace?(resource[:attributes], resource[:relationships]),
192
+ 'Fields for a resource object MUST share a common namespace with each ' \
193
+ 'other and with type and id')
194
+ end
195
+
196
+ # @param attributes [Hash] The attributes for resource
197
+ # @raise (see #check_compliance)
198
+ def check_attributes(attributes)
199
+ ensure!(attributes.is_a?(Hash),
200
+ 'The value of the attributes key MUST be an object')
201
+ # Attribute members can contain any json value (verified using OJ JSON parser), but
202
+ # must not contain any attribute or links member -- see #check_full_linkage for this check
203
+ # Member names checked separately.
204
+ end
205
+
206
+ # @param rels [Hash] The relationships obj for resource
207
+ # @raise (see #check_compliance)
208
+ def check_relationships(rels)
209
+ ensure!(rels.is_a?(Hash),
210
+ 'The value of the relationships key MUST be an object')
211
+ rels.each_value { |rel| check_relationship(rel) }
212
+ end
213
+
214
+ # @param rel [Hash] A relationship object
215
+ # @raise (see #check_compliance)
216
+ def check_relationship(rel)
217
+ ensure!(rel.is_a?(Hash), 'Each relationships member MUST be a object')
218
+ ensure!(!(rel.keys & RELATIONSHIP_KEYS).empty?,
219
+ 'A relationship object MUST contain at least one of ' \
220
+ "#{RELATIONSHIP_KEYS}")
221
+
222
+ # If relationship is a To-Many relationship, the links member may also have pagination links
223
+ # that traverse the pagination data
224
+ check_relationship_links(rel[:links]) if rel.key? :links
225
+ check_relationship_data(rel[:data]) if rel.key? :data
226
+ check_meta(rel[:meta]) if rel.key? :meta
227
+ end
228
+
229
+ # Raise if links don't contain at least one of the TO_ONE_RELATIONSHIP_LINK_KEYS
230
+ # @param links [Hash] A resource's relationships' relationship-links
231
+ # @raise (see #check_compliance)
232
+ # TODO: If a pagination links are present, they MUST paginate the relationships not the related resource data
233
+ def check_relationship_links(links)
234
+ ensure!(!(links.keys & TO_ONE_RELATIONSHIP_LINK_KEYS).empty?,
235
+ 'A relationship link MUST contain at least one of '\
236
+ "#{TO_ONE_RELATIONSHIP_LINK_KEYS}")
237
+ check_links(links)
238
+ end
239
+
240
+ # @param data [Hash] A resources relationships relationship data
241
+ # @raise (see #check_compliance)
242
+ def check_relationship_data(data)
243
+ case data
244
+ when Hash
245
+ check_resource_identifier(data)
246
+ when Array
247
+ data.each { |res_id| check_resource_identifier(res_id) }
248
+ when nil
249
+ # Do nothing
250
+ else
251
+ ensure!(false, 'Resource linkage (relationship data) MUST be either nil, an object or an array')
252
+ end
253
+ end
254
+
255
+ # @param res_id [Hash] A resource identifier object
256
+ def check_resource_identifier(res_id)
257
+ ensure!(res_id.is_a?(Hash),
258
+ 'A resource identifier object MUST be an object')
259
+ ensure!((res_id.keys & RESOURCE_IDENTIFIER_KEYS) == RESOURCE_IDENTIFIER_KEYS,
260
+ 'A resource identifier object MUST contain ' \
261
+ "#{RESOURCE_IDENTIFIER_KEYS} members")
262
+ ensure!(res_id[:id].is_a?(String), 'The resource identifier id member must be a string')
263
+ ensure!(res_id[:type].is_a?(String), 'The resource identifier type member must be a string')
264
+ check_meta(res_id[:meta]) if res_id.key? :meta
265
+ end
266
+
267
+ # -- TOP LEVEL - INCLUDED
268
+
269
+ # @param included [Array] The array of included resources
270
+ # @raise (see #check_compliance)
271
+ def check_included(included)
272
+ ensure!(included.is_a?(Array),
273
+ 'The top level included member MUST be represented as an array of resource objects')
274
+
275
+ check_included_resources(included)
276
+ # Full linkage check is in #check_members
277
+ end
278
+
279
+ # Check each included resource for compliance and make sure each type/id pair is unique
280
+ # @param (see #check_included)
281
+ # @raise (see #check_compliance)
282
+ def check_included_resources(included)
283
+ no_duplicate_type_and_id_pairs = true
284
+ set = {}
285
+ included.each do |res|
286
+ check_resource(res)
287
+ unless unique_pair?(set, res)
288
+ no_duplicate_type_and_id_pairs = false
289
+ break
290
+ end
291
+ end
292
+ ensure!(no_duplicate_type_and_id_pairs,
293
+ 'A compound document MUST NOT include more ' \
294
+ 'than one resource object for each type and id pair.')
295
+ end
296
+
297
+ # @param set [Hash] Set of unique pairs so far
298
+ # @param res [Hash] The resource to inspect
299
+ # @return [TrueClass | FalseClass] Whether the resource has a unique
300
+ # type and id pair
301
+ def unique_pair?(set, res)
302
+ pair = "#{res[:type]}|#{res[:id]}"
303
+ if set.key?(pair)
304
+ return false
305
+ end
306
+ set[pair] = true
307
+ true
308
+ end
309
+
310
+ # -- TOP LEVEL - META
311
+
312
+ # @param meta [Hash] The meta object
313
+ # @raise (see check_compliance)
314
+ def check_meta(meta)
315
+ ensure!(meta.is_a?(Hash), 'A meta object MUST be an object')
316
+ # Any members may be specified in a meta obj (all members will be valid json bc string is parsed by oj)
317
+ end
318
+
319
+ # -- TOP LEVEL - LINKS
320
+
321
+ # FIXME:
322
+ # Pagination Links:
323
+ # Only checked for on response
324
+ # Must only be included in links objects
325
+ # Must Paginate member they are inluded in (relationship vs primary resouce vs compound doc)
326
+
327
+ # FIXME:
328
+ # Response Questions:
329
+ #
330
+
331
+ # @param links [Hash] The links object
332
+ # @raise (see check_compliance)
333
+ def check_links(links)
334
+ ensure!(links.is_a?(Hash), 'A links object MUST be an object')
335
+ links.each_value { |link| check_link(link) }
336
+ nil
337
+ end
338
+
339
+ # @param link [String | Hash] A member of the links object
340
+ # @raise (see check_compliance)
341
+ def check_link(link)
342
+ # A link MUST be either a string URL or an object with href / meta
343
+ case link
344
+ when String
345
+ # Do nothing
346
+ when Hash
347
+ ensure!((link.keys - LINK_KEYS).empty?,
348
+ 'If the link is an object, it can contain the members href or meta')
349
+ ensure!(link[:href].nil? || link[:href].instance_of?(String),
350
+ 'The member href MUST be a string')
351
+ ensure!(link[:meta].nil? || link[:meta].instance_of?(Hash),
352
+ 'The value of each meta member MUST be an object')
353
+ else
354
+ ensure!(false,
355
+ 'A link MUST be represented as either a string or an object')
356
+ end
357
+ end
358
+
359
+ # -- TOP LEVEL - JSONAPI
360
+
361
+ # @param jsonapi [Hash] The top level jsonapi object
362
+ # @raise (see check_compliance)
363
+ def check_jsonapi(jsonapi)
364
+ ensure!(jsonapi.is_a?(Hash), 'A JSONAPI object MUST be an object')
365
+ if jsonapi.key?(:version)
366
+ ensure!(jsonapi[:version].is_a?(String),
367
+ "The value of JSONAPI's version member MUST be a string")
368
+ end
369
+ check_meta(jsonapi[:meta]) if jsonapi.key?(:meta)
370
+ end
371
+
372
+ # -- TOP LEVEL - ERRORS
373
+
374
+ # @param errors [Array] The array of errors contained in the jsonapi document
375
+ # @raise (see #check_compliance)
376
+ def check_errors(errors)
377
+ ensure!(errors.is_a?(Array),
378
+ 'Top level errors member MUST be an array')
379
+ errors.each { |error| check_error(error) }
380
+ end
381
+
382
+ # @param error [Hash] The individual error object
383
+ # @raise (see check_compliance)
384
+ def check_error(error)
385
+ ensure!(error.is_a?(Hash),
386
+ 'Error objects MUST be objects')
387
+ check_links(error[:links]) if error.key? :links
388
+ check_links(error[:meta]) if error.key? :meta
389
+ end
390
+
391
+ # -- TOP LEVEL - Check Full Linkage
392
+
393
+ # Checking if document is fully linked
394
+ # @param document [Hash] The jsonapi document
395
+ # @param http_method (see #check_for_matching_types)
396
+ def check_full_linkage(document, http_method)
397
+ return if http_method
398
+
399
+ ensure!(full_linkage?(document),
400
+ 'Compound documents require “full linkage”, meaning that every included resource MUST be ' \
401
+ 'identified by at least one resource identifier object in the same document.')
402
+ end
403
+
404
+ # **********************************
405
+ # * CHECK MEMBER NAMES *
406
+ # **********************************
407
+
408
+ # Checks all the member names in a document recursively and raises an error saying
409
+ # which member did not observe the jsonapi member name rules and which rule
410
+ # @param obj The entire request document or part of the request document.
411
+ # @raise (see #check_compliance)
412
+ def check_member_names(obj)
413
+ case obj
414
+ when Hash
415
+ obj.each do |k, v|
416
+ check_name(k)
417
+ check_member_names(v)
418
+ end
419
+ when Array
420
+ obj.each { |hsh| check_member_names(hsh) }
421
+ end
422
+ nil
423
+ end
424
+
425
+ # @param name The invidual member's name that is being checked
426
+ # @raise (see check_compliance)
427
+ def check_name(name)
428
+ msg = JSONAPI::Exceptions::NamingExceptions.check_member_constraints(name)
429
+ return if msg.nil?
430
+ raise InvalidDocument, "The member named '#{name}' raised: #{msg}"
431
+ end
432
+
433
+ # **********************************
434
+ # * CHECK FOR MATCHING TYPES *
435
+ # **********************************
436
+
437
+ # Raises a 409 error if the endpoint type does not match the data type on a post request
438
+ # @param document (see #check_compliance)
439
+ # @param http_method [String] The request request method
440
+ # @param path [String] The request path
441
+ def check_for_matching_types(document, http_method, path)
442
+ return unless http_method
443
+ return unless path
444
+
445
+ return unless JSONAPI::Utility.all_hash_path?(document, %i[data type])
446
+
447
+ res_type = document[:data][:type]
448
+ case http_method
449
+ when 'POST'
450
+ path_type = path.split('/')[-1]
451
+ check_post_type(path_type, res_type)
452
+ when 'PATCH'
453
+ temp = path.split('/')
454
+ path_type = temp[-2]
455
+ path_id = temp[-1]
456
+ res_id = document.dig(:data, :id)
457
+ check_patch_type(path_type, res_type, path_id, res_id)
458
+ end
459
+ end
460
+
461
+ # Raise 409 unless post resource type == endpoint resource type
462
+ # @param path_type [String] The resource type taken from the request path
463
+ # @param res_type [String] The resource type taken from the request body
464
+ # @raise [JSONAPI::Exceptions::DocumentExceptions::InvalidDocument]
465
+ def check_post_type(path_type, res_type)
466
+ ensure!(path_type.to_s.downcase.gsub(/-/, '_') == res_type.to_s.downcase.gsub(/-/, '_'),
467
+ "When processing a POST request, the resource object's type MUST " \
468
+ 'be amoung the type(s) that constitute the collection represented by the endpoint',
469
+ status_code: 409)
470
+ end
471
+
472
+ # Raise 409 unless path resource type and id == endpoint resource type and id
473
+ # @param path_type [String] The resource type taken from the request path
474
+ # @param res_type [String] The resource type taken from the request body
475
+ # @param path_id [String] The resource id taken from the path
476
+ # @param res_id [String] The resource id taken from the request body
477
+ # @raise [JSONAPI::Exceptions::DocumentExceptions::InvalidDocument]
478
+ def check_patch_type(path_type, res_type, path_id, res_id)
479
+ check =
480
+ path_type.to_s.downcase.gsub(/-/, '_') == res_type.to_s.downcase.gsub(/-/, '_') &&
481
+ path_id.to_s.downcase.gsub(/-/, '_') == res_id.to_s.downcase.gsub(/-/, '_')
482
+ ensure!(check,
483
+ "When processing a PATCH request, the resource object's type and id MUST " \
484
+ "match the server's endpoint",
485
+ status_code: 409)
486
+ end
487
+
488
+ # ********************************
489
+ # * GENERAL HELPER Methods *
490
+ # ********************************
491
+
492
+ # Helper function to raise InvalidDocument errors
493
+ # @param condition The condition to evaluate
494
+ # @param error_message [String] The message to raise InvalidDocument with
495
+ # @raise InvalidDocument
496
+ def ensure!(condition, error_message, status_code: 400)
497
+ raise InvalidDocument.new(status_code), error_message unless condition
498
+ end
499
+
500
+ # Helper Method for #check_top_level ---------------------------------
501
+
502
+ # TODO: Write tests for clearing_relationship_link
503
+ def clearing_relationship_link?(data, http_method, path)
504
+ http_method == 'PATCH' && data == [] && relationship_link?(path)
505
+ end
506
+
507
+ # Does the path length and values indicate that it is a relationsip link
508
+ # @param path [String] The request path
509
+ def relationship_link?(path)
510
+ path_arr = path.split('/')
511
+ path_arr[-2] == 'relationships' && path_arr.length >= 4
512
+ end
513
+
514
+ # Helper Method for #check_resource_members --------------------------
515
+
516
+ # Checks whether a resource's fields share a common namespace
517
+ # @param attributes [Hash] A resource's attributes
518
+ # @param relationships [Hash] A resource's relationships
519
+ def shares_common_namespace?(attributes, relationships)
520
+ true && \
521
+ !contains_type_or_id_member?(attributes) && \
522
+ !contains_type_or_id_member?(relationships) && \
523
+ keys_intersection_empty?(attributes, relationships)
524
+ end
525
+
526
+ # @param hash [Hash] The hash to check
527
+ def contains_type_or_id_member?(hash)
528
+ return false unless hash
529
+ hash.key?(:id) || hash.key?(:type)
530
+ end
531
+
532
+ # Checks to see if two hashes share any key members names
533
+ # @param arr1 [Array<Symbol>] The first hash key array
534
+ # @param arr2 [Array<Symbol>] The second hash key array
535
+ def keys_intersection_empty?(arr1, arr2)
536
+ return true unless arr1 && arr2
537
+ arr1.keys & arr2.keys == []
538
+ end
539
+
540
+ # Helper Methods for Full Linkage -----------------------------------
541
+
542
+ # @param document [Hash] The jsonapi document hash
543
+ def full_linkage?(document)
544
+ return true unless document[:included]
545
+ # ^ Checked earlier to make sure included only exists w data
546
+
547
+ possible_includes = get_possible_includes(document)
548
+ any_additional_includes?(possible_includes, document[:included])
549
+ end
550
+
551
+ # Get a collection of all possible includes
552
+ # Need to check relationships on primary resource(s) and also
553
+ # relationships on the included resource(s)
554
+ # @param (see #full_linkage?)
555
+ # @return [Hash] Collection of possible includes
556
+ def get_possible_includes(document)
557
+ possible_includes = {}
558
+ primary_data = document[:data]
559
+ include_arr = document[:included]
560
+ populate_w_primary_data(possible_includes, primary_data)
561
+ populate_w_include_mem(possible_includes, include_arr)
562
+ possible_includes
563
+ end
564
+
565
+ # @param possible_includes [Hash] The collection of possible includes
566
+ # @param actual_includes [Hash] The included top level object
567
+ def any_additional_includes?(possible_includes, actual_includes)
568
+ actual_includes.each do |res|
569
+ return false unless possible_includes.key? res_id_to_sym(res[:type], res[:id])
570
+ end
571
+ true
572
+ end
573
+
574
+ # @param possible_includes (see #any_additional_includes?)
575
+ # @param primary_data [Hash] The primary data of a document
576
+ def populate_w_primary_data(possible_includes, primary_data)
577
+ if primary_data.is_a? Array
578
+ primary_data.each do |res|
579
+ populate_w_res_rels(possible_includes, res)
580
+ end
581
+ else
582
+ populate_w_res_rels(possible_includes, primary_data)
583
+ end
584
+ end
585
+
586
+ # @param possible_includes (see #any_additional_includes?)
587
+ # @param include_arr [Array<Hash>] The array of includes
588
+ def populate_w_include_mem(possible_includes, include_arr)
589
+ include_arr.each do |res|
590
+ populate_w_res_rels(possible_includes, res)
591
+ end
592
+ end
593
+
594
+ # @param possible_includes (see #any_additional_includes?)
595
+ # @param resource [Hash] The resource to check
596
+ def populate_w_res_rels(possible_includes, resource)
597
+ return unless resource[:relationships]
598
+ resource[:relationships].each_value do |rel|
599
+ res_id = rel[:data]
600
+ next unless res_id
601
+
602
+ if res_id.is_a? Array
603
+ res_id.each { |id| possible_includes[res_id_to_sym(id[:type], id[:id])] = true }
604
+ else
605
+ possible_includes[res_id_to_sym(res_id[:type], res_id[:id])] = true
606
+ end
607
+ end
608
+ end
609
+
610
+ # Creates a hash key using type and id
611
+ # @param type [String] the resource type
612
+ # @param id [String] the resource id
613
+ def res_id_to_sym(type, id)
614
+ "#{type}|#{id}".to_sym
615
+ end
616
+ end
617
+ end
618
+ end
619
+ end