HTTP Client with HttpBuilder

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Apache Groovy 6.0 (6.0.0, 2026-09-22) and every example was run with Groovy 6.0.0 on JDK 26 (checked on 2026-10-14). The groovy-http-builder module is new in Groovy 6.0 and is still incubating, so its API can change in a later release. It puts a small closure DSL over the JDK’s own java.net.http.HttpClient: a script can send a request, read the answer as JSON, XML or HTML, and go on, without adding a third-party HTTP library. The module’s jar (groovy-http-builder-6.0.0.jar) is in the lib directory of the binary distribution, so the examples below need no @Grab; in a Gradle or Maven build, add the dependency org.apache.groovy:groovy-http-builder. The JSON and XML helpers build on JSON and XML Parsing, GPath & Security, the asynchronous variants relate to Async/Await, and @Grab for the optional jsoup library is covered on Grape Dependency Management.

Goals and scope

The module is deliberately small. Its documented goals are to keep the implementation easy to maintain, to use only JDK HTTP client primitives (jsoup is optional, for HTML), to make common request set-up declarative with closures, and to handle only the simple cases that come up in scripting: it is not a browser-automation tool (that is the job of Geb, see Testing with Spock & Geb). It offers two entry points, both in the groovy.http package:

  • HttpBuilder, an imperative DSL: create a client with HttpBuilder.http { ... }, call get, post, put, delete, patch (or the generic request(method, uri)), and read the HttpResult.

  • @HttpBuilderClient, a declarative style: annotate an interface, and an AST transformation generates the implementation that calls HttpBuilder underneath.

Everything on this page talks to a server that the examples start themselves on localhost, on an ephemeral port, so the output is reproducible and no network, account or credential is involved.

A local greenhouse server

The examples need something to call. GreenhouseServer is a small class built on the JDK’s com.sun.net.httpserver.HttpServer (module jdk.httpserver, available in any standard JDK). It binds to the loopback address with port 0, so the operating system chooses a free port, and it serves a handful of endpoints for the greenhouse scenario: JSON readings, an XML seed catalogue, an HTML status page, a form endpoint, redirects, an echo endpoint that reports the headers it received, a slow endpoint, and error responses. The server runs on a small thread pool so that concurrent requests are really served at the same time. GreenhouseServer.run { gh -> ... } starts it, passes it to the closure and always stops it.

import com.sun.net.httpserver.HttpExchange
import com.sun.net.httpserver.HttpServer
import groovy.json.JsonOutput
import groovy.json.JsonSlurper

import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors

class GreenhouseServer {
    HttpServer server

    static GreenhouseServer start() {
        def gs = new GreenhouseServer()
        gs.server = HttpServer.create(new InetSocketAddress(InetAddress.loopbackAddress, 0), 0)
        gs.routes()
        gs.server.executor = Executors.newFixedThreadPool(4)
        gs.server.start()
        gs
    }

    /** Starts a server, passes it to the closure and always stops it. */
    static void run(Closure body) {
        def gh = start()
        try { body(gh) } finally { gh.stop() }
    }

    String getBase() { "http://localhost:${server.address.port}" }
    void stop() {
        server.stop(0)
        (server.executor as ExecutorService).shutdownNow()
    }

    private void reply(HttpExchange ex, int status, String type, String body, Map headers = [:]) {
        byte[] bytes = body.getBytes('UTF-8')
        ex.responseHeaders.add('Content-Type', type)
        headers.each { k, v -> ex.responseHeaders.add(k, v) }
        ex.sendResponseHeaders(status, bytes.length == 0 ? -1 : bytes.length)
        if (bytes.length) ex.responseBody.withCloseable { it.write(bytes) }
        ex.close()
    }

    private void json(HttpExchange ex, int status, Object data) {
        reply(ex, status, 'application/json', JsonOutput.toJson(data))
    }

    private void routes() {
        server.createContext('/api/readings') { ex ->
            if (ex.requestMethod == 'POST') {
                def body = new JsonSlurper().parse(ex.requestBody)
                json(ex, 201, [ok: true, stored: body, contentType: ex.requestHeaders.getFirst('Content-Type')])
            } else {
                json(ex, 200, [zone: 'bench', items: [[sensor: 'T1', temperature: 21.5], [sensor: 'T2', temperature: 22.0]],
                               rawQuery: ex.requestURI.rawQuery])
            }
        }
        server.createContext('/api/catalogue.xml') { ex ->
            reply(ex, 200, 'application/xml',
                  '<catalogue><seed id="s1"><name>Tomato</name></seed><seed id="s2"><name>Basil</name></seed></catalogue>')
        }
        server.createContext('/greenhouse') { ex ->
            reply(ex, 200, 'text/html', '<html><body><h1>Greenhouse status</h1><p class="zone">Bench: ok</p></body></html>')
        }
        server.createContext('/api/water') { ex ->
            def form = ex.requestBody.text
            json(ex, 200, [ok: true, rawForm: form, contentType: ex.requestHeaders.getFirst('Content-Type')])
        }
        server.createContext('/old') { ex ->
            reply(ex, 302, 'text/plain', '', [Location: '/api/readings'])
        }
        server.createContext('/echo') { ex ->
            def h = ex.requestHeaders
            json(ex, 200, [zone: h.getFirst('X-Zone'), method: ex.requestMethod, path: ex.requestURI.path, host: h.getFirst('Host'),
                           body: ex.requestBody.text])
        }
        server.createContext('/jump') { ex ->
            def to = ex.requestURI.rawQuery.substring(3)
            reply(ex, 302, 'text/plain', '', [Location: to])
        }
        server.createContext('/after-post') { ex ->
            reply(ex, 303, 'text/plain', '', [Location: '/echo'])
        }
        server.createContext('/slow') { ex ->
            Thread.sleep(300)
            json(ex, 200, [zone: ex.requestURI.query.split('=')[1], delayed: true])
        }
        server.createContext('/boom') { ex -> json(ex, 500, [error: 'sensor bus offline']) }
        server.createContext('/api/plants/') { ex ->
            def name = URLDecoder.decode(ex.requestURI.rawPath.substring('/api/plants/'.length()), 'UTF-8')
            if (name == 'cactus') {
                json(ex, 404, [error: 'unknown plant'])
            } else {
                def plant = [name: name, bio: "${name} grows well on the bench"]
                if (ex.requestMethod != 'GET') plant.method = ex.requestMethod
                json(ex, 200, plant)
            }
        }
        server.createContext('/missing') { ex -> json(ex, 404, [error: 'no such zone']) }
        server.createContext('/plain') { ex -> reply(ex, 200, 'text/plain', 'just text') }
    }
}

The class is compiled once with groovyc into a cls directory, and each example is run with that directory on the class path (the groovy command does not add the current directory to the class path since 6.0, see Running Scripts & the groovy Command):

groovyc -d cls GreenhouseServer.groovy UnknownPlantException.groovy
groovy -cp cls script.groovy

UnknownPlantException is a small exception class that appears in the declarative-client section; it is compiled here for a reason explained there. In the examples gh.base is the server’s base URI, such as http://localhost: followed by the port. The port changes on every run, so the examples never print it.

Basic usage

HttpBuilder.http takes a configuration closure (or just a base URI string). Inside the closure, baseUri and header configure settings shared by every request. A request method (get here) takes the path and an optional closure that configures that single request, for example with query.

import groovy.http.HttpBuilder

GreenhouseServer.run { gh ->
    def http = HttpBuilder.http {
        baseUri "${gh.base}/"
        header 'User-Agent', 'greenhouse-monitor/1.0'
    }
    def res = http.get('/api/readings') {
        query zone: 'bench', 'min temp': 20
    }

    assert res.status == 200
    println res.json.items*.sensor
    println res.json.rawQuery               // what the server saw on the wire
    println res.headers.firstValue('content-type').get()
    println res.getClass().name
    println res.body().take(40)
}
[T1, T2]
zone=bench&min%20temp=20
application/json
groovy.http.HttpResult
{"zone":"bench","items":[{"sensor":"T1",

query encodes keys and values as URI query components with RFC 3986 percent-encoding, so the space in min temp reaches the server as %20 (not +, which is what form encoding uses, see below). The result is an HttpResult, a Groovy record with status, body and headers (a java.net.http.HttpHeaders), plus the helper properties json, xml, html and parsed described in the next sections. raw() returns the underlying java.net.http.HttpResponse. Note that a non-2xx status is not an exception: the caller decides what to do with it (see Error handling and status codes).

The JDK client without the DSL

For comparison, this is the same request written directly against the JDK. The DSL saves the URI building, the encoding of query components and the header plumbing; the JDK client remains the engine in both cases.

import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse

GreenhouseServer.run { gh ->
    def target = URI.create("${gh.base}/api/readings?zone=bench&min%20temp=20")
    def client = HttpClient.newHttpClient()
    def request = HttpRequest.newBuilder(target)
            .header('User-Agent', 'greenhouse-monitor/1.0')
            .GET()
            .build()
    def response = client.send(request, HttpResponse.BodyHandlers.ofString())

    assert response.statusCode() == 200
    println response.body().take(40)
}
{"zone":"bench","items":[{"sensor":"T1",

JSON

The json(...) helper in a request closure serialises an object as JSON and sets Content-Type: application/json. On the way back, res.json lazily parses the body with JsonSlurper, and res.parsed picks the parser from the response’s Content-Type, so for application/json it is the same object.

import groovy.http.HttpBuilder

GreenhouseServer.run { gh ->
    def http = HttpBuilder.http(gh.base)

    def posted = http.post('/api/readings') {
        json([sensor: 'T1', temperature: 21.5])
    }
    assert posted.status == 201
    println posted.json.stored
    println posted.json.contentType

    def got = http.get('/api/readings')
    assert got.json.items[0].sensor == 'T1'
    assert got.parsed.items[1].temperature == 22.0   // same object, chosen by Content-Type
    println got.json.getClass().name
}
[sensor:T1, temperature:21.5]
application/json
org.apache.groovy.json.internal.LazyMap

XML

res.xml parses the body with XmlSlurper and returns a GPathResult, so the usual GPath navigation applies (see XML Parsing, GPath & Security).

import groovy.http.HttpBuilder

GreenhouseServer.run { gh ->
    def http = HttpBuilder.http(gh.base)
    def catalogue = http.get('/api/catalogue.xml')

    println catalogue.xml.seed*.name*.text()
    println catalogue.parsed.seed.find { it.@id == 's2' }.name.text()
    println catalogue.xml.getClass().name
}
[Tomato, Basil]
Basil
groovy.xml.slurpersupport.NodeChild

HTML with jsoup

HTML parsing is optional: when jsoup is on the class path, res.html returns an org.jsoup.nodes.Document that can be queried with CSS selectors. For a standalone script, add the library with @Grab (this one downloads from Maven Central the first time it runs).

@Grab('org.jsoup:jsoup:1.21.2')
import groovy.http.HttpBuilder

GreenhouseServer.run { gh ->
    def page = HttpBuilder.http(gh.base).get('/greenhouse')

    println page.html.getClass().name
    println page.html.select('h1').text()
    println page.parsed.select('p.zone').text()
}
org.jsoup.nodes.Document
Greenhouse status
Bench: ok

Without jsoup, res.parsed falls back to the raw body as a String, and asking for res.html throws an IllegalStateException that names the missing library. HttpBuilder also writes a warning to standard error (a line starting with HttpResult unable to parse HTML, not shown in the output below, which is standard output only).

import groovy.http.HttpBuilder

GreenhouseServer.run { gh ->
    def page = HttpBuilder.http(gh.base).get('/greenhouse')

    println page.parsed.getClass().name       // falls back to the raw body
    try {
        page.html
    } catch (IllegalStateException e) {
        println e.message
    }
}
java.lang.String
HTML parsing requires jsoup on the classpath

Forms, plain text and content-type auto-parsing

The form(...) helper sends an application/x-www-form-urlencoded body and sets the content type, unless you set it yourself with header. It uses form semantics, so a space becomes +, unlike query. text(...), bytes(...) and body(...) send other payloads.

import groovy.http.HttpBuilder

GreenhouseServer.run { gh ->
    def http = HttpBuilder.http(gh.base)

    def watered = http.post('/api/water') {
        form(zone: 'bench top', minutes: 5)
    }
    println watered.json.rawForm
    println watered.json.contentType

    def note = http.get('/plain')
    println "${note.parsed.getClass().simpleName}: ${note.parsed}"

    def missing = http.get('/missing')              // 4xx is data, not an exception
    println "${missing.status} ${missing.parsed.error}"
}
zone=bench+top&minutes=5
application/x-www-form-urlencoded
String: just text
404 no such zone

res.parsed dispatches on the response’s Content-Type:

Content-Type parsed returns

application/json and application/*+json

a JSON object (JsonSlurper)

application/xml, text/xml and application/*+xml

an XML GPathResult (XmlSlurper)

text/html

a jsoup Document if jsoup is on the class path, otherwise the raw String

anything else

the raw String body

Configuring the client

HttpBuilder.http { ... } accepts, besides baseUri and header/headers, the timeouts connectTimeout and requestTimeout (both java.time.Duration values), followRedirects and confineToBaseUri (both below), and clientConfig. The clientConfig closure receives the JDK HttpClient.Builder before build() is called, so anything the JDK supports (an authenticator, an SSLContext, a proxy selector, a cookie handler, the protocol version) is available; the one exception is the redirect policy, which followRedirects owns and which overrides a policy set on the raw builder. A request closure can add or override headers and set a per-request timeout. Headers from the client apply to every request unless the request sets the same name.

import groovy.http.HttpBuilder
import java.net.http.HttpClient
import java.time.Duration

GreenhouseServer.run { gh ->
    def http = HttpBuilder.http {
        baseUri gh.base
        connectTimeout Duration.ofSeconds(2)
        requestTimeout Duration.ofSeconds(5)
        headers 'X-Zone': 'bench', 'Accept': 'application/json'
        clientConfig { HttpClient.Builder builder ->
            builder.version(HttpClient.Version.HTTP_1_1)
        }
    }

    println http.get('/echo').json.zone                          // builder default header
    println http.get('/echo') { header 'X-Zone', 'basket' }.json.zone   // per-request header
    try {
        http.get('/slow?zone=bed') { timeout Duration.ofMillis(50) }
    } catch (RuntimeException e) {
        println "${e.getClass().simpleName} caused by ${e.cause.getClass().simpleName}"
    }
}
bench
basket
RuntimeException caused by HttpTimeoutException

A request that exceeds its timeout fails with a RuntimeException whose cause is the JDK’s HttpTimeoutException.

Error handling and status codes

The imperative DSL returns every response, including 4xx and 5xx, so the code checks status itself (see the /missing request in the form example, which answered 404 with a JSON body). Only transport problems (timeouts, refused connections) and the SecurityException from confineToBaseUri become exceptions. The declarative clients behave differently, see Errors in declarative clients.

Redirects and header confinement

By default redirects are not followed: a 3xx response is returned and the caller can read its Location header. With followRedirects true at most 5 hops are followed, using the same method rewriting as the JDK’s HttpClient.Redirect.NORMAL: 301 and 302 turn a POST into a GET without the body, 303 turns everything except HEAD into a GET, and 307 and 308 keep the method and the body. A hop from an https URL to a plain http URL is never followed.

The security-relevant rule is about headers. Because HttpBuilder cannot know which header carries a credential (Authorization, Cookie, X-Api-Key, any custom name), it names none: headers configured on the builder or on a request belong to the origin (scheme, host and port) of the first request. A hop that stays on the origin keeps every header; a hop that leaves it sheds all caller-supplied headers for the rest of the chain, and coming back to the original origin does not restore them. The one exception is Content-Type on a 307 or 308 hop, which travels with the body it describes.

flowchart TD R["response is 3xx and followRedirects is true"] --> H{"hop stays on the same origin?"} H -->|"yes"| K["keep all caller-supplied headers"] H -->|"no"| S["drop all caller-supplied headers for the rest of the chain"] K --> M["rewrite method per status: 301/302 POST to GET, 303 to GET, 307/308 unchanged"] S --> M M --> L{"more than 5 hops, or https to http?"} L -->|"yes"| X["stop and return the 3xx"] L -->|"no"| N["send the next request"]

The next example uses two servers. They share a host name but listen on different ports, which makes them two origins. The client sends X-Zone: bench. The echo endpoint reports the header it received.

import groovy.http.HttpBuilder

GreenhouseServer.run { gh ->
    // A second server stands in for "another origin" (same host, different port).
    GreenhouseServer.run { other ->
        def plain = HttpBuilder.http(gh.base)
        def first = plain.get('/old')
        println "${first.status} -> ${first.headers.firstValue('location').get()}"   // not followed

        def http = HttpBuilder.http {
            baseUri gh.base
            followRedirects true
            header 'X-Zone', 'bench'
        }
        println http.get('/old').json.items.size()            // the 302 was followed to /api/readings
        println http.get('/jump?to=/echo').json.zone          // same origin: header kept

        def hop = http.get("/jump?to=${other.base}/echo")     // another origin: headers shed
        println "${hop.status} zone=${hop.json.zone} onOtherServer=${hop.json.host == other.base - 'http://'}"

        def after = http.post('/after-post') { text 'readings' }
        println "${after.json.method} body='${after.json.body}'"   // 303 turns POST into GET
    }
}
302 -> /api/readings
2
bench
200 zone=null onOtherServer=true
GET body=''

The header survives the same-origin redirect and is gone on the other server (zone=null, answered by the second server), and a POST that is answered with 303 arrives as a GET with no body. Which setting to use for which situation:

Scenario Configure

A same-origin API that sometimes redirects

followRedirects true: headers survive every same-origin hop

An API that redirects downloads to a CDN or pre-signed URL

followRedirects true: credentials are shed on the cross-origin hop, which such targets expect

A service that redirects to a sibling host that must receive your credentials

Send the request to the final host directly, or leave followRedirects off, read Location and re-issue the request yourself: an explicit decision to send credentials to the second origin

An http to https upgrade by redirect

Use the https URL as baseUri so no upgrade hop is needed

Requests and hops must never leave the API

confineToBaseUri true

A response streamed behind a redirect

Resolve the Location yourself and stream from the target (streaming requests never follow redirects)

Confining requests to the base URI

When request paths are built from untrusted input, confineToBaseUri true makes the client reject any request, and any redirect hop, that resolves outside the baseUri namespace, whether to another origin or above the base path (.. segments or an absolute path). A violation throws SecurityException before anything is sent. It requires baseUri.

import groovy.http.HttpBuilder

GreenhouseServer.run { gh ->
    GreenhouseServer.run { other ->
        def http = HttpBuilder.http {
            baseUri "${gh.base}/api/"
            confineToBaseUri true
            followRedirects true
        }
        println http.get('readings').status                    // inside /api/

        def attempts = [
            'parent path'      : '../echo',
            'absolute path'    : '/echo',
            'another origin'   : "${other.base}/echo",
            'redirect outside' : '/jump?to=/echo',
        ]
        attempts.each { label, target ->
            try {
                http.get(target)
                println "${label}: allowed"
            } catch (SecurityException e) {
                println "${label}: SecurityException"
            }
        }
    }
}
200
parent path: SecurityException
absolute path: SecurityException
another origin: SecurityException
redirect outside: SecurityException

The base is /api/, so the relative path readings is fine, while ../echo and /echo (an absolute path replaces the base path) are not, nor is another origin. The last entry shows that the redirect target is checked as well: /jump redirects to /echo, which is outside /api/, so the hop is refused.

Async requests

Every HTTP method has an Async variant (getAsync, postAsync, putAsync, deleteAsync, patchAsync and the generic requestAsync(method, uri, spec)) that returns a CompletableFuture<HttpResult>. They use the JDK’s HttpClient.sendAsync directly, so no extra threads are started by HttpBuilder, and the futures compose with the usual CompletableFuture methods. When the Groovy async module is on the class path they can also be used with await, see Async/Await.

import groovy.http.HttpBuilder

GreenhouseServer.run { gh ->
    def http = HttpBuilder.http(gh.base)

    long start = System.nanoTime()
    def futures = ['bench', 'basket', 'bed'].collect { zone ->
        http.getAsync("/slow?zone=${zone}")             // returns at once
    }
    def zones = futures*.get()*.json*.zone              // each call takes 300 ms
    long elapsedMs = (System.nanoTime() - start).intdiv(1_000_000)
    println zones
    println "ran concurrently: ${elapsedMs < 800}"

    def count = http.getAsync('/api/readings')
                    .thenApply { it.json.items.size() }
                    .get()
    println count

    def server500 = http.getAsync('/boom').get()        // a failure status is still a result
    println "${server500.status} ${server500.json.error}"

    def created = http.postAsync('/api/readings') { json([sensor: 'T3']) }.get()
    println created.status
}
[bench, basket, bed]
ran concurrently: true
2
500 sensor bus offline
201

The three /slow calls take 300 ms each but finish together, which the timing check confirms without printing a machine-dependent number. As with the blocking calls, a 500 answer is a normal HttpResult. For large bodies, streamAsync, getStreamAsync and postStreamAsync return an HttpStreamResult that exposes the body as a Flow.Publisher; they are not covered further here.

Declarative clients with @HttpBuilderClient

For an API with several endpoints, describe it as an interface and let the compiler write the client. Annotate the interface with @HttpBuilderClient and each method with @Get, @Post, @Put, @Delete or @Patch. The AST transformation generates an implementation that uses HttpBuilder, and adds three factory methods to the interface: create() (the annotation’s URL), create(String baseUrl) (override the URL at run time) and create(Closure config) (full control over the underlying HttpBuilder, for runtime values and clientConfig).

import groovy.http.*

@HttpBuilderClient('http://localhost:8080')          // placeholder, replaced by create(String)
@Header(name = 'Accept', value = 'application/json')
interface GreenhouseApi {
    @Get('/api/readings')
    Map readings(String zone, @Query('min') int minimum)

    @Get('/api/readings')
    HttpResult rawReadings()

    @Post('/api/readings')
    Map record(@Body Map reading)

    @Get('/plain')
    String plain()

    @Get('/echo')
    @Header(name = 'X-Zone', value = 'bench')
    Map echoBench()

    @Get('/echo')
    Map echo()

    @Delete('/echo')
    void remove()
}

GreenhouseServer.run { gh ->
    def api = GreenhouseApi.create(gh.base)

    println api.readings('bench', 20).rawQuery           // zone=bench&min=20
    println api.rawReadings().status
    println api.record([sensor: 'T1', temperature: 21.5]).stored
    println api.plain()
    println api.echoBench().zone
    api.remove()                                          // response discarded

    def scoped = GreenhouseApi.create {
        baseUri gh.base
        header 'X-Zone', 'basket'
    }
    println scoped.echo().zone
}
zone=bench&min=20
200
[sensor:T1, temperature:21.5]
just text
bench
basket

The annotation requires a base URL even when you intend to override it, which is why the interface above carries a placeholder that create(gh.base) replaces. @Header on the interface applies to every method and @Header on a method adds to it. In the last call, a header configured through create { ... } reaches the server because the method does not set that header itself.

Parameter mapping

Parameters are mapped by convention, with no annotation for the common case:

Condition Mapping

The name matches a {placeholder} in the URL

path variable, URL-encoded into the URL

Annotated with @Body

the request body, serialised as JSON

Anything else

a query parameter, using the parameter name as the key

@Query('name') is needed only when the query key differs from the parameter name, as with @Query('min') int minimum above. A method-level @Timeout(seconds) overrides the default request timeout, and the annotation itself takes connectTimeout, requestTimeout (both in seconds, 0 meaning none) and followRedirects, with the header confinement described earlier.

Return types

The return type decides how the answer is processed:

Return type Behaviour

Map or List

the response parsed as JSON

a class such as Plant

JSON parsed, then coerced to the class with as

GPathResult

XML parsed with XmlSlurper

a jsoup Document

HTML (needs jsoup)

String

the raw body

HttpResult

the full response (status, headers, body)

void

the response is discarded

CompletableFuture<T>

asynchronous; T decides the parsing

Request bodies are JSON with @Body, plain text with @BodyText, and form fields when the method carries @Form (every non-path parameter becomes a field).

import groovy.http.*
import groovy.xml.slurpersupport.GPathResult
import java.util.concurrent.CompletableFuture

class Plant {
    String name
    String bio
}

@HttpBuilderClient(value = 'http://localhost:8080', connectTimeout = 2, requestTimeout = 5, followRedirects = true)
interface PlantApi {
    @Get('/api/plants/{name}')
    Plant find(String name)                         // {name} is a path variable

    @Get('/api/plants/{name}')
    Plant findOrFail(String name) throws UnknownPlantException

    @Get('/api/plants/{name}')
    @Timeout(1)
    HttpResult details(String name)

    @Put('/api/plants/{name}')
    Map replace(String name, @Body Map plant)

    @Patch('/api/plants/{name}')
    Map patch(String name, @Body Map changes)

    @Post('/api/water')
    @Form
    Map water(String zone, int minutes)

    @Post('/echo')
    Map note(@BodyText String content)

    @Get('/api/catalogue.xml')
    GPathResult catalogue()

    @Get('/slow')
    CompletableFuture<Map> slow(String zone)
}

GreenhouseServer.run { gh ->
    def api = PlantApi.create(gh.base)

    def tomato = api.find('red tomato')              // the space is URL-encoded
    println "${tomato.getClass().simpleName}: ${tomato.name} / ${tomato.bio}"
    println api.details('mint').status
    println api.replace('mint', [bio: 'x']).method
    println api.patch('mint', [bio: 'y']).method
    println api.water('bench', 5).rawForm
    println api.note('watered by hand').body
    println api.catalogue().seed.name*.text()
    println api.slow('bed').get()

    try {
        api.findOrFail('cactus')
    } catch (UnknownPlantException e) {
        println "${e.getClass().name}: ${e.message}"
    }
    try {
        api.find('cactus')
    } catch (RuntimeException e) {
        println "${e.getClass().name}: ${e.message}"
    }
}
Plant: red tomato / red tomato grows well on the bench
200
PUT
PATCH
zone=bench&minutes=5
watered by hand
[Tomato, Basil]
[zone:bed, delayed:true]
UnknownPlantException: status 404: {"error":"unknown plant"}
java.lang.RuntimeException: HTTP 404: {"error":"unknown plant"}

Errors in declarative clients

Unlike the imperative DSL, a declarative method throws for a 4xx or 5xx response: a RuntimeException whose message starts with HTTP and the status. To get a domain exception, name it in the method’s throws clause. The generated client builds it with the first matching constructor out of (int status, String body), (String message) and the no-argument one.

class UnknownPlantException extends RuntimeException {
    final int status

    UnknownPlantException(int status, String body) {
        super("status ${status}: ${body}")
        this.status = status
    }
}

The exception class is looked up by name at run time, so it has to be on the class path as a compiled class. With Groovy 6.0.0, when the class was declared in the same script as the interface the call still threw the generic RuntimeException; once UnknownPlantException was compiled with groovyc and cls was on the class path, the typed exception was thrown (the last two lines of the output above). Declare exception types in their own compiled class (or jar) rather than inline in the script that uses them.

Coercion to a class is strict. A JSON property that the class does not have makes the coercion fail instead of being ignored:

import groovy.http.*
import org.codehaus.groovy.runtime.typehandling.GroovyCastException

class Plant {
    String name
    String bio
}

@HttpBuilderClient('http://localhost:8080')
interface StrictApi {
    @Put('/api/plants/{name}')
    Plant replace(String name, @Body Map plant)     // the server adds a "method" field to PUT replies
}

GreenhouseServer.run { gh ->
    try {
        StrictApi.create(gh.base).replace('mint', [bio: 'x'])
    } catch (GroovyCastException e) {
        println e.message.take(150)
    }
}
Cannot cast object '{name=mint, bio=mint grows well on the bench, method=PUT}' with class 'org.apache.groovy.json.internal.LazyMap' to class 'Plant' d

Either model every property the server sends, return a Map, or have the interface return an HttpResult and map the fields yourself.

Where the book is outdated

The Manning book Groovy in Action (first edition, Groovy 1.0, 2007) predates this module by nearly twenty years. Its web chapters reach HTTP through java.net.URL (for example url.text), through the Apache HTTP client libraries and through RPC protocols such as XML-RPC and SOAP, and none of that is how Groovy 6 recommends doing it. Check these points against the current documentation:

  • groovy-http-builder is new in 6.0 and incubating; the earlier third-party HttpBuilder and HttpBuilder-NG libraries are not part of Groovy, and this module is not a drop-in for them.

  • URL.text and URL.getText() still work for a quick GET, but offer no request configuration, no redirect policy and no status handling; HttpBuilder gives those without a dependency.

  • The XML-RPC and SOAP material in the book describes obsolete technology and is not covered here.

  • The JDK java.net.http.HttpClient (JDK 11 and later) is the engine; it did not exist in 2007.

Common mistakes

  • Treating a 404 from the imperative DSL as an exception: it is a normal HttpResult, so check status. The declarative clients are the opposite and throw.

  • Expecting redirects to be followed by default, or expecting Authorization-style headers to follow a cross-origin redirect. Neither happens (see Redirects and header confinement).

  • Calling res.html without jsoup on the class path: it throws IllegalStateException, while res.parsed silently gives the raw string.

  • Leaving out the base URL on @HttpBuilderClient: the annotation requires one even if create(String) overrides it.

  • Declaring the exception class from a throws clause in the same script as the interface, or returning a class that lacks a property the JSON contains.

  • Building request paths from user input without confineToBaseUri true.