jsonapi 0.1.1.beta1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,34 @@
1
+ module JSONAPI
2
+ # c.f. http://jsonapi.org/format/#document-links
3
+ class Links
4
+ def initialize(links_hash, options = {})
5
+ fail InvalidDocument, "the value of 'links' MUST be an object" unless
6
+ links_hash.is_a?(Hash)
7
+
8
+ @hash = links_hash
9
+ @links = {}
10
+ links_hash.each do |link_name, link_val|
11
+ @links[link_name.to_s] = Link.new(link_val, options)
12
+ define_singleton_method(link_name) do
13
+ @links[link_name.to_s]
14
+ end
15
+ end
16
+ end
17
+
18
+ def to_hash
19
+ @hash
20
+ end
21
+
22
+ def defined?(link_name)
23
+ @links.key?(link_name.to_s)
24
+ end
25
+
26
+ def [](link_name)
27
+ @links[link_name.to_s]
28
+ end
29
+
30
+ def keys
31
+ @links.keys
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,18 @@
1
+ require 'json'
2
+
3
+ module JSONAPI
4
+ module_function
5
+
6
+ # Parse a JSON API document.
7
+ #
8
+ # @param document [Hash, String] the JSON API document.
9
+ # @param options [Hash] options
10
+ # @option options [Boolean] :id_optional (false) whether the resource
11
+ # objects in the primary data must have an id
12
+ # @return [JSON::API::Document]
13
+ def parse(document, options = {})
14
+ hash = document.is_a?(Hash) ? document : JSON.parse(document)
15
+
16
+ Document.new(hash, options)
17
+ end
18
+ end
@@ -0,0 +1,57 @@
1
+ module JSONAPI
2
+ # c.f. http://jsonapi.org/format/#document-resource-object-relationships
3
+ class Relationship
4
+ attr_reader :data, :links, :meta
5
+
6
+ def initialize(relationship_hash, options = {})
7
+ @hash = relationship_hash
8
+ @options = options
9
+ @links_defined = relationship_hash.key?('links')
10
+ @data_defined = relationship_hash.key?('data')
11
+ @meta_defined = relationship_hash.key?('meta')
12
+ links_hash = relationship_hash['links'] || {}
13
+ @links = Links.new(links_hash, @options)
14
+ @data = parse_linkage(relationship_hash['data']) if
15
+ @data_defined
16
+ @meta = relationship_hash['meta'] if @meta_defined
17
+
18
+ validate!
19
+ end
20
+
21
+ def to_hash
22
+ @hash
23
+ end
24
+
25
+ def collection?
26
+ @data.is_a?(Array)
27
+ end
28
+
29
+ private
30
+
31
+ def validate!
32
+ case
33
+ when !@links_defined && !@data_defined && !@meta_defined
34
+ fail InvalidDocument,
35
+ "a relationship object MUST contain at least one of 'links'," \
36
+ " 'data', or 'meta'"
37
+ when @links_defined &&
38
+ !@links.defined?(:self) &&
39
+ !@links.defined?(:related)
40
+ fail InvalidDocument,
41
+ "the 'links' object of a relationship object MUST contain at" \
42
+ " least one of 'self' or 'related'"
43
+ end
44
+ end
45
+
46
+ def parse_linkage(linkage_hash)
47
+ collection = linkage_hash.is_a?(Array)
48
+ if collection
49
+ linkage_hash.map { |h| ResourceIdentifier.new(h, @options) }
50
+ elsif linkage_hash.nil?
51
+ nil
52
+ else
53
+ ResourceIdentifier.new(linkage_hash, @options)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,41 @@
1
+ module JSONAPI
2
+ # c.f. http://jsonapi.org/format/#document-resource-object-relationships
3
+ class Relationships
4
+ include Enumerable
5
+
6
+ def initialize(relationships_hash, options = {})
7
+ fail InvalidDocument,
8
+ "the value of 'relationships' MUST be an object" unless
9
+ relationships_hash.is_a?(Hash)
10
+
11
+ @hash = relationships_hash
12
+ @relationships = {}
13
+ relationships_hash.each do |rel_name, rel_hash|
14
+ @relationships[rel_name.to_s] = Relationship.new(rel_hash, options)
15
+ define_singleton_method(rel_name) do
16
+ @relationships[rel_name.to_s]
17
+ end
18
+ end
19
+ end
20
+
21
+ def to_hash
22
+ @hash
23
+ end
24
+
25
+ def each(&block)
26
+ @relationships.each(&block)
27
+ end
28
+
29
+ def [](rel_name)
30
+ @relationships[rel_name.to_s]
31
+ end
32
+
33
+ def defined?(rel_name)
34
+ @relationships.key?(rel_name.to_s)
35
+ end
36
+
37
+ def keys
38
+ @relationships.keys
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,151 @@
1
+ require 'active_support/core_ext/string/inflections'
2
+
3
+ module JSONAPI
4
+ class Resource
5
+ # Transform the resource object into a hash ready for ActiveRecord's
6
+ # new/create/update.
7
+ #
8
+ # @example
9
+ # payload = {
10
+ # 'data' => {
11
+ # 'type' => 'articles',
12
+ # 'id' => '1',
13
+ # 'attributes' => {
14
+ # 'title' => 'JSON API paints my bikeshed!',
15
+ # 'rating' => '5 stars'
16
+ # },
17
+ # 'relationships' => {
18
+ # 'author' => {
19
+ # 'data' => { 'type' => 'people', 'id' => '9' }
20
+ # },
21
+ # 'referree' => {
22
+ # 'data' => nil
23
+ # },
24
+ # 'publishing-journal' => {
25
+ # 'data' => nil
26
+ # },
27
+ # 'comments' => {
28
+ # 'data' => [
29
+ # { 'type' => 'comments', 'id' => '5' },
30
+ # { 'type' => 'comments', 'id' => '12' }
31
+ # ]
32
+ # }
33
+ # }
34
+ # }
35
+ # }
36
+ # document = JSON::API.parse(payload)
37
+ # options = {
38
+ # attributes: {
39
+ # except: [:rating]
40
+ # },
41
+ # relationships: {
42
+ # only: [:author, :'publishing-journal', :comments],
43
+ # polymorphic: [:author]
44
+ # },
45
+ # key_formatter: ->(x) { x.underscore }
46
+ # }
47
+ # document.data.to_activerecord_hash(options)
48
+ # # => {
49
+ # id: '1',
50
+ # title: 'JSON API paints my bikeshed!',
51
+ # author_id: '9',
52
+ # author_type: 'people',
53
+ # publishing_journal_id: nil,
54
+ # comment_ids: ['5', '12']
55
+ # }
56
+ #
57
+ # @param options [Hash]
58
+ # * :attributes (Hash)
59
+ # * :only (Array<Symbol,String>)
60
+ # * :except (Array<Symbol,String>)
61
+ # * :relationships (Hash)
62
+ # * :only (Array<Symbol,String>)
63
+ # * :except (Array<Symbol,String>)
64
+ # * :polymorphic (Array<Symbol,String>)
65
+ # * :key_formatter (lambda)
66
+ # @return [Hash]
67
+ def to_activerecord_hash(options = {})
68
+ options[:attributes] ||= {}
69
+ options[:relationships] ||= {}
70
+ hash = {}
71
+ hash[:id] = id unless id.nil?
72
+ hash.merge!(attributes_for_activerecord_hash(options))
73
+ hash.merge!(relationships_for_activerecord_hash(options))
74
+
75
+ hash
76
+ end
77
+
78
+ private
79
+
80
+ def attributes_for_activerecord_hash(options)
81
+ attributes_hashes =
82
+ filter_keys(attributes.keys, options[:attributes]).map do |key|
83
+ attribute_for_activerecord_hash(key, options[:key_formatter])
84
+ end
85
+
86
+ attributes_hashes.reduce({}, :merge)
87
+ end
88
+
89
+ def attribute_for_activerecord_hash(key, key_formatter)
90
+ { format_key(key, key_formatter).to_sym => attributes[key] }
91
+ end
92
+
93
+ def relationships_for_activerecord_hash(options)
94
+ relationship_hashes =
95
+ filter_keys(relationships.keys, options[:relationships]).map do |key|
96
+ polymorphic = (options[:relationships][:polymorphic] || [])
97
+ .include?(key.to_sym)
98
+ relationship_for_activerecord_hash(key,
99
+ options[:key_formatter],
100
+ polymorphic)
101
+ end
102
+
103
+ relationship_hashes.reduce({}, :merge)
104
+ end
105
+
106
+ def relationship_for_activerecord_hash(rel_name,
107
+ key_formatter,
108
+ polymorphic)
109
+ rel = relationships[rel_name]
110
+ key = format_key(rel_name, key_formatter)
111
+
112
+ if rel.collection?
113
+ to_many_relationship_for_activerecord_hash(key, rel)
114
+ else
115
+ to_one_relationship_for_activerecord_hash(key, rel, polymorphic)
116
+ end
117
+ end
118
+
119
+ def to_many_relationship_for_activerecord_hash(key, rel)
120
+ { "#{key.singularize}_ids".to_sym => rel.data.map(&:id) }
121
+ end
122
+
123
+ def to_one_relationship_for_activerecord_hash(key, rel, polymorphic)
124
+ value = rel.data ? rel.data.id : nil
125
+ hash = { "#{key}_id".to_sym => value }
126
+ if polymorphic && !rel.data.nil?
127
+ hash["#{key}_type".to_sym] = rel.data.type.singularize.capitalize
128
+ end
129
+
130
+ hash
131
+ end
132
+
133
+ def format_key(key, key_formatter)
134
+ if key_formatter
135
+ key_formatter.call(key)
136
+ else
137
+ key
138
+ end
139
+ end
140
+
141
+ def filter_keys(keys, filter)
142
+ if filter[:only]
143
+ keys & filter[:only].map(&:to_s)
144
+ elsif filter[:except]
145
+ keys - filter[:except].map(&:to_s)
146
+ else
147
+ keys
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,48 @@
1
+ require 'jsonapi/resource/active_record'
2
+
3
+ module JSONAPI
4
+ # c.f. http://jsonapi.org/format/#document-resource-objects
5
+ class Resource
6
+ attr_reader :id, :type, :attributes, :relationships, :links, :meta
7
+
8
+ def initialize(resource_hash, options = {})
9
+ @hash = resource_hash
10
+ @options = options.dup
11
+ @id_optional = @options.delete(:id_optional)
12
+ validate!(resource_hash)
13
+ @id = resource_hash['id']
14
+ @type = resource_hash['type']
15
+ @attributes_hash = resource_hash['attributes'] || {}
16
+ @attributes = Attributes.new(@attributes_hash, @options)
17
+ @relationships_hash = resource_hash['relationships'] || {}
18
+ @relationships = Relationships.new(@relationships_hash, @options)
19
+ @links_hash = resource_hash['links'] || {}
20
+ @links = Links.new(@links_hash, @options)
21
+ @meta = resource_hash['meta'] if resource_hash.key?('meta')
22
+ end
23
+
24
+ def to_hash
25
+ @hash
26
+ end
27
+
28
+ private
29
+
30
+ def validate!(resource_hash)
31
+ case
32
+ when !@id_optional && !resource_hash.key?('id')
33
+ # We might want to take care of
34
+ # > Exception: The id member is not required when the resource object
35
+ # > originates at the client and represents a new resource to be created
36
+ # > on the server.
37
+ # in the future.
38
+ fail InvalidDocument, "a resource object MUST contain an 'id'"
39
+ when !@id_optional && !resource_hash['id'].is_a?(String)
40
+ fail InvalidDocument, "the value of 'id' MUST be a string"
41
+ when !resource_hash.key?('type')
42
+ fail InvalidDocument, "a resource object MUST contain a 'type'"
43
+ when !resource_hash['type'].is_a?(String)
44
+ fail InvalidDocument, "the value of 'type' MUST be a string"
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,36 @@
1
+ module JSONAPI
2
+ # c.f. http://jsonapi.org/format/#document-resource-identifier-objects
3
+ class ResourceIdentifier
4
+ attr_reader :id, :type
5
+
6
+ def initialize(resource_identifier_hash, options = {})
7
+ @hash = resource_identifier_hash
8
+
9
+ validate!(resource_identifier_hash)
10
+
11
+ @id = resource_identifier_hash['id']
12
+ @type = resource_identifier_hash['type']
13
+ end
14
+
15
+ def to_hash
16
+ @hash
17
+ end
18
+
19
+ private
20
+
21
+ def validate!(resource_identifier_hash)
22
+ case
23
+ when !resource_identifier_hash.key?('id')
24
+ fail InvalidDocument,
25
+ "a resource identifier object MUST contain an 'id'"
26
+ when !resource_identifier_hash['id'].is_a?(String)
27
+ fail InvalidDocument, "the value of 'id' MUST be a string"
28
+ when !resource_identifier_hash.key?('type')
29
+ fail InvalidDocument,
30
+ "a resource identifier object MUST contain a 'type'"
31
+ when !resource_identifier_hash['type'].is_a?(String)
32
+ fail InvalidDocument, "the value of 'type' MUST be a string"
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,3 @@
1
+ module JSONAPI
2
+ VERSION = '0.1.1.beta1'.freeze
3
+ end
data/lib/jsonapi.rb ADDED
@@ -0,0 +1,18 @@
1
+ require 'jsonapi/exceptions'
2
+ require 'jsonapi/version'
3
+
4
+ require 'jsonapi/attributes'
5
+ require 'jsonapi/document'
6
+ require 'jsonapi/error'
7
+ require 'jsonapi/jsonapi'
8
+ require 'jsonapi/link'
9
+ require 'jsonapi/links'
10
+ require 'jsonapi/relationship'
11
+ require 'jsonapi/relationships'
12
+ require 'jsonapi/resource'
13
+ require 'jsonapi/resource_identifier'
14
+
15
+ require 'jsonapi/parse'
16
+
17
+ module JSONAPI
18
+ end
@@ -0,0 +1,95 @@
1
+ require 'jsonapi'
2
+
3
+ describe JSONAPI, '#parse' do
4
+ it 'succeeds when there are no duplicates' do
5
+ payload = {
6
+ 'data' => [
7
+ {
8
+ 'type' => 'articles',
9
+ 'id' => '1',
10
+ 'attributes' => {
11
+ 'title' => 'JSON API paints my bikeshed!'
12
+ },
13
+ 'links' => {
14
+ 'self' => 'http://example.com/articles/1'
15
+ },
16
+ 'relationships' => {
17
+ 'author' => {
18
+ 'links' => {
19
+ 'self' => 'http://example.com/articles/1/relationships/author',
20
+ 'related' => 'http://example.com/articles/1/author'
21
+ },
22
+ 'data' => { 'type' => 'people', 'id' => '9' }
23
+ },
24
+ 'journal' => {
25
+ 'data' => nil
26
+ },
27
+ 'comments' => {
28
+ 'links' => {
29
+ 'self' => 'http://example.com/articles/1/relationships/comments',
30
+ 'related' => 'http://example.com/articles/1/comments'
31
+ },
32
+ 'data' => [
33
+ { 'type' => 'comments', 'id' => '5' },
34
+ { 'type' => 'comments', 'id' => '12' }
35
+ ]
36
+ }
37
+ }
38
+ }]
39
+ }
40
+
41
+ JSONAPI.parse(payload, verify_duplicates: true)
42
+ end
43
+
44
+ it 'fails when there are duplicates within primary data' do
45
+ payload = {
46
+ 'data' => [
47
+ {
48
+ 'type' => 'articles',
49
+ 'id' => '1'
50
+ }, {
51
+ 'type' => 'articles',
52
+ 'id' => '1'
53
+ }]
54
+ }
55
+
56
+ expect { JSONAPI.parse(payload, verify_duplicates: true) }
57
+ .to raise_error(JSONAPI::InvalidDocument)
58
+ end
59
+
60
+ it 'fails when there are duplicates within included' do
61
+ payload = {
62
+ 'data' => nil,
63
+ 'included' => [
64
+ {
65
+ 'type' => 'articles',
66
+ 'id' => '1'
67
+ }, {
68
+ 'type' => 'articles',
69
+ 'id' => '1'
70
+ }]
71
+ }
72
+
73
+ expect { JSONAPI.parse(payload, verify_duplicates: true) }
74
+ .to raise_error(JSONAPI::InvalidDocument)
75
+ end
76
+
77
+ it 'fails when there are duplicates within primary data' do
78
+ payload = {
79
+ 'data' => [
80
+ {
81
+ 'type' => 'articles',
82
+ 'id' => '1'
83
+ }
84
+ ],
85
+ 'included' => [
86
+ {
87
+ 'type' => 'articles',
88
+ 'id' => '1'
89
+ }]
90
+ }
91
+
92
+ expect { JSONAPI.parse(payload, verify_duplicates: true) }
93
+ .to raise_error(JSONAPI::InvalidDocument)
94
+ end
95
+ end
@@ -0,0 +1,161 @@
1
+ require 'jsonapi'
2
+
3
+ describe JSONAPI, '#parse' do
4
+ it 'succeeds when no included property is provided' do
5
+ payload = {
6
+ 'data' => [
7
+ {
8
+ 'type' => 'articles',
9
+ 'id' => '1',
10
+ 'attributes' => {
11
+ 'title' => 'JSON API paints my bikeshed!'
12
+ },
13
+ 'links' => {
14
+ 'self' => 'http://example.com/articles/1'
15
+ },
16
+ 'relationships' => {
17
+ 'author' => {
18
+ 'links' => {
19
+ 'self' => 'http://example.com/articles/1/relationships/author',
20
+ 'related' => 'http://example.com/articles/1/author'
21
+ },
22
+ 'data' => { 'type' => 'people', 'id' => '9' }
23
+ },
24
+ 'journal' => {
25
+ 'data' => nil
26
+ },
27
+ 'comments' => {
28
+ 'links' => {
29
+ 'self' => 'http://example.com/articles/1/relationships/comments',
30
+ 'related' => 'http://example.com/articles/1/comments'
31
+ },
32
+ 'data' => [
33
+ { 'type' => 'comments', 'id' => '5' },
34
+ { 'type' => 'comments', 'id' => '12' }
35
+ ]
36
+ }
37
+ }
38
+ }]
39
+ }
40
+
41
+ JSONAPI.parse(payload, verify_linkage: true)
42
+ end
43
+
44
+ it 'succeeds when full linkage is respected' do
45
+ payload = {
46
+ 'data' => [
47
+ {
48
+ 'type' => 'articles',
49
+ 'id' => '1',
50
+ 'attributes' => {
51
+ 'title' => 'JSON API paints my bikeshed!'
52
+ },
53
+ 'links' => {
54
+ 'self' => 'http://example.com/articles/1'
55
+ },
56
+ 'relationships' => {
57
+ 'author' => {
58
+ 'links' => {
59
+ 'self' => 'http://example.com/articles/1/relationships/author',
60
+ 'related' => 'http://example.com/articles/1/author'
61
+ },
62
+ 'data' => { 'type' => 'people', 'id' => '9' }
63
+ },
64
+ 'journal' => {
65
+ 'data' => nil
66
+ },
67
+ 'comments' => {
68
+ 'links' => {
69
+ 'self' => 'http://example.com/articles/1/relationships/comments',
70
+ 'related' => 'http://example.com/articles/1/comments'
71
+ },
72
+ 'data' => [
73
+ { 'type' => 'comments', 'id' => '5' },
74
+ { 'type' => 'comments', 'id' => '12' }
75
+ ]
76
+ }
77
+ }
78
+ }],
79
+ 'included' => [
80
+ {
81
+ 'type' => 'comments',
82
+ 'id' => '5'
83
+ }, {
84
+ 'type' => 'comments',
85
+ 'id' => '12'
86
+ }, {
87
+ 'type' => 'comments',
88
+ 'id' => '13'
89
+ }, {
90
+ 'type' => 'people',
91
+ 'id' => '9',
92
+ 'relationships' => {
93
+ 'comments' => {
94
+ 'data' => [
95
+ {
96
+ 'type' => 'comments',
97
+ 'id' => '13'
98
+ }
99
+ ]
100
+ }
101
+ }
102
+ }
103
+ ]
104
+ }
105
+
106
+ JSONAPI.parse(payload, verify_linkage: true)
107
+ end
108
+
109
+ it 'fails when full linkage is not respected' do
110
+ payload = {
111
+ 'data' => [
112
+ {
113
+ 'type' => 'articles',
114
+ 'id' => '1',
115
+ 'attributes' => {
116
+ 'title' => 'JSON API paints my bikeshed!'
117
+ },
118
+ 'links' => {
119
+ 'self' => 'http://example.com/articles/1'
120
+ },
121
+ 'relationships' => {
122
+ 'author' => {
123
+ 'links' => {
124
+ 'self' => 'http://example.com/articles/1/relationships/author',
125
+ 'related' => 'http://example.com/articles/1/author'
126
+ },
127
+ 'data' => { 'type' => 'people', 'id' => '9' }
128
+ },
129
+ 'journal' => {
130
+ 'data' => nil
131
+ },
132
+ 'comments' => {
133
+ 'links' => {
134
+ 'self' => 'http://example.com/articles/1/relationships/comments',
135
+ 'related' => 'http://example.com/articles/1/comments'
136
+ },
137
+ 'data' => [
138
+ { 'type' => 'comments', 'id' => '5' },
139
+ { 'type' => 'comments', 'id' => '12' }
140
+ ]
141
+ }
142
+ }
143
+ }],
144
+ 'included' => [
145
+ {
146
+ 'type' => 'comments',
147
+ 'id' => '5'
148
+ }, {
149
+ 'type' => 'comments',
150
+ 'id' => '12'
151
+ }, {
152
+ 'type' => 'comments',
153
+ 'id' => '13'
154
+ }
155
+ ]
156
+ }
157
+
158
+ expect { JSONAPI.parse(payload, verify_linkage: true) }
159
+ .to raise_error(JSONAPI::InvalidDocument)
160
+ end
161
+ end