active_facets 1.2.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,291 @@
1
+ module ActiveFacets
2
+ module Serializer
3
+ module Base
4
+ extend ActiveSupport::Concern
5
+
6
+ module ClassMethods
7
+
8
+ # ###
9
+ # DSL
10
+ # ###
11
+
12
+ # See Readme.md
13
+
14
+ # DSL Defines a transform to rename or reformat an attribute
15
+ # @param api_attribute [Symbol] name of the attribute
16
+ # @param options [Hash]
17
+ # @option options [Symbol] :as internal method name to call on the resource for serialization and hydration
18
+ # @option options [Symbol] :from internal method name to call on the resource for serialization
19
+ # @option options [Symbol] :to internal method name to call on the resource for hydration
20
+ # @option options [Symbol] :with name of a CustomAttributeSerializer Class for serialization and hydration
21
+ # @option options [Symbol] :within name of nested json attribute to serialize/hyrdrate within
22
+ def transform(api_attribute, options = {})
23
+ if options[:as].present?
24
+ config.transforms_from[api_attribute] = options[:as]
25
+ config.transforms_to[api_attribute] = options[:as]
26
+ end
27
+ if options[:from].present?
28
+ config.transforms_from[api_attribute] = options[:from]
29
+ config.transforms_to[api_attribute] ||= options[:from]
30
+ end
31
+ if options[:to].present?
32
+ config.transforms_from[api_attribute] ||= options[:to]
33
+ config.transforms_to[api_attribute] = options[:to]
34
+ end
35
+ config.serializers[api_attribute] = options[:with] if options[:with].present?
36
+ config.namespaces[api_attribute] = options[:within] if options[:within].present?
37
+ expose api_attribute
38
+ end
39
+
40
+ # DSL Defines an attribute extension available for decoration and serialization
41
+ # @param api_attribute [Symbol] name of the attribute
42
+ def extension(api_attribute)
43
+ config.extensions[api_attribute] = true
44
+ config.serializers[api_attribute] = api_attribute.to_sym
45
+ expose api_attribute
46
+ end
47
+
48
+ # DSL Defines an alias that can be used instead of a Field Set
49
+ # @param field_set_name [Symbol] the alias name
50
+ # @param options [Hash]
51
+ # @option as [MIXED] a nested field_set collection
52
+ def expose(field_set_name, options = {})
53
+ field_set_name = field_set_name.to_sym
54
+ raise ActiveFacets::Errors::ConfigurationError.new(ActiveFacets::Errors::ConfigurationError::ALL_ATTRIBUTES_ERROR_MSG) if field_set_name == :all_attributes
55
+ raise ActiveFacets::Errors::ConfigurationError.new(ActiveFacets::Errors::ConfigurationError::ALL_FIELDS_ERROR_MSG) if field_set_name == :all
56
+ config.alias_field_set(field_set_name, options.key?(:as) ? options[:as] : field_set_name)
57
+ end
58
+
59
+ #DSL Defines an alias for common ActiveRecord attributes
60
+ def expose_timestamps
61
+ transform :created_at, with: :time
62
+ transform :updated_at, with: :time
63
+ expose :timestamps, as: [:id, :created_at, :updated_at]
64
+ end
65
+
66
+ #DSL Registers the class type to be serialized
67
+ def resource_class(klass)
68
+ config.resource_class = klass
69
+ end
70
+
71
+ # ###
72
+ # Public Interface
73
+ # ###
74
+
75
+ # Singleton
76
+ # @return [Serializer::Base]
77
+ def new
78
+ @instance ||= super
79
+ end
80
+
81
+ # TODO --jdc change new/instance contract
82
+ # def instance
83
+ # @instance ||= new
84
+ # end
85
+
86
+ # Memoized class getter
87
+ # @return [Config]
88
+ def config
89
+ @config ||= ActiveFacets::Config.new
90
+ end
91
+
92
+ end
93
+
94
+ # INSTANCE METHODS
95
+
96
+ # This method returns a hash suitable to pass into ActiveRecord.includes to avoid N+1
97
+ # @param field_set [Field Set] collections of fields to be serialized from this resource later
98
+ # given:
99
+ # :basic is a defined collection
100
+ # :extended is a defined collection
101
+ # :orders is a defined association
102
+ # :line_items is a defined association on OrderSerializer
103
+ # examples:
104
+ # :basic
105
+ # [:basic]
106
+ # {basic: nil}
107
+ # [:basic, :extended]
108
+ # [:basic, :extended, :orders]
109
+ # [:basic, :extended, {orders: :basic}]
110
+ # [:basic, :extended, {orders: [:basic, :extended]}]
111
+ # [:basic, :extended, {orders: [:basic, :line_items]}]
112
+ # [:basic, :extended, {orders: [:basic, {line_items: :extended}]}]
113
+ # @return [Hash]
114
+ def scoped_includes(field_set = nil, options = {})
115
+ result = {}
116
+ config.field_set_itterator(field_set) do |field, nested_field_set|
117
+ case value = scoped_include(field, nested_field_set, options)
118
+ when nil
119
+ when Hash
120
+ result.deep_merge! value
121
+ else
122
+ result[value] ||= nil
123
+ end
124
+ end
125
+ result
126
+ end
127
+
128
+ def scoped_include(field, nested_field_set, options)
129
+ if is_association? field
130
+ attribute = resource_attribute_name(field)
131
+ if nested_field_set
132
+ serializer_class = get_association_serializer_class(field, options)
133
+ attribute = { attribute => serializer_class.present? ? serializer_class.scoped_includes(nested_field_set, options) : nested_field_set }
134
+ end
135
+ attribute
136
+ else
137
+ custom_includes(field, options)
138
+ end
139
+ end
140
+
141
+ # Returns field_set serialized for dependant resources in custom attribute serializers & extensions
142
+ # @param field [Field]
143
+ # @return [Field Set]
144
+ def custom_includes(field, options)
145
+ attribute = resource_attribute_name(field)
146
+ custom_serializer_name = config.serializers[attribute]
147
+
148
+ if custom_serializer_name
149
+ custom_serializer = get_custom_serializer_class(custom_serializer_name, options)
150
+ if custom_serializer.respond_to? :custom_scope
151
+ custom_serializer.custom_scope
152
+ else
153
+ nil
154
+ end
155
+ else
156
+ nil
157
+ end
158
+ end
159
+
160
+ # Gets flattened fields from a Field Set Alias
161
+ # @param field_set_alias [Symbol] to retrieve aliased field_sets for
162
+ # @param include_relations [Boolean]
163
+ # @param include_nested_field_sets [Boolean]
164
+ # @return [Array] of symbols
165
+ def exposed_aliases(field_set_alias = :all, include_relations = false, include_nested_field_sets = false)
166
+ return include_nested_field_sets ? field_set_alias : [field_set_alias] unless normalized_field_sets = config.normalized_field_sets[field_set_alias]
167
+ result = normalized_field_sets[include_relations ? :fields : :attributes]
168
+ return result if include_nested_field_sets
169
+ result.keys.map(&:to_sym).sort
170
+ end
171
+
172
+ # This method returns a ActiveRecord model updated to match a JSON of hash values
173
+ # @param resource [ActiveRecord] to hydrate
174
+ # @param attribute [Hash] subset of the values returned by {resource.as_json}
175
+ # @return [ActiveRecord] resource
176
+ def from_hash(resource, attributes, options = {})
177
+ ActiveFacets::Serializer::Facade.new(self, resource, options).from_hash(attributes)
178
+ end
179
+
180
+ # This method returns a JSON of hash values representing the resource(s)
181
+ # @param resource [ActiveRecord || Array] CollectionProxy object ::or:: a collection of resources
182
+ # @param options [Hash] collection of values required that are not available in lexical field_set
183
+ # @return [JSON] representing the resource
184
+ def as_json(resources, options = {})
185
+ resource_itterator(resources) do |resource|
186
+ facade = ActiveFacets::Serializer::Facade.new(self, resource, options)
187
+ facade.as_json
188
+ end
189
+ end
190
+
191
+ # Memoized instance getter
192
+ # @return [Config]
193
+ def config
194
+ @config ||= self.class.config
195
+ end
196
+
197
+ ### TODO --jdc ^^ START REFLECTOR
198
+ # move all this to config or resource manager
199
+
200
+ # Constantizes the appropriate resource serializer class
201
+ # @return [Class]
202
+ def resource_class
203
+ @resource_class ||= config.resource_class
204
+ end
205
+
206
+ # Constantizes an appropriate resource serializer class for relations
207
+ # @param field [Symbol] to find relation reflection for
208
+ # @return [Reflection | nil]
209
+ def get_association_reflection(field)
210
+ @association_reflections ||= {}
211
+ @association_reflections[field] ||= resource_class.reflect_on_association(resource_attribute_name(field).to_sym)
212
+ end
213
+
214
+ # Constantizes an appropriate resource serializer class
215
+ # @param field [Symbol] to test as relation and find serializer class for
216
+ # @return [Class | nil]
217
+ def get_association_serializer_class(field, options)
218
+ @association_serializers ||= {}
219
+ unless @association_serializers.key? field
220
+ @association_serializers[field] = nil
221
+ #return nil if field isn't an association
222
+ if reflection = get_association_reflection(field)
223
+ #return nil if association doesn't have a custom class
224
+ @association_serializers[field] = ActiveFacets::ResourceManager.instance.serializer_for(reflection.klass, options)
225
+ end
226
+ end
227
+ @association_serializers[field]
228
+ end
229
+
230
+ # Constantizes an appropriate attribute serializer class
231
+ # @param attribute [Symbol] base_name of attribute serializer class to find
232
+ # @param options [Hash]
233
+ # @return [Class | nil]
234
+ def get_custom_serializer_class(attribute, options)
235
+ @custom_serializers ||= {}
236
+ @custom_serializers[attribute] ||= ActiveFacets::ResourceManager.instance.attribute_serializer_class_for(resource_class, attribute, options)
237
+ end
238
+
239
+ # Determines if public attribute maps to a private relation
240
+ # @param field [Symbol] public attribute name
241
+ # @return [Boolean]
242
+ def is_association?(field)
243
+ !!get_association_reflection(field)
244
+ end
245
+
246
+ # Renames attribute between resource.attribute_name and json.attribute_name
247
+ # @param field [Symbol] attribute name
248
+ # @param direction [Symbol] to apply translation
249
+ # @return [Symbol]
250
+ def resource_attribute_name(field, direction = :from)
251
+ (config.transforms(direction)[field] || field).to_sym
252
+ end
253
+
254
+ ### TODO ^^ END REFLECTOR
255
+
256
+ protected
257
+
258
+ # @return [Serializer::Base]
259
+ def initialize
260
+ config.compile! self
261
+ rescue SystemStackError => e
262
+ raise ActiveFacets::Errors::ConfigurationError.new(ActiveFacets::Errors::ConfigurationError::STACK_ERROR_MSG)
263
+ end
264
+
265
+ # Itterates a resource collection invoking block
266
+ # @param resource [ActiveRecord || Array] to traverse
267
+ # @param block [Block] to call for each resource
268
+ def resource_itterator(resource)
269
+ if resource.is_a?(Array)
270
+ resource.map do |resource|
271
+ yield resource
272
+ end.compact
273
+ else
274
+ yield resource
275
+ end
276
+ end
277
+
278
+ # Removes self.class_name from the end of self.module_name
279
+ # @return [String]
280
+ def module_base_name
281
+ @module_base_name ||= module_name.deconstantize
282
+ end
283
+
284
+ # Removes self.class_name from the end of self.class.name
285
+ # @return [String]
286
+ def module_name
287
+ @module_name ||= self.class.name.deconstantize
288
+ end
289
+ end
290
+ end
291
+ end
@@ -0,0 +1,223 @@
1
+ module ActiveFacets
2
+ module Serializer
3
+ class Facade
4
+ attr_accessor :serializer, # Serializer:Base
5
+ :resource, # Object to delegate to
6
+ :options, # Options Hash passed to as_json
7
+ :opts, # RCB specific options inside Options Hash
8
+ :fields, # Field Sets to apply
9
+ :field_overrides, # Field Overrides to apply
10
+ :overrides, # Field Overrides specific to resource
11
+ :version, # Serializer version to apply
12
+ :filters, # Filters to apply
13
+ :filters_enabled # Apply Filters, override global setting
14
+
15
+ # @return [Serializer::Facade]
16
+ def initialize(serializer, resource, options = {})
17
+ self.serializer = serializer
18
+ self.resource = resource
19
+ self.options = options
20
+ self.opts = options[ActiveFacets.opts_key] || {}
21
+
22
+ self.fields = opts[ActiveFacets.fields_key]
23
+ self.field_overrides = opts[ActiveFacets.field_overrides_key] || {}
24
+ self.overrides = ActiveFacets::ResourceManager.instance.resource_map(resource_class).inject({}) { |overrides, map_entry|
25
+ overrides.merge(field_overrides[map_entry] || {})
26
+ }
27
+
28
+ self.version = opts[ActiveFacets.version_key]
29
+ self.filters = opts[ActiveFacets.filters_key]
30
+ self.filters_enabled = opts.key?(ActiveFacets.filters_force_key) ? opts[ActiveFacets.filters_force_key] : ActiveFacets.filters_enabled
31
+ end
32
+
33
+ # This method returns a JSON of hash values representing the resource
34
+ # @param resource [ActiveRecord || Array] CollectionProxy object ::or:: a collection of resources
35
+ # @param opts [Hash] collection of values required that are not available in lexical scope
36
+ # @return [JSON] representing the values returned by {resource.serialize} method
37
+ def as_json
38
+ ActiveFacets.document_cache.fetch(self) {
39
+ serialize!
40
+ }
41
+ end
42
+
43
+ # @return [String] a cache key that can be used to identify this resource
44
+ def cache_key
45
+ version.to_s +
46
+ resource.cache_key +
47
+ fields.to_s +
48
+ field_overrides.to_s +
49
+ filters.to_s
50
+ end
51
+
52
+ # This method returns a ActiveRecord model updated to match a JSON of hash values
53
+ # @param resource [ActiveRecord] to hydrate
54
+ # @param attribute [Hash] subset of the values returned by {resource.as_json}
55
+ # @return [ActiveRecord] resource
56
+ def from_hash(attributes)
57
+ hydrate! ActiveFacets.deep_copy(attributes)
58
+ end
59
+
60
+ private
61
+
62
+ # @return [Config]
63
+ def config
64
+ @config ||= serializer.config
65
+ end
66
+
67
+ # @return [Boolean]
68
+ def allowed_field?(field)
69
+ overrides.blank? || overrides[field.to_sym]
70
+ end
71
+
72
+ # Checks field to see if it is a relation that is valid have Field Sets applied to it
73
+ # @param expression [Symbol]
74
+ # @return [Boolean]
75
+ def is_expression_scopeable?(expression)
76
+ resource.persisted? && is_active_relation?(expression) && is_relation_scopeable?(expression)
77
+ end
78
+
79
+ # Checks field to see if expression is a relation
80
+ # @return [Boolean]
81
+ def is_active_relation?(expression)
82
+ #TODO -jdc let me know if anyone finds a better way to identify Proxy objects
83
+ #NOTE:: Proxy Collections use method missing for most actions; .scoped is the only reliable test
84
+ expression.is_a?(ActiveRecord::Relation) || (expression.is_a?(Array) && expression.respond_to?(:scoped))
85
+ end
86
+
87
+ # Checks expression to determine if filters are enabled
88
+ # @return [Boolean]
89
+ def is_relation_scopeable?(expression)
90
+ filters_enabled
91
+ end
92
+
93
+ #TODO --jdc this is a hack for assets. fix by making this class the primary entry point
94
+ # rather than serializers and pass in resource class, or better yet, enforce pseudo resource classes
95
+ # @return [Class]
96
+ def resource_class
97
+ resource.is_a?(ActiveRecord::Base) ? resource.class : serializer.resource_class
98
+ end
99
+
100
+ # This method returns a JSON of hash values representing the resource
101
+ # @return [JSON]
102
+ def serialize!
103
+ json = {}.with_indifferent_access
104
+
105
+ config.field_set_itterator(fields) do |scope, nested_scopes|
106
+ begin
107
+ json[scope] = get_resource_attribute scope, nested_scopes if allowed_field?(scope)
108
+ rescue ActiveFacets::Errors::AttributeError => e
109
+ # Deliberately do nothing. Ignore scopes that do not map to resource methods (or aliases)
110
+ end
111
+ end
112
+
113
+ apply_custom_serializers! json
114
+ end
115
+
116
+ # Gets serialized field from the resource
117
+ # @param field [Symbol]
118
+ # @param nested_scope [Mixed] Field Set to pass for relations
119
+ # @return [Mixed]
120
+ def get_resource_attribute(field, nested_field_set)
121
+ if config.namespaces.key? field
122
+ if ns = get_resource_attribute!(config.namespaces[field])
123
+ ns[serializer.resource_attribute_name(field).to_s]
124
+ else
125
+ nil
126
+ end
127
+ elsif config.extensions.key?(field)
128
+ field
129
+ elsif serializer.is_association?(field)
130
+ get_association_attribute(field, nested_field_set)
131
+ else
132
+ get_resource_attribute!(serializer.resource_attribute_name(field))
133
+ end
134
+ end
135
+
136
+ # Invokes a method on the resource to retrieve the attribute value
137
+ # @param attribute [Symbol] identifies
138
+ # @return [Object]
139
+ def get_resource_attribute!(attribute)
140
+ raise ActiveFacets::Errors::AttributeError.new("#{resource.class.name}.#{attribute} missing") unless resource.respond_to?(attribute,true)
141
+ resource.send(attribute)
142
+ end
143
+
144
+ # Retrieves scoped association from cache or record
145
+ # @param field [Symbol] attribute to get
146
+ # @return [Array | ActiveRelation] of ActiveRecord
147
+ def get_association_attribute(field, nested_field_set)
148
+ association = serializer.resource_attribute_name(field)
149
+
150
+ ActiveFacets.document_cache.fetch_association(self, association, opts) do
151
+ attribute = resource.send(association)
152
+ attribute = attribute.scope_filters(filters) if is_expression_scopeable?(attribute)
153
+ ActiveFacets.restore_opts_after(options, ActiveFacets.fields_key, nested_field_set) do
154
+ attribute.as_json(options)
155
+ end
156
+ end
157
+ end
158
+
159
+ # Modifies json by reference by applying custom serializers to all attributes registered with custom serializers
160
+ # @param json [JSON] structure
161
+ # @return [JSON]
162
+ def apply_custom_serializers!(json)
163
+ config.serializers.each do |scope, type|
164
+ scope_s = scope
165
+ json[scope_s] = ActiveFacets.restore_opts_after(options, ActiveFacets.fields_key, fields) do
166
+ serializer.get_custom_serializer_class(type, options).serialize(json[scope_s], resource, options)
167
+ end if json.key? scope_s
168
+ end
169
+
170
+ json
171
+ end
172
+
173
+ # This method returns a ActiveRecord model updated to match a JSON of hash values
174
+ # @param json [JSON] attributes identical to the values returned by {serialize}
175
+ # @return [ActiveRecord] resource
176
+ def hydrate!(json)
177
+ filter_allowed_keys! json, serializer.exposed_aliases
178
+ hydrate_scopes! json
179
+ json.each do |scope, value|
180
+ set_resource_attribute scope, value
181
+ end
182
+
183
+ resource
184
+ end
185
+
186
+ # Modifies json by reference to remove all attributes from json which aren't exposed
187
+ # @param json [JSON] structure
188
+ # @param keys [Array] of attributes
189
+ # @return [JSON]
190
+ def filter_allowed_keys!(json, keys)
191
+ values = json.with_indifferent_access
192
+ json.replace ( keys.inject({}.with_indifferent_access) { |results, key|
193
+ results[key] = values[key] if values.key?(key)
194
+ results
195
+ } )
196
+ end
197
+
198
+ # Modifies json by reference by applying custom hydration to all fields registered with custom serializers
199
+ # @param json [JSON] structure
200
+ # @return [JSON]
201
+ def hydrate_scopes!(json)
202
+ config.serializers.each do |scope, type|
203
+ scope_s = scope
204
+ json[scope_s] = serializer.get_custom_serializer_class(type, options).hydrate(json[scope], resource, options) if json.key? scope_s
205
+ end
206
+ json
207
+ end
208
+
209
+ # Sets the specified attribute on the resource
210
+ # @param field [Symbol] to set
211
+ # @param value [Mixed] to set
212
+ # @return [Mixed] for chaining
213
+ def set_resource_attribute(field, value)
214
+ if config.namespaces.key? field
215
+ resource.send(config.namespaces[field].to_s+"=", {}) unless resource.send(config.namespaces[field]).present?
216
+ resource.send(config.namespaces[field])[serializer.resource_attribute_name(field,:to).to_s] = value
217
+ else
218
+ resource.send("#{serializer.resource_attribute_name(field,:to)}=", value)
219
+ end
220
+ end
221
+ end
222
+ end
223
+ end