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.
- checksums.yaml +7 -0
- data/.github/workflows/publish-gem.yml +60 -0
- data/.github/workflows/rake.yml +35 -0
- data/.rspec +3 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +106 -0
- data/LICENSE.txt +21 -0
- data/README.md +209 -0
- data/Rakefile +20 -0
- data/UsingTheRequestObject.md +74 -0
- data/UsingUserConfigurations.md +95 -0
- data/bin/bundle +114 -0
- data/bin/console +15 -0
- data/bin/htmldiff +29 -0
- data/bin/kramdown +29 -0
- data/bin/ldiff +29 -0
- data/bin/license_finder +29 -0
- data/bin/license_finder_pip.py +29 -0
- data/bin/maruku +29 -0
- data/bin/marutex +29 -0
- data/bin/nokogiri +29 -0
- data/bin/racc +29 -0
- data/bin/rackup +29 -0
- data/bin/rake +29 -0
- data/bin/redcarpet +29 -0
- data/bin/reverse_markdown +29 -0
- data/bin/rspec +29 -0
- data/bin/rubocop +29 -0
- data/bin/ruby-parse +29 -0
- data/bin/ruby-rewrite +29 -0
- data/bin/setup +8 -0
- data/bin/solargraph +29 -0
- data/bin/thor +29 -0
- data/bin/tilt +29 -0
- data/bin/yard +29 -0
- data/bin/yardoc +29 -0
- data/bin/yri +29 -0
- data/easy-jsonapi.gemspec +39 -0
- data/lib/easy/jsonapi.rb +12 -0
- data/lib/easy/jsonapi/collection.rb +144 -0
- data/lib/easy/jsonapi/config_manager.rb +144 -0
- data/lib/easy/jsonapi/config_manager/config.rb +49 -0
- data/lib/easy/jsonapi/document.rb +71 -0
- data/lib/easy/jsonapi/document/error.rb +48 -0
- data/lib/easy/jsonapi/document/error/error_member.rb +15 -0
- data/lib/easy/jsonapi/document/jsonapi.rb +26 -0
- data/lib/easy/jsonapi/document/jsonapi/jsonapi_member.rb +15 -0
- data/lib/easy/jsonapi/document/links.rb +36 -0
- data/lib/easy/jsonapi/document/links/link.rb +15 -0
- data/lib/easy/jsonapi/document/meta.rb +26 -0
- data/lib/easy/jsonapi/document/meta/meta_member.rb +14 -0
- data/lib/easy/jsonapi/document/resource.rb +56 -0
- data/lib/easy/jsonapi/document/resource/attributes.rb +37 -0
- data/lib/easy/jsonapi/document/resource/attributes/attribute.rb +29 -0
- data/lib/easy/jsonapi/document/resource/relationships.rb +40 -0
- data/lib/easy/jsonapi/document/resource/relationships/relationship.rb +50 -0
- data/lib/easy/jsonapi/document/resource_id.rb +28 -0
- data/lib/easy/jsonapi/exceptions.rb +27 -0
- data/lib/easy/jsonapi/exceptions/document_exceptions.rb +619 -0
- data/lib/easy/jsonapi/exceptions/headers_exceptions.rb +156 -0
- data/lib/easy/jsonapi/exceptions/naming_exceptions.rb +36 -0
- data/lib/easy/jsonapi/exceptions/query_params_exceptions.rb +67 -0
- data/lib/easy/jsonapi/exceptions/user_defined_exceptions.rb +253 -0
- data/lib/easy/jsonapi/field.rb +43 -0
- data/lib/easy/jsonapi/header_collection.rb +38 -0
- data/lib/easy/jsonapi/header_collection/header.rb +11 -0
- data/lib/easy/jsonapi/item.rb +88 -0
- data/lib/easy/jsonapi/middleware.rb +158 -0
- data/lib/easy/jsonapi/name_value_pair.rb +72 -0
- data/lib/easy/jsonapi/name_value_pair_collection.rb +78 -0
- data/lib/easy/jsonapi/parser.rb +38 -0
- data/lib/easy/jsonapi/parser/document_parser.rb +196 -0
- data/lib/easy/jsonapi/parser/headers_parser.rb +33 -0
- data/lib/easy/jsonapi/parser/rack_req_params_parser.rb +117 -0
- data/lib/easy/jsonapi/request.rb +40 -0
- data/lib/easy/jsonapi/request/query_param_collection.rb +56 -0
- data/lib/easy/jsonapi/request/query_param_collection/fields_param.rb +32 -0
- data/lib/easy/jsonapi/request/query_param_collection/fields_param/fieldset.rb +34 -0
- data/lib/easy/jsonapi/request/query_param_collection/filter_param.rb +28 -0
- data/lib/easy/jsonapi/request/query_param_collection/filter_param/filter.rb +34 -0
- data/lib/easy/jsonapi/request/query_param_collection/include_param.rb +119 -0
- data/lib/easy/jsonapi/request/query_param_collection/page_param.rb +55 -0
- data/lib/easy/jsonapi/request/query_param_collection/query_param.rb +47 -0
- data/lib/easy/jsonapi/request/query_param_collection/sort_param.rb +25 -0
- data/lib/easy/jsonapi/response.rb +22 -0
- data/lib/easy/jsonapi/utility.rb +158 -0
- data/lib/easy/jsonapi/version.rb +8 -0
- 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
|