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
|
@@ -0,0 +1,63 @@
|
|
|
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
|
+
# Scheduled report emails. Answers use the { success, data, message } envelope, except
|
|
12
|
+
# delete, which returns nil (204). Ids are GUID strings.
|
|
13
|
+
#
|
|
14
|
+
# Reads work with any API key or a user token. Writes (create, update, delete, toggle) need a
|
|
15
|
+
# user token (auth.login) or an API key granted the opt-in "report-schedules:write" scope; a
|
|
16
|
+
# key without it gets 403 API_KEY_SCOPE_REQUIRED.
|
|
17
|
+
#
|
|
18
|
+
# Numbers on the wire: +format+ Csv 0, Json 1; +frequencyType+ Daily 0, Weekly 1, Monthly 2;
|
|
19
|
+
# +dayOfWeek+ 0-6 (Sunday 0, for Weekly); +dayOfMonth+ 1-28 (for Monthly).
|
|
20
|
+
class ReportSchedules
|
|
21
|
+
def initialize(http, ws_id_proc)
|
|
22
|
+
@http = http
|
|
23
|
+
@ws_id = ws_id_proc
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def list
|
|
27
|
+
@http.get(ws_path)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def get(id)
|
|
31
|
+
@http.get("#{ws_path}/#{id}")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# request:: { reportType: (e.g. "shipments"), format:, frequencyType:, timeOfDayUtc: ("HH:mm:ss"),
|
|
35
|
+
# recipientEmails: (comma-separated), dayOfWeek:, dayOfMonth:, filterParametersJson:,
|
|
36
|
+
# maxFailures: (1-100, default 3) }.
|
|
37
|
+
def create(request)
|
|
38
|
+
@http.post(ws_path, body: request)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# request:: any of the create fields.
|
|
42
|
+
def update(id, request)
|
|
43
|
+
@http.put("#{ws_path}/#{id}", body: request)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Returns nil (204).
|
|
47
|
+
def delete(id)
|
|
48
|
+
@http.delete("#{ws_path}/#{id}")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Flips isEnabled. The envelope's data is nil; read the schedule to see the new state.
|
|
52
|
+
def toggle(id)
|
|
53
|
+
@http.post("#{ws_path}/#{id}/toggle")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def ws_path
|
|
59
|
+
"/api/workspaces/#{@ws_id.call}/report-schedules"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -14,8 +14,8 @@ module FlexOps
|
|
|
14
14
|
@ws_id = ws_id_proc
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
def list(page: nil, page_size: nil,
|
|
18
|
-
query = { page: page, pageSize: page_size,
|
|
17
|
+
def list(page: nil, page_size: nil, start_date: nil, end_date: nil)
|
|
18
|
+
query = { page: page, pageSize: page_size, startDate: start_date, endDate: end_date }.compact
|
|
19
19
|
@http.get("#{ws_path}/returns", query: query.empty? ? nil : query)
|
|
20
20
|
end
|
|
21
21
|
|
|
@@ -23,32 +23,46 @@ module FlexOps
|
|
|
23
23
|
@http.get("#{ws_path}/returns/#{return_id}")
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
+
# Creates an RMA and returns the enveloped RMA (201).
|
|
27
|
+
#
|
|
28
|
+
# request:: { orderId: (a number), orderNumber: (optional), reason:,
|
|
29
|
+
# items: [{ sku:, quantity:, reason: (optional) }], notes: (optional),
|
|
30
|
+
# customerEmail: (optional), reasonId: (optional) }
|
|
26
31
|
def create(request)
|
|
27
|
-
@http.post("#{ws_path}/returns", body: request)
|
|
32
|
+
@http.post("#{ws_path}/returns/rma", body: request)
|
|
28
33
|
end
|
|
29
34
|
|
|
30
|
-
|
|
31
|
-
|
|
35
|
+
# Generates a return label for an RMA.
|
|
36
|
+
#
|
|
37
|
+
# The carrier needs the package weight and all three dimensions: pass weight_oz, length_in,
|
|
38
|
+
# width_in and height_in. Anything left out comes from the workspace's default shipping
|
|
39
|
+
# preset; with no preset the Gateway answers 400 PackageMeasurementsRequired naming the
|
|
40
|
+
# missing fields.
|
|
41
|
+
#
|
|
42
|
+
# Only what you set is sent; the Gateway uses "usps" when carrier_code is left out.
|
|
43
|
+
# hazmat_classification (a HazmatClassification) declares what the return holds.
|
|
44
|
+
def generate_label(rma_number, weight_oz: nil, length_in: nil, width_in: nil, height_in: nil,
|
|
45
|
+
carrier_code: nil, service_type: nil, payment_confirmed: nil, hazmat_classification: nil,
|
|
46
|
+
rate_indicator: nil)
|
|
47
|
+
# Always a JSON object: the Gateway binds a required GenerateReturnLabelRequest body and refuses an empty one with 400.
|
|
48
|
+
body = {
|
|
49
|
+
carrierCode: carrier_code, serviceType: service_type, weightOz: weight_oz, lengthIn: length_in,
|
|
50
|
+
widthIn: width_in, heightIn: height_in, paymentConfirmed: payment_confirmed,
|
|
51
|
+
hazmatClassification: hazmat_classification, rateIndicator: rate_indicator
|
|
52
|
+
}.compact
|
|
53
|
+
@http.post("#{ws_path}/returns/#{rma_number}/label", body: body)
|
|
32
54
|
end
|
|
33
55
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
@http.post("#{ws_path}/returns/#{
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def mark_received(return_id, items)
|
|
47
|
-
@http.post("#{ws_path}/returns/#{return_id}/receive", body: { items: items })
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def process_refund(return_id)
|
|
51
|
-
@http.post("#{ws_path}/returns/#{return_id}/refund")
|
|
56
|
+
# Records the inspected items of a received RMA.
|
|
57
|
+
#
|
|
58
|
+
# rma_id:: the numeric RMA id (the route segment)
|
|
59
|
+
# rma_number:: the RMA number, for example "RMA-1001"
|
|
60
|
+
# inspected_items:: [{ partNumber:, partDescription: (optional), quantity:,
|
|
61
|
+
# disposition: 0 restock, 1 dispose or 2 forward, inspectionNotes: (optional) }]
|
|
62
|
+
# warehouse_id:: the receiving warehouse; the Gateway uses 1 when it is left out
|
|
63
|
+
def mark_received(rma_id, rma_number:, inspected_items:, warehouse_id: nil)
|
|
64
|
+
body = { rmaNumber: rma_number, warehouseId: warehouse_id, inspectedItems: inspected_items }.compact
|
|
65
|
+
@http.post("#{ws_path}/returns/#{rma_id}/receive", body: body)
|
|
52
66
|
end
|
|
53
67
|
|
|
54
68
|
private
|
|
@@ -22,10 +22,13 @@ module FlexOps
|
|
|
22
22
|
@http.get("#{ws_path}/shipping-rules/#{rule_id}")
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
# rule:: { name:, priority:, isEnabled:, warehouseId: (optional),
|
|
26
|
+
# conditions: [{ field:, operator:, value:, group: (optional) }], actions: [{ type:, value: }] }
|
|
25
27
|
def create(rule)
|
|
26
28
|
@http.post("#{ws_path}/shipping-rules", body: rule)
|
|
27
29
|
end
|
|
28
30
|
|
|
31
|
+
# rule:: the fields to change, named as for #create.
|
|
29
32
|
def update(rule_id, rule)
|
|
30
33
|
@http.put("#{ws_path}/shipping-rules/#{rule_id}", body: rule)
|
|
31
34
|
end
|
|
@@ -34,8 +37,21 @@ module FlexOps
|
|
|
34
37
|
@http.delete("#{ws_path}/shipping-rules/#{rule_id}")
|
|
35
38
|
end
|
|
36
39
|
|
|
37
|
-
|
|
38
|
-
|
|
40
|
+
# Sets rule priorities. +rules+ is an array of { ruleId:, priority: } hashes, or of rule ids
|
|
41
|
+
# in the order to apply (the first gets priority 0).
|
|
42
|
+
def reorder(rules)
|
|
43
|
+
items = rules.each_with_index.map do |rule, index|
|
|
44
|
+
rule.is_a?(Hash) ? rule : { ruleId: rule, priority: index }
|
|
45
|
+
end
|
|
46
|
+
@http.post("#{ws_path}/shipping-rules/reorder", body: { rules: items })
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Evaluates the workspace's rules against a sample shipment without changing anything.
|
|
50
|
+
#
|
|
51
|
+
# request:: { weightOz:, declaredValue:, destinationCountry:, destinationState:,
|
|
52
|
+
# destinationPostalCode:, orderSource:, itemCount:, shipMethod:, destinationZone:, tags: }
|
|
53
|
+
def test(request)
|
|
54
|
+
@http.post("#{ws_path}/shipping-rules/test", body: request)
|
|
39
55
|
end
|
|
40
56
|
|
|
41
57
|
private
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
# Copyright (c) 2021-2026 by FlexOps, LLC. All rights reserved.
|
|
7
7
|
# ***********************************************************************
|
|
8
8
|
|
|
9
|
+
require "erb"
|
|
10
|
+
|
|
9
11
|
module FlexOps
|
|
10
12
|
module Resources
|
|
11
13
|
class Shipping
|
|
@@ -27,24 +29,38 @@ module FlexOps
|
|
|
27
29
|
end
|
|
28
30
|
|
|
29
31
|
# Returns a raw preview or label; confirmation always belongs to the caller.
|
|
32
|
+
# A label without an orderId must declare hazmatClassification (a HazmatClassification;
|
|
33
|
+
# HazmatClassification::NONE for an ordinary parcel). The SDK never fills it in.
|
|
30
34
|
def create_label(request = {}, idempotency_key: nil, **fields)
|
|
31
35
|
request = request.merge(fields)
|
|
32
|
-
@http.post("
|
|
36
|
+
@http.post("/api/shipping/labels", body: request,
|
|
33
37
|
headers: idempotency_key.nil? ? {} : { "Idempotency-Key" => idempotency_key })
|
|
34
38
|
end
|
|
35
39
|
|
|
36
|
-
|
|
37
|
-
|
|
40
|
+
# The Gateway requires the carrier code (for example "USPS"). It is a keyword with no
|
|
41
|
+
# default value so older call sites fail with a clear ArgumentError before any request.
|
|
42
|
+
def cancel_label(label_id, carrier_code: nil)
|
|
43
|
+
raise ArgumentError, "carrier_code is required to cancel a label (for example \"USPS\")" if carrier_code.to_s.empty?
|
|
44
|
+
|
|
45
|
+
@http.delete("/api/shipping/labels/#{escape(label_id)}?carrierCode=#{escape(carrier_code)}")
|
|
38
46
|
end
|
|
39
47
|
|
|
40
48
|
def track(tracking_number)
|
|
41
|
-
@http.get("
|
|
49
|
+
@http.get("/api/shipping/track/#{escape(tracking_number)}")
|
|
42
50
|
end
|
|
43
51
|
|
|
52
|
+
# Validates an address with POST /api/addresses/validate and returns the bare result
|
|
53
|
+
# ("isValid", "validationUnavailable", "suggestedAddress", "isResidential", "warnings",
|
|
54
|
+
# "validatedBy", "checkedAt").
|
|
55
|
+
#
|
|
56
|
+
# The address may use the rate and label field names (addressLine1, stateProvince,
|
|
57
|
+
# countryCode, ...), the legacy street1/state/zip names, or the Gateway's own
|
|
58
|
+
# (line1, line2, city, state, postalCode, country). Only those fields and name are sent.
|
|
44
59
|
def validate_address(address)
|
|
45
|
-
@http.post("
|
|
60
|
+
@http.post("/api/addresses/validate", body: to_validation_address(address))
|
|
46
61
|
end
|
|
47
62
|
|
|
63
|
+
# The payload is sent as given. Set hazmatClassification on an item only when you have a declaration.
|
|
48
64
|
def create_batch(request)
|
|
49
65
|
@http.post("#{ws_path}/labels/batch", body: request)
|
|
50
66
|
end
|
|
@@ -62,19 +78,19 @@ module FlexOps
|
|
|
62
78
|
end
|
|
63
79
|
|
|
64
80
|
def get_carriers
|
|
65
|
-
@http.get("
|
|
81
|
+
@http.get("/api/shipping/carriers")
|
|
66
82
|
end
|
|
67
83
|
|
|
68
84
|
def get_recommendations(request)
|
|
69
|
-
@http.post("
|
|
85
|
+
@http.post("/api/shipping/recommendations", body: request)
|
|
70
86
|
end
|
|
71
87
|
|
|
72
88
|
def predict_delivery(request)
|
|
73
|
-
@http.post("
|
|
89
|
+
@http.post("/api/shipping/predictions/delivery", body: request)
|
|
74
90
|
end
|
|
75
91
|
|
|
76
92
|
def get_savings
|
|
77
|
-
@http.get("
|
|
93
|
+
@http.get("/api/shipping/savings")
|
|
78
94
|
end
|
|
79
95
|
|
|
80
96
|
private
|
|
@@ -82,6 +98,28 @@ module FlexOps
|
|
|
82
98
|
def ws_path
|
|
83
99
|
"/api/workspaces/#{@ws_id.call}"
|
|
84
100
|
end
|
|
101
|
+
|
|
102
|
+
def escape(segment)
|
|
103
|
+
ERB::Util.url_encode(segment.to_s)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Gateway AddressValidationRequest field for each accepted address field name.
|
|
107
|
+
VALIDATION_ADDRESS_FIELDS = {
|
|
108
|
+
"name" => "name",
|
|
109
|
+
"line1" => "line1", "addressLine1" => "line1", "street1" => "line1",
|
|
110
|
+
"line2" => "line2", "addressLine2" => "line2", "street2" => "line2",
|
|
111
|
+
"city" => "city",
|
|
112
|
+
"state" => "state", "stateProvince" => "state",
|
|
113
|
+
"postalCode" => "postalCode", "zip" => "postalCode",
|
|
114
|
+
"country" => "country", "countryCode" => "country"
|
|
115
|
+
}.freeze
|
|
116
|
+
|
|
117
|
+
def to_validation_address(address)
|
|
118
|
+
address.each_with_object({}) do |(key, value), mapped|
|
|
119
|
+
field = VALIDATION_ADDRESS_FIELDS[key.to_s]
|
|
120
|
+
mapped[field] = value unless field.nil? || value.nil?
|
|
121
|
+
end
|
|
122
|
+
end
|
|
85
123
|
end
|
|
86
124
|
end
|
|
87
125
|
end
|
|
@@ -14,27 +14,11 @@ module FlexOps
|
|
|
14
14
|
@ws_id = ws_id_proc
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
# Returns the configured workspace's balance, bare: { "hasWallet", "balanceCents", "balance" }.
|
|
18
|
+
# Funding and transaction history are managed in the FlexOps portal.
|
|
17
19
|
def get_balance
|
|
18
|
-
@
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def add_funds(amount)
|
|
22
|
-
@http.post("#{ws_path}/wallet/add-funds", body: { amount: amount })
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def list_transactions(page: nil, page_size: nil, start_date: nil, end_date: nil)
|
|
26
|
-
query = { page: page, pageSize: page_size, startDate: start_date, endDate: end_date }.compact
|
|
27
|
-
@http.get("#{ws_path}/wallet/transactions", query: query.empty? ? nil : query)
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def configure_auto_reload(enabled:, threshold: nil, amount: nil)
|
|
31
|
-
@http.put("#{ws_path}/wallet/auto-reload", body: { enabled: enabled, threshold: threshold, amount: amount }.compact)
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
private
|
|
35
|
-
|
|
36
|
-
def ws_path
|
|
37
|
-
"/api/workspaces/#{@ws_id.call}"
|
|
20
|
+
ws_id = @ws_id.call
|
|
21
|
+
@http.get("/api/wallet/my-balance", headers: ws_id.to_s.empty? ? {} : { "X-Workspace-Id" => ws_id.to_s })
|
|
38
22
|
end
|
|
39
23
|
end
|
|
40
24
|
end
|
|
@@ -20,16 +20,30 @@ module FlexOps
|
|
|
20
20
|
@http.get("#{ws_path}/webhooks")
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
# Creates a webhook subscription.
|
|
24
|
+
#
|
|
25
|
+
# request:: a hash with +url+, optional +description+ and optional +events+, an
|
|
26
|
+
# array of FlexOps::WebhookEventType names (strings or symbols). +events+
|
|
27
|
+
# is sent as the Gateway's comma-separated +eventTypes+; leave it out to
|
|
28
|
+
# receive every event. A raw +eventTypes+ string ("*" or comma-separated
|
|
29
|
+
# names, no spaces) is sent unchanged instead.
|
|
30
|
+
#
|
|
31
|
+
# Raises ArgumentError, before any request, when +events+ is empty, is not an
|
|
32
|
+
# array, names an event FlexOps does not send, or is given with +eventTypes+.
|
|
33
|
+
#
|
|
34
|
+
# Returns the new subscription itself ("id", "url", "eventTypes", "status",
|
|
35
|
+
# "secret", ...), not a success/data envelope: the Gateway answers 201 with the
|
|
36
|
+
# bare subscription. An enveloped answer is unwrapped, so "secret" (the whsec_...
|
|
37
|
+
# signing secret, returned only here) is always at the top level.
|
|
27
38
|
def create(request)
|
|
28
|
-
@http.post("#{ws_path}/webhooks", body: request)
|
|
39
|
+
unwrap_subscription(@http.post("#{ws_path}/webhooks", body: to_wire_request(request)))
|
|
29
40
|
end
|
|
30
41
|
|
|
42
|
+
# Updates a webhook subscription. +data+ takes +url+, +description+, +status+
|
|
43
|
+
# ("Active", "Paused" or "Disabled") and +events+, which replaces the subscribed
|
|
44
|
+
# events and is sent as +eventTypes+. Raises ArgumentError as #create does.
|
|
31
45
|
def update(webhook_id, data)
|
|
32
|
-
@http.put("#{ws_path}/webhooks/#{webhook_id}", body: data)
|
|
46
|
+
@http.put("#{ws_path}/webhooks/#{webhook_id}", body: to_wire_request(data))
|
|
33
47
|
end
|
|
34
48
|
|
|
35
49
|
def delete(webhook_id)
|
|
@@ -44,9 +58,74 @@ module FlexOps
|
|
|
44
58
|
@http.get("#{ws_path}/webhooks/#{webhook_id}/deliveries")
|
|
45
59
|
end
|
|
46
60
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
61
|
+
# Sends a test event (X-FlexOps-Event "test") to the subscription's URL.
|
|
62
|
+
def test(webhook_id)
|
|
63
|
+
@http.post("#{ws_path}/webhooks/#{webhook_id}/test")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Headers FlexOps sends with every webhook delivery.
|
|
67
|
+
EVENT_HEADER = "X-FlexOps-Event" # event name; "test" for a test event
|
|
68
|
+
EVENT_ID_HEADER = "X-FlexOps-Event-Id"
|
|
69
|
+
TIMESTAMP_HEADER = "X-FlexOps-Timestamp" # Unix seconds, part of the signed string
|
|
70
|
+
SIGNATURE_HEADER = "X-FlexOps-Signature" # "sha256=" + lowercase hex HMAC-SHA256
|
|
71
|
+
DELIVERY_HEADER = "X-FlexOps-Delivery"
|
|
72
|
+
|
|
73
|
+
# Seconds the X-FlexOps-Timestamp may be from now, either way.
|
|
74
|
+
DEFAULT_TOLERANCE = 300
|
|
75
|
+
|
|
76
|
+
SIGNATURE_PREFIX = "sha256="
|
|
77
|
+
private_constant :SIGNATURE_PREFIX
|
|
78
|
+
|
|
79
|
+
# Returns true when a FlexOps webhook delivery is authentic and fresh,
|
|
80
|
+
# false otherwise. See verify_signature! for the arguments.
|
|
81
|
+
def self.verify_signature(payload:, signature:, timestamp:, secret:, tolerance: DEFAULT_TOLERANCE, now: Time.now)
|
|
82
|
+
verify_signature!(payload: payload, signature: signature, timestamp: timestamp,
|
|
83
|
+
secret: secret, tolerance: tolerance, now: now)
|
|
84
|
+
rescue FlexOps::WebhookSignatureError
|
|
85
|
+
false
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Verifies a FlexOps webhook delivery and returns true, or raises
|
|
89
|
+
# FlexOps::WebhookSignatureError saying why.
|
|
90
|
+
#
|
|
91
|
+
# payload:: the raw request body exactly as received (verify before parsing it)
|
|
92
|
+
# signature:: the X-FlexOps-Signature value, "sha256=<hex>" (bare hex is accepted)
|
|
93
|
+
# timestamp:: the X-FlexOps-Timestamp value, Unix seconds
|
|
94
|
+
# secret:: the subscription secret ("whsec_..."), used as-is
|
|
95
|
+
# tolerance:: seconds the timestamp may be from +now+; non-positive keeps the default
|
|
96
|
+
# now:: the current time (a Time or Unix seconds), for tests
|
|
97
|
+
#
|
|
98
|
+
# FlexOps signs "{timestamp}.{body}" with HMAC-SHA256; the comparison is
|
|
99
|
+
# constant time.
|
|
100
|
+
def self.verify_signature!(payload:, signature:, timestamp:, secret:, tolerance: DEFAULT_TOLERANCE, now: Time.now)
|
|
101
|
+
if secret.nil? || secret.to_s.empty?
|
|
102
|
+
raise FlexOps::WebhookSignatureError.new("Webhook secret is empty", code: "WEBHOOK_MISSING_SECRET")
|
|
103
|
+
end
|
|
104
|
+
if signature.nil? || signature.to_s.empty?
|
|
105
|
+
raise FlexOps::WebhookSignatureError.new("Webhook signature header is missing", code: "WEBHOOK_MISSING_SIGNATURE")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
timestamp = timestamp.to_s
|
|
109
|
+
unless timestamp.match?(/\A\d+\z/)
|
|
110
|
+
raise FlexOps::WebhookSignatureError.new("Webhook timestamp header is missing or not Unix seconds",
|
|
111
|
+
code: "WEBHOOK_INVALID_TIMESTAMP")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
tolerance = DEFAULT_TOLERANCE unless tolerance.is_a?(Numeric) && tolerance.positive?
|
|
115
|
+
if (now.to_i - timestamp.to_i).abs > tolerance
|
|
116
|
+
raise FlexOps::WebhookSignatureError.new("Webhook timestamp is outside the tolerance",
|
|
117
|
+
code: "WEBHOOK_TIMESTAMP_OUTSIDE_TOLERANCE")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
signed = "#{timestamp}.".b << payload.to_s.b
|
|
121
|
+
expected = OpenSSL::HMAC.hexdigest("SHA256", secret.to_s, signed)
|
|
122
|
+
received = signature.to_s.delete_prefix(SIGNATURE_PREFIX).downcase
|
|
123
|
+
# OpenSSL.secure_compare hashes both sides first, so unequal lengths are safe.
|
|
124
|
+
unless OpenSSL.secure_compare(expected, received)
|
|
125
|
+
raise FlexOps::WebhookSignatureError.new("Webhook signature does not match", code: "WEBHOOK_SIGNATURE_MISMATCH")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
true
|
|
50
129
|
end
|
|
51
130
|
|
|
52
131
|
private
|
|
@@ -54,6 +133,50 @@ module FlexOps
|
|
|
54
133
|
def ws_path
|
|
55
134
|
"/api/workspaces/#{@ws_id.call}"
|
|
56
135
|
end
|
|
136
|
+
|
|
137
|
+
# The Gateway reads a comma-separated eventTypes string, ignores an events field
|
|
138
|
+
# (subscribing to every event) and matches each name exactly, so a name it never
|
|
139
|
+
# sends would leave the subscription silent; it is refused here instead.
|
|
140
|
+
def to_wire_request(request)
|
|
141
|
+
return request unless request.is_a?(Hash)
|
|
142
|
+
|
|
143
|
+
events_key = [:events, "events"].find { |key| request.key?(key) }
|
|
144
|
+
return request if events_key.nil?
|
|
145
|
+
|
|
146
|
+
body = request.reject { |key, _| key == events_key }
|
|
147
|
+
events = request[events_key]
|
|
148
|
+
return body if events.nil?
|
|
149
|
+
|
|
150
|
+
if [:eventTypes, "eventTypes"].any? { |key| body.key?(key) }
|
|
151
|
+
raise ArgumentError, "Pass events or eventTypes, not both."
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
body.merge(eventTypes: to_event_types(events))
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def unwrap_subscription(response)
|
|
158
|
+
return response unless response.is_a?(Hash) && response.key?("success") && response["data"].is_a?(Hash)
|
|
159
|
+
|
|
160
|
+
response["data"]
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def to_event_types(events)
|
|
164
|
+
unless events.is_a?(Array)
|
|
165
|
+
raise ArgumentError, "events must be an array of event names, not #{events.class}."
|
|
166
|
+
end
|
|
167
|
+
if events.empty?
|
|
168
|
+
raise ArgumentError, "events must name at least one event; leave it out to receive every event."
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
names = events.map { |event| event.is_a?(String) || event.is_a?(Symbol) ? event.to_s : event.inspect }.uniq
|
|
172
|
+
unknown = names - FlexOps::WebhookEventType::ALL
|
|
173
|
+
unless unknown.empty?
|
|
174
|
+
raise ArgumentError, "Unknown webhook event: #{unknown.join(", ")}. " \
|
|
175
|
+
"FlexOps sends #{FlexOps::WebhookEventType::ALL.join(", ")}."
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
names.join(",")
|
|
179
|
+
end
|
|
57
180
|
end
|
|
58
181
|
end
|
|
59
182
|
end
|
|
@@ -23,30 +23,25 @@ module FlexOps
|
|
|
23
23
|
@http.get("/api/workspaces/#{id}")
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
def create(request)
|
|
27
|
-
@http.post("/api/workspaces", body: request)
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def update(data)
|
|
31
|
-
@http.put("/api/workspaces/#{@ws_id.call}", body: data)
|
|
32
|
-
end
|
|
33
|
-
|
|
34
26
|
def list_members
|
|
35
27
|
@http.get("/api/workspaces/#{@ws_id.call}/members")
|
|
36
28
|
end
|
|
37
29
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
@http.post("/api/workspaces/#{@ws_id.call}/members/invite",
|
|
30
|
+
# Invites a member. +role+ is a FlexOps::WorkspaceRole number or name (for example
|
|
31
|
+
# :member); it is required because the Gateway reads a missing role as Owner.
|
|
32
|
+
def invite_member(email:, role:)
|
|
33
|
+
@http.post("/api/workspaces/#{@ws_id.call}/members/invite",
|
|
34
|
+
body: { email: email, role: FlexOps::WorkspaceRole.to_wire(role) })
|
|
42
35
|
end
|
|
43
36
|
|
|
44
37
|
def remove_member(user_id)
|
|
45
38
|
@http.delete("/api/workspaces/#{@ws_id.call}/members/#{user_id}")
|
|
46
39
|
end
|
|
47
40
|
|
|
41
|
+
# Changes a member's role; +role+ is a FlexOps::WorkspaceRole number or name.
|
|
48
42
|
def update_member_role(user_id, role)
|
|
49
|
-
@http.put("/api/workspaces/#{@ws_id.call}/members/#{user_id}/role",
|
|
43
|
+
@http.put("/api/workspaces/#{@ws_id.call}/members/#{user_id}/role",
|
|
44
|
+
body: { newRole: FlexOps::WorkspaceRole.to_wire(role) })
|
|
50
45
|
end
|
|
51
46
|
end
|
|
52
47
|
end
|
data/lib/flexops/version.rb
CHANGED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# ***********************************************************************
|
|
2
|
+
# Package : flexops
|
|
3
|
+
# Author : FlexOps, LLC
|
|
4
|
+
# Created : 2026-10-16
|
|
5
|
+
#
|
|
6
|
+
# Copyright (c) 2021-2026 by FlexOps, LLC. All rights reserved.
|
|
7
|
+
# ***********************************************************************
|
|
8
|
+
|
|
9
|
+
module FlexOps
|
|
10
|
+
# Event names FlexOps sends in the X-FlexOps-Event header and matches webhook
|
|
11
|
+
# subscriptions against.
|
|
12
|
+
#
|
|
13
|
+
# Names are PascalCase and matched exactly: a dotted name such as "label.created"
|
|
14
|
+
# matches nothing. TRACKING_UPDATED and TRACKING_DELIVERED are sent only to
|
|
15
|
+
# workspaces in the USPS push-tracking pilot.
|
|
16
|
+
module WebhookEventType
|
|
17
|
+
LABEL_CREATED = "LabelCreated"
|
|
18
|
+
LABEL_CANCELED = "LabelCanceled"
|
|
19
|
+
BATCH_COMPLETED = "BatchCompleted"
|
|
20
|
+
TRACKING_UPDATED = "TrackingUpdated"
|
|
21
|
+
TRACKING_DELIVERED = "TrackingDelivered"
|
|
22
|
+
|
|
23
|
+
ALL = [LABEL_CREATED, LABEL_CANCELED, BATCH_COMPLETED, TRACKING_UPDATED, TRACKING_DELIVERED].freeze
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
# Workspace member role, sent as the Gateway WorkspaceRole number.
|
|
11
|
+
module WorkspaceRole
|
|
12
|
+
OWNER = 0
|
|
13
|
+
ADMIN = 1
|
|
14
|
+
MEMBER = 2
|
|
15
|
+
MERCHANT = 3
|
|
16
|
+
|
|
17
|
+
ALL = { OWNER: OWNER, ADMIN: ADMIN, MEMBER: MEMBER, MERCHANT: MERCHANT }.freeze
|
|
18
|
+
|
|
19
|
+
# Returns the wire number for a role given as a number or a name (:member, "Admin").
|
|
20
|
+
# Raises ArgumentError, before any request, for anything else.
|
|
21
|
+
def self.to_wire(role)
|
|
22
|
+
return role if role.is_a?(Integer) && ALL.value?(role)
|
|
23
|
+
|
|
24
|
+
if role.is_a?(String) || role.is_a?(Symbol)
|
|
25
|
+
number = ALL[role.to_s.upcase.to_sym]
|
|
26
|
+
return number unless number.nil?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
raise ArgumentError, "Unknown workspace role: #{role.inspect}. Use one of #{ALL.keys.join(", ")} or 0-3."
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
data/lib/flexops.rb
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
|
|
9
9
|
require_relative "flexops/version"
|
|
10
10
|
require_relative "flexops/errors"
|
|
11
|
+
require_relative "flexops/hazmat_classification"
|
|
12
|
+
require_relative "flexops/webhook_event_type"
|
|
13
|
+
require_relative "flexops/workspace_role"
|
|
11
14
|
require_relative "flexops/http"
|
|
12
15
|
require_relative "flexops/client"
|
|
13
16
|
require_relative "flexops/resources/auth"
|
|
@@ -18,18 +21,17 @@ require_relative "flexops/resources/webhooks"
|
|
|
18
21
|
require_relative "flexops/resources/wallet"
|
|
19
22
|
require_relative "flexops/resources/insurance"
|
|
20
23
|
require_relative "flexops/resources/returns"
|
|
21
|
-
require_relative "flexops/resources/api_keys"
|
|
22
24
|
require_relative "flexops/resources/analytics"
|
|
23
25
|
require_relative "flexops/resources/orders"
|
|
24
26
|
require_relative "flexops/resources/inventory"
|
|
25
27
|
require_relative "flexops/resources/pickups"
|
|
26
28
|
require_relative "flexops/resources/scan_forms"
|
|
27
29
|
require_relative "flexops/resources/rules"
|
|
28
|
-
require_relative "flexops/resources/offsets"
|
|
29
30
|
require_relative "flexops/resources/hs_codes"
|
|
31
|
+
require_relative "flexops/resources/carbon_offsets"
|
|
30
32
|
require_relative "flexops/resources/recurring_shipments"
|
|
33
|
+
require_relative "flexops/resources/report_schedules"
|
|
31
34
|
require_relative "flexops/resources/email_templates"
|
|
32
|
-
require_relative "flexops/resources/reports"
|
|
33
35
|
|
|
34
36
|
module FlexOps
|
|
35
37
|
end
|