This is an automated email from the ASF dual-hosted git repository. paulk pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/groovy.git
commit 4119b4ad01e6e59a1fce02dc6140a7d74891ce04 Author: Paul King <[email protected]> AuthorDate: Tue Apr 14 07:07:19 2026 +1000 GROOVY-11924: Provide a minimal declarative http client for the new http builder module (better align feature parity between declarative and imperative sides) --- .../src/main/groovy/groovy/http/HttpBuilder.groovy | 90 +++++- .../groovy/http/HttpBuilderClientTransform.groovy | 95 +++++- .../groovy/groovy/http/HttpClientHelper.groovy | 137 +++++++-- .../http/{HttpBuilderClient.java => BodyText.java} | 27 +- .../http/{HttpBuilderClient.java => Form.java} | 27 +- .../main/java/groovy/http/HttpBuilderClient.java | 9 + .../http/{HttpBuilderClient.java => Timeout.java} | 29 +- .../src/spec/doc/http-builder.adoc | 215 +++++++++++++- .../groovy/http/HttpBuilderClientTest.groovy | 321 +++++++++++++++++++++ 9 files changed, 839 insertions(+), 111 deletions(-) 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 index 8e8ba1e009..51ae82124e 100644 --- a/subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpBuilder.groovy +++ b/subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpBuilder.groovy @@ -30,6 +30,7 @@ import java.net.http.HttpRequest import java.net.http.HttpResponse import java.nio.charset.StandardCharsets import java.time.Duration +import java.util.concurrent.CompletableFuture /** * Tiny DSL over JDK {@link HttpClient}. @@ -49,6 +50,12 @@ final class HttpBuilder { if (config.followRedirects) { clientBuilder.followRedirects(HttpClient.Redirect.NORMAL) } + if (config.clientConfigurer != null) { + Closure<?> code = (Closure<?>) config.clientConfigurer.clone() + code.resolveStrategy = Closure.DELEGATE_FIRST + code.delegate = clientBuilder + code.call(clientBuilder) + } client = clientBuilder.build() baseUri = config.baseUri defaultHeaders = Collections.unmodifiableMap(new LinkedHashMap<>(config.headers)) @@ -97,10 +104,69 @@ final class HttpBuilder { return request('DELETE', uri, spec) } + HttpResult patch(final Object uri = null, + @DelegatesTo(value = RequestSpec, strategy = Closure.DELEGATE_FIRST) + final Closure<?> spec = null) { + return request('PATCH', uri, spec) + } + HttpResult request(final String method, final Object uri, @DelegatesTo(value = RequestSpec, strategy = Closure.DELEGATE_FIRST) final Closure<?> spec = null) { + def (HttpRequest httpRequest, HttpResponse.BodyHandler<String> bodyHandler) = buildRequest(method, uri, spec) + HttpResponse<String> response + try { + response = client.send(httpRequest, bodyHandler) + } catch (InterruptedException e) { + Thread.currentThread().interrupt() + throw new RuntimeException("HTTP request " + method + " " + httpRequest.uri() + " was interrupted", e) + } catch (IOException e) { + throw new RuntimeException("I/O error during HTTP request " + method + " " + httpRequest.uri(), e) + } + return new HttpResult(response) + } + + CompletableFuture<HttpResult> requestAsync(final String method, + final Object uri, + @DelegatesTo(value = RequestSpec, strategy = Closure.DELEGATE_FIRST) + final Closure<?> spec = null) { + def (HttpRequest httpRequest, HttpResponse.BodyHandler<String> bodyHandler) = buildRequest(method, uri, spec) + return client.sendAsync(httpRequest, bodyHandler) + .thenApply { HttpResponse<String> response -> new HttpResult(response) } + } + + CompletableFuture<HttpResult> getAsync(final Object uri = null, + @DelegatesTo(value = RequestSpec, strategy = Closure.DELEGATE_FIRST) + final Closure<?> spec = null) { + return requestAsync('GET', uri, spec) + } + + CompletableFuture<HttpResult> postAsync(final Object uri = null, + @DelegatesTo(value = RequestSpec, strategy = Closure.DELEGATE_FIRST) + final Closure<?> spec = null) { + return requestAsync('POST', uri, spec) + } + + CompletableFuture<HttpResult> putAsync(final Object uri = null, + @DelegatesTo(value = RequestSpec, strategy = Closure.DELEGATE_FIRST) + final Closure<?> spec = null) { + return requestAsync('PUT', uri, spec) + } + + CompletableFuture<HttpResult> deleteAsync(final Object uri = null, + @DelegatesTo(value = RequestSpec, strategy = Closure.DELEGATE_FIRST) + final Closure<?> spec = null) { + return requestAsync('DELETE', uri, spec) + } + + CompletableFuture<HttpResult> patchAsync(final Object uri = null, + @DelegatesTo(value = RequestSpec, strategy = Closure.DELEGATE_FIRST) + final Closure<?> spec = null) { + return requestAsync('PATCH', uri, spec) + } + + private List buildRequest(final String method, final Object uri, final Closure<?> spec) { RequestSpec requestSpec = new RequestSpec() if (spec != null) { Closure<?> code = (Closure<?>) spec.clone() @@ -126,16 +192,7 @@ final class HttpBuilder { 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) + return [requestBuilder.build(), requestSpec.bodyHandler] } private URI resolveUri(final Object uri, final Map<String, Object> query) { @@ -215,6 +272,7 @@ final class HttpBuilder { Duration requestTimeout boolean followRedirects final Map<String, String> headers = [:] + Closure<?> clientConfigurer void baseUri(final Object value) { URI candidate = value instanceof URI ? (URI) value : URI.create(value.toString()) @@ -240,6 +298,18 @@ final class HttpBuilder { void headers(final Map<String, ?> values) { values.each { String name, Object value -> header(name, value) } } + + /** + * Provides direct access to the underlying {@code HttpClient.Builder} + * for advanced configuration (authenticator, SSL context, proxy, cookie handler, etc.). + * + * @param configurer a closure taking an {@code HttpClient.Builder} + */ + void clientConfig( + @DelegatesTo(value = HttpClient.Builder, strategy = Closure.DELEGATE_FIRST) + final Closure<?> configurer) { + this.clientConfigurer = configurer + } } static final class RequestSpec { diff --git a/subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpBuilderClientTransform.groovy b/subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpBuilderClientTransform.groovy index 93818040df..f424c5ad03 100644 --- a/subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpBuilderClientTransform.groovy +++ b/subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpBuilderClientTransform.groovy @@ -71,18 +71,24 @@ class HttpBuilderClientTransform extends AbstractASTTransformation { return } + int connectTimeout = getMemberIntValue(anno, 'connectTimeout') + int requestTimeout = getMemberIntValue(anno, 'requestTimeout') + boolean followRedirects = memberHasValue(anno, 'followRedirects', true) + // Collect interface-level @Header annotations Map<String, String> interfaceHeaders = collectHeaders(interfaceNode) // Generate the implementation class - ClassNode implClass = generateImplClass(interfaceNode, baseUrl, interfaceHeaders) + ClassNode implClass = generateImplClass(interfaceNode, baseUrl, interfaceHeaders, + connectTimeout, requestTimeout, followRedirects) 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) { + private ClassNode generateImplClass(ClassNode interfaceNode, String baseUrl, Map<String, String> interfaceHeaders, + int connectTimeout, int requestTimeout, boolean followRedirects) { String implName = interfaceNode.name + '$Client' ClassNode implClass = new ClassNode(implName, Opcodes.ACC_PUBLIC | Opcodes.ACC_SYNTHETIC, OBJECT_TYPE, [interfaceNode.getPlainNodeReference()] as ClassNode[], null) @@ -97,10 +103,27 @@ class HttpBuilderClientTransform extends AbstractASTTransformation { baseUrlParam.setInitialExpression(constX(baseUrl)) BlockStatement ctorBody = block( assignS(fieldX(helperField), - ctorX(HELPER_TYPE, args(varX(baseUrlParam), buildHeadersMapExpression(interfaceHeaders)))) + ctorX(HELPER_TYPE, args( + varX(baseUrlParam), + buildHeadersMapExpression(interfaceHeaders), + constX(connectTimeout), + constX(requestTimeout), + constX(followRedirects) + ))) ) implClass.addConstructor(Opcodes.ACC_PUBLIC, params(baseUrlParam), ClassNode.EMPTY_ARRAY, ctorBody) + // Constructor: takes pre-built HttpBuilder (for create(Closure) factory) + Parameter httpBuilderParam = param(make(HttpBuilder), 'httpBuilder') + BlockStatement ctorBody2 = block( + assignS(fieldX(helperField), + ctorX(HELPER_TYPE, args( + varX(httpBuilderParam), + buildHeadersMapExpression(interfaceHeaders) + ))) + ) + implClass.addConstructor(Opcodes.ACC_PUBLIC, params(httpBuilderParam), ClassNode.EMPTY_ARRAY, ctorBody2) + // Generate a method for each abstract interface method for (MethodNode method : interfaceNode.abstractMethods) { String httpMethod = null @@ -121,8 +144,9 @@ class HttpBuilderClientTransform extends AbstractASTTransformation { } Map<String, String> methodHeaders = collectHeaders(method) + int methodTimeout = getMethodTimeout(method) MethodNode implMethod = generateMethod(method, httpMethod, urlTemplate, - methodHeaders, helperField) + methodHeaders, helperField, methodTimeout) implClass.addMethod(implMethod) } @@ -130,14 +154,19 @@ class HttpBuilderClientTransform extends AbstractASTTransformation { } private MethodNode generateMethod(MethodNode method, String httpMethod, String urlTemplate, - Map<String, String> methodHeaders, FieldNode helperField) { + Map<String, String> methodHeaders, FieldNode helperField, + int methodTimeout) { Parameter[] params = method.parameters boolean isAsync = isAsyncReturn(method.returnType) String returnTypeName = resolveReturnTypeName(method.returnType) + boolean isForm = !method.getAnnotations(make(Form)).isEmpty() + + // Determine body mode: 'json' (default), 'form', or 'text' + String bodyMode = isForm ? 'form' : 'json' // Build path params map: params whose names appear as {name} in the URL MapExpression pathParams = new MapExpression() - MapExpression queryParams = new MapExpression() + MapExpression queryOrFormParams = new MapExpression() Expression bodyExpr = constX(null) for (Parameter p : params) { @@ -146,30 +175,42 @@ class HttpBuilderClientTransform extends AbstractASTTransformation { new MapEntryExpression(constX(p.name), varX(p))) } else if (hasAnnotation(p, Body)) { bodyExpr = varX(p) + } else if (hasAnnotation(p, BodyText)) { + bodyExpr = varX(p) + bodyMode = 'text' } else { - // Query parameter — use @Query name if specified, else param name + // Query parameter (or form field if @Form) — use @Query name if specified, else param name String queryName = getQueryParamName(p) - queryParams.addMapEntryExpression( + queryOrFormParams.addMapEntryExpression( new MapEntryExpression(constX(queryName), varX(p))) } } + // Error type from throws clause (first declared exception with HttpException-compatible constructor) + String errorTypeName = resolveErrorTypeName(method) + String executeMethod = isAsync ? 'executeAsync' : 'execute' Expression callExpr = callX(fieldX(helperField), executeMethod, args( constX(httpMethod), constX(urlTemplate), constX(returnTypeName), pathParams, - queryParams, + queryOrFormParams, buildHeadersMapExpression(methodHeaders), - bodyExpr + bodyExpr, + constX(methodTimeout), + constX(bodyMode), + constX(errorTypeName) )) boolean isVoid = method.returnType == VOID_TYPE || method.returnType.name == 'void' Statement body = isVoid ? block(stmt(callExpr), returnS(constX(null))) : returnS(callExpr) + // Preserve declared exceptions on the generated method + ClassNode[] exceptions = method.exceptions ?: ClassNode.EMPTY_ARRAY + return new MethodNode(method.name, Opcodes.ACC_PUBLIC, method.returnType, - cloneParams(params), ClassNode.EMPTY_ARRAY, body) + cloneParams(params), exceptions, body) } private void addCreateMethod(ClassNode interfaceNode, ClassNode implClass, String baseUrl) { @@ -191,6 +232,19 @@ class HttpBuilderClientTransform extends AbstractASTTransformation { params(baseUrlParam), ClassNode.EMPTY_ARRAY, withArgBody) interfaceNode.addMethod(createWithArg) } + + // create(Closure config) — advanced configuration + // The closure delegates to HttpBuilder.Config, giving access to + // baseUri, headers, timeouts, redirects, clientConfig, etc. + ClassNode closureType = make(Closure).getPlainNodeReference() + Parameter configParam = param(closureType, 'config') + // HttpBuilder.http(config) returns an HttpBuilder; pass it to the HttpBuilder constructor + Expression httpBuilderExpr = callX(make(HttpBuilder), 'http', args(varX(configParam))) + Statement configBody = returnS(ctorX(implClass, args(httpBuilderExpr))) + MethodNode createWithConfig = new MethodNode('create', + Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, interfaceNode.getPlainNodeReference(), + params(configParam), ClassNode.EMPTY_ARRAY, configBody) + interfaceNode.addMethod(createWithConfig) } private static Map<String, String> collectHeaders(AnnotatedNode node) { @@ -246,6 +300,25 @@ class HttpBuilderClientTransform extends AbstractASTTransformation { return p.name } + private static String resolveErrorTypeName(MethodNode method) { + ClassNode[] exceptions = method.exceptions + if (exceptions != null && exceptions.length > 0) { + return exceptions[0].name + } + return '' + } + + private static int getMethodTimeout(MethodNode method) { + AnnotationNode timeoutAnno = method.getAnnotations(make(Timeout)).find() + if (timeoutAnno) { + Expression expr = timeoutAnno.getMember('value') + if (expr instanceof ConstantExpression) { + return ((Number) ((ConstantExpression) expr).value).intValue() + } + } + return 0 + } + private static boolean hasAnnotation(Parameter p, Class<?> annoType) { return !p.getAnnotations(make(annoType)).isEmpty() } diff --git a/subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpClientHelper.groovy b/subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpClientHelper.groovy index 6a16845acc..64886e3b9f 100644 --- a/subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpClientHelper.groovy +++ b/subprojects/groovy-http-builder/src/main/groovy/groovy/http/HttpClientHelper.groovy @@ -20,6 +20,7 @@ package groovy.http import org.apache.groovy.lang.annotation.Incubating +import java.time.Duration import java.util.concurrent.CompletableFuture /** @@ -34,7 +35,27 @@ final class HttpClientHelper { private final Map<String, String> defaultHeaders HttpClientHelper(String baseUrl, Map<String, String> defaultHeaders) { - this.http = HttpBuilder.http(baseUrl) + this(baseUrl, defaultHeaders, 0, 0, false) + } + + HttpClientHelper(String baseUrl, Map<String, String> defaultHeaders, + int connectTimeoutSeconds, int requestTimeoutSeconds, + boolean followRedirects) { + this.http = HttpBuilder.http { + baseUri(baseUrl) + if (connectTimeoutSeconds > 0) connectTimeout(Duration.ofSeconds(connectTimeoutSeconds)) + if (requestTimeoutSeconds > 0) requestTimeout(Duration.ofSeconds(requestTimeoutSeconds)) + if (followRedirects) delegate.followRedirects(true) + } + this.defaultHeaders = Collections.unmodifiableMap(new LinkedHashMap<>(defaultHeaders)) + } + + /** + * Constructor accepting a pre-configured HttpBuilder instance. + * Used by the create(Closure) factory for advanced configuration. + */ + HttpClientHelper(HttpBuilder http, Map<String, String> defaultHeaders) { + this.http = http this.defaultHeaders = Collections.unmodifiableMap(new LinkedHashMap<>(defaultHeaders)) } @@ -51,16 +72,29 @@ final class HttpClientHelper { * @return the converted response */ Object execute(String method, String urlTemplate, String returnTypeName, - Map<String, Object> pathParams, Map<String, Object> queryParams, - Map<String, String> headers, Object body) { + Map<String, Object> pathParams, Map<String, Object> queryOrFormParams, + Map<String, String> headers, Object body, int timeoutSeconds = 0, + String bodyMode = 'json', String errorTypeName = '') { String url = resolveUrl(urlTemplate, pathParams) + boolean isForm = bodyMode == 'form' 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) } + if (isForm) { + form(queryOrFormParams) + } else { + queryOrFormParams.each { k, v -> query(k, v) } + } + if (timeoutSeconds > 0) timeout(Duration.ofSeconds(timeoutSeconds)) + if (body != null) { + switch (bodyMode) { + case 'text': text(body); break + case 'form': break // already handled above + default: json(body) + } + } } - return convertResult(result, returnTypeName) + return convertResult(result, returnTypeName, errorTypeName) } /** @@ -69,10 +103,29 @@ final class HttpClientHelper { * @return a CompletableFuture containing the converted response */ CompletableFuture<Object> executeAsync(String method, String urlTemplate, String returnTypeName, - Map<String, Object> pathParams, Map<String, Object> queryParams, - Map<String, String> headers, Object body) { - CompletableFuture.supplyAsync { - execute(method, urlTemplate, returnTypeName, pathParams, queryParams, headers, body) + Map<String, Object> pathParams, Map<String, Object> queryOrFormParams, + Map<String, String> headers, Object body, int timeoutSeconds = 0, + String bodyMode = 'json', String errorTypeName = '') { + String url = resolveUrl(urlTemplate, pathParams) + boolean isForm = bodyMode == 'form' + return http.requestAsync(method, url) { + defaultHeaders.each { k, v -> header(k, v) } + headers.each { k, v -> header(k, v) } + if (isForm) { + form(queryOrFormParams) + } else { + queryOrFormParams.each { k, v -> query(k, v) } + } + if (timeoutSeconds > 0) timeout(Duration.ofSeconds(timeoutSeconds)) + if (body != null) { + switch (bodyMode) { + case 'text': text(body); break + case 'form': break + default: json(body) + } + } + }.thenApply { HttpResult result -> + convertResult(result, returnTypeName, errorTypeName) } } @@ -84,14 +137,64 @@ final class HttpClientHelper { url } - private static Object convertResult(HttpResult result, String returnTypeName) { + private static Object convertResult(HttpResult result, String returnTypeName, String errorTypeName) { if (result.status() >= 400) { - throw new RuntimeException("HTTP ${result.status()}: ${result.body()}") + handleError(result, errorTypeName) + } + switch (returnTypeName) { + case 'void': return null + case String.name: + case 'java.lang.String': return result.body() + case HttpResult.name: return result + case 'groovy.xml.slurpersupport.GPathResult': return result.xml + case 'org.jsoup.nodes.Document': return result.html + case Map.name: + case 'java.util.Map': + case List.name: + case 'java.util.List': + case Object.name: return result.json + default: + // Typed response — parse JSON then coerce to target type + def json = result.json + try { + return json.asType(Class.forName(returnTypeName)) + } catch (Exception e) { + return json // fallback: return raw parsed JSON + } + } + } + + private static void handleError(HttpResult result, String errorTypeName) { + if (errorTypeName) { + try { + Class<?> errorType = Class.forName(errorTypeName) + Exception error = createError(errorType, result) + if (error != null) throw error + } catch (ClassNotFoundException ignored) { + // fall through to default + } } - if (returnTypeName == HttpResult.name) return result - if (returnTypeName == String.name || returnTypeName == 'java.lang.String') return result.body() - if (returnTypeName == 'void') return null - // Map, List, Object — parse as JSON - return result.json + throw new RuntimeException("HTTP ${result.status()}: ${result.body()}") + } + + private static Exception createError(Class<?> errorType, HttpResult result) { + String message = "HTTP ${result.status()}: ${result.body()}" + // Try constructor(int status, String body) + try { + return (Exception) errorType.getConstructor(int, String).newInstance(result.status(), result.body()) + } catch (ReflectiveOperationException ignored) {} + // Try constructor(Integer status, String body) + try { + return (Exception) errorType.getConstructor(Integer, String).newInstance(result.status(), result.body()) + } catch (ReflectiveOperationException ignored) {} + // Try constructor(String message) + try { + return (Exception) errorType.getConstructor(String).newInstance(message) + } catch (ReflectiveOperationException ignored) {} + // Try no-arg constructor + try { + return (Exception) errorType.getDeclaredConstructor().newInstance() + } catch (ReflectiveOperationException ignored) {} + return null } } diff --git a/subprojects/groovy-http-builder/src/main/java/groovy/http/HttpBuilderClient.java b/subprojects/groovy-http-builder/src/main/java/groovy/http/BodyText.java similarity index 60% copy from subprojects/groovy-http-builder/src/main/java/groovy/http/HttpBuilderClient.java copy to subprojects/groovy-http-builder/src/main/java/groovy/http/BodyText.java index 4282a41929..1b31797b42 100644 --- a/subprojects/groovy-http-builder/src/main/java/groovy/http/HttpBuilderClient.java +++ b/subprojects/groovy-http-builder/src/main/java/groovy/http/BodyText.java @@ -19,7 +19,6 @@ package groovy.http; import org.apache.groovy.lang.annotation.Incubating; -import org.codehaus.groovy.transform.GroovyASTTransformationClass; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; @@ -27,29 +26,13 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** - * Marks an interface as a declarative HTTP client. An implementation class - * is generated at compile time via AST transform, using {@link HttpBuilder} - * for request execution. - * <p> - * Example: - * <pre> - * {@code @HttpBuilderClient}('https://api.example.com') - * interface MyApi { - * {@code @Get}('/users/{id}') - * Map getUser(String id) - * } - * - * def api = MyApi.create() - * def user = api.getUser('123') - * </pre> + * Marks a method parameter as a plain text request body. + * Unlike {@link Body}, the parameter is sent as-is without JSON serialization. * * @since 6.0.0 */ @Incubating -@Retention(RetentionPolicy.SOURCE) -@Target(ElementType.TYPE) -@GroovyASTTransformationClass("groovy.http.HttpBuilderClientTransform") -public @interface HttpBuilderClient { - /** The base URL for all requests. */ - String value(); +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.PARAMETER) +public @interface BodyText { } diff --git a/subprojects/groovy-http-builder/src/main/java/groovy/http/HttpBuilderClient.java b/subprojects/groovy-http-builder/src/main/java/groovy/http/Form.java similarity index 60% copy from subprojects/groovy-http-builder/src/main/java/groovy/http/HttpBuilderClient.java copy to subprojects/groovy-http-builder/src/main/java/groovy/http/Form.java index 4282a41929..bcbb96aacd 100644 --- a/subprojects/groovy-http-builder/src/main/java/groovy/http/HttpBuilderClient.java +++ b/subprojects/groovy-http-builder/src/main/java/groovy/http/Form.java @@ -19,7 +19,6 @@ package groovy.http; import org.apache.groovy.lang.annotation.Incubating; -import org.codehaus.groovy.transform.GroovyASTTransformationClass; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; @@ -27,29 +26,13 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** - * Marks an interface as a declarative HTTP client. An implementation class - * is generated at compile time via AST transform, using {@link HttpBuilder} - * for request execution. - * <p> - * Example: - * <pre> - * {@code @HttpBuilderClient}('https://api.example.com') - * interface MyApi { - * {@code @Get}('/users/{id}') - * Map getUser(String id) - * } - * - * def api = MyApi.create() - * def user = api.getUser('123') - * </pre> + * Marks a method as sending a form-encoded POST body. + * All non-path parameters become form fields instead of query parameters. * * @since 6.0.0 */ @Incubating -@Retention(RetentionPolicy.SOURCE) -@Target(ElementType.TYPE) -@GroovyASTTransformationClass("groovy.http.HttpBuilderClientTransform") -public @interface HttpBuilderClient { - /** The base URL for all requests. */ - String value(); +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface Form { } diff --git a/subprojects/groovy-http-builder/src/main/java/groovy/http/HttpBuilderClient.java b/subprojects/groovy-http-builder/src/main/java/groovy/http/HttpBuilderClient.java index 4282a41929..858639e76d 100644 --- a/subprojects/groovy-http-builder/src/main/java/groovy/http/HttpBuilderClient.java +++ b/subprojects/groovy-http-builder/src/main/java/groovy/http/HttpBuilderClient.java @@ -52,4 +52,13 @@ import java.lang.annotation.Target; public @interface HttpBuilderClient { /** The base URL for all requests. */ String value(); + + /** Connection timeout in seconds. Default 0 means no timeout. */ + int connectTimeout() default 0; + + /** Request timeout in seconds. Default 0 means no timeout. */ + int requestTimeout() default 0; + + /** Whether to follow HTTP redirects. Default is false. */ + boolean followRedirects() default false; } diff --git a/subprojects/groovy-http-builder/src/main/java/groovy/http/HttpBuilderClient.java b/subprojects/groovy-http-builder/src/main/java/groovy/http/Timeout.java similarity index 60% copy from subprojects/groovy-http-builder/src/main/java/groovy/http/HttpBuilderClient.java copy to subprojects/groovy-http-builder/src/main/java/groovy/http/Timeout.java index 4282a41929..17729ab3d6 100644 --- a/subprojects/groovy-http-builder/src/main/java/groovy/http/HttpBuilderClient.java +++ b/subprojects/groovy-http-builder/src/main/java/groovy/http/Timeout.java @@ -19,7 +19,6 @@ package groovy.http; import org.apache.groovy.lang.annotation.Incubating; -import org.codehaus.groovy.transform.GroovyASTTransformationClass; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; @@ -27,29 +26,15 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** - * Marks an interface as a declarative HTTP client. An implementation class - * is generated at compile time via AST transform, using {@link HttpBuilder} - * for request execution. - * <p> - * Example: - * <pre> - * {@code @HttpBuilderClient}('https://api.example.com') - * interface MyApi { - * {@code @Get}('/users/{id}') - * Map getUser(String id) - * } - * - * def api = MyApi.create() - * def user = api.getUser('123') - * </pre> + * Overrides the request timeout for a specific method in an + * {@link HttpBuilderClient} interface. The value is in seconds. * * @since 6.0.0 */ @Incubating -@Retention(RetentionPolicy.SOURCE) -@Target(ElementType.TYPE) -@GroovyASTTransformationClass("groovy.http.HttpBuilderClientTransform") -public @interface HttpBuilderClient { - /** The base URL for all requests. */ - String value(); +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface Timeout { + /** Request timeout in seconds. */ + int value(); } diff --git a/subprojects/groovy-http-builder/src/spec/doc/http-builder.adoc b/subprojects/groovy-http-builder/src/spec/doc/http-builder.adoc index f97e748a66..cf483532c2 100644 --- a/subprojects/groovy-http-builder/src/spec/doc/http-builder.adoc +++ b/subprojects/groovy-http-builder/src/spec/doc/http-builder.adoc @@ -180,6 +180,62 @@ include::../test/HttpBuilderSpecTest.groovy[tags=html_login,indent=0] | raw string body |=== +== Advanced Client Configuration + +The `clientConfig` hook gives direct access to the JDK `HttpClient.Builder` +for advanced configuration — authenticator, SSL context, proxy, cookie handler: + +[source,groovy] +---- +def http = HttpBuilder.http { + baseUri 'https://api.example.com' + header 'Authorization', "Bearer ${token}" + clientConfig { builder -> + builder.authenticator(myAuthenticator) + .sslContext(mySSLContext) + .proxy(ProxySelector.of(new InetSocketAddress('proxy.corp', 8080))) + } +} +---- + +The `clientConfig` closure receives the `HttpClient.Builder` before `build()` +is called, so any JDK-supported configuration is available. + +== Async Requests + +Every HTTP method has an async variant that returns a `CompletableFuture<HttpResult>` +using the JDK `HttpClient.sendAsync()` natively (no extra threads): + +[source,groovy] +---- +def http = HttpBuilder.http('https://api.example.com') + +def future = http.getAsync('/users/alice') +// ... do other work while the request is in flight ... +def result = future.get() +assert result.json.name == 'alice' +---- + +Available methods: `getAsync`, `postAsync`, `putAsync`, `deleteAsync`, +`patchAsync`, and the generic `requestAsync(method, uri, spec)`. + +These compose naturally with `CompletableFuture` methods: + +[source,groovy] +---- +http.getAsync('/data') + .thenApply { it.json } + .thenAccept { data -> println "Got: $data" } +---- + +If the Groovy async module is on the classpath, these futures are +automatically `await`-able: + +[source,groovy] +---- +def result = await http.getAsync('/data') +---- + == Declarative HTTP Clients For APIs with multiple endpoints, you can define a typed interface and let @@ -209,33 +265,107 @@ def book = api.getBook('978-0-321-12521-7') ---- The AST transform generates an implementation class that uses `HttpBuilder` -under the hood. A `create()` factory method is added to the interface, with -an overload `create(String baseUrl)` for overriding the base URL at runtime. +under the hood. Three `create()` factory methods are added to the interface: + +- `create()` — uses the annotation URL and default settings +- `create(String baseUrl)` — overrides the base URL at runtime +- `create(Closure config)` — full control over the underlying `HttpBuilder`, + including base URL, headers, timeouts, redirects, and `clientConfig` for + JDK-level settings (authenticator, SSL, proxy) + +[source,groovy] +---- +// Runtime auth token +def api = MyApi.create { + baseUri 'https://api.example.com' + header 'Authorization', "Bearer ${token}" +} + +// Full JDK client customization +def api = MyApi.create { + baseUri 'https://internal.corp' + clientConfig { builder -> + builder.sslContext(mySSLContext) + } +} +---- === Parameter Mapping -Method parameters are mapped automatically: +Method parameters are mapped automatically by convention — no annotation +is needed for the common case: [cols="1,2"] |=== | Condition | Mapping | Name matches `{placeholder}` in URL -| Path variable — substituted into the URL +| Path variable — substituted into the URL (URL-encoded) -| `@Body` annotation +| Annotated with `@Body` | Request body — serialized as JSON -| `@Query` annotation (or no match) -| Query parameter +| Everything else +| Query parameter — the parameter name is used as the query key |=== +The `@Query` annotation is only needed when the query parameter name differs +from the method parameter name: + +[source,groovy] +---- +@Get('/search') +List search(String q) // ?q=... (implied) + +@Get('/search') +List search(@Query('q') String query) // ?q=... (explicit, different name) +---- + +=== HTTP Methods + +All standard HTTP methods are supported via annotations: +`@Get`, `@Post`, `@Put`, `@Delete`, `@Patch`. + +[source,groovy] +---- +@Patch('/items/{id}') +Map patchItem(String id, @Body Map updates) +---- + === Headers `@Header` annotations can be placed on the interface (applies to all methods) or on individual methods. Method-level headers are merged with interface-level headers. +=== Timeouts and Redirects + +Connection timeout, default request timeout, and redirect following can be +configured on the `@HttpBuilderClient` annotation: + +[source,groovy] +---- +@HttpBuilderClient(value = 'https://api.example.com', + connectTimeout = 5, + requestTimeout = 10, + followRedirects = true) +interface MyApi { + @Get('/users/{id}') + Map getUser(String id) // uses default 10s timeout + + @Get('/reports/generate') + @Timeout(60) + Map generateReport() // overrides to 60s for this method +} +---- + +- `connectTimeout` -- how long to wait for the TCP connection (client-level, in seconds) +- `requestTimeout` -- default request timeout applied to all methods (in seconds) +- `@Timeout(value)` -- per-method override of the request timeout (in seconds) +- `followRedirects` -- whether to follow HTTP redirects + +Default `0` means no timeout. + === Return Types The return type of each method determines how the response is processed: @@ -247,6 +377,15 @@ The return type of each method determines how the response is processed: | `Map` or `List` | Response parsed as JSON +| Typed class (e.g. `User`) +| Response parsed as JSON, then coerced to the target type + +| `GPathResult` +| Response parsed as XML (via `XmlSlurper`) + +| jsoup `Document` +| Response parsed as HTML (requires jsoup on classpath) + | `String` | Raw response body @@ -261,6 +400,68 @@ The return type of each method determines how the response is processed: |=== +For typed responses, the JSON is parsed and coerced to the target class +using Groovy's `as` coercion: + +[source,groovy] +---- +class User { + String name + String bio +} + +@HttpBuilderClient('https://api.example.com') +interface UserApi { + @Get('/users/{id}') + User getUser(String id) +} +---- + +=== Request Bodies + +By default, `@Body` parameters are serialized as JSON. Additional body +modes are available: + +[cols="1,2"] +|=== +| Annotation | Behaviour + +| `@Body` +| JSON body (default) + +| `@BodyText` +| Plain text body (sent as-is) + +| `@Form` (on method) +| All non-path parameters become form-encoded fields (`application/x-www-form-urlencoded`) + +|=== + +[source,groovy] +---- +@Post('/login') +@Form +Map login(String username, String password) // form-encoded POST + +@Post('/notes') +Map createNote(@BodyText String content) // plain text body +---- + +=== Error Handling + +By default, HTTP 4xx/5xx responses throw a `RuntimeException`. You can +declare a specific exception type in the method's `throws` clause, and +the generated client will throw that type instead: + +[source,groovy] +---- +@Get('/users/{id}') +Map getUser(String id) throws NotFoundException +---- + +The exception class is instantiated by trying constructors in order: +`(int status, String body)`, then `(String message)`, then no-arg. + === Async Methods returning `CompletableFuture` execute asynchronously: diff --git a/subprojects/groovy-http-builder/src/test/groovy/groovy/http/HttpBuilderClientTest.groovy b/subprojects/groovy-http-builder/src/test/groovy/groovy/http/HttpBuilderClientTest.groovy index 4448f20ce4..98c5a86fe6 100644 --- a/subprojects/groovy-http-builder/src/test/groovy/groovy/http/HttpBuilderClientTest.groovy +++ b/subprojects/groovy-http-builder/src/test/groovy/groovy/http/HttpBuilderClientTest.groovy @@ -75,11 +75,50 @@ class HttpBuilderClientTest { respond(exchange, 200, body) } else if (method == 'DELETE' && path =~ '/items/\\d+') { respond(exchange, 204, null) + } else if (method == 'PATCH' && path =~ '/items/\\d+') { + def body = new JsonSlurper().parseText(exchange.requestBody.text) + body.patched = true + respond(exchange, 200, body) } else { respond(exchange, 404, [error: 'not found']) } } + server.createContext('/form-echo') { HttpExchange exchange -> + if (exchange.requestMethod == 'POST') { + String body = exchange.requestBody.text + // Parse form-encoded body + def params = body.split('&').collectEntries { String pair -> + def parts = pair.split('=', 2) + [(URLDecoder.decode(parts[0], 'UTF-8')): parts.length > 1 ? URLDecoder.decode(parts[1], 'UTF-8') : ''] + } + respond(exchange, 200, params) + } else { + respond(exchange, 405, [error: 'method not allowed']) + } + } + + server.createContext('/text-echo') { HttpExchange exchange -> + if (exchange.requestMethod == 'POST') { + String body = exchange.requestBody.text + respond(exchange, 200, [text: body]) + } else { + respond(exchange, 405, [error: 'method not allowed']) + } + } + + server.createContext('/xml-data') { HttpExchange exchange -> + exchange.responseHeaders.set('Content-Type', 'application/xml') + byte[] xml = '<root><name>Groovy</name><version>6</version></root>'.bytes + exchange.sendResponseHeaders(200, xml.length) + exchange.responseBody.write(xml) + exchange.close() + } + + server.createContext('/not-found') { HttpExchange exchange -> + respond(exchange, 404, [error: 'resource not found', code: 404]) + } + server.start() } @@ -226,4 +265,286 @@ class HttpBuilderClientTest { api.deleteItem('123') """ } + + @Test + void testImpliedQueryParams() { + // No @Query annotation needed — non-path, non-body params are implied query params + assertScript """ + import groovy.http.* + + @HttpBuilderClient('http://127.0.0.1:${port}') + interface ImpliedApi { + @Get('/users') + List searchUsers(String name) + } + + def api = ImpliedApi.create() + def users = api.searchUsers('Alice') + assert users.size() == 1 + assert users[0].name == 'Alice' + """ + } + + @Test + void testPatch() { + assertScript """ + import groovy.http.* + + @HttpBuilderClient('http://127.0.0.1:${port}') + interface PatchApi { + @Patch('/items/{id}') + Map patchItem(String id, @Body Map updates) + } + + def api = PatchApi.create() + def result = api.patchItem('42', [name: 'Updated']) + assert result.name == 'Updated' + assert result.patched == true + """ + } + + @Test + void testPatchOnBuilder() { + def http = HttpBuilder.http("http://127.0.0.1:${port}") + def result = http.patch('/items/42') { + json([name: 'Patched']) + } + assert result.json.patched == true + } + + @Test + void testTimeoutAndRedirectConfig() { + // Verify that the timeout and redirect attributes compile and don't break anything + assertScript """ + import groovy.http.* + + @HttpBuilderClient(value = 'http://127.0.0.1:${port}', + connectTimeout = 5, + requestTimeout = 10, + followRedirects = true) + interface ConfiguredApi { + @Get('/users/{username}') + Map getUser(String username) + } + + def api = ConfiguredApi.create() + def user = api.getUser('alice') + assert user.name == 'alice' + """ + } + + @Test + void testFormPost() { + assertScript """ + import groovy.http.* + + @HttpBuilderClient('http://127.0.0.1:${port}') + interface FormApi { + @Post('/form-echo') + @Form + Map login(String username, String password) + } + + def api = FormApi.create() + def result = api.login('alice', 's3cret') + assert result.username == 'alice' + assert result.password == 's3cret' + """ + } + + @Test + void testBodyText() { + assertScript """ + import groovy.http.* + + @HttpBuilderClient('http://127.0.0.1:${port}') + interface TextApi { + @Post('/text-echo') + Map sendText(@BodyText String content) + } + + def api = TextApi.create() + def result = api.sendText('Hello, World!') + assert result.text.contains('Hello') + """ + } + + @Test + void testXmlResponse() { + assertScript """ + import groovy.http.* + import groovy.xml.slurpersupport.GPathResult + + @HttpBuilderClient('http://127.0.0.1:${port}') + interface XmlApi { + @Get('/xml-data') + GPathResult getData() + } + + def api = XmlApi.create() + def xml = api.getData() + assert xml instanceof GPathResult + assert xml.name.text() == 'Groovy' + assert xml.version.text() == '6' + """ + } + + @Test + void testTypedResponse() { + assertScript """ + import groovy.http.* + + class UserInfo { + String name + String bio + } + + @HttpBuilderClient('http://127.0.0.1:${port}') + interface TypedApi { + @Get('/users/{username}') + UserInfo getUser(String username) + } + + def api = TypedApi.create() + def user = api.getUser('alice') + assert user instanceof UserInfo + assert user.name == 'alice' + assert user.bio == 'Bio of alice' + """ + } + + @Test + void testErrorMappingViaThrows() { + // Error mapping uses the throws clause to determine exception type. + // The exception class must be visible to the helper's classloader, + // so we test with a RuntimeException subclass (always loadable). + assertScript """ + import groovy.http.* + + @HttpBuilderClient('http://127.0.0.1:${port}') + interface ErrorApi { + @Get('/not-found') + Map getData() throws IllegalStateException + } + + def api = ErrorApi.create() + try { + api.getData() + assert false, 'should have thrown' + } catch (IllegalStateException e) { + assert e.message.contains('404') + } + """ + } + + @Test + void testErrorDefaultsToRuntimeException() { + assertScript """ + import groovy.http.* + + @HttpBuilderClient('http://127.0.0.1:${port}') + interface DefaultErrorApi { + @Get('/not-found') + Map getData() + } + + def api = DefaultErrorApi.create() + try { + api.getData() + assert false, 'should have thrown' + } catch (RuntimeException e) { + assert e.message.contains('404') + } + """ + } + + @Test + void testImperativeAsync() { + def http = HttpBuilder.http("http://127.0.0.1:${port}") + def future = http.getAsync('/users/alice') + assert future instanceof java.util.concurrent.CompletableFuture + def result = future.get(5, java.util.concurrent.TimeUnit.SECONDS) + assert result.json.name == 'alice' + } + + @Test + void testClientConfig() { + // Imperative: clientConfig gives access to HttpClient.Builder + def http = HttpBuilder.http { + baseUri "http://127.0.0.1:${port}" + clientConfig { builder -> + // Can set authenticator, SSL, proxy, etc. + builder.followRedirects(java.net.http.HttpClient.Redirect.NORMAL) + } + } + def result = http.get('/users/alice') + assert result.json.name == 'alice' + } + + @Test + void testDeclarativeCreateWithClosure() { + assertScript """ + import groovy.http.* + + @HttpBuilderClient('http://wrong-host:9999') + interface ConfigApi { + @Get('/users/{username}') + Map getUser(String username) + } + + // create(Closure) overrides everything — base URL, headers, etc. + def api = ConfigApi.create { + baseUri 'http://127.0.0.1:${port}' + header 'X-Custom', 'from-closure' + } + def user = api.getUser('alice') + assert user.name == 'alice' + """ + } + + @Test + void testDeclarativeCreateWithAuth() { + assertScript """ + import groovy.http.* + + @HttpBuilderClient('http://127.0.0.1:${port}') + interface AuthApi { + @Get('/echo-headers') + Map echoHeaders() + } + + def api = AuthApi.create { + baseUri 'http://127.0.0.1:${port}' + header 'Authorization', 'Bearer my-secret-token' + } + def headers = api.echoHeaders() + def lc = headers.collectKeys(String::toLowerCase) + assert lc['authorization'] == 'Bearer my-secret-token' + """ + } + + @Test + void testPerMethodTimeout() { + assertScript """ + import groovy.http.* + + @HttpBuilderClient(value = 'http://127.0.0.1:${port}', + requestTimeout = 5) + interface TimeoutApi { + @Get('/users/{username}') + Map getUser(String username) // uses default 5s + + @Get('/users/{username}') + @Timeout(30) + Map getUserSlow(String username) // overrides to 30s + } + + def api = TimeoutApi.create() + def user = api.getUser('alice') + assert user.name == 'alice' + + def slow = api.getUserSlow('bob') + assert slow.name == 'bob' + """ + } }
