summaryrefslogtreecommitdiffstats
path: root/mobile/android/android-components/components/concept/fetch/src
diff options
context:
space:
mode:
Diffstat (limited to 'mobile/android/android-components/components/concept/fetch/src')
-rw-r--r--mobile/android/android-components/components/concept/fetch/src/main/AndroidManifest.xml4
-rw-r--r--mobile/android/android-components/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Client.kt98
-rw-r--r--mobile/android/android-components/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Headers.kt168
-rw-r--r--mobile/android/android-components/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Request.kt190
-rw-r--r--mobile/android/android-components/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/Response.kt160
-rw-r--r--mobile/android/android-components/components/concept/fetch/src/main/java/mozilla/components/concept/fetch/interceptor/Interceptor.kt93
-rw-r--r--mobile/android/android-components/components/concept/fetch/src/test/java/mozilla/components/concept/fetch/ClientTest.kt36
-rw-r--r--mobile/android/android-components/components/concept/fetch/src/test/java/mozilla/components/concept/fetch/HeadersTest.kt240
-rw-r--r--mobile/android/android-components/components/concept/fetch/src/test/java/mozilla/components/concept/fetch/RequestTest.kt191
-rw-r--r--mobile/android/android-components/components/concept/fetch/src/test/java/mozilla/components/concept/fetch/ResponseTest.kt258
-rw-r--r--mobile/android/android-components/components/concept/fetch/src/test/java/mozilla/components/concept/fetch/interceptor/InterceptorTest.kt132
-rw-r--r--mobile/android/android-components/components/concept/fetch/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker2
-rw-r--r--mobile/android/android-components/components/concept/fetch/src/test/resources/robolectric.properties1
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