From 9d3ca614726fea486e6d129f94bc75deb65087c9 Mon Sep 17 00:00:00 2001 From: javorosas Date: Thu, 10 Sep 2026 18:24:02 +0200 Subject: [PATCH 1/2] fix: serialize query params with the documented encoding --- VERSION.md | 3 ++ src/Http/BaseClient.php | 48 +++++++++++++++++++++++++++----- tests/Resources/InvoicesTest.php | 43 ++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 7 deletions(-) diff --git a/VERSION.md b/VERSION.md index fc8af16..a55827e 100644 --- a/VERSION.md +++ b/VERSION.md @@ -3,6 +3,9 @@ ## Added - Add `invoices.paymentSummary()` to get the related-document object needed to build a payment complement (complemento de pago): installment number, previous balance, and taxes prorated to the paid amount. +## Fixed +- Serialize query parameters with the encoding the API documents and the other official SDKs send: lists repeat the key (`status=valid&status=canceled`) instead of using indexes, and `null` values are omitted instead of being sent empty. Associate arrays (`date[gte]=...`) and explicit empty strings (`q=`) keep working. + 4.7.1 ## Fixed diff --git a/src/Http/BaseClient.php b/src/Http/BaseClient.php index 295742d..b40c2b8 100644 --- a/src/Http/BaseClient.php +++ b/src/Http/BaseClient.php @@ -319,17 +319,51 @@ protected function arrayToParams($params): string $parts = array(); foreach ($params as $parameter => $value) { - if (is_array($value)) { - foreach ($value as $key => $sub_param) { - $parts[] = $parameter . '[' . $key . ']' . '=' . urlencode((string) $sub_param); + $this->appendQueryParam($parts, (string) $parameter, $value); + } + + return empty($parts) ? '' : '?' . implode('&', $parts); + } + + /** + * Appends one query parameter using the encoding the API documents and the + * other SDKs send: lists repeat the key (`status=a&status=b`), associative + * arrays use bracket notation (`date[gte]=...`) and `null` values are + * omitted instead of being sent empty. + * + * @param array $parts Accumulated `key=value` pairs. + * @param string $parameter Query parameter name. + * @param mixed $value Value to serialize. + * @return void + */ + private function appendQueryParam(array &$parts, string $parameter, $value): void + { + if ($value === null) { + return; + } + + if (is_array($value)) { + if (array_is_list($value)) { + foreach ($value as $item) { + $this->appendQueryParam($parts, $parameter, $item); } - } else { - $param = is_bool($value) ? ($value ? 'true' : 'false') : urlencode((string) $value); - $parts[] = $parameter . '=' . $param; + + return; + } + + foreach ($value as $key => $sub_param) { + $this->appendQueryParam($parts, $parameter . '[' . $key . ']', $sub_param); } + + return; } - return empty($parts) ? '' : '?' . implode('&', $parts); + if (is_bool($value)) { + $parts[] = $parameter . '=' . ($value ? 'true' : 'false'); + return; + } + + $parts[] = $parameter . '=' . urlencode((string) $value); } /** diff --git a/tests/Resources/InvoicesTest.php b/tests/Resources/InvoicesTest.php index 9274bcd..a7012f5 100644 --- a/tests/Resources/InvoicesTest.php +++ b/tests/Resources/InvoicesTest.php @@ -57,6 +57,49 @@ public function testAllSerializesNestedDateRangeQueryParametersIntoUrl(): void ); } + public function testAllRepeatsTheKeyForListQueryParameters(): void + { + $httpClient = new FakeHttpClient(new Response(200, [], '{"data":[]}')); + $invoices = new Invoices('sk_test_abc123', ['httpClient' => $httpClient]); + + $invoices->all([ + 'status' => ['valid', 'canceled'], + ]); + + $request = $httpClient->requests()[0]; + self::assertSame( + 'status=valid&status=canceled', + urldecode($request->getUri()->getQuery()) + ); + } + + public function testAllOmitsNullQueryParameters(): void + { + $httpClient = new FakeHttpClient(new Response(200, [], '{"data":[]}')); + $invoices = new Invoices('sk_test_abc123', ['httpClient' => $httpClient]); + + $invoices->all([ + 'q' => null, + 'page' => 2, + ]); + + $request = $httpClient->requests()[0]; + self::assertSame('page=2', urldecode($request->getUri()->getQuery())); + } + + public function testAllSendsExplicitEmptyQueryValues(): void + { + $httpClient = new FakeHttpClient(new Response(200, [], '{"data":[]}')); + $invoices = new Invoices('sk_test_abc123', ['httpClient' => $httpClient]); + + $invoices->all([ + 'q' => '', + ]); + + $request = $httpClient->requests()[0]; + self::assertSame('q=', urldecode($request->getUri()->getQuery())); + } + public function testAllUrlEncodesScalarQueryValues(): void { $httpClient = new FakeHttpClient(new Response(200, [], '{"data":[]}')); From 02da3a0e1cd69fc1f23d183b467a7ff58d48ca78 Mon Sep 17 00:00:00 2001 From: javorosas Date: Thu, 10 Sep 2026 18:32:12 +0200 Subject: [PATCH 2/2] docs: fix typo in the release note --- VERSION.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION.md b/VERSION.md index a55827e..af6ef8b 100644 --- a/VERSION.md +++ b/VERSION.md @@ -4,7 +4,7 @@ - Add `invoices.paymentSummary()` to get the related-document object needed to build a payment complement (complemento de pago): installment number, previous balance, and taxes prorated to the paid amount. ## Fixed -- Serialize query parameters with the encoding the API documents and the other official SDKs send: lists repeat the key (`status=valid&status=canceled`) instead of using indexes, and `null` values are omitted instead of being sent empty. Associate arrays (`date[gte]=...`) and explicit empty strings (`q=`) keep working. +- Serialize query parameters with the encoding the API documents and the other official SDKs send: lists repeat the key (`status=valid&status=canceled`) instead of using indexes, and `null` values are omitted instead of being sent empty. Associative arrays (`date[gte]=...`) and explicit empty strings (`q=`) keep working. 4.7.1