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/).
+
+-----
+
+[](https://travis-ci.org/contributte/gosms)
+[](https://coveralls.io/r/contributte/gosms)
+[](https://packagist.org/packages/contributte/gosms)
+[](https://packagist.org/packages/contributte/gosms)
+[](https://packagist.org/packages/contributte/gosms)
+[](https://packagist.org/packages/contributte/gosms)
+[](https://github.com/phpstan/phpstan)
+
+## Discussion / Help
+
+[](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
+
+
+
+-----
+
+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'));
+});