waffo-pancake 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 +33 -0
- data/README.md +112 -7
- data/lib/waffo/pancake/api/customer_actions.rb +55 -0
- data/lib/waffo/pancake/api/product_actions.rb +64 -0
- data/lib/waffo/pancake/api/promotion_actions.rb +39 -0
- data/lib/waffo/pancake/api/queries.rb +292 -0
- data/lib/waffo/pancake/api/store_actions.rb +64 -0
- data/lib/waffo/pancake/client.rb +74 -165
- data/lib/waffo/pancake/connection.rb +113 -0
- data/lib/waffo/pancake/customer_session.rb +55 -0
- data/lib/waffo/pancake/errors.rb +5 -3
- data/lib/waffo/pancake/requests.rb +91 -0
- data/lib/waffo/pancake/store_checkout.rb +41 -0
- data/lib/waffo/pancake/test_helpers.rb +28 -6
- data/lib/waffo/pancake/version.rb +1 -1
- data/lib/waffo/pancake.rb +1 -0
- metadata +10 -1
data/lib/waffo/pancake/client.rb
CHANGED
|
@@ -2,10 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
require "base64"
|
|
4
4
|
require "json"
|
|
5
|
-
require "net/http"
|
|
6
5
|
require "openssl"
|
|
7
6
|
require "time"
|
|
8
|
-
|
|
7
|
+
|
|
8
|
+
require_relative "connection"
|
|
9
|
+
require_relative "requests"
|
|
10
|
+
require_relative "api/customer_actions"
|
|
11
|
+
require_relative "api/store_actions"
|
|
12
|
+
require_relative "api/product_actions"
|
|
13
|
+
require_relative "api/promotion_actions"
|
|
14
|
+
require_relative "api/queries"
|
|
15
|
+
require_relative "customer_session"
|
|
9
16
|
|
|
10
17
|
module Waffo
|
|
11
18
|
module Pancake
|
|
@@ -17,14 +24,19 @@ module Waffo
|
|
|
17
24
|
# client.cancel_subscription("ORD_...") # => {"orderId" => "ORD_...", "status" => "canceling"}
|
|
18
25
|
#
|
|
19
26
|
# Parameters may be given in snake_case; top-level keys are sent in camelCase. Nested
|
|
20
|
-
# hashes (metadata, prices, billing details) are sent exactly as given.
|
|
27
|
+
# hashes (metadata, prices, billing details) are sent exactly as given. Every action takes
|
|
28
|
+
# `idempotency_key:`. Routes that do not take an API key are reached through #customer.
|
|
21
29
|
class Client
|
|
30
|
+
include Requests
|
|
31
|
+
include API::CustomerActions
|
|
32
|
+
include API::StoreActions
|
|
33
|
+
include API::ProductActions
|
|
34
|
+
include API::PromotionActions
|
|
35
|
+
include API::Queries
|
|
36
|
+
|
|
22
37
|
DEFAULT_BASE_URL = "https://api.waffo.ai"
|
|
23
38
|
DEFAULT_TIMEOUT = 15
|
|
24
|
-
|
|
25
|
-
NETWORK_ERRORS = [
|
|
26
|
-
Timeout::Error, SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError, Net::HTTPBadResponse
|
|
27
|
-
].freeze
|
|
39
|
+
NETWORK_ERRORS = Connection::NETWORK_ERRORS
|
|
28
40
|
|
|
29
41
|
SUBSCRIPTION_ORDER_QUERY = <<~GRAPHQL
|
|
30
42
|
query ($id: String!) {
|
|
@@ -40,7 +52,7 @@ module Waffo
|
|
|
40
52
|
"attemptCount lastAttemptedAt createdAt"
|
|
41
53
|
WEBHOOK_DELIVERY_STATUSES = %w[pending success failed].freeze
|
|
42
54
|
|
|
43
|
-
attr_reader :merchant_id, :base_url, :store_id
|
|
55
|
+
attr_reader :merchant_id, :base_url, :store_id, :environment
|
|
44
56
|
|
|
45
57
|
# The canonical request the gateway checks `X-Signature` against: method, path,
|
|
46
58
|
# timestamp (seconds) and the base64 SHA-256 of the body, one per line.
|
|
@@ -52,45 +64,50 @@ module Waffo
|
|
|
52
64
|
|
|
53
65
|
# Every argument defaults to Waffo::Pancake.configuration (itself defaulting to the
|
|
54
66
|
# WAFFO_* environment variables). `transport` replaces the HTTP call: it takes the URI,
|
|
55
|
-
# the headers and the JSON body and answers `[status, body]`
|
|
56
|
-
# no answer). `logger` gets one info line per request and
|
|
57
|
-
# `request.waffo_pancake` event; neither ever sees the body or the key.
|
|
67
|
+
# the headers and the JSON body and answers `[status, body]` or `[status, body, headers]`
|
|
68
|
+
# (status nil when there was no answer). `logger` gets one info line per request and
|
|
69
|
+
# `instrumenter` one `request.waffo_pancake` event; neither ever sees the body or the key.
|
|
70
|
+
#
|
|
71
|
+
# The key's environment cannot be read from the key, so `environment` only tells #customer
|
|
72
|
+
# and create_authenticated_plan_change which one to use.
|
|
58
73
|
def initialize(config: Pancake.configuration, merchant_id: config.merchant_id, private_key: config.private_key,
|
|
59
|
-
store_id: config.store_id,
|
|
60
|
-
instrumenter: config.instrumenter, transport: nil)
|
|
74
|
+
store_id: config.store_id, environment: config.environment, base_url: config.base_url,
|
|
75
|
+
timeout: config.timeout, logger: config.logger, instrumenter: config.instrumenter, transport: nil)
|
|
61
76
|
raise ConfigurationError, "Missing merchant_id (WAFFO_MERCHANT_ID)" if blank?(merchant_id)
|
|
62
77
|
raise ConfigurationError, "Missing private_key (WAFFO_PRIVATE_KEY)" if blank?(private_key)
|
|
63
78
|
|
|
64
79
|
@merchant_id = merchant_id
|
|
65
80
|
@store_id = blank?(store_id) ? nil : store_id
|
|
81
|
+
@environment = blank?(environment) ? nil : Requests.environment!(environment)
|
|
66
82
|
@private_key = Keys.private_key(private_key)
|
|
67
83
|
@base_url = base_url.to_s.chomp("/")
|
|
68
|
-
@
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
84
|
+
@connection = Connection.new(base_url: @base_url, timeout: timeout, logger: logger, instrumenter: instrumenter,
|
|
85
|
+
transport: transport)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def inspect = "#<#{self.class} merchant_id=#{@merchant_id.inspect} environment=#{@environment.inspect}>"
|
|
89
|
+
|
|
90
|
+
# The same connection, authenticated as a customer with a token from issue_session_token.
|
|
91
|
+
def customer(token, environment: @environment)
|
|
92
|
+
CustomerSession.new(token: token, environment: environment, connection: @connection)
|
|
72
93
|
end
|
|
73
94
|
|
|
74
95
|
# --- Checkout ----------------------------------------------------------------------
|
|
75
96
|
|
|
76
97
|
# `purpose: "portal"` signs the customer into the store's customer portal instead; see
|
|
77
98
|
# create_customer_portal_link.
|
|
78
|
-
def issue_session_token(buyer_identity:, store_id: nil, product_id: nil, purpose: nil)
|
|
99
|
+
def issue_session_token(buyer_identity:, store_id: nil, product_id: nil, purpose: nil, idempotency_key: nil)
|
|
79
100
|
raise ArgumentError, "store_id or product_id is required" if store_id.nil? && product_id.nil?
|
|
80
101
|
|
|
81
|
-
action("/v1/actions/auth/issue-session-token",
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
# Anonymous checkout: the buyer fills everything in on the page.
|
|
86
|
-
# Returns {"sessionId", "checkoutUrl", "expiresAt"}.
|
|
87
|
-
def create_checkout_session(product_id:, currency:, **params)
|
|
88
|
-
action("/v1/actions/checkout/create-session", { product_id: product_id, currency: currency, **params })
|
|
102
|
+
action("/v1/actions/auth/issue-session-token", buyer_identity: buyer_identity, store_id: store_id,
|
|
103
|
+
product_id: product_id, purpose: purpose,
|
|
104
|
+
idempotency_key: idempotency_key)
|
|
89
105
|
end
|
|
90
106
|
|
|
91
107
|
# Authenticated checkout: `buyer_identity` (your stable id for the customer) goes into a
|
|
92
108
|
# session token, which Waffo uses for trial eligibility and the customer portal. The
|
|
93
|
-
# token is appended to the checkout URL as a fragment (`#token=...`).
|
|
109
|
+
# token is appended to the checkout URL as a fragment (`#token=...`). `idempotency_key`
|
|
110
|
+
# applies to the session only: a token is short-lived and cheap to issue again.
|
|
94
111
|
def create_authenticated_checkout(product_id:, currency:, buyer_identity:, **params)
|
|
95
112
|
token = issue_session_token(buyer_identity: buyer_identity, product_id: product_id)
|
|
96
113
|
session = create_checkout_session(product_id: product_id, currency: currency, **params)
|
|
@@ -101,6 +118,23 @@ module Waffo
|
|
|
101
118
|
)
|
|
102
119
|
end
|
|
103
120
|
|
|
121
|
+
# A plan change the customer confirms on Waffo's page. The URL is a bearer credential, like
|
|
122
|
+
# the one from create_customer_portal_link. Needs a known environment, because a test
|
|
123
|
+
# session needs `?test=true` before the fragment, and prod must not have it.
|
|
124
|
+
def create_authenticated_plan_change(origin_order_id:, product_id:, currency:, buyer_identity:, **params)
|
|
125
|
+
environment = Requests.environment!(@environment)
|
|
126
|
+
token = issue_session_token(buyer_identity: buyer_identity, product_id: product_id)
|
|
127
|
+
session = create_plan_change_session(origin_order_id: origin_order_id, product_id: product_id,
|
|
128
|
+
currency: currency, **params)
|
|
129
|
+
url = session.fetch("checkoutUrl")
|
|
130
|
+
url = "#{url}#{url.include?("?") ? "&" : "?"}test=true" if environment == "test"
|
|
131
|
+
session.merge(
|
|
132
|
+
"checkoutUrl" => "#{url}#token=#{token.fetch("token")}",
|
|
133
|
+
"token" => token["token"],
|
|
134
|
+
"tokenExpiresAt" => token["expiresAt"]
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
|
|
104
138
|
# --- Customer portal ----------------------------------------------------------------
|
|
105
139
|
|
|
106
140
|
# Signs a customer who is signed in on your site into the store's customer portal, with no
|
|
@@ -128,21 +162,6 @@ module Waffo
|
|
|
128
162
|
|
|
129
163
|
# --- Orders ------------------------------------------------------------------------
|
|
130
164
|
|
|
131
|
-
# pending → canceled at once; active, trialing and past_due → canceling, ending with the
|
|
132
|
-
# paid period. The terminal `canceled` arrives later as a webhook.
|
|
133
|
-
def cancel_subscription(order_id)
|
|
134
|
-
action("/v1/actions/subscription-order/cancel-order", { order_id: order_id })
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
# Undoes a cancellation while the order is still `canceling`.
|
|
138
|
-
def reactivate_subscription(order_id)
|
|
139
|
-
action("/v1/actions/subscription-order/reactivate-order", { order_id: order_id })
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
def cancel_onetime_order(order_id)
|
|
143
|
-
action("/v1/actions/onetime-order/cancel-order", { order_id: order_id })
|
|
144
|
-
end
|
|
145
|
-
|
|
146
165
|
# Nil when Waffo knows no such order. `productId` is the order's current product, so a plan
|
|
147
166
|
# change to another product can be told apart.
|
|
148
167
|
def subscription_order(order_id)
|
|
@@ -150,37 +169,7 @@ module Waffo
|
|
|
150
169
|
order && order.merge("productId" => order.dig("subscriptionProduct", "id"))
|
|
151
170
|
end
|
|
152
171
|
|
|
153
|
-
# ---
|
|
154
|
-
|
|
155
|
-
# prices: { "USD" => { amount: "9.99", taxCategory: "saas" } }
|
|
156
|
-
def create_subscription_product(store_id:, name:, billing_period:, prices:, **params)
|
|
157
|
-
action("/v1/actions/subscription-product/create-product",
|
|
158
|
-
{ store_id: store_id, name: name, billing_period: billing_period, prices: prices, **params })
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
def update_subscription_product(id:, **params)
|
|
162
|
-
action("/v1/actions/subscription-product/update-product", { id: id, **params })
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
# Copies the test version to production (first publish only).
|
|
166
|
-
def publish_subscription_product(id:)
|
|
167
|
-
action("/v1/actions/subscription-product/publish-product", { id: id })
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
def create_onetime_product(store_id:, name:, prices:, **params)
|
|
171
|
-
action("/v1/actions/onetime-product/create-product", { store_id: store_id, name: name, prices: prices, **params })
|
|
172
|
-
end
|
|
173
|
-
|
|
174
|
-
# --- Webhook endpoints -------------------------------------------------------------
|
|
175
|
-
|
|
176
|
-
def add_webhook(store_id:, url:, events:, test_mode:, channel: "http", **params)
|
|
177
|
-
action("/v1/actions/store/add-webhook",
|
|
178
|
-
{ store_id: store_id, channel: channel, url: url, events: events, test_mode: test_mode, **params })
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
def remove_webhook(id:)
|
|
182
|
-
action("/v1/actions/store/remove-webhook", { id: id })
|
|
183
|
-
end
|
|
172
|
+
# --- Webhook deliveries ------------------------------------------------------------
|
|
184
173
|
|
|
185
174
|
# Delivery records, newest first, for the API key's environment: id, eventType, eventId,
|
|
186
175
|
# payload, webhookUrl, status (pending / success / failed), httpStatus, responseBody,
|
|
@@ -205,25 +194,6 @@ module Waffo
|
|
|
205
194
|
graphql(query, storeId: require_store_id(store_id))&.fetch("webhookDeliveriesCount", nil)
|
|
206
195
|
end
|
|
207
196
|
|
|
208
|
-
# --- Low level ---------------------------------------------------------------------
|
|
209
|
-
|
|
210
|
-
# Returns `data`. Raises when the response carries `errors` and no data.
|
|
211
|
-
def graphql(query, variables = {})
|
|
212
|
-
status, envelope = request("/v1/graphql", { "query" => query, "variables" => variables })
|
|
213
|
-
raise_for(status, envelope, "/v1/graphql") if present?(envelope["errors"]) && envelope["data"].nil?
|
|
214
|
-
|
|
215
|
-
envelope["data"]
|
|
216
|
-
end
|
|
217
|
-
|
|
218
|
-
# Any action endpoint. Returns the envelope's `data`; raises Rejected or Unavailable on
|
|
219
|
-
# `errors` or a non-2xx status.
|
|
220
|
-
def action(path, params)
|
|
221
|
-
status, envelope = request(path, camelize(params))
|
|
222
|
-
raise_for(status, envelope, path) if present?(envelope["errors"]) || !(200..299).cover?(status)
|
|
223
|
-
|
|
224
|
-
envelope["data"] || {}
|
|
225
|
-
end
|
|
226
|
-
|
|
227
197
|
# A Ruby value as a GraphQL input literal: hashes as input objects, symbols and times as strings.
|
|
228
198
|
def self.graphql_literal(value)
|
|
229
199
|
case value
|
|
@@ -243,6 +213,18 @@ module Waffo
|
|
|
243
213
|
end
|
|
244
214
|
|
|
245
215
|
private
|
|
216
|
+
attr_reader :connection
|
|
217
|
+
|
|
218
|
+
def auth_headers(path, json)
|
|
219
|
+
timestamp = Time.now.to_i.to_s
|
|
220
|
+
{
|
|
221
|
+
"X-Merchant-Id" => @merchant_id,
|
|
222
|
+
"X-Timestamp" => timestamp,
|
|
223
|
+
"X-Signature" => self.class.sign(method: "POST", path: path, timestamp: timestamp, body: json,
|
|
224
|
+
private_key: @private_key)
|
|
225
|
+
}
|
|
226
|
+
end
|
|
227
|
+
|
|
246
228
|
def delivery_filter(status, event_type, filter)
|
|
247
229
|
if status && !WEBHOOK_DELIVERY_STATUSES.include?(status.to_s)
|
|
248
230
|
raise ArgumentError, "status must be one of #{WEBHOOK_DELIVERY_STATUSES.join(", ")}"
|
|
@@ -259,79 +241,6 @@ module Waffo
|
|
|
259
241
|
|
|
260
242
|
store_id
|
|
261
243
|
end
|
|
262
|
-
|
|
263
|
-
def request(path, params)
|
|
264
|
-
json = JSON.generate(params)
|
|
265
|
-
timestamp = Time.now.to_i.to_s
|
|
266
|
-
headers = {
|
|
267
|
-
"Content-Type" => "application/json",
|
|
268
|
-
"X-Merchant-Id" => @merchant_id,
|
|
269
|
-
"X-Timestamp" => timestamp,
|
|
270
|
-
"X-Signature" => self.class.sign(method: "POST", path: path, timestamp: timestamp, body: json,
|
|
271
|
-
private_key: @private_key)
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
275
|
-
status, body = instrument("request.waffo_pancake", method: "POST", path: path) do |payload|
|
|
276
|
-
call_transport(URI("#{@base_url}#{path}"), headers, json).tap { |answer| payload[:status] = answer.first }
|
|
277
|
-
end
|
|
278
|
-
elapsed = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).round(2)
|
|
279
|
-
@logger&.info("[Waffo] POST #{path} status=#{status.inspect} in #{elapsed}s")
|
|
280
|
-
|
|
281
|
-
raise Unavailable.new("Waffo #{path} unreachable: #{body}", status: nil) if status.nil?
|
|
282
|
-
|
|
283
|
-
envelope = parse(body)
|
|
284
|
-
raise_for(status, {}, path, "non-JSON response") unless envelope.is_a?(Hash)
|
|
285
|
-
|
|
286
|
-
[status, envelope]
|
|
287
|
-
end
|
|
288
|
-
|
|
289
|
-
def instrument(name, payload, &block)
|
|
290
|
-
return yield(payload) unless @instrumenter
|
|
291
|
-
|
|
292
|
-
@instrumenter.instrument(name, payload, &block)
|
|
293
|
-
end
|
|
294
|
-
|
|
295
|
-
def call_transport(uri, headers, json)
|
|
296
|
-
@transport.call(uri, headers, json)
|
|
297
|
-
rescue *NETWORK_ERRORS => e
|
|
298
|
-
[nil, "#{e.class}: #{e.message}"]
|
|
299
|
-
end
|
|
300
|
-
|
|
301
|
-
def http_post(uri, headers, json)
|
|
302
|
-
request = Net::HTTP::Post.new(uri, headers)
|
|
303
|
-
request.body = json
|
|
304
|
-
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: @timeout,
|
|
305
|
-
read_timeout: @timeout, write_timeout: @timeout) do |http|
|
|
306
|
-
http.request(request)
|
|
307
|
-
end
|
|
308
|
-
[response.code.to_i, response.body]
|
|
309
|
-
end
|
|
310
|
-
|
|
311
|
-
def raise_for(status, envelope, path, fallback = "request failed")
|
|
312
|
-
errors = Array(envelope["errors"])
|
|
313
|
-
message = errors.map { |e| e.is_a?(Hash) ? e["message"] : e.to_s }.compact.first || fallback
|
|
314
|
-
error = status.nil? || status == 429 || status >= 500 ? Unavailable : Rejected
|
|
315
|
-
raise error.new("Waffo #{path} status=#{status}: #{message}", status: status, errors: errors)
|
|
316
|
-
end
|
|
317
|
-
|
|
318
|
-
def parse(body)
|
|
319
|
-
JSON.parse(body.to_s)
|
|
320
|
-
rescue JSON::ParserError
|
|
321
|
-
nil
|
|
322
|
-
end
|
|
323
|
-
|
|
324
|
-
def camelize(params)
|
|
325
|
-
params.each_with_object({}) do |(key, value), out|
|
|
326
|
-
next if value.nil?
|
|
327
|
-
|
|
328
|
-
out[key.to_s.gsub(/_([a-z\d])/) { Regexp.last_match(1).upcase }] = value
|
|
329
|
-
end
|
|
330
|
-
end
|
|
331
|
-
|
|
332
|
-
def blank?(value) = value.nil? || value.to_s.strip.empty?
|
|
333
|
-
|
|
334
|
-
def present?(value) = !(value.nil? || (value.respond_to?(:empty?) && value.empty?))
|
|
335
244
|
end
|
|
336
245
|
end
|
|
337
246
|
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "time"
|
|
7
|
+
require "uri"
|
|
8
|
+
|
|
9
|
+
module Waffo
|
|
10
|
+
module Pancake
|
|
11
|
+
# One POST to Waffo: HTTP, instrumentation, the log line and envelope parsing. It knows
|
|
12
|
+
# nothing about authentication, so all three entry points share it.
|
|
13
|
+
#
|
|
14
|
+
# Invariant: the body and the headers are never logged or instrumented, because they hold
|
|
15
|
+
# credentials and buyer data.
|
|
16
|
+
class Connection
|
|
17
|
+
NETWORK_ERRORS = [
|
|
18
|
+
Timeout::Error, SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError, Net::HTTPBadResponse
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
Response = Struct.new(:status, :envelope, :headers)
|
|
22
|
+
|
|
23
|
+
# `transport` takes the URI, the headers and the JSON body and answers `[status, body]` or
|
|
24
|
+
# `[status, body, headers]`.
|
|
25
|
+
def initialize(base_url:, timeout:, logger: nil, instrumenter: nil, transport: nil)
|
|
26
|
+
@base_url = base_url.to_s.chomp("/")
|
|
27
|
+
@timeout = timeout
|
|
28
|
+
@logger = logger
|
|
29
|
+
@instrumenter = instrumenter
|
|
30
|
+
@transport = transport || method(:http_post)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
attr_reader :base_url
|
|
34
|
+
|
|
35
|
+
def post(path, headers, json)
|
|
36
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
37
|
+
status, body, response_headers = instrument("request.waffo_pancake", method: "POST", path: path) do |payload|
|
|
38
|
+
call_transport(URI("#{@base_url}#{path}"), headers, json).tap { |answer| payload[:status] = answer.first }
|
|
39
|
+
end
|
|
40
|
+
elapsed = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).round(2)
|
|
41
|
+
@logger&.info("[Waffo] POST #{path} status=#{status.inspect} in #{elapsed}s")
|
|
42
|
+
|
|
43
|
+
raise Unavailable.new("Waffo #{path} unreachable: #{body}", status: nil) if status.nil?
|
|
44
|
+
|
|
45
|
+
envelope = parse(body)
|
|
46
|
+
response = Response.new(status, envelope.is_a?(Hash) ? envelope : {}, response_headers || {})
|
|
47
|
+
fail_with(path, response, "non-JSON response") unless envelope.is_a?(Hash)
|
|
48
|
+
|
|
49
|
+
warn_about(path, response)
|
|
50
|
+
response
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def fail_with(path, response, fallback = "request failed")
|
|
54
|
+
errors = Array(response.envelope["errors"])
|
|
55
|
+
message = errors.map { |e| e.is_a?(Hash) ? e["message"] : e.to_s }.compact.first || fallback
|
|
56
|
+
status = response.status
|
|
57
|
+
error = status == 429 || status >= 500 ? Unavailable : Rejected
|
|
58
|
+
raise error.new("Waffo #{path} status=#{status}: #{message}", status: status, errors: errors,
|
|
59
|
+
retry_after: retry_after(response.headers))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
def warn_about(path, response)
|
|
64
|
+
warnings = response.envelope["warnings"]
|
|
65
|
+
return if warnings.nil? || warnings.empty?
|
|
66
|
+
|
|
67
|
+
@logger&.warn("[Waffo] POST #{path} warnings=#{warnings.size}")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Seconds to wait, from delay-seconds or an HTTP-date. Nil when the header is missing or
|
|
71
|
+
# unusable, so a caller falls back to its own backoff instead of retrying at once.
|
|
72
|
+
def retry_after(headers)
|
|
73
|
+
_, value = headers.find { |name, _| name.to_s.casecmp?("retry-after") }
|
|
74
|
+
value = value.first if value.is_a?(Array)
|
|
75
|
+
value = value.to_s.strip
|
|
76
|
+
return if value.empty?
|
|
77
|
+
return value.to_i if value.match?(/\A\d+\z/)
|
|
78
|
+
|
|
79
|
+
[(Time.httpdate(value) - Time.now).ceil, 0].max
|
|
80
|
+
rescue ArgumentError
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def instrument(name, payload, &block)
|
|
85
|
+
return yield(payload) unless @instrumenter
|
|
86
|
+
|
|
87
|
+
@instrumenter.instrument(name, payload, &block)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def call_transport(uri, headers, json)
|
|
91
|
+
@transport.call(uri, headers, json)
|
|
92
|
+
rescue *NETWORK_ERRORS => e
|
|
93
|
+
[nil, "#{e.class}: #{e.message}"]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def http_post(uri, headers, json)
|
|
97
|
+
request = Net::HTTP::Post.new(uri, headers)
|
|
98
|
+
request.body = json
|
|
99
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: @timeout,
|
|
100
|
+
read_timeout: @timeout, write_timeout: @timeout) do |http|
|
|
101
|
+
http.request(request)
|
|
102
|
+
end
|
|
103
|
+
[response.code.to_i, response.body, response.to_hash]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def parse(body)
|
|
107
|
+
JSON.parse(body.to_s)
|
|
108
|
+
rescue JSON::ParserError
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "requests"
|
|
4
|
+
require_relative "api/customer_actions"
|
|
5
|
+
|
|
6
|
+
module Waffo
|
|
7
|
+
module Pancake
|
|
8
|
+
# The API as one customer, authenticated with a session token from
|
|
9
|
+
# Client#issue_session_token. Use Client#customer to build one.
|
|
10
|
+
#
|
|
11
|
+
# A token is a bearer credential and is not bound to an environment, so X-Environment is
|
|
12
|
+
# required. `inspect` hides the token, so it cannot reach a log through an error report.
|
|
13
|
+
class CustomerSession
|
|
14
|
+
include Requests
|
|
15
|
+
include API::CustomerActions
|
|
16
|
+
|
|
17
|
+
attr_reader :environment
|
|
18
|
+
|
|
19
|
+
def initialize(token:, environment:, connection:)
|
|
20
|
+
raise ConfigurationError, "Missing session token" if token.to_s.strip.empty?
|
|
21
|
+
|
|
22
|
+
@token = token
|
|
23
|
+
@environment = Requests.environment!(environment)
|
|
24
|
+
@connection = connection
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# `language` is required: without it Waffo answers 400 "Invalid input" and does not name
|
|
28
|
+
# the field. buyer_email: is required unless the checkout session already has one. Optional:
|
|
29
|
+
# user_terminal ("web" or "app").
|
|
30
|
+
def create_onetime_order(checkout_session_id:, billing_detail:, language:, **params)
|
|
31
|
+
create_order("/v1/actions/onetime-order/create-order", checkout_session_id, billing_detail, language, params)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def create_subscription_order(checkout_session_id:, billing_detail:, language:, **params)
|
|
35
|
+
create_order("/v1/actions/subscription-order/create-order", checkout_session_id, billing_detail, language, params)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def inspect = "#<#{self.class} environment=#{@environment.inspect}>"
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
attr_reader :connection
|
|
42
|
+
|
|
43
|
+
def create_order(path, checkout_session_id, billing_detail, language, params)
|
|
44
|
+
raise ArgumentError, "language is required" if blank?(language)
|
|
45
|
+
|
|
46
|
+
action(path, checkout_session_id: checkout_session_id, billing_detail: billing_detail, language: language,
|
|
47
|
+
**params)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def auth_headers(_path, _json)
|
|
51
|
+
{ "Authorization" => "Bearer #{@token}", "X-Environment" => @environment }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
data/lib/waffo/pancake/errors.rb
CHANGED
|
@@ -3,14 +3,16 @@
|
|
|
3
3
|
module Waffo
|
|
4
4
|
module Pancake
|
|
5
5
|
# Base class. `status` is the HTTP status (nil when the request never got an answer) and
|
|
6
|
-
# `errors` the envelope's `errors` array, as Waffo sent it.
|
|
6
|
+
# `errors` the envelope's `errors` array, as Waffo sent it. `retry_after` is the seconds from
|
|
7
|
+
# the Retry-After header, nil when Waffo sent none.
|
|
7
8
|
class Error < StandardError
|
|
8
|
-
attr_reader :status, :errors
|
|
9
|
+
attr_reader :status, :errors, :retry_after
|
|
9
10
|
|
|
10
|
-
def initialize(message = nil, status: nil, errors: [])
|
|
11
|
+
def initialize(message = nil, status: nil, errors: [], retry_after: nil)
|
|
11
12
|
super(message)
|
|
12
13
|
@status = status
|
|
13
14
|
@errors = errors
|
|
15
|
+
@retry_after = retry_after
|
|
14
16
|
end
|
|
15
17
|
end
|
|
16
18
|
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Waffo
|
|
6
|
+
module Pancake
|
|
7
|
+
# Sends an explicit JSON null in an update, which clears the field. A plain nil is dropped,
|
|
8
|
+
# so a variable that happens to be nil can never erase data at Waffo.
|
|
9
|
+
NULL = Object.new
|
|
10
|
+
class << NULL
|
|
11
|
+
def to_json(*) = "null"
|
|
12
|
+
def inspect = "Waffo::Pancake::NULL"
|
|
13
|
+
end
|
|
14
|
+
NULL.freeze
|
|
15
|
+
|
|
16
|
+
# The request logic shared by Client, CustomerSession and StoreCheckout. An includer defines
|
|
17
|
+
# `auth_headers(path, json)` and `connection`.
|
|
18
|
+
#
|
|
19
|
+
# Waffo refuses a request that carries two authentication methods, so an includer sends the
|
|
20
|
+
# headers of one method only.
|
|
21
|
+
module Requests
|
|
22
|
+
IDEMPOTENCY_KEY = /\A[A-Za-z0-9_-]{1,256}\z/
|
|
23
|
+
|
|
24
|
+
# Any action endpoint. Returns the envelope's `data`, plus a "warnings" key when Waffo sent
|
|
25
|
+
# any. Raises Rejected or Unavailable on `errors` or a non-2xx status.
|
|
26
|
+
#
|
|
27
|
+
# Top-level keys are sent in camelCase and nil values are dropped; use NULL to clear a field.
|
|
28
|
+
# `idempotency_key:` is sent as X-Idempotency-Key. Waffo keeps the answer for 24 hours.
|
|
29
|
+
def action(path, params = {}, **named)
|
|
30
|
+
params = params.merge(named)
|
|
31
|
+
key = params.delete(:idempotency_key)
|
|
32
|
+
response = post(path, camelize(params), idempotency_headers(key))
|
|
33
|
+
succeeded = !present?(response.envelope["errors"]) && (200..299).cover?(response.status)
|
|
34
|
+
connection.fail_with(path, response) unless succeeded
|
|
35
|
+
|
|
36
|
+
with_warnings(response.envelope)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Returns `data`. Raises when the response carries `errors` and no data, or on a non-2xx
|
|
40
|
+
# status. Partial data comes back as is, because the fields Waffo did answer are still valid.
|
|
41
|
+
def graphql(query, variables = {})
|
|
42
|
+
path = "/v1/graphql"
|
|
43
|
+
response = post(path, { "query" => query, "variables" => variables })
|
|
44
|
+
envelope = response.envelope
|
|
45
|
+
failed = (present?(envelope["errors"]) && envelope["data"].nil?) || !(200..299).cover?(response.status)
|
|
46
|
+
connection.fail_with(path, response) if failed
|
|
47
|
+
|
|
48
|
+
envelope["data"]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.environment!(value)
|
|
52
|
+
value = value.to_s.strip.downcase
|
|
53
|
+
return value if Configuration::ENVIRONMENTS.include?(value)
|
|
54
|
+
|
|
55
|
+
raise ConfigurationError, "environment must be test or prod, got #{value.inspect}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
def post(path, params, extra_headers = {})
|
|
60
|
+
json = JSON.generate(params)
|
|
61
|
+
headers = { "Content-Type" => "application/json" }.merge(auth_headers(path, json)).merge(extra_headers)
|
|
62
|
+
connection.post(path, headers, json)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def idempotency_headers(key)
|
|
66
|
+
return {} if key.nil?
|
|
67
|
+
return { "X-Idempotency-Key" => key } if key.is_a?(String) && key.match?(IDEMPOTENCY_KEY)
|
|
68
|
+
|
|
69
|
+
raise ArgumentError, "idempotency_key must be 1 to 256 characters from A-Z a-z 0-9 _ -"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def with_warnings(envelope)
|
|
73
|
+
data = envelope["data"] || {}
|
|
74
|
+
warnings = envelope["warnings"]
|
|
75
|
+
data.is_a?(Hash) && present?(warnings) ? data.merge("warnings" => warnings) : data
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def camelize(params)
|
|
79
|
+
params.each_with_object({}) do |(key, value), out|
|
|
80
|
+
next if value.nil?
|
|
81
|
+
|
|
82
|
+
out[key.to_s.gsub(/_([a-z\d])/) { Regexp.last_match(1).upcase }] = value.equal?(NULL) ? nil : value
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def blank?(value) = value.nil? || value.to_s.strip.empty?
|
|
87
|
+
|
|
88
|
+
def present?(value) = !(value.nil? || (value.respond_to?(:empty?) && value.empty?))
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "requests"
|
|
4
|
+
require_relative "connection"
|
|
5
|
+
|
|
6
|
+
module Waffo
|
|
7
|
+
module Pancake
|
|
8
|
+
# Anonymous storefront access, authenticated with only the store's public slug. It holds no
|
|
9
|
+
# secret, so it suits code that cannot keep an API key.
|
|
10
|
+
#
|
|
11
|
+
# storefront = Waffo::Pancake::StoreCheckout.new(store_slug: "my-store", environment: "test")
|
|
12
|
+
# storefront.create_checkout_session(product_id: "PROD_...", currency: "USD")
|
|
13
|
+
class StoreCheckout
|
|
14
|
+
include Requests
|
|
15
|
+
|
|
16
|
+
attr_reader :store_slug, :environment
|
|
17
|
+
|
|
18
|
+
def initialize(store_slug:, config: Pancake.configuration, environment: config.environment,
|
|
19
|
+
base_url: config.base_url, timeout: config.timeout, logger: config.logger,
|
|
20
|
+
instrumenter: config.instrumenter, transport: nil)
|
|
21
|
+
raise ConfigurationError, "Missing store_slug" if store_slug.to_s.strip.empty?
|
|
22
|
+
|
|
23
|
+
@store_slug = store_slug
|
|
24
|
+
@environment = Requests.environment!(environment)
|
|
25
|
+
@connection = Connection.new(base_url: base_url, timeout: timeout, logger: logger, instrumenter: instrumenter,
|
|
26
|
+
transport: transport)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def create_checkout_session(product_id:, currency:, **params)
|
|
30
|
+
action("/v1/actions/checkout/create-session", product_id: product_id, currency: currency, **params)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
attr_reader :connection
|
|
35
|
+
|
|
36
|
+
def auth_headers(_path, _json)
|
|
37
|
+
{ "X-Store-Slug" => @store_slug, "X-Environment" => @environment }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|