digiwin_dsp 0.2.4 → 0.3.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 +4 -4
- data/CHANGELOG.md +50 -1
- data/README.md +60 -1
- data/lib/digiwin_dsp/client.rb +35 -17
- data/lib/digiwin_dsp/configuration.rb +31 -10
- data/lib/digiwin_dsp/resources/webhook_subscription.rb +72 -0
- data/lib/digiwin_dsp/version.rb +1 -1
- data/lib/digiwin_dsp/webhooks/event.rb +42 -0
- data/lib/digiwin_dsp/webhooks/inventory_update.rb +15 -0
- data/lib/digiwin_dsp/webhooks/invoice_update.rb +20 -0
- data/lib/digiwin_dsp/webhooks/logistics_update.rb +18 -0
- data/lib/digiwin_dsp/webhooks.rb +30 -0
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3cfde42cbd2b4448e9c5e4927e9ecb65f61ae19a46474154ae1349d73cd2461c
|
|
4
|
+
data.tar.gz: 6d35ad2fd9430c5440f626b15f3c98c9a8dd627c2732bb349bc355f6693d9de3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 95d2787c766fa0a316542b37d0b5620738e9dcff687562875698e5c1d0f3a3ffaaa2e93a81f24b34e0f6e27047a9c53d22e1368725879f36a6f48116b02f2b7c
|
|
7
|
+
data.tar.gz: c8f1b7781c06e3fa745d4e2a6770d7442dd443a38e10ca4ce6767b5aaa82bbf1016ef8aeb961922ebe9f41321d6702be561a1c82810223335a0a340c6f6eb27b
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,53 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.3.1] - 2026-06-12
|
|
10
|
+
|
|
11
|
+
Hardening patch from a full gem + docs review. All fixes grounded in the vendor YAML specs.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- **TLS failures now raise `DigiwinDsp::NetworkError`.** `Faraday::SSLError` sits directly under `Faraday::Error` (not `ConnectionFailed`), so certificate problems previously leaked as raw Faraday exceptions that `rescue DigiwinDsp::Error` could not catch.
|
|
16
|
+
- **Three more DSP failure messages classify into typed exceptions** instead of falling through to generic `Error`:
|
|
17
|
+
- `Shipped:訂單已出貨,不可取消` (DSPOOFFICIAL002) → `ValidationError` — permanent; the order left the warehouse
|
|
18
|
+
- `Processing:新增訂單處理中,不可取消` (DSPOOFFICIAL002) → `RateLimitError` — retryable once ERP processes the add
|
|
19
|
+
- `SalesNotCreate:銷貨單未成立` (DSPOOFFICIAL004) → `RateLimitError` — retryable once ERP converts the sales doc
|
|
20
|
+
Sidekiq/ActiveJob retry strategies can now distinguish "don't retry" from "retry later" on cancel and invoice flows.
|
|
21
|
+
- **`Webhooks::Event.parse_json` / `.extract_request` are now private class methods.** They were internal helpers accidentally exposed on all three event classes.
|
|
22
|
+
- **`WebhookSubscription` rejects non-`https://` callback addresses** at registration time. DSPOOFFICIAL100 mandates HTTPS callbacks, and with no HMAC signing a plaintext callback would be indefensible. (Previously the README claimed this was enforced when it wasn't.)
|
|
23
|
+
|
|
24
|
+
### Docs
|
|
25
|
+
|
|
26
|
+
- `dsp-api-spec.md`: corrected the e-invoice carrier field name — `Resources::Invoice` sends **`carrier_type`** (DSPOOFFICIAL004:164), not `carrier_code`. `carrier_code` is only on `Resources::Order` and the inbound webhook. Optional fields aren't validated client-side, so the wrong name silently drops carrier data.
|
|
27
|
+
- `dsp-api-spec.md`: failure-message table now covers 002 (cancel) and 004 (invoice) sources, not just 001.
|
|
28
|
+
- README: webhook security bullet now describes the actual https enforcement.
|
|
29
|
+
|
|
30
|
+
## [0.3.0] - 2026-05-28
|
|
31
|
+
|
|
32
|
+
DSP webhook support (DSPOOFFICIAL100). Lets a Rails app register a callback URL with DSP, then receive and parse the three documented ERP-originated push events: inventory updates, shipping/logistics status, and invoice issuance.
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
- **`DigiwinDsp::Resources::WebhookSubscription`** — outbound wrapper for `POST /v1/webhook` on the new webhook base path. Registers a callback URL for one of three actions (`product/inventory_update` / `wms/logistics/package/update` / `invoice/update`). Validates locally: known action, non-empty address, ≤500 chars per the spec.
|
|
37
|
+
- **`DigiwinDsp::Webhooks` module** with three inbound event parsers:
|
|
38
|
+
- `Webhooks::InventoryUpdate` — exposes `prod`, `platform_id`, `sale_page_id`, `spec_list`
|
|
39
|
+
- `Webhooks::LogisticsUpdate` — exposes `form_no`, `func_name`, `status_date`, `status_time`, `tracking_number`, `distributor_code`, `message`
|
|
40
|
+
- `Webhooks::InvoiceUpdate` — exposes `invoices` (Array; DSP batches multiple invoices per push)
|
|
41
|
+
- `Webhooks.parse(raw_body, action:)` dispatcher routes by action string
|
|
42
|
+
- `Webhooks::ParseError < ValidationError` for malformed JSON / envelope / unknown action
|
|
43
|
+
- **`Configuration#webhook_base_url`** — separate base path for DSPOOFFICIAL100 (`/api/webhook` vs the SalesOrder `/api/DSP`). Defaults resolve from `environment`; explicit override via `DIGIWIN_DSP_WEBHOOK_BASE_URL` env var. Same HTTPS + `allowed_hosts` guard as `base_url`.
|
|
44
|
+
- **Client `base_url:` kwarg override** so the new `WebhookSubscription` can target the webhook base path without leaking the routing into `Configuration#base_url`.
|
|
45
|
+
- **Dual-envelope detection in `Client#inspect_envelope`** — handles both `{Status, Message, response_detail[]}` (SalesOrder family) and `{srvver, std_data: {execution, response}}` (webhook family). Both run through the same `ENVELOPE_FAILURE_MAP` regex classifier, so `WrongStatus:`, `系統異常:`, `Duplicated:`, etc. produce the right typed exception regardless of endpoint.
|
|
46
|
+
- `docs/dsp-specs/DSPOOFFICIAL100.yaml` — the OpenAPI spec used to drive the implementation.
|
|
47
|
+
|
|
48
|
+
### Security
|
|
49
|
+
|
|
50
|
+
- ⚠️ **DSP does not sign inbound webhooks.** No HMAC header documented. README calls out defense-in-depth: HTTPS-only callback URL, unguessable path, optional IP allowlist, 200-within-30s reply, caller-side idempotency by `form_no` / `invoice_number`.
|
|
51
|
+
|
|
52
|
+
### Changed
|
|
53
|
+
|
|
54
|
+
- Removed dead `Client#envelope_error_attrs` helper (replaced by `classify_envelope_failure` which serves both envelope shapes).
|
|
55
|
+
|
|
9
56
|
## [0.2.4] - 2026-05-22
|
|
10
57
|
|
|
11
58
|
### Added
|
|
@@ -179,7 +226,9 @@ Initial release. Covers the four Self-hosted Website Module (自有官網模組)
|
|
|
179
226
|
- The gem is **synchronous on purpose**. Callers wrap requests in their own background job runner (e.g. ActiveJob) when needed.
|
|
180
227
|
- Idempotency: clients can send `X-Idempotency-Key` via the `idempotency_key:` kwarg. DSP also dedupes server-side by `form_no + platform_id`.
|
|
181
228
|
|
|
182
|
-
[Unreleased]: https://github.com/7a6163/digiwin_dsp/compare/v0.
|
|
229
|
+
[Unreleased]: https://github.com/7a6163/digiwin_dsp/compare/v0.3.1...HEAD
|
|
230
|
+
[0.3.1]: https://github.com/7a6163/digiwin_dsp/compare/v0.3.0...v0.3.1
|
|
231
|
+
[0.3.0]: https://github.com/7a6163/digiwin_dsp/compare/v0.2.4...v0.3.0
|
|
183
232
|
[0.2.4]: https://github.com/7a6163/digiwin_dsp/compare/v0.2.3...v0.2.4
|
|
184
233
|
[0.2.3]: https://github.com/7a6163/digiwin_dsp/compare/v0.2.2...v0.2.3
|
|
185
234
|
[0.2.2]: https://github.com/7a6163/digiwin_dsp/compare/v0.2.1...v0.2.2
|
data/README.md
CHANGED
|
@@ -13,6 +13,8 @@ Ruby client for the Digiwin DSP Self-hosted Website Module (自有官網模組)
|
|
|
13
13
|
| Cancel order | `DigiwinDsp::Resources::Cancellation` | `POST /v1/SalesOrder/cancel` |
|
|
14
14
|
| Invoice update | `DigiwinDsp::Resources::Invoice` | `POST /v1/SalesOrder/invoice` |
|
|
15
15
|
| Return | `DigiwinDsp::Resources::Return` | `POST /v1/SalesOrder/return` |
|
|
16
|
+
| Register webhook | `DigiwinDsp::Resources::WebhookSubscription` | `POST /v1/webhook` (on webhook_base_url) |
|
|
17
|
+
| Receive webhook (inbound) | `DigiwinDsp::Webhooks.parse` | — |
|
|
16
18
|
|
|
17
19
|
See [`docs/dsp-api-spec.md`](./docs/dsp-api-spec.md) (plus `docs/dsp-specs/*.yaml`) for the wire spec.
|
|
18
20
|
|
|
@@ -53,6 +55,7 @@ Every setting also falls back to an ENV var:
|
|
|
53
55
|
| `platform_id` | `DIGIWIN_DSP_PLATFORM_ID` | `nil` | sent **per-record** in `request_detail.platform_id` (not in auth headers) |
|
|
54
56
|
| `environment` | `DIGIWIN_DSP_ENV` | `:sandbox` | `:sandbox` (UAT) or `:production` |
|
|
55
57
|
| `base_url` | `DIGIWIN_DSP_BASE_URL` | resolved from `environment` | must be `https://` and have a host in `allowed_hosts` |
|
|
58
|
+
| `webhook_base_url` | `DIGIWIN_DSP_WEBHOOK_BASE_URL` | resolved from `environment` | for `WebhookSubscription`; same validation as `base_url` |
|
|
56
59
|
| `allowed_hosts` | — | `["digiwindsp.digiwin.com"]` | SSRF allowlist; extend if you proxy DSP through a different domain |
|
|
57
60
|
| `timeout` | — | `10` | seconds |
|
|
58
61
|
| `open_timeout` | — | `5` | seconds |
|
|
@@ -154,12 +157,68 @@ Each endpoint requires a specific `order_status` value inside `request_detail`.
|
|
|
154
157
|
|
|
155
158
|
### Idempotency
|
|
156
159
|
|
|
157
|
-
|
|
160
|
+
**DSP only dedupes on `form_no + platform_id`.** Use a deterministic `form_no` derived from your domain order ID and DSP will reject duplicates with `Duplicated:訂單不可重複` (mapped to `DuplicateRequestError`).
|
|
161
|
+
|
|
162
|
+
The `idempotency_key:` kwarg attaches an `X-Idempotency-Key` request header for logging/tracing on your side, but **DSP UAT does not act on it** (live-verified 2026-05-22 with two distinct `form_no` POSTs sharing the same key — both succeeded). Treat the header as a trace ID, not an idempotency guarantee.
|
|
158
163
|
|
|
159
164
|
```ruby
|
|
160
165
|
DigiwinDsp::Resources::Order.create(record, idempotency_key: "order-#{record['form_no']}")
|
|
161
166
|
```
|
|
162
167
|
|
|
168
|
+
### Webhooks (DSP push events)
|
|
169
|
+
|
|
170
|
+
Two halves — register a callback URL with DSP, then receive ERP-originated events at that URL.
|
|
171
|
+
|
|
172
|
+
**Register (outbound)** — tell DSP where to push notifications for one of three documented actions:
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
DigiwinDsp::Resources::WebhookSubscription.create(
|
|
176
|
+
action: "product/inventory_update", # or "wms/logistics/package/update", "invoice/update"
|
|
177
|
+
address: "https://yourshop.example.com/webhooks/dsp/inventory"
|
|
178
|
+
)
|
|
179
|
+
# => { "platform_id" => ..., "address" => ..., "action" => ... }
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
`platform_id` falls back to `Configuration#platform_id`; `prod` defaults to `"OFFICIALWEBSITE"`. Each action needs its own subscription call.
|
|
183
|
+
|
|
184
|
+
**Receive (inbound)** — parse what DSP POSTs to your callback URL:
|
|
185
|
+
|
|
186
|
+
```ruby
|
|
187
|
+
# config/routes.rb
|
|
188
|
+
post "/webhooks/dsp/inventory", to: "dsp_webhooks#inventory"
|
|
189
|
+
|
|
190
|
+
# app/controllers/dsp_webhooks_controller.rb
|
|
191
|
+
class DspWebhooksController < ActionController::API
|
|
192
|
+
def inventory
|
|
193
|
+
event = DigiwinDsp::Webhooks::InventoryUpdate.parse(request.raw_post)
|
|
194
|
+
# event.platform_id, event.sale_page_id, event.spec_list[] — see docs/dsp-specs/DSPOOFFICIAL100.yaml
|
|
195
|
+
DspInventoryUpdateJob.perform_later(event.raw)
|
|
196
|
+
head :ok
|
|
197
|
+
rescue DigiwinDsp::Webhooks::ParseError => e
|
|
198
|
+
Rails.logger.error("DSP webhook parse failed: #{e.dsp_message || e.message}")
|
|
199
|
+
head :bad_request
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Or use the dispatcher when one URL handles all 3 actions:
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
event = DigiwinDsp::Webhooks.parse(request.raw_post, action: params[:action])
|
|
208
|
+
case event
|
|
209
|
+
when DigiwinDsp::Webhooks::InventoryUpdate then ...
|
|
210
|
+
when DigiwinDsp::Webhooks::LogisticsUpdate then event.tracking_number
|
|
211
|
+
when DigiwinDsp::Webhooks::InvoiceUpdate then event.invoices.each { |inv| ... }
|
|
212
|
+
end
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
> ⚠️ **DSP does NOT sign inbound webhooks.** There is no HMAC header. Defend the callback endpoint with:
|
|
216
|
+
> - HTTPS-only — `WebhookSubscription` rejects non-`https://` addresses at registration time (DSP's own spec mandates HTTPS callbacks)
|
|
217
|
+
> - An unguessable URL path (treat it as a secret)
|
|
218
|
+
> - An IP allowlist for DSP's egress range if your network team can get one
|
|
219
|
+
> - Replying `200 OK` within 30 seconds (DSP will retry and may eventually block your endpoint if too many calls fail)
|
|
220
|
+
> - **Idempotency by `form_no` / `invoice_number` / etc. on your side** — DSP may retry the same event
|
|
221
|
+
|
|
163
222
|
### Background jobs
|
|
164
223
|
|
|
165
224
|
The gem is synchronous on purpose. Wrap calls in your own job runner:
|
data/lib/digiwin_dsp/client.rb
CHANGED
|
@@ -33,13 +33,17 @@ module DigiwinDsp
|
|
|
33
33
|
[/Duplicated:/, DuplicateRequestError], # order already exists
|
|
34
34
|
[/Processing:資料處理中/, RateLimitError], # transient; retry later
|
|
35
35
|
[/Processing:取消訂單處理中/, ValidationError], # cancel in flight
|
|
36
|
+
[/Processing:新增訂單處理中/, RateLimitError], # add in flight; cancel retryable after ERP processes (002)
|
|
37
|
+
[/Shipped:/, ValidationError], # already shipped — cancel permanently impossible (002)
|
|
38
|
+
[/SalesNotCreate:/, RateLimitError], # ERP hasn't converted sales doc yet; invoice retryable (004)
|
|
36
39
|
[/WrongStatus:/, ValidationError], # bad payload
|
|
37
40
|
[/系統異常:/, ServerError] # DSP internal error
|
|
38
41
|
].freeze
|
|
39
42
|
|
|
40
|
-
def initialize(configuration: DigiwinDsp.configuration, authenticator: nil)
|
|
41
|
-
@configuration
|
|
42
|
-
@authenticator
|
|
43
|
+
def initialize(configuration: DigiwinDsp.configuration, authenticator: nil, base_url: nil)
|
|
44
|
+
@configuration = configuration
|
|
45
|
+
@authenticator = authenticator || Authenticator.new(configuration)
|
|
46
|
+
@base_url_override = base_url
|
|
43
47
|
end
|
|
44
48
|
|
|
45
49
|
def post(path, body, idempotency_key: nil, headers: {})
|
|
@@ -51,7 +55,10 @@ module DigiwinDsp
|
|
|
51
55
|
req.body = body
|
|
52
56
|
end
|
|
53
57
|
handle_response(response)
|
|
54
|
-
rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
|
|
58
|
+
rescue Faraday::ConnectionFailed, Faraday::TimeoutError, Faraday::SSLError => e
|
|
59
|
+
# SSLError sits directly under Faraday::Error (not ConnectionFailed),
|
|
60
|
+
# so it needs an explicit entry — otherwise TLS failures leak as raw
|
|
61
|
+
# Faraday exceptions that `rescue DigiwinDsp::Error` won't catch.
|
|
55
62
|
raise NetworkError, e.message
|
|
56
63
|
end
|
|
57
64
|
|
|
@@ -78,7 +85,7 @@ module DigiwinDsp
|
|
|
78
85
|
end
|
|
79
86
|
|
|
80
87
|
def connection_base_url
|
|
81
|
-
base = @configuration.base_url
|
|
88
|
+
base = @base_url_override || @configuration.base_url
|
|
82
89
|
base.end_with?("/") ? base : "#{base}/"
|
|
83
90
|
end
|
|
84
91
|
|
|
@@ -108,11 +115,31 @@ module DigiwinDsp
|
|
|
108
115
|
end
|
|
109
116
|
|
|
110
117
|
def inspect_envelope(body)
|
|
111
|
-
return body unless body.is_a?(Hash)
|
|
118
|
+
return body unless body.is_a?(Hash)
|
|
112
119
|
|
|
113
|
-
|
|
120
|
+
failure = detect_envelope_failure(body)
|
|
121
|
+
return body unless failure
|
|
122
|
+
|
|
123
|
+
raise classify_envelope_failure(failure[:message], code: failure[:code], body: body)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Detects either envelope shape and returns {message, code} if it's a
|
|
127
|
+
# failure response, or nil if it's a success / non-envelope body.
|
|
128
|
+
# - DSPOOFFICIAL100: { srvver, std_data: { execution: { code, description }, response } }
|
|
129
|
+
# - DSPOOFFICIAL001-005: { Status, Message, response_detail }
|
|
130
|
+
def detect_envelope_failure(body)
|
|
131
|
+
if (exec = body.dig("std_data", "execution"))
|
|
132
|
+
return nil if exec["code"].to_s == "0"
|
|
133
|
+
|
|
134
|
+
{ message: exec["description"].to_s, code: exec["code"] }
|
|
135
|
+
elsif body["Status"].to_s.casecmp("failure").zero?
|
|
136
|
+
{ message: body["Message"].to_s, code: body["Status"] }
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def classify_envelope_failure(message, code:, body:)
|
|
114
141
|
klass = ENVELOPE_FAILURE_MAP.find { |regex, _| regex.match?(message) }&.last || Error
|
|
115
|
-
|
|
142
|
+
klass.new(message, code: code, dsp_message: message, request_id: body["request_id"], http_status: 200)
|
|
116
143
|
end
|
|
117
144
|
|
|
118
145
|
def http_message(status, body)
|
|
@@ -130,15 +157,6 @@ module DigiwinDsp
|
|
|
130
157
|
}
|
|
131
158
|
end
|
|
132
159
|
|
|
133
|
-
def envelope_error_attrs(body)
|
|
134
|
-
{
|
|
135
|
-
code: body["Status"],
|
|
136
|
-
dsp_message: body["Message"],
|
|
137
|
-
request_id: body["request_id"],
|
|
138
|
-
http_status: 200
|
|
139
|
-
}
|
|
140
|
-
end
|
|
141
|
-
|
|
142
160
|
# Block CRLF/LF/CR in header names and values. RFC 7230 §3.2.4 forbids
|
|
143
161
|
# them and Faraday + Net::HTTP catch many forms, but not all — close
|
|
144
162
|
# the gap to prevent header-injection / request-smuggling.
|
|
@@ -14,20 +14,28 @@ module DigiwinDsp
|
|
|
14
14
|
production: "https://digiwindsp.digiwin.com/DSP/api/DSP"
|
|
15
15
|
}.freeze
|
|
16
16
|
|
|
17
|
+
# Webhook-subscription endpoint (DSPOOFFICIAL100) lives at a separate
|
|
18
|
+
# base path from the SalesOrder endpoints.
|
|
19
|
+
WEBHOOK_BASE_URLS = {
|
|
20
|
+
sandbox: "https://digiwindsp.digiwin.com/DSP_UAT/api/webhook",
|
|
21
|
+
production: "https://digiwindsp.digiwin.com/DSP/api/webhook"
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
17
24
|
attr_accessor :api_key, :api_secret, :platform_id, :environment, :logger,
|
|
18
25
|
:timeout, :open_timeout, :allowed_hosts
|
|
19
|
-
attr_writer :base_url
|
|
26
|
+
attr_writer :base_url, :webhook_base_url
|
|
20
27
|
|
|
21
28
|
def initialize
|
|
22
|
-
@api_key
|
|
23
|
-
@api_secret
|
|
24
|
-
@platform_id
|
|
25
|
-
@environment
|
|
26
|
-
@base_url
|
|
27
|
-
@
|
|
28
|
-
@
|
|
29
|
-
@
|
|
30
|
-
@
|
|
29
|
+
@api_key = ENV["DIGIWIN_DSP_API_KEY"]
|
|
30
|
+
@api_secret = ENV["DIGIWIN_DSP_API_SECRET"]
|
|
31
|
+
@platform_id = ENV["DIGIWIN_DSP_PLATFORM_ID"]
|
|
32
|
+
@environment = ENV.fetch("DIGIWIN_DSP_ENV") { "sandbox" }.to_sym
|
|
33
|
+
@base_url = ENV["DIGIWIN_DSP_BASE_URL"]
|
|
34
|
+
@webhook_base_url = ENV["DIGIWIN_DSP_WEBHOOK_BASE_URL"]
|
|
35
|
+
@timeout = DEFAULT_TIMEOUT
|
|
36
|
+
@open_timeout = DEFAULT_OPEN_TIMEOUT
|
|
37
|
+
@logger = Logger.new(IO::NULL)
|
|
38
|
+
@allowed_hosts = DEFAULT_ALLOWED_HOSTS.dup
|
|
31
39
|
end
|
|
32
40
|
|
|
33
41
|
def base_url
|
|
@@ -36,6 +44,12 @@ module DigiwinDsp
|
|
|
36
44
|
url
|
|
37
45
|
end
|
|
38
46
|
|
|
47
|
+
def webhook_base_url
|
|
48
|
+
url = @webhook_base_url || resolve_default_webhook_base_url
|
|
49
|
+
validate_url!(url)
|
|
50
|
+
url
|
|
51
|
+
end
|
|
52
|
+
|
|
39
53
|
def validate!
|
|
40
54
|
return unless api_key.nil? || api_key.to_s.empty?
|
|
41
55
|
|
|
@@ -52,6 +66,13 @@ module DigiwinDsp
|
|
|
52
66
|
end
|
|
53
67
|
end
|
|
54
68
|
|
|
69
|
+
def resolve_default_webhook_base_url
|
|
70
|
+
WEBHOOK_BASE_URLS.fetch(environment) do
|
|
71
|
+
raise ConfigurationError,
|
|
72
|
+
"unknown environment #{environment.inspect}; expected one of #{WEBHOOK_BASE_URLS.keys.inspect}"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
55
76
|
def validate_url!(url)
|
|
56
77
|
uri = URI.parse(url)
|
|
57
78
|
raise ConfigurationError, "base_url must use https (got #{uri.scheme.inspect})" unless uri.scheme == "https"
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DigiwinDsp
|
|
4
|
+
module Resources
|
|
5
|
+
# Registers a webhook callback URL with DSP (DSPOOFFICIAL100,
|
|
6
|
+
# POST /v1/webhook on the webhook_base_url).
|
|
7
|
+
#
|
|
8
|
+
# Not a Resources::Base subclass — the request envelope is a single
|
|
9
|
+
# `request` object (not `request_detail[]` array), the fields are
|
|
10
|
+
# explicit kwargs (not an arbitrary hash), and it targets the
|
|
11
|
+
# webhook_base_url instead of base_url.
|
|
12
|
+
class WebhookSubscription
|
|
13
|
+
PATH = "/v1/webhook"
|
|
14
|
+
DEFAULT_PROD = "OFFICIALWEBSITE"
|
|
15
|
+
ACTIONS = %w[
|
|
16
|
+
product/inventory_update
|
|
17
|
+
wms/logistics/package/update
|
|
18
|
+
invoice/update
|
|
19
|
+
].freeze
|
|
20
|
+
ADDRESS_MAX_LENGTH = 500
|
|
21
|
+
|
|
22
|
+
def self.create(action:, address:, platform_id: nil, prod: DEFAULT_PROD)
|
|
23
|
+
new.create(action: action, address: address, platform_id: platform_id, prod: prod)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(client = nil)
|
|
27
|
+
@client = client || Client.new(base_url: DigiwinDsp.configuration.webhook_base_url)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def create(action:, address:, platform_id: nil, prod: DEFAULT_PROD)
|
|
31
|
+
validate_args!(action: action, address: address)
|
|
32
|
+
body = build_body(action: action, address: address, platform_id: platform_id, prod: prod)
|
|
33
|
+
response = @client.post(PATH, body)
|
|
34
|
+
response.dig("std_data", "response") ||
|
|
35
|
+
raise(DigiwinDsp::ServerError, "DSP returned execution.code=0 without std_data.response")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def validate_args!(action:, address:)
|
|
41
|
+
unless ACTIONS.include?(action)
|
|
42
|
+
raise DigiwinDsp::ValidationError,
|
|
43
|
+
"action must be one of #{ACTIONS.inspect} (got #{action.inspect})"
|
|
44
|
+
end
|
|
45
|
+
raise DigiwinDsp::ValidationError, "address is required" if address.nil? || address.to_s.empty?
|
|
46
|
+
|
|
47
|
+
# DSPOOFFICIAL100 mandates HTTPS for the callback ("必須在 30 秒內以
|
|
48
|
+
# HTTPS 回應") — and with no HMAC signing, a plaintext callback URL
|
|
49
|
+
# would be indefensible anyway.
|
|
50
|
+
raise DigiwinDsp::ValidationError, "address must be an https:// URL (got #{address.inspect})" unless address.start_with?("https://")
|
|
51
|
+
return unless address.length > ADDRESS_MAX_LENGTH
|
|
52
|
+
|
|
53
|
+
raise DigiwinDsp::ValidationError,
|
|
54
|
+
"address must be <= #{ADDRESS_MAX_LENGTH} chars (got #{address.length})"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def build_body(action:, address:, platform_id:, prod:)
|
|
58
|
+
resolved = resolve_platform_id(platform_id)
|
|
59
|
+
request = { "prod" => prod, "platform_id" => resolved, "action" => action, "address" => address }
|
|
60
|
+
{ "digi_body" => { "std_data" => { "parameter" => { "request" => request } } } }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def resolve_platform_id(explicit)
|
|
64
|
+
id = explicit || DigiwinDsp.configuration.platform_id
|
|
65
|
+
return id unless id.nil? || id.to_s.empty?
|
|
66
|
+
|
|
67
|
+
raise DigiwinDsp::ConfigurationError,
|
|
68
|
+
"platform_id is required (set via configure or pass explicitly)"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
data/lib/digiwin_dsp/version.rb
CHANGED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module DigiwinDsp
|
|
6
|
+
module Webhooks
|
|
7
|
+
# Base value-object for an inbound DSP webhook event. Subclasses add
|
|
8
|
+
# action-specific accessors over the `request` payload but share the
|
|
9
|
+
# JSON-parse + envelope-extract logic that lives here.
|
|
10
|
+
class Event
|
|
11
|
+
attr_reader :digi_header, :request, :raw
|
|
12
|
+
|
|
13
|
+
def self.parse(raw_body)
|
|
14
|
+
hash = parse_json(raw_body)
|
|
15
|
+
request = extract_request(hash)
|
|
16
|
+
new(digi_header: hash["digi_header"] || {}, request: request, raw: hash)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.parse_json(raw_body)
|
|
20
|
+
# max_nesting mirrors the Client's parser DoS guard.
|
|
21
|
+
JSON.parse(raw_body, max_nesting: 50)
|
|
22
|
+
rescue JSON::ParserError => e
|
|
23
|
+
raise ParseError, "invalid JSON: #{e.message}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.extract_request(hash)
|
|
27
|
+
raise ParseError, "envelope must be a JSON object" unless hash.is_a?(Hash)
|
|
28
|
+
|
|
29
|
+
hash.dig("digi_body", "std_data", "parameter", "request") ||
|
|
30
|
+
raise(ParseError, "envelope missing digi_body.std_data.parameter.request")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private_class_method :parse_json, :extract_request
|
|
34
|
+
|
|
35
|
+
def initialize(digi_header:, request:, raw:)
|
|
36
|
+
@digi_header = digi_header
|
|
37
|
+
@request = request
|
|
38
|
+
@raw = raw
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DigiwinDsp
|
|
4
|
+
module Webhooks
|
|
5
|
+
# Fired by DSP when an ERP-side inventory change happens.
|
|
6
|
+
# See docs/dsp-specs/DSPOOFFICIAL100.yaml under
|
|
7
|
+
# "庫存數量更新 product/inventory_update".
|
|
8
|
+
class InventoryUpdate < Event
|
|
9
|
+
def prod = request["prod"]
|
|
10
|
+
def platform_id = request["platform_id"]
|
|
11
|
+
def sale_page_id = request["sale_page_id"]
|
|
12
|
+
def spec_list = request["spec_list"] || []
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DigiwinDsp
|
|
4
|
+
module Webhooks
|
|
5
|
+
# Fired by DSP when one or more ERP-issued invoices land. Unlike the
|
|
6
|
+
# other two events, the request payload is an Array (DSP batches
|
|
7
|
+
# invoices). See docs/dsp-specs/DSPOOFFICIAL100.yaml under
|
|
8
|
+
# "發票資料更新 invoice/update".
|
|
9
|
+
class InvoiceUpdate < Event
|
|
10
|
+
def self.parse(raw_body)
|
|
11
|
+
event = super
|
|
12
|
+
raise ParseError, "invoice/update payload must be an array of invoice records" unless event.request.is_a?(Array)
|
|
13
|
+
|
|
14
|
+
event
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def invoices = request
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DigiwinDsp
|
|
4
|
+
module Webhooks
|
|
5
|
+
# Fired by DSP when a shipment changes state (handed off to carrier,
|
|
6
|
+
# delivered, etc.). See docs/dsp-specs/DSPOOFFICIAL100.yaml under
|
|
7
|
+
# "倉儲貨態更新 wms/logistics/package/update".
|
|
8
|
+
class LogisticsUpdate < Event
|
|
9
|
+
def form_no = request["form_no"]
|
|
10
|
+
def func_name = request["func_name"]
|
|
11
|
+
def status_date = request["status_date"]
|
|
12
|
+
def status_time = request["status_time"]
|
|
13
|
+
def tracking_number = request["tracking_number"]
|
|
14
|
+
def distributor_code = request["distributor_code"]
|
|
15
|
+
def message = request["message"]
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DigiwinDsp
|
|
4
|
+
# Inbound webhook payload parsers for events DSP pushes to your Rails app
|
|
5
|
+
# after you register a callback URL via `Resources::WebhookSubscription`.
|
|
6
|
+
#
|
|
7
|
+
# ⚠️ DSP does NOT sign these requests — there is no HMAC header. Defend
|
|
8
|
+
# the callback endpoint with HTTPS-only, an unguessable URL path, and
|
|
9
|
+
# (if possible) an IP allowlist for DSP's egress range.
|
|
10
|
+
module Webhooks
|
|
11
|
+
# Raised when JSON is malformed, the envelope is the wrong shape, or
|
|
12
|
+
# the action is unknown to the gem. Inherits from ValidationError so
|
|
13
|
+
# Rails controllers can rescue the existing DigiwinDsp::Error tree.
|
|
14
|
+
class ParseError < DigiwinDsp::ValidationError; end
|
|
15
|
+
|
|
16
|
+
# action string => constant name (lazy resolution via const_get so
|
|
17
|
+
# this module file doesn't force-load the per-event classes at boot).
|
|
18
|
+
ACTION_REGISTRY = {
|
|
19
|
+
"product/inventory_update" => :InventoryUpdate,
|
|
20
|
+
"wms/logistics/package/update" => :LogisticsUpdate,
|
|
21
|
+
"invoice/update" => :InvoiceUpdate
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
def self.parse(raw_body, action:)
|
|
25
|
+
sym = ACTION_REGISTRY[action] ||
|
|
26
|
+
raise(ParseError, "unknown action #{action.inspect}; expected one of #{ACTION_REGISTRY.keys.inspect}")
|
|
27
|
+
const_get(sym).parse(raw_body)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
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
|
+
version: 0.3.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Zac
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-06-12 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: faraday
|
|
@@ -73,12 +73,18 @@ files:
|
|
|
73
73
|
- lib/digiwin_dsp/resources/invoice.rb
|
|
74
74
|
- lib/digiwin_dsp/resources/order.rb
|
|
75
75
|
- lib/digiwin_dsp/resources/return.rb
|
|
76
|
+
- lib/digiwin_dsp/resources/webhook_subscription.rb
|
|
76
77
|
- lib/digiwin_dsp/serializers/base.rb
|
|
77
78
|
- lib/digiwin_dsp/serializers/cancellation_serializer.rb
|
|
78
79
|
- lib/digiwin_dsp/serializers/invoice_serializer.rb
|
|
79
80
|
- lib/digiwin_dsp/serializers/return_serializer.rb
|
|
80
81
|
- lib/digiwin_dsp/serializers/sales_order_serializer.rb
|
|
81
82
|
- lib/digiwin_dsp/version.rb
|
|
83
|
+
- lib/digiwin_dsp/webhooks.rb
|
|
84
|
+
- lib/digiwin_dsp/webhooks/event.rb
|
|
85
|
+
- lib/digiwin_dsp/webhooks/inventory_update.rb
|
|
86
|
+
- lib/digiwin_dsp/webhooks/invoice_update.rb
|
|
87
|
+
- lib/digiwin_dsp/webhooks/logistics_update.rb
|
|
82
88
|
homepage: https://github.com/7a6163/digiwin_dsp
|
|
83
89
|
licenses:
|
|
84
90
|
- MIT
|