Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,35 @@ transport = Mpp::Client::Transport.new(
response = transport.request(:get, "https://mpp.dev/api/ping/paid")
```

### Event hooks

Register hooks to observe the automatic payment lifecycle. Each registration returns an unsubscribe proc.

```ruby
server.on_challenge_created do |payload|
puts "challenge: #{payload[:challenge].id}"
end

server.on_payment_success do |payload|
puts "paid: #{payload[:receipt].reference}"
end

transport.on_challenge_received do |payload|
puts "received: #{payload[:challenge].id}"
nil
end

transport.on_payment_response do |payload|
puts "retry status: #{payload[:response].code}"
end

transport.on("*") do |event|
puts "payment event: #{event.name}"
end
```

Client events are `challenge.received`, `credential.created`, `payment.response`, and `payment.failed`. Server events are `challenge.created`, `payment.success`, and `payment.failed`.

### Rack Middleware

```ruby
Expand Down
7 changes: 4 additions & 3 deletions lib/mpp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ module Mpp
autoload :Json, "mpp/json"
autoload :BodyDigest, "mpp/body_digest"
autoload :Expires, "mpp/expires"
autoload :Events, "mpp/events"
autoload :Units, "mpp/units"
autoload :MemoryStore, "mpp/store"

Expand All @@ -39,9 +40,9 @@ module Extensions
autoload :MCP, "mpp/extensions/mcp"
end

sig { params(method: T.untyped, realm: T.untyped, secret_key: T.untyped).returns(T.untyped) }
def self.create(method:, realm: nil, secret_key: nil)
Server::MppHandler.create(method: method, realm: realm, secret_key: secret_key)
sig { params(method: T.untyped, realm: T.untyped, secret_key: T.untyped, events: T.nilable(Mpp::Events::Dispatcher)).returns(T.untyped) }
def self.create(method:, realm: nil, secret_key: nil, events: nil)
Server::MppHandler.create(method: method, realm: realm, secret_key: secret_key, events: events)
end

# Error hierarchy
Expand Down
171 changes: 153 additions & 18 deletions lib/mpp/client/transport.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,35 @@ module Client
class Transport
extend T::Sig

sig { params(methods: T::Array[T.untyped]).void }
def initialize(methods:)
sig { params(methods: T::Array[T.untyped], events: T.nilable(Mpp::Events::Dispatcher)).void }
def initialize(methods:, events: nil)
@methods = T.let(methods.to_h { |m| [m.name, m] }, T::Hash[String, T.untyped])
@events = T.let(events || Mpp::Events.client_dispatcher, Mpp::Events::Dispatcher)
end

sig { params(name: String, handler: T.nilable(T.untyped), block: T.nilable(T.proc.params(payload: T.untyped).returns(T.untyped))).returns(T.proc.void) }
def on(name, handler = nil, &block)
@events.on(name, handler, &block)
end

sig { params(handler: T.nilable(T.untyped), block: T.nilable(T.proc.params(payload: T.untyped).returns(T.untyped))).returns(T.proc.void) }
def on_challenge_received(handler = nil, &block)
on(Mpp::Events::CHALLENGE_RECEIVED, handler, &block)
end

sig { params(handler: T.nilable(T.untyped), block: T.nilable(T.proc.params(payload: T.untyped).returns(T.untyped))).returns(T.proc.void) }
def on_credential_created(handler = nil, &block)
on(Mpp::Events::CREDENTIAL_CREATED, handler, &block)
end

sig { params(handler: T.nilable(T.untyped), block: T.nilable(T.proc.params(payload: T.untyped).returns(T.untyped))).returns(T.proc.void) }
def on_payment_failed(handler = nil, &block)
on(Mpp::Events::PAYMENT_FAILED, handler, &block)
end

sig { params(handler: T.nilable(T.untyped), block: T.nilable(T.proc.params(payload: T.untyped).returns(T.untyped))).returns(T.proc.void) }
def on_payment_response(handler = nil, &block)
on(Mpp::Events::PAYMENT_RESPONSE, handler, &block)
end

# Send an HTTP request with automatic 402 payment handling.
Expand All @@ -34,7 +60,7 @@ def request(method, url, headers: {}, body: nil)

# Parse WWW-Authenticate headers
www_auth_headers = response.get_fields("www-authenticate") || []
challenge, matched_method = find_matching_challenge(www_auth_headers)
challenge, matched_method = find_matching_challenge(www_auth_headers, input: url, response: response)
return response unless challenge && matched_method

# Check expiry before paying (client-side guardrail)
Expand All @@ -47,11 +73,90 @@ def request(method, url, headers: {}, body: nil)
end
end

credential = matched_method.create_credential(challenge)
auth_header = credential.to_authorization
auth_header = nil
create_credential = Kernel.lambda do
auth_header ||= credential_authorization(matched_method.create_credential(challenge))
end

begin
event_credential = nil
if @events.has_handlers?(Mpp::Events::CHALLENGE_RECEIVED)
# challenge.received can override credential creation; first non-empty credential wins.
event_credential = @events.emit_first(Mpp::Events::CHALLENGE_RECEIVED, {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it significant that this event is the only one to use emit_first?

challenge: challenge,
challenges: [challenge],
create_credential: create_credential,
input: url,
method: matched_method,
response: response
})
end
auth_header = credential_authorization(event_credential) unless event_credential.nil?
auth_header ||= create_credential.call

if @events.has_handlers?(Mpp::Events::CREDENTIAL_CREATED)
@events.emit(Mpp::Events::CREDENTIAL_CREATED, {
challenge: challenge,
credential: auth_header,
input: url,
method: matched_method,
response: response
})
end
rescue => e
if @events.has_handlers?(Mpp::Events::PAYMENT_FAILED)
@events.emit(Mpp::Events::PAYMENT_FAILED, {
challenge: challenge,
challenges: [challenge],
error: e,
input: url,
method: matched_method,
response: response
})
end
raise
end

retry_headers = headers.merge("Authorization" => auth_header)
send_request(uri, method, retry_headers, body)
payment_response = nil
begin
payment_response = send_request(uri, method, retry_headers, body)
rescue => e
if @events.has_handlers?(Mpp::Events::PAYMENT_FAILED)
@events.emit(Mpp::Events::PAYMENT_FAILED, {
challenge: challenge,
challenges: [challenge],
credential: auth_header,
error: e,
input: url,
method: matched_method,
response: response
})
end
raise
end

if payment_response.code.to_i.between?(200, 299) && @events.has_handlers?(Mpp::Events::PAYMENT_RESPONSE)
@events.emit(Mpp::Events::PAYMENT_RESPONSE, {
challenge: challenge,
credential: auth_header,
input: url,
method: matched_method,
response: payment_response
})
elsif @events.has_handlers?(Mpp::Events::PAYMENT_FAILED)
@events.emit(Mpp::Events::PAYMENT_FAILED, {
challenge: challenge,
challenges: [challenge],
credential: auth_header,
error: Mpp::VerificationFailedError.new(reason: "retry returned HTTP #{payment_response.code}"),
input: url,
method: matched_method,
response: payment_response
})
end
Comment on lines +139 to +157
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we emit payment failed events when it's non-2xx?


payment_response
end

sig { params(url: T.any(URI::Generic, String), kwargs: T.untyped).returns(T.untyped) }
Expand Down Expand Up @@ -97,41 +202,71 @@ def send_request(uri, method, headers, body)
http.request(req)
end

sig { params(www_auth_headers: T.untyped).returns(T::Array[T.untyped]) }
def find_matching_challenge(www_auth_headers)
sig { params(www_auth_headers: T.untyped, input: T.untyped, response: T.untyped).returns(T::Array[T.untyped]) }
def find_matching_challenge(www_auth_headers, input: nil, response: nil)
www_auth_headers.each do |header|
next unless header.downcase.start_with?("payment ")

begin
parsed = Mpp::Challenge.from_www_authenticate(header)
return [parsed, @methods[parsed.method]] if @methods.key?(parsed.method)
rescue Mpp::ParseError
rescue Mpp::ParseError => e
if @events.has_handlers?(Mpp::Events::PAYMENT_FAILED)
@events.emit(Mpp::Events::PAYMENT_FAILED, {
error: e,
input: input,
response: response
})
end
next
end
end
[nil, nil]
end

sig { params(credential: T.untyped).returns(String) }
def credential_authorization(credential)
auth_header = if credential.respond_to?(:to_authorization)
credential.to_authorization
elsif credential.is_a?(String)
credential
else
raise ArgumentError, "Credential must be a String or respond to #to_authorization"
end

validate_authorization_header(auth_header)
auth_header
end

sig { params(auth_header: String).void }
def validate_authorization_header(auth_header)
unless auth_header.start_with?("Payment ") && auth_header.length > 8
raise ArgumentError, "Credential must be a non-empty Payment authorization header"
end

raise ArgumentError, "Credential contains invalid header characters" if auth_header.match?(/[[:cntrl:]]/)
end
end

# Module-level convenience methods
extend T::Sig

module_function

sig { params(method: T.untyped, url: T.untyped, methods: T::Array[T.untyped], kwargs: T.untyped).returns(T.untyped) }
def request(method, url, methods:, **kwargs)
transport = Transport.new(methods: methods)
sig { params(method: T.untyped, url: T.untyped, methods: T::Array[T.untyped], events: T.nilable(Mpp::Events::Dispatcher), kwargs: T.untyped).returns(T.untyped) }
def request(method, url, methods:, events: nil, **kwargs)
transport = Transport.new(methods: methods, events: events)
transport.request(method, url, **kwargs)
end

sig { params(url: T.untyped, methods: T::Array[T.untyped], kwargs: T.untyped).returns(T.untyped) }
def get(url, methods:, **kwargs)
request("GET", url, **T.unsafe({methods: methods, **kwargs}))
sig { params(url: T.untyped, methods: T::Array[T.untyped], events: T.nilable(Mpp::Events::Dispatcher), kwargs: T.untyped).returns(T.untyped) }
def get(url, methods:, events: nil, **kwargs)
request("GET", url, **T.unsafe({methods: methods, events: events, **kwargs}))
end

sig { params(url: T.untyped, methods: T::Array[T.untyped], kwargs: T.untyped).returns(T.untyped) }
def post(url, methods:, **kwargs)
request("POST", url, **T.unsafe({methods: methods, **kwargs}))
sig { params(url: T.untyped, methods: T::Array[T.untyped], events: T.nilable(Mpp::Events::Dispatcher), kwargs: T.untyped).returns(T.untyped) }
def post(url, methods:, events: nil, **kwargs)
request("POST", url, **T.unsafe({methods: methods, events: events, **kwargs}))
end
end
end
Loading