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.
@@ -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
@@ -1,23 +1,60 @@
1
- module EcfDgii
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
- attr_accessor :initial_delay, :max_delay, :max_retries, :backoff_multiplier, :timeout
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
- def initialize(initial_delay: 2.0, max_delay: 30.0, max_retries: 0, backoff_multiplier: 1.5, timeout: 300.0)
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
- TERMINAL_PROGRESS = %w[Completed Failed Rejected].freeze
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 = nil
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
- progress_value = progress.respond_to?(:value) ? progress.value : progress.to_s
74
+ return result if TERMINAL_PROGRESS.include?(progress)
38
75
 
39
- return result if TERMINAL_PROGRESS.include?(progress_value)
76
+ retries += 1
40
77
 
41
- elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
42
- if opts.timeout && opts.timeout > 0 && elapsed >= opts.timeout
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
- retries += 1
47
- if opts.max_retries && opts.max_retries > 0 && retries >= opts.max_retries
48
- raise PollingMaxRetriesError, "El polling excedió el máximo de #{opts.max_retries} intentos (último progreso: #{progress_value})"
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
@@ -1,5 +1,8 @@
1
1
  module EcfDgii
2
2
  class Railtie < Rails::Railtie
3
- # The integration works through initializers generated by the user.
3
+ # Make EcfDgii.client available in Rails console and controllers.
4
+ console do
5
+ EcfDgii.client
6
+ end
4
7
  end
5
8
  end
@@ -1,3 +1,4 @@
1
1
  module EcfDgii
2
- VERSION = "1.0.2"
2
+ # SDK Version
3
+ VERSION = "1.1.0"
3
4
  end
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.2
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