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 withHttpBuilder.http { ... }, callget,post,put,delete,patch(or the genericrequest(method, uri)), and read theHttpResult. -
@HttpBuilderClient, a declarative style: annotate an interface, and an AST transformation generates the implementation that callsHttpBuilderunderneath.
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 |
|---|---|
|
a JSON object ( |
|
an XML |
|
a jsoup |
anything else |
the raw |
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.
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 |
|
An API that redirects downloads to a CDN or pre-signed URL |
|
A service that redirects to a sibling host that must receive your credentials |
Send the request to the final host directly, or leave |
An |
Use the |
Requests and hops must never leave the API |
|
A response streamed behind a redirect |
Resolve the |
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 |
path variable, URL-encoded into the URL |
Annotated with |
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 |
|---|---|
|
the response parsed as JSON |
a class such as |
JSON parsed, then coerced to the class with |
|
XML parsed with |
a jsoup |
HTML (needs jsoup) |
|
the raw body |
|
the full response (status, headers, body) |
|
the response is discarded |
|
asynchronous; |
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-builderis 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.textandURL.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
404from the imperative DSL as an exception: it is a normalHttpResult, so checkstatus. 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.htmlwithout jsoup on the class path: it throwsIllegalStateException, whileres.parsedsilently gives the raw string. -
Leaving out the base URL on
@HttpBuilderClient: the annotation requires one even ifcreate(String)overrides it. -
Declaring the exception class from a
throwsclause 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.