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
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RateCard
|
|
4
|
+
module Providers
|
|
5
|
+
module EHub
|
|
6
|
+
# Implements the provider interface documented in
|
|
7
|
+
# docs/superpowers/specs/2026-09-02-provider-abstraction-design.md by
|
|
8
|
+
# delegating to the eHub-specific classes in this directory.
|
|
9
|
+
class Provider
|
|
10
|
+
LABEL = 'eHub'
|
|
11
|
+
|
|
12
|
+
# eHub answers a disguised-success rate-limit hiccup as an HTTP 201 with a
|
|
13
|
+
# per-service errors field describing it, so Client's status-code retry
|
|
14
|
+
# never sees it. Left alone, the exact same request prices a different
|
|
15
|
+
# random set of cells on every run.
|
|
16
|
+
TRANSIENT_ERROR_PATTERN = /too many requests|please try again|slow down/i
|
|
17
|
+
|
|
18
|
+
def label
|
|
19
|
+
LABEL
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def read_credential(ui:, io: $stdin)
|
|
23
|
+
TokenPrompt.read(ui: ui, io: io)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def identify(credential)
|
|
27
|
+
Token.decode(credential)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def client(credential)
|
|
31
|
+
Client.new(token: credential)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def parse_services(body)
|
|
35
|
+
ServiceCatalog.from_response(body)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def build_payload(spec:, weight:, address:)
|
|
39
|
+
Shipment.new(spec: spec, weight: weight, address: address).payload
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Normalizes one /rates response into what Grid needs to record a cell:
|
|
43
|
+
# - transient: a disguised-success rate-limit hiccup that should be retried
|
|
44
|
+
# - warnings: the response-level warnings array (account-wide)
|
|
45
|
+
# - service_errors: { service_id => message } for selected services only
|
|
46
|
+
# - service_values: { service_id => { rate_key => raw_value_or_nil } }
|
|
47
|
+
def parse_rate_response(body, spec:)
|
|
48
|
+
by_id = index_by_service_id(body)
|
|
49
|
+
{
|
|
50
|
+
transient: transient?(spec, by_id),
|
|
51
|
+
warnings: response_warnings(body),
|
|
52
|
+
service_errors: service_errors(spec, by_id),
|
|
53
|
+
service_values: service_values(spec, by_id)
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def transient?(spec, by_id)
|
|
60
|
+
spec.services.any? { |service| error_detail(by_id[service.id]) =~ TRANSIENT_ERROR_PATTERN }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def service_errors(spec, by_id)
|
|
64
|
+
spec.services.each_with_object({}) do |service, acc|
|
|
65
|
+
detail = error_detail(by_id[service.id])
|
|
66
|
+
acc[service.id] = detail if detail
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# RunSpec::RATE_KEY_FIELDS stays top-level rather than provider-owned (see
|
|
71
|
+
# its own comment), but the field names it maps to ('rate', 'meter_rate')
|
|
72
|
+
# are an eHub API fact — a provider whose response uses different field
|
|
73
|
+
# names would need RATE_KEY_FIELDS to become provider-specific too.
|
|
74
|
+
def service_values(spec, by_id)
|
|
75
|
+
spec.services.each_with_object({}) do |service, acc|
|
|
76
|
+
entry = by_id[service.id]
|
|
77
|
+
acc[service.id] = spec.rate_keys.each_with_object({}) do |rate_key, values|
|
|
78
|
+
field = RunSpec::RATE_KEY_FIELDS.fetch(rate_key)
|
|
79
|
+
values[rate_key] = entry && entry[field]
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def response_warnings(body)
|
|
85
|
+
return [] unless body.is_a?(Hash)
|
|
86
|
+
|
|
87
|
+
Array(body['warnings']).map(&:to_s).reject { |message| message.strip.empty? }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# `errors` is documented as populated when a service could not be rated. It
|
|
91
|
+
# comes back as a string, but an array is accepted so a list of carrier
|
|
92
|
+
# messages reads as one line instead of raising.
|
|
93
|
+
def error_detail(entry)
|
|
94
|
+
raw = entry && entry['errors']
|
|
95
|
+
detail = Array(raw).map(&:to_s).reject { |message| message.strip.empty? }.join('; ')
|
|
96
|
+
detail.empty? ? nil : detail
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def index_by_service_id(body)
|
|
100
|
+
entries = body.is_a?(Hash) ? (body['service_rates'] || []) : []
|
|
101
|
+
entries.each_with_object({}) do |entry, acc|
|
|
102
|
+
id = entry['service_id']
|
|
103
|
+
acc[id.to_i] = entry unless id.nil?
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RateCard
|
|
4
|
+
module Providers
|
|
5
|
+
module EHub
|
|
6
|
+
# Turns the /services response into the list of services the wizard can offer.
|
|
7
|
+
# Read live rather than from a hardcoded list so a service id that has drifted
|
|
8
|
+
# cannot silently produce a card of blank cells.
|
|
9
|
+
module ServiceCatalog
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Returns Array<Service>, de-duplicated and sorted for display.
|
|
13
|
+
def from_response(body)
|
|
14
|
+
entries = body.is_a?(Hash) ? (body['services'] || []) : []
|
|
15
|
+
|
|
16
|
+
services = entries.filter_map { |entry| build_service(entry) }.uniq(&:id)
|
|
17
|
+
sort_for_display(services)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def build_service(entry)
|
|
21
|
+
id = entry['service_id']
|
|
22
|
+
return nil if id.nil?
|
|
23
|
+
|
|
24
|
+
code = entry['service_code'].to_s
|
|
25
|
+
name = entry['service'].to_s
|
|
26
|
+
name = code if name.strip.empty?
|
|
27
|
+
|
|
28
|
+
Service.new(
|
|
29
|
+
id: id.to_i,
|
|
30
|
+
code: code,
|
|
31
|
+
name: name,
|
|
32
|
+
carrier: Constants::Carriers.for_carrier_code(entry['carrier_code']),
|
|
33
|
+
package_types: package_types(entry)
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# services[].package_types is an array of {type, name}; only the type is
|
|
38
|
+
# sent back in a rate request.
|
|
39
|
+
def package_types(entry)
|
|
40
|
+
Array(entry['package_types']).filter_map do |package_type|
|
|
41
|
+
next package_type.to_s unless package_type.is_a?(Hash)
|
|
42
|
+
|
|
43
|
+
type = package_type['type'].to_s
|
|
44
|
+
type.empty? ? nil : type
|
|
45
|
+
end.uniq
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def sort_for_display(services)
|
|
49
|
+
services.sort_by { |service| [carrier_rank(service.carrier), service.name.to_s] }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def carrier_rank(carrier)
|
|
53
|
+
index = Constants::Carriers.display_order.index(carrier)
|
|
54
|
+
index || Constants::Carriers.display_order.length
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RateCard
|
|
4
|
+
module Providers
|
|
5
|
+
module EHub
|
|
6
|
+
# Builds the request body for one (weight, zone) rate call.
|
|
7
|
+
#
|
|
8
|
+
# The shape follows the documented rate request
|
|
9
|
+
# (https://docs.ehub.com/rates/rate-request). The customs blocks are sent even
|
|
10
|
+
# for domestic cards because international services error without them; the
|
|
11
|
+
# docs mark the parcel-level one as required for international shipments.
|
|
12
|
+
class Shipment
|
|
13
|
+
DIMENSION = 2
|
|
14
|
+
ITEM_VALUE = 18.99
|
|
15
|
+
HS_TARIFF_CODE = '1704.90.3000'
|
|
16
|
+
|
|
17
|
+
def initialize(spec:, weight:, address:)
|
|
18
|
+
@spec = spec
|
|
19
|
+
@weight = weight
|
|
20
|
+
@address = address
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def payload
|
|
24
|
+
{
|
|
25
|
+
shipment: {
|
|
26
|
+
from_location: Constants::Addresses::ORIGIN,
|
|
27
|
+
to_location: to_location,
|
|
28
|
+
parcels: [parcel]
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
attr_reader :spec, :weight, :address
|
|
36
|
+
|
|
37
|
+
def to_location
|
|
38
|
+
{
|
|
39
|
+
company: 'Rate Card Builder',
|
|
40
|
+
phone: '000-000-0000'
|
|
41
|
+
}.merge(address)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def parcel
|
|
45
|
+
box = dims
|
|
46
|
+
{
|
|
47
|
+
package_type: spec.package_type,
|
|
48
|
+
length: box[:length],
|
|
49
|
+
width: box[:width],
|
|
50
|
+
height: box[:height],
|
|
51
|
+
weight: spec.weight_in_oz_for(weight),
|
|
52
|
+
parcel_items: [parcel_item],
|
|
53
|
+
customs_data: customs_data
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def dims
|
|
58
|
+
spec.dims_for(weight) || { length: DIMENSION, width: DIMENSION, height: DIMENSION }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The parcel as a whole. One item at quantity 1, so this mirrors the item's
|
|
62
|
+
# declaration; the docs require it for international shipments.
|
|
63
|
+
def customs_data
|
|
64
|
+
{
|
|
65
|
+
content_type: 'merchandise',
|
|
66
|
+
value: ITEM_VALUE,
|
|
67
|
+
hs_tariff_code: HS_TARIFF_CODE
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def parcel_item
|
|
72
|
+
{
|
|
73
|
+
name: 'Candy',
|
|
74
|
+
quantity: 1,
|
|
75
|
+
price: ITEM_VALUE,
|
|
76
|
+
customs_data: customs_data
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module RateCard
|
|
7
|
+
module Providers
|
|
8
|
+
module EHub
|
|
9
|
+
# Reads claims out of an eHub JWT so we can show the user which customer a
|
|
10
|
+
# pasted token belongs to. We decode so the user can confirm they are pointed at
|
|
11
|
+
# the intended production account before a run starts. Deliberately does NOT
|
|
12
|
+
# verify the signature: these claims are displayed, never trusted for
|
|
13
|
+
# authorization. The API's 401 is the only real check.
|
|
14
|
+
module Token
|
|
15
|
+
class DecodeError < Error; end
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def decode(raw)
|
|
20
|
+
payload = payload_from(raw)
|
|
21
|
+
user = payload.dig('data', 'user') || {}
|
|
22
|
+
|
|
23
|
+
customer_id = user['customer_id'] if user['customer_id'].is_a?(Integer)
|
|
24
|
+
email = presence(user['email'])
|
|
25
|
+
# FIXME - I don't even think we need a name field here tbh
|
|
26
|
+
name = email || (customer_id ? "customer #{customer_id}" : 'unknown customer')
|
|
27
|
+
|
|
28
|
+
{ name: name, customer_id: customer_id, email: email }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def presence(value)
|
|
32
|
+
string = value.to_s.strip
|
|
33
|
+
string.empty? ? nil : string
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def payload_from(raw)
|
|
37
|
+
segments = raw.to_s.split('.')
|
|
38
|
+
raise DecodeError, 'token is not a JWT (expected three segments)' unless segments.length == 3
|
|
39
|
+
|
|
40
|
+
# urlsafe_decode64 restores missing '=' padding itself, which matters
|
|
41
|
+
# because real JWT segments are always unpadded.
|
|
42
|
+
parsed = JSON.parse(Base64.urlsafe_decode64(segments[1]))
|
|
43
|
+
raise DecodeError, 'token payload is not a JSON object' unless parsed.is_a?(Hash)
|
|
44
|
+
|
|
45
|
+
parsed
|
|
46
|
+
rescue ArgumentError, JSON::ParserError
|
|
47
|
+
raise DecodeError, 'this does not look like an eHub API token — please try again'
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'io/console'
|
|
4
|
+
|
|
5
|
+
module RateCard
|
|
6
|
+
module Providers
|
|
7
|
+
module EHub
|
|
8
|
+
# Reads the API token before the TUI takes the terminal.
|
|
9
|
+
#
|
|
10
|
+
# This is deliberately NOT a TUI field. bubbletea-ruby's input reader returns
|
|
11
|
+
# one key per poll and discards the rest of the buffered bytes, so a pasted
|
|
12
|
+
# 200-character JWT arrives as a single character and the other 199 are lost
|
|
13
|
+
# — verified against 0.1.4, and bracketed_paste does not help. Typing is
|
|
14
|
+
# unaffected at any speed, so every other answer is safe inside the loop; the
|
|
15
|
+
# token is the one field that is always pasted.
|
|
16
|
+
#
|
|
17
|
+
# Reading it here also means the terminal's own line editing handles the
|
|
18
|
+
# paste, which is what makes it work.
|
|
19
|
+
module TokenPrompt
|
|
20
|
+
# DEC private mode reset for bracketed paste. A crashed or Ctrl-C'd
|
|
21
|
+
# prior run can leave the TUI's bracketed paste mode on in the
|
|
22
|
+
# terminal (iTerm2 persists terminal modes across process launches
|
|
23
|
+
# within a pane, which is where this has actually been observed); the
|
|
24
|
+
# next plain `gets` here then reads the paste's literal \e[200~ /
|
|
25
|
+
# \e[201~ wrapper as part of the token. Harmless on a terminal that
|
|
26
|
+
# never turned bracketed paste on.
|
|
27
|
+
BRACKETED_PASTE_OFF = "\e[?2004l"
|
|
28
|
+
PASTE_START = "\e[200~"
|
|
29
|
+
PASTE_END = "\e[201~"
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
def read(ui:, io: $stdin)
|
|
34
|
+
loop do
|
|
35
|
+
$stdout.print(BRACKETED_PASTE_OFF) if io.tty?
|
|
36
|
+
ui.prompt('eHub API token: ')
|
|
37
|
+
raw = read_masked(io)
|
|
38
|
+
return nil if raw.nil?
|
|
39
|
+
|
|
40
|
+
raw = strip_paste_markers(raw).strip
|
|
41
|
+
if raw.empty?
|
|
42
|
+
ui.error('no token on that line — paste the token')
|
|
43
|
+
next
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
begin
|
|
47
|
+
Token.decode(raw)
|
|
48
|
+
return raw
|
|
49
|
+
rescue Token::DecodeError => e
|
|
50
|
+
ui.error("#{e.message} — paste the token again")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def read_masked(io)
|
|
56
|
+
if io.respond_to?(:noecho) && io.tty?
|
|
57
|
+
value = io.noecho(&:gets)
|
|
58
|
+
puts
|
|
59
|
+
value
|
|
60
|
+
else
|
|
61
|
+
io.gets
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def strip_paste_markers(raw)
|
|
66
|
+
raw.delete_prefix(PASTE_START).delete_suffix("#{PASTE_END}\n").delete_suffix(PASTE_END)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RateCard
|
|
4
|
+
# Maps a provider key to the object that builds it. Adding a provider later
|
|
5
|
+
# means adding one entry here plus its providers/<name>/ implementation —
|
|
6
|
+
# nothing else here changes.
|
|
7
|
+
module Providers
|
|
8
|
+
REGISTRY = {
|
|
9
|
+
ehub: -> { EHub::Provider.new }
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def all
|
|
15
|
+
REGISTRY.keys
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def build(key)
|
|
19
|
+
REGISTRY.fetch(key) { raise ArgumentError, "unknown provider: #{key}" }.call
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
data/lib/rate_card/service.rb
CHANGED
|
@@ -3,6 +3,20 @@
|
|
|
3
3
|
module RateCard
|
|
4
4
|
# One shipping service the customer has enabled, as discovered from a probe call.
|
|
5
5
|
Service = Struct.new(:id, :code, :name, :carrier, :package_types, keyword_init: true) do
|
|
6
|
+
# Returns { carrier => Array<Service> } with carriers in display order.
|
|
7
|
+
# Provider-agnostic: any provider's #parse_services returns these same
|
|
8
|
+
# structs, so grouping them for display doesn't belong to any one provider.
|
|
9
|
+
def self.group_by_carrier(services)
|
|
10
|
+
services.group_by(&:carrier)
|
|
11
|
+
.sort_by { |carrier, _| carrier_rank(carrier) }
|
|
12
|
+
.to_h
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.carrier_rank(carrier)
|
|
16
|
+
index = Constants::Carriers.display_order.index(carrier)
|
|
17
|
+
index || Constants::Carriers.display_order.length
|
|
18
|
+
end
|
|
19
|
+
|
|
6
20
|
# The package types this service accepts, from services[].package_types.
|
|
7
21
|
# Always an array: callers offer these as choices, and a nil would have to
|
|
8
22
|
# be guarded at every one of them.
|
data/lib/rate_card/tui/app.rb
CHANGED
|
@@ -34,10 +34,11 @@ module RateCard
|
|
|
34
34
|
# the only way a worker thread can get a message onto the event loop.
|
|
35
35
|
attr_writer :notifier
|
|
36
36
|
|
|
37
|
-
def initialize(token:, output_base:,
|
|
38
|
-
client_factory: ->(tok) {
|
|
37
|
+
def initialize(token:, output_base:, provider:,
|
|
38
|
+
client_factory: ->(tok) { provider.client(tok) })
|
|
39
39
|
@token = token
|
|
40
40
|
@output_base = Pathname.new(output_base)
|
|
41
|
+
@provider = provider
|
|
41
42
|
@client_factory = client_factory
|
|
42
43
|
|
|
43
44
|
@stage = :loading
|
|
@@ -220,7 +221,7 @@ module RateCard
|
|
|
220
221
|
# chart is dropped from the menu rather than offered and then refused
|
|
221
222
|
# mid-run by Addresses.for_carrier.
|
|
222
223
|
def selectable_carriers
|
|
223
|
-
carriers =
|
|
224
|
+
carriers = Service.group_by_carrier(@services)
|
|
224
225
|
.keys
|
|
225
226
|
.select { |carrier| Constants::Addresses.supported?(carrier) }
|
|
226
227
|
return carriers & ['USPS'] if @answers[:rate_mode] == :cubic
|
|
@@ -339,7 +340,7 @@ module RateCard
|
|
|
339
340
|
def start_loading
|
|
340
341
|
client = @client_factory.call(@token)
|
|
341
342
|
lambda do
|
|
342
|
-
services =
|
|
343
|
+
services = @provider.parse_services(client.fetch_services)
|
|
343
344
|
if services.empty?
|
|
344
345
|
LoadFailed.new(NoServices.new('the API returned no services for this token'))
|
|
345
346
|
elsif (rateable = with_supported_carrier(services)).empty?
|
|
@@ -384,12 +385,13 @@ module RateCard
|
|
|
384
385
|
client = @client_factory.call(@token)
|
|
385
386
|
notifier = @notifier
|
|
386
387
|
spec = @spec
|
|
388
|
+
provider = @provider
|
|
387
389
|
|
|
388
390
|
lambda do
|
|
389
391
|
completed = 0
|
|
390
392
|
failed = 0
|
|
391
393
|
mutex = Mutex.new
|
|
392
|
-
grid = Grid.build(spec: spec, client: client, on_progress: lambda {
|
|
394
|
+
grid = Grid.build(spec: spec, client: client, provider: provider, on_progress: lambda {
|
|
393
395
|
mutex.synchronize { completed += 1 }
|
|
394
396
|
notifier&.send(ProgressAdvanced.new(completed: completed, failed: failed))
|
|
395
397
|
})
|
|
@@ -438,7 +440,7 @@ module RateCard
|
|
|
438
440
|
end
|
|
439
441
|
|
|
440
442
|
def identity
|
|
441
|
-
@identity ||=
|
|
443
|
+
@identity ||= @provider.identify(@token)
|
|
442
444
|
end
|
|
443
445
|
|
|
444
446
|
# ----------------------------------------------------------------- view
|
data/lib/rate_card/version.rb
CHANGED
data/lib/rate_card.rb
CHANGED
|
@@ -25,18 +25,21 @@ module RateCard
|
|
|
25
25
|
class UnsupportedCarrier < Error; end
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
require_relative 'rate_card/token'
|
|
29
|
-
require_relative 'rate_card/token_prompt'
|
|
28
|
+
require_relative 'rate_card/providers/ehub/token'
|
|
29
|
+
require_relative 'rate_card/providers/ehub/token_prompt'
|
|
30
30
|
require_relative 'rate_card/service'
|
|
31
31
|
require_relative 'rate_card/failure'
|
|
32
32
|
require_relative 'rate_card/constants/carriers'
|
|
33
33
|
require_relative 'rate_card/constants/addresses'
|
|
34
34
|
require_relative 'rate_card/constants/cubic_tiers'
|
|
35
|
-
require_relative 'rate_card/client'
|
|
36
|
-
require_relative 'rate_card/service_catalog'
|
|
35
|
+
require_relative 'rate_card/providers/ehub/client'
|
|
36
|
+
require_relative 'rate_card/providers/ehub/service_catalog'
|
|
37
37
|
require_relative 'rate_card/input'
|
|
38
38
|
require_relative 'rate_card/run_spec'
|
|
39
|
-
require_relative 'rate_card/shipment'
|
|
39
|
+
require_relative 'rate_card/providers/ehub/shipment'
|
|
40
|
+
require_relative 'rate_card/providers/ehub/provider'
|
|
41
|
+
require_relative 'rate_card/providers'
|
|
42
|
+
require_relative 'rate_card/provider_prompt'
|
|
40
43
|
require_relative 'rate_card/warning'
|
|
41
44
|
require_relative 'rate_card/grid'
|
|
42
45
|
require_relative 'rate_card/csv_writer'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rate-card
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- eHub
|
|
@@ -136,7 +136,6 @@ files:
|
|
|
136
136
|
- README.md
|
|
137
137
|
- exe/rate-card
|
|
138
138
|
- lib/rate_card.rb
|
|
139
|
-
- lib/rate_card/client.rb
|
|
140
139
|
- lib/rate_card/constants/addresses.rb
|
|
141
140
|
- lib/rate_card/constants/carriers.rb
|
|
142
141
|
- lib/rate_card/constants/cubic_tiers.rb
|
|
@@ -144,14 +143,18 @@ files:
|
|
|
144
143
|
- lib/rate_card/failure.rb
|
|
145
144
|
- lib/rate_card/grid.rb
|
|
146
145
|
- lib/rate_card/input.rb
|
|
146
|
+
- lib/rate_card/provider_prompt.rb
|
|
147
|
+
- lib/rate_card/providers.rb
|
|
148
|
+
- lib/rate_card/providers/ehub/client.rb
|
|
149
|
+
- lib/rate_card/providers/ehub/provider.rb
|
|
150
|
+
- lib/rate_card/providers/ehub/service_catalog.rb
|
|
151
|
+
- lib/rate_card/providers/ehub/shipment.rb
|
|
152
|
+
- lib/rate_card/providers/ehub/token.rb
|
|
153
|
+
- lib/rate_card/providers/ehub/token_prompt.rb
|
|
147
154
|
- lib/rate_card/run_spec.rb
|
|
148
155
|
- lib/rate_card/runner.rb
|
|
149
156
|
- lib/rate_card/service.rb
|
|
150
|
-
- lib/rate_card/service_catalog.rb
|
|
151
|
-
- lib/rate_card/shipment.rb
|
|
152
157
|
- lib/rate_card/table_renderer.rb
|
|
153
|
-
- lib/rate_card/token.rb
|
|
154
|
-
- lib/rate_card/token_prompt.rb
|
|
155
158
|
- lib/rate_card/tui/app.rb
|
|
156
159
|
- lib/rate_card/tui/fields/multi_select.rb
|
|
157
160
|
- lib/rate_card/tui/fields/select.rb
|
data/lib/rate_card/client.rb
DELETED
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'faraday'
|
|
4
|
-
require 'json'
|
|
5
|
-
|
|
6
|
-
module RateCard
|
|
7
|
-
# The single network seam. Talks to eHub production and classifies every
|
|
8
|
-
# outcome into: a parsed Hash, Unauthorized, or RequestFailed. Callers never
|
|
9
|
-
# see a Faraday exception.
|
|
10
|
-
#
|
|
11
|
-
# The fetch engine depends on this split: it records a nil cell for each
|
|
12
|
-
# RequestFailed and keeps going, but lets Unauthorized abort the whole run.
|
|
13
|
-
# Narrowing Unauthorized to 403 only would turn a rejected token into a card
|
|
14
|
-
# full of blank cells instead of one clear error — do not do that.
|
|
15
|
-
class Client
|
|
16
|
-
BASE_URL = 'https://api.ehub.com'
|
|
17
|
-
# No trailing slash: that is the documented path, and a trailing slash would
|
|
18
|
-
# rely on a redirect — which can drop the POST body.
|
|
19
|
-
RATES_PATH = '/api/v2/rates'
|
|
20
|
-
SERVICES_PATH = '/api/v2/services'
|
|
21
|
-
# 'ecommerce' services are storefront integrations, not shippable rates.
|
|
22
|
-
SERVICES_CATEGORY = 'shipping'
|
|
23
|
-
|
|
24
|
-
# The rate endpoint answers 201; 200 is accepted too so a change on the
|
|
25
|
-
# server side cannot turn a good response into a hard failure.
|
|
26
|
-
SUCCESS = [200, 201].freeze
|
|
27
|
-
# A rejected token will not improve on retry, so these are never retried.
|
|
28
|
-
# Production answers 403, but 401 stays in the list: if it ever comes back
|
|
29
|
-
# instead, we want one clear error rather than a card full of blank cells.
|
|
30
|
-
UNAUTHORIZED = [401, 403].freeze
|
|
31
|
-
# 429 and 5xx are transient; everything else is a decision, not a hiccup.
|
|
32
|
-
# The gateway codes stay in: 502/503/504 are the load balancer, not the
|
|
33
|
-
# rate engine, and they clear on their own within a backoff or two.
|
|
34
|
-
RETRYABLE = [429, 500, 502, 503, 504].freeze
|
|
35
|
-
BACKOFF = [0.5, 1.0].freeze
|
|
36
|
-
|
|
37
|
-
# stubs: a Faraday::Adapter::Test::Stubs, for specs only.
|
|
38
|
-
# sleeper: injected so retry backoff is testable without waiting.
|
|
39
|
-
def initialize(token:, stubs: nil, sleeper: ->(seconds) { sleep(seconds) })
|
|
40
|
-
@token = token
|
|
41
|
-
@stubs = stubs
|
|
42
|
-
@sleeper = sleeper
|
|
43
|
-
@connection = build_connection
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
# Returns the parsed response body as a Hash.
|
|
47
|
-
def fetch_rates(payload)
|
|
48
|
-
request('rate call') { post(RATES_PATH, payload) }
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
# Returns the parsed body of the service catalogue for this token's customer.
|
|
52
|
-
# Discovery uses this rather than a rate call: it costs nothing, and it lists
|
|
53
|
-
# services that would not have quoted at the single probe weight and zone.
|
|
54
|
-
def fetch_services
|
|
55
|
-
request('service list call') { get(SERVICES_PATH, category: SERVICES_CATEGORY) }
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
private
|
|
59
|
-
|
|
60
|
-
# Shared status handling: every call classifies into a Hash, Unauthorized,
|
|
61
|
-
# or RequestFailed, and retries the transient statuses the same way.
|
|
62
|
-
def request(label)
|
|
63
|
-
attempt = 0
|
|
64
|
-
loop do
|
|
65
|
-
status, body = yield
|
|
66
|
-
return body if SUCCESS.include?(status)
|
|
67
|
-
|
|
68
|
-
if UNAUTHORIZED.include?(status)
|
|
69
|
-
raise Unauthorized,
|
|
70
|
-
"production rejected this token (HTTP #{status}). It may be expired, revoked, " \
|
|
71
|
-
'or issued for a different environment — generate a new eHub API token and try again.'
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
unless RETRYABLE.include?(status) && attempt < BACKOFF.length
|
|
75
|
-
raise RequestFailed, "#{label} failed with HTTP #{status}#{error_detail(body)}"
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
@sleeper.call(BACKOFF[attempt])
|
|
79
|
-
attempt += 1
|
|
80
|
-
end
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def post(path, payload)
|
|
84
|
-
response = @connection.post(path, JSON.generate(payload))
|
|
85
|
-
[response.status, parse(response.body)]
|
|
86
|
-
rescue Faraday::Error => e
|
|
87
|
-
raise RequestFailed, "rate call failed: #{e.message}"
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
def get(path, params)
|
|
91
|
-
response = @connection.get(path, params)
|
|
92
|
-
[response.status, parse(response.body)]
|
|
93
|
-
rescue Faraday::Error => e
|
|
94
|
-
raise RequestFailed, "service list call failed: #{e.message}"
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
def parse(body)
|
|
98
|
-
return body if body.is_a?(Hash)
|
|
99
|
-
|
|
100
|
-
JSON.parse(body.to_s)
|
|
101
|
-
rescue JSON::ParserError
|
|
102
|
-
{}
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
def error_detail(body)
|
|
106
|
-
message = body.is_a?(Hash) ? (body['error'] || body['message']) : nil
|
|
107
|
-
message ? " (#{message})" : ''
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
def build_connection
|
|
111
|
-
Faraday.new(url: BASE_URL) do |f|
|
|
112
|
-
f.headers['Authorization'] = "Bearer #{@token}"
|
|
113
|
-
f.headers['Content-Type'] = 'application/json'
|
|
114
|
-
f.options.timeout = 30
|
|
115
|
-
f.options.open_timeout = 10
|
|
116
|
-
if @stubs
|
|
117
|
-
f.adapter :test, @stubs
|
|
118
|
-
else
|
|
119
|
-
f.adapter Faraday.default_adapter
|
|
120
|
-
end
|
|
121
|
-
end
|
|
122
|
-
end
|
|
123
|
-
end
|
|
124
|
-
end
|