Skip to content

Commit 29d0099

Browse files
fix: Fix empty body serializing as JSON array instead of object (#403)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent c7ba478 commit 29d0099

2 files changed

Lines changed: 58 additions & 1 deletion

File tree

lib/HttpClient.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,12 @@ private function buildRequestOptions(
224224
}
225225

226226
if ($body !== null) {
227-
$requestOptions['json'] = $body;
227+
// An empty array serializes to the JSON array `[]`, but JSON-object
228+
// endpoints expect an object `{}`. This happens whenever a body is
229+
// entirely optional and every field is omitted (e.g. challengeFactor
230+
// on a TOTP factor). Cast to an object so the empty case encodes as
231+
// `{}`; non-empty associative arrays already encode as objects.
232+
$requestOptions['json'] = $body === [] ? (object) $body : $body;
228233
}
229234

230235
return $requestOptions;

tests/HttpClientTest.php

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,58 @@ public function testResolveUrlPreservesEncodedIdAsSingleSegment(): void
324324
$this->assertSame('/organizations/om_xyz/foo', $rawSlashRequest->getUri()->getPath());
325325
}
326326

327+
public function testEmptyBodySerializesAsJsonObject(): void
328+
{
329+
// Issue #400: an all-optional body with every field omitted reduces to
330+
// an empty PHP array. Guzzle's `json` option encodes that to the JSON
331+
// array `[]`, which JSON-object endpoints (e.g. challengeFactor on a
332+
// TOTP factor) reject with a 422. The body must serialize to `{}`.
333+
$mock = new MockHandler([
334+
new Response(200, ['Content-Type' => 'application/json'], '{}'),
335+
]);
336+
$history = [];
337+
$handler = HandlerStack::create($mock);
338+
$handler->push(\GuzzleHttp\Middleware::history($history));
339+
340+
$client = new HttpClient(
341+
apiKey: 'test_key',
342+
clientId: null,
343+
baseUrl: 'https://api.workos.com',
344+
timeout: 10,
345+
maxRetries: 0,
346+
handler: $handler,
347+
);
348+
349+
$client->request('POST', 'auth/factors/auth_factor_123/challenge', body: []);
350+
351+
$request = $history[array_key_last($history)]['request'];
352+
$this->assertSame('{}', (string) $request->getBody());
353+
}
354+
355+
public function testNonEmptyBodyStillSerializesAsJsonObject(): void
356+
{
357+
$mock = new MockHandler([
358+
new Response(200, ['Content-Type' => 'application/json'], '{}'),
359+
]);
360+
$history = [];
361+
$handler = HandlerStack::create($mock);
362+
$handler->push(\GuzzleHttp\Middleware::history($history));
363+
364+
$client = new HttpClient(
365+
apiKey: 'test_key',
366+
clientId: null,
367+
baseUrl: 'https://api.workos.com',
368+
timeout: 10,
369+
maxRetries: 0,
370+
handler: $handler,
371+
);
372+
373+
$client->request('POST', 'auth/factors/auth_factor_123/challenge', body: ['sms_template' => 'Your code is {{code}}']);
374+
375+
$request = $history[array_key_last($history)]['request'];
376+
$this->assertSame('{"sms_template":"Your code is {{code}}"}', (string) $request->getBody());
377+
}
378+
327379
public function testNonStringCodeFieldIsIgnored(): void
328380
{
329381
$body = json_encode([

0 commit comments

Comments
 (0)