From b2638e1ec9320cfa77135186016a9079bb5f19b0 Mon Sep 17 00:00:00 2001 From: Michael Moffett Date: Thu, 24 Sep 2026 09:35:48 -0600 Subject: [PATCH] feat(ruby): PayKit.configure_from_env mirrors Python's configure_from Add `PayKit.configure_from_env(prefix = "PAY_KIT_", env: ENV)` to the Ruby SDK, reading the same knobs Python's `configure_from` reads and funnelling them through `PayKit.configure`. `docs/paykit-interface.md:89` makes `configure_from_env` a required entry point in every SDK; Ruby has only the block form, the gap lists. --- docs/paykit-interface.md | 1 - ruby/lib/pay_kit/config.rb | 35 ++++++++++++++++++++ ruby/test/pay_kit/config_test.rb | 56 ++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 1 deletion(-) diff --git a/docs/paykit-interface.md b/docs/paykit-interface.md index a7d433c31..ed304fb2e 100644 --- a/docs/paykit-interface.md +++ b/docs/paykit-interface.md @@ -269,7 +269,6 @@ conformant. - Add `accepts?(protocol)` on `Gate`; keep `x402_accepted?` / `mpp_accepted?` as deprecated aliases for one release. - Add `payer` and ensure `scheme` on `Payment`. -- Add `PayKit.configure_from_env`. - Stablecoin set: add `USDG`, `CASH`. - Wire `external_id` through every flow (currently inconsistent). diff --git a/ruby/lib/pay_kit/config.rb b/ruby/lib/pay_kit/config.rb index 54531af96..589f7482a 100644 --- a/ruby/lib/pay_kit/config.rb +++ b/ruby/lib/pay_kit/config.rb @@ -264,6 +264,21 @@ def configure @config end + def configure_from_env(prefix = "PAY_KIT_", env: ENV) + vars = env.to_h.select { |k, _| k.start_with?(prefix) }.transform_keys { |k| k.delete_prefix(prefix) } + configure do |c| + c.network = vars["NETWORK"] if vars.key?("NETWORK") + c.rpc_url = vars["RPC_URL"] if vars.key?("RPC_URL") + c.accept = split_env_list(vars["ACCEPT"]) if vars.key?("ACCEPT") + c.stablecoins = split_env_list(vars["STABLECOINS"]) if vars.key?("STABLECOINS") + c.preflight = parse_env_boolean(vars["PREFLIGHT"], "#{prefix}PREFLIGHT") if vars.key?("PREFLIGHT") + c.mpp.realm = vars["MPP_REALM"] if vars.key?("MPP_REALM") + c.mpp.challenge_binding_secret = vars["MPP_CHALLENGE_BINDING_SECRET"] if vars.key?("MPP_CHALLENGE_BINDING_SECRET") + c.mpp.expires_in = parse_env_integer(vars["MPP_EXPIRES_IN"], "#{prefix}MPP_EXPIRES_IN") if vars.key?("MPP_EXPIRES_IN") + c.x402.facilitator_url = vars["X402_FACILITATOR_URL"] if vars.key?("X402_FACILITATOR_URL") + end + end + def config @config ||= Config.new end @@ -279,5 +294,25 @@ def reset! @config = nil @pricing = nil end + + private + + def split_env_list(value) + value.split(",").map(&:strip).reject(&:empty?) + end + + def parse_env_boolean(value, key) + case value.strip.downcase + when "true", "1" then true + when "false", "0" then false + else raise ConfigurationError, "#{key} must be true, false, 1 or 0, got #{value.inspect}" + end + end + + def parse_env_integer(value, key) + Integer(value.strip, 10) + rescue ArgumentError + raise ConfigurationError, "#{key} must be an integer, got #{value.inspect}" + end end end diff --git a/ruby/test/pay_kit/config_test.rb b/ruby/test/pay_kit/config_test.rb index 21ea530eb..4e9a4e766 100644 --- a/ruby/test/pay_kit/config_test.rb +++ b/ruby/test/pay_kit/config_test.rb @@ -291,6 +291,62 @@ def test_x402_unknown_scheme_raises end end + # --- configure_from_env ---------------------------------------------- + + def test_configure_from_env_reads_scalars + env = { + "PAY_KIT_NETWORK" => "solana_devnet", + "PAY_KIT_RPC_URL" => "https://helius.example.com", + "PAY_KIT_PREFLIGHT" => "FALSE", + "PAY_KIT_MPP_REALM" => "Shop", + "PAY_KIT_MPP_CHALLENGE_BINDING_SECRET" => "rotate-me", + "PAY_KIT_MPP_EXPIRES_IN" => "90", + "PAY_KIT_X402_FACILITATOR_URL" => "https://facilitator.example.com" + } + config = PayKit.configure_from_env(env: env) + assert config.frozen? + assert_equal :solana_devnet, config.network + assert_equal "https://helius.example.com", config.rpc_url + refute config.preflight + assert_equal "Shop", config.mpp.realm + assert_equal "rotate-me", config.mpp.challenge_binding_secret + assert_equal 90, config.mpp.expires_in + assert_equal "https://facilitator.example.com", config.x402.facilitator_url + end + + def test_configure_from_env_splits_lists + config = PayKit.configure_from_env(env: {"PAY_KIT_ACCEPT" => "x402, mpp", "PAY_KIT_STABLECOINS" => "USDC,PYUSD"}) + assert_equal %i[x402 mpp], config.accept + assert_equal %i[USDC PYUSD], config.stablecoins + end + + def test_configure_from_env_keeps_defaults_when_unset + config = PayKit.configure_from_env(env: {}) + assert_equal :solana_localnet, config.network + assert_equal %i[x402 mpp], config.accept + assert_equal %i[USDC], config.stablecoins + assert_equal 300, config.mpp.expires_in + end + + def test_configure_from_env_custom_prefix + config = PayKit.configure_from_env("APP_", env: {"APP_NETWORK" => "solana_devnet", "PAY_KIT_NETWORK" => "bitcoin"}) + assert_equal :solana_devnet, config.network + end + + def test_configure_from_env_rejects_bad_values + { + "PAY_KIT_NETWORK" => "bitcoin", + "PAY_KIT_PREFLIGHT" => "maybe", + "PAY_KIT_MPP_EXPIRES_IN" => "soon", + "PAY_KIT_STABLECOINS" => " , " + }.each do |key, value| + PayKit.reset! + assert_raises(PayKit::ConfigurationError, key) do + PayKit.configure_from_env(env: {key => value}) + end + end + end + private # Replace `PayKit::Preflight.run` with a no-op spy for the duration of