Replacing a Node/Express API with Grails
Learn how to replace a RESTful API in Node/Express with Grails
Authors: Zachary Klein, Sergio del Amo
Grails Version: 3.3.0
1 Grails Training
Grails Training - Developed and delivered by the folks who created and actively maintain the Grails framework!.
2 Introduction
We are going to use the React + Node.js application described by Mark Volkmann in the SETT article Web App Step by Step, April 2017 issue of SETT. The article laid out a detailed blueprint for developing a modern web application from beginning to end. The sample project in this article featured a React frontend and a Node/express.jsbackend, backed by PostgresSQL database. The project included several advanced features, including WebSockets and HTTPS.
In this guide, we will demonstrate how to develop the same web app, using the Grails framework. The only explicit technology change we will be making will be the use of Grails over Node/express.js for the backend - otherwise we will use the same stack, including React and PostgresSQL. However, we will see how Grails simplifies and accelerates the development process and makes developers more productive without giving up finer grain control where needed.
The emphasis of this guide will not be so much learning how the “innards” of how web applications work, but rather on developer productivity. In addition, we will refer readers to the original SETT article for details on the implementation of the React application, as there we will be using almost the exact same code (any changes will be highlighted and explained).
This guide shows how to transition an application from a technology stack such as:
to:
3 Requirements
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
3.1 Following Along
To get started do the following:
-
Download and unzip the source
or
-
Clone the Git repository:
git clone https://github.com/grails-guides/grails-vs-nodejs.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-vs-nodejs/initial
and follow the instructions in the next sections.
You can go right to the completed example if you cd into grails-guides/grails-vs-nodejs/complete
|
4 Writing the Application
The completed sample project for this article can be found at:
4.1 React Profile
Every Grails project begins with a single create-app
command. For the
purposes of following along with this guide, you may choose to install
Grails via the official website, or using sdkman (recommended).
However, there is no need to install the framework on your machine to
create your Grails app - instead, let’s browse to
http://start.grails.org and create our application using the Grails
Application Forge.
Choose the latest version of Grails (3.3.0 as of the time of writing)
and select the react
profile.
With the use of application profiles, Grails allows you to build modern web applications. There are profiles to facilitate the construction of REST APIs or Web applications with a Javascript front-end |
Start both client and server applications
Once you’ve downloaded your application, expand it into a directory of
your choice, cd
into the project, and run the following two commands
(in two separate terminal sessions):
~ ./gradlew server:bootRun //Windows users use "gradlew.bat"
//in a second terminal session
~ ./gradlew client:bootRun
The gradlew
command launches the Gradle "wrapper”, which is provided
by the Gradle build tool that is used in all Grails projects since Grails 3.0.
The wrapper is a special script that actually download and install the Gradle
build tool (if necessary) before running your commands. Gradle will then
download all needed dependencies (including Grails) and install them in your project (caching them for future
use as well). This is why you don’t need to install Grails on your
machine: if your project includes the Gradle wrapper, it will handle
that for you.
You can think of Gradle roughly as an alternative to npm (which "does
not" stand for Node Package Manager). It doesn’t
provide the CLI that npm offers but it fulfills a similar purpose in dependency
management and build-processing. When a Gradle command (or "task") is run,
Gradle will first download all dependencies listed in the project’s build.gradle
file, similar to running npm install .
|
What about the server
and client
portion of those two commands?
Because we’re using the react
profile, Grails has actually created two
separate “apps” for us - the backend Grails application, and the React
application (which in turn is generated via create-react-app
). Gradle
treats these two apps as independent subprojects, with the above names.
This is called a
multi-project
build.
When running a Gradle “task” from the project root directory, anything
after ./gradlew [project_name]:
will match a task specific to that
subproject. The bootRun
task is configured in both projects to start
the respective app.
Where does bootRun come from? This Gradle task is inherited from the Spring Boot framework, upon which Grails is based. Of course
create-react-app projects don’t have such a task by default. The React profile provides the client:bootRun task as a wrapper around the npm/yarn start script.
This allows you to use advanced Gradle features like running both server and client in
parallel mode with one command. For developers, running ../gradlew client:bootRun is the same
as running npm start (or yarn start ) in a stock create-react-app
project, and in fact you can run the client app exactly that way if
you have npm /yarn installed on your machine.
|
Once the gradlew
commands have completed downloading dependencies and
launching their respective apps, you should be able to browse to
http://localhost:8080
to see the Grails backend application, and
http://localhost:3000
to view the React app.
Before we continue implementing our application, take a moment to explore the app we have right now. The Grails application by default is providing some useful metadata in JSON format, and the React app is consuming that data via a REST call and displaying it via the app’s navigation menus. This isn’t a very useful app, but you can see a lot of boilerplate has already been set up for you.
4.2 Datasource
Now that we have our basic application structure, its’s time to set up our database. We are trying to migrate a React + Node/express application which used PostgreSQL. However, it’s worth noting that Grails has already set up a basic datasource for us, in the form on an in-memory H2 database. This database is destroyed and recreated every time the app is run, and it will even be updated during runtime if new tables/columns are added to the domain classes (more on those later). For many apps, this default database will be very well suited for the initial stages of app development, especially if you’re making many iterative changes to your data model. However, in keeping with the app being migrated, we are going to replace this default H2 datasource with our desired PostgreSQL database.
Let’s recap the steps to install PostgresSQL on your machine:
To install PostgreSQL in Windows, see https://www.postgresql.org/download/windows/.
To install PostgreSQL in macOS:
Install Homebrew by following the instructions at http://brew.sh/. Enter the following: brew install postgresql To start the database server, enter
pg_ctl -D /usr/local/var/postgres start
.To stop the database server later, enter
pg_ctl -D /usr/local/var/postgres stop -m fast
.
Step by Step -- Mark Volkmann
Once you’ve installed Postgres (or if you already have it installed),
create a new database for our app using createdb
~ createdb ice_cream_db_2
If you recall in the previous article (where the above installation steps were taken from), the next steps here would be to create the database tables needed for our app. However, we won’t be using any SQL in this project. That’s because Grails offers a powerful and developer-friendly alternative: the GORM; a data access toolkit for the JVM.
4.3 GORM
GORM works with any JDBC-compatible database, which includes Postgres (as well as over 200 other databases). To begin using Postgres with our new Grails app, we have 2 steps to complete:
- Step #1
-
Install the JDBC driver in our
server
project. Editserver/build.gradle
, and find the section nameddependencies
. Add the following line of code:runtime 'org.postgresql:postgresql:9.4.1212'
This will tell Gradle to download version 9.4.1212 of theorg.postgresql.postgresql
library from the Maven Central repository, and install it in our app.
runtime 'org.postgresql:postgresql:9.4.1212'
You can think of build.gradle as filling a similar purpose to a package.json file in a Node.js project. It specifies repositories, dependencies, and custom tasks (similar to npm scripts) for your project.
|
- Step #2
-
Configure GORM to use our PostgreSQL database instead of the default H2 database. Edit
server/grails-app/conf/application.yml
, scroll down to the section starting withdatasource
, and replace it with the following content:
dataSource:
dbCreate: create-drop
driverClassName: org.postgresql.Driver
dialect: org.hibernate.dialect.PostgreSQLDialect
username: postgres
password:
url: jdbc:postgresql://localhost:5432/ice_cream_db_2
Now our Grails app is connected to our database, but we haven’t created any tables yet. With Grails, there’s no need to create the database schema manually (although you certainly can do so if you want). Instead, we’ll specify our domain model in code, by writing Domain Classes.
By convention, Grails will load any Groovy classes located under
grails-app/domain
as Domain Classes. This means that GORM will map
these classes to tables in the database, and map the properties of these
classes to columns in the respective tables. Optionally, GORM will
create these tables for us, which we have already enabled in our
application.yml
file with the dbCreate: update
setting.
This means it’s actually quite trivial to set up the database schema from the original article.
The React + Node/express used the next database schema:
create table ice_creams (
id serial primary key,
flavor text
);
create table users (
username text primary key,
password text -- encrypted length
);
create table user_ice_creams (
username text references users(username),
ice_cream_id integer references ice_creams(id)
);
GORM is the data access toolkit used by Grails by default. We can customize how the generated database schema looks like.
Domain classes in Grails by default dictate the way they are mapped to the database using sensible defaults. You can customize these with the ORM Mapping DSL.
For each of the tables we need in our
app, we will create a domain class under the grails-app/domain
directory.
Run the following commands:
~ ./grailsw create-domain-class demo.IceCream
~ ./grailsw create-domain-class demo.User
~ ./grailsw create-domain-class demo.UserIceCream
These commands will generate three Groovy classes, under
grails-app/domain/demo
. Edit these files with the following
content to generate an identical database schema as in the previous app:
package demo
class User implements Serializable {
String username
String password
static mapping = {
table 'users'
password type: 'text'
id name: 'username', generator: 'assigned'
version false
}
}
You may have noticed we have not encrypted our password column
- don’t worry, we’ll get to that later on.
|
package demo
class IceCream implements Serializable {
String flavor
static mapping = {
table 'ice_creams'
flavor type: 'text'
version false
}
}
package demo
class UserIceCream implements Serializable {
User user
IceCream iceCream
static mapping = {
table 'user_ice_creams'
id composite: ['user', 'iceCream']
user column: 'username'
iceCream column: 'ice_cream_id'
version false
}
}
The previous domain class represents a join table for our User
and IceCream
classes.
Package Naming
As is common in Java projects, we have created a “package” for our
domain classes. Packages help distinguish our classes from classes from
libraries or plugins that we might use later. The package is reflected
in the directory structure as well: these two files will be created
under grails-app/domain/demo/
.
Why are we using period instead of dash separators, as was shown in the previous article? In Java it is generally considered against convention to use dashes (hyphen) in package names. See this link for details on the Java naming conventions. |
4.4 Grails Console
If you were to start up your app now, Grails will connect to the Postgres database, create the tables and columns needed to persist your domain objects. Of course, there would be no data in the database initially. We will solve that issue shortly, but for now, we can run a Grails command that will give as an interactive console where we can create, update, delete and query our domain objects.
Run the following command:
~ ./grailsw console
(If you have Grails installed locally on your machine, you can run the
grails
command directly, e.g.: grails console
. However, Grails
projects include the grailsw
"wrapper" command which will install the
correct version of Grails for you.)
Now you should see the Grails Console. You can import any classes from your project (both your own code as well as any dependencies) and run any Groovy code you’d like.
The following code will show how to accomplish the database operations from the previous "Web App" article, using GORM and the Groovy language:
import demo.*
// Delete all rows from these tables:
// user and ice_cream via HQL updates (efficient for batch operations)
IceCream.executeUpdate("delete from IceCream")
User.executeUpdate("delete from User")
// Insert three new rows corresponding to three flavors.
def iceCreams = ['vanilla', 'chocolate', 'strawberry'].collect { flavor ->
new IceCream(flavor: flavor).save(flush: true)
}
// Get an array of the ids of the new rows.
def ids = iceCreams*.id
println "Inserted records with ids ${ids.join(',')}"
// Delete the first row (vanilla)
def vanilla = IceCream.get(ids[0])
vanilla.delete()
// Change the flavor of the second row (chocolate) to "chocolate chip".
def chocolate = IceCream.findByFlavor('chocolate')
chocolate.flavor = 'chocolate chip'
chocolate.save(flush: true)
// Get all the rows in the table.
iceCreams = IceCream.list()
// Output their ids and flavors.
iceCreams.each { iceCream ->
println "${iceCream.id}: ${iceCream.flavor}"
}
Enter the above code into the Grails Console (launched with the previous
command), and click the "Run" button to execute the script. If you like,
you can save the script to be reused later (note that this Groovy script
will only work in the Grails Console, not via the "plain" Groovy Console
or Groovy compiler (groovyc
) - this is because our domain classes need
to be loaded by Grails in order for this code to work).
You might think this method could be used to populate our database with
some initial data, and you’d be correct - any inserts/updates we make in
the Console are persisted to the database we configured in our
application.yml
file. However, Grails provides a BootStrap.groovy
file which is much better suited to this task as you will see in the next section.
4.5 Seed Data
Edit the file server/grails-app/init/demo/BootStrap.groovy
and add the
following code:
package demo
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
@Slf4j
@CompileStatic
class BootStrap {
UserService userService
UserRoleService userRoleService
RoleService roleService
IceCreamService iceCreamService
def init = { servletContext ->
log.info "Loading database..."
if (!iceCreamService.count()) {
List<Long> ids = []
for (String flavor : ['vanilla', 'chocolate', 'strawberry']) {
IceCream iceCream = iceCreamService.saveIcream(flavor)
ids << iceCream.id
}
log.info "Inserted records with ids ${ids.join(',')}"
}
if (!roleService.count()) {
Role role = roleService.saveRole( 'ROLE_USER')
log.info "Inserted role..."
User user = userService.createUser('sherlock', 'secret')
log.info "Inserted user..."
userRoleService.saveUserRole(user, role)
log.info "Associated user with role..."
}
}
def destroy = {
}
}
As you can see, we use several Grails Services (more about services in the next section) as collaborators. Grails encourages to keep all your business logic in the service layer.
package demo
import grails.compiler.GrailsCompileStatic
import grails.gorm.DetachedCriteria
import grails.gorm.transactions.ReadOnly
import grails.gorm.transactions.Transactional
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
import org.springframework.validation.ObjectError
@Slf4j
@CompileStatic
@Transactional
class IceCreamService {
IceCream saveIcream(String flavor, boolean flush = false) {
IceCream iceCream = new IceCream(flavor: flavor)
if ( !iceCream.save(flush: flush) ) {
log.error 'Failure while saving icream {}', iceCream.errors.toString()
}
iceCream
}
@ReadOnly
int count() {
IceCream.count() as int
}
package demo
import grails.gorm.transactions.ReadOnly
import grails.gorm.transactions.Transactional
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
@Slf4j
@Transactional
@CompileStatic
class RoleService {
Role saveRole(String authority, boolean flush = false) {
Role r = new Role(authority: authority)
if ( !r.save(flush: flush) ) {
log.error 'Failure while saving role {}', r.errors.toString()
}
r
}
@ReadOnly
int count() {
Role.count() as int
}
package demo
import grails.gorm.DetachedCriteria
import grails.gorm.transactions.ReadOnly
import grails.gorm.transactions.Transactional
import groovy.util.logging.Slf4j
@Slf4j
@Transactional
class UserService {
User createUser(String username, String password, boolean flush = false) {
User user = new User(username: username, password: password)
if ( !user.save(flush: flush) ) {
log.error 'Unable to save user {}', user.errors.toString()
}
user
}
package demo
import grails.gorm.transactions.Transactional
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
@Slf4j
@Transactional
@CompileStatic
class UserRoleService {
UserRole saveUserRole(User user, Role role, boolean flush = false) {
UserRole ur = new UserRole(user: user, role: role)
if ( !ur.save(flush: flush) ) {
log.error 'Failure while saving user {}', ur.errors.toString()
}
ur
}
4.6 Authentication
Because Grails is based upon Spring Boot, it is compatible with many other projects in the Spring Ecosystem. One of the most popular such projects is Spring Security. It provides powerful authentication and access control for Java web apps, and supports many authentication methods, from LDAP to OAuth2. Even better, there is a set of Grails plugins that make Spring Security a breeze to set up.
Edit server/build.gradle
again, and add the following two lines:
server/build.gradle
compile 'org.grails.plugins:spring-security-core:3.2.0.M1'
compile "org.grails.plugins:spring-security-rest:2.0.0.M2"
We have placed the Spring Security Core and REST plugins configuration in application.yml
grails:
plugin:
springsecurity:
userLookup:
userDomainClassName: demo.User
authorityJoinClassName: demo.UserRole
authority:
className: demo.Role
rest:
login:
endpointUrl: /login
useSecurityEventListener: true
controllerAnnotations:
staticRules:
-
pattern: /stomp/**
access:
- permitAll
-
pattern: /signup/**
access:
- permitAll
filterChain:
chainMap:
- # Stateless chain
pattern: /**
filters: 'JOINED_FILTERS,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter,-rememberMeAuthenticationFilter'
Please consult the Spring Security docs and the Grails Spring Security plugin docs for more information.
We have modified slightly the User
domain class. Moreover, we added two other domain classes to map roles and the relationship
between roles and users.
package demo
import groovy.transform.EqualsAndHashCode
import groovy.transform.ToString
import grails.compiler.GrailsCompileStatic
@GrailsCompileStatic
@EqualsAndHashCode(includes='username')
class User implements Serializable {
String username
String password
Date lastLogin = null (1)
boolean enabled = true
boolean accountExpired
boolean accountLocked
boolean passwordExpired
Set<Role> getAuthorities() {
(UserRole.findAllByUser(this) as List<UserRole>)*.role as Set<Role>
}
static constraints = {
password nullable: false, blank: false, password: true
username nullable: false, blank: false, unique: true
lastLogin nullable: true
}
static mapping = {
table 'users'
password column: '`password`', type: 'text'
id name: 'username', generator: 'assigned'
version false
}
}
1 | We’ll use this property to keep track of expired sessions |
package demo
import groovy.transform.EqualsAndHashCode
import groovy.transform.ToString
import grails.compiler.GrailsCompileStatic
import org.springframework.security.core.GrantedAuthority
@GrailsCompileStatic
@EqualsAndHashCode(includes='authority')
@ToString(includes='authority', includeNames=true, includePackage=false)
class Role implements Serializable, GrantedAuthority {
private static final long serialVersionUID = 1
String authority
static constraints = {
authority nullable: false, blank: false, unique: true
}
static mapping = {
cache true
}
}
package demo
import grails.gorm.DetachedCriteria
import groovy.transform.ToString
import org.codehaus.groovy.util.HashCodeHelper
import grails.compiler.GrailsCompileStatic
@GrailsCompileStatic
@ToString(cache=true, includeNames=true, includePackage=false)
class UserRole implements Serializable {
private static final long serialVersionUID = 1
User user
Role role
@Override
boolean equals(other) {
if (other instanceof UserRole) {
other.userId == user?.username && other.roleId == role?.id
}
}
@Override
int hashCode() {
int hashCode = HashCodeHelper.initHash()
if (user) {
hashCode = HashCodeHelper.updateHash(hashCode, user.username)
}
if (role) {
hashCode = HashCodeHelper.updateHash(hashCode, role.id)
}
hashCode
}
static UserRole get(String username, long roleId) {
criteriaFor(username, roleId).get()
}
static boolean exists(String username, long roleId) {
criteriaFor(username, roleId).count()
}
private static DetachedCriteria criteriaFor(String name, long roleId) {
UserRole.where {
user.username == name &&
role == Role.load(roleId)
}
}
static UserRole create(User user, Role role, boolean flush = false) {
def instance = new UserRole(user: user, role: role)
instance.save(flush: flush)
instance
}
static boolean remove(User u, Role r) {
if (u != null && r != null) {
UserRole.where { user == u && role == r }.deleteAll()
}
}
static int removeAll(User u) {
u == null ? 0 : UserRole.where { user == u }.deleteAll() as int
}
static int removeAll(Role r) {
r == null ? 0 : UserRole.where { role == r }.deleteAll() as int
}
static constraints = {
user nullable: false
role nullable: false, validator: { Role r, UserRole ur ->
if (ur.user?.id) {
if (UserRole.exists(ur.user.username, r.id)) {
return ['userRole.exists']
}
}
}
}
static mapping = {
id composite: ['user', 'role']
version false
}
}
We have added a “listener” which will encrypt our password
field whenever a new User
is created.
package demo
import grails.plugin.springsecurity.SpringSecurityService
import org.grails.datastore.mapping.engine.event.AbstractPersistenceEvent
import org.grails.datastore.mapping.engine.event.EventType
import org.grails.datastore.mapping.engine.event.PreInsertEvent
import org.grails.datastore.mapping.engine.event.PreUpdateEvent
import org.hibernate.event.spi.PreDeleteEvent
import org.springframework.beans.factory.annotation.Autowired
import grails.events.annotation.gorm.Listener
import groovy.transform.CompileStatic
@CompileStatic
class UserPasswordEncoderListener {
@Autowired
SpringSecurityService springSecurityService
@Listener(User)
void onPreInsertEvent(PreInsertEvent event) {
encodeUserPasswordForEvent(event)
}
@Listener(User)
void onPreUpdateEvent(PreUpdateEvent event) {
encodeUserPasswordForEvent(event)
}
private void encodeUserPasswordForEvent(AbstractPersistenceEvent event) {
if (event.entityObject instanceof User) {
User u = (event.entityObject as User)
if (u.password && ((event instanceof PreInsertEvent) || (event instanceof PreUpdateEvent && u.isDirty('password')))) {
event.getEntityAccess().setProperty("password", encodePassword(u.password))
}
}
}
private String encodePassword(String password) {
springSecurityService?.passwordEncoder ? springSecurityService.encodePassword(password) : password
}
}
We register this listener as a Bean in resources.groovy
import demo.UserPasswordEncoderListener
// Place your Spring DSL code here
beans = {
userPasswordEncoderListener(UserPasswordEncoderListener)
}
4.7 REST Services
Now that we have our database configured and populated, it’s time to set up our service layer. Before we do this, it’s important to understand the two main data-handling "artifacts" provided by the Grails framework.
-
Controllers: Grails is a MVC framework, where "C" stands for Controller. In an MVC app, controllers define the logic of the web application, and manage the communication between the model and the view. Controllers respond to requests, interact with data from the model, and then respond with either a view (HTML page) or some other consumable format (such as JSON). Controllers are Groovy classes located in
grails-app/controllers
-
Services: Many times we need to do more with our data than simply take a request and return a response. Real-world apps typically include a substantial amount of code dedicated to "business logic". Grails supports "services" which are classes that have full access to the model, but are not tied to a request. Controllers, as well as other parts of your app, can call services (which are made available via Spring’s dependency injection) to get back the data they need to respond to their requests. Services are Groovy classes located in
grails-app/services
and can be injected by name into other Grails artifacts.
To implement our RESTful API in our app, we’ll use controllers to respond to API requests (POST, GET, PUT, DELETE), and services to handle our "business logic" (which is pretty simple in our case). Let’s start with a controller:
The React app consumes several endpoints.
4.8 Signup Endpoint
Signup
To signup into the application the React app sends a POST request to the /signup endpoint. It includes a JSON Payload containing username and password. We are going to map this with Grails.
First, we register the endpoint in URLMappings by adding the next line into the mappings:
post '/signup'(controller: 'signup')
Next we create an controller which binds the JSON payload with the help of a command object.
package demo
import grails.validation.Validateable
class SignupCommand implements Validateable {
String username
String password
static constraints = {
username nullable: false, blank: false
password nullable: false, blank: false
}
}
package demo
import grails.plugin.springsecurity.annotation.Secured
import grails.plugin.springsecurity.rest.token.AccessToken
import grails.plugin.springsecurity.rest.token.generation.TokenGenerator
import grails.plugin.springsecurity.rest.token.rendering.AccessTokenJsonRenderer
import grails.plugin.springsecurity.userdetails.GrailsUser
import groovy.transform.CompileStatic
import org.springframework.http.HttpStatus
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.userdetails.UserDetails
(1)
@CompileStatic
@Secured(['IS_AUTHENTICATED_ANONYMOUSLY'])
class SignupController {
static responseFormats = ['json', 'xml']
UserRoleService userRoleService
UserService userService
RoleService roleService
TokenGenerator tokenGenerator
AccessTokenJsonRenderer accessTokenJsonRenderer
def signup(SignupCommand cmd) {
(2)
if ( userService.existsUserByUsername(cmd.username) ) {
render status: HttpStatus.UNPROCESSABLE_ENTITY.value(), "duplicate key"
return
}
Role roleUser = roleService.findByRoleName('ROLE_USER')
if ( !roleUser ) {
render status: HttpStatus.UNPROCESSABLE_ENTITY.value(), "default role: ROLE_USER does not exist"
return
}
User user = userService.createUser(cmd.username, cmd.password)
userRoleService.saveUserRole(user, roleUser)
(3)
UserDetails userDetails = new GrailsUser(user.username, user.password, user.enabled, !user.accountExpired,
!user.passwordExpired, !user.accountLocked, user.authorities as Collection<GrantedAuthority>, user.id)
AccessToken token = tokenGenerator.generateAccessToken(userDetails)
render status: HttpStatus.OK.value(), accessTokenJsonRenderer.generateJson(token)
}
}
1 | The @Secured annotation specifies the access controls for this controller - anonymous access is permitted |
2 | Check for duplicate usernames |
3 | Authenticate the newly created user, and generate the authentication token. This step saves the React app from having to make a second login request after the /signup request |
The controller uses several services as collaborators:
package demo
import grails.gorm.transactions.ReadOnly
import grails.gorm.transactions.Transactional
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
@Slf4j
@Transactional
@CompileStatic
class RoleService {
Role findByRoleName(String roleName) {
Role.where { authority == roleName }.get()
}
}
package demo
import grails.gorm.DetachedCriteria
import grails.gorm.transactions.ReadOnly
import grails.gorm.transactions.Transactional
import groovy.util.logging.Slf4j
@Slf4j
@Transactional
class UserService {
@ReadOnly
boolean existsUserByUsername(String username) {
findQueryByUsername(username).count()
}
DetachedCriteria<User> findQueryByUsername(String name) {
User.where { username == name }
}
User createUser(String username, String password, boolean flush = false) {
User user = new User(username: username, password: password)
if ( !user.save(flush: flush) ) {
log.error 'Unable to save user {}', user.errors.toString()
}
user
}
}
package demo
import grails.gorm.transactions.Transactional
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
@Slf4j
@Transactional
@CompileStatic
class UserRoleService {
UserRole saveUserRole(User user, Role role, boolean flush = false) {
UserRole ur = new UserRole(user: user, role: role)
if ( !ur.save(flush: flush) ) {
log.error 'Failure while saving user {}', ur.errors.toString()
}
ur
}
}
4.9 Flavors by User Endpoint
When the logs into the application or inmediately after signup, app he is presented with a list of his favourite flavors.
To fetch those flavors, the React app sends a GET request to the /ice-cream/$username endpoint. We are going to map this with Grails.
First, we register the endpoint in URLMappings by adding the next line into the mappings:
get "/ice-cream/$username"(controller: 'iceCream', action: 'index')
Next, we create a controller action which fetches a list of flavors for the logged username
package demo
import grails.plugin.springsecurity.SpringSecurityService
import grails.plugin.springsecurity.annotation.Secured
import groovy.transform.CompileDynamic
import groovy.transform.CompileStatic
@CompileStatic
@Secured(['ROLE_USER']) (1)
class IceCreamController {
static responseFormats = ['json']
static allowedMethods = [index: 'GET', save: 'POST', delete: 'DELETE',]
IceCreamService iceCreamService
UserIceCreamService userIceCreamService
SpringSecurityService springSecurityService
def index(Integer max) {
String username = loggedUsername()
if ( !username ) {
render status: 404
return
}
params.max = Math.min(max ?: 10, 100) (2)
List<IceCream> iceCreams = userIceCreamService.findAllIceCreamsByUsername(username)
[iceCreams: iceCreams] (3)
}
@CompileDynamic
protected String loggedUsername() {
springSecurityService.principal?.username as String
}
1 | The @Secured annotation specifies the access controls for this controller - authentication & ROLE_USER is required |
The controller action uses a service as a collaborators:
package demo
import grails.gorm.transactions.ReadOnly
import grails.gorm.transactions.Transactional
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
import org.springframework.validation.ObjectError
@Slf4j
@Transactional
@CompileStatic
class UserIceCreamService {
@ReadOnly
List<IceCream> findAllIceCreamsByUsername(String loggedUsername) {
UserIceCream.where {
user.username == loggedUsername
}.list()*.iceCream as List<IceCream>
}
}
We render JSON with the help of JSON Views
import demo.IceCream
model {
Iterable<IceCream> iceCreams
}
json tmpl.icecream(iceCreams)
Unresolved directive in <stdin> - include::/home/runner/work/grails-vs-nodejs/grails-vs-nodejs/complete/server/grails-app/views/iceCream/_iceCream.gson[]
4.10 Save User favourite Flavor
A user can add a flavor from his profile.
To do that, the React app sends a POST request to the /ice-cream/$username endpoint.
We are going to map this with Grails.
First, we register the endpoint in URLMappings by adding the next line into the mappings:
post "/ice-cream/$username"(controller: 'iceCream', action: 'save')
Next, we create a controller action which adds a flavor for the logged username:
package demo
import grails.plugin.springsecurity.SpringSecurityService
import grails.plugin.springsecurity.annotation.Secured
import groovy.transform.CompileDynamic
import groovy.transform.CompileStatic
@CompileStatic
@Secured(['ROLE_USER']) (1)
class IceCreamController {
static responseFormats = ['json']
static allowedMethods = [index: 'GET', save: 'POST', delete: 'DELETE',]
IceCreamService iceCreamService
UserIceCreamService userIceCreamService
SpringSecurityService springSecurityService
def save(String flavor) {
String username = loggedUsername()
if ( !username ) {
render status: 404
return
}
def id = iceCreamService.addIceCreamToUser(username, flavor)?.id
render id ?: [status: 500]
}
@CompileDynamic
protected String loggedUsername() {
springSecurityService.principal?.username as String
}
1 | The @Secured annotation specifies the access controls for this controller - authentication & ROLE_USER is required |
The controller action uses a service as a collaborators:
package demo
import grails.compiler.GrailsCompileStatic
import grails.gorm.DetachedCriteria
import grails.gorm.transactions.ReadOnly
import grails.gorm.transactions.Transactional
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
import org.springframework.validation.ObjectError
@Slf4j
@CompileStatic
@Transactional
class IceCreamService {
/**
* @return null if an error occurs while saving the ice cream or the association between icream and user
*/
@GrailsCompileStatic
IceCream addIceCreamToUser(String username, String iceCreamFlavor, boolean flush = false) {
User user = userService.findByUsername(username)
if ( !user ) {
log.error 'User {} does not exist', username
return null
}
IceCream iceCream = findQueryByFlavor(iceCreamFlavor).get() ?: new IceCream(flavor: iceCreamFlavor)
if(!iceCream.save(flush: flush)) {
iceCream.errors.allErrors.each { ObjectError error ->
log.error(error.toString())
}
return null
}
UserIceCream userIceCream = userIceCreamService.create(user, iceCream, flush)
if ( userIceCream.hasErrors() ) {
return null
}
iceCream
}
}
4.11 Delete User's Flavor
The user can remove a flavor from his profile.
To do that, the React app sends a DELETE request to the /ice-cream/$username/$id endpoint.
We are going to map this with Grails.
First, we register the endpoint in URLMappings by adding the next line into the mappings:
delete "/ice-cream/$username/$id"(controller: 'iceCream', action: 'delete')
Next, we create a controller action which deletes a particular flavor for the logged username:
package demo
import grails.plugin.springsecurity.SpringSecurityService
import grails.plugin.springsecurity.annotation.Secured
import groovy.transform.CompileDynamic
import groovy.transform.CompileStatic
@CompileStatic
@Secured(['ROLE_USER']) (1)
class IceCreamController {
static responseFormats = ['json']
static allowedMethods = [index: 'GET', save: 'POST', delete: 'DELETE',]
IceCreamService iceCreamService
UserIceCreamService userIceCreamService
SpringSecurityService springSecurityService
def delete(Long id) {
String username = loggedUsername()
if ( !username ) {
render status: 404
return
}
respond iceCreamService.removeIceCreamFromUser(username, id) ?: [status: 500]
}
@CompileDynamic
protected String loggedUsername() {
springSecurityService.principal?.username as String
}
1 | The @Secured annotation specifies the access controls for this controller - authentication & ROLE_USER is required |
The controller action uses a service as a collaborators:
package demo
import grails.compiler.GrailsCompileStatic
import grails.gorm.DetachedCriteria
import grails.gorm.transactions.ReadOnly
import grails.gorm.transactions.Transactional
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
import org.springframework.validation.ObjectError
@Slf4j
@CompileStatic
@Transactional
class IceCreamService {
Boolean removeIceCreamFromUser(String loggedUsername, Long id) {
IceCream iceCreamEntity = IceCream.load(id)
User loggedUser = User.load(loggedUsername)
UserIceCream.where {
user == loggedUser && iceCream == iceCreamEntity
}.deleteAll()
}
}
4.12 Websockets
Our final server-side feature is to push “session timeout” events to the client over a websocket connection. We’ll use another Grails plugin, the Spring Websocket plugin, to support this feature.
Install the plugin by adding another line to our build.gradle
file:
compile 'org.grails.plugins:grails-spring-websocket:2.3.0'
We now have to implement three classes to get our web socket session timeout working:
-
A configuration class to configure our websocket connection
-
A “listener” class to keep track of when new authentication tokens are created
-
A “scheduler” class to periodically check for “expired” sessions and push events over the websocket connection.
Because these classes are not Grails-specific, we will create them as
Groovy classes under server/src/main/groovy
.
Here is the complete code for these three classes:
package demo
import grails.plugin.springwebsocket.DefaultWebSocketConfig
import groovy.util.logging.Slf4j
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Configuration
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker
import org.springframework.web.socket.config.annotation.StompEndpointRegistry
@Slf4j
@Configuration
@EnableWebSocketMessageBroker
class CustomWebSocketConfig extends DefaultWebSocketConfig {
@Value('${allowedOrigin}')
String allowedOrigin (1)
@Override
void registerStompEndpoints(StompEndpointRegistry stompEndpointRegistry) { (2)
log.info 'registerStompEndpoints with allowedOrigin: {}', allowedOrigin
stompEndpointRegistry.addEndpoint("/stomp").setAllowedOrigins(allowedOrigin).withSockJS()
}
}
1 | Loads our allowedOrigin config property from application.yml |
2 | Configures the websocket connection to accept requests from our client server |
package demo
import org.springframework.context.ApplicationListener
import grails.plugin.springsecurity.rest.RestTokenCreationEvent
class TokenCreationEventListener implements ApplicationListener<RestTokenCreationEvent> {
void onApplicationEvent(RestTokenCreationEvent event) { (1)
User.withTransaction { (2)
User user = User.where { username == event.principal.username }.first()
user.lastLogin = new Date()
user.save(flush: true)
}
}
}
1 | We are extending a ApplicationListener interface, which is part of the Spring Framework and allows us to "listen" to specific application events. In this case, we are listening for RestTokenCreationEvent . You can find other events to listen for in the Spring Security REST plugin documentation. |
2 | The withTransaction method is needed here because our custom class doesn’t have access to GORM by default (unlike controllers and services). The User domain class is not actually important - we could use any domain class here. withNewSession will initiate a GORM/Hibernate transaction and allow us to query the database and persist changes. See the GORM documentation for more details. |
package demo
import groovy.time.TimeCategory
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Value
import org.springframework.messaging.simp.SimpMessagingTemplate
import org.springframework.scheduling.annotation.Scheduled
class SessionExpirationJobHolder {
@Autowired
SimpMessagingTemplate brokerMessagingTemplate (1)
@Value('${timeout.minutes}') (2)
Integer timeout
@Scheduled(cron = "0 * * * * *") (3)
void findExpiredSessions() {
Date timeoutDate
use( TimeCategory ) { (4)
timeoutDate = new Date() - timeout.minutes
}
User.withTransaction {
List<User> expiredUsers = User.where { //Query for loggedIn users with a lastLogin date after the timeout limit
lastLogin != null
lastLogin < timeoutDate
}.list()
//Iterate over the expired users
expiredUsers.each { user ->
user.lastLogin = null //Reset lastLogin date
user.save(flush: true)
(5)
brokerMessagingTemplate.convertAndSend "/topic/${user.username}".toString(), "logout"
}
}
}
}
1 | This class is provided by the spring-websocket plugin and allows us to push an event over a websocket channel |
2 | Loads our timeout.minutes property from application.yml |
3 | Run method every minute |
4 | Use Groovy’s TimeCategory DSL for time operations |
5 | Send a websocket message to a user-specific "channel" for each expired user - we’re using their username as the unique key for each channel |
With these classes in place, we need to plug them into the Spring
context by adding them as “beans” in our resources.groovy
file. Edit
the file as shown below.
import demo.UserPasswordEncoderListener
import demo.SessionExpirationJobHolder
import demo.TokenCreationEventListener
import demo.CustomWebSocketConfig
// Place your Spring DSL code here
beans = {
userPasswordEncoderListener(UserPasswordEncoderListener) (1)
webSocketConfig(CustomWebSocketConfig) (2)
tokenCreationEventListener(TokenCreationEventListener) (3)
sessionExpirationJobHolder(SessionExpirationJobHolder) (4)
}
1 | Register the password encoder listener |
2 | Custom settings for websockets |
3 | Listens for new access tokens and sets the loginDate |
4 | Checks for expired sessions |
Next, in order for our scheduled SessionExpirationJobHolder
class to
actually fire as scheduled, we have to enable scheduling in our
application (it’s not on by default). We do that by editing our app’s
Application.groovy
file (note the capitalization!).
package demo
import grails.boot.GrailsApp
import grails.boot.config.GrailsAutoConfiguration
@EnableScheduling
class Application extends GrailsAutoConfiguration {
static void main(String[] args) {
GrailsApp.run(Application, args)
}
}
And finally, you may remember we referenced a couple of new config
properties in the classes above. Let’s add those to our
application.yml
file (add the lines below to the end of the file).
allowedOrigin: https://localhost:3000 # accepted origin URL for websocket connections
timeout:
minutes: 1 #config setting for timeout
Grails applications can read config values set in a variety of ways, including YAML files, Groovy files, and system properties. See the Grails documentation for more on how to use configuration files. |
That’s all for the Grails backend server - we have support for CORS (out of the box), websockets, authentication, RESTful web services, scheduled methods, and persistence to PostgresSQL.
4.13 React
Now we’re ready to turn to the client-side portion of our app. For this step, we are going to be using the code found in the Github repo for the previous “Web App” article. You can access the code here: https://github.com/mvolkmann/ice-cream-app/tree/master/src
Download (or git clone
) the source files at the URL above, and copy
them into the client/src
directory (overwriting any existing files).
~ cd ../
~ git clone https://github.com/mvolkmann/ice-cream-app tmp
~ cp -Rv tmp/* ice-cream/client/
This will make the client
subproject identical to the Ice Cream app
from the previous article.
Now, let’s delete the files we don’t need.
~ cd ice-cream/client
~ rm -rf database/ server/ css/ images/
This should leave you with the following directories under client
:
-rw-r--r-- LICENSE
-rw-r--r-- README.md
drwxr-xr-x build
-rw-r--r-- build.gradle
drwxr-xr-x node_modules
-rw-r--r-- package.json
drwxr-xr-x public
drwxr-xr-x src
-rw-r--r-- yarn.lock
And the following files under client/src
:
-rw-r--r-- App.css
-rw-r--r-- App.js
-rw-r--r-- App.test.js
-rw-r--r-- config.js
-rw-r--r-- ice-cream-entry.js
-rw-r--r-- ice-cream-list.js
-rw-r--r-- ice-cream-row.js
-rw-r--r-- index.css
-rw-r--r-- index.js
-rw-r--r-- login.js
-rw-r--r-- main.js
We only have to edit 2 of these src
files, and update our
package.json
, in order to hook up the React app with our new Grails
backend.
First, edit client/package.json
as shown below:
{
"name": "ice-cream-app",
"version": "0.1.0",
"private": true,
"devDependencies": {
"eslint": "^3.17.1",
"eslint-plugin-flowtype": "^2.30.3",
"eslint-plugin-react": "^6.10.0",
"react-scripts": "0.8.4"
},
"dependencies": {
"react": "^15.4.2",
"react-dom": "^15.4.2",
"sockjs-client": "^1.1.4",
"stompjs": "^2.3.3"
},
"scripts": {
"build": "react-scripts build",
"coverage": "npm test -- --coverage",
"lint": "eslint src/**/*.js server/**/*.js",
"start": "react-scripts start",
"test": "react-scripts test --env=jsdom"
}
}
We’ve removed several packages that were needed by the Node Express
server, and we’ve replaced the socket.io
package with sockjs-client
and stompjs
, which is supported by the Spring Websockets library we’ve
configured previously (socket.io
is designed for Node apps and is not
compatible with Spring Websockets).
We also need to edit the config.js
file. This is provided by the React
profile for Grails and simply holds a few config variables, including a
SERVER_URL
value which sets the API base URL. Edit the file as shown
below:
import pjson from './../package.json';
export const SERVER_URL = 'http://localhost:8080';
export const CLIENT_VERSION = pjson.version;
export const REACT_VERSION = pjson.dependencies.react;
Now we have to update two of the React components in our Ice Cream app,
Login
and App
.
Edit the login.js
file as shown below (changes from the original
code are marked with //NEW:
comments).
import React, {Component, PropTypes as t} from 'react';
import 'whatwg-fetch';
function onChangePassword(event) {
React.setState({password: event.target.value});
}
function onChangeUsername(event) {
React.setState({username: event.target.value});
}
class Login extends Component {
static propTypes = {
password: t.string.isRequired,
restUrl: t.string.isRequired,
username: t.string.isRequired,
timeoutHandler: t.func //NEW: propType for our timeoutHander function
};
// This is called when the "Log In" button is pressed.
onLogin = async () => {
const {password, restUrl, username, timeoutHandler} = this.props;
const url = `${restUrl}/login`;
try {
// Send username and password to login REST service.
const res = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({username, password})
});
if (res.ok) { // successful login
const text = await res.text(); // returns a promise
//NEW: Spring Security REST returns the token in the response body, not the Authorization header
const token = `Bearer ${JSON.parse(text).access_token}`;
React.setState({
authenticated: true,
error: null, // clear previous error
route: 'main',
token
});
timeoutHandler(username) //NEW: Connects to a user-specific websocket channel
} else { //NEW: Any error response from the server indicates a failed login
const msg = "Invalid username or password";
React.setState({error: msg});
}
} catch (e) {
React.setState({error: `${url}; ${e.message}`});
}
}
// This is called when the "Signup" button is pressed.
onSignup = async () => {
const {password, restUrl, username, timeoutHandler} = this.props;
const url = `${restUrl}/signup`;
try {
const res = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({username, password})
});
if (res.ok) { // successful signup
const text = await res.text(); // returns a promise
const token = `Bearer ${JSON.parse(text).access_token}`; //NEW: See above
React.setState({
authenticated: true,
error: null, // clear previous error
route: 'main',
token
});
timeoutHandler(username) //NEW: Connect to user-specific websocket channel
} else { // unsuccessful signup
let text = await res.text(); // returns a promise
if (/duplicate key/.test(text)) {
text = `User ${username} already exists`;
}
React.setState({error: text});
}
} catch (e) {
React.setState({error: `${url}; ${e.message}`});
}
};
render() {
const {password, username} = this.props;
const canSubmit = username && password;
// We are handling sending the username and password
// to a REST service above, so we don't want
// the HTML form to submit anything for us.
// That is the reason for the call to preventDefault.
return (
<form className="login-form"
onSubmit={event => event.preventDefault()}>
<div className="row">
<label>Username:</label>
<input type="text"
autoFocus
onChange={onChangeUsername}
value={username}
/>
</div>
<div className="row">
<label>Password:</label>
<input type="password"
onChange={onChangePassword}
value={password}
/>
</div>
<div className="row submit">
{/* Pressing enter in either input invokes the first button. */}
<button disabled={!canSubmit} onClick={this.onLogin}>
Log In
</button>
<button disabled={!canSubmit} onClick={this.onSignup}>
Signup
</button>
</div>
</form>
);
}
}
export default Login;
Finally, we can edit the App.js
file to support our new websocket
channels, as well as our SERVER_URL
config setting. Like above, all
changes relative to the original code are marked with NEW
comments.
import React, {Component} from 'react';
import Login from './login';
import Main from './main';
import 'whatwg-fetch'; // for REST calls
import {SERVER_URL} from './config'; //NEW: Base url for REST calls
import './App.css';
// This allows the client to listen to sockJS events
// emitted from the server. It is used to proactively
// terminate sessions when the session timeout expires.
import SockJS from 'sockjs-client'; //NEW: SockJS & Stomp instead of socket.io
import Stomp from 'stompjs';
class App extends Component {
constructor() {
super();
// Redux is a popular library for managing state in a React application.
// This application, being somewhat small, opts for a simpler approach
// where the top-most component manages all of the state.
// Placing a bound version of the setState method on the React object
// allows other components to call it in order to modify state.
// Each call causes the UI to re-render,
// using the "Virtual DOM" to make this efficient.
React.setState = this.setState.bind(this);
}
//NEW: Remove the top-level websocket config in favor of user-specific channels (set on login)
// This is the initial state of the application.
state = {
authenticated: false,
error: '',
flavor: '',
iceCreamMap: {},
password: '',
restUrl: SERVER_URL, //NEW: Use our SERVER_URL variable
route: 'login', // controls the current page
token: '',
username: ''
};
/**
* NEW: Clears the token and redirects to the login page.
* No logout API call is needed because JWT tokens
* expire w/o state changes on the server */
logout = async () => {
React.setState({
authenticated: false,
route: 'login',
password: '',
username: ''
});
};
/**
* NEW: This function will be passed into the Login component as a prop
* This gets a SockJS connection from the server
* and subscribes to a "topic/[username]" channel for timeout events.
* If one is received, the user is logged out. */
timeoutHandler = (username) => {
const socket = new SockJS(`${SERVER_URL}/stomp`);
const client = Stomp.over(socket);
client.connect({}, () => {
client.subscribe(`/topic/${username}`, () => {
alert('Your session timed out.');
this.logout();
});
}, () => {
console.error('unable to connect');
});
};
render() {
// Use destructuring to extract data from the state object.
const {
authenticated, error, flavor, iceCreamMap,
password, restUrl, route, token, username
} = this.state;
return (
<div className="App">
<header>
<img className="header-img" src="ice-cream.png" alt="ice cream" />
Ice cream, we all scream for it!
{
authenticated ?
<button onClick={this.logout}>Log out</button> :
null
}
</header>
<div className="App-body">
{
// This is an alternative to controlling routing to pages
// that is far simpler than more full-blown solutions
// like react-router.
route === 'login' ?
<Login
username={username}
password={password}
restUrl={restUrl}
timeoutHandler={this.timeoutHandler} //NEW: Pass our timeoutHandler as a prop to <Login>
/> :
route === 'main' ?
<Main
flavor={flavor}
iceCreamMap={iceCreamMap}
restUrl={restUrl}
token={token}
username={username}
/> :
<div>Unknown route {route}</div>
}
{
// If an error has occurred, render it at the bottom of any page.
error ? <div className="error">{error}</div> : null
}
</div>
</div>
);
}
}
export default App;
5 Running the Application
Run the server application as follows:
~ ./gradlew server:bootRun
Grails application running at http://localhost:8443 in environment: development
Run the client side application (with HTTPS enabled) as follows:
~ export HTTPS = true
~ ./gradlew client:bootRun
Starting the development server...
Compiled successfully!
The app is running at:
https://localhost:3000/
You should be able to browser to https://localhost:3000
, create a new
user, and login. Try out the app! Add/remove some flavors to your user
account. After one minute, your user session will end and you’ll be
logged out (you can change this duration in the Grails’ app’s
application.yml
file). If you open multiple browsers and login with
different users at different times, each user will be logged out
separately only when their own session expires.
Congratulations! You’ve got the Ice Cream app running with a Grails backend.
5.1 Deployment
Deployment is a big topic on its own and one that we won’t cover in-depth. As with many Java web apps, our Grails backend can be deployed to any JEE container as a WAR file (this will require some additional configuration), or more cleanly as a self-contained JAR file (which doesn’t require a separate server to run).
Our React app can be built just like any create-react-app
project
using npm run build
(or yarn build
), and deployed to a standard web
server. Obviously attention must be given to making sure the React
client can reach the API backend (and the SERVER_URL
variable may need
to be set accordingly).
An attractive way to package and deploy a Grails/React app is to bundle both apps together into a single executable JAR file. This approach is documented in tutorial form in the “Combining the React profile projects” Grails Guide.
5.2 Summary
Of course this project has been a bit unusual in that we were taking an app designed with one technology stack and reimplementing in another. However, hopefully this exercise has shown you the capabilities and flexibility of the Grails framework, particularly with the minimal amount of changes needed to use the same React app against the new Grails backend.
Grails offers a slew of powerful web development features that we haven’t had occasion or time to demonstrate in this app - including JSON views, HAL+JSON support, custom data validation and constraints, NoSQL support, multi-tenancy, and much more. Please check out the Grails Guides website for more tutorials on building fun and powerful apps with Grails (including several Guides featuring React).