Skip to content

Commit

Permalink
Add raw_request (#965)
Browse files Browse the repository at this point in the history
* Add raw_request

* wip

* add deserialize method, tests, format

* encoding default None

* Move common fixtures into conftest, better raw_request tests

* README

* add 'preview' module

* Rename encoding=json -> api_mode=preview

* Use the preview API version

* tests

* Add stripe context

* default to preview api version if api_mode is preview

* Add test

* test datetime encoding

* add timezone, lint

* fix tests?

* Remove stripe_version default in preview.py

* how about now

* fix tests for real

* fix test / lint again

* again

* feedback

* orderedict

* use global client override

* remove client

* remove empty params

* more ordereddicts

* json.dumps

* Use JSONMatcher and QueryMatcher

---------

Co-authored-by: Annie Li <[email protected]>
Co-authored-by: Richard Marmorstein <[email protected]>
  • Loading branch information
3 people committed May 19, 2023
1 parent 5f12c0e commit 08fc8ff
Show file tree
Hide file tree
Showing 11 changed files with 496 additions and 64 deletions.
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,19 @@ To install a beta version use `pip install` with the exact version you'd like to
pip install stripe==5.3.0b3
```

### Custom requests

If you would like to send a request to an undocumented API (for example you are in a private beta), or if you prefer to bypass the method definitions in the library and specify your request details directly, you can use the `raw_request` method on `stripe`.

```python
response = stripe.raw_request(
"post", "/v1/beta_endpoint", param=123, stripe_version="2022-11-15; feature_beta=v3"
)

# (Optional) response is a StripeResponse. You can use `stripe.deserialize` to get a StripeObject.
deserialized_resp = stripe.deserialize(response)
```

> **Note**
> There can be breaking changes between beta versions. Therefore we recommend pinning the package version to a specific beta version in your [requirements file](https://pip.pypa.io/en/stable/user_guide/#requirements-files) or `setup.py`. This way you can install the same version each time without breaking changes unless you are intentionally looking for the latest beta version.
Expand Down
6 changes: 6 additions & 0 deletions stripe/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@
# Webhooks
from stripe.webhook import Webhook, WebhookSignature # noqa

from stripe.raw_request import _raw_request as raw_request # noqa

from stripe.raw_request import _deserialize as deserialize # noqa

from stripe.preview import preview # noqa


# Sets some basic information about the running application that's sent along
# with API requests. Useful for plugin authors to identify their plugin when
Expand Down
45 changes: 37 additions & 8 deletions stripe/api_requestor.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ def _encode_nested_dict(key, data, fmt="%s[%s]"):
return d


def _json_encode_date_callback(value):
if isinstance(value, datetime.datetime):
return _encode_datetime(value)
return value


def _api_encode(data):
for key, value in six.iteritems(data):
key = util.utf8(key)
Expand Down Expand Up @@ -115,16 +121,28 @@ def format_app_info(cls, info):
str += " (%s)" % (info["url"],)
return str

def request(self, method, url, params=None, headers=None):
def request(self, method, url, params=None, headers=None, api_mode=None):
rbody, rcode, rheaders, my_api_key = self.request_raw(
method.lower(), url, params, headers, is_streaming=False
method.lower(),
url,
params,
headers,
is_streaming=False,
api_mode=api_mode,
)
resp = self.interpret_response(rbody, rcode, rheaders)
return resp, my_api_key

def request_stream(self, method, url, params=None, headers=None):
def request_stream(
self, method, url, params=None, headers=None, api_mode=None
):
stream, rcode, rheaders, my_api_key = self.request_raw(
method.lower(), url, params, headers, is_streaming=True
method.lower(),
url,
params,
headers,
is_streaming=True,
api_mode=api_mode,
)
resp = self.interpret_streaming_response(stream, rcode, rheaders)
return resp, my_api_key
Expand Down Expand Up @@ -238,7 +256,7 @@ def specific_oauth_error(self, rbody, rcode, resp, rheaders, error_code):

return None

def request_headers(self, api_key, method):
def request_headers(self, api_key, method, api_mode):
user_agent = "Stripe/v1 PythonBindings/%s" % (version.VERSION,)
if stripe.app_info:
user_agent += " " + self.format_app_info(stripe.app_info)
Expand Down Expand Up @@ -272,8 +290,11 @@ def request_headers(self, api_key, method):
headers["Stripe-Account"] = self.stripe_account

if method == "post":
headers["Content-Type"] = "application/x-www-form-urlencoded"
headers.setdefault("Idempotency-Key", str(uuid.uuid4()))
if api_mode == "preview":
headers["Content-Type"] = "application/json"
else:
headers["Content-Type"] = "application/x-www-form-urlencoded"

if self.api_version is not None:
headers["Stripe-Version"] = self.api_version
Expand All @@ -287,6 +308,7 @@ def request_raw(
params=None,
supplied_headers=None,
is_streaming=False,
api_mode=None,
):
"""
Mechanism for issuing an API call
Expand Down Expand Up @@ -317,6 +339,13 @@ def request_raw(
# makes these parameter strings easier to read.
encoded_params = encoded_params.replace("%5B", "[").replace("%5D", "]")

if api_mode == "preview":
encoded_body = json.dumps(
params or {}, default=_json_encode_date_callback
)
else:
encoded_body = encoded_params

if method == "get" or method == "delete":
if params:
abs_url = _build_api_url(abs_url, encoded_params)
Expand All @@ -334,15 +363,15 @@ def request_raw(
"Content-Type"
] = "multipart/form-data; boundary=%s" % (generator.boundary,)
else:
post_data = encoded_params
post_data = encoded_body
else:
raise error.APIConnectionError(
"Unrecognized HTTP method %r. This may indicate a bug in the "
"Stripe bindings. Please contact [email protected] for "
"assistance." % (method,)
)

headers = self.request_headers(my_api_key, method)
headers = self.request_headers(my_api_key, method, api_mode)
if supplied_headers is not None:
for key, value in six.iteritems(supplied_headers):
headers[key] = value
Expand Down
1 change: 1 addition & 0 deletions stripe/api_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@

class _ApiVersion:
CURRENT = "2022-11-15"
PREVIEW = "20230509T165653"
20 changes: 20 additions & 0 deletions stripe/preview.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from stripe import raw_request


class Preview(object):
def _get_default_opts(self, params):
if "api_mode" not in params:
params["api_mode"] = "preview"
return params

def post(self, url, **params):
return raw_request("post", url, **self._get_default_opts(params))

def get(self, url, **params):
return raw_request("get", url, **self._get_default_opts(params))

def delete(self, url, **params):
return raw_request("delete", url, **self._get_default_opts(params))


preview = Preview()
47 changes: 47 additions & 0 deletions stripe/raw_request.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from stripe import api_requestor, util
from stripe.api_version import _ApiVersion


def _raw_request(method_, url_, **params):
params = None if params is None else params.copy()
api_key = util.read_special_variable(params, "api_key", None)
idempotency_key = util.read_special_variable(
params, "idempotency_key", None
)
stripe_version = util.read_special_variable(params, "stripe_version", None)
stripe_account = util.read_special_variable(params, "stripe_account", None)
api_mode = util.read_special_variable(params, "api_mode", None)
stripe_context = util.read_special_variable(params, "stripe_context", None)
headers = util.read_special_variable(params, "headers", None)

if api_mode == "preview":
stripe_version = stripe_version or _ApiVersion.PREVIEW

requestor = api_requestor.APIRequestor(
key=api_key,
api_version=stripe_version,
account=stripe_account,
)

if idempotency_key is not None:
headers = {} if headers is None else headers.copy()
headers.update(util.populate_headers(idempotency_key))

# stripe-context goes *here* and not in api_requestor. Properties
# go on api_requestor when you want them to persist onto requests
# made when you call instance methods on APIResources that come from
# the first request. No need for that here, as we aren't deserializing APIResources
if stripe_context is not None:
headers = {} if headers is None else headers.copy()
headers.update({"Stripe-Context": stripe_context})

response, _ = requestor.request(method_, url_, params, headers, api_mode)
return response


def _deserialize(
resp, api_key=None, stripe_version=None, stripe_account=None, params=None
):
return util.convert_to_stripe_object(
resp, api_key, stripe_version, stripe_account, params
)
55 changes: 55 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from stripe.six.moves.urllib.request import urlopen
from stripe.six.moves.urllib.error import HTTPError

from tests.test_api_requestor import APIHeaderMatcher
from tests.request_mock import RequestMock
from tests.stripe_mock import StripeMock

Expand Down Expand Up @@ -96,3 +97,57 @@ def setup_stripe():
@pytest.fixture
def request_mock(mocker):
return RequestMock(mocker)


@pytest.fixture
def http_client(mocker):
http_client = mocker.Mock(stripe.http_client.HTTPClient)
http_client._verify_ssl_certs = True
http_client.name = "mockclient"
return http_client


@pytest.fixture
def mock_response(mocker, http_client):
def mock_response(return_body, return_code, headers=None):
http_client.request_with_retries = mocker.Mock(
return_value=(return_body, return_code, headers or {})
)

return mock_response


@pytest.fixture
def mock_streaming_response(mocker, http_client):
def mock_streaming_response(return_body, return_code, headers=None):
http_client.request_stream_with_retries = mocker.Mock(
return_value=(return_body, return_code, headers or {})
)

return mock_streaming_response


@pytest.fixture
def check_call(http_client):
def check_call(
method,
abs_url=None,
headers=None,
post_data=None,
is_streaming=False,
):
if not abs_url:
abs_url = "%s%s" % (stripe.api_base, "/foo")
if not headers:
headers = APIHeaderMatcher(request_method=method)

if is_streaming:
http_client.request_stream_with_retries.assert_called_with(
method, abs_url, headers, post_data
)
else:
http_client.request_with_retries.assert_called_with(
method, abs_url, headers, post_data
)

return check_call
18 changes: 13 additions & 5 deletions tests/request_mock.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,19 +112,26 @@ def assert_api_version(self, expected_api_version):
)
raise AssertionError(msg)

def assert_requested(self, method, url, params=None, headers=None):
def assert_requested(
self, method, url, params=None, headers=None, api_mode=None
):
self.assert_requested_internal(
self.request_patcher, method, url, params, headers
self.request_patcher, method, url, params, headers, api_mode
)

def assert_requested_stream(self, method, url, params=None, headers=None):
def assert_requested_stream(
self, method, url, params=None, headers=None, api_mode=None
):
self.assert_requested_internal(
self.request_stream_patcher, method, url, params, headers
self.request_stream_patcher, method, url, params, headers, api_mode
)

def assert_requested_internal(self, patcher, method, url, params, headers):
def assert_requested_internal(
self, patcher, method, url, params, headers, api_mode
):
params = params or self._mocker.ANY
headers = headers or self._mocker.ANY
api_mode = api_mode or self._mocker.ANY
called = False
exception = None

Expand All @@ -134,6 +141,7 @@ def assert_requested_internal(self, patcher, method, url, params, headers):
(self._mocker.ANY, method, url),
(self._mocker.ANY, method, url, params),
(self._mocker.ANY, method, url, params, headers),
(self._mocker.ANY, method, url, params, headers, api_mode),
]

for args in possible_called_args:
Expand Down
Loading

0 comments on commit 08fc8ff

Please sign in to comment.