From fd379368128e21401bb9dc5f3f4378e1387f2564 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stas=20SU=C8=98COV?= Date: Wed, 21 Oct 2020 17:48:27 +0100 Subject: [PATCH] Rewrite the old implementation. --- .rubocop.yml | 43 +-- jsonapi-serializer.gemspec | 5 +- lib/jsonapi/serializer.rb | 61 +++- lib/jsonapi/serializer/base.rb | 53 +++ lib/jsonapi/serializer/core.rb | 397 ++++++++++++++++++++++ lib/jsonapi/serializer/dsl.rb | 192 +++++++++++ lib/jsonapi/serializer/errors.rb | 31 +- lib/jsonapi/serializer/trackable.rb | 71 ++++ lib/jsonapi/serializer/version.rb | 2 +- spec/fixtures/actor.rb | 21 +- spec/fixtures/movie.rb | 54 ++- spec/integration/caching_spec.rb | 50 ++- spec/integration/errors_spec.rb | 15 +- spec/integration/instrumentation_spec.rb | 6 +- spec/integration/key_transform_spec.rb | 22 +- spec/integration/mixed_collection_spec.rb | 22 ++ 16 files changed, 934 insertions(+), 111 deletions(-) create mode 100644 lib/jsonapi/serializer/base.rb create mode 100644 lib/jsonapi/serializer/core.rb create mode 100644 lib/jsonapi/serializer/dsl.rb create mode 100644 lib/jsonapi/serializer/trackable.rb create mode 100644 spec/integration/mixed_collection_spec.rb diff --git a/.rubocop.yml b/.rubocop.yml index 50263f3..6a64047 100644 --- a/.rubocop.yml +++ b/.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: +Metrics/BlockLength: Exclude: - - 'lib/**/**.rb' + - 'spec/**/**.rb' -Metrics/BlockLength: +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' diff --git a/jsonapi-serializer.gemspec b/jsonapi-serializer.gemspec index 0732452..4884488 100644 --- a/jsonapi-serializer.gemspec +++ b/jsonapi-serializer.gemspec @@ -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 diff --git a/lib/jsonapi/serializer.rb b/lib/jsonapi/serializer.rb index f7ebcaa..1406e43 100644 --- a/lib/jsonapi/serializer.rb +++ b/lib/jsonapi/serializer.rb @@ -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 diff --git a/lib/jsonapi/serializer/base.rb b/lib/jsonapi/serializer/base.rb new file mode 100644 index 0000000..b2c85ef --- /dev/null +++ b/lib/jsonapi/serializer/base.rb @@ -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 diff --git a/lib/jsonapi/serializer/core.rb b/lib/jsonapi/serializer/core.rb new file mode 100644 index 0000000..8dc6668 --- /dev/null +++ b/lib/jsonapi/serializer/core.rb @@ -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] 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] items to include + # @param known [Set] all the item identifiers already included + # @param fieldsets [Array] 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 diff --git a/lib/jsonapi/serializer/dsl.rb b/lib/jsonapi/serializer/dsl.rb new file mode 100644 index 0000000..ead12f6 --- /dev/null +++ b/lib/jsonapi/serializer/dsl.rb @@ -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 diff --git a/lib/jsonapi/serializer/errors.rb b/lib/jsonapi/serializer/errors.rb index f5372a8..66e5b09 100644 --- a/lib/jsonapi/serializer/errors.rb +++ b/lib/jsonapi/serializer/errors.rb @@ -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 diff --git a/lib/jsonapi/serializer/trackable.rb b/lib/jsonapi/serializer/trackable.rb new file mode 100644 index 0000000..b5dbd20 --- /dev/null +++ b/lib/jsonapi/serializer/trackable.rb @@ -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 diff --git a/lib/jsonapi/serializer/version.rb b/lib/jsonapi/serializer/version.rb index 5fe99dc..77b898c 100644 --- a/lib/jsonapi/serializer/version.rb +++ b/lib/jsonapi/serializer/version.rb @@ -1,5 +1,5 @@ module JSONAPI module Serializer - VERSION = '2.1.0'.freeze + VERSION = '3.0.0'.freeze end end diff --git a/spec/fixtures/actor.rb b/spec/fixtures/actor.rb index f85120a..5d848cb 100644 --- a/spec/fixtures/actor.rb +++ b/spec/fixtures/actor.rb @@ -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 diff --git a/spec/fixtures/movie.rb b/spec/fixtures/movie.rb index 3156443..657db3c 100644 --- a/spec/fixtures/movie.rb +++ b/spec/fixtures/movie.rb @@ -26,7 +26,7 @@ def url(obj = nil) @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 diff --git a/spec/integration/caching_spec.rb b/spec/integration/caching_spec.rb index f5fb9b1..246ba79 100644 --- a/spec/integration/caching_spec.rb +++ b/spec/integration/caching_spec.rb @@ -2,8 +2,8 @@ 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 @@ 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 @@ 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 @@ 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 diff --git a/spec/integration/errors_spec.rb b/spec/integration/errors_spec.rb index 67d4df5..5aa8273 100644 --- a/spec/integration/errors_spec.rb +++ b/spec/integration/errors_spec.rb @@ -7,19 +7,14 @@ 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 diff --git a/spec/integration/instrumentation_spec.rb b/spec/integration/instrumentation_spec.rb index 59ad425..9fbed22 100644 --- a/spec/integration/instrumentation_spec.rb +++ b/spec/integration/instrumentation_spec.rb @@ -10,9 +10,9 @@ 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( diff --git a/spec/integration/key_transform_spec.rb b/spec/integration/key_transform_spec.rb index d178384..2f9a6da 100644 --- a/spec/integration/key_transform_spec.rb +++ b/spec/integration/key_transform_spec.rb @@ -2,12 +2,12 @@ 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 @@ 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 diff --git a/spec/integration/mixed_collection_spec.rb b/spec/integration/mixed_collection_spec.rb new file mode 100644 index 0000000..989fd54 --- /dev/null +++ b/spec/integration/mixed_collection_spec.rb @@ -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