blob: 2a3a5e829f7612405a740a2c7ae281a7aceaa2e9 [file] [log] [blame]
/*
* Copyright (C) 2022 The Android Open Source Project
*
* 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.android.adservices.service.common;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.net.Uri;
import com.android.adservices.LogUtil;
import com.android.adservices.service.common.cache.CacheProviderFactory;
import com.android.adservices.service.common.cache.DBCacheEntry;
import com.android.adservices.service.common.cache.HttpCache;
import com.android.adservices.service.profiling.Tracing;
import com.android.internal.annotations.VisibleForTesting;
import com.google.common.base.Charsets;
import com.google.common.base.Preconditions;
import com.google.common.io.ByteStreams;
import com.google.common.util.concurrent.ClosingFuture;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.ListeningExecutorService;
import com.google.common.util.concurrent.MoreExecutors;
import java.io.BufferedInputStream;
import java.io.ByteArrayOutputStream;
import java.io.Closeable;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.SocketTimeoutException;
import java.net.URL;
import java.net.URLConnection;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ExecutorService;
import javax.net.ssl.HttpsURLConnection;
/**
* This is an HTTPS client to be used by the PP API services. The primary uses of this client
* include fetching payloads from ad tech-provided URIs and reporting on generated reporting URLs
* through GET or POST calls.
*/
public class AdServicesHttpsClient {
private final int mConnectTimeoutMs;
private final int mReadTimeoutMs;
private final long mMaxBytes;
private static final int DEFAULT_TIMEOUT_MS = 5000;
// Setting default max content size to 1024 * 1024 which is ~ 1MB
private static final long DEFAULT_MAX_BYTES = 1048576;
private static final String CONTENT_SIZE_ERROR = "Content size exceeds limit!";
private final ListeningExecutorService mExecutorService;
private final UriConverter mUriConverter;
private final HttpCache mCache;
/**
* Create an HTTPS client with the input {@link ExecutorService} and initial connect and read
* timeouts (in milliseconds). Using this constructor does not provide any caching.
*
* @param executorService an {@link ExecutorService} that allows connection and fetching to be
* executed outside the main calling thread
* @param connectTimeoutMs the timeout, in milliseconds, for opening an initial link with to a
* target resource using this client. If set to 0, this timeout is interpreted as infinite
* (see {@link URLConnection#setConnectTimeout(int)}).
* @param readTimeoutMs the timeout, in milliseconds, for reading a response from a target
* address using this client. If set to 0, this timeout is interpreted as infinite (see
* {@link URLConnection#setReadTimeout(int)}).
* @param maxBytes The maximum size of an HTTPS response in bytes.
*/
public AdServicesHttpsClient(
ExecutorService executorService,
int connectTimeoutMs,
int readTimeoutMs,
long maxBytes) {
this(
executorService,
connectTimeoutMs,
readTimeoutMs,
maxBytes,
new UriConverter(),
CacheProviderFactory.createNoOpCache());
}
/**
* Create an HTTPS client with the input {@link ExecutorService} and default initial connect and
* read timeouts. This will also contain the default size of an HTTPS response, 1 MB.
*
* @param executorService an {@link ExecutorService} that allows connection and fetching to be
* executed outside the main calling thread
* @param cache A {@link HttpCache} that caches requests and response based on the use case
*/
public AdServicesHttpsClient(
@NonNull ExecutorService executorService, @NonNull HttpCache cache) {
this(
executorService,
DEFAULT_TIMEOUT_MS,
DEFAULT_TIMEOUT_MS,
DEFAULT_MAX_BYTES,
new UriConverter(),
cache);
}
@VisibleForTesting
AdServicesHttpsClient(
ExecutorService executorService,
int connectTimeoutMs,
int readTimeoutMs,
long maxBytes,
UriConverter uriConverter,
@NonNull HttpCache cache) {
mConnectTimeoutMs = connectTimeoutMs;
mReadTimeoutMs = readTimeoutMs;
mExecutorService = MoreExecutors.listeningDecorator(executorService);
mMaxBytes = maxBytes;
mUriConverter = uriConverter;
mCache = cache;
}
/** Opens the Url Connection */
@NonNull
private URLConnection openUrl(@NonNull URL url) throws IOException {
Objects.requireNonNull(url);
return url.openConnection();
}
@NonNull
private HttpsURLConnection setupConnection(@NonNull URL url) throws IOException {
Objects.requireNonNull(url);
// We validated that the URL is https in toUrl
HttpsURLConnection urlConnection = (HttpsURLConnection) openUrl(url);
urlConnection.setConnectTimeout(mConnectTimeoutMs);
urlConnection.setReadTimeout(mReadTimeoutMs);
// Setting true explicitly to follow redirects
urlConnection.setInstanceFollowRedirects(true);
return urlConnection;
}
@NonNull
private HttpsURLConnection setupPostConnectionWithPlainText(URL url) throws IOException {
Objects.requireNonNull(url);
HttpsURLConnection urlConnection = setupConnection(url);
urlConnection.setRequestMethod("POST");
urlConnection.setRequestProperty("Content-Type", "text/plain");
urlConnection.setDoOutput(true);
return urlConnection;
}
/**
* Performs a GET request on the given URI in order to fetch a payload.
*
* @param uri a {@link Uri} pointing to a target server, converted to a URL for fetching
* @return a string containing the fetched payload
*/
@NonNull
public ListenableFuture<String> fetchPayload(@NonNull Uri uri) {
// TODO(b/260042942) : Refactor to let the Cache decide if caching should be used or not
return fetchPayload(uri, false);
}
/**
* Performs a GET request on the given URI in order to fetch a payload.
*
* @param uri a {@link Uri} pointing to a target server, converted to a URL for fetching
* @param useCache the intent to use cache for storing & retrieving results
* @return a string containing the fetched payload
*/
@NonNull
public ListenableFuture<String> fetchPayload(@NonNull Uri uri, boolean useCache) {
Objects.requireNonNull(uri);
return ClosingFuture.from(mExecutorService.submit(() -> mUriConverter.toUrl(uri)))
.transformAsync(
(closer, url) ->
ClosingFuture.from(
mExecutorService.submit(
() -> doFetchPayload(url, closer, useCache))),
mExecutorService)
.finishToFuture();
}
private String doFetchPayload(
@NonNull URL url, @NonNull ClosingFuture.DeferredCloser closer, boolean useCache)
throws IOException {
int traceCookie = Tracing.beginAsyncSection(Tracing.FETCH_PAYLOAD);
LogUtil.v("Downloading payload from: \"%s\"", url.toString());
if (useCache) {
DBCacheEntry cachedEntry = mCache.get(url);
if (cachedEntry != null) {
LogUtil.v("Cache hit for url: %s", url.toString());
return cachedEntry.getResponseBody();
}
LogUtil.v("Cache miss for url: %s", url.toString());
}
int httpTraceCookie = Tracing.beginAsyncSection(Tracing.HTTP_REQUEST);
HttpsURLConnection urlConnection;
try {
urlConnection = setupConnection(url);
} catch (IOException e) {
LogUtil.d(e, "Failed to open URL");
throw new IllegalArgumentException("Failed to open URL!");
}
InputStream inputStream = null;
try {
// TODO(b/237342352): Both connect and read timeouts are kludged in this method and if
// necessary need to be separated
Map<String, List<String>> requestPropertiesMap = urlConnection.getRequestProperties();
inputStream = new BufferedInputStream(urlConnection.getInputStream());
closer.eventuallyClose(new CloseableConnectionWrapper(urlConnection), mExecutorService);
int responseCode = urlConnection.getResponseCode();
if (isSuccessfulResponse(responseCode)) {
String responseBody =
fromInputStream(inputStream, urlConnection.getContentLengthLong());
if (useCache) {
LogUtil.v("Putting data in cache for url: %s", url);
mCache.put(url, responseBody, requestPropertiesMap);
}
return responseBody;
} else {
throwError(urlConnection, responseCode);
return null;
}
} catch (SocketTimeoutException e) {
throw new IOException("Connection timed out while reading response!", e);
} finally {
maybeDisconnect(urlConnection);
maybeClose(inputStream);
Tracing.endAsyncSection(Tracing.HTTP_REQUEST, httpTraceCookie);
Tracing.endAsyncSection(Tracing.FETCH_PAYLOAD, traceCookie);
}
}
/**
* Performs a GET request on a Uri without reading the response.
*
* @param uri The URI to perform the GET request on.
*/
public ListenableFuture<Void> getAndReadNothing(@NonNull Uri uri) {
Objects.requireNonNull(uri);
return ClosingFuture.from(mExecutorService.submit(() -> mUriConverter.toUrl(uri)))
.transformAsync(
(closer, url) ->
ClosingFuture.from(
mExecutorService.submit(
() -> doGetAndReadNothing(url, closer))),
mExecutorService)
.finishToFuture();
}
private Void doGetAndReadNothing(@NonNull URL url, @NonNull ClosingFuture.DeferredCloser closer)
throws IOException {
LogUtil.v("Reporting to: \"%s\"", url.toString());
HttpsURLConnection urlConnection;
try {
urlConnection = setupConnection(url);
} catch (IOException e) {
LogUtil.d(e, "Failed to open URL");
throw new IllegalArgumentException("Failed to open URL!");
}
try {
// TODO(b/237342352): Both connect and read timeouts are kludged in this method and if
// necessary need to be separated
closer.eventuallyClose(new CloseableConnectionWrapper(urlConnection), mExecutorService);
int responseCode = urlConnection.getResponseCode();
if (isSuccessfulResponse(responseCode)) {
LogUtil.d("GET request succeeded for URL: " + url);
} else {
LogUtil.d("GET request failed for URL: " + url);
throwError(urlConnection, responseCode);
}
return null;
} catch (SocketTimeoutException e) {
throw new IOException("Connection timed out while reading response!", e);
} finally {
maybeDisconnect(urlConnection);
}
}
/**
* Performs a POST request on a Uri and attaches {@code String} to the request
*
* @param uri to do the POST request on
* @param requestBody Attached to the POST request.
*/
public ListenableFuture<Void> postPlainText(@NonNull Uri uri, @NonNull String requestBody) {
Objects.requireNonNull(uri);
Objects.requireNonNull(requestBody);
return ClosingFuture.from(mExecutorService.submit(() -> mUriConverter.toUrl(uri)))
.transformAsync(
(closer, url) ->
ClosingFuture.from(
mExecutorService.submit(
() -> doPostPlainText(url, requestBody, closer))),
mExecutorService)
.finishToFuture();
}
private Void doPostPlainText(URL url, String data, ClosingFuture.DeferredCloser closer)
throws IOException {
LogUtil.v("Reporting to: \"%s\"", url.toString());
HttpsURLConnection urlConnection;
try {
urlConnection = setupPostConnectionWithPlainText(url);
} catch (IOException e) {
LogUtil.d(e, "Failed to open URL");
throw new IllegalArgumentException("Failed to open URL!");
}
try {
// TODO(b/237342352): Both connect and read timeouts are kludged in this method and if
// necessary need to be separated
closer.eventuallyClose(new CloseableConnectionWrapper(urlConnection), mExecutorService);
OutputStream os = urlConnection.getOutputStream();
OutputStreamWriter osw = new OutputStreamWriter(os, StandardCharsets.UTF_8);
osw.write(data);
osw.flush();
osw.close();
int responseCode = urlConnection.getResponseCode();
if (isSuccessfulResponse(responseCode)) {
LogUtil.d("POST request succeeded for URL: " + url);
} else {
LogUtil.d("POST request failed for URL: " + url);
throwError(urlConnection, responseCode);
}
return null;
} catch (SocketTimeoutException e) {
throw new IOException("Connection timed out while reading response!", e);
} finally {
maybeDisconnect(urlConnection);
}
}
private void throwError(final HttpsURLConnection urlConnection, int responseCode)
throws IOException {
InputStream errorStream = urlConnection.getErrorStream();
if (!Objects.isNull(errorStream)) {
String errorMessage =
fromInputStream(
urlConnection.getErrorStream(), urlConnection.getContentLengthLong());
String exceptionMessage =
String.format(
Locale.US,
"Server returned an error with code %d and message:" + " %s",
responseCode,
errorMessage);
LogUtil.d(exceptionMessage);
throw new IOException(exceptionMessage);
} else {
String exceptionMessage =
String.format(
Locale.US,
"Server returned an error with code %d and null" + " message",
responseCode);
LogUtil.d(exceptionMessage);
throw new IOException(exceptionMessage);
}
}
private static void maybeDisconnect(@Nullable URLConnection urlConnection) {
if (urlConnection == null) {
return;
}
if (urlConnection instanceof HttpURLConnection) {
HttpURLConnection httpUrlConnection = (HttpURLConnection) urlConnection;
httpUrlConnection.disconnect();
} else {
LogUtil.d("Not closing URLConnection of type %s", urlConnection.getClass());
}
}
private static void maybeClose(@Nullable InputStream inputStream) throws IOException {
if (inputStream == null) {
return;
} else {
inputStream.close();
}
}
/**
* @return the connection timeout, in milliseconds, when opening an initial link to a target
* address with this client
*/
public int getConnectTimeoutMs() {
return mConnectTimeoutMs;
}
/**
* @return the read timeout, in milliseconds, when reading the response from a target address
* with this client
*/
public int getReadTimeoutMs() {
return mReadTimeoutMs;
}
/**
* @return true if responseCode matches 2.*, i.e. 200, 204, 206
*/
public static boolean isSuccessfulResponse(int responseCode) {
return (responseCode / 100) == 2;
}
/**
* Reads a {@link InputStream} and returns a {@code String}. To enforce content size limits, we
* employ the following strategy: 1. If {@link URLConnection} cannot determine the content size,
* we invoke {@code manualStreamToString(InputStream)} where we manually apply the content
* restriction. 2. Otherwise, we invoke {@code streamToString(InputStream, long)}.
*
* @throws IOException if content size limit of is exceeded
*/
@NonNull
private String fromInputStream(@NonNull InputStream in, long size) throws IOException {
Objects.requireNonNull(in);
if (size == 0) {
return "";
} else if (size < 0) {
return manualStreamToString(in);
} else {
return streamToString(in, size);
}
}
@NonNull
private String streamToString(@NonNull InputStream in, long size) throws IOException {
Objects.requireNonNull(in);
if (size > mMaxBytes) {
throw new IOException(CONTENT_SIZE_ERROR);
}
return new String(ByteStreams.toByteArray(in), Charsets.UTF_8);
}
@NonNull
private String manualStreamToString(@NonNull InputStream in) throws IOException {
Objects.requireNonNull(in);
ByteArrayOutputStream into = new ByteArrayOutputStream();
byte[] buf = new byte[1024];
long total = 0;
for (int n; 0 < (n = in.read(buf)); ) {
total += n;
if (total <= mMaxBytes) {
into.write(buf, 0, n);
} else {
into.close();
throw new IOException(CONTENT_SIZE_ERROR);
}
}
into.close();
return into.toString("UTF-8");
}
private static class CloseableConnectionWrapper implements Closeable {
@Nullable final HttpsURLConnection mURLConnection;
private CloseableConnectionWrapper(HttpsURLConnection urlConnection) {
mURLConnection = urlConnection;
}
@Override
public void close() throws IOException {
LogUtil.d("Closing HTTPS connection and streams");
maybeClose(mURLConnection.getInputStream());
maybeClose(mURLConnection.getErrorStream());
maybeDisconnect(mURLConnection);
}
}
/** A light-weight class to convert Uri to URL */
public static final class UriConverter {
@NonNull
URL toUrl(@NonNull Uri uri) {
Objects.requireNonNull(uri);
Preconditions.checkArgument(
ValidatorUtil.HTTPS_SCHEME.equalsIgnoreCase(uri.getScheme()),
"URI \"%s\" must use HTTPS",
uri.toString());
URL url;
try {
url = new URL(uri.toString());
} catch (MalformedURLException e) {
LogUtil.d(e, "Uri is malformed! ");
throw new IllegalArgumentException("Uri is malformed!");
}
return url;
}
}
/** @return the cache associated with this instance of client */
public HttpCache getAssociatedCache() {
return mCache;
}
}