flexops 1.0.2 → 2.0.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.
data/lib/flexops/http.rb CHANGED
@@ -9,13 +9,24 @@
9
9
  require "net/http"
10
10
  require "uri"
11
11
  require "json"
12
+ require "securerandom"
12
13
 
13
14
  module FlexOps
14
15
  class HttpClient
15
16
  DEFAULT_BASE_URL = "https://gateway.flexops.io"
16
17
  DEFAULT_TIMEOUT = 30
17
18
  MAX_RETRIES = 3
19
+ # Statuses a GET is retried on.
18
20
  RETRYABLE_STATUSES = [429, 500, 502, 503, 504].freeze
21
+ # Statuses where a keyed write either never ran or is replayed by the Gateway. Never 500:
22
+ # the Gateway does not cache a 5xx response, so a retry would run the write again.
23
+ WRITE_RETRYABLE_STATUSES = [429, 502, 503, 504].freeze
24
+ WRITE_METHODS = %i[post put patch].freeze
25
+ IDEMPOTENCY_KEY_HEADER = "Idempotency-Key"
26
+ # The Gateway's 409 while the first request with the same Idempotency-Key is still running.
27
+ IDEMPOTENCY_CONFLICT = "IDEMPOTENCY_CONFLICT"
28
+
29
+ attr_reader :access_token
19
30
 
20
31
  def initialize(base_url: DEFAULT_BASE_URL, api_key: nil, access_token: nil, timeout: DEFAULT_TIMEOUT)
21
32
  @base_url = base_url.chomp("/")
@@ -34,30 +45,33 @@ module FlexOps
34
45
  @access_token = nil
35
46
  end
36
47
 
37
- def get(path, query: nil)
38
- request(:get, path, query: query)
48
+ def get(path, query: nil, headers: {})
49
+ request(:get, path, query: query, headers: headers)
39
50
  end
40
51
 
41
- def post(path, body: nil, query: nil)
42
- request(:post, path, body: body, query: query)
52
+ def post(path, body: nil, query: nil, headers: {})
53
+ request(:post, path, body: body, query: query, headers: headers)
43
54
  end
44
55
 
45
- def put(path, body: nil)
46
- request(:put, path, body: body)
56
+ def put(path, body: nil, headers: {})
57
+ request(:put, path, body: body, headers: headers)
47
58
  end
48
59
 
49
- def patch(path, body: nil)
50
- request(:patch, path, body: body)
60
+ def patch(path, body: nil, headers: {})
61
+ request(:patch, path, body: body, headers: headers)
51
62
  end
52
63
 
53
- def delete(path)
54
- request(:delete, path)
64
+ def delete(path, headers: {})
65
+ request(:delete, path, headers: headers)
55
66
  end
56
67
 
57
68
  private
58
69
 
59
- def request(method, path, body: nil, query: nil)
70
+ def request(method, path, body: nil, query: nil, headers: {})
60
71
  uri = build_uri(path, query)
72
+ # Every write carries one key for all of its attempts, so the Gateway replays a write it
73
+ # already finished instead of running it (and charging the wallet) again.
74
+ headers = with_idempotency_key(method, headers)
61
75
  last_error = nil
62
76
 
63
77
  (0..MAX_RETRIES).each do |attempt|
@@ -66,57 +80,71 @@ module FlexOps
66
80
  end
67
81
 
68
82
  req = build_request(method, uri, body)
83
+ headers.each { |name, value| req[name] = value }
69
84
  begin
70
85
  response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
71
86
  open_timeout: @timeout, read_timeout: @timeout) do |http|
72
87
  http.request(req)
73
88
  end
89
+ rescue StandardError => e
90
+ # The Gateway may have run a timed-out or dropped request. GETs and keyed writes are
91
+ # safe to send again; a DELETE is not covered by the idempotency middleware.
92
+ raise if method == :delete
74
93
 
75
- status = response.code.to_i
94
+ last_error = e
95
+ next
96
+ end
76
97
 
77
- if status >= 200 && status < 300
78
- return parse_response(response)
79
- end
98
+ status = response.code.to_i
80
99
 
81
- if status == 401
82
- raise AuthError
83
- end
100
+ if status >= 200 && status < 300
101
+ return parse_response(response)
102
+ end
84
103
 
85
- if status == 403
86
- raise Error.new("Access denied. Check your plan tier and feature entitlements.", status: 403, code: "FORBIDDEN")
87
- end
104
+ if status == 401
105
+ raise AuthError
106
+ end
88
107
 
89
- if status == 429
90
- retry_after = (response["retry-after"] || "0").to_i
91
- last_error = RateLimitError.new(retry_after: retry_after)
92
- next if RETRYABLE_STATUSES.include?(429)
93
- raise last_error
94
- end
108
+ # Rate limited: the request was refused before it ran, so every method retries.
109
+ if status == 429
110
+ retry_after = (response["retry-after"] || "0").to_i
111
+ last_error = RateLimitError.new(retry_after: retry_after)
112
+ next
113
+ end
95
114
 
96
- error_body = parse_error_body(response)
97
- error = Error.new(
98
- error_body["message"] || "HTTP #{status}: #{response.message}",
99
- status: status,
100
- errors: error_body["errors"]
101
- )
115
+ error_body = parse_error_body(response)
116
+ error = Error.new(
117
+ error_body["message"] || "HTTP #{status}: #{response.message}",
118
+ status: status,
119
+ code: error_body["errorCode"] || error_body["code"] || (status == 403 ? "FORBIDDEN" : nil),
120
+ errors: error_body["errors"]
121
+ )
102
122
 
103
- if RETRYABLE_STATUSES.include?(status)
104
- last_error = error
105
- next
106
- end
123
+ raise error unless retryable?(method, status, error.code)
107
124
 
108
- raise error
109
- rescue Error
110
- raise
111
- rescue StandardError => e
112
- last_error = e
113
- next if attempt < MAX_RETRIES
114
- end
125
+ last_error = error
115
126
  end
116
127
 
117
128
  raise last_error || Error.new("Request failed after retries", code: "RETRY_EXHAUSTED")
118
129
  end
119
130
 
131
+ # GETs retry on every RETRYABLE_STATUSES status. A write retries only where the Gateway either
132
+ # did not run it or will replay it for the same Idempotency-Key. A DELETE retries only on 429.
133
+ def retryable?(method, status, code)
134
+ case method
135
+ when :get then RETRYABLE_STATUSES.include?(status)
136
+ when :delete then false
137
+ else status == 409 ? code == IDEMPOTENCY_CONFLICT : WRITE_RETRYABLE_STATUSES.include?(status)
138
+ end
139
+ end
140
+
141
+ def with_idempotency_key(method, headers)
142
+ return headers unless WRITE_METHODS.include?(method)
143
+
144
+ given = headers.any? { |name, value| name.to_s.casecmp?(IDEMPOTENCY_KEY_HEADER) && !value.to_s.empty? }
145
+ given ? headers : headers.merge(IDEMPOTENCY_KEY_HEADER => SecureRandom.uuid)
146
+ end
147
+
120
148
  def build_uri(path, query)
121
149
  url = "#{@base_url}#{path.start_with?("/") ? path : "/#{path}"}"
122
150
  uri = URI.parse(url)
@@ -150,7 +178,11 @@ module FlexOps
150
178
  req
151
179
  end
152
180
 
181
+ # A 2xx with no body (204, or Content-Length 0) returns nil; JSON is parsed; anything else,
182
+ # such as a label file, is returned as the raw body string.
153
183
  def parse_response(response)
184
+ return nil if response.code.to_i == 204 || response["content-length"] == "0" || response.body.nil? || response.body.empty?
185
+
154
186
  content_type = response["content-type"] || ""
155
187
  if content_type.include?("application/json")
156
188
  JSON.parse(response.body)
@@ -160,7 +192,8 @@ module FlexOps
160
192
  end
161
193
 
162
194
  def parse_error_body(response)
163
- JSON.parse(response.body)
195
+ parsed = JSON.parse(response.body)
196
+ parsed.is_a?(Hash) ? parsed : {}
164
197
  rescue StandardError
165
198
  {}
166
199
  end
@@ -8,6 +8,8 @@
8
8
 
9
9
  module FlexOps
10
10
  module Resources
11
+ # Token acquisition for operations that need a signed-in user. Account management
12
+ # (registration, profile, passwords) belongs to the FlexOps portals.
11
13
  class Auth
12
14
  def initialize(http)
13
15
  @http = http
@@ -17,41 +19,18 @@ module FlexOps
17
19
  @http.post("/api/Account/login", body: { email: email, password: password })
18
20
  end
19
21
 
20
- def register(request)
21
- @http.post("/api/Account/register", body: request)
22
- end
22
+ # The Gateway RefreshTokenRequest requires the expired access token as well as the refresh
23
+ # token; it defaults to the client's current access token.
24
+ def refresh_token(refresh_token, access_token: nil)
25
+ token = access_token || @http.access_token
26
+ raise ArgumentError, "access_token is required to refresh a session" if token.to_s.empty?
23
27
 
24
- def refresh_token(refresh_token)
25
- @http.post("/api/Account/refresh-token", body: { refreshToken: refresh_token })
28
+ @http.post("/api/Account/refresh-token", body: { token: token, refreshToken: refresh_token })
26
29
  end
27
30
 
28
31
  def logout
29
32
  @http.post("/api/Account/logout")
30
33
  end
31
-
32
- def get_profile
33
- @http.get("/api/Account/profile")
34
- end
35
-
36
- def update_profile(data)
37
- @http.put("/api/Account/profile", body: data)
38
- end
39
-
40
- def change_password(current_password:, new_password:)
41
- @http.post("/api/Account/change-password", body: { currentPassword: current_password, newPassword: new_password })
42
- end
43
-
44
- def forgot_password(email:)
45
- @http.post("/api/Account/forgot-password", body: { email: email })
46
- end
47
-
48
- def reset_password(token:, new_password:)
49
- @http.post("/api/Account/reset-password", body: { token: token, newPassword: new_password })
50
- end
51
-
52
- def verify_email(token:)
53
- @http.post("/api/Account/verify-email", body: { token: token })
54
- end
55
34
  end
56
35
  end
57
36
  end
@@ -0,0 +1,60 @@
1
+ # ***********************************************************************
2
+ # Package : flexops
3
+ # Author : FlexOps, LLC
4
+ # Created : 2026-10-19
5
+ #
6
+ # Copyright (c) 2021-2026 by FlexOps, LLC. All rights reserved.
7
+ # ***********************************************************************
8
+
9
+ module FlexOps
10
+ module Resources
11
+ # Carbon offsets for shipments. Answers use the { success, data, message, errorCode } envelope.
12
+ class CarbonOffsets
13
+ def initialize(http, ws_id_proc)
14
+ @http = http
15
+ @ws_id = ws_id_proc
16
+ end
17
+
18
+ def quote(weight_lbs:, distance_miles:)
19
+ @http.post("#{ws_path}/quote", body: { weightLbs: weight_lbs, distanceMiles: distance_miles })
20
+ end
21
+
22
+ # Buys an offset for a shipment and debits the workspace wallet.
23
+ # Pass an idempotency_key you store with the purchase to make your own retry safe: after an
24
+ # error or a timeout, retry with the same key and the Gateway returns the first result instead
25
+ # of buying again. Without one, the SDK's own retries still share a key, but a new call gets a new key.
26
+ def purchase(tracking_number:, weight_lbs:, distance_miles:, idempotency_key: nil)
27
+ @http.post("#{ws_path}/purchase",
28
+ body: { trackingNumber: tracking_number, weightLbs: weight_lbs, distanceMiles: distance_miles },
29
+ headers: idempotency_key.nil? ? {} : { "Idempotency-Key" => idempotency_key })
30
+ end
31
+
32
+ def list(page: nil, page_size: nil)
33
+ query = { page: page, pageSize: page_size }.compact
34
+ @http.get(ws_path, query: query.empty? ? nil : query)
35
+ end
36
+
37
+ def get(offset_id)
38
+ @http.get("#{ws_path}/#{offset_id}")
39
+ end
40
+
41
+ def summary
42
+ @http.get("#{ws_path}/summary")
43
+ end
44
+
45
+ def get_settings
46
+ @http.get("#{ws_path}/settings")
47
+ end
48
+
49
+ def update_settings(auto_offset_enabled:)
50
+ @http.put("#{ws_path}/settings", body: { autoOffsetEnabled: auto_offset_enabled })
51
+ end
52
+
53
+ private
54
+
55
+ def ws_path
56
+ "/api/workspaces/#{@ws_id.call}/carbon-offsets"
57
+ end
58
+ end
59
+ end
60
+ end
@@ -8,6 +8,11 @@
8
8
 
9
9
  module FlexOps
10
10
  module Resources
11
+ # Carrier-specific calls through the legacy /api/ApiProxy passthrough (sunset 2027-06-30).
12
+ # Lookups (GET) work with an API key or a user token. The writes (POST/PUT/DELETE) are
13
+ # deprecated and need a user token (auth.login): the Gateway refuses API-key writes on the
14
+ # proxy with 403 API_KEY_SCOPE_REQUIRED. Label generation through the proxy is closed (410 Gone);
15
+ # buy, cancel and schedule through client.shipping, client.pickups and client.scan_forms.
11
16
  class Carriers
12
17
  attr_reader :usps, :ups, :fedex, :dhl
13
18
 
@@ -30,20 +35,37 @@ module FlexOps
30
35
  def validate_address(params) = @http.get("#{PROXY}/api/v3/AddressValidation/getUspsValidateAndCorrectAddress", query: params)
31
36
  def city_state_lookup(zip_code) = @http.get("#{PROXY}/api/v3/AddressValidation/getUspsCityStateLookupByZipCode", query: { zipCode: zip_code })
32
37
  def zip_code_lookup(params) = @http.get("#{PROXY}/api/v3/AddressValidation/getUspsZipCodeLookupByAddress", query: params)
38
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
39
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
33
40
  def get_domestic_rates(body) = @http.post("#{PROXY}/api/v3/RateCalculator/postUspsSearchDomesticBaseRates", body: body)
41
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
42
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
34
43
  def get_domestic_products(body) = @http.post("#{PROXY}/api/v3/RateCalculator/postUspsSearchEligibleDomesticProducts", body: body)
44
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
45
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
35
46
  def get_domestic_prices(body) = @http.post("#{PROXY}/api/v3/RateCalculator/postUspsSearchEligibleDomesticPrices", body: body)
47
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
48
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
36
49
  def get_international_rates(body) = @http.post("#{PROXY}/api/v3/RateCalculator/postUspsSearchInternationalBaseRates", body: body)
50
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
51
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
37
52
  def get_international_prices(body) = @http.post("#{PROXY}/api/v3/RateCalculator/postUspsSearchEligibleInternationalPrices", body: body)
38
- def create_domestic_label(body) = @http.post("#{PROXY}/api/v3/Shipping/postUspsGenerateDomesticShippingLabel", body: body)
39
- def create_return_label(body) = @http.post("#{PROXY}/api/v3/Shipping/postUspsGenerateDomesticReturnsShippingLabel", body: body)
40
- def create_international_label(body) = @http.post("#{PROXY}/api/v3/Shipping/postUspsGenerateInternationalShippingLabel", body: body)
53
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
54
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
41
55
  def cancel_domestic_label = @http.delete("#{PROXY}/api/v3/Shipping/cancelUspsDomesticShipmentLabel")
56
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
57
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
42
58
  def cancel_international_label = @http.delete("#{PROXY}/api/v3/Shipping/cancelUspsInternationalShipmentLabel")
43
59
  def track_summary(params) = @http.get("#{PROXY}/api/v3/Tracking/getUspsTrackingSummaryInformation", query: params)
44
60
  def track_detail(params) = @http.get("#{PROXY}/api/v3/Tracking/getUspsTrackingDetailInformation", query: params)
61
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
62
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
45
63
  def create_pickup(body) = @http.post("#{PROXY}/api/v3/CarrierPickup/postUspsCreateCarrierPickupSchedule", body: body)
64
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
65
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
46
66
  def cancel_pickup = @http.delete("#{PROXY}/api/v3/CarrierPickup/cancelUspsCarrierPickupSchedule")
67
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
68
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
47
69
  def create_scan_form(body) = @http.post("#{PROXY}/api/v3/ScanForm/postUspsCreateScanFormLabelShipment", body: body)
48
70
  def delivery_standards(params) = @http.get("#{PROXY}/api/v3/ServiceStandards/getUspsGetDeliveryStandardsEstimates", query: params)
49
71
  def find_drop_off_locations(params) = @http.get("#{PROXY}/api/v3/LocationSearch/getUspsFindValidDropOffLocations", query: params)
@@ -57,17 +79,36 @@ module FlexOps
57
79
  @http = http
58
80
  end
59
81
 
82
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
83
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
60
84
  def validate_address(body) = @http.post("#{PROXY}/api/v2/ShippingLabel/postUpsVerifyAddress", body: body)
85
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
86
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
61
87
  def get_rates(body) = @http.post("#{PROXY}/api/v2/ShippingLabel/postUpsRateCheck", body: body)
62
- def create_label(body) = @http.post("#{PROXY}/api/v2/ShippingLabel/generateNewUpsShipLabel", body: body)
63
88
  def track(params) = @http.get("#{PROXY}/api/v2/ShippingLabel/getSingleUpsTrackingDetail", query: params)
89
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
90
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
64
91
  def create_pickup(body) = @http.post("#{PROXY}/api/v2/ShippingLabel/postUpsCreatePickup", body: body)
92
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
93
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
65
94
  def cancel_pickup = @http.delete("#{PROXY}/api/v2/ShippingLabel/deleteUpsPickup")
95
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
96
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
66
97
  def get_transit_times(body) = @http.post("#{PROXY}/api/v2/ShippingLabel/postUpsGetTransitTimes", body: body)
98
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
99
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
67
100
  def get_landed_cost(body) = @http.post("#{PROXY}/api/v2/ShippingLabel/postUpsGetLandedCostQuote", body: body)
101
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
102
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
68
103
  def search_locations(body) = @http.post("#{PROXY}/api/v2/ShippingLabel/postUpsSearchLocations", body: body)
104
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
105
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
69
106
  def upload_document(body) = @http.post("#{PROXY}/api/v2/ShippingLabel/postUpsUploadPaperlessDocument", body: body)
107
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
108
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
70
109
  def create_freight_shipment(body) = @http.post("#{PROXY}/api/v2/ShippingLabel/postUpsCreateFreightShipment", body: body)
110
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
111
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
71
112
  def get_freight_rate(body) = @http.post("#{PROXY}/api/v2/ShippingLabel/postUpsGetFreightRate", body: body)
72
113
  end
73
114
 
@@ -78,26 +119,62 @@ module FlexOps
78
119
  @http = http
79
120
  end
80
121
 
122
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
123
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
81
124
  def validate_address(body) = @http.post("#{PROXY}/api/v3/AddressValidation/postFedExValidateAndCorrectDomesticAddress", body: body)
125
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
126
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
82
127
  def validate_postal_code(body) = @http.post("#{PROXY}/api/v3/AddressValidation/postFedExValidatePostalCode", body: body)
128
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
129
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
83
130
  def get_rates(body) = @http.post("#{PROXY}/api/v3/RateCalculator/postRetrieveFedExRateAndTransitTimesAsync", body: body)
84
- def create_shipment(body) = @http.post("#{PROXY}/api/v3/Shipping/postFedExCreateNewShipment", body: body)
131
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
132
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
85
133
  def cancel_shipment(body) = @http.put("#{PROXY}/api/v3/Shipping/putFedExCancelShipment", body: body)
134
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
135
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
86
136
  def validate_shipment(body) = @http.post("#{PROXY}/api/v3/Shipping/postFedExValidateShipment", body: body)
87
- def create_return_shipment(body) = @http.post("#{PROXY}/api/v3/Shipping/postFedExCreateNewReturnShipment", body: body)
137
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
138
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
88
139
  def track(body) = @http.post("#{PROXY}/api/v3/Tracking/postFedExRetrieveTrackingInfoByTrackingNumber", body: body)
140
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
141
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
89
142
  def track_multi_piece(body) = @http.post("#{PROXY}/api/v3/Tracking/postFedExRetrieveTrackingInfoForMultiPieceShipment", body: body)
143
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
144
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
90
145
  def register_tracking_notification(body) = @http.post("#{PROXY}/api/v3/Tracking/postFedExRegisterForTrackingNotification", body: body)
146
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
147
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
91
148
  def create_pickup(body) = @http.post("#{PROXY}/api/v3/CarrierPickup/postFedExCreateCarrierPickupRequest", body: body)
149
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
150
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
92
151
  def cancel_pickup(body) = @http.put("#{PROXY}/api/v3/CarrierPickup/putFedExCancelCarrierPickupRequest", body: body)
152
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
153
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
93
154
  def search_locations(body) = @http.post("#{PROXY}/api/v3/LocationSearch/postFedExSearchValidLocations", body: body)
155
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
156
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
94
157
  def get_service_standards(body) = @http.post("#{PROXY}/api/v3/ServiceStandards/postFedExRetrieveServicesAndTransitTimes", body: body)
158
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
159
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
95
160
  def get_freight_rate(body) = @http.post("#{PROXY}/api/v3/Freight/postFedExGetFreightRateQuote", body: body)
161
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
162
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
96
163
  def create_freight_shipment(body) = @http.post("#{PROXY}/api/v3/Freight/postFedExCreateFreightShipment", body: body)
164
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
165
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
97
166
  def ground_close(body) = @http.post("#{PROXY}/api/v3/GroundClose/postFedExCloseWithDocuments", body: body)
167
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
168
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
98
169
  def upload_trade_documents(body) = @http.post("#{PROXY}/api/v3/Trade/postFedExUploadTradeDocuments", body: body)
170
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
171
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
99
172
  def create_open_shipment(body) = @http.post("#{PROXY}/api/v3/OpenShip/postFedExCreateOpenShipment", body: body)
173
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
174
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
100
175
  def add_packages_to_open_shipment(body) = @http.post("#{PROXY}/api/v3/OpenShip/postFedExAddPackagesToOpenShipment", body: body)
176
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
177
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
101
178
  def confirm_open_shipment(body) = @http.post("#{PROXY}/api/v3/OpenShip/postFedExConfirmOpenShipment", body: body)
102
179
  end
103
180
 
@@ -110,16 +187,26 @@ module FlexOps
110
187
 
111
188
  def validate_address(params) = @http.get("#{PROXY}/api/v2/ShippingLabel/getDhlValidateAddress", query: params)
112
189
  def get_rates(params) = @http.get("#{PROXY}/api/v2/ShippingLabel/getDhlRates", query: params)
190
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
191
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
113
192
  def get_multi_piece_rates(body) = @http.post("#{PROXY}/api/v2/ShippingLabel/postDhlMultiPieceRates", body: body)
114
193
  def get_products(params) = @http.get("#{PROXY}/api/v2/ShippingLabel/getDhlProducts", query: params)
115
- def create_shipment(body) = @http.post("#{PROXY}/api/v2/ShippingLabel/postDhlCreateShipment", body: body)
116
194
  def track(params) = @http.get("#{PROXY}/api/v2/ShippingLabel/getDhlTrackSingleShipment", query: params)
117
195
  def track_multiple(params) = @http.get("#{PROXY}/api/v2/ShippingLabel/getDhlTrackMultipleShipments", query: params)
196
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
197
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
118
198
  def create_pickup(body) = @http.post("#{PROXY}/api/v2/ShippingLabel/postDhlCreatePickup", body: body)
119
- def update_pickup(body) = @http.patch("#{PROXY}/api/v2/ShippingLabel/patchDhlUpdatePickup", body: body)
199
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
200
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
120
201
  def cancel_pickup = @http.delete("#{PROXY}/api/v2/ShippingLabel/deleteDhlPickup")
202
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
203
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
121
204
  def calculate_landed_cost(body) = @http.post("#{PROXY}/api/v2/ShippingLabel/postDhlCalculateLandedCost", body: body)
205
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
206
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
122
207
  def screen_shipment(body) = @http.post("#{PROXY}/api/v2/ShippingLabel/postDhlScreenShipment", body: body)
208
+ # @deprecated Legacy carrier proxy (sunset 2027-06-30). Needs a user token: API keys get 403
209
+ # API_KEY_SCOPE_REQUIRED. Use client.shipping / client.pickups / client.scan_forms where they cover this.
123
210
  def upload_invoice(body) = @http.post("#{PROXY}/api/v2/ShippingLabel/postDhlUploadInvoice", body: body)
124
211
  def get_proof_of_delivery(params) = @http.get("#{PROXY}/api/v2/ShippingLabel/getDhlElectronicProofOfDelivery", query: params)
125
212
  def get_reference_data(params) = @http.get("#{PROXY}/api/v2/ShippingLabel/getDhlReferenceData", query: params)
@@ -1,15 +1,22 @@
1
1
  # ***********************************************************************
2
2
  # Package : flexops
3
3
  # Author : FlexOps, LLC
4
- # Created : 2026-03-31
4
+ # Created : 2026-10-19
5
5
  #
6
6
  # Copyright (c) 2021-2026 by FlexOps, LLC. All rights reserved.
7
7
  # ***********************************************************************
8
8
 
9
- # frozen_string_literal: true
10
-
11
9
  module FlexOps
12
10
  module Resources
11
+ # Branded tracking email templates, one per tracking event. Answers are bare JSON (no
12
+ # envelope); ids are GUID strings.
13
+ #
14
+ # Reads work with any API key or a user token. Writes (upsert, delete, preview) need a user
15
+ # token (auth.login) or an API key granted the opt-in "email-templates:write" scope; a key
16
+ # without it gets 403 API_KEY_SCOPE_REQUIRED.
17
+ #
18
+ # +eventType+ is the Gateway TrackingEmailEvent number: LabelCreated 0, InTransit 1,
19
+ # OutForDelivery 2, Delivered 3, Exception 4, ReturnToSender 5.
13
20
  class EmailTemplates
14
21
  def initialize(http, ws_id_proc)
15
22
  @http = http
@@ -17,33 +24,36 @@ module FlexOps
17
24
  end
18
25
 
19
26
  def list
20
- @http.get("#{ws_path}/shipment-email-templates/")
21
- end
22
-
23
- def get(id)
24
- @http.get("#{ws_path}/shipment-email-templates/#{id}")
27
+ @http.get(ws_path)
25
28
  end
26
29
 
27
- def create(request)
28
- @http.post("#{ws_path}/shipment-email-templates/", body: request)
30
+ def get(template_id)
31
+ @http.get("#{ws_path}/#{template_id}")
29
32
  end
30
33
 
31
- def update(id, request)
32
- @http.put("#{ws_path}/shipment-email-templates/#{id}", body: request)
34
+ # Creates or replaces the template for request's +eventType+.
35
+ #
36
+ # request:: { eventType: (0-5), subjectTemplate: (<= 500 chars), htmlBodyTemplate:,
37
+ # isEnabled: (default true) }. Returns the template.
38
+ def upsert(request)
39
+ @http.put(ws_path, body: request)
33
40
  end
34
41
 
35
- def delete(id)
36
- @http.delete("#{ws_path}/shipment-email-templates/#{id}")
42
+ # Returns nil (204).
43
+ def delete(template_id)
44
+ @http.delete("#{ws_path}/#{template_id}")
37
45
  end
38
46
 
39
- def preview(id, context = {})
40
- @http.post("#{ws_path}/shipment-email-templates/#{id}/preview", body: context)
47
+ # Renders a draft with sample placeholder values. Returns { renderedSubject, renderedHtmlBody }.
48
+ def preview(subject_template:, html_body_template:)
49
+ @http.post("#{ws_path}/preview",
50
+ body: { subjectTemplate: subject_template, htmlBodyTemplate: html_body_template })
41
51
  end
42
52
 
43
53
  private
44
54
 
45
55
  def ws_path
46
- "/api/workspaces/#{@ws_id.call}"
56
+ "/api/workspaces/#{@ws_id.call}/email-templates"
47
57
  end
48
58
  end
49
59
  end
@@ -8,31 +8,30 @@
8
8
 
9
9
  # frozen_string_literal: true
10
10
 
11
+ require "erb"
12
+
11
13
  module FlexOps
12
14
  module Resources
15
+ # HS code lookup and landed cost. These routes are not workspace-scoped; each returns a bare
16
+ # (not enveloped) answer.
13
17
  class HsCodes
14
- def initialize(http, ws_id_proc)
18
+ def initialize(http)
15
19
  @http = http
16
- @ws_id = ws_id_proc
17
20
  end
18
21
 
19
22
  def search(query, destination_country: nil, max_results: 10)
20
23
  params = { query: query, destinationCountry: destination_country, maxResults: max_results }.compact
21
- @http.get("#{ws_path}/shipping/hs-codes/search", query: params)
24
+ @http.get("/api/shipping/hs-codes/search", query: params)
22
25
  end
23
26
 
24
27
  def lookup(code)
25
- @http.get("#{ws_path}/shipping/hs-codes/#{code}")
28
+ @http.get("/api/shipping/hs-codes/#{ERB::Util.url_encode(code.to_s)}")
26
29
  end
27
30
 
31
+ # request:: { destinationCountry:, items: [{ description:, quantity:, value:, weightOz:,
32
+ # hsCode: (optional), originCountry:, skuOrProductId: (optional) }], shippingCost: }
28
33
  def estimate_landed_cost(request)
29
- @http.post("#{ws_path}/shipping/landed-cost", body: request)
30
- end
31
-
32
- private
33
-
34
- def ws_path
35
- "/api/workspaces/#{@ws_id.call}"
34
+ @http.post("/api/shipping/landed-cost", body: request)
36
35
  end
37
36
  end
38
37
  end