From 7a3614af5642e5eabc7edc73dced812fc9e36c47 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:48:11 +0000 Subject: [PATCH] Fix empty body serializing as JSON array instead of object An empty PHP array serializes to the JSON array `[]` via Guzzle's `json` option, but JSON-object endpoints (e.g. challengeFactor on a TOTP factor) expect `{}`. Cast empty arrays to `(object)` so the empty case encodes correctly. Closes #400 --- lib/HttpClient.php | 7 +++++- tests/HttpClientTest.php | 52 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/lib/HttpClient.php b/lib/HttpClient.php index e343ced1..ad7f94ab 100644 --- a/lib/HttpClient.php +++ b/lib/HttpClient.php @@ -224,7 +224,12 @@ private function buildRequestOptions( } if ($body !== null) { - $requestOptions['json'] = $body; + // An empty array serializes to the JSON array `[]`, but JSON-object + // endpoints expect an object `{}`. This happens whenever a body is + // entirely optional and every field is omitted (e.g. challengeFactor + // on a TOTP factor). Cast to an object so the empty case encodes as + // `{}`; non-empty associative arrays already encode as objects. + $requestOptions['json'] = $body === [] ? (object) $body : $body; } return $requestOptions; diff --git a/tests/HttpClientTest.php b/tests/HttpClientTest.php index be9037a2..4cb5309f 100644 --- a/tests/HttpClientTest.php +++ b/tests/HttpClientTest.php @@ -324,6 +324,58 @@ public function testResolveUrlPreservesEncodedIdAsSingleSegment(): void $this->assertSame('/organizations/om_xyz/foo', $rawSlashRequest->getUri()->getPath()); } + public function testEmptyBodySerializesAsJsonObject(): void + { + // Issue #400: an all-optional body with every field omitted reduces to + // an empty PHP array. Guzzle's `json` option encodes that to the JSON + // array `[]`, which JSON-object endpoints (e.g. challengeFactor on a + // TOTP factor) reject with a 422. The body must serialize to `{}`. + $mock = new MockHandler([ + new Response(200, ['Content-Type' => 'application/json'], '{}'), + ]); + $history = []; + $handler = HandlerStack::create($mock); + $handler->push(\GuzzleHttp\Middleware::history($history)); + + $client = new HttpClient( + apiKey: 'test_key', + clientId: null, + baseUrl: 'https://api.workos.com', + timeout: 10, + maxRetries: 0, + handler: $handler, + ); + + $client->request('POST', 'auth/factors/auth_factor_123/challenge', body: []); + + $request = $history[array_key_last($history)]['request']; + $this->assertSame('{}', (string) $request->getBody()); + } + + public function testNonEmptyBodyStillSerializesAsJsonObject(): void + { + $mock = new MockHandler([ + new Response(200, ['Content-Type' => 'application/json'], '{}'), + ]); + $history = []; + $handler = HandlerStack::create($mock); + $handler->push(\GuzzleHttp\Middleware::history($history)); + + $client = new HttpClient( + apiKey: 'test_key', + clientId: null, + baseUrl: 'https://api.workos.com', + timeout: 10, + maxRetries: 0, + handler: $handler, + ); + + $client->request('POST', 'auth/factors/auth_factor_123/challenge', body: ['sms_template' => 'Your code is {{code}}']); + + $request = $history[array_key_last($history)]['request']; + $this->assertSame('{"sms_template":"Your code is {{code}}"}', (string) $request->getBody()); + } + public function testNonStringCodeFieldIsIgnored(): void { $body = json_encode([