Stuck with refactoring classes on ruby on rails 5

Viewed 57

I have a class with different methods but on these methods i need to do a check on the access token before doing some calls

class SomeClass
    def initialize
        @client = SomeModule::Client.new
    end
    def get_intervention_chart(subId:, projectId:, interventionId:)
        @client.check_presence_of_access_token()
        SomeModule::Service::Project.new(@client).get_intervention_chart(subId: subId, projectId: projectId, interventionId: interventionId)
    end
    
    def get_intervention_documents(subId:, projectId:, interventionId:)
        @client.check_presence_of_access_token()
        SomeModule::Service::Project.new(@client).get_intervention_documents(subId: subId, projectId: projectId, interventionId: interventionId)
    end
end

As you can see, i call the method "check_presence_of_access_token" which check if the access token is there and if it's good to go, if not it gets another one and stock it in a file.

There is my Client class :

class Client
        class Configuration
            attr_accessor :access_token 
            attr_reader :access_token_path, :endpoint, :client_id, :client_secret, :subId
    
            def initialize
                @access_token = ''
                @access_token_path = Rails.root.join('tmp/connection_response.json')
                @endpoint = ENV['TOKEN_ENDPOINT']
                @client_id    = ENV['CLIENT_ID']
                @client_secret = ENV['CLIENT_SECRET']
                @subId = "SOME_ID"
            end
        end
        def initialize
            @configuration = Configuration.new
        end

        # Check if the file 'connection_response' is present and if the token provided is still valid (only 30 min)
        def check_presence_of_access_token          
            if File.exist?(self.configuration.access_token_path.to_s)
                access_token = JSON.parse(File.read(self.configuration.access_token_path.to_s))["access_token"]
                if access_token
                    jwt_decoded = JWT.decode(access_token, nil, false).first
                    # we want to check if the token will be valid in 60s to avoid making calls with expired token
                    if jwt_decoded["exp"] > (DateTime.now.to_i + 60)
                        self.configuration.access_token = access_token
                        return
                    end
                end
            end
            get_token()
        end
        def get_token
            config_hash = Hash.new {}
            config_hash["grant_type"] = "client_credentials"
            config_hash["client_id"] = self.configuration.client_id
            config_hash["client_secret"] = self.configuration.client_secret

            response = RestClient.post(self.configuration.endpoint, config_hash, headers: { 'Content-Type' => 'application/x-www-form-urlencoded' })
            response_body = JSON.parse(response.body)
            self.configuration.access_token = response_body["access_token"]

            stock_jwt(response_body.to_json)
        end

        def stock_jwt(response_body)
            File.open(self.configuration.access_token_path.to_s, 'w+') do |file|
                file.write(response_body)
            end
        end
end

I don't know how to refactor this, can you help me ?

1 Answers

There is a general principle for OO languages to be "lazy" and defer decisions to as late as possible. We'll use that principle to refactor your code to make the token automatically refresh itself on expiration, and/or fetch itself if it has not already done so.

There is also a group of principles known collectively as SOLID. We'll use those principles also.

The final principle I'll reference is "smaller is better", with respect to methods and modules. Combine that with the "S" (Single Responsibility) from SOLID, and you'll see the refactor contains a lot more, but much smaller methods.

Principles aside, it's not clear from the problem statement that the token is short-lived (lasts only for a "session") or long-lived (eg: lasts longer than a single session).

If the token is long-lived, then storing it into a file is okay, if the only processes using it are on the same system.

If multiple web servers will be using this code, then unless each one is to have its own token, the token should be shared across all of the systems using a data store of some kind, like Redis, MySQL, or Postgres.

Since your code is using a file, we'll assume that multiple processes on the same system might be sharing the token.

Given these principles and assumptions, here is a refactoring of your code, using a file to store the token, using "lazy" deferred, modular logic.

class Client
  class Configuration
    attr_accessor :access_token
    attr_reader :access_token_path, :endpoint, :client_id, :client_secret, :subId


    def initialize
      @access_token      = nil
      @access_token_path = Rails.root.join('tmp/connection_response.json')
      @endpoint          = ENV['TOKEN_ENDPOINT']
      @client_id         = ENV['CLIENT_ID']
      @client_secret     = ENV['CLIENT_SECRET']
      @sub_id            = "SOME_ID"
    end
  end
  
  attr_accessor :configuration
  delegate :access_token, :access_token_path, :endpoint, :client_id, :client_secret, :sub_id,
           to: :configuration

  TOKEN_EXPIRATION_TIME = 60 # seconds

  
  def initialize
    @configuration = Configuration.new
  end

  # returns a token, possibly refreshed or fetched for the first time
  def token
    unexpired_token || new_token
  end

  # returns an expired token
  def unexpired_token
    access_token unless token_expired?
  end

  def access_token
    # cache the result until it expires
    @access_token ||= JSON.parse(read_token)&.fetch("access_token", nil)
  end

  def read_token
    File.read(token_path)
  end

  def token_path
    access_token_path&.to_s || raise("No access token path configured!")
  end

  def token_expired?
    # the token expiration time should be in the *future*
    token_expiration_time.nil? || 
      token_expiration_time < (DateTime.now.to_i + TOKEN_EXPIRATION_TIME)
  end

  def token_expiration_time
    # cache the token expiration time; it won't change
    @token_expiration_time ||= decoded_token&.fetch("exp", nil)
  end

  def decoded_token
    @decoded_token ||= JWT.decode(access_token, nil, false).first
  end

  def new_token
    @access_token = store_token(new_access_token)
  end

  def store_token(token)
    @token_expiration_time = nil # reset cached values
    @decoded_token = nil
    IO.write(token_path, token)
    token
  end

  def new_access_token
    parse_token(request_token_response)
  end

  def parse_token(response)
    JSON.parse(response.body)&.fetch("access_token", nil)
  end

  def request_token_response
    RestClient.post(
      endpoint,
      credentials_hash,
      headers: { 'Content-Type' => 'application/x-www-form-urlencoded' }
    )
  end

  def credentials_hash
    {
      grant_type:    "client_credentials",
      client_id:     client_id || raise("Client ID not configured!"),
      client_secret: client_secret || raise("Client secret not configured!")
    }
  end
end

So, how does this work?

Assuming that the client code uses the token, just evaluating the token method will cause the unexpired token to be retrieved or refreshed (if it existed), or a new one fetched (if it hadn't existed).

So, there's no need to "check" for the token before using the @client connection. When the @client connection uses the token, the right stuff will happen.

Values that do not change are cached, to avoid having to redo the logic that produced them. Eg: There is no need to decode the JWT string repeatedly.

When the current token time expires, the token_expired? will turn true, causing its caller to return nil, causing that caller to fetch a new_token, which then gets stored.

The great advantage to these small methods are that each can be tested independently, since they each have a very simple purpose.

Good luck with your project!

Related