Skip to content

Commit 3e0f85e

Browse files
committed
Merge pull request #1252 from bf4/document_serialization_scope
[DOCS/TEST] Add serialization_scope example
2 parents 5e5d57e + 1b2f5ec commit 3e0f85e

File tree

3 files changed

+296
-36
lines changed

3 files changed

+296
-36
lines changed

docs/general/serializers.md

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,98 @@ PR please :)
156156
157157
#### #scope
158158
159-
PR please :)
159+
Allows you to include in the serializer access to an external method.
160+
161+
It's intended to provide an authorization context to the serializer, so that
162+
you may e.g. show an admin all comments on a post, else only published comments.
163+
164+
- `scope` is a method on the serializer instance that comes from `options[:scope]`. It may be nil.
165+
- `scope_name` is an option passed to the new serializer (`options[:scope_name]`). The serializer
166+
defines a method with that name that calls the `scope`, e.g. `def current_user; scope; end`.
167+
Note: it does not define the method if the serializer instance responds to it.
168+
169+
That's a lot of words, so here's some examples:
170+
171+
First, let's assume the serializer is instantiated in the controller, since that's the usual scenario.
172+
We'll refer to the serialization context as `controller`.
173+
174+
| options | `Serializer#scope` | method definition |
175+
|-------- | ------------------|--------------------|
176+
| `scope: current_user, scope_name: :current_user` | `current_user` | `Serializer#current_user` calls `controller.current_user`
177+
| `scope: view_context, scope_name: :view_context` | `view_context` | `Serializer#view_context` calls `controller.view_context`
178+
179+
We can take advantage of the scope to customize the objects returned based
180+
on the current user (scope).
181+
182+
For example, we can limit the posts the current user sees to those they created:
183+
184+
```ruby
185+
class PostSerializer < ActiveModel::Serializer
186+
attributes :id, :title, :body
187+
188+
# scope comments to those created_by the current user
189+
has_many :comments do
190+
object.comments.where(created_by: current_user)
191+
end
192+
end
193+
```
194+
195+
Whether you write the method as above or as `object.comments.where(created_by: scope)`
196+
is a matter of preference (assuming `scope_name` has been set).
197+
198+
##### Controller Authorization Context
199+
200+
In the controller, the scope/scope_name options are equal to
201+
the [`serialization_scope`method](https://github.com/rails-api/active_model_serializers/blob/d02cd30fe55a3ea85e1d351b6e039620903c1871/lib/action_controller/serialization.rb#L13-L20),
202+
which is `:current_user`, by default.
203+
204+
Specfically, the `scope_name` is defaulted to `:current_user`, and may be set as
205+
`serialization_scope :view_context`. The `scope` is set to `send(scope_name)` when `scope_name` is
206+
present and the controller responds to `scope_name`.
207+
208+
Thus, in a serializer, the controller provides `current_user` as the
209+
current authorization scope when you call `render :json`.
210+
211+
**IMPORTANT**: Since the scope is set at render, you may want to customize it so that `current_user` isn't
212+
called on every request. This was [also a problem](https://github.com/rails-api/active_model_serializers/pull/1252#issuecomment-159810477)
213+
in [`0.9`](https://github.com/rails-api/active_model_serializers/tree/0-9-stable#customizing-scope).
214+
215+
We can change the scope from `current_user` to `view_context`.
216+
217+
```diff
218+
class SomeController < ActionController::Base
219+
+ serialization_scope :view_context
220+
221+
def current_user
222+
User.new(id: 2, name: 'Bob', admin: true)
223+
end
224+
225+
def edit
226+
user = User.new(id: 1, name: 'Pete')
227+
render json: user, serializer: AdminUserSerializer, adapter: :json_api
228+
end
229+
end
230+
```
231+
232+
We could then use the controller method `view_context` in our serializer, like so:
233+
234+
```diff
235+
class AdminUserSerializer < ActiveModel::Serializer
236+
attributes :id, :name, :can_edit
237+
238+
def can_edit?
239+
+ view_context.current_user.admin?
240+
end
241+
end
242+
```
243+
244+
So that when we render the `#edit` action, we'll get
245+
246+
```json
247+
{"data":{"id":"1","type":"users","attributes":{"name":"Pete","can_edit":true}}}
248+
```
249+
250+
Where `can_edit` is `view_context.current_user.admin?` (true).
160251

161252
#### #read_attribute_for_serialization(key)
162253

lib/active_model/serializer.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@ def self._serializer_instance_method_defined?(name)
9696
_serializer_instance_methods.include?(name)
9797
end
9898

99+
# TODO: Fix load-order failures when different serializer instances define different
100+
# scope methods
99101
def self._serializer_instance_methods
100102
@_serializer_instance_methods ||= (public_instance_methods - Object.public_instance_methods).to_set
101103
end
Lines changed: 202 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,230 @@
11
require 'test_helper'
2-
require 'pathname'
32

4-
class DefaultScopeNameTest < ActionController::TestCase
5-
class UserSerializer < ActiveModel::Serializer
3+
module SerializationScopeTesting
4+
class User < ActiveModelSerializers::Model
5+
attr_accessor :id, :name, :admin
66
def admin?
7-
current_user.admin
7+
admin
88
end
9-
attributes :admin?
109
end
10+
class Comment < ActiveModelSerializers::Model
11+
attr_accessor :id, :body
12+
end
13+
class Post < ActiveModelSerializers::Model
14+
attr_accessor :id, :title, :body, :comments
15+
end
16+
class PostSerializer < ActiveModel::Serializer
17+
attributes :id, :title, :body, :comments
1118

12-
class UserTestController < ActionController::Base
13-
protect_from_forgery
14-
15-
before_action { request.format = :json }
19+
def body
20+
"The 'scope' is the 'current_user': #{scope == current_user}"
21+
end
1622

17-
def current_user
18-
User.new(id: 1, name: 'Pete', admin: false)
23+
def comments
24+
if current_user.admin?
25+
[Comment.new(id: 1, body: 'Admin')]
26+
else
27+
[Comment.new(id: 2, body: 'Scoped')]
28+
end
1929
end
2030

21-
def render_new_user
22-
render json: User.new(id: 1, name: 'Pete', admin: false), serializer: UserSerializer, adapter: :json_api
31+
def json_key
32+
'post'
2333
end
2434
end
35+
class PostTestController < ActionController::Base
36+
attr_accessor :current_user
37+
def render_post_by_non_admin
38+
self.current_user = User.new(id: 3, name: 'Pete', admin: false)
39+
render json: new_post, serializer: serializer, adapter: :json
40+
end
2541

26-
tests UserTestController
42+
def render_post_by_admin
43+
self.current_user = User.new(id: 3, name: 'Pete', admin: true)
44+
render json: new_post, serializer: serializer, adapter: :json
45+
end
46+
47+
private
2748

28-
def test_default_scope_name
29-
get :render_new_user
30-
assert_equal '{"data":{"id":"1","type":"users","attributes":{"admin?":false}}}', @response.body
49+
def new_post
50+
Post.new(id: 4, title: 'Title')
51+
end
52+
53+
def serializer
54+
PostSerializer
55+
end
3156
end
32-
end
57+
class PostViewContextSerializer < PostSerializer
58+
def body
59+
"The 'scope' is the 'view_context': #{scope == view_context}"
60+
end
3361

34-
class SerializationScopeNameTest < ActionController::TestCase
35-
class AdminUserSerializer < ActiveModel::Serializer
36-
def admin?
37-
current_admin.admin
62+
def comments
63+
if view_context.controller.current_user.admin?
64+
[Comment.new(id: 1, body: 'Admin')]
65+
else
66+
[Comment.new(id: 2, body: 'Scoped')]
67+
end
68+
end
69+
end
70+
class DefaultScopeTest < ActionController::TestCase
71+
tests PostTestController
72+
73+
def test_default_serialization_scope
74+
assert_equal :current_user, @controller._serialization_scope
75+
end
76+
77+
def test_default_serialization_scope_object
78+
assert_equal @controller.current_user, @controller.serialization_scope
79+
end
80+
81+
def test_default_scope_non_admin
82+
get :render_post_by_non_admin
83+
expected_json = {
84+
post: {
85+
id: 4,
86+
title: 'Title',
87+
body: "The 'scope' is the 'current_user': true",
88+
comments: [
89+
{ id: 2, body: 'Scoped' }
90+
]
91+
}
92+
}.to_json
93+
assert_equal expected_json, @response.body
94+
end
95+
96+
def test_default_scope_admin
97+
get :render_post_by_admin
98+
expected_json = {
99+
post: {
100+
id: 4,
101+
title: 'Title',
102+
body: "The 'scope' is the 'current_user': true",
103+
comments: [
104+
{ id: 1, body: 'Admin' }
105+
]
106+
}
107+
}.to_json
108+
assert_equal expected_json, @response.body
38109
end
39-
attributes :admin?
40110
end
111+
class SerializationScopeTest < ActionController::TestCase
112+
class PostViewContextTestController < PostTestController
113+
serialization_scope :view_context
114+
115+
private
116+
117+
def serializer
118+
PostViewContextSerializer
119+
end
120+
end
121+
tests PostViewContextTestController
41122

42-
class AdminUserTestController < ActionController::Base
43-
protect_from_forgery
123+
def test_defined_serialization_scope
124+
assert_equal :view_context, @controller._serialization_scope
125+
end
44126

45-
serialization_scope :current_admin
46-
before_action { request.format = :json }
127+
def test_defined_serialization_scope_object
128+
assert_equal @controller.view_context.class, @controller.serialization_scope.class
129+
end
47130

48-
def current_admin
49-
User.new(id: 2, name: 'Bob', admin: true)
131+
def test_serialization_scope_non_admin
132+
get :render_post_by_non_admin
133+
expected_json = {
134+
post: {
135+
id: 4,
136+
title: 'Title',
137+
body: "The 'scope' is the 'view_context': true",
138+
comments: [
139+
{ id: 2, body: 'Scoped' }
140+
]
141+
}
142+
}.to_json
143+
assert_equal expected_json, @response.body
50144
end
51145

52-
def render_new_user
53-
render json: User.new(id: 1, name: 'Pete', admin: false), serializer: AdminUserSerializer, adapter: :json_api
146+
def test_serialization_scope_admin
147+
get :render_post_by_admin
148+
expected_json = {
149+
post: {
150+
id: 4,
151+
title: 'Title',
152+
body: "The 'scope' is the 'view_context': true",
153+
comments: [
154+
{ id: 1, body: 'Admin' }
155+
]
156+
}
157+
}.to_json
158+
assert_equal expected_json, @response.body
54159
end
55160
end
161+
# FIXME: Has bugs. See comments below and
162+
# https://github.com/rails-api/active_model_serializers/issues/1509
163+
class NilSerializationScopeTest < ActionController::TestCase
164+
class PostViewContextTestController < ActionController::Base
165+
serialization_scope nil
166+
167+
attr_accessor :current_user
168+
169+
def render_post_with_no_scope
170+
self.current_user = User.new(id: 3, name: 'Pete', admin: false)
171+
render json: new_post, serializer: PostSerializer, adapter: :json
172+
end
56173

57-
tests AdminUserTestController
174+
# TODO: run test when
175+
# global state in Serializer._serializer_instance_methods is fixed
176+
# def render_post_with_passed_in_scope
177+
# self.current_user = User.new(id: 3, name: 'Pete', admin: false)
178+
# render json: new_post, serializer: PostSerializer, adapter: :json, scope: current_user, scope_name: :current_user
179+
# end
180+
181+
private
182+
183+
def new_post
184+
Post.new(id: 4, title: 'Title')
185+
end
186+
end
187+
tests PostViewContextTestController
188+
189+
def test_nil_serialization_scope
190+
assert_nil @controller._serialization_scope
191+
end
192+
193+
def test_nil_serialization_scope_object
194+
assert_nil @controller.serialization_scope
195+
end
196+
197+
# TODO: change to NoMethodError and match 'admin?' when the
198+
# global state in Serializer._serializer_instance_methods is fixed
199+
def test_nil_scope
200+
if Rails.version.start_with?('4.0')
201+
exception_class = NoMethodError
202+
exception_matcher = 'admin?'
203+
else
204+
exception_class = NameError
205+
exception_matcher = /admin|current_user/
206+
end
207+
exception = assert_raises(exception_class) do
208+
get :render_post_with_no_scope
209+
end
210+
assert_match exception_matcher, exception.message
211+
end
58212

59-
def test_override_scope_name_with_controller
60-
get :render_new_user
61-
assert_equal '{"data":{"id":"1","type":"users","attributes":{"admin?":true}}}', @response.body
213+
# TODO: run test when
214+
# global state in Serializer._serializer_instance_methods is fixed
215+
# def test_nil_scope_passed_in_current_user
216+
# get :render_post_with_passed_in_scope
217+
# expected_json = {
218+
# post: {
219+
# id: 4,
220+
# title: 'Title',
221+
# body: "The 'scope' is the 'current_user': true",
222+
# comments: [
223+
# { id: 2, body: 'Scoped' }
224+
# ]
225+
# }
226+
# }.to_json
227+
# assert_equal expected_json, @response.body
228+
# end
62229
end
63230
end

0 commit comments

Comments
 (0)