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&lt;String, Object&gt;. 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}