Compare commits

..

14 Commits

Author SHA1 Message Date
Clemens Kofler
7db80f673d Clarify README wording related to contributions 2022-04-04 18:52:58 +01:00
Peter Goldstein
bcee2b597b Add Ruby 3.1 to CI
Also fixes the unquoted 3.0, which needs to be quoted to avoid truncation
Addresses a few Rubocop issues and an active_support require issue on Rails 7 w/Zeitwerk
2022-02-02 21:40:34 +00:00
Igor Victor
44cf8495ea Update ci.yml 2021-04-12 11:50:08 +01:00
Stas SUȘCOV
37235057df
Version bump. 2021-03-11 18:35:11 +00:00
Stas SUȘCOV
c21f9def8f Updated the changelog. 2021-03-11 18:33:35 +00:00
Stas SUȘCOV
8b74954478 Added ruby v3 to CI matrix. 2021-03-11 18:33:35 +00:00
Stas SUȘCOV
ef93f7f358 Make rubocop happy. 2021-03-11 18:33:35 +00:00
HubertVonHerbert
c5eb1ce27c
Fix Ruby3 compatibility issue with Procs (#160)
* Fix Ruby3 compatibility issue with Procs

* Fix rubocop complaints

* Remove ruby 3 from CI actions

* Simplify check for &:foo procs
2021-03-11 18:29:59 +00:00
Ryan Romanchuk
a25d415b4d Fix most likely copy/paste error
this lambda yielding a bool for `belongs_to` might confuse readers or conflate the :if block

which would probably look something like this 

```ruby
belongs_to :primary_agent, if: proc { |movie, params| params[:current_user].present? } do |movie, params|
    # in here, params is a hash containing the `:current_user` key
    params[:current_user]
  end
```
2021-02-14 22:17:08 +00:00
Stas SUȘCOV
c3376037e7 Let everyone know there's a WIP branch for v3. 2021-01-11 11:11:24 +00:00
Yaroslav Kasperovych
963cd77900 Fix require clause in fastjson migration guide
Seems like the require clause should be different as it produces an error if used as it is right now.
2020-11-06 10:15:26 +00:00
Tony Dehnke
98dd59884c Fix namespace name per comment in #139 2020-10-26 12:01:52 +00:00
Guillaume Briday
b7e8a30833 Fix typo in readme 2020-10-25 18:13:08 +00:00
Guillaume Briday
f4ed4f0440 Adding migration section 2020-10-25 17:23:20 +00:00
26 changed files with 225 additions and 947 deletions

View File

@ -4,16 +4,16 @@ on: [push, pull_request]
jobs:
tests:
runs-on: ubuntu-18.04
runs-on: ubuntu-latest
strategy:
matrix:
ruby: [2.4, 2.5, 2.6, 2.7]
ruby: [2.4, 2.7, '3.0', 3.1, truffleruby-head]
steps:
- uses: actions/checkout@master
- name: Sets up the Ruby version
uses: actions/setup-ruby@v1
uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby }}

View File

@ -3,7 +3,8 @@ require:
- rubocop-rspec
AllCops:
NewCops: enable
NewCops: enable
SuggestExtensions: false
Style/FrozenStringLiteralComment:
Enabled: false
@ -41,25 +42,61 @@ Performance/TimesMap:
Exclude:
- 'spec/**/**.rb'
Metrics/ModuleLength:
Gemspec/RequiredRubyVersion:
Enabled: false
# TODO: Fix these...
Style/Documentation:
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
Metrics/CyclomaticComplexity:
Max: 12
Layout/LineLength:
Exclude:
- 'lib/**/**.rb'
Metrics/PerceivedComplexity:
Max: 12
Metrics/AbcSize:
Max: 36
Naming/PredicateName:
Exclude:
- 'lib/**/**.rb'
Naming/AccessorMethodName:
Exclude:
- '**/**/dsl.rb'
- 'lib/**/**.rb'
Style/CaseLikeIf:
Exclude:
- 'lib/fast_jsonapi/object_serializer.rb'
Style/OptionalBooleanParameter:
Exclude:
- 'lib/fast_jsonapi/serialization_core.rb'
- 'lib/fast_jsonapi/relationship.rb'
Lint/DuplicateBranch:
Exclude:
- 'lib/fast_jsonapi/relationship.rb'
Style/DocumentDynamicEvalDefinition:
Exclude:
- 'lib/extensions/has_one.rb'

View File

@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- ...
## [2.2.0] - 2021-03-11
### Added
- Proper error is raised on unsupported includes (#125)
### Changed
- Documentation updates (#137 #139 #143 #146)
### Fixed
- Empty relationships are no longer added to serialized doc (#116)
- Ruby v3 compatibility (#160)
## [2.1.0] - 2020-08-30
### Added

View File

@ -1,5 +1,11 @@
# JSON:API Serialization Library
## :warning: :construction: v2 (the `master` branch) is in maintenance mode! :construction: :warning:
We'll gladly accept bugfixes and security-related fixes for v2 (the `master` branch), but at this stage, contributions for new features/improvements are welcome only for v3. Please feel free to leave comments in the [v3 Pull Request](https://github.com/jsonapi-serializer/jsonapi-serializer/pull/141).
---
A fast [JSON:API](https://jsonapi.org/) serializer for Ruby Objects.
Previously this project was called **fast_jsonapi**, we forked the project
@ -40,6 +46,7 @@ article in the `docs` folder for any questions related to methodology.
* [Using helper methods](#using-helper-methods)
* [Performance Instrumentation](#performance-instrumentation)
* [Deserialization](#deserialization)
* [Migrating from Netflix/fast_jsonapi](#migrating-from-netflixfast_jsonapi)
* [Contributing](#contributing)
@ -459,7 +466,7 @@ class MovieSerializer
belongs_to :primary_agent do |movie, params|
# in here, params is a hash containing the `:current_user` key
params[:current_user].is_employee? ? true : false
params[:current_user]
end
end
@ -706,6 +713,49 @@ This gem provides the next features alongside deserialization:
- Filtering and sorting
- Pagination
## Migrating from Netflix/fast_jsonapi
If you come from [Netflix/fast_jsonapi](https://github.com/Netflix/fast_jsonapi), here is the instructions to switch.
### Modify your Gemfile
```diff
- gem 'fast_jsonapi'
+ gem 'jsonapi-serializer'
```
### Replace all constant references
```diff
class MovieSerializer
- include FastJsonapi::ObjectSerializer
+ include JSONAPI::Serializer
end
```
### Replace removed methods
```diff
- json_string = MovieSerializer.new(movie).serialized_json
+ json_string = MovieSerializer.new(movie).serializable_hash.to_json
```
### Replace require references
```diff
- require 'fast_jsonapi'
+ require 'jsonapi/serializer'
```
### Update your cache options
See [docs](https://github.com/jsonapi-serializer/jsonapi-serializer#caching).
```diff
- cache_options enabled: true, cache_length: 12.hours
+ cache_options store: Rails.cache, namespace: 'jsonapi-serializer', expires_in: 1.hour
```
## Contributing
Please follow the instructions we provide as part of the issue and

View File

@ -12,14 +12,16 @@ Gem::Specification.new do |gem|
gem.summary = 'Fast JSON:API serialization library'
gem.description = 'Fast, simple and easy to use '\
'JSON:API serialization library (also known as fast_jsonapi).'
'JSON:API serialization library (also known as fast_jsonapi).'
gem.homepage = 'https://github.com/jsonapi-serializer/jsonapi-serializer'
gem.licenses = ['Apache-2.0']
gem.files = Dir['lib/**/*']
gem.require_paths = ['lib']
gem.extra_rdoc_files = ['LICENSE.txt', 'README.md']
gem.add_development_dependency('dry-inflector')
gem.add_runtime_dependency('activesupport', '>= 4.2')
gem.add_development_dependency('activerecord')
gem.add_development_dependency('bundler')
gem.add_development_dependency('byebug')
gem.add_development_dependency('ffaker')
@ -31,5 +33,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')
gem.metadata['rubygems_mfa_required'] = 'true'
end

View File

@ -6,7 +6,16 @@ module FastJsonapi
# @param [Array<Object>] *params any number 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))
# The parameters array for a lambda created from a symbol (&:foo) differs
# from explictly defined procs/lambdas, so we can't deduce the number of
# parameters from the array length (and differs between Ruby 2.x and 3).
# In the case of negative arity -- unlimited/unknown argument count --
# just send the object to act as the method receiver.
if proc.arity.negative?
proc.call(params.first)
else
proc.call(*params.take(proc.parameters.length))
end
end
end
end

View File

@ -1,5 +1,6 @@
# frozen_string_literal: true
require 'active_support'
require 'active_support/time'
require 'active_support/concern'
require 'active_support/inflector'
@ -133,9 +134,7 @@ module FastJsonapi
def reflected_record_type
return @reflected_record_type if defined?(@reflected_record_type)
@reflected_record_type ||= begin
name.split('::').last.chomp('Serializer').underscore.to_sym if name&.end_with?('Serializer')
end
@reflected_record_type ||= (name.split('::').last.chomp('Serializer').underscore.to_sym if name&.end_with?('Serializer'))
end
def set_key_transform(transform_name)
@ -228,10 +227,10 @@ module FastJsonapi
# TODO: Remove this undocumented option.
# Delegate the caching to the serializer exclusively.
if !relationship.cached
uncachable_relationships_to_serialize[relationship.name] = relationship
else
if relationship.cached
cachable_relationships_to_serialize[relationship.name] = relationship
else
uncachable_relationships_to_serialize[relationship.name] = relationship
end
relationships_to_serialize[relationship.name] = relationship
end
@ -302,7 +301,7 @@ module FastJsonapi
def serializer_for(name)
namespace = self.name.gsub(/()?\w+Serializer$/, '')
serializer_name = name.to_s.demodulize.classify + 'Serializer'
serializer_name = "#{name.to_s.demodulize.classify}Serializer"
serializer_class_name = namespace + serializer_name
begin
serializer_class_name.constantize
@ -340,7 +339,7 @@ module FastJsonapi
def validate_includes!(includes)
return if includes.blank?
parse_includes_list(includes).keys.each do |include_item|
parse_includes_list(includes).each_key do |include_item|
relationship_to_include = relationships_to_serialize[include_item]
raise(JSONAPI::Serializer::UnsupportedIncludeError.new(include_item, name)) unless relationship_to_include

View File

@ -12,12 +12,12 @@ module FastJsonapi
object_block:,
serializer:,
relationship_type:,
cached: false,
polymorphic:,
conditional_proc:,
transform_method:,
links:,
meta:,
cached: false,
lazy_load_data: false
)
@owner = owner

View File

@ -1,5 +1,6 @@
# frozen_string_literal: true
require 'active_support'
require 'active_support/concern'
require 'digest/sha1'

View File

@ -1,67 +1,12 @@
require 'jsonapi/serializer/base'
require 'jsonapi/serializer/core'
require 'jsonapi/serializer/dsl'
require 'jsonapi/serializer/errors'
require 'jsonapi/serializer/trackable'
require 'fast_jsonapi'
# Provides JSONAPI related functionality
module JSONAPI
# Provides JSONAPI serialization functionality
module Serializer
extend Trackable
# Self registers any inherited/extended class to keep track of it
#
# @return nothing
# TODO: Move and cleanup the old implementation...
def self.included(base)
super
register_serializer(base)
base.class_eval do
include ::JSONAPI::Serializer::Base
extend ::JSONAPI::Serializer::DSL
extend ::JSONAPI::Serializer::Core
include FastJsonapi::ObjectSerializer
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

@ -1,53 +0,0 @@
# 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

@ -1,397 +0,0 @@
# 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

@ -1,192 +0,0 @@
# 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,34 +2,9 @@
module JSONAPI
module Serializer
# Generic error class
class Error < StandardError; end
# 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
class UnsupportedIncludeError < Error
attr_reader :include_item, :klass
def initialize(include_item, klass)
@ -39,7 +14,7 @@ module JSONAPI
end
def message
"#{include_item} is not available on #{klass}"
"#{include_item} is not specified as a relationship on #{klass}"
end
end
end

View File

@ -1,3 +1,4 @@
require 'active_support'
require 'active_support/notifications'
module JSONAPI

View File

@ -1,71 +0,0 @@
# 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 = '3.0.0'.freeze
VERSION = '2.2.0'.freeze
end
end

View File

@ -1,3 +1,4 @@
require 'active_support'
require 'active_support/cache'
class User

View File

@ -1,3 +1,4 @@
require 'active_support'
require 'active_support/cache'
require 'jsonapi/serializer/instrumentation'
@ -46,29 +47,22 @@ 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 do
self
end
has_many :played_movies, serializer: :bad, object_method_name: :movies
end
module Cached
class Actor < ::Actor; end
class ActorSerializer < ::ActorSerializer
set_type :cached_actor
has_many(:played_movies, serializer: :cached_movie) do |object|
# TODO: Fix this, the serializer gets cached on inherited classes...
has_many :played_movies, serializer: :movie do |object|
object.movies
end

View File

@ -48,22 +48,22 @@ class MovieSerializer
set_type :movie
attribute :released_in_year, &:year
attributes :name
attribute :release_year do |object|
attribute :release_year do |object, _params|
object.year
end
link :self, :url, if: ->(object, _params) { object.is_a?(Movie) }
link :self, :url
belongs_to :owner, serializer: UserSerializer
belongs_to(
:actor_or_user,
serializers: {
Actor => ActorSerializer,
User => UserSerializer
}
)
belongs_to :actor_or_user,
id_method_name: :uid,
polymorphic: {
Actor => :actor,
User => :user
}
has_many(
:actors,
@ -75,41 +75,52 @@ 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,
serializers: {
Actor => ActorSerializer,
User => UserSerializer
id_method_name: :uid,
polymorphic: {
Actor => :actor,
User => :user
}
) do |obj|
obj.polymorphics
end
has_many(:dynamic_actors_and_users) do |obj|
has_many(
:dynamic_actors_and_users,
id_method_name: :uid,
polymorphic: true
) do |obj|
obj.polymorphics
end
has_many(:auto_detected_actors_and_users) do |obj|
has_many(
:auto_detected_actors_and_users,
id_method_name: :uid
) 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, serializer: :cached_actor) do |obj|
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|
obj.owner
end
end

View File

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

View File

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

View File

@ -10,10 +10,8 @@ RSpec.describe JSONAPI::Serializer do
it do
payload = event_name = nil
notification_name = ''.concat(
::JSONAPI::Serializer::Instrumentation::NOTIFICATION_NAMESPACE,
'serializable_hash'
)
notification_name =
"#{::JSONAPI::Serializer::Instrumentation::NOTIFICATION_NAMESPACE}serializable_hash"
ActiveSupport::Notifications.subscribe(
notification_name

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,18 +16,4 @@ 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

@ -1,22 +0,0 @@
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

View File

@ -6,6 +6,7 @@ SimpleCov.start do
end
SimpleCov.minimum_coverage 90
require 'active_support'
require 'active_support/core_ext/object/json'
require 'jsonapi/serializer'
require 'ffaker'