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

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.

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 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)
    }
}

11.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)

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

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)
}

11.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

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