diff --git a/.rubocop.yml b/.rubocop.yml index c98f112b8..8a81174d4 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -41,6 +41,10 @@ Metrics/MethodLength: Metrics/ModuleLength: Enabled: false +Metrics/ParameterLists: + # There's 2 methods in `StripeClient` that have long parameter lists. + Max: 8 + Style/AccessModifierDeclarations: EnforcedStyle: inline diff --git a/lib/stripe.rb b/lib/stripe.rb index bf44801b6..d20604b61 100644 --- a/lib/stripe.rb +++ b/lib/stripe.rb @@ -119,6 +119,49 @@ def self.set_app_info(name, partner_id: nil, url: nil, version: nil) version: version, } end + + class Preview + def self._get_default_opts(opts) + { api_mode: :preview }.merge(opts) + end + + def self.get(url, opts = {}) + Stripe.raw_request(:get, url, {}, _get_default_opts(opts)) + end + + def self.post(url, params = {}, opts = {}) + Stripe.raw_request(:post, url, params, _get_default_opts(opts)) + end + + def self.delete(url, opts = {}) + Stripe.raw_request(:delete, url, {}, _get_default_opts(opts)) + end + end + + class RawRequest + include Stripe::APIOperations::Request + + def initialize + @opts = {} + end + + def execute(method, url, params = {}, opts = {}) + resp, = execute_resource_request(method, url, params, opts) + + resp + end + end + + # Sends a request to Stripe REST API + def self.raw_request(method, url, params = {}, opts = {}) + req = RawRequest.new + req.execute(method, url, params, opts) + end + + def self.deserialize(data) + data = JSON.parse(data) if data.is_a?(String) + Util.convert_to_stripe_object(data, {}) + end end Stripe.log_level = ENV["STRIPE_LOG"] unless ENV["STRIPE_LOG"].nil? diff --git a/lib/stripe/api_operations/request.rb b/lib/stripe/api_operations/request.rb index 8544dedd6..2458aa894 100644 --- a/lib/stripe/api_operations/request.rb +++ b/lib/stripe/api_operations/request.rb @@ -47,6 +47,7 @@ def execute_resource_request_stream(method, url, api_key = headers.delete(:api_key) api_base = headers.delete(:api_base) client = headers.delete(:client) + api_mode = headers.delete(:api_mode) # Assume all remaining opts must be headers resp, opts[:api_key] = client.send( @@ -54,6 +55,7 @@ def execute_resource_request_stream(method, url, method, url, api_base: api_base, api_key: api_key, headers: headers, params: params, + api_mode: api_mode, &read_body_chunk_block ) diff --git a/lib/stripe/api_version.rb b/lib/stripe/api_version.rb index b348fbad6..cb8e56f1c 100644 --- a/lib/stripe/api_version.rb +++ b/lib/stripe/api_version.rb @@ -4,5 +4,6 @@ module Stripe module ApiVersion CURRENT = "2022-11-15" + PREVIEW = "20230509T165653" end end diff --git a/lib/stripe/stripe_client.rb b/lib/stripe/stripe_client.rb index de796aa18..d85f5c722 100644 --- a/lib/stripe/stripe_client.rb +++ b/lib/stripe/stripe_client.rb @@ -212,9 +212,10 @@ def request end def execute_request(method, path, - api_base: nil, api_key: nil, headers: {}, params: {}) + api_base: nil, api_key: nil, + headers: {}, params: {}, api_mode: nil) http_resp, api_key = execute_request_internal( - method, path, api_base, api_key, headers, params + method, path, api_base, api_key, headers, params, api_mode ) begin @@ -245,6 +246,7 @@ def execute_request(method, path, def execute_request_stream(method, path, api_base: nil, api_key: nil, headers: {}, params: {}, + api_mode: nil, &read_body_chunk_block) unless block_given? raise ArgumentError, @@ -252,7 +254,8 @@ def execute_request_stream(method, path, end http_resp, api_key = execute_request_internal( - method, path, api_base, api_key, headers, params, &read_body_chunk_block + method, path, api_base, api_key, + headers, params, api_mode, &read_body_chunk_block ) # When the read_body_chunk_block is given, we no longer have access to the @@ -432,7 +435,7 @@ def self.maybe_gc_connection_managers private def execute_request_internal(method, path, api_base, api_key, headers, params, - &read_body_chunk_block) + api_mode, &read_body_chunk_block) raise ArgumentError, "method should be a symbol" \ unless method.is_a?(Symbol) raise ArgumentError, "path should be a string" \ @@ -456,8 +459,9 @@ def self.maybe_gc_connection_managers query_params, path = merge_query_params(query_params, path) - headers = request_headers(api_key, method) + headers = request_headers(api_key, method, api_mode) .update(Util.normalize_headers(headers)) + url = api_url(path, api_base) # Merge given query parameters with any already encoded in the path. @@ -468,7 +472,7 @@ def self.maybe_gc_connection_managers # a log-friendly variant of the encoded form. File objects are displayed # as such instead of as their file contents. body, body_log = - body_params ? encode_body(body_params, headers) : [nil, nil] + body_params ? encode_body(body_params, headers, api_mode) : [nil, nil] authenticator.authenticate(method, headers, body) unless api_key @@ -544,7 +548,7 @@ def self.maybe_gc_connection_managers # Encodes a set of body parameters using multipart if `Content-Type` is set # for that, or standard form-encoding otherwise. Returns the encoded body # and a version of the encoded body that's safe to be logged. - private def encode_body(body_params, headers) + private def encode_body(body_params, headers, api_mode) body = nil flattened_params = Util.flatten_params(body_params) @@ -560,15 +564,22 @@ def self.maybe_gc_connection_managers flattened_params = flattened_params.map { |k, v| [k, v.is_a?(String) ? v : v.to_s] }.to_h + elsif api_mode == :preview + body = JSON.generate(body_params) + headers["Content-Type"] = "application/json" else body = Util.encode_parameters(body_params) end - # We don't use `Util.encode_parameters` partly as an optimization (to not - # redo work we've already done), and partly because the encoded forms of - # certain characters introduce a lot of visual noise and it's nice to - # have a clearer format for logs. - body_log = flattened_params.map { |k, v| "#{k}=#{v}" }.join("&") + if api_mode == :preview + body_log = body + else + # We don't use `Util.encode_parameters` partly as an optimization (to + # not redo work we've already done), and partly because the encoded + # forms of certain characters introduce a lot of visual noise and it's + # nice to have a clearer format for logs. + body_log = flattened_params.map { |k, v| "#{k}=#{v}" }.join("&") + end [body, body_log] end @@ -868,7 +879,7 @@ def self.maybe_gc_connection_managers message + "\n\n(Network error: #{error.message})" end - private def request_headers(api_key, method) + private def request_headers(api_key, method, api_mode) user_agent = "Stripe/v1 RubyBindings/#{Stripe::VERSION}" unless Stripe.app_info.nil? user_agent += " " + format_app_info(Stripe.app_info) @@ -877,9 +888,13 @@ def self.maybe_gc_connection_managers headers = { "User-Agent" => user_agent, "Authorization" => "Bearer #{api_key}", - "Content-Type" => "application/x-www-form-urlencoded", } + if api_mode != :preview + # TODO: (major) don't set Content-Type if method is not post + headers["Content-Type"] = "application/x-www-form-urlencoded" + end + if config.enable_telemetry? && !@last_request_metrics.nil? headers["X-Stripe-Client-Telemetry"] = JSON.generate( last_request_metrics: @last_request_metrics.payload @@ -892,7 +907,12 @@ def self.maybe_gc_connection_managers headers["Idempotency-Key"] ||= SecureRandom.uuid end - headers["Stripe-Version"] = config.api_version if config.api_version + if api_mode == :preview + headers["Stripe-Version"] = ApiVersion::PREVIEW + elsif config.api_version + headers["Stripe-Version"] = config.api_version + end + headers["Stripe-Account"] = config.stripe_account if config.stripe_account user_agent = @system_profiler.user_agent diff --git a/test/stripe/preview_test.rb b/test/stripe/preview_test.rb new file mode 100644 index 000000000..211f524c6 --- /dev/null +++ b/test/stripe/preview_test.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require ::File.expand_path("../test_helper", __dir__) + +class PreviewTest < Test::Unit::TestCase + context "preview raw requests" do + should "send preview get request with correct default options" do + expected_body = "{\"id\": \"acc_123\"}" + req = nil + + stub_request(:get, "#{Stripe.api_base}/v2/accounts/acc_123") + .with { |request| req = request } + .to_return(body: expected_body) + + resp = Stripe::Preview.get("/v2/accounts/acc_123") + + assert_equal nil, req.headers["Content-Type"] + assert_equal Stripe::ApiVersion::PREVIEW, req.headers["Stripe-Version"] + assert_equal expected_body, resp.http_body + end + + should "send preview post request with correct default options" do + expected_body = "{\"id\": \"acc_123\"}" + req = nil + + stub_request(:post, "#{Stripe.api_base}/v2/accounts") + .with { |request| req = request } + .to_return(body: expected_body) + + resp = Stripe::Preview.post("/v2/accounts", { p1: 1, p2: "string" }) + + assert_equal "application/json", req.headers["Content-Type"] + assert_equal Stripe::ApiVersion::PREVIEW, req.headers["Stripe-Version"] + assert_equal "{\"p1\":1,\"p2\":\"string\"}", req.body + assert_equal expected_body, resp.http_body + end + + should "send preview delete request with correct default options" do + expected_body = "{\"id\": \"acc_123\"}" + req = nil + + stub_request(:delete, "#{Stripe.api_base}/v2/accounts/acc_123") + .with { |request| req = request } + .to_return(body: expected_body) + + resp = Stripe::Preview.delete("/v2/accounts/acc_123") + + assert_equal nil, req.headers["Content-Type"] + assert_equal Stripe::ApiVersion::PREVIEW, req.headers["Stripe-Version"] + assert_equal expected_body, resp.http_body + end + + should "allow overriding default options for preview requests" do + expected_body = "{\"id\": \"acc_123\"}" + stripe_version_override = "2022-11-15" + req = nil + + stub_request(:post, "#{Stripe.api_base}/v2/accounts") + .with { |request| req = request } + .to_return(body: expected_body) + + resp = Stripe::Preview.post("/v2/accounts", {}, { stripe_version: stripe_version_override }) + + assert_equal "application/json", req.headers["Content-Type"] + assert_equal stripe_version_override, req.headers["Stripe-Version"] + assert_equal expected_body, resp.http_body + end + + should "allow setting stripe_context for preview requests" do + expected_body = "{\"id\": \"acc_123\"}" + stripe_context = "acc_123" + req = nil + + stub_request(:post, "#{Stripe.api_base}/v2/accounts") + .with { |request| req = request } + .to_return(body: expected_body) + + Stripe::Preview.post("/v2/accounts", {}, { stripe_context: stripe_context }) + + assert_equal "application/json", req.headers["Content-Type"] + assert_equal stripe_context, req.headers["Stripe-Context"] + end + end +end diff --git a/test/stripe/raw_request_test.rb b/test/stripe/raw_request_test.rb new file mode 100644 index 000000000..78875defa --- /dev/null +++ b/test/stripe/raw_request_test.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require ::File.expand_path("../test_helper", __dir__) + +class RawRequestTest < Test::Unit::TestCase + context "raw_request" do + should "send get request and return a response" do + expected_body = "{\"id\": \"acc_123\"}" + req = nil + + stub_request(:get, "#{Stripe.api_base}/v1/accounts/acc_123") + .with { |request| req = request } + .to_return(body: expected_body) + + resp = Stripe.raw_request(:get, "/v1/accounts/acc_123") + + assert_equal expected_body, resp.http_body + assert_equal "application/x-www-form-urlencoded", req.headers["Content-Type"] + assert_equal Stripe::ApiVersion::CURRENT, req.headers["Stripe-Version"] + end + + should "send post request with body and return a response" do + expected_body = "{\"id\": \"acc_123\"}" + req = nil + + stub_request(:post, "#{Stripe.api_base}/v1/accounts/acc_123") + .with(body: "p1=1&p2=string") + .with { |request| req = request } + .to_return(body: expected_body) + + resp = Stripe.raw_request(:post, "/v1/accounts/acc_123", { p1: 1, p2: "string" }) + + assert_equal expected_body, resp.http_body + assert_equal "application/x-www-form-urlencoded", req.headers["Content-Type"] + assert_equal Stripe::ApiVersion::CURRENT, req.headers["Stripe-Version"] + end + + should "send post request with json body and return a response" do + expected_body = "{\"id\": \"acc_123\"}" + req = nil + + stub_request(:post, "#{Stripe.api_base}/v1/accounts/acc_123") + .with(body: "{\"p1\":1,\"p2\":\"string\"}") + .with { |request| req = request } + .to_return(body: expected_body) + + resp = Stripe.raw_request(:post, "/v1/accounts/acc_123", { p1: 1, p2: "string" }, { api_mode: :preview }) + + assert_equal expected_body, resp.http_body + assert_equal "application/json", req.headers["Content-Type"] + assert_equal Stripe::ApiVersion::PREVIEW, req.headers["Stripe-Version"] + end + + should "send post request with json body and headers and return a response" do + expected_body = "{\"id\": \"acc_123\"}" + req = nil + + stub_request(:post, "#{Stripe.api_base}/v1/accounts/acc_123") + .with(body: "{\"p1\":1,\"p2\":\"string\"}") + .with { |request| req = request } + .to_return(body: expected_body) + + resp = Stripe.raw_request(:post, "/v1/accounts/acc_123", { p1: 1, p2: "string" }, { api_mode: :preview, "Stripe-Context": "bar" }) + + assert_equal expected_body, resp.http_body + assert_equal "application/json", req.headers["Content-Type"] + assert_equal Stripe::ApiVersion::PREVIEW, req.headers["Stripe-Version"] + assert_equal "bar", req.headers["Stripe-Context"] + end + + should "send get request with json body and headers and return a response" do + expected_body = "{\"id\": \"acc_123\"}" + req = nil + + stub_request(:get, "#{Stripe.api_base}/v1/accounts/acc_123") + .with { |request| req = request } + .to_return(body: expected_body) + + resp = Stripe.raw_request(:get, "/v1/accounts/acc_123", {}, { api_mode: :preview, "Stripe-Account": "bar" }) + + assert_not_equal "application/x-www-form-urlencoded", req.headers["Content-Type"] + assert_equal expected_body, resp.http_body + end + + should "set default preview version when api_mode is preview and stripe_version not specified" do + expected_body = "{\"id\": \"acc_123\"}" + req = nil + + stub_request(:get, "#{Stripe.api_base}/v1/accounts/acc_123") + .with { |request| req = request } + .to_return(body: expected_body) + + Stripe.raw_request(:get, "/v1/accounts/acc_123", {}, { api_mode: :preview }) + + assert_equal Stripe::ApiVersion::PREVIEW, req.headers["Stripe-Version"] + end + + should "allow overriding stripe version when api_mode is preview" do + expected_body = "{\"id\": \"acc_123\"}" + req = nil + + stub_request(:get, "#{Stripe.api_base}/v1/accounts/acc_123") + .with { |request| req = request } + .to_return(body: expected_body) + + stripe_version_override = "2023-05-15.preview" + Stripe.raw_request(:get, "/v1/accounts/acc_123", {}, { api_mode: :preview, stripe_version: stripe_version_override }) + + assert_equal stripe_version_override, req.headers["Stripe-Version"] + end + end +end diff --git a/test/stripe_test.rb b/test/stripe_test.rb index c0c3bd817..26a3b2971 100644 --- a/test/stripe_test.rb +++ b/test/stripe_test.rb @@ -134,4 +134,42 @@ class StripeTest < Test::Unit::TestCase assert_equal "client", Stripe.client_id end end + + context "deserialize" do + should "deserializes string into known object" do + expected_body = "{\"id\": \"acc_123\", \"object\": \"account\"}" + + obj = Stripe.deserialize(expected_body) + + assert_equal obj.class, Stripe::Account + assert_equal obj.id, "acc_123" + end + + should "deserializes string into unknown object" do + expected_body = "{\"id\": \"acc_123\", \"object\": \"unknown\"}" + + obj = Stripe.deserialize(expected_body) + + assert_equal obj.class, Stripe::StripeObject + assert_equal obj.id, "acc_123" + end + + should "deserializes hash into known object" do + expected_body = { "id" => "acc_123", "object" => "account" } + + obj = Stripe.deserialize(expected_body) + + assert_equal obj.class, Stripe::Account + assert_equal obj.id, "acc_123" + end + + should "deserializes hash into unknown object" do + expected_body = { "id" => "acc_123", "object" => "unknown" } + + obj = Stripe.deserialize(expected_body) + + assert_equal obj.class, Stripe::StripeObject + assert_equal obj.id, "acc_123" + end + end end