diff --git a/src/multisafepay/api/base/abstract_manager.py b/src/multisafepay/api/base/abstract_manager.py index 52981d9..a5241b5 100644 --- a/src/multisafepay/api/base/abstract_manager.py +++ b/src/multisafepay/api/base/abstract_manager.py @@ -5,6 +5,7 @@ # See the DISCLAIMER.md file for disclaimer details. +import urllib.parse from multisafepay.client.client import Client @@ -29,3 +30,19 @@ def __init__(self: "AbstractManager", client: Client) -> None: """ self.client = client + + @staticmethod + def encode_path_segment(segment: str) -> str: + """ + URL encode a path segment to be safely included in a URL. + + Parameters + ---------- + segment (str): The path segment to encode + + Returns + ------- + str: The URL encoded path segment + + """ + return urllib.parse.quote(str(segment), safe="") diff --git a/src/multisafepay/api/paths/capture/capture_manager.py b/src/multisafepay/api/paths/capture/capture_manager.py index 1837f27..833bf61 100644 --- a/src/multisafepay/api/paths/capture/capture_manager.py +++ b/src/multisafepay/api/paths/capture/capture_manager.py @@ -59,8 +59,9 @@ def capture_reservation_cancel( """ json_data = json.dumps(capture_request.dict()) + encoded_order_id = self.encode_path_segment(order_id) response = self.client.create_patch_request( - f"json/capture/{order_id}", + f"json/capture/{encoded_order_id}", request_body=json_data, ) args: dict = { diff --git a/src/multisafepay/api/paths/gateways/gateway_manager.py b/src/multisafepay/api/paths/gateways/gateway_manager.py index fe427f2..bd12064 100644 --- a/src/multisafepay/api/paths/gateways/gateway_manager.py +++ b/src/multisafepay/api/paths/gateways/gateway_manager.py @@ -102,8 +102,9 @@ def get_by_code( options = {} options = {k: v for k, v in options.items() if k in ALLOWED_OPTIONS} + encoded_gateway_code = self.encode_path_segment(gateway_code) response = self.client.create_get_request( - f"json/gateways/{gateway_code}", + f"json/gateways/{encoded_gateway_code}", options, ) args: dict = { diff --git a/src/multisafepay/api/paths/issuers/issuer_manager.py b/src/multisafepay/api/paths/issuers/issuer_manager.py index cf630f6..2e21698 100644 --- a/src/multisafepay/api/paths/issuers/issuer_manager.py +++ b/src/multisafepay/api/paths/issuers/issuer_manager.py @@ -59,8 +59,9 @@ def get_issuers_by_gateway_code( if gateway_code not in ALLOWED_GATEWAY_CODES: raise InvalidArgumentException("Gateway code is not allowed") + encoded_gateway_code = self.encode_path_segment(gateway_code) response = self.client.create_get_request( - f"json/issuers/{gateway_code}", + f"json/issuers/{encoded_gateway_code}", ) args: dict = { **response.dict(), diff --git a/src/multisafepay/api/paths/orders/order_manager.py b/src/multisafepay/api/paths/orders/order_manager.py index eeefcfd..8fcd46c 100644 --- a/src/multisafepay/api/paths/orders/order_manager.py +++ b/src/multisafepay/api/paths/orders/order_manager.py @@ -102,7 +102,8 @@ def get(self: "OrderManager", order_id: str) -> CustomApiResponse: CustomApiResponse: The custom API response containing the order data. """ - endpoint = f"json/orders/{order_id}" + encoded_order_id = self.encode_path_segment(order_id) + endpoint = f"json/orders/{encoded_order_id}" context = {"order_id": order_id} response: ApiResponse = self.client.create_get_request( endpoint, @@ -152,8 +153,9 @@ def update( """ json_data = json.dumps(update_request.to_dict()) + encoded_order_id = self.encode_path_segment(order_id) response = self.client.create_patch_request( - f"json/orders/{order_id}", + f"json/orders/{encoded_order_id}", request_body=json_data, ) args: dict = { @@ -181,9 +183,10 @@ def capture( """ json_data = json.dumps(capture_request.to_dict()) + encoded_order_id = self.encode_path_segment(order_id) response = self.client.create_post_request( - f"json/orders/{order_id}/capture", + f"json/orders/{encoded_order_id}/capture", request_body=json_data, ) args: dict = { @@ -221,8 +224,9 @@ def refund( """ json_data = json.dumps(request_refund.to_dict()) + encoded_order_id = self.encode_path_segment(order_id) response = self.client.create_post_request( - f"json/orders/{order_id}/refunds", + f"json/orders/{encoded_order_id}/refunds", request_body=json_data, ) args: dict = { @@ -267,6 +271,7 @@ def refund_by_item( quantity, ) + # Encode the order_id before calling refund return self.refund(order.order_id, request_refund) @staticmethod diff --git a/src/multisafepay/api/paths/payment_methods/payment_method_manager.py b/src/multisafepay/api/paths/payment_methods/payment_method_manager.py index 31bc140..df43f43 100644 --- a/src/multisafepay/api/paths/payment_methods/payment_method_manager.py +++ b/src/multisafepay/api/paths/payment_methods/payment_method_manager.py @@ -126,8 +126,9 @@ def get_by_gateway_code( if options is None: options = {} options = {k: v for k, v in options.items() if k in ALLOWED_OPTIONS} + encoded_gateway_code = self.encode_path_segment(gateway_code) response = self.client.create_get_request( - f"json/payment-methods/{gateway_code}", + f"json/payment-methods/{encoded_gateway_code}", options, ) args: dict = { diff --git a/src/multisafepay/api/paths/recurring/recurring_manager.py b/src/multisafepay/api/paths/recurring/recurring_manager.py index ad6e707..1b22a60 100644 --- a/src/multisafepay/api/paths/recurring/recurring_manager.py +++ b/src/multisafepay/api/paths/recurring/recurring_manager.py @@ -64,8 +64,9 @@ def get_list( CustomApiResponse: The response containing the list of tokens. """ + encoded_reference = self.encode_path_segment(reference) response: ApiResponse = self.client.create_get_request( - f"json/recurring/{reference}", + f"json/recurring/{encoded_reference}", ) args: dict = { **response.dict(), @@ -109,8 +110,10 @@ def get( CustomApiResponse: The response containing the token data. """ + encoded_reference = self.encode_path_segment(reference) + encoded_token = self.encode_path_segment(token) response = self.client.create_get_request( - f"json/recurring/{reference}/token/{token}", + f"json/recurring/{encoded_reference}/token/{encoded_token}", ) args: dict = { **response.dict(), @@ -144,8 +147,10 @@ def delete( CustomApiResponse: The response after deleting the token. """ + encoded_reference = self.encode_path_segment(reference) + encoded_token = self.encode_path_segment(token) response = self.client.create_delete_request( - f"json/recurring/{reference}/remove/{token}", + f"json/recurring/{encoded_reference}/remove/{encoded_token}", ) args: dict = { **response.dict(), diff --git a/tests/multisafepay/unit/api/base/test_abstract_manager.py b/tests/multisafepay/unit/api/base/test_abstract_manager.py new file mode 100644 index 0000000..7de1891 --- /dev/null +++ b/tests/multisafepay/unit/api/base/test_abstract_manager.py @@ -0,0 +1,163 @@ +# 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. + + +from multisafepay.api.base.abstract_manager import AbstractManager + + +def test_encode_path_segment_with_normal_string(): + """Test encoding a normal string without special characters.""" + result = AbstractManager.encode_path_segment("normal_string") + assert result == "normal_string" + + +def test_encode_path_segment_with_spaces(): + """Test encoding a string with spaces.""" + result = AbstractManager.encode_path_segment("hello world") + assert result == "hello%20world" + + +def test_encode_path_segment_with_special_characters(): + """Test encoding a string with various special characters.""" + result = AbstractManager.encode_path_segment("hello@world#test") + assert result == "hello%40world%23test" + + +def test_encode_path_segment_with_forward_slash(): + """Test encoding a string with forward slashes.""" + result = AbstractManager.encode_path_segment("path/to/resource") + assert result == "path%2Fto%2Fresource" + + +def test_encode_path_segment_with_question_mark(): + """Test encoding a string with question marks.""" + result = AbstractManager.encode_path_segment("query?param=value") + assert result == "query%3Fparam%3Dvalue" + + +def test_encode_path_segment_with_ampersand(): + """Test encoding a string with ampersands.""" + result = AbstractManager.encode_path_segment("param1¶m2") + assert result == "param1%26param2" + + +def test_encode_path_segment_with_equals_sign(): + """Test encoding a string with equals signs.""" + result = AbstractManager.encode_path_segment("key=value") + assert result == "key%3Dvalue" + + +def test_encode_path_segment_with_percentage_sign(): + """Test encoding a string with percentage signs.""" + result = AbstractManager.encode_path_segment("discount%off") + assert result == "discount%25off" + + +def test_encode_path_segment_with_plus_sign(): + """Test encoding a string with plus signs.""" + result = AbstractManager.encode_path_segment("one+two") + assert result == "one%2Btwo" + + +def test_encode_path_segment_with_unicode_characters(): + """Test encoding a string with Unicode characters.""" + result = AbstractManager.encode_path_segment("café") + assert result == "caf%C3%A9" + + +def test_encode_path_segment_with_emoji(): + """Test encoding a string with emoji characters.""" + result = AbstractManager.encode_path_segment("hello😊world") + assert result == "hello%F0%9F%98%8Aworld" + + +def test_encode_path_segment_with_empty_string(): + """Test encoding an empty string.""" + result = AbstractManager.encode_path_segment("") + assert result == "" + + +def test_encode_path_segment_with_only_special_characters(): + """Test encoding a string with only special characters.""" + result = AbstractManager.encode_path_segment("!@#$%^&*()") + assert result == "%21%40%23%24%25%5E%26%2A%28%29" + + +def test_encode_path_segment_with_numbers(): + """Test encoding a string with numbers.""" + result = AbstractManager.encode_path_segment("123456") + assert result == "123456" + + +def test_encode_path_segment_with_mixed_alphanumeric(): + """Test encoding a string with mixed alphanumeric characters.""" + result = AbstractManager.encode_path_segment("abc123XYZ") + assert result == "abc123XYZ" + + +def test_encode_path_segment_with_hyphen_and_underscore(): + """Test encoding a string with hyphens and underscores (safe characters).""" + result = AbstractManager.encode_path_segment("test-value_123") + assert result == "test-value_123" + + +def test_encode_path_segment_with_period_and_tilde(): + """Test encoding a string with periods and tildes (safe characters).""" + result = AbstractManager.encode_path_segment("file.txt~backup") + assert result == "file.txt~backup" + + +def test_encode_path_segment_with_integer_input(): + """Test encoding an integer input (should be converted to string).""" + result = AbstractManager.encode_path_segment(12345) + assert result == "12345" + + +def test_encode_path_segment_with_float_input(): + """Test encoding a float input (should be converted to string).""" + result = AbstractManager.encode_path_segment(123.45) + assert result == "123.45" + + +def test_encode_path_segment_with_none_input(): + """Test encoding None input (should be converted to string).""" + result = AbstractManager.encode_path_segment(None) + assert result == "None" + + +def test_encode_path_segment_preserves_unreserved_characters(): + """Test that unreserved characters are not encoded.""" + # RFC 3986 unreserved characters: ALPHA / DIGIT / "-" / "." / "_" / "~" + unreserved = ( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + ) + result = AbstractManager.encode_path_segment(unreserved) + assert result == unreserved + + +def test_encode_path_segment_encodes_reserved_characters(): + """Test that reserved characters are properly encoded.""" + # Some RFC 3986 reserved characters + reserved = ":/?#[]@!$&'()*+,;=" + result = AbstractManager.encode_path_segment(reserved) + # All characters should be encoded since safe="" is used + assert ":" not in result + assert "/" not in result + assert "?" not in result + assert "#" not in result + assert "@" not in result + assert "!" not in result + assert "$" not in result + assert "&" not in result + assert "'" not in result + assert "(" not in result + assert ")" not in result + assert "*" not in result + assert "+" not in result + assert "," not in result + assert ";" not in result + assert "=" not in result