digiwin_dsp 0.4.2 → 0.6.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 +33 -1
- data/README.md +12 -6
- data/lib/digiwin_dsp/client.rb +42 -11
- data/lib/digiwin_dsp/configuration.rb +25 -13
- data/lib/digiwin_dsp/serializers/base.rb +49 -10
- data/lib/digiwin_dsp/version.rb +1 -1
- data/lib/digiwin_dsp/webhooks/event.rb +10 -2
- data/lib/digiwin_dsp/webhooks/inventory_update.rb +7 -0
- data/lib/digiwin_dsp/webhooks/logistics_update.rb +7 -0
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 39cfa7cd414965f7f63ac143177e2dcb4ec36dee5403a7fc211f7a6d782d4ad1
|
|
4
|
+
data.tar.gz: 86333bb27dd34ad93c350cdf2b195237faac24ca3c5192e843e4d43958c0c578
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 16f9fc374c1d7bb01d75f7963d616fb4fa26c649450700db75a54b7962756560371cb67f532d4afc06eb79f26d61561bbcfe94a376cd2f78f493814a364705d6
|
|
7
|
+
data.tar.gz: a6d881275de661e80a11ed41e0344bb1ec7848e681648c14c8449ddf38a1f153516c303ca36fd340c282fcf40748c1dbb44eade762aaf6adf371c13d7943e827
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,36 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.6.0] - 2026-10-22
|
|
10
|
+
|
|
11
|
+
### BREAKING
|
|
12
|
+
|
|
13
|
+
- **Date/time fields are format-checked before sending.** Every serializer now rejects malformed or impossible values (e.g. `20260230`, `25:00:00`) with `ValidationError`, per `docs/dsp-specs/*.yaml`: `create_datetime` `yyyy-MM-dd HH:mm:ss`; `order_date`, `invoice_date`, `order_expect_shipping_date` `YYYYMMDD`; `invoice_time` `HH:MM:SS`. Blank optional fields are skipped. **`invoice_time` without colons (`"123000"`) is now rejected** — DSP UAT accepted it, but the spec requires `HH:MM:SS` and DSP accepts invoice data unconditionally, so acceptance proved nothing about the ERP side. Values must be strings; `Time`/`Date` objects are rejected. The offending value is not echoed in the error message. Note: DSPOOFFICIAL001's `create_datetime` example uses slashes (`2024/01/10 04:48:45`), contradicting its own `yyyy-MM-dd HH:mm:ss` description; the slash form is rejected.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- **HTTP 500, 502 and 504 are no longer retried.** A gateway error can arrive after DSP has already processed the POST, so resending risked a duplicate, or a spurious `DuplicateRequestError` on orders. They now raise `ServerError` right away. Only 429 and 503 are retried, plus pre-send connection failures.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- **`Client.new(base_url:)` is now checked against https + `allowed_hosts`.** The per-client override previously skipped the SSRF guard, so the API key could be sent to any host, even over plain http. `Configuration#validate_url!` is now public.
|
|
22
|
+
- **`Webhooks.parse(nil, ...)` raises `Webhooks::ParseError`** instead of `TypeError`, which escaped `rescue DigiwinDsp::Error`.
|
|
23
|
+
- **Serializers reject empty or non-Hash input** with `ValidationError`: `[]` used to be sent as an empty `request_detail`, and `nil` records raised `NoMethodError`.
|
|
24
|
+
- **`allowed_hosts` matches hostnames case-insensitively**, so `https://DigiwinDSP.digiwin.com` is no longer rejected.
|
|
25
|
+
|
|
26
|
+
## [0.5.0] - 2026-10-01
|
|
27
|
+
|
|
28
|
+
### BREAKING
|
|
29
|
+
|
|
30
|
+
- **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.
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- **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.
|
|
35
|
+
- **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.
|
|
36
|
+
- **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.
|
|
37
|
+
- **`Configuration#environment=` accepts strings** (e.g. `c.environment = Rails.env`), matching the `DIGIWIN_DSP_ENV` path.
|
|
38
|
+
|
|
9
39
|
## [0.4.2] - 2026-06-18
|
|
10
40
|
|
|
11
41
|
### Added
|
|
@@ -264,7 +294,9 @@ Initial release. Covers the four Self-hosted Website Module (自有官網模組)
|
|
|
264
294
|
- The gem is **synchronous on purpose**. Callers wrap requests in their own background job runner (e.g. ActiveJob) when needed.
|
|
265
295
|
- Idempotency: clients can send `X-Idempotency-Key` via the `idempotency_key:` kwarg. DSP also dedupes server-side by `form_no + platform_id`.
|
|
266
296
|
|
|
267
|
-
[Unreleased]: https://github.com/7a6163/digiwin_dsp/compare/v0.
|
|
297
|
+
[Unreleased]: https://github.com/7a6163/digiwin_dsp/compare/v0.6.0...HEAD
|
|
298
|
+
[0.6.0]: https://github.com/7a6163/digiwin_dsp/compare/v0.5.0...v0.6.0
|
|
299
|
+
[0.5.0]: https://github.com/7a6163/digiwin_dsp/compare/v0.4.2...v0.5.0
|
|
268
300
|
[0.4.2]: https://github.com/7a6163/digiwin_dsp/compare/v0.4.1...v0.4.2
|
|
269
301
|
[0.4.1]: https://github.com/7a6163/digiwin_dsp/compare/v0.4.0...v0.4.1
|
|
270
302
|
[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
|
+
[](https://badge.fury.io/rb/digiwin_dsp)
|
|
3
4
|
[](https://github.com/7a6163/digiwin_dsp/actions/workflows/ci.yml)
|
|
4
5
|
[](https://codecov.io/gh/7a6163/digiwin_dsp)
|
|
5
|
-
[](https://www.ruby-lang.org/)
|
|
6
7
|
[](./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.
|
|
35
|
+
Ruby 3.3 – 4.0 supported (≥ 3.3 required).
|
|
33
36
|
|
|
34
37
|
## Configuration
|
|
35
38
|
|
|
@@ -143,6 +146,8 @@ Each has its own required-field set (8 / 11 / 19 fields respectively). Inspect `
|
|
|
143
146
|
DigiwinDsp::Serializers::CancellationSerializer::REQUIRED_FIELDS
|
|
144
147
|
```
|
|
145
148
|
|
|
149
|
+
Date/time fields are format-checked locally on every endpoint (blank optional ones are skipped) and raise `ValidationError` if malformed: `create_datetime` → `"2026-05-20 12:00:00"`, `order_date` / `invoice_date` / `order_expect_shipping_date` → `"20260520"`, `invoice_time` → `"12:30:00"`. Pass strings, e.g. `t.strftime("%H:%M:%S")`; `Time`/`Date` objects are rejected.
|
|
150
|
+
|
|
146
151
|
> ⚠️ **Invoice sync requires ERP-side customization.** Per DSPOOFFICIAL004's spec note (個案), DSP accepts your invoice data unconditionally, but it only becomes *visible inside the ERP* after Digiwin performs per-customer integration work. If invoices appear to sync successfully but the ERP team can't see them, this is why — confirm the customization with your Digiwin contact before debugging your own code.
|
|
147
152
|
|
|
148
153
|
### `order_status` enum
|
|
@@ -227,7 +232,8 @@ Every `Client#post` already retries transparently inside Faraday:
|
|
|
227
232
|
| Setting | Value |
|
|
228
233
|
|---|---|
|
|
229
234
|
| Attempts | up to 4 (1 original + max 3 retries) |
|
|
230
|
-
| Triggers | HTTP 429,
|
|
235
|
+
| Triggers | HTTP 429, 503; connection failures that happen *before* the request reaches DSP (refused, host/network unreachable, DNS failure, connect timeout) |
|
|
236
|
+
| Never retried | HTTP 500, 502, 504 (raise `ServerError`), read timeouts and mid-request connection resets (raise `NetworkError`) — DSP may already have processed the POST, so resending could duplicate it. Check DSP state before retrying |
|
|
231
237
|
| Backoff | exponential — ~0.5s, ~1s, ~2s between attempts, ±50% jitter |
|
|
232
238
|
|
|
233
239
|
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.
|
|
@@ -257,11 +263,11 @@ All exceptions inherit from `DigiwinDsp::Error` and carry structured attributes
|
|
|
257
263
|
| Exception | Raised when |
|
|
258
264
|
|---|---|
|
|
259
265
|
| `DigiwinDsp::ConfigurationError` | `api_key` missing at request time |
|
|
260
|
-
| `DigiwinDsp::ValidationError` | Required field missing locally, or DSP returns `WrongStatus:` / `Processing:取消訂單處理中` / HTTP 400 |
|
|
266
|
+
| `DigiwinDsp::ValidationError` | Required field missing or date/time field malformed locally, or DSP returns `WrongStatus:` / `Processing:取消訂單處理中` / HTTP 400 |
|
|
261
267
|
| `DigiwinDsp::AuthenticationError` | HTTP 401 / 403 |
|
|
262
268
|
| `DigiwinDsp::DuplicateRequestError` | DSP returns `Duplicated:` or HTTP 409 |
|
|
263
269
|
| `DigiwinDsp::RateLimitError` | DSP returns `Processing:資料處理中` (retryable) or persistent HTTP 429 |
|
|
264
|
-
| `DigiwinDsp::ServerError` | DSP returns `系統異常:` or HTTP 5xx
|
|
270
|
+
| `DigiwinDsp::ServerError` | DSP returns `系統異常:` or HTTP 5xx (503 only after retries are exhausted) |
|
|
265
271
|
| `DigiwinDsp::NetworkError` | TCP connect failure or timeout |
|
|
266
272
|
| `DigiwinDsp::Error` | Catch-all (unmapped failure message or unexpected status) |
|
|
267
273
|
|
|
@@ -324,7 +330,7 @@ DigiwinDsp::Resources::Order.create(
|
|
|
324
330
|
|
|
325
331
|
```bash
|
|
326
332
|
bin/setup # bundle install
|
|
327
|
-
bundle exec rspec # run the full test suite (
|
|
333
|
+
bundle exec rspec # run the full test suite (100% line coverage)
|
|
328
334
|
bundle exec rubocop # lint
|
|
329
335
|
bin/console # IRB with the gem loaded
|
|
330
336
|
```
|
data/lib/digiwin_dsp/client.rb
CHANGED
|
@@ -5,7 +5,10 @@ require "faraday/retry"
|
|
|
5
5
|
|
|
6
6
|
module DigiwinDsp
|
|
7
7
|
class Client
|
|
8
|
-
|
|
8
|
+
# Only statuses that mean "not processed". 500/502/504 are left out: a
|
|
9
|
+
# gateway error can arrive after DSP has already handled the POST, so a
|
|
10
|
+
# resend could duplicate it (same reasoning as read timeouts below).
|
|
11
|
+
RETRY_STATUSES = [429, 503].freeze
|
|
9
12
|
# Exponential backoff: ~0.5s, ~1s, ~2s between attempts, ±50% jitter so
|
|
10
13
|
# multiple Rails workers retrying the same upstream blip don't synchronize
|
|
11
14
|
# into a thundering herd against DSP.
|
|
@@ -13,6 +16,29 @@ module DigiwinDsp
|
|
|
13
16
|
RETRY_INTERVAL = 0.5
|
|
14
17
|
RETRY_BACKOFF_FACTOR = 2
|
|
15
18
|
RETRY_INTERVAL_RANDOMNESS = 0.5
|
|
19
|
+
# Transport failures that happen before the request reaches DSP, so a
|
|
20
|
+
# resend can't duplicate a POST. Read timeouts, ECONNRESET, EPIPE etc.
|
|
21
|
+
# are deliberately absent: DSP may already have processed the request.
|
|
22
|
+
PRE_SEND_ERRORS = [
|
|
23
|
+
Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, SocketError, Net::OpenTimeout
|
|
24
|
+
].freeze
|
|
25
|
+
RETRY_IF = lambda do |_env, error|
|
|
26
|
+
!error.is_a?(Faraday::ConnectionFailed) ||
|
|
27
|
+
PRE_SEND_ERRORS.any? { |klass| error.wrapped_exception.is_a?(klass) }
|
|
28
|
+
end
|
|
29
|
+
RETRY_OPTIONS = {
|
|
30
|
+
max: RETRY_MAX,
|
|
31
|
+
interval: RETRY_INTERVAL,
|
|
32
|
+
backoff_factor: RETRY_BACKOFF_FACTOR,
|
|
33
|
+
interval_randomness: RETRY_INTERVAL_RANDOMNESS,
|
|
34
|
+
retry_statuses: RETRY_STATUSES,
|
|
35
|
+
# Faraday::TimeoutError is left out: a read timeout on a POST must not
|
|
36
|
+
# be resent (see PRE_SEND_ERRORS).
|
|
37
|
+
exceptions: [Faraday::ConnectionFailed, Faraday::RetriableResponse],
|
|
38
|
+
# Empty methods routes every retry decision through RETRY_IF.
|
|
39
|
+
methods: [],
|
|
40
|
+
retry_if: RETRY_IF
|
|
41
|
+
}.freeze
|
|
16
42
|
USER_AGENT = "digiwin_dsp/#{VERSION} (Faraday/#{Faraday::VERSION})".freeze
|
|
17
43
|
|
|
18
44
|
STATUS_ERROR_MAP = {
|
|
@@ -67,13 +93,7 @@ module DigiwinDsp
|
|
|
67
93
|
def connection
|
|
68
94
|
@connection ||= Faraday.new(url: connection_base_url, headers: default_headers) do |f|
|
|
69
95
|
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]
|
|
96
|
+
f.request :retry, RETRY_OPTIONS
|
|
77
97
|
# max_nesting caps deserialization depth so a hostile / malformed DSP
|
|
78
98
|
# response can't allocate unbounded memory (DoS guard on the parser).
|
|
79
99
|
f.response :json, content_type: /\bjson\z/, parser_options: { max_nesting: 50 }
|
|
@@ -85,7 +105,7 @@ module DigiwinDsp
|
|
|
85
105
|
end
|
|
86
106
|
|
|
87
107
|
def connection_base_url
|
|
88
|
-
base = @base_url_override || @configuration.base_url
|
|
108
|
+
base = @base_url_override&.tap { |url| @configuration.validate_url!(url) } || @configuration.base_url
|
|
89
109
|
base.end_with?("/") ? base : "#{base}/"
|
|
90
110
|
end
|
|
91
111
|
|
|
@@ -114,8 +134,12 @@ module DigiwinDsp
|
|
|
114
134
|
klass.new(message, **error_attrs(status, body))
|
|
115
135
|
end
|
|
116
136
|
|
|
137
|
+
# Every DSP endpoint answers with a JSON object; anything else on a 2xx
|
|
138
|
+
# (IIS HTML error page, empty body) would otherwise surface downstream
|
|
139
|
+
# as NoMethodError, outside the DigiwinDsp::Error tree. The body itself
|
|
140
|
+
# stays out of the message (PII).
|
|
117
141
|
def inspect_envelope(body)
|
|
118
|
-
|
|
142
|
+
raise ServerError.new("DSP returned a non-JSON-object 2xx body (#{body.class})", http_status: 200) unless body.is_a?(Hash)
|
|
119
143
|
|
|
120
144
|
failure = detect_envelope_failure(body)
|
|
121
145
|
return body unless failure
|
|
@@ -128,7 +152,7 @@ module DigiwinDsp
|
|
|
128
152
|
# - DSPOOFFICIAL100: { srvver, std_data: { execution: { code, description }, response } }
|
|
129
153
|
# - DSPOOFFICIAL001-005: { Status, Message, response_detail }
|
|
130
154
|
def detect_envelope_failure(body)
|
|
131
|
-
if (exec = body
|
|
155
|
+
if (exec = execution_block(body))
|
|
132
156
|
return nil if exec["code"].to_s == "0"
|
|
133
157
|
|
|
134
158
|
{ message: exec["description"].to_s, code: exec["code"] }
|
|
@@ -142,6 +166,13 @@ module DigiwinDsp
|
|
|
142
166
|
end
|
|
143
167
|
end
|
|
144
168
|
|
|
169
|
+
# Hash-checked dig: a 2xx like {"std_data":"x"} must not raise TypeError.
|
|
170
|
+
def execution_block(body)
|
|
171
|
+
std_data = body["std_data"]
|
|
172
|
+
exec = std_data["execution"] if std_data.is_a?(Hash)
|
|
173
|
+
exec if exec.is_a?(Hash)
|
|
174
|
+
end
|
|
175
|
+
|
|
145
176
|
# Neither known envelope. If DSP ships a third shape, failures would
|
|
146
177
|
# otherwise pass through as "success" silently — leave a trace.
|
|
147
178
|
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, :
|
|
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"]
|
|
@@ -56,8 +62,26 @@ module DigiwinDsp
|
|
|
56
62
|
"api_key is required (set via DigiwinDsp.configure or DIGIWIN_DSP_API_KEY)"
|
|
57
63
|
end
|
|
58
64
|
|
|
65
|
+
# Public so Client can apply the same https + allowed_hosts guard to a per-client base_url override.
|
|
66
|
+
def validate_url!(url)
|
|
67
|
+
uri = URI.parse(url)
|
|
68
|
+
raise ConfigurationError, "base_url must use https (got #{uri.scheme.inspect})" unless uri.scheme == "https"
|
|
69
|
+
return if allowed_host?(uri.host)
|
|
70
|
+
|
|
71
|
+
raise ConfigurationError,
|
|
72
|
+
"base_url host #{uri.host.inspect} is not in allowed_hosts #{allowed_hosts.inspect}; " \
|
|
73
|
+
"add it via DigiwinDsp.configure { |c| c.allowed_hosts += [host] }"
|
|
74
|
+
rescue URI::InvalidURIError => e
|
|
75
|
+
raise ConfigurationError, "base_url is not a valid URI: #{e.message}"
|
|
76
|
+
end
|
|
77
|
+
|
|
59
78
|
private
|
|
60
79
|
|
|
80
|
+
# DNS hostnames are case-insensitive.
|
|
81
|
+
def allowed_host?(host)
|
|
82
|
+
!host.nil? && allowed_hosts.any? { |h| h.casecmp?(host) }
|
|
83
|
+
end
|
|
84
|
+
|
|
61
85
|
def resolve_default_base_url
|
|
62
86
|
BASE_URLS.fetch(environment) do
|
|
63
87
|
raise ConfigurationError,
|
|
@@ -71,17 +95,5 @@ module DigiwinDsp
|
|
|
71
95
|
"unknown environment #{environment.inspect}; expected one of #{WEBHOOK_BASE_URLS.keys.inspect}"
|
|
72
96
|
end
|
|
73
97
|
end
|
|
74
|
-
|
|
75
|
-
def validate_url!(url)
|
|
76
|
-
uri = URI.parse(url)
|
|
77
|
-
raise ConfigurationError, "base_url must use https (got #{uri.scheme.inspect})" unless uri.scheme == "https"
|
|
78
|
-
return if allowed_hosts.include?(uri.host)
|
|
79
|
-
|
|
80
|
-
raise ConfigurationError,
|
|
81
|
-
"base_url host #{uri.host.inspect} is not in allowed_hosts #{allowed_hosts.inspect}; " \
|
|
82
|
-
"add it via DigiwinDsp.configure { |c| c.allowed_hosts += [host] }"
|
|
83
|
-
rescue URI::InvalidURIError => e
|
|
84
|
-
raise ConfigurationError, "base_url is not a valid URI: #{e.message}"
|
|
85
|
-
end
|
|
86
98
|
end
|
|
87
99
|
end
|
|
@@ -1,10 +1,26 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
3
5
|
module DigiwinDsp
|
|
4
6
|
module Serializers
|
|
5
7
|
# Mix into a serializer module via `extend Base`. The hosting module must
|
|
6
8
|
# define a REQUIRED_FIELDS constant (array of string field names).
|
|
7
9
|
module Base
|
|
10
|
+
# strftime formats from docs/dsp-specs/*.yaml. Checked on every endpoint
|
|
11
|
+
# whenever the field is present; blank optional fields are skipped.
|
|
12
|
+
FIELD_FORMATS = {
|
|
13
|
+
"create_datetime" => "%Y-%m-%d %H:%M:%S",
|
|
14
|
+
"order_date" => "%Y%m%d",
|
|
15
|
+
"invoice_date" => "%Y%m%d",
|
|
16
|
+
"order_expect_shipping_date" => "%Y%m%d",
|
|
17
|
+
"invoice_time" => "%H:%M:%S"
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
# Spec-style labels for error messages: "%Y-%m-%d" -> "YYYY-MM-DD".
|
|
21
|
+
# %m (month) and %M (minute) both read "MM", as in the DSP docs.
|
|
22
|
+
FORMAT_LABELS = { "%Y" => "YYYY", "%m" => "MM", "%d" => "DD", "%H" => "HH", "%M" => "MM", "%S" => "SS" }.freeze
|
|
23
|
+
|
|
8
24
|
def serialize(records, digi_header: nil)
|
|
9
25
|
normalized = normalize(records)
|
|
10
26
|
validate!(normalized)
|
|
@@ -15,24 +31,47 @@ module DigiwinDsp
|
|
|
15
31
|
|
|
16
32
|
def normalize(records)
|
|
17
33
|
list = records.is_a?(Array) ? records : [records]
|
|
34
|
+
raise ValidationError, "records must not be empty" if list.empty?
|
|
35
|
+
# respond_to? rather than is_a?(Hash) so ActionController::Parameters keeps working.
|
|
36
|
+
raise ValidationError, "each record must be a Hash" unless list.all? { |r| r.respond_to?(:transform_keys) }
|
|
37
|
+
|
|
18
38
|
list.map { |r| r.transform_keys(&:to_s) }
|
|
19
39
|
end
|
|
20
40
|
|
|
21
41
|
def validate!(records)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
42
|
+
prefixes = records.size > 1 ? records.each_index.map { |i| "[#{i}]." } : [""]
|
|
43
|
+
pairs = records.zip(prefixes)
|
|
44
|
+
missing = pairs.flat_map { |r, pre| missing_fields(r, pre) }
|
|
45
|
+
invalid = pairs.flat_map { |r, pre| invalid_formats(r, pre) }
|
|
46
|
+
return if missing.empty? && invalid.empty?
|
|
26
47
|
|
|
27
|
-
raise ValidationError,
|
|
48
|
+
raise ValidationError, validation_message(missing, invalid)
|
|
28
49
|
end
|
|
29
50
|
|
|
30
|
-
def missing_fields(record,
|
|
31
|
-
|
|
32
|
-
|
|
51
|
+
def missing_fields(record, prefix)
|
|
52
|
+
self::REQUIRED_FIELDS.reject { |f| present?(record[f]) }.map { |f| "#{prefix}#{f}" }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The offending value stays out of the message, matching the PII rule on errors.
|
|
56
|
+
def invalid_formats(record, prefix)
|
|
57
|
+
FIELD_FORMATS.filter_map do |field, format|
|
|
58
|
+
value = record[field]
|
|
59
|
+
"#{prefix}#{field} (expected #{format.gsub(/%[YmdHMS]/, FORMAT_LABELS)})" if present?(value) && !valid_format?(value, format)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The strftime round-trip rejects what strptime tolerates: "2026-5-20", "20260520 ", "24:00:00".
|
|
64
|
+
def valid_format?(value, format)
|
|
65
|
+
value.is_a?(String) && DateTime.strptime(value, format).strftime(format) == value
|
|
66
|
+
rescue Date::Error
|
|
67
|
+
false
|
|
68
|
+
end
|
|
33
69
|
|
|
34
|
-
|
|
35
|
-
|
|
70
|
+
def validation_message(missing, invalid)
|
|
71
|
+
parts = []
|
|
72
|
+
parts << "missing or empty required fields: #{missing.join(", ")}" if missing.any?
|
|
73
|
+
parts << "invalid format: #{invalid.join(", ")}" if invalid.any?
|
|
74
|
+
parts.join("; ")
|
|
36
75
|
end
|
|
37
76
|
|
|
38
77
|
def present?(value)
|
data/lib/digiwin_dsp/version.rb
CHANGED
|
@@ -21,13 +21,21 @@ module DigiwinDsp
|
|
|
21
21
|
JSON.parse(raw_body, max_nesting: 50)
|
|
22
22
|
rescue JSON::ParserError => e
|
|
23
23
|
raise ParseError, "invalid JSON: #{e.message}"
|
|
24
|
+
rescue TypeError
|
|
25
|
+
# A nil / non-String body (e.g. missing raw_post) must stay inside the DigiwinDsp::Error tree.
|
|
26
|
+
raise ParseError, "raw body must be a String (got #{raw_body.class})"
|
|
24
27
|
end
|
|
25
28
|
|
|
26
29
|
def self.extract_request(hash)
|
|
27
30
|
raise ParseError, "envelope must be a JSON object" unless hash.is_a?(Hash)
|
|
28
31
|
|
|
29
|
-
|
|
30
|
-
|
|
32
|
+
# Walk by hand: Hash#dig raises TypeError when an intermediate level
|
|
33
|
+
# is a String/Array, which would escape the DigiwinDsp::Error tree
|
|
34
|
+
# (webhooks are unsigned, so anyone can send such a body).
|
|
35
|
+
request = %w[digi_body std_data parameter request].reduce(hash) do |node, key|
|
|
36
|
+
node.is_a?(Hash) ? node[key] : nil
|
|
37
|
+
end
|
|
38
|
+
request || raise(ParseError, "envelope missing digi_body.std_data.parameter.request")
|
|
31
39
|
end
|
|
32
40
|
|
|
33
41
|
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
|
+
version: 0.6.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-
|
|
11
|
+
date: 2026-10-22 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.
|
|
104
|
+
version: 3.3.0
|
|
105
105
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
106
106
|
requirements:
|
|
107
107
|
- - ">="
|