Skip to content

Allow Key Format to Be Overridden and Have Sensible Defaults #1029

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

Closed
wants to merge 14 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ tmp
*.swp
.ruby-version
.ruby-gemset
tags
2 changes: 2 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ Metrics/LineLength:

Metrics/MethodLength:
Max: 106 # TODO: Lower to 10
Exclude:
- test/**/*.rb

Metrics/PerceivedComplexity:
Max: 9 # TODO: Lower to 7
Expand Down
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,38 @@ class PostSerializer < ActiveModel::Serializer
end
```

### Overriding the Key Format
Copy link
Member

Choose a reason for hiding this comment

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

👍
It would be awesome if along with this PR you could add some Docs to our new documentation we have being working on before release.
Maybe event add some article about integrating it with ember.js using our How to section.


You can specify that serializers use a different style for the format of the
keys that are sent over. This can be done globally or at the controller action
level.

Each adapter has a default format, so typically you're either not going to have
to change this, or you'll edit the global setting.

For example, the JSON adapter defaults to `lower_camel` whereas the JSON API
adapter defaults to `dasherize`. Any other adapter will default to `unaltered`.

Copy link
Contributor

Choose a reason for hiding this comment

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

I think the JSON API adapter should default to lower_camel as well to avoid huge confusion on upgrade.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@beauby I am very much in the other camp on that. The JSON API standard recommends dasherized keys and the couple JSON API JS libraries I've seen default to expecting incoming requests to be formatted with dasherized keys.

In the vein of making this Just Work with JSON API, I think it should absolutely be dasherized. Otherwise almost everyone who uses it will need to add the global config option to their projects.

I'll bow to the majority opinion, but I just wanted to voice my concern.

Copy link
Contributor

Choose a reason for hiding this comment

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

You're right, if JSON API recommends dashes, we should do dashes by default.

Copy link
Member

Choose a reason for hiding this comment

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

```ruby
# Global
ActiveModel::Serializer.config.key_format = :lower_camel

# Per Controller Action
class MyController < ActionController::Base
def index
render json: my_resource,
key_format: :lower_camel
end
end
```

#### Currently Supported Key Formats ####

* `:unaltered`
* `:lower_camel`
* `:underscore`
* `:dasherize` (aliased to `json_api`)

### Built in Adapters

#### FlattenJSON
Expand Down
2 changes: 1 addition & 1 deletion lib/active_model/serializable_resource.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
require 'set'
module ActiveModel
class SerializableResource
ADAPTER_OPTION_KEYS = Set.new([:include, :fields, :adapter])
ADAPTER_OPTION_KEYS = Set.new([:include, :fields, :adapter, :key_format])

# Primary interface to composing a resource with a serializer and adapter.
# @return the serializable_resource, ready for #as_json/#to_json/#serializable_hash.
Expand Down
21 changes: 21 additions & 0 deletions lib/active_model/serializer/adapter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,27 @@ def include_meta(json)
json[meta_key] = meta if meta
json
end

def key_format
instance_options[:key_format] || ActiveModel::Serializer.config.key_format || default_key_format
end

def format_key(formattable_key)
case key_format

Choose a reason for hiding this comment

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

Assume we have namespaced object Admin::User then key should be user instead of admin/user. What do you think about adding something like formattable_key.to_s.demodulize?

Copy link
Member

Choose a reason for hiding this comment

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

@timurvafin Do you have a ref for Admin::User -> user? I'm not sure what I would do in such a case.

Copy link
Member

Choose a reason for hiding this comment

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

when :lower_camel
formattable_key.to_s.camelize(:lower).to_sym
when :dasherize, :json_api
formattable_key.to_s.underscore.dasherize.to_sym
when :underscore
formattable_key.to_s.underscore.to_sym
else
formattable_key
end
end

def default_key_format
:unaltered
end
end
end
end
30 changes: 26 additions & 4 deletions lib/active_model/serializer/adapter/json.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ class ActiveModel::Serializer::Adapter::Json < ActiveModel::Serializer::Adapter
extend ActiveSupport::Autoload
autoload :FragmentCache

# rubocop:disable Metrics/AbcSize
def serializable_hash(options = nil)
options ||= {}
if serializer.respond_to?(:each)
Expand All @@ -13,23 +14,39 @@ def serializable_hash(options = nil)
serializer.attributes(options)
end

core = core.each_with_object({}) do |(name, value), formatted_hash|
formatted_hash[format_key(name)] = value
end

serializer.associations.each do |association|
serializer = association.serializer
association_options = association.options

if serializer.respond_to?(:each)
array_serializer = serializer
hash[association.key] = array_serializer.map do |item|
cache_check(item) do
formatted_association_key = format_key(association.key)

hash[formatted_association_key] = array_serializer.map do |item|
attributes = cache_check(item) do
item.attributes(association_options)
end

attributes.each_with_object({}) do |(name, value), formatted_hash|
formatted_hash[format_key(name)] = value
end
end
else
hash[association.key] =
formatted_association_key = format_key(association.key)

hash[formatted_association_key] =
if serializer && serializer.object
cache_check(serializer) do
attributes = cache_check(serializer) do
serializer.attributes(options)
end

attributes.each_with_object({}) do |(name, value), formatted_hash|
formatted_hash[format_key(name)] = value
end
elsif association_options[:virtual_value]
association_options[:virtual_value]
end
Expand All @@ -40,6 +57,11 @@ def serializable_hash(options = nil)

{ root => result }
end
# rubocop:enable Metrics/AbcSize

def default_key_format
:lower_camel
end

def fragment_cache(cached_hash, non_cached_hash)
ActiveModel::Serializer::Adapter::Json::FragmentCache.new().fragment_cache(cached_hash, non_cached_hash)
Expand Down
29 changes: 26 additions & 3 deletions lib/active_model/serializer/adapter/json_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ def initialize(serializer, options = {})

def serializable_hash(options = nil)
options ||= {}

if serializer.respond_to?(:each)
serializable_hash_for_collection(serializer, options)
else
Expand All @@ -28,6 +29,10 @@ def fragment_cache(cached_hash, non_cached_hash)
ActiveModel::Serializer::Adapter::JsonApi::FragmentCache.new.fragment_cache(root, cached_hash, non_cached_hash)
end

def default_key_format
:dasherize
Copy link
Member

Choose a reason for hiding this comment

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

Awesome! Have you benchmarked the performance impact of mutating the hash by default?

Copy link
Member

Choose a reason for hiding this comment

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

yep that would be cool 😄 not required for the PR itself, but I'm curious about it. That remembers me the Benchmark PR I had, I might need to pick that up again.

end

private

ActiveModel.silence_warnings do
Expand Down Expand Up @@ -85,7 +90,7 @@ def resource_identifier_for(serializer)
type = resource_identifier_type_for(serializer)
id = resource_identifier_id_for(serializer)

{ id: id.to_s, type: type }
{ id: id.to_s, type: format_key(type).to_s }
end

def resource_object_for(serializer, options = {})
Expand All @@ -94,7 +99,15 @@ def resource_object_for(serializer, options = {})
cache_check(serializer) do
result = resource_identifier_for(serializer)
attributes = serializer.attributes(options).except(:id)
result[:attributes] = attributes if attributes.any?

if attributes.any?
attributes = attributes.each_with_object({}) do |(name, value), hash|
hash[format_key(name)] = value
end

result[:attributes] = attributes
end

result
end
end
Expand All @@ -120,7 +133,17 @@ def relationship_value_for(serializer, options = {})
end

def relationships_for(serializer)
Hash[serializer.associations.map { |association| [association.key, { data: relationship_value_for(association.serializer, association.options) }] }]
Hash[
serializer.associations.map do |association|
[
format_key(association.key),
{
data: relationship_value_for(association.serializer,
association.options)
}
]
end
]
end

def included_for(serializer)
Expand Down
1 change: 1 addition & 0 deletions lib/active_model/serializer/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ module Configuration
base.config.array_serializer = ActiveModel::Serializer::ArraySerializer
base.config.adapter = :flatten_json
base.config.jsonapi_resource_type = :plural
base.config.key_format = nil
Copy link
Member

Choose a reason for hiding this comment

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

Any particular reason to declare it as nil?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@joaomdmoura I just wanted to be explicit so that anyone spelunking in the code can easily look at this file and see what all the global configuration options are. I'm not tied to it if ya want me to remove it.

end
end
end
Expand Down
4 changes: 2 additions & 2 deletions test/action_controller/explicit_serializer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ def render_array_using_explicit_serializer_and_custom_serializers
@author = Author.new(name: 'Jane Blogger')
@author.posts = [@post]
@post.author = @author
@first_comment = Comment.new(id: 1, body: 'ZOMG A COMMENT')
@second_comment = Comment.new(id: 2, body: 'ZOMG ANOTHER COMMENT')
@first_comment = Comment.new(id: 1, body: 'ZOMG A COMMENT', original_format: 'markdown')
@second_comment = Comment.new(id: 2, body: 'ZOMG ANOTHER COMMENT', original_format: 'text')
@post.comments = [@first_comment, @second_comment]
@first_comment.post = @post
@first_comment.author = nil
Expand Down
20 changes: 12 additions & 8 deletions test/action_controller/serialization_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def render_array_using_implicit_serializer_and_meta
end

def render_object_with_cache_enabled
@comment = Comment.new(id: 1, body: 'ZOMG A COMMENT')
@comment = Comment.new(id: 1, body: 'ZOMG A COMMENT', original_format: 'markdown')
@author = Author.new(id: 1, name: 'Joao Moura.')
@post = Post.new(id: 1, title: 'New Post', body: 'Body', comments: [@comment], author: @author)

Expand All @@ -72,7 +72,7 @@ def update_and_render_object_with_cache_enabled
end

def render_object_expired_with_cache_enabled
comment = Comment.new(id: 1, body: 'ZOMG A COMMENT')
comment = Comment.new(id: 1, body: 'ZOMG A COMMENT', original_format: 'markdown')
author = Author.new(id: 1, name: 'Joao Moura.')
post = Post.new(id: 1, title: 'New Post', body: 'Body', comments: [comment], author: author)

Expand All @@ -91,7 +91,7 @@ def render_object_expired_with_cache_enabled
end

def render_changed_object_with_cache_enabled
comment = Comment.new(id: 1, body: 'ZOMG A COMMENT')
comment = Comment.new(id: 1, body: 'ZOMG A COMMENT', original_format: 'markdown')
author = Author.new(id: 1, name: 'Joao Moura.')
post = Post.new(id: 1, title: 'ZOMG a New Post', body: 'Body', comments: [comment], author: author)

Expand Down Expand Up @@ -121,7 +121,7 @@ def render_fragment_changed_object_with_except_cache_enabled
end

def render_fragment_changed_object_with_relationship
comment = Comment.new(id: 1, body: 'ZOMG A COMMENT')
comment = Comment.new(id: 1, body: 'ZOMG A COMMENT', original_format: 'markdown')
comment2 = Comment.new(id: 1, body: 'ZOMG AN UPDATED-BUT-NOT-CACHE-EXPIRED COMMENT')
like = Like.new(id: 1, likeable: comment, time: 3.days.ago)

Expand Down Expand Up @@ -262,7 +262,8 @@ def test_render_with_cache_enable
comments: [
{
id: 1,
body: 'ZOMG A COMMENT' }
body: 'ZOMG A COMMENT',
originalFormat: 'markdown' }
],
blog: {
id: 999,
Expand Down Expand Up @@ -301,7 +302,8 @@ def test_render_with_cache_enable_and_expired
comments: [
{
id: 1,
body: 'ZOMG A COMMENT' }
body: 'ZOMG A COMMENT',
originalFormat: 'markdown' }
],
blog: {
id: 999,
Expand Down Expand Up @@ -355,7 +357,8 @@ def test_render_fragment_changed_object_with_relationship
'time' => Time.zone.now.to_s,
'likeable' => {
'id' => 1,
'body' => 'ZOMG A COMMENT'
'body' => 'ZOMG A COMMENT',
'originalFormat' => 'markdown'
}
}

Expand All @@ -375,7 +378,8 @@ def test_cache_expiration_on_update
comments: [
{
id: 1,
body: 'ZOMG A COMMENT' }
body: 'ZOMG A COMMENT',
originalFormat: 'markdown' }
],
blog: {
id: 999,
Expand Down
2 changes: 1 addition & 1 deletion test/adapter/json/belongs_to_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ class BelongsToTest < Minitest::Test
def setup
@post = Post.new(id: 42, title: 'New Post', body: 'Body')
@anonymous_post = Post.new(id: 43, title: 'Hello!!', body: 'Hello, world!!')
@comment = Comment.new(id: 1, body: 'ZOMG A COMMENT')
@comment = Comment.new(id: 1, body: 'ZOMG A COMMENT', original_format: 'markdown')
@post.comments = [@comment]
@anonymous_post.comments = []
@comment.post = @post
Expand Down
2 changes: 1 addition & 1 deletion test/adapter/json/collection_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ def test_with_serializer_option

expected = { blogs: [{
id: 1,
special_attribute: 'Special',
specialAttribute: 'Special',
articles: [{ id: 1, title: 'Hello!!', body: 'Hello, world!!' }, { id: 2, title: 'New Post', body: 'Body' }]
}] }
assert_equal expected, adapter.serializable_hash
Expand Down
8 changes: 4 additions & 4 deletions test/adapter/json/has_many_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ def setup
ActionController::Base.cache_store.clear
@author = Author.new(id: 1, name: 'Steve K.')
@post = Post.new(id: 42, title: 'New Post', body: 'Body')
@first_comment = Comment.new(id: 1, body: 'ZOMG A COMMENT')
@second_comment = Comment.new(id: 2, body: 'ZOMG ANOTHER COMMENT')
@first_comment = Comment.new(id: 1, body: 'ZOMG A COMMENT', original_format: 'markdown')
@second_comment = Comment.new(id: 2, body: 'ZOMG ANOTHER COMMENT', original_format: 'text')
@post.comments = [@first_comment, @second_comment]
@post.author = @author
@first_comment.post = @post
Expand All @@ -25,8 +25,8 @@ def test_has_many
serializer = PostSerializer.new(@post)
adapter = ActiveModel::Serializer::Adapter::Json.new(serializer)
assert_equal([
{ id: 1, body: 'ZOMG A COMMENT' },
{ id: 2, body: 'ZOMG ANOTHER COMMENT' }
{ id: 1, body: 'ZOMG A COMMENT', originalFormat: 'markdown' },
{ id: 2, body: 'ZOMG ANOTHER COMMENT', originalFormat: 'text' }
], adapter.serializable_hash[:post][:comments])
end

Expand Down
Loading