Search code examples
ruby-on-railsdeviseactive-model-serializers

Overriding serializer when using devise_token_auth and active_model_serializer


I am unable to override the Rails serializer when using devise_token_auth and active_model_serializer for Devise sign_up method.

I would like to customize the returned fields from the Devise sign_up controller when querying my API.

The devise_token_auth gem documentation indicates:

To customize json rendering, implement the following protected controller methods

Registration Controller

...

render_create_success

...

Note: Controller overrides must implement the expected actions of the controllers that they replace.

That is all well and good, but how do I do this?

I've tried generating a UserController serializer like the following:

class UsersController < ApplicationController

  def default_serializer_options
    { serializer: UserSerializer }
  end

  # GET /users
  def index
    @users = User.all

    render json: @users
  end

end

but it's only being used for custom methods such as the index method above: it's not being picked up by devise methods like sign_up

I would appreciate a detailed response since I've looked everywhere but I only get a piece of the puzzle at a time.


Solution

  • Devise sign_up corresponds to devise_token_auth registrations controller and Devise sign_in corresponds to devise_token_auth sessions controller. Therefore when using this gem, customizing Devise sign_in and sign_up methods requires customizing both of these devise_token_auth controllers.

    There are two ways to go about this based on what you need to accomplish.

    Method #1

    If you want to completely customize a method in the controller then follow the documentation for overriding devise_token_auth controller methods here: https://github.com/lynndylanhurley/devise_token_auth#custom-controller-overrides

    This is what I did and it's working fine:

    #config/routes.rb
    
    ...
    
    mount_devise_token_auth_for 'User', at: 'auth', controllers: {
        sessions: 'overrides/sessions',
        registrations: 'overrides/registrations'
      }
    
    ...
    

    This will route all devise_token_auth sessions and registrations to LOCAL versions of the controllers if a method exists in your local controller override. If the method does not exist in your local override, then it will run the method from the gem. You basically have to copy the controllers from the gem into 'app/controllers/overrides' and make any changes to any method you need to customize. Erase the methods from the local copy you are not customizing. You can also add callbacks in this way. If you want to modify the response, customize the the render at the end of the method that will return the response as json via active_model_serializer.

    This is an example of my sessions controller which adds a couple of custom before_actions to add custom functionality:

    #app/controllers/overrides/sessions_controller.rb
    
    module Overrides
    
      class SessionsController < DeviseTokenAuth::SessionsController
    
        skip_before_action :authenticate_user_with_filter
        before_action :set_country_by_ip, :only => [:create]
        before_action :create_facebook_user, :only => [:create]
    
        def create
          # Check
    
          field = (resource_params.keys.map(&:to_sym) & resource_class.authentication_keys).first
          @resource = nil
          if field
            q_value = resource_params[field]
    
            if resource_class.case_insensitive_keys.include?(field)
              q_value.downcase!
            end
    
            #q = "#{field.to_s} = ? AND provider='email'"
            q = "#{field.to_s} = ? AND provider='#{params[:provider]}'"
    
            #if ActiveRecord::Base.connection.adapter_name.downcase.starts_with? 'mysql'
            #  q = "BINARY " + q
            #end
    
            @resource = resource_class.where(q, q_value).first
          end
    
          #sign in will be successful if @resource exists (matching user was found) and is a facebook login OR (email login and password matches)
          if @resource and (params[:provider] == 'facebook' || (valid_params?(field, q_value) and @resource.valid_password?(resource_params[:password]) and (!@resource.respond_to?(:active_for_authentication?) or @resource.active_for_authentication?)))
            # create client id
            @client_id = SecureRandom.urlsafe_base64(nil, false)
            @token     = SecureRandom.urlsafe_base64(nil, false)
    
            @resource.tokens[@client_id] = { token: BCrypt::Password.create(@token), expiry: (Time.now + DeviseTokenAuth.token_lifespan).to_i }
            @resource.save
    
            sign_in(:user, @resource, store: false, bypass: false)
    
            yield @resource if block_given?
    
            #render_create_success
            render json: { data: resource_data(resource_json: @resource.token_validation_response) }
    
    
          elsif @resource and not (!@resource.respond_to?(:active_for_authentication?) or @resource.active_for_authentication?)
            render_create_error_not_confirmed
          else
            render_create_error_bad_credentials
          end
        end
    
        def set_country_by_ip
          if !params['fb_code'].blank?
    
            if !params['user_ip'].blank?
              #checks if IP sent is valid, otherwise raise an error
              raise 'Invalid IP' unless (params['user_ip'] =~ Resolv::IPv4::Regex ? true : false)
              country_code = Custom::FacesLibrary.get_country_by_ip(params['user_ip'])
              country_id = Country.find_by(country_code: country_code)
              if country_id
                params.merge!(country_id: country_id.id, country_name: country_id.name, test: 'Test')
                I18n.locale = country_id.language_code
              else
                params.merge!(country_id: 1, country_name: 'International')
              end
            else
              params.merge!(country_id: 1, country_name: 'International')
            end
    
          end
        end
    
        def create_facebook_user
    
          if !params['fb_code'].blank?
    
            # TODO capture errors for invalid, expired or already used codes to return beter errors in API
            user_info, access_token = Omniauth::Facebook.authenticate(params['fb_code'])
            if user_info['email'].blank?
              Omniauth::Facebook.deauthorize(access_token)
            end
            #if Facebook user does not exist create it
            @user = User.find_by('uid = ? and provider = ?', user_info['id'], 'facebook')
            if !@user
              @graph = Koala::Facebook::API.new(access_token, ENV['FACEBOOK_APP_SECRET'])
              Koala.config.api_version = "v2.6"
              new_user_picture = @graph.get_picture_data(user_info['id'], type: :normal)
              new_user_info = {
                                uid: user_info['id'],
                                provider: 'facebook',
                                email: user_info['email'],
                                name: user_info['name'],
                                first_name: user_info['first_name'],
                                last_name: user_info['last_name'],
                                image: new_user_picture['data']['url'],
                                gender: user_info['gender'],
                                fb_auth_token: access_token,
                                friend_count: user_info['friends']['summary']['total_count'],
                                friends: user_info['friends']['data']
              }
              @user = User.new(new_user_info)
              @user.password = Devise.friendly_token.first(8)
              @user.country_id = params['country_id']
              @user.country_name = params['country_name']
    
              if !@user.save
                render json: @user.errors, status: :unprocessable_entity
              end
            end
            #regardless of user creation, merge facebook parameters for proper sign_in in standard action
    
            params.merge!(provider: 'facebook', email: @user.email)
    
          else
            params.merge!(provider: 'email')
          end
        end
      end
    
    end
    

    Notice the use of params.merge! in the callback to add custom parameters to the main controller methods. This is a nifty trick that unfortunately will be be deprecated in Rails 5.1 as params will no longer inherit from hash.

    Method #2

    If you just want to add functionality to a method in your custom controller, you can get away with subclassing a controller, inheriting from the original controller and passing a block to super as described here:

    https://github.com/lynndylanhurley/devise_token_auth#passing-blocks-to-controllers

    I have done this to the create method in my custom registrations controller.

    Modify the routes as in method #1

    #config/routes.rb
    
    ...
    
    mount_devise_token_auth_for 'User', at: 'auth', controllers: {
        sessions: 'overrides/sessions',
        registrations: 'overrides/registrations'
      }
    
    ...
    

    and customize the create method in the custom controller:

    #app/controllers/overrides/registrations_controller.rb
    
    module Overrides
      class RegistrationsController < DeviseTokenAuth::RegistrationsController
    
        skip_before_action :authenticate_user_with_filter
    
        #will run upon creating a new registration and will set the country_id and locale parameters
        #based on whether or not a user_ip param is sent with the request
        #will default to country_id=1 and locale='en' (International) if it's not sent.
        before_action :set_country_and_locale_by_ip, :only => [:create]
    
        def set_country_and_locale_by_ip
          if !params['user_ip'].blank?
            #checks if IP sent is valid, otherwise raise an error
            raise 'Invalid IP' unless (params['user_ip'] =~ Resolv::IPv4::Regex ? true : false)
            country_code = Custom::FacesLibrary.get_country_by_ip(params['user_ip'])
            #TODO check if there's an internet connection here or inside the library function
            #params.merge!(country_id: 1, country_name: 'International', locale: 'en')
            country_id = Country.find_by(country_code: country_code)
            if country_id
              params.merge!(country_id: country_id.id, locale: country_id.language_code, country_name: country_id.name)
            else
              params.merge!(country_id: 1, country_name: 'International', locale: 'en')
            end
          else
            params.merge!(country_id: 1, country_name: 'International', locale: 'en')
          end
        end
    
        #this will add behaviour to the registrations controller create method
        def create
          super do |resource|
            create_assets(@resource)
          end
        end
    
        def create_assets(user)
          begin
            Asset.create(user_id: user.id, name: "stars", qty: 50)
            Asset.create(user_id: user.id, name: "lives", qty: 5)
            Asset.create(user_id: user.id, name: "trophies", qty: 0)
          end
        end
    
      end
    end