├── .github └── workflows │ └── main.yml ├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── phpcs.xml.dist ├── phpstan.neon.dist ├── phpunit.xml.dist ├── src ├── Api │ ├── Channel.php │ ├── Model │ │ ├── ChannelInformation.php │ │ ├── JsonMessage.php │ │ ├── Message.php │ │ ├── PlainTextMessage.php │ │ ├── StatusInformation.php │ │ └── XmlMessage.php │ └── Status.php ├── Exception │ ├── AuthenticationRequiredException.php │ ├── InvalidUrlException.php │ └── NchanException.php ├── Http │ ├── Client.php │ ├── Request.php │ ├── Response.php │ ├── ThrowExceptionIfRequestRequiresAuthenticationClient.php │ └── Url.php ├── HttpAdapter │ ├── BasicAuthenticationCredentials.php │ ├── BearerAuthenticationCredentials.php │ ├── Credentials.php │ ├── HttpStreamWrapperClient.php │ ├── HttpStreamWrapperResponse.php │ ├── Psr18ClientAdapter.php │ ├── Psr7ResponseAdapter.php │ └── WithoutAuthenticationCredentials.php └── Nchan.php └── tests ├── Integration └── HttpAdapter │ ├── ClientTestCase.php │ ├── HttpStreamWrapperClientTest.php │ └── Psr18ClientAdapterTest.php ├── TestServer ├── PhpUnitStartServerListener.php └── index.php └── Unit ├── Api ├── ChannelTest.php ├── Model │ ├── ChannelInformationTest.php │ ├── JsonMessageTest.php │ ├── PlainTextMessageTest.php │ ├── StatusInformationTest.php │ └── XmlMessageTest.php └── StatusTest.php ├── Http ├── RequestTest.php ├── ThrowExceptionIfRequestRequiresAuthenticationClientTest.php └── UrlTest.php ├── HttpAdapter ├── BasicAuthenticationCredentialsTest.php ├── BearerAuthenticationCredentialsTest.php ├── HttpStreamWrapperResponseTest.php └── WithoutAuthenticationCredentialsTest.php └── NchanTest.php /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: master 6 | pull_request: 7 | 8 | jobs: 9 | pipeline: 10 | strategy: 11 | matrix: 12 | operating-system: [ubuntu-latest] 13 | php-version: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4'] 14 | name: ${{ matrix.php-version }} 15 | runs-on: ${{ matrix.operating-system }} 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v2 19 | 20 | - name: Setup PHP 21 | uses: shivammathur/setup-php@v2 22 | with: 23 | php-version: ${{ matrix.php-version }} 24 | 25 | - name: Prepare 26 | run: composer install 27 | 28 | - name: Testsuite 29 | run: | 30 | vendor/bin/phpunit 31 | vendor/bin/phpcs 32 | vendor/bin/phpstan analyse 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | /composer.lock 3 | /phpcs.xml 4 | /phpstan.neon 5 | /phpunit.xml 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Markus Reinhold 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # php-nchan-client 2 | 3 | ![CI](https://github.com/marein/php-nchan-client/workflows/CI/badge.svg?branch=master) 4 | 5 | __Table of contents__ 6 | 7 | * [Overview](#overview) 8 | * [Installation and requirements](#installation-and-requirements) 9 | * [Usage](#usage) 10 | * [Publish a message](#publish-a-message) 11 | * [Get channel information](#get-channel-information) 12 | * [Delete a channel](#delete-a-channel) 13 | * [Nchan status information](#nchan-status-information) 14 | * [Authorize requests](#authorize-requests) 15 | * [PSR-18 compatibility](#psr-18-compatibility) 16 | 17 | ## Overview 18 | 19 | This is a PHP client for [https://nchan.io](https://nchan.io). 20 | 21 | ## Installation and requirements 22 | 23 | ``` 24 | composer require marein/php-nchan-client 25 | ``` 26 | 27 | If you want to use the 28 | [PSR-18 adapter](#psr-18-compatibility), 29 | install a library that implements PSR-18 http client 30 | ([see here](https://packagist.org/providers/psr/http-client-implementation)) 31 | and a library that implements PSR-17 http factories 32 | ([see here](https://packagist.org/providers/psr/http-factory-implementation)). 33 | 34 | If you want to use the built-in http client (default if you don't set anything), 35 | enable the php configuration 36 | [allow_url_fopen](http://php.net/manual/en/filesystem.configuration.php#ini.allow-url-fopen). 37 | 38 | ## Usage 39 | 40 | The following code examples use the built-in http client. 41 | 42 | ### Publish a message 43 | 44 |
45 | Show code 46 | 47 | ```php 48 | channel('/path-to-publisher-endpoint'); 59 | $channelInformation = $channel->publish( 60 | new PlainTextMessage( 61 | 'my-message-name', 62 | 'my message content' 63 | ) 64 | ); 65 | 66 | // Nchan returns some channel information after publishing a message. 67 | var_dump($channelInformation); 68 | } 69 | ``` 70 |
71 | 72 | ### Get channel information 73 | 74 |
75 | Show code 76 | 77 | ```php 78 | channel('/path-to-publisher-endpoint'); 88 | $channelInformation = $channel->information(); 89 | 90 | var_dump($channelInformation); 91 | } 92 | ``` 93 |
94 | 95 | ### Delete a channel 96 | 97 |
98 | Show code 99 | 100 | ```php 101 | channel('/path-to-publisher-endpoint'); 111 | $channel->delete(); 112 | } 113 | ``` 114 |
115 | 116 | ### Nchan status information 117 | 118 | Endpoints with the `nchan_stub_status` directive can be queried as follows. 119 | 120 |
121 | Show code 122 | 123 | ```php 124 | status('/path-to-status-location'); 134 | $statusInformation = $status->information(); 135 | 136 | var_dump($statusInformation); 137 | } 138 | ``` 139 |
140 | 141 | ### Authorize requests 142 | 143 | Endpoints with the `nchan_authorize_request` directive must be authorized. 144 | The constructor of the 145 | [built-in http client](/src/HttpAdapter/HttpStreamWrapperClient.php) 146 | takes an implementation of type 147 | [Credentials](/src/HttpAdapter/Credentials.php). 148 | This library comes with 2 built-in implementations, 149 | [BasicAuthenticationCredentials](/src/HttpAdapter/BasicAuthenticationCredentials.php) 150 | and 151 | [BearerAuthenticationCredentials](/src/HttpAdapter/BearerAuthenticationCredentials.php). 152 | 153 |
154 | Show code 155 | 156 | ```php 157 | 182 | 183 | If you use another http client through the 184 | [PSR-18 adapter](#psr-18-compatibility), 185 | the respective http client has its own extension points to modify the request before it is sent. 186 | 187 | ## PSR-18 compatibility 188 | 189 | This library comes with a PSR-18 compatible 190 | [adapter](/src/HttpAdapter/Psr18ClientAdapter.php). 191 | There are good reasons not to use the built-in client. 192 | It's based on the http stream wrapper and `file_get_contents`. 193 | This closes the TCP connection after each request. 194 | Other clients, see below, can keep the connection open. 195 | 196 | The following example uses 197 | [guzzlehttp/guzzle](https://packagist.org/packages/guzzlehttp/guzzle) 198 | and 199 | [guzzlehttp/psr7](https://packagist.org/packages/guzzlehttp/psr7). 200 | 201 |
202 | Show code 203 | 204 | ```php 205 | 227 | 228 | The following code example uses 229 | [symfony/http-client](https://packagist.org/packages/symfony/http-client) 230 | and 231 | [nyholm/psr7](https://packagist.org/packages/nyholm/psr7). 232 | 233 |
234 | Show code 235 | 236 | ```php 237 | 267 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "marein/php-nchan-client", 3 | "description": "A PHP https://nchan.io client.", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "Markus Reinhold", 8 | "email": "markusreinhold@icloud.com" 9 | } 10 | ], 11 | "autoload": { 12 | "psr-4": { 13 | "Marein\\Nchan\\": "src/" 14 | } 15 | }, 16 | "autoload-dev": { 17 | "psr-4": { 18 | "Marein\\Nchan\\Tests\\": "tests" 19 | } 20 | }, 21 | "require": { 22 | "php": "^7.4 || ^8.0", 23 | "ext-json": "*", 24 | "psr/http-client": "^1.0", 25 | "psr/http-message": "^1.0 || ^2.0", 26 | "psr/http-factory": "^1.0" 27 | }, 28 | "require-dev": { 29 | "phpunit/phpunit": "^9.5", 30 | "symfony/process": "^5.4", 31 | "squizlabs/php_codesniffer": "3.6.2", 32 | "phpstan/phpstan": "1.3.3", 33 | "symfony/http-client": "^5.4", 34 | "nyholm/psr7": "^1.5" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /phpcs.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | src/ 8 | tests/ 9 | 10 | -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | parameters: 2 | paths: 3 | - src 4 | level: max 5 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 15 | 16 | 17 | ./tests/Unit 18 | 19 | 20 | ./tests/Integration 21 | 22 | 23 | 24 | 25 | 26 | ./src 27 | 28 | 29 | 30 | 31 | 32 | 33 | Marein\Nchan\Tests\Integration\HttpAdapter\HttpStreamWrapperClientTest 34 | 127.0.0.1:8000 35 | tests/TestServer/index.php 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /src/Api/Channel.php: -------------------------------------------------------------------------------- 1 | channelUrl = $channelUrl; 26 | $this->client = new ThrowExceptionIfRequestRequiresAuthenticationClient( 27 | $client 28 | ); 29 | } 30 | 31 | /** 32 | * @throws AuthenticationRequiredException 33 | * @throws NchanException 34 | */ 35 | public function publish(Message $message): ChannelInformation 36 | { 37 | $response = $this->client->post( 38 | new Request( 39 | $this->channelUrl, 40 | [ 41 | 'Accept' => 'application/json', 42 | 'Content-Type' => $message->contentType(), 43 | 'X-EventSource-Event' => $message->name() 44 | ], 45 | $message->content() 46 | ) 47 | ); 48 | 49 | if (!in_array($response->statusCode(), [Response::CREATED, Response::ACCEPTED])) { 50 | throw new NchanException( 51 | sprintf( 52 | 'Unable to publish to channel. Maybe the channel does not exists. HTTP status code was %s.', 53 | $response->statusCode() 54 | ) 55 | ); 56 | } 57 | 58 | return ChannelInformation::fromJson($response->body()); 59 | } 60 | 61 | /** 62 | * @throws AuthenticationRequiredException 63 | * @throws NchanException 64 | */ 65 | public function information(): ChannelInformation 66 | { 67 | $response = $this->client->get( 68 | new Request( 69 | $this->channelUrl, 70 | [ 71 | 'Accept' => 'application/json' 72 | ] 73 | ) 74 | ); 75 | 76 | if ($response->statusCode() !== Response::OK) { 77 | throw new NchanException( 78 | sprintf( 79 | 'Unable to get channel information. Maybe the channel does not exists. HTTP status code was %s.', 80 | $response->statusCode() 81 | ) 82 | ); 83 | } 84 | 85 | return ChannelInformation::fromJson($response->body()); 86 | } 87 | 88 | /** 89 | * @throws AuthenticationRequiredException 90 | * @throws NchanException 91 | */ 92 | public function delete(): void 93 | { 94 | $response = $this->client->delete( 95 | new Request( 96 | $this->channelUrl, 97 | [ 98 | 'Accept' => 'application/json' 99 | ] 100 | ) 101 | ); 102 | 103 | if (!in_array($response->statusCode(), [Response::OK, RESPONSE::NOT_FOUND])) { 104 | throw new NchanException( 105 | sprintf( 106 | 'Unable to delete channel. Maybe the channel does not exists. HTTP status code was %s.', 107 | $response->statusCode() 108 | ) 109 | ); 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/Api/Model/ChannelInformation.php: -------------------------------------------------------------------------------- 1 | numberOfMessages = $numberOfMessages; 45 | $this->secondsSinceLastPublishedMessage = $secondsSinceLastPublishedMessage; 46 | $this->numberOfSubscribers = $numberOfSubscribers; 47 | $this->lastMessageIdentifier = $lastMessageIdentifier; 48 | } 49 | 50 | /** 51 | * The json must look like this: 52 | * { 53 | * "messages": 10, 54 | * "requested": 1, 55 | * "subscribers": 100, 56 | * "last_message_id": "1504818382:1" 57 | * } 58 | * 59 | * @throws NchanException 60 | */ 61 | public static function fromJson(string $json): ChannelInformation 62 | { 63 | $response = json_decode($json, true); 64 | 65 | if (!is_array($response)) { 66 | throw new NchanException('Unable to parse JSON response: ' . json_last_error_msg()); 67 | } 68 | 69 | // Check if required keys exists in $response. 70 | if (count(array_diff_key(array_flip(self::REQUIRED_JSON_KEYS), $response)) !== 0) { 71 | throw new NchanException( 72 | sprintf( 73 | 'Unable to parse JSON response: Keys "%s" are required. Keys "%s" exists.', 74 | implode('", "', self::REQUIRED_JSON_KEYS), 75 | implode('", "', array_keys($response)) 76 | ) 77 | ); 78 | } 79 | 80 | return new self( 81 | (int)$response['messages'], 82 | (int)$response['requested'], 83 | (int)$response['subscribers'], 84 | (string)$response['last_message_id'] 85 | ); 86 | } 87 | 88 | /** 89 | * @return mixed 90 | */ 91 | public function __get(string $name) 92 | { 93 | return $this->$name; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Api/Model/JsonMessage.php: -------------------------------------------------------------------------------- 1 | name = $name; 16 | $this->content = $content; 17 | } 18 | 19 | public function name(): string 20 | { 21 | return $this->name; 22 | } 23 | 24 | public function content(): string 25 | { 26 | return $this->content; 27 | } 28 | 29 | abstract public function contentType(): string; 30 | } 31 | -------------------------------------------------------------------------------- /src/Api/Model/PlainTextMessage.php: -------------------------------------------------------------------------------- 1 | numberOfTotalPublishedMessages = $numberOfTotalPublishedMessages; 111 | $this->numberOfStoredMessages = $numberOfStoredMessages; 112 | $this->sharedMemoryUsedInKilobyte = $sharedMemoryUsedInKilobyte; 113 | $this->numberOfChannels = $numberOfChannels; 114 | $this->numberOfSubscribers = $numberOfSubscribers; 115 | $this->numberOfPendingRedisCommands = $numberOfPendingRedisCommands; 116 | $this->numberOfConnectedRedisServers = $numberOfConnectedRedisServers; 117 | $this->numberOfTotalReceivedInterprocessAlerts = $numberOfTotalReceivedInterprocessAlerts; 118 | $this->numberOfInterprocessAlertsInTransit = $numberOfInterprocessAlertsInTransit; 119 | $this->numberOfQueuedInterprocessAlerts = $numberOfQueuedInterprocessAlerts; 120 | $this->totalInterprocessSendDelayInSeconds = $totalInterprocessSendDelayInSeconds; 121 | $this->totalInterprocessReceiveDelayInSeconds = $totalInterprocessReceiveDelayInSeconds; 122 | } 123 | 124 | /** 125 | * The plain text must look like this: 126 | * total published messages: 3 127 | * stored messages: 3 128 | * shared memory used: 16K 129 | * channels: 2 130 | * subscribers: 2 131 | * redis pending commands: 0 132 | * redis connected servers: 2 133 | * total interprocess alerts received: 0 134 | * interprocess alerts in transit: 0 135 | * interprocess queued alerts: 0 136 | * total interprocess send delay: 0 137 | * total interprocess receive delay: 0 138 | * 139 | * @throws NchanException 140 | */ 141 | public static function fromPlainText(string $plainText): StatusInformation 142 | { 143 | $plainText = trim($plainText); 144 | $lines = explode("\n", $plainText); 145 | 146 | $response = []; 147 | foreach ($lines as $line) { 148 | // Appending ': ' prevents error if no ': ' exists. 149 | [$key, $value] = explode(': ', trim($line) . ': '); 150 | $response[$key] = $value; 151 | } 152 | 153 | // Check if required keys exists in $response. 154 | if (count(array_diff_key(array_flip(self::REQUIRED_PLAIN_TEXT_KEYS), $response)) !== 0) { 155 | throw new NchanException( 156 | sprintf( 157 | 'Unable to parse status information: Keys "%s" are required. Keys "%s" exists.', 158 | implode('", "', self::REQUIRED_PLAIN_TEXT_KEYS), 159 | implode('", "', array_keys($response)) 160 | ) 161 | ); 162 | } 163 | 164 | return new self( 165 | (int)$response['total published messages'], 166 | (int)$response['stored messages'], 167 | (int)$response['shared memory used'], 168 | (int)$response['channels'], 169 | (int)$response['subscribers'], 170 | (int)$response['redis pending commands'], 171 | (int)$response['redis connected servers'], 172 | (int)$response['total interprocess alerts received'], 173 | (int)$response['interprocess alerts in transit'], 174 | (int)$response['interprocess queued alerts'], 175 | (int)$response['total interprocess send delay'], 176 | (int)$response['total interprocess receive delay'] 177 | ); 178 | } 179 | 180 | /** 181 | * @return mixed 182 | */ 183 | public function __get(string $name) 184 | { 185 | return $this->$name; 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /src/Api/Model/XmlMessage.php: -------------------------------------------------------------------------------- 1 | statusUrl = $statusUrl; 25 | $this->client = new ThrowExceptionIfRequestRequiresAuthenticationClient( 26 | $client 27 | ); 28 | } 29 | 30 | /** 31 | * @throws AuthenticationRequiredException 32 | * @throws NchanException 33 | */ 34 | public function information(): StatusInformation 35 | { 36 | $response = $this->client->get( 37 | new Request( 38 | $this->statusUrl, 39 | [ 40 | 'Accept' => 'text/plain' 41 | ] 42 | ) 43 | ); 44 | 45 | if ($response->statusCode() !== Response::OK) { 46 | throw new NchanException( 47 | sprintf( 48 | 'Unable to retrieve status information. HTTP status code was %s.', 49 | $response->statusCode() 50 | ) 51 | ); 52 | } 53 | 54 | return StatusInformation::fromPlainText($response->body()); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Exception/AuthenticationRequiredException.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | private array $headers; 15 | 16 | private string $body; 17 | 18 | /** 19 | * @param array $headers 20 | */ 21 | public function __construct(Url $url, array $headers, string $body = '') 22 | { 23 | $this->url = $url; 24 | $this->headers = $headers; 25 | $this->body = $body; 26 | } 27 | 28 | public function url(): Url 29 | { 30 | return $this->url; 31 | } 32 | 33 | /** 34 | * @return array 35 | */ 36 | public function headers(): array 37 | { 38 | return $this->headers; 39 | } 40 | 41 | public function body(): string 42 | { 43 | return $this->body; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Http/Response.php: -------------------------------------------------------------------------------- 1 | client = $client; 16 | } 17 | 18 | public function get(Request $request): Response 19 | { 20 | return $this->throwExceptionIfRequestRequiresAuthenticationOrReturnResponse( 21 | 'GET', 22 | $request, 23 | $this->client->get($request) 24 | ); 25 | } 26 | 27 | public function post(Request $request): Response 28 | { 29 | return $this->throwExceptionIfRequestRequiresAuthenticationOrReturnResponse( 30 | 'POST', 31 | $request, 32 | $this->client->post($request) 33 | ); 34 | } 35 | 36 | public function delete(Request $request): Response 37 | { 38 | return $this->throwExceptionIfRequestRequiresAuthenticationOrReturnResponse( 39 | 'DELETE', 40 | $request, 41 | $this->client->delete($request) 42 | ); 43 | } 44 | 45 | /** 46 | * @throws AuthenticationRequiredException 47 | */ 48 | private function throwExceptionIfRequestRequiresAuthenticationOrReturnResponse( 49 | string $method, 50 | Request $request, 51 | Response $response 52 | ): Response { 53 | if ($response->statusCode() === Response::FORBIDDEN) { 54 | throw new AuthenticationRequiredException( 55 | sprintf( 56 | 'Request to "%s %s" requires authentication.', 57 | $method, 58 | $request->url() 59 | ) 60 | ); 61 | } 62 | 63 | return $response; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Http/Url.php: -------------------------------------------------------------------------------- 1 | value = $value; 39 | } 40 | 41 | /** 42 | * @throws InvalidUrlException 43 | */ 44 | public function append(string $value): Url 45 | { 46 | return new Url($this . $value); 47 | } 48 | 49 | public function __toString(): string 50 | { 51 | return $this->toString(); 52 | } 53 | 54 | public function toString(): string 55 | { 56 | return $this->value; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/HttpAdapter/BasicAuthenticationCredentials.php: -------------------------------------------------------------------------------- 1 | username = $username; 18 | $this->password = $password; 19 | } 20 | 21 | public function authenticate(Request $request): Request 22 | { 23 | $headers = $request->headers(); 24 | 25 | $encodedCredentials = base64_encode($this->username . ':' . $this->password); 26 | 27 | $headers['Authorization'] = 'Basic ' . $encodedCredentials; 28 | 29 | return new Request( 30 | $request->url(), 31 | $headers, 32 | $request->body() 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/HttpAdapter/BearerAuthenticationCredentials.php: -------------------------------------------------------------------------------- 1 | token = $token; 16 | } 17 | 18 | public function authenticate(Request $request): Request 19 | { 20 | $headers = $request->headers(); 21 | 22 | $headers['Authorization'] = 'Bearer ' . $this->token; 23 | 24 | return new Request( 25 | $request->url(), 26 | $headers, 27 | $request->body() 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/HttpAdapter/Credentials.php: -------------------------------------------------------------------------------- 1 | credentials = $credentials; 19 | } 20 | 21 | public static function withDefaults(): HttpStreamWrapperClient 22 | { 23 | return new self( 24 | new WithoutAuthenticationCredentials() 25 | ); 26 | } 27 | 28 | public function get(Request $request): Response 29 | { 30 | return $this->request('GET', $request); 31 | } 32 | 33 | public function post(Request $request): Response 34 | { 35 | return $this->request('POST', $request); 36 | } 37 | 38 | public function delete(Request $request): Response 39 | { 40 | return $this->request('DELETE', $request); 41 | } 42 | 43 | /** 44 | * @throws NchanException 45 | */ 46 | private function request(string $method, Request $request): Response 47 | { 48 | $request = $this->credentials->authenticate($request); 49 | 50 | $url = $request->url()->toString(); 51 | $headers = $request->headers(); 52 | $body = $request->body(); 53 | 54 | $options = [ 55 | 'http' => 56 | [ 57 | 'method' => $method, 58 | 'header' => $this->prepareHeadersForStreamContext($headers), 59 | 'content' => $body, 60 | 'ignore_errors' => true 61 | ] 62 | ]; 63 | 64 | $context = stream_context_create($options); 65 | 66 | // Suppress errors for file_get_contents. We will analyze this ourselves. 67 | $errorReportingLevelBeforeFileGetContents = error_reporting(0); 68 | 69 | $responseBody = file_get_contents( 70 | $url, 71 | false, 72 | $context 73 | ); 74 | 75 | error_reporting($errorReportingLevelBeforeFileGetContents); 76 | 77 | if ($responseBody === false) { 78 | throw new NchanException( 79 | error_get_last()['message'] ?? 'Unable to connect to ' . $url . '.' 80 | ); 81 | } 82 | 83 | return HttpStreamWrapperResponse::fromResponse($http_response_header, $responseBody); 84 | } 85 | 86 | /** 87 | * Transforms the array from 88 | * [ 89 | * 'firstHeaderName' => 'firstHeaderValue', 90 | * 'secondHeaderName' => 'secondHeaderValue' 91 | * ] 92 | * to string 93 | * "firstHeaderName: firstHeaderValue\r\n 94 | * secondHeaderName: secondHeaderValue". 95 | * 96 | * @param array $headers 97 | */ 98 | private function prepareHeadersForStreamContext(array $headers): string 99 | { 100 | return implode( 101 | "\r\n", 102 | array_map( 103 | static fn(string $name, string $value) => $name . ': ' . $value, 104 | array_keys($headers), 105 | $headers 106 | ) 107 | ); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/HttpAdapter/HttpStreamWrapperResponse.php: -------------------------------------------------------------------------------- 1 | statusCode = $statusCode; 19 | $this->body = $body; 20 | } 21 | 22 | /** 23 | * @param array $headers The result from $http_response_header. 24 | * 25 | * @throws NchanException 26 | */ 27 | public static function fromResponse(array $headers, string $body): HttpStreamWrapperResponse 28 | { 29 | // The first array value is for example "HTTP\1.1 200 OK" and must be set. 30 | if (!isset($headers[0])) { 31 | throw new NchanException('Unable to parse response header.'); 32 | } 33 | 34 | // Get the status code. 35 | preg_match('/HTTP[^ ]+ (\d+)/', $headers[0], $matches); 36 | 37 | if (!isset($matches[1])) { 38 | throw new NchanException('Unable to retrieve status code from response header.'); 39 | } 40 | 41 | $statusCode = (int)$matches[1]; 42 | 43 | return new self( 44 | $statusCode, 45 | $body 46 | ); 47 | } 48 | 49 | public function statusCode(): int 50 | { 51 | return $this->statusCode; 52 | } 53 | 54 | public function body(): string 55 | { 56 | return $this->body; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/HttpAdapter/Psr18ClientAdapter.php: -------------------------------------------------------------------------------- 1 | client = $client; 30 | $this->requestFactory = $requestFactory; 31 | $this->streamFactory = $streamFactory; 32 | } 33 | 34 | public function get(Request $request): Response 35 | { 36 | return $this->request('GET', $request); 37 | } 38 | 39 | public function post(Request $request): Response 40 | { 41 | return $this->request('POST', $request); 42 | } 43 | 44 | public function delete(Request $request): Response 45 | { 46 | return $this->request('DELETE', $request); 47 | } 48 | 49 | /** 50 | * @throws NchanException 51 | */ 52 | private function request(string $method, Request $request): Response 53 | { 54 | try { 55 | $psrRequest = $this->requestFactory 56 | ->createRequest($method, $request->url()->toString()) 57 | ->withBody($this->streamFactory->createStream($request->body())); 58 | 59 | foreach ($request->headers() as $name => $value) { 60 | $psrRequest = $psrRequest->withHeader($name, $value); 61 | } 62 | 63 | return new Psr7ResponseAdapter($this->client->sendRequest($psrRequest)); 64 | } catch (ClientExceptionInterface $exception) { 65 | throw new NchanException( 66 | $exception->getMessage(), 67 | $exception->getCode(), 68 | $exception 69 | ); 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/HttpAdapter/Psr7ResponseAdapter.php: -------------------------------------------------------------------------------- 1 | response = $response; 17 | } 18 | 19 | public function statusCode(): int 20 | { 21 | return $this->response->getStatusCode(); 22 | } 23 | 24 | public function body(): string 25 | { 26 | return (string)$this->response->getBody(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/HttpAdapter/WithoutAuthenticationCredentials.php: -------------------------------------------------------------------------------- 1 | baseUrl = new Url($baseUrl); 26 | $this->client = $client ?? HttpStreamWrapperClient::withDefaults(); 27 | } 28 | 29 | /** 30 | * @throws InvalidUrlException 31 | */ 32 | public function channel(string $name): Channel 33 | { 34 | return new Channel( 35 | $this->baseUrl->append($name), 36 | $this->client 37 | ); 38 | } 39 | 40 | /** 41 | * The path must be configured with the "nchan_stub_status;" directive. 42 | * 43 | * @throws InvalidUrlException 44 | */ 45 | public function status(string $path): Status 46 | { 47 | return new Status( 48 | $this->baseUrl->append($path), 49 | $this->client 50 | ); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /tests/Integration/HttpAdapter/ClientTestCase.php: -------------------------------------------------------------------------------- 1 | 'application/json' 26 | ] 27 | ); 28 | 29 | $client = $this->createClient($request); 30 | 31 | $response = $client->get($request); 32 | 33 | $serverResponse = unserialize($response->body()); 34 | 35 | $this->assertSame(201, $response->statusCode()); 36 | $this->assertSame('application/json', $serverResponse['SERVER']['HTTP_ACCEPT']); 37 | } 38 | 39 | /** 40 | * @test 41 | */ 42 | public function itShouldPerformPostRequest(): void 43 | { 44 | $request = new Request( 45 | new Url(getenv('INTEGRATION_TEST_BASE_URL') . '?statusCode=201'), 46 | [ 47 | 'Accept' => 'application/json', 48 | 'Content-Type' => 'application/x-www-form-urlencoded' 49 | ], 50 | http_build_query( 51 | [ 52 | 'message' => 'my-message-name' 53 | ] 54 | ) 55 | ); 56 | 57 | $client = $this->createClient($request); 58 | 59 | $response = $client->post($request); 60 | 61 | $serverResponse = unserialize($response->body()); 62 | 63 | $this->assertSame(201, $response->statusCode()); 64 | $this->assertSame('application/json', $serverResponse['SERVER']['HTTP_ACCEPT']); 65 | $this->assertSame('my-message-name', $serverResponse['POST']['message']); 66 | } 67 | 68 | /** 69 | * @test 70 | */ 71 | public function itShouldPerformDeleteRequest(): void 72 | { 73 | $request = new Request( 74 | new Url(getenv('INTEGRATION_TEST_BASE_URL') . '?statusCode=201'), 75 | [ 76 | 'Accept' => 'application/json' 77 | ] 78 | ); 79 | 80 | $client = $this->createClient($request); 81 | 82 | $response = $client->delete($request); 83 | 84 | $serverResponse = unserialize($response->body()); 85 | 86 | $this->assertSame(201, $response->statusCode()); 87 | $this->assertSame('application/json', $serverResponse['SERVER']['HTTP_ACCEPT']); 88 | } 89 | 90 | /** 91 | * @test 92 | */ 93 | public function itShouldThrowExceptionOnInvalidResponse(): void 94 | { 95 | $this->expectException(NchanException::class); 96 | 97 | $request = new Request( 98 | new Url(getenv('INTEGRATION_TEST_INVALID_BASE_URL')), 99 | [ 100 | 'Accept' => 'application/json' 101 | ] 102 | ); 103 | 104 | $client = $this->createClient($request); 105 | 106 | $client->get($request); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /tests/Integration/HttpAdapter/HttpStreamWrapperClientTest.php: -------------------------------------------------------------------------------- 1 | createMock(Credentials::class); 17 | $credentials->expects($this->once())->method('authenticate')->willReturn($request); 18 | 19 | return new HttpStreamWrapperClient($credentials); 20 | } 21 | 22 | /** 23 | * @test 24 | */ 25 | public function itShouldBeCreatedWithDefaults(): void 26 | { 27 | HttpStreamWrapperClient::withDefaults(); 28 | 29 | $this->assertTrue(true); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests/Integration/HttpAdapter/Psr18ClientAdapterTest.php: -------------------------------------------------------------------------------- 1 | suiteName = $suiteName; 41 | $this->socket = $socket; 42 | $this->documentRoot = $documentRoot; 43 | } 44 | 45 | /** 46 | * @inheritdoc 47 | */ 48 | public function startTestSuite(TestSuite $suite): void 49 | { 50 | if ($suite->getName() === $this->suiteName) { 51 | $process = new Process( 52 | [ 53 | 'php', 54 | '-S', 55 | $this->socket, 56 | $this->documentRoot 57 | ] 58 | ); 59 | $process->start(); 60 | 61 | // Wait for the server. 62 | sleep(1); 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /tests/TestServer/index.php: -------------------------------------------------------------------------------- 1 | $_GET, 14 | 'POST' => $_POST, 15 | 'SERVER' => $_SERVER 16 | ] 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /tests/Unit/Api/ChannelTest.php: -------------------------------------------------------------------------------- 1 | 'text/plain', 31 | 'Accept' => 'application/json', 32 | 'X-EventSource-Event' => 'my-message-name' 33 | ], 34 | 'my message content' 35 | ); 36 | 37 | $response = $this->createMock(Response::class); 38 | $response->method('body')->willReturn( 39 | '{"messages": 10, "requested": 1, "subscribers": 100, "last_message_id": "1504818382:1"}' 40 | ); 41 | $response->method('statusCode')->willReturn($statusCode); 42 | 43 | $client = $this->createMock(Client::class); 44 | $client->method('post')->with($expectedRequest)->willReturn($response); 45 | 46 | $message = $this->createMock(Message::class); 47 | $message->method('contentType')->willReturn('text/plain'); 48 | $message->method('name')->willReturn('my-message-name'); 49 | $message->method('content')->willReturn('my message content'); 50 | 51 | $channel = new Channel(new Url('http://localhost'), $client); 52 | $information = $channel->publish($message); 53 | 54 | $this->assertInstanceOf(ChannelInformation::class, $information); 55 | } 56 | } 57 | 58 | /** 59 | * @test 60 | */ 61 | public function itShouldReturnInformation(): void 62 | { 63 | $expectedRequest = new Request( 64 | new Url('http://localhost'), 65 | [ 66 | 'Accept' => 'application/json', 67 | ] 68 | ); 69 | 70 | $response = $this->createMock(Response::class); 71 | $response->method('body')->willReturn( 72 | '{"messages": 10, "requested": 1, "subscribers": 100, "last_message_id": "1504818382:1"}' 73 | ); 74 | $response->method('statusCode')->willReturn(200); 75 | 76 | $client = $this->createMock(Client::class); 77 | $client->method('get')->with($expectedRequest)->willReturn($response); 78 | 79 | $channel = new Channel(new Url('http://localhost'), $client); 80 | $information = $channel->information(); 81 | 82 | $this->assertInstanceOf(ChannelInformation::class, $information); 83 | } 84 | 85 | /** 86 | * @test 87 | */ 88 | public function itShouldDeleteChannel(): void 89 | { 90 | // 200 and 404 are valid status codes 91 | foreach ([200, 404] as $statusCode) { 92 | $expectedRequest = new Request( 93 | new Url('http://localhost'), 94 | [ 95 | 'Accept' => 'application/json', 96 | ] 97 | ); 98 | 99 | $response = $this->createMock(Response::class); 100 | $response->method('statusCode')->willReturn($statusCode); 101 | 102 | $client = $this->createMock(Client::class); 103 | $client->method('delete')->with($expectedRequest)->willReturn($response); 104 | 105 | $channel = new Channel(new Url('http://localhost'), $client); 106 | $channel->delete(); 107 | 108 | // Test passed 109 | $this->assertTrue(true); 110 | } 111 | } 112 | 113 | /** 114 | * @test 115 | */ 116 | public function publishShouldThrowExceptionOnNotExpectedStatusCode() 117 | { 118 | $this->expectException(NchanException::class); 119 | 120 | $response = $this->createMock(Response::class); 121 | $response->method('statusCode')->willReturn(302); 122 | 123 | $client = $this->createMock(Client::class); 124 | $client->method('post')->willReturn($response); 125 | 126 | $message = $this->createMock(Message::class); 127 | 128 | $channel = new Channel(new Url('http://localhost'), $client); 129 | $channel->publish($message); 130 | } 131 | 132 | /** 133 | * @test 134 | */ 135 | public function informationShouldThrowExceptionOnNotExpectedStatusCode() 136 | { 137 | $this->expectException(NchanException::class); 138 | 139 | $response = $this->createMock(Response::class); 140 | $response->method('statusCode')->willReturn(302); 141 | 142 | $client = $this->createMock(Client::class); 143 | $client->method('get')->willReturn($response); 144 | 145 | $channel = new Channel(new Url('http://localhost'), $client); 146 | $channel->information(); 147 | } 148 | 149 | /** 150 | * @test 151 | */ 152 | public function deleteShouldThrowExceptionOnNotExpectedStatusCode() 153 | { 154 | $this->expectException(NchanException::class); 155 | 156 | $response = $this->createMock(Response::class); 157 | $response->method('statusCode')->willReturn(302); 158 | 159 | $client = $this->createMock(Client::class); 160 | $client->method('delete')->willReturn($response); 161 | 162 | $channel = new Channel(new Url('http://localhost'), $client); 163 | $channel->delete(); 164 | } 165 | 166 | /** 167 | * @test 168 | */ 169 | public function publishShouldThrowExceptionIfForbidden() 170 | { 171 | $this->expectException(AuthenticationRequiredException::class); 172 | 173 | $response = $this->createMock(Response::class); 174 | $response->method('statusCode')->willReturn(403); 175 | 176 | $client = $this->createMock(Client::class); 177 | $client->method('post')->willReturn($response); 178 | 179 | $message = $this->createMock(Message::class); 180 | 181 | $channel = new Channel(new Url('http://localhost'), $client); 182 | $channel->publish($message); 183 | } 184 | 185 | /** 186 | * @test 187 | */ 188 | public function informationShouldThrowExceptionIfForbidden() 189 | { 190 | $this->expectException(AuthenticationRequiredException::class); 191 | 192 | $response = $this->createMock(Response::class); 193 | $response->method('statusCode')->willReturn(403); 194 | 195 | $client = $this->createMock(Client::class); 196 | $client->method('get')->willReturn($response); 197 | 198 | $channel = new Channel(new Url('http://localhost'), $client); 199 | $channel->information(); 200 | } 201 | 202 | /** 203 | * @test 204 | */ 205 | public function deleteShouldThrowExceptionIfForbidden() 206 | { 207 | $this->expectException(AuthenticationRequiredException::class); 208 | 209 | $response = $this->createMock(Response::class); 210 | $response->method('statusCode')->willReturn(403); 211 | 212 | $client = $this->createMock(Client::class); 213 | $client->method('delete')->willReturn($response); 214 | 215 | $channel = new Channel(new Url('http://localhost'), $client); 216 | $channel->delete(); 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /tests/Unit/Api/Model/ChannelInformationTest.php: -------------------------------------------------------------------------------- 1 | validJson(); 19 | 20 | $channelInformation = ChannelInformation::fromJson($json); 21 | 22 | $this->assertSame(10, $channelInformation->numberOfMessages); 23 | $this->assertSame(1, $channelInformation->secondsSinceLastPublishedMessage); 24 | $this->assertSame(100, $channelInformation->numberOfSubscribers); 25 | $this->assertSame('1504818382:1', $channelInformation->lastMessageIdentifier); 26 | } 27 | 28 | /** 29 | * @test 30 | */ 31 | public function itShouldBeThrowAnExceptionWhenJsonIsInvalid(): void 32 | { 33 | $this->expectException(NchanException::class); 34 | 35 | $json = 'malformed json"}'; 36 | 37 | ChannelInformation::fromJson($json); 38 | } 39 | 40 | /** 41 | * @test 42 | * @dataProvider missingKeysProvider 43 | */ 44 | public function itShouldBeThrowAnExceptionWhenJsonHasMissingKeys(string $json): void 45 | { 46 | $this->expectException(NchanException::class); 47 | 48 | ChannelInformation::fromJson($json); 49 | } 50 | 51 | /** 52 | * Returns various json with missing keys. 53 | * 54 | * @return array 55 | */ 56 | public function missingKeysProvider(): array 57 | { 58 | return [ 59 | ['{"requested": 1, "subscribers": 100, "last_message_id": "1504818382:1"}'], 60 | ['{"messages": 10, "subscribers": 100, "last_message_id": "1504818382:1"}'], 61 | ['{"messages": 10, "requested": 1, "last_message_id": "1504818382:1"}'], 62 | ['{"messages": 10, "requested": 1, "subscribers": 100}'] 63 | ]; 64 | } 65 | 66 | /** 67 | * Returns a valid json for tests. 68 | * 69 | * @return string 70 | */ 71 | private function validJson(): string 72 | { 73 | return '{"messages": 10, "requested": 1, "subscribers": 100, "last_message_id": "1504818382:1"}'; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /tests/Unit/Api/Model/JsonMessageTest.php: -------------------------------------------------------------------------------- 1 | assertEquals($expectedName, $message->name()); 24 | $this->assertEquals($expectedContent, $message->content()); 25 | $this->assertEquals($expectedContentType, $message->contentType()); 26 | } 27 | 28 | /** 29 | * @test 30 | */ 31 | public function itShouldBeCreatedWithEmptyName(): void 32 | { 33 | $expectedName = ''; 34 | $expectedContent = 'my message content'; 35 | $expectedContentType = 'application/json'; 36 | 37 | $message = new JsonMessage($expectedName, $expectedContent); 38 | 39 | $this->assertEquals($expectedName, $message->name()); 40 | $this->assertEquals($expectedContent, $message->content()); 41 | $this->assertEquals($expectedContentType, $message->contentType()); 42 | } 43 | 44 | /** 45 | * @test 46 | */ 47 | public function itShouldBeCreatedWithEmptyContent(): void 48 | { 49 | $expectedName = 'my-message-name'; 50 | $expectedContent = ''; 51 | $expectedContentType = 'application/json'; 52 | 53 | $message = new JsonMessage($expectedName, $expectedContent); 54 | 55 | $this->assertEquals($expectedName, $message->name()); 56 | $this->assertEquals($expectedContent, $message->content()); 57 | $this->assertEquals($expectedContentType, $message->contentType()); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /tests/Unit/Api/Model/PlainTextMessageTest.php: -------------------------------------------------------------------------------- 1 | assertEquals($expectedName, $message->name()); 24 | $this->assertEquals($expectedContent, $message->content()); 25 | $this->assertEquals($expectedContentType, $message->contentType()); 26 | } 27 | 28 | /** 29 | * @test 30 | */ 31 | public function itShouldBeCreatedWithEmptyName(): void 32 | { 33 | $expectedName = ''; 34 | $expectedContent = 'my message content'; 35 | $expectedContentType = 'text/plain'; 36 | 37 | $message = new PlainTextMessage($expectedName, $expectedContent); 38 | 39 | $this->assertEquals($expectedName, $message->name()); 40 | $this->assertEquals($expectedContent, $message->content()); 41 | $this->assertEquals($expectedContentType, $message->contentType()); 42 | } 43 | 44 | /** 45 | * @test 46 | */ 47 | public function itShouldBeCreatedWithEmptyContent(): void 48 | { 49 | $expectedName = 'my-message-name'; 50 | $expectedContent = ''; 51 | $expectedContentType = 'text/plain'; 52 | 53 | $message = new PlainTextMessage($expectedName, $expectedContent); 54 | 55 | $this->assertEquals($expectedName, $message->name()); 56 | $this->assertEquals($expectedContent, $message->content()); 57 | $this->assertEquals($expectedContentType, $message->contentType()); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /tests/Unit/Api/Model/StatusInformationTest.php: -------------------------------------------------------------------------------- 1 | validPlainText(); 19 | 20 | $statusInformation = StatusInformation::fromPlainText($plainText); 21 | 22 | $this->assertSame(1, $statusInformation->numberOfTotalPublishedMessages); 23 | $this->assertSame(2, $statusInformation->numberOfStoredMessages); 24 | $this->assertSame(3, $statusInformation->sharedMemoryUsedInKilobyte); 25 | $this->assertSame(4, $statusInformation->numberOfChannels); 26 | $this->assertSame(5, $statusInformation->numberOfSubscribers); 27 | $this->assertSame(6, $statusInformation->numberOfPendingRedisCommands); 28 | $this->assertSame(7, $statusInformation->numberOfConnectedRedisServers); 29 | $this->assertSame(8, $statusInformation->numberOfTotalReceivedInterprocessAlerts); 30 | $this->assertSame(9, $statusInformation->numberOfInterprocessAlertsInTransit); 31 | $this->assertSame(10, $statusInformation->numberOfQueuedInterprocessAlerts); 32 | $this->assertSame(11, $statusInformation->totalInterprocessSendDelayInSeconds); 33 | $this->assertSame(12, $statusInformation->totalInterprocessReceiveDelayInSeconds); 34 | } 35 | 36 | /** 37 | * @test 38 | */ 39 | public function itShouldBeThrowAnExceptionWhenPlainTextIsInvalid(): void 40 | { 41 | $this->expectException(NchanException::class); 42 | 43 | $plainText = 'malformed plain text'; 44 | 45 | StatusInformation::fromPlainText($plainText); 46 | } 47 | 48 | /** 49 | * @test 50 | * @dataProvider missingKeysProvider 51 | */ 52 | public function itShouldBeThrowAnExceptionWhenPlainTextHasMissingKeys(string $plainText): void 53 | { 54 | $this->expectException(NchanException::class); 55 | 56 | StatusInformation::fromPlainText($plainText); 57 | } 58 | 59 | /** 60 | * Returns various plain text with missing keys. 61 | * 62 | * @return array 63 | */ 64 | public function missingKeysProvider(): array 65 | { 66 | return [ 67 | [ 68 | ' 69 | stored messages: 2 70 | shared memory used: 3K 71 | channels: 4 72 | subscribers: 5 73 | redis pending commands: 6 74 | redis connected servers: 7 75 | total interprocess alerts received: 8 76 | interprocess alerts in transit: 9 77 | interprocess queued alerts: 10 78 | total interprocess send delay: 11 79 | total interprocess receive delay: 12 80 | ' 81 | ], 82 | [ 83 | ' 84 | total published messages: 1 85 | shared memory used: 3K 86 | channels: 4 87 | subscribers: 5 88 | redis pending commands: 6 89 | redis connected servers: 7 90 | total interprocess alerts received: 8 91 | interprocess alerts in transit: 9 92 | interprocess queued alerts: 10 93 | total interprocess send delay: 11 94 | total interprocess receive delay: 12 95 | ' 96 | ], 97 | [ 98 | ' 99 | total published messages: 1 100 | stored messages: 2 101 | channels: 4 102 | subscribers: 5 103 | redis pending commands: 6 104 | redis connected servers: 7 105 | total interprocess alerts received: 8 106 | interprocess alerts in transit: 9 107 | interprocess queued alerts: 10 108 | total interprocess send delay: 11 109 | total interprocess receive delay: 12 110 | ' 111 | ], 112 | [ 113 | ' 114 | total published messages: 1 115 | stored messages: 2 116 | shared memory used: 3K 117 | subscribers: 5 118 | redis pending commands: 6 119 | redis connected servers: 7 120 | total interprocess alerts received: 8 121 | interprocess alerts in transit: 9 122 | interprocess queued alerts: 10 123 | total interprocess send delay: 11 124 | total interprocess receive delay: 12 125 | ' 126 | ], 127 | [ 128 | ' 129 | total published messages: 1 130 | stored messages: 2 131 | shared memory used: 3K 132 | channels: 4 133 | redis pending commands: 6 134 | redis connected servers: 7 135 | total interprocess alerts received: 8 136 | interprocess alerts in transit: 9 137 | interprocess queued alerts: 10 138 | total interprocess send delay: 11 139 | total interprocess receive delay: 12 140 | ' 141 | ], 142 | [ 143 | ' 144 | total published messages: 1 145 | stored messages: 2 146 | shared memory used: 3K 147 | channels: 4 148 | subscribers: 5 149 | redis connected servers: 7 150 | total interprocess alerts received: 8 151 | interprocess alerts in transit: 9 152 | interprocess queued alerts: 10 153 | total interprocess send delay: 11 154 | total interprocess receive delay: 12 155 | ' 156 | ], 157 | [ 158 | ' 159 | total published messages: 1 160 | stored messages: 2 161 | shared memory used: 3K 162 | channels: 4 163 | subscribers: 5 164 | redis pending commands: 6 165 | total interprocess alerts received: 8 166 | interprocess alerts in transit: 9 167 | interprocess queued alerts: 10 168 | total interprocess send delay: 11 169 | total interprocess receive delay: 12 170 | ' 171 | ], 172 | [ 173 | ' 174 | total published messages: 1 175 | stored messages: 2 176 | shared memory used: 3K 177 | channels: 4 178 | subscribers: 5 179 | redis pending commands: 6 180 | redis connected servers: 7 181 | interprocess alerts in transit: 9 182 | interprocess queued alerts: 10 183 | total interprocess send delay: 11 184 | total interprocess receive delay: 12 185 | ' 186 | ], 187 | [ 188 | ' 189 | total published messages: 1 190 | stored messages: 2 191 | shared memory used: 3K 192 | channels: 4 193 | subscribers: 5 194 | redis pending commands: 6 195 | redis connected servers: 7 196 | total interprocess alerts received: 8 197 | interprocess queued alerts: 10 198 | total interprocess send delay: 11 199 | total interprocess receive delay: 12 200 | ' 201 | ], 202 | [ 203 | ' 204 | total published messages: 1 205 | stored messages: 2 206 | shared memory used: 3K 207 | channels: 4 208 | subscribers: 5 209 | redis pending commands: 6 210 | redis connected servers: 7 211 | total interprocess alerts received: 8 212 | interprocess alerts in transit: 9 213 | total interprocess send delay: 11 214 | total interprocess receive delay: 12 215 | ' 216 | ], 217 | [ 218 | ' 219 | total published messages: 1 220 | stored messages: 2 221 | shared memory used: 3K 222 | channels: 4 223 | subscribers: 5 224 | redis pending commands: 6 225 | redis connected servers: 7 226 | total interprocess alerts received: 8 227 | interprocess alerts in transit: 9 228 | interprocess queued alerts: 10 229 | total interprocess receive delay: 12 230 | ' 231 | ], 232 | [ 233 | ' 234 | total published messages: 1 235 | stored messages: 2 236 | shared memory used: 3K 237 | channels: 4 238 | subscribers: 5 239 | redis pending commands: 6 240 | redis connected servers: 7 241 | total interprocess alerts received: 8 242 | interprocess alerts in transit: 9 243 | interprocess queued alerts: 10 244 | total interprocess send delay: 11 245 | ' 246 | ] 247 | ]; 248 | } 249 | 250 | /** 251 | * Returns a valid plain text for tests. 252 | * 253 | * @return string 254 | */ 255 | private function validPlainText(): string 256 | { 257 | return <<< STRING 258 | total published messages: 1 259 | stored messages: 2 260 | shared memory used: 3K 261 | channels: 4 262 | subscribers: 5 263 | redis pending commands: 6 264 | redis connected servers: 7 265 | total interprocess alerts received: 8 266 | interprocess alerts in transit: 9 267 | interprocess queued alerts: 10 268 | total interprocess send delay: 11 269 | total interprocess receive delay: 12 270 | STRING; 271 | } 272 | } 273 | -------------------------------------------------------------------------------- /tests/Unit/Api/Model/XmlMessageTest.php: -------------------------------------------------------------------------------- 1 | assertEquals($expectedName, $message->name()); 24 | $this->assertEquals($expectedContent, $message->content()); 25 | $this->assertEquals($expectedContentType, $message->contentType()); 26 | } 27 | 28 | /** 29 | * @test 30 | */ 31 | public function itShouldBeCreatedWithEmptyName(): void 32 | { 33 | $expectedName = ''; 34 | $expectedContent = 'my message content'; 35 | $expectedContentType = 'application/xml'; 36 | 37 | $message = new XmlMessage($expectedName, $expectedContent); 38 | 39 | $this->assertEquals($expectedName, $message->name()); 40 | $this->assertEquals($expectedContent, $message->content()); 41 | $this->assertEquals($expectedContentType, $message->contentType()); 42 | } 43 | 44 | /** 45 | * @test 46 | */ 47 | public function itShouldBeCreatedWithEmptyContent(): void 48 | { 49 | $expectedName = 'my-message-name'; 50 | $expectedContent = ''; 51 | $expectedContentType = 'application/xml'; 52 | 53 | $message = new XmlMessage($expectedName, $expectedContent); 54 | 55 | $this->assertEquals($expectedName, $message->name()); 56 | $this->assertEquals($expectedContent, $message->content()); 57 | $this->assertEquals($expectedContentType, $message->contentType()); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /tests/Unit/Api/StatusTest.php: -------------------------------------------------------------------------------- 1 | createMock(Response::class); 24 | $response->method('body')->willReturn( 25 | <<< STRING 26 | total published messages: 1 27 | stored messages: 2 28 | shared memory used: 3K 29 | channels: 4 30 | subscribers: 5 31 | redis pending commands: 6 32 | redis connected servers: 7 33 | total interprocess alerts received: 8 34 | interprocess alerts in transit: 9 35 | interprocess queued alerts: 10 36 | total interprocess send delay: 11 37 | total interprocess receive delay: 12 38 | STRING 39 | ); 40 | $response->method('statusCode')->willReturn(200); 41 | 42 | $client = $this->createMock(Client::class); 43 | $client->method('get')->willReturn($response); 44 | 45 | $status = new Status(new Url('http://localhost'), $client); 46 | $information = $status->information(); 47 | 48 | $this->assertInstanceOf(StatusInformation::class, $information); 49 | } 50 | 51 | /** 52 | * @test 53 | */ 54 | public function informationShouldThrowExceptionIfForbidden() 55 | { 56 | $this->expectException(AuthenticationRequiredException::class); 57 | 58 | $response = $this->createMock(Response::class); 59 | $response->method('statusCode')->willReturn(403); 60 | 61 | $client = $this->createMock(Client::class); 62 | $client->method('get')->willReturn($response); 63 | 64 | $status = new Status(new Url('http://localhost'), $client); 65 | $status->information(); 66 | } 67 | 68 | /** 69 | * @test 70 | */ 71 | public function informationShouldThrowExceptionOnNotExpectedStatusCode() 72 | { 73 | $this->expectException(NchanException::class); 74 | 75 | $response = $this->createMock(Response::class); 76 | $response->method('statusCode')->willReturn(201); 77 | 78 | $client = $this->createMock(Client::class); 79 | $client->method('get')->willReturn($response); 80 | 81 | $status = new Status(new Url('http://localhost'), $client); 82 | $status->information(); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /tests/Unit/Http/RequestTest.php: -------------------------------------------------------------------------------- 1 | 'application/json']; 20 | $expectedBody = 'my body'; 21 | 22 | $request = new Request($expectedUrl, $expectedHeaders, $expectedBody); 23 | 24 | $this->assertEquals($expectedUrl, $request->url()); 25 | $this->assertEquals($expectedHeaders, $request->headers()); 26 | $this->assertEquals($expectedBody, $request->body()); 27 | } 28 | 29 | /** 30 | * @test 31 | */ 32 | public function itShouldBeCreatedWithEmptyBody(): void 33 | { 34 | $expectedUrl = new Url('http://localhost/my-url'); 35 | $expectedHeaders = ['Accept' => 'application/json']; 36 | $expectedBody = ''; 37 | 38 | $request = new Request($expectedUrl, $expectedHeaders); 39 | 40 | $this->assertEquals($expectedUrl, $request->url()); 41 | $this->assertEquals($expectedHeaders, $request->headers()); 42 | $this->assertEquals($expectedBody, $request->body()); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/Unit/Http/ThrowExceptionIfRequestRequiresAuthenticationClientTest.php: -------------------------------------------------------------------------------- 1 | expectException(AuthenticationRequiredException::class); 23 | 24 | $client = new ThrowExceptionIfRequestRequiresAuthenticationClient( 25 | $this->createForbiddenClient() 26 | ); 27 | 28 | $client->get(new Request(new Url('http://localhost'), [])); 29 | } 30 | 31 | /** 32 | * @test 33 | */ 34 | public function itShouldThrowExceptionOnForbiddenPostRequest(): void 35 | { 36 | $this->expectException(AuthenticationRequiredException::class); 37 | 38 | $client = new ThrowExceptionIfRequestRequiresAuthenticationClient( 39 | $this->createForbiddenClient() 40 | ); 41 | 42 | $client->post(new Request(new Url('http://localhost'), [])); 43 | } 44 | 45 | /** 46 | * @test 47 | */ 48 | public function itShouldThrowExceptionOnForbiddenDeleteRequest(): void 49 | { 50 | $this->expectException(AuthenticationRequiredException::class); 51 | 52 | $client = new ThrowExceptionIfRequestRequiresAuthenticationClient( 53 | $this->createForbiddenClient() 54 | ); 55 | 56 | $client->delete(new Request(new Url('http://localhost'), [])); 57 | } 58 | 59 | /** 60 | * @return Client 61 | */ 62 | private function createForbiddenClient(): Client 63 | { 64 | return new class implements Client { 65 | public function get(Request $request): Response 66 | { 67 | return $this->createForbiddenResponse(); 68 | } 69 | 70 | public function post(Request $request): Response 71 | { 72 | return $this->createForbiddenResponse(); 73 | } 74 | 75 | public function delete(Request $request): Response 76 | { 77 | return $this->createForbiddenResponse(); 78 | } 79 | 80 | private function createForbiddenResponse(): Response 81 | { 82 | return new class implements Response { 83 | public function statusCode(): int 84 | { 85 | return Response::FORBIDDEN; 86 | } 87 | 88 | public function body(): string 89 | { 90 | return ''; 91 | } 92 | }; 93 | } 94 | }; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /tests/Unit/Http/UrlTest.php: -------------------------------------------------------------------------------- 1 | assertEquals( 20 | $url, 21 | (new Url($url))->toString() 22 | ); 23 | } 24 | 25 | /** 26 | * @test 27 | * @dataProvider invalidUrlsProvider 28 | */ 29 | public function itShouldThrowAnExceptionOnInvalidUrls(string $url): void 30 | { 31 | $this->expectException(InvalidUrlException::class); 32 | 33 | new Url($url); 34 | } 35 | 36 | /** 37 | * @test 38 | */ 39 | public function itCanBeAppendedWithString(): void 40 | { 41 | $expectedUrl = 'http://localhost/foo/bar'; 42 | 43 | $url = new Url('http://localhost'); 44 | $urlCopy = clone $url; 45 | 46 | $newUrl = $url->append('/foo/bar'); 47 | 48 | $this->assertEquals($expectedUrl, $newUrl->toString()); 49 | # Test immutability 50 | $this->assertEquals($url, $urlCopy); 51 | } 52 | 53 | /** 54 | * @test 55 | */ 56 | public function itCanBeTypeCastedToString(): void 57 | { 58 | $expectedUrl = 'http://localhost'; 59 | 60 | $url = new Url($expectedUrl); 61 | 62 | $this->assertEquals($expectedUrl, (string)$url); 63 | $this->assertEquals($expectedUrl, $url->toString()); 64 | } 65 | 66 | /** 67 | * Returns valid urls. 68 | * 69 | * @return array 70 | */ 71 | public function validUrlsProvider(): array 72 | { 73 | return [ 74 | ['http://localhost'], 75 | ['https://localhost'], 76 | ['http://localhost/'], 77 | ['https://localhost/'], 78 | ['http://localhost/?foo=bar'], 79 | ['https://localhost/?foo=bar'], 80 | ['http://localhost/foo/bar'], 81 | ['https://localhost/foo/bar'] 82 | ]; 83 | } 84 | 85 | /** 86 | * Returns invalid urls. 87 | * 88 | * @return array 89 | */ 90 | public function invalidUrlsProvider(): array 91 | { 92 | return [ 93 | ['/'], 94 | ['/?foo=bar'], 95 | ['/foo/bar'], 96 | ['malformed'], 97 | ['http:///:80'] 98 | ]; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /tests/Unit/HttpAdapter/BasicAuthenticationCredentialsTest.php: -------------------------------------------------------------------------------- 1 | authenticate( 26 | new Request( 27 | new Url('http://localhost'), 28 | [] 29 | ) 30 | ); 31 | 32 | $this->assertTrue($request->headers()['Authorization'] === $expectedAuthorizationHeaderValue); 33 | } 34 | 35 | /** 36 | * @test 37 | */ 38 | public function itShouldOverrideAnExistingAuthenticationHeader(): void 39 | { 40 | $username = 'starwars'; 41 | $password = '25.05.1977'; 42 | $credentials = new BasicAuthenticationCredentials($username, $password); 43 | 44 | $expectedAuthorizationHeaderValue = 'Basic ' . base64_encode($username . ':' . $password); 45 | 46 | $request = $credentials->authenticate( 47 | new Request( 48 | new Url('http://localhost'), 49 | [ 50 | 'Authorization' => 'Token my-token' 51 | ] 52 | ) 53 | ); 54 | 55 | $this->assertTrue($request->headers()['Authorization'] === $expectedAuthorizationHeaderValue); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /tests/Unit/HttpAdapter/BearerAuthenticationCredentialsTest.php: -------------------------------------------------------------------------------- 1 | authenticate( 25 | new Request( 26 | new Url('http://localhost'), 27 | [] 28 | ) 29 | ); 30 | 31 | $this->assertTrue($request->headers()['Authorization'] === $expectedAuthorizationHeaderValue); 32 | } 33 | 34 | /** 35 | * @test 36 | */ 37 | public function itShouldOverrideAnExistingAuthenticationHeader(): void 38 | { 39 | $token = 'my-token'; 40 | $credentials = new BearerAuthenticationCredentials($token); 41 | 42 | $expectedAuthorizationHeaderValue = 'Bearer ' . $token; 43 | 44 | $request = $credentials->authenticate( 45 | new Request( 46 | new Url('http://localhost'), 47 | [ 48 | 'Authorization' => 'Token other-token' 49 | ] 50 | ) 51 | ); 52 | 53 | $this->assertTrue($request->headers()['Authorization'] === $expectedAuthorizationHeaderValue); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tests/Unit/HttpAdapter/HttpStreamWrapperResponseTest.php: -------------------------------------------------------------------------------- 1 | assertEquals($expectedStatusCode, $response->statusCode()); 24 | $this->assertEquals($expectedBody, $response->body()); 25 | } 26 | 27 | /** 28 | * @test 29 | */ 30 | public function itShouldBeCreatedFromStreamWrapperResponse(): void 31 | { 32 | $expectedStatusCode = 200; 33 | $expectedBody = 'my body'; 34 | 35 | $response = HttpStreamWrapperResponse::fromResponse( 36 | [ 37 | 'HTTP\1.1 200 OK' 38 | ], 39 | $expectedBody 40 | ); 41 | 42 | $this->assertEquals($expectedStatusCode, $response->statusCode()); 43 | $this->assertEquals($expectedBody, $response->body()); 44 | } 45 | 46 | /** 47 | * @test 48 | * @dataProvider incorrectResponseHeadersProvider 49 | */ 50 | public function itShouldThrowAnExceptionWhenIncorrectResponseHeadersArePassed(array $headers): void 51 | { 52 | $this->expectException(NchanException::class); 53 | 54 | HttpStreamWrapperResponse::fromResponse($headers, ''); 55 | } 56 | 57 | /** 58 | * Returns various incorrect response headers. 59 | * 60 | * @return array 61 | */ 62 | public function incorrectResponseHeadersProvider(): array 63 | { 64 | return [ 65 | [['wrong response header']], 66 | [['HTT 200 OK']], 67 | [['HTTP/1.1 OK']], 68 | [['HTTP/1.1 -123']], 69 | [['']], 70 | [[]] 71 | ]; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /tests/Unit/HttpAdapter/WithoutAuthenticationCredentialsTest.php: -------------------------------------------------------------------------------- 1 | authenticate($expectedRequest); 27 | 28 | $this->assertSame($expectedRequest, $request); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/Unit/NchanTest.php: -------------------------------------------------------------------------------- 1 | createNchan()->channel('/my-channel'); 21 | 22 | $this->assertInstanceOf(Channel::class, $channel); 23 | } 24 | 25 | /** 26 | * @test 27 | */ 28 | public function itShouldCreateStatusApi(): void 29 | { 30 | $status = $this->createNchan()->status('/status'); 31 | 32 | $this->assertInstanceOf(Status::class, $status); 33 | } 34 | 35 | /** 36 | * Returns a Nchan instance. 37 | * 38 | * @return Nchan 39 | */ 40 | private function createNchan(): Nchan 41 | { 42 | $client = $this->createMock(Client::class); 43 | 44 | $nchan = new Nchan( 45 | 'http://localhost', 46 | $client 47 | ); 48 | 49 | return $nchan; 50 | } 51 | } 52 | --------------------------------------------------------------------------------