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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7ea76bd5a8bb2c25914e0f9bd6c40ff1fdf5ac6c6c0e83b6652b9a4c20f26a33
4
- data.tar.gz: bca70e0fd5ba6f33831f2aaf4cf61aa29987a241d958dadd20539a34490bbf53
3
+ metadata.gz: 39cfa7cd414965f7f63ac143177e2dcb4ec36dee5403a7fc211f7a6d782d4ad1
4
+ data.tar.gz: 86333bb27dd34ad93c350cdf2b195237faac24ca3c5192e843e4d43958c0c578
5
5
  SHA512:
6
- metadata.gz: dc0e854bc169fda091248b0048c9cf79ab0cad639b0cd431dfce867d3f9eaeaab737bad0f8103975bf9311efe99cd737b9bb05d65019cabfcb27ac8d77d1da7f
7
- data.tar.gz: 619d56aa98ec48df0055bad21448618a244b8e29623429a07adaa8ca236229d7501ade6f978c69d7df3a495de1d5be6b5d6951e25f3ba050a18a9f6c4826176e
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.4.2...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
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
+ [![Gem Version](https://badge.fury.io/rb/digiwin_dsp.svg)](https://badge.fury.io/rb/digiwin_dsp)
3
4
  [![CI](https://github.com/7a6163/digiwin_dsp/actions/workflows/ci.yml/badge.svg)](https://github.com/7a6163/digiwin_dsp/actions/workflows/ci.yml)
4
5
  [![codecov](https://codecov.io/gh/7a6163/digiwin_dsp/branch/main/graph/badge.svg)](https://codecov.io/gh/7a6163/digiwin_dsp)
5
- [![Ruby](https://img.shields.io/badge/ruby-%E2%89%A53.2-CC342D)](https://www.ruby-lang.org/)
6
+ [![Ruby](https://img.shields.io/badge/ruby-3.3%E2%80%934.0-CC342D)](https://www.ruby-lang.org/)
6
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./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.2 required.
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, 500, 502, 503, 504, connection failures |
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 that exhausts retries |
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 (134 examples, 100% coverage)
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
  ```
@@ -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.
@@ -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
- return body unless body.is_a?(Hash)
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.dig("std_data", "execution"))
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, :environment, :logger,
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
- 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.4.2"
4
+ VERSION = "0.6.0"
5
5
  end
@@ -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
- hash.dig("digi_body", "std_data", "parameter", "request") ||
30
- raise(ParseError, "envelope missing digi_body.std_data.parameter.request")
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.2
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-06-18 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
@@ -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.2.0
104
+ version: 3.3.0
105
105
  required_rubygems_version: !ruby/object:Gem::Requirement
106
106
  requirements:
107
107
  - - ">="