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 +4 -4
- data/README.md +189 -45
- data/lib/flexops/client.rb +6 -7
- data/lib/flexops/errors.rb +9 -0
- data/lib/flexops/hazmat_classification.rb +34 -0
- data/lib/flexops/http.rb +75 -44
- data/lib/flexops/resources/auth.rb +8 -29
- data/lib/flexops/resources/carbon_offsets.rb +60 -0
- data/lib/flexops/resources/carriers.rb +95 -8
- data/lib/flexops/resources/email_templates.rb +27 -17
- data/lib/flexops/resources/hs_codes.rb +10 -11
- data/lib/flexops/resources/insurance.rb +19 -9
- data/lib/flexops/resources/inventory.rb +0 -8
- data/lib/flexops/resources/orders.rb +0 -8
- data/lib/flexops/resources/pickups.rb +14 -5
- data/lib/flexops/resources/recurring_shipments.rb +30 -15
- data/lib/flexops/resources/report_schedules.rb +63 -0
- data/lib/flexops/resources/returns.rb +37 -23
- data/lib/flexops/resources/rules.rb +18 -2
- data/lib/flexops/resources/shipping.rb +47 -9
- data/lib/flexops/resources/wallet.rb +4 -20
- data/lib/flexops/resources/webhooks.rb +132 -9
- data/lib/flexops/resources/workspaces.rb +8 -13
- data/lib/flexops/version.rb +1 -1
- data/lib/flexops/webhook_event_type.rb +25 -0
- data/lib/flexops/workspace_role.rb +32 -0
- data/lib/flexops.rb +5 -3
- metadata +7 -5
- data/lib/flexops/resources/api_keys.rb +0 -40
- data/lib/flexops/resources/offsets.rb +0 -38
- data/lib/flexops/resources/reports.rb +0 -46
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 99bb604e4e35131d7525633fbfad7103b380170556b47250599192256636eec1
|
|
4
|
+
data.tar.gz: b9fb3ccd224541332bd03fd9e8510937fbed4d093c6ab3443dfd41fee0565229
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
##
|
|
113
|
+
## Carrier-specific calls
|
|
111
114
|
|
|
112
|
-
|
|
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
|
-
#
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
126
|
-
info = client.carriers.ups.track(tracking_number: "1Z999AA10123456784")
|
|
221
|
+
## Webhook subscriptions
|
|
127
222
|
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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/
|
|
317
|
+
curl https://gateway.flexops.io/api/shipping/track/9400111899223456789012 \
|
|
175
318
|
-H "X-API-Key: fxk_live_..."
|
|
176
319
|
|
|
177
|
-
# Cancel a label
|
|
178
|
-
curl -X DELETE https://gateway.flexops.io/api/
|
|
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,
|
|
189
|
-
| `client.workspaces` |
|
|
190
|
-
| `client.shipping` | Rate shopping, labels, tracking, batch, cancel |
|
|
191
|
-
| `client.carriers` | USPS, UPS, FedEx
|
|
192
|
-
| `client.webhooks` | Subscription
|
|
193
|
-
| `client.wallet` |
|
|
194
|
-
| `client.insurance` |
|
|
195
|
-
| `client.returns` |
|
|
196
|
-
| `client.
|
|
197
|
-
| `client.
|
|
198
|
-
| `client.
|
|
199
|
-
| `client.
|
|
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
|
|
203
|
-
| `client.
|
|
204
|
-
| `client.
|
|
205
|
-
| `client.recurring_shipments` |
|
|
206
|
-
| `client.
|
|
207
|
-
| `client.
|
|
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.
|
|
366
|
+
- Ruby 3.1+
|
|
223
367
|
|
|
224
368
|
## License
|
|
225
369
|
|
data/lib/flexops/client.rb
CHANGED
|
@@ -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, :
|
|
14
|
-
:pickups, :scan_forms, :rules, :
|
|
15
|
-
:recurring_shipments, :
|
|
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
|
-
@
|
|
39
|
-
@
|
|
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)
|
data/lib/flexops/errors.rb
CHANGED
|
@@ -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
|
-
|
|
94
|
+
last_error = e
|
|
95
|
+
next
|
|
96
|
+
end
|
|
77
97
|
|
|
78
|
-
|
|
79
|
-
return parse_response(response)
|
|
80
|
-
end
|
|
98
|
+
status = response.code.to_i
|
|
81
99
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
100
|
+
if status >= 200 && status < 300
|
|
101
|
+
return parse_response(response)
|
|
102
|
+
end
|
|
85
103
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
104
|
+
if status == 401
|
|
105
|
+
raise AuthError
|
|
106
|
+
end
|
|
89
107
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|