Skip to content

Add serialization_scope example #1252

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

Merged
merged 3 commits into from
Mar 13, 2016
Merged
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
93 changes: 92 additions & 1 deletion docs/general/serializers.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,98 @@ PR please :)

#### #scope

PR please :)
Allows you to include in the serializer access to an external method.

It's intended to provide an authorization context to the serializer, so that
you may e.g. show an admin all comments on a post, else only published comments.

- `scope` is a method on the serializer instance that comes from `options[:scope]`. It may be nil.
- `scope_name` is an option passed to the new serializer (`options[:scope_name]`). The serializer
defines a method with that name that calls the `scope`, e.g. `def current_user; scope; end`.
Note: it does not define the method if the serializer instance responds to it.

That's a lot of words, so here's some examples:

First, let's assume the serializer is instantiated in the controller, since that's the usual scenario.
We'll refer to the serialization context as `controller`.

| options | `Serializer#scope` | method definition |
|-------- | ------------------|--------------------|
| `scope: current_user, scope_name: :current_user` | `current_user` | `Serializer#current_user` calls `controller.current_user`
| `scope: view_context, scope_name: :view_context` | `view_context` | `Serializer#view_context` calls `controller.view_context`

We can take advantage of the scope to customize the objects returned based
on the current user (scope).

For example, we can limit the posts the current user sees to those they created:

```ruby
class PostSerializer < ActiveModel::Serializer
attributes :id, :title, :body

# scope comments to those created_by the current user
has_many :comments do
object.comments.where(created_by: current_user)
Copy link
Member Author

Choose a reason for hiding this comment

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

is same as object.comments.where(created_by: scope)

end
end
```

Whether you write the method as above or as `object.comments.where(created_by: scope)`
is a matter of preference (assuming `scope_name` has been set).

##### Controller Authorization Context

In the controller, the scope/scope_name options are equal to
the [`serialization_scope`method](https://github.com/rails-api/active_model_serializers/blob/d02cd30fe55a3ea85e1d351b6e039620903c1871/lib/action_controller/serialization.rb#L13-L20),
which is `:current_user`, by default.
Copy link
Member Author

Choose a reason for hiding this comment

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

The controller defineds

      class_attribute :_serialization_scope
      self._serialization_scope = :current_user

    def serialization_scope
      send(_serialization_scope) if _serialization_scope &&
        respond_to?(_serialization_scope, true)
    end
      def serialization_scope(scope)
        self._serialization_scope = scope
      end
    end

then serialization resource calls

        serializable_resource.serialization_scope ||= serialization_scope
        serializable_resource.serialization_scope_name = _serialization_scope

notice that serialization_scope is actually the return value of sending the _serialization_scope method in the controller (if that method exists).

Those two setters correspond to the serializar options scope and scope_name respectively, which may also be passed in as params to render


Specfically, the `scope_name` is defaulted to `:current_user`, and may be set as
`serialization_scope :view_context`. The `scope` is set to `send(scope_name)` when `scope_name` is
present and the controller responds to `scope_name`.

Thus, in a serializer, the controller provides `current_user` as the
current authorization scope when you call `render :json`.
Copy link
Member Author

Choose a reason for hiding this comment

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

There is currently a bug that prevents you from passing scope or scope_name via render options.


**IMPORTANT**: Since the scope is set at render, you may want to customize it so that `current_user` isn't
called on every request. This was [also a problem](https://github.com/rails-api/active_model_serializers/pull/1252#issuecomment-159810477)
in [`0.9`](https://github.com/rails-api/active_model_serializers/tree/0-9-stable#customizing-scope).

We can change the scope from `current_user` to `view_context`.

```diff
class SomeController < ActionController::Base
+ serialization_scope :view_context

def current_user
User.new(id: 2, name: 'Bob', admin: true)
end

def edit
user = User.new(id: 1, name: 'Pete')
render json: user, serializer: AdminUserSerializer, adapter: :json_api
end
end
```

We could then use the controller method `view_context` in our serializer, like so:

```diff
class AdminUserSerializer < ActiveModel::Serializer
attributes :id, :name, :can_edit

def can_edit?
+ view_context.current_user.admin?
end
end
```

So that when we render the `#edit` action, we'll get

```json
{"data":{"id":"1","type":"users","attributes":{"name":"Pete","can_edit":true}}}
```

Where `can_edit` is `view_context.current_user.admin?` (true).

#### #read_attribute_for_serialization(key)

Expand Down
2 changes: 2 additions & 0 deletions lib/active_model/serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ def self._serializer_instance_method_defined?(name)
_serializer_instance_methods.include?(name)
end

# TODO: Fix load-order failures when different serializer instances define different
# scope methods
def self._serializer_instance_methods
@_serializer_instance_methods ||= (public_instance_methods - Object.public_instance_methods).to_set
end
Expand Down
237 changes: 202 additions & 35 deletions test/action_controller/serialization_scope_name_test.rb
Original file line number Diff line number Diff line change
@@ -1,63 +1,230 @@
require 'test_helper'
require 'pathname'

class DefaultScopeNameTest < ActionController::TestCase
class UserSerializer < ActiveModel::Serializer
module SerializationScopeTesting
class User < ActiveModelSerializers::Model
attr_accessor :id, :name, :admin
def admin?
current_user.admin
admin
end
attributes :admin?
end
class Comment < ActiveModelSerializers::Model
attr_accessor :id, :body
end
class Post < ActiveModelSerializers::Model
attr_accessor :id, :title, :body, :comments
end
class PostSerializer < ActiveModel::Serializer
attributes :id, :title, :body, :comments

class UserTestController < ActionController::Base
protect_from_forgery

before_action { request.format = :json }
def body
"The 'scope' is the 'current_user': #{scope == current_user}"
end

def current_user
User.new(id: 1, name: 'Pete', admin: false)
def comments
if current_user.admin?
[Comment.new(id: 1, body: 'Admin')]
else
[Comment.new(id: 2, body: 'Scoped')]
end
end

def render_new_user
render json: User.new(id: 1, name: 'Pete', admin: false), serializer: UserSerializer, adapter: :json_api
def json_key
'post'
end
end
class PostTestController < ActionController::Base
attr_accessor :current_user
def render_post_by_non_admin
self.current_user = User.new(id: 3, name: 'Pete', admin: false)
render json: new_post, serializer: serializer, adapter: :json
end

tests UserTestController
def render_post_by_admin
self.current_user = User.new(id: 3, name: 'Pete', admin: true)
render json: new_post, serializer: serializer, adapter: :json
end

private

def test_default_scope_name
get :render_new_user
assert_equal '{"data":{"id":"1","type":"users","attributes":{"admin?":false}}}', @response.body
def new_post
Post.new(id: 4, title: 'Title')
end

def serializer
PostSerializer
end
end
end
class PostViewContextSerializer < PostSerializer
def body
"The 'scope' is the 'view_context': #{scope == view_context}"
end

class SerializationScopeNameTest < ActionController::TestCase
class AdminUserSerializer < ActiveModel::Serializer
def admin?
current_admin.admin
def comments
if view_context.controller.current_user.admin?
[Comment.new(id: 1, body: 'Admin')]
else
[Comment.new(id: 2, body: 'Scoped')]
end
end
end
class DefaultScopeTest < ActionController::TestCase
tests PostTestController

def test_default_serialization_scope
assert_equal :current_user, @controller._serialization_scope
end

def test_default_serialization_scope_object
assert_equal @controller.current_user, @controller.serialization_scope
end

def test_default_scope_non_admin
get :render_post_by_non_admin
expected_json = {
post: {
id: 4,
title: 'Title',
body: "The 'scope' is the 'current_user': true",
comments: [
{ id: 2, body: 'Scoped' }
]
}
}.to_json
assert_equal expected_json, @response.body
end

def test_default_scope_admin
get :render_post_by_admin
expected_json = {
post: {
id: 4,
title: 'Title',
body: "The 'scope' is the 'current_user': true",
comments: [
{ id: 1, body: 'Admin' }
]
}
}.to_json
assert_equal expected_json, @response.body
end
attributes :admin?
end
class SerializationScopeTest < ActionController::TestCase
class PostViewContextTestController < PostTestController
serialization_scope :view_context

private

def serializer
PostViewContextSerializer
end
end
tests PostViewContextTestController

class AdminUserTestController < ActionController::Base
protect_from_forgery
def test_defined_serialization_scope
assert_equal :view_context, @controller._serialization_scope
end

serialization_scope :current_admin
before_action { request.format = :json }
def test_defined_serialization_scope_object
assert_equal @controller.view_context.class, @controller.serialization_scope.class
end

def current_admin
User.new(id: 2, name: 'Bob', admin: true)
def test_serialization_scope_non_admin
get :render_post_by_non_admin
expected_json = {
post: {
id: 4,
title: 'Title',
body: "The 'scope' is the 'view_context': true",
comments: [
{ id: 2, body: 'Scoped' }
]
}
}.to_json
assert_equal expected_json, @response.body
end

def render_new_user
render json: User.new(id: 1, name: 'Pete', admin: false), serializer: AdminUserSerializer, adapter: :json_api
def test_serialization_scope_admin
get :render_post_by_admin
expected_json = {
post: {
id: 4,
title: 'Title',
body: "The 'scope' is the 'view_context': true",
comments: [
{ id: 1, body: 'Admin' }
]
}
}.to_json
assert_equal expected_json, @response.body
end
end
# FIXME: Has bugs. See comments below and
# https://github.com/rails-api/active_model_serializers/issues/1509
class NilSerializationScopeTest < ActionController::TestCase
class PostViewContextTestController < ActionController::Base
serialization_scope nil

attr_accessor :current_user

def render_post_with_no_scope
self.current_user = User.new(id: 3, name: 'Pete', admin: false)
render json: new_post, serializer: PostSerializer, adapter: :json
end

tests AdminUserTestController
# TODO: run test when
# global state in Serializer._serializer_instance_methods is fixed
# def render_post_with_passed_in_scope
# self.current_user = User.new(id: 3, name: 'Pete', admin: false)
# render json: new_post, serializer: PostSerializer, adapter: :json, scope: current_user, scope_name: :current_user
# end

private

def new_post
Post.new(id: 4, title: 'Title')
end
end
tests PostViewContextTestController

def test_nil_serialization_scope
assert_nil @controller._serialization_scope
end

def test_nil_serialization_scope_object
assert_nil @controller.serialization_scope
end

# TODO: change to NoMethodError and match 'admin?' when the
# global state in Serializer._serializer_instance_methods is fixed
def test_nil_scope
if Rails.version.start_with?('4.0')
exception_class = NoMethodError
exception_matcher = 'admin?'
else
exception_class = NameError
exception_matcher = /admin|current_user/
end
exception = assert_raises(exception_class) do
get :render_post_with_no_scope
end
assert_match exception_matcher, exception.message
end
Copy link
Member Author

Choose a reason for hiding this comment

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

Bug in Serializer._serializer_instance_methods is causing the scoped method to sometimes be available to read_attribute_for_serialization and sometime not, since the public instance methods are cached once, but they change with scopes.

Copy link
Contributor

Choose a reason for hiding this comment

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

is the fix for this bug for another PR?


def test_override_scope_name_with_controller
get :render_new_user
assert_equal '{"data":{"id":"1","type":"users","attributes":{"admin?":true}}}', @response.body
# TODO: run test when
# global state in Serializer._serializer_instance_methods is fixed
# def test_nil_scope_passed_in_current_user
# get :render_post_with_passed_in_scope
# expected_json = {
# post: {
# id: 4,
# title: 'Title',
# body: "The 'scope' is the 'current_user': true",
# comments: [
# { id: 2, body: 'Scoped' }
# ]
# }
# }.to_json
# assert_equal expected_json, @response.body
# end
end
end