diff --git a/src/Client.php b/src/Client.php index 77036f4..e8140c9 100644 --- a/src/Client.php +++ b/src/Client.php @@ -18,26 +18,24 @@ */ class Client extends ResourceCollection { - const BASE_URL = 'https://rest.nexmo.com/'; - - /** - * @var string - */ - private $apiKey; - /** - * @var string + * @var array */ - private $apiSecret; + private $options = array( + 'apiKey' => null, + 'apiSecret' => null, + 'baseURL' => 'https://rest.nexmo.com/', + 'debug' => false, + 'timeout' => 5.0, + ); /** - * @param string $apiKey - * @param string $apiSecret + * @param array $options */ - public function __construct($apiKey, $apiSecret) + public function __construct(Array $options = []) { - $this->apiKey = $apiKey; - $this->apiSecret = $apiSecret; + // Override default options with options provided during instantiation. + $this->options = array_merge($this->options, $options); } protected function getNamespace() @@ -57,11 +55,13 @@ protected function loadClient() return; } $this->client = new HttpClient([ - 'base_url' => static::BASE_URL, + 'base_url' => $this->options['baseURL'], 'defaults' => [ + 'timeout' => $this->options['timeout'], + 'debug' => $this->options['debug'], 'query' => [ - 'api_key' => $this->apiKey, - 'api_secret' => $this->apiSecret + 'api_key' => $this->options['apiKey'], + 'api_secret' => $this->options['apiSecret'] ] ] ]); diff --git a/src/RateLimitSubscriber.php b/src/RateLimitSubscriber.php new file mode 100644 index 0000000..20cd9c1 --- /dev/null +++ b/src/RateLimitSubscriber.php @@ -0,0 +1,81 @@ + US/Canada routing at 1/sec, let's be safe and use that as default. + */ + private $maxRequestsPerSec = 1; + + /** + * @param string $timeKey + * LVN/shortcode when using the SMS or Voice API. Left as a blank string for the Developer API. + */ + private $timeKey = ''; + + /** + * @param int $rate + */ + public function setRate($rate) + { + $this->maxRequestsPerSec = $rate; + } + + /** + * @param string $key + */ + public function setKey($key) + { + $this->timeKey = $key; + } + + /** + * @return array + */ + public function getEvents() + { + return [ + 'before' => ['onBefore'], + 'complete' => ['onComplete'], + ]; + } + + /** + * @param BeforeEvent $event + */ + public function onBefore(BeforeEvent $event) + { + self::$time[$this->timeKey] = microtime(true); + } + + /** + * @param CompleteEvent $event + */ + public function onComplete(CompleteEvent $event) + { + // Convert requests-per-second to a minimum duration in seconds. + $min_request_duration_sec = 1 / $this->maxRequestsPerSec; + + // Calculate the duration of the previous request in seconds. + $elapsed_sec = microtime(true) - self::$time[$this->timeKey]; + + // If the duration is greater than the minimum duration, for this LVN, we must delay. + if ($elapsed_sec < $min_request_duration_sec) { + $min_request_duration_microsec = ($min_request_duration_sec - $elapsed_sec) * 1000000; + usleep($min_request_duration_microsec); + } + } +} \ No newline at end of file diff --git a/src/Service/Account/Balance.php b/src/Service/Account/Balance.php index 5156e03..f20b039 100644 --- a/src/Service/Account/Balance.php +++ b/src/Service/Account/Balance.php @@ -12,6 +12,15 @@ */ class Balance 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 */ diff --git a/src/Service/Account/Numbers.php b/src/Service/Account/Numbers.php index 8a86bc9..ee3214f 100644 --- a/src/Service/Account/Numbers.php +++ b/src/Service/Account/Numbers.php @@ -14,6 +14,15 @@ */ class Numbers 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 */ @@ -51,6 +60,10 @@ protected function validateResponse(array $json) if (!isset($json['count'])) { throw new Exception('count property expected'); } + if (0 == $json['count']) { + // If there are no numbers on the account, stop validating. + return; + } if (!isset($json['numbers']) || !is_array($json['numbers'])) { throw new Exception('numbers array property expected'); } @@ -67,9 +80,6 @@ protected function validateResponse(array $json) if (!isset($number['features']) || !is_array($number['features'])) { throw new Exception('number.features array property expected'); } - if (!isset($number['moHttpUrl'])) { - throw new Exception('number.moHttpUrl property expected'); - } } } } diff --git a/src/Service/Account/Pricing/Country.php b/src/Service/Account/Pricing/Country.php index d88ebb1..77bdf65 100644 --- a/src/Service/Account/Pricing/Country.php +++ b/src/Service/Account/Pricing/Country.php @@ -13,6 +13,15 @@ */ class Country extends Service { + /** + * @inheritdoc + */ + public function getRateLimit() + { + // Max number of requests per second. Nexmo developer API permits 3/sec max. + return 2; + } + /** * @inheritdoc */ @@ -34,7 +43,8 @@ public function invoke($country = null) } return new Entity\Pricing($this->exec([ - 'country' => $country, + // Nexmo API requires $country value to be uppercase. + 'country' => strtoupper($country), ])); } @@ -43,6 +53,10 @@ public function invoke($country = null) */ protected function validateResponse(array $json) { + if (!isset($json['mt'])) { + // Some countries don't have any values, e.g., BV. + return; + } if (!isset($json['country'])) { throw new Exception('country property expected'); } diff --git a/src/Service/Account/Pricing/International.php b/src/Service/Account/Pricing/International.php index dac7716..faea2ed 100644 --- a/src/Service/Account/Pricing/International.php +++ b/src/Service/Account/Pricing/International.php @@ -12,6 +12,15 @@ */ class International extends Country { + /** + * @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 */ diff --git a/src/Service/Account/Pricing/Phone.php b/src/Service/Account/Pricing/Phone.php index 9137d1d..88148ad 100644 --- a/src/Service/Account/Pricing/Phone.php +++ b/src/Service/Account/Pricing/Phone.php @@ -13,6 +13,15 @@ */ class Phone 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; + } + /** * @var string */ diff --git a/src/Service/Message.php b/src/Service/Message.php index c724c15..49da864 100644 --- a/src/Service/Message.php +++ b/src/Service/Message.php @@ -11,6 +11,20 @@ */ class Message extends Service { + /** + * @inheritdoc + */ + public function getRateLimit($params = null) + { + if (preg_match('/^1\d{10}$/', $params['from']) && preg_match('/^1\d{10}$/', $params['to'])) { + // SMS from US/Canadian LVN to US/Canadian recipient has a 1/sec rate limit. + // https://help.nexmo.com/hc/en-us/articles/203993598 + return 1; + } + // For all others, Nexmo Voice API has a 30/sec rate limit. + return 30; + } + /** * @return string */ @@ -83,7 +97,8 @@ public function invoke( throw new Exception("\$text parameter cannot be blank"); } - if ($type === 'text' && $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'; } @@ -104,9 +119,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/ResourceCollection.php b/src/Service/ResourceCollection.php index 82a184e..c59d292 100644 --- a/src/Service/ResourceCollection.php +++ b/src/Service/ResourceCollection.php @@ -39,6 +39,7 @@ public function __get($name) } $cls = $this->initializeClass($clsName); $cls->setClient($this->client); + $cls->rateLimitSubscriber = new \Nexmo\RateLimitSubscriber(); $this->resources[$name] = $cls; } diff --git a/src/Service/Service.php b/src/Service/Service.php index a9013b0..4576ccc 100644 --- a/src/Service/Service.php +++ b/src/Service/Service.php @@ -11,6 +11,11 @@ */ abstract class Service extends Resource { + /** + * @return int + */ + abstract public function getRateLimit(); + /** * @return string */ @@ -32,13 +37,24 @@ 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(), [ + // Configure the RateLimitSubscriber for this request. + $this->rateLimitSubscriber->setRate($this->getRateLimit($params)); + $this->rateLimitSubscriber->setKey(isset($params['from']) ? $params['from'] : ''); + + // Attach the RateLimitSubscriber with these settings. + $this->client->getEmitter()->attach($this->rateLimitSubscriber); + + // Send the request using the specified method, endpoint and query params. + $response = $this->client->send($this->client->createRequest($method, $this->getEndpoint(), [ 'query' => $params - ]); + ])); + + // Must remove the RateLimitSubscriber after each request. + $this->client->getEmitter()->detach($this->rateLimitSubscriber); try { $json = $response->json(); @@ -46,7 +62,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..6307c93 100644 --- a/src/Service/Verify.php +++ b/src/Service/Verify.php @@ -11,6 +11,15 @@ */ class Verify 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; + } + /** * @var VerifyCheck */ diff --git a/src/Service/VerifyCheck.php b/src/Service/VerifyCheck.php index faf3574..a1d1546 100644 --- a/src/Service/VerifyCheck.php +++ b/src/Service/VerifyCheck.php @@ -10,6 +10,15 @@ */ class VerifyCheck 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; + } + /** * @return string diff --git a/src/Service/Voice.php b/src/Service/Voice.php index 7839fe2..bbb9215 100644 --- a/src/Service/Voice.php +++ b/src/Service/Voice.php @@ -10,6 +10,15 @@ */ class Voice extends Service { + /** + * @inheritdoc + */ + public function getRateLimit() + { + // Max number of requests per second. Nexmo Voice API has a 30/sec rate limit. + return 30; + } + /** * @return string */