digiwin_dsp 0.5.0 → 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 +19 -1
- data/README.md +7 -5
- data/lib/digiwin_dsp/client.rb +5 -2
- data/lib/digiwin_dsp/configuration.rb +18 -12
- data/lib/digiwin_dsp/serializers/base.rb +49 -10
- data/lib/digiwin_dsp/version.rb +1 -1
- data/lib/digiwin_dsp/webhooks/event.rb +3 -0
- metadata +2 -2
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,23 @@ 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
|
+
|
|
9
26
|
## [0.5.0] - 2026-10-01
|
|
10
27
|
|
|
11
28
|
### BREAKING
|
|
@@ -277,7 +294,8 @@ Initial release. Covers the four Self-hosted Website Module (自有官網模組)
|
|
|
277
294
|
- The gem is **synchronous on purpose**. Callers wrap requests in their own background job runner (e.g. ActiveJob) when needed.
|
|
278
295
|
- Idempotency: clients can send `X-Idempotency-Key` via the `idempotency_key:` kwarg. DSP also dedupes server-side by `form_no + platform_id`.
|
|
279
296
|
|
|
280
|
-
[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
|
|
281
299
|
[0.5.0]: https://github.com/7a6163/digiwin_dsp/compare/v0.4.2...v0.5.0
|
|
282
300
|
[0.4.2]: https://github.com/7a6163/digiwin_dsp/compare/v0.4.1...v0.4.2
|
|
283
301
|
[0.4.1]: https://github.com/7a6163/digiwin_dsp/compare/v0.4.0...v0.4.1
|
data/README.md
CHANGED
|
@@ -146,6 +146,8 @@ Each has its own required-field set (8 / 11 / 19 fields respectively). Inspect `
|
|
|
146
146
|
DigiwinDsp::Serializers::CancellationSerializer::REQUIRED_FIELDS
|
|
147
147
|
```
|
|
148
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
|
+
|
|
149
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.
|
|
150
152
|
|
|
151
153
|
### `order_status` enum
|
|
@@ -230,8 +232,8 @@ Every `Client#post` already retries transparently inside Faraday:
|
|
|
230
232
|
| Setting | Value |
|
|
231
233
|
|---|---|
|
|
232
234
|
| Attempts | up to 4 (1 original + max 3 retries) |
|
|
233
|
-
| Triggers | HTTP 429,
|
|
234
|
-
| Never retried |
|
|
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 |
|
|
235
237
|
| Backoff | exponential — ~0.5s, ~1s, ~2s between attempts, ±50% jitter |
|
|
236
238
|
|
|
237
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.
|
|
@@ -261,11 +263,11 @@ All exceptions inherit from `DigiwinDsp::Error` and carry structured attributes
|
|
|
261
263
|
| Exception | Raised when |
|
|
262
264
|
|---|---|
|
|
263
265
|
| `DigiwinDsp::ConfigurationError` | `api_key` missing at request time |
|
|
264
|
-
| `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 |
|
|
265
267
|
| `DigiwinDsp::AuthenticationError` | HTTP 401 / 403 |
|
|
266
268
|
| `DigiwinDsp::DuplicateRequestError` | DSP returns `Duplicated:` or HTTP 409 |
|
|
267
269
|
| `DigiwinDsp::RateLimitError` | DSP returns `Processing:資料處理中` (retryable) or persistent HTTP 429 |
|
|
268
|
-
| `DigiwinDsp::ServerError` | DSP returns `系統異常:` or HTTP 5xx
|
|
270
|
+
| `DigiwinDsp::ServerError` | DSP returns `系統異常:` or HTTP 5xx (503 only after retries are exhausted) |
|
|
269
271
|
| `DigiwinDsp::NetworkError` | TCP connect failure or timeout |
|
|
270
272
|
| `DigiwinDsp::Error` | Catch-all (unmapped failure message or unexpected status) |
|
|
271
273
|
|
|
@@ -328,7 +330,7 @@ DigiwinDsp::Resources::Order.create(
|
|
|
328
330
|
|
|
329
331
|
```bash
|
|
330
332
|
bin/setup # bundle install
|
|
331
|
-
bundle exec rspec # run the full test suite (
|
|
333
|
+
bundle exec rspec # run the full test suite (100% line coverage)
|
|
332
334
|
bundle exec rubocop # lint
|
|
333
335
|
bin/console # IRB with the gem loaded
|
|
334
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.
|
|
@@ -102,7 +105,7 @@ module DigiwinDsp
|
|
|
102
105
|
end
|
|
103
106
|
|
|
104
107
|
def connection_base_url
|
|
105
|
-
base = @base_url_override || @configuration.base_url
|
|
108
|
+
base = @base_url_override&.tap { |url| @configuration.validate_url!(url) } || @configuration.base_url
|
|
106
109
|
base.end_with?("/") ? base : "#{base}/"
|
|
107
110
|
end
|
|
108
111
|
|
|
@@ -62,8 +62,26 @@ module DigiwinDsp
|
|
|
62
62
|
"api_key is required (set via DigiwinDsp.configure or DIGIWIN_DSP_API_KEY)"
|
|
63
63
|
end
|
|
64
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
|
+
|
|
65
78
|
private
|
|
66
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
|
+
|
|
67
85
|
def resolve_default_base_url
|
|
68
86
|
BASE_URLS.fetch(environment) do
|
|
69
87
|
raise ConfigurationError,
|
|
@@ -77,17 +95,5 @@ module DigiwinDsp
|
|
|
77
95
|
"unknown environment #{environment.inspect}; expected one of #{WEBHOOK_BASE_URLS.keys.inspect}"
|
|
78
96
|
end
|
|
79
97
|
end
|
|
80
|
-
|
|
81
|
-
def validate_url!(url)
|
|
82
|
-
uri = URI.parse(url)
|
|
83
|
-
raise ConfigurationError, "base_url must use https (got #{uri.scheme.inspect})" unless uri.scheme == "https"
|
|
84
|
-
return if allowed_hosts.include?(uri.host)
|
|
85
|
-
|
|
86
|
-
raise ConfigurationError,
|
|
87
|
-
"base_url host #{uri.host.inspect} is not in allowed_hosts #{allowed_hosts.inspect}; " \
|
|
88
|
-
"add it via DigiwinDsp.configure { |c| c.allowed_hosts += [host] }"
|
|
89
|
-
rescue URI::InvalidURIError => e
|
|
90
|
-
raise ConfigurationError, "base_url is not a valid URI: #{e.message}"
|
|
91
|
-
end
|
|
92
98
|
end
|
|
93
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,6 +21,9 @@ 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)
|
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-10-
|
|
11
|
+
date: 2026-10-22 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: faraday
|