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
3 changes: 3 additions & 0 deletions .github/workflows/craftgate-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ jobs:
- name: Compile sources
run: python -m compileall craftgate

- name: Run unit tests
run: python -m unittest tests.test_idempotency -v

- name: Build distribution and verify metadata
if: matrix.python-version == '3.12'
run: |
Expand Down
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,46 @@ resp = payment.create_payment(req)
print(f"Create Payment Result: {resp}")
~~~

## Idempotency

Mutating operations accept an optional idempotency key. Set it on the request object and the
client sends it as the `x-idempotency-key` header, so a request can be safely retried (e.g. after a timeout) without the
operation being performed twice — the server returns the result of the first request when it sees a repeated key.

Every request extends `BaseRequest`, which carries a `HeaderOptions` object, so the key is available on any request:

~~~python
import uuid

from craftgate import HeaderOptions

req = CreatePaymentRequest()
req.price = Decimal("100")
req.paid_price = Decimal("100")
req.currency = Currency.TRY
req.payment_group = PaymentGroup.LISTING_OR_SUBSCRIPTION
req.header_options = HeaderOptions(idempotency_key=str(uuid.uuid4()))
# ... other fields

resp = payment.create_payment(req)
~~~

`with_header_options()` sets it inline and returns the request, which is handy for operations whose parameters live in
the URL path:

~~~python
payment.expire_checkout_payment(
ExpireCheckoutPaymentRequest(token="456d1297-908e-4bd6-a13b-4be31a6e47d5")
.with_header_options(HeaderOptions(idempotency_key=str(uuid.uuid4()))))
~~~

> Use a fresh key per distinct operation, and reuse the same key when retrying that operation.

> The API honours the key on `POST`, `PATCH` and `DELETE` only. It is ignored on `PUT` endpoints, so retrying one of those is not de-duplicated.

`HeaderOptions` is sent as headers only — it never appears in the request body, the query string, or the request
signature.

## Examples

A variety of end-to-end samples (3DS, Checkout, APM, refunds, stored cards, marketplace, pre/post-auth) live under the
Expand Down
38 changes: 19 additions & 19 deletions craftgate/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,59 +23,59 @@ def __init__(self, options):

self.options = options

def payment(self):
def payment(self) -> PaymentAdapter:
return PaymentAdapter(self.options)

def bank_account_tracking(self):
def bank_account_tracking(self) -> BankAccountTrackingAdapter:
return BankAccountTrackingAdapter(self.options)

def bkm_express_payment(self):
def bkm_express_payment(self) -> BkmExpressPaymentAdapter:
return BkmExpressPaymentAdapter(self.options)

def file_reporting(self):
def file_reporting(self) -> FileReportingAdapter:
return FileReportingAdapter(self.options)

def fraud(self):
def fraud(self) -> FraudAdapter:
return FraudAdapter(self.options)

def hook(self):
def hook(self) -> HookAdapter:
return HookAdapter(self.options)

def installment(self):
def installment(self) -> InstallmentAdapter:
return InstallmentAdapter(self.options)

def juzdan_payment(self):
def juzdan_payment(self) -> JuzdanPaymentAdapter:
return JuzdanPaymentAdapter(self.options)

def masterpass_payment(self):
def masterpass_payment(self) -> MasterpassPaymentAdapter:
return MasterpassPaymentAdapter(self.options)

def merchant(self):
def merchant(self) -> MerchantAdapter:
return MerchantAdapter(self.options)

def merchant_apm(self):
def merchant_apm(self) -> MerchantApmAdapter:
return MerchantApmAdapter(self.options)

def onboarding(self):
def onboarding(self) -> OnboardingAdapter:
return OnboardingAdapter(self.options)

def pay_by_link(self):
def pay_by_link(self) -> PayByLinkAdapter:
return PayByLinkAdapter(self.options)

def payment_reporting(self):
def payment_reporting(self) -> PaymentReportingAdapter:
return PaymentReportingAdapter(self.options)

def payment_token(self):
def payment_token(self) -> PaymentTokenAdapter:
return PaymentTokenAdapter(self.options)

def settlement(self):
def settlement(self) -> SettlementAdapter:
return SettlementAdapter(self.options)

def settlement_reporting(self):
def settlement_reporting(self) -> SettlementReportingAdapter:
return SettlementReportingAdapter(self.options)

def wallet(self):
def wallet(self) -> WalletAdapter:
return WalletAdapter(self.options)

def meal_voucher_card_tokenization(self):
def meal_voucher_card_tokenization(self) -> MealVoucherCardTokenizationAdapter:
return MealVoucherCardTokenizationAdapter(self.options)
2 changes: 1 addition & 1 deletion craftgate/adapter/bank_account_tracking_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ def search_records(
) -> BankAccountTrackingRecordListResponse:
query = RequestQueryParamsBuilder.build_query_params(request)
path = "/bank-account-tracking/v1/merchant-bank-account-trackings/records" + query
headers = self._create_headers(None, path)
headers = self._create_headers_without_body(request, path)
return self._http_client.request(
method="GET",
url=self.request_options.base_url + path,
Expand Down
42 changes: 39 additions & 3 deletions craftgate/adapter/base_adapter.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import uuid
from typing import Any, Dict, Optional
from typing import Dict, Optional

from _version import VERSION
from craftgate.request.common.base_request import BaseRequest
from craftgate.request.common.header_options import HeaderOptions
from craftgate.request_options import RequestOptions
from craftgate.utils.hash_generator import HashGenerator


class BaseAdapter:
API_VERSION_HEADER_VALUE = "v1"
CLIENT_NAME = "craftgate-python-client"
Expand All @@ -16,15 +17,35 @@ class BaseAdapter:
CLIENT_VERSION_HEADER_NAME = "x-client-version"
SIGNATURE_HEADER_NAME = "x-signature"
LANGUAGE_HEADER_NAME = "lang"
IDEMPOTENCY_KEY_HEADER_NAME = "x-idempotency-key"

def __init__(self, request_options: RequestOptions) -> None:
self.request_options = request_options

def _create_headers(
self,
request_body: Optional[Any],
request_body: Optional[BaseRequest],
path: str,
custom_options: Optional[RequestOptions] = None
) -> Dict[str, str]:
return self._create_http_headers(
request_body, path, custom_options, self._header_options_of(request_body))

def _create_headers_without_body(
self,
request: Optional[BaseRequest],
path: str,
custom_options: Optional[RequestOptions] = None
) -> Dict[str, str]:
return self._create_http_headers(
None, path, custom_options, self._header_options_of(request))

def _create_http_headers(
self,
request_body: Optional[BaseRequest],
path: str,
custom_options: Optional[RequestOptions],
header_options: Optional[HeaderOptions]
) -> Dict[str, str]:
options = custom_options or self.request_options
random_key = self._generate_random_string()
Expand All @@ -49,7 +70,22 @@ def _create_headers(
if options.language:
headers[self.LANGUAGE_HEADER_NAME] = options.language

self._apply_request_scoped_headers(headers, header_options)

return headers

@staticmethod
def _header_options_of(request: Optional[BaseRequest]) -> Optional[HeaderOptions]:
return request.header_options if request is not None else None

def _apply_request_scoped_headers(
self, headers: Dict[str, str], header_options: Optional[HeaderOptions]
) -> None:
if header_options is None:
return

if header_options.idempotency_key is not None:
headers[self.IDEMPOTENCY_KEY_HEADER_NAME] = header_options.idempotency_key

def _generate_random_string(self) -> str:
return str(uuid.uuid4())
13 changes: 7 additions & 6 deletions craftgate/adapter/file_reporting_adapter.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from typing import Dict
from typing import Dict, Optional

from craftgate.adapter.base_adapter import BaseAdapter
from craftgate.net.base_http_client import BaseHttpClient
from craftgate.request.common.base_request import BaseRequest
from craftgate.request.create_report_request import CreateReportRequest
from craftgate.request.retrieve_daily_payment_report_request import RetrieveDailyPaymentReportRequest
from craftgate.request.retrieve_daily_transaction_report_request import RetrieveDailyTransactionReportRequest
Expand All @@ -23,7 +24,7 @@ def retrieve_daily_transaction_report(
) -> bytes:
query = RequestQueryParamsBuilder.build_query_params(request)
path = "/file-reporting/v1/transaction-reports" + query
headers = self._prepare_binary_headers(path)
headers = self._prepare_binary_headers(request, path)
return self._http_client.request(
method="GET",
url=self.request_options.base_url + path,
Expand All @@ -37,7 +38,7 @@ def retrieve_daily_payment_report(
) -> bytes:
query = RequestQueryParamsBuilder.build_query_params(request)
path = "/file-reporting/v1/payment-reports" + query
headers = self._prepare_binary_headers(path)
headers = self._prepare_binary_headers(request, path)
return self._http_client.request(
method="GET",
url=self.request_options.base_url + path,
Expand All @@ -60,7 +61,7 @@ def create_report(self, request: CreateReportRequest) -> ReportDemandResponse:
def retrieve_report(self, request: RetrieveReportRequest, report_id: int) -> bytes:
query = RequestQueryParamsBuilder.build_query_params(request)
path = "/file-reporting/v1/reports/{}".format(report_id) + query
headers = self._prepare_binary_headers(path)
headers = self._prepare_binary_headers(request, path)
return self._http_client.request(
method="GET",
url=self.request_options.base_url + path,
Expand All @@ -69,7 +70,7 @@ def retrieve_report(self, request: RetrieveReportRequest, report_id: int) -> byt
response_type=bytes
)

def _prepare_binary_headers(self, path: str) -> Dict[str, str]:
headers = self._create_headers(None, path)
def _prepare_binary_headers(self, request: Optional[BaseRequest], path: str) -> Dict[str, str]:
headers = self._create_headers_without_body(request, path)
headers["Content-Type"] = self.APPLICATION_OCTET_STREAM
return headers
31 changes: 15 additions & 16 deletions craftgate/adapter/fraud_adapter.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
from craftgate.adapter.base_adapter import BaseAdapter
from craftgate.model.fraud_check_status import FraudCheckStatus
from craftgate.model.fraud_value_type import FraudValueType
from craftgate.net.base_http_client import BaseHttpClient
from craftgate.request.delete_value_list_request import DeleteValueListRequest
from craftgate.request.fraud_add_card_fingerprint_to_list_request import FraudAddCardFingerprintToListRequest
from craftgate.request.fraud_value_list_request import FraudValueListRequest
from craftgate.request.remove_value_from_value_list_request import RemoveValueFromValueListRequest
from craftgate.request.search_fraud_checks_request import SearchFraudChecksRequest
from craftgate.request.search_fraud_rule_request import SearchFraudRuleRequest
from craftgate.request.update_fraud_check_request import UpdateFraudCheckRequest
from craftgate.request.update_fraud_check_status_request import UpdateFraudCheckStatusRequest
from craftgate.request_options import RequestOptions
from craftgate.response.fraud_all_value_lists_response import FraudAllValueListsResponse
from craftgate.response.fraud_check_list_response import FraudCheckListResponse
from craftgate.response.fraud_rule_response import FraudRuleResponse
from craftgate.response.fraud_value_list_response import FraudValueListResponse
from craftgate.utils.request_query_params_builder import RequestQueryParamsBuilder


class FraudAdapter(BaseAdapter):
def __init__(self, request_options: RequestOptions) -> None:
super(FraudAdapter, self).__init__(request_options)
Expand All @@ -23,7 +23,7 @@ def __init__(self, request_options: RequestOptions) -> None:
def search_fraud_checks(self, request: SearchFraudChecksRequest) -> FraudCheckListResponse:
query = RequestQueryParamsBuilder.build_query_params(request)
path = "/fraud/v1/fraud-checks" + query
headers = self._create_headers(None, path)
headers = self._create_headers_without_body(request, path)
return self._http_client.request(
method="GET",
url=self.request_options.base_url + path,
Expand All @@ -35,7 +35,7 @@ def search_fraud_checks(self, request: SearchFraudChecksRequest) -> FraudCheckLi
def search_fraud_rules(self, request: SearchFraudRuleRequest) -> FraudRuleResponse:
query = RequestQueryParamsBuilder.build_query_params(request)
path = "/fraud/v1/rules" + query
headers = self._create_headers(None, path)
headers = self._create_headers_without_body(request, path)
return self._http_client.request(
method="GET",
url=self.request_options.base_url + path,
Expand All @@ -44,15 +44,14 @@ def search_fraud_rules(self, request: SearchFraudRuleRequest) -> FraudRuleRespon
response_type=FraudCheckListResponse
)

def update_fraud_check_status(self, id: int, fraud_check_status: FraudCheckStatus) -> None:
path = "/fraud/v1/fraud-checks/{}/check-status".format(id)
body = UpdateFraudCheckRequest(check_status=fraud_check_status)
headers = self._create_headers(body, path)
def update_fraud_check_status(self, request: UpdateFraudCheckStatusRequest) -> None:
path = "/fraud/v1/fraud-checks/{}/check-status".format(request.id)
headers = self._create_headers(request, path)
self._http_client.request(
method="PUT",
url=self.request_options.base_url + path,
headers=headers,
body=body,
body=request,
response_type=None
)

Expand Down Expand Up @@ -87,9 +86,9 @@ def create_value_list(self, list_name: str, value_type: FraudValueType) -> None:
)
self.add_value_to_value_list(body)

def delete_value_list(self, list_name: str) -> None:
path = "/fraud/v1/value-lists/{}".format(list_name)
headers = self._create_headers(None, path)
def delete_value_list(self, request: DeleteValueListRequest) -> None:
path = "/fraud/v1/value-lists/{}".format(request.list_name)
headers = self._create_headers_without_body(request, path)
self._http_client.request(
method="DELETE",
url=self.request_options.base_url + path,
Expand Down Expand Up @@ -128,9 +127,9 @@ def add_card_fingerprint_to_value_list(
"""
self.add_card_fingerprint(request=request, list_name=list_name)

def remove_value_from_value_list(self, list_name: str, value_id: str) -> None:
path = "/fraud/v1/value-lists/{}/values/{}".format(list_name, value_id)
headers = self._create_headers(None, path)
def remove_value_from_value_list(self, request: RemoveValueFromValueListRequest) -> None:
path = "/fraud/v1/value-lists/{}/values/{}".format(request.list_name, request.value_id)
headers = self._create_headers_without_body(request, path)
self._http_client.request(
method="DELETE",
url=self.request_options.base_url + path,
Expand Down
2 changes: 1 addition & 1 deletion craftgate/adapter/installment_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ def __init__(self, request_options) -> None:
def search_installments(self, request: SearchInstallmentsRequest) -> InstallmentListResponse:
query = RequestQueryParamsBuilder.build_query_params(request)
path = "/installment/v1/installments" + query
headers = self._create_headers(None, path)
headers = self._create_headers_without_body(request, path)
return self._http_client.request(
method="GET",
url=self.request_options.base_url + path,
Expand Down
Loading
Loading