alba 0.13.0 → 1.2.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/lib/alba.rb CHANGED
@@ -1,5 +1,4 @@
1
1
  require_relative 'alba/version'
2
- require_relative 'alba/serializer'
3
2
  require_relative 'alba/resource'
4
3
 
5
4
  # Core module
@@ -10,9 +9,11 @@ module Alba
10
9
  # Error class for backend which is not supported
11
10
  class UnsupportedBackend < Error; end
12
11
 
12
+ # Error class for type which is not supported
13
+ class UnsupportedType < Error; end
14
+
13
15
  class << self
14
- attr_reader :backend, :encoder
15
- attr_accessor :default_serializer
16
+ attr_reader :backend, :encoder, :inferring, :_on_error, :transforming_root_key
16
17
 
17
18
  # Set the backend, which actually serializes object into JSON
18
19
  #
@@ -28,25 +29,64 @@ module Alba
28
29
  # Serialize the object with inline definitions
29
30
  #
30
31
  # @param object [Object] the object to be serialized
31
- # @param with [nil, Proc, Alba::Serializer] selializer
32
+ # @param key [Symbol]
32
33
  # @param block [Block] resource block
33
34
  # @return [String] serialized JSON string
34
35
  # @raise [ArgumentError] if block is absent or `with` argument's type is wrong
35
- def serialize(object, with: nil, &block)
36
+ def serialize(object, key: nil, &block)
36
37
  raise ArgumentError, 'Block required' unless block
37
38
 
38
- resource_class.class_eval(&block)
39
- resource = resource_class.new(object)
40
- with ||= @default_serializer
41
- resource.serialize(with: with)
39
+ klass = Class.new
40
+ klass.include(Alba::Resource)
41
+ klass.class_eval(&block)
42
+ resource = klass.new(object)
43
+ resource.serialize(key: key)
44
+ end
45
+
46
+ # Enable inference for key and resource name
47
+ def enable_inference!
48
+ begin
49
+ require 'active_support/inflector'
50
+ rescue LoadError
51
+ raise ::Alba::Error, 'To enable inference, please install `ActiveSupport` gem.'
52
+ end
53
+ @inferring = true
54
+ end
55
+
56
+ # Disable inference for key and resource name
57
+ def disable_inference!
58
+ @inferring = false
59
+ end
60
+
61
+ # Set error handler
62
+ #
63
+ # @param [Symbol] handler
64
+ # @param [Block]
65
+ def on_error(handler = nil, &block)
66
+ raise ArgumentError, 'You cannot specify error handler with both Symbol and block' if handler && block
67
+ raise ArgumentError, 'You must specify error handler with either Symbol or block' unless handler || block
68
+
69
+ @_on_error = handler || block
70
+ end
71
+
72
+ # Enable root key transformation
73
+ def enable_root_key_transformation!
74
+ @transforming_root_key = true
75
+ end
76
+
77
+ # Disable root key transformation
78
+ def disable_root_key_transformation!
79
+ @transforming_root_key = false
42
80
  end
43
81
 
44
82
  private
45
83
 
46
84
  def set_encoder
47
85
  @encoder = case @backend
48
- when :oj
86
+ when :oj, :oj_strict
49
87
  try_oj
88
+ when :oj_rails
89
+ try_oj(mode: :rails)
50
90
  when :active_support
51
91
  try_active_support
52
92
  when nil, :default, :json
@@ -56,10 +96,11 @@ module Alba
56
96
  end
57
97
  end
58
98
 
59
- def try_oj
99
+ def try_oj(mode: :strict)
60
100
  require 'oj'
61
- ->(hash) { Oj.dump(hash, mode: :strict) }
101
+ ->(hash) { Oj.dump(hash, mode: mode) }
62
102
  rescue LoadError
103
+ Kernel.warn '`Oj` is not installed, falling back to default JSON encoder.'
63
104
  default_encoder
64
105
  end
65
106
 
@@ -67,6 +108,7 @@ module Alba
67
108
  require 'active_support/json'
68
109
  ->(hash) { ActiveSupport::JSON.encode(hash) }
69
110
  rescue LoadError
111
+ Kernel.warn '`ActiveSupport` is not installed, falling back to default JSON encoder.'
70
112
  default_encoder
71
113
  end
72
114
 
@@ -76,14 +118,9 @@ module Alba
76
118
  JSON.dump(hash)
77
119
  end
78
120
  end
79
-
80
- def resource_class
81
- @resource_class ||= begin
82
- klass = Class.new
83
- klass.include(Alba::Resource)
84
- end
85
- end
86
121
  end
87
122
 
88
123
  @encoder = default_encoder
124
+ @_on_error = :raise
125
+ @transforming_root_key = false # TODO: This will be true since 2.0
89
126
  end
@@ -2,25 +2,40 @@ module Alba
2
2
  # Base class for `One` and `Many`
3
3
  # Child class should implement `to_hash` method
4
4
  class Association
5
+ attr_reader :object
6
+
5
7
  # @param name [Symbol] name of the method to fetch association
6
8
  # @param condition [Proc] a proc filtering data
7
9
  # @param resource [Class<Alba::Resource>] a resource class for the association
8
10
  # @param block [Block] used to define resource when resource arg is absent
9
- def initialize(name:, condition: nil, resource: nil, &block)
11
+ def initialize(name:, condition: nil, resource: nil, nesting: nil, &block)
10
12
  @name = name
11
13
  @condition = condition
12
14
  @block = block
13
- @resource = resource || resource_class
14
- raise ArgumentError, 'resource or block is required' if @resource.nil? && @block.nil?
15
- end
15
+ @resource = resource
16
+ return if @resource
16
17
 
17
- # @abstract
18
- def to_hash
19
- :not_implemented
18
+ if @block
19
+ @resource = resource_class
20
+ elsif Alba.inferring
21
+ const_parent = nesting.nil? ? Object : Object.const_get(nesting)
22
+ @resource = const_parent.const_get("#{ActiveSupport::Inflector.classify(@name)}Resource")
23
+ else
24
+ raise ArgumentError, 'When Alba.inferring is false, either resource or block is required'
25
+ end
20
26
  end
21
27
 
22
28
  private
23
29
 
30
+ def constantize(resource)
31
+ case resource # rubocop:disable Style/MissingElse
32
+ when Class
33
+ resource
34
+ when Symbol, String
35
+ Object.const_get(resource)
36
+ end
37
+ end
38
+
24
39
  def resource_class
25
40
  klass = Class.new
26
41
  klass.include(Alba::Resource)
@@ -16,6 +16,7 @@ module Alba
16
16
  # @return [String] transformed key
17
17
  # @raise [Alba::Error] when transform_type is not supported
18
18
  def transform(key, transform_type)
19
+ key = key.to_s
19
20
  case transform_type
20
21
  when :camel
21
22
  ActiveSupport::Inflector.camelize(key)
@@ -24,7 +25,7 @@ module Alba
24
25
  when :dash
25
26
  ActiveSupport::Inflector.dasherize(key)
26
27
  else
27
- raise ::Alba::Error, "Unknown transform_type: #{transform_type}. Supported transform_type are :camel and :dash."
28
+ raise ::Alba::Error, "Unknown transform_type: #{transform_type}. Supported transform_type are :camel, :lower_camel and :dash."
28
29
  end
29
30
  end
30
31
  end
data/lib/alba/many.rb CHANGED
@@ -6,12 +6,16 @@ module Alba
6
6
  # Recursively converts objects into an Array of Hashes
7
7
  #
8
8
  # @param target [Object] the object having an association method
9
+ # @param within [Hash] determines what associations to be serialized. If not set, it serializes all associations.
9
10
  # @param params [Hash] user-given Hash for arbitrary data
10
11
  # @return [Array<Hash>]
11
- def to_hash(target, params: {})
12
- objects = target.public_send(@name)
13
- objects = @condition.call(objects, params) if @condition
14
- objects.map { |o| @resource.new(o, params: params).to_hash }
12
+ def to_hash(target, within: nil, params: {})
13
+ @object = target.public_send(@name)
14
+ @object = @condition.call(@object, params) if @condition
15
+ return if @object.nil?
16
+
17
+ @resource = constantize(@resource)
18
+ @object.map { |o| @resource.new(o, params: params, within: within).to_hash }
15
19
  end
16
20
  end
17
21
  end
data/lib/alba/one.rb CHANGED
@@ -6,12 +6,16 @@ module Alba
6
6
  # Recursively converts an object into a Hash
7
7
  #
8
8
  # @param target [Object] the object having an association method
9
+ # @param within [Hash] determines what associations to be serialized. If not set, it serializes all associations.
9
10
  # @param params [Hash] user-given Hash for arbitrary data
10
11
  # @return [Hash]
11
- def to_hash(target, params: {})
12
- object = target.public_send(@name)
13
- object = @condition.call(object, params) if @condition
14
- @resource.new(object, params: params).to_hash
12
+ def to_hash(target, within: nil, params: {})
13
+ @object = target.public_send(@name)
14
+ @object = @condition.call(object, params) if @condition
15
+ return if @object.nil?
16
+
17
+ @resource = constantize(@resource)
18
+ @resource.new(object, params: params, within: within).to_hash
15
19
  end
16
20
  end
17
21
  end
data/lib/alba/resource.rb CHANGED
@@ -1,4 +1,3 @@
1
- require_relative 'serializer'
2
1
  require_relative 'one'
3
2
  require_relative 'many'
4
3
 
@@ -7,7 +6,7 @@ module Alba
7
6
  module Resource
8
7
  # @!parse include InstanceMethods
9
8
  # @!parse extend ClassMethods
10
- DSLS = {_attributes: {}, _serializer: nil, _key: nil, _transform_keys: nil}.freeze
9
+ DSLS = {_attributes: {}, _key: nil, _transform_keys: nil, _transforming_root_key: false, _on_error: nil}.freeze
11
10
  private_constant :DSLS
12
11
 
13
12
  # @private
@@ -25,32 +24,26 @@ module Alba
25
24
 
26
25
  # Instance methods
27
26
  module InstanceMethods
28
- attr_reader :object, :_key, :params
27
+ attr_reader :object, :params
29
28
 
30
29
  # @param object [Object] the object to be serialized
31
30
  # @param params [Hash] user-given Hash for arbitrary data
32
- def initialize(object, params: {})
31
+ # @param within [Hash] determines what associations to be serialized. If not set, it serializes all associations.
32
+ def initialize(object, params: {}, within: true)
33
33
  @object = object
34
34
  @params = params.freeze
35
+ @within = within
35
36
  DSLS.each_key { |name| instance_variable_set("@#{name}", self.class.public_send(name)) }
36
37
  end
37
38
 
38
- # Get serializer with `with` argument and serialize self with it
39
+ # Serialize object into JSON string
39
40
  #
40
- # @param with [nil, Proc, Alba::Serializer] selializer
41
+ # @param key [Symbol]
41
42
  # @return [String] serialized JSON string
42
- def serialize(with: nil)
43
- serializer = case with
44
- when nil
45
- @_serializer || empty_serializer
46
- when ->(obj) { obj.is_a?(Class) && obj <= Alba::Serializer }
47
- with
48
- when Proc
49
- inline_extended_serializer(with)
50
- else
51
- raise ArgumentError, 'Unexpected type for with, possible types are Class or Proc'
52
- end
53
- serializer.new(self).serialize
43
+ def serialize(key: nil)
44
+ key = key.nil? ? _key : key
45
+ hash = key && key != '' ? {key.to_s => serializable_hash} : serializable_hash
46
+ Alba.encoder.call(hash)
54
47
  end
55
48
 
56
49
  # A Hash for serialization
@@ -61,22 +54,67 @@ module Alba
61
54
  end
62
55
  alias to_hash serializable_hash
63
56
 
64
- # @return [Symbol]
65
- def key
66
- @_key || self.class.name.delete_suffix('Resource').downcase.gsub(/:{2}/, '_').to_sym
67
- end
68
-
69
57
  private
70
58
 
71
- # rubocop:disable Style/MethodCalledOnDoEndBlock
59
+ # @return [String]
60
+ def _key
61
+ return @_key.to_s unless @_key == true && Alba.inferring
62
+
63
+ resource_name = self.class.name.demodulize.delete_suffix('Resource').underscore
64
+ key = collection? ? resource_name.pluralize : resource_name
65
+ transforming_root_key = @_transforming_root_key.nil? ? Alba.transforming_root_key : @_transforming_root_key
66
+ transforming_root_key ? transform_key(key) : key
67
+ end
68
+
72
69
  def converter
73
- lambda do |resource|
74
- @_attributes.map do |key, attribute|
75
- [transform_key(key), fetch_attribute(resource, attribute)]
76
- end.to_h
70
+ lambda do |object|
71
+ arrays = @_attributes.map do |key, attribute|
72
+ key = transform_key(key)
73
+ if attribute.is_a?(Array) # Conditional
74
+ conditional_attribute(object, key, attribute)
75
+ else
76
+ [key, fetch_attribute(object, attribute)]
77
+ end
78
+ rescue ::Alba::Error, FrozenError, TypeError
79
+ raise
80
+ rescue StandardError => e
81
+ handle_error(e, object, key, attribute)
82
+ end
83
+ arrays.reject(&:empty?).to_h
84
+ end
85
+ end
86
+
87
+ def conditional_attribute(object, key, attribute)
88
+ condition = attribute.last
89
+ arity = condition.arity
90
+ return [] if arity <= 1 && !condition.call(object)
91
+
92
+ fetched_attribute = fetch_attribute(object, attribute.first)
93
+ attr = if attribute.first.is_a?(Alba::Association)
94
+ attribute.first.object
95
+ else
96
+ fetched_attribute
97
+ end
98
+ return [] if arity >= 2 && !condition.call(object, attr)
99
+
100
+ [key, fetched_attribute]
101
+ end
102
+
103
+ def handle_error(error, object, key, attribute)
104
+ on_error = @_on_error || Alba._on_error
105
+ case on_error
106
+ when :raise, nil
107
+ raise
108
+ when :nullify
109
+ [key, nil]
110
+ when :ignore
111
+ []
112
+ when Proc
113
+ on_error.call(error, object, key, attribute, self.class)
114
+ else
115
+ raise ::Alba::Error, "Unknown on_error: #{on_error.inspect}"
77
116
  end
78
117
  end
79
- # rubocop:enable Style/MethodCalledOnDoEndBlock
80
118
 
81
119
  # Override this method to supply custom key transform method
82
120
  def transform_key(key)
@@ -86,29 +124,81 @@ module Alba
86
124
  KeyTransformer.transform(key, @_transform_keys)
87
125
  end
88
126
 
89
- def fetch_attribute(resource, attribute)
127
+ def fetch_attribute(object, attribute)
90
128
  case attribute
91
129
  when Symbol
92
- resource.public_send attribute
130
+ object.public_send attribute
93
131
  when Proc
94
- instance_exec(resource, &attribute)
132
+ instance_exec(object, &attribute)
95
133
  when Alba::One, Alba::Many
96
- attribute.to_hash(resource, params: params)
134
+ within = check_within
135
+ return unless within
136
+
137
+ attribute.to_hash(object, params: params, within: within)
138
+ when Hash # Typed Attribute
139
+ typed_attribute(object, attribute)
97
140
  else
98
141
  raise ::Alba::Error, "Unsupported type of attribute: #{attribute.class}"
99
142
  end
100
143
  end
101
144
 
102
- def empty_serializer
103
- klass = Class.new
104
- klass.include Alba::Serializer
105
- klass
145
+ def typed_attribute(object, hash)
146
+ attr_name = hash[:attr_name]
147
+ type = hash[:type]
148
+ type_converter = hash[:type_converter]
149
+ value, result = type_check(object, attr_name, type)
150
+ return value if result
151
+ raise TypeError if !result && !type_converter
152
+
153
+ type_converter = type_converter_for(type) if type_converter == true
154
+ type_converter.call(value)
155
+ rescue TypeError
156
+ raise TypeError, "Attribute #{attr_name} is expected to be #{type} but actually #{value.nil? ? 'nil' : value.class.name}."
157
+ end
158
+
159
+ def type_check(object, attr_name, type)
160
+ value = object.public_send(attr_name)
161
+ type_correct = case type
162
+ when :String, ->(klass) { klass == String }
163
+ value.is_a?(String)
164
+ when :Integer, ->(klass) { klass == Integer }
165
+ value.is_a?(Integer)
166
+ when :Boolean
167
+ [true, false].include?(attr_name)
168
+ else
169
+ raise Alba::UnsupportedType, "Unknown type: #{type}"
170
+ end
171
+ [value, type_correct]
106
172
  end
107
173
 
108
- def inline_extended_serializer(with)
109
- klass = empty_serializer
110
- klass.class_eval(&with)
111
- klass
174
+ def type_converter_for(type)
175
+ case type
176
+ when :String, ->(klass) { klass == String }
177
+ ->(object) { object.to_s }
178
+ when :Integer, ->(klass) { klass == Integer }
179
+ ->(object) { Integer(object) }
180
+ when :Boolean
181
+ ->(object) { !!object }
182
+ else
183
+ raise Alba::UnsupportedType, "Unknown type: #{type}"
184
+ end
185
+ end
186
+
187
+ def check_within
188
+ case @within
189
+ when Hash # Traverse within tree
190
+ @within.fetch(_key.to_sym, nil)
191
+ when Array # within tree ends with Array
192
+ @within.find { |item| item.to_sym == _key.to_sym } # Check if at least one item in the array matches current resource
193
+ when Symbol # within tree could end with Symbol
194
+ @within == _key.to_sym # Check if the symbol matches current resource
195
+ when true # In this case, Alba serializes all associations.
196
+ true
197
+ when nil, false # In these cases, Alba stops serialization here.
198
+ false
199
+ else
200
+ raise Alba::Error, "Unknown type for within option: #{@within.class}"
201
+ end
112
202
  end
113
203
 
114
204
  def collection?
@@ -129,19 +219,32 @@ module Alba
129
219
  # Set multiple attributes at once
130
220
  #
131
221
  # @param attrs [Array<String, Symbol>]
132
- def attributes(*attrs)
133
- attrs.each { |attr_name| @_attributes[attr_name.to_sym] = attr_name.to_sym }
222
+ # @param options [Hash] option hash including `if` that is a condition to render these attributes
223
+ def attributes(*attrs, if: nil, **attrs_with_types) # rubocop:disable Naming/MethodParameterName
224
+ if_value = binding.local_variable_get(:if)
225
+ attrs.each do |attr_name|
226
+ attr = if_value ? [attr_name.to_sym, if_value] : attr_name.to_sym
227
+ @_attributes[attr_name.to_sym] = attr
228
+ end
229
+ attrs_with_types.each do |attr_name, type_and_converter|
230
+ attr_name = attr_name.to_sym
231
+ type, type_converter = type_and_converter
232
+ typed_attr = {attr_name: attr_name, type: type, type_converter: type_converter}
233
+ attr = if_value ? [typed_attr, if_value] : typed_attr
234
+ @_attributes[attr_name] = attr
235
+ end
134
236
  end
135
237
 
136
238
  # Set an attribute with the given block
137
239
  #
138
240
  # @param name [String, Symbol] key name
241
+ # @param options [Hash] option hash including `if` that is a condition to render
139
242
  # @param block [Block] the block called during serialization
140
243
  # @raise [ArgumentError] if block is absent
141
- def attribute(name, &block)
244
+ def attribute(name, **options, &block)
142
245
  raise ArgumentError, 'No block given in attribute method' unless block
143
246
 
144
- @_attributes[name.to_sym] = block
247
+ @_attributes[name.to_sym] = options[:if] ? [block, options[:if]] : block
145
248
  end
146
249
 
147
250
  # Set One association
@@ -150,10 +253,13 @@ module Alba
150
253
  # @param condition [Proc]
151
254
  # @param resource [Class<Alba::Resource>]
152
255
  # @param key [String, Symbol] used as key when given
256
+ # @param options [Hash] option hash including `if` that is a condition to render
153
257
  # @param block [Block]
154
258
  # @see Alba::One#initialize
155
- def one(name, condition = nil, resource: nil, key: nil, &block)
156
- @_attributes[key&.to_sym || name.to_sym] = One.new(name: name, condition: condition, resource: resource, &block)
259
+ def one(name, condition = nil, resource: nil, key: nil, **options, &block)
260
+ nesting = self.name&.rpartition('::')&.first
261
+ one = One.new(name: name, condition: condition, resource: resource, nesting: nesting, &block)
262
+ @_attributes[key&.to_sym || name.to_sym] = options[:if] ? [one, options[:if]] : one
157
263
  end
158
264
  alias has_one one
159
265
 
@@ -163,25 +269,27 @@ module Alba
163
269
  # @param condition [Proc]
164
270
  # @param resource [Class<Alba::Resource>]
165
271
  # @param key [String, Symbol] used as key when given
272
+ # @param options [Hash] option hash including `if` that is a condition to render
166
273
  # @param block [Block]
167
274
  # @see Alba::Many#initialize
168
- def many(name, condition = nil, resource: nil, key: nil, &block)
169
- @_attributes[key&.to_sym || name.to_sym] = Many.new(name: name, condition: condition, resource: resource, &block)
275
+ def many(name, condition = nil, resource: nil, key: nil, **options, &block)
276
+ nesting = self.name&.rpartition('::')&.first
277
+ many = Many.new(name: name, condition: condition, resource: resource, nesting: nesting, &block)
278
+ @_attributes[key&.to_sym || name.to_sym] = options[:if] ? [many, options[:if]] : many
170
279
  end
171
280
  alias has_many many
172
281
 
173
- # Set serializer for the resource
174
- #
175
- # @param name [Alba::Serializer]
176
- def serializer(name)
177
- @_serializer = name <= Alba::Serializer ? name : nil
178
- end
179
-
180
282
  # Set key
181
283
  #
182
284
  # @param key [String, Symbol]
183
285
  def key(key)
184
- @_key = key.to_sym
286
+ @_key = key.respond_to?(:to_sym) ? key.to_sym : key
287
+ end
288
+
289
+ # Set key to true
290
+ #
291
+ def key!
292
+ @_key = true
185
293
  end
186
294
 
187
295
  # Delete attributes
@@ -196,9 +304,22 @@ module Alba
196
304
 
197
305
  # Transform keys as specified type
198
306
  #
199
- # @params type [String, Symbol]
200
- def transform_keys(type)
307
+ # @param type [String, Symbol]
308
+ # @param root [Boolean] decides if root key also should be transformed
309
+ def transform_keys(type, root: nil)
201
310
  @_transform_keys = type.to_sym
311
+ @_transforming_root_key = root
312
+ end
313
+
314
+ # Set error handler
315
+ #
316
+ # @param [Symbol] handler
317
+ # @param [Block]
318
+ def on_error(handler = nil, &block)
319
+ raise ArgumentError, 'You cannot specify error handler with both Symbol and block' if handler && block
320
+ raise ArgumentError, 'You must specify error handler with either Symbol or block' unless handler || block
321
+
322
+ @_on_error = handler || block
202
323
  end
203
324
  end
204
325
  end