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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 813b05b6b115eed5a1fd308aa3c8a255da6df70afdf090101e7bfe0f74f605e7
4
- data.tar.gz: 7ecfa001245bff0c95125f9667e303506ab9c1170bf8cce12f039c9773715bb4
3
+ metadata.gz: 39cfa7cd414965f7f63ac143177e2dcb4ec36dee5403a7fc211f7a6d782d4ad1
4
+ data.tar.gz: 86333bb27dd34ad93c350cdf2b195237faac24ca3c5192e843e4d43958c0c578
5
5
  SHA512:
6
- metadata.gz: 1086238addb8b6feb861d80f4c133c9e891e4b64ed45821ffc0dd68bde3a8e0ea7fadd009ee2b34dfa78e9a640fe2b22d8590a1b364d80b43081688771b53ebd
7
- data.tar.gz: 12079e2663087764b54acae5b774e22398dc9ec48b3379684bfd6fdb0be51ef2604ac7d2db30e59bb7a9dae6bfdbac62b965c5c29b1ac1c1c7bf0c8247ab9fb2
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.5.0...HEAD
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, 500, 502, 503, 504; connection failures that happen *before* the request reaches DSP (refused, host/network unreachable, DNS failure, connect timeout) |
234
- | Never retried | Read timeouts and mid-request connection resets — DSP may already have processed the POST, so resending could duplicate it. These raise `NetworkError`; check DSP state before retrying |
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 that exhausts retries |
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 (134 examples, 100% coverage)
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
  ```
@@ -5,7 +5,10 @@ require "faraday/retry"
5
5
 
6
6
  module DigiwinDsp
7
7
  class Client
8
- RETRY_STATUSES = [429, 500, 502, 503, 504].freeze
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
- problems = []
23
- total = records.size
24
- total.times { |i| problems.concat(missing_fields(records[i], i, total)) }
25
- return if problems.empty?
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, "missing or empty required fields: #{problems.join(", ")}"
48
+ raise ValidationError, validation_message(missing, invalid)
28
49
  end
29
50
 
30
- def missing_fields(record, index, total)
31
- missing = self::REQUIRED_FIELDS.reject { |f| present?(record[f]) }
32
- return [] if missing.empty?
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
- prefix = total > 1 ? "[#{index}]." : ""
35
- missing.map { |f| "#{prefix}#{f}" }
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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DigiwinDsp
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.0"
5
5
  end
@@ -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.5.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-01 00:00:00.000000000 Z
11
+ date: 2026-10-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday