# HttpBuilder – lightweight HTTP client DSL (incubating)

version 6.0.0-RC-1

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