dilisense_pep_client 0.1.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.
- checksums.yaml +7 -0
- data/.env.example +2 -0
- data/CLAUDE.md +141 -0
- data/LICENSE +21 -0
- data/Makefile +98 -0
- data/README.md +500 -0
- data/Rakefile +37 -0
- data/dilisense_pep_client.gemspec +51 -0
- data/lib/dilisense_pep_client/audit_logger.rb +653 -0
- data/lib/dilisense_pep_client/circuit_breaker.rb +257 -0
- data/lib/dilisense_pep_client/client.rb +254 -0
- data/lib/dilisense_pep_client/configuration.rb +15 -0
- data/lib/dilisense_pep_client/errors.rb +488 -0
- data/lib/dilisense_pep_client/logger.rb +207 -0
- data/lib/dilisense_pep_client/metrics.rb +505 -0
- data/lib/dilisense_pep_client/validator.rb +456 -0
- data/lib/dilisense_pep_client/version.rb +5 -0
- data/lib/dilisense_pep_client.rb +107 -0
- metadata +246 -0
@@ -0,0 +1,257 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "concurrent-ruby"
|
4
|
+
|
5
|
+
module DilisensePepClient
|
6
|
+
# Circuit breaker implementation for API resilience and fault tolerance
|
7
|
+
#
|
8
|
+
# This class implements the Circuit Breaker pattern to protect against cascading failures
|
9
|
+
# when the Dilisense API becomes unavailable or starts returning errors frequently.
|
10
|
+
# It prevents unnecessary load on a failing service by temporarily blocking requests
|
11
|
+
# and allowing the service time to recover.
|
12
|
+
#
|
13
|
+
# States:
|
14
|
+
# - CLOSED: Normal operation, requests pass through
|
15
|
+
# - OPEN: Service is failing, all requests are blocked
|
16
|
+
# - HALF_OPEN: Testing if service has recovered, limited requests allowed
|
17
|
+
#
|
18
|
+
# The circuit breaker automatically transitions between states based on:
|
19
|
+
# - Failure threshold: Number of consecutive failures before opening
|
20
|
+
# - Recovery timeout: Time to wait before attempting to recover
|
21
|
+
# - Success criteria: Requirements to close the circuit after half-open
|
22
|
+
#
|
23
|
+
# Features:
|
24
|
+
# - Thread-safe operation using concurrent-ruby primitives
|
25
|
+
# - Configurable failure thresholds and recovery timeouts
|
26
|
+
# - Timeout protection for individual requests
|
27
|
+
# - Comprehensive metrics and logging
|
28
|
+
# - Security event logging for monitoring
|
29
|
+
#
|
30
|
+
# @example Basic usage with default settings
|
31
|
+
# breaker = CircuitBreaker.new(service_name: "dilisense_api")
|
32
|
+
# result = breaker.call do
|
33
|
+
# # Make API request here
|
34
|
+
# api_client.get("/endpoint")
|
35
|
+
# end
|
36
|
+
#
|
37
|
+
# @example Custom configuration for high-availability requirements
|
38
|
+
# breaker = CircuitBreaker.new(
|
39
|
+
# service_name: "dilisense_api",
|
40
|
+
# failure_threshold: 3, # Open after 3 failures
|
41
|
+
# recovery_timeout: 30, # Try again after 30 seconds
|
42
|
+
# timeout: 15, # Individual request timeout
|
43
|
+
# exceptions: [APIError, NetworkError] # Only these errors count as failures
|
44
|
+
# )
|
45
|
+
class CircuitBreaker
|
46
|
+
# Exception raised when the circuit breaker is in the OPEN state
|
47
|
+
# Indicates that requests are being blocked to protect the downstream service
|
48
|
+
class CircuitOpenError < StandardError
|
49
|
+
def initialize(service_name, next_attempt_time)
|
50
|
+
super("Circuit breaker is OPEN for #{service_name}. Next attempt allowed at #{next_attempt_time}")
|
51
|
+
@service_name = service_name
|
52
|
+
@next_attempt_time = next_attempt_time
|
53
|
+
end
|
54
|
+
|
55
|
+
attr_reader :service_name, :next_attempt_time
|
56
|
+
end
|
57
|
+
|
58
|
+
# Valid circuit breaker states following the standard pattern
|
59
|
+
STATES = %i[closed open half_open].freeze
|
60
|
+
|
61
|
+
# Initialize a new circuit breaker with specified configuration
|
62
|
+
#
|
63
|
+
# @param service_name [String] Name of the protected service (for logging and metrics)
|
64
|
+
# @param failure_threshold [Integer] Number of failures before opening the circuit (default: 5)
|
65
|
+
# @param recovery_timeout [Integer] Seconds to wait before attempting recovery (default: 60)
|
66
|
+
# @param timeout [Integer] Timeout for individual requests in seconds (default: 30)
|
67
|
+
# @param exceptions [Array<Class>] Exception types that count as failures (default: [StandardError])
|
68
|
+
def initialize(
|
69
|
+
service_name:,
|
70
|
+
failure_threshold: 5,
|
71
|
+
recovery_timeout: 60,
|
72
|
+
timeout: 30,
|
73
|
+
exceptions: [StandardError]
|
74
|
+
)
|
75
|
+
@service_name = service_name
|
76
|
+
@failure_threshold = failure_threshold
|
77
|
+
@recovery_timeout = recovery_timeout
|
78
|
+
@timeout = timeout
|
79
|
+
@exceptions = exceptions
|
80
|
+
|
81
|
+
# Initialize state - circuit starts in CLOSED (normal) state
|
82
|
+
@state = :closed
|
83
|
+
@failure_count = Concurrent::AtomicFixnum.new(0) # Thread-safe failure counter
|
84
|
+
@last_failure_time = Concurrent::AtomicReference.new # Track when last failure occurred
|
85
|
+
@next_attempt_time = Concurrent::AtomicReference.new # When to allow next attempt after opening
|
86
|
+
@success_count = Concurrent::AtomicFixnum.new(0) # Track successful requests
|
87
|
+
@mutex = Mutex.new # Synchronize state transitions
|
88
|
+
end
|
89
|
+
|
90
|
+
# Execute a block of code with circuit breaker protection
|
91
|
+
# This is the main method that wraps your API calls or other potentially failing operations
|
92
|
+
#
|
93
|
+
# @param block [Proc] The code to execute (typically an API call)
|
94
|
+
# @return [Object] Result of the executed block
|
95
|
+
# @raise [CircuitOpenError] When circuit is open and blocking requests
|
96
|
+
# @raise [Exception] Any exception raised by the protected code
|
97
|
+
#
|
98
|
+
# @example Protect an API call
|
99
|
+
# result = circuit_breaker.call do
|
100
|
+
# http_client.get("/api/endpoint")
|
101
|
+
# end
|
102
|
+
def call(&block)
|
103
|
+
case state
|
104
|
+
when :open
|
105
|
+
# Circuit is open - check if enough time has passed to allow a test request
|
106
|
+
check_if_half_open_allowed
|
107
|
+
raise CircuitOpenError.new(@service_name, @next_attempt_time.value)
|
108
|
+
when :half_open
|
109
|
+
# Circuit is testing recovery - attempt the request and reset if successful
|
110
|
+
attempt_reset(&block)
|
111
|
+
when :closed
|
112
|
+
# Circuit is closed - normal operation, execute the request
|
113
|
+
execute(&block)
|
114
|
+
end
|
115
|
+
rescue *@exceptions => e
|
116
|
+
# Catch configured exception types and record as failures
|
117
|
+
record_failure(e)
|
118
|
+
raise
|
119
|
+
end
|
120
|
+
|
121
|
+
def state
|
122
|
+
@mutex.synchronize { @state }
|
123
|
+
end
|
124
|
+
|
125
|
+
def failure_count
|
126
|
+
@failure_count.value
|
127
|
+
end
|
128
|
+
|
129
|
+
def success_count
|
130
|
+
@success_count.value
|
131
|
+
end
|
132
|
+
|
133
|
+
def metrics
|
134
|
+
{
|
135
|
+
service_name: @service_name,
|
136
|
+
state: state,
|
137
|
+
failure_count: failure_count,
|
138
|
+
success_count: success_count,
|
139
|
+
failure_threshold: @failure_threshold,
|
140
|
+
recovery_timeout: @recovery_timeout,
|
141
|
+
last_failure_time: @last_failure_time.value,
|
142
|
+
next_attempt_time: @next_attempt_time.value
|
143
|
+
}
|
144
|
+
end
|
145
|
+
|
146
|
+
def reset!
|
147
|
+
@mutex.synchronize do
|
148
|
+
@state = :closed
|
149
|
+
@failure_count.value = 0
|
150
|
+
@success_count.value = 0
|
151
|
+
@last_failure_time.value = nil
|
152
|
+
@next_attempt_time.value = nil
|
153
|
+
end
|
154
|
+
|
155
|
+
Logger.logger.info("Circuit breaker reset", service_name: @service_name)
|
156
|
+
end
|
157
|
+
|
158
|
+
def force_open!
|
159
|
+
@mutex.synchronize do
|
160
|
+
@state = :open
|
161
|
+
@next_attempt_time.value = Time.now + @recovery_timeout
|
162
|
+
end
|
163
|
+
|
164
|
+
Logger.logger.warn("Circuit breaker forced open", service_name: @service_name)
|
165
|
+
end
|
166
|
+
|
167
|
+
private
|
168
|
+
|
169
|
+
def execute(&block)
|
170
|
+
result = Concurrent::Promises.future(executor: :io) do
|
171
|
+
block.call
|
172
|
+
end.value!(@timeout)
|
173
|
+
|
174
|
+
record_success
|
175
|
+
result
|
176
|
+
rescue Concurrent::TimeoutError
|
177
|
+
record_failure(StandardError.new("Request timeout after #{@timeout} seconds"))
|
178
|
+
raise NetworkError, "Request timeout after #{@timeout} seconds"
|
179
|
+
end
|
180
|
+
|
181
|
+
def attempt_reset(&block)
|
182
|
+
result = execute(&block)
|
183
|
+
reset_after_success
|
184
|
+
result
|
185
|
+
rescue *@exceptions => e
|
186
|
+
trip_breaker
|
187
|
+
raise e
|
188
|
+
end
|
189
|
+
|
190
|
+
def record_failure(error)
|
191
|
+
@failure_count.increment
|
192
|
+
@last_failure_time.value = Time.now
|
193
|
+
|
194
|
+
Logger.log_security_event(
|
195
|
+
event_type: "circuit_breaker_failure",
|
196
|
+
details: {
|
197
|
+
service_name: @service_name,
|
198
|
+
error_class: error.class.name,
|
199
|
+
error_message: error.message,
|
200
|
+
failure_count: @failure_count.value
|
201
|
+
},
|
202
|
+
severity: @failure_count.value >= @failure_threshold ? :high : :medium
|
203
|
+
)
|
204
|
+
|
205
|
+
trip_breaker if @failure_count.value >= @failure_threshold
|
206
|
+
end
|
207
|
+
|
208
|
+
def record_success
|
209
|
+
@success_count.increment
|
210
|
+
|
211
|
+
if state == :half_open
|
212
|
+
Logger.logger.info("Circuit breaker success in half-open state",
|
213
|
+
service_name: @service_name,
|
214
|
+
success_count: @success_count.value)
|
215
|
+
end
|
216
|
+
end
|
217
|
+
|
218
|
+
def trip_breaker
|
219
|
+
@mutex.synchronize do
|
220
|
+
@state = :open
|
221
|
+
@next_attempt_time.value = Time.now + @recovery_timeout
|
222
|
+
end
|
223
|
+
|
224
|
+
Logger.log_security_event(
|
225
|
+
event_type: "circuit_breaker_opened",
|
226
|
+
details: {
|
227
|
+
service_name: @service_name,
|
228
|
+
failure_count: @failure_count.value,
|
229
|
+
next_attempt_time: @next_attempt_time.value
|
230
|
+
},
|
231
|
+
severity: :high
|
232
|
+
)
|
233
|
+
end
|
234
|
+
|
235
|
+
def check_if_half_open_allowed
|
236
|
+
return unless @next_attempt_time.value && Time.now >= @next_attempt_time.value
|
237
|
+
|
238
|
+
@mutex.synchronize do
|
239
|
+
if Time.now >= @next_attempt_time.value
|
240
|
+
@state = :half_open
|
241
|
+
Logger.logger.info("Circuit breaker entering half-open state", service_name: @service_name)
|
242
|
+
end
|
243
|
+
end
|
244
|
+
end
|
245
|
+
|
246
|
+
def reset_after_success
|
247
|
+
@mutex.synchronize do
|
248
|
+
@state = :closed
|
249
|
+
@failure_count.value = 0
|
250
|
+
@last_failure_time.value = nil
|
251
|
+
@next_attempt_time.value = nil
|
252
|
+
end
|
253
|
+
|
254
|
+
Logger.logger.info("Circuit breaker reset to closed after success", service_name: @service_name)
|
255
|
+
end
|
256
|
+
end
|
257
|
+
end
|
@@ -0,0 +1,254 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "faraday"
|
4
|
+
require "json"
|
5
|
+
|
6
|
+
module DilisensePepClient
|
7
|
+
# Main client class for interacting with the Dilisense PEP/Sanctions screening API
|
8
|
+
# This class handles all API communication and response processing
|
9
|
+
#
|
10
|
+
# @example Basic usage for individual screening
|
11
|
+
# client = DilisensePepClient::Client.new
|
12
|
+
# results = client.check_individual(names: "John Smith")
|
13
|
+
#
|
14
|
+
# @example Entity screening
|
15
|
+
# results = client.check_entity(names: "Apple Inc")
|
16
|
+
class Client
|
17
|
+
# Initialize a new API client
|
18
|
+
# Validates configuration and establishes HTTP connection
|
19
|
+
# @raise [ConfigurationError] if API key is missing or invalid
|
20
|
+
def initialize
|
21
|
+
validate_configuration!
|
22
|
+
@connection = build_connection
|
23
|
+
end
|
24
|
+
|
25
|
+
# Screen an individual against PEP and sanctions lists
|
26
|
+
#
|
27
|
+
# @param names [String, nil] Full name to search for (e.g., "John Smith")
|
28
|
+
# @param search_all [String, nil] Alternative to names parameter for broader search
|
29
|
+
# @param dob [String, nil] Date of birth in DD/MM/YYYY format (e.g., "14/06/1982")
|
30
|
+
# @param gender [String, nil] Gender - either "male" or "female"
|
31
|
+
# @param fuzzy_search [Integer, nil] Enable fuzzy matching: 1 for fuzzy, 2 for very fuzzy
|
32
|
+
# @param includes [String, nil] Comma-separated source IDs to search within
|
33
|
+
#
|
34
|
+
# @return [Array<Hash>] Array of matched individuals, each hash contains:
|
35
|
+
# - :name [String] Person's full name
|
36
|
+
# - :source_type [String] Type of source (PEP, SANCTION, etc.)
|
37
|
+
# - :pep_type [String, nil] Type of PEP if applicable
|
38
|
+
# - :gender [String, nil] Person's gender if available
|
39
|
+
# - :date_of_birth [Array<String>, nil] Dates of birth if available
|
40
|
+
# - :citizenship [Array<String>, nil] Countries of citizenship
|
41
|
+
# - :total_records [Integer] Number of matching records
|
42
|
+
# - :sources [Array<String>] List of source databases
|
43
|
+
#
|
44
|
+
# @example Search with full parameters
|
45
|
+
# results = client.check_individual(
|
46
|
+
# names: "Vladimir Putin",
|
47
|
+
# dob: "07/10/1952",
|
48
|
+
# gender: "male",
|
49
|
+
# fuzzy_search: 1
|
50
|
+
# )
|
51
|
+
#
|
52
|
+
# @raise [ValidationError] if parameters are invalid or conflicting
|
53
|
+
# @raise [APIError] if the API returns an error
|
54
|
+
def check_individual(names: nil, search_all: nil, dob: nil, gender: nil, fuzzy_search: nil, includes: nil)
|
55
|
+
params = build_individual_params(names: names, search_all: search_all, dob: dob, gender: gender, fuzzy_search: fuzzy_search, includes: includes)
|
56
|
+
validate_individual_params(params)
|
57
|
+
get_request("/v1/checkIndividual", params)
|
58
|
+
end
|
59
|
+
|
60
|
+
# Screen an entity (company/organization) against sanctions and watchlists
|
61
|
+
#
|
62
|
+
# @param names [String, nil] Entity name to search for (e.g., "Apple Inc")
|
63
|
+
# @param search_all [String, nil] Alternative parameter for broader entity search
|
64
|
+
# @param fuzzy_search [Integer, nil] Enable fuzzy matching: 1 for fuzzy, 2 for very fuzzy
|
65
|
+
#
|
66
|
+
# @return [Array<Hash>] Array of matched entities with details
|
67
|
+
#
|
68
|
+
# @example Basic entity search
|
69
|
+
# results = client.check_entity(names: "Bank Rossiya")
|
70
|
+
#
|
71
|
+
# @example Fuzzy search for entities
|
72
|
+
# results = client.check_entity(names: "Gazprom", fuzzy_search: 1)
|
73
|
+
#
|
74
|
+
# @raise [ValidationError] if parameters are invalid
|
75
|
+
# @raise [APIError] if the API returns an error
|
76
|
+
def check_entity(names: nil, search_all: nil, fuzzy_search: nil)
|
77
|
+
params = {}
|
78
|
+
params[:names] = names if names
|
79
|
+
params[:search_all] = search_all if search_all
|
80
|
+
params[:fuzzy_search] = fuzzy_search if fuzzy_search
|
81
|
+
|
82
|
+
validate_entity_params(params)
|
83
|
+
get_request("/v1/checkEntity", params)
|
84
|
+
end
|
85
|
+
|
86
|
+
private
|
87
|
+
|
88
|
+
# Validate that the API configuration is properly set
|
89
|
+
# Checks for presence and validity of API key
|
90
|
+
# @raise [ConfigurationError] if API key is missing or empty
|
91
|
+
def validate_configuration!
|
92
|
+
config = DilisensePepClient.configuration
|
93
|
+
raise ConfigurationError, "API key is required" if config.api_key.nil? || config.api_key.empty?
|
94
|
+
end
|
95
|
+
|
96
|
+
# Build parameters hash for individual screening API call
|
97
|
+
# Filters out nil values to keep request clean
|
98
|
+
#
|
99
|
+
# @param names [String, nil] Person's full name
|
100
|
+
# @param search_all [String, nil] Alternative search parameter
|
101
|
+
# @param dob [String, nil] Date of birth (DD/MM/YYYY)
|
102
|
+
# @param gender [String, nil] Gender (male/female)
|
103
|
+
# @param fuzzy_search [Integer, nil] Fuzzy search level (1 or 2)
|
104
|
+
# @param includes [String, nil] Source IDs to include
|
105
|
+
# @return [Hash] Parameters hash with non-nil values only
|
106
|
+
def build_individual_params(names:, search_all:, dob:, gender:, fuzzy_search:, includes:)
|
107
|
+
params = {}
|
108
|
+
params[:names] = names if names
|
109
|
+
params[:search_all] = search_all if search_all
|
110
|
+
params[:dob] = dob if dob
|
111
|
+
params[:gender] = gender if gender
|
112
|
+
params[:fuzzy_search] = fuzzy_search if fuzzy_search
|
113
|
+
params[:includes] = includes if includes
|
114
|
+
params
|
115
|
+
end
|
116
|
+
|
117
|
+
# Validate parameters for individual screening
|
118
|
+
# Ensures mutually exclusive parameters aren't used together
|
119
|
+
# and that at least one search parameter is provided
|
120
|
+
#
|
121
|
+
# @param params [Hash] Parameters to validate
|
122
|
+
# @raise [ValidationError] if parameters are invalid or missing
|
123
|
+
def validate_individual_params(params)
|
124
|
+
# Can't use both 'names' and 'search_all' - they're mutually exclusive
|
125
|
+
if params[:search_all] && params[:names]
|
126
|
+
raise ValidationError, "Cannot use both search_all and names parameters"
|
127
|
+
end
|
128
|
+
# Must have at least one search parameter
|
129
|
+
unless params[:search_all] || params[:names]
|
130
|
+
raise ValidationError, "Either search_all or names parameter is required"
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
# Validate parameters for entity screening
|
135
|
+
# Similar to individual validation but for entities
|
136
|
+
#
|
137
|
+
# @param params [Hash] Parameters to validate
|
138
|
+
# @raise [ValidationError] if parameters are invalid
|
139
|
+
def validate_entity_params(params)
|
140
|
+
# Can't use both search methods simultaneously
|
141
|
+
if params[:search_all] && params[:names]
|
142
|
+
raise ValidationError, "Cannot use both search_all and names parameters"
|
143
|
+
end
|
144
|
+
# Need at least one search parameter
|
145
|
+
unless params[:search_all] || params[:names]
|
146
|
+
raise ValidationError, "Either search_all or names parameter is required"
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
# Build the HTTP connection using Faraday
|
151
|
+
# Sets up headers, timeout, and authentication
|
152
|
+
#
|
153
|
+
# @return [Faraday::Connection] Configured HTTP client
|
154
|
+
def build_connection
|
155
|
+
config = DilisensePepClient.configuration
|
156
|
+
Faraday.new(url: config.base_url) do |f|
|
157
|
+
f.options.timeout = config.timeout
|
158
|
+
f.headers["x-api-key"] = config.api_key # API authentication
|
159
|
+
f.headers["User-Agent"] = "DilisensePepClient/#{VERSION}" # Identify our client
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
# Execute GET request to the API and process response
|
164
|
+
# Handles network errors and converts response to array format
|
165
|
+
#
|
166
|
+
# @param endpoint [String] API endpoint path (e.g., "/v1/checkIndividual")
|
167
|
+
# @param params [Hash] Query parameters for the request
|
168
|
+
# @return [Array<Hash>] Processed response as array of matches
|
169
|
+
# @raise [NetworkError] if connection fails or times out
|
170
|
+
# @raise [APIError] if API returns an error status
|
171
|
+
def get_request(endpoint, params)
|
172
|
+
response = @connection.get(endpoint, params)
|
173
|
+
raw_response = handle_response(response)
|
174
|
+
process_response_to_array(raw_response)
|
175
|
+
rescue Faraday::TimeoutError
|
176
|
+
raise NetworkError, "Request timeout"
|
177
|
+
rescue Faraday::ConnectionFailed
|
178
|
+
raise NetworkError, "Connection failed"
|
179
|
+
end
|
180
|
+
|
181
|
+
def handle_response(response)
|
182
|
+
case response.status
|
183
|
+
when 200
|
184
|
+
JSON.parse(response.body)
|
185
|
+
when 401
|
186
|
+
raise AuthenticationError.new("API key not valid", status: response.status, body: response.body)
|
187
|
+
when 400
|
188
|
+
raise ValidationError.new("Bad request: #{response.body}", status: response.status, body: response.body)
|
189
|
+
when 403
|
190
|
+
raise APIError.new("Forbidden", status: response.status, body: response.body)
|
191
|
+
when 429
|
192
|
+
raise APIError.new("Rate limit exceeded", status: response.status, body: response.body)
|
193
|
+
when 500
|
194
|
+
raise APIError.new("Internal server error", status: response.status, body: response.body)
|
195
|
+
else
|
196
|
+
raise APIError.new("Unexpected response: #{response.status}", status: response.status, body: response.body)
|
197
|
+
end
|
198
|
+
rescue JSON::ParserError
|
199
|
+
raise APIError.new("Invalid JSON response", status: response.status, body: response.body)
|
200
|
+
end
|
201
|
+
|
202
|
+
def process_response_to_array(raw_response)
|
203
|
+
return [] if raw_response["total_hits"] == 0
|
204
|
+
|
205
|
+
# Group records by person (using name as the key)
|
206
|
+
person_groups = raw_response["found_records"].group_by do |record|
|
207
|
+
normalize_name(record["name"])
|
208
|
+
end
|
209
|
+
|
210
|
+
# Convert each group to a person/entity object
|
211
|
+
person_groups.map do |normalized_name, records|
|
212
|
+
primary_record = records.first
|
213
|
+
all_sources = records.map { |r| r["source_id"] }.uniq
|
214
|
+
|
215
|
+
{
|
216
|
+
name: primary_record["name"],
|
217
|
+
source_type: primary_record["source_type"],
|
218
|
+
pep_type: primary_record["pep_type"],
|
219
|
+
gender: primary_record["gender"],
|
220
|
+
date_of_birth: primary_record["date_of_birth"],
|
221
|
+
citizenship: primary_record["citizenship"],
|
222
|
+
jurisdiction: primary_record["jurisdiction"],
|
223
|
+
address: primary_record["address"],
|
224
|
+
sanction_details: primary_record["sanction_details"],
|
225
|
+
sources: all_sources,
|
226
|
+
total_records: records.length,
|
227
|
+
raw_records: records
|
228
|
+
}
|
229
|
+
end
|
230
|
+
end
|
231
|
+
|
232
|
+
# Normalize a name for comparison/grouping purposes
|
233
|
+
# Handles various name formats and removes titles/suffixes
|
234
|
+
# This ensures "Mr. John Smith Jr." and "JOHN SMITH" are treated as the same person
|
235
|
+
#
|
236
|
+
# @param name [String] Name to normalize
|
237
|
+
# @return [String] Normalized name for comparison
|
238
|
+
def normalize_name(name)
|
239
|
+
return "" unless name
|
240
|
+
|
241
|
+
# Remove common variations and normalize for grouping
|
242
|
+
normalized = name.downcase
|
243
|
+
.gsub(/\s+/, " ") # Normalize whitespace (multiple spaces -> single space)
|
244
|
+
.gsub(/[^\w\s]/, "") # Remove punctuation (periods, commas, etc.)
|
245
|
+
.strip # Remove leading/trailing whitespace
|
246
|
+
|
247
|
+
# Handle common name variations - remove suffixes that don't affect identity
|
248
|
+
# This helps match "John Smith Jr." with "John Smith"
|
249
|
+
normalized.gsub(/\b(jr|sr|iii?|iv)\b/, "") # Remove suffixes (Jr, Sr, III, IV, etc.)
|
250
|
+
.gsub(/\s+/, " ") # Clean up any double spaces from removal
|
251
|
+
.strip
|
252
|
+
end
|
253
|
+
end
|
254
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module DilisensePepClient
|
4
|
+
# Simple configuration management
|
5
|
+
# Stores basic settings needed for API communication
|
6
|
+
class Configuration
|
7
|
+
attr_accessor :api_key, :base_url, :timeout
|
8
|
+
|
9
|
+
def initialize
|
10
|
+
@base_url = "https://api.dilisense.com"
|
11
|
+
@timeout = 30
|
12
|
+
@api_key = ENV["DILISENSE_API_KEY"]
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|