diff --git a/.gitignore b/.gitignore index 5a36a45c..ca37175a 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,5 @@ logs nohup.out out target -spring-integration-aws/src/test/resources/awscredentials.properties \ No newline at end of file +spring-integration-aws/src/test/resources/awscredentials.properties +spring-integration-pushnotify/src/test/resources/services.properties \ No newline at end of file diff --git a/spring-integration-pushnotify/README.md b/spring-integration-pushnotify/README.md new file mode 100644 index 00000000..39e2d950 --- /dev/null +++ b/spring-integration-pushnotify/README.md @@ -0,0 +1,354 @@ +Spring Integration Extension for Push Notifications +==================================================== + +Introduction +------------ +The primary intent of this extension is to provide support in spring integration to enable java applications send push notifications to mobile/handheld devices. + +We will support push notifications to Android and iOS devices as part of this extension. The initial phase adds support for Android +devices with the next phase providing support for iOS devices. + +To enable us to send push notifications to these devices we use the services provided by Google and Apple for Android and iOS devices respectively. Visit the below links for more information on these services + + - [GCM] Google Cloud Messaging + - [APNS] Apple Push Notification Service + + + +A Bit about Push notification +------------------------------- +We want to know when an interesting event has happened on a remote location, say a server application. +There are two ways to achieve this + +- Pull mechanism +- Push mechanism + +In the Pull approach the handheld device continuously polls to check if the event of its interest has happened. +This has two disadvantages + +1. It will unnecessarily send requests to the server to check for updates increasing the network usage and traffic. It could also overwhelm the server when many handheld/mobile devices continuously send requests to it. +2. This polling means that handheld device cannot be put to sleep and the application needs to run periodically reducing the battery life of the device which is crucial. + +An alternative approach to this problem is the push notification, which is similar to the Observer Design Pattern which notifies the listener when an event of it's registered interest has occurred. This addresses both the issues we discussed with the pull approach, +but it now introduces a service in between which interacts with the handheld devices for pushing the notifications to them and receiving the notifications to be pushed from the java application. +The server application never interacts with the handheld device +directly as far as the push is concerned (*the handheld device however might for some activity as we shall see in subsequent sections*) + + +Push to Android Devices +------------------------- + +Android is a Linux based operating system primarily for touch screen devices. +We won't delve into the details of Android OS here as it is out of our scope. +We however do recommend you know the basics concepts of Android Operating system and application development in Android as that would come handy when you run the sample application using the emulator from Android SDK. We shall give steps to setup and get the application running though. +The [Android development guide] is the ultimate reference but can be a bit time consuming to go though it if you just need to get a feel of the Android program and run it. +[Lars Vogel's] tutorial is good enough to start off with. + +To start using Google Cloud Messaging (GCM), you need to enable the service in your Google APIs. Look at the [Getting Started] section of the GCM to know how to create your Google API account and enable the GCM service. +Once you are done enabling the GCMservice we are good to go ahead +with the architecture after we introduce some terminologies here + + - ***Sender Id*** + This is the project id of your Google API Account, this will be unique across and no other user will receive + this project id. + + - ***Application Server*** + The server side application which is interested in notifying the handheld devices of an event that has occurred. + + - ***Mobile/Handheld Device*** + The handheld Android based device that would be receiving the push notifications about the events those have happened on the Application server. + + - ***GCM Servers/Service*** + The servers managed by Google that would taking the message from the application server and be pushing to the mobile device. + + - ***Sender's Token*** + This is the token that was generated when you saw the *Getting Started* section above followed it completely to setup your account. + This id will be sent from the application server using the spring integration extension to the GCM servers with the push request. + + - ***Registration ID*** + This is the unique registration id of the Android device with the GCM Servers. + This id is then passed on by the device to the application server which is then sent by the server in its request for pushing messages to the devices to the GCM. + As far as the push mechanism is concerned, the only time the device directly interacts with the server directly is when it wants to give the registration id. + + + +Now we get on with the typical architecture and flow of the push messaging. + +The below diagram shows the high level interaction of the components +involved in the push messaging. + +![gcm flow diagram](images/GcmFlow.png "Google Cloud Messaging (GCM) Flow") + +We will elaborate the steps below and also mention whose scope the step +lies in + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
StepStep DescriptionScope
1 + Send a request to GCM services to register the device, the registration request includes the Sender Id (see above for what a Sender Id is) + + Mobile Device/Application +
2 + Receive the request from the mobile device, register it and return the Registration ID to mobile device. This id uniquely identifies the device on the GCM servers. + It is important to note that the GCM Service periodically changes the Registration Ids of the devices and as a result it will push the Registration Ids to the Mobile device even if it has not requested a registration. + + GCM Service +
3 + Send the Registration ID to the Application server. This can be done using a simple HTTP call or opening a socket connection and sending the raw bytes using a predetermined protocol between the server and the mobile application. + Please note that since the GCM Service can send the new Registration Id to the Mobile device asynchronously, the Mobile application needs to send the new Registration id to the Application Server. + + Mobile Device/Application +
4 + Receive the Registration Id sent from the mobile application and store it in an application specific data store, typically a database. + + Application Server +
5 + Send the push notification request to the GCM servers, + this request includes the Registration Ids of the devices to which the data is to be pushed, the text string that represents the data to be pushed and other configuration attributes defined by GCM service. We will discuss these attributes and their significance later. + + Application Server +
6 + Parse the request from the Application Server and push/schedule to push to the Mobile Device/Application. + + GCM Service +
7 + Acknowledge the request with a response containing some predetermined attributes and their values. + These attributes and the possible values will be explained in later sections. + + GCM Service +
8 + Read the response from the GCM servers and take appropriate actions. Store the relevant attributes in the response in the data store discarding the rest. + + Application Server +
9 + Receive the push from the GCM Servers and execute the logic in the application for an action to be taken. + + Mobile Device/Application +
+ +In the above sequence of actions, step 1 to 4 happens only when the devices receives a registration id. All other steps 5 - 9 happens everytime the Application Server sends a push notification request. + +Where Does Spring Integration fit in the above steps? +------------------------------------------------------ + +Spring Integration will be used in all the steps where the scope is ***Application Server***. + + +This particular module for push notification along with other Spring Integration adapters together can be used to implement the the Application Server functionality. + + +For example, to implement the step 4 we can use the HTTP or the TCP/UDP inbound adapter/gateway support of spring integration to receive the Registration Id from the Mobile device. Upon receiving the ID we can use the JDBC or JPA support(Spring Integration version 2.2 and above) to store the data in a relational database. Adapters for other NoSQL solutions like Mongo DB and Redis is also available starting Spring Integration 2.2. These too can be used to store the Registration ids of the devices. + +For step 5 we will be using the push notification gateway to GCM Services. The details about this adapter, the important classes, the request message format, various attributes in the request and response will be explained in later sections in details. + +For step 8, the push notification gateway for GCM will return an object containing various response parameters from the GCM Service. The possible values and their meaning will be explained in later section. The application is now responsible to read the response values and store the relevant ones in the datastore using any of the adapters we have used in step 4 to interact with the underlying datastore. + +Visit the [Spring Integration Documentation] for more details on these adapters +(Note: This is currently 2.1.x, will have 2.2.x and above here once 2.2 goes GA) + +For sake of simplicity, we haven't implemented all the steps in our sample application. +Since our focus is on sending and receiving the push notifications, we will be copying the Registration Id of the device to which we will be sending the push notification manually in the sample application that would be sending the push notification (rather than letting the mobile application sending it to the server over TCP/UDP or HTTP request). + + +Having said that, let us look at the important classes and interfaces of the library + +Important classes and interfaces of the push notification library +------------------------------------------------------------------ + +For all the class names, *o.s.i.p* is used as the shortened version of *org.springframework.integration.pushnotify* + +1. *o.s.i.p.PushResponse* Implemented by the classes that contain the response of the push. It just defines one method, *isMulticast* to indicate if the push was for multiple devices or a single device. +2. *o.s.i.p.PushNotifyService* +This is the core interface that defines one method *push* that returns an instance of *o.s.i.p.PushResponse*. The *push* method accepts three parameters + +- A Map with String key and values for the data that needs to be pushed. The data is always sent in key value pair. The accepted type of the value is dependent on the particular service implementation. +- A Map of attributes as String key value pair, these are the attributes that are specific to the implementation which may affect the behavior of the push messaging. +- A var args parameter, that contains the String values which are the identifiers of the mobile device to which the push is to be sent. +This value should be recognized by the service provider of push messaging. +3. *o.s.i.p.AbstractPushNotifyService* This is the generic service that is to be extended by all the specific implementations of the push messaging. It implements *o.s.i.p.PushNotifyService*. +This class defines one abstract method *doPush* which is expected to be implemented by the specific implementation. If this method returns *null*, it is assumed that the message sending has failed and needs to be retried. If retries are enabled, [exponential backoff] approach is used for retrying upto the configured maximum time for retry. +4. *o.s.i.p.gcm.GCMPushNotifyService* +This is the core interface that extends *org.springframework.integration.pushnotify.PushNotifyService* interface. +This interface does not define any additional methods but only some constants which are relevant to GCM push messaging. +5. *o.s.i.p.gcm.GCMPushResponse* It extends from *o.s.i.p.PushResponse* and provides the caller who invoked the push with information returned from the GCM servers. It defines the below methods. +- *getResponseCode()* The HTTP response code for the push request. Possible response code are 200 (successful), 400 (bad request, containing invalid fields etc), 401 (authentication error), 500(internal error) and 503 (temporarily unavailable). +- *getErrorCodes()* The error codes if the response is other than 200 or 503. The value is a Map with the receiver id as a String and the value is the error code returned by GCM. +- *getSentMessageIds()* Identifiers from the server acknowledging the receipt of the message sent. +The Map contains the key which is the Receiver Id provided and the value is the message id received from the GCM server. +- *getCanonicalIds()* The Map returning the canonical ids, if any. +This id indicates that the device to which the message was sent has a new Registration Id with the server other than the one sent with the request. +The sender should replace the Registration Id of the receiver with this canonical id and use it for any subsequent requests sent to this device. +The key is the original Registration Id sent in the request and the value is the Canonical Id, that is the new Registration Id of the device. The application should check this response and replace the Registration Id it has with the canonical id received in its data store. As GCM will eventually discard the old Registration Id, all requests containing the old id will start failing. +- *getSuccessfulMessages()* The number of successful pushes from the provided number of Registration Ids +- *getFailedMessages()* Gets the number of messages which failed to execute a push to the remote device. +- *getNumberOfCanonicalIds()* Gets the number of canonical ids present in the response. +This will be same as the size of the map returned by *getCanonicalIds()*. +- *getMulticastId()* Gets an id representing the multicast id, if the request was to push to multiple Registration Ids. + +6. *o.s.i.p.gcm.GCMPushNotifyServiceImpl* The implementation class for the GCM Push messaging. +It extends from *o.s.i.p.AbstractPushNotifyService* and implements *o.s.i.p.gcm.GCMPushNotifyService* interface. The result of push is an instance of *o.s.i.p.gcm.GCMPushResponse*. +7. *o.s.i.p.gcm.GCMOutboundGateway* The gateway implementation of the GCM. This classes uses *o.s.i.p.gcm.GCMPushNotifyService* to send push notifications. +The response of this gateway is an instance of type *o.s.i.p.gcm.GCMPushResponse*. There are some attributes of this class which we will discuss when we introduce the request attributes to GCM service. + + +See [GCM Response] for more details about the response received from the GCM service. + +Handling the request message +----------------------------- +Now that we have seen the important classes, their purpose and their methods, let us now look at the supported request attributes for GCM and see how the payload of the incoming message handled by the *o.s.i.p.gcm.GCMOutboundGateway* class. + +Below table gives us the attributes supported by GCM. + + + + + + + + + + + + + + + + + + + + +
+   + + Attribute + + Description +
1collapse_key + It is a String value that is used to group a number of messages together. + It plays a part when the device is offline and the messages are pending to be delivered on the GCM server. Setting the collapse key collapses any message with the same collapse + key on the server and ensures only the last message gets delivered to the mobile device when it comes online. +
2delay_while_idleIndicates that the message should not be delivered when the device is idle and should be delayed till it becomes active. If a collapse key is mentioned, the rule mentioned above will be applied while delivery when the device becomes active.
3time_to_liveIndicates how long in seconds the message is to be kept on GCM servers if the device is offline or non reachable before it is discarded.
+ +For the above three attributes, the class *o.s.i.p.gcm.GCMOutboundGateway* has three setters, each of type *o.s.expression.Expression*. +If set, they are evaluated with the root object as the incoming message to compute the value of the above three attributes. +Though the attribute type is String, the value of time to live expression be parsable to a Long. The three setters to set these expression are +*setCollapseKeyExpression*, *setDelayWhileIdleExpression* and *setTimeToLiveExpression*. + +For the Receiver Ids, there is an expression that can be set in the gateway class using the *setReceiverIdsExpression* expression. This expression is evaluated with root object as the incoming message and the result type can either be a String, in which case it is assumed to be the only receiver id, a String[] representing multiple receiver ids or a Collection of String which again represents multiple receiver ids. + +Now for the payload. GCM accepts payload of type Map with key and value both as String types. +Hence the payload of the incoming message to gateway can be of three types. + +- Map, in this case the key has to be String and the value has to be String as well. +If the value is not a String, its toString representation is used and sent in the request. +- String, this is straight forward and the payload is sent as the value in the map. The key defaults to value *Data* and can be overridden by invoking the *setDefaultKey* method of the gateway and passing the required default key value. +- Any object, in this case the conversion service is used to convert it to a String if possible. +If it can be, then the converted String value is sent as the value with the *defaultKey* as the key as above. + +If none of the above three is possible, then a *o.s.i.MessagingException* is thrown. + +Namespace support for push messaging +------------------------------------- +Spring Integration's Push Messaging does provide namespace support similar to other Spring Integration components to abstract the underlying bean instantiation and wiring from the developer. +The developer's work is a simple as defining the xml tags using the namespace support with some predetermined attributes and all the necesary bean instantiation and wiring is done transparantly when the application context comes up. + +Below is the sample xml definition for setting up a GCM outbound gateway with all the possible attributes + + + +The expression and the literal value for each are mutually exclusive to each other. +For e.g. the *collapse-key* and the *collapse-key-expression* are mutually exclusive to each other. +The type attribute is mandatory that identifies the type of the service to push to. Currently *android* is the only value supported. +We have plans to support more services in future. + +Executing Testcases +------------------- + +All the test cases are present in the src/test/java folder + +The three important test cases are + +- *OutboundGatewayParserTests* +- *GCMOutboundGatewayTests* +- *GCMPushNotifyServiceImplTests* + +Of the above tests, *GCMPushNotifyServiceImplTests* is the test that connects to the GCM servers and tests sending push messages. +This test expects a properties file *services.properties* in the class path with two properties, *senderId* and *receiverId*. +The senderId is is the value of the API Key from your Google API console as seen below + +![Google API Console](images/GoogleAPIConsole.png) + +For the receiver id, run the sample and from the emulator register the device as per the teps given in the readme of the sample. +The registration id returned by the GCM server can be used to test the push from the test cases to the android application running on the emulator + + +[GCM]:http://developer.android.com/guide/google/gcm/index.html +[APNS]:http://developer.apple.com/library/mac/#documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/ApplePushService/ApplePushService.html +[Android development guide]:http://developer.android.com/guide/components/fundamentals.html +[Lars Vogel's]: http://www.vogella.com/articles/Android/article.html +[Getting Started]:http://developer.android.com/guide/google/gcm/gs.html +[Spring Integration Documentation]:http://static.springsource.org/spring-integration/reference/htmlsingle/ +[exponential backoff]:http://en.wikipedia.org/wiki/Exponential_backoff +[GCM Response]: http://developer.android.com/guide/google/gcm/gcm.html#response \ No newline at end of file diff --git a/spring-integration-pushnotify/images/GcmFlow.png b/spring-integration-pushnotify/images/GcmFlow.png new file mode 100644 index 00000000..fed3ed55 Binary files /dev/null and b/spring-integration-pushnotify/images/GcmFlow.png differ diff --git a/spring-integration-pushnotify/images/GoogleAPIConsole.png b/spring-integration-pushnotify/images/GoogleAPIConsole.png new file mode 100644 index 00000000..96601f40 Binary files /dev/null and b/spring-integration-pushnotify/images/GoogleAPIConsole.png differ diff --git a/spring-integration-pushnotify/pom.xml b/spring-integration-pushnotify/pom.xml new file mode 100644 index 00000000..f1a64e56 --- /dev/null +++ b/spring-integration-pushnotify/pom.xml @@ -0,0 +1,91 @@ + + + + 4.0.0 + org.springframework.integration + spring-integration-pushnotify + 1.0.0.BUILD-SNAPSHOT + + The project has components that enable java applications to send push notifications to + hand held devices running on Android platform. The Google Cloud Messaging (GCM) service is + used to send out notifications to the hand held devices running on Android platform. + + + + The Apache Software License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + 2.2.1 + + + + + + maven-compiler-plugin + + 1.6 + 1.6 + + + + maven-surefire-plugin + + + **/*Tests.java + + + **/*Abstract*.java + + + + + + + + + springsource-libs-milestone + Spring Framework Maven Milestone Repository + https://repo.springsource.org/libs-milestone + + + + + + org.springframework.integration + spring-integration-core + 2.2.0.M3 + + + + org.codehaus.jackson + jackson-core-asl + 1.9.9 + + + + org.codehaus.jackson + jackson-mapper-asl + 1.9.9 + + + + org.springframework.integration + spring-integration-test + 2.2.0.M3 + test + + + + junit + junit-dep + 4.10 + test + + + \ No newline at end of file diff --git a/spring-integration-pushnotify/src/main/java/org/springframework/integration/pushnotify/AbstractPushNotifyService.java b/spring-integration-pushnotify/src/main/java/org/springframework/integration/pushnotify/AbstractPushNotifyService.java new file mode 100644 index 00000000..b0209335 --- /dev/null +++ b/spring-integration-pushnotify/src/main/java/org/springframework/integration/pushnotify/AbstractPushNotifyService.java @@ -0,0 +1,143 @@ +/* + * Copyright 2002-2012 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.integration.pushnotify; + +import java.io.IOException; +import java.util.Map; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.util.Assert; + +/** + * The Common superclass implementing the common validations and other functionalities + * for the {@link PushNotifyService} implementations + * + * @author Amol Nayak + * + * @since 1.0 + * + */ +public abstract class AbstractPushNotifyService implements PushNotifyService { + + protected final Log logger = LogFactory.getLog(getClass()); + + /** + * Flag to enable the service to retry the requests that were unsuccessful either due to + * network issues or some recoverable error from the server. If set to true, exponential + * backoff and retry is used. Enabled by default. + */ + private volatile boolean retryRequests = true; + + private static final long INITIAL_RETRY_DELAY = 1000L; + + private volatile long maxRetryDelay = 10000L; //Defaults to 10 seconds. + + + /** + * The implemented version does some basic validation and delegates to + * the {@link #doPush(Map, String...) + * + * @param message + * @param attributes + * @param receiverId + * @return + */ + @Override + public final PushResponse push(Map message, Map attributes, String... receiverIds) + throws IOException { + Assert.notNull(message, "provided message is 'null'"); + Assert.isTrue(message.size() > 0,"provided message is an empty map"); + Assert.notNull(receiverIds,"provided receiver id is 'null'"); + Assert.isTrue(receiverIds.length > 0, "Must provide at least on receiver id"); + boolean retryRequest = retryRequests; + PushResponse response; + int base = 1; + int retryAttempt = 1; //for logging + //logic to exponentially retry the request + long delta = (long)(Math.random() * 1000); + while(true) { + response = doPush(message, attributes, receiverIds); + if(response == null && retryRequest) { + long delay = INITIAL_RETRY_DELAY * (2 * base - 1)/2; + long totalDelay = delay + delta; + if(totalDelay <= maxRetryDelay) { + String logMessage = String.format("Retry attempt %d, retrying after %d ms", retryAttempt++, totalDelay); + logger.info(logMessage); + base *= 2; + try { + Thread.sleep(totalDelay); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + else { + break; + } + } + else { + break; + } + } + return response; + } + + /** + * The sub classes need to implement the method that will push the given messages to the provided + * receiver ids + * + * @param message + * @param attributes + * @param receiverIds + * @return + */ + protected abstract PushResponse doPush(Map message, Map attributes, String... receiverIds) throws IOException; + + /** + * Checks if requests are to be retried using exponential backoff or not. + * @return + */ + public boolean shouldRetryRequests() { + return retryRequests; + } + + /** + * Set to true if requests are to be retried using exponential backoff. + * @param retryRequests + */ + public void setRetryRequests(boolean retryRequests) { + this.retryRequests = retryRequests; + } + + /** + * Gets the max interval for which the request thread would sleep to retry a request + * to the service + * + */ + public long getMaxRetryDelay() { + return maxRetryDelay; + } + + /** + * Sets the max interval in milli seconds for which the thread will sleep before retrying + * the request again with the push services + * + * @param maxRetryInterval + */ + public void setMaxRetryDelay(long maxRetryDelay) { + this.maxRetryDelay = maxRetryDelay; + } +} diff --git a/spring-integration-pushnotify/src/main/java/org/springframework/integration/pushnotify/PushNotifyService.java b/spring-integration-pushnotify/src/main/java/org/springframework/integration/pushnotify/PushNotifyService.java new file mode 100644 index 00000000..a6347f8d --- /dev/null +++ b/spring-integration-pushnotify/src/main/java/org/springframework/integration/pushnotify/PushNotifyService.java @@ -0,0 +1,46 @@ +/* + * Copyright 2002-2012 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.integration.pushnotify; + +import java.io.IOException; +import java.util.Map; + +/** + * The core service that would be performing the push operations to the hand held + * devices from the java applications + * + * @author Amol Nayak + * + * @since 1.0 + * + */ +public interface PushNotifyService { + + /** + * Push the message to multiple receivers identified by these multiple + * receiver ids. + * + * @param message The map that represents the key value pair of the message to be sent. + * @param attributes The map of attributes apart from the message that might be needed to be + * sent along with the request. These could be the service specific configuration parameters + * that would be used to change the behavior of the message sent. The values of the attributes + * if defined by the implementation. + * @param receiverId The unique identifiers that identifies the target devices to which the + * notification is to be sent. + * @return + */ + PushResponse push(Map message, Map attributes, String... receiverIds) throws IOException; +} diff --git a/spring-integration-pushnotify/src/main/java/org/springframework/integration/pushnotify/PushResponse.java b/spring-integration-pushnotify/src/main/java/org/springframework/integration/pushnotify/PushResponse.java new file mode 100644 index 00000000..53292371 --- /dev/null +++ b/spring-integration-pushnotify/src/main/java/org/springframework/integration/pushnotify/PushResponse.java @@ -0,0 +1,35 @@ +/* + * Copyright 2002-2012 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.integration.pushnotify; + +/** + * The response for the invocation of push to a single device using the {@link PushNotifyService} + * + * @author Amol Nayak + * + * @since 1.0 + * + */ +public interface PushResponse { + + /** + * True if the response is a multicast response, that is the response is for a request to + * push to multiple devices in one request + * + * @return + */ + boolean isMulticast(); +} diff --git a/spring-integration-pushnotify/src/main/java/org/springframework/integration/pushnotify/config/xml/IntegrationPushNotifyNamespaceHandler.java b/spring-integration-pushnotify/src/main/java/org/springframework/integration/pushnotify/config/xml/IntegrationPushNotifyNamespaceHandler.java new file mode 100644 index 00000000..a9e02a76 --- /dev/null +++ b/spring-integration-pushnotify/src/main/java/org/springframework/integration/pushnotify/config/xml/IntegrationPushNotifyNamespaceHandler.java @@ -0,0 +1,34 @@ +/* + * Copyright 2002-2012 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.integration.pushnotify.config.xml; + +import org.springframework.integration.config.xml.IntegrationNamespaceHandler; + +/** + * The namespace handler for the Integration push notify adapters + * + * @author Amol Nayak + * + * @since 1.0 + * + */ +public class IntegrationPushNotifyNamespaceHandler extends IntegrationNamespaceHandler { + + @Override + public void init() { + this.registerBeanDefinitionParser("outbound-gateway", new OutboundGatewayParser()); + } +} diff --git a/spring-integration-pushnotify/src/main/java/org/springframework/integration/pushnotify/config/xml/OutboundGatewayParser.java b/spring-integration-pushnotify/src/main/java/org/springframework/integration/pushnotify/config/xml/OutboundGatewayParser.java new file mode 100644 index 00000000..4dceca80 --- /dev/null +++ b/spring-integration-pushnotify/src/main/java/org/springframework/integration/pushnotify/config/xml/OutboundGatewayParser.java @@ -0,0 +1,276 @@ +/* + * Copyright 2002-2012 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.integration.pushnotify.config.xml; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.beans.factory.support.BeanDefinitionBuilder; +import org.springframework.beans.factory.xml.ParserContext; +import org.springframework.expression.Expression; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.common.LiteralExpression; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.integration.config.xml.AbstractConsumerEndpointParser; +import org.springframework.integration.handler.AbstractReplyProducingMessageHandler; +import org.springframework.integration.pushnotify.PushNotifyService; +import org.springframework.integration.pushnotify.gcm.GCMPushNotifyServiceImpl; +import org.springframework.integration.pushnotify.gcm.outbound.GCMOutboundGateway; +import org.springframework.util.StringUtils; +import org.w3c.dom.Element; +import org.w3c.dom.NamedNodeMap; + +/** + * The parser for the outbound-gateway element in int-pushnotify namespace + * + * @author Amol Nayak + * + * @since 1.0 + * + */ +public class OutboundGatewayParser extends AbstractConsumerEndpointParser { + + + private static final Log logger = LogFactory.getLog(OutboundGatewayParser.class); + + public static final String ADAPTER_TYPE = "type"; + private final Map gatewayTypes; + + public OutboundGatewayParser() { + super(); + gatewayTypes = new HashMap(); + //Just add in this map one instance for each type supported + gatewayTypes.put("android", new AndroidPushNotifyGatewayType()); + } + + @Override + protected BeanDefinitionBuilder parseHandler(Element element, + ParserContext parserContext) { + String adapterType = element.getAttribute(ADAPTER_TYPE); + if(!StringUtils.hasText(adapterType)) { + parserContext + .getReaderContext() + .error("Attribute " + ADAPTER_TYPE + " is mandatory and needs to have a non empty value", + adapterType); + } + + PushNotifyGatewayType gatewayType = gatewayTypes.get(adapterType.toLowerCase()); + if(gatewayType == null) { + parserContext + .getReaderContext() + .error("Type " + adapterType + " is not currently supported", element); + } + + //check if all the given attributes are supported by this type, else flag a warning + List attributes = new ArrayList(); + NamedNodeMap attributeMap = element.getAttributes(); + for(int i = 0 ; i < attributeMap.getLength(); i++) { + attributes.add(attributeMap.item(i).getNodeName()); + } + attributes.removeAll(gatewayType.getSupportedAttributes()); + if(attributes.size() > 0) { + //unsupported attributes present + StringBuilder message = new StringBuilder().append(attributes.get(0)); + for(int i = 1; i < attributes.size(); i++) { + message.append(", ").append(attributes.get(i)); + } + logger.warn("Attributes " + message + + " are not supported by gateway of type " + gatewayType.getGatewayType()); + } + BeanDefinitionBuilder builder = BeanDefinitionBuilder.genericBeanDefinition(gatewayType.getHandler()); + gatewayType.setupGateway(builder, element, parserContext); + return builder; + } + + private abstract class PushNotifyGatewayType { + + private final String gatewayType; + private final Class handler; + private final Class service; + private final List supportedAttributes = new ArrayList(); + private final ExpressionParser parser = new SpelExpressionParser(); + + public PushNotifyGatewayType(String gatewayType, + Class handler, + Class service, String... supportedAttributes) { + super(); + this.gatewayType = gatewayType; + this.handler = handler; + this.service = service; + if(supportedAttributes != null && supportedAttributes.length > 0) { + this.supportedAttributes.addAll(Arrays.asList(supportedAttributes)); + } + } + + public String getGatewayType() { + return gatewayType; + } + + public Class getHandler() { + return handler; + } + + public Class getService() { + return service; + } + + public List getSupportedAttributes() { + return new ArrayList(supportedAttributes); + } + + public abstract void setupGateway(BeanDefinitionBuilder builder, Element element, ParserContext parserContext); + + /** + * Helper method that does the below steps + * + * 1. If both are null, return null + * 2. If present, the literalValue and expressionValue are supposed to be + * mutually exclusive to each other. If not, its an error + * 3. If one of them is present, return an appropriate {@link Expression} instance + * + * @param element + * @param literalValueAttribute + * @param expressionValueAttribute + * @param context + * + * @return + */ + protected Expression getExpression(Element element, String literalValueAttribute, + String expressionValueAttribute, ParserContext context) { + Expression expression = null; + String literalValue = element.getAttribute(literalValueAttribute); + String expressionValue = element.getAttribute(expressionValueAttribute); + boolean hasLiteralValue = StringUtils.hasText(literalValue); + boolean hasExpressionValue = StringUtils.hasText(expressionValue); + if(hasLiteralValue || hasExpressionValue) { + if(hasLiteralValue && hasExpressionValue) { + context.getReaderContext() + .error(literalValueAttribute + " and " + expressionValueAttribute + + " are mutually exclusive to each other", element); + } + if(hasLiteralValue) { + expression = new LiteralExpression(literalValue); + } + else { + expression = parser.parseExpression(expressionValue); + } + } + return expression; + } + } + + private class AndroidPushNotifyGatewayType extends PushNotifyGatewayType { + + public AndroidPushNotifyGatewayType() { + super("android", + GCMOutboundGateway.class, + GCMPushNotifyServiceImpl.class, + "id" + ,"request-channel" + ,"reply-channel" + ,"type" + ,"service-ref" + ,"sender-id" + ,"sender-id-ref" + ,"collapse-key" + ,"collapse-key-expression" + ,"time-to-live" + ,"time-to-live-expression" + ,"delay-while-idle" + ,"delay-while-idle-expression" + ,"receiver-id" + ,"receiver-id-expression"); + } + + @Override + public void setupGateway(BeanDefinitionBuilder builder, Element element, ParserContext parserContext) { + String serviceRef = element.getAttribute("service-ref"); + String senderId = element.getAttribute("sender-id"); + String senderIdRef = element.getAttribute("sender-id-ref"); + boolean hasSenderId = StringUtils.hasText(senderId); + boolean hasSenderIdRef = StringUtils.hasText(senderIdRef); + + if(StringUtils.hasText(serviceRef)) { + if(hasSenderId || hasSenderIdRef) { + parserContext.getReaderContext() + .error("sender-id and sender-id-ref cannot be defined when service-ref is provided", + element); + } + builder.addConstructorArgReference(serviceRef); + } + else { + if(hasSenderId && hasSenderIdRef) { + parserContext.getReaderContext() + .error("sender-id and sender-id-ref are mutually exclusive to each other", + element); + } + + if(!(hasSenderId ^ hasSenderIdRef)) { + parserContext.getReaderContext() + .error("Atleast one of sender-id and sender-id-ref is required when service-ref is not provided", + element); + } + + BeanDefinitionBuilder service = BeanDefinitionBuilder.genericBeanDefinition(getService()); + if(hasSenderId) { + service.addConstructorArgValue(senderId); + } + else { + service.addConstructorArgReference(senderIdRef); + } + builder.addConstructorArgValue(service.getBeanDefinition()); + } + + //Collapse key + Expression collapseKeyExpr = + getExpression(element, "collapse-key", "collapse-key-expression", parserContext); + if(collapseKeyExpr != null) { + builder.addPropertyValue("collapseKeyExpression", collapseKeyExpr); + } + + //Time to live + Expression timeToLiveExpr = + getExpression(element, "time-to-live", "time-to-live-expression", parserContext); + if(timeToLiveExpr != null) { + builder.addPropertyValue("timeToLiveExpression", timeToLiveExpr); + } + + //Delay while idle expression + Expression delayWhileIdleExpr = + getExpression(element, "delay-while-idle", "delay-while-idle-expression", parserContext); + if(delayWhileIdleExpr != null) { + builder.addPropertyValue("delayWhileIdleExpression", delayWhileIdleExpr); + } + + //Receiver id expression + Expression receiverIdExpr = + getExpression(element, "receiver-id", "receiver-id-expression", parserContext); + if(receiverIdExpr != null) { + builder.addPropertyValue("receiverIdsExpression", receiverIdExpr); + } + } + } + + @Override + protected String getInputChannelAttributeName() { + return "request-channel"; + } +} diff --git a/spring-integration-pushnotify/src/main/java/org/springframework/integration/pushnotify/gcm/GCMPushNotifyServiceImpl.java b/spring-integration-pushnotify/src/main/java/org/springframework/integration/pushnotify/gcm/GCMPushNotifyServiceImpl.java new file mode 100644 index 00000000..e0869839 --- /dev/null +++ b/spring-integration-pushnotify/src/main/java/org/springframework/integration/pushnotify/gcm/GCMPushNotifyServiceImpl.java @@ -0,0 +1,685 @@ +/* + * Copyright 2002-2013 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.integration.pushnotify.gcm; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.codehaus.jackson.JsonFactory; +import org.codehaus.jackson.JsonGenerator; +import org.codehaus.jackson.map.ObjectMapper; +import org.springframework.integration.pushnotify.AbstractPushNotifyService; +import org.springframework.integration.pushnotify.PushNotifyService; +import org.springframework.integration.pushnotify.PushResponse; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * The implementation of the push messaging for Android hand held devices + * that used Google's Cloud Messaging (GCM) to push messages. + * + * @author Amol Nayak + * + * @since 1.0 + * + */ +public class GCMPushNotifyServiceImpl extends AbstractPushNotifyService + implements PushNotifyService { + + private static final Log logger = LogFactory.getLog(GCMPushNotifyServiceImpl.class); + + /** + * The parameter used to carry the registration id of the device to which the notification + * is to be sent + */ + public static final String REGISTRATION_ID = "registration_id"; + + /** + * The parameter used to carry the registration ids of the device to which the notification + * is to be sent + */ + public static final String REGISTRATION_IDS = "registration_ids"; + + /** + * The collapse key parameter of the request + */ + public static final String COLLAPSE_KEY = "collapse_key"; + + /** + * The prefix to the parameter for sending the data in case the request + * is sent out as plain text + */ + public static final String DATA_KEY_PREFIX = "data."; + + /** + * The key used in the JSON request to send the data to the GCM service + */ + public static final String DATA_KEY = "data"; + + /** + * The key used to specify if the messages should be held and not pushed if + * the device is idle. + */ + public static final String DELAY_WHILE_IDLE = "delay_while_idle"; + + /** + * The parameter that gives the time the message will live and will not be discarded + * before it is delivered to the device. + */ + public static final String TIME_TO_LIVE = "time_to_live"; + + /** + * The key in the JSON response that indicates that an error has occurred while sending the message + */ + public static final String ERROR = "error"; + + /** + * The key in the JSON response giving the message id of the successfully sent message + */ + public static final String MESSAGE_ID = "message_id"; + + /** + *The key in the JSON response giving the canonnical id of the device to which the message was posted + *may be present only if the message_id attribute is present + */ + public static final String CANONICAL_MESSAGES = "canonical_ids"; + + /** + * The key giving the number of failed messages in the request + */ + public static final String FAILURE_MESSAGES = "failure"; + + /** + * The key giving the number of successful messages in the request + */ + public static final String SUCCESS_MESSAGES = "success"; + + /** + * The key giving a unique id to the multi cast request, this is returned by the GCM server + */ + public static final String MULTICAST_ID = "multicast_id"; + + + /** + * The key present in the pain text response indicating the request is successful, the value + * of this key is the message id + */ + public static final String RESPONSE_TOKEN_ID = "id"; + + + /** + * The content type if the request is of type JSON. + */ + public static final String JSON_TYPE = "application/json"; + + /** + * The content type if the request is of type plain text. + */ + public static final String PLAIN_TEXT_TYPE = "application/x-www-form-urlencoded;charset=UTF-8"; + + /** + * The Web service for the GCM services + */ + public static final String GCM_SERVICE_URL = "https://android.googleapis.com/gcm/send"; + + + private static final String DEFAULT_ENCODING = "UTF-8"; + + /** + * The Sender id that would be used while making request to the GCM services + */ + private final String senderId; + + + + public GCMPushNotifyServiceImpl(String senderId) { + Assert.notNull(senderId, "senderId is 'null'"); + this.senderId = senderId; + } + + + /** + * Gets the content type of the content to be posted to the servers based in whether the type is + * JSON or plain text, the value is application/json or application/x-www-form-urlencoded;charset=UTF-8 + * for JSON and plain text content respectively + * + * @return + */ + private String getContentType(boolean sendAsJson) { + return sendAsJson? JSON_TYPE : PLAIN_TEXT_TYPE; + } + + + /* (non-Javadoc) + * @see org.springframework.integration.pushnotify.AbstractPushNotifyService#doPush(java.util.Map, java.lang.String[]) + */ + @Override + protected PushResponse doPush(Map message, Map attributes, String... receiverIds) { + + Map copy = null; + if(attributes != null) { + copy = new HashMap(attributes); + } + + try { + if(receiverIds.length > 1) { + return sendAsJson(message, copy, receiverIds); + } + else { + return sendAsPlainText(message, copy, receiverIds[0]); + } + } catch (IOException e) { + // TODO handle and throw a GCM Messaging exception + return null; + } + } + + /** + * The private method used to request to the GCM using plain text request type + * + * @param message + * @param attributes + * @param receiverId + */ + private PushResponse sendAsPlainText(Map message, Map attributes, String receiverId) + throws IOException{ + + String requestString = getPlainTextMessageBody(message, attributes, receiverId); + if(logger.isDebugEnabled()) { + logger.debug("Request String is " + requestString); + } + String contentType = getContentType(false); + return postMessage(contentType, requestString, receiverId); + } + + /** + * Posts the given message to to the GCM service + * + * @param contentType + * @param requestMessage + * @param receiverIds + * + * @return + */ + private GCMPushResponse postMessage(String contentType, String requestMessage, String... receiverIds) throws IOException { + URL url = new URL(GCM_SERVICE_URL); + HttpURLConnection connection = (HttpURLConnection)url.openConnection(); + connection.setDoOutput(true); + byte[] bytes = requestMessage.getBytes(); + connection.setRequestMethod("POST"); + connection.setRequestProperty("Content-Type", contentType); + connection.setRequestProperty("Authorization", "key=" + senderId); + connection.setFixedLengthStreamingMode(bytes.length); + OutputStream out = connection.getOutputStream(); + out.write(bytes); + out.flush(); + //Now Get the response + if(JSON_TYPE.equals(contentType)) { + return handleJSONResponse(connection, receiverIds); + } + else { + return handlePlainTextResponse(connection, receiverIds); + } + + } + + /** + * Parses the response from the GCM server for a request made with JSON message + * and constructs the instance of {@link GCMPushResponse} + * + * @param connection + * @param receiverIds + * + * @return + * @throws IOException + */ + @SuppressWarnings("unchecked") + private GCMPushResponse handleJSONResponse(HttpURLConnection connection, String... receiverIds) + throws IOException { + final int respCode = connection.getResponseCode(); + final Long multicastId; + final Integer successfulMessages; + final Integer failedMessages; + final Integer canonicalMessages; + final Map messageIds; + final Map canonicalIds; + final Map errorCodes; + if(respCode == 200) { //successful + messageIds = new HashMap(); + canonicalIds = new HashMap(); + errorCodes = new HashMap(); + + BufferedReader reader = new BufferedReader( + new InputStreamReader(connection.getInputStream())); + String line = reader.readLine(); + if(logger.isDebugEnabled()) { + logger.debug("JSON Response from the GCM server is " + line); + } + ObjectMapper mapper = new ObjectMapper(); + Map responseMap = mapper.readValue(new ByteArrayInputStream(line.getBytes()), Map.class); + + multicastId = (Long)responseMap.get(MULTICAST_ID); + successfulMessages = (Integer)responseMap.get(SUCCESS_MESSAGES); + failedMessages = (Integer)responseMap.get(FAILURE_MESSAGES); + canonicalMessages = (Integer)responseMap.get(CANONICAL_MESSAGES); + + + List> results = (List>)responseMap.get("results"); + int index = 0; + for(Map result:results) { + String currentRecId = receiverIds[index++]; + String messageId = result.get(MESSAGE_ID); + if(StringUtils.hasText(messageId)) { + messageIds.put(currentRecId, messageId); + String canonicalId = result.get(REGISTRATION_ID); + if(StringUtils.hasText(canonicalId)) { + canonicalIds.put(currentRecId, canonicalId); + } + } + else { + //it has to be error + errorCodes.put(currentRecId, result.get(ERROR)); + } + } + } + else if(respCode == 503) { + return null; + } + else { + multicastId = 0L; + successfulMessages = 0; + failedMessages = 0; + canonicalMessages = 0; + messageIds = Collections.EMPTY_MAP; + canonicalIds = Collections.EMPTY_MAP; + errorCodes = Collections.EMPTY_MAP; + } + + return new GCMPushResponse() { + + @Override + public boolean isMulticast() { + return true; + } + + @Override + public int getSuccessfulMessages() { + return successfulMessages; + } + + @Override + public Map getSentMessageIds() { + return messageIds; + } + + @Override + public int getResponseCode() { + return respCode; + } + + @Override + public int getNumberOfCanonicalIds() { + return canonicalMessages; + } + + @Override + public int getFailedMessages() { + return failedMessages; + } + + @Override + public Map getErrorCodes() { + return errorCodes; + } + + @Override + public Map getCanonicalIds() { + return canonicalIds; + } + + @Override + public long getMulticastId() { + return multicastId; + } + }; + + } + + + /** + * Parses the response from the GCM server and constructs the instance of {@link GCMPushResponse} + * + * @param connection + * @param receiverIds + * + * @return + * @throws IOException + */ + private GCMPushResponse handlePlainTextResponse(HttpURLConnection connection, final String... receiverIds) + throws IOException { + final int respCode = connection.getResponseCode(); + final String errorCode; + final String sentMessageId; + final String canonicalId; + if(respCode == 200) { //successful + BufferedReader reader = new BufferedReader( + new InputStreamReader(connection.getInputStream())); + String line = reader.readLine(); + if(logger.isDebugEnabled()) { + logger.debug("First line of response is " + line); + } + //Should be for the message id or Error + String[] splits = line.split("="); + String token = splits[0]; + if(RESPONSE_TOKEN_ID.equals(token)) { + sentMessageId = splits[1]; + errorCode = null; + } + else { + sentMessageId = null; + errorCode = splits[1]; + } + + //check for canonical id + line = reader.readLine(); + if(logger.isDebugEnabled()) { + logger.debug("Second line of response is " + line); + } + if(line != null) { + //canonical id present + splits = line.split("="); + canonicalId = splits[1]; + } + else { + canonicalId = null; + } + } + else if(respCode == 503) { + //for temp unavailable, + //TODO, handle this one to retry using expo backoff if needed + return null; + } + else { + //Error, could be 401 or 500 + errorCode = null; + sentMessageId = null; + canonicalId = null; + } + + + return new GCMPushResponse() { + + @Override + public boolean isMulticast() { + return false; + } + + @Override + public int getResponseCode() { + return respCode; + } + + + @Override + @SuppressWarnings("unchecked") + public Map getSentMessageIds() { + if(sentMessageId != null) { + return Collections.singletonMap(receiverIds[0], sentMessageId); + } + else { + return Collections.EMPTY_MAP; + } + + } + + @Override + @SuppressWarnings("unchecked") + public Map getErrorCodes() { + if(errorCode != null) { + return Collections.singletonMap(receiverIds[0], errorCode); + } + else { + return Collections.EMPTY_MAP; + } + + } + + @Override + @SuppressWarnings("unchecked") + public Map getCanonicalIds() { + if(canonicalId != null) { + return Collections.singletonMap(receiverIds[0], canonicalId); + } + else { + return Collections.EMPTY_MAP; + } + } + + @Override + public int getSuccessfulMessages() { + return sentMessageId != null? 1 : 0; + } + + @Override + public int getFailedMessages() { + return errorCode != null || respCode == 400 || respCode == 401 || respCode == 500? 1 : 0; + } + + @Override + public int getNumberOfCanonicalIds() { + return canonicalId != null? 1 : 0; + } + + @Override + public long getMulticastId() { + return 0; + } + }; + } + + /** + * Gets the message body that will be posted to the GCM server as a plain text message + * + * @param message + * @param attributes + * @param receiverId + * + * @return + */ + private String getPlainTextMessageBody(Map message, Map copy, String receiverId) + throws UnsupportedEncodingException { + Assert.notNull(receiverId); + StringBuilder builder = new StringBuilder(); + //Add the registration ID parameter + builder.append(REGISTRATION_ID).append("=").append(URLEncoder.encode(receiverId, DEFAULT_ENCODING)); + + for(Entry entry:message.entrySet()) { + builder.append("&") + .append(DATA_KEY_PREFIX) + .append(entry.getKey()) + .append("=") + .append(URLEncoder.encode(entry.getValue().toString(), "UTF-8")); + //We using toString, not conversion service + } + + if(copy != null && !copy.isEmpty()) { + //Check if the collapse key is present in the message + if(copy.containsKey(COLLAPSE_KEY)) { + builder.append("&").append(COLLAPSE_KEY) + .append("=") + .append(URLEncoder.encode(copy.get(COLLAPSE_KEY), DEFAULT_ENCODING)); + copy.remove(COLLAPSE_KEY); + } + + //Check if the delay if idle key is present + if(copy.containsKey(DELAY_WHILE_IDLE)) { + boolean delayWhileIdleFlag = getDelayWhileIdleFlag(copy); + builder.append("&").append(DELAY_WHILE_IDLE) + .append("=") + .append(delayWhileIdleFlag); + copy.remove(DELAY_WHILE_IDLE); + } + + //Check if the time to live key is present + if(copy.containsKey(TIME_TO_LIVE)) { + long timeToLive = getTimeToLive(copy); + builder.append("&").append(TIME_TO_LIVE) + .append("=") + .append(timeToLive); + copy.remove(TIME_TO_LIVE); + } + + if(copy.size() > 0) { + if(logger.isWarnEnabled()) { + String warnMessage = String.format("Attributes contain more %d elements which are not recognised as valid" + + " attributes by GCM, are those intended to be a part of message? Attributes are" + + " %s", copy.size(), copy); + logger.warn(warnMessage); + } + } + } + + String requestString = builder.toString(); + return requestString; + } + + + /** + * Gets the delay_while_idle flag's value to be sent out with the request to the GCM + * @param copy + * @return + */ + private boolean getDelayWhileIdleFlag(Map copy) { + boolean delayWhileIdleFlag = false; + String delayWhileIdle = copy.get(DELAY_WHILE_IDLE); + if(StringUtils.hasText(delayWhileIdle) + && ("1".equals(delayWhileIdle.trim()) + || + Boolean.valueOf(delayWhileIdle) + )) { + delayWhileIdleFlag = true; + } + return delayWhileIdleFlag; + } + + + /** + * Gets the the time_to_live attribute to be sent in the request + * @param copy + * @return + */ + private long getTimeToLive(Map copy) { + String timeToLoveString = copy.get(TIME_TO_LIVE); + //Will throw a NumberFormatException if the value is not numeric + long timeToLive = Long.parseLong(timeToLoveString); + return timeToLive; + } + + /** + * Private method that constructs the JSON request message using {@link #getJsonMessageBody(Map, Map, String...)} + * and then posts the content to to GCM service + * + * @param message + * @param attributes + * @param receiverIds + * @return + * @throws IOException + */ + private PushResponse sendAsJson(Map message, Map attributes, + String... receiverIds) throws IOException { + String requestMessage = getJsonMessageBody(message, attributes, receiverIds); + if(logger.isDebugEnabled()) { + logger.debug("Request message is " + requestMessage); + } + String contentType = getContentType(true); + return postMessage(contentType, requestMessage, receiverIds); + } + + /** + * Private helper method that constructs the JSON request message + * + * @param message + * @param attributes + * @param receiverIds + * @return + * @throws IOException + */ + private String getJsonMessageBody(Map message, Map attributes, + String... receiverIds) throws IOException { + String stringMessage = null; + ByteArrayOutputStream os = new ByteArrayOutputStream(); + JsonFactory factory = new JsonFactory(); + JsonGenerator generator = factory.createJsonGenerator(os); + //start writing the document + generator.writeStartObject(); + + //Start writing registration ids array + generator.writeArrayFieldStart(REGISTRATION_IDS); + for(String receiverId:receiverIds) { + generator.writeString(receiverId); + } + //end registration id array + generator.writeEndArray(); + + //Start the data node + generator.writeObjectFieldStart(DATA_KEY); + for(Entry dataKey:message.entrySet()) { + generator.writeStringField(dataKey.getKey(), dataKey.getValue().toString()); + //Again, we are using toString and not conversion service + } + //End for data node + generator.writeEndObject(); + + //Check the collapse key + if(attributes.containsKey(COLLAPSE_KEY)) { + generator.writeStringField(COLLAPSE_KEY, attributes.get(COLLAPSE_KEY)); + attributes.remove(COLLAPSE_KEY); + } + + //Check if delay while idle attribute is present + if(attributes.containsKey(DELAY_WHILE_IDLE)) { + boolean delayWhileIdleFlag = getDelayWhileIdleFlag(attributes); + generator.writeBooleanField(DELAY_WHILE_IDLE, delayWhileIdleFlag); + } + + //Check if the time to live key is present + if(attributes.containsKey(TIME_TO_LIVE)) { + long timeToLive = getTimeToLive(attributes); + generator.writeNumberField(TIME_TO_LIVE, timeToLive); + } + + //End the document + generator.writeEndObject(); + generator.close(); + stringMessage = new String(os.toByteArray()); + return stringMessage; + } +} diff --git a/spring-integration-pushnotify/src/main/java/org/springframework/integration/pushnotify/gcm/GCMPushResponse.java b/spring-integration-pushnotify/src/main/java/org/springframework/integration/pushnotify/gcm/GCMPushResponse.java new file mode 100644 index 00000000..e9c1ef88 --- /dev/null +++ b/spring-integration-pushnotify/src/main/java/org/springframework/integration/pushnotify/gcm/GCMPushResponse.java @@ -0,0 +1,94 @@ +/* + * Copyright 2002-2012 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.integration.pushnotify.gcm; + +import java.util.Map; + +import org.springframework.integration.pushnotify.PushResponse; + +/** + * The response for the the GCM request. + * + * @author Amol Nayak + * + * @since 1.0 + * + */ +public interface GCMPushResponse extends PushResponse { + + /** + * The HTTP Response code that was received for the request made + * + * @return + */ + int getResponseCode(); + + /** + * Gets the GCM error codes that was received if the received HTTP response code is other than 200 (successful) and + * 503 (temporarily available). The key is the receiver id and the value is the error code received + * + * @return + */ + Map getErrorCodes(); + + /** + * An identifiers from the server acknowledging the receipt of the message sent. + * The Map contains the key which is the receiverId provided and the value is the message + * id received from the GCM server. + * + * @return + */ + Map getSentMessageIds(); + + /** + * Gets the canonical id of the device to which the message was sent, this is optional and would + * not necessarily be present for all receiverIds. This id indicates that the device to which the message + * was sent has a new registration id with the server other than the one sent with the request. + * The sender should replace the registration id of the receiver with this canonical id and use it for any subsequent requests sent to this + * device. + * + * @return + */ + Map getCanonicalIds(); + + + /** + * Gets the number of successful messages as part of the requests + * + */ + int getSuccessfulMessages(); + + /** + * Gets the number of failed messages in the request + * + * @return + */ + int getFailedMessages(); + + /** + *Gets the number of canonical ids present in the response + * @return + */ + int getNumberOfCanonicalIds(); + + /** + * A long number giving the multicast id provided by the GCM server for multicast requests + * @return + */ + long getMulticastId(); + + +} diff --git a/spring-integration-pushnotify/src/main/java/org/springframework/integration/pushnotify/gcm/outbound/GCMOutboundGateway.java b/spring-integration-pushnotify/src/main/java/org/springframework/integration/pushnotify/gcm/outbound/GCMOutboundGateway.java new file mode 100644 index 00000000..27795fed --- /dev/null +++ b/spring-integration-pushnotify/src/main/java/org/springframework/integration/pushnotify/gcm/outbound/GCMOutboundGateway.java @@ -0,0 +1,278 @@ +/* + * Copyright 2002-2013 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.integration.pushnotify.gcm.outbound; + +import static org.springframework.integration.pushnotify.gcm.GCMPushNotifyServiceImpl.COLLAPSE_KEY; +import static org.springframework.integration.pushnotify.gcm.GCMPushNotifyServiceImpl.DELAY_WHILE_IDLE; +import static org.springframework.integration.pushnotify.gcm.GCMPushNotifyServiceImpl.TIME_TO_LIVE; + +import java.io.IOException; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.springframework.beans.factory.BeanFactory; +import org.springframework.context.expression.BeanFactoryResolver; +import org.springframework.core.convert.ConversionService; +import org.springframework.expression.Expression; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; +import org.springframework.integration.Message; +import org.springframework.integration.MessagingException; +import org.springframework.integration.handler.AbstractReplyProducingMessageHandler; +import org.springframework.integration.pushnotify.PushNotifyService; +import org.springframework.integration.pushnotify.gcm.GCMPushResponse; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * The Outbound gateway class for sending the payload of the incoming message to the GCM push notification + * service. The GCM push notify service accepts the messages in key value format. The message coming to the + * adapter thus should have the Either of the following + * 1. A Payload of type java.util.Map with both the keys and values as Strings. + * 2. A String payload, in which case it will be sent to the GCM Service with the key whose + * value is the {@link #defaultKey} + * 3. Any Object that can be converted to {@link String} using Spring's conversion services + * + * @author Amol Nayak + * + * @since 1.0 + * + */ +public class GCMOutboundGateway extends AbstractReplyProducingMessageHandler { + + private static final String DEFAULT_KEY = "Data"; + + private volatile String defaultKey; + + private final PushNotifyService service; + + private volatile Expression receiverIdsExpression; + + private volatile Expression collapseKeyExpression; + + private volatile Expression timeToLiveExpression; + + private volatile Expression delayWhileIdleExpression; + + private final StandardEvaluationContext context; + + private final String defaultReceiverIdsExpression = "headers['receiverIds']"; + + + + /** + * The default constructor that instantiates the gateway with an instance of {@link GCMPushNotifyService} + * + * @param service + */ + public GCMOutboundGateway(PushNotifyService service) { + Assert.notNull(service, "Provided 'service' is null"); + this.service = service; + context = new StandardEvaluationContext(); + } + + @Override + protected void onInit() { + super.onInit(); + BeanFactory factory = getBeanFactory(); + if(factory != null) { + context.setBeanResolver(new BeanFactoryResolver(factory)); + } + if(receiverIdsExpression == null) { + receiverIdsExpression = new SpelExpressionParser().parseExpression(defaultReceiverIdsExpression); + } + defaultKey = DEFAULT_KEY; + } + + /* (non-Javadoc) + * @see org.springframework.integration.handler.AbstractReplyProducingMessageHandler#handleRequestMessage(org.springframework.integration.Message) + */ + @SuppressWarnings("unchecked") + @Override + protected Object handleRequestMessage(Message requestMessage) { + Object payload = requestMessage.getPayload(); + ConversionService conversionService = getConversionService(); + Map message; + if(Map.class.isAssignableFrom(payload.getClass())) { + message = (Map)payload; + } + else if(String.class.isAssignableFrom(payload.getClass())) { + message = Collections.singletonMap(defaultKey, payload); + } + else if(conversionService.canConvert(payload.getClass(), String.class)) { + message = Collections.singletonMap(defaultKey, (Object)conversionService.convert(payload, String.class)); + } + else { + throw new MessagingException("Only messages with payload of Map, String or an " + + "object that can be converted to String are allowed"); + } + String[] receiverIds = getReceiverIds(requestMessage); + + Map attributes = getAttributes(requestMessage); + GCMPushResponse response; + try { + response = (GCMPushResponse)service.push(message, attributes, receiverIds); + } catch (IOException e) { + throw new MessagingException(requestMessage, "Caught IOException while pushing to using GCM", e); + } + return response; + } + + + /** + * Sets the default key to be used in case a {@link Map} is not provided as the payload + * in the incoming message. This value is used as the key of the message that is sent out to the + * GCM service to be pushed to the Android device. + * + * @param defaultKey + */ + public void setDefaultKey(String defaultKey) { + Assert.hasText(defaultKey, "Provided 'defaultKey' is either null or empty text"); + this.defaultKey = defaultKey; + } + + /** + * The Expression that would be executed on the incoming message to get the receiver ids. + * The the permitted return values on expression evaluation are String, String[] + * or {@link Collection} + * + * @param receiverIdsExpression + */ + public void setReceiverIdsExpression(Expression receiverIdsExpression) { + Assert.notNull(receiverIdsExpression, "Provided 'receiverIdsExpression' is null"); + this.receiverIdsExpression = receiverIdsExpression; + } + + /** + * Sets the collapse key expression to be used for the message that will be sent. + * + * @param collapseKeyExpression + */ + public void setCollapseKeyExpression(Expression collapseKeyExpression) { + Assert.notNull(collapseKeyExpression, "'collapseKeyExpression' is null"); + this.collapseKeyExpression = collapseKeyExpression; + } + + /** + * Sets the expression to find the time to live attribute. + * @param timeToLiveExpression + */ + public void setTimeToLiveExpression(Expression timeToLiveExpression) { + Assert.notNull(timeToLiveExpression, "'timeToLiveExpression' is null"); + this.timeToLiveExpression = timeToLiveExpression; + } + + /** + * Sets the expression for finding the value of the delay if idle attribute, + * A value that evaluates to 1 or true (case insensitive) will be assumed to be + * true. + * + * @param delayWhileIdleExpression + */ + public void setDelayWhileIdleExpression(Expression delayWhileIdleExpression) { + Assert.notNull(delayWhileIdleExpression, "'delayWhileIdleExpression' is null"); + this.delayWhileIdleExpression = delayWhileIdleExpression; + } + + /** + * Populates the attributes in the map by evaluating the expressions + * + * @param requestMessage + * @return + */ + private Map getAttributes(Message requestMessage) { + Map attributes = null; + if(timeToLiveExpression != null + || collapseKeyExpression != null + || delayWhileIdleExpression != null) { + + attributes = new HashMap(); + + if(timeToLiveExpression != null) { + String ttl = timeToLiveExpression.getValue(context, requestMessage, String.class); + if(StringUtils.hasText(ttl)) { + attributes.put(TIME_TO_LIVE, ttl); + } + } + + if(collapseKeyExpression != null) { + String collapseKey = collapseKeyExpression.getValue(context, requestMessage, String.class); + if(StringUtils.hasText(collapseKey)) { + attributes.put(COLLAPSE_KEY, collapseKey); + } + } + + if(delayWhileIdleExpression != null) { + String delayWhileIdle = delayWhileIdleExpression.getValue(context, requestMessage, String.class); + if(StringUtils.hasText(delayWhileIdle)) { + attributes.put(DELAY_WHILE_IDLE, delayWhileIdle); + } + } + } + + return attributes != null && attributes.size() > 0 ? attributes : null; + } + /** + * Evaluates the {@link #receiverIdsExpression} on the message to get the receiver ids + * @param requestMessage + * @return + */ + private String[] getReceiverIds(Message requestMessage) { + Object executionValue = receiverIdsExpression.getValue(context, requestMessage); + String[] receiverIds; + if(executionValue == null) { + throw new MessagingException(requestMessage, "Execution of receiverIdsExpression on message didn't yield any receiver ids"); + } + if(executionValue instanceof String) { + receiverIds = new String[]{(String)executionValue}; + } + else if(executionValue instanceof String[]) { + receiverIds = (String[])executionValue; + } + else if(executionValue instanceof Collection) { + @SuppressWarnings("rawtypes") + Collection coll = (Collection)executionValue; + if(coll.size() != 0) { + receiverIds = new String[coll.size()]; + int index = 0; + for(Object receiverId:coll) { + if(receiverId instanceof String) { + receiverIds[index++] = (String)receiverId; + } + else { + throw new MessagingException(requestMessage, "The collection of receiverIds is expected to contain String values only, " + + "found an object of type " + receiverId.getClass().getName()); + } + } + } + else { + receiverIds = new String[0]; + } + } + else { + throw new MessagingException(requestMessage, "Only String, String[] or Collection are the allowed values, " + + "the expression evaluated to type " + executionValue.getClass().getName()); + } + + if(receiverIds.length == 0) { + throw new MessagingException(requestMessage, "Execution of receiverIdsExpression on message didn't yield any receiver ids"); + } + + return receiverIds; + } +} diff --git a/spring-integration-pushnotify/src/main/resources/META-INF/spring.handlers b/spring-integration-pushnotify/src/main/resources/META-INF/spring.handlers new file mode 100644 index 00000000..f3287059 --- /dev/null +++ b/spring-integration-pushnotify/src/main/resources/META-INF/spring.handlers @@ -0,0 +1 @@ +http\://www.springframework.org/schema/integration/pushnotify=org.springframework.integration.pushnotify.config.xml.IntegrationPushNotifyNamespaceHandler \ No newline at end of file diff --git a/spring-integration-pushnotify/src/main/resources/META-INF/spring.schemas b/spring-integration-pushnotify/src/main/resources/META-INF/spring.schemas new file mode 100644 index 00000000..bf70a644 --- /dev/null +++ b/spring-integration-pushnotify/src/main/resources/META-INF/spring.schemas @@ -0,0 +1,2 @@ +http\://www.springframework.org/schema/integration/pushnotify/spring-integration-pushnotify-1.0.xsd=org/springframework/integration/pushnotify/config/xml/spring-integration-pushnotify-1.0.xsd +http\://www.springframework.org/schema/integration/pushnotify/spring-integration-pushnotify.xsd=org/springframework/integration/pushnotify/config/xml/spring-integration-pushnotify-1.0.xsd \ No newline at end of file diff --git a/spring-integration-pushnotify/src/main/resources/org/springframework/integration/pushnotify/config/xml/spring-integration-pushnotify-1.0.xsd b/spring-integration-pushnotify/src/main/resources/org/springframework/integration/pushnotify/config/xml/spring-integration-pushnotify-1.0.xsd new file mode 100644 index 00000000..4f882e73 --- /dev/null +++ b/spring-integration-pushnotify/src/main/resources/org/springframework/integration/pushnotify/config/xml/spring-integration-pushnotify-1.0.xsd @@ -0,0 +1,172 @@ + + + + + + + + + + Defines the configuration elements for Spring + Integration's Push notify support + + + + + + + Defines the outbound gateway element for all the + services to push to various + handheld platforms + + + + + + + Identifies the underlying Spring bean + definition, which is an + instance of either 'EventDrivenConsumer' or + 'PollingConsumer', + depending on whether the component's input + channel is a + 'SubscribableChannel' or 'PollableChannel'. + + + + + + + The receiving Message Channel of this endpoint. + + + + + + + + + + + + Message Channel to which replies should be + sent, + after receiving the response from the push service + + + + + + + + + + + + + + + + + An enumeration of values that would identify the + service to connect + to push the messages to. + + + + + + + + + + + + The reference to a custom service implementation + + + + + + + + + + + + + The sender id of the GCM that would be used by the adapter to push + the messages + to the GCM. See GCM documentation or the README of the adapter for + more details. + + + + + + + + The sender id reference of the GCM that would be used by the adapter + to push the messages + to the GCM. See GCM documentation or the README of the adapter for + more details. + Either of this attribute or sender-id attributes is mandatory. + + + + + + + + + + + + + + + + + + + + + + The enumeration of the service types that is + supported by the library + + + + + + + + \ No newline at end of file diff --git a/spring-integration-pushnotify/src/test/java/org/springframework/integration/pushnotify/config/xml/DummyPushNotifyService.java b/spring-integration-pushnotify/src/test/java/org/springframework/integration/pushnotify/config/xml/DummyPushNotifyService.java new file mode 100644 index 00000000..7f75e504 --- /dev/null +++ b/spring-integration-pushnotify/src/test/java/org/springframework/integration/pushnotify/config/xml/DummyPushNotifyService.java @@ -0,0 +1,44 @@ +/* + * Copyright 2002-2012 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.integration.pushnotify.config.xml; + +import java.io.IOException; +import java.util.Map; + +import org.springframework.integration.pushnotify.PushNotifyService; +import org.springframework.integration.pushnotify.PushResponse; + +/** + * The dummy implementation of the push notify service for testing + * + * @author Amol Nayak + * + * @since 1.0 + * + */ +public class DummyPushNotifyService implements PushNotifyService { + + /* (non-Javadoc) + * @see org.springframework.integration.pushnotify.PushNotifyService#push(java.util.Map, java.util.Map, java.lang.String[]) + */ + @Override + public PushResponse push(Map message, + Map attributes, String... receiverIds) + throws IOException { + return null; + } + +} diff --git a/spring-integration-pushnotify/src/test/java/org/springframework/integration/pushnotify/config/xml/OutboundGatewayParserTests.java b/spring-integration-pushnotify/src/test/java/org/springframework/integration/pushnotify/config/xml/OutboundGatewayParserTests.java new file mode 100644 index 00000000..86555cec --- /dev/null +++ b/spring-integration-pushnotify/src/test/java/org/springframework/integration/pushnotify/config/xml/OutboundGatewayParserTests.java @@ -0,0 +1,136 @@ +/* + * Copyright 2002-2012 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.integration.pushnotify.config.xml; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.springframework.integration.test.util.TestUtils.getPropertyValue; + +import org.junit.Test; +import org.springframework.beans.factory.parsing.BeanDefinitionParsingException; +import org.springframework.context.support.ClassPathXmlApplicationContext; +import org.springframework.expression.Expression; +import org.springframework.expression.common.LiteralExpression; +import org.springframework.expression.spel.standard.SpelExpression; +import org.springframework.integration.endpoint.EventDrivenConsumer; +import org.springframework.integration.pushnotify.PushNotifyService; +import org.springframework.integration.pushnotify.gcm.GCMPushNotifyServiceImpl; +import org.springframework.integration.pushnotify.gcm.outbound.GCMOutboundGateway; + +/** + * Test cases for parser definitions + * + * @author Amol Nayak + * + */ +public class OutboundGatewayParserTests { + + @Test + public void withDefaultService() { + ClassPathXmlApplicationContext ctx = new ClassPathXmlApplicationContext("pushnotify-simple-valid-definition.xml"); + EventDrivenConsumer consumer = ctx.getBean("validOne", EventDrivenConsumer.class); + GCMOutboundGateway gateway = getPropertyValue(consumer, "handler", GCMOutboundGateway.class); + assertNotNull(gateway); + PushNotifyService service = getPropertyValue(gateway, "service", PushNotifyService.class); + assertNotNull(service); + assertEquals(GCMPushNotifyServiceImpl.class, service.getClass()); + assertEquals("abc", getPropertyValue(service, "senderId", String.class)); + ctx.close(); + } + + @Test(expected=BeanDefinitionParsingException.class) + public void withoutSenderId() { + new ClassPathXmlApplicationContext("pushnotify-without-senderid.xml"); + } + + @Test(expected=BeanDefinitionParsingException.class) + public void withBothSenderIdAndRef() { + new ClassPathXmlApplicationContext("pushnotify-with-both-senderid-and-ref.xml"); + } + + @Test(expected=BeanDefinitionParsingException.class) + public void withBothSenderIdAndServiceRef() { + new ClassPathXmlApplicationContext("pushnotify-with-service-ref-and-senderid.xml"); + } + + @Test + public void withServiceRef() { + ClassPathXmlApplicationContext ctx = new ClassPathXmlApplicationContext("pushnotify-with-service-ref.xml"); + EventDrivenConsumer consumer = ctx.getBean("validOne", EventDrivenConsumer.class); + GCMOutboundGateway gateway = getPropertyValue(consumer, "handler", GCMOutboundGateway.class); + assertNotNull(gateway); + PushNotifyService service = getPropertyValue(gateway, "service", PushNotifyService.class); + assertNotNull(service); + assertEquals(DummyPushNotifyService.class, service.getClass()); + ctx.close(); + } + + @Test + public void withAllAttributes() { + ClassPathXmlApplicationContext ctx = new ClassPathXmlApplicationContext("pushnotify-with-all-attrs.xml"); + EventDrivenConsumer consumer = ctx.getBean("validOne", EventDrivenConsumer.class); + GCMOutboundGateway gateway = getPropertyValue(consumer, "handler", GCMOutboundGateway.class); + assertNotNull(gateway); + + Expression expr = getPropertyValue(gateway, "delayWhileIdleExpression", Expression.class); + assertNotNull(expr); + assertEquals(SpelExpression.class,expr.getClass()); + assertEquals("headers['dwi']", getPropertyValue(expr, "expression")); + + expr = getPropertyValue(gateway, "collapseKeyExpression", Expression.class); + assertNotNull(expr); + assertEquals(LiteralExpression.class,expr.getClass()); + assertEquals("ColKey", getPropertyValue(expr, "literalValue")); + + expr = getPropertyValue(gateway, "timeToLiveExpression", Expression.class); + assertNotNull(expr); + assertEquals(LiteralExpression.class,expr.getClass()); + assertEquals("10000", getPropertyValue(expr, "literalValue")); + + ctx.close(); + } + + @Test(expected=BeanDefinitionParsingException.class) + public void withBothBothLiteralAndExpressionAttribute() { + new ClassPathXmlApplicationContext("pushnotify-with-literal-and-expr.xml"); + } + + @Test + public void withLiteralReceiverId() { + ClassPathXmlApplicationContext ctx = new ClassPathXmlApplicationContext("pushnotify-with-literal-receiverid.xml"); + EventDrivenConsumer consumer = ctx.getBean("validOne", EventDrivenConsumer.class); + GCMOutboundGateway gateway = getPropertyValue(consumer, "handler", GCMOutboundGateway.class); + assertNotNull(gateway); + + Expression expr = getPropertyValue(gateway, "receiverIdsExpression", Expression.class); + assertNotNull(expr); + assertEquals(LiteralExpression.class,expr.getClass()); + assertEquals("SomeRecId", getPropertyValue(expr, "literalValue")); + } + + @Test + public void withExpressionReceiverId() { + ClassPathXmlApplicationContext ctx = new ClassPathXmlApplicationContext("pushnotify-with-receiverid-expr.xml"); + EventDrivenConsumer consumer = ctx.getBean("validOne", EventDrivenConsumer.class); + GCMOutboundGateway gateway = getPropertyValue(consumer, "handler", GCMOutboundGateway.class); + assertNotNull(gateway); + + Expression expr = getPropertyValue(gateway, "receiverIdsExpression", Expression.class); + assertNotNull(expr); + assertEquals(SpelExpression.class,expr.getClass()); + assertEquals("headers['recid']", getPropertyValue(expr, "expression")); + } +} diff --git a/spring-integration-pushnotify/src/test/java/org/springframework/integration/pushnotify/gcm/GCMOutboundGatewayTests.java b/spring-integration-pushnotify/src/test/java/org/springframework/integration/pushnotify/gcm/GCMOutboundGatewayTests.java new file mode 100644 index 00000000..f87ddd70 --- /dev/null +++ b/spring-integration-pushnotify/src/test/java/org/springframework/integration/pushnotify/gcm/GCMOutboundGatewayTests.java @@ -0,0 +1,229 @@ +/* + * Copyright 2002-2013 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.integration.pushnotify.gcm; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.Matchers.anyMap; +import static org.mockito.Matchers.anyVararg; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.integration.pushnotify.gcm.GCMPushNotifyServiceImpl.COLLAPSE_KEY; +import static org.springframework.integration.pushnotify.gcm.GCMPushNotifyServiceImpl.DELAY_WHILE_IDLE; +import static org.springframework.integration.pushnotify.gcm.GCMPushNotifyServiceImpl.TIME_TO_LIVE; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.junit.BeforeClass; +import org.junit.Test; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.common.LiteralExpression; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.integration.MessagingException; +import org.springframework.integration.pushnotify.PushNotifyService; +import org.springframework.integration.pushnotify.gcm.outbound.GCMOutboundGateway; +import org.springframework.integration.support.MessageBuilder; + +/** + * The test cases for the {@link GCMOutboundGateway} + * + * @author Amol Nayak + * + * @since 1.0 + * + */ +public class GCMOutboundGatewayTests { + + private static PushNotifyService service; + private static List receiverIds = new ArrayList(); + private static Map payload; + private static Map attributes; + private static GCMOutboundGateway gateway; + + @SuppressWarnings("unchecked") + @BeforeClass + public static void setup() throws IOException { + service = mock(PushNotifyService.class); + when(service.push(anyMap(), anyMap(), (String[])anyVararg())) + .then(new Answer() { + + @SuppressWarnings("rawtypes") + @Override + public GCMPushResponse answer(InvocationOnMock invocation) + throws Throwable { + Object[] args = invocation.getArguments(); + GCMOutboundGatewayTests.payload = (Map)args[0]; + GCMOutboundGatewayTests.attributes = (Map)args[1]; + GCMOutboundGatewayTests.receiverIds.clear(); + for(int i = 2; i < args.length; i++) { + GCMOutboundGatewayTests.receiverIds.add((String)args[i]); + } + return null; + } + }); + + gateway = new GCMOutboundGateway(service); + gateway.afterPropertiesSet(); + } + + /** + * Use a receiver id in the header that is String and with default expression. + */ + @Test + public void withStringReceiverId() { + gateway.handleMessage(MessageBuilder.withPayload("Test").setHeader("receiverIds", "RecId").build()); + assertEquals(1, receiverIds.size()); + assertEquals("RecId", receiverIds.get(0)); + } + + + /** + * Use a receiver id in the header that is String[] and with default expression. + */ + @Test + public void withStringReceiverIdArray() { + gateway.handleMessage(MessageBuilder.withPayload("Test") + .setHeader("receiverIds", new String[]{"RecId1", "RecId2"}).build()); + assertEquals(2, receiverIds.size()); + assertEquals("RecId1", receiverIds.get(0)); + assertEquals("RecId2", receiverIds.get(1)); + } + + /** + * Use a receiver id in the header that is List and with default expression. + */ + @Test + public void withStringReceiverIdList() { + gateway.handleMessage(MessageBuilder.withPayload("Test") + .setHeader("receiverIds", Arrays.asList(new String[]{"RecId1", "RecId2"})).build()); + assertEquals(2, receiverIds.size()); + assertEquals("RecId1", receiverIds.get(0)); + assertEquals("RecId2", receiverIds.get(1)); + } + + /** + * From a message with no receiver ids, should throw a {@link MessagingException} + */ + @Test(expected=MessagingException.class) + public void withNoReceiverIds() { + gateway.handleMessage(MessageBuilder.withPayload("Test").build()); + } + + /** + * From a message with no receiver ids, should throw a {@link MessagingException} + */ + @Test(expected=MessagingException.class) + public void withEmptyCollectionOfReceiverIds() { + gateway.handleMessage(MessageBuilder.withPayload("Test") + .setHeader("receiverIds", Collections.EMPTY_LIST) + .build()); + } + + /** + * From a message that has the receiver ids of incorrect type, java.lang.Integer in this case + * + */ + @Test(expected=MessagingException.class) + public void withIncorrectTypeForReceiverId() { + List list = new ArrayList(); + list.add(1); + gateway.handleMessage(MessageBuilder.withPayload("Test") + .setHeader("receiverIds", list) + .build()); + } + + /** + * Sends a request with payload as a {@link Map} + */ + @Test + public void withMapPayload() { + gateway.handleMessage(MessageBuilder.withPayload(Collections.singletonMap("Data", "Test")) + .setHeader("receiverIds", "RecId") + .build()); + assertEquals(1, payload.size()); + assertEquals("Test", payload.get("Data")); + } + + + /** + * Sends a request with payload as a {@link Map} + */ + @Test + public void withStringPayloadWithDefaultKey() { + gateway.handleMessage(MessageBuilder.withPayload("Test") + .setHeader("receiverIds", "RecId") + .build()); + assertEquals(1, payload.size()); + assertEquals("Test", payload.get("Data")); + } + + /** + * Sends a request with payload as a {@link Map} + */ + @Test + public void withStringPayloadWithCustomKey() { + gateway.setDefaultKey("DefaultKey"); + gateway.handleMessage(MessageBuilder.withPayload("Test") + .setHeader("receiverIds", "RecId") + .build()); + assertEquals(1, payload.size()); + assertEquals("Test", payload.get("DefaultKey")); + } + + /** + * Sets the literal expression for the attributes to be sent to GCM + */ + @Test + public void withAttributesAsLiteralExpressions() { + gateway.setCollapseKeyExpression(new LiteralExpression("CollapseKey")); + gateway.setDelayWhileIdleExpression(new LiteralExpression("true")); + gateway.setTimeToLiveExpression(new LiteralExpression("12345")); + gateway.handleMessage(MessageBuilder.withPayload("Test") + .setHeader("receiverIds", "RecId") + .build()); + assertNotNull(attributes); + assertEquals("CollapseKey", attributes.get(COLLAPSE_KEY)); + assertEquals("true", attributes.get(DELAY_WHILE_IDLE)); + assertEquals("12345", attributes.get(TIME_TO_LIVE)); + } + + /** + * Sets the literal expression for the attributes to be sent to GCM + */ + @Test + public void withAttributesAsSPELExpressions() { + ExpressionParser parser = new SpelExpressionParser(); + gateway.setCollapseKeyExpression(parser.parseExpression("headers['ck']")); //Not for Calvin Klein, but for collapse key + gateway.setDelayWhileIdleExpression(new LiteralExpression("true")); + gateway.setTimeToLiveExpression(parser.parseExpression("headers['ttl']")); + gateway.handleMessage(MessageBuilder.withPayload("Test") + .setHeader("receiverIds", "RecId") + .setHeader("ck", "CollapseKey") + .setHeader("ttl", 10000) + .build()); + assertNotNull(attributes); + assertEquals("CollapseKey", attributes.get(COLLAPSE_KEY)); + assertEquals("true", attributes.get(DELAY_WHILE_IDLE)); + assertEquals("10000", attributes.get(TIME_TO_LIVE)); + } +} diff --git a/spring-integration-pushnotify/src/test/java/org/springframework/integration/pushnotify/gcm/GCMPushNotifyServiceImplTests.java b/spring-integration-pushnotify/src/test/java/org/springframework/integration/pushnotify/gcm/GCMPushNotifyServiceImplTests.java new file mode 100644 index 00000000..970f4d79 --- /dev/null +++ b/spring-integration-pushnotify/src/test/java/org/springframework/integration/pushnotify/gcm/GCMPushNotifyServiceImplTests.java @@ -0,0 +1,188 @@ +/* + * Copyright 2002-2013 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.integration.pushnotify.gcm; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.springframework.integration.pushnotify.gcm.GCMPushNotifyServiceImpl.COLLAPSE_KEY; +import static org.springframework.integration.pushnotify.gcm.GCMPushNotifyServiceImpl.DELAY_WHILE_IDLE; +import static org.springframework.integration.pushnotify.gcm.GCMPushNotifyServiceImpl.TIME_TO_LIVE; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; + +import org.junit.BeforeClass; +import org.junit.Test; +import org.springframework.core.io.ClassPathResource; +import org.springframework.integration.pushnotify.PushNotifyService; + +/** + * The test class for {@link GCMPushNotifyServiceImpl} + * + * @author Amol Nayak + * + * @since 1.0 + * + */ +public class GCMPushNotifyServiceImplTests { + + + private static String receiverId; + private static String senderId; + + @BeforeClass + public static void setup() throws IOException { + ClassPathResource res = new ClassPathResource("services.properties"); + Properties props = new Properties(); + props.load(res.getInputStream()); + senderId = props.getProperty("senderId"); + receiverId = props.getProperty("receiverId"); + } + + /** + * Should get {@link IllegalArgumentException} on passing null message + */ + @Test(expected=IllegalArgumentException.class) + public void withNullMessage() throws IOException { + PushNotifyService service = new GCMPushNotifyServiceImpl("1"); + service.push(null, null, "1"); + } + + /** + * Should get {@link IllegalArgumentException} on passing null message + */ + @Test(expected=IllegalArgumentException.class) + public void withNullReceiverId() throws IOException { + PushNotifyService service = new GCMPushNotifyServiceImpl("1"); + Map data = new HashMap(); + data.put("Data", "Some Data"); + data.put("Data2", "Some Data 2"); + service.push(data, null, (String)null); + } + + /** + * Doesn't use attributes, the message contents should successfully be pushed to the GCM service + */ + @Test + public void withNullAttributes() throws IOException { + PushNotifyService service = new GCMPushNotifyServiceImpl(senderId); + Map data = new HashMap(); + data.put("Data", "Some Data"); + data.put("Data2", "Some Data 2"); + GCMPushResponse response = (GCMPushResponse)service.push(data, null, receiverId); + assertNotNull(response); + assertEquals(200, response.getResponseCode()); + assertNotNull(response.getSentMessageIds().get(receiverId)); + assertEquals(1, response.getSuccessfulMessages()); + assertEquals(0, response.getFailedMessages()); + } + + /** + * Sends with invalid sender, should get 401 error + */ + @Test + public void withInvalidSenderId() throws IOException { + PushNotifyService service = new GCMPushNotifyServiceImpl("123"); + Map data = new HashMap(); + data.put("Data", "Some Data"); + data.put("Data2", "Some Data 2"); + GCMPushResponse response = (GCMPushResponse)service.push(data, null, receiverId); + assertNotNull(response); + assertEquals(401, response.getResponseCode()); + assertEquals(0, response.getSuccessfulMessages()); + assertEquals(1, response.getFailedMessages()); + } + + /** + * Sends request with an invalid device id + */ + @Test + public void withInvalidDeviceId() throws IOException { + PushNotifyService service = new GCMPushNotifyServiceImpl(senderId); + Map data = new HashMap(); + data.put("Data", "Some Data"); + data.put("Data2", "Some Data 2"); + GCMPushResponse response = (GCMPushResponse)service.push(data, null, "123"); + assertNotNull(response); + assertEquals(200, response.getResponseCode()); + assertNull(response.getSentMessageIds().get("123")); + assertEquals("InvalidRegistration", response.getErrorCodes().get("123")); + assertEquals(0, response.getSuccessfulMessages()); + assertEquals(1, response.getFailedMessages()); + } + + /** + * Sends request with an invalid key, the key name begins with google. + */ + @Test + public void withInvalidKey() throws IOException { + PushNotifyService service = new GCMPushNotifyServiceImpl(senderId); + Map data = new HashMap(); + data.put("google.Data", "Some Data"); + GCMPushResponse response = (GCMPushResponse)service.push(data, null, receiverId); + assertNotNull(response); + assertEquals(200, response.getResponseCode()); + assertNull(response.getSentMessageIds().get(receiverId)); + assertEquals("InvalidDataKey", response.getErrorCodes().get(receiverId)); + assertEquals(0, response.getSuccessfulMessages()); + assertEquals(1, response.getFailedMessages()); + } + + + /** + * Publishes with some additional unrecognized attribute, the message is posted successfully with + * a warning msg printed to console + */ + @Test + public void publishWithAdditionalAttributes() throws IOException { + PushNotifyService service = new GCMPushNotifyServiceImpl(senderId); + Map data = new HashMap(); + data.put("Data", "Some Data"); + data.put("Data2", "Some Data 2"); + Map attributes = new HashMap(); + attributes.put("ExtraAttr", "Attr1"); + GCMPushResponse response = (GCMPushResponse)service.push(data, attributes, receiverId); + assertNotNull(response); + assertEquals(200, response.getResponseCode()); + assertNotNull(response.getSentMessageIds().get(receiverId)); + assertEquals(1, response.getSuccessfulMessages()); + assertEquals(0, response.getFailedMessages()); + } + + /** + * + */ + @Test + public void publishToMultipleInvalidSenders() throws IOException { + PushNotifyService service = new GCMPushNotifyServiceImpl(senderId); + Map data = new HashMap(); + data.put("Data", "Some Data"); + data.put("Data2", "Some Data 2"); + Map attributes = new HashMap(); + attributes.put(TIME_TO_LIVE, "1000"); + attributes.put(COLLAPSE_KEY, "Update Available"); + attributes.put(DELAY_WHILE_IDLE, "1"); + GCMPushResponse response = (GCMPushResponse)service.push(data, attributes, receiverId, "123"); + assertNotNull(response); + assertEquals(1, response.getSuccessfulMessages()); + assertEquals(1, response.getFailedMessages()); + assertNotNull(response.getSentMessageIds().get(receiverId)); + assertEquals("InvalidRegistration", response.getErrorCodes().get("123")); + } +} diff --git a/spring-integration-pushnotify/src/test/resources/log4j.properties b/spring-integration-pushnotify/src/test/resources/log4j.properties new file mode 100644 index 00000000..fed1d7e6 --- /dev/null +++ b/spring-integration-pushnotify/src/test/resources/log4j.properties @@ -0,0 +1,8 @@ +log4j.rootCategory=DEBUG, stdout + +log4j.appender.stdout=org.apache.log4j.ConsoleAppender +log4j.appender.stdout.layout=org.apache.log4j.PatternLayout +log4j.appender.stdout.layout.ConversionPattern=%d %p [%c] - <%m>%n + +log4j.category.org.springframework=WARN +log4j.category.org.springframework.integration.pushnotify=DEBUG diff --git a/spring-integration-pushnotify/src/test/resources/pushnotify-simple-valid-definition.xml b/spring-integration-pushnotify/src/test/resources/pushnotify-simple-valid-definition.xml new file mode 100644 index 00000000..4ce739cf --- /dev/null +++ b/spring-integration-pushnotify/src/test/resources/pushnotify-simple-valid-definition.xml @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/spring-integration-pushnotify/src/test/resources/pushnotify-with-all-attrs.xml b/spring-integration-pushnotify/src/test/resources/pushnotify-with-all-attrs.xml new file mode 100644 index 00000000..2b05b259 --- /dev/null +++ b/spring-integration-pushnotify/src/test/resources/pushnotify-with-all-attrs.xml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/spring-integration-pushnotify/src/test/resources/pushnotify-with-both-senderid-and-ref.xml b/spring-integration-pushnotify/src/test/resources/pushnotify-with-both-senderid-and-ref.xml new file mode 100644 index 00000000..88dec88f --- /dev/null +++ b/spring-integration-pushnotify/src/test/resources/pushnotify-with-both-senderid-and-ref.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + diff --git a/spring-integration-pushnotify/src/test/resources/pushnotify-with-literal-and-expr.xml b/spring-integration-pushnotify/src/test/resources/pushnotify-with-literal-and-expr.xml new file mode 100644 index 00000000..fb418ee8 --- /dev/null +++ b/spring-integration-pushnotify/src/test/resources/pushnotify-with-literal-and-expr.xml @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/spring-integration-pushnotify/src/test/resources/pushnotify-with-literal-receiverid.xml b/spring-integration-pushnotify/src/test/resources/pushnotify-with-literal-receiverid.xml new file mode 100644 index 00000000..23935b16 --- /dev/null +++ b/spring-integration-pushnotify/src/test/resources/pushnotify-with-literal-receiverid.xml @@ -0,0 +1,20 @@ + + + + + + + + diff --git a/spring-integration-pushnotify/src/test/resources/pushnotify-with-receiverid-expr.xml b/spring-integration-pushnotify/src/test/resources/pushnotify-with-receiverid-expr.xml new file mode 100644 index 00000000..9fce4736 --- /dev/null +++ b/spring-integration-pushnotify/src/test/resources/pushnotify-with-receiverid-expr.xml @@ -0,0 +1,20 @@ + + + + + + + + diff --git a/spring-integration-pushnotify/src/test/resources/pushnotify-with-service-ref-and-senderid.xml b/spring-integration-pushnotify/src/test/resources/pushnotify-with-service-ref-and-senderid.xml new file mode 100644 index 00000000..6cc83a58 --- /dev/null +++ b/spring-integration-pushnotify/src/test/resources/pushnotify-with-service-ref-and-senderid.xml @@ -0,0 +1,22 @@ + + + + + + + + + + diff --git a/spring-integration-pushnotify/src/test/resources/pushnotify-with-service-ref.xml b/spring-integration-pushnotify/src/test/resources/pushnotify-with-service-ref.xml new file mode 100644 index 00000000..4efe6647 --- /dev/null +++ b/spring-integration-pushnotify/src/test/resources/pushnotify-with-service-ref.xml @@ -0,0 +1,21 @@ + + + + + + + + + + diff --git a/spring-integration-pushnotify/src/test/resources/pushnotify-without-senderid.xml b/spring-integration-pushnotify/src/test/resources/pushnotify-without-senderid.xml new file mode 100644 index 00000000..9a82de60 --- /dev/null +++ b/spring-integration-pushnotify/src/test/resources/pushnotify-without-senderid.xml @@ -0,0 +1,19 @@ + + + + + + + + +