serega 0.7.0 → 0.8.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '001918eece6824f604d5bdd2a43321346d474355fd629759eaa894dabf94d7f4'
4
- data.tar.gz: e68ee37be935e8c4a9bc2eca849a6d56b68feb56155519a612976cddd2013871
3
+ metadata.gz: 0f1dd539e1a0b60a167cb67c5e8bfb509d5ce3be4d8ce8151a97a5f646f53378
4
+ data.tar.gz: 39ed8e53b95cc192a0819fbc4452d2cb6951b9208566d0597802cd98b828669e
5
5
  SHA512:
6
- metadata.gz: 4683bc9c753fbbcfc40c31e7a2833bddfe3a01711067a718ad9a01f263d8e7e576b768432e33dcaad688ddd2d3017e60ebfc3111d22a661d6f670ae465f9210c
7
- data.tar.gz: ad5fd5c647484d9b68053e28a0e8a728e285727b7419e797cf89f73ea00abaf226c6c59d137afb7ba41b47fc1490b4948e11128d059257ed431da0574657998e
6
+ metadata.gz: 7b6519e8fb225d6bc132c5144b2268b8b7866e030a3c7a582febac6fc468a3cae449cf9298e05b5468c31c8a975629891e196ee0f1537cee401ceab394c573a2
7
+ data.tar.gz: bef92a58a8f730af6e97c3a36dbf6062aa7e3b99ad5c44484682447063018ec9dbcb345cc4715d1637a3d6c4c388f4b794a144b8250986db19efbe961b92c472
data/README.md ADDED
@@ -0,0 +1,650 @@
1
+ [![Gem Version](https://badge.fury.io/rb/serega.svg)](https://badge.fury.io/rb/serega)
2
+ [![GitHub Actions](https://github.com/aglushkov/serega/actions/workflows/main.yml/badge.svg?event=push)](https://github.com/aglushkov/serega/actions/workflows/main.yml)
3
+ [![Test Coverage](https://api.codeclimate.com/v1/badges/f10c0659e16e25e49faa/test_coverage)](https://codeclimate.com/github/aglushkov/serega/test_coverage)
4
+ [![Maintainability](https://api.codeclimate.com/v1/badges/f10c0659e16e25e49faa/maintainability)](https://codeclimate.com/github/aglushkov/serega/maintainability)
5
+
6
+ # Serega Ruby Serializer
7
+
8
+ The Serega Ruby Serializer provides easy and powerful DSL to describe your objects and to serialize them to Hash or JSON.
9
+
10
+ ---
11
+
12
+ 📌 Serega does not depend on any gem and works with any framework
13
+
14
+ ---
15
+
16
+ It has some great features:
17
+
18
+ - Manually [select serialized fields](#selecting-fields)
19
+ - Solutions for N+1 problem (via [batch][batch], [preloads][preloads] or [activerecord_preloads][activerecord_preloads] plugins)
20
+ - Built-in object presenter ([presenter][presenter] plugin)
21
+ - Adding custom metadata (via [metadata][metadata] or [context_metadata][context_metadata] plugins)
22
+ - Attributes formatters ([formatters][formatters] plugin)
23
+
24
+ ## Installation
25
+
26
+ `bundle add serega`
27
+
28
+
29
+ ### Define serializers
30
+
31
+ Most apps should define **base serializer** with common plugins and settings to not repeat them in each serializer.
32
+ Children serializers will inherit everything (plugins, config, attributes) from parent.
33
+
34
+ ```ruby
35
+ class AppSerializer < Serega
36
+ # plugin :one
37
+ # plugin :two
38
+
39
+ # config.one = :one
40
+ # config.two = :two
41
+ end
42
+
43
+ class UserSerializer < AppSerializer
44
+ # attribute :one
45
+ # attribute :two
46
+ end
47
+
48
+ class CommentSerializer < AppSerializer
49
+ # attribute :one
50
+ # attribute :two
51
+ end
52
+ ```
53
+
54
+ ### Adding attributes
55
+
56
+ ```ruby
57
+ class UserSerializer < Serega
58
+ # Regular attribute
59
+ attribute :first_name
60
+
61
+ # Option :key specifies method in object
62
+ attribute :first_name, key: :old_first_name
63
+
64
+ # Block is used to define attribute value
65
+ attribute(:first_name) { |user| user.profile&.first_name }
66
+
67
+ # Option :value can be used with callable object to define attribute value
68
+ attribute :first_name, value: proc { |user| user.profile&.first_name }
69
+
70
+ # Option :delegate can be used to define attribute value. Sub-option :allow_nil by default is false
71
+ attribute :first_name, delegate: { to: :profile, allow_nil: true }
72
+
73
+ # Option :delegate can be used with :key sub-option to change method called on delegated object
74
+ attribute :first_name, delegate: { to: :profile, key: :fname }
75
+
76
+ # Option :const specifies attribute with specific constant value
77
+ attribute(:type, const: 'user')
78
+
79
+ # Option :hide specifies attributes that should not be serialized by default
80
+ attribute :tags, hide: true
81
+
82
+ # Option :serializer specifies nested serializer for attribute
83
+ # We can specify serializer as Class, String or Proc.
84
+ # Use String or Proc if you have cross references in serializers.
85
+ attribute :posts, serializer: PostSerializer
86
+ attribute :posts, serializer: "PostSerializer"
87
+ attribute :posts, serializer: -> { PostSerializer }
88
+
89
+ # Option `:many` specifies a has_many relationship
90
+ # Usually it is defined automatically by checking `is_a?(Enumerable)`
91
+ attribute :posts, serializer: PostSerializer, many: true
92
+
93
+ # Option `:preload` can be specified when enabled `:preloads` plugin
94
+ # It allows to specify associations to preload to attribute value
95
+ attribute :email, preload: :emails, value: proc { |user| user.emails.find(&:verified?) }
96
+
97
+ # Option `:hide_nil` can be specified when enabled `:hide_nil` plugin
98
+ # It is literally hides attribute if its value is nil
99
+ attribute :email, hide_nil: true
100
+
101
+ # Option `:format` can be specified when enabled `:formatters` plugin
102
+ # It changes attribute value
103
+ attribute :created_at, format: :iso_time
104
+ attribute :updated_at, format: :iso_time
105
+
106
+ # Option `:format` also can be used as Proc
107
+ attribute :created_at, format: proc { |time| time.strftime("%Y-%m-%d")}
108
+ end
109
+ ```
110
+
111
+ ### Serializing
112
+
113
+ We can serialize objects using class methods `.to_h`, `.to_json`, `.as_json` and same instance methods `#to_h`, `#to_json`, `#as_json`.
114
+ `to_h` method is also aliased as `call`.
115
+
116
+
117
+ ```ruby
118
+ user = OpenStruct.new(username: 'serega')
119
+
120
+ class UserSerializer < Serega
121
+ attribute :username
122
+ end
123
+
124
+ UserSerializer.to_h(user) # => {username: "serega"}
125
+ UserSerializer.to_h([user]) # => [{username: "serega"}]
126
+
127
+ UserSerializer.to_json(user) # => '{"username":"serega"}'
128
+ UserSerializer.to_json([user]) # => '[{"username":"serega"}]'
129
+
130
+ UserSerializer.as_json(user) # => {"username":"serega"}
131
+ UserSerializer.as_json([user]) # => [{"username":"serega"}]
132
+ ```
133
+
134
+ ---
135
+ ⚠️ When you serialize `Struct` object, specify manually `many: false`. As Struct is Enumerable and we check `object.is_a?(Enumerable)` to detect if we should return array.
136
+
137
+ ```ruby
138
+ UserSerializer.to_h(user_struct, many: false)
139
+ ```
140
+
141
+ ### Selecting Fields
142
+
143
+ By default all attributes are serialized (except marked as `hide: true`).
144
+
145
+ We can provide **modifiers** to select only needed attributes:
146
+
147
+ - *only* - lists attributes to serialize;
148
+ - *except* - lists attributes to not serialize;
149
+ - *with* - lists attributes to serialize additionally (By default all attributes are exposed and will be serialized, but some attributes can be hidden when they are defined with `hide: true` option, more on this below. `with` modifier can be used to expose such attributes).
150
+
151
+ Modifiers can be provided as Hash, Array, String, Symbol or their combinations.
152
+
153
+ With plugin [string_modifiers][string_modifiers] we can provide modifiers as single `String` with attributes split by comma `,` and nested values inside brackets `()`, like: `username,enemies(username,email)`. This can be very useful to accept list of fields in **GET** requests.
154
+
155
+ When provided non-existing attribute, `Serega::AttributeNotExist` error will be raised. This error can be muted with `check_initiate_params: false` parameter.
156
+
157
+ ```ruby
158
+ class UserSerializer < Serega
159
+ plugin :string_modifiers # to send all modifiers in one string
160
+
161
+ attribute :username
162
+ attribute :first_name
163
+ attribute :last_name
164
+ attribute :email, hide: true
165
+ attribute :enemies, serializer: UserSerializer, hide: true
166
+ end
167
+
168
+ joker = OpenStruct.new(username: 'The Joker', first_name: 'jack', last_name: 'Oswald White', email: 'joker@mail.com', enemies: [])
169
+ bruce = OpenStruct.new(username: 'Batman', first_name: 'Bruce', last_name: 'Wayne', email: 'bruce@wayneenterprises.com', enemies: [])
170
+ joker.enemies << bruce
171
+ bruce.enemies << joker
172
+
173
+ # Default
174
+ UserSerializer.to_h(bruce) # => {:username=>"Batman", :first_name=>"Bruce", :last_name=>"Wayne"}
175
+
176
+ # With `:only` modifier
177
+ # Next 3 lines are identical:
178
+ UserSerializer.to_h(bruce, only: [:username, { enemies: [:username, :email] }])
179
+ UserSerializer.new(only: [:username, { enemies: [:username, :email] }]).to_h(bruce)
180
+ UserSerializer.new(only: 'username,enemies(username,email)').to_h(bruce)
181
+ # => {:username=>"Batman", :enemies=>[{:username=>"The Joker", :email=>"joker@mail.com"}]}
182
+
183
+ # With `:except` modifier
184
+ # Next 3 lines are identical:
185
+ UserSerializer.new(except: %i[first_name last_name]).to_h(bruce)
186
+ UserSerializer.to_h(bruce, except: %i[first_name last_name])
187
+ UserSerializer.to_h(bruce, except: 'first_name,last_name')
188
+ # => {:username=>"Batman"}
189
+
190
+ # With `:with` modifier
191
+ # Next 3 lines are identical:
192
+ UserSerializer.new(with: %i[email enemies]).to_h(bruce)
193
+ UserSerializer.to_h(bruce, with: %i[email enemies])
194
+ UserSerializer.to_h(bruce, with: 'email,enemies')
195
+ # => {:username=>"Batman", :first_name=>"Bruce", :last_name=>"Wayne", :email=>"bruce@wayneenterprises.com", :enemies=>[{:username=>"The Joker", :first_name=>"jack", :last_name=>"Oswald White"}]}
196
+
197
+ # With not existing attribute
198
+ # Next 3 lines are identical:
199
+ UserSerializer.new(only: %i[first_name enemy]).to_h(bruce)
200
+ UserSerializer.to_h(bruce, only: %i[first_name enemy])
201
+ UserSerializer.to_h(bruce, only: 'first_name,enemy')
202
+ # => raises Serega::AttributeNotExist, "Attribute 'enemy' not exists"
203
+
204
+ # With not existing attribute and disabled validation
205
+ # Next 3 lines are identical:
206
+ UserSerializer.new(only: %i[first_name enemy], check_initiate_params: false).to_h(bruce)
207
+ UserSerializer.to_h(bruce, only: %i[first_name enemy], check_initiate_params: false)
208
+ UserSerializer.to_h(bruce, only: 'first_name,enemy', check_initiate_params: false)
209
+ # => {:first_name=>"Bruce"}
210
+ ```
211
+
212
+ ### Using Context
213
+
214
+ Sometimes you can decide to use some context during serialization, like current_user or any.
215
+
216
+ ```ruby
217
+ class UserSerializer < Serega
218
+ attribute(:email) do |user, ctx|
219
+ user.email if ctx[:current_user] == user
220
+ end
221
+ end
222
+
223
+ user = OpenStruct.new(email: 'email@example.com')
224
+ UserSerializer.(user, context: {current_user: user}) # => {:email=>"email@example.com"}
225
+ UserSerializer.new.to_h(user, context: {current_user: user}) # same
226
+ ```
227
+
228
+ ## Configuration
229
+
230
+ This is initial config options, other config options can be added by plugins
231
+
232
+ ```ruby
233
+ class AppSerializer < Serega
234
+ # Configure adapter to serialize to JSON.
235
+ # It is `JSON.dump` by default. When Oj gem is loaded then default is `Oj.dump(data, mode: :compat)`
236
+ config.to_json = ->(data) { Oj.dump(data, mode: :compat) }
237
+
238
+ # Configure adapter to de-serialize JSON. De-serialization is used only for `#as_json` method.
239
+ # It is `JSON.parse` by default. When Oj gem is loaded then default is `Oj.load(data)`
240
+ config.from_json = ->(data) { Oj.load(data) }
241
+
242
+ # Disable/enable validation of modifiers params `:with`, `:except`, `:only`
243
+ # By default it is enabled. After disabling, when provided not existed attribute it will be just skipped.
244
+ config.check_initiate_params = false # default is true, enabled
245
+
246
+ # Stores in memory prepared `maps` of serialized attributes during serialization.
247
+ # Next time serialization happens with same modifiers (`:only, :except, :with`), we will use already prepared `map`.
248
+ # Setting defines storage size (count of stored `maps` with different modifiers).
249
+ config.max_cached_map_per_serializer_count = 50 # default is 0, disabled
250
+ end
251
+ ```
252
+
253
+ ## Plugins
254
+
255
+ ### Plugin :preloads
256
+
257
+ Allows to define `:preloads` to attributes and then allows to merge preloads
258
+ from serialized attributes and return single associations hash.
259
+
260
+ Plugin accepts options:
261
+ - `auto_preload_attributes_with_delegate` - default `false`
262
+ - `auto_preload_attributes_with_serializer` - default `false`
263
+ - `auto_hide_attributes_with_preload` - default `false`
264
+
265
+ This options are very handy if you want to forget about finding preloads manually.
266
+
267
+ Preloads can be disabled with `preload: false` attribute option option.
268
+ Also automatically added preloads can be overwritten with manually specified `preload: :another_value`.
269
+
270
+ Some examples, **please read comments in the code below**
271
+
272
+ ```ruby
273
+ class AppSerializer < Serega
274
+ plugin :preloads,
275
+ auto_preload_attributes_with_delegate: true,
276
+ auto_preload_attributes_with_serializer: true,
277
+ auto_hide_attributes_with_preload: true
278
+ end
279
+
280
+ class UserSerializer < AppSerializer
281
+ # No preloads
282
+ attribute :username
283
+
284
+ # Specify `preload: :user_stats` manually
285
+ attribute :followers_count, preload: :user_stats, value: proc { |user| user.user_stats.followers_count }
286
+
287
+ # Automatically `preloads: :user_stats` as `auto_preload_attributes_with_delegate` option is true
288
+ attribute :comments_count, delegate: { to: :user_stats }
289
+
290
+ # Automatically `preloads: :albums` as `auto_preload_attributes_with_serializer` option is true
291
+ attribute :albums, serializer: 'AlbumSerializer'
292
+ end
293
+
294
+ class AlbumSerializer < AppSerializer
295
+ attribute :images_count, delegate: { to: :album_stats }
296
+ end
297
+
298
+ # By default preloads are empty, as we specify `auto_hide_attributes_with_preload = true`,
299
+ # and attributes with preloads will be not serialized
300
+ UserSerializer.new.preloads # => {}
301
+ UserSerializer.new.to_h(OpenStruct.new(username: 'foo')) # => {:username=>"foo"}
302
+
303
+ UserSerializer.new(with: :followers_count).preloads # => {:user_stats=>{}}
304
+ UserSerializer.new(with: %i[followers_count comments_count]).preloads # => {:user_stats=>{}}
305
+ UserSerializer.new(with: [:followers_count, :comments_count, { albums: :images_count }]).preloads # => {:user_stats=>{}, :albums=>{:album_stats=>{}}}
306
+ ```
307
+
308
+ ---
309
+ One tricky case, that you will probably never see in real life:
310
+
311
+ Manually you can preload multiple associations, like this:
312
+
313
+ ```ruby
314
+ attribute :image, serializer: ImageSerializer, preload: { attachment: :blob }, value: proc { |record| record.attachment }
315
+ ```
316
+
317
+ In this case we mark last element (in this case it will be `blob`) as main,
318
+ so nested associations, if any, will be preloaded to this `blob`.
319
+ If you need to preload them to `attachment`,
320
+ please specify additionally `:preload_path` option like this:
321
+
322
+ ```ruby
323
+ attribute :image, serializer: ImageSerializer, preload: { attachment: :blob }, preload_path: %i[attachment], value: proc { |record| record.attachment }
324
+ ```
325
+ ---
326
+
327
+ 📌 Plugin `:preloads` only allows to group preloads together in single Hash, but they should be preloaded manually. For now there are only [activerecord_preloads][activerecord_preloads] plugin that can automatically preload associations.
328
+
329
+
330
+ ### Plugin :activerecord_preloads
331
+
332
+ (depends on [preloads][preloads] plugin, that must be loaded first)
333
+
334
+ Automatically preloads associations to serialized objects.
335
+
336
+ It takes all defined preloads from serialized attributes (including attributes from serialized relations),
337
+ merges them into single associations hash and then uses ActiveRecord::Associations::Preloader
338
+ to preload associations to serialized objects.
339
+
340
+
341
+ ```ruby
342
+ class AppSerializer < Serega
343
+ plugin :preloads,
344
+ auto_preload_attributes_with_delegate: true,
345
+ auto_preload_attributes_with_serializer: true,
346
+ auto_hide_attributes_with_preload: false
347
+
348
+ plugin :activerecord_preloads
349
+ end
350
+
351
+ class UserSerializer < AppSerializer
352
+ attribute :username
353
+ attribute :comments_count, delegate: { to: :user_stats }
354
+ attribute :albums, serializer: AlbumSerializer
355
+ end
356
+
357
+ class AlbumSerializer < AppSerializer
358
+ attribute :title
359
+ attribute :downloads_count, preload: :downloads, value: proc { |album| album.downloads.count }
360
+ end
361
+
362
+ UserSerializer.to_h(user) # => preloads {users_stats: {}, albums: { downloads: {} }}
363
+ ```
364
+
365
+ ### Plugin :batch
366
+
367
+ Adds ability to load attributes values in batches.
368
+
369
+ It can be used to omit N+1, to calculate counters for different objects in single query, to request any data from external storage.
370
+
371
+ Added new `:batch` attribute option, example:
372
+ ```
373
+ attribute :name, batch: { key: :id, loader: :name_loader, default: '' }
374
+ ```
375
+
376
+ Attribute `:batch` option must be a hash with this keys:
377
+ - `key` (required) [Symbol, Proc, callable] - Defines identifier of current object
378
+ - `loader` (required) [Symbol, Proc, callable] - Defines how to fetch values for batch of keys. Accepts 3 parameters: keys, context, point.
379
+ - `default` (optional) - Default value used when loader does not return value for current key. By default it is `nil` or `[]` when attribute has additional option `many: true` (ex: `attribute :name, many: true, batch: { ... }`).
380
+
381
+ If `:loader` was defined using name (as Symbol) then batch loader must be defined using `config.batch_loaders.define(:loader_name) { ... }` method. Result of this block must be a Hash where keys are - provided keys, and values are - batch loaded values for according keys.
382
+
383
+ Batch loader works well with [`activerecord_preloads`][activerecord_preloads] plugin.
384
+
385
+ ```ruby
386
+ class PostSerializer < Serega
387
+ plugin :batch
388
+
389
+ # Define batch loader via callable class, it must accept three args (keys, context, nested_attributes)
390
+ attribute :comments_count, batch: { key: :id, loader: PostCommentsCountBatchLoader, default: 0}
391
+
392
+ # Define batch loader via Symbol, later we should define this loader via config.batch_loaders.define(:posts_comments_counter) { ... }
393
+ attribute :comments_count, batch: { key: :id, loader: :posts_comments_counter, default: 0}
394
+
395
+ # Define batch loader with serializer
396
+ attribute :comments, serializer: CommentSerializer, batch: { key: :id, loader: :posts_comments, default: []}
397
+
398
+ # Resulted block must return hash like { key => value(s) }
399
+ config.batch_loaders.define(:posts_comments_counter) do |keys|
400
+ Comment.group(:post_id).where(post_id: keys).count
401
+ end
402
+
403
+ # We can return objects that will be automatically serialized if attribute defined with :serializer
404
+ # Parameter `context` can be used when loading batch
405
+ # Parameter `point` can be used to find nested attributes that will be serialized
406
+ config.batch_loaders.define(:posts_comments) do |keys, context, point|
407
+ # point.nested_points - if you need to manually check all nested attributes that will be serialized
408
+ # point.preloads - if you need to find nested preloads (works with :preloads plugin only)
409
+
410
+ Comment
411
+ .preload(point.preloads) # Can be skipped when used :activerecord_preloads plugin
412
+ .where(post_id: keys)
413
+ .where(is_spam: false)
414
+ .group_by(&:post_id)
415
+ end
416
+ end
417
+ ```
418
+
419
+ ### Plugin :root
420
+
421
+ Allows to add root key to your serialized data
422
+
423
+ Accepts options:
424
+ - :root - specifies root for all responses
425
+ - :root_one - specifies root for single object serialization only
426
+ - :root_many - specifies root for multiple objects serialization only
427
+
428
+ Adds additional config options:
429
+ - config.root.one
430
+ - config.root.many
431
+ - config.root.one=
432
+ - config.root_many=
433
+
434
+ Default root is `:data`.
435
+
436
+ Root also can be changed per serialization.
437
+
438
+ Also root can be removed for all responses by providing `root: nil`. In this case no root will be added to response, but
439
+ you still can to add it per serialization
440
+
441
+ ```ruby
442
+ #@example Define plugin
443
+
444
+ class UserSerializer < Serega
445
+ plugin :root # default root is :data
446
+ end
447
+
448
+ class UserSerializer < Serega
449
+ plugin :root, root: :users
450
+ end
451
+
452
+ class UserSerializer < Serega
453
+ plugin :root, root_one: :user, root_many: :people
454
+ end
455
+
456
+ class UserSerializer < Serega
457
+ plugin :root, root: nil # no root by default
458
+ end
459
+ ```
460
+
461
+ ```ruby
462
+ # @example Change root per serialization:
463
+
464
+ class UserSerializer < Serega
465
+ plugin :root
466
+ end
467
+
468
+ UserSerializer.to_h(nil) # => {:data=>nil}
469
+ UserSerializer.to_h(nil, root: :user) # => {:user=>nil}
470
+ UserSerializer.to_h(nil, root: nil) # => nil
471
+ ```
472
+
473
+ ### Plugin :metadata
474
+
475
+ Depends on: [`:root`][root] plugin, that must be loaded first
476
+
477
+ Adds ability to describe metadata and adds it to serialized response
478
+
479
+ Added class-level method `:meta_attribute`, to define metadata, it accepts:
480
+ - *path [Array<Symbol>] - nested hash keys beginning from the root object.
481
+ - **options [Hash] - defaults are `hide_nil: false, hide_empty: false`
482
+ - &block [Proc] - describes value for current meta attribute
483
+
484
+ ```ruby
485
+ class AppSerializer < Serega
486
+ plugin :root
487
+ plugin :metadata
488
+
489
+ meta_attribute(:version) { '1.2.3' }
490
+ meta_attribute(:ab_tests, :names) { %i[foo bar] }
491
+ meta_attribute(:meta, :paging, hide_nil: true) do |records, ctx|
492
+ next unless records.respond_to?(:total_count)
493
+
494
+ { page: records.page, per_page: records.per_page, total_count: records.total_count }
495
+ end
496
+ end
497
+
498
+ AppSerializer.to_h(nil) # => {:data=>nil, :version=>"1.2.3", :ab_tests=>{:names=>[:foo, :bar]}}
499
+ ```
500
+
501
+ ### Plugin :context_metadata
502
+
503
+ Depends on: [`:root`][root] plugin, that must be loaded first
504
+
505
+ Allows to provide metadata and attach it to serialized response.
506
+
507
+ Accepts option `:context_metadata_key` with name of keyword that must be used to provide metadata. By default it is `:meta`
508
+
509
+ Key can be changed in children serializers using config `config.context_metadata.key=(value)`
510
+
511
+ ```ruby
512
+ class UserSerializer < Serega
513
+ plugin :root, root: :data
514
+ plugin :context_metadata, context_metadata_key: :meta
515
+
516
+ # Same:
517
+ # plugin :context_metadata
518
+ # config.context_metadata.key = :meta
519
+ end
520
+
521
+ UserSerializer.to_h(nil, meta: { version: '1.0.1' })
522
+ # => {:data=>nil, :version=>"1.0.1"}
523
+ ```
524
+
525
+ ### Plugin :formatters
526
+
527
+ Allows to define `formatters` and apply them on attributes.
528
+
529
+ Config option `config.formatters.add` can be used to add formatters.
530
+
531
+ Attribute option `:format` now can be used with name of formatter or with callable instance.
532
+
533
+ ```ruby
534
+ class AppSerializer < Serega
535
+ plugin :formatters, formatters: {
536
+ iso8601: ->(value) { time.iso8601.round(6) },
537
+ on_off: ->(value) { value ? 'ON' : 'OFF' },
538
+ money: ->(value) { value.round(2) }
539
+ }
540
+ end
541
+
542
+ class UserSerializer < Serega
543
+ # Additionally we can add formatters via config in subclasses
544
+ config.formatters.add(
545
+ iso8601: ->(value) { time.iso8601.round(6) },
546
+ on_off: ->(value) { value ? 'ON' : 'OFF' },
547
+ money: ->(value) { value.round(2) }
548
+ )
549
+
550
+ # Using predefined formatter
551
+ attribute :commission, format: :money
552
+ attribute :is_logined, format: :on_off
553
+ attribute :created_at, format: :iso8601
554
+ attribute :updated_at, format: :iso8601
555
+
556
+ # Using `callable` formatter
557
+ attribute :score_percent, format: PercentFormmatter # callable class
558
+ attribute :score_percent, format: proc { |percent| "#{percent.round(2)}%" }
559
+ end
560
+ ```
561
+
562
+ ### Plugin :presenter
563
+
564
+ Helps to write clear code by adding attribute names as methods to Presenter
565
+
566
+ ```ruby
567
+ class UserSerializer < Serega
568
+ plugin :presenter
569
+
570
+ attribute :name
571
+ attribute :address
572
+
573
+ class Presenter
574
+ def name
575
+ [first_name, last_name].compact_blank.join(' ')
576
+ end
577
+
578
+ def address
579
+ [country, city, address].join("\n")
580
+ end
581
+ end
582
+ end
583
+ ```
584
+
585
+ ### Plugin :string_modifiers
586
+
587
+ Allows to specify modifiers as strings.
588
+
589
+ Serialized attributes must be split with `,` and nested attributes must be defined inside brackets `(`, `)`.
590
+
591
+ Modifiers can still be provided old way with nested hashes or arrays.
592
+
593
+ ```ruby
594
+ PostSerializer.plugin :string_modifiers
595
+ PostSerializer.new(only: "id,user(id,username)").to_h(post)
596
+ PostSerializer.new(except: "user(username,email)").to_h(post)
597
+ PostSerializer.new(with: "user(email)").to_h(post)
598
+
599
+ # Modifiers can still be provided old way with nested hashes or arrays.
600
+ PostSerializer.new(with: {user: %i[email, username]}).to_h(post)
601
+ ```
602
+
603
+ ### Plugin :hide_nil
604
+
605
+ Allows to hide attributes with `nil` values
606
+
607
+ ```ruby
608
+ class UserSerializer < Serega
609
+ plugin :hide_nil
610
+
611
+ attribute :email, hide_nil: true
612
+ end
613
+ ```
614
+
615
+ ## Errors
616
+
617
+ - `Serega::SeregaError` is a base error raised by this gem.
618
+ - `Serega::AttributeNotExist` error is raised when validating attributes in `:only, :except, :with` modifiers
619
+
620
+ ## Release
621
+
622
+ To release a new version, read [RELEASE.md](https://github.com/aglushkov/serega/blob/master/RELEASE.md).
623
+
624
+ ## Development
625
+
626
+ - `bundle install` - install dependencies
627
+ - `bin/console` - open irb console with loaded gems
628
+ - `bundle exec rspec` - run tests
629
+ - `bundle exec rubocop` - check code standards
630
+ - `yard stats --list-undoc --no-cache` - view undocumented code
631
+ - `yard server --reload` - view code documentation
632
+
633
+ ## Contributing
634
+
635
+ Bug reports, pull requests and improvements ideas are very welcome!
636
+
637
+ ## License
638
+
639
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
640
+
641
+
642
+ [activerecord_preloads]: #plugin-activerecord_preloads
643
+ [batch]: #plugin-batch
644
+ [context_metadata]: #plugin-context_metadata
645
+ [formatters]: #plugin-formatters
646
+ [metadata]: #plugin-metadata
647
+ [preloads]: #plugin-preloads
648
+ [presenter]: #plugin-presenter
649
+ [root]: #plugin-root
650
+ [string_modifiers]: #plugin-string_modifiers
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.7.0
1
+ 0.8.1
@@ -9,12 +9,15 @@ class Serega
9
9
  # Attribute instance methods
10
10
  #
11
11
  module AttributeInstanceMethods
12
+ # Returns attribute name
12
13
  # @return [Symbol] Attribute name
13
14
  attr_reader :name
14
15
 
16
+ # Returns attribute options
15
17
  # @return [Hash] Attribute options
16
18
  attr_reader :opts
17
19
 
20
+ # Returns attribute block
18
21
  # @return [Proc] Attribute originally added block
19
22
  attr_reader :block
20
23
 
@@ -40,26 +43,32 @@ class Serega
40
43
  @block = block
41
44
  end
42
45
 
43
- # @return [Symbol] Object method name to will be used to get attribute value unless block provided
46
+ # Method name that will be used to get attribute value
47
+ #
48
+ # @return [Symbol] key
44
49
  def key
45
50
  @key ||= opts.key?(:key) ? opts[:key].to_sym : name
46
51
  end
47
52
 
48
- # @return [Boolean, nil] Attribute initial :hide option value
53
+ # Shows current opts[:hide] option
54
+ # @return [Boolean, nil] Attribute :hide option value
49
55
  def hide
50
56
  opts[:hide]
51
57
  end
52
58
 
53
- # @return [Boolean, nil] Attribute initialization :many option
59
+ # Shows current opts[:many] option
60
+ # @return [Boolean, nil] Attribute :many option value
54
61
  def many
55
62
  opts[:many]
56
63
  end
57
64
 
65
+ # Shows whether attribute has specified serializer
58
66
  # @return [Boolean] Checks if attribute is relationship (if :serializer option exists)
59
67
  def relation?
60
68
  !opts[:serializer].nil?
61
69
  end
62
70
 
71
+ # Shows specified serializer class
63
72
  # @return [Serega, nil] Attribute serializer if exists
64
73
  def serializer
65
74
  return @serializer if instance_variable_defined?(:@serializer)
@@ -73,6 +82,7 @@ class Serega
73
82
  end
74
83
  end
75
84
 
85
+ # Returns final block that will be used to find attribute value
76
86
  # @return [Proc] Proc to find attribute value
77
87
  def value_block
78
88
  return @value_block if instance_variable_defined?(:@value_block)
@@ -130,12 +140,13 @@ class Serega
130
140
  end
131
141
 
132
142
  def delegate_block
133
- return unless opts.key?(:delegate)
143
+ delegate = opts[:delegate]
144
+ return unless delegate
134
145
 
135
- key_method_name = key
136
- delegate_to = opts[:delegate][:to]
146
+ key_method_name = delegate[:key] || key
147
+ delegate_to = delegate[:to]
137
148
 
138
- if opts[:delegate][:allow_nil]
149
+ if delegate[:allow_nil]
139
150
  proc { |object| object.public_send(delegate_to)&.public_send(key_method_name) }
140
151
  else
141
152
  proc { |object| object.public_send(delegate_to).public_send(key_method_name) }
data/lib/serega/config.rb CHANGED
@@ -8,6 +8,10 @@ class Serega
8
8
  #
9
9
  class SeregaConfig
10
10
  # :nocov: We can't use both :oj and :json adapters together
11
+
12
+ #
13
+ # Default config options
14
+ #
11
15
  DEFAULTS = {
12
16
  plugins: [],
13
17
  initiate_keys: %i[only with except check_initiate_params].freeze,
@@ -23,7 +27,9 @@ class Serega
23
27
  # SeregaConfig Instance methods
24
28
  module SeregaConfigInstanceMethods
25
29
  #
26
- # @return [Hash] All config options
30
+ # Shows current config as Hash
31
+ #
32
+ # @return [Hash] config options
27
33
  #
28
34
  attr_reader :opts
29
35
 
@@ -37,31 +43,41 @@ class Serega
37
43
  @opts = SeregaUtils::EnumDeepDup.call(opts)
38
44
  end
39
45
 
46
+ #
47
+ # Shows used plugins
48
+ #
40
49
  # @return [Array] Used plugins
50
+ #
41
51
  def plugins
42
52
  opts.fetch(:plugins)
43
53
  end
44
54
 
45
- # @return [Array<Symbol>] Allowed options keys for serializer initialization
55
+ # Returns options names allowed in `Serega#new` method
56
+ # @return [Array<Symbol>] allowed options keys
46
57
  def initiate_keys
47
58
  opts.fetch(:initiate_keys)
48
59
  end
49
60
 
61
+ # Returns options names allowed in `Serega.attribute` method
50
62
  # @return [Array<Symbol>] Allowed options keys for attribute initialization
51
63
  def attribute_keys
52
64
  opts.fetch(:attribute_keys)
53
65
  end
54
66
 
67
+ # Returns options names allowed in `to_h, to_json, as_json` methods
55
68
  # @return [Array<Symbol>] Allowed options keys for serialization
56
69
  def serialize_keys
57
70
  opts.fetch(:serialize_keys)
58
71
  end
59
72
 
73
+ # Returns :check_initiate_params config option
60
74
  # @return [Boolean] Current :check_initiate_params config option
61
75
  def check_initiate_params
62
76
  opts.fetch(:check_initiate_params)
63
77
  end
64
78
 
79
+ # Sets :check_initiate_params config option
80
+ #
65
81
  # @param value [Boolean] Set :check_initiate_params config option
66
82
  #
67
83
  # @return [Boolean] :check_initiate_params config option
@@ -70,11 +86,14 @@ class Serega
70
86
  opts[:check_initiate_params] = value
71
87
  end
72
88
 
89
+ # Returns :max_cached_map_per_serializer_count config option
73
90
  # @return [Boolean] Current :max_cached_map_per_serializer_count config option
74
91
  def max_cached_map_per_serializer_count
75
92
  opts.fetch(:max_cached_map_per_serializer_count)
76
93
  end
77
94
 
95
+ # Sets :max_cached_map_per_serializer_count config option
96
+ #
78
97
  # @param value [Boolean] Set :check_initiate_params config option
79
98
  #
80
99
  # @return [Boolean] New :max_cached_map_per_serializer_count config option
@@ -83,22 +102,26 @@ class Serega
83
102
  opts[:max_cached_map_per_serializer_count] = value
84
103
  end
85
104
 
105
+ # Returns current `to_json` adapter
86
106
  # @return [#call] Callable that used to construct JSON
87
107
  def to_json
88
108
  opts.fetch(:to_json)
89
109
  end
90
110
 
111
+ # Sets current `to_json` adapter
91
112
  # @param value [#call] Callable that used to construct JSON
92
113
  # @return [#call] Provided callable object
93
114
  def to_json=(value)
94
115
  opts[:to_json] = value
95
116
  end
96
117
 
118
+ # Returns current `from_json` adapter
97
119
  # @return [#call] Callable that used to parse JSON
98
120
  def from_json
99
121
  opts.fetch(:from_json)
100
122
  end
101
123
 
124
+ # Sets current `from_json` adapter
102
125
  # @param value [#call] Callable that used to parse JSON
103
126
  # @return [#call] Provided callable object
104
127
  def from_json=(value)
@@ -9,6 +9,7 @@ class Serega
9
9
  # Stores link to current serializer class
10
10
  #
11
11
  module SerializerClassHelper
12
+ # Shows serializer class current class is namespaced under
12
13
  # @return [Class<Serega>] Serializer class that current class is namespaced under.
13
14
  attr_accessor :serializer_class
14
15
  end
@@ -11,9 +11,11 @@ class Serega
11
11
  module InstanceMethods
12
12
  extend Forwardable
13
13
 
14
+ # Shows current attribute
14
15
  # @return [Serega::SeregaAttribute] Current attribute
15
16
  attr_reader :attribute
16
17
 
18
+ # Shows nested points
17
19
  # @return [NilClass, Array<Serega::SeregaMapPoint>] Nested points or nil
18
20
  attr_reader :nested_points
19
21
 
@@ -22,6 +22,8 @@ class Serega
22
22
  preload_handler.preload(object, preloads)
23
23
  end
24
24
 
25
+ # Returns handlers which will try to check if serialized object fits for preloading using this handler
26
+ #
25
27
  # @return [Array] Registered preload adapters for different types of initial records
26
28
  def handlers
27
29
  @handlers ||= [
@@ -35,6 +35,7 @@ class Serega
35
35
  # end
36
36
  #
37
37
  module Batch
38
+ # Returns plugin name
38
39
  # @return [Symbol] Plugin name
39
40
  def self.plugin_name
40
41
  :batch
@@ -159,6 +160,7 @@ class Serega
159
160
  @many = many
160
161
  end
161
162
 
163
+ # Returns proc that will be used to batch load registered keys values
162
164
  # @return [#call] batch loader
163
165
  def loader
164
166
  @batch_loader ||= begin
@@ -168,6 +170,7 @@ class Serega
168
170
  end
169
171
  end
170
172
 
173
+ # Returns proc that will be used to find batch_key for current attribute.
171
174
  # @return [Object] key (uid) of batch loaded object
172
175
  def key
173
176
  @batch_key ||= begin
@@ -176,6 +179,7 @@ class Serega
176
179
  end
177
180
  end
178
181
 
182
+ # Returns default value to use if batch loader does not return value for some key
179
183
  # @return [Object] default value for missing key
180
184
  def default_value
181
185
  if opts.key?(:default)
@@ -192,6 +196,8 @@ class Serega
192
196
  # @see Serega::SeregaConfig
193
197
  #
194
198
  module ConfigInstanceMethods
199
+ #
200
+ # Returns all batch loaders registered for current serializer
195
201
  #
196
202
  # @return [Serega::SeregaPlugins::Batch::BatchLoadersConfig] configuration for batch loaders
197
203
  #
@@ -253,11 +259,13 @@ class Serega
253
259
  #
254
260
  # Serega::SeregaMapPoint additional/patched class methods
255
261
  #
256
- # @see Serega::SeregaAttribute
262
+ # @see SeregaAttribute
257
263
  #
258
264
  module MapPointInstanceMethods
259
265
  #
260
- # @return [Serega::Batch::BatchModel] batch model that encapsulates everything needed to load current batch
266
+ # Returns BatchModel, an object that encapsulates all batch_loader methods for current point
267
+ #
268
+ # @return [BatchModel] batch model that encapsulates everything needed to load current batch
261
269
  #
262
270
  def batch
263
271
  return @batch if instance_variable_defined?(:@batch)
@@ -272,7 +280,7 @@ class Serega
272
280
  #
273
281
  # Serega additional/patched instance methods
274
282
  #
275
- # @see Serega
283
+ # @see Serega::InstanceMethods
276
284
  #
277
285
  module InstanceMethods
278
286
  private
@@ -32,6 +32,7 @@ class Serega
32
32
 
33
33
  delegate_opts = opts[:delegate]
34
34
  check_opt_delegate_to(delegate_opts)
35
+ check_opt_delegate_key(delegate_opts)
35
36
  check_opt_delegate_allow_nil(delegate_opts)
36
37
  end
37
38
 
@@ -42,13 +43,16 @@ class Serega
42
43
  Utils::CheckOptIsStringOrSymbol.call(delegate_opts, :to)
43
44
  end
44
45
 
45
- def check_opt_delegate_allow_nil(delegate_opts)
46
- return unless delegate_opts.key?(:allow_nil)
46
+ def check_opt_delegate_key(delegate_opts)
47
+ Utils::CheckOptIsStringOrSymbol.call(delegate_opts, :key)
48
+ end
47
49
 
50
+ def check_opt_delegate_allow_nil(delegate_opts)
48
51
  Utils::CheckOptIsBool.call(delegate_opts, :allow_nil)
49
52
  end
50
53
 
51
54
  def check_usage_with_other_params(opts, block)
55
+ raise SeregaError, "Option :delegate can not be used together with option :key" if opts.key?(:key)
52
56
  raise SeregaError, "Option :delegate can not be used together with option :const" if opts.key?(:const)
53
57
  raise SeregaError, "Option :delegate can not be used together with option :value" if opts.key?(:value)
54
58
  raise SeregaError, "Option :delegate can not be used together with block" if block
@@ -39,9 +39,7 @@ class Serega
39
39
  end
40
40
 
41
41
  def check_modifiers
42
- Initiate::CheckModifiers.call(serializer_class, opts[:only])
43
- Initiate::CheckModifiers.call(serializer_class, opts[:except])
44
- Initiate::CheckModifiers.call(serializer_class, opts[:with])
42
+ Initiate::CheckModifiers.new.call(serializer_class, opts[:only], opts[:with], opts[:except])
45
43
  end
46
44
 
47
45
  def serializer_class
@@ -10,55 +10,72 @@ class Serega
10
10
  # Modifiers validation
11
11
  #
12
12
  class CheckModifiers
13
- class << self
14
- # Validates provided fields names are existing attributes
15
- #
16
- # @param serializer_class [Serega]
17
- # @param fields [Hash] validated fields
18
- #
19
- # @raise [Serega::AttributeNotExist] when modifier not exist as attribute
20
- #
21
- # @return [void]
22
- #
23
- def call(serializer_class, fields)
24
- return unless fields
13
+ #
14
+ # Validates provided fields names are existing attributes
15
+ #
16
+ # @param serializer_class [Serega]
17
+ # @param only [Hash, nil] `only` modifier
18
+ # @param with [Hash, nil] `with` modifier
19
+ # @param except [Hash, nil] `except` modifier
20
+ #
21
+ # @raise [Serega::AttributeNotExist] when some checked modifier has not existing attribute
22
+ #
23
+ # @return [void]
24
+ #
25
+ def call(serializer_class, only, with, except)
26
+ validate(serializer_class, only) if only
27
+ validate(serializer_class, with) if with
28
+ validate(serializer_class, except) if except
25
29
 
26
- validate(serializer_class, fields, [])
27
- end
30
+ raise_errors if any_error?
31
+ end
28
32
 
29
- private
33
+ private
30
34
 
31
- def validate(serializer_class, fields, prev_names)
32
- fields.each do |name, nested_fields|
33
- attribute = serializer_class.attributes[name]
35
+ def validate(serializer_class, fields)
36
+ fields.each do |name, nested_fields|
37
+ attribute = serializer_class && serializer_class.attributes[name]
38
+
39
+ # Save error when no attribute with checked name exists
40
+ unless attribute
41
+ save_error(name)
42
+ next
43
+ end
34
44
 
35
- raise_error(name, prev_names) unless attribute
36
- next if nested_fields.empty?
45
+ # Return when attribute has no nested fields
46
+ next if nested_fields.empty?
37
47
 
38
- raise_nested_error(name, prev_names, nested_fields) unless attribute.relation?
39
- nested_serializer = attribute.serializer
40
- validate(nested_serializer, nested_fields, prev_names + [name])
48
+ with_parent_name(name) do
49
+ validate(attribute.serializer, nested_fields)
41
50
  end
42
51
  end
52
+ end
43
53
 
44
- def raise_error(name, prev_names)
45
- field_name = field_name(name, prev_names)
54
+ def parents_names
55
+ @parents_names ||= []
56
+ end
46
57
 
47
- raise Serega::AttributeNotExist, "Attribute #{field_name} not exists"
48
- end
58
+ def with_parent_name(name)
59
+ parents_names << name
60
+ yield
61
+ parents_names.pop
62
+ end
49
63
 
50
- def raise_nested_error(name, prev_names, nested_fields)
51
- field_name = field_name(name, prev_names)
52
- first_nested = nested_fields.keys.first
64
+ def error_attributes
65
+ @error_attributes ||= []
66
+ end
53
67
 
54
- raise Serega::AttributeNotExist, "Attribute #{field_name} has no :serializer option specified to add nested '#{first_nested}' attribute"
55
- end
68
+ def save_error(name)
69
+ full_attribute_name = [*parents_names, name].join(".")
70
+ error_attributes << full_attribute_name
71
+ end
56
72
 
57
- def field_name(name, prev_names)
58
- res = "'#{name}'"
59
- res += " ('#{prev_names.join(".")}.#{name}')" if prev_names.any?
60
- res
61
- end
73
+ def raise_errors
74
+ raise Serega::AttributeNotExist, "Not existing attributes: #{error_attributes.join(", ")}"
75
+ end
76
+
77
+ def any_error?
78
+ defined?(@error_attributes)
62
79
  end
63
80
  end
64
81
  end
data/lib/serega.rb CHANGED
@@ -4,9 +4,11 @@ require_relative "serega/version"
4
4
 
5
5
  # Parent class for your serializers
6
6
  class Serega
7
+ # Frozen hash
7
8
  # @return [Hash] frozen hash
8
9
  FROZEN_EMPTY_HASH = {}.freeze
9
10
 
11
+ # Frozen array
10
12
  # @return [Array] frozen array
11
13
  FROZEN_EMPTY_ARRAY = [].freeze
12
14
  end
@@ -64,6 +66,7 @@ class Serega
64
66
  # Serializers class methods
65
67
  #
66
68
  module ClassMethods
69
+ # Returns current config
67
70
  # @return [SeregaConfig] current serializer config
68
71
  attr_reader :config
69
72
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: serega
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrey Glushkov
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2022-12-07 00:00:00.000000000 Z
11
+ date: 2022-12-10 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: |
14
14
  JSON Serializer
@@ -25,6 +25,7 @@ executables: []
25
25
  extensions: []
26
26
  extra_rdoc_files: []
27
27
  files:
28
+ - README.md
28
29
  - VERSION
29
30
  - lib/serega.rb
30
31
  - lib/serega/attribute.rb
@@ -92,8 +93,8 @@ homepage: https://github.com/aglushkov/serega
92
93
  licenses:
93
94
  - MIT
94
95
  metadata:
95
- homepage_uri: https://github.com/aglushkov/serega
96
96
  source_code_uri: https://github.com/aglushkov/serega
97
+ documentation_uri: https://www.rubydoc.info/gems/serega
97
98
  changelog_uri: https://github.com/aglushkov/serega/blob/master/CHANGELOG.md
98
99
  post_install_message:
99
100
  rdoc_options: []