Skip to content
Open
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
45 changes: 45 additions & 0 deletions conf/guides.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2823,6 +2823,51 @@ guides:
helpWithGrails:
title: Help with Grails

- name: 'grails-http-client'
title: 'Calling REST APIs with Spring HTTP Services in Grails 8'
subtitle: 'Add a declarative @HttpExchange client to a Grails 8 REST API, combine GORM data with external API results, and test with Spock.'
authors:
- 'Sanjana'
category: 'Grails REST APIs'
publicationDate: '2026-06-26'
versions:
'8':
sourcePath: guides/grails-http-client/v8
publicationDate: '2026-06-26'
tags:
- 'rest-api'
- 'http-client'
- 'http-exchange'
- 'spring-boot'
- 'gorm'
- 'postgresql'
- 'spock'
- 'testcontainers'
- 'integration-testing'
sampleRef:
repo: 'grails-guides/grails-http-client'
branch: 'grails8'
toc:
introduction:
title: Introduction
requirements: What you will need
gettingStarted:
title: Getting Started
httpClientSetup:
title: HTTP client setup
domainAndControllers:
title: Domain and local REST API
searchService:
title: Search service and controller
testing:
title: Testing
runningTheApp:
title: Running the application
summary:
title: Summary
helpWithGrails:
title: Do you need help with Grails?

- name: 'grails-htmx'
title: 'HTMX with Grails 8'
subtitle: 'Build a small task tracker with server-rendered Grails 8 GSP and HTMX-driven inline editing, live search, optimistic delete, and toggle - no SPA, no JSON.'
Expand Down
40 changes: 40 additions & 0 deletions guides/grails-http-client/v8/guide/domainAndControllers.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
Local record label data stays in GORM while album search results come from the external API.

[[recordLabel]]
== RecordLabel domain

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

Seed development data in `BootStrap`:

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

[[urlMappings]]
== URL mappings

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

[[recordLabelController]]
== RecordLabelController

The REST controller delegates persistence to GORM and returns JSON views:

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

Call `validate()` after binding request JSON and before `save`. That returns structured `422` responses for constraint violations.
22 changes: 22 additions & 0 deletions guides/grails-http-client/v8/guide/gettingStarted.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
Clone the repository and run the starting application:

[source,bash]
----
git clone -b grails8 https://github.com/grails-guides/grails-http-client.git
cd grails-http-client/initial
./gradlew bootRun
----

Open http://localhost:8080/[http://localhost:8080/] for the welcome JSON payload. The `initial/` project is a vanilla Grails 8 REST API starter with no HTTP client yet.

To skip ahead, `cd complete` and run the same commands — that tree contains the finished `@HttpExchange` client, services, and tests.

[[cloneAndRun]]
== Verify tests

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

Both `initial` and `complete` must pass in CI (see `.github/workflows/grails8.yml`).
1 change: 1 addition & 0 deletions guides/grails-http-client/v8/guide/helpWithGrails.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
include::{commondir}/common-helpWithGrails.adoc[]
39 changes: 39 additions & 0 deletions guides/grails-http-client/v8/guide/httpClientSetup.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
Register HTTP service interfaces on the application class with `@ImportHttpServices`:

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

Spring Boot scans `example` for `@HttpExchange` interfaces and auto-configures a `RestClient` proxy for each one.

[[itunesClient]]
== ItunesClient

Declare the iTunes Search API client as a Spring HTTP service interface:

[source,groovy]
.src/main/groovy/example/ItunesClient.groovy
----
include::../snippets/src/main/groovy/example/ItunesClient.groovy[]
----

[[dtos]]
== Response DTOs

Map the JSON response with simple POGOs:

[source,groovy]
.src/main/groovy/example/Album.groovy
----
include::../snippets/src/main/groovy/example/Album.groovy[]
----

[source,groovy]
.src/main/groovy/example/SearchResult.groovy
----
include::../snippets/src/main/groovy/example/SearchResult.groovy[]
----

Spring deserializes the iTunes JSON into these types automatically.
5 changes: 5 additions & 0 deletions guides/grails-http-client/v8/guide/introduction.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Learn how to call external REST APIs from a Grails 8 application using Spring Boot's built-in HTTP Services support: define a `@HttpExchange` interface, register it with `@ImportHttpServices`, inject the generated client into a Grails service, and expose results through JSON views. Local `RecordLabel` data stays in GORM/PostgreSQL; album metadata comes from the iTunes Search API.

No Micronaut plugin or extra HTTP client dependency is required — this uses the same Spring stack Grails 8 already runs on.

This guide follows the standard Grails guides layout: work in the `initial/` project and compare your progress with `complete/`.
5 changes: 5 additions & 0 deletions guides/grails-http-client/v8/guide/requirements.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
* Approximately 45 minutes
* JDK 21 (Apache Grails 8 requires Java 21)
* A https://www.grails.org/[Grails] installation or the bundled Gradle wrapper in `initial/` and `complete/`
* **Docker** — required for integration tests (Testcontainers PostgreSQL)
* **PostgreSQL** on `localhost:5432` — required for `./gradlew bootRun` in both `initial/` and `complete/` (default database `devDb`; see `grails-app/conf/application.yml`)
15 changes: 15 additions & 0 deletions guides/grails-http-client/v8/guide/runningTheApp.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[source,bash]
----
cd complete
./gradlew bootRun
----

Example endpoints:

[source,bash]
----
curl -s http://localhost:8080/api/recordLabels
curl -s 'http://localhost:8080/api/search?q=U2'
----

The search endpoint returns album metadata from the iTunes Search API. Record labels are served from your local PostgreSQL database.
17 changes: 17 additions & 0 deletions guides/grails-http-client/v8/guide/searchService.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
Inject the `@HttpExchange` client into a Grails service:

[source,groovy]
.grails-app/services/example/ItunesSearchService.groovy
----
include::../snippets/grails-app/services/example/ItunesSearchService.groovy[]
----

Expose search results through a thin controller:

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

The service trims blank search terms and returns an empty list rather than calling the remote API with invalid input.
8 changes: 8 additions & 0 deletions guides/grails-http-client/v8/guide/summary.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
You added a declarative HTTP client to a Grails 8 REST API:

* `@ImportHttpServices` and a `@HttpExchange` interface for the iTunes Search API
* A Grails service that injects the generated client
* Local GORM data alongside external API results
* Spock unit and integration tests

Next steps: add error handling for remote API failures, cache search results, or secure outbound calls with API keys.
52 changes: 52 additions & 0 deletions guides/grails-http-client/v8/guide/testing.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
[[unitTests]]
== Unit tests

Domain constraints:

[source,groovy]
.src/test/groovy/example/RecordLabelSpec.groovy
----
include::../snippets/src/test/groovy/example/RecordLabelSpec.groovy[]
----

Service delegation to the HTTP client with a mock:

[source,groovy]
.src/test/groovy/example/ItunesSearchServiceSpec.groovy
----
include::../snippets/src/test/groovy/example/ItunesSearchServiceSpec.groovy[]
----

[[integrationTests]]
== Integration tests

Verify the HTTP client is registered as a Spring bean:

[source,groovy]
.src/integration-test/groovy/example/ItunesClientIntegrationSpec.groovy
----
include::../snippets/src/integration-test/groovy/example/ItunesClientIntegrationSpec.groovy[]
----

Assert GORM persistence against real PostgreSQL (Testcontainers):

[source,groovy]
.src/integration-test/groovy/example/RecordLabelIntegrationSpec.groovy
----
include::../snippets/src/integration-test/groovy/example/RecordLabelIntegrationSpec.groovy[]
----

Run unit tests:

[source,bash]
----
cd complete
./gradlew test
----

Run integration tests (requires Docker):

[source,bash]
----
./gradlew integrationTest
----
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package example

import grails.gorm.transactions.Transactional

class RecordLabelController {

static responseFormats = ['json']
static allowedMethods = [index: 'GET', show: 'GET', save: 'POST', update: 'PUT', delete: 'DELETE']

def index(Integer max) {
params.max = Math.min(max ?: 10, 100)
respond RecordLabel.list(params), model: [recordLabelCount: RecordLabel.count()]
}

def show(Long id) {
respond RecordLabel.get(id)
}

@Transactional
def save() {
def recordLabel = new RecordLabel(request.JSON as Map)
if (!recordLabel.validate()) {
respond recordLabel.errors, status: 422
return
}
recordLabel.save(failOnError: true, flush: true)
respond recordLabel, status: 201
}

@Transactional
def update(Long id) {
def recordLabel = RecordLabel.get(id)
if (!recordLabel) {
render status: 404
return
}
recordLabel.properties = request.JSON
if (!recordLabel.validate()) {
respond recordLabel.errors, status: 422
return
}
recordLabel.save(failOnError: true, flush: true)
respond recordLabel
}

@Transactional
def delete(Long id) {
def recordLabel = RecordLabel.get(id)
if (!recordLabel) {
render status: 404
return
}
recordLabel.delete(flush: true)
render status: 204
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package example

import groovy.transform.CompileStatic

@CompileStatic
class SearchController {

static responseFormats = ['json']
static allowedMethods = [index: 'GET']

ItunesSearchService itunesSearchService

def index(String q) {
if (!q?.trim()) {
respond([searchTerm: q, albums: []])
return
}
List<Album> albums = itunesSearchService.searchAlbums(q)
respond([searchTerm: q.trim(), albums: albums])
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package example

class UrlMappings {

static mappings = {
"/$controller/$action?/$id?(.$format)?"{
constraints {
// apply constraints here
}
}

"/api/search"(controller: 'search', action: 'index')
"/api/recordLabels"(resources: 'recordLabel')

"/"(controller: 'application', action: 'index')
"500"(view: '/error')
"404"(view: '/notFound')
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package example

import grails.persistence.Entity
import groovy.util.logging.Slf4j

@Slf4j
@Entity
class RecordLabel {

String name

static constraints = {
name blank: false, nullable: false, maxSize: 100, unique: true
}

String toString() {
name
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package example

import grails.boot.GrailsApp
import grails.boot.config.GrailsAutoConfiguration
import groovy.transform.CompileStatic
import org.springframework.web.service.registry.ImportHttpServices

@CompileStatic
@ImportHttpServices(basePackages = 'example')
class Application extends GrailsAutoConfiguration {
static void main(String[] args) {
GrailsApp.run(Application, args)
}
}
Loading