# This serializer uses `.serializable_hash` to convert objects to Ruby primitives (with the # top-level being either an array or a hash). class RESTFramework::Serializers::NativeSerializer < RESTFramework::Serializers::BaseSerializer class_attribute :config class_attribute :singular_config class_attribute :plural_config class_attribute :action_config # Accept/ignore `*args` to be compatible with the `ActiveModel::Serializer#initialize` signature. def initialize(object=nil, *args, many: nil, model: nil, **kwargs) super(object, *args, **kwargs) if many.nil? # Determine if we are dealing with many objects or just one. @many = @object.is_a?(Enumerable) else @many = many end # Determine model either explicitly, or by inspecting @object or @controller. @model = model @model ||= @object.class if @object.is_a?(ActiveRecord::Base) @model ||= @object[0].class if @many && @object.is_a?(Enumerable) && @object.is_a?(ActiveRecord::Base) @model ||= @controller.class.get_model if @controller end # Get controller action, if possible. def get_action return @controller&.action_name&.to_sym end # Get a locally defined native serializer configuration, if one is defined. def get_local_native_serializer_config action = self.get_action if action && self.action_config # Index action should use :list serializer config if :index is not provided. action = :list if action == :index && !self.action_config.key?(:index) return self.action_config[action] if self.action_config[action] end # No action_config, so try singular/plural config if explicitly instructed to via @many. return self.plural_config if @many == true && self.plural_config return self.singular_config if @many == false && self.singular_config # Lastly, try returning the default config, or singular/plural config in that order. return self.config || self.singular_config || self.plural_config end # Get a native serializer configuration from the controller. def get_controller_native_serializer_config return nil unless @controller if @many == true controller_serializer = @controller.try(:native_serializer_plural_config) elsif @many == false controller_serializer = @controller.try(:native_serializer_singular_config) end return controller_serializer || @controller.try(:native_serializer_config) end # Filter a single subconfig for specific keys. By default, keys from `fields` are removed from the # provided `subcfg`. There are two (mutually exclusive) options to adjust the behavior: # # `add`: Add any `fields` to the `subcfg` which aren't already in the `subcfg`. # `only`: Remove any values found in the `subcfg` not in `fields`. def self.filter_subcfg(subcfg, fields:, add: false, only: false) raise "`add` and `only` conflict with one another" if add && only # Don't process nil `subcfg`s. return subcfg unless subcfg if subcfg.is_a?(Array) subcfg = subcfg.map(&:to_sym) if add # Only add fields which are not already included. subcfg += fields - subcfg elsif only subcfg.select! { |c| c.in?(fields) } else subcfg -= fields end elsif subcfg.is_a?(Hash) subcfg = subcfg.symbolize_keys if add # Add doesn't make sense in a hash context since we wouldn't know the values. elsif only subcfg.select! { |k, _v| k.in?(fields) } else subcfg.reject! { |k, _v| k.in?(fields) } end else # Subcfg is a single element (assume string/symbol). subcfg = subcfg.to_sym if add subcfg = subcfg.in?(fields) ? fields : [subcfg, *fields] elsif only subcfg = subcfg.in?(fields) ? subcfg : [] else subcfg = subcfg.in?(fields) ? [] : subcfg end end return subcfg end # Filter out configuration properties based on the :except/:only query parameters. def filter_from_request(cfg) return cfg unless @controller except_param = @controller.try(:native_serializer_except_query_param) only_param = @controller.try(:native_serializer_only_query_param) if except_param && except = @controller.request&.query_parameters&.[](except_param).presence if except = except.split(",").map(&:strip).map(&:to_sym).presence # Filter `only`, `except` (additive), `include`, `methods`, and `serializer_methods`. if cfg[:only] cfg[:only] = self.class.filter_subcfg(cfg[:only], fields: except) elsif cfg[:except] cfg[:except] = self.class.filter_subcfg(cfg[:except], fields: except, add: true) else cfg[:except] = except end cfg[:include] = self.class.filter_subcfg(cfg[:include], fields: except) cfg[:methods] = self.class.filter_subcfg(cfg[:methods], fields: except) cfg[:serializer_methods] = self.class.filter_subcfg( cfg[:serializer_methods], fields: except ) cfg[:includes_map] = self.class.filter_subcfg(cfg[:includes_map], fields: except) end elsif only_param && only = @controller.request&.query_parameters&.[](only_param).presence if only = only.split(",").map(&:strip).map(&:to_sym).presence # Filter `only`, `include`, and `methods`. Adding anything to `except` is not needed, # because any configuration there takes precedence over `only`. if cfg[:only] cfg[:only] = self.class.filter_subcfg(cfg[:only], fields: only, only: true) else cfg[:only] = only end cfg[:include] = self.class.filter_subcfg(cfg[:include], fields: only, only: true) cfg[:methods] = self.class.filter_subcfg(cfg[:methods], fields: only, only: true) cfg[:serializer_methods] = self.class.filter_subcfg( cfg[:serializer_methods], fields: only, only: true ) cfg[:includes_map] = self.class.filter_subcfg(cfg[:includes_map], fields: only, only: true) end end return cfg end # Get the associations limit from the controller. def _get_associations_limit return @_get_associations_limit if defined?(@_get_associations_limit) limit = @controller&.native_serializer_associations_limit # Extract the limit from the query parameters if it's set. if query_param = @controller&.native_serializer_associations_limit_query_param if @controller.request.query_parameters.key?(query_param) query_limit = @controller.request.query_parameters[query_param].to_i if query_limit > 0 limit = query_limit else limit = nil end end end return @_get_associations_limit = limit end # Get a serializer configuration from the controller. `@controller` and `@model` must be set. def _get_controller_serializer_config(fields) columns = [] includes = {} methods = [] serializer_methods = {} # We try to construct performant queries using Active Record's `includes` method. This is # sometimes impossible, for example when limiting the number of associated records returned, so # we should only add associations here when it's useful, and using the `Bullet` gem is helpful # in determining when that is the case. includes_map = {} column_names = @model.column_names reflections = @model.reflections attachment_reflections = @model.attachment_reflections fields.each do |f| field_config = @controller.class.get_field_config(f) next if field_config[:write_only] if f.in?(column_names) columns << f elsif ref = reflections[f] sub_columns = [] sub_methods = [] field_config[:sub_fields].each do |sf| if !ref.polymorphic? && sf.in?(ref.klass.column_names) sub_columns << sf else sub_methods << sf end end sub_config = {only: sub_columns, methods: sub_methods} # Apply certain rules regarding collection associations. if ref.collection? # If we need to limit the number of serialized association records, then dynamically add a # serializer method to do so. if limit = self._get_associations_limit serializer_methods[f] = f self.define_singleton_method(f) do |record| next record.send(f).limit(limit).as_json(**sub_config) end else includes[f] = sub_config includes_map[f] = f.to_sym end # If we need to include the association count, then add it here. if @controller.native_serializer_include_associations_count method_name = "#{f}.count" serializer_methods[method_name] = method_name self.define_singleton_method(method_name) do |record| next record.send(f).count end end else includes[f] = sub_config includes_map[f] = f.to_sym end elsif ref = reflections["rich_text_#{f}"] # ActionText Integration: Define rich text serializer method. includes_map[f] = :"rich_text_#{f}" serializer_methods[f] = f self.define_singleton_method(f) do |record| next record.send(f).to_s end elsif ref = attachment_reflections[f] # ActiveStorage Integration: Define attachment serializer method. if ref.macro == :has_one_attached serializer_methods[f] = f includes_map[f] = {"#{f}_attachment": :blob} self.define_singleton_method(f) do |record| next record.send(f).attachment&.url end elsif ref.macro == :has_many_attached serializer_methods[f] = f includes_map[f] = {"#{f}_attachments": :blob} self.define_singleton_method(f) do |record| # Iterating the collection yields attachment objects. next record.send(f).map(&:url) end end elsif @model.method_defined?(f) methods << f end end return { only: columns, include: includes, methods: methods, serializer_methods: serializer_methods, includes_map: includes_map, } end # Get the raw serializer config, prior to any adjustments from the request. # # Use `deep_dup` on any class mutables (array, hash, etc) to avoid mutating class state. def get_raw_serializer_config # Return a locally defined serializer config if one is defined. if local_config = self.get_local_native_serializer_config return local_config.deep_dup end # Return a serializer config if one is defined on the controller. if serializer_config = self.get_controller_native_serializer_config return serializer_config.deep_dup end # If the config wasn't determined, build a serializer config from controller fields. if @model && fields = @controller&.get_fields return self._get_controller_serializer_config(fields.deep_dup) end # By default, pass an empty configuration, using the default Rails serializer. return {} end # Get a configuration passable to `serializable_hash` for the object, filtered if required. def get_serializer_config return self.filter_from_request(self.get_raw_serializer_config) end # Serialize a single record and merge results of `serializer_methods`. def _serialize(record, config, serializer_methods) # Ensure serializer_methods is either falsy, or a hash. if serializer_methods && !serializer_methods.is_a?(Hash) serializer_methods = [serializer_methods].flatten.map { |m| [m, m] }.to_h end # Merge serialized record with any serializer method results. return record.serializable_hash(config).merge( serializer_methods&.map { |m, k| [k.to_sym, self.send(m, record)] }.to_h, ) end def serialize(*args) config = self.get_serializer_config serializer_methods = config.delete(:serializer_methods) includes_map = config.delete(:includes_map) if @object.respond_to?(:to_ary) # Preload associations using `includes` to avoid N+1 queries. For now this also allows filter # backends to use associated data; perhaps it may be wise to have a system in place for # filters to preload their own associations? @object = @object.includes(*includes_map.values) if includes_map.present? return @object.map { |r| self._serialize(r, config, serializer_methods) } end return self._serialize(@object, config, serializer_methods) end # Allow a serializer instance to be used as a hash directly in a nested serializer config. def [](key) @_nested_config ||= self.get_serializer_config return @_nested_config[key] end def []=(key, value) @_nested_config ||= self.get_serializer_config return @_nested_config[key] = value end # Allow a serializer class to be used as a hash directly in a nested serializer config. def self.[](key) @_nested_config ||= self.new.get_serializer_config return @_nested_config[key] end def self.[]=(key, value) @_nested_config ||= self.new.get_serializer_config return @_nested_config[key] = value end end # Alias for convenience. RESTFramework::NativeSerializer = RESTFramework::Serializers::NativeSerializer