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