decision_agent 0.2.0 → 1.0.1
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 +4 -4
- data/README.md +313 -8
- data/bin/decision_agent +104 -0
- data/lib/decision_agent/agent.rb +72 -1
- data/lib/decision_agent/context.rb +1 -0
- data/lib/decision_agent/data_enrichment/cache/memory_adapter.rb +86 -0
- data/lib/decision_agent/data_enrichment/cache_adapter.rb +49 -0
- data/lib/decision_agent/data_enrichment/circuit_breaker.rb +135 -0
- data/lib/decision_agent/data_enrichment/client.rb +220 -0
- data/lib/decision_agent/data_enrichment/config.rb +78 -0
- data/lib/decision_agent/data_enrichment/errors.rb +36 -0
- data/lib/decision_agent/decision.rb +102 -2
- data/lib/decision_agent/dmn/adapter.rb +135 -0
- data/lib/decision_agent/dmn/cache.rb +306 -0
- data/lib/decision_agent/dmn/decision_graph.rb +327 -0
- data/lib/decision_agent/dmn/decision_tree.rb +192 -0
- data/lib/decision_agent/dmn/errors.rb +30 -0
- data/lib/decision_agent/dmn/exporter.rb +217 -0
- data/lib/decision_agent/dmn/feel/evaluator.rb +819 -0
- data/lib/decision_agent/dmn/feel/functions.rb +420 -0
- data/lib/decision_agent/dmn/feel/parser.rb +349 -0
- data/lib/decision_agent/dmn/feel/simple_parser.rb +276 -0
- data/lib/decision_agent/dmn/feel/transformer.rb +372 -0
- data/lib/decision_agent/dmn/feel/types.rb +276 -0
- data/lib/decision_agent/dmn/importer.rb +77 -0
- data/lib/decision_agent/dmn/model.rb +197 -0
- data/lib/decision_agent/dmn/parser.rb +191 -0
- data/lib/decision_agent/dmn/testing.rb +333 -0
- data/lib/decision_agent/dmn/validator.rb +315 -0
- data/lib/decision_agent/dmn/versioning.rb +229 -0
- data/lib/decision_agent/dmn/visualizer.rb +513 -0
- data/lib/decision_agent/dsl/condition_evaluator.rb +984 -838
- data/lib/decision_agent/dsl/schema_validator.rb +53 -14
- data/lib/decision_agent/evaluators/dmn_evaluator.rb +308 -0
- data/lib/decision_agent/evaluators/json_rule_evaluator.rb +69 -9
- data/lib/decision_agent/explainability/condition_trace.rb +83 -0
- data/lib/decision_agent/explainability/explainability_result.rb +52 -0
- data/lib/decision_agent/explainability/rule_trace.rb +39 -0
- data/lib/decision_agent/explainability/trace_collector.rb +24 -0
- data/lib/decision_agent/monitoring/alert_manager.rb +5 -1
- data/lib/decision_agent/simulation/errors.rb +18 -0
- data/lib/decision_agent/simulation/impact_analyzer.rb +498 -0
- data/lib/decision_agent/simulation/monte_carlo_simulator.rb +635 -0
- data/lib/decision_agent/simulation/replay_engine.rb +486 -0
- data/lib/decision_agent/simulation/scenario_engine.rb +318 -0
- data/lib/decision_agent/simulation/scenario_library.rb +163 -0
- data/lib/decision_agent/simulation/shadow_test_engine.rb +287 -0
- data/lib/decision_agent/simulation/what_if_analyzer.rb +1002 -0
- data/lib/decision_agent/simulation.rb +17 -0
- data/lib/decision_agent/version.rb +1 -1
- data/lib/decision_agent/versioning/activerecord_adapter.rb +23 -8
- data/lib/decision_agent/web/dmn_editor.rb +426 -0
- data/lib/decision_agent/web/public/app.js +119 -0
- data/lib/decision_agent/web/public/dmn-editor.css +596 -0
- data/lib/decision_agent/web/public/dmn-editor.html +250 -0
- data/lib/decision_agent/web/public/dmn-editor.js +553 -0
- data/lib/decision_agent/web/public/index.html +52 -0
- data/lib/decision_agent/web/public/simulation.html +130 -0
- data/lib/decision_agent/web/public/simulation_impact.html +478 -0
- data/lib/decision_agent/web/public/simulation_replay.html +551 -0
- data/lib/decision_agent/web/public/simulation_shadow.html +546 -0
- data/lib/decision_agent/web/public/simulation_whatif.html +532 -0
- data/lib/decision_agent/web/public/styles.css +86 -0
- data/lib/decision_agent/web/server.rb +1059 -23
- data/lib/decision_agent.rb +60 -2
- metadata +105 -61
- data/spec/ab_testing/ab_test_assignment_spec.rb +0 -253
- data/spec/ab_testing/ab_test_manager_spec.rb +0 -612
- data/spec/ab_testing/ab_test_spec.rb +0 -270
- data/spec/ab_testing/ab_testing_agent_spec.rb +0 -481
- data/spec/ab_testing/storage/adapter_spec.rb +0 -64
- data/spec/ab_testing/storage/memory_adapter_spec.rb +0 -485
- data/spec/activerecord_thread_safety_spec.rb +0 -553
- data/spec/advanced_operators_spec.rb +0 -3150
- data/spec/agent_spec.rb +0 -289
- data/spec/api_contract_spec.rb +0 -430
- data/spec/audit_adapters_spec.rb +0 -92
- data/spec/auth/access_audit_logger_spec.rb +0 -394
- data/spec/auth/authenticator_spec.rb +0 -112
- data/spec/auth/password_reset_spec.rb +0 -294
- data/spec/auth/permission_checker_spec.rb +0 -207
- data/spec/auth/permission_spec.rb +0 -73
- data/spec/auth/rbac_adapter_spec.rb +0 -550
- data/spec/auth/rbac_config_spec.rb +0 -82
- data/spec/auth/role_spec.rb +0 -51
- data/spec/auth/session_manager_spec.rb +0 -172
- data/spec/auth/session_spec.rb +0 -112
- data/spec/auth/user_spec.rb +0 -130
- data/spec/comprehensive_edge_cases_spec.rb +0 -1777
- data/spec/context_spec.rb +0 -127
- data/spec/decision_agent_spec.rb +0 -96
- data/spec/decision_spec.rb +0 -423
- data/spec/dsl/condition_evaluator_spec.rb +0 -774
- data/spec/dsl_validation_spec.rb +0 -648
- data/spec/edge_cases_spec.rb +0 -353
- data/spec/evaluation_spec.rb +0 -364
- data/spec/evaluation_validator_spec.rb +0 -165
- data/spec/examples/feedback_aware_evaluator_spec.rb +0 -460
- data/spec/examples.txt +0 -1633
- data/spec/issue_verification_spec.rb +0 -759
- data/spec/json_rule_evaluator_spec.rb +0 -587
- data/spec/monitoring/alert_manager_spec.rb +0 -378
- data/spec/monitoring/metrics_collector_spec.rb +0 -499
- data/spec/monitoring/monitored_agent_spec.rb +0 -222
- data/spec/monitoring/prometheus_exporter_spec.rb +0 -242
- data/spec/monitoring/storage/activerecord_adapter_spec.rb +0 -498
- data/spec/monitoring/storage/base_adapter_spec.rb +0 -61
- data/spec/monitoring/storage/memory_adapter_spec.rb +0 -247
- data/spec/performance_optimizations_spec.rb +0 -486
- data/spec/replay_edge_cases_spec.rb +0 -699
- data/spec/replay_spec.rb +0 -210
- data/spec/rfc8785_canonicalization_spec.rb +0 -215
- data/spec/scoring_spec.rb +0 -225
- data/spec/spec_helper.rb +0 -60
- data/spec/testing/batch_test_importer_spec.rb +0 -693
- data/spec/testing/batch_test_runner_spec.rb +0 -307
- data/spec/testing/test_coverage_analyzer_spec.rb +0 -292
- data/spec/testing/test_result_comparator_spec.rb +0 -392
- data/spec/testing/test_scenario_spec.rb +0 -113
- data/spec/thread_safety_spec.rb +0 -482
- data/spec/thread_safety_spec.rb.broken +0 -878
- data/spec/versioning/adapter_spec.rb +0 -156
- data/spec/versioning_spec.rb +0 -1030
- data/spec/web/middleware/auth_middleware_spec.rb +0 -133
- data/spec/web/middleware/permission_middleware_spec.rb +0 -247
- data/spec/web_ui_rack_spec.rb +0 -1840
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../cache_adapter"
|
|
4
|
+
require "monitor"
|
|
5
|
+
|
|
6
|
+
module DecisionAgent
|
|
7
|
+
module DataEnrichment
|
|
8
|
+
module Cache
|
|
9
|
+
# In-memory cache adapter (default, no dependencies)
|
|
10
|
+
class MemoryAdapter < CacheAdapter
|
|
11
|
+
include MonitorMixin
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
super
|
|
15
|
+
@cache = {}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Get cached value
|
|
19
|
+
#
|
|
20
|
+
# @param key [String] Cache key
|
|
21
|
+
# @return [Hash, nil] Cached data or nil if not found/expired
|
|
22
|
+
def get(key)
|
|
23
|
+
synchronize do
|
|
24
|
+
entry = @cache[key]
|
|
25
|
+
return nil unless entry
|
|
26
|
+
|
|
27
|
+
# Check if expired
|
|
28
|
+
if entry[:expires_at] < Time.now
|
|
29
|
+
@cache.delete(key)
|
|
30
|
+
return nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
entry[:value]
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Set cached value
|
|
38
|
+
#
|
|
39
|
+
# @param key [String] Cache key
|
|
40
|
+
# @param value [Hash] Data to cache
|
|
41
|
+
# @param ttl [Integer] Time to live in seconds
|
|
42
|
+
def set(key, value, ttl)
|
|
43
|
+
synchronize do
|
|
44
|
+
@cache[key] = {
|
|
45
|
+
value: value,
|
|
46
|
+
expires_at: Time.now + ttl
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Delete cached value
|
|
52
|
+
#
|
|
53
|
+
# @param key [String] Cache key
|
|
54
|
+
def delete(key)
|
|
55
|
+
synchronize do
|
|
56
|
+
@cache.delete(key)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Clear all cached values
|
|
61
|
+
def clear
|
|
62
|
+
synchronize do
|
|
63
|
+
@cache.clear
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Get cache statistics
|
|
68
|
+
#
|
|
69
|
+
# @return [Hash] Cache statistics
|
|
70
|
+
def stats
|
|
71
|
+
synchronize do
|
|
72
|
+
now = Time.now
|
|
73
|
+
valid_entries = @cache.count { |_k, v| v[:expires_at] >= now }
|
|
74
|
+
expired_entries = @cache.size - valid_entries
|
|
75
|
+
|
|
76
|
+
{
|
|
77
|
+
size: @cache.size,
|
|
78
|
+
valid: valid_entries,
|
|
79
|
+
expired: expired_entries
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DecisionAgent
|
|
4
|
+
module DataEnrichment
|
|
5
|
+
# Base class for cache adapters
|
|
6
|
+
class CacheAdapter
|
|
7
|
+
# Get cached value
|
|
8
|
+
#
|
|
9
|
+
# @param key [String] Cache key
|
|
10
|
+
# @return [Hash, nil] Cached data or nil if not found/expired
|
|
11
|
+
def get(key)
|
|
12
|
+
raise NotImplementedError, "#{self.class} must implement #get"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Set cached value
|
|
16
|
+
#
|
|
17
|
+
# @param key [String] Cache key
|
|
18
|
+
# @param value [Hash] Data to cache
|
|
19
|
+
# @param ttl [Integer] Time to live in seconds
|
|
20
|
+
def set(key, value, ttl)
|
|
21
|
+
raise NotImplementedError, "#{self.class} must implement #set"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Delete cached value
|
|
25
|
+
#
|
|
26
|
+
# @param key [String] Cache key
|
|
27
|
+
def delete(key)
|
|
28
|
+
raise NotImplementedError, "#{self.class} must implement #delete"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Clear all cached values
|
|
32
|
+
def clear
|
|
33
|
+
raise NotImplementedError, "#{self.class} must implement #clear"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Generate cache key from request parameters
|
|
37
|
+
#
|
|
38
|
+
# @param endpoint_name [Symbol] Endpoint identifier
|
|
39
|
+
# @param params [Hash] Request parameters
|
|
40
|
+
# @return [String] Cache key
|
|
41
|
+
def cache_key(endpoint_name, params)
|
|
42
|
+
# Sort params for consistent key generation
|
|
43
|
+
sorted_params = params.sort.to_h
|
|
44
|
+
param_string = sorted_params.map { |k, v| "#{k}=#{v}" }.join("&")
|
|
45
|
+
"#{endpoint_name}:#{Digest::SHA256.hexdigest(param_string)}"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
module DecisionAgent
|
|
6
|
+
module DataEnrichment
|
|
7
|
+
# Circuit breaker pattern for resilience
|
|
8
|
+
#
|
|
9
|
+
# Prevents cascading failures by opening the circuit after N failures
|
|
10
|
+
class CircuitBreaker
|
|
11
|
+
include MonitorMixin
|
|
12
|
+
|
|
13
|
+
# Circuit states
|
|
14
|
+
CLOSED = :closed # Normal operation
|
|
15
|
+
OPEN = :open # Circuit is open, failing fast
|
|
16
|
+
HALF_OPEN = :half_open # Testing if service recovered
|
|
17
|
+
|
|
18
|
+
def initialize(failure_threshold: 5, timeout: 60, success_threshold: 2)
|
|
19
|
+
super()
|
|
20
|
+
@failure_threshold = failure_threshold
|
|
21
|
+
@timeout = timeout
|
|
22
|
+
@success_threshold = success_threshold
|
|
23
|
+
@state = CLOSED
|
|
24
|
+
@failure_count = 0
|
|
25
|
+
@success_count = 0
|
|
26
|
+
@last_failure_time = nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Execute block with circuit breaker protection
|
|
30
|
+
#
|
|
31
|
+
# @yield Block to execute
|
|
32
|
+
# @return [Object] Result of block execution
|
|
33
|
+
# @raise [CircuitOpenError] If circuit is open
|
|
34
|
+
def call
|
|
35
|
+
synchronize do
|
|
36
|
+
check_state
|
|
37
|
+
raise CircuitOpenError, "Circuit is open" if @state == OPEN
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
begin
|
|
41
|
+
result = yield
|
|
42
|
+
record_success
|
|
43
|
+
result
|
|
44
|
+
rescue StandardError => e
|
|
45
|
+
record_failure
|
|
46
|
+
raise e
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Check if circuit is open
|
|
51
|
+
#
|
|
52
|
+
# @return [Boolean]
|
|
53
|
+
def open?
|
|
54
|
+
synchronize do
|
|
55
|
+
check_state
|
|
56
|
+
@state == OPEN
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Reset circuit breaker to closed state
|
|
61
|
+
def reset
|
|
62
|
+
synchronize do
|
|
63
|
+
@state = CLOSED
|
|
64
|
+
@failure_count = 0
|
|
65
|
+
@success_count = 0
|
|
66
|
+
@last_failure_time = nil
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Get current state
|
|
71
|
+
#
|
|
72
|
+
# @return [Symbol] Current state
|
|
73
|
+
def state
|
|
74
|
+
synchronize do
|
|
75
|
+
check_state
|
|
76
|
+
@state
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def check_state
|
|
83
|
+
case @state
|
|
84
|
+
when OPEN
|
|
85
|
+
# Check if timeout has elapsed, move to half-open
|
|
86
|
+
if @last_failure_time && (Time.now - @last_failure_time) >= @timeout
|
|
87
|
+
@state = HALF_OPEN
|
|
88
|
+
@success_count = 0
|
|
89
|
+
end
|
|
90
|
+
when HALF_OPEN
|
|
91
|
+
# State remains half-open until success threshold is met
|
|
92
|
+
when CLOSED
|
|
93
|
+
# State remains closed until failure threshold is met
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def record_success
|
|
98
|
+
synchronize do
|
|
99
|
+
case @state
|
|
100
|
+
when HALF_OPEN
|
|
101
|
+
@success_count += 1
|
|
102
|
+
if @success_count >= @success_threshold
|
|
103
|
+
@state = CLOSED
|
|
104
|
+
@failure_count = 0
|
|
105
|
+
@success_count = 0
|
|
106
|
+
end
|
|
107
|
+
when CLOSED
|
|
108
|
+
@failure_count = 0 # Reset failure count on success
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def record_failure
|
|
114
|
+
synchronize do
|
|
115
|
+
@failure_count += 1
|
|
116
|
+
@last_failure_time = Time.now
|
|
117
|
+
|
|
118
|
+
case @state
|
|
119
|
+
when HALF_OPEN
|
|
120
|
+
# Failures in half-open state immediately open the circuit
|
|
121
|
+
@state = OPEN
|
|
122
|
+
@success_count = 0
|
|
123
|
+
when CLOSED
|
|
124
|
+
# Open circuit if failure threshold is met
|
|
125
|
+
@state = OPEN if @failure_count >= @failure_threshold
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Error raised when circuit is open
|
|
131
|
+
class CircuitOpenError < StandardError
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
require "base64"
|
|
7
|
+
require_relative "cache_adapter"
|
|
8
|
+
require_relative "cache/memory_adapter"
|
|
9
|
+
require_relative "circuit_breaker"
|
|
10
|
+
require_relative "errors"
|
|
11
|
+
|
|
12
|
+
module DecisionAgent
|
|
13
|
+
module DataEnrichment
|
|
14
|
+
# HTTP client for data enrichment requests
|
|
15
|
+
class Client
|
|
16
|
+
# Error classes
|
|
17
|
+
class RequestError < StandardError
|
|
18
|
+
attr_reader :status_code, :response_body
|
|
19
|
+
|
|
20
|
+
def initialize(message, status_code: nil, response_body: nil)
|
|
21
|
+
super(message)
|
|
22
|
+
@status_code = status_code
|
|
23
|
+
@response_body = response_body
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class TimeoutError < RequestError
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class NetworkError < RequestError
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
attr_reader :config, :cache_adapter, :circuit_breaker
|
|
34
|
+
|
|
35
|
+
def initialize(config:, cache_adapter: nil, circuit_breaker: nil)
|
|
36
|
+
@config = config
|
|
37
|
+
@cache_adapter = cache_adapter || Cache::MemoryAdapter.new
|
|
38
|
+
@circuit_breakers = {}
|
|
39
|
+
@circuit_breaker_default = circuit_breaker || CircuitBreaker.new
|
|
40
|
+
@circuit_breaker = @circuit_breaker_default
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Fetch data from configured endpoint
|
|
44
|
+
#
|
|
45
|
+
# @param endpoint_name [Symbol] Endpoint identifier
|
|
46
|
+
# @param params [Hash] Request parameters
|
|
47
|
+
# @param use_cache [Boolean] Whether to use cache (default: true)
|
|
48
|
+
# @return [Hash] Response data
|
|
49
|
+
# @raise [RequestError] If request fails
|
|
50
|
+
def fetch(endpoint_name, params: {}, use_cache: true)
|
|
51
|
+
endpoint_config = @config.endpoint(endpoint_name)
|
|
52
|
+
raise ArgumentError, "Unknown endpoint: #{endpoint_name}" unless endpoint_config
|
|
53
|
+
|
|
54
|
+
# Generate cache key
|
|
55
|
+
cache_key = @cache_adapter.cache_key(endpoint_name, params)
|
|
56
|
+
|
|
57
|
+
# Check cache first
|
|
58
|
+
if use_cache
|
|
59
|
+
cached = @cache_adapter.get(cache_key)
|
|
60
|
+
return cached if cached
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Execute request with circuit breaker
|
|
64
|
+
circuit_breaker = get_circuit_breaker(endpoint_name)
|
|
65
|
+
begin
|
|
66
|
+
response_data = circuit_breaker.call do
|
|
67
|
+
execute_request(endpoint_name, endpoint_config, params)
|
|
68
|
+
end
|
|
69
|
+
rescue CircuitBreaker::CircuitOpenError => e
|
|
70
|
+
# Try to return cached data on circuit open
|
|
71
|
+
cached = @cache_adapter.get(cache_key) if use_cache
|
|
72
|
+
return cached if cached
|
|
73
|
+
|
|
74
|
+
raise RequestError, "Circuit breaker is open for #{endpoint_name}: #{e.message}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Cache response
|
|
78
|
+
@cache_adapter.set(cache_key, response_data, endpoint_config[:cache][:ttl]) if use_cache && endpoint_config[:cache][:ttl].positive?
|
|
79
|
+
|
|
80
|
+
response_data
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Clear cache for endpoint
|
|
84
|
+
#
|
|
85
|
+
# @param endpoint_name [Symbol, nil] Endpoint identifier, or nil to clear all
|
|
86
|
+
def clear_cache(endpoint_name = nil)
|
|
87
|
+
if endpoint_name
|
|
88
|
+
# Clear cache entries for this endpoint (requires cache adapter support)
|
|
89
|
+
# For now, just clear all if endpoint-specific clearing isn't supported
|
|
90
|
+
end
|
|
91
|
+
@cache_adapter.clear
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def execute_request(endpoint_name, endpoint_config, params)
|
|
97
|
+
uri = URI(endpoint_config[:url])
|
|
98
|
+
method = endpoint_config[:method]
|
|
99
|
+
timeout = endpoint_config[:timeout]
|
|
100
|
+
|
|
101
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
102
|
+
http.use_ssl = (uri.scheme == "https")
|
|
103
|
+
http.read_timeout = timeout
|
|
104
|
+
http.open_timeout = timeout
|
|
105
|
+
|
|
106
|
+
request = build_request(uri, method, endpoint_config, params)
|
|
107
|
+
|
|
108
|
+
response = http.request(request)
|
|
109
|
+
|
|
110
|
+
handle_response(response, endpoint_name)
|
|
111
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
112
|
+
raise TimeoutError, "Request timeout for #{endpoint_name}: #{e.message}"
|
|
113
|
+
rescue StandardError => e
|
|
114
|
+
raise NetworkError, "Network error for #{endpoint_name}: #{e.message}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def build_request(uri, method, endpoint_config, params)
|
|
118
|
+
headers = endpoint_config[:headers].dup || {}
|
|
119
|
+
apply_auth(headers, endpoint_config[:auth])
|
|
120
|
+
|
|
121
|
+
case method
|
|
122
|
+
when :get
|
|
123
|
+
# Add params to query string for GET requests
|
|
124
|
+
if params.any?
|
|
125
|
+
query_string = URI.encode_www_form(params)
|
|
126
|
+
uri_with_query = URI("#{uri}?#{query_string}")
|
|
127
|
+
request = Net::HTTP::Get.new(uri_with_query)
|
|
128
|
+
else
|
|
129
|
+
request = Net::HTTP::Get.new(uri)
|
|
130
|
+
end
|
|
131
|
+
when :post
|
|
132
|
+
request = Net::HTTP::Post.new(uri)
|
|
133
|
+
request.body = params.to_json
|
|
134
|
+
headers["Content-Type"] ||= "application/json"
|
|
135
|
+
when :put
|
|
136
|
+
request = Net::HTTP::Put.new(uri)
|
|
137
|
+
request.body = params.to_json
|
|
138
|
+
headers["Content-Type"] ||= "application/json"
|
|
139
|
+
when :delete
|
|
140
|
+
request = Net::HTTP::Delete.new(uri)
|
|
141
|
+
else
|
|
142
|
+
raise ArgumentError, "Unsupported HTTP method: #{method}"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
headers.each { |k, v| request[k] = v }
|
|
146
|
+
request
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def apply_auth(headers, auth_config)
|
|
150
|
+
return unless auth_config
|
|
151
|
+
|
|
152
|
+
case auth_config[:type]
|
|
153
|
+
when :api_key
|
|
154
|
+
header_name = auth_config[:header] || "X-API-Key"
|
|
155
|
+
api_key = get_secret(auth_config[:secret_key] || "API_KEY")
|
|
156
|
+
headers[header_name] = api_key
|
|
157
|
+
when :basic
|
|
158
|
+
username = get_secret(auth_config[:username_key] || "USERNAME")
|
|
159
|
+
password = get_secret(auth_config[:password_key] || "PASSWORD")
|
|
160
|
+
credentials = Base64.strict_encode64("#{username}:#{password}")
|
|
161
|
+
headers["Authorization"] = "Basic #{credentials}"
|
|
162
|
+
when :bearer
|
|
163
|
+
token = get_secret(auth_config[:token_key] || "TOKEN")
|
|
164
|
+
headers["Authorization"] = "Bearer #{token}"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def get_secret(key)
|
|
169
|
+
# Try environment variable first
|
|
170
|
+
return ENV[key] if ENV.key?(key)
|
|
171
|
+
return ENV[key.upcase] if ENV.key?(key.upcase)
|
|
172
|
+
|
|
173
|
+
# Could extend to support vault integration here
|
|
174
|
+
raise ArgumentError, "Secret not found: #{key}. Set environment variable #{key.upcase}"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def handle_response(response, endpoint_name)
|
|
178
|
+
case response.code.to_i
|
|
179
|
+
when 200..299
|
|
180
|
+
parse_response(response)
|
|
181
|
+
when 400..499
|
|
182
|
+
raise RequestError.new(
|
|
183
|
+
"Client error for #{endpoint_name}: HTTP #{response.code}",
|
|
184
|
+
status_code: response.code.to_i,
|
|
185
|
+
response_body: response.body
|
|
186
|
+
)
|
|
187
|
+
when 500..599
|
|
188
|
+
raise RequestError.new(
|
|
189
|
+
"Server error for #{endpoint_name}: HTTP #{response.code}",
|
|
190
|
+
status_code: response.code.to_i,
|
|
191
|
+
response_body: response.body
|
|
192
|
+
)
|
|
193
|
+
else
|
|
194
|
+
raise RequestError.new(
|
|
195
|
+
"Unexpected response for #{endpoint_name}: HTTP #{response.code}",
|
|
196
|
+
status_code: response.code.to_i,
|
|
197
|
+
response_body: response.body
|
|
198
|
+
)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def parse_response(response)
|
|
203
|
+
content_type = response["Content-Type"] || ""
|
|
204
|
+
body = response.body
|
|
205
|
+
|
|
206
|
+
return {} if body.nil? || body.empty?
|
|
207
|
+
|
|
208
|
+
if content_type.include?("application/json")
|
|
209
|
+
JSON.parse(body, symbolize_names: true)
|
|
210
|
+
else
|
|
211
|
+
{ body: body, content_type: content_type }
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def get_circuit_breaker(endpoint_name)
|
|
216
|
+
@circuit_breakers[endpoint_name] ||= @circuit_breaker_default.dup
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DecisionAgent
|
|
4
|
+
module DataEnrichment
|
|
5
|
+
# Configuration for data enrichment endpoints
|
|
6
|
+
class Config
|
|
7
|
+
attr_accessor :endpoints, :default_timeout, :default_retry, :default_cache
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@endpoints = {}
|
|
11
|
+
@default_timeout = 5 # seconds
|
|
12
|
+
@default_retry = { max_attempts: 3, backoff: :exponential }
|
|
13
|
+
@default_cache = { ttl: 3600, adapter: :memory }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Add or update an endpoint configuration
|
|
17
|
+
#
|
|
18
|
+
# @param name [Symbol] Endpoint identifier
|
|
19
|
+
# @param url [String] Base URL for the endpoint
|
|
20
|
+
# @param method [Symbol] HTTP method (:get, :post, :put, :delete)
|
|
21
|
+
# @param auth [Hash] Authentication configuration
|
|
22
|
+
# @param cache [Hash] Cache configuration
|
|
23
|
+
# @param retry_config [Hash] Retry configuration
|
|
24
|
+
# @param timeout [Integer] Request timeout in seconds
|
|
25
|
+
# @param headers [Hash] Default headers
|
|
26
|
+
# @param rate_limit [Hash] Rate limiting configuration
|
|
27
|
+
#
|
|
28
|
+
# @example
|
|
29
|
+
# config.add_endpoint(:credit_bureau,
|
|
30
|
+
# url: "https://api.creditbureau.com/v1/score",
|
|
31
|
+
# method: :post,
|
|
32
|
+
# auth: { type: :api_key, header: "X-API-Key" },
|
|
33
|
+
# cache: { ttl: 3600, adapter: :redis },
|
|
34
|
+
# retry_config: { max_attempts: 3, backoff: :exponential }
|
|
35
|
+
# )
|
|
36
|
+
def add_endpoint(name, url:, method: :get, auth: nil, cache: nil, retry_config: nil, timeout: nil, headers: {}, rate_limit: nil) # rubocop:disable Metrics/ParameterLists
|
|
37
|
+
@endpoints[name.to_sym] = {
|
|
38
|
+
url: url,
|
|
39
|
+
method: method.to_sym,
|
|
40
|
+
auth: auth,
|
|
41
|
+
cache: cache || @default_cache.dup,
|
|
42
|
+
retry: retry_config || @default_retry.dup,
|
|
43
|
+
timeout: timeout || @default_timeout,
|
|
44
|
+
headers: headers,
|
|
45
|
+
rate_limit: rate_limit
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Get endpoint configuration
|
|
50
|
+
#
|
|
51
|
+
# @param name [Symbol] Endpoint identifier
|
|
52
|
+
# @return [Hash, nil] Endpoint configuration or nil if not found
|
|
53
|
+
def endpoint(name)
|
|
54
|
+
@endpoints[name.to_sym]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Check if endpoint exists
|
|
58
|
+
#
|
|
59
|
+
# @param name [Symbol] Endpoint identifier
|
|
60
|
+
# @return [Boolean]
|
|
61
|
+
def endpoint?(name)
|
|
62
|
+
@endpoints.key?(name.to_sym)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Remove endpoint configuration
|
|
66
|
+
#
|
|
67
|
+
# @param name [Symbol] Endpoint identifier
|
|
68
|
+
def remove_endpoint(name)
|
|
69
|
+
@endpoints.delete(name.to_sym)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Clear all endpoint configurations
|
|
73
|
+
def clear
|
|
74
|
+
@endpoints.clear
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DecisionAgent
|
|
4
|
+
module DataEnrichment
|
|
5
|
+
# Base error class for data enrichment
|
|
6
|
+
class Error < StandardError
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# Error raised when endpoint is not configured
|
|
10
|
+
class EndpointNotFoundError < Error
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Error raised when request fails
|
|
14
|
+
class RequestError < Error
|
|
15
|
+
attr_reader :status_code, :response_body
|
|
16
|
+
|
|
17
|
+
def initialize(message, status_code: nil, response_body: nil)
|
|
18
|
+
super(message)
|
|
19
|
+
@status_code = status_code
|
|
20
|
+
@response_body = response_body
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Error raised when request times out
|
|
25
|
+
class TimeoutError < RequestError
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Error raised when network error occurs
|
|
29
|
+
class NetworkError < RequestError
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Error raised when circuit breaker is open
|
|
33
|
+
class CircuitOpenError < Error
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|