Apache5HttpTransportTest.java

/*
 * Copyright 2019 Google LLC
 *
 * Licensed 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.
 */

package com.google.api.client.http.apache.v5;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import static org.junit.Assume.assumeTrue;

import com.google.api.client.http.GenericUrl;
import com.google.api.client.http.HttpResponseException;
import com.google.api.client.http.HttpTransport;
import com.google.api.client.http.LowLevelHttpResponse;
import com.google.api.client.util.ByteArrayStreamingContent;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import org.apache.hc.client5.http.ConnectTimeoutException;
import org.apache.hc.client5.http.HttpHostConnectException;
import org.apache.hc.client5.http.classic.HttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.core5.http.ClassicHttpRequest;
import org.apache.hc.core5.http.ClassicHttpResponse;
import org.apache.hc.core5.http.ContentType;
import org.apache.hc.core5.http.EntityDetails;
import org.apache.hc.core5.http.Header;
import org.apache.hc.core5.http.HttpException;
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.HttpRequestInterceptor;
import org.apache.hc.core5.http.HttpRequestMapper;
import org.apache.hc.core5.http.HttpResponse;
import org.apache.hc.core5.http.HttpStatus;
import org.apache.hc.core5.http.impl.bootstrap.HttpServer;
import org.apache.hc.core5.http.impl.io.HttpRequestExecutor;
import org.apache.hc.core5.http.impl.io.HttpService;
import org.apache.hc.core5.http.io.HttpClientConnection;
import org.apache.hc.core5.http.io.HttpRequestHandler;
import org.apache.hc.core5.http.io.entity.ByteArrayEntity;
import org.apache.hc.core5.http.io.support.BasicHttpServerRequestHandler;
import org.apache.hc.core5.http.protocol.HttpContext;
import org.apache.hc.core5.http.protocol.HttpProcessor;
import org.junit.Assert;
import org.junit.Test;

/** Tests {@link Apache5HttpTransport}. */
public class Apache5HttpTransportTest {

  @Test
  public void testApacheHttpTransport() {
    Apache5HttpTransport transport = new Apache5HttpTransport();
    checkHttpTransport(transport);
    assertFalse(transport.isMtls());
  }

  @Test
  public void testApacheHttpTransportWithParam() {
    Apache5HttpTransport transport = new Apache5HttpTransport(HttpClients.custom().build(), true);
    checkHttpTransport(transport);
    assertTrue(transport.isMtls());
  }

  @Test
  public void testNewDefaultHttpClient() {
    HttpClient client = Apache5HttpTransport.newDefaultHttpClient();
    checkHttpClient(client);
  }

  private void checkHttpTransport(Apache5HttpTransport transport) {
    assertNotNull(transport);
    HttpClient client = transport.getHttpClient();
    checkHttpClient(client);
  }

  private void checkHttpClient(HttpClient client) {
    assertNotNull(client);
    // TODO(chingor): Is it possible to test this effectively? The newer HttpClient implementations
    // are read-only and we're testing that we built the client with the right configuration
  }

  @Test
  public void testRequestsWithContent() throws IOException {
    // This test confirms that we can set the content on any type of request
    HttpClient mockClient =
        new MockHttpClient() {
          @Override
          public ClassicHttpResponse executeOpen(
              HttpHost target, ClassicHttpRequest request, HttpContext context) {
            return new MockClassicHttpResponse();
          }
        };
    Apache5HttpTransport transport = new Apache5HttpTransport(mockClient);

    // Test GET.
    execute(transport.buildRequest("GET", "http://www.test.url"));
    // Test DELETE.
    execute(transport.buildRequest("DELETE", "http://www.test.url"));
    // Test HEAD.
    execute(transport.buildRequest("HEAD", "http://www.test.url"));

    // Test PATCH.
    execute(transport.buildRequest("PATCH", "http://www.test.url"));
    // Test PUT.
    execute(transport.buildRequest("PUT", "http://www.test.url"));
    // Test POST.
    execute(transport.buildRequest("POST", "http://www.test.url"));
    // Test PATCH.
    execute(transport.buildRequest("PATCH", "http://www.test.url"));
  }

  private void execute(Apache5HttpRequest request) throws IOException {
    byte[] bytes = "abc".getBytes(StandardCharsets.UTF_8);
    request.setStreamingContent(new ByteArrayStreamingContent(bytes));
    request.setContentType("text/html");
    request.setContentLength(bytes.length);
    request.execute();
  }

  @Test
  public void testRequestShouldNotFollowRedirects() throws IOException {
    final AtomicInteger requestsAttempted = new AtomicInteger(0);
    HttpRequestExecutor requestExecutor =
        new HttpRequestExecutor() {
          @Override
          public ClassicHttpResponse execute(
              ClassicHttpRequest request, HttpClientConnection connection, HttpContext context)
              throws IOException, HttpException {
            ClassicHttpResponse response = new MockClassicHttpResponse();
            response.setCode(302);
            response.setReasonPhrase(null);
            response.addHeader("location", "https://google.com/path");
            response.addHeader(HttpHeaders.SET_COOKIE, "");
            requestsAttempted.incrementAndGet();
            return response;
          }
        };
    HttpClient client = HttpClients.custom().setRequestExecutor(requestExecutor).build();
    Apache5HttpTransport transport = new Apache5HttpTransport(client);
    Apache5HttpRequest request = transport.buildRequest("GET", "https://google.com");
    LowLevelHttpResponse response = request.execute();
    assertEquals(1, requestsAttempted.get());
    assertEquals(302, response.getStatusCode());
  }

  @Test
  public void testRequestCanSetHeaders() {
    final AtomicBoolean interceptorCalled = new AtomicBoolean(false);
    HttpClient client =
        HttpClients.custom()
            .addRequestInterceptorFirst(
                new HttpRequestInterceptor() {
                  @Override
                  public void process(
                      HttpRequest request, EntityDetails details, HttpContext context)
                      throws HttpException, IOException {
                    Header header = request.getFirstHeader("foo");
                    assertNotNull("Should have found header", header);
                    assertEquals("bar", header.getValue());
                    interceptorCalled.set(true);
                    throw new IOException("cancelling request");
                  }
                })
            .build();

    Apache5HttpTransport transport = new Apache5HttpTransport(client);
    Apache5HttpRequest request = transport.buildRequest("GET", "https://google.com");
    request.addHeader("foo", "bar");
    try {
      LowLevelHttpResponse response = request.execute();
      fail("should not actually make the request");
    } catch (IOException exception) {
      assertEquals("cancelling request", exception.getMessage());
    }
    assertTrue("Expected to have called our test interceptor", interceptorCalled.get());
  }

  @Test(timeout = 10_000L)
  public void testConnectTimeout() {
    // TODO(chanseok): Java 17 returns an IOException (SocketException: Network is unreachable).
    // Figure out a way to verify connection timeout works on Java 17+.
    assumeTrue(System.getProperty("java.version").compareTo("17") < 0);

    HttpTransport httpTransport = new Apache5HttpTransport();
    GenericUrl url = new GenericUrl("http://google.com:81");
    try {
      httpTransport.createRequestFactory().buildGetRequest(url).setConnectTimeout(100).execute();
      fail("should have thrown an exception");
    } catch (HttpHostConnectException | ConnectTimeoutException expected) {
      // expected
    } catch (IOException e) {
      fail("unexpected IOException: " + e.getClass().getName() + ": " + e.getMessage());
    }
  }

  private static class FakeServer implements AutoCloseable {
    private final HttpServer server;

    FakeServer(final HttpRequestHandler httpHandler) throws IOException {
      HttpRequestMapper<HttpRequestHandler> mapper =
          new HttpRequestMapper<HttpRequestHandler>() {
            @Override
            public HttpRequestHandler resolve(HttpRequest request, HttpContext context)
                throws HttpException {
              return httpHandler;
            };
          };
      server =
          new HttpServer(
              0,
              HttpService.builder()
                  .withHttpProcessor(
                      new HttpProcessor() {
                        @Override
                        public void process(
                            HttpRequest request, EntityDetails entity, HttpContext context)
                            throws HttpException, IOException {}

                        @Override
                        public void process(
                            HttpResponse response, EntityDetails entity, HttpContext context)
                            throws HttpException, IOException {}
                      })
                  .withHttpServerRequestHandler(new BasicHttpServerRequestHandler(mapper))
                  .build(),
              null,
              null,
              null,
              null,
              null,
              null);
      //       server.createContext("/", httpHandler);
      server.start();
    }

    public int getPort() {
      return server.getLocalPort();
    }

    @Override
    public void close() {
      server.initiateShutdown();
    }
  }

  @Test
  public void testNormalizedUrl() throws IOException {
    final HttpRequestHandler handler =
        new HttpRequestHandler() {
          @Override
          public void handle(
              ClassicHttpRequest request, ClassicHttpResponse response, HttpContext context)
              throws HttpException, IOException {
            // Extract the request URI and convert to bytes
            byte[] responseData = request.getRequestUri().getBytes(StandardCharsets.UTF_8);

            // Set the response headers (status code and content length)
            response.setCode(HttpStatus.SC_OK);
            response.setHeader(HttpHeaders.CONTENT_LENGTH, String.valueOf(responseData.length));

            // Set the response entity (body)
            ByteArrayEntity entity = new ByteArrayEntity(responseData, ContentType.TEXT_PLAIN);
            response.setEntity(entity);
          }
        };
    try (FakeServer server = new FakeServer(handler)) {
      HttpTransport transport = new Apache5HttpTransport();
      GenericUrl testUrl = new GenericUrl("http://localhost/foo//bar");
      testUrl.setPort(server.getPort());
      com.google.api.client.http.HttpResponse response =
          transport.createRequestFactory().buildGetRequest(testUrl).execute();
      assertEquals(200, response.getStatusCode());
      assertEquals("/foo//bar", response.parseAsString());
    }
  }

  @Test
  public void testReadErrorStream() throws IOException {
    final HttpRequestHandler handler =
        new HttpRequestHandler() {
          @Override
          public void handle(
              ClassicHttpRequest request, ClassicHttpResponse response, HttpContext context)
              throws HttpException, IOException {
            byte[] responseData = "Forbidden".getBytes(StandardCharsets.UTF_8);
            response.setCode(HttpStatus.SC_FORBIDDEN); // 403 Forbidden
            response.setHeader(HttpHeaders.CONTENT_LENGTH, String.valueOf(responseData.length));
            ByteArrayEntity entity = new ByteArrayEntity(responseData, ContentType.TEXT_PLAIN);
            response.setEntity(entity);
          }
        };
    try (FakeServer server = new FakeServer(handler)) {
      HttpTransport transport = new Apache5HttpTransport();
      GenericUrl testUrl = new GenericUrl("http://localhost/foo//bar");
      testUrl.setPort(server.getPort());
      com.google.api.client.http.HttpRequest getRequest =
          transport.createRequestFactory().buildGetRequest(testUrl);
      getRequest.setThrowExceptionOnExecuteError(false);
      com.google.api.client.http.HttpResponse response = getRequest.execute();
      assertEquals(403, response.getStatusCode());
      assertEquals("Forbidden", response.parseAsString());
    }
  }

  @Test
  public void testReadErrorStream_withException() throws IOException {
    final HttpRequestHandler handler =
        new HttpRequestHandler() {
          @Override
          public void handle(
              ClassicHttpRequest request, ClassicHttpResponse response, HttpContext context)
              throws HttpException, IOException {
            byte[] responseData = "Forbidden".getBytes(StandardCharsets.UTF_8);
            response.setCode(HttpStatus.SC_FORBIDDEN); // 403 Forbidden
            response.setHeader(HttpHeaders.CONTENT_LENGTH, String.valueOf(responseData.length));
            ByteArrayEntity entity = new ByteArrayEntity(responseData, ContentType.TEXT_PLAIN);
            response.setEntity(entity);
          }
        };
    try (FakeServer server = new FakeServer(handler)) {
      HttpTransport transport = new Apache5HttpTransport();
      GenericUrl testUrl = new GenericUrl("http://localhost/foo//bar");
      testUrl.setPort(server.getPort());
      com.google.api.client.http.HttpRequest getRequest =
          transport.createRequestFactory().buildGetRequest(testUrl);
      try {
        getRequest.execute();
        Assert.fail();
      } catch (HttpResponseException ex) {
        assertEquals("Forbidden", ex.getContent());
      }
    }
  }

  private boolean isWindows() {
    return System.getProperty("os.name").startsWith("Windows");
  }
}