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

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


The following commit(s) were added to refs/heads/master by this push:
     new 06de6e0199 @W-21931609 Improve Check cron alerting
06de6e0199 is described below

commit 06de6e0199b4584d56900466f80e94a8f06ccd33
Author: James Bognar <[email protected]>
AuthorDate: Fri Apr 10 12:06:35 2026 -0400

    @W-21931609 Improve Check cron alerting
---
 .../apachehttpclient45/ApacheHc45Transport.java    |   1 -
 .../apachehttpclient50/ApacheHc5Transport.java     |  11 +-
 .../ng/rest/client/HttpTransportBuilder.java       |  60 ++++++
 .../apache/juneau/ng/rest/client/NgRestClient.java |  99 ++++++++-
 .../juneau/ng/rest/client/NgRestRequest.java       | 160 +++++++++++++-
 .../juneau/ng/rest/client/NgRestResponse.java      |  38 ++++
 .../apache/juneau/ng/rest/client/ResponseBody.java | 119 ++++++++++
 .../juneau/ng/rest/client/ResponseHeader.java      | 172 +++++++++++++++
 .../juneau/ng/rest/client/RestCallInterceptor.java |  88 ++++++++
 .../ng/rest/client/remote/NgRemoteClient.java      | 239 +++++++++++++++++++++
 10 files changed, 972 insertions(+), 15 deletions(-)

diff --git 
a/juneau-rest/juneau-ng-rest-client-apache-httpclient-45/src/main/java/org/apache/juneau/ng/rest/client/apachehttpclient45/ApacheHc45Transport.java
 
b/juneau-rest/juneau-ng-rest-client-apache-httpclient-45/src/main/java/org/apache/juneau/ng/rest/client/apachehttpclient45/ApacheHc45Transport.java
index d5cd4bfdaa..f20845a1ce 100644
--- 
a/juneau-rest/juneau-ng-rest-client-apache-httpclient-45/src/main/java/org/apache/juneau/ng/rest/client/apachehttpclient45/ApacheHc45Transport.java
+++ 
b/juneau-rest/juneau-ng-rest-client-apache-httpclient-45/src/main/java/org/apache/juneau/ng/rest/client/apachehttpclient45/ApacheHc45Transport.java
@@ -18,7 +18,6 @@ package org.apache.juneau.ng.rest.client.apachehttpclient45;
 
 import java.io.*;
 
-import org.apache.http.*;
 import org.apache.http.client.methods.*;
 import org.apache.http.entity.*;
 import org.apache.http.impl.client.*;
diff --git 
a/juneau-rest/juneau-ng-rest-client-apache-httpclient-50/src/main/java/org/apache/juneau/ng/rest/client/apachehttpclient50/ApacheHc5Transport.java
 
b/juneau-rest/juneau-ng-rest-client-apache-httpclient-50/src/main/java/org/apache/juneau/ng/rest/client/apachehttpclient50/ApacheHc5Transport.java
index 95375a4167..0fd83f43ae 100644
--- 
a/juneau-rest/juneau-ng-rest-client-apache-httpclient-50/src/main/java/org/apache/juneau/ng/rest/client/apachehttpclient50/ApacheHc5Transport.java
+++ 
b/juneau-rest/juneau-ng-rest-client-apache-httpclient-50/src/main/java/org/apache/juneau/ng/rest/client/apachehttpclient50/ApacheHc5Transport.java
@@ -85,9 +85,9 @@ public final class ApacheHc5Transport implements 
HttpTransport {
        @Override /* HttpTransport */
        public TransportResponse execute(TransportRequest request) throws 
TransportException {
                var hcRequest = buildHcRequest(request);
-               CloseableHttpResponse hcResponse;
+               ClassicHttpResponse hcResponse;
                try {
-                       hcResponse = httpClient.execute(hcRequest);
+                       hcResponse = httpClient.executeOpen(null, hcRequest, 
null);
                } catch (IOException e) {
                        throw new TransportException("HTTP transport error: " + 
e.getMessage(), e);
                }
@@ -123,11 +123,12 @@ public final class ApacheHc5Transport implements 
HttpTransport {
                return new EntityTemplate(body.getContentLength(), contentType, 
null, body::writeTo);
        }
 
-       private static TransportResponse 
buildTransportResponse(CloseableHttpResponse hcResponse) throws 
TransportException {
+       private static TransportResponse 
buildTransportResponse(ClassicHttpResponse hcResponse) throws 
TransportException {
                var builder = TransportResponse.builder()
                        .statusCode(hcResponse.getCode())
-                       .reasonPhrase(hcResponse.getReasonPhrase())
-                       .closeCallback(hcResponse);
+                       .reasonPhrase(hcResponse.getReasonPhrase());
+               if (hcResponse instanceof Closeable)
+                       builder.closeCallback((Closeable) hcResponse);
                for (var h : hcResponse.getHeaders())
                        builder.header(h.getName(), h.getValue());
                var entity = hcResponse.getEntity();
diff --git 
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/ng/rest/client/HttpTransportBuilder.java
 
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/ng/rest/client/HttpTransportBuilder.java
new file mode 100644
index 0000000000..cfd8583e75
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/ng/rest/client/HttpTransportBuilder.java
@@ -0,0 +1,60 @@
+/*
+ * 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 org.apache.juneau.ng.rest.client;
+
+/**
+ * A builder interface for configuring and creating {@link HttpTransport} 
instances.
+ *
+ * <p>
+ * Transport modules (Apache HC, Java HTTP Client, OkHttp, etc.) implement 
this interface to expose their
+ * configuration options in a transport-agnostic way.  The built transport is 
then passed to
+ * {@link NgRestClient.Builder#transport(HttpTransport)}.
+ *
+ * <p>
+ * Example usage:
+ * <p class='bjava'>
+ *     HttpTransport <jv>transport</jv> = ApacheHc5Transport.builder()
+ *             .connectTimeout(Duration.ofSeconds(5))
+ *             .readTimeout(Duration.ofSeconds(30))
+ *             .build();
+ *
+ *     NgRestClient <jv>client</jv> = NgRestClient.create()
+ *             .transport(<jv>transport</jv>)
+ *             .build();
+ * </p>
+ *
+ * <p>
+ * <b>Beta — API subject to change:</b> This type is part of the 
next-generation REST client and HTTP stack
+ * ({@code org.apache.juneau.ng.*}).
+ * It is not API-frozen: binary- and source-incompatible changes may appear in 
the <b>next major</b> Juneau release
+ * (and possibly earlier).
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ *     <li class='link'><a class="doclink" 
href="https://juneau.apache.org/docs/topics/juneau-ng-rest-client";>juneau-ng 
REST client</a>
+ * </ul>
+ *
+ * @since 9.2.1
+ */
+public interface HttpTransportBuilder {
+
+       /**
+        * Builds and returns a configured {@link HttpTransport} instance.
+        *
+        * @return A new transport. Never <jk>null</jk>.
+        */
+       HttpTransport build();
+}
diff --git 
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/ng/rest/client/NgRestClient.java
 
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/ng/rest/client/NgRestClient.java
index 036abe0ddc..c143378ac9 100644
--- 
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/ng/rest/client/NgRestClient.java
+++ 
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/ng/rest/client/NgRestClient.java
@@ -23,7 +23,9 @@ import java.util.*;
 import java.util.function.*;
 
 import org.apache.juneau.ng.http.*;
+import org.apache.juneau.ng.http.entity.*;
 import org.apache.juneau.ng.http.part.*;
+import org.apache.juneau.ng.rest.client.remote.*;
 
 /**
  * Next-generation transport-agnostic REST client.
@@ -60,10 +62,27 @@ import org.apache.juneau.ng.http.part.*;
 })
 public final class NgRestClient implements Closeable {
 
+       /**
+        * Default body converters applied in order when {@link 
NgRestRequest#body(Object)} is called.
+        *
+        * <p>
+        * Custom converters registered via {@link 
Builder#bodyConverter(BodyConverter[])} are prepended to this list.
+        * Call {@link Builder#bodyConverters(BodyConverter[])} to replace all 
defaults.
+        */
+       public static final List<BodyConverter<?>> DEFAULT_BODY_CONVERTERS = 
List.of(
+               BodyConverter.of(HttpBody.class, body -> 
TransportBody.of(body)),
+               BodyConverter.of(InputStream.class, is -> 
TransportBody.of(StreamBody.of(is))),
+               BodyConverter.of(byte[].class, bytes -> 
TransportBody.of(ByteArrayBody.of(bytes))),
+               BodyConverter.of(java.io.File.class, file -> 
TransportBody.of(FileBody.of(file)))
+       );
+
        final HttpTransport transport;
        final List<HttpHeader> defaultHeaders;
        final List<HttpPart> defaultQueryData;
        final String rootUrl;
+       final List<RestCallInterceptor> interceptors;
+       final RestLogger logger;
+       final List<BodyConverter<?>> bodyConverters;
 
        private NgRestClient(Builder builder) {
                this.transport = assertArgNotNull("transport",
@@ -71,6 +90,9 @@ public final class NgRestClient implements Closeable {
                this.defaultHeaders = List.copyOf(builder.defaultHeaders);
                this.defaultQueryData = List.copyOf(builder.defaultQueryData);
                this.rootUrl = builder.rootUrl;
+               this.interceptors = List.copyOf(builder.interceptors);
+               this.logger = builder.logger;
+               this.bodyConverters = List.copyOf(builder.bodyConverters);
        }
 
        private static HttpTransport discoverTransport() {
@@ -78,7 +100,7 @@ public final class NgRestClient implements Closeable {
                for (var p : ServiceLoader.load(HttpTransportProvider.class))
                        if (p.isAvailable())
                                providers.add(p);
-               if (providers.isEmpty())
+               if (providers.isEmpty()) // HTT: requires test classpath with 
no transport modules present
                        return null;
                
providers.sort(Comparator.comparingInt(HttpTransportProvider::getPriority));
                return providers.get(0).create();
@@ -179,6 +201,26 @@ public final class NgRestClient implements Closeable {
                return transport;
        }
 
+       /**
+        * Creates a Java proxy for the given {@link 
org.apache.juneau.ng.http.remote.Remote}-annotated interface.
+        *
+        * <p>
+        * Each method call on the returned proxy will be translated into an 
HTTP request using this client.
+        *
+        * <p class='bjava'>
+        *      UserService <jv>svc</jv> = 
client.remote(UserService.<jk>class</jk>);
+        *      String <jv>user</jv> = <jv>svc</jv>.getUser(<js>"42"</js>);
+        * </p>
+        *
+        * @param <T> The interface type.
+        * @param iface The interface class. Must be annotated with {@link 
org.apache.juneau.ng.http.remote.Remote}. Must not be <jk>null</jk>.
+        * @return A proxy instance backed by this client. Never <jk>null</jk>.
+        * @throws IllegalArgumentException If {@code iface} is not an 
interface or not annotated with {@code @Remote}.
+        */
+       public <T> T remote(Class<T> iface) {
+               return new NgRemoteClient(this).create(iface);
+       }
+
        @Override /* Closeable */
        public void close() throws IOException {
                transport.close();
@@ -202,6 +244,9 @@ public final class NgRestClient implements Closeable {
                final List<HttpHeader> defaultHeaders = new ArrayList<>();
                final List<HttpPart> defaultQueryData = new ArrayList<>();
                String rootUrl;
+               final List<RestCallInterceptor> interceptors = new 
ArrayList<>();
+               RestLogger logger;
+               List<BodyConverter<?>> bodyConverters = new 
ArrayList<>(DEFAULT_BODY_CONVERTERS);
 
                private Builder() {}
 
@@ -288,6 +333,58 @@ public final class NgRestClient implements Closeable {
                        return this;
                }
 
+               /**
+                * Adds one or more lifecycle interceptors called before/after 
each request.
+                *
+                * @param value The interceptors to add. Must not be 
<jk>null</jk>.
+                * @return This object.
+                */
+               public Builder interceptors(RestCallInterceptor... value) {
+                       interceptors.addAll(Arrays.asList(value));
+                       return this;
+               }
+
+               /**
+                * Sets the logger called at the end of every request (success 
or failure).
+                *
+                * @param value The logger. May be <jk>null</jk> to disable 
logging.
+                * @return This object.
+                */
+               public Builder logger(RestLogger value) {
+                       logger = value;
+                       return this;
+               }
+
+               /**
+                * Prepends custom body converters to the default converter 
list.
+                *
+                * <p>
+                * Custom converters are checked before the defaults when 
{@link NgRestRequest#body(Object)} is called.
+                *
+                * @param value The converters to prepend. Must not be 
<jk>null</jk>.
+                * @return This object.
+                */
+               public Builder bodyConverter(BodyConverter<?>... value) {
+                       var prepended = new 
ArrayList<BodyConverter<?>>(Arrays.asList(value));
+                       prepended.addAll(bodyConverters);
+                       bodyConverters = prepended;
+                       return this;
+               }
+
+               /**
+                * Replaces the entire body converter list (including defaults).
+                *
+                * <p>
+                * Use this when you want full control over body conversion, 
including disabling the built-in defaults.
+                *
+                * @param value The complete replacement converter list. Must 
not be <jk>null</jk>.
+                * @return This object.
+                */
+               public Builder bodyConverters(BodyConverter<?>... value) {
+                       bodyConverters = new ArrayList<>(Arrays.asList(value));
+                       return this;
+               }
+
                /**
                 * Builds and returns the {@link NgRestClient}.
                 *
diff --git 
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/ng/rest/client/NgRestRequest.java
 
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/ng/rest/client/NgRestRequest.java
index 7c68e0423a..a7f53ce6f3 100644
--- 
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/ng/rest/client/NgRestRequest.java
+++ 
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/ng/rest/client/NgRestRequest.java
@@ -19,10 +19,12 @@ package org.apache.juneau.ng.rest.client;
 import java.io.*;
 import java.net.*;
 import java.nio.charset.*;
+import java.time.*;
 import java.util.*;
 import java.util.function.*;
 
 import org.apache.juneau.ng.http.*;
+import org.apache.juneau.ng.http.entity.*;
 import org.apache.juneau.ng.http.header.*;
 import org.apache.juneau.ng.http.part.*;
 
@@ -53,6 +55,9 @@ public final class NgRestRequest {
        private final List<HttpPart> formData = new ArrayList<>();
        private final Map<String, Object> pathData = new LinkedHashMap<>();
        private HttpBody body;
+       private TransportBody convertedBody;
+       private boolean debug;
+       private URI resolvedUri;
 
        NgRestRequest(NgRestClient client, String method, String url) {
                this.client = client;
@@ -201,16 +206,105 @@ public final class NgRestRequest {
        // --------------------------------------------------
 
        /**
-        * Sets the request body.
+        * Sets the request body directly from an {@link HttpBody}.
         *
         * @param value The body. May be <jk>null</jk>.
         * @return This object.
         */
        public NgRestRequest body(HttpBody value) {
                body = value;
+               convertedBody = null;
                return this;
        }
 
+       /**
+        * Sets the request body from an arbitrary Java object, converting it 
via the client's body converter chain.
+        *
+        * <p>
+        * The default converters handle: {@link HttpBody} (passthrough), 
{@link InputStream},
+        * {@code byte[]}, and {@link java.io.File}. Custom converters can be 
registered on the builder.
+        *
+        * @param value The body object. May be <jk>null</jk> to clear the body.
+        * @return This object.
+        * @throws IOException If a converter fails.
+        * @throws IllegalArgumentException If no converter can handle the 
given type.
+        */
+       public NgRestRequest body(Object value) throws IOException {
+               if (value == null) {
+                       body = null;
+                       convertedBody = null;
+                       return this;
+               }
+               for (var converter : client.bodyConverters) {
+                       if (converter.canConvert(value)) {
+                               convertedBody = converter.convert(value);
+                               body = null;
+                               return this;
+                       }
+               }
+               throw new IllegalArgumentException("No BodyConverter found for 
type: " + value.getClass().getName());
+       }
+
+       /**
+        * Sets the request body to a raw string (UTF-8, {@code text/plain}).
+        *
+        * <p>
+        * Unlike {@link #body(Object)}, this bypasses the converter chain and 
sends the string as-is.
+        *
+        * @param value The string body. May be <jk>null</jk> to clear the body.
+        * @return This object.
+        */
+       public NgRestRequest bodyString(String value) {
+               if (value == null) {
+                       body = null;
+                       convertedBody = null;
+               } else {
+                       body = StringBody.of(value, "text/plain");
+                       convertedBody = null;
+               }
+               return this;
+       }
+
+       // --------------------------------------------------
+       // Debug
+       // --------------------------------------------------
+
+       /**
+        * Flags this request for verbose debug logging.
+        *
+        * <p>
+        * When set, the configured {@link RestLogger} will receive an entry 
with {@link RestLogEntry#isDebug()} {@code true},
+        * enabling full request/response header and body logging for this call 
only.
+        *
+        * @return This object.
+        */
+       public NgRestRequest debug() {
+               debug = true;
+               return this;
+       }
+
+       // --------------------------------------------------
+       // Accessors (for logging / interceptors)
+       // --------------------------------------------------
+
+       /**
+        * Returns the HTTP method for this request (e.g. {@code "GET"}).
+        *
+        * @return The method. Never <jk>null</jk>.
+        */
+       public String getMethod() {
+               return method;
+       }
+
+       /**
+        * Returns the fully resolved URI for this request, or {@code null} if 
{@link #run()} has not been called yet.
+        *
+        * @return The URI, possibly <jk>null</jk>.
+        */
+       public URI getUri() {
+               return resolvedUri;
+       }
+
        // --------------------------------------------------
        // Execute
        // --------------------------------------------------
@@ -225,19 +319,64 @@ public final class NgRestRequest {
         * @throws TransportException If a network-level error occurs.
         * @throws NgRestCallException If the response could not be processed.
         */
+       @SuppressWarnings({
+               "resource" // response is returned to caller who must close it; 
transport is owned by client
+       })
        public NgRestResponse run() throws TransportException, 
NgRestCallException {
-               var transportRequest = buildTransportRequest();
-               var transportResponse = 
client.transport.execute(transportRequest);
-               return new NgRestResponse(transportResponse);
+               var start = Instant.now();
+               NgRestResponse response = null;
+               Throwable error = null;
+               try {
+                       for (var interceptor : client.interceptors)
+                               interceptor.onInit(this);
+
+                       var transportRequest = buildTransportRequest();
+                       var transportResponse = 
client.transport.execute(transportRequest);
+                       response = new NgRestResponse(transportResponse);
+
+                       for (var interceptor : client.interceptors)
+                               interceptor.onConnect(this, response);
+
+                       return response;
+               } catch (TransportException e) {
+                       error = e;
+                       throw e;
+               } catch (NgRestCallException e) {
+                       error = e;
+                       throw e;
+               } catch (Exception e) {
+                       error = e;
+                       throw new TransportException("Request failed: " + 
e.getMessage(), e);
+               } finally {
+                       var elapsed = Duration.between(start, Instant.now());
+                       NgRestResponse finalResponse = response;
+                       for (var interceptor : client.interceptors) {
+                               try {
+                                       interceptor.onClose(this, 
finalResponse);
+                               } catch (Exception e2) { // HTT: exception in 
onClose; hard to test reliably
+                                       // suppress interceptor close errors — 
best effort
+                               }
+                       }
+                       if (client.logger != null) {
+                               var entry = RestLogEntry.builder()
+                                       .request(this)
+                                       .response(finalResponse)
+                                       .error(error)
+                                       .elapsed(elapsed)
+                                       .debug(debug)
+                                       .build();
+                               client.logger.log(entry);
+                       }
+               }
        }
 
        private TransportRequest buildTransportRequest() {
                var resolvedUrl = applyPathSubstitutions(url);
-               var uriWithQuery = appendQuery(resolvedUrl);
+               resolvedUri = appendQuery(resolvedUrl);
 
                var builder = TransportRequest.builder()
                        .method(method)
-                       .uri(uriWithQuery);
+                       .uri(resolvedUri);
 
                // Headers
                for (var h : headers) {
@@ -246,8 +385,13 @@ public final class NgRestRequest {
                                builder.header(h.getName(), v);
                }
 
-               // Form body takes precedence if form data is present and no 
explicit body was set
-               if (!formData.isEmpty() && body == null) {
+               // Pre-converted body from body(Object) takes priority
+               if (convertedBody != null) {
+                       if (convertedBody.getContentType() != null)
+                               builder.header("Content-Type", 
convertedBody.getContentType());
+                       builder.body(convertedBody);
+               } else if (!formData.isEmpty() && body == null) {
+                       // Form body when form data is present and no explicit 
body was set
                        var formBody = buildFormBody();
                        builder.header("Content-Type", 
formBody.getContentType());
                        builder.body(TransportBody.of(formBody));
diff --git 
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/ng/rest/client/NgRestResponse.java
 
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/ng/rest/client/NgRestResponse.java
index d8ab909185..4a971a65ca 100644
--- 
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/ng/rest/client/NgRestResponse.java
+++ 
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/ng/rest/client/NgRestResponse.java
@@ -20,6 +20,8 @@ import java.io.*;
 import java.nio.charset.*;
 import java.util.*;
 
+import org.apache.juneau.ng.rest.client.assertion.*;
+
 /**
  * An HTTP response returned by {@link NgRestRequest#run()}.
  *
@@ -138,6 +140,42 @@ public final class NgRestResponse implements Closeable {
                return this;
        }
 
+       /**
+        * Returns a fluent body accessor for this response.
+        *
+        * @return A new body accessor. Never <jk>null</jk>.
+        */
+       public ResponseBody body() {
+               return new ResponseBody(this);
+       }
+
+       /**
+        * Returns a fluent assertion object for this response.
+        *
+        * <p>
+        * Use this for test-style validation of the response in production or 
test code:
+        * <p class='bjava'>
+        *      <jv>resp</jv>.assertThat()
+        *              .statusCode(200)
+        *              .body().contains(<js>"alice"</js>);
+        * </p>
+        *
+        * @return A new assertion object. Never <jk>null</jk>.
+        */
+       public ResponseAssertion assertThat() {
+               return new ResponseAssertion(this);
+       }
+
+       /**
+        * Returns a fluent header accessor for the named response header.
+        *
+        * @param name The header name (case-insensitive). Must not be 
<jk>null</jk>.
+        * @return A new header accessor. Never <jk>null</jk>.
+        */
+       public ResponseHeader header(String name) {
+               return new ResponseHeader(name, this);
+       }
+
        @Override /* Closeable */
        public void close() throws IOException {
                response.close();
diff --git 
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/ng/rest/client/ResponseBody.java
 
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/ng/rest/client/ResponseBody.java
new file mode 100644
index 0000000000..11e95dc30b
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/ng/rest/client/ResponseBody.java
@@ -0,0 +1,119 @@
+/*
+ * 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 org.apache.juneau.ng.rest.client;
+
+import java.io.*;
+import java.nio.charset.*;
+
+/**
+ * A fluent accessor for an HTTP response body returned by {@link 
NgRestResponse}.
+ *
+ * <p>
+ * Provides convenient methods for reading the body as a string, byte array, 
or stream.
+ * The body is read lazily; each method reads and returns the body content 
once.
+ *
+ * <p>
+ * Obtain instances via {@link NgRestResponse#body()}.
+ *
+ * <p>
+ * <b>Beta — API subject to change:</b> This type is part of the 
next-generation REST client and HTTP stack
+ * ({@code org.apache.juneau.ng.*}).
+ * It is not API-frozen: binary- and source-incompatible changes may appear in 
the <b>next major</b> Juneau release
+ * (and possibly earlier).
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ *     <li class='link'><a class="doclink" 
href="https://juneau.apache.org/docs/topics/juneau-ng-rest-client";>juneau-ng 
REST client</a>
+ * </ul>
+ *
+ * @since 9.2.1
+ */
+public final class ResponseBody {
+
+       private final NgRestResponse response;
+
+       ResponseBody(NgRestResponse response) {
+               this.response = response;
+       }
+
+       /**
+        * Returns the response body as a UTF-8 string.
+        *
+        * @return The body string, or <jk>null</jk> if the response has no 
body.
+        * @throws IOException If an I/O error occurs reading the body.
+        */
+       public String asString() throws IOException {
+               return response.getBodyAsString();
+       }
+
+       /**
+        * Returns the response body decoded with the given charset.
+        *
+        * @param charset The character set to use for decoding. Must not be 
<jk>null</jk>.
+        * @return The body string, or <jk>null</jk> if the response has no 
body.
+        * @throws IOException If an I/O error occurs reading the body.
+        */
+       @SuppressWarnings({
+               "resource" // Body stream owned by transport; release by 
closing NgRestResponse, not the stream
+       })
+       public String asString(Charset charset) throws IOException {
+               var stream = response.getBodyStream();
+               if (stream == null)
+                       return null;
+               return new String(stream.readAllBytes(), charset);
+       }
+
+       /**
+        * Returns the response body as a byte array.
+        *
+        * @return The body bytes, or <jk>null</jk> if the response has no body.
+        * @throws IOException If an I/O error occurs reading the body.
+        */
+       @SuppressWarnings({
+               "resource" // Body stream owned by transport; release by 
closing NgRestResponse, not the stream
+       })
+       public byte[] asBytes() throws IOException {
+               var stream = response.getBodyStream();
+               if (stream == null)
+                       return null;
+               return stream.readAllBytes();
+       }
+
+       /**
+        * Returns the raw response body stream.
+        *
+        * <p>
+        * Callers should not close this stream directly; close the parent 
{@link NgRestResponse} instead.
+        *
+        * @return The body stream, or <jk>null</jk> if the response has no 
body.
+        */
+       public InputStream asStream() {
+               return response.getBodyStream();
+       }
+
+       /**
+        * Reads the response body into a byte array and returns it, or returns 
{@code null} if there is no body.
+        *
+        * <p>
+        * Equivalent to {@link #asBytes()}.
+        *
+        * @return The body bytes, or <jk>null</jk> if the response has no body.
+        * @throws IOException If an I/O error occurs reading the body.
+        */
+       public byte[] readAllBytes() throws IOException {
+               return asBytes();
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/ng/rest/client/ResponseHeader.java
 
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/ng/rest/client/ResponseHeader.java
new file mode 100644
index 0000000000..1d93bbfd74
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/ng/rest/client/ResponseHeader.java
@@ -0,0 +1,172 @@
+/*
+ * 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 org.apache.juneau.ng.rest.client;
+
+import java.util.*;
+import java.util.stream.*;
+
+/**
+ * A fluent accessor for a named HTTP response header.
+ *
+ * <p>
+ * Provides convenient typed-access methods for reading header values.
+ * Returns {@code null} or empty results when the named header is absent from 
the response.
+ *
+ * <p>
+ * Obtain instances via {@link NgRestResponse#header(String)}.
+ *
+ * <p>
+ * <b>Beta — API subject to change:</b> This type is part of the 
next-generation REST client and HTTP stack
+ * ({@code org.apache.juneau.ng.*}).
+ * It is not API-frozen: binary- and source-incompatible changes may appear in 
the <b>next major</b> Juneau release
+ * (and possibly earlier).
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ *     <li class='link'><a class="doclink" 
href="https://juneau.apache.org/docs/topics/juneau-ng-rest-client";>juneau-ng 
REST client</a>
+ * </ul>
+ *
+ * @since 9.2.1
+ */
+public final class ResponseHeader {
+
+       private final String name;
+       private final NgRestResponse response;
+
+       ResponseHeader(String name, NgRestResponse response) {
+               this.name = name;
+               this.response = response;
+       }
+
+       /**
+        * Returns the header name.
+        *
+        * @return The header name. Never <jk>null</jk>.
+        */
+       public String getName() {
+               return name;
+       }
+
+       /**
+        * Returns <jk>true</jk> if this header is present in the response.
+        *
+        * @return <jk>true</jk> if present.
+        */
+       public boolean isPresent() {
+               return response.getFirstHeader(name) != null;
+       }
+
+       /**
+        * Returns the first value of this header, or <jk>null</jk> if absent.
+        *
+        * @return The header value, possibly <jk>null</jk>.
+        */
+       public String getValue() {
+               var h = response.getFirstHeader(name);
+               return h != null ? h.value() : null;
+       }
+
+       /**
+        * Returns the first value of this header, or the given default if 
absent.
+        *
+        * @param defaultValue The default value to return when the header is 
absent.
+        * @return The header value, or {@code defaultValue} if absent.
+        */
+       public String orElse(String defaultValue) {
+               var value = getValue();
+               return value != null ? value : defaultValue;
+       }
+
+       /**
+        * Returns the first value of this header as an {@link Optional}.
+        *
+        * @return An optional containing the first header value, or empty if 
absent.
+        */
+       public Optional<String> asOptional() {
+               return Optional.ofNullable(getValue());
+       }
+
+       /**
+        * Returns the first value of this header parsed as an {@code int}, or 
{@code -1} if absent or not parseable.
+        *
+        * @return The integer value, or {@code -1} if absent or not an integer.
+        */
+       public int asInteger() {
+               var value = getValue();
+               if (value == null)
+                       return -1;
+               try {
+                       return Integer.parseInt(value.trim());
+               } catch (NumberFormatException e) {
+                       return -1;
+               }
+       }
+
+       /**
+        * Returns the first value of this header parsed as a {@code long}, or 
{@code -1L} if absent or not parseable.
+        *
+        * @return The long value, or {@code -1L} if absent or not a long.
+        */
+       public long asLong() {
+               var value = getValue();
+               if (value == null)
+                       return -1L;
+               try {
+                       return Long.parseLong(value.trim());
+               } catch (NumberFormatException e) {
+                       return -1L;
+               }
+       }
+
+       /**
+        * Returns all values of this header.
+        *
+        * <p>
+        * For repeated headers, this returns each header value in encounter 
order.
+        *
+        * @return An unmodifiable list of header values. Never <jk>null</jk>, 
but possibly empty.
+        */
+       public List<String> getValues() {
+               return response.getHeaders().stream()
+                       .filter(h -> name.equalsIgnoreCase(h.name()))
+                       .map(TransportHeader::value)
+                       .collect(Collectors.toUnmodifiableList());
+       }
+
+       /**
+        * Returns the first value as a comma-split list of individual tokens.
+        *
+        * <p>
+        * Useful for headers like {@code Accept} or {@code Allow} that combine 
multiple values with commas.
+        *
+        * @return A list of trimmed tokens. Never <jk>null</jk>, but possibly 
empty.
+        */
+       public List<String> asCsvList() {
+               var value = getValue();
+               if (value == null)
+                       return List.of();
+               return Arrays.stream(value.split(","))
+                       .map(String::trim)
+                       .filter(s -> !s.isEmpty())
+                       .collect(Collectors.toUnmodifiableList());
+       }
+
+       @Override /* Object */
+       public String toString() {
+               var value = getValue();
+               return value != null ? name + ": " + value : name + ": 
<absent>";
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/ng/rest/client/RestCallInterceptor.java
 
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/ng/rest/client/RestCallInterceptor.java
new file mode 100644
index 0000000000..376bcc9435
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/ng/rest/client/RestCallInterceptor.java
@@ -0,0 +1,88 @@
+/*
+ * 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 org.apache.juneau.ng.rest.client;
+
+/**
+ * Lifecycle interceptor for REST calls made by {@link NgRestClient}.
+ *
+ * <p>
+ * Registered on the client builder via {@link 
NgRestClient.Builder#interceptors(RestCallInterceptor...)}.
+ * All callbacks have default no-op implementations so implementations only 
need to override the hooks they care about.
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ *     NgRestClient <jv>client</jv> = NgRestClient.<jsm>builder</jsm>()
+ *             .transport(<jv>transport</jv>)
+ *             .interceptors(new RestCallInterceptor() {
+ *                     &#64;Override
+ *                     <jk>public void</jk> onConnect(NgRestRequest req, 
NgRestResponse res) {
+ *                             System.<jf>out</jf>.println(<js>"Response: 
"</js> + res.getStatusCode());
+ *                     }
+ *             })
+ *             .build();
+ * </p>
+ *
+ * <p>
+ * <b>Beta — API subject to change:</b> This type is part of the 
next-generation REST client and HTTP stack
+ * ({@code org.apache.juneau.ng.*}).
+ * It is not API-frozen: binary- and source-incompatible changes may appear in 
the <b>next major</b> Juneau release
+ * (and possibly earlier).
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ *     <li class='link'><a class="doclink" 
href="https://juneau.apache.org/docs/topics/juneau-ng-rest-client";>juneau-ng 
REST client</a>
+ * </ul>
+ *
+ * @since 9.2.1
+ */
+public interface RestCallInterceptor {
+
+       /**
+        * Called before the transport request is built and sent.
+        *
+        * <p>
+        * Use this hook to add headers, modify query parameters, or 
short-circuit the call.
+        *
+        * @param req The request being built. Never <jk>null</jk>.
+        * @throws Exception If the call should be aborted.
+        */
+       default void onInit(NgRestRequest req) throws Exception {}
+
+       /**
+        * Called after a response has been received from the transport.
+        *
+        * <p>
+        * Use this hook to inspect status codes, validate headers, or modify 
how the response is interpreted.
+        *
+        * @param req The request that was sent. Never <jk>null</jk>.
+        * @param res The response received. Never <jk>null</jk>.
+        * @throws Exception If a post-connect error should be raised.
+        */
+       default void onConnect(NgRestRequest req, NgRestResponse res) throws 
Exception {}
+
+       /**
+        * Called when the request is being closed (in the {@code finally} 
block of {@link NgRestRequest#run()}).
+        *
+        * <p>
+        * This is called regardless of whether the call succeeded or failed, 
and is always invoked before
+        * the logger receives its entry. Use it for cleanup, metrics 
recording, or span/trace finalization.
+        *
+        * @param req The request. Never <jk>null</jk>.
+        * @param res The response, or <jk>null</jk> if the transport failed 
before a response was received.
+        * @throws Exception If an error occurs during close handling.
+        */
+       default void onClose(NgRestRequest req, NgRestResponse res) throws 
Exception {}
+}
diff --git 
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/ng/rest/client/remote/NgRemoteClient.java
 
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/ng/rest/client/remote/NgRemoteClient.java
new file mode 100644
index 0000000000..8386e70b6e
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/ng/rest/client/remote/NgRemoteClient.java
@@ -0,0 +1,239 @@
+/*
+ * 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 org.apache.juneau.ng.rest.client.remote;
+
+import java.io.*;
+import java.lang.reflect.*;
+
+import org.apache.juneau.ng.http.remote.*;
+import org.apache.juneau.ng.rest.client.*;
+
+/**
+ * Creates Java proxy instances for {@link Remote}-annotated interfaces, 
backed by an {@link NgRestClient}.
+ *
+ * <p>
+ * Each interface method call translates to an HTTP request via the client.
+ *
+ * <p>
+ * Obtain instances via {@link NgRestClient#remote(Class)}.
+ *
+ * <p class='bjava'>
+ *     <ja>@Remote</ja>(path=<js>"/api/users"</js>)
+ *     <jk>public interface</jk> UserService {{
+ *             <ja>@RemoteGet</ja>(<js>"/{id}"</js>)
+ *             String getUser(<ja>@Path</ja>(<js>"id"</js>) String id);
+ *     }}
+ *
+ *     UserService <jv>svc</jv> = client.remote(UserService.<jk>class</jk>);
+ *     String <jv>user</jv> = <jv>svc</jv>.getUser(<js>"42"</js>);
+ * </p>
+ *
+ * <p>
+ * <b>Beta — API subject to change:</b> This type is part of the 
next-generation REST client and HTTP stack
+ * ({@code org.apache.juneau.ng.*}).
+ * It is not API-frozen: binary- and source-incompatible changes may appear in 
the <b>next major</b> Juneau release
+ * (and possibly earlier).
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ *     <li class='link'><a class="doclink" 
href="https://juneau.apache.org/docs/topics/juneau-ng-rest-client";>juneau-ng 
REST client</a>
+ * </ul>
+ *
+ * @since 9.2.1
+ */
+public final class NgRemoteClient {
+
+       private final NgRestClient client;
+
+       /**
+        * Constructor.
+        *
+        * @param client The underlying REST client. Must not be <jk>null</jk>.
+        */
+       public NgRemoteClient(NgRestClient client) {
+               if (client == null)
+                       throw new IllegalArgumentException("client must not be 
null");
+               this.client = client;
+       }
+
+       /**
+        * Creates a Java proxy for the given {@link Remote}-annotated 
interface.
+        *
+        * @param <T> The interface type.
+        * @param iface The interface class. Must be annotated with {@link 
Remote}. Must not be <jk>null</jk>.
+        * @return A proxy instance. Never <jk>null</jk>.
+        * @throws IllegalArgumentException If {@code iface} is not an 
interface or not annotated with {@link Remote}.
+        */
+       @SuppressWarnings("unchecked")
+       public <T> T create(Class<T> iface) {
+               if (iface == null)
+                       throw new IllegalArgumentException("iface must not be 
null");
+               var meta = RrpcInterfaceMeta.of(iface);
+               return (T) Proxy.newProxyInstance(
+                       iface.getClassLoader(),
+                       new Class<?>[]{iface},
+                       new RemoteInvocationHandler(client, meta)
+               );
+       }
+
+       // 
------------------------------------------------------------------------------------------------------------------
+       // InvocationHandler
+       // 
------------------------------------------------------------------------------------------------------------------
+
+       @SuppressWarnings({
+               "resource" // resp is closed within this method or returned to 
caller (RESPONSE mode)
+       })
+       private static final class RemoteInvocationHandler implements 
InvocationHandler {
+
+               private final NgRestClient client;
+               private final RrpcInterfaceMeta meta;
+
+               RemoteInvocationHandler(NgRestClient client, RrpcInterfaceMeta 
meta) {
+                       this.client = client;
+                       this.meta = meta;
+               }
+
+               @Override
+               public Object invoke(Object proxy, Method method, Object[] 
args) throws Throwable {
+                       // Handle Object methods directly
+                       if (method.getDeclaringClass() == Object.class)
+                               return method.invoke(this, args);
+
+                       var methodMeta = meta.getMethodMeta(method);
+                       if (methodMeta == null)
+                               throw new UnsupportedOperationException("Method 
'" + method.getName() + "' has no @Remote* annotation");
+
+                       // Build the full path: basePath + methodPath
+                       var basePath = meta.getBasePath();
+                       var methodPath = methodMeta.getPath();
+                       var fullPath = combinePaths(basePath, methodPath);
+
+                       // Build the request
+                       var req = buildRequest(methodMeta.getHttpMethod(), 
fullPath, method, args);
+
+                       // Execute and process the return value
+                       return processReturn(req, methodMeta.getReturnType(), 
method.getReturnType());
+               }
+
+               private NgRestRequest buildRequest(String httpMethod, String 
path, Method method, Object[] args) {
+                       var req = switch (httpMethod) {
+                               case "GET" -> client.get(path);
+                               case "POST" -> client.post(path);
+                               case "PUT" -> client.put(path);
+                               case "PATCH" -> client.patch(path);
+                               case "DELETE" -> client.delete(path);
+                               default -> throw new 
IllegalArgumentException("Unsupported HTTP method: " + httpMethod); // HTT
+                       };
+
+                       // Process method parameters
+                       if (args != null) {
+                               var params = method.getParameters();
+                               for (int i = 0; i < params.length; i++) {
+                                       var param = params[i];
+                                       var arg = args[i];
+                                       if (arg == null)
+                                               continue;
+
+                                       // Check for @Path annotation on 
parameter
+                                       var pathAnnotation = 
param.getAnnotation(org.apache.juneau.ng.http.remote.Path.class);
+                                       if (pathAnnotation != null) {
+                                               var name = 
pathAnnotation.value().isEmpty() ? param.getName() : pathAnnotation.value();
+                                               req = req.pathData(name, 
String.valueOf(arg));
+                                               continue;
+                                       }
+
+                                       // Check for @Query annotation on 
parameter
+                                       var queryAnnotation = 
param.getAnnotation(org.apache.juneau.ng.http.remote.Query.class);
+                                       if (queryAnnotation != null) {
+                                               var name = 
queryAnnotation.value().isEmpty() ? param.getName() : queryAnnotation.value();
+                                               req = req.queryData(name, 
String.valueOf(arg));
+                                               continue;
+                                       }
+
+                                       // Check for @Header annotation on 
parameter
+                                       var headerAnnotation = 
param.getAnnotation(org.apache.juneau.ng.http.remote.Header.class);
+                                       if (headerAnnotation != null) {
+                                               var name = 
headerAnnotation.value().isEmpty() ? param.getName() : headerAnnotation.value();
+                                               req = req.header(name, 
String.valueOf(arg));
+                                               continue;
+                                       }
+
+                                       // Check for @Body annotation on 
parameter
+                                       var bodyAnnotation = 
param.getAnnotation(org.apache.juneau.ng.http.remote.Body.class);
+                                       if (bodyAnnotation != null) {
+                                               if (arg instanceof 
org.apache.juneau.ng.http.HttpBody b)
+                                                       req = req.body(b);
+                                               else
+                                                       req = 
req.bodyString(String.valueOf(arg));
+                                               continue;
+                                       }
+
+                                       // Single unannotated parameter — treat 
as body if it's the only one
+                                       if (params.length == 1) {
+                                               if (arg instanceof 
org.apache.juneau.ng.http.HttpBody b)
+                                                       req = req.body(b);
+                                               else
+                                                       req = 
req.bodyString(String.valueOf(arg));
+                                       }
+                               }
+                       }
+
+                       return req;
+               }
+
+               private Object processReturn(NgRestRequest req, RemoteReturn 
returnMode, Class<?> returnType) throws Exception {
+                       return switch (returnMode) {
+                       case BODY -> {
+                               try (var resp = req.run()) { // HTT - exception 
during close() branch
+                                       if (returnType == void.class || 
returnType == Void.class)
+                                               yield null;
+                                       if (returnType == String.class)
+                                               yield resp.getBodyAsString();
+                                       if (returnType == InputStream.class)
+                                               yield resp.getBodyStream();
+                                       if (returnType == byte[].class)
+                                               yield resp.body().asBytes();
+                                       yield resp.getBodyAsString();
+                               }
+                       }
+                       case STATUS -> {
+                               try (var resp = req.run()) { // HTT - exception 
during close() branch
+                                       var sc = resp.getStatusCode();
+                                       if (returnType == int.class || 
returnType == Integer.class)
+                                               yield sc;
+                                       if (returnType == boolean.class || 
returnType == Boolean.class)
+                                               yield sc < 400;
+                                       yield sc;
+                               }
+                       }
+                               case RESPONSE -> req.run(); // caller must close
+                       };
+               }
+
+               private static String combinePaths(String base, String method) {
+                       if (base.isEmpty())
+                               return method.isEmpty() ? "" : method;
+                       if (method.isEmpty())
+                               return base;
+                       // Avoid double slashes
+                       if (base.endsWith("/") && method.startsWith("/"))
+                               return base + method.substring(1);
+                       if (!base.endsWith("/") && !method.startsWith("/"))
+                               return base + "/" + method;
+                       return base + method;
+               }
+       }
+}


Reply via email to