Grails Events
Grails Events to handle a common scenario. A user registers himself in an application and the app sends the user a welcome email.
Authors: Sergio del Amo
Grails Version: 5.0.1
1 Getting Started
In this guide, you are going to use Grails Events to handle a common scenario. A user registers in an application and the app sends the user a welcome email. For example, asking him to verify his email address. We are going to trigger the Email Notification by publishing an event when the user is registered.
1.1 What you will need
To complete this guide, you will need the following:
-
Some time on your hands
-
A decent text editor or IDE
-
JDK 1.8 or greater installed with
JAVA_HOME
configured appropriately
1.2 How to complete the guide
To get started do the following:
-
Download and unzip the source
or
-
Clone the Git repository:
git clone https://github.com/grails-guides/grails-events.git
The Grails guides repositories contain two folders:
-
initial
Initial project. Often a simple Grails app with some additional code to give you a head-start. -
complete
A completed example. It is the result of working through the steps presented by the guide and applying those changes to theinitial
folder.
To complete the guide, go to the initial
folder
-
cd
intograils-guides/grails-events/initial
and follow the instructions in the next sections.
You can go right to the completed example if you cd into grails-guides/grails-events/complete
|
2 Writing the Application
Grails 3.3 introduces a new Events API that replaces the previous implementation that was based on Reactor 2.x (which is no longer maintained and deprecated).
In Grails 3.3 and above a new EventBus abstraction has been introduced. Like the PromiseFactory notion, there are implementations of the EventBus interface for common asynchronous frameworks like GPars and RxJava.
Your project already contains the events dependency:
compile "org.grails.plugins:events"
Grails Events capabilites documentation can be found in async.grails.org.
2.1 Domain Classes
Add two domain classes.
package demo
class User {
String firstName
String lastName
String email
static constraints = {
firstName nullable: false
lastName nullable: false
email nullable: false, email: true, unique: true
}
}
package demo
class Notification {
String email
String subject
static constraints = {
subject nullable: false
email nullable: false, email: true
}
}
2.2 Data Services
Introduced in GORM 6.1, Data Services take the work out of implemented service layer logic by adding the ability to automatically implement abstract classes or interfaces using GORM logic.
Add two data services classes for the previous domain classes.
package demo
import grails.gorm.services.Service
@Service(User)
interface UserService {
int count()
void deleteByEmail(String email)
}
package demo
import grails.gorm.services.Service
@Service(Notification)
interface NotificationService {
int count()
void deleteByEmail(String email)
}
2.3 Url Mapping
Modify UrlMappings
to map the endpoints which handle User registration.
"/signup"(controller: 'register', action: 'index')
"/register"(controller: 'register', action: 'save')
/signup
presents a registration form.
/register
handles a POST submission which saves a user.
2.4 View
Create a GSP file which contains the user registration form.
<html>
<head>
<title>Sign Up</title>
<meta name="layout" content="main" />
<style type="text/css">
ol li {
list-style-type: none;
margin: 10px auto;
}
ol li label {
width: 120px;
}
</style>
</head>
<body>
<div id="content" role="main">
<g:hasErrors bean="${signUpInstance}">
<ul class="errors">
<g:eachError bean="${signUpInstance}">
<li><g:message error="${it}"/></li>
</g:eachError>
</ul>
</g:hasErrors>
<p class="message">${flash.message}</p>
<h1><g:message code="register.title" default="Register Form"/></h1>
<g:form controller="register" action="save" method="POST">
<ol>
<li>
<label for="firstName">First Name</label>
<g:textField name="firstName" id="firstName" value="${signUpInstance.firstName}" />
</li>
<li>
<label for="lastName">Last Name</label>
<g:textField name="lastName" id="lastName" value="${signUpInstance.lastName}" />
</li>
<li>
<label for="email">Email</label>
<g:textField name="email" id="email" value="${signUpInstance.email}" />
</li>
<li>
<g:submitButton name="Submit" id="submit"/>
</li>
</ol>
</g:form>
</div>
</body>
</html>
2.5 Services
Add a service which handles the registration of a user in the database.
package demo
import grails.events.annotation.Publisher
import grails.gorm.transactions.Transactional
import grails.validation.ValidationException
import groovy.transform.CompileStatic
import org.springframework.context.MessageSource
import org.springframework.dao.DuplicateKeyException
@CompileStatic
class RegisterService {
MessageSource messageSource
@Transactional
@Publisher (1)
User saveUser(User user) {
user.save(failOnError: true) (2)
user
}
SaveUserResult register(RegisterCommand cmd, Locale locale) {
User user = cmd as User
try {
saveUser(user)
return new SaveUserResult(message: userSavedMessage(cmd.email, locale))
} catch (ValidationException | DuplicateKeyException validationException) {
return new SaveUserResult(error: userSavedErrorMessage(cmd.email, locale))
}
}
String userSavedErrorMessage(String email, Locale locale) {
messageSource.getMessage('user.saved.error',
[email] as Object[],
"Error while saving user with ${email} email address",
locale)
}
String userSavedMessage(String email, Locale locale) {
messageSource.getMessage('user.saved',
[email] as Object[],
"User saved with ${email} email address",
locale)
}
}
@CompileStatic
class SaveUserResult {
String message
String error
}
1 | Annotate the method with @Publisher . The event Id uses the method name saveUser |
2 | Raise an exception if the validation fails. This rollbacks the transaction. If the transaction is rollbacked, no event is triggered. |
We consume the event in a different service. The consumer is completely decoupled from the sender.
In a real application you will probably send an email to the user who just registered asking him to verify his email address.
In this guide, we save a Notification
in the database.
package demo
import grails.events.annotation.Subscriber
import grails.gorm.transactions.Transactional
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
@Slf4j
@CompileStatic
class WelcomeEmailService {
@Transactional
@Subscriber (1)
void saveUser(User user) {
Notification notification = new Notification(email: user.email, subject: 'Welcome to Grails App')
// TODO Send Email
if ( !notification.save() ) {
log.error('unable to save notification {}', notification.errors.toString())
}
}
}
1 | To consume an event use the Subscriber annotation. The method name saveUser is used by default for the event id, although it can start with the word "on". In other words either a method name of saveUser or onSaveUser would work for the this guide example. |
2.6 Controller
Create a Command Object to handle the form data binding and validation.
package demo
import grails.validation.Validateable
class RegisterCommand implements Validateable {
String firstName
String lastName
String email
static constraints = {
firstName nullable: false
lastName nullable: false
email nullable: false, email: true
}
Object asType(Class clazz) {
if (clazz == User) {
def user = new User()
copyProperties(this, user)
return user
}
else {
super.asType(clazz)
}
}
def copyProperties(source, target) {
source.properties.each { key, value ->
if (target.hasProperty(key) && !(key in ['class', 'metaClass'])) {
target[key] = value
}
}
}
}
Create a controller with two actions, one action presents the registration form the other action handles the form submission.
package demo
import groovy.transform.CompileStatic
@CompileStatic
class RegisterController {
static allowedMethods = [index: 'GET', save: 'POST']
RegisterService registerService
def index() {
[signUpInstance: new RegisterCommand()]
}
def save(RegisterCommand cmd) {
if ( cmd.hasErrors() ) {
render view: 'index', model: [signUpInstance: cmd]
return
}
SaveUserResult result = registerService.register(cmd, request.locale)
flash.message = result.message
if ( result.error ) {
flash.error = result.error
render view: 'index', model: [signUpInstance: cmd]
return
}
redirect action: 'index'
}
}
2.7 Testing
Create a functional test which verifies that a Notification
instance is created, due to the event triggering, when a User
is created.
package demo
import geb.Page
import geb.module.TextInput
class SignUpPage extends Page {
static url = '/signup'
static content = {
firstNameInput { $(name: "firstName").module(TextInput) }
lastNameInput { $(name: "lastName").module(TextInput) }
emailInput { $(name: "email").module(TextInput) }
submitButton { $('#submit') }
}
void submit(String firstName, String lastName, String email) {
firstNameInput.text = firstName
lastNameInput.text = lastName
emailInput.text = email
submitButton.click()
}
}
package demo
import geb.spock.GebSpec
import grails.gorm.transactions.Rollback
import grails.testing.mixin.integration.Integration
@Integration
class RegisterControllerSpec extends GebSpec {
NotificationService notificationService
UserService userService
@Rollback
def "If you signup a User, an Event triggers which causes a Notification to be saved"() {
when: 'you signup with a non existing user'
SignUpPage page = to SignUpPage
page.submit('Sergio', 'del Amo', '[email protected]')
then: 'the user gets created and a notification is saved due to the event being triggered'
userService.count() == old(userService.count()) + 1
notificationService.count() == old(notificationService.count()) + 1
when: 'you try to signup with a user which is already in the database'
page = to SignUpPage
page.submit('Sergio', 'del Amo', '[email protected]')
then: 'The user is not saved and no event gets triggered'
noExceptionThrown()
userService.count() == old(userService.count())
notificationService.count() == old(notificationService.count())
cleanup:
userService.deleteByEmail('[email protected]')
notificationService.deleteByEmail('[email protected]')
}
}
3 Running the Tests
To run the tests:
./grailsw
grails> test-app
grails> open test-report
or
./gradlew check
open build/reports/tests/index.html