[
https://issues.apache.org/jira/browse/GROOVY-11879?page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel&focusedCommentId=18067620#comment-18067620
]
ASF GitHub Bot commented on GROOVY-11879:
-----------------------------------------
Copilot commented on code in PR #2401:
URL: https://github.com/apache/groovy/pull/2401#discussion_r2974471303
##########
subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpBuilder.groovy:
##########
@@ -0,0 +1,292 @@
+/*
+ * 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 = client.send(
+ requestBuilder.build(),
+ requestSpec.bodyHandler
+ )
+ return new HttpResult(response)
+ }
+
+ private URI resolveUri(final Object uri, final Map<String, Object> query) {
+ URI target = toUri(uri)
+ if (baseUri != null && !target.isAbsolute()) {
+ target = baseUri.resolve(target.toString())
+ }
+ if (baseUri == null && !target.isAbsolute()) {
+ throw new IllegalArgumentException("Request URI must be absolute
when no baseUri is configured")
+ }
+ return appendQuery(target, query)
+ }
+
+ 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
Review Comment:
Key error semantics (e.g., throwing when a relative URI is used without a
configured `baseUri`, or when `uri` is omitted) aren't currently covered by
tests. Adding negative tests for these exceptions would help lock in the
intended contract and prevent accidental behavior changes.
##########
subprojects/groovy-http-builder/README.md:
##########
@@ -0,0 +1,228 @@
+<!--
+SPDX-License-Identifier: Apache-2.0
+
+Licensed 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
+
+ https://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.
+-->
+# groovy-http-builder (experimental)
+
+A tiny experiment showing that a small DSL over JDK `java.net.http.HttpClient`
can be pleasant to use while staying lightweight.
+
+## Goals
+
+- Keep implementation small and easy to maintain.
+- Use only JDK HTTP client primitives (we optionally allow Jsoup for HTML
parsing as a minor breakage of this rule).
+- Make common request setup declarative with Groovy closures.
+- Handle only the simple cases that often pop up in scripting and not the full
use cases that Apache Geb covers.
+- Include JSON/XML/HTML response parsing hooks while intentionally keeping
request hooks minimal.
+
+## Example
+
+```groovy
+import groovy.http.HttpBuilder
+
+def http = HttpBuilder.http {
+ baseUri 'https://example.com/'
+ header 'User-Agent', 'my-app/1.0'
+}
+
+def res = http.get('/api/items') {
+ query page: 1, size: 10
+}
+
+assert res.status == 200
+println res.body
+```
+
+## Non-DSL Equivalent (JDK HttpClient)
+
+```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 baseUri = 'https://example.com/'
+def query = [page: 1, size: 10]
+ .collect { k, v ->
+ "${URLEncoder.encode(k.toString(), StandardCharsets.UTF_8)}=" +
+ URLEncoder.encode(v.toString(), StandardCharsets.UTF_8)
+ }
+ .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` Example
+
+```groovy
+import static groovy.http.HttpBuilder.http
+
+def github = http 'https://api.github.com'
+def res = github.get('/repos/apache/groovy')
+
+assert res.status == 200
+assert res.json.license.name == 'Apache License 2.0'
+assert res.parsed.license.name == 'Apache License 2.0' // auto-parsed from
Content-Type
+```
+
+### Non-DSL Equivalent (JDK HttpClient)
+
+```groovy
+import groovy.json.JsonSlurper
+
+import java.net.URI
+import java.net.http.HttpClient
+import java.net.http.HttpRequest
+import java.net.http.HttpResponse
+
+def client = HttpClient.newHttpClient()
+def request =
HttpRequest.newBuilder(URI.create('https://api.github.com/repos/apache/groovy'))
+ .GET()
+ .build()
+
+def response = client.send(request, HttpResponse.BodyHandlers.ofString())
+def payload = new JsonSlurper().parseText(response.body())
+
+assert response.statusCode() == 200
+assert payload.license.name == 'Apache License 2.0'
+```
+
+## JSON `post` Example
+
+```groovy
+def result = http.post('/api/items') {
+ json([name: 'book', qty: 2])
+}
+
+assert result.status == 200
+assert result.json.ok
+```
+
+## XML `get` Example
+
+```groovy
+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
+```
+
+## HTML `get` Example (jsoup)
+
+```groovy
+@Grab('org.jsoup:jsoup:1.22.1')
+import static groovy.http.HttpBuilder.http
+
+def client = http('https://mvnrepository.com')
+def res = client.get('/artifact/org.codehaus.groovy/groovy-all') {
+ header 'User-Agent', 'Mozilla/5.0 (Macintosh)'
+}
+
+assert res.status == 200
+
+def license = res.parsed.select('div.metadata-row
span.badge.badge-license')*.text().join(', ')
+assert license == 'Apache 2.0'
+```
+
+### Non-DSL Equivalent (JDK HttpClient + jsoup)
+
+```groovy
+@Grab('org.jsoup:jsoup:1.22.1')
+import org.jsoup.Jsoup
+
+import java.net.URI
+import java.net.http.HttpClient
+import java.net.http.HttpRequest
+import java.net.http.HttpResponse
+
+def client = HttpClient.newHttpClient()
+def request =
HttpRequest.newBuilder(URI.create('https://mvnrepository.com/artifact/org.codehaus.groovy/groovy-all'))
+ .header('User-Agent', 'Mozilla/5.0 (Macintosh)')
+ .GET()
+ .build()
+
+def response = client.send(request, HttpResponse.BodyHandlers.ofString())
+def document = Jsoup.parse(response.body())
+
+assert response.statusCode() == 200
+def license = document.select('div.metadata-row
span.badge.badge-license')*.text().join(', ')
+assert license == 'Apache 2.0'
+```
+
+## HTML login Example
+
+```groovy
+@Grab('org.jsoup:jsoup:1.22.1')
+import static groovy.http.HttpBuilder.http
+
+def app = http {
+ baseUri 'http://myapp.com'
+ 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'
+```
+
+### Form URL-Encoding Helper
+
+This example shows the `form` helper.
+
+```groovy
+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`).
+`result.parsed` dispatches by response `Content-Type`:
+- `application/json` and `application/*+json` -> JSON object
+- `application/xml`, `text/xml`, and `application/*+xml` -> XML object
+- `text/html` -> jsoup `Document` if jsoup is found on the classpath,
otherwise raw string body
Review Comment:
The README says HTML auto-parsing is triggered only for `text/html`, but the
PR description also claims `application/xhtml+xml` is supported. Please make
the README and implementation/PR description consistent (either document only
what is implemented, or extend the parser dispatch to include XHTML).
```suggestion
- `text/html` and `application/xhtml+xml` -> jsoup `Document` if jsoup is
found on the classpath, otherwise raw string body
```
##########
subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpBuilder.groovy:
##########
@@ -0,0 +1,292 @@
+/*
+ * 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 = client.send(
+ requestBuilder.build(),
+ requestSpec.bodyHandler
+ )
+ return new HttpResult(response)
+ }
+
+ private URI resolveUri(final Object uri, final Map<String, Object> query) {
+ URI target = toUri(uri)
+ if (baseUri != null && !target.isAbsolute()) {
+ target = baseUri.resolve(target.toString())
+ }
+ if (baseUri == null && !target.isAbsolute()) {
+ throw new IllegalArgumentException("Request URI must be absolute
when no baseUri is configured")
+ }
+ return appendQuery(target, query)
+ }
+
+ 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 = URLEncoder.encode(key, StandardCharsets.UTF_8)
+ String encodedValue = value == null ? "" :
URLEncoder.encode(value.toString(), StandardCharsets.UTF_8)
+ pairs.add(encodedKey + "=" + encodedValue)
+ }
Review Comment:
`URLEncoder.encode(...)` applies `application/x-www-form-urlencoded` rules
(e.g., spaces become `+`), which isn't strictly correct for encoding URI query
components. Consider using an RFC 3986–style percent-encoding for query params
(at minimum converting `+` to `%20`) so URLs behave correctly with
servers/frameworks that treat `+` as a literal plus in the query string.
##########
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()
Review Comment:
`getParsed()` only treats `text/html` as HTML, but the PR description
mentions also supporting `application/xhtml+xml`. If XHTML is intended to be
auto-parsed, include it (and potentially other HTML-ish media types) in the
dispatch logic; otherwise please align the docs/description with the actual
behavior.
> A very simple DSL over the JDK's HTTP client
> --------------------------------------------
>
> Key: GROOVY-11879
> URL: https://issues.apache.org/jira/browse/GROOVY-11879
> Project: Groovy
> Issue Type: New Feature
> Reporter: Paul King
> Priority: Major
>
> In the past there have been very handy libraries like HttpBuilder and
> HttpBuilderNG. They are no longer supported. Instead folks use the native JDK
> client in JDK11+, libraries like okhttp, or our own Apache Geb. For simple
> scripting scenarios, none of the options are as clean as what the old http
> builder libraries provided.
> We rejected a previous proposal to have a built-in client: GROOVY-8209. At
> the time it was going to add additional dependencies and the JDK builtin
> client wasn't out. Times have moved on and there are times when doing simple
> scripting when a tiny bit of syntactic sugar over the JDKs client would
> provide useful without adding too much maintenance to our project.
> It isn't meant to handle many tricky cases - we have Geb for that and we can
> make use of the existing APIs of the JDK client for some less common things
> not covered by the little DSL.
--
This message was sent by Atlassian Jira
(v8.20.10#820010)