ksef-client-rails 0.1.0 → 1.0.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/CHANGELOG.md +161 -0
- data/NOTICE +40 -0
- data/README.md +524 -6
- data/examples/fa3_full.xml +143 -0
- data/examples/fa3_minimal.xml +65 -0
- data/examples/fa3_typical.xml +76 -0
- data/lib/generators/ksef/install/install_generator.rb +20 -0
- data/lib/generators/ksef/install/templates/ksef.rb +25 -0
- data/lib/ksef/auth/coordinator.rb +164 -0
- data/lib/ksef/auth/ksef_token_encryptor.rb +36 -0
- data/lib/ksef/batch/package_builder.rb +125 -0
- data/lib/ksef/batch/splitter.rb +99 -0
- data/lib/ksef/batch/uploader.rb +114 -0
- data/lib/ksef/client.rb +135 -0
- data/lib/ksef/configuration.rb +206 -0
- data/lib/ksef/crypto/cryptography_service.rb +136 -0
- data/lib/ksef/crypto/csr.rb +106 -0
- data/lib/ksef/crypto/public_keys.rb +125 -0
- data/lib/ksef/environments.rb +61 -0
- data/lib/ksef/errors.rb +187 -0
- data/lib/ksef/http/connection.rb +432 -0
- data/lib/ksef/http/error_parser.rb +194 -0
- data/lib/ksef/http/pagination.rb +80 -0
- data/lib/ksef/invoices/builder/checks.rb +127 -0
- data/lib/ksef/invoices/builder/number.rb +67 -0
- data/lib/ksef/invoices/builder/xml.rb +256 -0
- data/lib/ksef/invoices/builder.rb +251 -0
- data/lib/ksef/invoices/export.rb +241 -0
- data/lib/ksef/invoices/sample.rb +78 -0
- data/lib/ksef/invoices/schema_outline.rb +120 -0
- data/lib/ksef/invoices/validator.rb +200 -0
- data/lib/ksef/models/form_code.rb +49 -0
- data/lib/ksef/models/ksef_number.rb +74 -0
- data/lib/ksef/models/nip.rb +62 -0
- data/lib/ksef/polish_time.rb +49 -0
- data/lib/ksef/polling.rb +65 -0
- data/lib/ksef/qr/code.rb +104 -0
- data/lib/ksef/qr/verification_link.rb +215 -0
- data/lib/ksef/railtie.rb +20 -0
- data/lib/ksef/resources/auth.rb +116 -0
- data/lib/ksef/resources/base.rb +120 -0
- data/lib/ksef/resources/batch_sessions.rb +43 -0
- data/lib/ksef/resources/certificates.rb +82 -0
- data/lib/ksef/resources/collective_identifiers.rb +70 -0
- data/lib/ksef/resources/exports.rb +47 -0
- data/lib/ksef/resources/invoices.rb +67 -0
- data/lib/ksef/resources/limits.rb +33 -0
- data/lib/ksef/resources/online_sessions.rb +62 -0
- data/lib/ksef/resources/peppol.rb +21 -0
- data/lib/ksef/resources/permissions.rb +180 -0
- data/lib/ksef/resources/security.rb +18 -0
- data/lib/ksef/resources/sessions.rb +125 -0
- data/lib/ksef/resources/test_data.rb +178 -0
- data/lib/ksef/resources/tokens.rb +62 -0
- data/lib/ksef/sessions/online.rb +168 -0
- data/lib/ksef/sync/incremental.rb +169 -0
- data/lib/ksef/testing.rb +153 -0
- data/lib/ksef/token_manager.rb +130 -0
- data/lib/ksef/version.rb +11 -0
- data/lib/ksef/xades/auth_token_request.rb +135 -0
- data/lib/ksef/xades/external_signer.rb +58 -0
- data/lib/ksef/xades/signer.rb +312 -0
- data/lib/ksef-client-rails.rb +3 -2
- data/lib/ksef.rb +116 -0
- data/schemas/FA/schemat_FA(2)_v1-0E.xsd +3661 -0
- data/schemas/FA/schemat_FA(3)_v1-0E.xsd +3950 -0
- data/schemas/README.md +53 -0
- data/schemas/RR/schemat_FA_RR(1)_v1-1E.xsd +2188 -0
- data/schemas/bazowe/ElementarneTypyDanych_v10-0E.xsd +1 -0
- data/schemas/bazowe/KodyKrajow_v10-0E.xsd +1283 -0
- data/schemas/bazowe/StrukturyDanych_v10-0E.xsd +1 -0
- metadata +291 -9
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "faraday/net_http_persistent"
|
|
5
|
+
require "json"
|
|
6
|
+
require "openssl"
|
|
7
|
+
require "socket"
|
|
8
|
+
require "timeout"
|
|
9
|
+
require "uri"
|
|
10
|
+
|
|
11
|
+
module Ksef
|
|
12
|
+
module Http
|
|
13
|
+
# Lightweight response object returned by {Connection}.
|
|
14
|
+
class Response
|
|
15
|
+
# @return [Integer] HTTP code
|
|
16
|
+
attr_reader :status
|
|
17
|
+
# @return [Hash] response headers (keys as produced by Faraday, usually lowercase)
|
|
18
|
+
attr_reader :headers
|
|
19
|
+
# @return [Object, nil] body parsed from JSON (or nil for raw/empty responses)
|
|
20
|
+
attr_reader :body
|
|
21
|
+
# @return [String] raw body (bytes) — useful for binary downloads
|
|
22
|
+
attr_reader :raw_body
|
|
23
|
+
|
|
24
|
+
def initialize(status:, headers:, body:, raw_body:)
|
|
25
|
+
@status = status
|
|
26
|
+
@headers = headers
|
|
27
|
+
@body = body
|
|
28
|
+
@raw_body = raw_body
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [Boolean] whether the status is within 2xx
|
|
32
|
+
def success?
|
|
33
|
+
status.between?(200, 299)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param name [String] header name (case-insensitive)
|
|
37
|
+
# @return [String, nil]
|
|
38
|
+
def header(name)
|
|
39
|
+
headers[name] || headers[name.downcase] || headers[name.upcase]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Faraday-based HTTP layer: JSON and raw-byte requests, base headers, retries, X-System-Warning
|
|
44
|
+
# logging, errors via {ErrorParser}. Transport failures surface as {Ksef::NetworkError}. Every HTTP
|
|
45
|
+
# attempt is logged at debug level and published as `request.ksef` (ActiveSupport::Notifications).
|
|
46
|
+
#
|
|
47
|
+
# Stateless in tokens: each request carries its own +bearer:+, so accessToken /
|
|
48
|
+
# authenticationToken / refreshToken cannot be confused at the resource level.
|
|
49
|
+
class Connection
|
|
50
|
+
# A body shorter than its Content-Length: the connection dropped mid-response.
|
|
51
|
+
class TruncatedBody < IOError; end
|
|
52
|
+
|
|
53
|
+
IDEMPOTENT_METHODS = %i[get head].freeze
|
|
54
|
+
# Presigned URLs address a single blob, so an upload (PUT) can be repeated safely too.
|
|
55
|
+
PRESIGNED_IDEMPOTENT_METHODS = %i[get head put].freeze
|
|
56
|
+
# Statuses worth retrying for an idempotent request (transient server/gateway failures).
|
|
57
|
+
RETRY_STATUSES = [500, 502, 503, 504].freeze
|
|
58
|
+
# Transport failures, retried for an idempotent request. Faraday wraps only some of them; a host
|
|
59
|
+
# or network that is down, a local firewall or a dropped keep-alive socket surface unwrapped.
|
|
60
|
+
TRANSPORT_ERRORS = [Faraday::ConnectionFailed, Faraday::TimeoutError, Timeout::Error, SystemCallError,
|
|
61
|
+
IOError, SocketError, Net::HTTP::Persistent::Error].freeze
|
|
62
|
+
# TLS failures — never retried.
|
|
63
|
+
TLS_ERRORS = [Faraday::SSLError, OpenSSL::SSL::SSLError].freeze
|
|
64
|
+
# First backoff delay (s) for a retried failure; it doubles with every attempt.
|
|
65
|
+
RETRY_BASE_DELAY = 0.5
|
|
66
|
+
# ActiveSupport::Notifications event, payload `{ method:, path:, status: }` — never a query string,
|
|
67
|
+
# header or body.
|
|
68
|
+
NOTIFICATION = "request.ksef"
|
|
69
|
+
# Content types whose 2xx body is not echoed into an error: it may carry a token.
|
|
70
|
+
JSON_TYPES = %r{\Aapplication/(?:[\w.+-]+\+)?json}i
|
|
71
|
+
|
|
72
|
+
@cert_store_lock = Mutex.new
|
|
73
|
+
|
|
74
|
+
# The system CA bundle, loaded once per process and shared by every connection: each store holds
|
|
75
|
+
# about 1 MiB that Ruby's GC does not see.
|
|
76
|
+
# @return [OpenSSL::X509::Store]
|
|
77
|
+
def self.default_cert_store
|
|
78
|
+
@cert_store_lock.synchronize do
|
|
79
|
+
@default_cert_store ||= OpenSSL::X509::Store.new.tap(&:set_default_paths)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [String] the API base URL requests go to
|
|
84
|
+
attr_reader :base_url
|
|
85
|
+
|
|
86
|
+
# @param config [Ksef::Configuration]
|
|
87
|
+
# @param base_url [String, nil] base URL override (defaults to config.api_url)
|
|
88
|
+
def initialize(config:, base_url: nil)
|
|
89
|
+
@config = config
|
|
90
|
+
@base_url = base_url || config.api_url
|
|
91
|
+
@faraday = build_faraday
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Performs the request and returns a {Response}; raises {Ksef::ApiError} on an error status.
|
|
95
|
+
#
|
|
96
|
+
# @param method [Symbol] :get/:post/:delete/:put
|
|
97
|
+
# @param path [String] relative path (appended to base_url) or a full URL (http...)
|
|
98
|
+
# @param bearer [String, #call, nil] token for the Authorization header; a callable is read again for
|
|
99
|
+
# every attempt, so a retry after a long Retry-After sends the current token
|
|
100
|
+
# @param json [Object, nil] body serialized to JSON
|
|
101
|
+
# @param body [String, nil] raw body (takes precedence over json)
|
|
102
|
+
# @param query [Hash, nil] query parameters (an Array value repeats the key)
|
|
103
|
+
# @param headers [Hash] extra headers
|
|
104
|
+
# @param raw [Boolean, :xml] when true, JSON is not parsed (binary downloads); :xml also refuses a 2xx
|
|
105
|
+
# body that is not an XML document (an HTML proxy page, an empty body)
|
|
106
|
+
# @param auth [Boolean] when false, neither Authorization nor the base KSeF headers are sent
|
|
107
|
+
# @return [Response]
|
|
108
|
+
# @raise [Ksef::ApiError, Ksef::NetworkError]
|
|
109
|
+
# @raise [Ksef::ConfigurationError] for a token with a control character (CR, LF, …)
|
|
110
|
+
def request(method, path, bearer: nil, json: nil, body: nil, query: nil, headers: {}, raw: false, auth: true)
|
|
111
|
+
opts = { bearer: bearer, json: json, body: body, query: query, headers: headers, raw: raw, auth: auth }
|
|
112
|
+
target = "#{method.to_s.upcase} #{normalize_path(path)}"
|
|
113
|
+
response = with_retries(IDEMPOTENT_METHODS.include?(method), target) do
|
|
114
|
+
observe(method, normalize_path(path)) { run(method, path, **opts) }
|
|
115
|
+
end
|
|
116
|
+
log_system_warning(response)
|
|
117
|
+
raise_on_error(response) unless response.success?
|
|
118
|
+
raise unexpected_body_error(response) if !raw && response.body.is_a?(String)
|
|
119
|
+
raise unexpected_body_error(response, "XML") if raw == :xml && !xml_document?(response)
|
|
120
|
+
|
|
121
|
+
response
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def get(path, **)
|
|
125
|
+
request(:get, path, **)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def post(path, **)
|
|
129
|
+
request(:post, path, **)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def delete(path, **)
|
|
133
|
+
request(:delete, path, **)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def put(path, **)
|
|
137
|
+
request(:put, path, **)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Closes the keep-alive connections; the next request opens new ones (a request already under way
|
|
141
|
+
# finishes on its own connection).
|
|
142
|
+
# @return [void]
|
|
143
|
+
def close
|
|
144
|
+
old = [@faraday, @presigned_faraday]
|
|
145
|
+
@faraday = build_faraday
|
|
146
|
+
@presigned_faraday = nil
|
|
147
|
+
old.compact.each(&:close)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Transfer from/to a presigned URL (a different host — Azure Blob), WITHOUT a token and
|
|
151
|
+
# without KSeF headers. Used to download export parts and upload batch package parts.
|
|
152
|
+
#
|
|
153
|
+
# @param method [Symbol, String] :get/:put/:post
|
|
154
|
+
# @param url [String] full presigned URL
|
|
155
|
+
# @param body [String, nil] raw bytes (for uploads)
|
|
156
|
+
# @param headers [Hash] headers from the server response (method+headers as in the C# pattern)
|
|
157
|
+
# @return [Response]
|
|
158
|
+
# @raise [Ksef::ApiError, Ksef::NetworkError]
|
|
159
|
+
def presigned_transfer(method, url, body: nil, headers: {})
|
|
160
|
+
url = https_url(url)
|
|
161
|
+
verb = method.to_s.downcase.to_sym
|
|
162
|
+
# The URL carries a signature, so the error message names the verb only.
|
|
163
|
+
response = with_retries(PRESIGNED_IDEMPOTENT_METHODS.include?(verb), "presigned #{verb.upcase}") do
|
|
164
|
+
observe(verb, "presigned") do
|
|
165
|
+
build_response(presigned_faraday.run_request(verb, url, body, stringify(headers)), true)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
raise_on_error(response) unless response.success?
|
|
169
|
+
response
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Keeps a proxy password out of logs, `pp` output and exception reports.
|
|
173
|
+
# @return [String]
|
|
174
|
+
def inspect
|
|
175
|
+
"#<#{self.class.name} base_url=#{@base_url.inspect} adapter=#{@config.adapter.inspect}>"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
private
|
|
179
|
+
|
|
180
|
+
# The presigned URL comes from the API response; refusing anything but https keeps a tampered
|
|
181
|
+
# or misconfigured response from moving invoice data onto a plaintext channel. The normalized URL
|
|
182
|
+
# is what gets sent: Faraday reads "HTTPS://…" or "https:host" as a relative path.
|
|
183
|
+
# @return [String]
|
|
184
|
+
def https_url(url)
|
|
185
|
+
uri = URI.parse(url.to_s).normalize
|
|
186
|
+
return uri.to_s if uri.is_a?(URI::HTTPS) && !uri.host.to_s.empty?
|
|
187
|
+
|
|
188
|
+
raise ConfigurationError, "presigned transfer refused: URL is not https://host (#{uri.scheme || 'no scheme'})"
|
|
189
|
+
rescue URI::InvalidURIError
|
|
190
|
+
raise ConfigurationError, "presigned transfer refused: invalid URL"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Keeps Faraday's default query encoder: re-encoding a signed URL differently could break it.
|
|
194
|
+
def presigned_faraday
|
|
195
|
+
@presigned_faraday ||= Faraday.new(ssl: { cert_store: self.class.default_cert_store }) do |f|
|
|
196
|
+
f.options.timeout = @config.timeout
|
|
197
|
+
f.options.open_timeout = @config.open_timeout
|
|
198
|
+
f.proxy = @config.proxy if @config.proxy
|
|
199
|
+
f.adapter(*Array(@config.adapter))
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def build_faraday
|
|
204
|
+
# The base URL MUST end with "/" so that relative paths (without a leading "/") resolve
|
|
205
|
+
# RELATIVE TO the version prefix (/v2) instead of replacing it — Faraday treats a path with
|
|
206
|
+
# a leading "/" as root-relative and would wipe out "/v2".
|
|
207
|
+
base = @base_url.end_with?("/") ? @base_url : "#{@base_url}/"
|
|
208
|
+
Faraday.new(url: base, ssl: { cert_store: self.class.default_cert_store }) do |f|
|
|
209
|
+
f.options.timeout = @config.timeout
|
|
210
|
+
f.options.open_timeout = @config.open_timeout
|
|
211
|
+
# Array parameters go out as repeated keys (`status=A&status=B`), as the contract defines
|
|
212
|
+
# them; Faraday's default would send `status[]=A`, which the API does not recognize.
|
|
213
|
+
f.options.params_encoder = Faraday::FlatParamsEncoder
|
|
214
|
+
f.proxy = @config.proxy if @config.proxy
|
|
215
|
+
f.adapter(*Array(@config.adapter))
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def run(method, path, bearer:, json:, body:, query:, headers:, raw:, auth:)
|
|
220
|
+
req_headers = build_headers(bearer, json, headers, auth)
|
|
221
|
+
payload = body || (json && JSON.generate(json))
|
|
222
|
+
|
|
223
|
+
faraday_response = @faraday.run_request(method, normalize_path(path), payload, req_headers) do |req|
|
|
224
|
+
req.params.update(query) if query
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
build_response(faraday_response, raw)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Absolute URLs are left as-is; a relative path has its leading "/" stripped so that it
|
|
231
|
+
# resolves against the "…/v2/" base.
|
|
232
|
+
def normalize_path(path)
|
|
233
|
+
path.match?(%r{\Ahttps?://}) ? path : path.sub(%r{\A/+}, "")
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def build_headers(bearer, json, extra, auth)
|
|
237
|
+
headers = {}
|
|
238
|
+
headers.merge!(@config.base_headers) if auth
|
|
239
|
+
headers["Content-Type"] = "application/json" if json
|
|
240
|
+
token = bearer.respond_to?(:call) ? bearer.call : bearer
|
|
241
|
+
headers["Authorization"] = "Bearer #{checked_token(token)}" if auth && token
|
|
242
|
+
headers.merge(stringify(extra))
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Surrounding whitespace (a token read from a file) is dropped; a control character inside would
|
|
246
|
+
# make net-http echo the whole header into its error.
|
|
247
|
+
def checked_token(token)
|
|
248
|
+
text = token.to_s.strip
|
|
249
|
+
raise ConfigurationError, "the token contains a control character (CR, LF, …)" if text.match?(/[[:cntrl:]]/)
|
|
250
|
+
|
|
251
|
+
text
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def build_response(faraday_response, raw)
|
|
255
|
+
raw_body = faraday_response.body.to_s
|
|
256
|
+
check_length!(faraday_response.headers || {}, raw_body)
|
|
257
|
+
parsed = raw ? nil : parse_json(raw_body)
|
|
258
|
+
Response.new(
|
|
259
|
+
status: faraday_response.status,
|
|
260
|
+
headers: faraday_response.headers || {},
|
|
261
|
+
body: parsed,
|
|
262
|
+
raw_body: raw_body
|
|
263
|
+
)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Net::HTTP returns a body cut short by a dropped connection without an error.
|
|
267
|
+
def check_length!(headers, raw_body)
|
|
268
|
+
expected = headers["content-length"] || headers["Content-Length"]
|
|
269
|
+
return if expected.nil? || headers["content-encoding"] || headers["Content-Encoding"]
|
|
270
|
+
return unless raw_body.bytesize < Integer(expected, exception: false).to_i
|
|
271
|
+
|
|
272
|
+
raise TruncatedBody, "response body cut short: #{raw_body.bytesize} of #{expected} bytes"
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def parse_json(raw_body)
|
|
276
|
+
return nil if raw_body.nil? || raw_body.strip.empty?
|
|
277
|
+
|
|
278
|
+
JSON.parse(raw_body)
|
|
279
|
+
rescue JSON::ParserError
|
|
280
|
+
raw_body
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# One debug line and one {NOTIFICATION} per attempt. A presigned URL carries a signature, so it is
|
|
284
|
+
# reported as "presigned"; a KSeF path loses any query string.
|
|
285
|
+
def observe(method, path)
|
|
286
|
+
payload = { method: method.to_s.upcase, path: path.to_s.sub(/\?.*\z/m, ""), status: nil }
|
|
287
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
288
|
+
response = instrument(payload) { yield.tap { |res| payload[:status] = res.status } }
|
|
289
|
+
log_request(payload, payload[:status], started)
|
|
290
|
+
response
|
|
291
|
+
rescue StandardError => e
|
|
292
|
+
log_request(payload, e.class.name, started)
|
|
293
|
+
raise
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def instrument(payload, &)
|
|
297
|
+
return yield unless defined?(ActiveSupport::Notifications)
|
|
298
|
+
|
|
299
|
+
ActiveSupport::Notifications.instrument(NOTIFICATION, payload, &)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def log_request(payload, outcome, started)
|
|
303
|
+
logger = @config.logger
|
|
304
|
+
return unless logger
|
|
305
|
+
|
|
306
|
+
ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
|
|
307
|
+
logger.debug { "[ksef] #{payload[:method]} #{payload[:path]} -> #{outcome} (#{ms} ms)" }
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Up to config.max_retries retries: a 429 (any method — the request was not processed) after its
|
|
311
|
+
# Retry-After, unless the server asks for longer than config.max_retry_after; for idempotent
|
|
312
|
+
# requests also 5xx and network failures, with exponential backoff; for any request a failure that
|
|
313
|
+
# happened before anything was sent (a pool checkout or a connect timeout). TLS failures are not
|
|
314
|
+
# retried, except a peer that dropped a reused keep-alive connection without close_notify.
|
|
315
|
+
def with_retries(idempotent, target)
|
|
316
|
+
attempt = 0
|
|
317
|
+
loop do
|
|
318
|
+
attempt += 1
|
|
319
|
+
begin
|
|
320
|
+
response = yield
|
|
321
|
+
rescue *TLS_ERRORS => e
|
|
322
|
+
raise network_error(e, target) unless idempotent && dropped?(e) && retry_allowed?(attempt)
|
|
323
|
+
|
|
324
|
+
pause(backoff(attempt), e.class.name)
|
|
325
|
+
next
|
|
326
|
+
rescue *TRANSPORT_ERRORS => e
|
|
327
|
+
raise network_error(e, target) unless (idempotent || never_sent?(e)) && retry_allowed?(attempt)
|
|
328
|
+
|
|
329
|
+
pause(backoff(attempt), e.class.name)
|
|
330
|
+
next
|
|
331
|
+
end
|
|
332
|
+
delay = retry_delay(response, idempotent, attempt) if retry_allowed?(attempt)
|
|
333
|
+
return response unless delay
|
|
334
|
+
|
|
335
|
+
pause(delay, "HTTP #{response.status}")
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# OpenSSL 3 reports a peer that closed the connection without close_notify as an SSL error.
|
|
340
|
+
def dropped?(error)
|
|
341
|
+
error.message.match?(/unexpected eof/i)
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# A pool checkout timeout (more threads than `pool_size`) or a connect timeout: the request never
|
|
345
|
+
# left the process.
|
|
346
|
+
def never_sent?(error)
|
|
347
|
+
causes(error).any? do |cause|
|
|
348
|
+
cause.is_a?(Net::OpenTimeout) || (defined?(ConnectionPool::TimeoutError) && cause.is_a?(ConnectionPool::TimeoutError))
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def causes(error)
|
|
353
|
+
chain = []
|
|
354
|
+
while error && chain.size < 10
|
|
355
|
+
chain << error
|
|
356
|
+
error = error.cause
|
|
357
|
+
end
|
|
358
|
+
chain
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def retry_allowed?(attempt)
|
|
362
|
+
attempt <= @config.max_retries
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# @return [Numeric, nil] seconds to wait before retrying the response, nil when it is final
|
|
366
|
+
def retry_delay(response, idempotent, attempt)
|
|
367
|
+
if response.status == 429
|
|
368
|
+
seconds = (ErrorParser.retry_after_seconds(response.headers) || 1).clamp(0..)
|
|
369
|
+
seconds if @config.max_retry_after.nil? || seconds <= @config.max_retry_after
|
|
370
|
+
elsif idempotent && RETRY_STATUSES.include?(response.status)
|
|
371
|
+
backoff(attempt)
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def backoff(attempt)
|
|
376
|
+
RETRY_BASE_DELAY * (2**(attempt - 1))
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def pause(seconds, reason)
|
|
380
|
+
@config.logger&.warn("[ksef] #{reason} — retrying in #{seconds}s")
|
|
381
|
+
sleep(seconds)
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def network_error(error, target)
|
|
385
|
+
timeout = error.is_a?(Faraday::TimeoutError) || error.is_a?(Timeout::Error)
|
|
386
|
+
klass = timeout && !never_sent?(error) ? RequestTimeout : NetworkError
|
|
387
|
+
klass.new("#{target}: #{error.class}: #{error.message}")
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# A 2xx body that is not JSON — an HTML page from a proxy, WAF or maintenance screen — would
|
|
391
|
+
# otherwise reach the resources as a String and read as an empty result. A JSON-typed body (cut
|
|
392
|
+
# short, say) is not quoted: it may carry a token.
|
|
393
|
+
def unexpected_body_error(response, expected = "JSON")
|
|
394
|
+
type = response.header("Content-Type") || "no Content-Type"
|
|
395
|
+
if type.match?(JSON_TYPES)
|
|
396
|
+
return ApiError.new("Unexpected #{type} response of #{response.raw_body.bytesize} bytes that is not " \
|
|
397
|
+
"valid #{expected}", status: response.status)
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
excerpt = ErrorParser.excerpt(response.raw_body)
|
|
401
|
+
ApiError.new("Unexpected non-#{expected} response (#{type}): #{excerpt}",
|
|
402
|
+
status: response.status, body: excerpt)
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def xml_document?(response)
|
|
406
|
+
return false if response.header("Content-Type").to_s.match?(%r{\Atext/html}i)
|
|
407
|
+
|
|
408
|
+
start = response.raw_body.byteslice(0, 512).to_s.b.delete_prefix("\xEF\xBB\xBF".b).lstrip
|
|
409
|
+
start.start_with?("<") && !start.match?(/\A<(?:!doctype\s+html|html[\s>])/i)
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def log_system_warning(response)
|
|
413
|
+
warning = response.header("X-System-Warning")
|
|
414
|
+
return unless warning && !warning.to_s.empty?
|
|
415
|
+
|
|
416
|
+
@config.logger&.warn("[ksef] X-System-Warning: #{warning}")
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def raise_on_error(response)
|
|
420
|
+
raise ErrorParser.build_error(
|
|
421
|
+
status: response.status,
|
|
422
|
+
body: response.body || response.raw_body,
|
|
423
|
+
headers: response.headers
|
|
424
|
+
)
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
def stringify(hash)
|
|
428
|
+
hash.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v.to_s }
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
end
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ksef
|
|
4
|
+
module Http
|
|
5
|
+
# Parses both KSeF error formats — legacy `exception.exceptionDetailList[]` and RFC 7807
|
|
6
|
+
# problem-details — regardless of the X-Error-Format header sent, then maps the HTTP status
|
|
7
|
+
# (and a few business codes) onto an exception class.
|
|
8
|
+
module ErrorParser
|
|
9
|
+
# Business codes of a 400 that deserve their own class. 21308 (a deceased person's credentials) stays
|
|
10
|
+
# a plain BadRequest: authenticating again does not help.
|
|
11
|
+
CODE_CLASSES = {
|
|
12
|
+
21_184 => SessionUnavailable,
|
|
13
|
+
21_301 => ReauthenticationRequired,
|
|
14
|
+
21_304 => ReauthenticationRequired
|
|
15
|
+
}.freeze
|
|
16
|
+
# Longest excerpt of a non-JSON body kept in an error message.
|
|
17
|
+
EXCERPT_LENGTH = 300
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# Builds the appropriate {Ksef::ApiError} (or subclass) from the response.
|
|
22
|
+
#
|
|
23
|
+
# @param status [Integer] HTTP code
|
|
24
|
+
# @param body [Object, String, nil] parsed or raw response body
|
|
25
|
+
# @param headers [Hash] response headers (for Retry-After)
|
|
26
|
+
# @return [Ksef::ApiError]
|
|
27
|
+
def build_error(status:, body:, headers: {})
|
|
28
|
+
parsed = coerce_body(body)
|
|
29
|
+
info = extract(parsed)
|
|
30
|
+
klass = error_class_for(status, info[:codes])
|
|
31
|
+
|
|
32
|
+
kwargs = {
|
|
33
|
+
status: status,
|
|
34
|
+
codes: info[:codes],
|
|
35
|
+
trace_id: info[:trace_id],
|
|
36
|
+
details: info[:details],
|
|
37
|
+
body: parsed
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if klass == RateLimited
|
|
41
|
+
kwargs[:retry_after] = retry_after_seconds(headers)
|
|
42
|
+
return RateLimited.new(info[:message], **kwargs)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
klass.new(info[:message], **kwargs)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Extracts the common fields (message, codes, trace_id, details) from both formats.
|
|
49
|
+
# @return [Hash]
|
|
50
|
+
def extract(body)
|
|
51
|
+
return empty_info unless body.is_a?(Hash)
|
|
52
|
+
|
|
53
|
+
from_legacy(body) || from_problem_details(body) || from_flat(body) || empty_info
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @param status [Integer] HTTP code
|
|
57
|
+
# @param codes [Array<Integer>] business codes from the body
|
|
58
|
+
# @return [Class] exception class for the HTTP code (a code-specific class for a 400 when one exists)
|
|
59
|
+
def error_class_for(status, codes = [])
|
|
60
|
+
base = status_class(status)
|
|
61
|
+
return base unless status == 400
|
|
62
|
+
|
|
63
|
+
codes.each do |code|
|
|
64
|
+
specific = CODE_CLASSES[code]
|
|
65
|
+
return specific if specific
|
|
66
|
+
end
|
|
67
|
+
base
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def status_class(status)
|
|
71
|
+
case status
|
|
72
|
+
when 400 then BadRequest
|
|
73
|
+
when 401 then Unauthorized
|
|
74
|
+
when 403 then Forbidden
|
|
75
|
+
when 404 then NotFound
|
|
76
|
+
when 409 then Conflict
|
|
77
|
+
when 410 then Gone
|
|
78
|
+
when 429 then RateLimited
|
|
79
|
+
when 500..599 then ServerError
|
|
80
|
+
else ApiError
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# --- legacy format: exception.exceptionDetailList[] ---------------------------------
|
|
85
|
+
def from_legacy(body)
|
|
86
|
+
exception = body["exception"] || body[:exception]
|
|
87
|
+
return nil unless exception.is_a?(Hash)
|
|
88
|
+
|
|
89
|
+
list = Array(exception["exceptionDetailList"] || exception["ExceptionDetailList"]).grep(Hash)
|
|
90
|
+
codes = list.map { |d| int_or_nil(d["exceptionCode"] || d["ExceptionCode"]) }.compact
|
|
91
|
+
messages = list.map { |d| d["exceptionDescription"] || d["ExceptionDescription"] }.compact
|
|
92
|
+
{
|
|
93
|
+
message: messages.first || exception["exceptionType"],
|
|
94
|
+
codes: codes,
|
|
95
|
+
trace_id: exception["referenceNumber"] || exception["ReferenceNumber"] || exception["serviceCode"],
|
|
96
|
+
details: list
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# --- RFC 7807 format: title/status/errors[] ----------------------------------------
|
|
101
|
+
def from_problem_details(body)
|
|
102
|
+
return nil unless body.key?("title") || body.key?("detail") || body.key?("errors")
|
|
103
|
+
|
|
104
|
+
errors = body["errors"] || []
|
|
105
|
+
errors = errors.values.flatten if errors.is_a?(Hash) # the { "field": ["msg"] } variant
|
|
106
|
+
codes = Array(errors).map { |e| e.is_a?(Hash) ? int_or_nil(e["code"]) : nil }.compact
|
|
107
|
+
message = body["detail"] || body["title"]
|
|
108
|
+
message = "#{message} (#{body['reasonCode']})" if body["reasonCode"] # 403: why access was denied
|
|
109
|
+
message = [message, *Array(errors).filter_map { |e| error_text(e) }].compact.join(" — ")
|
|
110
|
+
{
|
|
111
|
+
message: message,
|
|
112
|
+
codes: codes,
|
|
113
|
+
trace_id: body["traceId"] || body["trace_id"],
|
|
114
|
+
details: Array(errors)
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# --- flat format: { code:, message: } or { status: { code:, description:, details: } } ---
|
|
119
|
+
def from_flat(body)
|
|
120
|
+
status = body["status"]
|
|
121
|
+
if status.is_a?(Hash) # the legacy 429 TooManyRequestsResponse
|
|
122
|
+
return {
|
|
123
|
+
message: status["description"],
|
|
124
|
+
codes: [int_or_nil(status["code"])].compact,
|
|
125
|
+
trace_id: body["traceId"],
|
|
126
|
+
details: Array(status["details"])
|
|
127
|
+
}
|
|
128
|
+
end
|
|
129
|
+
if body["message"] || body["code"]
|
|
130
|
+
return {
|
|
131
|
+
message: body["message"],
|
|
132
|
+
codes: [int_or_nil(body["code"])].compact,
|
|
133
|
+
trace_id: body["traceId"],
|
|
134
|
+
details: [body]
|
|
135
|
+
}
|
|
136
|
+
end
|
|
137
|
+
nil
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def empty_info
|
|
141
|
+
{ message: nil, codes: [], trace_id: nil, details: [] }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# "Błąd walidacji danych wejściowych. (Wskazany kod formularza nie jest wspierany.)" — the
|
|
145
|
+
# problem-details `detail` alone is generic ("Żądanie jest nieprawidłowe.").
|
|
146
|
+
def error_text(error)
|
|
147
|
+
return error if error.is_a?(String) # the { "field": ["message"] } variant
|
|
148
|
+
return nil unless error.is_a?(Hash) && error["description"]
|
|
149
|
+
|
|
150
|
+
details = Array(error["details"]).join("; ")
|
|
151
|
+
details.empty? ? error["description"] : "#{error['description']} (#{details})"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def coerce_body(body)
|
|
155
|
+
return body unless body.is_a?(String)
|
|
156
|
+
return nil if body.strip.empty?
|
|
157
|
+
|
|
158
|
+
JSON.parse(body)
|
|
159
|
+
rescue JSON::ParserError
|
|
160
|
+
{ "message" => excerpt(body) }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# A non-JSON body (an HTML gateway page, say) as a short UTF-8 string: bodies without a charset
|
|
164
|
+
# arrive as BINARY, and a whole page in an exception message helps no one.
|
|
165
|
+
# @param body [String]
|
|
166
|
+
# @return [String]
|
|
167
|
+
def excerpt(body)
|
|
168
|
+
text = body.to_s.dup.force_encoding(Encoding::UTF_8).scrub("?").gsub(/\s+/, " ").strip
|
|
169
|
+
text.length > EXCERPT_LENGTH ? "#{text[0, EXCERPT_LENGTH]}…" : text
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def retry_after_seconds(headers)
|
|
173
|
+
raw = header_value(headers, "Retry-After")
|
|
174
|
+
return nil if raw.nil? || raw.to_s.strip.empty?
|
|
175
|
+
|
|
176
|
+
Integer(raw)
|
|
177
|
+
rescue ArgumentError, TypeError
|
|
178
|
+
nil
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def header_value(headers, name)
|
|
182
|
+
return nil unless headers
|
|
183
|
+
|
|
184
|
+
headers[name] || headers[name.downcase] || headers[name.upcase]
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def int_or_nil(value)
|
|
188
|
+
Integer(value)
|
|
189
|
+
rescue ArgumentError, TypeError
|
|
190
|
+
nil
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|