Skip to content

Commit c0ccac8

Browse files
committed
Initial commit for a possible replacement of the parser.
1 parent af6a7f5 commit c0ccac8

File tree

7 files changed

+519
-0
lines changed

7 files changed

+519
-0
lines changed

validator/README.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# jsonapi-validator
2+
Ruby gem for validating [JSON API](http://jsonapi.org) documents.
3+
4+
## Installation
5+
```ruby
6+
# In Gemfile
7+
gem 'jsonapi-validator'
8+
```
9+
then
10+
```
11+
$ bundle
12+
```
13+
or manually via
14+
```
15+
$ gem install jsonapi-validator
16+
```
17+
18+
## Usage
19+
20+
First, require the gem:
21+
```ruby
22+
require 'jsonapi/validator'
23+
```
24+
Then simply validate a document:
25+
```ruby
26+
# This will raise JSONAPI::Validator::InvalidDocument if an error is found.
27+
JSONAPI.validate_document!(document_hash)
28+
```
29+
or a resource create/update payload:
30+
```ruby
31+
JSONAPI.validate_resource!(document_hash)
32+
# Optionally, you can provide some resource-related constraints:
33+
params = {
34+
permitted: {
35+
id: true,
36+
attributes: [:title, :date],
37+
relationships: [:comments, :author]
38+
},
39+
required: {
40+
id: true,
41+
attributes: [:title],
42+
relationships: [:comments, :author]
43+
},
44+
types: {
45+
primary: [:posts],
46+
relationships: {
47+
comments: {
48+
kind: :has_many,
49+
types: [:comments]
50+
},
51+
author: {
52+
kind: :has_one,
53+
types: [:users, :superusers]
54+
}
55+
}
56+
}
57+
}
58+
JSONAPI.parse_resource!(document_hash, params)
59+
```
60+
or a relationship update payload:
61+
```ruby
62+
JSONAPI.parse_relationship!(document_hash)
63+
# Optionally, specify type information for the relationship:
64+
JSONAPI.parse_relationship!(document_hash, kind: :has_many, types: [:comments])
65+
```
66+
67+
## License
68+
69+
jsonapi-validator is released under the [MIT License](http://www.opensource.org/licenses/MIT).

validator/lib/jsonapi/validator.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
require 'jsonapi/validator/document'
2+
require 'jsonapi/validator/relationship'
3+
require 'jsonapi/validator/resource'
4+
5+
module JSONAPI
6+
module_function
7+
8+
# @see JSONAPI::Validator::Document.validate!
9+
def validate!(document)
10+
Validator::Document.validate!(document)
11+
end
12+
13+
# @see JSONAPI::Validator::Resource.validate!
14+
def validate_resource!(document, params = {})
15+
Validator::Resource.validate!(document, params)
16+
end
17+
18+
# @see JSONAPI::Validator::Relationship.validate!
19+
def validate_relationship!(document, params = {})
20+
Validator::Relationship.validate!(document, params)
21+
end
22+
end
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
require 'jsonapi/validator/exceptions'
2+
3+
module JSONAPI
4+
module Validator
5+
class Document
6+
TOP_LEVEL_KEYS = %w(data errors meta).freeze
7+
EXTENDED_TOP_LEVEL_KEYS = (TOP_LEVEL_KEYS +
8+
%w(jsonapi links included)).freeze
9+
RESOURCE_KEYS = %w(id type attributes relationships links meta).freeze
10+
RESOURCE_IDENTIFIER_KEYS = %w(id type).freeze
11+
EXTENDED_RESOURCE_IDENTIFIER_KEYS = (RESOURCE_IDENTIFIER_KEYS +
12+
%w(meta)).freeze
13+
RELATIONSHIP_KEYS = %w(data links meta).freeze
14+
RELATIONSHIP_LINK_KEYS = %w(self related).freeze
15+
JSONAPI_OBJECT_KEYS = %w(version meta).freeze
16+
17+
# Validate the structure of a JSON API document.
18+
#
19+
# @param [Hash] document The input JSONAPI document.
20+
# @raise [JSONAPI::Validator::InvalidDocument] if document is invalid.
21+
def self.validate!(document)
22+
ensure!(document.is_a?(Hash),
23+
'A JSON object MUST be at the root of every JSON API request ' \
24+
'and response containing data.')
25+
unexpected_keys = document.keys - EXTENDED_TOP_LEVEL_KEYS
26+
ensure!(unexpected_keys.empty?,
27+
"Unexpected members at top level: #{unexpected_keys}.")
28+
ensure!(!(document.keys & TOP_LEVEL_KEYS).empty?,
29+
"A document MUST contain at least one of #{TOP_LEVEL_KEYS}.")
30+
ensure!(!(document.key?('data') && document.key?('errors')),
31+
'The members data and errors MUST NOT coexist in the same ' \
32+
'document.')
33+
ensure!(document.key?('data') || !document.key?('included'),
34+
'If a document does not contain a top-level data key, the ' \
35+
'included member MUST NOT be present either.')
36+
validate_data!(document['data']) if document.key?('data')
37+
validate_errors!(document['errors']) if document.key?('errors')
38+
validate_meta!(document['meta']) if document.key?('meta')
39+
validate_jsonapi!(document['jsonapi']) if document.key?('jsonapi')
40+
validate_included!(document['included']) if document.key?('included')
41+
validate_links!(document['links']) if document.key?('links')
42+
end
43+
44+
# @api private
45+
def self.validate_data!(data)
46+
if data.is_a?(Hash)
47+
validate_primary_resource!(data)
48+
elsif data.is_a?(Array)
49+
data.each { |res| validate_resource!(res) }
50+
elsif data.nil?
51+
# Do nothing
52+
else
53+
ensure!(false,
54+
'Primary data must be either nil, an object or an array.')
55+
end
56+
end
57+
58+
# @api private
59+
def self.validate_primary_resource!(res)
60+
ensure!(res.is_a?(Hash), 'A resource object must be an object.')
61+
ensure!(res.key?('type'), 'A resource object must have a type.')
62+
unexpected_keys = res.keys - RESOURCE_KEYS
63+
ensure!(unexpected_keys.empty?,
64+
"Unexpected members for primary resource: #{unexpected_keys}")
65+
validate_attributes!(res['attributes']) if res.key?('attributes')
66+
validate_relationships!(res['relationships']) if res.key?('relationships')
67+
validate_links!(res['links']) if res.key?('links')
68+
validate_meta!(res['meta']) if res.key?('meta')
69+
end
70+
71+
# @api private
72+
def self.validate_resource!(res)
73+
validate_primary_resource!(res)
74+
ensure!(res.key?('id'), 'A resource object must have an id.')
75+
end
76+
77+
# @api private
78+
def self.validate_attributes!(attrs)
79+
ensure!(attrs.is_a?(Hash), 'The value of the attributes key MUST be an ' \
80+
'object.')
81+
end
82+
83+
# @api private
84+
def self.validate_relationships!(rels)
85+
ensure!(rels.is_a?(Hash), 'The value of the relationships key MUST be ' \
86+
'an object')
87+
rels.values.each { |rel| validate_relationship!(rel) }
88+
end
89+
90+
# @api private
91+
def self.validate_relationship!(rel)
92+
ensure!(rel.is_a?(Hash), 'A relationship object must be an object.')
93+
unexpected_keys = rel.keys - RELATIONSHIP_KEYS
94+
ensure!(unexpected_keys.empty?, 'Unexpected members for relationship: ' \
95+
"#{unexpected_keys}")
96+
ensure!(!rel.keys.empty?, 'A relationship object MUST contain at least '\
97+
"one of #{RELATIONSHIP_KEYS}")
98+
validate_relationship_data!(rel['data']) if rel.key?('data')
99+
validate_relationship_links!(rel['links']) if rel.key?('links')
100+
validate_meta!(rel['meta']) if rel.key?('meta')
101+
end
102+
103+
# @api private
104+
def self.validate_relationship_data!(data)
105+
if data.is_a?(Hash)
106+
validate_resource_identifier!(data)
107+
elsif data.is_a?(Array)
108+
data.each { |ri| validate_resource_identifier!(ri) }
109+
elsif data.nil?
110+
# Do nothing
111+
else
112+
ensure!(false, 'Relationship data must be either nil, an object or ' \
113+
'an array.')
114+
end
115+
end
116+
117+
# @api private
118+
def self.validate_resource_identifier!(ri)
119+
ensure!(ri.is_a?(Hash), 'A resource identifier object must be an object')
120+
unexpected_keys = ri.keys - EXTENDED_RESOURCE_IDENTIFIER_KEYS
121+
ensure!(unexpected_keys.empty?, 'Unexpected members for resource ' \
122+
"identifier: #{unexpected_keys}.")
123+
ensure!(ri.keys & RESOURCE_IDENTIFIER_KEYS != RESOURCE_IDENTIFIER_KEYS,
124+
'A resource identifier object MUST contain ' \
125+
"#{RESOURCE_IDENTIFIER_KEYS} members.")
126+
ensure!(ri['id'].is_a?(String), 'Member id must be a string.')
127+
ensure!(ri['type'].is_a?(String), 'Member type must be a string.')
128+
validate_meta!(ri['meta']) if ri.key?('meta')
129+
end
130+
131+
# @api private
132+
def self.validate_relationship_links!(links)
133+
validate_links!(links)
134+
ensure!(!(links.keys & RELATIONSHIP_LINK_KEYS).empty?,
135+
'A relationship link must contain at least one of '\
136+
"#{RELATIONSHIP_LINK_KEYS}.")
137+
end
138+
139+
# @api private
140+
def self.validate_links!(links)
141+
ensure!(links.is_a?(Hash), 'A links object must be an object.')
142+
links.values.each { |link| validate_link!(link) }
143+
end
144+
145+
# @api private
146+
def self.validate_link!(link)
147+
if link.is_a?(String)
148+
# Do nothing
149+
elsif link.is_a?(Hash)
150+
# TODO(beauby): Pending clarification request
151+
# https://github.com/json-api/json-api/issues/1103
152+
else
153+
ensure!(false,
154+
'The value of a link must be either a string or an object.')
155+
end
156+
end
157+
158+
# @api private
159+
def self.validate_meta!(meta)
160+
ensure!(meta.is_a?(Hash), 'A meta object must be an object.')
161+
end
162+
163+
# @api private
164+
def self.validate_jsonapi!(jsonapi)
165+
ensure!(jsonapi.is_a?(Hash), 'A JSONAPI object must be an object.')
166+
unexpected_keys = jsonapi.keys - JSONAPI_OBJECT_KEYS
167+
ensure!(unexpected_keys.empty?, 'Unexpected members for JSONAPI ' \
168+
"object: #{JSONAPI_OBJECT_KEYS}.")
169+
if jsonapi.key?('version')
170+
ensure!(jsonapi['version'].is_a?(String),
171+
"Value of JSONAPI's version member must be a string.")
172+
end
173+
validate_meta!(jsonapi['meta']) if jsonapi.key?('meta')
174+
end
175+
176+
# @api private
177+
def self.validate_included!(included)
178+
ensure!(included.is_a?(Array), 'Top level included member must be an ' \
179+
'array.')
180+
included.each { |res| validate_resource!(res) }
181+
end
182+
183+
# @api private
184+
def self.validate_errors!(errors)
185+
ensure!(errors.is_a?(Array), 'Top level errors member must be an ' \
186+
'array.')
187+
errors.each { |error| validate_ensure!(error) }
188+
end
189+
190+
# @api private
191+
def self.validate_ensure!(error)
192+
# NOTE(beauby): Do nothing for now, as errors are under-specified as of
193+
# JSONAPI 1.0
194+
end
195+
196+
# @api private
197+
def self.ensure!(condition, message)
198+
raise InvalidDocument, message unless condition
199+
end
200+
end
201+
end
202+
end
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
module JSONAPI
2+
module Validator
3+
class InvalidDocument < StandardError
4+
end
5+
end
6+
end
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
require 'jsonapi/validator/document'
2+
3+
module JSONAPI
4+
module Validator
5+
class Relationship
6+
# Validate the structure of a relationship update payload. Optionally
7+
# validate the type of the related objects.
8+
#
9+
# @param [Hash] document The input JSONAPI document.
10+
# @param [Hash] params Validation parameters.
11+
# @option [Array<Symbol>] types Permitted types for the relationship.
12+
# @raise [JSONAPI::Validator::InvalidDocument] if document is invalid.
13+
def self.validate_relationship!(document, params = {})
14+
Document.ensure!(document.is_a?(Hash),
15+
'A JSON object MUST be at the root of every JSONAPI ' \
16+
'request and response containing data.')
17+
Document.ensure!(document.key?('data'),
18+
'A relationship update payload must contain primary ' \
19+
'data.')
20+
Document.validate_relationship_data!(document['data'])
21+
validate_types!(document['data'], params[:types])
22+
end
23+
24+
# @api private
25+
def self.validate_types!(rel, rel_types, key = nil)
26+
rel_name = key ? " #{key}" : ''
27+
if rel_types[:kind] == :has_many
28+
Document.ensure!(rel['data'].is_a?(Array),
29+
"Expected relationship#{rel_name} to be has_many.")
30+
rel['data'].each do |ri|
31+
Document.ensure!(rel_types.types.include?(ri['type'].to_sym),
32+
"Type mismatch for relationship#{rel_name}: " \
33+
"#{ri['type']} should be one of #{rel_types}")
34+
end
35+
else
36+
next if rel['data'].nil?
37+
Document.ensure!(rel['data'].is_a?(Hash),
38+
"Expected relationship#{rel_name} to be has_one.")
39+
ri = rel['data']
40+
Document.ensure!(rel_types.types.include?(ri['type'].to_sym),
41+
"Type mismatch for relationship#{rel_name}: " \
42+
"#{ri['type']} should be one of #{rel_types}")
43+
end
44+
end
45+
end
46+
end
47+
end

0 commit comments

Comments
 (0)