digiwin_dsp 0.1.1 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c0fd438c3029652103006f9fbb4b69c21abb7d699fb9e77234822e939660d710
4
- data.tar.gz: 2b823c5afdb2452d18e2c22f9ad2111f1f0df5ab84862a400ba63a80b6c30bd2
3
+ metadata.gz: ce7484e03d7d931e7b16d56ac6be5c6842414da91b22a559d4d298aaa69c25e7
4
+ data.tar.gz: d250ea83a0f9516d49679311ab960e0009f3597ac46b553e6efdc0e344d280ed
5
5
  SHA512:
6
- metadata.gz: 9695c0dea30adeced40a3b118ce95162dbd6cbcf0498ce30e82cd1d663107ae96a0dc0b57b54008eb7815d008a403efa1b73a915652d7d7ef488382982890ad5
7
- data.tar.gz: 982922896540b5f6e5b2f7cbf2cdda02529bb4bbf89ee53005ea5030f0522b74407da2115547054b024a5dfae7caf175c46fc141e8006506e4f965ffdf8824fb
6
+ metadata.gz: 2b496c0024aacf110d67fdb273d5f22b93c3825685dd0acedea7e235efa6cfd2aee4cb0cc247edf96272fe1de58a5da95ae233200b3cbb0dfc4f8333cc987fd8
7
+ data.tar.gz: 43641ec002ecd264abf8d3734234ea0bd66674608c8c499c74f5264a9d577181c0ba97664a9140ca3fa68a033e6055e368c2680ef7f3248ea199e36ffc7e2b08
data/CHANGELOG.md CHANGED
@@ -6,6 +6,63 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.0] - 2026-05-21
10
+
11
+ Security + correctness release. Addresses every HIGH and MEDIUM finding
12
+ from the v0.1.x code review. Pre-1.0 SemVer; contains one breaking change.
13
+
14
+ ### BREAKING
15
+
16
+ - **`DigiwinDsp::Error#response_body` removed.** Storing the full DSP
17
+ response on every exception leaked buyer PII (names, addresses, phone
18
+ numbers) to Sentry/Honeybadger/Rollbar via their default instance-var
19
+ serialization. Structured fields remain: `#code`, `#dsp_message`,
20
+ `#http_status`, `#request_id`. If you need the raw body, capture it in
21
+ your own Faraday middleware before the request reaches this gem.
22
+ **Migration:** delete any `e.response_body` access from rescue blocks.
23
+
24
+ ### Added
25
+
26
+ - `Configuration#allowed_hosts` (default `["digiwindsp.digiwin.com"]`) —
27
+ SSRF allowlist enforced on `#base_url`. Extend it for proxy/mock setups:
28
+ `c.allowed_hosts += ["dsp-proxy.your-co.internal"]`.
29
+ - CRLF (`\r` / `\n`) validation on `idempotency_key` and every entry of
30
+ the `headers:` kwarg in `Client#post`. Raises `ArgumentError` on
31
+ injection attempts (closes a header-smuggling vector that Faraday's
32
+ default adapter doesn't fully catch).
33
+ - `bundler-audit` ~> 0.9 in dev/test + a CI step (`bundle-audit check
34
+ --update`) that fails on any known CVE in the locked dependency tree.
35
+
36
+ ### Changed
37
+
38
+ - `Configuration#base_url` now validates the resolved URL:
39
+ - scheme MUST be `https` (no HTTP downgrade for the `DSP-api-key`)
40
+ - host MUST be in `allowed_hosts` (default: only `digiwindsp.digiwin.com`)
41
+ - malformed URIs raise `ConfigurationError`
42
+ - Resources::{Order,Cancellation,Invoice,Return}#create now raise
43
+ `DigiwinDsp::ServerError` when DSP returns `Status:"Success"` without a
44
+ `response_detail` key, instead of returning `nil` (which became a
45
+ silent `NoMethodError` downstream).
46
+ - Faraday retry now uses real exponential backoff with jitter
47
+ (`interval: 0.5, backoff_factor: 2, interval_randomness: 0.5`).
48
+ Previously `interval: 0, backoff_factor: 1` fired all three retries
49
+ instantly — actively counterproductive on 429 throttling.
50
+ - Faraday `:json` middleware gains `parser_options: { max_nesting: 50 }`
51
+ as a DoS guard against hostile / malformed DSP responses.
52
+ - `Resources::*.create` class-method shortcuts now declare typed kwargs
53
+ (`idempotency_key:`, `digi_header:`) so caller typos raise `ArgumentError`
54
+ at call time instead of being swallowed by `**`.
55
+ - README configuration table distinguishes runtime-used settings from
56
+ reserved ones; calls out that `platform_id` lives in `request_detail`,
57
+ not auth headers; documents `allowed_hosts` + the proxy-override pattern.
58
+
59
+ ### Removed
60
+
61
+ - `DigiwinDsp::Authenticator#auth_headers` no longer calls
62
+ `Configuration#validate!`. Validation was redundant (Client#post runs
63
+ it per-request) and silently skipped after construction-time mutation
64
+ due to connection memoization.
65
+
9
66
  ## [0.1.1] - 2026-05-21
10
67
 
11
68
  ### Added
@@ -26,7 +83,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
26
83
 
27
84
  ## [0.1.0] - 2026-05-21
28
85
 
29
- Initial release. Covers the four 自有官網模組 endpoints under `/v1/SalesOrder/*`.
86
+ Initial release. Covers the four Self-hosted Website Module (自有官網模組) endpoints under `/v1/SalesOrder/*`.
30
87
 
31
88
  ### Added
32
89
 
@@ -61,6 +118,7 @@ Initial release. Covers the four 自有官網模組 endpoints under `/v1/SalesOr
61
118
  - The gem is **synchronous on purpose**. Callers wrap requests in their own background job runner (e.g. ActiveJob) when needed.
62
119
  - Idempotency: clients can send `X-Idempotency-Key` via the `idempotency_key:` kwarg. DSP also dedupes server-side by `form_no + platform_id`.
63
120
 
64
- [Unreleased]: https://github.com/7a6163/digiwin_dsp/compare/v0.1.1...HEAD
121
+ [Unreleased]: https://github.com/7a6163/digiwin_dsp/compare/v0.2.0...HEAD
122
+ [0.2.0]: https://github.com/7a6163/digiwin_dsp/compare/v0.1.1...v0.2.0
65
123
  [0.1.1]: https://github.com/7a6163/digiwin_dsp/compare/v0.1.0...v0.1.1
66
124
  [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,15 @@ 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
+ # Order matters: more-specific patterns first.
20
29
  ENVELOPE_FAILURE_MAP = [
21
- [/\ADuplicated:/, DuplicateRequestError],
22
- [/\AProcessing:資料處理中/, RateLimitError],
23
- [/\AProcessing:取消訂單處理中/, ValidationError],
24
- [/\AWrongStatus:/, ValidationError],
25
- [/\A系統異常:/, ServerError]
30
+ [/\ADuplicated:/, DuplicateRequestError], # order already exists
31
+ [/\AProcessing:資料處理中/, RateLimitError], # transient; retry later
32
+ [/\AProcessing:取消訂單處理中/, ValidationError], # cancel in flight
33
+ [/\AWrongStatus:/, ValidationError], # bad payload
34
+ [/\A系統異常:/, ServerError] # DSP internal error
26
35
  ].freeze
27
36
 
28
37
  def initialize(configuration: DigiwinDsp.configuration, authenticator: nil)
@@ -32,6 +41,7 @@ module DigiwinDsp
32
41
 
33
42
  def post(path, body, idempotency_key: nil, headers: {})
34
43
  @configuration.validate!
44
+ sanitize_request_headers!(idempotency_key, headers)
35
45
  response = connection.post(normalize_path(path)) do |req|
36
46
  req.headers["X-Idempotency-Key"] = idempotency_key if idempotency_key
37
47
  headers.each { |k, v| req.headers[k] = v }
@@ -48,12 +58,15 @@ module DigiwinDsp
48
58
  @connection ||= Faraday.new(url: connection_base_url, headers: default_headers) do |f|
49
59
  f.request :json
50
60
  f.request :retry,
51
- max: 3,
52
- interval: 0.0,
53
- backoff_factor: 1,
61
+ max: RETRY_MAX,
62
+ interval: RETRY_INTERVAL,
63
+ backoff_factor: RETRY_BACKOFF_FACTOR,
64
+ interval_randomness: RETRY_INTERVAL_RANDOMNESS,
54
65
  retry_statuses: RETRY_STATUSES,
55
66
  methods: %i[get post put patch delete]
56
- f.response :json, content_type: /\bjson\z/
67
+ # max_nesting caps deserialization depth so a hostile / malformed DSP
68
+ # response can't allocate unbounded memory (DoS guard on the parser).
69
+ f.response :json, content_type: /\bjson\z/, parser_options: { max_nesting: 50 }
57
70
  f.response :logger, @configuration.logger, headers: false, bodies: false, log_level: :debug
58
71
  f.adapter Faraday.default_adapter
59
72
  f.options.timeout = @configuration.timeout
@@ -110,8 +123,7 @@ module DigiwinDsp
110
123
  code: hash["error_code"] || hash["code"],
111
124
  dsp_message: hash["error_message"] || hash["message"],
112
125
  request_id: hash["request_id"],
113
- http_status: status,
114
- response_body: body
126
+ http_status: status
115
127
  }
116
128
  end
117
129
 
@@ -120,9 +132,21 @@ module DigiwinDsp
120
132
  code: body["Status"],
121
133
  dsp_message: body["Message"],
122
134
  request_id: body["request_id"],
123
- http_status: 200,
124
- response_body: body
135
+ http_status: 200
125
136
  }
126
137
  end
138
+
139
+ # Block CRLF/LF/CR in header names and values. RFC 7230 §3.2.4 forbids
140
+ # them and Faraday + Net::HTTP catch many forms, but not all — close
141
+ # the gap to prevent header-injection / request-smuggling.
142
+ def sanitize_request_headers!(idempotency_key, headers)
143
+ sanitize_header!("X-Idempotency-Key", idempotency_key) if idempotency_key
144
+ headers.each { |k, v| sanitize_header!(k, v) }
145
+ end
146
+
147
+ def sanitize_header!(name, value)
148
+ raise ArgumentError, "invalid header value for #{name.inspect}: contains CRLF/LF/CR" if value.to_s.match?(/[\r\n]/)
149
+ raise ArgumentError, "invalid header name #{name.inspect}: contains CRLF/LF/CR" if name.to_s.match?(/[\r\n]/)
150
+ end
127
151
  end
128
152
  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.0"
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.0
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: []