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.
@@ -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