experian_consumer_view 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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