@@ -0,0 +1,21 @@ | |||
MIT License | |||
Copyright (c) 2020 Peppinux | |||
Permission is hereby granted, free of charge, to any person obtaining a copy | |||
of this software and associated documentation files (the "Software"), to deal | |||
in the Software without restriction, including without limitation the rights | |||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |||
copies of the Software, and to permit persons to whom the Software is | |||
furnished to do so, subject to the following conditions: | |||
The above copyright notice and this permission notice shall be included in all | |||
copies or substantial portions of the Software. | |||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |||
SOFTWARE. |
@@ -0,0 +1,203 @@ | |||
# DERO Merchant Python SDK | |||
Library with bindings for the [DERO Merchant REST API](https://merchant.dero.io/docs) for accepting DERO payments on a Python backend. | |||
## Requirements | |||
- A store registered on your [DERO Merchant Dashboard](https://merchant.dero.io/dashboard) to receive an API Key and a Secret Key, required to send requests to the API. | |||
- A Python web server. | |||
## Installation | |||
`pip install dero-merchant-python-sdk` | |||
## Usage | |||
### Import | |||
`import deromerchant` | |||
### Setup | |||
```python | |||
dm_client = deromerchant.Client( | |||
"API_KEY_OF_YOUR_STORE_GOES_HERE", # REQUIRED | |||
"SECRET_KEY_OF_YOUR_STORE_GOES_HERE", # REQUIRED | |||
scheme="https", # OPTIONAL. Default: https | |||
host="merchant.dero.io", # OPTIONAL. Default: merchant.dero.io | |||
api_version="v1", # OPTIONAL. Default: v1 | |||
) | |||
try: | |||
res = dm_client.ping() | |||
print(res) # {"ping":"pong"} | |||
except deromerchant.APIError as api_err: | |||
# Error returned by the API. Probably invalid API Key. | |||
print(api_err) | |||
except Exception as err: | |||
# Somethign went wrong while sending the request. | |||
# The server is offline or bad scheme/host/api version were provided. | |||
print(err) | |||
``` | |||
### Create a Payment | |||
```python | |||
try: | |||
# payment = dm_client.create_payment("USD", 1) // USD value will be converted to DERO | |||
# payment = dm_client.create_payment("EUR", 100) // Same thing goes for EUR and other currencies supported by the CoinGecko API V3 | |||
payment = dm_client.create_payment("DERO", 10) | |||
print(payment) | |||
""" | |||
Dictionary | |||
{ | |||
'paymentID': '9ad7fd4ccd85035b30bb8f3f4bea058d50fb38c6b12aca83bedc2bbc21a3d1b1', | |||
'status': 'pending', | |||
'currency': 'DERO', | |||
'currencyAmount': 10, | |||
'exchangeRate': 1, | |||
'deroAmount': '10.000000000000', | |||
'atomicDeroAmount': 10000000000000, | |||
'integratedAddress': 'dETin8HwLs94N6j8zASZjD8htBbQTkhUuicZEYKBG6zQENd8mrhopv3YqaeP3Q9q1RMLHX3PvF4F4Xy1cN3Rndq7daiU3kG58ZaPFPqhm3i2KCg9Jc2nRSb3n8A8NFpz9mWp7D4kJcC2dY', | |||
'creationTime': '2020-08-02T22:14:56.119235Z', | |||
'ttl': 60 | |||
} | |||
""" | |||
except deromerchant.APIError as api_err: | |||
# Handle API Error | |||
except Exception as err: | |||
# Handle error | |||
``` | |||
### Get a Payment from its ID | |||
```python | |||
try: | |||
payment_id = "9ad7fd4ccd85035b30bb8f3f4bea058d50fb38c6b12aca83bedc2bbc21a3d1b1" | |||
payment = dm_client.get_payment(payment_id) | |||
print(payment) | |||
""" | |||
Dictionary | |||
{ | |||
'paymentID': '9ad7fd4ccd85035b30bb8f3f4bea058d50fb38c6b12aca83bedc2bbc21a3d1b1', | |||
'status': 'pending', | |||
'currency': 'DERO', | |||
'currencyAmount': 10, | |||
'exchangeRate': 1, | |||
'deroAmount': '10.000000000000', | |||
'atomicDeroAmount': 10000000000000, | |||
'integratedAddress': 'dETin8HwLs94N6j8zASZjD8htBbQTkhUuicZEYKBG6zQENd8mrhopv3YqaeP3Q9q1RMLHX3PvF4F4Xy1cN3Rndq7daiU3kG58ZaPFPqhm3i2KCg9Jc2nRSb3n8A8NFpz9mWp7D4kJcC2dY', | |||
'creationTime': '2020-08-02T22:14:56.119235Z', | |||
'ttl': 55 | |||
} | |||
""" | |||
except deromerchant.APIError as api_err: | |||
# Handle API Error | |||
except Exception as err: | |||
# Handle error | |||
``` | |||
### Get an array of Payments from their IDs | |||
```python | |||
try: | |||
payment_ids = ["9ad7fd4ccd85035b30bb8f3f4bea058d50fb38c6b12aca83bedc2bbc21a3d1b1", "7d3dadd862344b2792a591d92391e49fdb15c3b0db6fe73b901000c54c97922c"] | |||
payments = dm_client.get_payments(payment_ids) | |||
print(payments) | |||
""" | |||
List of dictionaries | |||
[ | |||
{ | |||
'paymentID': '7d3dadd862344b2792a591d92391e49fdb15c3b0db6fe73b901000c54c97922c', | |||
'status': 'error', | |||
'currency': 'USD', | |||
'currencyAmount': 10, | |||
'exchangeRate': 1.18, | |||
'deroAmount': '8.474576271186', | |||
'atomicDeroAmount': 8474576271186, | |||
'integratedAddress': 'dETin8HwLs94N6j8zASZjD8htBbQTkhUuicZEYKBG6zQENd8mrhopv3YqaeP3Q9q1RMLHX3PvF4F4Xy1cN3Rndq7daiTzQYtH859kKAUCwhfgvRQM3BVMsaEvKibuCxfRoMD6k9MK7wtBk', | |||
'creationTime': '2020-08-02T14:59:54.259882Z', | |||
'ttl': 0 | |||
}, | |||
{ | |||
'paymentID': '9ad7fd4ccd85035b30bb8f3f4bea058d50fb38c6b12aca83bedc2bbc21a3d1b1', | |||
'status': 'pending', | |||
'currency': 'DERO', | |||
'currencyAmount': 10, | |||
'exchangeRate': 1, | |||
'deroAmount': '10.000000000000', | |||
'atomicDeroAmount': 10000000000000, | |||
'integratedAddress': 'dETin8HwLs94N6j8zASZjD8htBbQTkhUuicZEYKBG6zQENd8mrhopv3YqaeP3Q9q1RMLHX3PvF4F4Xy1cN3Rndq7daiU3kG58ZaPFPqhm3i2KCg9Jc2nRSb3n8A8NFpz9mWp7D4kJcC2dY', | |||
'creationTime': '2020-08-02T22:14:56.119235Z', | |||
'ttl': 52 | |||
} | |||
] | |||
""" | |||
except deromerchant.APIError as api_err: | |||
# Handle API Error | |||
except Exception as err: | |||
# Handle error | |||
``` | |||
### Get an array of filtered Payments | |||
_Not detailed because this endpoint was created for an internal usecase._ | |||
```python | |||
try: | |||
res = dm_client.get_filtered_payments( | |||
limit=None, | |||
page=None, | |||
sort_by=None, | |||
order_by=None, | |||
filter_status=None, | |||
filter_currency=None | |||
) | |||
print(res) # Dictionary | |||
except deromerchant.APIError as api_err: | |||
# Handle API Error | |||
except Exception as err: | |||
# Handle error | |||
``` | |||
### Get Pay helper page URL | |||
```python | |||
payment_id = "9ad7fd4ccd85035b30bb8f3f4bea058d50fb38c6b12aca83bedc2bbc21a3d1b1" | |||
pay_url = dm_client.get_pay_helper_url(payment_id) | |||
print(pay_url) # https://merchant.dero.io/pay/9ad7fd4ccd85035b30bb8f3f4bea058d50fb38c6b12aca83bedc2bbc21a3d1b1 | |||
``` | |||
### Verify Webhook Signature | |||
When using Webhooks to receive Payment status updates, it is highly suggested to verify the HTTP requests are actually sent by the DERO Merchant server thorugh the X-Signature header. | |||
**Example using Flask** | |||
```python | |||
import deromerchant | |||
import json | |||
from flask import Flask, request | |||
app = Flask(__name__) | |||
WEBHOOK_SECRET_KEY = "WEBHOOK_SECRET_KEY_OF_YOUR_STORE_GOES_HERE" | |||
@app.route("/dero_merchant_webhook_example", methods=["POST"]) | |||
def hello_world(): | |||
try: | |||
req_json = request.get_json() # Dictionary | |||
req_body = json.dumps(req_json, separators=(",", ":")) # String, required by verify_webhook_signature | |||
req_signature = request.headers["X-Signature"] | |||
valid = deromerchant.verify_webhook_signature(req_body, req_signature, WEBHOOK_SECRET_KEY) | |||
if valid: | |||
# Signature was verified. As such, as long Webhook Secret Key was stored securely, request should be trusted. | |||
# Proceed with updating the status of the order on your store associated to req_json["paymentID"] accordingly to req_json["status"] | |||
print(req_json) | |||
""" | |||
Dictionary | |||
{ | |||
'status': 'paid', | |||
'paymentID': '38ad8cf0c5da388fe9b5b44f6641619659c99df6cdece60c6e202acd78e895b1' | |||
} | |||
""" | |||
else: | |||
# Signature of the body provided in the request does not match the signature of the body generated using webhook_secret_key. | |||
# As such, REQUEST SHOULD NOT BE TRUSTED. | |||
# This could also mean a wrong WEBHOOK_SECRET_KEY was provided as a param, so be extra careful when copying the value from the Dashboard. | |||
except Exception as err: | |||
# Handle error | |||
return ("", 204) # No response needed | |||
``` |
@@ -0,0 +1,16 @@ | |||
"""Python SDK for DERO Merchant REST API | |||
This module allows developers to interact with the DERO Merchant REST API. | |||
Requires: | |||
requests (module) | |||
Exports: | |||
Client (class): exposes methods to send requests to the API. | |||
APIError (Exception): representation of the error object returned by the API. | |||
verify_webhook_signature (function): verifies the signature of requests to the webhook. | |||
""" | |||
from .client import Client | |||
from .api_error import APIError | |||
from .webhook import verify_webhook_signature |
@@ -0,0 +1,25 @@ | |||
class APIError(Exception): | |||
"""Exception that represents the error object returned by the API. | |||
Attributes: | |||
code: Integer of the error code returned by the API | |||
message: String of the error message returned by the API | |||
""" | |||
def __init__(self, code: int, message: str): | |||
"""Inits APIError with code and message | |||
Args: | |||
code: Integer of the error code returned by the API | |||
message: String of the error message returned by the API | |||
""" | |||
self.code = code | |||
self.message = message | |||
def __str__(self): | |||
"""Describes the exception. | |||
Returns: | |||
A string with the description of the error. | |||
""" | |||
return f'API Error {self.code}: {self.message}' |
@@ -0,0 +1,181 @@ | |||
import requests | |||
import json | |||
from .crypto_util import sign_message | |||
from .api_error import APIError | |||
DEFAULT_SCHEME = "https" | |||
DEFAULT_HOST = "merchant.dero.io" | |||
DEFAULT_API_VERSION = "v1" | |||
class Client: | |||
"""Dero Merchant Client. Has methods to interact with the Dero Merchant REST API. | |||
Attributes: | |||
__scheme: String of the scheme of the URL of the API. | |||
__host: String of the host of the URL of the API. | |||
__api_version: String of the version of the API. | |||
__base_url: URL of the API. | |||
timeout: Integer of the seconds before a connection to the API times out. | |||
__api_key: String of the API Key of the Client. | |||
__secret_key: String of the Secret Key of the Client. | |||
""" | |||
def __init__(self, api_key: str, secret_key: str, scheme=DEFAULT_SCHEME, host=DEFAULT_HOST, api_version=DEFAULT_API_VERSION): | |||
"""Inits Client with API Key, Secret Key, scheme, host and API Version | |||
Args: | |||
api_key: String of the API Key of the Client. | |||
secret_key: String of the Secret Key of the Client. | |||
scheme: String of the scheme of the URL of the API. | |||
host: String of the host of the URL of the API. | |||
api_version: String of the version of the API. | |||
""" | |||
self.__scheme = scheme | |||
self.__host = host | |||
self.__api_version = api_version | |||
self.__base_url = f'{self.__scheme}://{self.__host}/api/{self.__api_version}' | |||
self.timeout = 10 | |||
self.__api_key = api_key | |||
self.__secret_key = secret_key | |||
def __send_request(self, method: str, endpoint: str, query_params: dict=None, payload=None, sign_body=False) -> dict: | |||
"""Sends a request to the API. | |||
Args: | |||
method: String of the method of the request. | |||
endpoint: String of the endpoint of the request. | |||
query_params: Dictionary of the query params to send in the request. | |||
payload: Payload to send as the JSON body of the request. | |||
sign_body: Bool of whether the request body has to be signed or not. | |||
Returns: | |||
A dictionary of the JSON response. | |||
Raises: | |||
Exception: An error occured send the request. | |||
APIError: An error was returned as a response by the API. | |||
""" | |||
url = self.__base_url + endpoint | |||
headers = { | |||
"User-Agent": "DeroMerchant_Client_Python/1.0", | |||
"X-API-Key": self.__api_key, | |||
} | |||
json_payload = None | |||
json_res = None | |||
try: | |||
if payload is not None: | |||
headers["Content-Type"] = "application/json" | |||
headers["Accept"] = "applciation/json" | |||
json_payload = json.dumps(payload, separators=(",", ":")) | |||
if sign_body is not False: | |||
signature = sign_message(json_payload, self.__secret_key) | |||
headers["X-Signature"] = signature | |||
res = requests.request(method, url, timeout=self.timeout, params=query_params, headers=headers, data=json_payload) | |||
if "application/json" in res.headers.get("Content-Type"): | |||
json_res = res.json() | |||
if (res.status_code < 200) or (res.status_code > 299): | |||
if (json_res is not None) and ("error" in json_res): | |||
raise APIError(json_res["error"]["code"], json_res["error"]["message"]) | |||
else: | |||
if res.status_code == 404: | |||
raise Exception(f'error 404: page {res.url} not found') | |||
else: | |||
raise Exception(f'error {res.status_code} returned by {res.url}') | |||
except Exception as err: | |||
raise Exception(f'DeroMerchant Client: {err}') | |||
else: | |||
return json_res | |||
def ping(self) -> dict: | |||
"""Pings the API. | |||
Returns: | |||
A dictionary with the JSON response. | |||
""" | |||
return self.__send_request( | |||
method="GET", | |||
endpoint="/ping" | |||
) | |||
def create_payment(self, currency: str, amount: float) -> dict: | |||
"""Creates a new payment. | |||
Args: | |||
currency: String of the currency of the payment. | |||
amount: Float of the amount of the payment. | |||
Returns: | |||
A dictionary with the JSON response of the newly created Payment. | |||
""" | |||
return self.__send_request( | |||
method="POST", | |||
endpoint="/payment", | |||
payload={ | |||
"currency": currency, | |||
"amount": amount, | |||
}, | |||
sign_body=True | |||
) | |||
def get_payment(self, payment_id: str) -> dict: | |||
"""Gets a payment from its ID. | |||
Args: | |||
payment_id: String of the payment ID. | |||
Returns: | |||
A dictionary with the JSON response of the requested Payment. | |||
""" | |||
return self.__send_request( | |||
method="GET", | |||
endpoint=f'/payment/{payment_id}' | |||
) | |||
def get_payments(self, payment_ids: list) -> list: | |||
"""Gets payments from their IDs. | |||
Args: | |||
payment_ids: List of strings of the payment IDs. | |||
Returns: | |||
A list containing dictionaries with the JSON response of the requested payments. | |||
""" | |||
return self.__send_request( | |||
method="POST", | |||
endpoint="/payments", | |||
payload=payment_ids | |||
) | |||
def get_filtered_payments(self, limit: int=None, page: int=None, sort_by: str=None, order_by: str=None, filter_status:str=None, filter_currency: str=None) -> dict: | |||
"""Gets filtered payments""" | |||
return self.__send_request( | |||
method="GET", | |||
endpoint="/payments", | |||
query_params={ | |||
"limit": limit, | |||
"page": page, | |||
"sort_by": sort_by, | |||
"order_by": order_by, | |||
"filter_status": filter_status, | |||
"filter_currency": filter_currency, | |||
} | |||
) | |||
def get_pay_helper_url(self, payment_id: str) -> str: | |||
"""Gets the URL of the Pay helper page of the payment ID. | |||
Args: | |||
payment_id: String of the payment ID. | |||
Returns: | |||
String with the URL of the Pay helper page of the payment ID. | |||
""" | |||
return f'{self.__scheme}://{self.__host}/pay/{payment_id}' |
@@ -0,0 +1,37 @@ | |||
import hmac | |||
import hashlib | |||
import binascii | |||
def sign_message(message: str, key: str) -> str: | |||
"""Signs a message with a key. | |||
Args: | |||
message: | |||
String of the message we want to sign. | |||
key: | |||
String of the SHA256 hex encoded key we want to use to sign the message. | |||
Returns: | |||
A string containing the SHA256 hex encoded signature of the message. | |||
""" | |||
key_bytes = binascii.unhexlify(key) | |||
message_bytes = message.encode() | |||
return hmac.new(key_bytes, message_bytes, hashlib.sha256).hexdigest() | |||
def valid_mac(message: str, message_mac: str, key: str) -> bool: | |||
"""Verifies if the signature of a message is valid. | |||
Args: | |||
message: | |||
String of the message we want to verify. | |||
message_mac: | |||
String of the SHA256 hex encoded signature of the message we want to verify. | |||
key: | |||
String of the SHA256 hex encoded key used to sign the message. | |||
Returns: | |||
A bool with the validity of the signature. | |||
""" | |||
signed_message = sign_message(message, key) | |||
return hmac.compare_digest(bytearray.fromhex(message_mac), bytearray.fromhex(signed_message)) |
@@ -0,0 +1,16 @@ | |||
from .crypto_util import valid_mac | |||
def verify_webhook_signature(req_body: str, req_signature: str, webhook_secret_key: str) -> bool: | |||
"""Verifies the signature of a webhook request. | |||
Args: | |||
req_body: | |||
A string of the body of the webhook request. | |||
req_signature: | |||
A string of the SHA256 hex encoded signature of the body of the webhook request. | |||
webhook_secret_key: | |||
A string of the SHA256 hex encoded webhook secret key. | |||
Returns: | |||
A bool with the validity of the signature. | |||
""" | |||
return valid_mac(req_body, req_signature, webhook_secret_key) |
@@ -0,0 +1 @@ | |||
requests |
@@ -0,0 +1,26 @@ | |||
import setuptools | |||
with open("README.md") as f: | |||
readme = f.read() | |||
with open("LICENSE") as f: | |||
license = f.read() | |||
setuptools.setup( | |||
name="dero-merchant-python-sdk", | |||
version="1.0.0", | |||
author="Peppinux", | |||
description="Python SDK for DERO Merchant REST API", | |||
long_description=readme, | |||
long_description_content_type="text/markdown", | |||
url="https://github.com/peppinux/dero-merchant-python-sdk", | |||
license=license, | |||
packages=setuptools.find_packages(), | |||
classifiers=[ | |||
"Programming Language :: Python :: 3.6", | |||
"License :: OSI Approved :: MIT License", | |||
"Operating System :: OS Independent", | |||
"Intended Audience :: Developers", | |||
], | |||
python_requires=">=3.6", | |||
) |