diff --git a/README.md b/README.md index c544360..9bee9cc 100644 --- a/README.md +++ b/README.md @@ -5,5 +5,133 @@ Unofficial [Nexmo](https://www.nexmo.com/) Rest Client [Nexmo API Documentation](https://docs.nexmo.com/) ## How to Install ```composer require connect-corp/nexmo-client``` + +## Usage examples + +### Setting up the client object + + $nexmo_client_options = array( + 'apiKey' => '…', + 'apiSecret' => '…', + 'debug' => false, + 'timeout' => 5.0, + ); + $nexmo = new \Nexmo\Client($nexmo_client_options); + +### Sending a message + + $from = '1234567890'; + $to = '15551232020'; + $text = 'hello world'; + try { + $response = $nexmo->message->invoke($from, $to, 'text', $text); + } catch (Exception $e) { + die($e->getMessage()); + } + foreach ($response['messages'] as $i => $m) { + switch ($m['status']) { + case '0': + echo 'Message sent successfully:'; + print_r($m); + break; + + default: + echo 'Message sending failed:' + print_r($m); + break; + } + } + +### Getting account balance + + try { + $response = $nexmo->account->balance(); + } catch (Exception $e) { + die($e->getMessage()); + } + echo "Account balance is $response"; + +### Getting pricing by destination country + + $country = 'US'; + try { + $response = $nexmo->account->pricing->country($country); + } catch (Exception $e) { + die($e->getMessage()); + } + echo 'Price is ' . $response->price(); + +### Getting pricing by recipient number + + $number = '15551232020'; + try { + // SMS pricing. + $response = $nexmo->account->pricing->sms($number); + // Voice pricing. + $response = $nexmo->account->pricing->voice($number); + } catch (Exception $e) { + die($e->getMessage()); + } + echo 'Price is ' . $response->price(); + + +### Search for long virtual numbers by country + + $country = 'US'; + try { + $response = $nexmo->number->search($country); + } catch (Exception $e) { + die($e->getMessage()); + } + $all = $response->all(); + if (isset($all['numbers'])) { + foreach ($all['numbers'] as $n) { + printf("%d \$%01.2f %-10s %-15s\n", $n['msisdn'], $n['cost'], $n['type'], join(',', $n['features'])); + } + } + +### Buy a long virtual number + + $country = 'US'; + $msisdn = '1234567890'; // Number found using $nexmo->number->search() + try { + $response = $nexmo->number->buy($country, $msisdn); + } catch (Exception $e) { + die($e->getMessage()); + } + if (200 == $response['error-code']) { + echo 'Number purchase success'; + } + +### List long virtual numbers in your account + + $country = 'US'; + try { + $response = $nexmo->account->numbers(); + } catch (Exception $e) { + die($e->getMessage()); + } + $all = $response->all(); + if (isset($all['numbers'])) { + foreach ($all['numbers'] as $n) { + printf("%d %-2s %-10s %-15s\n", $n['msisdn'], $n['country'], $n['type'], join(',', $n['features'])); + } + } + +### Cancel a long virtual number + + $country = 'US'; + $msisdn = '1234567890'; // Number found using $nexmo->account->numbers() + try { + $response = $nexmo->number->cancel($country, $msisdn); + } catch (Exception $e) { + die($e->getMessage()); + } + if (200 == $response['error-code']) { + echo 'Number cancel success'; + } + ## Contributors - @CarsonF +- @com + diff --git a/src/Client.php b/src/Client.php index 77036f4..956c076 100644 --- a/src/Client.php +++ b/src/Client.php @@ -9,10 +9,11 @@ /** * Class Client * - * @property-read Service\Account $account Account management APIs - * @property-read Service\Message $message - * @property-read Service\Voice $voice - * @property-read Service\Verify $verify + * @property-read Service\Account $account Account management APIs + * @property-read Service\MarketingMessage $marketingmessage + * @property-read Service\Message $message + * @property-read Service\Voice $voice + * @property-read Service\Verify $verify * * @package Nexmo\Client */ diff --git a/src/Service/MarketingMessage.php b/src/Service/MarketingMessage.php new file mode 100644 index 0000000..efd9f50 --- /dev/null +++ b/src/Service/MarketingMessage.php @@ -0,0 +1,85 @@ +exec([ + 'from' => $from, + 'keyword' => $keyword, + 'to' => $to, + 'text' => $text + ]); + } + + protected function validateResponse(array $json) + { + if (!isset($json['message-count'])) { + throw new NexmoException('message-count property expected'); + } + + if (!isset($json['messages'])) { + throw new NexmoException('messages property expected'); + } + + foreach ($json['messages'] as $message) { + if (!isset($message['status'])) { + throw new NexmoException('status property expected'); + } + + if (!empty($message["error-text"])) { + throw new NexmoException("Unable to send sms message: " . $message["error-text"] . ' - status ' . $message['status']); + } + + if ($message['status'] > 0) { + throw new NexmoException("Unable to send sms message: status " . $message['status']); + } + } + + return true; + } +} diff --git a/src/Service/Message.php b/src/Service/Message.php index 6206e75..83192a5 100644 --- a/src/Service/Message.php +++ b/src/Service/Message.php @@ -81,7 +81,8 @@ public function invoke( throw new Exception("\$text parameter cannot be blank"); } - if ($this->containsUnicode($text)) { + // If $type is empty, 'text' will be assumed by Nexmo's SMS API. + if (($type == '' || $type === 'text') && $this->containsUnicode($text)) { $type = 'unicode'; } @@ -102,9 +103,38 @@ public function invoke( ]); } + /** + * @param string $text + * @return int + */ protected function containsUnicode($text) { - return max(array_map('ord', str_split($text))) > 127; + // Valid GSM default character-set codepoint values from http://unicode.org/Public/MAPPINGS/ETSI/GSM0338.TXT + $gsm_0338_codepoints = [0x0040, 0x00A3, 0x0024, 0x00A5, 0x00E8, 0x00E9, 0x00F9, 0x00EC, 0x00F2, 0x00E7, 0x000A, 0x00D8, 0x00F8, 0x000D, 0x00C5, 0x00E5, 0x0394, 0x005F, 0x03A6, 0x0393, 0x039B, 0x03A9, 0x03A0, 0x03A8, 0x03A3, 0x0398, 0x039E, 0x00A0, 0x000C, 0x005E, 0x007B, 0x007D, 0x005C, 0x005B, 0x007E, 0x005D, 0x007C, 0x20AC, 0x00C6, 0x00E6, 0x00DF, 0x00C9, 0x0020, 0x0021, 0x0022, 0x0023, 0x00A4, 0x0025, 0x0026, 0x0027, 0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F, 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, 0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F, 0x00A1, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047, 0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F, 0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057, 0x0058, 0x0059, 0x005A, 0x00C4, 0x00D6, 0x00D1, 0x00DC, 0x00A7, 0x00BF, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067, 0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F, 0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077, 0x0078, 0x0079, 0x007A, 0x00E4, 0x00F6, 0x00F1, 0x00FC, 0x00E0]; + + // Split $text into an array in a way that respects multibyte characters. + $text_chars = preg_split('//u', $text, null, PREG_SPLIT_NO_EMPTY); + + // Array of codepoint values for characters in $text. + $text_codepoints = array_map([$this, 'uord'], $text_chars); + + // Filter the array to contain only codepoints from $text that are not in the set of valid GSM codepoints. + $non_gsm_codepoints = array_diff($text_codepoints, $gsm_0338_codepoints); + + // The text contains unicode if the result is not empty. + return !empty($non_gsm_codepoints); + } + + /** + * @param char $unicode_char + * @return int + */ + public function uord($unicode_char) + { + $k = mb_convert_encoding($unicode_char, 'UCS-2LE', 'UTF-8'); + $k1 = ord(substr($k, 0, 1)); + $k2 = ord(substr($k, 1, 1)); + return $k2 * 256 + $k1; } /** diff --git a/src/Service/Number.php b/src/Service/Number.php new file mode 100644 index 0000000..2912972 --- /dev/null +++ b/src/Service/Number.php @@ -0,0 +1,40 @@ +search->invoke($country, $index, $size, $pattern, $searchPattern, $features); + } + + /** + * @return array + * @throws Exception + */ + public function buy($country, $msisdn) + { + return $this->buy->invoke($country, $msisdn); + } + /** + * @return array + * @throws Exception + */ + public function cancel($country, $msisdn) + { + return $this->cancel->invoke($country, $msisdn); + } +} diff --git a/src/Service/Number/Buy.php b/src/Service/Number/Buy.php new file mode 100644 index 0000000..ad9b06f --- /dev/null +++ b/src/Service/Number/Buy.php @@ -0,0 +1,81 @@ + + */ +class Buy extends Service +{ + /** + * @inheritdoc + */ + public function getRateLimit() + { + // Max number of requests per second. Nexmo developer API claims 3/sec max, but actually more than 2/sec causes error 429 Too Many Requests. + return 2; + } + + /** + * @inheritdoc + */ + public function getEndpoint() + { + return 'number/buy'; + } + + /** + * @param string $country + * @param string $msisdn + * + * @return boolean + * @throws Exception + */ + public function invoke($country = null, $msisdn = null) + { + if (!$country) { + throw new Exception("\$country parameter cannot be blank"); + } + if (!$msisdn) { + throw new Exception("\$msisdn parameter cannot be blank"); + } + + return $this->exec([ + // Nexmo API requires $country value to be uppercase. + 'country' => strtoupper($country), + 'msisdn' => $msisdn, + ], 'POST'); + } + + + /** + * @inheritdoc + */ + protected function validateResponse(array $json) + { + if (!isset($json['error-code'])) { + throw new Exception('no error code'); + } + + switch ($json['error-code']) { + case '200': + return true; + + case '401': + throw new Exception('error 401 wrong credentials'); + + case '420': + throw new Exception('error 420 wrong parameters'); + + default: + throw new Exception('unknown error code'); + } + } +} diff --git a/src/Service/Number/Cancel.php b/src/Service/Number/Cancel.php new file mode 100644 index 0000000..96c7cb3 --- /dev/null +++ b/src/Service/Number/Cancel.php @@ -0,0 +1,81 @@ + + */ +class Cancel extends Service +{ + /** + * @inheritdoc + */ + public function getRateLimit() + { + // Max number of requests per second. Nexmo developer API claims 3/sec max, but actually more than 2/sec causes error 429 Too Many Requests. + return 2; + } + + /** + * @inheritdoc + */ + public function getEndpoint() + { + return 'number/cancel'; + } + + /** + * @param string $country + * @param string $msisdn + * + * @return boolean + * @throws Exception + */ + public function invoke($country = null, $msisdn = null) + { + if (!$country) { + throw new Exception("\$country parameter cannot be blank"); + } + if (!$msisdn) { + throw new Exception("\$msisdn parameter cannot be blank"); + } + + return $this->exec([ + // Nexmo API requires $country value to be uppercase. + 'country' => strtoupper($country), + 'msisdn' => $msisdn, + ], 'POST'); + } + + + /** + * @inheritdoc + */ + protected function validateResponse(array $json) + { + if (!isset($json['error-code'])) { + throw new Exception('no error code'); + } + + switch ($json['error-code']) { + case '200': + return true; + + case '401': + throw new Exception('error 401 wrong credentials'); + + case '420': + throw new Exception('error 420 wrong parameters'); + + default: + throw new Exception('unknown error code'); + } + } +} diff --git a/src/Service/Number/Search.php b/src/Service/Number/Search.php new file mode 100644 index 0000000..4282936 --- /dev/null +++ b/src/Service/Number/Search.php @@ -0,0 +1,66 @@ +exec([ + // Nexmo API requires $country value to be uppercase. + 'country' => strtoupper($country), + 'index' => $index, + 'size' => $size, + 'pattern' => $pattern, + 'search_pattern' => $searchPattern, + 'features' => $features, + ])); + } + + /** + * @inheritdoc + */ + protected function validateResponse(array $json) + { + // If the 'numbers' element exists (which it won't if no numbers are available for a search), validate it is an array. + if (isset($json['numbers']) && !is_array($json['numbers'])) { + throw new Exception('numbers property not an array'); + } + } +} diff --git a/src/Service/Service.php b/src/Service/Service.php index a9013b0..4e80e01 100644 --- a/src/Service/Service.php +++ b/src/Service/Service.php @@ -32,13 +32,13 @@ abstract protected function validateResponse(array $json); * @throws Exception * @return array */ - protected function exec($params) + protected function exec($params, $method = 'GET') { $params = array_filter($params); - $response = $this->client->get($this->getEndpoint(), [ + $response = $this->client->send($this->client->createRequest($method, $this->getEndpoint(), [ 'query' => $params - ]); + ])); try { $json = $response->json(); @@ -46,7 +46,10 @@ protected function exec($params) throw new Exception($e->getMessage(), 0, $e); } - $this->validateResponse($json); + // Because validateResponse() expects an array, we can only do so if the response body is not empty (which in some cases is a valid response), otherwise $json will be null. + if (strlen($response->getBody()) > 0) { + $this->validateResponse($json); + } return $json; } diff --git a/src/Service/Verify.php b/src/Service/Verify.php index f7eeec1..05bba5a 100644 --- a/src/Service/Verify.php +++ b/src/Service/Verify.php @@ -77,15 +77,15 @@ protected function validateResponse(array $response) } if (!empty($response['error_text'])) { - throw new Exception('Unable to verify number: ' . $response['error_text'] . ' - status ' . $response['status']); + throw new Exception('Unable to verify number: ' . $response['error_text'] . ' - status ' . $response['status'], $response['status']); } if ($response['status'] > 0) { - throw new Exception('Unable to verify number: status ' . $response['status']); + throw new Exception('Unable to verify number: status ' . $response['status'], $response['status']); } if (!isset($response['request_id'])) { - throw new Exception('request_id property expected'); + throw new Exception('request_id property expected', $response['status']); } return true; diff --git a/src/Service/VerifyCheck.php b/src/Service/VerifyCheck.php index faf3574..2353491 100644 --- a/src/Service/VerifyCheck.php +++ b/src/Service/VerifyCheck.php @@ -55,11 +55,11 @@ protected function validateResponse(array $response) } if (!empty($response['error_text'])) { - throw new Exception('Unable to verify number: ' . $response['error_text'] . ' - status ' . $response['status']); + throw new Exception('Unable to verify number: ' . $response['error_text'] . ' - status ' . $response['status'], $response['status']); } if ($response['status'] > 0) { - throw new Exception('Unable to verify number: status ' . $response['status']); + throw new Exception('Unable to verify number: status ' . $response['status'], $response['status']); } return true; diff --git a/tests/Service/Account/NumbersTest.php b/tests/Service/Account/NumbersTest.php index a43b8f7..fb081e2 100644 --- a/tests/Service/Account/NumbersTest.php +++ b/tests/Service/Account/NumbersTest.php @@ -141,7 +141,7 @@ class NumbersMock extends Numbers { public $executedParams; - protected function exec($params) + protected function exec($params, $method = 'GET') { $this->executedParams = $params; return parent::exec($params); diff --git a/tests/Service/Account/Pricing/CountryTest.php b/tests/Service/Account/Pricing/CountryTest.php index da613c1..17c2c2e 100644 --- a/tests/Service/Account/Pricing/CountryTest.php +++ b/tests/Service/Account/Pricing/CountryTest.php @@ -123,7 +123,7 @@ class CountryMock extends Country { public $executedParams; - protected function exec($params) + protected function exec($params, $method = 'GET') { $this->executedParams = $params; return parent::exec($params); diff --git a/tests/Service/Account/Pricing/InternationalTest.php b/tests/Service/Account/Pricing/InternationalTest.php index 1ee5c99..7fd6964 100644 --- a/tests/Service/Account/Pricing/InternationalTest.php +++ b/tests/Service/Account/Pricing/InternationalTest.php @@ -96,7 +96,7 @@ class InternationalMock extends International { public $executedParams; - protected function exec($params) + protected function exec($params, $method = 'GET') { $this->executedParams = $params; return parent::exec($params); diff --git a/tests/Service/Account/Pricing/PhoneTest.php b/tests/Service/Account/Pricing/PhoneTest.php index 9a6f994..7f39bc4 100644 --- a/tests/Service/Account/Pricing/PhoneTest.php +++ b/tests/Service/Account/Pricing/PhoneTest.php @@ -100,7 +100,7 @@ class PhoneMock extends Phone { public $executedParams; - protected function exec($params) + protected function exec($params, $method = 'GET') { $this->executedParams = $params; return parent::exec($params); diff --git a/tests/Service/MarketingMessageTest.php b/tests/Service/MarketingMessageTest.php new file mode 100644 index 0000000..d5e133a --- /dev/null +++ b/tests/Service/MarketingMessageTest.php @@ -0,0 +1,86 @@ +service = new MarketingMessageMock(); + $this->service->setClient($this->guzzle()); + } + public function testInvoke() + { + $this->service->invoke(62687, 'test-keyword', 5005551111, 'test message'); + $this->assertSame($this->service->executedParams, [ + 'from' => 62687, + 'keyword' => 'test-keyword', + 'to' => 5005551111, + 'text' => 'test message' + ]); + } + public function testGetEndpoint() + { + $this->assertEquals($this->service->getEndpoint(), 'sc/us/marketing/json'); + } + public function testFromParameterRequired() + { + $this->setExpectedException('\Nexmo\Exception', '$from parameter cannot be blank'); + $this->service->invoke(); + } + public function testKeywordParameterRequired() + { + $this->setExpectedException('\Nexmo\Exception', '$keyword parameter cannot be blank'); + $this->service->invoke(62687); + } + public function testToParameterRequired() + { + $this->setExpectedException('\Nexmo\Exception', '$to parameter cannot be blank'); + $this->service->invoke(62687, 'test-keyword'); + } + public function testTextParameterRequired() + { + $this->setExpectedException('\Nexmo\Exception', '$text parameter cannot be blank'); + $this->service->invoke(62687, 'test-keyword', 5005551111); + } + public function testValidateResponseMessageCountProperty() + { + $this->setExpectedException('\Nexmo\Exception', 'message-count property expected'); + $this->service->testValidateResponse([]); + } + public function testValidateResponseMessagesProperty() + { + $this->setExpectedException('\Nexmo\Exception', 'messages property expected'); + $this->service->testValidateResponse(['message-count' => 1]); + } + public function testValidateResponseStatusProperty() + { + $this->setExpectedException('\Nexmo\Exception', 'status property expected'); + $this->service->testValidateResponse(['message-count' => 1, 'messages' => ['error']]); + } + public function testValidateResponseStatusNotZero() + { + $this->setExpectedException('\Nexmo\Exception', 'Unable to send sms message: Unknown - status 1'); + $this->service->testValidateResponse(['message-count' => 1, 'messages' => [['error-text' => 'Unknown', 'status' => 1]]]); + } + public function testValidateResponseErrorText() + { + $this->setExpectedException('\Nexmo\Exception', 'Unable to send sms message: error - status 2'); + $this->service->testValidateResponse(['message-count' => 1, 'messages' => [['error-text' => 'error', 'status' => 2]]]); + } + public function testValidateResponseSuccess() + { + $this->assertTrue($this->service->testValidateResponse(['message-count' => 1, 'messages' => [['status' => 0]]])); + } +} +class MarketingMessageMock extends MarketingMessage +{ + use TestServiceTrait; +} diff --git a/tests/Service/MessageTest.php b/tests/Service/MessageTest.php index f684a2c..1e1c7b6 100644 --- a/tests/Service/MessageTest.php +++ b/tests/Service/MessageTest.php @@ -85,7 +85,7 @@ public function testValidateResponseStatusProperty() public function testValidateResponseStatusNotZero() { - $this->setExpectedException('\Nexmo\Exception', 'Unable to send sms message: status 1'); + $this->setExpectedException('\Nexmo\Exception', 'Unable to send sms message: Unknown - status code: 1'); $this->service->testValidateResponse(['message-count' => 1, 'messages' => [['status' => 1]]]); } @@ -93,7 +93,7 @@ public function testValidateResponseStatusNotZero() public function testValidateResponseErrorText() { - $this->setExpectedException('\Nexmo\Exception', 'Unable to send sms message: error - status 2'); + $this->setExpectedException('\Nexmo\Exception', 'Unable to send sms message: error - status code: 2'); $this->service->testValidateResponse(['message-count' => 1, 'messages' => [['error-text' => 'error', 'status' => 2]]]); } diff --git a/tests/Service/TestServiceTrait.php b/tests/Service/TestServiceTrait.php index e3112c5..cb930a5 100644 --- a/tests/Service/TestServiceTrait.php +++ b/tests/Service/TestServiceTrait.php @@ -11,7 +11,7 @@ public function testValidateResponse($params) return $this->validateResponse($params); } - protected function exec($params) + protected function exec($params, $method = 'GET') { $this->executedParams = $params; }