diff options
Diffstat (limited to 'mobile/android/android-components/components/concept/fetch/src')
13 files changed, 1573 insertions, 0 deletions
diff --git a/mobile/android/android-components/components/concept/fetch/src/main/AndroidManifest.xml b/mobile/android/android-components/components/concept/fetch/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..e16cda1d34 --- /dev/null +++ b/mobile/android/android-components/components/concept/fetch/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<manifest /> diff --git a/mobile/android/android-components/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Client.kt b/mobile/android/android-components/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Client.kt new file mode 100644 index 0000000000..fbb9eb7c72 --- /dev/null +++ b/mobile/android/android-components/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Client.kt @@ -0,0 +1,98 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.concept.fetch + +import android.util.Base64 +import java.io.ByteArrayInputStream +import java.io.IOException +import java.net.URLDecoder +import java.nio.charset.Charset + +/** + * A generic [Client] for fetching resources via HTTP/s. + * + * Abstract base class / interface for clients implementing the `concept-fetch` component. + * + * The [Request]/[Response] API is inspired by the Web Fetch API: + * https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API + */ +abstract class Client { + /** + * Starts the process of fetching a resource from the network as described by the [Request] object. This call is + * synchronous. + * + * A [Response] may keep references to open streams. Therefore it's important to always close the [Response] or + * its [Response.Body]. + * + * Use the `use()` extension method when performing multiple operations on the [Response] object: + * + * ```Kotlin + * client.fetch(request).use { response -> + * // Use response. Resources will get released automatically at the end of the block. + * } + * ``` + * + * Alternatively you can use multiple `use*()` methods on the [Response.Body] object. + * + * @param request The request to be executed by this [Client]. + * @return The [Response] returned by the server. + * @throws IOException if the request could not be executed due to cancellation, a connectivity problem or a + * timeout. + */ + @Throws(IOException::class) + abstract fun fetch(request: Request): Response + + /** + * Generates a [Response] based on the provided [Request] for a data URI. + * + * @param request The [Request] for the data URI. + * @return The generated [Response] including the decoded bytes as body. + */ + @Suppress("ComplexMethod", "TooGenericExceptionCaught") + protected fun fetchDataUri(request: Request): Response { + if (!request.isDataUri()) { + throw IOException("Not a data URI") + } + return try { + val dataUri = request.url + + val (contentType, bytes) = if (dataUri.contains(DATA_URI_BASE64_EXT)) { + dataUri.substringAfter(DATA_URI_SCHEME).substringBefore(DATA_URI_BASE64_EXT) to + Base64.decode(dataUri.substring(dataUri.lastIndexOf(',') + 1), Base64.DEFAULT) + } else { + val contentType = dataUri.substringAfter(DATA_URI_SCHEME).substringBefore(",") + val charset = if (contentType.contains(DATA_URI_CHARSET)) { + Charset.forName(contentType.substringAfter(DATA_URI_CHARSET).substringBefore(",")) + } else { + Charsets.UTF_8 + } + contentType to + URLDecoder.decode(dataUri.substring(dataUri.lastIndexOf(',') + 1), charset.name()).toByteArray() + } + + val headers = MutableHeaders().apply { + set(Headers.Names.CONTENT_LENGTH, bytes.size.toString()) + if (contentType.isNotEmpty()) { + set(Headers.Names.CONTENT_TYPE, contentType) + } + } + + Response( + dataUri, + Response.SUCCESS, + headers, + Response.Body(ByteArrayInputStream(bytes), contentType), + ) + } catch (e: Exception) { + throw IOException("Failed to decode data URI") + } + } + + companion object { + const val DATA_URI_BASE64_EXT = ";base64" + const val DATA_URI_SCHEME = "data:" + const val DATA_URI_CHARSET = "charset=" + } +} diff --git a/mobile/android/android-components/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Headers.kt b/mobile/android/android-components/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Headers.kt new file mode 100644 index 0000000000..9b49884bfe --- /dev/null +++ b/mobile/android/android-components/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Headers.kt @@ -0,0 +1,168 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.concept.fetch + +/** + * A collection of HTTP [Headers] (immutable) of a [Request] or [Response]. + */ +interface Headers : Iterable<Header> { + /** + * Returns the number of headers (key / value combinations). + */ + val size: Int + + /** + * Gets the [Header] at the specified [index]. + */ + operator fun get(index: Int): Header + + /** + * Returns the last values corresponding to the specified header field name. Or null if the header does not exist. + */ + operator fun get(name: String): String? + + /** + * Returns the list of values corresponding to the specified header field name. + */ + fun getAll(name: String): List<String> + + /** + * Sets the [Header] at the specified [index]. + */ + operator fun set(index: Int, header: Header) + + /** + * Returns true if a [Header] with the given [name] exists. + */ + operator fun contains(name: String): Boolean + + /** + * A collection of common HTTP header names. + * + * A list of common HTTP request headers can be found at + * https://en.wikipedia.org/wiki/List_of_HTTP_header_fields#Standard_request_fields + * + * A list of common HTTP response headers can be found at + * https://en.wikipedia.org/wiki/List_of_HTTP_header_fields#Standard_response_fields + * + * @see [Headers.Values] + */ + object Names { + const val CONTENT_DISPOSITION = "Content-Disposition" + const val CONTENT_RANGE = "Content-Range" + const val RANGE = "Range" + const val CONTENT_LENGTH = "Content-Length" + const val CONTENT_TYPE = "Content-Type" + const val COOKIE = "Cookie" + const val REFERRER = "Referer" + const val USER_AGENT = "User-Agent" + } + + /** + * A collection of common HTTP header values. + * + * @see [Headers.Names] + */ + object Values { + const val CONTENT_TYPE_FORM_URLENCODED = "application/x-www-form-urlencoded" + const val CONTENT_TYPE_APPLICATION_JSON = "application/json" + } +} + +/** + * Represents a [Header] containing of a [name] and [value]. + */ +data class Header( + val name: String, + val value: String, +) { + init { + if (name.isEmpty()) { + throw IllegalArgumentException("Header name cannot be empty") + } + } +} + +/** + * A collection of HTTP [Headers] (mutable) of a [Request] or [Response]. + */ +class MutableHeaders(headers: List<Header>) : Headers, MutableIterable<Header> { + + private val headers = headers.toMutableList() + + constructor(vararg pairs: Pair<String, String>) : this( + pairs.map { (name, value) -> Header(name, value) }.toMutableList(), + ) + + /** + * Gets the [Header] at the specified [index]. + */ + override fun get(index: Int): Header = headers[index] + + /** + * Returns the last value corresponding to the specified header field name. Or null if the header does not exist. + */ + override fun get(name: String) = + headers.lastOrNull { header -> header.name.equals(name, ignoreCase = true) }?.value + + /** + * Returns the list of values corresponding to the specified header field name. + */ + override fun getAll(name: String): List<String> = headers + .filter { header -> header.name.equals(name, ignoreCase = true) } + .map { header -> header.value } + + /** + * Sets the [Header] at the specified [index]. + */ + override fun set(index: Int, header: Header) { + headers[index] = header + } + + /** + * Returns an iterator over the headers that supports removing elements during iteration. + */ + override fun iterator(): MutableIterator<Header> = headers.iterator() + + /** + * Returns true if a [Header] with the given [name] exists. + */ + override operator fun contains(name: String): Boolean = + headers.any { it.name.equals(name, ignoreCase = true) } + + /** + * Returns the number of headers (key / value combinations). + */ + override val size: Int + get() = headers.size + + /** + * Append a header without removing the headers already present. + */ + fun append(name: String, value: String): MutableHeaders { + headers.add(Header(name, value)) + return this + } + + /** + * Set the only occurrence of the header; potentially overriding an already existing header. + */ + fun set(name: String, value: String): MutableHeaders { + headers.forEachIndexed { index, current -> + if (current.name.equals(name, ignoreCase = true)) { + headers[index] = Header(name, value) + return this + } + } + + return append(name, value) + } + + override fun equals(other: Any?) = other is MutableHeaders && headers == other.headers + + override fun hashCode() = headers.hashCode() +} + +fun List<Header>.toMutableHeaders() = MutableHeaders(this) diff --git a/mobile/android/android-components/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Request.kt b/mobile/android/android-components/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Request.kt new file mode 100644 index 0000000000..7ea1a46df3 --- /dev/null +++ b/mobile/android/android-components/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Request.kt @@ -0,0 +1,190 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.concept.fetch + +import android.net.Uri +import mozilla.components.concept.fetch.Request.CookiePolicy +import java.io.Closeable +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.util.concurrent.TimeUnit + +/** + * The [Request] data class represents a resource request to be send by a [Client]. + * + * It's API is inspired by the Request interface of the Web Fetch API: + * https://developer.mozilla.org/en-US/docs/Web/API/Request + * + * @property url The URL of the request. + * @property method The request method (GET, POST, ..) + * @property headers Optional HTTP headers to be send with the request. + * @property connectTimeout A timeout to be used when connecting to the resource. If the timeout expires before the + * connection can be established, a [java.net.SocketTimeoutException] is raised. A timeout of zero is interpreted as an + * infinite timeout. + * @property readTimeout A timeout to be used when reading from the resource. If the timeout expires before there is + * data available for read, a java.net.SocketTimeoutException is raised. A timeout of zero is interpreted as an infinite + * timeout. + * @property body An optional body to be send with the request. + * @property redirect Whether the [Client] should follow redirects (HTTP 3xx) for this request or not. + * @property cookiePolicy A policy to specify whether or not cookies should be + * sent with the request, defaults to [CookiePolicy.INCLUDE] + * @property useCaches Whether caches should be used or a network request + * should be forced, defaults to true (use caches). + * @property private Whether the request should be performed in a private context, defaults to false. + * The feature is not support in all [Client]s, check support before using. + * @see [Headers.Names] + * @see [Headers.Values] + */ +data class Request( + val url: String, + val method: Method = Method.GET, + val headers: MutableHeaders? = MutableHeaders(), + val connectTimeout: Pair<Long, TimeUnit>? = null, + val readTimeout: Pair<Long, TimeUnit>? = null, + val body: Body? = null, + val redirect: Redirect = Redirect.FOLLOW, + val cookiePolicy: CookiePolicy = CookiePolicy.INCLUDE, + val useCaches: Boolean = true, + val private: Boolean = false, +) { + var referrerUrl: String? = null + var conservative: Boolean = false + + /** + * Create a Request for Backward compatibility. + * @property referrerUrl An optional url of the referrer. + * @property conservative Whether to turn off bleeding-edge network features to avoid breaking core browser + * functionality, defaults to false. Set to true for Mozilla services only. + */ + constructor( + url: String, + method: Method = Method.GET, + headers: MutableHeaders? = MutableHeaders(), + connectTimeout: Pair<Long, TimeUnit>? = null, + readTimeout: Pair<Long, TimeUnit>? = null, + body: Body? = null, + redirect: Redirect = Redirect.FOLLOW, + cookiePolicy: CookiePolicy = CookiePolicy.INCLUDE, + useCaches: Boolean = true, + private: Boolean = false, + referrerUrl: String? = null, + conservative: Boolean = false, + ) : this(url, method, headers, connectTimeout, readTimeout, body, redirect, cookiePolicy, useCaches, private) { + this.referrerUrl = referrerUrl + this.conservative = conservative + } + + /** + * A [Body] to be send with the [Request]. + * + * @param stream A stream that will be read and send to the resource. + */ + class Body( + private val stream: InputStream, + ) : Closeable { + companion object { + /** + * Create a [Body] from the provided [String]. + */ + fun fromString(value: String): Body = Body(value.byteInputStream()) + + /** + * Create a [Body] from the provided [File]. + */ + fun fromFile(file: File): Body = Body(file.inputStream()) + + /** + * Create a [Body] from the provided [unencodedParams] in the format of Content-Type + * "application/x-www-form-urlencoded". Parameters are formatted as "key1=value1&key2=value2..." + * and values are percent-encoded. If the given map is empty, the response body will contain the + * empty string. + * + * @see [Headers.Values.CONTENT_TYPE_FORM_URLENCODED] + */ + fun fromParamsForFormUrlEncoded(vararg unencodedParams: Pair<String, String>): Body { + // It's unintuitive to use the Uri class format and encode + // but its GET query syntax is exactly what we need. + val uriBuilder = Uri.Builder() + unencodedParams.forEach { (key, value) -> uriBuilder.appendQueryParameter(key, value) } + val encodedBody = uriBuilder.build().encodedQuery ?: "" // null when the given map is empty. + return Body(encodedBody.byteInputStream()) + } + } + + /** + * Executes the given [block] function on the body's stream and then closes it down correctly whether an + * exception is thrown or not. + */ + fun <R> useStream(block: (InputStream) -> R): R = use { + block(stream) + } + + /** + * Closes this body and releases any system resources associated with it. + */ + override fun close() { + try { + stream.close() + } catch (e: IOException) { + // Ignore + } + } + } + + /** + * Request methods. + * + * The request method token is the primary source of request semantics; + * it indicates the purpose for which the client has made this request + * and what is expected by the client as a successful result. + * + * https://tools.ietf.org/html/rfc7231#section-4 + */ + enum class Method { + GET, + HEAD, + POST, + PUT, + DELETE, + CONNECT, + OPTIONS, + TRACE, + } + + enum class Redirect { + /** + * Automatically follow redirects. + */ + FOLLOW, + + /** + * Do not follow redirects and let caller handle them manually. + */ + MANUAL, + } + + enum class CookiePolicy { + /** + * Include cookies when sending the request. + */ + INCLUDE, + + /** + * Do not send cookies with the request. + */ + OMIT, + } +} + +/** + * Checks whether or not the request is for a data URI. + */ +fun Request.isDataUri() = url.startsWith("data:") + +/** + * Checks whether or not the request is for a data blob. + */ +fun Request.isBlobUri() = url.startsWith("blob:") diff --git a/mobile/android/android-components/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Response.kt b/mobile/android/android-components/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Response.kt new file mode 100644 index 0000000000..b72a0e2ef4 --- /dev/null +++ b/mobile/android/android-components/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Response.kt @@ -0,0 +1,160 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.concept.fetch + +import mozilla.components.concept.fetch.Response.Body +import mozilla.components.concept.fetch.Response.Companion.CLIENT_ERROR_STATUS_RANGE +import mozilla.components.concept.fetch.Response.Companion.SUCCESS_STATUS_RANGE +import java.io.BufferedReader +import java.io.Closeable +import java.io.IOException +import java.io.InputStream +import java.nio.charset.Charset + +/** + * The [Response] data class represents a response to a [Request] send by a [Client]. + * + * You can create a [Response] object using the constructor, but you are more likely to encounter a [Response] object + * being returned as the result of calling [Client.fetch]. + * + * A [Response] may hold references to other resources (e.g. streams). Therefore it's important to always close the + * [Response] object or its [Body]. This can be done by either consuming the content of the [Body] with one of the + * available methods or by using Kotlin's extension methods for using [Closeable] implementations (like `use()`): + * + * ```Kotlin + * val response = ... + * response.use { + * // Use response. Resources will get released automatically at the end of the block. + * } + * ``` + */ +data class Response( + val url: String, + val status: Int, + val headers: Headers, + val body: Body, +) : Closeable { + /** + * Closes this [Response] and its [Body] and releases any system resources associated with it. + */ + override fun close() { + body.close() + } + + /** + * A [Body] returned along with the [Request]. + * + * **The response body can be consumed only once.**. + * + * @param stream the input stream from which the response body can be read. + * @param contentType optional content-type as provided in the response + * header. If specified, an attempt will be made to look up the charset + * which will be used for decoding the body. If not specified, or if the + * charset can't be found, UTF-8 will be used for decoding. + */ + open class Body( + private val stream: InputStream, + contentType: String? = null, + ) : Closeable, AutoCloseable { + + @Suppress("TooGenericExceptionCaught") + private val charset = contentType?.let { + val charset = it.substringAfter("charset=") + try { + Charset.forName(charset) + } catch (e: Exception) { + Charsets.UTF_8 + } + } ?: Charsets.UTF_8 + + /** + * Creates a usable stream from this body. + * + * Executes the given [block] function with the stream as parameter and then closes it down correctly + * whether an exception is thrown or not. + */ + fun <R> useStream(block: (InputStream) -> R): R = use { + block(stream) + } + + /** + * Creates a buffered reader from this body. + * + * Executes the given [block] function with the buffered reader as parameter and then closes it down correctly + * whether an exception is thrown or not. + * + * @param charset the optional charset to use when decoding the body. If not specified, + * the charset provided in the response content-type header will be used. If the header + * is missing or the charset is not supported, UTF-8 will be used. + * @param block a function to consume the buffered reader. + * + */ + fun <R> useBufferedReader(charset: Charset? = null, block: (BufferedReader) -> R): R = use { + block(stream.bufferedReader(charset ?: this.charset)) + } + + /** + * Reads this body completely as a String. + * + * Takes care of closing the body down correctly whether an exception is thrown or not. + * + * @param charset the optional charset to use when decoding the body. If not specified, + * the charset provided in the response content-type header will be used. If the header + * is missing or the charset not supported, UTF-8 will be used. + */ + fun string(charset: Charset? = null): String = use { + // We don't use a BufferedReader because it'd unnecessarily allocate more memory: if the + // BufferedReader is reading into a buffer whose length >= the BufferedReader's buffer + // length, then the BufferedReader reads directly into the other buffer as an optimization + // and the BufferedReader's buffer is unused (i.e. you get no benefit from the BufferedReader + // and you can just use a Reader). In this case, both the BufferedReader and readText + // would allocate a buffer of DEFAULT_BUFFER_SIZE so we removed the unnecessary + // BufferedReader and cut memory consumption in half. See + // https://github.com/mcomella/android-components/commit/db8488599f9f652b4d5775f70eeb4ab91462cbe6 + // for code verifying this behavior. + // + // The allocation can be further optimized by setting the buffer size to Content-Length + // header. See https://github.com/mozilla-mobile/android-components/issues/11015 + stream.reader(charset ?: this.charset).readText() + } + + /** + * Closes this [Body] and releases any system resources associated with it. + */ + override fun close() { + try { + stream.close() + } catch (e: IOException) { + // Ignore + } + } + + companion object { + /** + * Creates an empty response body. + */ + fun empty() = Body("".byteInputStream()) + } + } + + companion object { + val SUCCESS_STATUS_RANGE = 200..299 + val CLIENT_ERROR_STATUS_RANGE = 400..499 + const val SUCCESS = 200 + const val NO_CONTENT = 204 + } +} + +/** + * Returns true if the response was successful (status in the range 200-299) or false otherwise. + */ +val Response.isSuccess: Boolean + get() = status in SUCCESS_STATUS_RANGE + +/** + * Returns true if the response was a client error (status in the range 400-499) or false otherwise. + */ +val Response.isClientError: Boolean + get() = status in CLIENT_ERROR_STATUS_RANGE diff --git a/mobile/android/android-components/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/interceptor/Interceptor.kt b/mobile/android/android-components/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/interceptor/Interceptor.kt new file mode 100644 index 0000000000..d92c5ad5ab --- /dev/null +++ b/mobile/android/android-components/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/interceptor/Interceptor.kt @@ -0,0 +1,93 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.concept.fetch.interceptor + +import mozilla.components.concept.fetch.Client +import mozilla.components.concept.fetch.Request +import mozilla.components.concept.fetch.Response + +/** + * An [Interceptor] for a [Client] implementation. + * + * Interceptors can monitor, modify, retry, redirect or record requests as well as responses going through a [Client]. + */ +interface Interceptor { + /** + * Allows an [Interceptor] to intercept a request and modify request or response. + * + * An interceptor can retrieve the request by calling [Chain.request]. + * + * If the interceptor wants to continue executing the chain (which will execute potentially other interceptors and + * may eventually perform the request) it can call [Chain.proceed] and pass along the original or a modified + * request. + * + * Finally the interceptor needs to return a [Response]. This can either be the [Response] from calling + * [Chain.proceed] - modified or unmodified - or a [Response] the interceptor created manually or obtained from + * a different source. + */ + fun intercept(chain: Chain): Response + + /** + * The request interceptor chain. + */ + interface Chain { + /** + * The current request. May be modified by a previously executed interceptor. + */ + val request: Request + + /** + * Proceed executing the interceptor chain and eventually perform the request. + */ + fun proceed(request: Request): Response + } +} + +/** + * Creates a new [Client] instance that will use the provided list of [Interceptor] instances. + */ +fun Client.withInterceptors( + vararg interceptors: Interceptor, +): Client = InterceptorClient(this, interceptors.toList()) + +/** + * A [Client] instance that will wrap the provided [actualClient] and call the interceptor chain before executing + * the request. + */ +private class InterceptorClient( + private val actualClient: Client, + private val interceptors: List<Interceptor>, +) : Client() { + override fun fetch(request: Request): Response = + InterceptorChain(actualClient, interceptors.toList(), request) + .proceed(request) +} + +/** + * [InterceptorChain] implementation that keeps track of executing the chain of interceptors before executing the + * request on the provided [client]. + */ +private class InterceptorChain( + private val client: Client, + private val interceptors: List<Interceptor>, + private var currentRequest: Request, +) : Interceptor.Chain { + private var index = 0 + + override val request: Request + get() = currentRequest + + override fun proceed(request: Request): Response { + currentRequest = request + + return if (index < interceptors.size) { + val interceptor = interceptors[index] + index++ + interceptor.intercept(this) + } else { + client.fetch(request) + } + } +} diff --git a/mobile/android/android-components/components/concept/fetch/src/test/java/mozilla/components/concept/fetch/ClientTest.kt b/mobile/android/android-components/components/concept/fetch/src/test/java/mozilla/components/concept/fetch/ClientTest.kt new file mode 100644 index 0000000000..96e0663e7e --- /dev/null +++ b/mobile/android/android-components/components/concept/fetch/src/test/java/mozilla/components/concept/fetch/ClientTest.kt @@ -0,0 +1,36 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.concept.fetch + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +class ClientTest { + @ExperimentalCoroutinesApi + @Test + fun `Async request with coroutines`() = runTest { + val client = TestClient(responseBody = Response.Body("Hello World".byteInputStream())) + val request = Request("https://www.mozilla.org") + + val deferredResponse = async { client.fetch(request) } + + val body = deferredResponse.await().body.string() + assertEquals("Hello World", body) + } +} + +private class TestClient( + private val responseUrl: String? = null, + private val responseStatus: Int = 200, + private val responseHeaders: Headers = MutableHeaders(), + private val responseBody: Response.Body = Response.Body.empty(), +) : Client() { + override fun fetch(request: Request): Response { + return Response(responseUrl ?: request.url, responseStatus, responseHeaders, responseBody) + } +} diff --git a/mobile/android/android-components/components/concept/fetch/src/test/java/mozilla/components/concept/fetch/HeadersTest.kt b/mobile/android/android-components/components/concept/fetch/src/test/java/mozilla/components/concept/fetch/HeadersTest.kt new file mode 100644 index 0000000000..c84bd5f53a --- /dev/null +++ b/mobile/android/android-components/components/concept/fetch/src/test/java/mozilla/components/concept/fetch/HeadersTest.kt @@ -0,0 +1,240 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.concept.fetch + +import mozilla.components.support.test.expectException +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.lang.IllegalArgumentException + +class HeadersTest { + @Test + fun `Creating Headers using constructor`() { + val headers = MutableHeaders( + "Accept" to "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Encoding" to "gzip, deflate", + "Accept-Language" to "en-US,en;q=0.5", + "Connection" to "keep-alive", + "Dnt" to "1", + "User-Agent" to "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:65.0) Gecko/20100101 Firefox/65.0", + ) + + assertEquals(6, headers.size) + + assertEquals("Accept", headers[0].name) + assertEquals("Accept-Encoding", headers[1].name) + assertEquals("Accept-Language", headers[2].name) + assertEquals("Connection", headers[3].name) + assertEquals("Dnt", headers[4].name) + assertEquals("User-Agent", headers[5].name) + + assertEquals("text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", headers[0].value) + assertEquals("gzip, deflate", headers[1].value) + assertEquals("en-US,en;q=0.5", headers[2].value) + assertEquals("keep-alive", headers[3].value) + assertEquals("1", headers[4].value) + assertEquals("Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:65.0) Gecko/20100101 Firefox/65.0", headers[5].value) + } + + @Test + fun `Setting headers`() { + val headers = MutableHeaders() + + headers.set("Accept-Encoding", "gzip, deflate") + headers.set("Connection", "keep-alive") + headers.set("Accept-Encoding", "gzip") + headers.set("Dnt", "1") + + assertEquals(3, headers.size) + + assertEquals("Accept-Encoding", headers[0].name) + assertEquals("Connection", headers[1].name) + assertEquals("Dnt", headers[2].name) + + assertEquals("gzip", headers[0].value) + assertEquals("keep-alive", headers[1].value) + assertEquals("1", headers[2].value) + } + + @Test + fun `Appending headers`() { + val headers = MutableHeaders() + + headers.append("Accept-Encoding", "gzip, deflate") + headers.append("Connection", "keep-alive") + headers.append("Accept-Encoding", "gzip") + headers.append("Dnt", "1") + + assertEquals(4, headers.size) + + assertEquals("Accept-Encoding", headers[0].name) + assertEquals("Connection", headers[1].name) + assertEquals("Accept-Encoding", headers[2].name) + assertEquals("Dnt", headers[3].name) + + assertEquals("gzip, deflate", headers[0].value) + assertEquals("keep-alive", headers[1].value) + assertEquals("gzip", headers[2].value) + assertEquals("1", headers[3].value) + } + + @Test + fun `Overriding headers at index`() { + val headers = MutableHeaders().apply { + set("User-Agent", "Mozilla/5.0") + set("Connection", "keep-alive") + set("Accept-Encoding", "gzip") + } + + headers[2] = Header("Dnt", "0") + headers[0] = Header("Accept-Language", "en-US,en;q=0.5") + + assertEquals(3, headers.size) + + assertEquals("Accept-Language", headers[0].name) + assertEquals("Connection", headers[1].name) + assertEquals("Dnt", headers[2].name) + + assertEquals("en-US,en;q=0.5", headers[0].value) + assertEquals("keep-alive", headers[1].value) + assertEquals("0", headers[2].value) + } + + @Test + fun `Contains header with name`() { + val headers = MutableHeaders().apply { + set("User-Agent", "Mozilla/5.0") + set("Connection", "keep-alive") + set("Accept-Encoding", "gzip") + } + + assertTrue(headers.contains("User-Agent")) + assertTrue(headers.contains("Connection")) + assertTrue(headers.contains("Accept-Encoding")) + + assertFalse(headers.contains("Accept-Language")) + assertFalse(headers.contains("Dnt")) + assertFalse(headers.contains("Accept")) + } + + @Test + fun `Throws if header name is empty`() { + expectException(IllegalArgumentException::class) { + MutableHeaders( + "" to "Mozilla/5.0", + ) + } + + expectException(IllegalArgumentException::class) { + MutableHeaders() + .append("", "Mozilla/5.0") + } + + expectException(IllegalArgumentException::class) { + MutableHeaders() + .set("", "Mozilla/5.0") + } + + expectException(IllegalArgumentException::class) { + Header("", "Mozilla/5.0") + } + } + + @Test + fun `Iterator usage`() { + val headers = MutableHeaders().apply { + set("User-Agent", "Mozilla/5.0") + set("Connection", "keep-alive") + set("Accept-Encoding", "gzip") + } + + var i = 0 + headers.forEach { _ -> i++ } + + assertEquals(3, i) + + assertNotNull(headers.firstOrNull { header -> header.name == "User-Agent" }) + } + + @Test + fun `Creating and modifying headers`() { + val headers = MutableHeaders( + "Accept" to "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Encoding" to "gzip, deflate", + "Accept-Language" to "en-US,en;q=0.5", + "Connection" to "keep-alive", + "Dnt" to "1", + "User-Agent" to "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:65.0) Gecko/20100101 Firefox/65.0", + ) + + headers.set("Dnt", "0") + headers.set("User-Agent", "Mozilla/6.0") + headers.append("Accept", "*/*") + + assertEquals(7, headers.size) + + assertEquals("Accept", headers[0].name) + assertEquals("Accept-Encoding", headers[1].name) + assertEquals("Accept-Language", headers[2].name) + assertEquals("Connection", headers[3].name) + assertEquals("Dnt", headers[4].name) + assertEquals("User-Agent", headers[5].name) + assertEquals("Accept", headers[6].name) + + assertEquals("text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", headers[0].value) + assertEquals("gzip, deflate", headers[1].value) + assertEquals("en-US,en;q=0.5", headers[2].value) + assertEquals("keep-alive", headers[3].value) + assertEquals("0", headers[4].value) + assertEquals("Mozilla/6.0", headers[5].value) + assertEquals("*/*", headers[6].value) + } + + @Test + fun `In operator`() { + val headers = MutableHeaders().apply { + set("User-Agent", "Mozilla/5.0") + set("Connection", "keep-alive") + set("Accept-Encoding", "gzip") + } + + assertTrue("User-Agent" in headers) + assertTrue("Connection" in headers) + assertTrue("Accept-Encoding" in headers) + + assertFalse("Accept-Language" in headers) + assertFalse("Accept" in headers) + assertFalse("Dnt" in headers) + } + + @Test + fun `Get multiple headers by name`() { + val headers = MutableHeaders().apply { + append("Accept-Encoding", "gzip") + append("Accept-Encoding", "deflate") + append("Connection", "keep-alive") + } + + val values = headers.getAll("Accept-Encoding") + assertEquals(2, values.size) + assertEquals("gzip", values[0]) + assertEquals("deflate", values[1]) + } + + @Test + fun `Getting headers by name`() { + val headers = MutableHeaders().apply { + append("Accept-Encoding", "gzip") + append("Accept-Encoding", "deflate") + append("Connection", "keep-alive") + } + + assertEquals("deflate", headers["Accept-Encoding"]) + assertEquals("keep-alive", headers["Connection"]) + } +} diff --git a/mobile/android/android-components/components/concept/fetch/src/test/java/mozilla/components/concept/fetch/RequestTest.kt b/mobile/android/android-components/components/concept/fetch/src/test/java/mozilla/components/concept/fetch/RequestTest.kt new file mode 100644 index 0000000000..d6b05456da --- /dev/null +++ b/mobile/android/android-components/components/concept/fetch/src/test/java/mozilla/components/concept/fetch/RequestTest.kt @@ -0,0 +1,191 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.concept.fetch + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.support.test.mock +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.doThrow +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.net.URLEncoder +import java.util.UUID +import java.util.concurrent.TimeUnit + +@RunWith(AndroidJUnit4::class) +class RequestTest { + + @Test + fun `URL-only Request`() { + val request = Request("https://www.mozilla.org") + + assertEquals("https://www.mozilla.org", request.url) + assertEquals(Request.Method.GET, request.method) + } + + @Test + fun `Fully configured Request`() { + val request = Request( + url = "https://www.mozilla.org", + method = Request.Method.POST, + headers = MutableHeaders( + "Accept-Language" to "en-US,en;q=0.5", + "Connection" to "keep-alive", + "Dnt" to "1", + ), + connectTimeout = Pair(10, TimeUnit.SECONDS), + readTimeout = Pair(1, TimeUnit.MINUTES), + body = Request.Body.fromString("Hello World!"), + redirect = Request.Redirect.MANUAL, + cookiePolicy = Request.CookiePolicy.INCLUDE, + useCaches = true, + referrerUrl = "https://mozilla.org", + conservative = true, + ) + + assertEquals("https://www.mozilla.org", request.url) + assertEquals(Request.Method.POST, request.method) + + assertEquals(10, request.connectTimeout!!.first) + assertEquals(TimeUnit.SECONDS, request.connectTimeout!!.second) + + assertEquals(1, request.readTimeout!!.first) + assertEquals(TimeUnit.MINUTES, request.readTimeout!!.second) + + assertEquals("Hello World!", request.body!!.useStream { it.bufferedReader().readText() }) + assertEquals(Request.Redirect.MANUAL, request.redirect) + assertEquals(Request.CookiePolicy.INCLUDE, request.cookiePolicy) + assertEquals(true, request.useCaches) + assertEquals("https://mozilla.org", request.referrerUrl) + assertEquals(true, request.conservative) + + val headers = request.headers!! + assertEquals(3, headers.size) + + assertEquals("Accept-Language", headers[0].name) + assertEquals("Connection", headers[1].name) + assertEquals("Dnt", headers[2].name) + + assertEquals("en-US,en;q=0.5", headers[0].value) + assertEquals("keep-alive", headers[1].value) + assertEquals("1", headers[2].value) + } + + @Test + fun `Create request body from string`() { + val body = Request.Body.fromString("Hello World") + assertEquals("Hello World", body.readText()) + } + + @Test + fun `Create request body from file`() { + val file = File.createTempFile(UUID.randomUUID().toString(), UUID.randomUUID().toString()) + file.writer().use { it.write("Banana") } + + val body = Request.Body.fromFile(file) + assertEquals("Banana", body.readText()) + } + + @Test + fun `WHEN creating a request body from empty params THEN the empty string is returned`() { + assertEquals("", Request.Body.fromParamsForFormUrlEncoded().readText()) + } + + @Test + fun `WHEN creating a request body from params with empty keys or values THEN they are represented as the empty string in the result`() { + // In practice, we don't expect anyone to do this but this test is here as to documentation of what happens. + val expected = "=value&hello=world&key=" + val body = Request.Body.fromParamsForFormUrlEncoded( + "" to "value", + "hello" to "world", + "key" to "", + ) + assertEquals(expected, body.readText()) + } + + @Test + fun `WHEN creating a request body from non-alphabetized params for urlencoded THEN it's in the correct format and ordering`() { + val inputUrl = "https://github.com/mozilla-mobile/android-components/issues/2394" + val encodedURL = URLEncoder.encode(inputUrl, Charsets.UTF_8.name()) + val expected = "v=2&url=$encodedURL" + + val body = Request.Body.fromParamsForFormUrlEncoded( + "v" to "2", + "url" to inputUrl, + ) + assertEquals(expected, body.readText()) + } + + @Test + fun `Closing body closes stream`() { + val stream: InputStream = mock() + + val body = Request.Body(stream) + + verify(stream, never()).close() + + body.close() + + verify(stream).close() + } + + @Test + fun `Using stream closes stream`() { + val stream: InputStream = mock() + + val body = Request.Body(stream) + + verify(stream, never()).close() + + body.useStream { + // Do nothing + } + + verify(stream).close() + } + + @Test + fun `Stream throwing on close`() { + val stream: InputStream = mock() + doThrow(IOException()).`when`(stream).close() + + val body = Request.Body(stream) + body.close() + } + + @Test(expected = IllegalStateException::class) + fun `useStream rethrows and closes stream`() { + val stream: InputStream = mock() + val body = Request.Body(stream) + + try { + body.useStream { + throw IllegalStateException() + } + } finally { + verify(stream).close() + } + } + + @Test + fun `Is a blob Request`() { + var request = Request(url = "blob:https://mdn.mozillademos.org/d518464c-5075-9046") + + assertTrue(request.isBlobUri()) + + request = Request(url = "https://mdn.mozillademos.org/d518464c-5075-9046") + + assertFalse(request.isBlobUri()) + } +} + +private fun Request.Body.readText(): String = useStream { it.bufferedReader().readText() } diff --git a/mobile/android/android-components/components/concept/fetch/src/test/java/mozilla/components/concept/fetch/ResponseTest.kt b/mobile/android/android-components/components/concept/fetch/src/test/java/mozilla/components/concept/fetch/ResponseTest.kt new file mode 100644 index 0000000000..625f580d3f --- /dev/null +++ b/mobile/android/android-components/components/concept/fetch/src/test/java/mozilla/components/concept/fetch/ResponseTest.kt @@ -0,0 +1,258 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.concept.fetch + +import mozilla.components.concept.fetch.Headers.Names.CONTENT_TYPE +import mozilla.components.support.test.mock +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.Mockito +import org.mockito.Mockito.never +import org.mockito.Mockito.spy +import org.mockito.Mockito.verify +import java.io.IOException +import java.io.InputStream + +class ResponseTest { + @Test + fun `Creating String from Body`() { + val stream = "Hello World".byteInputStream() + + val body = spy(Response.Body(stream)) + assertEquals("Hello World", body.string()) + + verify(body).close() + } + + @Test + fun `Creating BufferedReader from Body`() { + val stream = "Hello World".byteInputStream() + + val body = spy(Response.Body(stream)) + + var readerUsed = false + body.useBufferedReader { reader -> + assertEquals("Hello World", reader.readText()) + readerUsed = true + } + + assertTrue(readerUsed) + + verify(body).close() + } + + @Test + fun `Creating BufferedReader from Body with custom Charset `() { + var stream = "ÄäÖöÜü".byteInputStream(Charsets.ISO_8859_1) + var body = spy(Response.Body(stream, "text/plain; charset=UTF-8")) + var readerUsed = false + body.useBufferedReader { reader -> + assertNotEquals("ÄäÖöÜü", reader.readText()) + readerUsed = true + } + assertTrue(readerUsed) + + stream = "ÄäÖöÜü".byteInputStream(Charsets.ISO_8859_1) + body = spy(Response.Body(stream, "text/plain; charset=UTF-8")) + readerUsed = false + body.useBufferedReader(Charsets.ISO_8859_1) { reader -> + assertEquals("ÄäÖöÜü", reader.readText()) + readerUsed = true + } + assertTrue(readerUsed) + + verify(body).close() + } + + @Test + fun `Creating String from Body with custom Charset `() { + var stream = "ÄäÖöÜü".byteInputStream(Charsets.ISO_8859_1) + var body = spy(Response.Body(stream, "text/plain; charset=UTF-8")) + assertNotEquals("ÄäÖöÜü", body.string()) + + stream = "ÄäÖöÜü".byteInputStream(Charsets.ISO_8859_1) + body = spy(Response.Body(stream, "text/plain; charset=UTF-8")) + assertEquals("ÄäÖöÜü", body.string(Charsets.ISO_8859_1)) + + verify(body).close() + } + + @Test + fun `Creating Body with invalid charset falls back to UTF-8`() { + var stream = "ÄäÖöÜü".byteInputStream(Charsets.UTF_8) + var body = spy(Response.Body(stream, "text/plain; charset=invalid")) + var readerUsed = false + body.useBufferedReader { reader -> + assertEquals("ÄäÖöÜü", reader.readText()) + readerUsed = true + } + assertTrue(readerUsed) + + verify(body).close() + } + + @Test + fun `Using InputStream from Body`() { + val body = spy(Response.Body("Hello World".byteInputStream())) + + var streamUsed = false + body.useStream { stream -> + assertEquals("Hello World", stream.bufferedReader().readText()) + streamUsed = true + } + + assertTrue(streamUsed) + + verify(body).close() + } + + @Test + fun `Closing Body closes stream`() { + val stream = spy("Hello World".byteInputStream()) + + val body = spy(Response.Body(stream)) + body.close() + + verify(stream).close() + } + + @Test + fun `success() extension function returns true for 2xx response codes`() { + assertTrue(Response("https://www.mozilla.org", 200, headers = mock(), body = mock()).isSuccess) + assertTrue(Response("https://www.mozilla.org", 203, headers = mock(), body = mock()).isSuccess) + + assertFalse(Response("https://www.mozilla.org", 404, headers = mock(), body = mock()).isSuccess) + assertFalse(Response("https://www.mozilla.org", 500, headers = mock(), body = mock()).isSuccess) + assertFalse(Response("https://www.mozilla.org", 302, headers = mock(), body = mock()).isSuccess) + } + + @Test + fun `clientError() extension function returns true for 4xx response codes`() { + assertTrue(Response("https://www.mozilla.org", 404, headers = mock(), body = mock()).isClientError) + assertTrue(Response("https://www.mozilla.org", 403, headers = mock(), body = mock()).isClientError) + + assertFalse(Response("https://www.mozilla.org", 200, headers = mock(), body = mock()).isClientError) + assertFalse(Response("https://www.mozilla.org", 203, headers = mock(), body = mock()).isClientError) + assertFalse(Response("https://www.mozilla.org", 500, headers = mock(), body = mock()).isClientError) + assertFalse(Response("https://www.mozilla.org", 302, headers = mock(), body = mock()).isClientError) + } + + @Test + fun `Fully configured Response`() { + val response = Response( + url = "https://www.mozilla.org", + status = 200, + headers = MutableHeaders( + CONTENT_TYPE to "text/html; charset=utf-8", + "Connection" to "Close", + "Expires" to "Thu, 08 Nov 2018 15:41:43 GMT", + ), + body = Response.Body("Hello World".byteInputStream()), + ) + + assertEquals("https://www.mozilla.org", response.url) + assertEquals(200, response.status) + assertEquals("Hello World", response.body.string()) + + val headers = response.headers + assertEquals(3, headers.size) + + assertEquals("Content-Type", headers[0].name) + assertEquals("Connection", headers[1].name) + assertEquals("Expires", headers[2].name) + + assertEquals("text/html; charset=utf-8", headers[0].value) + assertEquals("Close", headers[1].value) + assertEquals("Thu, 08 Nov 2018 15:41:43 GMT", headers[2].value) + } + + @Test + fun `Closing body closes stream of body`() { + val stream: InputStream = mock() + val response = Response("url", 200, MutableHeaders(), Response.Body(stream)) + + verify(stream, never()).close() + + response.body.close() + + verify(stream).close() + } + + @Test + fun `Closing response closes stream of body`() { + val stream: InputStream = mock() + val response = Response("url", 200, MutableHeaders(), Response.Body(stream)) + + verify(stream, never()).close() + + response.close() + + verify(stream).close() + } + + @Test + fun `Empty body`() { + val body = Response.Body.empty() + assertEquals("", body.string()) + } + + @Test + fun `Creating string closes stream`() { + val stream: InputStream = spy("".byteInputStream()) + val body = Response.Body(stream) + + verify(stream, never()).close() + + body.string() + + verify(stream).close() + } + + @Test(expected = TestException::class) + fun `Using buffered reader closes stream`() { + val stream: InputStream = spy("".byteInputStream()) + val body = Response.Body(stream) + + verify(stream, never()).close() + + try { + body.useBufferedReader { + throw TestException() + } + } finally { + verify(stream).close() + } + } + + @Test(expected = TestException::class) + fun `Using stream closes stream`() { + val stream: InputStream = spy("".byteInputStream()) + val body = Response.Body(stream) + + verify(stream, never()).close() + + try { + body.useStream { + throw TestException() + } + } finally { + verify(stream).close() + } + } + + @Test + fun `Stream throwing on close`() { + val stream: InputStream = mock() + Mockito.doThrow(IOException()).`when`(stream).close() + + val body = Response.Body(stream) + body.close() + } +} + +private class TestException : RuntimeException() diff --git a/mobile/android/android-components/components/concept/fetch/src/test/java/mozilla/components/concept/fetch/interceptor/InterceptorTest.kt b/mobile/android/android-components/components/concept/fetch/src/test/java/mozilla/components/concept/fetch/interceptor/InterceptorTest.kt new file mode 100644 index 0000000000..3237665c12 --- /dev/null +++ b/mobile/android/android-components/components/concept/fetch/src/test/java/mozilla/components/concept/fetch/interceptor/InterceptorTest.kt @@ -0,0 +1,132 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.concept.fetch.interceptor + +import mozilla.components.concept.fetch.Client +import mozilla.components.concept.fetch.MutableHeaders +import mozilla.components.concept.fetch.Request +import mozilla.components.concept.fetch.Response +import mozilla.components.concept.fetch.isSuccess +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class InterceptorTest { + @Test + fun `Interceptors are invoked`() { + var interceptorInvoked1 = false + var interceptorInvoked2 = false + + val interceptor1 = object : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + interceptorInvoked1 = true + return chain.proceed(chain.request) + } + } + + val interceptor2 = object : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + interceptorInvoked2 = true + return chain.proceed(chain.request) + } + } + + val fake = FakeClient() + val client = fake.withInterceptors(interceptor1, interceptor2) + + assertFalse(interceptorInvoked1) + assertFalse(interceptorInvoked2) + + val response = client.fetch(Request(url = "https://www.mozilla.org")) + assertTrue(fake.resourceFetched) + assertTrue(response.isSuccess) + + assertTrue(interceptorInvoked1) + assertTrue(interceptorInvoked2) + } + + @Test + fun `Interceptors are invoked in order`() { + val order = mutableListOf<String>() + + val fake = FakeClient() + val client = fake.withInterceptors( + object : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + assertEquals("https://www.mozilla.org", chain.request.url) + order.add("A") + return chain.proceed( + chain.request.copy( + url = chain.request.url + "/a", + ), + ) + } + }, + object : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + assertEquals("https://www.mozilla.org/a", chain.request.url) + order.add("B") + return chain.proceed( + chain.request.copy( + url = chain.request.url + "/b", + ), + ) + } + }, + object : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + assertEquals("https://www.mozilla.org/a/b", chain.request.url) + order.add("C") + return chain.proceed( + chain.request.copy( + url = chain.request.url + "/c", + ), + ) + } + }, + ) + + val response = client.fetch(Request(url = "https://www.mozilla.org")) + assertTrue(fake.resourceFetched) + assertTrue(response.isSuccess) + + assertEquals("https://www.mozilla.org/a/b/c", response.url) + assertEquals(listOf("A", "B", "C"), order) + } + + @Test + fun `Intercepted request is never fetched`() { + val fake = FakeClient() + val client = fake.withInterceptors( + object : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + return Response("https://www.firefox.com", 203, MutableHeaders(), Response.Body.empty()) + } + }, + ) + + val response = client.fetch(Request(url = "https://www.mozilla.org")) + assertFalse(fake.resourceFetched) + assertTrue(response.isSuccess) + assertEquals(203, response.status) + } +} + +private class FakeClient( + val response: Response? = null, +) : Client() { + var resourceFetched = false + + override fun fetch(request: Request): Response { + resourceFetched = true + return response ?: Response( + url = request.url, + status = 200, + body = Response.Body.empty(), + headers = MutableHeaders(), + ) + } +} diff --git a/mobile/android/android-components/components/concept/fetch/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobile/android/android-components/components/concept/fetch/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000000..cf1c399ea8 --- /dev/null +++ b/mobile/android/android-components/components/concept/fetch/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1,2 @@ +mock-maker-inline +// This allows mocking final classes (classes are final by default in Kotlin) diff --git a/mobile/android/android-components/components/concept/fetch/src/test/resources/robolectric.properties b/mobile/android/android-components/components/concept/fetch/src/test/resources/robolectric.properties new file mode 100644 index 0000000000..932b01b9eb --- /dev/null +++ b/mobile/android/android-components/components/concept/fetch/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=28 |