ecf-dgii 1.0.2 → 1.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 +4 -4
- data/README.md +214 -60
- data/lib/ecf-dgii.rb +2 -1
- data/lib/ecf_dgii/client.rb +300 -152
- data/lib/ecf_dgii/exceptions.rb +29 -0
- data/lib/ecf_dgii/frontend_client.rb +217 -0
- data/lib/ecf_dgii/polling.rb +78 -21
- data/lib/ecf_dgii/railtie.rb +4 -1
- data/lib/ecf_dgii/version.rb +2 -1
- metadata +3 -1
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require_relative "exceptions"
|
|
3
|
+
|
|
4
|
+
module EcfDgii
|
|
5
|
+
# A restricted, read-only client that only exposes GET endpoints.
|
|
6
|
+
# Suitable for use in frontend / browser code where write operations
|
|
7
|
+
# should not be available.
|
|
8
|
+
#
|
|
9
|
+
# Token lifecycle is handled automatically:
|
|
10
|
+
# 1. On each request, checks {get_cached_token}. If nil, calls {get_token}
|
|
11
|
+
# then {cache_token}.
|
|
12
|
+
# 2. On 401 responses, calls {get_token} again, updates the cache, and
|
|
13
|
+
# retries the request.
|
|
14
|
+
#
|
|
15
|
+
# Mirrors the TypeScript {EcfFrontendClient} 1:1.
|
|
16
|
+
#
|
|
17
|
+
# @example Basic usage
|
|
18
|
+
# client = EcfDgii::FrontendClient.new(
|
|
19
|
+
# get_token: -> { my_backend.fetch_token },
|
|
20
|
+
# environment: :test
|
|
21
|
+
# )
|
|
22
|
+
# ecfs = client.query_ecf("131460941", "E310000051630")
|
|
23
|
+
class FrontendClient
|
|
24
|
+
ENVIRONMENT_URLS = {
|
|
25
|
+
test: "https://api.test.ecfx.ssd.com.do",
|
|
26
|
+
cert: "https://api.cert.ecfx.ssd.com.do",
|
|
27
|
+
prod: "https://api.prod.ecfx.ssd.com.do"
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
# @return [EcfDgii::Generated::ApiClient] The underlying generated API client.
|
|
31
|
+
attr_reader :api_client
|
|
32
|
+
|
|
33
|
+
# @return [Symbol] The configured environment.
|
|
34
|
+
attr_reader :environment
|
|
35
|
+
|
|
36
|
+
# Create a new frontend client.
|
|
37
|
+
#
|
|
38
|
+
# @param get_token [Proc] Function that fetches a fresh token
|
|
39
|
+
# (e.g. calls your backend's GET /ecf-token). **Required**.
|
|
40
|
+
# @param cache_token [Proc, nil] Function to cache the token.
|
|
41
|
+
# Defaults to file-based cache at +~/.ecf-dgii/token+.
|
|
42
|
+
# @param get_cached_token [Proc, nil] Function to retrieve a cached token.
|
|
43
|
+
# Defaults to file-based cache.
|
|
44
|
+
# @param base_url [String, nil] Base URL override. Takes precedence over +environment+.
|
|
45
|
+
# @param environment [Symbol] Target environment (+:test+, +:cert+, +:prod+).
|
|
46
|
+
# Defaults to +:test+.
|
|
47
|
+
# @param timeout [Integer] HTTP timeout in seconds. Defaults to 30.
|
|
48
|
+
def initialize(get_token:, cache_token: nil, get_cached_token: nil,
|
|
49
|
+
base_url: nil, environment: :test, timeout: 30)
|
|
50
|
+
raise ArgumentError, "get_token is required for EcfDgii::FrontendClient" unless get_token
|
|
51
|
+
|
|
52
|
+
resolved_url = base_url || ENVIRONMENT_URLS[environment.to_sym]
|
|
53
|
+
raise ArgumentError, "Invalid environment or base URL" if resolved_url.nil? || resolved_url.empty?
|
|
54
|
+
|
|
55
|
+
@get_token = get_token
|
|
56
|
+
@cache_token = cache_token || method(:default_cache_token)
|
|
57
|
+
@get_cached_token = get_cached_token || method(:default_get_cached_token)
|
|
58
|
+
|
|
59
|
+
config = EcfDgii::Generated::Configuration.new
|
|
60
|
+
uri = URI.parse(resolved_url)
|
|
61
|
+
|
|
62
|
+
config.scheme = uri.scheme
|
|
63
|
+
config.host = uri.host
|
|
64
|
+
config.base_path = uri.path.empty? ? "" : uri.path
|
|
65
|
+
config.timeout = timeout
|
|
66
|
+
|
|
67
|
+
@api_client = EcfDgii::Generated::ApiClient.new(config)
|
|
68
|
+
@environment = environment.to_sym
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# Base API clients (lazy-loaded)
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
def ecf_api
|
|
76
|
+
@ecf_api ||= EcfDgii::Generated::EcfApi.new(token_api_client)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def company_api
|
|
80
|
+
@company_api ||= EcfDgii::Generated::CompanyApi.new(token_api_client)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# ECF query operations (read-only GETs)
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
# Query ECFs by RNC and eNCF.
|
|
88
|
+
def query_ecf(rnc, encf, opts = {})
|
|
89
|
+
ecf_api.query_ecf(rnc, encf, opts)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Search ECFs for a specific RNC.
|
|
93
|
+
def search_ecfs(rnc, opts = {})
|
|
94
|
+
ecf_api.search_ecfs(rnc, opts)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Search all ECFs across all companies.
|
|
98
|
+
def search_all_ecfs(opts = {})
|
|
99
|
+
ecf_api.search_all_ecfs(opts)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Get a specific ECF by message ID.
|
|
103
|
+
def get_ecf_by_id(rnc, id)
|
|
104
|
+
ecf_api.get_ecf_by_id(rnc, id)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
# Company operations (read-only GETs)
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
# List companies with optional filters.
|
|
112
|
+
def get_companies(opts = {})
|
|
113
|
+
company_api.get_companies(opts)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Get a company by RNC.
|
|
117
|
+
def get_company_by_rnc(rnc)
|
|
118
|
+
company_api.get_company_by_rnc(rnc)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
# Creates an ApiClient that injects a Bearer token with cache + auto-refresh.
|
|
124
|
+
def token_api_client
|
|
125
|
+
return @token_api_client if @token_api_client
|
|
126
|
+
|
|
127
|
+
config = @api_client.config.dup
|
|
128
|
+
# We'll override the access_token via a custom middleware-like approach.
|
|
129
|
+
# Faraday supports request/response middleware via the builder.
|
|
130
|
+
# Instead of a fixed access_token, we inject it per-request with retry on 401.
|
|
131
|
+
|
|
132
|
+
client = EcfDgii::Generated::ApiClient.new(config)
|
|
133
|
+
|
|
134
|
+
# Store original call method
|
|
135
|
+
original_call = client.method(:call_api)
|
|
136
|
+
|
|
137
|
+
# Define the client's token lifecycle as a singleton method
|
|
138
|
+
client.define_singleton_method(:call_api) do |http_method, path, opts = {}|
|
|
139
|
+
# 1. Get token (from cache or fresh)
|
|
140
|
+
token = get_cached_token_proc.call
|
|
141
|
+
if token.nil? || token.empty?
|
|
142
|
+
token = get_token_proc.call
|
|
143
|
+
cache_token_proc.call(token)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Set the token
|
|
147
|
+
opts[:header_params] ||= {}
|
|
148
|
+
opts[:header_params]["Authorization"] = "Bearer #{token}"
|
|
149
|
+
|
|
150
|
+
# Make the request
|
|
151
|
+
response = original_call.call(http_method, path, opts)
|
|
152
|
+
|
|
153
|
+
# 2. On 401, refresh and retry
|
|
154
|
+
if response.respond_to?(:code) && response.code == 401
|
|
155
|
+
token = get_token_proc.call
|
|
156
|
+
cache_token_proc.call(token)
|
|
157
|
+
opts[:header_params]["Authorization"] = "Bearer #{token}"
|
|
158
|
+
response = original_call.call(http_method, path, opts)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
response
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Store procs for the singleton method closure
|
|
165
|
+
client.instance_variable_set(:@get_token_proc, @get_token)
|
|
166
|
+
client.instance_variable_set(:@cache_token_proc, @cache_token)
|
|
167
|
+
client.instance_variable_set(:@get_cached_token_proc, @get_cached_token)
|
|
168
|
+
|
|
169
|
+
# Define accessors for the closure
|
|
170
|
+
client.define_singleton_method(:get_token_proc) { @get_token_proc }
|
|
171
|
+
client.define_singleton_method(:cache_token_proc) { @cache_token_proc }
|
|
172
|
+
client.define_singleton_method(:get_cached_token_proc) { @get_cached_token_proc }
|
|
173
|
+
|
|
174
|
+
@token_api_client = client
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Default file-based token cache: ~/.ecf-dgii/token
|
|
178
|
+
def default_cache_dir
|
|
179
|
+
@default_cache_dir ||= begin
|
|
180
|
+
dir = File.expand_path("~/.ecf-dgii")
|
|
181
|
+
Dir.mkdir(dir) unless Dir.exist?(dir)
|
|
182
|
+
dir
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def default_cache_token(token)
|
|
187
|
+
File.write(File.join(default_cache_dir, "token"), token)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def default_get_cached_token
|
|
191
|
+
path = File.join(default_cache_dir, "token")
|
|
192
|
+
File.exist?(path) ? File.read(path).strip : nil
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Factory that creates a restricted read-only client suitable for frontend use.
|
|
197
|
+
# Only GET endpoints are exposed.
|
|
198
|
+
#
|
|
199
|
+
# @param get_token [Proc] Function that fetches a fresh token.
|
|
200
|
+
# @param cache_token [Proc, nil] Function to cache the token.
|
|
201
|
+
# @param get_cached_token [Proc, nil] Function to retrieve a cached token.
|
|
202
|
+
# @param base_url [String, nil] Base URL override.
|
|
203
|
+
# @param environment [Symbol] Target environment.
|
|
204
|
+
#
|
|
205
|
+
# @return [EcfDgii::FrontendClient]
|
|
206
|
+
def self.create_frontend_client(get_token:, cache_token: nil, get_cached_token: nil,
|
|
207
|
+
base_url: nil, environment: :test, timeout: 30)
|
|
208
|
+
FrontendClient.new(
|
|
209
|
+
get_token: get_token,
|
|
210
|
+
cache_token: cache_token,
|
|
211
|
+
get_cached_token: get_cached_token,
|
|
212
|
+
base_url: base_url,
|
|
213
|
+
environment: environment,
|
|
214
|
+
timeout: timeout
|
|
215
|
+
)
|
|
216
|
+
end
|
|
217
|
+
end
|
data/lib/ecf_dgii/polling.rb
CHANGED
|
@@ -1,23 +1,60 @@
|
|
|
1
|
-
|
|
2
|
-
class PollingError < StandardError; end
|
|
3
|
-
class PollingTimeoutError < PollingError; end
|
|
4
|
-
class PollingMaxRetriesError < PollingError; end
|
|
1
|
+
require_relative "exceptions"
|
|
5
2
|
|
|
3
|
+
module EcfDgii
|
|
4
|
+
# Configuration options for polling with exponential backoff.
|
|
5
|
+
#
|
|
6
|
+
# Matches the TypeScript SDK's PollingOptions interface 1:1.
|
|
6
7
|
class PollingOptions
|
|
7
|
-
|
|
8
|
+
# @return [Float] Initial delay between polls in seconds. Default: 1.0
|
|
9
|
+
attr_accessor :initial_delay
|
|
10
|
+
|
|
11
|
+
# @return [Float] Maximum delay between polls in seconds. Default: 30.0
|
|
12
|
+
attr_accessor :max_delay
|
|
13
|
+
|
|
14
|
+
# @return [Integer] Maximum number of retries. Default: 60
|
|
15
|
+
attr_accessor :max_retries
|
|
16
|
+
|
|
17
|
+
# @return [Float] Backoff multiplier. Default: 2.0
|
|
18
|
+
attr_accessor :backoff_multiplier
|
|
8
19
|
|
|
9
|
-
|
|
20
|
+
# @return [Float, nil] Total timeout in seconds. Optional (nil = no timeout).
|
|
21
|
+
attr_accessor :timeout
|
|
22
|
+
|
|
23
|
+
# @return [Proc, nil] Optional cancellation callable.
|
|
24
|
+
# Called before each poll iteration. If it returns a truthy value,
|
|
25
|
+
# polling is aborted with PollingTimeoutError.
|
|
26
|
+
attr_accessor :cancellation
|
|
27
|
+
|
|
28
|
+
def initialize(initial_delay: 1.0, max_delay: 30.0, max_retries: 60,
|
|
29
|
+
backoff_multiplier: 2.0, timeout: nil, cancellation: nil)
|
|
10
30
|
@initial_delay = initial_delay
|
|
11
31
|
@max_delay = max_delay
|
|
12
32
|
@max_retries = max_retries
|
|
13
33
|
@backoff_multiplier = backoff_multiplier
|
|
14
34
|
@timeout = timeout
|
|
35
|
+
@cancellation = cancellation
|
|
15
36
|
end
|
|
16
37
|
end
|
|
17
38
|
|
|
39
|
+
# Polling logic with exponential backoff.
|
|
40
|
+
#
|
|
41
|
+
# Usage:
|
|
42
|
+
# EcfDgii::Polling.poll_until_complete do
|
|
43
|
+
# client.query_ecf(rnc, encf)
|
|
44
|
+
# end
|
|
18
45
|
module Polling
|
|
19
|
-
|
|
46
|
+
# Terminal progress values matching the ECF API contract.
|
|
47
|
+
# - "Finished" → ECF processing completed successfully
|
|
48
|
+
# - "Error" → ECF processing failed (throws EcfError in client)
|
|
49
|
+
TERMINAL_PROGRESS = %w[Finished Error].freeze
|
|
20
50
|
|
|
51
|
+
# Poll a block until its result indicates completion, using exponential backoff.
|
|
52
|
+
#
|
|
53
|
+
# @yieldreturn [Object] An object that responds to #progress
|
|
54
|
+
# @param options [PollingOptions, nil] Polling configuration
|
|
55
|
+
# @return [Object] The final result when progress is terminal
|
|
56
|
+
# @raise [PollingTimeoutError] If total timeout is exceeded
|
|
57
|
+
# @raise [PollingMaxRetriesError] If max retries is exceeded
|
|
21
58
|
def self.poll_until_complete(options = nil)
|
|
22
59
|
opts = options || PollingOptions.new
|
|
23
60
|
delay = opts.initial_delay
|
|
@@ -25,32 +62,52 @@ module EcfDgii
|
|
|
25
62
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
26
63
|
|
|
27
64
|
loop do
|
|
65
|
+
# Check cancellation before each iteration
|
|
66
|
+
if opts.cancellation && opts.cancellation.call
|
|
67
|
+
raise PollingTimeoutError, "Polling was cancelled"
|
|
68
|
+
end
|
|
69
|
+
|
|
28
70
|
result = yield
|
|
29
71
|
|
|
30
|
-
progress =
|
|
31
|
-
if result.respond_to?(:progress)
|
|
32
|
-
progress = result.progress
|
|
33
|
-
elsif result.is_a?(Hash)
|
|
34
|
-
progress = result[:progress] || result["progress"]
|
|
35
|
-
end
|
|
72
|
+
progress = extract_progress(result)
|
|
36
73
|
|
|
37
|
-
|
|
74
|
+
return result if TERMINAL_PROGRESS.include?(progress)
|
|
38
75
|
|
|
39
|
-
|
|
76
|
+
retries += 1
|
|
40
77
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
raise PollingTimeoutError, "El polling excedió el tiempo límite de #{opts.timeout}s (último progreso: #{progress_value})"
|
|
78
|
+
if opts.max_retries > 0 && retries >= opts.max_retries
|
|
79
|
+
raise PollingMaxRetriesError.new(opts.max_retries)
|
|
44
80
|
end
|
|
45
81
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
82
|
+
if opts.timeout && opts.timeout > 0
|
|
83
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
84
|
+
if elapsed >= opts.timeout
|
|
85
|
+
raise PollingTimeoutError,
|
|
86
|
+
"Polling timed out after #{opts.timeout}s (last progress: #{progress})"
|
|
87
|
+
end
|
|
49
88
|
end
|
|
50
89
|
|
|
51
90
|
sleep(delay)
|
|
52
91
|
delay = [delay * opts.backoff_multiplier, opts.max_delay].min
|
|
53
92
|
end
|
|
54
93
|
end
|
|
94
|
+
|
|
95
|
+
# Extract the progress value from a result, regardless of its type.
|
|
96
|
+
#
|
|
97
|
+
# @param result [Object] An API model object, Hash, or anything responding to #progress
|
|
98
|
+
# @return [String] The progress value as a string
|
|
99
|
+
def self.extract_progress(result)
|
|
100
|
+
progress = nil
|
|
101
|
+
if result.respond_to?(:progress)
|
|
102
|
+
progress = result.progress
|
|
103
|
+
elsif result.is_a?(Hash)
|
|
104
|
+
progress = result[:progress] || result["progress"]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
progress = progress.value if progress.respond_to?(:value)
|
|
108
|
+
progress.to_s
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private_class_method :extract_progress
|
|
55
112
|
end
|
|
56
113
|
end
|
data/lib/ecf_dgii/railtie.rb
CHANGED
data/lib/ecf_dgii/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ecf-dgii
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- SSD Smart Software Development SRL
|
|
@@ -143,6 +143,8 @@ files:
|
|
|
143
143
|
- README.md
|
|
144
144
|
- lib/ecf-dgii.rb
|
|
145
145
|
- lib/ecf_dgii/client.rb
|
|
146
|
+
- lib/ecf_dgii/exceptions.rb
|
|
147
|
+
- lib/ecf_dgii/frontend_client.rb
|
|
146
148
|
- lib/ecf_dgii/generated.rb
|
|
147
149
|
- lib/ecf_dgii/generated/api/api_key_api.rb
|
|
148
150
|
- lib/ecf_dgii/generated/api/aprobacion_comercial_api.rb
|