Skip to content
Draft
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
3 changes: 2 additions & 1 deletion Adyen/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import copy
import hashlib
import hmac
from urllib.parse import urlencode

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Import quote from urllib.parse to support encoding spaces as %20 instead of + in query parameters, ensuring compliance with RFC 3986.

Suggested change
from urllib.parse import urlencode
from urllib.parse import quote, urlencode



# generates HMAC signature for the NotificationRequest object
Expand Down Expand Up @@ -94,4 +95,4 @@ def is_valid_hmac_payload(hmac_signature, hmac_key, payload):


def get_query(query_parameters):
return "?" + "&".join([f"{k}={v}" for k, v in query_parameters.items()])
return "?" + urlencode(query_parameters)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

By default, urlencode uses quote_plus which encodes spaces as +. While + is standard for application/x-www-form-urlencoded POST bodies, RFC 3986 defines + as a reserved character in URLs, and spaces should be encoded as %20 in query strings. Some web servers or API gateways do not decode + to spaces in query parameters, which can lead to unexpected behavior (e.g., receiving literal + characters instead of spaces). Using quote_via=quote ensures spaces are encoded as %20, which is universally compatible and compliant with RFC 3986.

Additionally, using doseq=True ensures that if any query parameter contains a list or sequence of values, it is correctly encoded as repeated parameters (e.g., param=a&param=b) rather than stringifying the sequence.

Suggested change
return "?" + urlencode(query_parameters)
return "?" + urlencode(query_parameters, doseq=True, quote_via=quote)

43 changes: 43 additions & 0 deletions test/QueryEncodingTest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import unittest
from unittest.mock import Mock
from urllib.parse import parse_qs, urlsplit

import Adyen
from Adyen.util import get_query


class TestQueryEncoding(unittest.TestCase):
def test_reserved_characters_round_trip(self):
parameters = {
"reference": "Invoice A&B + 10% #café",
"filter[key]&name": "x=y?z/1",
"empty": "",
"pageNumber": 0,
}
encoded = get_query(parameters)
self.assertEqual(
parse_qs(urlsplit("https://example.test/" + encoded).query, keep_blank_values=True),
{key: [str(value)] for key, value in parameters.items()},
)
self.assertEqual(urlsplit("https://example.test/" + encoded).fragment, "")
Comment on lines +10 to +22

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Update the test to verify that spaces are encoded as %20 (not +) and to cover list/sequence parameters using doseq=True.

Suggested change
def test_reserved_characters_round_trip(self):
parameters = {
"reference": "Invoice A&B + 10% #café",
"filter[key]&name": "x=y?z/1",
"empty": "",
"pageNumber": 0,
}
encoded = get_query(parameters)
self.assertEqual(
parse_qs(urlsplit("https://example.test/" + encoded).query, keep_blank_values=True),
{key: [str(value)] for key, value in parameters.items()},
)
self.assertEqual(urlsplit("https://example.test/" + encoded).fragment, "")
def test_reserved_characters_round_trip(self):
parameters = {
"reference": "Invoice A&B + 10% #café",
"filter[key]&name": "x=y?z/1",
"empty": "",
"pageNumber": 0,
"list_param": ["a", "b"],
}
encoded = get_query(parameters)
self.assertNotIn("+", encoded)
self.assertEqual(
parse_qs(urlsplit("https://example.test/" + encoded).query, keep_blank_values=True),
{key: [str(v) for v in value] if isinstance(value, list) else [str(value)] for key, value in parameters.items()},
)
self.assertEqual(urlsplit("https://example.test/" + encoded).fragment, "")


def test_public_service_preserves_query_values(self):
adyen = Adyen.Adyen()
adyen.client.platform = "test"
adyen.client.xapikey = "test-api-key"
adyen.client._init_http_client()
request = Mock(return_value=["{}", {}, 200, {}])
adyen.client.http_client.request = request
parameters = {"reference": "A&B + # café", "pageNumber": 3}
adyen.checkout.payments_api.payment_methods(
{"merchantAccount": "test-merchant"}, query_parameters=parameters
)
url = request.call_args.args[1]
self.assertEqual(
parse_qs(urlsplit(url).query),
{"reference": ["A&B + # café"], "pageNumber": ["3"]},
)
self.assertEqual(parameters, {"reference": "A&B + # café", "pageNumber": 3})

def test_empty_query_keeps_existing_output(self):
self.assertEqual(get_query({}), "?")