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() {
+ * @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;
+ }
+ }
+}