Skip to content
Merged
8 changes: 7 additions & 1 deletion lib/HttpClient/CurlClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
30 changes: 21 additions & 9 deletions lib/Service/AbstractService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '';
Expand All @@ -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']);
}
Expand All @@ -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']);
}
Expand All @@ -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']);
}
Expand Down
24 changes: 21 additions & 3 deletions lib/Util/Util.php
Original file line number Diff line number Diff line change
Expand Up @@ -154,19 +154,25 @@ 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;
}
if (static::isList($h)) {
$results = [];
foreach ($h as $v) {
$results[] = static::objectsToIds($v);
$results[] = static::objectsToIds($v, $serializeNull);
}

return $results;
Expand All @@ -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;
Expand Down
99 changes: 96 additions & 3 deletions tests/Stripe/Service/AbstractServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand All @@ -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']);
}
Comment thread
jar-stripe marked this conversation as resolved.

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']);
}
Comment thread
jar-stripe marked this conversation as resolved.

public function testRequestCoercesInt64ParamsWhenSchemaProvided()
{
$capturedParams = null;
Expand Down
91 changes: 90 additions & 1 deletion tests/Stripe/Util/UtilTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
Loading