mpp-rb 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +63 -1
- data/lib/mpp/challenge.rb +15 -5
- data/lib/mpp/client/transport.rb +5 -4
- data/lib/mpp/errors.rb +22 -0
- data/lib/mpp/http/headers.rb +61 -0
- data/lib/mpp/http.rb +8 -0
- data/lib/mpp/methods/evm/assets.rb +103 -0
- data/lib/mpp/methods/evm/authorization.rb +140 -0
- data/lib/mpp/methods/evm/charge_intent.rb +129 -0
- data/lib/mpp/methods/evm/evm_method.rb +117 -0
- data/lib/mpp/methods/evm.rb +64 -0
- data/lib/mpp/methods/stripe/charge_intent.rb +4 -1
- data/lib/mpp/methods/tempo/attribution.rb +3 -9
- data/lib/mpp/methods/tempo/client_method.rb +17 -6
- data/lib/mpp/methods/tempo/fee_payer_client.rb +120 -0
- data/lib/mpp/methods/tempo/intents.rb +27 -7
- data/lib/mpp/methods/tempo/proof.rb +39 -12
- data/lib/mpp/methods/tempo/relay.rb +257 -0
- data/lib/mpp/methods/tempo.rb +2 -0
- data/lib/mpp/parsing.rb +2 -0
- data/lib/mpp/receipt.rb +4 -3
- data/lib/mpp/server/accept_payment.rb +194 -0
- data/lib/mpp/server/compose.rb +387 -0
- data/lib/mpp/server/decorator.rb +28 -7
- data/lib/mpp/server/middleware.rb +162 -30
- data/lib/mpp/server/mpp_handler.rb +267 -33
- data/lib/mpp/server/result.rb +100 -0
- data/lib/mpp/server.rb +4 -0
- data/lib/mpp/version.rb +1 -1
- data/lib/mpp/x402/facilitator.rb +127 -0
- data/lib/mpp/x402/header.rb +131 -0
- data/lib/mpp/x402/server.rb +247 -0
- data/lib/mpp/x402/types.rb +33 -0
- data/lib/mpp/x402.rb +12 -0
- data/lib/mpp.rb +6 -3
- metadata +32 -1
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# typed: false
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "digest"
|
|
5
|
+
require "json"
|
|
6
|
+
require "net/http"
|
|
7
|
+
require "time"
|
|
8
|
+
require "uri"
|
|
9
|
+
require_relative "../../http/headers"
|
|
10
|
+
require_relative "attribution"
|
|
11
|
+
|
|
12
|
+
module Mpp
|
|
13
|
+
module Methods
|
|
14
|
+
module Tempo
|
|
15
|
+
# HTTP client for a Tempo API-compatible MPP relay.
|
|
16
|
+
#
|
|
17
|
+
# `resolve` accepts:
|
|
18
|
+
# * a URL string (unauthenticated relay)
|
|
19
|
+
# * a config hash (`url:` plus optional `headers:`, or mppx-style
|
|
20
|
+
# `api_base_url:` / `api_key:`)
|
|
21
|
+
# * any object that responds to `#validate` and `#broadcast`
|
|
22
|
+
class Relay
|
|
23
|
+
DEFAULT_API_BASE_URL = "https://api.tempo.xyz"
|
|
24
|
+
VALIDATE_PATH = "/v1/mpp/validate"
|
|
25
|
+
BROADCAST_PATH = "/v1/mpp/broadcast"
|
|
26
|
+
DEFAULT_TIMEOUT = Rpc::DEFAULT_TIMEOUT
|
|
27
|
+
|
|
28
|
+
ERROR_CODES = %w[
|
|
29
|
+
already_used
|
|
30
|
+
broadcast_failed
|
|
31
|
+
expired
|
|
32
|
+
invalid_payment
|
|
33
|
+
insufficient_funds
|
|
34
|
+
policy_denied
|
|
35
|
+
screen_rejected
|
|
36
|
+
simulation_failed
|
|
37
|
+
temporarily_unavailable
|
|
38
|
+
unsupported
|
|
39
|
+
unknown
|
|
40
|
+
].freeze
|
|
41
|
+
|
|
42
|
+
attr_reader :base_url
|
|
43
|
+
|
|
44
|
+
def initialize(url, headers: nil)
|
|
45
|
+
@base_url = Mpp::Http::Headers.normalize_base_url(url)
|
|
46
|
+
raise ArgumentError, "relay url is required" if @base_url.empty?
|
|
47
|
+
|
|
48
|
+
@headers = headers
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.resolve_optional(relay)
|
|
52
|
+
return if relay.nil? || relay == false
|
|
53
|
+
|
|
54
|
+
resolve(relay)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.resolve(relay)
|
|
58
|
+
return relay if relay.is_a?(Relay)
|
|
59
|
+
return new(relay) if relay.is_a?(String) && !relay.empty?
|
|
60
|
+
return from_config(relay) if relay.is_a?(Hash)
|
|
61
|
+
return relay if duck_type?(relay)
|
|
62
|
+
|
|
63
|
+
raise ArgumentError, "relay must be a URL, {url:, headers:}, or an object that implements #validate and #broadcast"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.from_config(config)
|
|
67
|
+
cfg = Mpp::Http::Headers.symbolize(config)
|
|
68
|
+
url = cfg[:url] || cfg[:api_base_url] || cfg[:apiBaseUrl] || cfg[:base_url] || cfg[:baseUrl]
|
|
69
|
+
url = DEFAULT_API_BASE_URL if url.nil? || url.to_s.empty?
|
|
70
|
+
headers = merge_api_key_headers(cfg[:headers], cfg[:api_key] || cfg[:apiKey])
|
|
71
|
+
new(url.to_s, headers: headers)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.duck_type?(relay)
|
|
75
|
+
!relay.nil? && relay.respond_to?(:validate) && relay.respond_to?(:broadcast)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.merge_api_key_headers(headers, api_key)
|
|
79
|
+
return headers if api_key.nil? || api_key.to_s.empty?
|
|
80
|
+
|
|
81
|
+
defaults = {"tempo-api-key" => api_key.to_s}
|
|
82
|
+
return defaults if headers.nil?
|
|
83
|
+
|
|
84
|
+
if headers.respond_to?(:call)
|
|
85
|
+
original = headers
|
|
86
|
+
->(path) {
|
|
87
|
+
extra = (original.arity == 0) ? original.call : original.call(path)
|
|
88
|
+
extra = {} unless extra.is_a?(Hash)
|
|
89
|
+
defaults.merge(Mpp::Http::Headers.stringify(extra))
|
|
90
|
+
}
|
|
91
|
+
elsif headers.is_a?(Hash)
|
|
92
|
+
defaults.merge(Mpp::Http::Headers.stringify(headers))
|
|
93
|
+
else
|
|
94
|
+
defaults
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
private_class_method :merge_api_key_headers
|
|
98
|
+
|
|
99
|
+
def verify(credential, _request = nil)
|
|
100
|
+
input = to_relay_input(credential)
|
|
101
|
+
validate(input)
|
|
102
|
+
broadcast(input)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def validate(input)
|
|
106
|
+
response = post(VALIDATE_PATH, input)
|
|
107
|
+
raise failure(response) unless success?(response)
|
|
108
|
+
|
|
109
|
+
true
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def broadcast(input)
|
|
113
|
+
response = post(BROADCAST_PATH, input, extra_headers: {
|
|
114
|
+
"Idempotency-Key" => idempotency_key(input)
|
|
115
|
+
})
|
|
116
|
+
raise failure(response) unless broadcast_success?(response)
|
|
117
|
+
|
|
118
|
+
to_receipt(response.fetch("receipt"))
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def post(path, input, extra_headers: {})
|
|
124
|
+
uri = URI.parse("#{@base_url}#{path}")
|
|
125
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
126
|
+
http.use_ssl = uri.scheme == "https"
|
|
127
|
+
http.read_timeout = DEFAULT_TIMEOUT
|
|
128
|
+
|
|
129
|
+
request = Net::HTTP::Post.new(uri)
|
|
130
|
+
request["Accept"] = "application/json"
|
|
131
|
+
request["Content-Type"] = "application/json"
|
|
132
|
+
Mpp::Http::Headers.resolve(@headers, path).each { |key, value| request[key] = value }
|
|
133
|
+
extra_headers.each { |key, value| request[key] = value }
|
|
134
|
+
request.body = JSON.generate(input)
|
|
135
|
+
|
|
136
|
+
response = http.request(request)
|
|
137
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
138
|
+
raise Mpp::VerificationFailedError.new(reason: "relay #{path} returned HTTP #{response.code}")
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
body = response.body.to_s
|
|
142
|
+
if body.empty?
|
|
143
|
+
raise Mpp::VerificationFailedError.new(reason: "relay #{path} returned an empty body")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
parsed = JSON.parse(body)
|
|
147
|
+
unless parsed.is_a?(Hash)
|
|
148
|
+
raise Mpp::VerificationFailedError.new(reason: "relay #{path} returned JSON that is not an object")
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
parsed
|
|
152
|
+
rescue JSON::ParserError
|
|
153
|
+
raise Mpp::VerificationFailedError.new(reason: "relay #{path} returned invalid JSON")
|
|
154
|
+
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ETIMEDOUT, Net::OpenTimeout, Net::ReadTimeout, SocketError => e
|
|
155
|
+
raise Mpp::VerificationFailedError.new(reason: "relay request failed: #{e.message}")
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def to_relay_input(credential)
|
|
159
|
+
echo = credential.challenge
|
|
160
|
+
challenge = {
|
|
161
|
+
"id" => echo.id,
|
|
162
|
+
"realm" => echo.realm,
|
|
163
|
+
"method" => echo.method,
|
|
164
|
+
"intent" => echo.intent,
|
|
165
|
+
"request" => decode_request(echo.request)
|
|
166
|
+
}
|
|
167
|
+
challenge["expires"] = echo.expires if echo.expires
|
|
168
|
+
challenge["digest"] = echo.digest if echo.digest
|
|
169
|
+
challenge["opaque"] = echo.opaque if echo.opaque
|
|
170
|
+
|
|
171
|
+
input = {
|
|
172
|
+
"challenge" => challenge,
|
|
173
|
+
"payload" => credential.payload
|
|
174
|
+
}
|
|
175
|
+
input["source"] = credential.source if credential.source
|
|
176
|
+
input
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def decode_request(request)
|
|
180
|
+
return request if request.is_a?(Hash)
|
|
181
|
+
return request unless request.is_a?(String) && !request.empty?
|
|
182
|
+
|
|
183
|
+
Mpp::Parsing.b64_decode(request)
|
|
184
|
+
rescue Mpp::ParseError
|
|
185
|
+
request
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def idempotency_key(input)
|
|
189
|
+
payload = input["payload"]
|
|
190
|
+
if payload.is_a?(Hash) && payload["type"] == "transaction" && payload["signature"].is_a?(String)
|
|
191
|
+
hex = payload["signature"].delete_prefix("0x")
|
|
192
|
+
if hex.match?(/\A[0-9a-fA-F]+\z/) && hex.length.even?
|
|
193
|
+
hash = Attribution.keccak256([hex].pack("H*"))
|
|
194
|
+
return "mpp_0x#{hash.unpack1("H*")}"
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
digest = Digest::SHA256.hexdigest(Mpp::Json.compact_encode(input))
|
|
199
|
+
"mpp_0x#{digest}"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def success?(response)
|
|
203
|
+
response.is_a?(Hash) && response["success"] == true
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def broadcast_success?(response)
|
|
207
|
+
success?(response) && relay_receipt?(response["receipt"])
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def relay_receipt?(value)
|
|
211
|
+
value.is_a?(Hash) &&
|
|
212
|
+
value["method"].is_a?(String) &&
|
|
213
|
+
value["reference"].is_a?(String) &&
|
|
214
|
+
value["timestamp"].is_a?(String) &&
|
|
215
|
+
(value["externalId"].nil? || value["externalId"].is_a?(String))
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def to_receipt(receipt)
|
|
219
|
+
timestamp = begin
|
|
220
|
+
Time.iso8601(receipt["timestamp"].to_s.gsub("Z", "+00:00"))
|
|
221
|
+
rescue ArgumentError
|
|
222
|
+
raise failure
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
Mpp::Receipt.success(
|
|
226
|
+
receipt["reference"],
|
|
227
|
+
timestamp: timestamp,
|
|
228
|
+
method: receipt["method"],
|
|
229
|
+
external_id: receipt["externalId"]
|
|
230
|
+
)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def failure(response = nil)
|
|
234
|
+
code = relay_error_code(response)
|
|
235
|
+
return Mpp::PaymentExpiredError.new if code == "expired"
|
|
236
|
+
|
|
237
|
+
reason = case code
|
|
238
|
+
when "already_used", "broadcast_failed", "insufficient_funds", "invalid_payment",
|
|
239
|
+
"simulation_failed", "unsupported", "temporarily_unavailable"
|
|
240
|
+
code.tr("_", " ")
|
|
241
|
+
end
|
|
242
|
+
Mpp::VerificationFailedError.new(reason: reason)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def relay_error_code(value)
|
|
246
|
+
return unless value.is_a?(Hash)
|
|
247
|
+
|
|
248
|
+
error = value["error"]
|
|
249
|
+
return unless error.is_a?(Hash)
|
|
250
|
+
|
|
251
|
+
code = error["code"]
|
|
252
|
+
code if ERROR_CODES.include?(code)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
data/lib/mpp/methods/tempo.rb
CHANGED
|
@@ -11,6 +11,8 @@ module Mpp
|
|
|
11
11
|
autoload :Rpc, "mpp/methods/tempo/rpc"
|
|
12
12
|
autoload :Transaction, "mpp/methods/tempo/transaction"
|
|
13
13
|
autoload :FeePayerPolicy, "mpp/methods/tempo/fee_payer_policy"
|
|
14
|
+
autoload :FeePayerClient, "mpp/methods/tempo/fee_payer_client"
|
|
15
|
+
autoload :Relay, "mpp/methods/tempo/relay"
|
|
14
16
|
autoload :Schemas, "mpp/methods/tempo/schemas"
|
|
15
17
|
# Eagerly require client_method so the Tempo.tempo factory method is available
|
|
16
18
|
require_relative "tempo/client_method"
|
data/lib/mpp/parsing.rb
CHANGED
|
@@ -235,6 +235,7 @@ module Mpp
|
|
|
235
235
|
reference: data["reference"].to_s,
|
|
236
236
|
method: method,
|
|
237
237
|
external_id: data["externalId"]&.to_s,
|
|
238
|
+
subscription_id: data["subscriptionId"]&.to_s,
|
|
238
239
|
extra: extra
|
|
239
240
|
)
|
|
240
241
|
end
|
|
@@ -256,6 +257,7 @@ module Mpp
|
|
|
256
257
|
"timestamp" => timestamp_str
|
|
257
258
|
}
|
|
258
259
|
payload["externalId"] = receipt.external_id if receipt.external_id
|
|
260
|
+
payload["subscriptionId"] = receipt.subscription_id if receipt.subscription_id
|
|
259
261
|
payload["extra"] = receipt.extra if receipt.extra
|
|
260
262
|
|
|
261
263
|
b64_encode(payload)
|
data/lib/mpp/receipt.rb
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
module Mpp
|
|
5
|
-
Receipt = Data.define(:status, :timestamp, :reference, :method, :external_id, :extra) do
|
|
6
|
-
def initialize(status:, timestamp:, reference:, method: "", external_id: nil, extra: nil)
|
|
5
|
+
Receipt = Data.define(:status, :timestamp, :reference, :method, :external_id, :extra, :subscription_id) do
|
|
6
|
+
def initialize(status:, timestamp:, reference:, method: "", external_id: nil, extra: nil, subscription_id: nil)
|
|
7
7
|
super
|
|
8
8
|
end
|
|
9
9
|
|
|
@@ -18,13 +18,14 @@ module Mpp
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
# Create a success receipt with current timestamp.
|
|
21
|
-
def self.success(reference, timestamp: nil, method: "tempo", external_id: nil, extra: nil)
|
|
21
|
+
def self.success(reference, timestamp: nil, method: "tempo", external_id: nil, extra: nil, subscription_id: nil)
|
|
22
22
|
new(
|
|
23
23
|
status: "success",
|
|
24
24
|
timestamp: timestamp || Time.now.utc,
|
|
25
25
|
reference: reference,
|
|
26
26
|
method: method,
|
|
27
27
|
external_id: external_id,
|
|
28
|
+
subscription_id: subscription_id,
|
|
28
29
|
extra: extra
|
|
29
30
|
)
|
|
30
31
|
end
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Mpp
|
|
5
|
+
module Server
|
|
6
|
+
# Parse, format, and rank the Accept-Payment client-preference header.
|
|
7
|
+
#
|
|
8
|
+
# Syntax mirrors HTTP content negotiation: `method/intent[;q=value]`,
|
|
9
|
+
# comma-separated. Wildcards (`*` / `tempo/*` / `*/charge`) are allowed.
|
|
10
|
+
# Entries with q=0 are excluded. If the header is empty, invalid, or
|
|
11
|
+
# filters out every offer, the original offer list is returned.
|
|
12
|
+
module AcceptPayment
|
|
13
|
+
extend T::Sig
|
|
14
|
+
|
|
15
|
+
TOKEN_RE = /\A(?:\*|[a-z0-9-]+)\z/
|
|
16
|
+
ENTRY_RE = %r{\A(?<method>[^/;\s]+|\*)\s*/\s*(?<intent>[^/;\s]+|\*)(?<params>(?:\s*;\s*.+)?)\z}
|
|
17
|
+
PARAM_RE = /\A(?<name>[A-Za-z0-9_-]+)\s*=\s*(?<value>\S+)\z/
|
|
18
|
+
QVALUE_RE = /\A(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)\z/
|
|
19
|
+
|
|
20
|
+
Entry = T.type_alias { T::Hash[Symbol, T.untyped] }
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# Parse an Accept-Payment header. Raises ArgumentError on malformed input.
|
|
25
|
+
sig { params(header: String).returns(T::Array[Entry]) }
|
|
26
|
+
def parse(header)
|
|
27
|
+
parts = header.split(",").map(&:strip).reject(&:empty?)
|
|
28
|
+
Kernel.raise ArgumentError, "Accept-Payment header is empty." if parts.empty?
|
|
29
|
+
|
|
30
|
+
parts.each_with_index.map { |part, index| parse_entry(part, index) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Filter and reorder offers by Accept-Payment. Returns `offers` unchanged
|
|
34
|
+
# when the header is missing, malformed, or matches nothing.
|
|
35
|
+
sig { params(offers: T::Array[T.untyped], header: T.nilable(String)).returns(T::Array[T.untyped]) }
|
|
36
|
+
def apply(offers, header)
|
|
37
|
+
return offers if header.nil? || header.strip.empty?
|
|
38
|
+
|
|
39
|
+
begin
|
|
40
|
+
preferences = parse(header)
|
|
41
|
+
rescue ArgumentError
|
|
42
|
+
return offers
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
ranked = rank(offers, preferences)
|
|
46
|
+
ranked.empty? ? offers : ranked
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Order offers by the best matching client preference.
|
|
50
|
+
# More specific matches win before comparing q-values.
|
|
51
|
+
sig { params(offers: T::Array[T.untyped], preferences: T::Array[Entry]).returns(T::Array[T.untyped]) }
|
|
52
|
+
def rank(offers, preferences)
|
|
53
|
+
scored = []
|
|
54
|
+
offers.each_with_index do |offer, index|
|
|
55
|
+
match = best_match(offer, preferences)
|
|
56
|
+
next unless match && T.unsafe(match[:q]) > 0
|
|
57
|
+
|
|
58
|
+
scored << {match: match, offer: offer, index: index}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
scored.sort_by! { |row| [-T.unsafe(row[:match][:q]), T.unsafe(row[:index])] }
|
|
62
|
+
scored.map { |row| row[:offer] }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
sig { params(value: T.untyped).returns(String) }
|
|
66
|
+
def key_of(value)
|
|
67
|
+
method, intent = method_intent(value)
|
|
68
|
+
Kernel.raise ArgumentError, "Missing payment method name." if method.empty?
|
|
69
|
+
|
|
70
|
+
"#{method}/#{intent}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
sig { params(part: String, index: Integer).returns(Entry) }
|
|
74
|
+
def parse_entry(part, index)
|
|
75
|
+
match = ENTRY_RE.match(part)
|
|
76
|
+
Kernel.raise ArgumentError, "Invalid Accept-Payment entry: #{part}" unless match
|
|
77
|
+
|
|
78
|
+
method = T.must(match[:method])
|
|
79
|
+
intent = T.must(match[:intent])
|
|
80
|
+
assert_token!(method, "method")
|
|
81
|
+
assert_token!(intent, "intent")
|
|
82
|
+
|
|
83
|
+
q = 1.0
|
|
84
|
+
split_parameters(match[:params]).each do |param|
|
|
85
|
+
next if param.empty?
|
|
86
|
+
|
|
87
|
+
parameter_match = PARAM_RE.match(param)
|
|
88
|
+
Kernel.raise ArgumentError, "Invalid Accept-Payment parameter: #{param}" unless parameter_match
|
|
89
|
+
|
|
90
|
+
next unless parameter_match[:name] == "q"
|
|
91
|
+
|
|
92
|
+
q = parse_header_q(T.must(parameter_match[:value]), %(Accept-Payment entry "#{part}"))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
{method: method, intent: intent, q: q, index: index}
|
|
96
|
+
end
|
|
97
|
+
private_class_method :parse_entry
|
|
98
|
+
|
|
99
|
+
sig { params(offer: T.untyped, preferences: T::Array[Entry]).returns(T.nilable(Entry)) }
|
|
100
|
+
def best_match(offer, preferences)
|
|
101
|
+
method, intent = method_intent(offer)
|
|
102
|
+
best = T.let(nil, T.nilable(Entry))
|
|
103
|
+
|
|
104
|
+
preferences.each do |preference|
|
|
105
|
+
next unless matches?(method, intent, preference)
|
|
106
|
+
|
|
107
|
+
candidate = preference.merge(specificity: specificity(preference))
|
|
108
|
+
if better_match?(candidate, best)
|
|
109
|
+
best = candidate
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
best
|
|
114
|
+
end
|
|
115
|
+
private_class_method :best_match
|
|
116
|
+
|
|
117
|
+
sig { params(candidate: Entry, best: T.nilable(Entry)).returns(T::Boolean) }
|
|
118
|
+
def better_match?(candidate, best)
|
|
119
|
+
return true if best.nil?
|
|
120
|
+
return true if T.unsafe(candidate[:specificity]) > T.unsafe(best[:specificity])
|
|
121
|
+
return true if T.unsafe(candidate[:specificity]) == T.unsafe(best[:specificity]) &&
|
|
122
|
+
T.unsafe(candidate[:q]) > T.unsafe(best[:q])
|
|
123
|
+
return true if T.unsafe(candidate[:specificity]) == T.unsafe(best[:specificity]) &&
|
|
124
|
+
T.unsafe(candidate[:q]) == T.unsafe(best[:q]) &&
|
|
125
|
+
T.unsafe(candidate[:index]) < T.unsafe(best[:index])
|
|
126
|
+
|
|
127
|
+
false
|
|
128
|
+
end
|
|
129
|
+
private_class_method :better_match?
|
|
130
|
+
|
|
131
|
+
sig { params(method: String, intent: String, preference: Entry).returns(T::Boolean) }
|
|
132
|
+
def matches?(method, intent, preference)
|
|
133
|
+
(preference[:method] == "*" || preference[:method] == method) &&
|
|
134
|
+
(preference[:intent] == "*" || preference[:intent] == intent)
|
|
135
|
+
end
|
|
136
|
+
private_class_method :matches?
|
|
137
|
+
|
|
138
|
+
sig { params(preference: Entry).returns(Integer) }
|
|
139
|
+
def specificity(preference)
|
|
140
|
+
((preference[:method] == "*") ? 0 : 1) + ((preference[:intent] == "*") ? 0 : 1)
|
|
141
|
+
end
|
|
142
|
+
private_class_method :specificity
|
|
143
|
+
|
|
144
|
+
sig { params(offer: T.untyped).returns([String, String]) }
|
|
145
|
+
def method_intent(offer)
|
|
146
|
+
if offer.is_a?(Hash)
|
|
147
|
+
[(offer[:method] || offer["method"]).to_s, (offer[:intent] || offer["intent"]).to_s]
|
|
148
|
+
else
|
|
149
|
+
[offer.method.to_s, offer.intent.to_s]
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
private_class_method :method_intent
|
|
153
|
+
|
|
154
|
+
sig { params(value: T.nilable(String)).returns(T::Array[String]) }
|
|
155
|
+
def split_parameters(value)
|
|
156
|
+
return [] if value.nil? || value.empty?
|
|
157
|
+
|
|
158
|
+
value.split(";").map(&:strip).reject(&:empty?)
|
|
159
|
+
end
|
|
160
|
+
private_class_method :split_parameters
|
|
161
|
+
|
|
162
|
+
sig { params(value: String, context: String).returns(Float) }
|
|
163
|
+
def parse_header_q(value, context)
|
|
164
|
+
unless QVALUE_RE.match?(value)
|
|
165
|
+
Kernel.raise ArgumentError, "Invalid q-value for #{context}. Expected an HTTP qvalue."
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
assert_q(Kernel.Float(value), context)
|
|
169
|
+
end
|
|
170
|
+
private_class_method :parse_header_q
|
|
171
|
+
|
|
172
|
+
sig { params(value: Float, context: String).returns(Float) }
|
|
173
|
+
def assert_q(value, context)
|
|
174
|
+
Kernel.raise ArgumentError, "Invalid q-value for #{context}. Expected a value between 0 and 1." if value.negative? || value > 1
|
|
175
|
+
|
|
176
|
+
rounded = (value * 1000).round
|
|
177
|
+
if (value * 1000 - rounded).abs > 1e-9
|
|
178
|
+
Kernel.raise ArgumentError, "Invalid q-value for #{context}. Expected at most 3 decimal places."
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
rounded / 1000.0
|
|
182
|
+
end
|
|
183
|
+
private_class_method :assert_q
|
|
184
|
+
|
|
185
|
+
sig { params(value: String, label: String).void }
|
|
186
|
+
def assert_token!(value, label)
|
|
187
|
+
return if TOKEN_RE.match?(value)
|
|
188
|
+
|
|
189
|
+
Kernel.raise ArgumentError, "Invalid Accept-Payment #{label}: #{value}"
|
|
190
|
+
end
|
|
191
|
+
private_class_method :assert_token!
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|