ksef_client 0.1.0.rc1

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,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+
5
+ module Ksef
6
+ module HTTP
7
+ # Builds the single Faraday connection a {Ksef::Client} owns.
8
+ #
9
+ # One connection per client, shared across threads — safe with the default `net_http`
10
+ # adapter (DESIGN.md §5.2). The adapter is kept swappable via configuration.
11
+ module Connection
12
+ # `application/problem+json` is the current error content type, so the response
13
+ # parser must match it as well as plain `application/json`
14
+ # (docs/REFERENCE.md §5.1).
15
+ JSON_CONTENT_TYPE = /\bjson\b/
16
+
17
+ class << self
18
+ # @param config [Ksef::Configuration]
19
+ # @return [Faraday::Connection]
20
+ def build(config)
21
+ Faraday.new(url: config.base_url, headers: default_headers(config)) do |f|
22
+ f.request :json
23
+
24
+ # Registration order is load-bearing. Faraday runs `on_complete` callbacks
25
+ # innermost-first, so the JSON parser must be registered *after* the error
26
+ # handler for the handler to see a decoded body rather than a raw string.
27
+ f.use ErrorHandler
28
+ f.use SystemWarning, logger: config.logger
29
+ f.response :json, content_type: JSON_CONTENT_TYPE
30
+
31
+ apply_transport_options(f, config)
32
+ f.adapter config.adapter
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def apply_transport_options(faraday, config)
39
+ faraday.options.open_timeout = config.open_timeout
40
+ faraday.options.timeout = config.read_timeout
41
+ faraday.proxy = config.proxy if config.proxy
42
+
43
+ # TLS verification is never disabled, on any code path (DESIGN.md §4.5).
44
+ faraday.ssl.verify = true
45
+ faraday.ssl.min_version = :TLS1_2
46
+ end
47
+
48
+ def default_headers(config)
49
+ {
50
+ "Accept" => "application/json",
51
+ # Opts into RFC7807 error bodies. Every one of the 83 operations documents
52
+ # this header, and the modern envelope is opt-in: without it the API returns
53
+ # the deprecated shapes, which carry no traceId, no structured `errors[]`
54
+ # codes on 400 and no reasonCode on 403 (docs/REFERENCE.md §5.1).
55
+ "X-Error-Format" => "problem-details",
56
+ "User-Agent" => config.user_agent
57
+ }
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+
5
+ module Ksef
6
+ module HTTP
7
+ # Maps KSeF error responses and Faraday transport failures onto the {Ksef::Error}
8
+ # hierarchy (DESIGN.md §6.7).
9
+ #
10
+ # Status coverage follows what the contract actually declares — 400, 401, 403, 410
11
+ # and 429 (docs/REFERENCE.md §5.4). 5xx is handled defensively: the spec never
12
+ # declares it, so the body shape is unknown and degrades to a raw payload.
13
+ class ErrorHandler < Faraday::Middleware
14
+ STATUS_MAP = {
15
+ 400 => ApiError,
16
+ 401 => AuthenticationError,
17
+ 403 => AuthorizationError,
18
+ 410 => ResourceGoneError,
19
+ 429 => RateLimitedError
20
+ }.freeze
21
+
22
+ def call(env)
23
+ super
24
+ rescue Faraday::TimeoutError => e
25
+ raise Ksef::TimeoutError, "KSeF request timed out: #{e.message}"
26
+ rescue Faraday::SSLError => e
27
+ raise Ksef::ConnectionError, "TLS failure talking to KSeF: #{e.message}"
28
+ rescue Faraday::ConnectionFailed => e
29
+ # The net_http adapter reports `Net::OpenTimeout` as a connection failure, which
30
+ # loses the distinction a caller most needs after submitting an invoice: a
31
+ # timeout may have been processed server-side, a refused connection was not.
32
+ raise Ksef::TimeoutError, "KSeF connection timed out: #{e.message}" if timeout?(e.wrapped_exception)
33
+
34
+ raise Ksef::ConnectionError, "Could not connect to KSeF: #{e.message}"
35
+ end
36
+
37
+ def on_complete(env)
38
+ status = env.status
39
+ return if status < 400
40
+
41
+ problem = ProblemDetails.parse(status: status, body: env.body)
42
+ raise build_error(status, problem, env)
43
+ end
44
+
45
+ private
46
+
47
+ # `Net::OpenTimeout` and `Net::ReadTimeout` both descend from `Timeout::Error`.
48
+ def timeout?(wrapped)
49
+ defined?(Timeout::Error) && wrapped.is_a?(Timeout::Error)
50
+ end
51
+
52
+ def build_error(status, problem, env)
53
+ message = "KSeF API #{status}: #{problem.summary}"
54
+ message = "#{message} (traceId: #{problem.trace_id})" if problem.trace_id
55
+
56
+ if status == 429
57
+ RateLimitedError.new(message, problem: problem, retry_after: retry_after_from(env))
58
+ else
59
+ error_class(status).new(message, problem: problem)
60
+ end
61
+ end
62
+
63
+ def error_class(status)
64
+ STATUS_MAP[status] || (status >= 500 ? ServerError : ApiError)
65
+ end
66
+
67
+ # `Retry-After` is present on every declared 429 and is expressed in seconds
68
+ # (docs/REFERENCE.md §5.5). The HTTP-date form is accepted defensively.
69
+ def retry_after_from(env)
70
+ raw = env.response_headers&.[]("Retry-After")
71
+ return if raw.nil? || raw.to_s.strip.empty?
72
+
73
+ value = raw.to_s.strip
74
+ return Integer(value, 10) if /\A\d+\z/.match?(value)
75
+
76
+ seconds = (Time.httpdate(value) - Time.now).ceil
77
+ seconds.positive? ? seconds : 0
78
+ rescue ArgumentError
79
+ nil
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+
5
+ module Ksef
6
+ module HTTP
7
+ # Surfaces the `X-System-Warning` header the API sets on every successful response
8
+ # (docs/REFERENCE.md §5.5).
9
+ #
10
+ # This is the Ministry's in-band channel for advisory notices — deprecations,
11
+ # forthcoming contract changes — and is not mentioned in DESIGN.md. Swallowing it
12
+ # would mean users only learn about a change when it breaks them.
13
+ class SystemWarning < Faraday::Middleware
14
+ HEADER = "X-System-Warning"
15
+
16
+ def initialize(app, logger: nil)
17
+ super(app)
18
+ @logger = logger
19
+ end
20
+
21
+ def on_complete(env)
22
+ warning = env.response_headers&.[](HEADER)
23
+ return if warning.nil? || warning.to_s.strip.empty?
24
+
25
+ @logger&.warn("[ksef_client] #{HEADER}: #{warning}")
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ksef
4
+ # A parsed KSeF error body.
5
+ #
6
+ # KSeF serves errors in two different envelopes and the response `Content-Type` picks
7
+ # between them (docs/REFERENCE.md §5.1): `application/problem+json` is current, while
8
+ # `application/json` returns legacy shapes the spec marks deprecated but which are
9
+ # still live in the wild. This class normalises all of them, and degrades to a raw
10
+ # payload for bodies that are neither (an HTML block page from the WAF in front of the
11
+ # API, for instance).
12
+ ProblemDetails = Data.define(
13
+ :status,
14
+ :title,
15
+ :detail,
16
+ :instance,
17
+ :timestamp,
18
+ :trace_id,
19
+ :entries,
20
+ :reason_code,
21
+ :security,
22
+ :raw
23
+ )
24
+
25
+ # Parsing and presentation for {Ksef::ProblemDetails}.
26
+ class ProblemDetails
27
+ # One entry of a 400's `errors[]`. Called `ApiError` in the OpenAPI spec, renamed
28
+ # here to avoid colliding with the {Ksef::ApiError} exception class.
29
+ Entry = Data.define(:code, :description, :details)
30
+
31
+ # Only `status` and `raw` are common to every envelope; everything else is absent in
32
+ # at least one of them, so the builders below name only what they actually have.
33
+ DEFAULTS = {
34
+ status: nil, title: nil, detail: nil, instance: nil, timestamp: nil,
35
+ trace_id: nil, entries: [].freeze, reason_code: nil, security: {}.freeze, raw: nil
36
+ }.freeze
37
+
38
+ class << self
39
+ # @param status [Integer] HTTP status
40
+ # @param body [Hash, String, nil] decoded JSON body, or the raw string
41
+ # @return [Ksef::ProblemDetails]
42
+ def parse(status:, body:)
43
+ return build(status: status, raw: body) unless body.is_a?(Hash)
44
+
45
+ if body["exception"].is_a?(Hash)
46
+ legacy_exception(status, body)
47
+ elsif body["status"].is_a?(Hash)
48
+ legacy_status(status, body)
49
+ else
50
+ problem_json(status, body)
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def build(**attrs) = new(**DEFAULTS, **attrs)
57
+
58
+ # The current `application/problem+json` shape.
59
+ def problem_json(status, body)
60
+ build(
61
+ status: body["status"] || status,
62
+ title: body["title"],
63
+ detail: body["detail"],
64
+ instance: body["instance"],
65
+ timestamp: body["timestamp"],
66
+ trace_id: body["traceId"],
67
+ entries: Array(body["errors"]).filter_map { |e| entry_from_api_error(e) },
68
+ reason_code: body["reasonCode"],
69
+ security: body["security"] || {},
70
+ raw: body
71
+ )
72
+ end
73
+
74
+ # Deprecated `ExceptionResponse`: details nest under `exception.exceptionDetailList`.
75
+ def legacy_exception(status, body)
76
+ exception = body["exception"]
77
+ entries = Array(exception["exceptionDetailList"]).filter_map { |e| entry_from_exception_detail(e) }
78
+
79
+ build(
80
+ status: status,
81
+ title: exception["serviceName"],
82
+ detail: entries.first&.description,
83
+ timestamp: exception["timestamp"],
84
+ # The legacy envelope has no traceId; referenceNumber is its closest analogue.
85
+ trace_id: exception["referenceNumber"],
86
+ entries: entries,
87
+ raw: body
88
+ )
89
+ end
90
+
91
+ # Deprecated `TooManyRequestsResponse`: `status` is an object, not an integer.
92
+ def legacy_status(status, body)
93
+ inner = body["status"]
94
+ details = Array(inner["details"])
95
+ entry = Entry.new(code: inner["code"], description: inner["description"], details: details)
96
+
97
+ build(
98
+ status: inner["code"] || status,
99
+ title: inner["description"],
100
+ detail: details.first,
101
+ entries: [entry],
102
+ raw: body
103
+ )
104
+ end
105
+
106
+ def entry_from_api_error(entry)
107
+ return unless entry.is_a?(Hash)
108
+
109
+ Entry.new(
110
+ code: entry["code"],
111
+ description: entry["description"],
112
+ details: Array(entry["details"])
113
+ )
114
+ end
115
+
116
+ def entry_from_exception_detail(entry)
117
+ return unless entry.is_a?(Hash)
118
+
119
+ Entry.new(
120
+ code: entry["exceptionCode"],
121
+ description: entry["exceptionDescription"],
122
+ details: Array(entry["details"])
123
+ )
124
+ end
125
+ end
126
+
127
+ # @return [Integer, nil] the first KSeF error code, when the body carried one
128
+ def code = entries.first&.code
129
+
130
+ # @return [Array<String>] every detail message, across all entries
131
+ def details = entries.flat_map(&:details)
132
+
133
+ # A single line suitable for an exception message.
134
+ #
135
+ # @return [String]
136
+ def summary
137
+ lead = detail || title || entries.first&.description
138
+ parts = [lead, *details.reject { |d| d == lead }].compact
139
+ body = parts.empty? ? "HTTP #{status}" : parts.join(" ")
140
+ code ? "[#{code}] #{body}" : body
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ksef
4
+ # When the client may transparently retry a request.
5
+ #
6
+ # The governing rule from DESIGN.md §6.7 is a business one, not a technical one:
7
+ # **invoice submission is never auto-retried.** A duplicate invoice in KSeF is a real
8
+ # tax problem that the gem must not create on a user's behalf. So retries are gated on
9
+ # the HTTP method being idempotent, not merely on the status looking transient — a 429
10
+ # on a POST is surfaced as {Ksef::RateLimitedError} with `#retry_after` for the caller
11
+ # to act on deliberately.
12
+ RetryPolicy = Data.define(
13
+ :max_attempts,
14
+ :base_interval,
15
+ :max_interval,
16
+ :max_retry_after,
17
+ :backoff_factor,
18
+ :retry_statuses,
19
+ :respect_retry_after
20
+ )
21
+
22
+ # Behaviour for {Ksef::RetryPolicy}.
23
+ class RetryPolicy
24
+ # Methods with no side effects, so replaying them is safe.
25
+ IDEMPOTENT_METHODS = %i[get head].freeze
26
+
27
+ class << self
28
+ def default
29
+ new(
30
+ max_attempts: 3,
31
+ base_interval: 1.0,
32
+ max_interval: 30.0,
33
+ max_retry_after: 60.0,
34
+ backoff_factor: 2.0,
35
+ retry_statuses: [429, 500, 502, 503, 504].freeze,
36
+ respect_retry_after: true
37
+ )
38
+ end
39
+
40
+ # Disable retries entirely.
41
+ def none
42
+ default.with(max_attempts: 1)
43
+ end
44
+ end
45
+
46
+ # @param method [Symbol] the HTTP verb, lowercase
47
+ # @param status [Integer, nil] the response status, or nil for a transport failure
48
+ # @param attempt [Integer] 1-based
49
+ # @param retry_after [Integer, Float, nil] seconds, from the `Retry-After` header
50
+ def retryable?(method:, status:, attempt: 1, retry_after: nil)
51
+ return false if attempt >= max_attempts
52
+ return false unless IDEMPOTENT_METHODS.include?(method.to_s.downcase.to_sym)
53
+ # Waiting less than the server demanded provokes a longer block, so if we are not
54
+ # prepared to wait the full period we must not retry at all.
55
+ return false if respect_retry_after && retry_after && retry_after.to_f > max_retry_after
56
+ return true if status.nil? # connection reset / timeout on an idempotent request
57
+
58
+ retry_statuses.include?(status)
59
+ end
60
+
61
+ # Seconds to wait before the next attempt.
62
+ #
63
+ # `Retry-After` wins over the computed backoff, and is deliberately **not** clamped to
64
+ # `max_interval`: on 429 the block period is dynamic and lengthens with repeat
65
+ # offences, so retrying earlier than the server asked makes things strictly worse
66
+ # (docs/REFERENCE.md §6). `max_retry_after` bounds this instead, by declining the
67
+ # retry outright in {#retryable?}.
68
+ #
69
+ # @param attempt [Integer] 1-based
70
+ # @param retry_after [Integer, Float, nil] seconds, from the `Retry-After` header
71
+ def interval_for(attempt:, retry_after: nil)
72
+ return retry_after.to_f if respect_retry_after && retry_after
73
+
74
+ [base_interval * (backoff_factor**(attempt - 1)), max_interval].min
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ksef
4
+ VERSION = "0.1.0.rc1"
5
+ end
data/lib/ksef.rb ADDED
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zeitwerk"
4
+
5
+ # Ruby client for KSeF 2.0, the Polish National e-Invoice System.
6
+ #
7
+ # Two decoupled subsystems live under this namespace (DESIGN.md §5):
8
+ # - {Ksef::Client} and friends — transport: auth, crypto, sessions, invoices.
9
+ # - {Ksef::FA3} — the FA(3) invoice builder, usable with no HTTP at all.
10
+ module Ksef
11
+ class << self
12
+ # The gem's Zeitwerk loader. Exposed for `loader.eager_load` in forking servers.
13
+ attr_reader :loader
14
+ end
15
+
16
+ @loader = Zeitwerk::Loader.new.tap do |loader|
17
+ loader.push_dir(__dir__)
18
+ # `ksef_client.rb` is the gem entry point, not a constant definition (DESIGN.md §5.3).
19
+ loader.ignore("#{__dir__}/ksef_client.rb")
20
+ # `errors.rb` defines the whole hierarchy and no `Ksef::Errors`, so it cannot follow
21
+ # Zeitwerk's file-to-constant rule. It is tiny and always needed — load it eagerly.
22
+ loader.ignore("#{__dir__}/ksef/errors.rb")
23
+ loader.inflector.inflect(
24
+ "fa3" => "FA3",
25
+ "http" => "HTTP",
26
+ "version" => "VERSION"
27
+ )
28
+ loader.setup
29
+ end
30
+ end
31
+
32
+ require_relative "ksef/errors"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The gem is named `ksef_client`; the namespace is `Ksef`. See DESIGN.md §5.3.
4
+ # This file exists so `require "ksef_client"` works; all setup lives in `ksef.rb`.
5
+ require_relative "ksef"
metadata ADDED
@@ -0,0 +1,130 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ksef_client
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0.rc1
5
+ platform: ruby
6
+ authors:
7
+ - Tibor Molnár
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: bigdecimal
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '3.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '3.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: faraday
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '2.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: nokogiri
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.16'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.16'
54
+ - !ruby/object:Gem::Dependency
55
+ name: zeitwerk
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '2.6'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '2.6'
68
+ description: |
69
+ A Ruby client for KSeF 2.0, Poland's mandatory national e-invoicing system.
70
+ Covers the REST transport layer (KSeF-token authentication, payload encryption,
71
+ interactive sessions, invoice submission, status polling and UPO retrieval) and
72
+ ships a standalone FA(3) invoice builder with XSD-backed validation. The builder
73
+ has no HTTP dependency and can be used on its own.
74
+ email:
75
+ - tibor@timcraft.pl
76
+ executables: []
77
+ extensions: []
78
+ extra_rdoc_files: []
79
+ files:
80
+ - CHANGELOG.md
81
+ - CONTRIBUTING.md
82
+ - LICENSE
83
+ - README.md
84
+ - SECURITY.md
85
+ - docs/REFERENCE.md
86
+ - docs/errors.md
87
+ - lib/ksef.rb
88
+ - lib/ksef/configuration.rb
89
+ - lib/ksef/environments.rb
90
+ - lib/ksef/errors.rb
91
+ - lib/ksef/fa3/schema/LICENSE.upstream.txt
92
+ - lib/ksef/fa3/schema/bazowe/ElementarneTypyDanych_v10-0E.xsd
93
+ - lib/ksef/fa3/schema/bazowe/KodyKrajow_v10-0E.xsd
94
+ - lib/ksef/fa3/schema/bazowe/StrukturyDanych_v10-0E.xsd
95
+ - lib/ksef/fa3/schema/schemat_FA(3)_v1-0E.xsd
96
+ - lib/ksef/http/connection.rb
97
+ - lib/ksef/http/error_handler.rb
98
+ - lib/ksef/http/system_warning.rb
99
+ - lib/ksef/problem_details.rb
100
+ - lib/ksef/retry_policy.rb
101
+ - lib/ksef/version.rb
102
+ - lib/ksef_client.rb
103
+ homepage: https://github.com/tibortc/ksef_client
104
+ licenses:
105
+ - MIT
106
+ metadata:
107
+ rubygems_mfa_required: 'true'
108
+ source_code_uri: https://github.com/tibortc/ksef_client
109
+ changelog_uri: https://github.com/tibortc/ksef_client/blob/main/CHANGELOG.md
110
+ bug_tracker_uri: https://github.com/tibortc/ksef_client/issues
111
+ documentation_uri: https://rubydoc.info/gems/ksef_client/0.1.0.rc1
112
+ rdoc_options: []
113
+ require_paths:
114
+ - lib
115
+ required_ruby_version: !ruby/object:Gem::Requirement
116
+ requirements:
117
+ - - ">="
118
+ - !ruby/object:Gem::Version
119
+ version: 3.2.0
120
+ required_rubygems_version: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ requirements: []
126
+ rubygems_version: 4.0.16
127
+ specification_version: 4
128
+ summary: Ruby client for KSeF 2.0 (Polish National e-Invoice System) with an FA(3)
129
+ invoice builder
130
+ test_files: []