From 444ffd7056d8510776059d286d669fcc7ce75095 Mon Sep 17 00:00:00 2001 From: peterdeme Date: Wed, 19 Jan 2022 16:39:08 +0100 Subject: [PATCH] feat: add full feature parity --- .devcontainer/Dockerfile | 9 + .devcontainer/devcontainer.json | 45 +++ .github/workflows/ci.yml | 19 +- .gitignore | 2 +- .phan/config.php | 36 +- ...s-fixer.dist.php => .php-cs-fixer.dist.php | 0 CONTRIBUTING.md | 29 ++ README.md | 25 +- composer.json | 24 +- lib/GetStream/StreamChat/Channel.php | 52 ++- lib/GetStream/StreamChat/Client.php | 329 ++++++++++++++---- lib/GetStream/StreamChat/StreamException.php | 48 ++- tests/integration/IntegrationTest.php | 196 +++++++++-- tests/unit/ClientTest.php | 43 +-- 14 files changed, 681 insertions(+), 176 deletions(-) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json rename .php_cs-fixer.dist.php => .php-cs-fixer.dist.php (100%) create mode 100644 CONTRIBUTING.md diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..3d1150a --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,9 @@ +ARG VARIANT=8-bullseye +FROM mcr.microsoft.com/vscode/devcontainers/php:0-${VARIANT} + +RUN pecl install ast && \ + echo "extension=ast.so" >> "$PHP_INI_DIR/php.ini-development" && \ + mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini" + +ENV PHAN_ALLOW_XDEBUG 0 +ENV PHAN_DISABLE_XDEBUG_WARN 1 \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..a6908d1 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,45 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: +// https://github.com/microsoft/vscode-dev-containers/tree/v0.195.0/containers/php +{ + "name": "PHP", + "build": { + "dockerfile": "Dockerfile", + "args": { + // Update VARIANT to pick a PHP version: 8, 8.0, 7, 7.4, 7.3 + // Append -bullseye or -buster to pin to an OS version. + // Use -bullseye variants on local on arm64/Apple Silicon. + "VARIANT": "8-bullseye" + } + }, + "settings": { + "php.validate.executablePath": "/usr/local/bin/php", + "php-cs-fixer.onsave": true, + "php-cs-fixer.config": ".php-cs-fixer.dist.php", + }, + "extensions": [ + "pkief.material-icon-theme", + "eamodio.gitlens", + "visualstudioexptteam.vscodeintellicode", + "github.copilot", + "felixfbecker.php-debug", + "bmewburn.vscode-intelephense-client", + "junstyle.php-cs-fixer" + ], + // Use 'forwardPorts' to make a list of ports inside the container available locally. + // "forwardPorts": [8080], + // Use 'portsAttributes' to set default properties for specific forwarded ports. More info: https://code.visualstudio.com/docs/remote/devcontainerjson-reference. + "portsAttributes": { + "8000": { + "label": "Stream PHP SDK", + "onAutoForward": "notify" + } + }, + // Use 'otherPortsAttributes' to configure any ports that aren't configured using 'portsAttributes'. + // "otherPortsAttributes": { + // "onAutoForward": "silent" + // }, + // Use 'postCreateCommand' to run commands after the container is created. + // "postCreateCommand": "sudo chmod a+x \"$(pwd)\" && sudo rm -rf /var/www/html && sudo ln -s \"$(pwd)\" /var/www/html" + // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. + "remoteUser": "vscode" +} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4faf61..c48ff05 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,10 @@ name: build on: [pull_request] +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref }} + cancel-in-progress: true + jobs: build: name: 🧪 Test & lint @@ -9,7 +13,7 @@ jobs: strategy: max-parallel: 1 matrix: - php-versions: ['7.3', '7.4', '8.0'] + php-versions: ['7.3', '7.4', '8.0', '8.1'] steps: - name: Checkout uses: actions/checkout@v2 @@ -23,7 +27,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-versions }} - extensions: mbstring, intl + extensions: ast, mbstring, intl ini-values: post_max_size=256M, max_execution_time=180 tools: composer:v2 @@ -31,14 +35,15 @@ jobs: run: composer install --no-interaction - name: Format - run: vendor/bin/php-cs-fixer fix --config=.php_cs-fixer.dist.php -v --dry-run --stop-on-violation + if: ${{ matrix.php-versions == '7.3' }} + run: vendor/bin/php-cs-fixer fix -v --dry-run --stop-on-violation - name: Quality - if: matrix.php-versions == '7.4' - run: vendor/bin/phan --force-polyfill-parser || true + if: ${{ matrix.php-versions == '7.3' }} + run: vendor/bin/phan --no-progress-bar - name: Test env: - STREAM_API_KEY: ${{ secrets.STREAM_API_KEY }} - STREAM_API_SECRET: ${{ secrets.STREAM_API_SECRET }} + STREAM_KEY: ${{ secrets.STREAM_API_KEY }} + STREAM_SECRET: ${{ secrets.STREAM_API_SECRET }} run: vendor/bin/phpunit diff --git a/.gitignore b/.gitignore index d0f6490..6b3bb65 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,6 @@ composer.lock .phplint-cache .phpunit.result.cache .envrc -.php_cs.cache +.php*cache .idea .vscode diff --git a/.phan/config.php b/.phan/config.php index 2a52eb3..97f732d 100644 --- a/.phan/config.php +++ b/.phan/config.php @@ -18,6 +18,39 @@ // TODO: Set this. 'target_php_version' => null, + 'plugins' => [ + 'AlwaysReturnPlugin', + 'DuplicateArrayKeyPlugin', + 'PrintfCheckerPlugin', + 'UnreachableCodePlugin', + 'UseReturnValuePlugin', + 'EmptyStatementListPlugin', + 'LoopVariableReusePlugin', + 'RedundantAssignmentPlugin', + 'UnknownClassElementAccessPlugin', + 'MoreSpecificElementTypePlugin', + 'UnsafeCodePlugin', + 'WhitespacePlugin', + 'PHPDocInWrongCommentPlugin', + 'NoAssertPlugin', + 'NumericalComparisonPlugin', + 'StrictLiteralComparisonPlugin', + 'DeprecateAliasPlugin', + 'ShortArrayPlugin', + 'AvoidableGetterPlugin', + 'RemoveDebugStatementPlugin', + 'HasPHPDocPlugin', + ], + + 'suppress_issue_types' => [ + 'PhanPluginDescriptionlessCommentOnPublicMethod', + 'PhanPluginDescriptionlessCommentOnProtectedProperty', + 'PhanPluginDescriptionlessCommentOnPublicProperty', + 'PhanPluginDescriptionlessCommentOnPrivateMethod', + 'PhanPluginDescriptionlessCommentOnPrivateProperty', + 'PhanPluginDescriptionlessCommentOnProtectedMethod', + ], + // A list of directories that should be parsed for class and // method information. After excluding the directories // defined in exclude_analysis_directory_list, the remaining @@ -49,6 +82,7 @@ // should be added to both the `directory_list` // and `exclude_analysis_directory_list` arrays. 'exclude_analysis_directory_list' => [ - 'vendor/' + 'vendor/', + 'tests/', ], ]; diff --git a/.php_cs-fixer.dist.php b/.php-cs-fixer.dist.php similarity index 100% rename from .php_cs-fixer.dist.php rename to .php-cs-fixer.dist.php diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0eb9cf4 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,29 @@ +# ♻️ Contributing + +We welcome code changes that improve this library or fix a problem, please make sure to follow all best practices and add tests if applicable before submitting a Pull Request on Github. We are very happy to merge your code in the official repository. Make sure to sign our [Contributor License Agreement (CLA)](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) first. See our license file for more details. + +## Getting started + +### Restore dependencies + +Installing dependencies into `./vendor` folder: + +```bash +$ composer install +``` + +### Run tests + +The tests we have are full fledged integration tests, meaning they will actually reach out to a Stream app. Hence the tests require at least two environment variables: `STREAM_KEY` and `STREAM_SECRET`. + +```bash +$ export STREAM_KEY="" +$ export STREAM_SECRET="" +$ vendor/bin/phpunit +``` + +> 💡 Note: On Unix systems you could use [direnv](https://direnv.net/) to set up these variables. + +## IDE specific setup + +If you use VS Code, you can pull up a Dockerized development environment with [Remote-Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) extension. The proper configuration is already included in [.devcontainer](./.devcontainer/) folder. Once you're inside the container, just run the `composer install` command and you're good to go. diff --git a/README.md b/README.md index df3b642..850cee4 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ require_once "./vendor/autoload.php"; Instantiate a new client, find your API keys in the dashboard. ```php -$client = new GetStream\StreamChat\Client(getenv("STREAM_API_KEY"), getenv("STREAM_API_SECRET")); +$client = new GetStream\StreamChat\Client(getenv("STREAM_KEY"), getenv("STREAM_SECRET")); ``` Generate a token for clientside use @@ -64,14 +64,6 @@ $expiration = (new DateTime())->getTimestamp() + 3600; $token = $client->createToken("bob-1", $expiration); ``` -Set location. Tell the client where your app is [hosted](https://getstream.io/chat/docs/multi_region/?language=php&q=locations). - -```php - -$client->setLocation("singapore"); - -``` - ## Update / Create users ```php @@ -220,10 +212,18 @@ $client->deleteDevice($device_id, 'june'); ### Copyright and License Information -[BSD-3](https://github.com/GetStream/stream-chat-php/blob/master/LICENSE). +[BSD-3](./LICENSE). ## Contributing +Installing dependencies into `./vendor` folder: + +```bash +$ composer install +``` + +For more tips head over to [CONTRIBUTING.md](./CONTRIBUTING.md). + ### Commit message convention Since we're autogenerating our [CHANGELOG](./CHANGELOG.md), we need to follow a specific commit message convention. @@ -240,3 +240,8 @@ The job creates a pull request with the changelog. Check if it looks good. - Merge the pull request. Once the PR is merged, it automatically kicks off another job which will create the tag and created a GitHub release. + +## We are hiring! +We've recently closed a $38 million Series B funding round and we keep actively growing. Our APIs are used by more than a billion end-users, and you'll have a chance to make a huge impact on the product within a team of the strongest engineers all over the world. + +Check out our current openings and apply via Stream's website. diff --git a/composer.json b/composer.json index ce80f4c..efa90dc 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,12 @@ { "name": "get-stream/stream-chat", "description": "A PHP client for Stream Chat (https://getstream.io/chat/)", - "keywords": ["stream", "chat", "api", "chat-sdk"], + "keywords": [ + "stream", + "chat", + "api", + "chat-sdk" + ], "homepage": "https://getstream.io/chat/", "license": "BSD-3-Clause", "authors": [ @@ -10,24 +15,27 @@ "email": "support@getstream.io" } ], + "support": { + "issues": "https://github.com/GetStream/stream-chat-php/issues", + "docs": "https://getstream.io/chat/docs/php/?language=php" + }, "require": { "php": ">=7.3", "guzzlehttp/guzzle": "^6.3.3 || ^7.0.1", "firebase/php-jwt": "^v5.0.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^2.18.0", + "friendsofphp/php-cs-fixer": "^3.0.0", "phan/phan": "^5.2.1", - "phpunit/phpunit": "^9.5.10", - "ramsey/uuid": "^4.2.3" + "phpunit/phpunit": "^9.5.11" }, "autoload": { "psr-0": { - "GetStream\\StreamChat" : "lib/" + "GetStream\\StreamChat": "lib/" }, "psr-4": { - "GetStream\\Unit\\" : "tests/unit/", - "GetStream\\Integration\\" : "tests/integration/" + "GetStream\\Unit\\": "tests/unit/", + "GetStream\\Integration\\": "tests/integration/" } } -} +} \ No newline at end of file diff --git a/lib/GetStream/StreamChat/Channel.php b/lib/GetStream/StreamChat/Channel.php index 52a28ef..efdcc0e 100644 --- a/lib/GetStream/StreamChat/Channel.php +++ b/lib/GetStream/StreamChat/Channel.php @@ -2,14 +2,9 @@ namespace GetStream\StreamChat; -use DateTime; -use Exception; -use Firebase\JWT\JWT; -use GuzzleHttp\Client as GuzzleClient; -use GuzzleHttp\Exception\ClientException; -use GuzzleHttp\HandlerStack; -use GuzzleHttp\Psr7\Uri; - +/** + * Class for handling Stream Chat Channels + */ class Channel { @@ -45,7 +40,7 @@ public function __construct($client, $channelTypeName, $channelId=null, $data=nu } /** - * @return mixed + * @return string * @throws StreamException */ private function getUrl() @@ -56,6 +51,9 @@ private function getUrl() return "channels/" . $this->channelType . '/' . $this->id; } + /** + * @return string + */ public function getCID() { return "{$this->channelType}:{$this->id}"; @@ -64,10 +62,10 @@ public function getCID() /** * @param array $payload * @param string $userId - * @return mixed + * @return array * @throws StreamException */ - private function addUser($payload, $userId) + private static function addUser($payload, $userId) { $payload["user"] = ["id" => $userId]; return $payload; @@ -85,11 +83,21 @@ public function sendMessage($message, $userId, $parentId=null) $message['parent_id'] = $parentId; } $payload = [ - "message" => $this->addUser($message, $userId) + "message" => Channel::addUser($message, $userId) ]; return $this->client->post($this->getUrl() . "/message", $payload); } + /** Returns multiple messages. + * @param array $messageIds + * @return mixed + * @throws StreamException + */ + public function getManyMessages($messageIds) + { + return $this->client->get($this->getUrl() . "/messages", ["ids" => implode(",", $messageIds)]); + } + /** * @param array $event * @param string $userId @@ -99,7 +107,7 @@ public function sendMessage($message, $userId, $parentId=null) public function sendEvent($event, $userId) { $payload = [ - "event" => $this->addUser($event, $userId) + "event" => Channel::addUser($event, $userId) ]; return $this->client->post($this->getUrl() . "/event", $payload); } @@ -114,7 +122,7 @@ public function sendEvent($event, $userId) public function sendReaction($messageId, $reaction, $userId) { $payload = [ - "reaction" => $this->addUser($reaction, $userId) + "reaction" => Channel::addUser($reaction, $userId) ]; return $this->client->post( "messages/" . $messageId . "/reaction", @@ -245,7 +253,7 @@ public function update($channelData, $updateMessage=null) */ public function updatePartial($set = null, $unset = null) { - if ($set == null && $unset == null) { + if ($set === null && $unset === null) { throw new StreamException("set or unset is required"); } $update = [ @@ -345,7 +353,7 @@ public function markRead($userId, $data=null) if ($data === null) { $data = []; } - $payload = $this->addUser($data, $userId); + $payload = Channel::addUser($data, $userId); return $this->client->post($this->getUrl() . "/read", $payload); } @@ -481,6 +489,16 @@ public function deleteImage($url) return $this->client->delete($this->getUrl() . '/image', ["url" => $url]); } + /** + * @param array $event + * @return mixed + * @throws StreamException + */ + public function sendCustomEvent($event) + { + return $this->client->post($this->getUrl() . '/event', $event); + } + /** * hides the channel from queryChannels for the user until a message is added * @@ -525,7 +543,7 @@ public function mute($userId, $expirationInMilliSeconds = null) "user_id" => $userId, "channel_cid" => $this->getCID(), ]; - if ($expirationInMilliSeconds !== null) { + if ($expirationInMilliSeconds != null) { $postData["expiration"] = $expirationInMilliSeconds; } return $this->client->post("moderation/mute/channel", $postData); diff --git a/lib/GetStream/StreamChat/Client.php b/lib/GetStream/StreamChat/Client.php index 0558a57..5ac5227 100644 --- a/lib/GetStream/StreamChat/Client.php +++ b/lib/GetStream/StreamChat/Client.php @@ -11,11 +11,18 @@ use GuzzleHttp\Psr7\Uri; use GuzzleHttp\Psr7\MultipartStream; +/** + * A constant class for internal usage + * @internal + */ class Constant { const VERSION = '2.8.0'; } +/** + * A client for the Stream Chat API + */ class Client { /** @@ -36,7 +43,7 @@ class Client /** * @var string */ - protected $protocol; + protected $authToken; /** * @var string @@ -58,6 +65,11 @@ class Client */ protected $httpRequestHeaders = []; + /** + * @var HandlerStack + */ + private $handler; + /** * @param string $apiKey * @param string $apiSecret @@ -65,39 +77,38 @@ class Client * @param string $location * @param float $timeout */ - public function __construct($apiKey, $apiSecret, $apiVersion='v1.0', $location='us-east', $timeout=3.0) + public function __construct($apiKey, $apiSecret, $apiVersion='v1.0', $location='us-east', $timeout=null) { - $this->apiKey = $apiKey; - $this->apiSecret = $apiSecret; + $this->apiKey = $apiKey ?? getenv("STREAM_KEY"); + $this->apiSecret = $apiSecret ?? getenv("STREAM_SECRET"); + + if (!$this->apiKey || !$this->apiSecret) { + throw new StreamException('API key and secret are required.'); + } + + if ($timeout != null) { + $this->timeout = $timeout; + } elseif (getenv("STREAM_CHAT_TIMEOUT")) { + $this->timeout = floatval(getenv("STREAM_CHAT_TIMEOUT")); + } else { + $this->timeout = 3.0; + } + $this->apiVersion = $apiVersion; - $this->timeout = $timeout; $this->location = $location; - $this->protocol = 'https'; $this->authToken = JWT::encode(["server"=>"true"], $this->apiSecret, 'HS256'); + $this->handler = HandlerStack::create(); } - /** - * @param string $protocol - */ - public function setProtocol($protocol) - { - $this->protocol = $protocol; - } - - /** - * @param string $location + /** Sets the location for the URL. Deprecated, and will be removed in a future version. + * Stream's new Edge infrastructure removes the need to specifically set a regional URL. + * The baseURL is https://chat.stream-io-api.com regardless of region. + * @param string $location + * @return void + * @deprecated */ public function setLocation($location) { - $this->location = $location; - } - - /** - * @return Batcher - */ - public function batcher() - { - return new Batcher($this, $this->signer, $this->apiKey); } /** @@ -105,19 +116,22 @@ public function batcher() */ public function getBaseUrl() { - $baseUrl = getenv('STREAM_BASE_CHAT_URL'); - if (!$baseUrl) { - // try STREAM_BASE_URL for backwards compatibility - $baseUrl = getenv('STREAM_BASE_URL'); - } - if ($baseUrl) { - return $baseUrl; + $envVarKeys = ["STREAM_CHAT_URL", "STREAM_BASE_CHAT_URL", "STREAM_BASE_URL"]; + + foreach ($envVarKeys as $envVarKey) { + $baseUrl = getenv($envVarKey); + + if ($baseUrl) { + return $baseUrl; + } } + $localPort = getenv('STREAM_LOCAL_API_PORT'); if ($localPort) { return "http://localhost:$localPort"; } - return "{$this->protocol}://chat-proxy-{$this->location}.stream-io-api.com"; + + return "https://chat.stream-io-api.com"; } /** @@ -130,38 +144,31 @@ public function buildRequestUrl($uri) return "{$baseUrl}/{$uri}"; } - - /** - * @return \GuzzleHttp\HandlerStack - */ - public function getHandlerStack() - { - return HandlerStack::create(); - } - /** * @return \GuzzleHttp\Client */ public function getHttpClient() { - $handler = $this->getHandlerStack(); return new GuzzleClient([ 'base_uri' => $this->getBaseUrl(), 'timeout' => $this->timeout, - 'handler' => $handler, + 'handler' => $this->handler, 'headers' => ['Accept-Encoding' => 'gzip'], ]); } + /** Sets a Guzzle HTTP option that add to the request. See `\GuzzleHttp\RequestOptions`. + * @param string $option + * @param mixed $value + * @return void + */ public function setGuzzleDefaultOption($option, $value) { $this->guzzleOptions[$option] = $value; } /** - * @param string $resource - * @param string $action - * @return array + * @return string[] */ protected function getHttpRequestHeaders() { @@ -178,10 +185,10 @@ protected function getHttpRequestHeaders() * @param string $method * @param array $data * @param array $queryParams - * @param string $resource - * @param string $action + * @param array $multipart * @return mixed * @throws StreamException + * @suppress PhanPluginMoreSpecificActualReturnType */ public function makeHttpRequest($uri, $method, $data = [], $queryParams = [], $multipart = []) { @@ -199,7 +206,7 @@ public function makeHttpRequest($uri, $method, $data = [], $queryParams = [], $m $options['body'] = new MultipartStream($multipart, $boundary); $headers['Content-Type'] = "multipart/form-data;boundary=" . $boundary; } else { - if ($method === 'POST' || $method == 'PUT' || $method == 'PATCH') { + if ($method === 'POST' || $method === 'PUT' || $method === 'PATCH') { $options['json'] = $data; } } @@ -229,21 +236,22 @@ public function makeHttpRequest($uri, $method, $data = [], $queryParams = [], $m */ public function createToken($userId, $expiration=null, $issuedAt=null) { - $payload = [ - 'user_id' => $userId, - ]; - if ($expiration !== null) { + $payload = ['user_id' => $userId]; + + if ($expiration != null) { if (gettype($expiration) !== 'integer') { throw new StreamException("expiration must be a unix timestamp"); } $payload['exp'] = $expiration; } - if ($issuedAt !== null) { + + if ($issuedAt != null) { if (gettype($issuedAt) !== 'integer') { throw new StreamException("issuedAt must be a unix timestamp"); } $payload['iat'] = $issuedAt; } + return JWT::encode($payload, $this->apiSecret, 'HS256'); } @@ -255,7 +263,7 @@ public function createToken($userId, $expiration=null, $issuedAt=null) */ public function get($uri, $queryParams=null) { - return $this->makeHttpRequest($uri, "GET", null, $queryParams); + return $this->makeHttpRequest($uri, "GET", [], $queryParams); } /** @@ -266,7 +274,7 @@ public function get($uri, $queryParams=null) */ public function delete($uri, $queryParams=null) { - return $this->makeHttpRequest($uri, "DELETE", null, $queryParams); + return $this->makeHttpRequest($uri, "DELETE", [], $queryParams); } /** @@ -323,6 +331,26 @@ public function updateAppSettings($settings) return $this->patch("app", $settings); } + /** Sends a test push. + * @param array $pushSettings + * @return mixed + * @throws StreamException + */ + public function checkPush($pushSettings) + { + return $this->post("check_push", $pushSettings); + } + + /** Sends a test SQS push. + * @param array $sqsSettings + * @return mixed + * @throws StreamException + */ + public function checkSqs($sqsSettings) + { + return $this->post("check_sqs", $sqsSettings); + } + /** * @param array $users * @return mixed @@ -430,6 +458,16 @@ public function deleteChannels($cids, $options=null) return $this->post("channels/delete", $options); } + /** Creates a guest user. + * @param array $guestRequest + * @return mixed + * @throws StreamException + */ + public function setGuestUser($guestRequest) + { + return $this->post("guest", $guestRequest); + } + /** * @param string $userId * @param array $options @@ -529,6 +567,18 @@ public function removeShadowBan($targetId, $options=null) return $this->unbanUser($targetId, $options); } + /** Queries banned users. + * @param array $filterConditions + * @param array $options + * @return mixed + * @throws StreamException + */ + public function queryBannedUsers($filterConditions, $options=[]) + { + $options["filter_conditions"] = $filterConditions; + return $this->get("query_banned_users", ["payload" => json_encode($options)]); + } + /** * @param string $messageId * @return mixed @@ -718,7 +768,7 @@ public function updateMessage($message) try { $messageId = $message["id"]; } catch (Exception $e) { - throw StreamException("A message must have an id"); + throw new StreamException("A message must have an id"); } $options = ["message" => $message]; return $this->post("messages/" . $messageId, $options); @@ -767,7 +817,7 @@ public function queryUsers($filterConditions, $sort=null, $options=null) */ public function queryChannels($filterConditions, $sort=null, $options=null) { - if ($filterConditions == null || count($filterConditions) == 0) { + if (!$filterConditions) { throw new StreamException("filterConditions can't be empty"); } if ($options === null) { @@ -873,6 +923,106 @@ public function getChannel($channelTypeName, $channelId, $data=null) return $this->Channel($channelTypeName, $channelId, $data); } + /** Creates a blocklist. + * @param array $blocklist + * @return mixed + * @throws StreamException + */ + public function createBlocklist($blocklist) + { + return $this->post("blocklists", $blocklist); + } + + /** Lists all blocklists. + * @return mixed + * @throws StreamException + */ + public function listBlocklists() + { + return $this->get("blocklists"); + } + + /** Returns a blocklist. + * @param string $name + * @return mixed + * @throws StreamException + */ + public function getBlocklist($name) + { + return $this->get("blocklists/${name}"); + } + + /** Updates a blocklist. + * @param string $name + * @param array $blocklist + * @return mixed + * @throws StreamException + */ + public function updateBlocklist($name, $blocklist) + { + return $this->put("blocklists/${name}", $blocklist); + } + + /** Deletes a blocklist. + * @param string $name + * @return mixed + * @throws StreamException + */ + public function deleteBlocklist($name) + { + return $this->delete("blocklists/${name}"); + } + + /** Creates a command. + * @param array $command + * @return mixed + * @throws StreamException + */ + public function createCommand($command) + { + return $this->post("commands", $command); + } + + /** Lists all commands. + * @return mixed + * @throws StreamException + */ + public function listCommands() + { + return $this->get("commands"); + } + + /** Returns a command. + * @param string $name + * @return mixed + * @throws StreamException + */ + public function getCommand($name) + { + return $this->get("commands/${name}"); + } + + /** Updates a command. + * @param string $name + * @param array $command + * @return mixed + * @throws StreamException + */ + public function updateCommand($name, $command) + { + return $this->put("commands/${name}", $command); + } + + /** Deletes a command. + * @param string $name + * @return mixed + * @throws StreamException + */ + public function deleteCommand($name) + { + return $this->delete("commands/${name}"); + } + /** * @param string $deviceId * @param string $pushProvider // apn or firebase @@ -968,6 +1118,15 @@ public function revokeUsersToken($userIDs, $before) return $this->partialUpdateUsers($updates); } + /** + * @param bool $serverSide + * @param bool $android + * @param bool $ios + * @param bool $web + * @param array $endpoints + * @return mixed + * @throws StreamException + */ public function getRateLimits($serverSide=false, $android=false, $ios=false, $web=false, $endpoints=null) { $data = []; @@ -990,15 +1149,16 @@ public function getRateLimits($serverSide=false, $android=false, $ios=false, $we } /** - * @param array $userId - * @return mixed + * @param string $requestBody + * @param string $XSignature + * @return bool * @throws StreamException */ public function verifyWebhook($requestBody, $XSignature) { $signature = hash_hmac("sha256", $requestBody, $this->apiSecret); - return $signature == $XSignature; + return $signature === $XSignature; } /** @@ -1038,6 +1198,15 @@ public function search($filterConditions, $query, $options=null) return $this->get("search", ["payload" => json_encode($options)]); } + /** + * @param string $uri + * @param string $url + * @param string $name + * @param array $user + * @param string $contentType + * @return mixed + * @throws StreamException + */ public function sendFile($uri, $url, $name, $user, $contentType=null) { if ($contentType === null) { @@ -1046,7 +1215,7 @@ public function sendFile($uri, $url, $name, $user, $contentType=null) $multipart = [ [ 'name' => 'file', - 'contents' => file_get_contents($url, 'r'), + 'contents' => file_get_contents($url), 'filename' => $name, // let guzzle handle the content-type // 'headers' => [ 'Content-Type' => $contentType] @@ -1058,10 +1227,21 @@ public function sendFile($uri, $url, $name, $user, $contentType=null) // 'headers' => ['Content-Type' => 'application/json'] ] ]; - $response = $this->makeHttpRequest($uri, 'POST', null, null, $multipart); + $response = $this->makeHttpRequest($uri, 'POST', [], [], $multipart); return $response; } + /** Runs a message command action. + * @param string $messageId + * @param array $formData + * @return mixed + * @throws StreamException + */ + public function sendMessageAction($messageId, $userId, $formData) + { + return $this->post("messages/${messageId}/action", ["user_id" => $userId, "form_data" => $formData]); + } + /** * @return mixed * @throws StreamException @@ -1113,6 +1293,17 @@ public function deleteRole($name) return $this->delete("roles/${name}"); } + /** Translates a message to a language. + * @param string $messageId + * @param string $language + * @return mixed + * @throws StreamException + */ + public function translateMessage($messageId, $language) + { + return $this->post("messages/${messageId}/translate", ["language" => $language]); + } + /** * Schedules channel export task for list of channels * @param $requests array of requests for channel export. Each of them should contain `type` and `id` fields and optionally `messages_since` and `messages_until` @@ -1138,6 +1329,16 @@ public function exportChannel($request, $options) return $this->exportChannels([$request], $options); } + /** + * Gets the status of a channel export task. + * @param string $id id of the task + * @return mixed returns the status of the task + */ + public function getExportChannelStatus($id) + { + return $this->get("export_channels/${id}"); + } + /** * Returns task status * @param $id string task ID diff --git a/lib/GetStream/StreamChat/StreamException.php b/lib/GetStream/StreamChat/StreamException.php index 50f3886..6109eab 100644 --- a/lib/GetStream/StreamChat/StreamException.php +++ b/lib/GetStream/StreamChat/StreamException.php @@ -2,35 +2,53 @@ namespace GetStream\StreamChat; +use GuzzleHttp\Exception\ClientException; + +/** + * Exception when a client error is encountered + */ class StreamException extends \Exception { - private function getRateLimitValue($headerName) - { - /* Sample headers - - x-ratelimit-limit: 2000 - x-ratelimit-remaining: 1998 - x-ratelimit-reset: 1543604520 - - */ - $e = $this->getPrevious(); - if ($e) { - return (string)$e->getResponse()->getHeader("x-ratelimit-" . $headerName)[0]; - } - } - + /** + * @return string|null + */ public function getRateLimitLimit() { return $this->getRateLimitValue("limit"); } + /** + * @return string|null + */ public function getRateLimitRemaining() { return $this->getRateLimitValue("remaining"); } + /** + * @return string|null + */ public function getRateLimitReset() { return $this->getRateLimitValue("reset"); } + + /** + * @param string $headerName + * @return string|null + */ + private function getRateLimitValue($headerName) + { + $e = $this->getPrevious(); + + if ($e && $e instanceof ClientException) { + $headerValues = $e->getResponse()->getHeader("x-ratelimit-" . $headerName); + + if ($headerValues) { + return $headerValues[0]; + } + } + + return null; + } } diff --git a/tests/integration/IntegrationTest.php b/tests/integration/IntegrationTest.php index 300a4bd..d053ed8 100644 --- a/tests/integration/IntegrationTest.php +++ b/tests/integration/IntegrationTest.php @@ -5,7 +5,6 @@ use GetStream\StreamChat\Client; use GetStream\StreamChat\StreamException; use PHPUnit\Framework\TestCase; -use Ramsey\Uuid\Uuid; class IntegrationTest extends TestCase { @@ -17,15 +16,19 @@ class IntegrationTest extends TestCase protected function setUp():void { $this->client = new Client( - getenv('STREAM_API_KEY'), - getenv('STREAM_API_SECRET'), + getenv('STREAM_KEY'), + getenv('STREAM_SECRET'), 'v1.0', getenv('STREAM_REGION') ); - $this->client->setLocation('us-east'); $this->client->timeout = 10000; } + private function generateGuid() + { + return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex(random_bytes(16)), 4)); + } + public function testAuth() { $this->expectException(\GetStream\StreamChat\StreamException::class); @@ -48,13 +51,23 @@ public function testListChannelTypes() private function getUser() { // this creates a user on the server - $user = ["id" => Uuid::uuid4()->toString()]; + $user = ["id" => $this->generateGuid()]; $response = $this->client->upsertUser($user); $this->assertTrue(array_key_exists("users", $response)); $this->assertTrue(array_key_exists($user["id"], $response["users"])); return $user; } + public function testStreamException() + { + try { + $this->client->muteUser("invalid_user_id", "invalid_user_id"); + $this->fail("An exception must be thrown."); + } catch (StreamException $e) { + $this->assertGreaterThan(0, $e->getRateLimitLimit()); + } + } + public function testMuteUser() { $user1 = $this->getUser(); @@ -78,9 +91,42 @@ public function testUpdateAppSettings() $this->assertTrue(array_key_exists("duration", $response)); } + public function testCheckPush() + { + $user = $this->getUser(); + $channel = $this->getChannel(); + $response = $channel->sendMessage(["text" => "How many syllables are there in xyz"], $user["id"]); + + $pushResponse = $this->client->checkPush(["message_id" => $response["message"]["id"], "skip_devices" => true, "user_id" => $user["id"]]); + + $this->assertTrue(array_key_exists("rendered_message", $pushResponse)); + } + + public function testCheckSqs() + { + $response = $this->client->checkSqs([ + "sqs_url" => "https://foo.com/bar", + "sqs_key" => "key", + "sqs_secret" => "secret"]); + + $this->assertTrue(array_key_exists("status", $response)); + } + + public function testGuestUser() + { + try { + $response = $this->client->setGuestUser(["user" => ["id" => $this->generateGuid()]]); + } catch (Exception $e) { + // Guest user isn't allowed on all applications + return; + } + + $this->assertTrue(array_key_exists("access_token", $response)); + } + public function testUpsertUser() { - $user = ["id" => Uuid::uuid4()->toString()]; + $user = ["id" => $this->generateGuid()]; $response = $this->client->upsertUser($user); $this->assertTrue(array_key_exists("users", $response)); $this->assertTrue(array_key_exists($user["id"], $response["users"])); @@ -88,7 +134,7 @@ public function testUpsertUser() public function testUpsertUsers() { - $user = ["id" => Uuid::uuid4()->toString()]; + $user = ["id" => $this->generateGuid()]; $response = $this->client->upsertUsers([$user]); $this->assertTrue(array_key_exists("users", $response)); $this->assertTrue(array_key_exists($user["id"], $response["users"])); @@ -121,7 +167,7 @@ public function testDeleteUsers() public function testDeleteChannels() { - $user = ["id" => Uuid::uuid4()->toString()]; + $user = ["id" => $this->generateGuid()]; $response = $this->client->upsertUser($user); $c1 = $this->getChannel(); @@ -272,6 +318,79 @@ public function testUnBanUser() $response = $this->client->unBanUser($user1["id"], ["user_id" => $user2["id"]]); } + public function testQueryBannedUsers() + { + $user1 = $this->getUser(); + $user2 = $this->getUser(); + $response = $this->client->banUser($user1["id"], ["user_id" => $user2["id"], "reason" => "because"]); + + $queryResp = $this->client->queryBannedUsers(["reason" => "because"], ["limit" => 1]); + + $this->assertTrue(array_key_exists("bans", $queryResp)); + } + + public function testBlockListsEndToEnd() + { + $name = $this->generateGuid(); + + $this->client->createBlocklist(["name" => $name, "words" => ["test"]]); + + $listResp = $this->client->listBlocklists(); + $this->assertTrue(array_key_exists("blocklists", $listResp)); + + $getResp = $this->client->getBlocklist($name); + $this->assertTrue(array_key_exists("blocklist", $getResp)); + + $updateResp = $this->client->updateBlocklist($name, ["words" => ["test", "test2"]]); + $this->assertTrue(array_key_exists("duration", $updateResp)); + + $deleteResp = $this->client->deleteBlocklist($name); + $this->assertTrue(array_key_exists("duration", $deleteResp)); + } + + public function testCommandsEndToEnd() + { + $name = $this->generateGuid(); + + $this->client->createCommand(["name" => $name, "description" => "Test php end2end test"]); + + $listResp = $this->client->listCommands(); + $this->assertTrue(array_key_exists("commands", $listResp)); + + $getResp = $this->client->getCommand($name); + $this->assertEquals($name, $getResp["name"]); + + $updateResp = $this->client->updateCommand($name, ["description" => "Test php end2end test 2"]); + $this->assertTrue(array_key_exists("duration", $updateResp)); + + $updateResp = $this->client->deleteCommand($name); + $this->assertTrue(array_key_exists("duration", $updateResp)); + } + + public function testSendMessageAction() + { + $user = $this->getUser(); + $channel = $this->getChannel(); + $msgId = $this->generateGuid(); + $channel->sendMessage(["id" => $msgId, "text" => "/giphy wave"], $user["id"]); + + $response = $this->client->sendMessageAction($msgId, $user["id"], ["image_action" => "shuffle"]); + + $this->assertTrue(array_key_exists("message", $response)); + } + + public function testTranslateMessage() + { + $user = $this->getUser(); + $channel = $this->getChannel(); + $msgId = $this->generateGuid(); + $channel->sendMessage(["id" => $msgId, "text" => "hello world"], $user["id"]); + + $response = $this->client->translateMessage($msgId, "hu"); + + $this->assertTrue(array_key_exists("message", $response)); + } + public function testFlagUser() { $user1 = $this->getUser(); @@ -297,7 +416,7 @@ public function getChannel() { $channel = $this->client->Channel( "messaging", - Uuid::uuid4()->toString(), + $this->generateGuid(), ["test" => true, "language" => "php"] ); $channel->create($this->getUser()["id"]); @@ -308,7 +427,7 @@ public function testChannelWithoutData() { $channel = $this->client->Channel( "messaging", - Uuid::uuid4()->toString() + $this->generateGuid() ); $channel->create($this->getUser()["id"]); return $channel; @@ -318,7 +437,7 @@ public function testGetChannelWithoutData() { $channel = $this->client->getChannel( "messaging", - Uuid::uuid4()->toString() + $this->generateGuid() ); $channel->create($this->getUser()["id"]); return $channel; @@ -328,7 +447,7 @@ public function testUpdateMessage() { $user = $this->getUser(); $channel = $this->getChannel(); - $msgId = Uuid::uuid4()->toString(); + $msgId = $this->generateGuid(); $msg = ["id" => $msgId, "text" => "hello world"]; $response = $channel->sendMessage($msg, $user["id"]); $this->assertSame("hello world", $response["message"]["text"]); @@ -345,18 +464,31 @@ public function testDeleteMessage() { $user = $this->getUser(); $channel = $this->getChannel(); - $msgId = Uuid::uuid4()->toString(); + $msgId = $this->generateGuid(); $msg = ["id" => $msgId, "text" => "helloworld"]; $response = $channel->sendMessage($msg, $user["id"]); $response = $this->client->deleteMessage($msgId); } + public function testManyMessages() + { + $user = $this->getUser(); + $channel = $this->getChannel(); + $msgId = $this->generateGuid(); + $msg = ["id" => $msgId, "text" => "helloworld"]; + $channel->sendMessage($msg, $user["id"]); + + $msgResponse = $channel->getManyMessages([$msgId]); + + $this->assertTrue(array_key_exists("messages", $msgResponse)); + } + public function testFlagMessage() { $user = $this->getUser(); $user2 = $this->getUser(); $channel = $this->getChannel(); - $msgId = Uuid::uuid4()->toString(); + $msgId = $this->generateGuid(); $msg = ["id" => $msgId, "text" => "hello world"]; $response = $channel->sendMessage($msg, $user["id"]); $response = $this->client->flagMessage($msgId, ["user_id" => $user2["id"]]); @@ -367,7 +499,7 @@ public function testUnFlagMessage() $user = $this->getUser(); $user2 = $this->getUser(); $channel = $this->getChannel(); - $msgId = Uuid::uuid4()->toString(); + $msgId = $this->generateGuid(); $msg = ["id" => $msgId, "text" => "hello world"]; $response = $channel->sendMessage($msg, $user["id"]); $response = $this->client->flagMessage($msgId, ["user_id" => $user2["id"]]); @@ -379,7 +511,7 @@ public function testQueryMessageFlags() $user = $this->getUser(); $user2 = $this->getUser(); $channel = $this->getChannel(); - $msgId = Uuid::uuid4()->toString(); + $msgId = $this->generateGuid(); $channel->sendMessage(["id" => $msgId, "text" => "flag me!"], $user["id"]); $this->client->flagMessage($msgId, ["user_id" => $user2["id"]]); @@ -417,6 +549,18 @@ public function testQueryUsersYoungHobbits() $this->assertEquals([50, 38, 36, 28], $ages); } + public function testQueryChannelsThrowsIfNullConditions() + { + $this->expectException(\GetStream\StreamChat\StreamException::class); + $this->client->queryChannels(null); + } + + public function testQueryChannelsThrowsIfEmptyConditions() + { + $this->expectException(\GetStream\StreamChat\StreamException::class); + $this->client->queryChannels([]); + } + public function testQueryChannelsMembersIn() { $this->createFellowship(); @@ -430,12 +574,12 @@ public function testQueryChannelsMembersIn() public function testQueryMembers() { - $bob = ["id" => Uuid::uuid4()->toString(), "name" => "bob the builder"]; - $bobSponge = ["id" => Uuid::uuid4()->toString(), "name" => "bob the sponge"]; + $bob = ["id" => $this->generateGuid(), "name" => "bob the builder"]; + $bobSponge = ["id" => $this->generateGuid(), "name" => "bob the sponge"]; $this->client->upsertUsers([$bob, $bobSponge]); $channel = $this->client->Channel( "messaging", - Uuid::uuid4()->toString(), + $this->generateGuid(), ["members" => [$bob["id"], $bobSponge["id"]]] ); $channel->create($bob["id"]); @@ -456,8 +600,8 @@ public function testQueryMembers() public function testQueryMembersMemberBasedChannel() { - $bob = ["id" => Uuid::uuid4()->toString(), "name" => "bob the builder"]; - $bobSponge = ["id" => Uuid::uuid4()->toString(), "name" => "bob the sponge"]; + $bob = ["id" => $this->generateGuid(), "name" => "bob the builder"]; + $bobSponge = ["id" => $this->generateGuid(), "name" => "bob the sponge"]; $this->client->upsertUsers([$bob, $bobSponge]); $channel = $this->client->Channel( "messaging", @@ -483,14 +627,14 @@ public function testDevices() $response = $this->client->getDevices($user["id"]); $this->assertTrue(array_key_exists("devices", $response)); $this->assertSame(count($response["devices"]), 0); - $this->client->addDevice(Uuid::uuid4()->toString(), "apn", $user["id"]); + $this->client->addDevice($this->generateGuid(), "apn", $user["id"]); $response = $this->client->getDevices($user["id"]); $this->assertSame(count($response["devices"]), 1); $response = $this->client->deleteDevice($response["devices"][0]["id"], $user["id"]); $response = $this->client->getDevices($user["id"]); $this->assertSame(count($response["devices"]), 0); // overdoing it a little? - $this->client->addDevice(Uuid::uuid4()->toString(), "apn", $user["id"]); + $this->client->addDevice($this->generateGuid(), "apn", $user["id"]); $response = $this->client->getDevices($user["id"]); $this->assertSame(count($response["devices"]), 1); } @@ -903,7 +1047,7 @@ public function testChannelHideShow() $response = $this->client->queryChannels(["id" => $channel->id], null, ['user_id' => $user1["id"]]); $this->assertSame(count($response["channels"]), 0); // send message - $msgId = Uuid::uuid4()->toString(); + $msgId = $this->generateGuid(); $msg = ["id" => $msgId, "text" => "hello world"]; $response = $channel->sendMessage($msg, $user2["id"]); // channel should be 'visible' @@ -913,7 +1057,7 @@ public function testChannelHideShow() public function testPartialUpdateUsers() { - $carmen = ["id" => Uuid::uuid4()->toString(), "name" => "Carmen SanDiego", "hat" => "blue", "location" => "Here"]; + $carmen = ["id" => $this->generateGuid(), "name" => "Carmen SanDiego", "hat" => "blue", "location" => "Here"]; $response = $this->client->upsertUser($carmen); $this->assertTrue(array_key_exists("users", $response)); $this->assertTrue(array_key_exists($carmen["id"], $response["users"])); @@ -922,7 +1066,7 @@ public function testPartialUpdateUsers() $response = $this->client->queryUsers(["id" => $carmen["id"]]); $this->assertSame($response["users"][0]["hat"], "red"); $this->assertSame($response["users"][0]["location"], "Here"); - $wally = ["id" => Uuid::uuid4()->toString(), "name" => "Wally", "shirt" => "white", "location" => "There"]; + $wally = ["id" => $this->generateGuid(), "name" => "Wally", "shirt" => "white", "location" => "There"]; $response = $this->client->upsertUser($wally); $response = $this->client->partialUpdateUsers([ ["id" => $carmen["id"], "set" => ["coat" => "red"], "unset" => ["location"]], diff --git a/tests/unit/ClientTest.php b/tests/unit/ClientTest.php index f13d13d..d09a951 100644 --- a/tests/unit/ClientTest.php +++ b/tests/unit/ClientTest.php @@ -14,35 +14,17 @@ public function setUp():void $this->client = new Client('key', 'secret'); } - public function testClientSetProtocol() + public function testClientHostnameWhenNoEnvVarAvailable() { $client = new Client('key', 'secret'); - $client->setProtocol('asdfg'); $url = $client->buildRequestUrl('x'); - $this->assertSame('asdfg://chat-proxy-us-east.stream-io-api.com/x', $url); + $this->assertSame('https://chat.stream-io-api.com/x', $url); } - public function testClientHostnames() - { - $client = new Client('key', 'secret'); - $client->setLocation('qa'); - $url = $client->buildRequestUrl('x'); - $this->assertSame('https://chat-proxy-qa.stream-io-api.com/x', $url); - - $client = new Client('key', 'secret', $api_version = '1234', $location = 'asdfg'); - $url = $client->buildRequestUrl('y'); - $this->assertSame('https://chat-proxy-asdfg.stream-io-api.com/y', $url); - - $client = new Client('key', 'secret'); - $client->setLocation('us-east'); - $url = $client->buildRequestUrl('z'); - $this->assertSame('https://chat-proxy-us-east.stream-io-api.com/z', $url); - } - - public function testEnvironmentVariable() + public function testBaseUrlEnvironmentVariables() { // Arrange - $previous = getenv('STREAM_BASE_URL'); + $original = getenv('STREAM_BASE_URL'); putenv('STREAM_BASE_URL=test.stream-api.com/api'); $client = new Client('key', 'secret'); @@ -53,31 +35,38 @@ public function testEnvironmentVariable() $this->assertSame('test.stream-api.com/api', $baseUrl); // Teardown - if ($previous === false) { + if ($original === false) { // Remove the environment variable. putenv('STREAM_BASE_URL'); } else { - putenv('STREAM_BASE_URL='.$previous); + putenv('STREAM_BASE_URL='.$original); } } - public function testCreateToken() + public function testCreateTokenWithNoExpiration() { $token = $this->client->createToken("tommaso"); + $payload = (array)JWT::decode($token, 'secret', ['HS256']); $this->assertTrue(in_array("tommaso", $payload)); $this->assertSame("tommaso", $payload['user_id']); + } + + public function testCreateTokenWithExpiration() + { $expires = (new DateTime())->getTimestamp() + 3600; + $token = $this->client->createToken("tommaso", $expires); + $payload = (array)JWT::decode($token, 'secret', ['HS256']); $this->assertTrue(array_key_exists("exp", $payload)); $this->assertSame($payload['exp'], $expires); } - public function testCreateTokenExpiration() + public function testCreateTokenExpirationThrowsIfNotUnixTimestamp() { $this->expectException(\GetStream\StreamChat\StreamException::class); $expires = new DateTime(); - $token = $this->client->createToken("tommaso", $expires); + $this->client->createToken("tommaso", $expires); } }