# frozen_string_literal: true require_relative 'errors' require_relative 'transformers/result_transformer' require 'active_support' require 'active_support/cache' module ExperianConsumerView # Top-level wrapper for accessing the ExperianConsumerView API. Once an instance is created with the appropriate # credentials, the +lookup+ method provides the ability to lookup individuals, households, or postcodes in the # ConsumerView API and return all the data your account has access to. # # This class automatically handles logging in to the ConsumerView API, obtaining an authorisation token (which is # valid for approximately 30 minutes), and then looking up the data. The authorisation token is cached so that it's # not necessary to login again for every single lookup request. # # Note that by default the authorisation is cached in-memory using +ActiveSupport::Cache::MemoryStore+. This is # suitable for single-server applications, but is unlikely to be suitable for distributed applications, or those # hosted on cloud infrastructure. A distributed cache, such as +ActiveSupport::Cache::RedisCacheStore+ or # +ActiveSupport::Cache::MemCacheStore+ is recommended for distributed or cloud-hosted applications. # # If an in-memory data-store were used in distributed or cloud-hosted applications, then the multiple servers will be # unaware of each others tokens, and therefore each server would login to the ConsumerView API independently, even if # another server already had a valid token. Logging in to the ConsumerView API multiple times with the same # credentials will revoke prior tokens, meaning other servers will find their cached tokens are invalid the next time # they try a lookup. This will likely lead to a situation where many lookup attempts fail the first time due to the # server in question not having the most up-to-date token. class Client include ExperianConsumerView::Errors CACHE_KEY = 'ExperianConsumerView::Client::CachedToken' attr_writer :result_transformer # @param user_id [String] the username / email used to authorize use of the ConsumerView API # @param password [String] the password used to authorize use of the ConsumerView API # @param client_id [String] your 5-digit Experian client ID # @param asset_id [String] your 6-character Experian asset ID # @param options [Hash] a hash of advanced options for configuring the client # # @option options [ActiveSupport::Cache] :token_cache optional cache to store login tokens. If no cache is provided, # a default in-memory cache is used, however such a cache is not suitable for distributed or cloud environments, # and will likely result in frequently invalidating the Experian ConsumerView authorization token. # @option options [#transform] :result_transformer optional object whose +transform+ method accepts a hash # containing the results returned by the ConsumerView API for a single individual, household or postcode, and # transforms this hash into the desired output. By default, an instance of +ResultTransformer+ is used, which will # transform some common attributes returned by the ConsumerView API into hashes with richer details than returned # by the raw API. # @option options [String] :api_base_url optional base URL to make ConsumerView API calls against. By default, uses # the Experian production ConsumerView server. def initialize(user_id:, password:, client_id:, asset_id:, options: {}) @user_id = user_id @password = password @client_id = client_id @asset_id = asset_id @token_cache = options[:token_cache] || default_token_cache @result_transformer = options[:result_transformer] || default_result_transformer @api = ExperianConsumerView::Api.new(url: options[:api_base_url]) end # Looks up 1 or more search items in the ConsumerView API. # # Note that the demographic / propensity keys returned will only be those which the client & asset have access to. # Refer to the Experian ConsumerView API Documentation for exact details of the keys & possible values. # # @param search_items [Hash] a hash of identifiers to search keys for an individual / household / postcode as # required by the ConsumerView API. Eg. # { "PersonA" => { "email" => "person.a@example.com" }, "Postcode1" => { "postcode" => "SW1A 1AA" } }. # Note that the top-level key is not passed to the ConsumerView API, it is just used for convenience when # returning results. # @param auto_retries [Integer] optional number of times the lookup should be retried if a transient / potentially # recoverable error occurs. Defaults to 1. # # @returns [Hash] a hash of identifiers to the results returned by the ConsumerView API. Eg. # # { # "PersonA" => { "pc_mosaic_uk_7_group":"G", "Match":"P" } , # "Postcode1" => { "pc_mosaic_uk_7_group":"G", "Match":"PC" } # } # def lookup(search_items:, auto_retries: 1) ordered_identifiers = search_items.keys ordered_terms = search_items.values token = auth_token attempts = 0 begin ordered_results = @api.batch_lookup( user_id: @user_id, token: token, client_id: @client_id, asset_id: @asset_id, batched_search_keys: ordered_terms ) rescue ApiBadCredentialsError, ApiServerRefreshingError => e # Bad Credentials can sometimes be caused by race conditions - eg. one thread / server updating the cached # token while another is querying with the old token. Retrying once should avoid the client throwing # unnecessary errors to the calling code. # Experian docs also recommend retrying when a server refresh is in progress, and if that fails, retrying again # in approximately 10 minutes. raise e unless attempts < auto_retries token = auth_token(force_lookup: true) attempts += 1 retry end results_hash(identifiers: ordered_identifiers, results: ordered_results) end private def auth_token(force_lookup: false) # ConsumerView auth tokens last for 30 minutes before expiring & becoming invalid. # After 29 minutes, the cache entry will expire, and the first process to find the expired entry will refresh it, # while allowing other processes to use the existing value for another 10s. This should alleviate race conditions, # but will not eliminate them entirely. Note that in a distributed / cloud / multi-server environment, a shared # cache MUST be used. An in-memory store would mean multiple instances logging to the ConsumerView API, and each # login will change the active token, which other servers will not see, leading to frequent authorisation # failures. @token_cache.fetch( CACHE_KEY, expires_in: 29.minutes, race_condition_ttl: 10.seconds, force: force_lookup ) do @api.get_auth_token(user_id: @user_id, password: @password) end end def results_hash(identifiers:, results:) raise ApiResultSizeMismatchError unless results.size == identifiers.size # Construct a hash of { identifier => result_hash } # Hash[identifiers.zip(results)] results_hash = {} results.each_with_index do |single_result, i| results_hash[identifiers[i]] = @result_transformer.transform(single_result) end results_hash end def default_token_cache ActiveSupport::Cache::MemoryStore.new end def default_result_transformer ExperianConsumerView::Transformers::ResultTransformer.default end end end