corthmann/eeny-meeny

View on GitHub
lib/eeny-meeny/middleware.rb

Summary

Maintainability
A
2 hrs
Test Coverage
require 'rack'
require 'time'
require 'active_support/time'
require 'eeny-meeny/models/experiment'
require 'eeny-meeny/models/encryptor'
require 'eeny-meeny/models/cookie'

module EenyMeeny
  class Middleware

    # Headers
    HTTP_COOKIE    = 'HTTP_COOKIE'.freeze
    REQUEST_METHOD = 'REQUEST_METHOD'.freeze
    QUERY_STRING   = 'QUERY_STRING'.freeze

    def initialize(app)
      @app = app
      @experiments = EenyMeeny::Experiment.find_all
      @cookie_config = EenyMeeny.config.cookies
    end

    def call(env)
      cookies          = Rack::Utils.parse_query(env[HTTP_COOKIE],';,')  { |s| Rack::Utils.unescape(s) rescue s }
      query_parameters = query_hash(env)
      now              = Time.zone.now
      new_cookies      = {}
      delete_cookies   = find_deprecated_cookies(cookies, now)
      # Prepare for experiments.
      @experiments.each do |experiment|
        # Skip inactive experiments
        next unless experiment.active?(now)
        env, new_cookies = prepare_experiment(env, cookies, new_cookies, query_parameters, experiment)
      end
      # Prepare smoke tests
      env, new_cookies = prepare_smoke_test(env, new_cookies, query_parameters)
      # Delegate to app
      status, headers, body = @app.call(env)
      response = Rack::Response.new(body, status, headers)
      # Add new cookies to 'Set-Cookie' header
      new_cookies.each do |key, value|
        response.set_cookie(key,value.to_h)
      end
      delete_cookies.each do |key, value|
        response.delete_cookie(key, value: value, path: @cookie_config[:path], same_site: @cookie_config[:same_site])
      end
      response.finish
    end

    private

    def find_deprecated_cookies(cookies, now)
      deprecated_cookies = {}
      cookies.each do |cookie_name, value|
        # Skip any cookie that does not have the 'eeny_meeny_' prefix
        next unless cookie_name.to_s.start_with?(EenyMeeny::Cookie::EXPERIMENT_PREFIX)
        # Mark cookies that does not match any existing active experiment as 'deprecated'.
        experiment = EenyMeeny::Experiment.find_by_cookie_name(cookie_name)
        next if experiment && experiment.active?(now)
        deprecated_cookies[cookie_name] = value
      end
      deprecated_cookies
    end

    def prepare_experiment(env, cookies, new_cookies, query_parameters, experiment)
      # Trigger experiment through query parameters
      cookie_name = EenyMeeny::Cookie.cookie_name(experiment)
      has_experiment_trigger = EenyMeeny.config.query_parameters[:experiment] && query_parameters.key?(cookie_name)
      # Skip experiments that already have a cookie
      return env, new_cookies unless has_experiment_trigger || !cookies.key?(cookie_name)
      cookie = if has_experiment_trigger
                 # Trigger experiment variation through query parameter.
                 EenyMeeny::Cookie.create_for_experiment_variation(experiment, query_parameters[cookie_name].to_sym, @cookie_config)
               else
                 EenyMeeny::Cookie.create_for_experiment(experiment, @cookie_config)
               end
      # Set HTTP_COOKIE header to enable experiment on first pageview
      env = add_or_replace_http_cookie(env, cookie)
      new_cookies[cookie.name] = cookie
      return env, new_cookies
    end

    def prepare_smoke_test(env, new_cookies, query_parameters)
      # Skip if the EenyMeeny 'smoke test' query parameters configuration is disabled.
      return env, new_cookies unless EenyMeeny.config.query_parameters[:smoke_test]
      # Skip if no valid 'smoke_test_id' query parameter present
      return env, new_cookies unless query_parameters.key?('smoke_test_id') && (query_parameters['smoke_test_id'] =~ /\A[A-Za-z_]+\z/)
      # Set HTTP_COOKIE header to enable smoke test on first pageview
      cookie = EenyMeeny::Cookie.create_for_smoke_test(query_parameters['smoke_test_id'], **@cookie_config)
      env = add_or_replace_http_cookie(env, cookie)
      new_cookies[cookie.name] = cookie
      return env, new_cookies
    end

    def query_hash(env)
      # Query Params are only relevant if EenyMeeny.config have them enabled.
      return {} unless EenyMeeny.config.query_parameters[:experiment] || EenyMeeny.config.query_parameters[:smoke_test]
      # Query Params are only relevant to HTTP GET requests.
      return {} unless env[REQUEST_METHOD] == 'GET'
      Rack::Utils.parse_query(env[QUERY_STRING], '&;')
    end

    def add_or_replace_http_cookie(env, cookie)
      cookie_name_escaped = Rack::Utils.escape(cookie.name)
      cookie_string = "#{cookie_name_escaped}=#{Rack::Utils.escape(cookie.value)}"
      env[HTTP_COOKIE] = '' if env[HTTP_COOKIE].nil?
      return env if env[HTTP_COOKIE].sub!(/#{Regexp.escape(cookie_name_escaped)}=[^;]+/, cookie_string)
      env[HTTP_COOKIE] += '; ' unless env[HTTP_COOKIE].empty?
      env[HTTP_COOKIE] += cookie_string
      env
    end
  end
end