Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add explicit serializer not found policy #1949

Closed

Conversation

GregPK
Copy link

@GregPK GregPK commented Oct 20, 2016

Purpose

Allow users to setup a policy to fail explicity when an ActiveRecord::Base inherited model is being used without an explicit Serializer defined.

Currently, when rendering a ActiveRecord::Base model it falls back on the default JSON serializer, exposing all attributes.

Changes

  • ActiveModel::Serializer#get_serializer_for

Related GitHub issues

Copy link
Contributor

@NullVoxPopuli NullVoxPopuli left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like a good start, just a few comments / questions.


if serializer_class
serializer_class
elsif klass.superclass && ![BasicObject, Object].include?(klass.superclass)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why'd you add to the condition here?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to get a meaningful error message. Otherwise, we always get klass == BasicObject and hence ("Serializer for [BasicObject] not found"), since it's at the bottom of the object hierarchy.
I agree this is a bit wonky, but I couldn't think of anything better.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

perhaps,

But since we don't just deal with ActiveRecord objects, I think this scenario would behave unexpectedly with your code.

2016-10-20-115654_326x111_scrot

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@NullVoxPopuli As I understand it (from reading this) we cannot find a serializer for any object:

An ActiveModel::Serializer wraps a serializable resource and exposes an attributes method ...

So the method in question would have to at least have :serialized_hash implemented, probably inherit ActiveModel::Model or include ActiveModel::Serialization.

Also: I've refined the test to show what I want to do with the class handed to the policy:
837df53

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

right, but what is klass right now? (this is a huge chance in improve the in-code docs).

  • does it represent a serializable object? (PORO, AR, etc -- none of these need serialized_hash)

So, with your test, what happens if Comment were defined as:

class Comment; end

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what you mean - I've added a test to see if this doesn't mess up POROs - please take a look if this is what you're concerned about.

This is of course under the assumption that if an object is not serializable then it should not trigger the policy.

elsif klass.superclass && ![BasicObject, Object].include?(klass.superclass)
get_serializer_for(klass.superclass)
elsif config.on_serializer_not_found.respond_to?(:call) && klass.respond_to?(:serialized_attributes)
config.on_serializer_not_found.call(klass)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure how I feel about ending with situation 4 (as described in the method comments) as the result of an empty block.

Can you explain why you're checking for serialized_attributes in the elsif here, and what you would do if on_serializer_not_found is nil?

@@ -10,6 +10,7 @@ module Configuration
config = base.config
config.collection_serializer = ActiveModel::Serializer::CollectionSerializer
config.serializer_lookup_enabled = true
config.on_serializer_not_found = -> {}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what if this is nil?
should it start out as nil? does defaulting to an empty block feel weird?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comments above about the effects (TLDR: none). When I set the default here, I thought that maybe someone will be looking at the code here to consider the values for on_serializer_not_found. I thought that an empty proc will communicate better that this was created to be (as opposed to nil which could be anything).

ActiveModelSerializers.config.on_serializer_not_found = @previous_serializer_not_found_policy
end

def test_serializer_not_found_triggers_configured_policy
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you change these to the test 'serializer not found triggers configured policy' test format.

Thanks!

end
end

def self.excluded_base_class?(klass)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this method name doesn't make sense, it has a negative word, yet the condition is testing for the positive


if serializer_class
serializer_class
elsif klass.superclass && !excluded_base_class?(klass.superclass)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see, where I'm getting caught up on this, is that I'm reading this as:

"if the objec i'm trying to serialize has a superclass, and that superclass is neither ActiveRecord::Base, BasicObject, nor Object, then get the serializer for that ....."

And here is the problem (in my looking things over too quickly -- you're getting the serializer for the superclass, not klass -- this makes sense now -- sorry about the confusion).

So, I guess for clarity, the condition should be extracted to a method -- maybe

`is_superclass_allowed_to_be_serializable?(klass)

but then, why don't we want those superclasses serializable? I know it's weird otherwise, but what if someone wanted to try to write a general serializer for Object? would that be super weird? idk.

Copy link
Author

@GregPK GregPK Oct 21, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem here is that I only need this additional if condition to actually have a side-effect on the next if, which is really why we're coming up against this kind of reasoning issues. I'll try to come up with a better solution.


if serializer_class
serializer_class
elsif klass.superclass && find_serializer_class(klass.superclass)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this makes much more sense!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also, I was jus about to suggest extracting the serializer_lookup to a method. :-)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah - it was obvious after staring for 2 hours at it

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if A < B < C and there is a serializer for C but not for B?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds like we need to check ancestors?

elsif klass.superclass && find_serializer_class(klass.superclass)
get_serializer_class(klass.superclass)
elsif config.on_serializer_not_found.respond_to?(:call) && klass.respond_to?(:serialized_attributes)
config.on_serializer_not_found.call(klass)
end
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what happens if all the conditions are false? what do we do then?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we return nil like we always did :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

;-)

@NullVoxPopuli
Copy link
Contributor

I feel MUCH better about this now. so, you get the 👍 from me.

I'd like one more maintainer to review though -- @bf4, @beauby?

end
end

def self.find_serializer_class(klass)
serializer_lookup_chain_for(klass).map(&:safe_constantize).find { |x| x && x < ActiveModel::Serializer }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can be lazified, i.e.:

serializer_lookup_chain_for(klass).lazy.map(&:safe_constantize).find { |x| x && x < ActiveModel::Serializer }

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lazified.

@GregPK GregPK force-pushed the issue_1853_serializer_not_found_policy branch 2 times, most recently from b09d925 to b3bf65a Compare October 30, 2016 10:28
@GregPK
Copy link
Author

GregPK commented Oct 30, 2016

@NullVoxPopuli @beauby I cleaned up the code and took care of the ancestor issues. Please take a look.

@GregPK GregPK closed this Oct 30, 2016
@GregPK GregPK reopened this Oct 30, 2016
@NullVoxPopuli
Copy link
Contributor

@GregPK can you fix the rubocop issue real quick?


if serializer_class
serializer_class
elsif (ancestor_serializer_classes = ancestor_serializer_classes(klass)).present?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so this is change 1: s/klass.superclass/ancestor_serializer_classes(klass)

This is similar to code in either 0.8 or 0.9 IIRC

better Ruby to check for .any? as you know you have a collection and you're checking if there's anything in it. present? is one of those convinces Rails gives us that makes our code more less clear.

serializer_class
elsif (ancestor_serializer_classes = ancestor_serializer_classes(klass)).present?
ancestor_serializer_classes.first
elsif config.on_serializer_not_found.respond_to?(:call) && klass.respond_to?(:serialized_attributes)
Copy link
Member

@bf4 bf4 Oct 31, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and this is change 2: a new condition

Since we own this config, we shouldn't need to check if it responds to call. We can either make a default strategy or just check if the config is set. I think I like having a default strategy that does nothing an returns :no_serializer_found

What is serialized_attributes that you are checking for?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. The config setup you mentioned is probably the best way to go. However, if we setup a default strategy that returns a symbol, we will brake backwards compatibility for anyone relying on the historical behaviour of returning nil.
  2. You're right about checking for callability. I think we can actually do elsif config.on_serializer_not_found - if it's an empty proc it will run, and when someone messes up the config value (ie setting it to :no_serializer_found instead of -> { :no_serializer_found } he will get an explicit error about what's wrong with the code. WDYT?
  3. For :serialized_attributes, I'm checking whether this is actually something that represents a serializable AR resource. I'm aware that this excludes non AR-based objects. Maybe change this to klass.respond_to?(:serialized_attributes) || klass.respond_to?(:serialized_hash)? This will handle both cases. I would much rather not couple this to AR via serialized_attributes but I could not find any other way of detecting is the object is both AR-based and serializable.

@NullVoxPopuli
Copy link
Contributor

needs rebase

@mrnugget
Copy link

mrnugget commented Apr 6, 2017

Any updates on this? I'd love to see this get merged instead of having to add the monkey-patch (which is great btw.!) to our codebase.

@bf4
Copy link
Member

bf4 commented Apr 6, 2017

I suppose what I might like is a strict mode. i.e., if no serializer is passed explicitly, then raise an error. This means that it would error if you tried to pass in a hash. The problem with having a 'default serializer' is that if someone tries to serialize a hash, we have no way of knowing what 'type' it is. This is particularly problematic for JSON API.

so, it would be something like:

- return nil unless config.serializer_lookup_enabled
+ case config.serializer_lookup_enabled
+ when :strict_mode then fail StrictMode, "Won't lookup serializer for '#{klass}' In Strict mode serializers must be explicit"
+ when false then return nil
+ else nil # continue
+ end
serializers_cache.fetch_or_store(klass) do

@mrnugget
Copy link

mrnugget commented Apr 7, 2017

I thought about changing the approach to this problem, too. I tried by adding a last element to the lookup chain, that raises. The idea being: "if you reached this end of the lookup chain, there's no serializer for you". The only problem is that every element in the chain will be called for every lookup in one flat_map call, so it raises every time, even though the lookup may stop earlier.

@beauby
Copy link
Contributor

beauby commented Apr 7, 2017

Truth is, relying on serializer inference incurs a massive perf drop, for a few saved keystrokes.

@mrnugget
Copy link

mrnugget commented Apr 7, 2017

What I have now running is a slightly modified version of the code in this pull request. Slightly modified in order to make it work with Rails 5.

-      elsif config.on_serializer_not_found.respond_to?(:call) && klass.respond_to?(:serialized_attributes)
+      elsif config.on_serializer_not_found.respond_to?(:call) && [ActiveRecord::Base, ApplicationRecord].include?(klass.superclass)
      config.on_serializer_not_found.call(klass)

The change was necessary, because serialized_attributes was removed in Rails 5. Now the code says: "Okay, by looking for a serializer and walking up the superclasses you've now reached ApplicationRecord/ActiveRecord::Base and so far we found no serializer, that means.. raise an exception". It works, without breaking the serialization of Hash and String for example.

@NullVoxPopuli
Copy link
Contributor

Truth is, relying on serializer inference incurs a massive perf drop, for a few saved keystrokes.

@beauby do we have benchmarks on this?
Also, do we have a solution for explicit -- yet polymorphic -- serializers?

@GregPK GregPK closed this Aug 26, 2019
@GregPK GregPK deleted the issue_1853_serializer_not_found_policy branch August 26, 2019 17:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants