From a19c30456d77db4a7998c48f6db628fc2ec7c933 Mon Sep 17 00:00:00 2001 From: Marco Antonio Gil Date: Tue, 24 Mar 2026 11:57:44 +0100 Subject: [PATCH] PTHMINT-101: Add dev-only custom base URL support Allow passing a custom API base_url for local development while preventing custom URLs in non-dev builds. Client now accepts base_url and resolves it via _resolve_base_url (uses MSP_SDK_BUILD_PROFILE, MSP_SDK_ALLOW_CUSTOM_BASE_URL, MSP_SDK_CUSTOM_BASE_URL); _normalize_base_url validates/normalizes the URL. Sdk forwards base_url to Client. Added unit tests covering default test/live URLs, dev-only custom URL acceptance, env-provided URL, and blocking in release/when flag disabled. README updated with usage and guardrails. --- README.md | 28 +++++++ src/multisafepay/client/client.py | 51 +++++++++++- src/multisafepay/sdk.py | 4 + .../unit/client/test_unit_client.py | 81 +++++++++++++++++++ tests/multisafepay/unit/test_unit_sdk.py | 66 +++++++++++++++ 5 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 tests/multisafepay/unit/test_unit_sdk.py diff --git a/README.md b/README.md index f49eae8..9b35aa8 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,34 @@ from multisafepay import Sdk multisafepay_sdk: Sdk = Sdk(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="", + 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. diff --git a/src/multisafepay/client/client.py b/src/multisafepay/client/client.py index 37805b6..d3bb2b5 100644 --- a/src/multisafepay/client/client.py +++ b/src/multisafepay/client/client.py @@ -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 @@ -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" @@ -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. @@ -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, diff --git a/src/multisafepay/sdk.py b/src/multisafepay/sdk.py index 1efb44a..b22dc3b 100644 --- a/src/multisafepay/sdk.py +++ b/src/multisafepay/sdk.py @@ -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. @@ -57,6 +58,8 @@ 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( @@ -64,6 +67,7 @@ def __init__( is_production, transport, locale, + base_url, ) self.recurring_manager = RecurringManager(self.client) diff --git a/tests/multisafepay/unit/client/test_unit_client.py b/tests/multisafepay/unit/client/test_unit_client.py index 1be127a..25ce93c 100644 --- a/tests/multisafepay/unit/client/test_unit_client.py +++ b/tests/multisafepay/unit/client/test_unit_client.py @@ -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/" diff --git a/tests/multisafepay/unit/test_unit_sdk.py b/tests/multisafepay/unit/test_unit_sdk.py new file mode 100644 index 0000000..038338e --- /dev/null +++ b/tests/multisafepay/unit/test_unit_sdk.py @@ -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", + )