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.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +161 -0
  3. data/NOTICE +40 -0
  4. data/README.md +524 -6
  5. data/examples/fa3_full.xml +143 -0
  6. data/examples/fa3_minimal.xml +65 -0
  7. data/examples/fa3_typical.xml +76 -0
  8. data/lib/generators/ksef/install/install_generator.rb +20 -0
  9. data/lib/generators/ksef/install/templates/ksef.rb +25 -0
  10. data/lib/ksef/auth/coordinator.rb +164 -0
  11. data/lib/ksef/auth/ksef_token_encryptor.rb +36 -0
  12. data/lib/ksef/batch/package_builder.rb +125 -0
  13. data/lib/ksef/batch/splitter.rb +99 -0
  14. data/lib/ksef/batch/uploader.rb +114 -0
  15. data/lib/ksef/client.rb +135 -0
  16. data/lib/ksef/configuration.rb +206 -0
  17. data/lib/ksef/crypto/cryptography_service.rb +136 -0
  18. data/lib/ksef/crypto/csr.rb +106 -0
  19. data/lib/ksef/crypto/public_keys.rb +125 -0
  20. data/lib/ksef/environments.rb +61 -0
  21. data/lib/ksef/errors.rb +187 -0
  22. data/lib/ksef/http/connection.rb +432 -0
  23. data/lib/ksef/http/error_parser.rb +194 -0
  24. data/lib/ksef/http/pagination.rb +80 -0
  25. data/lib/ksef/invoices/builder/checks.rb +127 -0
  26. data/lib/ksef/invoices/builder/number.rb +67 -0
  27. data/lib/ksef/invoices/builder/xml.rb +256 -0
  28. data/lib/ksef/invoices/builder.rb +251 -0
  29. data/lib/ksef/invoices/export.rb +241 -0
  30. data/lib/ksef/invoices/sample.rb +78 -0
  31. data/lib/ksef/invoices/schema_outline.rb +120 -0
  32. data/lib/ksef/invoices/validator.rb +200 -0
  33. data/lib/ksef/models/form_code.rb +49 -0
  34. data/lib/ksef/models/ksef_number.rb +74 -0
  35. data/lib/ksef/models/nip.rb +62 -0
  36. data/lib/ksef/polish_time.rb +49 -0
  37. data/lib/ksef/polling.rb +65 -0
  38. data/lib/ksef/qr/code.rb +104 -0
  39. data/lib/ksef/qr/verification_link.rb +215 -0
  40. data/lib/ksef/railtie.rb +20 -0
  41. data/lib/ksef/resources/auth.rb +116 -0
  42. data/lib/ksef/resources/base.rb +120 -0
  43. data/lib/ksef/resources/batch_sessions.rb +43 -0
  44. data/lib/ksef/resources/certificates.rb +82 -0
  45. data/lib/ksef/resources/collective_identifiers.rb +70 -0
  46. data/lib/ksef/resources/exports.rb +47 -0
  47. data/lib/ksef/resources/invoices.rb +67 -0
  48. data/lib/ksef/resources/limits.rb +33 -0
  49. data/lib/ksef/resources/online_sessions.rb +62 -0
  50. data/lib/ksef/resources/peppol.rb +21 -0
  51. data/lib/ksef/resources/permissions.rb +180 -0
  52. data/lib/ksef/resources/security.rb +18 -0
  53. data/lib/ksef/resources/sessions.rb +125 -0
  54. data/lib/ksef/resources/test_data.rb +178 -0
  55. data/lib/ksef/resources/tokens.rb +62 -0
  56. data/lib/ksef/sessions/online.rb +168 -0
  57. data/lib/ksef/sync/incremental.rb +169 -0
  58. data/lib/ksef/testing.rb +153 -0
  59. data/lib/ksef/token_manager.rb +130 -0
  60. data/lib/ksef/version.rb +11 -0
  61. data/lib/ksef/xades/auth_token_request.rb +135 -0
  62. data/lib/ksef/xades/external_signer.rb +58 -0
  63. data/lib/ksef/xades/signer.rb +312 -0
  64. data/lib/ksef-client-rails.rb +3 -2
  65. data/lib/ksef.rb +116 -0
  66. data/schemas/FA/schemat_FA(2)_v1-0E.xsd +3661 -0
  67. data/schemas/FA/schemat_FA(3)_v1-0E.xsd +3950 -0
  68. data/schemas/README.md +53 -0
  69. data/schemas/RR/schemat_FA_RR(1)_v1-1E.xsd +2188 -0
  70. data/schemas/bazowe/ElementarneTypyDanych_v10-0E.xsd +1 -0
  71. data/schemas/bazowe/KodyKrajow_v10-0E.xsd +1283 -0
  72. data/schemas/bazowe/StrukturyDanych_v10-0E.xsd +1 -0
  73. 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