Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 57 additions & 2 deletions conf/guides.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3466,6 +3466,61 @@ guides:
helpWithGrails:
title: Do you need help with Grails?

- name: 'grails-rest-library'
title: 'A Library REST API with Grails 8'
subtitle: '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'
category: 'Web Layer'
publicationDate: '2026-05-03'
versions:
'8':
sourcePath: guides/grails-rest-library/v8
publicationDate: '2026-05-03'
tags:
- 'rest'
- 'rest-api'
- 'json-views'
- 'gson'
- 'restful-controller'
- 'gorm'
- 'pagination'
- 'validation'
- 'grails8'
sampleRef:
repo: 'grails-guides/grails-rest-library'
branch: 'grails8'
toc:
gettingStarted:
title: Getting Started
whatYouWillBuild: What You Will Build
requirements: What You Will Need
howto: How to Complete the Guide
createApp:
title: Creating the Application
download: Download a Grails 8 rest_api Starter
domainModel:
title: The Book and Author Domain Model
bootstrapData: Bootstrap Sample Data
urlMappings:
title: URL Mappings and v1 Versioning
controllers:
title: RestfulController With a Hard Page Cap
jsonViews:
title: JSON Views
validation:
title: Structured 422 Validation Responses
pagination:
title: Pagination, Sorting, and Filtering
bulkCreate:
title: All-or-Nothing Bulk Create
versioning:
title: URL-Prefix vs Content-Negotiation Versioning
runningTheApp:
title: Running the Application
helpWithGrails:
title: Do you need help with Grails?

- name: 'grails-scheduled'
title: 'Grails + @Scheduled'
subtitle: 'Learn how to use Spring Task Execution and Scheduling to schedule periodic tasks inside your Grails applications'
Expand Down Expand Up @@ -4444,7 +4499,7 @@ guides:
- 'gorm'
- 'grails3'
sampleRef:
repo: 'grails-guides/rest-hibernate'
repo: 'grails-guides/grails-rest-library'
branch: 'grails3'
toc:
training:
Expand Down Expand Up @@ -4481,7 +4536,7 @@ guides:
- 'gorm'
- 'grails4'
sampleRef:
repo: 'grails-guides/rest-hibernate'
repo: 'grails-guides/grails-rest-library'
branch: 'grails4'
toc:
training:
Expand Down
14 changes: 14 additions & 0 deletions guides/grails-rest-library/v8/guide/bootstrapData.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
A `BootStrap` class seeds two authors and four books on first startup, but only outside production:

[source,groovy]
.grails-app/init/example/BootStrap.groovy
----
include::../snippets/grails-app/init/example/BootStrap.groovy[]
----

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 `@Transactional` annotation on the `init` closure 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.
15 changes: 15 additions & 0 deletions guides/grails-rest-library/v8/guide/bulkCreate.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
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:

[source,bash]
----
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.
11 changes: 11 additions & 0 deletions guides/grails-rest-library/v8/guide/controllers.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
`AuthorController` extends `grails.rest.RestfulController<Author>`, which provides every action the URL mappings reference (`index`, `show`, `save`, `update`, `patch`, `delete`) for free:

[source,groovy]
.grails-app/controllers/example/AuthorController.groovy
----
include::../snippets/grails-app/controllers/example/AuthorController.groovy[]
----

`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.
1 change: 1 addition & 0 deletions guides/grails-rest-library/v8/guide/createApp.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Generate a fresh Apache Grails 8 *Rest API* application from link:https://prev-snapshot.grails.org[prev-snapshot.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.
21 changes: 21 additions & 0 deletions guides/grails-rest-library/v8/guide/domainModel.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
Two domain classes model the library. `Author` is independent; `Book` belongs to an `Author`.

[source,groovy]
.grails-app/domain/example/Author.groovy
----
include::../snippets/grails-app/domain/example/Author.groovy[]
----

[source,groovy]
.grails-app/domain/example/Book.groovy
----
include::../snippets/grails-app/domain/example/Book.groovy[]
----

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.
9 changes: 9 additions & 0 deletions guides/grails-rest-library/v8/guide/download.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[source,bash]
----
curl -L -o library.zip \
"https://prev-snapshot.grails.org/create/rest_api/example.library?lang=GROOVY&build=GRADLE&test=SPOCK&javaVersion=JDK_21&features=postgres,testcontainers,views-json,database-migration"
unzip library.zip
cd library
----

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 changes: 3 additions & 0 deletions guides/grails-rest-library/v8/guide/gettingStarted.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
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 change: 1 addition & 0 deletions guides/grails-rest-library/v8/guide/helpWithGrails.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
include::{commondir}/common-helpWithGrails.adoc[]
11 changes: 11 additions & 0 deletions guides/grails-rest-library/v8/guide/howto.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
You can either type the code in this guide as you read or skip ahead and clone the finished sample:

[source,bash]
----
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 `https://prev-snapshot.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.
35 changes: 35 additions & 0 deletions guides/grails-rest-library/v8/guide/jsonViews.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
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:

[source,groovy]
.grails-app/views/book/_book.gson
----
include::../snippets/grails-app/views/book/_book.gson[]
----

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`:

[source,groovy]
.grails-app/views/book/index.gson
----
include::../snippets/grails-app/views/book/index.gson[]
----

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:

[source,groovy]
.grails-app/views/book/show.gson
----
include::../snippets/grails-app/views/book/show.gson[]
----

Author views follow the same pattern: `_author.gson` partial, `index.gson` list with pagination metadata, `show.gson` one-liner.
51 changes: 51 additions & 0 deletions guides/grails-rest-library/v8/guide/pagination.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
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`:

[source,bash]
----
curl -s 'http://localhost:8080/v1/books?max=2&offset=2&sort=publishedOn&order=desc' | jq
----

Returns:

[source,json]
----
{
"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.

[TIP]
====
*Filtering* sits orthogonal to pagination. If you want `GET /v1/books?author=2`, override `listAllResources` to consume `params.author`:

[source,groovy]
----
@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.
====
5 changes: 5 additions & 0 deletions guides/grails-rest-library/v8/guide/requirements.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
To complete this guide you will need:

* JDK 21
* About 30 minutes
* `curl` or another HTTP client for hitting the running endpoints
40 changes: 40 additions & 0 deletions guides/grails-rest-library/v8/guide/runningTheApp.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
With everything in place, run the app:

[source,bash]
----
./gradlew bootRun
----

The bootstrap data populates two authors and four books on first start. Smoke-test the surface:

[source,bash]
----
# 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.
24 changes: 24 additions & 0 deletions guides/grails-rest-library/v8/guide/urlMappings.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
URL-prefix versioning lives in `UrlMappings.groovy`:

[source,groovy]
.grails-app/controllers/example/UrlMappings.groovy
----
include::../snippets/grails-app/controllers/example/UrlMappings.groovy[]
----

The `'/v1/books'(resources: 'book')` line generates the standard seven REST routes:

[cols="1,4,3"]
|===
| 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.
34 changes: 34 additions & 0 deletions guides/grails-rest-library/v8/guide/validation.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
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:

[source,groovy]
.grails-app/controllers/example/BookController.groovy
----
include::../snippets/grails-app/controllers/example/BookController.groovy[]
----

When validation fails, the body looks like:

[source,json]
----
{
"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.
14 changes: 14 additions & 0 deletions guides/grails-rest-library/v8/guide/versioning.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
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.
8 changes: 8 additions & 0 deletions guides/grails-rest-library/v8/guide/whatYouWillBuild.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
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`.
Loading
Loading