diff --git a/src/Command/Api/ApiBaseCommand.php b/src/Command/Api/ApiBaseCommand.php index 5c048b55..7574d8e0 100644 --- a/src/Command/Api/ApiBaseCommand.php +++ b/src/Command/Api/ApiBaseCommand.php @@ -114,6 +114,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int $acquiaCloudClient->addOption('headers', [ 'Accept' => 'application/hal+json, version=2', ]); + // Some POST endpoints have no request body (e.g. schedule-delete). The + // Cloud Platform API requires both a Content-Type header and a body for + // all POST requests. Guzzle's `json` option satisfies both; passing an + // empty object sends `{}` with Content-Type: application/json. + // PUT/PATCH endpoints with no body (e.g. site-instances:domain:add) must + // not send `{}`: Guzzle creates a non-seekable stream which triggers + // `curl_setopt_array(): Stream does not support seeking`. + if (strtoupper($this->method) === 'POST' && empty($this->postParams)) { + $acquiaCloudClient->addOption('json', new \stdClass()); + } try { if ($this->output->isVeryVerbose()) { diff --git a/tests/phpunit/src/Commands/Api/ApiCommandTest.php b/tests/phpunit/src/Commands/Api/ApiCommandTest.php index 216f4b66..333fd1c7 100644 --- a/tests/phpunit/src/Commands/Api/ApiCommandTest.php +++ b/tests/phpunit/src/Commands/Api/ApiCommandTest.php @@ -59,6 +59,48 @@ public function testTrustedProxiesCommandExecutesHttpGet(): void $this->assertEquals(0, $this->getStatusCode()); } + /** + * A body-less POST (no requestBody in the spec) must send an empty JSON + * object so the Cloud Platform API receives both a Content-Type header and + * a body, preventing HTTP 415 / HTTP 400 errors. + */ + public function testBodylessPostSendsEmptyJsonBody(): void + { + $agreementUuid = 'da1c0a8e-ff69-45db-88fc-acd6d2affbb7'; + $this->mockRequest('postAcceptAgreement', $agreementUuid); + $this->clientProphecy->addOption('headers', ['Accept' => 'application/hal+json, version=2']) + ->shouldBeCalled(); + $this->clientProphecy->addOption('json', new \stdClass()) + ->shouldBeCalled(); + $this->command = $this->getApiCommandByName('api:agreements:accept'); + $this->executeCommand(['agreementUuid' => $agreementUuid]); + $this->assertEquals(0, $this->getStatusCode()); + } + + /** + * A body-less PUT (requestBody with no properties, required: false) must + * NOT send `{}`. Guzzle creates a non-seekable stream for the empty object + * which triggers `curl_setopt_array(): Stream does not support seeking`. + */ + public function testBodylessPutDoesNotSendEmptyJsonBody(): void + { + $siteId = '3e8ecbec-ea7c-4260-8414-ef2938c859bc'; + $environmentId = 'd3f7270e-c45f-4801-9308-5e8afe84a323'; + $domainName = 'example.com'; + $this->mockRequest('add_domain_to_site', [$siteId, $environmentId, $domainName]); + $this->clientProphecy->addOption('headers', ['Accept' => 'application/hal+json, version=2']) + ->shouldBeCalled(); + $this->clientProphecy->addOption('json', new \stdClass()) + ->shouldNotBeCalled(); + $this->command = $this->getApiCommandByName('api:site-instances:domain:add'); + $this->executeCommand([ + 'domainName' => $domainName, + 'environmentId' => $environmentId, + 'siteId' => $siteId, + ]); + $this->assertEquals(0, $this->getStatusCode()); + } + /** * oneOf entries that lack a 'type' key (e.g. unresolved $ref) must be * skipped silently rather than throwing an "Undefined array key" warning.