[
https://issues.apache.org/jira/browse/GROOVY-11924?page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel&focusedCommentId=18072942#comment-18072942
]
ASF GitHub Bot commented on GROOVY-11924:
-----------------------------------------
Copilot commented on code in PR #2463:
URL: https://github.com/apache/groovy/pull/2463#discussion_r3069350218
##########
subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpClientHelper.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 org.apache.groovy.lang.annotation.Incubating
+
+import java.util.concurrent.CompletableFuture
+
+/**
+ * Runtime helper used by the generated declarative HTTP client
implementations.
+ * Not intended for direct use.
+ *
+ * @since 6.0.0
+ */
+@Incubating
+final class HttpClientHelper {
+ private final HttpBuilder http
+ private final Map<String, String> defaultHeaders
+
+ HttpClientHelper(String baseUrl, Map<String, String> defaultHeaders) {
+ this.http = HttpBuilder.http(baseUrl)
+ this.defaultHeaders = Collections.unmodifiableMap(new
LinkedHashMap<>(defaultHeaders))
+ }
+
+ /**
+ * Execute an HTTP request synchronously.
+ *
+ * @param method HTTP method (GET, POST, PUT, DELETE, PATCH)
+ * @param urlTemplate URL template with {param} placeholders
+ * @param returnType the method's return type for response conversion
+ * @param pathParams map of path parameter names to values
+ * @param queryParams map of query parameter names to values
+ * @param headers map of additional headers for this request
+ * @param body request body (serialized as JSON if non-null)
+ * @return the converted response
+ */
+ Object execute(String method, String urlTemplate, Class<?> returnType,
+ Map<String, Object> pathParams, Map<String, Object>
queryParams,
+ Map<String, String> headers, Object body) {
+ String url = resolveUrl(urlTemplate, pathParams)
+ HttpResult result = http.request(method, url) {
+ defaultHeaders.each { k, v -> header(k, v) }
+ headers.each { k, v -> header(k, v) }
+ queryParams.each { k, v -> query(k, v) }
+ if (body != null) { json(body) }
+ }
+ return convertResult(result, returnType)
+ }
+
+ /**
+ * Execute an HTTP request asynchronously.
+ *
+ * @return a CompletableFuture containing the converted response
+ */
+ CompletableFuture<Object> executeAsync(String method, String urlTemplate,
Class<?> returnType,
+ Map<String, Object> pathParams,
Map<String, Object> queryParams,
+ Map<String, String> headers, Object
body) {
+ CompletableFuture.supplyAsync {
+ execute(method, urlTemplate, returnType, pathParams, queryParams,
headers, body)
+ }
+ }
+
+ private static String resolveUrl(String template, Map<String, Object>
params) {
+ String url = template
+ params.each { String k, Object v ->
+ url = url.replace("{${k}}", URLEncoder.encode(String.valueOf(v),
'UTF-8'))
+ }
+ url
+ }
+
Review Comment:
`resolveUrl` uses `URLEncoder.encode` for path variables. `URLEncoder` is
for form/query encoding (e.g. spaces become `+`) and is not correct for URL
path segments. This can produce incorrect URLs for values containing spaces or
`+`. Use proper path-segment encoding (e.g. percent-encode and replace `+` with
`%20`) or build a `URI` and let it encode components appropriately.
```suggestion
url = url.replace("{${k}}", encodePathSegment(String.valueOf(v)))
}
url
}
private static String encodePathSegment(String value) {
URLEncoder.encode(value, 'UTF-8').replace("+", "%20")
}
```
##########
subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpClientHelper.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 org.apache.groovy.lang.annotation.Incubating
+
+import java.util.concurrent.CompletableFuture
+
+/**
+ * Runtime helper used by the generated declarative HTTP client
implementations.
+ * Not intended for direct use.
+ *
+ * @since 6.0.0
+ */
+@Incubating
+final class HttpClientHelper {
+ private final HttpBuilder http
+ private final Map<String, String> defaultHeaders
+
+ HttpClientHelper(String baseUrl, Map<String, String> defaultHeaders) {
+ this.http = HttpBuilder.http(baseUrl)
+ this.defaultHeaders = Collections.unmodifiableMap(new
LinkedHashMap<>(defaultHeaders))
+ }
+
+ /**
+ * Execute an HTTP request synchronously.
+ *
+ * @param method HTTP method (GET, POST, PUT, DELETE, PATCH)
+ * @param urlTemplate URL template with {param} placeholders
+ * @param returnType the method's return type for response conversion
+ * @param pathParams map of path parameter names to values
+ * @param queryParams map of query parameter names to values
+ * @param headers map of additional headers for this request
+ * @param body request body (serialized as JSON if non-null)
+ * @return the converted response
+ */
+ Object execute(String method, String urlTemplate, Class<?> returnType,
+ Map<String, Object> pathParams, Map<String, Object>
queryParams,
+ Map<String, String> headers, Object body) {
+ String url = resolveUrl(urlTemplate, pathParams)
+ HttpResult result = http.request(method, url) {
+ defaultHeaders.each { k, v -> header(k, v) }
+ headers.each { k, v -> header(k, v) }
+ queryParams.each { k, v -> query(k, v) }
+ if (body != null) { json(body) }
+ }
+ return convertResult(result, returnType)
+ }
+
+ /**
+ * Execute an HTTP request asynchronously.
+ *
+ * @return a CompletableFuture containing the converted response
+ */
+ CompletableFuture<Object> executeAsync(String method, String urlTemplate,
Class<?> returnType,
+ Map<String, Object> pathParams,
Map<String, Object> queryParams,
+ Map<String, String> headers, Object
body) {
+ CompletableFuture.supplyAsync {
+ execute(method, urlTemplate, returnType, pathParams, queryParams,
headers, body)
+ }
+ }
+
+ private static String resolveUrl(String template, Map<String, Object>
params) {
+ String url = template
+ params.each { String k, Object v ->
+ url = url.replace("{${k}}", URLEncoder.encode(String.valueOf(v),
'UTF-8'))
+ }
+ url
+ }
+
+ private static Object convertResult(HttpResult result, Class<?>
returnType) {
+ if (result.status() >= 400) {
+ throw new RuntimeException("HTTP ${result.status()}:
${result.body()}")
+ }
+ if (returnType == HttpResult) return result
+ if (returnType == String) return result.body()
+ if (returnType == void || returnType == Void) return null
Review Comment:
`returnType == void` is not a reliable/valid way to detect void at runtime.
Use `returnType == Void.TYPE` / `returnType == void.class` (and optionally
`returnType == Void`) to ensure this branch works consistently.
```suggestion
if (returnType == Void.TYPE || returnType == Void) return null
```
##########
subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpBuilderClientTransform.groovy:
##########
@@ -0,0 +1,254 @@
+/*
+ * 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 org.codehaus.groovy.ast.*
+import org.codehaus.groovy.ast.expr.*
+import org.codehaus.groovy.ast.stmt.*
+import org.codehaus.groovy.control.CompilePhase
+import org.codehaus.groovy.control.SourceUnit
+import org.codehaus.groovy.transform.AbstractASTTransformation
+import org.codehaus.groovy.transform.GroovyASTTransformation
+import org.objectweb.asm.Opcodes
+
+import java.util.concurrent.CompletableFuture
+
+import static org.codehaus.groovy.ast.ClassHelper.*
+import static org.codehaus.groovy.ast.tools.GeneralUtils.*
+
+/**
+ * AST transform that generates an implementation class for interfaces
+ * annotated with {@link HttpBuilderClient}.
+ *
+ * @since 6.0.0
+ */
+@GroovyASTTransformation(phase = CompilePhase.CANONICALIZATION)
+class HttpBuilderClientTransform extends AbstractASTTransformation {
+
+ private static final ClassNode HELPER_TYPE = make(HttpClientHelper)
+ private static final ClassNode FUTURE_TYPE = make(CompletableFuture)
+ private static final ClassNode HTTP_RESULT_TYPE = make(HttpResult)
+
+ private static final Map<String, String> HTTP_METHOD_ANNOTATIONS = [
+ 'groovy.http.Get' : 'GET',
+ 'groovy.http.Post' : 'POST',
+ 'groovy.http.Put' : 'PUT',
+ 'groovy.http.Delete': 'DELETE',
+ 'groovy.http.Patch' : 'PATCH',
+ ]
+
+ @Override
+ void visit(ASTNode[] nodes, SourceUnit source) {
+ init(nodes, source)
+ AnnotationNode anno = (AnnotationNode) nodes[0]
+ AnnotatedNode target = (AnnotatedNode) nodes[1]
+
+ if (!(target instanceof ClassNode) || !target.isInterface()) {
+ addError("@HttpBuilderClient can only be applied to interfaces",
target)
+ return
+ }
+
+ ClassNode interfaceNode = (ClassNode) target
+ String baseUrl = getMemberStringValue(anno, 'value')
+ if (!baseUrl) {
+ addError("@HttpBuilderClient requires a base URL", anno)
+ return
+ }
+
+ // Collect interface-level @Header annotations
+ Map<String, String> interfaceHeaders = collectHeaders(interfaceNode)
+
+ // Generate the implementation class
+ ClassNode implClass = generateImplClass(interfaceNode, baseUrl,
interfaceHeaders)
+ source.AST.addClass(implClass)
+
+ // Add static create() factory method to the interface
+ addCreateMethod(interfaceNode, implClass, baseUrl)
+ }
+
+ private ClassNode generateImplClass(ClassNode interfaceNode, String
baseUrl, Map<String, String> interfaceHeaders) {
+ String implName = interfaceNode.name + '$Client'
+ ClassNode implClass = new ClassNode(implName, Opcodes.ACC_PUBLIC |
Opcodes.ACC_SYNTHETIC,
+ OBJECT_TYPE, [interfaceNode.getPlainNodeReference()] as
ClassNode[], null)
+ implClass.sourcePosition = interfaceNode
+
+ // Field: private final HttpClientHelper __helper
+ FieldNode helperField = implClass.addField('__helper',
Opcodes.ACC_PRIVATE | Opcodes.ACC_FINAL,
+ HELPER_TYPE, null)
+
+ // Constructor: takes baseUrl string with default
+ Parameter baseUrlParam = param(STRING_TYPE, 'baseUrl')
+ baseUrlParam.setInitialExpression(constX(baseUrl))
+ BlockStatement ctorBody = block(
+ assignS(fieldX(helperField),
+ ctorX(HELPER_TYPE, args(varX(baseUrlParam),
buildHeadersMapExpression(interfaceHeaders))))
+ )
+ implClass.addConstructor(Opcodes.ACC_PUBLIC, params(baseUrlParam),
ClassNode.EMPTY_ARRAY, ctorBody)
+
+ // Generate a method for each abstract interface method
+ for (MethodNode method : interfaceNode.abstractMethods) {
+ String httpMethod = null
+ String urlTemplate = null
+
+ for (Map.Entry<String, String> entry : HTTP_METHOD_ANNOTATIONS) {
+ AnnotationNode methodAnno =
method.getAnnotations(make(entry.key)).find()
+ if (methodAnno) {
+ httpMethod = entry.value
+ urlTemplate = getMemberStringValue(methodAnno, 'value')
+ break
+ }
+ }
+
+ if (!httpMethod || !urlTemplate) continue
+
+ Map<String, String> methodHeaders = collectHeaders(method)
+ MethodNode implMethod = generateMethod(method, httpMethod,
urlTemplate,
+ methodHeaders, helperField)
+ implClass.addMethod(implMethod)
+ }
+
+ return implClass
+ }
+
+ private MethodNode generateMethod(MethodNode method, String httpMethod,
String urlTemplate,
+ Map<String, String> methodHeaders,
FieldNode helperField) {
+ Parameter[] params = method.parameters
+ boolean isAsync = isAsyncReturn(method.returnType)
+ Class<?> effectiveReturnType = resolveReturnType(method.returnType)
+
+ // Build path params map: params whose names appear as {name} in the
URL
+ MapExpression pathParams = new MapExpression()
+ MapExpression queryParams = new MapExpression()
+ Expression bodyExpr = constX(null)
+
+ for (Parameter p : params) {
+ if (urlTemplate.contains("{${p.name}}")) {
+ pathParams.addMapEntryExpression(
+ new MapEntryExpression(constX(p.name), varX(p)))
+ } else if (hasAnnotation(p, Body)) {
+ bodyExpr = varX(p)
+ } else {
+ // Query parameter — use @Query name if specified, else param
name
+ String queryName = getQueryParamName(p)
+ queryParams.addMapEntryExpression(
+ new MapEntryExpression(constX(queryName), varX(p)))
+ }
+ }
+
+ String executeMethod = isAsync ? 'executeAsync' : 'execute'
+ Expression callExpr = callX(fieldX(helperField), executeMethod, args(
+ constX(httpMethod),
+ constX(urlTemplate),
+ classX(make(effectiveReturnType)),
+ pathParams,
+ queryParams,
+ buildHeadersMapExpression(methodHeaders),
+ bodyExpr
+ ))
+
+ Statement body = returnS(callExpr)
Review Comment:
Generated methods always use `returnS(callExpr)`. For interface methods
declared `void`, this will generate a `return <value>` in a void method, which
fails compilation (and contradicts the docs' `void deleteBook(...)` example).
Special-case void return types to emit an expression statement (invoke helper)
and a plain `return` (or no explicit return).
```suggestion
Statement body = method.returnType == VOID_TYPE ? stmt(callExpr) :
returnS(callExpr)
```
##########
subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpBuilderClientTransform.groovy:
##########
@@ -0,0 +1,254 @@
+/*
+ * 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 org.codehaus.groovy.ast.*
+import org.codehaus.groovy.ast.expr.*
+import org.codehaus.groovy.ast.stmt.*
+import org.codehaus.groovy.control.CompilePhase
+import org.codehaus.groovy.control.SourceUnit
+import org.codehaus.groovy.transform.AbstractASTTransformation
+import org.codehaus.groovy.transform.GroovyASTTransformation
+import org.objectweb.asm.Opcodes
+
+import java.util.concurrent.CompletableFuture
+
+import static org.codehaus.groovy.ast.ClassHelper.*
+import static org.codehaus.groovy.ast.tools.GeneralUtils.*
+
+/**
+ * AST transform that generates an implementation class for interfaces
+ * annotated with {@link HttpBuilderClient}.
+ *
+ * @since 6.0.0
+ */
+@GroovyASTTransformation(phase = CompilePhase.CANONICALIZATION)
+class HttpBuilderClientTransform extends AbstractASTTransformation {
+
+ private static final ClassNode HELPER_TYPE = make(HttpClientHelper)
+ private static final ClassNode FUTURE_TYPE = make(CompletableFuture)
+ private static final ClassNode HTTP_RESULT_TYPE = make(HttpResult)
+
+ private static final Map<String, String> HTTP_METHOD_ANNOTATIONS = [
+ 'groovy.http.Get' : 'GET',
+ 'groovy.http.Post' : 'POST',
+ 'groovy.http.Put' : 'PUT',
+ 'groovy.http.Delete': 'DELETE',
+ 'groovy.http.Patch' : 'PATCH',
+ ]
+
+ @Override
+ void visit(ASTNode[] nodes, SourceUnit source) {
+ init(nodes, source)
+ AnnotationNode anno = (AnnotationNode) nodes[0]
+ AnnotatedNode target = (AnnotatedNode) nodes[1]
+
+ if (!(target instanceof ClassNode) || !target.isInterface()) {
+ addError("@HttpBuilderClient can only be applied to interfaces",
target)
+ return
+ }
+
+ ClassNode interfaceNode = (ClassNode) target
+ String baseUrl = getMemberStringValue(anno, 'value')
+ if (!baseUrl) {
+ addError("@HttpBuilderClient requires a base URL", anno)
+ return
+ }
+
+ // Collect interface-level @Header annotations
+ Map<String, String> interfaceHeaders = collectHeaders(interfaceNode)
+
+ // Generate the implementation class
+ ClassNode implClass = generateImplClass(interfaceNode, baseUrl,
interfaceHeaders)
+ source.AST.addClass(implClass)
+
+ // Add static create() factory method to the interface
+ addCreateMethod(interfaceNode, implClass, baseUrl)
+ }
+
+ private ClassNode generateImplClass(ClassNode interfaceNode, String
baseUrl, Map<String, String> interfaceHeaders) {
+ String implName = interfaceNode.name + '$Client'
+ ClassNode implClass = new ClassNode(implName, Opcodes.ACC_PUBLIC |
Opcodes.ACC_SYNTHETIC,
+ OBJECT_TYPE, [interfaceNode.getPlainNodeReference()] as
ClassNode[], null)
+ implClass.sourcePosition = interfaceNode
+
+ // Field: private final HttpClientHelper __helper
+ FieldNode helperField = implClass.addField('__helper',
Opcodes.ACC_PRIVATE | Opcodes.ACC_FINAL,
+ HELPER_TYPE, null)
+
+ // Constructor: takes baseUrl string with default
+ Parameter baseUrlParam = param(STRING_TYPE, 'baseUrl')
+ baseUrlParam.setInitialExpression(constX(baseUrl))
+ BlockStatement ctorBody = block(
+ assignS(fieldX(helperField),
+ ctorX(HELPER_TYPE, args(varX(baseUrlParam),
buildHeadersMapExpression(interfaceHeaders))))
+ )
+ implClass.addConstructor(Opcodes.ACC_PUBLIC, params(baseUrlParam),
ClassNode.EMPTY_ARRAY, ctorBody)
+
+ // Generate a method for each abstract interface method
+ for (MethodNode method : interfaceNode.abstractMethods) {
+ String httpMethod = null
+ String urlTemplate = null
+
+ for (Map.Entry<String, String> entry : HTTP_METHOD_ANNOTATIONS) {
+ AnnotationNode methodAnno =
method.getAnnotations(make(entry.key)).find()
+ if (methodAnno) {
+ httpMethod = entry.value
+ urlTemplate = getMemberStringValue(methodAnno, 'value')
+ break
+ }
+ }
+
+ if (!httpMethod || !urlTemplate) continue
+
+ Map<String, String> methodHeaders = collectHeaders(method)
+ MethodNode implMethod = generateMethod(method, httpMethod,
urlTemplate,
+ methodHeaders, helperField)
+ implClass.addMethod(implMethod)
+ }
+
+ return implClass
+ }
+
+ private MethodNode generateMethod(MethodNode method, String httpMethod,
String urlTemplate,
+ Map<String, String> methodHeaders,
FieldNode helperField) {
+ Parameter[] params = method.parameters
+ boolean isAsync = isAsyncReturn(method.returnType)
+ Class<?> effectiveReturnType = resolveReturnType(method.returnType)
+
+ // Build path params map: params whose names appear as {name} in the
URL
+ MapExpression pathParams = new MapExpression()
+ MapExpression queryParams = new MapExpression()
+ Expression bodyExpr = constX(null)
+
+ for (Parameter p : params) {
+ if (urlTemplate.contains("{${p.name}}")) {
+ pathParams.addMapEntryExpression(
+ new MapEntryExpression(constX(p.name), varX(p)))
+ } else if (hasAnnotation(p, Body)) {
+ bodyExpr = varX(p)
+ } else {
+ // Query parameter — use @Query name if specified, else param
name
+ String queryName = getQueryParamName(p)
+ queryParams.addMapEntryExpression(
+ new MapEntryExpression(constX(queryName), varX(p)))
+ }
+ }
+
+ String executeMethod = isAsync ? 'executeAsync' : 'execute'
+ Expression callExpr = callX(fieldX(helperField), executeMethod, args(
+ constX(httpMethod),
+ constX(urlTemplate),
+ classX(make(effectiveReturnType)),
+ pathParams,
+ queryParams,
+ buildHeadersMapExpression(methodHeaders),
+ bodyExpr
+ ))
+
+ Statement body = returnS(callExpr)
+
+ return new MethodNode(method.name, Opcodes.ACC_PUBLIC,
method.returnType,
+ cloneParams(params), ClassNode.EMPTY_ARRAY, body)
+ }
+
+ private void addCreateMethod(ClassNode interfaceNode, ClassNode implClass,
String baseUrl) {
+ // create() — uses the annotation URL
+ Statement noArgBody = returnS(ctorX(implClass, args(constX(baseUrl))))
+ MethodNode createNoArg = new MethodNode('create',
+ Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC,
interfaceNode.getPlainNodeReference(),
+ Parameter.EMPTY_ARRAY, ClassNode.EMPTY_ARRAY, noArgBody)
+ interfaceNode.addMethod(createNoArg)
+
+ // create(String baseUrl) — override URL
+ Parameter baseUrlParam = param(STRING_TYPE, 'baseUrl')
+ Statement withArgBody = returnS(ctorX(implClass,
args(varX(baseUrlParam))))
+ MethodNode createWithArg = new MethodNode('create',
+ Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC,
interfaceNode.getPlainNodeReference(),
+ params(baseUrlParam), ClassNode.EMPTY_ARRAY, withArgBody)
+ interfaceNode.addMethod(createWithArg)
+ }
+
+ private static Map<String, String> collectHeaders(AnnotatedNode node) {
+ Map<String, String> headers = new LinkedHashMap<>()
+ for (AnnotationNode anno : node.getAnnotations(make(Header))) {
+ headers[getMemberStringValue(anno, 'name')] =
getMemberStringValue(anno, 'value')
+ }
+ for (AnnotationNode anno : node.getAnnotations(make(Headers))) {
+ Expression members = anno.getMember('value')
+ if (members instanceof ListExpression) {
+ for (Expression expr : ((ListExpression) members).expressions)
{
+ if (expr instanceof AnnotationConstantExpression) {
+ AnnotationNode inner = ((AnnotationConstantExpression)
expr).value
+ headers[getMemberStringValue(inner, 'name')] =
getMemberStringValue(inner, 'value')
+ }
+ }
+ }
+ }
+ return headers
+ }
+
+ private static MapExpression buildHeadersMapExpression(Map<String, String>
headers) {
+ MapExpression map = new MapExpression()
+ headers.each { k, v ->
+ map.addMapEntryExpression(new MapEntryExpression(constX(k),
constX(v)))
+ }
+ return map
+ }
+
+ private static boolean isAsyncReturn(ClassNode returnType) {
+ return returnType.name == CompletableFuture.name ||
+ returnType.redirect()?.name == CompletableFuture.name
+ }
+
+ private static Class<?> resolveReturnType(ClassNode returnType) {
+ if (isAsyncReturn(returnType)) {
+ GenericsType[] generics = returnType.genericsTypes
+ if (generics?.length == 1) {
+ return generics[0].type.typeClass
+ }
+ return Object
+ }
+ if (returnType == VOID_TYPE || returnType.name == 'void') return void
+ try {
+ return returnType.typeClass
+ } catch (Exception ignored) {
+ return Object
+ }
+ }
+
Review Comment:
`resolveReturnType` uses `ClassNode.typeClass` (and for async generics calls
it without a try/catch). This can trigger classloading during compilation and
may fail for types not yet loadable (e.g. `CompletableFuture<MyDto>` where
`MyDto` is compiled in the same unit), degrading to `Object` or throwing.
Consider keeping this as a `ClassNode` and generating a class literal
expression from the AST type, instead of converting to `Class<?>` via
`typeClass`.
```suggestion
private static ClassNode resolveReturnType(ClassNode returnType) {
if (isAsyncReturn(returnType)) {
GenericsType[] generics = returnType.genericsTypes
if (generics?.length == 1 && generics[0]?.type != null) {
return generics[0].type.getPlainNodeReference()
}
return OBJECT_TYPE
}
if (returnType == VOID_TYPE || returnType.name == 'void') return
VOID_TYPE
return returnType?.getPlainNodeReference() ?: OBJECT_TYPE
}
private static ClassExpression buildReturnTypeClassExpression(ClassNode
returnType) {
return classX(resolveReturnType(returnType))
}
```
##########
subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpBuilderClientTransform.groovy:
##########
@@ -0,0 +1,254 @@
+/*
+ * 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 org.codehaus.groovy.ast.*
+import org.codehaus.groovy.ast.expr.*
+import org.codehaus.groovy.ast.stmt.*
+import org.codehaus.groovy.control.CompilePhase
+import org.codehaus.groovy.control.SourceUnit
+import org.codehaus.groovy.transform.AbstractASTTransformation
+import org.codehaus.groovy.transform.GroovyASTTransformation
+import org.objectweb.asm.Opcodes
+
+import java.util.concurrent.CompletableFuture
+
+import static org.codehaus.groovy.ast.ClassHelper.*
+import static org.codehaus.groovy.ast.tools.GeneralUtils.*
+
+/**
+ * AST transform that generates an implementation class for interfaces
+ * annotated with {@link HttpBuilderClient}.
+ *
+ * @since 6.0.0
+ */
+@GroovyASTTransformation(phase = CompilePhase.CANONICALIZATION)
+class HttpBuilderClientTransform extends AbstractASTTransformation {
+
+ private static final ClassNode HELPER_TYPE = make(HttpClientHelper)
+ private static final ClassNode FUTURE_TYPE = make(CompletableFuture)
+ private static final ClassNode HTTP_RESULT_TYPE = make(HttpResult)
+
+ private static final Map<String, String> HTTP_METHOD_ANNOTATIONS = [
+ 'groovy.http.Get' : 'GET',
+ 'groovy.http.Post' : 'POST',
+ 'groovy.http.Put' : 'PUT',
+ 'groovy.http.Delete': 'DELETE',
+ 'groovy.http.Patch' : 'PATCH',
+ ]
+
+ @Override
+ void visit(ASTNode[] nodes, SourceUnit source) {
+ init(nodes, source)
+ AnnotationNode anno = (AnnotationNode) nodes[0]
+ AnnotatedNode target = (AnnotatedNode) nodes[1]
+
+ if (!(target instanceof ClassNode) || !target.isInterface()) {
+ addError("@HttpBuilderClient can only be applied to interfaces",
target)
+ return
+ }
+
+ ClassNode interfaceNode = (ClassNode) target
+ String baseUrl = getMemberStringValue(anno, 'value')
+ if (!baseUrl) {
+ addError("@HttpBuilderClient requires a base URL", anno)
+ return
+ }
+
+ // Collect interface-level @Header annotations
+ Map<String, String> interfaceHeaders = collectHeaders(interfaceNode)
+
+ // Generate the implementation class
+ ClassNode implClass = generateImplClass(interfaceNode, baseUrl,
interfaceHeaders)
+ source.AST.addClass(implClass)
+
+ // Add static create() factory method to the interface
+ addCreateMethod(interfaceNode, implClass, baseUrl)
+ }
+
+ private ClassNode generateImplClass(ClassNode interfaceNode, String
baseUrl, Map<String, String> interfaceHeaders) {
+ String implName = interfaceNode.name + '$Client'
+ ClassNode implClass = new ClassNode(implName, Opcodes.ACC_PUBLIC |
Opcodes.ACC_SYNTHETIC,
+ OBJECT_TYPE, [interfaceNode.getPlainNodeReference()] as
ClassNode[], null)
+ implClass.sourcePosition = interfaceNode
+
+ // Field: private final HttpClientHelper __helper
+ FieldNode helperField = implClass.addField('__helper',
Opcodes.ACC_PRIVATE | Opcodes.ACC_FINAL,
+ HELPER_TYPE, null)
+
+ // Constructor: takes baseUrl string with default
+ Parameter baseUrlParam = param(STRING_TYPE, 'baseUrl')
+ baseUrlParam.setInitialExpression(constX(baseUrl))
+ BlockStatement ctorBody = block(
+ assignS(fieldX(helperField),
+ ctorX(HELPER_TYPE, args(varX(baseUrlParam),
buildHeadersMapExpression(interfaceHeaders))))
+ )
+ implClass.addConstructor(Opcodes.ACC_PUBLIC, params(baseUrlParam),
ClassNode.EMPTY_ARRAY, ctorBody)
+
+ // Generate a method for each abstract interface method
+ for (MethodNode method : interfaceNode.abstractMethods) {
+ String httpMethod = null
+ String urlTemplate = null
+
+ for (Map.Entry<String, String> entry : HTTP_METHOD_ANNOTATIONS) {
+ AnnotationNode methodAnno =
method.getAnnotations(make(entry.key)).find()
+ if (methodAnno) {
+ httpMethod = entry.value
+ urlTemplate = getMemberStringValue(methodAnno, 'value')
+ break
+ }
+ }
+
+ if (!httpMethod || !urlTemplate) continue
+
+ Map<String, String> methodHeaders = collectHeaders(method)
+ MethodNode implMethod = generateMethod(method, httpMethod,
urlTemplate,
+ methodHeaders, helperField)
+ implClass.addMethod(implMethod)
+ }
Review Comment:
If an interface method lacks an HTTP method annotation (or has one but no
URL), the transform currently `continue`s and silently omits the
implementation. Since the impl class still implements the interface, this leads
to a later/cryptic compilation failure. Emit a transform error when
encountering an abstract method without exactly one supported HTTP annotation.
##########
subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpBuilderClientTransform.groovy:
##########
@@ -0,0 +1,254 @@
+/*
+ * 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 org.codehaus.groovy.ast.*
+import org.codehaus.groovy.ast.expr.*
+import org.codehaus.groovy.ast.stmt.*
+import org.codehaus.groovy.control.CompilePhase
+import org.codehaus.groovy.control.SourceUnit
+import org.codehaus.groovy.transform.AbstractASTTransformation
+import org.codehaus.groovy.transform.GroovyASTTransformation
+import org.objectweb.asm.Opcodes
+
+import java.util.concurrent.CompletableFuture
+
+import static org.codehaus.groovy.ast.ClassHelper.*
+import static org.codehaus.groovy.ast.tools.GeneralUtils.*
+
+/**
+ * AST transform that generates an implementation class for interfaces
+ * annotated with {@link HttpBuilderClient}.
+ *
+ * @since 6.0.0
+ */
+@GroovyASTTransformation(phase = CompilePhase.CANONICALIZATION)
+class HttpBuilderClientTransform extends AbstractASTTransformation {
+
+ private static final ClassNode HELPER_TYPE = make(HttpClientHelper)
+ private static final ClassNode FUTURE_TYPE = make(CompletableFuture)
+ private static final ClassNode HTTP_RESULT_TYPE = make(HttpResult)
+
+ private static final Map<String, String> HTTP_METHOD_ANNOTATIONS = [
+ 'groovy.http.Get' : 'GET',
+ 'groovy.http.Post' : 'POST',
+ 'groovy.http.Put' : 'PUT',
+ 'groovy.http.Delete': 'DELETE',
+ 'groovy.http.Patch' : 'PATCH',
+ ]
+
+ @Override
+ void visit(ASTNode[] nodes, SourceUnit source) {
+ init(nodes, source)
+ AnnotationNode anno = (AnnotationNode) nodes[0]
+ AnnotatedNode target = (AnnotatedNode) nodes[1]
+
+ if (!(target instanceof ClassNode) || !target.isInterface()) {
+ addError("@HttpBuilderClient can only be applied to interfaces",
target)
+ return
+ }
+
+ ClassNode interfaceNode = (ClassNode) target
+ String baseUrl = getMemberStringValue(anno, 'value')
+ if (!baseUrl) {
+ addError("@HttpBuilderClient requires a base URL", anno)
+ return
+ }
+
+ // Collect interface-level @Header annotations
+ Map<String, String> interfaceHeaders = collectHeaders(interfaceNode)
+
+ // Generate the implementation class
+ ClassNode implClass = generateImplClass(interfaceNode, baseUrl,
interfaceHeaders)
+ source.AST.addClass(implClass)
+
+ // Add static create() factory method to the interface
+ addCreateMethod(interfaceNode, implClass, baseUrl)
+ }
+
+ private ClassNode generateImplClass(ClassNode interfaceNode, String
baseUrl, Map<String, String> interfaceHeaders) {
+ String implName = interfaceNode.name + '$Client'
+ ClassNode implClass = new ClassNode(implName, Opcodes.ACC_PUBLIC |
Opcodes.ACC_SYNTHETIC,
+ OBJECT_TYPE, [interfaceNode.getPlainNodeReference()] as
ClassNode[], null)
+ implClass.sourcePosition = interfaceNode
+
+ // Field: private final HttpClientHelper __helper
+ FieldNode helperField = implClass.addField('__helper',
Opcodes.ACC_PRIVATE | Opcodes.ACC_FINAL,
+ HELPER_TYPE, null)
+
+ // Constructor: takes baseUrl string with default
+ Parameter baseUrlParam = param(STRING_TYPE, 'baseUrl')
+ baseUrlParam.setInitialExpression(constX(baseUrl))
+ BlockStatement ctorBody = block(
+ assignS(fieldX(helperField),
+ ctorX(HELPER_TYPE, args(varX(baseUrlParam),
buildHeadersMapExpression(interfaceHeaders))))
+ )
+ implClass.addConstructor(Opcodes.ACC_PUBLIC, params(baseUrlParam),
ClassNode.EMPTY_ARRAY, ctorBody)
+
+ // Generate a method for each abstract interface method
+ for (MethodNode method : interfaceNode.abstractMethods) {
+ String httpMethod = null
+ String urlTemplate = null
+
+ for (Map.Entry<String, String> entry : HTTP_METHOD_ANNOTATIONS) {
+ AnnotationNode methodAnno =
method.getAnnotations(make(entry.key)).find()
+ if (methodAnno) {
+ httpMethod = entry.value
+ urlTemplate = getMemberStringValue(methodAnno, 'value')
+ break
+ }
+ }
+
+ if (!httpMethod || !urlTemplate) continue
+
+ Map<String, String> methodHeaders = collectHeaders(method)
+ MethodNode implMethod = generateMethod(method, httpMethod,
urlTemplate,
+ methodHeaders, helperField)
+ implClass.addMethod(implMethod)
+ }
+
+ return implClass
+ }
+
+ private MethodNode generateMethod(MethodNode method, String httpMethod,
String urlTemplate,
+ Map<String, String> methodHeaders,
FieldNode helperField) {
+ Parameter[] params = method.parameters
+ boolean isAsync = isAsyncReturn(method.returnType)
+ Class<?> effectiveReturnType = resolveReturnType(method.returnType)
+
+ // Build path params map: params whose names appear as {name} in the
URL
+ MapExpression pathParams = new MapExpression()
+ MapExpression queryParams = new MapExpression()
+ Expression bodyExpr = constX(null)
+
+ for (Parameter p : params) {
+ if (urlTemplate.contains("{${p.name}}")) {
+ pathParams.addMapEntryExpression(
+ new MapEntryExpression(constX(p.name), varX(p)))
+ } else if (hasAnnotation(p, Body)) {
+ bodyExpr = varX(p)
+ } else {
+ // Query parameter — use @Query name if specified, else param
name
+ String queryName = getQueryParamName(p)
+ queryParams.addMapEntryExpression(
+ new MapEntryExpression(constX(queryName), varX(p)))
+ }
+ }
+
+ String executeMethod = isAsync ? 'executeAsync' : 'execute'
+ Expression callExpr = callX(fieldX(helperField), executeMethod, args(
+ constX(httpMethod),
+ constX(urlTemplate),
+ classX(make(effectiveReturnType)),
+ pathParams,
+ queryParams,
+ buildHeadersMapExpression(methodHeaders),
+ bodyExpr
+ ))
+
+ Statement body = returnS(callExpr)
+
+ return new MethodNode(method.name, Opcodes.ACC_PUBLIC,
method.returnType,
+ cloneParams(params), ClassNode.EMPTY_ARRAY, body)
+ }
+
+ private void addCreateMethod(ClassNode interfaceNode, ClassNode implClass,
String baseUrl) {
+ // create() — uses the annotation URL
+ Statement noArgBody = returnS(ctorX(implClass, args(constX(baseUrl))))
+ MethodNode createNoArg = new MethodNode('create',
+ Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC,
interfaceNode.getPlainNodeReference(),
+ Parameter.EMPTY_ARRAY, ClassNode.EMPTY_ARRAY, noArgBody)
+ interfaceNode.addMethod(createNoArg)
+
+ // create(String baseUrl) — override URL
+ Parameter baseUrlParam = param(STRING_TYPE, 'baseUrl')
+ Statement withArgBody = returnS(ctorX(implClass,
args(varX(baseUrlParam))))
+ MethodNode createWithArg = new MethodNode('create',
+ Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC,
interfaceNode.getPlainNodeReference(),
+ params(baseUrlParam), ClassNode.EMPTY_ARRAY, withArgBody)
+ interfaceNode.addMethod(createWithArg)
+ }
+
Review Comment:
The transform unconditionally adds static `create()` overloads. If a user
defines their own `create` method on the interface, this will cause a
duplicate-method compilation error. Consider checking for existing `create`
signatures before adding, and/or using a less collision-prone name.
```suggestion
if (!hasMethodSignature(interfaceNode, 'create')) {
Statement noArgBody = returnS(ctorX(implClass,
args(constX(baseUrl))))
MethodNode createNoArg = new MethodNode('create',
Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC,
interfaceNode.getPlainNodeReference(),
Parameter.EMPTY_ARRAY, ClassNode.EMPTY_ARRAY, noArgBody)
interfaceNode.addMethod(createNoArg)
}
// create(String baseUrl) — override URL
Parameter baseUrlParam = param(STRING_TYPE, 'baseUrl')
if (!hasMethodSignature(interfaceNode, 'create', STRING_TYPE)) {
Statement withArgBody = returnS(ctorX(implClass,
args(varX(baseUrlParam))))
MethodNode createWithArg = new MethodNode('create',
Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC,
interfaceNode.getPlainNodeReference(),
params(baseUrlParam), ClassNode.EMPTY_ARRAY, withArgBody)
interfaceNode.addMethod(createWithArg)
}
}
private static boolean hasMethodSignature(ClassNode node, String name,
ClassNode... parameterTypes) {
node.methods.any { MethodNode method ->
method.name == name &&
method.parameters.length == parameterTypes.length &&
method.parameters.indices.every { int i ->
method.parameters[i].type == parameterTypes[i] ||
method.parameters[i].type?.name ==
parameterTypes[i]?.name
}
}
}
```
##########
subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpClientHelper.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 org.apache.groovy.lang.annotation.Incubating
+
+import java.util.concurrent.CompletableFuture
+
+/**
+ * Runtime helper used by the generated declarative HTTP client
implementations.
+ * Not intended for direct use.
+ *
+ * @since 6.0.0
+ */
+@Incubating
+final class HttpClientHelper {
+ private final HttpBuilder http
+ private final Map<String, String> defaultHeaders
+
+ HttpClientHelper(String baseUrl, Map<String, String> defaultHeaders) {
+ this.http = HttpBuilder.http(baseUrl)
+ this.defaultHeaders = Collections.unmodifiableMap(new
LinkedHashMap<>(defaultHeaders))
+ }
+
+ /**
+ * Execute an HTTP request synchronously.
+ *
+ * @param method HTTP method (GET, POST, PUT, DELETE, PATCH)
+ * @param urlTemplate URL template with {param} placeholders
+ * @param returnType the method's return type for response conversion
+ * @param pathParams map of path parameter names to values
+ * @param queryParams map of query parameter names to values
+ * @param headers map of additional headers for this request
+ * @param body request body (serialized as JSON if non-null)
+ * @return the converted response
+ */
+ Object execute(String method, String urlTemplate, Class<?> returnType,
+ Map<String, Object> pathParams, Map<String, Object>
queryParams,
+ Map<String, String> headers, Object body) {
+ String url = resolveUrl(urlTemplate, pathParams)
+ HttpResult result = http.request(method, url) {
+ defaultHeaders.each { k, v -> header(k, v) }
+ headers.each { k, v -> header(k, v) }
+ queryParams.each { k, v -> query(k, v) }
+ if (body != null) { json(body) }
+ }
+ return convertResult(result, returnType)
+ }
+
+ /**
+ * Execute an HTTP request asynchronously.
+ *
+ * @return a CompletableFuture containing the converted response
+ */
+ CompletableFuture<Object> executeAsync(String method, String urlTemplate,
Class<?> returnType,
+ Map<String, Object> pathParams,
Map<String, Object> queryParams,
+ Map<String, String> headers, Object
body) {
+ CompletableFuture.supplyAsync {
+ execute(method, urlTemplate, returnType, pathParams, queryParams,
headers, body)
+ }
+ }
Review Comment:
`executeAsync` wraps the synchronous, blocking `HttpClient.send(...)` call
in `CompletableFuture.supplyAsync(...)` without specifying an executor, which
uses the common pool. Blocking I/O on the common pool can lead to thread
starvation under load. Prefer `HttpClient.sendAsync(...)` (or a dedicated
executor) for async behavior.
##########
subprojects/groovy-http-builder/src/test/groovy/groovy/http/HttpBuilderClientTest.groovy:
##########
@@ -0,0 +1,223 @@
+/*
+ * 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 groovy.json.JsonOutput
+import groovy.json.JsonSlurper
+import groovy.test.GroovyTestCase
+
+import java.util.concurrent.CompletableFuture
+
+class HttpBuilderClientTest extends GroovyTestCase {
+
+ static HttpServer server
+ static int port
+
+ static void setUpClass() {
+ server = HttpServer.create(new InetSocketAddress(0), 0)
+ port = server.address.port
+
+ server.createContext('/users') { exchange ->
+ String path = exchange.requestURI.path
+ String method = exchange.requestMethod
+
+ if (method == 'GET' && path =~ '/users/\\w+') {
+ String username = path.split('/')[-1]
+ respond(exchange, 200, [name: username, bio: "Bio of
${username}"])
+ } else if (method == 'GET') {
+ def query = parseQuery(exchange.requestURI.query)
+ def users = [[name: 'Alice'], [name: 'Bob']]
+ if (query.name) {
+ users = users.findAll { it.name == query.name }
+ }
+ respond(exchange, 200, users)
+ } else if (method == 'POST') {
+ def body = new
JsonSlurper().parseText(exchange.requestBody.text)
+ body.id = 42
+ respond(exchange, 201, body)
+ } else {
+ respond(exchange, 404, [error: 'not found'])
+ }
+ }
+
+ server.createContext('/echo-headers') { exchange ->
+ def headers = [:]
+ exchange.requestHeaders.each { k, v -> headers[k] = v.join(', ') }
+ respond(exchange, 200, headers)
+ }
+
+ server.createContext('/items') { exchange ->
+ String method = exchange.requestMethod
+ String path = exchange.requestURI.path
+ if (method == 'PUT' && path =~ '/items/\\d+') {
+ def body = new
JsonSlurper().parseText(exchange.requestBody.text)
+ respond(exchange, 200, body)
+ } else if (method == 'DELETE' && path =~ '/items/\\d+') {
+ respond(exchange, 204, null)
+ } else {
+ respond(exchange, 404, [error: 'not found'])
+ }
+ }
+
+ server.start()
+ }
+
+ static void tearDownClass() {
+ server?.stop(0)
+ }
+
+ private static void respond(com.sun.net.httpserver.HttpExchange exchange,
int status, Object body) {
+ String json = body != null ? JsonOutput.toJson(body) : ''
+ exchange.responseHeaders.set('Content-Type', 'application/json')
+ exchange.sendResponseHeaders(status, json ? json.bytes.length : -1)
+ if (json) {
+ exchange.responseBody.write(json.bytes)
+ }
Review Comment:
`respond(...)` calculates `Content-Length` using `json.bytes.length` and
writes `json.bytes` without specifying a charset, which depends on the platform
default encoding. Use a fixed charset (e.g. UTF-8) for consistent length
calculation and response bytes.
##########
subprojects/groovy-http-builder/src/test/groovy/groovy/http/HttpBuilderClientTest.groovy:
##########
@@ -0,0 +1,223 @@
+/*
+ * 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 groovy.json.JsonOutput
+import groovy.json.JsonSlurper
+import groovy.test.GroovyTestCase
+
+import java.util.concurrent.CompletableFuture
+
+class HttpBuilderClientTest extends GroovyTestCase {
+
+ static HttpServer server
+ static int port
+
+ static void setUpClass() {
+ server = HttpServer.create(new InetSocketAddress(0), 0)
+ port = server.address.port
+
+ server.createContext('/users') { exchange ->
+ String path = exchange.requestURI.path
+ String method = exchange.requestMethod
+
+ if (method == 'GET' && path =~ '/users/\\w+') {
+ String username = path.split('/')[-1]
+ respond(exchange, 200, [name: username, bio: "Bio of
${username}"])
+ } else if (method == 'GET') {
+ def query = parseQuery(exchange.requestURI.query)
+ def users = [[name: 'Alice'], [name: 'Bob']]
+ if (query.name) {
+ users = users.findAll { it.name == query.name }
+ }
+ respond(exchange, 200, users)
+ } else if (method == 'POST') {
+ def body = new
JsonSlurper().parseText(exchange.requestBody.text)
+ body.id = 42
+ respond(exchange, 201, body)
+ } else {
+ respond(exchange, 404, [error: 'not found'])
+ }
+ }
+
+ server.createContext('/echo-headers') { exchange ->
+ def headers = [:]
+ exchange.requestHeaders.each { k, v -> headers[k] = v.join(', ') }
+ respond(exchange, 200, headers)
+ }
+
+ server.createContext('/items') { exchange ->
+ String method = exchange.requestMethod
+ String path = exchange.requestURI.path
+ if (method == 'PUT' && path =~ '/items/\\d+') {
+ def body = new
JsonSlurper().parseText(exchange.requestBody.text)
+ respond(exchange, 200, body)
+ } else if (method == 'DELETE' && path =~ '/items/\\d+') {
+ respond(exchange, 204, null)
+ } else {
+ respond(exchange, 404, [error: 'not found'])
+ }
+ }
+
+ server.start()
+ }
+
+ static void tearDownClass() {
+ server?.stop(0)
+ }
+
+ private static void respond(com.sun.net.httpserver.HttpExchange exchange,
int status, Object body) {
+ String json = body != null ? JsonOutput.toJson(body) : ''
+ exchange.responseHeaders.set('Content-Type', 'application/json')
+ exchange.sendResponseHeaders(status, json ? json.bytes.length : -1)
+ if (json) {
+ exchange.responseBody.write(json.bytes)
+ }
+ exchange.close()
+ }
+
+ private static Map<String, String> parseQuery(String query) {
+ if (!query) return [:]
+ query.split('&').collectEntries { String pair ->
+ def parts = pair.split('=', 2)
+ [(URLDecoder.decode(parts[0], 'UTF-8')): parts.length > 1 ?
URLDecoder.decode(parts[1], 'UTF-8') : '']
+ }
+ }
+
+ // -
> Provide a minimal declarative http client for the new http builder module
> -------------------------------------------------------------------------
>
> Key: GROOVY-11924
> URL: https://issues.apache.org/jira/browse/GROOVY-11924
> Project: Groovy
> Issue Type: New Feature
> Reporter: Paul King
> Assignee: Paul King
> Priority: Major
>
--
This message was sent by Atlassian Jira
(v8.20.10#820010)