Skip to content

Commit ce5ad35

Browse files
joewizclaude
andcommitted
[bugfix] http-client: honor http:body/@method for inline body serialization
The native EXPath HTTP Client ignored the @method attribute on http:body, so an inline body could only be XML-serialized or sent as text -- a binary/base64/hex inline body could not be sent at all (only http:body/@src, added in eXist-db#6510/eXist-db#6511, delivered raw bytes). RequestBuilder now honors @method (EXPath HTTP Client 3.1): - binary / base64: the body's content is base64-decoded and sent as raw bytes. - hex: the content is hex-decoded and sent as raw bytes. - text: the content's string value is sent (an element child is serialized as its text, not its markup). - xml / xhtml / html (or no @method): unchanged -- child elements are XML-serialized as before. Malformed base64/hex content raises err:HC005. The string value of body content is computed by an explicit recursive walk, because the in-memory DOM's getTextContent does not recurse into element children of a constructed http:body. Multipart-part @method, JSON/adaptive serialization, and multiple external $bodies remain follow-ups (eXist-db#6512). SendRequestFunctionTest gains bodyMethodBinarySendsDecodedBytes, bodyMethodHexSendsDecodedBytes, and bodyMethodTextSerializesAsText. Part of eXist-db#6512 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e226bec commit ce5ad35

2 files changed

Lines changed: 119 additions & 9 deletions

File tree

extensions/modules/http-client/src/main/java/org/exist/xquery/modules/httpclient/RequestBuilder.java

Lines changed: 69 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
import java.util.ArrayList;
5555
import java.util.Base64;
5656
import java.util.HashMap;
57+
import java.util.HexFormat;
5758
import java.util.List;
5859
import java.util.Map;
5960

@@ -84,6 +85,7 @@ public class RequestBuilder {
8485
private String bodyContent;
8586
private String bodySrc;
8687
private byte[] bodyResourceBytes;
88+
private String bodyMethod;
8789
private String multipartMediaType;
8890
private final List<BodyPart> multipartBodies = new ArrayList<>();
8991

@@ -143,8 +145,12 @@ private void parseBody(final Element bodyElem) throws XPathException {
143145
bodyMediaType = bodyElem.getAttribute("media-type");
144146
bodySrc = getAttr(bodyElem, "src");
145147
if (bodySrc == null) {
146-
// Body content is the text/XML content of the body element
147-
bodyContent = getBodyContent(bodyElem);
148+
bodyMethod = getAttr(bodyElem, "method");
149+
// For binary/base64/hex/text the body is the string value of the content (the base64/hex
150+
// lexical, or the text serialization; decoded as needed in applyBody); for xml/xhtml/html
151+
// (or no @method) the child elements are XML-serialized.
152+
bodyContent = isRawContentMethod(bodyMethod)
153+
? stringValue(bodyElem) : getBodyContent(bodyElem);
148154
return;
149155
}
150156
if (hasNonWhitespaceContent(bodyElem)) {
@@ -326,7 +332,7 @@ private void applyAuthorization(final HttpRequest.Builder builder, final String
326332
}
327333
}
328334

329-
private void applyBody(final HttpRequest.Builder builder, final String upperMethod, final boolean isMultipart) {
335+
private void applyBody(final HttpRequest.Builder builder, final String upperMethod, final boolean isMultipart) throws XPathException {
330336
if (isMultipart) {
331337
applyMultipartBody(builder, upperMethod);
332338
} else if (bodyResourceBytes != null) {
@@ -350,6 +356,35 @@ private void applyResourceBody(final HttpRequest.Builder builder, final String u
350356
builder.method(upperMethod, HttpRequest.BodyPublishers.ofByteArray(bodyResourceBytes));
351357
}
352358

359+
/**
360+
* The http:body @method values whose content is sent as raw bytes (binary/base64/hex) or as a
361+
* plain string (text), rather than XML-serialized.
362+
*/
363+
private static boolean isRawContentMethod(final String method) {
364+
return "binary".equals(method) || "base64".equals(method)
365+
|| "hex".equals(method) || "text".equals(method);
366+
}
367+
368+
/**
369+
* The string value of an element: the concatenation of all descendant text, computed explicitly
370+
* because the in-memory DOM's getTextContent does not recurse into element children for a
371+
* constructed http:body.
372+
*/
373+
private static String stringValue(final Node node) {
374+
final StringBuilder sb = new StringBuilder();
375+
final NodeList children = node.getChildNodes();
376+
for (int i = 0; i < children.getLength(); i++) {
377+
final Node child = children.item(i);
378+
final short type = child.getNodeType();
379+
if (type == Node.TEXT_NODE || type == Node.CDATA_SECTION_NODE) {
380+
sb.append(child.getNodeValue());
381+
} else if (type == Node.ELEMENT_NODE) {
382+
sb.append(stringValue(child));
383+
}
384+
}
385+
return sb.toString();
386+
}
387+
353388
/**
354389
* Multipart request body, built with Methanol's MultipartBodyPublisher (the JDK client has no
355390
* multipart support). Each http:body part becomes a part with its own Content-Type.
@@ -373,19 +408,44 @@ private void applyMultipartBody(final HttpRequest.Builder builder, final String
373408
builder.method(upperMethod, publisher);
374409
}
375410

376-
private void applySingleBody(final HttpRequest.Builder builder, final String upperMethod) {
377-
final Charset charset = bodyMediaType != null
378-
? Charset.forName(ContentTypeHelper.extractCharset(bodyMediaType))
379-
: StandardCharsets.UTF_8;
380-
final HttpRequest.BodyPublisher bodyPublisher =
381-
HttpRequest.BodyPublishers.ofString(bodyContent, charset);
411+
private void applySingleBody(final HttpRequest.Builder builder, final String upperMethod) throws XPathException {
412+
final HttpRequest.BodyPublisher bodyPublisher = bodyPublisherForMethod();
382413
// Set Content-Type if not already set via headers
383414
if (bodyMediaType != null && headers.stream().noneMatch(h -> "Content-Type".equalsIgnoreCase(h[0]))) {
384415
builder.header("Content-Type", bodyMediaType);
385416
}
386417
builder.method(upperMethod, bodyPublisher);
387418
}
388419

420+
/**
421+
* Builds the body publisher for a single (non-multipart) body, honoring http:body/@method (EXPath
422+
* HTTP Client 3.1). {@code binary}/{@code base64} decode the body's text content from base64 and
423+
* {@code hex} from hexadecimal -- both sent as raw bytes; {@code text}/{@code xml}/{@code xhtml}/
424+
* {@code html} (or no @method) are sent as a string in the media-type's charset (default UTF-8).
425+
*/
426+
private HttpRequest.BodyPublisher bodyPublisherForMethod() throws XPathException {
427+
if ("binary".equals(bodyMethod) || "base64".equals(bodyMethod)) {
428+
try {
429+
return HttpRequest.BodyPublishers.ofByteArray(Base64.getMimeDecoder().decode(bodyContent.strip()));
430+
} catch (final IllegalArgumentException e) {
431+
throw new XPathException((org.exist.xquery.Expression) null, HttpClientModule.HC005,
432+
"http:body with method='" + bodyMethod + "' requires base64-encoded content: " + e.getMessage());
433+
}
434+
}
435+
if ("hex".equals(bodyMethod)) {
436+
try {
437+
return HttpRequest.BodyPublishers.ofByteArray(HexFormat.of().parseHex(bodyContent.strip()));
438+
} catch (final IllegalArgumentException e) {
439+
throw new XPathException((org.exist.xquery.Expression) null, HttpClientModule.HC005,
440+
"http:body with method='hex' requires hexadecimal content: " + e.getMessage());
441+
}
442+
}
443+
final Charset charset = bodyMediaType != null
444+
? Charset.forName(ContentTypeHelper.extractCharset(bodyMediaType))
445+
: StandardCharsets.UTF_8;
446+
return HttpRequest.BodyPublishers.ofString(bodyContent, charset);
447+
}
448+
389449
/**
390450
* Whether a 401 response should be answered with credentials (EXPath challenge-response):
391451
* credentials and an auth method are present but were not sent preemptively (because

extensions/modules/http-client/src/test/java/org/exist/xquery/modules/httpclient/SendRequestFunctionTest.java

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -816,6 +816,56 @@ public void bodySrcWithoutMediaTypeThrowsHC005() {
816816
.withStackTraceContaining("HC005");
817817
}
818818

819+
/**
820+
* http:body/@method='binary' decodes the body's base64 text content and sends the raw bytes
821+
* (EXPath HTTP Client 3.1 serialization method). base64('hello') = 'aGVsbG8='.
822+
*/
823+
@Test
824+
public void bodyMethodBinarySendsDecodedBytes() throws XMLDBException {
825+
final ResourceSet result = existEmbeddedServer.executeQuery(
826+
HTTP_NS +
827+
"let $response := http:send-request(\n" +
828+
" <http:request method='POST' href='" + baseUrl() + "/echo'>\n" +
829+
" <http:body media-type='application/octet-stream' method='binary'>aGVsbG8=</http:body>\n" +
830+
" </http:request>)\n" +
831+
"return parse-json($response[2])?body");
832+
assertEquals("method='binary' should base64-decode the body to raw bytes",
833+
"hello", result.getResource(0).getContent().toString());
834+
}
835+
836+
/**
837+
* http:body/@method='hex' decodes the body's hexadecimal text content. hex('hi') = '6869'.
838+
*/
839+
@Test
840+
public void bodyMethodHexSendsDecodedBytes() throws XMLDBException {
841+
final ResourceSet result = existEmbeddedServer.executeQuery(
842+
HTTP_NS +
843+
"let $response := http:send-request(\n" +
844+
" <http:request method='POST' href='" + baseUrl() + "/echo'>\n" +
845+
" <http:body media-type='application/octet-stream' method='hex'>6869</http:body>\n" +
846+
" </http:request>)\n" +
847+
"return parse-json($response[2])?body");
848+
assertEquals("method='hex' should hex-decode the body to raw bytes",
849+
"hi", result.getResource(0).getContent().toString());
850+
}
851+
852+
/**
853+
* http:body/@method='text' serializes element content as text (its string value), not as XML --
854+
* so a child element is sent as its text, not its markup.
855+
*/
856+
@Test
857+
public void bodyMethodTextSerializesAsText() throws XMLDBException {
858+
final ResourceSet result = existEmbeddedServer.executeQuery(
859+
HTTP_NS +
860+
"let $response := http:send-request(\n" +
861+
" <http:request method='POST' href='" + baseUrl() + "/echo'>\n" +
862+
" <http:body media-type='text/plain' method='text'><a>x</a></http:body>\n" +
863+
" </http:request>)\n" +
864+
"return parse-json($response[2])?body");
865+
assertEquals("method='text' should send the element's string value, not its markup",
866+
"x", result.getResource(0).getContent().toString());
867+
}
868+
819869
@Test
820870
public void putMethod() throws XMLDBException {
821871
final ResourceSet result = existEmbeddedServer.executeQuery(

0 commit comments

Comments
 (0)