diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..57239a0 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,67 @@ +name: Test Python open payments sdk + +on: + push: + branches: + - '**' + pull_request: + branches: + - '**' + +jobs: + test: + runs-on: ${{ matrix.os }} + + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + python-version: ["3.10","3.11","3.12","3.13"] + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Setup Poetry on Windows + if: runner.os == 'Windows' + run: | + (Invoke-WebRequest -Uri https://install.python-poetry.org -UseBasicParsing).Content | py - + echo "$env:APPDATA\Python\Scripts" >> $env:GITHUB_PATH + + - name: Verify Poetry and create env on Windows + if: runner.os == 'Windows' + run: | + poetry --version + poetry config virtualenvs.create false + + + - name: Setup Poetry on Ubuntu + if: runner.os != 'Windows' + run: | + curl -sSL https://install.python-poetry.org | python3 - + echo "$HOME/.local/bin" >> $GITHUB_PATH + poetry config virtualenvs.create false + + - name: Install dependencies + run: | + poetry install --no-interaction --no-root + poetry install --only dev + + - name: Run Unit Tests + run: | + poetry run pytest tests/unit/ + + audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Install dependencies + run: | + python3 -m pip install . # assumes dependencies declared in pyproject.toml + python3 -m pip install pip-audit + - name: Run pip-audit + run: pip-audit . diff --git a/.gitignore b/.gitignore index 1e61ff7..04ebe4d 100644 --- a/.gitignore +++ b/.gitignore @@ -170,3 +170,4 @@ poetry.toml pyrightconfig.json # End of https://www.toptal.com/developers/gitignore/api/python +privkey.pem diff --git a/README.md b/README.md index 4f8818c..24abd66 100644 --- a/README.md +++ b/README.md @@ -21,13 +21,130 @@ An Open Payments server runs two sub-systems, a resource server which exposes AP ## Local development -### Dependencies - - Python >= 3.11 + + To install python visit [Python Download](https://www.python.org/downloads/) + - Poetry + To install poetry visit [Poetry Documentation](https://python-poetry.org/docs/). ### Installation +1. Activate your virtual emvironment. No need to create one, Poetry creates one. + Read [managing environments in Poetry](https://python-poetry.org/docs/managing-environments/). + +2. Install the dependencies in the poetry.lock + ``` > poetry install ``` + +## Usage + +To use this SDK, you will first need to install it in your project. Currently, you will need to build from source but once it is hosted on PyPi you will be able to install it with `pip`. + +```bash +python3 -m pip install open-payments-python-sdk #currently not setup +``` + +## Installing from source + +Clone the repository + +```bash +git clone https://github.com/interledger/open-payments-python-sdk.git +cd open-payments-python-sdk +``` + +Build the package + +```bash +poetry build +``` + +After running this command, the wheel package will be written to the `dist/` folder in the repo you just cloned + +Install it in your project + +```bash +pip install open-payments-python-sdk/dist/open_payments_sdk-0.1.0-py3-none-any.whl +``` + +# Initialising the Client + +To create a client you can do so by importing the `OpenPaymentsClient` defined in the [`client`](./src/client/client.py) module and instantiating it. + +```python +from open_payments_sdk.client.client import OpenPaymentsClient + +with open("privkey.pem","r",encoding="utf_8") as privkey: + private_key = privkey.read() + +op_client = OpenPaymentsClient(keyid="27b4f8d2-746c-4522-b3f0-874ca15bfe65",private_key=private_key) +``` + +The client is to be created after you have created a key pair and have obtained the `kid` and `private_key` + +Some helper functions have been created to ease key pair creation. A class called `KeyManager` has been created and it provides functions to create a key pair and load a private key from UTF-8 string. It also returns an object that has information to be registered at the AS when registering the public key. + +```python +import json +from open_payments_sdk.gnap_utils.keys import KeyManager + +key_manager = KeyManager() +key_pair = key_manager.generate_key_pair() # generate key pair + +with open("privkey.pem", "w",encoding="utf_8") as pem_file: # save private key to file + pem_file.write(key_pair.private_key_pem) + + +with open("jwks.json","w", encoding="utf_8") as jwks_file: # save jwks.json file + jwks_file.write(json.dumps(key_pair.jwks.keys[0].__dict__)) + + +with open("privkey.pem", "r", encoding="utf_8") as privkey: # load private key from file system + private_key = privkey.read() + +private_key = key_manager.load_ed25519_private_key_from_pem( + private_key +) # load private key fron file utf_8 string + +public_key = private_key.public_key() # derive public key from private key +``` + +## Wallets + +You can use the created client to interact with the resource server. In this case we will use it to interact with a wallet address to get the wallet address details and jwks.json + +```python +#get wallet address +wallet_address_details = op_client.wallet.get_wallet_address("https://ilp.interledger-test.dev/elijahokellosalary") + +# Response +{'id': AnyUrl('https://ilp.interledger-test.dev/elijahokellosalary'), 'publicName': 'elijahokellosalary', 'assetCode': AssetCode(root='USD'), 'assetScale': AssetScale(root=2), 'authServer': AnyUrl('https://auth.interledger-test.dev/'), 'resourceServer': AnyUrl('https://ilp.interledger-test.dev/')} + +``` + +Get Wallet jwks + +```python +#get wallet jwks +wallet_jwks = op_client.wallet.get_keys("https://ilp.interledger-test.dev/elijahokellosalary") + +# Response +{'keys': []} + +``` + +## Grants + +You can use the created client to request a grant from the authorization server. In this case we will use it to request a grant for an incoming payment resource. Check the [fixtures](./tests/conftest.py) file to see the request body. + +```python +wallet = op_client.wallet.get_wallet_address("https://ilp.interledger-test.dev/5c327379") +grant_response = op_client.grants.post_grant_request(grant_request=grant_req_dto,auth_server_endpoint=str(wallet.authServer)) + +# Response +{'access_token': {'access': [{'actions': ['create', 'read'], 'identifier': 'https://ilp.interledger-test.dev/5c327379', 'type': 'incoming-payment'}], 'value': '2E6F040D518B6F1A0883', 'manage': 'https://auth.interledger-test.dev/token/dad85db0-804d-4778-bf78-33eb5f81d86e', 'expires_in': 600}, 'continue': {'access_token': {'value': '3A088F83D39BDCDEC995'}, 'uri': 'https://auth.interledger-test.dev/continue/d2bb7a46-8cd9-4dde-83e2-821353b50579'}} + +``` diff --git a/poetry.lock b/poetry.lock index 5e6f75a..f5c124b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. [[package]] name = "annotated-types" @@ -25,13 +25,14 @@ files = [ ] [package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} idna = ">=2.8" sniffio = ">=1.1" typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] -test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21)"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""] trio = ["trio (>=0.26.1)"] [[package]] @@ -103,6 +104,8 @@ mypy-extensions = ">=0.4.3" packaging = ">=22.0" pathspec = ">=0.9.0" platformdirs = ">=2" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} [package.extras] colorama = ["colorama (>=0.4.3)"] @@ -122,6 +125,87 @@ files = [ {file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"}, ] +[[package]] +name = "cffi" +version = "1.17.1" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "platform_python_implementation != \"PyPy\"" +files = [ + {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, + {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, + {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, + {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, + {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, + {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, + {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, + {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, + {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, + {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, + {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"}, + {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"}, + {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"}, + {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"}, + {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, + {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, +] + +[package.dependencies] +pycparser = "*" + [[package]] name = "click" version = "8.1.8" @@ -150,6 +234,66 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "cryptography" +version = "45.0.4" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = "!=3.9.0,!=3.9.1,>=3.7" +groups = ["main"] +files = [ + {file = "cryptography-45.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:425a9a6ac2823ee6e46a76a21a4e8342d8fa5c01e08b823c1f19a8b74f096069"}, + {file = "cryptography-45.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:680806cf63baa0039b920f4976f5f31b10e772de42f16310a6839d9f21a26b0d"}, + {file = "cryptography-45.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4ca0f52170e821bc8da6fc0cc565b7bb8ff8d90d36b5e9fdd68e8a86bdf72036"}, + {file = "cryptography-45.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f3fe7a5ae34d5a414957cc7f457e2b92076e72938423ac64d215722f6cf49a9e"}, + {file = "cryptography-45.0.4-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:25eb4d4d3e54595dc8adebc6bbd5623588991d86591a78c2548ffb64797341e2"}, + {file = "cryptography-45.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ce1678a2ccbe696cf3af15a75bb72ee008d7ff183c9228592ede9db467e64f1b"}, + {file = "cryptography-45.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:49fe9155ab32721b9122975e168a6760d8ce4cffe423bcd7ca269ba41b5dfac1"}, + {file = "cryptography-45.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2882338b2a6e0bd337052e8b9007ced85c637da19ef9ecaf437744495c8c2999"}, + {file = "cryptography-45.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:23b9c3ea30c3ed4db59e7b9619272e94891f8a3a5591d0b656a7582631ccf750"}, + {file = "cryptography-45.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0a97c927497e3bc36b33987abb99bf17a9a175a19af38a892dc4bbb844d7ee2"}, + {file = "cryptography-45.0.4-cp311-abi3-win32.whl", hash = "sha256:e00a6c10a5c53979d6242f123c0a97cff9f3abed7f064fc412c36dc521b5f257"}, + {file = "cryptography-45.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:817ee05c6c9f7a69a16200f0c90ab26d23a87701e2a284bd15156783e46dbcc8"}, + {file = "cryptography-45.0.4-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:964bcc28d867e0f5491a564b7debb3ffdd8717928d315d12e0d7defa9e43b723"}, + {file = "cryptography-45.0.4-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6a5bf57554e80f75a7db3d4b1dacaa2764611ae166ab42ea9a72bcdb5d577637"}, + {file = "cryptography-45.0.4-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:46cf7088bf91bdc9b26f9c55636492c1cce3e7aaf8041bbf0243f5e5325cfb2d"}, + {file = "cryptography-45.0.4-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7bedbe4cc930fa4b100fc845ea1ea5788fcd7ae9562e669989c11618ae8d76ee"}, + {file = "cryptography-45.0.4-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:eaa3e28ea2235b33220b949c5a0d6cf79baa80eab2eb5607ca8ab7525331b9ff"}, + {file = "cryptography-45.0.4-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:7ef2dde4fa9408475038fc9aadfc1fb2676b174e68356359632e980c661ec8f6"}, + {file = "cryptography-45.0.4-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6a3511ae33f09094185d111160fd192c67aa0a2a8d19b54d36e4c78f651dc5ad"}, + {file = "cryptography-45.0.4-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:06509dc70dd71fa56eaa138336244e2fbaf2ac164fc9b5e66828fccfd2b680d6"}, + {file = "cryptography-45.0.4-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5f31e6b0a5a253f6aa49be67279be4a7e5a4ef259a9f33c69f7d1b1191939872"}, + {file = "cryptography-45.0.4-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:944e9ccf67a9594137f942d5b52c8d238b1b4e46c7a0c2891b7ae6e01e7c80a4"}, + {file = "cryptography-45.0.4-cp37-abi3-win32.whl", hash = "sha256:c22fe01e53dc65edd1945a2e6f0015e887f84ced233acecb64b4daadb32f5c97"}, + {file = "cryptography-45.0.4-cp37-abi3-win_amd64.whl", hash = "sha256:627ba1bc94f6adf0b0a2e35d87020285ead22d9f648c7e75bb64f367375f3b22"}, + {file = "cryptography-45.0.4-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a77c6fb8d76e9c9f99f2f3437c1a4ac287b34eaf40997cfab1e9bd2be175ac39"}, + {file = "cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7aad98a25ed8ac917fdd8a9c1e706e5a0956e06c498be1f713b61734333a4507"}, + {file = "cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3530382a43a0e524bc931f187fc69ef4c42828cf7d7f592f7f249f602b5a4ab0"}, + {file = "cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:6b613164cb8425e2f8db5849ffb84892e523bf6d26deb8f9bb76ae86181fa12b"}, + {file = "cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:96d4819e25bf3b685199b304a0029ce4a3caf98947ce8a066c9137cc78ad2c58"}, + {file = "cryptography-45.0.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b97737a3ffbea79eebb062eb0d67d72307195035332501722a9ca86bab9e3ab2"}, + {file = "cryptography-45.0.4-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4828190fb6c4bcb6ebc6331f01fe66ae838bb3bd58e753b59d4b22eb444b996c"}, + {file = "cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:03dbff8411206713185b8cebe31bc5c0eb544799a50c09035733716b386e61a4"}, + {file = "cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:51dfbd4d26172d31150d84c19bbe06c68ea4b7f11bbc7b3a5e146b367c311349"}, + {file = "cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:0339a692de47084969500ee455e42c58e449461e0ec845a34a6a9b9bf7df7fb8"}, + {file = "cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:0cf13c77d710131d33e63626bd55ae7c0efb701ebdc2b3a7952b9b23a0412862"}, + {file = "cryptography-45.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bbc505d1dc469ac12a0a064214879eac6294038d6b24ae9f71faae1448a9608d"}, + {file = "cryptography-45.0.4.tar.gz", hash = "sha256:7405ade85c83c37682c8fe65554759800a4a8c54b2d96e0f8ad114d31b808d57"}, +] + +[package.dependencies] +cffi = {version = ">=1.14", markers = "platform_python_implementation != \"PyPy\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs ; python_full_version >= \"3.8.0\"", "sphinx-rtd-theme (>=3.0.0) ; python_full_version >= \"3.8.0\""] +docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] +nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2) ; python_full_version >= \"3.8.0\""] +pep8test = ["check-sdist ; python_full_version >= \"3.8.0\"", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"] +sdist = ["build (>=1.0.0)"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["certifi (>=2024)", "cryptography-vectors (==45.0.4)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] +test-randomorder = ["pytest-randomly"] + [[package]] name = "datamodel-code-generator" version = "0.28.1" @@ -193,6 +337,25 @@ files = [ {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, ] +[[package]] +name = "exceptiongroup" +version = "1.3.0" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +markers = "python_version == \"3.10\"" +files = [ + {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, + {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} + +[package.extras] +test = ["pytest (>=6)"] + [[package]] name = "executing" version = "2.2.0" @@ -206,7 +369,7 @@ files = [ ] [package.extras] -tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich"] +tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich ; python_version >= \"3.11\""] [[package]] name = "genson" @@ -232,6 +395,43 @@ files = [ {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, ] +[[package]] +name = "http-message-signatures" +version = "0.6.1" +description = "An implementation of the IETF HTTP Message Signatures draft standard" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "http_message_signatures-0.6.1-py3-none-any.whl", hash = "sha256:210346bfcf1e90c5877b90b4f0f7fd0e20128087d488b4b300e01daf1e002d75"}, + {file = "http_message_signatures-0.6.1.tar.gz", hash = "sha256:e0c409b1529826e90a75563d892db82eba1d1afae95b14e242f9221e8b9382e4"}, +] + +[package.dependencies] +cryptography = ">=36.0.2" +http-sfv = ">=0.9.3" + +[package.extras] +tests = ["build", "coverage", "flake8", "mypy", "requests", "ruff", "wheel"] + +[[package]] +name = "http-sfv" +version = "0.9.9" +description = "Parse and serialise HTTP Structured Field Values" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "http_sfv-0.9.9-py3-none-any.whl", hash = "sha256:5feed51c90e9a1dc797701662d044d936923cf0027255c75452b8240e33d6c82"}, + {file = "http_sfv-0.9.9.tar.gz", hash = "sha256:e132dc9bef990832bc01824f5fa9d4efc7d0f4271e4b227db35c8ef38540c739"}, +] + +[package.dependencies] +typing-extensions = "*" + +[package.extras] +dev = ["black", "mypy"] + [[package]] name = "httpcore" version = "1.0.7" @@ -273,7 +473,7 @@ httpcore = "==1.*" idna = "*" [package.extras] -brotli = ["brotli", "brotlicffi"] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] @@ -308,7 +508,7 @@ files = [ [package.extras] docs = ["jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx"] -testing = ["pygments", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] +testing = ["pygments", "pytest (>=6)", "pytest-black (>=0.3.7) ; platform_python_implementation != \"PyPy\"", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1) ; platform_python_implementation != \"PyPy\""] [[package]] name = "iniconfig" @@ -335,8 +535,9 @@ files = [ ] [package.dependencies] -decorator = {version = "*", markers = "python_version >= \"3.11\""} -ipython = {version = ">=7.31.1", markers = "python_version >= \"3.11\""} +decorator = {version = "*", markers = "python_version > \"3.6\""} +ipython = {version = ">=7.31.1", markers = "python_version > \"3.6\""} +tomli = {version = "*", markers = "python_version > \"3.6\" and python_version < \"3.11\""} [[package]] name = "ipython" @@ -353,6 +554,7 @@ files = [ [package.dependencies] colorama = {version = "*", markers = "sys_platform == \"win32\""} decorator = "*" +exceptiongroup = {version = "*", markers = "python_version < \"3.11\""} jedi = ">=0.16" matplotlib-inline = "*" pexpect = {version = ">4.3", markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\""} @@ -365,7 +567,7 @@ typing_extensions = {version = ">=4.6", markers = "python_version < \"3.12\""} [package.extras] all = ["ipython[black,doc,kernel,matplotlib,nbconvert,nbformat,notebook,parallel,qtconsole]", "ipython[test,test-extra]"] black = ["black"] -doc = ["docrepr", "exceptiongroup", "intersphinx_registry", "ipykernel", "ipython[test]", "matplotlib", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "sphinxcontrib-jquery", "tomli", "typing_extensions"] +doc = ["docrepr", "exceptiongroup", "intersphinx_registry", "ipykernel", "ipython[test]", "matplotlib", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "sphinxcontrib-jquery", "tomli ; python_version < \"3.11\"", "typing_extensions"] kernel = ["ipykernel"] matplotlib = ["matplotlib"] nbconvert = ["nbconvert"] @@ -660,6 +862,19 @@ files = [ [package.extras] tests = ["pytest"] +[[package]] +name = "pycparser" +version = "2.22" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "platform_python_implementation != \"PyPy\"" +files = [ + {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, + {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, +] + [[package]] name = "pydantic" version = "2.10.6" @@ -679,7 +894,7 @@ typing-extensions = ">=4.12.2" [package.extras] email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] [[package]] name = "pydantic-core" @@ -835,9 +1050,11 @@ files = [ [package.dependencies] colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" pluggy = ">=1.5,<2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] @@ -1022,5 +1239,5 @@ files = [ [metadata] lock-version = "2.1" -python-versions = ">=3.11" -content-hash = "6c8e2abb6d1036ec42bc0a67a634bcbef586b44a6b34946e92f44c2f73c30ac1" +python-versions = ">=3.10" +content-hash = "9dde0f8e1090fb17c60fe4ae8de9749f3a21819dbaa747bba43daa10099db2c6" diff --git a/pyproject.toml b/pyproject.toml index ced0687..3a5fd16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,13 +3,17 @@ name = "open-payments-sdk" version = "0.1.0" description = "Python SDK implementation for open-payments API" authors = [ - {name = "Yiannis Giannelos",email = "johngiannelos@gmail.com"} + {name = "Yiannis Giannelos",email = "johngiannelos@gmail.com"}, + {name = "Elijah Okello", email = "elijahokello90@gmail.com"}, + {name = "Kasweka Michael Mukoko", email = "kaswekamukoko@gmail.com"} ] license = {text = "Apache-2.0"} -requires-python = ">=3.11" +requires-python = ">=3.10" dependencies = [ "httpx (>=0.28.1,<0.29.0)", - "pydantic (>=2.10.6,<3.0.0)" + "pydantic (>=2.10.6,<3.0.0)", + "cryptography (>=45.0.4,<46.0.0)", + "http-message-signatures (>=0.6.1,<0.7.0)" ] [build-system] diff --git a/scripts/fetch_spec.sh b/scripts/fetch_spec.sh old mode 100644 new mode 100755 index e203cd2..842911a --- a/scripts/fetch_spec.sh +++ b/scripts/fetch_spec.sh @@ -7,6 +7,7 @@ fi REPO_URL="https://github.com/interledger/open-payments" REPO_PATH=$(mktemp -d) +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" git clone $REPO_URL "$REPO_PATH" -cp -r "$REPO_PATH"/openapi/* ./spec \ No newline at end of file +cp -r "$REPO_PATH"/openapi/* "$SCRIPT_DIR/../spec" \ No newline at end of file diff --git a/spec/auth-server.yaml b/spec/auth-server.yaml index c275f62..be70301 100644 --- a/spec/auth-server.yaml +++ b/spec/auth-server.yaml @@ -74,10 +74,24 @@ paths: uri: 'https://auth.interledger-test.dev/continue/4CF492MLVMSW9MKMXKHQ' '400': description: Bad Request + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/error-invalid-request' + - $ref: '#/components/schemas/error-invalid-client' '401': description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/error-invalid-client' '500': description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/error-request-denied' requestBody: content: application/json: @@ -135,6 +149,7 @@ paths: identifier: 'http://ilp.interledger-test.dev/bob' client: 'https://webmonize.com/.well-known/pay' description: '' + required: true description: Make a new grant request security: [] tags: @@ -198,10 +213,29 @@ paths: wait: 30 '400': description: Bad Request + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/error-too-fast' + - $ref: '#/components/schemas/error-invalid-client' '401': description: Unauthorized + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/error-invalid-client' + - $ref: '#/components/schemas/error-invalid-continuation' + - $ref: '#/components/schemas/error-request-denied' '404': description: Not Found + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/error-invalid-continuation' + - $ref: '#/components/schemas/error-invalid-request' requestBody: content: application/json: @@ -226,12 +260,21 @@ paths: responses: '204': description: No Content - '400': - description: Bad Request '401': description: Unauthorized + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/error-invalid-client' + - $ref: '#/components/schemas/error-invalid-continuation' + - $ref: '#/components/schemas/error-invalid-request' '404': description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/error-invalid-request' description: Cancel a grant request or delete a grant client side. tags: - grant @@ -279,10 +322,28 @@ paths: assetScale: 2 '400': description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/error-invalid-rotation' '401': description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/error-invalid-client' '404': description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/error-invalid-rotation' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/error-request-denied' description: Management endpoint to rotate access token. tags: - token @@ -293,10 +354,18 @@ paths: responses: '204': description: No Content - '400': - description: Bad Request '401': description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/error-invalid-client' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/error-request-denied' tags: - token components: @@ -512,26 +581,107 @@ components: limits-outgoing: title: limits-outgoing description: Open Payments specific property that defines the limits under which outgoing payments can be created. - type: object - properties: - receiver: - $ref: ./schemas.yaml#/components/schemas/receiver - debitAmount: - description: 'All amounts are maxima, i.e. multiple payments can be created under a grant as long as the total amounts of these payments do not exceed the maximum amount per interval as specified in the grant.' - $ref: ./schemas.yaml#/components/schemas/amount - receiveAmount: - description: 'All amounts are maxima, i.e. multiple payments can be created under a grant as long as the total amounts of these payments do not exceed the maximum amount per interval as specified in the grant.' - $ref: ./schemas.yaml#/components/schemas/amount - interval: - $ref: '#/components/schemas/interval' anyOf: - - not: - required: - - interval - - required: + - type: object + properties: + receiver: + $ref: ./schemas.yaml#/components/schemas/receiver + interval: + $ref: '#/components/schemas/interval' + - type: object + properties: + receiver: + $ref: ./schemas.yaml#/components/schemas/receiver + interval: + $ref: '#/components/schemas/interval' + debitAmount: + description: 'All amounts are maxima, i.e. multiple payments can be created under a grant as long as the total amounts of these payments do not exceed the maximum amount per interval as specified in the grant.' + $ref: ./schemas.yaml#/components/schemas/amount + required: - debitAmount - - required: + - type: object + properties: + receiver: + $ref: ./schemas.yaml#/components/schemas/receiver + interval: + $ref: '#/components/schemas/interval' + receiveAmount: + description: 'All amounts are maxima, i.e. multiple payments can be created under a grant as long as the total amounts of these payments do not exceed the maximum amount per interval as specified in the grant.' + $ref: ./schemas.yaml#/components/schemas/amount + required: - receiveAmount + error-invalid-client: + type: object + properties: + error: + type: object + properties: + description: + type: string + code: + type: string + enum: + - invalid_client + error-invalid-request: + type: object + properties: + error: + type: object + properties: + description: + type: string + code: + type: string + enum: + - invalid_request + error-request-denied: + type: object + properties: + error: + type: object + properties: + description: + type: string + code: + type: string + enum: + - request_denied + error-too-fast: + type: object + properties: + error: + type: object + properties: + description: + type: string + code: + type: string + enum: + - too_fast + error-invalid-continuation: + type: object + properties: + error: + type: object + properties: + description: + type: string + code: + type: string + enum: + - invalid_continuation + error-invalid-rotation: + type: object + properties: + error: + type: object + properties: + description: + type: string + code: + type: string + enum: + - invalid_rotation securitySchemes: GNAP: name: Authorization diff --git a/spec/resource-server.yaml b/spec/resource-server.yaml index fb2cb99..5f11d15 100644 --- a/spec/resource-server.yaml +++ b/spec/resource-server.yaml @@ -16,11 +16,11 @@ info: A quote is a commitment from the Account Servicing Entity to deliver a particular amount to a receiver when sending a particular amount from the wallet address. It is only valid for a limited time. - All resource and collection resource representations use JSON and the media-type `application/json`. + All resource and collection resource representations use JSON and the media-type `application/json`. The `wallet address` resource has three collections of sub-resources: - 1. `/incoming-payments` contains the **incoming payment** sub-resources - 2. `/outgoing-payments` contains the **outgoing payment** sub-resources + 1. `/incoming-payments` contains the **incoming payment** sub-resources + 2. `/outgoing-payments` contains the **outgoing payment** sub-resources 3. `/quotes` contains the **quote** sub-resources Access to resources and permission to execute the methods exposed by the API is determined by the grants given to the client represented by an access token used in API requests. @@ -73,7 +73,6 @@ paths: metadata: externalRef: INV2022-02-0137 createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' methods: - type: ilp ilpAddress: g.ilp.iwuyge987y.98y08y @@ -101,6 +100,7 @@ paths: writeOnly: true metadata: type: object + additionalProperties: true description: Additional metadata associated with the incoming payment. (Optional) required: - walletAddress @@ -176,7 +176,6 @@ paths: externalRef: Coffee w/ Mo on 10 March 22 expiresAt: '2022-04-12T23:20:50.52Z' createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' completed: true - id: 'https://ilp.interledger-test.dev/incoming-payments/32abc219-3dc3-44ec-a225-790cacfca8fa' walletAddress: 'https://ilp.interledger-test.dev/alice/' @@ -186,7 +185,6 @@ paths: assetScale: 2 expiresAt: '2022-04-12T23:20:50.52Z' createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: 'I love your website, Alice! Thanks for the great content' completed: false @@ -207,7 +205,6 @@ paths: completed: true expiresAt: '2022-04-12T23:20:50.52Z' createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: 'I love your website, Alice! Thanks for the great content' - id: 'https://ilp.interledger-test.dev/incoming-payments/016da9d5-c9a4-4c80-a354-86b915a04ff8' @@ -223,7 +220,6 @@ paths: completed: true expiresAt: '2022-04-12T23:20:50.52Z' createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: 'Hi Mo, this is for the cappuccino I bought for you the other day.' externalRef: Coffee w/ Mo on 10 March 22 @@ -284,7 +280,6 @@ paths: metadata: description: Thank you for the shoes. createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' '401': $ref: '#/components/responses/401' '403': @@ -405,7 +400,6 @@ paths: assetCode: USD assetScale: 2 createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: APlusVideo subscription externalRef: 'customer: 847458475' @@ -426,7 +420,6 @@ paths: assetCode: USD assetScale: 2 createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: Thank you for your purchase at ShoeShop! externalRef: INV2022-8943756 @@ -455,7 +448,6 @@ paths: assetCode: USD assetScale: 2 createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: Thank you for your purchase at ShoeShop! externalRef: INV2022-8943756 @@ -476,7 +468,6 @@ paths: assetCode: USD assetScale: 2 createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: APlusVideo subscription externalRef: 'customer: 847458475' @@ -526,6 +517,7 @@ paths: expiresAt: '2022-04-12T23:20:50.52Z' '400': description: No amount was provided and no amount could be inferred from the receiver. + $ref: '#/components/responses/400' '401': $ref: '#/components/responses/401' '403': @@ -647,7 +639,6 @@ paths: completed: false expiresAt: '2022-04-12T23:20:50.52Z' createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: Thanks for the flowers! externalRef: INV-12876 @@ -660,6 +651,7 @@ paths: '403': $ref: '#/components/responses/403' '404': + $ref: '#/components/responses/404' description: Incoming Payment Not Found parameters: - $ref: '#/components/parameters/optional-signature-input' @@ -697,7 +689,6 @@ paths: completed: true expiresAt: '2022-04-12T23:20:50.52Z' createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: 'Hi Mo, this is for the cappuccino I bought for you the other day.' externalRef: Coffee w/ Mo on 10 March 2 @@ -706,6 +697,7 @@ paths: '403': $ref: '#/components/responses/403' '404': + $ref: '#/components/responses/404' description: Incoming Payment Not Found description: |- A client with the appropriate permissions MAY mark a non-expired **incoming payment** as `completed` indicating that the client is not going to make any further payments toward this **incoming payment**, even though the full `incomingAmount` may not have been received. @@ -749,7 +741,6 @@ paths: assetCode: USD assetScale: 2 createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: Thanks for the flowers! externalRef: INV-12876 @@ -758,6 +749,7 @@ paths: '403': $ref: '#/components/responses/403' '404': + $ref: '#/components/responses/404' description: Outgoing Payment Not Found description: A client can fetch the latest state of an outgoing payment. parameters: @@ -800,6 +792,7 @@ paths: '403': $ref: '#/components/responses/403' '404': + $ref: '#/components/responses/404' description: Quote Not Found description: A client can fetch the latest state of a quote. parameters: @@ -827,7 +820,6 @@ components: completed: true expiresAt: '2022-04-12T23:20:50.52Z' createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: 'Hi Mo, this is for the cappuccino I bought for you the other day.' externalRef: Coffee w/ Mo on 10 March 22 @@ -843,7 +835,6 @@ components: assetScale: 2 expiresAt: '2022-04-12T23:20:50.52Z' createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-03-12T23:20:50.52Z' properties: id: type: string @@ -871,22 +862,18 @@ components: format: date-time metadata: type: object + additionalProperties: true description: Additional metadata associated with the incoming payment. (Optional) createdAt: type: string format: date-time description: The date and time when the incoming payment was created. - updatedAt: - type: string - format: date-time - description: The date and time when the incoming payment was updated. required: - id - walletAddress - completed - receivedAmount - createdAt - - updatedAt incoming-payment-with-methods: title: Incoming Payment with payment methods description: An **incoming payment** resource with public details. @@ -946,7 +933,6 @@ components: assetCode: USD assetScale: 2 createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: APlusVideo subscription externalRef: 'customer: 847458475' @@ -963,7 +949,6 @@ components: assetCode: USD assetScale: 2 createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: Thank you for your purchase at ShoeShop! externalRef: INV2022-8943756 @@ -1002,15 +987,12 @@ components: description: The total amount that has been sent under this outgoing payment. metadata: type: object + additionalProperties: true description: Additional metadata associated with the outgoing payment. (Optional) createdAt: type: string format: date-time description: The date and time when the outgoing payment was created. - updatedAt: - type: string - format: date-time - description: The date and time when the outgoing payment was updated. required: - id - walletAddress @@ -1019,7 +1001,6 @@ components: - debitAmount - sentAmount - createdAt - - updatedAt outgoing-payment-with-spent-amounts: title: Outgoing Payment With Grant Spent Amounts description: 'An **outgoing payment** resource represents a payment that will be, is currently being, or has previously been, sent from the wallet address.' @@ -1046,7 +1027,6 @@ components: assetCode: USD assetScale: 2 createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: APlusVideo subscription externalRef: 'customer: 847458475' @@ -1071,7 +1051,6 @@ components: assetCode: USD assetScale: 2 createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: APlusVideo subscription externalRef: 'customer: 847458475' @@ -1115,15 +1094,12 @@ components: description: The total amount successfully received (by all receivers) using the current outgoing payment grant. metadata: type: object + additionalProperties: true description: Additional metadata associated with the outgoing payment. (Optional) createdAt: type: string format: date-time description: The date and time when the outgoing payment was created. - updatedAt: - type: string - format: date-time - description: The date and time when the outgoing payment was updated. required: - id - walletAddress @@ -1132,7 +1108,6 @@ components: - debitAmount - sentAmount - createdAt - - updatedAt quote: title: Quote description: A **quote** resource represents the quoted amount details with which an Outgoing Payment may be created. @@ -1265,6 +1240,38 @@ components: - type: string ilpAddress: string sharedSecret: string + error-response: + type: object + properties: + error: + type: object + properties: + code: + type: string + description: + type: string + details: + type: object + additionalProperties: true + description: Additional details about the error. + required: + - description + - code + required: + - error + examples: + error-response-minimal: + value: + error: + code: 'invalid_request' + description: Error description + error-response-full: + value: + error: + code: 'invalid_request' + description: Error description + details: + anyKey: anyValue securitySchemes: GNAP: name: Authorization @@ -1275,6 +1282,17 @@ components: All requests must also be signed using a client key over some select headers and a digest of the request body. responses: + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/error-response' + examples: + Detailed error response: + $ref: '#/components/examples/error-response-full' + Standard error response: + $ref: '#/components/examples/error-response-minimal' '401': description: Authorization required headers: @@ -1282,8 +1300,37 @@ components: schema: type: string description: The address of the authorization server for grant requests in the format `GNAP as_uri=` + content: + application/json: + schema: + $ref: '#/components/schemas/error-response' + examples: + Detailed error response: + $ref: '#/components/examples/error-response-full' + Standard error response: + $ref: '#/components/examples/error-response-minimal' '403': description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/error-response' + examples: + Detailed error response: + $ref: '#/components/examples/error-response-full' + Standard error response: + $ref: '#/components/examples/error-response-minimal' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/error-response' + examples: + Detailed error response: + $ref: '#/components/examples/error-response-full' + Standard error response: + $ref: '#/components/examples/error-response-minimal' parameters: cursor: schema: diff --git a/spec/wallet-address-server.yaml b/spec/wallet-address-server.yaml index f849600..e55e156 100644 --- a/spec/wallet-address-server.yaml +++ b/spec/wallet-address-server.yaml @@ -39,6 +39,13 @@ paths: assetScale: 2 authServer: 'https://ilp.interledger-test.dev/auth' resourceServer: 'https://ilp.interledger-test.dev/op' + '302': + description: If the `Accept` header is `text/html` in the request, the server may choose to redirect to an HTML page for the given wallet address. + headers: + Location: + description: 'The URL of the wallet address webpage.' + schema: + type: string '404': description: Wallet Address Not Found operationId: get-wallet-address @@ -197,5 +204,6 @@ components: x: oy0L_vTygNE4IogRyn_F5GmHXdqYVjIXkWs2jky7zsI did-document: type: object + additionalProperties: true title: DID Document description: A DID Document using JSON encoding diff --git a/src/open_payments_sdk/api/auth.py b/src/open_payments_sdk/api/auth.py index 11d429b..e1c315a 100644 --- a/src/open_payments_sdk/api/auth.py +++ b/src/open_payments_sdk/api/auth.py @@ -1,56 +1,147 @@ -import json +""" +Grants Module +""" +from logging import Logger -from open_payments_sdk.configuration import Configuration -from open_payments_sdk.http import Client as HttpClient -from open_payments_sdk.models.auth import AccessToken -from open_payments_sdk.models.auth import Grant as AuthGrant +from open_payments_sdk.gnap_utils.security import SecurityBase +from open_payments_sdk.http import HttpClient +from open_payments_sdk.models.auth import AccessToken, Grant from open_payments_sdk.models.auth import (GrantContinueResponse, GrantRequest, InteractRef) +from open_payments_sdk.utils.utils import get_default_covered_components, get_default_headers -class Grants: - def __init__(self, auth_server_endpoint: str, http_client: HttpClient = None): - if not http_client: - cfg = Configuration() - http_client = HttpClient(cfg) +class Grants(SecurityBase): + """ + Class to handle Grants in the sdk + """ + def __init__(self, keyid: str, private_key: str ,logger: Logger,http_client: HttpClient): + super().__init__(keyid=keyid, private_key=private_key,logger=logger) + self.logger = logger self.http_client = http_client - self.auth_server_endpoint = auth_server_endpoint - def post_grant_request(self, grant_request: GrantRequest) -> AuthGrant: + def post_grant_request( + self, + grant_request: GrantRequest, + auth_server_endpoint: str, + ) -> Grant: + """ + Grant Request + """ data = grant_request.model_dump(exclude_unset=True, mode="json") - response = self.http_client.post(self.auth_server_endpoint, json=data) - return AuthGrant.model_validate(json.loads(response)) - def post_grant_continuation_request(self, req_id: str, interact_ref: InteractRef): - base_url = self.auth_server_endpoint.rstrip("/") - url = f"{base_url}/continue/{req_id}" + req_headers = { + **get_default_headers() + } + request = self.http_client.build_request( + method="POST", + url=auth_server_endpoint, + json=data, + headers=req_headers + ) + request = self.set_content_digest(request=request) + request = self.sign_request(request,("content-type","content-digest","content-length",*get_default_covered_components())) + response = self.http_client.send(request=request) + return Grant.model_validate(response.json()) + + def post_grant_continuation_request( + self, + interact_ref: InteractRef, + continue_uri: str, + access_token: str + ) -> GrantContinueResponse: + """ + Continue Grant Request + """ data = interact_ref.model_dump(exclude_unset=True, mode="json") - response = self.http_client.post(url, json=data) - return GrantContinueResponse.model_validate(json.loads(response)) + req_headers = { + **get_default_headers(), + **self.get_auth_header(access_token=access_token) + } + request = self.http_client.build_request( + method="POST", + url=continue_uri, + json=data, + headers=req_headers + ) + request = self.set_content_digest(request=request) + request = self.sign_request(request,("content-type","content-digest","content-length","authorization",*get_default_covered_components())) + response = self.http_client.send(request=request) + return GrantContinueResponse.model_validate(response.json()) - def delete_grant(self, req_id: str) -> None: - base_url = self.auth_server_endpoint.rstrip("/") + def delete_grant( + self, + req_id: str, + auth_server_endpoint: str, + access_token: str + ) -> None: + """ + Delete Grant + """ + base_url = auth_server_endpoint.rstrip("/") url = f"{base_url}/continue/{req_id}" - self.http_client.delete(url) - - -class AccessTokens: - def __init__(self, auth_server_endpoint: str, http_client: HttpClient = None): - if not http_client: - cfg = Configuration() - http_client = HttpClient(cfg) + req_headers = { + **self.get_auth_header(access_token=access_token) + } + request = self.http_client.build_request( + method="DELETE", + url=url, + headers=req_headers + ) + request = self.sign_request(request,("authorization",*get_default_covered_components())) + self.http_client.send(request=request) +class AccessTokens(SecurityBase): + """ + Access Token Class + """ + def __init__(self, keyid: str , private_key: str,logger: Logger, http_client: HttpClient ): + super().__init__(keyid=keyid, private_key=private_key, logger=logger) self.http_client = http_client - self.auth_server_endpoint = auth_server_endpoint - def post_rotate_access_token(self, token_id: str) -> AccessToken: - base_url = self.auth_server_endpoint.rstrip("/") + def post_rotate_access_token( + self, + token_id: str, + auth_server_endpoint: str, + access_token: str + ) -> AccessToken: + """ + Rotate Access Token + """ + base_url = auth_server_endpoint.rstrip("/") url = f"{base_url}/token/{token_id}" - response = self.http_client.post(url) - return AccessToken.model_validate(json.loads(response)) + req_headers = { + **self.get_auth_header(access_token=access_token) + } + request = self.http_client.build_request( + method="POST", + url=url, + headers=req_headers + ) + request = self.sign_request(request,("authorization",*get_default_covered_components())) + response = self.http_client.send(request=request) + return AccessToken.model_validate(response.json()) - def delete_access_token(self, token_id: str) -> None: - base_url = self.auth_server_endpoint.rstrip("/") + def delete_access_token( + self, + token_id: str, + auth_server_endpoint: str, + access_token: str + ) -> None: + """ + Delete Access Token + """ + base_url = auth_server_endpoint.rstrip("/") url = f"{base_url}/token/{token_id}" - self.http_client.delete(url) + req_headers = { + **self.get_auth_header(access_token=access_token) + } + + request = self.http_client.build_request( + method="DELETE", + url=url, + headers=req_headers + ) + request = self.sign_request(request,("authorization",*get_default_covered_components())) + self.http_client.send(request=request) \ No newline at end of file diff --git a/src/open_payments_sdk/api/resource.py b/src/open_payments_sdk/api/resource.py index 4779517..732b6b8 100644 --- a/src/open_payments_sdk/api/resource.py +++ b/src/open_payments_sdk/api/resource.py @@ -1,5 +1,9 @@ -from open_payments_sdk.configuration import Configuration -from open_payments_sdk.http import Client as HttpClient +""" +Resource Server Module +""" +from logging import Logger +from open_payments_sdk.gnap_utils.security import SecurityBase +from open_payments_sdk.http import HttpClient from open_payments_sdk.models.resource import (IncomingPayment, IncomingPaymentRequest, IncomingPaymentResponse, @@ -9,96 +13,250 @@ PaginatedOutgoingPayments, PaymentListQuery, Quote, QuoteRequest) +from open_payments_sdk.utils.utils import get_default_covered_components, get_default_headers -class IncomingPayments: - def __init__(self, resource_server_endpoint: str, http_client: HttpClient = None): - if not http_client: - cfg = Configuration() - http_client = HttpClient(cfg) - +class IncomingPayments(SecurityBase): + """ + Class for handling incoming payments resources + """ + def __init__(self, keyid: str, private_key: str,logger: Logger, http_client: HttpClient): + super().__init__(keyid=keyid,private_key=private_key,logger=logger) self.http_client = http_client - self.resource_server_endpoint = resource_server_endpoint.rstrip("/") - def post_create_payment(self, payment: IncomingPaymentRequest) -> IncomingPayment: - base_url = self.resource_server_endpoint + def post_create_payment( + self, + payment: IncomingPaymentRequest, + resource_server_endpoint: str, + access_token: str + ) -> IncomingPayment: + """ + Create Incoming Payment + """ + base_url = resource_server_endpoint.rstrip("/") url = f"{base_url}/incoming-payments" data = payment.model_dump(exclude_unset=True, mode="json") - response = self.http_client.post(url, json=data) + req_headers = { + **get_default_headers(), + **self.get_auth_header(access_token=access_token) + } + request = self.http_client.build_request( + method="POST", + url=url, + json=data, + headers=req_headers + ) + request = self.set_content_digest(request=request) + request = self.sign_request(request,("content-type","content-digest","content-length","authorization",*get_default_covered_components())) + response = self.http_client.send(request=request) return IncomingPayment.model_validate(response.json()) def get_incoming_payments( - self, query: PaymentListQuery - ) -> PaginatedIncomingPayments: - base_url = self.resource_server_endpoint + self, query: PaymentListQuery, + resource_server_endpoint: str, + access_token: str + ) -> PaginatedIncomingPayments: + """ + Get Incoming Payment + """ + base_url = resource_server_endpoint.rstrip("/") url = f"{base_url}/incoming-payments" query_params = query.model_dump(exclude_unset=True, mode="json") - response = self.http_client.get(url, params=query_params) + req_headers = { + **self.get_auth_header(access_token=access_token) + } + request = self.http_client.build_request( + method="GET", + url=url, + headers=req_headers, + params=query_params + ) + request = self.sign_request(request,("authorization",*get_default_covered_components())) + response = self.http_client.send(request=request) return PaginatedIncomingPayments.model_validate(response.json()) - def get_incoming_payment(self, payment_id: str) -> IncomingPayment: - base_url = self.resource_server_endpoint + def get_incoming_payment( + self, + payment_id: str, + resource_server_endpoint: str, + access_token: str + ) -> IncomingPayment: + """ + Get Incoming Payment + """ + base_url = resource_server_endpoint.rstrip("/") url = f"{base_url}/incoming-payments/{payment_id}" - response = self.http_client.get(url) + req_headers = { + **self.get_auth_header(access_token=access_token) + } + request = self.http_client.build_request( + method="GET", + url=url, + headers=req_headers + ) + request = self.sign_request(request,("authorization",*get_default_covered_components())) + response = self.http_client.send(request=request) return IncomingPaymentResponse.model_validate(response.json()) - def post_complete_incoming_payment(self, payment_id: str) -> IncomingPayment: - base_url = self.resource_server_endpoint + def post_complete_incoming_payment( + self, + payment_id: str, + resource_server_endpoint: str, + access_token: str + ) -> IncomingPayment: + """ + Complete Incoming Payment + """ + base_url = resource_server_endpoint.rstrip("/") url = f"{base_url}/incoming-payments/{payment_id}/complete" - response = self.http_client.post(url) + req_headers = { + **self.get_auth_header(access_token=access_token) + } + request = self.http_client.build_request( + method="POST", + url=url, + headers=req_headers + ) + request = self.sign_request(request,("authorization",*get_default_covered_components())) + response = self.http_client.send(request=request) return IncomingPayment.model_validate(response.json()) -class OutgoingPayments: - def __init__(self, resource_server_endpoint: str, http_client: HttpClient = None): - if not http_client: - cfg = Configuration() - http_client = HttpClient(cfg) - +class OutgoingPayments(SecurityBase): + """ + Class for handling outgoing payments resources + """ + def __init__(self, keyid: str, private_key: str, logger: Logger, http_client: HttpClient): + super().__init__(keyid=keyid,private_key=private_key,logger=logger) self.http_client = http_client - self.resource_server_endpoint = resource_server_endpoint.rstrip("/") - def post_create_payment(self, payment: OutgoingPaymentRequest) -> OutgoingPayment: - base_url = self.resource_server_endpoint + def post_create_payment( + self, payment: OutgoingPaymentRequest, + resource_server_endpoint: str, + access_token: str + ) -> OutgoingPayment: + """ + Create an Outgoing Payment Resource + """ + base_url = resource_server_endpoint.rstrip("/") url = f"{base_url}/outgoing-payments" data = payment.model_dump(exclude_unset=True, mode="json") - response = self.http_client.post(url, json=data) + req_headers = { + **get_default_headers(), + **self.get_auth_header(access_token=access_token) + } + request = self.http_client.build_request( + method="POST", + url=url, + json=data, + headers=req_headers + ) + request = self.set_content_digest(request=request) + request = self.sign_request(request,("content-type","content-digest","content-length","authorization",*get_default_covered_components())) + response = self.http_client.send(request=request) return OutgoingPayment.model_validate(response.json()) def get_outgoing_payments( - self, query: PaymentListQuery + self, + query: PaymentListQuery, + resource_server_endpoint: str, + access_token: str ) -> PaginatedOutgoingPayments: - base_url = self.resource_server_endpoint + """ + Get Outgoing Payments + """ + base_url = resource_server_endpoint.rstrip("/") url = f"{base_url}/outgoing-payments" query_params = query.model_dump(exclude_unset=True, mode="json") - response = self.http_client.get(url, params=query_params) + req_headers = { + **self.get_auth_header(access_token=access_token) + } + request = self.http_client.build_request( + method="GET", + url=url, + headers=req_headers, + params=query_params + ) + response = request = self.sign_request(request,("authorization",*get_default_covered_components())) + self.http_client.send(request=request) return PaginatedOutgoingPayments.model_validate(response.json()) - def get_outgoing_payment(self, payment_id: str) -> OutgoingPayment: - base_url = self.resource_server_endpoint + def get_outgoing_payment( + self, payment_id: str, + resource_server_endpoint: str, + access_token: str + ) -> OutgoingPayment: + """ + Get Outgoing Payment + """ + base_url = resource_server_endpoint url = f"{base_url}/outgoing-payments/{payment_id}" - response = self.http_client.get(url) + req_headers = { + **self.get_auth_header(access_token=access_token) + } + request = self.http_client.build_request( + method="GET", + url=url, + headers=req_headers + ) + request = self.sign_request(request,("authorization",*get_default_covered_components())) + response = self.http_client.send(request=request) return OutgoingPayment.model_validate(response.json()) -class Quotes: - def __init__(self, resource_server_endpoint: str, http_client: HttpClient = None): - if not http_client: - cfg = Configuration() - http_client = HttpClient(cfg) - +class Quotes(SecurityBase): + """ + Class for handling Quote resources + """ + def __init__(self, keyid: str, private_key: str,logger: Logger ,http_client: HttpClient): + super().__init__(keyid=keyid,private_key=private_key,logger=logger) self.http_client = http_client - self.resource_server_endpoint = resource_server_endpoint.rstrip("/") - def post_create_quote(self, quote: QuoteRequest) -> Quote: - base_url = self.resource_server_endpoint + def post_create_quote( + self, quote: QuoteRequest, + resource_server_endpoint: str, + access_token: str + ) -> Quote: + """ + Create a Quote + """ + base_url = resource_server_endpoint.rstrip("/") url = f"{base_url}/quotes" data = quote.model_dump(exclude_unset=True, mode="json") - response = self.http_client.post(url, json=data) + req_headers = { + **get_default_headers(), + **self.get_auth_header(access_token=access_token) + } + request = self.http_client.build_request( + method="POST", + url=url, + headers=req_headers, + json=data + ) + request = self.set_content_digest(request=request) + request = self.sign_request(request,("content-type","content-digest","content-length","authorization",*get_default_covered_components())) + response = self.http_client.send(request=request) return Quote.model_validate(response.json()) - def get_quote(self, quote_id: str) -> Quote: - base_url = self.resource_server_endpoint + def get_quote( + self, + quote_id: str, + resource_server_endpoint: str, + access_token: str + ) -> Quote: + """ + Get a Quote + """ + base_url = resource_server_endpoint.strip("/") url = f"{base_url}/quotes/{quote_id}" - response = self.http_client.get(url) + req_headers = { + **self.get_auth_header(access_token=access_token) + } + request = self.http_client.build_request( + method="GET", + url=url, + headers=req_headers + ) + request = self.sign_request(request,("authorization",*get_default_covered_components())) + response = self.http_client.send(request=request) return Quote.model_validate(response.json()) diff --git a/src/open_payments_sdk/api/wallet.py b/src/open_payments_sdk/api/wallet.py index a8c2687..6f6577d 100644 --- a/src/open_payments_sdk/api/wallet.py +++ b/src/open_payments_sdk/api/wallet.py @@ -1,29 +1,30 @@ -import json - -from open_payments_sdk.configuration import Configuration -from open_payments_sdk.http import Client as HttpClient +from open_payments_sdk.http import HttpClient from open_payments_sdk.models.wallet import JsonWebKeySet, WalletAddress class Wallet: - def __init__( - self, wallet_address_server_endpoint: str, http_client: HttpClient = None - ): - if not http_client: - cfg = Configuration() - http_client = HttpClient(cfg) - + """ + Class for handling Wallet resource + """ + def __init__(self, http_client: HttpClient): self.http_client = http_client - self.wallet_address_server_endpoint = wallet_address_server_endpoint - def get_wallet_address(self) -> WalletAddress: + def get_wallet_address(self, wallet_address_server_endpoint: str) -> WalletAddress: """Get wallet address from address server""" - response = self.http_client.get(self.wallet_address_server_endpoint) - return WalletAddress.model_validate(json.loads(response)) + request = self.http_client.build_request( + method="GET", + url=wallet_address_server_endpoint + ) + response = self.http_client.send(request=request) + return WalletAddress.model_validate(response.json()) - def get_keys(self) -> JsonWebKeySet: + def get_keys(self, wallet_address_server_endpoint: str) -> JsonWebKeySet: """Get keys from address server""" - base_url = self.wallet_address_server_endpoint.rstrip("/") + base_url = wallet_address_server_endpoint.rstrip("/") url = f"{base_url}/jwks.json" - response = self.http_client.get(url) - return JsonWebKeySet.model_validate(json.loads(response)) + request = self.http_client.build_request( + method="GET", + url=url + ) + response = self.http_client.send(request=request) + return JsonWebKeySet.model_validate(response.json()) diff --git a/src/open_payments_sdk/client/__init__.py b/src/open_payments_sdk/client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/open_payments_sdk/client/client.py b/src/open_payments_sdk/client/client.py new file mode 100644 index 0000000..47ef48e --- /dev/null +++ b/src/open_payments_sdk/client/client.py @@ -0,0 +1,61 @@ +""" +Open Payments API Client Module +""" + +import logging +from open_payments_sdk import configuration +from open_payments_sdk.api.auth import AccessTokens, Grants +from open_payments_sdk.api.resource import IncomingPayments, OutgoingPayments, Quotes +from open_payments_sdk.api.wallet import Wallet +from open_payments_sdk.http import HttpClient + + +class OpenPaymentsClient: + """ + Open Payments API Client + """ + def __init__(self, keyid: str, private_key: str, client_wallet_address: str,cfg: configuration.Configuration = None, http_client: HttpClient = None): + if not cfg: + cfg = configuration.Configuration() + if not http_client : + http_client = HttpClient(http_timeout=10.0) # TODO: get from cfg + self.http_client = http_client + self.logger = logging.getLogger(__name__) + self.logger.addHandler(cfg.get_log_handler()) + self.user_agent = cfg.user_agent + self.client_wallet_address = client_wallet_address + self.keyid = keyid + self.private_key = private_key + self.grants = Grants( + keyid=keyid, + private_key=private_key, + logger=self.logger, + http_client=self.http_client + ) + self.access_tokens = AccessTokens( + keyid=keyid, + private_key=private_key, + logger=self.logger, + http_client=self.http_client, + ) + self.wallet = Wallet(self.http_client) + self.incoming_payments = IncomingPayments( + keyid=keyid, + private_key=private_key, + logger=self.logger, + http_client=self.http_client + ) + self.outgoing_payments = OutgoingPayments( + keyid=keyid, + private_key=private_key, + logger=self.logger, + http_client=self.http_client + ) + self.quotes = Quotes( + keyid=keyid, + private_key=private_key, + logger=self.logger, + http_client=self.http_client + ) + + diff --git a/src/open_payments_sdk/gnap_utils/__init__.py b/src/open_payments_sdk/gnap_utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/open_payments_sdk/gnap_utils/hash.py b/src/open_payments_sdk/gnap_utils/hash.py new file mode 100644 index 0000000..721bdd1 --- /dev/null +++ b/src/open_payments_sdk/gnap_utils/hash.py @@ -0,0 +1,16 @@ +import base64 +import hashlib + + +class HashManager: + """ + Class for verifying a returned hash + """ + def verify_hash(self, client_nonce, interact_nonce, interact_ref, auth_server_url, received_hash)->bool: + """ + Method for verifying a returned hash + """ + data = f"{client_nonce}\n{interact_nonce}\n{interact_ref}\n{auth_server_url}/" + hash_bytes = hashlib.sha256(data.encode("utf-8")).digest() + computed_hash = base64.b64encode(hash_bytes).decode("utf-8") + return computed_hash == received_hash diff --git a/src/open_payments_sdk/gnap_utils/http_signatures.py b/src/open_payments_sdk/gnap_utils/http_signatures.py new file mode 100644 index 0000000..120811f --- /dev/null +++ b/src/open_payments_sdk/gnap_utils/http_signatures.py @@ -0,0 +1,55 @@ +""" +HTTP Signatures Helper functions +""" + +from http_message_signatures import HTTPSignatureKeyResolver +from http_message_signatures.resolvers import HTTPSignatureComponentResolver +from http_message_signatures.structures import CaseInsensitiveDict + +from open_payments_sdk.gnap_utils.keys import KeyManager + +class OPKeyResolver(HTTPSignatureKeyResolver): + """ + Key Resolver Class + """ + def __init__(self, keyid: str, private_key: str): + super().__init__() + self.keys = {keyid: private_key.encode("utf-8")} + + def resolve_public_key(self, key_id: str): + """ + Get Public Key + """ + key_manager = KeyManager() + private_key = key_manager.load_ed25519_private_key_from_pem(self.keys[key_id]) + return private_key.public_key() + + def resolve_private_key(self, key_id: str): + """ + Get Private Key + """ + return self.keys[key_id] + + +class PatchedHTTPSignatureComponentResolver(HTTPSignatureComponentResolver): + """ + Component Resolver to be used by http signing logic. The upstream resolver class has a bug which I fixed via a PR + https://github.com/pyauth/http-message-signatures/pull/18 + + The new package is not yet deployed. In the meantime this class fixes the bug and it works in this package + """ + def __init__(self, message): + """ + Do not call upstream class constructor because it is buggy + """ + self.message = message + self.message_type = "request" + if hasattr(message, "status_code"): + self.message_type = "response" + self.url = str(message.url) + self.headers = CaseInsensitiveDict(message.headers) + + def get_request_response(self, *, key: str): + """ + Required implementation from abstract class. Since it is not used in the lib. Just pass + """ diff --git a/src/open_payments_sdk/gnap_utils/keys.py b/src/open_payments_sdk/gnap_utils/keys.py new file mode 100644 index 0000000..0e18960 --- /dev/null +++ b/src/open_payments_sdk/gnap_utils/keys.py @@ -0,0 +1,62 @@ +""" + Key Management module +""" +import base64 +from typing import Union +import uuid +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives import serialization + +from open_payments_sdk.models.keys import Key, KeyJwks, KeyPair + +class KeyManager: + """ + Key Management class + """ + + def generate_key_pair(self) -> KeyPair: + """ + Generate Key Pair + """ + private_key = Ed25519PrivateKey.generate() + public_key = private_key.public_key() + private_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption() + ) + private_key_pem = private_pem.decode('utf-8') + raw_public_bytes = public_key.public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw + ) + x = base64.urlsafe_b64encode(raw_public_bytes).rstrip(b'=').decode() + kid = str(uuid.uuid4()) + key = Key( + kid=kid, + x=x, + alg="EdDSA", + kty="OKP", + crv="Ed25519" + ) + key_jwks = KeyJwks(keys=[key]) + keypair = KeyPair(jwks=key_jwks, private_key_pem=private_key_pem) + return KeyPair.model_validate(keypair) + + def load_ed25519_private_key_from_pem(self,pem_bytes: Union[str, bytes]) -> Ed25519PrivateKey: + """ + Read private key from str or bytes string + """ + if isinstance(pem_bytes,str): + pem_bytes = pem_bytes.encode("utf-8") + + private_key = serialization.load_pem_private_key( + data=pem_bytes, + password=None + ) + + if not isinstance(private_key, Ed25519PrivateKey): + raise ValueError("Loaded key is not an Ed25519PrivateKey") + + return private_key + diff --git a/src/open_payments_sdk/gnap_utils/security.py b/src/open_payments_sdk/gnap_utils/security.py new file mode 100644 index 0000000..445c062 --- /dev/null +++ b/src/open_payments_sdk/gnap_utils/security.py @@ -0,0 +1,54 @@ +""" +Shared class for making secure requests +""" + +import hashlib +from logging import Logger +from typing import Sequence +from http_message_signatures import HTTPMessageSigner, algorithms +import http_sfv +from httpx import Request +from open_payments_sdk.gnap_utils.hash import HashManager +from open_payments_sdk.gnap_utils.http_signatures import OPKeyResolver, PatchedHTTPSignatureComponentResolver +from open_payments_sdk.gnap_utils.keys import KeyManager + + +class SecurityBase(): + """ + Base class to provide shared functionality for making authenticated requests + """ + def __init__(self, keyid: str, private_key: str, logger: Logger): + self.key_manager = KeyManager() + self.hash_manager = HashManager() + self.http_signatures = HTTPMessageSigner(signature_algorithm=algorithms.ED25519, key_resolver=OPKeyResolver(keyid=keyid,private_key=private_key),component_resolver_class=PatchedHTTPSignatureComponentResolver) + self.keyid = keyid + self.private_key = private_key + self.logger = logger + + def get_auth_header(self, access_token: str) -> dict: + """ + Prepare Authorization GNAP header + """ + return { + "Authorization": f"GNAP {access_token}" + } + + def sign_request(self, message: Request, covered_component_ids: Sequence[str] )-> Request: + """ + Prepare http signature headers + """ + self.http_signatures.sign( + message=message, + key_id=self.keyid, + covered_component_ids=covered_component_ids, + label="sig1" + ) + return message + + def set_content_digest(self, request: Request) -> Request: + """ + Compute Digest + """ + request.headers["Content-Digest"] = str(http_sfv.Dictionary({"sha-512": hashlib.sha512(request.content).digest()})) + return request + \ No newline at end of file diff --git a/src/open_payments_sdk/http.py b/src/open_payments_sdk/http.py index cfea5e9..1312255 100644 --- a/src/open_payments_sdk/http.py +++ b/src/open_payments_sdk/http.py @@ -1,30 +1,43 @@ -import logging +""" +HTTP Client +""" +from httpx import Request, Response, Client -import httpx +class HttpClient: + """ + HTTP Client + """ + http_timeout: float -from open_payments_sdk import configuration + def __init__(self, http_timeout: float): + self.http_timeout = http_timeout + def build_request( + self, + method: str, + url: str, + headers = None, + data = None, + json: dict = None, + params: dict = None + ) -> Request: + """ + Build request + """ + return Request( + method=method, + url=url, + headers=headers, + json=json, + data=data, + params=params + ) -class Client: - def __init__(self, cfg: configuration.Configuration): - self.logger = logging.getLogger(__name__) - self.logger.addHandler(cfg.get_log_handler()) - self.user_agent = cfg.user_agent - - @staticmethod - def get(url, params=None, headers=None): - res = httpx.get(url, params=params, headers=headers) - res.raise_for_status() - return res.text - - @staticmethod - def post(url, json=None, headers=None): - res = httpx.post(url, json=json, headers=headers) - res.raise_for_status() - return res.text - - @staticmethod - def delete(url, params=None, headers=None): - res = httpx.delete(url, params=params, headers=headers) + def send(self, request: Request) -> Response: + """ + Make an http request + """ + with Client(timeout=self.http_timeout) as client: + res = client.send(request=request) res.raise_for_status() - return res.text + return res diff --git a/src/open_payments_sdk/models/auth.py b/src/open_payments_sdk/models/auth.py index 4b5c4f3..e5da46a 100644 --- a/src/open_payments_sdk/models/auth.py +++ b/src/open_payments_sdk/models/auth.py @@ -1,7 +1,7 @@ from enum import Enum -from typing import List, Optional, Union +from typing import Any, List, Optional, Union -from pydantic import AnyUrl, BaseModel, ConfigDict, Field, RootModel, conint +from pydantic import AnyUrl, BaseModel, ConfigDict, Field, RootModel, conint, model_validator, root_validator class TypeIncoming(Enum): @@ -296,13 +296,27 @@ class GrantRequest(BaseModel): client: Client interact: Optional[InteractRequest] = None - -class InteractionInstructionsResponse(BaseModel): +class ReservedKeyMappingModel(BaseModel): + """ + Base class that maps 'continue' to 'cont' in incoming data. + """ + + @model_validator(mode='before') + @classmethod + def replace_continue_key(cls, values: Any) -> Any: + """ + Map continue to cont + """ + if isinstance(values, dict) and "continue" in values: + values["cont"] = values.pop("continue") + return values + +class InteractionInstructionsResponse(ReservedKeyMappingModel): interact: InteractResponse cont: Continue -class GrantResponse(BaseModel): +class GrantResponse(ReservedKeyMappingModel): access_token: AccessToken cont: Continue @@ -315,10 +329,10 @@ class Grant(RootModel[Union[InteractionInstructionsResponse, GrantResponse]]): ) -class InteractRef(RootModel): - root: str = Field( +class InteractRef(BaseModel): + interact_ref: str = Field( ..., - description="The interaction reference generated for this interaction by the AS.", + description="The interaction reference generated for this interaction by the AS." ) diff --git a/src/open_payments_sdk/models/http_signatures.py b/src/open_payments_sdk/models/http_signatures.py new file mode 100644 index 0000000..1d24a7f --- /dev/null +++ b/src/open_payments_sdk/models/http_signatures.py @@ -0,0 +1,14 @@ +from pydantic import BaseModel, ConfigDict + +class SignatureBaseReturn(BaseModel): + signature_params: str + signature_base: str + + model_config = ConfigDict(extra='forbid') + +class SignatureHeaders(BaseModel): + signature_input: str + signature: str + + model_config = ConfigDict(extra="forbid") + \ No newline at end of file diff --git a/src/open_payments_sdk/models/keys.py b/src/open_payments_sdk/models/keys.py new file mode 100644 index 0000000..6c935b5 --- /dev/null +++ b/src/open_payments_sdk/models/keys.py @@ -0,0 +1,22 @@ +from pydantic import BaseModel, ConfigDict +from typing import List + +class Key(BaseModel): + kid: str + x: str + alg: str + kty: str + crv: str + + model_config = ConfigDict(extra="forbid") + + +class KeyJwks(BaseModel): + keys: List[Key] + + model_config = ConfigDict(extra="forbid") + +class KeyPair(BaseModel): + jwks: KeyJwks + private_key_pem: str + diff --git a/src/open_payments_sdk/models/resource.py b/src/open_payments_sdk/models/resource.py index b3ff0a1..278193f 100644 --- a/src/open_payments_sdk/models/resource.py +++ b/src/open_payments_sdk/models/resource.py @@ -277,10 +277,9 @@ class IncomingPaymentRequest(BaseModel): class IncomingPaymentResponse( - RootModel(Union[PublicIncomingPayment, IncomingPaymentWithMethods]) + RootModel[Union[PublicIncomingPayment, IncomingPaymentWithMethods]] ): - root: Union[PublicIncomingPayment, IncomingPaymentWithMethods] - + pass class PaymentListQuery(BaseModel): walletAddress: WalletAddress @@ -315,11 +314,9 @@ class OutgoingPaymentRequestWithIncoming(BaseModel): class OutgoingPaymentRequest( - RootModel( - Union[OutgoingPaymentRequestWithIncoming, OutgoingPaymentRequestWithQuote] - ) + RootModel[Union[OutgoingPaymentRequestWithIncoming, OutgoingPaymentRequestWithQuote]] ): - root: Union[OutgoingPaymentRequestWithIncoming, OutgoingPaymentRequestWithQuote] + pass class PaginatedOutgoingPayments(BaseModel): @@ -346,7 +343,8 @@ class QuoteFixedSent(QuoteRequestBase): debitAmount: Amount + class QuoteRequest( - RootModel(Union[QuoteRequestBase, QuoteFixedSent, QuoteFixedReceive]) + RootModel[Union[QuoteRequestBase, QuoteFixedSent, QuoteFixedReceive]] ): - root: Union[QuoteRequestBase, QuoteFixedSent, QuoteFixedReceive] + pass diff --git a/src/open_payments_sdk/utils/utils.py b/src/open_payments_sdk/utils/utils.py new file mode 100644 index 0000000..9268f96 --- /dev/null +++ b/src/open_payments_sdk/utils/utils.py @@ -0,0 +1,18 @@ +""" +Common Utilities +""" + + +def get_default_headers() -> dict: + """ + Get default headers + """ + return { + "Content-Type": "application/json" + } + +def get_default_covered_components() -> tuple: + """ + Return default covered components + """ + return ("@method","@target-uri") \ No newline at end of file diff --git a/test/integration/test_grants.py b/test/integration/test_grants.py deleted file mode 100644 index 9f076b8..0000000 --- a/test/integration/test_grants.py +++ /dev/null @@ -1,32 +0,0 @@ -import pytest -import yaml - -from open_payments_sdk.api.auth import Grants -from open_payments_sdk.models.auth import GrantRequest - - -@pytest.fixture -def auth_server(request): - root = request.config.rootpath - path = root / "spec" / "auth-server.yaml" - with open(path) as f: - spec = yaml.safe_load(f) - return spec["servers"][0]["url"] - - -@pytest.fixture -def example_grant_requests(request): - root = request.config.rootpath - spec_path = root / "spec" / "auth-server.yaml" - with open(spec_path) as f: - spec = yaml.safe_load(f) - path = spec["paths"]["/"]["post"] - examples = path["requestBody"]["content"]["application/json"]["examples"] - return examples.values() - - -def test_post_grant_request(auth_server, example_grant_requests): - grant = Grants(auth_server) - for example in example_grant_requests: - grant_request = GrantRequest.model_validate(example["value"]) - grant.post_grant_request(grant_request) diff --git a/test/integration/test_wallet.py b/test/integration/test_wallet.py deleted file mode 100644 index e78f0b9..0000000 --- a/test/integration/test_wallet.py +++ /dev/null @@ -1,23 +0,0 @@ -import pytest -import yaml - -from open_payments_sdk.api.wallet import Wallet - - -@pytest.fixture -def wallet_address_server(request): - root = request.config.rootpath - path = root / "spec" / "wallet-address-server.yaml" - with open(path) as f: - spec = yaml.safe_load(f) - return spec["servers"][0]["url"] - - -def test_get_wallet_address(wallet_address_server): - wallet = Wallet(wallet_address_server) - wallet.get_wallet_address() - - -def test_get_wallet_address_keys(wallet_address_server): - wallet = Wallet(wallet_address_server) - wallet.get_keys() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..49fd4b3 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,83 @@ +import pytest +from open_payments_sdk.client.client import OpenPaymentsClient +from open_payments_sdk.models.auth import GrantRequest + +@pytest.fixture +def keyid_private_key() -> dict: + """ + Get Private Key and Key Id + """ + with open("tests/integration/privkey.pem.example","r",encoding="utf_8") as privkey: + private_key = privkey.read() + return { + "private_key": private_key, + "keyid": "a96a5611-c5fa-49c0-8cb4-184763eca0b8" + } + +@pytest.fixture +def op_client(keyid_private_key) -> OpenPaymentsClient: + """ + Test OP client creation + """ + client = OpenPaymentsClient( + keyid=keyid_private_key["keyid"], + private_key=keyid_private_key["private_key"], + client_wallet_address="https://ilp.interledger-test.dev/elijahokellosalary" + ) + return client + +@pytest.fixture +def wallet_address_server()-> str: + """ + Get Wallet Address + """ + return "https://ilp.interledger-test.dev/elijahokellosalary" + +@pytest.fixture +def grant() -> str: + """ + get access token + """ + wallet = op_client.wallet.get_wallet_address("https://ilp.interledger-test.dev/5c327379") + return op_client.grants.post_grant_request(grant_request=grant_req_dto,auth_server_endpoint=str(wallet.authServer)) + +@pytest.fixture +def grant_req_dto() -> GrantRequest: + """ + Grant Request DTO + """ + grant_req = { + "access_token": { + "access": [ + { + "type": "incoming-payment", + "actions": [ + "create", + "read" + ], + "identifier": "https://ilp.interledger-test.dev/5c327379" + } + ] + }, + "client": "https://ilp.interledger-test.dev/elijahokellosalary", + "interact": { + "start": [ + "redirect" + ], + "finish": { + "method": "redirect", + "uri": "https://webmonize.com/return/876FGRD8VC", + "nonce": "4edb2194-dbdf-46bb-9397-d5fd57b7c8a7" + } + } + } + return GrantRequest(**grant_req) + + +@pytest.fixture +def interactive_grant_req_dto() -> GrantRequest: #TODO complete writing tests + """ + Create Interactive Grant + """ + grant_req = {} + return GrantRequest(**grant_req) \ No newline at end of file diff --git a/tests/integration/privkey.pem.example b/tests/integration/privkey.pem.example new file mode 100644 index 0000000..081020f --- /dev/null +++ b/tests/integration/privkey.pem.example @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIJcnQm2kGthtlZvFof+0bC9EvyNBEf+QNffr8sols5ah +-----END PRIVATE KEY----- diff --git a/tests/integration/test_grants.py b/tests/integration/test_grants.py new file mode 100644 index 0000000..2978cf4 --- /dev/null +++ b/tests/integration/test_grants.py @@ -0,0 +1,23 @@ +import json +from open_payments_sdk.client.client import OpenPaymentsClient +from open_payments_sdk.models.auth import GrantRequest + + +def test_create_grant_request(op_client: OpenPaymentsClient, grant_req_dto: GrantRequest): + """ + Test create grant request and get back access token + """ + wallet = op_client.wallet.get_wallet_address("https://ilp.interledger-test.dev/5c327379") + grant_response = op_client.grants.post_grant_request(grant_request=grant_req_dto,auth_server_endpoint=str(wallet.authServer)) + assert grant_response.root.access_token.value is not None + assert grant_response.root.access_token.value != "" + + +def test_create_interactive_grant_request( + op_client: OpenPaymentsClient, + grant_req_dto: GrantRequest + ): + """ + Test interactive grant request + """ + \ No newline at end of file diff --git a/tests/integration/test_wallet.py b/tests/integration/test_wallet.py new file mode 100644 index 0000000..0dcdb36 --- /dev/null +++ b/tests/integration/test_wallet.py @@ -0,0 +1,15 @@ +def test_get_wallet_address(op_client,wallet_address_server): + """ + Test get wallet address + """ + wallet = op_client.wallet.get_wallet_address(wallet_address_server) + assert str(wallet.id) == wallet_address_server + assert wallet.assetCode is not None + + +def test_get_wallet_address_keys(op_client,wallet_address_server): + """ + Test get jwks.json + """ + keys = op_client.wallet.get_keys(wallet_address_server) + assert keys.keys[0].kid is not None diff --git a/tests/unit/test_op_client.py b/tests/unit/test_op_client.py new file mode 100644 index 0000000..e999bc6 --- /dev/null +++ b/tests/unit/test_op_client.py @@ -0,0 +1,27 @@ +""" +Unit Tests for OP Client +""" +from open_payments_sdk.api.auth import AccessTokens, Grants +from open_payments_sdk.api.resource import IncomingPayments, OutgoingPayments, Quotes +from open_payments_sdk.client.client import OpenPaymentsClient + +def test_create_op_client(keyid_private_key): + """ + Test OP client creation + """ + keyid = keyid_private_key["keyid"] + private_key = keyid_private_key["private_key"] + client = OpenPaymentsClient( + keyid=keyid_private_key["keyid"], + private_key=keyid_private_key["private_key"], + client_wallet_address="https://ilp.interledger-test.dev/elijahokellosalary" + ) + assert client.keyid == keyid + assert client.private_key == private_key + + assert isinstance(client.grants, Grants) + assert isinstance(client.access_tokens, AccessTokens) + assert isinstance(client.incoming_payments, IncomingPayments) + assert isinstance(client.outgoing_payments, OutgoingPayments) + assert isinstance(client.quotes, Quotes) +