[
https://issues.apache.org/jira/browse/GROOVY-11879?page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel&focusedCommentId=18067633#comment-18067633
]
ASF GitHub Bot commented on GROOVY-11879:
-----------------------------------------
Copilot commented on code in PR #2401:
URL: https://github.com/apache/groovy/pull/2401#discussion_r2974641276
##########
subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpBuilder.groovy:
##########
@@ -0,0 +1,299 @@
+/*
+ * 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
+ )
Review Comment:
`HttpClient#send` can throw `IOException` and `InterruptedException`. Right
now those propagate without preserving the interrupt flag and without any
context about which request failed. Consider catching `InterruptedException` to
re-interrupt the thread and wrapping both exceptions in a runtime exception
that includes the HTTP method and resolved URI (or exposing a checked/typed
exception in the API).
```suggestion
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 (java.io.IOException e) {
throw new RuntimeException("I/O error during HTTP request " +
method + " " + resolvedUri, e)
}
```
##########
subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpBuilder.groovy:
##########
@@ -0,0 +1,299 @@
+/*
+ * 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 = 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 = new LinkedHashMap<>()
+
+ void baseUri(final Object value) {
+ baseUri = value instanceof URI ? (URI) value :
URI.create(value.toString())
Review Comment:
`Config.baseUri` currently accepts any `URI.create(...)` result, including
relative URIs. Since the DSL relies on `baseUri` to make relative request URIs
absolute, it would be better to validate that the configured base URI is
absolute (has scheme/host) and fail fast with a clear
`IllegalArgumentException` if it isn't.
```suggestion
URI uri = value instanceof URI ? (URI) value :
URI.create(value.toString())
if (!uri.isAbsolute() || uri.getScheme() == null ||
uri.getHost() == null) {
throw new IllegalArgumentException("Config.baseUri must be
an absolute URI with scheme and host: " + uri)
}
baseUri = uri
```
> 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)