serega 0.7.0 → 0.8.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.
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: []