jsonapi 0.1.1.beta1

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.
@@ -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