diff --git a/lib/HttpClient/CurlClient.php b/lib/HttpClient/CurlClient.php index c210a32c4..d9fd963bf 100644 --- a/lib/HttpClient/CurlClient.php +++ b/lib/HttpClient/CurlClient.php @@ -202,7 +202,13 @@ public function getConnectTimeout() */ private function constructUrlAndBody($method, $absUrl, $params, $hasFile, $apiMode) { - $params = Util\Util::objectsToIds($params); + // For V2 POST bodies, preserve null values so they serialize to JSON + // null (the V2 mechanism for clearing fields / metadata keys). + // For all other cases (V1, GET/DELETE query params), strip nulls as + // before — null values become empty strings in query params which + // causes server errors. + $serializeNull = ('post' === $method && 'v2' === $apiMode); + $params = Util\Util::objectsToIds($params, $serializeNull); if ('post' === $method) { $absUrl = Util\Util::utf8($absUrl); if ($hasFile) { diff --git a/lib/Service/AbstractService.php b/lib/Service/AbstractService.php index d990bf0ca..fdafae752 100644 --- a/lib/Service/AbstractService.php +++ b/lib/Service/AbstractService.php @@ -49,18 +49,25 @@ public function getStreamingClient() } /** - * Translate null values to empty strings. For service methods, - * we interpret null as a request to unset the field, which - * corresponds to sending an empty string for the field to the - * API. + * Translate null values to empty strings for v1 API requests. + * For v1, we interpret null as a request to unset the field, + * which corresponds to sending an empty string in the + * form-encoded body. + * + * For v2, null values are preserved as-is so they serialize + * to JSON null, which is the v2 mechanism for clearing fields. * * @param null|array $params + * @param 'v1'|'v2' $apiMode */ - private static function formatParams($params) + private static function formatParams($params, $apiMode) { if (null === $params) { return null; } + if ('v2' === $apiMode) { + return $params; + } \array_walk_recursive($params, static function (&$value, $key) { if (null === $value) { $value = ''; @@ -72,7 +79,8 @@ private static function formatParams($params) protected function request($method, $path, $params, $opts, $schemas = null) { - $params = self::formatParams($params); + $apiMode = \Stripe\Util\Util::getApiMode($path); + $params = self::formatParams($params, $apiMode); if (null !== $schemas && isset($schemas['request_schema'])) { $params = \Stripe\Util\Int64::coerceRequestParams($params, $schemas['request_schema']); } @@ -82,12 +90,15 @@ protected function request($method, $path, $params, $opts, $schemas = null) protected function requestStream($method, $path, $readBodyChunkCallable, $params, $opts) { - return $this->getStreamingClient()->requestStream($method, $path, $readBodyChunkCallable, self::formatParams($params), $opts); + $apiMode = \Stripe\Util\Util::getApiMode($path); + + return $this->getStreamingClient()->requestStream($method, $path, $readBodyChunkCallable, self::formatParams($params, $apiMode), $opts); } protected function requestCollection($method, $path, $params, $opts, $schemas = null) { - $params = self::formatParams($params); + $apiMode = \Stripe\Util\Util::getApiMode($path); + $params = self::formatParams($params, $apiMode); if (null !== $schemas && isset($schemas['request_schema'])) { $params = \Stripe\Util\Int64::coerceRequestParams($params, $schemas['request_schema']); } @@ -97,7 +108,8 @@ protected function requestCollection($method, $path, $params, $opts, $schemas = protected function requestSearchResult($method, $path, $params, $opts, $schemas = null) { - $params = self::formatParams($params); + $apiMode = \Stripe\Util\Util::getApiMode($path); + $params = self::formatParams($params, $apiMode); if (null !== $schemas && isset($schemas['request_schema'])) { $params = \Stripe\Util\Int64::coerceRequestParams($params, $schemas['request_schema']); } diff --git a/lib/Util/Util.php b/lib/Util/Util.php index 96f92a61e..fb20edc60 100644 --- a/lib/Util/Util.php +++ b/lib/Util/Util.php @@ -154,11 +154,17 @@ public static function secureCompare($a, $b) * ApiResource, then it is replaced by the resource's ID. * Also clears out null values. * + * When $serializeNull is true (used for V2 POST request + * bodies), null values in associative arrays are preserved instead of + * stripped. This is necessary because V2 JSON bodies use explicit null + * to signal "delete this field / metadata key". + * * @param mixed $h + * @param bool $serializeNull when true, preserve null values instead of stripping them * * @return mixed */ - public static function objectsToIds($h) + public static function objectsToIds($h, $serializeNull) { if ($h instanceof \Stripe\ApiResource) { return $h->id; @@ -166,7 +172,7 @@ public static function objectsToIds($h) if (static::isList($h)) { $results = []; foreach ($h as $v) { - $results[] = static::objectsToIds($v); + $results[] = static::objectsToIds($v, $serializeNull); } return $results; @@ -175,9 +181,21 @@ public static function objectsToIds($h) $results = []; foreach ($h as $k => $v) { if (null === $v) { + if ($serializeNull) { + $results[$k] = null; + } + continue; } - $results[$k] = static::objectsToIds($v); + $results[$k] = static::objectsToIds($v, $serializeNull); + } + + // If the input was an associative array with string keys but + // all values were stripped, $results is an empty indexed array. + // PHP's json_encode would render that as [] (JSON array) instead + // of {} (JSON object). Cast to object to preserve the type. + if (empty($results) && !empty($h)) { + return (object) $results; } return $results; diff --git a/tests/Stripe/Service/AbstractServiceTest.php b/tests/Stripe/Service/AbstractServiceTest.php index ab23fa60e..afa8dd7d7 100644 --- a/tests/Stripe/Service/AbstractServiceTest.php +++ b/tests/Stripe/Service/AbstractServiceTest.php @@ -73,18 +73,18 @@ public function testRetrieveThrowsIfIdNullIsWhitespace() public function testFormatParams() { - $result = $this->formatParamsReflector->invoke(null, ['foo' => null]); + $result = $this->formatParamsReflector->invoke(null, ['foo' => null], 'v1'); self::assertTrue('' === $result['foo']); self::assertTrue(null !== $result['foo']); - $result = $this->formatParamsReflector->invoke(null, ['foo' => ['bar' => null, 'baz' => 1, 'nest' => ['triplynestednull' => null, 'triplynestednonnull' => 1]]]); + $result = $this->formatParamsReflector->invoke(null, ['foo' => ['bar' => null, 'baz' => 1, 'nest' => ['triplynestednull' => null, 'triplynestednonnull' => 1]]], 'v1'); self::assertTrue('' === $result['foo']['bar']); self::assertTrue(null !== $result['foo']['bar']); self::assertTrue(1 === $result['foo']['baz']); self::assertTrue('' === $result['foo']['nest']['triplynestednull']); self::assertTrue(1 === $result['foo']['nest']['triplynestednonnull']); - $result = $this->formatParamsReflector->invoke(null, ['foo' => ['zero', null, null, 'three'], 'toplevelnull' => null, 'toplevelnonnull' => 4]); + $result = $this->formatParamsReflector->invoke(null, ['foo' => ['zero', null, null, 'three'], 'toplevelnull' => null, 'toplevelnonnull' => 4], 'v1'); self::assertTrue('zero' === $result['foo'][0]); self::assertTrue('' === $result['foo'][1]); self::assertTrue('' === $result['foo'][2]); @@ -93,6 +93,99 @@ public function testFormatParams() self::assertTrue(4 === $result['toplevelnonnull']); } + public function testFormatParamsV1ConvertsNullToEmptyString() + { + // v1 behavior: null → '' (unchanged from previous behavior) + $result = $this->formatParamsReflector->invoke(null, ['foo' => null], 'v1'); + self::assertSame('', $result['foo']); + } + + public function testFormatParamsV2PreservesNull() + { + // v2 behavior: null values are preserved for JSON encoding + $result = $this->formatParamsReflector->invoke(null, ['foo' => null], 'v2'); + self::assertArrayHasKey('foo', $result); + self::assertNull($result['foo']); + } + + public function testFormatParamsV2PreservesNestedNull() + { + $result = $this->formatParamsReflector->invoke(null, [ + 'name' => 'test', + 'description' => null, + 'nested' => ['inner' => null, 'value' => 42], + ], 'v2'); + self::assertSame('test', $result['name']); + self::assertNull($result['description']); + self::assertNull($result['nested']['inner']); + self::assertSame(42, $result['nested']['value']); + } + + public function testFormatParamsV2NullParamsReturnsNull() + { + $result = $this->formatParamsReflector->invoke(null, null, 'v2'); + self::assertNull($result); + } + + public function testRequestPreservesNullForV2Path() + { + $capturedParams = null; + $mockClient = $this->createMock(\Stripe\StripeClientInterface::class); + $mockClient->expects(self::once()) + ->method('request') + ->with( + self::equalTo('post'), + self::equalTo('/v2/test/resource'), + self::callback(static function ($params) use (&$capturedParams) { + $capturedParams = $params; + + return true; + }), + self::anything() + ) + ->willReturn(\Stripe\StripeObject::constructFrom([])) + ; + + $service = new ConcreteTestService($mockClient); + $service->publicRequest('post', '/v2/test/resource', [ + 'name' => 'test', + 'description' => null, + ], []); + + self::assertSame('test', $capturedParams['name']); + self::assertArrayHasKey('description', $capturedParams); + self::assertNull($capturedParams['description']); + } + + public function testRequestConvertsNullToEmptyStringForV1Path() + { + $capturedParams = null; + $mockClient = $this->createMock(\Stripe\StripeClientInterface::class); + $mockClient->expects(self::once()) + ->method('request') + ->with( + self::equalTo('post'), + self::equalTo('/v1/test/resource'), + self::callback(static function ($params) use (&$capturedParams) { + $capturedParams = $params; + + return true; + }), + self::anything() + ) + ->willReturn(\Stripe\StripeObject::constructFrom([])) + ; + + $service = new ConcreteTestService($mockClient); + $service->publicRequest('post', '/v1/test/resource', [ + 'name' => 'test', + 'description' => null, + ], []); + + self::assertSame('test', $capturedParams['name']); + self::assertSame('', $capturedParams['description']); + } + public function testRequestCoercesInt64ParamsWhenSchemaProvided() { $capturedParams = null; diff --git a/tests/Stripe/Util/UtilTest.php b/tests/Stripe/Util/UtilTest.php index 281af6fb0..66f8ce76d 100644 --- a/tests/Stripe/Util/UtilTest.php +++ b/tests/Stripe/Util/UtilTest.php @@ -75,10 +75,99 @@ public function testObjectsToIds() 'foo' => 'bar', 'customer' => 'cus_123', ], - Util::objectsToIds($params) + Util::objectsToIds($params, false) ); } + public function testObjectsToIdsSerializeNullPreservesNulls() + { + $params = [ + 'foo' => 'bar', + 'null_value' => null, + ]; + + $result = Util::objectsToIds($params, true); + self::assertArrayHasKey('null_value', $result); + self::assertNull($result['null_value']); + self::assertSame('bar', $result['foo']); + } + + public function testObjectsToIdsSerializeNullPreservesNestedNulls() + { + $params = [ + 'metadata' => ['key' => 'value', 'to_delete' => null], + 'name' => 'test', + ]; + + $result = Util::objectsToIds($params, true); + self::assertSame('test', $result['name']); + self::assertSame('value', $result['metadata']['key']); + self::assertArrayHasKey('to_delete', $result['metadata']); + self::assertNull($result['metadata']['to_delete']); + } + + public function testObjectsToIdsDefaultStripsNulls() + { + // serializeEmpty=false: null values are stripped + $params = [ + 'foo' => 'bar', + 'null_value' => null, + ]; + + $result = Util::objectsToIds($params, false); + self::assertArrayNotHasKey('null_value', $result); + self::assertSame('bar', $result['foo']); + } + + public function testObjectsToIdsSerializeNullWithApiResource() + { + // ApiResource replacement should still work with serializeEmpty + $params = [ + 'customer' => Util::convertToStripeObject( + [ + 'id' => 'cus_123', + 'object' => 'customer', + ], + null + ), + 'description' => null, + ]; + + $result = Util::objectsToIds($params, true); + self::assertSame('cus_123', $result['customer']); + self::assertArrayHasKey('description', $result); + self::assertNull($result['description']); + } + + public function testObjectsToIdsEmptyAssocArrayBecomesObject() + { + // When all values are stripped from an associative array, + // the result should be an object (for correct JSON encoding + // as {} instead of []) + $params = [ + 'metadata' => ['only_null' => null], + ]; + + $result = Util::objectsToIds($params, false); + // metadata's only value was null and got stripped; the result + // for metadata should be an object (stdClass) not an empty array + self::assertInstanceOf(\stdClass::class, $result['metadata']); + self::assertSame('{}', \json_encode($result['metadata'])); + } + + public function testObjectsToIdsSerializeNullInList() + { + // Lists should pass through serializeEmpty to nested elements + $params = [ + ['key' => null, 'name' => 'test'], + ]; + + $result = Util::objectsToIds($params, true); + self::assertArrayHasKey('key', $result[0]); + self::assertNull($result[0]['key']); + self::assertSame('test', $result[0]['name']); + } + public function testEncodeParameters() { $params = [