qube_sync 0.3.0 → 0.4.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/CHANGELOG.md +48 -0
- data/README.md +235 -75
- data/Rakefile +12 -0
- data/lib/qube_sync/client.rb +579 -0
- data/lib/qube_sync/operations/account.rb +337 -0
- data/lib/qube_sync/operations/account_tax_line_info.rb +30 -0
- data/lib/qube_sync/operations/aging_report.rb +160 -0
- data/lib/qube_sync/operations/alert.rb +91 -0
- data/lib/qube_sync/operations/ar_refund_credit_card.rb +399 -0
- data/lib/qube_sync/operations/bar_code.rb +37 -0
- data/lib/qube_sync/operations/bill.rb +1073 -0
- data/lib/qube_sync/operations/bill_payment_check.rb +413 -0
- data/lib/qube_sync/operations/bill_payment_credit_card.rb +315 -0
- data/lib/qube_sync/operations/bill_to_pay.rb +66 -0
- data/lib/qube_sync/operations/billing_rate.rb +172 -0
- data/lib/qube_sync/operations/budget_summary_report.rb +80 -0
- data/lib/qube_sync/operations/build_assembly.rb +318 -0
- data/lib/qube_sync/operations/charge.rb +452 -0
- data/lib/qube_sync/operations/check.rb +1055 -0
- data/lib/qube_sync/operations/class.rb +176 -0
- data/lib/qube_sync/operations/cleared_status.rb +39 -0
- data/lib/qube_sync/operations/company.rb +39 -0
- data/lib/qube_sync/operations/company_activity.rb +31 -0
- data/lib/qube_sync/operations/credit_card_charge.rb +1000 -0
- data/lib/qube_sync/operations/credit_card_credit.rb +1000 -0
- data/lib/qube_sync/operations/credit_memo.rb +1243 -0
- data/lib/qube_sync/operations/currency.rb +195 -0
- data/lib/qube_sync/operations/custom_detail_report.rb +176 -0
- data/lib/qube_sync/operations/custom_summary_report.rb +180 -0
- data/lib/qube_sync/operations/customer.rb +744 -0
- data/lib/qube_sync/operations/customer_msg.rb +117 -0
- data/lib/qube_sync/operations/customer_type.rb +133 -0
- data/lib/qube_sync/operations/data_event_recovery_info.rb +54 -0
- data/lib/qube_sync/operations/data_ext.rb +201 -0
- data/lib/qube_sync/operations/data_ext_def.rb +179 -0
- data/lib/qube_sync/operations/date_driven_terms.rb +131 -0
- data/lib/qube_sync/operations/deposit.rb +555 -0
- data/lib/qube_sync/operations/employee.rb +577 -0
- data/lib/qube_sync/operations/employee_default.rb +100 -0
- data/lib/qube_sync/operations/entity.rb +118 -0
- data/lib/qube_sync/operations/estimate.rb +1055 -0
- data/lib/qube_sync/operations/form1099_category_account_mapping.rb +90 -0
- data/lib/qube_sync/operations/general_detail_report.rb +185 -0
- data/lib/qube_sync/operations/general_summary_report.rb +180 -0
- data/lib/qube_sync/operations/host.rb +36 -0
- data/lib/qube_sync/operations/inventory_adjustment.rb +482 -0
- data/lib/qube_sync/operations/inventory_site.rb +214 -0
- data/lib/qube_sync/operations/invoice.rb +1208 -0
- data/lib/qube_sync/operations/item.rb +115 -0
- data/lib/qube_sync/operations/item_assemblies_can_build.rb +44 -0
- data/lib/qube_sync/operations/item_discount.rb +319 -0
- data/lib/qube_sync/operations/item_fixed_asset.rb +317 -0
- data/lib/qube_sync/operations/item_group.rb +253 -0
- data/lib/qube_sync/operations/item_inventory.rb +446 -0
- data/lib/qube_sync/operations/item_inventory_assembly.rb +483 -0
- data/lib/qube_sync/operations/item_non_inventory.rb +461 -0
- data/lib/qube_sync/operations/item_other_charge.rb +434 -0
- data/lib/qube_sync/operations/item_payment.rb +268 -0
- data/lib/qube_sync/operations/item_receipt.rb +1011 -0
- data/lib/qube_sync/operations/item_sales_tax.rb +270 -0
- data/lib/qube_sync/operations/item_sales_tax_group.rb +200 -0
- data/lib/qube_sync/operations/item_service.rb +455 -0
- data/lib/qube_sync/operations/item_sites.rb +96 -0
- data/lib/qube_sync/operations/item_subtotal.rb +182 -0
- data/lib/qube_sync/operations/job_report.rb +156 -0
- data/lib/qube_sync/operations/job_type.rb +131 -0
- data/lib/qube_sync/operations/journal_entry.rb +518 -0
- data/lib/qube_sync/operations/lead.rb +216 -0
- data/lib/qube_sync/operations/list.rb +42 -0
- data/lib/qube_sync/operations/list_deleted.rb +57 -0
- data/lib/qube_sync/operations/list_display.rb +59 -0
- data/lib/qube_sync/operations/list_merge.rb +55 -0
- data/lib/qube_sync/operations/other_name.rb +245 -0
- data/lib/qube_sync/operations/paycheck.rb +139 -0
- data/lib/qube_sync/operations/payment_method.rb +128 -0
- data/lib/qube_sync/operations/payroll_detail_report.rb +161 -0
- data/lib/qube_sync/operations/payroll_item.rb +75 -0
- data/lib/qube_sync/operations/payroll_item_addition.rb +98 -0
- data/lib/qube_sync/operations/payroll_item_company_contribution.rb +134 -0
- data/lib/qube_sync/operations/payroll_item_deduction.rb +124 -0
- data/lib/qube_sync/operations/payroll_item_direct_deposit.rb +70 -0
- data/lib/qube_sync/operations/payroll_item_non_wage.rb +93 -0
- data/lib/qube_sync/operations/payroll_item_tax.rb +123 -0
- data/lib/qube_sync/operations/payroll_item_wage.rb +168 -0
- data/lib/qube_sync/operations/payroll_item_workers_compensation.rb +108 -0
- data/lib/qube_sync/operations/payroll_last_period.rb +30 -0
- data/lib/qube_sync/operations/payroll_liability_adjustment.rb +204 -0
- data/lib/qube_sync/operations/payroll_liability_check.rb +137 -0
- data/lib/qube_sync/operations/payroll_prior_payment.rb +178 -0
- data/lib/qube_sync/operations/payroll_summary_report.rb +157 -0
- data/lib/qube_sync/operations/payroll_year_to_date_adjustment.rb +211 -0
- data/lib/qube_sync/operations/preferences.rb +61 -0
- data/lib/qube_sync/operations/price_level.rb +305 -0
- data/lib/qube_sync/operations/purchase_order.rb +1038 -0
- data/lib/qube_sync/operations/qb_command_exec.rb +37 -0
- data/lib/qube_sync/operations/receive_payment.rb +627 -0
- data/lib/qube_sync/operations/receive_payment_to_deposit.rb +38 -0
- data/lib/qube_sync/operations/sales_order.rb +1045 -0
- data/lib/qube_sync/operations/sales_receipt.rb +1337 -0
- data/lib/qube_sync/operations/sales_rep.rb +172 -0
- data/lib/qube_sync/operations/sales_tax_code.rb +205 -0
- data/lib/qube_sync/operations/sales_tax_payable.rb +37 -0
- data/lib/qube_sync/operations/sales_tax_payment_check.rb +375 -0
- data/lib/qube_sync/operations/sales_tax_return.rb +35 -0
- data/lib/qube_sync/operations/sales_tax_return_line.rb +43 -0
- data/lib/qube_sync/operations/ship_method.rb +116 -0
- data/lib/qube_sync/operations/special_account.rb +55 -0
- data/lib/qube_sync/operations/special_item.rb +44 -0
- data/lib/qube_sync/operations/standard_terms.rb +125 -0
- data/lib/qube_sync/operations/template.rb +36 -0
- data/lib/qube_sync/operations/terms.rb +91 -0
- data/lib/qube_sync/operations/time_report.rb +127 -0
- data/lib/qube_sync/operations/time_tracking.rb +303 -0
- data/lib/qube_sync/operations/to_do.rb +260 -0
- data/lib/qube_sync/operations/transaction.rb +356 -0
- data/lib/qube_sync/operations/transfer.rb +213 -0
- data/lib/qube_sync/operations/transfer_inventory.rb +384 -0
- data/lib/qube_sync/operations/txn.rb +38 -0
- data/lib/qube_sync/operations/txn_deleted.rb +54 -0
- data/lib/qube_sync/operations/txn_display.rb +76 -0
- data/lib/qube_sync/operations/txn_void.rb +36 -0
- data/lib/qube_sync/operations/unit_of_measure_set.rb +149 -0
- data/lib/qube_sync/operations/vehicle.rb +152 -0
- data/lib/qube_sync/operations/vehicle_mileage.rb +191 -0
- data/lib/qube_sync/operations/vendor.rb +661 -0
- data/lib/qube_sync/operations/vendor_credit.rb +1008 -0
- data/lib/qube_sync/operations/vendor_type.rb +131 -0
- data/lib/qube_sync/operations/workers_comp_code.rb +184 -0
- data/lib/qube_sync/operations.rb +1165 -0
- data/lib/qube_sync/version.rb +1 -1
- data/lib/qube_sync.rb +1 -0
- data/sig/qube_sync/client.rbs +82 -0
- data/sig/qube_sync/operations/account.rbs +65 -0
- data/sig/qube_sync/operations/account_tax_line_info.rbs +13 -0
- data/sig/qube_sync/operations/aging_report.rbs +29 -0
- data/sig/qube_sync/operations/alert.rbs +53 -0
- data/sig/qube_sync/operations/ar_refund_credit_card.rbs +53 -0
- data/sig/qube_sync/operations/bar_code.rbs +16 -0
- data/sig/qube_sync/operations/bill.rbs +78 -0
- data/sig/qube_sync/operations/bill_payment_check.rbs +62 -0
- data/sig/qube_sync/operations/bill_payment_credit_card.rbs +45 -0
- data/sig/qube_sync/operations/bill_to_pay.rbs +19 -0
- data/sig/qube_sync/operations/billing_rate.rbs +40 -0
- data/sig/qube_sync/operations/budget_summary_report.rbs +22 -0
- data/sig/qube_sync/operations/build_assembly.rbs +64 -0
- data/sig/qube_sync/operations/charge.rbs +80 -0
- data/sig/qube_sync/operations/check.rbs +81 -0
- data/sig/qube_sync/operations/class.rbs +43 -0
- data/sig/qube_sync/operations/cleared_status.rbs +16 -0
- data/sig/qube_sync/operations/company.rbs +15 -0
- data/sig/qube_sync/operations/company_activity.rbs +14 -0
- data/sig/qube_sync/operations/credit_card_charge.rbs +69 -0
- data/sig/qube_sync/operations/credit_card_credit.rbs +69 -0
- data/sig/qube_sync/operations/credit_memo.rbs +162 -0
- data/sig/qube_sync/operations/currency.rbs +52 -0
- data/sig/qube_sync/operations/custom_detail_report.rbs +31 -0
- data/sig/qube_sync/operations/custom_summary_report.rbs +33 -0
- data/sig/qube_sync/operations/customer.rbs +174 -0
- data/sig/qube_sync/operations/customer_msg.rbs +31 -0
- data/sig/qube_sync/operations/customer_type.rbs +32 -0
- data/sig/qube_sync/operations/data_event_recovery_info.rbs +20 -0
- data/sig/qube_sync/operations/data_ext.rbs +53 -0
- data/sig/qube_sync/operations/data_ext_def.rbs +51 -0
- data/sig/qube_sync/operations/date_driven_terms.rbs +35 -0
- data/sig/qube_sync/operations/deposit.rbs +100 -0
- data/sig/qube_sync/operations/employee.rbs +173 -0
- data/sig/qube_sync/operations/employee_default.rbs +68 -0
- data/sig/qube_sync/operations/entity.rbs +24 -0
- data/sig/qube_sync/operations/estimate.rbs +148 -0
- data/sig/qube_sync/operations/form1099_category_account_mapping.rbs +33 -0
- data/sig/qube_sync/operations/general_detail_report.rbs +31 -0
- data/sig/qube_sync/operations/general_summary_report.rbs +32 -0
- data/sig/qube_sync/operations/host.rbs +19 -0
- data/sig/qube_sync/operations/inventory_adjustment.rbs +105 -0
- data/sig/qube_sync/operations/inventory_site.rbs +66 -0
- data/sig/qube_sync/operations/invoice.rbs +144 -0
- data/sig/qube_sync/operations/item.rbs +25 -0
- data/sig/qube_sync/operations/item_assemblies_can_build.rbs +15 -0
- data/sig/qube_sync/operations/item_discount.rbs +62 -0
- data/sig/qube_sync/operations/item_fixed_asset.rbs +97 -0
- data/sig/qube_sync/operations/item_group.rbs +62 -0
- data/sig/qube_sync/operations/item_inventory.rbs +87 -0
- data/sig/qube_sync/operations/item_inventory_assembly.rbs +94 -0
- data/sig/qube_sync/operations/item_non_inventory.rbs +64 -0
- data/sig/qube_sync/operations/item_other_charge.rbs +59 -0
- data/sig/qube_sync/operations/item_payment.rbs +55 -0
- data/sig/qube_sync/operations/item_receipt.rbs +71 -0
- data/sig/qube_sync/operations/item_sales_tax.rbs +57 -0
- data/sig/qube_sync/operations/item_sales_tax_group.rbs +50 -0
- data/sig/qube_sync/operations/item_service.rbs +62 -0
- data/sig/qube_sync/operations/item_sites.rbs +26 -0
- data/sig/qube_sync/operations/item_subtotal.rbs +48 -0
- data/sig/qube_sync/operations/job_report.rbs +28 -0
- data/sig/qube_sync/operations/job_type.rbs +32 -0
- data/sig/qube_sync/operations/journal_entry.rbs +88 -0
- data/sig/qube_sync/operations/lead.rbs +91 -0
- data/sig/qube_sync/operations/list.rbs +15 -0
- data/sig/qube_sync/operations/list_deleted.rbs +17 -0
- data/sig/qube_sync/operations/list_display.rbs +21 -0
- data/sig/qube_sync/operations/list_merge.rbs +22 -0
- data/sig/qube_sync/operations/other_name.rbs +71 -0
- data/sig/qube_sync/operations/paycheck.rbs +27 -0
- data/sig/qube_sync/operations/payment_method.rbs +33 -0
- data/sig/qube_sync/operations/payroll_detail_report.rbs +29 -0
- data/sig/qube_sync/operations/payroll_item.rbs +23 -0
- data/sig/qube_sync/operations/payroll_item_addition.rbs +48 -0
- data/sig/qube_sync/operations/payroll_item_company_contribution.rbs +58 -0
- data/sig/qube_sync/operations/payroll_item_deduction.rbs +54 -0
- data/sig/qube_sync/operations/payroll_item_direct_deposit.rbs +30 -0
- data/sig/qube_sync/operations/payroll_item_non_wage.rbs +23 -0
- data/sig/qube_sync/operations/payroll_item_tax.rbs +58 -0
- data/sig/qube_sync/operations/payroll_item_wage.rbs +48 -0
- data/sig/qube_sync/operations/payroll_item_workers_compensation.rbs +42 -0
- data/sig/qube_sync/operations/payroll_last_period.rbs +13 -0
- data/sig/qube_sync/operations/payroll_liability_adjustment.rbs +59 -0
- data/sig/qube_sync/operations/payroll_liability_check.rbs +27 -0
- data/sig/qube_sync/operations/payroll_prior_payment.rbs +48 -0
- data/sig/qube_sync/operations/payroll_summary_report.rbs +30 -0
- data/sig/qube_sync/operations/payroll_year_to_date_adjustment.rbs +66 -0
- data/sig/qube_sync/operations/preferences.rbs +26 -0
- data/sig/qube_sync/operations/price_level.rbs +57 -0
- data/sig/qube_sync/operations/purchase_order.rbs +159 -0
- data/sig/qube_sync/operations/qb_command_exec.rbs +22 -0
- data/sig/qube_sync/operations/receive_payment.rbs +102 -0
- data/sig/qube_sync/operations/receive_payment_to_deposit.rbs +15 -0
- data/sig/qube_sync/operations/sales_order.rbs +153 -0
- data/sig/qube_sync/operations/sales_receipt.rbs +139 -0
- data/sig/qube_sync/operations/sales_rep.rbs +43 -0
- data/sig/qube_sync/operations/sales_tax_code.rbs +49 -0
- data/sig/qube_sync/operations/sales_tax_payable.rbs +16 -0
- data/sig/qube_sync/operations/sales_tax_payment_check.rbs +64 -0
- data/sig/qube_sync/operations/sales_tax_return.rbs +14 -0
- data/sig/qube_sync/operations/sales_tax_return_line.rbs +15 -0
- data/sig/qube_sync/operations/ship_method.rbs +31 -0
- data/sig/qube_sync/operations/special_account.rbs +16 -0
- data/sig/qube_sync/operations/special_item.rbs +17 -0
- data/sig/qube_sync/operations/standard_terms.rbs +34 -0
- data/sig/qube_sync/operations/template.rbs +15 -0
- data/sig/qube_sync/operations/terms.rbs +23 -0
- data/sig/qube_sync/operations/time_report.rbs +26 -0
- data/sig/qube_sync/operations/time_tracking.rbs +57 -0
- data/sig/qube_sync/operations/to_do.rbs +59 -0
- data/sig/qube_sync/operations/transaction.rbs +39 -0
- data/sig/qube_sync/operations/transfer.rbs +47 -0
- data/sig/qube_sync/operations/transfer_inventory.rbs +76 -0
- data/sig/qube_sync/operations/txn.rbs +15 -0
- data/sig/qube_sync/operations/txn_deleted.rbs +17 -0
- data/sig/qube_sync/operations/txn_display.rbs +22 -0
- data/sig/qube_sync/operations/txn_void.rbs +15 -0
- data/sig/qube_sync/operations/types.rbs +434 -0
- data/sig/qube_sync/operations/unit_of_measure_set.rbs +51 -0
- data/sig/qube_sync/operations/vehicle.rbs +43 -0
- data/sig/qube_sync/operations/vehicle_mileage.rbs +38 -0
- data/sig/qube_sync/operations/vendor.rbs +142 -0
- data/sig/qube_sync/operations/vendor_credit.rbs +70 -0
- data/sig/qube_sync/operations/vendor_type.rbs +32 -0
- data/sig/qube_sync/operations/workers_comp_code.rbs +52 -0
- data/sig/qube_sync/operations.rbs +147 -0
- metadata +255 -2
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "time"
|
|
7
|
+
require "uri"
|
|
8
|
+
require_relative "version"
|
|
9
|
+
|
|
10
|
+
module QubeSync
|
|
11
|
+
class Error < StandardError; end
|
|
12
|
+
|
|
13
|
+
# Any answer from QuBe Sync outside 2xx, after the retries.
|
|
14
|
+
#
|
|
15
|
+
# - `status`: the HTTP status, an Integer.
|
|
16
|
+
# - `errors`: the body's `errors` as parsed (a String, a Hash or an Array), or nil when the
|
|
17
|
+
# body isn't JSON or has none.
|
|
18
|
+
# - `code`: `errors["code"]` when `errors` is a Hash with a String `code` (`"prompt_changed"`,
|
|
19
|
+
# `"not_awaiting_input"`...), else nil.
|
|
20
|
+
# - `message`: one line, `"QuBe Sync answered <status>: <text>"`.
|
|
21
|
+
# - `body`: the raw response body.
|
|
22
|
+
# - `request_id`: the `x-request-id` response header, for support, or nil.
|
|
23
|
+
#
|
|
24
|
+
# It never holds the API key or the Authorization header.
|
|
25
|
+
class APIError < Error
|
|
26
|
+
MAX_RAW = 500
|
|
27
|
+
|
|
28
|
+
attr_reader :status, :errors, :code, :body, :request_id
|
|
29
|
+
|
|
30
|
+
def initialize(status:, body: "", request_id: nil)
|
|
31
|
+
@status = Integer(status)
|
|
32
|
+
@body = body.to_s
|
|
33
|
+
@request_id = request_id
|
|
34
|
+
parsed = parse_json(@body)
|
|
35
|
+
@errors = parsed.is_a?(Hash) ? parsed["errors"] : nil
|
|
36
|
+
@code = @errors.is_a?(Hash) && @errors["code"].is_a?(String) ? @errors["code"] : nil
|
|
37
|
+
super("QuBe Sync answered #{@status}: #{summary}")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def parse_json(text)
|
|
43
|
+
JSON.parse(text)
|
|
44
|
+
rescue JSON::ParserError
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def summary
|
|
49
|
+
text =
|
|
50
|
+
case @errors
|
|
51
|
+
when String
|
|
52
|
+
@errors
|
|
53
|
+
when Hash
|
|
54
|
+
if @errors["detail"].is_a?(String)
|
|
55
|
+
@errors["detail"]
|
|
56
|
+
else
|
|
57
|
+
@errors.map { |field, messages| "#{field}: #{Array(messages).join(", ")}" }.join("; ")
|
|
58
|
+
end
|
|
59
|
+
when Array
|
|
60
|
+
@errors.map do |problem|
|
|
61
|
+
if problem.is_a?(Hash)
|
|
62
|
+
"#{problem["path"]}: #{problem["message"]}"
|
|
63
|
+
else
|
|
64
|
+
problem.to_s
|
|
65
|
+
end
|
|
66
|
+
end.join("; ")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
text = raw_summary if text.nil? || text.strip.empty?
|
|
70
|
+
text.gsub(/\s+/, " ").strip
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def raw_summary
|
|
74
|
+
raw = @body.strip
|
|
75
|
+
raw.empty? ? "(no body)" : raw[0, MAX_RAW]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# A client for the QuBe Sync v2 API.
|
|
80
|
+
#
|
|
81
|
+
# client = QubeSync::Client.new(api_key: ENV.fetch("QUBE_API_KEY"))
|
|
82
|
+
# connection = client.create_connection(webhook_url: "https://myapp.example/qube/connection")
|
|
83
|
+
# connection["links"]["onboarding"] # send the customer here
|
|
84
|
+
#
|
|
85
|
+
# Every method returns the response's `data` (a Hash or an Array with String keys) unless its
|
|
86
|
+
# documentation says otherwise, and nil for an empty answer. Anything outside 2xx raises
|
|
87
|
+
# QubeSync::APIError. A connection that still fails after the retries raises Ruby's own error
|
|
88
|
+
# (Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNREFUSED...).
|
|
89
|
+
#
|
|
90
|
+
# It uses only the standard library: `require "qube_sync/client"` loads neither Faraday nor
|
|
91
|
+
# the v1 functions. It never reads the environment.
|
|
92
|
+
class Client
|
|
93
|
+
DEFAULT_BASE_URL = "https://qubesync.com"
|
|
94
|
+
API_PATH = "/api/v2"
|
|
95
|
+
RETRY_STATUSES = [429, 502, 503, 504].freeze
|
|
96
|
+
RETRY_AFTER_STATUSES = [429, 503].freeze
|
|
97
|
+
MAX_RETRY_AFTER = 30
|
|
98
|
+
# A retry is made only when at least this many seconds are left before total_timeout.
|
|
99
|
+
MIN_TIME_LEFT = 1
|
|
100
|
+
RETRY_SAFE_METHODS = %w[GET PUT DELETE].freeze
|
|
101
|
+
METHODS = %w[GET POST PUT DELETE PATCH].freeze
|
|
102
|
+
QUEUE_METHODS = %w[GET POST PUT DELETE].freeze
|
|
103
|
+
NETWORK_ERRORS = [
|
|
104
|
+
Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout,
|
|
105
|
+
Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::ECONNABORTED, Errno::EPIPE,
|
|
106
|
+
Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::ETIMEDOUT,
|
|
107
|
+
EOFError, IOError, SocketError
|
|
108
|
+
].freeze
|
|
109
|
+
# Failures after the request may have reached QuBe Sync: its answer was lost, so the request
|
|
110
|
+
# may have been carried out.
|
|
111
|
+
ANSWER_LOST_ERRORS = [
|
|
112
|
+
Net::ReadTimeout, Net::WriteTimeout, Errno::ECONNRESET, Errno::ECONNABORTED, Errno::EPIPE, EOFError, IOError
|
|
113
|
+
].freeze
|
|
114
|
+
ANSWER_LOST_STATUSES = [502, 504].freeze
|
|
115
|
+
|
|
116
|
+
attr_reader :base_url, :open_timeout, :read_timeout, :max_retries, :total_timeout
|
|
117
|
+
|
|
118
|
+
# `api_key` is required. `base_url` is the site, without `/api/v2`. `open_timeout` and
|
|
119
|
+
# `read_timeout` are seconds, per attempt. `max_retries` is how many times a retry-safe
|
|
120
|
+
# request is sent again. `total_timeout` (seconds) bounds a whole call, retries and their
|
|
121
|
+
# waits included. `on_retry` is a callable, called before each retry's wait with a frozen
|
|
122
|
+
# Hash: `attempt`, `max_retries`, `delay`, `elapsed`, `method`, `url` (without its query),
|
|
123
|
+
# `status` and `error`; an exception it raises stops the call and reaches the caller.
|
|
124
|
+
# `sleep` (a callable taking seconds) and `clock` (a callable returning monotonic seconds)
|
|
125
|
+
# are for tests.
|
|
126
|
+
def initialize(api_key:, base_url: DEFAULT_BASE_URL, open_timeout: 5, read_timeout: 30, max_retries: 2,
|
|
127
|
+
total_timeout: 60, on_retry: nil, sleep: nil, clock: nil)
|
|
128
|
+
unless api_key.is_a?(String) && !api_key.strip.empty?
|
|
129
|
+
raise ArgumentError, "api_key is required"
|
|
130
|
+
end
|
|
131
|
+
unless max_retries.is_a?(Integer) && max_retries >= 0
|
|
132
|
+
raise ArgumentError, "max_retries must be a non-negative Integer"
|
|
133
|
+
end
|
|
134
|
+
unless total_timeout.is_a?(Numeric) && total_timeout.positive?
|
|
135
|
+
raise ArgumentError, "total_timeout must be a positive number of seconds"
|
|
136
|
+
end
|
|
137
|
+
unless on_retry.nil? || on_retry.respond_to?(:call)
|
|
138
|
+
raise ArgumentError, "on_retry must respond to call"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
@base_url = base_url.to_s.sub(%r{/+\z}, "")
|
|
142
|
+
@base_uri = parse_http_uri(@base_url)
|
|
143
|
+
raise ArgumentError, "base_url must be an absolute http or https URL" unless @base_uri
|
|
144
|
+
|
|
145
|
+
@authorization = "Basic #{["#{api_key}:"].pack("m0")}"
|
|
146
|
+
@open_timeout = open_timeout
|
|
147
|
+
@read_timeout = read_timeout
|
|
148
|
+
@max_retries = max_retries
|
|
149
|
+
@total_timeout = total_timeout
|
|
150
|
+
@on_retry = on_retry
|
|
151
|
+
@sleep = sleep || ->(seconds) { Kernel.sleep(seconds) }
|
|
152
|
+
@clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def inspect
|
|
156
|
+
"#<#{self.class.name} base_url=#{@base_url}>"
|
|
157
|
+
end
|
|
158
|
+
alias to_s inspect
|
|
159
|
+
|
|
160
|
+
# -- Connections ------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
def create_connection(webhook_url:, name: nil)
|
|
163
|
+
call(:post, "/connections", compact(webhook_url: webhook_url, name: name))
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def get_connection(id)
|
|
167
|
+
call(:get, "/connections/#{segment(id)}")
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def list_connections
|
|
171
|
+
call(:get, "/connections")
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def update_connection(id, webhook_url: nil, name: nil)
|
|
175
|
+
call(:put, "/connections/#{segment(id)}", compact(webhook_url: webhook_url, name: name))
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def delete_connection(id)
|
|
179
|
+
call(:delete, "/connections/#{segment(id)}")
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def regenerate_onboarding(id)
|
|
183
|
+
call(:post, "/connections/#{segment(id)}/onboarding-url")
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Returns `{"id" => ..., "password" => ...}`.
|
|
187
|
+
def set_password(id, password: nil)
|
|
188
|
+
body = password.nil? ? nil : {password: password}
|
|
189
|
+
call(:post, "/connections/#{segment(id)}/password", body)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Returns the QWC file's XML as a String.
|
|
193
|
+
def get_qwc(id)
|
|
194
|
+
body = request(:post, "/connections/#{segment(id)}/qwc")
|
|
195
|
+
body.is_a?(Hash) ? body["qwc"] : nil
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# -- Workflow runs ----------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
# Sends `reference` as the Idempotency-Key, so a retry never starts a second run.
|
|
201
|
+
def start_run(connection_id, workflow, input, reference:, webhook_url:, version: nil)
|
|
202
|
+
body = compact(workflow: workflow, reference: reference, input: input, webhook_url: webhook_url, version: version)
|
|
203
|
+
call(:post, "/connections/#{segment(connection_id)}/workflow-runs", body,
|
|
204
|
+
headers: {"Idempotency-Key" => reference.to_s})
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# `get_run(run["links"]["run"])` or `get_run(connection_id, run_id)`.
|
|
208
|
+
def get_run(run_url_or_connection_id, run_id = nil)
|
|
209
|
+
if run_id.nil?
|
|
210
|
+
call(:get, run_url_or_connection_id)
|
|
211
|
+
else
|
|
212
|
+
call(:get, "/connections/#{segment(run_url_or_connection_id)}/workflow-runs/#{segment(run_id)}")
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def list_runs(connection_id, reference: nil, state: nil, outcome: nil)
|
|
217
|
+
call(:get, "/connections/#{segment(connection_id)}/workflow-runs",
|
|
218
|
+
query: {reference: reference, state: state, outcome: outcome})
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# `cancel_url` is the run's `links.cancel`.
|
|
222
|
+
def cancel_run(cancel_url, reason: nil)
|
|
223
|
+
call(:post, cancel_url, compact(reason: reason))
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Returns the whole body: `{"data" => [...], "next_after" => ...}`.
|
|
227
|
+
def run_events(connection_id, run_id, after: nil, limit: nil)
|
|
228
|
+
request(:get, "/connections/#{segment(connection_id)}/workflow-runs/#{segment(run_id)}/events",
|
|
229
|
+
query: {after: after, limit: limit})
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# -- Questions --------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
# `decisions_url` is the run's `links.decisions`. It's retried like a GET when the link
|
|
235
|
+
# carries a `prompt_token` (a run's link always does while it asks), since the token ties the
|
|
236
|
+
# answer to one question. A retry after a lost answer may raise APIError 409 with code
|
|
237
|
+
# `prompt_changed`: the question has been answered, by the first attempt or by someone else.
|
|
238
|
+
# Without a `prompt_token` it's sent once.
|
|
239
|
+
def answer(decisions_url, choice:, data: nil)
|
|
240
|
+
call(:post, decisions_url, compact(choice: choice, data: data), retry_safe: prompt_token?(decisions_url))
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# `search_url` is the run's `links.search`. Returns the matches, an Array.
|
|
244
|
+
def search(search_url, field:, q:)
|
|
245
|
+
call(:get, search_url, query: {field: field, q: q})
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# -- Queued requests --------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
# Queues a QuickBooks operation by its id, as `client.customer.create(...)` and the other
|
|
251
|
+
# entity methods do: `queue("customer.create", connection_id, {name: "Acme"}, webhook_url:)`.
|
|
252
|
+
# The params go as the JSON body or the query string, as the operation takes them, and
|
|
253
|
+
# `webhook_url` in the query string. Without `idempotency_key:`, the client makes a UUID for
|
|
254
|
+
# this call and sends the same one on its retries.
|
|
255
|
+
#
|
|
256
|
+
# For an operation newer than this version of the gem, pass `method:` (`:post`, `:get`,
|
|
257
|
+
# `:put`, `:delete`) and `path:`, relative to the connection (`"customers"`); a GET sends the
|
|
258
|
+
# params as the query string, any other method as the body. For a known id they replace the
|
|
259
|
+
# index's.
|
|
260
|
+
def queue(operation_id, connection_id, params = {}, webhook_url:, idempotency_key: nil, method: nil, path: nil)
|
|
261
|
+
entry = Operations::INDEX[operation_id.to_s]
|
|
262
|
+
if entry.nil? && (method.nil? || path.nil?)
|
|
263
|
+
raise ArgumentError, "unknown operation #{operation_id.to_s.inspect}: pass method: and path: for an operation " \
|
|
264
|
+
"this version of qube_sync doesn't know"
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
verb = (method || entry[:method]).to_s.upcase
|
|
268
|
+
raise ArgumentError, "method must be one of :get, :post, :put, :delete" unless QUEUE_METHODS.include?(verb)
|
|
269
|
+
|
|
270
|
+
params_in = (method.nil? && entry) ? entry[:params_in] : (verb == "GET" ? :query : :body)
|
|
271
|
+
full_path = "/connections/#{segment(connection_id)}/#{operation_path(path || entry[:path])}"
|
|
272
|
+
params ||= {}
|
|
273
|
+
query = {webhook_url: webhook_url}
|
|
274
|
+
body = nil
|
|
275
|
+
if params_in == :query
|
|
276
|
+
query = query.merge(params.to_h.reject { |name, _| name.to_s == "webhook_url" })
|
|
277
|
+
else
|
|
278
|
+
body = params
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
call(verb, full_path, body, query: query, headers: {"Idempotency-Key" => key_for(idempotency_key)})
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Queues qbXML as written.
|
|
285
|
+
def queue_qbxml(connection_id, request_xml, webhook_url:, idempotency_key: nil)
|
|
286
|
+
call(:post, "/connections/#{segment(connection_id)}/queued-requests", {request_xml: request_xml},
|
|
287
|
+
query: {webhook_url: webhook_url}, headers: {"Idempotency-Key" => key_for(idempotency_key)})
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def get_request(connection_id, id)
|
|
291
|
+
call(:get, "/connections/#{segment(connection_id)}/queued-requests/#{segment(id)}")
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def request_pages(connection_id, id)
|
|
295
|
+
call(:get, "/connections/#{segment(connection_id)}/queued-requests/#{segment(id)}/pages")
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Returns the whole body: `{"data" => [...], "meta" => {"page" => 1, "total_pages" => 3}}`.
|
|
299
|
+
def list_requests(connection_id, page: nil, page_size: nil, state: nil, webhook_state: nil, sort: nil,
|
|
300
|
+
sort_direction: nil, search_phrase: nil)
|
|
301
|
+
query = {
|
|
302
|
+
page: page, page_size: page_size, state: state, webhook_state: webhook_state, sort: sort,
|
|
303
|
+
sort_direction: sort_direction, search_phrase: search_phrase
|
|
304
|
+
}
|
|
305
|
+
request(:get, "/connections/#{segment(connection_id)}/queued-requests", query: query)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Yields every request on every page, fetching page after page. Takes `list_requests`'
|
|
309
|
+
# filters. Without a block, returns an Enumerator.
|
|
310
|
+
def each_request(connection_id, **filters, &block)
|
|
311
|
+
return enum_for(:each_request, connection_id, **filters) unless block
|
|
312
|
+
|
|
313
|
+
page = filters.delete(:page) || 1
|
|
314
|
+
loop do
|
|
315
|
+
body = list_requests(connection_id, **filters, page: page)
|
|
316
|
+
data = body.is_a?(Hash) ? Array(body["data"]) : []
|
|
317
|
+
break if data.empty?
|
|
318
|
+
|
|
319
|
+
data.each(&block)
|
|
320
|
+
total_pages = body.dig("meta", "total_pages")
|
|
321
|
+
break if !total_pages.is_a?(Integer) || page >= total_pages
|
|
322
|
+
|
|
323
|
+
page += 1
|
|
324
|
+
end
|
|
325
|
+
nil
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def discard_request(connection_id, id)
|
|
329
|
+
call(:post, "/connections/#{segment(connection_id)}/queued-requests/#{segment(id)}/discard")
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# -- Anything else ----------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
# Sends any request and returns the parsed JSON body whole (not just `data`), or nil for an
|
|
335
|
+
# empty answer. `path_or_url` is a path under /api/v2 (starting with "/") or an absolute URL
|
|
336
|
+
# on this client's own origin. `body` is sent as JSON (a String as is).
|
|
337
|
+
def request(method, path_or_url, body = nil, query: {}, headers: {})
|
|
338
|
+
perform(method, path_or_url, body, query: query, headers: headers)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
private
|
|
342
|
+
|
|
343
|
+
def perform(method, path_or_url, body, query:, headers:, retry_safe: false)
|
|
344
|
+
verb = method.to_s.upcase
|
|
345
|
+
raise ArgumentError, "unsupported method #{method.inspect}" unless METHODS.include?(verb)
|
|
346
|
+
|
|
347
|
+
uri = build_uri(path_or_url, query)
|
|
348
|
+
request_headers = {
|
|
349
|
+
"Accept" => "application/json",
|
|
350
|
+
"User-Agent" => "qube_sync-ruby/#{QubeSync::VERSION}"
|
|
351
|
+
}
|
|
352
|
+
payload = nil
|
|
353
|
+
unless body.nil?
|
|
354
|
+
request_headers["Content-Type"] = "application/json"
|
|
355
|
+
payload = body.is_a?(String) ? body : JSON.generate(body)
|
|
356
|
+
end
|
|
357
|
+
headers.each do |name, value|
|
|
358
|
+
next if name.to_s.casecmp?("authorization")
|
|
359
|
+
|
|
360
|
+
request_headers[name.to_s] = value.to_s
|
|
361
|
+
end
|
|
362
|
+
request_headers["Authorization"] = @authorization
|
|
363
|
+
|
|
364
|
+
retry_safe ||= RETRY_SAFE_METHODS.include?(verb) ||
|
|
365
|
+
request_headers.any? { |name, value| name.casecmp?("idempotency-key") && !value.empty? }
|
|
366
|
+
|
|
367
|
+
send_with_retries(verb, uri, request_headers, payload, retry_safe)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def call(method, path_or_url, body = nil, query: {}, headers: {}, retry_safe: false)
|
|
371
|
+
parsed = perform(method, path_or_url, body, query: query, headers: headers, retry_safe: retry_safe)
|
|
372
|
+
parsed.is_a?(Hash) ? parsed["data"] : parsed
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def send_with_retries(verb, uri, headers, payload, retry_safe)
|
|
376
|
+
started = @clock.call
|
|
377
|
+
attempt = 0
|
|
378
|
+
answer_lost = false
|
|
379
|
+
loop do
|
|
380
|
+
error = nil
|
|
381
|
+
response = nil
|
|
382
|
+
time_left = @total_timeout - (@clock.call - started)
|
|
383
|
+
begin
|
|
384
|
+
response = send_once(verb, uri, headers, payload, time_left)
|
|
385
|
+
rescue *NETWORK_ERRORS => e
|
|
386
|
+
error = e
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
status = response&.code.to_i
|
|
390
|
+
# A retried DELETE that finds nothing, after an earlier attempt's answer was lost, did
|
|
391
|
+
# what it was asked: that attempt deleted it.
|
|
392
|
+
return nil if verb == "DELETE" && status == 404 && answer_lost
|
|
393
|
+
|
|
394
|
+
answer_lost ||= ANSWER_LOST_ERRORS.any? { |klass| error.is_a?(klass) } ||
|
|
395
|
+
ANSWER_LOST_STATUSES.include?(status)
|
|
396
|
+
retryable = error || RETRY_STATUSES.include?(status)
|
|
397
|
+
if retryable && retry_safe && attempt < @max_retries
|
|
398
|
+
delay = error ? backoff(attempt) : retry_delay(attempt, status, response)
|
|
399
|
+
elapsed = @clock.call - started
|
|
400
|
+
if @total_timeout - elapsed - delay >= MIN_TIME_LEFT
|
|
401
|
+
notify_retry(attempt + 1, delay, elapsed, verb, uri, error ? nil : status, error)
|
|
402
|
+
@sleep.call(delay)
|
|
403
|
+
attempt += 1
|
|
404
|
+
next
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
raise error if error
|
|
409
|
+
return parse_body(response) if (200..299).cover?(status)
|
|
410
|
+
|
|
411
|
+
raise APIError.new(status: status, body: response.body, request_id: response["x-request-id"])
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def notify_retry(attempt, delay, elapsed, verb, uri, status, error)
|
|
416
|
+
return unless @on_retry
|
|
417
|
+
|
|
418
|
+
url = uri.dup
|
|
419
|
+
url.query = nil
|
|
420
|
+
url.fragment = nil
|
|
421
|
+
@on_retry.call({
|
|
422
|
+
attempt: attempt, max_retries: @max_retries, delay: delay.to_f, elapsed: elapsed.to_f,
|
|
423
|
+
method: verb, url: url.to_s, status: status, error: error
|
|
424
|
+
}.freeze)
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# Each attempt's timeouts are the configured ones, cut to the time left before total_timeout.
|
|
428
|
+
def send_once(verb, uri, headers, payload, time_left)
|
|
429
|
+
time_left = [time_left, 0.001].max
|
|
430
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
431
|
+
http.use_ssl = uri.scheme == "https"
|
|
432
|
+
http.open_timeout = [@open_timeout, time_left].min
|
|
433
|
+
http.read_timeout = [@read_timeout, time_left].min
|
|
434
|
+
http.write_timeout = [@read_timeout, time_left].min
|
|
435
|
+
http.max_retries = 0 # the client retries, and only what's safe to send again
|
|
436
|
+
|
|
437
|
+
request = Net::HTTPGenericRequest.new(verb, !payload.nil?, verb != "HEAD", uri.request_uri, headers)
|
|
438
|
+
request.body = payload unless payload.nil?
|
|
439
|
+
http.start { |connection| connection.request(request) }
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def prompt_token?(link)
|
|
443
|
+
query = URI.parse(link.to_s).query
|
|
444
|
+
return false if query.nil?
|
|
445
|
+
|
|
446
|
+
URI.decode_www_form(query).any? { |name, value| name == "prompt_token" && !value.to_s.empty? }
|
|
447
|
+
rescue URI::InvalidURIError, ArgumentError
|
|
448
|
+
false
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def parse_body(response)
|
|
452
|
+
text = response.body
|
|
453
|
+
return nil if response.code.to_i == 204 || text.nil? || text.strip.empty?
|
|
454
|
+
|
|
455
|
+
JSON.parse(text)
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
def backoff(attempt)
|
|
459
|
+
[0.5 * (2**attempt), MAX_RETRY_AFTER].min * (1 + rand * 0.25)
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def retry_delay(attempt, status, response)
|
|
463
|
+
if RETRY_AFTER_STATUSES.include?(status)
|
|
464
|
+
seconds = Float(response["retry-after"].to_s.strip, exception: false)
|
|
465
|
+
return [seconds, MAX_RETRY_AFTER].min if seconds && seconds >= 0
|
|
466
|
+
end
|
|
467
|
+
backoff(attempt)
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def build_uri(path_or_url, query)
|
|
471
|
+
text = path_or_url.to_s
|
|
472
|
+
uri =
|
|
473
|
+
if text.start_with?("/")
|
|
474
|
+
parse_http_uri("#{@base_url}#{API_PATH}#{text}") ||
|
|
475
|
+
raise(ArgumentError, "not a valid path: #{text.inspect}")
|
|
476
|
+
else
|
|
477
|
+
link = parse_http_uri(text)
|
|
478
|
+
raise ArgumentError, "expected a path starting with \"/\" or an absolute URL, got #{text.inspect}" unless link
|
|
479
|
+
unless origin(link) == origin(@base_uri)
|
|
480
|
+
raise ArgumentError, "refusing a link on #{origin(link)}: this client only sends requests to #{origin(@base_uri)}"
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
link
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
extra = encode_query(query)
|
|
487
|
+
unless extra.empty?
|
|
488
|
+
uri = uri.dup
|
|
489
|
+
uri.query = [uri.query, extra].compact.reject(&:empty?).join("&")
|
|
490
|
+
end
|
|
491
|
+
uri
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def parse_http_uri(text)
|
|
495
|
+
uri = URI.parse(text)
|
|
496
|
+
return nil unless uri.is_a?(URI::HTTP) && uri.host && !uri.host.empty?
|
|
497
|
+
|
|
498
|
+
uri
|
|
499
|
+
rescue URI::InvalidURIError
|
|
500
|
+
nil
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def origin(uri)
|
|
504
|
+
"#{uri.scheme.downcase}://#{uri.host.downcase}:#{uri.port}"
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
# Query strings as the v2 API reads them: an array of scalars repeats its bare key
|
|
508
|
+
# (`list_id=1&list_id=2`); a nested Hash brackets its keys (`name_range[from]=A`), and an
|
|
509
|
+
# array inside one brackets too (`currency_filter[list_id][]=1`). nil values are left out,
|
|
510
|
+
# booleans are `true`/`false`, a Time or Date is ISO 8601.
|
|
511
|
+
def encode_query(query)
|
|
512
|
+
raise ArgumentError, "query must be a Hash" unless query.nil? || query.is_a?(Hash)
|
|
513
|
+
|
|
514
|
+
pairs = []
|
|
515
|
+
(query || {}).each { |name, value| query_pairs(name.to_s, value, true, pairs) }
|
|
516
|
+
URI.encode_www_form(pairs)
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
def query_pairs(key, value, top, pairs)
|
|
520
|
+
case value
|
|
521
|
+
when nil
|
|
522
|
+
nil
|
|
523
|
+
when Array
|
|
524
|
+
value.each do |item|
|
|
525
|
+
next if item.nil?
|
|
526
|
+
raise ArgumentError, "query parameter #{key} can't hold an array of arrays" if item.is_a?(Array)
|
|
527
|
+
|
|
528
|
+
if item.is_a?(Hash)
|
|
529
|
+
query_pairs("#{key}[]", item, false, pairs)
|
|
530
|
+
else
|
|
531
|
+
pairs << [top ? key : "#{key}[]", query_scalar(key, item)]
|
|
532
|
+
end
|
|
533
|
+
end
|
|
534
|
+
when Hash
|
|
535
|
+
value.each { |name, inner| query_pairs("#{key}[#{name}]", inner, false, pairs) }
|
|
536
|
+
else
|
|
537
|
+
pairs << [key, query_scalar(key, value)]
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
def query_scalar(key, value)
|
|
542
|
+
case value
|
|
543
|
+
when String, Symbol, Numeric, true, false then value.to_s
|
|
544
|
+
when Time then value.iso8601
|
|
545
|
+
else
|
|
546
|
+
return value.iso8601 if value.respond_to?(:iso8601)
|
|
547
|
+
|
|
548
|
+
raise ArgumentError, "query parameter #{key} must be a String, Numeric, boolean, Array or Hash"
|
|
549
|
+
end
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
def segment(value)
|
|
553
|
+
text = value.to_s
|
|
554
|
+
raise ArgumentError, "expected an id, got #{value.inspect}" if text.empty? || text == "." || text == ".."
|
|
555
|
+
|
|
556
|
+
URI.encode_uri_component(text)
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
def operation_path(operation)
|
|
560
|
+
parts = operation.to_s.sub(%r{\A/+}, "").split("/")
|
|
561
|
+
raise ArgumentError, "expected an operation such as \"customers\"" if parts.empty?
|
|
562
|
+
|
|
563
|
+
parts.map { |part| segment(part) }.join("/")
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
def key_for(idempotency_key)
|
|
567
|
+
key = idempotency_key.nil? ? SecureRandom.uuid : idempotency_key.to_s
|
|
568
|
+
raise ArgumentError, "idempotency_key can't be empty" if key.empty?
|
|
569
|
+
|
|
570
|
+
key
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
def compact(hash)
|
|
574
|
+
hash.compact
|
|
575
|
+
end
|
|
576
|
+
end
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
require_relative "operations"
|