Grails MVC Application with Apache Camel
Apache Camel is exception. So exceptional in fact, as a developer at SaaS Industries, you will rely on it for nearly all of your work. Camel is an Enterprise Service Bus (ESB) that is well supported and well proven. While this lesson is not to make you a master of using Camel, it is intended to provide enough information so you realize its importance.
In our last lesson where we simply allowed for Grails scaffolding to build us endpoints to our domain models, we will now use Camel to build out the actual endpoint routes we want publicly consumed. We will learn some base Enterprise Integration Patterns (EIP) and see how Camel and Grails work very well together. We will also introduce Swagger UI to our endpoints, demonstrating the power of Swagger and why it is used for our applications.
Installation of Camel
Installing Camel into Grails is quite easy. We simply need to update our dependencies
in build.gradle
with the following lines of code
def camelVersion = '2.18.3'
dependencies {
...
...
...
compile group: 'org.apache.camel', name: 'camel-spring-boot', version:"${camelVersion}"
compile group: 'org.apache.camel', name: 'camel-core', version:"${camelVersion}"
compile group: 'org.apache.camel', name: 'camel-spring', version:"${camelVersion}"
compile group: 'org.apache.camel', name: 'camel-test-spring', version:"${camelVersion}"
compile group: 'org.apache.camel', name: 'camel-groovy', version:"${camelVersion}"
compile group: 'org.apache.camel', name: 'camel-http4', version:"${camelVersion}"
compile group: 'org.apache.camel', name: 'camel-netty4-http', version:"${camelVersion}"
compile group: 'org.apache.camel', name: 'camel-jackson', version:"${camelVersion}"
compile group: 'org.apache.camel', name: 'camel-swagger-java', version:"${camelVersion}"
Using Apache Camel
In this application's lesson, we are simply going to use Camel to create our publicly consumed endpoints. While the scaffolding automates the View and Controller endpoints, the scaffolding is simply a tool for us to use internally. Because of this fact, we will eventually see that we put Camel endpoints on their own port. This is two-fold. First, Since Camel runs its own web containers, and that Grails is using port 8080, hence we cannot use it for Camel. While port 8080 will be used by the Fabric8 system to access health checks, our public consumer of our API will use the Camel endpoints. We will try to use port 7777 for Camel. This port was chosen to keep logical separation between everything and hopefully to help a developer to quickly realize the ports purpose (eg. 8xxx vs 7xxxx).
Creating an Apache Camel Service
We will not create our first service using Apache Camel. The reason why this is a service and not a controller is because our Grails controllers are scaffolded out for internal use. We will expose endpoints through a path named services/routes
. Since Camel can run standalone, we are technically building these services as parts "separate" from Grails. But because the Camel services are tied into the Grails runtime, we will be able to access the entire Grails application.
Our application is simply an Address Book that is keyed by a Person
. This means we do not need routes for Address
nor PhoneNumber
, instead, when we access a Person
through a RESTful route, we will be able to get the Address
and PhoneNumber
this way. Being said, we need to create that PersonService
.
Unlike normal, instead of using grails create-service
, we will instead create these manually. As we create what Grails considers "normal" services, we will then use their built in, opinionated, command line tool.
mkdir grails-app/services/addressbook/routes
touch grails-app/services/routes/addressbook/PersonRouteService.groovy
Opening that file in your IDE, add the following stub code
package addressbook.routes
import org.apache.camel.builder.RouteBuilder
class PersonRouteService extends RouteBuilder {
@Override
void configure() throws Exception {
}
}
RouteBuilder
is a Camel class that requires implementation of configure
. configure
will be called as the application starts, building out the endpoint for the public to use.
Configuring the Endpoint
As stated by the method stub, our RouteBuilder
requires some configuration. We will be creating a RESTful endpoint, so let's add some code to that method and update our class to the following:
package addressbook.routes
import org.apache.camel.builder.RouteBuilder
import org.apache.camel.model.rest.RestBindingMode
class PersonRouteService extends RouteBuilder {
final static String apiVersionPath = "/v1"
@Override
void configure() throws Exception {
restConfiguration()
// the web container component that will accept connections
.component('netty4-http')
// use http in this demo
.scheme('http')
// accept connections from anyone on port 7777
.host('0.0.0.0').port(7777)
// set a api context path starting with `/api`
.contextPath('/api')
// restrict consumption to JSON
.bindingMode(RestBindingMode.json)
// set an api context path, `/v1`
.apiContextPath("$apiVersionPath/api-doc")
// title our route for swagger
.apiProperty("api.title", "person list")
// set some reference properties for swagger
.apiProperty("api.version", "1.0.0")
// tell swagger we will use cors
.apiProperty("cors", "true")
// allow cross origin resource sharing (cors)
.enableCORS(true)
// simply print json pretty
.dataFormatProperty("prettyPrint", "true")
}
}
Each method used in the above code is commented for clarity, but for brevity, it will be removed in later examples. You should be able to tell that this is simply setting up our container to accept RESTful connections using JSON. Later we will cover Swagger, but of now, we can still inject the properties to assist in the Swagger documentation.
Building RESTful Routes
The first thing we want to do is call the Camel DSL for rest. We also want to tell it to consume and produce json.
@Override
void configure() throws Exception {
...
...
...
rest("$apiVersionPath/person")
.consumes('application/json')
.produces('application/json')
Adding - GET /person
For our example, our Read operation in our CRUD methods will only produce a findAll
and findByID
equivalent. Being said, let us first create the findAll
route. Adding to the rest
method DSL:
void configure() throws Exception {
...
...
...
rest("$apiVersionPath/person")
.consumes('application/json')
.produces('application/json')
.get()
.description('get the list of people in the address book, returning the name and id')
.type(PersonDTO.class)
.outType(ListPersonDTO.class)
.responseMessage()
.code(200)
.message("Success")
.endResponseMessage()
.route()
.to('log:persons-findall?level=INFO&showAll=false&multiline=true')
.to('bean:personService?method=findAll()')
.endRest()
If you notice, our outType
is a ListPersonDTO
. We will need to create this class now. Under utils
, create a package addressbook.dtos
and then create the class ListPersonDTO
package addressbook.dtos
class ListPersonDTO {
Long id
String name
}
We will also need the PersonDTO
since that is our type
package addressbook.dtos
class PersonDTO {
Long id
String name
List<AddressDTO> addresses
List<PhoneNumberDTO> phoneNumbers
}
And finally our supporting DTOs
package addressbook.dtos
class AddressDTO {
Long id
String address
}
package addressbook.dtos
class PhoneNumberDTO {
Long id
String number
}
Looking back at our RESTful route and the GET request execution for findALL
, you will notice we have a DSL method named route
. This method tells the Camel layer to take the GET request from REST and hand it off to the ESB. Inside this method we have a our first to
method. The to
method activates a service to work upon the message. In our case, the message is a PersonDTO
. This type conversion from JSON to PersonDTO is handled automatically for us through Camel type conversion at the type(PersonDTO.class)
DSL call. The first service we are activating is the log. The second service we are activating is a bean named PersonService
, which if we were doing traditional MVC, would be the same service we'd call from the controller. Let us create that service with the Grails helpers
grails create-service PersonService
Once we have created our PersonService
, modify the class to look as the following:
package addressbook
import grails.transaction.Transactional
@Transactional
class PersonService {
def findAll(){
Person.list()
}
}
This service doesn't do much. It simply delegates to the domain model and via Gorm, finds all people. Of course, this finds all Person
, so it would be wise for a production system with millions of Person
.
If we now visit our browser we will see the following:
Our application returns a list of Person
, and of course its empty because we have added no Person
to query. For a sanity check, add a Person
to init/BootStrap
. We typically will not add data here, but since this is for the learning process, we can simply do it here, or use our scaffolded MVC application directly to manually add some test data.
package addressbook
class BootStrap {
def init = { servletContext ->
def me = new Person(name: 'anthony')
me.save(failOnErrors: true, flush: true)
def address = new Address(person: me, address: "123 e street")
address.save(failOnErrors: true, flush: true)
def phone = new PhoneNumber(person: me, number: '123-123-1234')
phone.save(failOnErrors: true, flush: true)
}
def destroy = {
}
}
If you chose the bootstrap route, you will need to restart your application for the changes to take effect.
Now if you revisit the route, you will notice that you get a runtime error stating that the application cannot build the JSON through the reference chain. We will have to update our PersonService
to coerce the domain models into DTOs. Update PersonService
to the following source:
package addressbook
import addressbook.dtos.AddressDTO
import addressbook.dtos.PersonDTO
import addressbook.dtos.PhoneNumberDTO
import grails.transaction.Transactional
@Transactional
class PersonService {
def findAll(){
def persons = Person.list()
def dtos = []
persons.each { person ->
dtos.add(coerceToListPersonDTO(person))
}
dtos
}
ListPersonDTO coerceToListPersonDTO(Person person) {
ListPersonDTO.newInstance(person.properties.subMap(['id', 'name']))
}
}
Now if you revisit the route, you will see the following returned.
For the findAll
method, we have omitted any superfluous data pertaining to address or phone number. We simply want the list of users. Next, when we look at getting a person by a specific id, we will then return their associated data.
Add - GET /person/{id}
We now have a route to list all Person
, but if we wanted a specific Person
, REST tells us to use a route similar to /person/1
. The trailing number on route is the id to access. Because of there can be many people in our address book, it doesn't make sense to create a route for each person, instead we can use a placeholder, person/{id}
, in which the id will be injected by Camel.
This means we need to update our PersonRouteService
to reflect this new route. Update your class to the following code.
package addressbook.routes
import addressbook.dtos.ListPersonDTO
import addressbook.dtos.PersonDTO
import org.apache.camel.builder.RouteBuilder
import org.apache.camel.model.rest.RestBindingMode
class PersonRouteService extends RouteBuilder {
final static String apiVersionPath = "/v1"
@Override
void configure() throws Exception {
restConfiguration()
.component('netty4-http')
.scheme('http')
.host('0.0.0.0').port(7777)
.contextPath('/api')
.bindingMode(RestBindingMode.json)
.apiContextPath("$apiVersionPath/api-doc")
.apiProperty("api.title", "person list")
.apiProperty("api.version", "1.0.0")
.apiProperty("cors", "true")
.enableCORS(true)
.dataFormatProperty("prettyPrint", "true")
rest("$apiVersionPath/person")
.consumes('application/json')
.produces('application/json')
.get()
.description('get a apple')
.type(PersonDTO.class)
.outType(ListPersonDTO.class)
.responseMessage().code(200).message("Success").endResponseMessage()
.route()
.to('log:person-findall?level=INFO&showAll=false&multiline=true')
.to('bean:personService?method=findAll()')
.endRest()
// our new route
.get("/{id}")
.description('get a person via id')
.type(PersonDTO.class)
.outType(PersonDTO.class)
.responseMessage()
.code(200)
.message("Success")
.endResponseMessage()
.responseMessage()
.code(404)
.message("Person not found")
.endResponseMessage()
.route()
.to('log:persons-findall?level=INFO&showAll=false&multiline=true')
.to('bean:personService?method=findOne(${header.id})')
.endRest()
}
}
We add in to our responseMessage
DSL a 404 when a user of our application tries to access a non-existent Person
. Since we are dynamically crafting routes according to a Person.id
, it makes sense that respond with a 404 when that person is non-existent.
You should also notice that when we hand off the REST request to the ESB in the route
DSL, we are now calling upon a method named findOne
which takes an id
as a parameter. This means we have to update PersonService
to handle this request.
class PersonService {
...
...
...
def findOne(Long id){
coerceToPersonDTO(Person.findById(id))
}
PersonDTO coerceToPersonDTO(Person person){
def dto = PersonDTO.newInstance person.properties.subMap(['id', 'name'])
dto.addresses = new ArrayList<>()
dto.phoneNumbers = new ArrayList<>()
person.addresses.each { address ->
dto.addresses.add AddressDTO.newInstance(address.properties.subMap(['id', 'address']))
}
person.phoneNumbers.each { number ->
dto.phoneNumbers.add PhoneNumberDTO.newInstance(number.properties.subMap(['id', 'number']))
}
dto
}
}
Now if you revisit your browser, you can call the person/{id}
route and get a full valued listing.
Add - POST /person
To do
Add - UPDATE /person/{id}
To do
Add - DELETE /person/{id}
To do
Conclusion
In this article we used Apache Camel to build our RESTful routes and pass the request methods off to ESP activated services. While this satisfies our MVC application using Apache Camel, we still need to break apart the MVC into a micro-serviced based application. At this point you should have a reasonable understanding of how Grails is opinionated, but yet tolerable. We integrated Camel into Grails via the service layer, allowing for the Grails controller scaffolding to handle our developer space (port 8080) and using Camel REST to allow for our publicly accessible space (port 7777).