This is an automated email from the ASF dual-hosted git repository.

sunlan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git


The following commit(s) were added to refs/heads/master by this push:
     new dbf30a5c6f GroovyGROOVY-11879: A very simple DSL over the JDK's HTTP 
client (#2401)
dbf30a5c6f is described below

commit dbf30a5c6fed6f196f3f9baae0807293f935d785
Author: Paul King <[email protected]>
AuthorDate: Sat Mar 28 04:56:04 2026 +1100

    GroovyGROOVY-11879: A very simple DSL over the JDK's HTTP client (#2401)
---
 settings.gradle                                    |   1 +
 subprojects/groovy-binary/src/spec/doc/index.adoc  |   2 +
 subprojects/groovy-http-builder/build.gradle       |  39 +++
 .../src/main/groovy/groovy/http/HttpBuilder.groovy | 312 ++++++++++++++++++++
 .../src/main/groovy/groovy/http/HttpResult.groovy  |  97 +++++++
 .../src/spec/doc/http-builder.adoc                 | 183 ++++++++++++
 .../src/spec/test/HttpBuilderSpecTest.groovy       | 240 ++++++++++++++++
 .../test/groovy/groovy/http/HttpBuilderTest.groovy | 320 +++++++++++++++++++++
 8 files changed, 1194 insertions(+)

diff --git a/settings.gradle b/settings.gradle
index a1fdc1fbf6..8621023316 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -66,6 +66,7 @@ def subprojects = [
         'groovy-jsr223',
         'groovy-logging-test',
         'groovy-ginq',
+        'groovy-http-builder',
         'groovy-macro',
         'groovy-macro-library',
         'groovy-nio',
diff --git a/subprojects/groovy-binary/src/spec/doc/index.adoc 
b/subprojects/groovy-binary/src/spec/doc/index.adoc
index 60c5381449..220f2454f8 100644
--- a/subprojects/groovy-binary/src/spec/doc/index.adoc
+++ b/subprojects/groovy-binary/src/spec/doc/index.adoc
@@ -118,6 +118,8 @@ 
include::../../../../../subprojects/groovy-typecheckers/src/spec/doc/typechecker
 
 
include::../../../../../subprojects/groovy-contracts/src/spec/doc/contracts-userguide.adoc[leveloffset=+2]
 
+include::../../../../../subprojects/groovy-http-builder/src/spec/doc/http-builder.adoc[leveloffset=+2]
+
 === Scripting Ant tasks
 
 Groovy integrates very well with http://ant.apache.org[Apache Ant] thanks to 
<<antbuilder,AntBuilder>>.
diff --git a/subprojects/groovy-http-builder/build.gradle 
b/subprojects/groovy-http-builder/build.gradle
new file mode 100644
index 0000000000..2839f8f5ee
--- /dev/null
+++ b/subprojects/groovy-http-builder/build.gradle
@@ -0,0 +1,39 @@
+/*
+ *  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.
+ */
+plugins {
+    id 'org.apache.groovy-library'
+}
+
+dependencies {
+    api rootProject
+    implementation projects.groovyJson
+    implementation projects.groovyXml
+    testRuntimeOnly "org.jsoup:jsoup:1.22.1"
+    testImplementation projects.groovyTest
+}
+
+groovyLibrary {
+    optionalModule()
+    withoutBinaryCompatibilityChecks()
+}
+
+
+
+
+
diff --git 
a/subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpBuilder.groovy
 
b/subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpBuilder.groovy
new file mode 100644
index 0000000000..8e8ba1e009
--- /dev/null
+++ 
b/subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpBuilder.groovy
@@ -0,0 +1,312 @@
+/*
+ *  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.
+ */
+package groovy.http
+
+import groovy.lang.DelegatesTo
+import groovy.lang.Closure
+import groovy.json.JsonOutput
+import org.apache.groovy.lang.annotation.Incubating
+
+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
+import java.time.Duration
+
+/**
+ * Tiny DSL over JDK {@link HttpClient}.
+ */
+@Incubating
+final class HttpBuilder {
+    private final HttpClient client
+    private final URI baseUri
+    private final Map<String, String> defaultHeaders
+    private final Duration defaultRequestTimeout
+
+    private HttpBuilder(final Config config) {
+        HttpClient.Builder clientBuilder = HttpClient.newBuilder()
+        if (config.connectTimeout != null) {
+            clientBuilder.connectTimeout(config.connectTimeout)
+        }
+        if (config.followRedirects) {
+            clientBuilder.followRedirects(HttpClient.Redirect.NORMAL)
+        }
+        client = clientBuilder.build()
+        baseUri = config.baseUri
+        defaultHeaders = Collections.unmodifiableMap(new 
LinkedHashMap<>(config.headers))
+        defaultRequestTimeout = config.requestTimeout
+    }
+
+    static HttpBuilder http(
+            @DelegatesTo(value = Config, strategy = Closure.DELEGATE_FIRST)
+            final Closure<?> spec
+    ) {
+        Config config = new Config()
+        Closure<?> code = (Closure<?>) spec.clone()
+        code.resolveStrategy = Closure.DELEGATE_FIRST
+        code.delegate = config
+        code.call()
+        return new HttpBuilder(config)
+    }
+
+    static HttpBuilder http(final String baseUri) {
+        Config config = new Config()
+        config.baseUri(baseUri)
+        return new HttpBuilder(config)
+    }
+
+    HttpResult get(final Object uri = null,
+                   @DelegatesTo(value = RequestSpec, strategy = 
Closure.DELEGATE_FIRST)
+                   final Closure<?> spec = null) {
+        return request('GET', uri, spec)
+    }
+
+    HttpResult post(final Object uri = null,
+                    @DelegatesTo(value = RequestSpec, strategy = 
Closure.DELEGATE_FIRST)
+                    final Closure<?> spec = null) {
+        return request('POST', uri, spec)
+    }
+
+    HttpResult put(final Object uri = null,
+                   @DelegatesTo(value = RequestSpec, strategy = 
Closure.DELEGATE_FIRST)
+                   final Closure<?> spec = null) {
+        return request('PUT', uri, spec)
+    }
+
+    HttpResult delete(final Object uri = null,
+                      @DelegatesTo(value = RequestSpec, strategy = 
Closure.DELEGATE_FIRST)
+                      final Closure<?> spec = null) {
+        return request('DELETE', uri, spec)
+    }
+
+    HttpResult request(final String method,
+                       final Object uri,
+                       @DelegatesTo(value = RequestSpec, strategy = 
Closure.DELEGATE_FIRST)
+                       final Closure<?> spec = null) {
+        RequestSpec requestSpec = new RequestSpec()
+        if (spec != null) {
+            Closure<?> code = (Closure<?>) spec.clone()
+            code.resolveStrategy = Closure.DELEGATE_FIRST
+            code.delegate = requestSpec
+            code.call()
+        }
+
+        URI resolvedUri = resolveUri(uri, requestSpec.queryParameters)
+        HttpRequest.Builder requestBuilder = 
HttpRequest.newBuilder(resolvedUri)
+
+        Duration timeout = requestSpec.timeout ?: defaultRequestTimeout
+        if (timeout != null) {
+            requestBuilder.timeout(timeout)
+        }
+
+        defaultHeaders.each { String name, String value ->
+            requestBuilder.header(name, value)
+        }
+        requestSpec.headers.each { String name, String value ->
+            requestBuilder.setHeader(name, value)
+        }
+
+        requestBuilder.method(method, bodyPublisher(method, requestSpec.body))
+
+        HttpResponse<String> response
+        try {
+            response = client.send(requestBuilder.build(), 
requestSpec.bodyHandler)
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt()
+            throw new RuntimeException("HTTP request " + method + " " + 
resolvedUri + " was interrupted", e)
+        } catch (IOException e) {
+            throw new RuntimeException("I/O error during HTTP request " + 
method + " " + resolvedUri, e)
+        }
+        return new HttpResult(response)
+    }
+
+    private URI resolveUri(final Object uri, final Map<String, Object> query) {
+        URI target = toUri(uri)
+        if (!target.isAbsolute()) {
+            if (baseUri == null) {
+                throw new IllegalArgumentException('Request URI must be 
absolute when no baseUri is configured')
+            }
+            target = baseUri.resolve(target.toString())
+        }
+        return appendQuery(target, query)
+    }
+
+    private static URI requireAbsoluteUriWithHost(final URI uri, final String 
name) {
+        if (uri == null || !uri.isAbsolute() || uri.host == null) {
+            throw new IllegalArgumentException(name + ' must be an absolute 
URI with scheme and host')
+        }
+        return uri
+    }
+
+    private URI toUri(final Object value) {
+        if (value == null) {
+            if (baseUri == null) {
+                throw new IllegalArgumentException('URI must be provided when 
no baseUri is configured')
+            }
+            return baseUri
+        }
+        if (value instanceof URI) {
+            return (URI) value
+        }
+        return URI.create(value.toString())
+    }
+
+    private static URI appendQuery(final URI uri, final Map<String, Object> 
queryValues) {
+        if (queryValues.isEmpty()) {
+            return uri
+        }
+
+        List<String> pairs = new ArrayList<>()
+        if (uri.query != null && !uri.query.isEmpty()) {
+            pairs.add(uri.query)
+        }
+
+        queryValues.each { String key, Object value ->
+            String encodedKey = encodeQueryComponent(key)
+            String encodedValue = value == null ? '' : 
encodeQueryComponent(value.toString())
+            pairs.add(encodedKey + '=' + encodedValue)
+        }
+
+        String query = pairs.join('&')
+        return new URI(uri.scheme, uri.authority, uri.path, query, 
uri.fragment)
+    }
+
+    private static String encodeQueryComponent(final String value) {
+        return URLEncoder.encode(value, StandardCharsets.UTF_8)
+                .replace('+', '%20')
+                .replace('*', '%2A')
+                .replace('%7E', '~')
+    }
+
+    private static HttpRequest.BodyPublisher bodyPublisher(final String 
method, final Object body) {
+        if (body == null) {
+            return HttpRequest.BodyPublishers.noBody()
+        }
+        if ('GET'.equalsIgnoreCase(method)) {
+            throw new IllegalArgumentException('GET requests do not support a 
body in this DSL')
+        }
+        if (body instanceof byte[]) {
+            return HttpRequest.BodyPublishers.ofByteArray((byte[]) body)
+        }
+        return HttpRequest.BodyPublishers.ofString(body.toString())
+    }
+
+    static final class Config {
+        URI baseUri
+        Duration connectTimeout
+        Duration requestTimeout
+        boolean followRedirects
+        final Map<String, String> headers = [:]
+
+        void baseUri(final Object value) {
+            URI candidate = value instanceof URI ? (URI) value : 
URI.create(value.toString())
+            baseUri = requireAbsoluteUriWithHost(candidate, 'baseUri')
+        }
+
+        void connectTimeout(final Duration value) {
+            connectTimeout = value
+        }
+
+        void requestTimeout(final Duration value) {
+            requestTimeout = value
+        }
+
+        void followRedirects(final boolean value) {
+            followRedirects = value
+        }
+
+        void header(final String name, final Object value) {
+            headers.put(name, String.valueOf(value))
+        }
+
+        void headers(final Map<String, ?> values) {
+            values.each { String name, Object value -> header(name, value) }
+        }
+    }
+
+    static final class RequestSpec {
+        Duration timeout
+        Object body
+        HttpResponse.BodyHandler<String> bodyHandler = 
HttpResponse.BodyHandlers.ofString()
+        final Map<String, String> headers = new LinkedHashMap<>()
+        final Map<String, Object> queryParameters = new LinkedHashMap<>()
+
+        void timeout(final Duration value) {
+            timeout = value
+        }
+
+        void header(final String name, final Object value) {
+            headers.put(name, String.valueOf(value))
+        }
+
+        void headers(final Map<String, ?> values) {
+            values.each { String name, Object value -> header(name, value) }
+        }
+
+        void query(final String name, final Object value) {
+            queryParameters.put(name, value)
+        }
+
+        void query(final Map<String, ?> values) {
+            values.each { String name, Object value -> query(name, value) }
+        }
+
+        void text(final Object value) {
+            body = value == null ? null : value.toString()
+        }
+
+        void bytes(final byte[] value) {
+            body = value
+        }
+
+        void body(final Object value) {
+            body = value
+        }
+
+        /**
+         * Encodes map entries as application/x-www-form-urlencoded and sets a 
default content type.
+         */
+        void form(final Map<String, ?> values) {
+            if (!headers.find{ it.key.equalsIgnoreCase('Content-Type') }) {
+                header('Content-Type', 'application/x-www-form-urlencoded')
+            }
+            body = values.collect { String name, Object value ->
+                String encodedName = URLEncoder.encode(name, 
StandardCharsets.UTF_8)
+                String encodedValue = value == null ? '' : 
URLEncoder.encode(value.toString(), StandardCharsets.UTF_8)
+                encodedName + '=' + encodedValue
+            }.join('&')
+        }
+
+        /**
+         * Serializes the given value as JSON and sets a default content type.
+         */
+        void json(final Object value) {
+            if (!headers.find{ it.key.equalsIgnoreCase('Content-Type') }) {
+                header('Content-Type', 'application/json')
+            }
+            body = JsonOutput.toJson(value)
+        }
+
+        void asString() {
+            bodyHandler = HttpResponse.BodyHandlers.ofString()
+        }
+    }
+}
diff --git 
a/subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpResult.groovy 
b/subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpResult.groovy
new file mode 100644
index 0000000000..a37a0baed3
--- /dev/null
+++ 
b/subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpResult.groovy
@@ -0,0 +1,97 @@
+/*
+ *  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.
+ */
+package groovy.http
+
+import groovy.json.JsonSlurper
+import groovy.xml.XmlSlurper
+import org.apache.groovy.lang.annotation.Incubating
+
+import java.net.http.HttpHeaders
+import java.net.http.HttpResponse
+import java.util.Locale
+
+/**
+ * Simple response wrapper for the {@link HttpBuilder} DSL.
+ */
+@Incubating
+record HttpResult(int status, String body, HttpHeaders headers, 
HttpResponse<String> raw) {
+
+    HttpResult(final HttpResponse<String> response) {
+        this(response.statusCode(), response.body(), response.headers(), 
response)
+    }
+
+    Object getJson() {
+        return new JsonSlurper().parseText(body)
+    }
+
+    Object getXml() {
+        return new XmlSlurper().parseText(body)
+    }
+
+    Object getHtml() {
+        try {
+            Class<?> jsoup = loadOptionalClass('org.jsoup.Jsoup')
+            if (jsoup == null) {
+                throw new ClassNotFoundException('org.jsoup.Jsoup')
+            }
+            return jsoup.getMethod('parse', String).invoke(null, body)
+        } catch (ClassNotFoundException e) {
+            throw new IllegalStateException("HTML parsing requires jsoup on 
the classpath", e)
+        } catch (ReflectiveOperationException e) {
+            throw new IllegalStateException("Unable to parse HTML via jsoup", 
e)
+        }
+    }
+
+    private static Class<?> loadOptionalClass(final String className) {
+        List<ClassLoader> classLoaders = [
+                Thread.currentThread().contextClassLoader,
+                HttpResult.class.classLoader,
+                ClassLoader.systemClassLoader
+        ].findAll { it != null }.unique()
+
+        for (ClassLoader classLoader : classLoaders) {
+            try {
+                return Class.forName(className, false, classLoader)
+            } catch (ClassNotFoundException ignore) {
+                // try next class loader
+            }
+        }
+        return null
+    }
+
+    Object getParsed() {
+        String contentType = headers.firstValue('Content-Type').orElse('')
+        String mediaType = contentType.split(';', 
2)[0].trim().toLowerCase(Locale.ROOT)
+
+        if (mediaType == 'application/json' || mediaType.endsWith('+json')) {
+            return getJson()
+        }
+        if (mediaType == 'application/xml' || mediaType == 'text/xml' || 
mediaType.endsWith('+xml')) {
+            return getXml()
+        }
+        if (mediaType == 'text/html') {
+            try {
+                return getHtml()
+            } catch (IllegalStateException ignored) {
+                System.err.println "HttpResult unable to parse HTML: 
$ignored.message"
+            }
+        }
+        return body
+    }
+}
diff --git a/subprojects/groovy-http-builder/src/spec/doc/http-builder.adoc 
b/subprojects/groovy-http-builder/src/spec/doc/http-builder.adoc
new file mode 100644
index 0000000000..36a883a759
--- /dev/null
+++ b/subprojects/groovy-http-builder/src/spec/doc/http-builder.adoc
@@ -0,0 +1,183 @@
+//////////////////////////////////////////
+
+  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 serialises 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
+|===
+
+
diff --git 
a/subprojects/groovy-http-builder/src/spec/test/HttpBuilderSpecTest.groovy 
b/subprojects/groovy-http-builder/src/spec/test/HttpBuilderSpecTest.groovy
new file mode 100644
index 0000000000..0f76340b23
--- /dev/null
+++ b/subprojects/groovy-http-builder/src/spec/test/HttpBuilderSpecTest.groovy
@@ -0,0 +1,240 @@
+/*
+ *  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.
+ */
+import com.sun.net.httpserver.HttpServer
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+
+import java.nio.charset.StandardCharsets
+
+import static org.junit.jupiter.api.Assumptions.assumeFalse
+
+class HttpBuilderSpecTest {
+
+    private HttpServer server
+    private URI rootUri
+
+    @BeforeEach
+    void setup() {
+        server = HttpServer.create(new InetSocketAddress('127.0.0.1', 0), 0)
+        server.createContext('/api/items') { exchange ->
+            String query = exchange.requestURI.query ?: ''
+            String method = exchange.requestMethod
+            String contentType = 
exchange.requestHeaders.getFirst('Content-Type') ?: ''
+            String requestBody = 
exchange.requestBody.getText(StandardCharsets.UTF_8.name())
+            String ua = exchange.requestHeaders.getFirst('User-Agent') ?: ''
+            String body
+            if (method == 'GET') {
+                body = 
"""{"items":[{"name":"book","qty":2}],"query":"${query}","ua":"${ua}"}"""
+            } else if (method == 'POST' && 
contentType.contains('application/json')) {
+                body = """{"ok":true,"received":${requestBody}}"""
+            } else {
+                body = """{"ok":true}"""
+            }
+            byte[] bytes = body.getBytes(StandardCharsets.UTF_8)
+            exchange.responseHeaders.add('Content-Type', 'application/json')
+            exchange.sendResponseHeaders(200, bytes.length)
+            exchange.responseBody.withCloseable { it.write(bytes) }
+        }
+        server.createContext('/api/repo.xml') { exchange ->
+            String body = '<repo><name>groovy</name><license>Apache License 
2.0</license></repo>'
+            byte[] bytes = body.getBytes(StandardCharsets.UTF_8)
+            exchange.responseHeaders.add('Content-Type', 'application/xml')
+            exchange.sendResponseHeaders(200, bytes.length)
+            exchange.responseBody.withCloseable { it.write(bytes) }
+        }
+        server.createContext('/login') { exchange ->
+            String method = exchange.requestMethod
+            if (method == 'GET') {
+                String body = '<!DOCTYPE html><html><body><h1>Please 
Login</h1></body></html>'
+                byte[] bytes = body.getBytes(StandardCharsets.UTF_8)
+                exchange.responseHeaders.add('Content-Type', 'text/html; 
charset=UTF-8')
+                exchange.sendResponseHeaders(200, bytes.length)
+                exchange.responseBody.withCloseable { it.write(bytes) }
+            } else {
+                String requestBody = 
exchange.requestBody.getText(StandardCharsets.UTF_8.name())
+                String body
+                if (requestBody.contains('username=admin')) {
+                    body = '<!DOCTYPE html><html><body><h1>Admin 
Section</h1></body></html>'
+                } else {
+                    body = '<!DOCTYPE html><html><body><h1>Login 
Failed</h1></body></html>'
+                }
+                byte[] bytes = body.getBytes(StandardCharsets.UTF_8)
+                exchange.responseHeaders.add('Content-Type', 'text/html; 
charset=UTF-8')
+                exchange.sendResponseHeaders(200, bytes.length)
+                exchange.responseBody.withCloseable { it.write(bytes) }
+            }
+        }
+        server.start()
+        rootUri = URI.create("http://127.0.0.1:${server.address.port}";)
+    }
+
+    @AfterEach
+    void cleanup() {
+        server?.stop(0)
+    }
+
+    @Test
+    void testBasicGetWithQuery() {
+        assertScript """
+        // tag::basic_get_with_query[]
+        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
+        // end::basic_get_with_query[]
+        assert res.json.ua == 'my-app/1.0'
+        """
+    }
+
+    @Test
+    void testJsonGet() {
+        assertScript """
+        // tag::json_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
+        // end::json_get[]
+        """
+    }
+
+    @Test
+    void testJsonPost() {
+        assertScript """
+        import static groovy.http.HttpBuilder.http
+
+        def http = http '${rootUri}'
+        // tag::json_post[]
+        def result = http.post('/api/items') {
+            json([name: 'book', qty: 2])
+        }
+
+        assert result.status == 200
+        assert result.json.ok
+        // end::json_post[]
+        """
+    }
+
+    @Test
+    void testXmlGet() {
+        assertScript """
+        import static groovy.http.HttpBuilder.http
+
+        def http = http '${rootUri}'
+        // tag::xml_get[]
+        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
+        // end::xml_get[]
+        """
+    }
+
+    @Test
+    void testHtmlLogin() {
+        assertScript """
+        import static groovy.http.HttpBuilder.http
+
+        // tag::html_login[]
+        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'
+        // end::html_login[]
+        """
+    }
+
+    @Test
+    void testHtmlJsoup() {
+        // Skip on JDKs with TLS fingerprints that trigger Cloudflare bot 
detection
+        def jdkVersion = Runtime.version().feature()
+        assumeFalse(jdkVersion in [18, 19, 20, 22],
+            "Skipping on JDK ${jdkVersion} due to Cloudflare TLS 
fingerprinting")
+
+        assertScript '''
+        import static groovy.http.HttpBuilder.http
+
+        // tag::html_jsoup[]
+        // @Grab('org.jsoup:jsoup:1.22.1') // needed if running as standalone 
script
+        def client = http('https://mvnrepository.com')
+        def res = client.get('/artifact/org.codehaus.groovy/groovy-all') {
+            header 'User-Agent', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 
10_15_7) AppleWebKit/537.36'
+        }
+
+        assert res.status == 200
+
+        def license = 
res.parsed.select('span.badge.badge-license')*.text().join(', ')
+        assert license == 'Apache 2.0'
+        // end::html_jsoup[]
+        '''
+    }
+
+    @Test
+    void testFormPost() {
+        assertScript """
+        import static groovy.http.HttpBuilder.http
+
+        def http = http '${rootUri}'
+        // tag::form_post[]
+        def result = http.post('/login') {
+            form(username: 'admin', password: 'p@ssw0rd')
+        }
+
+        assert result.status == 200
+        // end::form_post[]
+        """
+    }
+
+    private static void assertScript(String script) {
+        new GroovyShell(HttpBuilderSpecTest.classLoader).evaluate(script)
+    }
+}
+
+
+
+
+
+
+
diff --git 
a/subprojects/groovy-http-builder/src/test/groovy/groovy/http/HttpBuilderTest.groovy
 
b/subprojects/groovy-http-builder/src/test/groovy/groovy/http/HttpBuilderTest.groovy
new file mode 100644
index 0000000000..afee7861ae
--- /dev/null
+++ 
b/subprojects/groovy-http-builder/src/test/groovy/groovy/http/HttpBuilderTest.groovy
@@ -0,0 +1,320 @@
+/*
+ *  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.
+ */
+package groovy.http
+
+import com.sun.net.httpserver.HttpServer
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+
+import static org.junit.jupiter.api.Assertions.assertThrows
+
+import java.nio.charset.StandardCharsets
+import java.time.Duration
+
+class HttpBuilderTest {
+
+    private HttpServer server
+    private URI rootUri
+
+    @BeforeEach
+    void setup() {
+        server = HttpServer.create(new InetSocketAddress('127.0.0.1', 0), 0)
+        server.createContext('/hello') { exchange ->
+            String body = 
"method=${exchange.requestMethod};query=${exchange.requestURI.query};ua=${exchange.requestHeaders.getFirst('User-Agent')}"
+            byte[] bytes = body.getBytes(StandardCharsets.UTF_8)
+            exchange.sendResponseHeaders(200, bytes.length)
+            exchange.responseBody.withCloseable { it.write(bytes) }
+        }
+        server.createContext('/echo') { exchange ->
+            String requestBody = 
exchange.requestBody.getText(StandardCharsets.UTF_8.name())
+            String body = 
"method=${exchange.requestMethod};header=${exchange.requestHeaders.getFirst('X-Trace')};body=${requestBody}"
+            byte[] bytes = body.getBytes(StandardCharsets.UTF_8)
+            exchange.sendResponseHeaders(201, bytes.length)
+            exchange.responseBody.withCloseable { it.write(bytes) }
+        }
+        server.createContext('/json') { exchange ->
+            String requestBody = 
exchange.requestBody.getText(StandardCharsets.UTF_8.name())
+            String contentType = 
exchange.requestHeaders.getFirst('Content-Type')
+            String body = 
/{"ok":true,"contentType":"${contentType}","requestBody":${requestBody}}/
+            byte[] bytes = body.getBytes(StandardCharsets.UTF_8)
+            exchange.responseHeaders.add('Content-Type', 'application/json')
+            exchange.sendResponseHeaders(200, bytes.length)
+            exchange.responseBody.withCloseable { it.write(bytes) }
+        }
+        server.createContext('/xml') { exchange ->
+            String body = '<repo><name>groovy</name><license>Apache License 
2.0</license></repo>'
+            byte[] bytes = body.getBytes(StandardCharsets.UTF_8)
+            exchange.responseHeaders.add('Content-Type', 'application/xml')
+            exchange.sendResponseHeaders(200, bytes.length)
+            exchange.responseBody.withCloseable { it.write(bytes) }
+        }
+        server.createContext('/plain') { exchange ->
+            String body = 'just text'
+            byte[] bytes = body.getBytes(StandardCharsets.UTF_8)
+            exchange.responseHeaders.add('Content-Type', 'text/plain')
+            exchange.sendResponseHeaders(200, bytes.length)
+            exchange.responseBody.withCloseable { it.write(bytes) }
+        }
+        server.createContext('/form') { exchange ->
+            String requestBody = 
exchange.requestBody.getText(StandardCharsets.UTF_8.name())
+            String contentType = 
exchange.requestHeaders.getFirst('Content-Type')
+            String body = 
"method=${exchange.requestMethod};contentType=${contentType};body=${requestBody}"
+            byte[] bytes = body.getBytes(StandardCharsets.UTF_8)
+            exchange.sendResponseHeaders(200, bytes.length)
+            exchange.responseBody.withCloseable { it.write(bytes) }
+        }
+        server.createContext('/html') { exchange ->
+            String body = '<!DOCTYPE html><html><head><link rel="preconnect" 
crossorigin></head><body><span class="b lic">Apache License 
2.0</span></body></html>'
+            byte[] bytes = body.getBytes(StandardCharsets.UTF_8)
+            exchange.responseHeaders.add('Content-Type', 'text/html; 
charset=UTF-8')
+            exchange.sendResponseHeaders(200, bytes.length)
+            exchange.responseBody.withCloseable { it.write(bytes) }
+        }
+        server.createContext('/redirect-target') { exchange ->
+            String body = 'redirect reached'
+            byte[] bytes = body.getBytes(StandardCharsets.UTF_8)
+            exchange.sendResponseHeaders(200, bytes.length)
+            exchange.responseBody.withCloseable { it.write(bytes) }
+        }
+        server.createContext('/redirect-me') { exchange ->
+            exchange.responseHeaders.add('Location', '/redirect-target')
+            exchange.sendResponseHeaders(302, -1)
+            exchange.close()
+        }
+        server.start()
+        rootUri = URI.create("http://127.0.0.1:${server.address.port}/";)
+    }
+
+    @AfterEach
+    void cleanup() {
+        server?.stop(0)
+    }
+
+    @Test
+    void getsWithBaseUriDefaultHeadersAndQueryDsl() {
+        HttpBuilder http = HttpBuilder.http {
+            baseUri rootUri
+            connectTimeout Duration.ofSeconds(2)
+            requestTimeout Duration.ofSeconds(2)
+            header 'User-Agent', 'groovy-http-builder-test'
+        }
+
+        HttpResult result = http.get('/hello') {
+            query lang: 'groovy', page: 1
+        }
+
+        assert result.status == 200
+        assert result.body.contains('method=GET')
+        assert result.body.contains('lang=groovy')
+        assert result.body.contains('page=1')
+        assert result.body.contains('ua=groovy-http-builder-test')
+    }
+
+    @Test
+    void getsUsingStringBaseUriFactoryWithoutClosureConfig() {
+        HttpBuilder http = HttpBuilder.http(rootUri.toString())
+
+        HttpResult result = http.get('/hello') {
+            query page: 1
+        }
+
+        assert result.status == 200
+        assert result.body.contains('method=GET')
+        assert result.body.contains('page=1')
+    }
+
+    @Test
+    void relativeUriWithoutBaseUriConfiguredThrows() {
+        HttpBuilder http = HttpBuilder.http {
+            header 'User-Agent', 'groovy-http-builder-test'
+        }
+
+        IllegalArgumentException error = 
assertThrows(IllegalArgumentException) {
+            http.get('/hello')
+        }
+
+        assert error.message == 'Request URI must be absolute when no baseUri 
is configured'
+    }
+
+    @Test
+    void omittedUriWithoutBaseUriConfiguredThrows() {
+        HttpBuilder http = HttpBuilder.http {
+            header 'User-Agent', 'groovy-http-builder-test'
+        }
+
+        IllegalArgumentException error = 
assertThrows(IllegalArgumentException) {
+            http.get()
+        }
+
+        assert error.message == 'URI must be provided when no baseUri is 
configured'
+    }
+
+    @Test
+    void relativeBaseUriConfiguredInClosureThrows() {
+        IllegalArgumentException error = 
assertThrows(IllegalArgumentException) {
+            HttpBuilder.http {
+                baseUri '/api'
+            }
+        }
+
+        assert error.message == 'baseUri must be an absolute URI with scheme 
and host'
+    }
+
+    @Test
+    void relativeBaseUriConfiguredViaStringFactoryThrows() {
+        IllegalArgumentException error = 
assertThrows(IllegalArgumentException) {
+            HttpBuilder.http('/api')
+        }
+
+        assert error.message == 'baseUri must be an absolute URI with scheme 
and host'
+    }
+
+    @Test
+    void queryDslUsesRfc3986StyleEncoding() {
+        HttpBuilder http = HttpBuilder.http(rootUri.toString())
+
+        HttpResult result = http.get('/hello') {
+            query 'sp ace', 'a b'
+            query 'plus', 'c+d'
+            query 'marks', '~*'
+            query 'empty', null
+        }
+
+        assert result.status == 200
+        assert 
result.body.contains('query=sp%20ace=a%20b&plus=c%2Bd&marks=~%2A&empty=')
+    }
+
+    @Test
+    void postsWithBodyAndPerRequestHeader() {
+        HttpBuilder http = HttpBuilder.http {
+            baseUri rootUri
+        }
+
+        HttpResult result = http.post('/echo') {
+            header 'X-Trace', 'trace-42'
+            text 'hello from DSL'
+        }
+
+        assert result.status == 201
+        assert result.body == 'method=POST;header=trace-42;body=hello from DSL'
+    }
+
+    @Test
+    void formHookEncodesBodyAndSetsDefaultContentType() {
+        HttpBuilder http = HttpBuilder.http(rootUri.toString())
+
+        HttpResult result = http.post('/form') {
+            form([username: 'admin', password: 'p@ss word'])
+        }
+
+        assert result.status == 200
+        assert result.body == 
'method=POST;contentType=application/x-www-form-urlencoded;body=username=admin&password=p%40ss+word'
+    }
+
+    @Test
+    void perRequestHeaderOverridesDefaultHeader() {
+        HttpBuilder http = HttpBuilder.http {
+            baseUri rootUri
+            connectTimeout Duration.ofSeconds(2)
+            requestTimeout Duration.ofSeconds(2)
+            header 'User-Agent', 'default-ua'
+        }
+        HttpResult result = http.get('/hello') {
+            header 'User-Agent', 'overridden-ua'
+        }
+        assert result.status == 200
+        assert result.body.contains('ua=overridden-ua')
+        assert !result.body.contains('ua=default-ua')
+    }
+
+    @Test
+    void jsonHookSerializesRequestAndParsesResponse() {
+        HttpBuilder http = HttpBuilder.http {
+            baseUri rootUri
+        }
+
+        HttpResult result = http.post('/json') {
+            json([name: 'Groovy', version: 6])
+        }
+
+        assert result.status == 200
+        Map payload = (Map) result.getJson()
+        assert payload.ok == true
+        assert payload.contentType == 'application/json'
+        assert payload.requestBody.name == 'Groovy'
+        assert payload.requestBody.version == 6
+
+        Map parsed = (Map) result.parsed
+        assert parsed.ok == true
+    }
+
+    @Test
+    void xmlHookParsesResponseBody() {
+        HttpBuilder http = HttpBuilder.http(rootUri.toString())
+
+        HttpResult result = http.get('/xml')
+
+        assert result.status == 200
+        def xml = result.xml
+        assert xml.name.text() == 'groovy'
+        assert xml.license.text() == 'Apache License 2.0'
+
+        def parsed = result.parsed
+        assert parsed.name.text() == 'groovy'
+    }
+
+    @Test
+    void parsedFallsBackToRawBodyForUnsupportedContentType() {
+        HttpBuilder http = HttpBuilder.http(rootUri.toString())
+
+        HttpResult result = http.get('/plain')
+
+        assert result.status == 200
+        assert result.parsed == 'just text'
+    }
+
+    @Test
+    void htmlHookParsesMalformedHtmlViaJsoup() {
+        HttpBuilder http = HttpBuilder.http(rootUri.toString())
+
+        HttpResult result = http.get('/html')
+
+        assert result.status == 200
+        assert result.html.select('span.b.lic').text() == 'Apache License 2.0'
+        assert result.parsed.select('span.b.lic').text() == 'Apache License 
2.0'
+    }
+
+    @Test
+    void followsRedirectsWhenFlagEnabled() {
+        HttpBuilder noRedirectClient = HttpBuilder.http {
+            baseUri rootUri
+        }
+        HttpResult noRedirect = noRedirectClient.get('/redirect-me')
+        assert noRedirect.status == 302
+
+        HttpBuilder redirectClient = HttpBuilder.http {
+            baseUri rootUri
+            followRedirects true
+        }
+        HttpResult redirected = redirectClient.get('/redirect-me')
+        assert redirected.status == 200
+        assert redirected.body == 'redirect reached'
+    }
+}


Reply via email to