blob: 466d6d2ceb6193087bbdffa45fb13b8cd718554f [file] [log] [blame]
/*
* Copyright 2022 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.android.libraries.mobiledatadownload.testing;
import android.net.Uri;
import android.util.Log;
import com.google.common.base.Optional;
import java.io.File;
import java.io.IOException;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.atomic.AtomicBoolean;
import org.apache.http.Header;
import org.apache.http.HttpException;
import org.apache.http.HttpRequest;
import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.entity.FileEntity;
import org.apache.http.impl.DefaultConnectionReuseStrategy;
import org.apache.http.impl.DefaultHttpResponseFactory;
import org.apache.http.impl.DefaultHttpServerConnection;
import org.apache.http.params.BasicHttpParams;
import org.apache.http.params.CoreConnectionPNames;
import org.apache.http.params.HttpParams;
import org.apache.http.protocol.BasicHttpContext;
import org.apache.http.protocol.BasicHttpProcessor;
import org.apache.http.protocol.HttpContext;
import org.apache.http.protocol.HttpRequestHandler;
import org.apache.http.protocol.HttpRequestHandlerRegistry;
import org.apache.http.protocol.HttpService;
/** TestHttpServer is a simple http server that listens to http requests on a single thread. */
public final class TestHttpServer {
private static final String TAG = "TestHttpServer";
private static final String TEST_HOST = "localhost";
private static final String HEAD_REQUEST_METHOD = "HEAD";
private static final String ETAG_HEADER = "ETag";
private static final String IF_NONE_MATCH_HEADER = "If-None-Match";
private static final String BINARY_CONTENT_TYPE = "application/binary";
private static final String PROTO_CONTENT_TYPE = "application/x-protobuf";
private static final String TEXT_CONTENT_TYPE = "text/plain";
private final HttpParams httpParams = new BasicHttpParams();
private final HttpService httpService;
private final HttpRequestHandlerRegistry registry;
private final AtomicBoolean finished = new AtomicBoolean();
private Thread serverThread;
private ServerSocket serverSocket;
// 0 means user didn't specify a port number and will use automatically assigned port.
private final int userDesignatedPort;
public TestHttpServer() {
this(0);
}
public TestHttpServer(int portNumber) {
userDesignatedPort = portNumber;
httpParams.setBooleanParameter(CoreConnectionPNames.TCP_NODELAY, true);
registry = new HttpRequestHandlerRegistry();
httpService =
new HttpService(
new BasicHttpProcessor(),
new DefaultConnectionReuseStrategy(),
new DefaultHttpResponseFactory());
httpService.setHandlerResolver(registry);
httpService.setParams(httpParams);
}
/** Registers a handler for an endpoint pattern. */
public void registerHandler(String pattern, HttpRequestHandler handler) {
registry.register(pattern, handler);
}
/** Registers a handler that binds onto a text file for an endpoint pattern. */
public void registerTextFile(String pattern, String filepath) {
registerFile(pattern, filepath, TEXT_CONTENT_TYPE, /* eTagOptional = */ Optional.absent());
}
/** Registers a handler that binds onto a file for an endpoint pattern. */
public void registerBinaryFile(String pattern, String filepath) {
registerFile(pattern, filepath, BINARY_CONTENT_TYPE, /*eTagOptional=*/ Optional.absent());
}
/**
* Registers a handler that binds onto a proto file for an endpoint pattern with the specified
* ETag.
*/
public void registerProtoFileWithETag(String pattern, String filepath, String eTag) {
registerFile(pattern, filepath, PROTO_CONTENT_TYPE, Optional.of(eTag));
}
private void registerFile(
String pattern, String filepath, String contentType, Optional<String> eTagOptional) {
registerHandler(
pattern,
(httpRequest, httpResponse, httpContext) -> {
if (eTagOptional.isPresent()) {
String eTag = eTagOptional.get();
httpResponse.addHeader(ETAG_HEADER, eTag);
setHttpStatusCode(httpRequest, httpResponse, eTag);
if (httpResponse.getStatusLine().getStatusCode() == HttpStatus.SC_NOT_MODIFIED
|| HEAD_REQUEST_METHOD.equals(httpRequest.getRequestLine().getMethod())) {
return;
}
} else { // The ETag is not present.
httpResponse.setStatusCode(HttpStatus.SC_OK);
}
File file = new File(filepath);
httpResponse.setEntity(new FileEntity(file, contentType));
});
}
/** Starts the test http server and returns the prefix of the test url. */
public Uri.Builder startServer() throws IOException {
serverSocket =
new ServerSocket(
/*port=*/ userDesignatedPort, /*backlog=*/ 0, InetAddress.getByName(TEST_HOST));
serverThread =
new Thread(
() -> {
try {
while (!finished.get()) {
Socket socket = serverSocket.accept();
handleRequest(socket);
}
} catch (IOException e) {
Log.e(TAG, "Exception: " + e);
}
});
serverThread.start();
return getTestUrlPrefix();
}
public void stopServer() {
try {
finished.set(true);
serverSocket.close();
serverThread.join();
} catch (IOException | InterruptedException e) {
Log.e(TAG, "Exception when stopping server: " + e);
}
}
private void handleRequest(Socket socket) {
DefaultHttpServerConnection connection = new DefaultHttpServerConnection();
try {
connection.bind(socket, httpParams);
HttpContext httpContext = new BasicHttpContext();
httpService.handleRequest(connection, httpContext);
} catch (IOException | HttpException e) {
Log.e(TAG, "Unexpected exception while processing request " + e);
} finally {
try {
connection.shutdown();
} catch (IOException e) {
// Ignore.
}
}
}
private Uri.Builder getTestUrlPrefix() {
String authority = TEST_HOST + ":" + serverSocket.getLocalPort();
return new Uri.Builder().scheme("http").encodedAuthority(authority);
}
private static void setHttpStatusCode(
HttpRequest httpRequest, HttpResponse httpResponse, String eTag) {
Header[] headers = httpRequest.getAllHeaders();
// We use `If-None-Match` header and ETag to detect whether the file has been changed since the
// last sync. If the ETag from client matches the one at server, the file is not changed and
// HttpStatus.SC_NOT_MODIFIED is returned; otherwise, the file is changed and HttpStatus.SC_OK
// is returned.
for (Header header : headers) {
// Find the `If-None-Match` header.
if (!IF_NONE_MATCH_HEADER.equals(header.getName())) {
continue;
}
httpResponse.setStatusCode(
eTag.equals(header.getValue()) ? HttpStatus.SC_NOT_MODIFIED : HttpStatus.SC_OK);
return;
}
httpResponse.setStatusCode(HttpStatus.SC_OK);
}
}