digiwin_dsp 0.2.4 → 0.3.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 +4 -4
- data/CHANGELOG.md +28 -1
- data/README.md +60 -1
- data/lib/digiwin_dsp/client.rb +28 -16
- data/lib/digiwin_dsp/configuration.rb +31 -10
- data/lib/digiwin_dsp/resources/webhook_subscription.rb +67 -0
- data/lib/digiwin_dsp/version.rb +1 -1
- data/lib/digiwin_dsp/webhooks/event.rb +40 -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: 6a72275c796b57bd858c4d7c5a67b7074e2738985ae92a9011836c0db9383321
|
|
4
|
+
data.tar.gz: 78db9041211bbc64cf7b9cb9cbfccb5f1dbef00efbfc4d5aa7b7a9e44d458f63
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 82cc7dbb4ca9194b851ee1bdcd853fc6c084d13ae41d6d1c38ebefec8fba80be722bf40e77e332db7ef1e821aec8d8522358740bcd4531e21744fcb66844f9fa
|
|
7
|
+
data.tar.gz: ba67eb5115c8aa4d978b71f52c1ac063eb2643bdaccdfa30cb9ed3f3dfd1022b3378497c04cfc468d317a211453d983751ce2cfba635eeceb6d9e8c98044655c
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,32 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.3.0] - 2026-05-28
|
|
10
|
+
|
|
11
|
+
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.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **`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.
|
|
16
|
+
- **`DigiwinDsp::Webhooks` module** with three inbound event parsers:
|
|
17
|
+
- `Webhooks::InventoryUpdate` — exposes `prod`, `platform_id`, `sale_page_id`, `spec_list`
|
|
18
|
+
- `Webhooks::LogisticsUpdate` — exposes `form_no`, `func_name`, `status_date`, `status_time`, `tracking_number`, `distributor_code`, `message`
|
|
19
|
+
- `Webhooks::InvoiceUpdate` — exposes `invoices` (Array; DSP batches multiple invoices per push)
|
|
20
|
+
- `Webhooks.parse(raw_body, action:)` dispatcher routes by action string
|
|
21
|
+
- `Webhooks::ParseError < ValidationError` for malformed JSON / envelope / unknown action
|
|
22
|
+
- **`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`.
|
|
23
|
+
- **Client `base_url:` kwarg override** so the new `WebhookSubscription` can target the webhook base path without leaking the routing into `Configuration#base_url`.
|
|
24
|
+
- **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.
|
|
25
|
+
- `docs/dsp-specs/DSPOOFFICIAL100.yaml` — the OpenAPI spec used to drive the implementation.
|
|
26
|
+
|
|
27
|
+
### Security
|
|
28
|
+
|
|
29
|
+
- ⚠️ **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`.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- Removed dead `Client#envelope_error_attrs` helper (replaced by `classify_envelope_failure` which serves both envelope shapes).
|
|
34
|
+
|
|
9
35
|
## [0.2.4] - 2026-05-22
|
|
10
36
|
|
|
11
37
|
### Added
|
|
@@ -179,7 +205,8 @@ Initial release. Covers the four Self-hosted Website Module (自有官網模組)
|
|
|
179
205
|
- The gem is **synchronous on purpose**. Callers wrap requests in their own background job runner (e.g. ActiveJob) when needed.
|
|
180
206
|
- Idempotency: clients can send `X-Idempotency-Key` via the `idempotency_key:` kwarg. DSP also dedupes server-side by `form_no + platform_id`.
|
|
181
207
|
|
|
182
|
-
[Unreleased]: https://github.com/7a6163/digiwin_dsp/compare/v0.
|
|
208
|
+
[Unreleased]: https://github.com/7a6163/digiwin_dsp/compare/v0.3.0...HEAD
|
|
209
|
+
[0.3.0]: https://github.com/7a6163/digiwin_dsp/compare/v0.2.4...v0.3.0
|
|
183
210
|
[0.2.4]: https://github.com/7a6163/digiwin_dsp/compare/v0.2.3...v0.2.4
|
|
184
211
|
[0.2.3]: https://github.com/7a6163/digiwin_dsp/compare/v0.2.2...v0.2.3
|
|
185
212
|
[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 (the gem's `address` validation requires this implicitly via `allowed_hosts` if you register through it)
|
|
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
|
@@ -37,9 +37,10 @@ module DigiwinDsp
|
|
|
37
37
|
[/系統異常:/, ServerError] # DSP internal error
|
|
38
38
|
].freeze
|
|
39
39
|
|
|
40
|
-
def initialize(configuration: DigiwinDsp.configuration, authenticator: nil)
|
|
41
|
-
@configuration
|
|
42
|
-
@authenticator
|
|
40
|
+
def initialize(configuration: DigiwinDsp.configuration, authenticator: nil, base_url: nil)
|
|
41
|
+
@configuration = configuration
|
|
42
|
+
@authenticator = authenticator || Authenticator.new(configuration)
|
|
43
|
+
@base_url_override = base_url
|
|
43
44
|
end
|
|
44
45
|
|
|
45
46
|
def post(path, body, idempotency_key: nil, headers: {})
|
|
@@ -78,7 +79,7 @@ module DigiwinDsp
|
|
|
78
79
|
end
|
|
79
80
|
|
|
80
81
|
def connection_base_url
|
|
81
|
-
base = @configuration.base_url
|
|
82
|
+
base = @base_url_override || @configuration.base_url
|
|
82
83
|
base.end_with?("/") ? base : "#{base}/"
|
|
83
84
|
end
|
|
84
85
|
|
|
@@ -108,11 +109,31 @@ module DigiwinDsp
|
|
|
108
109
|
end
|
|
109
110
|
|
|
110
111
|
def inspect_envelope(body)
|
|
111
|
-
return body unless body.is_a?(Hash)
|
|
112
|
+
return body unless body.is_a?(Hash)
|
|
112
113
|
|
|
113
|
-
|
|
114
|
+
failure = detect_envelope_failure(body)
|
|
115
|
+
return body unless failure
|
|
116
|
+
|
|
117
|
+
raise classify_envelope_failure(failure[:message], code: failure[:code], body: body)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Detects either envelope shape and returns {message, code} if it's a
|
|
121
|
+
# failure response, or nil if it's a success / non-envelope body.
|
|
122
|
+
# - DSPOOFFICIAL100: { srvver, std_data: { execution: { code, description }, response } }
|
|
123
|
+
# - DSPOOFFICIAL001-005: { Status, Message, response_detail }
|
|
124
|
+
def detect_envelope_failure(body)
|
|
125
|
+
if (exec = body.dig("std_data", "execution"))
|
|
126
|
+
return nil if exec["code"].to_s == "0"
|
|
127
|
+
|
|
128
|
+
{ message: exec["description"].to_s, code: exec["code"] }
|
|
129
|
+
elsif body["Status"].to_s.casecmp("failure").zero?
|
|
130
|
+
{ message: body["Message"].to_s, code: body["Status"] }
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def classify_envelope_failure(message, code:, body:)
|
|
114
135
|
klass = ENVELOPE_FAILURE_MAP.find { |regex, _| regex.match?(message) }&.last || Error
|
|
115
|
-
|
|
136
|
+
klass.new(message, code: code, dsp_message: message, request_id: body["request_id"], http_status: 200)
|
|
116
137
|
end
|
|
117
138
|
|
|
118
139
|
def http_message(status, body)
|
|
@@ -130,15 +151,6 @@ module DigiwinDsp
|
|
|
130
151
|
}
|
|
131
152
|
end
|
|
132
153
|
|
|
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
154
|
# Block CRLF/LF/CR in header names and values. RFC 7230 §3.2.4 forbids
|
|
143
155
|
# them and Faraday + Net::HTTP catch many forms, but not all — close
|
|
144
156
|
# 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,67 @@
|
|
|
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
|
+
return unless address.length > ADDRESS_MAX_LENGTH
|
|
47
|
+
|
|
48
|
+
raise DigiwinDsp::ValidationError,
|
|
49
|
+
"address must be <= #{ADDRESS_MAX_LENGTH} chars (got #{address.length})"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def build_body(action:, address:, platform_id:, prod:)
|
|
53
|
+
resolved = resolve_platform_id(platform_id)
|
|
54
|
+
request = { "prod" => prod, "platform_id" => resolved, "action" => action, "address" => address }
|
|
55
|
+
{ "digi_body" => { "std_data" => { "parameter" => { "request" => request } } } }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def resolve_platform_id(explicit)
|
|
59
|
+
id = explicit || DigiwinDsp.configuration.platform_id
|
|
60
|
+
return id unless id.nil? || id.to_s.empty?
|
|
61
|
+
|
|
62
|
+
raise DigiwinDsp::ConfigurationError,
|
|
63
|
+
"platform_id is required (set via configure or pass explicitly)"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
data/lib/digiwin_dsp/version.rb
CHANGED
|
@@ -0,0 +1,40 @@
|
|
|
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
|
+
def initialize(digi_header:, request:, raw:)
|
|
34
|
+
@digi_header = digi_header
|
|
35
|
+
@request = request
|
|
36
|
+
@raw = raw
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
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.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-05-
|
|
11
|
+
date: 2026-05-28 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
|