digiwin_dsp 0.4.2 → 0.5.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: 7ea76bd5a8bb2c25914e0f9bd6c40ff1fdf5ac6c6c0e83b6652b9a4c20f26a33
4
- data.tar.gz: bca70e0fd5ba6f33831f2aaf4cf61aa29987a241d958dadd20539a34490bbf53
3
+ metadata.gz: 813b05b6b115eed5a1fd308aa3c8a255da6df70afdf090101e7bfe0f74f605e7
4
+ data.tar.gz: 7ecfa001245bff0c95125f9667e303506ab9c1170bf8cce12f039c9773715bb4
5
5
  SHA512:
6
- metadata.gz: dc0e854bc169fda091248b0048c9cf79ab0cad639b0cd431dfce867d3f9eaeaab737bad0f8103975bf9311efe99cd737b9bb05d65019cabfcb27ac8d77d1da7f
7
- data.tar.gz: 619d56aa98ec48df0055bad21448618a244b8e29623429a07adaa8ca236229d7501ade6f978c69d7df3a495de1d5be6b5d6951e25f3ba050a18a9f6c4826176e
6
+ metadata.gz: 1086238addb8b6feb861d80f4c133c9e891e4b64ed45821ffc0dd68bde3a8e0ea7fadd009ee2b34dfa78e9a640fe2b22d8590a1b364d80b43081688771b53ebd
7
+ data.tar.gz: 12079e2663087764b54acae5b774e22398dc9ec48b3379684bfd6fdb0be51ef2604ac7d2db30e59bb7a9dae6bfdbac62b965c5c29b1ac1c1c7bf0c8247ab9fb2
data/CHANGELOG.md CHANGED
@@ -6,6 +6,19 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.5.0] - 2026-10-01
10
+
11
+ ### BREAKING
12
+
13
+ - **Ruby 3.2 support dropped; supported range is now 3.3 – 4.0.** `required_ruby_version` is `>= 3.3.0` and CI runs 3.3 / 3.4 / 4.0. The dev-only `parallel < 2.0` pin (kept for the 3.2 CI row) is removed.
14
+
15
+ ### Fixed
16
+
17
+ - **POSTs are no longer resent after a read timeout.** faraday-retry's defaults retried `Faraday::TimeoutError` for every method, so a slow DSP that had already created the order would see a resend and the caller would get a spurious `DuplicateRequestError` (or a real duplicate on invoice/return, which have no documented dedupe). Only failures that happen before the request is sent (connection refused, host/network unreachable, DNS failure, connect timeout) are retried now — which also makes the README's "connection failures" claim true; previously `Faraday::ConnectionFailed` was never retried.
18
+ - **Non-object 2xx bodies raise `ServerError`** instead of being returned and blowing up as `NoMethodError` in `Resources::Base#create` / `WebhookSubscription#create` (outside the `DigiwinDsp::Error` tree, so `rescue`/`retry_on` missed it). A `{"std_data":"..."}` body no longer raises `TypeError` either.
19
+ - **Malformed inbound webhooks raise `Webhooks::ParseError`** instead of `TypeError` when an envelope level isn't an object, and `InventoryUpdate` / `LogisticsUpdate` now reject a non-object `request` payload.
20
+ - **`Configuration#environment=` accepts strings** (e.g. `c.environment = Rails.env`), matching the `DIGIWIN_DSP_ENV` path.
21
+
9
22
  ## [0.4.2] - 2026-06-18
10
23
 
11
24
  ### Added
@@ -264,7 +277,8 @@ Initial release. Covers the four Self-hosted Website Module (自有官網模組)
264
277
  - The gem is **synchronous on purpose**. Callers wrap requests in their own background job runner (e.g. ActiveJob) when needed.
265
278
  - Idempotency: clients can send `X-Idempotency-Key` via the `idempotency_key:` kwarg. DSP also dedupes server-side by `form_no + platform_id`.
266
279
 
267
- [Unreleased]: https://github.com/7a6163/digiwin_dsp/compare/v0.4.2...HEAD
280
+ [Unreleased]: https://github.com/7a6163/digiwin_dsp/compare/v0.5.0...HEAD
281
+ [0.5.0]: https://github.com/7a6163/digiwin_dsp/compare/v0.4.2...v0.5.0
268
282
  [0.4.2]: https://github.com/7a6163/digiwin_dsp/compare/v0.4.1...v0.4.2
269
283
  [0.4.1]: https://github.com/7a6163/digiwin_dsp/compare/v0.4.0...v0.4.1
270
284
  [0.4.0]: https://github.com/7a6163/digiwin_dsp/compare/v0.3.1...v0.4.0
data/README.md CHANGED
@@ -1,10 +1,13 @@
1
1
  # digiwin_dsp
2
2
 
3
+ [![Gem Version](https://badge.fury.io/rb/digiwin_dsp.svg)](https://badge.fury.io/rb/digiwin_dsp)
3
4
  [![CI](https://github.com/7a6163/digiwin_dsp/actions/workflows/ci.yml/badge.svg)](https://github.com/7a6163/digiwin_dsp/actions/workflows/ci.yml)
4
5
  [![codecov](https://codecov.io/gh/7a6163/digiwin_dsp/branch/main/graph/badge.svg)](https://codecov.io/gh/7a6163/digiwin_dsp)
5
- [![Ruby](https://img.shields.io/badge/ruby-%E2%89%A53.2-CC342D)](https://www.ruby-lang.org/)
6
+ [![Ruby](https://img.shields.io/badge/ruby-3.3%E2%80%934.0-CC342D)](https://www.ruby-lang.org/)
6
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE.txt)
7
8
 
9
+ Homepage: <https://rubygems.org/gems/digiwin_dsp>
10
+
8
11
  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
12
 
10
13
  | Operation | Resource | Endpoint |
@@ -29,7 +32,7 @@ gem "digiwin_dsp"
29
32
  bundle install
30
33
  ```
31
34
 
32
- Ruby ≥ 3.2 required.
35
+ Ruby 3.3 – 4.0 supported (≥ 3.3 required).
33
36
 
34
37
  ## Configuration
35
38
 
@@ -227,7 +230,8 @@ Every `Client#post` already retries transparently inside Faraday:
227
230
  | Setting | Value |
228
231
  |---|---|
229
232
  | Attempts | up to 4 (1 original + max 3 retries) |
230
- | Triggers | HTTP 429, 500, 502, 503, 504, connection failures |
233
+ | Triggers | HTTP 429, 500, 502, 503, 504; connection failures that happen *before* the request reaches DSP (refused, host/network unreachable, DNS failure, connect timeout) |
234
+ | Never retried | Read timeouts and mid-request connection resets — DSP may already have processed the POST, so resending could duplicate it. These raise `NetworkError`; check DSP state before retrying |
231
235
  | Backoff | exponential — ~0.5s, ~1s, ~2s between attempts, ±50% jitter |
232
236
 
233
237
  So one `Resources::Order.create` call can take up to ~`4 × timeout + 3.5s` in the worst case (default `timeout` 10s → ~44s). **Size your job timeouts and queue latency budgets accordingly** — if you also add `retry_on` in ActiveJob/Sidekiq (recommended for `RateLimitError`, which DSP signals via the envelope and the gem does *not* retry internally), the two layers multiply.
@@ -13,6 +13,29 @@ module DigiwinDsp
13
13
  RETRY_INTERVAL = 0.5
14
14
  RETRY_BACKOFF_FACTOR = 2
15
15
  RETRY_INTERVAL_RANDOMNESS = 0.5
16
+ # Transport failures that happen before the request reaches DSP, so a
17
+ # resend can't duplicate a POST. Read timeouts, ECONNRESET, EPIPE etc.
18
+ # are deliberately absent: DSP may already have processed the request.
19
+ PRE_SEND_ERRORS = [
20
+ Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, SocketError, Net::OpenTimeout
21
+ ].freeze
22
+ RETRY_IF = lambda do |_env, error|
23
+ !error.is_a?(Faraday::ConnectionFailed) ||
24
+ PRE_SEND_ERRORS.any? { |klass| error.wrapped_exception.is_a?(klass) }
25
+ end
26
+ RETRY_OPTIONS = {
27
+ max: RETRY_MAX,
28
+ interval: RETRY_INTERVAL,
29
+ backoff_factor: RETRY_BACKOFF_FACTOR,
30
+ interval_randomness: RETRY_INTERVAL_RANDOMNESS,
31
+ retry_statuses: RETRY_STATUSES,
32
+ # Faraday::TimeoutError is left out: a read timeout on a POST must not
33
+ # be resent (see PRE_SEND_ERRORS).
34
+ exceptions: [Faraday::ConnectionFailed, Faraday::RetriableResponse],
35
+ # Empty methods routes every retry decision through RETRY_IF.
36
+ methods: [],
37
+ retry_if: RETRY_IF
38
+ }.freeze
16
39
  USER_AGENT = "digiwin_dsp/#{VERSION} (Faraday/#{Faraday::VERSION})".freeze
17
40
 
18
41
  STATUS_ERROR_MAP = {
@@ -67,13 +90,7 @@ module DigiwinDsp
67
90
  def connection
68
91
  @connection ||= Faraday.new(url: connection_base_url, headers: default_headers) do |f|
69
92
  f.request :json
70
- f.request :retry,
71
- max: RETRY_MAX,
72
- interval: RETRY_INTERVAL,
73
- backoff_factor: RETRY_BACKOFF_FACTOR,
74
- interval_randomness: RETRY_INTERVAL_RANDOMNESS,
75
- retry_statuses: RETRY_STATUSES,
76
- methods: %i[get post put patch delete]
93
+ f.request :retry, RETRY_OPTIONS
77
94
  # max_nesting caps deserialization depth so a hostile / malformed DSP
78
95
  # response can't allocate unbounded memory (DoS guard on the parser).
79
96
  f.response :json, content_type: /\bjson\z/, parser_options: { max_nesting: 50 }
@@ -114,8 +131,12 @@ module DigiwinDsp
114
131
  klass.new(message, **error_attrs(status, body))
115
132
  end
116
133
 
134
+ # Every DSP endpoint answers with a JSON object; anything else on a 2xx
135
+ # (IIS HTML error page, empty body) would otherwise surface downstream
136
+ # as NoMethodError, outside the DigiwinDsp::Error tree. The body itself
137
+ # stays out of the message (PII).
117
138
  def inspect_envelope(body)
118
- return body unless body.is_a?(Hash)
139
+ raise ServerError.new("DSP returned a non-JSON-object 2xx body (#{body.class})", http_status: 200) unless body.is_a?(Hash)
119
140
 
120
141
  failure = detect_envelope_failure(body)
121
142
  return body unless failure
@@ -128,7 +149,7 @@ module DigiwinDsp
128
149
  # - DSPOOFFICIAL100: { srvver, std_data: { execution: { code, description }, response } }
129
150
  # - DSPOOFFICIAL001-005: { Status, Message, response_detail }
130
151
  def detect_envelope_failure(body)
131
- if (exec = body.dig("std_data", "execution"))
152
+ if (exec = execution_block(body))
132
153
  return nil if exec["code"].to_s == "0"
133
154
 
134
155
  { message: exec["description"].to_s, code: exec["code"] }
@@ -142,6 +163,13 @@ module DigiwinDsp
142
163
  end
143
164
  end
144
165
 
166
+ # Hash-checked dig: a 2xx like {"std_data":"x"} must not raise TypeError.
167
+ def execution_block(body)
168
+ std_data = body["std_data"]
169
+ exec = std_data["execution"] if std_data.is_a?(Hash)
170
+ exec if exec.is_a?(Hash)
171
+ end
172
+
145
173
  # Neither known envelope. If DSP ships a third shape, failures would
146
174
  # otherwise pass through as "success" silently — leave a trace.
147
175
  def warn_unknown_envelope(body)
@@ -21,9 +21,15 @@ module DigiwinDsp
21
21
  production: "https://digiwindsp.digiwin.com/DSP/api/webhook"
22
22
  }.freeze
23
23
 
24
- attr_accessor :api_key, :platform_id, :environment, :logger,
24
+ attr_accessor :api_key, :platform_id, :logger,
25
25
  :timeout, :open_timeout, :allowed_hosts
26
26
  attr_writer :base_url, :webhook_base_url
27
+ attr_reader :environment
28
+
29
+ # Accept strings too (e.g. `c.environment = Rails.env`), like the ENV path.
30
+ def environment=(value)
31
+ @environment = value&.to_sym
32
+ end
27
33
 
28
34
  def initialize
29
35
  @api_key = ENV["DIGIWIN_DSP_API_KEY"]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DigiwinDsp
4
- VERSION = "0.4.2"
4
+ VERSION = "0.5.0"
5
5
  end
@@ -26,8 +26,13 @@ module DigiwinDsp
26
26
  def self.extract_request(hash)
27
27
  raise ParseError, "envelope must be a JSON object" unless hash.is_a?(Hash)
28
28
 
29
- hash.dig("digi_body", "std_data", "parameter", "request") ||
30
- raise(ParseError, "envelope missing digi_body.std_data.parameter.request")
29
+ # Walk by hand: Hash#dig raises TypeError when an intermediate level
30
+ # is a String/Array, which would escape the DigiwinDsp::Error tree
31
+ # (webhooks are unsigned, so anyone can send such a body).
32
+ request = %w[digi_body std_data parameter request].reduce(hash) do |node, key|
33
+ node.is_a?(Hash) ? node[key] : nil
34
+ end
35
+ request || raise(ParseError, "envelope missing digi_body.std_data.parameter.request")
31
36
  end
32
37
 
33
38
  private_class_method :parse_json, :extract_request
@@ -6,6 +6,13 @@ module DigiwinDsp
6
6
  # See docs/dsp-specs/DSPOOFFICIAL100.yaml under
7
7
  # "庫存數量更新 product/inventory_update".
8
8
  class InventoryUpdate < Event
9
+ def self.parse(raw_body)
10
+ event = super
11
+ raise ParseError, "product/inventory_update payload must be a JSON object" unless event.request.is_a?(Hash)
12
+
13
+ event
14
+ end
15
+
9
16
  def prod = request["prod"]
10
17
  def platform_id = request["platform_id"]
11
18
  def sale_page_id = request["sale_page_id"]
@@ -6,6 +6,13 @@ module DigiwinDsp
6
6
  # delivered, etc.). See docs/dsp-specs/DSPOOFFICIAL100.yaml under
7
7
  # "倉儲貨態更新 wms/logistics/package/update".
8
8
  class LogisticsUpdate < Event
9
+ def self.parse(raw_body)
10
+ event = super
11
+ raise ParseError, "wms/logistics/package/update payload must be a JSON object" unless event.request.is_a?(Hash)
12
+
13
+ event
14
+ end
15
+
9
16
  def form_no = request["form_no"]
10
17
  def func_name = request["func_name"]
11
18
  def status_date = request["status_date"]
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: digiwin_dsp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.2
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Zac
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-06-18 00:00:00.000000000 Z
11
+ date: 2026-10-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -101,7 +101,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
101
101
  requirements:
102
102
  - - ">="
103
103
  - !ruby/object:Gem::Version
104
- version: 3.2.0
104
+ version: 3.3.0
105
105
  required_rubygems_version: !ruby/object:Gem::Requirement
106
106
  requirements:
107
107
  - - ">="