iso-deserializer 0.8.0

Sign up to get free protection for your applications and to get access to all the features.
data/README.md ADDED
@@ -0,0 +1,641 @@
1
+ # Deserializer
2
+ ## Features
3
+ - Hash transformation and sanitization
4
+ - Deserialization of complex parameters into a hash that an AR model can take
5
+ - Avoid having multiple definitions in fragile arrays when using strong params
6
+ - Easy create and update from JSON without writing heavy controllers
7
+ - [ActiveModel::Serializer](https://github.com/rails-api/active_model_serializers)-like interface and conventions
8
+
9
+ ## Problem
10
+ Let's say we have an API with an endpoint that takes this JSON:
11
+
12
+ ```json
13
+ {
14
+ "restaurant_id" : 13,
15
+ "user_id" : 6,
16
+ "dish_name" : "risotto con funghi",
17
+ "description" : "repulsive beyond belief",
18
+ "ratings" : {
19
+ "taste" : "terrible",
20
+ "color" : "horrendous",
21
+ "texture" : "vile",
22
+ "smell" : "delightful, somehow"
23
+ }
24
+ }
25
+ ```
26
+
27
+ But this goes into a flat DishReview model:
28
+
29
+ ```ruby
30
+ t.belongs_to :restaurant
31
+ t.belongs_to :user
32
+ t.string :name # field name different from API (dish_name)
33
+ t.string :description
34
+ t.string :taste
35
+ t.string :color
36
+ t.string :texture
37
+ t.string :smell
38
+ ```
39
+
40
+ ### Solution (No `Deserializer`)
41
+ Permit some params, do some parsing and feed that into `DishReview.new`:
42
+
43
+ ```ruby
44
+ class DishReviewController < BaseController
45
+
46
+ def create
47
+ review_params = get_review_params(params)
48
+ @review = DishReview.new(review_params)
49
+ if @review.save
50
+ # return review
51
+ else
52
+ # return sad errors splody
53
+ end
54
+ end
55
+
56
+ # rest of RUD
57
+
58
+ protected
59
+
60
+ def permitted_params
61
+ [
62
+ :restaurant_id,
63
+ :user_id
64
+ :dish_name,
65
+ :description,
66
+ :taste,
67
+ :color,
68
+ :texture,
69
+ :smell
70
+ ]
71
+ end
72
+
73
+ def get_review_params(params)
74
+ review_params = params.require(:review)
75
+
76
+ review_params[:name] ||= review_params.delete(:dish_name)
77
+
78
+ ratings = review_params.delete(:ratings)
79
+ if (ratings.present?)
80
+ ratings.each{|rating, value| review_params[rating] = value if valid_rating?(rating) }
81
+ end
82
+
83
+ review_params.permit(permitted_params)
84
+ end
85
+
86
+ def valid_rating?(rating)
87
+ ["taste", "color", "texture", "smell"].include? rating
88
+ end
89
+ end
90
+ ```
91
+
92
+ #### What's up with that?
93
+ - You have to do this for every action
94
+ - Controllers are obese, hard to parse and fragile
95
+ - Controllers are doing non-controller-y things
96
+
97
+ ### Solution (With `Deserializer`)
98
+ `DishReviewDeserializer`:
99
+
100
+ ```ruby
101
+ module MyApi
102
+ module V1
103
+ class DishReviewDeserializer < Deserializer::Base
104
+ attributes :restaurant_id
105
+ :user_id
106
+ :description
107
+
108
+ attribute :name, key: :dish_name
109
+
110
+ has_one :ratings, :deserializer => RatingsDeserializer
111
+
112
+ def ratings
113
+ object
114
+ end
115
+
116
+ end
117
+ end
118
+ end
119
+ ```
120
+
121
+ `RatingsDeserializer`:
122
+
123
+ ```ruby
124
+ module MyApi
125
+ module V1
126
+ class RatingsDeserializer < Deserializer::Base
127
+
128
+ attributes :taste,
129
+ :color,
130
+ :texture,
131
+ :smell
132
+ end
133
+ end
134
+ end
135
+ ```
136
+
137
+ All of this allows your controller to be so very small:
138
+
139
+ ```ruby
140
+ class DishReviewsController < YourApiController::Base
141
+ def create
142
+ @review = DishReview.new( MyApi::V1::DishReviewDeserializer.from_params(params) )
143
+
144
+ if @review.save
145
+ # return review
146
+ else
147
+ # return sad errors splody
148
+ end
149
+ end
150
+
151
+ # RUD
152
+ end
153
+ ```
154
+
155
+ #### What's up with that?
156
+ - Un-pollutes controllers from all the parsing
157
+ - Builds deserializers that look like our serializers
158
+
159
+ ## Definition
160
+ Inherit from `Deserializer::Base` and define it in much the same way you would an [ActiveModel::Serializer](https://github.com/rails-api/active_model_serializers).
161
+
162
+ ### attributes
163
+ Use `attributes` for straight mapping from params to the model:
164
+
165
+ ```ruby
166
+ class PostDeserializer < Deserializer::Base
167
+ attributes :title,
168
+ :body
169
+ end
170
+ ```
171
+
172
+ ```ruby
173
+ # Example params
174
+ {
175
+ "title" => "lorem",
176
+ "body" => "ipsum"
177
+ }
178
+ # Resulting hash
179
+ {
180
+ title: "lorem",
181
+ body: "ipsum"
182
+ }
183
+ ```
184
+
185
+ ### attribute
186
+ Allows the following customizations for each `attribute`
187
+ #### :key
188
+
189
+ ```ruby
190
+ class PostDeserializer < Deserializer::Base
191
+ attribute :title, ignore_empty: true
192
+ attribute :body, key: :content
193
+ end
194
+ ```
195
+
196
+ `:content` here is what it will get in params while `:body` is what it will be inserted into the result.
197
+
198
+ ```ruby
199
+ # Example params
200
+ {
201
+ "title" => "lorem",
202
+ "content" => "ipsum"
203
+ }
204
+ # Resulting hash
205
+ {
206
+ title: "lorem",
207
+ body: "ipsum"
208
+ }
209
+ ```
210
+
211
+ #### :ignore_empty
212
+ While `Deserializer`'s default is to pass all values through, this option will drop any key with `false`/`nil`/`""`/`[]`/`{}` values from the result.
213
+
214
+ ```ruby
215
+ # Example params
216
+ {
217
+ "title" => "",
218
+ "text" => nil
219
+ }
220
+ # Resulting hash
221
+ {}
222
+ ```
223
+
224
+ #### :convert_with
225
+ Allows deserializing and converting a value at the same time. For example:
226
+
227
+ ```ruby
228
+ class Post < ActiveRecord::Base
229
+ belongs_to :post_type # this is a domain table
230
+ end
231
+ ```
232
+
233
+ If we serialize with
234
+
235
+ ```ruby
236
+ class PostSerializer < ActiveModel::Serializer
237
+ attribute :type
238
+
239
+ def type
240
+ object.post_type.symbolic_name
241
+ end
242
+ end
243
+ ```
244
+
245
+ Then, when we get a symbolic name from the controller but want to work with an id in the backend, we can:
246
+
247
+ ```ruby
248
+ class PostDeserializer < Deserializer::Base
249
+ attribute :title, ignore_empty: true
250
+ attribute :body
251
+ attribute :post_type_id, key: :type, convert_with: to_type_id
252
+
253
+ def to_type_id(value)
254
+ Type.find_by_symbolic_name.id
255
+ end
256
+ end
257
+ ```
258
+
259
+ ```ruby
260
+ # Example params
261
+ {
262
+ "title" => "lorem",
263
+ "body" => "ipsum",
264
+ "type" => "BLAGABLAG"
265
+ }
266
+ # Resulting hash
267
+ {
268
+ title: "lorem",
269
+ body: "ipsum",
270
+ post_type_id: 1
271
+ }
272
+ ```
273
+
274
+ ### has_one
275
+ `has_one` association expects a param and its deserializer:
276
+
277
+ ```ruby
278
+ class DishDeserializer < Deserializer::Base
279
+ # probably other stuff
280
+ has_one :ratings, deserializer: RatingsDeserializer
281
+ end
282
+
283
+ class RatingsDeserializer < Deserializer::Base
284
+ attributes :taste,
285
+ :smell
286
+ end
287
+ ```
288
+
289
+ ```ruby
290
+ # Example params
291
+ {
292
+ "ratings" => {
293
+ "taste" => "bad",
294
+ "smell" => "good"
295
+ }
296
+ }
297
+ # Resulting hash
298
+ {
299
+ ratings: {
300
+ taste: "bad",
301
+ smell: "good"
302
+ }
303
+ }
304
+ ```
305
+
306
+ #### Deserialize into a Different Name
307
+ In the example above, if `ratings` inside `Dish` is called `scores` in your ActiveRecord, you can:
308
+
309
+ ```ruby
310
+ class DishDeserializer < Deserializer::Base
311
+ has_one :ratings, deserializer: RatingsDeserializer
312
+
313
+ def ratings
314
+ :scores
315
+ end
316
+ end
317
+ ```
318
+
319
+ ```ruby
320
+ # Example params
321
+ {
322
+ "ratings" => {
323
+ "taste" => "bad",
324
+ "smell" => "good"
325
+ }
326
+ }
327
+ # Resulting hash
328
+ {
329
+ scores: {
330
+ taste: "bad",
331
+ smell: "good"
332
+ }
333
+ }
334
+ ```
335
+
336
+ #### Deserialize into Parent Object
337
+ To deserialize `ratings` into the `dish` object, you can use `object`:
338
+
339
+ ```ruby
340
+ class DishDeserializer < Deserializer::Base
341
+ has_one :ratings, deserializer: RatingsDeserializer
342
+
343
+ def ratings
344
+ object
345
+ end
346
+ end
347
+ ```
348
+
349
+ ```ruby
350
+ # Resulting hash
351
+ {
352
+ taste: "bad",
353
+ smell: "good"
354
+ }
355
+ ```
356
+
357
+ #### Deserialize into a Different Sub-object
358
+
359
+ ```ruby
360
+ class DishDeserializer < Deserializer::Base
361
+ has_one :colors, deserializer: ColorsDeserializer
362
+ has_one :ratings, deserializer: RatingsDeserializer
363
+
364
+ def colors
365
+ :ratings
366
+ end
367
+ end
368
+ ```
369
+
370
+ Given params:
371
+
372
+ ```ruby
373
+ # Example params
374
+ {
375
+ "ratings" =>
376
+ {
377
+ "taste" => "bad",
378
+ "smell" => "good"
379
+ },
380
+ "colors" =>
381
+ {
382
+ "color" => "red"
383
+ }
384
+ }
385
+ # Resulting hash
386
+ {
387
+ ratings: {
388
+ taste: "bad",
389
+ smell: "good",
390
+ color: "red"
391
+ }
392
+ }
393
+ ```
394
+
395
+ #### key
396
+
397
+ You can deserialize a `has_one` association into a different key from what the json gives you. For example:
398
+ ```json
399
+ {
400
+ id: 6,
401
+ name: "mac & cheese",
402
+ alias:
403
+ {
404
+ id: 83,
405
+ name: "macaroni and cheese"
406
+ }
407
+ }
408
+ ```
409
+
410
+ but your model is
411
+
412
+ ```ruby
413
+ class Dish
414
+ has_one :alias
415
+ accepted_nested_attributes_for :alias
416
+ end
417
+ ```
418
+ instead of renaming the hash in the controller, you can do
419
+
420
+ ```ruby
421
+ class DishDeserializer < Deserializer::Base
422
+ attributes :id,
423
+ :name
424
+
425
+ has_one :alias_attributes, deserializer: AliasDeserializer, key: :alias
426
+ end
427
+ ```
428
+
429
+ which would output
430
+
431
+ ```ruby
432
+ {
433
+ id: 6,
434
+ name: "mac & cheese",
435
+ alias_attributes:
436
+ {
437
+ id: 83,
438
+ name: "macaroni and cheese"
439
+ }
440
+ }
441
+ ```
442
+
443
+
444
+ ### has_many
445
+ `has_many` association expects a param and its deserializer:
446
+
447
+ ```ruby
448
+ class DishDeserializer < Deserializer::Base
449
+ # probably other stuff
450
+ has_many :ratings, deserializer: RatingsDeserializer
451
+ end
452
+
453
+ class RatingsDeserializer < Deserializer::Base
454
+ attributes :user_id,
455
+ :rating,
456
+ :comment
457
+ end
458
+ ```
459
+
460
+ ```ruby
461
+ # Example params
462
+ {
463
+ "ratings" => [
464
+ { "user_id" => 6,
465
+ "rating" => 3,
466
+ "comment" => "not bad"
467
+ },
468
+ { "user_id" => 25,
469
+ "rating" => 2,
470
+ "comment" => "gross"
471
+ }
472
+ ]
473
+ }
474
+ # Resulting hash
475
+ {
476
+ ratings: [
477
+ { user_id: 6,
478
+ rating: 3,
479
+ comment: "not bad"
480
+ },
481
+ { user_id: 25,
482
+ rating: 2,
483
+ comment: "gross"
484
+ }
485
+ ]
486
+ }
487
+ ```
488
+
489
+ #### key
490
+
491
+ You can deserialize a `has_many` association into a different key from what the json gives you. For example:
492
+ ```json
493
+ {
494
+ id: 6,
495
+ name: "mac & cheese",
496
+ aliases: [
497
+ {
498
+ id: 83,
499
+ name: "macaroni and cheese"
500
+ },
501
+ {
502
+ id: 86,
503
+ name: "cheesy pasta"
504
+ }
505
+ ]
506
+ }
507
+ ```
508
+
509
+ but your model is
510
+
511
+ ```ruby
512
+ class Dish
513
+ has_many :aliases
514
+ accepted_nested_attributes_for :aliases
515
+ end
516
+ ```
517
+ instead of renaming the hash in the controller, you can do
518
+
519
+ ```ruby
520
+ class DishDeserializer < Deserializer::Base
521
+ attributes :id,
522
+ :name
523
+
524
+ has_many :aliases_attributes, deserializer: AliasDeserializer, key: :aliases
525
+ end
526
+ ```
527
+
528
+ which would output
529
+
530
+ ```ruby
531
+ {
532
+ id: 6,
533
+ name: "mac & cheese",
534
+ aliases_attributes: [
535
+ {
536
+ id: 83,
537
+ name: "macaroni and cheese"
538
+ },
539
+ {
540
+ id: 86,
541
+ name: "cheesy pasta"
542
+ }
543
+ ]
544
+ }
545
+ ```
546
+
547
+ ### nests
548
+ Sometimes you get a flat param list, but want it to be nested for `updated_nested_attributes`
549
+
550
+ If you have 2 models that look like
551
+
552
+ ```ruby
553
+ class RestaurantLocation
554
+ belongs_to :address
555
+ # t.string :name
556
+ end
557
+
558
+ # where Address is something like
559
+ t.string :line_1
560
+ t.string :line_2
561
+ t.string :city
562
+ t.string :state
563
+ ```
564
+
565
+ And you want to update them at the same time, as they're closely tied, `nests` lets you define
566
+
567
+ ```ruby
568
+ class ResaturantLocationDeserializer < Deserializer::Base
569
+ attribute :name
570
+
571
+ nests :address, deserializer: AddressDeserializer
572
+ end
573
+
574
+ class AddressDeserializer
575
+ attributes :line_1,
576
+ :line_2,
577
+ :city,
578
+ :state
579
+ end
580
+ ```
581
+ And now you can take a single block of json
582
+
583
+ ```ruby
584
+ # Example params into restaurant_location endpoint
585
+ {
586
+ "name" => "Little Caesars: Et Two Brute",
587
+ "line_1" => "2 Brute St.",
588
+ "city" => "Seattle",
589
+ "state" => "WA"
590
+ }
591
+
592
+ # Resulting hash
593
+ {
594
+ name: "Little Caesars: Et Two Brute",
595
+ address: {
596
+ line_1: "2 Brute St",
597
+ city: "Seattle",
598
+ state: "WA"
599
+ }
600
+ }
601
+
602
+ ```
603
+
604
+
605
+
606
+ ## Functions
607
+ ### from_params
608
+ `MyDeserializer.from_params(params)` creates the JSON that your AR model will then consume.
609
+
610
+ ```ruby
611
+ @review = DishReview.new( MyApi::V1::DishReviewDeserializer.from_params(params) )
612
+ ```
613
+
614
+ ### permitted_params
615
+ Just call `MyDeserializer.permitted_params` and you'll have the full array of keys you expect params to have.
616
+
617
+ ## Installation
618
+ Add this line to your application's Gemfile:
619
+
620
+ ```
621
+ gem 'deserializer'
622
+ ```
623
+
624
+ And then execute:
625
+
626
+ ```
627
+ $ bundle
628
+ ```
629
+
630
+ Or install it yourself as:
631
+
632
+ ```
633
+ $ gem install deserializer
634
+ ```
635
+
636
+ ## Contributing
637
+ 1. Fork it ( [https://github.com/gaorlov/deserializer/fork](https://github.com/gaorlov/deserializer/fork) )
638
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
639
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
640
+ 4. Push to the branch (`git push origin my-new-feature`)
641
+ 5. Create a new Pull Request
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ require 'rake/testtask'
2
+
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << 'lib'
6
+ t.libs << 'test'
7
+ t.pattern = 'test/**/*_test.rb'
8
+ t.verbose = false
9
+ end
10
+
11
+ task default: :test
@@ -0,0 +1,25 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'deserializer/version'
5
+
6
+ Gem::Specification.new do |s|
7
+ s.name = "iso-deserializer"
8
+ s.version = Deserializer::VERSION
9
+ s.authors = ["Isometric"]
10
+ s.email = ["andy@iso.io"]
11
+ s.homepage = "https://github.com/gaorlov/deserializer"
12
+ s.summary = "deserialization"
13
+ s.description = "conversion from complexy write params to a json blob that an AR model can consume"
14
+ s.license = "MIT"
15
+
16
+ s.files = `git ls-files -z`.split("\x0")
17
+ s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ s.test_files = s.files.grep(%r{^(test|s|features)/})
19
+ s.require_paths = ["lib"]
20
+
21
+ s.add_dependency "activesupport", ">= 5.0.0"
22
+
23
+ s.add_development_dependency "rake"
24
+ s.add_development_dependency "m", "~> 1.3.1"
25
+ end
@@ -0,0 +1,8 @@
1
+ source "https://rubygems.org"
2
+
3
+ gem "rake"
4
+ gem "activesupport", ">= 4.0.0"
5
+ gem 'simplecov', :group => :test
6
+ gem 'minitest', :group => :test
7
+
8
+ gemspec :path => "../"
@@ -0,0 +1,8 @@
1
+ source "https://rubygems.org"
2
+
3
+ gem "rake"
4
+ gem "activesupport", ">= 5.0.0"
5
+ gem 'simplecov', :group => :test
6
+ gem 'minitest', :group => :test
7
+
8
+ gemspec :path => "../"
@@ -0,0 +1,8 @@
1
+ source "https://rubygems.org"
2
+
3
+ gem "rake"
4
+ gem "activesupport", ">= 6.0.0"
5
+ gem 'simplecov', :group => :test
6
+ gem 'minitest', :group => :test
7
+
8
+ gemspec :path => "../"