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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,47 @@ from multisafepay import Sdk
multisafepay_sdk: Sdk = Sdk(api_key='<api_key>', is_production=True)
```

### Development-only custom base URL override

By default, the SDK only targets:

- `test`: `https://testapi.multisafepay.com/v1/`
- `live`: `https://api.multisafepay.com/v1/`

For local development, a custom base URL can be enabled with strict guardrails:

```bash
export MSP_SDK_BUILD_PROFILE=dev
export MSP_SDK_ALLOW_CUSTOM_BASE_URL=1
```

You can provide the custom base URL either via environment variable or via the SDK argument.

Environment variable option:

```bash
export MSP_SDK_CUSTOM_BASE_URL="https://dev-api.example.com/v1"
```

SDK argument option:

```python
from multisafepay import Sdk

sdk = Sdk(
api_key="<api_key>",
is_production=False,
base_url="https://dev-api.example.com/v1",
)
```

Precedence when both are set:

- The explicit SDK argument base_url takes priority.
- If base_url is not passed, MSP_SDK_CUSTOM_BASE_URL is used.

In any non-dev profile (including default `release`), custom base URLs are blocked and the SDK will only use `test/live` URLs.

## Examples

Go to the folder `examples` to see how to use the SDK.
Expand Down
57 changes: 56 additions & 1 deletion src/multisafepay/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@

"""HTTP client module for making API requests to MultiSafepay services."""

import os
from typing import Any, Optional
from urllib.parse import urlparse

from multisafepay.api.base.response.api_response import ApiResponse
from multisafepay.transport import HTTPTransport, RequestsTransport
Expand All @@ -33,6 +35,9 @@ class Client:

LIVE_URL = "https://api.multisafepay.com/v1/"
TEST_URL = "https://testapi.multisafepay.com/v1/"
BUILD_PROFILE_ENV = "MSP_SDK_BUILD_PROFILE"
CUSTOM_BASE_URL_ENV = "MSP_SDK_CUSTOM_BASE_URL"
ALLOW_CUSTOM_BASE_URL_ENV = "MSP_SDK_ALLOW_CUSTOM_BASE_URL"

METHOD_POST = "POST"
METHOD_GET = "GET"
Expand All @@ -45,6 +50,7 @@ def __init__(
is_production: bool,
transport: Optional[HTTPTransport] = None,
locale: str = "en_US",
base_url: Optional[str] = None,
) -> None:
"""
Initialize the Client.
Expand All @@ -56,13 +62,62 @@ def __init__(
transport (Optional[HTTPTransport], optional): Custom HTTP transport implementation.
Defaults to RequestsTransport if not provided.
locale (str, optional): Locale for the requests. Defaults to "en_US".
base_url (Optional[str], optional): Custom API base URL.
Only allowed when running with `MSP_SDK_BUILD_PROFILE=dev`
and `MSP_SDK_ALLOW_CUSTOM_BASE_URL=1`.

"""
self.api_key = ApiKey(api_key=api_key)
self.url = self.LIVE_URL if is_production else self.TEST_URL
self.url = self._resolve_base_url(
is_production=is_production,
explicit_base_url=base_url,
)
self.transport = transport or RequestsTransport()
self.locale = locale

def _resolve_base_url(
self: "Client",
is_production: bool,
explicit_base_url: Optional[str],
) -> str:
profile = os.getenv(self.BUILD_PROFILE_ENV, "release").strip().lower()
if profile != "dev":
profile = "release"

env_base_url = os.getenv(self.CUSTOM_BASE_URL_ENV, "").strip()
requested_base_url = (explicit_base_url or env_base_url or "").strip()

if not requested_base_url:
return self.LIVE_URL if is_production else self.TEST_URL

allow_custom = os.getenv(
self.ALLOW_CUSTOM_BASE_URL_ENV,
"0",
).strip().lower() in {"1", "true", "yes"}

if profile != "dev" or not allow_custom:
msg = (
"Custom base URL is only allowed in dev profile with "
"MSP_SDK_ALLOW_CUSTOM_BASE_URL enabled."
)
raise ValueError(msg)

return self._normalize_base_url(requested_base_url)

@staticmethod
def _normalize_base_url(base_url: str) -> str:
parsed = urlparse(base_url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError("Invalid base URL.")

if parsed.params or parsed.query or parsed.fragment:
raise ValueError("Invalid base URL.")

path = parsed.path.rstrip("/")
path = "/" if not path else path + "/"

return f"{parsed.scheme}://{parsed.netloc}{path}"

def create_get_request(
self: "Client",
endpoint: str,
Expand Down
4 changes: 4 additions & 0 deletions src/multisafepay/sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ def __init__(
is_production: bool,
transport: Optional[HTTPTransport] = None,
locale: str = "en_US",
base_url: Optional[str] = None,
) -> None:
"""
Initialize the SDK with the provided configuration.
Expand All @@ -57,13 +58,16 @@ def __init__(
If not provided, defaults to RequestsTransport, by default None.
locale : str, optional
The locale to use for requests, by default "en_US".
base_url : Optional[str], optional
Custom API base URL (dev-only guardrails apply), by default None.

"""
self.client = Client(
api_key.strip(),
is_production,
transport,
locale,
base_url,
)
self.recurring_manager = RecurringManager(self.client)

Expand Down
176 changes: 176 additions & 0 deletions tests/multisafepay/unit/client/test_unit_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,179 @@ def test_initializes_with_custom_requests_session_via_transport():
assert client.transport is transport
assert client.transport.session is session
session.close()


def test_defaults_to_test_url(monkeypatch: pytest.MonkeyPatch):
"""Test that client defaults to test URL when not in production."""
monkeypatch.delenv("MSP_SDK_BUILD_PROFILE", raising=False)
monkeypatch.delenv("MSP_SDK_CUSTOM_BASE_URL", raising=False)
monkeypatch.delenv("MSP_SDK_ALLOW_CUSTOM_BASE_URL", raising=False)

client = Client(api_key="mock_api_key", is_production=False)
assert client.url == Client.TEST_URL


def test_defaults_to_live_url(monkeypatch: pytest.MonkeyPatch):
"""Test that client defaults to live URL when in production mode."""
monkeypatch.delenv("MSP_SDK_BUILD_PROFILE", raising=False)
monkeypatch.delenv("MSP_SDK_CUSTOM_BASE_URL", raising=False)
monkeypatch.delenv("MSP_SDK_ALLOW_CUSTOM_BASE_URL", raising=False)

client = Client(api_key="mock_api_key", is_production=True)
assert client.url == Client.LIVE_URL


def test_allows_custom_base_url_only_in_dev_profile(
monkeypatch: pytest.MonkeyPatch,
):
"""Test that custom base URL is allowed only in dev profile with flag enabled."""
monkeypatch.setenv("MSP_SDK_BUILD_PROFILE", "dev")
monkeypatch.setenv("MSP_SDK_ALLOW_CUSTOM_BASE_URL", "1")

client = Client(
api_key="mock_api_key",
is_production=False,
base_url="https://dev-api.multisafepay.test/v1",
)
assert client.url == "https://dev-api.multisafepay.test/v1/"


def test_blocks_custom_base_url_in_release_profile(
monkeypatch: pytest.MonkeyPatch,
):
"""Test that custom base URL is blocked in release profile."""
monkeypatch.setenv("MSP_SDK_BUILD_PROFILE", "release")
monkeypatch.setenv("MSP_SDK_ALLOW_CUSTOM_BASE_URL", "1")

with pytest.raises(ValueError, match="Custom base URL"):
Client(
api_key="mock_api_key",
is_production=False,
base_url="https://dev-api.multisafepay.test/v1",
)


def test_blocks_custom_base_url_when_flag_disabled(
monkeypatch: pytest.MonkeyPatch,
):
"""Test that custom base URL is blocked when the enable flag is disabled."""
monkeypatch.setenv("MSP_SDK_BUILD_PROFILE", "dev")
monkeypatch.setenv("MSP_SDK_ALLOW_CUSTOM_BASE_URL", "0")

with pytest.raises(ValueError, match="Custom base URL"):
Client(
api_key="mock_api_key",
is_production=False,
base_url="https://dev-api.multisafepay.test/v1",
)


def test_allows_custom_base_url_from_env_in_dev_profile(
monkeypatch: pytest.MonkeyPatch,
):
"""Test that custom base URL can be provided via environment in dev profile."""
monkeypatch.setenv("MSP_SDK_BUILD_PROFILE", "dev")
monkeypatch.setenv("MSP_SDK_ALLOW_CUSTOM_BASE_URL", "1")
monkeypatch.setenv(
"MSP_SDK_CUSTOM_BASE_URL",
"https://dev-api.multisafepay.test/v1",
)

client = Client(api_key="mock_api_key", is_production=False)

assert client.url == "https://dev-api.multisafepay.test/v1/"


def test_explicit_base_url_takes_precedence_over_env(
monkeypatch: pytest.MonkeyPatch,
):
"""Test that an explicit base URL overrides the environment value."""
monkeypatch.setenv("MSP_SDK_BUILD_PROFILE", "dev")
monkeypatch.setenv("MSP_SDK_ALLOW_CUSTOM_BASE_URL", "1")
monkeypatch.setenv(
"MSP_SDK_CUSTOM_BASE_URL",
"https://env-api.multisafepay.test/v1",
)

client = Client(
api_key="mock_api_key",
is_production=False,
base_url="https://explicit-api.multisafepay.test/v1",
)

assert client.url == "https://explicit-api.multisafepay.test/v1/"


def test_rejects_custom_base_url_with_query(
monkeypatch: pytest.MonkeyPatch,
):
"""Test that custom base URL rejects query strings."""
monkeypatch.setenv("MSP_SDK_BUILD_PROFILE", "dev")
monkeypatch.setenv("MSP_SDK_ALLOW_CUSTOM_BASE_URL", "1")

with pytest.raises(ValueError, match="Invalid base URL"):
Client(
api_key="mock_api_key",
is_production=False,
base_url="https://dev-api.multisafepay.test/v1?foo=bar",
)


def test_rejects_custom_base_url_with_fragment(
monkeypatch: pytest.MonkeyPatch,
):
"""Test that custom base URL rejects fragments."""
monkeypatch.setenv("MSP_SDK_BUILD_PROFILE", "dev")
monkeypatch.setenv("MSP_SDK_ALLOW_CUSTOM_BASE_URL", "1")

with pytest.raises(ValueError, match="Invalid base URL"):
Client(
api_key="mock_api_key",
is_production=False,
base_url="https://dev-api.multisafepay.test/v1#section",
)


def test_rejects_custom_base_url_with_params(
monkeypatch: pytest.MonkeyPatch,
):
"""Test that custom base URL rejects path parameters."""
monkeypatch.setenv("MSP_SDK_BUILD_PROFILE", "dev")
monkeypatch.setenv("MSP_SDK_ALLOW_CUSTOM_BASE_URL", "1")

with pytest.raises(ValueError, match="Invalid base URL"):
Client(
api_key="mock_api_key",
is_production=False,
base_url="https://dev-api.multisafepay.test/v1;foo",
)


def test_rejects_custom_base_url_without_scheme(
monkeypatch: pytest.MonkeyPatch,
):
"""Test that custom base URL rejects missing scheme."""
monkeypatch.setenv("MSP_SDK_BUILD_PROFILE", "dev")
monkeypatch.setenv("MSP_SDK_ALLOW_CUSTOM_BASE_URL", "1")

with pytest.raises(ValueError, match="Invalid base URL"):
Client(
api_key="mock_api_key",
is_production=False,
base_url="dev-api.multisafepay.test/v1",
)


def test_rejects_custom_base_url_without_netloc(
monkeypatch: pytest.MonkeyPatch,
):
"""Test that custom base URL rejects missing netloc."""
monkeypatch.setenv("MSP_SDK_BUILD_PROFILE", "dev")
monkeypatch.setenv("MSP_SDK_ALLOW_CUSTOM_BASE_URL", "1")

with pytest.raises(ValueError, match="Invalid base URL"):
Client(
api_key="mock_api_key",
is_production=False,
base_url="https:///v1",
)
Loading
Loading