├── VERSION ├── spec ├── resource_spec.rb ├── relationship_spec.rb ├── spec_helper.rb └── response_spec.rb ├── Gemfile ├── lib └── jsonapi │ ├── parser │ ├── exceptions.rb │ ├── relationship.rb │ ├── resource.rb │ └── document.rb │ └── parser.rb ├── .travis.yml ├── Rakefile ├── .gitignore ├── jsonapi-parser.gemspec ├── LICENSE └── README.md /VERSION: -------------------------------------------------------------------------------- 1 | 0.1.1 2 | -------------------------------------------------------------------------------- /spec/resource_spec.rb: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/relationship_spec.rb: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /lib/jsonapi/parser/exceptions.rb: -------------------------------------------------------------------------------- 1 | module JSONAPI 2 | module Parser 3 | class InvalidDocument < StandardError 4 | end 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'simplecov' 2 | SimpleCov.start 3 | 4 | require 'codecov' 5 | SimpleCov.formatter = SimpleCov::Formatter::Codecov 6 | 7 | require 'jsonapi/parser' 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | sudo: false 3 | before_install: 4 | - bundle update 5 | rvm: 6 | - 2.1 7 | - 2.2 8 | - 2.3.0 9 | - ruby-head 10 | matrix: 11 | allow_failures: 12 | - rvm: ruby-head 13 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rspec/core/rake_task' 3 | 4 | RSpec::Core::RakeTask.new(:spec) do |t| 5 | t.pattern = Dir.glob('spec/**/*_spec.rb') 6 | end 7 | 8 | task default: :test 9 | task test: :spec 10 | -------------------------------------------------------------------------------- /lib/jsonapi/parser.rb: -------------------------------------------------------------------------------- 1 | require 'jsonapi/parser/document' 2 | require 'jsonapi/parser/relationship' 3 | require 'jsonapi/parser/resource' 4 | 5 | module JSONAPI 6 | module_function 7 | 8 | # @see JSONAPI::Parser::Document.validate! 9 | def parse_response!(document) 10 | Parser::Document.parse!(document) 11 | end 12 | 13 | # @see JSONAPI::Parser::Resource.validate! 14 | def parse_resource!(document) 15 | Parser::Resource.parse!(document) 16 | end 17 | 18 | # @see JSONAPI::Parser::Relationship.validate! 19 | def parse_relationship!(document) 20 | Parser::Relationship.parse!(document) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | /.config 4 | /coverage/ 5 | /InstalledFiles 6 | /pkg/ 7 | /spec/reports/ 8 | /spec/examples.txt 9 | /test/tmp/ 10 | /test/version_tmp/ 11 | /tmp/ 12 | 13 | ## Specific to RubyMotion: 14 | .dat* 15 | .repl_history 16 | build/ 17 | 18 | ## Documentation cache and generated files: 19 | /.yardoc/ 20 | /_yardoc/ 21 | /doc/ 22 | /rdoc/ 23 | 24 | ## Environment normalization: 25 | /.bundle/ 26 | /vendor/bundle 27 | /lib/bundler/man/ 28 | 29 | # for a library or gem, you might want to ignore these files since the code is 30 | # intended to run in multiple environments; otherwise, check them in: 31 | Gemfile.lock 32 | .ruby-version 33 | .ruby-gemset 34 | 35 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 36 | .rvmrc 37 | -------------------------------------------------------------------------------- /lib/jsonapi/parser/relationship.rb: -------------------------------------------------------------------------------- 1 | require 'jsonapi/parser/document' 2 | 3 | module JSONAPI 4 | module Parser 5 | class Relationship 6 | # Validate the structure of a relationship update payload. 7 | # 8 | # @param [Hash] document The input JSONAPI document. 9 | # @raise [JSONAPI::Parser::InvalidDocument] if document is invalid. 10 | def self.parse!(document) 11 | Document.ensure!(document.is_a?(Hash), 12 | 'A JSON object MUST be at the root of every JSONAPI ' \ 13 | 'request and response containing data.') 14 | Document.ensure!(document.keys == ['data'].freeze, 15 | 'A relationship update payload must contain primary ' \ 16 | 'data.') 17 | Document.parse_relationship_data!(document['data']) 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /jsonapi-parser.gemspec: -------------------------------------------------------------------------------- 1 | version = File.read(File.expand_path('../VERSION', __FILE__)).strip 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = 'jsonapi-parser' 5 | spec.version = version 6 | spec.author = 'Lucas Hosseini' 7 | spec.email = 'lucas.hosseini@gmail.com' 8 | spec.summary = 'Validate JSON API documents.' 9 | spec.description = 'Validate JSONAPI response documents, resource ' \ 10 | 'creation/update payloads, and relationship ' \ 11 | 'update payloads.' 12 | spec.homepage = 'https://github.com/jsonapi-rb/jsonapi-parser' 13 | spec.license = 'MIT' 14 | 15 | spec.files = Dir['README.md', 'lib/**/*'] 16 | spec.require_path = 'lib' 17 | 18 | spec.add_development_dependency 'rake', '~> 11.3' 19 | spec.add_development_dependency 'rspec', '~> 3.5' 20 | spec.add_development_dependency 'codecov', '~> 0.1' 21 | end 22 | -------------------------------------------------------------------------------- /lib/jsonapi/parser/resource.rb: -------------------------------------------------------------------------------- 1 | require 'jsonapi/parser/document' 2 | 3 | module JSONAPI 4 | module Parser 5 | class Resource 6 | # Validate the structure of a resource create/update payload. 7 | # 8 | # @param [Hash] document The input JSONAPI document. 9 | # @raise [JSONAPI::Parser::InvalidDocument] if document is invalid. 10 | def self.parse!(document) 11 | Document.ensure!(document.is_a?(Hash), 12 | 'A JSON object MUST be at the root of every JSONAPI ' \ 13 | 'request and response containing data.') 14 | Document.ensure!(document.keys == ['data'].freeze && 15 | document['data'].is_a?(Hash), 16 | 'The request MUST include a single resource object ' \ 17 | 'as primary data.') 18 | Document.parse_primary_resource!(document['data']) 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Lucas Hosseini 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jsonapi-parser 2 | Ruby gem for validating [JSON API](http://jsonapi.org) documents. 3 | 4 | ## Status 5 | 6 | [![Gem Version](https://badge.fury.io/rb/jsonapi-parser.svg)](https://badge.fury.io/rb/jsonapi-parser) 7 | [![Build Status](https://secure.travis-ci.org/jsonapi-rb/jsonapi-parser.svg?branch=master)](http://travis-ci.org/jsonapi-rb/parser?branch=master) 8 | [![codecov](https://codecov.io/gh/jsonapi-rb/jsonapi-parser/branch/master/graph/badge.svg)](https://codecov.io/gh/jsonapi-rb/parser) 9 | [![Gitter chat](https://badges.gitter.im/gitterHQ/gitter.png)](https://gitter.im/jsonapi-rb/Lobby) 10 | 11 | ## Resources 12 | 13 | * Chat: [gitter](http://gitter.im/jsonapi-rb) 14 | * Twitter: [@jsonapirb](http://twitter.com/jsonapirb) 15 | * Docs: [jsonapi-rb.org](http://jsonapi-rb.org) 16 | 17 | ## Installation 18 | ```ruby 19 | # In Gemfile 20 | gem 'jsonapi-parser' 21 | ``` 22 | then 23 | ``` 24 | $ bundle 25 | ``` 26 | or manually via 27 | ``` 28 | $ gem install jsonapi-parser 29 | ``` 30 | 31 | ## Usage 32 | 33 | First, require the gem: 34 | ```ruby 35 | require 'jsonapi/parser' 36 | ``` 37 | Then simply parse a document: 38 | ```ruby 39 | # This will raise JSONAPI::Parser::InvalidDocument if an error is found. 40 | JSONAPI.parse_response!(document_hash) 41 | ``` 42 | or a resource create/update payload: 43 | ```ruby 44 | JSONAPI.parse_resource!(document_hash) 45 | ``` 46 | or a relationship update payload: 47 | ```ruby 48 | JSONAPI.parse_relationship!(document_hash) 49 | ``` 50 | 51 | ## License 52 | 53 | jsonapi-parser is released under the [MIT License](http://www.opensource.org/licenses/MIT). 54 | -------------------------------------------------------------------------------- /spec/response_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe JSONAPI::Parser, '.parse_response!' do 4 | it 'succeeds on nil data' do 5 | payload = { 'data' => nil } 6 | 7 | expect { JSONAPI.parse_response!(payload) }.not_to raise_error 8 | end 9 | 10 | it 'succeeds on empty array data' do 11 | payload = { 'data' => [] } 12 | 13 | expect { JSONAPI.parse_response!(payload) }.not_to raise_error 14 | end 15 | 16 | it 'works' do 17 | payload = { 18 | 'data' => [ 19 | { 20 | 'type' => 'articles', 21 | 'id' => '1', 22 | 'attributes' => { 'title' => 'JSON API paints my bikeshed!' }, 23 | 'links' => { 'self' => 'http://example.com/articles/1' }, 24 | 'relationships' => { 25 | 'author' => { 26 | 'links' => { 27 | 'self' => 'http://example.com/articles/1/relationships/author', 28 | 'related' => 'http://example.com/articles/1/author' 29 | }, 30 | 'data' => { 'type' => 'people', 'id' => '9' } 31 | }, 32 | 'journal' => { 33 | 'data' => nil 34 | }, 35 | 'comments' => { 36 | 'links' => { 37 | 'self' => 'http://example.com/articles/1/relationships/comments', 38 | 'related' => 'http://example.com/articles/1/comments' 39 | }, 40 | 'data' => [ 41 | { 'type' => 'comments', 'id' => '5' }, 42 | { 'type' => 'comments', 'id' => '12' } 43 | ] 44 | } 45 | } 46 | } 47 | ], 48 | 'meta' => { 'count' => '13' } 49 | } 50 | 51 | expect { JSONAPI.parse_response!(payload) }.not_to raise_error 52 | end 53 | 54 | it 'passes regardless of id/type order' do 55 | payload = { 56 | 'data' => [ 57 | { 58 | 'type' => 'articles', 59 | 'id' => '1', 60 | 'relationships' => { 61 | 'comments' => { 62 | 'data' => [ 63 | { 'type' => 'comments', 'id' => '5' }, 64 | { 'id' => '12', 'type' => 'comments' } 65 | ] 66 | } 67 | } 68 | } 69 | ] 70 | } 71 | 72 | expect { JSONAPI.parse_response!(payload) }.to_not raise_error 73 | end 74 | 75 | it 'fails when an element is missing type or id' do 76 | payload = { 77 | 'data' => [ 78 | { 79 | 'type' => 'articles', 80 | 'id' => '1', 81 | 'relationships' => { 82 | 'author' => { 83 | 'data' => { 'type' => 'people' } 84 | } 85 | } 86 | } 87 | ] 88 | } 89 | 90 | expect { JSONAPI.parse_response!(payload) }.to raise_error( 91 | JSONAPI::Parser::InvalidDocument, 92 | 'A resource identifier object MUST contain ["id", "type"] members.' 93 | ) 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /lib/jsonapi/parser/document.rb: -------------------------------------------------------------------------------- 1 | require 'jsonapi/parser/exceptions' 2 | 3 | module JSONAPI 4 | module Parser 5 | class Document 6 | TOP_LEVEL_KEYS = %w(data errors meta).freeze 7 | RESOURCE_IDENTIFIER_KEYS = %w(id type).freeze 8 | RELATIONSHIP_KEYS = %w(data links meta).freeze 9 | RELATIONSHIP_LINK_KEYS = %w(self related).freeze 10 | JSONAPI_OBJECT_KEYS = %w(version meta).freeze 11 | 12 | # Validate the structure of a JSONAPI response document. 13 | # 14 | # @param [Hash] document The input JSONAPI document. 15 | # @raise [JSONAPI::Parser::InvalidDocument] if document is invalid. 16 | def self.parse!(document) 17 | ensure!(document.is_a?(Hash), 18 | 'A JSON object MUST be at the root of every JSON API request ' \ 19 | 'and response containing data.') 20 | ensure!(!(document.keys & TOP_LEVEL_KEYS).empty?, 21 | "A document MUST contain at least one of #{TOP_LEVEL_KEYS}.") 22 | ensure!(!(document.key?('data') && document.key?('errors')), 23 | 'The members data and errors MUST NOT coexist in the same ' \ 24 | 'document.') 25 | ensure!(document.key?('data') || !document.key?('included'), 26 | 'If a document does not contain a top-level data key, the ' \ 27 | 'included member MUST NOT be present either.') 28 | parse_data!(document['data']) if document.key?('data') 29 | parse_errors!(document['errors']) if document.key?('errors') 30 | parse_meta!(document['meta']) if document.key?('meta') 31 | parse_jsonapi!(document['jsonapi']) if document.key?('jsonapi') 32 | parse_included!(document['included']) if document.key?('included') 33 | parse_links!(document['links']) if document.key?('links') 34 | end 35 | 36 | # @api private 37 | def self.parse_data!(data) 38 | if data.is_a?(Hash) 39 | parse_resource!(data) 40 | elsif data.is_a?(Array) 41 | data.each { |res| parse_resource!(res) } 42 | elsif data.nil? 43 | # Do nothing 44 | else 45 | ensure!(false, 46 | 'Primary data must be either nil, an object or an array.') 47 | end 48 | end 49 | 50 | # @api private 51 | def self.parse_primary_resource!(res) 52 | ensure!(res.is_a?(Hash), 'A resource object must be an object.') 53 | ensure!(res.key?('type'), 'A resource object must have a type.') 54 | parse_attributes!(res['attributes']) if res.key?('attributes') 55 | parse_relationships!(res['relationships']) if res.key?('relationships') 56 | parse_links!(res['links']) if res.key?('links') 57 | parse_meta!(res['meta']) if res.key?('meta') 58 | end 59 | 60 | # @api private 61 | def self.parse_resource!(res) 62 | parse_primary_resource!(res) 63 | ensure!(res.key?('id'), 'A resource object must have an id.') 64 | end 65 | 66 | # @api private 67 | def self.parse_attributes!(attrs) 68 | ensure!(attrs.is_a?(Hash), 69 | 'The value of the attributes key MUST be an object.') 70 | end 71 | 72 | # @api private 73 | def self.parse_relationships!(rels) 74 | ensure!(rels.is_a?(Hash), 75 | 'The value of the relationships key MUST be an object') 76 | rels.values.each { |rel| parse_relationship!(rel) } 77 | end 78 | 79 | # @api private 80 | def self.parse_relationship!(rel) 81 | ensure!(rel.is_a?(Hash), 'A relationship object must be an object.') 82 | ensure!(!rel.keys.empty?, 83 | 'A relationship object MUST contain at least one of ' \ 84 | "#{RELATIONSHIP_KEYS}") 85 | parse_relationship_data!(rel['data']) if rel.key?('data') 86 | parse_relationship_links!(rel['links']) if rel.key?('links') 87 | parse_meta!(rel['meta']) if rel.key?('meta') 88 | end 89 | 90 | # @api private 91 | def self.parse_relationship_data!(data) 92 | if data.is_a?(Hash) 93 | parse_resource_identifier!(data) 94 | elsif data.is_a?(Array) 95 | data.each { |ri| parse_resource_identifier!(ri) } 96 | elsif data.nil? 97 | # Do nothing 98 | else 99 | ensure!(false, 'Relationship data must be either nil, an object or ' \ 100 | 'an array.') 101 | end 102 | end 103 | 104 | # @api private 105 | def self.parse_resource_identifier!(ri) 106 | ensure!(ri.is_a?(Hash), 107 | 'A resource identifier object must be an object') 108 | ensure!(RESOURCE_IDENTIFIER_KEYS & ri.keys == RESOURCE_IDENTIFIER_KEYS, 109 | 'A resource identifier object MUST contain ' \ 110 | "#{RESOURCE_IDENTIFIER_KEYS} members.") 111 | ensure!(ri['id'].is_a?(String), 'Member id must be a string.') 112 | ensure!(ri['type'].is_a?(String), 'Member type must be a string.') 113 | parse_meta!(ri['meta']) if ri.key?('meta') 114 | end 115 | 116 | # @api private 117 | def self.parse_relationship_links!(links) 118 | parse_links!(links) 119 | ensure!(!(links.keys & RELATIONSHIP_LINK_KEYS).empty?, 120 | 'A relationship link must contain at least one of '\ 121 | "#{RELATIONSHIP_LINK_KEYS}.") 122 | end 123 | 124 | # @api private 125 | def self.parse_links!(links) 126 | ensure!(links.is_a?(Hash), 'A links object must be an object.') 127 | links.values.each { |link| parse_link!(link) } 128 | end 129 | 130 | # @api private 131 | def self.parse_link!(link) 132 | if link.is_a?(String) 133 | # Do nothing 134 | elsif link.is_a?(Hash) 135 | # TODO(beauby): Pending clarification request 136 | # https://github.com/json-api/json-api/issues/1103 137 | else 138 | ensure!(false, 139 | 'The value of a link must be either a string or an object.') 140 | end 141 | end 142 | 143 | # @api private 144 | def self.parse_meta!(meta) 145 | ensure!(meta.is_a?(Hash), 'A meta object must be an object.') 146 | end 147 | 148 | # @api private 149 | def self.parse_jsonapi!(jsonapi) 150 | ensure!(jsonapi.is_a?(Hash), 'A JSONAPI object must be an object.') 151 | unexpected_keys = jsonapi.keys - JSONAPI_OBJECT_KEYS 152 | ensure!(unexpected_keys.empty?, 153 | 'Unexpected members for JSONAPI object: ' \ 154 | "#{JSONAPI_OBJECT_KEYS}.") 155 | if jsonapi.key?('version') 156 | ensure!(jsonapi['version'].is_a?(String), 157 | "Value of JSONAPI's version member must be a string.") 158 | end 159 | parse_meta!(jsonapi['meta']) if jsonapi.key?('meta') 160 | end 161 | 162 | # @api private 163 | def self.parse_included!(included) 164 | ensure!(included.is_a?(Array), 165 | 'Top level included member must be an array.') 166 | included.each { |res| parse_resource!(res) } 167 | end 168 | 169 | # @api private 170 | def self.parse_errors!(errors) 171 | ensure!(errors.is_a?(Array), 172 | 'Top level errors member must be an array.') 173 | errors.each { |error| parse_error!(error) } 174 | end 175 | 176 | # @api private 177 | def self.parse_error!(_error) 178 | # NOTE(beauby): Do nothing for now, as errors are under-specified as of 179 | # JSONAPI 1.0 180 | end 181 | 182 | # @api private 183 | def self.ensure!(condition, message) 184 | raise InvalidDocument, message unless condition 185 | end 186 | end 187 | end 188 | end 189 | --------------------------------------------------------------------------------