iso-deserializer 0.8.0

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.
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 => "../"