roar-jsonapi 0.0.1

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,98 @@
1
+ module Roar
2
+ module JSON
3
+ module JSONAPI
4
+ # @api private
5
+ module Options
6
+ # Transforms `field:` and `include:`` options to their internal
7
+ # equivalents.
8
+ #
9
+ # @see Document#to_hash
10
+ class Include
11
+ DEFAULT_INTERNAL_INCLUDES = [:attributes, :relationships].freeze
12
+
13
+ def self.call(options, mappings)
14
+ new.(options, mappings)
15
+ end
16
+
17
+ def call(options, mappings)
18
+ include, fields = *options.values_at(:include, :fields)
19
+ return options if options[:_json_api_parsed] || !(include || fields)
20
+
21
+ internal_options = {}
22
+ rewrite_include_option!(internal_options, include,
23
+ mappings.fetch(:id, :id))
24
+ rewrite_fields!(internal_options, fields,
25
+ mappings.fetch(:relationships, {}))
26
+
27
+ options.reject { |key, _| [:include, :fields].include?(key) }
28
+ .merge(internal_options)
29
+ end
30
+
31
+ private
32
+
33
+ def rewrite_include_option!(options, include, id_mapping)
34
+ include_paths = parse_include_option(include)
35
+ default_includes = [id_mapping.to_sym] + DEFAULT_INTERNAL_INCLUDES
36
+ options[:include] = default_includes + [:included]
37
+ options[:included] = { include: include_paths.map(&:first) - [:_self] }
38
+ include_paths.each do |include_path|
39
+ options[:included].merge!(
40
+ explode_include_path(*include_path, default_includes)
41
+ )
42
+ end
43
+ options
44
+ end
45
+
46
+ def rewrite_fields!(options, fields, rel_mappings)
47
+ (fields || {}).each do |type, raw_value|
48
+ fields_value = parse_fields_value(raw_value)
49
+ relationship_name = (rel_mappings.key(type.to_s) || type).to_sym
50
+ if relationship_name == :_self
51
+ options[:attributes] = { include: fields_value }
52
+ options[:relationships] = { include: fields_value }
53
+ else
54
+ options[:included][relationship_name] ||= {}
55
+ options[:included][relationship_name].merge!(
56
+ attributes: { include: fields_value },
57
+ relationships: { include: fields_value },
58
+ _json_api_parsed: true # flag to halt recursive parsing
59
+ )
60
+ end
61
+ end
62
+ end
63
+
64
+ def parse_include_option(include_value)
65
+ Array(include_value).flat_map { |i| i.to_s.split(',') }.map { |path|
66
+ path.split('.').map(&:to_sym)
67
+ }
68
+ end
69
+
70
+ def parse_fields_value(fields_value)
71
+ Array(fields_value).flat_map { |v| v.to_s.split(',') }.map(&:to_sym)
72
+ end
73
+
74
+ def explode_include_path(*include_path, default_includes)
75
+ head, *tail = *include_path
76
+ hash = {}
77
+ result = hash[head] ||= {
78
+ include: default_includes.dup, _json_api_parsed: true
79
+ }
80
+
81
+ tail.each do |key|
82
+ break unless result[:included].nil?
83
+
84
+ result[:include] << :included
85
+ result[:included] ||= {}
86
+
87
+ result = result[:included][key] ||= {
88
+ include: default_includes.dup, _json_api_parsed: true
89
+ }
90
+ end
91
+
92
+ hash
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,7 @@
1
+ module Roar
2
+ module JSON
3
+ module JSONAPI
4
+ VERSION = '0.0.1'.freeze
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,25 @@
1
+ $LOAD_PATH.push File.expand_path('../lib', __FILE__)
2
+ require 'roar/json/json_api/version'
3
+
4
+ Gem::Specification.new do |s|
5
+ s.name = 'roar-jsonapi'
6
+ s.version = Roar::JSON::JSONAPI::VERSION
7
+ s.platform = Gem::Platform::RUBY
8
+ s.authors = ['Nick Sutterer', 'Alex Coles']
9
+ s.email = ['apotonick@gmail.com', 'alex@alexbcoles.com']
10
+ s.homepage = 'http://trailblazer.to/gems/roar/jsonapi.html'
11
+ s.summary = 'Parse and render JSON API documents using representers.'
12
+ s.description = 'Object-oriented representers help you define nested JSON API documents which can then be rendered and parsed using one and the same concept.'
13
+ s.license = 'MIT'
14
+
15
+ s.files = `git ls-files`.split("\n")
16
+ s.test_files = `git ls-files -- {test}/*`.split("\n")
17
+ s.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) }
18
+ s.require_paths = ['lib']
19
+
20
+ s.add_runtime_dependency 'roar', '~> 1.1'
21
+
22
+ s.add_development_dependency 'rake', '>= 0.10.1'
23
+ s.add_development_dependency 'minitest', '>= 5.10'
24
+ s.add_development_dependency 'multi_json'
25
+ end
@@ -0,0 +1,399 @@
1
+ require 'test_helper'
2
+ require 'roar/json/json_api'
3
+ require 'json'
4
+
5
+ class JsonapiCollectionRenderTest < MiniTest::Spec
6
+ let(:article) { Article.new(1, 'Health walk', Author.new(2), Author.new('editor:1'), [Comment.new('comment:1', 'Ice and Snow'), Comment.new('comment:2', 'Red Stripe Skank')]) }
7
+ let(:article2) { Article.new(2, 'Virgin Ska', Author.new('author:1'), nil, [Comment.new('comment:3', 'Cool song!')]) }
8
+ let(:article3) { Article.new(3, 'Gramo echo', Author.new('author:1'), nil, [Comment.new('comment:4', 'Skalar')]) }
9
+ let(:decorator) { ArticleDecorator.for_collection.new([article, article2, article3]) }
10
+
11
+ it 'renders full document' do
12
+ json = decorator.to_json
13
+ json.must_equal_json(%({
14
+ "data": [{
15
+ "type": "articles",
16
+ "id": "1",
17
+ "attributes": {
18
+ "title": "Health walk"
19
+ },
20
+ "relationships": {
21
+ "author": {
22
+ "data": {
23
+ "type": "authors",
24
+ "id": "2"
25
+ },
26
+ "links": {
27
+ "self": "/articles/1/relationships/author",
28
+ "related": "/articles/1/author"
29
+ }
30
+ },
31
+ "editor": {
32
+ "data": {
33
+ "type": "editors",
34
+ "id": "editor:1"
35
+ },
36
+ "meta": {
37
+ "peer-reviewed": false
38
+ }
39
+ },
40
+ "comments": {
41
+ "data": [{
42
+ "type": "comments",
43
+ "id": "comment:1"
44
+ }, {
45
+ "type": "comments",
46
+ "id": "comment:2"
47
+ }],
48
+ "links": {
49
+ "self": "/articles/1/relationships/comments",
50
+ "related": "/articles/1/comments"
51
+ },
52
+ "meta": {
53
+ "comment-count": 5
54
+ }
55
+ }
56
+ },
57
+ "links": {
58
+ "self": "http://Article/1"
59
+ },
60
+ "meta": {
61
+ "reviewers": ["Christian Bernstein"],
62
+ "reviewer-initials": "C.B."
63
+ }
64
+ }, {
65
+ "type": "articles",
66
+ "id": "2",
67
+ "attributes": {
68
+ "title": "Virgin Ska"
69
+ },
70
+ "relationships": {
71
+ "author": {
72
+ "data": {
73
+ "type": "authors",
74
+ "id": "author:1"
75
+ },
76
+ "links": {
77
+ "self": "/articles/2/relationships/author",
78
+ "related": "/articles/2/author"
79
+ }
80
+ },
81
+ "editor": {
82
+ "data": null,
83
+ "meta": {
84
+ "peer-reviewed": false
85
+ }
86
+ },
87
+ "comments": {
88
+ "data": [{
89
+ "type": "comments",
90
+ "id": "comment:3"
91
+ }],
92
+ "links": {
93
+ "self": "/articles/2/relationships/comments",
94
+ "related": "/articles/2/comments"
95
+ },
96
+ "meta": {
97
+ "comment-count": 5
98
+ }
99
+ }
100
+ },
101
+ "links": {
102
+ "self": "http://Article/2"
103
+ },
104
+ "meta": {
105
+ "reviewers": ["Christian Bernstein"],
106
+ "reviewer-initials": "C.B."
107
+ }
108
+ }, {
109
+ "type": "articles",
110
+ "id": "3",
111
+ "attributes": {
112
+ "title": "Gramo echo"
113
+ },
114
+ "relationships": {
115
+ "author": {
116
+ "data": {
117
+ "type": "authors",
118
+ "id": "author:1"
119
+ },
120
+ "links": {
121
+ "self": "/articles/3/relationships/author",
122
+ "related": "/articles/3/author"
123
+ }
124
+ },
125
+ "editor": {
126
+ "data": null,
127
+ "meta": {
128
+ "peer-reviewed": false
129
+ }
130
+ },
131
+ "comments": {
132
+ "data": [{
133
+ "type": "comments",
134
+ "id": "comment:4"
135
+ }],
136
+ "links": {
137
+ "self": "/articles/3/relationships/comments",
138
+ "related": "/articles/3/comments"
139
+ },
140
+ "meta": {
141
+ "comment-count": 5
142
+ }
143
+ }
144
+ },
145
+ "links": {
146
+ "self": "http://Article/3"
147
+ },
148
+ "meta": {
149
+ "reviewers": ["Christian Bernstein"],
150
+ "reviewer-initials": "C.B."
151
+ }
152
+ }],
153
+ "links": {
154
+ "self": "//articles"
155
+ },
156
+ "meta": {
157
+ "count": 3
158
+ },
159
+ "included": [{
160
+ "type": "authors",
161
+ "id": "2",
162
+ "links": {
163
+ "self": "http://authors/2"
164
+ }
165
+ }, {
166
+ "type": "editors",
167
+ "id": "editor:1"
168
+ }, {
169
+ "type": "comments",
170
+ "id": "comment:1",
171
+ "attributes": {
172
+ "body": "Ice and Snow"
173
+ },
174
+ "links": {
175
+ "self": "http://comments/comment:1"
176
+ }
177
+ }, {
178
+ "type": "comments",
179
+ "id": "comment:2",
180
+ "attributes": {
181
+ "body": "Red Stripe Skank"
182
+ },
183
+ "links": {
184
+ "self": "http://comments/comment:2"
185
+ }
186
+ }, {
187
+ "type": "authors",
188
+ "id": "author:1",
189
+ "links": {
190
+ "self": "http://authors/author:1"
191
+ }
192
+ }, {
193
+ "type": "comments",
194
+ "id": "comment:3",
195
+ "attributes": {
196
+ "body": "Cool song!"
197
+ },
198
+ "links": {
199
+ "self": "http://comments/comment:3"
200
+ }
201
+ }, {
202
+ "type": "comments",
203
+ "id": "comment:4",
204
+ "attributes": {
205
+ "body": "Skalar"
206
+ },
207
+ "links": {
208
+ "self": "http://comments/comment:4"
209
+ }
210
+ }]
211
+ }))
212
+ end
213
+
214
+ it 'included: false suppresses compound docs' do
215
+ json = decorator.to_json(included: false)
216
+ json.must_equal_json(%({
217
+ "data": [{
218
+ "type": "articles",
219
+ "id": "1",
220
+ "attributes": {
221
+ "title": "Health walk"
222
+ },
223
+ "relationships": {
224
+ "author": {
225
+ "data": {
226
+ "type": "authors",
227
+ "id": "2"
228
+ },
229
+ "links": {
230
+ "self": "/articles/1/relationships/author",
231
+ "related": "/articles/1/author"
232
+ }
233
+ },
234
+ "editor": {
235
+ "data": {
236
+ "type": "editors",
237
+ "id": "editor:1"
238
+ },
239
+ "meta": {
240
+ "peer-reviewed": false
241
+ }
242
+ },
243
+ "comments": {
244
+ "data": [{
245
+ "type": "comments",
246
+ "id": "comment:1"
247
+ }, {
248
+ "type": "comments",
249
+ "id": "comment:2"
250
+ }],
251
+ "links": {
252
+ "self": "/articles/1/relationships/comments",
253
+ "related": "/articles/1/comments"
254
+ },
255
+ "meta": {
256
+ "comment-count": 5
257
+ }
258
+ }
259
+ },
260
+ "links": {
261
+ "self": "http://Article/1"
262
+ },
263
+ "meta": {
264
+ "reviewers": ["Christian Bernstein"],
265
+ "reviewer-initials": "C.B."
266
+ }
267
+ }, {
268
+ "type": "articles",
269
+ "id": "2",
270
+ "attributes": {
271
+ "title": "Virgin Ska"
272
+ },
273
+ "relationships": {
274
+ "author": {
275
+ "data": {
276
+ "type": "authors",
277
+ "id": "author:1"
278
+ },
279
+ "links": {
280
+ "self": "/articles/2/relationships/author",
281
+ "related": "/articles/2/author"
282
+ }
283
+ },
284
+ "editor": {
285
+ "data": null,
286
+ "meta": {
287
+ "peer-reviewed": false
288
+ }
289
+ },
290
+ "comments": {
291
+ "data": [{
292
+ "type": "comments",
293
+ "id": "comment:3"
294
+ }],
295
+ "links": {
296
+ "self": "/articles/2/relationships/comments",
297
+ "related": "/articles/2/comments"
298
+ },
299
+ "meta": {
300
+ "comment-count": 5
301
+ }
302
+ }
303
+ },
304
+ "links": {
305
+ "self": "http://Article/2"
306
+ },
307
+ "meta": {
308
+ "reviewers": ["Christian Bernstein"],
309
+ "reviewer-initials": "C.B."
310
+ }
311
+ }, {
312
+ "type": "articles",
313
+ "id": "3",
314
+ "attributes": {
315
+ "title": "Gramo echo"
316
+ },
317
+ "relationships": {
318
+ "author": {
319
+ "data": {
320
+ "type": "authors",
321
+ "id": "author:1"
322
+ },
323
+ "links": {
324
+ "self": "/articles/3/relationships/author",
325
+ "related": "/articles/3/author"
326
+ }
327
+ },
328
+ "editor": {
329
+ "data": null,
330
+ "meta": {
331
+ "peer-reviewed": false
332
+ }
333
+ },
334
+ "comments": {
335
+ "data": [{
336
+ "type": "comments",
337
+ "id": "comment:4"
338
+ }],
339
+ "links": {
340
+ "self": "/articles/3/relationships/comments",
341
+ "related": "/articles/3/comments"
342
+ },
343
+ "meta": {
344
+ "comment-count": 5
345
+ }
346
+ }
347
+ },
348
+ "links": {
349
+ "self": "http://Article/3"
350
+ },
351
+ "meta": {
352
+ "reviewers": ["Christian Bernstein"],
353
+ "reviewer-initials": "C.B."
354
+ }
355
+ }],
356
+ "links": {
357
+ "self": "//articles"
358
+ },
359
+ "meta": {
360
+ "count": 3
361
+ }
362
+ }))
363
+ end
364
+
365
+ it 'passes :user_options to toplevel links when rendering' do
366
+ hash = decorator.to_hash(user_options: { page: 2, per_page: 10 })
367
+ hash['links'].must_equal('self' => '//articles?page=2&per_page=10')
368
+ end
369
+
370
+ it 'renders additional meta information if meta option supplied' do
371
+ hash = decorator.to_hash(meta: { page: 2, total: 9 })
372
+ hash['meta'].must_equal('count' => 3, page: 2, total: 9)
373
+ end
374
+
375
+ it 'does not render additional meta information if meta option is empty' do
376
+ hash = decorator.to_hash(meta: {})
377
+ hash['meta'][:page].must_be_nil
378
+ hash['meta'][:total].must_be_nil
379
+ end
380
+
381
+ describe 'Fetching Resources (empty collection)' do
382
+ let(:document) {
383
+ %({
384
+ "data": [],
385
+ "links": {
386
+ "self": "//articles"
387
+ },
388
+ "meta": {
389
+ "count": 0
390
+ }
391
+ })
392
+ }
393
+
394
+ let(:articles) { [] }
395
+ subject { ArticleDecorator.for_collection.new(articles).to_json }
396
+
397
+ it { subject.must_equal_json document }
398
+ end
399
+ end