active_facet 1.2.8

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.
@@ -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