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 00970c6bbe TODO-362: redesign NG RestClient debug logging onto
RichLogger
00970c6bbe is described below
commit 00970c6bbeb34b5d2a6f5b228d94cfe16f8adf33
Author: James Bognar <[email protected]>
AuthorDate: Sat Aug 15 15:12:38 2026 -0400
TODO-362: redesign NG RestClient debug logging onto RichLogger
Drive NG client debug via JUL logger level only (INFO=basic, FINE=+headers,
FINEST=+bodies) on RichLogger. Add RestClientDebugFormatter +
BasicRestClientDebugFormatter and a RestClientDebugPipeline for tier
resolution/emission. Add
RestClient.Builder.debugLoggerName(String)/debugFormatter(...) with default
logger name RestClient.class.getName(); wire RestRequest/RestResponse to emit
bounded request/response capture through RichLogger. Remove legacy
RestLogger/RestLogEntry/RestLogLevel [...]
---
.../rest/client/RestClientFeatures_Test.java | 648 ++++-----------------
.../rest/client/BasicRestClientDebugFormatter.java | 135 +++++
.../apache/juneau/rest/client/BasicRestLogger.java | 251 --------
.../org/apache/juneau/rest/client/RestClient.java | 42 +-
.../rest/client/RestClientDebugFormatter.java | 76 +++
.../rest/client/RestClientDebugPipeline.java | 53 ++
.../apache/juneau/rest/client/RestLogEntry.java | 298 ----------
.../juneau/rest/client/RestLogLevelResolver.java | 155 -----
.../org/apache/juneau/rest/client/RestLogger.java | 63 --
.../org/apache/juneau/rest/client/RestRequest.java | 182 +++++-
.../apache/juneau/rest/client/RestResponse.java | 122 +++-
.../juneau/rest/client/BasicRestLogger_Test.java | 74 ---
.../rest/client/RestClient_DebugLogging_Test.java | 177 ++++++
.../juneau/rest/client/RestLogEntry_Test.java | 91 ---
14 files changed, 871 insertions(+), 1496 deletions(-)
diff --git
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/RestClientFeatures_Test.java
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/RestClientFeatures_Test.java
index 14cd830812..74e30923ef 100644
---
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/RestClientFeatures_Test.java
+++
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/RestClientFeatures_Test.java
@@ -31,8 +31,8 @@ import org.apache.juneau.rest.mock.*;
import org.junit.jupiter.api.*;
/**
- * Tests for RestCallInterceptor, RestLogger, RestLogEntry,
RestLogLevelResolver,
- * BasicRestLogger, BodyConverter, and related RestClient features.
+ * Tests for RestCallInterceptor, client debug logging, BodyConverter,
+ * and related RestClient features.
*/
@SuppressWarnings({
"java:S5778" // assertThrows lambdas with chained calls; intermediate
invocations do not throw in practice
@@ -191,461 +191,132 @@ class RestClientFeatures_Test {
}
//
=================================================================================================================
- // B — RestLogger
+ // B — Client debug logging (RichLogger + formatter tiers)
//
=================================================================================================================
@Test
- void b01_logger_called_on_success() throws Exception {
- var entries = new ArrayList<RestLogEntry>();
- var transport = MockHttpTransport.of(200, "ok");
- try (var client = RestClient.builder()
- .transport(transport)
- .rootUrl("http://x.com")
- .logger(entries::add)
- .build()) {
- try (var r = client.get("/api").run()) { /*
Intentionally empty: response is executed and auto-closed via
try-with-resources; assertions follow. */ }
- }
- assertEquals(1, entries.size());
- var entry = entries.get(0);
- assertNotNull(entry.getRequest());
- assertNotNull(entry.getResponse());
- assertNull(entry.getError());
- assertEquals("GET", entry.getRequest().getMethod());
- assertEquals(200, entry.getStatusCode());
- assertFalse(entry.isError());
- assertFalse(entry.isDebug());
- assertEquals(System.Logger.Level.INFO, entry.getLevel());
- }
-
- @Test
- void b02_logger_called_on_transport_error() throws Exception {
- var entries = new ArrayList<RestLogEntry>();
- var transport = MockHttpTransport.builder()
- .fallback(req -> { throw new
TransportException("simulated failure"); })
- .build();
- try (var client = RestClient.builder()
- .transport(transport)
- .rootUrl("http://x.com")
- .logger(entries::add)
- .build()) {
- assertThrows(TransportException.class, () ->
client.get("/api").run());
- }
- assertEquals(1, entries.size());
- var entry = entries.get(0);
- assertNotNull(entry.getError());
- assertNull(entry.getResponse());
- assertEquals(0, entry.getStatusCode());
- assertTrue(entry.isError());
- assertEquals(System.Logger.Level.ERROR, entry.getLevel());
- }
-
- @Test
- void b03_logger_status400_is_warning() throws Exception {
- var entries = new ArrayList<RestLogEntry>();
- var transport = MockHttpTransport.of(404, "Not Found");
- try (var client = RestClient.builder()
- .transport(transport)
- .rootUrl("http://x.com")
- .logger(entries::add)
- .build()) {
- try (var r = client.get("/missing").run()) { /*
Intentionally empty: response is executed and auto-closed via
try-with-resources; assertions follow. */ }
- }
- assertEquals(1, entries.size());
- assertEquals(System.Logger.Level.WARNING,
entries.get(0).getLevel());
- assertTrue(entries.get(0).isError());
- }
-
- @Test
- void b04_logger_status500_is_error() throws Exception {
- var entries = new ArrayList<RestLogEntry>();
- var transport = MockHttpTransport.of(500, "Internal Server
Error");
- try (var client = RestClient.builder()
- .transport(transport)
- .rootUrl("http://x.com")
- .logger(entries::add)
- .build()) {
- try (var r = client.get("/crash").run()) { /*
Intentionally empty: response is executed and auto-closed via
try-with-resources; assertions follow. */ }
- }
- assertEquals(1, entries.size());
- assertEquals(System.Logger.Level.ERROR,
entries.get(0).getLevel());
- }
-
- @Test
- void b05_debug_flag_set_on_entry() throws Exception {
- var entries = new ArrayList<RestLogEntry>();
- var transport = MockHttpTransport.of(200, "ok");
- try (var client = RestClient.builder()
- .transport(transport)
- .rootUrl("http://x.com")
- .logger(entries::add)
- .build()) {
- try (var r = client.get("/").debug().run()) { /*
Intentionally empty: response is executed and auto-closed via
try-with-resources; assertions follow. */ }
- }
- assertTrue(entries.get(0).isDebug());
- }
-
- @Test
- void b06_logger_null_no_logging() throws Exception {
- // Without logger, run() should complete normally
- var transport = MockHttpTransport.of(200, "ok");
- try (var client = RestClient.builder()
- .transport(transport)
- .rootUrl("http://x.com")
- .build()) {
- try (var r = client.get("/").run()) {
- assertEquals(200, r.getStatusCode());
+ void b01_infoTier_emitsBasicOnly() throws Exception {
+ var loggerName = getClass().getName() + ".b01";
+ var logger =
org.apache.juneau.commons.logging.RichLogger.getLogger(loggerName);
+ var previous = logger.getLevel();
+ logger.setLevel(java.util.logging.Level.INFO);
+ try (var capture =
logger.captureEvents(java.util.logging.Level.INFO)) {
+ try (var client = RestClient.builder()
+ .transport(MockHttpTransport.of(200,
"ok"))
+ .rootUrl("http://x.com")
+ .debugLoggerName(loggerName)
+ .build();
+ var response = client.get("/info").run()) {
+ assertEquals(200, response.getStatusCode());
}
- }
- }
-
- //
=================================================================================================================
- // C — RestLogEntry.format()
- //
=================================================================================================================
-
- @Test
- void c01_format_default_success() throws Exception {
- var entries = new ArrayList<RestLogEntry>();
- var transport = MockHttpTransport.builder()
- .fallback(req ->
TransportResponse.builder().statusCode(200).reasonPhrase("OK").build())
- .build();
- try (var client = RestClient.builder()
- .transport(transport)
- .rootUrl("http://x.com")
- .logger(entries::add)
- .build()) {
- try (var r = client.get("/users").run()) { /*
Intentionally empty: response is executed and auto-closed via
try-with-resources; assertions follow. */ }
- }
- var fmt = entries.get(0).format();
- assertTrue(fmt.contains("GET"), "format should contain method:
" + fmt);
- assertTrue(fmt.contains("200"), "format should contain status:
" + fmt);
- assertTrue(fmt.contains("ms"), "format should contain elapsed:
" + fmt);
- }
-
- @Test
- void c02_format_with_template() throws Exception {
- var entries = new ArrayList<RestLogEntry>();
- var transport = MockHttpTransport.of(200, "OK");
- try (var client = RestClient.builder()
- .transport(transport)
- .rootUrl("http://example.com")
- .logger(entries::add)
- .build()) {
- try (var r = client.get("/items").run()) { /*
Intentionally empty: response is executed and auto-closed via
try-with-resources; assertions follow. */ }
- }
- var entry = entries.get(0);
- assertEquals("GET", entry.format("{method}"));
- assertTrue(entry.format("{uri}").contains("/items"));
- assertEquals("200", entry.format("{status}"));
- assertTrue(entry.format("{elapsed}").endsWith("ms"));
- assertEquals("", entry.format("{error}"));
- }
-
- @Test
- void c03_format_error_template() throws Exception {
- var entries = new ArrayList<RestLogEntry>();
- var transport = MockHttpTransport.builder()
- .fallback(req -> { throw new
TransportException("connection refused"); })
- .build();
- try (var client = RestClient.builder()
- .transport(transport)
- .rootUrl("http://x.com")
- .logger(entries::add)
- .build()) {
- assertThrows(TransportException.class, () ->
client.get("/").run());
- }
- var entry = entries.get(0);
- assertTrue(entry.format("{error}").contains("connection
refused"));
- }
-
- @Test
- void c04_hasResponseHeader() throws Exception {
- var entries = new ArrayList<RestLogEntry>();
- var transport = MockHttpTransport.builder()
- .fallback(req -> TransportResponse.builder()
- .statusCode(200)
- .header("X-Custom", "value")
- .build())
- .build();
- try (var client = RestClient.builder()
- .transport(transport)
- .rootUrl("http://x.com")
- .logger(entries::add)
- .build()) {
- try (var r = client.get("/").run()) { /* Intentionally
empty: response is executed and auto-closed via try-with-resources; assertions
follow. */ }
- }
- var entry = entries.get(0);
- assertTrue(entry.hasResponseHeader("X-Custom"));
- assertFalse(entry.hasResponseHeader("X-Missing"));
- }
-
- @Test
- void c05_hasResponseHeader_noResponse() throws Exception {
- var entries = new ArrayList<RestLogEntry>();
- var transport = MockHttpTransport.builder()
- .fallback(req -> { throw new
TransportException("fail"); })
- .build();
- try (var client = RestClient.builder()
- .transport(transport)
- .rootUrl("http://x.com")
- .logger(entries::add)
- .build()) {
- assertThrows(TransportException.class, () ->
client.get("/").run());
- }
- assertFalse(entries.get(0).hasResponseHeader("Anything"));
- }
-
- @Test
- void c06_getUri_set_after_run() throws Exception {
- var entries = new ArrayList<RestLogEntry>();
- var transport = MockHttpTransport.of(200, "ok");
- try (var client = RestClient.builder()
- .transport(transport)
- .rootUrl("http://x.com")
- .logger(entries::add)
- .build()) {
- try (var r = client.get("/path").run()) { /*
Intentionally empty: response is executed and auto-closed via
try-with-resources; assertions follow. */ }
- }
- var uri = entries.get(0).getRequest().getUri();
- assertNotNull(uri);
- assertTrue(uri.toString().contains("/path"));
- }
-
- //
=================================================================================================================
- // D — RestLogLevelResolver
- //
=================================================================================================================
-
- @Test
- void d01_default_resolver_info_for_2xx() {
- var entry = stubEntry(200, null);
- assertEquals(System.Logger.Level.INFO,
RestLogLevelResolver.DEFAULT.resolve(entry));
- }
-
- @Test
- void d02_default_resolver_warning_for_4xx() {
- var entry = stubEntry(404, null);
- assertEquals(System.Logger.Level.WARNING,
RestLogLevelResolver.DEFAULT.resolve(entry));
- }
-
- @Test
- void d03_default_resolver_error_for_5xx() {
- var entry = stubEntry(500, null);
- assertEquals(System.Logger.Level.ERROR,
RestLogLevelResolver.DEFAULT.resolve(entry));
- }
-
- @Test
- void d04_default_resolver_error_for_transport_error() {
- var entry = stubEntry(0, new TransportException("fail"));
- assertEquals(System.Logger.Level.ERROR,
RestLogLevelResolver.DEFAULT.resolve(entry));
- }
-
- @Test
- void d05_custom_resolver_via_rules() {
- var resolver = RestLogLevelResolver.rules()
- .rule(System.Logger.Level.ERROR, e -> e.getStatusCode()
>= 500)
- .rule(System.Logger.Level.WARNING, e ->
e.getStatusCode() >= 400)
- .defaultLevel(System.Logger.Level.DEBUG)
- .build();
- assertEquals(System.Logger.Level.DEBUG,
resolver.resolve(stubEntry(200, null)));
- assertEquals(System.Logger.Level.WARNING,
resolver.resolve(stubEntry(400, null)));
- assertEquals(System.Logger.Level.ERROR,
resolver.resolve(stubEntry(503, null)));
- }
-
- @Test
- void d06_default_resolver_warning_for_thrown_header() throws Exception {
- var entries = new ArrayList<RestLogEntry>();
- var transport = MockHttpTransport.builder()
- .fallback(req -> TransportResponse.builder()
- .statusCode(200)
- .header("Thrown",
"com.example.MyException;message")
- .build())
- .build();
- try (var client = RestClient.builder()
- .transport(transport)
- .rootUrl("http://x.com")
- .logger(entries::add)
- .build()) {
- try (var r = client.get("/").run()) { /* Intentionally
empty: response is executed and auto-closed via try-with-resources; assertions
follow. */ }
- }
- // Thrown header present with 200 → WARNING
- assertEquals(System.Logger.Level.WARNING,
entries.get(0).getLevel());
- }
-
- //
=================================================================================================================
- // E — BasicRestLogger
- //
=================================================================================================================
-
- @Test
- void e01_basicRestLogger_of_logs_info() throws Exception {
- var messages = new ArrayList<String>();
- var jdkLogger = new System.Logger() {
- @Override public String getName() { return "test"; }
- @Override public boolean isLoggable(Level level) {
return true; }
- @Override public void log(Level level, ResourceBundle
bundle, String msg, Throwable thrown) { messages.add(level + ":" + msg); }
- @Override public void log(Level level, ResourceBundle
bundle, String format, Object... params) { messages.add(level + ":" + format); }
- };
- var transport = MockHttpTransport.builder()
- .fallback(req ->
TransportResponse.builder().statusCode(200).reasonPhrase("OK").build())
- .build();
- try (var client = RestClient.builder()
- .transport(transport)
- .rootUrl("http://x.com")
- .logger(BasicRestLogger.of(jdkLogger))
- .build()) {
- try (var r = client.get("/test").run()) { /*
Intentionally empty: response is executed and auto-closed via
try-with-resources; assertions follow. */ }
- }
- assertEquals(1, messages.size());
- assertTrue(messages.get(0).startsWith("INFO:"), "Should log at
INFO level: " + messages.get(0));
- assertTrue(messages.get(0).contains("200"), "Message should
contain status: " + messages.get(0));
- }
-
- @Test
- void e02_basicRestLogger_logs_error_with_exception() throws Exception {
- var messages = new ArrayList<String>();
- var throwables = new ArrayList<Throwable>();
- var jdkLogger = new System.Logger() {
- @Override public String getName() { return "test"; }
- @Override public boolean isLoggable(Level level) {
return true; }
- @Override public void log(Level level, ResourceBundle
bundle, String msg, Throwable thrown) {
- messages.add(level + ":" + msg);
- throwables.add(thrown);
+ assertNotNull(capture.last());
+ assertEquals(java.util.logging.Level.INFO,
capture.last().getLevel());
+ var msg = capture.last().getMessage();
+ assertTrue(msg.contains("HTTP GET"), "Basic message
should include request line");
+ assertFalse(msg.contains("---Request Headers---"),
"INFO should not include headers");
+ assertFalse(msg.contains("---Response Content
UTF-8---"), "INFO should not include bodies");
+ } finally {
+ logger.setLevel(previous);
+ }
+ }
+
+ @Test
+ void b02_fineTier_includesHeadersAndRedacts() throws Exception {
+ var loggerName = getClass().getName() + ".b02";
+ var logger =
org.apache.juneau.commons.logging.RichLogger.getLogger(loggerName);
+ var previous = logger.getLevel();
+ logger.setLevel(java.util.logging.Level.FINE);
+ try (var capture =
logger.captureEvents(java.util.logging.Level.FINE)) {
+ try (var client = RestClient.builder()
+ .transport(MockHttpTransport.builder()
+ .fallback(req ->
TransportResponse.builder()
+ .statusCode(200)
+
.header("Authorization", "Bearer response-secret")
+ .build())
+ .build())
+ .rootUrl("http://x.com")
+ .debugLoggerName(loggerName)
+ .build();
+ var response =
client.get("/headers").header("Authorization", "Bearer request-secret").run()) {
+ assertEquals(200, response.getStatusCode());
}
- @Override public void log(Level level, ResourceBundle
bundle, String format, Object... params) { messages.add(level + ":" + format); }
- };
- var transport = MockHttpTransport.builder()
- .fallback(req -> { throw new
TransportException("network error"); })
- .build();
- try (var client = RestClient.builder()
- .transport(transport)
- .rootUrl("http://x.com")
- .logger(BasicRestLogger.of(jdkLogger))
- .build()) {
- assertThrows(TransportException.class, () ->
client.get("/fail").run());
- }
- assertEquals(1, messages.size());
- assertTrue(messages.get(0).startsWith("ERROR:"), "Should log at
ERROR level: " + messages.get(0));
- assertNotNull(throwables.get(0), "Throwable should be included
on ERROR");
- }
-
- @Test
- void e03_basicRestLogger_filter_suppresses_entry() throws Exception {
- var messages = new ArrayList<String>();
- var jdkLogger = new System.Logger() {
- @Override public String getName() { return "test"; }
- @Override public boolean isLoggable(Level level) {
return true; }
- @Override public void log(Level level, ResourceBundle
bundle, String msg, Throwable thrown) { messages.add(msg); }
- @Override public void log(Level level, ResourceBundle
bundle, String format, Object... params) { messages.add(format); }
- };
- var transport = MockHttpTransport.of(200, "ok");
- try (var client = RestClient.builder()
- .transport(transport)
- .rootUrl("http://x.com")
- .logger(BasicRestLogger.create()
- .logger(jdkLogger)
- .filter(e -> false) // filter all
- .build())
- .build()) {
- try (var r = client.get("/").run()) { /* Intentionally
empty: response is executed and auto-closed via try-with-resources; assertions
follow. */ }
- }
- assertTrue(messages.isEmpty(), "Filtered logger should log
nothing");
- }
-
- @Test
- void e04_basicRestLogger_customTemplates() throws Exception {
- var messages = new ArrayList<String>();
- var jdkLogger = new System.Logger() {
- @Override public String getName() { return "test"; }
- @Override public boolean isLoggable(Level level) {
return true; }
- @Override public void log(Level level, ResourceBundle
bundle, String msg, Throwable thrown) { messages.add(msg); }
- @Override public void log(Level level, ResourceBundle
bundle, String format, Object... params) { messages.add(format); }
- };
- var transport = MockHttpTransport.of(200, "ok");
- try (var client = RestClient.builder()
- .transport(transport)
- .rootUrl("http://x.com")
- .logger(BasicRestLogger.create()
- .logger(jdkLogger)
-
.infoTemplate("CUSTOM:{method}:{status}")
- .build())
- .build()) {
- try (var r = client.get("/").run()) { /* Intentionally
empty: response is executed and auto-closed via try-with-resources; assertions
follow. */ }
- }
- assertEquals(1, messages.size());
- assertTrue(messages.get(0).startsWith("CUSTOM:GET:200"),
"Expected custom template: " + messages.get(0));
- }
-
- @Test
- void e05_basicRestLogger_debug_entry_uses_debugTemplate() throws
Exception {
- var messages = new ArrayList<String>();
- var jdkLogger = new System.Logger() {
- @Override public String getName() { return "test"; }
- @Override public boolean isLoggable(Level level) {
return true; }
- @Override public void log(Level level, ResourceBundle
bundle, String msg, Throwable thrown) { messages.add(level + ":" + msg); }
- @Override public void log(Level level, ResourceBundle
bundle, String format, Object... params) { messages.add(level + ":" + format); }
- };
- var transport = MockHttpTransport.of(200, "ok");
- try (var client = RestClient.builder()
- .transport(transport)
- .rootUrl("http://x.com")
- .logger(BasicRestLogger.create()
- .logger(jdkLogger)
- .debugTemplate("DBG:{method}")
- .build())
- .build()) {
- try (var r = client.get("/").debug().run()) { /*
Intentionally empty: response is executed and auto-closed via
try-with-resources; assertions follow. */ }
- }
- assertEquals(1, messages.size());
- assertTrue(messages.get(0).startsWith("DEBUG:DBG:GET"),
"Expected debug template: " + messages.get(0));
- }
-
- @Test
- void e06_basicRestLogger_warning_level() throws Exception {
- var messages = new ArrayList<String>();
- var jdkLogger = new System.Logger() {
- @Override public String getName() { return "test"; }
- @Override public boolean isLoggable(Level level) {
return true; }
- @Override public void log(Level level, ResourceBundle
bundle, String msg, Throwable thrown) { messages.add(level + ":" + msg); }
- @Override public void log(Level level, ResourceBundle
bundle, String format, Object... params) { messages.add(level + ":" + format); }
- };
- var transport = MockHttpTransport.of(404, "Not Found");
- try (var client = RestClient.builder()
- .transport(transport)
- .rootUrl("http://x.com")
- .logger(BasicRestLogger.create()
- .logger(jdkLogger)
- .warningTemplate("WARN:{status}")
- .build())
- .build()) {
- try (var r = client.get("/missing").run()) { /*
Intentionally empty: response is executed and auto-closed via
try-with-resources; assertions follow. */ }
- }
- assertEquals(1, messages.size());
- assertTrue(messages.get(0).startsWith("WARNING:WARN:404"),
"Expected warning template: " + messages.get(0));
- }
-
- @Test
- void e07_basicRestLogger_customLevelResolver() throws Exception {
- var messages = new ArrayList<String>();
- var jdkLogger = new System.Logger() {
- @Override public String getName() { return "test"; }
- @Override public boolean isLoggable(Level level) {
return true; }
- @Override public void log(Level level, ResourceBundle
bundle, String msg, Throwable thrown) { messages.add(level + ":" + msg); }
- @Override public void log(Level level, ResourceBundle
bundle, String format, Object... params) { messages.add(level + ":" + format); }
- };
- var transport = MockHttpTransport.of(200, "ok");
- var alwaysError = RestLogLevelResolver.rules()
- .defaultLevel(System.Logger.Level.ERROR)
- .build();
- try (var client = RestClient.builder()
- .transport(transport)
- .rootUrl("http://x.com")
- .logger(BasicRestLogger.create()
- .logger(jdkLogger)
- .levelResolver(alwaysError)
- .build())
- .build()) {
- try (var r = client.get("/").run()) { /* Intentionally
empty: response is executed and auto-closed via try-with-resources; assertions
follow. */ }
+ assertNotNull(capture.last());
+ assertEquals(java.util.logging.Level.FINE,
capture.last().getLevel());
+ var msg = capture.last().getMessage();
+ assertTrue(msg.contains("---Request Headers---"), "FINE
should include request headers");
+ assertTrue(msg.contains("---Response Headers---"),
"FINE should include response headers");
+ assertTrue(msg.contains("Authorization:"),
"Authorization header should be present");
+ assertFalse(msg.contains("request-secret"), "Raw
request secret should not leak");
+ assertFalse(msg.contains("response-secret"), "Raw
response secret should not leak");
+ } finally {
+ logger.setLevel(previous);
+ }
+ }
+
+ @Test
+ void b03_finestTier_includesBodies() throws Exception {
+ var loggerName = getClass().getName() + ".b03";
+ var logger =
org.apache.juneau.commons.logging.RichLogger.getLogger(loggerName);
+ var previous = logger.getLevel();
+ logger.setLevel(java.util.logging.Level.FINEST);
+ try (var capture =
logger.captureEvents(java.util.logging.Level.FINEST)) {
+ try (var client = RestClient.builder()
+ .transport(MockHttpTransport.builder()
+ .fallback(req -> {
+ if (req.getBody() !=
null) {
+ try {
+
req.getBody().writeTo(new ByteArrayOutputStream());
+ } catch
(IOException e) {
+ throw
new RuntimeException(e);
+ }
+ }
+ return
TransportResponse.builder()
+ .statusCode(200)
+ .body(new
ByteArrayInputStream("uvwxyz".getBytes(StandardCharsets.UTF_8)))
+ .build();
+ })
+ .build())
+ .rootUrl("http://x.com")
+ .debugLoggerName(loggerName)
+ .debugFormatter(new
BasicRestClientDebugFormatter().bodyCap(4))
+ .build();
+ var response =
client.post("/body").bodyString("abcdef").run()) {
+ assertEquals("uvwxyz",
response.body().asString());
+ }
+ assertNotNull(capture.last());
+ assertEquals(java.util.logging.Level.FINEST,
capture.last().getLevel());
+ var msg = capture.last().getMessage();
+ assertTrue(msg.contains("---Request Content UTF-8---"));
+ assertTrue(msg.contains("abcd"));
+ assertTrue(msg.contains("---Response Content
UTF-8---"));
+ assertTrue(msg.contains("uvwx"));
+ } finally {
+ logger.setLevel(previous);
+ }
+ }
+
+ @Test
+ void b04_transportError_emitsThrown() throws Exception {
+ var loggerName = getClass().getName() + ".b04";
+ var logger =
org.apache.juneau.commons.logging.RichLogger.getLogger(loggerName);
+ var previous = logger.getLevel();
+ logger.setLevel(java.util.logging.Level.INFO);
+ try (var capture =
logger.captureEvents(java.util.logging.Level.INFO)) {
+ try (var client = RestClient.builder()
+ .transport(MockHttpTransport.builder()
+ .fallback(req -> { throw new
TransportException("simulated failure"); })
+ .build())
+ .rootUrl("http://x.com")
+ .debugLoggerName(loggerName)
+ .build()) {
+ assertThrows(TransportException.class, () ->
client.get("/fail").run());
+ }
+ assertNotNull(capture.last());
+ assertNotNull(capture.last().getThrown());
+
assertTrue(capture.last().getThrown().getMessage().contains("simulated
failure"));
+ } finally {
+ logger.setLevel(previous);
}
- assertEquals(1, messages.size());
- assertTrue(messages.get(0).startsWith("ERROR:"), "Should log at
ERROR even for 200: " + messages.get(0));
}
//
=================================================================================================================
@@ -952,51 +623,6 @@ class RestClientFeatures_Test {
// K — Additional coverage tests
//
=================================================================================================================
- @Test
- void k01_basicRestLogger_filter_allows_entry() throws Exception {
- // When filter is non-null and returns TRUE, the entry IS logged
- var messages = new ArrayList<String>();
- var jdkLogger = new System.Logger() {
- @Override public String getName() { return "test"; }
- @Override public boolean isLoggable(Level level) {
return true; }
- @Override public void log(Level level, ResourceBundle
bundle, String msg, Throwable thrown) { messages.add(msg); }
- @Override public void log(Level level, ResourceBundle
bundle, String format, Object... params) { messages.add(format); }
- };
- var transport = MockHttpTransport.of(200, "ok");
- try (var client = RestClient.builder()
- .transport(transport)
- .rootUrl("http://x.com")
- .logger(BasicRestLogger.create()
- .logger(jdkLogger)
- .filter(e -> true) // filter allows all
entries
- .build())
- .build()) {
- try (var r = client.get("/").run()) { /* Intentionally
empty: response is executed and auto-closed via try-with-resources; assertions
follow. */ }
- }
- // With filter returning true, entry IS logged
- assertEquals(1, messages.size(), "Entry should be logged when
filter returns true");
- }
-
- @Test
- void k02_restLogEntry_error_with_null_message() throws Exception {
- // Error with null message falls back to class simple name
- var entries = new ArrayList<RestLogEntry>();
- var transport = MockHttpTransport.builder()
- .fallback(req -> { throw new
TransportException((String)null); })
- .build();
- try (var client = RestClient.builder()
- .transport(transport)
- .rootUrl("http://x.com")
- .logger(entries::add)
- .build()) {
- assertThrows(TransportException.class, () ->
client.get("/").run());
- }
- var entry = entries.get(0);
- // {error} should use class simple name when getMessage() is
null
- var errorText = entry.format("{error}");
- assertTrue(errorText.contains("TransportException"), "Error
text should contain class name when message is null: " + errorText);
- }
-
@Test
void k03_ngRestResponse_getHeaders() throws Exception {
var transport = MockHttpTransport.builder()
@@ -1594,34 +1220,4 @@ class RestClientFeatures_Test {
}
}
- //
=================================================================================================================
- // Helpers
- //
=================================================================================================================
-
- /** Creates a stub RestLogEntry by making a real mock call and
capturing the logger entry. */
- private static RestLogEntry stubEntry(int status, Throwable error) {
- var entries = new ArrayList<RestLogEntry>();
- MockHttpTransport transport;
- if (error != null) {
- transport = MockHttpTransport.builder()
- .fallback(req -> { throw new
TransportException(error.getMessage()); })
- .build();
- } else {
- transport = MockHttpTransport.builder()
- .fallback(req ->
TransportResponse.builder().statusCode(status).build())
- .build();
- }
- try (var client = RestClient.builder()
- .transport(transport)
- .rootUrl("http://x.com")
- .logger(entries::add)
- .build()) {
- try {
- try (var r = client.get("/").run()) { /*
Intentionally empty: response is executed and auto-closed via
try-with-resources; assertions follow. */ }
- } catch (Exception ignored) { /* Intentionally empty:
the request may fail in error/exception scenarios; the test only inspects the
captured log entries. */ }
- } catch (Exception e) {
- throw new RuntimeException(e);
- }
- return entries.get(0);
- }
}
diff --git
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/BasicRestClientDebugFormatter.java
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/BasicRestClientDebugFormatter.java
new file mode 100644
index 0000000000..4faaea9ad2
--- /dev/null
+++
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/BasicRestClientDebugFormatter.java
@@ -0,0 +1,135 @@
+/*
+ * 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.rest.client;
+
+import static org.apache.juneau.commons.utils.IoUtils.*;
+import static org.apache.juneau.commons.utils.StringUtils.*;
+
+import java.net.*;
+import java.util.*;
+
+import org.apache.juneau.http.*;
+
+/**
+ * Default {@link RestClientDebugFormatter} implementation.
+ *
+ * @since 10.0.0
+ */
+public class BasicRestClientDebugFormatter implements RestClientDebugFormatter
{
+
+ /** Default body capture cap, in bytes (8 KB). */
+ public static final int DEFAULT_BODY_CAP = 8 * 1024;
+
+ /** The set of header names whose values are masked. */
+ protected Set<String> redactedHeaders = RedactedHeaders.DEFAULT;
+
+ /** The body capture cap in bytes. */
+ protected int bodyCap = DEFAULT_BODY_CAP;
+
+ /** Constructor. */
+ public BasicRestClientDebugFormatter() {}
+
+ /**
+ * Overrides the redacted-header set.
+ *
+ * @param value The new set of header names to mask.
+ * @return This object.
+ */
+ public BasicRestClientDebugFormatter redactedHeaders(Collection<String>
value) {
+ redactedHeaders = new LinkedHashSet<>(value);
+ return this;
+ }
+
+ /**
+ * Overrides the body capture cap.
+ *
+ * @param value The new cap in bytes.
+ * @return This object.
+ */
+ public BasicRestClientDebugFormatter bodyCap(int value) {
+ bodyCap = value;
+ return this;
+ }
+
+ @Override /* RestClientDebugFormatter */
+ public int bodyCap() {
+ return bodyCap;
+ }
+
+ @Override /* RestClientDebugFormatter */
+ public String formatBasic(RestRequest req, RestResponse res) {
+ var uri = req.getUri();
+ return new StringBuilder()
+ .append('[').append(res.getStatusCode()).append("] ")
+ .append("HTTP ").append(req.getMethod()).append(' ')
+ .append(uri != null ? uri : URI.create(""))
+ .append(" (").append(req.getExecTime() != null ?
req.getExecTime().toMillis() : 0).append("ms)")
+ .toString();
+ }
+
+ @Override /* RestClientDebugFormatter */
+ public String formatHeaders(RestRequest req, RestResponse res) {
+ var sb = new StringBuilder();
+
+ var reqLen = req.getCachedContentLength();
+ if (reqLen >= 0)
+ sb.append("\n\tRequest length:
").append(reqLen).append(" bytes");
+
+ sb.append("\n\tResponse code: ").append(res.getStatusCode());
+
+ var resLen = res.getCachedContentLength();
+ if (resLen >= 0)
+ sb.append("\n\tResponse length:
").append(resLen).append(" bytes");
+
+ if (!req.getResolvedHeaders().isEmpty()) {
+ sb.append("\n---Request Headers---");
+ for (var h : req.getResolvedHeaders())
+ sb.append("\n\t").append(h.name()).append(":
").append(RedactedHeaders.redact(h.name(), h.value(), redactedHeaders));
+ }
+
+ if (!res.getHeaders().isEmpty()) {
+ sb.append("\n---Response Headers---");
+ for (var h : res.getHeaders())
+ sb.append("\n\t").append(h.name()).append(":
").append(RedactedHeaders.redact(h.name(), h.value(), redactedHeaders));
+ }
+ return sb.toString();
+ }
+
+ @Override /* RestClientDebugFormatter */
+ public String formatBody(RestRequest req, RestResponse res) {
+ var sb = new StringBuilder();
+ appendBody(sb, "Request", req.getCachedContent(),
req.getCachedContentLength());
+ appendBody(sb, "Response", res.getCachedContent(),
res.getCachedContentLength());
+ return sb.toString();
+ }
+
+ private void appendBody(StringBuilder sb, String label, byte[] content,
long totalLength) {
+ if (content == null || content.length == 0)
+ return;
+ try {
+ sb.append("\n---").append(label).append(" Content
UTF-8---");
+ sb.append("\n").append(new String(content, UTF8));
+ sb.append("\n---").append(label).append(" Content
Hex---");
+ sb.append("\n").append(toSpacedHex(content));
+ var omitted = totalLength - content.length;
+ if (omitted > 0)
+ sb.append("\n…[truncated
").append(omitted).append(" bytes]");
+ } catch (Exception e) {
+ sb.append("\n").append(e.getLocalizedMessage());
+ }
+ }
+}
diff --git
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/BasicRestLogger.java
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/BasicRestLogger.java
deleted file mode 100644
index 938ddf8f7b..0000000000
---
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/BasicRestLogger.java
+++ /dev/null
@@ -1,251 +0,0 @@
-/*
- * 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.rest.client;
-
-import java.util.function.*;
-
-/**
- * Convenience {@link RestLogger} implementation that routes log entries to a
{@link java.lang.System.Logger}.
- *
- * <p>
- * Uses the JDK 9+ {@link java.lang.System.Logger} facade, which automatically
routes to whatever logging framework is on
- * the classpath (SLF4J, Log4j 2, Logback, JUL) with no additional dependency.
- *
- * <h5 class='section'>Example — shorthand:</h5>
- * <p class='bjava'>
- * RestClient <jv>client</jv> = RestClient.<jsm>builder</jsm>()
- *
.logger(BasicRestLogger.<jsm>of</jsm>(System.<jsm>getLogger</jsm>(<js>"myapp.http"</js>)))
- * .build();
- * </p>
- *
- * <h5 class='section'>Example — custom level resolver and template:</h5>
- * <p class='bjava'>
- * RestClient <jv>client</jv> = RestClient.<jsm>builder</jsm>()
- * .logger(BasicRestLogger.<jsm>create</jsm>()
- *
.logger(System.<jsm>getLogger</jsm>(<js>"myapp.http"</js>))
- * .infoTemplate(<js>"{method} {uri} -> {status}
({elapsed})"</js>)
- * .errorTemplate(<js>"{method} {uri} -> {error}
({elapsed})"</js>)
- * .build())
- * .build();
- * </p>
- *
- * <p>
- * <b>Default behavior</b> (no filter, all requests logged, using {@link
RestLogLevelResolver#DEFAULT}):
- * <ul>
- * <li>{@code INFO} → {@code "GET https://api.example.com/users -> 200 OK
(42ms)"}
- * <li>{@code WARNING} → {@code "GET https://api.example.com/users -> 404
Not Found (8ms)"}
- * <li>{@code ERROR} → {@code "GET https://api.example.com/users ->
connection refused (12ms)"} + exception
- * </ul>
- *
- * <p>
- * <b>Beta — API subject to change:</b> This type is part of the
next-generation REST client and HTTP stack.
- * 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/NextGenRestClient">juneau-ng REST
client</a>
- * </ul>
- *
- * @since 9.2.1
- */
-public final class BasicRestLogger implements RestLogger {
-
- private static final String DEFAULT_INFO_TEMPLATE = "{method} {uri}
-> {status} {reason} ({elapsed})";
- private static final String DEFAULT_WARNING_TEMPLATE = "{method} {uri}
-> {status} {reason} ({elapsed})";
- private static final String DEFAULT_ERROR_TEMPLATE = "{method} {uri}
-> {error} ({elapsed})";
- private static final String DEFAULT_DEBUG_TEMPLATE =
- "--> {method} {uri}\n (elapsed: {elapsed}, status: {status}
{reason})";
-
- private final System.Logger logger;
- private final RestLogLevelResolver levelResolver;
- private final Predicate<RestLogEntry> filter;
- private final String infoTemplate;
- private final String warningTemplate;
- private final String errorTemplate;
- private final String debugTemplate;
-
- private BasicRestLogger(Builder builder) {
- this.logger = builder.logger;
- this.levelResolver = builder.levelResolver;
- this.filter = builder.filter;
- this.infoTemplate = builder.infoTemplate;
- this.warningTemplate = builder.warningTemplate;
- this.errorTemplate = builder.errorTemplate;
- this.debugTemplate = builder.debugTemplate;
- }
-
- /**
- * Creates a {@link BasicRestLogger} using {@link
RestLogLevelResolver#DEFAULT} and default templates.
- *
- * @param logger The target {@link java.lang.System.Logger}. Must not
be <jk>null</jk>.
- * @return A new instance. Never <jk>null</jk>.
- */
- public static BasicRestLogger of(System.Logger logger) {
- return create().logger(logger).build();
- }
-
- /**
- * Returns a new builder for full configuration.
- *
- * @return A new builder. Never <jk>null</jk>.
- */
- public static Builder create() {
- return new Builder();
- }
-
- @Override /* RestLogger */
- public void log(RestLogEntry entry) {
- if (filter != null && !filter.test(entry))
- return;
-
- if (entry.isDebug()) {
- logger.log(System.Logger.Level.DEBUG,
entry.format(debugTemplate));
- return;
- }
-
- // Use this logger's own resolver (may differ from the DEFAULT
used to compute entry.getLevel())
- var level = levelResolver.resolve(entry);
-
- var template = switch (level) {
- case ERROR -> errorTemplate;
- case WARNING -> warningTemplate;
- default -> infoTemplate;
- };
-
- var message = entry.format(template);
-
- if (level == System.Logger.Level.ERROR && entry.getError() !=
null)
- logger.log(level, message, entry.getError());
- else
- logger.log(level, message);
- }
-
- //
-----------------------------------------------------------------------------------------------------------------
- // Builder
- //
-----------------------------------------------------------------------------------------------------------------
-
- /**
- * Fluent builder for {@link BasicRestLogger}.
- *
- * @since 9.2.1
- */
- public static final class Builder {
-
- System.Logger logger;
- RestLogLevelResolver levelResolver =
RestLogLevelResolver.DEFAULT;
- Predicate<RestLogEntry> filter;
- String infoTemplate = DEFAULT_INFO_TEMPLATE;
- String warningTemplate = DEFAULT_WARNING_TEMPLATE;
- String errorTemplate = DEFAULT_ERROR_TEMPLATE;
- String debugTemplate = DEFAULT_DEBUG_TEMPLATE;
-
- private Builder() {}
-
- /**
- * Sets the target {@link java.lang.System.Logger} to emit log
messages to.
- *
- * @param value The logger. Must not be <jk>null</jk>.
- * @return This object.
- */
- public Builder logger(System.Logger value) {
- logger = value;
- return this;
- }
-
- /**
- * Sets the level resolver used to compute the log level for
each entry.
- *
- * <p>
- * Defaults to {@link RestLogLevelResolver#DEFAULT}.
- *
- * @param value The resolver. Must not be <jk>null</jk>.
- * @return This object.
- */
- public Builder levelResolver(RestLogLevelResolver value) {
- levelResolver = value;
- return this;
- }
-
- /**
- * Sets a filter that controls which entries are logged.
- *
- * <p>
- * Only entries for which the predicate returns {@code true}
will be logged.
- * By default, all entries are logged.
- *
- * @param value The filter predicate. Can be <jk>null</jk> (no
filtering \u2014 all entries are logged).
- * @return This object.
- */
- public Builder filter(Predicate<RestLogEntry> value) {
- filter = value;
- return this;
- }
-
- /**
- * Sets the message template used for {@link
java.lang.System.Logger.Level#INFO} entries.
- *
- * @param value The template string. Must not be <jk>null</jk>.
- * @return This object.
- */
- public Builder infoTemplate(String value) {
- infoTemplate = value;
- return this;
- }
-
- /**
- * Sets the message template used for {@link
java.lang.System.Logger.Level#WARNING} entries.
- *
- * @param value The template string. Must not be <jk>null</jk>.
- * @return This object.
- */
- public Builder warningTemplate(String value) {
- warningTemplate = value;
- return this;
- }
-
- /**
- * Sets the message template used for {@link
java.lang.System.Logger.Level#ERROR} entries.
- *
- * @param value The template string. Must not be <jk>null</jk>.
- * @return This object.
- */
- public Builder errorTemplate(String value) {
- errorTemplate = value;
- return this;
- }
-
- /**
- * Sets the message template used for debug entries (when
{@link RestLogEntry#isDebug()} is {@code true}).
- *
- * @param value The template string. Must not be <jk>null</jk>.
- * @return This object.
- */
- public Builder debugTemplate(String value) {
- debugTemplate = value;
- return this;
- }
-
- /**
- * Builds and returns the {@link BasicRestLogger}.
- *
- * @return A new instance. Never <jk>null</jk>.
- */
- public BasicRestLogger build() {
- return new BasicRestLogger(this);
- }
- }
-}
diff --git
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestClient.java
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestClient.java
index a2863c3c58..298ab5d1d3 100644
---
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestClient.java
+++
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestClient.java
@@ -24,7 +24,9 @@ import java.io.*;
import java.util.*;
import java.util.function.*;
import java.util.stream.*;
+import java.util.logging.*;
+import org.apache.juneau.commons.logging.*;
import org.apache.juneau.commons.http.*;
import org.apache.juneau.http.*;
import org.apache.juneau.http.entity.*;
@@ -94,7 +96,8 @@ public final class RestClient implements Closeable {
final List<HttpPart> defaultQueryData;
final String rootUrl;
final List<RestCallInterceptor> interceptors;
- final RestLogger logger;
+ final RichLogger debugLogger;
+ final RestClientDebugFormatter debugFormatter;
final List<BodyConverter<?>> bodyConverters;
final SerializerSet serializers;
final ParserSet parsers;
@@ -108,7 +111,8 @@ public final class RestClient implements Closeable {
this.defaultQueryData = List.copyOf(builder.defaultQueryData);
this.rootUrl = builder.rootUrl;
this.interceptors = List.copyOf(builder.interceptors);
- this.logger = builder.logger;
+ this.debugLogger = RichLogger.getLogger(builder.debugLoggerName
!= null ? builder.debugLoggerName : RestClient.class.getName());
+ this.debugFormatter = builder.debugFormatter != null ?
builder.debugFormatter : new BasicRestClientDebugFormatter();
this.bodyConverters = List.copyOf(builder.bodyConverters);
this.serializers = builder.serializers;
this.parsers = builder.parsers;
@@ -380,7 +384,8 @@ public final class RestClient implements Closeable {
final List<HttpPart> defaultQueryData = l();
String rootUrl;
final List<RestCallInterceptor> interceptors = l();
- RestLogger logger;
+ RestClientDebugFormatter debugFormatter;
+ String debugLoggerName;
List<BodyConverter<?>> bodyConverters = new
ArrayList<>(DEFAULT_BODY_CONVERTERS);
SerializerSet serializers;
ParserSet parsers;
@@ -488,16 +493,39 @@ public final class RestClient implements Closeable {
}
/**
- * Sets the logger called at the end of every request (success
or failure).
+ * Sets the formatter used for level-driven REST debug logging.
*
- * @param value The logger. May be <jk>null</jk> to disable
logging.
+ * <p>
+ * The formatter renders cumulative content based on the
resolved debug logger level:
+ * <ul>
+ * <li>{@link Level#INFO INFO} - basic line ({@link
RestClientDebugFormatter#formatBasic(RestRequest, RestResponse)}).
+ * <li>{@link Level#FINE FINE} - basic + headers.
+ * <li>{@link Level#FINEST FINEST} - basic + headers +
body.
+ * </ul>
+ *
+ * @param value The formatter. Can be <jk>null</jk> to restore
the default formatter.
+ * @return This object.
+ */
+ public Builder debugFormatter(RestClientDebugFormatter value) {
+ debugFormatter = value;
+ return this;
+ }
+
+ /**
+ * Sets the logger name used for REST debug records.
+ *
+ * <p>
+ * If not set, defaults to {@code RestClient.class.getName()}.
+ *
+ * @param value The logger name. Can be <jk>null</jk> to use
the default logger name.
* @return This object.
*/
- public Builder logger(RestLogger value) {
- logger = value;
+ public Builder debugLoggerName(String value) {
+ debugLoggerName = value;
return this;
}
+
/**
* Prepends custom body converters to the default converter
list.
*
diff --git
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestClientDebugFormatter.java
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestClientDebugFormatter.java
new file mode 100644
index 0000000000..d7e0ae5f08
--- /dev/null
+++
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestClientDebugFormatter.java
@@ -0,0 +1,76 @@
+/*
+ * 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.rest.client;
+
+/**
+ * Per-tier formatter for JUL-level-driven REST client debug logging.
+ *
+ * <p>
+ * The client debug pipeline invokes these methods cumulatively based on the
resolved logger level:
+ * <ul>
+ * <li>{@link #formatBasic(RestRequest, RestResponse)} at {@code INFO}.
+ * <li>{@link #formatHeaders(RestRequest, RestResponse)} added at {@code
FINE}.
+ * <li>{@link #formatBody(RestRequest, RestResponse)} added at {@code
FINEST}.
+ * </ul>
+ *
+ * <p>
+ * <b>Beta - API subject to change:</b> This type is part of the
next-generation REST client and HTTP stack.
+ *
+ * @since 10.0.0
+ */
+public interface RestClientDebugFormatter {
+
+ /**
+ * Renders the basic ({@code INFO}-tier) portion of a debug record.
+ *
+ * @param req The request.
+ * @param res The response.
+ * @return The formatted message chunk.
+ */
+ String formatBasic(RestRequest req, RestResponse res);
+
+ /**
+ * Renders the headers ({@code FINE}-tier) portion of a debug record.
+ *
+ * @param req The request.
+ * @param res The response.
+ * @return The formatted message chunk.
+ */
+ default String formatHeaders(RestRequest req, RestResponse res) {
+ return "";
+ }
+
+ /**
+ * Renders the body ({@code FINEST}-tier) portion of a debug record.
+ *
+ * @param req The request.
+ * @param res The response.
+ * @return The formatted message chunk.
+ */
+ default String formatBody(RestRequest req, RestResponse res) {
+ return "";
+ }
+
+ /**
+ * Maximum number of request/response body bytes to capture for {@code
FINEST} rendering.
+ *
+ * @return The capture cap in bytes.
+ */
+ default int bodyCap() {
+ return 8 * 1024;
+ }
+}
diff --git
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestClientDebugPipeline.java
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestClientDebugPipeline.java
new file mode 100644
index 0000000000..90b1cab517
--- /dev/null
+++
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestClientDebugPipeline.java
@@ -0,0 +1,53 @@
+/*
+ * 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.rest.client;
+
+import java.util.logging.*;
+
+import org.apache.juneau.commons.logging.RichLogger;
+
+final class RestClientDebugPipeline {
+
+ private RestClientDebugPipeline() {}
+
+ static Level resolveTier(RichLogger logger) {
+ if (logger.isLoggable(Level.FINEST))
+ return Level.FINEST;
+ if (logger.isLoggable(Level.FINE))
+ return Level.FINE;
+ if (logger.isLoggable(Level.INFO))
+ return Level.INFO;
+ return null;
+ }
+
+ static void emit(RichLogger logger, RestClientDebugFormatter formatter,
Level level, RestRequest req, RestResponse res, Throwable thrown) {
+ if (level == null)
+ return;
+
+ var sb = new StringBuilder(formatter.formatBasic(req, res));
+ if (level.intValue() <= Level.FINE.intValue())
+ sb.append(formatter.formatHeaders(req, res));
+ if (level.intValue() <= Level.FINEST.intValue())
+ sb.append(formatter.formatBody(req, res));
+
+ var record = new LogRecord(level, sb.toString());
+ record.setLoggerName(logger.getName());
+ if (thrown != null)
+ record.setThrown(thrown);
+ logger.log(record);
+ }
+}
diff --git
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestLogEntry.java
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestLogEntry.java
deleted file mode 100644
index 8668f0dedf..0000000000
---
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestLogEntry.java
+++ /dev/null
@@ -1,298 +0,0 @@
-/*
- * 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.rest.client;
-
-import static org.apache.juneau.commons.utils.Shorts.*;
-
-import java.time.*;
-
-/**
- * Structured log entry produced after every {@link RestRequest#run()}
invocation.
- *
- * <p>
- * Passed to a {@link RestLogger} in the {@code finally} block of {@link
RestRequest#run()}.
- * When the transport fails before a response is received, {@link
#getResponse()} is {@code null}
- * and {@link #getError()} is non-{@code null}.
- *
- * <p>
- * <b>Beta — API subject to change:</b> This type is part of the
next-generation REST client and HTTP stack.
- * 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/NextGenRestClient">juneau-ng REST
client</a>
- * </ul>
- *
- * @since 9.2.1
- */
-@SuppressWarnings({
- "resource" // Eclipse resource analysis: response is borrowed for
logging snapshot only
-})
-public final class RestLogEntry {
-
- private final RestRequest request;
-
- private final RestResponse response;
- private final Throwable error;
- private final Duration elapsed;
- private final boolean debug;
- private final System.Logger.Level level;
-
- RestLogEntry(RestRequest request, RestResponse response, Throwable
error, Duration elapsed, boolean debug, System.Logger.Level level) {
- this.request = request;
- this.response = response;
- this.error = error;
- this.elapsed = elapsed;
- this.debug = debug;
- this.level = level;
- }
-
- /**
- * Returns the request that triggered this log entry.
- *
- * @return The request. Never <jk>null</jk>.
- */
- public RestRequest getRequest() {
- return request;
- }
-
- /**
- * Returns the response received, or {@code null} if the transport
failed before a response was received.
- *
- * @return The response, or <jk>null</jk> if the transport failed
before a response was received.
- */
- public RestResponse getResponse() {
- return response;
- }
-
- /**
- * Returns the error that occurred, or {@code null} on success.
- *
- * @return The error, or <jk>null</jk> on success.
- */
- public Throwable getError() {
- return error;
- }
-
- /**
- * Returns the elapsed time for the request (from before {@code onInit}
to after the response is received).
- *
- * @return The elapsed time. Never <jk>null</jk>.
- */
- public Duration getElapsed() {
- return elapsed;
- }
-
- /**
- * Returns {@code true} if {@link RestRequest#debug()} was called on
the originating request.
- *
- * @return {@code true} if this is a debug request.
- */
- public boolean isDebug() {
- return debug;
- }
-
- /**
- * Returns the log level computed by the configured {@link
RestLogLevelResolver}.
- *
- * @return The log level. Never <jk>null</jk>.
- */
- public System.Logger.Level getLevel() {
- return level;
- }
-
- /**
- * Returns {@code true} if the call resulted in an error (transport
error or HTTP status ≥ 400).
- *
- * @return {@code true} if this entry represents an error condition.
- */
- public boolean isError() {
- return error != null || getStatusCode() >= 400;
- }
-
- /**
- * Returns the HTTP status code, or {@code 0} if no response was
received.
- *
- * @return The status code.
- */
- public int getStatusCode() {
- return response != null ? response.getStatusCode() : 0;
- }
-
- /**
- * Returns {@code true} if the response contains a header with the
given name (case-insensitive).
- *
- * @param name The header name. Must not be <jk>null</jk>.
- * @return {@code true} if the header is present.
- */
- public boolean hasResponseHeader(String name) {
- return response != null && response.getFirstHeader(name) !=
null;
- }
-
- /**
- * Formats the entry using the default template: {@code "METHOD URI ->
STATUS REASON (ELAPSEDms)"}.
- *
- * @return A formatted string. Never <jk>null</jk>.
- */
- public String format() {
- return format("{method} {uri} -> {status} {reason}
({elapsed})");
- }
-
- /**
- * Formats the entry using a named-placeholder template.
- *
- * <p>
- * Supported placeholders:
- * <ul>
- * <li>{@code {method}} — HTTP method (e.g. {@code GET})
- * <li>{@code {uri}} — Full request URI
- * <li>{@code {status}} — Response status code, or {@code 0} if no
response
- * <li>{@code {reason}} — Response reason phrase, or empty string
- * <li>{@code {elapsed}} — Elapsed time (e.g. {@code 42ms})
- * <li>{@code {error}} — Error message, or empty string if no error
- * </ul>
- *
- * @param template The template string. Must not be <jk>null</jk>.
- * @return A formatted string. Never <jk>null</jk>.
- */
- public String format(String template) {
- var method = request.getMethod();
- var uri = request.getUri() != null ?
request.getUri().toString() : ""; // HTT: uri is always set when format() is
called from logger
- var status = getStatusCode() > 0 ?
String.valueOf(getStatusCode()) : "0";
- var reason = response != null && response.getReasonPhrase() !=
null ? response.getReasonPhrase() : "";
- var elapsedMs = elapsed.toMillis() + "ms";
- var errorMsg = "";
- if (error != null)
- errorMsg = error.getMessage() != null ?
error.getMessage() : cns(error);
-
- return template
- .replace("{method}", method)
- .replace("{uri}", uri)
- .replace("{status}", status)
- .replace("{reason}", reason)
- .replace("{elapsed}", elapsedMs)
- .replace("{error}", errorMsg);
- }
-
- @Override /* Object */
- public String toString() {
- return format();
- }
-
- /**
- * Creates a new builder.
- *
- * @return A new builder. Never <jk>null</jk>.
- */
- public static Builder builder() {
- return new Builder();
- }
-
- /**
- * Builder for {@link RestLogEntry}.
- *
- * @since 9.2.1
- */
- public static final class Builder {
-
- RestRequest request;
-
- RestResponse response;
- Throwable error;
- Duration elapsed = Duration.ZERO;
- boolean debug;
- RestLogLevelResolver levelResolver =
RestLogLevelResolver.DEFAULT;
-
- private Builder() {}
-
- /**
- * Sets the request.
- *
- * @param value The request. Must not be <jk>null</jk>.
- * @return This object.
- */
- public Builder request(RestRequest value) {
- request = value;
- return this;
- }
-
- /**
- * Sets the response.
- *
- * @param value The response. May be <jk>null</jk> if transport
failed before receiving a response.
- * @return This object.
- */
- public Builder response(RestResponse value) {
- response = value;
- return this;
- }
-
- /**
- * Sets the error.
- *
- * @param value The error. May be <jk>null</jk> on success.
- * @return This object.
- */
- public Builder error(Throwable value) {
- error = value;
- return this;
- }
-
- /**
- * Sets the elapsed time.
- *
- * @param value The elapsed duration. Must not be <jk>null</jk>.
- * @return This object.
- */
- public Builder elapsed(Duration value) {
- elapsed = value;
- return this;
- }
-
- /**
- * Sets whether this was a debug request.
- *
- * @param value {@code true} if {@link RestRequest#debug()} was
called.
- * @return This object.
- */
- public Builder debug(boolean value) {
- debug = value;
- return this;
- }
-
- /**
- * Sets the level resolver used to compute {@link
RestLogEntry#getLevel()}.
- *
- * @param value The resolver. Must not be <jk>null</jk>.
- * @return This object.
- */
- public Builder levelResolver(RestLogLevelResolver value) {
- levelResolver = value;
- return this;
- }
-
- /**
- * Builds the {@link RestLogEntry}.
- *
- * @return A new instance. Never <jk>null</jk>.
- */
- public RestLogEntry build() {
- var entry = new RestLogEntry(request, response, error,
elapsed, debug, null);
- return new RestLogEntry(request, response, error,
elapsed, debug, levelResolver.resolve(entry));
- }
- }
-}
diff --git
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestLogLevelResolver.java
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestLogLevelResolver.java
deleted file mode 100644
index 65a274992f..0000000000
---
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestLogLevelResolver.java
+++ /dev/null
@@ -1,155 +0,0 @@
-/*
- * 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.rest.client;
-
-import static org.apache.juneau.commons.utils.Shorts.*;
-
-import java.util.*;
-import java.util.function.*;
-
-/**
- * Determines the {@link java.lang.System.Logger.Level} for a {@link
RestLogEntry}.
- *
- * <p>
- * Used by {@link BasicRestLogger} (and available to custom {@link RestLogger}
implementations via
- * {@link RestLogEntry#getLevel()}) to decide how to emit each log line.
- *
- * <p>
- * The built-in {@link #DEFAULT} resolver applies these ordered rules:
- * <ul>
- * <li>{@link java.lang.System.Logger.Level#ERROR} — transport error or
HTTP status ≥ 500
- * <li>{@link java.lang.System.Logger.Level#WARNING} — HTTP status ≥ 400,
or response contains a {@code Thrown} header
- * <li>{@link java.lang.System.Logger.Level#INFO} — all other calls
- * </ul>
- *
- * <p>
- * Custom resolvers can be built using the fluent {@link #rules()} builder:
- *
- * <h5 class='section'>Example:</h5>
- * <p class='bjava'>
- * RestLogLevelResolver <jv>resolver</jv> =
RestLogLevelResolver.<jsm>rules</jsm>()
- * .rule(System.Logger.Level.<jsf>ERROR</jsf>, <jv>e</jv> ->
<jv>e</jv>.getStatusCode() >= 500)
- * .rule(System.Logger.Level.<jsf>WARNING</jsf>, <jv>e</jv> ->
<jv>e</jv>.getStatusCode() >= 400)
- * .defaultLevel(System.Logger.Level.<jsf>INFO</jsf>)
- * .build();
- * </p>
- *
- * <p>
- * <b>Beta — API subject to change:</b> This type is part of the
next-generation REST client and HTTP stack.
- * 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/NextGenRestClient">juneau-ng REST
client</a>
- * </ul>
- *
- * @since 9.2.1
- */
-@FunctionalInterface
-public interface RestLogLevelResolver {
-
- /**
- * Computes the log level for the given entry.
- *
- * @param entry The log entry. Never <jk>null</jk>.
- * @return The computed level. Never <jk>null</jk>.
- */
- System.Logger.Level resolve(RestLogEntry entry);
-
- /**
- * The default level resolver.
- *
- * <ul>
- * <li>{@link java.lang.System.Logger.Level#ERROR} — transport
error ({@code getError() != null}) or HTTP status ≥ 500
- * <li>{@link java.lang.System.Logger.Level#WARNING} — HTTP status
≥ 400, or response has a {@code Thrown} header
- * (a Juneau server-side header indicating an exception was
thrown)
- * <li>{@link java.lang.System.Logger.Level#INFO} — all other calls
- * </ul>
- */
- RestLogLevelResolver DEFAULT = rules()
- .rule(System.Logger.Level.ERROR, e -> e.getError() != null ||
e.getStatusCode() >= 500)
- .rule(System.Logger.Level.WARNING, e -> e.getStatusCode() >=
400 || e.hasResponseHeader("Thrown"))
- .defaultLevel(System.Logger.Level.INFO)
- .build();
-
- /**
- * Returns a new {@link RuleBuilder} for constructing ordered
predicate-based level resolvers.
- *
- * @return A new builder. Never <jk>null</jk>.
- */
- static RuleBuilder rules() {
- return new RuleBuilder();
- }
-
- /**
- * Fluent builder for ordered predicate-based {@link
RestLogLevelResolver} instances.
- *
- * <p>
- * Rules are evaluated in registration order; the first matching rule
wins. If no rule matches,
- * the {@link #defaultLevel(System.Logger.Level)} is used (default:
{@link java.lang.System.Logger.Level#INFO}).
- *
- * @since 9.2.1
- */
- class RuleBuilder {
-
- private final List<Rule> rules = l();
- private System.Logger.Level defaultLevel =
System.Logger.Level.INFO;
-
- private RuleBuilder() {}
-
- /**
- * Adds a rule that maps to the given level when the predicate
matches.
- *
- * @param level The level to emit when the predicate is true.
Must not be <jk>null</jk>.
- * @param when The predicate tested against each log entry.
Must not be <jk>null</jk>.
- * @return This object.
- */
- public RuleBuilder rule(System.Logger.Level level,
Predicate<RestLogEntry> when) {
- rules.add(new Rule(level, when));
- return this;
- }
-
- /**
- * Sets the fallback level used when no rule matches.
- *
- * @param value The default level. Must not be <jk>null</jk>.
- * @return This object.
- */
- public RuleBuilder defaultLevel(System.Logger.Level value) {
- defaultLevel = value;
- return this;
- }
-
- /**
- * Builds and returns the {@link RestLogLevelResolver}.
- *
- * @return A new instance. Never <jk>null</jk>.
- */
- public RestLogLevelResolver build() {
- var snapshot = List.copyOf(rules);
- var fallback = defaultLevel;
- return entry -> {
- for (var rule : snapshot)
- if (rule.when().test(entry))
- return rule.level();
- return fallback;
- };
- }
-
- private record Rule(System.Logger.Level level,
Predicate<RestLogEntry> when) {}
- }
-}
diff --git
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestLogger.java
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestLogger.java
deleted file mode 100644
index e1dfce4cf4..0000000000
---
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestLogger.java
+++ /dev/null
@@ -1,63 +0,0 @@
-/*
- * 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.rest.client;
-
-/**
- * Pluggable logging interface for REST calls made by {@link RestClient}.
- *
- * <p>
- * Called in the {@code finally} block of every {@link RestRequest#run()}
invocation, regardless of success or failure.
- * The {@link RestLogEntry} carries the request, response (or {@code null} on
transport error), elapsed time, and error.
- *
- * <p>
- * This is a {@link FunctionalInterface} — lambda expressions and method
references are valid implementations:
- *
- * <h5 class='section'>Example:</h5>
- * <p class='bjava'>
- * RestClient <jv>client</jv> = RestClient.<jsm>builder</jsm>()
- * .transport(<jv>transport</jv>)
- * .logger(<jv>entry</jv> ->
System.<jf>out</jf>.println(<jv>entry</jv>.format()))
- * .build();
- * </p>
- *
- * <p>
- * Multiple loggers can be combined using lambda composition:
- * <p class='bjava'>
- * RestLogger <jv>combined</jv> = <jv>entry</jv> -> {
<jv>errorLogger</jv>.log(<jv>entry</jv>);
<jv>perfLogger</jv>.log(<jv>entry</jv>); };
- * </p>
- *
- * <p>
- * <b>Beta — API subject to change:</b> This type is part of the
next-generation REST client and HTTP stack.
- * 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/NextGenRestClient">juneau-ng REST
client</a>
- * </ul>
- *
- * @since 9.2.1
- */
-@FunctionalInterface
-public interface RestLogger {
-
- /**
- * Receives a log entry for a completed (or failed) REST call.
- *
- * @param entry The log entry. Never <jk>null</jk>.
- */
- void log(RestLogEntry entry);
-}
diff --git
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestRequest.java
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestRequest.java
index b837d69d3f..0ddaf93ba8 100644
---
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestRequest.java
+++
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestRequest.java
@@ -24,6 +24,7 @@ import java.nio.charset.*;
import java.time.*;
import java.util.*;
import java.util.function.*;
+import java.util.logging.*;
import org.apache.juneau.http.*;
import org.apache.juneau.http.entity.*;
@@ -59,11 +60,15 @@ public final class RestRequest {
private final Map<String,Object> pathData = m();
private HttpBody body;
private TransportBody convertedBody;
- private boolean debug;
private URI resolvedUri;
private Duration timeout;
// Per-request interceptors, unioned with the client-level interceptors
at run() time.
private final List<RestCallInterceptor> requestInterceptors = l();
+ private List<TransportHeader> resolvedHeaders = List.of();
+ private byte[] cachedContent;
+ private long cachedContentLength = -1;
+ private Throwable exception;
+ private Duration execTime;
RestRequest(RestClient client, String method, String url) {
this.client = client;
@@ -326,24 +331,6 @@ public final class RestRequest {
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 RestRequest debug() {
- debug = true;
- return this;
- }
-
// --------------------------------------------------
// Timeout / interceptors
// --------------------------------------------------
@@ -420,6 +407,51 @@ public final class RestRequest {
return resolvedUri;
}
+ /**
+ * Returns the headers resolved onto the transport request.
+ *
+ * @return The resolved headers in wire order.
+ */
+ public List<TransportHeader> getResolvedHeaders() {
+ return resolvedHeaders;
+ }
+
+ /**
+ * Returns request body bytes captured for debug logging (up to the
configured body cap).
+ *
+ * @return Captured request bytes, or <jk>null</jk> if not captured.
+ */
+ public byte[] getCachedContent() {
+ return cachedContent;
+ }
+
+ /**
+ * Returns the total request body length observed during write, or
{@code -1} if unknown.
+ *
+ * @return The total body length.
+ */
+ public long getCachedContentLength() {
+ return cachedContentLength;
+ }
+
+ /**
+ * Returns the terminal request exception, if any.
+ *
+ * @return The terminal exception, or <jk>null</jk>.
+ */
+ public Throwable getException() {
+ return exception;
+ }
+
+ /**
+ * Returns the measured request execution time.
+ *
+ * @return The request execution time, or <jk>null</jk> if unset.
+ */
+ public Duration getExecTime() {
+ return execTime;
+ }
+
// --------------------------------------------------
// Execute
// --------------------------------------------------
@@ -441,6 +473,8 @@ public final class RestRequest {
var start = Instant.now();
RestResponse response = null;
Throwable error = null;
+ var debugLevel =
RestClientDebugPipeline.resolveTier(client.debugLogger);
+ var debugCap = client.debugFormatter.bodyCap();
// Union the client-level (builder) interceptors with any
per-request (interface- then
// method-level) interceptors, preserving the order builder →
interface → method.
var effectiveInterceptors = effectiveInterceptors();
@@ -449,12 +483,22 @@ public final class RestRequest {
interceptor.onInit(this);
var transportRequest = buildTransportRequest();
+ resolvedHeaders = transportRequest.getHeaders();
+ if (transportRequest.getBody() == null) {
+ cachedContentLength = 0;
+ } else {
+ cachedContentLength =
transportRequest.getBody().getContentLength();
+ }
+ if (debugLevel == Level.FINEST)
+ transportRequest =
captureRequestBody(transportRequest, debugCap);
var transportResponse =
client.transport.execute(transportRequest);
- response = new RestResponse(transportResponse, client);
+ response = new RestResponse(transportResponse, client,
this, debugLevel, debugCap);
for (var interceptor : effectiveInterceptors)
interceptor.onConnect(this, response);
+ execTime = Duration.between(start, Instant.now());
+ response.setExecTime(execTime);
return response;
} catch (TransportException | RestCallException e) {
error = e;
@@ -464,12 +508,15 @@ public final class RestRequest {
throw new TransportException("Request failed: " +
e.getMessage(), e);
} finally {
var elapsed = Duration.between(start, Instant.now());
+ execTime = elapsed;
+ exception = error;
RestResponse finalResponse = response;
if (error != null && finalResponse != null) {
// The response was assigned but an error after
that point (e.g. a throwing onConnect
// interceptor) means it is never returned to
the caller, so the caller can never close it
// themselves — close it here to avoid leaking
the underlying connection/body.
try {
+ finalResponse.setExecTime(elapsed);
finalResponse.close();
} catch (IOException closeError) { // HTT:
close() failing on an already-broken response is not reliably reproducible
// suppress — best effort, and the
original error is what propagates
@@ -482,15 +529,10 @@ public final class RestRequest {
// 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);
+ if (error != null && finalResponse == null) {
+ var syntheticResponse = new
RestResponse(TransportResponse.builder().statusCode(0).build(), client, this,
debugLevel, debugCap);
+ syntheticResponse.setExecTime(elapsed);
+
RestClientDebugPipeline.emit(client.debugLogger, client.debugFormatter,
debugLevel, this, syntheticResponse, error);
}
}
}
@@ -598,6 +640,51 @@ public final class RestRequest {
return builder.build();
}
+ private TransportRequest captureRequestBody(TransportRequest request,
int cap) {
+ var originalBody = request.getBody();
+ if (originalBody == null) {
+ cachedContent = null;
+ cachedContentLength = 0;
+ return request;
+ }
+
+ var wrappedBody = new HttpBody() {
+ @Override
+ public String getContentType() {
+ return originalBody.getContentType();
+ }
+
+ @Override
+ public long getContentLength() {
+ return originalBody.getContentLength();
+ }
+
+ @Override
+ public void writeTo(OutputStream out) throws
IOException {
+ var tee = new BoundedCaptureOutputStream(out,
cap);
+ try {
+ originalBody.writeTo(tee);
+ } finally {
+ cachedContent = tee.getCapturedBytes();
+ cachedContentLength =
tee.getTotalBytesWritten();
+ }
+ }
+
+ @Override
+ public boolean isRepeatable() {
+ return originalBody.isRepeatable();
+ }
+ };
+
+ var builder = TransportRequest.builder()
+ .method(request.getMethod())
+ .uri(request.getUri())
+ .headers(request.getHeaders())
+ .body(TransportBody.of(wrappedBody))
+ .timeout(request.getTimeout());
+ return builder.build();
+ }
+
private String applyPathSubstitutions(String template) {
var result = template;
Object remainder = null;
@@ -683,4 +770,41 @@ public final class RestRequest {
private static String urlEncode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
+
+ private static final class BoundedCaptureOutputStream extends
OutputStream {
+ private final OutputStream delegate;
+ private final ByteArrayOutputStream capture = new
ByteArrayOutputStream();
+ private final int cap;
+ private long totalBytesWritten;
+
+ BoundedCaptureOutputStream(OutputStream delegate, int cap) {
+ this.delegate = delegate;
+ this.cap = cap;
+ }
+
+ byte[] getCapturedBytes() {
+ return capture.toByteArray();
+ }
+
+ long getTotalBytesWritten() {
+ return totalBytesWritten;
+ }
+
+ @Override
+ public void write(int b) throws IOException {
+ delegate.write(b);
+ totalBytesWritten++;
+ if (capture.size() < cap)
+ capture.write(b);
+ }
+
+ @Override
+ public void write(byte[] b, int off, int len) throws
IOException {
+ delegate.write(b, off, len);
+ totalBytesWritten += len;
+ var remaining = cap - capture.size();
+ if (remaining > 0)
+ capture.write(b, off, Math.min(len, remaining));
+ }
+ }
}
diff --git
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestResponse.java
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestResponse.java
index d6d6d5a981..4518aa64a3 100644
---
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestResponse.java
+++
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RestResponse.java
@@ -18,7 +18,9 @@ package org.apache.juneau.rest.client;
import java.io.*;
import java.nio.charset.*;
+import java.time.*;
import java.util.*;
+import java.util.logging.*;
import org.apache.juneau.rest.client.assertion.*;
@@ -43,10 +45,42 @@ public final class RestResponse implements Closeable {
private final TransportResponse response;
private final RestClient client;
+ private final RestRequest request;
+ private final Level debugLevel;
+ private final int debugBodyCap;
+ private final InputStream body;
+ private byte[] cachedContent;
+ private long cachedContentLength = -1;
+ private boolean debugEmitted;
+ private Duration execTime;
RestResponse(TransportResponse response, RestClient client) {
+ this(response, client, null, null, 0);
+ }
+
+ RestResponse(TransportResponse response, RestClient client, RestRequest
request, Level debugLevel, int debugBodyCap) {
this.response = response;
this.client = client;
+ this.request = request;
+ this.debugLevel = debugLevel;
+ this.debugBodyCap = debugBodyCap;
+ var originalBody = response.getBody();
+ if (debugLevel == Level.FINEST && originalBody != null)
+ this.body = new BoundedCaptureInputStream(originalBody,
debugBodyCap);
+ else
+ this.body = originalBody;
+ if (originalBody == null) {
+ cachedContentLength = 0;
+ } else {
+ var h = response.getFirstHeader("Content-Length");
+ if (h != null) {
+ try {
+ cachedContentLength =
Long.parseLong(h.value());
+ } catch (NumberFormatException e) {
+ cachedContentLength = -1;
+ }
+ }
+ }
}
/**
@@ -107,7 +141,7 @@ public final class RestResponse implements Closeable {
* @throws IOException If an I/O error occurs reading the body.
*/
public String getBodyAsString() throws IOException {
- var body = response.getBody();
+ var body = this.body;
if (body == null)
return null;
return new String(body.readAllBytes(), StandardCharsets.UTF_8);
@@ -122,7 +156,7 @@ public final class RestResponse implements Closeable {
* @return The body stream, possibly <jk>null</jk>.
*/
public InputStream getBodyStream() {
- return response.getBody();
+ return body;
}
/**
@@ -190,6 +224,90 @@ public final class RestResponse implements Closeable {
@Override /* Closeable */
public void close() throws IOException {
+ if (body instanceof BoundedCaptureInputStream bcis) {
+ bcis.drain();
+ cachedContent = bcis.getCapturedBytes();
+ cachedContentLength = bcis.getTotalBytesRead();
+ }
+ if (!debugEmitted && client != null && debugLevel != null) {
+ var thrown = request != null ? request.getException() :
null;
+ RestClientDebugPipeline.emit(client.debugLogger,
client.debugFormatter, debugLevel, request, this, thrown);
+ debugEmitted = true;
+ }
response.close();
}
+
+ void setExecTime(Duration value) {
+ execTime = value;
+ }
+
+ /**
+ * Returns response body bytes captured for debug logging (up to the
configured body cap).
+ *
+ * @return Captured response bytes, or <jk>null</jk> if not captured.
+ */
+ public byte[] getCachedContent() {
+ return cachedContent;
+ }
+
+ /**
+ * Returns the total response body length observed while
reading/draining, or {@code -1} if unknown.
+ *
+ * @return The total body length.
+ */
+ public long getCachedContentLength() {
+ return cachedContentLength;
+ }
+
+ Duration getExecTime() {
+ return execTime;
+ }
+
+ private static final class BoundedCaptureInputStream extends
FilterInputStream {
+ private final ByteArrayOutputStream capture = new
ByteArrayOutputStream();
+ private final int cap;
+ private long totalBytesRead;
+
+ BoundedCaptureInputStream(InputStream in, int cap) {
+ super(in);
+ this.cap = cap;
+ }
+
+ byte[] getCapturedBytes() {
+ return capture.toByteArray();
+ }
+
+ long getTotalBytesRead() {
+ return totalBytesRead;
+ }
+
+ void drain() throws IOException {
+ while (read() != -1) {
+ // consume
+ }
+ }
+
+ @Override
+ public int read() throws IOException {
+ var b = super.read();
+ if (b == -1)
+ return -1;
+ totalBytesRead++;
+ if (capture.size() < cap)
+ capture.write(b);
+ return b;
+ }
+
+ @Override
+ public int read(byte[] b, int off, int len) throws IOException {
+ var count = super.read(b, off, len);
+ if (count <= 0)
+ return count;
+ totalBytesRead += count;
+ var remaining = cap - capture.size();
+ if (remaining > 0)
+ capture.write(b, off, Math.min(count,
remaining));
+ return count;
+ }
+ }
}
diff --git
a/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/BasicRestLogger_Test.java
b/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/BasicRestLogger_Test.java
deleted file mode 100644
index 4a9ef45b44..0000000000
---
a/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/BasicRestLogger_Test.java
+++ /dev/null
@@ -1,74 +0,0 @@
-/*
- * 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.rest.client;
-
-import static org.junit.jupiter.api.Assertions.*;
-
-import org.apache.juneau.*;
-import org.junit.jupiter.api.*;
-
-/**
- * Unit tests for {@link BasicRestLogger.Builder#errorTemplate(String)}.
- *
- * <p>
- * {@code infoTemplate}/{@code warningTemplate}/{@code debugTemplate} are
exercised via the broader
- * {@code RestClientFeatures_Test} suite in {@code juneau-integration-tests};
{@code errorTemplate} specifically
- * requires a transport-level failure (no {@link RestResponse}), which this
test constructs directly.
- */
-@SuppressWarnings({
- "resource" // 'failing' is handed to (and closed by) the enclosing
RestClient under test.
-})
-class BasicRestLogger_Test extends TestBase {
-
- private static final class CapturingLogger implements System.Logger {
- String message;
- Level level;
- Throwable thrown;
-
- @Override public String getName() { return "test"; }
- @Override public boolean isLoggable(Level level) { return true;
}
-
- @Override
- public void log(Level level, java.util.ResourceBundle bundle,
String msg, Throwable thrown) {
- this.level = level;
- this.message = msg;
- this.thrown = thrown;
- }
-
- @Override
- public void log(Level level, java.util.ResourceBundle bundle,
String format, Object... params) {
- this.level = level;
- this.message = format;
- }
- }
-
- @Test
- void a01_errorTemplate_appliedOnTransportFailure() throws Exception {
- var captured = new CapturingLogger();
- HttpTransport failing = tReq -> {
- throw new TransportException("boom");
- };
- try (var client = RestClient.builder().transport(failing)
-
.logger(BasicRestLogger.create().logger(captured).errorTemplate("ERR[{method}
{uri}]: {error}").build())
- .build()) {
- assertThrows(TransportException.class, () ->
client.get("http://x/").run());
- }
- assertEquals(System.Logger.Level.ERROR, captured.level);
- assertTrue(captured.message.startsWith("ERR[GET http://x/]:"),
"Unexpected message: " + captured.message);
- assertNotNull(captured.thrown, "The transport exception should
be passed through to the logger");
- }
-}
diff --git
a/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/RestClient_DebugLogging_Test.java
b/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/RestClient_DebugLogging_Test.java
new file mode 100644
index 0000000000..5eefc47299
--- /dev/null
+++
b/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/RestClient_DebugLogging_Test.java
@@ -0,0 +1,177 @@
+/*
+ * 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.rest.client;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.nio.charset.*;
+import java.util.logging.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.logging.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Tests for level-driven REST client debug logging.
+ */
+@SuppressWarnings({
+ "resource" // Clients/responses in tests are closed with
try-with-resources.
+})
+class RestClient_DebugLogging_Test extends TestBase {
+
+ @Test
+ void a01_infoTier_emitsBasicOnly() throws Exception {
+ var loggerName = getClass().getName() + ".a01";
+ var logger = RichLogger.getLogger(loggerName);
+ var prevLevel = logger.getLevel();
+ logger.setLevel(Level.INFO);
+ try (var c = logger.captureEvents(Level.INFO)) {
+ try (var client = RestClient.builder()
+ .transport(req -> response(200, "ok"))
+ .debugLoggerName(loggerName)
+ .build();
+ var res =
client.get("http://example.com/basic").run()) {
+ assertSame(logger, client.debugLogger);
+ assertEquals(Level.INFO,
RestClientDebugPipeline.resolveTier(client.debugLogger));
+ res.body().asString();
+ }
+ assertNotNull(c.last());
+ var msg = c.last().getMessage();
+ assertEquals(Level.INFO, c.last().getLevel());
+ assertTrue(msg.contains("[200] HTTP GET
http://example.com/basic"));
+ assertFalse(msg.contains("---Request Headers---"));
+ assertFalse(msg.contains("---Request Content
UTF-8---"));
+ } finally {
+ logger.setLevel(prevLevel);
+ }
+ }
+
+ @Test
+ void a02_fineTier_includesHeadersWithoutBodies() throws Exception {
+ var loggerName = getClass().getName() + ".a02";
+ var logger = RichLogger.getLogger(loggerName);
+ var prevLevel = logger.getLevel();
+ logger.setLevel(Level.FINE);
+ try (var c = logger.captureEvents(Level.FINE)) {
+ try (var client = RestClient.builder()
+ .transport(req ->
TransportResponse.builder().statusCode(200).header("X-Res", "rv").body(new
ByteArrayInputStream("rb".getBytes(StandardCharsets.UTF_8))).build())
+ .debugLoggerName(loggerName)
+ .build();
+ var res =
client.get("http://example.com/h").header("Authorization", "secret").run()) {
+ res.body().asString();
+ }
+ assertNotNull(c.last());
+ assertEquals(Level.FINE, c.last().getLevel());
+ var msg = c.last().getMessage();
+ assertTrue(msg.contains("---Request Headers---"));
+ assertTrue(msg.contains("Authorization: [REDACTED]"));
+ assertTrue(msg.contains("---Response Headers---"));
+ assertTrue(msg.contains("X-Res: rv"));
+ assertFalse(msg.contains("---Request Content
UTF-8---"));
+ } finally {
+ logger.setLevel(prevLevel);
+ }
+ }
+
+ @Test
+ void a03_finestTier_includesBodiesWithTruncation() throws Exception {
+ var loggerName = getClass().getName() + ".a03";
+ var logger = RichLogger.getLogger(loggerName);
+ var prevLevel = logger.getLevel();
+ logger.setLevel(Level.FINEST);
+ try (var c = logger.captureEvents(Level.FINEST)) {
+ try (var client = RestClient.builder()
+ .transport(req -> {
+ if (req.getBody() != null) {
+ try {
+
req.getBody().writeTo(new ByteArrayOutputStream());
+ } catch (IOException e) {
+ throw new
RuntimeException(e);
+ }
+ }
+ return response(200, "uvwxyz");
+ })
+ .debugLoggerName(loggerName)
+ .debugFormatter(new
BasicRestClientDebugFormatter().bodyCap(4))
+ .build();
+ var res =
client.post("http://example.com/body").bodyString("abcdef").run()) {
+ res.body().asString();
+ }
+ assertNotNull(c.last());
+ assertEquals(Level.FINEST, c.last().getLevel());
+ var msg = c.last().getMessage();
+ assertTrue(msg.contains("---Request Content UTF-8---"));
+ assertTrue(msg.contains("abcd"));
+ assertTrue(msg.contains("…[truncated 2 bytes]"));
+ assertTrue(msg.contains("---Response Content
UTF-8---"));
+ assertTrue(msg.contains("uvwx"));
+ } finally {
+ logger.setLevel(prevLevel);
+ }
+ }
+
+ @Test
+ void a04_finestTier_drainsBodyOnCloseWhenPartiallyRead() throws
Exception {
+ var loggerName = getClass().getName() + ".a04";
+ var logger = RichLogger.getLogger(loggerName);
+ var prevLevel = logger.getLevel();
+ logger.setLevel(Level.FINEST);
+ try (var c = logger.captureEvents(Level.FINEST)) {
+ try (var client = RestClient.builder()
+ .transport(req -> response(200, "abcdef"))
+ .debugLoggerName(loggerName)
+ .build();
+ var res =
client.get("http://example.com/partial").run()) {
+ var stream = res.body().asStream();
+ assertNotNull(stream);
+ assertEquals('a', stream.read());
+ }
+ assertNotNull(c.last());
+ var msg = c.last().getMessage();
+ assertTrue(msg.contains("---Response Content
UTF-8---"));
+ assertTrue(msg.contains("abcdef"));
+ } finally {
+ logger.setLevel(prevLevel);
+ }
+ }
+
+ @Test
+ void a05_defaultLoggerName_isRestClientClassName() throws Exception {
+ var logger = RichLogger.getLogger(RestClient.class.getName());
+ var prevLevel = logger.getLevel();
+ logger.setLevel(Level.INFO);
+ try (var c = logger.captureEvents(Level.INFO)) {
+ try (var client = RestClient.builder().transport(req ->
response(200, "ok")).build();
+ var res =
client.get("http://example.com/default").run()) {
+ res.body().asString();
+ }
+ assertNotNull(c.last());
+ assertEquals(RestClient.class.getName(),
c.last().getLoggerName());
+ } finally {
+ logger.setLevel(prevLevel);
+ }
+ }
+
+ private static TransportResponse response(int statusCode, String body) {
+ return TransportResponse.builder()
+ .statusCode(statusCode)
+ .reasonPhrase("OK")
+ .body(new
ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8)))
+ .build();
+ }
+}
diff --git
a/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/RestLogEntry_Test.java
b/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/RestLogEntry_Test.java
deleted file mode 100644
index d61d134aa2..0000000000
---
a/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/RestLogEntry_Test.java
+++ /dev/null
@@ -1,91 +0,0 @@
-/*
- * 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.rest.client;
-
-import static org.junit.jupiter.api.Assertions.*;
-
-import java.time.*;
-
-import org.apache.juneau.*;
-import org.junit.jupiter.api.*;
-
-/**
- * Unit tests for {@link RestLogEntry} constructed directly via {@link
RestLogEntry#builder()}, covering the
- * plain accessors ({@link RestLogEntry#getElapsed()}, {@link
RestLogEntry#toString()}) and the builder's
- * {@link RestLogEntry.Builder#levelResolver(RestLogLevelResolver)} override,
none of which require an actual
- * transport round-trip.
- */
-class RestLogEntry_Test extends TestBase {
-
- @SuppressWarnings({
- "resource" // Factory returns a Closeable for the caller to
close; Eclipse JDT @Owning warning is by design.
- })
- private static RestResponse response(int statusCode) {
- var b = TransportResponse.builder().statusCode(statusCode);
- return new RestResponse(b.build(), RestClient.create());
- }
-
- @Test
- void a01_getElapsed_returnsConfiguredDuration() throws Exception {
- try (var client = RestClient.create()) {
- var req = client.get("http://x/");
- try (var resp = response(200)) {
- var entry =
RestLogEntry.builder().request(req).response(resp).elapsed(Duration.ofMillis(42)).build();
- assertEquals(Duration.ofMillis(42),
entry.getElapsed());
- }
- }
- }
-
- @Test
- void a02_toString_delegatesToDefaultFormat() throws Exception {
- try (var client = RestClient.create()) {
- var req = client.get("http://x/");
- try (var resp = response(200)) {
- var entry =
RestLogEntry.builder().request(req).response(resp).elapsed(Duration.ofMillis(7)).build();
- assertEquals(entry.format(), entry.toString());
- assertTrue(entry.toString().contains("GET"),
"Unexpected: " + entry.toString());
- }
- }
- }
-
- @Test
- void a03_levelResolver_overrideIsUsedToComputeLevel() throws Exception {
- try (var client = RestClient.create()) {
- var req = client.get("http://x/");
- try (var resp = response(200)) {
- var entry =
RestLogEntry.builder().request(req).response(resp)
- .levelResolver(e ->
System.Logger.Level.ERROR)
- .build();
- assertEquals(System.Logger.Level.ERROR,
entry.getLevel());
- }
- }
- }
-
- @Test
- void a04_debugFlagAndError_propagateThroughBuilder() throws Exception {
- try (var client = RestClient.create()) {
- var req = client.get("http://x/");
- var ex = new RuntimeException("boom");
- var entry =
RestLogEntry.builder().request(req).error(ex).debug(true).build();
- assertTrue(entry.isDebug());
- assertSame(ex, entry.getError());
- assertTrue(entry.isError());
- assertEquals(0, entry.getStatusCode());
- assertFalse(entry.hasResponseHeader("X-Any"));
- }
- }
-}