├── .github ├── PULL_REQUEST_TEMPLATE.md ├── stale.yml └── workflows │ ├── ci.yml │ ├── release.yml │ └── release_pr.yml ├── .gitignore ├── CHANGELOG.md ├── LICENCE.txt ├── README.md ├── build.gradle ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── scripts └── get_staging_repository_id.py └── src ├── main ├── java │ └── com │ │ └── pusher │ │ └── rest │ │ ├── Pusher.java │ │ ├── PusherAbstract.java │ │ ├── PusherAsync.java │ │ ├── PusherException.java │ │ ├── SignatureUtil.java │ │ ├── crypto │ │ ├── CryptoUtil.java │ │ └── nacl │ │ │ ├── SecretBox.java │ │ │ └── TweetNaclFast.java │ │ ├── data │ │ ├── AuthData.java │ │ ├── EncryptedMessage.java │ │ ├── Event.java │ │ ├── EventBatch.java │ │ ├── PresenceUser.java │ │ ├── Result.java │ │ ├── TriggerData.java │ │ └── Validity.java │ │ ├── marshaller │ │ ├── DataMarshaller.java │ │ └── DefaultDataMarshaller.java │ │ └── util │ │ └── Prerequisites.java └── javadoc │ └── css │ └── styles.css └── test └── java └── com └── pusher └── rest ├── PusherAsyncHttpTest.java ├── PusherChannelAuthTest.java ├── PusherHttpTest.java ├── PusherTest.java ├── PusherTestUrlConfig.java ├── SignatureUtilTest.java ├── crypto ├── CryptoUtilTest.java └── nacl │ └── SecretBoxTest.java └── util ├── Matchers.java └── PusherNoHttp.java /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## What does this PR do? 2 | 3 | [Description here] 4 | 5 | ## CHANGELOG 6 | 7 | - [CHANGED] Describe your change here. Look at CHANGELOG.md to see the format. 8 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Configuration for probot-stale - https://github.com/probot/stale 2 | 3 | # Number of days of inactivity before an Issue or Pull Request becomes stale 4 | daysUntilStale: 90 5 | 6 | # Number of days of inactivity before an Issue or Pull Request with the stale label is closed. 7 | # Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale. 8 | daysUntilClose: 7 9 | 10 | # Only issues or pull requests with all of these labels are check if stale. Defaults to `[]` (disabled) 11 | onlyLabels: [] 12 | 13 | # Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable 14 | exemptLabels: 15 | - pinned 16 | - security 17 | 18 | # Set to true to ignore issues with an assignee (defaults to false) 19 | exemptAssignees: true 20 | 21 | # Comment to post when marking as stale. Set to `false` to disable 22 | markComment: > 23 | This issue has been automatically marked as stale because it has not had 24 | recent activity. It will be closed if no further activity occurs. If you'd 25 | like this issue to stay open please leave a comment indicating how this issue 26 | is affecting you. Thank you. 27 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: pusher-http-java CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [master, main] 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-20.04 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | java-version: [11, 17, 21] 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Set up JDK ${{ matrix.java-version }} 19 | uses: actions/setup-java@v2 20 | with: 21 | java-version: "${{ matrix.java-version }}" 22 | distribution: "adopt" 23 | - name: Build & Test 24 | run: ./gradlew build 25 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: [ master ] 4 | 5 | jobs: 6 | check-release-tag: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout code 10 | uses: actions/checkout@v2 11 | with: 12 | fetch-depth: 0 13 | - name: Prepare tag 14 | id: prepare_tag 15 | continue-on-error: true 16 | run: | 17 | export TAG=v$(awk '/^version = / { gsub("\"", ""); print $3 }' build.gradle) 18 | echo "TAG=$TAG" >> $GITHUB_ENV 19 | 20 | export CHECK_TAG=$(git tag | grep $TAG) 21 | if [[ $CHECK_TAG ]]; then 22 | echo "Skipping because release tag already exists" 23 | exit 1 24 | fi 25 | - name: Output 26 | id: release_output 27 | if: ${{ steps.prepare_tag.outcome == 'success' }} 28 | run: | 29 | echo "::set-output name=tag::${{ env.TAG }}" 30 | outputs: 31 | tag: ${{ steps.release_output.outputs.tag }} 32 | 33 | create-github-release: 34 | runs-on: ubuntu-latest 35 | needs: check-release-tag 36 | if: ${{ needs.check-release-tag.outputs.tag }} 37 | steps: 38 | - uses: actions/checkout@v2 39 | - name: Prepare tag 40 | run: | 41 | export TAG=v$(awk '/^version = / { gsub("\"", ""); print $3 }' build.gradle) 42 | echo "TAG=$TAG" >> $GITHUB_ENV 43 | - name: Setup git 44 | run: | 45 | git config user.email "pusher-ci@pusher.com" 46 | git config user.name "Pusher CI" 47 | - name: Prepare description 48 | run: | 49 | csplit -s CHANGELOG.md "/##/" {1} 50 | cat xx01 > CHANGELOG.tmp 51 | - name: Create Release 52 | uses: actions/create-release@v1 53 | env: 54 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 55 | with: 56 | tag_name: ${{ env.TAG }} 57 | release_name: ${{ env.TAG }} 58 | body_path: CHANGELOG.tmp 59 | draft: false 60 | prerelease: false 61 | 62 | publish: 63 | runs-on: ubuntu-latest 64 | needs: create-github-release 65 | steps: 66 | - uses: actions/checkout@v2 67 | - name: Create gradle.properties 68 | shell: bash 69 | run: | 70 | mkdir -p _gradle_user_home 71 | echo "GRADLE_USER_HOME=_gradle_user_home" >> $GITHUB_ENV 72 | cat < _gradle_user_home/gradle.properties 73 | github.username=${{ secrets.PUSHER_CI_GITHUB_PRIVATE_TOKEN }} 74 | github.password="" 75 | maven.username=${{ secrets.MAVEN_USERNAME }} 76 | maven.password=${{ secrets.MAVEN_PASSWORD }} 77 | signing.keyId=${{ secrets.SIGNING_KEY_ID }} 78 | signing.password=${{ secrets.SIGNING_PASSWORD }} 79 | signing.secretKeyRingFile=_gradle_user_home/pusher-maven-gpg-signing-key.gpg 80 | FILE 81 | echo "${{ secrets.PUSHER_MAVEN_GPG_SIGNING_KEY }}" | base64 --decode > _gradle_user_home/pusher-maven-gpg-signing-key.gpg 82 | - name: Publish 83 | run: | 84 | ./gradlew publish 85 | 86 | finish-release: 87 | runs-on: ubuntu-latest 88 | needs: publish 89 | env: 90 | NEXUS_USERNAME: ${{ secrets.MAVEN_USERNAME }} 91 | NEXUS_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} 92 | steps: 93 | - uses: actions/checkout@v2 94 | - id: get_staging_repository_id 95 | name: Get staging repository id 96 | run: | 97 | echo "staging_repository_id=$(python3 scripts/get_staging_repository_id.py)" >> $GITHUB_OUTPUT 98 | - name: Release 99 | uses: nexus-actions/release-nexus-staging-repo@main 100 | with: 101 | username: ${{ secrets.MAVEN_USERNAME }} 102 | password: ${{ secrets.MAVEN_PASSWORD }} 103 | staging_repository_id: ${{ steps.get_staging_repository_id.outputs.staging_repository_id }} 104 | -------------------------------------------------------------------------------- /.github/workflows/release_pr.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | pull_request: 5 | types: [ labeled ] 6 | branches: 7 | - master 8 | 9 | jobs: 10 | prepare-release: 11 | name: Prepare release 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Get current version 16 | shell: bash 17 | run: | 18 | CURRENT_VERSION=$(awk '/^version = / { gsub("\"", ""); print $3 }' build.gradle) 19 | echo "CURRENT_VERSION=$CURRENT_VERSION" >> $GITHUB_ENV 20 | - uses: actions/checkout@v2 21 | with: 22 | repository: pusher/public_actions 23 | path: .github/actions 24 | - uses: ./.github/actions/prepare-version-bump 25 | id: bump 26 | with: 27 | current_version: ${{ env.CURRENT_VERSION }} 28 | - name: Push 29 | shell: bash 30 | run: | 31 | perl -pi -e 's/version = "${{env.CURRENT_VERSION}}"/version = "${{steps.bump.outputs.new_version}}"/' build.gradle 32 | perl -pi -e 's/${{env.CURRENT_VERSION}}/${{steps.bump.outputs.new_version}}/' README.md 33 | 34 | git add README.md CHANGELOG.md build.gradle 35 | git commit -m "Bump to version ${{ steps.bump.outputs.new_version }}" 36 | git push 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .classpath 2 | .project 3 | target 4 | 5 | # Output directory 6 | target 7 | 8 | build 9 | .gradle 10 | .idea 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.3.4 4 | 5 | - [CHANGED] Updated org.asynchttpclient:async-http-client to 3.0.1 to address a vulnerability 6 | - [CHANGED] Updated CI matrix to remove Java 8 and add Java 21, reflecting current support policy 7 | - [CHANGED] Replaced deprecated constructor with in 8 | - [REMOVED] Deprecated org.ajoberstar.github-pages plugin and associated configuration 9 | 10 | ## 1.3.1 2022-05-16 11 | 12 | - [CHANGED] Use SecureRandom.nextBytes instead of SecureRandom.generateSeed 13 | 14 | ## 1.3.0 2021-09-02 15 | 16 | - [ADDED] Add end-to-end encryption support 17 | 18 | ## 1.2.1 2021-04-19 19 | 20 | - [CHANGED] Upgraded asynchttpclient to v2.12.13 (https://nvd.nist.gov/vuln/detail/CVE-2019-20444) 21 | 22 | ## 1.2.0 2020-04-20 23 | 24 | - [ADDED] Support for asynchronous http calls 25 | -------------------------------------------------------------------------------- /LICENCE.txt: -------------------------------------------------------------------------------- 1 | Pusher Java Library 2 | http://pusher.com/ 3 | 4 | Copyright 2014, Pusher 5 | Released under the MIT licence. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pusher Channels Java HTTP library 2 | 3 | [![Build Status](https://github.com/pusher/pusher-http-java/workflows/pusher-http-java%20CI/badge.svg)](https://github.com/pusher/pusher-http-java/actions?query=branch%3Amaster) 4 | [![Maven Central](https://img.shields.io/maven-central/v/com.pusher/pusher-http-java.svg?label=Maven%20Central)](https://search.maven.org/search?q=g:%22com.pusher%22%20AND%20a:%22pusher-http-java%22) 5 | [![GitHub license](https://img.shields.io/badge/license-MIT-lightgrey.svg)](https://raw.githubusercontent.com/pusher/chatkit-android/master/LICENCE.txt) 6 | 7 | In order to use this library, you need to have an account on . After registering, you will need the application credentials for your app. 8 | 9 | ## Supported platforms 10 | 11 | * Java SE - supports versions 8, 11 and 17. 12 | * Oracle JDK 13 | * OpenJDK 14 | 15 | ## Installation 16 | 17 | The pusher-http-java library is available in Maven Central: 18 | 19 | ``` 20 | 21 | com.pusher 22 | pusher-http-java 23 | 1.3.4 24 | 25 | ``` 26 | 27 | ## JavaDoc 28 | 29 | Javadocs for the latest version are published at . Javadoc artifacts are also in Maven and should be available for automatic download and attaching by your IDE. 30 | 31 | ## Synchronous vs asynchronous 32 | 33 | The pusher-http-java library provides two APIs: 34 | 35 | - `com.pusher.rest.Pusher`, synchronous, based on Apache HTTP Client (4 series) 36 | - `com.pusher.rest.PusherAsync`, asynchronous, based on [AsyncHttpClient (AHC)](https://github.com/AsyncHttpClient/async-http-client) 37 | 38 | The following examples are using `Pusher`, but `PusherAsync` exposes the exact same API, while returning `CompletableFuture` instead of `T`. 39 | 40 | ## Configuration 41 | 42 | The minimum configuration required to use the `Pusher` object are the three constructor arguments which identify your Pusher app. You can find them by going to "API Keys" on your app at . 43 | 44 | ```java 45 | Pusher pusher = new Pusher(appId, apiKey, apiSecret); 46 | ``` 47 | 48 | You then need to specify your app cluster (eg `mt1`/`eu`/`ap1`/`us2`) 49 | 50 | ```java 51 | pusher.setCluster(); 52 | ``` 53 | 54 | ### From URL 55 | 56 | The basic parameters may also be set from a URL, as provided (for example) as an environment variable when running on Heroku with the Pusher Channels addon: 57 | 58 | ```java 59 | Pusher pusher = new Pusher("http://:@api-.pusher.com/apps/app_id"); 60 | ``` 61 | 62 | Note: the API URL differs depending on the cluster your app was created in. For example: 63 | 64 | ``` 65 | http://:@api-mt1.pusher.com/apps/app_id 66 | http://:@api-eu.pusher.com/apps/app_id 67 | ``` 68 | 69 | This form sets the `key`, `secret`, `appId`, `host` and `secure` (based on the protocol in the URL) parameters all at once. 70 | 71 | ### Additional options 72 | 73 | There are additional options which can be set on the `Pusher` object once constructed: 74 | 75 | #### Host 76 | 77 | 78 | If you wish to set a non-standard endpoint, perhaps for testing, you may use `setHost`. 79 | 80 | ```java 81 | pusher.setHost("api-eu.pusher.com"); 82 | ``` 83 | 84 | #### SSL 85 | 86 | HTTPS can be used as transport by calling `setEncrypted(true)`. Note that your credentials are not exposed on an unencrypted connection, however the contents of your messages are. Use this option if your messages themselves are sensitive. 87 | 88 | #### Advanced HTTP configuration 89 | 90 | ##### Synchronous library 91 | 92 | The synchronous library uses Apache HTTP Client (4 series) internally to make HTTP requests. In order to expose some of the rich and fine configuration available in this component, it is partially exposed. The HttpClient uses the Builder pattern to specify configuration. The Pusher Channels library exposes a method to fetch an `HttpClientBuilder` with sensible defaults, and a method to set the client instance in use to one created by a particular builder. By using these two methods, you can further configure the client, overriding defaults or adding new settings. 93 | 94 | For example: 95 | 96 | ###### HTTP Proxy 97 | 98 | To set a proxy: 99 | 100 | ```java 101 | HttpClientBuilder builder = Pusher.defaultHttpClientBuilder(); 102 | builder.setProxy(new HttpHost("proxy.example.com")); 103 | pusher.configureHttpClient(builder); 104 | ``` 105 | 106 | ##### Asynchronous library 107 | 108 | The asynchronous library uses AsyncHttpClient (AHC) internally to make HTTP requests. Just like the synchronous library, you have a fine-grained control over the HTTP configuration, see https://github.com/AsyncHttpClient/async-http-client. For example: 109 | 110 | ###### HTTP Proxy 111 | 112 | To set a proxy: 113 | 114 | ```java 115 | pusherAsync.configureHttpClient( 116 | config() 117 | .setProxyServer(proxyServer("127.0.0.1", 38080)) 118 | .setMaxRequestRetry(5) 119 | ); 120 | ``` 121 | 122 | ## Usage 123 | 124 | ### General info on responses 125 | 126 | Requests return a member of the `Result` class. Results have a `getStatus` method returning a member of `Status` which classifies their outcome, for example `Status.SUCCESS` and `Status.AUTHENTICATION_ERROR`. Error `Result`s yield a description from `getMessage`. 127 | 128 | ### Publishing events 129 | 130 | To send an event to one or more channels use the `trigger` method. 131 | 132 | The data parameter is serialised using the GSON library () by default. You may 133 | specify your own marshalling library if you wish by using the `setDataMarshaller` method. POJO classes or `java.util.Map`s 134 | are suitable for marshalling. 135 | 136 | #### Single channel 137 | 138 | ```java 139 | pusher.trigger("channel-one", "test_event", Collections.singletonMap("message", "hello world")); 140 | ``` 141 | 142 | #### Multiple channels 143 | 144 | ```java 145 | List channels = new ArrayList<>(); 146 | channels.add("channel-one"); 147 | channels.add("channel-two"); 148 | 149 | pusher.trigger(channels, "test_event", Collections.singletonMap("message", "hello world")); 150 | ``` 151 | 152 | You can trigger an event to at most 10 channels at once. Passing more than 10 channels will cause an exception to be thrown. 153 | 154 | #### Excluding event recipients 155 | 156 | In order to avoid the client that triggered the event from also receiving it, the `trigger` function takes an optional `socketId` parameter. For more information see: . 157 | 158 | ```java 159 | pusher.trigger(channel, event, data, "1302.1081607"); 160 | ``` 161 | 162 | ### Authenticating private channels 163 | 164 | To authorise your users to access private channels on Channels, you can use the `authenticate` method. This method returns the response body which should be returned to the user requesting authentication. 165 | 166 | ```java 167 | String authBody = pusher.authenticate(socketId, channel); 168 | ``` 169 | 170 | For more information see: 171 | 172 | ### Authenticating presence channels 173 | 174 | Using presence channels is similar to using private channels, but you can specify extra data to identify that particular user. This data is passed as a `PresenceUser` object. As with the message data in `trigger`, the `userInfo` is serialised using GSON. 175 | 176 | ```java 177 | String userId = "unique_user_id"; 178 | Map userInfo = new HashMap<>(); 179 | userInfo.put("name", "Phil Leggetter"); 180 | userInfo.put("twitterId", "@leggetter"); 181 | 182 | String authBody = pusher.authenticate(socketId, channel, new PresenceUser(userId, userInfo)); 183 | ``` 184 | 185 | For more information see: 186 | 187 | ### Application state 188 | 189 | It's possible to query the state of the application using the `get` method. 190 | 191 | The `path` parameter identifies the resource that the request should be made to and the `parameters` parameter should be a map of additional query string key and value pairs. 192 | 193 | For example: 194 | 195 | #### Get the list of channels in an application 196 | 197 | ```java 198 | Result result = pusher.get("/channels", params); 199 | if (result.getStatus() == Status.SUCCESS) { 200 | String channelListJson = result.getMessage(); 201 | // Parse and act upon list 202 | } 203 | ``` 204 | 205 | Information on the optional `params` and the structure of the returned JSON is defined in the [HTTP API reference](https://pusher.com/docs/channels/library_auth_reference/rest-api#get-channels-fetch-info-for-multiple-channels-). 206 | 207 | #### Get the state of a channel 208 | 209 | ```java 210 | Result result = pusher.get("/channels/[channel_name]"); 211 | ``` 212 | 213 | Information on the optional `params` option property and the structure of the returned JSON is defined in the [HTTP API reference](https://pusher.com/docs/channels/library_auth_reference/rest-api#get-channel-fetch-info-for-one-channel-). 214 | 215 | #### Get the list of users in a presence channel 216 | 217 | ```java 218 | Result result = pusher.get("/channels/[channel_name]/users"); 219 | ``` 220 | 221 | The `channel_name` in the path must be a [presence channel](https://pusher.com/docs/channels/using_channels/presence-channels). The structure of the returned JSON is defined in the [HTTP API reference](https://pusher.com/docs/channels/library_auth_reference/rest-api#get-users). 222 | 223 | ### WebHooks 224 | 225 | The library provides a simple helper to validate the authenticity of webhooks received from Channels. 226 | 227 | Call `validateWebhookSignature` with the values from the `X-Pusher-Key` and `X-Pusher-Signature` headers from the webhook request, and the body as a String. You will receive a member of `Validity` indicating whether the webhook could be validated or not. 228 | 229 | ### Generating HTTP API signatures 230 | 231 | If you wanted to send the HTTP API requests manually (e.g. using a different HTTP client), you can use the `signedUri` method to generate a java.net.URI for your request which contains the necessary signatures: 232 | 233 | ```java 234 | URI requestUri = pusher.signedUri("GET", "/apps//channels", null); // no body on GET request 235 | ``` 236 | 237 | Note that the URI does not include the body parameter, however the signature contains a digest of it, so the body must be sent with the request as it was presented at signature time: 238 | 239 | ```java 240 | URI requestUri = pusher.signedUri("POST", "/apps//events", body); 241 | 242 | PostRequest request = new PostRequest(requestUri); 243 | request.setBody(body); 244 | request.execute(); 245 | ``` 246 | 247 | Additional query params may be passed to be added to the URI: 248 | 249 | ```java 250 | URI requestUri = pusher.signedUri("GET", "/apps//channels", null, Collections.singletonMap("filter_by_prefix", "myprefix-")); 251 | ``` 252 | 253 | Query parameters can't contain following keys, as they are used to sign the request: 254 | 255 | - auth_key 256 | - auth_timestamp 257 | - auth_version 258 | - auth_signature 259 | - body_md5 260 | 261 | ### Multi-threaded usage with the synchronous library 262 | 263 | The library is threadsafe and intended for use from many threads simultaneously. By default, HTTP connections are persistent and a pool of open connections is maintained. This re-use reduces the overhead involved in repeated TCP connection establishments and teardowns. 264 | 265 | IO calls are blocking, and if more threads make requests at one time than the configured maximum pool size, they will wait for a connection to become idle. By default there are at most 2 concurrent connections maintained. This should be enough to many use cases, but it can be configured: 266 | 267 | ```java 268 | PoolingHttpClientConnectionManager connManager = new PoolingHttpClientConnectionManager(); 269 | connManager.setDefaultMaxPerRoute(maxConns); 270 | 271 | pusher.configureHttpClient( 272 | Pusher.defaultHttpClientBuilder() 273 | .setConnectionManager(connManager) 274 | ); 275 | ``` 276 | 277 | ### End-to-end encryption 278 | 279 | This library supports end-to-end encryption of your private channels. This means that only you and your connected clients will be able to read your messages. Pusher cannot decrypt them. 280 | 281 | You can enable this feature by following these steps: 282 | 283 | 1. You should first set up Private channels. This involves [creating an authentication endpoint on your server](https://pusher.com/docs/authenticating_users). 284 | 285 | 2. Next, generate your 32 byte master encryption key, encode it as base64 and pass it to the Pusher constructor. 286 | 287 | This is secret, and you should never share this with anyone. Not even Pusher. 288 | 289 | Generate base64 encoded 32 byte key: 290 | 291 | ```bash 292 | openssl rand -base64 32 293 | ``` 294 | 295 | ```java 296 | Pusher pusher = new Pusher(APP_ID, API_KEY, API_SECRET, ENCRYPTION_MASTER_KEY_BASE64); 297 | ``` 298 | 299 | 5. Channels where you wish to use end-to-end encryption should be prefixed with `private-encrypted-`. 300 | 301 | 6. Subscribe to these channels in your client, and you're done! You can verify it is working by checking out the debug console on the [dashboard](https://dashboard.pusher.com/) and seeing the scrambled ciphertext. 302 | 303 | **Important note: This will **not** encrypt messages on channels that are not prefixed by `private-encrypted-`.** 304 | 305 | **Limitation**: you cannot trigger a single event on multiple channels in a call to `trigger` when one of the channels is e2e encrypted (i.e. prefixed by `private-encrypted-`). 306 | 307 | Rationale: the methods in this library map directly to individual Channels HTTP API requests. If we allowed triggering a single event on multiple channels (some encrypted, some unencrypted), then it would require two API requests: one where the event is encrypted to the encrypted channels, and one where the event is unencrypted for unencrypted channels. 308 | 309 | 310 | ## License 311 | 312 | This code is free to use under the terms of the MIT license. 313 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | import org.apache.tools.ant.filters.ReplaceTokens 2 | 3 | buildscript { 4 | repositories { 5 | mavenCentral() 6 | } 7 | } 8 | 9 | plugins { 10 | id 'java-library' 11 | id 'maven-publish' 12 | id "signing" 13 | } 14 | 15 | def getProperty = { property -> 16 | if (!project.hasProperty(property)) { 17 | throw new GradleException("${property} property must be set") 18 | } 19 | return project.property(property) 20 | } 21 | 22 | repositories { 23 | mavenCentral() 24 | } 25 | 26 | group = "com.pusher" 27 | version = "1.3.4" 28 | description = "Pusher HTTP Client" 29 | 30 | java { 31 | sourceCompatibility = JavaVersion.VERSION_11 32 | targetCompatibility = JavaVersion.VERSION_11 33 | withSourcesJar() 34 | withJavadocJar() 35 | } 36 | 37 | dependencies { 38 | implementation 'org.apache.httpcomponents:httpclient:4.5.13' 39 | implementation 'org.asynchttpclient:async-http-client:3.0.1' 40 | implementation 'com.google.code.gson:gson:2.8.9' 41 | testImplementation 'org.apache.httpcomponents:httpclient:4.5.13' 42 | testImplementation 'org.hamcrest:hamcrest-all:1.3' 43 | testImplementation 'org.jmock:jmock-junit5:2.12.0' 44 | testImplementation 'org.jmock:jmock-imposters:2.12.0' 45 | testImplementation 'org.junit.jupiter:junit-jupiter:5.7.1' 46 | } 47 | 48 | processResources { 49 | filter(ReplaceTokens, tokens: [ 50 | version: project.version 51 | ]) 52 | } 53 | 54 | javadoc { 55 | title "Pusher HTTP Java" 56 | options.linkSource = true 57 | } 58 | 59 | jar { 60 | duplicatesStrategy = DuplicatesStrategy.EXCLUDE 61 | manifest { 62 | attributes( 63 | 'Created-By': 'Pusher', 64 | 'Implementation-Vendor': 'Pusher', 65 | 'Implementation-Title': 'Pusher HTTP Java', 66 | 'Implementation-Version': version 67 | ) 68 | } 69 | } 70 | 71 | publishing { 72 | publications { 73 | mavenJava(MavenPublication) { 74 | artifactId = 'pusher-http-java' 75 | 76 | from components.java 77 | pom { 78 | name = 'Pusher HTTP Client' 79 | packaging = 'jar' 80 | artifactId = 'pusher-http-java' 81 | description = "This is a Java library for interacting with Pusher.com's HTTP API." 82 | url = 'http://github.com/pusher/pusher-http-java' 83 | scm { 84 | connection = 'scm:git:git@github.com:pusher/pusher-http-java' 85 | developerConnection = 'scm:git:git@github.com:pusher/pusher-http-java' 86 | url = 'http://github.com/pusher/pusher-http-java' 87 | } 88 | licenses { 89 | license { 90 | name = 'MIT' 91 | url = 'https://raw.github.com/pusher/pusher-http-java/master/LICENCE.txt' 92 | distribution = 'https://raw.github.com/pusher/pusher-http-java/mvn-repo/' 93 | } 94 | } 95 | organization { 96 | name = 'Pusher' 97 | url = 'http://pusher.com' 98 | } 99 | issueManagement { 100 | system = 'GitHub' 101 | url = 'https://github.com/pusher/pusher-http-java/issues' 102 | } 103 | developer { 104 | id = 'pusher-team' 105 | name = 'Pusher Engineering Team' 106 | organization = 'Pusher' 107 | organizationUrl = 'https://pusher.com' 108 | } 109 | } 110 | } 111 | } 112 | repositories { 113 | maven { 114 | def releaseRepositoryUrl = "https://oss.sonatype.org/service/local/staging/deploy/maven2/" 115 | def snapshotRepositoryUrl = "https://oss.sonatype.org/content/repositories/snapshots/" 116 | url = version.endsWith("SNAPSHOT") ? snapshotRepositoryUrl : releaseRepositoryUrl 117 | credentials { 118 | username = findProperty("maven.username") ?: "" 119 | password = findProperty("maven.password") ?: "" 120 | } 121 | } 122 | } 123 | } 124 | 125 | signing { 126 | sign publishing.publications.mavenJava 127 | } 128 | 129 | test { 130 | useJUnitPlatform() 131 | 132 | testLogging { 133 | showStandardStreams = true 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pusher/pusher-http-java/1101e40fff05bf12bd9f8f5d12eb8f8bf9a8b747/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit 84 | 85 | APP_NAME="Gradle" 86 | APP_BASE_NAME=${0##*/} 87 | 88 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 89 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | 142 | # Increase the maximum file descriptors if we can. 143 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 144 | case $MAX_FD in #( 145 | max*) 146 | MAX_FD=$( ulimit -H -n ) || 147 | warn "Could not query maximum file descriptor limit" 148 | esac 149 | case $MAX_FD in #( 150 | '' | soft) :;; #( 151 | *) 152 | ulimit -n "$MAX_FD" || 153 | warn "Could not set maximum file descriptor limit to $MAX_FD" 154 | esac 155 | fi 156 | 157 | # Collect all arguments for the java command, stacking in reverse order: 158 | # * args from the command line 159 | # * the main class name 160 | # * -classpath 161 | # * -D...appname settings 162 | # * --module-path (only if needed) 163 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 164 | 165 | # For Cygwin or MSYS, switch paths to Windows format before running java 166 | if "$cygwin" || "$msys" ; then 167 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 168 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 169 | 170 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 171 | 172 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 173 | for arg do 174 | if 175 | case $arg in #( 176 | -*) false ;; # don't mess with options #( 177 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 178 | [ -e "$t" ] ;; #( 179 | *) false ;; 180 | esac 181 | then 182 | arg=$( cygpath --path --ignore --mixed "$arg" ) 183 | fi 184 | # Roll the args list around exactly as many times as the number of 185 | # args, so each arg winds up back in the position where it started, but 186 | # possibly modified. 187 | # 188 | # NB: a `for` loop captures its iteration list before it begins, so 189 | # changing the positional parameters here affects neither the number of 190 | # iterations, nor the values presented in `arg`. 191 | shift # remove old arg 192 | set -- "$@" "$arg" # push replacement arg 193 | done 194 | fi 195 | 196 | # Collect all arguments for the java command; 197 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of 198 | # shell script including quotes and variable substitutions, so put them in 199 | # double quotes to make sure that they get re-expanded; and 200 | # * put everything else in single quotes, so that it's not re-expanded. 201 | 202 | set -- \ 203 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 204 | -classpath "$CLASSPATH" \ 205 | org.gradle.wrapper.GradleWrapperMain \ 206 | "$@" 207 | 208 | # Stop when "xargs" is not available. 209 | if ! command -v xargs >/dev/null 2>&1 210 | then 211 | die "xargs is not available" 212 | fi 213 | 214 | # Use "xargs" to parse quoted args. 215 | # 216 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 217 | # 218 | # In Bash we could simply go: 219 | # 220 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 221 | # set -- "${ARGS[@]}" "$@" 222 | # 223 | # but POSIX shell has neither arrays nor command substitution, so instead we 224 | # post-process each arg (as a line of input to sed) to backslash-escape any 225 | # character that might be a shell metacharacter, then use eval to reverse 226 | # that process (while maintaining the separation between arguments), and wrap 227 | # the whole thing up as a single "set" statement. 228 | # 229 | # This will of course break if any of these variables contains a newline or 230 | # an unmatched quote. 231 | # 232 | 233 | eval "set -- $( 234 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 235 | xargs -n1 | 236 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 237 | tr '\n' ' ' 238 | )" '"$@"' 239 | 240 | exec "$JAVACMD" "$@" 241 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if %ERRORLEVEL% equ 0 goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if %ERRORLEVEL% equ 0 goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | set EXIT_CODE=%ERRORLEVEL% 84 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 85 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 86 | exit /b %EXIT_CODE% 87 | 88 | :mainEnd 89 | if "%OS%"=="Windows_NT" endlocal 90 | 91 | :omega 92 | -------------------------------------------------------------------------------- /scripts/get_staging_repository_id.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | import os 4 | import sys 5 | import urllib.request 6 | 7 | username = os.environ.get('NEXUS_USERNAME') 8 | password = os.environ.get('NEXUS_PASSWORD') 9 | 10 | def get(url, username, password): 11 | req = urllib.request.Request(url) 12 | base64_auth = base64.b64encode(bytes('{}:{}'.format(username, password),'ascii')) 13 | req.add_header("Authorization", "Basic {}".format(base64_auth.decode('utf-8'))) 14 | req.add_header('Accept', 'application/json') 15 | with urllib.request.urlopen(req) as response: 16 | return response.read() 17 | 18 | def getRepositories(username, password): 19 | return json.loads(get("https://oss.sonatype.org/service/local/staging/profile_repositories", username, password)) 20 | 21 | repositories = getRepositories(username, password).get("data") 22 | if len(repositories) != 1: 23 | sys.stderr.write("Zero or more than one staging repository. Exiting. Please execute the process manually.") 24 | exit(1) 25 | 26 | repositoryId = repositories[0].get("repositoryId") 27 | print(repositoryId) 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/main/java/com/pusher/rest/Pusher.java: -------------------------------------------------------------------------------- 1 | package com.pusher.rest; 2 | 3 | import com.pusher.rest.data.Result; 4 | import org.apache.http.HttpResponse; 5 | import org.apache.http.client.config.RequestConfig; 6 | import org.apache.http.client.methods.HttpGet; 7 | import org.apache.http.client.methods.HttpPost; 8 | import org.apache.http.client.methods.HttpRequestBase; 9 | import org.apache.http.entity.StringEntity; 10 | import org.apache.http.impl.DefaultConnectionReuseStrategy; 11 | import org.apache.http.impl.client.CloseableHttpClient; 12 | import org.apache.http.impl.client.DefaultConnectionKeepAliveStrategy; 13 | import org.apache.http.impl.client.HttpClientBuilder; 14 | import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; 15 | 16 | import java.io.ByteArrayOutputStream; 17 | import java.io.IOException; 18 | import java.net.URI; 19 | 20 | /** 21 | * A library for interacting with the Pusher HTTP API. 22 | *

23 | * See http://github.com/pusher/pusher-http-java for an overview 24 | *

25 | * Essentially: 26 | *

 27 |  * // Init
 28 |  * Pusher pusher = new Pusher(APP_ID, KEY, SECRET);
 29 |  * // Publish
 30 |  * Result triggerResult = pusher.trigger("my-channel", "my-eventname", myPojoForSerialisation);
 31 |  * if (triggerResult.getStatus() != Status.SUCCESS) {
 32 |  *   if (triggerResult.getStatus().shouldRetry()) {
 33 |  *     // Temporary, let's schedule a retry
 34 |  *   }
 35 |  *   else {
 36 |  *     // Something is wrong with our request
 37 |  *   }
 38 |  * }
 39 |  *
 40 |  * // Query
 41 |  * Result channelListResult = pusher.get("/channels");
 42 |  * if (channelListResult.getStatus() == Status.SUCCESS) {
 43 |  *   String channelListAsJson = channelListResult.getMessage();
 44 |  *   // etc
 45 |  * }
 46 |  * 
47 | * 48 | * See {@link PusherAsync} for the asynchronous implementation. 49 | */ 50 | public class Pusher extends PusherAbstract implements AutoCloseable { 51 | 52 | private int requestTimeout = 4000; // milliseconds 53 | 54 | private CloseableHttpClient client; 55 | 56 | /** 57 | * Construct an instance of the Pusher object through which you may interact with the Pusher API. 58 | *

59 | * The parameters to use are found on your dashboard at https://app.pusher.com and are specific per App. 60 | *

61 | * @param appId The ID of the App you will to interact with. 62 | * @param key The App Key, the same key you give to websocket clients to identify your app when they connect to Pusher. 63 | * @param secret The App Secret. Used to sign requests to the API, this should be treated as sensitive and not distributed. 64 | */ 65 | public Pusher(final String appId, final String key, final String secret) { 66 | super(appId, key, secret); 67 | configureHttpClient(defaultHttpClientBuilder()); 68 | } 69 | 70 | /** 71 | * Construct an instance of the Pusher object through which you may interact with the Pusher API. 72 | *

73 | * The parameters to use are found on your dashboard at https://app.pusher.com and are specific per App. 74 | *

75 | * 76 | * @param appId The ID of the App you will to interact with. 77 | * @param key The App Key, the same key you give to websocket clients to identify your app when they connect to Pusher. 78 | * @param secret The App Secret. Used to sign requests to the API, this should be treated as sensitive and not distributed. 79 | * @param encryptionMasterKeyBase64 32 byte key, base64 encoded. This key, along with the channel name, are used to derive per-channel encryption keys. 80 | */ 81 | public Pusher(final String appId, final String key, final String secret, final String encryptionMasterKeyBase64) { 82 | super(appId, key, secret, encryptionMasterKeyBase64); 83 | 84 | configureHttpClient(defaultHttpClientBuilder()); 85 | } 86 | 87 | public Pusher(final String url) { 88 | super(url); 89 | configureHttpClient(defaultHttpClientBuilder()); 90 | } 91 | 92 | /* 93 | * CONFIG 94 | */ 95 | 96 | /** 97 | * Default: 4000 98 | * 99 | * @param requestTimeout the request timeout in milliseconds 100 | */ 101 | public void setRequestTimeout(final int requestTimeout) { 102 | this.requestTimeout = requestTimeout; 103 | } 104 | 105 | /** 106 | * Returns an HttpClientBuilder with the settings used by default applied. You may apply 107 | * further configuration (for example an HTTP proxy), override existing configuration 108 | * (for example, the connection manager which handles connection pooling for reuse) and 109 | * then call {@link #configureHttpClient(HttpClientBuilder)} to have this configuration 110 | * applied to all subsequent calls. 111 | * 112 | * @see #configureHttpClient(HttpClientBuilder) 113 | * 114 | * @return an {@link org.apache.http.impl.client.HttpClientBuilder} with the default settings applied 115 | */ 116 | public static HttpClientBuilder defaultHttpClientBuilder() { 117 | return HttpClientBuilder.create() 118 | .setConnectionManager(new PoolingHttpClientConnectionManager()) 119 | .setConnectionReuseStrategy(new DefaultConnectionReuseStrategy()) 120 | .setKeepAliveStrategy(new DefaultConnectionKeepAliveStrategy()) 121 | .disableRedirectHandling(); 122 | } 123 | 124 | /** 125 | * Configure the HttpClient instance which will be used for making calls to the Pusher API. 126 | *

127 | * This method allows almost complete control over all aspects of the HTTP client, including 128 | *

    129 | *
  • proxy host
  • 130 | *
  • connection pooling and reuse strategies
  • 131 | *
  • automatic retry and backoff strategies
  • 132 | *
133 | * It is strongly recommended that you take the value of {@link #defaultHttpClientBuilder()} 134 | * as a base, apply your custom config to that and then pass the builder in here, to ensure 135 | * that sensible defaults for configuration areas you are not setting explicitly are retained. 136 | *

137 | * e.g. 138 | *

139 |      * pusher.configureHttpClient(
140 |      *     Pusher.defaultHttpClientBuilder()
141 |      *           .setProxy(new HttpHost("proxy.example.com"))
142 |      *           .disableAutomaticRetries()
143 |      * );
144 |      * 
145 | * 146 | * @see #defaultHttpClientBuilder() 147 | * 148 | * @param builder an {@link org.apache.http.impl.client.HttpClientBuilder} with which to configure 149 | * the internal HTTP client 150 | */ 151 | public void configureHttpClient(final HttpClientBuilder builder) { 152 | try { 153 | close(); 154 | } catch (final Exception e) { 155 | // Not a lot useful we can do here 156 | } 157 | 158 | this.client = builder.build(); 159 | } 160 | 161 | /* 162 | * REST 163 | */ 164 | 165 | @Override 166 | protected Result doGet(final URI uri) { 167 | return httpCall(new HttpGet(uri)); 168 | } 169 | 170 | @Override 171 | protected Result doPost(final URI uri, final String body) { 172 | final StringEntity bodyEntity = new StringEntity(body, "UTF-8"); 173 | bodyEntity.setContentType("application/json"); 174 | 175 | final HttpPost request = new HttpPost(uri); 176 | request.setEntity(bodyEntity); 177 | 178 | return httpCall(request); 179 | } 180 | 181 | Result httpCall(final HttpRequestBase request) { 182 | final RequestConfig config = RequestConfig.custom() 183 | .setSocketTimeout(requestTimeout) 184 | .setConnectionRequestTimeout(requestTimeout) 185 | .setConnectTimeout(requestTimeout) 186 | .build(); 187 | request.setConfig(config); 188 | 189 | try { 190 | final HttpResponse response = client.execute(request); 191 | 192 | final ByteArrayOutputStream baos = new ByteArrayOutputStream(); 193 | response.getEntity().writeTo(baos); 194 | final String responseBody = new String(baos.toByteArray(), "UTF-8"); 195 | 196 | return Result.fromHttpCode(response.getStatusLine().getStatusCode(), responseBody); 197 | } 198 | catch (final IOException e) { 199 | return Result.fromException(e); 200 | } 201 | } 202 | 203 | @Override 204 | public void close() throws Exception { 205 | if (client != null) { 206 | client.close(); 207 | } 208 | } 209 | 210 | } 211 | -------------------------------------------------------------------------------- /src/main/java/com/pusher/rest/PusherAbstract.java: -------------------------------------------------------------------------------- 1 | package com.pusher.rest; 2 | 3 | import com.google.gson.FieldNamingPolicy; 4 | import com.google.gson.Gson; 5 | import com.google.gson.GsonBuilder; 6 | import com.pusher.rest.crypto.CryptoUtil; 7 | import com.pusher.rest.data.*; 8 | import com.pusher.rest.marshaller.DataMarshaller; 9 | import com.pusher.rest.marshaller.DefaultDataMarshaller; 10 | import com.pusher.rest.util.Prerequisites; 11 | 12 | import java.net.URI; 13 | import java.nio.charset.StandardCharsets; 14 | import java.util.ArrayList; 15 | import java.util.Collections; 16 | import java.util.List; 17 | import java.util.Map; 18 | import java.util.regex.Matcher; 19 | import java.util.regex.Pattern; 20 | 21 | /** 22 | * Parent class for Pusher clients, deals with anything that isn't IO related. 23 | * 24 | * @param The return type of the IO calls. 25 | * 26 | * See {@link Pusher} for the synchronous implementation, {@link PusherAsync} for the asynchronous implementation. 27 | */ 28 | public abstract class PusherAbstract { 29 | protected static final Gson BODY_SERIALISER = new GsonBuilder() 30 | .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) 31 | .disableHtmlEscaping() 32 | .create(); 33 | 34 | private static final Pattern HEROKU_URL = Pattern.compile("(https?)://(.+):(.+)@(.+:?.*)/apps/(.+)"); 35 | private static final String ENCRYPTED_CHANNEL_PREFIX = "private-encrypted-"; 36 | 37 | protected final String appId; 38 | protected final String key; 39 | protected final String secret; 40 | 41 | protected String host = "api.pusherapp.com"; 42 | protected String scheme = "http"; 43 | 44 | private DataMarshaller dataMarshaller; 45 | private CryptoUtil crypto; 46 | private final boolean hasValidEncryptionMasterKey; 47 | 48 | /** 49 | * Construct an instance of the Pusher object through which you may interact with the Pusher API. 50 | *

51 | * The parameters to use are found on your dashboard at https://app.pusher.com and are specific per App. 52 | *

53 | * 54 | * @param appId The ID of the App you will to interact with. 55 | * @param key The App Key, the same key you give to websocket clients to identify your app when they connect to Pusher. 56 | * @param secret The App Secret. Used to sign requests to the API, this should be treated as sensitive and not distributed. 57 | */ 58 | public PusherAbstract(final String appId, final String key, final String secret) { 59 | Prerequisites.nonEmpty("appId", appId); 60 | Prerequisites.nonEmpty("key", key); 61 | Prerequisites.nonEmpty("secret", secret); 62 | Prerequisites.isValidSha256Key("secret", secret); 63 | 64 | this.appId = appId; 65 | this.key = key; 66 | this.secret = secret; 67 | this.hasValidEncryptionMasterKey = false; 68 | 69 | configureDataMarshaller(); 70 | } 71 | 72 | /** 73 | * Construct an instance of the Pusher object through which you may interact with the Pusher API. 74 | *

75 | * The parameters to use are found on your dashboard at https://app.pusher.com and are specific per App. 76 | *

77 | * 78 | * @param appId The ID of the App you will to interact with. 79 | * @param key The App Key, the same key you give to websocket clients to identify your app when they connect to Pusher. 80 | * @param secret The App Secret. Used to sign requests to the API, this should be treated as sensitive and not distributed. 81 | * @param encryptionMasterKeyBase64 32 byte key, base64 encoded. This key, along with the channel name, are used to derive per-channel encryption keys. 82 | */ 83 | public PusherAbstract(final String appId, final String key, final String secret, final String encryptionMasterKeyBase64) { 84 | Prerequisites.nonEmpty("appId", appId); 85 | Prerequisites.nonEmpty("key", key); 86 | Prerequisites.nonEmpty("secret", secret); 87 | Prerequisites.isValidSha256Key("secret", secret); 88 | Prerequisites.nonEmpty("encryptionMasterKeyBase64", encryptionMasterKeyBase64); 89 | 90 | this.appId = appId; 91 | this.key = key; 92 | this.secret = secret; 93 | 94 | this.crypto = new CryptoUtil(encryptionMasterKeyBase64); 95 | this.hasValidEncryptionMasterKey = true; 96 | 97 | configureDataMarshaller(); 98 | } 99 | 100 | public PusherAbstract(final String url) { 101 | Prerequisites.nonNull("url", url); 102 | 103 | final Matcher m = HEROKU_URL.matcher(url); 104 | if (m.matches()) { 105 | this.scheme = m.group(1); 106 | this.key = m.group(2); 107 | this.secret = m.group(3); 108 | this.host = m.group(4); 109 | this.appId = m.group(5); 110 | this.hasValidEncryptionMasterKey = false; 111 | } else { 112 | throw new IllegalArgumentException("URL '" + url + "' does not match pattern '://:@[:]/apps/'"); 113 | } 114 | 115 | Prerequisites.isValidSha256Key("secret", secret); 116 | configureDataMarshaller(); 117 | } 118 | 119 | private void configureDataMarshaller() { 120 | this.dataMarshaller = new DefaultDataMarshaller(); 121 | } 122 | 123 | protected void setCryptoUtil(CryptoUtil crypto) { 124 | this.crypto = crypto; 125 | } 126 | 127 | /* 128 | * CONFIG 129 | */ 130 | 131 | /** 132 | * For testing or specifying an alternative cluster. See also {@link #setCluster(String)} for the latter. 133 | *

134 | * Default: api.pusherapp.com 135 | * 136 | * @param host the API endpoint host 137 | */ 138 | public void setHost(final String host) { 139 | Prerequisites.nonNull("host", host); 140 | 141 | this.host = host; 142 | } 143 | 144 | /** 145 | * For Specifying an alternative cluster. 146 | *

147 | * See also {@link #setHost(String)} for targetting an arbitrary endpoint. 148 | * 149 | * @param cluster the Pusher cluster to target 150 | */ 151 | public void setCluster(final String cluster) { 152 | Prerequisites.nonNull("cluster", cluster); 153 | 154 | this.host = "api-" + cluster + ".pusher.com"; 155 | } 156 | 157 | /** 158 | * Set whether to use a secure connection to the API (SSL). 159 | *

160 | * Authentication is secure even without this option, requests cannot be faked or replayed with access 161 | * to their plain text, a secure connection is only required if the requests or responses contain 162 | * sensitive information. 163 | *

164 | * Default: false 165 | * 166 | * @param encrypted whether to use SSL to contact the API 167 | */ 168 | public void setEncrypted(final boolean encrypted) { 169 | this.scheme = encrypted ? "https" : "http"; 170 | } 171 | 172 | /** 173 | * Set the Gson instance used to marshal Objects passed to {@link #trigger(List, String, Object)} 174 | * Set the marshaller used to serialize Objects passed to {@link #trigger(List, String, Object)} 175 | * and friends. 176 | * By default, the library marshals the objects provided to JSON using the Gson library 177 | * (see https://code.google.com/p/google-gson/ for more details). By providing an instance 178 | * here, you may exert control over the marshalling, for example choosing how Java property 179 | * names are mapped on to the field names in the JSON representation, allowing you to match 180 | * the expected scheme on the client side. 181 | * We added the {@link #setDataMarshaller(DataMarshaller)} method to allow specification 182 | * of other marshalling libraries. This method was kept around to maintain backwards 183 | * compatibility. 184 | * @param gson a GSON instance configured to your liking 185 | */ 186 | public void setGsonSerialiser(final Gson gson) { 187 | setDataMarshaller(new DefaultDataMarshaller(gson)); 188 | } 189 | 190 | /** 191 | * Set a custom marshaller used to serialize Objects passed to {@link #trigger(List, String, Object)} 192 | * and friends. 193 | *

194 | * By default, the library marshals the objects provided to JSON using the Gson library 195 | * (see https://code.google.com/p/google-gson/ for more details). By providing an instance 196 | * here, you may exert control over the marshalling, for example choosing how Java property 197 | * names are mapped on to the field names in the JSON representation, allowing you to match 198 | * the expected scheme on the client side. 199 | * 200 | * @param marshaller a DataMarshaller instance configured to your liking 201 | */ 202 | public void setDataMarshaller(final DataMarshaller marshaller) { 203 | this.dataMarshaller = marshaller; 204 | } 205 | 206 | /** 207 | * This method provides an override point if the default Gson based serialisation is absolutely 208 | * unsuitable for your use case, even with customisation of the Gson instance doing the serialisation. 209 | *

210 | * For example, in the simplest case, you might already have your data pre-serialised and simply want 211 | * to elide the default serialisation: 212 | *

213 |      * Pusher pusher = new Pusher(appId, key, secret) {
214 |      *     protected String serialise(final Object data) {
215 |      *         return (String)data;
216 |      *     }
217 |      * };
218 |      *
219 |      * pusher.trigger("my-channel", "my-event", "{\"my-data\":\"my-value\"}");
220 |      * 
221 | * 222 | * @param data an unserialised event payload 223 | * @return a serialised event payload 224 | */ 225 | protected String serialise(final Object data) { 226 | return dataMarshaller.marshal(data); 227 | } 228 | 229 | /* 230 | * REST 231 | */ 232 | 233 | /** 234 | * Publish a message to a single channel. 235 | *

236 | * The message data should be a POJO, which will be serialised to JSON for submission. 237 | * Use {@link #setDataMarshaller(DataMarshaller)} to control the serialisation 238 | *

239 | * Note that if you do not wish to create classes specifically for the purpose of specifying 240 | * the message payload, use Map<String, Object>. These maps will nest just fine. 241 | * 242 | * @param channel the channel name on which to trigger the event 243 | * @param eventName the name given to the event 244 | * @param data an object which will be serialised to create the event body 245 | * @return a {@link Result} object encapsulating the success state and response to the request 246 | */ 247 | public T trigger(final String channel, final String eventName, final Object data) { 248 | return trigger(channel, eventName, data, null); 249 | } 250 | 251 | /** 252 | * Publish identical messages to multiple channels. 253 | * 254 | * @param channels the channel names on which to trigger the event 255 | * @param eventName the name given to the event 256 | * @param data an object which will be serialised to create the event body 257 | * @return a {@link Result} object encapsulating the success state and response to the request 258 | */ 259 | public T trigger(final List channels, final String eventName, final Object data) { 260 | return trigger(channels, eventName, data, null); 261 | } 262 | 263 | /** 264 | * Publish a message to a single channel, excluding the specified socketId from receiving the message. 265 | * 266 | * @param channel the channel name on which to trigger the event 267 | * @param eventName the name given to the event 268 | * @param data an object which will be serialised to create the event body 269 | * @param socketId a socket id which should be excluded from receiving the event 270 | * @return a {@link Result} object encapsulating the success state and response to the request 271 | */ 272 | public T trigger(final String channel, final String eventName, final Object data, final String socketId) { 273 | return trigger(Collections.singletonList(channel), eventName, data, socketId); 274 | } 275 | 276 | /** 277 | * Publish identical messages to multiple channels, excluding the specified socketId from receiving the message. 278 | * 279 | * @param channels the channel names on which to trigger the event 280 | * @param eventName the name given to the event 281 | * @param data an object which will be serialised to create the event body 282 | * @param socketId a socket id which should be excluded from receiving the event 283 | * @return a {@link Result} object encapsulating the success state and response to the request 284 | */ 285 | public T trigger(final List channels, final String eventName, final Object data, final String socketId) { 286 | Prerequisites.nonNull("channels", channels); 287 | Prerequisites.nonNull("eventName", eventName); 288 | Prerequisites.nonNull("data", data); 289 | Prerequisites.maxLength("channels", 100, channels); 290 | Prerequisites.noNullMembers("channels", channels); 291 | Prerequisites.areValidChannels(channels); 292 | Prerequisites.isValidSocketId(socketId); 293 | 294 | final String eventBody; 295 | final String encryptedChannel = channels.stream() 296 | .filter(this::isEncryptedChannel) 297 | .findFirst() 298 | .orElse(""); 299 | 300 | if (encryptedChannel.isEmpty()) { 301 | eventBody = serialise(data); 302 | } else { 303 | requireEncryptionMasterKey(); 304 | 305 | if (channels.size() > 1) { 306 | throw PusherException.cannotTriggerMultipleChannelsWithEncryption(); 307 | } 308 | 309 | eventBody = encryptPayload(encryptedChannel, serialise(data)); 310 | } 311 | 312 | final String body = BODY_SERIALISER.toJson(new TriggerData(channels, eventName, eventBody, socketId)); 313 | 314 | return post("/events", body); 315 | } 316 | 317 | 318 | /** 319 | * Publish a batch of different events with a single API call. 320 | *

321 | * The batch is limited to 10 events on our multi-tenant clusters. 322 | * 323 | * @param batch a list of events to publish 324 | * @return a {@link Result} object encapsulating the success state and response to the request 325 | */ 326 | public T trigger(final List batch) { 327 | final List eventsWithSerialisedBodies = new ArrayList(batch.size()); 328 | 329 | for (final Event e : batch) { 330 | final String eventData; 331 | 332 | if (isEncryptedChannel(e.getChannel())) { 333 | requireEncryptionMasterKey(); 334 | 335 | eventData = encryptPayload(e.getChannel(), serialise(e.getData())); 336 | } else { 337 | eventData = serialise(e.getData()); 338 | } 339 | 340 | eventsWithSerialisedBodies.add( 341 | new Event( 342 | e.getChannel(), 343 | e.getName(), 344 | eventData, 345 | e.getSocketId() 346 | ) 347 | ); 348 | } 349 | 350 | final String body = BODY_SERIALISER.toJson(new EventBatch(eventsWithSerialisedBodies)); 351 | 352 | return post("/batch_events", body); 353 | } 354 | 355 | /** 356 | * Make a generic HTTP call to the Pusher API. 357 | *

358 | * See: http://pusher.com/docs/rest_api 359 | *

360 | * NOTE: the path specified here is relative to that of your app. For example, to access 361 | * the channel list for your app, simply pass "/channels". Do not include the "/apps/[appId]" 362 | * at the beginning of the path. 363 | * 364 | * @param path the path (e.g. /channels) to query 365 | * @return a {@link Result} object encapsulating the success state and response to the request 366 | */ 367 | public T get(final String path) { 368 | return get(path, Collections.emptyMap()); 369 | } 370 | 371 | /** 372 | * Make a generic HTTP call to the Pusher API. 373 | *

374 | * See: http://pusher.com/docs/rest_api 375 | *

376 | * Parameters should be a map of query parameters for the HTTP call, and may be null 377 | * if none are required. 378 | *

379 | * NOTE: the path specified here is relative to that of your app. For example, to access 380 | * the channel list for your app, simply pass "/channels". Do not include the "/apps/[appId]" 381 | * at the beginning of the path. 382 | * 383 | * @param path the path (e.g. /channels) to query 384 | * @param parameters query parameters to submit with the request 385 | * @return a {@link Result} object encapsulating the success state and response to the request 386 | */ 387 | public T get(final String path, final Map parameters) { 388 | final String fullPath = "/apps/" + appId + path; 389 | final URI uri = SignatureUtil.uri("GET", scheme, host, fullPath, null, key, secret, parameters); 390 | 391 | return doGet(uri); 392 | } 393 | 394 | protected abstract T doGet(final URI uri); 395 | 396 | /** 397 | * Make a generic HTTP call to the Pusher API. 398 | *

399 | * The body should be a UTF-8 encoded String 400 | *

401 | * See: http://pusher.com/docs/rest_api 402 | *

403 | * NOTE: the path specified here is relative to that of your app. For example, to access 404 | * the channel list for your app, simply pass "/channels". Do not include the "/apps/[appId]" 405 | * at the beginning of the path. 406 | * 407 | * @param path the path (e.g. /channels) to submit 408 | * @param body the body to submit 409 | * @return a {@link Result} object encapsulating the success state and response to the request 410 | */ 411 | public T post(final String path, final String body) { 412 | final String fullPath = "/apps/" + appId + path; 413 | final URI uri = SignatureUtil.uri("POST", scheme, host, fullPath, body, key, secret, Collections.emptyMap()); 414 | 415 | return doPost(uri, body); 416 | } 417 | 418 | protected abstract T doPost(final URI uri, final String body); 419 | 420 | /** 421 | * If you wanted to send the HTTP API requests manually (e.g. using a different HTTP client), this method 422 | * will return a java.net.URI which includes all of the appropriate query parameters which sign the request. 423 | * 424 | * @param method the HTTP method, e.g. GET, POST 425 | * @param path the HTTP path, e.g. /channels 426 | * @param body the HTTP request body, if there is one (otherwise pass null) 427 | * @return a URI object which includes the necessary query params for request authentication 428 | */ 429 | public URI signedUri(final String method, final String path, final String body) { 430 | return signedUri(method, path, body, Collections.emptyMap()); 431 | } 432 | 433 | /** 434 | * If you wanted to send the HTTP API requests manually (e.g. using a different HTTP client), this method 435 | * will return a java.net.URI which includes all of the appropriate query parameters which sign the request. 436 | *

437 | * Note that any further query parameters you wish to be add must be specified here, as they form part of the signature. 438 | * 439 | * @param method the HTTP method, e.g. GET, POST 440 | * @param path the HTTP path, e.g. /channels 441 | * @param body the HTTP request body, if there is one (otherwise pass null) 442 | * @param parameters HTTP query parameters to be included in the request 443 | * @return a URI object which includes the necessary query params for request authentication 444 | */ 445 | public URI signedUri(final String method, final String path, final String body, final Map parameters) { 446 | return SignatureUtil.uri(method, scheme, host, path, body, key, secret, parameters); 447 | } 448 | 449 | /* 450 | * CHANNEL AUTHENTICATION 451 | */ 452 | 453 | /** 454 | * Generate authentication response to authorise a user on a private channel 455 | *

456 | * The return value is the complete body which should be returned to a client requesting authorisation. 457 | * 458 | * @param socketId the socket id of the connection to authenticate 459 | * @param channel the name of the channel which the socket id should be authorised to join 460 | * @return an authentication string, suitable for return to the requesting client 461 | */ 462 | public String authenticate(final String socketId, final String channel) { 463 | Prerequisites.nonNull("socketId", socketId); 464 | Prerequisites.nonNull("channel", channel); 465 | Prerequisites.isValidChannel(channel); 466 | Prerequisites.isValidSocketId(socketId); 467 | 468 | if (channel.startsWith("presence-")) { 469 | throw new IllegalArgumentException("This method is for private channels, use authenticate(String, String, PresenceUser) to authenticate for a presence channel."); 470 | } 471 | if (!channel.startsWith("private-")) { 472 | throw new IllegalArgumentException("Authentication is only applicable to private and presence channels"); 473 | } 474 | 475 | final String signature = SignatureUtil.sign(socketId + ":" + channel, secret); 476 | 477 | final AuthData authData = new AuthData(key, signature); 478 | 479 | if (isEncryptedChannel(channel)) { 480 | requireEncryptionMasterKey(); 481 | 482 | authData.setSharedSecret(crypto.generateBase64EncodedSharedSecret(channel)); 483 | } 484 | 485 | return BODY_SERIALISER.toJson(authData); 486 | } 487 | 488 | /** 489 | * Generate authentication response to authorise a user on a presence channel 490 | *

491 | * The return value is the complete body which should be returned to a client requesting authorisation. 492 | * 493 | * @param socketId the socket id of the connection to authenticate 494 | * @param channel the name of the channel which the socket id should be authorised to join 495 | * @param user a {@link PresenceUser} object which represents the channel data to be associated with the user 496 | * @return an authentication string, suitable for return to the requesting client 497 | */ 498 | public String authenticate(final String socketId, final String channel, final PresenceUser user) { 499 | Prerequisites.nonNull("socketId", socketId); 500 | Prerequisites.nonNull("channel", channel); 501 | Prerequisites.nonNull("user", user); 502 | Prerequisites.isValidChannel(channel); 503 | Prerequisites.isValidSocketId(socketId); 504 | 505 | if (channel.startsWith("private-")) { 506 | throw new IllegalArgumentException("This method is for presence channels, use authenticate(String, String) to authenticate for a private channel."); 507 | } 508 | if (!channel.startsWith("presence-")) { 509 | throw new IllegalArgumentException("Authentication is only applicable to private and presence channels"); 510 | } 511 | 512 | final String channelData = BODY_SERIALISER.toJson(user); 513 | final String signature = SignatureUtil.sign(socketId + ":" + channel + ":" + channelData, secret); 514 | return BODY_SERIALISER.toJson(new AuthData(key, signature, channelData)); 515 | } 516 | 517 | /* 518 | * WEBHOOK VALIDATION 519 | */ 520 | 521 | /** 522 | * Check the signature on a webhook received from Pusher 523 | * 524 | * @param xPusherKeyHeader the X-Pusher-Key header as received in the webhook request 525 | * @param xPusherSignatureHeader the X-Pusher-Signature header as received in the webhook request 526 | * @param body the webhook body 527 | * @return enum representing the possible validities of the webhook request 528 | */ 529 | public Validity validateWebhookSignature(final String xPusherKeyHeader, final String xPusherSignatureHeader, final String body) { 530 | if (!xPusherKeyHeader.trim().equals(key)) { 531 | // We can't validate the signature, because it was signed with a different key to the one we were initialised with. 532 | return Validity.SIGNED_WITH_WRONG_KEY; 533 | } 534 | 535 | final String recalculatedSignature = SignatureUtil.sign(body, secret); 536 | return xPusherSignatureHeader.trim().equals(recalculatedSignature) ? Validity.VALID : Validity.INVALID; 537 | } 538 | 539 | private boolean isEncryptedChannel(final String channel) { 540 | return channel.startsWith(ENCRYPTED_CHANNEL_PREFIX); 541 | } 542 | 543 | private void requireEncryptionMasterKey() 544 | { 545 | if (hasValidEncryptionMasterKey) { 546 | return; 547 | } 548 | 549 | throw PusherException.encryptionMasterKeyRequired(); 550 | } 551 | 552 | private String encryptPayload(final String encryptedChannel, final String payload) { 553 | final EncryptedMessage encryptedMsg = crypto.encrypt( 554 | encryptedChannel, 555 | payload.getBytes(StandardCharsets.UTF_8) 556 | ); 557 | 558 | return BODY_SERIALISER.toJson(encryptedMsg); 559 | } 560 | } 561 | -------------------------------------------------------------------------------- /src/main/java/com/pusher/rest/PusherAsync.java: -------------------------------------------------------------------------------- 1 | package com.pusher.rest; 2 | 3 | import com.pusher.rest.data.Result; 4 | import org.asynchttpclient.AsyncHttpClient; 5 | import org.asynchttpclient.DefaultAsyncHttpClientConfig; 6 | import org.asynchttpclient.Request; 7 | import org.asynchttpclient.RequestBuilder; 8 | import org.asynchttpclient.util.HttpConstants; 9 | 10 | import java.io.IOException; 11 | import java.net.URI; 12 | import java.util.concurrent.CompletableFuture; 13 | 14 | import static java.nio.charset.StandardCharsets.UTF_8; 15 | import static org.asynchttpclient.Dsl.asyncHttpClient; 16 | import static org.asynchttpclient.Dsl.config; 17 | 18 | /** 19 | * A library for interacting with the Pusher HTTP API asynchronously. 20 | *

21 | * See http://github.com/pusher/pusher-http-java for an overview 22 | *

23 | * Essentially: 24 | *

 25 |  * // Init
 26 |  * PusherAsync pusher = new PusherAsync(APP_ID, KEY, SECRET);
 27 |  *
 28 |  * // Publish
 29 |  * CompletableFuture<Result> futureTriggerResult = pusher.trigger("my-channel", "my-eventname", myPojoForSerialisation);
 30 |  * triggerResult.thenAccept(triggerResult -> {
 31 |  *   if (triggerResult.getStatus() == Status.SUCCESS) {
 32 |  *     // request was successful
 33 |  *   } else {
 34 |  *     // something went wrong with the request
 35 |  *   }
 36 |  * });
 37 |  *
 38 |  * // Query
 39 |  * CompletableFuture<Result> futureChannelListResult = pusher.get("/channels");
 40 |  * futureChannelListResult.thenAccept(triggerResult -> {
 41 |  *   if (triggerResult.getStatus() == Status.SUCCESS) {
 42 |  *     String channelListAsJson = channelListResult.getMessage();
 43 |  *     // etc.
 44 |  *   } else {
 45 |  *     // something went wrong with the request
 46 |  *   }
 47 |  * });
 48 |  * 
49 | * 50 | * See {@link Pusher} for the synchronous implementation. 51 | */ 52 | public class PusherAsync extends PusherAbstract> implements AutoCloseable { 53 | 54 | private AsyncHttpClient client; 55 | 56 | /** 57 | * Construct an instance of the Pusher object through which you may interact with the Pusher API. 58 | *

59 | * The parameters to use are found on your dashboard at https://app.pusher.com and are specific per App. 60 | *

61 | * 62 | * @param appId The ID of the App you will to interact with. 63 | * @param key The App Key, the same key you give to websocket clients to identify your app when they connect to Pusher. 64 | * @param secret The App Secret. Used to sign requests to the API, this should be treated as sensitive and not distributed. 65 | */ 66 | public PusherAsync(final String appId, final String key, final String secret) { 67 | super(appId, key, secret); 68 | configureHttpClient(config()); 69 | } 70 | 71 | /** 72 | * Construct an instance of the Pusher object through which you may interact with the Pusher API. 73 | *

74 | * The parameters to use are found on your dashboard at https://app.pusher.com and are specific per App. 75 | *

76 | * 77 | * @param appId The ID of the App you will to interact with. 78 | * @param key The App Key, the same key you give to websocket clients to identify your app when they connect to Pusher. 79 | * @param secret The App Secret. Used to sign requests to the API, this should be treated as sensitive and not distributed. 80 | * @param encryptionMasterKeyBase64 32 byte key, base64 encoded. This key, along with the channel name, are used to derive per-channel encryption keys. 81 | */ 82 | public PusherAsync(final String appId, final String key, final String secret, final String encryptionMasterKeyBase64) { 83 | super(appId, key, secret, encryptionMasterKeyBase64); 84 | configureHttpClient(config()); 85 | } 86 | 87 | public PusherAsync(final String url) { 88 | super(url); 89 | configureHttpClient(config()); 90 | } 91 | 92 | /* 93 | * CONFIG 94 | */ 95 | 96 | /** 97 | * Configure the AsyncHttpClient instance which will be used for making calls to the Pusher API. 98 | *

99 | * This method allows almost complete control over all aspects of the HTTP client, including 100 | *

    101 | *
  • proxy host
  • 102 | *
  • connection pooling and reuse strategies
  • 103 | *
  • automatic retry and backoff strategies
  • 104 | *
105 | *

106 | * e.g. 107 | *

108 |      * pusher.configureHttpClient(
109 |      *     config()
110 |      *         .setProxyServer(proxyServer("127.0.0.1", 38080))
111 |      *         .setMaxRequestRetry(5)
112 |      * );
113 |      * 
114 | * 115 | * @param builder an {@link DefaultAsyncHttpClientConfig.Builder} with which to configure 116 | * the internal HTTP client 117 | */ 118 | public void configureHttpClient(final DefaultAsyncHttpClientConfig.Builder builder) { 119 | try { 120 | close(); 121 | } catch (final Exception e) { 122 | // Not a lot useful we can do here 123 | } 124 | 125 | this.client = asyncHttpClient(builder); 126 | } 127 | 128 | /* 129 | * REST 130 | */ 131 | 132 | @Override 133 | protected CompletableFuture doGet(final URI uri) { 134 | final Request request = new RequestBuilder(HttpConstants.Methods.GET) 135 | .setUrl(uri.toString()) 136 | .build(); 137 | 138 | return httpCall(request); 139 | } 140 | 141 | @Override 142 | protected CompletableFuture doPost(final URI uri, final String body) { 143 | final Request request = new RequestBuilder(HttpConstants.Methods.POST) 144 | .setUrl(uri.toString()) 145 | .setBody(body) 146 | .addHeader("Content-Type", "application/json") 147 | .build(); 148 | 149 | return httpCall(request); 150 | } 151 | 152 | CompletableFuture httpCall(final Request request) { 153 | return client 154 | .prepareRequest(request) 155 | .execute() 156 | .toCompletableFuture() 157 | .thenApply(response -> Result.fromHttpCode(response.getStatusCode(), response.getResponseBody(UTF_8))) 158 | .exceptionally(Result::fromThrowable); 159 | } 160 | 161 | @Override 162 | public void close() throws Exception { 163 | if (client != null && !client.isClosed()) { 164 | client.close(); 165 | } 166 | } 167 | 168 | } 169 | -------------------------------------------------------------------------------- /src/main/java/com/pusher/rest/PusherException.java: -------------------------------------------------------------------------------- 1 | package com.pusher.rest; 2 | 3 | public class PusherException extends RuntimeException { 4 | 5 | public PusherException(String errorMessage) { 6 | super(errorMessage); 7 | } 8 | 9 | public static PusherException encryptionMasterKeyRequired() { 10 | return new PusherException("You cannot use encrypted channels without setting a master encryption key"); 11 | } 12 | 13 | public static PusherException cannotTriggerMultipleChannelsWithEncryption() { 14 | return new PusherException("You cannot trigger to multiple channels when using encrypted channels"); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/com/pusher/rest/SignatureUtil.java: -------------------------------------------------------------------------------- 1 | package com.pusher.rest; 2 | 3 | import java.io.UnsupportedEncodingException; 4 | import java.net.URI; 5 | import java.net.URISyntaxException; 6 | import java.security.InvalidKeyException; 7 | import java.security.MessageDigest; 8 | import java.security.NoSuchAlgorithmException; 9 | import java.util.Arrays; 10 | import java.util.HashMap; 11 | import java.util.Map; 12 | import java.util.Map.Entry; 13 | 14 | import javax.crypto.Mac; 15 | import javax.crypto.spec.SecretKeySpec; 16 | 17 | import org.apache.commons.codec.binary.Hex; 18 | import org.apache.http.client.utils.URIBuilder; 19 | 20 | import com.pusher.rest.util.Prerequisites; 21 | 22 | public class SignatureUtil { 23 | 24 | public static URI uri(final String method, 25 | final String scheme, 26 | final String host, 27 | final String path, 28 | final String body, 29 | final String key, 30 | final String secret, 31 | final Map extraParams) { 32 | 33 | Prerequisites.noReservedKeys(extraParams); 34 | 35 | try { 36 | final Map allParams = new HashMap(extraParams); 37 | allParams.put("auth_key", key); 38 | allParams.put("auth_version", "1.0"); 39 | allParams.put("auth_timestamp", Long.toString(System.currentTimeMillis() / 1000)); 40 | if (body != null) { 41 | allParams.put("body_md5", bodyMd5(body)); 42 | } 43 | 44 | // This is where the auth gets a bit weird. The query params for the request must include 45 | // the auth signature which is a signature over all the params except itself. 46 | allParams.put("auth_signature", sign(buildSignatureString(method, path, allParams), secret)); 47 | 48 | final URIBuilder b = new URIBuilder() 49 | .setScheme(scheme) 50 | .setHost(host) 51 | .setPath(path); 52 | 53 | for (final Entry e : allParams.entrySet()) { 54 | b.setParameter(e.getKey(), e.getValue()); 55 | } 56 | 57 | return b.build(); 58 | } 59 | catch (final URISyntaxException e) { 60 | throw new RuntimeException("Could not build URI", e); 61 | } 62 | } 63 | 64 | private static String bodyMd5(final String body) { 65 | try { 66 | final MessageDigest md = MessageDigest.getInstance("MD5"); 67 | final byte[] digest = md.digest(body.getBytes("UTF-8")); 68 | return Hex.encodeHexString(digest); 69 | } 70 | // If this doesn't exist, we're pretty much out of luck. 71 | catch (final NoSuchAlgorithmException e) { 72 | throw new RuntimeException("The Pusher HTTP client requires MD5 support", e); 73 | } 74 | catch (final UnsupportedEncodingException e) { 75 | throw new RuntimeException("The Pusher HTTP client needs UTF-8 support", e); 76 | } 77 | } 78 | 79 | public static String sign(final String input, final String secret) { 80 | try { 81 | final Mac mac = Mac.getInstance("HmacSHA256"); 82 | mac.init(new SecretKeySpec(secret.getBytes(), "SHA256")); 83 | 84 | final byte[] digest = mac.doFinal(input.getBytes("UTF-8")); 85 | return Hex.encodeHexString(digest); 86 | } 87 | catch (final InvalidKeyException e) { 88 | /// We validate this when the key is first provided, so we should never encounter it here. 89 | throw new RuntimeException("Invalid secret key", e); 90 | } 91 | // If either of these doesn't exist, we're pretty much out of luck. 92 | catch (final NoSuchAlgorithmException e) { 93 | throw new RuntimeException("The Pusher HTTP client requires HmacSHA256 support", e); 94 | } 95 | catch (final UnsupportedEncodingException e) { 96 | throw new RuntimeException("The Pusher HTTP client needs UTF-8 support", e); 97 | } 98 | } 99 | 100 | // Visible for testing 101 | static String buildSignatureString(final String method, final String path, final Map queryParams) { 102 | final StringBuilder sb = new StringBuilder(); 103 | sb.append(method) 104 | .append('\n') 105 | .append(path) 106 | .append('\n'); 107 | 108 | final String[] keys = queryParams.keySet().toArray(new String[0]); 109 | Arrays.sort(keys); 110 | 111 | boolean first = true; 112 | for (final String key : keys) { 113 | if (!first) sb.append('&'); 114 | else first = false; 115 | 116 | sb.append(key) 117 | .append('=') 118 | .append(queryParams.get(key)); 119 | } 120 | 121 | return sb.toString(); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/main/java/com/pusher/rest/crypto/CryptoUtil.java: -------------------------------------------------------------------------------- 1 | package com.pusher.rest.crypto; 2 | 3 | import com.pusher.rest.crypto.nacl.SecretBox; 4 | import com.pusher.rest.data.EncryptedMessage; 5 | import com.pusher.rest.util.Prerequisites; 6 | 7 | import java.nio.charset.StandardCharsets; 8 | import java.security.MessageDigest; 9 | import java.security.NoSuchAlgorithmException; 10 | import java.util.Base64; 11 | import java.util.Map; 12 | 13 | public class CryptoUtil { 14 | 15 | private static final String SHARED_SECRET_ENCRYPTION_ALGO = "SHA-256"; 16 | private static final int MASTER_KEY_LENGTH = 32; 17 | private final byte[] encryptionMasterKey; 18 | 19 | public CryptoUtil(final String base64EncodedMasterKey) { 20 | Prerequisites.nonEmpty("base64EncodedMasterKey", base64EncodedMasterKey); 21 | 22 | this.encryptionMasterKey = parseEncryptionMasterKey(base64EncodedMasterKey); 23 | } 24 | 25 | public String generateBase64EncodedSharedSecret(final String channel) { 26 | return Base64.getEncoder().withoutPadding().encodeToString( 27 | generateSharedSecret(channel) 28 | ); 29 | } 30 | 31 | public EncryptedMessage encrypt(final String channel, final byte[] message) { 32 | final byte[] sharedSecret = generateSharedSecret(channel); 33 | 34 | final Map res = SecretBox.box(sharedSecret, message); 35 | 36 | return new EncryptedMessage( 37 | Base64.getEncoder().encodeToString(res.get("nonce")), 38 | Base64.getEncoder().encodeToString(res.get("cipher")) 39 | ); 40 | } 41 | 42 | public String decrypt(final String channel, final EncryptedMessage encryptedMessage) { 43 | final byte[] sharedSecret = generateSharedSecret(channel); 44 | 45 | final byte[] decryptMessage = SecretBox.open( 46 | sharedSecret, 47 | Base64.getDecoder().decode(encryptedMessage.getNonce()), 48 | Base64.getDecoder().decode(encryptedMessage.getCiphertext().getBytes()) 49 | ); 50 | 51 | return new String(decryptMessage, StandardCharsets.UTF_8); 52 | } 53 | 54 | private byte[] parseEncryptionMasterKey(final String base64EncodedEncryptionMasterKey) { 55 | final byte[] key = Base64.getDecoder().decode(base64EncodedEncryptionMasterKey); 56 | 57 | if (key.length != MASTER_KEY_LENGTH) { 58 | throw new IllegalArgumentException("encryptionMasterKeyBase64 must be a 32 byte key, base64 encoded"); 59 | } 60 | 61 | return key; 62 | } 63 | 64 | private byte[] generateSharedSecret(final String channel) { 65 | try { 66 | MessageDigest digest = MessageDigest.getInstance(CryptoUtil.SHARED_SECRET_ENCRYPTION_ALGO); 67 | byte[] channelB = channel.getBytes(StandardCharsets.UTF_8); 68 | 69 | byte[] buf = new byte[channelB.length + encryptionMasterKey.length]; 70 | System.arraycopy(channelB, 0, buf, 0, channelB.length); 71 | System.arraycopy(encryptionMasterKey, 0, buf, channelB.length, encryptionMasterKey.length); 72 | 73 | return digest.digest(buf); 74 | } catch (NoSuchAlgorithmException e) { 75 | throw new RuntimeException(e); 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/main/java/com/pusher/rest/crypto/nacl/SecretBox.java: -------------------------------------------------------------------------------- 1 | package com.pusher.rest.crypto.nacl; 2 | 3 | import java.security.SecureRandom; 4 | import java.util.HashMap; 5 | import java.util.Map; 6 | 7 | /** 8 | * SecretBox class controls access to TweetNaclFast to prevent users from 9 | * reaching out to TweetNaclFast. TweetNaclFast must stay package private. 10 | */ 11 | public class SecretBox { 12 | 13 | private final static int NONCE_LENGTH = 24; 14 | 15 | public static Map box(final byte[] key, final byte[] message) { 16 | TweetNaclFast.SecretBox secretBox = new TweetNaclFast.SecretBox(key); 17 | 18 | final byte[] nonce = new byte[NONCE_LENGTH]; 19 | new SecureRandom().nextBytes(nonce); 20 | final byte[] cipher = secretBox.box(message, nonce); 21 | 22 | final Map res = new HashMap<>(); 23 | res.put("cipher", cipher); 24 | res.put("nonce", nonce); 25 | 26 | return res; 27 | } 28 | 29 | public static byte[] open(final byte[] key, final byte[] nonce, final byte[] cipher) { 30 | TweetNaclFast.SecretBox secretBox = new TweetNaclFast.SecretBox(key); 31 | 32 | final byte[] decryptedMessage = secretBox.open(cipher, nonce); 33 | 34 | if (decryptedMessage == null) { 35 | throw new RuntimeException("can't decrypt"); 36 | } 37 | 38 | return decryptedMessage; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/main/java/com/pusher/rest/data/AuthData.java: -------------------------------------------------------------------------------- 1 | package com.pusher.rest.data; 2 | 3 | public class AuthData { 4 | 5 | private final String auth; 6 | private final String channelData; 7 | private String sharedSecret; 8 | 9 | /** 10 | * Private channel constructor 11 | * 12 | * @param key App key 13 | * @param signature Auth signature 14 | */ 15 | public AuthData(final String key, final String signature) { 16 | this(key, signature, null); 17 | } 18 | 19 | /** 20 | * Presence channel constructor 21 | * 22 | * @param key App key 23 | * @param signature Auth signature 24 | * @param channelData Extra user data 25 | */ 26 | public AuthData(final String key, final String signature, final String channelData) { 27 | this.auth = key + ":" + signature; 28 | this.channelData = channelData; 29 | } 30 | 31 | public String getAuth() { 32 | return auth; 33 | } 34 | 35 | public String getChannelData() { 36 | return channelData; 37 | } 38 | 39 | public String getSharedSecret() { 40 | return sharedSecret; 41 | } 42 | 43 | public void setSharedSecret(final String sharedSecret) { 44 | this.sharedSecret = sharedSecret; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/main/java/com/pusher/rest/data/EncryptedMessage.java: -------------------------------------------------------------------------------- 1 | package com.pusher.rest.data; 2 | 3 | public class EncryptedMessage { 4 | 5 | private final String nonce; 6 | private final String ciphertext; 7 | 8 | public EncryptedMessage(String nonce, String ciphertext) { 9 | this.nonce = nonce; 10 | this.ciphertext = ciphertext; 11 | } 12 | 13 | public String getNonce() { 14 | return nonce; 15 | } 16 | 17 | public String getCiphertext() { 18 | return ciphertext; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/java/com/pusher/rest/data/Event.java: -------------------------------------------------------------------------------- 1 | package com.pusher.rest.data; 2 | 3 | import com.pusher.rest.util.Prerequisites; 4 | 5 | /** 6 | * POJO for JSON encoding of trigger batch events. 7 | */ 8 | public class Event { 9 | 10 | private final String channel; 11 | private final String name; 12 | private final Object data; 13 | private final String socketId; 14 | 15 | public Event(final String channel, final String eventName, final Object data) { 16 | this(channel, eventName, data, null); 17 | } 18 | 19 | public Event(final String channel, final String eventName, final Object data, final String socketId) { 20 | Prerequisites.nonNull("channel", channel); 21 | Prerequisites.nonNull("eventName", eventName); 22 | Prerequisites.nonNull("data", data); 23 | 24 | this.channel = channel; 25 | this.name = eventName; 26 | this.data = data; 27 | this.socketId = socketId; 28 | } 29 | 30 | public String getChannel() { 31 | return channel; 32 | } 33 | 34 | public String getName() { 35 | return name; 36 | } 37 | 38 | public Object getData() { 39 | return data; 40 | } 41 | 42 | public String getSocketId() { 43 | return socketId; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/main/java/com/pusher/rest/data/EventBatch.java: -------------------------------------------------------------------------------- 1 | package com.pusher.rest.data; 2 | 3 | import java.util.List; 4 | 5 | /** 6 | * POJO body for batch events 7 | */ 8 | public class EventBatch { 9 | 10 | private final List batch; 11 | 12 | public EventBatch(final List batch) { 13 | this.batch = batch; 14 | } 15 | 16 | public List getBatch() { 17 | return batch; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/com/pusher/rest/data/PresenceUser.java: -------------------------------------------------------------------------------- 1 | package com.pusher.rest.data; 2 | 3 | import com.pusher.rest.util.Prerequisites; 4 | 5 | /** 6 | * Represents a precence channel "user", that is a user from the domain of your application. 7 | */ 8 | public class PresenceUser { 9 | 10 | private final Object userId; 11 | private final Object userInfo; 12 | 13 | /** 14 | * Represents a presence channel user with no additional data associated other than the userId 15 | * 16 | * @param userId the unique ID to associate with the user 17 | */ 18 | public PresenceUser(final String userId) { 19 | this((Object)userId, null); 20 | } 21 | 22 | /** 23 | * Represents a presence channel user with no additional data associated other than the userId 24 | * 25 | * @param userId the unique ID to associate with the user 26 | */ 27 | public PresenceUser(final Number userId) { 28 | this((Object)userId, null); 29 | } 30 | 31 | /** 32 | * Represents a presence channel user and a map of data associated with the user 33 | * 34 | * @param userId the unique ID to associate with the user 35 | * @param userInfo additional data to be associated with the user 36 | */ 37 | public PresenceUser(final String userId, final Object userInfo) { 38 | this((Object)userId, userInfo); 39 | } 40 | 41 | /** 42 | * Represents a presence channel user and a map of data associated with the user 43 | * 44 | * @param userId the unique ID to associate with the user 45 | * @param userInfo additional data to be associated with the user 46 | */ 47 | public PresenceUser(final Number userId, final Object userInfo) { 48 | this((Object)userId, userInfo); 49 | } 50 | 51 | /** 52 | * There's not really a great way to accept either a string or numeric value in a typesafe way, 53 | * so this will have to do. 54 | * 55 | * @param userId the unique ID to associate with the user 56 | * @param userInfo additional data to be associated with the user 57 | */ 58 | private PresenceUser(final Object userId, final Object userInfo) { 59 | Prerequisites.nonNull("userId", userId); 60 | 61 | this.userId = userId; 62 | this.userInfo = userInfo; 63 | } 64 | 65 | public Object getUserId() { 66 | return userId; 67 | } 68 | 69 | public Object getUserInfo() { 70 | return userInfo; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/main/java/com/pusher/rest/data/Result.java: -------------------------------------------------------------------------------- 1 | package com.pusher.rest.data; 2 | 3 | import static com.pusher.rest.data.Result.Status.*; 4 | 5 | import java.io.IOException; 6 | 7 | import org.apache.http.client.ClientProtocolException; 8 | 9 | public class Result { 10 | public enum Status { 11 | SUCCESS(false), // No need! 12 | CLIENT_ERROR(false), 13 | AUTHENTICATION_ERROR(false), 14 | MESSAGE_QUOTA_EXCEEDED(false), 15 | NOT_FOUND(false), 16 | SERVER_ERROR(true), 17 | NETWORK_ERROR(true), 18 | UNKNOWN_ERROR(true), 19 | ; 20 | 21 | private final boolean shouldRetry; 22 | 23 | private Status(final boolean shouldRetry) { 24 | this.shouldRetry = shouldRetry; 25 | } 26 | 27 | /** 28 | * @return whether the call should be retried without modification with an expectation of success 29 | */ 30 | public boolean shouldRetry() { 31 | return shouldRetry; 32 | } 33 | } 34 | 35 | private final Status status; 36 | private final Integer httpStatus; 37 | private final String message; 38 | 39 | private Result(final Status status, final Integer httpStatus, final String message) { 40 | this.status = status; 41 | this.httpStatus = httpStatus; 42 | this.message = message; 43 | } 44 | 45 | /** 46 | * Factory method 47 | * 48 | * @param statusCode HTTP status code 49 | * @param responseBody HTTP response body 50 | * @return a Result encapsulating the params 51 | */ 52 | public static Result fromHttpCode(final int statusCode, final String responseBody) { 53 | final Status status; 54 | switch (statusCode) { 55 | case 200: 56 | status = SUCCESS; 57 | break; 58 | case 400: 59 | status = CLIENT_ERROR; 60 | break; 61 | case 401: 62 | status = AUTHENTICATION_ERROR; 63 | break; 64 | case 403: 65 | status = MESSAGE_QUOTA_EXCEEDED; 66 | break; 67 | case 404: 68 | status = NOT_FOUND; 69 | break; 70 | default: 71 | status = statusCode >= 500 && statusCode < 600 ? SERVER_ERROR : UNKNOWN_ERROR; 72 | } 73 | 74 | return new Result(status, statusCode, responseBody); 75 | } 76 | 77 | /** 78 | * Factory method 79 | * 80 | * @param e cause 81 | * @return a Result encapsulating the params 82 | */ 83 | public static Result fromException(final IOException e) { 84 | return new Result(Status.NETWORK_ERROR, null, e.toString()); 85 | } 86 | 87 | /** 88 | * Factory method 89 | * 90 | * @param e cause 91 | * @return a Result encapsulating the params 92 | */ 93 | public static Result fromException(final ClientProtocolException e) { 94 | return new Result(Status.UNKNOWN_ERROR, null, e.toString()); 95 | } 96 | 97 | /** 98 | * Factory method 99 | * 100 | * @param t cause 101 | * @return a Result encapsulating the params 102 | */ 103 | public static Result fromThrowable(final Throwable t) { 104 | return new Result(Status.UNKNOWN_ERROR, null, t.toString()); 105 | } 106 | 107 | /** 108 | * @return the enum classifying the result of the call 109 | */ 110 | public Status getStatus() { 111 | return status; 112 | } 113 | 114 | /** 115 | * @return the data response (success) or descriptive message (error) returned from the call 116 | */ 117 | public String getMessage() { 118 | return message; 119 | } 120 | 121 | /** 122 | * @return the HTTP status code of the call, useful for debugging instances of UNKNOWN_ERROR 123 | */ 124 | public Integer getHttpStatus() { 125 | return httpStatus; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/main/java/com/pusher/rest/data/TriggerData.java: -------------------------------------------------------------------------------- 1 | package com.pusher.rest.data; 2 | 3 | import java.util.List; 4 | 5 | /** 6 | * POJO for JSON encoding of trigger request bodies. 7 | */ 8 | public class TriggerData { 9 | 10 | private final List channels; 11 | private final String name; 12 | private final String data; 13 | private final String socketId; 14 | 15 | public TriggerData(final List channels, final String eventName, final String data, final String socketId) { 16 | this.channels = channels; 17 | this.name = eventName; 18 | this.data = data; 19 | this.socketId = socketId; 20 | } 21 | 22 | public List getChannels() { 23 | return channels; 24 | } 25 | 26 | public String getName() { 27 | return name; 28 | } 29 | 30 | public String getData() { 31 | return data; 32 | } 33 | 34 | public String getSocketId() { 35 | return socketId; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/java/com/pusher/rest/data/Validity.java: -------------------------------------------------------------------------------- 1 | package com.pusher.rest.data; 2 | 3 | public enum Validity { 4 | VALID, 5 | INVALID, 6 | SIGNED_WITH_WRONG_KEY; 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/com/pusher/rest/marshaller/DataMarshaller.java: -------------------------------------------------------------------------------- 1 | package com.pusher.rest.marshaller; 2 | 3 | public interface DataMarshaller { 4 | 5 | String marshal(final Object data); 6 | } 7 | -------------------------------------------------------------------------------- /src/main/java/com/pusher/rest/marshaller/DefaultDataMarshaller.java: -------------------------------------------------------------------------------- 1 | package com.pusher.rest.marshaller; 2 | 3 | import com.google.gson.FieldNamingPolicy; 4 | import com.google.gson.Gson; 5 | import com.google.gson.GsonBuilder; 6 | 7 | public class DefaultDataMarshaller implements DataMarshaller { 8 | 9 | private final Gson gson; 10 | 11 | public DefaultDataMarshaller() { 12 | gson = new GsonBuilder() 13 | .disableHtmlEscaping() 14 | .create(); 15 | } 16 | 17 | public DefaultDataMarshaller(Gson customGsonInstance) { 18 | gson = customGsonInstance; 19 | } 20 | 21 | public String marshal(final Object data) { 22 | return gson.toJson(data); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/com/pusher/rest/util/Prerequisites.java: -------------------------------------------------------------------------------- 1 | package com.pusher.rest.util; 2 | 3 | import java.security.InvalidKeyException; 4 | import java.security.NoSuchAlgorithmException; 5 | import java.util.Arrays; 6 | import java.util.HashSet; 7 | import java.util.List; 8 | import java.util.Map; 9 | import java.util.Set; 10 | import java.util.regex.Pattern; 11 | 12 | import javax.crypto.Mac; 13 | import javax.crypto.spec.SecretKeySpec; 14 | 15 | public final class Prerequisites { 16 | 17 | private static final Pattern VALID_CHANNEL = Pattern.compile("\\A[-a-zA-Z0-9_=@,.;]+\\z"); 18 | private static final Pattern VALID_SOCKET_ID = Pattern.compile("\\A\\d+\\.\\d+\\z"); 19 | 20 | private static final Set RESERVED_QUERY_KEYS = new HashSet( 21 | Arrays.asList(new String[] { "auth_key", "auth_timestamp", "auth_version", "auth_signature", "body_md5" })); 22 | 23 | public static void nonNull(final String name, final Object ref) { 24 | if (ref == null) throw new IllegalArgumentException("Parameter [" + name + "] must not be null"); 25 | } 26 | 27 | public static void nonEmpty(final String name, final String ref) { 28 | nonNull(name, ref); 29 | if (ref.length() == 0) throw new IllegalArgumentException("Parameter [" + name + "] must not be empty"); 30 | } 31 | 32 | public static void maxLength(final String name, final int max, final List ref) { 33 | if (ref.size() > max) throw new IllegalArgumentException("Parameter [" + name + "] must have size < " + max); 34 | } 35 | 36 | public static void noNullMembers(final String name, final List ref) { 37 | for (Object e : ref) { 38 | if (e == null) throw new IllegalArgumentException("Parameter [" + name + "] must not contain null elements"); 39 | } 40 | } 41 | 42 | public static void noReservedKeys(final Map params) { 43 | for (String k : params.keySet()) { 44 | if (RESERVED_QUERY_KEYS.contains(k.toLowerCase())) { 45 | throw new IllegalArgumentException("Query parameter key [" + k + "] is reserved and should not be submitted. It will be generated by the signature generation."); 46 | } 47 | } 48 | } 49 | 50 | public static void isValidSha256Key(final String name, final String key) { 51 | try { 52 | Mac mac = Mac.getInstance("HmacSHA256"); 53 | mac.init(new SecretKeySpec(key.getBytes(), "SHA256")); 54 | // If that goes OK, then we're good to go 55 | } 56 | catch (final NoSuchAlgorithmException e) { 57 | // Out of luck. 58 | throw new RuntimeException("The Pusher HTTP client requires HmacSHA256 support", e); 59 | } 60 | catch (final InvalidKeyException e) { 61 | // Failed the test 62 | throw new IllegalArgumentException("Parameter [" + name + "] must be a valid SHA256 key", e); 63 | } 64 | } 65 | 66 | public static void areValidChannels(final List channels) { 67 | for (String channel : channels) { 68 | isValidChannel(channel); 69 | } 70 | } 71 | 72 | public static void isValidChannel(final String channel) { 73 | matchesRegex("channel", VALID_CHANNEL, channel); 74 | } 75 | 76 | public static void isValidSocketId(final String socketId) { 77 | if (socketId != null) { 78 | matchesRegex("socket_id", VALID_SOCKET_ID, socketId); 79 | } 80 | } 81 | 82 | private static void matchesRegex(final String name, final Pattern regex, final String toMatch) { 83 | nonNull(name, toMatch); 84 | if (!regex.matcher(toMatch).matches()) { 85 | throw new IllegalArgumentException(name + " [" + toMatch + "] is not valid"); 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/main/javadoc/css/styles.css: -------------------------------------------------------------------------------- 1 | /* Javadoc style sheet */ 2 | 3 | /* Define colors, fonts and other style attributes here to override the defaults */ 4 | 5 | /* Page background color */ 6 | body { background-color: #FFFFFF; color:#333; font-size: 100%; } 7 | 8 | body { font-size: 0.875em; line-height: 1.286em; font-family: "Helvetica", "Arial", sans-serif; } 9 | 10 | code { color: #777; line-height: 1.286em; font-family: "Consolas", "Lucida Console", "Droid Sans Mono", "Andale Mono", "Monaco", "Lucida Sans Typewriter"; } 11 | 12 | a { text-decoration: none; color: #16569A; /* also try #2E85ED, #0033FF, #6C93C6, #1D7BBE, #1D8DD2 */ } 13 | a:hover { color: #EEEEEE; background-color: #16569A; } 14 | a:visited { color: #CC3300; } 15 | a:visited:hover { color: #fff; background-color: #CC3300; } 16 | 17 | table[border="1"] { border: 1px solid #ddd; } 18 | table[border="1"] td, table[border="1"] th { border: 1px solid #ddd; } 19 | table[cellpadding="3"] td { padding: 0.5em; } 20 | 21 | font[size="-1"] { font-size: 0.85em; line-height: 1.5em; } 22 | font[size="-2"] { font-size: 0.8em; } 23 | font[size="+2"] { font-size: 1.4em; line-height: 1.3em; padding: 0.4em 0; } 24 | 25 | /* Headings */ 26 | h1 { font-size: 1.5em; line-height: 1.286em;} 27 | 28 | /* Table colors */ 29 | .TableHeadingColor { background: #ccc; color:#444; } /* Dark mauve */ 30 | .TableSubHeadingColor { background: #ddd; color:#444; } /* Light mauve */ 31 | .TableRowColor { background: #FFFFFF; color:#666; font-size: 0.95em; } /* White */ 32 | .TableRowColor code { color:#000; } /* White */ 33 | 34 | /* Font used in left-hand frame lists */ 35 | .FrameTitleFont { font-size: 100%; } 36 | .FrameHeadingFont { font-size: 90%; } 37 | .FrameItemFont { font-size: 0.9em; line-height: 1.3em; 38 | } 39 | /* Java Interfaces */ 40 | .FrameItemFont a i { 41 | font-style: normal; color: #666; 42 | } 43 | .FrameItemFont a:hover i { 44 | font-style: normal; color: #fff; background-color: #666; 45 | } 46 | 47 | /* Navigation bar fonts and colors */ 48 | .NavBarCell1 { background-color:#E0E6DF; } /* Light mauve */ 49 | .NavBarCell1Rev { background-color:#16569A; color:#FFFFFF} /* Dark Blue */ 50 | .NavBarFont1 { } 51 | .NavBarFont1Rev { color:#FFFFFF; } 52 | 53 | .NavBarCell2 { background-color:#FFFFFF; color:#000000} 54 | .NavBarCell3 { background-color:#FFFFFF; color:#000000} 55 | -------------------------------------------------------------------------------- /src/test/java/com/pusher/rest/PusherAsyncHttpTest.java: -------------------------------------------------------------------------------- 1 | package com.pusher.rest; 2 | 3 | import com.pusher.rest.data.Result; 4 | import com.pusher.rest.data.Result.Status; 5 | import org.apache.http.entity.StringEntity; 6 | import org.apache.http.impl.bootstrap.HttpServer; 7 | import org.apache.http.impl.bootstrap.ServerBootstrap; 8 | import org.asynchttpclient.Request; 9 | import org.asynchttpclient.RequestBuilder; 10 | import org.asynchttpclient.util.HttpConstants; 11 | import org.junit.jupiter.api.AfterEach; 12 | import org.junit.jupiter.api.BeforeEach; 13 | import org.junit.jupiter.api.Test; 14 | 15 | import static org.hamcrest.MatcherAssert.assertThat; 16 | import static org.hamcrest.Matchers.containsString; 17 | import static org.hamcrest.Matchers.is; 18 | 19 | /** 20 | * Tests which use a local server to check response handling 21 | */ 22 | public class PusherAsyncHttpTest { 23 | 24 | private HttpServer server; 25 | private Request request; 26 | 27 | private int responseStatus = 200; 28 | private String responseBody; 29 | 30 | private PusherAsync p; 31 | 32 | @BeforeEach 33 | public void setup() throws Exception { 34 | server = ServerBootstrap.bootstrap() 35 | .registerHandler("/*", (httpRequest, httpResponse, httpContext) -> { 36 | httpResponse.setStatusCode(responseStatus); 37 | if (responseBody != null) { 38 | httpResponse.setEntity(new StringEntity(responseBody)); 39 | } 40 | }) 41 | .create(); 42 | 43 | server.start(); 44 | 45 | request = new RequestBuilder(HttpConstants.Methods.GET) 46 | .setUrl("http://" + server.getInetAddress().getHostName() + ":" + server.getLocalPort() + "/test") 47 | .build(); 48 | 49 | p = new PusherAsync(PusherTest.APP_ID, PusherTest.KEY, PusherTest.SECRET); 50 | } 51 | 52 | @AfterEach 53 | public void teardown() { 54 | server.stop(); 55 | } 56 | 57 | @Test 58 | public void successReturnsOkAndBody() throws Exception { 59 | responseStatus = 200; 60 | responseBody = "{}"; 61 | 62 | Result result = p.httpCall(request).get(); 63 | assertThat(result.getStatus(), is(Status.SUCCESS)); 64 | assertThat(result.getMessage(), is(responseBody)); 65 | } 66 | 67 | @Test 68 | public void status400ReturnsGenericErrorAndMessage() throws Exception { 69 | responseStatus = 400; 70 | responseBody = "A lolcat got all up in ur request"; 71 | 72 | Result result = p.httpCall(request).get(); 73 | assertThat(result.getStatus(), is(Status.CLIENT_ERROR)); 74 | assertThat(result.getMessage(), is(responseBody)); 75 | } 76 | 77 | @Test 78 | public void status401ReturnsAuthenticationErrorAndMessage() throws Exception { 79 | responseStatus = 401; 80 | responseBody = "Sorry, not in those shoes"; 81 | 82 | Result result = p.httpCall(request).get(); 83 | assertThat(result.getStatus(), is(Status.AUTHENTICATION_ERROR)); 84 | assertThat(result.getMessage(), is(responseBody)); 85 | } 86 | 87 | @Test 88 | public void status403ReturnsAuthenticationErrorAndMessage() throws Exception { 89 | responseStatus = 403; 90 | responseBody = "Sorry, not with all those friends"; 91 | 92 | Result result = p.httpCall(request).get(); 93 | assertThat(result.getStatus(), is(Status.MESSAGE_QUOTA_EXCEEDED)); 94 | assertThat(result.getMessage(), is(responseBody)); 95 | } 96 | 97 | @Test 98 | public void status404ReturnsNotFoundErrorAndMessage() throws Exception { 99 | responseStatus = 404; 100 | responseBody = "This is not the endpoint you are looking for"; 101 | 102 | Result result = p.httpCall(request).get(); 103 | assertThat(result.getStatus(), is(Status.NOT_FOUND)); 104 | assertThat(result.getMessage(), is(responseBody)); 105 | } 106 | 107 | @Test 108 | public void status500ReturnsServerErrorAndMessage() throws Exception { 109 | responseStatus = 500; 110 | responseBody = "Gary? Gary! It's still on fire!!"; 111 | 112 | Result result = p.httpCall(request).get(); 113 | assertThat(result.getStatus(), is(Status.SERVER_ERROR)); 114 | assertThat(result.getMessage(), is(responseBody)); 115 | } 116 | 117 | @Test 118 | public void status503ReturnsServerErrorAndMessage() throws Exception { 119 | responseStatus = 503; 120 | responseBody = "Gary, did you once again restart all the back-ends at once?!"; 121 | 122 | Result result = p.httpCall(request).get(); 123 | assertThat(result.getStatus(), is(Status.SERVER_ERROR)); 124 | assertThat(result.getMessage(), is(responseBody)); 125 | } 126 | 127 | @Test 128 | public void connectionRefusedReturnsUnknownError() throws Exception { 129 | server.stop(); // don't listen for this test 130 | 131 | Result result = p.httpCall(request).get(); 132 | assertThat(result.getStatus(), is(Status.UNKNOWN_ERROR)); 133 | assertThat(result.getMessage(), containsString("Connection refused")); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/test/java/com/pusher/rest/PusherChannelAuthTest.java: -------------------------------------------------------------------------------- 1 | package com.pusher.rest; 2 | 3 | import com.pusher.rest.data.PresenceUser; 4 | import com.pusher.rest.util.PusherNoHttp; 5 | import org.junit.jupiter.api.Assertions; 6 | import org.junit.jupiter.api.Test; 7 | 8 | import java.util.Collections; 9 | 10 | import static org.hamcrest.MatcherAssert.assertThat; 11 | import static org.hamcrest.Matchers.is; 12 | 13 | public class PusherChannelAuthTest { 14 | 15 | private final PusherNoHttp p = new PusherNoHttp("00001", "278d425bdf160c739803", "7ad3773142a6692b25b8"); 16 | 17 | @Test 18 | public void privateChannelAuth() { 19 | assertThat(p.authenticate("1234.1234", "private-foobar"), 20 | is("{\"auth\":\"278d425bdf160c739803:58df8b0c36d6982b82c3ecf6b4662e34fe8c25bba48f5369f135bf843651c3a4\"}")); 21 | } 22 | 23 | @Test 24 | public void complexPrivateChannelAuth() { 25 | assertThat(p.authenticate("1234.1234", "private-azAZ9_=@,.;"), 26 | is("{\"auth\":\"278d425bdf160c739803:208cbbce2a22fd7d7c3509046b17a97b99d345cf4c195bc0d54af9004a022b0b\"}")); 27 | } 28 | 29 | @Test 30 | public void privateChannelWrongPrefix() { 31 | 32 | Assertions.assertThrows(IllegalArgumentException.class, () -> { 33 | p.authenticate("1234.1234", "presence-foobar"); 34 | }); 35 | } 36 | 37 | @Test 38 | public void privateChannelNoPrefix() { 39 | Assertions.assertThrows(IllegalArgumentException.class, () -> { 40 | p.authenticate("1234.1234", "foobar"); 41 | }); 42 | } 43 | 44 | @Test 45 | public void trailingColonSocketId() { 46 | Assertions.assertThrows(IllegalArgumentException.class, () -> { 47 | p.authenticate("1.1:", "private-foobar"); 48 | }); 49 | } 50 | 51 | @Test 52 | public void trailingNLSocketId() { 53 | Assertions.assertThrows(IllegalArgumentException.class, () -> { 54 | p.authenticate("1.1\n", "private-foobar"); 55 | }); 56 | } 57 | 58 | @Test 59 | public void leadingColonSocketId() { 60 | Assertions.assertThrows(IllegalArgumentException.class, () -> { 61 | p.authenticate(":1.1", "private-foobar"); 62 | }); 63 | } 64 | 65 | @Test 66 | public void leadingColonNLSocketId() { 67 | Assertions.assertThrows(IllegalArgumentException.class, () -> { 68 | p.authenticate(":\n1.1", "private-foobar"); 69 | }); 70 | } 71 | 72 | @Test 73 | public void trailingColonNLSocketId() { 74 | Assertions.assertThrows(IllegalArgumentException.class, () -> { 75 | p.authenticate("1.1\n:", "private-foobar"); 76 | }); 77 | } 78 | 79 | @Test 80 | public void trailingColonChannel() { 81 | Assertions.assertThrows(IllegalArgumentException.class, () -> { 82 | p.authenticate("1.1", "private-foobar:"); 83 | }); 84 | } 85 | 86 | @Test 87 | public void leadingColonChannel() { 88 | Assertions.assertThrows(IllegalArgumentException.class, () -> { 89 | p.authenticate("1.1", ":private-foobar"); 90 | }); 91 | } 92 | 93 | @Test 94 | public void leadingColonNLChannel() { 95 | Assertions.assertThrows(IllegalArgumentException.class, () -> { 96 | p.authenticate("1.1", ":\nprivate-foobar"); 97 | }); 98 | } 99 | 100 | @Test 101 | public void trailingColonNLChannel() { 102 | Assertions.assertThrows(IllegalArgumentException.class, () -> { 103 | p.authenticate("1.1", "private-foobar\n:"); 104 | }); 105 | } 106 | 107 | @Test 108 | public void trailingNLChannel() { 109 | Assertions.assertThrows(IllegalArgumentException.class, () -> { 110 | p.authenticate("1.1", "private-foobar\n"); 111 | }); 112 | } 113 | 114 | @Test 115 | public void presenceChannelAuth() { 116 | assertThat(p.authenticate("1234.1234", "presence-foobar", new PresenceUser(Integer.valueOf(10), Collections.singletonMap("name", "Mr. Pusher"))), 117 | is("{\"auth\":\"278d425bdf160c739803:afaed3695da2ffd16931f457e338e6c9f2921fa133ce7dac49f529792be6304c\",\"channel_data\":\"{\\\"user_id\\\":10,\\\"user_info\\\":{\\\"name\\\":\\\"Mr. Pusher\\\"}}\"}")); 118 | } 119 | 120 | @Test 121 | public void presenceChannelWrongPrefix() { 122 | Assertions.assertThrows(IllegalArgumentException.class, () -> { 123 | p.authenticate("1234.1234", "private-foobar", new PresenceUser("dave")); 124 | }); 125 | } 126 | 127 | @Test 128 | public void presenceChannelNoPrefix() { 129 | Assertions.assertThrows(IllegalArgumentException.class, () -> { 130 | p.authenticate("1234.1234", "foobar", new PresenceUser("dave")); 131 | }); 132 | } 133 | 134 | @Test 135 | public void encryptedChannelWithoutMasterKey() { 136 | final Exception exception = Assertions.assertThrows(RuntimeException.class, () -> { 137 | p.authenticate("1234.1234", "private-encrypted-foobar"); 138 | }); 139 | 140 | assertThat( 141 | exception.getMessage(), 142 | is(PusherException.encryptionMasterKeyRequired().getMessage()) 143 | ); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/test/java/com/pusher/rest/PusherHttpTest.java: -------------------------------------------------------------------------------- 1 | package com.pusher.rest; 2 | 3 | import static org.hamcrest.Matchers.*; 4 | import static org.hamcrest.MatcherAssert.assertThat; 5 | 6 | import org.apache.http.client.methods.HttpGet; 7 | import org.apache.http.entity.StringEntity; 8 | import org.apache.http.impl.bootstrap.HttpServer; 9 | import org.apache.http.impl.bootstrap.ServerBootstrap; 10 | import org.junit.jupiter.api.AfterEach; 11 | import org.junit.jupiter.api.BeforeEach; 12 | import org.junit.jupiter.api.Test; 13 | 14 | import com.pusher.rest.data.Result; 15 | import com.pusher.rest.data.Result.Status; 16 | 17 | /** 18 | * Tests which use a local server to check response handling 19 | */ 20 | public class PusherHttpTest { 21 | 22 | private HttpServer server; 23 | private HttpGet request; 24 | 25 | private int responseStatus = 200; 26 | private String responseBody; 27 | 28 | private Pusher p; 29 | 30 | @BeforeEach 31 | public void setup() throws Exception { 32 | server = ServerBootstrap.bootstrap() 33 | .registerHandler("/*", (httpRequest, httpResponse, httpContext) -> { 34 | httpResponse.setStatusCode(responseStatus); 35 | if (responseBody != null) { 36 | httpResponse.setEntity(new StringEntity(responseBody)); 37 | } 38 | }).create(); 39 | 40 | server.start(); 41 | 42 | request = new HttpGet("http://" + server.getInetAddress().getHostName() + ":" + server.getLocalPort() + "/test"); 43 | 44 | p = new Pusher(PusherTest.APP_ID, PusherTest.KEY, PusherTest.SECRET); 45 | } 46 | 47 | @AfterEach 48 | public void teardown() { 49 | server.stop(); 50 | } 51 | 52 | @Test 53 | public void successReturnsOkAndBody() { 54 | responseStatus = 200; 55 | responseBody = "{}"; 56 | 57 | Result result = p.httpCall(request); 58 | assertThat(result.getStatus(), is(Status.SUCCESS)); 59 | assertThat(result.getMessage(), is(responseBody)); 60 | } 61 | 62 | @Test 63 | public void status400ReturnsGenericErrorAndMessage() { 64 | responseStatus = 400; 65 | responseBody = "A lolcat got all up in ur request"; 66 | 67 | Result result = p.httpCall(request); 68 | assertThat(result.getStatus(), is(Status.CLIENT_ERROR)); 69 | assertThat(result.getMessage(), is(responseBody)); 70 | } 71 | 72 | @Test 73 | public void status401ReturnsAuthenticationErrorAndMessage() { 74 | responseStatus = 401; 75 | responseBody = "Sorry, not in those shoes"; 76 | 77 | Result result = p.httpCall(request); 78 | assertThat(result.getStatus(), is(Status.AUTHENTICATION_ERROR)); 79 | assertThat(result.getMessage(), is(responseBody)); 80 | } 81 | 82 | @Test 83 | public void status403ReturnsAuthenticationErrorAndMessage() { 84 | responseStatus = 403; 85 | responseBody = "Sorry, not with all those friends"; 86 | 87 | Result result = p.httpCall(request); 88 | assertThat(result.getStatus(), is(Status.MESSAGE_QUOTA_EXCEEDED)); 89 | assertThat(result.getMessage(), is(responseBody)); 90 | } 91 | 92 | @Test 93 | public void status404ReturnsNotFoundErrorAndMessage() { 94 | responseStatus = 404; 95 | responseBody = "This is not the endpoint you are looking for"; 96 | 97 | Result result = p.httpCall(request); 98 | assertThat(result.getStatus(), is(Status.NOT_FOUND)); 99 | assertThat(result.getMessage(), is(responseBody)); 100 | } 101 | 102 | @Test 103 | public void status500ReturnsServerErrorAndMessage() { 104 | responseStatus = 500; 105 | responseBody = "Gary? Gary! It's on fire!!"; 106 | 107 | Result result = p.httpCall(request); 108 | assertThat(result.getStatus(), is(Status.SERVER_ERROR)); 109 | assertThat(result.getMessage(), is(responseBody)); 110 | } 111 | 112 | @Test 113 | public void status503ReturnsServerErrorAndMessage() { 114 | responseStatus = 503; 115 | responseBody = "Gary, did you restart all the back-ends at once?!"; 116 | 117 | Result result = p.httpCall(request); 118 | assertThat(result.getStatus(), is(Status.SERVER_ERROR)); 119 | assertThat(result.getMessage(), is(responseBody)); 120 | } 121 | 122 | @Test 123 | public void connectionRefusedReturnsNetworkError() { 124 | server.stop(); // don't listen for this test 125 | 126 | Result result = p.httpCall(request); 127 | assertThat(result.getStatus(), is(Status.NETWORK_ERROR)); 128 | assertThat(result.getMessage(), containsString("Connection refused")); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/test/java/com/pusher/rest/PusherTest.java: -------------------------------------------------------------------------------- 1 | package com.pusher.rest; 2 | 3 | import com.google.gson.FieldNamingPolicy; 4 | import com.google.gson.Gson; 5 | import com.google.gson.GsonBuilder; 6 | import com.pusher.rest.crypto.CryptoUtil; 7 | import com.pusher.rest.data.EncryptedMessage; 8 | import com.pusher.rest.data.Event; 9 | import com.pusher.rest.marshaller.DataMarshaller; 10 | import org.apache.http.impl.client.CloseableHttpClient; 11 | import org.apache.http.impl.client.HttpClientBuilder; 12 | import org.jmock.Expectations; 13 | import org.jmock.Mockery; 14 | import org.jmock.imposters.ByteBuddyClassImposteriser; 15 | import org.jmock.junit5.JUnit5Mockery; 16 | import org.junit.jupiter.api.Assertions; 17 | import org.junit.jupiter.api.BeforeEach; 18 | import org.junit.jupiter.api.Test; 19 | 20 | import java.io.IOException; 21 | import java.nio.charset.StandardCharsets; 22 | import java.util.*; 23 | 24 | import static com.pusher.rest.util.Matchers.*; 25 | import static org.hamcrest.MatcherAssert.assertThat; 26 | import static org.hamcrest.Matchers.containsString; 27 | import static org.hamcrest.Matchers.is; 28 | 29 | /** 30 | * Tests which mock the HttpClient to check outgoing requests 31 | */ 32 | public class PusherTest { 33 | 34 | static final String APP_ID = "00001"; 35 | static final String KEY = "157a2f3df564323a4a73"; 36 | static final String SECRET = "3457a88be87f890dcd98"; 37 | static final String VALID_MASTER_KEY = "VGhlIDMyIGNoYXJzIGxvbmcgZW5jcnlwdGlvbiBrZXk="; 38 | static final String INVALID_MASTER_KEY = "VGhlIDMyIGNoYXJzIGxvbmcgZW5jce"; 39 | 40 | private final Mockery context = new JUnit5Mockery() {{ 41 | setImposteriser(ByteBuddyClassImposteriser.INSTANCE); 42 | }}; 43 | 44 | private CloseableHttpClient httpClient = context.mock(CloseableHttpClient.class); 45 | 46 | private final Pusher p = new Pusher(APP_ID, KEY, SECRET); 47 | 48 | @BeforeEach 49 | public void setup() { 50 | configureHttpClient(p); 51 | } 52 | 53 | private void configureHttpClient(Pusher p) { 54 | p.configureHttpClient(new HttpClientBuilder() { 55 | @Override 56 | public CloseableHttpClient build() { 57 | return httpClient; 58 | } 59 | }); 60 | } 61 | 62 | /* 63 | * Serialisation tests 64 | */ 65 | 66 | @SuppressWarnings("unused") 67 | private static class MyPojo { 68 | private String aString; 69 | private int aNumber; 70 | 71 | public MyPojo() { 72 | this.aString = "value"; 73 | this.aNumber = 42; 74 | } 75 | 76 | public MyPojo(final String aString, final int aNumber) { 77 | this.aString = aString; 78 | this.aNumber = aNumber; 79 | } 80 | } 81 | 82 | @Test 83 | public void serialisePojo() throws IOException { 84 | context.checking(new Expectations() {{ 85 | oneOf(httpClient).execute(with(field("data", "{\"aString\":\"value\",\"aNumber\":42}"))); 86 | }}); 87 | 88 | p.trigger("my-channel", "event", new MyPojo()); 89 | } 90 | @Test 91 | 92 | public void customSerialisationGson() throws Exception { 93 | p.setGsonSerialiser(new GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_DASHES).create()); 94 | 95 | context.checking(new Expectations() {{ 96 | oneOf(httpClient).execute(with(field("data", "{\"a-string\":\"value\",\"a-number\":42}"))); 97 | }}); 98 | 99 | p.trigger("my-channel", "event", new MyPojo()); 100 | } 101 | 102 | @Test 103 | public void customSerialisationDataMarshaller() throws Exception { 104 | p.setDataMarshaller(new DataMarshaller() { 105 | private Gson gson = new GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_DASHES).create(); 106 | public String marshal(final Object data) { 107 | return gson.toJson(data); 108 | } 109 | }); 110 | 111 | context.checking(new Expectations() {{ 112 | oneOf(httpClient).execute(with(field("data", "{\"a-string\":\"value\",\"a-number\":42}"))); 113 | }}); 114 | 115 | p.trigger("my-channel", "event", new MyPojo()); 116 | } 117 | 118 | @Test 119 | public void customSerialisationByExtension() throws Exception { 120 | Pusher p = new Pusher(APP_ID, KEY, SECRET) { 121 | @Override 122 | protected String serialise(Object data) { 123 | return (String)data; 124 | } 125 | }; 126 | configureHttpClient(p); 127 | 128 | context.checking(new Expectations() {{ 129 | oneOf(httpClient).execute(with(field("data", "this is my string data"))); 130 | }}); 131 | 132 | p.trigger("my-channel", "event", "this is my string data"); 133 | } 134 | 135 | @Test 136 | public void batchEvents() throws IOException { 137 | final List> res = new ArrayList>() {{ 138 | add(new HashMap() {{ 139 | put("channel", "my-channel"); 140 | put("name", "event-name"); 141 | put("data", "{\"aString\":\"value1\",\"aNumber\":42}"); 142 | }}); 143 | 144 | add(new HashMap() {{ 145 | put("channel", "my-channel"); 146 | put("name", "event-name"); 147 | put("data", "{\"aString\":\"value2\",\"aNumber\":43}"); 148 | put("socket_id", "22.33"); 149 | }}); 150 | }}; 151 | 152 | context.checking(new Expectations() {{ 153 | oneOf(httpClient).execute( 154 | with(field("batch", res)) 155 | ); 156 | }}); 157 | 158 | List batch = new ArrayList<>(); 159 | batch.add(new Event("my-channel", "event-name", new MyPojo("value1", 42))); 160 | batch.add(new Event("my-channel", "event-name", new MyPojo("value2", 43), "22.33")); 161 | 162 | p.trigger(batch); 163 | } 164 | 165 | @Test 166 | public void mapShouldBeASuitableObjectForData() throws IOException { 167 | context.checking(new Expectations() {{ 168 | oneOf(httpClient).execute(with(field("data", "{\"name\":\"value\"}"))); 169 | }}); 170 | 171 | p.trigger("my-channel", "event", Collections.singletonMap("name", "value")); 172 | } 173 | 174 | @Test 175 | public void multiLayerMapShouldSerialiseFully() throws IOException { 176 | Map data = new HashMap(); 177 | data.put("k1", "v1"); 178 | Map level2 = new HashMap(); 179 | level2.put("k3", "v3"); 180 | List level3 = new ArrayList(); 181 | level3.add("v4"); 182 | level3.add("v5"); 183 | level2.put("k4", level3);; 184 | data.put("k2", level2); 185 | 186 | final String expectedData = "{\"k1\":\"v1\",\"k2\":{\"k3\":\"v3\",\"k4\":[\"v4\",\"v5\"]}}"; 187 | context.checking(new Expectations() {{ 188 | oneOf(httpClient).execute(with(field("data", expectedData))); 189 | }}); 190 | 191 | p.trigger("my-channel", "event", data); 192 | } 193 | 194 | @Test 195 | public void channelList() throws Exception { 196 | final List channels = Arrays.asList(new String[] { "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten" }); 197 | 198 | context.checking(new Expectations() {{ 199 | oneOf(httpClient).execute(with(field("channels", channels))); 200 | }}); 201 | 202 | p.trigger(channels, "event", Collections.singletonMap("name", "value")); 203 | } 204 | 205 | @Test 206 | public void channelListLimitOverLimit() { 207 | Assertions.assertThrows(IllegalArgumentException.class, () -> { 208 | 209 | final List channels = Arrays.asList(new String[101]); 210 | p.trigger(channels, "event", Collections.singletonMap("name", "value")); 211 | }); 212 | 213 | } 214 | 215 | @Test 216 | public void socketIdExclusion() throws Exception { 217 | final String socketId = "12345.6789"; 218 | context.checking(new Expectations() {{ 219 | oneOf(httpClient).execute(with(field("socket_id", socketId))); 220 | }}); 221 | 222 | p.trigger("channel", "event", Collections.singletonMap("name", "value"), socketId); 223 | } 224 | 225 | @Test 226 | public void genericGet() throws Exception { 227 | context.checking(new Expectations() {{ 228 | oneOf(httpClient).execute(with(path("/apps/" + APP_ID + "/channels"))); 229 | }}); 230 | 231 | p.get("/channels"); 232 | } 233 | 234 | @Test 235 | public void reservedParameter() { 236 | Assertions.assertThrows(IllegalArgumentException.class, () -> { 237 | p.get("/channels", Collections.singletonMap("auth_timestamp", "anything")); 238 | }); 239 | } 240 | 241 | @Test 242 | public void testTriggerOnEncryptedChannel() throws IOException { 243 | final Pusher pe = new Pusher(APP_ID, KEY, SECRET, VALID_MASTER_KEY); 244 | configureHttpClient(pe); 245 | 246 | CryptoUtil cryptoUtilMock = context.mock(CryptoUtil.class); 247 | pe.setCryptoUtil(cryptoUtilMock); 248 | 249 | final Map testData = Collections.singletonMap("n", "1"); 250 | 251 | final byte[] expectedMessage = "{\"n\":\"1\"}".getBytes(StandardCharsets.UTF_8); 252 | context.checking(new Expectations() {{ 253 | oneOf(cryptoUtilMock).encrypt(with(same("private-encrypted-test")), with(equal(expectedMessage))); 254 | will(returnValue(new EncryptedMessage("1", "2"))); 255 | }}); 256 | 257 | final String expectedData = "{\"nonce\":\"1\",\"ciphertext\":\"2\"}"; 258 | context.checking(new Expectations() {{ 259 | oneOf(httpClient).execute(with(field("data", expectedData))); 260 | }}); 261 | 262 | 263 | pe.trigger("private-encrypted-test", "test-event", testData); 264 | } 265 | 266 | @Test 267 | public void testTriggerBatchOnEncryptedChannel() throws IOException { 268 | final Pusher pe = new Pusher(APP_ID, KEY, SECRET, VALID_MASTER_KEY); 269 | configureHttpClient(pe); 270 | 271 | CryptoUtil cryptoUtilMock = context.mock(CryptoUtil.class); 272 | pe.setCryptoUtil(cryptoUtilMock); 273 | 274 | final byte[] expectedMessage = "{\"n\":\"1\"}".getBytes(StandardCharsets.UTF_8); 275 | context.checking(new Expectations() {{ 276 | oneOf(cryptoUtilMock).encrypt(with(same("private-encrypted-test")), with(equal(expectedMessage))); 277 | will(returnValue(new EncryptedMessage("e1", "e2"))); 278 | }}); 279 | 280 | final List> expectedData = new ArrayList>() {{ 281 | add(new HashMap() {{ 282 | put("channel", "private-encrypted-test"); 283 | put("name", "event-name"); 284 | put("data", "{\"nonce\":\"e1\",\"ciphertext\":\"e2\"}"); 285 | }}); 286 | 287 | add(new HashMap() {{ 288 | put("channel", "my-channel"); 289 | put("name", "event-name"); 290 | put("data", "{\"n\":\"2\"}"); 291 | }}); 292 | }}; 293 | 294 | context.checking(new Expectations() {{ 295 | oneOf(httpClient).execute(with(field("batch", expectedData))); 296 | }}); 297 | 298 | List testBatch = new ArrayList(); 299 | testBatch.add(new Event("private-encrypted-test", "event-name", Collections.singletonMap("n", "1"))); 300 | testBatch.add(new Event("my-channel", "event-name", Collections.singletonMap("n", "2"))); 301 | 302 | pe.trigger(testBatch); 303 | } 304 | 305 | @Test 306 | public void testInstantiatePusherWithInvalidMasterKey() { 307 | final Exception exception = Assertions.assertThrows(IllegalArgumentException.class, () -> { 308 | new Pusher(APP_ID, KEY, SECRET, INVALID_MASTER_KEY); 309 | }); 310 | 311 | assertThat( 312 | exception.getMessage(), 313 | containsString("encryptionMasterKeyBase64 must be a 32 byte key, base64 encoded") 314 | ); 315 | } 316 | 317 | @Test 318 | public void testInstantiatePusherWithEmptyMasterKey() { 319 | Assertions.assertThrows(IllegalArgumentException.class, () -> { 320 | new Pusher(APP_ID, KEY, SECRET, ""); 321 | }); 322 | 323 | Assertions.assertThrows(IllegalArgumentException.class, () -> { 324 | new Pusher(APP_ID, KEY, SECRET, null); 325 | }); 326 | } 327 | 328 | @Test 329 | public void testTriggerOnEncryptedChannelWithoutMasterKey() { 330 | final Exception exception = Assertions.assertThrows(RuntimeException.class, () -> { 331 | p.trigger("private-encrypted-test", "test-event", "testData"); 332 | }); 333 | 334 | assertThat( 335 | exception.getMessage(), 336 | is(PusherException.encryptionMasterKeyRequired().getMessage()) 337 | ); 338 | } 339 | 340 | @Test 341 | public void testTriggerBatchOnEncryptedChannelWithoutMasterKey() { 342 | List events = Arrays.asList( 343 | new Event("private-encrypted-test", "test_event", "test_data1"), 344 | new Event("private-encrypted-test", "test_event", "test_data2") 345 | ); 346 | 347 | final Exception exception = Assertions.assertThrows(RuntimeException.class, () -> { 348 | p.trigger(events); 349 | }); 350 | 351 | assertThat( 352 | exception.getMessage(), 353 | is(PusherException.encryptionMasterKeyRequired().getMessage()) 354 | ); 355 | } 356 | 357 | @Test 358 | public void testTriggerOnMultipleChannelsWithEncryptedChannelIsNotSupported() throws IOException { 359 | final Pusher pe = new Pusher(APP_ID, KEY, SECRET, VALID_MASTER_KEY); 360 | configureHttpClient(pe); 361 | 362 | final Exception exception = Assertions.assertThrows(RuntimeException.class, () -> { 363 | pe.trigger(Arrays.asList("private-encrypted-test", "another-channel"), "test_data", "test_data"); 364 | }); 365 | 366 | assertThat( 367 | exception.getMessage(), 368 | is(PusherException.cannotTriggerMultipleChannelsWithEncryption().getMessage()) 369 | ); 370 | } 371 | 372 | @Test 373 | public void testTriggerOnMultipleEncryptedChannelsIsNotSupported() throws IOException { 374 | final Pusher pe = new Pusher(APP_ID, KEY, SECRET, VALID_MASTER_KEY); 375 | configureHttpClient(pe); 376 | 377 | final Exception exception = Assertions.assertThrows(RuntimeException.class, () -> { 378 | pe.trigger(Arrays.asList("private-encrypted-test1", "private-encrypted-test2"), "test_data", "test_data"); 379 | }); 380 | 381 | assertThat( 382 | exception.getMessage(), 383 | is(PusherException.cannotTriggerMultipleChannelsWithEncryption().getMessage()) 384 | ); 385 | } 386 | } 387 | -------------------------------------------------------------------------------- /src/test/java/com/pusher/rest/PusherTestUrlConfig.java: -------------------------------------------------------------------------------- 1 | package com.pusher.rest; 2 | 3 | import static org.hamcrest.Matchers.is; 4 | import static org.hamcrest.MatcherAssert.assertThat; 5 | 6 | import java.lang.reflect.Field; 7 | 8 | import com.pusher.rest.util.PusherNoHttp; 9 | import org.junit.jupiter.api.Assertions; 10 | import org.junit.jupiter.api.Test; 11 | 12 | public class PusherTestUrlConfig { 13 | 14 | @Test 15 | public void testUrl() throws Exception { 16 | PusherNoHttp p = new PusherNoHttp("https://key:secret@api.example.com:4433/apps/00001"); 17 | 18 | assertField(p, "scheme", "https"); 19 | assertField(p, "key", "key"); 20 | assertField(p, "secret", "secret"); 21 | assertField(p, "host", "api.example.com:4433"); 22 | assertField(p, "appId", "00001"); 23 | } 24 | 25 | @Test 26 | public void testUrlNoPort() throws Exception { 27 | PusherNoHttp p = new PusherNoHttp("http://key:secret@api.example.com/apps/00001"); 28 | 29 | assertField(p, "scheme", "http"); 30 | assertField(p, "key", "key"); 31 | assertField(p, "secret", "secret"); 32 | assertField(p, "host", "api.example.com"); 33 | assertField(p, "appId", "00001"); 34 | } 35 | 36 | @Test 37 | public void testUrlMissingField() throws Exception { 38 | Assertions.assertThrows(IllegalArgumentException.class, () -> { 39 | new PusherNoHttp("https://key@api.example.com:4433/apps/appId"); 40 | }); 41 | } 42 | 43 | @Test 44 | public void testUrlEmptySecret() throws Exception { 45 | Assertions.assertThrows(IllegalArgumentException.class, () -> { 46 | new PusherNoHttp("https://key:@api.example.com:4433/apps/appId"); 47 | }); 48 | } 49 | 50 | @Test 51 | public void testUrlEmptyKey() throws Exception { 52 | Assertions.assertThrows(IllegalArgumentException.class, () -> { 53 | new PusherNoHttp("https://:secret@api.example.com:4433/apps/appId"); 54 | }); 55 | } 56 | 57 | @Test 58 | public void testUrlInvalidScheme() throws Exception { 59 | Assertions.assertThrows(IllegalArgumentException.class, () -> { 60 | new PusherNoHttp("telnet://key:secret@api.example.com:4433/apps/appId"); 61 | }); 62 | } 63 | 64 | private static , V> void assertField(final T o, final String fieldName, final V expected) throws Exception { 65 | final Field field = PusherAbstract.class.getDeclaredField(fieldName); 66 | field.setAccessible(true); 67 | final V actual = (V)field.get(o); 68 | 69 | assertThat(actual, is(expected)); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/test/java/com/pusher/rest/SignatureUtilTest.java: -------------------------------------------------------------------------------- 1 | package com.pusher.rest; 2 | 3 | import static org.hamcrest.Matchers.*; 4 | import static org.hamcrest.MatcherAssert.assertThat; 5 | 6 | import java.util.Arrays; 7 | import java.util.Collections; 8 | import java.util.LinkedHashMap; 9 | import java.util.Map; 10 | 11 | import org.junit.jupiter.api.Test; 12 | 13 | public class SignatureUtilTest { 14 | 15 | @Test 16 | public void stringToSignFields() { 17 | assertThat(SignatureUtil.buildSignatureString("POST", "/a/path", Collections.singletonMap("k", "v")), 18 | is("POST\n/a/path\nk=v")); 19 | 20 | assertThat(SignatureUtil.buildSignatureString("GET", "/a/nother/path", Collections.singletonMap("K", "V")), 21 | is("GET\n/a/nother/path\nK=V")); 22 | } 23 | 24 | @Test 25 | public void stringToSignQueryParamsOrderedByKey() { 26 | Map params = new LinkedHashMap(); 27 | params.put("a", "v1"); 28 | params.put("zat", "v4"); 29 | params.put("car", "v2"); 30 | params.put("cat", "v3"); 31 | 32 | // Make sure the iteration order is not incidentally alphabetical, or we're not testing what we intend to. 33 | String[] defaultOrder = params.keySet().toArray(new String[0]); 34 | String[] sortedOrder = params.keySet().toArray(new String[0]); 35 | Arrays.sort(sortedOrder); 36 | assertThat(defaultOrder, not(equalTo(sortedOrder))); 37 | 38 | String toSign = SignatureUtil.buildSignatureString("POST", "/", params); 39 | assertThat(toSign, containsString("a=v1&car=v2&cat=v3&zat=v4")); 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /src/test/java/com/pusher/rest/crypto/CryptoUtilTest.java: -------------------------------------------------------------------------------- 1 | package com.pusher.rest.crypto; 2 | 3 | import com.pusher.rest.data.EncryptedMessage; 4 | import org.junit.jupiter.api.Test; 5 | 6 | import java.nio.charset.StandardCharsets; 7 | import java.util.Base64; 8 | import java.util.HashSet; 9 | 10 | import static org.junit.jupiter.api.Assertions.*; 11 | 12 | public class CryptoUtilTest { 13 | 14 | final String encryptedChannel = "private-encrypted-test"; 15 | final String testMessage = "{\"message\": \"Hello, world!\"}"; 16 | final byte[] testData = testMessage.getBytes(StandardCharsets.UTF_8); 17 | final String base64EncodedEncryptionMasterKey = "VGhlIDMyIGNoYXJzIGxvbmcgZW5jcnlwdGlvbiBrZXk="; 18 | final CryptoUtil crypto = new CryptoUtil(base64EncodedEncryptionMasterKey); 19 | 20 | @Test 21 | void parseEncryptionMasterKeyWithInvalidKey() { 22 | assertThrows(IllegalArgumentException.class, () -> new CryptoUtil(null)); 23 | 24 | assertThrows(IllegalArgumentException.class, () -> new CryptoUtil("")); 25 | 26 | assertThrows(IllegalArgumentException.class, () -> new CryptoUtil("TG9yZW0gSXBzdW0ga")); 27 | } 28 | 29 | @Test 30 | void encrypt() { 31 | final EncryptedMessage encryptedMessage = crypto.encrypt(encryptedChannel, testData); 32 | 33 | assertNotNull(encryptedMessage.getNonce()); 34 | assertNotNull(encryptedMessage.getCiphertext()); 35 | 36 | assertNotEquals(testMessage, encryptedMessage.getCiphertext()); 37 | assertNotEquals(testData, Base64.getDecoder().decode(encryptedMessage.getCiphertext())); 38 | } 39 | 40 | @Test 41 | void encryptionRandomness() { 42 | final EncryptedMessage m1 = crypto.encrypt(encryptedChannel, testData); 43 | final EncryptedMessage m2 = crypto.encrypt(encryptedChannel, testData); 44 | 45 | assertNotEquals(m1.getNonce(), m2.getNonce()); 46 | assertNotEquals(m1.getCiphertext(), m2.getCiphertext()); 47 | } 48 | 49 | @Test 50 | void encryptionMoreRandomnessCheck() { 51 | final int expectedDistinctItems = 10000; 52 | 53 | HashSet uniqueValues = new HashSet<>(); 54 | for (int i = 1; i <= expectedDistinctItems; i++) { 55 | final EncryptedMessage m = crypto.encrypt(encryptedChannel, testData); 56 | 57 | uniqueValues.add(m.getNonce().concat(m.getCiphertext())); // Set only stores unique values 58 | } 59 | 60 | assertEquals( 61 | expectedDistinctItems, 62 | uniqueValues.size(), 63 | "Generated fewer than expected unique values" 64 | ); 65 | } 66 | 67 | @Test 68 | void decrypt() { 69 | final EncryptedMessage encryptedMessage = crypto.encrypt(encryptedChannel, testData); 70 | final String decryptedMessage = crypto.decrypt(encryptedChannel, encryptedMessage); 71 | 72 | assertEquals(testMessage, decryptedMessage); 73 | } 74 | 75 | @Test 76 | void generateBase64EncodedSharedSecret() { 77 | final String sharedKey = crypto.generateBase64EncodedSharedSecret(encryptedChannel); 78 | 79 | assertEquals("1O0FFr6NiG4d9D4A5bWBh3EG9Y/wfjzqw172LUXwVQ4", sharedKey); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/test/java/com/pusher/rest/crypto/nacl/SecretBoxTest.java: -------------------------------------------------------------------------------- 1 | package com.pusher.rest.crypto.nacl; 2 | 3 | import org.junit.jupiter.api.Test; 4 | 5 | import java.nio.charset.StandardCharsets; 6 | import java.util.Base64; 7 | import java.util.Map; 8 | 9 | import static org.junit.jupiter.api.Assertions.*; 10 | 11 | class SecretBoxTest { 12 | 13 | final byte[] message = "{\"message\": \"Hello, world!\"}".getBytes(StandardCharsets.UTF_8); 14 | final byte[] key = Base64.getDecoder().decode("VGhlIDMyIGNoYXJzIGxvbmcgZW5jcnlwdGlvbiBrZXk="); 15 | 16 | @Test 17 | void encryptMessage() { 18 | final Map encryptedMessage = SecretBox.box(key, message); 19 | 20 | assertNotNull(encryptedMessage.get("cipher")); 21 | assertNotNull(encryptedMessage.get("nonce")); 22 | 23 | assertNotEquals( 24 | message, 25 | encryptedMessage.get("cipher") 26 | ); 27 | 28 | assertArrayEquals(message, SecretBox.open( 29 | key, 30 | encryptedMessage.get("nonce"), 31 | encryptedMessage.get("cipher") 32 | )); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/test/java/com/pusher/rest/util/Matchers.java: -------------------------------------------------------------------------------- 1 | package com.pusher.rest.util; 2 | 3 | import java.io.ByteArrayOutputStream; 4 | import java.util.Map; 5 | 6 | import org.apache.http.client.methods.HttpPost; 7 | import org.apache.http.client.methods.HttpRequestBase; 8 | import org.hamcrest.Description; 9 | import org.hamcrest.Matcher; 10 | import org.hamcrest.TypeSafeDiagnosingMatcher; 11 | 12 | import com.google.gson.Gson; 13 | 14 | public class Matchers { 15 | 16 | public static Matcher field(final String fieldName, final T expected) { 17 | return new TypeSafeDiagnosingMatcher() { 18 | @Override 19 | public void describeTo(Description description) { 20 | description.appendText("HTTP request with field [" + fieldName + "], value [" + expected + "] in JSON body"); 21 | } 22 | 23 | @Override 24 | public boolean matchesSafely(HttpPost item, Description mismatchDescription) { 25 | try { 26 | @SuppressWarnings("unchecked") 27 | T actual = (T)new Gson().fromJson(retrieveBody(item), Map.class).get(fieldName); 28 | mismatchDescription.appendText("value was [" + actual + "]"); 29 | return expected.equals(actual); 30 | } 31 | catch (Exception e) { 32 | return false; 33 | } 34 | } 35 | }; 36 | } 37 | public static Matcher path(final String expected) { 38 | return new TypeSafeDiagnosingMatcher() { 39 | @Override 40 | public void describeTo(Description description) { 41 | description.appendText("HTTP request with path [" + expected + "]"); 42 | } 43 | 44 | @Override 45 | public boolean matchesSafely(HttpRequestBase item, Description mismatchDescription) { 46 | try { 47 | String actual = item.getURI().getPath(); 48 | mismatchDescription.appendText("value was [" + actual + "]"); 49 | return expected.equals(actual); 50 | } 51 | catch (Exception e) { 52 | return false; 53 | } 54 | } 55 | }; 56 | } 57 | 58 | public static Matcher stringBody(final String expected) { 59 | return new TypeSafeDiagnosingMatcher() { 60 | @Override 61 | public void describeTo(Description description) { 62 | description.appendText("HTTP request with body [" + expected + "]"); 63 | } 64 | 65 | @Override 66 | public boolean matchesSafely(HttpPost item, Description mismatchDescription) { 67 | try { 68 | String actual = retrieveBody(item); 69 | mismatchDescription.appendText("Expected body [" + expected + "], but received [" + actual + "]"); 70 | return expected.equals(actual); 71 | } 72 | catch (Exception e) { 73 | mismatchDescription.appendText("Encountered exception [" + e + "] attempting match"); 74 | return false; 75 | } 76 | } 77 | }; 78 | } 79 | 80 | 81 | private static String retrieveBody(HttpPost e) throws Exception { 82 | ByteArrayOutputStream baos = new ByteArrayOutputStream(); 83 | e.getEntity().writeTo(baos); 84 | return new String(baos.toByteArray(), "UTF-8"); 85 | } 86 | 87 | } 88 | -------------------------------------------------------------------------------- /src/test/java/com/pusher/rest/util/PusherNoHttp.java: -------------------------------------------------------------------------------- 1 | package com.pusher.rest.util; 2 | 3 | import com.pusher.rest.PusherAbstract; 4 | 5 | import java.net.URI; 6 | 7 | public class PusherNoHttp extends PusherAbstract { 8 | 9 | public PusherNoHttp(final String appId, final String key, final String secret) { 10 | super(appId, key, secret); 11 | } 12 | 13 | public PusherNoHttp(final String url) { 14 | super(url); 15 | } 16 | 17 | @Override 18 | protected Object doGet(final URI uri) { 19 | throw new IllegalStateException("Shouldn't have been called, HTTP level not implemented"); 20 | } 21 | 22 | @Override 23 | protected Object doPost(final URI uri, final String body) { 24 | throw new IllegalStateException("Shouldn't have been called, HTTP level not implemented"); 25 | } 26 | 27 | } 28 | --------------------------------------------------------------------------------