experian_consumer_view 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'bundler/setup'
5
+ require 'experian_consumer_view'
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ # (If you use this, don't forget to add pry to your Gemfile!)
11
+ # require "pry"
12
+ # Pry.start
13
+
14
+ require 'irb'
15
+ IRB.start(__FILE__)
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/experian_consumer_view/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'experian_consumer_view'
7
+ spec.version = ExperianConsumerView::VERSION
8
+ spec.authors = ['Andrew Sibley']
9
+ spec.email = ['andrew.s@38degrees.org.uk']
10
+ spec.license = 'MIT'
11
+ spec.homepage = 'https://github.com/38degrees/experian_consumer_view'
12
+ spec.summary = "Ruby wrapper for Experian's ConsumerView API."
13
+ spec.description = "
14
+ Experian's ConsumerView API is a commercially licensed API which allows you
15
+ to obtain various demographic data on UK consumers at the postcode, household,
16
+ and individual level. This gem provides a simple Ruby wrapper to use the API.
17
+ "
18
+
19
+ spec.required_ruby_version = Gem::Requirement.new('>= 2.3.0')
20
+
21
+ # spec.metadata['allowed_push_host'] = "TODO: Set to 'http://mygemserver.com'"
22
+ # spec.metadata["homepage_uri"] = spec.homepage
23
+ # spec.metadata["source_code_uri"] = "TODO: Put your gem's public repo URL here."
24
+ # spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here."
25
+
26
+ # Specify which files should be added to the gem when it is released.
27
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
28
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
29
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
30
+ end
31
+ spec.bindir = 'exe'
32
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
33
+ spec.require_paths = ['lib']
34
+
35
+ spec.add_dependency 'activesupport', '>= 5.2'
36
+ spec.add_dependency 'faraday', '~> 1.0'
37
+
38
+ spec.add_development_dependency 'bundler', '~> 2.1'
39
+ spec.add_development_dependency 'codecov', '>= 0.2'
40
+ spec.add_development_dependency 'pry', '>= 0.12'
41
+ spec.add_development_dependency 'rake', '>= 12.0'
42
+ spec.add_development_dependency 'rspec', '~> 3.0'
43
+ spec.add_development_dependency 'rubocop', '~> 0.91.0'
44
+ spec.add_development_dependency 'webmock', '~> 3.9'
45
+ spec.add_development_dependency 'yard', '~> 0.9.25'
46
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ Dir[File.join(File.dirname(__FILE__), 'experian_consumer_view', '**', '*.rb')].sort.each { |file| require file }
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'errors'
4
+
5
+ require 'faraday'
6
+ require 'json'
7
+
8
+ module ExperianConsumerView
9
+ # Low-level class for accessing the Experian ConsumerView API. It is not recommended to use this class directly.
10
+ # The +ExperianConsumerView::Client+ class is designed to be directly used by applications.
11
+ #
12
+ # This class provides low-level access to make specific HTTP calls to the ConsumerView API, such as logging in to get
13
+ # an authorisation token, and performing lookups of an individual / household / postcode.
14
+ class Api
15
+ include ExperianConsumerView::Errors
16
+
17
+ PRODUCTION_URL = 'https://neartime.experian.co.uk'
18
+ STAGING_URL = 'https://stg.neartime.experian.co.uk'
19
+
20
+ LOGIN_PATH = '/overture/login'
21
+ SINGLE_LOOKUP_PATH = '/overture/lookup'
22
+ BATCH_LOOKUP_PATH = '/overture/batch'
23
+
24
+ # @param url [String] optional base URL for the API wrapper to connect to. Defaults to the Experian ConsumerView
25
+ # production server.
26
+ def initialize(url: nil)
27
+ @httpclient = Faraday.new(
28
+ url: url || PRODUCTION_URL,
29
+ headers: { 'Content-Type' => 'application/json', 'Accept' => 'application/json' }
30
+ )
31
+ end
32
+
33
+ # Logs in to the Experian ConsumerView API, and gets an authorization token.
34
+ #
35
+ # @param user_id [String] the username / email used to authorize use of the ConsumerView API
36
+ # @param password [String] the password used to authorize use of the ConsumerView API
37
+ def get_auth_token(user_id:, password:)
38
+ query_params = { 'userid' => user_id, 'password' => password }
39
+
40
+ result = @httpclient.post(LOGIN_PATH, query_params.to_json)
41
+ check_http_result_status(result)
42
+
43
+ JSON.parse(result.body)['token']
44
+ end
45
+
46
+ # Looks up demographic data for a single individual / household / postcode.
47
+ #
48
+ # Note that the demographic / propensity keys returned will only be those which the given client & asset have access
49
+ # to. Refer to the Experian ConsumerView API Documentation for exact details of the keys & possible values.
50
+ #
51
+ # @param user_id [String] the username / email used to authorize use of the ConsumerView API
52
+ # @param token [String] the time-limited authorization token provided when logging into the API
53
+ # @param client_id [String] your 5-digit Experian client ID
54
+ # @param asset_id [String] your 6-character Experian asset ID
55
+ # @param search_keys [Hash] hash containing the keys required to look up an individual / household / postcode.
56
+ # Refer to the Experian ConsumerView API Documentation for exact details on the required keys.
57
+ #
58
+ # @return [Hash] a hash containing a key/value pair for each demographic / propensity for the individual / household
59
+ # / postcode which was successfully looked up. Returns an empty hash if the lookup does not find any matches.
60
+ def single_lookup(user_id:, token:, client_id:, asset_id:, search_keys:)
61
+ # TODO: Delete this if looking up a single item via the batch method isn't any slower - no point supporting both!
62
+
63
+ query_params = {
64
+ 'ssoId' => user_id,
65
+ 'token' => token,
66
+ 'clientId' => client_id,
67
+ 'assetId' => asset_id
68
+ }
69
+ query_params.merge!(search_keys)
70
+
71
+ result = @httpclient.post(SINGLE_LOOKUP_PATH, query_params.to_json)
72
+ check_http_result_status(result)
73
+
74
+ JSON.parse(result.body)
75
+ end
76
+
77
+ # Looks up demographic data for a batch of individuals / households / postcodes.
78
+ #
79
+ # Note that the demographic / propensity keys returned will only be those which the given client & asset have access
80
+ # to. Refer to the Experian ConsumerView API Documentation for exact details of the keys & possible values.
81
+ #
82
+ # @param user_id [String] the username / email used to authorize use of the ConsumerView API
83
+ # @param token [String] the time-limited authorization token provided when logging into the API
84
+ # @param client_id [String] your 5-digit Experian client ID
85
+ # @param asset_id [String] your 6-character Experian asset ID
86
+ # @param search_keys [Array<Hash>] an array of hashes, each hash containing the keys required to look up an
87
+ # individual / household / postcode. Refer to the Experian ConsumerView API Documentation for exact details on the
88
+ # required keys.
89
+ #
90
+ # @return [Array<Hash>] an array of hashes, each hash containing a key/value pair for each demographic / propensity
91
+ # for the individual / household / postcode which was successfully looked up. Returns an empty hash for any items
92
+ # in the batch where no matches were found. The order of the results array is the same as the order of the
93
+ # supplied search array - ie. element 0 of the results array contains the hash of demographic data for the
94
+ # individual / household / postcode supplied in position 0 of the batch of search keys.
95
+ def batch_lookup(user_id:, token:, client_id:, asset_id:, batched_search_keys:)
96
+ raise ApiBatchTooBigError if batched_search_keys.length > ExperianConsumerView::MAX_LOOKUP_BATCH_SIZE
97
+
98
+ query_params = {
99
+ 'ssoId' => user_id,
100
+ 'token' => token,
101
+ 'clientId' => client_id,
102
+ 'assetId' => asset_id,
103
+ 'batch' => batched_search_keys
104
+ }
105
+
106
+ result = @httpclient.post(BATCH_LOOKUP_PATH, query_params.to_json)
107
+ check_http_result_status(result)
108
+
109
+ JSON.parse(result.body)
110
+ end
111
+
112
+ private
113
+
114
+ # Helper to check the result, and throw an appropriate error if something went wrong
115
+ def check_http_result_status(result)
116
+ return if result.status == 200
117
+
118
+ # An error occurred - attempt to extract the response string from the body if we can
119
+ response = get_response(result)
120
+
121
+ case result.status
122
+ when 401
123
+ raise ApiBadCredentialsError.new(result.status, response)
124
+ when 404
125
+ raise ApiEndpointNotFoundError.new(result.status, response)
126
+ when 417
127
+ raise ApiIncorrectJsonError.new(result.status, response)
128
+ when 500
129
+ raise ApiServerError.new(result.status, response)
130
+ when 503
131
+ raise ApiServerRefreshingError(result.status, response) if response == 'Internal refresh in progress'
132
+
133
+ raise ApiServerError.new(result.status, response)
134
+ when 515
135
+ raise ApiHttpVersionNotSupportedError.new(result.status, response)
136
+ else
137
+ raise ApiUnhandledHttpError.new(result.status, response)
138
+ end
139
+ end
140
+
141
+ def get_response(result)
142
+ # TODO: Temp debugging for convenience
143
+ # puts result.body.class.to_s
144
+ # puts result.body.to_s
145
+
146
+ # TODO: Is this complex handling necessary? Check if all types of error are consistent in the body they return...
147
+ if result.body&.is_a?(Hash)
148
+ result.body['response']
149
+ elsif result.body&.is_a?(String)
150
+ JSON.parse(result.body)['response']
151
+ else
152
+ ''
153
+ end
154
+ rescue JSON::ParserError
155
+ ''
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'errors'
4
+ require_relative 'transformers/result_transformer'
5
+
6
+ require 'active_support'
7
+ require 'active_support/cache'
8
+
9
+ module ExperianConsumerView
10
+ # Top-level wrapper for accessing the ExperianConsumerView API. Once an instance is created with the appropriate
11
+ # credentials, the +lookup+ method provides the ability to lookup individuals, households, or postcodes in the
12
+ # ConsumerView API and return all the data your account has access to.
13
+ #
14
+ # This class automatically handles logging in to the ConsumerView API, obtaining an authorisation token (which is
15
+ # valid for approximately 30 minutes), and then looking up the data. The authorisation token is cached so that it's
16
+ # not necessary to login again for every single lookup request.
17
+ #
18
+ # Note that by default the authorisation is cached in-memory using +ActiveSupport::Cache::MemoryStore+. This is
19
+ # suitable for single-server applications, but is unlikely to be suitable for distributed applications, or those
20
+ # hosted on cloud infrastructure. A distributed cache, such as +ActiveSupport::Cache::RedisCacheStore+ or
21
+ # +ActiveSupport::Cache::MemCacheStore+ is recommended for distributed or cloud-hosted applications.
22
+ #
23
+ # If an in-memory data-store were used in distributed or cloud-hosted applications, then the multiple servers will be
24
+ # unaware of each others tokens, and therefore each server would login to the ConsumerView API independently, even if
25
+ # another server already had a valid token. Logging in to the ConsumerView API multiple times with the same
26
+ # credentials will revoke prior tokens, meaning other servers will find their cached tokens are invalid the next time
27
+ # they try a lookup. This will likely lead to a situation where many lookup attempts fail the first time due to the
28
+ # server in question not having the most up-to-date token.
29
+ class Client
30
+ include ExperianConsumerView::Errors
31
+
32
+ CACHE_KEY = 'ExperianConsumerView::Client::CachedToken'
33
+
34
+ attr_writer :result_transformer
35
+
36
+ # @param user_id [String] the username / email used to authorize use of the ConsumerView API
37
+ # @param password [String] the password used to authorize use of the ConsumerView API
38
+ # @param client_id [String] your 5-digit Experian client ID
39
+ # @param asset_id [String] your 6-character Experian asset ID
40
+ # @param options [Hash] a hash of advanced options for configuring the client
41
+ #
42
+ # @option options [ActiveSupport::Cache] :token_cache optional cache to store login tokens. If no cache is provided,
43
+ # a default in-memory cache is used, however such a cache is not suitable for distributed or cloud environments,
44
+ # and will likely result in frequently invalidating the Experian ConsumerView authorization token.
45
+ # @option options [#transform] :result_transformer optional object whose +transform+ method accepts a hash
46
+ # containing the results returned by the ConsumerView API for a single individual, household or postcode, and
47
+ # transforms this hash into the desired output. By default, an instance of +ResultTransformer+ is used, which will
48
+ # transform some common attributes returned by the ConsumerView API into hashes with richer details than returned
49
+ # by the raw API.
50
+ # @option options [String] :api_base_url optional base URL to make ConsumerView API calls against. By default, uses
51
+ # the Experian production ConsumerView server.
52
+ def initialize(user_id:, password:, client_id:, asset_id:, options: {})
53
+ @user_id = user_id
54
+ @password = password
55
+ @client_id = client_id
56
+ @asset_id = asset_id
57
+
58
+ @token_cache = options[:token_cache] || default_token_cache
59
+ @result_transformer = options[:result_transformer] || default_result_transformer
60
+ @api = ExperianConsumerView::Api.new(url: options[:api_base_url])
61
+ end
62
+
63
+ # Looks up 1 or more search items in the ConsumerView API.
64
+ #
65
+ # Note that the demographic / propensity keys returned will only be those which the client & asset have access to.
66
+ # Refer to the Experian ConsumerView API Documentation for exact details of the keys & possible values.
67
+ #
68
+ # @param search_items [Hash] a hash of identifiers to search keys for an individual / household / postcode as
69
+ # required by the ConsumerView API. Eg.
70
+ # <tt>{ "PersonA" => { "email" => "person.a@example.com" }, "Postcode1" => { "postcode" => "SW1A 1AA" } }</tt>.
71
+ # Note that the top-level key is not passed to the ConsumerView API, it is just used for convenience when
72
+ # returning results.
73
+ # @param auto_retries [Integer] optional number of times the lookup should be retried if a transient / potentially
74
+ # recoverable error occurs. Defaults to 1.
75
+ #
76
+ # @returns [Hash] a hash of identifiers to the results returned by the ConsumerView API. Eg.
77
+ # <tt>
78
+ # {
79
+ # "PersonA" => { "pc_mosaic_uk_7_group":"G", "Match":"P" } ,
80
+ # "Postcode1" => { "pc_mosaic_uk_7_group":"G", "Match":"PC" }
81
+ # }
82
+ # </tt>
83
+ def lookup(search_items:, auto_retries: 1)
84
+ ordered_identifiers = search_items.keys
85
+ ordered_terms = search_items.values
86
+
87
+ token = auth_token
88
+ attempts = 0
89
+ begin
90
+ ordered_results = @api.batch_lookup(
91
+ user_id: @user_id,
92
+ token: token,
93
+ client_id: @client_id,
94
+ asset_id: @asset_id,
95
+ batched_search_keys: ordered_terms
96
+ )
97
+ rescue ApiBadCredentialsError, ApiServerRefreshingError => e
98
+ # Bad Credentials can sometimes be caused by race conditions - eg. one thread / server updating the cached
99
+ # token while another is querying with the old token. Retrying once should avoid the client throwing
100
+ # unnecessary errors to the calling code.
101
+ # Experian docs also recommend retrying when a server refresh is in progress, and if that fails, retrying again
102
+ # in approximately 10 minutes.
103
+ raise e unless attempts < auto_retries
104
+
105
+ token = auth_token(force_lookup: true)
106
+ attempts += 1
107
+ retry
108
+ end
109
+
110
+ results_hash(identifiers: ordered_identifiers, results: ordered_results)
111
+ end
112
+
113
+ private
114
+
115
+ def auth_token(force_lookup: false)
116
+ # ConsumerView auth tokens last for 30 minutes before expiring & becoming invalid.
117
+ # After 29 minutes, the cache entry will expire, and the first process to find the expired entry will refresh it,
118
+ # while allowing other processes to use the existing value for another 10s. This should alleviate race conditions,
119
+ # but will not eliminate them entirely. Note that in a distributed / cloud / multi-server environment, a shared
120
+ # cache MUST be used. An in-memory store would mean multiple instances logging to the ConsumerView API, and each
121
+ # login will change the active token, which other servers will not see, leading to frequent authorisation
122
+ # failures.
123
+ @token_cache.fetch(
124
+ CACHE_KEY, expires_in: 29.minutes, race_condition_ttl: 10.seconds, force: force_lookup
125
+ ) do
126
+ @api.get_auth_token(user_id: @user_id, password: @password)
127
+ end
128
+ end
129
+
130
+ def results_hash(identifiers:, results:)
131
+ raise ApiResultSizeMismatchError unless results.size == identifiers.size
132
+
133
+ # Construct a hash of { identifier => result_hash }
134
+ # Hash[identifiers.zip(results)]
135
+
136
+ results_hash = {}
137
+ results.each_with_index do |single_result, i|
138
+ results_hash[identifiers[i]] = @result_transformer.transform(single_result)
139
+ end
140
+
141
+ results_hash
142
+ end
143
+
144
+ def default_token_cache
145
+ ActiveSupport::Cache::MemoryStore.new
146
+ end
147
+
148
+ def default_result_transformer
149
+ ExperianConsumerView::Transformers::ResultTransformer.default
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ExperianConsumerView
4
+ MAX_LOOKUP_BATCH_SIZE = 5_000
5
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ExperianConsumerView
4
+ module Errors
5
+ # Base helper class for errors caused due to unexpected HTTP responses
6
+ class ApiHttpError < StandardError
7
+ attr_reader :code, :response
8
+
9
+ def initialize(code, response)
10
+ super()
11
+ @code = code
12
+ @response = response
13
+ end
14
+
15
+ def message
16
+ "HTTP code [#{@code}], response text [#{@response}]"
17
+ end
18
+ end
19
+
20
+ # Thrown for HTTP 401 codes. This means there is a problem with some of the supplied credentials. Race conditions
21
+ # may lead to out-of-date or invalid API tokens occasionally. These errors may be auto-retried.
22
+ class ApiBadCredentialsError < ApiHttpError; end
23
+
24
+ # Thrown for HTTP 404 codes. These imply the API endpoints may have changed, likely requiring a code change to
25
+ # this code library.
26
+ class ApiEndpointNotFoundError < ApiHttpError; end
27
+
28
+ # Thrown for HTTP 417 codes. These imply the API format may have changed, likely requiring a code change to this
29
+ # code library.
30
+ class ApiIncorrectJsonError < ApiHttpError; end
31
+
32
+ # Thrown for HTTP 500 codes, and HTTP 503 codes where the text response is "Server error". This means a serious
33
+ # server error has occurred. This can only be resolved on the server side by Experian - inform them if it persists.
34
+ class ApiServerError < ApiHttpError; end
35
+
36
+ # Thrown for HTTP 503 codes where the text response is "Internal refresh in progress". This means the server is
37
+ # temporarily down while data is being refreshed, but the request should complete successfully once the refresh
38
+ # operation is complete. These errors may be auto-retried.
39
+ class ApiServerRefreshingError < ApiHttpError; end
40
+
41
+ # Thrown for HTTP 505 codes. These imply an error which likely requires a code change to this code library.
42
+ class ApiHttpVersionNotSupportedError < ApiHttpError; end
43
+
44
+ # Thrown for unhandled HTTP codes.
45
+ class ApiUnhandledHttpError < ApiHttpError; end
46
+
47
+ # Thrown when the API is passed a batch which is too big to be given to the API
48
+ class ApiBatchTooBigError < StandardError; end
49
+
50
+ # Thrown when the API returns data successfully, but the size of the data returned does not match the size of the
51
+ # query data provided, meaning there is no way to know for sure which result relates to which query string. Such
52
+ # an error either implies a serious issue with the Experian API, or with this code library (or a change to the API
53
+ # contract).
54
+ class ApiResultSizeMismatchError < StandardError; end
55
+
56
+ # Thrown when the API returns a value for an attribute which is unrecognised and therefore can't be transformed
57
+ class AttributeValueUnrecognisedError < StandardError; end
58
+ end
59
+ end