Merge pull request #1252 from bf4/document_serialization_scope

[DOCS/TEST] Add serialization_scope example
This commit is contained in:
Benjamin Fleischer 2016-03-12 22:37:35 -06:00
commit 3e0f85e9c9
3 changed files with 315 additions and 55 deletions

View File

@ -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)
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.
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`.
**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)

View File

@ -96,6 +96,8 @@ module ActiveModel
_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

View File

@ -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
end
attributes :admin?
end
class UserTestController < ActionController::Base
protect_from_forgery
before_action { request.format = :json }
def current_user
User.new(id: 1, name: 'Pete', admin: false)
end
def render_new_user
render json: User.new(id: 1, name: 'Pete', admin: false), serializer: UserSerializer, adapter: :json_api
admin
end
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
tests UserTestController
def body
"The 'scope' is the 'current_user': #{scope == current_user}"
end
def test_default_scope_name
get :render_new_user
assert_equal '{"data":{"id":"1","type":"users","attributes":{"admin?":false}}}', @response.body
end
end
class SerializationScopeNameTest < ActionController::TestCase
class AdminUserSerializer < ActiveModel::Serializer
def admin?
current_admin.admin
end
attributes :admin?
end
class AdminUserTestController < ActionController::Base
protect_from_forgery
serialization_scope :current_admin
before_action { request.format = :json }
def current_admin
User.new(id: 2, name: 'Bob', admin: true)
end
def render_new_user
render json: User.new(id: 1, name: 'Pete', admin: false), serializer: AdminUserSerializer, adapter: :json_api
end
end
tests AdminUserTestController
def test_override_scope_name_with_controller
get :render_new_user
assert_equal '{"data":{"id":"1","type":"users","attributes":{"admin?":true}}}', @response.body
def comments
if current_user.admin?
[Comment.new(id: 1, body: 'Admin')]
else
[Comment.new(id: 2, body: 'Scoped')]
end
end
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
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 new_post
Post.new(id: 4, title: 'Title')
end
def serializer
PostSerializer
end
end
class PostViewContextSerializer < PostSerializer
def body
"The 'scope' is the 'view_context': #{scope == view_context}"
end
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
end
class SerializationScopeTest < ActionController::TestCase
class PostViewContextTestController < PostTestController
serialization_scope :view_context
private
def serializer
PostViewContextSerializer
end
end
tests PostViewContextTestController
def test_defined_serialization_scope
assert_equal :view_context, @controller._serialization_scope
end
def test_defined_serialization_scope_object
assert_equal @controller.view_context.class, @controller.serialization_scope.class
end
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 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
# 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
# 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