rate-card 0.1.0 → 0.1.2
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/README.md +10 -202
- data/exe/rate-card +12 -5
- data/lib/rate_card/constants/addresses.rb +6 -1
- data/lib/rate_card/constants/carriers.rb +2 -2
- data/lib/rate_card/grid.rb +24 -72
- data/lib/rate_card/provider_prompt.rb +19 -0
- data/lib/rate_card/providers/ehub/client.rb +128 -0
- data/lib/rate_card/providers/ehub/provider.rb +109 -0
- data/lib/rate_card/providers/ehub/service_catalog.rb +59 -0
- data/lib/rate_card/providers/ehub/shipment.rb +82 -0
- data/lib/rate_card/providers/ehub/token.rb +52 -0
- data/lib/rate_card/providers/ehub/token_prompt.rb +71 -0
- data/lib/rate_card/providers.rb +22 -0
- data/lib/rate_card/service.rb +14 -0
- data/lib/rate_card/tui/app.rb +8 -6
- data/lib/rate_card/version.rb +1 -1
- data/lib/rate_card.rb +8 -5
- metadata +9 -6
- data/lib/rate_card/client.rb +0 -124
- data/lib/rate_card/service_catalog.rb +0 -62
- data/lib/rate_card/shipment.rb +0 -78
- data/lib/rate_card/token.rb +0 -48
- data/lib/rate_card/token_prompt.rb +0 -58
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f117b9526f8fe1f4dbde79da9e35bbc8ff432871c3b9736f0a0699d7542ac189
|
|
4
|
+
data.tar.gz: d50336d03593f510d841f4e2e2b7bbe280eff3c5fd2f063664deef532dea0182
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6879442c47e5188d2792bd1d002d67e8a36fb4b3e9d3bc0ec9d14652c39854915f972171cba58f04be38cef50d747fb5bf1f9af50b8cbec81360a9e7367796ce
|
|
7
|
+
data.tar.gz: b5545e4ee1a80c13833861b1b6301bc2ca2b094aa316bb59b49433cabd4a22a54e9f89eca2e2a0d69427b1c5e7544b7e73a16bc11c491a8f868b1272f3a7e6e5
|
data/README.md
CHANGED
|
@@ -1,226 +1,34 @@
|
|
|
1
1
|
# rate-card
|
|
2
2
|
|
|
3
3
|
Interactive CLI that generates a shipping rate card — a weight × zone grid of rates — from the
|
|
4
|
-
|
|
4
|
+
eHub production API. Prints the card to your terminal and saves CSVs to `~/Downloads`.
|
|
5
5
|
|
|
6
6
|
## Install
|
|
7
7
|
|
|
8
|
-
Homebrew — nothing else needed, and no Ruby setup of your own:
|
|
9
|
-
|
|
10
|
-
```bash
|
|
11
|
-
brew install jxsoren/tools/rate-card
|
|
12
|
-
rate-card
|
|
13
|
-
```
|
|
14
|
-
|
|
15
|
-
> The tap repo (`jxsoren/homebrew-tools`) does not exist yet, so this command does not
|
|
16
|
-
> work until it is created — see **Release** below. The gem install underneath it does.
|
|
17
|
-
|
|
18
|
-
Homebrew installs its own Ruby and keeps every gem inside the keg, so this cannot
|
|
19
|
-
collide with an rbenv or system Ruby you already have.
|
|
20
|
-
|
|
21
|
-
If you already work in Ruby, the gem is equivalent:
|
|
22
|
-
|
|
23
|
-
```bash
|
|
24
|
-
gem install rate-card
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
To upgrade: `brew upgrade rate-card`, or `gem update rate-card`.
|
|
28
|
-
|
|
29
|
-
## Develop
|
|
30
|
-
|
|
31
8
|
```bash
|
|
32
|
-
|
|
33
|
-
bundle exec exe/rate-card
|
|
34
|
-
NO_COLOR=1 bundle exec rspec
|
|
9
|
+
cd ~ && brew install jxsoren/tools/rate-card
|
|
35
10
|
```
|
|
36
11
|
|
|
37
|
-
|
|
38
|
-
uncoloured rendered output, and lipgloss colours whenever stdout is a tty. See the comment
|
|
39
|
-
in `spec/spec_helper.rb`.
|
|
40
|
-
|
|
41
|
-
Ruby 3.3.4 (pinned in `.ruby-version`); the gemspec floor is 3.2, and Homebrew currently
|
|
42
|
-
builds against Ruby 4.0.
|
|
43
|
-
|
|
44
|
-
The TUI is [bubbletea-ruby](https://github.com/marcoroth/bubbletea-ruby) with `bubbles` and
|
|
45
|
-
`lipgloss`. These ship precompiled native binaries, so no Go toolchain is needed to install
|
|
46
|
-
them. Note that `bubbles` is a shared gem name — `0.0.x` is an unrelated gem that pulls in
|
|
47
|
-
`aws-sdk` — which is why the gemspec pins `~> 0.1`.
|
|
48
|
-
|
|
49
|
-
## Release
|
|
50
|
-
|
|
51
|
-
Bump `lib/rate_card/version.rb`, commit, then:
|
|
12
|
+
Then run it:
|
|
52
13
|
|
|
53
14
|
```bash
|
|
54
|
-
|
|
15
|
+
rate-card
|
|
55
16
|
```
|
|
56
17
|
|
|
57
|
-
|
|
58
|
-
`sha256` to match what was pushed. The tap commit is left to you — see the script's
|
|
59
|
-
closing output. `packaging/homebrew/rate-card.rb` is the source of truth for the formula;
|
|
60
|
-
the tap's `Formula/rate-card.rb` is a copy.
|
|
61
|
-
|
|
62
|
-
## Use
|
|
18
|
+
If you already work in Ruby, the gem is equivalent:
|
|
63
19
|
|
|
64
20
|
```bash
|
|
65
|
-
|
|
21
|
+
gem install rate-card
|
|
66
22
|
```
|
|
67
23
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
1. **eHub API token** — pasted, masked. Decoded so you can confirm the account before the run
|
|
71
|
-
starts. Never written to disk. A malformed token re-prompts rather than ending the run.
|
|
72
|
-
2. **Rate by** — Weight or Cubic dimensions. Only asked when the token has USPS
|
|
73
|
-
services, since USPS cubic pricing (Ground Advantage Cubic, Priority Mail Cubic) is
|
|
74
|
-
priced by which of ten official volume tiers the package falls into, not by weight.
|
|
75
|
-
Choosing Cubic restricts the carrier to USPS and replaces the weight-unit and
|
|
76
|
-
weight-range questions with a multi-select of the ten tiers. Choosing Cubic does not check
|
|
77
|
-
that the service you go on to pick is actually cubic-priced — you're responsible for
|
|
78
|
-
selecting a cubic-rated USPS service (e.g. "USPS Ground Advantage Cubic").
|
|
79
|
-
3. **Carrier**, then **services** — the list comes from the customer's own service catalogue
|
|
80
|
-
(`GET /api/v2/services?category=shipping`), so it is exactly what that customer has enabled,
|
|
81
|
-
including services that would not quote at a single probe weight.
|
|
82
|
-
4. **Zones** (`1-8`, or `1,3,5`); then, in weight mode, **weight unit** (`oz`/`lbs`) and
|
|
83
|
-
**weight range** — or, in cubic mode, the **cubic tiers** to include; then
|
|
84
|
-
**package type**, **rate columns** (shipper rate, meter rate).
|
|
85
|
-
5. A recap of every answer, with the call count, and a **Run** / **Back** choice. It opens on
|
|
86
|
-
**Back**, so a stray enter carried over from the previous question goes back rather than
|
|
87
|
-
spending 128 production calls; `ctrl-c` abandons the session outright.
|
|
88
|
-
|
|
89
|
-
Bad input at the zone or weight prompt re-prompts; it never costs you the run.
|
|
90
|
-
|
|
91
|
-
`esc` steps back to the previous question at any point in the wizard, including from the recap.
|
|
92
|
-
The question you land on reopens on the answer it already has; the answers that came *after* it
|
|
93
|
-
are forgotten, since they were given against a choice you are about to change.
|
|
94
|
-
|
|
95
|
-
### Keys
|
|
96
|
-
|
|
97
|
-
| Key | Effect |
|
|
98
|
-
| --- | --- |
|
|
99
|
-
| `↑` / `↓`, `k` / `j` | Move between choices |
|
|
100
|
-
| `space` | Tick or untick (services, rate columns) |
|
|
101
|
-
| `a` | Tick or untick everything |
|
|
102
|
-
| `enter` | Confirm the answer |
|
|
103
|
-
| `esc` | Go back to the previous question |
|
|
104
|
-
| `ctrl-c` | Abandon the run at any point |
|
|
105
|
-
|
|
106
|
-
Answered questions stay on screen as you go, and the fetch shows a live bar with a running
|
|
107
|
-
count of failed cells — so a run that is going wrong can be abandoned early rather than waited
|
|
108
|
-
out.
|
|
109
|
-
|
|
110
|
-
### One caveat: paste only the token
|
|
111
|
-
|
|
112
|
-
The token prompt is deliberately a plain readline **before** the TUI starts, because
|
|
113
|
-
bubbletea-ruby 0.1.4's input reader returns one key per poll and discards the rest of the
|
|
114
|
-
buffered bytes: a pasted 200-character JWT arrives as a single character. Typing is unaffected
|
|
115
|
-
at any speed, so every other answer is safe inside the TUI — but **type** the zone and weight
|
|
116
|
-
ranges rather than pasting them.
|
|
117
|
-
|
|
118
|
-
To reproduce the underlying bug: run a bare Bubbletea model that logs every `KeyMessage`, then
|
|
119
|
-
write 36 bytes to its pty in one call. One message arrives, carrying one rune; the other 35
|
|
120
|
-
bytes are discarded.
|
|
121
|
-
|
|
122
|
-
For a bare `rate-card` command:
|
|
24
|
+
## Update
|
|
123
25
|
|
|
124
26
|
```bash
|
|
125
|
-
|
|
27
|
+
cd ~ && brew update && brew upgrade rate-card
|
|
126
28
|
```
|
|
127
29
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
| Flag | Effect |
|
|
131
|
-
| --- | --- |
|
|
132
|
-
| `--output-dir DIR` | Save CSVs somewhere other than `~/Downloads/rate_cards` |
|
|
133
|
-
| `--no-table` | Skip printing tables to stdout |
|
|
134
|
-
| `--version`, `--help` | As expected |
|
|
135
|
-
|
|
136
|
-
Everything else is interactive by design — a second input path would let flags and wizard
|
|
137
|
-
answers disagree.
|
|
138
|
-
|
|
139
|
-
## Output
|
|
140
|
-
|
|
141
|
-
Both to your terminal and to disk:
|
|
142
|
-
|
|
143
|
-
```
|
|
144
|
-
ops@acme.test (1042) · USPS Ground Advantage · shipper rate
|
|
145
|
-
┌────────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐
|
|
146
|
-
│ wt(oz) │ Z1 │ Z2 │ Z3 │ Z4 │ Z5 │ Z6 │ Z7 │ Z8 │
|
|
147
|
-
├────────┼──────┼──────┼──────┼──────┼──────┼──────┼──────┼──────┤
|
|
148
|
-
│ 1 │ 1.11 │ 2.22 │ 3.33 │ 4.44 │ 5.55 │ 6.66 │ 7.77 │ 8.88 │
|
|
149
|
-
│ 2 │ 2.11 │ 3.22 │ 4.33 │ 5.44 │ — │ 7.66 │ 8.77 │ 9.88 │
|
|
150
|
-
└────────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┘
|
|
151
|
-
|
|
152
|
-
⚠ 1 cell failed
|
|
153
|
-
wt 2 Z5 → 500 upstream carrier error
|
|
154
|
-
|
|
155
|
-
✓ Saved 2 files to ~/Downloads/rate_cards/ops_acme_test_1042_2026-09-01T12-00-00Z
|
|
156
|
-
GroundAdvantage_shipper_rate.csv
|
|
157
|
-
GroundAdvantage_meter_rate.csv
|
|
158
|
-
```
|
|
159
|
-
|
|
160
|
-
The rates above are invented placeholders, not a real quote.
|
|
161
|
-
|
|
162
|
-
One CSV per service per rate column. Header is `weight` plus one column per zone. The run
|
|
163
|
-
directory is timestamped, so a new card never clobbers an old one.
|
|
164
|
-
|
|
165
|
-
**A missing rate is an empty cell, never `0.00`.** A zero in a rate table reads as a genuine
|
|
166
|
-
free rate; `../rate_table_builder` writes `0.0` there and this tool deliberately does not.
|
|
167
|
-
Failures are listed after the tables with weight, zone and reason, and a run with holes still
|
|
168
|
-
writes its files.
|
|
169
|
-
|
|
170
|
-
## Call volume
|
|
171
|
-
|
|
172
|
-
One API call per (weight × zone). **Services are free**: one response carries every service the
|
|
173
|
-
customer has enabled, so checking six costs the same as one. A 16-weight, 8-zone card is 128
|
|
174
|
-
calls, fanned out 8 at a time.
|
|
175
|
-
|
|
176
|
-
Two distinct no-output cases, which the tool does not conflate:
|
|
177
|
-
|
|
178
|
-
- **Every call failed** → likely network or token.
|
|
179
|
-
- **Calls succeeded but nothing was priced** → your service, package type or weight range.
|
|
180
|
-
USPS First Class is not quoted above 13 oz, for instance.
|
|
181
|
-
|
|
182
|
-
A carrier failure is not a failed call: eHub answers 201 and puts the reason in the response
|
|
183
|
-
`warnings` array, or in a service's `errors` field. Both are reported under
|
|
184
|
-
*the API reported N warnings*, deduplicated with an occurrence count.
|
|
185
|
-
|
|
186
|
-
## Tests
|
|
30
|
+
## Uninstall
|
|
187
31
|
|
|
188
32
|
```bash
|
|
189
|
-
|
|
33
|
+
brew uninstall rate-card
|
|
190
34
|
```
|
|
191
|
-
|
|
192
|
-
**No test contacts production** — `Client` is the only network seam, and specs stub it via
|
|
193
|
-
Faraday's test adapter.
|
|
194
|
-
|
|
195
|
-
## Design
|
|
196
|
-
|
|
197
|
-
The wizard's only product is a validated `RunSpec`; everything downstream consumes only that.
|
|
198
|
-
So the fetch/assemble/render engine is testable with no prompting and no network.
|
|
199
|
-
|
|
200
|
-
| File | Responsibility |
|
|
201
|
-
| --- | --- |
|
|
202
|
-
| `exe/rate-card` | Entrypoint: flags → wizard → runner. Every error becomes one clean line |
|
|
203
|
-
| `client.rb` | The only network seam. Every outcome becomes a Hash, `Unauthorized`, or `RequestFailed` |
|
|
204
|
-
| `token.rb` | Decodes the JWT payload to name the account. Does not verify the signature |
|
|
205
|
-
| `service_catalog.rb` | Service catalogue response → `Service` objects, grouped by carrier |
|
|
206
|
-
| `run_spec.rb` | The validated inputs for one run |
|
|
207
|
-
| `shipment.rb` | One (weight, zone) request payload |
|
|
208
|
-
| `grid.rb` | Fans out the calls, assembles cells and failures |
|
|
209
|
-
| `csv_writer.rb` / `table_renderer.rb` | Peers reading the same `Grid`, so files and terminal cannot disagree |
|
|
210
|
-
| `ui.rb` | Every byte the tool prints |
|
|
211
|
-
| `wizard.rb` / `runner.rb` | Ask; then orchestrate |
|
|
212
|
-
|
|
213
|
-
`constants/addresses.rb` holds hand-verified zone addresses — zone membership is a property of
|
|
214
|
-
a real address and a carrier's zone chart, so it is never derived at runtime.
|
|
215
|
-
|
|
216
|
-
## Related tools
|
|
217
|
-
|
|
218
|
-
- `../rate_sheet_builder` — batch, multi-customer, YAML roster. Use it for many customers at once.
|
|
219
|
-
- `../rate_table_builder` — the abandoned predecessor to this tool.
|
|
220
|
-
|
|
221
|
-
## Not done
|
|
222
|
-
|
|
223
|
-
`spec/fixtures/rates_response.json` was never captured, so no test pins the production response
|
|
224
|
-
field names (`service_rates`, `service_id`, `rate`, `meter_rate`, `errors`). If eHub renames one, the tool
|
|
225
|
-
will produce empty cells rather than failing loudly. Capturing one real response would close
|
|
226
|
-
that gap — see Task 16 of `docs/superpowers/plans/2026-08-31-rate-card-cli.md`.
|
data/exe/rate-card
CHANGED
|
@@ -14,6 +14,8 @@ options = { output_base: DEFAULT_OUTPUT_BASE, show_table: true }
|
|
|
14
14
|
parser = OptionParser.new do |opts|
|
|
15
15
|
opts.banner = 'Usage: rate-card [options]'
|
|
16
16
|
opts.separator ''
|
|
17
|
+
# eHub is the only registered provider today (see RateCard::Providers) — this
|
|
18
|
+
# line should name the provider generically once a second one exists.
|
|
17
19
|
opts.separator 'Generates a shipping rate card from the eHub production API.'
|
|
18
20
|
opts.separator 'All rate-card inputs are collected interactively.'
|
|
19
21
|
opts.separator ''
|
|
@@ -48,11 +50,14 @@ end
|
|
|
48
50
|
ui = RateCard::UI.new(io: $stdout)
|
|
49
51
|
ui.banner
|
|
50
52
|
|
|
53
|
+
provider_key = RateCard::ProviderPrompt.read(ui: ui)
|
|
54
|
+
provider = RateCard::Providers.build(provider_key)
|
|
55
|
+
|
|
51
56
|
# Read before the TUI starts. bubbletea-ruby 0.1.4 drops all but the first
|
|
52
|
-
# character of a pasted burst, and the
|
|
53
|
-
# pasted; out here the terminal's own line editing handles it.
|
|
54
|
-
|
|
55
|
-
if
|
|
57
|
+
# character of a pasted burst, and the credential is the one answer that is
|
|
58
|
+
# always pasted; out here the terminal's own line editing handles it.
|
|
59
|
+
credential = provider.read_credential(ui: ui)
|
|
60
|
+
if credential.nil?
|
|
56
61
|
ui.info('Cancelled — nothing was built.')
|
|
57
62
|
exit 0
|
|
58
63
|
end
|
|
@@ -60,7 +65,7 @@ end
|
|
|
60
65
|
# The TUI owns the terminal from here until it quits: the catalogue lookup, the
|
|
61
66
|
# remaining answers and the fetch all happen inside its event loop. Reporting
|
|
62
67
|
# happens after, on a terminal that has been handed back.
|
|
63
|
-
app = RateCard::TUI::App.new(token:
|
|
68
|
+
app = RateCard::TUI::App.new(token: credential, output_base: options[:output_base], provider: provider)
|
|
64
69
|
runner = Bubbletea::Runner.new(app)
|
|
65
70
|
app.notifier = runner
|
|
66
71
|
|
|
@@ -82,6 +87,8 @@ end
|
|
|
82
87
|
case app.error
|
|
83
88
|
when nil then nil
|
|
84
89
|
when RateCard::Unauthorized
|
|
90
|
+
# Wording is eHub-specific — fine while eHub is the only registered provider;
|
|
91
|
+
# generalize once a second one exists.
|
|
85
92
|
ui.error('Production rejected that token. Check it is a current eHub API token.')
|
|
86
93
|
exit 1
|
|
87
94
|
when RateCard::NoServices
|
|
@@ -65,7 +65,12 @@ module RateCard
|
|
|
65
65
|
# calculator, split this back into its own hand-verified hash.
|
|
66
66
|
DHL_ECOMMERCE = USPS
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
# OSM's zone calculator in ehub (app/services/carriers/osm/zone_calculator.rb)
|
|
69
|
+
# is likewise an unmodified subclass of USPS's, so OSM zones are USPS
|
|
70
|
+
# zones - same reasoning as DHL_ECOMMERCE above.
|
|
71
|
+
OSM = USPS
|
|
72
|
+
|
|
73
|
+
BY_CARRIER = { 'USPS' => USPS, 'UPS' => UPS, 'FedEx' => FEDEX, 'DHL' => DHL_ECOMMERCE, 'OSM' => OSM }.freeze
|
|
69
74
|
|
|
70
75
|
module_function
|
|
71
76
|
|
|
@@ -7,7 +7,7 @@ module RateCard
|
|
|
7
7
|
module Carriers
|
|
8
8
|
OTHER = 'Other'
|
|
9
9
|
|
|
10
|
-
DISPLAY_ORDER = ['USPS', 'UPS', 'FedEx', 'DHL', 'Amazon', OTHER].freeze
|
|
10
|
+
DISPLAY_ORDER = ['USPS', 'UPS', 'FedEx', 'DHL', 'OSM', 'Amazon', OTHER].freeze
|
|
11
11
|
|
|
12
12
|
# Every /services entry carries a lowercase carrier_code. Anything not
|
|
13
13
|
# listed here keeps its own code, upcased: an unrecognised carrier is
|
|
@@ -27,7 +27,7 @@ module RateCard
|
|
|
27
27
|
# silently inherit the wrong zone chart.
|
|
28
28
|
CARRIER_CODES = {
|
|
29
29
|
'usps' => 'USPS', 'ups' => 'UPS', 'fedex' => 'FedEx',
|
|
30
|
-
'dhl' => 'DHL Express', 'dhl_ecommerce' => 'DHL', 'amazon' => 'Amazon'
|
|
30
|
+
'dhl' => 'DHL Express', 'dhl_ecommerce' => 'DHL', 'osm' => 'OSM', 'amazon' => 'Amazon'
|
|
31
31
|
}.freeze
|
|
32
32
|
|
|
33
33
|
module_function
|
data/lib/rate_card/grid.rb
CHANGED
|
@@ -6,29 +6,27 @@ module RateCard
|
|
|
6
6
|
# The fetch engine and the assembled result.
|
|
7
7
|
#
|
|
8
8
|
# One call per (weight, zone); each response is harvested for every selected
|
|
9
|
-
# service,
|
|
10
|
-
#
|
|
11
|
-
#
|
|
9
|
+
# service, via the provider's #parse_rate_response. A cell whose call failed
|
|
10
|
+
# is nil and is listed in #failures — never 0.0, which in a rate table reads
|
|
11
|
+
# as a real free rate.
|
|
12
12
|
class Grid
|
|
13
13
|
THREADS = 8
|
|
14
|
-
|
|
15
|
-
# eHub answers these as an HTTP 201 "success" with the per-service errors
|
|
16
|
-
# field describing a hiccup that clears on its own, so Client's status-code
|
|
17
|
-
# retry never sees them. Left alone, the exact same request prices a
|
|
18
|
-
# different random set of cells on every run.
|
|
19
|
-
TRANSIENT_ERROR_PATTERN = /too many requests|please try again|slow down/i
|
|
20
14
|
RETRY_BACKOFF = [0.5, 1.0].freeze
|
|
21
15
|
|
|
22
16
|
# on_progress: called with no arguments after each completed call.
|
|
23
17
|
# retry_sleeper: injected so retry backoff is testable without waiting.
|
|
24
|
-
def self.build(spec:, client:, on_progress: nil, retry_sleeper: ->(seconds) { sleep(seconds) })
|
|
25
|
-
new(spec).tap { |grid| grid.send(:fetch_all, client, on_progress, retry_sleeper) }
|
|
18
|
+
def self.build(spec:, client:, provider:, on_progress: nil, retry_sleeper: ->(seconds) { sleep(seconds) })
|
|
19
|
+
new(spec, provider).tap { |grid| grid.send(:fetch_all, client, on_progress, retry_sleeper) }
|
|
26
20
|
end
|
|
27
21
|
|
|
28
22
|
attr_reader :spec
|
|
29
23
|
|
|
30
|
-
|
|
24
|
+
# provider defaults to nil only for specs that construct a Grid directly to
|
|
25
|
+
# seed @cells as a fixture and never fetch — #build (the real constructor)
|
|
26
|
+
# always supplies one.
|
|
27
|
+
def initialize(spec, provider = nil)
|
|
31
28
|
@spec = spec
|
|
29
|
+
@provider = provider
|
|
32
30
|
@cells = {}
|
|
33
31
|
@failures = []
|
|
34
32
|
@warnings = Hash.new(0)
|
|
@@ -44,36 +42,23 @@ module RateCard
|
|
|
44
42
|
@failures.sort_by { |f| [f.weight, f.zone] }
|
|
45
43
|
end
|
|
46
44
|
|
|
47
|
-
# What the API said went wrong on calls that otherwise succeeded: the
|
|
48
|
-
# response-level `warnings` array and the per-service `errors` field. Both
|
|
49
|
-
# arrive with an HTTP 201, so without this a carrier outage would show up as
|
|
50
|
-
# a card of blank cells with nothing to explain them.
|
|
51
|
-
#
|
|
52
|
-
# This card's own services sort first, however loud the account-wide noise
|
|
53
|
-
# is: one call rates every service on the token, so a token with 37 services
|
|
54
|
-
# enabled can bury the two warnings that explain this card's blank cells
|
|
55
|
-
# under thirty about services nobody selected.
|
|
56
45
|
def warnings
|
|
57
46
|
@warnings.map { |(message, scope), count| Warning.new(message: message, count: count, scope: scope) }
|
|
58
47
|
.sort_by { |warning| [scope_rank(warning), -warning.count, warning.message] }
|
|
59
48
|
end
|
|
60
49
|
|
|
61
|
-
# True only when no call got through. Deliberately NOT "no cell has a
|
|
62
|
-
# value": a run can have every call succeed and still price nothing, if the
|
|
63
|
-
# API does not return the selected service (USPS First Class is not priced
|
|
64
|
-
# above 13 oz, for instance). Conflating the two would blame the network for
|
|
65
|
-
# what is really a service or weight selection problem.
|
|
66
50
|
def all_failed?
|
|
67
51
|
@succeeded.zero? && @failures.any?
|
|
68
52
|
end
|
|
69
53
|
|
|
70
|
-
# Did any cell actually get a rate? False means we have nothing to write.
|
|
71
54
|
def any_rates?
|
|
72
55
|
@cells.values.any?
|
|
73
56
|
end
|
|
74
57
|
|
|
75
58
|
private
|
|
76
59
|
|
|
60
|
+
attr_reader :provider
|
|
61
|
+
|
|
77
62
|
def fetch_all(client, on_progress, retry_sleeper)
|
|
78
63
|
Parallel.each(cell_coordinates, in_threads: THREADS) do |weight, zone|
|
|
79
64
|
fetch_cell(client, weight, zone, retry_sleeper)
|
|
@@ -86,12 +71,11 @@ module RateCard
|
|
|
86
71
|
end
|
|
87
72
|
|
|
88
73
|
def fetch_cell(client, weight, zone, retry_sleeper)
|
|
89
|
-
payload =
|
|
90
|
-
|
|
91
|
-
record_response(
|
|
74
|
+
payload = provider.build_payload(spec: spec, weight: weight, address: spec.address_for(zone))
|
|
75
|
+
result = fetch_with_transient_retry(client, payload, retry_sleeper)
|
|
76
|
+
record_response(result, weight, zone)
|
|
92
77
|
@mutex.synchronize { @succeeded += 1 }
|
|
93
78
|
rescue Unauthorized
|
|
94
|
-
# No later call can succeed; let it abort the whole run.
|
|
95
79
|
raise
|
|
96
80
|
rescue StandardError => e
|
|
97
81
|
@mutex.synchronize do
|
|
@@ -103,42 +87,33 @@ module RateCard
|
|
|
103
87
|
attempt = 0
|
|
104
88
|
loop do
|
|
105
89
|
body = client.fetch_rates(payload)
|
|
106
|
-
|
|
90
|
+
result = provider.parse_rate_response(body, spec: spec)
|
|
91
|
+
return result unless result[:transient] && attempt < RETRY_BACKOFF.length
|
|
107
92
|
|
|
108
93
|
retry_sleeper.call(RETRY_BACKOFF[attempt])
|
|
109
94
|
attempt += 1
|
|
110
95
|
end
|
|
111
96
|
end
|
|
112
97
|
|
|
113
|
-
|
|
114
|
-
# rather than a real problem with this request.
|
|
115
|
-
def transient_error?(body)
|
|
116
|
-
by_id = index_by_service_id(body)
|
|
117
|
-
spec.services.any? { |service| error_detail(by_id[service.id]) =~ TRANSIENT_ERROR_PATTERN }
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
def record_response(body, weight, zone)
|
|
121
|
-
by_id = index_by_service_id(body)
|
|
122
|
-
|
|
98
|
+
def record_response(result, weight, zone)
|
|
123
99
|
@mutex.synchronize do
|
|
124
|
-
record_warnings(
|
|
100
|
+
record_warnings(result)
|
|
125
101
|
|
|
126
102
|
spec.services.each do |service|
|
|
127
|
-
entry = by_id[service.id]
|
|
128
103
|
spec.rate_keys.each do |rate_key|
|
|
129
|
-
|
|
130
|
-
@cells[[service.id, rate_key, weight, zone]] = coerce(
|
|
104
|
+
raw = result[:service_values].dig(service.id, rate_key)
|
|
105
|
+
@cells[[service.id, rate_key, weight, zone]] = coerce(raw)
|
|
131
106
|
end
|
|
132
107
|
end
|
|
133
108
|
end
|
|
134
109
|
end
|
|
135
110
|
|
|
136
111
|
# Caller holds @mutex.
|
|
137
|
-
def record_warnings(
|
|
138
|
-
|
|
112
|
+
def record_warnings(result)
|
|
113
|
+
result[:warnings].each { |message| @warnings[[message, Warning::ACCOUNT]] += 1 }
|
|
139
114
|
|
|
140
115
|
spec.services.each do |service|
|
|
141
|
-
detail =
|
|
116
|
+
detail = result[:service_errors][service.id]
|
|
142
117
|
next if detail.nil?
|
|
143
118
|
|
|
144
119
|
@warnings[["#{service.name} (#{service.id}): #{detail}", Warning::SERVICE]] += 1
|
|
@@ -149,29 +124,6 @@ module RateCard
|
|
|
149
124
|
warning.account_wide? ? 1 : 0
|
|
150
125
|
end
|
|
151
126
|
|
|
152
|
-
def response_warnings(body)
|
|
153
|
-
return [] unless body.is_a?(Hash)
|
|
154
|
-
|
|
155
|
-
Array(body['warnings']).map(&:to_s).reject { |message| message.strip.empty? }
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
# `errors` is documented as populated when a service could not be rated. It
|
|
159
|
-
# comes back as a string, but an array is accepted so a list of carrier
|
|
160
|
-
# messages reads as one line instead of raising.
|
|
161
|
-
def error_detail(entry)
|
|
162
|
-
raw = entry && entry['errors']
|
|
163
|
-
detail = Array(raw).map(&:to_s).reject { |message| message.strip.empty? }.join('; ')
|
|
164
|
-
detail.empty? ? nil : detail
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
def index_by_service_id(body)
|
|
168
|
-
entries = body.is_a?(Hash) ? (body['service_rates'] || []) : []
|
|
169
|
-
entries.each_with_object({}) do |entry, acc|
|
|
170
|
-
id = entry['service_id']
|
|
171
|
-
acc[id.to_i] = entry unless id.nil?
|
|
172
|
-
end
|
|
173
|
-
end
|
|
174
|
-
|
|
175
127
|
# nil stays nil. Anything numeric becomes a Float. Never defaults to zero.
|
|
176
128
|
def coerce(raw)
|
|
177
129
|
return nil if raw.nil? || raw.to_s.strip.empty?
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RateCard
|
|
4
|
+
# The one thing printed before the credential prompt, so the run says which
|
|
5
|
+
# provider it targets before anything is pasted into it. Selecting among
|
|
6
|
+
# more than one registered provider is not implemented yet, so more than one
|
|
7
|
+
# key raises rather than silently choosing the first.
|
|
8
|
+
module ProviderPrompt
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def read(ui:, keys: Providers.all)
|
|
12
|
+
raise ArgumentError, 'multi-provider selection is not implemented yet' if keys.length > 1
|
|
13
|
+
|
|
14
|
+
key = keys.first
|
|
15
|
+
ui.info("Provider: #{Providers.build(key).label}")
|
|
16
|
+
key
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'faraday'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module RateCard
|
|
7
|
+
module Providers
|
|
8
|
+
module EHub
|
|
9
|
+
# The single network seam. Talks to eHub production and classifies every
|
|
10
|
+
# outcome into: a parsed Hash, Unauthorized, or RequestFailed. Callers never
|
|
11
|
+
# see a Faraday exception.
|
|
12
|
+
#
|
|
13
|
+
# The fetch engine depends on this split: it records a nil cell for each
|
|
14
|
+
# RequestFailed and keeps going, but lets Unauthorized abort the whole run.
|
|
15
|
+
# Narrowing Unauthorized to 403 only would turn a rejected token into a card
|
|
16
|
+
# full of blank cells instead of one clear error — do not do that.
|
|
17
|
+
class Client
|
|
18
|
+
BASE_URL = 'https://api.ehub.com'
|
|
19
|
+
# No trailing slash: that is the documented path, and a trailing slash would
|
|
20
|
+
# rely on a redirect — which can drop the POST body.
|
|
21
|
+
RATES_PATH = '/api/v2/rates'
|
|
22
|
+
SERVICES_PATH = '/api/v2/services'
|
|
23
|
+
# 'ecommerce' services are storefront integrations, not shippable rates.
|
|
24
|
+
SERVICES_CATEGORY = 'shipping'
|
|
25
|
+
|
|
26
|
+
# The rate endpoint answers 201; 200 is accepted too so a change on the
|
|
27
|
+
# server side cannot turn a good response into a hard failure.
|
|
28
|
+
SUCCESS = [200, 201].freeze
|
|
29
|
+
# A rejected token will not improve on retry, so these are never retried.
|
|
30
|
+
# Production answers 403, but 401 stays in the list: if it ever comes back
|
|
31
|
+
# instead, we want one clear error rather than a card full of blank cells.
|
|
32
|
+
UNAUTHORIZED = [401, 403].freeze
|
|
33
|
+
# 429 and 5xx are transient; everything else is a decision, not a hiccup.
|
|
34
|
+
# The gateway codes stay in: 502/503/504 are the load balancer, not the
|
|
35
|
+
# rate engine, and they clear on their own within a backoff or two.
|
|
36
|
+
RETRYABLE = [429, 500, 502, 503, 504].freeze
|
|
37
|
+
BACKOFF = [0.5, 1.0].freeze
|
|
38
|
+
|
|
39
|
+
# stubs: a Faraday::Adapter::Test::Stubs, for specs only.
|
|
40
|
+
# sleeper: injected so retry backoff is testable without waiting.
|
|
41
|
+
def initialize(token:, stubs: nil, sleeper: ->(seconds) { sleep(seconds) })
|
|
42
|
+
@token = token
|
|
43
|
+
@stubs = stubs
|
|
44
|
+
@sleeper = sleeper
|
|
45
|
+
@connection = build_connection
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Returns the parsed response body as a Hash.
|
|
49
|
+
def fetch_rates(payload)
|
|
50
|
+
request('rate call') { post(RATES_PATH, payload) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Returns the parsed body of the service catalogue for this token's customer.
|
|
54
|
+
# Discovery uses this rather than a rate call: it costs nothing, and it lists
|
|
55
|
+
# services that would not have quoted at the single probe weight and zone.
|
|
56
|
+
def fetch_services
|
|
57
|
+
request('service list call') { get(SERVICES_PATH, category: SERVICES_CATEGORY) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# Shared status handling: every call classifies into a Hash, Unauthorized,
|
|
63
|
+
# or RequestFailed, and retries the transient statuses the same way.
|
|
64
|
+
def request(label)
|
|
65
|
+
attempt = 0
|
|
66
|
+
loop do
|
|
67
|
+
status, body = yield
|
|
68
|
+
return body if SUCCESS.include?(status)
|
|
69
|
+
|
|
70
|
+
if UNAUTHORIZED.include?(status)
|
|
71
|
+
raise Unauthorized,
|
|
72
|
+
"production rejected this token (HTTP #{status}). It may be expired, revoked, " \
|
|
73
|
+
'or issued for a different environment — generate a new eHub API token and try again.'
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
unless RETRYABLE.include?(status) && attempt < BACKOFF.length
|
|
77
|
+
raise RequestFailed, "#{label} failed with HTTP #{status}#{error_detail(body)}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
@sleeper.call(BACKOFF[attempt])
|
|
81
|
+
attempt += 1
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def post(path, payload)
|
|
86
|
+
response = @connection.post(path, JSON.generate(payload))
|
|
87
|
+
[response.status, parse(response.body)]
|
|
88
|
+
rescue Faraday::Error => e
|
|
89
|
+
raise RequestFailed, "rate call failed: #{e.message}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def get(path, params)
|
|
93
|
+
response = @connection.get(path, params)
|
|
94
|
+
[response.status, parse(response.body)]
|
|
95
|
+
rescue Faraday::Error => e
|
|
96
|
+
raise RequestFailed, "service list call failed: #{e.message}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def parse(body)
|
|
100
|
+
return body if body.is_a?(Hash)
|
|
101
|
+
|
|
102
|
+
JSON.parse(body.to_s)
|
|
103
|
+
rescue JSON::ParserError
|
|
104
|
+
{}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def error_detail(body)
|
|
108
|
+
message = body.is_a?(Hash) ? (body['error'] || body['message']) : nil
|
|
109
|
+
message ? " (#{message})" : ''
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def build_connection
|
|
113
|
+
Faraday.new(url: BASE_URL) do |f|
|
|
114
|
+
f.headers['Authorization'] = "Bearer #{@token}"
|
|
115
|
+
f.headers['Content-Type'] = 'application/json'
|
|
116
|
+
f.options.timeout = 30
|
|
117
|
+
f.options.open_timeout = 10
|
|
118
|
+
if @stubs
|
|
119
|
+
f.adapter :test, @stubs
|
|
120
|
+
else
|
|
121
|
+
f.adapter Faraday.default_adapter
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|