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

+ * + * + *

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()); + } + + }