active_facet 1.2.8

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