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