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

application/json, application/\*+json

JSON object (JsonSlurper)

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

XML object (XmlSlurper)

text/html

jsoup Document (if jsoup is on the classpath, otherwise raw string)

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-Type on a 307/308 hop, which forwards the request body: the header describing that body travels with it, as in browsers.

  • A hop from an https URL to a plain http URL is never followed (matching HttpClient.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 (/old/new)

followRedirects true — headers survive every same-origin hop

API that redirects downloads to a CDN or pre-signed URL (e.g. an API host redirecting to cloud storage)

followRedirects true — credentials are shed on the cross-origin hop, which such targets expect (pre-signed URLs typically reject requests that also carry an Authorization header)

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 followRedirects off, read Location from the 3xx, and re-issue the request yourself — an explicit decision to send credentials to the second origin

Server upgrades http to https with a redirect

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

Requests (and redirect hops) must never leave the API at all

confineToBaseUri true — see below

Streaming a response served behind a redirect

Resolve the Location of the 3xx yourself and stream from the target

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 underlying HttpBuilder for runtime values (e.g. auth tokens) or anything not covered by the annotations, including clientConfig for 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 {placeholder} in URL

Path variable — substituted into the URL (URL-encoded)

Annotated with @Body

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

Map or List

Response parsed as JSON

Typed class (e.g. User)

Response parsed as JSON, then coerced to the target type

GPathResult

Response parsed as XML (via XmlSlurper)

jsoup Document

Response parsed as HTML (requires jsoup on classpath)

String

Raw response body

HttpResult

Full response (status, headers, body)

void

Response discarded

CompletableFuture<T>

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

@Body

JSON body (default)

@BodyText

Plain text body (sent as-is)

@Form (on method)

All non-path parameters become form-encoded fields (application/x-www-form-urlencoded)

@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()