001package com.pusher.rest; 002 003import com.google.gson.FieldNamingPolicy; 004import com.google.gson.Gson; 005import com.google.gson.GsonBuilder; 006import com.pusher.rest.data.AuthData; 007import com.pusher.rest.data.Event; 008import com.pusher.rest.data.EventBatch; 009import com.pusher.rest.data.PresenceUser; 010import com.pusher.rest.data.Result; 011import com.pusher.rest.data.TriggerData; 012import com.pusher.rest.data.Validity; 013import com.pusher.rest.marshaller.DataMarshaller; 014import com.pusher.rest.marshaller.DefaultDataMarshaller; 015import com.pusher.rest.util.Prerequisites; 016 017import java.net.URI; 018import java.util.ArrayList; 019import java.util.Collections; 020import java.util.List; 021import java.util.Map; 022import java.util.regex.Matcher; 023import java.util.regex.Pattern; 024 025/** 026 * Parent class for Pusher clients, deals with anything that isn't IO related. 027 * 028 * @param <T> The return type of the IO calls. 029 * 030 * See {@link Pusher} for the synchronous implementation, {@link PusherAsync} for the asynchronous implementation. 031 */ 032public abstract class PusherAbstract<T> { 033 protected static final Gson BODY_SERIALISER = new GsonBuilder() 034 .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) 035 .create(); 036 037 private static final Pattern HEROKU_URL = Pattern.compile("(https?)://(.+):(.+)@(.+:?.*)/apps/(.+)"); 038 039 protected final String appId; 040 protected final String key; 041 protected final String secret; 042 043 protected String host = "api.pusherapp.com"; 044 protected String scheme = "http"; 045 046 private DataMarshaller dataMarshaller; 047 048 /** 049 * Construct an instance of the Pusher object through which you may interact with the Pusher API. 050 * <p> 051 * The parameters to use are found on your dashboard at https://app.pusher.com and are specific per App. 052 * <p> 053 * 054 * @param appId The ID of the App you will to interact with. 055 * @param key The App Key, the same key you give to websocket clients to identify your app when they connect to Pusher. 056 * @param secret The App Secret. Used to sign requests to the API, this should be treated as sensitive and not distributed. 057 */ 058 public PusherAbstract(final String appId, final String key, final String secret) { 059 Prerequisites.nonEmpty("appId", appId); 060 Prerequisites.nonEmpty("key", key); 061 Prerequisites.nonEmpty("secret", secret); 062 Prerequisites.isValidSha256Key("secret", secret); 063 064 this.appId = appId; 065 this.key = key; 066 this.secret = secret; 067 068 configureDataMarshaller(); 069 } 070 071 public PusherAbstract(final String url) { 072 Prerequisites.nonNull("url", url); 073 074 final Matcher m = HEROKU_URL.matcher(url); 075 if (m.matches()) { 076 this.scheme = m.group(1); 077 this.key = m.group(2); 078 this.secret = m.group(3); 079 this.host = m.group(4); 080 this.appId = m.group(5); 081 } else { 082 throw new IllegalArgumentException("URL '" + url + "' does not match pattern '<scheme>://<key>:<secret>@<host>[:<port>]/apps/<appId>'"); 083 } 084 085 Prerequisites.isValidSha256Key("secret", secret); 086 configureDataMarshaller(); 087 } 088 089 private void configureDataMarshaller() { 090 this.dataMarshaller = new DefaultDataMarshaller(); 091 } 092 093 /* 094 * CONFIG 095 */ 096 097 /** 098 * For testing or specifying an alternative cluster. See also {@link #setCluster(String)} for the latter. 099 * <p> 100 * Default: api.pusherapp.com 101 * 102 * @param host the API endpoint host 103 */ 104 public void setHost(final String host) { 105 Prerequisites.nonNull("host", host); 106 107 this.host = host; 108 } 109 110 /** 111 * For Specifying an alternative cluster. 112 * <p> 113 * See also {@link #setHost(String)} for targetting an arbitrary endpoint. 114 * 115 * @param cluster the Pusher cluster to target 116 */ 117 public void setCluster(final String cluster) { 118 Prerequisites.nonNull("cluster", cluster); 119 120 this.host = "api-" + cluster + ".pusher.com"; 121 } 122 123 /** 124 * Set whether to use a secure connection to the API (SSL). 125 * <p> 126 * Authentication is secure even without this option, requests cannot be faked or replayed with access 127 * to their plain text, a secure connection is only required if the requests or responses contain 128 * sensitive information. 129 * <p> 130 * Default: false 131 * 132 * @param encrypted whether to use SSL to contact the API 133 */ 134 public void setEncrypted(final boolean encrypted) { 135 this.scheme = encrypted ? "https" : "http"; 136 } 137 138 /** 139 * Set the Gson instance used to marshal Objects passed to {@link #trigger(List, String, Object)} 140 * Set the marshaller used to serialize Objects passed to {@link #trigger(List, String, Object)} 141 * and friends. 142 * By default, the library marshals the objects provided to JSON using the Gson library 143 * (see https://code.google.com/p/google-gson/ for more details). By providing an instance 144 * here, you may exert control over the marshalling, for example choosing how Java property 145 * names are mapped on to the field names in the JSON representation, allowing you to match 146 * the expected scheme on the client side. 147 * We added the {@link #setDataMarshaller(DataMarshaller)} method to allow specification 148 * of other marshalling libraries. This method was kept around to maintain backwards 149 * compatibility. 150 * @param gson a GSON instance configured to your liking 151 */ 152 public void setGsonSerialiser(final Gson gson) { 153 setDataMarshaller(new DefaultDataMarshaller(gson)); 154 } 155 156 /** 157 * Set a custom marshaller used to serialize Objects passed to {@link #trigger(List, String, Object)} 158 * and friends. 159 * <p> 160 * By default, the library marshals the objects provided to JSON using the Gson library 161 * (see https://code.google.com/p/google-gson/ for more details). By providing an instance 162 * here, you may exert control over the marshalling, for example choosing how Java property 163 * names are mapped on to the field names in the JSON representation, allowing you to match 164 * the expected scheme on the client side. 165 * 166 * @param marshaller a DataMarshaller instance configured to your liking 167 */ 168 public void setDataMarshaller(final DataMarshaller marshaller) { 169 this.dataMarshaller = marshaller; 170 } 171 172 /** 173 * This method provides an override point if the default Gson based serialisation is absolutely 174 * unsuitable for your use case, even with customisation of the Gson instance doing the serialisation. 175 * <p> 176 * For example, in the simplest case, you might already have your data pre-serialised and simply want 177 * to elide the default serialisation: 178 * <pre> 179 * Pusher pusher = new Pusher(appId, key, secret) { 180 * protected String serialise(final Object data) { 181 * return (String)data; 182 * } 183 * }; 184 * 185 * pusher.trigger("my-channel", "my-event", "{\"my-data\":\"my-value\"}"); 186 * </pre> 187 * 188 * @param data an unserialised event payload 189 * @return a serialised event payload 190 */ 191 protected String serialise(final Object data) { 192 return dataMarshaller.marshal(data); 193 } 194 195 /* 196 * REST 197 */ 198 199 /** 200 * Publish a message to a single channel. 201 * <p> 202 * The message data should be a POJO, which will be serialised to JSON for submission. 203 * Use {@link #setDataMarshaller(DataMarshaller)} to control the serialisation 204 * <p> 205 * Note that if you do not wish to create classes specifically for the purpose of specifying 206 * the message payload, use Map<String, Object>. These maps will nest just fine. 207 * 208 * @param channel the channel name on which to trigger the event 209 * @param eventName the name given to the event 210 * @param data an object which will be serialised to create the event body 211 * @return a {@link Result} object encapsulating the success state and response to the request 212 */ 213 public T trigger(final String channel, final String eventName, final Object data) { 214 return trigger(channel, eventName, data, null); 215 } 216 217 /** 218 * Publish identical messages to multiple channels. 219 * 220 * @param channels the channel names on which to trigger the event 221 * @param eventName the name given to the event 222 * @param data an object which will be serialised to create the event body 223 * @return a {@link Result} object encapsulating the success state and response to the request 224 */ 225 public T trigger(final List<String> channels, final String eventName, final Object data) { 226 return trigger(channels, eventName, data, null); 227 } 228 229 /** 230 * Publish a message to a single channel, excluding the specified socketId from receiving the message. 231 * 232 * @param channel the channel name on which to trigger the event 233 * @param eventName the name given to the event 234 * @param data an object which will be serialised to create the event body 235 * @param socketId a socket id which should be excluded from receiving the event 236 * @return a {@link Result} object encapsulating the success state and response to the request 237 */ 238 public T trigger(final String channel, final String eventName, final Object data, final String socketId) { 239 return trigger(Collections.singletonList(channel), eventName, data, socketId); 240 } 241 242 /** 243 * Publish identical messages to multiple channels, excluding the specified socketId from receiving the message. 244 * 245 * @param channels the channel names on which to trigger the event 246 * @param eventName the name given to the event 247 * @param data an object which will be serialised to create the event body 248 * @param socketId a socket id which should be excluded from receiving the event 249 * @return a {@link Result} object encapsulating the success state and response to the request 250 */ 251 public T trigger(final List<String> channels, final String eventName, final Object data, final String socketId) { 252 Prerequisites.nonNull("channels", channels); 253 Prerequisites.nonNull("eventName", eventName); 254 Prerequisites.nonNull("data", data); 255 Prerequisites.maxLength("channels", 100, channels); 256 Prerequisites.noNullMembers("channels", channels); 257 Prerequisites.areValidChannels(channels); 258 Prerequisites.isValidSocketId(socketId); 259 260 final String body = BODY_SERIALISER.toJson(new TriggerData(channels, eventName, serialise(data), socketId)); 261 262 return post("/events", body); 263 } 264 265 /** 266 * Publish a batch of different events with a single API call. 267 * <p> 268 * The batch is limited to 10 events on our multi-tenant clusters. 269 * 270 * @param batch a list of events to publish 271 * @return a {@link Result} object encapsulating the success state and response to the request 272 */ 273 public T trigger(final List<Event> batch) { 274 final List<Event> eventsWithSerialisedBodies = new ArrayList<Event>(batch.size()); 275 for (final Event e : batch) { 276 eventsWithSerialisedBodies.add( 277 new Event( 278 e.getChannel(), 279 e.getName(), 280 serialise(e.getData()), 281 e.getSocketId() 282 ) 283 ); 284 } 285 final String body = BODY_SERIALISER.toJson(new EventBatch(eventsWithSerialisedBodies)); 286 287 return post("/batch_events", body); 288 } 289 290 /** 291 * Make a generic HTTP call to the Pusher API. 292 * <p> 293 * See: http://pusher.com/docs/rest_api 294 * <p> 295 * NOTE: the path specified here is relative to that of your app. For example, to access 296 * the channel list for your app, simply pass "/channels". Do not include the "/apps/[appId]" 297 * at the beginning of the path. 298 * 299 * @param path the path (e.g. /channels) to query 300 * @return a {@link Result} object encapsulating the success state and response to the request 301 */ 302 public T get(final String path) { 303 return get(path, Collections.<String, String>emptyMap()); 304 } 305 306 /** 307 * Make a generic HTTP call to the Pusher API. 308 * <p> 309 * See: http://pusher.com/docs/rest_api 310 * <p> 311 * Parameters should be a map of query parameters for the HTTP call, and may be null 312 * if none are required. 313 * <p> 314 * NOTE: the path specified here is relative to that of your app. For example, to access 315 * the channel list for your app, simply pass "/channels". Do not include the "/apps/[appId]" 316 * at the beginning of the path. 317 * 318 * @param path the path (e.g. /channels) to query 319 * @param parameters query parameters to submit with the request 320 * @return a {@link Result} object encapsulating the success state and response to the request 321 */ 322 public T get(final String path, final Map<String, String> parameters) { 323 final String fullPath = "/apps/" + appId + path; 324 final URI uri = SignatureUtil.uri("GET", scheme, host, fullPath, null, key, secret, parameters); 325 326 return doGet(uri); 327 } 328 329 protected abstract T doGet(final URI uri); 330 331 /** 332 * Make a generic HTTP call to the Pusher API. 333 * <p> 334 * The body should be a UTF-8 encoded String 335 * <p> 336 * See: http://pusher.com/docs/rest_api 337 * <p> 338 * NOTE: the path specified here is relative to that of your app. For example, to access 339 * the channel list for your app, simply pass "/channels". Do not include the "/apps/[appId]" 340 * at the beginning of the path. 341 * 342 * @param path the path (e.g. /channels) to submit 343 * @param body the body to submit 344 * @return a {@link Result} object encapsulating the success state and response to the request 345 */ 346 public T post(final String path, final String body) { 347 final String fullPath = "/apps/" + appId + path; 348 final URI uri = SignatureUtil.uri("POST", scheme, host, fullPath, body, key, secret, Collections.<String, String>emptyMap()); 349 350 return doPost(uri, body); 351 } 352 353 protected abstract T doPost(final URI uri, final String body); 354 355 /** 356 * If you wanted to send the HTTP API requests manually (e.g. using a different HTTP client), this method 357 * will return a java.net.URI which includes all of the appropriate query parameters which sign the request. 358 * 359 * @param method the HTTP method, e.g. GET, POST 360 * @param path the HTTP path, e.g. /channels 361 * @param body the HTTP request body, if there is one (otherwise pass null) 362 * @return a URI object which includes the necessary query params for request authentication 363 */ 364 public URI signedUri(final String method, final String path, final String body) { 365 return signedUri(method, path, body, Collections.<String, String>emptyMap()); 366 } 367 368 /** 369 * If you wanted to send the HTTP API requests manually (e.g. using a different HTTP client), this method 370 * will return a java.net.URI which includes all of the appropriate query parameters which sign the request. 371 * <p> 372 * Note that any further query parameters you wish to be add must be specified here, as they form part of the signature. 373 * 374 * @param method the HTTP method, e.g. GET, POST 375 * @param path the HTTP path, e.g. /channels 376 * @param body the HTTP request body, if there is one (otherwise pass null) 377 * @param parameters HTTP query parameters to be included in the request 378 * @return a URI object which includes the necessary query params for request authentication 379 */ 380 public URI signedUri(final String method, final String path, final String body, final Map<String, String> parameters) { 381 return SignatureUtil.uri(method, scheme, host, path, body, key, secret, parameters); 382 } 383 384 /* 385 * CHANNEL AUTHENTICATION 386 */ 387 388 /** 389 * Generate authentication response to authorise a user on a private channel 390 * <p> 391 * The return value is the complete body which should be returned to a client requesting authorisation. 392 * 393 * @param socketId the socket id of the connection to authenticate 394 * @param channel the name of the channel which the socket id should be authorised to join 395 * @return an authentication string, suitable for return to the requesting client 396 */ 397 public String authenticate(final String socketId, final String channel) { 398 Prerequisites.nonNull("socketId", socketId); 399 Prerequisites.nonNull("channel", channel); 400 Prerequisites.isValidChannel(channel); 401 Prerequisites.isValidSocketId(socketId); 402 403 if (channel.startsWith("presence-")) { 404 throw new IllegalArgumentException("This method is for private channels, use authenticate(String, String, PresenceUser) to authenticate for a presence channel."); 405 } 406 if (!channel.startsWith("private-")) { 407 throw new IllegalArgumentException("Authentication is only applicable to private and presence channels"); 408 } 409 410 final String signature = SignatureUtil.sign(socketId + ":" + channel, secret); 411 return BODY_SERIALISER.toJson(new AuthData(key, signature)); 412 } 413 414 /** 415 * Generate authentication response to authorise a user on a presence channel 416 * <p> 417 * The return value is the complete body which should be returned to a client requesting authorisation. 418 * 419 * @param socketId the socket id of the connection to authenticate 420 * @param channel the name of the channel which the socket id should be authorised to join 421 * @param user a {@link PresenceUser} object which represents the channel data to be associated with the user 422 * @return an authentication string, suitable for return to the requesting client 423 */ 424 public String authenticate(final String socketId, final String channel, final PresenceUser user) { 425 Prerequisites.nonNull("socketId", socketId); 426 Prerequisites.nonNull("channel", channel); 427 Prerequisites.nonNull("user", user); 428 Prerequisites.isValidChannel(channel); 429 Prerequisites.isValidSocketId(socketId); 430 431 if (channel.startsWith("private-")) { 432 throw new IllegalArgumentException("This method is for presence channels, use authenticate(String, String) to authenticate for a private channel."); 433 } 434 if (!channel.startsWith("presence-")) { 435 throw new IllegalArgumentException("Authentication is only applicable to private and presence channels"); 436 } 437 438 final String channelData = BODY_SERIALISER.toJson(user); 439 final String signature = SignatureUtil.sign(socketId + ":" + channel + ":" + channelData, secret); 440 return BODY_SERIALISER.toJson(new AuthData(key, signature, channelData)); 441 } 442 443 /* 444 * WEBHOOK VALIDATION 445 */ 446 447 /** 448 * Check the signature on a webhook received from Pusher 449 * 450 * @param xPusherKeyHeader the X-Pusher-Key header as received in the webhook request 451 * @param xPusherSignatureHeader the X-Pusher-Signature header as received in the webhook request 452 * @param body the webhook body 453 * @return enum representing the possible validities of the webhook request 454 */ 455 public Validity validateWebhookSignature(final String xPusherKeyHeader, final String xPusherSignatureHeader, final String body) { 456 if (!xPusherKeyHeader.trim().equals(key)) { 457 // We can't validate the signature, because it was signed with a different key to the one we were initialised with. 458 return Validity.SIGNED_WITH_WRONG_KEY; 459 } 460 461 final String recalculatedSignature = SignatureUtil.sign(body, secret); 462 return xPusherSignatureHeader.trim().equals(recalculatedSignature) ? Validity.VALID : Validity.INVALID; 463 } 464 465}