Calling REST APIs with Spring HTTP Services in Grails 8
Add a declarative @HttpExchange client to a Grails 8 REST API, combine GORM data with external API results, and test with Spock.
Authors: Sanjana
Grails Version: 8
1 Introduction
Learn how to call external REST APIs from a Grails 8 application using Spring Framework HTTP Services: 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 is required - this uses the same Spring stack Grails 8 already runs on. Add spring-boot-starter-json so the HTTP client can deserialize JSON responses.
This guide follows the standard Grails guides layout: work in the initial/ project and compare your progress with complete/.
2 What you will need
-
Approximately 45 minutes
-
JDK 21 (Apache Grails 8 requires Java 21)
-
A Grails installation or the bundled Gradle wrapper in
initial/andcomplete/ -
Docker - required for integration tests (Testcontainers PostgreSQL)
-
PostgreSQL on
localhost:5432- required for./gradlew bootRunin bothinitial/andcomplete/(default databasedevDb; seegrails-app/conf/application.yml)
3 Getting Started
Clone the repository and run the starting application:
git clone -b grails8 https://github.com/grails-guides/grails-http-client.git
cd grails-http-client/initial
./gradlew bootRun
Open 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.
Verify tests
./gradlew test
Both initial and complete must pass in CI (see .github/workflows/grails8.yml).
4 HTTP client setup
Register HTTP service interfaces with @ImportHttpServices and explicitly import the client configuration:
package example
import grails.boot.GrailsApp
import grails.boot.config.GrailsAutoConfiguration
import groovy.transform.CompileStatic
import org.springframework.context.annotation.Import
import org.springframework.web.service.registry.ImportHttpServices
@CompileStatic
@ImportHttpServices(basePackages = 'example')
@Import(ItunesClientConfiguration)
class Application extends GrailsAutoConfiguration {
static void main(String[] args) {
GrailsApp.run(Application, args)
}
}
@ImportHttpServices scans example for @HttpExchange interfaces and registers a RestClient-backed proxy bean for each one. @Import(ItunesClientConfiguration) ensures Grails loads the RestClient group configurer. This requires the Grails 8 Spring 7 / Boot 4 baseline.
ItunesClient
Declare the iTunes Search API client as a Spring HTTP service interface:
package example
import groovy.transform.CompileStatic
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.service.annotation.GetExchange
import org.springframework.web.service.annotation.HttpExchange
@CompileStatic
@HttpExchange
interface ItunesClient {
@GetExchange('/search?limit=25&media=music&entity=album&term={term}')
SearchResult search(@PathVariable('term') String term)
}
Configure the RestClient base URL from itunes.base-url (defaults to the public iTunes API; integration tests override it with MockWebServer). The iTunes API returns JSON with a text/javascript content type, so add that media type to Spring’s Jackson converter:
package example
import groovy.transform.CompileStatic
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.MediaType
import org.springframework.http.converter.HttpMessageConverter
import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter
import org.springframework.web.client.support.RestClientHttpServiceGroupConfigurer
@CompileStatic
@Configuration
class ItunesClientConfiguration {
private static final MediaType JAVASCRIPT = MediaType.parseMediaType('text/javascript')
@Bean
RestClientHttpServiceGroupConfigurer itunesBaseUrlConfigurer(
@Value('${itunes.base-url:https://itunes.apple.com}') String itunesBaseUrl) {
return { groups ->
groups.forEachClient { group, builder ->
builder.baseUrl(itunesBaseUrl)
builder.messageConverters { List<HttpMessageConverter<?>> converters ->
JacksonJsonHttpMessageConverter jacksonConverter =
(JacksonJsonHttpMessageConverter) converters.find { HttpMessageConverter<?> converter ->
converter instanceof JacksonJsonHttpMessageConverter
}
jacksonConverter.supportedMediaTypes = jacksonConverter.supportedMediaTypes + JAVASCRIPT
}
}
}
}
}
Response DTOs
Map the JSON response with simple POGOs:
package example
import groovy.transform.CompileStatic
@CompileStatic
class Album {
String artistName
String collectionName
String collectionViewUrl
}
package example
import groovy.transform.CompileStatic
@CompileStatic
class SearchResult {
int resultCount
List<Album> results = []
}
With spring-boot-starter-json on the classpath and text/javascript enabled above, Spring deserializes the iTunes JSON into these types automatically.
5 Domain and local REST API
Local record label data stays in GORM while album search results come from the external API.
RecordLabel domain
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
}
}
Seed development data in BootStrap:
package example
class BootStrap {
def init = { servletContext ->
environments {
development {
RecordLabel.withTransaction {
if (RecordLabel.count() == 0) {
['Island Records', 'Motown', 'Blue Note'].each { labelName ->
new RecordLabel(name: labelName).save(failOnError: true)
}
}
}
}
}
}
def destroy = {
}
}
URL mappings
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')
}
}
RecordLabelController
The REST controller delegates persistence to GORM and returns JSON views:
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
}
}
Call validate() after binding request JSON and before save. That returns structured 422 responses for constraint violations.
6 Search service and controller
Inject the @HttpExchange client into a Grails service:
package example
import groovy.transform.CompileStatic
import org.springframework.beans.factory.annotation.Autowired
@CompileStatic
class ItunesSearchService {
@Autowired
ItunesClient itunesClient
List<Album> searchAlbums(String searchTerm) {
if (!searchTerm?.trim()) {
return []
}
SearchResult searchResult = itunesClient.search(searchTerm.trim())
searchResult?.results ?: []
}
}
Expose search results through a thin controller:
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])
}
}
The service trims blank search terms and returns an empty list rather than calling the remote API with invalid input.
7 Testing
Unit tests
Domain constraints:
package example
import grails.testing.gorm.DataTest
import spock.lang.Specification
class RecordLabelSpec extends Specification implements DataTest {
Class<?>[] getDomainClassesToMock() {
[RecordLabel] as Class[]
}
void 'name cannot be blank'() {
when:
RecordLabel recordLabel = new RecordLabel(name: '')
then:
!recordLabel.validate()
recordLabel.errors['name'].code in ['blank', 'nullable']
}
void 'name must be unique'() {
given:
new RecordLabel(name: 'Motown').save(flush: true)
when:
RecordLabel duplicate = new RecordLabel(name: 'Motown')
then:
!duplicate.validate()
duplicate.errors['name'].code == 'unique'
}
}
Service delegation to the HTTP client with a mock:
package example
import spock.lang.Specification
class ItunesSearchServiceSpec extends Specification {
ItunesSearchService service = new ItunesSearchService()
void 'searchAlbums delegates to the ItunesClient HTTP service'() {
given:
def client = Mock(ItunesClient)
service.itunesClient = client
def albums = [new Album(artistName: 'U2', collectionName: 'The Joshua Tree')]
client.search('U2') >> new SearchResult(resultCount: 1, results: albums)
expect:
service.searchAlbums('U2') == albums
}
void 'searchAlbums returns an empty list for blank terms'() {
expect:
service.searchAlbums(null).isEmpty()
service.searchAlbums(' ').isEmpty()
}
}
Integration tests
Verify the HTTP client is registered as a Spring bean, encodes query values, accepts the iTunes API’s text/javascript response, and deserializes albums:
package example
import grails.gorm.transactions.Rollback
import grails.testing.mixin.integration.Integration
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.test.context.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
import spock.lang.Specification
@Integration
@Rollback
class ItunesClientIntegrationSpec extends Specification {
static MockWebServer mockWebServer = new MockWebServer()
static {
mockWebServer.start()
}
@Autowired
ItunesClient itunesClient
@DynamicPropertySource
static void itunesBaseUrl(DynamicPropertyRegistry registry) {
String baseUrl = mockWebServer.url('/').toString()
if (baseUrl.endsWith('/')) {
baseUrl = baseUrl[0..-2]
}
registry.add('itunes.base-url', { baseUrl })
}
void cleanupSpec() {
mockWebServer.shutdown()
}
void 'declarative ItunesClient HTTP service is registered as a Spring bean'() {
expect:
itunesClient != null
itunesClient instanceof ItunesClient
}
void 'search binds the term query parameter and deserializes albums'() {
given:
String searchTerm = 'U2 & Friends'
mockWebServer.enqueue(new MockResponse()
.setHeader('Content-Type', 'text/javascript; charset=utf-8')
.setBody('''{"resultCount":1,"results":[{"artistName":"U2","collectionName":"The Joshua Tree","collectionViewUrl":"https://example.com/album"}]}'''))
when:
SearchResult result = itunesClient.search(searchTerm)
then:
result.resultCount == 1
result.results.size() == 1
result.results[0].artistName == 'U2'
result.results[0].collectionName == 'The Joshua Tree'
result.results[0].collectionViewUrl == 'https://example.com/album'
and:
RecordedRequest request = mockWebServer.takeRequest()
request.method == 'GET'
request.path.contains('/search?')
request.requestUrl.queryParameter('term') == searchTerm
}
}
Assert GORM persistence against real PostgreSQL (Testcontainers):
package example
import grails.testing.mixin.integration.Integration
import grails.gorm.transactions.Rollback
import spock.lang.Specification
@Integration
@Rollback
class RecordLabelIntegrationSpec extends Specification {
void 'record labels persist in PostgreSQL'() {
when:
RecordLabel.withTransaction {
new RecordLabel(name: 'Integration Label').save(flush: true, failOnError: true)
}
then:
RecordLabel.count() >= 1
RecordLabel.findByName('Integration Label') != null
}
}
Run unit tests (from the repository root):
cd complete
./gradlew test
Run integration tests (requires Docker):
./gradlew integrationTest
8 Running the application
From the repository root:
cd complete
./gradlew bootRun
Example endpoints:
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.
9 Summary
You added a declarative HTTP client to a Grails 8 REST API:
-
@ImportHttpServicesand a@HttpExchangeinterface 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.
10 Do you need help with Grails?
Help with Apache Grails
Apache Grails is supported by an active community of contributors and the Apache Software Foundation. If you need help working through a guide, want to discuss the framework, or have run into something that looks like a bug, the channels below are the right place to start.
-
Slack - real-time conversation with the Apache Grails community.
-
Developer mailing list - design discussions and contributor coordination.
-
Users mailing list - end-user questions and answers.
-
Issue tracker on GitHub - file a bug or feature request against the framework.
For Grails plugins, see the matching project on the apache org or the plugin’s own GitHub repository.