Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions dfns_sdk/__init__.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
"""Dfns Python SDK - Auto-generated from OpenAPI specification."""

from .auth import KeySigner, Signer
from .client import DfnsClient
from .delegated_client import DfnsDelegatedClient
from .types import DfnsClientConfig, DfnsDelegatedClientConfig, DfnsError
from .auth import Signer, KeySigner
from .base_auth_api import (
BaseAuthApi,
SignUserActionChallengeRequest,
UserActionChallengeResponse,
SignUserActionChallengeRequest,
)
from .client import DfnsClient
from .delegated_client import DfnsDelegatedClient
from .types import DfnsClientConfig, DfnsDelegatedClientConfig, DfnsError

__all__ = [
"DfnsClient",
Expand Down
2 changes: 1 addition & 1 deletion dfns_sdk/_internal/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""Internal modules."""

from .http_client import AsyncHttpClient, HttpClient
from .http_client import HttpClient, AsyncHttpClient

__all__ = ["HttpClient", "AsyncHttpClient"]
131 changes: 43 additions & 88 deletions dfns_sdk/_internal/http_client.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
"""HTTP client for making API requests."""

import hashlib
import json
from collections.abc import Mapping
from typing import Any, cast
from typing import Any, Optional, TypeVar, TYPE_CHECKING
from urllib.parse import urlencode

import httpx

from dfns_sdk.types import DfnsClientConfig, DfnsDelegatedClientConfig, DfnsError

if TYPE_CHECKING:
from dfns_sdk.auth import Signer

T = TypeVar("T")



class HttpClient:
"""HTTP client for Dfns API requests."""
Expand All @@ -21,7 +25,7 @@ def __init__(self, config: "DfnsClientConfig | DfnsDelegatedClientConfig"):
timeout=30.0,
)

def _build_headers(self, user_action_token: str | None = None) -> dict[str, str]:
def _build_headers(self, user_action_token: Optional[str] = None) -> dict[str, str]:
"""Build request headers."""
headers = {
"Content-Type": "application/json",
Expand All @@ -37,8 +41,8 @@ def _build_headers(self, user_action_token: str | None = None) -> dict[str, str]
def _build_url(
self,
path: str,
path_params: Mapping[str, Any] | None = None,
query_params: Mapping[str, Any] | None = None,
path_params: Optional[dict[str, Any]] = None,
query_params: Optional[dict[str, Any]] = None,
) -> str:
"""Build the full URL with path and query parameters."""
url = path
Expand Down Expand Up @@ -69,7 +73,7 @@ def _handle_response(self, response: httpx.Response) -> Any:
raise DfnsError(
message=response.text or "Unknown error",
status_code=response.status_code,
) from None
)

if response.status_code == 204 or not response.content:
return None
Expand All @@ -80,7 +84,7 @@ def _get_user_action_token(
self,
method: str,
path: str,
body: Any = None,
body: Optional[Any] = None,
) -> str:
"""
Get a user action token by creating and signing a challenge.
Expand All @@ -96,8 +100,7 @@ def _get_user_action_token(
Raises:
DfnsError: If no signer is configured or signing fails.
"""
signer = getattr(self.config, "signer", None)
if signer is None:
if not self.config.signer:
raise DfnsError(
message="Signer required for this operation. Configure a signer in DfnsClientConfig.",
status_code=None,
Expand All @@ -121,7 +124,7 @@ def _get_user_action_token(
challenge = self._handle_response(challenge_response)

# Step 2: Sign the challenge
assertion = signer.sign(challenge)
assertion = self.config.signer.sign(challenge)

# Step 3: Submit signed challenge to get user action token
signature_body = {
Expand All @@ -137,52 +140,28 @@ def _get_user_action_token(
)
result = self._handle_response(signature_response)

return cast(str, result["userAction"])
return result["userAction"]

def request(
self,
method: str,
path: str,
path_params: Mapping[str, Any] | None = None,
query_params: Mapping[str, Any] | None = None,
body: Any = None,
path_params: Optional[dict[str, Any]] = None,
query_params: Optional[dict[str, Any]] = None,
body: Optional[Any] = None,
requires_signature: bool = False,
file: bytes | None = None,
) -> Any:
"""Make an HTTP request to the API."""
url = self._build_url(path, path_params, query_params)

# Use the path with params substituted for signing
signing_path = path
if path_params:
for key, value in path_params.items():
signing_path = signing_path.replace(f"{{{key}}}", str(value))

# Multipart upload: send the JSON body (plus the file checksum the API
# expects) as the "data" part and the bytes as the "file" part. The signed
# payload is the "data" object so it matches what is transmitted.
if file is not None:
data = dict(body) if body else {}
data["fileChecksum"] = hashlib.sha256(file).hexdigest()
user_action_token = None
if requires_signature:
user_action_token = self._get_user_action_token(method, signing_path, data)
# Let httpx set the multipart Content-Type (with boundary); the default
# JSON content type from _build_headers would otherwise mislabel the body.
headers = self._build_headers(user_action_token)
headers.pop("Content-Type", None)
response = self._client.request(
method=method,
url=url,
headers=headers,
data={"data": json.dumps(data, separators=(",", ":"))},
files={"file": ("upload.bin", file)},
)
return self._handle_response(response)

# Get user action token if required
user_action_token = None
if requires_signature:
# Use the path with params substituted for signing
signing_path = path
if path_params:
for key, value in path_params.items():
signing_path = signing_path.replace(f"{{{key}}}", str(value))
user_action_token = self._get_user_action_token(method, signing_path, body)

headers = self._build_headers(user_action_token)
Expand All @@ -200,9 +179,9 @@ def request_with_user_action(
self,
method: str,
path: str,
path_params: Mapping[str, Any] | None = None,
query_params: Mapping[str, Any] | None = None,
body: Any = None,
path_params: Optional[dict[str, Any]] = None,
query_params: Optional[dict[str, Any]] = None,
body: Optional[Any] = None,
user_action: str = "",
) -> Any:
"""
Expand Down Expand Up @@ -244,6 +223,7 @@ def __exit__(self, *args: Any) -> None:
self.close()



class AsyncHttpClient:
"""Async HTTP client for Dfns API requests."""

Expand All @@ -254,7 +234,7 @@ def __init__(self, config: DfnsClientConfig):
timeout=30.0,
)

def _build_headers(self, user_action_token: str | None = None) -> dict[str, str]:
def _build_headers(self, user_action_token: Optional[str] = None) -> dict[str, str]:
"""Build request headers."""
headers = {
"Content-Type": "application/json",
Expand All @@ -270,8 +250,8 @@ def _build_headers(self, user_action_token: str | None = None) -> dict[str, str]
def _build_url(
self,
path: str,
path_params: Mapping[str, Any] | None = None,
query_params: Mapping[str, Any] | None = None,
path_params: Optional[dict[str, Any]] = None,
query_params: Optional[dict[str, Any]] = None,
) -> str:
"""Build the full URL with path and query parameters."""
url = path
Expand Down Expand Up @@ -302,7 +282,7 @@ def _handle_response(self, response: httpx.Response) -> Any:
raise DfnsError(
message=response.text or "Unknown error",
status_code=response.status_code,
) from None
)

if response.status_code == 204 or not response.content:
return None
Expand All @@ -313,7 +293,7 @@ async def _get_user_action_token(
self,
method: str,
path: str,
body: Any = None,
body: Optional[Any] = None,
) -> str:
"""
Get a user action token by creating and signing a challenge.
Expand All @@ -329,8 +309,7 @@ async def _get_user_action_token(
Raises:
DfnsError: If no signer is configured or signing fails.
"""
signer = getattr(self.config, "signer", None)
if signer is None:
if not self.config.signer:
raise DfnsError(
message="Signer required for this operation. Configure a signer in DfnsClientConfig.",
status_code=None,
Expand All @@ -354,7 +333,7 @@ async def _get_user_action_token(
challenge = self._handle_response(challenge_response)

# Step 2: Sign the challenge
assertion = signer.sign(challenge)
assertion = self.config.signer.sign(challenge)

# Step 3: Submit signed challenge to get user action token
signature_body = {
Expand All @@ -370,52 +349,28 @@ async def _get_user_action_token(
)
result = self._handle_response(signature_response)

return cast(str, result["userAction"])
return result["userAction"]

async def request(
self,
method: str,
path: str,
path_params: Mapping[str, Any] | None = None,
query_params: Mapping[str, Any] | None = None,
body: Any = None,
path_params: Optional[dict[str, Any]] = None,
query_params: Optional[dict[str, Any]] = None,
body: Optional[Any] = None,
requires_signature: bool = False,
file: bytes | None = None,
) -> Any:
"""Make an async HTTP request to the API."""
url = self._build_url(path, path_params, query_params)

# Use the path with params substituted for signing
signing_path = path
if path_params:
for key, value in path_params.items():
signing_path = signing_path.replace(f"{{{key}}}", str(value))

# Multipart upload: send the JSON body (plus the file checksum the API
# expects) as the "data" part and the bytes as the "file" part. The signed
# payload is the "data" object so it matches what is transmitted.
if file is not None:
data = dict(body) if body else {}
data["fileChecksum"] = hashlib.sha256(file).hexdigest()
user_action_token = None
if requires_signature:
user_action_token = await self._get_user_action_token(method, signing_path, data)
# Let httpx set the multipart Content-Type (with boundary); the default
# JSON content type from _build_headers would otherwise mislabel the body.
headers = self._build_headers(user_action_token)
headers.pop("Content-Type", None)
response = await self._client.request(
method=method,
url=url,
headers=headers,
data={"data": json.dumps(data, separators=(",", ":"))},
files={"file": ("upload.bin", file)},
)
return self._handle_response(response)

# Get user action token if required
user_action_token = None
if requires_signature:
# Use the path with params substituted for signing
signing_path = path
if path_params:
for key, value in path_params.items():
signing_path = signing_path.replace(f"{{{key}}}", str(value))
user_action_token = await self._get_user_action_token(method, signing_path, body)

headers = self._build_headers(user_action_token)
Expand Down
10 changes: 5 additions & 5 deletions dfns_sdk/auth.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
"""Authentication utilities for the Dfns SDK."""

import base64
import hashlib
import json
from typing import Any, Protocol, TypedDict
from typing import Any, Optional, Protocol, TypedDict

from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec, ed25519, padding, rsa
from cryptography.hazmat.primitives.asymmetric import ec, ed25519, padding


class UserActionChallenge(TypedDict):
Expand Down Expand Up @@ -127,11 +128,10 @@ def _sign_bytes(self, data: bytes) -> bytes:
return self._private_key.sign(data)
elif isinstance(self._private_key, ec.EllipticCurvePrivateKey):
return self._private_key.sign(data, ec.ECDSA(hashes.SHA256()))
elif isinstance(self._private_key, rsa.RSAPrivateKey):
else:
# RSA
return self._private_key.sign(
data,
padding.PKCS1v15(),
hashes.SHA256(),
)
else:
raise ValueError(f"Unsupported private key type: {type(self._private_key).__name__}")
9 changes: 4 additions & 5 deletions dfns_sdk/base_auth_api.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Base authentication API for delegated client operations."""

from typing import Any, TypedDict, cast
import json
from typing import Any, TypedDict, Optional

from ._internal import HttpClient

Expand Down Expand Up @@ -92,7 +93,7 @@ def create_user_action_challenge(
Returns:
The challenge response containing the challenge to sign.
"""
response = http_client.request(
return http_client.request(
method="POST",
path="/auth/action/init",
body={
Expand All @@ -103,7 +104,6 @@ def create_user_action_challenge(
},
requires_signature=False,
)
return cast(UserActionChallengeResponse, response)

@staticmethod
def sign_user_action_challenge(
Expand All @@ -121,10 +121,9 @@ def sign_user_action_challenge(
Returns:
Dictionary containing the userAction token.
"""
response = http_client.request(
return http_client.request(
method="POST",
path="/auth/action",
body=signed_challenge,
requires_signature=False,
)
return cast(dict[str, Any], response)
6 changes: 2 additions & 4 deletions dfns_sdk/client.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"""Main Dfns client."""

from typing import Any

from .types import DfnsClientConfig
from ._internal import HttpClient
from .generated.agreements import AgreementsClient
from .generated.allocations import AllocationsClient
Expand All @@ -18,7 +17,6 @@
from .generated.swaps import SwapsClient
from .generated.wallets import WalletsClient
from .generated.webhooks import WebhooksClient
from .types import DfnsClientConfig


class DfnsClient:
Expand Down Expand Up @@ -82,5 +80,5 @@ def close(self) -> None:
def __enter__(self) -> "DfnsClient":
return self

def __exit__(self, *args: Any) -> None:
def __exit__(self, *args) -> None:
self.close()
Loading
Loading