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 1b6abc9889 Finish TODO-150 next-gen remote-proxy parity implementation
1b6abc9889 is described below
commit 1b6abc9889bc3aa4b57352a35619b1994f3421f8
Author: James Bognar <[email protected]>
AuthorDate: Tue Jun 2 08:04:07 2026 -0400
Finish TODO-150 next-gen remote-proxy parity implementation
---
.../org/apache/juneau/rest/client/RestRequest.java | 27 ++
.../juneau/rest/client/remote/RemoteClient.java | 422 ++++++++++++++++++---
.../client/RemoteProxy_FeatureParity_Test.java | 133 ++++---
juneau-utest/test-run-history.tsv | 1 +
4 files changed, 474 insertions(+), 109 deletions(-)
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 349189221c..2f8294255f 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
@@ -406,13 +406,40 @@ public final class RestRequest {
private String applyPathSubstitutions(String template) {
var result = template;
+ Object remainder = null;
for (var entry : pathData.entrySet()) {
+ if ("/*".equals(entry.getKey())) {
+ remainder = entry.getValue(); //
@PathRemainder — applied after named substitutions
+ continue;
+ }
var replacement = entry.getValue() != null ?
entry.getValue().toString() : "";
result = result.replace("{" + entry.getKey() + "}",
urlEncode(replacement));
}
+ if (remainder != null) {
+ var r = remainder.toString();
+ if (! r.isEmpty()) {
+ if (result.endsWith("/*"))
+ result = result.substring(0,
result.length() - 2);
+ if (! result.endsWith("/"))
+ result += "/";
+ result += urlEncodePath(r);
+ }
+ }
return result;
}
+ /** URL-encodes a path remainder while preserving {@code /} segment
separators. */
+ private static String urlEncodePath(String value) {
+ var segments = value.split("/", -1);
+ var sb = new StringBuilder();
+ for (var i = 0; i < segments.length; i++) {
+ if (i > 0)
+ sb.append('/');
+ sb.append(urlEncode(segments[i]));
+ }
+ return sb.toString();
+ }
+
private URI appendQuery(String baseUrl) {
if (queryData.isEmpty()) {
try {
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 88f85d3136..be336ba9ad 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
@@ -18,15 +18,30 @@ package org.apache.juneau.rest.client.remote;
import static org.apache.juneau.commons.utils.AssertionUtils.assertArgNotNull;
import static org.apache.juneau.commons.utils.StringUtils.firstNonEmpty;
+import static org.apache.juneau.commons.utils.ThrowableUtils.rex;
import java.io.*;
import java.lang.reflect.*;
+import java.util.*;
+import java.util.concurrent.*;
+import java.util.function.*;
-import org.apache.juneau.http.annotation.Content;
+import org.apache.juneau.MarshallingContext;
+import org.apache.juneau.commons.httppart.HttpPartType;
+import org.apache.juneau.http.annotation.*;
+import org.apache.juneau.http.header.HttpHeaderList;
+import org.apache.juneau.http.part.PartList;
import org.apache.juneau.http.remote.Remote;
import org.apache.juneau.http.remote.RemoteReturn;
import org.apache.juneau.http.HttpBody;
+import org.apache.juneau.http.response.BasicHttpException;
+import org.apache.juneau.http.response.BasicHttpResponse;
import org.apache.juneau.http.remote.RrpcInterfaceMeta;
+import org.apache.juneau.httppart.HttpPartSchema;
+import org.apache.juneau.json.JsonParser;
+import org.apache.juneau.json.JsonSerializer;
+import org.apache.juneau.oapi.OpenApiSerializer;
+import org.apache.juneau.parser.ParseException;
import org.apache.juneau.rest.client.*;
/**
@@ -135,10 +150,10 @@ public final class RemoteClient {
var req = buildRequest(methodMeta.getHttpMethod(),
fullPath, method, args);
// Execute and process the return value
- return processReturn(req, methodMeta.getReturnType(),
method.getReturnType());
+ return processReturn(req, methodMeta.getReturnType(),
method);
}
- private RestRequest buildRequest(String httpMethod, String
path, Method method, Object[] args) {
+ private RestRequest buildRequest(String httpMethod, String
path, Method method, Object[] args) throws IOException {
var req = switch (httpMethod) {
case "GET" -> client.get(path);
case "POST" -> client.post(path);
@@ -150,67 +165,225 @@ public final class RemoteClient {
if (args != null) {
var params = method.getParameters();
- for (int i = 0; i < params.length; i++) {
- var param = params[i];
- var arg = args[i];
- if (arg == null)
- continue;
-
- var pathAnnotation =
param.getAnnotation(org.apache.juneau.http.annotation.Path.class);
- if (pathAnnotation != null) {
- var name =
firstNonEmpty(pathAnnotation.value(), pathAnnotation.name(), param.getName());
- req = req.pathData(name,
String.valueOf(arg));
- continue;
- }
-
- var queryAnnotation =
param.getAnnotation(org.apache.juneau.http.annotation.Query.class);
- if (queryAnnotation != null) {
- var name =
firstNonEmpty(queryAnnotation.value(), queryAnnotation.name(), param.getName());
- req = req.queryData(name,
String.valueOf(arg));
- continue;
- }
-
- var headerAnnotation =
param.getAnnotation(org.apache.juneau.http.annotation.Header.class);
- if (headerAnnotation != null) {
- var name =
firstNonEmpty(headerAnnotation.value(), headerAnnotation.name(),
param.getName());
- req = req.header(name,
String.valueOf(arg));
- continue;
- }
-
- var contentAnnotation =
param.getAnnotation(Content.class);
- if (contentAnnotation != null) {
- if (arg instanceof HttpBody b)
- req = req.body(b);
- else
- req =
req.bodyString(String.valueOf(arg));
- continue;
- }
-
- if (params.length == 1) {
- if (arg instanceof HttpBody b)
- req = req.body(b);
- else
- req =
req.bodyString(String.valueOf(arg));
- }
- }
+ for (int i = 0; i < params.length; i++)
+ bindParam(req, params[i], args[i],
params.length == 1);
}
return req;
}
- private Object processReturn(RestRequest req, RemoteReturn
returnMode, Class<?> returnType) throws Exception {
+ /**
+ * Binds a single method argument to the outgoing request based
on its HTTP-part annotation.
+ *
+ * <p>
+ * Supports {@code @Path}, {@code @Query}, {@code @Header},
{@code @FormData}, {@code @Content}, and
+ * {@code @Request}. {@code @Query}/{@code @Header}/{@code
@FormData} honor dynamic name/value expansion
+ * (Map / {@code "*"} / part-list / bean) and parameter-level
{@code def()} defaults for {@code null} args.
+ */
+ private void bindParam(RestRequest req, Parameter param, Object
arg, boolean soleParam) throws IOException {
+ var path = param.getAnnotation(Path.class);
+ if (path != null) {
+ if (arg != null)
+
req.pathData(firstNonEmpty(path.value(), path.name(), param.getName()),
+
serializePart(HttpPartType.PATH, HttpPartSchema.create(path, null), arg));
+ return;
+ }
+
+ var query = param.getAnnotation(Query.class);
+ if (query != null) {
+ bindParts(HttpPartType.QUERY,
HttpPartSchema.create(query, null), query.value(), query.name(), query.def(),
arg, param.getName(), req::queryData);
+ return;
+ }
+
+ var header = param.getAnnotation(Header.class);
+ if (header != null) {
+ bindParts(HttpPartType.HEADER,
HttpPartSchema.create(header, null), header.value(), header.name(),
header.def(), arg, param.getName(), req::header);
+ return;
+ }
+
+ var formData = param.getAnnotation(FormData.class);
+ if (formData != null) {
+ bindParts(HttpPartType.FORMDATA,
HttpPartSchema.create(formData, null), formData.value(), formData.name(),
formData.def(), arg, param.getName(), req::formData);
+ return;
+ }
+
+ var request = param.getAnnotation(Request.class);
+ if (request != null) {
+ if (arg != null)
+ bindRequestBean(req, arg);
+ return;
+ }
+
+ // G12: @PathRemainder appends the (part-serialized)
value as the trailing path remainder ("/*").
+ var pathRemainder =
param.getAnnotation(PathRemainder.class);
+ if (pathRemainder != null) {
+ if (arg != null)
+ req.pathData("/*",
serializePart(HttpPartType.PATH, HttpPartSchema.create(pathRemainder, null),
arg));
+ return;
+ }
+
+ if (param.getAnnotation(Content.class) != null) {
+ if (arg != null)
+ withContentBody(req, arg);
+ return;
+ }
+
+ if (soleParam && arg != null)
+ withContentBody(req, arg);
+ }
+
+ /**
+ * Binds a {@code @Query}/{@code @Header}/{@code @FormData}
argument as one or more name/value parts.
+ *
+ * <p>
+ * Single values are serialized via the configured {@link
HttpPartSerializer} honoring the resolved
+ * {@link HttpPartSchema} ({@code @Schema} format /
collection-format / {@code skipIfEmpty}); dynamic
+ * {@code "*"}/blank-name arguments expand into multiple parts
(G8).
+ *
+ * @param partType The HTTP part category
(query/header/form-data).
+ * @param schema The resolved part schema (from the annotation
+ {@code @Schema}).
+ * @param annValue The annotation {@code value()} (may be
blank, or {@code "*"} for dynamic expansion).
+ * @param annName The annotation {@code name()} (may be blank).
+ * @param def The parameter-level default applied when {@code
arg} is <jk>null</jk>.
+ * @param arg The argument value.
+ * @param fallbackName The name to use when the annotation
specifies none (param or bean-property name).
+ * @param adder Sink that records a single name/value part on
the request.
+ */
+ private static void bindParts(HttpPartType partType,
HttpPartSchema schema, String annValue, String annName, String def, Object arg,
String fallbackName, BiConsumer<String,String> adder) {
+ var explicit = firstNonEmpty(annValue, annName);
+ if (arg == null) {
+ if (def != null && ! def.isEmpty())
+ adder.accept("*".equals(explicit) ?
fallbackName : firstNonEmpty(explicit, fallbackName), def);
+ return;
+ }
+ if ("*".equals(explicit) || (explicit == null &&
isExpandable(arg))) {
+ expandPairs(partType, arg, adder);
+ return;
+ }
+ if (schema != null && schema.isSkipIfEmpty() &&
isEmptyArg(arg))
+ return;
+ var serialized = serializePart(partType, schema, arg);
+ if (serialized == null)
+ return;
+ if (serialized.isEmpty() && schema != null &&
schema.isSkipIfEmpty())
+ return;
+ adder.accept(firstNonEmpty(explicit, fallbackName),
serialized);
+ }
+
+ /** Returns <jk>true</jk> if the argument is an empty
string/collection/array/map (for {@code skipIfEmpty}). */
+ private static boolean isEmptyArg(Object arg) {
+ if (arg == null)
+ return true;
+ if (arg instanceof CharSequence cs)
+ return cs.isEmpty();
+ if (arg instanceof Map<?,?> m)
+ return m.isEmpty();
+ if (arg instanceof Collection<?> c)
+ return c.isEmpty();
+ if (arg.getClass().isArray())
+ return Array.getLength(arg) == 0;
+ return false;
+ }
+
+ /**
+ * Expands a dynamic argument (Map / {@link PartList} / {@link
HttpHeaderList} / bean) into discrete
+ * name/value parts. Map/bean values are part-serialized;
{@link PartList}/{@link HttpHeaderList} entries
+ * are already string-valued and passed through.
+ */
+ private static void expandPairs(HttpPartType partType, Object
arg, BiConsumer<String,String> adder) {
+ if (arg instanceof Map<?,?> m) {
+ m.forEach((k, v) -> { if (k != null && v !=
null) adder.accept(String.valueOf(k), serializePart(partType, null, v)); });
+ } else if (arg instanceof PartList pl) {
+ for (var p : pl)
+ if (p.getValue() != null)
+ adder.accept(p.getName(),
p.getValue());
+ } else if (arg instanceof HttpHeaderList hl) {
+ for (var h : hl)
+ if (h.getValue() != null)
+ adder.accept(h.getName(),
h.getValue());
+ } else {
+ for (var e :
MarshallingContext.DEFAULT.toBeanMap(arg).entrySet())
+ if (e.getValue() != null)
+ adder.accept(e.getKey(),
serializePart(partType, null, e.getValue()));
+ }
+ }
+
+ /**
+ * Serializes an HTTP-part value via the OpenAPI part
serializer, honoring the supplied schema
+ * (collection format, value format, enums, etc.). Returns the
non-URL-encoded string form (G6).
+ */
+ private static String serializePart(HttpPartType partType,
HttpPartSchema schema, Object value) {
+ try {
+ return
OpenApiSerializer.DEFAULT.getPartSession().serialize(partType, schema, value);
+ } catch (Exception e) {
+ throw rex(e, "Could not serialize HTTP {0} part
value of type {1}", partType, value == null ? "null" :
value.getClass().getName());
+ }
+ }
+
+ /**
+ * Binds the HTTP-part-annotated getters of a {@code @Request}
bean as discrete request parts.
+ */
+ private static void bindRequestBean(RestRequest req, Object
bean) {
+ for (var m : bean.getClass().getMethods()) {
+ if (m.getParameterCount() != 0 ||
m.getDeclaringClass() == Object.class)
+ continue;
+ var q = m.getAnnotation(Query.class);
+ var h = m.getAnnotation(Header.class);
+ var f = m.getAnnotation(FormData.class);
+ var p = m.getAnnotation(Path.class);
+ if (q == null && h == null && f == null && p ==
null)
+ continue;
+ Object value;
+ try {
+ value = m.invoke(bean);
+ } catch (ReflectiveOperationException e) {
+ throw rex(e, "Could not read @Request
bean property via {0}", m.getName());
+ }
+ var prop = propertyName(m.getName());
+ if (q != null)
+ bindParts(HttpPartType.QUERY,
HttpPartSchema.create(q, null), q.value(), q.name(), q.def(), value, prop,
req::queryData);
+ else if (h != null)
+ bindParts(HttpPartType.HEADER,
HttpPartSchema.create(h, null), h.value(), h.name(), h.def(), value, prop,
req::header);
+ else if (f != null)
+ bindParts(HttpPartType.FORMDATA,
HttpPartSchema.create(f, null), f.value(), f.name(), f.def(), value, prop,
req::formData);
+ else if (value != null)
+ req.pathData(firstNonEmpty(p.value(),
p.name(), prop), serializePart(HttpPartType.PATH, HttpPartSchema.create(p,
null), value));
+ }
+ }
+
+ /**
+ * Returns <jk>true</jk> if a blank-named part argument should
be expanded into multiple name/value parts
+ * (Map / {@link PartList} / {@link HttpHeaderList} / bean)
rather than serialized as a single value.
+ */
+ private static boolean isExpandable(Object arg) {
+ return arg instanceof Map || arg instanceof PartList ||
arg instanceof HttpHeaderList || isBean(arg);
+ }
+
+ private static boolean isBean(Object arg) {
+ return ! (arg instanceof CharSequence || arg instanceof
Number || arg instanceof Boolean
+ || arg instanceof Character || arg instanceof
Enum || arg instanceof Date
+ || arg instanceof Collection ||
arg.getClass().isArray());
+ }
+
+ /** Derives a bean-property name from a getter name (e.g.
{@code getFoo} → {@code foo}). */
+ private static String propertyName(String methodName) {
+ if (methodName.startsWith("get") && methodName.length()
> 3)
+ return
Character.toLowerCase(methodName.charAt(3)) + methodName.substring(4);
+ if (methodName.startsWith("is") && methodName.length()
> 2)
+ return
Character.toLowerCase(methodName.charAt(2)) + methodName.substring(3);
+ return methodName;
+ }
+
+ private Object processReturn(RestRequest req, RemoteReturn
returnMode, Method method) throws Exception {
+ var returnType = method.getReturnType();
+ var genericReturnType = method.getGenericReturnType();
return switch (returnMode) {
- case BODY -> {
+ case BODY -> processBody(req, returnType,
genericReturnType, method);
+ case BEAN -> {
+ // HTTP-response bean (e.g. Ok / NotFound):
materialize from status + body rather than parsing.
try (var resp = req.run()) { // HTT - exception
during close() branch
- if (returnType == void.class ||
returnType == Void.class)
- yield null;
- if (returnType == String.class)
- yield resp.getBodyAsString();
- if (returnType == InputStream.class)
- yield resp.getBodyStream();
- if (returnType == byte[].class)
- yield resp.body().asBytes();
- yield resp.getBodyAsString();
+ throwIfError(resp, method);
+ yield instantiateHttpType(returnType,
resp.getBodyAsString());
}
}
case STATUS -> {
@@ -224,11 +397,142 @@ public final class RemoteClient {
}
}
case RESPONSE -> req.run(); // caller must close
- case BEAN, NONE -> throw new
UnsupportedOperationException(
- "RestClient remote proxies support
RemoteReturn.BODY, STATUS, and RESPONSE only; got " + returnMode);
+ case NONE -> {
+ try (var resp = req.run()) { // HTT - exception
during close() branch
+ yield null;
+ }
+ }
};
}
+ /**
+ * Handles {@link RemoteReturn#BODY} returns, including {@link
Optional} and {@link CompletableFuture}/{@link Future}
+ * wrappers (G11) and HTTP-response beans (G2/G3).
+ */
+ private Object processBody(RestRequest req, Class<?>
returnType, Type genericReturnType, Method method) throws Exception {
+ if (returnType == Optional.class) {
+ var inner = innerType(genericReturnType);
+ return
Optional.ofNullable(processBodyValue(req, rawClass(inner), inner, method));
+ }
+ if (returnType == CompletableFuture.class || returnType
== Future.class) {
+ var inner = innerType(genericReturnType);
+ return
CompletableFuture.completedFuture(processBodyValue(req, rawClass(inner), inner,
method));
+ }
+ return processBodyValue(req, returnType,
genericReturnType, method);
+ }
+
+ /** Runs the request and materializes a single (unwrapped) body
value. */
+ private Object processBodyValue(RestRequest req, Class<?>
returnType, Type genericReturnType, Method method) throws Exception {
+ try (var resp = req.run()) { // HTT - exception during
close() branch
+ throwIfError(resp, method);
+ if (returnType == void.class || returnType ==
Void.class)
+ return null;
+ if (returnType == String.class)
+ return resp.getBodyAsString();
+ if (returnType == InputStream.class)
+ return resp.getBodyStream();
+ if (returnType == byte[].class)
+ return resp.body().asBytes();
+ if
(BasicHttpResponse.class.isAssignableFrom(returnType) ||
BasicHttpException.class.isAssignableFrom(returnType))
+ return instantiateHttpType(returnType,
resp.getBodyAsString());
+ return parseBody(resp, genericReturnType);
+ }
+ }
+
+ /**
+ * Maps an error HTTP status (≥400) to a typed exception
declared in the method's {@code throws} clause (G4).
+ *
+ * <p>
+ * A declared exception type matches when it extends {@link
BasicHttpException} and its static {@code STATUS_CODE}
+ * field equals the response status code (e.g. {@code 404}
→ {@link org.apache.juneau.http.response.NotFound}).
+ * If no declared type matches, the response is left for normal
body handling.
+ */
+ private static void throwIfError(RestResponse resp, Method
method) throws Exception {
+ var sc = resp.getStatusCode();
+ if (sc < 400)
+ return;
+ for (var et : method.getExceptionTypes()) {
+ if
(BasicHttpException.class.isAssignableFrom(et) && httpStatusCode(et) == sc)
+ throw (BasicHttpException)
instantiateHttpType(et, resp.getBodyAsString());
+ }
+ }
+
+ /** Returns the static {@code STATUS_CODE} field of an HTTP
response/exception type, or {@code -1} if absent. */
+ private static int httpStatusCode(Class<?> c) {
+ try {
+ return c.getField("STATUS_CODE").getInt(null);
+ } catch (ReflectiveOperationException e) {
+ return -1;
+ }
+ }
+
+ /**
+ * Instantiates an HTTP response/exception bean, preferring a
{@code (String body)} constructor and falling back
+ * to the no-arg constructor (status code is intrinsic to the
type).
+ */
+ private static Object instantiateHttpType(Class<?> c, String
body) {
+ try {
+ try {
+ return
c.getConstructor(String.class).newInstance(body);
+ } catch (NoSuchMethodException e) {
+ return c.getConstructor().newInstance();
+ }
+ } catch (ReflectiveOperationException e) {
+ throw rex(e, "Could not instantiate HTTP
response/exception type {0}", c.getName());
+ }
+ }
+
+ /** Returns the raw {@link Class} of a possibly-parameterized
type. */
+ private static Class<?> rawClass(Type t) {
+ if (t instanceof Class<?> c)
+ return c;
+ if (t instanceof ParameterizedType p)
+ return (Class<?>) p.getRawType();
+ return Object.class;
+ }
+
+ /** Returns the first type argument of a parameterized wrapper
(e.g. {@code Optional<T>} → {@code T}). */
+ private static Type innerType(Type t) {
+ if (t instanceof ParameterizedType p) {
+ var args = p.getActualTypeArguments();
+ if (args.length > 0)
+ return args[0];
+ }
+ return Object.class;
+ }
+
+ private static RestRequest withContentBody(RestRequest req,
Object arg) throws IOException {
+ if (arg instanceof HttpBody b)
+ return req.body(b);
+ if (arg instanceof String s)
+ return req.bodyString(s);
+ if (arg instanceof Reader r)
+ return req.bodyString(readReader(r));
+ return
req.bodyString(JsonSerializer.DEFAULT.serialize(arg));
+ }
+
+ private static Object parseBody(RestResponse resp, Type
returnType) throws Exception {
+ var body = resp.getBodyAsString();
+ if (body == null)
+ return null;
+ try {
+ return JsonParser.DEFAULT.parse(body,
returnType);
+ } catch (ParseException e) {
+ if (returnType == Object.class)
+ return body;
+ throw e;
+ }
+ }
+
+ private static String readReader(Reader reader) throws
IOException {
+ var sb = new StringBuilder();
+ var buffer = new char[4096];
+ int len;
+ while ((len = reader.read(buffer)) != -1)
+ sb.append(buffer, 0, len);
+ return sb.toString();
+ }
+
private static String combinePaths(String base, String method) {
if (base.isEmpty())
return method.isEmpty() ? "" : method;
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/rest/client/RemoteProxy_FeatureParity_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/rest/client/RemoteProxy_FeatureParity_Test.java
index c819841a83..de485eace1 100644
---
a/juneau-utest/src/test/java/org/apache/juneau/rest/client/RemoteProxy_FeatureParity_Test.java
+++
b/juneau-utest/src/test/java/org/apache/juneau/rest/client/RemoteProxy_FeatureParity_Test.java
@@ -21,6 +21,7 @@ import static org.junit.jupiter.api.Assertions.*;
import java.io.*;
import java.util.*;
+import org.apache.juneau.commons.annotation.Schema;
import org.apache.juneau.http.HttpParts;
import org.apache.juneau.http.annotation.*;
import org.apache.juneau.http.header.HttpHeaderList;
@@ -50,10 +51,13 @@ import org.junit.jupiter.api.*;
* </ul>
*
* <p>
- * Capabilities the beta engine does not yet support are landed as <b>{@code
@Disabled}</b> specs whose bodies assert the
- * <i>desired</i> parity behavior, so each flips green when its gap closes.
Gap ids (G1–G11) match the plan's gap
- * inventory. The synthetic interface, DTOs, enums, and the shared {@code
@Rest} fixture are nested static types below
- * (house convention, mirroring {@code RemoteClient_Test}).
+ * Capabilities were originally landed as <b>{@code @Disabled}</b> specs whose
bodies assert the <i>desired</i> parity
+ * behavior, each flipping green as its gap closed. Gap ids (G1–G12)
match the plan's gap inventory; as of the
+ * G6 + G12 slice <b>all gaps are closed</b> and all 72 cells are active (no
remaining {@code @Disabled} specs) —
+ * G6 adds part-serializer/{@code @Schema} coverage ({@code b33}/{@code
b34}/{@code c44}/{@code c45}) and G12 adds
+ * {@code @PathRemainder} coverage ({@code b35}/{@code c46}). The synthetic
interface, DTOs,
+ * enums, and the shared {@code @Rest} fixture are nested static types below
(house convention, mirroring
+ * {@code RemoteClient_Test}).
*
* <p>
* Cross-walk: each synthetic method's comment names the feature id (F-row) it
exercises.
@@ -133,7 +137,7 @@ class RemoteProxy_FeatureParity_Test {
@RemoteGet(path="/ping", returns=RemoteReturn.RESPONSE)
RestResponse pingResponse(); // RemoteReturn.RESPONSE
@RemoteGet("/ping") void
pingVoid(); // void return
- // ---- Gap rows (present-but-@Disabled)
-----------------------------------------------------------------------
+ // ---- Former gap rows (all now supported)
--------------------------------------------------------------------
@RemotePost("/beanContent") String
postBean(@Content A_Bean bean); // F12 @Content bean (G1)
@RemotePost("/listContent") String
postList(@Content List<A_Bean> beans); // F13 @Content List<bean> (G1)
@@ -169,6 +173,13 @@ class RemoteProxy_FeatureParity_Test {
@RemoteGet("/list")
java.util.concurrent.CompletableFuture<List<A_Bean>> getListAsync(); // G11
async return
@RemoteGet("/bean") Optional<A_Bean>
getBeanOptional(); // G11 Optional return
+
+ // G6 — part serializer honors @Schema (collection format,
skipIfEmpty) instead of toString()/enum-name luck
+ @RemoteGet("/csv") String
getPipes(@Query(name="tags", schema=@Schema(collectionFormat="pipes")) String[]
tags); // G6 collectionFormat=pipes
+ @RemoteGet("/skip") String
getSkip(@Query(name="q", schema=@Schema(skipIfEmpty=true)) String q,
@Query("keep") String keep); // G6 skipIfEmpty
+
+ // G12 — @PathRemainder appended as the trailing path remainder
+ @RemoteGet("/remainder") String
getRemainder(@PathRemainder String remainder); // G12 @PathRemainder
}
/** Tiny secondary interface to assert {@code @RemoteOp} verb
resolution (F-row verb completeness). */
@@ -228,6 +239,9 @@ class RemoteProxy_FeatureParity_Test {
@RestGet("/rest/dynh") public String dynH(@Header("a") String
a, @Header("b") String b) { return "a=" + a + ",b=" + b; }
@RestPost("/rest/dynf") public String dynF(@FormData("a")
String a, @FormData("b") String b) { return "a=" + a + ",b=" + b; }
@RestGet("/rest/def") public String def(@Query("view") String
view) { return "view=" + view; }
+ @RestGet("/rest/csv") public String csv(@Query("tags") String
tags) { return "tags=" + tags; }
+ @RestGet("/rest/skip") public String skip(@Query("q") String
q, @Query("keep") String keep) { return "q=" + q + ",keep=" + keep; }
+ @RestGet("/rest/remainder/*") public String
remainder(@PathRemainder String r) { return "r=" + r; }
}
//
=================================================================================================================
@@ -383,9 +397,8 @@ class RemoteProxy_FeatureParity_Test {
}
}
- // ---- Gap cells (response/body-centric) — @Disabled
--------------------------------------------------------
+ // ---- Former gap cells (response/body-centric) — now active
------------------------------------------------
- @Disabled("G1: next-gen RemoteClient stringifies @Content beans
via String.valueOf instead of serializing with the configured serializer
(RemoteClient.java:180-187; RestRequest.java:232 has no serializer in the
converter chain).")
@Test void b20_contentBean_serialized_F12() throws Exception {
try (var mrc =
MockRestClient.create(A_ParityResource.class)) {
var b = new A_Bean(); b.setName("na44");
@@ -393,7 +406,6 @@ class RemoteProxy_FeatureParity_Test {
}
}
- @Disabled("G1: next-gen RemoteClient does not serialize
@Content List<bean> via the configured serializer (RemoteClient.java:180-187;
RestRequest.java:232).")
@Test void b21_contentList_serialized_F13() throws Exception {
try (var mrc =
MockRestClient.create(A_ParityResource.class)) {
var b = new A_Bean(); b.setName("na44");
@@ -401,14 +413,12 @@ class RemoteProxy_FeatureParity_Test {
}
}
- @Disabled("G7: Reader is not a convertible request-body type in
the next-gen converter chain (RestRequest.java:238-245); RemoteClient
stringifies it (RemoteClient.java:180-187).")
@Test void b22_contentReader_streamed_F14() throws Exception {
try (var mrc =
MockRestClient.create(A_ParityResource.class)) {
assertEquals("hello", proxy(mrc).postReader(new
StringReader("hello")));
}
}
- @Disabled("G2: next-gen RemoteClient returns the raw body
String for bean return types; ResponseBody has no as(Class) parser hook
(RemoteClient.java:201-215; ResponseBody.java).")
@Test void b23_returnBean_parsed_F15() throws Exception {
try (var mrc =
MockRestClient.create(A_ParityResource.class)) {
var bean = proxy(mrc).getBean();
@@ -416,7 +426,6 @@ class RemoteProxy_FeatureParity_Test {
}
}
- @Disabled("G2: next-gen RemoteClient cannot parse a response
body into List<bean> (RemoteClient.java:201-215; ResponseBody.java).")
@Test void b24_returnList_parsed_F16() throws Exception {
try (var mrc =
MockRestClient.create(A_ParityResource.class)) {
var l = proxy(mrc).getList();
@@ -425,130 +434,133 @@ class RemoteProxy_FeatureParity_Test {
}
}
- @Disabled("G2: next-gen RemoteClient cannot parse a response
body into a Map (RemoteClient.java:201-215; ResponseBody.java).")
@Test void b25_returnMap_parsed_F17() throws Exception {
try (var mrc =
MockRestClient.create(A_ParityResource.class)) {
assertEquals("v", proxy(mrc).getMap().get("k"));
}
}
- @Disabled("G2: next-gen RemoteClient cannot parse a numeric
response body into Integer (returns the raw String)
(RemoteClient.java:201-215).")
@Test void b26_returnInteger_parsed_F18() throws Exception {
try (var mrc =
MockRestClient.create(A_ParityResource.class)) {
assertEquals(Integer.valueOf(5),
proxy(mrc).patchCount("x"));
}
}
- @Disabled("G2: next-gen RemoteClient returns the raw String for
an Object return type instead of a parsed POJO (RemoteClient.java:213).")
@Test void b27_returnObject_parsed_F19() throws Exception {
try (var mrc =
MockRestClient.create(A_ParityResource.class)) {
assertEquals(123, proxy(mrc).getObject());
}
}
- @Disabled("G2/G3: next-gen RemoteClient cannot materialize an
HTTP-response bean (Ok); RemoteReturn.BEAN throws UnsupportedOperationException
(RemoteClient.java:227).")
@Test void b28_returnResponseBean_F21() throws Exception {
try (var mrc =
MockRestClient.create(A_ParityResource.class)) {
assertNotNull(proxy(mrc).postOk("x"));
}
}
- @Disabled("G4: next-gen RemoteClient has no error-status ->
typed-exception mapping (RemoteClient.java:201-230).")
@Test void b29_typedException_onErrorStatus_F22() throws
Exception {
try (var mrc =
MockRestClient.create(A_ParityResource.class)) {
assertThrows(NotFound.class, () ->
proxy(mrc).getOrThrow("missing"));
}
}
- @Disabled("G5: @FormData parameters are not bound by next-gen
RemoteClient.buildRequest (RemoteClient.java:151-195).")
@Test void b30_formData_bound_G5() throws Exception {
try (var mrc =
MockRestClient.create(A_ParityResource.class)) {
assertEquals("a=v", proxy(mrc).postForm("v"));
}
}
- @Disabled("G5: @Request beans are not expanded into parts by
next-gen RemoteClient.buildRequest (RemoteClient.java:151-195).")
@Test void b31_requestBean_expanded_G5() throws Exception {
try (var mrc =
MockRestClient.create(A_ParityResource.class)) {
assertEquals("a=ra", proxy(mrc).getReq(new
A_RequestBean()));
}
}
- @Disabled("G11: next-gen RemoteClient does not implement
Optional<T> returns (RemoteClient.java:201-229); classic engine does.")
@Test void b32_optionalReturn_G11() throws Exception {
try (var mrc =
MockRestClient.create(A_ParityResource.class)) {
assertTrue(proxy(mrc).getBeanOptional().isPresent());
}
}
+ // ---- G6 (part serializer / @Schema) + G12 (@PathRemainder) —
final slice -----------------------------------
+
+ @Test void b33_partSerializer_collectionFormat_G6() throws
Exception {
+ try (var mrc =
MockRestClient.create(A_ParityResource.class)) {
+ // @Schema(collectionFormat="pipes") -> "a|b|c"
rather than the array's toString()
+ assertEquals("tags=a|b|c",
proxy(mrc).getPipes(new String[]{"a", "b", "c"}));
+ }
+ }
+
+ @Test void b34_partSerializer_skipIfEmpty_G6() throws Exception
{
+ try (var mrc =
MockRestClient.create(A_ParityResource.class)) {
+ // @Schema(skipIfEmpty=true) on an empty value
omits the "q" param entirely
+ assertEquals("q=null,keep=y",
proxy(mrc).getSkip("", "y"));
+ }
+ }
+
+ @Test void b35_pathRemainder_G12() throws Exception {
+ try (var mrc =
MockRestClient.create(A_ParityResource.class)) {
+ assertEquals("r=abc",
proxy(mrc).getRemainder("abc"));
+ }
+ }
+
// ---- G8: dynamic name/value pairs (round-trip) — server
echoes the N distinct parts it received ----------
- // Desired parity: each map/part-list/bean expands to TWO
distinct parts (a=1, b=2). The engine currently
- // stringifies the whole argument into a single bogus part, so
each cell is @Disabled until G8 closes.
+ // Parity: each map/part-list/bean expands to TWO distinct
parts (a=1, b=2) rather than one bogus part (G8 closed).
- @Disabled("G8: @Query Map is not expanded into distinct query
params — engine stringifies the whole map as one value
(RemoteClient.java:166-170). Classic: RestRequest.java:2801-2825;
Query.java:124-164.")
@Test void b40_dynQueryMap_G8() throws Exception {
try (var mrc =
MockRestClient.create(A_ParityResource.class)) {
assertEquals("a=1,b=2",
proxy(mrc).dynQueryMap(Map.<String,Object>of("a", "1", "b", "2")));
}
}
- @Disabled("G8: @Query(\"*\") Map dynamic-pairs contract not
honored (RemoteClient.java:166-170). Classic: RestRequest.java:2801-2825;
Query.java:124-164.")
@Test void b41_dynQueryMapStar_G8() throws Exception {
try (var mrc =
MockRestClient.create(A_ParityResource.class)) {
assertEquals("a=1,b=2",
proxy(mrc).dynQueryMapStar(Map.<String,Object>of("a", "1", "b", "2")));
}
}
- @Disabled("G8: @Query(\"*\") PartList (NameValuePairs) form not
expanded into distinct query params (RemoteClient.java:166-170). Classic:
RestRequest.java:2801-2825.")
@Test void b42_dynQueryPartList_G8() throws Exception {
try (var mrc =
MockRestClient.create(A_ParityResource.class)) {
assertEquals("a=1,b=2",
proxy(mrc).dynQueryPartList(PartList.of(HttpParts.part("a", "1"),
HttpParts.part("b", "2"))));
}
}
- @Disabled("G8: @Query bean not expanded into per-property query
params (RemoteClient.java:166-170). Classic: RestRequest.java:2801-2825;
Query.java:124-164.")
@Test void b43_dynQueryBean_G8() throws Exception {
try (var mrc =
MockRestClient.create(A_ParityResource.class)) {
assertEquals("a=1,b=2",
proxy(mrc).dynQueryBean(A_PairBean.of("1", "2")));
}
}
- @Disabled("G8: @Header Map is not expanded into distinct
headers (RemoteClient.java:173-178). Classic: RestRequest.java:2733-2767;
Header.java:116-171.")
@Test void b44_dynHeaderMap_G8() throws Exception {
try (var mrc =
MockRestClient.create(A_ParityResource.class)) {
assertEquals("a=1,b=2",
proxy(mrc).dynHeaderMap(Map.<String,Object>of("a", "1", "b", "2")));
}
}
- @Disabled("G8: @Header(\"*\") Map dynamic-pairs contract not
honored (RemoteClient.java:173-178). Classic: RestRequest.java:2733-2767;
Header.java:116-171.")
@Test void b45_dynHeaderMapStar_G8() throws Exception {
try (var mrc =
MockRestClient.create(A_ParityResource.class)) {
assertEquals("a=1,b=2",
proxy(mrc).dynHeaderMapStar(Map.<String,Object>of("a", "1", "b", "2")));
}
}
- @Disabled("G8: @Header(\"*\") HttpHeaderList (NameValuePairs)
form not expanded into distinct headers (RemoteClient.java:173-178). Classic:
RestRequest.java:2733-2767.")
@Test void b46_dynHeaderList_G8() throws Exception {
try (var mrc =
MockRestClient.create(A_ParityResource.class)) {
assertEquals("a=1,b=2",
proxy(mrc).dynHeaderList(HttpHeaderList.ofPairs("a", "1", "b", "2")));
}
}
- @Disabled("G8: @Header bean not expanded into per-property
headers (RemoteClient.java:173-178). Classic: RestRequest.java:2733-2767;
Header.java:116-171.")
@Test void b47_dynHeaderBean_G8() throws Exception {
try (var mrc =
MockRestClient.create(A_ParityResource.class)) {
assertEquals("a=1,b=2",
proxy(mrc).dynHeaderBean(A_PairBean.of("1", "2")));
}
}
- @Disabled("G8: @FormData Map is not bound/expanded into
distinct form fields by next-gen RemoteClient (@FormData unbound:
RemoteClient.java:151-195; G5/G8). Classic: RestRequest.java:formDataArg.")
@Test void b48_dynFormDataMap_G8() throws Exception {
try (var mrc =
MockRestClient.create(A_ParityResource.class)) {
assertEquals("a=1,b=2",
proxy(mrc).dynFormDataMap(Map.<String,Object>of("a", "1", "b", "2")));
}
}
- @Disabled("G8: @FormData(\"*\") Map dynamic-pairs not
bound/expanded by next-gen RemoteClient (@FormData unbound:
RemoteClient.java:151-195; G5/G8). Classic: RestRequest.java:formDataArg.")
@Test void b49_dynFormDataMapStar_G8() throws Exception {
try (var mrc =
MockRestClient.create(A_ParityResource.class)) {
assertEquals("a=1,b=2",
proxy(mrc).dynFormDataMapStar(Map.<String,Object>of("a", "1", "b", "2")));
@@ -657,9 +669,8 @@ class RemoteProxy_FeatureParity_Test {
}
}
- // ---- Gap cells (request-serialization-centric) — @Disabled
------------------------------------------------
+ // ---- Former gap cells (request-serialization-centric) — now
active ----------------------------------------
- @Disabled("G1: @Content bean is stringified via String.valueOf
rather than serialized to JSON (RemoteClient.java:180-187;
RestRequest.java:232).")
@Test void c20_contentBean_serializedRequest_F12() throws
Exception {
var captured = new ArrayList<TransportRequest>();
try (var c = client(captured)) {
@@ -669,7 +680,6 @@ class RemoteProxy_FeatureParity_Test {
}
}
- @Disabled("G7: Reader is not a convertible request-body type
(RestRequest.java:238-245); RemoteClient stringifies it
(RemoteClient.java:180-187).")
@Test void c21_contentReader_streamedRequest_F14() throws
Exception {
var captured = new ArrayList<TransportRequest>();
try (var c = client(captured)) {
@@ -680,7 +690,6 @@ class RemoteProxy_FeatureParity_Test {
// ---- G8: dynamic name/value pairs (request shape) — assert
TWO distinct parts are emitted from the map ----
- @Disabled("G8: @Query Map is not expanded into separate query
params — the engine stringifies the whole map as one value
(RemoteClient.java:166-170). Classic: RestRequest.java:2801-2825;
Query.java:124-164.")
@Test void c22_dynQueryMap_G8() throws Exception {
var captured = new ArrayList<TransportRequest>();
try (var c = client(captured)) {
@@ -690,7 +699,6 @@ class RemoteProxy_FeatureParity_Test {
}
}
- @Disabled("G8: @Query(\"*\") Map dynamic-pairs contract not
honored (RemoteClient.java:166-170). Classic: RestRequest.java:2801-2825;
Query.java:124-164.")
@Test void c23_dynQueryMapStar_G8() throws Exception {
var captured = new ArrayList<TransportRequest>();
try (var c = client(captured)) {
@@ -700,7 +708,6 @@ class RemoteProxy_FeatureParity_Test {
}
}
- @Disabled("G8: @Query(\"*\") PartList (NameValuePairs) form not
expanded into separate query params (RemoteClient.java:166-170). Classic:
RestRequest.java:2801-2825.")
@Test void c24_dynQueryPartList_G8() throws Exception {
var captured = new ArrayList<TransportRequest>();
try (var c = client(captured)) {
@@ -710,7 +717,6 @@ class RemoteProxy_FeatureParity_Test {
}
}
- @Disabled("G8: @Query bean not expanded into per-property query
params (RemoteClient.java:166-170). Classic: RestRequest.java:2801-2825;
Query.java:124-164.")
@Test void c25_dynQueryBean_G8() throws Exception {
var captured = new ArrayList<TransportRequest>();
try (var c = client(captured)) {
@@ -720,7 +726,6 @@ class RemoteProxy_FeatureParity_Test {
}
}
- @Disabled("G8: @Header Map is not expanded into separate
headers (RemoteClient.java:173-178). Classic: RestRequest.java:2733-2767;
Header.java:116-171.")
@Test void c26_dynHeaderMap_G8() throws Exception {
var captured = new ArrayList<TransportRequest>();
try (var c = client(captured)) {
@@ -730,7 +735,6 @@ class RemoteProxy_FeatureParity_Test {
}
}
- @Disabled("G8: @Header(\"*\") Map dynamic-pairs contract not
honored (RemoteClient.java:173-178). Classic: RestRequest.java:2733-2767;
Header.java:116-171.")
@Test void c27_dynHeaderMapStar_G8() throws Exception {
var captured = new ArrayList<TransportRequest>();
try (var c = client(captured)) {
@@ -740,7 +744,6 @@ class RemoteProxy_FeatureParity_Test {
}
}
- @Disabled("G8: @Header(\"*\") HttpHeaderList (NameValuePairs)
form not expanded into separate headers (RemoteClient.java:173-178). Classic:
RestRequest.java:2733-2767.")
@Test void c28_dynHeaderList_G8() throws Exception {
var captured = new ArrayList<TransportRequest>();
try (var c = client(captured)) {
@@ -750,7 +753,6 @@ class RemoteProxy_FeatureParity_Test {
}
}
- @Disabled("G8: @Header bean not expanded into per-property
headers (RemoteClient.java:173-178). Classic: RestRequest.java:2733-2767;
Header.java:116-171.")
@Test void c29_dynHeaderBean_G8() throws Exception {
var captured = new ArrayList<TransportRequest>();
try (var c = client(captured)) {
@@ -760,7 +762,6 @@ class RemoteProxy_FeatureParity_Test {
}
}
- @Disabled("G8/G5: @FormData Map is neither bound nor expanded
into separate form fields by next-gen RemoteClient (RemoteClient.java:151-195).
Classic: RestRequest.java:formDataArg.")
@Test void c30_dynFormDataMap_G8() throws Exception {
var captured = new ArrayList<TransportRequest>();
try (var c = client(captured)) {
@@ -770,7 +771,6 @@ class RemoteProxy_FeatureParity_Test {
}
}
- @Disabled("G8/G5: @FormData(\"*\") Map dynamic-pairs neither
bound nor expanded by next-gen RemoteClient (RemoteClient.java:151-195).
Classic: RestRequest.java:formDataArg.")
@Test void c31_dynFormDataMapStar_G8() throws Exception {
var captured = new ArrayList<TransportRequest>();
try (var c = client(captured)) {
@@ -780,7 +780,6 @@ class RemoteProxy_FeatureParity_Test {
}
}
- @Disabled("G9: @Query(def=...) parameter default is not applied
for a null argument — the engine skips null args entirely
(RemoteClient.java:156). Classic applies parameter defaults
(RestClient.java:7244-7255).")
@Test void c40_paramDefault_G9() throws Exception {
var captured = new ArrayList<TransportRequest>();
try (var c = client(captured)) {
@@ -789,7 +788,6 @@ class RemoteProxy_FeatureParity_Test {
}
}
- @Disabled("G5: @FormData is not bound to a form-data part by
next-gen RemoteClient (RemoteClient.java:151-195).")
@Test void c41_formData_request_G5() throws Exception {
var captured = new ArrayList<TransportRequest>();
try (var c = client(captured)) {
@@ -798,16 +796,51 @@ class RemoteProxy_FeatureParity_Test {
}
}
- @Disabled("G11: CompletableFuture<T> async returns are not
implemented by next-gen RemoteClient (RemoteClient.java:201-229); classic
engine submits to an executor (RestClient.java:7330-7349).")
@Test void c42_asyncReturn_G11() throws Exception {
+ // Dedicated transport: the shared capture stub returns
the literal body "result" (asserted by c01/c10),
+ // which is not valid JSON for List<A_Bean>; this stub
returns a parseable list so the async return can be verified.
+ var t = MockHttpTransport.builder()
+ .fallback(req ->
TransportResponse.builder().statusCode(200).body(new
ByteArrayInputStream("[{\"name\":\"na44\"}]".getBytes())).build())
+ .build();
+ try (var c =
RestClient.builder().transport(t).rootUrl("http://x.com").build()) {
+ var f =
c.remote(A_ParityClient.class).getListAsync();
+ var list = f.get();
+ assertNotNull(list);
+ assertEquals(1, list.size());
+ assertEquals("na44", list.get(0).getName());
+ }
+ }
+
+ // ---- G6 (part serializer / @Schema) + G12 (@PathRemainder) —
request-shape ---------------------------------
+
+ @Test void c44_partSerializer_collectionFormat_G6() throws
Exception {
var captured = new ArrayList<TransportRequest>();
try (var c = client(captured)) {
- var f =
c.remote(A_ParityClient.class).getListAsync();
- assertNotNull(f.get());
+ c.remote(A_ParityClient.class).getPipes(new
String[]{"a", "b", "c"});
+ var uri =
java.net.URLDecoder.decode(captured.get(0).getUri().toString(),
java.nio.charset.StandardCharsets.UTF_8);
+ assertTrue(uri.contains("tags=a|b|c"), uri);
+ }
+ }
+
+ @Test void c45_partSerializer_skipIfEmpty_G6() throws Exception
{
+ var captured = new ArrayList<TransportRequest>();
+ try (var c = client(captured)) {
+ c.remote(A_ParityClient.class).getSkip("", "y");
+ var uri = captured.get(0).getUri().toString();
+ assertTrue(uri.contains("keep=y"), uri);
+ assertFalse(uri.contains("q="), uri);
+ }
+ }
+
+ @Test void c46_pathRemainder_G12() throws Exception {
+ var captured = new ArrayList<TransportRequest>();
+ try (var c = client(captured)) {
+
c.remote(A_ParityClient.class).getRemainder("a/b/c"); // multi-segment:
slashes preserved
+ var path = captured.get(0).getUri().getPath();
+
assertTrue(path.endsWith("/rest/remainder/a/b/c"), path);
}
}
- @Disabled("G2/G1 (F23): full JSON serializer+parser
request/response round-trip not wired through the next-gen RemoteClient
(RemoteClient.java:180-215; RestRequest.java:232; ResponseBody.java).")
@Test void c43_jsonRoundTrip_F23() throws Exception {
try (var mrc =
MockRestClient.create(A_ParityResource.class)) {
var svc =
mrc.getClient().remote(A_ParityClient.class);
diff --git a/juneau-utest/test-run-history.tsv
b/juneau-utest/test-run-history.tsv
index 56369923b0..082d8bb205 100644
--- a/juneau-utest/test-run-history.tsv
+++ b/juneau-utest/test-run-history.tsv
@@ -63,3 +63,4 @@ timestamp git_sha branch tests_run failures
errors skipped surefire_sec wall_sec
2026-06-01T18:55:33Z 8eceb21bb5b9 master 126190 0 0 26
185
2026-06-01T19:34:19Z f8486683b13c master 126197 0 0 26
185
2026-06-02T11:01:51Z 9363babe275f master 126197 0 0 26
181
+2026-06-02T12:03:07Z 46da4ebd52bd master 126197 0 0 26
175