A Library REST API with Grails 8
Build a Book + Author REST API on Grails 8 using RestfulController, JSON views, structured 422 validation responses, bounded pagination, all-or-nothing bulk create, and URL-prefix versioning under /v1/.
Authors: James Fredley
Grails Version: 8
1 Getting Started
In this guide you will build a small Library REST API on Apache Grails 8 with two domain classes (Book and Author), a RestfulController-driven CRUD surface, JSON views (.gson), and the production-quality concerns most beginner REST guides skip: structured 422 validation responses, bounded pagination, URL-prefix versioning, and an all-or-nothing bulk-create endpoint.
This guide targets Apache Grails 8 / Spring Boot 4 / JDK 21.
1.1 What You Will Build
By the end of the guide you will have:
-
A
BookandAuthordomain pair, withBookbelongsTo: Authorand reasonable constraints (a regex-validated ISBN, a positivepageCount, optionaldateOfBirth). -
BookController extends RestfulController<Book>andAuthorController extends RestfulController<Author>, each scoped to JSON viaresponseFormatsand bounded by alistAllResourcesoverride that hard-capsparams.maxat 100. -
JSON views (
.gson) undergrails-app/views/book/andgrails-app/views/author/that render the API responses. Each view emits a body, embeds a HAL-style_linksobject, and reuses a shared_book.gson/_author.gsonpartial. -
A
POST /v1/books/bulkendpoint that accepts a JSON array, validates every entry, and returns a structured 422 with the field errors of every failing entry on the first validation failure (the whole transaction rolls back). -
URL-prefix versioning: every endpoint lives under
/v1/. The chapter on versioning compares this with theAccept: application/vnd.example.v2+jsoncontent-negotiation approach and recommends starting with URL prefixes. -
Sample data in
BootStrapsocurl http://localhost:8080/v1/booksreturns four real books from two real authors immediately after./gradlew bootRun.
1.2 What You Will Need
To complete this guide you will need:
-
JDK 21
-
About 30 minutes
-
curlor another HTTP client for hitting the running endpoints
1.3 How to Complete the Guide
You can either type the code in this guide as you read or skip ahead and clone the finished sample:
git clone -b grails8 https://github.com/grails-guides/grails-rest-library.git
cd grails-rest-library/complete
./gradlew bootRun
curl -s http://localhost:8080/v1/books | jq
initial/ is the vanilla Grails 8 rest_api starter from start.grails.org (with the postgres, testcontainers, views-json, and database-migration features). complete/ adds the Book + Author domain model, the controllers, the JSON views, and the bootstrap data.
2 Creating the Application
Generate a fresh Apache Grails 8 Rest API application from start.grails.org. Pick the rest_api application type (not web); it ships JSON views by default and skips the GSP view layer the regular web profile drags along.
2.1 Download a Grails 8 Rest API Starter
Open start.grails.org, pick the rest_api profile, name the application library, set the package to example, choose JDK 21, tick the postgres, testcontainers, views-json, and database-migration features, and download the generated zip. Unzip it and cd into the library directory.
The rest_api starter is leaner than the web starter: no asset pipeline, no GSP, no Bootstrap webjars. The views-json feature adds the .gson view support and the matching org.apache.grails:grails-views-gson dependency.
3 The Book and Author Domain Model
Two domain classes model the library. Author is independent; Book belongs to an Author.
package example
import grails.persistence.Entity
@Entity
class Author {
String name
String biography
Date dateOfBirth
static hasMany = [books: Book]
static constraints = {
name blank: false, maxSize: 255
biography nullable: true, maxSize: 4000
dateOfBirth nullable: true
}
static mapping = {
books fetch: 'lazy'
}
String toString() { name }
}
package example
import grails.persistence.Entity
@Entity
class Book {
String title
String isbn
Integer pageCount
Date publishedOn
static belongsTo = [author: Author]
static constraints = {
title blank: false, maxSize: 255
isbn blank: false, unique: true, maxSize: 20, matches: /^(97(8|9))?\d{9}(\d|X)$/
pageCount nullable: true, min: 1
publishedOn nullable: true
}
String toString() { title }
}
A few constraint choices to highlight:
-
name blank: false, maxSize: 255on Author andtitleon Book reject empty strings and cap database column width, so the rejection is surfaced at validation time, not as a SQL error. -
isbn matches: /^(97(8|9))?\d{9}(\d|X)$/is a soft ISBN-10/13 check. It is intentionally permissive (no checksum validation); the regex is a chapter-and-verse example of how Grails'matches:constraint composes withunique: true. -
pageCount nullable: true, min: 1allows an unset value but rejects zero or negative integers.
The static mapping block on Author lazy-loads books. With eager loading, every Author.list() call would fetch every book of every author - the textbook N+1 problem. Lazy loading delegates to GORM’s session lifecycle, and views explicitly opt back in to eager fetch via Author.list(fetch: [books: 'join']) when they need it.
3.1 Bootstrap Sample Data
A BootStrap class seeds two authors and four books on first startup, but only outside production:
package example
import grails.util.Environment
class BootStrap {
Closure init = { servletContext ->
if (Environment.current == Environment.PRODUCTION) {
log.info 'production environment - skipping bootstrap data'
return
}
if (Author.count() > 0) {
log.info 'authors already present - skipping bootstrap data'
return
}
Author.withTransaction {
Author tolkien = new Author(name: 'J.R.R. Tolkien',
biography: 'English writer and philologist (1892-1973).',
dateOfBirth: Date.parse('yyyy-MM-dd', '1892-01-03'))
.save(failOnError: true)
Author leguin = new Author(name: 'Ursula K. Le Guin',
biography: 'American author (1929-2018).',
dateOfBirth: Date.parse('yyyy-MM-dd', '1929-10-21'))
.save(failOnError: true)
new Book(author: tolkien, title: 'The Hobbit', isbn: '9780547928227', pageCount: 310,
publishedOn: Date.parse('yyyy-MM-dd', '1937-09-21')).save(failOnError: true)
new Book(author: tolkien, title: 'The Fellowship of the Ring', isbn: '9780547928210', pageCount: 423,
publishedOn: Date.parse('yyyy-MM-dd', '1954-07-29')).save(failOnError: true)
new Book(author: leguin, title: 'A Wizard of Earthsea', isbn: '9780547851402', pageCount: 205,
publishedOn: Date.parse('yyyy-MM-dd', '1968-11-01')).save(failOnError: true)
new Book(author: leguin, title: 'The Left Hand of Darkness', isbn: '9780441478125', pageCount: 304,
publishedOn: Date.parse('yyyy-MM-dd', '1969-03-01')).save(failOnError: true)
}
}
Closure destroy = { }
}
Two guards keep this safe:
-
Environment.current == Environment.PRODUCTIONearly-exit means the seed never runs against a real database. -
The
Author.count() > 0check makes the seed idempotent during development - subsequent restarts do not create duplicate rows.
The Author.withTransaction { } wrapper means the four save(failOnError: true) calls all commit together or all roll back. Without it, a failure on book 3 would leave books 1 and 2 in the database in a half-loaded state - and a bare save() in BootStrap outside any transaction may not flush at all.
4 URL Mappings and v1 Versioning
URL-prefix versioning lives in UrlMappings.groovy:
package example
class UrlMappings {
static mappings = {
'/v1/books'(resources: 'book') {
collection {
'/bulk'(controller: 'book', action: 'bulkCreate', method: 'POST')
}
}
'/v1/authors'(resources: 'author')
'/'(controller: 'application', action: 'index')
'500'(view: '/error')
'404'(view: '/notFound')
}
}
The '/v1/books'(resources: 'book') line generates the standard seven REST routes:
| Method | Path | Controller action |
|---|---|---|
GET |
|
|
POST |
|
|
GET |
|
|
PUT |
|
|
PATCH |
|
|
DELETE |
|
|
GET |
|
|
The nested collection { … } block adds non-instance routes - in our case POST /v1/books/bulk mapped to a bulkCreate action we will write later.
5 RestfulController With a Hard Page Cap
AuthorController extends grails.rest.RestfulController<Author>, which provides every action the URL mappings reference (index, show, save, update, patch, delete) for free:
package example
import grails.rest.RestfulController
class AuthorController extends RestfulController<Author> {
static responseFormats = ['json']
AuthorController() {
super(Author)
}
@Override
def index(Integer max) {
if (max != null && max < 0) {
max = null
}
params.max = Math.min(max ?: 25, 100)
params.offset = Math.max(params.int('offset', 0), 0)
respond listAllResources(params), model: [authorCount: countResources()]
}
}
responseFormats = ['json'] restricts every action to negotiate JSON; the legacy XML-by-default behaviour from older Grails versions is gone.
The single override is listAllResources. Without it, a GET /v1/authors?max=99999 would happily return every row in the table. The hard cap (Math.min(params.int('max', 25), 100)) bounds the response, with 25 as the default when the client does not pass max. params.offset, params.sort, and params.order flow through unchanged so a paginating client gets ?max=25&offset=50&sort=name&order=asc semantics out of the box.
6 JSON Views
JSON views (.gson) shape the response without touching the controller. Each view declares its model and emits the JSON body using a Groovy DSL.
The shared partial:
import example.Book
model {
Book book
}
json {
id book.id
title book.title
isbn book.isbn
pageCount book.pageCount
publishedOn book.publishedOn?.format('yyyy-MM-dd')
author {
id book.author.id
name book.author.name
}
_links {
self {
href g.link(resource: book, absolute: true)
}
author {
href g.link(resource: book.author, absolute: true)
}
}
}
The model { } block declares the Groovy types the view expects. The json { } block emits the response body. The _links object is plain JSON; nothing about HAL or HATEOAS is enforced - it is a convention you can either keep or drop, but having it makes API discovery from a browser dramatically easier.
The list view reuses the partial via g.render:
import example.Book
model {
Iterable<Book> bookList
Integer bookCount
}
json {
page Math.max(0, (params.int('offset', 0) ?: 0).intdiv(params.int('max', 25)))
pageSize params.int('max', 25)
total bookCount
items g.render(template: 'book', collection: bookList ?: [], var: 'book')
}
Three things to notice:
-
The
modelblock declares both the page (Iterable<Book> bookList) and the total count (Long bookCount).RestfulController.indexpopulates both automatically. -
The pagination metadata at the top (
page,pageSize,total) gives clients enough information to render a pager without re-querying. -
bookList.collect { Book b → g.render(template: 'book', model: [book: b]) }reuses the_book.gsonpartial for every item. One source of truth for the body shape; both the show and index views use it.
The show.gson view is a one-liner that delegates to the same partial:
import example.Book
model {
Book book
}
json g.render(template: 'book', model: [book: book])
Author views follow the same pattern: _author.gson partial, index.gson list with pagination metadata, show.gson one-liner.
7 Structured 422 Validation Responses
When a POST /v1/books body fails validation, RestfulController returns a 422 by default - but the body shape is not always what API consumers expect. The hand-rolled bulkCreate action in BookController shows the structured-422 pattern:
package example
import grails.gorm.transactions.Transactional
import grails.rest.RestfulController
import org.springframework.context.MessageSource
import org.springframework.http.HttpStatus
import org.springframework.validation.FieldError
class BookController extends RestfulController<Book> {
static responseFormats = ['json']
MessageSource messageSource
BookController() {
super(Book)
}
@Override
def index(Integer max) {
if (max != null && max < 0) {
max = null
}
params.max = Math.min(max ?: 25, 100)
params.offset = Math.max(params.int('offset', 0), 0)
respond listAllResources(params), model: [bookCount: countResources()]
}
@Override
protected List<Book> listAllResources(Map params) {
Long authorId = params.long('author')
Map queryParams = [max: params.max, offset: params.offset, sort: params.sort, order: params.order].findAll { it.value != null }
if (authorId != null) {
return Book.where {
author.id == authorId
}.list(queryParams)
}
Book.list(queryParams)
}
@Override
protected Integer countResources() {
Long authorId = params.long('author')
if (authorId != null) {
return Book.where {
author.id == authorId
}.count() as Integer
}
Book.count()
}
@Transactional
def bulkCreate() {
if (!(request.JSON instanceof List)) {
response.status = HttpStatus.UNPROCESSABLE_ENTITY.value()
respond([
errors: [[code: 'invalid.body', message: 'Request body must be a JSON array of book objects']]
])
return
}
List<Book> drafts = request.JSON.collect { Object json ->
Book draft = new Book()
bindData(draft, json as Map)
draft
}
List<Map<String, Object>> failures = []
drafts.eachWithIndex { Book draft, int index ->
draft.validate()
if (draft.hasErrors()) {
failures << [
index : index,
errors: draft.errors.allErrors.collect { error ->
Map<String, Object> payload = [
object : error.objectName,
code : error.code,
message: messageSource.getMessage(error, request.locale)
]
if (error instanceof FieldError) {
payload.field = error.field
payload.rejectedValue = error.rejectedValue
payload.bindingFailure = error.bindingFailure
}
payload
}
]
}
}
if (failures) {
transactionStatus.setRollbackOnly()
response.status = HttpStatus.UNPROCESSABLE_ENTITY.value()
respond([books: failures])
return
}
drafts.each { Book book ->
book.save(flush: true, failOnError: true)
}
params.max = Math.max(drafts.size(), 1)
params.offset = 0
respond drafts, [status: HttpStatus.CREATED, view: 'index', model: [bookCount: drafts.size()]]
}
}
When validation fails, the body looks like:
{
"books": [
{
"index": 0,
"errors": [
{ "field": "title", "code": "blank", "message": "Property [title] of class [Book] cannot be blank" },
{ "field": "isbn", "code": "matches", "message": "Property [isbn] of class [Book] does not match the required pattern" }
]
},
{
"index": 2,
"errors": [
{ "field": "isbn", "code": "unique", "message": "Property [isbn] of class [Book] with value [9780547928227] must be unique" }
]
}
]
}
The shape is opinionated: each failing object has its source-array index so the client can map the failure back to the input, and each error has a stable code (blank, matches, unique, nullable, min, max) the client can branch on without parsing free-text messages. Any client building a form on top of this API can light up the right field with the right error without string-matching.
The transactionStatus.setRollbackOnly() line is what makes bulkCreate all-or-nothing: even though the action’s @Transactional started a transaction implicitly, this call marks it for rollback regardless of what the action returns. The four books that did validate never reach the database.
8 Pagination, Sorting, and Filtering
Every list endpoint in this guide exposes the same three query parameters:
-
max- page size, default 25, hard-capped at 100. -
offset- zero-based starting offset, default 0. -
sort- column name to sort by, with optionalorder=ascororder=desc.
RestfulController.index reads params.max and params.offset automatically; the override on listAllResources we already wrote bounds them.
Round-trip with curl:
curl -s 'http://localhost:8080/v1/books?max=2&offset=2&sort=publishedOn&order=desc' | jq
Returns:
{
"page": 1,
"pageSize": 2,
"total": 4,
"items": [
{ "id": 2, "title": "The Fellowship of the Ring", ... },
{ "id": 1, "title": "The Hobbit", ... }
]
}
The page value is offset / pageSize; total is the unpaginated row count. A client that wants a page-and-size API instead of an offset-and-size API can compute either from the other.
|
Filtering sits orthogonal to pagination. If you want
This stays out of the URL mappings layer; the same route serves both filtered and unfiltered queries. |
9 All-or-Nothing Bulk Create
A frequent ask from API consumers is "let me create N records in one round trip". The single POST /v1/books/bulk endpoint added in the URL mappings handles it:
curl -s -X POST http://localhost:8080/v1/books/bulk \
-H 'Content-Type: application/json' \
-d '[
{ "title": "The Two Towers", "isbn": "9780547928203", "pageCount": 416, "publishedOn": "1954-11-11", "author": { "id": 1 } },
{ "title": "The Return of the King", "isbn": "9780547928197", "pageCount": 432, "publishedOn": "1955-10-20", "author": { "id": 1 } }
]'
The bulkCreate action validates every entry first, then saves them. If any single book fails validation, the whole transaction rolls back via transactionStatus.setRollbackOnly() and the response is a 422 with structured field errors for every failing entry. If all entries validate, the response is a 201 with the persisted bodies.
This is the safest semantic for bulk creates in a typed API: clients never have to deal with "of the ten you sent, six made it through and four did not". They get either all ten or none, with a precise diagnosis on failure.
10 URL-Prefix vs Content-Negotiation Versioning
Two patterns dominate REST versioning:
-
URL prefix:
/v1/books,/v2/books. Cheap to implement, trivially debuggable, plays well with caches and proxies. -
Content negotiation:
Accept: application/vnd.example.v2+json. Cleaner semantically (the URL is the resource, not the representation), but adds friction to every consumer.
This guide leads with URL prefix because it is the cheaper choice for a project that has not yet shipped its first breaking change. The '/v1/books'(resources: 'book') URL mapping localises every v1 route under a single prefix; when v2 ships, you add a second '/v2/books'(resources: 'bookV2') mapping to a fresh BookV2Controller and let v1 keep serving its existing clients while you migrate them.
The migration discipline that does matter:
-
Never break v1 within its lifetime. Even adding a new required field is a breaking change for a strict client. Add fields as optional; deprecate via response headers.
-
Document a deprecation timeline on day 1 of v2. "v1 sunsets 12 months after v2 GA" gives consumers a window.
-
Run v1 and v2 from the same codebase whenever you can. Two controllers, two view trees, one domain model. Forking the domain model is a smell.
For the small percentage of cases where content negotiation makes sense - public APIs with thousands of consumers, where /v1 in URLs would be visually offensive - Grails' responseFormats and the Accept-header mapping are first-class. The mechanics are the same; only the surface changes.
11 Testing the API
The library API ships with a layered Spock suite: fast domain and controller unit tests, @Integration tests against a real database, and functional HTTP tests that drive the JSON endpoints end to end. The integration and functional layers run against a Testcontainers-managed PostgreSQL database, so a Docker daemon is the only host requirement.
Test dependencies
Testcontainers artifacts are not in the Grails BOM, so they need an explicit testcontainers-bom to resolve a version. The web testing support is required for ControllerUnitTest even in a rest-api profile app:
testImplementation "org.apache.grails:grails-testing-support-datamapping"
testImplementation "org.apache.grails:grails-testing-support-views-gson"
testImplementation "org.apache.grails:grails-testing-support-web"
testImplementation "org.spockframework:spock-core"
testImplementation platform("org.testcontainers:testcontainers-bom:$testcontainersVersion")
testImplementation "org.testcontainers:postgresql"
testImplementation "org.testcontainers:spock"
testImplementation "org.testcontainers:testcontainers"
The testcontainersVersion is kept in gradle.properties alongside grailsVersion, so the version lives in one place:
testcontainersVersion=1.20.4
Because this app uses the database-migration plugin, an empty changelog.groovy means GORM will not auto-create the schema in the test environment. The test profile therefore turns the migration off and lets GORM create the schema directly:
dataSource:
url: jdbc:tc:postgresql:12:///postgres
driverClassName: org.testcontainers.jdbc.ContainerDatabaseDriver
dbCreate: create-drop
grails:
plugin:
databasemigration:
updateOnStart: false
Unit: domain constraints and controller paging
DataTest registers the in-memory datastore for Author and Book so constraint logic runs in milliseconds with no Spring context. Book belongsTo Author, so both classes are mocked:
package example
import grails.testing.gorm.DataTest
import spock.lang.Specification
import spock.lang.Unroll
/**
* Domain constraint unit tests for Book. DataTest mocks both Book and its
* owning Author so the belongsTo association can be satisfied without a
* Spring context or a real database.
*/
class BookSpec extends Specification implements DataTest {
Class[] getDomainClassesToMock() { [Author, Book] }
private Book bookWith(Map overrides) {
Author author = new Author(name: 'Author').save(flush: true, failOnError: true)
new Book([author: author, title: 'A Title', isbn: '9780547928227', pageCount: 100] + overrides)
}
void "a fully populated book validates"() {
expect:
bookWith([:]).validate()
}
void "author is required"() {
when:
Book b = new Book(title: 'No Author', isbn: '9780547928227')
then:
!b.validate()
b.errors.getFieldError('author').code == 'nullable'
}
void "title is required"() {
when:
Book b = bookWith(title: null)
then:
!b.validate()
b.errors.getFieldError('title').code == 'nullable'
}
void "isbn must match the ISBN pattern"() {
when:
Book b = bookWith(isbn: 'not-an-isbn')
then:
!b.validate()
b.errors.getFieldError('isbn').code == 'matches.invalid'
}
void "isbn must be unique"() {
given:
bookWith(isbn: '9780547928227').save(flush: true, failOnError: true)
when:
Book dup = bookWith(isbn: '9780547928227')
then:
!dup.validate()
dup.errors.getFieldError('isbn').code == 'unique'
}
@Unroll
void "pageCount #pageCount is #valid"() {
expect:
bookWith(pageCount: pageCount).validate() == valid
where:
pageCount || valid
null || true
1 || true
500 || true
0 || false
-5 || false
}
}
The RestfulController pagination clamping (default 25, hard cap 100, negative treated as default) is pinned with a ControllerUnitTest:
package example
import grails.testing.gorm.DataTest
import grails.testing.web.controllers.ControllerUnitTest
import spock.lang.Specification
import spock.lang.Unroll
/**
* Controller unit test for the pagination clamping the custom index action
* applies before it queries. No Spring context, no real database.
*/
class BookControllerSpec extends Specification
implements ControllerUnitTest<BookController>, DataTest {
Class[] getDomainClassesToMock() { [Author, Book] }
@Unroll
void "index clamps max #requested to #expected"() {
when:
controller.index(requested)
then:
controller.params.max == expected
where:
requested || expected
500 || 100 // capped at 100
10 || 10 // within range, untouched
null || 25 // default
-5 || 25 // negative treated as default
}
void "index never produces a negative offset"() {
given:
controller.params.offset = -10
when:
controller.index(25)
then:
controller.params.offset == 0
}
}
Integration: relationships against a real database
@Integration with @Rollback boots the full context and isolates each method. BootStrap seeds four books outside production, so these specs use ISBNs that do not collide with the seed set and filter queries by an author they create:
package example
import grails.gorm.transactions.Rollback
import grails.testing.mixin.integration.Integration
import spock.lang.Specification
/**
* @Integration boots the full Spring context against the Testcontainers
* PostgreSQL database. @Rollback isolates each method, so a bare GORM call
* runs inside an ambient session.
*
* BootStrap seeds four books outside production, so these specs use ISBNs
* that do not collide with the seed set and filter queries by an author
* they create themselves.
*/
@Integration
@Rollback
class BookIntegrationSpec extends Specification {
void "books are filtered by author with a where query"() {
given:
Author tolkien = new Author(name: 'Integration Tolkien').save(flush: true, failOnError: true)
Author leguin = new Author(name: 'Integration Le Guin').save(flush: true, failOnError: true)
new Book(author: tolkien, title: 'TT Book', isbn: '9783000000010', pageCount: 310).save(flush: true, failOnError: true)
new Book(author: leguin, title: 'LG Book', isbn: '9783000000027', pageCount: 205).save(flush: true, failOnError: true)
when:
List<Book> tolkienBooks = Book.where { author.id == tolkien.id }.list()
then:
tolkienBooks.size() == 1
tolkienBooks.first().title == 'TT Book'
}
void "pagination returns at most the requested page size"() {
given:
Author a = new Author(name: 'Integration Prolific').save(flush: true, failOnError: true)
(1..5).each { int i ->
new Book(author: a, title: "Paged ${i}", isbn: String.format('978400000000%d', i), pageCount: 100 + i)
.save(flush: true, failOnError: true)
}
when:
List<Book> firstPage = Book.where { author.id == a.id }.list(max: 2, offset: 0, sort: 'title', order: 'asc')
then:
firstPage.size() == 2
firstPage*.title == ['Paged 1', 'Paged 2']
}
void "an author's books collection reflects persisted books"() {
given:
Author a = new Author(name: 'Integration Author').save(flush: true, failOnError: true)
a.addToBooks(new Book(title: 'One', isbn: '9785000000019', pageCount: 100))
a.addToBooks(new Book(title: 'Two', isbn: '9785000000026', pageCount: 200))
a.save(flush: true, failOnError: true)
expect:
Author.get(a.id).books.size() == 2
}
}
Functional: the REST surface over real HTTP
The functional spec sends real requests with java.net.http.HttpClient against the booted app (port injected via @Value('${local.server.port}')). It asserts the paginated envelope, the page-size clamp, and - most importantly - the all-or-nothing bulkCreate contract: a request with one invalid entry returns 422 and persists nothing. @Rollback is not used (the endpoints commit), so the fixture is created and read back inside withNewTransaction blocks:
package example
import grails.testing.mixin.integration.Integration
import groovy.json.JsonOutput
import groovy.json.JsonSlurper
import org.springframework.beans.factory.annotation.Value
import spock.lang.Specification
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
/**
* Functional tests drive the real REST/JSON surface over HTTP with
* java.net.http.HttpClient. @Rollback is deliberately NOT used: the
* endpoints commit, so the fixture is seeded (and read back) inside
* withNewTransaction blocks. BootStrap data is not populated in the
* @Integration context, so each spec owns its fixture.
*/
@Integration
class BookFunctionalSpec extends Specification {
@Value('${local.server.port}')
Integer serverPort
HttpClient client = HttpClient.newHttpClient()
Long authorId
void setup() {
Book.withNewTransaction {
Author a = Author.findByName('Functional Author') ?: new Author(name: 'Functional Author').save(failOnError: true)
authorId = a.id
if (!Book.findByIsbn('9781111111113')) {
new Book(author: a, title: 'Seeded One', isbn: '9781111111113', pageCount: 100).save(failOnError: true)
}
if (!Book.findByIsbn('9782222222226')) {
new Book(author: a, title: 'Seeded Two', isbn: '9782222222226', pageCount: 200).save(failOnError: true)
}
}
}
private URI uri(String path) { URI.create("http://localhost:${serverPort}${path}") }
private HttpResponse<String> getJson(String path) {
client.send(HttpRequest.newBuilder(uri(path)).GET().build(), HttpResponse.BodyHandlers.ofString())
}
private HttpResponse<String> postJson(String path, String body) {
client.send(HttpRequest.newBuilder(uri(path))
.header('Content-Type', 'application/json')
.POST(HttpRequest.BodyPublishers.ofString(body)).build(),
HttpResponse.BodyHandlers.ofString())
}
void "GET /v1/books returns a paginated JSON envelope"() {
when:
HttpResponse<String> resp = getJson('/v1/books')
Map json = new JsonSlurper().parseText(resp.body()) as Map
then:
resp.statusCode() == 200
json.containsKey('page')
json.containsKey('pageSize')
json.total >= 2
json.items.size() >= 2
json.items.every { it.title && it.isbn && it.author?.name }
}
void "GET /v1/books?max= clamps the page size to 100"() {
when:
HttpResponse<String> resp = getJson('/v1/books?max=500')
Map json = new JsonSlurper().parseText(resp.body()) as Map
then:
resp.statusCode() == 200
json.pageSize == 100
}
void "POST /v1/books/bulk creates every book and returns 201"() {
given:
String body = JsonOutput.toJson([
[title: 'The Two Towers', isbn: '9780547928203', pageCount: 416, author: [id: authorId]],
[title: 'The Return of the King', isbn: '9780547928197', pageCount: 432, author: [id: authorId]]
])
when:
HttpResponse<String> resp = postJson('/v1/books/bulk', body)
then:
resp.statusCode() == 201
Book.withNewTransaction {
Book.findByIsbn('9780547928203') != null && Book.findByIsbn('9780547928197') != null
}
}
void "POST /v1/books/bulk rolls back entirely when any entry is invalid"() {
given: 'one valid and one invalid entry in the same request'
long before = Book.withNewTransaction { Book.count() }
String body = JsonOutput.toJson([
[title: 'Would Be Valid', isbn: '9780000000086', pageCount: 100, author: [id: authorId]],
[title: '', isbn: 'not-an-isbn', author: [id: authorId]]
])
when:
HttpResponse<String> resp = postJson('/v1/books/bulk', body)
Map json = new JsonSlurper().parseText(resp.body()) as Map
then: 'a 422 reports the failing index and NOTHING is persisted'
resp.statusCode() == 422
json.books.size() == 1
json.books.first().index == 1
Book.withNewTransaction { Book.count() } == before
Book.withNewTransaction { Book.findByIsbn('9780000000086') == null }
}
void "GET /v1/authors returns the authors as JSON"() {
when:
HttpResponse<String> resp = getJson('/v1/authors')
Map json = new JsonSlurper().parseText(resp.body()) as Map
then:
resp.statusCode() == 200
json.total >= 1
json.items.any { it.name == 'Functional Author' }
}
// -------------------------------------------------------------------------
// Show (GET /v1/books/{id})
// -------------------------------------------------------------------------
void "GET /v1/books/{id} returns a single book"() {
given:
Long bookId = Book.withNewTransaction {
Book.findByIsbn('9781111111113').id
}
when:
HttpResponse<String> resp = getJson("/v1/books/${bookId}")
Map json = new JsonSlurper().parseText(resp.body()) as Map
then:
resp.statusCode() == 200
json.title == 'Seeded One'
json.isbn == '9781111111113'
json.pageCount == 100
json.author.name == 'Functional Author'
json._links.self.href.contains("id=${bookId}")
}
void "GET /v1/books/{id} returns 404 for non-existent book"() {
when:
HttpResponse<String> resp = getJson('/v1/books/999999')
then:
resp.statusCode() == 404
}
// -------------------------------------------------------------------------
// Save (POST /v1/books)
// -------------------------------------------------------------------------
void "POST /v1/books creates a new book"() {
given:
String body = JsonOutput.toJson([
title: 'Functional Created', isbn: '9780000000003', pageCount: 250,
author: [id: authorId]
])
when:
HttpResponse<String> resp = postJson('/v1/books', body)
Map json = new JsonSlurper().parseText(resp.body()) as Map
then:
resp.statusCode() == 201
json.title == 'Functional Created'
json.isbn == '9780000000003'
cleanup:
Book.withNewTransaction { Book.findByIsbn('9780000000003')?.delete(flush: true) }
}
void "POST /v1/books returns 422 when validation fails"() {
given:
String body = JsonOutput.toJson([
title: '', isbn: 'bad-isbn', author: [id: authorId]
])
when:
HttpResponse<String> resp = postJson('/v1/books', body)
Map json = new JsonSlurper().parseText(resp.body()) as Map
then:
resp.statusCode() == 422
json.errors.any { it.field == 'title' }
json.errors.any { it.field == 'isbn' }
}
// -------------------------------------------------------------------------
// Update (PUT /v1/books/{id})
// -------------------------------------------------------------------------
private HttpResponse<String> putJson(String path, String body) {
client.send(HttpRequest.newBuilder(uri(path))
.header('Content-Type', 'application/json')
.PUT(HttpRequest.BodyPublishers.ofString(body)).build(),
HttpResponse.BodyHandlers.ofString())
}
void "PUT /v1/books/{id} updates an existing book"() {
given:
Long bookId = Book.withNewTransaction { Book.findByIsbn('9782222222226').id }
String body = JsonOutput.toJson([title: 'Updated Title', pageCount: 999])
when:
HttpResponse<String> resp = putJson("/v1/books/${bookId}", body)
Map json = new JsonSlurper().parseText(resp.body()) as Map
then:
resp.statusCode() == 200
json.title == 'Updated Title'
json.pageCount == 999
cleanup:
Book.withNewTransaction {
Book b = Book.findByIsbn('9782222222226')
if (b) {
b.title = 'Seeded Two'
b.pageCount = 200
b.save(flush: true, failOnError: true)
}
}
}
void "PUT /v1/books/{id} returns 422 when validation fails"() {
given:
Long bookId = Book.withNewTransaction { Book.findByIsbn('9781111111113').id }
String body = JsonOutput.toJson([title: ''])
when:
HttpResponse<String> resp = putJson("/v1/books/${bookId}", body)
then:
resp.statusCode() == 422
}
void "PUT /v1/books/{id} returns 404 for non-existent book"() {
when:
HttpResponse<String> resp = putJson('/v1/books/999999', JsonOutput.toJson([title: 'Nope']))
then:
resp.statusCode() == 404
}
// -------------------------------------------------------------------------
// Delete (DELETE /v1/books/{id})
// -------------------------------------------------------------------------
private HttpResponse<String> deleteResource(String path) {
client.send(HttpRequest.newBuilder(uri(path)).DELETE().build(),
HttpResponse.BodyHandlers.ofString())
}
void "DELETE /v1/books/{id} deletes a book"() {
given: 'a disposable book not used by other tests'
Long disposableId = Book.withNewTransaction {
new Book(author: Author.findByName('Functional Author'),
title: 'Delete Me Book', isbn: '9789999999997', pageCount: 1)
.save(flush: true, failOnError: true).id
}
when:
HttpResponse<String> resp = deleteResource("/v1/books/${disposableId}")
then:
resp.statusCode() == 204
Book.withNewTransaction { Book.get(disposableId) == null }
}
void "DELETE /v1/books/{id} returns 404 for non-existent book"() {
when:
HttpResponse<String> resp = deleteResource('/v1/books/999999')
then:
resp.statusCode() == 404
}
// -------------------------------------------------------------------------
// Author filter on GET /v1/books
// -------------------------------------------------------------------------
void "GET /v1/books?author= filters by author"() {
given: 'two authors, each owning a distinct book, so the filter has something to exclude'
Long targetAuthorId = Book.withNewTransaction {
Author target = new Author(name: 'Filter Target Author').save(failOnError: true)
Author other = new Author(name: 'Filter Other Author').save(failOnError: true)
new Book(author: target, title: 'Target Book', isbn: '9784444444443', pageCount: 100).save(failOnError: true)
new Book(author: other, title: 'Excluded Book', isbn: '9785555555556', pageCount: 100).save(failOnError: true)
target.id
}
when:
HttpResponse<String> resp = getJson("/v1/books?author=${targetAuthorId}")
Map json = new JsonSlurper().parseText(resp.body()) as Map
then: 'only the target author\'s books come back; the other author\'s book is excluded'
resp.statusCode() == 200
json.items.size() > 0
json.items.every { it.author.id == targetAuthorId }
json.items.every { it.isbn != '9785555555556' }
}
// -------------------------------------------------------------------------
// Negative offset clamping
// -------------------------------------------------------------------------
void "GET /v1/books?offset= clamps negative offset to 0"() {
when:
HttpResponse<String> resp = getJson('/v1/books?offset=-5')
Map json = new JsonSlurper().parseText(resp.body()) as Map
then:
resp.statusCode() == 200
json.page == 0
}
}
Run the suite:
./gradlew test # unit - milliseconds, no Docker
./gradlew integrationTest # integration + functional - Docker required
12 Running the Application
With everything in place, run the app:
./gradlew bootRun
The bootstrap data populates two authors and four books on first start. Smoke-test the surface:
# List
curl -s http://localhost:8080/v1/books | jq
curl -s http://localhost:8080/v1/authors | jq
# Show single
curl -s http://localhost:8080/v1/books/1 | jq
# Pagination
curl -s 'http://localhost:8080/v1/books?max=2&offset=2&sort=title' | jq
# Create
curl -s -X POST http://localhost:8080/v1/books \
-H 'Content-Type: application/json' \
-d '{ "title": "The Silmarillion", "isbn": "9780544338012", "pageCount": 384, "author": { "id": 1 } }' | jq
# Validation failure (422 with structured field errors)
curl -s -X POST http://localhost:8080/v1/books \
-H 'Content-Type: application/json' \
-d '{ "title": "" }' | jq
# Bulk create (all-or-nothing)
curl -s -X POST http://localhost:8080/v1/books/bulk \
-H 'Content-Type: application/json' \
-d '[
{ "title": "The Two Towers", "isbn": "9780547928203", "pageCount": 416, "author": { "id": 1 } }
]' | jq
Each of these returns a sensible JSON body with the right HTTP status. The validation-failure curl returns 422 with the structured field errors from the validation chapter; the rest return 200 (list/show), 201 (create), or 204 (delete) as appropriate.
13 Do you need help with Grails?
Help with Apache Grails
Apache Grails is supported by an active community of contributors and the Apache Software Foundation. If you need help working through a guide, want to discuss the framework, or have run into something that looks like a bug, the channels below are the right place to start.
-
Slack - real-time conversation with the Apache Grails community.
-
Developer mailing list - design discussions and contributor coordination.
-
Users mailing list - end-user questions and answers.
-
Issue tracker on GitHub - file a bug or feature request against the framework.
For Grails plugins, see the matching project on the apache org or the plugin’s own GitHub repository.