You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@juneau.apache.org by ja...@apache.org on 2026/10/05 22:19:38 UTC
(juneau) branch master updated: Bind non-public records (BeanMeta record accessor access) and harden JSON parser strictness (DEL round-trip, reject hex/octal literals); RemoteClient classifies java.time scalars and parses untyped decimals as Double; fix the MCP elicitation stale-connection flake by draining unconsumed chunked request bodies in RequestContent (keep-alive regression test); fix DataTablesAjax_BrowserTest CI failure; lift rowActions/selection/bulk through CardEnvelope.liftTable, whitelist copyLink, and add j [...]
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 641fadab09 Bind non-public records (BeanMeta record accessor access) and harden JSON parser strictness (DEL round-trip, reject hex/octal literals); RemoteClient classifies java.time scalars and parses untyped decimals as Double; fix the MCP elicitation stale-connection flake by draining unconsumed chunked request bodies in RequestContent (keep-alive regression test); fix DataTablesAjax_BrowserTest CI failure; lift rowActions/selection/bulk through CardEnvelope.liftTable, whitelis [...]
641fadab09 is described below
commit 641fadab09cc44e451b28b58e10842841e522c8f
Author: James Bognar <ja...@apache.org>
AuthorDate: Mon Oct 5 18:19:32 2026 -0400
Bind non-public records (BeanMeta record accessor access) and harden JSON parser strictness (DEL round-trip, reject hex/octal literals); RemoteClient classifies java.time scalars and parses untyped decimals as Double; fix the MCP elicitation stale-connection flake by draining unconsumed chunked request bodies in RequestContent (keep-alive regression test); fix DataTablesAjax_BrowserTest CI failure; lift rowActions/selection/bulk through CardEnvelope.liftTable, whitelist copyLink, and [...]
---
.../org/apache/juneau/commons/bean/BeanMeta.java | 33 ++++-
.../commons/bean/BeanMeta_RecordAccess_Test.java | 84 +++++++++++
.../juneau/commons/utils/StringUtils_Test.java | 5 +
.../main/java/org/apache/juneau/config/Config.java | 2 +-
.../apache/juneau/marshall/parquet/Parquet.java | 4 +-
.../juneau/marshall/parquet/ParquetConfig.java | 8 +-
.../juneau/marshall/parser/ParserReader.java | 48 +++++--
.../juneau/marshall/yaml/YamlSerializer.java | 18 +--
.../org/apache/juneau/NonPublicRecord_Test.java | 103 ++++++++++++++
.../java/org/apache/juneau/ParserReader_Test.java | 64 +++++++++
.../juneau/marshall/json/JsonParser_Test.java | 25 ++++
.../v20260728/McpElicitationIntegration_Test.java | 16 +--
.../mcp/v20260728/McpMrtrIntegration_Test.java | 17 +--
...nt_ChunkedKeepAlive_JettyMicroservice_Test.java | 157 +++++++++++++++++++++
.../juneau/rest/client/HttpTransportBuilder.java | 6 +-
.../org/apache/juneau/rest/client/RestRequest.java | 8 +-
.../juneau/rest/client/TransportRequest.java | 4 +-
.../juneau/rest/client/remote/RemoteClient.java | 9 +-
.../juneau/rest/client/RemoteClient_Test.java | 26 ++++
...moteClient_InvocationHandlerInternals_Test.java | 18 +++
.../view/freemarker/console/CardEnvelope.java | 26 +++-
.../freemarker/console/ToolkitPackRegistry.java | 6 +-
.../view/freemarker/console/CardEnvelope_Test.java | 33 +++++
.../console/ToolkitPackRegistry_Test.java | 5 +-
.../rest/server/console/ConsoleChromeMixin.java | 2 +-
.../apache/juneau/rest/server/views/Render.java | 9 +-
.../juneau/rest/server/views/ViewsMixin.java | 4 +-
.../server/views/DataTablesAjax_BrowserTest.java | 17 +++
.../rest/server/views/ViewsMixin_Serving_Test.java | 5 +
.../src/test/js/datatables-ajax.cjs | 21 ++-
.../apache/juneau/rest/server/arg/RestOpArg.java | 5 +-
.../rest/server/httppart/RequestContent.java | 28 ++++
32 files changed, 730 insertions(+), 86 deletions(-)
diff --git a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/bean/BeanMeta.java b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/bean/BeanMeta.java
index 0396fab40b..21db4d8260 100644
--- a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/bean/BeanMeta.java
+++ b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/bean/BeanMeta.java
@@ -1205,7 +1205,7 @@ public class BeanMeta<T> {
// among the declared constructors; the bean-class visibility check already governs the record itself.
var rcon = ci.getDeclaredConstructor(x -> x.hasParameterTypes(paramTypes)).orElse(null);
if (rcon != null)
- return new BeanConstructor(o(rcon.accessible()), components.stream().map(RecordComponent::getName).toList());
+ return new BeanConstructor(o(makeRecordConstructorAccessible(ci, rcon)), components.stream().map(RecordComponent::getName).toList());
}
if (implClassConstructor != null)
@@ -1428,6 +1428,37 @@ public class BeanMeta<T> {
return l;
}
+ /**
+ * Makes a record's canonical constructor accessible, failing with a clear message if the module system refuses.
+ *
+ * @param ci The record class.
+ * @param rcon The canonical constructor.
+ * @return The same constructor.
+ * @throws BeanRuntimeException If the record is non-public and its package is not opened to Juneau.
+ */
+ static ConstructorInfo makeRecordConstructorAccessible(ClassInfo ci, ConstructorInfo rcon) {
+ return makeRecordConstructorAccessible(ci, rcon, ConstructorInfo::setAccessible);
+ }
+
+ // Package-private seam so tests can force setAccessible() to report failure.
+ static ConstructorInfo makeRecordConstructorAccessible(ClassInfo ci, ConstructorInfo rcon, Predicate<ConstructorInfo> accessor) {
+ // Always attempt setAccessible (a public record nested in a non-public class still needs it);
+ // only fail loudly when the record is not effectively public.
+ var ok = accessor.test(rcon);
+ if (! ok && ! (rcon.isPublic() && isEffectivelyPublic(ci))) {
+ var pkg = ci.getPackage() == null ? "" : ci.getPackage().getName();
+ throw brex(ci, "Could not make the canonical constructor of non-public record '%s' accessible. The package '%s' must be opened to Juneau, e.g. 'opens %s to org.apache.juneau.commons;' in module-info or '--add-opens <module>/%s=ALL-UNNAMED'.", ci.getNameFull(), pkg, pkg, pkg);
+ }
+ return rcon;
+ }
+
+ private static boolean isEffectivelyPublic(ClassInfo ci) {
+ for (Class<?> c = ci.inner(); c != null; c = c.getEnclosingClass())
+ if (! Modifier.isPublic(c.getModifiers()))
+ return false;
+ return true;
+ }
+
private static boolean isRecordAccessor(MethodInfo m, ClassInfo ci) {
return ci.getRecordComponents().stream()
.anyMatch(rc -> rc.getName().equals(m.getNameSimple())
diff --git a/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/bean/BeanMeta_RecordAccess_Test.java b/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/bean/BeanMeta_RecordAccess_Test.java
new file mode 100644
index 0000000000..65683d2b92
--- /dev/null
+++ b/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/bean/BeanMeta_RecordAccess_Test.java
@@ -0,0 +1,84 @@
+/*
+ * 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.commons.bean;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.commons.*;
+import org.apache.juneau.commons.reflect.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Tests the module-opens diagnostic raised when a non-public record's canonical constructor can't be made accessible.
+ */
+public class BeanMeta_RecordAccess_Test extends TestBase {
+
+ record PackageRec(String name) {}
+ public record PublicRec(String name) {}
+
+ static class Hidden {
+ public record NestedPublicRec(String name) {}
+ }
+
+ private static ConstructorInfo ctor(Class<?> c) {
+ return ClassInfo.of(c).getDeclaredConstructors().get(0);
+ }
+
+ @Test void a01_nonPublicRecord_notOpened_throwsClearMessage() {
+ var ci = ClassInfo.of(PackageRec.class);
+ var e = assertThrows(BeanRuntimeException.class, () -> BeanMeta.makeRecordConstructorAccessible(ci, ctor(PackageRec.class), x -> false));
+ var m = e.getMessage();
+ assertTrue(m.contains("PackageRec"), m);
+ assertTrue(m.contains("opens org.apache.juneau.commons.bean to"), m);
+ assertTrue(m.contains("--add-opens"), m);
+ }
+
+ @Test void a02_nonPublicRecord_opened_returnsConstructor() {
+ var ci = ClassInfo.of(PackageRec.class);
+ var c = ctor(PackageRec.class);
+ assertSame(c, BeanMeta.makeRecordConstructorAccessible(ci, c, x -> true));
+ }
+
+ @Test void a03_publicRecord_failedAccess_doesNotThrow() {
+ var ci = ClassInfo.of(PublicRec.class);
+ var c = ctor(PublicRec.class);
+ assertSame(c, BeanMeta.makeRecordConstructorAccessible(ci, c, x -> false));
+ }
+
+ @Test void a03b_publicRecord_alwaysAttemptsSetAccessible() {
+ var calls = new java.util.concurrent.atomic.AtomicInteger();
+ var c = ctor(PublicRec.class);
+ BeanMeta.makeRecordConstructorAccessible(ClassInfo.of(PublicRec.class), c, x -> { calls.incrementAndGet(); return true; });
+ assertEquals(1, calls.get());
+ }
+
+ @Test void a05_publicRecordInNonPublicClass_failedAccess_throws() {
+ // Not effectively public, so a failed setAccessible is reported.
+ var ci = ClassInfo.of(Hidden.NestedPublicRec.class);
+ assertThrows(BeanRuntimeException.class, () -> BeanMeta.makeRecordConstructorAccessible(ci, ctor(Hidden.NestedPublicRec.class), x -> false));
+ }
+
+ @Test void a06_publicRecordInNonPublicClass_bindsNormally() {
+ var ci = ClassInfo.of(Hidden.NestedPublicRec.class);
+ assertNotNull(BeanMeta.makeRecordConstructorAccessible(ci, ctor(Hidden.NestedPublicRec.class)));
+ }
+
+ @Test void a04_realSetAccessible_nonPublicRecord_succeedsOnClasspath() {
+ var ci = ClassInfo.of(PackageRec.class);
+ assertNotNull(BeanMeta.makeRecordConstructorAccessible(ci, ctor(PackageRec.class)));
+ }
+}
diff --git a/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/utils/StringUtils_Test.java b/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/utils/StringUtils_Test.java
index 1a785b3c25..5e4944dbfe 100755
--- a/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/utils/StringUtils_Test.java
+++ b/juneau-core/juneau-commons/src/test/java/org/apache/juneau/commons/utils/StringUtils_Test.java
@@ -4698,6 +4698,11 @@ class StringUtils_Test extends TestBase {
assertEquals(123.45f, parseNumber("123.45", Float.class));
assertEquals(123.45f, parseNumber("123.45", Float.TYPE));
+ // Untyped (auto-detect) decimals are Double, never Float (WORK-J0588 regression guard).
+ assertEquals(Double.class, parseNumber("1.5", null).getClass());
+ assertEquals(Double.class, parseNumber("3.4028235E38", Number.class).getClass());
+ assertEquals(Double.class, parseNumber("0.1", Number.class).getClass());
+
// Test Long type - triggers code path
assertEquals(123L, parseNumber("123", Long.class));
assertEquals(123L, parseNumber("123", Long.TYPE));
diff --git a/juneau-core/juneau-config/src/main/java/org/apache/juneau/config/Config.java b/juneau-core/juneau-config/src/main/java/org/apache/juneau/config/Config.java
index 166f0374e0..81d0375b7e 100644
--- a/juneau-core/juneau-config/src/main/java/org/apache/juneau/config/Config.java
+++ b/juneau-core/juneau-config/src/main/java/org/apache/juneau/config/Config.java
@@ -1248,7 +1248,7 @@ public class Config extends Context implements ConfigEventListener {
}
/**
- * Saves this config file to the specified writer as an INI file.
+ * Saves this config file to the specified writer using this config's {@link ConfigFormat} (INI, YAML, etc.).
*
* @param w
* The writer to send the output to.
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parquet/Parquet.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parquet/Parquet.java
index 5794806bdb..70179cba09 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parquet/Parquet.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parquet/Parquet.java
@@ -33,9 +33,9 @@ import java.lang.annotation.*;
@Inherited
public @interface Parquet {
- /** Override Parquet physical type. */
+ /** Override Parquet physical type. <b>Reserved:</b> currently read by neither the serializer nor the parser, so it has no effect. */
String parquetType() default "";
- /** Override logical type. */
+ /** Override logical type. <b>Reserved:</b> currently read by neither the serializer nor the parser, so it has no effect. */
String logicalType() default "";
}
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parquet/ParquetConfig.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parquet/ParquetConfig.java
index ce600a1028..20e5e5c7f0 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parquet/ParquetConfig.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parquet/ParquetConfig.java
@@ -37,7 +37,13 @@ import org.apache.juneau.marshall.*;
@ContextApply({ ParquetConfigAnnotation.SerializerApply.class, ParquetConfigAnnotation.ParserApply.class })
public @interface ParquetConfig {
- /** Compression codec (UNCOMPRESSED, GZIP). */
+ /**
+ * Compression codec used when <b>writing</b> (<js>"UNCOMPRESSED"</js> or <js>"GZIP"</js>, case-insensitive).
+ *
+ * <p>
+ * Any other value (including <js>"SNAPPY"</js>, which the parser can decode but the serializer cannot write)
+ * falls back to <js>"UNCOMPRESSED"</js>. The parser detects the codec from the file and ignores this setting.
+ */
String compressionCodec() default "";
/** Row group size in bytes. */
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parser/ParserReader.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parser/ParserReader.java
index df0421c311..d3eee4340d 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parser/ParserReader.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/parser/ParserReader.java
@@ -20,6 +20,7 @@ import static org.apache.juneau.commons.utils.Shorts.*;
import static org.apache.juneau.commons.utils.StringUtils.*;
import java.io.*;
+import java.util.*;
import org.apache.juneau.commons.io.*;
@@ -71,7 +72,7 @@ public class ParserReader extends Reader implements Positionable {
private int iMark = -1; // Mark position in buffer
private int iEnd; // The last good character position in the buffer
private boolean endReached;
- private boolean holesExist;
+ private BitSet holes; // Deleted positions (relative to iMark); null unless an escape was collapsed in the current mark
private final boolean unbuffered;
/**
@@ -131,9 +132,7 @@ public class ParserReader extends Reader implements Positionable {
* @return This object.
*/
public final ParserReader delete(int count) {
- for (var i = 0; i < count; i++)
- buff[iCurrent - i - 1] = 127;
- holesExist = true;
+ markHoles(iCurrent - count, iCurrent);
return this;
}
@@ -160,16 +159,16 @@ public class ParserReader extends Reader implements Positionable {
public final String getMarked(int offsetStart, int offsetEnd) {
int offset = 0;
- // Holes are \u00FF 'delete' characters that we need to get rid of now.
- if (holesExist) {
+ // Holes are positions removed by delete()/replace() that we need to get rid of now.
+ // They are tracked by position (not by sentinel character) so real DEL characters are preserved.
+ if (holes != null) {
for (var i = iMark; i < iCurrent; i++) {
- char c = buff[i];
- if (c == 127)
+ if (holes.get(i - iMark))
offset++;
else
- buff[i - offset] = c;
+ buff[i - offset] = buff[i];
}
- holesExist = false;
+ holes = null;
}
int start = iMark + offsetStart;
int len = iCurrent - iMark + offsetEnd - offsetStart - offset;
@@ -186,6 +185,7 @@ public class ParserReader extends Reader implements Positionable {
*/
public final void mark() {
iMark = iCurrent;
+ holes = null;
}
/**
@@ -358,22 +358,42 @@ public class ParserReader extends Reader implements Positionable {
if (c < 0x10000) {
if (offset < 1)
throw ioex("Buffer underflow.");
+ clearHoles(iCurrent - offset, iCurrent - offset + 1);
buff[iCurrent - offset] = (char)c;
} else {
if (offset < 2)
throw ioex("Buffer underflow.");
c -= 0x10000;
+ clearHoles(iCurrent - offset, iCurrent - offset + 2);
buff[iCurrent - offset] = (char)(0xd800 + (c >> 10));
buff[iCurrent - offset + 1] = (char)(0xdc00 + (c & 0x3ff));
offset--;
}
- // Fill in the gap with DEL characters.
- for (var i = 1; i < offset; i++)
- buff[iCurrent - i] = 127;
- holesExist |= (offset > 1);
+ // Mark the gap as deleted.
+ if (offset > 1)
+ markHoles(iCurrent - offset + 1, iCurrent);
return this;
}
+ /*
+ * Un-deletes [from,to) because a replacement character is being written there.
+ */
+ private void clearHoles(int from, int to) {
+ if (holes != null && iMark >= 0)
+ holes.clear(Math.max(from - iMark, 0), Math.max(to - iMark, 0));
+ }
+
+ /*
+ * Records [from,to) as deleted; positions are stored relative to iMark so they survive buffer shifts.
+ */
+ private void markHoles(int from, int to) {
+ if (iMark < 0)
+ return;
+ if (holes == null)
+ holes = new BitSet();
+ holes.set(Math.max(from - iMark, 0), Math.max(to - iMark, 0));
+ }
+
/**
* Pushes the last read character back into the stream.
*
diff --git a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/yaml/YamlSerializer.java b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/yaml/YamlSerializer.java
index fc35914474..bcac90f965 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/yaml/YamlSerializer.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/marshall/yaml/YamlSerializer.java
@@ -85,27 +85,27 @@ import org.apache.juneau.marshall.stream.*;
* <jv>serializer</jv> = YamlSerializer.<jsf>DEFAULT</jsf>.copy().ws().build();
*
* <jc>// Serialize a POJO to YAML</jc>
- * String <jv>yaml</jv> = <jv>serializer</jv>.write(<jv>someObject</jv>);
+ * <jv>yaml</jv> = <jv>serializer</jv>.write(<jv>someObject</jv>);
* </p>
*
- * <h5 class='figure'>Example output (Map of name/age):</h5>
+ * <h5 class='figure'>Example output (LinkedHashMap of name/age):</h5>
* <p class='bcode'>
* name: Alice
* age: 30
* </p>
*
- * <h5 class='figure'>Complex (nested object + array):</h5>
+ * <h5 class='figure'>Complex (nested bean + array; bean properties are sorted alphabetically by default):</h5>
* <p class='bcode'>
- * name: Alice
- * age: 30
* address:
- * street: 123 Main St
* city: Boston
* state: MA
+ * street: "123 Main St"
+ * age: 30
+ * name: Alice
* tags:
- * - a
- * - b
- * - c
+ * - a
+ * - b
+ * - c
* </p>
*
* <h5 class='section'>Limitations compared to JSON</h5>
diff --git a/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/NonPublicRecord_Test.java b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/NonPublicRecord_Test.java
new file mode 100644
index 0000000000..1a850fcd2a
--- /dev/null
+++ b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/NonPublicRecord_Test.java
@@ -0,0 +1,103 @@
+/*
+ * 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;
+
+import static org.apache.juneau.commons.reflect.Visibility.*;
+import static org.apache.juneau.test.bct.BctAssertions.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.marshall.json5.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Tests binding of non-public (private / package-private) records.
+ */
+class NonPublicRecord_Test extends TestBase {
+
+ public record PublicRec(String name, int age) {}
+ record PackageRec(String name, int age) {}
+ private record PrivateRec(String name, int age) {}
+ protected record ProtectedRec(String name, int age) {}
+
+ static class Hidden {
+ public record NestedPublicRec(String name, int age) {}
+ }
+
+ private static Json5Serializer.Builder sb() {
+ return Json5Serializer.create();
+ }
+
+ private static Json5Parser.Builder pb() {
+ return Json5Parser.create();
+ }
+
+ @Test void a01_publicRecord_default() throws Exception {
+ assertString("{age:1,name:'a'}", Json5Serializer.DEFAULT.write(new PublicRec("a", 1)));
+ assertBean(Json5Parser.DEFAULT.read("{name:'a',age:1}", PublicRec.class), "name,age", "a,1");
+ }
+
+ @Test void a02_privateRecord_default_notBound() throws Exception {
+ var s = Json5Serializer.DEFAULT.write(new PrivateRec("a", 1));
+ assertFalse(s.startsWith("{"), s);
+ }
+
+ @Test void a03_packageRecord_default_notBound() throws Exception {
+ var s = Json5Serializer.DEFAULT.write(new PackageRec("a", 1));
+ assertFalse(s.startsWith("{"), s);
+ }
+
+ @Test void b01_privateRecord_roundTrip() throws Exception {
+ var s = sb().beanClassVisibility(PRIVATE).beanConstructorVisibility(PRIVATE).build();
+ var p = pb().beanClassVisibility(PRIVATE).beanConstructorVisibility(PRIVATE).build();
+ var json = s.write(new PrivateRec("a", 1));
+ assertString("{age:1,name:'a'}", json);
+ assertBean(p.read(json, PrivateRec.class), "name,age", "a,1");
+ }
+
+ @Test void b02_packageRecord_roundTrip() throws Exception {
+ var s = sb().beanClassVisibility(PRIVATE).beanConstructorVisibility(PRIVATE).build();
+ var p = pb().beanClassVisibility(PRIVATE).beanConstructorVisibility(PRIVATE).build();
+ var json = s.write(new PackageRec("a", 1));
+ assertString("{age:1,name:'a'}", json);
+ assertBean(p.read(json, PackageRec.class), "name,age", "a,1");
+ }
+
+ @Test void b03_protectedRecord_roundTrip() throws Exception {
+ var s = sb().beanClassVisibility(PROTECTED).beanConstructorVisibility(PROTECTED).build();
+ var p = pb().beanClassVisibility(PROTECTED).beanConstructorVisibility(PROTECTED).build();
+ var json = s.write(new ProtectedRec("a", 1));
+ assertString("{age:1,name:'a'}", json);
+ assertBean(p.read(json, ProtectedRec.class), "name,age", "a,1");
+ }
+
+ @Test void b04_privateRecord_classVisibilityOnly() throws Exception {
+ // Class visibility alone is enough; a record's canonical constructor is implied by the record declaration.
+ var s = sb().beanClassVisibility(PRIVATE).build();
+ var p = pb().beanClassVisibility(PRIVATE).build();
+ var json = s.write(new PrivateRec("a", 1));
+ assertString("{age:1,name:'a'}", json);
+ assertBean(p.read(json, PrivateRec.class), "name,age", "a,1");
+ }
+
+ @Test void b05_publicRecordInPackagePrivateClass_roundTrip() throws Exception {
+ var s = sb().beanClassVisibility(PRIVATE).build();
+ var p = pb().beanClassVisibility(PRIVATE).build();
+ var json = s.write(new Hidden.NestedPublicRec("a", 1));
+ assertString("{age:1,name:'a'}", json);
+ assertBean(p.read(json, Hidden.NestedPublicRec.class), "name,age", "a,1");
+ }
+}
diff --git a/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/ParserReader_Test.java b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/ParserReader_Test.java
index be196d9440..71b21d1132 100755
--- a/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/ParserReader_Test.java
+++ b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/ParserReader_Test.java
@@ -20,7 +20,12 @@ import static org.junit.jupiter.api.Assertions.*;
import java.io.*;
+import org.apache.juneau.marshall.json.*;
+import org.apache.juneau.marshall.json5.*;
+import org.apache.juneau.marshall.json5l.*;
+import org.apache.juneau.marshall.jsonl.*;
import org.apache.juneau.marshall.parser.*;
+import org.apache.juneau.marshall.uon.*;
import org.junit.jupiter.api.*;
@SuppressWarnings({
@@ -160,6 +165,65 @@ class ParserReader_Test extends TestBase {
// Utility methods
//====================================================================================================
+ //====================================================================================================
+ // Holes created by delete()/replace() are removed by position; real DEL (0x7F) characters survive.
+ //====================================================================================================
+ @Test void a03_holesPreserveRealDelCharacters() throws Exception {
+ var del = "\u007F";
+
+ // replace() with offset > 1 collapses an escape sequence; surrounding DELs are preserved.
+ var pr = createParserReader("\"a" + del + "\\u0041" + del + "b\"");
+ pr.mark();
+ read(pr, 4); // "a<DEL>\
+ pr.delete(); // drop the backslash
+ read(pr, 5); // u0041
+ pr.replace('A', 6); // collapse \u0041 (offset covers 6 chars read so far)
+ read(pr, 1); // <DEL>
+ read(pr, 2); // b"
+ assertEquals("a" + del + "A" + del + "b", pr.getMarked(1, -1));
+
+ // delete(count) with several holes plus real DELs.
+ pr = createParserReader("x" + del + "~~" + del + "y~" + del);
+ pr.mark();
+ read(pr, 4);
+ pr.delete(2);
+ read(pr, 3);
+ pr.delete(1);
+ read(pr, 1);
+ assertEquals("x" + del + del + "y" + del, pr.getMarked());
+
+ // Holes do not leak into a later mark.
+ pr = createParserReader("~a" + del + "b");
+ pr.mark();
+ read(pr, 1);
+ pr.delete();
+ assertEquals("", pr.getMarked());
+ pr.mark();
+ read(pr, 3);
+ assertEquals("a" + del + "b", pr.getMarked());
+ }
+
+
+ //====================================================================================================
+ // Parsers that collapse escapes through ParserReader.delete()/replace() keep real DEL (U+007F) characters.
+ // (YAML, TOML, Prototext and INI do not use delete()/replace(), so they are not exposed to this.)
+ //====================================================================================================
+ @Test void a04_delSurvivesEscapesInParsers() throws Exception {
+ var del = "\u007F";
+ var expected = "a" + del + "b\nc" + del;
+
+ assertEquals(expected, JsonParser.DEFAULT.read("\"a" + del + "b\\nc\\u007f\"", String.class));
+ assertEquals(expected, Json5Parser.DEFAULT.read("\"a" + del + "b\\nc\\u007f\"", String.class));
+ assertEquals(expected, Json5Parser.DEFAULT.read("'a" + del + "b\\nc" + del + "'", String.class));
+ assertEquals(expected, JsonParser.DEFAULT.read("\"a" + del + "b\\nc\\u007f\"", Object.class));
+ assertEquals("a" + del + "b'c" + del, UonParser.DEFAULT.read("'a" + del + "b~'c" + del + "'", String.class));
+
+ var jl = JsonlParser.DEFAULT.read("\"a" + del + "b\\nc\\u007f\"", String.class);
+ assertEquals(expected, jl);
+ var j5l = Json5lParser.DEFAULT.read("'a" + del + "b\\nc" + del + "'", String.class);
+ assertEquals(expected, j5l);
+ }
+
private static String read(ParserReader r) throws IOException {
return read(r, Integer.MAX_VALUE);
}
diff --git a/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/json/JsonParser_Test.java b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/json/JsonParser_Test.java
index 7811c941b2..297951998e 100755
--- a/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/json/JsonParser_Test.java
+++ b/juneau-core/juneau-marshall/src/test/java/org/apache/juneau/marshall/json/JsonParser_Test.java
@@ -110,6 +110,8 @@ class JsonParser_Test extends TestBase {
assertThrowsWithMessage(Exception.class, "Invalid JSON number", ()->p.read("-0123", Number.class));
assertThrowsWithMessage(Exception.class, "Invalid JSON number", ()->p.read("0x123", Number.class));
assertThrowsWithMessage(Exception.class, "Invalid JSON number", ()->p.read("-0x123", Number.class));
+ assertThrows(ParseException.class, ()->p.read("017", Number.class));
+ assertThrows(ParseException.class, ()->p.read("-017", Number.class));
}
@Test void a06b_validJsonNumbers() throws Exception {
@@ -117,6 +119,29 @@ class JsonParser_Test extends TestBase {
assertEquals(0, p.read("-0", Number.class).intValue());
}
+ @Test void a06c_laxJson5AcceptsHexAndOctal() throws Exception {
+ var p5 = Json5Parser.DEFAULT;
+ assertEquals(31, p5.read("0x1F", Number.class).intValue());
+ assertEquals(15, p5.read("017", Number.class).intValue());
+ }
+
+ //====================================================================================================
+ // DEL (U+007F) is legal unescaped in JSON strings and must survive a round-trip.
+ //====================================================================================================
+ @Test void a06d_delCharacterRoundTrip() throws Exception {
+ var s = "a\u007Fb";
+ var json = JsonSerializer.DEFAULT.toString(s);
+ assertString(s, p.read(json, String.class));
+ assertEquals(3, p.read(json, String.class).length());
+ }
+
+ @Test void a06e_delCharacterUnescaped() throws Exception {
+ var r = p.read("\"a\u007Fb\"", String.class);
+ assertString("a\u007Fb", r);
+ assertEquals(3, r.length());
+ assertEquals(0x7F, r.charAt(1));
+ }
+
//====================================================================================================
// testUnquotedStrings
// Lax parser allows unquoted strings if POJO can be converted from a string.
diff --git a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpElicitationIntegration_Test.java b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpElicitationIntegration_Test.java
index 7782b6e920..ca4c66846c 100644
--- a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpElicitationIntegration_Test.java
+++ b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpElicitationIntegration_Test.java
@@ -28,7 +28,6 @@ import org.apache.juneau.commons.inject.*;
import org.apache.juneau.marshall.json.*;
import org.apache.juneau.marshall.marshaller.Json;
import org.apache.juneau.microservice.*;
-import org.apache.juneau.rest.client.RestCallInterceptor;
import org.apache.juneau.rest.server.*;
import org.apache.juneau.rest.server.mcp.*;
import org.apache.juneau.rest.server.mcp.v20260728.*;
@@ -124,26 +123,13 @@ class McpElicitationIntegration_Test extends TestBase {
static MicroserviceTestFixture fixture = MicroserviceTestFixture.create()
.configurations(FixtureConfig.class);
- // Forces a fresh TCP connection per request instead of reusing a pooled keep-alive one: a client that
- // issues two or more requests (pause then resume) can otherwise race the embedded Jetty fixture
- // tearing down an idle pooled connection between calls. That surfaces as NoHttpResponseException /
- // ApacheHc45Transport$StaleConnectionException on the elicitation resume POST. Resume is a mutating
- // tools/call (requestState + ElicitAction, including cancel) and must not be replayed, so this test
- // client disables keep-alive rather than retrying. Same interceptor as McpMrtrIntegration_Test.
- private static final RestCallInterceptor CLOSE_CONNECTION_PER_REQUEST = new RestCallInterceptor() {
- @Override public void onInit(org.apache.juneau.rest.client.RestRequest req) {
- req.header("Connection", "close");
- }
- };
-
private static McpClient.Builder clientBuilder(boolean withElicitation) {
var caps = new ClientCapabilities();
if (withElicitation)
caps.setElicitation(new ElicitationCapability());
return McpClient.builder()
.endpoint(fixture.getRootUrl() + "/")
- .clientCapabilities(caps)
- .interceptor(CLOSE_CONNECTION_PER_REQUEST);
+ .clientCapabilities(caps);
}
/**
diff --git a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpMrtrIntegration_Test.java b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpMrtrIntegration_Test.java
index 97768ebcd5..95f16cda48 100644
--- a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpMrtrIntegration_Test.java
+++ b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpMrtrIntegration_Test.java
@@ -29,7 +29,6 @@ import org.apache.juneau.commons.inject.*;
import org.apache.juneau.marshall.json.*;
import org.apache.juneau.marshall.marshaller.Json;
import org.apache.juneau.microservice.*;
-import org.apache.juneau.rest.client.RestCallInterceptor;
import org.apache.juneau.rest.server.*;
import org.apache.juneau.rest.server.mcp.*;
import org.apache.juneau.rest.server.mcp.v20260728.*;
@@ -184,24 +183,13 @@ class McpMrtrIntegration_Test extends TestBase {
static MicroserviceTestFixture fixture = MicroserviceTestFixture.create()
.configurations(FixtureConfig.class);
- // Forces a fresh TCP connection per request instead of reusing a pooled keep-alive one: a client that
- // issues two or more requests (every test below does) can otherwise race the embedded Jetty fixture
- // tearing down/resetting an idle pooled connection between calls, surfacing as a spurious
- // NoHttpResponseException instead of the response the test actually expects.
- private static final RestCallInterceptor CLOSE_CONNECTION_PER_REQUEST = new RestCallInterceptor() {
- @Override public void onInit(org.apache.juneau.rest.client.RestRequest req) {
- req.header("Connection", "close");
- }
- };
-
private static McpClient.Builder clientBuilder(boolean withElicitation) {
var caps = new ClientCapabilities();
if (withElicitation)
caps.setElicitation(new ElicitationCapability());
return McpClient.builder()
.endpoint(fixture.getRootUrl() + "/")
- .clientCapabilities(caps)
- .interceptor(CLOSE_CONNECTION_PER_REQUEST);
+ .clientCapabilities(caps);
}
private static McpClient.Builder clientBuilderAt(String path, boolean withElicitation) {
@@ -210,8 +198,7 @@ class McpMrtrIntegration_Test extends TestBase {
caps.setElicitation(new ElicitationCapability());
return McpClient.builder()
.endpoint(fixture.getRootUrl() + path)
- .clientCapabilities(caps)
- .interceptor(CLOSE_CONNECTION_PER_REQUEST);
+ .clientCapabilities(caps);
}
// =================================================================================================================
diff --git a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/httppart/RequestContent_ChunkedKeepAlive_JettyMicroservice_Test.java b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/httppart/RequestContent_ChunkedKeepAlive_JettyMicroservice_Test.java
new file mode 100644
index 0000000000..42347c97fd
--- /dev/null
+++ b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/httppart/RequestContent_ChunkedKeepAlive_JettyMicroservice_Test.java
@@ -0,0 +1,157 @@
+/*
+ * 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.server.httppart;
+
+import static java.nio.charset.StandardCharsets.*;
+import static org.apache.juneau.test.bct.BctAssertions.*;
+
+import java.io.*;
+import java.net.*;
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.http.Content;
+import org.apache.juneau.marshall.collections.*;
+import org.apache.juneau.marshall.json.*;
+import org.apache.juneau.microservice.*;
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.servlet.*;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.extension.*;
+
+import jakarta.servlet.*;
+
+/**
+ * Real-Jetty regression coverage for {@link RequestContent} draining a parsed request entity to end-of-stream.
+ *
+ * <p>
+ * A reader parser stops as soon as the top-level value is complete, so without an explicit drain the
+ * zero-length terminating chunk of a {@code Transfer-Encoding: chunked} request body is never read whenever it
+ * arrives after the JSON bytes. The container then finds unconsumed request content when the (already committed)
+ * response completes and aborts the connection — with no {@code Connection: close} header, because the
+ * headers have already gone out — so a client reusing that pooled keep-alive connection for its next request
+ * fails with an immediate EOF. Streaming HTTP clients (the JDK {@code HttpClient} with an
+ * {@code ofInputStream} publisher, Apache HttpClient with a streaming entity) routinely flush the terminator
+ * separately from the content, so this surfaced as an intermittent stale-connection failure on the MCP
+ * elicitation resume POST.
+ *
+ * <p>
+ * The exchanges below are driven over a raw socket so the terminator timing is deterministic rather than at the
+ * mercy of a client library's write scheduling.
+ */
+@org.apache.juneau.testing.JettyMicroserviceTest
+@SuppressWarnings({
+ "java:S2925" // The sleep deliberately holds back the chunk terminator past the handler's completion; there is no server-side seam to latch on.
+})
+class RequestContent_ChunkedKeepAlive_JettyMicroservice_Test extends TestBase {
+
+ @Rest(paths="/echo/*", serializers=JsonSerializer.class, parsers=JsonParser.class, defaultAccept="application/json")
+ public static class EchoServlet extends RestServlet {
+ private static final long serialVersionUID = 1L;
+
+ @RestPost("/")
+ public JsonMap echo(@Content JsonMap body) {
+ return body;
+ }
+ }
+
+ @Configuration
+ public static class Config {
+ @Bean(name="echoServlet")
+ public Servlet echoServlet() { return new EchoServlet(); }
+ }
+
+ @RegisterExtension
+ static MicroserviceTestFixture fixture = MicroserviceTestFixture.create()
+ .configurations(Config.class);
+
+ /** How long the terminating chunk is held back: comfortably longer than the handler takes on a warm connection. */
+ private static final long TERMINATOR_DELAY_MS = 300;
+
+ @Test void a01_lateChunkTerminator_keepsConnectionAlive() throws Exception {
+ var uri = fixture.getRootUrl();
+ try (var socket = new Socket(uri.getHost(), uri.getPort())) {
+ socket.setSoTimeout(10_000);
+ var out = socket.getOutputStream();
+ var in = new BufferedInputStream(socket.getInputStream());
+ var statuses = new ArrayList<String>();
+
+ // Warm-up exchange, so the late-terminator exchange below is handled faster than the delay.
+ statuses.add(exchange(out, in, "{\"n\":1}", 0));
+ // The JSON is complete but its 0-chunk arrives only after the handler has returned.
+ statuses.add(exchange(out, in, "{\"n\":2}", TERMINATOR_DELAY_MS));
+ // Reuses the same keep-alive connection; fails with EOF/broken pipe if the server aborted it.
+ statuses.add(exchange(out, in, "{\"n\":3}", 0));
+
+ assertList(statuses, "HTTP/1.1 200 OK {\"n\":1}", "HTTP/1.1 200 OK {\"n\":2}", "HTTP/1.1 200 OK {\"n\":3}");
+ }
+ }
+
+ // Sends one chunked POST (terminator delayed by delayMs) and returns "<status-line> <body>" of the response.
+ private static String exchange(OutputStream out, InputStream in, String json, long delayMs) throws Exception {
+ var body = json.getBytes(UTF_8);
+ out.write(("POST /echo/ HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nAccept: application/json\r\n"
+ + "Transfer-Encoding: chunked\r\n\r\n" + Integer.toHexString(body.length) + "\r\n").getBytes(ISO_8859_1));
+ out.write(body);
+ out.write("\r\n".getBytes(ISO_8859_1));
+ out.flush();
+ if (delayMs > 0)
+ Thread.sleep(delayMs);
+ out.write("0\r\n\r\n".getBytes(ISO_8859_1));
+ out.flush();
+ return readResponse(in);
+ }
+
+ // Reads one HTTP/1.1 response (chunked or Content-Length framed) and returns "<status-line> <body>".
+ private static String readResponse(InputStream in) throws IOException {
+ var status = readLine(in);
+ if (status == null)
+ throw new EOFException("Connection closed by server before a response status line was received.");
+ var chunked = false;
+ var contentLength = -1;
+ for (var line = readLine(in); line != null && ! line.isEmpty(); line = readLine(in)) {
+ var lc = line.toLowerCase(Locale.ROOT);
+ if (lc.startsWith("transfer-encoding:") && lc.contains("chunked"))
+ chunked = true;
+ else if (lc.startsWith("content-length:"))
+ contentLength = Integer.parseInt(lc.substring(15).trim());
+ }
+ var body = new ByteArrayOutputStream();
+ if (chunked) {
+ for (var size = Integer.parseInt(readLine(in).trim(), 16); size > 0; size = Integer.parseInt(readLine(in).trim(), 16)) {
+ body.write(in.readNBytes(size));
+ readLine(in);
+ }
+ readLine(in);
+ } else if (contentLength > 0) {
+ body.write(in.readNBytes(contentLength));
+ }
+ return status + " " + body.toString(UTF_8);
+ }
+
+ private static String readLine(InputStream in) throws IOException {
+ var sb = new StringBuilder();
+ for (var c = in.read(); c != -1; c = in.read()) {
+ if (c == '\n')
+ return sb.toString();
+ if (c != '\r')
+ sb.append((char)c);
+ }
+ return sb.isEmpty() ? null : sb.toString();
+ }
+}
diff --git a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/HttpTransportBuilder.java b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/HttpTransportBuilder.java
index 7992663bd3..946408e4d6 100644
--- a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/HttpTransportBuilder.java
+++ b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/HttpTransportBuilder.java
@@ -27,9 +27,9 @@ package org.apache.juneau.rest.client;
* <p>
* Example usage:
* <p class='bjava'>
- * HttpTransport <jv>transport</jv> = ApacheHc5Transport.builder()
- * .connectTimeout(Duration.ofSeconds(5))
- * .readTimeout(Duration.ofSeconds(30))
+ * <jc>// Transport-specific options (here, the underlying Apache client) are set on the concrete builder.</jc>
+ * HttpTransport <jv>transport</jv> = ApacheHc5Transport.<jsm>builder</jsm>()
+ * .httpClient(<jv>myCloseableHttpClient</jv>)
* .build();
*
* RestClient <jv>client</jv> = RestClient.create()
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 8b5e72ceae..92404a8870 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
@@ -344,8 +344,12 @@ public final class RestRequest {
* Sets a per-call response timeout for this request.
*
* <p>
- * Threaded onto the {@link TransportRequest} and applied by the transport as the response/read timeout. Connect
- * timeouts remain a client-level setting.
+ * Threaded onto the {@link TransportRequest}. Connect timeouts remain a client-level setting.
+ *
+ * <p>
+ * <b>Transport support:</b> currently only {@link JavaHttpTransport} applies this value (as the JDK
+ * {@code HttpRequest} timeout). The Apache HttpClient, OkHttp and Jetty transports do not consult it; configure
+ * their own timeouts on the transport builder (e.g. {@code JettyHttpTransportBuilder.responseTimeoutMs(long)}).
*
* @param value The response timeout. May be <jk>null</jk> to use the transport default.
* @return This object.
diff --git a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/TransportRequest.java b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/TransportRequest.java
index 784d0a5080..7bbffefae6 100644
--- a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/TransportRequest.java
+++ b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/TransportRequest.java
@@ -147,8 +147,8 @@ public final class TransportRequest {
* Returns the per-call response timeout, or {@code null} if none was set.
*
* <p>
- * Transports apply this as the response/read timeout for the request; a {@code null} value means the
- * transport's own default applies. Connect timeouts remain a client-level concern.
+ * A {@code null} value means the transport's own default applies. Connect timeouts remain a client-level
+ * concern. Currently only {@link JavaHttpTransport} honors this value; other transports ignore it.
*
* @return The response timeout, possibly <jk>null</jk>.
*/
diff --git a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/remote/RemoteClient.java b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/remote/RemoteClient.java
index 0895bf38dd..9eccfa86f4 100644
--- a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/remote/RemoteClient.java
+++ b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/remote/RemoteClient.java
@@ -698,7 +698,7 @@ public final class RemoteClient {
private static boolean isBean(Object arg) {
return ! (arg instanceof CharSequence || arg instanceof Number || arg instanceof Boolean
- || arg instanceof Character || arg instanceof Enum || arg instanceof java.util.Date
+ || arg instanceof Character || arg instanceof Enum || isDateTime(arg)
|| arg instanceof Collection || arg.getClass().isArray());
}
@@ -1146,7 +1146,12 @@ public final class RemoteClient {
/** Returns <jk>true</jk> if the {@code @Part} argument is a scalar that should be sent as a text field. */
private static boolean isScalarPart(Object arg) {
return arg instanceof CharSequence || arg instanceof Number || arg instanceof Boolean
- || arg instanceof Character || arg instanceof Enum || arg instanceof java.util.Date;
+ || arg instanceof Character || arg instanceof Enum || isDateTime(arg);
+ }
+
+ /** Returns <jk>true</jk> for {@link java.util.Date} and {@code java.time} values (Instant, LocalDate, Duration, Period, ...), which are scalars. */
+ private static boolean isDateTime(Object arg) {
+ return arg instanceof java.util.Date || arg instanceof java.time.temporal.Temporal || arg instanceof java.time.temporal.TemporalAmount;
}
/**
diff --git a/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/RemoteClient_Test.java b/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/RemoteClient_Test.java
index 5527e1d214..961958ee1d 100644
--- a/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/RemoteClient_Test.java
+++ b/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/RemoteClient_Test.java
@@ -363,6 +363,32 @@ class RemoteClient_Test extends TestBase {
}
}
+ @Remote
+ interface JavaTimeQueryService {
+ @RemoteGet("/echo-query")
+ String getInstant(@Query java.time.Instant when);
+
+ @RemoteGet("/echo-query")
+ String getLocalDate(@Query java.time.LocalDate d);
+
+ @RemoteGet("/echo-query")
+ String getDuration(@Query java.time.Duration d);
+
+ @RemoteGet("/echo-query")
+ String getOffsetDateTime(@Query java.time.OffsetDateTime d);
+ }
+
+ @Test void g02_query_javaTimeArgs_sentAsScalars() throws Exception {
+ try (var client = RestClient.builder().rootUrl(rootUrl()).allowPrivateUrls(true).build()) {
+ var svc = client.remote(JavaTimeQueryService.class);
+ // Blank-named parts must serialize as a single scalar value, not expand as a bean.
+ assertTrue(svc.getInstant(java.time.Instant.parse("2026-01-02T03:04:05Z")).endsWith("=2026-01-02T03:04:05Z"));
+ assertTrue(svc.getLocalDate(java.time.LocalDate.of(2026, 1, 2)).endsWith("=2026-01-02"));
+ assertTrue(svc.getDuration(java.time.Duration.ofSeconds(5)).endsWith("=PT5S"));
+ assertTrue(svc.getOffsetDateTime(java.time.OffsetDateTime.of(2026, 1, 2, 3, 4, 5, 0, java.time.ZoneOffset.UTC)).endsWith("=2026-01-02T03:04:05Z"));
+ }
+ }
+
// -----------------------------------------------------------------------
// H — Object method delegation (toString, hashCode, equals on proxy)
// -----------------------------------------------------------------------
diff --git a/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/remote/RemoteClient_InvocationHandlerInternals_Test.java b/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/remote/RemoteClient_InvocationHandlerInternals_Test.java
index 7baf0b4f8b..f11c08f1e6 100644
--- a/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/remote/RemoteClient_InvocationHandlerInternals_Test.java
+++ b/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/remote/RemoteClient_InvocationHandlerInternals_Test.java
@@ -185,6 +185,19 @@ class RemoteClient_InvocationHandlerInternals_Test extends TestBase {
assertEquals(false, invokeStatic("isBean", new Class<?>[]{Object.class}, new int[]{1}));
}
+ @Test void e06b_isBean_javaTimeTypes_allFalse() throws Exception {
+ for (var v : javaTimeValues())
+ assertEquals(false, invokeStatic("isBean", new Class<?>[]{Object.class}, v), v.getClass().getName());
+ }
+
+ private static Object[] javaTimeValues() {
+ return new Object[]{
+ java.time.Instant.EPOCH, java.time.LocalDate.of(2026, 1, 2), java.time.LocalDateTime.of(2026, 1, 2, 3, 4),
+ java.time.OffsetDateTime.of(2026, 1, 2, 3, 4, 5, 0, java.time.ZoneOffset.UTC), java.time.ZonedDateTime.of(2026, 1, 2, 3, 4, 5, 0, java.time.ZoneOffset.UTC),
+ java.time.Duration.ofSeconds(5), java.time.Period.ofDays(3), java.time.LocalTime.NOON
+ };
+ }
+
private static Object TimeUnit() { return java.util.concurrent.TimeUnit.SECONDS; }
@Test void e07_isBean_plainObject_true() throws Exception {
@@ -336,6 +349,11 @@ class RemoteClient_InvocationHandlerInternals_Test extends TestBase {
assertEquals(true, invokeStatic("isScalarPart", new Class<?>[]{Object.class}, new Date()));
}
+ @Test void j03b_isScalarPart_javaTimeTypes_true() throws Exception {
+ for (var v : javaTimeValues())
+ assertEquals(true, invokeStatic("isScalarPart", new Class<?>[]{Object.class}, v), v.getClass().getName());
+ }
+
@Test void j04_isScalarPart_nonScalar_false() throws Exception {
assertEquals(false, invokeStatic("isScalarPart", new Class<?>[]{Object.class}, List.of("x")));
assertEquals(false, invokeStatic("isScalarPart", new Class<?>[]{Object.class}, new Object()));
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/CardEnvelope.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/CardEnvelope.java
index e742c1e67c..3ebe31ab3a 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/CardEnvelope.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/CardEnvelope.java
@@ -57,13 +57,28 @@ final class CardEnvelope {
}
}
- /** Author-catalog VIEW_META fields copied verbatim onto the lifted {@code view} when present. */
+ /**
+ * Author-catalog VIEW_META fields copied verbatim onto the lifted {@code view} when present.
+ *
+ * <p>
+ * Every view-level option {@code juneau-views.js} / {@code juneau-urlstate.js} reads off the view def must be listed
+ * here, otherwise it is silently dropped on FTL-built pages: {@code defaultOrder}, {@code ribbon}, {@code dataMode},
+ * {@code rowType}, {@code pollIntervalMs}, {@code columnConfig}, {@code cleanAddress}, {@code primary},
+ * {@code copyLink} (set {@code false} to suppress the View Settings Copy-link button), {@code rowActions} (row-action
+ * catalog), {@code rowClassRules}, and {@code pausePollingWhileEditing}.
+ */
private static final Set<String> VIEW_META_PASSTHROUGH =
Set.of(
"defaultOrder", "ribbon", "dataMode", "rowType", "pollIntervalMs", "columnConfig",
- "cleanAddress", "primary"
+ "cleanAddress", "primary", "copyLink", "rowActions", "rowClassRules", "pausePollingWhileEditing"
);
+ /**
+ * Author-catalog SLOT_META (envelope-level, sibling of {@code view}) fields copied verbatim onto the lifted slot when
+ * present: {@code selection} ({@code {rowIdField, selectAll}}) and {@code bulk} (the bulk-actions contract object).
+ */
+ private static final Set<String> SLOT_META_PASSTHROUGH = Set.of("selection", "bulk");
+
/**
* Lifts a {@code type="datatables"} author catalog into the frozen SLOT_META envelope that
* {@code JuneauViews.init.mountTableSlot} handshakes (F2).
@@ -72,7 +87,9 @@ final class CardEnvelope {
* The author writes IRS-portable catalog JSON5 (a bare {@code {dataUrl, columns:[{key,label}]}}),
* <b>not</b> hand-authored VIEW_META. This method wraps it in a {@link ViewsMixin#SLOT_CONTRACT_VERSION}
* slot carrying a {@link ViewsMixin#CONTRACT_VERSION} view: author {@code key}/{@code label} become
- * VIEW_META {@code data}/{@code title} ({@code data}/{@code title} are also accepted verbatim). An
+ * VIEW_META {@code data}/{@code title} ({@code data}/{@code title} are also accepted verbatim). The view-level
+ * options in {@link #VIEW_META_PASSTHROUGH} land on {@code view}; {@code selection} and {@code bulk} land on the slot
+ * itself ({@link #SLOT_META_PASSTHROUGH}). An
* object that already carries both {@code contractVersion} and {@code view} is treated as a
* pre-built SLOT_META envelope and passed through unchanged (escape hatch).
*
@@ -115,6 +132,9 @@ final class CardEnvelope {
slot.put("contractVersion", ViewsMixin.SLOT_CONTRACT_VERSION);
slot.put("layout", "wide");
slot.put("view", view);
+ for (var f : SLOT_META_PASSTHROUGH)
+ if (catalog.containsKey(f))
+ slot.put(f, catalog.get(f));
return slot;
}
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ToolkitPackRegistry.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ToolkitPackRegistry.java
index 77c24202a0..70dc9b3eed 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ToolkitPackRegistry.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/main/java/org/apache/juneau/rest/server/view/freemarker/console/ToolkitPackRegistry.java
@@ -27,7 +27,10 @@ import org.apache.juneau.rest.server.widgets.*;
*
* <p>
* Ships the {@code "views"} pack against the real {@link ViewsMixin} path constants. Load order is a
- * contract: CSS then JS, JS in the order the JRM {@code TableSlotPage} uses. A consumer can register
+ * contract: CSS then JS, JS in the order the JRM {@code TableSlotPage} uses (renders, icons, search, pagestate,
+ * urlstate, ribbon, [datatables glue], views, config, regions, helpers). {@code juneau-urlstate.js} is required
+ * by the View Settings Copy-link button and shareable-URL state ({@code JuneauViews.urlState}); it must precede
+ * {@code juneau-views.js} / {@code juneau-config.js}. A consumer can register
* extra packs via {@code ConsoleFreemarkerMixin.Builder.registerToolkitPack(...)}.
*
* <p>
@@ -89,6 +92,7 @@ public final class ToolkitPackRegistry {
ViewsMixin.ICONS_JS_PATH,
ViewsMixin.SEARCH_JS_PATH,
ViewsMixin.PAGESTATE_JS_PATH,
+ ViewsMixin.URLSTATE_JS_PATH,
ViewsMixin.RIBBON_JS_PATH,
ViewsMixin.DATATABLES_JS_PATH,
ViewsMixin.VIEWS_JS_PATH,
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/CardEnvelope_Test.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/CardEnvelope_Test.java
index f8acd13cde..49600d0f5f 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/CardEnvelope_Test.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/CardEnvelope_Test.java
@@ -60,6 +60,39 @@ class CardEnvelope_Test extends TestBase {
assertEquals("html", m.getString("type"));
}
+ private static final String BASE = "dataUrl:'/d', columns:[{key:'a',label:'A'}]";
+
+ @Test void liftTable_viewLevelKeys_roundTrip() {
+ var slot = CardEnvelope.liftTable("c1", CardEnvelope.parse("{" + BASE + """
+ , copyLink:false, primary:true, columnConfig:true,
+ rowActions:[{id:'del',label:'Delete'}],
+ rowClassRules:{'is-bad':'status==\\'bad\\''},
+ pausePollingWhileEditing:true
+ }"""));
+ assertBean(slot.getMap("view"), "id,copyLink,primary,columnConfig,pausePollingWhileEditing", "c1,false,true,true,true");
+ assertBean(slot.getMap("view").getList("rowActions").get(0), "id,label", "del,Delete");
+ assertTrue(slot.getMap("view").getMap("rowClassRules").containsKey("is-bad"));
+ }
+
+ @Test void liftTable_slotLevelKeys_selectionAndBulk() {
+ var slot = CardEnvelope.liftTable("c1", CardEnvelope.parse("{" + BASE + """
+ , selection:{rowIdField:'id', selectAll:false},
+ bulk:{contractVersion:1, actions:[{id:'x'}]}
+ }"""));
+ assertBean(slot.getMap("selection"), "rowIdField,selectAll", "id,false");
+ assertBean(slot.getMap("bulk"), "contractVersion", "1");
+ assertFalse(slot.getMap("view").containsKey("selection"));
+ assertFalse(slot.getMap("view").containsKey("bulk"));
+ }
+
+ @Test void liftTable_absentKeys_notEmitted() {
+ var slot = CardEnvelope.liftTable("c1", CardEnvelope.parse("{" + BASE + "}"));
+ for (var k : List.of("selection", "bulk"))
+ assertFalse(slot.containsKey(k), k);
+ for (var k : List.of("copyLink", "rowActions", "rowClassRules", "pausePollingWhileEditing"))
+ assertFalse(slot.getMap("view").containsKey(k), k);
+ }
+
// The new v1 main sources live here; the test CWD is the module directory (surefire).
private static final Path MAIN =
Path.of("src/main/java/org/apache/juneau/rest/server/view/freemarker/console");
diff --git a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/ToolkitPackRegistry_Test.java b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/ToolkitPackRegistry_Test.java
index 37a555e3f0..3ce4bacfb8 100644
--- a/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/ToolkitPackRegistry_Test.java
+++ b/juneau-rest/juneau-rest-server-console-ui-freemarker/src/test/java/org/apache/juneau/rest/server/view/freemarker/console/ToolkitPackRegistry_Test.java
@@ -73,11 +73,12 @@ class ToolkitPackRegistry_Test extends TestBase {
assertTrue(indexOfContaining(r.cssUrls(), "juneau-views.css") >= 0, () -> r.cssUrls().toString());
assertTrue(indexOfContaining(r.cssUrls(), "juneau-config.css") >= 0, () -> r.cssUrls().toString());
- // JS load order is a contract: renders, icons, search, pagestate, ribbon, datatables glue, views, config,
+ // JS load order is a contract: renders, icons, search, pagestate, urlstate (Copy link), ribbon, datatables glue, views, config,
// regions, helpers LAST.
var js = r.jsUrls();
var order = List.of(
- "juneau-renders.js", "juneau-icons.js", "juneau-search.js", "juneau-pagestate.js", "juneau-ribbon.js",
+ "juneau-renders.js", "juneau-icons.js", "juneau-search.js", "juneau-pagestate.js", "juneau-urlstate.js",
+ "juneau-ribbon.js",
"juneau-datatables.js", "juneau-views.js", "juneau-config.js", "juneau-regions.js", "juneau-helpers.js");
var prev = -1;
for (var name : order) {
diff --git a/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin.java b/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin.java
index a1a60bfac4..1071a33c29 100644
--- a/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin.java
+++ b/juneau-rest/juneau-rest-server-console-ui/src/main/java/org/apache/juneau/rest/server/console/ConsoleChromeMixin.java
@@ -174,7 +174,7 @@ public class ConsoleChromeMixin {
public static final String CONSOLE_JS_PATH = "/juneau-console/juneau-console.js";
/** {@link #CONSOLE_JS_PATH} minus the {@code /juneau-console} prefix - see the class javadoc's mount-styles section. */
- static final String CONSOLE_JS_PATH_UNPREFIXED = "/juneau-console.js";
+ public static final String CONSOLE_JS_PATH_UNPREFIXED = "/juneau-console.js";
/** Classpath location of the console shell script. */
public static final String CONSOLE_JS_RESOURCE = "/org/apache/juneau/console/juneau-console.js";
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Render.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Render.java
index 0d733583e5..a466654511 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Render.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/Render.java
@@ -24,12 +24,15 @@ import org.apache.juneau.commons.bean.*;
import org.apache.juneau.rest.server.widgets.*;
/**
- * A named cell-renderer reference in the {@code VIEW_META} wire contract (design doc §6.6).
+ * A named cell-renderer reference in the {@code VIEW_META} wire contract.
*
* <p>
- * Serializes to the canonical object form <c>{"id":"tag","meta":{"field":"status"}}</c>. The client
+ * Serializes to the canonical object form <c>{"id":"tag","meta":{"field":"status"}}</c>; the optional
+ * {@link #meta} and {@link #popover} members are omitted from the wire when unset. The client
* {@code juneau-renders.js} registry looks up {@link #id} and invokes the matching renderer, passing the
- * {@link #meta} map through as per-column context. Timestamp renderers ({@code ts-zulu}, {@code datetime})
+ * {@link #meta} map through as per-column context (and painting {@link #popover}, when present, from row data
+ * already on the client). Typed factories exist for the renderers that take structured metadata:
+ * {@link #progress(int)} and {@link #pill(String)}. Timestamp renderers ({@code ts-zulu}, {@code datetime})
* honor {@code meta.popup}: {@code off} disables the local/California hover popup; {@code ts-zulu} defaults
* the popup on, {@code datetime} defaults it off.
*
diff --git a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewsMixin.java b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewsMixin.java
index ba97954ab4..9de1144e75 100644
--- a/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewsMixin.java
+++ b/juneau-rest/juneau-rest-server-views/src/main/java/org/apache/juneau/rest/server/views/ViewsMixin.java
@@ -309,14 +309,14 @@ public class ViewsMixin {
*
* <h5 class='section'>Example:</h5>
* <p class='bjava'>
- * <jc>// A views-only application serves the console shell at the same stable path ConsoleChromeMixin uses.</jc>
+ * <jc>// A views-only application serves the console shell at the same stable (unprefixed) path ConsoleChromeMixin serves it at.</jc>
* <ja>@Rest</ja>(mixins=ViewsMixin.<jk>class</jk>)
* <jk>public class</jk> MyResource {}
*
* <jc>// GET /juneau-console.js now returns the shell runtime.</jc>
* </p>
*/
- public static final String CONSOLE_JS_PATH = "/juneau-console.js";
+ public static final String CONSOLE_JS_PATH = ConsoleChromeMixin.CONSOLE_JS_PATH_UNPREFIXED;
/**
* The frozen {@code VIEW_META} contract-version handshake constant. FTL {@code <@card type="datatables">}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/DataTablesAjax_BrowserTest.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/DataTablesAjax_BrowserTest.java
index 0588237dd0..9439277ca4 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/DataTablesAjax_BrowserTest.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/DataTablesAjax_BrowserTest.java
@@ -28,6 +28,7 @@ import java.util.concurrent.*;
import org.apache.juneau.*;
import org.apache.juneau.commons.beanquery.*;
+import org.apache.juneau.http.BeanQueryRequest;
import org.apache.juneau.marshall.marshaller.*;
import org.apache.juneau.rest.server.datatables.*;
import org.apache.juneau.rest.server.datatables.adapter.*;
@@ -118,6 +119,8 @@ class DataTablesAjax_BrowserTest extends TestBase {
try (var os = ex.getResponseBody()) {
os.write(bytes);
}
+ } catch (BeanQuerySyntaxException e) {
+ sendBadRequest(ex, e);
} catch (Exception e) {
sendFailure(ex, e);
}
@@ -133,6 +136,20 @@ class DataTablesAjax_BrowserTest extends TestBase {
server.start();
}
+ /**
+ * Answers a query syntax error the way {@code RestContext.convertThrowable} does: a 400 carrying the error code in
+ * the {@code X-BeanQuery-Error} header and the message as the body.
+ */
+ private static void sendBadRequest(HttpExchange ex, BeanQuerySyntaxException e) throws IOException {
+ var bytes = e.getMessage().getBytes(UTF_8);
+ ex.getResponseHeaders().add(BeanQueryRequest.ERROR_HEADER, e.code().name());
+ ex.getResponseHeaders().add("Content-Type", "text/plain;charset=utf-8");
+ ex.sendResponseHeaders(400, bytes.length);
+ try (var os = ex.getResponseBody()) {
+ os.write(bytes);
+ }
+ }
+
/**
* Answers a handler failure with a 500 carrying the stack trace, instead of throwing (which makes the JDK server
* drop the connection with no response) - so the prober's network diagnostics show why a draw failed.
diff --git a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsMixin_Serving_Test.java b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsMixin_Serving_Test.java
index 34bf39f2c2..4014328a33 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsMixin_Serving_Test.java
+++ b/juneau-rest/juneau-rest-server-views/src/test/java/org/apache/juneau/rest/server/views/ViewsMixin_Serving_Test.java
@@ -1224,6 +1224,11 @@ class ViewsMixin_Serving_Test extends TestBase {
.assertContent().asString().isContains("window.JuneauConsole");
}
+ @Test void q03_consoleJsPath_singleSourceOfTruth() {
+ assertEquals("/juneau-console.js", ViewsMixin.CONSOLE_JS_PATH);
+ assertEquals(org.apache.juneau.rest.server.console.ConsoleChromeMixin.CONSOLE_JS_PATH_UNPREFIXED, ViewsMixin.CONSOLE_JS_PATH);
+ }
+
@Test void q02_hostWithoutMixin_consoleJsRouteIs404() throws Exception {
cNoMixin.get(ViewsMixin.CONSOLE_JS_PATH).run().assertStatus(404);
}
diff --git a/juneau-rest/juneau-rest-server-views/src/test/js/datatables-ajax.cjs b/juneau-rest/juneau-rest-server-views/src/test/js/datatables-ajax.cjs
index 50965ea493..97d87b4601 100644
--- a/juneau-rest/juneau-rest-server-views/src/test/js/datatables-ajax.cjs
+++ b/juneau-rest/juneau-rest-server-views/src/test/js/datatables-ajax.cjs
@@ -133,11 +133,11 @@ const PROBE = async function () {
out.t1Page2 = { firstRow: firstRowText('t1', 1) };
// 6) #t3 has a bad column (data: 'nope'); its draws surface the server's error via DataTables' default
- // errMode dialog (window.alert), captured by the harness's page.on('dialog') listener below. DataTables fires
- // dt-error just before that alert, which is what this step waits on. Searching the column makes the failure
- // independent of the initial-order draw.
+ // errMode dialog (window.alert), captured by the harness's page.on('dialog') listener below. DataTables
+ // (dt-error) or the Juneau glue (error.dt) fires its event just before that alert, which is what this step
+ // waits on. Searching the column makes the failure independent of the initial-order draw.
let t3Errored = false;
- window.jQuery('#t3').on('dt-error.dt', () => { t3Errored = true; });
+ window.jQuery('#t3').on('error.dt dt-error.dt', () => { t3Errored = true; });
window.JuneauDataTables_t3.table.column(0).search('x').draw();
await waitFor('t3Error', () => t3Errored, 10000);
await sleep(100); // let the alert itself reach page.on('dialog')
@@ -160,6 +160,12 @@ const PROBE = async function () {
const dialogs = [];
page.on('pageerror', e => failures.push(String(e)));
page.on('console', m => { if (m.type() === 'error') failures.push(m.text()); });
+ // #t3 (the only table sending X-Test-Header) deliberately draws a bad column, so the server answers 400 and the
+ // browser logs one "Failed to load resource" console error per response. Those are expected; anything else is not.
+ let expectedBadColumn400s = 0;
+ page.on('response', r => {
+ if (r.status() === 400 && r.request().headers()['x-test-header']) expectedBadColumn400s++;
+ });
page.on('dialog', async d => { dialogs.push(d.message()); await d.dismiss(); });
let report;
@@ -176,7 +182,12 @@ const PROBE = async function () {
dumpDiagnostics(diag);
throw error;
}
- report.jsFailures = failures.slice();
+ const is400 = f => /Failed to load resource.*\b400\b/.test(f);
+ const unexpected = [];
+ const expected = [];
+ for (const f of failures) (is400(f) && expected.length < expectedBadColumn400s ? expected : unexpected).push(f);
+ report.jsFailures = unexpected;
+ report.expectedBadColumnFailures = expected;
report.diagnostics = diag.slice();
report.dialogs = dialogs.slice();
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/arg/RestOpArg.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/arg/RestOpArg.java
index 007bb7c6ff..675e4febb1 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/arg/RestOpArg.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/arg/RestOpArg.java
@@ -26,7 +26,8 @@ import org.apache.juneau.rest.server.*;
*
* <h5 class='figure'>Example:</h5>
* <p class='bjava'>
- * <jc>// A simple parameter resolver that resolves TimeZone parameters.</jc>
+ * <jc>// A simple parameter resolver that resolves TimeZone parameters from the Time-Zone header.</jc>
+ * <jc>// (TimeZone parameters are already resolved out-of-the-box; this is purely illustrative.)</jc>
* <jk>public class</jk> TimeZoneArg <jk>implements</jk> RestOpArg {
*
* <jc>// Implementers must provide a static creator method that returns a RestParam if it's
@@ -41,7 +42,7 @@ import org.apache.juneau.rest.server.*;
*
* <ja>@Override</ja>
* <jk>public</jk> Object resolve(RestOpSession <jv>opSession</jv>) <jk>throws</jk> Exception {
- * <jk>return</jk> <jv>opSession</jv>.getRequest().getHeaders().getTimeZone();
+ * <jk>return</jk> <jv>opSession</jv>.getRequest().getTimeZone().<jsm>orElse</jsm>(<jk>null</jk>);
* }
* }
* </p>
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/httppart/RequestContent.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/httppart/RequestContent.java
index 9db09d34e9..0f20990fa1 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/httppart/RequestContent.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/httppart/RequestContent.java
@@ -594,6 +594,7 @@ public class RequestContent {
try (Closeable in = session.isReaderParser() ? getUnbufferedReader() : getInputStream()) {
var o = session.read(in, cm);
+ drain(in);
if (nn(schema))
schema.validateOutput(o);
return o;
@@ -616,6 +617,33 @@ public class RequestContent {
ct.isPresent() ? ct.get().asMediaType().orElse(null) : "not-specified", Json5.of(req.getOpContext().getParsers().getSupportedMediaTypes()));
}
+ // Reads and discards whatever remains of the request entity after a parser has returned.
+ //
+ // A parser stops as soon as the top-level value is complete, leaving the end of the entity unread: for a
+ // Transfer-Encoding: chunked request, at least the zero-length terminating chunk whenever the client flushes it
+ // separately from the content. If that is still unread when the response completes, the container finds
+ // unconsumed request content and aborts the connection. By then the response is usually committed, so no
+ // Connection: close can be added, and a client reusing the pooled keep-alive connection fails its next request
+ // with an immediate EOF. Draining here (blocking until end-of-stream) keeps the connection reusable.
+ //
+ // Best effort: the stream is still bounded by @Rest(maxInput), and any failure (limit exceeded, client gone)
+ // just falls back to the container's own unconsumed-content handling. A zero-byte read also stops the loop,
+ // since BoundedServletInputStream returns 0 rather than -1 once its limit is reached.
+ // Q: Can we move this to IOUtils?
+ private static void drain(Closeable in) {
+ try {
+ if (in instanceof Reader r) {
+ var buf = new char[256];
+ while (r.read(buf) > 0) { /* discard */ }
+ } else if (in instanceof InputStream is) {
+ var buf = new byte[256];
+ while (is.read(buf) > 0) { /* discard */ }
+ }
+ } catch (IOException ignored) {
+ // best-effort; the parsed value is already complete
+ }
+ }
+
/**
* Same as {@link #getReader()}, but doesn't encapsulate the result in a {@link BufferedReader};
*