Skip to content
Merged
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,5 @@ e2e/node_modules
e2e/playwright-report
e2e/test-results
.aider*
/tools/server/.lwjgl/
/tools/server/.lwjgl/
12 changes: 12 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,18 @@ Do **not** modify existing headers’ years.
* Entire repo:

* `mvn -o verify` (long; only when appropriate)
* Slow tests (entire repo):

* `mvn -o verify -PslowTestsOnly,-skipSlowTests,-formatting -Dmaven.javadoc.skip -Djapicmp.skip -Denforcer.skip -Danimal.sniffer.skip | tail -500`
* Slow tests (by module):

* `mvn -o -pl <module> verify -PslowTestsOnly,-skipSlowTests,-formatting -Dmaven.javadoc.skip -Djapicmp.skip -Denforcer.skip -Danimal.sniffer.skip | tail -500`
* Integration tests (entire repo):

* `mvn -o verify -PskipUnitTests,-formatting -Dmaven.javadoc.skip -Denforcer.skip -Danimal.sniffer.skip | tail -500`
* Integration tests (by module):

* `mvn -o -pl <module> verify -PskipUnitTests,-formatting -Dmaven.javadoc.skip -Denforcer.skip -Danimal.sniffer.skip | tail -500`
* Useful flags:

* `-Dtest=ClassName`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpUriRequest;
import org.apache.http.client.methods.RequestBuilder;
import org.apache.http.client.protocol.HttpClientContext;
import org.apache.http.client.utils.URIBuilder;
import org.apache.http.entity.ContentType;
Expand Down Expand Up @@ -1058,44 +1059,61 @@ protected void executeNoContent(HttpUriRequest method) throws IOException, RDF4J
}

protected HttpResponse execute(HttpUriRequest method) throws IOException, RDF4JException {
return executeWithRedirects(method, 5);
}

private HttpResponse executeWithRedirects(HttpUriRequest method, int redirectsLeft)
throws IOException, RDF4JException {
boolean consume = true;
if (params != null) {
method.setParams(params);
}
HttpResponse response = httpClient.execute(method, httpContext);

try {
int httpCode = response.getStatusLine().getStatusCode();
if (httpCode >= 200 && httpCode < 300 || httpCode == HttpURLConnection.HTTP_NOT_FOUND) {
consume = false;
return response; // everything OK, control flow can continue
} else {
switch (httpCode) {
case HttpURLConnection.HTTP_UNAUTHORIZED: // 401
throw new UnauthorizedException();
case HttpURLConnection.HTTP_UNAVAILABLE: // 503
throw new QueryInterruptedException();
default:
ErrorInfo errInfo = getErrorInfo(response);
// Throw appropriate exception
if (errInfo.getErrorType() == ErrorType.MALFORMED_DATA) {
throw new RDFParseException(errInfo.getErrorMessage());
} else if (errInfo.getErrorType() == ErrorType.UNSUPPORTED_FILE_FORMAT) {
throw new UnsupportedRDFormatException(errInfo.getErrorMessage());
} else if (errInfo.getErrorType() == ErrorType.MALFORMED_QUERY) {
throw new MalformedQueryException(errInfo.getErrorMessage());
} else if (errInfo.getErrorType() == ErrorType.UNSUPPORTED_QUERY_LANGUAGE) {
throw new UnsupportedQueryLanguageException(errInfo.getErrorMessage());
} else if (contentTypeIs(response, "application/shacl-validation-report")) {
RDFFormat format = getContentTypeSerialisation(response);
throw new RepositoryException(new RemoteShaclValidationException(
new StringReader(errInfo.toString()), "", format));

} else if (!errInfo.toString().isEmpty()) {
throw new RepositoryException(errInfo.toString());
} else {
throw new RepositoryException(response.getStatusLine().getReasonPhrase());
}
}

// Follow redirects for any method (preserving method and entity)
if (redirectsLeft > 0 && (httpCode == HttpURLConnection.HTTP_MOVED_PERM
|| httpCode == HttpURLConnection.HTTP_MOVED_TEMP || httpCode == 307 || httpCode == 308)) {
Header location = response.getFirstHeader("Location");
if (location != null) {
// consume and follow
EntityUtils.consumeQuietly(response.getEntity());
java.net.URI uri = java.net.URI.create(location.getValue());
HttpUriRequest redirect = RequestBuilder.copy(method).setUri(uri).build();
return executeWithRedirects(redirect, redirectsLeft - 1);
}
}

switch (httpCode) {
case HttpURLConnection.HTTP_UNAUTHORIZED: // 401
throw new UnauthorizedException();
case HttpURLConnection.HTTP_UNAVAILABLE: // 503
throw new QueryInterruptedException();
default:
ErrorInfo errInfo = getErrorInfo(response);
// Throw appropriate exception
if (errInfo.getErrorType() == ErrorType.MALFORMED_DATA) {
throw new RDFParseException(errInfo.getErrorMessage());
} else if (errInfo.getErrorType() == ErrorType.UNSUPPORTED_FILE_FORMAT) {
throw new UnsupportedRDFormatException(errInfo.getErrorMessage());
} else if (errInfo.getErrorType() == ErrorType.MALFORMED_QUERY) {
throw new MalformedQueryException(errInfo.getErrorMessage());
} else if (errInfo.getErrorType() == ErrorType.UNSUPPORTED_QUERY_LANGUAGE) {
throw new UnsupportedQueryLanguageException(errInfo.getErrorMessage());
} else if (contentTypeIs(response, "application/shacl-validation-report")) {
RDFFormat format = getContentTypeSerialisation(response);
throw new RepositoryException(new RemoteShaclValidationException(
new StringReader(errInfo.toString()), "", format));

} else if (!errInfo.toString().isEmpty()) {
throw new RepositoryException(errInfo.toString());
} else {
throw new RepositoryException(response.getStatusLine().getReasonPhrase());
}
}
} finally {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URI;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
Expand All @@ -24,15 +25,20 @@
import java.util.concurrent.atomic.AtomicLong;

import org.apache.http.HttpConnection;
import org.apache.http.HttpRequest;
import org.apache.http.HttpResponse;
import org.apache.http.ProtocolException;
import org.apache.http.client.HttpClient;
import org.apache.http.client.HttpRequestRetryHandler;
import org.apache.http.client.ServiceUnavailableRetryStrategy;
import org.apache.http.client.config.CookieSpecs;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.methods.HttpUriRequest;
import org.apache.http.client.methods.RequestBuilder;
import org.apache.http.client.protocol.HttpClientContext;
import org.apache.http.client.utils.HttpClientUtils;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.DefaultRedirectStrategy;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.protocol.HttpContext;
import org.eclipse.rdf4j.http.client.util.HttpClientBuilders;
Expand Down Expand Up @@ -579,6 +585,7 @@ private CloseableHttpClient createHttpClient() {
.setMaxConnPerRoute(MAX_CONN_PER_ROUTE)
.setMaxConnTotal(MAX_CONN_TOTAL)
.useSystemProperties()
.setRedirectStrategy(new SameMethodRedirectStrategy())
.setDefaultRequestConfig(requestConfig)
.build();
}
Expand All @@ -593,10 +600,43 @@ public RequestConfig getDefaultRequestConfig() {
.setConnectTimeout(currentConnectionTimeout)
.setConnectionRequestTimeout(currentConnectionRequestTimeout)
.setSocketTimeout(currentSocketTimeout)
.setRedirectsEnabled(true)
.setRelativeRedirectsAllowed(true)
.setExpectContinueEnabled(true)
.setCookieSpec(CookieSpecs.STANDARD)
.build();
}

/**
* Redirect strategy that follows 301/302/307/308 for any HTTP method and preserves the original method and entity.
*/
private static class SameMethodRedirectStrategy extends DefaultRedirectStrategy {
private static final String[] REDIRECT_METHODS = new String[] { "GET", "HEAD", "POST", "PUT", "DELETE",
"PATCH" };

@Override
protected boolean isRedirectable(String method) {
for (String m : REDIRECT_METHODS) {
if (m.equalsIgnoreCase(method)) {
return true;
}
}
return false;
}

@Override
public HttpUriRequest getRedirect(HttpRequest request,
HttpResponse response, HttpContext context)
throws ProtocolException {
URI uri = getLocationURI(request, response, context);
// Preserve original method and entity
RequestBuilder rb = RequestBuilder
.copy(request);
rb.setUri(uri);
return rb.build();
}
}

/**
* Switches the current timeout settings to use the SPARQL-specific timeouts. This method should be called when
* making SPARQL SERVICE calls to apply shorter timeout values.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
import static org.mockserver.model.HttpRequest.request;
import static org.mockserver.model.HttpResponse.response;

import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;

import org.eclipse.rdf4j.common.transaction.IsolationLevels;
Expand Down Expand Up @@ -77,6 +79,111 @@ public void testCreateRepositoryExecutesPut(MockServerClient client) throws Exce
);
}

@Test
public void testCreateRepositoryFollowsRedirectOnPut(MockServerClient client) throws Exception {
// Simulate reverse-proxy forcing redirect on state-changing PUT
String originalPath = "/rdf4j-server/repositories/test";
String redirectedPath = "/https/rdf4j-server/repositories/test";
String redirectLocation = "http://localhost:" + client.getPort() + redirectedPath;

// First request responds with 301 and Location header
client.when(
request()
.withMethod("PUT")
.withPath(originalPath),
Times.once()
)
.respond(
response()
.withStatusCode(301)
.withHeader("Location", redirectLocation)
);

// Redirect target responds successfully
client.when(
request()
.withMethod("PUT")
.withPath(redirectedPath),
Times.once()
)
.respond(
response()
);

RepositoryConfig config = new RepositoryConfig("test");

// Expected: client should follow the 301 redirect and succeed without throwing
getRDF4JSession().createRepository(config);

// Verify both the original and redirected requests were made with additional headers preserved
client.verify(
request()
.withMethod("PUT")
.withPath(originalPath)
.withHeader(testHeader, testValue)
);
client.verify(
request()
.withMethod("PUT")
.withPath(redirectedPath)
.withHeader(testHeader, testValue)
);
}

@Test
public void testRemoveDataTransactionFollowsRedirectOnDelete(MockServerClient client) throws Exception {
// Start transaction and get transaction URL
String transactionStartUrl = Protocol.getTransactionsLocation(getRDF4JSession().getRepositoryURL());
HttpRequest transactionCreateRequest = request()
.withMethod("POST")
.withPath("/rdf4j-server/repositories/test/transactions");
client.when(transactionCreateRequest, Times.once())
.respond(response().withStatusCode(201).withHeader("Location", transactionStartUrl + "/1"));

// First attempt: PUT .../transactions/1?action=DELETE responds with 301 and Location header
String originalPath = "/rdf4j-server/repositories/test/transactions/1";
String redirectedPath = "/https/rdf4j-server/repositories/test/transactions/1";
String redirectLocation = "http://localhost:" + client.getPort() + redirectedPath + "?action=DELETE";

client.when(
request()
.withMethod("PUT")
.withPath(originalPath)
.withQueryStringParameter("action", "DELETE"),
Times.once())
.respond(response().withStatusCode(301).withHeader("Location", redirectLocation));

// Redirect target responds successfully (204 No Content)
client.when(
request()
.withMethod("PUT")
.withPath(redirectedPath)
.withQueryStringParameter("action", "DELETE"),
Times.once())
.respond(response().withStatusCode(204));

// Begin transaction, then attempt removeData (DELETE action) which should follow redirect
getRDF4JSession().beginTransaction(IsolationLevels.SERIALIZABLE);
ByteArrayInputStream data = new ByteArrayInputStream("<s> <p> <o> .".getBytes(StandardCharsets.UTF_8));
getRDF4JSession().removeData(data, null, RDFFormat.NTRIPLES);

// Verify original and redirected requests occurred with header preserved
client.verify(
request()
.withMethod("PUT")
.withPath(originalPath)
.withQueryStringParameter("action", "DELETE")
.withHeader(testHeader, testValue)
);
client.verify(
request()
.withMethod("PUT")
.withPath(redirectedPath)
.withQueryStringParameter("action", "DELETE")
.withHeader(testHeader, testValue)
);
}

@Test
public void testUpdateRepositoryExecutesPost(MockServerClient client) throws Exception {
RepositoryConfig config = new RepositoryConfig("test");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,13 @@ protected void setRequestAttributes(HttpServletRequest request) throws ClientHTT
request.setAttribute(REPOSITORY_KEY, new RepositoryConfigRepository(repositoryManager));
} else if (nextRepositoryID != null) {
try {
// For requests to delete a repository, we must not attempt to initialize the repository. Otherwise a
// corrupt/invalid configuration can block deletion.
if ("DELETE".equals(request.getMethod()) && request.getPathInfo().equals("/" + nextRepositoryID)) {
request.setAttribute(REPOSITORY_ID_KEY, nextRepositoryID);
return;
}

Repository repository = repositoryManager.getRepository(nextRepositoryID);
if (repository == null && !"PUT".equals(request.getMethod())) {
throw new ClientHTTPException(SC_NOT_FOUND, "Unknown repository: " + nextRepositoryID);
Expand Down
Loading
Loading