# frozen_string_literal: true module JSONAPI class ResourceSerializer attr_reader :link_builder, :key_formatter, :serialization_options, :fields, :include_directives, :always_include_to_one_linkage_data, :always_include_to_many_linkage_data, :options # initialize # Options can include # include: # Purpose: determines which objects will be side loaded with the source objects in a linked section # Example: ['comments','author','comments.tags','author.posts'] # fields: # Purpose: determines which fields are serialized for a resource type. This encompasses both attributes and # relationship ids in the links section for a resource. Fields are global for a resource type. # Example: { people: [:id, :email, :comments], posts: [:id, :title, :author], comments: [:id, :body, :post]} # key_formatter: KeyFormatter instance to override the default configuration # serialization_options: additional options that will be passed to resource meta and links lambdas def initialize(primary_resource_klass, options = {}) @options = options @primary_resource_klass = primary_resource_klass @fields = options.fetch(:fields, {}) @include = options.fetch(:include, []) @include_directives = options.fetch(:include_directives, JSONAPI::IncludeDirectives.new(@primary_resource_klass, @include)) @key_formatter = options.fetch(:key_formatter, JSONAPI.configuration.key_formatter) @id_formatter = ValueFormatter.value_formatter_for(:id) @link_builder = generate_link_builder(primary_resource_klass, options) @always_include_to_one_linkage_data = options.fetch(:always_include_to_one_linkage_data, JSONAPI.configuration.always_include_to_one_linkage_data) @always_include_to_many_linkage_data = options.fetch(:always_include_to_many_linkage_data, JSONAPI.configuration.always_include_to_many_linkage_data) @serialization_options = options.fetch(:serialization_options, {}) # Warning: This makes ResourceSerializer non-thread-safe. That's not a problem with the # request-specific way it's currently used, though. @value_formatter_type_cache = NaiveCache.new{|arg| ValueFormatter.value_formatter_for(arg) } @_config_keys = {} @_supplying_attribute_fields = {} @_supplying_relationship_fields = {} end # Converts a single resource, or an array of resources to a hash, conforming to the JSONAPI structure def serialize_to_hash(source) include_related = include_directives[:include_related] resource_set = JSONAPI::ResourceSet.new(source, include_related, options) resource_set.populate!(self, options[:context], options) if source.is_a?(Array) serialize_resource_set_to_hash_plural(resource_set) else serialize_resource_set_to_hash_single(resource_set) end end # Converts a resource_set to a hash, conforming to the JSONAPI structure def serialize_resource_set_to_hash_single(resource_set) primary_objects = [] included_objects = [] resource_set.resource_klasses.each_value do |resource_klass| resource_klass.each_value do |resource| serialized_resource = object_hash(resource[:resource], resource[:relationships]) if resource[:primary] primary_objects.push(serialized_resource) else included_objects.push(serialized_resource) end end end fail "Too many primary objects for show" if (primary_objects.count > 1) primary_hash = { 'data' => primary_objects[0] } primary_hash['included'] = included_objects if included_objects.size > 0 primary_hash end def serialize_resource_set_to_hash_plural(resource_set) primary_objects = [] included_objects = [] resource_set.resource_klasses.each_value do |resource_klass| resource_klass.each_value do |resource| serialized_resource = object_hash(resource[:resource], resource[:relationships]) if resource[:primary] primary_objects.push(serialized_resource) else included_objects.push(serialized_resource) end end end primary_hash = { 'data' => primary_objects } primary_hash['included'] = included_objects if included_objects.size > 0 primary_hash end def serialize_related_resource_set_to_hash_plural(resource_set, _source_resource) return serialize_resource_set_to_hash_plural(resource_set) end def serialize_to_relationship_hash(source, requested_relationship, resource_ids) if requested_relationship.is_a?(JSONAPI::Relationship::ToOne) data = to_one_linkage(resource_ids[0]) else data = to_many_linkage(resource_ids) end rel_hash = { 'data': data } links = default_relationship_links(source, requested_relationship) rel_hash['links'] = links unless links.blank? rel_hash end def format_key(key) @key_formatter.format(key) end def unformat_key(key) @key_formatter.unformat(key) end def format_value(value, format) @value_formatter_type_cache.get(format).format(value) end def config_key(resource_klass) @_config_keys.fetch resource_klass do desc = self.config_description(resource_klass).map(&:inspect).join(",") key = JSONAPI.configuration.resource_cache_digest_function.call(desc) @_config_keys[resource_klass] = "SRLZ-#{key}" end end def config_description(resource_klass) { class_name: self.class.name, serialization_options: serialization_options.sort.map(&:as_json), supplying_attribute_fields: supplying_attribute_fields(resource_klass).sort, supplying_relationship_fields: supplying_relationship_fields(resource_klass).sort, link_builder_base_url: link_builder.base_url, key_formatter_class: key_formatter.uncached.class.name, always_include_to_one_linkage_data: always_include_to_one_linkage_data, always_include_to_many_linkage_data: always_include_to_many_linkage_data } end def object_hash(source, relationship_data) obj_hash = {} return obj_hash if source.nil? fetchable_fields = Set.new(source.fetchable_fields) if source.is_a?(JSONAPI::CachedResponseFragment) id_format = source.resource_klass._attribute_options(:id)[:format] id_format = 'id' if id_format == :default obj_hash['id'] = format_value(source.id, id_format) obj_hash['type'] = source.type obj_hash['links'] = source.links_json if source.links_json obj_hash['attributes'] = source.attributes_json if source.attributes_json relationships = cached_relationships_hash(source, fetchable_fields, relationship_data) obj_hash['relationships'] = relationships unless relationships.blank? obj_hash['meta'] = source.meta_json if source.meta_json else # TODO Should this maybe be using @id_formatter instead, for consistency? id_format = source.class._attribute_options(:id)[:format] # protect against ids that were declared as an attribute, but did not have a format set. id_format = 'id' if id_format == :default obj_hash['id'] = format_value(source.id, id_format) obj_hash['type'] = format_key(source.class._type.to_s) links = links_hash(source) obj_hash['links'] = links unless links.empty? attributes = attributes_hash(source, fetchable_fields) obj_hash['attributes'] = attributes unless attributes.empty? relationships = relationships_hash(source, fetchable_fields, relationship_data) obj_hash['relationships'] = relationships unless relationships.blank? meta = meta_hash(source) obj_hash['meta'] = meta unless meta.empty? end obj_hash end private def supplying_attribute_fields(resource_klass) @_supplying_attribute_fields.fetch resource_klass do attrs = Set.new(resource_klass._attributes.keys.map(&:to_sym)) cur = resource_klass while !cur.root? # do not traverse beyond the first root resource if @fields.has_key?(cur._type) attrs &= @fields[cur._type] break end cur = cur.superclass end @_supplying_attribute_fields[resource_klass] = attrs end end def supplying_relationship_fields(resource_klass) @_supplying_relationship_fields.fetch resource_klass do relationships = Set.new(resource_klass._relationships.keys.map(&:to_sym)) cur = resource_klass while !cur.root? # do not traverse beyond the first root resource if @fields.has_key?(cur._type) relationships &= @fields[cur._type] break end cur = cur.superclass end @_supplying_relationship_fields[resource_klass] = relationships end end def attributes_hash(source, fetchable_fields) fields = fetchable_fields & supplying_attribute_fields(source.class) fields.each_with_object({}) do |name, hash| unless name == :id format = source.class._attribute_options(name)[:format] hash[format_key(name)] = format_value(source.public_send(name), format) end end end def custom_generation_options @_custom_generation_options ||= { serializer: self, serialization_options: @serialization_options } end def meta_hash(source) meta = source.meta(custom_generation_options) (meta.is_a?(Hash) && meta) || {} end def links_hash(source) links = custom_links_hash(source) if !links.key?('self') && !source.class.exclude_link?(:self) links['self'] = link_builder.self_link(source) end links.compact end def custom_links_hash(source) custom_links = source.custom_links(custom_generation_options) (custom_links.is_a?(Hash) && custom_links) || {} end def relationships_hash(source, fetchable_fields, relationship_data) relationships = source.class._relationships.select{|k,_v| fetchable_fields.include?(k) } field_set = supplying_relationship_fields(source.class) & relationships.keys relationships.each_with_object({}) do |(name, relationship), hash| include_data = false if field_set.include?(name) if relationship_data[name] include_data = true if relationship.is_a?(JSONAPI::Relationship::ToOne) rids = relationship_data[name].first else rids = relationship_data[name] end end ro = relationship_object(source, relationship, rids, include_data) hash[format_key(name)] = ro unless ro.blank? end end end def cached_relationships_hash(source, fetchable_fields, relationship_data) relationships = {} source.relationships.try(:each_pair) do |k,v| if fetchable_fields.include?(unformat_key(k).to_sym) relationships[k.to_sym] = v end end field_set = supplying_relationship_fields(source.resource_klass).collect {|k| format_key(k).to_sym } & relationships.keys relationships.each_with_object({}) do |(name, relationship), hash| if field_set.include?(name) relationship_name = unformat_key(name).to_sym relationship_klass = source.resource_klass._relationship(relationship_name) if relationship_klass.is_a?(JSONAPI::Relationship::ToOne) # include_linkage = @always_include_to_one_linkage_data | relationship_klass.always_include_linkage_data if relationship_data[relationship_name] rids = relationship_data[relationship_name].first relationship['data'] = to_one_linkage(rids) end else # include_linkage = relationship_klass.always_include_linkage_data if relationship_data[relationship_name] rids = relationship_data[relationship_name] relationship['data'] = to_many_linkage(rids) end end hash[format_key(name)] = relationship end end end def self_link(source, relationship) link_builder.relationships_self_link(source, relationship) end def related_link(source, relationship) link_builder.relationships_related_link(source, relationship) end def default_relationship_links(source, relationship) links = {} links['self'] = self_link(source, relationship) unless relationship.exclude_link?(:self) links['related'] = related_link(source, relationship) unless relationship.exclude_link?(:related) links.compact end def to_many_linkage(rids) linkage = [] rids && rids.each do |details| id = details.id type = details.resource_klass.try(:_type) if type && id linkage.append({'type' => format_key(type), 'id' => @id_formatter.format(id)}) end end linkage end def to_one_linkage(rid) return unless rid { 'type' => format_key(rid.resource_klass._type), 'id' => @id_formatter.format(rid.id), } end def relationship_object_to_one(source, relationship, rid, include_data) link_object_hash = {} links = default_relationship_links(source, relationship) link_object_hash['links'] = links unless links.blank? link_object_hash['data'] = to_one_linkage(rid) if include_data link_object_hash end def relationship_object_to_many(source, relationship, rids, include_data) link_object_hash = {} links = default_relationship_links(source, relationship) link_object_hash['links'] = links unless links.blank? link_object_hash['data'] = to_many_linkage(rids) if include_data link_object_hash end def relationship_object(source, relationship, rid, include_data) if relationship.is_a?(JSONAPI::Relationship::ToOne) relationship_object_to_one(source, relationship, rid, include_data) elsif relationship.is_a?(JSONAPI::Relationship::ToMany) relationship_object_to_many(source, relationship, rid, include_data) end end def generate_link_builder(primary_resource_klass, options) LinkBuilder.new( base_url: options.fetch(:base_url, ''), primary_resource_klass: primary_resource_klass, route_formatter: options.fetch(:route_formatter, JSONAPI.configuration.route_formatter), url_helpers: options.fetch(:url_helpers, options[:controller]), ) end end end