easy-jsonapi 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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