From 1817398b579d9ec608b45cd7dd24c7a6271a4806 Mon Sep 17 00:00:00 2001 From: Santiago Pastorino Date: Mon, 14 Oct 2013 17:17:01 -0200 Subject: [PATCH] Add back DESIGN.textile file --- DESIGN.textile | 586 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 586 insertions(+) create mode 100644 DESIGN.textile diff --git a/DESIGN.textile b/DESIGN.textile new file mode 100644 index 00000000..559982e4 --- /dev/null +++ b/DESIGN.textile @@ -0,0 +1,586 @@ +This was the original design document for serializers. It is useful mostly for historical purposes as the public API has changed. + +h2. Rails Serializers + +This guide describes how to use Active Model serializers to build non-trivial JSON services in Rails. By reading this guide, you will learn: + +* When to use the built-in Active Model serialization +* When to use a custom serializer for your models +* How to use serializers to encapsulate authorization concerns +* How to create serializer templates to describe the application-wide structure of your serialized JSON +* How to build resources not backed by a single database table for use with JSON services + +This guide covers an intermediate topic and assumes familiarity with Rails conventions. It is suitable for applications that expose a +JSON API that may return different results based on the authorization status of the user. + +h3. Serialization + +By default, Active Record objects can serialize themselves into JSON by using the `to_json` method. This method takes a series of additional +parameter to control which properties and associations Rails should include in the serialized output. + +When building a web application that uses JavaScript to retrieve JSON data from the server, this mechanism has historically been the primary +way that Rails developers prepared their responses. This works great for simple cases, as the logic for serializing an Active Record object +is neatly encapsulated in Active Record itself. + +However, this solution quickly falls apart in the face of serialization requirements based on authorization. For instance, a web service +may choose to expose additional information about a resource only if the user is entitled to access it. In addition, a JavaScript front-end +may want information that is not neatly described in terms of serializing a single Active Record object, or in a different format than. + +In addition, neither the controller nor the model seems like the correct place for logic that describes how to serialize an model object +*for the current user*. + +Serializers solve these problems by encapsulating serialization in an object designed for this purpose. If the default +to_json+ semantics, +with at most a few configuration options serve your needs, by all means continue to use the built-in +to_json+. If you find yourself doing +hash-driven-development in your controllers, juggling authorization logic and other concerns, serializers are for you! + +h3. The Most Basic Serializer + +A basic serializer is a simple Ruby object named after the model class it is serializing. + +
+class PostSerializer
+  def initialize(post, scope)
+    @post, @scope = post, scope
+  end
+
+  def as_json
+    { post: { title: @post.name, body: @post.body } }
+  end
+end
+
+ +A serializer is initialized with two parameters: the model object it should serialize and an authorization scope. By default, the +authorization scope is the current user (+current_user+) but you can use a different object if you want. The serializer also +implements an +as_json+ method, which returns a Hash that will be sent to the JSON encoder. + +Rails will transparently use your serializer when you use +render :json+ in your controller. + +
+class PostsController < ApplicationController
+  def show
+    @post = Post.find(params[:id])
+    render json: @post
+  end
+end
+
+ +Because +respond_with+ uses +render :json+ under the hood for JSON requests, Rails will automatically use your serializer when +you use +respond_with+ as well. + +h4. +serializable_hash+ + +In general, you will want to implement +serializable_hash+ and +as_json+ to allow serializers to embed associated content +directly. The easiest way to implement these two methods is to have +as_json+ call +serializable_hash+ and insert the root. + +
+class PostSerializer
+  def initialize(post, scope)
+    @post, @scope = post, scope
+  end
+
+  def serializable_hash
+    { title: @post.name, body: @post.body }
+  end
+
+  def as_json
+    { post: serializable_hash }
+  end
+end
+
+ +h4. Authorization + +Let's update our serializer to include the email address of the author of the post, but only if the current user has superuser +access. + +
+class PostSerializer
+  def initialize(post, scope)
+    @post, @scope = post, scope
+  end
+
+  def as_json
+    { post: serializable_hash }
+  end
+
+  def serializable_hash
+    hash = post
+    hash.merge!(super_data) if super?
+    hash
+  end
+
+private
+  def post
+    { title: @post.name, body: @post.body }
+  end
+
+  def super_data
+    { email: @post.email }
+  end
+
+  def super?
+    @scope.superuser?
+  end
+end
+
+ +h4. Testing + +One benefit of encapsulating our objects this way is that it becomes extremely straight-forward to test the serialization +logic in isolation. + +
+require "ostruct"
+
+class PostSerializerTest < ActiveSupport::TestCase
+  # For now, we use a very simple authorization structure. These tests will need
+  # refactoring if we change that.
+  plebe = OpenStruct.new(super?: false)
+  god   = OpenStruct.new(super?: true)
+
+  post  = OpenStruct.new(title: "Welcome to my blog!", body: "Blah blah blah", email: "tenderlove@gmail.com")
+
+  test "a regular user sees just the title and body" do
+    json = PostSerializer.new(post, plebe).to_json
+    hash = JSON.parse(json)
+
+    assert_equal post.title, hash.delete("title")
+    assert_equal post.body, hash.delete("body")
+    assert_empty hash
+  end
+
+  test "a superuser sees the title, body and email" do
+    json = PostSerializer.new(post, god).to_json
+    hash = JSON.parse(json)
+
+    assert_equal post.title, hash.delete("title")
+    assert_equal post.body, hash.delete("body")
+    assert_equal post.email, hash.delete("email")
+    assert_empty hash
+  end
+end
+
+ +It's important to note that serializer objects define a clear interface specifically for serializing an existing object. +In this case, the serializer expects to receive a post object with +name+, +body+ and +email+ attributes and an authorization +scope with a +super?+ method. + +By defining a clear interface, it's must easier to ensure that your authorization logic is behaving correctly. In this case, +the serializer doesn't need to concern itself with how the authorization scope decides whether to set the +super?+ flag, just +whether it is set. In general, you should document these requirements in your serializer files and programatically via tests. +The documentation library +YARD+ provides excellent tools for describing this kind of requirement: + +
+class PostSerializer
+  # @param [~body, ~title, ~email] post the post to serialize
+  # @param [~super] scope the authorization scope for this serializer
+  def initialize(post, scope)
+    @post, @scope = post, scope
+  end
+
+  # ...
+end
+
+ +h3. Attribute Sugar + +To simplify this process for a number of common cases, Rails provides a default superclass named +ActiveModel::Serializer+ +that you can use to implement your serializers. + +For example, you will sometimes want to simply include a number of existing attributes from the source model into the outputted +JSON. In the above example, the +title+ and +body+ attributes were always included in the JSON. Let's see how to use ++ActiveModel::Serializer+ to simplify our post serializer. + +
+class PostSerializer < ActiveModel::Serializer
+  attributes :title, :body
+
+  def serializable_hash
+    hash = attributes
+    hash.merge!(super_data) if super?
+    hash
+  end
+
+private
+  def super_data
+    { email: @post.email }
+  end
+
+  def super?
+    @scope.superuser?
+  end
+end
+
+ +First, we specified the list of included attributes at the top of the class. This will create an instance method called ++attributes+ that extracts those attributes from the post model. + +NOTE: Internally, +ActiveModel::Serializer+ uses +read_attribute_for_serialization+, which defaults to +read_attribute+, which defaults to +send+. So if you're rolling your own models for use with the serializer, you can use simple Ruby accessors for your attributes if you like. + +Next, we use the attributes method in our +serializable_hash+ method, which allowed us to eliminate the +post+ method we hand-rolled +earlier. We could also eliminate the +as_json+ method, as +ActiveModel::Serializer+ provides a default +as_json+ method for +us that calls our +serializable_hash+ method and inserts a root. But we can go a step further! + +
+class PostSerializer < ActiveModel::Serializer
+  attributes :title, :body
+
+private
+  def attributes
+    hash = super
+    hash.merge!(email: post.email) if super?
+    hash
+  end
+
+  def super?
+    @scope.superuser?
+  end
+end
+
+ +The superclass provides a default +initialize+ method as well as a default +serializable_hash+ method, which uses ++attributes+. We can call +super+ to get the hash based on the attributes we declared, and then add in any additional +attributes we want to use. + +NOTE: +ActiveModel::Serializer+ will create an accessor matching the name of the current class for the resource you pass in. In this case, because we have defined a PostSerializer, we can access the resource with the +post+ accessor. + +h3. Associations + +In most JSON APIs, you will want to include associated objects with your serialized object. In this case, let's include +the comments with the current post. + +
+class PostSerializer < ActiveModel::Serializer
+  attributes :title, :body
+  has_many :comments
+
+private
+  def attributes
+    hash = super
+    hash.merge!(email: post.email) if super?
+    hash
+  end
+
+  def super?
+    @scope.superuser?
+  end
+end
+
+ +The default +serializable_hash+ method will include the comments as embedded objects inside the post. + +
+{
+  post: {
+    title: "Hello Blog!",
+    body: "This is my first post. Isn't it fabulous!",
+    comments: [
+      {
+        title: "Awesome",
+        body: "Your first post is great"
+      }
+    ]
+  }
+}
+
+ +Rails uses the same logic to generate embedded serializations as it does when you use +render :json+. In this case, +because you didn't define a +CommentSerializer+, Rails used the default +as_json+ on your comment object. + +If you define a serializer, Rails will automatically instantiate it with the existing authorization scope. + +
+class CommentSerializer
+  def initialize(comment, scope)
+    @comment, @scope = comment, scope
+  end
+
+  def serializable_hash
+    { title: @comment.title }
+  end
+
+  def as_json
+    { comment: serializable_hash }
+  end
+end
+
+ +If we define the above comment serializer, the outputted JSON will change to: + +
+{
+  post: {
+    title: "Hello Blog!",
+    body: "This is my first post. Isn't it fabulous!",
+    comments: [{ title: "Awesome" }]
+  }
+}
+
+ +Let's imagine that our comment system allows an administrator to kill a comment, and we only want to allow +users to see the comments they're entitled to see. By default, +has_many :comments+ will simply use the ++comments+ accessor on the post object. We can override the +comments+ accessor to limit the comments used +to just the comments we want to allow for the current user. + +
+class PostSerializer < ActiveModel::Serializer
+  attributes :title. :body
+  has_many :comments
+
+private
+  def attributes
+    hash = super
+    hash.merge!(email: post.email) if super?
+    hash
+  end
+
+  def comments
+    post.comments_for(scope)
+  end
+
+  def super?
+    @scope.superuser?
+  end
+end
+
+ ++ActiveModel::Serializer+ will still embed the comments, but this time it will use just the comments +for the current user. + +NOTE: The logic for deciding which comments a user should see still belongs in the model layer. In general, you should encapsulate concerns that require making direct Active Record queries in scopes or public methods on your models. + +h4. Modifying Associations + +You can also rename associations if required. Say for example you have an association that +makes sense to be named one thing in your code, but another when data is serialized. +You can use the option to specify a different name for an association. +Here is an example: + +
+class UserSerializer < ActiveModel::Serializer
+  has_many :followed_posts, key: :posts
+  has_one :owned_account, key: :account
+end
+
+ +Using the :key without a :serializer option will use implicit detection +to determine a serializer. In this example, you'd have to define two classes: PostSerializer +and AccountSerializer. You can also add the :serializer option +to set it explicitly: + +
+class UserSerializer < ActiveModel::Serializer
+  has_many :followed_posts, key: :posts, serializer: CustomPostSerializer
+  has_one :owne_account, key: :account, serializer: PrivateAccountSerializer
+end
+
+ +h3. Customizing Associations + +Not all front-ends expect embedded documents in the same form. In these cases, you can override the +default +serializable_hash+, and use conveniences provided by +ActiveModel::Serializer+ to avoid having to +build up the hash manually. + +For example, let's say our front-end expects the posts and comments in the following format: + +
+{
+  post: {
+    id: 1
+    title: "Hello Blog!",
+    body: "This is my first post. Isn't it fabulous!",
+    comments: [1,2]
+  },
+  comments: [
+    {
+      id: 1
+      title: "Awesome",
+      body: "Your first post is great"
+    },
+    {
+      id: 2
+      title: "Not so awesome",
+      body: "Why is it so short!"
+    }
+  ]
+}
+
+ +We could achieve this with a custom +as_json+ method. We will also need to define a serializer for comments. + +
+class CommentSerializer < ActiveModel::Serializer
+  attributes :id, :title, :body
+
+  # define any logic for dealing with authorization-based attributes here
+end
+
+class PostSerializer < ActiveModel::Serializer
+  attributes :title, :body
+  has_many :comments
+
+  def as_json
+    { post: serializable_hash }.merge!(associations)
+  end
+
+  def serializable_hash
+    post_hash = attributes
+    post_hash.merge!(association_ids)
+    post_hash
+  end
+
+private
+  def attributes
+    hash = super
+    hash.merge!(email: post.email) if super?
+    hash
+  end
+
+  def comments
+    post.comments_for(scope)
+  end
+
+  def super?
+    @scope.superuser?
+  end
+end
+
+ +Here, we used two convenience methods: +associations+ and +association_ids+. The first, ++associations+, creates a hash of all of the define associations, using their defined +serializers. The second, +association_ids+, generates a hash whose key is the association +name and whose value is an Array of the association's keys. + +The +association_ids+ helper will use the overridden version of the association, so in +this case, +association_ids+ will only include the ids of the comments provided by the ++comments+ method. + + +h3. Special Association Serializers + +So far, associations defined in serializers use either the +as_json+ method on the model +or the defined serializer for the association type. Sometimes, you may want to serialize +associated models differently when they are requested as part of another resource than +when they are requested on their own. + +For instance, we might want to provide the full comment when it is requested directly, +but only its title when requested as part of the post. To achieve this, you can define +a serializer for associated objects nested inside the main serializer. + +
+class PostSerializer < ActiveModel::Serializer
+  class CommentSerializer < ActiveModel::Serializer
+    attributes :id, :title
+  end
+
+  # same as before
+  # ...
+end
+
+ +In other words, if a +PostSerializer+ is trying to serialize comments, it will first +look for +PostSerializer::CommentSerializer+ before falling back to +CommentSerializer+ +and finally +comment.as_json+. + +h3. Overriding the Defaults + +h4. Authorization Scope + +By default, the authorization scope for serializers is +:current_user+. This means +that when you call +render json: @post+, the controller will automatically call +its +current_user+ method and pass that along to the serializer's initializer. + +If you want to change that behavior, simply use the +serialization_scope+ class +method. + +
+class PostsController < ApplicationController
+  serialization_scope :current_app
+end
+
+ +You can also implement an instance method called (no surprise) +serialization_scope+, +which allows you to define a dynamic authorization scope based on the current request. + +WARNING: If you use different objects as authorization scopes, make sure that they all implement whatever interface you use in your serializers to control what the outputted JSON looks like. + +h3. Using Serializers Outside of a Request + +The serialization API encapsulates the concern of generating a JSON representation of +a particular model for a particular user. As a result, you should be able to easily use +serializers, whether you define them yourself or whether you use +ActiveModel::Serializer+ +outside a request. + +For instance, if you want to generate the JSON representation of a post for a user outside +of a request: + +
+user = get_user # some logic to get the user in question
+PostSerializer.new(post, user).to_json # reliably generate JSON output
+
+ +If you want to generate JSON for an anonymous user, you should be able to use whatever +technique you use in your application to generate anonymous users outside of a request. +Typically, that means creating a new user and not saving it to the database: + +
+user = User.new # create a new anonymous user
+PostSerializer.new(post, user).to_json
+
+ +In general, the better you encapsulate your authorization logic, the more easily you +will be able to use the serializer outside of the context of a request. For instance, +if you use an authorization library like Cancan, which uses a uniform +user.can?(action, model)+, +the authorization interface can very easily be replaced by a plain Ruby object for +testing or usage outside the context of a request. + +h3. Collections + +So far, we've talked about serializing individual model objects. By default, Rails +will serialize collections, including when using the +associations+ helper, by +looping over each element of the collection, calling +serializable_hash+ on the element, +and then grouping them by their type (using the plural version of their class name +as the root). + +For example, an Array of post objects would serialize as: + +
+{
+  posts: [
+    {
+      title: "FIRST POST!",
+      body: "It's my first pooooost"
+    },
+    { title: "Second post!",
+      body: "Zomg I made it to my second post"
+    }
+  ]
+}
+
+ +If you want to change the behavior of serialized Arrays, you need to create +a custom Array serializer. + +
+class ArraySerializer < ActiveModel::ArraySerializer
+  def serializable_array
+    serializers.map do |serializer|
+      serializer.serializable_hash
+    end
+  end
+
+  def as_json
+    hash = { root => serializable_array }
+    hash.merge!(associations)
+    hash
+  end
+end
+
+ +When generating embedded associations using the +associations+ helper inside a +regular serializer, it will create a new ArraySerializer with the +associated content and call its +serializable_array+ method. In this case, those +embedded associations will not recursively include associations. + +When generating an Array using +render json: posts+, the controller will invoke +the +as_json+ method, which will include its associations and its root.