digiwin_dsp 0.1.1 → 0.2.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c0fd438c3029652103006f9fbb4b69c21abb7d699fb9e77234822e939660d710
4
- data.tar.gz: 2b823c5afdb2452d18e2c22f9ad2111f1f0df5ab84862a400ba63a80b6c30bd2
3
+ metadata.gz: cf609190d9515001cb6540d2bbf8a88c8e1f6a5294ea21edbe1408bad4305fc4
4
+ data.tar.gz: 61f86d3485d0322c3bd0af81bddcdf0ae3105327d9de63a038dbab6854a3901b
5
5
  SHA512:
6
- metadata.gz: 9695c0dea30adeced40a3b118ce95162dbd6cbcf0498ce30e82cd1d663107ae96a0dc0b57b54008eb7815d008a403efa1b73a915652d7d7ef488382982890ad5
7
- data.tar.gz: 982922896540b5f6e5b2f7cbf2cdda02529bb4bbf89ee53005ea5030f0522b74407da2115547054b024a5dfae7caf175c46fc141e8006506e4f965ffdf8824fb
6
+ metadata.gz: 977f1b3a3e31e5de38b1bc48327ef5427cbedeb12b60949f1812c964a768fc96ab1810ca4b1aae7f4427c38b85c6fe108f296082ec0b202052bab76578901a31
7
+ data.tar.gz: 9301f03f1af6630147a6d1ad88fd7db41fdd70c329886b5b526a75bbf4f2492643852f353115f5c65e22c759656d1cb86708620d28199f3d9b4514360f69da90
data/CHANGELOG.md CHANGED
@@ -6,6 +6,85 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.1] - 2026-05-21
10
+
11
+ ### Fixed
12
+
13
+ - **Envelope failure-message regexes now match anywhere in the string**
14
+ (dropped the `\A` anchor). Discovered via live UAT smoke test: DSP
15
+ prepends the offending `form_no` to `Message`, so the actual response
16
+ is e.g. `"ORDER-123:Duplicated:訂單不可重複"` rather than the
17
+ OpenAPI-example shape `"Duplicated:訂單不可重複"`. Previously the
18
+ prefix caused `DuplicateRequestError` / `RateLimitError` /
19
+ `ValidationError` / `ServerError` classifications to fall through to
20
+ generic `DigiwinDsp::Error`, defeating typed-rescue logic in callers.
21
+
22
+ ### Changed
23
+
24
+ - CI CVE audit switched from the `bundler-audit` Ruby gem to the
25
+ `7a6163/gem-audit-action@v1` GitHub Action (which wraps the
26
+ `gem-audit` Rust binary). Faster, no runtime gem to keep updated, and
27
+ the action handles platform/version selection. Dropped `bundler-audit`
28
+ from the dev/test Gemfile group.
29
+ - Rubocop now excludes `scripts/**/*` (operator scripts; not gem source).
30
+
31
+ ## [0.2.0] - 2026-05-21
32
+
33
+ Security + correctness release. Addresses every HIGH and MEDIUM finding
34
+ from the v0.1.x code review. Pre-1.0 SemVer; contains one breaking change.
35
+
36
+ ### BREAKING
37
+
38
+ - **`DigiwinDsp::Error#response_body` removed.** Storing the full DSP
39
+ response on every exception leaked buyer PII (names, addresses, phone
40
+ numbers) to Sentry/Honeybadger/Rollbar via their default instance-var
41
+ serialization. Structured fields remain: `#code`, `#dsp_message`,
42
+ `#http_status`, `#request_id`. If you need the raw body, capture it in
43
+ your own Faraday middleware before the request reaches this gem.
44
+ **Migration:** delete any `e.response_body` access from rescue blocks.
45
+
46
+ ### Added
47
+
48
+ - `Configuration#allowed_hosts` (default `["digiwindsp.digiwin.com"]`) —
49
+ SSRF allowlist enforced on `#base_url`. Extend it for proxy/mock setups:
50
+ `c.allowed_hosts += ["dsp-proxy.your-co.internal"]`.
51
+ - CRLF (`\r` / `\n`) validation on `idempotency_key` and every entry of
52
+ the `headers:` kwarg in `Client#post`. Raises `ArgumentError` on
53
+ injection attempts (closes a header-smuggling vector that Faraday's
54
+ default adapter doesn't fully catch).
55
+ - `bundler-audit` ~> 0.9 in dev/test + a CI step (`bundle-audit check
56
+ --update`) that fails on any known CVE in the locked dependency tree.
57
+
58
+ ### Changed
59
+
60
+ - `Configuration#base_url` now validates the resolved URL:
61
+ - scheme MUST be `https` (no HTTP downgrade for the `DSP-api-key`)
62
+ - host MUST be in `allowed_hosts` (default: only `digiwindsp.digiwin.com`)
63
+ - malformed URIs raise `ConfigurationError`
64
+ - Resources::{Order,Cancellation,Invoice,Return}#create now raise
65
+ `DigiwinDsp::ServerError` when DSP returns `Status:"Success"` without a
66
+ `response_detail` key, instead of returning `nil` (which became a
67
+ silent `NoMethodError` downstream).
68
+ - Faraday retry now uses real exponential backoff with jitter
69
+ (`interval: 0.5, backoff_factor: 2, interval_randomness: 0.5`).
70
+ Previously `interval: 0, backoff_factor: 1` fired all three retries
71
+ instantly — actively counterproductive on 429 throttling.
72
+ - Faraday `:json` middleware gains `parser_options: { max_nesting: 50 }`
73
+ as a DoS guard against hostile / malformed DSP responses.
74
+ - `Resources::*.create` class-method shortcuts now declare typed kwargs
75
+ (`idempotency_key:`, `digi_header:`) so caller typos raise `ArgumentError`
76
+ at call time instead of being swallowed by `**`.
77
+ - README configuration table distinguishes runtime-used settings from
78
+ reserved ones; calls out that `platform_id` lives in `request_detail`,
79
+ not auth headers; documents `allowed_hosts` + the proxy-override pattern.
80
+
81
+ ### Removed
82
+
83
+ - `DigiwinDsp::Authenticator#auth_headers` no longer calls
84
+ `Configuration#validate!`. Validation was redundant (Client#post runs
85
+ it per-request) and silently skipped after construction-time mutation
86
+ due to connection memoization.
87
+
9
88
  ## [0.1.1] - 2026-05-21
10
89
 
11
90
  ### Added
@@ -26,7 +105,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
26
105
 
27
106
  ## [0.1.0] - 2026-05-21
28
107
 
29
- Initial release. Covers the four 自有官網模組 endpoints under `/v1/SalesOrder/*`.
108
+ Initial release. Covers the four Self-hosted Website Module (自有官網模組) endpoints under `/v1/SalesOrder/*`.
30
109
 
31
110
  ### Added
32
111
 
@@ -61,6 +140,8 @@ Initial release. Covers the four 自有官網模組 endpoints under `/v1/SalesOr
61
140
  - The gem is **synchronous on purpose**. Callers wrap requests in their own background job runner (e.g. ActiveJob) when needed.
62
141
  - Idempotency: clients can send `X-Idempotency-Key` via the `idempotency_key:` kwarg. DSP also dedupes server-side by `form_no + platform_id`.
63
142
 
64
- [Unreleased]: https://github.com/7a6163/digiwin_dsp/compare/v0.1.1...HEAD
143
+ [Unreleased]: https://github.com/7a6163/digiwin_dsp/compare/v0.2.1...HEAD
144
+ [0.2.1]: https://github.com/7a6163/digiwin_dsp/compare/v0.2.0...v0.2.1
145
+ [0.2.0]: https://github.com/7a6163/digiwin_dsp/compare/v0.1.1...v0.2.0
65
146
  [0.1.1]: https://github.com/7a6163/digiwin_dsp/compare/v0.1.0...v0.1.1
66
147
  [0.1.0]: https://github.com/7a6163/digiwin_dsp/releases/tag/v0.1.0
data/README.md CHANGED
@@ -5,14 +5,14 @@
5
5
  [![Ruby](https://img.shields.io/badge/ruby-%E2%89%A53.2-CC342D)](https://www.ruby-lang.org/)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE.txt)
7
7
 
8
- Ruby client for the Digiwin DSP **自有官網模組** (Official Website Module) API. Lets a Rails 自有官網 push orders, cancellations, invoice updates, and returns into the Digiwin ERP through the DSP gateway.
8
+ Ruby client for the Digiwin DSP Self-hosted Website Module (自有官網模組) API. Lets your storefront push orders, cancellations, invoice updates, and returns into the Digiwin ERP through the DSP gateway.
9
9
 
10
10
  | Operation | Resource | Endpoint |
11
11
  |---|---|---|
12
- | 新增訂單 | `DigiwinDsp::Resources::Order` | `POST /v1/SalesOrder/add` |
13
- | 取消訂單 | `DigiwinDsp::Resources::Cancellation` | `POST /v1/SalesOrder/cancel` |
14
- | 發票更新 | `DigiwinDsp::Resources::Invoice` | `POST /v1/SalesOrder/invoice` |
15
- | 退貨 | `DigiwinDsp::Resources::Return` | `POST /v1/SalesOrder/return` |
12
+ | Create order | `DigiwinDsp::Resources::Order` | `POST /v1/SalesOrder/add` |
13
+ | Cancel order | `DigiwinDsp::Resources::Cancellation` | `POST /v1/SalesOrder/cancel` |
14
+ | Invoice update | `DigiwinDsp::Resources::Invoice` | `POST /v1/SalesOrder/invoice` |
15
+ | Return | `DigiwinDsp::Resources::Return` | `POST /v1/SalesOrder/return` |
16
16
 
17
17
  See [`docs/dsp-api-spec.md`](./docs/dsp-api-spec.md) (plus `docs/dsp-specs/*.yaml`) for the wire spec.
18
18
 
@@ -46,16 +46,17 @@ end
46
46
 
47
47
  Every setting also falls back to an ENV var:
48
48
 
49
- | Setting | ENV var | Default |
50
- |---|---|---|
51
- | `api_key` | `DIGIWIN_DSP_API_KEY` | _(required)_ |
52
- | `api_secret` | `DIGIWIN_DSP_API_SECRET` | `nil` |
53
- | `platform_id` | `DIGIWIN_DSP_PLATFORM_ID` | `nil` |
54
- | `environment` | `DIGIWIN_DSP_ENV` | `:sandbox` |
55
- | `base_url` | `DIGIWIN_DSP_BASE_URL` | resolved from `environment` |
56
- | `timeout` | — | `10` |
57
- | `open_timeout` | — | `5` |
58
- | `logger` | — | `Logger.new(IO::NULL)` |
49
+ | Setting | ENV var | Default | Notes |
50
+ |---|---|---|---|
51
+ | `api_key` | `DIGIWIN_DSP_API_KEY` | _(required)_ | sent as `DSP-api-key` header |
52
+ | `api_secret` | `DIGIWIN_DSP_API_SECRET` | `nil` | reserved for future HMAC signing; unused today |
53
+ | `platform_id` | `DIGIWIN_DSP_PLATFORM_ID` | `nil` | sent **per-record** in `request_detail.platform_id` (not in auth headers) |
54
+ | `environment` | `DIGIWIN_DSP_ENV` | `:sandbox` | `:sandbox` (UAT) or `:production` |
55
+ | `base_url` | `DIGIWIN_DSP_BASE_URL` | resolved from `environment` | must be `https://` and have a host in `allowed_hosts` |
56
+ | `allowed_hosts` | — | `["digiwindsp.digiwin.com"]` | SSRF allowlist; extend if you proxy DSP through a different domain |
57
+ | `timeout` | — | `10` | seconds |
58
+ | `open_timeout` | — | `5` | seconds |
59
+ | `logger` | — | `Logger.new(IO::NULL)` | any Logger-like object |
59
60
 
60
61
  Base URLs (resolved from `environment`):
61
62
 
@@ -64,6 +65,21 @@ Base URLs (resolved from `environment`):
64
65
 
65
66
  See [`.env.local.example`](./.env.local.example) for a starter env file.
66
67
 
68
+ ### Custom proxy host
69
+
70
+ If you front DSP with a corporate proxy or use a mock server, add the host:
71
+
72
+ ```ruby
73
+ DigiwinDsp.configure do |c|
74
+ c.allowed_hosts += ["dsp-proxy.your-co.internal"]
75
+ c.base_url = "https://dsp-proxy.your-co.internal/api/DSP"
76
+ end
77
+ ```
78
+
79
+ Any host not in `allowed_hosts`, or any non-`https://` URL, raises
80
+ `DigiwinDsp::ConfigurationError`. This is an SSRF + HTTP-downgrade guard
81
+ since `DIGIWIN_DSP_BASE_URL` accepts arbitrary input.
82
+
67
83
  ## Usage
68
84
 
69
85
  Each resource exposes a single `#create(records, idempotency_key:, digi_header:)` method that returns the parsed `response_detail` array on success or raises a typed exception on failure.
@@ -75,7 +91,7 @@ record = {
75
91
  "platform_id" => "acme_storefront_test",
76
92
  "create_datetime" => Time.now.strftime("%Y-%m-%d %H:%M:%S"),
77
93
  "site_no" => "acme_storefront_test",
78
- "form_no" => "WEB202605200001", # 官網訂單編號
94
+ "form_no" => "WEB202605200001", # storefront order number
79
95
  "order_date" => "20260520",
80
96
  "buyer_name" => "王小明",
81
97
  "receiver_name" => "王小明",
@@ -92,7 +108,7 @@ record = {
92
108
  "price" => "100",
93
109
  "subtotal" => "100",
94
110
  "payment" => "100",
95
- "order_status" => "3", # 3 = 新增
111
+ "order_status" => "3", # 3 = new order
96
112
  "last_record" => "Y" # "Y" on the final line
97
113
  }
98
114
 
@@ -151,7 +167,7 @@ end
151
167
 
152
168
  ## Error handling
153
169
 
154
- All exceptions inherit from `DigiwinDsp::Error` and carry rich attributes (`#code`, `#dsp_message`, `#http_status`, `#request_id`, `#response_body`).
170
+ All exceptions inherit from `DigiwinDsp::Error` and carry structured attributes safe for logging: `#code`, `#dsp_message`, `#http_status`, `#request_id`. The raw response body is **intentionally not exposed** on exceptions to prevent PII leakage to error reporters like Sentry/Rollbar that serialize exception instance variables.
155
171
 
156
172
  | Exception | Raised when |
157
173
  |---|---|
@@ -8,8 +8,10 @@ module DigiwinDsp
8
8
  @configuration = configuration
9
9
  end
10
10
 
11
+ # Validation lives in Client#post (called per-request). Authenticator
12
+ # only ran validate! once per connection lifetime due to Client's
13
+ # memoized #default_headers, so the redundant call was misleading.
11
14
  def auth_headers
12
- @configuration.validate!
13
15
  { HEADER_NAME => @configuration.api_key }
14
16
  end
15
17
  end
@@ -6,6 +6,13 @@ require "faraday/retry"
6
6
  module DigiwinDsp
7
7
  class Client
8
8
  RETRY_STATUSES = [429, 500, 502, 503, 504].freeze
9
+ # Exponential backoff: ~0.5s, ~1s, ~2s between attempts, ±50% jitter so
10
+ # multiple Rails workers retrying the same upstream blip don't synchronize
11
+ # into a thundering herd against DSP.
12
+ RETRY_MAX = 3
13
+ RETRY_INTERVAL = 0.5
14
+ RETRY_BACKOFF_FACTOR = 2
15
+ RETRY_INTERVAL_RANDOMNESS = 0.5
9
16
  USER_AGENT = "digiwin_dsp/#{VERSION} (Faraday/#{Faraday::VERSION})".freeze
10
17
 
11
18
  STATUS_ERROR_MAP = {
@@ -16,13 +23,17 @@ module DigiwinDsp
16
23
  429 => RateLimitError
17
24
  }.freeze
18
25
 
19
- # Order matters more-specific patterns first.
26
+ # Patterns DSP returns in the response body's `Message` field on Status=Failure.
27
+ # The Chinese substrings are verbatim DSP responses — see docs/dsp-api-spec.md.
28
+ # Live DSP prepends the offending form_no to Message (e.g.
29
+ # "ORDER-123:Duplicated:訂單不可重複"), so we substring-match rather than
30
+ # anchor with \A. Order matters: more-specific patterns first.
20
31
  ENVELOPE_FAILURE_MAP = [
21
- [/\ADuplicated:/, DuplicateRequestError],
22
- [/\AProcessing:資料處理中/, RateLimitError],
23
- [/\AProcessing:取消訂單處理中/, ValidationError],
24
- [/\AWrongStatus:/, ValidationError],
25
- [/\A系統異常:/, ServerError]
32
+ [/Duplicated:/, DuplicateRequestError], # order already exists
33
+ [/Processing:資料處理中/, RateLimitError], # transient; retry later
34
+ [/Processing:取消訂單處理中/, ValidationError], # cancel in flight
35
+ [/WrongStatus:/, ValidationError], # bad payload
36
+ [/系統異常:/, ServerError] # DSP internal error
26
37
  ].freeze
27
38
 
28
39
  def initialize(configuration: DigiwinDsp.configuration, authenticator: nil)
@@ -32,6 +43,7 @@ module DigiwinDsp
32
43
 
33
44
  def post(path, body, idempotency_key: nil, headers: {})
34
45
  @configuration.validate!
46
+ sanitize_request_headers!(idempotency_key, headers)
35
47
  response = connection.post(normalize_path(path)) do |req|
36
48
  req.headers["X-Idempotency-Key"] = idempotency_key if idempotency_key
37
49
  headers.each { |k, v| req.headers[k] = v }
@@ -48,12 +60,15 @@ module DigiwinDsp
48
60
  @connection ||= Faraday.new(url: connection_base_url, headers: default_headers) do |f|
49
61
  f.request :json
50
62
  f.request :retry,
51
- max: 3,
52
- interval: 0.0,
53
- backoff_factor: 1,
63
+ max: RETRY_MAX,
64
+ interval: RETRY_INTERVAL,
65
+ backoff_factor: RETRY_BACKOFF_FACTOR,
66
+ interval_randomness: RETRY_INTERVAL_RANDOMNESS,
54
67
  retry_statuses: RETRY_STATUSES,
55
68
  methods: %i[get post put patch delete]
56
- f.response :json, content_type: /\bjson\z/
69
+ # max_nesting caps deserialization depth so a hostile / malformed DSP
70
+ # response can't allocate unbounded memory (DoS guard on the parser).
71
+ f.response :json, content_type: /\bjson\z/, parser_options: { max_nesting: 50 }
57
72
  f.response :logger, @configuration.logger, headers: false, bodies: false, log_level: :debug
58
73
  f.adapter Faraday.default_adapter
59
74
  f.options.timeout = @configuration.timeout
@@ -110,8 +125,7 @@ module DigiwinDsp
110
125
  code: hash["error_code"] || hash["code"],
111
126
  dsp_message: hash["error_message"] || hash["message"],
112
127
  request_id: hash["request_id"],
113
- http_status: status,
114
- response_body: body
128
+ http_status: status
115
129
  }
116
130
  end
117
131
 
@@ -120,9 +134,21 @@ module DigiwinDsp
120
134
  code: body["Status"],
121
135
  dsp_message: body["Message"],
122
136
  request_id: body["request_id"],
123
- http_status: 200,
124
- response_body: body
137
+ http_status: 200
125
138
  }
126
139
  end
140
+
141
+ # Block CRLF/LF/CR in header names and values. RFC 7230 §3.2.4 forbids
142
+ # them and Faraday + Net::HTTP catch many forms, but not all — close
143
+ # the gap to prevent header-injection / request-smuggling.
144
+ def sanitize_request_headers!(idempotency_key, headers)
145
+ sanitize_header!("X-Idempotency-Key", idempotency_key) if idempotency_key
146
+ headers.each { |k, v| sanitize_header!(k, v) }
147
+ end
148
+
149
+ def sanitize_header!(name, value)
150
+ raise ArgumentError, "invalid header value for #{name.inspect}: contains CRLF/LF/CR" if value.to_s.match?(/[\r\n]/)
151
+ raise ArgumentError, "invalid header name #{name.inspect}: contains CRLF/LF/CR" if name.to_s.match?(/[\r\n]/)
152
+ end
127
153
  end
128
154
  end
@@ -1,45 +1,67 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "logger"
4
+ require "uri"
4
5
 
5
6
  module DigiwinDsp
6
7
  class Configuration
7
8
  DEFAULT_TIMEOUT = 10
8
9
  DEFAULT_OPEN_TIMEOUT = 5
10
+ DEFAULT_ALLOWED_HOSTS = ["digiwindsp.digiwin.com"].freeze
9
11
 
10
12
  BASE_URLS = {
11
13
  sandbox: "https://digiwindsp.digiwin.com/DSP_UAT/api/DSP",
12
14
  production: "https://digiwindsp.digiwin.com/DSP/api/DSP"
13
15
  }.freeze
14
16
 
15
- attr_accessor :api_key, :api_secret, :platform_id, :environment, :logger, :timeout, :open_timeout
17
+ attr_accessor :api_key, :api_secret, :platform_id, :environment, :logger,
18
+ :timeout, :open_timeout, :allowed_hosts
16
19
  attr_writer :base_url
17
20
 
18
21
  def initialize
19
- @api_key = ENV["DIGIWIN_DSP_API_KEY"]
20
- @api_secret = ENV["DIGIWIN_DSP_API_SECRET"]
21
- @platform_id = ENV["DIGIWIN_DSP_PLATFORM_ID"]
22
- @environment = ENV.fetch("DIGIWIN_DSP_ENV") { "sandbox" }.to_sym
23
- @base_url = ENV["DIGIWIN_DSP_BASE_URL"]
24
- @timeout = DEFAULT_TIMEOUT
25
- @open_timeout = DEFAULT_OPEN_TIMEOUT
26
- @logger = Logger.new(IO::NULL)
22
+ @api_key = ENV["DIGIWIN_DSP_API_KEY"]
23
+ @api_secret = ENV["DIGIWIN_DSP_API_SECRET"]
24
+ @platform_id = ENV["DIGIWIN_DSP_PLATFORM_ID"]
25
+ @environment = ENV.fetch("DIGIWIN_DSP_ENV") { "sandbox" }.to_sym
26
+ @base_url = ENV["DIGIWIN_DSP_BASE_URL"]
27
+ @timeout = DEFAULT_TIMEOUT
28
+ @open_timeout = DEFAULT_OPEN_TIMEOUT
29
+ @logger = Logger.new(IO::NULL)
30
+ @allowed_hosts = DEFAULT_ALLOWED_HOSTS.dup
27
31
  end
28
32
 
29
33
  def base_url
30
- return @base_url if @base_url
34
+ url = @base_url || resolve_default_base_url
35
+ validate_url!(url)
36
+ url
37
+ end
38
+
39
+ def validate!
40
+ return unless api_key.nil? || api_key.to_s.empty?
41
+
42
+ raise ConfigurationError,
43
+ "api_key is required (set via DigiwinDsp.configure or DIGIWIN_DSP_API_KEY)"
44
+ end
45
+
46
+ private
31
47
 
48
+ def resolve_default_base_url
32
49
  BASE_URLS.fetch(environment) do
33
50
  raise ConfigurationError,
34
51
  "unknown environment #{environment.inspect}; expected one of #{BASE_URLS.keys.inspect}"
35
52
  end
36
53
  end
37
54
 
38
- def validate!
39
- return unless api_key.nil? || api_key.to_s.empty?
55
+ def validate_url!(url)
56
+ uri = URI.parse(url)
57
+ raise ConfigurationError, "base_url must use https (got #{uri.scheme.inspect})" unless uri.scheme == "https"
58
+ return if allowed_hosts.include?(uri.host)
40
59
 
41
60
  raise ConfigurationError,
42
- "api_key is required (set via DigiwinDsp.configure or DIGIWIN_DSP_API_KEY)"
61
+ "base_url host #{uri.host.inspect} is not in allowed_hosts #{allowed_hosts.inspect}; " \
62
+ "add it via DigiwinDsp.configure { |c| c.allowed_hosts += [host] }"
63
+ rescue URI::InvalidURIError => e
64
+ raise ConfigurationError, "base_url is not a valid URI: #{e.message}"
43
65
  end
44
66
  end
45
67
  end
@@ -5,8 +5,8 @@ module DigiwinDsp
5
5
  class Cancellation
6
6
  PATH = "/v1/SalesOrder/cancel"
7
7
 
8
- def self.create(records, **)
9
- new.create(records, **)
8
+ def self.create(records, idempotency_key: nil, digi_header: nil)
9
+ new.create(records, idempotency_key: idempotency_key, digi_header: digi_header)
10
10
  end
11
11
 
12
12
  def initialize(client = Client.new)
@@ -16,7 +16,9 @@ module DigiwinDsp
16
16
  def create(records, idempotency_key: nil, digi_header: nil)
17
17
  body = Serializers::CancellationSerializer.serialize(records, digi_header: digi_header)
18
18
  response = @client.post(PATH, body, idempotency_key: idempotency_key)
19
- response["response_detail"]
19
+ response.fetch("response_detail") do
20
+ raise DigiwinDsp::ServerError, "DSP returned Status=Success without response_detail"
21
+ end
20
22
  end
21
23
  end
22
24
  end
@@ -5,8 +5,8 @@ module DigiwinDsp
5
5
  class Invoice
6
6
  PATH = "/v1/SalesOrder/invoice"
7
7
 
8
- def self.create(records, **)
9
- new.create(records, **)
8
+ def self.create(records, idempotency_key: nil, digi_header: nil)
9
+ new.create(records, idempotency_key: idempotency_key, digi_header: digi_header)
10
10
  end
11
11
 
12
12
  def initialize(client = Client.new)
@@ -16,7 +16,9 @@ module DigiwinDsp
16
16
  def create(records, idempotency_key: nil, digi_header: nil)
17
17
  body = Serializers::InvoiceSerializer.serialize(records, digi_header: digi_header)
18
18
  response = @client.post(PATH, body, idempotency_key: idempotency_key)
19
- response["response_detail"]
19
+ response.fetch("response_detail") do
20
+ raise DigiwinDsp::ServerError, "DSP returned Status=Success without response_detail"
21
+ end
20
22
  end
21
23
  end
22
24
  end
@@ -5,8 +5,8 @@ module DigiwinDsp
5
5
  class Order
6
6
  PATH = "/v1/SalesOrder/add"
7
7
 
8
- def self.create(records, **)
9
- new.create(records, **)
8
+ def self.create(records, idempotency_key: nil, digi_header: nil)
9
+ new.create(records, idempotency_key: idempotency_key, digi_header: digi_header)
10
10
  end
11
11
 
12
12
  def initialize(client = Client.new)
@@ -16,7 +16,9 @@ module DigiwinDsp
16
16
  def create(records, idempotency_key: nil, digi_header: nil)
17
17
  body = Serializers::SalesOrderSerializer.serialize(records, digi_header: digi_header)
18
18
  response = @client.post(PATH, body, idempotency_key: idempotency_key)
19
- response["response_detail"]
19
+ response.fetch("response_detail") do
20
+ raise DigiwinDsp::ServerError, "DSP returned Status=Success without response_detail"
21
+ end
20
22
  end
21
23
  end
22
24
  end
@@ -5,8 +5,8 @@ module DigiwinDsp
5
5
  class Return
6
6
  PATH = "/v1/SalesOrder/return"
7
7
 
8
- def self.create(records, **)
9
- new.create(records, **)
8
+ def self.create(records, idempotency_key: nil, digi_header: nil)
9
+ new.create(records, idempotency_key: idempotency_key, digi_header: digi_header)
10
10
  end
11
11
 
12
12
  def initialize(client = Client.new)
@@ -16,7 +16,9 @@ module DigiwinDsp
16
16
  def create(records, idempotency_key: nil, digi_header: nil)
17
17
  body = Serializers::ReturnSerializer.serialize(records, digi_header: digi_header)
18
18
  response = @client.post(PATH, body, idempotency_key: idempotency_key)
19
- response["response_detail"]
19
+ response.fetch("response_detail") do
20
+ raise DigiwinDsp::ServerError, "DSP returned Status=Success without response_detail"
21
+ end
20
22
  end
21
23
  end
22
24
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DigiwinDsp
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.1"
5
5
  end
data/lib/digiwin_dsp.rb CHANGED
@@ -4,15 +4,19 @@ require "zeitwerk"
4
4
 
5
5
  module DigiwinDsp
6
6
  class Error < StandardError
7
- attr_reader :code, :dsp_message, :request_id, :http_status, :response_body
8
-
9
- def initialize(message = nil, code: nil, dsp_message: nil, request_id: nil, http_status: nil, response_body: nil)
7
+ # NOTE: response_body is intentionally NOT exposed.
8
+ # Exception reporters (Sentry, Honeybadger, Rollbar, etc.) serialize
9
+ # instance variables by default; storing the raw DSP body would leak
10
+ # buyer PII (names, addresses, phone numbers) into third-party logging.
11
+ # Use the structured fields below for safe logging.
12
+ attr_reader :code, :dsp_message, :request_id, :http_status
13
+
14
+ def initialize(message = nil, code: nil, dsp_message: nil, request_id: nil, http_status: nil)
10
15
  super(message)
11
- @code = code
12
- @dsp_message = dsp_message
13
- @request_id = request_id
14
- @http_status = http_status
15
- @response_body = response_body
16
+ @code = code
17
+ @dsp_message = dsp_message
18
+ @request_id = request_id
19
+ @http_status = http_status
16
20
  end
17
21
  end
18
22
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: digiwin_dsp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Zac
@@ -52,8 +52,9 @@ dependencies:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
54
  version: '2.6'
55
- description: Synchronous Ruby gem wrapping the Digiwin DSP 自有官網模組 endpoints (create
56
- order, cancel order, invoice update, return) for use from a Rails site.
55
+ description: Synchronous Ruby gem wrapping the Digiwin DSP Self-hosted Website Module
56
+ (自有官網模組) endpoints — create order, cancel order, invoice update, and return for
57
+ use from a Rails storefront.
57
58
  email:
58
59
  - 579103+7a6163@users.noreply.github.com
59
60
  executables: []
@@ -102,5 +103,5 @@ requirements: []
102
103
  rubygems_version: 3.5.22
103
104
  signing_key:
104
105
  specification_version: 4
105
- summary: Ruby client for the Digiwin DSP 自有官網模組 API.
106
+ summary: Ruby client for the Digiwin DSP Self-hosted Website Module (自有官網模組) API.
106
107
  test_files: []