diff options
Diffstat (limited to 'cronet/src')
3 files changed, 1017 insertions, 0 deletions
diff --git a/cronet/src/main/AndroidManifest.xml b/cronet/src/main/AndroidManifest.xml new file mode 100644 index 0000000..0dec093 --- /dev/null +++ b/cronet/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest package="com.android.volley.cronet" /> diff --git a/cronet/src/main/java/com/android/volley/cronet/CronetHttpStack.java b/cronet/src/main/java/com/android/volley/cronet/CronetHttpStack.java new file mode 100644 index 0000000..874029b --- /dev/null +++ b/cronet/src/main/java/com/android/volley/cronet/CronetHttpStack.java @@ -0,0 +1,634 @@ +/* + * Copyright (C) 2020 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.volley.cronet; + +import android.content.Context; +import android.text.TextUtils; +import android.util.Base64; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import com.android.volley.AuthFailureError; +import com.android.volley.Header; +import com.android.volley.Request; +import com.android.volley.RequestTask; +import com.android.volley.VolleyLog; +import com.android.volley.toolbox.AsyncHttpStack; +import com.android.volley.toolbox.ByteArrayPool; +import com.android.volley.toolbox.HttpHeaderParser; +import com.android.volley.toolbox.HttpResponse; +import com.android.volley.toolbox.PoolingByteArrayOutputStream; +import com.android.volley.toolbox.UrlRewriter; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.nio.ByteBuffer; +import java.nio.channels.Channels; +import java.nio.channels.WritableByteChannel; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import org.chromium.net.CronetEngine; +import org.chromium.net.CronetException; +import org.chromium.net.UploadDataProvider; +import org.chromium.net.UploadDataProviders; +import org.chromium.net.UrlRequest; +import org.chromium.net.UrlRequest.Callback; +import org.chromium.net.UrlResponseInfo; + +/** + * A {@link AsyncHttpStack} that's based on Cronet's fully asynchronous API for network requests. + * + * <p><b>WARNING</b>: This API is experimental and subject to breaking changes. Please see + * https://github.com/google/volley/wiki/Asynchronous-Volley for more details. + */ +public class CronetHttpStack extends AsyncHttpStack { + + private final CronetEngine mCronetEngine; + private final ByteArrayPool mPool; + private final UrlRewriter mUrlRewriter; + private final RequestListener mRequestListener; + + // cURL logging support + private final boolean mCurlLoggingEnabled; + private final CurlCommandLogger mCurlCommandLogger; + private final boolean mLogAuthTokensInCurlCommands; + + private CronetHttpStack( + CronetEngine cronetEngine, + ByteArrayPool pool, + UrlRewriter urlRewriter, + RequestListener requestListener, + boolean curlLoggingEnabled, + CurlCommandLogger curlCommandLogger, + boolean logAuthTokensInCurlCommands) { + mCronetEngine = cronetEngine; + mPool = pool; + mUrlRewriter = urlRewriter; + mRequestListener = requestListener; + mCurlLoggingEnabled = curlLoggingEnabled; + mCurlCommandLogger = curlCommandLogger; + mLogAuthTokensInCurlCommands = logAuthTokensInCurlCommands; + + mRequestListener.initialize(this); + } + + @Override + public void executeRequest( + final Request<?> request, + final Map<String, String> additionalHeaders, + final OnRequestComplete callback) { + if (getBlockingExecutor() == null || getNonBlockingExecutor() == null) { + throw new IllegalStateException("Must set blocking and non-blocking executors"); + } + final Callback urlCallback = + new Callback() { + PoolingByteArrayOutputStream bytesReceived = null; + WritableByteChannel receiveChannel = null; + + @Override + public void onRedirectReceived( + UrlRequest urlRequest, + UrlResponseInfo urlResponseInfo, + String newLocationUrl) { + urlRequest.followRedirect(); + } + + @Override + public void onResponseStarted( + UrlRequest urlRequest, UrlResponseInfo urlResponseInfo) { + bytesReceived = + new PoolingByteArrayOutputStream( + mPool, getContentLength(urlResponseInfo)); + receiveChannel = Channels.newChannel(bytesReceived); + urlRequest.read(ByteBuffer.allocateDirect(1024)); + } + + @Override + public void onReadCompleted( + UrlRequest urlRequest, + UrlResponseInfo urlResponseInfo, + ByteBuffer byteBuffer) { + byteBuffer.flip(); + try { + receiveChannel.write(byteBuffer); + byteBuffer.clear(); + urlRequest.read(byteBuffer); + } catch (IOException e) { + urlRequest.cancel(); + callback.onError(e); + } + } + + @Override + public void onSucceeded( + UrlRequest urlRequest, UrlResponseInfo urlResponseInfo) { + List<Header> headers = getHeaders(urlResponseInfo.getAllHeadersAsList()); + HttpResponse response = + new HttpResponse( + urlResponseInfo.getHttpStatusCode(), + headers, + bytesReceived.toByteArray()); + callback.onSuccess(response); + } + + @Override + public void onFailed( + UrlRequest urlRequest, + UrlResponseInfo urlResponseInfo, + CronetException e) { + callback.onError(e); + } + }; + + String url = request.getUrl(); + String rewritten = mUrlRewriter.rewriteUrl(url); + if (rewritten == null) { + callback.onError(new IOException("URL blocked by rewriter: " + url)); + return; + } + url = rewritten; + + // We can call allowDirectExecutor here and run directly on the network thread, since all + // the callbacks are non-blocking. + final UrlRequest.Builder builder = + mCronetEngine + .newUrlRequestBuilder(url, urlCallback, getNonBlockingExecutor()) + .allowDirectExecutor() + .disableCache() + .setPriority(getPriority(request)); + // request.getHeaders() may be blocking, so submit it to the blocking executor. + getBlockingExecutor() + .execute( + new SetUpRequestTask<>(request, url, builder, additionalHeaders, callback)); + } + + private class SetUpRequestTask<T> extends RequestTask<T> { + UrlRequest.Builder builder; + String url; + Map<String, String> additionalHeaders; + OnRequestComplete callback; + Request<T> request; + + SetUpRequestTask( + Request<T> request, + String url, + UrlRequest.Builder builder, + Map<String, String> additionalHeaders, + OnRequestComplete callback) { + super(request); + // Note that this URL may be different from Request#getUrl() due to the UrlRewriter. + this.url = url; + this.builder = builder; + this.additionalHeaders = additionalHeaders; + this.callback = callback; + this.request = request; + } + + @Override + public void run() { + try { + mRequestListener.onRequestPrepared(request, builder); + CurlLoggedRequestParameters requestParameters = new CurlLoggedRequestParameters(); + setHttpMethod(requestParameters, request); + setRequestHeaders(requestParameters, request, additionalHeaders); + requestParameters.applyToRequest(builder, getNonBlockingExecutor()); + UrlRequest urlRequest = builder.build(); + if (mCurlLoggingEnabled) { + mCurlCommandLogger.logCurlCommand(generateCurlCommand(url, requestParameters)); + } + urlRequest.start(); + } catch (AuthFailureError authFailureError) { + callback.onAuthError(authFailureError); + } + } + } + + @VisibleForTesting + public static List<Header> getHeaders(List<Map.Entry<String, String>> headersList) { + List<Header> headers = new ArrayList<>(); + for (Map.Entry<String, String> header : headersList) { + headers.add(new Header(header.getKey(), header.getValue())); + } + return headers; + } + + /** Sets the connection parameters for the UrlRequest */ + private void setHttpMethod(CurlLoggedRequestParameters requestParameters, Request<?> request) + throws AuthFailureError { + switch (request.getMethod()) { + case Request.Method.DEPRECATED_GET_OR_POST: + // This is the deprecated way that needs to be handled for backwards compatibility. + // If the request's post body is null, then the assumption is that the request is + // GET. Otherwise, it is assumed that the request is a POST. + byte[] postBody = request.getPostBody(); + if (postBody != null) { + requestParameters.setHttpMethod("POST"); + addBodyIfExists(requestParameters, request.getPostBodyContentType(), postBody); + } else { + requestParameters.setHttpMethod("GET"); + } + break; + case Request.Method.GET: + // Not necessary to set the request method because connection defaults to GET but + // being explicit here. + requestParameters.setHttpMethod("GET"); + break; + case Request.Method.DELETE: + requestParameters.setHttpMethod("DELETE"); + break; + case Request.Method.POST: + requestParameters.setHttpMethod("POST"); + addBodyIfExists(requestParameters, request.getBodyContentType(), request.getBody()); + break; + case Request.Method.PUT: + requestParameters.setHttpMethod("PUT"); + addBodyIfExists(requestParameters, request.getBodyContentType(), request.getBody()); + break; + case Request.Method.HEAD: + requestParameters.setHttpMethod("HEAD"); + break; + case Request.Method.OPTIONS: + requestParameters.setHttpMethod("OPTIONS"); + break; + case Request.Method.TRACE: + requestParameters.setHttpMethod("TRACE"); + break; + case Request.Method.PATCH: + requestParameters.setHttpMethod("PATCH"); + addBodyIfExists(requestParameters, request.getBodyContentType(), request.getBody()); + break; + default: + throw new IllegalStateException("Unknown method type."); + } + } + + /** + * Sets the request headers for the UrlRequest. + * + * @param requestParameters parameters that we are adding the request headers to + * @param request to get the headers from + * @param additionalHeaders for the UrlRequest + * @throws AuthFailureError is thrown if Request#getHeaders throws ones + */ + private void setRequestHeaders( + CurlLoggedRequestParameters requestParameters, + Request<?> request, + Map<String, String> additionalHeaders) + throws AuthFailureError { + requestParameters.putAllHeaders(additionalHeaders); + // Request.getHeaders() takes precedence over the given additional (cache) headers). + requestParameters.putAllHeaders(request.getHeaders()); + } + + /** Sets the UploadDataProvider of the UrlRequest.Builder */ + private void addBodyIfExists( + CurlLoggedRequestParameters requestParameters, + String contentType, + @Nullable byte[] body) { + requestParameters.setBody(contentType, body); + } + + /** Helper method that maps Volley's request priority to Cronet's */ + private int getPriority(Request<?> request) { + switch (request.getPriority()) { + case LOW: + return UrlRequest.Builder.REQUEST_PRIORITY_LOW; + case HIGH: + case IMMEDIATE: + return UrlRequest.Builder.REQUEST_PRIORITY_HIGHEST; + case NORMAL: + default: + return UrlRequest.Builder.REQUEST_PRIORITY_MEDIUM; + } + } + + private int getContentLength(UrlResponseInfo urlResponseInfo) { + List<String> content = urlResponseInfo.getAllHeaders().get("Content-Length"); + if (content == null) { + return 1024; + } else { + return Integer.parseInt(content.get(0)); + } + } + + private String generateCurlCommand(String url, CurlLoggedRequestParameters requestParameters) { + StringBuilder builder = new StringBuilder("curl "); + + // HTTP method + builder.append("-X ").append(requestParameters.getHttpMethod()).append(" "); + + // Request headers + for (Map.Entry<String, String> header : requestParameters.getHeaders().entrySet()) { + builder.append("--header \"").append(header.getKey()).append(": "); + if (!mLogAuthTokensInCurlCommands + && ("Authorization".equals(header.getKey()) + || "Cookie".equals(header.getKey()))) { + builder.append("[REDACTED]"); + } else { + builder.append(header.getValue()); + } + builder.append("\" "); + } + + // URL + builder.append("\"").append(url).append("\""); + + // Request body (if any) + if (requestParameters.getBody() != null) { + if (requestParameters.getBody().length >= 1024) { + builder.append(" [REQUEST BODY TOO LARGE TO INCLUDE]"); + } else if (isBinaryContentForLogging(requestParameters)) { + String base64 = Base64.encodeToString(requestParameters.getBody(), Base64.NO_WRAP); + builder.insert(0, "echo '" + base64 + "' | base64 -d > /tmp/$$.bin; ") + .append(" --data-binary @/tmp/$$.bin"); + } else { + // Just assume the request body is UTF-8 since this is for debugging. + try { + builder.append(" --data-ascii \"") + .append(new String(requestParameters.getBody(), "UTF-8")) + .append("\""); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException("Could not encode to UTF-8", e); + } + } + } + + return builder.toString(); + } + + /** Rough heuristic to determine whether the request body is binary, for logging purposes. */ + private boolean isBinaryContentForLogging(CurlLoggedRequestParameters requestParameters) { + // Check to see if the content is gzip compressed - this means it should be treated as + // binary content regardless of the content type. + String contentEncoding = requestParameters.getHeaders().get("Content-Encoding"); + if (contentEncoding != null) { + String[] encodings = TextUtils.split(contentEncoding, ","); + for (String encoding : encodings) { + if ("gzip".equals(encoding.trim())) { + return true; + } + } + } + + // If the content type is a known text type, treat it as text content. + String contentType = requestParameters.getHeaders().get("Content-Type"); + if (contentType != null) { + return !contentType.startsWith("text/") + && !contentType.startsWith("application/xml") + && !contentType.startsWith("application/json"); + } + + // Otherwise, assume it is binary content. + return true; + } + + /** + * Builder is used to build an instance of {@link CronetHttpStack} from values configured by the + * setters. + */ + public static class Builder { + private static final int DEFAULT_POOL_SIZE = 4096; + private CronetEngine mCronetEngine; + private final Context context; + private ByteArrayPool mPool; + private UrlRewriter mUrlRewriter; + private RequestListener mRequestListener; + private boolean mCurlLoggingEnabled; + private CurlCommandLogger mCurlCommandLogger; + private boolean mLogAuthTokensInCurlCommands; + + public Builder(Context context) { + this.context = context; + } + + /** Sets the CronetEngine to be used. Defaults to a vanialla CronetEngine. */ + public Builder setCronetEngine(CronetEngine engine) { + mCronetEngine = engine; + return this; + } + + /** Sets the ByteArrayPool to be used. Defaults to a new pool with 4096 bytes. */ + public Builder setPool(ByteArrayPool pool) { + mPool = pool; + return this; + } + + /** Sets the UrlRewriter to be used. Default is to return the original string. */ + public Builder setUrlRewriter(UrlRewriter urlRewriter) { + mUrlRewriter = urlRewriter; + return this; + } + + /** Set the optional RequestListener to be used. */ + public Builder setRequestListener(RequestListener requestListener) { + mRequestListener = requestListener; + return this; + } + + /** + * Sets whether cURL logging should be enabled for debugging purposes. + * + * <p>When enabled, for each request dispatched to the network, a roughly-equivalent cURL + * command will be logged to logcat. + * + * <p>The command may be missing some headers that are added by Cronet automatically, and + * the full request body may not be included if it is too large. To inspect the full + * requests and responses, see {@code CronetEngine#startNetLogToFile}. + * + * <p>WARNING: This is only intended for debugging purposes and should never be enabled on + * production devices. + * + * @see #setCurlCommandLogger(CurlCommandLogger) + * @see #setLogAuthTokensInCurlCommands(boolean) + */ + public Builder setCurlLoggingEnabled(boolean curlLoggingEnabled) { + mCurlLoggingEnabled = curlLoggingEnabled; + return this; + } + + /** + * Sets the function used to log cURL commands. + * + * <p>Allows customization of the logging performed when cURL logging is enabled. + * + * <p>By default, when cURL logging is enabled, cURL commands are logged using {@link + * VolleyLog#v}, e.g. at the verbose log level with the same log tag used by the rest of + * Volley. This function may optionally be invoked to provide a custom logger. + * + * @see #setCurlLoggingEnabled(boolean) + */ + public Builder setCurlCommandLogger(CurlCommandLogger curlCommandLogger) { + mCurlCommandLogger = curlCommandLogger; + return this; + } + + /** + * Sets whether to log known auth tokens in cURL commands, or redact them. + * + * <p>By default, headers which may contain auth tokens (e.g. Authorization or Cookie) will + * have their values redacted. Passing true to this method will disable this redaction and + * log the values of these headers. + * + * <p>This heuristic is not perfect; tokens that are logged in unknown headers, or in the + * request body itself, will not be redacted as they cannot be detected generically. + * + * @see #setCurlLoggingEnabled(boolean) + */ + public Builder setLogAuthTokensInCurlCommands(boolean logAuthTokensInCurlCommands) { + mLogAuthTokensInCurlCommands = logAuthTokensInCurlCommands; + return this; + } + + public CronetHttpStack build() { + if (mCronetEngine == null) { + mCronetEngine = new CronetEngine.Builder(context).build(); + } + if (mUrlRewriter == null) { + mUrlRewriter = + new UrlRewriter() { + @Override + public String rewriteUrl(String originalUrl) { + return originalUrl; + } + }; + } + if (mRequestListener == null) { + mRequestListener = new RequestListener() {}; + } + if (mPool == null) { + mPool = new ByteArrayPool(DEFAULT_POOL_SIZE); + } + if (mCurlCommandLogger == null) { + mCurlCommandLogger = + new CurlCommandLogger() { + @Override + public void logCurlCommand(String curlCommand) { + VolleyLog.v(curlCommand); + } + }; + } + return new CronetHttpStack( + mCronetEngine, + mPool, + mUrlRewriter, + mRequestListener, + mCurlLoggingEnabled, + mCurlCommandLogger, + mLogAuthTokensInCurlCommands); + } + } + + /** Callback interface allowing clients to intercept different parts of the request flow. */ + public abstract static class RequestListener { + private CronetHttpStack mStack; + + void initialize(CronetHttpStack stack) { + mStack = stack; + } + + /** + * Called when a request is prepared and about to be sent over the network. + * + * <p>Clients may use this callback to customize UrlRequests before they are dispatched, + * e.g. to enable socket tagging or request finished listeners. + */ + public void onRequestPrepared(Request<?> request, UrlRequest.Builder requestBuilder) {} + + /** @see AsyncHttpStack#getNonBlockingExecutor() */ + protected Executor getNonBlockingExecutor() { + return mStack.getNonBlockingExecutor(); + } + + /** @see AsyncHttpStack#getBlockingExecutor() */ + protected Executor getBlockingExecutor() { + return mStack.getBlockingExecutor(); + } + } + + /** + * Interface for logging cURL commands for requests. + * + * @see Builder#setCurlCommandLogger(CurlCommandLogger) + */ + public interface CurlCommandLogger { + /** Log the given cURL command. */ + void logCurlCommand(String curlCommand); + } + + /** + * Internal container class for request parameters that impact logged cURL commands. + * + * <p>When cURL logging is enabled, an equivalent cURL command to a given request must be + * generated and logged. However, the Cronet UrlRequest object is write-only. So, we write any + * relevant parameters into this read-write container so they can be referenced when generating + * the cURL command (if needed) and then merged into the UrlRequest. + */ + private static class CurlLoggedRequestParameters { + private final TreeMap<String, String> mHeaders = + new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + private String mHttpMethod; + @Nullable private byte[] mBody; + + /** + * Return the headers to be used for the request. + * + * <p>The returned map is case-insensitive. + */ + TreeMap<String, String> getHeaders() { + return mHeaders; + } + + /** Apply all the headers in the given map to the request. */ + void putAllHeaders(Map<String, String> headers) { + mHeaders.putAll(headers); + } + + String getHttpMethod() { + return mHttpMethod; + } + + void setHttpMethod(String httpMethod) { + mHttpMethod = httpMethod; + } + + @Nullable + byte[] getBody() { + return mBody; + } + + void setBody(String contentType, @Nullable byte[] body) { + mBody = body; + if (body != null && !mHeaders.containsKey(HttpHeaderParser.HEADER_CONTENT_TYPE)) { + // Set the content-type unless it was already set (by Request#getHeaders). + mHeaders.put(HttpHeaderParser.HEADER_CONTENT_TYPE, contentType); + } + } + + void applyToRequest(UrlRequest.Builder builder, ExecutorService nonBlockingExecutor) { + for (Map.Entry<String, String> header : mHeaders.entrySet()) { + builder.addHeader(header.getKey(), header.getValue()); + } + builder.setHttpMethod(mHttpMethod); + if (mBody != null) { + UploadDataProvider dataProvider = UploadDataProviders.create(mBody); + builder.setUploadDataProvider(dataProvider, nonBlockingExecutor); + } + } + } +} diff --git a/cronet/src/test/java/com/android/volley/cronet/CronetHttpStackTest.java b/cronet/src/test/java/com/android/volley/cronet/CronetHttpStackTest.java new file mode 100644 index 0000000..cedb6ff --- /dev/null +++ b/cronet/src/test/java/com/android/volley/cronet/CronetHttpStackTest.java @@ -0,0 +1,381 @@ +/* + * Copyright (C) 2020 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.volley.cronet; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.android.volley.Header; +import com.android.volley.cronet.CronetHttpStack.CurlCommandLogger; +import com.android.volley.mock.TestRequest; +import com.android.volley.toolbox.AsyncHttpStack.OnRequestComplete; +import com.android.volley.toolbox.UrlRewriter; +import com.google.common.collect.ImmutableMap; +import com.google.common.util.concurrent.MoreExecutors; +import java.io.UnsupportedEncodingException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import org.chromium.net.CronetEngine; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Answers; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +@RunWith(RobolectricTestRunner.class) +public class CronetHttpStackTest { + @Mock private CurlCommandLogger mMockCurlCommandLogger; + @Mock private OnRequestComplete mMockOnRequestComplete; + @Mock private UrlRewriter mMockUrlRewriter; + + // A fake would be ideal here, but Cronet doesn't (yet) provide one, and at the moment we aren't + // exercising the full response flow. + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + private CronetEngine mMockCronetEngine; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + } + + @Test + public void curlLogging_disabled() { + CronetHttpStack stack = + createStack( + new Consumer<CronetHttpStack.Builder>() { + @Override + public void accept(CronetHttpStack.Builder builder) { + // Default parameters should not enable cURL logging. + } + }); + + stack.executeRequest( + new TestRequest.Get(), ImmutableMap.<String, String>of(), mMockOnRequestComplete); + + verify(mMockCurlCommandLogger, never()).logCurlCommand(anyString()); + } + + @Test + public void curlLogging_simpleTextRequest() { + CronetHttpStack stack = + createStack( + new Consumer<CronetHttpStack.Builder>() { + @Override + public void accept(CronetHttpStack.Builder builder) { + builder.setCurlLoggingEnabled(true); + } + }); + + stack.executeRequest( + new TestRequest.Get(), ImmutableMap.<String, String>of(), mMockOnRequestComplete); + + ArgumentCaptor<String> curlCommandCaptor = ArgumentCaptor.forClass(String.class); + verify(mMockCurlCommandLogger).logCurlCommand(curlCommandCaptor.capture()); + assertEquals("curl -X GET \"http://foo.com\"", curlCommandCaptor.getValue()); + } + + @Test + public void curlLogging_rewrittenUrl() { + CronetHttpStack stack = + createStack( + new Consumer<CronetHttpStack.Builder>() { + @Override + public void accept(CronetHttpStack.Builder builder) { + builder.setCurlLoggingEnabled(true) + .setUrlRewriter(mMockUrlRewriter); + } + }); + when(mMockUrlRewriter.rewriteUrl("http://foo.com")).thenReturn("http://bar.com"); + + stack.executeRequest( + new TestRequest.Get(), ImmutableMap.<String, String>of(), mMockOnRequestComplete); + + ArgumentCaptor<String> curlCommandCaptor = ArgumentCaptor.forClass(String.class); + verify(mMockCurlCommandLogger).logCurlCommand(curlCommandCaptor.capture()); + assertEquals("curl -X GET \"http://bar.com\"", curlCommandCaptor.getValue()); + } + + @Test + public void curlLogging_headers_withoutTokens() { + CronetHttpStack stack = + createStack( + new Consumer<CronetHttpStack.Builder>() { + @Override + public void accept(CronetHttpStack.Builder builder) { + builder.setCurlLoggingEnabled(true); + } + }); + + stack.executeRequest( + new TestRequest.Delete() { + @Override + public Map<String, String> getHeaders() { + return ImmutableMap.of( + "SomeHeader", "SomeValue", + "Authorization", "SecretToken"); + } + }, + ImmutableMap.of("SomeOtherHeader", "SomeValue"), + mMockOnRequestComplete); + + ArgumentCaptor<String> curlCommandCaptor = ArgumentCaptor.forClass(String.class); + verify(mMockCurlCommandLogger).logCurlCommand(curlCommandCaptor.capture()); + // NOTE: Header order is stable because the implementation uses a TreeMap. + assertEquals( + "curl -X DELETE --header \"Authorization: [REDACTED]\" " + + "--header \"SomeHeader: SomeValue\" " + + "--header \"SomeOtherHeader: SomeValue\" \"http://foo.com\"", + curlCommandCaptor.getValue()); + } + + @Test + public void curlLogging_headers_withTokens() { + CronetHttpStack stack = + createStack( + new Consumer<CronetHttpStack.Builder>() { + @Override + public void accept(CronetHttpStack.Builder builder) { + builder.setCurlLoggingEnabled(true) + .setLogAuthTokensInCurlCommands(true); + } + }); + + stack.executeRequest( + new TestRequest.Delete() { + @Override + public Map<String, String> getHeaders() { + return ImmutableMap.of( + "SomeHeader", "SomeValue", + "Authorization", "SecretToken"); + } + }, + ImmutableMap.of("SomeOtherHeader", "SomeValue"), + mMockOnRequestComplete); + + ArgumentCaptor<String> curlCommandCaptor = ArgumentCaptor.forClass(String.class); + verify(mMockCurlCommandLogger).logCurlCommand(curlCommandCaptor.capture()); + // NOTE: Header order is stable because the implementation uses a TreeMap. + assertEquals( + "curl -X DELETE --header \"Authorization: SecretToken\" " + + "--header \"SomeHeader: SomeValue\" " + + "--header \"SomeOtherHeader: SomeValue\" \"http://foo.com\"", + curlCommandCaptor.getValue()); + } + + @Test + public void curlLogging_textRequest() { + CronetHttpStack stack = + createStack( + new Consumer<CronetHttpStack.Builder>() { + @Override + public void accept(CronetHttpStack.Builder builder) { + builder.setCurlLoggingEnabled(true); + } + }); + + stack.executeRequest( + new TestRequest.PostWithBody() { + @Override + public byte[] getBody() { + try { + return "hello".getBytes("UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException(e); + } + } + + @Override + public String getBodyContentType() { + return "text/plain; charset=UTF-8"; + } + }, + ImmutableMap.<String, String>of(), + mMockOnRequestComplete); + + ArgumentCaptor<String> curlCommandCaptor = ArgumentCaptor.forClass(String.class); + verify(mMockCurlCommandLogger).logCurlCommand(curlCommandCaptor.capture()); + assertEquals( + "curl -X POST " + + "--header \"Content-Type: text/plain; charset=UTF-8\" \"http://foo.com\" " + + "--data-ascii \"hello\"", + curlCommandCaptor.getValue()); + } + + @Test + public void curlLogging_gzipTextRequest() { + CronetHttpStack stack = + createStack( + new Consumer<CronetHttpStack.Builder>() { + @Override + public void accept(CronetHttpStack.Builder builder) { + builder.setCurlLoggingEnabled(true); + } + }); + + stack.executeRequest( + new TestRequest.PostWithBody() { + @Override + public byte[] getBody() { + return new byte[] {1, 2, 3, 4, 5}; + } + + @Override + public String getBodyContentType() { + return "text/plain"; + } + + @Override + public Map<String, String> getHeaders() { + return ImmutableMap.of("Content-Encoding", "gzip, identity"); + } + }, + ImmutableMap.<String, String>of(), + mMockOnRequestComplete); + + ArgumentCaptor<String> curlCommandCaptor = ArgumentCaptor.forClass(String.class); + verify(mMockCurlCommandLogger).logCurlCommand(curlCommandCaptor.capture()); + assertEquals( + "echo 'AQIDBAU=' | base64 -d > /tmp/$$.bin; curl -X POST " + + "--header \"Content-Encoding: gzip, identity\" " + + "--header \"Content-Type: text/plain\" \"http://foo.com\" " + + "--data-binary @/tmp/$$.bin", + curlCommandCaptor.getValue()); + } + + @Test + public void curlLogging_binaryRequest() { + CronetHttpStack stack = + createStack( + new Consumer<CronetHttpStack.Builder>() { + @Override + public void accept(CronetHttpStack.Builder builder) { + builder.setCurlLoggingEnabled(true); + } + }); + + stack.executeRequest( + new TestRequest.PostWithBody() { + @Override + public byte[] getBody() { + return new byte[] {1, 2, 3, 4, 5}; + } + + @Override + public String getBodyContentType() { + return "application/octet-stream"; + } + }, + ImmutableMap.<String, String>of(), + mMockOnRequestComplete); + + ArgumentCaptor<String> curlCommandCaptor = ArgumentCaptor.forClass(String.class); + verify(mMockCurlCommandLogger).logCurlCommand(curlCommandCaptor.capture()); + assertEquals( + "echo 'AQIDBAU=' | base64 -d > /tmp/$$.bin; curl -X POST " + + "--header \"Content-Type: application/octet-stream\" \"http://foo.com\" " + + "--data-binary @/tmp/$$.bin", + curlCommandCaptor.getValue()); + } + + @Test + public void curlLogging_largeRequest() { + CronetHttpStack stack = + createStack( + new Consumer<CronetHttpStack.Builder>() { + @Override + public void accept(CronetHttpStack.Builder builder) { + builder.setCurlLoggingEnabled(true); + } + }); + + stack.executeRequest( + new TestRequest.PostWithBody() { + @Override + public byte[] getBody() { + return new byte[2048]; + } + + @Override + public String getBodyContentType() { + return "application/octet-stream"; + } + }, + ImmutableMap.<String, String>of(), + mMockOnRequestComplete); + + ArgumentCaptor<String> curlCommandCaptor = ArgumentCaptor.forClass(String.class); + verify(mMockCurlCommandLogger).logCurlCommand(curlCommandCaptor.capture()); + assertEquals( + "curl -X POST " + + "--header \"Content-Type: application/octet-stream\" \"http://foo.com\" " + + "[REQUEST BODY TOO LARGE TO INCLUDE]", + curlCommandCaptor.getValue()); + } + + @Test + public void getHeadersEmptyTest() { + List<Map.Entry<String, String>> list = new ArrayList<>(); + List<Header> actual = CronetHttpStack.getHeaders(list); + List<Header> expected = new ArrayList<>(); + assertEquals(expected, actual); + } + + @Test + public void getHeadersNonEmptyTest() { + Map<String, String> headers = new HashMap<>(); + for (int i = 1; i < 5; i++) { + headers.put("key" + i, "value" + i); + } + List<Map.Entry<String, String>> list = new ArrayList<>(headers.entrySet()); + List<Header> actual = CronetHttpStack.getHeaders(list); + List<Header> expected = new ArrayList<>(); + for (int i = 1; i < 5; i++) { + expected.add(new Header("key" + i, "value" + i)); + } + assertHeaderListsEqual(expected, actual); + } + + private void assertHeaderListsEqual(List<Header> expected, List<Header> actual) { + assertEquals(expected.size(), actual.size()); + for (int i = 0; i < expected.size(); i++) { + assertEquals(expected.get(i).getName(), actual.get(i).getName()); + assertEquals(expected.get(i).getValue(), actual.get(i).getValue()); + } + } + + private CronetHttpStack createStack(Consumer<CronetHttpStack.Builder> stackEditor) { + CronetHttpStack.Builder builder = + new CronetHttpStack.Builder(RuntimeEnvironment.application) + .setCronetEngine(mMockCronetEngine) + .setCurlCommandLogger(mMockCurlCommandLogger); + stackEditor.accept(builder); + CronetHttpStack stack = builder.build(); + stack.setBlockingExecutor(MoreExecutors.newDirectExecutorService()); + stack.setNonBlockingExecutor(MoreExecutors.newDirectExecutorService()); + return stack; + } +} |