Grails OOB(out of the box)
Getting started¶
For this tutorial you will need
-
JDK (I advise 8, but you can take 7 as well).
-
Git.
-
Grails 3.2.11 (you can install it with http://sdkman.io on most Unix based systems.)
After all is installed clone the repo:
git clone https://github.com/9ci/angle-grinder
and switch to branch rest_tutorial
branch, and go to angle-grinder/grails/restTutorial
, the final result is in
the snapshot
folder for each step
So first let's create new grails app:
$ grails create-app -profile rest-api -features hibernate4 resttutorail Application created at angle-grinder/grails/restTutorial
Grails 3 provides several different profiles you can read about them in the grails docs
Creating an API with Grails Web Services¶
As described in the grails ws docs we will use the default out of the box functionality as a starting point.
Creating a GORM domain¶
grails create-domain-class Contact
Then set it up like so:
Contact.groovy
package resttutorial class Contact { String firstName String lastName String email Boolean inactive static constraints = { firstName nullable: false inactive bindable: false } }
To avoid writing nullable: true
we will set the default to allow nulls for fields
Add the following to grails-app/conf/application.groovy
application.groovy
grails.gorm.default.constraints = { '*' (nullable: true, blank: true) }
We will load 100 rows of mock test data from a file Contacts.json
in resources.
The mock data was generated from a great tool https://www.mockaroo.com
Add the following code to grails-app/init/BootStrap.groovy
BootStrap.groovy
package resttutorial import groovy.json.JsonSlurper class BootStrap { def grailsApplication def init = { servletContext -> def data = new JsonSlurper().parse(new File("../resources/Contacts.json")) data.each{ Contact contact = new Contact(it) contact.save(failOnError:true,flush: true) } } def destroy = { } }
Adding the @Resource
annotation to our domain¶
:url-dr: {docs-grails}#domainResources
So now we can start working on creating REST Api for our app. The easiest way is to use {url-dr}[domain resources]. So as we see from {url-dr}[docs] we just need to update our domain a bit (just add {docs-grails-api}/grails/rest/Resource.html[@Resource] anotation) in such a way:
Contact.groovy
import grails.rest.Resource @Resource(uri = '/contact', formats = ["json"]) class Contact { ... }
:memo On plural resource names As you will notice we did not pluralize it to contacts above as many will do. We are aware of the debate on this in the rest world. We feel this will cause confusion down the line to do it.
- English plural rules like "cherry/cherries" or "goose/geese/moose/meese" are not the nicest thing to think of while developing API, particularly when english is not your mother tongue.
- Many times, as in Grails, we want to generate endpoint from the model, which is usually singular. It does not play nicely with the above pluralization exceptions and creates more work maintaining UrlMappings.
- When the model is singular, which is normally is for us, keeping the rest endpoint singular will have the rest developers and the grails developers speaking the same language
- The argument "usually you start querying by a Get to display a list" does not refer to any real use case. And we will end up querying single items as much as and even more than a list of items.
The RestfullController
¶
@Resource
creates a RestfullController for the domain
The
@Resource
annotation
is used in an ASTTransformation that creates a controller that extends RestfullController. See ResourceTransform for details on how it does this. Later we will show how to specify the controller to user with superClass property.
Default Endpoints and Status Codes¶
Url Mappings¶
The Extending Restful Controllers section of the grails docs outlines the action names and the URIs they map to:
.URI, Controller Action and Response Defaults [cols="2,1,1,3", format="csv", options="header", width="80",grid=rows] |=== URI, Method, Action, Response Data /contact , GET , index , Paged List /contact/create, GET , create , Contact.newInstance() unsaved /contact, POST , save , The successfully saved contact (same as show's get) /contact/\({id}, GET , show , The contact for the id /contact/\)/edit, GET , edit , The contact for the id. same as show /contact/\({id}, PUT , update , The successfully updated contact /contact/\), DELETE , delete , Empty response with HTTP status code 204 |===
==== Status Code Defaults
Piecing together the {docs-HttpStatus}[HttpStatus codes] and results from RestfullController, RestResponder and _errors.gson, these are what looks like the out of the box status codes as of Grails 3.2.2
.Status Codes Out Of Box
[options="header", cols="1,2", grid=rows]
|===
| Status Code | Description
| 200 - OK | Everything worked as expected. default
| 201 - CREATED | Resource/instance was created. returned from save
action
| 204 - NO_CONTENT | response code on successful DELETE request
| 404 - NOT_FOUND | The requested resource doesn't exist.
| 405 - METHOD_NOT_ALLOWED | If method (GET,POST,etc..) is not setup in static allowedMethods
for action or resource is read only
| 406 - NOT_ACCEPTABLE | Accept header requests a response in an unsupported format. not configed in mime-types. RestResponder uses this
| 422 - UNPROCESSABLE_ENTITY | Validation errors.
|===
=== API Namespace
A Namespace is a mechanism to partition resources into a logically named group.
So the controllers that response for the REST endpoints we will move to separate namespace to avoid cases when we need to have Controllers for GSP rendering or some other not related to REST stuff.
As a our preferred namespace design we will use the "api" namespace prefix for the rest of the tutorial.
So we will add namespace = 'api'
on the contact @Resource. @Resource has also property uri
but it will override namespace property,
for example if @Resource(namespace = 'api', uri='contacts', formats = ["json"]) url for resource will be localhost:8080/contacts
, not
.Contact.groovy
@Resource(namespace = 'api', formats = ["json"]) class Contact
Also we need to update UrlMappings.groovy, there are two ways:
- Add
/api
prefix to each mapping for exampleget "/api/$controller(.$format)?"(action:"index")
- Use
group
property
We will use the second case:
.UrlMappings.groovy
package resttutorial class UrlMappings { static mappings = { group("/api") { delete "/$controller/$id(.$format)?"(action:"delete") get "/$controller(.$format)?"(action:"index") get "/$controller/$id(.$format)?"(action:"show") post "/$controller(.$format)?"(action:"save") put "/$controller/$id(.$format)?"(action:"update") patch "/$controller/$id(.$format)?"(action:"patch") } ... } }
You can see all available endpoints that Grails create for us with url-mappings-report:
$ grails url-mappings-report [options="header", cols="1,2", grid=rows] Dynamic Mappings | * | ERROR: 500 | View: /error | | * | ERROR: 404 | View: /notFound | | GET | /api/\({controller}(.\){format)? | Action: index | | POST | /api/\({controller}(.\){format)? | Action: save | | DELETE | /api/\({controller}/\)(.\({format)? | Action: delete | | GET | /api/\)/\({id}(.\){format)? | Action: show | | PUT | /api/\({controller}/\)(.\({format)? | Action: update | | PATCH | /api/\)/\({id}(.\){format)? | Action: patch |
Controller: application | * | / | Action: index |
Controller: contact | GET | /api/contact/create | Action: create | | GET | /api/contact/\({id}/edit | Action: edit | | POST | /api/contact | Action: save | | GET | /api/contact | Action: index | | DELETE | /api/contact/\) | Action: delete | | PATCH | /api/contact/\({id} | Action: patch | | PUT | /api/contact/\) | Action: update | | GET | /api/contact/${id} | Action: show |
=== Using CURL to test CRUD and List
Fire up the app with run-app
===== GET (list):¶
curl -i -X GET -H "Content-Type: application/json" localhost:8080/api/contact HTTP/1.1 200 X-Application-Context: application:development Content-Type: application/json;charset=UTF-8 Transfer-Encoding: chunked Date: Mon, 31 Jul 2017 12:30:31 GMT
[{"id":1,"email":"mscott0@ameblo.jp","firstName":"Marie","lastName":"Scott"},{"id":2,"email":"jrodriguez1@scribd.com","firstName":"Joseph","lastName":"Rodriguez"}, ...¶
===== POST:¶
curl -i -X POST -H "Content-Type: application/json" -d '{"firstName":"Joe", "lastName": "Cool"}' localhost:8080/api/contact HTTP/1.1 201 X-Application-Context: application:development Location: http://localhost:8080/api/contact/101 Content-Type: application/json;charset=UTF-8 Transfer-Encoding: chunked Date: Mon, 31 Jul 2017 12:30:44 GMT
{"id":101,"firstName":"Joe","lastName":"Cool"}¶
===== GET (by id):¶
curl -i -X GET -H "Content-Type: application/json" localhost:8080/api/contact/101 HTTP/1.1 200 X-Application-Context: application:development Content-Type: application/json;charset=UTF-8 Transfer-Encoding: chunked Date: Mon, 31 Jul 2017 12:31:00 GMT
{"id":101,"firstName":"Joe","lastName":"Cool"}¶
===== PUT:¶
curl -i -X PUT -H "Content-Type: application/json" -d '{"firstName": "New Name", "lastName": "New Last name"}' localhost:8080/api/contact/101 HTTP/1.1 200 X-Application-Context: application:development Location: http://localhost:8080/api/contact/101 Content-Type: application/json;charset=UTF-8 Transfer-Encoding: chunked Date: Mon, 31 Jul 2017 12:32:01 GMT
{"id":101,"firstName":"New Name","lastName":"New Last name"}¶
===== DELETE:¶
curl -i -X DELETE -H "Content-Type: application/json" localhost:8080/api/contact/50 HTTP/1.1 204 X-Application-Context: application:development Date: Mon, 31 Jul 2017 12:32:24 GMT
===== 422 - Post Validation Error:¶
curl -i -X POST -H "Content-Type: application/json" -d '{"lastName": "Cool"}' localhost:8080/api/contact HTTP/1.1 422 X-Application-Context: application:development Content-Type: application/json;charset=UTF-8 Transfer-Encoding: chunked Date: Mon, 31 Jul 2017 12:32:41 GMT
{"message":"Property [firstName] of class [class resttutorial.Contact] cannot be null","path":"/contact/index","_links":{"self":{"href":"http://localhost:8080/contact/index"}}}¶
===== 404 - Get Error:¶
curl -i -X GET -H "Content-Type: application/json" localhost:8080/api/contact/105 HTTP/1.1 404 X-Application-Context: application:development Content-Type: application/json;charset=UTF-8 Content-Language: en-US Transfer-Encoding: chunked Date: Mon, 31 Jul 2017 12:32:55 GMT
{"message":"Not Found","error":404}¶
===== 406 - NOT_ACCEPTABLE:
We did not setup XML support so we will get a 406. You may try adding XML to formats to see if this.¶
curl -i -X GET -H "Accept: application/xml" http://localhost:8080/api/contact/8 HTTP/1.1 406 X-Application-Context: application:development Content-Length: 0 Date: Mon, 31 Jul 2017 12:33:13 GMT
=== Functional Tests for the API
The next step is to add functional tests for our app. One option is to use Grails functional tests and RestBuilder. We will cover another javscript option later the angle-grinder section The line in the buidl.gradle that allows us to use RestBuilder is
testCompile "org.grails:grails-datastore-rest-client"¶
it is added by default when you create a grails app with -profile rest-api
==== POST testing example
Here is an example of POST
request (creating of a new contact).
RestBuilder we use to emulate request from external source. Note, in Grails3 integration tests run on the random port,
so you cant call http://localhost:8080/api/contact
, but we can use serverPort
variable instead. And to make it more
intelligent lets use baseUrl. See example:
.ContactSpec.groovy
package resttutorial import grails.plugins.rest.client.RestBuilder import grails.plugins.rest.client.RestResponse import grails.test.mixin.integration.Integration import org.grails.web.json.JSONElement import spock.lang.Shared import spock.lang.Specification @Integration class ContactSpec extends Specification { @Shared RestBuilder rest = new RestBuilder() def getBaseUrl(){"http://localhost:${serverPort}/api"} void "check POST request"() { when: RestResponse response = rest.post("${baseUrl}/contact"){ json([ firstName: "Test contact", email:"foo@bar.com", inactive:true //is bindable: false - see domain, so it wont be set to contact ]) } then: response.status == 201 JSONElement json = response.json json.id == 101 json.firstName == "Test contact" json.lastName == null json.email == "foo@bar.com" json.inactive == null } }
More tests examples are in the snapshot1 project's {url-snapshot1}/src/integration-test/groovy/resttutorial/ContactSpec.groovy[ContactSpec.groovy]
=== GSON and Grails Views Defaults
As you can see by inspecting the views directory, by default Grails creates a number of gson files. Support for them is provided with http://views.grails.org/latest/#_introduction[Grails Views Plugin]
The obvious question how does it work. If you look at sources of the RestfullController it doesn't "call" this templates
explicitly. So under the hood plugin just looks on request, if url ends on .json
(localhost:8080/api/contact/1.json) or if
Accept
header containing application/json
the .gson view will be rendered.
If you delete default generated templates, then it will show default Grails page. Go ahead and try to delete notFound.gson
and try
curl -i -X GET -H "Content-Type: application/json" localhost:8080/api/contact/105 HTTP/1.1 404 X-Application-Context: application:development Content-Type: text/html;charset=utf-8 Content-Language: en-US Content-Length: 990 Date: Mon, 31 Jul 2017 12:34:06 GMT