Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/Core/Omit.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace SentDm\Core;

/**
* Marks a body param the caller didn't pass, distinct from one they passed as `null`.
*
* Every optional body param already defaults to `null`, so a method can't otherwise tell
* "leave this field alone" from "clear this field" when the API gives the two a different
* meaning. Use this as the default instead of `null` for such a param, and check for it
* with `$value instanceof Omit` before building the request body. Same role as the
* `Omit`/`omit` sentinel the Python SDK already uses for the same problem.
*/
final class Omit
{
}
8 changes: 5 additions & 3 deletions src/ServiceContracts/Channels/VoiceContract.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use SentDm\Channels\Voice\APIResponseOfVoiceSecret;
use SentDm\Channels\Voice\VoiceUpdateParams\Status;
use SentDm\Core\Exceptions\APIException;
use SentDm\Core\Omit;
use SentDm\RequestOptions;

/**
Expand Down Expand Up @@ -76,8 +77,9 @@ public function retrieve(
* @api
*
* @param string $number Path param: The number in E.164 format with the plus sign URL-encoded, e.g. %2B12125550100
* @param string|null $agentID Body param: One of your voice agents, such as agent_9f2ab000-0000-4000-8000-000000000001, to answer calls to
* the number, or null to unbind it so the callback URL answers them again. The agent wins over
* @param string|null|Omit $agentID Body param: One of your voice agents, such as agent_9f2ab000-0000-4000-8000-000000000001, to answer calls to
* the number, or null to unbind it so the callback URL answers them again. Leave this
* parameter out entirely to leave the current binding alone. The agent wins over
* callback_url for calls to the number; calls your app places from it still follow your app.
* @param string|null $callbackURL Body param: A new callback URL for the number, active or not: an absolute HTTP or HTTPS URL on a public host,
* where Sent asks what to do with each call. The signing secret is kept.
Expand All @@ -96,7 +98,7 @@ public function retrieve(
*/
public function update(
string $number,
?string $agentID = null,
string|null|Omit $agentID = new Omit,
?string $callbackURL = null,
?bool $defaultForAppCalls = null,
?bool $sandbox = null,
Expand Down
16 changes: 12 additions & 4 deletions src/Services/Channels/VoiceService.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use SentDm\Channels\Voice\VoiceUpdateParams\Status;
use SentDm\Client;
use SentDm\Core\Exceptions\APIException;
use SentDm\Core\Omit;
use SentDm\Core\Util;
use SentDm\RequestOptions;
use SentDm\ServiceContracts\Channels\VoiceContract;
Expand Down Expand Up @@ -181,8 +182,9 @@ public function retrieve(
* With `sandbox: true` nothing is written: the request is validated against the stored number and the number is reported with `200` as it would read after the change.
*
* @param string $number Path param: The number in E.164 format with the plus sign URL-encoded, e.g. %2B12125550100
* @param string|null $agentID Body param: One of your voice agents, such as agent_9f2ab000-0000-4000-8000-000000000001, to answer calls to
* the number, or null to unbind it so the callback URL answers them again. The agent wins over
* @param string|null|Omit $agentID Body param: One of your voice agents, such as agent_9f2ab000-0000-4000-8000-000000000001, to answer calls to
* the number, or null to unbind it so the callback URL answers them again. Leave this
* parameter out entirely to leave the current binding alone. The agent wins over
* callback_url for calls to the number; calls your app places from it still follow your app.
* @param string|null $callbackURL Body param: A new callback URL for the number, active or not: an absolute HTTP or HTTPS URL on a public host,
* where Sent asks what to do with each call. The signing secret is kept.
Expand All @@ -201,7 +203,7 @@ public function retrieve(
*/
public function update(
string $number,
?string $agentID = null,
string|null|Omit $agentID = new Omit,
?string $callbackURL = null,
?bool $defaultForAppCalls = null,
?bool $sandbox = null,
Expand All @@ -210,9 +212,9 @@ public function update(
?string $xProfileID = null,
RequestOptions|array|null $requestOptions = null,
): APIResponseOfVoiceNumber {
/** @var array<string, mixed> $params */
$params = Util::removeNulls(
[
'agentID' => $agentID,
'callbackURL' => $callbackURL,
'defaultForAppCalls' => $defaultForAppCalls,
'sandbox' => $sandbox,
Expand All @@ -222,6 +224,12 @@ public function update(
],
);

// agent_id carries meaning as an explicit null (unbind), so it skips removeNulls()
// entirely instead of being stripped alongside every other unset field above.
if (!($agentID instanceof Omit)) {
$params['agentID'] = $agentID;
}

// @phpstan-ignore-next-line argument.type
$response = $this->raw->update($number, params: $params, requestOptions: $requestOptions);

Expand Down
59 changes: 59 additions & 0 deletions tests/Services/Channels/VoiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace Tests\Services\Channels;

use Http\Discovery\Psr17FactoryDiscovery;
use Http\Mock\Client as MockTransporter;
use PHPUnit\Framework\Attributes\CoversNothing;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
Expand Down Expand Up @@ -71,6 +73,63 @@ public function testUpdate(): void
$this->assertInstanceOf(APIResponseOfVoiceNumber::class, $result);
}

#[Test]
public function testUpdateSendsExplicitNullToUnbindAgent(): void
{
$transporter = new MockTransporter;
$transporter->setDefaultResponse(
Psr17FactoryDiscovery::findResponseFactory()
->createResponse()
->withStatus(200)
->withHeader('Content-Type', 'application/json')
->withBody(Psr17FactoryDiscovery::findStreamFactory()->createStream(
json_encode(['data' => []], flags: Util::JSON_ENCODE_FLAGS) ?: ''
))
);

$client = new Client(
baseUrl: 'http://localhost',
apiKey: 'My API Key',
requestOptions: ['transporter' => $transporter],
);

$client->channels->voice->update('+12125550100', agentID: null);

$this->assertNotFalse($requested = $transporter->getRequests()[0] ?? false);
/** @var array<string, mixed> $sent */
$sent = json_decode((string) $requested->getBody(), true);
$this->assertArrayHasKey('agent_id', $sent);
$this->assertNull($sent['agent_id']);
}

#[Test]
public function testUpdateOmitsAgentIdWhenNotPassed(): void
{
$transporter = new MockTransporter;
$transporter->setDefaultResponse(
Psr17FactoryDiscovery::findResponseFactory()
->createResponse()
->withStatus(200)
->withHeader('Content-Type', 'application/json')
->withBody(Psr17FactoryDiscovery::findStreamFactory()->createStream(
json_encode(['data' => []], flags: Util::JSON_ENCODE_FLAGS) ?: ''
))
);

$client = new Client(
baseUrl: 'http://localhost',
apiKey: 'My API Key',
requestOptions: ['transporter' => $transporter],
);

$client->channels->voice->update('+12125550100', status: 'ACTIVE');

$this->assertNotFalse($requested = $transporter->getRequests()[0] ?? false);
/** @var array<string, mixed> $sent */
$sent = json_decode((string) $requested->getBody(), true);
$this->assertArrayNotHasKey('agent_id', $sent);
}

#[Test]
public function testList(): void
{
Expand Down