Show Navigation

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 Book and Author domain pair, with Book belongsTo: Author and reasonable constraints (a regex-validated ISBN, a positive pageCount, optional dateOfBirth).

  • BookController extends RestfulController<Book> and AuthorController extends RestfulController<Author>, each scoped to JSON via responseFormats and bounded by a listAllResources override that hard-caps params.max at 100.

  • JSON views (.gson) under grails-app/views/book/ and grails-app/views/author/ that render the API responses. Each view emits a body, embeds a HAL-style _links object, and reuses a shared _book.gson / _author.gson partial.

  • A POST /v1/books/bulk endpoint 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 the Accept: application/vnd.example.v2+json content-negotiation approach and recommends starting with URL prefixes.

  • Sample data in BootStrap so curl http://localhost:8080/v1/books returns 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

  • curl or 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.

grails-app/domain/example/Author.groovy
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 }
}
grails-app/domain/example/Book.groovy
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: 255 on Author and title on 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 with unique: true.

  • pageCount nullable: true, min: 1 allows 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:

grails-app/init/example/BootStrap.groovy
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.PRODUCTION early-exit means the seed never runs against a real database.

  • The Author.count() > 0 check 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:

grails-app/controllers/example/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

/v1/books

index

POST

/v1/books

save

GET

/v1/books/{id}

show

PUT

/v1/books/{id}

update

PATCH

/v1/books/{id}

patch

DELETE

/v1/books/{id}

delete

GET

/v1/books/create

create (rarely used in JSON APIs; safe to ignore)

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:

grails-app/controllers/example/AuthorController.groovy
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:

grails-app/views/book/_book.gson
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:

grails-app/views/book/index.gson
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 model block declares both the page (Iterable<Book> bookList) and the total count (Long bookCount). RestfulController.index populates 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.gson partial 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:

grails-app/views/book/show.gson
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:

grails-app/controllers/example/BookController.groovy
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 optional order=asc or order=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 GET /v1/books?author=2, override listAllResources to consume params.author:

@Override
protected List<Book> listAllResources(Map params) {
    params.max = Math.min(params.int('max', 25), 100)
    if (params.author) {
        Book.findAllByAuthor(Author.get(params.long('author')), params)
    } else {
        Book.list(params)
    }
}

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:

build.gradle (test dependencies)
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:

gradle.properties
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:

src/test/resources/application-test.yml
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:

src/test/groovy/example/BookSpec.groovy
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:

src/test/groovy/example/BookControllerSpec.groovy
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:

src/integration-test/groovy/example/BookIntegrationSpec.groovy
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:

src/integration-test/groovy/example/BookFunctionalSpec.groovy
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.

For Grails plugins, see the matching project on the apache org or the plugin’s own GitHub repository.