Skip to content

Commit

Permalink
added support for idempotency key
Browse files Browse the repository at this point in the history
  • Loading branch information
Michal Schielmann committed Aug 12, 2024
1 parent c0158f6 commit 9070c60
Show file tree
Hide file tree
Showing 19 changed files with 220 additions and 59 deletions.
4 changes: 2 additions & 2 deletions shift4/blacklist.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@


class Blacklist(Resource):
def create(self, params):
return self._post("/blacklist", params)
def create(self, params, request_options=None):
return self._post("/blacklist", params, request_options=request_options)

def get(self, blacklist_rule_id):
return self._get("/blacklist/%s" % blacklist_rule_id)
Expand Down
14 changes: 10 additions & 4 deletions shift4/cards.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,20 @@


class Cards(Resource):
def create(self, customer_id, params):
return self._post("/customers/%s/cards" % customer_id, params)
def create(self, customer_id, params, request_options=None):
return self._post(
"/customers/%s/cards" % customer_id, params, request_options=request_options
)

def get(self, customer_id, card_id):
return self._get("/customers/%s/cards/%s" % (customer_id, card_id))

def update(self, customer_id, card_id, params):
return self._post("/customers/%s/cards/%s" % (customer_id, card_id), params)
def update(self, customer_id, card_id, params, request_options=None):
return self._post(
"/customers/%s/cards/%s" % (customer_id, card_id),
params,
request_options=request_options,
)

def delete(self, customer_id, card_id):
return self._delete("/customers/%s/cards/%s" % (customer_id, card_id))
Expand Down
22 changes: 14 additions & 8 deletions shift4/charges.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,26 @@


class Charges(Resource):
def create(self, params):
return self._post("/charges", params)
def create(self, params, request_options=None):
return self._post("/charges", params, request_options=request_options)

def get(self, charge_id):
return self._get("/charges/%s" % charge_id)

def update(self, charge_id, params):
return self._post("/charges/%s" % charge_id, params)
def update(self, charge_id, params, request_options=None):
return self._post(
"/charges/%s" % charge_id, params, request_options=request_options
)

def list(self, params=None):
return self._get("/charges", params)

def capture(self, charge_id):
return self._post("/charges/%s/capture" % charge_id)
def capture(self, charge_id, request_options=None):
return self._post(
"/charges/%s/capture" % charge_id, request_options=request_options
)

def refund(self, charge_id, params=None):
return self._post("/charges/%s/refund" % charge_id, params)
def refund(self, charge_id, params=None, request_options=None):
return self._post(
"/charges/%s/refund" % charge_id, params, request_options=request_options
)
10 changes: 6 additions & 4 deletions shift4/credits.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@


class Credits(Resource):
def create(self, params):
return self._post("/credits", params)
def create(self, params, request_options=None):
return self._post("/credits", params, request_options=request_options)

def get(self, credit_id):
return self._get("/credits/%s" % credit_id)

def update(self, credit_id, params):
return self._post("/credits/%s" % credit_id, params)
def update(self, credit_id, params, request_options=None):
return self._post(
"/credits/%s" % credit_id, params, request_options=request_options
)

def list(self, params=None):
return self._get("/credits", params)
10 changes: 6 additions & 4 deletions shift4/customers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@


class Customers(Resource):
def create(self, params):
return self._post("/customers", params)
def create(self, params, request_options=None):
return self._post("/customers", params, request_options=request_options)

def get(self, customer_id):
return self._get("/customers/%s" % customer_id)

def update(self, customer_id, params):
return self._post("/customers/%s" % customer_id, params)
def update(self, customer_id, params, request_options=None):
return self._post(
"/customers/%s" % customer_id, params, request_options=request_options
)

def delete(self, customer_id):
return self._delete("/customers/%s" % customer_id)
Expand Down
12 changes: 8 additions & 4 deletions shift4/disputes.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@ class Disputes(Resource):
def get(self, dispute_id):
return self._get("/disputes/%s" % dispute_id)

def update(self, dispute_id, params):
return self._post("/disputes/%s" % dispute_id, params)
def update(self, dispute_id, params, request_options=None):
return self._post(
"/disputes/%s" % dispute_id, params, request_options=request_options
)

def close(self, dispute_id):
return self._post("/disputes/%s/close" % dispute_id)
def close(self, dispute_id, request_options=None):
return self._post(
"/disputes/%s/close" % dispute_id, request_options=request_options
)

def list(self, params=None):
return self._get("/disputes", params)
4 changes: 2 additions & 2 deletions shift4/payment_methods.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@


class PaymentMethods(Resource):
def create(self, params):
return self._post("/payment-methods", params)
def create(self, params, request_options=None):
return self._post("/payment-methods", params, request_options=request_options)

def get(self, payment_method_id):
return self._get("/payment-methods/%s" % payment_method_id)
Expand Down
10 changes: 6 additions & 4 deletions shift4/plans.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@


class Plans(Resource):
def create(self, params):
return self._post("/plans", params)
def create(self, params, request_options=None):
return self._post("/plans", params, request_options=request_options)

def get(self, plan_id):
return self._get("/plans/%s" % plan_id)

def update(self, plan_id, params):
return self._post("/plans/%s" % plan_id, params)
def update(self, plan_id, params, request_options=None):
return self._post(
"/plans/%s" % plan_id, params, request_options=request_options
)

def delete(self, plan_id):
return self._delete("/plans/%s" % plan_id)
Expand Down
14 changes: 14 additions & 0 deletions shift4/request_options.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
class RequestOptions:
__idempotency_key = None

def name(self):
return self.__class__.__name__.lower()

def set_idempotency_key(self, idempotency_key):
self.__idempotency_key = idempotency_key

def has_idempotency_key(self):
return self.__idempotency_key is not None

def get_idempotency_key(self):
return self.__idempotency_key
27 changes: 20 additions & 7 deletions shift4/resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ def name(self):
def _get(self, path, params=None, url=None):
return self.__request("GET", path, params=params, url=url)

def _post(self, path, json=None, url=None):
return self.__request("POST", path, json=json, url=url)
def _post(self, path, json=None, url=None, request_options=None):
return self.__request(
"POST", path, json=json, url=url, request_options=request_options
)

def _multipart(self, path, params=None, files=None, url=None):
return self.__request("POST", path, params=params, files=files, url=url)
Expand All @@ -23,14 +25,23 @@ def _delete(self, path, params=None, url=None):
return self.__request("DELETE", path, params=params, url=url)

@classmethod
def __request(cls, method, path, params=None, json=None, files=None, url=None):
def __request(
cls,
method,
path,
params=None,
json=None,
files=None,
url=None,
request_options=None,
):
if url is None:
url = api.api_url.rstrip("/")
resp = requests.request(
method,
url=url + path,
auth=(api.secret_key, ""),
headers=cls.__create_headers(),
headers=cls.__create_headers(request_options),
files=files,
params=params,
json=json,
Expand All @@ -51,12 +62,14 @@ def __request(cls, method, path, params=None, json=None, files=None, url=None):
)

@classmethod
def __create_headers(cls):
def __create_headers(cls, request_options=None):
user_agent = "Shift4-Python/%s (Python/%s.%s.%s)" % (
__version__,
sys.version_info.major,
sys.version_info.minor,
sys.version_info.micro,
)

return {"User-Agent": user_agent}
headers = {"User-Agent": user_agent}
if request_options is not None and request_options.has_idempotency_key():
headers["Idempotency-Key"] = request_options.get_idempotency_key()
return headers
12 changes: 8 additions & 4 deletions shift4/subscriptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@


class Subscriptions(Resource):
def create(self, params):
return self._post("/subscriptions", params)
def create(self, params, request_options=None):
return self._post("/subscriptions", params, request_options=request_options)

def get(self, subscription_id):
return self._get("/subscriptions/%s" % subscription_id)

def update(self, subscription_id, params):
return self._post("/subscriptions/%s" % subscription_id, params)
def update(self, subscription_id, params, request_options=None):
return self._post(
"/subscriptions/%s" % subscription_id,
params,
request_options=request_options,
)

def cancel(self, subscription_id):
return self._delete("/subscriptions/%s" % subscription_id)
Expand Down
102 changes: 102 additions & 0 deletions tests/integration/test_charges.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from shift4.request_options import RequestOptions
from . import random_string
from .data.charges import valid_charge_req
from .data.customers import valid_customer_req
from .testcase import TestCase
Expand Down Expand Up @@ -87,3 +89,103 @@ def test_list(self, api):
self.assert_list_response_contains_exactly_by_id(
charges_after_last_id, [charge2, charge1]
)

def test_will_not_create_duplicate_if_same_idempotency_key_is_used(self, api):
# given
request_options = RequestOptions()
request_options.set_idempotency_key(random_string())
charge_req = valid_charge_req()

# when
first_call_response = api.charges.create(
charge_req, request_options=request_options
)
second_call_response = api.charges.create(
charge_req, request_options=request_options
)

# then
assert first_call_response == second_call_response

def test_will_create_two_instances_if_different_idempotency_keys_are_used(
self, api
):
# given
request_options = RequestOptions()
request_options.set_idempotency_key(random_string())
other_request_options = RequestOptions()
other_request_options.set_idempotency_key(random_string())
charge_req = valid_charge_req()

# when
first_call_response = api.charges.create(
charge_req, request_options=request_options
)
second_call_response = api.charges.create(
charge_req, request_options=other_request_options
)

# then
assert first_call_response != second_call_response

def test_will_create_two_instances_if_no_idempotency_keys_are_used(self, api):
# given
charge_req = valid_charge_req()

# when
first_call_response = api.charges.create(charge_req)
second_call_response = api.charges.create(charge_req)

# then
assert first_call_response != second_call_response

def test_will_throw_exception_if_same_idempotency_key_is_used_for_two_different_create_requests(
self, api
):
# given
request_options = RequestOptions()
request_options.set_idempotency_key(random_string())
charge_req = valid_charge_req()

# when
api.charges.create(charge_req, request_options=request_options)
charge_req["amount"] = "42"
exception = self.assert_shift4_exception(
api.charges.create(charge_req, request_options=request_options)
)

# then
assert exception.type == "invalid_request"
assert exception.code is None
assert (
exception.message
== "Idempotent key used for request with different parameters."
)

def test_will_throw_exception_if_same_idempotency_key_is_used_for_two_different_update_requests(
self, api
):
# given
request_options = RequestOptions()
request_options.set_idempotency_key(random_string())
charge_req = valid_charge_req()
created = api.charges.create(charge_req, request_options=request_options)
update_request_params = {
"description": "updated description",
"metadata": {"key": "updated value"},
}

# when
api.charges.update(created["id"], update_request_params, request_options)
update_request_params["description"] = "other description"
exception = self.assert_shift4_exception(
api.charges.update(created["id"], update_request_params)
)

# then
assert exception.type == "invalid_request"
assert exception.code is None
assert (
exception.message
== "Idempotent key used for request with different parameters."
)
12 changes: 6 additions & 6 deletions tests/integration/test_customers.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,18 @@ def test_create_and_get(self, api):
def test_create_without_email(self, api):
exception = self.assert_shift4_exception(api.customers.create, {})
assert exception.type == "invalid_request"
assert exception.code == None
assert exception.code is None
assert exception.message == "email: Must not be empty."
assert exception.charge_id == None
assert exception.blacklist_rule_id == None
assert exception.charge_id is None
assert exception.blacklist_rule_id is None

def test_get_with_invalid_id(self, api):
exception = self.assert_shift4_exception(api.customers.get, "1")
assert exception.type == "invalid_request"
assert exception.code == None
assert exception.code is None
assert exception.message == "Customer '1' does not exist"
assert exception.charge_id == None
assert exception.blacklist_rule_id == None
assert exception.charge_id is None
assert exception.blacklist_rule_id is None

def test_delete(self, api):
# given
Expand Down
Loading

0 comments on commit 9070c60

Please sign in to comment.