roar-jsonapi 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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