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 f6d3ae9627 Add next-generation REST client streaming-cursor parity and 
content-negotiation core.
f6d3ae9627 is described below

commit f6d3ae9627643c1330e0c9ce81b9f0b7927b3a80
Author: James Bognar <[email protected]>
AuthorDate: Mon Jun 15 12:22:07 2026 -0400

    Add next-generation REST client streaming-cursor parity and 
content-negotiation core.
    
    Brings the transport-agnostic next-gen REST client to parity with the 
classic
    client on token/record-streaming cursors, and adds the serializer/parser
    negotiation it previously lacked:
    
    - Streaming parity: response-side cursor binding (ResponseBody.asCursor),
      request-side true-streaming bodies (RestRequest.streamBodyEntity + new
      RecordStreamBody HttpBody), and @Remote proxy cursor return-type / 
@Content
      parameter binding (RemoteClient).
    - Content negotiation: serializer/parser registry on RestClient
      (serializer(s)/parser(s) + defaultSerializer/defaultParser builder knobs 
and
      getDefaultSerializer/getMatchingParser/getDefaultAccept resolvers); 
RestRequest
      serializes unconverted POJOs to text or binary (byte[]) bodies and 
advertises a
      default Accept; ResponseBody.as supports negotiated and forced parsers.
    - Tests across juneau-rest-client and juneau-rest-mock (cursor binding,
      negotiation, RecordStreamBody) plus integration-test updates.
    
    Co-authored-by: Cursor <[email protected]>
---
 .../client/RemoteProxy_FeatureParity_Test.java     |   7 +-
 .../rest/client/RestClientFeatures_Test.java       |  35 +++-
 .../juneau/rest/client/RecordStreamBody.java       | 194 +++++++++++++++++++++
 .../apache/juneau/rest/client/ResponseBody.java    | 127 ++++++++++++++
 .../org/apache/juneau/rest/client/RestClient.java  | 158 +++++++++++++++++
 .../org/apache/juneau/rest/client/RestRequest.java |  74 +++++++-
 .../apache/juneau/rest/client/RestResponse.java    |  19 +-
 .../juneau/rest/client/remote/RemoteClient.java    |  46 ++++-
 .../juneau/rest/client/RecordStreamBody_Test.java  | 156 +++++++++++++++++
 .../rest/client/ResponseBody_Cursor_Test.java      | 107 ++++++++++++
 .../rest/client/RestClient_Negotiation_Test.java   | 122 +++++++++++++
 .../rest/mock/NextGenContentNegotiation_Test.java  | 193 ++++++++++++++++++++
 .../mock/RemoteCursorBinding_NextGen_Test.java     | 187 ++++++++++++++++++++
 13 files changed, 1405 insertions(+), 20 deletions(-)

diff --git 
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/RemoteProxy_FeatureParity_Test.java
 
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/RemoteProxy_FeatureParity_Test.java
index ee2ba4664d..2cbcb1a55d 100644
--- 
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/RemoteProxy_FeatureParity_Test.java
+++ 
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/RemoteProxy_FeatureParity_Test.java
@@ -16,6 +16,7 @@
  */
 package org.apache.juneau.rest.client;
 
+import static org.apache.juneau.commons.utils.IoUtils.*;
 import static org.junit.jupiter.api.Assertions.*;
 
 import java.io.*;
@@ -214,8 +215,10 @@ class RemoteProxy_FeatureParity_Test {
                }
 
                // Gap endpoints — return plain strings so the fixture needs no 
server-side serializer config.
-               @RestPost("/rest/beanContent") public String 
beanContent(@Content String body) { return body; }
-               @RestPost("/rest/listContent") public String 
listContent(@Content String body) { return body; }
+               // @Content POJO bodies arrive as serializer-negotiated JSON 
(Content-Type: application/json); read the raw
+               // stream via @Content Reader so the echo faithfully returns 
the posted bytes without server-side parsing.
+               @RestPost("/rest/beanContent") public String 
beanContent(@Content Reader body) throws IOException { return read(body); }
+               @RestPost("/rest/listContent") public String 
listContent(@Content Reader body) throws IOException { return read(body); }
                @RestPost("/rest/readerContent") public String 
readerContent(@Content String body) { return body; }
                @RestGet("/rest/bean")  public String bean() { return 
"{\"name\":\"na44\"}"; }
                @RestGet("/rest/list")  public String list() { return 
"[{\"name\":\"na44\"}]"; }
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 9348444722..39b540ea1b 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
@@ -758,15 +758,26 @@ class RestClientFeatures_Test {
        }
 
        @Test
-       void f07_body_noConverter_throws() throws Exception {
-               var transport = MockHttpTransport.of(200, "ok");
+       void f07_body_noConverter_fallsBackToDefaultSerializer() throws 
Exception {
+               var transport = MockHttpTransport.builder()
+                       .recordRequests()
+                       .fallback(req -> 
TransportResponse.builder().statusCode(200).build())
+                       .build();
+               // No converter handles String, so the POJO is serialized with 
the client's default (JSON) serializer.
                try (var client = RestClient.builder()
                                .transport(transport)
                                .rootUrl("http://x.com";)
                                .bodyConverters() // empty converter list
                                .build()) {
-                       assertThrows(IllegalArgumentException.class, () -> 
client.post("/").body("no-converter").run());
+                       try (var r = 
client.post("/").body("no-converter").run()) {
+                               assertEquals(200, r.getStatusCode());
+                       }
                }
+               var req = transport.getRecordedRequests().get(0);
+               var baos = new ByteArrayOutputStream();
+               req.getBody().writeTo(baos);
+               assertEquals("\"no-converter\"", 
baos.toString(StandardCharsets.UTF_8));
+               assertEquals("application/json", 
req.getFirstHeader("Content-Type").value());
        }
 
        @Test
@@ -795,16 +806,26 @@ class RestClientFeatures_Test {
 
        @Test
        void f09_bodyConverters_replacesDefaults() throws Exception {
-               var transport = MockHttpTransport.of(200, "ok");
-               // Replace all converters — InputStream no longer handled
+               var transport = MockHttpTransport.builder()
+                       .recordRequests()
+                       .fallback(req -> 
TransportResponse.builder().statusCode(200).build())
+                       .build();
+               // Replace all converters — InputStream is no longer 
converter-handled and falls back to the default serializer.
                try (var client = RestClient.builder()
                                .transport(transport)
                                .rootUrl("http://x.com";)
                                .bodyConverters() // no converters
                                .build()) {
-                       assertThrows(IllegalArgumentException.class,
-                               () -> client.post("/").body(new 
ByteArrayInputStream(new byte[0])).run());
+                       try (var r = client.post("/").body(new 
ByteArrayInputStream(new byte[0])).run()) {
+                               assertEquals(200, r.getStatusCode());
+                       }
                }
+               var req = transport.getRecordedRequests().get(0);
+               var baos = new ByteArrayOutputStream();
+               req.getBody().writeTo(baos);
+               // The JSON serializer streams the InputStream content 
directly; an empty stream yields an empty body.
+               assertEquals("", baos.toString(StandardCharsets.UTF_8));
+               assertEquals("application/json", 
req.getFirstHeader("Content-Type").value());
        }
 
        // 
=================================================================================================================
diff --git 
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RecordStreamBody.java
 
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RecordStreamBody.java
new file mode 100644
index 0000000000..d27a7b7f02
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/RecordStreamBody.java
@@ -0,0 +1,194 @@
+/*
+ * 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.AssertionUtils.*;
+import static org.apache.juneau.commons.utils.ThrowableUtils.*;
+
+import java.io.*;
+import java.util.function.*;
+
+import org.apache.juneau.http.*;
+import org.apache.juneau.marshall.json.*;
+import org.apache.juneau.marshall.serializer.*;
+import org.apache.juneau.marshall.stream.*;
+
+/**
+ * A streaming HTTP request body that emits its content through a 
token/record-streaming cursor.
+ *
+ * <p>
+ * Lets a caller pass a {@code Consumer<RecordWriter>} or {@code 
Consumer<TokenWriter>} as the body of a request.
+ * The consumer is invoked lazily during {@link #writeTo(OutputStream)} 
&mdash; the cursor writes directly to the
+ * live transport output stream, so arbitrarily large payloads are streamed to 
the wire without being buffered in
+ * memory first.
+ *
+ * <p>
+ * While the next-generation client negotiates a parser for inbound/response 
bodies (from the response
+ * {@code Content-Type}), the request-body OUTPUT format here is an explicit 
caller choice: the {@link Serializer}
+ * that backs the cursor is supplied directly (defaulting to {@link 
JsonSerializer#DEFAULT}).  The body's
+ * {@link #getContentType()} reflects that serializer's media type.
+ *
+ * <h5 class='section'>Repeatability:</h5>
+ * <p>
+ * Streaming bodies are <b>non-repeatable</b> by default ({@link 
#isRepeatable()} returns {@code false}) because the
+ * caller's consumer may be backed by a one-shot source.  A non-repeatable 
body fails fast (throws {@link IOException})
+ * if a resend is required (i.e. {@link #writeTo(OutputStream)} is invoked 
more than once).  Callers whose consumer can
+ * be replayed safely may opt in via {@link #repeatable()}.
+ *
+ * <h5 class='section'>Usage:</h5>
+ * <p class='bjava'>
+ *     <ja>@Remote</ja>
+ *     <jk>public interface</jk> MyApi {
+ *
+ *             <ja>@RemotePost</ja>(<js>"/bulk-upload"</js>)
+ *             <jk>void</jk> upload(<ja>@Content</ja> RecordStreamBody 
<jv>body</jv>);
+ *     }
+ *
+ *     <jc>// Caller</jc>
+ *     <jv>api</jv>.upload(RecordStreamBody.<jsm>record</jsm>(<jv>w</jv> -&gt; 
{
+ *             <jk>for</jk> (Bean <jv>b</jv> : <jv>source</jv>())
+ *                     <jv>w</jv>.write(<jv>b</jv>);
+ *     }));
+ * </p>
+ *
+ * <p>
+ * <b>Beta — API subject to change:</b> This type is part of the 
next-generation REST client and HTTP stack
+ * ({@code org.apache.juneau.marshall.ng.*}).
+ * It is not API-frozen: binary- and source-incompatible changes may appear in 
the <b>next major</b> Juneau release
+ * (and possibly earlier).
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ *     <li class='link'><a class="doclink" 
href="https://juneau.apache.org/docs/topics/NextGenRestClient";>juneau-ng REST 
client</a>
+ * </ul>
+ *
+ * @since 9.2.1
+ */
+public final class RecordStreamBody implements HttpBody {
+
+       /**
+        * Creates a body that streams via a {@link RecordWriter} (whole-value 
record cursor) using the default JSON serializer.
+        *
+        * @param consumer The callback that emits records. Must not be 
<jk>null</jk>.
+        * @return A new body. Never <jk>null</jk>.
+        */
+       public static RecordStreamBody record(Consumer<RecordWriter> consumer) {
+               return record(JsonSerializer.DEFAULT, consumer);
+       }
+
+       /**
+        * Creates a body that streams via a {@link RecordWriter} (whole-value 
record cursor) using the given serializer.
+        *
+        * @param serializer The serializer that opens the record cursor. Must 
not be <jk>null</jk> and must implement
+        *      {@link RecordWritable}.
+        * @param consumer The callback that emits records. Must not be 
<jk>null</jk>.
+        * @return A new body. Never <jk>null</jk>.
+        * @throws IllegalArgumentException If the serializer does not support 
the record-writer surface.
+        */
+       public static RecordStreamBody record(Serializer serializer, 
Consumer<RecordWriter> consumer) {
+               assertArgNotNull("serializer", serializer);
+               assertArgNotNull("consumer", consumer);
+               if (! (serializer instanceof RecordWritable))
+                       throw illegalArg("Serializer ''{0}'' does not support 
the record-writer surface.", serializer.getClass().getName());
+               return new RecordStreamBody(serializer, consumer, 
RecordWriter.class, false);
+       }
+
+       /**
+        * Creates a body that streams via a {@link TokenWriter} (fine-grained 
structural cursor) using the default JSON serializer.
+        *
+        * @param consumer The callback that emits structural events. Must not 
be <jk>null</jk>.
+        * @return A new body. Never <jk>null</jk>.
+        */
+       public static RecordStreamBody token(Consumer<TokenWriter> consumer) {
+               return token(JsonSerializer.DEFAULT, consumer);
+       }
+
+       /**
+        * Creates a body that streams via a {@link TokenWriter} (fine-grained 
structural cursor) using the given serializer.
+        *
+        * @param serializer The serializer that opens the token cursor. Must 
not be <jk>null</jk> and must implement
+        *      {@link TokenWritable}.
+        * @param consumer The callback that emits structural events. Must not 
be <jk>null</jk>.
+        * @return A new body. Never <jk>null</jk>.
+        * @throws IllegalArgumentException If the serializer does not support 
the token-writer surface.
+        */
+       public static RecordStreamBody token(Serializer serializer, 
Consumer<TokenWriter> consumer) {
+               assertArgNotNull("serializer", serializer);
+               assertArgNotNull("consumer", consumer);
+               if (! (serializer instanceof TokenWritable))
+                       throw illegalArg("Serializer ''{0}'' does not support 
the token-writer surface.", serializer.getClass().getName());
+               return new RecordStreamBody(serializer, consumer, 
TokenWriter.class, false);
+       }
+
+       private final Serializer serializer;
+       private final Consumer<?> consumer;
+       private final Class<?> writerKind;
+       private final boolean repeatable;
+       private boolean written;
+
+       private RecordStreamBody(Serializer serializer, Consumer<?> consumer, 
Class<?> writerKind, boolean repeatable) {
+               this.serializer = serializer;
+               this.consumer = consumer;
+               this.writerKind = writerKind;
+               this.repeatable = repeatable;
+       }
+
+       /**
+        * Returns a copy of this body flagged as repeatable.
+        *
+        * <p>
+        * Only opt in when the caller's consumer can be safely replayed (e.g. 
it iterates a re-readable source).
+        *
+        * @return A new repeatable body. Never <jk>null</jk>.
+        */
+       public RecordStreamBody repeatable() {
+               return new RecordStreamBody(serializer, consumer, writerKind, 
true);
+       }
+
+       @Override /* HttpBody */
+       public String getContentType() {
+               var mt = serializer.getResponseContentType();
+               return mt == null ? null : mt.toString();
+       }
+
+       @Override /* HttpBody */
+       public long getContentLength() {
+               return -1;
+       }
+
+       @Override /* HttpBody */
+       public boolean isRepeatable() {
+               return repeatable;
+       }
+
+       @Override /* HttpBody */
+       @SuppressWarnings({
+               "unchecked", // Consumer is paired with writerKind at 
construction; the cast matches the opened cursor type.
+               "resource"   // The cursor wraps the caller-owned 'out'; its 
close() flushes but does not close 'out'.
+       })
+       public void writeTo(OutputStream out) throws IOException {
+               assertArgNotNull("out", out);
+               if (written && ! repeatable)
+                       throw new IOException("Non-repeatable streaming body 
cannot be resent.  Mark the body repeatable() if its producer can be 
replayed.");
+               written = true;
+               try (var w = (writerKind == TokenWriter.class)
+                               ? ((TokenWritable) 
serializer).serializeTokens(out)
+                               : ((RecordWritable) 
serializer).serializeRecords(out)) {
+                       ((Consumer<RecordWriter>) consumer).accept(w);
+                       w.flush();
+               }
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/ResponseBody.java
 
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/ResponseBody.java
index 13cfc2c5d8..8256852f5b 100644
--- 
a/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/ResponseBody.java
+++ 
b/juneau-rest/juneau-rest-client/src/main/java/org/apache/juneau/rest/client/ResponseBody.java
@@ -16,9 +16,14 @@
  */
 package org.apache.juneau.rest.client;
 
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+
 import java.io.*;
 import java.nio.charset.*;
 
+import org.apache.juneau.marshall.parser.*;
+import org.apache.juneau.marshall.stream.*;
+
 /**
  * A fluent accessor for an HTTP response body returned by {@link 
RestResponse}.
  *
@@ -116,4 +121,126 @@ public final class ResponseBody {
        public byte[] readAllBytes() throws IOException {
                return asBytes();
        }
+
+       /**
+        * Opens a token/record-streaming cursor over the response body using 
the parser negotiated from the response
+        * {@code Content-Type}.
+        *
+        * <p>
+        * Equivalent to {@link #asCursor(Parser, Class) 
asCursor(negotiatedParser, type)} where the parser is selected
+        * from the response {@code Content-Type} header (defaulting to JSON 
when no match is found); use
+        * {@link #asCursor(Parser, Class)} to read with an explicit parser.
+        *
+        * @param <T> The cursor type ({@link RecordReader}, {@link 
TokenReader}, or a concrete subtype).
+        * @param type The declared cursor type. Must not be <jk>null</jk>.
+        * @return A cursor over the live response body. Never <jk>null</jk>.
+        * @throws IOException If the body is missing, the parser does not 
support the requested cursor surface, or the
+        *      produced cursor is not assignable to {@code type}.
+        */
+       public <T> T asCursor(Class<T> type) throws IOException {
+               return asCursor(negotiatedParser(), type);
+       }
+
+       /**
+        * Parses the response body to {@code type} using the parser negotiated 
from the response {@code Content-Type}
+        * (defaulting to JSON when the header is absent, or the client has no 
matching/configured parser).
+        *
+        * <p>
+        * Use {@link #as(Parser, Class)} to force a specific parser, bypassing 
content negotiation.
+        *
+        * @param <T> The type to parse to.
+        * @param type The type to parse to. Must not be <jk>null</jk>.
+        * @return The parsed body, or <jk>null</jk> if the response has no 
body.
+        * @throws IOException If an I/O error occurs reading the body or the 
body could not be parsed.
+        */
+       public <T> T as(Class<T> type) throws IOException {
+               return as(negotiatedParser(), type);
+       }
+
+       /**
+        * Parses the response body to {@code type} using the given parser.
+        *
+        * <p>
+        * This forces the supplied parser, bypassing the {@code Content-Type} 
negotiation performed by {@link #as(Class)}.
+        *
+        * <p>
+        * A parse failure is surfaced strictly as an {@link IOException} 
wrapping the underlying {@link ParseException};
+        * the malformed body is never returned to the caller.
+        *
+        * @param <T> The type to parse to.
+        * @param parser The parser to use. Must not be <jk>null</jk>.
+        * @param type The type to parse to. Must not be <jk>null</jk>.
+        * @return The parsed body, or <jk>null</jk> if the response has no 
body.
+        * @throws IOException If an I/O error occurs reading the body or the 
body could not be parsed.
+        */
+       public <T> T as(Parser parser, Class<T> type) throws IOException {
+               assertArgNotNull("parser", parser);
+               assertArgNotNull("type", type);
+               var body = response.getBodyAsString();
+               if (body == null)
+                       return null;
+               try {
+                       return parser.parse(body, type);
+               } catch (ParseException e) {
+                       throw new IOException(e);
+               }
+       }
+
+       private Parser negotiatedParser() {
+               var h = response.getFirstHeader("Content-Type");
+               return response.getClient().getMatchingParser(h == null ? null 
: h.value());
+       }
+
+       /**
+        * Opens a token/record-streaming cursor over the response body using 
the given parser.
+        *
+        * <p>
+        * This forces the supplied parser, bypassing the {@code Content-Type} 
negotiation performed by
+        * {@link #asCursor(Class)}.
+        *
+        * <p>
+        * The cursor reads directly from the live response stream &mdash; the 
body is not buffered into memory.  The
+        * caller owns the returned cursor and the parent {@link RestResponse}; 
close them when done.
+        *
+        * <p>
+        * When {@code type} is (or extends) {@link TokenReader} the parser 
must implement {@link TokenReadable};
+        * otherwise it must implement {@link RecordReadable}.
+        *
+        * @param <T> The cursor type ({@link RecordReader}, {@link 
TokenReader}, or a concrete subtype).
+        * @param parser The parser that opens the cursor. Must not be 
<jk>null</jk>.
+        * @param type The declared cursor type. Must not be <jk>null</jk>.
+        * @return A cursor over the live response body. Never <jk>null</jk>.
+        * @throws IOException If the body is missing, the parser does not 
support the requested cursor surface, or the
+        *      produced cursor is not assignable to {@code type}.
+        */
+       @SuppressWarnings({
+               "unchecked", // The produced cursor is verified assignable to 
'type' before the cast.
+               "resource"   // The cursor reads from the borrowed response 
stream; the caller closes the cursor / RestResponse.
+       })
+       public <T> T asCursor(Parser parser, Class<T> type) throws IOException {
+               assertArgNotNull("parser", parser);
+               assertArgNotNull("type", type);
+
+               var isToken = TokenReader.class.isAssignableFrom(type);
+               var supported = isToken ? parser instanceof TokenReadable : 
parser instanceof RecordReadable;
+               if (! supported)
+                       throw new IOException("Parser '" + 
parser.getClass().getName() + "' does not support the "
+                               + (isToken ? "token-reader" : "record-reader") 
+ " surface.");
+
+               var stream = response.getBodyStream();
+               if (stream == null)
+                       throw new IOException("Response has no body to open a 
cursor over.");
+
+               Object input = parser.isReaderParser() ? new 
InputStreamReader(stream, StandardCharsets.UTF_8) : stream;
+               var cursor = isToken
+                       ? ((TokenReadable) parser).parseTokens(input)
+                       : ((RecordReadable) parser).parseRecords(input);
+
+               if (! type.isInstance(cursor))
+                       throw new IOException("Parser '" + 
parser.getClass().getName() + "' produced cursor type '"
+                               + (cursor == null ? "null" : 
cursor.getClass().getName())
+                               + "' which is not assignable to the declared 
type '" + type.getName() + "'.");
+
+               return (T) cursor;
+       }
 }
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 8aa7ef9e10..03081c59b8 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
@@ -22,12 +22,17 @@ import static 
org.apache.juneau.commons.utils.CollectionUtils.*;
 import java.io.*;
 import java.util.*;
 import java.util.function.*;
+import java.util.stream.*;
 
+import org.apache.juneau.commons.http.*;
 import org.apache.juneau.http.*;
 import org.apache.juneau.http.entity.*;
 import org.apache.juneau.http.header.*;
 import org.apache.juneau.http.part.*;
 import org.apache.juneau.http.remote.*;
+import org.apache.juneau.marshall.json.*;
+import org.apache.juneau.marshall.parser.*;
+import org.apache.juneau.marshall.serializer.*;
 import org.apache.juneau.rest.client.remote.*;
 
 /**
@@ -86,6 +91,10 @@ public final class RestClient implements Closeable {
        final List<RestCallInterceptor> interceptors;
        final RestLogger logger;
        final List<BodyConverter<?>> bodyConverters;
+       final SerializerSet serializers;
+       final ParserSet parsers;
+       final Serializer defaultSerializer;
+       final Parser defaultParser;
 
        private RestClient(Builder builder) {
                this.transport = assertArgNotNull("transport",
@@ -96,6 +105,10 @@ public final class RestClient implements Closeable {
                this.interceptors = List.copyOf(builder.interceptors);
                this.logger = builder.logger;
                this.bodyConverters = List.copyOf(builder.bodyConverters);
+               this.serializers = builder.serializers;
+               this.parsers = builder.parsers;
+               this.defaultSerializer = builder.defaultSerializer;
+               this.defaultParser = builder.defaultParser;
        }
 
        private static HttpTransport discoverTransport() {
@@ -204,6 +217,75 @@ public final class RestClient implements Closeable {
                return transport;
        }
 
+       /**
+        * Returns the serializer used for outbound bodies when the format is 
not otherwise discernable.
+        *
+        * <p>
+        * <b>Beta — API subject to change:</b> This type is part of the 
next-generation REST client and HTTP stack
+        * ({@code org.apache.juneau.marshall.ng.*}).
+        * It is not API-frozen: binary- and source-incompatible changes may 
appear in the <b>next major</b> Juneau release
+        * (and possibly earlier).
+        *
+        * @return The default serializer. Never <jk>null</jk>.
+        */
+       public Serializer getDefaultSerializer() {
+               if (defaultSerializer != null)
+                       return defaultSerializer;
+               if (serializers != null && ! serializers.isEmpty())
+                       return serializers.getSerializers().get(0);
+               return JsonSerializer.DEFAULT;
+       }
+
+       /**
+        * Returns the parser matching the given response {@code Content-Type}, 
falling back to the default/JSON.
+        *
+        * <p>
+        * <b>Beta — API subject to change:</b> This type is part of the 
next-generation REST client and HTTP stack
+        * ({@code org.apache.juneau.marshall.ng.*}).
+        * It is not API-frozen: binary- and source-incompatible changes may 
appear in the <b>next major</b> Juneau release
+        * (and possibly earlier).
+        *
+        * @param contentType The response {@code Content-Type} header value. 
May be <jk>null</jk>.
+        * @return The matching parser. Never <jk>null</jk>.
+        */
+       public Parser getMatchingParser(String contentType) {
+               if (parsers != null && contentType != null) {
+                       var p = parsers.getParser(contentType);
+                       if (p != null)
+                               return p;
+               }
+               if (defaultParser != null)
+                       return defaultParser;
+               if (parsers != null && ! parsers.isEmpty())
+                       return parsers.getParsers().get(0);
+               return JsonParser.DEFAULT;
+       }
+
+       /**
+        * Returns the default {@code Accept} header value advertising what 
this client can read.
+        *
+        * <p>
+        * <b>Beta — API subject to change:</b> This type is part of the 
next-generation REST client and HTTP stack
+        * ({@code org.apache.juneau.marshall.ng.*}).
+        * It is not API-frozen: binary- and source-incompatible changes may 
appear in the <b>next major</b> Juneau release
+        * (and possibly earlier).
+        *
+        * @return The default {@code Accept} header value. Never <jk>null</jk>.
+        */
+       public String getDefaultAccept() {
+               if (parsers != null) {
+                       var mts = parsers.getSupportedMediaTypes();
+                       if (! mts.isEmpty())
+                               return 
mts.stream().map(MediaType::toString).collect(Collectors.joining(", "));
+               }
+               if (defaultParser != null) {
+                       var mts = defaultParser.getMediaTypes();
+                       if (! mts.isEmpty())
+                               return 
mts.stream().map(MediaType::toString).collect(Collectors.joining(", "));
+               }
+               return "application/json";
+       }
+
        /**
         * Creates a Java proxy for the given {@link Remote}-annotated 
interface.
         *
@@ -250,6 +332,12 @@ public final class RestClient implements Closeable {
                final List<RestCallInterceptor> interceptors = new 
ArrayList<>();
                RestLogger logger;
                List<BodyConverter<?>> bodyConverters = new 
ArrayList<>(DEFAULT_BODY_CONVERTERS);
+               SerializerSet serializers;
+               ParserSet parsers;
+               Serializer defaultSerializer;
+               Parser defaultParser;
+               final List<Serializer> serializerList = new ArrayList<>();
+               final List<Parser> parserList = new ArrayList<>();
 
                private Builder() {}
 
@@ -390,12 +478,82 @@ public final class RestClient implements Closeable {
                        return this;
                }
 
+               /**
+                * Sets the serializer registry used for outbound bodies.
+                *
+                * @param value The serializer set. May be <jk>null</jk>.
+                * @return This object.
+                */
+               public Builder serializers(SerializerSet value) {
+                       serializers = value;
+                       return this;
+               }
+
+               /**
+                * Sets the parser registry used for inbound bodies.
+                *
+                * @param value The parser set. May be <jk>null</jk>.
+                * @return This object.
+                */
+               public Builder parsers(ParserSet value) {
+                       parsers = value;
+                       return this;
+               }
+
+               /**
+                * Appends serializers (built into a {@link SerializerSet} at 
build time if no set was supplied).
+                *
+                * @param value The serializers to append. Must not be 
<jk>null</jk>.
+                * @return This object.
+                */
+               public Builder serializer(Serializer... value) {
+                       serializerList.addAll(Arrays.asList(value));
+                       return this;
+               }
+
+               /**
+                * Appends parsers (built into a {@link ParserSet} at build 
time if no set was supplied).
+                *
+                * @param value The parsers to append. Must not be 
<jk>null</jk>.
+                * @return This object.
+                */
+               public Builder parser(Parser... value) {
+                       parserList.addAll(Arrays.asList(value));
+                       return this;
+               }
+
+               /**
+                * Designates the default serializer used when the outbound 
format is not otherwise discernable.
+                *
+                * @param value The default serializer. May be <jk>null</jk>.
+                * @return This object.
+                */
+               public Builder defaultSerializer(Serializer value) {
+                       defaultSerializer = value;
+                       return this;
+               }
+
+               /**
+                * Designates the default parser used when the response {@code 
Content-Type} is absent or unmatched.
+                *
+                * @param value The default parser. May be <jk>null</jk>.
+                * @return This object.
+                */
+               public Builder defaultParser(Parser value) {
+                       defaultParser = value;
+                       return this;
+               }
+
                /**
                 * Builds and returns the {@link RestClient}.
                 *
                 * @return A new instance. Never <jk>null</jk>.
                 */
                public RestClient build() {
+                       if (serializers == null && ! serializerList.isEmpty())
+                               serializers = 
SerializerSet.create().add(serializerList.toArray(new Serializer[0])).build();
+                       if (parsers == null && ! parserList.isEmpty())
+                               parsers = 
ParserSet.create().add(parserList.toArray(new Parser[0])).build();
                        return new RestClient(this);
                }
        }
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 400f6b8d36..82d444d990 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
@@ -29,6 +29,7 @@ import org.apache.juneau.http.*;
 import org.apache.juneau.http.entity.*;
 import org.apache.juneau.http.header.*;
 import org.apache.juneau.http.part.*;
+import org.apache.juneau.marshall.serializer.*;
 
 /**
  * Fluent builder for a single HTTP request.
@@ -221,16 +222,23 @@ public final class RestRequest {
        }
 
        /**
-        * Sets the request body from an arbitrary Java object, converting it 
via the client's body converter chain.
+        * Sets the request body from an arbitrary Java object.
         *
         * <p>
-        * The default converters handle: {@link HttpBody} (passthrough), 
{@link InputStream},
-        * {@code byte[]}, and {@link java.io.File}. Custom converters can be 
registered on the builder.
+        * The object is first run through the client's body converter chain. 
The default converters handle:
+        * {@link HttpBody} (passthrough), {@link InputStream}, {@code byte[]}, 
and {@link java.io.File}.
+        * Custom converters can be registered on the builder.
+        *
+        * <p>
+        * If no converter matches, the object is serialized with the client's 
default serializer
+        * ({@link RestClient#getDefaultSerializer()}) and sent as a string 
body using the serializer's content type.
         *
         * @param value The body object. May be <jk>null</jk> to clear the body.
         * @return This object.
-        * @throws IOException If a converter fails.
-        * @throws IllegalArgumentException If no converter can handle the 
given type.
+        * @throws IOException If a converter fails or the default serializer 
fails.
+        * @throws IllegalArgumentException If the default serializer produces 
output that is neither text nor {@code byte[]}.
+        *      Binary ({@code byte[]}) serializer output is sent as a binary 
body using the serializer's media type
+        *      (falling back to {@code application/octet-stream} when the 
media type is <jk>null</jk>).
         */
        public RestRequest body(Object value) throws IOException {
                if (value == null) {
@@ -245,7 +253,50 @@ public final class RestRequest {
                                return this;
                        }
                }
-               throw new IllegalArgumentException("No BodyConverter found for 
type: " + cn(value));
+               // No converter matched: serialize the POJO with the client's 
default serializer.
+               var s = client.getDefaultSerializer();
+               Object out;
+               try {
+                       out = s.serialize(value);
+               } catch (SerializeException e) {
+                       throw new IOException(e);
+               }
+               var mt = s.getResponseContentType();
+               if (out instanceof byte[]) {
+                       body = ByteArrayBody.of((byte[]) out, mt != null ? 
mt.toString() : "application/octet-stream");
+                       convertedBody = null;
+                       return this;
+               }
+               if (! (out instanceof CharSequence))
+                       throw new IllegalArgumentException("Default serializer 
'" + cn(s) + "' produced output that is neither text nor byte[] for type: " + 
cn(value));
+               body = StringBody.of(out.toString(), mt != null ? mt.toString() 
: "application/json");
+               convertedBody = null;
+               return this;
+       }
+
+       /**
+        * Sets the request body to a token/record-streaming cursor body.
+        *
+        * <p>
+        * Convenience for {@link #body(HttpBody) body(streamBody)} that 
documents streaming intent and avoids a cast.
+        * The {@link RecordStreamBody} writes directly to the transport output 
stream during {@link #run()}, so large
+        * payloads are streamed to the wire without being buffered in memory.
+        *
+        * <h5 class='section'>Example:</h5>
+        * <p class='bjava'>
+        *      <jv>client</jv>.post(<js>"/bulk-upload"</js>)
+        *              
.streamBodyEntity(RecordStreamBody.<jsm>record</jsm>(<jv>w</jv> -&gt; {
+        *                      <jk>for</jk> (Bean <jv>b</jv> : 
<jv>source</jv>())
+        *                              <jv>w</jv>.write(<jv>b</jv>);
+        *              }))
+        *              .run();
+        * </p>
+        *
+        * @param value The streaming body. Must not be <jk>null</jk>.
+        * @return This object.
+        */
+       public RestRequest streamBodyEntity(RecordStreamBody value) {
+               return body((HttpBody) value);
        }
 
        /**
@@ -335,7 +386,7 @@ public final class RestRequest {
 
                        var transportRequest = buildTransportRequest();
                        var transportResponse = 
client.transport.execute(transportRequest);
-                       response = new RestResponse(transportResponse);
+                       response = new RestResponse(transportResponse, client);
 
                        for (var interceptor : client.interceptors)
                                interceptor.onConnect(this, response);
@@ -385,6 +436,15 @@ public final class RestRequest {
                                builder.header(h.getName(), v);
                }
 
+               if (client.parsers != null || client.defaultParser != null) {
+                       var hasAccept = headers.stream().anyMatch(h -> 
"Accept".equalsIgnoreCase(h.getName()));
+                       if (! hasAccept) {
+                               var accept = client.getDefaultAccept();
+                               if (accept != null)
+                                       builder.header("Accept", accept);
+                       }
+               }
+
                // Pre-converted body from body(Object) takes priority
                if (convertedBody != null) {
                        if (convertedBody.getContentType() != null)
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 ef15e66e27..31f2e45b7d 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
@@ -43,9 +43,26 @@ import org.apache.juneau.rest.client.assertion.*;
 public final class RestResponse implements Closeable {
 
        private final TransportResponse response;
+       private final RestClient client;
 
-       RestResponse(TransportResponse response) {
+       RestResponse(TransportResponse response, RestClient client) {
                this.response = response;
+               this.client = client;
+       }
+
+       /**
+        * Returns the client that produced this response.
+        *
+        * <p>
+        * <b>Beta — API subject to change:</b> This type is part of the 
next-generation REST client and HTTP stack
+        * ({@code org.apache.juneau.marshall.ng.*}).
+        * It is not API-frozen: binary- and source-incompatible changes may 
appear in the <b>next major</b> Juneau release
+        * (and possibly earlier).
+        *
+        * @return The client that produced this response. Never <jk>null</jk>.
+        */
+       public RestClient getClient() {
+               return client;
        }
 
        /**
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 42d129a53a..fe4037bff0 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
@@ -36,9 +36,9 @@ import org.apache.juneau.http.remote.*;
 import org.apache.juneau.http.response.*;
 import org.apache.juneau.marshall.*;
 import org.apache.juneau.marshall.httppart.*;
-import org.apache.juneau.marshall.json.*;
 import org.apache.juneau.marshall.oapi.*;
 import org.apache.juneau.marshall.parser.*;
+import org.apache.juneau.marshall.stream.*;
 import org.apache.juneau.rest.client.*;
 
 /**
@@ -440,6 +440,9 @@ public final class RemoteClient {
 
                /** Runs the request and materializes a single (unwrapped) body 
value. */
                private Object processBodyValue(RestRequest req, Class<?> 
returnType, Type genericReturnType, Method method) throws Exception {
+                       // Cursor return types stream over the live response 
body; the caller owns the cursor (and the response it holds).
+                       if (RecordReader.class.isAssignableFrom(returnType))
+                               return processCursor(req, returnType, method);
                        try (var resp = req.run()) { // HTT - exception during 
close() branch
                                throwIfError(resp, method);
                                if (returnType == void.class || returnType == 
Void.class)
@@ -530,15 +533,52 @@ public final class RemoteClient {
                                return req.bodyString(s);
                        if (arg instanceof Reader r)
                                return req.bodyString(readReader(r));
-                       return 
req.bodyString(JsonSerializer.DEFAULT.serialize(arg));
+                       return req.body(arg);
                }
 
+               /**
+                * Runs the request and opens a token/record-streaming cursor 
over the (live) response body.
+                *
+                * <p>
+                * Unlike the buffered body paths, the response is <b>not</b> 
closed here on success: the returned cursor reads
+                * directly from the response stream and the caller owns it 
(close the cursor when done).  On any failure before
+                * the cursor is handed back, the response is closed.
+                */
+               @SuppressWarnings({
+                       "resource" // On success the response is owned by the 
returned cursor (caller closes it); on failure it is closed in the finally 
block.
+               })
+               private Object processCursor(RestRequest req, Class<?> 
returnType, Method method) throws Exception {
+                       var resp = req.run();
+                       var ok = false;
+                       try {
+                               throwIfError(resp, method);
+                               var cursor = resp.body().asCursor(returnType);
+                               ok = true;
+                               return cursor;
+                       } finally {
+                               if (! ok)
+                                       resp.close();
+                       }
+               }
+
+               /**
+                * Parses a response body for an {@code @Remote}-proxy method 
return value.
+                *
+                * <p>
+                * Unlike {@link 
org.apache.juneau.rest.client.ResponseBody#as(org.apache.juneau.marshall.parser.Parser,
 Class)},
+                * which always surfaces a parse failure as an {@code 
IOException}, this method applies a deliberate proxy-only
+                * leniency: when the declared return type is {@code Object}, a 
{@link ParseException} is swallowed and the raw
+                * response body string is returned as-is. For any other 
declared return type the {@code ParseException}
+                * propagates unchanged.
+                */
                private static Object parseBody(RestResponse resp, Type 
returnType) throws Exception {
                        var body = resp.getBodyAsString();
                        if (body == null)
                                return null;
+                       var h = resp.getFirstHeader("Content-Type");
+                       var parser = resp.getClient().getMatchingParser(h == 
null ? null : h.value());
                        try {
-                               return JsonParser.DEFAULT.parse(body, 
returnType);
+                               return parser.parse(body, returnType);
                        } catch (ParseException e) {
                                if (returnType == Object.class)
                                        return body;
diff --git 
a/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/RecordStreamBody_Test.java
 
b/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/RecordStreamBody_Test.java
new file mode 100644
index 0000000000..e47b44ec58
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/RecordStreamBody_Test.java
@@ -0,0 +1,156 @@
+/*
+ * 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 org.apache.juneau.marshall.json.*;
+import org.apache.juneau.marshall.plaintext.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Unit tests for the next-generation {@link RecordStreamBody} streaming 
request body.
+ */
+class RecordStreamBody_Test {
+
+       public static class Bean {
+               public String name;
+               public int age;
+               public Bean() {}
+               public Bean(String name, int age) { this.name = name; this.age 
= age; }
+       }
+
+       private static String writeToString(RecordStreamBody body) throws 
IOException {
+               var baos = new ByteArrayOutputStream();
+               body.writeTo(baos);
+               return new String(baos.toByteArray(), StandardCharsets.UTF_8);
+       }
+
+       // 
==========================================================================
+       // a — record(...) (whole-value record cursor)
+       // 
==========================================================================
+
+       @Test
+       @SuppressWarnings({
+               "resource" // Fluent writer is caller-owned; nothing new to 
close.
+       })
+       void a01_record_defaultJson() throws Exception {
+               var body = RecordStreamBody.record(w -> {
+                       try {
+                               w.write(new Bean("dave", 99));
+                       } catch (IOException e) {
+                               throw new UncheckedIOException(e);
+                       }
+               });
+               // The record cursor's POJO walk emits bean properties in 
alphabetical order.
+               assertEquals("{\"age\":99,\"name\":\"dave\"}", 
writeToString(body));
+       }
+
+       @Test
+       void a02_record_metadata() {
+               var body = RecordStreamBody.record(w -> {});
+               assertEquals("application/json", body.getContentType());
+               assertEquals(-1, body.getContentLength());
+               assertFalse(body.isRepeatable());
+       }
+
+       @Test
+       @SuppressWarnings({
+               "resource" // Fluent writer is caller-owned; nothing new to 
close.
+       })
+       void a03_record_explicitSerializer() throws Exception {
+               var body = RecordStreamBody.record(JsonSerializer.DEFAULT, w -> 
{
+                       try {
+                               w.write(new Bean("amy", 7));
+                       } catch (IOException e) {
+                               throw new UncheckedIOException(e);
+                       }
+               });
+               assertEquals("{\"age\":7,\"name\":\"amy\"}", 
writeToString(body));
+       }
+
+       // 
==========================================================================
+       // b — token(...) (fine-grained structural cursor)
+       // 
==========================================================================
+
+       @Test
+       @SuppressWarnings({
+               "resource" // Fluent writer is caller-owned; nothing new to 
close.
+       })
+       void b01_token_defaultJson() throws Exception {
+               var body = RecordStreamBody.token(w -> {
+                       try {
+                               w.startObject();
+                               w.fieldName("name");
+                               w.string("eve");
+                               w.fieldName("age");
+                               w.number(45);
+                               w.endObject();
+                       } catch (IOException e) {
+                               throw new UncheckedIOException(e);
+                       }
+               });
+               assertEquals("{\"name\":\"eve\",\"age\":45}", 
writeToString(body));
+       }
+
+       // 
==========================================================================
+       // c — repeatability + capability mismatch
+       // 
==========================================================================
+
+       @Test
+       void c01_repeatable_optIn() {
+               var body = RecordStreamBody.record(w -> {}).repeatable();
+               assertTrue(body.isRepeatable());
+       }
+
+       @Test
+       void c02_repeatable_isReusable() throws Exception {
+               var body = RecordStreamBody.record(w -> {
+                       try {
+                               w.write(new Bean("x", 1));
+                       } catch (IOException e) {
+                               throw new UncheckedIOException(e);
+                       }
+               }).repeatable();
+               // A repeatable body must produce identical output across 
multiple writeTo invocations.
+               assertEquals(writeToString(body), writeToString(body));
+       }
+
+       @Test
+       void c03_nonRepeatable_failsFastOnResend() throws Exception {
+               var body = RecordStreamBody.record(w -> {
+                       try {
+                               w.write(new Bean("x", 1));
+                       } catch (IOException e) {
+                               throw new UncheckedIOException(e);
+                       }
+               });
+               writeToString(body); // First write succeeds.
+               var baos = new ByteArrayOutputStream();
+               // A non-repeatable body must fail fast if a resend (second 
write) is required.
+               assertThrows(IOException.class, () -> body.writeTo(baos));
+       }
+
+       @Test
+       void c04_tokenSurfaceUnsupported_failsFastAtConstruction() {
+               // PlainTextSerializer is a writer-serializer but does not 
implement the token-writer surface.
+               assertThrows(IllegalArgumentException.class, () -> 
RecordStreamBody.token(PlainTextSerializer.DEFAULT, w -> {}));
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/ResponseBody_Cursor_Test.java
 
b/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/ResponseBody_Cursor_Test.java
new file mode 100644
index 0000000000..41631d37ba
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/ResponseBody_Cursor_Test.java
@@ -0,0 +1,107 @@
+/*
+ * 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 org.apache.juneau.marshall.json.*;
+import org.apache.juneau.marshall.jsonl.*;
+import org.apache.juneau.marshall.stream.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Unit tests for {@link ResponseBody#asCursor(Class)} / {@link 
ResponseBody#asCursor(org.apache.juneau.marshall.parser.Parser, Class)}.
+ */
+class ResponseBody_Cursor_Test {
+
+       public static class Bean {
+               public String name;
+               public int age;
+               public Bean() {}
+       }
+
+       private static RestResponse response(String json) {
+               var tr = TransportResponse.builder()
+                       .statusCode(200)
+                       .header("Content-Type", "application/json")
+                       .body(new 
ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8)))
+                       .build();
+               return new RestResponse(tr, RestClient.create());
+       }
+
+       // 
==========================================================================
+       // a — record / token cursors
+       // 
==========================================================================
+
+       @Test
+       void a01_recordCursor_explicitParser() throws Exception {
+               try (var resp = response("{\"name\":\"alice\",\"age\":30}")) {
+                       try (RecordReader r = 
resp.body().asCursor(JsonParser.DEFAULT, RecordReader.class)) {
+                               var b = r.read(Bean.class);
+                               assertEquals("alice", b.name);
+                               assertEquals(30, b.age);
+                       }
+               }
+       }
+
+       @Test
+       void a02_recordCursor_defaultJsonParser() throws Exception {
+               try (var resp = response("{\"name\":\"bob\",\"age\":40}")) {
+                       try (RecordReader r = 
resp.body().asCursor(RecordReader.class)) {
+                               var b = r.read(Bean.class);
+                               assertEquals("bob", b.name);
+                               assertEquals(40, b.age);
+                       }
+               }
+       }
+
+       @Test
+       void a03_tokenCursor() throws Exception {
+               try (var resp = response("{\"name\":\"carol\",\"age\":50}")) {
+                       try (TokenReader r = 
resp.body().asCursor(TokenReader.class)) {
+                               var b = r.read(Bean.class);
+                               assertEquals("carol", b.name);
+                               assertEquals(50, b.age);
+                       }
+               }
+       }
+
+       @Test
+       void a04_concreteCursorType() throws Exception {
+               try (var resp = response("{\"name\":\"dan\",\"age\":60}")) {
+                       try (JsonTokenReader r = 
resp.body().asCursor(JsonTokenReader.class)) {
+                               var b = r.read(Bean.class);
+                               assertEquals("dan", b.name);
+                       }
+               }
+       }
+
+       // 
==========================================================================
+       // b — error paths
+       // 
==========================================================================
+
+       @Test
+       void b01_cursorTypeNotAssignable() throws Exception {
+               // JsonParser produces a JsonTokenReader, which is not 
assignable to JsonlTokenReader.
+               try (var resp = response("{\"name\":\"x\",\"age\":1}")) {
+                       assertThrows(IOException.class, () -> 
resp.body().asCursor(JsonParser.DEFAULT, JsonlTokenReader.class));
+               }
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/RestClient_Negotiation_Test.java
 
b/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/RestClient_Negotiation_Test.java
new file mode 100644
index 0000000000..7b895a3019
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-client/src/test/java/org/apache/juneau/rest/client/RestClient_Negotiation_Test.java
@@ -0,0 +1,122 @@
+/*
+ * 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.marshall.json.*;
+import org.apache.juneau.marshall.jsonl.*;
+import org.apache.juneau.marshall.parser.*;
+import org.apache.juneau.marshall.serializer.*;
+import org.junit.jupiter.api.*;
+
+class RestClient_Negotiation_Test {
+
+       @Test
+       void a01_unconfigured_defaultsToJson() throws Exception {
+               try (var c = RestClient.create()) {
+                       assertSame(JsonSerializer.DEFAULT, 
c.getDefaultSerializer());
+                       assertSame(JsonParser.DEFAULT, 
c.getMatchingParser(null));
+                       assertSame(JsonParser.DEFAULT, 
c.getMatchingParser("application/jsonl"));
+                       assertEquals("application/json", c.getDefaultAccept());
+               }
+       }
+
+       @Test
+       void a02_matchesByContentType() throws Exception {
+               var sset = SerializerSet.create().add(JsonSerializer.DEFAULT, 
JsonlSerializer.DEFAULT).build();
+               var pset = ParserSet.create().add(JsonParser.DEFAULT, 
JsonlParser.DEFAULT).build();
+               try (var c = 
RestClient.builder().serializers(sset).parsers(pset).build()) {
+                       assertInstanceOf(JsonlParser.class, 
c.getMatchingParser("application/jsonl"));
+                       assertInstanceOf(JsonParser.class, 
c.getMatchingParser("application/json"));
+                       assertSame(JsonSerializer.DEFAULT, 
c.getDefaultSerializer());
+                       assertInstanceOf(JsonParser.class, 
c.getMatchingParser("text/unknown"));
+                       
assertTrue(c.getDefaultAccept().contains("application/json"));
+                       
assertTrue(c.getDefaultAccept().contains("application/jsonl"));
+               }
+       }
+
+       @Test
+       void a03_explicitDefaultOverridesFirstInSet() throws Exception {
+               var sset = SerializerSet.create().add(JsonSerializer.DEFAULT, 
JsonlSerializer.DEFAULT).build();
+               var pset = ParserSet.create().add(JsonParser.DEFAULT, 
JsonlParser.DEFAULT).build();
+               try (var c = RestClient.builder()
+                               .serializers(sset).parsers(pset)
+                               .defaultSerializer(JsonlSerializer.DEFAULT)
+                               .defaultParser(JsonlParser.DEFAULT)
+                               .build()) {
+                       assertSame(JsonlSerializer.DEFAULT, 
c.getDefaultSerializer());
+                       assertSame(JsonlParser.DEFAULT, 
c.getMatchingParser("text/unknown"));
+                       assertInstanceOf(JsonParser.class, 
c.getMatchingParser("application/json"));
+               }
+       }
+
+       @Test
+       void a04_appendConvenience() throws Exception {
+               try (var c = RestClient.builder()
+                               .serializer(JsonlSerializer.DEFAULT)
+                               .parser(JsonlParser.DEFAULT)
+                               .build()) {
+                       assertSame(JsonlSerializer.DEFAULT, 
c.getDefaultSerializer());
+                       assertInstanceOf(JsonlParser.class, 
c.getMatchingParser("application/jsonl"));
+               }
+       }
+
+       @Test
+       void a05_explicitDefaultIsFallbackWithoutSet() throws Exception {
+               try (var c = RestClient.builder()
+                               .defaultSerializer(JsonlSerializer.DEFAULT)
+                               .defaultParser(JsonlParser.DEFAULT)
+                               .build()) {
+                       assertSame(JsonlSerializer.DEFAULT, 
c.getDefaultSerializer());
+                       assertSame(JsonlParser.DEFAULT, 
c.getMatchingParser("text/unknown"));
+               }
+       }
+
+       @Test
+       void a06_defaultAcceptExactOrder() throws Exception {
+               var pset = ParserSet.create().add(JsonParser.DEFAULT, 
JsonlParser.DEFAULT).build();
+               try (var c = RestClient.builder().parsers(pset).build()) {
+                       assertEquals("application/json, text/json, 
application/jcs+json, application/jsonl, application/x-ndjson, text/jsonl", 
c.getDefaultAccept());
+               }
+       }
+
+       @Test
+       void a08_loneDefaultParserAdvertisesItsMediaTypes() throws Exception {
+               try (var c = 
RestClient.builder().defaultParser(JsonlParser.DEFAULT).build()) {
+                       var accept = c.getDefaultAccept();
+                       assertTrue(accept.startsWith("application/jsonl"), () 
-> "Default Accept was: " + accept);
+                       assertFalse(accept.equals("application/json"), () -> 
"Default Accept was: " + accept);
+               }
+       }
+
+       @Test
+       void a07_explicitSetWinsOverAppend() throws Exception {
+               var sset = 
SerializerSet.create().add(JsonSerializer.DEFAULT).build();
+               var pset = ParserSet.create().add(JsonParser.DEFAULT).build();
+               try (var c = RestClient.builder()
+                               .serializers(sset).parsers(pset)
+                               .serializer(JsonlSerializer.DEFAULT)
+                               .parser(JsonlParser.DEFAULT)
+                               .build()) {
+                       assertSame(JsonSerializer.DEFAULT, 
c.getDefaultSerializer());
+                       assertInstanceOf(JsonParser.class, 
c.getMatchingParser("text/unknown"));
+                       assertInstanceOf(JsonParser.class, 
c.getMatchingParser("application/jsonl"));
+                       assertFalse(c.getDefaultAccept().contains("jsonl"));
+               }
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-mock/src/test/java/org/apache/juneau/rest/mock/NextGenContentNegotiation_Test.java
 
b/juneau-rest/juneau-rest-mock/src/test/java/org/apache/juneau/rest/mock/NextGenContentNegotiation_Test.java
new file mode 100644
index 0000000000..dbba7c281a
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-mock/src/test/java/org/apache/juneau/rest/mock/NextGenContentNegotiation_Test.java
@@ -0,0 +1,193 @@
+/*
+ * 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.mock;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.http.*;
+import org.apache.juneau.http.remote.*;
+import org.apache.juneau.marshall.json.*;
+import org.apache.juneau.marshall.jsonl.*;
+import org.apache.juneau.marshall.msgpack.*;
+import org.apache.juneau.marshall.parser.*;
+import org.apache.juneau.marshall.serializer.*;
+import org.apache.juneau.rest.client.*;
+import org.apache.juneau.rest.server.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * End-to-end integration tests for next-generation client content 
negotiation, run in-process against a JSONL
+ * {@code @Rest} server via {@link MockRestClient}.
+ *
+ * <p>
+ * Each test builds a next-generation {@link RestClient} over the mock 
transport (mirroring the harness wiring used by
+ * {@code RemoteCursorBinding_NextGen_Test}) and configures it with a 
serializer and/or parser set so the negotiation
+ * paths are exercised:
+ * <ul>
+ *     <li>{@code a01} — outbound {@code Content-Type} comes from the client's 
default serializer.
+ *     <li>{@code a02} — outbound default {@code Accept} advertises the 
client's configured parsers.
+ *     <li>{@code a03} — inbound cursor parser is negotiated from the response 
{@code Content-Type}.
+ *     <li>{@code a04} — a plain {@code @Remote} return is parsed by the 
negotiated parser (no per-call marshaller).
+ * </ul>
+ */
+@SuppressWarnings({
+       "resource" // MockRestClient and the negotiating RestClient are closed 
by their try-with-resources blocks.
+})
+class NextGenContentNegotiation_Test {
+
+       public static class Bean {
+               public String name;
+               public int age;
+               public Bean() {}
+               public Bean(String name, int age) { this.name = name; this.age 
= age; }
+       }
+
+       @Rest(serializers = JsonlSerializer.class, parsers = JsonlParser.class, 
defaultAccept = "application/jsonl")
+       public static class JsonlServer {
+               @RestGet(path = "/feed")
+               public List<Bean> feed() {
+                       return List.of(new Bean("a", 1), new Bean("b", 2), new 
Bean("c", 3));
+               }
+               @RestGet(path = "/one")
+               public Bean one() {
+                       return new Bean("solo", 7);
+               }
+               @RestGet(path = "/echoAccept")
+               public String echoAccept(@Header("Accept") String accept) { 
return accept; }
+               @RestPost(path = "/echoContentType")
+               public String echoContentType(@Header("Content-Type") String 
ct) { return ct; }
+       }
+
+       @Remote
+       public interface JsonlApi {
+               @RemoteGet("/feed")
+               JsonlTokenReader getFeed();
+       }
+
+       @Test
+       void a01_outboundContentTypeFromDefaultSerializer() throws Exception {
+               var sset = 
SerializerSet.create().add(JsonlSerializer.DEFAULT).build();
+               try (var mock = MockRestClient.create(JsonlServer.class)) {
+                       try (var nc = 
RestClient.builder().transport(mock.getClient().getTransport()).serializers(sset).build())
 {
+                               var rawBody = 
nc.post("/echoContentType").body(new Bean("a", 1)).run().body().asString();
+                               var echoed = JsonParser.DEFAULT.parse(rawBody, 
String.class);
+                               
assertTrue(echoed.startsWith("application/jsonl"), () -> "Echoed Content-Type 
was: " + echoed);
+                       }
+               }
+       }
+
+       @Test
+       void a08_outboundBinaryBodyContentType() throws Exception {
+               try (var mock = MockRestClient.create(JsonlServer.class)) {
+                       try (var nc = 
RestClient.builder().transport(mock.getClient().getTransport()).defaultSerializer(MsgPackSerializer.DEFAULT).build())
 {
+                               var rawBody = 
nc.post("/echoContentType").body(new Bean("a", 1)).run().body().asString();
+                               var echoed = JsonParser.DEFAULT.parse(rawBody, 
String.class);
+                               
assertTrue(echoed.startsWith("application/msgpack"), () -> "Echoed Content-Type 
was: " + echoed);
+                       }
+               }
+       }
+
+       @Test
+       void a02_defaultAcceptAdvertisesParsers() throws Exception {
+               var pset = ParserSet.create().add(JsonlParser.DEFAULT).build();
+               try (var mock = MockRestClient.create(JsonlServer.class)) {
+                       try (var nc = 
RestClient.builder().transport(mock.getClient().getTransport()).parsers(pset).build())
 {
+                               var rawBody = 
nc.get("/echoAccept").run().body().asString();
+                               var echoed = JsonParser.DEFAULT.parse(rawBody, 
String.class);
+                               
assertTrue(echoed.startsWith("application/jsonl"), () -> "Echoed Accept was: " 
+ echoed);
+                       }
+               }
+       }
+
+       @Test
+       void a07_loneDefaultParserAdvertisesAccept() throws Exception {
+               // A client configured with only a default parser (no parser 
set) must still advertise an Accept header.
+               try (var mock = MockRestClient.create(JsonlServer.class)) {
+                       try (var nc = 
RestClient.builder().transport(mock.getClient().getTransport()).defaultParser(JsonlParser.DEFAULT).build())
 {
+                               var rawBody = 
nc.get("/echoAccept").run().body().asString();
+                               var echoed = JsonParser.DEFAULT.parse(rawBody, 
String.class);
+                               
assertTrue(echoed.contains("application/jsonl"), () -> "Echoed Accept was: " + 
echoed);
+                       }
+               }
+       }
+
+       @Test
+       void a03_inboundCursorNegotiation() throws Exception {
+               var pset = ParserSet.create().add(JsonParser.DEFAULT, 
JsonlParser.DEFAULT).build();
+               try (var mock = MockRestClient.create(JsonlServer.class)) {
+                       try (var nc = 
RestClient.builder().transport(mock.getClient().getTransport()).parsers(pset).build())
 {
+                               try (var resp = nc.get("/feed").run();
+                                        JsonlTokenReader r = 
resp.body().asCursor(JsonlTokenReader.class)) {
+                                       var got = new ArrayList<Bean>();
+                                       while (r.canRead())
+                                               got.add(r.read(Bean.class));
+                                       assertEquals(3, got.size());
+                                       assertEquals("a", got.get(0).name);
+                                       assertEquals(3, got.get(2).age);
+                               }
+                       }
+               }
+       }
+
+       @Test
+       void a04_jsonlRemoteReturn() throws Exception {
+               var pset = ParserSet.create().add(JsonParser.DEFAULT, 
JsonlParser.DEFAULT).build();
+               try (var mock = MockRestClient.create(JsonlServer.class)) {
+                       try (var nc = 
RestClient.builder().transport(mock.getClient().getTransport()).parsers(pset).build())
 {
+                               var api = nc.remote(JsonlApi.class);
+                               try (JsonlTokenReader r = api.getFeed()) {
+                                       var got = new ArrayList<Bean>();
+                                       while (r.canRead())
+                                               got.add(r.read(Bean.class));
+                                       assertEquals(3, got.size());
+                               }
+                       }
+               }
+       }
+
+       @Test
+       void a05_inboundAsNegotiated() throws Exception {
+               // JSON is first in the set, so a working as(Bean.class) proves 
negotiation actively picked JSONL from the
+               // response Content-Type (the JSON parser cannot read the JSONL 
line).
+               var pset = ParserSet.create().add(JsonParser.DEFAULT, 
JsonlParser.DEFAULT).build();
+               try (var mock = MockRestClient.create(JsonlServer.class)) {
+                       try (var nc = 
RestClient.builder().transport(mock.getClient().getTransport()).parsers(pset).build())
 {
+                               try (var resp = nc.get("/one").run()) {
+                                       var b = resp.body().as(Bean.class);
+                                       assertEquals("solo", b.name);
+                                       assertEquals(7, b.age);
+                               }
+                       }
+               }
+       }
+
+       @Test
+       void a06_inboundAsForcedParser() throws Exception {
+               // No parser set configured: proves the forced-parser overload 
bypasses negotiation entirely.
+               try (var mock = MockRestClient.create(JsonlServer.class)) {
+                       try (var nc = 
RestClient.builder().transport(mock.getClient().getTransport()).build()) {
+                               try (var resp = nc.get("/one").run()) {
+                                       var b = 
resp.body().as(JsonlParser.DEFAULT, Bean.class);
+                                       assertEquals("solo", b.name);
+                                       assertEquals(7, b.age);
+                               }
+                       }
+               }
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-mock/src/test/java/org/apache/juneau/rest/mock/RemoteCursorBinding_NextGen_Test.java
 
b/juneau-rest/juneau-rest-mock/src/test/java/org/apache/juneau/rest/mock/RemoteCursorBinding_NextGen_Test.java
new file mode 100644
index 0000000000..8f6b301a55
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-mock/src/test/java/org/apache/juneau/rest/mock/RemoteCursorBinding_NextGen_Test.java
@@ -0,0 +1,187 @@
+/*
+ * 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.mock;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+
+import org.apache.juneau.http.*;
+import org.apache.juneau.http.remote.*;
+import org.apache.juneau.marshall.json.*;
+import org.apache.juneau.marshall.stream.*;
+import org.apache.juneau.rest.client.*;
+import org.apache.juneau.rest.server.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Integration tests for next-generation client-side cursor binding on {@code 
@Remote} interfaces and the
+ * {@link RestRequest#streamBodyEntity(RecordStreamBody)} convenience.
+ *
+ * <p>
+ * Verifies that {@code RecordReader} / {@code TokenReader} (and concrete 
subtypes like {@code JsonTokenReader}) work
+ * as {@code @RemoteOp} return types, and that {@code RecordStreamBody} works 
as a {@code @Content} parameter and as a
+ * directly-set request body.  The next-generation client performs no {@code 
Content-Type} negotiation, so cursors use
+ * the default JSON marshaller.
+ */
+class RemoteCursorBinding_NextGen_Test {
+
+       public static class Bean {
+               public String name;
+               public int age;
+               public Bean() {}
+               public Bean(String name, int age) { this.name = name; this.age 
= age; }
+       }
+
+       @Rest(serializers = JsonSerializer.class, parsers = JsonParser.class, 
defaultAccept = "application/json")
+       public static class JsonServer {
+               @RestGet(path = "/bean")
+               public Bean get() {
+                       return new Bean("alice", 30);
+               }
+
+               @RestPost(path = "/echo")
+               public Bean echo(@Content Bean b) {
+                       return b;
+               }
+       }
+
+       @Remote
+       public interface JsonClientApi {
+               @RemoteGet("/bean")
+               RecordReader getBean();
+
+               @RemoteGet("/bean")
+               TokenReader getBeanAsTokens();
+
+               @RemoteGet("/bean")
+               JsonTokenReader getBeanAsJsonTokens();
+
+               @RemotePost("/echo")
+               Bean echo(@Content RecordStreamBody body);
+       }
+
+       // 
==========================================================================
+       // a — return-type cursor binding
+       // 
==========================================================================
+
+       @Test
+       void a01_recordReaderReturnType() throws Exception {
+               try (var client = MockRestClient.create(JsonServer.class)) {
+                       var api = 
client.getClient().remote(JsonClientApi.class);
+                       try (RecordReader r = api.getBean()) {
+                               var b = r.read(Bean.class);
+                               assertEquals("alice", b.name);
+                               assertEquals(30, b.age);
+                       }
+               }
+       }
+
+       @Test
+       void a02_tokenReaderReturnType() throws Exception {
+               try (var client = MockRestClient.create(JsonServer.class)) {
+                       var api = 
client.getClient().remote(JsonClientApi.class);
+                       try (TokenReader r = api.getBeanAsTokens()) {
+                               var b = r.read(Bean.class);
+                               assertEquals("alice", b.name);
+                               assertEquals(30, b.age);
+                       }
+               }
+       }
+
+       @Test
+       void a03_concreteCursorReturnType() throws Exception {
+               try (var client = MockRestClient.create(JsonServer.class)) {
+                       var api = 
client.getClient().remote(JsonClientApi.class);
+                       try (JsonTokenReader r = api.getBeanAsJsonTokens()) {
+                               var b = r.read(Bean.class);
+                               assertEquals("alice", b.name);
+                       }
+               }
+       }
+
+       // 
==========================================================================
+       // b — RecordStreamBody @Content parameter
+       // 
==========================================================================
+
+       @Test
+       @SuppressWarnings({
+               "resource" // Fluent writer calls return the caller-owned 
writer for chaining; nothing new to close.
+       })
+       void b01_recordStreamBody_record() throws Exception {
+               try (var client = MockRestClient.create(JsonServer.class)) {
+                       var api = 
client.getClient().remote(JsonClientApi.class);
+                       Bean got = api.echo(RecordStreamBody.record(w -> {
+                               try {
+                                       w.write(new Bean("dave", 99));
+                               } catch (IOException e) {
+                                       throw new UncheckedIOException(e);
+                               }
+                       }));
+                       assertEquals("dave", got.name);
+                       assertEquals(99, got.age);
+               }
+       }
+
+       @Test
+       @SuppressWarnings({
+               "resource" // Fluent writer calls return the caller-owned 
writer for chaining; nothing new to close.
+       })
+       void b02_recordStreamBody_token() throws Exception {
+               try (var client = MockRestClient.create(JsonServer.class)) {
+                       var api = 
client.getClient().remote(JsonClientApi.class);
+                       Bean got = api.echo(RecordStreamBody.token(w -> {
+                               try {
+                                       w.startObject();
+                                       w.fieldName("name");
+                                       w.string("eve");
+                                       w.fieldName("age");
+                                       w.number(45);
+                                       w.endObject();
+                               } catch (IOException e) {
+                                       throw new UncheckedIOException(e);
+                               }
+                       }));
+                       assertEquals("eve", got.name);
+                       assertEquals(45, got.age);
+               }
+       }
+
+       // 
==========================================================================
+       // c — streamBodyEntity(...) directly on a request
+       // 
==========================================================================
+
+       @Test
+       @SuppressWarnings({
+               "resource" // Fluent writer calls return the caller-owned 
writer for chaining; nothing new to close.
+       })
+       void c01_streamBodyEntity_direct() throws Exception {
+               try (var client = MockRestClient.create(JsonServer.class)) {
+                       try (var resp = 
client.post("/echo").streamBodyEntity(RecordStreamBody.record(w -> {
+                               try {
+                                       w.write(new Bean("frank", 12));
+                               } catch (IOException e) {
+                                       throw new UncheckedIOException(e);
+                               }
+                       })).run()) {
+                               var got = 
JsonParser.DEFAULT.parse(resp.body().asString(), Bean.class);
+                               assertEquals("frank", got.name);
+                               assertEquals(12, got.age);
+                       }
+               }
+       }
+}

Reply via email to