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,488 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DilisensePepClient
4
+ # Base error class for all DilisensePepClient exceptions
5
+ # Provides enhanced error handling with context, timestamps, and request tracking
6
+ # All other error classes inherit from this base class
7
+ #
8
+ # @example Catching any gem error
9
+ # begin
10
+ # DilisensePepClient.check_individual(names: "John")
11
+ # rescue DilisensePepClient::Error => e
12
+ # puts "Error: #{e.message}"
13
+ # puts "Error code: #{e.error_code}"
14
+ # puts "Request ID: #{e.request_id}"
15
+ # end
16
+ class Error < StandardError
17
+ attr_reader :error_code, :context, :timestamp, :request_id
18
+
19
+ def initialize(message, error_code: nil, context: {}, request_id: nil)
20
+ super(message)
21
+ @error_code = error_code
22
+ @context = context || {}
23
+ @timestamp = Time.now.utc.iso8601
24
+ @request_id = request_id || generate_request_id
25
+
26
+ log_error
27
+ end
28
+
29
+ def to_h
30
+ {
31
+ error_type: self.class.name,
32
+ message: message,
33
+ error_code: error_code,
34
+ context: context,
35
+ timestamp: timestamp,
36
+ request_id: request_id,
37
+ backtrace: backtrace&.first(10)
38
+ }
39
+ end
40
+
41
+ def retryable?
42
+ false
43
+ end
44
+
45
+ def security_event?
46
+ false
47
+ end
48
+
49
+ private
50
+
51
+ def log_error
52
+ return unless defined?(DilisensePepClient::Logger)
53
+
54
+ severity = security_event? ? :error : :warn
55
+
56
+ DilisensePepClient::Logger.logger.send(severity, "#{self.class.name} raised", {
57
+ error_code: error_code,
58
+ message: message,
59
+ context: context,
60
+ request_id: request_id,
61
+ retryable: retryable?
62
+ })
63
+ end
64
+
65
+ def generate_request_id
66
+ require "securerandom"
67
+ SecureRandom.hex(8)
68
+ end
69
+ end
70
+
71
+ # Raised when there's a problem with gem configuration
72
+ # Usually means the API key is missing or invalid
73
+ #
74
+ # @example Common cause - missing API key
75
+ # # This will raise ConfigurationError if API key not set
76
+ # client = DilisensePepClient::Client.new
77
+ class ConfigurationError < Error
78
+ def initialize(message, config_key: nil, config_value: nil, **options)
79
+ context = {
80
+ config_key: config_key,
81
+ config_value: sanitize_config_value(config_value)
82
+ }.merge(options.fetch(:context, {}))
83
+
84
+ super(message, error_code: "CONFIG_ERROR", context: context, **options)
85
+ end
86
+
87
+ def security_event?
88
+ context[:config_key]&.to_s&.match?(/api_key|secret|token/)
89
+ end
90
+
91
+ private
92
+
93
+ def sanitize_config_value(value)
94
+ return "[REDACTED]" if value.to_s.match?(/api_key|secret|token|password/i)
95
+ value.to_s.length > 50 ? "#{value.to_s[0..10]}..." : value
96
+ end
97
+ end
98
+
99
+ # Raised when the API returns an error response
100
+ # Contains HTTP status code, response body, and headers for debugging
101
+ #
102
+ # @example Handling API errors
103
+ # begin
104
+ # results = client.check_individual(names: "Test")
105
+ # rescue DilisensePepClient::APIError => e
106
+ # puts "API error: #{e.message}"
107
+ # puts "Status: #{e.status}"
108
+ # puts "Retryable? #{e.retryable?}"
109
+ # end
110
+ class APIError < Error
111
+ attr_reader :status, :body, :headers
112
+
113
+ def initialize(message, status: nil, body: nil, headers: {}, **options)
114
+ @status = status
115
+ @body = sanitize_body(body)
116
+ @headers = sanitize_headers(headers)
117
+
118
+ context = {
119
+ status: status,
120
+ response_size: body&.to_s&.length,
121
+ endpoint: options[:endpoint]
122
+ }.merge(options.fetch(:context, {}))
123
+
124
+ error_code = determine_error_code(status)
125
+
126
+ super(message, error_code: error_code, context: context, **options)
127
+ end
128
+
129
+ def retryable?
130
+ case status
131
+ when 429, 502, 503, 504 then true
132
+ when 500..599 then true
133
+ else false
134
+ end
135
+ end
136
+
137
+ def client_error?
138
+ status.to_i.between?(400, 499)
139
+ end
140
+
141
+ def server_error?
142
+ status.to_i.between?(500, 599)
143
+ end
144
+
145
+ private
146
+
147
+ def determine_error_code(status)
148
+ case status
149
+ when 400 then "BAD_REQUEST"
150
+ when 401 then "UNAUTHORIZED"
151
+ when 403 then "FORBIDDEN"
152
+ when 404 then "NOT_FOUND"
153
+ when 429 then "RATE_LIMITED"
154
+ when 500 then "INTERNAL_SERVER_ERROR"
155
+ when 502 then "BAD_GATEWAY"
156
+ when 503 then "SERVICE_UNAVAILABLE"
157
+ when 504 then "GATEWAY_TIMEOUT"
158
+ else "API_ERROR"
159
+ end
160
+ end
161
+
162
+ def sanitize_body(body)
163
+ return nil unless body
164
+ body_str = body.to_s
165
+ return body_str if body_str.length <= 1000
166
+ "#{body_str[0..1000]}... (truncated)"
167
+ end
168
+
169
+ def sanitize_headers(headers)
170
+ return {} unless headers.is_a?(Hash)
171
+
172
+ headers.transform_values do |value|
173
+ case value.to_s.downcase
174
+ when /authorization|api.?key|token|secret/
175
+ "[REDACTED]"
176
+ else
177
+ value.to_s.length > 100 ? "#{value.to_s[0..100]}..." : value
178
+ end
179
+ end
180
+ end
181
+ end
182
+
183
+ # Raised when there's a network problem (timeout, connection failure, etc.)
184
+ # These errors are usually retryable after a delay
185
+ #
186
+ # @example Network timeout
187
+ # begin
188
+ # results = client.check_individual(names: "Test")
189
+ # rescue DilisensePepClient::NetworkError => e
190
+ # puts "Network problem: #{e.message}"
191
+ # # Usually safe to retry after a delay
192
+ # end
193
+ class NetworkError < Error
194
+ def initialize(message, network_error: nil, **options)
195
+ context = {
196
+ network_error_class: network_error&.class&.name,
197
+ network_error_message: network_error&.message
198
+ }.merge(options.fetch(:context, {}))
199
+
200
+ super(message, error_code: "NETWORK_ERROR", context: context, **options)
201
+ end
202
+
203
+ def retryable?
204
+ true
205
+ end
206
+ end
207
+
208
+ # Raised specifically for authentication failures (401 status)
209
+ # Usually means the API key is invalid or has been revoked
210
+ #
211
+ # @example Invalid API key
212
+ # # This raises AuthenticationError for invalid API key
213
+ # DilisensePepClient.configure do |config|
214
+ # config.api_key = "invalid_key"
215
+ # end
216
+ # DilisensePepClient.check_individual(names: "Test") # => AuthenticationError
217
+ class AuthenticationError < APIError
218
+ def initialize(message, **options)
219
+ super(message, error_code: "AUTH_ERROR", **options)
220
+ end
221
+
222
+ def security_event?
223
+ true
224
+ end
225
+
226
+ def retryable?
227
+ false # Auth errors should not be retried automatically
228
+ end
229
+ end
230
+
231
+ # Raised when input parameters fail validation
232
+ # Contains details about which validation rules failed
233
+ #
234
+ # @example Invalid parameters
235
+ # # This raises ValidationError - can't use both parameters
236
+ # client.check_individual(names: "John", search_all: "John")
237
+ #
238
+ # @example Missing required parameters
239
+ # # This raises ValidationError - need at least one search param
240
+ # client.check_individual()
241
+ class ValidationError < Error
242
+ attr_reader :validation_errors
243
+
244
+ def initialize(message, validation_errors: [], field: nil, **options)
245
+ @validation_errors = Array(validation_errors)
246
+
247
+ context = {
248
+ field: field,
249
+ validation_errors: @validation_errors,
250
+ error_count: @validation_errors.size
251
+ }.merge(options.fetch(:context, {}))
252
+
253
+ super(message, error_code: "VALIDATION_ERROR", context: context, **options)
254
+ end
255
+
256
+ def retryable?
257
+ false # Validation errors require user intervention
258
+ end
259
+ end
260
+
261
+ # Rate limiting errors
262
+ class RateLimitError < APIError
263
+ attr_reader :retry_after, :limit, :remaining
264
+
265
+ def initialize(message, retry_after: nil, limit: nil, remaining: nil, **options)
266
+ @retry_after = retry_after
267
+ @limit = limit
268
+ @remaining = remaining
269
+
270
+ context = {
271
+ retry_after: retry_after,
272
+ rate_limit: limit,
273
+ rate_remaining: remaining,
274
+ reset_time: retry_after ? Time.now + retry_after : nil
275
+ }.merge(options.fetch(:context, {}))
276
+
277
+ super(message, error_code: "RATE_LIMITED", context: context, **options)
278
+ end
279
+
280
+ def retryable?
281
+ true
282
+ end
283
+
284
+ def suggested_retry_delay
285
+ @retry_after || 60 # Default to 60 seconds if not specified
286
+ end
287
+ end
288
+
289
+ # Timeout-related errors
290
+ class TimeoutError < NetworkError
291
+ attr_reader :timeout_duration
292
+
293
+ def initialize(message, timeout_duration: nil, **options)
294
+ @timeout_duration = timeout_duration
295
+
296
+ context = {
297
+ timeout_duration: timeout_duration,
298
+ timeout_type: determine_timeout_type(message)
299
+ }.merge(options.fetch(:context, {}))
300
+
301
+ super(message, error_code: "TIMEOUT_ERROR", context: context, **options)
302
+ end
303
+
304
+ def retryable?
305
+ true
306
+ end
307
+
308
+ private
309
+
310
+ def determine_timeout_type(message)
311
+ case message.downcase
312
+ when /connection/ then "connection_timeout"
313
+ when /read/ then "read_timeout"
314
+ when /write/ then "write_timeout"
315
+ else "general_timeout"
316
+ end
317
+ end
318
+ end
319
+
320
+ # Circuit breaker errors (from our circuit breaker implementation)
321
+ class CircuitBreakerError < Error
322
+ attr_reader :service_name, :circuit_state, :next_attempt_time
323
+
324
+ def initialize(message, service_name:, circuit_state:, next_attempt_time: nil, **options)
325
+ @service_name = service_name
326
+ @circuit_state = circuit_state
327
+ @next_attempt_time = next_attempt_time
328
+
329
+ context = {
330
+ service_name: service_name,
331
+ circuit_state: circuit_state,
332
+ next_attempt_time: next_attempt_time
333
+ }.merge(options.fetch(:context, {}))
334
+
335
+ super(message, error_code: "CIRCUIT_BREAKER_OPEN", context: context, **options)
336
+ end
337
+
338
+ def retryable?
339
+ circuit_state == :half_open
340
+ end
341
+
342
+ def security_event?
343
+ true # Circuit breaker events are significant for monitoring
344
+ end
345
+ end
346
+
347
+ # Data processing errors
348
+ class DataProcessingError < Error
349
+ attr_reader :data_type, :processing_stage
350
+
351
+ def initialize(message, data_type: nil, processing_stage: nil, **options)
352
+ @data_type = data_type
353
+ @processing_stage = processing_stage
354
+
355
+ context = {
356
+ data_type: data_type,
357
+ processing_stage: processing_stage
358
+ }.merge(options.fetch(:context, {}))
359
+
360
+ super(message, error_code: "DATA_PROCESSING_ERROR", context: context, **options)
361
+ end
362
+ end
363
+
364
+ # Compliance and audit-related errors
365
+ class ComplianceError < Error
366
+ attr_reader :compliance_rule, :severity_level
367
+
368
+ def initialize(message, compliance_rule:, severity_level: :medium, **options)
369
+ @compliance_rule = compliance_rule
370
+ @severity_level = severity_level
371
+
372
+ context = {
373
+ compliance_rule: compliance_rule,
374
+ severity_level: severity_level,
375
+ requires_escalation: severity_level == :critical
376
+ }.merge(options.fetch(:context, {}))
377
+
378
+ super(message, error_code: "COMPLIANCE_VIOLATION", context: context, **options)
379
+ end
380
+
381
+ def security_event?
382
+ true
383
+ end
384
+
385
+ def critical?
386
+ severity_level == :critical
387
+ end
388
+ end
389
+
390
+ # Error factory for consistent error creation
391
+ class ErrorFactory
392
+ class << self
393
+ def create_from_response(response, endpoint: nil, request_id: nil)
394
+ case response.status
395
+ when 401
396
+ AuthenticationError.new(
397
+ "API authentication failed",
398
+ status: response.status,
399
+ body: response.body,
400
+ headers: response.headers,
401
+ endpoint: endpoint,
402
+ request_id: request_id
403
+ )
404
+ when 429
405
+ retry_after = extract_retry_after(response.headers)
406
+ RateLimitError.new(
407
+ "API rate limit exceeded",
408
+ status: response.status,
409
+ body: response.body,
410
+ headers: response.headers,
411
+ retry_after: retry_after,
412
+ endpoint: endpoint,
413
+ request_id: request_id
414
+ )
415
+ when 400..499
416
+ APIError.new(
417
+ "Client error: #{response.status}",
418
+ status: response.status,
419
+ body: response.body,
420
+ headers: response.headers,
421
+ endpoint: endpoint,
422
+ request_id: request_id
423
+ )
424
+ when 500..599
425
+ APIError.new(
426
+ "Server error: #{response.status}",
427
+ status: response.status,
428
+ body: response.body,
429
+ headers: response.headers,
430
+ endpoint: endpoint,
431
+ request_id: request_id
432
+ )
433
+ else
434
+ APIError.new(
435
+ "Unexpected response: #{response.status}",
436
+ status: response.status,
437
+ body: response.body,
438
+ headers: response.headers,
439
+ endpoint: endpoint,
440
+ request_id: request_id
441
+ )
442
+ end
443
+ end
444
+
445
+ def create_network_error(original_error, context: {})
446
+ case original_error
447
+ when ::Faraday::TimeoutError
448
+ TimeoutError.new(
449
+ "Request timeout",
450
+ network_error: original_error,
451
+ context: context
452
+ )
453
+ when ::Faraday::ConnectionFailed
454
+ NetworkError.new(
455
+ "Connection failed",
456
+ network_error: original_error,
457
+ context: context
458
+ )
459
+ else
460
+ NetworkError.new(
461
+ "Network error: #{original_error.class.name}",
462
+ network_error: original_error,
463
+ context: context
464
+ )
465
+ end
466
+ end
467
+
468
+ private
469
+
470
+ def extract_retry_after(headers)
471
+ retry_after = headers["retry-after"] || headers["Retry-After"]
472
+ return nil unless retry_after
473
+
474
+ # Handle both seconds and HTTP-date formats
475
+ if retry_after.match?(/^\d+$/)
476
+ retry_after.to_i
477
+ else
478
+ # Parse HTTP-date and calculate seconds until then
479
+ begin
480
+ Time.parse(retry_after) - Time.now
481
+ rescue ArgumentError
482
+ nil
483
+ end
484
+ end
485
+ end
486
+ end
487
+ end
488
+ end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "semantic_logger"
4
+ require "json"
5
+
6
+ module DilisensePepClient
7
+ # Industrial-grade structured logging system for compliance and monitoring
8
+ #
9
+ # This class provides enterprise-level logging capabilities specifically designed for
10
+ # financial services and FinTech applications that require comprehensive audit trails,
11
+ # security monitoring, and regulatory compliance.
12
+ #
13
+ # Features:
14
+ # - Structured JSON logging in production environments
15
+ # - Automatic PII anonymization and data sanitization
16
+ # - Security event classification and severity levels
17
+ # - Compliance-ready audit trail generation
18
+ # - Request correlation through unique request IDs
19
+ # - Environment-specific log levels and formatting
20
+ # - Integration with SemanticLogger for enterprise features
21
+ #
22
+ # The logger automatically handles different environments:
23
+ # - Production: JSON structured logs to stdout, INFO level
24
+ # - Staging: Colored logs to stdout, INFO level
25
+ # - Development: Colored logs to stdout, DEBUG level
26
+ #
27
+ # All sensitive data (API keys, tokens, PII) is automatically sanitized before logging.
28
+ # Security events are classified by severity and logged with appropriate detail levels.
29
+ #
30
+ # @example Basic API request logging
31
+ # Logger.log_api_request(
32
+ # endpoint: "/v1/checkIndividual",
33
+ # params: { names: "John Smith" },
34
+ # duration: 1.5,
35
+ # response_status: 200
36
+ # )
37
+ #
38
+ # @example Security event logging
39
+ # Logger.log_security_event(
40
+ # event_type: "authentication_failure",
41
+ # details: { user_id: "user123", reason: "invalid_api_key" },
42
+ # severity: :high
43
+ # )
44
+ class Logger
45
+ class << self
46
+ # Initialize the logging system with environment-specific configuration
47
+ # Sets up SemanticLogger with appropriate appenders, formatters, and log levels
48
+ #
49
+ # @param environment [String] The application environment (production, staging, development)
50
+ # @return [SemanticLogger::Logger] Configured logger instance
51
+ def setup!(environment = ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development")
52
+ configure_semantic_logger(environment)
53
+ @logger = SemanticLogger["DilisensePepClient"]
54
+ end
55
+
56
+ # Get the configured logger instance, initializing if necessary
57
+ # Uses lazy initialization to ensure logger is configured when first accessed
58
+ #
59
+ # @return [SemanticLogger::Logger] The configured logger instance
60
+ def logger
61
+ @logger ||= setup!
62
+ end
63
+
64
+ # Log API requests for performance monitoring and debugging
65
+ # Creates structured logs with sanitized parameters and error details
66
+ #
67
+ # @param endpoint [String] The API endpoint being called (e.g., "/v1/checkIndividual")
68
+ # @param params [Hash] Request parameters (will be sanitized automatically)
69
+ # @param duration [Float, nil] Request duration in seconds
70
+ # @param response_status [Integer, nil] HTTP response status code
71
+ # @param error [Exception, nil] Error object if request failed
72
+ # @param request_id [String, nil] Unique request identifier (generated if not provided)
73
+ # @return [Hash] The structured log payload
74
+ def log_api_request(endpoint:, params:, duration: nil, response_status: nil, error: nil, request_id: nil)
75
+ payload = {
76
+ event_type: "api_request",
77
+ endpoint: endpoint,
78
+ params: sanitize_params(params), # Remove sensitive data from parameters
79
+ duration_ms: duration&.*(1000)&.round(2), # Convert to milliseconds for easier reading
80
+ response_status: response_status,
81
+ request_id: request_id || generate_request_id,
82
+ timestamp: Time.now.iso8601,
83
+ environment: Rails.env rescue "unknown"
84
+ }
85
+
86
+ if error
87
+ # Include error details for failed requests
88
+ payload[:error] = {
89
+ class: error.class.name,
90
+ message: error.message,
91
+ backtrace: error.backtrace&.first(5) # Limited backtrace for log size management
92
+ }
93
+ logger.error("API request failed", payload)
94
+ else
95
+ logger.info("API request completed", payload)
96
+ end
97
+
98
+ payload
99
+ end
100
+
101
+ def log_screening_event(type:, query:, results_count:, duration: nil, user_id: nil, request_id: nil)
102
+ payload = {
103
+ event_type: "screening_event",
104
+ screening_type: type,
105
+ query_hash: hash_pii(query),
106
+ results_count: results_count,
107
+ duration_ms: duration&.*(1000)&.round(2),
108
+ user_id: user_id,
109
+ request_id: request_id || generate_request_id,
110
+ timestamp: Time.now.iso8601,
111
+ compliance_metadata: {
112
+ retention_category: "pep_screening",
113
+ data_classification: "restricted"
114
+ }
115
+ }
116
+
117
+ logger.info("Screening completed", payload)
118
+ payload
119
+ end
120
+
121
+ def log_configuration_change(config_key:, old_value:, new_value:, user_id: nil)
122
+ payload = {
123
+ event_type: "configuration_change",
124
+ config_key: config_key,
125
+ old_value: sanitize_config_value(old_value),
126
+ new_value: sanitize_config_value(new_value),
127
+ user_id: user_id,
128
+ timestamp: Time.now.iso8601
129
+ }
130
+
131
+ logger.warn("Configuration changed", payload)
132
+ payload
133
+ end
134
+
135
+ def log_security_event(event_type:, details:, severity: :medium, user_id: nil)
136
+ payload = {
137
+ event_type: "security_event",
138
+ security_event_type: event_type,
139
+ severity: severity,
140
+ details: details,
141
+ user_id: user_id,
142
+ timestamp: Time.now.iso8601,
143
+ source_ip: Thread.current[:request_ip]
144
+ }
145
+
146
+ case severity
147
+ when :critical
148
+ logger.fatal("Critical security event", payload)
149
+ when :high
150
+ logger.error("High severity security event", payload)
151
+ when :medium
152
+ logger.warn("Medium severity security event", payload)
153
+ else
154
+ logger.info("Security event", payload)
155
+ end
156
+
157
+ payload
158
+ end
159
+
160
+ private
161
+
162
+ def configure_semantic_logger(environment)
163
+ SemanticLogger.application = "DilisensePepClient"
164
+
165
+ case environment.to_s.downcase
166
+ when "production"
167
+ SemanticLogger.default_level = :info
168
+ SemanticLogger.add_appender(io: $stdout, formatter: :json)
169
+ when "staging"
170
+ SemanticLogger.default_level = :info
171
+ SemanticLogger.add_appender(io: $stdout, formatter: :color)
172
+ else
173
+ SemanticLogger.default_level = :debug
174
+ SemanticLogger.add_appender(io: $stdout, formatter: :color)
175
+ end
176
+ end
177
+
178
+ def sanitize_params(params)
179
+ return {} unless params.is_a?(Hash)
180
+
181
+ params.transform_values do |value|
182
+ case value.to_s.downcase
183
+ when /api_key|token|secret|password/
184
+ "[REDACTED]"
185
+ else
186
+ value.is_a?(String) && value.length > 100 ? "#{value[0..100]}..." : value
187
+ end
188
+ end
189
+ end
190
+
191
+ def sanitize_config_value(value)
192
+ return "[REDACTED]" if value.to_s.match?(/api_key|token|secret|password/i)
193
+ value.to_s.length > 50 ? "#{value.to_s[0..50]}..." : value
194
+ end
195
+
196
+ def hash_pii(data)
197
+ require "digest"
198
+ Digest::SHA256.hexdigest(data.to_s)[0..16]
199
+ end
200
+
201
+ def generate_request_id
202
+ require "securerandom"
203
+ SecureRandom.hex(8)
204
+ end
205
+ end
206
+ end
207
+ end