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.
@@ -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
- require "uri"
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]` (status nil when there was
56
- # no answer). `logger` gets one info line per request and `instrumenter` one
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, base_url: config.base_url, timeout: config.timeout, logger: config.logger,
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
- @timeout = timeout
69
- @logger = logger
70
- @instrumenter = instrumenter
71
- @transport = transport || method(:http_post)
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
- { buyer_identity: buyer_identity, store_id: store_id, product_id: product_id, purpose: purpose })
83
- end
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
- # --- Products ----------------------------------------------------------------------
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
@@ -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