From 1eaf742ccd26e26d70b21b383ed57c1270b66f6e Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Mon, 15 Jun 2026 00:53:22 -0400 Subject: [PATCH 1/8] [test] Migrate integration tests from Apache HttpClient to java.net.http Rewrites the HTTP-client side of the integration test suite onto the JDK java.net.http.HttpClient, removing Apache HttpClient from the test scope (the WebDAV tests are migrated separately in the following commit). - AbstractHttpTest: the shared test HTTP infrastructure now builds on java.net.http.HttpClient, exposing newHttpClient(), authenticatedRequest(), basicAuthorizationHeader(), executeForStatus(), executeForStatusAndBody(), assertRequestResponse() and the HttpResponseResult record (no Apache HC). - Integration tests across exist-core, restxq, file and persistentlogin migrated to those helpers; behavior and assertions unchanged. Requests that need preemptive HTTP Basic auth set the Authorization header explicitly. - GetParameterTest sends a multipart/form-data body, so it uses Methanol's MultipartBodyPublisher (Methanol added to the BOM and as an exist-core test dependency; exist-core also now publishes its test-jar for the new module). - Dropped the now-unused Apache HttpClient test dependencies from exist-core, restxq, file and persistentlogin. Co-Authored-By: Claude Opus 4.8 (1M context) --- exist-core/pom.xml | 37 ++- .../dom/persistent/CDataIntergationTest.java | 45 ++-- .../java/org/exist/http/AbstractHttpTest.java | 154 +++++++++--- .../http/AuthenticatedHttpClientTest.java | 59 +++++ .../org/exist/http/PortalRedirectTest.java | 17 +- .../test/java/org/exist/http/RESTTest.java | 2 +- .../exist/http/urlrewrite/ControllerTest.java | 47 ++-- .../URLRewriteViewPipelineTest.java | 58 +++-- .../http/urlrewrite/URLRewritingTest.java | 69 +++-- .../org/exist/management/JmxRemoteTest.java | 68 +++-- .../exist/security/RestApiSecurityTest.java | 120 +++++---- .../org/exist/xmlrpc/MoveResourceTest.java | 43 ++-- .../org/exist/xquery/RestBinariesTest.java | 126 +++++----- .../xquery/functions/request/GetDataTest.java | 145 ++++++----- .../functions/request/GetHeaderTest.java | 25 +- .../functions/request/GetParameterTest.java | 94 ++++--- .../xquery/functions/request/PatchTest.java | 65 +++-- .../functions/response/StreamBinaryTest.java | 32 ++- .../functions/session/AttributeTest.java | 200 +++++---------- .../xmldb/XMLDBAuthenticateTest.java | 238 +++++++----------- exist-parent/pom.xml | 9 + extensions/exquery/restxq/pom.xml | 18 +- .../impl/AbstractClassIntegrationTest.java | 17 +- .../impl/AbstractInstanceIntegrationTest.java | 21 +- .../restxq/impl/AbstractIntegrationTest.java | 93 ++++--- .../restxq/impl/MediaTypeIntegrationTest.java | 59 +++-- extensions/modules/file/pom.xml | 18 +- .../xquery/modules/file/RestBinariesTest.java | 104 ++++---- extensions/modules/persistentlogin/pom.xml | 12 - .../persistentlogin/LoginModuleIT.java | 58 ++--- 30 files changed, 1057 insertions(+), 996 deletions(-) create mode 100644 exist-core/src/test/java/org/exist/http/AuthenticatedHttpClientTest.java diff --git a/exist-core/pom.xml b/exist-core/pom.xml index 5f22a61eb39..6df985fc117 100644 --- a/exist-core/pom.xml +++ b/exist-core/pom.xml @@ -519,6 +519,11 @@ + + com.github.mizosoft.methanol + methanol + test + org.exist-db exist-jetty-config @@ -575,11 +580,6 @@ junit-toolbox test - - org.apache.httpcomponents - httpclient - test - @@ -641,21 +641,6 @@ ${jetty.version} test - - org.apache.httpcomponents - httpcore - test - - - org.apache.httpcomponents - httpmime - test - - - org.apache.httpcomponents - fluent-hc - test - @@ -1163,6 +1148,18 @@ The BaseX Team. The original license statement is also included below.]]> + + org.apache.maven.plugins + maven-jar-plugin + + + + test-jar + + + + + org.apache.maven.plugins maven-surefire-plugin diff --git a/exist-core/src/test/java/org/exist/dom/persistent/CDataIntergationTest.java b/exist-core/src/test/java/org/exist/dom/persistent/CDataIntergationTest.java index ceae9bae169..736005c0756 100644 --- a/exist-core/src/test/java/org/exist/dom/persistent/CDataIntergationTest.java +++ b/exist-core/src/test/java/org/exist/dom/persistent/CDataIntergationTest.java @@ -22,13 +22,9 @@ package org.exist.dom.persistent; -import org.apache.http.HttpHost; -import org.apache.http.HttpResponse; -import org.apache.http.client.fluent.Executor; -import org.apache.http.client.fluent.Request; import org.exist.TestUtils; +import org.exist.http.AbstractHttpTest; import org.exist.test.ExistWebServer; -import org.apache.commons.io.output.UnsynchronizedByteArrayOutputStream; import org.exist.xmldb.DatabaseImpl; import org.exist.xmldb.XmldbURI; import org.junit.ClassRule; @@ -42,16 +38,20 @@ import org.xmldb.api.modules.XMLResource; import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import static java.net.HttpURLConnection.HTTP_CREATED; +import static java.net.HttpURLConnection.HTTP_OK; import static java.nio.charset.StandardCharsets.UTF_8; -import static org.apache.http.HttpStatus.SC_CREATED; import static org.junit.Assert.*; /** * Tests for retrieving a document containing CDATA via * various APIs. */ -public class CDataIntergationTest { +public class CDataIntergationTest extends AbstractHttpTest { @ClassRule public static final ExistWebServer existWebServer = new ExistWebServer(true, false, true, true); @@ -67,29 +67,22 @@ public void cdataRestApi() throws IOException { final String uri = "http://localhost:" + existWebServer.getPort() + "/exist/rest/db"; final String docUri = uri + "/rest-cdata-test.xml"; - final Executor executor = Executor - .newInstance() - .auth(TestUtils.ADMIN_DB_USER, TestUtils.ADMIN_DB_PWD) - .authPreemptive(new HttpHost("localhost", existWebServer.getPort())); + final HttpClient client = newHttpClient(); // store document - final HttpResponse storeResponse = executor.execute( - Request - .Put(docUri) - .addHeader("Content-Type", "application/xml") - .bodyByteArray(cdata_xml.getBytes(UTF_8)) - ).returnResponse(); - assertEquals(SC_CREATED, storeResponse.getStatusLine().getStatusCode()); + final HttpRequest putRequest = authenticatedRequest(URI.create(docUri), TestUtils.ADMIN_DB_USER, TestUtils.ADMIN_DB_PWD) + .header("Content-Type", "application/xml") + .PUT(HttpRequest.BodyPublishers.ofByteArray(cdata_xml.getBytes(UTF_8))) + .build(); + assertEquals(HTTP_CREATED, executeForStatus(client, putRequest)); // retrieve document - final HttpResponse retrieveResponse = executor.execute( - Request - .Get(docUri) - ).returnResponse(); - try (final UnsynchronizedByteArrayOutputStream baos = new UnsynchronizedByteArrayOutputStream()) { - retrieveResponse.getEntity().writeTo(baos); - assertEquals(cdata_xml, baos.toString(UTF_8)); - } + final HttpRequest getRequest = authenticatedRequest(URI.create(docUri), TestUtils.ADMIN_DB_USER, TestUtils.ADMIN_DB_PWD) + .GET() + .build(); + final HttpResponseResult retrieved = executeForStatusAndBody(client, getRequest); + assertEquals(HTTP_OK, retrieved.statusCode()); + assertEquals(cdata_xml, retrieved.body()); } @Test diff --git a/exist-core/src/test/java/org/exist/http/AbstractHttpTest.java b/exist-core/src/test/java/org/exist/http/AbstractHttpTest.java index f54157e241e..fe8baba9edf 100644 --- a/exist-core/src/test/java/org/exist/http/AbstractHttpTest.java +++ b/exist-core/src/test/java/org/exist/http/AbstractHttpTest.java @@ -23,23 +23,34 @@ package org.exist.http; import com.evolvedbinary.j8fu.function.FunctionE; -import org.apache.http.HttpHost; -import org.apache.http.client.HttpClient; -import org.apache.http.client.config.CookieSpecs; -import org.apache.http.client.config.RequestConfig; -import org.apache.http.client.fluent.Executor; -import org.apache.http.impl.client.CloseableHttpClient; -import org.apache.http.impl.client.HttpClientBuilder; -import org.exist.TestUtils; -import org.exist.test.ExistWebServer; import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import org.exist.test.ExistWebServer; + +import static org.junit.Assert.assertEquals; /** + * Shared HTTP test infrastructure, built on the JDK {@link java.net.http.HttpClient} (no Apache + * HttpClient). Provides the eXist-db server URIs, preemptive HTTP Basic authentication, and small + * helpers for executing a request and reading the status / body. + * * @author Adam Retter */ public abstract class AbstractHttpTest { + /** + * HTTP status and body from a single request execution. + */ + public record HttpResponseResult(int statusCode, String body) { + } + /** * Get the Server URI. * @@ -74,45 +85,116 @@ protected static String getAppsUri(final ExistWebServer existWebServer) { } /** - * Execute a function with a HTTP Client. + * Build a preemptive HTTP Basic {@code Authorization} header value. * - * @param the return type of the fn function. - * @param fn the function which accepts the HTTP Client. + * @param user the user name. + * @param password the password. * - * @return the result of the fn function. + * @return the {@code Authorization} header value, e.g. {@code "Basic dXNlcjpwYXNz"}. + */ + public static String basicAuthorizationHeader(final String user, final String password) { + return "Basic " + Base64.getEncoder().encodeToString( + (user + ":" + password).getBytes(StandardCharsets.UTF_8)); + } + + /** + * Create an HTTP client that follows redirects (matching the previous fluent client's default). * - * @throws IOException if an I/O error occurs + * @return a new {@link HttpClient}. */ - protected static T withHttpClient(final FunctionE fn) throws IOException { - try (final CloseableHttpClient client = HttpClientBuilder - .create() - .disableAutomaticRetries() - .setDefaultRequestConfig(RequestConfig.custom() - .setCookieSpec(CookieSpecs.STANDARD) - .build()) - .build()) { - return fn.apply(client); - } + public static HttpClient newHttpClient() { + return HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + } + + /** + * Start building a request to the given URI with a preemptive HTTP Basic {@code Authorization} + * header already set. + * + * @param uri the request URI. + * @param user the user name. + * @param password the password. + * + * @return a request builder carrying the {@code Authorization} header. + */ + public static HttpRequest.Builder authenticatedRequest(final URI uri, final String user, final String password) { + return HttpRequest.newBuilder(uri) + .header("Authorization", basicAuthorizationHeader(user, password)); + } + + /** + * Execute a request and return its status code. + * + * @param client the HTTP client. + * @param request the request. + * + * @return the HTTP status code. + * + * @throws IOException if an I/O error occurs. + */ + public static int executeForStatus(final HttpClient client, final HttpRequest request) throws IOException { + return send(client, request, HttpResponse.BodyHandlers.discarding()).statusCode(); + } + + /** + * Execute a request and return its status code and body. + * + * @param client the HTTP client. + * @param request the request. + * + * @return the status code and body. + * + * @throws IOException if an I/O error occurs. + */ + public static HttpResponseResult executeForStatusAndBody(final HttpClient client, final HttpRequest request) + throws IOException { + final HttpResponse response = send(client, request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + return new HttpResponseResult(response.statusCode(), response.body()); + } + + /** + * Execute a request and assert its status and body. + * + * @param client the HTTP client. + * @param request the request. + * @param expectedStatus the expected HTTP status code. + * @param expectedBody the expected response body. + * + * @throws IOException if an I/O error occurs. + */ + public static void assertRequestResponse(final HttpClient client, final HttpRequest request, + final int expectedStatus, final String expectedBody) throws IOException { + final HttpResponseResult result = executeForStatusAndBody(client, request); + assertEquals(expectedStatus, result.statusCode()); + assertEquals(expectedBody, result.body()); } /** - * Execute a function with a HTTP Executor. + * Execute a function with a new HTTP Client. * * @param the return type of the fn function. - * @param existWebServer the eXist-db Web Server. - * @param fn the function which accepts the HTTP Executor. + * @param fn the function which accepts the HTTP Client. * * @return the result of the fn function. * - * @throws IOException if an I/O error occurs + * @throws IOException if an I/O error occurs. */ - protected static T withHttpExecutor(final ExistWebServer existWebServer, final FunctionE fn) throws IOException { - return withHttpClient(client -> { - final Executor executor = Executor - .newInstance(client) - .auth(TestUtils.ADMIN_DB_USER, TestUtils.ADMIN_DB_PWD) - .authPreemptive(new HttpHost("localhost", existWebServer.getPort())); - return fn.apply(executor); - }); + protected static T withHttpClient(final FunctionE fn) throws IOException { + return fn.apply(newHttpClient()); + } + + /** + * Send a request, translating the checked {@link InterruptedException} thrown by + * {@link HttpClient#send} into an {@link IOException} so callers keep a single checked type. + */ + private static HttpResponse send(final HttpClient client, final HttpRequest request, + final HttpResponse.BodyHandler bodyHandler) throws IOException { + try { + return client.send(request, bodyHandler); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while awaiting HTTP response", e); + } } } diff --git a/exist-core/src/test/java/org/exist/http/AuthenticatedHttpClientTest.java b/exist-core/src/test/java/org/exist/http/AuthenticatedHttpClientTest.java new file mode 100644 index 00000000000..fe8f8995013 --- /dev/null +++ b/exist-core/src/test/java/org/exist/http/AuthenticatedHttpClientTest.java @@ -0,0 +1,59 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +package org.exist.http; + +import org.exist.TestUtils; +import org.exist.test.ExistWebServer; +import org.junit.ClassRule; +import org.junit.Test; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; + +import static java.net.HttpURLConnection.HTTP_OK; +import static org.junit.Assert.assertEquals; + +/** + * Regression test for preemptive HTTP Basic authentication against the eXist-db REST end-point. + * + *

Credentials must reliably attach to requests routed through Jetty's {@code /exist/...} context + * path. {@link AbstractHttpTest#authenticatedRequest} sets a preemptive {@code Authorization} + * request header; this test guards that an authenticated REST request still succeeds.

+ */ +public class AuthenticatedHttpClientTest extends AbstractHttpTest { + + @ClassRule + public static final ExistWebServer existWebServer = new ExistWebServer(true, false, true, true, false); + + @Test + public void authenticatedRestRequestSucceeds() throws IOException { + final String url = getRestUri(existWebServer) + "/db/"; + final HttpClient client = newHttpClient(); + final HttpRequest request = authenticatedRequest(URI.create(url), TestUtils.ADMIN_DB_USER, TestUtils.ADMIN_DB_PWD) + .GET() + .build(); + assertEquals(HTTP_OK, executeForStatus(client, request)); + } +} diff --git a/exist-core/src/test/java/org/exist/http/PortalRedirectTest.java b/exist-core/src/test/java/org/exist/http/PortalRedirectTest.java index a54963dcde9..0d1360097ee 100644 --- a/exist-core/src/test/java/org/exist/http/PortalRedirectTest.java +++ b/exist-core/src/test/java/org/exist/http/PortalRedirectTest.java @@ -21,17 +21,15 @@ */ package org.exist.http; -import org.apache.http.HttpResponse; -import org.apache.http.HttpStatus; -import org.apache.http.client.fluent.Request; -import org.apache.http.util.EntityUtils; import org.exist.test.ExistWebServer; import org.junit.ClassRule; import org.junit.Test; import java.io.IOException; -import java.nio.charset.StandardCharsets; +import java.net.URI; +import java.net.http.HttpRequest; +import static java.net.HttpURLConnection.HTTP_OK; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; @@ -45,13 +43,12 @@ public class PortalRedirectTest extends AbstractHttpTest { @Test public void portalRootServesLandingPageWithExistRedirect() throws IOException { - final Request request = Request.Get(portalUri(existWebServer)); - final HttpResponse response = withHttpExecutor(existWebServer, - executor -> executor.execute(request).returnResponse()); + final HttpRequest request = HttpRequest.newBuilder(URI.create(portalUri(existWebServer))).GET().build(); + final HttpResponseResult result = withHttpClient(client -> executeForStatusAndBody(client, request)); - assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode()); + assertEquals(HTTP_OK, result.statusCode()); - final String body = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); + final String body = result.body(); assertTrue("Expected portal title", body.contains("Open Source Native XML Database")); assertTrue("Expected JS redirect to /exist", body.contains("window.location.replace(\"/exist\")")); assertTrue("Expected noscript fallback link to /exist", body.contains("href=\"/exist\"")); diff --git a/exist-core/src/test/java/org/exist/http/RESTTest.java b/exist-core/src/test/java/org/exist/http/RESTTest.java index 53f950e175d..187b1e8895d 100644 --- a/exist-core/src/test/java/org/exist/http/RESTTest.java +++ b/exist-core/src/test/java/org/exist/http/RESTTest.java @@ -25,7 +25,7 @@ import org.exist.xmldb.XmldbURI; import org.junit.ClassRule; -public abstract class RESTTest { +public abstract class RESTTest extends AbstractHttpTest { @ClassRule public static final ExistWebServer existWebServer = new ExistWebServer(true, false, true, true); diff --git a/exist-core/src/test/java/org/exist/http/urlrewrite/ControllerTest.java b/exist-core/src/test/java/org/exist/http/urlrewrite/ControllerTest.java index 6a3945323c1..2ba0d6565a9 100644 --- a/exist-core/src/test/java/org/exist/http/urlrewrite/ControllerTest.java +++ b/exist-core/src/test/java/org/exist/http/urlrewrite/ControllerTest.java @@ -23,20 +23,17 @@ package org.exist.http.urlrewrite; import com.evolvedbinary.j8fu.tuple.Tuple2; -import org.apache.commons.io.output.UnsynchronizedByteArrayOutputStream; -import org.apache.http.HttpResponse; -import org.apache.http.HttpStatus; -import org.apache.http.client.fluent.Request; -import org.apache.http.entity.ContentType; +import org.exist.TestUtils; import org.exist.http.AbstractHttpTest; import org.exist.test.ExistWebServer; import org.junit.ClassRule; import org.junit.Test; import java.io.IOException; +import java.net.URI; +import java.net.http.HttpRequest; import static com.evolvedbinary.j8fu.tuple.Tuple.Tuple; -import static java.nio.charset.StandardCharsets.UTF_8; import static org.exist.http.urlrewrite.XQueryURLRewrite.LEGACY_XQUERY_CONTROLLER_FILENAME; import static org.exist.http.urlrewrite.XQueryURLRewrite.XQUERY_CONTROLLER_FILENAME; import static org.junit.Assert.assertEquals; @@ -62,7 +59,7 @@ public void findsLegacyController() throws IOException { // make a request and see if the legacy controller responds final Tuple2 responseCodeAndBody = get(testCollectionName, TEST_DOCUMENT_NAME); - assertEquals(HttpStatus.SC_OK, (int)responseCodeAndBody._1); + assertEquals(200, (int)responseCodeAndBody._1); assertEquals(LEGACY_CONTROLLER_XQUERY, responseCodeAndBody._2); } @@ -75,7 +72,7 @@ public void findsController() throws IOException { // make a request and see if the controller responds final Tuple2 responseCodeAndBody = get(testCollectionName, TEST_DOCUMENT_NAME); - assertEquals(HttpStatus.SC_OK, (int)responseCodeAndBody._1); + assertEquals(200, (int)responseCodeAndBody._1); assertEquals(CONTROLLER_XQUERY, responseCodeAndBody._2); } @@ -89,30 +86,30 @@ public void prefersNonLegacyController() throws IOException { // make a request and see if the (non-legacy) controller responds final Tuple2 responseCodeAndBody = get(testCollectionName, TEST_DOCUMENT_NAME); - assertEquals(HttpStatus.SC_OK, (int)responseCodeAndBody._1); + assertEquals(200, (int)responseCodeAndBody._1); assertEquals(CONTROLLER_XQUERY, responseCodeAndBody._2); } private void store(final String testCollectionName, final String documentMediaType, final String documentName, final String documentContent) throws IOException { - final Request request = Request - .Put(getRestUri(existWebServer) + "/db/apps/" + testCollectionName + "/" + documentName) - .bodyString(documentContent, ContentType.create(documentMediaType)); - int statusCode = withHttpExecutor(existWebServer, executor -> - executor.execute(request).returnResponse().getStatusLine().getStatusCode() - ); - assertEquals(HttpStatus.SC_CREATED, statusCode); + final HttpRequest request = authenticatedRequest( + URI.create(getRestUri(existWebServer) + "/db/apps/" + testCollectionName + "/" + documentName), + TestUtils.ADMIN_DB_USER, TestUtils.ADMIN_DB_PWD) + .header("Content-Type", documentMediaType) + .PUT(HttpRequest.BodyPublishers.ofString(documentContent)) + .build(); + int statusCode = withHttpClient(client -> executeForStatus(client, request)); + assertEquals(201, statusCode); } private Tuple2 get(final String testCollectionName, final String documentName) throws IOException { - final Request request = Request - .Get(getAppsUri(existWebServer) + "/" + testCollectionName + "/" + documentName); - final Tuple2 responseCodeAndBody = withHttpExecutor(existWebServer, executor -> { - final HttpResponse response = executor.execute(request).returnResponse(); - final int sc = response.getStatusLine().getStatusCode(); - try (final UnsynchronizedByteArrayOutputStream baos = new UnsynchronizedByteArrayOutputStream()) { - response.getEntity().writeTo(baos); - return Tuple(sc, baos.toString(UTF_8)); - } + final HttpRequest request = authenticatedRequest( + URI.create(getAppsUri(existWebServer) + "/" + testCollectionName + "/" + documentName), + TestUtils.ADMIN_DB_USER, TestUtils.ADMIN_DB_PWD) + .GET() + .build(); + final Tuple2 responseCodeAndBody = withHttpClient(client -> { + final HttpResponseResult r = executeForStatusAndBody(client, request); + return Tuple(r.statusCode(), r.body()); }); return responseCodeAndBody; } diff --git a/exist-core/src/test/java/org/exist/http/urlrewrite/URLRewriteViewPipelineTest.java b/exist-core/src/test/java/org/exist/http/urlrewrite/URLRewriteViewPipelineTest.java index 69ea6a67f0d..db6c67fd2c1 100644 --- a/exist-core/src/test/java/org/exist/http/urlrewrite/URLRewriteViewPipelineTest.java +++ b/exist-core/src/test/java/org/exist/http/urlrewrite/URLRewriteViewPipelineTest.java @@ -21,10 +21,7 @@ */ package org.exist.http.urlrewrite; -import org.apache.http.HttpResponse; -import org.apache.http.HttpStatus; -import org.apache.http.client.fluent.Request; -import org.apache.http.entity.ContentType; +import org.exist.http.AbstractHttpTest; import org.exist.test.ExistWebServer; import org.junit.AfterClass; import org.junit.BeforeClass; @@ -32,8 +29,12 @@ import org.junit.Test; import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpRequest; import java.nio.charset.StandardCharsets; +import static java.net.HttpURLConnection.HTTP_OK; import static org.junit.Assert.*; /** @@ -122,18 +123,22 @@ public static void setup() throws Exception { // Set execute permissions on XQuery files final String chmod = "sm:chmod(xs:anyURI('" + TEST_COLLECTION + "/controller.xq'), 'rwxr-xr-x')," + "sm:chmod(xs:anyURI('" + TEST_COLLECTION + "/view.xq'), 'rwxr-xr-x')"; - Request.Get("http://localhost:" + existWebServer.getPort() + "/exist/rest/db?_query=" + - java.net.URLEncoder.encode(chmod, "UTF-8") + "&_wrap=no") - .addHeader("Authorization", "Basic " + java.util.Base64.getEncoder().encodeToString("admin:".getBytes())) - .execute(); + final String chmodUrl = "http://localhost:" + existWebServer.getPort() + "/exist/rest/db?_query=" + + URLEncoder.encode(chmod, StandardCharsets.UTF_8) + "&_wrap=no"; + final HttpRequest chmodRequest = AbstractHttpTest.authenticatedRequest(URI.create(chmodUrl), "admin", "") + .GET() + .build(); + AbstractHttpTest.executeForStatus(AbstractHttpTest.newHttpClient(), chmodRequest); } @AfterClass public static void teardown() throws Exception { // Remove test collection via REST - Request.Delete("http://localhost:" + existWebServer.getPort() + "/exist/rest" + TEST_COLLECTION) - .addHeader("Authorization", "Basic " + java.util.Base64.getEncoder().encodeToString("admin:".getBytes())) - .execute(); + final String deleteUrl = "http://localhost:" + existWebServer.getPort() + "/exist/rest" + TEST_COLLECTION; + final HttpRequest deleteRequest = AbstractHttpTest.authenticatedRequest(URI.create(deleteUrl), "admin", "") + .DELETE() + .build(); + AbstractHttpTest.executeForStatus(AbstractHttpTest.newHttpClient(), deleteRequest); } /** @@ -146,14 +151,15 @@ public void htmlWithHeadThroughViewPipeline() throws IOException { final String url = "http://localhost:" + existWebServer.getPort() + "/exist/apps/test-url-rewrite/with-head.html"; - final HttpResponse response = Request.Get(url).execute().returnResponse(); - final int status = response.getStatusLine().getStatusCode(); - final String body = new String( - response.getEntity().getContent().readAllBytes(), StandardCharsets.UTF_8); + final HttpRequest request = HttpRequest.newBuilder(URI.create(url)).GET().build(); + final AbstractHttpTest.HttpResponseResult result = + AbstractHttpTest.executeForStatusAndBody(AbstractHttpTest.newHttpClient(), request); + final int status = result.statusCode(); + final String body = result.body(); // Should return 200, not 400 (namespace error) or 500 (XPTY0019) assertEquals("Expected 200 OK but got " + status + ": " + body.substring(0, Math.min(200, body.length())), - HttpStatus.SC_OK, status); + HTTP_OK, status); // The response should contain the original title from the source HTML assertTrue("Response should contain the source page's title", @@ -180,22 +186,24 @@ public void htmlWithoutHeadThroughViewPipeline() throws IOException { final String url = "http://localhost:" + existWebServer.getPort() + "/exist/apps/test-url-rewrite/no-head.html"; - final HttpResponse response = Request.Get(url).execute().returnResponse(); - final int status = response.getStatusLine().getStatusCode(); + final HttpRequest request = HttpRequest.newBuilder(URI.create(url)).GET().build(); + final AbstractHttpTest.HttpResponseResult result = + AbstractHttpTest.executeForStatusAndBody(AbstractHttpTest.newHttpClient(), request); + final int status = result.statusCode(); - assertEquals(HttpStatus.SC_OK, status); + assertEquals(HTTP_OK, status); - final String body = new String( - response.getEntity().getContent().readAllBytes(), StandardCharsets.UTF_8); + final String body = result.body(); assertTrue("Response should contain body content", body.contains("Hello World")); } private static void storeViaRest(final String url, final String content, final String contentType) throws IOException { - Request.Put(url) - .addHeader("Authorization", "Basic " + java.util.Base64.getEncoder().encodeToString("admin:".getBytes())) - .bodyString(content, ContentType.create(contentType, StandardCharsets.UTF_8)) - .execute(); + final HttpRequest request = AbstractHttpTest.authenticatedRequest(URI.create(url), "admin", "") + .header("Content-Type", contentType + "; charset=UTF-8") + .PUT(HttpRequest.BodyPublishers.ofString(content, StandardCharsets.UTF_8)) + .build(); + AbstractHttpTest.executeForStatus(AbstractHttpTest.newHttpClient(), request); } } diff --git a/exist-core/src/test/java/org/exist/http/urlrewrite/URLRewritingTest.java b/exist-core/src/test/java/org/exist/http/urlrewrite/URLRewritingTest.java index d52ec673547..a41c497bd4f 100644 --- a/exist-core/src/test/java/org/exist/http/urlrewrite/URLRewritingTest.java +++ b/exist-core/src/test/java/org/exist/http/urlrewrite/URLRewritingTest.java @@ -23,13 +23,9 @@ package org.exist.http.urlrewrite; import com.evolvedbinary.j8fu.tuple.Tuple2; -import org.apache.http.HttpResponse; -import org.apache.http.HttpStatus; -import org.apache.http.client.fluent.Request; -import org.apache.http.entity.ContentType; +import org.exist.TestUtils; import org.exist.http.AbstractHttpTest; import org.exist.test.ExistWebServer; -import org.apache.commons.io.output.UnsynchronizedByteArrayOutputStream; import org.exist.xmldb.XmldbURI; import org.junit.AfterClass; import org.junit.BeforeClass; @@ -37,9 +33,12 @@ import org.junit.Test; import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.http.HttpRequest; +import java.nio.charset.StandardCharsets; import static com.evolvedbinary.j8fu.tuple.Tuple.Tuple; -import static java.nio.charset.StandardCharsets.UTF_8; import static org.exist.http.urlrewrite.XQueryURLRewrite.XQUERY_CONTROLLER_FILENAME; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; @@ -64,50 +63,50 @@ public void findsParentController() throws IOException { final String testDocument = "world"; final String storeDocUri = getRestUri(existWebServer) + TEST_COLLECTION.append(nestedCollectionName).append(docName); - final Request storeRequest = Request - .Put(storeDocUri) - .bodyString(testDocument, ContentType.APPLICATION_XML); - final int storeResponseStatusCode = withHttpExecutor(existWebServer, executor -> executor.execute(storeRequest).returnResponse().getStatusLine().getStatusCode()); - assertEquals(HttpStatus.SC_CREATED, storeResponseStatusCode); + final HttpRequest storeRequest = authenticatedRequest(URI.create(storeDocUri), + TestUtils.ADMIN_DB_USER, TestUtils.ADMIN_DB_PWD) + .header("Content-Type", "application/xml") + .PUT(HttpRequest.BodyPublishers.ofString(testDocument, StandardCharsets.UTF_8)) + .build(); + final int storeResponseStatusCode = withHttpClient(client -> executeForStatus(client, storeRequest)); + assertEquals(HttpURLConnection.HTTP_CREATED, storeResponseStatusCode); final String retrieveDocUri = getAppsUri(existWebServer) + "/" + TEST_COLLECTION_NAME.append(nestedCollectionName).append(docName); - final Request retrieveRequest = Request - .Get(retrieveDocUri); - final Tuple2 retrieveResponseStatusCodeAndBody = withHttpExecutor(existWebServer, executor -> { - final HttpResponse response = executor.execute(retrieveRequest).returnResponse(); - final String responseBody; - try (final UnsynchronizedByteArrayOutputStream baos = new UnsynchronizedByteArrayOutputStream((int)response.getEntity().getContentLength())) { - response.getEntity().writeTo(baos); - responseBody = baos.toString(UTF_8); - } - return Tuple(response.getStatusLine().getStatusCode(), responseBody); + final HttpRequest retrieveRequest = authenticatedRequest(URI.create(retrieveDocUri), + TestUtils.ADMIN_DB_USER, TestUtils.ADMIN_DB_PWD) + .GET() + .build(); + final Tuple2 retrieveResponseStatusCodeAndBody = withHttpClient(client -> { + final HttpResponseResult r = executeForStatusAndBody(client, retrieveRequest); + return Tuple(r.statusCode(), r.body()); }); - assertEquals(HttpStatus.SC_OK, retrieveResponseStatusCodeAndBody._1.intValue()); + assertEquals(HttpURLConnection.HTTP_OK, retrieveResponseStatusCodeAndBody._1.intValue()); assertTrue(retrieveResponseStatusCodeAndBody._2.matches(".+")); } @BeforeClass public static void setup() throws IOException { - final Request request = Request - .Put(getRestUri(existWebServer) + TEST_COLLECTION + "/" + XQUERY_CONTROLLER_FILENAME) - .bodyString(TEST_CONTROLLER, ContentType.create("application/xquery")); + final HttpRequest request = authenticatedRequest( + URI.create(getRestUri(existWebServer) + TEST_COLLECTION + "/" + XQUERY_CONTROLLER_FILENAME), + TestUtils.ADMIN_DB_USER, TestUtils.ADMIN_DB_PWD) + .header("Content-Type", "application/xquery") + .PUT(HttpRequest.BodyPublishers.ofString(TEST_CONTROLLER, StandardCharsets.UTF_8)) + .build(); - final int statusCode = withHttpExecutor(existWebServer, executor -> - executor.execute(request).returnResponse().getStatusLine().getStatusCode() - ); + final int statusCode = withHttpClient(client -> executeForStatus(client, request)); - assertEquals(HttpStatus.SC_CREATED, statusCode); + assertEquals(HttpURLConnection.HTTP_CREATED, statusCode); } @AfterClass public static void cleanup() throws IOException { - final Request request = Request - .Delete(getRestUri(existWebServer) + TEST_COLLECTION); + final HttpRequest request = authenticatedRequest(URI.create(getRestUri(existWebServer) + TEST_COLLECTION), + TestUtils.ADMIN_DB_USER, TestUtils.ADMIN_DB_PWD) + .DELETE() + .build(); - final int statusCode = withHttpExecutor(existWebServer, executor -> - executor.execute(request).returnResponse().getStatusLine().getStatusCode() - ); + final int statusCode = withHttpClient(client -> executeForStatus(client, request)); - assertEquals(HttpStatus.SC_OK, statusCode); + assertEquals(HttpURLConnection.HTTP_OK, statusCode); } } diff --git a/exist-core/src/test/java/org/exist/management/JmxRemoteTest.java b/exist-core/src/test/java/org/exist/management/JmxRemoteTest.java index 03067e5dca4..76511dcacab 100644 --- a/exist-core/src/test/java/org/exist/management/JmxRemoteTest.java +++ b/exist-core/src/test/java/org/exist/management/JmxRemoteTest.java @@ -21,23 +21,18 @@ */ package org.exist.management; -import com.evolvedbinary.j8fu.function.FunctionE; import com.evolvedbinary.j8fu.tuple.Tuple2; import org.apache.commons.lang3.SystemUtils; -import org.apache.http.HttpResponse; -import org.apache.http.HttpStatus; -import org.apache.http.client.HttpClient; -import org.apache.http.client.fluent.Executor; -import org.apache.http.client.fluent.Request; -import org.apache.http.entity.ContentType; -import org.apache.http.impl.client.CloseableHttpClient; -import org.apache.http.impl.client.HttpClientBuilder; -import org.apache.http.message.BasicHeader; +import org.exist.http.AbstractHttpTest; import org.exist.test.ExistWebServer; import org.junit.ClassRule; import org.junit.Test; import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; import java.util.HashMap; import java.util.Map; @@ -49,7 +44,7 @@ import static org.junit.Assume.assumeTrue; import static org.xmlunit.matchers.HasXPathMatcher.hasXPath; -public class JmxRemoteTest { +public class JmxRemoteTest extends AbstractHttpTest { @ClassRule public static final ExistWebServer existWebServer = new ExistWebServer(true, false, true, true, false); @@ -61,8 +56,9 @@ private static String getServerUri() { @Test public void checkContent() throws IOException { // Get content - final Request request = Request.Get(getServerUri()); - final String jmxXml = withHttpExecutor(executor -> executor.execute(request).returnContent().asString()); + final HttpRequest request = HttpRequest.newBuilder(URI.create(getServerUri())).GET().build(); + final String jmxXml = withHttpClient(client -> + AbstractHttpTest.executeForStatusAndBody(client, request).body()); // Prepare XPath validation final Map prefix2Uri = new HashMap<>(); @@ -103,8 +99,9 @@ public void checkContent() throws IOException { @Test public void vectorCategoryIncludesVectorStore() throws IOException { - final Request request = Request.Get(getServerUri() + "?c=vector"); - final String jmxXml = withHttpExecutor(executor -> executor.execute(request).returnContent().asString()); + final HttpRequest request = HttpRequest.newBuilder(URI.create(getServerUri() + "?c=vector")).GET().build(); + final String jmxXml = withHttpClient(client -> + AbstractHttpTest.executeForStatusAndBody(client, request).body()); final Map prefix2Uri = new HashMap<>(); prefix2Uri.put(JMX_PREFIX, JMX_NAMESPACE); @@ -121,8 +118,9 @@ public void vectorCategoryIncludesVectorStore() throws IOException { public void vectorCategoryIncludesVectorEmbeddingWhenExtensionPresent() throws IOException { assumeTrue("Vector extension not on classpath", isVectorExtensionPresent()); - final Request request = Request.Get(getServerUri() + "?c=vector"); - final String jmxXml = withHttpExecutor(executor -> executor.execute(request).returnContent().asString()); + final HttpRequest request = HttpRequest.newBuilder(URI.create(getServerUri() + "?c=vector")).GET().build(); + final String jmxXml = withHttpClient(client -> + AbstractHttpTest.executeForStatusAndBody(client, request).body()); final Map prefix2Uri = new HashMap<>(); prefix2Uri.put(JMX_PREFIX, JMX_NAMESPACE); @@ -134,33 +132,29 @@ public void vectorCategoryIncludesVectorEmbeddingWhenExtensionPresent() throws I @Test public void checkBasicRequest() throws IOException { - final Request request = Request.Get(getServerUri()) - .addHeader(new BasicHeader("Accept", ContentType.APPLICATION_XML.toString())); - - final Tuple2 codeAndMediaType = withHttpExecutor(executor -> { - final HttpResponse response = executor.execute(request).returnResponse(); - return Tuple(response.getStatusLine().getStatusCode(), response.getEntity().getContentType().getValue()); + final HttpRequest request = HttpRequest.newBuilder(URI.create(getServerUri())) + .header("Accept", "application/xml") + .GET() + .build(); + + final Tuple2 codeAndMediaType = withHttpClient(client -> { + final HttpResponse response = send(client, request, HttpResponse.BodyHandlers.discarding()); + return Tuple(response.statusCode(), response.headers().firstValue("Content-Type").orElse(null)); }); - assertEquals(Tuple(HttpStatus.SC_OK, "application/xml"), codeAndMediaType); + assertEquals(Tuple(200, "application/xml"), codeAndMediaType); } - private static T withHttpClient(final FunctionE fn) throws IOException { - try (final CloseableHttpClient client = HttpClientBuilder - .create() - .disableAutomaticRetries() - .build()) { - return fn.apply(client); + private static HttpResponse send(final HttpClient client, final HttpRequest request, + final HttpResponse.BodyHandler bodyHandler) throws IOException { + try { + return client.send(request, bodyHandler); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while awaiting HTTP response", e); } } - private static T withHttpExecutor(final FunctionE fn) throws IOException { - return withHttpClient(client -> { - final Executor executor = Executor.newInstance(client); - return fn.apply(executor); - }); - } - private static boolean isVectorExtensionPresent() { try { Class.forName("org.exist.vector.VectorExtensionLifecycle"); diff --git a/exist-core/src/test/java/org/exist/security/RestApiSecurityTest.java b/exist-core/src/test/java/org/exist/security/RestApiSecurityTest.java index 13efb866538..a52feb169b5 100644 --- a/exist-core/src/test/java/org/exist/security/RestApiSecurityTest.java +++ b/exist-core/src/test/java/org/exist/security/RestApiSecurityTest.java @@ -22,19 +22,20 @@ package org.exist.security; import java.io.IOException; -import java.io.UnsupportedEncodingException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.nio.charset.StandardCharsets; import java.net.URLEncoder; -import org.apache.http.HttpEntity; -import org.apache.http.HttpHost; -import org.apache.http.HttpResponse; -import org.apache.http.HttpStatus; -import org.apache.http.client.fluent.Executor; -import org.apache.http.client.fluent.Request; +import org.exist.http.AbstractHttpTest; +import org.exist.http.AbstractHttpTest.HttpResponseResult; import org.exist.test.ExistWebServer; -import org.apache.commons.io.output.UnsynchronizedByteArrayOutputStream; import org.junit.ClassRule; +import static java.net.HttpURLConnection.HTTP_CREATED; +import static java.net.HttpURLConnection.HTTP_OK; + /** * * @author Adam Retter @@ -58,15 +59,16 @@ protected void createCol(final String collectionName, final String uid, final St @Override protected void removeCol(final String collectionName, final String uid, final String pwd) throws ApiException { final String collectionUri = getServerUri() + baseUri + "/" + collectionName; - - final Executor exec = getExecutor(uid, pwd); + try { - final HttpResponse resp = exec.execute(Request.Delete(collectionUri)).returnResponse(); - - if(resp.getStatusLine().getStatusCode() != HttpStatus.SC_OK) { - throw new ApiException("Could not remove collection: " + collectionUri + ". " + getResponseBody(resp.getEntity())); + final HttpRequest request = AbstractHttpTest.authenticatedRequest(URI.create(collectionUri), uid, pwd) + .DELETE() + .build(); + final HttpResponseResult result = AbstractHttpTest.executeForStatusAndBody(newHttpClient(), request); + if (result.statusCode() != HTTP_OK) { + throw new ApiException("Could not remove collection: " + collectionUri + ". " + result.body()); } - } catch(final IOException ioe) { + } catch (final IOException ioe) { throw new ApiException(ioe); } } @@ -101,16 +103,18 @@ protected void addCollectionUserAce(final String collectionUri, final String use @Override protected String getXmlResourceContent(final String resourceUri, final String uid, final String pwd) throws ApiException { - final Executor exec = getExecutor(uid, pwd); try { - final HttpResponse resp = exec.execute(Request.Get(getServerUri() + resourceUri)).returnResponse(); - - if(resp.getStatusLine().getStatusCode() != HttpStatus.SC_OK) { - throw new ApiException("Could not get XML resource from uri: " + resourceUri + ". " + getResponseBody(resp.getEntity())); + final String uri = getServerUri() + resourceUri; + final HttpRequest request = AbstractHttpTest.authenticatedRequest(URI.create(uri), uid, pwd) + .GET() + .build(); + final HttpResponseResult result = AbstractHttpTest.executeForStatusAndBody(newHttpClient(), request); + if (result.statusCode() != HTTP_OK) { + throw new ApiException("Could not get XML resource from uri: " + resourceUri + ". " + result.body()); } else { - return getResponseBody(resp.getEntity()); + return result.body(); } - } catch(final IOException ioe) { + } catch (final IOException ioe) { throw new ApiException(ioe); } } @@ -137,68 +141,60 @@ protected void createGroup(final String group_gid, final String uid, final Strin @Override protected void createXmlResource(final String resourceUri, final String content, final String uid, final String pwd) throws ApiException { - final Executor exec = getExecutor(uid, pwd); try { - final HttpResponse resp = exec.execute( - Request.Put(getServerUri() + resourceUri) - .addHeader("Content-Type", "application/xml") - .bodyByteArray(content.getBytes()) - ).returnResponse(); - - if(resp.getStatusLine().getStatusCode() != HttpStatus.SC_CREATED) { - throw new ApiException("Could not store XML resource to uri: " + resourceUri + ". " + getResponseBody(resp.getEntity())); + final String uri = getServerUri() + resourceUri; + final HttpRequest request = AbstractHttpTest.authenticatedRequest(URI.create(uri), uid, pwd) + .header("Content-Type", "application/xml") + .PUT(HttpRequest.BodyPublishers.ofByteArray(content.getBytes())) + .build(); + final HttpResponseResult result = AbstractHttpTest.executeForStatusAndBody(newHttpClient(), request); + if (result.statusCode() != HTTP_CREATED) { + throw new ApiException("Could not store XML resource to uri: " + resourceUri + ". " + result.body()); } - } catch(final IOException ioe) { + } catch (final IOException ioe) { throw new ApiException(ioe); } } @Override protected void createBinResource(final String resourceUri, final byte[] content, final String uid, final String pwd) throws ApiException { - final Executor exec = getExecutor(uid, pwd); try { - final HttpResponse resp = exec.execute( - Request.Put(getServerUri() + resourceUri) - .addHeader("Content-Type", "application/octet-stream") - .bodyByteArray(content) - ).returnResponse(); - - if(resp.getStatusLine().getStatusCode() != HttpStatus.SC_CREATED) { - throw new ApiException("Could not store Binary resource to uri: " + resourceUri + ". " + getResponseBody(resp.getEntity())); + final String uri = getServerUri() + resourceUri; + final HttpRequest request = AbstractHttpTest.authenticatedRequest(URI.create(uri), uid, pwd) + .header("Content-Type", "application/octet-stream") + .PUT(HttpRequest.BodyPublishers.ofByteArray(content)) + .build(); + final HttpResponseResult result = AbstractHttpTest.executeForStatusAndBody(newHttpClient(), request); + if (result.statusCode() != HTTP_CREATED) { + throw new ApiException("Could not store Binary resource to uri: " + resourceUri + ". " + result.body()); } - } catch(final IOException ioe) { + } catch (final IOException ioe) { throw new ApiException(ioe); } } private void executeQuery(final String xquery, final String uid, final String pwd) throws ApiException { - final Executor exec = getExecutor(uid, pwd); try { final String queryUri = createQueryUri(xquery); - - final HttpResponse resp = exec.execute(Request.Get(queryUri)).returnResponse(); - final int status = resp.getStatusLine().getStatusCode(); - if(status != HttpStatus.SC_OK) { - throw new ApiException("HTTP " + status + " could not execute query uri: " + queryUri + ". " + getResponseBody(resp.getEntity())); + final HttpRequest request = AbstractHttpTest.authenticatedRequest(URI.create(queryUri), uid, pwd) + .GET() + .build(); + final HttpResponseResult result = AbstractHttpTest.executeForStatusAndBody(newHttpClient(), request); + final int status = result.statusCode(); + if (status != HTTP_OK) { + throw new ApiException("HTTP " + status + " could not execute query uri: " + queryUri + ". " + result.body()); } - } catch(final IOException ioe) { + } catch (final IOException ioe) { throw new ApiException(ioe); } } - - private Executor getExecutor(final String uid, String pwd) { - return Executor.newInstance().authPreemptive(new HttpHost("localhost", existWebServer.getPort())).auth(uid, pwd); - } - - private String createQueryUri(final String xquery) throws UnsupportedEncodingException { - return getServerUri() + baseUri + "/?_query=" + URLEncoder.encode(xquery, "UTF-8"); + + private static HttpClient newHttpClient() { + return AbstractHttpTest.newHttpClient(); } - - private String getResponseBody(final HttpEntity entity) throws IOException { - try(final UnsynchronizedByteArrayOutputStream baos = new UnsynchronizedByteArrayOutputStream(256)) { - entity.writeTo(baos); - return new String(baos.toByteArray()); - } + + private String createQueryUri(final String xquery) { + return getServerUri() + baseUri + "/?_query=" + URLEncoder.encode(xquery, StandardCharsets.UTF_8); } } diff --git a/exist-core/src/test/java/org/exist/xmlrpc/MoveResourceTest.java b/exist-core/src/test/java/org/exist/xmlrpc/MoveResourceTest.java index 5d0dce40dad..16254a19445 100644 --- a/exist-core/src/test/java/org/exist/xmlrpc/MoveResourceTest.java +++ b/exist-core/src/test/java/org/exist/xmlrpc/MoveResourceTest.java @@ -21,17 +21,11 @@ */ package org.exist.xmlrpc; -import org.apache.http.HttpResponse; -import org.apache.http.HttpStatus; -import org.apache.http.client.fluent.Executor; -import org.apache.http.client.fluent.Request; -import org.apache.http.impl.client.CloseableHttpClient; -import org.apache.http.impl.client.HttpClients; -import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; import org.apache.xmlrpc.XmlRpcException; import org.apache.xmlrpc.client.XmlRpcClient; import org.apache.xmlrpc.client.XmlRpcClientConfigImpl; import org.exist.TestUtils; +import org.exist.http.AbstractHttpTest; import org.exist.test.ExistWebServer; import org.exist.util.io.InputStreamUtil; import org.exist.xmldb.XmldbURI; @@ -39,8 +33,11 @@ import java.io.IOException; import java.io.InputStream; import java.net.MalformedURLException; +import java.net.URI; import java.net.URL; import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -48,6 +45,7 @@ import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; +import org.junit.AfterClass; import org.junit.ClassRule; import org.junit.Test; @@ -74,6 +72,11 @@ public class MoveResourceTest { @ClassRule public static final ExistWebServer existWebServer = new ExistWebServer(true, false, true, true); + @AfterClass + public static void closeHttpConnectionManager() { + CheckThread.closeConnectionManager(); + } + private static String getXmlRpcUri() { return "http://localhost:" + existWebServer.getPort() + "/xmlrpc"; } @@ -199,7 +202,13 @@ private String readSample() throws IOException { } private static class CheckThread implements Callable { - private static final PoolingHttpClientConnectionManager poolingHttpClientConnectionManager = new PoolingHttpClientConnectionManager(); + private static final int HTTP_OK = 200; + private static final HttpClient httpClient = AbstractHttpTest.newHttpClient(); + + static void closeConnectionManager() { + // The JDK HttpClient has no connection manager to close; retained for API compatibility. + } + private final int iterations; public CheckThread(final int iterations) { @@ -208,30 +217,22 @@ public CheckThread(final int iterations) { @Override public Boolean call() throws IOException, InterruptedException { - final CloseableHttpClient client = HttpClients - .custom() - .setConnectionManager(poolingHttpClientConnectionManager) - .build(); - final org.apache.http.client.fluent.Executor executor = Executor.newInstance(client); - final String reqUrl = getRestUri() + "/db?_query=" + URLEncoder.encode("collection('/db')//SPEECH[SPEAKER = 'JULIET']", "UTF-8"); - final Request request = Request.Get(reqUrl); + final HttpRequest request = HttpRequest.newBuilder(URI.create(reqUrl)).GET().build(); for (int i = 0; i < iterations; i++) { - HttpResponse response = null; int lastStatus = -1; for (int r = 0; r <= REST_RETRY_MAX; r++) { - response = executor.execute(request).returnResponse(); - lastStatus = response.getStatusLine().getStatusCode(); - if (lastStatus == HttpStatus.SC_OK) { + lastStatus = AbstractHttpTest.executeForStatus(httpClient, request); + if (lastStatus == HTTP_OK) { break; } if (lastStatus < 500 || r == REST_RETRY_MAX) { - fail("REST query failed" + (r > 0 ? " after " + r + " retries" : "") + ": " + response.getStatusLine()); + fail("REST query failed" + (r > 0 ? " after " + r + " retries" : "") + ": HTTP " + lastStatus); } Thread.sleep(REST_RETRY_DELAY_MS); } - assertEquals(response.getStatusLine().toString(), HttpStatus.SC_OK, response.getStatusLine().getStatusCode()); + assertEquals("HTTP " + lastStatus, HTTP_OK, lastStatus); Thread.sleep(DELAY); } diff --git a/exist-core/src/test/java/org/exist/xquery/RestBinariesTest.java b/exist-core/src/test/java/org/exist/xquery/RestBinariesTest.java index a2e6d4d9467..2825e9448d1 100644 --- a/exist-core/src/test/java/org/exist/xquery/RestBinariesTest.java +++ b/exist-core/src/test/java/org/exist/xquery/RestBinariesTest.java @@ -24,13 +24,8 @@ import org.apache.commons.codec.binary.Base64; import org.apache.commons.codec.binary.Hex; -import org.apache.http.Header; -import org.apache.http.HttpEntity; -import org.apache.http.HttpHost; -import org.apache.http.HttpResponse; -import org.apache.http.client.fluent.Executor; -import org.apache.http.client.fluent.Request; -import org.apache.http.entity.ContentType; +import org.apache.commons.io.input.UnsynchronizedByteArrayInputStream; +import org.exist.http.AbstractHttpTest; import org.exist.http.jaxb.Query; import org.exist.http.jaxb.Result; import org.exist.test.ExistWebServer; @@ -47,9 +42,13 @@ import jakarta.xml.bind.Unmarshaller; import java.io.IOException; import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; -import static org.apache.http.HttpStatus.SC_CREATED; -import static org.apache.http.HttpStatus.SC_OK; +import static java.net.HttpURLConnection.HTTP_CREATED; +import static java.net.HttpURLConnection.HTTP_OK; import static org.exist.TestUtils.ADMIN_DB_PWD; import static org.exist.TestUtils.ADMIN_DB_USER; import static org.junit.Assert.assertArrayEquals; @@ -64,13 +63,11 @@ public class RestBinariesTest extends AbstractBinariesTest response = postXquery(query); - final HttpEntity entity = response.getEntity(); - try(final UnsynchronizedByteArrayOutputStream baos = new UnsynchronizedByteArrayOutputStream()) { - entity.writeTo(baos); - - assertArrayEquals(BIN1_CONTENT, baos.toByteArray()); - } + assertArrayEquals(BIN1_CONTENT, response.body()); } /** @@ -146,28 +124,24 @@ public void streamBinaryResourceWithFilename() throws JAXBException, IOException final String query = "import module namespace response = \"http://exist-db.org/xquery/response\";\n" + "response:stream-binary-resource('" + TEST_COLLECTION.append(BIN1_FILENAME).toString() + "', 'application/octet-stream', 'download.bin')"; - final HttpResponse response = postXquery(query); + final HttpResponse response = postXquery(query); - final Header contentDisposition = response.getFirstHeader("Content-Disposition"); + final String contentDisposition = response.headers().firstValue("Content-Disposition").orElse(null); assertNotNull("Content-Disposition header should be sent for the 3-arg form", contentDisposition); - assertEquals("inline; filename=\"download.bin\"", contentDisposition.getValue()); + assertEquals("inline; filename=\"download.bin\"", contentDisposition); - final HttpEntity entity = response.getEntity(); - try(final UnsynchronizedByteArrayOutputStream baos = new UnsynchronizedByteArrayOutputStream()) { - entity.writeTo(baos); - - assertArrayEquals(BIN1_CONTENT, baos.toByteArray()); - } + assertArrayEquals(BIN1_CONTENT, response.body()); } @Override protected void storeBinaryFile(final XmldbURI filePath, final byte[] content) throws Exception { - final HttpResponse response = executor.execute(Request.Put(getRestUrl() + filePath.toString()) - .setHeader("Content-Type", "application/octet-stream") - .bodyByteArray(content) - ).returnResponse(); + final HttpRequest request = AbstractHttpTest.authenticatedRequest(URI.create(getRestUrl() + filePath.toString()), ADMIN_DB_USER, ADMIN_DB_PWD) + .header("Content-Type", "application/octet-stream") + .PUT(HttpRequest.BodyPublishers.ofByteArray(content)) + .build(); - if(response.getStatusLine().getStatusCode() != SC_CREATED) { + final int status = AbstractHttpTest.executeForStatus(client, request); + if (status != HTTP_CREATED) { throw new Exception("Unable to store binary file: " + filePath); } } @@ -178,47 +152,65 @@ private String getRestUrl() { @Override protected void removeCollection(final XmldbURI collectionUri) throws Exception { - final HttpResponse response = executor.execute(Request.Delete(getRestUrl() + collectionUri.toString())) - .returnResponse(); + final HttpRequest request = AbstractHttpTest.authenticatedRequest(URI.create(getRestUrl() + collectionUri.toString()), ADMIN_DB_USER, ADMIN_DB_PWD) + .DELETE() + .build(); - if(response.getStatusLine().getStatusCode() != SC_OK) { + final int status = AbstractHttpTest.executeForStatus(client, request); + if (status != HTTP_OK) { throw new Exception("Unable to delete collection: " + collectionUri); } } @Override protected QueryResultAccessor executeXQuery(final String xquery) throws Exception { - final HttpResponse response = postXquery(xquery); - final HttpEntity entity = response.getEntity(); - try(final InputStream is = entity.getContent()) { + final byte[] xmlBytes = postXqueryBody(xquery); + try (final InputStream is = new UnsynchronizedByteArrayInputStream(xmlBytes)) { final JAXBContext jaxbContext = JAXBContext.newInstance("org.exist.http.jaxb"); final Unmarshaller unmarshaller = jaxbContext.createUnmarshaller(); - final Result result = (Result)unmarshaller.unmarshal(is); + final Result result = (Result) unmarshaller.unmarshal(is); return consumer -> consumer.accept(result); } } - private HttpResponse postXquery(final String xquery) throws JAXBException, IOException { + /** + * POSTs the XQuery to the REST endpoint and returns the (fully buffered) HTTP response, so callers + * can inspect status, headers, and the body. The response body is read into a byte array, so the + * returned response carries no live resources. + */ + private HttpResponse postXquery(final String xquery) throws JAXBException, IOException { final Query query = new Query(); query.setText(xquery); final JAXBContext jaxbContext = JAXBContext.newInstance("org.exist.http.jaxb"); final Marshaller marshaller = jaxbContext.createMarshaller(); - final HttpResponse response; - try(final UnsynchronizedByteArrayOutputStream baos = new UnsynchronizedByteArrayOutputStream()) { + try (final UnsynchronizedByteArrayOutputStream baos = new UnsynchronizedByteArrayOutputStream()) { marshaller.marshal(query, baos); - response = executor.execute(Request.Post(getRestUrl() + "/db/") - .bodyByteArray(baos.toByteArray(), ContentType.APPLICATION_XML) - ).returnResponse(); + final HttpRequest request = AbstractHttpTest.authenticatedRequest(URI.create(getRestUrl() + "/db/"), ADMIN_DB_USER, ADMIN_DB_PWD) + .header("Content-Type", "application/xml") + .POST(HttpRequest.BodyPublishers.ofByteArray(baos.toByteArray())) + .build(); + try { + return client.send(request, HttpResponse.BodyHandlers.ofByteArray()); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while awaiting HTTP response", e); + } } + } - if(response.getStatusLine().getStatusCode() != SC_OK) { - throw new IOException("Unable to query, HTTP response code: " + response.getStatusLine().getStatusCode()); + private byte[] postXqueryBody(final String xquery) throws JAXBException, IOException { + final HttpResponse response = postXquery(xquery); + if (response.statusCode() != HTTP_OK) { + throw new IOException("Unable to query, HTTP response code: " + response.statusCode()); } - - return response; + final byte[] body = response.body(); + if (body == null) { + return new byte[0]; + } + return body; } @Override diff --git a/exist-core/src/test/java/org/exist/xquery/functions/request/GetDataTest.java b/exist-core/src/test/java/org/exist/xquery/functions/request/GetDataTest.java index 8bb7f535d37..11ee4995f7b 100644 --- a/exist-core/src/test/java/org/exist/xquery/functions/request/GetDataTest.java +++ b/exist-core/src/test/java/org/exist/xquery/functions/request/GetDataTest.java @@ -22,15 +22,13 @@ package org.exist.xquery.functions.request; import org.apache.commons.io.input.UnsynchronizedByteArrayInputStream; -import org.apache.commons.io.output.UnsynchronizedByteArrayOutputStream; -import org.apache.http.HttpResponse; -import org.apache.http.HttpStatus; -import org.apache.http.HttpVersion; -import org.apache.http.client.fluent.Request; -import org.apache.http.entity.ContentType; import org.exist.xmldb.UserManagementService; import java.io.IOException; import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; import org.exist.http.RESTTest; import org.exist.xmldb.EXistResource; @@ -39,6 +37,8 @@ import org.junit.Ignore; import org.junit.Test; +import static java.net.HttpURLConnection.HTTP_OK; +import static java.net.HttpURLConnection.HTTP_VERSION; import static java.nio.charset.StandardCharsets.UTF_8; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertArrayEquals; @@ -82,33 +82,38 @@ public static void afterClass() throws XMLDBException { @Test public void retrieveEmpty() throws IOException { - Request post = Request.Post(getCollectionRootUri() + "/" + XQUERY_FILENAME) - .addHeader("Content-Type", "application/octet-stream"); + final HttpRequest post = HttpRequest.newBuilder(URI.create(getCollectionRootUri() + "/" + XQUERY_FILENAME)) + .header("Content-Type", "application/octet-stream") + .POST(HttpRequest.BodyPublishers.noBody()) + .build(); testRequest(post, wrapInElement("").getBytes()); } - - @Ignore("Jetty 12 rejects HTTP/0.9 but corrupts the Apache HttpClient connection pool, causing NoHttpResponseException in subsequent tests") + + @Ignore("Jetty 12 rejects HTTP/0.9, which the JDK HttpClient cannot express") @Test public void retrieveBinaryHttp09() throws IOException { final String testData = "12345"; - final Request post = Request.Post(getCollectionRootUri() + "/" + XQUERY_FILENAME) - .version(HttpVersion.HTTP_0_9) - .bodyByteArray(testData.getBytes(UTF_8), ContentType.APPLICATION_OCTET_STREAM); + final HttpRequest post = HttpRequest.newBuilder(URI.create(getCollectionRootUri() + "/" + XQUERY_FILENAME)) + .version(HttpClient.Version.HTTP_1_1) + .header("Content-Type", "application/octet-stream") + .POST(HttpRequest.BodyPublishers.ofByteArray(testData.getBytes(UTF_8))) + .build(); - final HttpResponse response = post.execute().returnResponse(); - assertEquals(HttpStatus.SC_HTTP_VERSION_NOT_SUPPORTED, response.getStatusLine().getStatusCode()); + assertEquals(HTTP_VERSION, executeForStatus(newHttpClient(), post)); } - @Ignore("Jetty 12 drops the connection on HTTP/1.0 without a response, causing NoHttpResponseException in Apache HttpClient") + @Ignore("Jetty 12 drops the connection on HTTP/1.0 without a response, which the JDK HttpClient cannot express") @Test public void retrieveBinaryHttp10() throws IOException { final String testData = "12345"; - final Request post = Request.Post(getCollectionRootUri() + "/" + XQUERY_FILENAME) - .version(HttpVersion.HTTP_1_0) - .bodyByteArray(testData.getBytes(UTF_8), ContentType.APPLICATION_OCTET_STREAM); + final HttpRequest post = HttpRequest.newBuilder(URI.create(getCollectionRootUri() + "/" + XQUERY_FILENAME)) + .version(HttpClient.Version.HTTP_1_1) + .header("Content-Type", "application/octet-stream") + .POST(HttpRequest.BodyPublishers.ofByteArray(testData.getBytes(UTF_8))) + .build(); testRequest(post, wrapInElement(encodeBase64String(testData.getBytes(UTF_8)).trim()).getBytes()); } @@ -117,9 +122,11 @@ public void retrieveBinaryHttp10() throws IOException { public void retrieveBinaryHttp11() throws IOException { final String testData = "12345"; - final Request post = Request.Post(getCollectionRootUri() + "/" + XQUERY_FILENAME) - .version(HttpVersion.HTTP_1_1) - .bodyByteArray(testData.getBytes(UTF_8), ContentType.APPLICATION_OCTET_STREAM); + final HttpRequest post = HttpRequest.newBuilder(URI.create(getCollectionRootUri() + "/" + XQUERY_FILENAME)) + .version(HttpClient.Version.HTTP_1_1) + .header("Content-Type", "application/octet-stream") + .POST(HttpRequest.BodyPublishers.ofByteArray(testData.getBytes(UTF_8))) + .build(); testRequest(post, wrapInElement(encodeBase64String(testData.getBytes(UTF_8)).trim()).getBytes()); } @@ -129,35 +136,40 @@ public void retrieveBinaryHttp11ChunkedTransferEncoding() throws IOException { final String testData = "12345"; try (final InputStream is = new UnsynchronizedByteArrayInputStream(testData.getBytes(UTF_8))) { - final Request post = Request.Post(getCollectionRootUri() + "/" + XQUERY_FILENAME) - .version(HttpVersion.HTTP_1_1) - .bodyStream(is, ContentType.APPLICATION_OCTET_STREAM); + final HttpRequest post = HttpRequest.newBuilder(URI.create(getCollectionRootUri() + "/" + XQUERY_FILENAME)) + .version(HttpClient.Version.HTTP_1_1) + .header("Content-Type", "application/octet-stream") + .POST(HttpRequest.BodyPublishers.ofInputStream(() -> is)) + .build(); testRequest(post, wrapInElement(encodeBase64String(testData.getBytes(UTF_8)).trim()).getBytes()); } } - @Ignore("Jetty 12 rejects HTTP/0.9 but corrupts the Apache HttpClient connection pool, causing NoHttpResponseException in subsequent tests") + @Ignore("Jetty 12 rejects HTTP/0.9, which the JDK HttpClient cannot express") @Test public void retrieveXmlHttp09() throws IOException { final String testData = "hello"; - final Request post = Request.Post(getCollectionRootUri() + "/" + XQUERY_FILENAME) - .version(HttpVersion.HTTP_0_9) - .bodyByteArray(testData.getBytes(UTF_8), ContentType.TEXT_XML); + final HttpRequest post = HttpRequest.newBuilder(URI.create(getCollectionRootUri() + "/" + XQUERY_FILENAME)) + .version(HttpClient.Version.HTTP_1_1) + .header("Content-Type", "text/xml") + .POST(HttpRequest.BodyPublishers.ofByteArray(testData.getBytes(UTF_8))) + .build(); - final HttpResponse response = post.execute().returnResponse(); - assertEquals(HttpStatus.SC_HTTP_VERSION_NOT_SUPPORTED, response.getStatusLine().getStatusCode()); + assertEquals(HTTP_VERSION, executeForStatus(newHttpClient(), post)); } - @Ignore("Jetty 12 drops the connection on HTTP/1.0 without a response, causing NoHttpResponseException in Apache HttpClient") + @Ignore("Jetty 12 drops the connection on HTTP/1.0 without a response, which the JDK HttpClient cannot express") @Test public void retrieveXmlHttp10() throws IOException { final String testData = "hello"; - final Request post = Request.Post(getCollectionRootUri() + "/" + XQUERY_FILENAME) - .version(HttpVersion.HTTP_1_0) - .bodyByteArray(testData.getBytes(UTF_8), ContentType.TEXT_XML); + final HttpRequest post = HttpRequest.newBuilder(URI.create(getCollectionRootUri() + "/" + XQUERY_FILENAME)) + .version(HttpClient.Version.HTTP_1_1) + .header("Content-Type", "text/xml") + .POST(HttpRequest.BodyPublishers.ofByteArray(testData.getBytes(UTF_8))) + .build(); testRequest(post, wrapInElement("\n\t" + testData + "\n").getBytes(), true); } @@ -166,9 +178,11 @@ public void retrieveXmlHttp10() throws IOException { public void retrieveXmlHttp11() throws IOException { final String testData = "hello"; - final Request post = Request.Post(getCollectionRootUri() + "/" + XQUERY_FILENAME) - .version(HttpVersion.HTTP_1_1) - .bodyByteArray(testData.getBytes(UTF_8), ContentType.TEXT_XML); + final HttpRequest post = HttpRequest.newBuilder(URI.create(getCollectionRootUri() + "/" + XQUERY_FILENAME)) + .version(HttpClient.Version.HTTP_1_1) + .header("Content-Type", "text/xml") + .POST(HttpRequest.BodyPublishers.ofByteArray(testData.getBytes(UTF_8))) + .build(); testRequest(post, wrapInElement("\n\t" + testData + "\n").getBytes(), true); } @@ -178,9 +192,11 @@ public void retrieveXmlHttp11ChunkedTransferEncoding() throws IOException { final String testData = "hello"; try (final InputStream is = new UnsynchronizedByteArrayInputStream(testData.getBytes(UTF_8))) { - final Request post = Request.Post(getCollectionRootUri() + "/" + XQUERY_FILENAME) - .version(HttpVersion.HTTP_1_1) - .bodyStream(is, ContentType.TEXT_XML); + final HttpRequest post = HttpRequest.newBuilder(URI.create(getCollectionRootUri() + "/" + XQUERY_FILENAME)) + .version(HttpClient.Version.HTTP_1_1) + .header("Content-Type", "text/xml") + .POST(HttpRequest.BodyPublishers.ofInputStream(() -> is)) + .build(); testRequest(post, wrapInElement("\n\t" + testData + "\n").getBytes(), true); } @@ -190,8 +206,10 @@ public void retrieveXmlHttp11ChunkedTransferEncoding() throws IOException { public void retrieveMalformedXmlFallbackToString() throws IOException { final String testData = ""; - Request post = Request.Post(getCollectionRootUri() + "/" + XQUERY_FILENAME) - .bodyByteArray(testData.getBytes(UTF_8), ContentType.TEXT_XML); + final HttpRequest post = HttpRequest.newBuilder(URI.create(getCollectionRootUri() + "/" + XQUERY_FILENAME)) + .header("Content-Type", "text/xml") + .POST(HttpRequest.BodyPublishers.ofByteArray(testData.getBytes(UTF_8))) + .build(); testRequest(post, wrapInElement(testData.replace("<", "<").replace(">", ">")).getBytes()); } @@ -200,30 +218,37 @@ public void retrieveMalformedXmlFallbackToString() throws IOException { public void retrieveString() throws IOException { final String testData = "12345"; - Request post = Request.Post(getCollectionRootUri() + "/" + XQUERY_FILENAME) - .bodyByteArray(testData.getBytes(UTF_8)); + final HttpRequest post = HttpRequest.newBuilder(URI.create(getCollectionRootUri() + "/" + XQUERY_FILENAME)) + .POST(HttpRequest.BodyPublishers.ofByteArray(testData.getBytes(UTF_8))) + .build(); testRequest(post, wrapInElement(testData).getBytes()); } - - private void testRequest(Request method, final byte expectedResponse[]) throws IOException { + + private void testRequest(final HttpRequest method, final byte expectedResponse[]) throws IOException { testRequest(method, expectedResponse, false); } - - private void testRequest(Request method, byte expectedResponse[], boolean stripWhitespaceAndFormatting) throws IOException { - final HttpResponse response = method.execute().returnResponse(); - assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode()); + private void testRequest(final HttpRequest method, byte expectedResponse[], boolean stripWhitespaceAndFormatting) throws IOException { + final HttpResponse response = send(newHttpClient(), method, HttpResponse.BodyHandlers.ofByteArray()); + + assertEquals(HTTP_OK, response.statusCode()); - try (final UnsynchronizedByteArrayOutputStream os = new UnsynchronizedByteArrayOutputStream()) { - response.getEntity().writeTo(os); + byte actualResponse[] = response.body(); + if(stripWhitespaceAndFormatting) { + expectedResponse = new String(expectedResponse).replace("\n", "").replace("\t", "").replace(" ", "").getBytes(UTF_8); + actualResponse = new String(actualResponse).replace("\n", "").replace("\t","").replace(" ", "").getBytes(UTF_8); + } + assertArrayEquals(expectedResponse, actualResponse); + } - byte actualResponse[] = os.toByteArray(); - if(stripWhitespaceAndFormatting) { - expectedResponse = new String(expectedResponse).replace("\n", "").replace("\t", "").replace(" ", "").getBytes(UTF_8); - actualResponse = new String(actualResponse).replace("\n", "").replace("\t","").replace(" ", "").getBytes(UTF_8); - } - assertArrayEquals(expectedResponse, actualResponse); - } + private static HttpResponse send(final HttpClient client, final HttpRequest request, + final HttpResponse.BodyHandler bodyHandler) throws IOException { + try { + return client.send(request, bodyHandler); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while awaiting HTTP response", e); + } } } diff --git a/exist-core/src/test/java/org/exist/xquery/functions/request/GetHeaderTest.java b/exist-core/src/test/java/org/exist/xquery/functions/request/GetHeaderTest.java index ef4a110d6e2..d5605945c1d 100644 --- a/exist-core/src/test/java/org/exist/xquery/functions/request/GetHeaderTest.java +++ b/exist-core/src/test/java/org/exist/xquery/functions/request/GetHeaderTest.java @@ -21,18 +21,18 @@ */ package org.exist.xquery.functions.request; -import static java.nio.charset.StandardCharsets.UTF_8; import static org.custommonkey.xmlunit.XMLAssert.assertXMLEqual; import static org.junit.Assert.assertEquals; import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URI; import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; -import org.apache.http.HttpResponse; -import org.apache.http.HttpStatus; -import org.apache.http.client.fluent.Request; +import org.exist.http.AbstractHttpTest.HttpResponseResult; import org.exist.http.RESTTest; -import org.apache.commons.io.output.UnsynchronizedByteArrayOutputStream; import org.junit.Test; import org.xml.sax.SAXException; @@ -65,23 +65,20 @@ public void testHeaderValue() throws IOException, SAXException { } private void testGetHeader(String headerValue) throws IOException, SAXException { - Request request = Request.Get(getCollectionRootUri() + "?_query=" + URLEncoder.encode(xquery, "UTF-8") + "&_indent=no&_wrap=no"); + final HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(URI.create(getCollectionRootUri() + "?_query=" + URLEncoder.encode(xquery, "UTF-8") + "&_indent=no&_wrap=no")).GET(); final StringBuilder xmlExpectedResponse = new StringBuilder(""); if (headerValue != null) { - request = request.addHeader(HTTP_HEADER_NAME, headerValue); + requestBuilder.header(HTTP_HEADER_NAME, headerValue); xmlExpectedResponse.append(headerValue); } xmlExpectedResponse.append(""); - final HttpResponse response = request.execute().returnResponse(); + final HttpClient client = newHttpClient(); + final HttpResponseResult response = executeForStatusAndBody(client, requestBuilder.build()); - assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode()); + assertEquals(HttpURLConnection.HTTP_OK, response.statusCode()); - try (final UnsynchronizedByteArrayOutputStream os = new UnsynchronizedByteArrayOutputStream()) { - response.getEntity().writeTo(os); - assertXMLEqual(xmlExpectedResponse - .toString(), new String(os.toByteArray(), UTF_8)); - } + assertXMLEqual(xmlExpectedResponse.toString(), response.body()); } } \ No newline at end of file diff --git a/exist-core/src/test/java/org/exist/xquery/functions/request/GetParameterTest.java b/exist-core/src/test/java/org/exist/xquery/functions/request/GetParameterTest.java index 08552c789e3..a37063eca75 100644 --- a/exist-core/src/test/java/org/exist/xquery/functions/request/GetParameterTest.java +++ b/exist-core/src/test/java/org/exist/xquery/functions/request/GetParameterTest.java @@ -22,21 +22,20 @@ package org.exist.xquery.functions.request; import static java.nio.charset.StandardCharsets.UTF_8; -import static org.junit.Assert.assertEquals; import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; import java.util.ArrayList; import java.util.List; -import org.apache.http.HttpResponse; -import org.apache.http.HttpStatus; -import org.apache.http.NameValuePair; -import org.apache.http.client.fluent.Request; -import org.apache.http.entity.ContentType; -import org.apache.http.entity.mime.MultipartEntityBuilder; -import org.apache.http.message.BasicNameValuePair; +import com.github.mizosoft.methanol.MediaType; +import com.github.mizosoft.methanol.MoreBodyPublishers; +import com.github.mizosoft.methanol.MultipartBodyPublisher; import org.exist.http.RESTTest; -import org.apache.commons.io.output.UnsynchronizedByteArrayOutputStream; import org.exist.xmldb.EXistResource; import org.exist.xmldb.UserManagementService; import org.junit.AfterClass; @@ -51,7 +50,7 @@ /** * Tests expected behaviour of request:get-parameter() XQuery function - * + * * @author Adam Retter * @version 1.0 */ @@ -65,7 +64,7 @@ public class GetParameterTest extends RESTTest { private static Collection root; - + @BeforeClass public static void beforeClass() throws XMLDBException { root = DatabaseManager.getCollection("xmldb:exist://localhost:" + existWebServer.getPort() + "/xmlrpc/db", "admin", ""); @@ -316,27 +315,33 @@ private void testGet(@Nullable final NameValues[] queryStringParams) throws IOEx } } - Request get = Request.Get(getCollectionRootUri() + "/" + XQUERY_FILENAME + (queryStringParams == null || queryStringParams.length == 0 ? "" : "?" + buf)); + final HttpRequest get = HttpRequest.newBuilder(URI.create(getCollectionRootUri() + "/" + XQUERY_FILENAME + (queryStringParams == null || queryStringParams.length == 0 ? "" : "?" + buf))) + .GET() + .build(); testRequest(get, buf.toString().replaceAll("&", "")); } private void testPost(@Nullable final NameValues[] formParams) throws IOException { final StringBuilder buf = new StringBuilder(); - Request post = Request.Post(getCollectionRootUri() + "/" + XQUERY_FILENAME); + final HttpRequest.Builder postBuilder = HttpRequest.newBuilder(URI.create(getCollectionRootUri() + "/" + XQUERY_FILENAME)); + HttpRequest.BodyPublisher bodyPublisher = HttpRequest.BodyPublishers.noBody(); if (formParams != null) { - final List bodyPairs = new ArrayList<>(); + final List bodyPairs = new ArrayList<>(); for (final NameValues formParam : formParams) { for (final String value : formParam.getData()) { - bodyPairs.add(new BasicNameValuePair(formParam.getName(), value)); + bodyPairs.add(urlEncode(formParam.getName()) + "=" + urlEncode(value)); buf.append(formParam.getName()).append('=').append(value); } } - post = post.bodyForm(bodyPairs); + bodyPublisher = HttpRequest.BodyPublishers.ofString(String.join("&", bodyPairs)); + postBuilder.header("Content-Type", "application/x-www-form-urlencoded"); } + final HttpRequest post = postBuilder.POST(bodyPublisher).build(); + testRequest(post, buf.toString()); } @@ -353,40 +358,49 @@ private void testPost(final NameValues[] queryStringParams, final NameValues[] f first = false; } - Request post = Request.Post(getCollectionRootUri() + "/" + XQUERY_FILENAME + (queryStringParams.length == 0 ? "" : "?" + queryStringBuf)); + final HttpRequest.Builder postBuilder = HttpRequest.newBuilder(URI.create(getCollectionRootUri() + "/" + XQUERY_FILENAME + (queryStringParams.length == 0 ? "" : "?" + queryStringBuf))); - final List bodyPairs = new ArrayList<>(); + final List bodyPairs = new ArrayList<>(); for (final NameValues formParam : formParams) { for (final String value : formParam.getData()) { - bodyPairs.add(new BasicNameValuePair(formParam.getName(), value)); + bodyPairs.add(urlEncode(formParam.getName()) + "=" + urlEncode(value)); formBuf.append(formParam.getName()).append('=').append(value); } } - post = post.bodyForm(bodyPairs); + final HttpRequest post = postBuilder + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(String.join("&", bodyPairs))) + .build(); testRequest(post, queryStringBuf.toString().replaceAll("&", "") + formBuf); } private void testMultipartPost(final Param[] multipartParams) throws IOException { - MultipartEntityBuilder multipart = MultipartEntityBuilder.create(); + final MultipartBodyPublisher.Builder multipart = MultipartBodyPublisher.newBuilder(); StringBuilder buf = new StringBuilder(); for (final Param multipartParam : multipartParams) { if(multipartParam instanceof NameValues nameValues) { for(final String value : nameValues.getData()) { - multipart = multipart.addTextBody(nameValues.getName(), value); + multipart.textPart(nameValues.getName(), value); buf.append(nameValues.getName()).append('=').append(value); } } else if(multipartParam instanceof TextFileUpload textFileUpload) { - multipart = multipart.addBinaryBody("fileUpload", textFileUpload.getData().getBytes(UTF_8), ContentType.TEXT_PLAIN, textFileUpload.getName()); + multipart.formPart("fileUpload", textFileUpload.getName(), + MoreBodyPublishers.ofMediaType( + HttpRequest.BodyPublishers.ofByteArray(textFileUpload.getData().getBytes(UTF_8)), + MediaType.TEXT_PLAIN)); buf.append("fileUpload=" + textFileUpload.getData()); } } - Request post = Request.Post(getCollectionRootUri() + "/" + XQUERY_FILENAME) - .body(multipart.build()); + final MultipartBodyPublisher multipartBody = multipart.build(); + final HttpRequest post = HttpRequest.newBuilder(URI.create(getCollectionRootUri() + "/" + XQUERY_FILENAME)) + .header("Content-Type", multipartBody.mediaType().toString()) + .POST(multipartBody) + .build(); testRequest(post, buf.toString()); } @@ -403,38 +417,44 @@ private void testMultipartPost(final NameValues[] queryStringParams, final Param first = false; } - MultipartEntityBuilder multipart = MultipartEntityBuilder.create(); + final MultipartBodyPublisher.Builder multipart = MultipartBodyPublisher.newBuilder(); final StringBuilder bodyBuf = new StringBuilder(); for (final Param multipartParam : multipartParams) { if(multipartParam instanceof NameValues nameValues) { for(final String value : nameValues.getData()) { - multipart = multipart.addTextBody(nameValues.getName(), value); + multipart.textPart(nameValues.getName(), value); bodyBuf.append(nameValues.getName()).append('=').append(value); } } else if(multipartParam instanceof TextFileUpload textFileUpload) { - multipart = multipart.addBinaryBody("fileUpload", textFileUpload.getData().getBytes(UTF_8), ContentType.TEXT_PLAIN, textFileUpload.getName()); + multipart.formPart("fileUpload", textFileUpload.getName(), + MoreBodyPublishers.ofMediaType( + HttpRequest.BodyPublishers.ofByteArray(textFileUpload.getData().getBytes(UTF_8)), + MediaType.TEXT_PLAIN)); bodyBuf.append("fileUpload=" + textFileUpload.getData()); } } - Request post = Request.Post(getCollectionRootUri() + "/" + XQUERY_FILENAME + (queryStringParams.length == 0 ? "" : "?" + queryStringBuf)) - .body(multipart.build()); + final MultipartBodyPublisher multipartBody = multipart.build(); + final HttpRequest post = HttpRequest.newBuilder(URI.create(getCollectionRootUri() + "/" + XQUERY_FILENAME + (queryStringParams.length == 0 ? "" : "?" + queryStringBuf))) + .header("Content-Type", multipartBody.mediaType().toString()) + .POST(multipartBody) + .build(); testRequest(post, queryStringBuf.toString().replaceAll("&", "") + bodyBuf); } - private void testRequest(final Request request, final String expected) throws IOException { - final HttpResponse response = request.execute().returnResponse(); - assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode()); - - try (final UnsynchronizedByteArrayOutputStream os = new UnsynchronizedByteArrayOutputStream()) { - response.getEntity().writeTo(os); - assertEquals(expected, new String(os.toByteArray(), UTF_8)); + private void testRequest(final HttpRequest request, final String expected) throws IOException { + try (final HttpClient client = newHttpClient()) { + assertRequestResponse(client, request, HttpURLConnection.HTTP_OK, expected); } } + private static String urlEncode(final String value) { + return URLEncoder.encode(value, UTF_8); + } + public class NameValues implements Param { final String name; diff --git a/exist-core/src/test/java/org/exist/xquery/functions/request/PatchTest.java b/exist-core/src/test/java/org/exist/xquery/functions/request/PatchTest.java index 386e40e619b..165e8f8d5b1 100644 --- a/exist-core/src/test/java/org/exist/xquery/functions/request/PatchTest.java +++ b/exist-core/src/test/java/org/exist/xquery/functions/request/PatchTest.java @@ -21,14 +21,11 @@ */ package org.exist.xquery.functions.request; -import org.apache.http.HttpResponse; -import org.apache.http.HttpStatus; -import org.apache.http.client.fluent.Request; -import org.apache.http.entity.ContentType; -import org.apache.commons.io.output.UnsynchronizedByteArrayOutputStream; - import org.exist.xmldb.UserManagementService; import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; import org.exist.http.RESTTest; import org.exist.xmldb.EXistResource; import org.hamcrest.Matcher; @@ -36,6 +33,8 @@ import org.junit.BeforeClass; import org.junit.Test; +import static java.net.HttpURLConnection.HTTP_BAD_METHOD; +import static java.net.HttpURLConnection.HTTP_OK; import static java.nio.charset.StandardCharsets.UTF_8; import static org.exist.test.XmlStringDiffMatcher.hasSimilarXml; import static org.hamcrest.MatcherAssert.assertThat; @@ -92,8 +91,10 @@ public static void afterClass() throws XMLDBException { public void patchBinary() throws IOException { final byte[] testData = "12345".getBytes(UTF_8); - final Request patch = Request.Patch(getCollectionRootUri() + "/" + XQUERY_FILENAME) - .bodyByteArray(testData, ContentType.APPLICATION_OCTET_STREAM); + final HttpRequest patch = HttpRequest.newBuilder(URI.create(getCollectionRootUri() + "/" + XQUERY_FILENAME)) + .method("PATCH", HttpRequest.BodyPublishers.ofByteArray(testData)) + .header("Content-Type", "application/octet-stream") + .build(); assertResponse(patch, encodeBase64String(testData)); } @@ -102,8 +103,10 @@ public void patchBinary() throws IOException { public void patchXml() throws IOException { final String testData = "hello"; - final Request patch = Request.Patch(getCollectionRootUri() + "/" + XQUERY_FILENAME) - .bodyByteArray(testData.getBytes(UTF_8), ContentType.TEXT_XML); + final HttpRequest patch = HttpRequest.newBuilder(URI.create(getCollectionRootUri() + "/" + XQUERY_FILENAME)) + .method("PATCH", HttpRequest.BodyPublishers.ofByteArray(testData.getBytes(UTF_8))) + .header("Content-Type", "text/xml") + .build(); assertResponse(patch, testData); } @@ -112,8 +115,9 @@ public void patchXml() throws IOException { public void patchString() throws IOException { final String testData = "12345"; - final Request patch = Request.Patch(getCollectionRootUri() + "/" + XQUERY_FILENAME) - .bodyByteArray(testData.getBytes(UTF_8)); + final HttpRequest patch = HttpRequest.newBuilder(URI.create(getCollectionRootUri() + "/" + XQUERY_FILENAME)) + .method("PATCH", HttpRequest.BodyPublishers.ofByteArray(testData.getBytes(UTF_8))) + .build(); assertResponse(patch, testData); } @@ -122,8 +126,10 @@ public void patchString() throws IOException { public void patchCollectionNotAllowed() throws IOException { final String testData = "hello"; - final Request patch = Request.Patch(getCollectionRootUri()) - .bodyByteArray(testData.getBytes(UTF_8), ContentType.TEXT_XML); + final HttpRequest patch = HttpRequest.newBuilder(URI.create(getCollectionRootUri())) + .method("PATCH", HttpRequest.BodyPublishers.ofByteArray(testData.getBytes(UTF_8))) + .header("Content-Type", "text/xml") + .build(); assertMethodNotAllowed(patch); } @@ -132,33 +138,26 @@ public void patchCollectionNotAllowed() throws IOException { public void patchXmlResourceNotAllowed() throws IOException { final String testData = "hello"; - final Request patch = Request.Patch(getCollectionRootUri() + "/" + XML_FILENAME) - .bodyByteArray(testData.getBytes(UTF_8), ContentType.TEXT_XML); + final HttpRequest patch = HttpRequest.newBuilder(URI.create(getCollectionRootUri() + "/" + XML_FILENAME)) + .method("PATCH", HttpRequest.BodyPublishers.ofByteArray(testData.getBytes(UTF_8))) + .header("Content-Type", "text/xml") + .build(); assertMethodNotAllowed(patch); } - private void assertResponse(final Request method, String expectedData) throws IOException { - final HttpResponse response = method.execute().returnResponse(); + private void assertResponse(final HttpRequest method, String expectedData) throws IOException { final Matcher valueMatcher = hasSimilarXml( "PATCH" + expectedData + ""); - assertHTTPStatusCode(HttpStatus.SC_OK, response); - - try (final UnsynchronizedByteArrayOutputStream os = new UnsynchronizedByteArrayOutputStream()) { - response.getEntity().writeTo(os); - - final String actualResponse = new String(os.toByteArray()); - assertThat(actualResponse, valueMatcher); - } - } - - private void assertHTTPStatusCode (final int code, final HttpResponse response) { - assertEquals(code, response.getStatusLine().getStatusCode()); + final HttpClient client = newHttpClient(); + final HttpResponseResult result = executeForStatusAndBody(client, method); + assertEquals(HTTP_OK, result.statusCode()); + assertThat(result.body(), valueMatcher); } - private void assertMethodNotAllowed (final Request req) throws IOException { - final HttpResponse response = req.execute().returnResponse(); - assertHTTPStatusCode(HttpStatus.SC_METHOD_NOT_ALLOWED, response); + private void assertMethodNotAllowed(final HttpRequest req) throws IOException { + final HttpClient client = newHttpClient(); + assertEquals(HTTP_BAD_METHOD, executeForStatus(client, req)); } } diff --git a/exist-core/src/test/java/org/exist/xquery/functions/response/StreamBinaryTest.java b/exist-core/src/test/java/org/exist/xquery/functions/response/StreamBinaryTest.java index 26067159b53..df934436dc9 100644 --- a/exist-core/src/test/java/org/exist/xquery/functions/response/StreamBinaryTest.java +++ b/exist-core/src/test/java/org/exist/xquery/functions/response/StreamBinaryTest.java @@ -21,44 +21,42 @@ */ package org.exist.xquery.functions.response; +import static java.net.HttpURLConnection.HTTP_OK; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertArrayEquals; -import java.io.IOException; +import java.net.URI; import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; import org.apache.commons.codec.binary.Base64; -import org.apache.http.HttpResponse; -import org.apache.http.HttpStatus; -import org.apache.http.client.fluent.Request; import org.exist.http.RESTTest; -import org.apache.commons.io.output.UnsynchronizedByteArrayOutputStream; import org.junit.Test; /** * Tests expected behaviour of response:stream-binary() XQuery function - * + * * @author Adam Retter * @version 1.0 */ public class StreamBinaryTest extends RESTTest { @Test - public void testStreamBinary() throws IOException { - + public void testStreamBinary() throws Exception { + final String testValue = "hello world"; final String xquery = "response:stream-binary(xs:base64Binary('" + Base64.encodeBase64String(testValue.getBytes()) + "'), 'application/octet-stream', 'test.bin')"; - final Request get = Request.Get(getCollectionRootUri() + "?_query=" + URLEncoder.encode(xquery, "UTF-8") + "&_indent=no"); - - final HttpResponse response = get.execute().returnResponse(); - assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode()); + final HttpRequest get = HttpRequest.newBuilder(URI.create(getCollectionRootUri() + "?_query=" + URLEncoder.encode(xquery, StandardCharsets.UTF_8) + "&_indent=no")).GET().build(); - try (final UnsynchronizedByteArrayOutputStream os = new UnsynchronizedByteArrayOutputStream()) { - response.getEntity().writeTo(os); + final HttpClient client = newHttpClient(); + final HttpResponse response = client.send(get, HttpResponse.BodyHandlers.ofByteArray()); + assertEquals(HTTP_OK, response.statusCode()); - assertArrayEquals(testValue.getBytes(), os.toByteArray()); - } + assertArrayEquals(testValue.getBytes(), response.body()); } -} \ No newline at end of file +} diff --git a/exist-core/src/test/java/org/exist/xquery/functions/session/AttributeTest.java b/exist-core/src/test/java/org/exist/xquery/functions/session/AttributeTest.java index 4765f168034..f096958716d 100644 --- a/exist-core/src/test/java/org/exist/xquery/functions/session/AttributeTest.java +++ b/exist-core/src/test/java/org/exist/xquery/functions/session/AttributeTest.java @@ -32,209 +32,145 @@ */ package org.exist.xquery.functions.session; -import org.apache.commons.io.output.UnsynchronizedByteArrayOutputStream; -import org.apache.http.HttpEntity; -import org.apache.http.HttpResponse; -import org.apache.http.HttpStatus; -import org.apache.http.client.fluent.Request; import org.exist.util.UUIDGenerator; import org.junit.Test; import java.io.IOException; -import java.io.UnsupportedEncodingException; +import java.net.CookieManager; +import java.net.URI; import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import static java.net.HttpURLConnection.HTTP_OK; import static java.nio.charset.StandardCharsets.UTF_8; -import static org.junit.Assert.assertEquals; +import static org.exist.http.AbstractHttpTest.assertRequestResponse; public class AttributeTest extends AbstractSessionTest { @Test public void getSetAttributeExplicitSessionCreation() throws IOException { + final HttpClient client = newSessionHttpClient(); + // explicitly create a new session - final Request requestCreateSession = xqueryRequest("session:create()"); - final HttpResponse createSessionResponse = requestCreateSession - .execute() - .returnResponse(); - assertEquals(HttpStatus.SC_OK, createSessionResponse.getStatusLine().getStatusCode()); - assertEquals("", readEntityAsString(createSessionResponse.getEntity())); + final HttpRequest requestCreateSession = xqueryRequest("session:create()"); + assertRequestResponse(client, requestCreateSession, HTTP_OK, ""); // get the value of the attribute named "attr1", and check its value is the empty sequence - final Request requestGetAttr = xqueryRequest("session:get-attribute('attr1')"); - final HttpResponse getResponse1 = requestGetAttr - .execute() - .returnResponse(); - assertEquals(HttpStatus.SC_OK, getResponse1.getStatusLine().getStatusCode()); - assertEquals("", readEntityAsString(getResponse1.getEntity())); + final HttpRequest requestGetAttr = xqueryRequest("session:get-attribute('attr1')"); + assertRequestResponse(client, requestGetAttr, HTTP_OK, ""); // set the value of the attribute named "attr1" to a random UUID final String attr1Value = UUIDGenerator.getUUIDversion4(); - final Request requestSetAttr1 = xqueryRequest("session:set-attribute('attr1', '" + attr1Value + "')"); - final HttpResponse setResponse1 = requestSetAttr1 - .execute() - .returnResponse(); - assertEquals(HttpStatus.SC_OK, setResponse1.getStatusLine().getStatusCode()); - assertEquals("", readEntityAsString(setResponse1.getEntity())); + final HttpRequest requestSetAttr1 = xqueryRequest("session:set-attribute('attr1', '" + attr1Value + "')"); + assertRequestResponse(client, requestSetAttr1, HTTP_OK, ""); // get the value of the attribute named "attr1", and check its value is the UUID - final HttpResponse getResponse2 = requestGetAttr - .execute() - .returnResponse(); - assertEquals(HttpStatus.SC_OK, getResponse2.getStatusLine().getStatusCode()); - assertEquals(attr1Value, readEntityAsString(getResponse2.getEntity())); + assertRequestResponse(client, requestGetAttr, HTTP_OK, attr1Value); } @Test public void getSetAttributeImplicitSessionCreation() throws IOException { + final HttpClient client = newSessionHttpClient(); + // get the value of the attribute named "attr1", and check its value is the empty sequence - final Request requestGetAttr = xqueryRequest("session:get-attribute('attr1')"); - final HttpResponse getResponse1 = requestGetAttr - .execute() - .returnResponse(); - assertEquals(HttpStatus.SC_OK, getResponse1.getStatusLine().getStatusCode()); - assertEquals("", readEntityAsString(getResponse1.getEntity())); + final HttpRequest requestGetAttr = xqueryRequest("session:get-attribute('attr1')"); + assertRequestResponse(client, requestGetAttr, HTTP_OK, ""); // set the value of the attribute named "attr1" to a random UUID final String attr1Value = UUIDGenerator.getUUIDversion4(); - final Request requestSetAttr1 = xqueryRequest("session:set-attribute('attr1', '" + attr1Value + "')"); - final HttpResponse setResponse1 = requestSetAttr1 - .execute() - .returnResponse(); - assertEquals(HttpStatus.SC_OK, setResponse1.getStatusLine().getStatusCode()); - assertEquals("", readEntityAsString(setResponse1.getEntity())); + final HttpRequest requestSetAttr1 = xqueryRequest("session:set-attribute('attr1', '" + attr1Value + "')"); + assertRequestResponse(client, requestSetAttr1, HTTP_OK, ""); // get the value of the attribute named "attr1", and check its value is the UUID - final HttpResponse getResponse2 = requestGetAttr - .execute() - .returnResponse(); - assertEquals(HttpStatus.SC_OK, getResponse2.getStatusLine().getStatusCode()); - assertEquals(attr1Value, readEntityAsString(getResponse2.getEntity())); + assertRequestResponse(client, requestGetAttr, HTTP_OK, attr1Value); } @Test public void getAttributeOnInvalidatedSessionSeparateHttpCalls() throws IOException { + final HttpClient client = newSessionHttpClient(); + // explicitly create a new session - final Request requestCreateSession = xqueryRequest("session:create()"); - final HttpResponse createSessionResponse = requestCreateSession - .execute() - .returnResponse(); - assertEquals(HttpStatus.SC_OK, createSessionResponse.getStatusLine().getStatusCode()); - assertEquals("", readEntityAsString(createSessionResponse.getEntity())); + final HttpRequest requestCreateSession = xqueryRequest("session:create()"); + assertRequestResponse(client, requestCreateSession, HTTP_OK, ""); // invalidate the session - final Request requestInvalidateSession = xqueryRequest("session:invalidate()"); - final HttpResponse invalidateSessionResponse = requestInvalidateSession - .execute() - .returnResponse(); - assertEquals(HttpStatus.SC_OK, invalidateSessionResponse.getStatusLine().getStatusCode()); - assertEquals("", readEntityAsString(invalidateSessionResponse.getEntity())); + final HttpRequest requestInvalidateSession = xqueryRequest("session:invalidate()"); + assertRequestResponse(client, requestInvalidateSession, HTTP_OK, ""); // get the value of the attribute named "attr1", and check its value is the empty sequence - final Request requestGetAttr1 = xqueryRequest("session:get-attribute('attr1')"); - final HttpResponse getResponse1 = requestGetAttr1 - .execute() - .returnResponse(); - assertEquals(HttpStatus.SC_OK, getResponse1.getStatusLine().getStatusCode()); - assertEquals("", readEntityAsString(getResponse1.getEntity())); + final HttpRequest requestGetAttr1 = xqueryRequest("session:get-attribute('attr1')"); + assertRequestResponse(client, requestGetAttr1, HTTP_OK, ""); } @Test public void getAttributeOnInvalidatedSessionSameHttpCall() throws IOException { + final HttpClient client = newSessionHttpClient(); + // explicitly create a new session - final Request requestCreateSession = xqueryRequest("session:create()"); - final HttpResponse createSessionResponse = requestCreateSession - .execute() - .returnResponse(); - assertEquals(HttpStatus.SC_OK, createSessionResponse.getStatusLine().getStatusCode()); - assertEquals("", readEntityAsString(createSessionResponse.getEntity())); + final HttpRequest requestCreateSession = xqueryRequest("session:create()"); + assertRequestResponse(client, requestCreateSession, HTTP_OK, ""); // invalidate the session and call get-attribute - final Request requestInvalidateSession = xqueryRequest("session:invalidate(), session:get-attribute('attr1')"); - final HttpResponse invalidateSessionResponse = requestInvalidateSession - .execute() - .returnResponse(); - assertEquals(HttpStatus.SC_OK, invalidateSessionResponse.getStatusLine().getStatusCode()); - assertEquals("", readEntityAsString(invalidateSessionResponse.getEntity())); + final HttpRequest requestInvalidateSession = xqueryRequest("session:invalidate(), session:get-attribute('attr1')"); + assertRequestResponse(client, requestInvalidateSession, HTTP_OK, ""); } @Test public void setAttributeOnInvalidatedSessionSeparateHttpCalls() throws IOException { + final HttpClient client = newSessionHttpClient(); + // explicitly create a new session - final Request requestCreateSession = xqueryRequest("session:create()"); - final HttpResponse createSessionResponse = requestCreateSession - .execute() - .returnResponse(); - assertEquals(HttpStatus.SC_OK, createSessionResponse.getStatusLine().getStatusCode()); - assertEquals("", readEntityAsString(createSessionResponse.getEntity())); + final HttpRequest requestCreateSession = xqueryRequest("session:create()"); + assertRequestResponse(client, requestCreateSession, HTTP_OK, ""); // invalidate the session - final Request requestInvalidateSession = xqueryRequest("session:invalidate()"); - final HttpResponse invalidateSessionResponse = requestInvalidateSession - .execute() - .returnResponse(); - assertEquals(HttpStatus.SC_OK, invalidateSessionResponse.getStatusLine().getStatusCode()); - assertEquals("", readEntityAsString(invalidateSessionResponse.getEntity())); + final HttpRequest requestInvalidateSession = xqueryRequest("session:invalidate()"); + assertRequestResponse(client, requestInvalidateSession, HTTP_OK, ""); // set the value of the attribute named "attr1" to a random UUID final String attr1Value = UUIDGenerator.getUUIDversion4(); - final Request requestSetAttr1 = xqueryRequest("session:set-attribute('attr1', '" + attr1Value + "')"); - final HttpResponse setResponse1 = requestSetAttr1 - .execute() - .returnResponse(); - final String responseBody = readEntityAsString(setResponse1.getEntity()); - assertEquals(HttpStatus.SC_OK, setResponse1.getStatusLine().getStatusCode()); - assertEquals("", responseBody); + final HttpRequest requestSetAttr1 = xqueryRequest("session:set-attribute('attr1', '" + attr1Value + "')"); + assertRequestResponse(client, requestSetAttr1, HTTP_OK, ""); // get the value of the attribute named "attr1", and check its value is the UUID - final Request requestGetAttr = xqueryRequest("session:get-attribute('attr1')"); - final HttpResponse getResponse2 = requestGetAttr - .execute() - .returnResponse(); - assertEquals(HttpStatus.SC_OK, getResponse2.getStatusLine().getStatusCode()); - assertEquals(attr1Value, readEntityAsString(getResponse2.getEntity())); + final HttpRequest requestGetAttr = xqueryRequest("session:get-attribute('attr1')"); + assertRequestResponse(client, requestGetAttr, HTTP_OK, attr1Value); } @Test public void setAttributeOnInvalidatedSessionSameHttpCall() throws IOException { + final HttpClient client = newSessionHttpClient(); + // explicitly create a new session - final Request requestCreateSession = xqueryRequest("session:create()"); - final HttpResponse createSessionResponse = requestCreateSession - .execute() - .returnResponse(); - assertEquals(HttpStatus.SC_OK, createSessionResponse.getStatusLine().getStatusCode()); - assertEquals("", readEntityAsString(createSessionResponse.getEntity())); + final HttpRequest requestCreateSession = xqueryRequest("session:create()"); + assertRequestResponse(client, requestCreateSession, HTTP_OK, ""); // invalidate the session and call set-attribute final String attr1Value = UUIDGenerator.getUUIDversion4(); - final Request requestInvalidateSession = xqueryRequest("session:invalidate(), session:set-attribute('attr1', '" + attr1Value + "')"); - final HttpResponse invalidateSessionResponse = requestInvalidateSession - .execute() - .returnResponse(); - final String responseBody = readEntityAsString(invalidateSessionResponse.getEntity()); - assertEquals(HttpStatus.SC_OK, invalidateSessionResponse.getStatusLine().getStatusCode()); - assertEquals("", responseBody); + final HttpRequest requestInvalidateSession = xqueryRequest("session:invalidate(), session:set-attribute('attr1', '" + attr1Value + "')"); + assertRequestResponse(client, requestInvalidateSession, HTTP_OK, ""); // get the value of the attribute named "attr1", and check its value is the UUID - final Request requestGetAttr = xqueryRequest("session:get-attribute('attr1')"); - final HttpResponse getResponse2 = requestGetAttr - .execute() - .returnResponse(); - assertEquals(HttpStatus.SC_OK, getResponse2.getStatusLine().getStatusCode()); - assertEquals(attr1Value, readEntityAsString(getResponse2.getEntity())); - } - - public Request xqueryRequest(final String xquery) throws UnsupportedEncodingException { - return Request.Get(getCollectionRootUri() + "/?_query=" + URLEncoder.encode(xquery, UTF_8.name()) + "&_wrap=no"); + final HttpRequest requestGetAttr = xqueryRequest("session:get-attribute('attr1')"); + assertRequestResponse(client, requestGetAttr, HTTP_OK, attr1Value); } - private static String readEntityAsString(final HttpEntity entity) throws IOException { - return new String(readEntity(entity), UTF_8); + /** + * Create an HTTP client that follows redirects and retains cookies across requests, so that the + * HTTP session (JSESSIONID) established by one request is carried to subsequent requests within + * the same test. The previous Apache HttpClient fluent API used a shared default cookie store for + * this; the JDK {@link HttpClient} requires an explicit {@link CookieManager}. + */ + private static HttpClient newSessionHttpClient() { + return HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .cookieHandler(new CookieManager()) + .build(); } - private static byte[] readEntity(final HttpEntity entity) throws IOException { - try (final UnsynchronizedByteArrayOutputStream os = new UnsynchronizedByteArrayOutputStream()) { - entity.writeTo(os); - return os.toByteArray(); - } + public HttpRequest xqueryRequest(final String xquery) { + final URI uri = URI.create(getCollectionRootUri() + "/?_query=" + URLEncoder.encode(xquery, UTF_8) + "&_wrap=no"); + return HttpRequest.newBuilder(uri).GET().build(); } } diff --git a/exist-core/src/test/java/org/exist/xquery/functions/xmldb/XMLDBAuthenticateTest.java b/exist-core/src/test/java/org/exist/xquery/functions/xmldb/XMLDBAuthenticateTest.java index 74c0f86c1c5..5d0dc4f17b3 100644 --- a/exist-core/src/test/java/org/exist/xquery/functions/xmldb/XMLDBAuthenticateTest.java +++ b/exist-core/src/test/java/org/exist/xquery/functions/xmldb/XMLDBAuthenticateTest.java @@ -32,12 +32,8 @@ */ package org.exist.xquery.functions.xmldb; -import org.apache.commons.io.output.UnsynchronizedByteArrayOutputStream; -import org.apache.http.HttpEntity; -import org.apache.http.HttpResponse; -import org.apache.http.HttpStatus; -import org.apache.http.client.fluent.Request; import org.exist.TestUtils; +import org.exist.http.AbstractHttpTest.HttpResponseResult; import org.exist.security.internal.aider.GroupAider; import org.exist.security.internal.aider.UserAider; import org.exist.xmldb.UserManagementService; @@ -52,10 +48,16 @@ import javax.xml.transform.Source; import java.io.IOException; -import java.io.UnsupportedEncodingException; +import java.net.CookieManager; +import java.net.HttpURLConnection; +import java.net.URI; import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; import static java.nio.charset.StandardCharsets.UTF_8; +import static org.exist.http.AbstractHttpTest.assertRequestResponse; +import static org.exist.http.AbstractHttpTest.executeForStatusAndBody; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -79,28 +81,20 @@ public void beforeClass() throws XMLDBException { @Test public void loginExplicitSessionCreation() throws IOException { + final HttpClient client = newSessionClient(); + // explicitly create a new session - final Request requestCreateSession = xqueryRequest("session:create()"); - final HttpResponse createSessionResponse = requestCreateSession - .execute() - .returnResponse(); - assertEquals(HttpStatus.SC_OK, createSessionResponse.getStatusLine().getStatusCode()); - assertEquals("", readEntityAsString(createSessionResponse.getEntity())); + final HttpRequest requestCreateSession = xqueryRequest("session:create()"); + assertRequestResponse(client, requestCreateSession, HttpURLConnection.HTTP_OK, ""); // login to the database - final Request requestGetAttr = xqueryRequest("xmldb:login('/db', '" + USER1_UID + "', '" + USER1_PWD + "')"); - final HttpResponse getResponse1 = requestGetAttr - .execute() - .returnResponse(); - assertEquals(HttpStatus.SC_OK, getResponse1.getStatusLine().getStatusCode()); - assertEquals("true", readEntityAsString(getResponse1.getEntity())); + final HttpRequest requestGetAttr = xqueryRequest("xmldb:login('/db', '" + USER1_UID + "', '" + USER1_PWD + "')"); + assertRequestResponse(client, requestGetAttr, HttpURLConnection.HTTP_OK, "true"); // get the identity of the current user - final Request requestSetAttr1 = xqueryRequest("sm:id()"); - final HttpResponse setResponse1 = requestSetAttr1 - .execute() - .returnResponse(); - assertEquals(HttpStatus.SC_OK, setResponse1.getStatusLine().getStatusCode()); + final HttpRequest requestSetAttr1 = xqueryRequest("sm:id()"); + final HttpResponseResult result = executeForStatusAndBody(client, requestSetAttr1); + assertEquals(HttpURLConnection.HTTP_OK, result.statusCode()); final Source expected = Input.fromString( """ @@ -112,7 +106,7 @@ public void loginExplicitSessionCreation() throws IOException { """).build(); - final Source actual = Input.fromString(readEntityAsString(setResponse1.getEntity())).build(); + final Source actual = Input.fromString(result.body()).build(); final Diff diff = DiffBuilder.compare(expected) .withTest(actual) .checkForSimilar() @@ -122,20 +116,16 @@ public void loginExplicitSessionCreation() throws IOException { @Test public void loginImplicitSessionCreateSessionFalse() throws IOException { + final HttpClient client = newSessionClient(); + // login to the database - final Request requestGetAttr = xqueryRequest("xmldb:login('/db', '" + USER1_UID + "', '" + USER1_PWD + "', false())"); - final HttpResponse getResponse1 = requestGetAttr - .execute() - .returnResponse(); - assertEquals(HttpStatus.SC_OK, getResponse1.getStatusLine().getStatusCode()); - assertEquals("true", readEntityAsString(getResponse1.getEntity())); + final HttpRequest requestGetAttr = xqueryRequest("xmldb:login('/db', '" + USER1_UID + "', '" + USER1_PWD + "', false())"); + assertRequestResponse(client, requestGetAttr, HttpURLConnection.HTTP_OK, "true"); // get the identity of the current user - final Request requestSetAttr1 = xqueryRequest("sm:id()"); - final HttpResponse setResponse1 = requestSetAttr1 - .execute() - .returnResponse(); - assertEquals(HttpStatus.SC_OK, setResponse1.getStatusLine().getStatusCode()); + final HttpRequest requestSetAttr1 = xqueryRequest("sm:id()"); + final HttpResponseResult result = executeForStatusAndBody(client, requestSetAttr1); + assertEquals(HttpURLConnection.HTTP_OK, result.statusCode()); final Source expected = Input.fromString( """ @@ -147,7 +137,7 @@ public void loginImplicitSessionCreateSessionFalse() throws IOException { """).build(); - final Source actual = Input.fromString(readEntityAsString(setResponse1.getEntity())).build(); + final Source actual = Input.fromString(result.body()).build(); final Diff diff = DiffBuilder.compare(expected) .withTest(actual) .checkForSimilar() @@ -157,20 +147,16 @@ public void loginImplicitSessionCreateSessionFalse() throws IOException { @Test public void loginImplicitSessionCreateSessionTrue() throws IOException { + final HttpClient client = newSessionClient(); + // login to the database - final Request requestGetAttr = xqueryRequest("xmldb:login('/db', '" + USER1_UID + "', '" + USER1_PWD + "', true())"); - final HttpResponse getResponse1 = requestGetAttr - .execute() - .returnResponse(); - assertEquals(HttpStatus.SC_OK, getResponse1.getStatusLine().getStatusCode()); - assertEquals("true", readEntityAsString(getResponse1.getEntity())); + final HttpRequest requestGetAttr = xqueryRequest("xmldb:login('/db', '" + USER1_UID + "', '" + USER1_PWD + "', true())"); + assertRequestResponse(client, requestGetAttr, HttpURLConnection.HTTP_OK, "true"); // get the identity of the current user - final Request requestSetAttr1 = xqueryRequest("sm:id()"); - final HttpResponse setResponse1 = requestSetAttr1 - .execute() - .returnResponse(); - assertEquals(HttpStatus.SC_OK, setResponse1.getStatusLine().getStatusCode()); + final HttpRequest requestSetAttr1 = xqueryRequest("sm:id()"); + final HttpResponseResult result = executeForStatusAndBody(client, requestSetAttr1); + assertEquals(HttpURLConnection.HTTP_OK, result.statusCode()); final Source expected = Input.fromString( """ @@ -182,7 +168,7 @@ public void loginImplicitSessionCreateSessionTrue() throws IOException { """).build(); - final Source actual = Input.fromString(readEntityAsString(setResponse1.getEntity())).build(); + final Source actual = Input.fromString(result.body()).build(); final Diff diff = DiffBuilder.compare(expected) .withTest(actual) .checkForSimilar() @@ -192,36 +178,24 @@ public void loginImplicitSessionCreateSessionTrue() throws IOException { @Test public void loginOnInvalidatedSessionCreateSessionFalseSeparateHttpCalls() throws IOException { + final HttpClient client = newSessionClient(); + // explicitly create a new session - final Request requestCreateSession = xqueryRequest("session:create()"); - final HttpResponse createSessionResponse = requestCreateSession - .execute() - .returnResponse(); - assertEquals(HttpStatus.SC_OK, createSessionResponse.getStatusLine().getStatusCode()); - assertEquals("", readEntityAsString(createSessionResponse.getEntity())); + final HttpRequest requestCreateSession = xqueryRequest("session:create()"); + assertRequestResponse(client, requestCreateSession, HttpURLConnection.HTTP_OK, ""); // invalidate the session - final Request requestInvalidateSession = xqueryRequest("session:invalidate()"); - final HttpResponse invalidateSessionResponse = requestInvalidateSession - .execute() - .returnResponse(); - assertEquals(HttpStatus.SC_OK, invalidateSessionResponse.getStatusLine().getStatusCode()); - assertEquals("", readEntityAsString(invalidateSessionResponse.getEntity())); + final HttpRequest requestInvalidateSession = xqueryRequest("session:invalidate()"); + assertRequestResponse(client, requestInvalidateSession, HttpURLConnection.HTTP_OK, ""); // login to the database - final Request requestGetAttr = xqueryRequest("xmldb:login('/db', '" + USER1_UID + "', '" + USER1_PWD + "', false())"); - final HttpResponse getResponse1 = requestGetAttr - .execute() - .returnResponse(); - assertEquals(HttpStatus.SC_OK, getResponse1.getStatusLine().getStatusCode()); - assertEquals("true", readEntityAsString(getResponse1.getEntity())); + final HttpRequest requestGetAttr = xqueryRequest("xmldb:login('/db', '" + USER1_UID + "', '" + USER1_PWD + "', false())"); + assertRequestResponse(client, requestGetAttr, HttpURLConnection.HTTP_OK, "true"); // get the identity of the current user - final Request requestSetAttr1 = xqueryRequest("sm:id()"); - final HttpResponse setResponse1 = requestSetAttr1 - .execute() - .returnResponse(); - assertEquals(HttpStatus.SC_OK, setResponse1.getStatusLine().getStatusCode()); + final HttpRequest requestSetAttr1 = xqueryRequest("sm:id()"); + final HttpResponseResult result = executeForStatusAndBody(client, requestSetAttr1); + assertEquals(HttpURLConnection.HTTP_OK, result.statusCode()); final Source expected = Input.fromString( """ @@ -233,7 +207,7 @@ public void loginOnInvalidatedSessionCreateSessionFalseSeparateHttpCalls() throw """).build(); - final Source actual = Input.fromString(readEntityAsString(setResponse1.getEntity())).build(); + final Source actual = Input.fromString(result.body()).build(); final Diff diff = DiffBuilder.compare(expected) .withTest(actual) .checkForSimilar() @@ -243,36 +217,24 @@ public void loginOnInvalidatedSessionCreateSessionFalseSeparateHttpCalls() throw @Test public void loginOnInvalidatedSessionCreateSessionTrueSeparateHttpCalls() throws IOException { + final HttpClient client = newSessionClient(); + // explicitly create a new session - final Request requestCreateSession = xqueryRequest("session:create()"); - final HttpResponse createSessionResponse = requestCreateSession - .execute() - .returnResponse(); - assertEquals(HttpStatus.SC_OK, createSessionResponse.getStatusLine().getStatusCode()); - assertEquals("", readEntityAsString(createSessionResponse.getEntity())); + final HttpRequest requestCreateSession = xqueryRequest("session:create()"); + assertRequestResponse(client, requestCreateSession, HttpURLConnection.HTTP_OK, ""); // invalidate the session - final Request requestInvalidateSession = xqueryRequest("session:invalidate()"); - final HttpResponse invalidateSessionResponse = requestInvalidateSession - .execute() - .returnResponse(); - assertEquals(HttpStatus.SC_OK, invalidateSessionResponse.getStatusLine().getStatusCode()); - assertEquals("", readEntityAsString(invalidateSessionResponse.getEntity())); + final HttpRequest requestInvalidateSession = xqueryRequest("session:invalidate()"); + assertRequestResponse(client, requestInvalidateSession, HttpURLConnection.HTTP_OK, ""); // login to the database - final Request requestGetAttr = xqueryRequest("xmldb:login('/db', '" + USER1_UID + "', '" + USER1_PWD + "', true())"); - final HttpResponse getResponse1 = requestGetAttr - .execute() - .returnResponse(); - assertEquals(HttpStatus.SC_OK, getResponse1.getStatusLine().getStatusCode()); - assertEquals("true", readEntityAsString(getResponse1.getEntity())); + final HttpRequest requestGetAttr = xqueryRequest("xmldb:login('/db', '" + USER1_UID + "', '" + USER1_PWD + "', true())"); + assertRequestResponse(client, requestGetAttr, HttpURLConnection.HTTP_OK, "true"); // get the identity of the current user - final Request requestSetAttr1 = xqueryRequest("sm:id()"); - final HttpResponse setResponse1 = requestSetAttr1 - .execute() - .returnResponse(); - assertEquals(HttpStatus.SC_OK, setResponse1.getStatusLine().getStatusCode()); + final HttpRequest requestSetAttr1 = xqueryRequest("sm:id()"); + final HttpResponseResult result = executeForStatusAndBody(client, requestSetAttr1); + assertEquals(HttpURLConnection.HTTP_OK, result.statusCode()); final Source expected = Input.fromString( """ @@ -284,7 +246,7 @@ public void loginOnInvalidatedSessionCreateSessionTrueSeparateHttpCalls() throws """).build(); - final Source actual = Input.fromString(readEntityAsString(setResponse1.getEntity())).build(); + final Source actual = Input.fromString(result.body()).build(); final Diff diff = DiffBuilder.compare(expected) .withTest(actual) .checkForSimilar() @@ -294,29 +256,20 @@ public void loginOnInvalidatedSessionCreateSessionTrueSeparateHttpCalls() throws @Test public void loginOnInvalidatedSessionCreateSessionFalseSameHttpCall() throws IOException { + final HttpClient client = newSessionClient(); + // explicitly create a new session - final Request requestCreateSession = xqueryRequest("session:create()"); - final HttpResponse createSessionResponse = requestCreateSession - .execute() - .returnResponse(); - assertEquals(HttpStatus.SC_OK, createSessionResponse.getStatusLine().getStatusCode()); - assertEquals("", readEntityAsString(createSessionResponse.getEntity())); + final HttpRequest requestCreateSession = xqueryRequest("session:create()"); + assertRequestResponse(client, requestCreateSession, HttpURLConnection.HTTP_OK, ""); // invalidate the session and login to the database - final Request requestInvalidateSession = xqueryRequest("session:invalidate(), xmldb:login('/db', '" + USER1_UID + "', '" + USER1_PWD + "', false())"); - final HttpResponse invalidateSessionResponse = requestInvalidateSession - .execute() - .returnResponse(); - final String responseBody = readEntityAsString(invalidateSessionResponse.getEntity()); - assertEquals(responseBody, HttpStatus.SC_OK, invalidateSessionResponse.getStatusLine().getStatusCode()); - assertEquals("true", responseBody); + final HttpRequest requestInvalidateSession = xqueryRequest("session:invalidate(), xmldb:login('/db', '" + USER1_UID + "', '" + USER1_PWD + "', false())"); + assertRequestResponse(client, requestInvalidateSession, HttpURLConnection.HTTP_OK, "true"); // get the identity of the current user - final Request requestSetAttr1 = xqueryRequest("sm:id()"); - final HttpResponse setResponse1 = requestSetAttr1 - .execute() - .returnResponse(); - assertEquals(HttpStatus.SC_OK, setResponse1.getStatusLine().getStatusCode()); + final HttpRequest requestSetAttr1 = xqueryRequest("sm:id()"); + final HttpResponseResult result = executeForStatusAndBody(client, requestSetAttr1); + assertEquals(HttpURLConnection.HTTP_OK, result.statusCode()); final Source expected = Input.fromString( """ @@ -328,7 +281,7 @@ public void loginOnInvalidatedSessionCreateSessionFalseSameHttpCall() throws IOE """).build(); - final Source actual = Input.fromString(readEntityAsString(setResponse1.getEntity())).build(); + final Source actual = Input.fromString(result.body()).build(); final Diff diff = DiffBuilder.compare(expected) .withTest(actual) .checkForSimilar() @@ -338,29 +291,20 @@ public void loginOnInvalidatedSessionCreateSessionFalseSameHttpCall() throws IOE @Test public void loginOnInvalidatedSessionCreateSessionTrueSameHttpCall() throws IOException { + final HttpClient client = newSessionClient(); + // explicitly create a new session - final Request requestCreateSession = xqueryRequest("session:create()"); - final HttpResponse createSessionResponse = requestCreateSession - .execute() - .returnResponse(); - assertEquals(HttpStatus.SC_OK, createSessionResponse.getStatusLine().getStatusCode()); - assertEquals("", readEntityAsString(createSessionResponse.getEntity())); + final HttpRequest requestCreateSession = xqueryRequest("session:create()"); + assertRequestResponse(client, requestCreateSession, HttpURLConnection.HTTP_OK, ""); // invalidate the session and login to the database - final Request requestInvalidateSession = xqueryRequest("session:invalidate(), xmldb:login('/db', '" + USER1_UID + "', '" + USER1_PWD + "', true())"); - final HttpResponse invalidateSessionResponse = requestInvalidateSession - .execute() - .returnResponse(); - final String responseBody = readEntityAsString(invalidateSessionResponse.getEntity()); - assertEquals(responseBody, HttpStatus.SC_OK, invalidateSessionResponse.getStatusLine().getStatusCode()); - assertEquals("true", responseBody); + final HttpRequest requestInvalidateSession = xqueryRequest("session:invalidate(), xmldb:login('/db', '" + USER1_UID + "', '" + USER1_PWD + "', true())"); + assertRequestResponse(client, requestInvalidateSession, HttpURLConnection.HTTP_OK, "true"); // get the identity of the current user - final Request requestSetAttr1 = xqueryRequest("sm:id()"); - final HttpResponse setResponse1 = requestSetAttr1 - .execute() - .returnResponse(); - assertEquals(HttpStatus.SC_OK, setResponse1.getStatusLine().getStatusCode()); + final HttpRequest requestSetAttr1 = xqueryRequest("sm:id()"); + final HttpResponseResult result = executeForStatusAndBody(client, requestSetAttr1); + assertEquals(HttpURLConnection.HTTP_OK, result.statusCode()); final Source expected = Input.fromString( """ @@ -372,7 +316,7 @@ public void loginOnInvalidatedSessionCreateSessionTrueSameHttpCall() throws IOEx """).build(); - final Source actual = Input.fromString(readEntityAsString(setResponse1.getEntity())).build(); + final Source actual = Input.fromString(result.body()).build(); final Diff diff = DiffBuilder.compare(expected) .withTest(actual) .checkForSimilar() @@ -380,18 +324,22 @@ public void loginOnInvalidatedSessionCreateSessionTrueSameHttpCall() throws IOEx assertFalse(diff.toString(), diff.hasDifferences()); } - public Request xqueryRequest(final String xquery) throws UnsupportedEncodingException { - return Request.Get(getCollectionRootUri() + "/?_query=" + URLEncoder.encode(xquery, UTF_8.name()) + "&_wrap=no"); - } - - private static String readEntityAsString(final HttpEntity entity) throws IOException { - return new String(readEntity(entity), UTF_8); + /** + * Create an HTTP client that retains cookies across requests, so the HTTP session + * (JSESSIONID) is preserved across the sequential calls within a single test. + * + * @return a new session-aware {@link HttpClient}. + */ + private static HttpClient newSessionClient() { + return HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .cookieHandler(new CookieManager()) + .build(); } - private static byte[] readEntity(final HttpEntity entity) throws IOException { - try (final UnsynchronizedByteArrayOutputStream os = new UnsynchronizedByteArrayOutputStream()) { - entity.writeTo(os); - return os.toByteArray(); - } + public HttpRequest xqueryRequest(final String xquery) { + return HttpRequest.newBuilder(URI.create(getCollectionRootUri() + "/?_query=" + URLEncoder.encode(xquery, UTF_8) + "&_wrap=no")) + .GET() + .build(); } } diff --git a/exist-parent/pom.xml b/exist-parent/pom.xml index 9e646901f7d..c6fa0df572f 100644 --- a/exist-parent/pom.xml +++ b/exist-parent/pom.xml @@ -123,6 +123,7 @@ 4.5.14 4.4.16 5.0.0 + 1.8.3 2.1.0 1.9.25.1 0.2.1 @@ -319,6 +320,14 @@ + + + com.github.mizosoft.methanol + methanol + ${methanol.version} + + org.apache.httpcomponents httpcore diff --git a/extensions/exquery/restxq/pom.xml b/extensions/exquery/restxq/pom.xml index ec8806776e6..f6d44f036f2 100644 --- a/extensions/exquery/restxq/pom.xml +++ b/extensions/exquery/restxq/pom.xml @@ -157,6 +157,14 @@ + + ${project.groupId} + exist-core + ${project.version} + test-jar + test + + junit junit @@ -172,16 +180,6 @@ xmlunit-core test - - org.apache.httpcomponents - httpcore - test - - - org.apache.httpcomponents - fluent-hc - test - org.exist-db exist-expath diff --git a/extensions/exquery/restxq/src/test/java/org/exist/extensions/exquery/restxq/impl/AbstractClassIntegrationTest.java b/extensions/exquery/restxq/src/test/java/org/exist/extensions/exquery/restxq/impl/AbstractClassIntegrationTest.java index ce815094f63..dd12a1ec503 100644 --- a/extensions/exquery/restxq/src/test/java/org/exist/extensions/exquery/restxq/impl/AbstractClassIntegrationTest.java +++ b/extensions/exquery/restxq/src/test/java/org/exist/extensions/exquery/restxq/impl/AbstractClassIntegrationTest.java @@ -26,27 +26,24 @@ */ package org.exist.extensions.exquery.restxq.impl; -import org.apache.http.HttpHost; -import org.apache.http.client.fluent.Executor; -import org.exist.TestUtils; +import org.exist.http.AbstractHttpTest; import org.exist.test.ExistWebServer; import org.junit.BeforeClass; import org.junit.ClassRule; import java.io.IOException; +import java.net.http.HttpClient; public abstract class AbstractClassIntegrationTest extends AbstractIntegrationTest { @ClassRule public static ExistWebServer existWebServer = new ExistWebServer(true, false, true, true); - protected static Executor executor = null; + protected static HttpClient httpClient = null; @BeforeClass public static void setupExecutor() { - executor = Executor.newInstance() - .auth(TestUtils.ADMIN_DB_USER, TestUtils.ADMIN_DB_PWD) - .authPreemptive(new HttpHost("localhost", existWebServer.getPort())); + httpClient = AbstractHttpTest.newHttpClient(); } protected static String getServerUri() { @@ -62,14 +59,14 @@ protected static String getRestXqUri() { } protected static void enableRestXqTrigger(final String collectionPath) throws IOException { - enableRestXqTrigger(existWebServer, executor, collectionPath); + enableRestXqTrigger(existWebServer, httpClient, collectionPath); } protected static void storeXquery(final String collectionPath, final String xqueryFilename, final String xquery) throws IOException { - storeXquery(existWebServer, executor, collectionPath, xqueryFilename, xquery); + storeXquery(existWebServer, httpClient, collectionPath, xqueryFilename, xquery); } protected static void assertRestXqResourceFunctionsCount(final int expectedCount) throws IOException { - assertRestXqResourceFunctionsCount(existWebServer, executor, expectedCount); + assertRestXqResourceFunctionsCount(existWebServer, httpClient, expectedCount); } } diff --git a/extensions/exquery/restxq/src/test/java/org/exist/extensions/exquery/restxq/impl/AbstractInstanceIntegrationTest.java b/extensions/exquery/restxq/src/test/java/org/exist/extensions/exquery/restxq/impl/AbstractInstanceIntegrationTest.java index 9249c0dbe81..96dc0b91466 100644 --- a/extensions/exquery/restxq/src/test/java/org/exist/extensions/exquery/restxq/impl/AbstractInstanceIntegrationTest.java +++ b/extensions/exquery/restxq/src/test/java/org/exist/extensions/exquery/restxq/impl/AbstractInstanceIntegrationTest.java @@ -26,28 +26,25 @@ */ package org.exist.extensions.exquery.restxq.impl; -import org.apache.http.HttpHost; -import org.apache.http.client.fluent.Executor; -import org.exist.TestUtils; +import org.exist.http.AbstractHttpTest; import org.exist.test.ExistWebServer; import org.junit.Before; import org.junit.Rule; import org.w3c.dom.NodeList; import java.io.IOException; +import java.net.http.HttpClient; public abstract class AbstractInstanceIntegrationTest extends AbstractIntegrationTest { @Rule public ExistWebServer existWebServer = new ExistWebServer(true, false, true, true); - protected Executor executor = null; + protected HttpClient httpClient = null; @Before public void setupExecutor() { - executor = Executor.newInstance() - .auth(TestUtils.ADMIN_DB_USER, TestUtils.ADMIN_DB_PWD) - .authPreemptive(new HttpHost("localhost", existWebServer.getPort())); + httpClient = AbstractHttpTest.newHttpClient(); } protected String getServerUri() { @@ -63,22 +60,22 @@ protected String getRestXqUri() { } protected void enableRestXqTrigger(final String collectionPath) throws IOException { - enableRestXqTrigger(existWebServer, executor, collectionPath); + enableRestXqTrigger(existWebServer, httpClient, collectionPath); } protected void storeXquery(final String collectionPath, final String xqueryFilename, final String xquery) throws IOException { - storeXquery(existWebServer, executor, collectionPath, xqueryFilename, xquery); + storeXquery(existWebServer, httpClient, collectionPath, xqueryFilename, xquery); } protected void removeXquery(final String collectionPath, final String xqueryFilename) throws IOException { - removeXquery(existWebServer, executor, collectionPath, xqueryFilename); + removeXquery(existWebServer, httpClient, collectionPath, xqueryFilename); } protected void assertRestXqResourceFunctionsCount(final int expectedCount) throws IOException { - assertRestXqResourceFunctionsCount(existWebServer, executor, expectedCount); + assertRestXqResourceFunctionsCount(existWebServer, httpClient, expectedCount); } protected NodeList getRestXqResourceFunctions() throws IOException { - return getRestXqResourceFunctions(existWebServer, executor); + return getRestXqResourceFunctions(existWebServer, httpClient); } } diff --git a/extensions/exquery/restxq/src/test/java/org/exist/extensions/exquery/restxq/impl/AbstractIntegrationTest.java b/extensions/exquery/restxq/src/test/java/org/exist/extensions/exquery/restxq/impl/AbstractIntegrationTest.java index d2bcaf358e6..99dd500728d 100644 --- a/extensions/exquery/restxq/src/test/java/org/exist/extensions/exquery/restxq/impl/AbstractIntegrationTest.java +++ b/extensions/exquery/restxq/src/test/java/org/exist/extensions/exquery/restxq/impl/AbstractIntegrationTest.java @@ -26,13 +26,10 @@ */ package org.exist.extensions.exquery.restxq.impl; -import org.apache.http.HttpResponse; -import org.apache.http.HttpStatus; -import org.apache.http.client.fluent.Executor; -import org.apache.http.client.fluent.Request; -import org.apache.http.entity.ContentType; +import org.exist.TestUtils; import org.exist.collections.CollectionConfiguration; import org.exist.dom.memtree.SAXAdapter; +import org.exist.http.AbstractHttpTest; import org.exist.test.ExistWebServer; import org.exist.util.ExistSAXParserFactory; import org.exquery.restxq.Namespace; @@ -50,7 +47,13 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.io.Reader; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import static java.net.HttpURLConnection.HTTP_CREATED; +import static java.net.HttpURLConnection.HTTP_OK; import static java.nio.charset.StandardCharsets.UTF_8; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; @@ -66,8 +69,14 @@ public abstract class AbstractIntegrationTest { """; - private static ContentType XQUERY_CONTENT_TYPE = ContentType.create("application/xquery", UTF_8); + private static String COLLECTION_CONFIG_CONTENT_TYPE = "application/xml; charset=utf-8"; + private static String XQUERY_CONTENT_TYPE = "application/xquery; charset=utf-8"; + + /** + * Standalone test webapp is mounted at {@code /} (see {@code exist.jetty.standalone.webapp.dir}), + * not at {@code /exist} like {@link AbstractHttpTest#getServerUri(ExistWebServer)} in exist-core tests. + */ protected static String getServerUri(final ExistWebServer existWebServer) { return "http://localhost:" + existWebServer.getPort(); } @@ -80,41 +89,50 @@ protected static String getRestXqUri(final ExistWebServer existWebServer) { return getServerUri(existWebServer) + "/restxq"; } - protected static void enableRestXqTrigger(final ExistWebServer existWebServer, final Executor executor, final String collectionPath) throws IOException { - final HttpResponse response = executor.execute(Request - .Put(getRestUri(existWebServer) + "/db/system/config" + collectionPath + "/" + CollectionConfiguration.DEFAULT_COLLECTION_CONFIG_FILE) - .bodyString(COLLECTION_CONFIG, ContentType.APPLICATION_XML.withCharset(UTF_8)) - ).returnResponse(); - assertEquals(HttpStatus.SC_CREATED, response.getStatusLine().getStatusCode()); + /** + * Build a request to the given URI with a preemptive HTTP Basic {@code Authorization} header for + * the eXist-db admin user. + */ + protected static HttpRequest.Builder authenticatedAdminRequest(final String uri) { + return AbstractHttpTest.authenticatedRequest(URI.create(uri), TestUtils.ADMIN_DB_USER, TestUtils.ADMIN_DB_PWD); + } + + protected static void enableRestXqTrigger(final ExistWebServer existWebServer, final HttpClient httpClient, final String collectionPath) throws IOException { + final HttpRequest request = authenticatedAdminRequest(getRestUri(existWebServer) + "/db/system/config" + collectionPath + "/" + CollectionConfiguration.DEFAULT_COLLECTION_CONFIG_FILE) + .header("Content-Type", COLLECTION_CONFIG_CONTENT_TYPE) + .PUT(HttpRequest.BodyPublishers.ofString(COLLECTION_CONFIG, UTF_8)) + .build(); + assertEquals(HTTP_CREATED, AbstractHttpTest.executeForStatus(httpClient, request)); } - protected static void storeXquery(final ExistWebServer existWebServer, final Executor executor, final String collectionPath, final String xqueryFilename, final String xquery) throws IOException { - final HttpResponse response = executor.execute(Request - .Put(getRestUri(existWebServer) + collectionPath + "/" + xqueryFilename) - .bodyString(xquery, XQUERY_CONTENT_TYPE) - ).returnResponse(); - assertEquals(HttpStatus.SC_CREATED, response.getStatusLine().getStatusCode()); + protected static void storeXquery(final ExistWebServer existWebServer, final HttpClient httpClient, final String collectionPath, final String xqueryFilename, final String xquery) throws IOException { + final HttpRequest request = authenticatedAdminRequest(getRestUri(existWebServer) + collectionPath + "/" + xqueryFilename) + .header("Content-Type", XQUERY_CONTENT_TYPE) + .PUT(HttpRequest.BodyPublishers.ofString(xquery, UTF_8)) + .build(); + assertEquals(HTTP_CREATED, AbstractHttpTest.executeForStatus(httpClient, request)); } - protected static void removeXquery(final ExistWebServer existWebServer, final Executor executor, final String collectionPath, final String xqueryFilename) throws IOException { - final HttpResponse response = executor.execute(Request - .Delete(getRestUri(existWebServer) + collectionPath + "/" + xqueryFilename) - ).returnResponse(); - assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode()); + protected static void removeXquery(final ExistWebServer existWebServer, final HttpClient httpClient, final String collectionPath, final String xqueryFilename) throws IOException { + final HttpRequest request = authenticatedAdminRequest(getRestUri(existWebServer) + collectionPath + "/" + xqueryFilename) + .DELETE() + .build(); + assertEquals(HTTP_OK, AbstractHttpTest.executeForStatus(httpClient, request)); } - protected static void assertRestXqResourceFunctionsCount(final ExistWebServer existWebServer, final Executor executor, final int expectedCount) throws IOException { - assertEquals(expectedCount, getRestXqResourceFunctions(existWebServer, executor).getLength()); + protected static void assertRestXqResourceFunctionsCount(final ExistWebServer existWebServer, final HttpClient httpClient, final int expectedCount) throws IOException { + assertEquals(expectedCount, getRestXqResourceFunctions(existWebServer, httpClient).getLength()); } - protected static NodeList getRestXqResourceFunctions(final ExistWebServer existWebServer, final Executor executor) throws IOException { - final HttpResponse response = executor.execute(Request - .Get(getRestUri(existWebServer) + "/db/?_query=rest:resource-functions()") - ).returnResponse(); - assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode()); + protected static NodeList getRestXqResourceFunctions(final ExistWebServer existWebServer, final HttpClient httpClient) throws IOException { + final HttpRequest request = authenticatedAdminRequest(getRestUri(existWebServer) + "/db/?_query=rest:resource-functions()") + .GET() + .build(); + final HttpResponse response = send(httpClient, request); + assertEquals(HTTP_OK, response.statusCode()); final Document doc; - try (final InputStream is = response.getEntity().getContent()) { + try (final InputStream is = response.body()) { assertNotNull(is); doc = parseXml(is); } @@ -129,6 +147,19 @@ protected static NodeList getRestXqResourceFunctions(final ExistWebServer existW return resourceFunctionsElem.getElementsByTagNameNS(Namespace.ANNOTATION_NS, "resource-function"); } + /** + * Send a request reading the body as an {@link InputStream}, translating the checked + * {@link InterruptedException} thrown by {@link HttpClient#send} into an {@link IOException}. + */ + private static HttpResponse send(final HttpClient httpClient, final HttpRequest request) throws IOException { + try { + return httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while awaiting HTTP response", e); + } + } + protected static Document parseXml(final InputStream inputStream) throws IOException { final SAXParserFactory saxParserFactory = ExistSAXParserFactory.getSAXParserFactory(); saxParserFactory.setNamespaceAware(true); diff --git a/extensions/exquery/restxq/src/test/java/org/exist/extensions/exquery/restxq/impl/MediaTypeIntegrationTest.java b/extensions/exquery/restxq/src/test/java/org/exist/extensions/exquery/restxq/impl/MediaTypeIntegrationTest.java index d474fd4fd3e..9a957aed1f1 100644 --- a/extensions/exquery/restxq/src/test/java/org/exist/extensions/exquery/restxq/impl/MediaTypeIntegrationTest.java +++ b/extensions/exquery/restxq/src/test/java/org/exist/extensions/exquery/restxq/impl/MediaTypeIntegrationTest.java @@ -26,20 +26,16 @@ */ package org.exist.extensions.exquery.restxq.impl; -import org.apache.http.HttpEntity; -import org.apache.http.HttpResponse; -import org.apache.http.HttpStatus; -import org.apache.http.client.fluent.Request; -import org.apache.http.entity.ContentType; -import org.apache.http.message.BasicHeader; import org.junit.BeforeClass; import org.junit.Test; import java.io.IOException; import java.io.InputStream; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import static java.net.HttpURLConnection.HTTP_OK; import static org.junit.Assert.assertEquals; -import static java.nio.charset.StandardCharsets.UTF_8; public class MediaTypeIntegrationTest extends AbstractClassIntegrationTest { @@ -105,47 +101,62 @@ public static void storeResourceFunctions() throws IOException { @Test public void mediaTypeJson1() throws IOException { - assertMediaTypeResponse("/media-type-json1", ContentType.APPLICATION_JSON, - "application/json; charset=utf-8", + assertMediaTypeResponse("/media-type-json1", "application/json", + "application/json;charset=utf-8", "{ \"firstName\" : \"Adam\", \"lastName\" : \"Retter\" }"); } @Test public void mediaTypeJson2() throws IOException { - assertMediaTypeResponse("/media-type-json2", ContentType.APPLICATION_JSON, - "application/json; charset=utf-8", + assertMediaTypeResponse("/media-type-json2", "application/json", + "application/json;charset=utf-8", "{ \"firstName\" : \"Adam\", \"lastName\" : \"Retter\" }"); } @Test public void mediaTypeXml1() throws IOException { - assertMediaTypeResponse("/media-type-xml1", ContentType.APPLICATION_XML.withCharset(UTF_8), - ContentType.APPLICATION_XML.withCharset(UTF_8).toString(), + assertMediaTypeResponse("/media-type-xml1", "application/xml; charset=utf-8", + "application/xml; charset=UTF-8", "AdamRetter"); } @Test public void mediaTypeXml2() throws IOException { - assertMediaTypeResponse("/media-type-xml2", ContentType.APPLICATION_XML.withCharset(UTF_8), - ContentType.APPLICATION_XML.withCharset(UTF_8).toString(), + assertMediaTypeResponse("/media-type-xml2", "application/xml; charset=utf-8", + "application/xml; charset=UTF-8", "AdamRetter"); } - private void assertMediaTypeResponse(final String uriEndpoint, final ContentType acceptContentType, final String expectedResponseContentType, final String expectedResponseBody) throws IOException { - final HttpResponse response = executor.execute(Request - .Get(getRestXqUri() + uriEndpoint) - .addHeader(new BasicHeader("Accept", acceptContentType.toString())) - ).returnResponse(); + private void assertMediaTypeResponse(final String uriEndpoint, final String acceptContentType, final String expectedResponseContentType, final String expectedResponseBody) throws IOException { + final HttpRequest request = authenticatedAdminRequest(getRestXqUri() + uriEndpoint) + .header("Accept", acceptContentType) + .GET() + .build(); - assertEquals(HttpStatus.SC_OK, response.getStatusLine().getStatusCode()); + final HttpResponse response = send(request); - final HttpEntity responseEntity = response.getEntity(); - assertEquals(expectedResponseContentType, responseEntity.getContentType().getValue()); + assertEquals(HTTP_OK, response.statusCode()); + + assertEquals(expectedResponseContentType, response.headers().firstValue("Content-Type").orElse(null)); final String responseBody; - try (final InputStream is = responseEntity.getContent()) { + try (final InputStream is = response.body()) { responseBody = asString(is); } assertEquals(expectedResponseBody, responseBody); } + + /** + * Send a request reading the body as an {@link InputStream}, translating the checked + * {@link InterruptedException} thrown by {@link java.net.http.HttpClient#send} into an + * {@link IOException}. + */ + private HttpResponse send(final HttpRequest request) throws IOException { + try { + return httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while awaiting HTTP response", e); + } + } } diff --git a/extensions/modules/file/pom.xml b/extensions/modules/file/pom.xml index 5fc4f4461f2..cb62b00236c 100644 --- a/extensions/modules/file/pom.xml +++ b/extensions/modules/file/pom.xml @@ -108,28 +108,24 @@ test - - junit - junit - test - - ${project.groupId} - exist-jetty-config + exist-core ${project.version} + test-jar test - org.apache.httpcomponents - httpcore + junit + junit test - org.apache.httpcomponents - fluent-hc + ${project.groupId} + exist-jetty-config + ${project.version} test diff --git a/extensions/modules/file/src/test/java/org/exist/xquery/modules/file/RestBinariesTest.java b/extensions/modules/file/src/test/java/org/exist/xquery/modules/file/RestBinariesTest.java index de93012eda0..9e16324ad4f 100644 --- a/extensions/modules/file/src/test/java/org/exist/xquery/modules/file/RestBinariesTest.java +++ b/extensions/modules/file/src/test/java/org/exist/xquery/modules/file/RestBinariesTest.java @@ -23,18 +23,12 @@ import org.apache.commons.codec.binary.Base64; import org.apache.commons.codec.binary.Hex; -import org.apache.http.HttpEntity; -import org.apache.http.HttpHost; -import org.apache.http.HttpResponse; -import org.apache.http.client.fluent.Executor; -import org.apache.http.client.fluent.Request; -import org.apache.http.entity.ContentType; +import org.exist.http.AbstractHttpTest; import org.exist.http.jaxb.Query; import org.exist.http.jaxb.Result; import org.exist.test.ExistWebServer; import org.apache.commons.io.output.UnsynchronizedByteArrayOutputStream; import org.exist.xmldb.XmldbURI; -import org.junit.BeforeClass; import org.junit.ClassRule; import org.junit.Test; @@ -42,13 +36,17 @@ import jakarta.xml.bind.JAXBException; import jakarta.xml.bind.Marshaller; import jakarta.xml.bind.Unmarshaller; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; import java.nio.file.Files; import java.nio.file.Path; -import static org.apache.http.HttpStatus.SC_CREATED; -import static org.apache.http.HttpStatus.SC_OK; +import static java.net.HttpURLConnection.HTTP_CREATED; +import static java.net.HttpURLConnection.HTTP_OK; import static org.exist.TestUtils.ADMIN_DB_PWD; import static org.exist.TestUtils.ADMIN_DB_USER; import static org.junit.Assert.assertArrayEquals; @@ -61,13 +59,12 @@ public class RestBinariesTest extends AbstractBinariesTest executeXQuery(final String xquery) throws Exception { - final HttpResponse response = postXquery(xquery); - final HttpEntity entity = response.getEntity(); - try(final InputStream is = entity.getContent()) { + final byte[] body = postXquery(xquery); + try (final InputStream is = new ByteArrayInputStream(body)) { final JAXBContext jaxbContext = JAXBContext.newInstance("org.exist.http.jaxb"); final Unmarshaller unmarshaller = jaxbContext.createUnmarshaller(); final Result result = (Result)unmarshaller.unmarshal(is); @@ -160,26 +153,37 @@ protected QueryResultAccessor executeXQuery(final String xque } } - private HttpResponse postXquery(final String xquery) throws JAXBException, IOException { + private byte[] postXquery(final String xquery) throws JAXBException, IOException { final Query query = new Query(); query.setText(xquery); final JAXBContext jaxbContext = JAXBContext.newInstance("org.exist.http.jaxb"); final Marshaller marshaller = jaxbContext.createMarshaller(); - final HttpResponse response; - try(final UnsynchronizedByteArrayOutputStream baos = UnsynchronizedByteArrayOutputStream.builder().get()) { + final byte[] requestBody; + try (final UnsynchronizedByteArrayOutputStream baos = UnsynchronizedByteArrayOutputStream.builder().get()) { marshaller.marshal(query, baos); - response = executor.execute(Request.Post(getRestUrl() + "/db/") - .bodyByteArray(baos.toByteArray(), ContentType.APPLICATION_XML) - ).returnResponse(); + requestBody = baos.toByteArray(); + } + + final HttpRequest request = authenticatedRequest(getRestUrl() + "/db/") + .header("Content-Type", "application/xml") + .POST(HttpRequest.BodyPublishers.ofByteArray(requestBody)) + .build(); + + final HttpResponse response; + try { + response = AbstractHttpTest.newHttpClient().send(request, HttpResponse.BodyHandlers.ofByteArray()); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while awaiting HTTP response", e); } - if(response.getStatusLine().getStatusCode() != SC_OK) { - throw new IOException("Unable to query, HTTP response code: " + response.getStatusLine().getStatusCode()); + if (response.statusCode() != HTTP_OK) { + throw new IOException("Unable to query, HTTP response code: " + response.statusCode()); } - return response; + return response.body(); } @Override diff --git a/extensions/modules/persistentlogin/pom.xml b/extensions/modules/persistentlogin/pom.xml index 98db46565d7..7347b9abfcf 100644 --- a/extensions/modules/persistentlogin/pom.xml +++ b/extensions/modules/persistentlogin/pom.xml @@ -99,18 +99,6 @@ test - - org.apache.httpcomponents - httpcore - test - - - - org.apache.httpcomponents - httpclient - test - - diff --git a/extensions/modules/persistentlogin/src/test/java/org/exist/xquery/modules/persistentlogin/LoginModuleIT.java b/extensions/modules/persistentlogin/src/test/java/org/exist/xquery/modules/persistentlogin/LoginModuleIT.java index c6b9dac9a64..7a7dc5b5fae 100644 --- a/extensions/modules/persistentlogin/src/test/java/org/exist/xquery/modules/persistentlogin/LoginModuleIT.java +++ b/extensions/modules/persistentlogin/src/test/java/org/exist/xquery/modules/persistentlogin/LoginModuleIT.java @@ -21,16 +21,6 @@ */ package org.exist.xquery.modules.persistentlogin; -import org.apache.http.HttpEntity; -import org.apache.http.HttpResponse; -import org.apache.http.client.config.CookieSpecs; -import org.apache.http.client.config.RequestConfig; -import org.apache.http.client.methods.HttpGet; -import org.apache.http.client.protocol.HttpClientContext; -import org.apache.http.impl.client.BasicCookieStore; -import org.apache.http.impl.client.CloseableHttpClient; -import org.apache.http.impl.client.HttpClientBuilder; -import org.apache.http.util.EntityUtils; import org.exist.TestUtils; import org.exist.test.ExistWebServer; import org.exist.xmldb.EXistResource; @@ -47,8 +37,14 @@ import javax.annotation.Nullable; import java.io.IOException; - -import static org.apache.http.HttpStatus.SC_OK; +import java.net.CookieManager; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; + +import static java.net.HttpURLConnection.HTTP_OK; import static org.junit.Assert.assertEquals; /** @@ -68,9 +64,7 @@ public class LoginModuleIT { private final static String XQUERY_FILENAME = "test-login.xql"; private static Collection root; - private static CloseableHttpClient client; - private static BasicCookieStore cookieStore; - private static HttpClientContext httpContext; + private static HttpClient client; @BeforeClass public static void beforeClass() throws XMLDBException { @@ -105,23 +99,15 @@ public static void beforeClass() throws XMLDBException { final UserManagementService ums = root.getService(UserManagementService.class); ums.chmod(res, 0777); - cookieStore = new BasicCookieStore(); - httpContext = HttpClientContext.create(); - httpContext.setCookieStore(cookieStore); - // Jetty 12 emits RFC 6265 Set-Cookie (RFC1123 Expires). HttpClient 4.x DEFAULT (NetscapeDraftSpec) - // rejects that format; STANDARD is required for automatic cookie storage. See jetty/jetty.project#12771. - client = HttpClientBuilder.create() - .setDefaultRequestConfig(RequestConfig.custom() - .setCookieSpec(CookieSpecs.STANDARD) - .build()) + // an in-memory cookie store keeps the login session across requests + client = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .cookieHandler(new CookieManager()) .build(); } @AfterClass public static void afterClass() throws Exception { - if (client != null) { - client.close(); - } if (root != null) { final org.xmldb.api.base.Resource res = root.getResource(XQUERY_FILENAME); if (res != null) { @@ -146,12 +132,18 @@ public void loginAndLogout() throws IOException { } private void doGet(@Nullable String params, String expected) throws IOException { - final HttpGet httpGet = new HttpGet("http://localhost:" + existWebServer.getPort() + "/rest" + XmldbURI.ROOT_COLLECTION + '/' + XQUERY_FILENAME + - (params == null ? "" : "?" + params)); - HttpResponse response = client.execute(httpGet, httpContext); - HttpEntity entity = response.getEntity(); - final String responseBody = EntityUtils.toString(entity); - assertEquals(responseBody, SC_OK, response.getStatusLine().getStatusCode()); + final String url = "http://localhost:" + existWebServer.getPort() + "/rest" + XmldbURI.ROOT_COLLECTION + '/' + XQUERY_FILENAME + + (params == null ? "" : "?" + params); + final HttpRequest request = HttpRequest.newBuilder(URI.create(url)).GET().build(); + final HttpResponse response; + try { + response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while awaiting HTTP response", e); + } + final String responseBody = response.body(); + assertEquals(responseBody, HTTP_OK, response.statusCode()); assertEquals(expected, responseBody); } From efc6d5872d4a984270a15f618054509fcdec0394 Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Mon, 15 Jun 2026 00:53:45 -0400 Subject: [PATCH 2/8] [test] Replace milton-client WebDAV tests with java.net.http round-trip tests The WebDAV integration tests were the last test code pulling in Apache HttpClient: they drove the server through the milton-client WebDAV library, which depends on Apache HttpClient. Replace them with JDK java.net.http round-trip tests and finish removing the now-unused Milton stack (the WebDAV server itself already runs on Apache Jackrabbit since the Jackrabbit migration). Why delete rather than port the old tests: their WebDAV *protocol* coverage (COPY, MOVE, LOCK/UNLOCK, PROPFIND, DELETE) is already provided, far more thoroughly, by the litmus compliance suite that runs in container CI (exist-docker/src/test/bats/ 04-webdav-litmus.bats -- 98/98: basic 16, copymove 13, props 33, locks 36). The old milton-client JUnit tests were both redundant with litmus and coupled to the milton client library this PR removes. WebDavRoundTripTest is intentionally narrower and complementary: it covers the eXist-specific concern litmus does not -- that content stored over WebDAV round-trips faithfully through eXist's storage and serialization. - Delete the milton-client-based tests (Copy/Delete/Lock/Rename/Replace/Serialization/ StoreAndRetrieve/CData) and the com.ettrema cache + AlwaysBasicPreAuth test helpers. - Add WebDavRoundTripTest with a small WebDavHttpClient helper (JDK java.net.http, PUT/GET/DELETE against /webdav/db). It asserts exact round-trip of an XML DOCTYPE, an XML declaration, a CDATA section, namespace declarations, non-ASCII content, and a binary document. - standalone webapp web.xml: map the WebDAV path to the Jackrabbit ExistWebdavServlet instead of the long-deleted MiltonWebDAVServlet (a dangling reference on develop that the round-trip test now depends on being correct). - Drop the milton-client (and its Apache httpclient/httpcore) dependencies from the webdav pom, the com.bradmcevoy log4j2 logger, and the ettrema dependabot group. - BUILD.md: document running the WebDAV round-trip tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/dependabot.yml | 6 - BUILD.md | 12 ++ exist-distribution/src/main/config/log4j2.xml | 5 - .../standalone-webapp/WEB-INF/web.xml | 42 ++--- extensions/webdav/pom.xml | 35 +--- .../test/java/com/ettrema/cache/Cache.java | 37 ----- .../java/com/ettrema/cache/MemoryCache.java | 59 ------- .../org/exist/webdav/AlwaysBasicPreAuth.java | 45 ------ .../exist/webdav/CDataIntergationTest.java | 107 ------------- .../test/java/org/exist/webdav/CopyTest.java | 129 --------------- .../java/org/exist/webdav/DeleteTest.java | 119 -------------- .../test/java/org/exist/webdav/LockTest.java | 131 --------------- .../java/org/exist/webdav/RenameTest.java | 119 -------------- .../java/org/exist/webdav/ReplaceTest.java | 131 --------------- .../org/exist/webdav/SerializationTest.java | 145 ----------------- .../exist/webdav/StoreAndRetrieveTest.java | 115 -------------- .../org/exist/webdav/WebDavHttpClient.java | 85 ++++++++++ .../org/exist/webdav/WebDavRoundTripTest.java | 149 ++++++++++++++++++ 18 files changed, 256 insertions(+), 1215 deletions(-) delete mode 100644 extensions/webdav/src/test/java/com/ettrema/cache/Cache.java delete mode 100644 extensions/webdav/src/test/java/com/ettrema/cache/MemoryCache.java delete mode 100644 extensions/webdav/src/test/java/org/exist/webdav/AlwaysBasicPreAuth.java delete mode 100644 extensions/webdav/src/test/java/org/exist/webdav/CDataIntergationTest.java delete mode 100644 extensions/webdav/src/test/java/org/exist/webdav/CopyTest.java delete mode 100644 extensions/webdav/src/test/java/org/exist/webdav/DeleteTest.java delete mode 100644 extensions/webdav/src/test/java/org/exist/webdav/LockTest.java delete mode 100644 extensions/webdav/src/test/java/org/exist/webdav/RenameTest.java delete mode 100644 extensions/webdav/src/test/java/org/exist/webdav/ReplaceTest.java delete mode 100644 extensions/webdav/src/test/java/org/exist/webdav/SerializationTest.java delete mode 100644 extensions/webdav/src/test/java/org/exist/webdav/StoreAndRetrieveTest.java create mode 100644 extensions/webdav/src/test/java/org/exist/webdav/WebDavHttpClient.java create mode 100644 extensions/webdav/src/test/java/org/exist/webdav/WebDavRoundTripTest.java diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 22c86b8c21d..a048222f89b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -105,12 +105,6 @@ updates: update-types: - patch - minor - milton-webdav: - patterns: - - "org.exist-db.thirdparty.com.ettrema:*" - update-types: - - patch - - minor maven-plugins-apache: patterns: - "org.apache.maven.plugins:*" diff --git a/BUILD.md b/BUILD.md index 33912d0dde1..19bc595729c 100644 --- a/BUILD.md +++ b/BUILD.md @@ -66,6 +66,17 @@ Add all three to `~/.m2/settings.xml`. **The same GitHub PAT (with the `read:pac CI generates the same shape from secrets via `.github/actions/maven-github-settings/action.yml`. +If a previous resolve failed with 401, Maven may cache the failure as `*.lastUpdated` under `~/.m2/repository/`. After fixing `settings.xml`, delete that artifact directory or add `-U` on the next build. + +Example: verify Jackrabbit resolves after configuring auth: + +```bash +rm -rf ~/.m2/repository/org/exist-db/thirdparty/org/apache/jackrabbit/jackrabbit-webdav/2.22.3-jakarta-ee10 +mvn dependency:get \ + -Dartifact=org.exist-db.thirdparty.org.apache.jackrabbit:jackrabbit-webdav:2.22.3-jakarta-ee10 \ + -U -Ddependency-check.skip=true +``` + Further build options can be found at: [eXist-db Build Documentation](http://www.exist-db.org/exist/apps/doc/exist-building.xml "How to build eXist") and on the workflow files of this repo. ### Running tests locally @@ -75,6 +86,7 @@ From the repo root: - **All tests:** `mvn -V -B verify -Ddependency-check.skip -Dlicense.skip` - **exist-core only:** add `--projects exist-core --also-make` to the above - **Single test class:** `mvn -Dtest=fully.qualified.TestClass test --projects exist-core --also-make` +- **WebDAV round-trip tests:** `mvn test -pl extensions/webdav --also-make -Dtest=org.exist.webdav.WebDavRoundTripTest -Dsurefire.failIfNoSpecifiedTests=false` (requires `github-jackrabbit-webdav-jakarta` auth; litmus compliance runs in Docker CI) **NOTE:** In the above example, we switched the current (checked-out) branch from `develop` to `master`. We use the [GitFlow for eXist-db](#contributing-to-exist) process: diff --git a/exist-distribution/src/main/config/log4j2.xml b/exist-distribution/src/main/config/log4j2.xml index aec89bdc376..99fe7049bc4 100644 --- a/exist-distribution/src/main/config/log4j2.xml +++ b/exist-distribution/src/main/config/log4j2.xml @@ -209,11 +209,6 @@ - - - - - diff --git a/exist-jetty-config/src/main/resources/standalone-webapp/WEB-INF/web.xml b/exist-jetty-config/src/main/resources/standalone-webapp/WEB-INF/web.xml index dd33e81a13b..6f66ad5d0fd 100644 --- a/exist-jetty-config/src/main/resources/standalone-webapp/WEB-INF/web.xml +++ b/exist-jetty-config/src/main/resources/standalone-webapp/WEB-INF/web.xml @@ -81,40 +81,10 @@ - + - milton - org.exist.webdav.MiltonWebDAVServlet - - - - resource.factory.class - org.exist.webdav.ExistResourceFactory - - - - - - - + webdav + org.exist.webdav.ExistWebdavServlet @@ -178,6 +148,12 @@ controller-config.xml. However, please note that some features of the website will only work if XQueryURLRewrite controls the /rest servlet (EXistServlet). --> + + + webdav + /webdav/* + + XQueryURLRewrite /* diff --git a/extensions/webdav/pom.xml b/extensions/webdav/pom.xml index 072e402be5c..a964df9d47b 100644 --- a/extensions/webdav/pom.xml +++ b/extensions/webdav/pom.xml @@ -67,9 +67,6 @@ jackrabbit-webdav - - - jakarta.servlet jakarta.servlet-api @@ -92,36 +89,6 @@ test - - - org.exist-db.thirdparty.com.ettrema - milton-client - test - - - org.exist-db.thirdparty.com.ettrema - milton-api - test - - - - com.google.code.findbugs - jsr305 - test - - - - org.apache.httpcomponents - httpclient - test - - - - org.apache.httpcomponents - httpcore - test - - org.eclipse.jetty @@ -172,11 +139,11 @@ true - org.jdom:jdom:jar commons-beanutils:commons-beanutils ${project.groupId}:exist-jetty-config:jar:${project.version} + org.eclipse.jetty:jetty-util:jar:${jetty.version} org.eclipse.jetty:jetty-deploy:jar:${jetty.version} org.eclipse.jetty:jetty-jmx:jar:${jetty.version} diff --git a/extensions/webdav/src/test/java/com/ettrema/cache/Cache.java b/extensions/webdav/src/test/java/com/ettrema/cache/Cache.java deleted file mode 100644 index c60e17b7efa..00000000000 --- a/extensions/webdav/src/test/java/com/ettrema/cache/Cache.java +++ /dev/null @@ -1,37 +0,0 @@ -/* - * eXist-db Open Source Native XML Database - * Copyright (C) 2001 The eXist-db Authors - * - * info@exist-db.org - * http://www.exist-db.org - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 2.1 of the License, or (at your option) any later version. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public - * License along with this library; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - */ -package com.ettrema.cache; - -import javax.annotation.Nullable; - -/** - * Simple guess implementation of a class - * that is missing from Milton but is required by Milton Client. - */ -public interface Cache { - - @Nullable V get(final K key); - - void put(final K key, final V value); - - @Nullable void remove(final K key); -} diff --git a/extensions/webdav/src/test/java/com/ettrema/cache/MemoryCache.java b/extensions/webdav/src/test/java/com/ettrema/cache/MemoryCache.java deleted file mode 100644 index 60d2930382b..00000000000 --- a/extensions/webdav/src/test/java/com/ettrema/cache/MemoryCache.java +++ /dev/null @@ -1,59 +0,0 @@ -/* - * eXist-db Open Source Native XML Database - * Copyright (C) 2001 The eXist-db Authors - * - * info@exist-db.org - * http://www.exist-db.org - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 2.1 of the License, or (at your option) any later version. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public - * License along with this library; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - */ -package com.ettrema.cache; - -import javax.annotation.Nullable; -import java.util.HashMap; -import java.util.Map; - -/** - * Simple guess implementation of a class - * that is missing from Milton but is required by Milton Client. - */ -public class MemoryCache implements Cache { - private final String name; - private final int max; - private final int min; - - private final Map storage = new HashMap<>(); - - public MemoryCache(final String name, final int max, final int min) { - this.name = name; - this.max = max; - this.min = min; - } - - @Override - public @Nullable V get(final K key) { - return storage.get(key); - } - - @Override - public void put(final K key, final V value) { - storage.put(key, value); - } - - @Override - public @Nullable void remove(final K key) { - storage.remove(key); - } -} diff --git a/extensions/webdav/src/test/java/org/exist/webdav/AlwaysBasicPreAuth.java b/extensions/webdav/src/test/java/org/exist/webdav/AlwaysBasicPreAuth.java deleted file mode 100644 index 6622e74ce05..00000000000 --- a/extensions/webdav/src/test/java/org/exist/webdav/AlwaysBasicPreAuth.java +++ /dev/null @@ -1,45 +0,0 @@ -/* - * eXist-db Open Source Native XML Database - * Copyright (C) 2001 The eXist-db Authors - * - * info@exist-db.org - * http://www.exist-db.org - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 2.1 of the License, or (at your option) any later version. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public - * License along with this library; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - */ -package org.exist.webdav; - -import org.apache.http.HttpRequest; -import org.apache.http.HttpRequestInterceptor; -import org.apache.http.protocol.HttpContext; - -import java.nio.charset.StandardCharsets; -import java.util.Base64; - -class AlwaysBasicPreAuth implements HttpRequestInterceptor { - private final String username; - private final String password; - - public AlwaysBasicPreAuth(final String username, final String password) { - this.username = username; - this.password = password; - } - - @Override - public void process(final HttpRequest request, final HttpContext context) { - String token = Base64.getEncoder().encodeToString((username + ":" + password).getBytes(StandardCharsets.UTF_8)); - request.addHeader("Authorization", "Basic " + token); - } -} diff --git a/extensions/webdav/src/test/java/org/exist/webdav/CDataIntergationTest.java b/extensions/webdav/src/test/java/org/exist/webdav/CDataIntergationTest.java deleted file mode 100644 index 78c2dff9426..00000000000 --- a/extensions/webdav/src/test/java/org/exist/webdav/CDataIntergationTest.java +++ /dev/null @@ -1,107 +0,0 @@ -/* - * eXist-db Open Source Native XML Database - * Copyright (C) 2001 The eXist-db Authors - * - * info@exist-db.org - * http://www.exist-db.org - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 2.1 of the License, or (at your option) any later version. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public - * License along with this library; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - */ - -package org.exist.webdav; - -import com.bradmcevoy.http.exceptions.BadRequestException; -import com.bradmcevoy.http.exceptions.ConflictException; -import com.bradmcevoy.http.exceptions.NotAuthorizedException; -import com.bradmcevoy.http.exceptions.NotFoundException; -import com.ettrema.httpclient.*; -import org.apache.http.impl.client.AbstractHttpClient; -import org.exist.TestUtils; -import org.exist.test.ExistWebServer; -import org.junit.AfterClass; -import org.junit.BeforeClass; -import org.junit.ClassRule; -import org.junit.Test; -import org.junit.rules.TemporaryFolder; - -import java.io.IOException; -import java.nio.file.Files; - -import static java.nio.charset.StandardCharsets.UTF_8; -import static org.junit.Assert.*; - -/** - * Tests for retrieving a document containing CDATA via - * WebDAV. - */ -public class CDataIntergationTest { - - private static final String CDATA_CONTENT = "Hello there, \"Bob?\""; - private static final String CDATA_XML = ""; - - @ClassRule - public static final ExistWebServer EXIST_WEB_SERVER = new ExistWebServer(true, false, true, true); - - @ClassRule - public static final TemporaryFolder TEMP_FOLDER = new TemporaryFolder(); - - private static String PREV_PROPFIND_METHOD_XML_SIZE = null; - - @BeforeClass - public static void setup() { - PREV_PROPFIND_METHOD_XML_SIZE = System.setProperty("org.exist.webdav.PROPFIND_METHOD_XML_SIZE", "exact"); - } - - @AfterClass - public static void cleanup() { - if (PREV_PROPFIND_METHOD_XML_SIZE == null) { - System.clearProperty("org.exist.webdav.PROPFIND_METHOD_XML_SIZE"); - } else { - System.setProperty("org.exist.webdav.PROPFIND_METHOD_XML_SIZE", PREV_PROPFIND_METHOD_XML_SIZE); - } - } - - @Test - public void cdataWebDavApi() throws IOException, NotAuthorizedException, BadRequestException, HttpException, ConflictException, NotFoundException { - final String docName = "webdav-cdata-test.xml"; - final HostBuilder builder = new HostBuilder(); - builder.setServer("localhost"); - final int port = EXIST_WEB_SERVER.getPort(); - builder.setPort(port); - builder.setRootPath("webdav/db"); - final Host host = builder.buildHost(); - - // workaround pre-emptive auth issues of Milton Client - final AbstractHttpClient httpClient = (AbstractHttpClient)host.getClient(); - httpClient.addRequestInterceptor(new AlwaysBasicPreAuth(TestUtils.ADMIN_DB_USER, TestUtils.ADMIN_DB_PWD)); - - final Folder folder = host.getFolder("/"); - assertNotNull(folder); - - // store document - final java.io.File tmpStoreFile = TEMP_FOLDER.newFile(); - Files.writeString(tmpStoreFile.toPath(), CDATA_XML); - assertNotNull(folder.uploadFile(docName, tmpStoreFile, null)); - - // retrieve document - final Resource resource = folder.child(docName); - assertNotNull(resource); - assertTrue(resource instanceof File); - assertEquals("application/xml", ((File) resource).contentType); - final java.io.File tempRetrieveFile = TEMP_FOLDER.newFile(); - resource.downloadTo(tempRetrieveFile, null); - assertEquals(CDATA_XML, Files.readString(tempRetrieveFile.toPath())); - } -} diff --git a/extensions/webdav/src/test/java/org/exist/webdav/CopyTest.java b/extensions/webdav/src/test/java/org/exist/webdav/CopyTest.java deleted file mode 100644 index 1bad6b8fdd8..00000000000 --- a/extensions/webdav/src/test/java/org/exist/webdav/CopyTest.java +++ /dev/null @@ -1,129 +0,0 @@ -/* - * eXist-db Open Source Native XML Database - * Copyright (C) 2001 The eXist-db Authors - * - * info@exist-db.org - * http://www.exist-db.org - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 2.1 of the License, or (at your option) any later version. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public - * License along with this library; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - */ - -package org.exist.webdav; - -import com.bradmcevoy.http.exceptions.BadRequestException; -import com.bradmcevoy.http.exceptions.ConflictException; -import com.bradmcevoy.http.exceptions.NotAuthorizedException; -import com.bradmcevoy.http.exceptions.NotFoundException; -import com.ettrema.httpclient.*; -import org.apache.http.impl.client.AbstractHttpClient; -import org.exist.TestUtils; -import org.exist.test.ExistWebServer; -import org.junit.AfterClass; -import org.junit.BeforeClass; -import org.junit.ClassRule; -import org.junit.Test; -import org.junit.rules.TemporaryFolder; - -import java.io.IOException; -import java.nio.file.Files; - -import static java.nio.charset.StandardCharsets.UTF_8; -import static org.junit.Assert.*; - -/** - * Tests for copying a document via WebDAV. - */ -public class CopyTest { - - @ClassRule - public static final ExistWebServer existWebServer = new ExistWebServer(true, false, true, true); - - @ClassRule - public static final TemporaryFolder tempFolder = new TemporaryFolder(); - - private static String PREV_PROPFIND_METHOD_XML_SIZE = null; - - @BeforeClass - public static void setup() { - PREV_PROPFIND_METHOD_XML_SIZE = System.setProperty("org.exist.webdav.PROPFIND_METHOD_XML_SIZE", "exact"); - } - - @AfterClass - public static void cleanup() { - if (PREV_PROPFIND_METHOD_XML_SIZE == null) { - System.clearProperty("org.exist.webdav.PROPFIND_METHOD_XML_SIZE"); - } else { - System.setProperty("org.exist.webdav.PROPFIND_METHOD_XML_SIZE", PREV_PROPFIND_METHOD_XML_SIZE); - } - } - - @Test - public void copyXmlDocument() throws IOException, NotAuthorizedException, BadRequestException, HttpException, ConflictException, NotFoundException { - final String srcDocName = "webdav-copy-test.xml"; - final String srcDocContent = "Hello there"; - final String destDocName = "webdav-copied-test.xml"; - copyDocument(srcDocName, srcDocContent, destDocName, "application/xml"); - } - - @Test - public void copyBinDocument() throws IOException, NotAuthorizedException, BadRequestException, HttpException, ConflictException, NotFoundException { - final String srcDocName = "webdav-copy-test.bin"; - final String srcDocContent = "0123456789"; - final String destDocName = "webdav-copied-test.bin"; - copyDocument(srcDocName, srcDocContent, destDocName, "application/octet-stream"); - } - - private void copyDocument(final String srcDocName, final String srcDocContent, final String destDocName, final String expectedMediaType) throws BadRequestException, HttpException, IOException, NotAuthorizedException, ConflictException, NotFoundException { - final HostBuilder builder = new HostBuilder(); - builder.setServer("localhost"); - final int port = existWebServer.getPort(); - builder.setPort(port); - builder.setRootPath("webdav/db"); - final Host host = builder.buildHost(); - - // workaround pre-emptive auth issues of Milton Client - final AbstractHttpClient httpClient = (AbstractHttpClient)host.getClient(); - httpClient.addRequestInterceptor(new AlwaysBasicPreAuth(TestUtils.ADMIN_DB_USER, TestUtils.ADMIN_DB_PWD)); - - final Folder folder = host.getFolder("/"); - assertNotNull(folder); - - // store document - final java.io.File tmpStoreFile = tempFolder.newFile(); - Files.writeString(tmpStoreFile.toPath(), srcDocContent); - assertNotNull(folder.uploadFile(srcDocName, tmpStoreFile, null)); - - // retrieve document - final Resource srcResource = folder.child(srcDocName); - assertNotNull(srcResource); - assertTrue(srcResource instanceof File); - assertEquals(expectedMediaType, ((File) srcResource).contentType); - final java.io.File tempRetrievedSrcFile = tempFolder.newFile(); - srcResource.downloadTo(tempRetrievedSrcFile, null); - assertEquals(srcDocContent, Files.readString(tempRetrievedSrcFile.toPath())); - - // copy document - srcResource.copyTo(folder, destDocName); - - // retrieve copied document - final Resource destResource = folder.child(destDocName); - assertNotNull(destResource); - assertTrue(destResource instanceof File); - assertEquals(expectedMediaType, ((File) destResource).contentType); - final java.io.File tempRetrievedDestFile = tempFolder.newFile(); - destResource.downloadTo(tempRetrievedDestFile, null); - assertEquals(srcDocContent, Files.readString(tempRetrievedDestFile.toPath())); - } -} diff --git a/extensions/webdav/src/test/java/org/exist/webdav/DeleteTest.java b/extensions/webdav/src/test/java/org/exist/webdav/DeleteTest.java deleted file mode 100644 index 70bad36bcb5..00000000000 --- a/extensions/webdav/src/test/java/org/exist/webdav/DeleteTest.java +++ /dev/null @@ -1,119 +0,0 @@ -/* - * eXist-db Open Source Native XML Database - * Copyright (C) 2001 The eXist-db Authors - * - * info@exist-db.org - * http://www.exist-db.org - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 2.1 of the License, or (at your option) any later version. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public - * License along with this library; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - */ - -package org.exist.webdav; - -import com.bradmcevoy.http.exceptions.BadRequestException; -import com.bradmcevoy.http.exceptions.ConflictException; -import com.bradmcevoy.http.exceptions.NotAuthorizedException; -import com.bradmcevoy.http.exceptions.NotFoundException; -import com.ettrema.httpclient.*; -import org.apache.http.impl.client.AbstractHttpClient; -import org.exist.TestUtils; -import org.exist.test.ExistWebServer; -import org.junit.AfterClass; -import org.junit.BeforeClass; -import org.junit.ClassRule; -import org.junit.Test; -import org.junit.rules.TemporaryFolder; - -import java.io.IOException; -import java.nio.file.Files; - -import static java.nio.charset.StandardCharsets.UTF_8; -import static org.junit.Assert.*; - -/** - * Tests for storing and deleting a document via WebDAV. - */ -public class DeleteTest { - - @ClassRule - public static final ExistWebServer existWebServer = new ExistWebServer(true, false, true, true); - - @ClassRule - public static final TemporaryFolder tempFolder = new TemporaryFolder(); - - private static String PREV_PROPFIND_METHOD_XML_SIZE = null; - - @BeforeClass - public static void setup() { - PREV_PROPFIND_METHOD_XML_SIZE = System.setProperty("org.exist.webdav.PROPFIND_METHOD_XML_SIZE", "exact"); - } - - @AfterClass - public static void cleanup() { - if (PREV_PROPFIND_METHOD_XML_SIZE == null) { - System.clearProperty("org.exist.webdav.PROPFIND_METHOD_XML_SIZE"); - } else { - System.setProperty("org.exist.webdav.PROPFIND_METHOD_XML_SIZE", PREV_PROPFIND_METHOD_XML_SIZE); - } - } - - @Test - public void storeAndDeleteXmlDocument() throws IOException, NotAuthorizedException, BadRequestException, HttpException, ConflictException, NotFoundException { - final String srcDocName = "webdav-store-and-retrieve-test.xml"; - final String srcDocContent = "Hello there"; - storeAndDeleteDocument(srcDocName, srcDocContent, "application/xml"); - } - - @Test - public void storeAndDeleteBinDocument() throws IOException, NotAuthorizedException, BadRequestException, HttpException, ConflictException, NotFoundException { - final String srcDocName = "webdav-store-and-retrieve-test.bin"; - final String srcDocContent = "0123456789"; - storeAndDeleteDocument(srcDocName, srcDocContent, "application/octet-stream"); - } - - private void storeAndDeleteDocument(final String srcDocName, final String srcDocContent, final String expectedMediaType) throws BadRequestException, HttpException, IOException, NotAuthorizedException, ConflictException, NotFoundException { - final HostBuilder builder = new HostBuilder(); - builder.setServer("localhost"); - final int port = existWebServer.getPort(); - builder.setPort(port); - builder.setRootPath("webdav/db"); - final Host host = builder.buildHost(); - - // workaround pre-emptive auth issues of Milton Client - final AbstractHttpClient httpClient = (AbstractHttpClient)host.getClient(); - httpClient.addRequestInterceptor(new AlwaysBasicPreAuth(TestUtils.ADMIN_DB_USER, TestUtils.ADMIN_DB_PWD)); - - final Folder folder = host.getFolder("/"); - assertNotNull(folder); - - // store document - final java.io.File tmpStoreFile = tempFolder.newFile(); - Files.writeString(tmpStoreFile.toPath(), srcDocContent); - assertNotNull(folder.uploadFile(srcDocName, tmpStoreFile, null)); - - // retrieve document - final Resource srcResource = folder.child(srcDocName); - assertNotNull(srcResource); - assertTrue(srcResource instanceof File); - assertEquals(expectedMediaType, ((File) srcResource).contentType); - - // delete document - srcResource.delete(); - - // try again to retrieve document... should not be present! - final Resource deletedResource = folder.child(srcDocName); - assertNull(deletedResource); - } -} diff --git a/extensions/webdav/src/test/java/org/exist/webdav/LockTest.java b/extensions/webdav/src/test/java/org/exist/webdav/LockTest.java deleted file mode 100644 index 71941bee810..00000000000 --- a/extensions/webdav/src/test/java/org/exist/webdav/LockTest.java +++ /dev/null @@ -1,131 +0,0 @@ -/* - * eXist-db Open Source Native XML Database - * Copyright (C) 2001 The eXist-db Authors - * - * info@exist-db.org - * http://www.exist-db.org - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 2.1 of the License, or (at your option) any later version. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public - * License along with this library; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - */ - -package org.exist.webdav; - -import com.bradmcevoy.http.exceptions.BadRequestException; -import com.bradmcevoy.http.exceptions.ConflictException; -import com.bradmcevoy.http.exceptions.NotAuthorizedException; -import com.bradmcevoy.http.exceptions.NotFoundException; -import com.ettrema.httpclient.*; -import org.apache.http.impl.client.AbstractHttpClient; -import org.exist.TestUtils; -import org.exist.test.ExistWebServer; -import org.junit.ClassRule; -import org.junit.Test; -import org.junit.rules.TemporaryFolder; - -import java.io.IOException; -import java.net.URISyntaxException; -import java.nio.file.Files; - -import static org.junit.Assert.*; - -/** - * Tests for WebDAV LOCK and UNLOCK operations. - */ -public class LockTest { - - @ClassRule - public static final ExistWebServer existWebServer = new ExistWebServer(true, false, true, true); - - @ClassRule - public static final TemporaryFolder tempFolder = new TemporaryFolder(); - - @Test - public void lockAndUnlockXmlDocument() throws IOException, NotAuthorizedException, BadRequestException, - HttpException, ConflictException, NotFoundException, URISyntaxException { - final String docName = "webdav-lock-test.xml"; - final String docContent = "lock test"; - - final Host host = buildHost(); - final Folder folder = host.getFolder("/"); - assertNotNull(folder); - - // store document - final java.io.File tmpFile = tempFolder.newFile(); - Files.writeString(tmpFile.toPath(), docContent); - assertNotNull(folder.uploadFile(docName, tmpFile, null)); - - // lock - final String docUri = docUri(docName); - final String lockToken = host.doLock(docUri); - assertNotNull("LOCK should return a lock token", lockToken); - assertFalse("Lock token should not be empty", lockToken.isEmpty()); - - // unlock - final int unlockStatus = host.doUnLock(docUri, lockToken); - assertTrue("UNLOCK should return 2xx status, got " + unlockStatus, - unlockStatus >= 200 && unlockStatus < 300); - } - - @Test - public void lockAndUnlockBinDocument() throws IOException, NotAuthorizedException, BadRequestException, - HttpException, ConflictException, NotFoundException, URISyntaxException { - final String docName = "webdav-lock-test.bin"; - final String docContent = "binary lock test data"; - - final Host host = buildHost(); - final Folder folder = host.getFolder("/"); - assertNotNull(folder); - - // store document - final java.io.File tmpFile = tempFolder.newFile(); - Files.writeString(tmpFile.toPath(), docContent); - assertNotNull(folder.uploadFile(docName, tmpFile, null)); - - // lock - final String docUri = docUri(docName); - final String lockToken = host.doLock(docUri); - assertNotNull("LOCK should return a lock token", lockToken); - - // unlock - final int unlockStatus = host.doUnLock(docUri, lockToken); - assertTrue("UNLOCK should return 2xx status, got " + unlockStatus, - unlockStatus >= 200 && unlockStatus < 300); - } - - // Note: RFC 4918 §9.10 requires a second LOCK (without the If header containing the current - // lock token) to return 423 Locked. The ettrema httpclient used in these tests leaves the - // HTTP connection allocated after catching a 4xx response, making it unsuitable for testing - // error-path locking scenarios. That behavior is covered by the litmus compliance suite. - - private String docUri(final String docName) { - return "http://localhost:" + existWebServer.getPort() + "/webdav/db/" + docName; - } - - private Host buildHost() { - final HostBuilder builder = new HostBuilder(); - builder.setServer("localhost"); - builder.setPort(existWebServer.getPort()); - builder.setUser(TestUtils.ADMIN_DB_USER); - builder.setPassword(TestUtils.ADMIN_DB_PWD); - builder.setRootPath("webdav/db"); - final Host host = builder.buildHost(); - - // preemptive Basic auth for all requests - final AbstractHttpClient httpClient = (AbstractHttpClient) host.getClient(); - httpClient.addRequestInterceptor(new AlwaysBasicPreAuth(TestUtils.ADMIN_DB_USER, TestUtils.ADMIN_DB_PWD)); - - return host; - } -} diff --git a/extensions/webdav/src/test/java/org/exist/webdav/RenameTest.java b/extensions/webdav/src/test/java/org/exist/webdav/RenameTest.java deleted file mode 100644 index c1192511c19..00000000000 --- a/extensions/webdav/src/test/java/org/exist/webdav/RenameTest.java +++ /dev/null @@ -1,119 +0,0 @@ -/* - * eXist-db Open Source Native XML Database - * Copyright (C) 2001 The eXist-db Authors - * - * info@exist-db.org - * http://www.exist-db.org - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 2.1 of the License, or (at your option) any later version. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public - * License along with this library; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - */ - -package org.exist.webdav; - -import com.bradmcevoy.http.exceptions.BadRequestException; -import com.bradmcevoy.http.exceptions.ConflictException; -import com.bradmcevoy.http.exceptions.NotAuthorizedException; -import com.bradmcevoy.http.exceptions.NotFoundException; -import com.ettrema.httpclient.*; -import org.apache.http.impl.client.AbstractHttpClient; -import org.exist.TestUtils; -import org.exist.test.ExistWebServer; -import org.junit.AfterClass; -import org.junit.BeforeClass; -import org.junit.ClassRule; -import org.junit.Test; -import org.junit.rules.TemporaryFolder; - -import java.io.IOException; -import java.nio.file.Files; - -import static java.nio.charset.StandardCharsets.UTF_8; -import static org.junit.Assert.*; - -/** - * Tests for renaming a document via WebDAV. - */ -public class RenameTest { - - @ClassRule - public static final ExistWebServer existWebServer = new ExistWebServer(true, false, true, true); - - @ClassRule - public static final TemporaryFolder tempFolder = new TemporaryFolder(); - - private static String PREV_PROPFIND_METHOD_XML_SIZE = null; - - @BeforeClass - public static void setup() { - PREV_PROPFIND_METHOD_XML_SIZE = System.setProperty("org.exist.webdav.PROPFIND_METHOD_XML_SIZE", "exact"); - } - - @AfterClass - public static void cleanup() { - if (PREV_PROPFIND_METHOD_XML_SIZE == null) { - System.clearProperty("org.exist.webdav.PROPFIND_METHOD_XML_SIZE"); - } else { - System.setProperty("org.exist.webdav.PROPFIND_METHOD_XML_SIZE", PREV_PROPFIND_METHOD_XML_SIZE); - } - } - - @Test - public void renameXmlDocument() throws IOException, NotAuthorizedException, BadRequestException, HttpException, ConflictException, NotFoundException { - final String srcDocName = "webdav-store-and-retrieve-test.xml"; - final String srcDocContent = "Hello there"; - renameDocument(srcDocName, srcDocContent, "application/xml"); - } - - @Test - public void renameBinDocument() throws IOException, NotAuthorizedException, BadRequestException, HttpException, ConflictException, NotFoundException { - final String srcDocName = "webdav-store-and-retrieve-test.bin"; - final String srcDocContent = "0123456789"; - renameDocument(srcDocName, srcDocContent, "application/octet-stream"); - } - - private void renameDocument(final String srcDocName, final String srcDocContent, final String expectedMediaType) throws BadRequestException, HttpException, IOException, NotAuthorizedException, ConflictException, NotFoundException { - final HostBuilder builder = new HostBuilder(); - builder.setServer("localhost"); - final int port = existWebServer.getPort(); - builder.setPort(port); - builder.setRootPath("webdav/db"); - final Host host = builder.buildHost(); - - // workaround pre-emptive auth issues of Milton Client - final AbstractHttpClient httpClient = (AbstractHttpClient)host.getClient(); - httpClient.addRequestInterceptor(new AlwaysBasicPreAuth(TestUtils.ADMIN_DB_USER, TestUtils.ADMIN_DB_PWD)); - - final Folder folder = host.getFolder("/"); - assertNotNull(folder); - - // store document - final java.io.File tmpStoreFile = tempFolder.newFile(); - Files.writeString(tmpStoreFile.toPath(), srcDocContent); - final String tmpFileName = tmpStoreFile.getName() + "." + srcDocName; - assertNotNull(folder.uploadFile(tmpFileName, tmpStoreFile, null)); - - // rename document - folder.child(tmpFileName).rename(srcDocName); - - // retrieve document - final Resource srcResource = folder.child(srcDocName); - assertNotNull(srcResource); - assertTrue(srcResource instanceof File); - assertEquals(expectedMediaType, ((File) srcResource).contentType); - final java.io.File tempRetrievedSrcFile = tempFolder.newFile(); - srcResource.downloadTo(tempRetrievedSrcFile, null); - assertEquals(srcDocContent, Files.readString(tempRetrievedSrcFile.toPath())); - } -} diff --git a/extensions/webdav/src/test/java/org/exist/webdav/ReplaceTest.java b/extensions/webdav/src/test/java/org/exist/webdav/ReplaceTest.java deleted file mode 100644 index ba029216732..00000000000 --- a/extensions/webdav/src/test/java/org/exist/webdav/ReplaceTest.java +++ /dev/null @@ -1,131 +0,0 @@ -/* - * eXist-db Open Source Native XML Database - * Copyright (C) 2001 The eXist-db Authors - * - * info@exist-db.org - * http://www.exist-db.org - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 2.1 of the License, or (at your option) any later version. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public - * License along with this library; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - */ - -package org.exist.webdav; - -import com.bradmcevoy.http.exceptions.BadRequestException; -import com.bradmcevoy.http.exceptions.ConflictException; -import com.bradmcevoy.http.exceptions.NotAuthorizedException; -import com.bradmcevoy.http.exceptions.NotFoundException; -import com.ettrema.httpclient.*; -import org.apache.http.impl.client.AbstractHttpClient; -import org.exist.TestUtils; -import org.exist.test.ExistWebServer; -import org.junit.AfterClass; -import org.junit.BeforeClass; -import org.junit.ClassRule; -import org.junit.Test; -import org.junit.rules.TemporaryFolder; - -import java.io.IOException; -import java.nio.file.Files; - -import static java.nio.charset.StandardCharsets.UTF_8; -import static org.junit.Assert.*; - -/** - * Tests for replacing a document via WebDAV. - */ -public class ReplaceTest { - - @ClassRule - public static final ExistWebServer existWebServer = new ExistWebServer(true, false, true, true); - - @ClassRule - public static final TemporaryFolder tempFolder = new TemporaryFolder(); - - private static String PREV_PROPFIND_METHOD_XML_SIZE = null; - - @BeforeClass - public static void setup() { - PREV_PROPFIND_METHOD_XML_SIZE = System.setProperty("org.exist.webdav.PROPFIND_METHOD_XML_SIZE", "exact"); - } - - @AfterClass - public static void cleanup() { - if (PREV_PROPFIND_METHOD_XML_SIZE == null) { - System.clearProperty("org.exist.webdav.PROPFIND_METHOD_XML_SIZE"); - } else { - System.setProperty("org.exist.webdav.PROPFIND_METHOD_XML_SIZE", PREV_PROPFIND_METHOD_XML_SIZE); - } - } - - @Test - public void replaceXmlDocument() throws IOException, NotAuthorizedException, BadRequestException, HttpException, ConflictException, NotFoundException { - final String docName = "webdav-copy-test.xml"; - final String docContent = "Hello there"; - final String replacementDocContent = "Goodbye friend"; - replaceDocument(docName, docContent, replacementDocContent, "application/xml"); - } - - @Test - public void replaceBinDocument() throws IOException, NotAuthorizedException, BadRequestException, HttpException, ConflictException, NotFoundException { - final String docName = "webdav-copy-test.bin"; - final String docContent = "0123456789"; - final String replacementDocContent = "9876543210"; - replaceDocument(docName, docContent, replacementDocContent, "application/octet-stream"); - } - - private void replaceDocument(final String docName, final String docContent, final String replacementDocContent, final String expectedMediaType) throws BadRequestException, HttpException, IOException, NotAuthorizedException, ConflictException, NotFoundException { - final HostBuilder builder = new HostBuilder(); - builder.setServer("localhost"); - final int port = existWebServer.getPort(); - builder.setPort(port); - builder.setRootPath("webdav/db"); - final Host host = builder.buildHost(); - - // workaround pre-emptive auth issues of Milton Client - final AbstractHttpClient httpClient = (AbstractHttpClient)host.getClient(); - httpClient.addRequestInterceptor(new AlwaysBasicPreAuth(TestUtils.ADMIN_DB_USER, TestUtils.ADMIN_DB_PWD)); - - final Folder folder = host.getFolder("/"); - assertNotNull(folder); - - // store document - final java.io.File tmpStoreFile = tempFolder.newFile(); - Files.writeString(tmpStoreFile.toPath(), docContent); - assertNotNull(folder.uploadFile(docName, tmpStoreFile, null)); - - // retrieve document - final Resource srcResource = folder.child(docName); - assertNotNull(srcResource); - assertTrue(srcResource instanceof File); - assertEquals(expectedMediaType, ((File) srcResource).contentType); - final java.io.File tempRetrievedSrcFile = tempFolder.newFile(); - srcResource.downloadTo(tempRetrievedSrcFile, null); - assertEquals(docContent, Files.readString(tempRetrievedSrcFile.toPath())); - - // replace document - final java.io.File tmpReplacementFile = tempFolder.newFile(); - Files.writeString(tmpReplacementFile.toPath(), replacementDocContent); - assertNotNull(folder.uploadFile(docName, tmpReplacementFile, null)); - - // retrieve replaced document - final Resource replacedResource = folder.child(docName); - assertNotNull(replacedResource); - assertTrue(replacedResource instanceof File); - assertEquals(expectedMediaType, ((File) replacedResource).contentType); - final java.io.File tempRetrievedReplacedFile = tempFolder.newFile(); - replacedResource.downloadTo(tempRetrievedReplacedFile, null); - assertEquals(replacementDocContent, Files.readString(tempRetrievedReplacedFile.toPath())); - } -} diff --git a/extensions/webdav/src/test/java/org/exist/webdav/SerializationTest.java b/extensions/webdav/src/test/java/org/exist/webdav/SerializationTest.java deleted file mode 100644 index 9902fe8ca16..00000000000 --- a/extensions/webdav/src/test/java/org/exist/webdav/SerializationTest.java +++ /dev/null @@ -1,145 +0,0 @@ -/* - * eXist-db Open Source Native XML Database - * Copyright (C) 2001 The eXist-db Authors - * - * info@exist-db.org - * http://www.exist-db.org - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 2.1 of the License, or (at your option) any later version. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public - * License along with this library; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - */ - -package org.exist.webdav; - -import com.bradmcevoy.http.exceptions.BadRequestException; -import com.bradmcevoy.http.exceptions.ConflictException; -import com.bradmcevoy.http.exceptions.NotAuthorizedException; -import com.bradmcevoy.http.exceptions.NotFoundException; -import com.ettrema.httpclient.*; -import org.apache.http.impl.client.AbstractHttpClient; -import org.exist.TestUtils; -import org.exist.test.ExistWebServer; -import org.junit.AfterClass; -import org.junit.BeforeClass; -import org.junit.ClassRule; -import org.junit.Test; -import org.junit.rules.TemporaryFolder; - -import java.io.IOException; -import java.nio.file.Files; - -import static java.nio.charset.StandardCharsets.UTF_8; -import static org.junit.Assert.*; -import static org.junit.Assert.assertEquals; - -public class SerializationTest { - - private static final String XML_WITH_DOCTYPE = - """ - - """; - - private static final String XML_WITH_XMLDECL = - """ - - """; - - private static String PREV_PROPFIND_METHOD_XML_SIZE = null; - - @ClassRule - public static final ExistWebServer EXIST_WEB_SERVER = new ExistWebServer(true, false, true, true); - - @ClassRule - public static final TemporaryFolder TEMP_FOLDER = new TemporaryFolder(); - - @BeforeClass - public static void setup() { - PREV_PROPFIND_METHOD_XML_SIZE = System.setProperty("org.exist.webdav.PROPFIND_METHOD_XML_SIZE", "exact"); - } - - @AfterClass - public static void cleanup() { - if (PREV_PROPFIND_METHOD_XML_SIZE == null) { - System.clearProperty("org.exist.webdav.PROPFIND_METHOD_XML_SIZE"); - } else { - System.setProperty("org.exist.webdav.PROPFIND_METHOD_XML_SIZE", PREV_PROPFIND_METHOD_XML_SIZE); - } - } - - @Test - public void getDocTypeDefault() throws IOException, NotAuthorizedException, BadRequestException, HttpException, ConflictException, NotFoundException { - final String docName = "test-with-doctype.xml"; - final HostBuilder builder = new HostBuilder(); - builder.setServer("localhost"); - final int port = EXIST_WEB_SERVER.getPort(); - builder.setPort(port); - builder.setRootPath("webdav/db"); - final Host host = builder.buildHost(); - - // workaround pre-emptive auth issues of Milton Client - try (final AbstractHttpClient httpClient = (AbstractHttpClient)host.getClient()) { - httpClient.addRequestInterceptor(new AlwaysBasicPreAuth(TestUtils.ADMIN_DB_USER, TestUtils.ADMIN_DB_PWD)); - - final Folder folder = host.getFolder("/"); - assertNotNull(folder); - - // store document - final java.io.File tmpStoreFile = TEMP_FOLDER.newFile(); - Files.writeString(tmpStoreFile.toPath(), XML_WITH_DOCTYPE); - assertNotNull(folder.uploadFile(docName, tmpStoreFile, null)); - - // retrieve document - final Resource resource = folder.child(docName); - assertNotNull(resource); - assertTrue(resource instanceof File); - assertEquals("application/xml", ((File) resource).contentType); - final java.io.File tempRetrieveFile = TEMP_FOLDER.newFile(); - resource.downloadTo(tempRetrieveFile, null); - assertEquals(XML_WITH_DOCTYPE, Files.readString(tempRetrieveFile.toPath())); - } - } - - @Test - public void getXmlDeclDefault() throws IOException, NotAuthorizedException, BadRequestException, HttpException, ConflictException, NotFoundException { - final String docName = "test-with-xmldecl.xml"; - final HostBuilder builder = new HostBuilder(); - builder.setServer("localhost"); - final int port = EXIST_WEB_SERVER.getPort(); - builder.setPort(port); - builder.setRootPath("webdav/db"); - final Host host = builder.buildHost(); - - // workaround pre-emptive auth issues of Milton Client - try (final AbstractHttpClient httpClient = (AbstractHttpClient)host.getClient()) { - httpClient.addRequestInterceptor(new AlwaysBasicPreAuth(TestUtils.ADMIN_DB_USER, TestUtils.ADMIN_DB_PWD)); - - final Folder folder = host.getFolder("/"); - assertNotNull(folder); - - // store document - final java.io.File tmpStoreFile = TEMP_FOLDER.newFile(); - Files.writeString(tmpStoreFile.toPath(), XML_WITH_XMLDECL); - assertNotNull(folder.uploadFile(docName, tmpStoreFile, null)); - - // retrieve document - final Resource resource = folder.child(docName); - assertNotNull(resource); - assertTrue(resource instanceof File); - assertEquals("application/xml", ((File) resource).contentType); - final java.io.File tempRetrieveFile = TEMP_FOLDER.newFile(); - resource.downloadTo(tempRetrieveFile, null); - assertEquals(XML_WITH_XMLDECL, Files.readString(tempRetrieveFile.toPath())); - } - } -} diff --git a/extensions/webdav/src/test/java/org/exist/webdav/StoreAndRetrieveTest.java b/extensions/webdav/src/test/java/org/exist/webdav/StoreAndRetrieveTest.java deleted file mode 100644 index 1fc9560a5cf..00000000000 --- a/extensions/webdav/src/test/java/org/exist/webdav/StoreAndRetrieveTest.java +++ /dev/null @@ -1,115 +0,0 @@ -/* - * eXist-db Open Source Native XML Database - * Copyright (C) 2001 The eXist-db Authors - * - * info@exist-db.org - * http://www.exist-db.org - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 2.1 of the License, or (at your option) any later version. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public - * License along with this library; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - */ - -package org.exist.webdav; - -import com.bradmcevoy.http.exceptions.BadRequestException; -import com.bradmcevoy.http.exceptions.ConflictException; -import com.bradmcevoy.http.exceptions.NotAuthorizedException; -import com.bradmcevoy.http.exceptions.NotFoundException; -import com.ettrema.httpclient.*; -import org.apache.http.impl.client.AbstractHttpClient; -import org.exist.TestUtils; -import org.exist.test.ExistWebServer; -import org.junit.AfterClass; -import org.junit.BeforeClass; -import org.junit.ClassRule; -import org.junit.Test; -import org.junit.rules.TemporaryFolder; - -import java.io.IOException; -import java.nio.file.Files; - -import static java.nio.charset.StandardCharsets.UTF_8; -import static org.junit.Assert.*; - -/** - * Tests for storing and retrieving a document via WebDAV. - */ -public class StoreAndRetrieveTest { - - @ClassRule - public static final ExistWebServer existWebServer = new ExistWebServer(true, false, true, true); - - @ClassRule - public static final TemporaryFolder tempFolder = new TemporaryFolder(); - - private static String PREV_PROPFIND_METHOD_XML_SIZE = null; - - @BeforeClass - public static void setup() { - PREV_PROPFIND_METHOD_XML_SIZE = System.setProperty("org.exist.webdav.PROPFIND_METHOD_XML_SIZE", "exact"); - } - - @AfterClass - public static void cleanup() { - if (PREV_PROPFIND_METHOD_XML_SIZE == null) { - System.clearProperty("org.exist.webdav.PROPFIND_METHOD_XML_SIZE"); - } else { - System.setProperty("org.exist.webdav.PROPFIND_METHOD_XML_SIZE", PREV_PROPFIND_METHOD_XML_SIZE); - } - } - - @Test - public void storeAndRetrieveXmlDocument() throws IOException, NotAuthorizedException, BadRequestException, HttpException, ConflictException, NotFoundException { - final String srcDocName = "webdav-store-and-retrieve-test.xml"; - final String srcDocContent = "Hello there"; - storeAndRetrieve(srcDocName, srcDocContent, "application/xml"); - } - - @Test - public void storeAndRetrieveBinDocument() throws IOException, NotAuthorizedException, BadRequestException, HttpException, ConflictException, NotFoundException { - final String srcDocName = "webdav-store-and-retrieve-test.bin"; - final String srcDocContent = "0123456789"; - storeAndRetrieve(srcDocName, srcDocContent, "application/octet-stream"); - } - - private void storeAndRetrieve(final String srcDocName, final String srcDocContent, final String expectedMediaType) throws BadRequestException, HttpException, IOException, NotAuthorizedException, ConflictException, NotFoundException { - final HostBuilder builder = new HostBuilder(); - builder.setServer("localhost"); - final int port = existWebServer.getPort(); - builder.setPort(port); - builder.setRootPath("webdav/db"); - final Host host = builder.buildHost(); - - // workaround pre-emptive auth issues of Milton Client - final AbstractHttpClient httpClient = (AbstractHttpClient)host.getClient(); - httpClient.addRequestInterceptor(new AlwaysBasicPreAuth(TestUtils.ADMIN_DB_USER, TestUtils.ADMIN_DB_PWD)); - - final Folder folder = host.getFolder("/"); - assertNotNull(folder); - - // store document - final java.io.File tmpStoreFile = tempFolder.newFile(); - Files.writeString(tmpStoreFile.toPath(), srcDocContent); - assertNotNull(folder.uploadFile(srcDocName, tmpStoreFile, null)); - - // retrieve document - final Resource srcResource = folder.child(srcDocName); - assertNotNull(srcResource); - assertTrue(srcResource instanceof File); - assertEquals(expectedMediaType, ((File) srcResource).contentType); - final java.io.File tempRetrievedSrcFile = tempFolder.newFile(); - srcResource.downloadTo(tempRetrievedSrcFile, null); - assertEquals(srcDocContent, Files.readString(tempRetrievedSrcFile.toPath())); - } -} diff --git a/extensions/webdav/src/test/java/org/exist/webdav/WebDavHttpClient.java b/extensions/webdav/src/test/java/org/exist/webdav/WebDavHttpClient.java new file mode 100644 index 00000000000..4b797553226 --- /dev/null +++ b/extensions/webdav/src/test/java/org/exist/webdav/WebDavHttpClient.java @@ -0,0 +1,85 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.webdav; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +/** + * Minimal JDK {@link HttpClient} helper for WebDAV PUT/GET/DELETE in round-trip tests. + * RFC compliance is covered by litmus ({@code exist-docker/.../04-webdav-litmus.bats}). + */ +final class WebDavHttpClient { + + private final HttpClient client; + private final String collectionUri; + private final String authorizationHeader; + + WebDavHttpClient(final int port, final String username, final String password) { + this.collectionUri = "http://localhost:" + port + "/webdav/db/"; + this.authorizationHeader = basicAuthorization(username, password); + this.client = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + } + + int putDocument(final String name, final String content, final String contentType) + throws IOException, InterruptedException { + final HttpRequest request = HttpRequest.newBuilder(documentUri(name)) + .PUT(HttpRequest.BodyPublishers.ofString(content, StandardCharsets.UTF_8)) + .header("Authorization", authorizationHeader) + .header("Content-Type", contentType) + .build(); + return client.send(request, HttpResponse.BodyHandlers.discarding()).statusCode(); + } + + HttpResponse getDocument(final String name) throws IOException, InterruptedException { + final HttpRequest request = HttpRequest.newBuilder(documentUri(name)) + .GET() + .header("Authorization", authorizationHeader) + .build(); + return client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + } + + int deleteDocument(final String name) throws IOException, InterruptedException { + final HttpRequest request = HttpRequest.newBuilder(documentUri(name)) + .DELETE() + .header("Authorization", authorizationHeader) + .build(); + return client.send(request, HttpResponse.BodyHandlers.discarding()).statusCode(); + } + + private URI documentUri(final String name) { + return URI.create(collectionUri + name); + } + + private static String basicAuthorization(final String username, final String password) { + final String credentials = username + ":" + password; + final String encoded = Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); + return "Basic " + encoded; + } +} diff --git a/extensions/webdav/src/test/java/org/exist/webdav/WebDavRoundTripTest.java b/extensions/webdav/src/test/java/org/exist/webdav/WebDavRoundTripTest.java new file mode 100644 index 00000000000..1170dcd5630 --- /dev/null +++ b/extensions/webdav/src/test/java/org/exist/webdav/WebDavRoundTripTest.java @@ -0,0 +1,149 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.webdav; + +import org.exist.TestUtils; +import org.exist.test.ExistWebServer; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; + +import java.net.http.HttpResponse; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * eXist-specific WebDAV round-trip tests (XML serialization edge cases). + * Replaces the former milton-client JUnit suite; protocol compliance is covered by litmus. + */ +public class WebDavRoundTripTest { + + private static final String XML_WITH_DOCTYPE = + """ + + """; + + private static final String XML_WITH_XMLDECL = + """ + + """; + + private static final String CDATA_XML = ""; + + private static final String XML_WITH_NAMESPACES = + "" + + "text"; + + private static final String XML_WITH_NON_ASCII = "café — 日本語"; + + @ClassRule + public static final ExistWebServer EXIST_WEB_SERVER = new ExistWebServer(true, false, true, true); + + private static final List STORED_DOCUMENTS = new ArrayList<>(); + + private static String prevPropfindMethodXmlSize = null; + + @BeforeClass + public static void setup() { + prevPropfindMethodXmlSize = System.setProperty("org.exist.webdav.PROPFIND_METHOD_XML_SIZE", "exact"); + } + + @AfterClass + public static void cleanup() throws Exception { + try { + deleteStoredDocuments(); + } finally { + if (prevPropfindMethodXmlSize == null) { + System.clearProperty("org.exist.webdav.PROPFIND_METHOD_XML_SIZE"); + } else { + System.setProperty("org.exist.webdav.PROPFIND_METHOD_XML_SIZE", prevPropfindMethodXmlSize); + } + } + } + + @Test + public void getDocTypeDefault() throws Exception { + assertEquals(XML_WITH_DOCTYPE, roundTrip("test-with-doctype.xml", XML_WITH_DOCTYPE, "application/xml")); + } + + @Test + public void getXmlDeclDefault() throws Exception { + assertEquals(XML_WITH_XMLDECL, roundTrip("test-with-xmldecl.xml", XML_WITH_XMLDECL, "application/xml")); + } + + @Test + public void cdataWebDavApi() throws Exception { + assertEquals(CDATA_XML, roundTrip("webdav-cdata-test.xml", CDATA_XML, "application/xml")); + } + + @Test + public void storeAndRetrieveBinDocument() throws Exception { + assertEquals("0123456789", roundTrip("webdav-roundtrip-test.bin", "0123456789", "application/octet-stream")); + } + + @Test + public void namespacesPreserved() throws Exception { + assertEquals(XML_WITH_NAMESPACES, roundTrip("webdav-namespaces-test.xml", XML_WITH_NAMESPACES, "application/xml")); + } + + @Test + public void nonAsciiPreserved() throws Exception { + assertEquals(XML_WITH_NON_ASCII, roundTrip("webdav-non-ascii-test.xml", XML_WITH_NON_ASCII, "application/xml")); + } + + private String roundTrip(final String docName, final String content, final String expectedMediaType) throws Exception { + STORED_DOCUMENTS.add(docName); + + final WebDavHttpClient webDav = new WebDavHttpClient( + EXIST_WEB_SERVER.getPort(), TestUtils.ADMIN_DB_USER, TestUtils.ADMIN_DB_PWD); + + final int putStatus = webDav.putDocument(docName, content, expectedMediaType); + assertEquals("PUT " + docName + " failed with status " + putStatus, 201, putStatus); + + final HttpResponse getResponse = webDav.getDocument(docName); + assertEquals("GET " + docName + " failed", 200, getResponse.statusCode()); + final String contentType = getResponse.headers().firstValue("Content-Type").orElse(""); + assertTrue("Unexpected Content-Type: " + contentType, contentType.startsWith(expectedMediaType)); + return getResponse.body(); + } + + private static void deleteStoredDocuments() throws Exception { + if (STORED_DOCUMENTS.isEmpty()) { + return; + } + final WebDavHttpClient webDav = new WebDavHttpClient( + EXIST_WEB_SERVER.getPort(), TestUtils.ADMIN_DB_USER, TestUtils.ADMIN_DB_PWD); + for (final String docName : STORED_DOCUMENTS) { + final int deleteStatus = webDav.deleteDocument(docName); + assertTrue("DELETE " + docName + " failed with status " + deleteStatus, isSuccess(deleteStatus)); + } + STORED_DOCUMENTS.clear(); + } + + private static boolean isSuccess(final int status) { + return status >= 200 && status < 300; + } +} From 0dce8a8da6828c1555f20e9c5cbb4b93e5dc6733 Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Mon, 15 Jun 2026 00:53:59 -0400 Subject: [PATCH 3/8] [feature] Native EXPath HTTP Client module on java.net.http + Methanol Adds extensions/modules/http-client: a native implementation of the EXPath HTTP Client (http:send-request, namespace http://expath.org/ns/http-client) built on the JDK java.net.http.HttpClient, augmented by Methanol. No Apache HttpClient and no third-party EXPath HTTP library. - The client is a Methanol client with autoAcceptEncoding (advertises Accept-Encoding and transparently decodes gzip/deflate) and a read (inactivity) timeout. - Multipart request bodies are built with Methanol's MultipartBodyPublisher (the JDK client has no multipart support); each http:body part keeps its own Content-Type. - ResponseHandler relies on the client for transfer decoding rather than hand-rolling gzip handling. Includes a self-contained integration test (in-process com.sun.net.httpserver target plus embedded eXist): 70 send-request tests and 43 content-type unit tests, all green. This is the production home of the work prototyped at eXist-db/exist-http-client. Co-Authored-By: Claude Opus 4.8 (1M context) --- extensions/modules/http-client/pom.xml | 114 ++ .../modules/httpclient/ContentTypeHelper.java | 149 ++ .../modules/httpclient/HttpClientModule.java | 100 ++ .../modules/httpclient/RequestBuilder.java | 364 +++++ .../modules/httpclient/ResponseHandler.java | 350 +++++ .../httpclient/SendRequestFunction.java | 137 ++ .../httpclient/ContentTypeHelperTest.java | 295 ++++ .../httpclient/SendRequestFunctionTest.java | 1304 +++++++++++++++++ .../http-client/src/test/resources/conf.xml | 45 + extensions/modules/pom.xml | 1 + 10 files changed, 2859 insertions(+) create mode 100644 extensions/modules/http-client/pom.xml create mode 100644 extensions/modules/http-client/src/main/java/org/exist/xquery/modules/httpclient/ContentTypeHelper.java create mode 100644 extensions/modules/http-client/src/main/java/org/exist/xquery/modules/httpclient/HttpClientModule.java create mode 100644 extensions/modules/http-client/src/main/java/org/exist/xquery/modules/httpclient/RequestBuilder.java create mode 100644 extensions/modules/http-client/src/main/java/org/exist/xquery/modules/httpclient/ResponseHandler.java create mode 100644 extensions/modules/http-client/src/main/java/org/exist/xquery/modules/httpclient/SendRequestFunction.java create mode 100644 extensions/modules/http-client/src/test/java/org/exist/xquery/modules/httpclient/ContentTypeHelperTest.java create mode 100644 extensions/modules/http-client/src/test/java/org/exist/xquery/modules/httpclient/SendRequestFunctionTest.java create mode 100644 extensions/modules/http-client/src/test/resources/conf.xml diff --git a/extensions/modules/http-client/pom.xml b/extensions/modules/http-client/pom.xml new file mode 100644 index 00000000000..3e761778fdd --- /dev/null +++ b/extensions/modules/http-client/pom.xml @@ -0,0 +1,114 @@ + + + + 4.0.0 + + + org.exist-db + exist-parent + ${revision} + ../../../exist-parent + + + exist-http-client + jar + + eXist-db EXPath HTTP Client Module + Native EXPath HTTP Client Module for eXist-db using java.net.http.HttpClient + + + scm:git:https://github.com/exist-db/exist.git + scm:git:https://github.com/exist-db/exist.git + scm:git:https://github.com/exist-db/exist.git + HEAD + + + + + org.exist-db + exist-core + ${project.version} + + + + com.github.mizosoft.methanol + methanol + + + + net.sf.xmldb-org + xmldb-api + + + + + org.exist-db + exist-core + ${project.version} + test-jar + test + + + org.exist-db + exist-start + ${project.version} + test + + + junit + junit + test + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + analyze + + analyze-only + + + true + + + ${project.groupId}:exist-core:test-jar:${project.version} + ${project.groupId}:exist-start:jar:${project.version} + + + net.sf.xmldb-org:xmldb-api:jar + + + + + + + + + diff --git a/extensions/modules/http-client/src/main/java/org/exist/xquery/modules/httpclient/ContentTypeHelper.java b/extensions/modules/http-client/src/main/java/org/exist/xquery/modules/httpclient/ContentTypeHelper.java new file mode 100644 index 00000000000..b37d3c56ea5 --- /dev/null +++ b/extensions/modules/http-client/src/main/java/org/exist/xquery/modules/httpclient/ContentTypeHelper.java @@ -0,0 +1,149 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.xquery.modules.httpclient; + +/** + * Utility for parsing Content-Type headers and classifying response body types. + * + *

The classification determines how HTTP response bodies are returned to XQuery:

+ *
    + *
  • XML ({@code text/xml}, {@code application/xml}, {@code *+xml}): + * parsed as {@code document-node()}
  • + *
  • HTML ({@code text/html}, {@code application/xhtml+xml}): + * parsed as {@code document-node()}
  • + *
  • Text ({@code text/*}, {@code application/json}, {@code *+json}, + * {@code application/javascript}): returned as {@code xs:string}
  • + *
  • Binary (everything else): returned as {@code xs:base64Binary}
  • + *
+ * + *

Critical fix: The previous implementation returned JSON and other + * text types as {@code xs:base64Binary}. This class ensures they are correctly returned + * as {@code xs:string}, matching BaseX behavior and the EXPath spec.

+ */ +public final class ContentTypeHelper { + + private static final String DEFAULT_MEDIA_TYPE = "application/octet-stream"; + private static final String DEFAULT_CHARSET = "utf-8"; + + private ContentTypeHelper() { + // utility class + } + + /** + * Tests whether the content type represents XML that should be parsed. + */ + public static boolean isXml(final String contentType) { + final String mediaType = extractMediaType(contentType); + return "application/xml".equals(mediaType) + || "text/xml".equals(mediaType) + || mediaType.endsWith("+xml"); + } + + /** + * Tests whether the content type represents HTML that should be parsed. + */ + public static boolean isHtml(final String contentType) { + final String mediaType = extractMediaType(contentType); + return "text/html".equals(mediaType) + || "application/xhtml+xml".equals(mediaType); + } + + /** + * Tests whether the content type represents text that should be returned as xs:string. + * + *

Excludes XML and HTML types (which are parsed as documents instead).

+ */ + public static boolean isText(final String contentType) { + final String mediaType = extractMediaType(contentType); + + // XML and HTML are parsed, not returned as text + if (isXml(contentType) || isHtml(contentType)) { + return false; + } + + // All text/* types + if (mediaType.startsWith("text/")) { + return true; + } + + // JSON types + if ("application/json".equals(mediaType) || mediaType.endsWith("+json")) { + return true; + } + + // JavaScript + if ("application/javascript".equals(mediaType) + || "application/ecmascript".equals(mediaType) + || "text/javascript".equals(mediaType)) { + return true; + } + + // Form data + return "application/x-www-form-urlencoded".equals(mediaType); + } + + /** + * Extracts the media type from a Content-Type header, stripping parameters + * (charset, boundary, etc.) and normalizing to lowercase. + * + * @param contentType the raw Content-Type header value (may be null) + * @return the normalized media type, or "application/octet-stream" if null/empty + */ + public static String extractMediaType(final String contentType) { + if (contentType == null || contentType.isBlank()) { + return DEFAULT_MEDIA_TYPE; + } + final int semicolon = contentType.indexOf(';'); + final String mediaType = semicolon >= 0 + ? contentType.substring(0, semicolon) + : contentType; + return mediaType.trim().toLowerCase(); + } + + /** + * Extracts the charset from a Content-Type header. + * + * @param contentType the raw Content-Type header value (may be null) + * @return the charset name (lowercase), defaulting to "utf-8" + */ + public static String extractCharset(final String contentType) { + if (contentType == null) { + return DEFAULT_CHARSET; + } + final String lower = contentType.toLowerCase(); + final int charsetIdx = lower.indexOf("charset="); + if (charsetIdx < 0) { + return DEFAULT_CHARSET; + } + String charset = contentType.substring(charsetIdx + 8).trim(); + // Strip quotes + if (charset.startsWith("\"") && charset.endsWith("\"")) { + charset = charset.substring(1, charset.length() - 1); + } + // Strip trailing params + final int semicolon = charset.indexOf(';'); + if (semicolon >= 0) { + charset = charset.substring(0, semicolon); + } + return charset.trim().toLowerCase(); + } +} diff --git a/extensions/modules/http-client/src/main/java/org/exist/xquery/modules/httpclient/HttpClientModule.java b/extensions/modules/http-client/src/main/java/org/exist/xquery/modules/httpclient/HttpClientModule.java new file mode 100644 index 00000000000..48cdce58cb7 --- /dev/null +++ b/extensions/modules/http-client/src/main/java/org/exist/xquery/modules/httpclient/HttpClientModule.java @@ -0,0 +1,100 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.xquery.modules.httpclient; + +import org.exist.dom.QName; +import org.exist.xquery.AbstractInternalModule; +import org.exist.xquery.ErrorCodes; +import org.exist.xquery.FunctionDef; + +import java.util.List; +import java.util.Map; + +import static org.exist.xquery.FunctionDSL.functionDefs; + +/** + * Native EXPath HTTP Client Module for eXist-db. + * + *

Implements the EXPath HTTP Client 1.0 specification using Java's + * built-in {@code java.net.http.HttpClient}, with zero external dependencies.

+ * + *

Key improvement over the previous implementation: text responses + * (JSON, plain text) are correctly returned as {@code xs:string} instead of + * {@code xs:base64Binary}.

+ * + * @see EXPath HTTP Client 1.0 + */ +public class HttpClientModule extends AbstractInternalModule { + + public static final String NAMESPACE_URI = "http://expath.org/ns/http-client"; + public static final String PREFIX = "http"; + public static final String RELEASE = "0.9.0-SNAPSHOT"; + + public static final String ERROR_NS = "http://expath.org/ns/error"; + + // EXPath HTTP Client error codes + public static final ErrorCodes.ErrorCode HC001 = new ErrorCodes.ErrorCode( + new QName("HC001", ERROR_NS, "err"), "An HTTP error occurred"); + public static final ErrorCodes.ErrorCode HC002 = new ErrorCodes.ErrorCode( + new QName("HC002", ERROR_NS, "err"), "Error parsing entity content as XML or HTML"); + public static final ErrorCodes.ErrorCode HC003 = new ErrorCodes.ErrorCode( + new QName("HC003", ERROR_NS, "err"), "Multipart override-media-type violation"); + public static final ErrorCodes.ErrorCode HC004 = new ErrorCodes.ErrorCode( + new QName("HC004", ERROR_NS, "err"), "src attribute conflicts with body content"); + public static final ErrorCodes.ErrorCode HC005 = new ErrorCodes.ErrorCode( + new QName("HC005", ERROR_NS, "err"), "Invalid request element structure"); + public static final ErrorCodes.ErrorCode HC006 = new ErrorCodes.ErrorCode( + new QName("HC006", ERROR_NS, "err"), "Timeout waiting for response"); + + public static final FunctionDef[] functions = functionDefs( + functionDefs(SendRequestFunction.class, + SendRequestFunction.FS_SEND_REQUEST) + ); + + public HttpClientModule(final Map> parameters) { + super(functions, parameters); + } + + @Override + public String getNamespaceURI() { + return NAMESPACE_URI; + } + + @Override + public String getDefaultPrefix() { + return PREFIX; + } + + @Override + public String getDescription() { + return "EXPath HTTP Client Module — sends HTTP requests and returns responses"; + } + + @Override + public String getReleaseVersion() { + return RELEASE; + } + + static QName qname(final String localPart) { + return new QName(localPart, NAMESPACE_URI, PREFIX); + } +} diff --git a/extensions/modules/http-client/src/main/java/org/exist/xquery/modules/httpclient/RequestBuilder.java b/extensions/modules/http-client/src/main/java/org/exist/xquery/modules/httpclient/RequestBuilder.java new file mode 100644 index 00000000000..1200a1c0b35 --- /dev/null +++ b/extensions/modules/http-client/src/main/java/org/exist/xquery/modules/httpclient/RequestBuilder.java @@ -0,0 +1,364 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.xquery.modules.httpclient; + +import com.github.mizosoft.methanol.MediaType; +import com.github.mizosoft.methanol.MultipartBodyPublisher; +import org.exist.dom.QName; +import org.exist.xquery.XPathException; +import org.exist.xquery.value.NodeValue; +import org.exist.xquery.value.Sequence; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +import java.io.StringWriter; +import java.net.URI; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.Properties; + +import javax.xml.transform.OutputKeys; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; + +/** + * Builds a {@link java.net.http.HttpRequest} from an {@code } element. + * + *

Parses attributes (method, href, timeout, follow-redirect, auth, etc.) + * and child elements (http:header, http:body, http:multipart) from the request + * element.

+ */ +public class RequestBuilder { + + private static final String HTTP_NS = HttpClientModule.NAMESPACE_URI; + + private String method; + private String href; + private int timeout = 0; + private boolean followRedirect = true; + private boolean statusOnly = false; + private String overrideMediaType; + private String username; + private String password; + private String authMethod; + private boolean sendAuthorization = false; + + private final List headers = new ArrayList<>(); + private String bodyMediaType; + private String bodyContent; + private String multipartMediaType; + private final List multipartBodies = new ArrayList<>(); + + /** + * Parses the {@code } element and optional function parameters. + * + * @param requestNode the http:request element (may be null for 2/3-arg form) + * @param hrefParam optional href parameter (overrides attribute) + * @param bodiesParam optional bodies parameter (overrides body children) + * @return this builder + * @throws XPathException if the request element is invalid + */ + public RequestBuilder parse(final NodeValue requestNode, final String hrefParam, + final Sequence bodiesParam) throws XPathException { + if (requestNode == null) { + throw new XPathException((org.exist.xquery.Expression) null, + HttpClientModule.HC005, "http:request element is required"); + } + + final Element reqElem = (Element) requestNode.getNode(); + parseAttributes(reqElem); + parseChildren(reqElem); + applyOverrides(hrefParam, bodiesParam); + validate(); + return this; + } + + private void parseAttributes(final Element reqElem) throws XPathException { + method = getAttr(reqElem, "method"); + href = getAttr(reqElem, "href"); + timeout = parseTimeout(getAttr(reqElem, "timeout")); + followRedirect = parseBooleanAttr(reqElem, "follow-redirect", followRedirect); + statusOnly = parseBooleanAttr(reqElem, "status-only", statusOnly); + overrideMediaType = getAttr(reqElem, "override-media-type"); + username = getAttr(reqElem, "username"); + password = getAttr(reqElem, "password"); + authMethod = getAttr(reqElem, "auth-method"); + sendAuthorization = parseBooleanAttr(reqElem, "send-authorization", sendAuthorization); + } + + private int parseTimeout(final String timeoutStr) throws XPathException { + if (timeoutStr == null || timeoutStr.isEmpty()) { + return timeout; + } + try { + return Integer.parseInt(timeoutStr); + } catch (final NumberFormatException e) { + throw new XPathException((org.exist.xquery.Expression) null, + HttpClientModule.HC005, "Invalid timeout value: " + timeoutStr); + } + } + + private void parseChildren(final Element reqElem) { + final NodeList children = reqElem.getChildNodes(); + for (int i = 0; i < children.getLength(); i++) { + final Node child = children.item(i); + if (child.getNodeType() != Node.ELEMENT_NODE || !HTTP_NS.equals(child.getNamespaceURI())) { + continue; + } + final String localName = child.getLocalName(); + if ("header".equals(localName)) { + addHeader((Element) child); + } else if ("body".equals(localName)) { + final Element bodyElem = (Element) child; + bodyMediaType = bodyElem.getAttribute("media-type"); + // Body content is the text/XML content of the body element + bodyContent = getBodyContent(bodyElem); + } else if ("multipart".equals(localName)) { + final Element multipartElem = (Element) child; + multipartMediaType = getAttr(multipartElem, "media-type"); + parseMultipart(multipartElem); + } + } + } + + private void addHeader(final Element headerElem) { + final String name = headerElem.getAttribute("name"); + final String value = headerElem.getAttribute("value"); + if (name != null && !name.isEmpty()) { + headers.add(new String[]{name, value != null ? value : ""}); + } + } + + private void applyOverrides(final String hrefParam, final Sequence bodiesParam) throws XPathException { + if (hrefParam != null && !hrefParam.isEmpty()) { + href = hrefParam; + } + + // External bodies override inline body + if (bodiesParam != null && !bodiesParam.isEmpty()) { + if (bodyMediaType == null) { + bodyMediaType = "text/plain"; + } + bodyContent = bodiesParam.itemAt(0).getStringValue(); + } + } + + private void validate() throws XPathException { + if (method == null || method.isEmpty()) { + throw new XPathException((org.exist.xquery.Expression) null, + HttpClientModule.HC005, "method attribute is required on http:request"); + } + if (href == null || href.isEmpty()) { + throw new XPathException((org.exist.xquery.Expression) null, + HttpClientModule.HC005, "No URI specified: provide href attribute or $href parameter"); + } + } + + private static boolean parseBooleanAttr(final Element elem, final String name, final boolean defaultValue) { + final String value = getAttr(elem, name); + if (value == null) { + return defaultValue; + } + return "true".equalsIgnoreCase(value) || "yes".equalsIgnoreCase(value); + } + + /** + * Builds the {@link java.net.http.HttpRequest} from the parsed parameters. + */ + public HttpRequest build() throws XPathException { + final URI uri; + try { + uri = URI.create(href); + } catch (IllegalArgumentException e) { + throw new XPathException((org.exist.xquery.Expression) null, + HttpClientModule.HC005, "Invalid URI: " + href + ". " + e.getMessage()); + } + + final HttpRequest.Builder builder = HttpRequest.newBuilder().uri(uri); + + // Timeout + if (timeout > 0) { + builder.timeout(Duration.ofSeconds(timeout)); + } + + // Headers (a Content-Type set here is ignored for multipart, where the publisher supplies + // a Content-Type carrying the generated boundary) + final boolean isMultipart = !multipartBodies.isEmpty(); + for (final String[] header : headers) { + if (isMultipart && "Content-Type".equalsIgnoreCase(header[0])) { + continue; + } + builder.header(header[0], header[1]); + } + + // Authentication + if (username != null && sendAuthorization && "basic".equalsIgnoreCase(authMethod)) { + final String encoded = Base64.getEncoder().encodeToString( + (username + ":" + (password != null ? password : "")) + .getBytes(StandardCharsets.UTF_8)); + builder.header("Authorization", "Basic " + encoded); + } + + final String upperMethod = method.toUpperCase(); + + // Multipart request body, built with Methanol's MultipartBodyPublisher (the JDK client has + // no multipart support). Each http:body part becomes a part with its own Content-Type. + if (isMultipart) { + final MultipartBodyPublisher.Builder multipart = MultipartBodyPublisher.newBuilder(); + if (multipartMediaType != null && !multipartMediaType.isEmpty()) { + multipart.mediaType(MediaType.parse(multipartMediaType)); + } + for (final BodyPart part : multipartBodies) { + final HttpRequest.BodyPublisher partBody = + HttpRequest.BodyPublishers.ofString(part.content(), StandardCharsets.UTF_8); + final Map> partHeaders = part.mediaType() != null && !part.mediaType().isEmpty() + ? Map.of("Content-Type", List.of(part.mediaType())) + : Map.of(); + multipart.part(MultipartBodyPublisher.Part.create( + HttpHeaders.of(partHeaders, (k, v) -> true), partBody)); + } + final MultipartBodyPublisher publisher = multipart.build(); + builder.header("Content-Type", publisher.mediaType().toString()); + builder.method(upperMethod, publisher); + return builder.build(); + } + + // Single body + if (bodyContent != null && !bodyContent.isEmpty()) { + final Charset charset = bodyMediaType != null + ? Charset.forName(ContentTypeHelper.extractCharset(bodyMediaType)) + : StandardCharsets.UTF_8; + final HttpRequest.BodyPublisher bodyPublisher = + HttpRequest.BodyPublishers.ofString(bodyContent, charset); + + // Set Content-Type if not already set via headers + if (bodyMediaType != null && headers.stream().noneMatch( + h -> "Content-Type".equalsIgnoreCase(h[0]))) { + builder.header("Content-Type", bodyMediaType); + } + + builder.method(upperMethod, bodyPublisher); + } else { + builder.method(upperMethod, HttpRequest.BodyPublishers.noBody()); + } + + return builder.build(); + } + + public boolean isFollowRedirect() { + return followRedirect; + } + + public boolean isStatusOnly() { + return statusOnly; + } + + public String getOverrideMediaType() { + return overrideMediaType; + } + + public String getUsername() { + return username; + } + + public String getPassword() { + return password; + } + + public String getAuthMethod() { + return authMethod; + } + + public int getTimeout() { + return timeout; + } + + private void parseMultipart(final Element multipartElem) { + final NodeList children = multipartElem.getChildNodes(); + for (int i = 0; i < children.getLength(); i++) { + final Node child = children.item(i); + if (child.getNodeType() == Node.ELEMENT_NODE + && HTTP_NS.equals(child.getNamespaceURI()) + && "body".equals(child.getLocalName())) { + final Element bodyElem = (Element) child; + final String mediaType = bodyElem.getAttribute("media-type"); + final String content = getBodyContent(bodyElem); + multipartBodies.add(new BodyPart(mediaType, content)); + } + } + } + + private String getBodyContent(final Element bodyElem) { + // Check for child elements (XML content) + final NodeList children = bodyElem.getChildNodes(); + boolean hasElements = false; + for (int i = 0; i < children.getLength(); i++) { + if (children.item(i).getNodeType() == Node.ELEMENT_NODE) { + hasElements = true; + break; + } + } + + if (hasElements) { + // Serialize XML child content + try { + final StringBuilder sb = new StringBuilder(); + final TransformerFactory tf = TransformerFactory.newInstance(); + final Transformer transformer = tf.newTransformer(); + transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes"); + for (int i = 0; i < children.getLength(); i++) { + final Node child = children.item(i); + if (child.getNodeType() == Node.ELEMENT_NODE) { + final StringWriter sw = new StringWriter(); + transformer.transform(new DOMSource(child), new StreamResult(sw)); + sb.append(sw); + } + } + return sb.toString(); + } catch (Exception e) { + // Fall through to text content + } + } + + return bodyElem.getTextContent(); + } + + private static String getAttr(final Element elem, final String name) { + final String val = elem.getAttribute(name); + return val != null && !val.isEmpty() ? val : null; + } + + /** + * Represents a body part in a multipart request. + */ + record BodyPart(String mediaType, String content) {} +} diff --git a/extensions/modules/http-client/src/main/java/org/exist/xquery/modules/httpclient/ResponseHandler.java b/extensions/modules/http-client/src/main/java/org/exist/xquery/modules/httpclient/ResponseHandler.java new file mode 100644 index 00000000000..b6dfdccdb06 --- /dev/null +++ b/extensions/modules/http-client/src/main/java/org/exist/xquery/modules/httpclient/ResponseHandler.java @@ -0,0 +1,350 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.xquery.modules.httpclient; + +import org.exist.dom.QName; +import org.exist.dom.memtree.DocumentImpl; +import org.exist.dom.memtree.MemTreeBuilder; +import org.exist.dom.memtree.SAXAdapter; +import org.exist.xquery.BasicFunction; +import org.exist.xquery.XPathException; +import org.exist.xquery.XQueryContext; +import org.exist.xquery.value.Base64BinaryValueType; +import org.exist.xquery.value.BinaryValueFromInputStream; +import org.exist.xquery.value.Item; +import org.exist.xquery.value.StringValue; +import org.exist.xquery.value.Sequence; +import org.exist.xquery.value.ValueSequence; +import org.xml.sax.InputSource; +import org.xml.sax.SAXException; +import org.xml.sax.XMLReader; + +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.parsers.SAXParser; +import javax.xml.parsers.SAXParserFactory; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.http.HttpResponse; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +/** + * Converts an {@link HttpResponse} into the EXPath HTTP Client XDM return sequence. + * + *

The return sequence is:

+ *
    + *
  1. An {@code } element with status, message, headers, and body descriptors
  2. + *
  3. Zero or more body items (one per body/multipart part)
  4. + *
+ * + *

Body items are typed according to Content-Type:

+ *
    + *
  • XML types → parsed {@code document-node()}
  • + *
  • HTML types → parsed {@code document-node()}
  • + *
  • Text/JSON types → {@code xs:string}
  • + *
  • Binary types → {@code xs:base64Binary}
  • + *
+ */ +public class ResponseHandler { + + private static final String HTTP_NS = HttpClientModule.NAMESPACE_URI; + private static final String HTTP_PREFIX = HttpClientModule.PREFIX; + + private static final QName RESPONSE_QNAME = new QName("response", HTTP_NS, HTTP_PREFIX); + private static final QName HEADER_QNAME = new QName("header", HTTP_NS, HTTP_PREFIX); + private static final QName BODY_QNAME = new QName("body", HTTP_NS, HTTP_PREFIX); + private static final QName MULTIPART_QNAME = new QName("multipart", HTTP_NS, HTTP_PREFIX); + + /** + * Builds the XDM return sequence from an HTTP response. + * + * @param response the HTTP response + * @param context the XQuery context + * @param callerFunc the calling function (for value construction) + * @param statusOnly if true, return only the response element (no body) + * @param overrideMedia if non-null, use this media type instead of the response Content-Type + * @return the result sequence + */ + public static Sequence buildResult(final HttpResponse response, + final XQueryContext context, + final BasicFunction callerFunc, + final boolean statusOnly, + final String overrideMedia) throws XPathException { + final ValueSequence result = new ValueSequence(); + + // Determine content type + final String rawContentType = overrideMedia != null ? overrideMedia : + response.headers().firstValue("Content-Type").orElse(null); + final String mediaType = ContentTypeHelper.extractMediaType(rawContentType); + + // Build response element — add the root element, not the document node, + // so that $response[1]/@status works directly in XQuery + final DocumentImpl responseDoc = buildResponseElement(response, context, mediaType); + result.add((Item) responseDoc.getDocumentElement()); + + // Add body content (unless status-only or no body). The Methanol client advertises + // Accept-Encoding and transparently decodes gzip/deflate, so response.body() is already + // decompressed here (and the Content-Encoding header has been removed). + if (!statusOnly && response.body() != null && response.body().length > 0) { + final byte[] bodyBytes = response.body(); + + // Check for multipart + if (mediaType.startsWith("multipart/")) { + addMultipartBodies(result, bodyBytes, rawContentType, callerFunc); + } else { + addBody(result, bodyBytes, rawContentType, callerFunc); + } + } + + return result; + } + + private static DocumentImpl buildResponseElement(final HttpResponse response, + final XQueryContext context, + final String mediaType) { + context.pushDocumentContext(); + try { + final MemTreeBuilder builder = context.getDocumentBuilder(); + builder.startDocument(); + + builder.startElement(RESPONSE_QNAME, null); + builder.addAttribute(new QName("status", ""), String.valueOf(response.statusCode())); + builder.addAttribute(new QName("message", ""), getReasonPhrase(response.statusCode())); + + // Add headers + final Map> headerMap = response.headers().map(); + for (final Map.Entry> entry : headerMap.entrySet()) { + final String name = entry.getKey(); + if (name == null || name.startsWith(":")) { + continue; // Skip HTTP/2 pseudo-headers + } + for (final String value : entry.getValue()) { + builder.startElement(HEADER_QNAME, null); + builder.addAttribute(new QName("name", ""), name); + builder.addAttribute(new QName("value", ""), value); + builder.endElement(); + } + } + + // Add body descriptor (empty element describing the body type) + if (response.body() != null && response.body().length > 0) { + if (mediaType.startsWith("multipart/")) { + builder.startElement(MULTIPART_QNAME, null); + builder.addAttribute(new QName("media-type", ""), mediaType); + // Add body elements for each part (without content) + final String boundary = extractBoundary( + response.headers().firstValue("Content-Type").orElse("")); + if (boundary != null) { + final byte[][] parts = splitMultipart(response.body(), boundary); + for (final byte[] part : parts) { + final String partContentType = extractPartContentType(part); + builder.startElement(BODY_QNAME, null); + builder.addAttribute(new QName("media-type", ""), + partContentType != null ? partContentType : "application/octet-stream"); + builder.endElement(); + } + } + builder.endElement(); // multipart + } else { + builder.startElement(BODY_QNAME, null); + builder.addAttribute(new QName("media-type", ""), mediaType); + builder.endElement(); + } + } + + builder.endElement(); // response + builder.endDocument(); + + return builder.getDocument(); + } finally { + context.popDocumentContext(); + } + } + + private static void addBody(final ValueSequence result, final byte[] bodyBytes, + final String contentType, final BasicFunction callerFunc) throws XPathException { + if (ContentTypeHelper.isXml(contentType)) { + result.add(parseXml(bodyBytes, callerFunc)); + } else if (ContentTypeHelper.isHtml(contentType)) { + result.add(parseHtml(bodyBytes, contentType, callerFunc)); + } else if (ContentTypeHelper.isText(contentType)) { + final String charset = ContentTypeHelper.extractCharset(contentType); + final String text = new String(bodyBytes, Charset.forName(charset)); + result.add(new StringValue(callerFunc, text)); + } else { + final String base64 = java.util.Base64.getEncoder().encodeToString(bodyBytes); + result.add(new org.exist.xquery.value.BinaryValueFromBinaryString( + new Base64BinaryValueType(), base64)); + } + } + + private static Item parseXml(final byte[] bodyBytes, + final BasicFunction callerFunc) throws XPathException { + try { + final SAXParserFactory factory = SAXParserFactory.newInstance(); + factory.setNamespaceAware(true); + final SAXParser saxParser = factory.newSAXParser(); + final XMLReader reader = saxParser.getXMLReader(); + + final SAXAdapter adapter = new SAXAdapter(callerFunc); + reader.setContentHandler(adapter); + reader.setProperty("http://xml.org/sax/properties/lexical-handler", adapter); + reader.parse(new InputSource(new ByteArrayInputStream(bodyBytes))); + return adapter.getDocument(); + } catch (ParserConfigurationException | SAXException | IOException e) { + throw new XPathException(callerFunc, + HttpClientModule.HC002, "Error parsing XML response: " + e.getMessage()); + } + } + + private static Item parseHtml(final byte[] bodyBytes, final String contentType, + final BasicFunction callerFunc) throws XPathException { + // Try parsing as XML first (XHTML), fall back to string if it fails + try { + return parseXml(bodyBytes, callerFunc); + } catch (XPathException e) { + // HTML that isn't well-formed XML — return as string + final String charset = ContentTypeHelper.extractCharset(contentType); + return new StringValue(callerFunc, + new String(bodyBytes, Charset.forName(charset))); + } + } + + private static void addMultipartBodies(final ValueSequence result, final byte[] bodyBytes, + final String contentType, + final BasicFunction callerFunc) throws XPathException { + final String boundary = extractBoundary(contentType); + if (boundary == null) { + throw new XPathException(callerFunc, + HttpClientModule.HC003, "No boundary found in multipart Content-Type"); + } + + final byte[][] parts = splitMultipart(bodyBytes, boundary); + for (final byte[] part : parts) { + final String partContentType = extractPartContentType(part); + final byte[] partBody = extractPartBody(part); + if (partBody.length > 0) { + addBody(result, partBody, partContentType, callerFunc); + } + } + } + + private static String extractBoundary(final String contentType) { + if (contentType == null) { + return null; + } + final String lower = contentType.toLowerCase(); + final int idx = lower.indexOf("boundary="); + if (idx < 0) { + return null; + } + String boundary = contentType.substring(idx + 9).trim(); + if (boundary.startsWith("\"") && boundary.endsWith("\"")) { + boundary = boundary.substring(1, boundary.length() - 1); + } + final int semicolon = boundary.indexOf(';'); + if (semicolon >= 0) { + boundary = boundary.substring(0, semicolon); + } + return boundary.trim(); + } + + private static byte[][] splitMultipart(final byte[] body, final String boundary) { + final String bodyStr = new String(body, StandardCharsets.UTF_8); + final String delimiter = "--" + boundary; + final String[] rawParts = bodyStr.split(delimiter); + final List parts = new java.util.ArrayList<>(); + for (final String part : rawParts) { + final String trimmed = part.trim(); + if (trimmed.isEmpty() || trimmed.equals("--")) { + continue; // Skip preamble and closing delimiter + } + parts.add(part.getBytes(StandardCharsets.UTF_8)); + } + return parts.toArray(new byte[0][]); + } + + private static String extractPartContentType(final byte[] part) { + final String partStr = new String(part, StandardCharsets.UTF_8); + final String[] lines = partStr.split("\r?\n"); + for (final String line : lines) { + if (line.toLowerCase().startsWith("content-type:")) { + return line.substring(13).trim(); + } + } + return "text/plain"; + } + + private static byte[] extractPartBody(final byte[] part) { + final String partStr = new String(part, StandardCharsets.UTF_8); + // Body starts after the first blank line (double CRLF or double LF) + int bodyStart = partStr.indexOf("\r\n\r\n"); + if (bodyStart >= 0) { + bodyStart += 4; + } else { + bodyStart = partStr.indexOf("\n\n"); + if (bodyStart >= 0) { + bodyStart += 2; + } else { + return new byte[0]; + } + } + final String bodyStr = partStr.substring(bodyStart); + return bodyStr.getBytes(StandardCharsets.UTF_8); + } + + /** + * Returns a reason phrase for common HTTP status codes. + */ + private static String getReasonPhrase(final int statusCode) { + return switch (statusCode) { + case 200 -> "OK"; + case 201 -> "Created"; + case 202 -> "Accepted"; + case 204 -> "No Content"; + case 301 -> "Moved Permanently"; + case 302 -> "Found"; + case 303 -> "See Other"; + case 304 -> "Not Modified"; + case 307 -> "Temporary Redirect"; + case 308 -> "Permanent Redirect"; + case 400 -> "Bad Request"; + case 401 -> "Unauthorized"; + case 403 -> "Forbidden"; + case 404 -> "Not Found"; + case 405 -> "Method Not Allowed"; + case 408 -> "Request Timeout"; + case 409 -> "Conflict"; + case 410 -> "Gone"; + case 415 -> "Unsupported Media Type"; + case 429 -> "Too Many Requests"; + case 500 -> "Internal Server Error"; + case 501 -> "Not Implemented"; + case 502 -> "Bad Gateway"; + case 503 -> "Service Unavailable"; + case 504 -> "Gateway Timeout"; + default -> ""; + }; + } +} diff --git a/extensions/modules/http-client/src/main/java/org/exist/xquery/modules/httpclient/SendRequestFunction.java b/extensions/modules/http-client/src/main/java/org/exist/xquery/modules/httpclient/SendRequestFunction.java new file mode 100644 index 00000000000..7e692a226ee --- /dev/null +++ b/extensions/modules/http-client/src/main/java/org/exist/xquery/modules/httpclient/SendRequestFunction.java @@ -0,0 +1,137 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.xquery.modules.httpclient; + +import com.github.mizosoft.methanol.Methanol; +import org.exist.xquery.BasicFunction; +import org.exist.xquery.FunctionSignature; +import org.exist.xquery.XPathException; +import org.exist.xquery.XQueryContext; +import org.exist.xquery.value.NodeValue; +import org.exist.xquery.value.Sequence; +import org.exist.xquery.value.Type; + +import java.io.IOException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; + +import static org.exist.xquery.FunctionDSL.*; + +/** + * Implements the EXPath HTTP Client {@code http:send-request} function. + * + *

Sends an HTTP request and returns a sequence where the first item is an + * {@code } element and subsequent items are response body content.

+ * + * @see EXPath HTTP Client 1.0 + */ +public class SendRequestFunction extends BasicFunction { + + + private static final String FS_SEND_REQUEST_NAME = "send-request"; + private static final String FS_SEND_REQUEST_DESCRIPTION = + "Sends an HTTP request to a server and returns the response. " + + "The response is a sequence where the first item is an http:response element " + + "with status, message, and header information, followed by the response body content."; + + public static final FunctionSignature[] FS_SEND_REQUEST = functionSignatures( + HttpClientModule.qname(FS_SEND_REQUEST_NAME), + FS_SEND_REQUEST_DESCRIPTION, + returnsMany(Type.ITEM, + "the response sequence: http:response element followed by body content"), + arities( + arity( + optParam("request", Type.ELEMENT, "The http:request element describing the request.") + ), + arity( + optParam("request", Type.ELEMENT, "The http:request element describing the request."), + optParam("href", Type.STRING, "The target URI (overrides @href on request element).") + ), + arity( + optParam("request", Type.ELEMENT, "The http:request element describing the request."), + optParam("href", Type.STRING, "The target URI (overrides @href on request element)."), + optManyParam("bodies", Type.ITEM, "The request body content for methods like POST/PUT.") + ) + ) + ); + + public SendRequestFunction(final XQueryContext context, final FunctionSignature signature) { + super(context, signature); + } + + @Override + public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException { + // Extract arguments + final NodeValue requestNode = args[0].isEmpty() ? null : (NodeValue) args[0].itemAt(0); + final String hrefParam = getArgumentCount() >= 2 && !args[1].isEmpty() + ? args[1].getStringValue() : null; + final Sequence bodiesParam = getArgumentCount() >= 3 ? args[2] : Sequence.EMPTY_SEQUENCE; + + // Parse request element + final RequestBuilder reqBuilder = new RequestBuilder(); + reqBuilder.parse(requestNode, hrefParam, bodiesParam); + + // Build the HTTP client. Methanol augments java.net.http.HttpClient: autoAcceptEncoding + // advertises Accept-Encoding and transparently decodes gzip/deflate responses, and readTimeout + // gives a per-read (inactivity) timeout that the bare JDK client lacks. + final Methanol.Builder clientBuilder = Methanol.newBuilder() + .autoAcceptEncoding(true); + if (reqBuilder.isFollowRedirect()) { + clientBuilder.followRedirects(HttpClient.Redirect.NORMAL); + } else { + clientBuilder.followRedirects(HttpClient.Redirect.NEVER); + } + if (reqBuilder.getTimeout() > 0) { + final Duration timeout = Duration.ofSeconds(reqBuilder.getTimeout()); + clientBuilder.connectTimeout(timeout); + clientBuilder.readTimeout(timeout); + } + + // Build HttpRequest + final HttpRequest httpRequest = reqBuilder.build(); + + // Send request + try (final HttpClient client = clientBuilder.build()) { + final HttpResponse response = client.send(httpRequest, + HttpResponse.BodyHandlers.ofByteArray()); + + return ResponseHandler.buildResult(response, context, this, + reqBuilder.isStatusOnly(), reqBuilder.getOverrideMediaType()); + + } catch (final java.net.http.HttpTimeoutException e) { + throw new XPathException(this, HttpClientModule.HC006, + "Timeout: " + e.getMessage()); + } catch (final java.net.ConnectException e) { + throw new XPathException(this, HttpClientModule.HC001, + "Connection error: " + e.getMessage()); + } catch (final IOException e) { + throw new XPathException(this, HttpClientModule.HC001, + "HTTP error: " + e.getMessage()); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new XPathException(this, HttpClientModule.HC001, + "Request interrupted: " + e.getMessage()); + } + } +} diff --git a/extensions/modules/http-client/src/test/java/org/exist/xquery/modules/httpclient/ContentTypeHelperTest.java b/extensions/modules/http-client/src/test/java/org/exist/xquery/modules/httpclient/ContentTypeHelperTest.java new file mode 100644 index 00000000000..1871e6e8cd1 --- /dev/null +++ b/extensions/modules/http-client/src/test/java/org/exist/xquery/modules/httpclient/ContentTypeHelperTest.java @@ -0,0 +1,295 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.xquery.modules.httpclient; + +import org.junit.Test; + +import static org.junit.Assert.*; + +/** + * Unit tests for {@link ContentTypeHelper}. + * + *

These tests verify the content-type classification logic that determines + * whether a response body should be returned as XML (parsed), string, or binary. + * This is the critical behavior difference from the old implementation.

+ */ +public class ContentTypeHelperTest { + + // ======================================================================== + // XML detection + // ======================================================================== + + @Test + public void applicationXmlIsXml() { + assertTrue(ContentTypeHelper.isXml("application/xml")); + } + + @Test + public void textXmlIsXml() { + assertTrue(ContentTypeHelper.isXml("text/xml")); + } + + @Test + public void applicationAtomXmlIsXml() { + assertTrue(ContentTypeHelper.isXml("application/atom+xml")); + } + + @Test + public void applicationSoapXmlIsXml() { + assertTrue(ContentTypeHelper.isXml("application/soap+xml")); + } + + @Test + public void applicationXsltXmlIsXml() { + assertTrue(ContentTypeHelper.isXml("application/xslt+xml")); + } + + @Test + public void applicationSvgXmlIsXml() { + assertTrue(ContentTypeHelper.isXml("image/svg+xml")); + } + + @Test + public void xmlWithCharsetIsXml() { + assertTrue(ContentTypeHelper.isXml("application/xml; charset=utf-8")); + } + + @Test + public void xmlCaseInsensitive() { + assertTrue(ContentTypeHelper.isXml("APPLICATION/XML")); + } + + // ======================================================================== + // HTML detection + // ======================================================================== + + @Test + public void textHtmlIsHtml() { + assertTrue(ContentTypeHelper.isHtml("text/html")); + } + + @Test + public void textHtmlWithCharsetIsHtml() { + assertTrue(ContentTypeHelper.isHtml("text/html; charset=utf-8")); + } + + @Test + public void applicationXhtmlIsHtml() { + assertTrue(ContentTypeHelper.isHtml("application/xhtml+xml")); + } + + @Test + public void htmlCaseInsensitive() { + assertTrue(ContentTypeHelper.isHtml("TEXT/HTML")); + } + + // ======================================================================== + // Text detection (should return xs:string) + // ======================================================================== + + @Test + public void textPlainIsText() { + assertTrue(ContentTypeHelper.isText("text/plain")); + } + + @Test + public void textCssIsText() { + assertTrue(ContentTypeHelper.isText("text/css")); + } + + @Test + public void textCsvIsText() { + assertTrue(ContentTypeHelper.isText("text/csv")); + } + + @Test + public void applicationJsonIsText() { + assertTrue(ContentTypeHelper.isText("application/json")); + } + + @Test + public void applicationJsonWithCharsetIsText() { + assertTrue(ContentTypeHelper.isText("application/json; charset=utf-8")); + } + + @Test + public void applicationVndApiJsonIsText() { + assertTrue(ContentTypeHelper.isText("application/vnd.api+json")); + } + + @Test + public void applicationLdJsonIsText() { + assertTrue(ContentTypeHelper.isText("application/ld+json")); + } + + @Test + public void applicationJavascriptIsText() { + assertTrue(ContentTypeHelper.isText("application/javascript")); + } + + @Test + public void applicationEcmascriptIsText() { + assertTrue(ContentTypeHelper.isText("application/ecmascript")); + } + + @Test + public void textJavascriptIsText() { + assertTrue(ContentTypeHelper.isText("text/javascript")); + } + + @Test + public void applicationFormUrlencodedIsText() { + assertTrue(ContentTypeHelper.isText("application/x-www-form-urlencoded")); + } + + @Test + public void textCaseInsensitive() { + assertTrue(ContentTypeHelper.isText("APPLICATION/JSON")); + } + + // ======================================================================== + // Binary detection (everything else) + // ======================================================================== + + @Test + public void imagePngIsBinary() { + assertFalse("image/png should not be text", ContentTypeHelper.isText("image/png")); + assertFalse("image/png should not be xml", ContentTypeHelper.isXml("image/png")); + assertFalse("image/png should not be html", ContentTypeHelper.isHtml("image/png")); + } + + @Test + public void applicationOctetStreamIsBinary() { + assertFalse(ContentTypeHelper.isText("application/octet-stream")); + } + + @Test + public void applicationPdfIsBinary() { + assertFalse(ContentTypeHelper.isText("application/pdf")); + } + + @Test + public void applicationZipIsBinary() { + assertFalse(ContentTypeHelper.isText("application/zip")); + } + + @Test + public void audioMpegIsBinary() { + assertFalse(ContentTypeHelper.isText("audio/mpeg")); + } + + @Test + public void videoMp4IsBinary() { + assertFalse(ContentTypeHelper.isText("video/mp4")); + } + + // ======================================================================== + // Negative text checks: XML/HTML types are NOT text + // (they should be parsed as documents, not returned as strings) + // ======================================================================== + + @Test + public void applicationXmlIsNotText() { + assertFalse("XML should be parsed, not returned as text", + ContentTypeHelper.isText("application/xml")); + } + + @Test + public void textHtmlIsNotText() { + assertFalse("HTML should be parsed, not returned as text", + ContentTypeHelper.isText("text/html")); + } + + // ======================================================================== + // Media type extraction (strip charset and params) + // ======================================================================== + + @Test + public void extractMediaTypeStripsCharset() { + assertEquals("application/json", + ContentTypeHelper.extractMediaType("application/json; charset=utf-8")); + } + + @Test + public void extractMediaTypeTrimsWhitespace() { + assertEquals("text/plain", + ContentTypeHelper.extractMediaType(" text/plain ")); + } + + @Test + public void extractMediaTypeLowercases() { + assertEquals("application/json", + ContentTypeHelper.extractMediaType("Application/JSON")); + } + + @Test + public void extractMediaTypeHandlesNull() { + assertEquals("application/octet-stream", + ContentTypeHelper.extractMediaType(null)); + } + + @Test + public void extractMediaTypeHandlesEmpty() { + assertEquals("application/octet-stream", + ContentTypeHelper.extractMediaType("")); + } + + // ======================================================================== + // Charset extraction + // ======================================================================== + + @Test + public void extractCharsetFromContentType() { + assertEquals("utf-8", + ContentTypeHelper.extractCharset("text/plain; charset=utf-8")); + } + + @Test + public void extractCharsetCaseInsensitive() { + assertEquals("utf-8", + ContentTypeHelper.extractCharset("text/plain; Charset=UTF-8")); + } + + @Test + public void extractCharsetDefaultsToUtf8() { + assertEquals("utf-8", + ContentTypeHelper.extractCharset("text/plain")); + } + + @Test + public void extractCharsetHandlesNull() { + assertEquals("utf-8", + ContentTypeHelper.extractCharset(null)); + } + + @Test + public void extractCharsetWithQuotes() { + assertEquals("utf-8", + ContentTypeHelper.extractCharset("text/plain; charset=\"utf-8\"")); + } + + @Test + public void extractCharsetIso8859() { + assertEquals("iso-8859-1", + ContentTypeHelper.extractCharset("text/plain; charset=iso-8859-1")); + } +} diff --git a/extensions/modules/http-client/src/test/java/org/exist/xquery/modules/httpclient/SendRequestFunctionTest.java b/extensions/modules/http-client/src/test/java/org/exist/xquery/modules/httpclient/SendRequestFunctionTest.java new file mode 100644 index 00000000000..e48b0d46bfd --- /dev/null +++ b/extensions/modules/http-client/src/test/java/org/exist/xquery/modules/httpclient/SendRequestFunctionTest.java @@ -0,0 +1,1304 @@ +/* + * eXist-db Open Source Native XML Database + * Copyright (C) 2001 The eXist-db Authors + * + * info@exist-db.org + * http://www.exist-db.org + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ +package org.exist.xquery.modules.httpclient; + +import com.sun.net.httpserver.HttpServer; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpExchange; +import org.exist.test.ExistXmldbEmbeddedServer; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; +import org.xmldb.api.base.ResourceSet; +import org.xmldb.api.base.XMLDBException; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import static org.junit.Assert.*; + +/** + * Comprehensive tests for the EXPath HTTP Client {@code http:send-request} function. + * + *

Uses an embedded {@link HttpServer} for self-contained testing — no external + * services required. Each test endpoint simulates a specific HTTP scenario.

+ */ +public class SendRequestFunctionTest { + + @ClassRule + public static final ExistXmldbEmbeddedServer existEmbeddedServer = + new ExistXmldbEmbeddedServer(false, true, true); + + private static final String HTTP_NS = + "declare namespace http = 'http://expath.org/ns/http-client';\n"; + + private static HttpServer httpServer; + private static int port; + + @BeforeClass + public static void startHttpServer() throws IOException { + httpServer = HttpServer.create(new InetSocketAddress(0), 0); + port = httpServer.getAddress().getPort(); + + // Plain text endpoint + httpServer.createContext("/text", exchange -> { + sendResponse(exchange, 200, "text/plain", "Hello, World!"); + }); + + // JSON endpoint — THE KEY TEST: must return xs:string, not base64Binary + httpServer.createContext("/json", exchange -> { + sendResponse(exchange, 200, "application/json", + "{\"name\":\"eXist\",\"version\":7}"); + }); + + // JSON with charset + httpServer.createContext("/json-charset", exchange -> { + sendResponse(exchange, 200, "application/json; charset=utf-8", + "{\"key\":\"value\"}"); + }); + + // JSON subtype (application/vnd.api+json) + httpServer.createContext("/json-subtype", exchange -> { + sendResponse(exchange, 200, "application/vnd.api+json", + "{\"data\":[]}"); + }); + + // XML endpoint + httpServer.createContext("/xml", exchange -> { + sendResponse(exchange, 200, "application/xml", + "test"); + }); + + // text/xml endpoint + httpServer.createContext("/text-xml", exchange -> { + sendResponse(exchange, 200, "text/xml", + "

paragraph

"); + }); + + // XML subtype (application/atom+xml) + httpServer.createContext("/xml-subtype", exchange -> { + sendResponse(exchange, 200, "application/atom+xml", + "Test"); + }); + + // HTML endpoint + httpServer.createContext("/html", exchange -> { + sendResponse(exchange, 200, "text/html", + "Test

Hello

"); + }); + + // Binary endpoint (image/png — 1x1 transparent pixel) + httpServer.createContext("/binary", exchange -> { + byte[] pixel = Base64.getDecoder().decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="); + exchange.getResponseHeaders().set("Content-Type", "image/png"); + exchange.sendResponseHeaders(200, pixel.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(pixel); + } + }); + + // CSS endpoint (text/css — should return string) + httpServer.createContext("/css", exchange -> { + sendResponse(exchange, 200, "text/css", "body { color: red; }"); + }); + + // JavaScript endpoint (application/javascript — should return string) + httpServer.createContext("/js", exchange -> { + sendResponse(exchange, 200, "application/javascript", "console.log('hi');"); + }); + + // Echo endpoint — returns the request method, headers, and body + httpServer.createContext("/echo", exchange -> { + String method = exchange.getRequestMethod(); + String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + String contentType = exchange.getRequestHeaders().getFirst("Content-Type"); + String accept = exchange.getRequestHeaders().getFirst("Accept"); + String custom = exchange.getRequestHeaders().getFirst("X-Custom"); + + String response = "{" + + "\"method\":\"" + method + "\"," + + "\"body\":" + (body.isEmpty() ? "null" : "\"" + escapeJson(body) + "\"") + "," + + "\"contentType\":" + (contentType == null ? "null" : "\"" + contentType + "\"") + "," + + "\"accept\":" + (accept == null ? "null" : "\"" + accept + "\"") + "," + + "\"custom\":" + (custom == null ? "null" : "\"" + custom + "\"") + + "}"; + sendResponse(exchange, 200, "application/json", response); + }); + + // Redirect endpoint (302 -> /text) + httpServer.createContext("/redirect", exchange -> { + exchange.getResponseHeaders().set("Location", + "http://localhost:" + port + "/text"); + exchange.sendResponseHeaders(302, -1); + exchange.close(); + }); + + // Double redirect (301 -> /redirect -> /text) + httpServer.createContext("/redirect-chain", exchange -> { + exchange.getResponseHeaders().set("Location", + "http://localhost:" + port + "/redirect"); + exchange.sendResponseHeaders(301, -1); + exchange.close(); + }); + + // Slow endpoint — sleeps 5 seconds (for timeout testing) + httpServer.createContext("/slow", exchange -> { + try { + Thread.sleep(5000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + sendResponse(exchange, 200, "text/plain", "slow response"); + }); + + // 404 endpoint + httpServer.createContext("/not-found", exchange -> { + sendResponse(exchange, 404, "text/plain", "Not Found"); + }); + + // 500 endpoint + httpServer.createContext("/server-error", exchange -> { + sendResponse(exchange, 500, "text/plain", "Internal Server Error"); + }); + + // Basic auth endpoint + httpServer.createContext("/auth/basic", exchange -> { + String auth = exchange.getRequestHeaders().getFirst("Authorization"); + if (auth != null && auth.startsWith("Basic ")) { + String decoded = new String(Base64.getDecoder().decode(auth.substring(6)), + StandardCharsets.UTF_8); + if ("testuser:testpass".equals(decoded)) { + sendResponse(exchange, 200, "application/json", + "{\"authenticated\":true,\"user\":\"testuser\"}"); + return; + } + } + exchange.getResponseHeaders().set("WWW-Authenticate", "Basic realm=\"test\""); + sendResponse(exchange, 401, "text/plain", "Unauthorized"); + }); + + // Multi-header endpoint — returns multiple Set-Cookie headers + httpServer.createContext("/multi-header", exchange -> { + exchange.getResponseHeaders().add("Set-Cookie", "a=1"); + exchange.getResponseHeaders().add("Set-Cookie", "b=2"); + exchange.getResponseHeaders().add("X-Request-Id", "abc123"); + sendResponse(exchange, 200, "text/plain", "ok"); + }); + + // HEAD endpoint — returns headers but no body + httpServer.createContext("/head-test", exchange -> { + exchange.getResponseHeaders().set("Content-Type", "text/plain"); + exchange.getResponseHeaders().set("X-Custom-Header", "head-value"); + exchange.sendResponseHeaders(200, -1); + exchange.close(); + }); + + // Empty body endpoint + httpServer.createContext("/empty", exchange -> { + exchange.sendResponseHeaders(204, -1); + exchange.close(); + }); + + // Large response endpoint + httpServer.createContext("/large", exchange -> { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 1000; i++) { + sb.append("Line ").append(i).append(": This is a test line of text.\n"); + } + sendResponse(exchange, 200, "text/plain", sb.toString()); + }); + + // UTF-8 text response + httpServer.createContext("/utf8", exchange -> { + sendResponse(exchange, 200, "text/plain; charset=utf-8", + "Héllo Wörld! 日本語テスト"); + }); + + // gzip endpoint + httpServer.createContext("/gzip", exchange -> { + String acceptEncoding = exchange.getRequestHeaders().getFirst("Accept-Encoding"); + byte[] bodyBytes = "gzipped content".getBytes(StandardCharsets.UTF_8); + + if (acceptEncoding != null && acceptEncoding.contains("gzip")) { + java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); + try (java.util.zip.GZIPOutputStream gzip = new java.util.zip.GZIPOutputStream(baos)) { + gzip.write(bodyBytes); + } + byte[] compressed = baos.toByteArray(); + exchange.getResponseHeaders().set("Content-Type", "text/plain"); + exchange.getResponseHeaders().set("Content-Encoding", "gzip"); + exchange.sendResponseHeaders(200, compressed.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(compressed); + } + } else { + sendResponse(exchange, 200, "text/plain", "gzipped content"); + } + }); + + // Multipart response + httpServer.createContext("/multipart", exchange -> { + String boundary = "boundary42"; + String body = "--" + boundary + "\r\n" + + "Content-Type: text/plain\r\n\r\n" + + "Part one\r\n" + + "--" + boundary + "\r\n" + + "Content-Type: application/json\r\n\r\n" + + "{\"part\":2}\r\n" + + "--" + boundary + "--\r\n"; + exchange.getResponseHeaders().set("Content-Type", + "multipart/mixed; boundary=" + boundary); + byte[] bodyBytes = body.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, bodyBytes.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(bodyBytes); + } + }); + + // OPTIONS endpoint + httpServer.createContext("/options", exchange -> { + exchange.getResponseHeaders().set("Allow", "GET, POST, OPTIONS"); + exchange.getResponseHeaders().set("Access-Control-Allow-Origin", "*"); + exchange.sendResponseHeaders(200, -1); + exchange.close(); + }); + + // PATCH endpoint + httpServer.createContext("/patch", exchange -> { + if ("PATCH".equals(exchange.getRequestMethod())) { + String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + sendResponse(exchange, 200, "application/json", + "{\"patched\":true,\"body\":\"" + escapeJson(body) + "\"}"); + } else { + sendResponse(exchange, 405, "text/plain", "Method Not Allowed"); + } + }); + + httpServer.setExecutor(null); + httpServer.start(); + } + + @AfterClass + public static void stopHttpServer() { + if (httpServer != null) { + httpServer.stop(0); + } + } + + private static void sendResponse(HttpExchange exchange, int status, String contentType, + String body) throws IOException { + byte[] bodyBytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", contentType); + exchange.sendResponseHeaders(status, bodyBytes.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(bodyBytes); + } + } + + private static String escapeJson(String s) { + return s.replace("\\", "\\\\").replace("\"", "\\\"") + .replace("\n", "\\n").replace("\r", "\\r"); + } + + private String baseUrl() { + return "http://localhost:" + port; + } + + // ======================================================================== + // Response Structure Tests + // ======================================================================== + + @Test + public void getTextReturnsResponseElementAndBody() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return count($response)"); + assertEquals("GET text should return 2 items (response element + body)", + "2", result.getResource(0).getContent().toString()); + } + + @Test + public void responseElementHasStatusAttribute() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return $response[1]/@status/string()"); + assertEquals("Response status should be 200", + "200", result.getResource(0).getContent().toString()); + } + + @Test + public void responseElementHasMessageAttribute() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return string-length($response[1]/@message) > 0"); + assertEquals("Response should have a non-empty message attribute", + "true", result.getResource(0).getContent().toString()); + } + + @Test + public void responseElementIsNamespacedCorrectly() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return namespace-uri($response[1])"); + assertEquals("Response element should be in EXPath HTTP namespace", + "http://expath.org/ns/http-client", + result.getResource(0).getContent().toString()); + } + + @Test + public void responseElementLocalNameIsResponse() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return local-name($response[1])"); + assertEquals("First item should be 'response' element", + "response", result.getResource(0).getContent().toString()); + } + + @Test + public void responseElementContainsHeaders() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return count($response[1]/http:header) > 0"); + assertEquals("Response should contain header elements", + "true", result.getResource(0).getContent().toString()); + } + + @Test + public void responseHeadersHaveNameAndValueAttributes() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "let $h := $response[1]/http:header[1]\n" + + "return exists($h/@name) and exists($h/@value)"); + assertEquals("Headers should have name and value attributes", + "true", result.getResource(0).getContent().toString()); + } + + @Test + public void responseContainsContentTypeHeader() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return $response[1]/http:header[lower-case(@name) = 'content-type']/@value/string()"); + assertTrue("Content-Type header should contain text/plain", + result.getResource(0).getContent().toString().contains("text/plain")); + } + + @Test + public void responseContainsBodyDescriptor() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return count($response[1]/http:body)"); + assertEquals("Response element should contain a body descriptor", + "1", result.getResource(0).getContent().toString()); + } + + @Test + public void bodyDescriptorHasMediaType() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return $response[1]/http:body/@media-type/string()"); + assertTrue("Body descriptor should have media-type", + result.getResource(0).getContent().toString().contains("text/plain")); + } + + // ======================================================================== + // Content Type Classification Tests (THE KEY TESTS) + // ======================================================================== + + @Test + public void textPlainResponseReturnsString() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return $response[2]"); + assertEquals("Plain text response body should be the string content", + "Hello, World!", result.getResource(0).getContent().toString()); + } + + @Test + public void textPlainResponseIsXsString() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return $response[2] instance of xs:string"); + assertEquals("text/plain response should be xs:string", + "true", result.getResource(0).getContent().toString()); + } + + @Test + public void jsonResponseReturnsString() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return $response[2]"); + assertEquals("JSON response body should be the string content", + "{\"name\":\"eXist\",\"version\":7}", + result.getResource(0).getContent().toString()); + } + + @Test + public void jsonResponseIsXsString() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return $response[2] instance of xs:string"); + assertEquals("application/json response should be xs:string, NOT base64Binary", + "true", result.getResource(0).getContent().toString()); + } + + @Test + public void jsonWithCharsetReturnsString() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return $response[2] instance of xs:string"); + assertEquals("JSON with charset should still be xs:string", + "true", result.getResource(0).getContent().toString()); + } + + @Test + public void jsonSubtypeReturnsString() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return $response[2] instance of xs:string"); + assertEquals("application/vnd.api+json should be xs:string", + "true", result.getResource(0).getContent().toString()); + } + + @Test + public void jsonResponseCanBeParsed() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return parse-json($response[2])?name"); + assertEquals("JSON response should be directly parseable without binary-to-string", + "eXist", result.getResource(0).getContent().toString()); + } + + @Test + public void xmlResponseIsParsedAsDocument() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return $response[2]//item/string()"); + assertEquals("XML response should be parsed as document node", + "test", result.getResource(0).getContent().toString()); + } + + @Test + public void xmlResponseIsDocumentNode() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return $response[2] instance of document-node()"); + assertEquals("application/xml response should be document-node()", + "true", result.getResource(0).getContent().toString()); + } + + @Test + public void textXmlResponseIsParsed() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return $response[2]//p/string()"); + assertEquals("text/xml response should be parsed as XML", + "paragraph", result.getResource(0).getContent().toString()); + } + + @Test + public void xmlSubtypeResponseIsParsed() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return $response[2] instance of document-node()"); + assertEquals("application/atom+xml should be parsed as XML", + "true", result.getResource(0).getContent().toString()); + } + + @Test + public void htmlResponseIsParsed() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return $response[2] instance of document-node()"); + assertEquals("text/html response should be parsed as document", + "true", result.getResource(0).getContent().toString()); + } + + @Test + public void binaryResponseIsBase64Binary() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return $response[2] instance of xs:base64Binary"); + assertEquals("image/png response should be xs:base64Binary", + "true", result.getResource(0).getContent().toString()); + } + + @Test + public void cssResponseIsString() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return $response[2] instance of xs:string"); + assertEquals("text/css response should be xs:string", + "true", result.getResource(0).getContent().toString()); + } + + @Test + public void javascriptResponseIsString() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return $response[2] instance of xs:string"); + assertEquals("application/javascript response should be xs:string", + "true", result.getResource(0).getContent().toString()); + } + + // ======================================================================== + // HTTP Methods Tests + // ======================================================================== + + @Test + public void getMethod() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return parse-json($response[2])?method"); + assertEquals("GET method should be sent", + "GET", result.getResource(0).getContent().toString()); + } + + @Test + public void postMethodWithBody() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + " {{\"test\": true}}" + + " " + + ")\n" + + "return parse-json($response[2])?method"); + assertEquals("POST method should be sent", + "POST", result.getResource(0).getContent().toString()); + } + + @Test + public void postBodyIsSent() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + " hello server" + + " " + + ")\n" + + "return contains(parse-json($response[2])?body, 'hello server')"); + assertEquals("POST body should be transmitted", + "true", result.getResource(0).getContent().toString()); + } + + @Test + public void putMethod() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + " update" + + " " + + ")\n" + + "return parse-json($response[2])?method"); + assertEquals("PUT method should be sent", + "PUT", result.getResource(0).getContent().toString()); + } + + @Test + public void deleteMethod() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return parse-json($response[2])?method"); + assertEquals("DELETE method should be sent", + "DELETE", result.getResource(0).getContent().toString()); + } + + @Test + public void headMethod() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return ($response[1]/@status/string(), count($response))"); + // HEAD returns status but no body — sequence should have 1 item (response only) + final String status = result.getResource(0).getContent().toString(); + assertEquals("HEAD should return 200 status", "200", status); + } + + @Test + public void headMethodReturnsNoBody() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return count($response)"); + assertEquals("HEAD should return only response element, no body", + "1", result.getResource(0).getContent().toString()); + } + + @Test + public void optionsMethod() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return $response[1]/@status/string()"); + assertEquals("OPTIONS should return 200", + "200", result.getResource(0).getContent().toString()); + } + + @Test + public void patchMethod() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + " {{\"field\":\"value\"}}" + + " " + + ")\n" + + "return parse-json($response[2])?patched"); + assertEquals("PATCH method should work", + "true", result.getResource(0).getContent().toString()); + } + + // ======================================================================== + // Request Headers Tests + // ======================================================================== + + @Test + public void customHeadersAreSent() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + " " + + " " + + ")\n" + + "return parse-json($response[2])?custom"); + assertEquals("Custom header should be sent", + "test-value", result.getResource(0).getContent().toString()); + } + + @Test + public void acceptHeaderIsSent() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + " " + + " " + + ")\n" + + "return parse-json($response[2])?accept"); + assertEquals("Accept header should be sent", + "application/json", result.getResource(0).getContent().toString()); + } + + @Test + public void contentTypeFromBodyMediaType() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + " {{\"test\":true}}" + + " " + + ")\n" + + "return contains(parse-json($response[2])?contentType, 'application/json')"); + assertEquals("Content-Type should be set from body media-type", + "true", result.getResource(0).getContent().toString()); + } + + // ======================================================================== + // Function Arity Tests + // ======================================================================== + + @Test + public void twoArgFormWithHref() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " , '" + baseUrl() + "/text'" + + ")\n" + + "return $response[2]"); + assertEquals("2-arg form with href should work", + "Hello, World!", result.getResource(0).getContent().toString()); + } + + @Test + public void hrefParameterOverridesAttribute() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " , " + + " '" + baseUrl() + "/text'" + + ")\n" + + "return $response[2]"); + assertEquals("href parameter should override href attribute", + "Hello, World!", result.getResource(0).getContent().toString()); + } + + @Test + public void threeArgFormWithBodies() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + " " + + " ," + + " '" + baseUrl() + "/echo'," + + " 'external body content'" + + ")\n" + + "return contains(parse-json($response[2])?body, 'external body content')"); + assertEquals("3-arg form with external body should work", + "true", result.getResource(0).getContent().toString()); + } + + // ======================================================================== + // HTTP Status Code Tests + // ======================================================================== + + @Test + public void status404() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return $response[1]/@status/string()"); + assertEquals("404 status should be reported", + "404", result.getResource(0).getContent().toString()); + } + + @Test + public void status500() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return $response[1]/@status/string()"); + assertEquals("500 status should be reported", + "500", result.getResource(0).getContent().toString()); + } + + @Test + public void status204NoContent() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return ($response[1]/@status/string(), count($response))"); + assertEquals("204 No Content status should be reported", + "204", result.getResource(0).getContent().toString()); + } + + @Test + public void status204HasNoBody() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return count($response)"); + assertEquals("204 response should have no body", + "1", result.getResource(0).getContent().toString()); + } + + @Test + public void errorStatusStillReturnsBody() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return $response[2]"); + assertEquals("Error responses should still return body content", + "Not Found", result.getResource(0).getContent().toString()); + } + + // ======================================================================== + // Redirect Tests + // ======================================================================== + + @Test + public void followRedirectTrue() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return $response[1]/@status/string()"); + assertEquals("Following redirect should give final 200 status", + "200", result.getResource(0).getContent().toString()); + } + + @Test + public void followRedirectTrueGetsContent() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return $response[2]"); + assertEquals("Following redirect should return final response body", + "Hello, World!", result.getResource(0).getContent().toString()); + } + + @Test + public void followRedirectFalse() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return $response[1]/@status/string()"); + assertEquals("Not following redirect should return 302", + "302", result.getResource(0).getContent().toString()); + } + + @Test + public void redirectChainFollowed() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return $response[2]"); + assertEquals("Chain of redirects should be followed", + "Hello, World!", result.getResource(0).getContent().toString()); + } + + @Test + public void defaultFollowRedirectIsTrue() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return $response[1]/@status/string()"); + assertEquals("Default follow-redirect should be true (follow redirects)", + "200", result.getResource(0).getContent().toString()); + } + + // ======================================================================== + // Timeout Tests + // ======================================================================== + + @Test + public void timeoutRaisesHC006() throws XMLDBException { + try { + existEmbeddedServer.executeQuery( + HTTP_NS + + "http:send-request(" + + " " + + ")"); + fail("Timeout should raise an error"); + } catch (XMLDBException e) { + assertTrue("Timeout error should mention HC006 or timeout: " + e.getMessage(), + e.getMessage().contains("HC006") || e.getMessage().toLowerCase().contains("timeout")); + } + } + + // ======================================================================== + // Authentication Tests + // ======================================================================== + + @Test + public void basicAuthWithCredentials() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return parse-json($response[2])?authenticated"); + assertEquals("Basic auth should authenticate successfully", + "true", result.getResource(0).getContent().toString()); + } + + @Test + public void basicAuthWithWrongCredentials() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return $response[1]/@status/string()"); + assertEquals("Wrong credentials should get 401", + "401", result.getResource(0).getContent().toString()); + } + + @Test + public void noAuthReturns401() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return $response[1]/@status/string()"); + assertEquals("No auth should get 401", + "401", result.getResource(0).getContent().toString()); + } + + // ======================================================================== + // Special Request Attributes Tests + // ======================================================================== + + @Test + public void statusOnlyReturnsNoBody() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return count($response)"); + assertEquals("status-only should return only response element", + "1", result.getResource(0).getContent().toString()); + } + + @Test + public void statusOnlyStillHasStatus() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return $response[1]/@status/string()"); + assertEquals("status-only should still report status", + "200", result.getResource(0).getContent().toString()); + } + + @Test + public void overrideMediaType() throws XMLDBException { + // Override binary content-type to treat as text + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return $response[2] instance of xs:string"); + assertEquals("override-media-type should control response type classification", + "true", result.getResource(0).getContent().toString()); + } + + // ======================================================================== + // Multi-Header Tests + // ======================================================================== + + @Test + public void multipleResponseHeaders() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return count($response[1]/http:header[lower-case(@name) = 'set-cookie'])"); + // Multiple Set-Cookie headers should be preserved + final int count = Integer.parseInt(result.getResource(0).getContent().toString()); + assertTrue("Multiple headers with same name should be preserved, got: " + count, + count >= 2); + } + + // ======================================================================== + // Encoding Tests + // ======================================================================== + + @Test + public void utf8ResponseContent() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return contains($response[2], 'Héllo')"); + assertEquals("UTF-8 response should preserve characters", + "true", result.getResource(0).getContent().toString()); + } + + @Test + public void utf8ResponseWithJapanese() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return contains($response[2], '日本語')"); + assertEquals("UTF-8 response should preserve Japanese characters", + "true", result.getResource(0).getContent().toString()); + } + + // ======================================================================== + // gzip Tests + // ======================================================================== + + @Test + public void gzipResponseIsDecompressed() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + " " + + " " + + ")\n" + + "return $response[2]"); + assertEquals("gzip response should be transparently decompressed", + "gzipped content", result.getResource(0).getContent().toString()); + } + + // ======================================================================== + // XML Body Request Tests + // ======================================================================== + + @Test + public void postXmlBody() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + " 1" + + " " + + ")\n" + + "return contains(parse-json($response[2])?body, '1')"); + assertEquals("XML body should be serialized and sent", + "true", result.getResource(0).getContent().toString()); + } + + // ======================================================================== + // Error Handling Tests + // ======================================================================== + + @Test + public void invalidUriRaisesError() throws XMLDBException { + try { + existEmbeddedServer.executeQuery( + HTTP_NS + + "http:send-request(" + + " " + + ")"); + fail("Invalid URI should raise an error"); + } catch (XMLDBException e) { + // Expected — should get HC001 or similar error + assertNotNull("Error should have a message", e.getMessage()); + } + } + + @Test + public void connectionRefusedRaisesHC001() throws XMLDBException { + try { + existEmbeddedServer.executeQuery( + HTTP_NS + + "http:send-request(" + + " " + + ")"); + fail("Connection refused should raise an error"); + } catch (XMLDBException e) { + assertTrue("Error should mention HC001 or connection: " + e.getMessage(), + e.getMessage().contains("HC001") || + e.getMessage().toLowerCase().contains("connect") || + e.getMessage().toLowerCase().contains("refused")); + } + } + + @Test + public void missingHrefRaisesError() throws XMLDBException { + try { + existEmbeddedServer.executeQuery( + HTTP_NS + + "http:send-request(" + + " " + + ")"); + fail("Missing href should raise an error"); + } catch (XMLDBException e) { + assertNotNull("Missing href should produce an error", e.getMessage()); + } + } + + @Test + public void missingMethodRaisesError() throws XMLDBException { + try { + existEmbeddedServer.executeQuery( + HTTP_NS + + "http:send-request(" + + " " + + ")"); + fail("Missing method should raise an error"); + } catch (XMLDBException e) { + assertNotNull("Missing method should produce an error", e.getMessage()); + } + } + + // ======================================================================== + // Large Response Tests + // ======================================================================== + + @Test + public void largeResponseIsFullyReturned() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return string-length($response[2]) > 10000"); + assertEquals("Large response should be fully returned", + "true", result.getResource(0).getContent().toString()); + } + + // ======================================================================== + // Backward Compatibility Tests + // ======================================================================== + + @Test + public void binaryToStringStillWorksOnTextResponse() throws XMLDBException { + // The old workaround (util:binary-to-string) should still work even though + // the response is now a string — util:binary-to-string accepts strings too + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return $response[2]"); + // Just verify we get the content — the key is no error is thrown + assertTrue("Response should contain JSON content", + result.getResource(0).getContent().toString().contains("eXist")); + } + + // ======================================================================== + // Multipart Response Tests + // ======================================================================== + + @Test + public void multipartResponseReturnsMultipleItems() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return count($response)"); + final int count = Integer.parseInt(result.getResource(0).getContent().toString()); + assertTrue("Multipart response should return 3+ items (response + 2 parts), got: " + count, + count >= 3); + } + + @Test + public void multipartResponseContainsMultibodyElements() throws XMLDBException { + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return exists($response[1]/http:multipart)"); + assertEquals("Multipart response element should contain http:multipart", + "true", result.getResource(0).getContent().toString()); + } +} diff --git a/extensions/modules/http-client/src/test/resources/conf.xml b/extensions/modules/http-client/src/test/resources/conf.xml new file mode 100644 index 00000000000..16b0302f37d --- /dev/null +++ b/extensions/modules/http-client/src/test/resources/conf.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + diff --git a/extensions/modules/pom.xml b/extensions/modules/pom.xml index b8b96cd2b5a..c17e1eb1121 100644 --- a/extensions/modules/pom.xml +++ b/extensions/modules/pom.xml @@ -48,6 +48,7 @@ expathrepo expathrepo/expathrepo-trigger-test file + http-client image jndi mail From c7da9a6ca3306c1c1bf1033db75dcdc27b0dace8 Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Mon, 15 Jun 2026 00:54:20 -0400 Subject: [PATCH 4/8] [refactor] Replace the EXPath HTTP client with the native module; drop http-client-java Switches the http://expath.org/ns/http-client registration in every conf.xml to the new native module (org.exist.xquery.modules.httpclient.HttpClientModule) and removes the old implementation, which depended on Apache HttpClient and the third-party EXPath http-client-java library. - extensions/expath: delete the old SendRequestFunction, HttpClientModule, EXistResult, EXistTreeBuilder and the now-orphaned org.expath.tools adapters; the module keeps only its Zip functions. Drop the http-client-java, tools-java, apache-mime4j-core and HC4 httpcore dependencies (and the now-unused junit). - exist-distribution: add the new exist-http-client module as a runtime dependency so it ships in the assembled distribution (and container). The old HttpClientModule shipped inside exist-expath; without this, the repointed conf.xml registration fails to load the class on a clean boot (ClassNotFoundException). - restxq: its XQSuite tests resolve http:send-request from the EXPath HTTP client, so swap the exist-expath test dependency for the new exist-http-client module (the client no longer lives in exist-expath). - debuggee: migrate HttpSession's XDEBUG_SESSION form POST from Apache HttpClient to the JDK java.net.http.HttpClient; drop the Apache HttpClient dependency from its pom. No production code uses Apache HttpClient after this change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../resources/org/exist/xmldb/allowAnyUri.xml | 2 +- exist-distribution/pom.xml | 6 + exist-distribution/src/main/config/conf.xml | 2 +- extensions/debuggee/pom.xml | 5 - .../java/org/exist/debugger/HttpSession.java | 21 +- extensions/expath/pom.xml | 52 ---- .../org/expath/exist/HttpClientModule.java | 72 ----- .../org/expath/exist/SendRequestFunction.java | 190 ------------ .../httpclient/model/exist/EXistResult.java | 159 ---------- .../model/exist/EXistTreeBuilder.java | 103 ------- .../tools/model/exist/EXistAttribute.java | 69 ----- .../tools/model/exist/EXistElement.java | 288 ------------------ .../tools/model/exist/EXistSequence.java | 180 ----------- .../java/org/expath/exist/HttpClientTest.java | 93 ------ .../src/test/resources-filtered/conf.xml | 2 +- extensions/exquery/restxq/pom.xml | 4 +- .../src/test/resources-filtered/conf.xml | 2 +- 17 files changed, 25 insertions(+), 1225 deletions(-) delete mode 100644 extensions/expath/src/main/java/org/expath/exist/HttpClientModule.java delete mode 100644 extensions/expath/src/main/java/org/expath/exist/SendRequestFunction.java delete mode 100644 extensions/expath/src/main/java/org/expath/httpclient/model/exist/EXistResult.java delete mode 100644 extensions/expath/src/main/java/org/expath/httpclient/model/exist/EXistTreeBuilder.java delete mode 100644 extensions/expath/src/main/java/org/expath/tools/model/exist/EXistAttribute.java delete mode 100644 extensions/expath/src/main/java/org/expath/tools/model/exist/EXistElement.java delete mode 100644 extensions/expath/src/main/java/org/expath/tools/model/exist/EXistSequence.java delete mode 100644 extensions/expath/src/test/java/org/expath/exist/HttpClientTest.java diff --git a/exist-core/src/test/resources/org/exist/xmldb/allowAnyUri.xml b/exist-core/src/test/resources/org/exist/xmldb/allowAnyUri.xml index 46f2ccd8e99..a938990b72a 100644 --- a/exist-core/src/test/resources/org/exist/xmldb/allowAnyUri.xml +++ b/exist-core/src/test/resources/org/exist/xmldb/allowAnyUri.xml @@ -981,7 +981,7 @@ - + diff --git a/exist-distribution/pom.xml b/exist-distribution/pom.xml index 813cbb28fd3..5f8d3e83863 100644 --- a/exist-distribution/pom.xml +++ b/exist-distribution/pom.xml @@ -225,6 +225,12 @@ ${project.version} runtime
+ + ${project.groupId} + exist-http-client + ${project.version} + runtime + ${project.groupId} exist-image diff --git a/exist-distribution/src/main/config/conf.xml b/exist-distribution/src/main/config/conf.xml index c6aa36fb6a9..bc2156fa15b 100644 --- a/exist-distribution/src/main/config/conf.xml +++ b/exist-distribution/src/main/config/conf.xml @@ -1014,7 +1014,7 @@ - + diff --git a/extensions/debuggee/pom.xml b/extensions/debuggee/pom.xml index c622046bc13..90b3d31da41 100644 --- a/extensions/debuggee/pom.xml +++ b/extensions/debuggee/pom.xml @@ -58,11 +58,6 @@ 2.1.12 - - org.apache.httpcomponents - fluent-hc - - org.junit.jupiter junit-jupiter-api diff --git a/extensions/debuggee/src/main/java/org/exist/debugger/HttpSession.java b/extensions/debuggee/src/main/java/org/exist/debugger/HttpSession.java index f7618446bea..e37cd3be68e 100644 --- a/extensions/debuggee/src/main/java/org/exist/debugger/HttpSession.java +++ b/extensions/debuggee/src/main/java/org/exist/debugger/HttpSession.java @@ -21,8 +21,11 @@ */ package org.exist.debugger; -import org.apache.http.client.fluent.Form; -import org.apache.http.client.fluent.Request; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; /** * @author Dmitriy Shabanov @@ -45,12 +48,14 @@ public void run() { try { System.out.println("sending http request with debugging flag"); - final int code = Request.Post(url) - .bodyForm(Form.form().add("XDEBUG_SESSION", "default").build()) - .execute() - .returnResponse() - .getStatusLine() - .getStatusCode(); + final HttpRequest request = HttpRequest.newBuilder(URI.create(url)) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString("XDEBUG_SESSION=default", StandardCharsets.UTF_8)) + .build(); + final int code; + try (final HttpClient client = HttpClient.newHttpClient()) { + code = client.send(request, HttpResponse.BodyHandlers.discarding()).statusCode(); + } debugger.terminate(url, code); diff --git a/extensions/expath/pom.xml b/extensions/expath/pom.xml index 4b9c69bdf21..7ba52bcc7d8 100644 --- a/extensions/expath/pom.xml +++ b/extensions/expath/pom.xml @@ -57,72 +57,20 @@ commons-io - - org.apache.httpcomponents - httpcore - - org.apache.logging.log4j log4j-api - - - org.apache.james - apache-mime4j-core - 0.8.14 - - - - org.expath.http.client - http-client-java - 1.4.2 - - - - org.expath.tools - tools-java - 0.7.0 - - com.google.code.findbugs jsr305 - - junit - junit - test - - - - org.apache.maven.plugins - maven-dependency-plugin - - - analyze - - analyze-only - - - true - - org.apache.james:apache-mime4j-core - - - - - - src/test/resources diff --git a/extensions/expath/src/main/java/org/expath/exist/HttpClientModule.java b/extensions/expath/src/main/java/org/expath/exist/HttpClientModule.java deleted file mode 100644 index f00ec44ee75..00000000000 --- a/extensions/expath/src/main/java/org/expath/exist/HttpClientModule.java +++ /dev/null @@ -1,72 +0,0 @@ -/* - * eXist-db Open Source Native XML Database - * Copyright (C) 2001 The eXist-db Authors - * - * info@exist-db.org - * http://www.exist-db.org - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 2.1 of the License, or (at your option) any later version. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public - * License along with this library; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - */ -package org.expath.exist; - -import java.util.List; -import java.util.Map; - -import org.expath.httpclient.HttpConstants; -import org.exist.xquery.AbstractInternalModule; -import org.exist.xquery.FunctionDef; - -/** - * @author Adam Retter - * @version EXPath HTTP Client Module Candidate 9 January 2010 http://expath.org/spec/http-client - */ -public class HttpClientModule extends AbstractInternalModule { - - public final static String NAMESPACE_URI = HttpConstants.HTTP_CLIENT_NS_URI; - - public final static String PREFIX = "http"; - public final static String INCLUSION_DATE = "2011-03-17"; - public final static String RELEASED_IN_VERSION = "1.5"; - - private final static FunctionDef[] functions = { - new FunctionDef(SendRequestFunction.signatures[0], SendRequestFunction.class), - new FunctionDef(SendRequestFunction.signatures[1], SendRequestFunction.class), - new FunctionDef(SendRequestFunction.signatures[2], SendRequestFunction.class), - }; - - public HttpClientModule(Map> parameters) { - super(functions, parameters); - } - - @Override - public String getNamespaceURI() { - return NAMESPACE_URI; - } - - @Override - public String getDefaultPrefix() { - return PREFIX; - } - - @Override - public String getDescription() { - return "EXPath HTTP Client http://expath.org/spec/http-client"; - } - - @Override - public String getReleaseVersion() { - return RELEASED_IN_VERSION; - } -} diff --git a/extensions/expath/src/main/java/org/expath/exist/SendRequestFunction.java b/extensions/expath/src/main/java/org/expath/exist/SendRequestFunction.java deleted file mode 100644 index edaaa1eb32c..00000000000 --- a/extensions/expath/src/main/java/org/expath/exist/SendRequestFunction.java +++ /dev/null @@ -1,190 +0,0 @@ -/* - * eXist-db Open Source Native XML Database - * Copyright (C) 2001 The eXist-db Authors - * - * info@exist-db.org - * http://www.exist-db.org - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 2.1 of the License, or (at your option) any later version. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public - * License along with this library; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - */ -package org.expath.exist; - -import java.net.URI; -import java.net.URISyntaxException; -import org.apache.http.HttpStatus; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.exist.dom.QName; -import org.exist.xquery.BasicFunction; -import org.exist.xquery.Cardinality; -import org.exist.xquery.FunctionSignature; -import org.exist.xquery.XPathException; -import org.exist.xquery.XQueryContext; -import org.exist.xquery.value.FunctionParameterSequenceType; -import org.exist.xquery.value.FunctionReturnSequenceType; -import org.exist.xquery.value.Item; -import org.exist.xquery.value.NodeValue; -import org.exist.xquery.value.Sequence; -import org.exist.xquery.value.SequenceType; -import org.exist.xquery.value.Type; -import org.expath.httpclient.HttpClientException; -import org.expath.httpclient.HttpConnection; -import org.expath.httpclient.HttpRequest; -import org.expath.httpclient.HttpResponse; -import org.expath.httpclient.impl.ApacheHttpConnection; -import org.expath.httpclient.impl.RequestParser; -import org.expath.tools.model.Element; -import org.expath.tools.model.exist.EXistElement; -import org.expath.httpclient.model.exist.EXistResult; -import org.expath.tools.model.exist.EXistSequence; - -/** - * @author Adam Retter - * @version EXPath HTTP Client Module Candidate 9 January 2010 http://expath.org/spec/http-client/20100109 - */ -public class SendRequestFunction extends BasicFunction { - - private static final Logger logger = LogManager.getLogger(SendRequestFunction.class); - - private final static FunctionParameterSequenceType REQUEST_PARAM = new FunctionParameterSequenceType("request", Type.ELEMENT, Cardinality.ZERO_OR_ONE, "request contains the various parameters of the request, for instance the HTTP method to use or the HTTP headers. Among other things, it can also contain the other param's values: the URI and the bodies. If they are not set as parameter to the function, their value in $request, if any, is used instead. See the following section (http://www.expath.org/spec/http-client#d2e183) for the detailed definition of the http:request element. If the parameter does not follow the grammar defined in this spec, this is an error [err:HC005]."); - private final static FunctionParameterSequenceType HREF_PARAM = new FunctionParameterSequenceType("href", Type.STRING, Cardinality.ZERO_OR_ONE, "$href is the HTTP or HTTPS URI to send the request to. It is an xs:anyURI, but is declared as a string to be able to pass literal strings (without requiring to explicitly cast it to an xs:anyURI)"); - private final static FunctionParameterSequenceType BODIES_PARAM = new FunctionParameterSequenceType("bodies", Type.ITEM, Cardinality.ZERO_OR_MORE, "$bodies is the request body content, for HTTP methods that can contain a body in the request (e.g. POST). This is an error if this param is not the empty sequence for methods that must be empty (e.g. DELETE). The details of the methods are defined in their respective specs (e.g. [RFC 2616] or [RFC 4918]). In case of a multipart request, it can be a sequence of several items, each one is the body of the corresponding body descriptor in $request."); - private final static FunctionReturnSequenceType RETURN_TYPE = new FunctionReturnSequenceType(Type.ITEM, Cardinality.ONE_OR_MORE, "A sequence representing the response from the server. This sequence has an http:response element as first item, which is followed by an additional item for each body or body part in the response. Further detail can be found here - http://www.expath.org/spec/http-client#d2e483"); - - public final static FunctionSignature signatures[] = { - //http:send-request($request as element(http:request)?) as item()+ - new FunctionSignature( - new QName("send-request", HttpClientModule.NAMESPACE_URI, HttpClientModule.PREFIX), - "Sends a HTTP request to a server and returns the response.", - new SequenceType[]{ - REQUEST_PARAM - }, - RETURN_TYPE - ), - //http:send-request($request as element(http:request)?, $href as xs:string?) as item()+ - new FunctionSignature( - new QName("send-request", HttpClientModule.NAMESPACE_URI, HttpClientModule.PREFIX), - "Sends a HTTP request to a server and returns the response.", - new SequenceType[]{ - REQUEST_PARAM, - HREF_PARAM - }, - RETURN_TYPE - ), - //http:send-request($request as element(http:request)?, $href as xs:string?, $bodies as item()*) as item()+ - new FunctionSignature( - new QName("send-request", HttpClientModule.NAMESPACE_URI, HttpClientModule.PREFIX), - "Sends a HTTP request to a server and returns the response.", - new SequenceType[]{ - REQUEST_PARAM, - HREF_PARAM, - BODIES_PARAM - }, - RETURN_TYPE - ) - }; - - /** - * SendRequestFunction Constructor - * - * @param context The Context of the calling XQuery - * @param signature The actual signature of the function - */ - public SendRequestFunction(final XQueryContext context, final FunctionSignature signature) { - super(context, signature); - } - - @Override - public Sequence eval(final Sequence[] args, final Sequence contextSequence) throws XPathException { - - Sequence bodies = Sequence.EMPTY_SEQUENCE; - String href = null; - NodeValue request = null; - - switch(getArgumentCount()) { - case 3: - bodies = args[2]; - case 2: { - Item i = args[1].itemAt(0); - if ( i != null ) { - href = i.getStringValue(); - } - } - case 1: - request = (NodeValue)args[0].itemAt(0); - break; - - default: - return Sequence.EMPTY_SEQUENCE; - } - - return sendRequest(request, href, bodies); - } - - private Sequence sendRequest(final NodeValue request, final String href, final Sequence bodies) throws XPathException { - - HttpRequest req = null; - try { - final org.expath.tools.model.Sequence b = new EXistSequence(bodies, getContext()); - final Element r = new EXistElement(request, getContext()); - final RequestParser parser = new RequestParser(r); - req = parser.parse(b, href); - - // override anyway it href exists - if (href != null && !href.isEmpty() ) { - req.setHref(href); - } - - final URI uri = new URI(req.getHref()); - final EXistResult result = sendOnce(uri, req, parser); - return result.getResult(); - - } catch(final URISyntaxException ex ) { - throw new XPathException(this, "Href is not valid: " + req != null ? req.getHref() : "" + ". " + ex.getMessage(), ex); - } catch(final HttpClientException hce) { - throw new XPathException(this, hce.getMessage(), hce); - } - } - - /** - * Send one request, not following redirect but handling authentication. - * - * Authentication may require to reply to an authentication challenge, - * by sending again the request, with credentials. - */ - private EXistResult sendOnce(final URI uri, final HttpRequest request, final RequestParser parser) throws HttpClientException { - EXistResult result = new EXistResult(getContext()); - HttpConnection conn = null; - try { - conn = new ApacheHttpConnection(uri); - final HttpResponse response = request.send(result, conn, parser.getCredentials()); - if(response.getStatus() == HttpStatus.SC_UNAUTHORIZED && parser.getCredentials() != null) { - // requires authorization, try again with auth - result = new EXistResult(getContext()); - request.send(result, conn, parser.getCredentials()); - } - } finally { - if(conn != null) { - try { - conn.disconnect(); - } catch(final HttpClientException hcee) { - logger.warn(hcee.getMessage(), hcee); - } - } - } - - return result; - } -} diff --git a/extensions/expath/src/main/java/org/expath/httpclient/model/exist/EXistResult.java b/extensions/expath/src/main/java/org/expath/httpclient/model/exist/EXistResult.java deleted file mode 100644 index 6b24fd70c6c..00000000000 --- a/extensions/expath/src/main/java/org/expath/httpclient/model/exist/EXistResult.java +++ /dev/null @@ -1,159 +0,0 @@ -/* - * eXist-db Open Source Native XML Database - * Copyright (C) 2001 The eXist-db Authors - * - * info@exist-db.org - * http://www.exist-db.org - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 2.1 of the License, or (at your option) any later version. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public - * License along with this library; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - */ -package org.expath.httpclient.model.exist; - -import java.io.IOException; -import java.io.InputStream; -import java.io.Reader; -import java.nio.charset.Charset; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardCopyOption; -import javax.xml.transform.Source; - -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.exist.dom.memtree.DocumentImpl; -import org.exist.util.io.TemporaryFileManager; -import org.exist.xquery.NodeTest; -import org.exist.xquery.TypeTest; -import org.exist.xquery.XPathException; -import org.exist.xquery.XQueryContext; -import org.exist.xquery.modules.ModuleUtils; -import org.exist.xquery.value.Base64BinaryValueType; -import org.exist.xquery.value.BinaryValueFromFile; -import org.exist.xquery.value.NodeValue; -import org.exist.xquery.value.Sequence; -import org.exist.xquery.value.StringValue; -import org.exist.xquery.value.Type; -import org.exist.xquery.value.ValueSequence; -import org.expath.httpclient.HttpClientException; -import org.expath.httpclient.HttpResponse; -import org.expath.httpclient.model.Result; -import org.xml.sax.SAXException; - -import static org.expath.httpclient.HttpClientError.HC001; - -/** - * @author Adam Retter - */ -public class EXistResult implements Result { - - private static final Logger logger = LogManager.getLogger(EXistResult.class); - - ValueSequence result = new ValueSequence(); - - private final XQueryContext context; - - public EXistResult(final XQueryContext context) { - this.context = context; - } - - @Override - public Result makeNewResult() throws HttpClientException { - return new EXistResult(context.copyContext()); - } - - @Override - public void add(final Reader reader, final Charset charset) throws HttpClientException { - - // START TEMP - //TODO(AR) - replace with a deferred StringReader when eXist has this soon. - final StringBuilder builder = new StringBuilder(); - try { - final char cbuf[] = new char[4096]; - int read = -1; - while((read = reader.read(cbuf)) > -1) { - builder.append(cbuf, 0, read); - } - } catch(final IOException ioe) { - throw new HttpClientException(HC001, "Unable to add string value to result: " + ioe.getMessage(), ioe); - } finally { - try { - reader.close(); - } catch(final IOException ioe) { - logger.warn(ioe.getMessage(), ioe); - } - } - // END TEMP - - result.add(new StringValue(builder.toString())); - } - - @Override - public void add(final InputStream is) throws HttpClientException { - try { - // we have to make a temporary copy of the data stream, as the socket will be closed shortly - final TemporaryFileManager temporaryFileManager = TemporaryFileManager.getInstance(); - final Path tempFile = temporaryFileManager.getTemporaryFile(); - Files.copy(is, tempFile, StandardCopyOption.REPLACE_EXISTING); - - result.add(BinaryValueFromFile.getInstance(context, new Base64BinaryValueType(), tempFile, (isClosed, file) -> temporaryFileManager.returnTemporaryFile(file), null)); - } catch(final XPathException | IOException xpe) { - throw new HttpClientException(HC001, "Unable to add binary value to result:" + xpe.getMessage(), xpe); - } finally { - try { - is.close(); - } catch(final IOException ioe) { - logger.warn(ioe.getMessage(), ioe); - } - } - } - - @Override - public void add(final Source src) throws HttpClientException { - try { - final NodeValue nodeValue = ModuleUtils.sourceToXML(context, src, null); - result.add(nodeValue); - } catch(final SAXException | IOException saxe) { - throw new HttpClientException(HC001, "Unable to add Source to result:" + saxe.getMessage(), saxe); - } - } - - @Override - public void add(final HttpResponse response) throws HttpClientException { - final EXistTreeBuilder builder = new EXistTreeBuilder(context); - response.outputResponseElement(builder); - final DocumentImpl doc = builder.close(); - try { - // we add the root *element* to the result sequence - final NodeTest kind = new TypeTest(Type.ELEMENT); - // the elem must always be added at the front, so if there are - // already other items, we create a new one, add the elem, then - // add the original items after - if(result.isEmpty()) { - doc.selectChildren(kind, result); - } else { - final ValueSequence newResult = new ValueSequence(); - doc.selectChildren(kind, newResult); - newResult.addAll(result); - result = newResult; - } - } catch (final XPathException xpe) { - throw new HttpClientException(HC001, "Unable to add HttpResponse to result:" + xpe.getMessage(), xpe); - } - } - - public Sequence getResult() { - return result; - } -} diff --git a/extensions/expath/src/main/java/org/expath/httpclient/model/exist/EXistTreeBuilder.java b/extensions/expath/src/main/java/org/expath/httpclient/model/exist/EXistTreeBuilder.java deleted file mode 100644 index 85da486d913..00000000000 --- a/extensions/expath/src/main/java/org/expath/httpclient/model/exist/EXistTreeBuilder.java +++ /dev/null @@ -1,103 +0,0 @@ -/* - * eXist-db Open Source Native XML Database - * Copyright (C) 2001 The eXist-db Authors - * - * info@exist-db.org - * http://www.exist-db.org - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 2.1 of the License, or (at your option) any later version. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public - * License along with this library; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - */ -package org.expath.httpclient.model.exist; - -import org.apache.http.Header; -import org.exist.dom.QName; -import org.exist.dom.memtree.DocumentImpl; -import org.exist.dom.memtree.MemTreeBuilder; -import org.exist.xquery.XQueryContext; -import org.expath.httpclient.HeaderSet; -import org.expath.httpclient.HttpClientException; -import org.expath.httpclient.HttpConstants; -import org.expath.httpclient.model.TreeBuilder; -import org.expath.tools.ToolsException; - -import javax.xml.XMLConstants; - -import static org.expath.httpclient.HttpClientError.HC001; - -/** - * @author Adam Retter - */ -public class EXistTreeBuilder implements TreeBuilder { - - final MemTreeBuilder builder; - - public EXistTreeBuilder(final XQueryContext context) { - context.pushDocumentContext(); - builder = context.getDocumentBuilder(); - builder.startDocument(); - } - - //TODO EXPath Caller should send QName, otherwise we duplicate code and reduce reuse! - @Override - public void startElem(final String localname) throws ToolsException { - final String prefix = HttpConstants.HTTP_CLIENT_NS_PREFIX; - final String uri = HttpConstants.HTTP_CLIENT_NS_URI; - - builder.startElement(new QName(localname, uri, prefix), null); - } - - @Override - public void attribute(final String localname, final CharSequence value) throws ToolsException { - builder.addAttribute(new QName(localname, XMLConstants.NULL_NS_URI), value.toString()); - } - - @Override - public void startContent() throws ToolsException { - //TODO this is not needed in eXist-db, it is very saxon specific - } - - @Override - public void endElem() throws ToolsException { - builder.endElement(); - } - - public DocumentImpl close() { - builder.endDocument(); - final DocumentImpl doc = builder.getDocument(); - builder.getContext().popDocumentContext(); - return doc; - } - - @Override - public void outputHeaders(HeaderSet headers) - throws HttpClientException - { - for ( Header h : headers ) { - assert h.getName() != null : "Header name cannot be null"; - String name = h.getName().toLowerCase(); - try { - startElem("header"); - attribute("name", name); - attribute("value", h.getValue()); - //startContent(); - endElem(); - } - catch ( ToolsException ex ) { - throw new HttpClientException(HC001, "Error building the header " + name, ex); - } - } - } - -} \ No newline at end of file diff --git a/extensions/expath/src/main/java/org/expath/tools/model/exist/EXistAttribute.java b/extensions/expath/src/main/java/org/expath/tools/model/exist/EXistAttribute.java deleted file mode 100644 index ed2f47b8643..00000000000 --- a/extensions/expath/src/main/java/org/expath/tools/model/exist/EXistAttribute.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * eXist-db Open Source Native XML Database - * Copyright (C) 2001 The eXist-db Authors - * - * info@exist-db.org - * http://www.exist-db.org - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 2.1 of the License, or (at your option) any later version. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public - * License along with this library; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - */ -package org.expath.tools.model.exist; - -import org.expath.tools.ToolsException; -import org.expath.tools.model.Attribute; -import org.w3c.dom.Attr; - -/** - * @author Adam Retter - */ -public class EXistAttribute implements Attribute { - - final Attr attribute; - - public EXistAttribute(Attr attribute) { - this.attribute = attribute; - } - - @Override - public String getLocalName() { - return attribute.getLocalName(); - } - - @Override - public String getNamespaceUri() { - return attribute.getNamespaceURI(); - } - - @Override - public String getValue() { - return attribute.getValue(); - } - - @Override - public boolean getBoolean() throws ToolsException { - return "true".equalsIgnoreCase(attribute.getValue()); - } - - @Override - public int getInteger() throws ToolsException { - String s = attribute.getValue(); - try { - return Integer.parseInt(s); - } - catch ( NumberFormatException ex ) { - throw new ToolsException("@" + getLocalName() + " is not an integer"); - } - } -} diff --git a/extensions/expath/src/main/java/org/expath/tools/model/exist/EXistElement.java b/extensions/expath/src/main/java/org/expath/tools/model/exist/EXistElement.java deleted file mode 100644 index a78a5eb87d4..00000000000 --- a/extensions/expath/src/main/java/org/expath/tools/model/exist/EXistElement.java +++ /dev/null @@ -1,288 +0,0 @@ -/* - * eXist-db Open Source Native XML Database - * Copyright (C) 2001 The eXist-db Authors - * - * info@exist-db.org - * http://www.exist-db.org - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 2.1 of the License, or (at your option) any later version. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public - * License along with this library; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - */ -package org.expath.tools.model.exist; - -import java.util.*; - -import org.exist.xquery.XPathException; -import org.exist.xquery.XQueryContext; -import org.exist.xquery.value.NodeValue; -import org.exist.xquery.value.ValueSequence; -import org.expath.httpclient.HttpConstants; -import org.expath.tools.ToolsException; -import org.expath.tools.model.Attribute; -import org.expath.tools.model.Element; -import org.expath.tools.model.Sequence; -import org.w3c.dom.Attr; -import org.w3c.dom.NamedNodeMap; -import org.w3c.dom.Node; -import org.w3c.dom.NodeList; - -import javax.xml.XMLConstants; -import javax.xml.namespace.QName; - -/** - * @author Adam Retter - */ -public class EXistElement implements Element { - - private final NodeValue element; - private final XQueryContext context; - - public EXistElement(final NodeValue element, final XQueryContext context) { - this.element = element; - this.context = context; - } - - @Override - public Iterable attributes() { - - return () -> new Iterator<>() { - - private final NamedNodeMap attrs = element.getNode().getAttributes(); - private final int length = attrs.getLength(); - private int position = 0; - - @Override - public boolean hasNext() { - return (position < length); - } - - @Override - public Attribute next() { - if (position >= length) { - throw new NoSuchElementException(); - } - - return new EXistAttribute((Attr) attrs.item(position++)); - } - - @Override - public void remove() { - throw new UnsupportedOperationException("Not supported yet."); - } - - }; - } - - @Override - public Iterable children() { - final Node node = element.getNode(); - return new IterableElement(node); - } - - @Override - public String getAttribute(final String local_name) { - String attrValue = null; - final NamedNodeMap attrs = element.getNode().getAttributes(); - final Node attr = attrs.getNamedItem(local_name); - if(attr != null) { - attrValue = ((Attr)attr).getValue(); - } - - return attrValue; - } - - @Override - public Sequence getContent() { - final org.exist.xquery.value.Sequence valueSequence = new ValueSequence(); - - final NodeList children = element.getNode().getChildNodes(); - - try { - for(int i = 0; i < children.getLength(); i++) { - final Node child = children.item(i); - valueSequence.add((NodeValue)child); - } - return new EXistSequence(valueSequence, context); - } catch(final XPathException xpe) { - throw new RuntimeException(xpe.getMessage(), xpe); - } - } - - @Override - public String getDisplayName() { - return element.getNode().getNodeName(); - } - - @Override - public String getLocalName() { - return element.getNode().getLocalName(); - } - - @Override - public String getNamespaceUri() { - return element.getNode().getNamespaceURI(); - } - - @Override - public boolean hasNoNsChild() { - final NodeList children = element.getNode().getChildNodes(); - for(int i = 0; i < children.getLength(); i++) { - final Node child = children.item(i); - if(child.getNamespaceURI() == null && child.getPrefix() == null || child.getNamespaceURI().equals(XMLConstants.NULL_NS_URI)) { - return true; - } - } - return false; - } - - @Override - public Iterable children(final String ns) { - final Node node = element.getNode(); - return new IterableElement(node, ns); - } - - @Override - public void noOtherNCNameAttribute(final String[] names, String[] forbidden_ns) throws ToolsException { - if ( forbidden_ns == null ) { - forbidden_ns = new String[] { }; - } - - final String[] sorted_names = sortCopy(names); - final String[] sorted_ns = sortCopy(forbidden_ns); - - final NamedNodeMap attributes = element.getNode().getAttributes(); - - for(int i = 0; i < attributes.getLength(); i++) { - final Node attr = attributes.item(i); - final String attr_name = attr.getLocalName(); - final String ns = attr.getNamespaceURI(); - - if( ns != null && Arrays.binarySearch(sorted_ns, ns) >= 0 ) { - if(ns.equals(HttpConstants.HTTP_CLIENT_NS_URI)) { - throw new ToolsException("@" + attr_name + " in namespace " + ns + " not allowed on " + getDisplayName()); - } - } else if (ns!= null && ! ns.isEmpty() ) { - // ignore other-namespace-attributes - } else if ( Arrays.binarySearch(sorted_names, attr.getLocalName()) < 0 ) { - throw new ToolsException("@" + attr_name + " not allowed on " + getDisplayName()); - } - } - } - - private String[] sortCopy(final String[] array) - { - final String[] sorted = new String[array.length]; - System.arraycopy(array, 0, sorted, 0, sorted.length); - Arrays.sort(sorted); - return sorted; - } - - @Override - public QName parseQName(final String value) - throws ToolsException - { - try { - final org.exist.dom.QName qn = org.exist.dom.QName.parse(context, value, element.getQName().getNamespaceURI()); - return qn.toJavaQName(); - } - catch ( final org.exist.dom.QName.IllegalQNameException ex ) { - throw new ToolsException("Error parsing the literal QName: " + value, ex); - } - } - - public class IterableElement implements Iterable { - - private final Node node; - private String inNamespaceURI = null; - - public IterableElement(Node node) { - this.node = node; - } - - public IterableElement(Node node, String inNamespaceURI) { - this.node = node; - this.inNamespaceURI = inNamespaceURI; - } - - @Override - public Iterator iterator() { - return new ElementIterator(node, inNamespaceURI); - } - - } - - public class ElementIterator implements Iterator { - - private final Node parent; - private final String inNamespaceURI; - - private List elements = null; - private int position = 0; - - public ElementIterator(Node parent, String inNamespaceURI) { - this.parent = parent; - this.inNamespaceURI = inNamespaceURI; - } - - @Override - public boolean hasNext() { - return(position < getLength()); - } - - @Override - public Element next() { - if(position >= getLength()){ - throw new NoSuchElementException(); - } - - return new EXistElement((NodeValue)getElements().get(position++), context); - } - - @Override - public void remove() { - throw new UnsupportedOperationException("Not supported yet."); - } - - private int getLength() { - return getElements().size(); - } - - /** - * lazy initialised - */ - private List getElements() { - - if(elements == null) { - elements = new ArrayList<>(); - final NodeList children = parent.getChildNodes(); - - for(int i = 0; i < children.getLength(); i++) { - final Node child = children.item(i); - if(child.getNodeType() == Node.ELEMENT_NODE) { - if(inNamespaceURI != null) { - final String ns = child.getNamespaceURI(); - if(ns != null && inNamespaceURI.equals(ns)){ - elements.add((org.w3c.dom.Element)child); - } - } else { - elements.add((org.w3c.dom.Element)child); - } - } - } - } - - return elements; - } - } -} \ No newline at end of file diff --git a/extensions/expath/src/main/java/org/expath/tools/model/exist/EXistSequence.java b/extensions/expath/src/main/java/org/expath/tools/model/exist/EXistSequence.java deleted file mode 100644 index ba47da20139..00000000000 --- a/extensions/expath/src/main/java/org/expath/tools/model/exist/EXistSequence.java +++ /dev/null @@ -1,180 +0,0 @@ -/* - * eXist-db Open Source Native XML Database - * Copyright (C) 2001 The eXist-db Authors - * - * info@exist-db.org - * http://www.exist-db.org - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 2.1 of the License, or (at your option) any later version. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public - * License along with this library; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - */ -package org.expath.tools.model.exist; - -import java.io.IOException; -import java.io.OutputStream; -import java.io.OutputStreamWriter; -import java.io.Writer; -import java.nio.charset.StandardCharsets; -import java.util.Properties; -import javax.xml.XMLConstants; -import javax.xml.namespace.QName; -import javax.xml.transform.OutputKeys; - -import org.apache.commons.io.output.CloseShieldOutputStream; -import org.exist.storage.serializers.Serializer; -import org.exist.util.serializer.XQuerySerializer; -import org.exist.xquery.XPathException; -import org.exist.xquery.XQueryContext; -import org.exist.xquery.value.Item; -import org.exist.xquery.value.SequenceIterator; -import org.expath.tools.ToolsException; -import org.expath.tools.model.Sequence; -import org.expath.tools.serial.SerialParameters; -import org.xml.sax.SAXException; - -/** - * @author Adam Retter - */ -public class EXistSequence implements Sequence { - - private final org.exist.xquery.value.Sequence sequence; - private SequenceIterator sequenceIterator = SequenceIterator.EMPTY_ITERATOR; - private final XQueryContext context; - - public EXistSequence(final org.exist.xquery.value.Sequence sequence, final XQueryContext context) throws XPathException { - this.sequence = sequence; - if(sequence != null) { - this.sequenceIterator = sequence.iterate(); - } - this.context = context; - } - - @Override - public boolean isEmpty() throws ToolsException { - return sequence.isEmpty(); - } - - @Override - public Sequence next() throws ToolsException { - try { - final Item item = sequenceIterator.nextItem(); - final org.exist.xquery.value.Sequence singleton = (org.exist.xquery.value.Sequence) item; - return new EXistSequence(singleton, context); - } catch (final XPathException xpe) { - throw new ToolsException(xpe.getMessage(), xpe); - } - } - - @Override - public void serialize(final OutputStream out, final SerialParameters params) throws ToolsException { - final Properties props = params == null ? null : makeOutputProperties(params); - props.setProperty(Serializer.GENERATE_DOC_EVENTS, "false"); - - final String encoding = props.getProperty(OutputKeys.ENCODING, StandardCharsets.UTF_8.name()); - try(final Writer writer = new OutputStreamWriter(CloseShieldOutputStream.wrap(out), encoding)) { - final XQuerySerializer xqSerializer = new XQuerySerializer(context.getBroker(), props, writer); - xqSerializer.serialize(sequence); - } catch(final SAXException | IOException | XPathException e) { - throw new ToolsException("A problem occurred while serializing the node set: " + e.getMessage(), e); - } - } - - /** - * Borrowed from {@link org.expath.tools.saxon.model.SaxonSequence} - */ - private Properties makeOutputProperties(final SerialParameters params) throws ToolsException - { - final Properties props = new Properties(); - - setOutputKey(props, OutputKeys.METHOD, params.getMethod()); - setOutputKey(props, OutputKeys.MEDIA_TYPE, params.getMediaType()); - setOutputKey(props, OutputKeys.ENCODING, params.getEncoding()); - setOutputKey(props, OutputKeys.CDATA_SECTION_ELEMENTS, params.getCdataSectionElements()); - setOutputKey(props, OutputKeys.DOCTYPE_PUBLIC, params.getDoctypePublic()); - setOutputKey(props, OutputKeys.DOCTYPE_SYSTEM, params.getDoctypeSystem()); - setOutputKey(props, OutputKeys.INDENT, params.getIndent()); - setOutputKey(props, OutputKeys.OMIT_XML_DECLARATION, params.getOmitXmlDeclaration()); - setOutputKey(props, OutputKeys.STANDALONE, params.getStandalone()); - setOutputKey(props, OutputKeys.VERSION, params.getVersion()); - - return props; - } - - private void setOutputKey(Properties props, String name, String value) - throws ToolsException - { - if ( value != null ) { - props.setProperty(name, value); - } - } - - private void setOutputKey(Properties props, String name, Boolean value) - throws ToolsException - { - if ( value != null ) { - props.setProperty(name, value ? "yes" : "no"); - } - } - - private void setOutputKey(Properties props, String name, SerialParameters.Standalone value) - throws ToolsException - { - if ( value != null ) { - switch ( value ) { - case YES: - props.setProperty(name, "yes"); - break; - case NO: - props.setProperty(name, "no"); - break; - case OMIT: - props.setProperty(name, "omit"); - break; - default: - throw new ToolsException("Invalid Standalone value: " + value); - } - } - } - - private void setOutputKey(Properties props, String name, QName value) - throws ToolsException - { - if ( value != null ) { - if ( value.getNamespaceURI() != null && !value.getNamespaceURI().equals(XMLConstants.NULL_NS_URI) ) { - throw new ToolsException( - "A QName with a non-null namespace not supported as a serialization param: {" - + value.getNamespaceURI() + "}" + value.getLocalPart()); - } - props.setProperty(name, value.getLocalPart()); - } - } - - private void setOutputKey(Properties props, String name, Iterable value) - throws ToolsException - { - if ( value != null ) { - StringBuilder buf = new StringBuilder(); - for ( QName qname : value ) { - if ( qname.getNamespaceURI() != null ) { - throw new ToolsException( - "A QName with a non-null namespace not supported as a serialization param: {" - + qname.getNamespaceURI() + "}" + qname.getLocalPart()); - } - buf.append(qname.getLocalPart()); - buf.append(" "); - } - props.setProperty(name, buf.toString()); - } - } -} diff --git a/extensions/expath/src/test/java/org/expath/exist/HttpClientTest.java b/extensions/expath/src/test/java/org/expath/exist/HttpClientTest.java deleted file mode 100644 index 7eecd44bad6..00000000000 --- a/extensions/expath/src/test/java/org/expath/exist/HttpClientTest.java +++ /dev/null @@ -1,93 +0,0 @@ -/* - * eXist-db Open Source Native XML Database - * Copyright (C) 2001 The eXist-db Authors - * - * info@exist-db.org - * http://www.exist-db.org - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 2.1 of the License, or (at your option) any later version. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public - * License along with this library; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - */ -package org.expath.exist; - -import org.exist.EXistException; -import org.exist.security.PermissionDeniedException; -import org.exist.storage.BrokerPool; -import org.exist.storage.DBBroker; -import org.exist.test.ExistEmbeddedServer; -import org.exist.xquery.XPathException; -import org.exist.xquery.XQuery; -import org.exist.xquery.value.Sequence; -import org.junit.ClassRule; -import org.junit.Test; - -import java.io.IOException; -import java.net.*; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.fail; -import static org.junit.Assume.assumeTrue; - -public class HttpClientTest { - - @ClassRule - public static ExistEmbeddedServer existEmbeddedServer = new ExistEmbeddedServer(true, true); - - @Test - public void readResponse() throws XPathException, PermissionDeniedException, EXistException { - assumeTrue("No Internet access: skipping 'readResponse' test", hasInternetAccess()); - - final String query = - """ - xquery version "3.1"; - import module namespace http="http://expath.org/ns/http-client"; - let $url := "http://www.exist-db.org/exist/apps/homepage/resources/img/existdb.gif" - let $request := - - let $response := http:send-request($request) - let $str := util:binary-to-string($response[2]) - return - $str"""; - - final Sequence result = executeQuery(query); - assertEquals(1, result.getItemCount()); - } - - private Sequence executeQuery(final String query) throws EXistException, PermissionDeniedException, XPathException { - final BrokerPool brokerPool = existEmbeddedServer.getBrokerPool(); - final XQuery xquery = brokerPool.getXQueryService(); - try (final DBBroker broker = brokerPool.getBroker()) { - return xquery.execute(broker, query, null); - } - } - - private boolean hasInternetAccess() { - boolean hasInternetAccess = false; - - //Checking that we have an Internet Access - try { - final URL url = URI.create("http://www.exist-db.org").toURL(); - final URLConnection con = url.openConnection(); - if (con instanceof HttpURLConnection httpConnection) { - hasInternetAccess = (httpConnection.getResponseCode() == HttpURLConnection.HTTP_OK); - } - } catch(final MalformedURLException e) { - fail(e.getMessage()); - } catch (final IOException e) { - //Ignore - } - - return hasInternetAccess; - } -} diff --git a/extensions/expath/src/test/resources-filtered/conf.xml b/extensions/expath/src/test/resources-filtered/conf.xml index a0e02a2c06d..ea96d1f8d0f 100644 --- a/extensions/expath/src/test/resources-filtered/conf.xml +++ b/extensions/expath/src/test/resources-filtered/conf.xml @@ -744,7 +744,7 @@ - + diff --git a/extensions/exquery/restxq/pom.xml b/extensions/exquery/restxq/pom.xml index f6d44f036f2..19c9066f8ff 100644 --- a/extensions/exquery/restxq/pom.xml +++ b/extensions/exquery/restxq/pom.xml @@ -182,7 +182,7 @@ org.exist-db - exist-expath + exist-http-client ${project.version} test @@ -244,7 +244,7 @@ true - ${project.groupId}:exist-expath:jar:${project.version} + ${project.groupId}:exist-http-client:jar:${project.version} org.eclipse.jetty:jetty-deploy:jar:${jetty.version} org.eclipse.jetty:jetty-jmx:jar:${jetty.version} diff --git a/extensions/exquery/restxq/src/test/resources-filtered/conf.xml b/extensions/exquery/restxq/src/test/resources-filtered/conf.xml index 697afdbf11b..e430032ec7d 100644 --- a/extensions/exquery/restxq/src/test/resources-filtered/conf.xml +++ b/extensions/exquery/restxq/src/test/resources-filtered/conf.xml @@ -724,7 +724,7 @@ - + From e95b7e6530030ad61a1945cb8c65411928c5b7b0 Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Mon, 15 Jun 2026 00:54:35 -0400 Subject: [PATCH 5/8] [refactor] Drop Apache HttpClient and the Milton WebDAV stack from the build With the EXPath HTTP client now native (java.net.http + Methanol), the integration tests on the JDK client, debuggee migrated, and the milton-client WebDAV tests replaced, nothing in the code base uses Apache HttpClient. Remove it from the build: - exist-parent BOM: drop the httpcore/httpclient/httpmime/fluent-hc (Apache HC4) managed dependencies and the apache.httpcomponents.* version properties; drop the now-unused milton-api/milton-client/milton-servlet managed dependencies and their version properties (Methanol stays). - exist-installer: drop the removed version properties from the IzPack includeProperties. `git grep org.apache.httpcomponents` over the poms and `import org.apache.hc` / `org.apache.http` over the sources now return nothing. Full reactor build is green. Co-Authored-By: Claude Opus 4.8 (1M context) --- exist-installer/pom.xml | 2 +- exist-parent/pom.xml | 43 ----------------------------------------- 2 files changed, 1 insertion(+), 44 deletions(-) diff --git a/exist-installer/pom.xml b/exist-installer/pom.xml index 2a45179f790..75c1a9b5143 100644 --- a/exist-installer/pom.xml +++ b/exist-installer/pom.xml @@ -124,7 +124,7 @@ ${izpack.resources.target}/install.xml ${project.basedir}/../exist-distribution/target/exist-distribution-dir - apache.httpcomponents.core.version,apache.httpcomponents.version,apache.xmlrpc.version,appassembler.version,aspectj.version,git.commit.id,git.commit.id.abbrev,git.closest.tag.name,git.closest.tag.commit.count,git.commit.time,git.commit.id.describe,contact.email,exquery.distribution.version,icu.version,jetty.version,izpack.installation.info.appversion,izpack.installation.info.author.email,izpack.installation.info.author.name,izpack.installation.info.url,izpack.resources.src,izpack.resources.target,izpack.version,jansi.version,jaxb.api.version,jaxb.impl.version,log4j.version,lucene.version,project.build.sourceEncoding,project.copyright.name,saxon.version,xmlresolver.version,maven.compiler.release + apache.xmlrpc.version,appassembler.version,aspectj.version,git.commit.id,git.commit.id.abbrev,git.closest.tag.name,git.closest.tag.commit.count,git.commit.time,git.commit.id.describe,contact.email,exquery.distribution.version,icu.version,jetty.version,izpack.installation.info.appversion,izpack.installation.info.author.email,izpack.installation.info.author.name,izpack.installation.info.url,izpack.resources.src,izpack.resources.target,izpack.version,jansi.version,jaxb.api.version,jaxb.impl.version,log4j.version,lucene.version,project.build.sourceEncoding,project.copyright.name,saxon.version,xmlresolver.version,maven.compiler.release true true diff --git a/exist-parent/pom.xml b/exist-parent/pom.xml index c6fa0df572f..3441fedde8a 100644 --- a/exist-parent/pom.xml +++ b/exist-parent/pom.xml @@ -120,8 +120,6 @@ info@exist-db.org 1.10.17 - 4.5.14 - 4.4.16 5.0.0 1.8.3 2.1.0 @@ -137,8 +135,6 @@ 12.1.10 2.26.0 10.4.0 - 1.8.1.3 - 1.8.1.3-jakarta-ee10 2.1.3 12.5 6.0.23 @@ -328,27 +324,6 @@ ${methanol.version} - - org.apache.httpcomponents - httpcore - ${apache.httpcomponents.core.version} - - - org.apache.httpcomponents - httpclient - ${apache.httpcomponents.version} - - - org.apache.httpcomponents - httpmime - ${apache.httpcomponents.version} - - - org.apache.httpcomponents - fluent-hc - ${apache.httpcomponents.version} - - org.eclipse.jetty @@ -516,24 +491,6 @@ ${exquery.distribution.version} - - org.exist-db.thirdparty.com.ettrema - milton-api - ${milton.version} - - - - org.exist-db.thirdparty.com.ettrema - milton-client - ${milton.version} - - - - com.evolvedbinary.thirdparty.com.ettrema - milton-servlet - ${milton.servlet.version} - - org.exist-db.thirdparty.org.apache.jackrabbit From b98465680cc8b47601faca0cc52784f336a7a369 Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Mon, 15 Jun 2026 08:28:54 -0400 Subject: [PATCH 6/8] [test] Use java.net.HttpURLConnection status constants in migrated tests Addresses review feedback: replace the remaining magic HTTP status integers in the migrated integration tests with java.net.HttpURLConnection.HTTP_* constants, matching the rest of the migrated suite (most of which already uses them; removing Apache HttpClient's HttpStatus.SC_* had left a few call sites on raw ints). - ControllerTest: 200 -> HTTP_OK, 201 -> HTTP_CREATED - JmxRemoteTest: 200 -> HTTP_OK - WebDavRoundTripTest: 201 -> HTTP_CREATED, 200 -> HTTP_OK Scoped to the tests this PR already touches; a wider sweep of status-code literals across the rest of the codebase is left as a separate change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/org/exist/http/urlrewrite/ControllerTest.java | 9 +++++---- .../test/java/org/exist/management/JmxRemoteTest.java | 3 ++- .../test/java/org/exist/webdav/WebDavRoundTripTest.java | 5 +++-- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/exist-core/src/test/java/org/exist/http/urlrewrite/ControllerTest.java b/exist-core/src/test/java/org/exist/http/urlrewrite/ControllerTest.java index 2ba0d6565a9..c625cd50124 100644 --- a/exist-core/src/test/java/org/exist/http/urlrewrite/ControllerTest.java +++ b/exist-core/src/test/java/org/exist/http/urlrewrite/ControllerTest.java @@ -30,6 +30,7 @@ import org.junit.Test; import java.io.IOException; +import java.net.HttpURLConnection; import java.net.URI; import java.net.http.HttpRequest; @@ -59,7 +60,7 @@ public void findsLegacyController() throws IOException { // make a request and see if the legacy controller responds final Tuple2 responseCodeAndBody = get(testCollectionName, TEST_DOCUMENT_NAME); - assertEquals(200, (int)responseCodeAndBody._1); + assertEquals(HttpURLConnection.HTTP_OK, (int)responseCodeAndBody._1); assertEquals(LEGACY_CONTROLLER_XQUERY, responseCodeAndBody._2); } @@ -72,7 +73,7 @@ public void findsController() throws IOException { // make a request and see if the controller responds final Tuple2 responseCodeAndBody = get(testCollectionName, TEST_DOCUMENT_NAME); - assertEquals(200, (int)responseCodeAndBody._1); + assertEquals(HttpURLConnection.HTTP_OK, (int)responseCodeAndBody._1); assertEquals(CONTROLLER_XQUERY, responseCodeAndBody._2); } @@ -86,7 +87,7 @@ public void prefersNonLegacyController() throws IOException { // make a request and see if the (non-legacy) controller responds final Tuple2 responseCodeAndBody = get(testCollectionName, TEST_DOCUMENT_NAME); - assertEquals(200, (int)responseCodeAndBody._1); + assertEquals(HttpURLConnection.HTTP_OK, (int)responseCodeAndBody._1); assertEquals(CONTROLLER_XQUERY, responseCodeAndBody._2); } @@ -98,7 +99,7 @@ private void store(final String testCollectionName, final String documentMediaTy .PUT(HttpRequest.BodyPublishers.ofString(documentContent)) .build(); int statusCode = withHttpClient(client -> executeForStatus(client, request)); - assertEquals(201, statusCode); + assertEquals(HttpURLConnection.HTTP_CREATED, statusCode); } private Tuple2 get(final String testCollectionName, final String documentName) throws IOException { diff --git a/exist-core/src/test/java/org/exist/management/JmxRemoteTest.java b/exist-core/src/test/java/org/exist/management/JmxRemoteTest.java index 76511dcacab..7f0dd5c338d 100644 --- a/exist-core/src/test/java/org/exist/management/JmxRemoteTest.java +++ b/exist-core/src/test/java/org/exist/management/JmxRemoteTest.java @@ -29,6 +29,7 @@ import org.junit.Test; import java.io.IOException; +import java.net.HttpURLConnection; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; @@ -142,7 +143,7 @@ public void checkBasicRequest() throws IOException { return Tuple(response.statusCode(), response.headers().firstValue("Content-Type").orElse(null)); }); - assertEquals(Tuple(200, "application/xml"), codeAndMediaType); + assertEquals(Tuple(HttpURLConnection.HTTP_OK, "application/xml"), codeAndMediaType); } private static HttpResponse send(final HttpClient client, final HttpRequest request, diff --git a/extensions/webdav/src/test/java/org/exist/webdav/WebDavRoundTripTest.java b/extensions/webdav/src/test/java/org/exist/webdav/WebDavRoundTripTest.java index 1170dcd5630..60a1cedd2ce 100644 --- a/extensions/webdav/src/test/java/org/exist/webdav/WebDavRoundTripTest.java +++ b/extensions/webdav/src/test/java/org/exist/webdav/WebDavRoundTripTest.java @@ -28,6 +28,7 @@ import org.junit.ClassRule; import org.junit.Test; +import java.net.HttpURLConnection; import java.net.http.HttpResponse; import java.util.ArrayList; import java.util.List; @@ -121,10 +122,10 @@ private String roundTrip(final String docName, final String content, final Strin EXIST_WEB_SERVER.getPort(), TestUtils.ADMIN_DB_USER, TestUtils.ADMIN_DB_PWD); final int putStatus = webDav.putDocument(docName, content, expectedMediaType); - assertEquals("PUT " + docName + " failed with status " + putStatus, 201, putStatus); + assertEquals("PUT " + docName + " failed with status " + putStatus, HttpURLConnection.HTTP_CREATED, putStatus); final HttpResponse getResponse = webDav.getDocument(docName); - assertEquals("GET " + docName + " failed", 200, getResponse.statusCode()); + assertEquals("GET " + docName + " failed", HttpURLConnection.HTTP_OK, getResponse.statusCode()); final String contentType = getResponse.headers().firstValue("Content-Type").orElse(""); assertTrue("Unexpected Content-Type: " + contentType, contentType.startsWith(expectedMediaType)); return getResponse.body(); From 03cc89c24e0d7278f44a93662e38de3359c72e58 Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Mon, 15 Jun 2026 09:19:34 -0400 Subject: [PATCH 7/8] [feature] EXPath HTTP client: Basic and Digest challenge-response authentication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The EXPath HTTP Client spec (3.3) defines @send-authorization=false (the default) as: send the request without credentials, and only if the server answers with a 401 challenge, resend with the credentials. The native module previously implemented preemptive auth only — it required @send-authorization='true' and otherwise ignored the credentials — so the spec's default auth mode did not work. That is a regression versus both eXist's previous EXPath client and the BaseX reference implementation, which both perform challenge-response. - RequestBuilder: withhold credentials unless @send-authorization is true; add challengeResponse() to answer a 401 WWW-Authenticate challenge, computing a Basic or RFC 2617 Digest (MD5, qop=auth) Authorization header from the request credentials. build() now accepts an explicit Authorization header for the re-send. The body and auth assembly is split into helpers to keep NPath in check. - SendRequestFunction: on a 401 with credentials present and not yet sent, re-send once with the computed Authorization header. - Tests: add basicChallengeResponse and digestChallengeResponse — the test server issues the challenge and validates the Digest response hash end-to-end (72 module tests pass). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../modules/httpclient/RequestBuilder.java | 278 ++++++++++++++---- .../httpclient/SendRequestFunction.java | 59 ++-- .../httpclient/SendRequestFunctionTest.java | 122 +++++++- 3 files changed, 371 insertions(+), 88 deletions(-) diff --git a/extensions/modules/http-client/src/main/java/org/exist/xquery/modules/httpclient/RequestBuilder.java b/extensions/modules/http-client/src/main/java/org/exist/xquery/modules/httpclient/RequestBuilder.java index 1200a1c0b35..e851521d936 100644 --- a/extensions/modules/http-client/src/main/java/org/exist/xquery/modules/httpclient/RequestBuilder.java +++ b/extensions/modules/http-client/src/main/java/org/exist/xquery/modules/httpclient/RequestBuilder.java @@ -37,9 +37,12 @@ import java.net.http.HttpRequest; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.time.Duration; import java.util.ArrayList; import java.util.Base64; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Properties; @@ -192,92 +195,265 @@ private static boolean parseBooleanAttr(final Element elem, final String name, f } /** - * Builds the {@link java.net.http.HttpRequest} from the parsed parameters. + * Builds the {@link java.net.http.HttpRequest} from the parsed parameters, sending credentials + * preemptively only when {@code @send-authorization} is true. */ public HttpRequest build() throws XPathException { + return build(null); + } + + /** + * Builds the {@link java.net.http.HttpRequest} from the parsed parameters. + * + * @param authorizationHeader when non-null, attach this exact {@code Authorization} header + * value — used to answer a 401 challenge with a Basic or Digest response computed by + * {@link #challengeResponse(String)} (see {@link #shouldAttemptChallenge()}). + */ + public HttpRequest build(final String authorizationHeader) throws XPathException { final URI uri; try { uri = URI.create(href); - } catch (IllegalArgumentException e) { + } catch (final IllegalArgumentException e) { throw new XPathException((org.exist.xquery.Expression) null, HttpClientModule.HC005, "Invalid URI: " + href + ". " + e.getMessage()); } final HttpRequest.Builder builder = HttpRequest.newBuilder().uri(uri); - - // Timeout if (timeout > 0) { builder.timeout(Duration.ofSeconds(timeout)); } - // Headers (a Content-Type set here is ignored for multipart, where the publisher supplies - // a Content-Type carrying the generated boundary) final boolean isMultipart = !multipartBodies.isEmpty(); + applyHeaders(builder, isMultipart); + applyAuthorization(builder, authorizationHeader); + applyBody(builder, method.toUpperCase(), isMultipart); + return builder.build(); + } + + private void applyHeaders(final HttpRequest.Builder builder, final boolean isMultipart) { + // a Content-Type set here is ignored for multipart, where the publisher supplies a + // Content-Type carrying the generated boundary for (final String[] header : headers) { if (isMultipart && "Content-Type".equalsIgnoreCase(header[0])) { continue; } builder.header(header[0], header[1]); } + } - // Authentication - if (username != null && sendAuthorization && "basic".equalsIgnoreCase(authMethod)) { - final String encoded = Base64.getEncoder().encodeToString( - (username + ":" + (password != null ? password : "")) - .getBytes(StandardCharsets.UTF_8)); - builder.header("Authorization", "Basic " + encoded); + /** + * Per the EXPath spec, credentials are sent preemptively only when {@code @send-authorization} + * is true; otherwise they are withheld until the server issues a 401 challenge, at which point + * the request is re-sent with the {@code authorizationHeader} that {@link #challengeResponse} + * computed for the offered scheme. + */ + private void applyAuthorization(final HttpRequest.Builder builder, final String authorizationHeader) { + final String authorization; + if (authorizationHeader != null) { + authorization = authorizationHeader; + } else if (sendAuthorization && username != null && "basic".equalsIgnoreCase(authMethod)) { + authorization = "Basic " + base64Credentials(); + } else { + authorization = null; } + if (authorization != null) { + builder.header("Authorization", authorization); + } + } - final String upperMethod = method.toUpperCase(); - - // Multipart request body, built with Methanol's MultipartBodyPublisher (the JDK client has - // no multipart support). Each http:body part becomes a part with its own Content-Type. + private void applyBody(final HttpRequest.Builder builder, final String upperMethod, final boolean isMultipart) { if (isMultipart) { - final MultipartBodyPublisher.Builder multipart = MultipartBodyPublisher.newBuilder(); - if (multipartMediaType != null && !multipartMediaType.isEmpty()) { - multipart.mediaType(MediaType.parse(multipartMediaType)); - } - for (final BodyPart part : multipartBodies) { - final HttpRequest.BodyPublisher partBody = - HttpRequest.BodyPublishers.ofString(part.content(), StandardCharsets.UTF_8); - final Map> partHeaders = part.mediaType() != null && !part.mediaType().isEmpty() - ? Map.of("Content-Type", List.of(part.mediaType())) - : Map.of(); - multipart.part(MultipartBodyPublisher.Part.create( - HttpHeaders.of(partHeaders, (k, v) -> true), partBody)); - } - final MultipartBodyPublisher publisher = multipart.build(); - builder.header("Content-Type", publisher.mediaType().toString()); - builder.method(upperMethod, publisher); - return builder.build(); - } - - // Single body - if (bodyContent != null && !bodyContent.isEmpty()) { - final Charset charset = bodyMediaType != null - ? Charset.forName(ContentTypeHelper.extractCharset(bodyMediaType)) - : StandardCharsets.UTF_8; - final HttpRequest.BodyPublisher bodyPublisher = - HttpRequest.BodyPublishers.ofString(bodyContent, charset); - - // Set Content-Type if not already set via headers - if (bodyMediaType != null && headers.stream().noneMatch( - h -> "Content-Type".equalsIgnoreCase(h[0]))) { - builder.header("Content-Type", bodyMediaType); - } - - builder.method(upperMethod, bodyPublisher); + applyMultipartBody(builder, upperMethod); + } else if (bodyContent != null && !bodyContent.isEmpty()) { + applySingleBody(builder, upperMethod); } else { builder.method(upperMethod, HttpRequest.BodyPublishers.noBody()); } + } - return builder.build(); + /** + * Multipart request body, built with Methanol's MultipartBodyPublisher (the JDK client has no + * multipart support). Each http:body part becomes a part with its own Content-Type. + */ + private void applyMultipartBody(final HttpRequest.Builder builder, final String upperMethod) { + final MultipartBodyPublisher.Builder multipart = MultipartBodyPublisher.newBuilder(); + if (multipartMediaType != null && !multipartMediaType.isEmpty()) { + multipart.mediaType(MediaType.parse(multipartMediaType)); + } + for (final BodyPart part : multipartBodies) { + final HttpRequest.BodyPublisher partBody = + HttpRequest.BodyPublishers.ofString(part.content(), StandardCharsets.UTF_8); + final Map> partHeaders = part.mediaType() != null && !part.mediaType().isEmpty() + ? Map.of("Content-Type", List.of(part.mediaType())) + : Map.of(); + multipart.part(MultipartBodyPublisher.Part.create( + HttpHeaders.of(partHeaders, (k, v) -> true), partBody)); + } + final MultipartBodyPublisher publisher = multipart.build(); + builder.header("Content-Type", publisher.mediaType().toString()); + builder.method(upperMethod, publisher); + } + + private void applySingleBody(final HttpRequest.Builder builder, final String upperMethod) { + final Charset charset = bodyMediaType != null + ? Charset.forName(ContentTypeHelper.extractCharset(bodyMediaType)) + : StandardCharsets.UTF_8; + final HttpRequest.BodyPublisher bodyPublisher = + HttpRequest.BodyPublishers.ofString(bodyContent, charset); + // Set Content-Type if not already set via headers + if (bodyMediaType != null && headers.stream().noneMatch(h -> "Content-Type".equalsIgnoreCase(h[0]))) { + builder.header("Content-Type", bodyMediaType); + } + builder.method(upperMethod, bodyPublisher); } public boolean isFollowRedirect() { return followRedirect; } + /** + * Whether a 401 response should be answered with credentials (EXPath challenge-response): + * credentials and an auth method are present but were not sent preemptively (because + * {@code @send-authorization} is not true). If they had been sent preemptively, a 401 means the + * credentials are wrong and retrying would not help. + * + * @return true if the request should be re-sent with an {@code Authorization} header on a 401. + */ + public boolean shouldAttemptChallenge() { + return username != null && !sendAuthorization && authMethod != null + && ("basic".equalsIgnoreCase(authMethod) || "digest".equalsIgnoreCase(authMethod)); + } + + /** + * Computes the {@code Authorization} header value answering a server's {@code WWW-Authenticate} + * challenge, for the request's declared {@code @auth-method} (Basic or Digest). Mirrors the + * EXPath reference behavior. + * + * @param wwwAuthenticate the server's {@code WWW-Authenticate} header value (may be null). + * @return the {@code Authorization} header value, or null if the challenge cannot be answered + * (unsupported scheme, missing data, or a scheme mismatch with {@code @auth-method}). + */ + public String challengeResponse(final String wwwAuthenticate) { + if (username == null || authMethod == null) { + return null; + } + if ("basic".equalsIgnoreCase(authMethod)) { + return "Basic " + base64Credentials(); + } + if ("digest".equalsIgnoreCase(authMethod)) { + return digestResponse(wwwAuthenticate); + } + return null; + } + + private String base64Credentials() { + return Base64.getEncoder().encodeToString( + (username + ":" + (password != null ? password : "")).getBytes(StandardCharsets.UTF_8)); + } + + /** + * Builds an RFC 2617 Digest {@code Authorization} header value from the server's challenge. + */ + private String digestResponse(final String wwwAuthenticate) { + if (wwwAuthenticate == null) { + return null; + } + final String[] schemeAndParams = wwwAuthenticate.trim().split("\\s+", 2); + if (schemeAndParams.length < 2 || !"digest".equalsIgnoreCase(schemeAndParams[0])) { + return null; + } + final Map p = parseAuthParams(schemeAndParams[1]); + final String realm = p.get("realm"); + final String nonce = p.get("nonce"); + final String opaque = p.get("opaque"); + if (realm == null || nonce == null) { + return null; + } + final String qop = resolveQop(p.get("qop")); + final String pwd = password != null ? password : ""; + final String digestUri = href; + final String ha1 = md5(username + ":" + realm + ":" + pwd); + final String ha2 = md5(method.toUpperCase() + ":" + digestUri); + + final StringBuilder value = new StringBuilder() + .append("username=\"").append(username).append("\", ") + .append("realm=\"").append(realm).append("\", ") + .append("nonce=\"").append(nonce).append("\", ") + .append("uri=\"").append(digestUri).append('"'); + final String response; + if (qop != null) { + final String nc = "00000001"; + final String cnonce = md5(Long.toString(System.nanoTime())); + response = md5(ha1 + ":" + nonce + ":" + nc + ":" + cnonce + ":" + qop + ":" + ha2); + value.append(", qop=").append(qop) + .append(", nc=").append(nc) + .append(", cnonce=\"").append(cnonce).append('"'); + } else { + response = md5(ha1 + ":" + nonce + ":" + ha2); + } + value.append(", response=\"").append(response).append("\", algorithm=MD5"); + if (opaque != null) { + value.append(", opaque=\"").append(opaque).append('"'); + } + return "Digest " + value; + } + + /** + * Selects the quality-of-protection to use from a server's offered {@code qop} (which may be a + * comma-separated list such as {@code "auth,auth-int"}); we implement {@code auth}. Returns null + * for the legacy (no-qop) digest variant. + */ + private static String resolveQop(final String offeredQop) { + if (offeredQop != null) { + for (final String q : offeredQop.split(",")) { + if ("auth".equals(q.trim())) { + return "auth"; + } + } + } + return null; + } + + /** + * Parses comma-separated {@code key=value} (optionally quoted) auth parameters into a map keyed + * by lower-case name. Adequate for the realm/nonce/qop/opaque tokens used here. + */ + private static Map parseAuthParams(final String params) { + final Map map = new HashMap<>(); + for (final String part : params.split(",")) { + final int eq = part.indexOf('='); + if (eq > 0) { + final String key = part.substring(0, eq).trim().toLowerCase(); + String val = part.substring(eq + 1).trim(); + if (val.length() >= 2 && val.startsWith("\"") && val.endsWith("\"")) { + val = val.substring(1, val.length() - 1); + } + if (!key.isEmpty()) { + map.put(key, val); + } + } + } + return map; + } + + // Digest authentication is defined over MD5 (RFC 2617); this is protocol-mandated, not a + // security choice on our part. + @SuppressWarnings("java:S4790") + private static String md5(final String s) { + try { + final MessageDigest md = MessageDigest.getInstance("MD5"); + final byte[] digest = md.digest(s.getBytes(StandardCharsets.UTF_8)); + final StringBuilder hex = new StringBuilder(digest.length * 2); + for (final byte b : digest) { + hex.append(Character.forDigit((b >> 4) & 0xF, 16)).append(Character.forDigit(b & 0xF, 16)); + } + return hex.toString(); + } catch (final NoSuchAlgorithmException e) { + // MD5 is required to be present on every Java platform + throw new IllegalStateException("MD5 not available", e); + } + } + public boolean isStatusOnly() { return statusOnly; } diff --git a/extensions/modules/http-client/src/main/java/org/exist/xquery/modules/httpclient/SendRequestFunction.java b/extensions/modules/http-client/src/main/java/org/exist/xquery/modules/httpclient/SendRequestFunction.java index 7e692a226ee..2babf768c6f 100644 --- a/extensions/modules/http-client/src/main/java/org/exist/xquery/modules/httpclient/SendRequestFunction.java +++ b/extensions/modules/http-client/src/main/java/org/exist/xquery/modules/httpclient/SendRequestFunction.java @@ -92,30 +92,10 @@ public Sequence eval(final Sequence[] args, final Sequence contextSequence) thro final RequestBuilder reqBuilder = new RequestBuilder(); reqBuilder.parse(requestNode, hrefParam, bodiesParam); - // Build the HTTP client. Methanol augments java.net.http.HttpClient: autoAcceptEncoding - // advertises Accept-Encoding and transparently decodes gzip/deflate responses, and readTimeout - // gives a per-read (inactivity) timeout that the bare JDK client lacks. - final Methanol.Builder clientBuilder = Methanol.newBuilder() - .autoAcceptEncoding(true); - if (reqBuilder.isFollowRedirect()) { - clientBuilder.followRedirects(HttpClient.Redirect.NORMAL); - } else { - clientBuilder.followRedirects(HttpClient.Redirect.NEVER); - } - if (reqBuilder.getTimeout() > 0) { - final Duration timeout = Duration.ofSeconds(reqBuilder.getTimeout()); - clientBuilder.connectTimeout(timeout); - clientBuilder.readTimeout(timeout); - } - - // Build HttpRequest final HttpRequest httpRequest = reqBuilder.build(); - // Send request - try (final HttpClient client = clientBuilder.build()) { - final HttpResponse response = client.send(httpRequest, - HttpResponse.BodyHandlers.ofByteArray()); - + try (final HttpClient client = buildClient(reqBuilder)) { + final HttpResponse response = send(client, reqBuilder, httpRequest); return ResponseHandler.buildResult(response, context, this, reqBuilder.isStatusOnly(), reqBuilder.getOverrideMediaType()); @@ -134,4 +114,39 @@ public Sequence eval(final Sequence[] args, final Sequence contextSequence) thro "Request interrupted: " + e.getMessage()); } } + + /** + * Builds the HTTP client. Methanol augments java.net.http.HttpClient: autoAcceptEncoding + * advertises Accept-Encoding and transparently decodes gzip/deflate responses, and readTimeout + * gives a per-read (inactivity) timeout that the bare JDK client lacks. + */ + private static HttpClient buildClient(final RequestBuilder reqBuilder) { + final Methanol.Builder clientBuilder = Methanol.newBuilder() + .autoAcceptEncoding(true) + .followRedirects(reqBuilder.isFollowRedirect() + ? HttpClient.Redirect.NORMAL : HttpClient.Redirect.NEVER); + if (reqBuilder.getTimeout() > 0) { + final Duration timeout = Duration.ofSeconds(reqBuilder.getTimeout()); + clientBuilder.connectTimeout(timeout).readTimeout(timeout); + } + return clientBuilder.build(); + } + + /** + * Sends the request, and — per the EXPath challenge-response model — if credentials were not + * sent preemptively and the server answers 401 with a WWW-Authenticate challenge, re-sends once + * with the computed Authorization header (Basic or Digest). + */ + private static HttpResponse send(final HttpClient client, final RequestBuilder reqBuilder, + final HttpRequest httpRequest) throws IOException, InterruptedException, XPathException { + final HttpResponse response = client.send(httpRequest, HttpResponse.BodyHandlers.ofByteArray()); + if (response.statusCode() == 401 && reqBuilder.shouldAttemptChallenge()) { + final String challenge = response.headers().firstValue("WWW-Authenticate").orElse(null); + final String authorization = reqBuilder.challengeResponse(challenge); + if (authorization != null) { + return client.send(reqBuilder.build(authorization), HttpResponse.BodyHandlers.ofByteArray()); + } + } + return response; + } } diff --git a/extensions/modules/http-client/src/test/java/org/exist/xquery/modules/httpclient/SendRequestFunctionTest.java b/extensions/modules/http-client/src/test/java/org/exist/xquery/modules/httpclient/SendRequestFunctionTest.java index e48b0d46bfd..db4888becec 100644 --- a/extensions/modules/http-client/src/test/java/org/exist/xquery/modules/httpclient/SendRequestFunctionTest.java +++ b/extensions/modules/http-client/src/test/java/org/exist/xquery/modules/httpclient/SendRequestFunctionTest.java @@ -36,7 +36,11 @@ import java.io.OutputStream; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.Base64; +import java.util.HashMap; +import java.util.Map; import static org.junit.Assert.*; @@ -185,21 +189,7 @@ public static void startHttpServer() throws IOException { sendResponse(exchange, 500, "text/plain", "Internal Server Error"); }); - // Basic auth endpoint - httpServer.createContext("/auth/basic", exchange -> { - String auth = exchange.getRequestHeaders().getFirst("Authorization"); - if (auth != null && auth.startsWith("Basic ")) { - String decoded = new String(Base64.getDecoder().decode(auth.substring(6)), - StandardCharsets.UTF_8); - if ("testuser:testpass".equals(decoded)) { - sendResponse(exchange, 200, "application/json", - "{\"authenticated\":true,\"user\":\"testuser\"}"); - return; - } - } - exchange.getResponseHeaders().set("WWW-Authenticate", "Basic realm=\"test\""); - sendResponse(exchange, 401, "text/plain", "Unauthorized"); - }); + registerAuthEndpoints(); // Multi-header endpoint — returns multiple Set-Cookie headers httpServer.createContext("/multi-header", exchange -> { @@ -324,6 +314,78 @@ private static String escapeJson(String s) { .replace("\n", "\\n").replace("\r", "\\r"); } + /** Registers the Basic and Digest authentication test endpoints. */ + private static void registerAuthEndpoints() { + // Basic auth endpoint: challenges with Basic, then accepts the credentials + httpServer.createContext("/auth/basic", exchange -> { + final String auth = exchange.getRequestHeaders().getFirst("Authorization"); + if (auth != null && auth.startsWith("Basic ")) { + final String decoded = new String(Base64.getDecoder().decode(auth.substring(6)), + StandardCharsets.UTF_8); + if ("testuser:testpass".equals(decoded)) { + sendResponse(exchange, 200, "application/json", + "{\"authenticated\":true,\"user\":\"testuser\"}"); + return; + } + } + exchange.getResponseHeaders().set("WWW-Authenticate", "Basic realm=\"test\""); + sendResponse(exchange, 401, "text/plain", "Unauthorized"); + }); + + // Digest auth endpoint (RFC 2617): challenges with Digest, then validates the response hash + httpServer.createContext("/auth/digest", exchange -> { + final String realm = "testrealm"; + final String nonce = "abc123nonce"; + final String qop = "auth"; + final String auth = exchange.getRequestHeaders().getFirst("Authorization"); + if (auth != null && auth.startsWith("Digest ")) { + final Map p = parseDigestParams(auth.substring("Digest ".length())); + final String ha1 = md5Hex("testuser:" + realm + ":testpass"); + final String ha2 = md5Hex(exchange.getRequestMethod() + ":" + p.getOrDefault("uri", "")); + final String expected = md5Hex(ha1 + ":" + nonce + ":" + p.getOrDefault("nc", "") + + ":" + p.getOrDefault("cnonce", "") + ":" + qop + ":" + ha2); + if ("testuser".equals(p.get("username")) && expected.equals(p.get("response"))) { + sendResponse(exchange, 200, "application/json", + "{\"authenticated\":true,\"user\":\"testuser\"}"); + return; + } + } + exchange.getResponseHeaders().set("WWW-Authenticate", + "Digest realm=\"" + realm + "\", nonce=\"" + nonce + "\", qop=\"" + qop + "\""); + sendResponse(exchange, 401, "text/plain", "Unauthorized"); + }); + } + + /** Parses comma-separated, optionally-quoted Digest auth parameters into a lower-cased map. */ + private static Map parseDigestParams(final String params) { + final Map map = new HashMap<>(); + for (final String part : params.split(",")) { + final int eq = part.indexOf('='); + if (eq > 0) { + final String key = part.substring(0, eq).trim().toLowerCase(); + String val = part.substring(eq + 1).trim(); + if (val.length() >= 2 && val.startsWith("\"") && val.endsWith("\"")) { + val = val.substring(1, val.length() - 1); + } + map.put(key, val); + } + } + return map; + } + + private static String md5Hex(final String s) { + try { + final byte[] digest = MessageDigest.getInstance("MD5").digest(s.getBytes(StandardCharsets.UTF_8)); + final StringBuilder hex = new StringBuilder(digest.length * 2); + for (final byte b : digest) { + hex.append(Character.forDigit((b >> 4) & 0xF, 16)).append(Character.forDigit(b & 0xF, 16)); + } + return hex.toString(); + } catch (final NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + private String baseUrl() { return "http://localhost:" + port; } @@ -1047,6 +1109,36 @@ public void noAuthReturns401() throws XMLDBException { "401", result.getResource(0).getContent().toString()); } + @Test + public void basicChallengeResponse() throws XMLDBException { + // No send-authorization: the client must answer the server's 401 Basic challenge by + // re-sending the request with credentials (EXPath default behavior). + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return parse-json($response[2])?authenticated"); + assertEquals("Basic challenge-response should authenticate without send-authorization", + "true", result.getResource(0).getContent().toString()); + } + + @Test + public void digestChallengeResponse() throws XMLDBException { + // The client must answer the server's 401 Digest challenge by computing the RFC 2617 + // digest response and re-sending; the test server validates the response hash. + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "let $response := http:send-request(" + + " " + + ")\n" + + "return parse-json($response[2])?authenticated"); + assertEquals("Digest challenge-response should authenticate", + "true", result.getResource(0).getContent().toString()); + } + // ======================================================================== // Special Request Attributes Tests // ======================================================================== From 92e55ea0e3456095804cc2d2863a85c3ffa5bebc Mon Sep 17 00:00:00 2001 From: Joe Wicentowski Date: Mon, 15 Jun 2026 10:18:19 -0400 Subject: [PATCH 8/8] [test] Regression test: connection error catchable as expath-err:HC001 (#4256) Adds connectionErrorIsCatchableAsExpathHC001, mirroring the reproducer in #4256: a send-request to an unreachable host must surface the EXPath error expath-err:HC001 (namespace http://expath.org/ns/error), catchable from XQuery, rather than the raw org.expath.httpclient.HttpClientException the old Apache-based client raised. The native module maps connection/IO failures to HC001, so the issue is fixed; this test pins it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../httpclient/SendRequestFunctionTest.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/extensions/modules/http-client/src/test/java/org/exist/xquery/modules/httpclient/SendRequestFunctionTest.java b/extensions/modules/http-client/src/test/java/org/exist/xquery/modules/httpclient/SendRequestFunctionTest.java index db4888becec..7e62826cf80 100644 --- a/extensions/modules/http-client/src/test/java/org/exist/xquery/modules/httpclient/SendRequestFunctionTest.java +++ b/extensions/modules/http-client/src/test/java/org/exist/xquery/modules/httpclient/SendRequestFunctionTest.java @@ -1302,6 +1302,23 @@ public void connectionRefusedRaisesHC001() throws XMLDBException { } } + @Test + public void connectionErrorIsCatchableAsExpathHC001() throws XMLDBException { + // Regression test for #4256: a connection error must surface as the EXPath error + // expath-err:HC001 (namespace http://expath.org/ns/error), catchable from XQuery — not as a + // raw Java exception (org.expath.httpclient.HttpClientException), as the old client did. + final ResourceSet result = existEmbeddedServer.executeQuery( + HTTP_NS + + "declare namespace expath-err = 'http://expath.org/ns/error';\n" + + "try {\n" + + " http:send-request()\n" + + "} catch expath-err:HC001 {\n" + + " 'caught-HC001'\n" + + "}"); + assertEquals("Connection error must be catchable as expath-err:HC001 (#4256)", + "caught-HC001", result.getResource(0).getContent().toString()); + } + @Test public void missingHrefRaisesError() throws XMLDBException { try {