Compare commits
1 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
fd37936812 |
45
.rubocop.yml
45
.rubocop.yml
@ -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'
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
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
|
||||
|
53
lib/jsonapi/serializer/base.rb
Normal file
53
lib/jsonapi/serializer/base.rb
Normal 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
|
397
lib/jsonapi/serializer/core.rb
Normal file
397
lib/jsonapi/serializer/core.rb
Normal 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
|
192
lib/jsonapi/serializer/dsl.rb
Normal file
192
lib/jsonapi/serializer/dsl.rb
Normal 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
|
@ -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
|
||||
|
71
lib/jsonapi/serializer/trackable.rb
Normal file
71
lib/jsonapi/serializer/trackable.rb
Normal 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
|
@ -1,5 +1,5 @@
|
||||
module JSONAPI
|
||||
module Serializer
|
||||
VERSION = '2.1.0'.freeze
|
||||
VERSION = '3.0.0'.freeze
|
||||
end
|
||||
end
|
||||
|
21
spec/fixtures/actor.rb
vendored
21
spec/fixtures/actor.rb
vendored
@ -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
|
||||
|
||||
|
54
spec/fixtures/movie.rb
vendored
54
spec/fixtures/movie.rb
vendored
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -10,9 +10,9 @@ RSpec.describe JSONAPI::Serializer do
|
||||
|
||||
it do
|
||||
payload = event_name = nil
|
||||
notification_name = (
|
||||
::JSONAPI::Serializer::Instrumentation::NOTIFICATION_NAMESPACE +
|
||||
'serializable_hash'
|
||||
notification_name = ''.concat(
|
||||
::JSONAPI::Serializer::Instrumentation::NOTIFICATION_NAMESPACE,
|
||||
'serializable_hash'
|
||||
)
|
||||
|
||||
ActiveSupport::Notifications.subscribe(
|
||||
|
@ -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
|
||||
|
22
spec/integration/mixed_collection_spec.rb
Normal file
22
spec/integration/mixed_collection_spec.rb
Normal 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
|
Loading…
x
Reference in New Issue
Block a user