From 1bc5eedce9078dfe4449c0a08d35d33af119d699 Mon Sep 17 00:00:00 2001 From: Quinn Comendant Date: Sat, 29 Aug 2015 22:49:04 -0500 Subject: [PATCH 1/9] Updated Client so the constructor accepts an array of options which override default options. This allows additional configuration of the HttpClient (e.g., to change 'base_url' to sandbox URL, 'debug', and 'timeout'). --- src/Client.php | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) 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'] ] ] ]); From f699311cfa9e726e2eaf3d024e0445c0dabdf1b1 Mon Sep 17 00:00:00 2001 From: Quinn Comendant Date: Sat, 29 Aug 2015 22:56:31 -0500 Subject: [PATCH 2/9] Added Number/Buy, Number/Search and Number/Cancel services. Modified Service->exec() to allow specifying http method (these services require POST requests). --- src/Service/Service.php | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) 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; } From 17345f670ee6a7a6b01a535bd1315860bd6908e4 Mon Sep 17 00:00:00 2001 From: Quinn Comendant Date: Sat, 29 Aug 2015 23:01:02 -0500 Subject: [PATCH 3/9] Added Number/Buy, Number/Search and Number/Cancel services. (New files for these services forgot to be added during previous commit.) --- src/Service/Number.php | 40 +++++++++++++++++ src/Service/Number/Buy.php | 81 +++++++++++++++++++++++++++++++++++ src/Service/Number/Cancel.php | 81 +++++++++++++++++++++++++++++++++++ src/Service/Number/Search.php | 66 ++++++++++++++++++++++++++++ 4 files changed, 268 insertions(+) create mode 100644 src/Service/Number.php create mode 100644 src/Service/Number/Buy.php create mode 100644 src/Service/Number/Cancel.php create mode 100644 src/Service/Number/Search.php 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'); + } + } +} From e44d8f440055053ac6aa57b7a54c645d19da7ffb Mon Sep 17 00:00:00 2001 From: Quinn Comendant Date: Sat, 29 Aug 2015 22:56:31 -0500 Subject: [PATCH 4/9] Added Number/Buy, Number/Search and Number/Cancel services. Modified Service->exec() to allow specifying http method (these services require POST requests). --- src/Service/Service.php | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) 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; } From 05fdead0bbad60b65714f6bc27a8878d3718dbbe Mon Sep 17 00:00:00 2001 From: Quinn Comendant Date: Sat, 29 Aug 2015 23:01:02 -0500 Subject: [PATCH 5/9] Added Number/Buy, Number/Search and Number/Cancel services. (New files for these services forgot to be added during previous commit.) --- src/Service/Number.php | 40 +++++++++++++++++ src/Service/Number/Buy.php | 81 +++++++++++++++++++++++++++++++++++ src/Service/Number/Cancel.php | 81 +++++++++++++++++++++++++++++++++++ src/Service/Number/Search.php | 66 ++++++++++++++++++++++++++++ 4 files changed, 268 insertions(+) create mode 100644 src/Service/Number.php create mode 100644 src/Service/Number/Buy.php create mode 100644 src/Service/Number/Cancel.php create mode 100644 src/Service/Number/Search.php 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'); + } + } +} From 3f821fd521ff4f8223171d62c3c2d4af7f5be701 Mon Sep 17 00:00:00 2001 From: Quinn Comendant Date: Sat, 29 Aug 2015 23:50:42 -0500 Subject: [PATCH 6/9] Added rate limiting to prevent submitting requests to the Nexmo APIs too quickly. Nexmo will reject requests if they are submitted too quickly with a "429 Too Many Requests" error. The limit for the Developer API is 3 requests per second, and the SMS and Voice APIs limit at 30 requests per second (except US/Canada, it's 1 request per second). Details at https://help.nexmo.com/hc/en-us/articles/203993598 This commit includes changes to use a RateLimitSubscriber, which is a native hook for the Guzzle HTTP client. It times the previous request, and if it is faster than the minimum to stay below the rate limit, it will sleep long enough so the second request does not violate the rate limit. SMS and Voice requests are timed on a per-LVN basis so the delay will only occur when sending from the same LVN too quickly would trigger the limit, but sending from different LVNs will still be fast. Each service class is given a getRateLimit() method which returns the limit for that specific API endpoint. --- src/RateLimitSubscriber.php | 83 +++++++++++++++++++ src/Service/Account/Balance.php | 9 ++ src/Service/Account/Numbers.php | 9 ++ src/Service/Account/Pricing/Country.php | 9 ++ src/Service/Account/Pricing/International.php | 9 ++ src/Service/Account/Pricing/Phone.php | 9 ++ src/Service/Message.php | 14 ++++ src/Service/ResourceCollection.php | 1 + src/Service/Service.php | 27 +++++- src/Service/Verify.php | 9 ++ src/Service/VerifyCheck.php | 9 ++ src/Service/Voice.php | 9 ++ 12 files changed, 193 insertions(+), 4 deletions(-) create mode 100644 src/RateLimitSubscriber.php diff --git a/src/RateLimitSubscriber.php b/src/RateLimitSubscriber.php new file mode 100644 index 0000000..251b50d --- /dev/null +++ b/src/RateLimitSubscriber.php @@ -0,0 +1,83 @@ + 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); + self::$counter[$this->timeKey] = isset(self::$counter[$this->timeKey]) ? self::$counter[$this->timeKey] + 1 : 1; + } + + /** + * @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..4c03773 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 */ diff --git a/src/Service/Account/Pricing/Country.php b/src/Service/Account/Pricing/Country.php index d88ebb1..162ab3d 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 */ 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..16225e8 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 */ 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 */ From e8b1ac649cc8984f03bb07ea3848343240d51bcf Mon Sep 17 00:00:00 2001 From: Quinn Comendant Date: Sun, 30 Aug 2015 00:15:35 -0500 Subject: [PATCH 7/9] Updated the Message->containsUnicode() method to detect characters which are outside the GSM default character set. Nexmo claims to be able to support the GSM 03.38 Basic Character Set in text (not unicode) messages. Here, I've changed this method to test specifically for these. The old method fails because many of these characters are outside the normal ASCII range, and because the characters cannot be converted safely using ord() and str_split(). --- src/Service/Message.php | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/src/Service/Message.php b/src/Service/Message.php index c724c15..ab00ebc 100644 --- a/src/Service/Message.php +++ b/src/Service/Message.php @@ -83,7 +83,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 +105,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; } /** From c20fc28d1089aa22761a9e3b3218c0164c41d8b5 Mon Sep 17 00:00:00 2001 From: Quinn Comendant Date: Sun, 30 Aug 2015 16:05:49 -0500 Subject: [PATCH 8/9] Nexmo's API is inconsitent when dealing with edge cases. I've had to relax the validation to avoid throwing exceptions during perfectly valid API responses. * In Account/Numbers.php we end validation if the count is zero, which can occur if the account has no numbers attached. Also, removed the validation for moHttpUrl because this value may not exist if a number has been added without one (in which case the account's default moHttpUrl will be used). * In Account/Pricing/Country.php the country parameter should always be uppercase (Nexmo will error if it is not). Also, some countries to not have a 'mt' price value set (e.g., BV) so we can't freak out if it is missing. --- src/Service/Account/Numbers.php | 7 ++++--- src/Service/Account/Pricing/Country.php | 7 ++++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/Service/Account/Numbers.php b/src/Service/Account/Numbers.php index 8a86bc9..93acfe1 100644 --- a/src/Service/Account/Numbers.php +++ b/src/Service/Account/Numbers.php @@ -51,6 +51,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 +71,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..1fa60df 100644 --- a/src/Service/Account/Pricing/Country.php +++ b/src/Service/Account/Pricing/Country.php @@ -34,7 +34,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 +44,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'); } From 5436db6631ce3237c16e67d4adba4832e7693e1f Mon Sep 17 00:00:00 2001 From: Quinn Comendant Date: Thu, 17 Nov 2016 12:12:10 -0600 Subject: [PATCH 9/9] no message --- src/RateLimitSubscriber.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/RateLimitSubscriber.php b/src/RateLimitSubscriber.php index 251b50d..20cd9c1 100644 --- a/src/RateLimitSubscriber.php +++ b/src/RateLimitSubscriber.php @@ -13,7 +13,6 @@ class RateLimitSubscriber implements SubscriberInterface * (or better, send from each LVN in parallel, concurrent threads). */ private static $time = []; - private static $counter = []; /** * @param float $max_requests_per_sec @@ -60,7 +59,6 @@ public function getEvents() public function onBefore(BeforeEvent $event) { self::$time[$this->timeKey] = microtime(true); - self::$counter[$this->timeKey] = isset(self::$counter[$this->timeKey]) ? self::$counter[$this->timeKey] + 1 : 1; } /**