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/ 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)

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:

grails-app/init/example/Application.groovy
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:

src/main/groovy/example/ItunesClient.groovy
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:

src/main/groovy/example/ItunesClientConfiguration.groovy
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:

src/main/groovy/example/Album.groovy
package example

import groovy.transform.CompileStatic

@CompileStatic
class Album {
    String artistName
    String collectionName
    String collectionViewUrl
}
src/main/groovy/example/SearchResult.groovy
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

grails-app/domain/example/RecordLabel.groovy
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:

grails-app/init/example/BootStrap.groovy
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

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

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

grails-app/services/example/ItunesSearchService.groovy
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:

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

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

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

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

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

  • @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.

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.

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