diff --git a/conf/guides.yml b/conf/guides.yml index 9a0bcf68f2c..444713984ec 100644 --- a/conf/guides.yml +++ b/conf/guides.yml @@ -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' @@ -4444,7 +4499,7 @@ guides: - 'gorm' - 'grails3' sampleRef: - repo: 'grails-guides/rest-hibernate' + repo: 'grails-guides/grails-rest-library' branch: 'grails3' toc: training: @@ -4481,7 +4536,7 @@ guides: - 'gorm' - 'grails4' sampleRef: - repo: 'grails-guides/rest-hibernate' + repo: 'grails-guides/grails-rest-library' branch: 'grails4' toc: training: diff --git a/guides/grails-rest-library/v8/guide/bootstrapData.adoc b/guides/grails-rest-library/v8/guide/bootstrapData.adoc new file mode 100644 index 00000000000..68b143d8a75 --- /dev/null +++ b/guides/grails-rest-library/v8/guide/bootstrapData.adoc @@ -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. diff --git a/guides/grails-rest-library/v8/guide/bulkCreate.adoc b/guides/grails-rest-library/v8/guide/bulkCreate.adoc new file mode 100644 index 00000000000..fbdaa26e11c --- /dev/null +++ b/guides/grails-rest-library/v8/guide/bulkCreate.adoc @@ -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. diff --git a/guides/grails-rest-library/v8/guide/controllers.adoc b/guides/grails-rest-library/v8/guide/controllers.adoc new file mode 100644 index 00000000000..8d1e26a059b --- /dev/null +++ b/guides/grails-rest-library/v8/guide/controllers.adoc @@ -0,0 +1,11 @@ +`AuthorController` extends `grails.rest.RestfulController`, 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. diff --git a/guides/grails-rest-library/v8/guide/createApp.adoc b/guides/grails-rest-library/v8/guide/createApp.adoc new file mode 100644 index 00000000000..f939c0a73a1 --- /dev/null +++ b/guides/grails-rest-library/v8/guide/createApp.adoc @@ -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. diff --git a/guides/grails-rest-library/v8/guide/domainModel.adoc b/guides/grails-rest-library/v8/guide/domainModel.adoc new file mode 100644 index 00000000000..043289a174a --- /dev/null +++ b/guides/grails-rest-library/v8/guide/domainModel.adoc @@ -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. diff --git a/guides/grails-rest-library/v8/guide/download.adoc b/guides/grails-rest-library/v8/guide/download.adoc new file mode 100644 index 00000000000..8e5404730d8 --- /dev/null +++ b/guides/grails-rest-library/v8/guide/download.adoc @@ -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. diff --git a/guides/grails-rest-library/v8/guide/gettingStarted.adoc b/guides/grails-rest-library/v8/guide/gettingStarted.adoc new file mode 100644 index 00000000000..3140ba257ea --- /dev/null +++ b/guides/grails-rest-library/v8/guide/gettingStarted.adoc @@ -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. diff --git a/guides/grails-rest-library/v8/guide/helpWithGrails.adoc b/guides/grails-rest-library/v8/guide/helpWithGrails.adoc new file mode 100644 index 00000000000..e062f614b1a --- /dev/null +++ b/guides/grails-rest-library/v8/guide/helpWithGrails.adoc @@ -0,0 +1 @@ +include::{commondir}/common-helpWithGrails.adoc[] diff --git a/guides/grails-rest-library/v8/guide/howto.adoc b/guides/grails-rest-library/v8/guide/howto.adoc new file mode 100644 index 00000000000..1fc06895855 --- /dev/null +++ b/guides/grails-rest-library/v8/guide/howto.adoc @@ -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. diff --git a/guides/grails-rest-library/v8/guide/jsonViews.adoc b/guides/grails-rest-library/v8/guide/jsonViews.adoc new file mode 100644 index 00000000000..3bc0311eea8 --- /dev/null +++ b/guides/grails-rest-library/v8/guide/jsonViews.adoc @@ -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 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. diff --git a/guides/grails-rest-library/v8/guide/pagination.adoc b/guides/grails-rest-library/v8/guide/pagination.adoc new file mode 100644 index 00000000000..9b57a4db55a --- /dev/null +++ b/guides/grails-rest-library/v8/guide/pagination.adoc @@ -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 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. +==== diff --git a/guides/grails-rest-library/v8/guide/requirements.adoc b/guides/grails-rest-library/v8/guide/requirements.adoc new file mode 100644 index 00000000000..388220a38d1 --- /dev/null +++ b/guides/grails-rest-library/v8/guide/requirements.adoc @@ -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 diff --git a/guides/grails-rest-library/v8/guide/runningTheApp.adoc b/guides/grails-rest-library/v8/guide/runningTheApp.adoc new file mode 100644 index 00000000000..35c4e59b834 --- /dev/null +++ b/guides/grails-rest-library/v8/guide/runningTheApp.adoc @@ -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. diff --git a/guides/grails-rest-library/v8/guide/urlMappings.adoc b/guides/grails-rest-library/v8/guide/urlMappings.adoc new file mode 100644 index 00000000000..8c13e85b689 --- /dev/null +++ b/guides/grails-rest-library/v8/guide/urlMappings.adoc @@ -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. diff --git a/guides/grails-rest-library/v8/guide/validation.adoc b/guides/grails-rest-library/v8/guide/validation.adoc new file mode 100644 index 00000000000..9b72ab21bce --- /dev/null +++ b/guides/grails-rest-library/v8/guide/validation.adoc @@ -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. diff --git a/guides/grails-rest-library/v8/guide/versioning.adoc b/guides/grails-rest-library/v8/guide/versioning.adoc new file mode 100644 index 00000000000..0c0755c855b --- /dev/null +++ b/guides/grails-rest-library/v8/guide/versioning.adoc @@ -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. diff --git a/guides/grails-rest-library/v8/guide/whatYouWillBuild.adoc b/guides/grails-rest-library/v8/guide/whatYouWillBuild.adoc new file mode 100644 index 00000000000..bc5f884261d --- /dev/null +++ b/guides/grails-rest-library/v8/guide/whatYouWillBuild.adoc @@ -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` and `AuthorController extends RestfulController`, 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`. diff --git a/guides/grails-rest-library/v8/snippets/grails-app/controllers/example/AuthorController.groovy b/guides/grails-rest-library/v8/snippets/grails-app/controllers/example/AuthorController.groovy new file mode 100644 index 00000000000..3c51d83e3b8 --- /dev/null +++ b/guides/grails-rest-library/v8/snippets/grails-app/controllers/example/AuthorController.groovy @@ -0,0 +1,23 @@ +package example + +import grails.rest.RestfulController + +class AuthorController extends RestfulController { + + static responseFormats = ['json'] + + AuthorController() { super(Author) } + + /** + * GET /v1/authors?max=20&offset=0&sort=name&order=asc + * + * Bound `max` defaults to 25 and is hard-capped at 100 so a paginating + * client cannot ask for the full table in one request. `offset`, + * `sort`, and `order` flow through to the GORM query unchanged. + */ + @Override + protected List listAllResources(Map params) { + params.max = Math.min(params.int('max', 25), 100) + Author.list(params) + } +} diff --git a/guides/grails-rest-library/v8/snippets/grails-app/controllers/example/BookController.groovy b/guides/grails-rest-library/v8/snippets/grails-app/controllers/example/BookController.groovy new file mode 100644 index 00000000000..c5957679782 --- /dev/null +++ b/guides/grails-rest-library/v8/snippets/grails-app/controllers/example/BookController.groovy @@ -0,0 +1,55 @@ +package example + +import grails.gorm.transactions.Transactional +import grails.rest.RestfulController +import org.springframework.http.HttpStatus + +class BookController extends RestfulController { + + static responseFormats = ['json'] + + BookController() { super(Book) } + + @Override + protected List listAllResources(Map params) { + params.max = Math.min(params.int('max', 25), 100) + Book.list(params) + } + + /** + * POST /v1/books/bulk + * + * All-or-nothing bulk create. The whole request rolls back on the + * first validation failure; the response is a structured 422 with + * the field errors of every failing book in the batch. + */ + @Transactional + def bulkCreate() { + if (!request.JSON instanceof List) { + response.status = HttpStatus.UNPROCESSABLE_ENTITY.value() + respond errors: [[message: 'request body must be a JSON array of Book objects']] + return + } + + List drafts = request.JSON.collect { json -> new Book(json as Map) } + List failures = [] + drafts.eachWithIndex { Book b, int i -> + if (!b.validate()) { + failures << [index: i, errors: b.errors.fieldErrors.collect { + [field: it.field, code: it.code, message: it.defaultMessage] + }] + } + } + + if (failures) { + transactionStatus.setRollbackOnly() + response.status = HttpStatus.UNPROCESSABLE_ENTITY.value() + respond books: failures + return + } + + drafts*.save(flush: true) + response.status = HttpStatus.CREATED.value() + respond books: drafts + } +} diff --git a/guides/grails-rest-library/v8/snippets/grails-app/controllers/example/UrlMappings.groovy b/guides/grails-rest-library/v8/snippets/grails-app/controllers/example/UrlMappings.groovy new file mode 100644 index 00000000000..928225125ff --- /dev/null +++ b/guides/grails-rest-library/v8/snippets/grails-app/controllers/example/UrlMappings.groovy @@ -0,0 +1,19 @@ +package example + +class UrlMappings { + + static mappings = { + + '/v1/books'(resources: 'book') { + collection { + '/bulk'(controller: 'book', action: 'bulkCreate', method: 'POST') + } + } + '/v1/authors'(resources: 'author') + + '/'(view: '/index') + + '500'(view: '/error') + '404'(view: '/notFound') + } +} diff --git a/guides/grails-rest-library/v8/snippets/grails-app/domain/example/Author.groovy b/guides/grails-rest-library/v8/snippets/grails-app/domain/example/Author.groovy new file mode 100644 index 00000000000..274de2d48dc --- /dev/null +++ b/guides/grails-rest-library/v8/snippets/grails-app/domain/example/Author.groovy @@ -0,0 +1,25 @@ +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 } +} diff --git a/guides/grails-rest-library/v8/snippets/grails-app/domain/example/Book.groovy b/guides/grails-rest-library/v8/snippets/grails-app/domain/example/Book.groovy new file mode 100644 index 00000000000..3547c7ccf00 --- /dev/null +++ b/guides/grails-rest-library/v8/snippets/grails-app/domain/example/Book.groovy @@ -0,0 +1,23 @@ +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, matches: /^(97(8|9))?\d{9}(\d|X)$/ + pageCount nullable: true, min: 1 + publishedOn nullable: true + } + + String toString() { title } +} diff --git a/guides/grails-rest-library/v8/snippets/grails-app/init/example/BootStrap.groovy b/guides/grails-rest-library/v8/snippets/grails-app/init/example/BootStrap.groovy new file mode 100644 index 00000000000..4e231fb0db0 --- /dev/null +++ b/guides/grails-rest-library/v8/snippets/grails-app/init/example/BootStrap.groovy @@ -0,0 +1,44 @@ +package example + +import grails.gorm.transactions.Transactional +import grails.util.Environment +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +class BootStrap { + + private static final Logger log = LoggerFactory.getLogger(BootStrap) + + @Transactional + 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 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 = { } +} diff --git a/guides/grails-rest-library/v8/snippets/grails-app/views/author/_author.gson b/guides/grails-rest-library/v8/snippets/grails-app/views/author/_author.gson new file mode 100644 index 00000000000..ac96c7b2d78 --- /dev/null +++ b/guides/grails-rest-library/v8/snippets/grails-app/views/author/_author.gson @@ -0,0 +1,17 @@ +import example.Author + +model { + Author author +} + +json { + id author.id + name author.name + biography author.biography + dateOfBirth author.dateOfBirth?.format('yyyy-MM-dd') + bookCount author.books?.size() ?: 0 + _links { + self url: g.createLink(controller: 'author', action: 'show', id: author.id, absolute: true) + books url: g.createLink(controller: 'book', action: 'index', params: [author: author.id], absolute: true) + } +} diff --git a/guides/grails-rest-library/v8/snippets/grails-app/views/author/index.gson b/guides/grails-rest-library/v8/snippets/grails-app/views/author/index.gson new file mode 100644 index 00000000000..c8b2fbc0f6d --- /dev/null +++ b/guides/grails-rest-library/v8/snippets/grails-app/views/author/index.gson @@ -0,0 +1,13 @@ +import example.Author + +model { + Iterable authorList + Long authorCount +} + +json { + page Math.max(0, (params.int('offset', 0) ?: 0).intdiv(params.int('max', 25) ?: 25)) + pageSize params.int('max', 25) + total authorCount + items authorList.collect { Author a -> g.render(template: 'author', model: [author: a]) } +} diff --git a/guides/grails-rest-library/v8/snippets/grails-app/views/author/show.gson b/guides/grails-rest-library/v8/snippets/grails-app/views/author/show.gson new file mode 100644 index 00000000000..59a0eb0bcc6 --- /dev/null +++ b/guides/grails-rest-library/v8/snippets/grails-app/views/author/show.gson @@ -0,0 +1,7 @@ +import example.Author + +model { + Author author +} + +json g.render(template: 'author', model: [author: author]) diff --git a/guides/grails-rest-library/v8/snippets/grails-app/views/book/_book.gson b/guides/grails-rest-library/v8/snippets/grails-app/views/book/_book.gson new file mode 100644 index 00000000000..28e7cf9d683 --- /dev/null +++ b/guides/grails-rest-library/v8/snippets/grails-app/views/book/_book.gson @@ -0,0 +1,21 @@ +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 url: g.createLink(controller: 'book', action: 'show', id: book.id, absolute: true) + author url: g.createLink(controller: 'author', action: 'show', id: book.author.id, absolute: true) + } +} diff --git a/guides/grails-rest-library/v8/snippets/grails-app/views/book/index.gson b/guides/grails-rest-library/v8/snippets/grails-app/views/book/index.gson new file mode 100644 index 00000000000..763066abd96 --- /dev/null +++ b/guides/grails-rest-library/v8/snippets/grails-app/views/book/index.gson @@ -0,0 +1,13 @@ +import example.Book + +model { + Iterable bookList + Long bookCount +} + +json { + page Math.max(0, (params.int('offset', 0) ?: 0).intdiv(params.int('max', 25) ?: 25)) + pageSize params.int('max', 25) + total bookCount + items bookList.collect { Book b -> g.render(template: 'book', model: [book: b]) } +} diff --git a/guides/grails-rest-library/v8/snippets/grails-app/views/book/show.gson b/guides/grails-rest-library/v8/snippets/grails-app/views/book/show.gson new file mode 100644 index 00000000000..fa61f6c25fc --- /dev/null +++ b/guides/grails-rest-library/v8/snippets/grails-app/views/book/show.gson @@ -0,0 +1,7 @@ +import example.Book + +model { + Book book +} + +json g.render(template: 'book', model: [book: book])