Compare commits

...

1 Commits

Author SHA1 Message Date
Stas SUȘCOV
fd37936812
Rewrite the old implementation. 2020-11-13 16:29:00 +00:00
16 changed files with 935 additions and 112 deletions

View File

@ -2,6 +2,9 @@ require:
- rubocop-performance
- rubocop-rspec
AllCops:
NewCops: enable
Style/FrozenStringLiteralComment:
Enabled: false
@ -38,41 +41,25 @@ Performance/TimesMap:
Exclude:
- 'spec/**/**.rb'
# TODO: Fix these...
Style/Documentation:
Metrics/ModuleLength:
Enabled: false
Style/GuardClause:
Exclude:
- 'lib/**/**.rb'
Style/ConditionalAssignment:
Exclude:
- 'lib/**/**.rb'
Style/IfUnlessModifier:
Exclude:
- 'lib/**/**.rb'
Lint/AssignmentInCondition:
Exclude:
- 'lib/**/**.rb'
Metrics:
Exclude:
- 'lib/**/**.rb'
Metrics/BlockLength:
Exclude:
- 'spec/**/**.rb'
Metrics/MethodLength:
Enabled: false
Layout/LineLength:
Exclude:
- 'lib/**/**.rb'
Metrics/CyclomaticComplexity:
Max: 12
Naming/PredicateName:
Exclude:
- 'lib/**/**.rb'
Metrics/PerceivedComplexity:
Max: 12
Metrics/AbcSize:
Max: 36
Naming/AccessorMethodName:
Exclude:
- 'lib/**/**.rb'
- '**/**/dsl.rb'

View File

@ -19,9 +19,7 @@ Gem::Specification.new do |gem|
gem.require_paths = ['lib']
gem.extra_rdoc_files = ['LICENSE.txt', 'README.md']
gem.add_runtime_dependency('activesupport', '>= 4.2')
gem.add_development_dependency('activerecord')
gem.add_development_dependency('dry-inflector')
gem.add_development_dependency('bundler')
gem.add_development_dependency('byebug')
gem.add_development_dependency('ffaker')
@ -33,4 +31,5 @@ Gem::Specification.new do |gem|
gem.add_development_dependency('rubocop-rspec')
gem.add_development_dependency('simplecov')
gem.add_development_dependency('sqlite3')
gem.add_development_dependency('activesupport')
end

View File

@ -1,12 +1,67 @@
require 'fast_jsonapi'
require 'jsonapi/serializer/base'
require 'jsonapi/serializer/core'
require 'jsonapi/serializer/dsl'
require 'jsonapi/serializer/errors'
require 'jsonapi/serializer/trackable'
# Provides JSONAPI related functionality
module JSONAPI
# Provides JSONAPI serialization functionality
module Serializer
# TODO: Move and cleanup the old implementation...
extend Trackable
# Self registers any inherited/extended class to keep track of it
#
# @return nothing
def self.included(base)
super
register_serializer(base)
base.class_eval do
include FastJsonapi::ObjectSerializer
end
include ::JSONAPI::Serializer::Base
extend ::JSONAPI::Serializer::DSL
extend ::JSONAPI::Serializer::Core
end
end
# Serializes an object or a collection
#
# The `options` are passed to the serializer instance and follow the
# same purpose.
#
# The only extra option is the `options[:serializers]`
# one can use to pass a mapping of object to serializer classes to
# be strict about how each object is serialized.
#
# @param object_or_collection [Object] to be serialized
# @param options [Hash] serialization parameters
# @return [Hash] of serialized JSONAPI data
def self.serialize(object_or_collection, options: nil)
options = options.dup || {}
serializers = options.delete(:serializers)
is_col = options[:is_collection] = collection?(
object_or_collection, force: options[:is_collection]
)
serializer = for_object(object_or_collection.first, serializers) if is_col
serializer ||= for_object(object_or_collection, serializers)
serializer.new(object_or_collection, options).serializable_hash
end
# Detects a collection/enumerable
#
# @param resource [Object] to detect
# @return [TrueClass] on a successful detection
def self.collection?(resource, force: nil)
return force unless force.nil?
# Rails 4 does not use [Enumerable]...
active_record = defined?(ActiveRecord::Relation)
return true if active_record && resource.is_a?(ActiveRecord::Relation)
resource.is_a?(Enumerable) && !resource.respond_to?(:each_pair)
end
end
end

View File

@ -0,0 +1,53 @@
# frozen_string_literal: true
module JSONAPI
module Serializer
# Our serializer PORO functionality
module Base
def initialize(resource, opts = {})
@resource = resource
@options = opts.dup
@params = @options.delete(:params) || {}
@fieldsets = @options.delete(:fields) || {}
@includes = @options.delete(:include) || []
@include = @includes.map(&:to_s).map(&:strip).reject(&:empty?)
end
def serializable_hash
is_collection = ::JSONAPI::Serializer.collection?(
@resource, force: @options[:is_collection]
)
jsonapi = { data: nil }
jsonapi[:data] = [] if is_collection
jsonapi[:meta] = @options[:meta] if @options[:meta].is_a?(Hash)
jsonapi[:links] = @options[:links] if @options[:links].is_a?(Hash)
return jsonapi if @resource.nil? || (is_collection && @resource.empty?)
data = []
included = []
# Used to avoid including duplicate records...
included_oids = Set.new
Array(@resource).each do |record|
serializer_class = self.class unless is_collection
serializer_class ||= JSONAPI::Serializer.for_object(record)
fieldset = @fieldsets[serializer_class.record_type]
data << serializer_class.record_hash(record, fieldset, @params)
included += serializer_class.record_includes(
record, @includes, included_oids, @fieldsets, @params
)
end
jsonapi[:data] = data
jsonapi[:data] = data.first unless is_collection
jsonapi[:included] = included unless @includes.empty?
jsonapi
end
end
end
end

View File

@ -0,0 +1,397 @@
# frozen_string_literal: true
require 'digest/sha1'
module JSONAPI
module Serializer
# Our JSONAPI implementation
module Core
DEFAULT_CACHE_NAMESPACE = 'jsonapi-serializer'
# Generates the JSONAPI [Hash] for a record
#
# @param record [Object] the record to process
# @param fieldset [Array<String>] of attributes to serialize
# @param params [Hash] the record processing parameters
# @return [Hash]
def record_hash(record, fieldset, params)
if @cache_store_instance
cache_opts = record_cache_options(
@cache_store_options, fieldset, @options
)
rhash = @cache_store_instance.fetch(record, **cache_opts) do
rels = cachable_relationships_to_serialize
record_hash_data(record, fieldset, params, rels)
end
unless uncachable_relationships_to_serialize.nil?
rels = uncachable_relationships_to_serialize
rhash[:relationships] = (rhash[:relationships] || {}).merge(
relationships_hash(record, rels, fieldset, params)
)
end
else
rels = @relationships_to_serialize
rhash = record_hash_data(record, fieldset, params, rels)
end
rhash[:meta] = meta_hash(@meta_to_serialize, record, params)
rhash.delete(:meta) if rhash[:meta].nil?
rhash.delete(:links) if rhash[:links].nil?
rhash
end
# Generates the JSONAPI [Array] for (includes) related records of a record
#
# @param record [Object] the record to process
# @param items [Array<String>] items to include
# @param known [Set] all the item identifiers already included
# @param fieldsets [Array<String>] of attributes to serialize
# @param params [Hash] the record processing parameters
# @return [Array] of data
# rubocop:disable Metrics/BlockLength
def record_includes(record, items, known, fieldsets, params)
return [] if items.nil? || @relationships_to_serialize.nil?
return [] if items.empty? || @relationships_to_serialize.empty?
items = parse_includes_list(items)
items.each_with_object([]) do |(item, item_includes), included|
to_include = record_include_item(item, record, params)
next if to_include.nil?
rel_objects, rel_options = to_include
Array(rel_objects).each do |rel_obj|
serializer = rel_options[:serializer]
if serializer.is_a?(Proc)
serializer = call_proc(
serializer, rel_obj, params
)
end
serializer ||= ::JSONAPI::Serializer.for_object(
rel_obj, rel_options[:serializers]
)
if item_includes.any?
included.concat(
serializer.record_includes(
rel_obj, item_includes, known, fieldsets, params
)
)
end
rel_obj_id = serializer.record_type.to_s.dup.concat(
serializer.id_from_record(rel_obj, params).to_s
)
next if known.include?(rel_obj_id)
known << rel_obj_id
included << serializer.record_hash(
rel_obj, fieldsets[serializer.record_type], params
)
end
end
end
# rubocop:enable Metrics/BlockLength
# Returns the record identifier value
#
# @param record [Object] the record to process
# @param params [Hash] the record processing parameters
# @return [String] identifier of the record
def id_from_record(record, params)
raise ::JSONAPI::Serializer::IdError if record_id.nil?
call_proc_or_method(record_id, record, params)
end
# Returns the record identifier data
#
# @param record [Object] the record to process
# @param params [Hash] the record processing parameters
# @return [Hash] with the type and identifier of the record
def id_hash(id, use_default: false)
if id.present?
{ id: id.to_s, type: record_type }
elsif use_default
{ id: nil, type: record_type }
end
end
private
def record_include_item(item, record, params)
relationship = @relationships_to_serialize[item]
raise IncludeError.new(item, self) if relationship.nil?
rel_options = relationship[:options]
return unless condition_passes?(rel_options[:if], record, params)
objects = call_proc_or_method(
relationship[:object_block] || relationship[:name], record, params
)
return if objects.nil?
return if objects.respond_to?(:empty?) && objects.empty?
[objects, rel_options]
end
def record_hash_data(record, fieldset, params, relationships)
temp_hash = id_hash(id_from_record(record, params), use_default: true)
if @attributes_to_serialize
temp_hash[:attributes] = attributes_hash(
record, fieldset, params
)
end
unless relationships.nil?
temp_hash[:relationships] = relationships_hash(
record,
relationships,
fieldset,
params
)
end
temp_hash[:links] = links_hash(@data_links, record, params)
temp_hash
end
def relationships_hash(record, relationships, fieldset, params)
relationships = relationships.slice(*fieldset) unless fieldset.nil?
relationships.each_with_object({}) do |(key, rel), rhash|
rel_opts = rel[:options]
next unless condition_passes?(rel_opts[:if], record, params)
key = run_key_transform(key)
rhash[key] = {
data: relationship_ids(rel, record, params),
meta: meta_hash(rel_opts[:meta], record, params),
links: links_hash(rel_opts[:links], record, params)
}
rhash[key].delete(:meta) if rhash[key][:meta].nil?
rhash[key].delete(:links) if rhash[key][:links].nil?
end
end
# Returns the relationship linkage data
#
# @param relationship [Hash] representing the relationship definition
# @param record [Object] the record to process
# @param params [Hash] the object processing parameters
# @return [Array] of hashes
# rubocop:disable Metrics/PerceivedComplexity,Metrics/CyclomaticComplexity
def relationship_ids(relationship, record, params)
rel_opts = relationship[:options]
has_many = (relationship[:relationship_type] == :has_many)
serializer = rel_opts[:serializer]
ids_rails_postfix = '_id'
ids_rails_postfix = '_ids' if has_many
if serializer.is_a?(Class)
ids_meth = rel_opts[:ids_method_name]
ids_meth ||= relationship[:name].to_s + ids_rails_postfix
ids = record.public_send(ids_meth) if record.respond_to?(ids_meth)
return serializer.id_hash(ids) unless ids.nil? && has_many
return ids.map! { |oid| serializer.id_hash(oid) } unless ids.nil?
end
obj_method_name = relationship[:object_block] || relationship[:name]
rel_objects = call_proc_or_method(obj_method_name, record, params)
rel_objects = Array(rel_objects)
ids = rel_objects.map do |robj|
robj_ser = serializer
robj_ser = call_proc(robj_ser, robj, params) if robj_ser.is_a?(Proc)
robj_ser ||= ::JSONAPI::Serializer.for_object(robj)
robj_ser.id_hash(robj_ser.id_from_record(robj, params))
end
return ids if has_many
ids.first
end
# rubocop:enable Metrics/PerceivedComplexity,Metrics/CyclomaticComplexity
def cachable_relationships_to_serialize
return nil if @relationships_to_serialize.nil?
@cachable_relationships_to_serialize ||= (
@relationships_to_serialize.to_a -
uncachable_relationships_to_serialize.to_a
).to_h
end
def uncachable_relationships_to_serialize
return nil if @relationships_to_serialize.nil?
@uncachable_relationships_to_serialize ||= \
@relationships_to_serialize.select do |_, rel|
rel[:options][:serializer]&.cache_store_instance.nil?
end
end
# Processes the meta
#
# @param maybe_meta [Object] either a [Hash], [String] or [Proc] to call
# @param record [Object] the meta object to process
# @param params [Hash] the object processing parameters
# @return [Hash] or nothing
def meta_hash(maybe_meta, record, params)
return maybe_meta if maybe_meta.is_a?(Hash)
return if maybe_meta.nil?
call_proc_or_method(maybe_meta, record, params)
end
# Processes the links
#
# @param maybe_links [Object] either a [Hash], [String] or [Proc] to call
# @param record [Object] the meta object to process
# @param params [Hash] the object processing parameters
# @return [Hash] or nothing
def links_hash(maybe_links, record, params)
return if maybe_links.nil?
is_callable = !maybe_links.is_a?(Hash)
return call_proc_or_method(maybe_links, record, params) if is_callable
maybe_links.each_with_object({}) do |(key, link), lhash|
options = {}
method = link
if link.is_a?(Hash)
options = link[:options]
method = link[:method]
end
next unless condition_passes?(options[:if], record, params)
key = run_key_transform(key)
lhash[key] = call_proc_or_method(method, record, params)
end
end
# Cache options helper. Use it to adapt cache keys/rules.
#
# If a fieldset is specified, it modifies the namespace to include the
# fields from the fieldset.
#
# @param options [Hash] default cache options
# @param fieldset [Array, nil] passed fieldset values
# @param params [Hash] the serializer params
#
# @return [Hash] processed options hash
# rubocop:disable Lint/UnusedMethodArgument
def record_cache_options(options, fieldset, params)
return options unless fieldset
options = options ? options.dup : {}
options[:namespace] ||= const_get('DEFAULT_CACHE_NAMESPACE')
fskey = fieldset.join('_')
# Use a fixed-length fieldset key if the current length is more than
# the length of a SHA1 digest
fskey = Digest::SHA1.hexdigest(fskey) if fskey.length > 40
options[:namespace] = "#{options[:namespace]}-fieldset:#{fskey}"
options
end
# rubocop:enable Lint/UnusedMethodArgument
def attributes_hash(record, fieldset, params)
attributes = @attributes_to_serialize
attributes = attributes.slice(*fieldset) unless fieldset.nil?
attributes.each_with_object({}) do |(key, attribute), ahash|
options = attribute[:options] || {}
method = attribute[:method]
next unless condition_passes?(options[:if], record, params)
key = run_key_transform(key)
ahash[key] = call_proc_or_method(method, record, params)
end
end
def condition_passes?(maybe_proc, record, params)
return true unless maybe_proc.is_a?(Proc)
call_proc(maybe_proc, record, params)
end
def call_proc_or_method(maybe_proc, record, params)
return call_proc(maybe_proc, record, params) if maybe_proc.is_a?(Proc)
record.public_send(maybe_proc)
end
# Calls [Proc] with respect to the number of parameters it takes
#
# @param proc [Proc] to call
# @param params [Array] of parameters to be passed to the Proc
# @return [Object] the result of the Proc call with the supplied parameters
def call_proc(proc, *params)
proc.call(*params.take(proc.parameters.length))
end
# It chops out the root association (first part) from each include.
#
# It keeps an unique list and collects all of the rest of the include
# value to hand it off to the next related to include serializer.
#
# This method will turn that include array into a Hash that looks like:
#
# {
# authors: Set.new([
# 'books',
# 'books.genre',
# 'books.genre.books',
# 'books.genre.books.authors',
# 'books.genre.books.genre'
# ]),
# genre: Set.new(['books'])
# }
#
# Because the serializer only cares about the root associations
# included, it only needs the first segment of each include
# (for books, it's the "authors" and "genre") and it doesn't need to
# waste cycles parsing the rest of the include value. That will be done
# by the next serializer in line.
#
# @param includes_list [List] to be parsed
# @return [Hash]
def parse_includes_list(includes_list)
includes_list.each_with_object({}) do |include_item, include_sets|
root, tail = include_item.to_s.split('.', 2)
include_sets[root.to_sym] ||= Set.new
include_sets[root.to_sym] << tail.to_sym if tail
end
end
end
end
end

View File

@ -0,0 +1,192 @@
# frozen_string_literal: true
# rubocop:disable Lint/DuplicateRescueException
begin
require 'active_support/inflector'
rescue LoadError
require 'dry/inflector'
rescue LoadError
warn(
'No inflector found. Install `dry-inflector` or `active_support/inflector`'
)
end
# rubocop:enable Lint/DuplicateRescueException
module JSONAPI
module Serializer
# Our serializer DSL
module DSL
attr_writer :record_type, :record_id
attr_accessor(
:transform_method,
:attributes_to_serialize,
:relationships_to_serialize,
:data_links,
:meta_to_serialize,
:cache_store_instance,
:cache_store_options
)
def inherited(subclass)
super
subclass.attributes_to_serialize = attributes_to_serialize.dup
subclass.relationships_to_serialize = relationships_to_serialize.dup
subclass.transform_method = transform_method
subclass.data_links = data_links.dup
subclass.cache_store_instance = cache_store_instance
subclass.cache_store_options = cache_store_options
subclass.meta_to_serialize = meta_to_serialize
subclass.record_id = record_id
::JSONAPI::Serializer.register_serializer(subclass)
end
def set_key_transform(transform_name)
@transform_method = TRANSFORMS_MAPPING[transform_name.to_sym]
end
def set_type(type_name)
@record_type = type_name
end
def record_type
@record_type ||= run_key_transform(
name.chomp('Serializer'),
[:demodulize, :underscore]
)
run_key_transform(@record_type)
end
def set_id(id_name = nil, &block)
@record_id = block || id_name
end
def record_id
@record_id || :id
end
def attributes(*attributes_list, **options, &block)
@attributes_to_serialize ||= {}
attributes_list.each do |attr_name|
@attributes_to_serialize[attr_name] = {
key: attr_name,
method: block || attr_name,
options: options.freeze
}.freeze
end
end
alias attribute attributes
def belongs_to(relationship_name, options = {}, &block)
@relationships_to_serialize ||= {}
@relationships_to_serialize[relationship_name] = {
name: relationship_name,
relationship_type: __callee__,
options: options,
object_block: block
}
# Run the resolver...
::JSONAPI::Serializer
.serializers.map(&:resolve_relationship_serializers!)
end
alias has_one belongs_to
alias has_many belongs_to
def meta(meta_name = nil, &block)
@meta_to_serialize = meta_name || block
end
def link(link_name, link_method_name = nil, **options, &block)
@data_links ||= {}
@data_links[link_name] = {
key: link_name,
method: link_method_name || block,
options: options.freeze
}.freeze
end
def cache_options(cache_options)
@cache_store_instance = cache_options[:store]
@cache_store_options = cache_options.except(:store).freeze
end
def resolve_relationship_serializers!
return if @relationships_to_serialize.nil?
@relationships_to_serialize.each do |_rel_name, relationship|
next if relationship.frozen?
resolve_relationship_serializer(relationship)
end
rescue NotFoundError
# Ignore it, probably not all serializers are defined...
end
def run_key_transform(key, transform_methods = nil)
return key.to_sym if inflector.nil?
tmethods = @transform_method || transform_methods
return key.to_sym if tmethods.nil?
Array(tmethods).each do |tmethod|
key = inflector.send(tmethod, key.to_s)
end
key.to_sym
end
private
# Maps tranformations to inflector methods
TRANSFORMS_MAPPING = {
camel: :camelize,
camel_lower: [:camelize, :lower],
dash: :dasherize,
underscore: :underscore
}.freeze
# Resolves serializer options of relationship into proper classe(s)
#
# We skip classes and procs since those suggest per record serializer
# classes. On no options available, the name of the relationship is used
# to reflect on the serializer class.
#
# @params relationship [Hash] the relationship data
# @return nil
def resolve_relationship_serializer(relationship)
opts = relationship[:options]
# The two are exclusive...
return opts.delete(:serializer) unless opts[:serializers].nil?
sname = opts[:serializer] || relationship[:name].to_s
return if sname.is_a?(Class) || sname.is_a?(Proc)
singularize = relationship[:relationship_type] == :has_many
sname = inflector.singularize(sname) if inflector && singularize
opts[:serializer] = ::JSONAPI::Serializer.for_type(sname)
opts.freeze && relationship.freeze
end
# Helper method to pick an available inflector implementation
#
# @return [Object]
def inflector
ActiveSupport::Inflector
rescue NameError
Dry::Inflector.new
end
end
end
end

View File

@ -2,17 +2,44 @@
module JSONAPI
module Serializer
# Generic error class
class Error < StandardError; end
class UnsupportedIncludeError < Error
# Used to indicate that a resource `id` is missing
class IdError < StandardError
def message
'Resource ID is missing, see: '\
'https://jsonapi.org/format/#document-resource-object-identification'
end
end
# Used to indicate that there's no serializer for an object class or type
class NotFoundError < Error
attr_reader :object, :classes
def initialize(object, classes)
super()
@object = object
@classes = classes
end
def message
"No serializer found for #{object.inspect} in #{classes.join(',')}."
end
end
# Used to indicate when there's a problem with an item from includes
class IncludeError < Error
attr_reader :include_item, :klass
def initialize(include_item, klass)
super()
@include_item = include_item
@klass = klass
end
def message
"#{include_item} is not specified as a relationship on #{klass}"
"#{include_item} is not available on #{klass}"
end
end
end

View File

@ -0,0 +1,71 @@
# frozen_string_literal: true
module JSONAPI
module Serializer
# Allows tracking and registering the descendant serializer classes
module Trackable
# Adds a [Class] to the list of known serializers
#
# Mostly used internally to resolve easily record serializer classes
#
# @param klass [Class] to add to the list of known serializers
# @return [Class] or nothing if the class is already cached
def register_serializer(klass)
@serializers ||= []
@serializers << klass unless @serializers.include?(klass)
end
# Returns a list of available serializers
#
# @return [Array] of classes
def serializers
@serializers
end
# Returns the serializer class for a record/object
#
# It follows the basic convention that the object class name and its
# serializer class name are in the same namespace and the later ends
# with the name `Serializer`.
#
# Ex.: [MyApp::UserModel] has the serializer [MyApp::UserModelSerializer]
#
# @param object [Object] to find the serialization class for
# @param mapping [Hash] custom map of model to serializer classes
# @return [Class] of the serialization class
def for_object(object, mapping = nil)
if mapping.is_a?(Hash)
return (
mapping[object.class] || NotFoundError.new(object, mapping.values)
)
end
serializers.each do |klass|
return klass if klass.name == "#{object.class.name}Serializer"
end
raise NotFoundError.new(object, serializers)
end
# Returns the serializer class for a type
#
# It looks for existing serializers types that match
#
# Ex.: [MyApp::UserModelSerializer.record_type] would be `user`.
#
# @param record_type [String] to find the serialization class for
# @param mapping [Hash] custom map of model to serializer classes
# @return [Class] of the serialization class
def for_type(record_type, mapping = nil)
classes = mapping.values if mapping.is_a?(Hash)
classes ||= serializers
classes.each do |klass|
return klass if klass.record_type.to_s == record_type.to_s
end
raise NotFoundError.new(record_type, classes)
end
end
end
end

View File

@ -1,5 +1,5 @@
module JSONAPI
module Serializer
VERSION = '2.1.0'.freeze
VERSION = '3.0.0'.freeze
end
end

View File

@ -46,22 +46,29 @@ class CamelCaseActorSerializer
obj.movie_urls.values[0]
end
has_many(
:played_movies,
serializer: :movie
) do |object|
has_many(:played_movies, serializer: :movie) do |object|
object.movies
end
end
class DasherizedActorSerializer < CamelCaseActorSerializer
set_type :user_actor
set_key_transform :dash
end
class BadMovieSerializerActorSerializer < ActorSerializer
has_many :played_movies, serializer: :bad, object_method_name: :movies
has_many :played_movies do
self
end
end
module Cached
class Actor < ::Actor; end
class ActorSerializer < ::ActorSerializer
# TODO: Fix this, the serializer gets cached on inherited classes...
has_many :played_movies, serializer: :movie do |object|
set_type :cached_actor
has_many(:played_movies, serializer: :cached_movie) do |object|
object.movies
end

View File

@ -26,7 +26,7 @@ class Movie
@url ||= FFaker::Internet.http_url
return @url if obj.nil?
@url + '?' + obj.hash.to_s
"#{@url}?#{obj.hash}"
end
def owner=(ownr)
@ -53,16 +53,17 @@ class MovieSerializer
object.year
end
link :self, :url
link :self, :url, if: ->(object, _params) { object.is_a?(Movie) }
belongs_to :owner, serializer: UserSerializer
belongs_to :actor_or_user,
id_method_name: :uid,
polymorphic: {
Actor => :actor,
User => :user
belongs_to(
:actor_or_user,
serializers: {
Actor => ActorSerializer,
User => UserSerializer
}
)
has_many(
:actors,
@ -74,52 +75,41 @@ class MovieSerializer
)
has_one(
:creator,
object_method_name: :owner,
id_method_name: :uid,
serializer: ->(object, _params) { UserSerializer if object.is_a?(User) }
)
) do |object|
object.owner
end
has_many(
:actors_and_users,
id_method_name: :uid,
polymorphic: {
Actor => :actor,
User => :user
serializers: {
Actor => ActorSerializer,
User => UserSerializer
}
) do |obj|
obj.polymorphics
end
has_many(
:dynamic_actors_and_users,
id_method_name: :uid,
polymorphic: true
) do |obj|
has_many(:dynamic_actors_and_users) do |obj|
obj.polymorphics
end
has_many(
:auto_detected_actors_and_users,
id_method_name: :uid
) do |obj|
has_many(:auto_detected_actors_and_users) do |obj|
obj.polymorphics
end
end
module Cached
class Movie < ::Movie; end
class MovieSerializer < ::MovieSerializer
set_type :cached_movie
cache_options(
store: ActorSerializer.cache_store_instance,
namespace: 'test'
)
has_one(
:creator,
id_method_name: :uid,
serializer: :actor,
# TODO: Remove this undocumented option.
# Delegate the caching to the serializer exclusively.
cached: false
) do |obj|
has_one(:creator, serializer: :cached_actor) do |obj|
obj.owner
end
end

View File

@ -2,8 +2,8 @@ require 'spec_helper'
RSpec.describe JSONAPI::Serializer do
let(:actor) do
faked = Actor.fake
movie = Movie.fake
faked = Cached::Actor.fake
movie = Cached::Movie.fake
movie.owner = User.fake
movie.actors = [faked]
faked.movies = [movie]
@ -16,11 +16,14 @@ RSpec.describe JSONAPI::Serializer do
expect(cache_store.delete(actor, namespace: 'test')).to be(false)
Cached::ActorSerializer.new(
[actor, actor], include: ['played_movies', 'played_movies.owner']
[actor, actor],
include: ['played_movies', 'played_movies.owner']
).serializable_hash
expect(cache_store.delete(actor, namespace: 'test')).to be(true)
expect(cache_store.delete(actor.movies[0], namespace: 'test')).to be(true)
expect(
cache_store.delete(actor.movies[0], namespace: 'test')
).to be(true)
expect(
cache_store.delete(actor.movies[0].owner, namespace: 'test')
).to be(false)
@ -29,7 +32,9 @@ RSpec.describe JSONAPI::Serializer do
context 'without relationships' do
let(:user) { User.fake }
let(:serialized) { Cached::UserSerializer.new(user).serializable_hash.as_json }
let(:serialized) do
Cached::UserSerializer.new(user).serializable_hash.as_json
end
it do
expect(serialized['data']).not_to have_key('relationships')
@ -43,37 +48,46 @@ RSpec.describe JSONAPI::Serializer do
expect(cache_store.delete(actor, namespace: 'test')).to be(false)
Cached::ActorSerializer.new(
[actor], fields: { actor: %i[first_name] }
[actor], fields: { cached_actor: %i[first_name] }
).serializable_hash
# Expect cached keys to match the passed fieldset
expect(cache_store.read(actor, namespace: 'test-fieldset:first_name')[:attributes].keys).to eq(%i[first_name])
cached_actor = cache_store.read(
actor, namespace: 'test-fieldset:first_name'
)
Cached::ActorSerializer.new(
[actor]
).serializable_hash
expect(cached_actor[:attributes].keys).to eq(%i[first_name])
Cached::ActorSerializer.new([actor]).serializable_hash
# Expect cached keys to match all valid actor fields (no fieldset)
expect(cache_store.read(actor, namespace: 'test')[:attributes].keys).to eq(%i[first_name last_name email])
expect(cache_store.delete(actor, namespace: 'test')).to be(true)
expect(cache_store.delete(actor, namespace: 'test-fieldset:first_name')).to be(true)
cached_actor = cache_store.read(actor, namespace: 'test')
expect(cached_actor[:attributes].keys)
.to eq(%i[first_name last_name email])
expect(
cache_store.delete(actor, namespace: 'test-fieldset:first_name')
).to be(true)
end
end
context 'when long fieldset is provided' do
let(:actor_keys) { %i[first_name last_name more_fields yet_more_fields so_very_many_fields] }
let(:actor_keys) do
%i[first_name last_name more_fields yet_more_fields so_very_many_fields]
end
let(:digest_key) { Digest::SHA1.hexdigest(actor_keys.join('_')) }
it 'includes the hashed fieldset in the namespace' do
Cached::ActorSerializer.new(
[actor], fields: { actor: actor_keys }
[actor], fields: { cached_actor: actor_keys }
).serializable_hash
expect(cache_store.read(actor, namespace: "test-fieldset:#{digest_key}")[:attributes].keys).to eq(
%i[first_name last_name]
cached_actor = cache_store.read(
actor, namespace: "test-fieldset:#{digest_key}"
)
expect(cache_store.delete(actor, namespace: "test-fieldset:#{digest_key}")).to be(true)
expect(cached_actor[:attributes].keys).to eq(%i[first_name last_name])
end
end
end

View File

@ -7,19 +7,14 @@ RSpec.describe JSONAPI::Serializer do
describe 'with errors' do
it do
expect do
BadMovieSerializerActorSerializer.new(
actor, include: ['played_movies']
)
end.to raise_error(
NameError, /cannot resolve a serializer class for 'bad'/
)
BadMovieSerializerActorSerializer.new(actor).serializable_hash
end.to raise_error(JSONAPI::Serializer::NotFoundError)
end
it do
expect { ActorSerializer.new(actor, include: ['bad_include']) }
.to raise_error(
JSONAPI::Serializer::UnsupportedIncludeError, /bad_include is not specified as a relationship/
)
expect do
ActorSerializer.new(actor, include: ['bad_include']).serializable_hash
end.to raise_error(JSONAPI::Serializer::IncludeError)
end
end
end

View File

@ -10,8 +10,8 @@ RSpec.describe JSONAPI::Serializer do
it do
payload = event_name = nil
notification_name = (
::JSONAPI::Serializer::Instrumentation::NOTIFICATION_NAMESPACE +
notification_name = ''.concat(
::JSONAPI::Serializer::Instrumentation::NOTIFICATION_NAMESPACE,
'serializable_hash'
)

View File

@ -2,12 +2,12 @@ require 'spec_helper'
RSpec.describe JSONAPI::Serializer do
let(:actor) { Actor.fake }
let(:params) { {} }
let(:serialized) do
CamelCaseActorSerializer.new(actor, params).serializable_hash.as_json
end
describe 'camel case key tranformation' do
let(:serialized) do
CamelCaseActorSerializer.new(actor).serializable_hash.as_json
end
it do
expect(serialized['data']).to have_id(actor.uid)
expect(serialized['data']).to have_type('UserActor')
@ -16,4 +16,18 @@ RSpec.describe JSONAPI::Serializer do
expect(serialized['data']).to have_link('MovieUrl').with_value(nil)
end
end
describe 'inherited class dasherized case key tranformation' do
let(:serialized) do
DasherizedActorSerializer.new(actor).serializable_hash.as_json
end
it do
expect(serialized['data']).to have_id(actor.uid)
expect(serialized['data']).to have_type('user-actor')
expect(serialized['data']).to have_attribute('first-name')
expect(serialized['data']).to have_relationship('played-movies')
expect(serialized['data']).to have_link('movie-url').with_value(nil)
end
end
end

View File

@ -0,0 +1,22 @@
require 'spec_helper'
RSpec.describe JSONAPI::Serializer do
let(:user) { User.fake }
let(:actor) { Actor.fake }
let(:movie) { Movie.fake }
let(:serialized) do
JSONAPI::Serializer.serialize(
[user, actor, movie]
).as_json
end
it do
expect(serialized['data']).to include(
have_type(:user).and(have_id(user.uid))
).and(
include(have_type('actor').and(have_id(actor.uid)))
).and(
include(have_type('movie').and(have_id(movie.id)))
)
end
end