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.22.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.
10. 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')
11. 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)
}
}
11.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)
11.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)
11.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.
11.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
Default 0 means no timeout.
11.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)
}
11.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
11.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.
11.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()