diff --git a/.docs/README.md b/.docs/README.md new file mode 100644 index 0000000..2eceb9b --- /dev/null +++ b/.docs/README.md @@ -0,0 +1,112 @@ +# GoSMS.cz Api Integration + +## Content + +- [Requirements - what do you need](#requirements) +- [Installation - how to register an extension](#installation) +- [Usage - how to use it](#usage) + + +## Requirements + +Create account on GoSMS.cz and copy clientId and clientSecret from administration. + +If you use default HTTP client, you need to install and register [guzzlette](https://github.com/contributte/guzzlette/) extension. + +Default AccessTokenSessionProvider uses [nette/http](https://github.com/nette/http) as its session handler; + +* **clientId** +* **clientSecret** +* **httpClient** +* **accessTokenProvider** + + +## Installation + +```yaml +extensions: + guzzlette: Contributte\Guzzlette\DI\GuzzleExtension # optional for default HTTP client + gosms: App\Model\GoSMS\DI\GoSmsExtension + +gosms: + # Required + clientId: 10185_2jz2pog5jtgkocs0oc0008kow8kkwsccsk8c8ogogggs44cskg + clientSecret: caajrzi80zs4cwgg8400swwo8wgc4kook0s8s48kw8s00sgws + + # Optional + httpClient: + accessTokenProvider: +``` + + +## Usage + +We prepared 2 clients: `AccountClient` and `MessageClient`. They mirror methods from [GoSMS.cz Api documentation](https://doc.gosms.cz/) so read documentation first. All methods except `send` return raw data as received from GoSMS.cz api. + +All methods throw ClientException with error message and code as response status when response status is not 200/201; + +### AccountClient + +* `detail()` - [Organization detail](https://doc.gosms.cz/#detail-organizace) + +### MessageClient + +* `send(Contributte\Gosms\Entity\Message)` - [Sends message](https://doc.gosms.cz/#jak-poslat-zpravu) + * Unfortunately GoSMS.cz does not include newly created message ID. We parse their response for you and include it in result object as `parsedId`. This id is needed by other methods. +* `test(Contributte\Gosms\Entity\Message)` - [Test creating message withou sending](https://doc.gosms.cz/#testovaci-vytvoreni-zpravy-bez-odeslani) +* `detail(string $id)` - [Sent message detail](https://doc.gosms.cz/#detail-zpravy) +* `replies(string $id)` - [List sent message replies](https://doc.gosms.cz/#seznam-odpovedi-u-zpravy) +* `delete(string $id)` - [Delete sent message](https://doc.gosms.cz/#smazani-zpravy) + + +```php +messageClient = $messageClient; + } + + public function handleSend(): void + { + $result = NULL; + $msg = new Message('Message body', ['+420711555444'], 1); + + try { + $result = $this->messageClient->send($msg); + } catch (ClientException $e) { + // Response status + $e->getCode(); + // Response body + $e->getMessage(); + exit; + } + + // Process successful result as you like + $this->saveSentMessage($result->parsedId, $msg); + } + +} + +``` + + +### AccessTokenProvider + +We have two build in AccessToken providers; + +* `AccessTokenClient` - fetches and stores accessToken for 1 request +* `AccessTokenSessionProvider` - fetches and stores accessToken in session until access token expires diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..575834a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +# EditorConfig is awesome: http://EditorConfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = tab +indent_size = tab +tab_width = 4 + +[{*.json,*.yml,*.md}] +indent_style = space +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c52e7b9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +# Not archived +.docs export-ignore +tests export-ignore +.editorconfig export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.travis.yml export-ignore +phpstan.neon export-ignore +README.md export-ignore +ruleset.xml export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e4b95ba --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# IDE +/.idea + +# Composer +/vendor +/composer.lock diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..8203d96 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,63 @@ +language: php +php: + - 7.2 + - 7.3 + +before_install: + # Turn off XDebug + - phpenv config-rm xdebug.ini || return 0 + +install: + # Composer + - travis_retry composer install --no-progress --prefer-dist + +script: + # Tests + - composer run-script tests + +after_failure: + # Print *.actual content + - for i in $(find tests -name \*.actual); do echo "--- $i"; cat $i; echo; echo; done + +jobs: + include: + - env: title="Lowest Dependencies 7.1" + php: 7.2 + install: + - travis_retry composer update --no-progress --prefer-dist --prefer-lowest + script: + - composer run-script tests + + - stage: Quality Assurance + php: 7.2 + script: + - composer run-script qa + + - stage: Phpstan + php: 7.2 + script: + - composer run-script phpstan + + - stage: Test Coverage + if: branch = master AND type = push + php: 7.2 + script: + - composer run-script coverage + after_script: + - wget https://github.com/php-coveralls/php-coveralls/releases/download/v2.1.0/php-coveralls.phar + - php php-coveralls.phar --verbose --config tests/.coveralls.yml + + - stage: Outdated Dependencies + if: branch = master AND type = cron + php: 7.2 + script: + - composer outdated --direct --strict + + allow_failures: + - stage: Test Coverage + +sudo: false + +cache: + directories: + - $HOME/.composer/cache diff --git a/README.md b/README.md new file mode 100644 index 0000000..940aba1 --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# GoSMS.cz Api Integration + +Easy-to-use [GoSMS.cz](https://gosms.cz) API integration for [`Nette Framework`](https://github.com/nette/). + +----- + +[![Build Status](https://img.shields.io/travis/contributte/gosms.svg?style=flat-square)](https://travis-ci.org/contributte/gosms) +[![Code coverage](https://img.shields.io/coveralls/contributte/gosms.svg?style=flat-square)](https://coveralls.io/r/contributte/gosms) +[![Licence](https://img.shields.io/packagist/l/contributte/gosms.svg?style=flat-square)](https://packagist.org/packages/contributte/gosms) +[![Downloads this Month](https://img.shields.io/packagist/dm/contributte/gosms.svg?style=flat-square)](https://packagist.org/packages/contributte/gosms) +[![Downloads total](https://img.shields.io/packagist/dt/contributte/gosms.svg?style=flat-square)](https://packagist.org/packages/contributte/gosms) +[![Latest stable](https://img.shields.io/packagist/v/contributte/gosms.svg?style=flat-square)](https://packagist.org/packages/contributte/gosms) +[![PHPStan](https://img.shields.io/badge/PHPStan-enabled-brightgreen.svg?style=flat)](https://github.com/phpstan/phpstan) + +## Discussion / Help + +[![Join the chat](https://img.shields.io/gitter/room/contributte/contributte.svg?style=flat-square)](http://bit.ly/ctteg) + +## Install + +``` +composer require contributte/gosms +``` + +## Versions + +| State | Version | Branch | PHP | +|-------------|---------|----------|----------| +| stable | `^0.1` | `master` | `>= 7.1` | + +## Overview + +- [Requirements - what do do you need](https://github.com/contributte/gosms/blob/master/.docs/README.md#requirements) +- [Installation - how to register an extension](https://github.com/contributte/gosms/blob/master/.docs/README.md#Installation) +- [Usage - how to use it](https://github.com/contributte/gosms/blob/master/.docs/README.md#usage) + +## Maintainers + + + + + + + + +
+ + + +
+ Milan Felix Šulc +
+ + + +
+ Filip Šuška +
+ +----- + +Thank you for testing, reporting and contributing. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..1bbd7ec --- /dev/null +++ b/composer.json @@ -0,0 +1,57 @@ +{ + "name": "contributte/gosms", + "description": "Full featured GoSMS.cz HTTP client for nette", + "keywords": ["contributte", "gosms", "nette", "sms"], + "type": "library", + "license": "MIT", + "homepage": "https://github.com/contributte/gosms", + "authors": [ + { + "name": "Filip Šuška" + } + ], + "require": { + "php": ">= 7.2", + "ext-json": "*", + "guzzlehttp/psr7": "^1.4", + "nette/utils": "~2.4 || ~3.0" + }, + "require-dev": { + "nette/di": "~2.4", + "contributte/guzzlette": "~2.1", + "nette/http": "~2.4.10", + "ninjify/qa": "^0.8.0", + "ninjify/nunjuck": "^0.2.0", + "mockery/mockery": "^1.1.0", + "phpstan/phpstan-shim": "^0.11", + "phpstan/phpstan-deprecation-rules": "^0.11", + "phpstan/phpstan-nette": "^0.11", + "phpstan/phpstan-strict-rules": "^0.11" + }, + "autoload": { + "psr-4": { + "Contributte\\Gosms\\": "src" + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "scripts": { + "qa": [ + "linter src tests", + "codesniffer src tests" + ], + "tests": [ + "tester -s -p php --colors 1 -C tests/cases" + ], + "coverage": [ + "tester -s -p phpdbg --colors 1 -C --coverage ./coverage.xml --coverage-src ./src tests/cases" + ], + "phpstan": [ + "vendor/bin/phpstan analyse -l max -c phpstan.neon src" + ] + }, + "suggest": { + "contributte/guzzlette": "As default HttpClient", + "nette/http": "As session handler in AccessTokenSessionProvider" + } +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..9e9adeb --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,5 @@ +includes: + - vendor/phpstan/phpstan-deprecation-rules/rules.neon + - vendor/phpstan/phpstan-nette/extension.neon + - vendor/phpstan/phpstan-nette/rules.neon + - vendor/phpstan/phpstan-strict-rules/rules.neon diff --git a/ruleset.xml b/ruleset.xml new file mode 100644 index 0000000..c5640ac --- /dev/null +++ b/ruleset.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + *tests/cases/* + + + + *tests/cases/* + + + + *tests/fixtures/* + + + + /tests/tmp + diff --git a/src/Auth/AccessTokenClient.php b/src/Auth/AccessTokenClient.php new file mode 100644 index 0000000..8cb10fc --- /dev/null +++ b/src/Auth/AccessTokenClient.php @@ -0,0 +1,61 @@ +client = $client; + } + + public function getAccessToken(Config $config): AccessToken + { + // Store AccessToken at least for one request + if ($this->accessToken === null || $this->accessToken->getExpiresAt()->modify('- 5 minutes') < new DateTimeImmutable()) { + $this->accessToken = $this->generateAccessToken($config); + } + + return $this->accessToken; + } + + protected function generateAccessToken(Config $config): AccessToken + { + $body = sprintf('client_id=%s&client_secret=%s&grant_type=client_credentials', $config->getClientId(), $config->getClientSecret()); + + $response = $this->client->sendRequest( + new Request('POST', self::URL, ['Content-Type' => 'application/x-www-form-urlencoded'], $body) + ); + + if ($response->getStatusCode() !== 200) { + throw new ClientException($response->getBody()->getContents(), $response->getStatusCode()); + } + + $data = Json::decode($response->getBody()->getContents()); + + return new AccessToken( + $data->access_token, + $data->expires_in, + $data->token_type, + $data->scope + ); + } + +} diff --git a/src/Auth/AccessTokenSessionProvider.php b/src/Auth/AccessTokenSessionProvider.php new file mode 100644 index 0000000..e967fa5 --- /dev/null +++ b/src/Auth/AccessTokenSessionProvider.php @@ -0,0 +1,75 @@ +session = $session; + } + + public function getAccessToken(Config $config): AccessToken + { + $token = $this->accessToken; + + // If we have it in session we retrieve it + if ($this->accessToken === null) { + $token = $this->accessToken = $this->getSessionAccessToken(); + } + + $this->accessToken = parent::getAccessToken($config); + + if ($token === null || $token->getAccessToken() !== $this->accessToken->getAccessToken()) { + $this->setSessionAccessToken($this->accessToken); + } + + return $this->accessToken; + } + + private function getSessionAccessToken(): ?AccessToken + { + if (!$this->session->exists()) return null; + + $section = $this->session->getSection(self::SESSION_NAME); + if (!isset($section['accessToken'])) return null; + + /** @var DateTimeImmutable $expiresAt */ + $expiresAt = DateTimeImmutable::createFromFormat(DateTimeImmutable::ATOM, $section['expiresAt']); + + return new AccessToken( + $section['accessToken'], + $section['expiresIn'], + $section['tokenType'], + $section['scope'], + $expiresAt + ); + } + + private function setSessionAccessToken(AccessToken $token): void + { + $section = $this->session->getSection(self::SESSION_NAME); + $section->setExpiration($token->getExpiresAt()); + + foreach ($token->toArray() as $k => $v) { + $section[$k] = $v instanceof DateTimeImmutable ? $v->format(DateTimeImmutable::ATOM) : $v; + } + } + +} diff --git a/src/Auth/IAccessTokenProvider.php b/src/Auth/IAccessTokenProvider.php new file mode 100644 index 0000000..e4ce9fc --- /dev/null +++ b/src/Auth/IAccessTokenProvider.php @@ -0,0 +1,13 @@ +client = $client; + $this->config = $config; + $this->accessTokenProvider = $accessTokenProvider; + } + + protected function doRequest(RequestInterface $request): ResponseInterface + { + $token = $this->accessTokenProvider->getAccessToken($this->config); + $request = $request->withHeader('Authorization', 'Bearer ' . $token->getAccessToken()); + + return $this->client->sendRequest($request); + } + + protected function assertResponse(ResponseInterface $response, int $code = 200): void + { + if ($response->getStatusCode() !== $code) { + throw new ClientException($response->getBody()->getContents(), $response->getStatusCode()); + } + } + +} diff --git a/src/Client/AccountClient.php b/src/Client/AccountClient.php new file mode 100644 index 0000000..3dda78b --- /dev/null +++ b/src/Client/AccountClient.php @@ -0,0 +1,20 @@ +doRequest(new Request('GET', self::BASE_URL . '/')); + + $this->assertResponse($response); + + return Json::decode($response->getBody()->getContents()); + } + +} diff --git a/src/Client/MessageClient.php b/src/Client/MessageClient.php new file mode 100644 index 0000000..f068cd7 --- /dev/null +++ b/src/Client/MessageClient.php @@ -0,0 +1,81 @@ +toArray()); + + $response = $this->doRequest( + new Request('POST', self::BASE_URL, ['Content-Type' => 'application/json'], $body) + ); + + $this->assertResponse($response, 201); + + $res = Json::decode($response->getBody()->getContents()); + $res->parsedId = str_replace('messages/', '', Strings::match($res->link, '~messages/\d+~')[0]); + + return $res; + } + + public function test(Message $message): object + { + $body = Json::encode($message->toArray()); + + $response = $this->doRequest( + new Request('POST', self::BASE_URL . '/test', ['Content-Type' => 'application/json'], $body) + ); + + $this->assertResponse($response); + + return Json::decode($response->getBody()->getContents()); + } + + public function detail(string $id): object + { + $url = sprintf('%s/%d', self::BASE_URL, $id); + + $response = $this->doRequest( + new Request('GET', $url) + ); + + $this->assertResponse($response); + + return Json::decode($response->getBody()->getContents()); + } + + public function replies(string $id): object + { + $url = sprintf('%s/%d/replies', self::BASE_URL, $id); + + $response = $this->doRequest( + new Request('GET', $url) + ); + + $this->assertResponse($response); + + return Json::decode($response->getBody()->getContents()); + } + + public function delete(string $id): void + { + $url = sprintf('%s/%d', self::BASE_URL, $id); + + $response = $this->doRequest( + new Request('DELETE', $url) + ); + + $this->assertResponse($response); + } + +} diff --git a/src/Config.php b/src/Config.php new file mode 100644 index 0000000..779a57e --- /dev/null +++ b/src/Config.php @@ -0,0 +1,30 @@ +clientId = $clientId; + $this->clientSecret = $clientSecret; + } + + public function getClientId(): string + { + return $this->clientId; + } + + public function getClientSecret(): string + { + return $this->clientSecret; + } + +} diff --git a/src/DI/GoSmsExtension.php b/src/DI/GoSmsExtension.php new file mode 100644 index 0000000..c989301 --- /dev/null +++ b/src/DI/GoSmsExtension.php @@ -0,0 +1,58 @@ + null, + 'clientSecret' => null, + 'httpClient' => GuzzletteClient::class, + 'accessTokenProvider' => AccessTokenSessionProvider::class, + ]; + + public function loadConfiguration(): void + { + $config = $this->validateConfig($this->defaults); + $builder = $this->getContainerBuilder(); + + Validators::assertField($config, 'clientId', 'string|number'); + Validators::assertField($config, 'clientSecret', 'string'); + Validators::assertField($config, 'httpClient', 'string'); + Validators::assertField($config, 'accessTokenProvider', 'string'); + + $configStatement = new Statement(Config::class, [ + $config['clientId'], + $config['clientSecret'], + ]); + + // HttpClient + $hc = $builder->addDefinition($this->prefix('httpClient')); + Compiler::loadDefinition($hc, $config['httpClient']); + + // AccessTokenProvider + $atp = $builder->addDefinition($this->prefix('accessTokenProvider')); + Compiler::loadDefinition($atp, $config['accessTokenProvider']); + + // Message Client + $builder->addDefinition($this->prefix('message')) + ->setFactory(MessageClient::class, [$configStatement]); + + // Account Client + $builder->addDefinition($this->prefix('account')) + ->setFactory(AccountClient::class, [$configStatement]); + } + +} diff --git a/src/Entity/AccessToken.php b/src/Entity/AccessToken.php new file mode 100644 index 0000000..7446379 --- /dev/null +++ b/src/Entity/AccessToken.php @@ -0,0 +1,73 @@ +accessToken = $accessToken; + $this->expiresIn = $expiresIn; + $this->tokenType = $tokenType; + $this->scope = $scope; + $this->expiresAt = $expiresAt ?? new DateTimeImmutable(sprintf('+%d seconds', $expiresIn)); + } + + public function getAccessToken(): string + { + return $this->accessToken; + } + + public function getExpiresIn(): int + { + return $this->expiresIn; + } + + public function getTokenType(): string + { + return $this->tokenType; + } + + public function getScope(): string + { + return $this->scope; + } + + public function getExpiresAt(): DateTimeImmutable + { + return $this->expiresAt; + } + + /** + * @return mixed[] + */ + public function toArray(): array + { + return [ + 'accessToken' => $this->accessToken, + 'expiresIn' => $this->expiresIn, + 'tokenType' => $this->tokenType, + 'scope' => $this->scope, + 'expiresAt' => $this->expiresAt, + ]; + } + +} diff --git a/src/Entity/Message.php b/src/Entity/Message.php new file mode 100644 index 0000000..5cef702 --- /dev/null +++ b/src/Entity/Message.php @@ -0,0 +1,78 @@ +message = $message; + $this->recipients = $recipients; + $this->channel = $channel; + } + + public function getMessage(): string + { + return $this->message; + } + + /** + * @return mixed[] + */ + public function getRecipients(): array + { + return $this->recipients; + } + + public function getChannel(): int + { + return $this->channel; + } + + public function getExpectedSendStart(): ?DateTimeImmutable + { + return $this->expectedSendStart; + } + + public function setExpectedSendStart(?DateTimeImmutable $expectedSendStart): void + { + $this->expectedSendStart = $expectedSendStart; + } + + /** + * @return mixed[] + */ + public function toArray(): array + { + $arr = [ + 'message' => $this->message, + 'recipients' => $this->recipients, + 'channel' => $this->channel, + ]; + + if ($this->expectedSendStart !== null) { + $arr['expectedSendStart'] = $this->expectedSendStart->format(DateTimeImmutable::ATOM); + } + + return $arr; + } + +} diff --git a/src/Exception/ClientException.php b/src/Exception/ClientException.php new file mode 100644 index 0000000..8081934 --- /dev/null +++ b/src/Exception/ClientException.php @@ -0,0 +1,8 @@ +client = $clientFactory->createClient(['timeout' => 30, 'http_errors' => false]); + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + return $this->client->send($request); + } + +} diff --git a/src/Http/IHttpClient.php b/src/Http/IHttpClient.php new file mode 100644 index 0000000..71e7101 --- /dev/null +++ b/src/Http/IHttpClient.php @@ -0,0 +1,13 @@ +load(function (Compiler $compiler): void { + $compiler->addExtension('guz', new GuzzleExtension()); + $compiler->addExtension('http', new HttpExtension()); + $compiler->addExtension('session', new SessionExtension()); + $compiler->addExtension('gosms', new GoSmsExtension()) + ->addConfig([ + 'gosms' => [ + 'clientId' => 'X', + 'clientSecret' => 'Y', + ], + ]); + }, 1); + + /** @var Container $container */ + $container = new $class(); + + // Service created + Assert::type(MessageClient::class, $container->getService('gosms.message')); + Assert::type(AccountClient::class, $container->getService('gosms.account')); +});