//////////////////////////////////////////

  Licensed to the Apache Software Foundation (ASF) under one
  or more contributor license agreements.  See the NOTICE file
  distributed with this work for additional information
  regarding copyright ownership.  The ASF licenses this file
  to you under the Apache License, Version 2.0 (the
  "License"); you may not use this file except in compliance
  with the License.  You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

  Unless required by applicable law or agreed to in writing,
  software distributed under the License is distributed on an
  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
  KIND, either express or implied.  See the License for the
  specific language governing permissions and limitations
  under the License.

//////////////////////////////////////////

= HttpBuilder – lightweight HTTP client DSL (incubating)

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.

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

== Basic Usage

Create a client with `HttpBuilder.http`, configure shared settings in the
closure, and issue requests:

[source,groovy]
----
include::../test/HttpBuilderSpecTest.groovy[tags=basic_get_with_query,indent=0]
----

`query(...)` encodes keys and values as URI query components using RFC 3986
style percent-encoding — for example, spaces become `%20`.

=== Non-DSL Equivalent (JDK HttpClient)

The snippet above is equivalent to the following plain JDK code:

[source,groovy]
----
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()
----

== JSON

=== GET

[source,groovy]
----
include::../test/HttpBuilderSpecTest.groovy[tags=json_get,indent=0]
----

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

=== POST

[source,groovy]
----
include::../test/HttpBuilderSpecTest.groovy[tags=json_post,indent=0]
----

The `json(...)` helper serializes the supplied object as JSON and sets
`Content-Type: application/json` automatically.

== XML

[source,groovy]
----
include::../test/HttpBuilderSpecTest.groovy[tags=xml_get,indent=0]
----

`result.xml` parses the response body with `XmlSlurper`.
`result.parsed` dispatches to `xml` for XML content types.

== HTML (jsoup)

If https://jsoup.org[jsoup] is on the classpath, `result.html` returns a
jsoup `Document`:

[source,groovy]
----
include::../test/HttpBuilderSpecTest.groovy[tags=html_jsoup,indent=0]
----

`result.parsed` dispatches to jsoup for `text/html` content types when jsoup
is available, otherwise it falls back to the raw string body.

== Form URL-Encoding

The `form(...)` helper sends `application/x-www-form-urlencoded` POST bodies:

[source,groovy]
----
include::../test/HttpBuilderSpecTest.groovy[tags=form_post,indent=0]
----

`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 `+`.

== HTML Login Example

Combining `form(...)` with HTML parsing enables simple login flows:

[source,groovy]
----
include::../test/HttpBuilderSpecTest.groovy[tags=html_login,indent=0]
----

== Content-Type Auto-Parsing

`result.parsed` dispatches by the response `Content-Type`:

[cols="1,1"]
|===
| 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
|===

== Advanced Client Configuration

The `clientConfig` hook gives direct access to the JDK `HttpClient.Builder`
for advanced configuration — authenticator, SSL context, proxy, cookie handler:

[source,groovy]
----
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,Redirects>>), so a policy set on the raw builder is overridden.

[[redirects]]
== 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`:

[source,groovy]
----
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.

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

=== Which settings for which scenario

[cols="1,1"]
|===
| 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
|===

=== 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:

[source,groovy]
----
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
----

== Async Requests

Every HTTP method has an async variant that returns a `CompletableFuture<HttpResult>`
using the JDK `HttpClient.sendAsync()` natively (no extra threads):

[source,groovy]
----
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:

[source,groovy]
----
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:

[source,groovy]
----
def result = await http.getAsync('/data')
----

== 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:

[source,groovy]
----
@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)

[source,groovy]
----
// 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)
    }
}
----

=== Parameter Mapping

Method parameters are mapped automatically by convention — no annotation
is needed for the common case:

[cols="1,2"]
|===
| 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:

[source,groovy]
----
@Get('/search')
List search(String q)                    // ?q=...  (implied)

@Get('/search')
List search(@Query('q') String query)    // ?q=...  (explicit, different name)
----

=== HTTP Methods

All standard HTTP methods are supported via annotations:
`@Get`, `@Post`, `@Put`, `@Delete`, `@Patch`.

[source,groovy]
----
@Patch('/items/{id}')
Map patchItem(String id, @Body Map updates)
----

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

=== Timeouts and Redirects

Connection timeout, default request timeout, and redirect following can be
configured on the `@HttpBuilderClient` annotation:

[source,groovy]
----
@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,Redirects>>

Default `0` means no timeout.

=== Return Types

The return type of each method determines how the response is processed:

[cols="1,2"]
|===
| 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:

[source,groovy]
----
class User {
    String name
    String bio
}

@HttpBuilderClient('https://api.example.com')
interface UserApi {
    @Get('/users/{id}')
    User getUser(String id)
}
----

=== Request Bodies

By default, `@Body` parameters are serialized as JSON. Additional body
modes are available:

[cols="1,2"]
|===
| 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`)

|===

[source,groovy]
----
@Post('/login')
@Form
Map login(String username, String password)    // form-encoded POST

@Post('/notes')
Map createNote(@BodyText String content)       // plain text body
----

=== 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:

[source,groovy]
----
@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.

=== Async

Methods returning `CompletableFuture` execute asynchronously:

[source,groovy]
----
@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()
----

