diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/config/RedirectMethodPolicy.java b/httpclient5/src/main/java/org/apache/hc/client5/http/config/RedirectMethodPolicy.java
new file mode 100644
index 0000000000..e99af91374
--- /dev/null
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/config/RedirectMethodPolicy.java
@@ -0,0 +1,51 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.config;
+
+
+/**
+ * Policy controlling method/body rewriting on 301/302 redirects.
+ *
+ * @since 5.6
+ */
+public enum RedirectMethodPolicy {
+ /**
+ * Browser compatibility: POST→GET for 301/302; 303→GET; 307/308 preserve.
+ */
+ BROWSER_COMPAT,
+
+ /**
+ * Preserve original method (& body if repeatable) for 301/302.
+ */
+ PRESERVE_METHOD,
+
+ /**
+ * Preserve original method (& body if repeatable) for 301/302
+ * only when the redirect stays on the same authority (scheme+host+port).
+ */
+ PRESERVE_SAME_AUTH
+}
\ No newline at end of file
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/config/RequestConfig.java b/httpclient5/src/main/java/org/apache/hc/client5/http/config/RequestConfig.java
index f13d940fe3..81b9453693 100644
--- a/httpclient5/src/main/java/org/apache/hc/client5/http/config/RequestConfig.java
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/config/RequestConfig.java
@@ -69,13 +69,15 @@ public class RequestConfig implements Cloneable {
private final ExpectContinueTrigger expectContinueTrigger;
+ private final RedirectMethodPolicy redirectMethodPolicy;
+
/**
* Intended for CDI compatibility
*/
protected RequestConfig() {
this(false, null, null, false, false, 0, false, null, null,
DEFAULT_CONNECTION_REQUEST_TIMEOUT, null, null, DEFAULT_CONN_KEEP_ALIVE, false, false, false, null,
- ExpectContinueTrigger.ALWAYS);
+ ExpectContinueTrigger.ALWAYS, null);
}
RequestConfig(
@@ -96,7 +98,8 @@ protected RequestConfig() {
final boolean hardCancellationEnabled,
final boolean protocolUpgradeEnabled,
final Path unixDomainSocket,
- final ExpectContinueTrigger expectContinueTrigger) {
+ final ExpectContinueTrigger expectContinueTrigger,
+ final RedirectMethodPolicy redirectMethodPolicy) {
super();
this.expectContinueEnabled = expectContinueEnabled;
this.proxy = proxy;
@@ -116,6 +119,7 @@ protected RequestConfig() {
this.protocolUpgradeEnabled = protocolUpgradeEnabled;
this.unixDomainSocket = unixDomainSocket;
this.expectContinueTrigger = expectContinueTrigger;
+ this.redirectMethodPolicy = redirectMethodPolicy;
}
/**
@@ -248,6 +252,13 @@ public ExpectContinueTrigger getExpectContinueTrigger() {
return expectContinueTrigger;
}
+ /**
+ * @since 5.6
+ */
+ public RedirectMethodPolicy getRedirectMethodPolicy() {
+ return redirectMethodPolicy;
+ }
+
@Override
protected RequestConfig clone() throws CloneNotSupportedException {
return (RequestConfig) super.clone();
@@ -274,6 +285,7 @@ public String toString() {
builder.append(", hardCancellationEnabled=").append(hardCancellationEnabled);
builder.append(", protocolUpgradeEnabled=").append(protocolUpgradeEnabled);
builder.append(", unixDomainSocket=").append(unixDomainSocket);
+ builder.append(", redirectMethodPolicy=").append(redirectMethodPolicy);
builder.append("]");
return builder.toString();
}
@@ -323,6 +335,7 @@ public static class Builder {
private boolean protocolUpgradeEnabled;
private Path unixDomainSocket;
private ExpectContinueTrigger expectContinueTrigger;
+ private RedirectMethodPolicy redirectMethodPolicy;
Builder() {
super();
@@ -334,6 +347,7 @@ public static class Builder {
this.hardCancellationEnabled = true;
this.protocolUpgradeEnabled = true;
this.expectContinueTrigger = ExpectContinueTrigger.ALWAYS;
+ this.redirectMethodPolicy = RedirectMethodPolicy.BROWSER_COMPAT;
}
/**
@@ -693,6 +707,17 @@ public Builder setExpectContinueTrigger(final ExpectContinueTrigger trigger) {
this.expectContinueTrigger = Args.notNull(trigger, "ExpectContinueTrigger");
return this;
}
+ /**
+ * Control method/body rewriting for 301/302.
+ * Default is {@link RedirectMethodPolicy#BROWSER_COMPAT}.
+ *
+ * @since 5.6
+ */
+ public Builder setRedirectMethodPolicy(final RedirectMethodPolicy policy) {
+ this.redirectMethodPolicy = Args.notNull(policy, "policy");
+ return this;
+ }
+
public RequestConfig build() {
return new RequestConfig(
@@ -713,7 +738,8 @@ public RequestConfig build() {
hardCancellationEnabled,
protocolUpgradeEnabled,
unixDomainSocket,
- expectContinueTrigger);
+ expectContinueTrigger,
+ redirectMethodPolicy);
}
}
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/AsyncRedirectExec.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/AsyncRedirectExec.java
index c65b7bba76..31db9ad02e 100644
--- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/AsyncRedirectExec.java
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/async/AsyncRedirectExec.java
@@ -60,6 +60,7 @@
import org.apache.hc.core5.http.support.BasicRequestBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.apache.hc.client5.http.config.RedirectMethodPolicy; // <<< ADDED
/**
* Request execution handler in the asynchronous request execution chain
@@ -147,8 +148,14 @@ public AsyncDataConsumer handleResponse(
case HttpStatus.SC_MOVED_PERMANENTLY:
case HttpStatus.SC_MOVED_TEMPORARILY:
if (Method.POST.isSame(request.getMethod())) {
- redirectBuilder = BasicRequestBuilder.get();
- state.currentEntityProducer = null;
+ final RedirectMethodPolicy methodPolicy = config.getRedirectMethodPolicy();
+ final boolean sameAuth = Objects.equals(currentScope.route.getTargetHost(), newTarget);
+ if (methodPolicy == RedirectMethodPolicy.PRESERVE_METHOD || methodPolicy == RedirectMethodPolicy.PRESERVE_SAME_AUTH && sameAuth) {
+ redirectBuilder = BasicRequestBuilder.copy(currentScope.originalRequest);
+ } else {
+ redirectBuilder = BasicRequestBuilder.get();
+ state.currentEntityProducer = null;
+ }
} else {
redirectBuilder = BasicRequestBuilder.copy(currentScope.originalRequest);
}
@@ -293,4 +300,4 @@ public void execute(
internalExecute(state, chain, asyncExecCallback);
}
-}
+}
\ No newline at end of file
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/RedirectExec.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/RedirectExec.java
index 892c374228..f43bfa3cf4 100644
--- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/RedirectExec.java
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/classic/RedirectExec.java
@@ -59,6 +59,7 @@
import org.apache.hc.core5.util.Args;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.apache.hc.client5.http.config.RedirectMethodPolicy; // <<< ADDED
/**
* Request execution handler in the classic request execution chain
@@ -146,7 +147,13 @@ public ClassicHttpResponse execute(
case HttpStatus.SC_MOVED_PERMANENTLY:
case HttpStatus.SC_MOVED_TEMPORARILY:
if (Method.POST.isSame(request.getMethod())) {
- redirectBuilder = ClassicRequestBuilder.get();
+ final RedirectMethodPolicy methodPolicy = config.getRedirectMethodPolicy();
+ final boolean sameAuth = Objects.equals(currentScope.route.getTargetHost(), newTarget);
+ if (methodPolicy == RedirectMethodPolicy.PRESERVE_METHOD || methodPolicy == RedirectMethodPolicy.PRESERVE_SAME_AUTH && sameAuth) {
+ redirectBuilder = ClassicRequestBuilder.copy(currentScope.originalRequest);
+ } else {
+ redirectBuilder = ClassicRequestBuilder.get();
+ }
} else {
redirectBuilder = ClassicRequestBuilder.copy(currentScope.originalRequest);
}
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/examples/AsyncClientRedirectPreserveMethod.java b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/AsyncClientRedirectPreserveMethod.java
new file mode 100644
index 0000000000..c467342c46
--- /dev/null
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/AsyncClientRedirectPreserveMethod.java
@@ -0,0 +1,143 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.examples;
+
+import java.util.concurrent.Future;
+
+import org.apache.hc.client5.http.async.methods.SimpleHttpRequest;
+import org.apache.hc.client5.http.async.methods.SimpleHttpResponse;
+import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder;
+import org.apache.hc.client5.http.async.methods.SimpleRequestProducer;
+import org.apache.hc.client5.http.async.methods.SimpleResponseConsumer;
+import org.apache.hc.client5.http.config.RedirectMethodPolicy;
+import org.apache.hc.client5.http.config.RequestConfig;
+import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
+import org.apache.hc.client5.http.impl.async.HttpAsyncClients;
+import org.apache.hc.core5.concurrent.FutureCallback;
+import org.apache.hc.core5.http.ContentType;
+import org.apache.hc.core5.http.message.StatusLine;
+import org.apache.hc.core5.io.CloseMode;
+
+/**
+ * Demonstrates how to control 301/302 redirect method rewriting in the
+ * async client using {@link RedirectMethodPolicy}.
+ *
+ * The example executes the same JSON POST twice against a 301 redirecting URL:
+ * once with the default browser-compatible policy (resulting in a GET without body),
+ * and once with {@link RedirectMethodPolicy#PRESERVE_METHOD} (resulting in a POST with body).
+ *
+ *
+ * Notes
+ *
+ * - When preserving the method, the {@code AsyncEntityProducer} must be repeatable.
+ * - 303 is always followed with GET; 307/308 always preserve method/body.
+ * - Redirect safety rules (e.g., stripping {@code Authorization} across authorities) still apply.
+ *
+ *
+ * How to run
+ * {@code
+ * $ mvn -q -DskipTests exec:java -Dexec.mainClass=org.apache.hc.client5.http.examples.AsyncClientRedirectPreserveMethod
+ * }
+ *
+ * @see RequestConfig#setRedirectMethodPolicy(RedirectMethodPolicy)
+ * @see RedirectMethodPolicy
+ * @since 5.6
+ */
+public class AsyncClientRedirectPreserveMethod {
+
+ private static String redirectUrl() {
+ // httpbin: redirect to /anything with status 301
+ return "https://httpbin.org/redirect-to?url=/anything&status_code=301";
+ }
+
+ private static void runOnce(
+ final CloseableHttpAsyncClient client,
+ final String label) throws Exception {
+
+ final SimpleHttpRequest req = SimpleRequestBuilder.post(redirectUrl())
+ .setBody("{\"hello\":\"world\"}", ContentType.APPLICATION_JSON)
+ .build();
+
+ System.out.println("\n[" + label + "] Executing " + req);
+ final Future f = client.execute(
+ SimpleRequestProducer.create(req),
+ SimpleResponseConsumer.create(),
+ new FutureCallback() {
+ @Override
+ public void completed(final SimpleHttpResponse response) {
+ System.out.println("[" + label + "] " + new StatusLine(response));
+ final String body = response.getBodyText();
+ System.out.println(body != null ? body : "");
+ }
+
+ @Override
+ public void failed(final Exception ex) {
+ System.out.println("[" + label + "] failed: " + ex);
+ }
+
+ @Override
+ public void cancelled() {
+ System.out.println("[" + label + "] cancelled");
+ }
+ });
+ f.get();
+ }
+
+ public static void main(final String[] args) throws Exception {
+ final RequestConfig browserCompat = RequestConfig.custom()
+ .setRedirectsEnabled(true)
+ .setRedirectMethodPolicy(RedirectMethodPolicy.BROWSER_COMPAT)
+ .build();
+
+ final RequestConfig preserveMethod = RequestConfig.custom()
+ .setRedirectsEnabled(true)
+ .setRedirectMethodPolicy(RedirectMethodPolicy.PRESERVE_METHOD)
+ .build();
+
+ try (CloseableHttpAsyncClient clientDefault = HttpAsyncClients.custom()
+ .setDefaultRequestConfig(browserCompat)
+ .build();
+ CloseableHttpAsyncClient clientPreserve = HttpAsyncClients.custom()
+ .setDefaultRequestConfig(preserveMethod)
+ .build()) {
+
+ System.out.println("== Async client redirect demo ==");
+ System.out.println("URL: " + redirectUrl());
+ System.out.println("Sending POST with JSON body...\n");
+
+ clientDefault.start();
+ clientPreserve.start();
+
+ runOnce(clientDefault, "Default (BROWSER_COMPAT: POST→GET)");
+ runOnce(clientPreserve, "Opt-in (PRESERVE_METHOD: keep POST)");
+
+ System.out.println("\nShutting down");
+ clientDefault.close(CloseMode.GRACEFUL);
+ clientPreserve.close(CloseMode.GRACEFUL);
+ }
+ }
+}
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/examples/ClassicClientRedirectPreserveMethod.java b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/ClassicClientRedirectPreserveMethod.java
new file mode 100644
index 0000000000..43ba78414b
--- /dev/null
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/examples/ClassicClientRedirectPreserveMethod.java
@@ -0,0 +1,99 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.examples;
+
+import java.nio.charset.StandardCharsets;
+
+import org.apache.hc.client5.http.classic.methods.HttpPost;
+import org.apache.hc.client5.http.config.RedirectMethodPolicy;
+import org.apache.hc.client5.http.config.RequestConfig;
+import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
+import org.apache.hc.client5.http.impl.classic.HttpClients;
+import org.apache.hc.core5.http.ClassicHttpResponse;
+import org.apache.hc.core5.http.ContentType;
+import org.apache.hc.core5.http.io.entity.EntityUtils;
+import org.apache.hc.core5.http.io.entity.StringEntity;
+import org.apache.hc.core5.http.message.StatusLine;
+
+/**
+ * Demonstrates how to control 301/302 redirect method rewriting in the
+ * classic client using {@link RedirectMethodPolicy}.
+ *
+ * By default (browser-compatible), a 301/302 after a POST is followed with a GET and
+ * the request body is dropped. When {@link RedirectMethodPolicy#PRESERVE_METHOD} is enabled,
+ * the client preserves the original method and (repeatable) body on 301/302 as well.
+ *
+ *
+ * What this example does
+ *
+ * - Sends a JSON POST to an endpoint that returns a 301 redirect to
/anything.
+ * - Runs twice:
+ *
+ * - Default policy: shows
"method":"GET" and empty body.
+ * - PRESERVE_METHOD: shows
"method":"POST" and echoes the JSON body.
+ *
+ *
+ *
+ *
+ * Notes
+ *
+ * - Preservation requires a repeatable entity. Non-repeatable entities cannot be re-sent automatically.
+ * - 303 is always followed with GET; 307/308 always preserve method/body.
+ * - Authorization headers are not forwarded across different authorities unless explicitly allowed by the redirect strategy.
+ *
+ *
+ * How to run
+ * {@code
+ * $ mvn -q -DskipTests exec:java -Dexec.mainClass=org.apache.hc.client5.http.examples.ClassicClientRedirectPreserveMethod
+ * }
+ *
+ * @since 5.6
+ */
+public class ClassicClientRedirectPreserveMethod {
+
+ private static String redirectUrl() {
+ return "https://httpbin.org/redirect-to?url=/anything&status_code=301";
+ }
+
+ public static void main(final String[] args) throws Exception {
+ final RequestConfig cfg = RequestConfig.custom()
+ .setRedirectsEnabled(true)
+ .setRedirectMethodPolicy(RedirectMethodPolicy.PRESERVE_METHOD)
+ .build();
+
+ try (CloseableHttpClient client = HttpClients.custom().build()) {
+ final HttpPost post = new HttpPost(redirectUrl());
+ post.setConfig(cfg);
+ post.setEntity(new StringEntity("{\"hello\":\"world\"}", ContentType.APPLICATION_JSON));
+
+ try (ClassicHttpResponse res = client.executeOpen(null, post, null)) {
+ System.out.println(new StatusLine(res));
+ System.out.println(EntityUtils.toString(res.getEntity(), StandardCharsets.UTF_8));
+ }
+ }
+ }
+}
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TestAsyncRedirectExecTest.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TestAsyncRedirectExecTest.java
new file mode 100644
index 0000000000..5405d39e59
--- /dev/null
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/async/TestAsyncRedirectExecTest.java
@@ -0,0 +1,161 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * .
+ *
+ */
+package org.apache.hc.client5.http.impl.async;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.hc.client5.http.HttpRoute;
+import org.apache.hc.client5.http.async.AsyncExecCallback;
+import org.apache.hc.client5.http.async.AsyncExecChain;
+import org.apache.hc.client5.http.async.AsyncExecRuntime;
+import org.apache.hc.client5.http.config.RedirectMethodPolicy;
+import org.apache.hc.client5.http.config.RequestConfig;
+import org.apache.hc.client5.http.protocol.HttpClientContext;
+import org.apache.hc.client5.http.routing.HttpRoutePlanner;
+import org.apache.hc.core5.concurrent.CancellableDependency;
+import org.apache.hc.core5.http.EntityDetails;
+import org.apache.hc.core5.http.HttpHeaders;
+import org.apache.hc.core5.http.HttpHost;
+import org.apache.hc.core5.http.HttpRequest;
+import org.apache.hc.core5.http.HttpResponse;
+import org.apache.hc.core5.http.HttpStatus;
+import org.apache.hc.core5.http.io.support.ClassicResponseBuilder;
+import org.apache.hc.core5.http.nio.AsyncDataConsumer;
+import org.apache.hc.core5.http.nio.AsyncEntityProducer;
+import org.apache.hc.core5.http.nio.entity.StringAsyncEntityProducer;
+import org.apache.hc.core5.http.support.BasicRequestBuilder;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+/**
+ * Async redirect tests focused on preserving POST on 301/302 when
+ * {@link RedirectMethodPolicy#PRESERVE_METHOD} is enabled.
+ */
+class TestAsyncRedirectExecTest {
+
+ @Test
+ void testPostMovedPermanentlyPreserveMethodAsync() throws Exception {
+ // Arrange
+ final HttpRoutePlanner routePlanner = Mockito.mock(HttpRoutePlanner.class);
+ final AsyncRedirectExec redirectExec =
+ new AsyncRedirectExec(routePlanner, new org.apache.hc.client5.http.impl.DefaultRedirectStrategy());
+ final AsyncExecChain chain = Mockito.mock(AsyncExecChain.class);
+
+ final HttpHost target = new HttpHost("localhost", 80);
+ final HttpRoute route = new HttpRoute(target);
+
+ final URI targetUri = new URI("http://localhost:80/stuff");
+ final HttpRequest request = BasicRequestBuilder.post().setUri(targetUri).build();
+
+ final AsyncEntityProducer entityProducer =
+ new StringAsyncEntityProducer("stuff", org.apache.hc.core5.http.ContentType.TEXT_PLAIN);
+
+ final HttpClientContext context = HttpClientContext.create();
+ context.setRequestConfig(RequestConfig.custom()
+ .setRedirectsEnabled(true)
+ .setRedirectMethodPolicy(RedirectMethodPolicy.PRESERVE_METHOD)
+ .build());
+
+ final AsyncExecRuntime execRuntime = Mockito.mock(AsyncExecRuntime.class);
+ final CancellableDependency dependency = Mockito.mock(CancellableDependency.class);
+ final AtomicInteger execCount = new AtomicInteger(0);
+
+ final AsyncExecChain.Scope scope = new AsyncExecChain.Scope(
+ "test",
+ route,
+ request,
+ dependency, // not null
+ context,
+ execRuntime,
+ /* scheduler */ null,
+ execCount); // AtomicInteger, not int
+
+ final List seen = new ArrayList<>();
+ final AtomicInteger call = new AtomicInteger(0);
+
+ Mockito.doAnswer(inv -> {
+ final HttpRequest req = inv.getArgument(0);
+ final AsyncExecCallback cb = inv.getArgument(3);
+ seen.add(req);
+
+ if (call.getAndIncrement() == 0) {
+ // First hop: 301 with Location to the same authority
+ final HttpResponse r1 = ClassicResponseBuilder
+ .create(HttpStatus.SC_MOVED_PERMANENTLY)
+ .addHeader(HttpHeaders.LOCATION, "http://localhost:80/other-stuff")
+ .build();
+ cb.handleResponse(r1, /* entity details */ null);
+ cb.completed();
+ } else {
+ // Second hop: final 200 OK
+ final HttpResponse r2 = ClassicResponseBuilder
+ .create(HttpStatus.SC_OK)
+ .build();
+ cb.handleResponse(r2, null);
+ cb.completed();
+ }
+ return null;
+ }).when(chain).proceed(
+ Mockito.any(HttpRequest.class),
+ Mockito.any(AsyncEntityProducer.class),
+ Mockito.any(AsyncExecChain.Scope.class),
+ Mockito.any(AsyncExecCallback.class));
+
+ // Act
+ redirectExec.execute(request, entityProducer, scope, chain, new AsyncExecCallback() {
+ @Override
+ public AsyncDataConsumer handleResponse(final HttpResponse response,
+ final EntityDetails entityDetails) {
+ return null; // no-op
+ }
+
+ @Override
+ public void handleInformationResponse(final HttpResponse response) {
+ }
+
+ @Override
+ public void completed() {
+ }
+
+ @Override
+ public void failed(final Exception cause) {
+ throw new AssertionError(cause);
+ }
+ });
+
+ // Assert
+ Assertions.assertEquals(2, seen.size(), "Expected two chain.proceed() calls");
+ final HttpRequest first = seen.get(0);
+ final HttpRequest second = seen.get(1);
+ Assertions.assertEquals("POST", first.getMethod(), "first hop should be POST");
+ Assertions.assertEquals("POST", second.getMethod(), "redirected hop should preserve POST");
+ }
+}
diff --git a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/classic/TestRedirectExec.java b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/classic/TestRedirectExec.java
index 45889442ec..9108ff1aa5 100644
--- a/httpclient5/src/test/java/org/apache/hc/client5/http/impl/classic/TestRedirectExec.java
+++ b/httpclient5/src/test/java/org/apache/hc/client5/http/impl/classic/TestRedirectExec.java
@@ -437,4 +437,51 @@ static ClassicHttpRequest matchesRequestUri(final URI requestUri) {
}
+ @Test
+ void testPostMovedPermanentlyPreserveMethod() throws Exception {
+ final HttpRoute route = new HttpRoute(target);
+ final URI targetUri = new URI("http://localhost:80/stuff");
+ final ClassicHttpRequest request = ClassicRequestBuilder.post()
+ .setUri(targetUri)
+ .setEntity("stuff")
+ .build();
+
+ final HttpClientContext context = HttpClientContext.create();
+ context.setRequestConfig(RequestConfig.custom()
+ .setRedirectsEnabled(true)
+ .setRedirectMethodPolicy(org.apache.hc.client5.http.config.RedirectMethodPolicy.PRESERVE_METHOD)
+ .build());
+
+ final URI redirect1 = new URI("http://localhost:80/other-stuff");
+ final ClassicHttpResponse response1 = ClassicResponseBuilder.create(HttpStatus.SC_MOVED_PERMANENTLY)
+ .addHeader(HttpHeaders.LOCATION, redirect1.toASCIIString())
+ .build();
+ final ClassicHttpResponse response2 = ClassicResponseBuilder.create(HttpStatus.SC_OK).build();
+
+ Mockito.when(chain.proceed(
+ HttpRequestMatcher.matchesRequestUri(targetUri),
+ ArgumentMatchers.any())).thenReturn(response1);
+ Mockito.when(chain.proceed(
+ HttpRequestMatcher.matchesRequestUri(redirect1),
+ ArgumentMatchers.any())).thenReturn(response2);
+
+ final ExecChain.Scope scope = new ExecChain.Scope("test", route, request, endpoint, context);
+ final ClassicHttpResponse finalResponse = redirectExec.execute(request, scope, chain);
+ Assertions.assertEquals(200, finalResponse.getCode());
+
+ final ArgumentCaptor reqCaptor = ArgumentCaptor.forClass(ClassicHttpRequest.class);
+ Mockito.verify(chain, Mockito.times(2)).proceed(reqCaptor.capture(), ArgumentMatchers.any());
+
+ final List allValues = reqCaptor.getAllValues();
+ Assertions.assertNotNull(allValues);
+ Assertions.assertEquals(2, allValues.size());
+ final ClassicHttpRequest request1 = allValues.get(0);
+ final ClassicHttpRequest request2 = allValues.get(1);
+ Assertions.assertSame(request, request1);
+ Assertions.assertEquals("POST", request1.getMethod());
+ Assertions.assertEquals("POST", request2.getMethod());
+ Assertions.assertNotNull(request2.getEntity());
+ }
+
+
}