Skip to content

Commit 8ae9307

Browse files
authored
GH-5220 Follow redirects for state-changing HTTP requests; add tests (#5431)
2 parents 6a92f19 + c405912 commit 8ae9307

3 files changed

Lines changed: 193 additions & 28 deletions

File tree

core/http/client/src/main/java/org/eclipse/rdf4j/http/client/SPARQLProtocolSession.java

Lines changed: 46 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
import org.apache.http.client.methods.HttpGet;
5050
import org.apache.http.client.methods.HttpPost;
5151
import org.apache.http.client.methods.HttpUriRequest;
52+
import org.apache.http.client.methods.RequestBuilder;
5253
import org.apache.http.client.protocol.HttpClientContext;
5354
import org.apache.http.client.utils.URIBuilder;
5455
import org.apache.http.entity.ContentType;
@@ -1058,44 +1059,61 @@ protected void executeNoContent(HttpUriRequest method) throws IOException, RDF4J
10581059
}
10591060

10601061
protected HttpResponse execute(HttpUriRequest method) throws IOException, RDF4JException {
1062+
return executeWithRedirects(method, 5);
1063+
}
1064+
1065+
private HttpResponse executeWithRedirects(HttpUriRequest method, int redirectsLeft)
1066+
throws IOException, RDF4JException {
10611067
boolean consume = true;
10621068
if (params != null) {
10631069
method.setParams(params);
10641070
}
10651071
HttpResponse response = httpClient.execute(method, httpContext);
1066-
10671072
try {
10681073
int httpCode = response.getStatusLine().getStatusCode();
10691074
if (httpCode >= 200 && httpCode < 300 || httpCode == HttpURLConnection.HTTP_NOT_FOUND) {
10701075
consume = false;
10711076
return response; // everything OK, control flow can continue
1072-
} else {
1073-
switch (httpCode) {
1074-
case HttpURLConnection.HTTP_UNAUTHORIZED: // 401
1075-
throw new UnauthorizedException();
1076-
case HttpURLConnection.HTTP_UNAVAILABLE: // 503
1077-
throw new QueryInterruptedException();
1078-
default:
1079-
ErrorInfo errInfo = getErrorInfo(response);
1080-
// Throw appropriate exception
1081-
if (errInfo.getErrorType() == ErrorType.MALFORMED_DATA) {
1082-
throw new RDFParseException(errInfo.getErrorMessage());
1083-
} else if (errInfo.getErrorType() == ErrorType.UNSUPPORTED_FILE_FORMAT) {
1084-
throw new UnsupportedRDFormatException(errInfo.getErrorMessage());
1085-
} else if (errInfo.getErrorType() == ErrorType.MALFORMED_QUERY) {
1086-
throw new MalformedQueryException(errInfo.getErrorMessage());
1087-
} else if (errInfo.getErrorType() == ErrorType.UNSUPPORTED_QUERY_LANGUAGE) {
1088-
throw new UnsupportedQueryLanguageException(errInfo.getErrorMessage());
1089-
} else if (contentTypeIs(response, "application/shacl-validation-report")) {
1090-
RDFFormat format = getContentTypeSerialisation(response);
1091-
throw new RepositoryException(new RemoteShaclValidationException(
1092-
new StringReader(errInfo.toString()), "", format));
1093-
1094-
} else if (!errInfo.toString().isEmpty()) {
1095-
throw new RepositoryException(errInfo.toString());
1096-
} else {
1097-
throw new RepositoryException(response.getStatusLine().getReasonPhrase());
1098-
}
1077+
}
1078+
1079+
// Follow redirects for any method (preserving method and entity)
1080+
if (redirectsLeft > 0 && (httpCode == HttpURLConnection.HTTP_MOVED_PERM
1081+
|| httpCode == HttpURLConnection.HTTP_MOVED_TEMP || httpCode == 307 || httpCode == 308)) {
1082+
Header location = response.getFirstHeader("Location");
1083+
if (location != null) {
1084+
// consume and follow
1085+
EntityUtils.consumeQuietly(response.getEntity());
1086+
java.net.URI uri = java.net.URI.create(location.getValue());
1087+
HttpUriRequest redirect = RequestBuilder.copy(method).setUri(uri).build();
1088+
return executeWithRedirects(redirect, redirectsLeft - 1);
1089+
}
1090+
}
1091+
1092+
switch (httpCode) {
1093+
case HttpURLConnection.HTTP_UNAUTHORIZED: // 401
1094+
throw new UnauthorizedException();
1095+
case HttpURLConnection.HTTP_UNAVAILABLE: // 503
1096+
throw new QueryInterruptedException();
1097+
default:
1098+
ErrorInfo errInfo = getErrorInfo(response);
1099+
// Throw appropriate exception
1100+
if (errInfo.getErrorType() == ErrorType.MALFORMED_DATA) {
1101+
throw new RDFParseException(errInfo.getErrorMessage());
1102+
} else if (errInfo.getErrorType() == ErrorType.UNSUPPORTED_FILE_FORMAT) {
1103+
throw new UnsupportedRDFormatException(errInfo.getErrorMessage());
1104+
} else if (errInfo.getErrorType() == ErrorType.MALFORMED_QUERY) {
1105+
throw new MalformedQueryException(errInfo.getErrorMessage());
1106+
} else if (errInfo.getErrorType() == ErrorType.UNSUPPORTED_QUERY_LANGUAGE) {
1107+
throw new UnsupportedQueryLanguageException(errInfo.getErrorMessage());
1108+
} else if (contentTypeIs(response, "application/shacl-validation-report")) {
1109+
RDFFormat format = getContentTypeSerialisation(response);
1110+
throw new RepositoryException(new RemoteShaclValidationException(
1111+
new StringReader(errInfo.toString()), "", format));
1112+
1113+
} else if (!errInfo.toString().isEmpty()) {
1114+
throw new RepositoryException(errInfo.toString());
1115+
} else {
1116+
throw new RepositoryException(response.getStatusLine().getReasonPhrase());
10991117
}
11001118
}
11011119
} finally {

core/http/client/src/main/java/org/eclipse/rdf4j/http/client/SharedHttpClientSessionManager.java

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import java.io.IOException;
1414
import java.net.HttpURLConnection;
15+
import java.net.URI;
1516
import java.util.Map;
1617
import java.util.Objects;
1718
import java.util.concurrent.ConcurrentHashMap;
@@ -24,15 +25,20 @@
2425
import java.util.concurrent.atomic.AtomicLong;
2526

2627
import org.apache.http.HttpConnection;
28+
import org.apache.http.HttpRequest;
2729
import org.apache.http.HttpResponse;
30+
import org.apache.http.ProtocolException;
2831
import org.apache.http.client.HttpClient;
2932
import org.apache.http.client.HttpRequestRetryHandler;
3033
import org.apache.http.client.ServiceUnavailableRetryStrategy;
3134
import org.apache.http.client.config.CookieSpecs;
3235
import org.apache.http.client.config.RequestConfig;
36+
import org.apache.http.client.methods.HttpUriRequest;
37+
import org.apache.http.client.methods.RequestBuilder;
3338
import org.apache.http.client.protocol.HttpClientContext;
3439
import org.apache.http.client.utils.HttpClientUtils;
3540
import org.apache.http.impl.client.CloseableHttpClient;
41+
import org.apache.http.impl.client.DefaultRedirectStrategy;
3642
import org.apache.http.impl.client.HttpClientBuilder;
3743
import org.apache.http.protocol.HttpContext;
3844
import org.eclipse.rdf4j.http.client.util.HttpClientBuilders;
@@ -579,6 +585,7 @@ private CloseableHttpClient createHttpClient() {
579585
.setMaxConnPerRoute(MAX_CONN_PER_ROUTE)
580586
.setMaxConnTotal(MAX_CONN_TOTAL)
581587
.useSystemProperties()
588+
.setRedirectStrategy(new SameMethodRedirectStrategy())
582589
.setDefaultRequestConfig(requestConfig)
583590
.build();
584591
}
@@ -593,10 +600,43 @@ public RequestConfig getDefaultRequestConfig() {
593600
.setConnectTimeout(currentConnectionTimeout)
594601
.setConnectionRequestTimeout(currentConnectionRequestTimeout)
595602
.setSocketTimeout(currentSocketTimeout)
603+
.setRedirectsEnabled(true)
604+
.setRelativeRedirectsAllowed(true)
605+
.setExpectContinueEnabled(true)
596606
.setCookieSpec(CookieSpecs.STANDARD)
597607
.build();
598608
}
599609

610+
/**
611+
* Redirect strategy that follows 301/302/307/308 for any HTTP method and preserves the original method and entity.
612+
*/
613+
private static class SameMethodRedirectStrategy extends DefaultRedirectStrategy {
614+
private static final String[] REDIRECT_METHODS = new String[] { "GET", "HEAD", "POST", "PUT", "DELETE",
615+
"PATCH" };
616+
617+
@Override
618+
protected boolean isRedirectable(String method) {
619+
for (String m : REDIRECT_METHODS) {
620+
if (m.equalsIgnoreCase(method)) {
621+
return true;
622+
}
623+
}
624+
return false;
625+
}
626+
627+
@Override
628+
public HttpUriRequest getRedirect(HttpRequest request,
629+
HttpResponse response, HttpContext context)
630+
throws ProtocolException {
631+
URI uri = getLocationURI(request, response, context);
632+
// Preserve original method and entity
633+
RequestBuilder rb = RequestBuilder
634+
.copy(request);
635+
rb.setUri(uri);
636+
return rb.build();
637+
}
638+
}
639+
600640
/**
601641
* Switches the current timeout settings to use the SPARQL-specific timeouts. This method should be called when
602642
* making SPARQL SERVICE calls to apply shorter timeout values.

core/http/client/src/test/java/org/eclipse/rdf4j/http/client/RDF4JProtocolSessionTest.java

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
import static org.mockserver.model.HttpRequest.request;
1515
import static org.mockserver.model.HttpResponse.response;
1616

17+
import java.io.ByteArrayInputStream;
18+
import java.nio.charset.StandardCharsets;
1719
import java.util.HashMap;
1820

1921
import org.eclipse.rdf4j.common.transaction.IsolationLevels;
@@ -77,6 +79,111 @@ public void testCreateRepositoryExecutesPut(MockServerClient client) throws Exce
7779
);
7880
}
7981

82+
@Test
83+
public void testCreateRepositoryFollowsRedirectOnPut(MockServerClient client) throws Exception {
84+
// Simulate reverse-proxy forcing redirect on state-changing PUT
85+
String originalPath = "/rdf4j-server/repositories/test";
86+
String redirectedPath = "/https/rdf4j-server/repositories/test";
87+
String redirectLocation = "http://localhost:" + client.getPort() + redirectedPath;
88+
89+
// First request responds with 301 and Location header
90+
client.when(
91+
request()
92+
.withMethod("PUT")
93+
.withPath(originalPath),
94+
Times.once()
95+
)
96+
.respond(
97+
response()
98+
.withStatusCode(301)
99+
.withHeader("Location", redirectLocation)
100+
);
101+
102+
// Redirect target responds successfully
103+
client.when(
104+
request()
105+
.withMethod("PUT")
106+
.withPath(redirectedPath),
107+
Times.once()
108+
)
109+
.respond(
110+
response()
111+
);
112+
113+
RepositoryConfig config = new RepositoryConfig("test");
114+
115+
// Expected: client should follow the 301 redirect and succeed without throwing
116+
getRDF4JSession().createRepository(config);
117+
118+
// Verify both the original and redirected requests were made with additional headers preserved
119+
client.verify(
120+
request()
121+
.withMethod("PUT")
122+
.withPath(originalPath)
123+
.withHeader(testHeader, testValue)
124+
);
125+
client.verify(
126+
request()
127+
.withMethod("PUT")
128+
.withPath(redirectedPath)
129+
.withHeader(testHeader, testValue)
130+
);
131+
}
132+
133+
@Test
134+
public void testRemoveDataTransactionFollowsRedirectOnDelete(MockServerClient client) throws Exception {
135+
// Start transaction and get transaction URL
136+
String transactionStartUrl = Protocol.getTransactionsLocation(getRDF4JSession().getRepositoryURL());
137+
HttpRequest transactionCreateRequest = request()
138+
.withMethod("POST")
139+
.withPath("/rdf4j-server/repositories/test/transactions");
140+
client.when(transactionCreateRequest, Times.once())
141+
.respond(response().withStatusCode(201).withHeader("Location", transactionStartUrl + "/1"));
142+
143+
// First attempt: PUT .../transactions/1?action=DELETE responds with 301 and Location header
144+
String originalPath = "/rdf4j-server/repositories/test/transactions/1";
145+
String redirectedPath = "/https/rdf4j-server/repositories/test/transactions/1";
146+
String redirectLocation = "http://localhost:" + client.getPort() + redirectedPath + "?action=DELETE";
147+
148+
client.when(
149+
request()
150+
.withMethod("PUT")
151+
.withPath(originalPath)
152+
.withQueryStringParameter("action", "DELETE"),
153+
Times.once())
154+
.respond(response().withStatusCode(301).withHeader("Location", redirectLocation));
155+
156+
// Redirect target responds successfully (204 No Content)
157+
client.when(
158+
request()
159+
.withMethod("PUT")
160+
.withPath(redirectedPath)
161+
.withQueryStringParameter("action", "DELETE"),
162+
Times.once())
163+
.respond(response().withStatusCode(204));
164+
165+
// Begin transaction, then attempt removeData (DELETE action) which should follow redirect
166+
getRDF4JSession().beginTransaction(IsolationLevels.SERIALIZABLE);
167+
ByteArrayInputStream data = new ByteArrayInputStream("<s> <p> <o> .".getBytes(StandardCharsets.UTF_8));
168+
getRDF4JSession().removeData(data, null, RDFFormat.NTRIPLES);
169+
170+
// Verify original and redirected requests occurred with header preserved
171+
client.verify(
172+
request()
173+
.withMethod("PUT")
174+
.withPath(originalPath)
175+
.withQueryStringParameter("action", "DELETE")
176+
.withHeader(testHeader, testValue)
177+
);
178+
client.verify(
179+
request()
180+
.withMethod("PUT")
181+
.withPath(redirectedPath)
182+
.withQueryStringParameter("action", "DELETE")
183+
.withHeader(testHeader, testValue)
184+
);
185+
}
186+
80187
@Test
81188
public void testUpdateRepositoryExecutesPost(MockServerClient client) throws Exception {
82189
RepositoryConfig config = new RepositoryConfig("test");

0 commit comments

Comments
 (0)