The groovy-http-builder module provides a tiny declarative DSL over
the JDK java.net.http.HttpClient.
It is designed for scripting and simple automation tasks where a full-blown
HTTP library would be overkill.
1. Goals
-
Keep the implementation small and easy to maintain.
-
Use only JDK HTTP client primitives (Jsoup is optionally supported for HTML parsing).
-
Make common request setup declarative with Groovy closures.
-
Handle only the simple cases that often pop up in scripting — not the full use cases that Apache Geb covers.
-
Include JSON/XML/HTML response parsing hooks while intentionally keeping request hooks minimal.
2. Basic Usage
Create a client with HttpBuilder.http, configure shared settings in the
closure, and issue requests:
import groovy.http.HttpBuilder
def http = HttpBuilder.http {
baseUri '${rootUri}/'
header 'User-Agent', 'my-app/1.0'
}
def res = http.get('/api/items') {
query page: 1, size: 10
}
assert res.status == 200
query(…) encodes keys and values as URI query components using RFC 3986
style percent-encoding — for example, spaces become %20.
2.1. Non-DSL Equivalent (JDK HttpClient)
The snippet above is equivalent to the following plain JDK code:
import java.net.URI
import java.net.URLEncoder
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.nio.charset.StandardCharsets
def encodeQueryComponent = { Object value ->
URLEncoder.encode(value.toString(), StandardCharsets.UTF_8)
.replace('+', '%20')
.replace('*', '%2A')
.replace('%7E', '~')
}
def baseUri = 'https://example.com/'
def query = [page: 1, size: 10]
.collect { k, v ->
"${encodeQueryComponent(k)}=${encodeQueryComponent(v)}"
}
.join('&')
def target = URI.create(baseUri).resolve("/api/items?${query}")
def client = HttpClient.newHttpClient()
def request = HttpRequest.newBuilder(target)
.header('User-Agent', 'my-app/1.0')
.GET()
.build()
def response = client.send(request, HttpResponse.BodyHandlers.ofString())
assert response.statusCode() == 200
println response.body()
3. JSON
3.1. GET
import static groovy.http.HttpBuilder.http
def client = http '${rootUri}'
def res = client.get('/api/items')
assert res.status == 200
assert res.json.items[0].name == 'book'
assert res.parsed.items[0].name == 'book' // auto-parsed from Content-Type
res.json lazily parses the response body as JSON.
res.parsed auto-dispatches by the response Content-Type header, so for
application/json it behaves identically to res.json.
3.2. POST
def result = http.post('/api/items') {
json([name: 'book', qty: 2])
}
assert result.status == 200
assert result.json.ok
The json(…) helper serializes the supplied object as JSON and sets
Content-Type: application/json automatically.
4. XML
def result = http.get('/api/repo.xml')
assert result.status == 200
assert result.xml.license.text() == 'Apache License 2.0'
assert result.parsed.license.text() == 'Apache License 2.0' // auto-parsed from Content-Type
result.xml parses the response body with XmlSlurper.
result.parsed dispatches to xml for XML content types.
5. HTML (jsoup)
If jsoup is on the classpath, result.html returns a
jsoup Document:
// @Grab('org.jsoup:jsoup:1.23.1') // needed if running as standalone script
def client = http('${mvnrepositoryUri}')
def res = client.get('/artifact/org.apache.groovy/groovy-all') {
header 'User-Agent', 'Mozilla/5.0 (Macintosh)'
}
assert res.status == 200
def license = res.parsed.select('span.badge.badge-license')*.text().join(', ')
assert license == 'Apache 2.0'
result.parsed dispatches to jsoup for text/html content types when jsoup
is available, otherwise it falls back to the raw string body.
6. Form URL-Encoding
The form(…) helper sends application/x-www-form-urlencoded POST bodies:
def result = http.post('/login') {
form(username: 'admin', password: 'p@ssw0rd')
}
assert result.status == 200
form(…) encodes values as application/x-www-form-urlencoded and sets
Content-Type automatically (unless you override it with header).
Unlike query(…), form(…) uses form semantics, so spaces become +.
7. HTML Login Example
Combining form(…) with HTML parsing enables simple login flows:
def app = http {
baseUri '${rootUri}'
followRedirects true
header 'User-Agent', 'Mozilla/5.0 (Macintosh)'
}
def loginPage = app.get('/login')
assert loginPage.status == 200
assert loginPage.html.select('h1').text() == 'Please Login'
def afterLogin = app.post('/login') {
form(username: 'admin', password: 'p@ssw0rd')
}
assert afterLogin.status == 200
assert afterLogin.html.select('h1').text() == 'Admin Section'
8. Content-Type Auto-Parsing
result.parsed dispatches by the response Content-Type:
| Content-Type | Parsed as |
|---|---|
|
JSON object ( |
|
XML object ( |
|
jsoup |
anything else |
raw string body |
9. Advanced Client Configuration
The clientConfig hook gives direct access to the JDK HttpClient.Builder
for advanced configuration — authenticator, SSL context, proxy, cookie handler:
def http = HttpBuilder.http {
baseUri 'https://api.example.com'
header 'Authorization', "Bearer ${token}"
clientConfig { builder ->
builder.authenticator(myAuthenticator)
.sslContext(mySSLContext)
.proxy(ProxySelector.of(new InetSocketAddress('proxy.corp', 8080)))
}
}
The clientConfig closure receives the HttpClient.Builder before build()
is called, so any JDK-supported configuration is available — with one
exception: the redirect policy is owned by followRedirects (see
Redirects), so a policy set on the raw builder is overridden.
10. Redirects
Redirects are not followed by default: a 3xx response is returned to the
caller, which can inspect its Location header and decide what to do.
Opt in with followRedirects true:
def http = HttpBuilder.http {
baseUri 'https://api.example.com'
followRedirects true
header 'Authorization', "Bearer ${token}"
}
At most 5 hops are followed. Methods are rewritten the same way as the JDK’s
HttpClient.Redirect.NORMAL policy: 301 and 302 downgrade POST to
GET (dropping the body), 303 downgrades everything except HEAD to
GET, and 307/308 preserve the method and body.
10.1. Headers do not leave their origin
Headers configured on the builder or on an individual request are treated as
intended for one origin only: the scheme, host, and port of the first
request. Which of them hold credentials is not something HttpBuilder can
know — a token may sit in Authorization, Cookie, X-Api-Key, or any
custom header — so the policy does not name header names:
-
A hop that stays on the origin keeps every header.
-
A hop that leaves the origin sheds all caller-supplied headers, builder defaults and per-request headers alike, for the remainder of the chain. Returning to the original origin later does not restore them: once another server directs the chain, no hop gets the credentials back.
-
The one exception is
Content-Typeon a307/308hop, which forwards the request body: the header describing that body travels with it, as in browsers. -
A hop from an
httpsURL to a plainhttpURL is never followed (matchingHttpClient.Redirect.NORMAL); the 3xx is returned to the caller.
Note that the scheme is part of the origin, so a redirect from
http://api.example.com to https://api.example.com also sheds headers.
Streaming requests (streamAsync and friends) never follow redirects; a
3xx is always returned as-is.
10.2. Which settings for which scenario
| Scenario | What to configure |
|---|---|
Same-origin API that occasionally redirects ( |
|
API that redirects downloads to a CDN or pre-signed URL (e.g. an API host redirecting to cloud storage) |
|
Service that redirects to a sibling host which must receive your credentials (e.g. a regional endpoint on another hostname) |
Send the request to the final host directly; or leave |
Server upgrades |
Configure the |
Requests (and redirect hops) must never leave the API at all |
|
Streaming a response served behind a redirect |
Resolve the |
10.3. Confining requests to the base URI
When request URIs are built from untrusted input, confineToBaseUri true
rejects any request — and any redirect hop — that resolves outside the
baseUri namespace, whether to another origin or above the base path
(e.g. via .. segments or an absolute path). Violations throw
SecurityException instead of executing. It requires baseUri to be set:
def http = HttpBuilder.http {
baseUri 'https://api.example.com/v2/'
confineToBaseUri true
followRedirects true
}
http.get('items/42') // ok: https://api.example.com/v2/items/42
http.get('../admin') // SecurityException: escapes /v2/
http.get('https://evil.example') // SecurityException: different origin
11. Async Requests
Every HTTP method has an async variant that returns a CompletableFuture<HttpResult>
using the JDK HttpClient.sendAsync() natively (no extra threads):
def http = HttpBuilder.http('https://api.example.com')
def future = http.getAsync('/users/alice')
// ... do other work while the request is in flight ...
def result = future.get()
assert result.json.name == 'alice'
Available methods: getAsync, postAsync, putAsync, deleteAsync,
patchAsync, and the generic requestAsync(method, uri, spec).
These compose naturally with CompletableFuture methods:
http.getAsync('/data')
.thenApply { it.json }
.thenAccept { data -> println "Got: $data" }
If the Groovy async module is on the classpath, these futures are
automatically await-able:
def result = await http.getAsync('/data')
12. Declarative HTTP Clients
For APIs with multiple endpoints, you can define a typed interface and let
Groovy generate the implementation at compile time. Annotate an interface with
@HttpBuilderClient and each method with an HTTP method annotation:
@HttpBuilderClient('https://api.example.com')
@Header(name = 'Accept', value = 'application/json')
interface BookApi {
@Get('/books/{id}')
Map getBook(String id)
@Get('/books')
List searchBooks(@Query('q') String query)
@Post('/books')
Map createBook(@Body Map book)
@Delete('/books/{id}')
void deleteBook(String id)
}
def api = BookApi.create()
def book = api.getBook('978-0-321-12521-7')
The AST transform generates an implementation class that uses HttpBuilder
under the hood. Three create() factory methods are added to the interface:
-
create()— uses the annotation URL and default settings -
create(String baseUrl)— overrides the base URL at runtime -
create(Closure config)— full control over the underlyingHttpBuilderfor runtime values (e.g. auth tokens) or anything not covered by the annotations, includingclientConfigfor JDK-level settings (authenticator, SSL, proxy)
// Runtime auth token
def api = MyApi.create {
baseUri 'https://api.example.com'
header 'Authorization', "Bearer ${token}"
}
// Full JDK client customization
def api = MyApi.create {
baseUri 'https://internal.corp'
clientConfig { builder ->
builder.sslContext(mySSLContext)
}
}
12.1. Parameter Mapping
Method parameters are mapped automatically by convention — no annotation is needed for the common case:
| Condition | Mapping |
|---|---|
Name matches |
Path variable — substituted into the URL (URL-encoded) |
Annotated with |
Request body — serialized as JSON |
Everything else |
Query parameter — the parameter name is used as the query key |
The @Query annotation is only needed when the query parameter name differs
from the method parameter name:
@Get('/search')
List search(String q) // ?q=... (implied)
@Get('/search')
List search(@Query('q') String query) // ?q=... (explicit, different name)
12.2. HTTP Methods
All standard HTTP methods are supported via annotations:
@Get, @Post, @Put, @Delete, @Patch.
@Patch('/items/{id}')
Map patchItem(String id, @Body Map updates)
12.3. Headers
@Header annotations can be placed on the interface (applies to all methods)
or on individual methods. Method-level headers are merged with interface-level headers.
12.4. Timeouts and Redirects
Connection timeout, default request timeout, and redirect following can be
configured on the @HttpBuilderClient annotation:
@HttpBuilderClient(value = 'https://api.example.com',
connectTimeout = 5,
requestTimeout = 10,
followRedirects = true)
interface MyApi {
@Get('/users/{id}')
Map getUser(String id) // uses default 10s timeout
@Get('/reports/generate')
@Timeout(60)
Map generateReport() // overrides to 60s for this method
}
-
connectTimeout— how long to wait for the TCP connection (client-level, in seconds) -
requestTimeout— default request timeout applied to all methods (in seconds) -
@Timeout(value)— per-method override of the request timeout (in seconds) -
followRedirects— whether to follow HTTP redirects, with the origin-confined header semantics described in Redirects
Default 0 means no timeout.
12.5. Return Types
The return type of each method determines how the response is processed:
| Return type | Behaviour |
|---|---|
|
Response parsed as JSON |
Typed class (e.g. |
Response parsed as JSON, then coerced to the target type |
|
Response parsed as XML (via |
jsoup |
Response parsed as HTML (requires jsoup on classpath) |
|
Raw response body |
|
Full response (status, headers, body) |
|
Response discarded |
|
Asynchronous execution; inner type determines parsing |
For typed responses, the JSON is parsed and coerced to the target class
using Groovy’s as coercion:
class User {
String name
String bio
}
@HttpBuilderClient('https://api.example.com')
interface UserApi {
@Get('/users/{id}')
User getUser(String id)
}
12.6. Request Bodies
By default, @Body parameters are serialized as JSON. Additional body
modes are available:
| Annotation | Behaviour |
|---|---|
|
JSON body (default) |
|
Plain text body (sent as-is) |
|
All non-path parameters become form-encoded fields ( |
@Post('/login')
@Form
Map login(String username, String password) // form-encoded POST
@Post('/notes')
Map createNote(@BodyText String content) // plain text body
12.7. Error Handling
By default, HTTP 4xx/5xx responses throw a RuntimeException. You can
declare a specific exception type in the method’s throws clause, and
the generated client will throw that type instead:
@Get('/users/{id}')
Map getUser(String id) throws NotFoundException
The exception class is instantiated by trying constructors in order:
(int status, String body), then (String message), then no-arg.
12.8. Async
Methods returning CompletableFuture execute asynchronously:
@HttpBuilderClient('https://api.example.com')
interface AsyncApi {
@Get('/data/{id}')
CompletableFuture<Map> getData(String id)
}
def api = AsyncApi.create()
def future = api.getData('42')
// ... do other work ...
def data = future.get()