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.
@@ -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
@@ -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.
@@ -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) { Client.new(token: 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 = ServiceCatalog.group_by_carrier(@services)
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 = ServiceCatalog.from_response(client.fetch_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 ||= Token.decode(@token)
443
+ @identity ||= @provider.identify(@token)
442
444
  end
443
445
 
444
446
  # ----------------------------------------------------------------- view
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RateCard
4
- VERSION = '0.1.0'
4
+ VERSION = '0.1.2'
5
5
  end
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.0
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
@@ -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