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
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,34 @@ 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
```

Then pass `base_url`:

```python
from multisafepay import Sdk

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

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
51 changes: 50 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,56 @@ 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 custom base URL.")

return base_url.rstrip("/") + "/"

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
81 changes: 81 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,84 @@ 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/"
66 changes: 66 additions & 0 deletions tests/multisafepay/unit/test_unit_sdk.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Copyright (c) MultiSafepay, Inc. All rights reserved.

# This file is licensed under the Open Software License (OSL) version 3.0.
# For a copy of the license, see the LICENSE.txt file in the project root.

# See the DISCLAIMER.md file for disclaimer details.

"""Unit tests for SDK-level environment/base URL guardrails."""

import pytest

from multisafepay import Sdk
from multisafepay.client.client import Client


def test_sdk_uses_test_url_by_default(monkeypatch: pytest.MonkeyPatch):
"""Test that SDK 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)

sdk = Sdk(api_key="mock_api_key", is_production=False)

assert sdk.get_client().url == Client.TEST_URL


def test_sdk_uses_live_url_in_production(monkeypatch: pytest.MonkeyPatch):
"""Test that SDK client uses live URL 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)

sdk = Sdk(api_key="mock_api_key", is_production=True)

assert sdk.get_client().url == Client.LIVE_URL


def test_sdk_allows_custom_base_url_in_dev_when_enabled(
monkeypatch: pytest.MonkeyPatch,
):
"""Test that SDK allows custom base URL in dev profile when enabled."""
monkeypatch.setenv("MSP_SDK_BUILD_PROFILE", "dev")
monkeypatch.setenv("MSP_SDK_ALLOW_CUSTOM_BASE_URL", "1")

sdk = Sdk(
api_key="mock_api_key",
is_production=False,
base_url="https://dev-api.multisafepay.test/v1",
)

assert sdk.get_client().url == "https://dev-api.multisafepay.test/v1/"


def test_sdk_blocks_custom_base_url_in_release(
monkeypatch: pytest.MonkeyPatch,
):
"""Test that SDK blocks custom base URL 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"):
Sdk(
api_key="mock_api_key",
is_production=False,
base_url="https://dev-api.multisafepay.test/v1",
)
Loading