In this tutorial, we're going to build a chat using Pusher's Presence Channels. Using Pusher, our chat will be able to request e-signed Non-Disclosure Agreements (NDAs) to its members using the HelloSign API.
You will need the following to take on this project:
We're going to use Pusher and HelloSign webhooks to receive events from these APIs, and we'll use ngrok to keep everything in a local environment.
The app's design is based on this pen, and it works in the following way:
First, a user creates a chat:
Then, another user joins the chat:
At any time, the chat owner can send a request to sign an NDA:
When a member of the chat signs the NDA, a notification is sent with a link to view the signed document:
We won't list the complete source code for all the files, only the relevant parts. However, you can find the entire code of the application on Github.
You'll need JDK 7 or higher (JDK 8 is preferred), as well as Maven 3.0 or higher.
Create a free account at https://dashboard.pusher.com/accounts/sign_up.
When you first log in, you'll be asked to enter some configuration options:
Enter a name, choose Javascript as your front-end tech, and Java as your back-end tech.
Then go to either the Getting Started or App Keys tab to copy your App ID, Key, and Secret credentials; we'll need them later.
Sign up at https://www.hellosign.com/. At the time of this writing, your free account has the following limitations:
But don't worry, these limitations don't apply in test mode, which is the mode we're going to use.
When a member is added to the chat or an NDA is signed, a webhook will be triggered (think of a webhook as a callback). This means an HTTP request will be made to our server, so we'll need to deploy our application on the cloud or keep it locally and use a service like ngrok to make it publicly available.
Ngrok proxies external requests to your local machine by creating a secure tunnel and giving you a public URL.
ngrok is a Go program, distributed as a single executable file (no additional dependencies). For now, just download it from https://ngrok.com/download and unzip the compressed file.
Now that we have all we need, let's create the app.
One of the easiest ways to create a Spring Boot app is to use the project generator at https://start.spring.io/.
Go to that page and choose to generate a Maven project with the following dependencies:
Enter a Group ID, an Artifact ID and generate the project:
Unzip the content of the downloaded file. At this point, you can import the project to an IDE if you want. For example, in Eclipse, go to File -> Import and choose Existing Maven Projects:
Let's add some configurations to the pom.xml
file. In the properties
section, change the Java version if you're not using Java 8 and add the following line:
1<spring.version>4.3.1.RELEASE</spring.version>
The latest version of the Spring Framework at the time of this writing is 4.3.1.RELEASE
. The above line will ensure Spring Boot uses this version.
Also, in dependencies
, add the dependencies we'll need for our project:
1<dependency>
2 <groupId>org.apache.commons</groupId>
3 <artifactId>commons-lang3</artifactId>
4 <version>3.4</version>
5</dependency>
6
7<dependency>
8 <groupId>com.pusher</groupId>
9 <artifactId>pusher-http-java</artifactId>
10 <version>1.0.0</version>
11</dependency>
12
13<dependency>
14 <groupId>com.hellosign</groupId>
15 <artifactId>hellosign-java-sdk</artifactId>
16 <version>3.4.0</version>
17</dependency>
Now, onto the project organization. Inside src/main/java
, we'll work with the following package structure:
com.example.config
will contain configuration classescom.example.constants
will contain interfaces with constants values used in the appcom.example.model
will contain the JPA entity modelscom.example.repository
will contain the Spring JPA interfaces to work with the modelcom.example.service
will contain the business service classes of the appcom.example.web
will contain the Spring MVC controllers of the appcom.example.web.vo
will contain the objects used for communication between the view and the controllersInside src/main/resources
, we'll put some configuration files in addition to the following directory structure:
static/css
will contain the CSS style files used in the applicationstatic/img
will contain the images used in the applicationstatic/js
will contain the Javascript files used in the applicationtemplates
will contain the Thymeleaf templates used in the applicationWe'll first create the com/example/web/ChatController
class with the following content:
1@Controller
2public class ChatController {
3
4 @RequestMapping(method=RequestMethod.GET, value="/")
5 public ModelAndView index() {
6 ModelAndView modelAndView = new ModelAndView();
7
8 modelAndView.setViewName("index");
9 modelAndView.addObject("text", "Hello World!");
10
11 return modelAndView;
12 }
13}
This controller defines a /
route that shows an index
template. Next, create the file src/main/resources/templates/index.html
with some HTML content like:
1<!DOCTYPE HTML>
2<html xmlns:th="http://www.thymeleaf.org">
3<head>
4 <title>NDA Chat</title>
5 <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
6</head>
7<body>
8 <p th:text="${text}" />
9</body>
10</html>
Using Thymeleaf syntax, this will print the text
variable defined in the controller. You can learn more about Thymeleaf here.
Run the application either by executing the com.example.NdaChatApplication
class on your IDE or on the command line with:
1$ mvn spring-boot:run
Additionally, you can create a JAR file and execute it using command line:
1$ mvn package -DskipTests
2$ java -jar target/nda-chat-0.0.1-SNAPSHOT.jar
When you open http://localhost:8080/
in a browser, you should see something like the following:
Now, let's take a moment to configure ngrok.
In a new terminal window, navigate to the directory where you unzipped ngrok.
We'll start ngrok by telling it which port we want to expose to the Internet. For our app, that would be port 8080
:
1./ngrok http 8080
Alternatively, if you're on Windows:
1ngrok http 8080
Now, you should see something like this:
See that URL in the Forwarding row(s) with the ngrok.io
domain? That's your public URL. Your public URL will likely be different than the one you see in the example. That's because ngrok generates a random URL every time you run it.
If you open in a browser http://[YOUR_GENERATED_SUBDOMAIN].ngrok.io
, you should see the same page found on http://localhost:8080
:
Open this ngrok URL in another computer if you want. Once again, you should see the same page. This means that our local server is now available publicly. The only disadvantage to using ngrok for server exposure is that this URL is not permanent. If you restart ngrok, it will give you another URL.
You can specify a subdomain, for example, to get the URL http://chat.ngrok.io
use the command:
1ngrok http -subdomain=chat 8080
However, this requires a paid plan. You can get more info in this page.
Nevertheless, as long as you don't stop or restart ngrok, the URL won't change, so let's leave it running for now.
In the next section, we'll go deeper into the code of the application starting with the database layer.
We'll use H2 as the database for our application. It can work as an embedded in-memory database, which is perfect for our purposes.
Open the src/main/resources/application.properties
file and enter the following options:
1spring.datasource.platform=h2
2
3spring.jpa.hibernate.ddl-auto="none"
4
5spring.h2.console.enabled=true
6
7spring.jpa.properties.hibernate.show_sql=true
8spring.jpa.properties.hibernate.use_sql_comments=true
9spring.jpa.properties.hibernate.format_sql=true
10spring.jpa.properties.hibernate.type=trace
In Spring Boot, by default, JPA databases will be created automatically if you use an embedded database (such as H2, HSQL or Derby). If they are in the classpath, Spring Boot will execute the files schema.sql
(to define the structure of the database) and data.sql
(to insert initial data).
However, we can define profiles to run distinct scripts for different databases. That's the purpose of the line:
1spring.datasource.platform=h2
It tells Spring Boot to execute the files schema-h2.sql
and data-h2.sql
. For this application, create the file src/main/resources/schema-h2.sql
with the following content:
1CREATE TABLE chat(
2 chat_id BIGINT IDENTITY PRIMARY KEY,
3 chat_name VARCHAR(50) NOT NULL,
4 chat_description VARCHAR(150) NOT NULL,
5 chat_active BOOLEAN NOT NULL,
6 created_at TIMESTAMP NOT NULL
7);
8
9CREATE TABLE user(
10 user_id BIGINT IDENTITY PRIMARY KEY,
11 user_email VARCHAR(50) NOT NULL,
12 user_name VARCHAR(50) NOT NULL,
13 user_active BOOLEAN NOT NULL,
14 nda_signed BOOLEAN NOT NULL,
15 owns_chat BOOLEAN NOT NULL,
16 chat_id BIGINT NOT NULL,
17 sign_id VARCHAR(255),
18 created_at TIMESTAMP NOT NULL,
19 CONSTRAINT FK_user_chat FOREIGN KEY (chat_id) REFERENCES chat(chat_id)
20);
21
22CREATE TABLE message(
23 message_id BIGINT IDENTITY PRIMARY KEY,
24 chat_id BIGINT NOT NULL,
25 user_id BIGINT NOT NULL,
26 message_text VARCHAR(1000) NOT NULL,
27 created_at TIMESTAMP NOT NULL,
28 CONSTRAINT FK_message_chat FOREIGN KEY (chat_id) REFERENCES chat(chat_id),
29 CONSTRAINT FK_message_user FOREIGN KEY (user_id) REFERENCES user(user_id)
30);
By default, Hibernate (the JPA implementation used by Spring Boot), will try to create the schema. Since Spring Boot will be responsible for that, we have to turn off this feature using:
1spring.jpa.hibernate.ddl-auto="none"
H2 provides a browser-based console that Spring Boot can auto-configure for you at the path /h2-console
. However for auto-configuration to work, these conditions need to be met:
com.h2database:h2
is on the classpathSince we're not using Spring Boot's developer tools, we have to explicitly configure it with:
1spring.h2.console.enabled=true
The rest of the application.properties
files tell Hibernate to print in the console the generated SQL statements for debugging purposes.
Now that we have our schema, let's create the JPA entities that will represent the database tables.
Create the src/main/java/com/example/model/Chat.java
file with the following content:
1@Entity
2public class Chat implements Serializable {
3
4 /** ID of the chat */
5 @Id
6 @Column(name="chat_id")
7 @GeneratedValue(strategy = GenerationType.IDENTITY)
8 private Long id;
9
10 /** Name of the chat */
11 @Column(name="chat_name", nullable = false)
12 private String name;
13
14 /** Description of the chat */
15 @Column(name="chat_description", nullable = false)
16 private String description;
17
18 /** Indicates if the chat is active */
19 @Column(name="chat_active", nullable = false)
20 private Boolean active;
21
22 /** The date and time when the chat was created */
23 @Column(name="created_at", nullable = false)
24 private Date createdAt;
25
26 /** Members of the chat */
27 @OneToMany(fetch = FetchType.LAZY,
28 cascade = CascadeType.ALL,
29 mappedBy = "chat",
30 orphanRemoval = true)
31 private Set<User> members = new LinkedHashSet<User>();
32
33 /**
34 * Helper method to add a member
35 * @param user Member to add to the chat
36 */
37 public void addMember(User user) {
38 this.members.add(user);
39 user.setChat(this);
40 }
41
42 // Getters and Setters ...
43
44 @Override
45 public String toString(){
46 return new ToStringBuilder(this).
47 append("id", id).
48 append("name", name).
49 append("description", description).
50 append("active", active).
51 append("createdAt", createdAt).
52 toString();
53 }
54
55 @Override
56 public boolean equals(Object obj) {
57 if (obj == null) { return false; }
58 if (obj == this) { return true; }
59 if (obj.getClass() != getClass()) {
60 return false;
61 }
62 Chat that = (Chat) obj;
63 return new EqualsBuilder()
64 .appendSuper(super.equals(obj))
65 .append(id, that.id)
66 .isEquals();
67 }
68
69 @Override
70 public int hashCode() {
71 return new HashCodeBuilder(5, 13).
72 append(id).
73 toHashCode();
74 }
75}
Notice how the one-to-many relationship with the User
entity is set up and the helper method establishes it. In addition, as the good practices dictate, we're also defining the toString()
, equals()
, and hashCode()
methods with classes from the commons-lang library.
The src/main/java/com/example/model/User.java
file contains the following code:
1@Entity
2public class User implements Serializable {
3
4 /** ID of the user */
5 @Id
6 @Column(name="user_id")
7 @GeneratedValue(strategy = GenerationType.IDENTITY)
8 private Long id;
9
10 /** Email of the user */
11 @Column(name="user_email", nullable = false)
12 private String email;
13
14 /** Name of the user */
15 @Column(name="user_name", nullable = false)
16 private String name;
17
18 /** Indicates if the user is active */
19 @Column(name="user_active", nullable = false)
20 private Boolean active;
21
22 /** Indicates if the user has signed the NDA */
23 @Column(name="nda_signed", nullable = false)
24 private Boolean ndaSigned;
25
26 /** Indicates if the user is the owner of the chat */
27 @Column(name="owns_chat", nullable = false)
28 private Boolean ownsChat;
29
30 /** ID of the HelloSign request to sign the NDA */
31 @Column(name="sign_id", nullable = true)
32 private String signId;
33
34 /** Date and time of the creation of the user */
35 @Column(name="created_at", nullable = false)
36 private Date createdAt;
37
38 /** Object that represents the chat the user belongs to */
39 @ManyToOne
40 @JoinColumn(name = "chat_id",
41 nullable = false, updatable = false)
42 private Chat chat;
43
44 // Getters and Setters ...
45
46 @Override
47 public String toString(){
48 return new ToStringBuilder(this).
49 append("id", id).
50 append("email", email).
51 append("name", name).
52 append("active", active).
53 append("ndaSigned", ndaSigned).
54 append("ownsChat", ownsChat).
55 append("createdAt", createdAt).
56 toString();
57 }
58
59 @Override
60 public boolean equals(Object obj) {
61 if (obj == null) { return false; }
62 if (obj == this) { return true; }
63 if (obj.getClass() != getClass()) {
64 return false;
65 }
66 User that = (User) obj;
67 return new EqualsBuilder()
68 .appendSuper(super.equals(obj))
69 .append(id, that.id)
70 .isEquals();
71 }
72
73 @Override
74 public int hashCode() {
75 return new HashCodeBuilder(3, 11).
76 append(id).
77 toHashCode();
78 }
79}
In turn, src/main/java/com/example/model/Message.java
contains:
1@Entity
2public class Message implements Serializable {
3
4 /** ID of the message */
5 @Id
6 @Column(name="message_id")
7 @GeneratedValue(strategy = GenerationType.IDENTITY)
8 private Long id;
9
10 /** ID of the chat that the message belongs to */
11 @Column(name="chat_id", nullable = false)
12 private Long idChat;
13
14 /** Text of the message */
15 @Column(name="message_text", nullable = false)
16 private String message;
17
18 /** Date and time when the message was created */
19 @Column(name="created_at", nullable = false)
20 private Date createdAt;
21
22 /** Object that represents the user that post the message */
23 @ManyToOne(fetch = FetchType.EAGER)
24 @JoinColumn(name = "user_id",
25 nullable = false, updatable = false)
26 private User user;
27
28 // Getters and Setters ...
29
30 public String getCreatedAtString() {
31 String time = "";
32 if(this.createdAt != null) {
33 DateFormat df = new SimpleDateFormat("hh:mm a");
34 time = df.format(this.createdAt);
35 }
36
37 return time;
38 }
39
40 public String getMessageFormatted() {
41 String msg = "";
42
43 if(this.message != null) {
44 msg = this.message.replaceAll("(\r?\n)", "<br />");
45 }
46
47 return msg;
48 }
49
50 @Override
51 public String toString(){
52 return new ToStringBuilder(this).
53 append("id", id).
54 append("idChat", idChat).
55 append("message", message).
56 append("createdAt", createdAt).
57 toString();
58 }
59
60 @Override
61 public boolean equals(Object obj) {
62 if (obj == null) { return false; }
63 if (obj == this) { return true; }
64 if (obj.getClass() != getClass()) {
65 return false;
66 }
67 Message that = (Message) obj;
68 return new EqualsBuilder()
69 .appendSuper(super.equals(obj))
70 .append(id, that.id)
71 .isEquals();
72 }
73
74 @Override
75 public int hashCode() {
76 return new HashCodeBuilder(7, 15).
77 append(id).
78 toHashCode();
79 }
80}
The repository objects will be handled by Spring Data JPA, which reduces the amount of boilerplate code by generating the code to implement the data-access layer code from interfaces you define. You can read about how to work with Spring Data in this page.
By default, Spring Data creates the following methods for basic Create, Read, Update, and Delete (CRUD) functionality:
1<S extends T> S save(S entity);
2T findOne(ID primaryKey);
3Iterable<T> findAll();
4Long count();
5void delete(T entity);
6boolean exists(ID primaryKey);
This way, we just have to define the business methods used by the application.
For the chat repository, our business methods would be:
1@Repository
2public interface ChatRepository extends CrudRepository<Chat, Long> {
3
4 /**
5 * Finds all active chats
6 * @return a List of Chat objects ordered by their ID
7 */
8 List<Chat> findByActiveTrueOrderById();
9
10 /**
11 * Finds an active chat by its name
12 * @param chatName Name of the chat
13 * @return List of Chat objects that meet the search criteria
14 */
15 List<Chat> findByNameAndActiveTrue(String chatName);
16}
For the user repository:
1@Repository
2public interface UserRepository extends CrudRepository<User, Long> {
3
4 /**
5 * Finds active members of a chat who haven't sign the NDA and are not the chat owner
6 * @param chat the Chat object that the users belongs to
7 * @return A List of User objects that represent the members of the chat
8 */
9 List<User> findByChatAndNdaSignedFalseAndActiveTrueAndOwnsChatFalse(Chat chat);
10
11 /**
12 * Finds users by their sign request ID
13 * @param signId HelloSign sign request ID
14 * @return A List of User objects that meet the search criteria
15 */
16 @EntityGraph(attributePaths = { "chat" })
17 List<User> findBySignId(String signId);
18}
The @EntityGraph
annotation indicates that the chat relationship object must be fetched.
For the message repository:
1@Repository
2public interface MessageRepository extends CrudRepository<Message, Long> {
3
4 /**
5 * Finds the messages of a chat
6 * @param idChat ID of the chat
7 * @return a List of Message objects that meet the search criteria
8 */
9 List<Message> findByIdChatOrderByCreatedAt(Long idChat);
10}
In the next section, we're going to review the service layer of our chat application.
The service layer is just a thin wrapper of the database layer. Its main purpose is to provide transactional capabilities to the repository methods.
We're going to set up two services: the chat service and the user service.
1@Service
2@Transactional
3public class ChatService {
4
5 @Autowired
6 private UserRepository userRepository;
7
8 @Autowired
9 private ChatRepository chatRepository;
10
11 @Autowired
12 private MessageRepository messageRepository;
13
14 /**
15 * Saves(creates or modify) a chat object
16 * @param chat The object to save
17 */
18 public void saveChat(Chat chat) {
19 chatRepository.save(chat);
20 }
21
22 /**
23 * Add a new user to a chat
24 * @param id ID of the chat
25 * @param user Object that represents the user to add
26 * @return the Chat object with the new user added
27 */
28 public Chat addNewUserToChat(Long id, User user) {
29 Chat chat = getChat(id);
30 chat.addMember(user);
31
32 userRepository.save(user);
33
34 return chat;
35 }
36
37 /**
38 * Saves a message and its relation to a user
39 * @param msg The message to save
40 * @param idUser The ID of the user that post the message
41 */
42 public void saveMessage(Message msg, Long idUser) {
43 User user = userRepository.findOne(idUser);
44 msg.setUser(user);
45
46 messageRepository.save(msg);
47 }
48
49 /**
50 * Marks an active chat as inactive
51 * @param chatName The name of the chat to mark
52 */
53 public void markChatAsInactive(String chatName) {
54 List<Chat> chats = chatRepository.findByNameAndActiveTrue(chatName);
55
56 if(chats != null && !chats.isEmpty()) {
57 Chat chat = chats.get(0);
58 chat.setActive(Boolean.FALSE);
59 chatRepository.save(chat);
60 }
61 }
62
63 /**
64 * Finds a chat by its ID
65 * @param id ID of the chat to find
66 * @return a chat object
67 */
68 public Chat getChat(Long id) {
69 return chatRepository.findOne(id);
70 }
71
72 /**
73 * Get all the active chats
74 * @return a List of chat objects
75 */
76 public List<Chat> getAllActiveChats() {
77 return chatRepository.findByActiveTrueOrderById();
78 }
79
80 /**
81 * Find an active chat by name
82 * @param chatName Name of the chat
83 * @return a chat object
84 */
85 public Chat getActiveChatByName(String chatName) {
86 List<Chat> chats = chatRepository.findByNameAndActiveTrue(chatName);
87 Chat chat = null;
88
89 if(chats != null && !chats.isEmpty()) {
90 chat = chats.get(0);
91 }
92
93 return chat;
94 }
95
96 /**
97 * Gets all the messages of a chat
98 * @param idChat ID of the chat
99 * @return a List of message objects
100 */
101 public List<Message> getAllChatMessages(Long idChat) {
102 return messageRepository.findByIdChatOrderByCreatedAt(idChat);
103 }
104}
1@Service
2@Transactional
3public class UserService {
4
5 @Autowired
6 private UserRepository userRepository;
7
8 @Autowired
9 private ChatRepository chatRepository;
10
11 /**
12 * Marks a user as inactive
13 * @param idUser ID of the user
14 */
15 public void markUserAsInactive(Long idUser) {
16 User user = userRepository.findOne(idUser);
17
18 if(user != null) {
19 user.setActive(Boolean.FALSE);
20 userRepository.save(user);
21 }
22 }
23
24 /**
25 * Finds all the active members of a chat that haven't sign the NDA
26 * @param idChat ID of the chat
27 * @return a List of User objects
28 */
29 public List<User> getChatMembersToSignNda(Long idChat) {
30 Chat chat = chatRepository.findOne(idChat);
31
32 return userRepository.findByChatAndNdaSignedFalseAndActiveTrueAndOwnsChatFalse(chat);
33 }
34
35 /**
36 * Gets a user by the HelloSign ID of her signature request
37 * @param signId ID of the signature request
38 * @return a User object
39 */
40 public User getUserBySignId(String signId) {
41 List<User> users = userRepository.findBySignId(signId);
42 User user = null;
43
44 if(users != null && !users.isEmpty()) {
45 user = users.get(0);
46 }
47
48 return user;
49 }
50
51 /**
52 * Saves(creates or modify) a user object
53 * @param user The object to save
54 */
55 public void saveUser(User user) {
56 userRepository.save(user);
57 }
58}
Finally, in the src/main/java/com/example/constants/GeneralConstants.java
and src/main/java/com/example/constants/HelloSignConstants.java
files, we'll define the constants we'll use in the next sections:
1public interface GeneralConstants {
2
3 /** Channel prefix required by Pusher for presence chats */
4 String CHANNEL_PREFIX = "presence-";
5
6 /** ID used to store the object in the session */
7 String ID_SESSION_CHAT_INFO = "chatInfo";
8}
1public interface HelloSignConstants {
2
3 /** Subject for the email sent by HelloSign to request a sign */
4 String EMAIL_SUBJECT = "The NDA for the chat ";
5
6 /** ID of the signing role */
7 String SIGNING_ROLE = "Consultant";
8
9 /** ID of the template custom field */
10 String NAME_TEMPLATE_FIELD = "name";
11
12 /** Error message when the file of the signed document doesn't exist */
13 String FILE_DOWNLOAD_ERROR_MSG = "Sorry. The file does not exist.";
14
15 /** Content type of the file */
16 String FILE_CONTENT_TYPE = "application/pdf";
17
18 /** ID for the request signed event */
19 String REQUEST_SIGNED_EVENT = "signature_request_signed";
20
21 /** ID for the request sent event */
22 String REQUEST_SENT_EVENT = "signature_request_sent";
23
24 /** Response to the webhook request required by HelloSign */
25 String WEBHOOK_RESPONSE = "Hello API Event Received";
26}
Now that we've taken care of most of the boilerplate code, we can go to the fun stuff: the controller and the view layers.
Open the ChatController
class and modify the index
method, so it looks like this:
1public class ChatController {
2
3 private Logger logger = LoggerFactory.getLogger(ChatController.class);
4
5 @Autowired
6 private ChatService chatService;
7
8 /**
9 * Route for the main page
10 * @return Object with the view information
11 */
12 @RequestMapping(method=RequestMethod.GET, value="/")
13 public ModelAndView index() {
14 ModelAndView modelAndView = new ModelAndView();
15
16 List<Chat> list = chatService.getAllActiveChats();
17 logger.debug("" + list.size());
18
19 modelAndView.setViewName("index");
20 modelAndView.addObject("chat", new ChatForm());
21 modelAndView.addObject("chats", list);
22
23 return modelAndView;
24 }
25}
This will fetch all the active chats to present them on the index page. Next, modify the index.html
template. The code below will be the layout for our index page.
1<!DOCTYPE HTML>
2<html xmlns:th="http://www.thymeleaf.org">
3<head>
4 <title>NDA Chat</title>
5 <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
6 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/3.2.0/animate.min.css" />
7 <link rel="stylesheet" href="/css/style.css" />
8</head>
9<body>
10 <div class="container clearfix">
11 <div class="options-container">
12 <a id="createChat" href="#createChatModal">Create a new chat</a>
13
14 <div th:unless="${#lists.isEmpty(chats)}">
15 OR
16
17 <a id="joinChat" href="#joinChatModal">Join an existing chat</a>
18 </div>
19 </div>
20 </div>
21
22 <div id="createChatModal">
23 <div id="close-container" class="close-createChatModal">
24 <img class="close-icon" src="img/close.svg" />
25 </div>
26
27 <div class="modal-content">
28 <form id="create-chat-form" role="form" th:action="@{/chat/create}" method="post" th:object="${chat}">
29 <div>
30 <label for="chatName">Chat Name</label>
31 <input type="text" id="chatName" name="chatName" maxlength="50" size="35" th:field="${chat.chatName}"/>
32 </div>
33 <div>
34 <label for="chatDescription">Chat Description</label>
35 <input type="text" id="chatDescription" name="chatDescription" maxlength="150" size="60" th:field="${chat.chatDescription}"/>
36 </div>
37 <div>
38 <label for="userEmail">Your E-mail:</label>
39 <input type="text" id="userEmail" name="userEmail" maxlength="50" size="35" th:field="${chat.userEmail}"/>
40 </div>
41 <div>
42 <label for="userName">Your Name:</label>
43 <input type="text" id="userName" name="userName" maxlength="50" size="35" th:field="${chat.userName}"/>
44 </div>
45 <div>
46 <button type="submit">Save</button>
47 </div>
48 </form>
49 </div>
50 </div>
51
52 <div id="joinChatModal" th:unless="${#lists.isEmpty(chats)}">
53 <div id="close-container" class="close-joinChatModal">
54 <img class="close-icon" src="img/close.svg" />
55 </div>
56
57 <div class="modal-content">
58 <form id="join-chat-form" role="form" th:action="@{/chat/join}" method="post" th:object="${chat}">
59 <div>
60 <label for="idChat">Chat</label>
61 <select id="idChat" name="idChat" th:field="${chat.idChat}">
62 <option value="">---- Select ----</option>
63 <option th:each="chat : ${chats}"
64 th:value="${chat.id}"
65 th:text="${chat.name}"></option>
66 </select>
67 </div>
68 <div>
69 <label for="userEmail">Your E-mail:</label>
70 <input type="text" id="userEmail" name="userEmail" maxlength="50" size="35" th:field="${chat.userEmail}"/>
71 </div>
72 <div>
73 <label for="userName">Your Name:</label>
74 <input type="text" id="userName" name="userName" maxlength="50" size="35" th:field="${chat.userName}"/>
75 </div>
76 <div>
77 <button type="submit">Save</button>
78 </div>
79 </form>
80 </div>
81 </div>
82
83 <script src="https://code.jquery.com/jquery-2.2.4.min.js"></script>
84 <script src="http://ajax.aspnetcdn.com/ajax/jquery.validate/1.15.0/jquery.validate.min.js"></script>
85 <script src="js/animatedModal.min.js"></script>
86 <script src="js/validate.js"></script>
87
88 <script>
89 $("#createChat").animatedModal({
90 modalTarget:'createChatModal',
91 color: '#94C2ED'
92 });
93 </script>
94
95 <script th:unless="${#lists.isEmpty(chats)}">
96 $("#joinChat").animatedModal({
97 modalTarget:'joinChatModal',
98 color: '#94C2ED'
99 });
100 </script>
101</body>
102</html>
Some remarks on this page:
validate.js
script.1@RequestMapping(method=RequestMethod.GET, value="/chat/validate/name", produces = "application/json")
2@ResponseBody
3public String validateChatName(@RequestParam String chatName) {
4 Chat chat = chatService.getActiveChatByName(chatName);
5
6 return String.valueOf(chat == null);
7}
When a chat is created, the createChat
controller method is invoked:
1@RequestMapping(method=RequestMethod.POST, value="/chat/create")
2public String createChat(ChatForm form, Model model) {
3
4 User user = new User();
5 user.setName(form.getUserName());
6 user.setActive(Boolean.TRUE);
7 user.setCreatedAt(new Date());
8 user.setEmail(form.getUserEmail());
9 user.setNdaSigned(Boolean.FALSE);
10 user.setOwnsChat(Boolean.TRUE);
11
12 Chat chat = new Chat();
13 chat.setActive(Boolean.TRUE);
14 chat.setCreatedAt(new Date());
15 chat.setDescription(form.getChatDescription());
16
17 form.setChatName(form.getChatName().toLowerCase().replaceAll("\\s+", "-")); // Replace blank spaces with a hyphen
18 form.setPresenceChatName(GeneralConstants.CHANNEL_PREFIX + form.getChatName());
19 chat.setName(form.getChatName());
20
21 chat.addMember(user);
22
23 chatService.saveChat(chat);
24
25 form.setIsUserChatOwner(Boolean.TRUE);
26 form.setIdChat(chat.getId());
27 form.setIdUser(user.getId());
28
29 model.addAttribute(GeneralConstants.ID_SESSION_CHAT_INFO, form);
30
31 return "redirect:/chat";
32}
Notice how the helper method addMember()
is used to set up the relationship between the chat and the user; it allows both chat and user to be saved with a single method call on the Chat
object. Also, the channel name is prefixed with presence-
(defined as GeneralConstants.CHANNEL_PREFIX
). This prefix is required by Pusher Presence Channels (more on this later). Finally, the ChatForm
is added to the Model
object.
Now, to save this object to the HTTP session, we only have to annotate the controller class with @SessionAttributes
and the same identifier used when it was added to the Model
object:
1@Controller
2@SessionAttributes(GeneralConstants.ID_SESSION_CHAT_INFO)
3public class ChatController {
4 ...
5}
This way, the chat information will be available to all pages of our web app.
The method to join an existing chat is similar:
1@RequestMapping(method=RequestMethod.POST, value="/chat/join")
2public String joinChat(ChatForm form, Model model) {
3 User user = new User();
4 user.setName(form.getUserName());
5 user.setActive(Boolean.TRUE);
6 user.setCreatedAt(new Date());
7 user.setEmail(form.getUserEmail());
8 user.setNdaSigned(Boolean.FALSE);
9 user.setOwnsChat(Boolean.FALSE);
10
11 Chat chat = chatService.addNewUserToChat(form.getIdChat(), user);
12
13 form.setIsUserChatOwner(Boolean.FALSE);
14 form.setIdUser(user.getId());
15 form.setChatName(chat.getName());
16 form.setChatDescription(chat.getDescription());
17 form.setPresenceChatName(GeneralConstants.CHANNEL_PREFIX + form.getChatName());
18
19 model.addAttribute(GeneralConstants.ID_SESSION_CHAT_INFO, form);
20
21 return "redirect:/chat";
22}
As you can see, both methods redirect to the /chat
route, which is defined by this method:
1@RequestMapping(method=RequestMethod.GET, value="/chat")
2public ModelAndView chat(
3 @SessionAttribute(GeneralConstants.ID_SESSION_CHAT_INFO) ChatForm chatInfo) {
4 ModelAndView modelAndView = new ModelAndView();
5 List<Message> list = chatService.getAllChatMessages(chatInfo.getIdChat());
6
7 modelAndView.setViewName("chat");
8 modelAndView.addObject("messages", list);
9
10 return modelAndView;
11}
It gets all the existing chat messages (if any) to present the chat history to the user.
In the next sections, we're going to talk about Pusher presence chats, Pusher webhook setup, and the configurations in HelloSign that allow a user to sign an NDA document.
Presence channels provide information about who is subscribed to the channel. For this, an HTTP request is made to determine if the current user has permission to access the channel and to provide information about that user.
On the client-side, once a subscription is authenticated, you can access the information about the users with the members
property of the channel and the local user with the members.me
property.
You can also subscribe to the following events on the channel:
pusher:subscription_succeeded
)pusher:subscription_error
)pusher:member_added
)pusher:member_removed
)You can learn more about presence channels here.
On the server-side, webhooks allow us to be notified about the following events:
channel_vacated
)channel_vacated
)member_added
)member_removed
)client_event
)There is a delay of a few seconds between a client disconnecting and the channel_vacated
and member_removed
events being sent so that momentary drops in connection or page navigations would not be taken into account.
You can know more about Pusher webhooks here.
Let's configure the webhooks for channel and presence events. Go to your Pusher dashboard, select your app and then the Webhooks tab. Add two webhooks with your public URL and /pusher/webhook
. My URL would be http://4e2f1461.ngrok.io/pusher/webhook
. This webhook would be used for the Event types Channel existence and Presence:
Create the src/main/resources/templates/chat.html
file with the following content:
1<!DOCTYPE HTML>
2<html xmlns:th="http://www.thymeleaf.org">
3<head>
4 <title>NDA Chat</title>
5 <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
6 <link rel="stylesheet prefetch" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.4.0/css/font-awesome.min.css" />
7 <link rel="stylesheet" href="/css/style.css" />
8</head>
9<body>
10 <div class="container clearfix">
11 <div class="chat">
12 <div class="chat-header clearfix">
13
14 <div class="chat-about">
15 <div class="chat-name" th:text="${session.chatInfo.chatName}"></div>
16 <div class="chat-desc" th:text="${session.chatInfo.chatDescription}"></div>
17 </div>
18 <div class="loader">
19 <img src="/img/loader.gif" />
20 </div>
21 </div> <!-- end chat-header -->
22
23 <div class="chat-history">
24 <ul>
25 <li class="clearfix" th:each="msg : ${messages}" >
26 <div th:if="${session.chatInfo.idUser == msg.user.id}">
27 <div class="message-data">
28 <span class="message-data-name" th:inline="text"><i class="fa fa-circle online"></i> [[${msg.user.name}]]</span>
29 <span class="message-data-time" th:text="${msg.createdAtString}"></span>
30 </div>
31 <div class="message my-message" th:utext="${msg.messageFormatted}">
32 </div>
33 </div>
34
35 <div class="clearfix" th:if="${session.chatInfo.idUser != msg.user.id}">
36 <div class="message-data align-right">
37 <span class="message-data-time" th:text="${msg.createdAtString}"></span>
38 <span class="message-data-name" th:text="${msg.user.name}"></span> <i class="fa fa-circle me"></i>
39 </div>
40 <div class="message other-message float-right" th:utext="${msg.messageFormatted}">
41 </div>
42 </div>
43 </li>
44 </ul>
45
46 </div> <!-- end chat-history -->
47
48 <div class="chat-message clearfix">
49 <textarea name="message-to-send" id="message-to-send" placeholder ="Type your message" rows="3"></textarea>
50
51 <a id="send-btn" class="float-right button">Send</a>
52 <a id="request-nda-btn" class="float-left button" th:if="${session.chatInfo.isUserChatOwner}">Request NDA</a>
53
54 </div> <!-- end chat-message -->
55
56 </div> <!-- end chat -->
57
58 </div> <!-- end container -->
59
60 <script id="message-template" type="text/x-handlebars-template">
61 <li class="clearfix">
62 <div class="message-data">
63 <span class="message-data-name"><i class="fa fa-circle online"></i> {{name}}</span>
64 <span class="message-data-time">{{time}}</span>
65 </div>
66 <div class="message my-message">
67 {{{msg}}}
68 </div>
69 </li>
70 </script>
71
72 <script id="message-response-template" type="text/x-handlebars-template">
73 <li class="clearfix">
74 <div class="message-data align-right">
75 <span class="message-data-time" >{{time}}</span>
76 <span class="message-data-name" >{{name}}</span> <i class="fa fa-circle me"></i>
77 </div>
78 <div class="message other-message float-right">
79 {{{msg}}}
80 </div>
81 </li>
82 </script>
83
84 <script id="message-system-template" type="text/x-handlebars-template">
85 <li>
86 <div class="message-data-system">
87 <span><b>{{{msg}}}</b></span>
88 </div>
89 </li>
90 </script>
91
92 <script src="https://js.pusher.com/3.1/pusher.min.js"></script>
93 <script src="https://code.jquery.com/jquery-2.2.4.min.js"></script>
94 <script src="https://cdnjs.cloudflare.com/ajax/libs/handlebars.js/4.0.5/handlebars.min.js"></script>
95 <script th:inline="javascript">
96 var CHANNEL = /*[[${session.chatInfo.presenceChatName}]]*/ 'NA';
97 var PUSHER_KEY = /*[[${key}]]*/ 'NA';
98 </script>
99 <script src="js/chat.js"></script>
100</body>
101</html>
Let's review this code. This part will print the chat information from the session:
1<div class="chat-about">
2 <div class="chat-name" th:text="${session.chatInfo.chatName}"></div>
3 <div class="chat-desc" th:text="${session.chatInfo.chatDescription}"></div>
4</div>
Then, it will print the messages sent before the user subscribed to the chat (differentiating between the messages sent by the local user and the other chat members):
1<li class="clearfix" th:each="msg : ${messages}" >
2 <div th:if="${session.chatInfo.idUser == msg.user.id}">
3 <div class="message-data">
4 <span class="message-data-name" th:inline="text"><i class="fa fa-circle online"></i> [[${msg.user.name}]]</span>
5 <span class="message-data-time" th:text="${msg.createdAtString}"></span>
6 </div>
7 <div class="message my-message" th:utext="${msg.messageFormatted}">
8 </div>
9 </div>
10
11 <div class="clearfix" th:if="${session.chatInfo.idUser != msg.user.id}">
12 <div class="message-data align-right">
13 <span class="message-data-time" th:text="${msg.createdAtString}"></span>
14 <span class="message-data-name" th:text="${msg.user.name}"></span> <i class="fa fa-circle me"></i>
15 </div>
16 <div class="message other-message float-right" th:utext="${msg.messageFormatted}">
17 </div>
18 </div>
19</li>
If the local user is the chat owner, the Request NDA button is rendered:
1<a id="request-nda-btn" class="float-left button" th:if="${session.chatInfo.isUserChatOwner}">Request NDA</a>
The Handlebars templates for messages are defined:
1<script id="message-template" type="text/x-handlebars-template">
2<li class="clearfix">
3 <div class="message-data">
4 <span class="message-data-name"><i class="fa fa-circle online"></i> {{name}}</span>
5 <span class="message-data-time">{{time}}</span>
6 </div>
7 <div class="message my-message">
8 {{{msg}}}
9 </div>
10</li>
11</script>
12
13<script id="message-response-template" type="text/x-handlebars-template">
14<li class="clearfix">
15 <div class="message-data align-right">
16 <span class="message-data-time" >{{time}}</span>
17 <span class="message-data-name" >{{name}}</span> <i class="fa fa-circle me"></i>
18 </div>
19 <div class="message other-message float-right">
20 {{{msg}}}
21 </div>
22</li>
23</script>
24
25<script id="message-system-template" type="text/x-handlebars-template">
26<li>
27 <div class="message-data-system">
28 <span><b>{{{msg}}}</b></span>
29 </div>
30</li>
31</script>
And variables from the server are printed using Thymeleaf's syntax for inline Javascript:
1<script th:inline="javascript">
2 var CHANNEL = /*[[${session.chatInfo.presenceChatName}]]*/ 'NA'; var
3 PUSHER_KEY = /*[[${key}]]*/ 'NA';
4</script>
To get the Pusher key, let's modify our controller code. But first, let's define a configuration object that will take the values of Pusher's API from environment (or command line) variables:
1@Component
2public class PusherSettings {
3 /** Pusher App ID */
4 @Value("${pusher.appId}")
5 private String appId;
6
7 /** Pusher Key */
8 @Value("${pusher.key}")
9 private String key;
10
11 /** Pusher Secret */
12 @Value("${pusher.secret}")
13 private String secret;
14
15 /**
16 * Creates a new instance of the Pusher object to use its API
17 *
18 * @return An instance of the Pusher object
19 */
20 public Pusher newInstance() {
21 return new Pusher(appId, key, secret);
22 }
23
24 public String getPusherKey() {
25 return key;
26 }
27}
Property values can be injected directly into your beans using the @Value
annotation and are accessible after a bean has been constructed.
Now, in ChatController
, inject an instance of this object:
1@Autowired
2private PusherSettings pusherSettings;
Modify the chat
method to add the key to the modelAndView
object:
1@RequestMapping(method=RequestMethod.GET, value="/chat")
2public ModelAndView chat(
3 @SessionAttribute(GeneralConstants.ID_SESSION_CHAT_INFO) ChatForm chatInfo) {
4
5 ...
6
7 modelAndView.addObject("key", pusherSettings.getPusherKey());
8
9 return modelAndView;
10}
The Javascript code that gives the chat functionality to this page is on the js/chat.js
file:
1$(document).ready(function() {
2 var chatHistory = $(".chat-history");
3 var chatHistoryList = chatHistory.find("ul");
4 var sendBtn = $("#send-btn");
5 var ndaBtn = $("#request-nda-btn");
6 var textarea = $("#message-to-send");
7
8 function addMessage() {
9 var messageToSend = textarea.val().trim();
10 if (messageToSend !== "") {
11 $.ajax({
12 method: "POST",
13 url: "/chat/message",
14 contentType: "application/json; charset=UTF-8",
15 data: JSON.stringify({ message: messageToSend })
16 }).done(function(msg) {
17 console.log(msg);
18 textarea.val("");
19 });
20 }
21 }
22
23 function scrollToBottom() {
24 chatHistory.scrollTop(chatHistory[0].scrollHeight);
25 }
26
27 function addSystemMessage(message) {
28 var template = Handlebars.compile($("#message-system-template").html());
29 var params = {
30 msg: message
31 };
32
33 chatHistoryList.append(template(params));
34 scrollToBottom();
35 }
36
37 scrollToBottom();
38
39 var pusher = new Pusher(PUSHER_KEY, {
40 encrypted: true,
41 authEndpoint: "/chat/auth"
42 });
43 var presenceChannel = pusher.subscribe(CHANNEL);
44
45 presenceChannel.bind("pusher:subscription_succeeded", function() {
46 console.log(presenceChannel.members.me);
47 addSystemMessage("You have joined the chat");
48 });
49
50 presenceChannel.bind("pusher:subscription_error", function(status) {
51 alert("Subscription to the channel failed with status " + status);
52 });
53
54 presenceChannel.bind("pusher:member_added", function(member) {
55 console.log("pusher:member_added");
56 addSystemMessage(member.info.name + " has joined the chat");
57 });
58
59 presenceChannel.bind("pusher:member_removed", function(member) {
60 console.log("pusher:member_removed");
61 addSystemMessage(member.info.name + " has left the chat");
62 });
63
64 presenceChannel.bind("new_message", function(data) {
65 if (data.message !== "") {
66 var templateEl =
67 presenceChannel.members.me.id === data.userId
68 ? $("#message-template")
69 : $("#message-response-template");
70 var template = Handlebars.compile(templateEl.html());
71 var params = {
72 msg: data.message.replace(/(\r?\n)/g, "<br />"),
73 name: data.userName,
74 time: data.time
75 };
76
77 chatHistoryList.append(template(params));
78 scrollToBottom();
79 }
80 });
81
82 presenceChannel.bind("system_message", function(data) {
83 if (data.message !== "") {
84 addSystemMessage(data.message);
85 }
86 });
87
88 sendBtn.on("click", function() {
89 addMessage();
90 });
91
92 ndaBtn.on("click", function() {
93 $.ajax({
94 method: "POST",
95 url: "/chat/request/nda"
96 }).done(function(msg) {
97 console.log(msg);
98 });
99 });
100
101 $(document)
102 .ajaxStart(function() {
103 $(".loader").show();
104 })
105 .ajaxStop(function() {
106 $(".loader").hide();
107 });
108});
After defining the functions to add messages, we can create the pusher object.
1var pusher = new Pusher(PUSHER_KEY, {
2 encrypted: true,
3 authEndpoint: "/chat/auth"
4});
Then, the subscription to the channel is made and the presence events are bound.
The API endpoints of the application for the chat functionality are defined in the com.example.web.PusherController
.
This class is annotated with the @RestController
annotation:
1@RestController
2public class PusherController {
3 ...
4}
In Spring MVC 4, if your controller is annotated with @RestController
instead of @Controller
, you don't need the @ResponseBody
annotation to specify responses formatted as JSON.
Let's wire the services we'll need (notice the@PostConstruct
annotation in the method that creates the pusher
instance):
1@RestController
2public class PusherController {
3 private Logger logger = LoggerFactory.getLogger(PusherController.class);
4
5 @Autowired
6 private ChatService chatService;
7
8 @Autowired
9 private UserService userService;
10
11 @Autowired
12 private PusherSettings pusherSettings;
13
14 private Pusher pusher;
15
16 /**
17 * Method executed after the object is created
18 * that creates an instance of the Pusher object
19 */
20 @PostConstruct
21 public void createPusherObject() {
22 pusher = pusherSettings.newInstance();
23 }
24}
After that, let's define the authentication endpoint for the presence chat:
1@RestController
2public class PusherController {
3 ...
4
5 @RequestMapping(method = RequestMethod.POST, value= "/chat/auth")
6 public String auth(
7 @RequestParam(value="socket_id") String socketId,
8 @RequestParam(value="channel_name") String channel,
9 @SessionAttribute(GeneralConstants.ID_SESSION_CHAT_INFO) ChatForm chatInfo){
10
11 Long userId = chatInfo.getIdUser();
12 Map<String, String> userInfo = new HashMap<>();
13 userInfo.put("name", chatInfo.getUserName());
14 userInfo.put("email", chatInfo.getUserEmail());
15
16 String res = pusher.authenticate(socketId, channel, new PresenceUser(userId, userInfo));
17
18 return res;
19 }
20}
In the method, we just get the information about the chat and the user from the session to make the actual authentication and return the information.
To register a message, we insert it in the database and publish an event into the presence channel afterwards:
1@RestController
2public class PusherController {
3 ...
4
5 @RequestMapping(value = "/chat/message",
6 method = RequestMethod.POST,
7 consumes = "application/json",
8 produces = "application/json")
9 public ChatMessageResponse messsage(
10 @RequestBody ChatMessageRequest request,
11 @SessionAttribute(GeneralConstants.ID_SESSION_CHAT_INFO) ChatForm chatInfo) {
12
13 Message msg = new Message();
14 msg.setCreatedAt(new Date());
15 msg.setIdChat(chatInfo.getIdChat());
16 msg.setMessage(request.getMessage());
17
18 chatService.saveMessage(msg, chatInfo.getIdUser());
19
20 ChatMessageResponse response = new ChatMessageResponse();
21 response.setMessage(msg.getMessage());
22 response.setTime(msg.getCreatedAtString());
23 response.setUserId(msg.getUser().getId());
24 response.setUserName(msg.getUser().getName());
25
26 pusher.trigger(chatInfo.getPresenceChatName(), "new_message", response);
27
28 return response;
29 }
30}
Finally, we need to define the method that will handle Pusher's webhook requests:
1@RestController
2public class PusherController {
3 ...
4
5 @RequestMapping(value = "/pusher/webhook",
6 method = RequestMethod.POST,
7 consumes = "application/json")
8 public String webhook(
9 @RequestHeader(value="X-Pusher-Key") String key,
10 @RequestHeader(value="X-Pusher-Signature") String signature,
11 @RequestBody String json) throws JsonParseException, JsonMappingException, IOException {
12 Validity valid = pusher.validateWebhookSignature(key, signature, json);
13
14 if(Validity.VALID.equals(valid)) {
15 ObjectMapper mapper = new ObjectMapper();
16 PusherWebhookRequest request = mapper.readValue(json, PusherWebhookRequest.class);
17
18 if(request.getEvents() != null) {
19 for(PusherWebhookRequest.Event event : request.getEvents()) {
20 switch(event.getName()) {
21 case "channel_occupied":
22 logger.info("channel_occupied: " + event.getChannel());
23 break;
24 case "channel_vacated":
25 logger.info("channel_vacated: " + event.getChannel());
26 chatService.markChatAsInactive(event.getChannel().replace(GeneralConstants.CHANNEL_PREFIX, ""));
27 break;
28 case "member_added":
29 logger.info("member_added: " + event.getUserId());
30 break;
31 case "member_removed":
32 logger.info("member_removed: " + event.getUserId());
33 userService.markUserAsInactive(event.getUserId());
34 break;
35 }
36 }
37 }
38 }
39
40 return "OK";
41 }
42}
In the code above, we verify that the request comes from Pusher. Valid WebHooks will contain these headers:
X-Pusher-Key
: The currently active Pusher's API key.X-Pusher-Signature
: An HMAC SHA256 hex digest formed by signing the POST payload (body) with Pusher's APIS token's secretTo perform the authentication, Pusher's library requires these headers and the request body as arguments to the validateWebhookSignature
method:
1Validity valid = pusher.validateWebhookSignature(key, signature, json);
If the request is valid, the JSON object is converted to an object of type PusherWebhookRequest
. These are sample requests in JSON format:
1{
2 "time_ms":1469203501957,
3 "events":[
4 {
5 "channel":"presence-test",
6 "name":"channel_occupied"
7 }
8 ]
9}
10
11
12{
13 "time_ms":1469203501957,
14 "events":[
15 {
16 "channel":"presence-test",
17 "user_id":"1",
18 "name":"member_added"
19 }
20 ]
21}
Finally, the event is handled accordingly (for channel_vacated
events the chat is marked as inactive and for member_removed
events, the user is marked as inactive too).
The chat functionality is completed, now the only thing missing is the signing of the NDA document.
We're going to work with a document with some Lorem ipsum text that will represent our NDA agreement:
You can get the sample PDF document here.
Go to your HelloSign dashboard and choose the Templates option in the menu on the left. The following screen will be shown:
Choose Create a Template and press Continue if a warning pop-up appears. You'll reach the following screen:
Next, upload the sample document:
We'll only require the signature of one person, so let's enter a role -- Consultant:
Click the Prepare Docs for Signing button. The following window will be shown:
Click the Signature button and then click on the place where you want the signature on the document:
Add a Textbox selecting Me (when sending). under Who fills this out? and with the value name in Field Label (this will be used to reference the field when requesting the signature via the API), and a Sign Date field:
Then, click Continue and add a title and a message for the recipient:
Finally, click the Create Template button. The template ID will be shown:
Store the template ID, we'll need it later.
Now let's set up the API. On the menu under your email:
Go to Settings and then to the API tab:
Under Account Callback, enter the URL http://4e2f1461.ngrok.io/hellosign/webhook
(or whatever your domain is, just keep the /hellosign/webhook
):
If you test the webhook (assuming you have it already configured), HelloSign will send a message to the URL, for example:
1{
2 "event":{
3 "event_type":"callback_test",
4 "event_time":"1469227065",
5 "event_hash":"8db73f2e2749aa0b79ff4a461a12922a575a9a436cc5e195962f594a17d4060c",
6 "event_metadata":{
7 "related_signature_id":null,
8 "reported_for_account_id":"74d9aec621265624c4d3b5f14fe71735fcf8bf87",
9 "reported_for_app_id":null,
10 "event_message":null
11 }
12 }
13}
You can find more information about HelloSign webhooks here.
Now click the Reveal Key button and copy your API key, we'll need it for the following section.
We'll create a class similar to PusherController
for the HelloSign functionality. Create the class com.example.web.HelloSignController
and inject the following dependencies:
1@RestController
2public class HelloSignController {
3 private Logger logger = LoggerFactory.getLogger(HelloSignController.class);
4
5 @Autowired
6 private UserService userService;
7
8 @Autowired
9 private PusherSettings pusherSettings;
10
11 @Value("${hellosign.apikey}")
12 private String helloSignApiKey;
13
14 @Value("${hellosign.templateId}")
15 private String helloSignTemplateId;
16
17 @Value("${hellosign.testMode}")
18 private Boolean testMode;
19
20 private Pusher pusher;
21
22 /**
23 * Method executed after the object is created
24 * that creates an instance of the Pusher object
25 */
26 @PostConstruct
27 public void createPusherObject() {
28 pusher = pusherSettings.newInstance();
29 }
30}
Notice how the values of the HelloSign API will be injected from system variables. Next, we'll add the method to request a signature from a chat member:
1@RequestMapping(value = "/chat/request/nda",
2 method = RequestMethod.POST,
3 produces = "application/json")
4public String requestNda(
5 @SessionAttribute(GeneralConstants.ID_SESSION_CHAT_INFO) ChatForm chatInfo)
6 throws HelloSignException {
7
8 List<User> users = userService.getChatMembersToSignNda(chatInfo.getIdChat());
9
10 if(users != null && !users.isEmpty()) {
11 HelloSignClient client = new HelloSignClient(helloSignApiKey);
12
13 for(User user : users) {
14 TemplateSignatureRequest request = new TemplateSignatureRequest();
15 request.setSubject(HelloSignConstants.EMAIL_SUBJECT + chatInfo.getChatName());
16 request.setSigner(HelloSignConstants.SIGNING_ROLE, user.getEmail(), user.getName());
17 request.setCustomFieldValue(HelloSignConstants.NAME_TEMPLATE_FIELD, user.getName());
18 request.setTemplateId(helloSignTemplateId);
19 request.setTestMode(testMode);
20
21 SignatureRequest newRequest = client.sendTemplateSignatureRequest(request);
22
23 user.setSignId(newRequest.getId());
24 userService.saveUser(user);
25 }
26
27 }
28
29 return "OK";
30}
In this method, we get the list of chat members who haven't signed the agreement yet. Then, we make a signature request for each of them. There's a lot of options to set (here's the signature request documentation). However, since we've done most of the work when we set the template, this method only sets:
Finally, the returned sign ID is saved in the User
table. We'll use this value to get the user information later.
Next, set up a method to respond to the HelloSign webhook:
1@RequestMapping(value = "/hellosign/webhook",
2 method = RequestMethod.POST)
3public String webhook(@RequestParam String json) throws HelloSignException {
4 JSONObject jsonObject = new JSONObject(json);
5 Event event = new Event(jsonObject);
6
7 boolean validRequest = event.isValid(helloSignApiKey);
8
9 if(validRequest) {
10 SignatureRequest signatureRequest = event.getSignatureRequest();
11 User user = null;
12 ChatMessageResponse response = new ChatMessageResponse();
13
14 logger.info(event.getTypeString());
15 switch(event.getTypeString()) {
16 case HelloSignConstants.REQUEST_SIGNED_EVENT:
17 user = userService.getUserBySignId(signatureRequest.getId());
18 if(user != null) {
19 response.setMessage(user.getName() + " has signed the NDA agreement. Download the file <a href=\"/download/" + signatureRequest.getId() + "\" target=\"_blank\">here</a>");
20 pusher.trigger(GeneralConstants.CHANNEL_PREFIX + user.getChat().getName(), "system_message", response);
21 }
22 break;
23 case HelloSignConstants.REQUEST_SENT_EVENT:
24 user = userService.getUserBySignId(signatureRequest.getId());
25 if(user != null) {
26 response.setMessage("The signature request has been sent to " + user.getName());
27 pusher.trigger(GeneralConstants.CHANNEL_PREFIX + user.getChat().getName(), "system_message", response);
28 }
29 break;
30 }
31 }
32
33 return HelloSignConstants.WEBHOOK_RESPONSE;
34}
There are many events we can listen for, but for this application, we're only interested in the event that a sign request is sent and the event that an NDA document is signed. Once we determine that these events have taken place, we can send the appropriate notifications to the chat.
As with all webhooks requests, we need to validate that the request really came from HelloSign. Fortunately, the HelloSign Java library provides an object of type com.hellosign.sdk.resource.Event
, which takes the JSON object from the request and validates it with a method call:
1JSONObject jsonObject = new JSONObject(json);
2Event event = new Event(jsonObject);
3
4boolean validRequest = event.isValid(helloSignApiKey);
For example, here's the JSON sent when a user signs the document:
1{
2 "metadata": {},
3 "response_data": [
4 {
5 "api_id": "9d295b_9",
6 "signature_id": "a267902280c190df81677fc7733c5e4c",
7 "name": null,
8 "type": "signature",
9 "value": null,
10 "required": true
11 },
12 {
13 "api_id": "9d295b_11",
14 "signature_id": "a267902280c190df81677fc7733c5e4c",
15 "name": null,
16 "type": "date_signed",
17 "value": "07/22/2016",
18 "required": false
19 }
20 ],
21 "signature_request_id": "ce00f316b22e4bb9eb1978d1072681726b621037",
22 "original_title": "The NDA for the chat test",
23 "subject": "The NDA for the chat test",
24 "custom_fields": [{
25 "api_id": "0652b9_9",
26 "editor": null,
27 "name": "name",
28 "type": "text",
29 "value": "Esteban Herrera",
30 "required": null
31 }],
32 "signing_redirect_url": null,
33 "title": "The NDA for the chat test",
34 "message": "Please sign the agreement so we can start the chat",
35 "details_url": "https://www.hellosign.com/home/manage?guid=ce00f316b22e4bb9eb1978d1072681726b621037",
36 "signatures": [{
37 "signer_name": "Esteban Herrera",
38 "signature_id": "a267902280c190df81677fc7733c5e4c",
39 "status_code": "signed",
40 "last_viewed_at": 1469241388,
41 "signed_at": 1469241960,
42 "signer_email_address": "[email protected]",
43 "last_reminded_at": null,
44 "error": null,
45 "has_pin": false,
46 "order": null
47 }],
48 "has_error": false,
49 "requester_email_address": "[email protected]",
50 "signing_url": "https://www.hellosign.com/sign/ce00f316b22e4bb9eb1978d1072681726b621037",
51 "test_mode": true,
52 "is_complete": true,
53 "cc_email_addresses": [],
54 "files_url": "https://api.hellosign.com/v3/signature_request/files/ce00f316b22e4bb9eb1978d1072681726b621037",
55 "final_copy_uri": "/v3/signature_request/final_copy/ce00f316b22e4bb9eb1978d1072681726b621037"
56}
If there are no errors, we have to return a response body with the text Hello API Event Received
. If an error occurs, the webhook request will be considered a failure, and it will be retried later. Notice the simmilarity between webhooks and callbacks here.
Once the document is signed, we can view it (or download it) as a PDF file. From the application, we can request it by clicking a link, which is handled by this method:
1@RequestMapping(value="/download/{id}", method = RequestMethod.GET)
2public void downloadFile(HttpServletResponse response, @PathVariable("id") String id) throws IOException, HelloSignException {
3 HelloSignClient client = new HelloSignClient(helloSignApiKey);
4 File file = client.getFiles(id);
5
6 if(!file.exists()){
7 String errorMessage = HelloSignConstants.FILE_DOWNLOAD_ERROR_MSG;
8 System.out.println(errorMessage);
9 OutputStream outputStream = response.getOutputStream();
10 outputStream.write(errorMessage.getBytes(Charset.forName("UTF-8")));
11 outputStream.close();
12
13 return;
14 }
15
16 response.setContentType(HelloSignConstants.FILE_CONTENT_TYPE);
17 response.setHeader("Content-Disposition", String.format("inline; filename=\"" + file.getName() +"\""));
18 response.setContentLength((int)file.length());
19
20 InputStream inputStream = new BufferedInputStream(new FileInputStream(file));
21 FileCopyUtils.copy(inputStream, response.getOutputStream());
22}
The application is now complete.
To run the application, we have to pass all the values we injected with the @Value
annotation either as environment variables or as command-line options using inline JSON (more info here).
If we're using Eclipse, for example, we can configure the values in the Arguments section of the Run Configuration dialog:
If we're using mvn spring-boot:run
, we can do it this way:
1mvn spring-boot:run -Dspring.application.json='{"pusher":{"appId":"XXX", "key":"XXX", "secret":"XXX"},"hellosign":{"apikey":"XXX", "templateId":"XXX", "testMode":true}}'
Or if we're using the JAR file built by Maven:
1java -jar target/nda-chat-0.0.1-SNAPSHOT.jar --spring.application.json='{"pusher":{"appId":"XXX", "key":"XXX", "secret":"XXX"},"hellosign":{"apikey":"XXX", "templateId":"XXX", "testMode":true}}'
Either way, once the application is running, create a chat, join the chat in another browser (or in incognito mode so the sessions can be different) and request users to sign the NDA.
The signer will receive an email from HelloSign:
To sign the document, the user will have to create a HelloSign account:
After signing in, since we're using test mode, we'll see a warning:
Then, the document will be presented:
Click on the signature field and you will see a signature window. You can enter your signature in various ways (by drawing, typing, uploading an image, or using a smartphone):
Once you're done, the signature will be added to the document:
When you press the Continue button at the right top, you'll have to agree to the terms of service:
Once you've read, agreed to the terms, and signed, you've successfully signed the Non-Disclosure Agreement. In your HelloSign dashboard, you'll be able to see the signed NDA document under the Documents option in the menu on the left:
If you choose the Preview option on the document menu, you'll see the signed document and related information:
Remember that you can view the state of the database at any time with the H2 web console:
You just have to connect to the database with the default URL jdbc:h2:mem:testdb
, and the user sa
with no password:
And to view or replay the webhook requests, ngrok provides a console on http://localhost:4040
:
We have come a long way with this application, learning how to set up presence chats with Pusher, signing documents with the HelloSign API, using ngrok to work locally with webhooks and expose our database publicly, and building a complete application using Spring MVC and Spring Data under Spring Boot.
I hope this tutorial guided you in the areas above and perhaps helped you find new ways to use Java and HTML.
Thank you for reading. Contact me via email or through the comments section below if you need anything. Remember that the code of the app is on Github.