flexops 1.1.0 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ae38cf941276d4053203fda4a44fb9ab1adb873d24bba9ec1de7fc634be3e7e2
4
- data.tar.gz: 43b602d72cbac545bff0391aa2dee8ea28f73bd7807a6042ec17731fe7943881
3
+ metadata.gz: 99bb604e4e35131d7525633fbfad7103b380170556b47250599192256636eec1
4
+ data.tar.gz: b9fb3ccd224541332bd03fd9e8510937fbed4d093c6ab3443dfd41fee0565229
5
5
  SHA512:
6
- metadata.gz: '0959231eed12a9abd27bc7d370ab9576e32393a131248e231f2d7bebb622c74c29c21596f55036c1af8df73a277a3cbb8ee282c516aca90fc787839c76f102dc'
7
- data.tar.gz: 42fa01ac062f0f54ed7632e403a0d3a5ed37e3e942e6f11a45797b047f7f343c3b0419d354e83af9be217d58d1877a322ee7fee7fa6c79e9749e069cfca0f7cf
6
+ metadata.gz: 12823e90e21cd1eeefcf883ab5c7ed9a4e9d008b2e26bd80ac6014a05e3b6e6b57e4054a3757574173886b2c8ed6a08e6c8d3a2863f6034eb592e6e8d03da5c1
7
+ data.tar.gz: fb7a48fb211dfb1af4ddd9a0be588eb58318b8ffd445578a09dcacdef625ff9dc8e3dbc593b59a21da18acf678d67d9ca0bd76aef55a22843be9748bfddcf5fa
data/README.md CHANGED
@@ -45,7 +45,9 @@ request = {
45
45
  origin: {name: "Warehouse", addressLine1: "123 Main St", city: "New York", stateProvince: "NY", postalCode: "10001", countryCode: "US"},
46
46
  destination: {name: "Customer", addressLine1: "456 Oak Ave", city: "Los Angeles", stateProvince: "CA", postalCode: "90210", countryCode: "US"},
47
47
  package: {weight: 16, weightUnit: "oz"},
48
- maximumPostageAmount: 10.25 # Caller-approved ceiling in USD.
48
+ maximumPostageAmount: 10.25, # Caller-approved ceiling in USD.
49
+ # Required without an orderId. The SDK never fills it in; NONE (0) = not hazardous.
50
+ hazmatClassification: FlexOps::HazmatClassification::NONE
49
51
  }
50
52
  preview = client.shipping.create_label(request)
51
53
  puts preview["quotedPostageAmount"]
@@ -95,7 +97,8 @@ client = FlexOps::Client.new(api_key: "fxk_live_...", workspace_id: "ws_abc123")
95
97
 
96
98
  ```ruby
97
99
  client = FlexOps::Client.new(base_url: "https://gateway.flexops.io")
98
- client.auth.login(email: "user@example.com", password: "password")
100
+ session = client.auth.login(email: "user@example.com", password: "password")
101
+ client.set_access_token(session["data"]["token"])
99
102
  client.workspace_id = "ws_abc123"
100
103
  ```
101
104
 
@@ -107,38 +110,176 @@ Use `fxk_test_...` (instead of `fxk_live_...`) to route to the sandbox environme
107
110
  client = FlexOps::Client.new(api_key: "fxk_test_...", workspace_id: "ws_abc123")
108
111
  ```
109
112
 
110
- ## Direct carrier operations
113
+ ## Carrier-specific calls
111
114
 
112
- Access carrier-specific endpoints when you need full control:
115
+ `client.carriers.usps`, `.ups`, `.fedex` and `.dhl` call carrier routes through the legacy
116
+ `/api/ApiProxy` passthrough, which the Gateway retires on 2027-06-30.
117
+
118
+ - Lookups (GET), such as address checks, tracking and DHL rates, work with an API key or a user
119
+ token.
120
+ - The writes (POST, PUT and DELETE, such as `usps.get_domestic_rates`, `fedex.cancel_shipment`
121
+ or `ups.create_pickup`) are deprecated. They need a user token from `auth.login`: API keys get
122
+ 403 `API_KEY_SCOPE_REQUIRED`, and no API-key scope will be added for them. They stop working when
123
+ the proxy is retired.
124
+ - Label generation through the proxy is closed (410 Gone), so the SDK has no carrier label
125
+ method.
126
+
127
+ Use the native routes instead where they cover the call:
128
+
129
+ | Instead of | Use |
130
+ |---|---|
131
+ | Carrier rate calls | `client.shipping.get_rates` |
132
+ | Carrier labels | `client.shipping.create_label` (`client.returns.generate_label` for returns) |
133
+ | Label cancels | `client.shipping.cancel_label` |
134
+ | Carrier pickups | `client.pickups.schedule`, `list`, `get`, `cancel` |
135
+ | USPS scan forms | `client.scan_forms.create` |
136
+ | Address validation | `client.shipping.validate_address` |
137
+ | Tracking | `client.shipping.track` |
138
+
139
+ ```ruby
140
+ # USPS city and state for a ZIP code (works with an API key)
141
+ place = client.carriers.usps.city_state_lookup("10001")
142
+ ```
143
+
144
+ ## Retries and idempotency
145
+
146
+ The client retries a request up to three times with exponential backoff:
147
+
148
+ - GET: after a dropped connection or timeout, and on 429, 500, 502, 503 and 504.
149
+ - POST, PUT and PATCH: after a dropped connection or timeout, and on 429, 502, 503, 504 and
150
+ 409 `IDEMPOTENCY_CONFLICT` (the first request with that key is still running). Never on 500:
151
+ the Gateway does not replay a 5xx, so a retry could run the write (and charge the wallet)
152
+ again.
153
+ - DELETE: only on 429.
154
+
155
+ A key reused with a different request body gets 422 `IDEMPOTENCY_KEY_REUSED`; it is not retried
156
+ and raises `FlexOps::Error` with that `code`. Error codes are matched exactly, so a label
157
+ purchase's own 409 `IdempotencyConflict` (altered inputs under a used key) is not retried either.
158
+
159
+ Every POST, PUT and PATCH carries an `Idempotency-Key` header. Pass your own to
160
+ `shipping.create_label(request, idempotency_key: ...)`, `insurance.purchase(..., idempotency_key: ...)`
161
+ or `carbon_offsets.purchase(..., idempotency_key: ...)`, and store it so a retry your app makes after
162
+ an error or a timeout reuses it; otherwise the client generates one UUID
163
+ per call and sends it on every retry of that call, so the Gateway replays a write it already
164
+ finished instead of running it twice. A 2xx response with no body returns `nil`.
165
+
166
+ ## Workspace members
167
+
168
+ Roles are the Gateway `WorkspaceRole` numbers (Owner 0, Admin 1, Member 2, Merchant 3). Pass a
169
+ `FlexOps::WorkspaceRole` constant or a name; the SDK sends the number.
170
+
171
+ ```ruby
172
+ client.workspaces.invite_member(email: "ops@example.com", role: :member)
173
+ client.workspaces.update_member_role(user_id, FlexOps::WorkspaceRole::ADMIN)
174
+
175
+ result = client.workspaces.list_members
176
+ members = result["data"]["members"] # also "pendingInvites", "userLimit", "canInviteMore"
177
+ ```
178
+
179
+ ## Recurring shipments, report schedules and email templates
180
+
181
+ Reads work with any API key or a user token. Writes (every POST, PUT and DELETE, including the
182
+ email template preview) need a user token from `auth.login` or an API key granted the
183
+ resource's opt-in scope: `recurring-shipments:write`, `report-schedules:write` or
184
+ `email-templates:write`. Older keys do not have these scopes; a key without one gets 403
185
+ `API_KEY_SCOPE_REQUIRED` with `requiredScopes`. Enums are sent as numbers and ids are GUID
186
+ strings.
113
187
 
114
188
  ```ruby
115
- # USPS domestic label
116
- label = client.carriers.usps.create_domestic_label(
117
- image_type: "PDF",
118
- mail_class: "PRIORITY_MAIL",
119
- weight_in_ounces: 16
189
+ # Recurring shipments (bare JSON). interval: Daily 0, Weekly 1, Biweekly 2, Monthly 3, Custom 4
190
+ schedule = client.recurring_shipments.create(
191
+ name: "Weekly restock", addressId: address_id, presetId: preset_id,
192
+ interval: 1, firstRunUtc: "2026-11-02T14:00:00Z"
120
193
  )
194
+ client.recurring_shipments.pause(schedule["id"]) # nil (204); also resume, delete
195
+ client.recurring_shipments.trigger(schedule["id"], idempotency_key: run_key) # creates an order now
196
+
197
+ # Report schedules ({ success, data, message } envelope).
198
+ # format: Csv 0, Json 1; frequencyType: Daily 0, Weekly 1, Monthly 2
199
+ report = client.report_schedules.create(
200
+ reportType: "shipments", format: 0, frequencyType: 1, dayOfWeek: 1,
201
+ timeOfDayUtc: "08:00:00", recipientEmails: "ops@example.com"
202
+ )["data"]
203
+ client.report_schedules.toggle(report["id"]) # flips isEnabled
204
+
205
+ # Tracking email templates (bare JSON), one per event. eventType: LabelCreated 0, InTransit 1,
206
+ # OutForDelivery 2, Delivered 3, Exception 4, ReturnToSender 5
207
+ client.email_templates.upsert(eventType: 3, subjectTemplate: "Delivered: {{tracking_number}}",
208
+ htmlBodyTemplate: "<p>Your package arrived.</p>")
209
+ client.email_templates.preview(subject_template: "Hi {{recipient_name}}", html_body_template: "<p>{{status}}</p>")
210
+ ```
211
+
212
+ ## Wallet balance
213
+
214
+ ```ruby
215
+ balance = client.wallet.get_balance # {"hasWallet" => true, "balanceCents" => 1250, "balance" => 12.5}
216
+ ```
121
217
 
122
- # FedEx rate quote
123
- rates = client.carriers.fedex.get_rates(...)
218
+ The request carries `X-Workspace-Id` set to the client's `workspace_id`. Funding and
219
+ transaction history are managed in the FlexOps portal.
124
220
 
125
- # UPS tracking
126
- info = client.carriers.ups.track(tracking_number: "1Z999AA10123456784")
221
+ ## Webhook subscriptions
127
222
 
128
- # DHL shipment
129
- shipment = client.carriers.dhl.create_shipment(...)
223
+ ```ruby
224
+ # Leave events out to receive every event.
225
+ webhook = client.webhooks.create(
226
+ url: "https://your-app.com/webhooks/flexops",
227
+ events: [FlexOps::WebhookEventType::LABEL_CREATED,
228
+ FlexOps::WebhookEventType::LABEL_CANCELED,
229
+ FlexOps::WebhookEventType::BATCH_COMPLETED]
230
+ )
231
+ signing_secret = webhook["secret"] # "whsec_...", returned only on create; store it
232
+
233
+ # Change the events (or url, description, status: "Active", "Paused", "Disabled").
234
+ client.webhooks.update(webhook["id"], events: [FlexOps::WebhookEventType::LABEL_CREATED])
130
235
  ```
131
236
 
237
+ `create` returns the subscription itself (`"id"`, `"url"`, `"eventTypes"`, `"status"`, `"secret"`, ...), because the Gateway answers it with the bare subscription. The other webhook methods return the usual `{"success" => true, "data" => ...}` envelope.
238
+
239
+ `events` is sent as the Gateway's comma-separated `eventTypes`. FlexOps sends these events, named exactly as below (`FlexOps::WebhookEventType::ALL` lists them). Names are PascalCase and matched exactly, so a name like `label.created` would never match; `create` and `update` raise `ArgumentError`, before any request, for an unknown name, an empty list or a non-array.
240
+
241
+ | Event | Sent when |
242
+ |---|---|
243
+ | `LabelCreated` | A live label is purchased (sandbox labels send nothing) |
244
+ | `LabelCanceled` | A live label is voided |
245
+ | `BatchCompleted` | A batch label job finishes |
246
+ | `TrackingUpdated` | A USPS tracking scan arrives (USPS push-tracking pilot workspaces only) |
247
+ | `TrackingDelivered` | A USPS package is delivered (USPS push-tracking pilot workspaces only) |
248
+
249
+ You can send a raw `eventTypes:` string (`"*"` or names joined by commas with no spaces) instead of `events:`; it is passed through unchecked, for example to subscribe to an event newer than your SDK version.
250
+
132
251
  ## Webhook verification
133
252
 
253
+ FlexOps signs each delivery with HMAC-SHA256 over `{X-FlexOps-Timestamp}.{raw body}`, keyed with your subscription secret (`whsec_...`, used as-is), and sends it as `X-FlexOps-Signature: sha256=<hex>`. Verify the raw body before you parse it.
254
+
134
255
  ```ruby
135
- valid = FlexOps::Resources::Webhooks.verify_signature(
136
- payload: request.body.read,
137
- signature: request.headers["X-FlexOps-Signature"],
138
- secret: "whsec_..."
139
- )
256
+ # Rails controller action
257
+ def flexops_webhook
258
+ payload = request.raw_post
259
+
260
+ valid = FlexOps::Resources::Webhooks.verify_signature(
261
+ payload: payload,
262
+ signature: request.headers["X-FlexOps-Signature"], # "sha256=..."
263
+ timestamp: request.headers["X-FlexOps-Timestamp"], # Unix seconds
264
+ secret: ENV.fetch("FLEXOPS_WEBHOOK_SECRET") # "whsec_..."
265
+ )
266
+ return head :unauthorized unless valid
267
+
268
+ # The body is the event data itself (no type/data envelope).
269
+ # The event name is in a header; test events send "test".
270
+ data = JSON.parse(payload)
271
+ case request.headers["X-FlexOps-Event"]
272
+ when "test"
273
+ # data: {"type"=>"test", "message"=>"...", "timestamp"=>"...", "subscriptionId"=>"..."}
274
+ when "TrackingUpdated"
275
+ # data["trackingNumber"], data["status"], ...
276
+ end
277
+ head :ok
278
+ end
140
279
  ```
141
280
 
281
+ Deliveries whose timestamp is more than 300 seconds from your clock (either way) are rejected to limit replays; pass `tolerance:` (seconds) to change that. `verify_signature!` takes the same arguments and raises `FlexOps::WebhookSignatureError` instead of returning `false`; its `code` says why (`WEBHOOK_SIGNATURE_MISMATCH`, `WEBHOOK_TIMESTAMP_OUTSIDE_TOLERANCE`, `WEBHOOK_INVALID_TIMESTAMP`, `WEBHOOK_MISSING_SIGNATURE`, `WEBHOOK_MISSING_SECRET`).
282
+
142
283
  ## Curl quickstart
143
284
 
144
285
  Every SDK method is a thin wrapper around the FlexOps REST API. If you want to verify the API before committing to the SDK — or you're integrating from a language we don't ship a SDK for — these curl invocations hit the same endpoints:
@@ -155,7 +296,8 @@ curl -X POST https://gateway.flexops.io/api/shipping/rates \
155
296
  }'
156
297
 
157
298
  # Preview a live label; this does not purchase it.
158
- curl -X POST https://gateway.flexops.io/api/workspaces/ws_abc123/shipping/labels \
299
+ # hazmatClassification 0 = not hazardous; required when there is no orderId.
300
+ curl -X POST https://gateway.flexops.io/api/shipping/labels \
159
301
  -H "X-API-Key: fxk_live_..." \
160
302
  -H "Content-Type: application/json" \
161
303
  -d '{
@@ -163,7 +305,8 @@ curl -X POST https://gateway.flexops.io/api/workspaces/ws_abc123/shipping/labels
163
305
  "origin": {"name": "Warehouse", "addressLine1": "123 Main St", "city": "New York", "stateProvince": "NY", "postalCode": "10001", "countryCode": "US"},
164
306
  "destination": {"name": "Customer", "addressLine1": "456 Oak Ave", "city": "Los Angeles", "stateProvince": "CA", "postalCode": "90210", "countryCode": "US"},
165
307
  "package": {"weight": 16, "weightUnit": "oz"},
166
- "maximumPostageAmount": 10.25
308
+ "maximumPostageAmount": 10.25,
309
+ "hazmatClassification": 0
167
310
  }'
168
311
 
169
312
  # After explicit approval, resend the same body with confirmationToken from
@@ -171,11 +314,11 @@ curl -X POST https://gateway.flexops.io/api/workspaces/ws_abc123/shipping/labels
171
314
  # See the SDK example above; retain that key for retries.
172
315
 
173
316
  # Track a shipment
174
- curl https://gateway.flexops.io/api/workspaces/ws_abc123/shipping/track/9400111899223456789012 \
317
+ curl https://gateway.flexops.io/api/shipping/track/9400111899223456789012 \
175
318
  -H "X-API-Key: fxk_live_..."
176
319
 
177
- # Cancel a label (via the unified carrier-agnostic endpoint)
178
- curl -X DELETE https://gateway.flexops.io/api/v3.0/shipping/Usps/cancel/9400111899223456789012 \
320
+ # Cancel a label
321
+ curl -X DELETE "https://gateway.flexops.io/api/shipping/labels/lbl_123?carrierCode=USPS" \
179
322
  -H "X-API-Key: fxk_live_..."
180
323
  ```
181
324
 
@@ -185,26 +328,27 @@ Use an `fxk_test_...` key instead of `fxk_live_...` to hit the sandbox environme
185
328
 
186
329
  | Resource | Description |
187
330
  |----------|-------------|
188
- | `client.auth` | Login, register, password management |
189
- | `client.workspaces` | Workspace CRUD, membership, branding |
190
- | `client.shipping` | Rate shopping, labels, tracking, batch, cancel |
191
- | `client.carriers` | USPS, UPS, FedEx, DHL direct endpoints |
192
- | `client.webhooks` | Subscription CRUD, signature verification, delivery logs |
193
- | `client.wallet` | Balance, transactions, auto-reload |
194
- | `client.insurance` | Quotes, purchase, claims (first-party + U-PIC) |
195
- | `client.returns` | RMA lifecycle: create, batch, QR codes, photo upload, cost recovery |
196
- | `client.api_keys` | Key creation, rotation, revocation |
197
- | `client.analytics` | Shipments, orders, carrier performance |
198
- | `client.orders` | Order management |
199
- | `client.inventory` | Warehouse inventory |
200
- | `client.pickups` | Carrier pickup scheduling |
331
+ | `client.auth` | Login, token refresh, logout |
332
+ | `client.workspaces` | List and read workspaces; list, invite and remove members, change roles |
333
+ | `client.shipping` | Rate shopping, labels, tracking, address validation, batch, cancel |
334
+ | `client.carriers` | USPS, UPS, FedEx and DHL calls through the legacy proxy; writes are deprecated and need a user token |
335
+ | `client.webhooks` | Subscription create/update/delete, secret rotation, test events, delivery logs, signature verification |
336
+ | `client.wallet` | Workspace balance |
337
+ | `client.insurance` | Providers, quotes, policies, void, claims |
338
+ | `client.returns` | List and read returns; create RMAs, return labels, receive |
339
+ | `client.analytics` | Shipments, orders, carrier performance (legacy proxy reads) |
340
+ | `client.orders` | Order reads (legacy proxy) |
341
+ | `client.inventory` | Inventory reads (legacy proxy) |
342
+ | `client.pickups` | Schedule, list, read and cancel carrier pickups |
201
343
  | `client.scan_forms` | USPS scan forms |
202
- | `client.rules` | Shipping automation rules |
203
- | `client.offsets` | Carbon offset purchases |
204
- | `client.hs_codes` | HS code lookup for international customs |
205
- | `client.recurring_shipments` | Scheduled recurring shipments |
206
- | `client.email_templates` | Branded post-purchase email templates |
207
- | `client.reports` | Report generation and scheduled delivery |
344
+ | `client.rules` | Shipping rules: create, read, update, delete, reorder, test |
345
+ | `client.hs_codes` | HS code search and lookup, landed cost |
346
+ | `client.carbon_offsets` | Carbon offset quotes, purchases, summary and settings |
347
+ | `client.recurring_shipments` | Recurring shipment schedules: create, update, pause, resume, trigger, delete |
348
+ | `client.report_schedules` | Scheduled report emails: create, update, toggle, delete |
349
+ | `client.email_templates` | Tracking email templates: upsert, preview, delete |
350
+
351
+ API keys, workspace settings and account management are managed in the FlexOps portals.
208
352
 
209
353
  ## Configuration
210
354
 
@@ -219,7 +363,7 @@ client = FlexOps::Client.new(
219
363
 
220
364
  ## Requirements
221
365
 
222
- - Ruby 3.0+
366
+ - Ruby 3.1+
223
367
 
224
368
  ## License
225
369
 
@@ -10,9 +10,9 @@ module FlexOps
10
10
  class Client
11
11
  attr_accessor :workspace_id
12
12
  attr_reader :auth, :workspaces, :shipping, :carriers, :webhooks, :wallet,
13
- :insurance, :returns, :api_keys, :analytics, :orders, :inventory,
14
- :pickups, :scan_forms, :rules, :offsets, :hs_codes,
15
- :recurring_shipments, :email_templates, :reports
13
+ :insurance, :returns, :analytics, :orders, :inventory,
14
+ :pickups, :scan_forms, :rules, :hs_codes, :carbon_offsets,
15
+ :recurring_shipments, :report_schedules, :email_templates
16
16
 
17
17
  def initialize(api_key: nil, access_token: nil, base_url: "https://gateway.flexops.io", workspace_id: nil, timeout: 30)
18
18
  @http = HttpClient.new(base_url: base_url, api_key: api_key, access_token: access_token, timeout: timeout)
@@ -28,18 +28,17 @@ module FlexOps
28
28
  @wallet = Resources::Wallet.new(@http, ws_id_proc)
29
29
  @insurance = Resources::Insurance.new(@http, ws_id_proc)
30
30
  @returns = Resources::Returns.new(@http, ws_id_proc)
31
- @api_keys = Resources::ApiKeys.new(@http, ws_id_proc)
32
31
  @analytics = Resources::Analytics.new(@http)
33
32
  @orders = Resources::Orders.new(@http)
34
33
  @inventory = Resources::Inventory.new(@http)
35
34
  @pickups = Resources::Pickups.new(@http, ws_id_proc)
36
35
  @scan_forms = Resources::ScanForms.new(@http, ws_id_proc)
37
36
  @rules = Resources::Rules.new(@http, ws_id_proc)
38
- @offsets = Resources::Offsets.new(@http, ws_id_proc)
39
- @hs_codes = Resources::HsCodes.new(@http, ws_id_proc)
37
+ @hs_codes = Resources::HsCodes.new(@http)
38
+ @carbon_offsets = Resources::CarbonOffsets.new(@http, ws_id_proc)
40
39
  @recurring_shipments = Resources::RecurringShipments.new(@http, ws_id_proc)
40
+ @report_schedules = Resources::ReportSchedules.new(@http, ws_id_proc)
41
41
  @email_templates = Resources::EmailTemplates.new(@http, ws_id_proc)
42
- @reports = Resources::Reports.new(@http, ws_id_proc)
43
42
  end
44
43
 
45
44
  def set_access_token(token)
@@ -32,4 +32,13 @@ module FlexOps
32
32
  @retry_after = retry_after
33
33
  end
34
34
  end
35
+
36
+ # Raised by Resources::Webhooks.verify_signature! when a webhook delivery
37
+ # is not authentic or not fresh. +code+ says why, for example
38
+ # "WEBHOOK_SIGNATURE_MISMATCH" or "WEBHOOK_TIMESTAMP_OUTSIDE_TOLERANCE".
39
+ class WebhookSignatureError < Error
40
+ def initialize(message, code:)
41
+ super(message, code: code)
42
+ end
43
+ end
35
44
  end
@@ -0,0 +1,34 @@
1
+ # ***********************************************************************
2
+ # Package : flexops
3
+ # Author : FlexOps, LLC
4
+ # Created : 2026-10-12
5
+ #
6
+ # Copyright (c) 2021-2026 by FlexOps, LLC. All rights reserved.
7
+ # ***********************************************************************
8
+
9
+ module FlexOps
10
+ # USPS HAZMAT classification, sent as the Gateway HazmatClassification number.
11
+ #
12
+ # There is no default: leave hazmatClassification out when you have no answer, or send
13
+ # HazmatClassification::NONE (0) to declare that the parcel holds no dangerous goods.
14
+ # A label without an orderId must declare one.
15
+ module HazmatClassification
16
+ NONE = 0
17
+ LIMITED_QUANTITY = 1
18
+ LITHIUM_BATTERY_CONTAINED_IN_EQUIPMENT = 2
19
+ LITHIUM_BATTERY_PACKED_WITH_EQUIPMENT = 3
20
+ LITHIUM_BATTERY_STANDALONE = 4
21
+ OTHER_RESTRICTED = 5
22
+ LITHIUM_BATTERY_USED_DEVICE = 6
23
+
24
+ ALL = {
25
+ NONE: NONE,
26
+ LIMITED_QUANTITY: LIMITED_QUANTITY,
27
+ LITHIUM_BATTERY_CONTAINED_IN_EQUIPMENT: LITHIUM_BATTERY_CONTAINED_IN_EQUIPMENT,
28
+ LITHIUM_BATTERY_PACKED_WITH_EQUIPMENT: LITHIUM_BATTERY_PACKED_WITH_EQUIPMENT,
29
+ LITHIUM_BATTERY_STANDALONE: LITHIUM_BATTERY_STANDALONE,
30
+ OTHER_RESTRICTED: OTHER_RESTRICTED,
31
+ LITHIUM_BATTERY_USED_DEVICE: LITHIUM_BATTERY_USED_DEVICE
32
+ }.freeze
33
+ end
34
+ end
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
52
  def post(path, body: nil, query: nil, headers: {})
42
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
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|
@@ -72,53 +86,65 @@ module FlexOps
72
86
  open_timeout: @timeout, read_timeout: @timeout) do |http|
73
87
  http.request(req)
74
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
75
93
 
76
- status = response.code.to_i
94
+ last_error = e
95
+ next
96
+ end
77
97
 
78
- if status >= 200 && status < 300
79
- return parse_response(response)
80
- end
98
+ status = response.code.to_i
81
99
 
82
- if status == 401
83
- raise AuthError
84
- end
100
+ if status >= 200 && status < 300
101
+ return parse_response(response)
102
+ end
85
103
 
86
- if status == 403
87
- raise Error.new("Access denied. Check your plan tier and feature entitlements.", status: 403, code: "FORBIDDEN")
88
- end
104
+ if status == 401
105
+ raise AuthError
106
+ end
89
107
 
90
- if status == 429
91
- retry_after = (response["retry-after"] || "0").to_i
92
- last_error = RateLimitError.new(retry_after: retry_after)
93
- next if RETRYABLE_STATUSES.include?(429)
94
- raise last_error
95
- 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
96
114
 
97
- error_body = parse_error_body(response)
98
- error = Error.new(
99
- error_body["message"] || "HTTP #{status}: #{response.message}",
100
- status: status,
101
- code: error_body["errorCode"] || error_body["code"],
102
- errors: error_body["errors"]
103
- )
104
-
105
- if RETRYABLE_STATUSES.include?(status)
106
- last_error = error
107
- next
108
- end
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
+ )
109
122
 
110
- raise error
111
- rescue Error
112
- raise
113
- rescue StandardError => e
114
- last_error = e
115
- next if attempt < MAX_RETRIES
116
- end
123
+ raise error unless retryable?(method, status, error.code)
124
+
125
+ last_error = error
117
126
  end
118
127
 
119
128
  raise last_error || Error.new("Request failed after retries", code: "RETRY_EXHAUSTED")
120
129
  end
121
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
+
122
148
  def build_uri(path, query)
123
149
  url = "#{@base_url}#{path.start_with?("/") ? path : "/#{path}"}"
124
150
  uri = URI.parse(url)
@@ -152,7 +178,11 @@ module FlexOps
152
178
  req
153
179
  end
154
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.
155
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
+
156
186
  content_type = response["content-type"] || ""
157
187
  if content_type.include?("application/json")
158
188
  JSON.parse(response.body)
@@ -162,7 +192,8 @@ module FlexOps
162
192
  end
163
193
 
164
194
  def parse_error_body(response)
165
- JSON.parse(response.body)
195
+ parsed = JSON.parse(response.body)
196
+ parsed.is_a?(Hash) ? parsed : {}
166
197
  rescue StandardError
167
198
  {}
168
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