diff --git a/.gitmodules b/.gitmodules index c28d113725..6f838176bd 100644 --- a/.gitmodules +++ b/.gitmodules @@ -5,3 +5,6 @@ [submodule "vspace-bridge"] path = vspace-bridge url = git@git.shanqu.cc:cwzs/android/vspace-bridge.git +[submodule "module_common/src/debug/assets/assistant-android-mock"] + path = module_common/src/debug/assets/assistant-android-mock + url = git@git.shanqu.cc:halo/android/assistant-android-mock.git diff --git a/dependencies.gradle b/dependencies.gradle index b3eddc986f..3ad6958d46 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -46,6 +46,7 @@ ext { rxBinding2 = "2.1.1" retrofit = "2.3.0" okHttp = "3.12.13" + okHttpForDebug = "3.14.9" gson = "2.8.2" zxing = "3.2.1" fresco = "2.4.0" diff --git a/module_common/build.gradle b/module_common/build.gradle index 719d3e7c5a..6c4ab458b6 100644 --- a/module_common/build.gradle +++ b/module_common/build.gradle @@ -89,5 +89,9 @@ dependencies { api "com.lg:skeleton:${skeleton}" api "com.google.android:flexbox:${flexbox}" + debugApi "com.squareup.okhttp3:okhttp-tls:${okHttpForDebug}" + debugApi "com.squareup.okio:okio:2.2.2" + debugApi "junit:junit:4.12" + api(project(path: ":module_core")) } \ No newline at end of file diff --git a/module_common/src/debug/assets/assistant-android-mock b/module_common/src/debug/assets/assistant-android-mock new file mode 160000 index 0000000000..9bf0ef9e5f --- /dev/null +++ b/module_common/src/debug/assets/assistant-android-mock @@ -0,0 +1 @@ +Subproject commit 9bf0ef9e5fdc730b33d2700b3305e284a1380620 diff --git a/module_common/src/debug/java/com/gh/gamecenter/Injection.java b/module_common/src/debug/java/com/gh/gamecenter/Injection.java index a204a2fd9e..73dc7acb8f 100644 --- a/module_common/src/debug/java/com/gh/gamecenter/Injection.java +++ b/module_common/src/debug/java/com/gh/gamecenter/Injection.java @@ -3,6 +3,8 @@ package com.gh.gamecenter; import android.app.Application; +import com.gh.gamecenter.mock.MockConfig; + import okhttp3.OkHttpClient; import okhttp3.logging.HttpLoggingInterceptor; @@ -25,6 +27,7 @@ public class Injection { HttpLoggingInterceptor interceptor = new HttpLoggingInterceptor(); interceptor.setLevel(HttpLoggingInterceptor.Level.BODY); builder.addNetworkInterceptor(interceptor); + MockConfig.addBuilderMock(builder); return builder; } diff --git a/module_common/src/debug/java/com/gh/gamecenter/mock/MockConfig.kt b/module_common/src/debug/java/com/gh/gamecenter/mock/MockConfig.kt new file mode 100644 index 0000000000..231bd664df --- /dev/null +++ b/module_common/src/debug/java/com/gh/gamecenter/mock/MockConfig.kt @@ -0,0 +1,14 @@ +package com.gh.gamecenter.mock + +import com.gh.gamecenter.mock.mockinizer.mockinize +import okhttp3.OkHttpClient +object MockConfig { + + private const val MOCK_SWITCH_IS_ON: Boolean = false + + @JvmStatic + fun addBuilderMock(builder: OkHttpClient.Builder) { + if (MOCK_SWITCH_IS_ON) + builder.mockinize(mocks) + } +} \ No newline at end of file diff --git a/module_common/src/debug/java/com/gh/gamecenter/mock/Mocks.kt b/module_common/src/debug/java/com/gh/gamecenter/mock/Mocks.kt new file mode 100644 index 0000000000..488844553f --- /dev/null +++ b/module_common/src/debug/java/com/gh/gamecenter/mock/Mocks.kt @@ -0,0 +1,23 @@ +package com.gh.gamecenter.mock + +import com.gh.gamecenter.common.HaloApp +import com.gh.gamecenter.mock.mockinizer.RequestFilter +import com.gh.gamecenter.mock.mockwebserver3.MockResponse + + +private fun getJsonByAssets(fileName: String): String{ + return HaloApp.getInstance().resources.assets.open("assistant-android-mock/apiMock/${fileName}").bufferedReader().use { + it.readText() + } +} + +val mocks: Map = mapOf( + + RequestFilter("/v5d5d0/home/union") to MockResponse().apply { + setResponseCode(200) + setBody( + getJsonByAssets("home_union.json") + ) + } + +) \ No newline at end of file diff --git a/module_common/src/debug/java/com/gh/gamecenter/mock/mockinizer/Logger.kt b/module_common/src/debug/java/com/gh/gamecenter/mock/mockinizer/Logger.kt new file mode 100644 index 0000000000..ca0900ceff --- /dev/null +++ b/module_common/src/debug/java/com/gh/gamecenter/mock/mockinizer/Logger.kt @@ -0,0 +1,13 @@ +package com.gh.gamecenter.mock.mockinizer + +import android.util.Log + +object DebugLogger : Logger { + override fun d(log: String) { + Log.d("Mockinizer", log) + } +} + +interface Logger { + fun d(log: String) +} \ No newline at end of file diff --git a/module_common/src/debug/java/com/gh/gamecenter/mock/mockinizer/MockWebServerExt.kt b/module_common/src/debug/java/com/gh/gamecenter/mock/mockinizer/MockWebServerExt.kt new file mode 100644 index 0000000000..1d18213b47 --- /dev/null +++ b/module_common/src/debug/java/com/gh/gamecenter/mock/mockinizer/MockWebServerExt.kt @@ -0,0 +1,27 @@ +package com.gh.gamecenter.mock.mockinizer + +import com.gh.gamecenter.mock.mockwebserver3.MockWebServer +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import okhttp3.tls.HandshakeCertificates +import okhttp3.tls.HeldCertificate + + +//原库是固定了一个port = 34567端口 但是固定一个端口的话 会产生崩溃 项目有多个apiService的创建 这边也会调用多次 导致端口被占用 +internal fun MockWebServer.configure(port: Int = ((0..9999).random() + 30000)): MockWebServer { + + GlobalScope.launch { + start(port) + } + + val localhostCertificate = HeldCertificate.Builder() + .addSubjectAlternativeName("127.0.0.1") + .build() + + val serverCertificates = HandshakeCertificates.Builder() + .heldCertificate(localhostCertificate) + .build() + + useHttps(serverCertificates.sslSocketFactory(), false) + return this +} \ No newline at end of file diff --git a/module_common/src/debug/java/com/gh/gamecenter/mock/mockinizer/MockinizerInterceptor.kt b/module_common/src/debug/java/com/gh/gamecenter/mock/mockinizer/MockinizerInterceptor.kt new file mode 100644 index 0000000000..b2e1df95f2 --- /dev/null +++ b/module_common/src/debug/java/com/gh/gamecenter/mock/mockinizer/MockinizerInterceptor.kt @@ -0,0 +1,76 @@ +package com.gh.gamecenter.mock.mockinizer + +import com.gh.gamecenter.mock.mockwebserver3.MockResponse +import com.gh.gamecenter.mock.mockwebserver3.MockWebServer +import okhttp3.HttpUrl +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response + + +class MockinizerInterceptor( + private val mocks: Map = emptyMap(), + private val mockServer: MockWebServer, + private val log: Logger = DebugLogger +) : Interceptor { + + + private fun matchMockForFilterUrl(request: Request): MockResponse? { + val urlPath = request.url().uri().path + for ((requestFilter, mockResponse) in mocks) { + if (requestFilter.path == urlPath) { + return mockResponse + } + } + return null + } + + override fun intercept(chain: Interceptor.Chain): Response { + + fun findMockResponse(request: Request): MockResponse? { + matchMockForFilterUrl(request) + return with(RequestFilter.from(request, log)) { + val foundMockResponse = matchMockForFilterUrl(request) + ?: mocks[this] + ?: mocks[this.copy(body = null)] + ?: mocks[this.copy(headers = null)] + ?: mocks[this.copy(body = null, headers = null)] + + if (foundMockResponse == null) { + log.d("No mocks found for $request") + } else { + log.d( + "Found Mock response: $foundMockResponse " + + "for request: $request" + ) + } + + foundMockResponse + } + } + + fun Interceptor.Chain.findServer(): HttpUrl = + when (findMockResponse(request())) { + is MockResponse -> { + request().url().newBuilder() + .host(mockServer.hostName) + .port(mockServer.port) +// .scheme("http") //TODO: make http - https configurable + .build() + } + else -> + request().url() + } + + return with(chain) { + proceed( + request().newBuilder() + .url(findServer()) + .build() + ) + } + } + +} + + diff --git a/module_common/src/debug/java/com/gh/gamecenter/mock/mockinizer/OkHttpClientExt.kt b/module_common/src/debug/java/com/gh/gamecenter/mock/mockinizer/OkHttpClientExt.kt new file mode 100644 index 0000000000..0a4fa84ada --- /dev/null +++ b/module_common/src/debug/java/com/gh/gamecenter/mock/mockinizer/OkHttpClientExt.kt @@ -0,0 +1,153 @@ +package com.gh.gamecenter.mock.mockinizer + +import com.gh.gamecenter.mock.mockwebserver3.Dispatcher +import com.gh.gamecenter.mock.mockwebserver3.MockResponse +import com.gh.gamecenter.mock.mockwebserver3.MockWebServer +import com.gh.gamecenter.mock.mockwebserver3.RecordedRequest +import okhttp3.Headers +import okhttp3.OkHttpClient +import java.security.cert.X509Certificate +import javax.net.ssl.* + + +/** + * The main function that wires up the [MockWebServer] with [OkHttpClient]. Generally only the + * mocks map needs to be defined. The default values for the other params should be fine for most + * projects. + * + * @param mocks Map of RequestFilter / MockResponse Entries to define requests that + * should be directed to the mock server instead of the real one. default value is an empty map. + * @param trustManagers Array of TrustManager to be used for https connections with mock server + * default value is an all trusting manager + * @param socketFactory SSLSocketFactory to be used for RetroFit https connections + * default value is using the previously defined trustManagers + * @param hostnameVerifier HostNameVerifier the interface to be used to verify hostnames. + * default value is an all verifying verifier + * + * @return OkHttpClient.Builder for chaining + */ +fun OkHttpClient.Builder.mockinize( + mocks: Map = mapOf(), + mockWebServer: MockWebServer = MockWebServer().configure(), + trustManagers: Array = getAllTrustingManagers(), + socketFactory: SSLSocketFactory = getSslSocketFactory(trustManagers), + hostnameVerifier: HostnameVerifier = HostnameVerifier { _, _ -> true }, + log: Logger = DebugLogger +): OkHttpClient.Builder { + + addInterceptor(MockinizerInterceptor(mocks, mockWebServer)) + .sslSocketFactory(socketFactory, trustManagers[0] as X509TrustManager) + .hostnameVerifier(hostnameVerifier) + Mockinizer.init(mockWebServer, mocks) + log.d("Mockinized $this with mocks: $mocks and MockWebServer $mockWebServer") + return this +} + +private fun getSslSocketFactory(trustManagers: Array): SSLSocketFactory = + SSLContext.getInstance("SSL").apply { + init(null, trustManagers, java.security.SecureRandom()) + }.socketFactory + +private fun getAllTrustingManagers(): Array = arrayOf( + object : X509TrustManager { + + override fun getAcceptedIssuers(): Array = emptyArray() + + override fun checkClientTrusted( + chain: Array, + authType: String + ) { + } + + override fun checkServerTrusted( + chain: Array, + authType: String + ) { + } + } +) + +internal class MockDispatcher(private val mocks: Map) : Dispatcher() { + + @Throws(InterruptedException::class) + override fun dispatch(request: RecordedRequest): MockResponse { + return with(RequestFilter.from(request)) { + mocks[this] + ?: mocks[copy(body = null)] + ?: mocks[copy(headers = request.headers.withClearedOkhttpHeaders())] + ?: mocks[copy(headers = null)] + ?: mocks[copy(query = null)] + ?: mocks[copy(body = null, headers = request.headers.withClearedOkhttpHeaders())] + ?: mocks[copy(body = null, headers = null)] + ?: mocks[copy(body = null, query = null)] + ?: mocks[copy(headers = null, query = null)] + ?: mocks[copy(headers = request.headers.withClearedOkhttpHeaders(), query = null)] + ?: mocks[copy(body = null, headers = null, query = null)] + ?: mocks[copy(body = null, headers = request.headers.withClearedOkhttpHeaders(), query = null)] + ?: MockResponse() + .setResponseCode(404) + .setBody("""{ + |"error":"Mockinizer could not dispatch response for $request", + |"requestFilter":"$this"" + |}""".trimMargin()) + } + } + + /** + * Removes headers that OkHttp would add to RecordedRequest + */ + private fun Headers.withClearedOkhttpHeaders() = + if ( + get(":authority")?.startsWith("localhost:") == true && + get(":scheme")?.matches("https?".toRegex()) == true && + get("accept-encoding") == "gzip" && + get("user-agent")?.startsWith("okhttp/") == true + ) { + newBuilder() + .removeAll(":authority") + .removeAll(":scheme") + .removeAll("accept-encoding") + .removeAll("user-agent") + .build() + } else { + this + } +} + +object Mockinizer { + + internal var mockWebServer: MockWebServer? = null + + internal fun init( + mockWebServer: MockWebServer, + mocks: Map + ) { + + mocks.entries.forEach { (requestFilter, mockResponse) -> + mockResponse.setHeader( + "Mockinizer", + " <-- Real request ${requestFilter.path} is now mocked to $mockResponse" + ) + mockResponse.setHeader( + "server", + "Mockinizer by Thomas Fuchs-Martin" + ) + } + + mockWebServer.dispatcher = MockDispatcher(mocks) + + Mockinizer.mockWebServer = mockWebServer + + } + + @JvmStatic + fun start(port: Int = 34567) { + mockWebServer?.start(port) + } + + @JvmStatic + fun shutDown() { + mockWebServer?.shutdown() + } + +} diff --git a/module_common/src/debug/java/com/gh/gamecenter/mock/mockinizer/RequestFilter.kt b/module_common/src/debug/java/com/gh/gamecenter/mock/mockinizer/RequestFilter.kt new file mode 100644 index 0000000000..bd9d807f72 --- /dev/null +++ b/module_common/src/debug/java/com/gh/gamecenter/mock/mockinizer/RequestFilter.kt @@ -0,0 +1,82 @@ +package com.gh.gamecenter.mock.mockinizer + +import com.gh.gamecenter.mock.mockwebserver3.RecordedRequest +import okhttp3.Headers +import okhttp3.Request +import okhttp3.RequestBody +import okio.Buffer + +/** + * This class is to define the requests that should get filtered and served by the mock server. + * Setting a parameter to null means that any values for that parameter should be filtered. + * @param path the path part of the request url. The default is null + * @param query the query part of the request url. The default is null + * @param method the method of the request. The default is GET + * @param body the request body. Cannot be used together with GET requests. The default is null + * @param headers the http headers to filter. The default is null headers + */ +data class RequestFilter( + val path: String? = null, + val query: String? = null, + val method: Method = Method.GET, + val body: String? = null, + val headers: Headers? = null +) { + + companion object { + + fun from(request: Request, log: Logger = DebugLogger) = + RequestFilter( + path = request.url().encodedPath(), + query = request.url().encodedQuery(), + method = getMethodOrDefault(request.method()), + body = request.body()?.asString(), + headers = request.headers() + ).also { + log.d( + "Created RequestFilter $it \n" + + " for request: $request" + ) + } + + fun from(request: RecordedRequest, log: Logger = DebugLogger) = + RequestFilter( + path = request.requestUrl?.encodedPath(), + query = request.requestUrl?.encodedQuery(), + method = getMethodOrDefault(request.method), + body = request.body.clone().readUtf8(), + headers = request.headers + ).also { + log.d( + "Created RequestFilter $it \n" + + " for recorded request: $request" + ) + } + + private fun getMethodOrDefault(method: String?) = + try { + Method.valueOf(method.orEmpty()) + } catch (e: IllegalArgumentException) { + Method.GET + } + } +} + +enum class Method { + GET, POST, PUT, PATCH, DELETE; +} + +fun RequestFilter.url(): String = when (query.isNullOrBlank()) { + true -> path.orEmpty() + false -> path.orEmpty().plus("?").plus(query) +} + +fun RequestBody.asString(): String { + val buffer = Buffer() + writeTo(buffer) + return buffer.readUtf8() +} + +fun RecordedRequest.asString(): String { + return "$path - $method - ${body.clone().readUtf8()} - $headers" +} diff --git a/module_common/src/debug/java/com/gh/gamecenter/mock/mockwebserver3/Dispatcher.kt b/module_common/src/debug/java/com/gh/gamecenter/mock/mockwebserver3/Dispatcher.kt new file mode 100644 index 0000000000..53aa008464 --- /dev/null +++ b/module_common/src/debug/java/com/gh/gamecenter/mock/mockwebserver3/Dispatcher.kt @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2012 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.gh.gamecenter.mock.mockwebserver3 + + +/** Handler for mock server requests. */ +abstract class Dispatcher { + /** + * Returns a response to satisfy `request`. This method may block (for instance, to wait on + * a CountdownLatch). + */ + @Throws(InterruptedException::class) + abstract fun dispatch(request: RecordedRequest): MockResponse + + /** + * Returns an early guess of the next response, used for policy on how an incoming request should + * be received. The default implementation returns an empty response. Mischievous implementations + * can return other values to test HTTP edge cases, such as unhappy socket policies or throttled + * request bodies. + */ + open fun peek(): MockResponse { + return MockResponse().apply { this.socketPolicy = SocketPolicy.KEEP_OPEN } + } + + /** + * Release any resources held by this dispatcher. Any requests that are currently being dispatched + * should return immediately. Responses returned after shutdown will not be transmitted: their + * socket connections have already been closed. + */ + open fun shutdown() {} +} diff --git a/module_common/src/debug/java/com/gh/gamecenter/mock/mockwebserver3/MockResponse.kt b/module_common/src/debug/java/com/gh/gamecenter/mock/mockwebserver3/MockResponse.kt new file mode 100644 index 0000000000..9def584b46 --- /dev/null +++ b/module_common/src/debug/java/com/gh/gamecenter/mock/mockwebserver3/MockResponse.kt @@ -0,0 +1,336 @@ +/* + * Copyright (C) 2011 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.gh.gamecenter.mock.mockwebserver3 + +import com.gh.gamecenter.mock.mockwebserver3.internal.duplex.DuplexResponseBody +import okhttp3.Headers +import okhttp3.WebSocketListener +import okhttp3.internal.http2.Settings +import okio.Buffer +import java.util.concurrent.TimeUnit + +/** A scripted response to be replayed by the mock web server. */ +class MockResponse : Cloneable { + /** Returns the HTTP response line, such as "HTTP/1.1 200 OK". */ + @set:JvmName("status") + var status: String = "" + + private var headersBuilder = Headers.Builder() + private var trailersBuilder = Headers.Builder() + + /** The HTTP headers, such as "Content-Length: 0". */ + @set:JvmName("headers") + var headers: Headers + get() = headersBuilder.build() + set(value) { + this.headersBuilder = value.newBuilder() + } + + @set:JvmName("trailers") + var trailers: Headers + get() = trailersBuilder.build() + set(value) { + this.trailersBuilder = value.newBuilder() + } + + private var body: Buffer? = null + + var throttleBytesPerPeriod = Long.MAX_VALUE + private set + private var throttlePeriodAmount = 1L + private var throttlePeriodUnit = TimeUnit.SECONDS + + @set:JvmName("socketPolicy") + var socketPolicy = SocketPolicy.KEEP_OPEN + + /** + * Sets the [HTTP/2 error code](https://tools.ietf.org/html/rfc7540#section-7) to be + * returned when resetting the stream. + * This is only valid with [SocketPolicy.RESET_STREAM_AT_START]. + */ + @set:JvmName("http2ErrorCode") + var http2ErrorCode = -1 + + private var bodyDelayAmount = 0L + private var bodyDelayUnit = TimeUnit.MILLISECONDS + + private var headersDelayAmount = 0L + private var headersDelayUnit = TimeUnit.MILLISECONDS + + private var promises = mutableListOf() + var settings: Settings = Settings() + private set + var webSocketListener: WebSocketListener? = null + private set + var duplexResponseBody: DuplexResponseBody? = null + private set + val isDuplex: Boolean + get() = duplexResponseBody != null + + /** Returns the streams the server will push with this response. */ + val pushPromises: List + get() = promises + + /** Creates a new mock response with an empty body. */ + init { + setResponseCode(200) + setHeader("Content-Length", 0L) + } + + public override fun clone(): MockResponse { + val result = super.clone() as MockResponse + result.headersBuilder = headersBuilder.build().newBuilder() + result.promises = promises.toMutableList() + return result + } + + @JvmName("-deprecated_getStatus") + @Deprecated( + message = "moved to var", + replaceWith = ReplaceWith(expression = "status"), + level = DeprecationLevel.ERROR) + fun getStatus(): String = status + + @Deprecated( + message = "moved to var. Replace setStatus(...) with status(...) to fix Java", + replaceWith = ReplaceWith(expression = "apply { this.status = status }"), + level = DeprecationLevel.WARNING) + fun setStatus(status: String) = apply { + this.status = status + } + + fun setResponseCode(code: Int): MockResponse { + val reason = when (code) { + in 100..199 -> "Informational" + in 200..299 -> "OK" + in 300..399 -> "Redirection" + in 400..499 -> "Client Error" + in 500..599 -> "Server Error" + else -> "Mock Response" + } + return apply { status = "HTTP/1.1 $code $reason" } + } + + /** + * Removes all HTTP headers including any "Content-Length" and "Transfer-encoding" headers that + * were added by default. + */ + fun clearHeaders() = apply { + headersBuilder = Headers.Builder() + } + + /** + * Adds [header] as an HTTP header. For well-formed HTTP [header] should contain a + * name followed by a colon and a value. + */ + fun addHeader(header: String) = apply { + headersBuilder.add(header) + } + + /** + * Adds a new header with the name and value. This may be used to add multiple headers with the + * same name. + */ + fun addHeader(name: String, value: Any) = apply { + headersBuilder.add(name, value.toString()) + } + + /** + * Adds a new header with the name and value. This may be used to add multiple headers with the + * same name. Unlike [addHeader] this does not validate the name and + * value. + */ + fun addHeaderLenient(name: String, value: Any) = apply { + headersBuilder.add(name, value.toString()) + } + + /** + * Removes all headers named [name], then adds a new header with the name and value. + */ + fun setHeader(name: String, value: Any) = apply { + removeHeader(name) + addHeader(name, value) + } + + /** Removes all headers named [name]. */ + fun removeHeader(name: String) = apply { + headersBuilder.removeAll(name) + } + + /** Returns a copy of the raw HTTP payload. */ + fun getBody(): Buffer? = body?.clone() + + fun setBody(body: Buffer) = apply { + setHeader("Content-Length", body.size) + this.body = body.clone() // Defensive copy. + } + + /** Sets the response body to the UTF-8 encoded bytes of [body]. */ + fun setBody(body: String): MockResponse = setBody(Buffer().writeUtf8(body)) + + fun setBody(duplexResponseBody: DuplexResponseBody) = apply { + this.duplexResponseBody = duplexResponseBody + } + + /** + * Sets the response body to [body], chunked every [maxChunkSize] bytes. + */ + fun setChunkedBody(body: Buffer, maxChunkSize: Int) = apply { + removeHeader("Content-Length") + headersBuilder.add(CHUNKED_BODY_HEADER) + + val bytesOut = Buffer() + while (!body.exhausted()) { + val chunkSize = minOf(body.size, maxChunkSize.toLong()) + bytesOut.writeHexadecimalUnsignedLong(chunkSize) + bytesOut.writeUtf8("\r\n") + bytesOut.write(body, chunkSize) + bytesOut.writeUtf8("\r\n") + } + bytesOut.writeUtf8("0\r\n") // Last chunk. Trailers follow! + this.body = bytesOut + } + + /** + * Sets the response body to the UTF-8 encoded bytes of [body], + * chunked every [maxChunkSize] bytes. + */ + fun setChunkedBody(body: String, maxChunkSize: Int): MockResponse = + setChunkedBody(Buffer().writeUtf8(body), maxChunkSize) + + @JvmName("-deprecated_getHeaders") + @Deprecated( + message = "moved to var", + replaceWith = ReplaceWith(expression = "headers"), + level = DeprecationLevel.ERROR) + fun getHeaders(): Headers = headers + + @Deprecated( + message = "moved to var. Replace setHeaders(...) with headers(...) to fix Java", + replaceWith = ReplaceWith(expression = "apply { this.headers = headers }"), + level = DeprecationLevel.WARNING) + fun setHeaders(headers: Headers) = apply { this.headers = headers } + + @JvmName("-deprecated_getTrailers") + @Deprecated( + message = "moved to var", + replaceWith = ReplaceWith(expression = "trailers"), + level = DeprecationLevel.ERROR) + fun getTrailers(): Headers = trailers + + @Deprecated( + message = "moved to var. Replace setTrailers(...) with trailers(...) to fix Java", + replaceWith = ReplaceWith(expression = "apply { this.trailers = trailers }"), + level = DeprecationLevel.WARNING) + fun setTrailers(trailers: Headers) = apply { this.trailers = trailers } + + @JvmName("-deprecated_getSocketPolicy") + @Deprecated( + message = "moved to var", + replaceWith = ReplaceWith(expression = "socketPolicy"), + level = DeprecationLevel.ERROR) + fun getSocketPolicy() = socketPolicy + + @Deprecated( + message = "moved to var. Replace setSocketPolicy(...) with socketPolicy(...) to fix Java", + replaceWith = ReplaceWith(expression = "apply { this.socketPolicy = socketPolicy }"), + level = DeprecationLevel.WARNING) + fun setSocketPolicy(socketPolicy: SocketPolicy) = apply { + this.socketPolicy = socketPolicy + } + + @JvmName("-deprecated_getHttp2ErrorCode") + @Deprecated( + message = "moved to var", + replaceWith = ReplaceWith(expression = "http2ErrorCode"), + level = DeprecationLevel.ERROR) + fun getHttp2ErrorCode() = http2ErrorCode + + @Deprecated( + message = "moved to var. Replace setHttp2ErrorCode(...) with http2ErrorCode(...) to fix Java", + replaceWith = ReplaceWith(expression = "apply { this.http2ErrorCode = http2ErrorCode }"), + level = DeprecationLevel.WARNING) + fun setHttp2ErrorCode(http2ErrorCode: Int) = apply { + this.http2ErrorCode = http2ErrorCode + } + + /** + * Throttles the request reader and response writer to sleep for the given period after each + * series of [bytesPerPeriod] bytes are transferred. Use this to simulate network behavior. + */ + fun throttleBody(bytesPerPeriod: Long, period: Long, unit: TimeUnit) = apply { + throttleBytesPerPeriod = bytesPerPeriod + throttlePeriodAmount = period + throttlePeriodUnit = unit + } + + fun getThrottlePeriod(unit: TimeUnit): Long = + unit.convert(throttlePeriodAmount, throttlePeriodUnit) + + /** + * Set the delayed time of the response body to [delay]. This applies to the response body + * only; response headers are not affected. + */ + fun setBodyDelay(delay: Long, unit: TimeUnit) = apply { + bodyDelayAmount = delay + bodyDelayUnit = unit + } + + fun getBodyDelay(unit: TimeUnit): Long = + unit.convert(bodyDelayAmount, bodyDelayUnit) + + fun setHeadersDelay(delay: Long, unit: TimeUnit) = apply { + headersDelayAmount = delay + headersDelayUnit = unit + } + + fun getHeadersDelay(unit: TimeUnit): Long = + unit.convert(headersDelayAmount, headersDelayUnit) + + /** + * When [protocols][MockWebServer.protocols] include [HTTP_2][okhttp3.Protocol], this attaches a + * pushed stream to this response. + */ + fun withPush(promise: PushPromise) = apply { + promises.add(promise) + } + + /** + * When [protocols][MockWebServer.protocols] include [HTTP_2][okhttp3.Protocol], this pushes + * [settings] before writing the response. + */ + fun withSettings(settings: Settings) = apply { + this.settings = settings + } + + /** + * Attempts to perform a web socket upgrade on the connection. + * This will overwrite any previously set status or body. + */ + fun withWebSocketUpgrade(listener: WebSocketListener) = apply { + status = "HTTP/1.1 101 Switching Protocols" + setHeader("Connection", "Upgrade") + setHeader("Upgrade", "websocket") + body = null + webSocketListener = listener + } + + override fun toString() = status + + companion object { + private const val CHUNKED_BODY_HEADER = "Transfer-encoding: chunked" + } +} diff --git a/module_common/src/debug/java/com/gh/gamecenter/mock/mockwebserver3/MockWebServer.kt b/module_common/src/debug/java/com/gh/gamecenter/mock/mockwebserver3/MockWebServer.kt new file mode 100644 index 0000000000..ed65604c8c --- /dev/null +++ b/module_common/src/debug/java/com/gh/gamecenter/mock/mockwebserver3/MockWebServer.kt @@ -0,0 +1,1174 @@ +/* + * Copyright (C) 2011 Google Inc. + * Copyright (C) 2013 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.gh.gamecenter.mock.mockwebserver3 + +import com.gh.gamecenter.mock.mockwebserver3.internal.duplex.DuplexResponseBody +import okhttp3.Headers +import okhttp3.HttpUrl +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import com.gh.gamecenter.mock.okhttp3.internal.duplex.MwsDuplexAccess +import okhttp3.internal.http.HttpMethod +import okhttp3.internal.http2.ErrorCode +import okhttp3.internal.http2.Header +import okhttp3.internal.http2.Http2Connection +import okhttp3.internal.http2.Http2Stream +import okhttp3.internal.platform.Platform +import okhttp3.internal.ws.RealWebSocket +import okhttp3.internal.ws.WebSocketProtocol +import okio.* + +import okio.ByteString.Companion.encodeUtf8 +import org.junit.rules.ExternalResource + +import java.io.Closeable +import java.io.IOException +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.ProtocolException +import java.net.Proxy +import java.net.ServerSocket +import java.net.Socket +import java.net.SocketException +import java.security.SecureRandom +import java.security.cert.CertificateException +import java.security.cert.X509Certificate +import java.util.* +import java.util.concurrent.* +import java.util.concurrent.atomic.AtomicInteger +import java.util.logging.Level +import java.util.logging.Logger +import javax.net.ServerSocketFactory +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocket +import javax.net.ssl.SSLSocketFactory +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager +import kotlin.jvm.JvmName +import kotlin.jvm.JvmOverloads + +/** + * A scriptable web server. Callers supply canned responses and the server replays them upon request + * in sequence. + */ +class MockWebServer : ExternalResource(), Closeable { + private val requestQueue = LinkedBlockingQueue() + private val openClientSockets = + Collections.newSetFromMap(ConcurrentHashMap()) + private val openConnections = + Collections.newSetFromMap(ConcurrentHashMap()) + + private val atomicRequestCount = AtomicInteger() + + /** + * The number of HTTP requests received thus far by this server. This may exceed the number of + * HTTP connections when connection reuse is in practice. + */ + val requestCount: Int + get() = atomicRequestCount.get() + + /** The number of bytes of the POST body to keep in memory to the given limit. */ + var bodyLimit = Long.MAX_VALUE + + var serverSocketFactory: ServerSocketFactory? = null + get() { + if (field == null && started) { + field = ServerSocketFactory.getDefault() // Build the default value lazily. + } + return field + } + set(value) { + check(!started) { "serverSocketFactory must not be set after start()" } + field = value + } + + private var serverSocket: ServerSocket? = null + private var sslSocketFactory: SSLSocketFactory? = null + private var executor: ExecutorService? = null + private var tunnelProxy: Boolean = false + private var clientAuth = CLIENT_AUTH_NONE + + /** + * The dispatcher used to respond to HTTP requests. The default dispatcher is a [QueueDispatcher], + * which serves a fixed sequence of responses from a [queue][enqueue]. + * + * Other dispatchers can be configured. They can vary the response based on timing or the content + * of the request. + */ + var dispatcher: Dispatcher = QueueDispatcher() + + private var portField: Int = -1 + val port: Int + get() { + before() + return portField + } + + val hostName: String + get() { + before() + return inetSocketAddress!!.address.canonicalHostName + } + + private var inetSocketAddress: InetSocketAddress? = null + + /** + * True if ALPN is used on incoming HTTPS connections to negotiate a protocol like HTTP/1.1 or + * HTTP/2. This is true by default; set to false to disable negotiation and restrict connections + * to HTTP/1.1. + */ + var protocolNegotiationEnabled = true + + /** Returns an immutable list containing [elements]. */ + @SafeVarargs + fun immutableListOf(vararg elements: T): List { + return Collections.unmodifiableList(Arrays.asList(*elements.clone())) + } + + /** + * The protocols supported by ALPN on incoming HTTPS connections in order of preference. The list + * must contain [Protocol.HTTP_1_1]. It must not contain null. + * + * This list is ignored when [negotiation is disabled][protocolNegotiationEnabled]. + */ + @get:JvmName("protocols") var protocols: List = + immutableListOf(Protocol.HTTP_2, Protocol.HTTP_1_1) + set(value) { + val protocolList = Collections.unmodifiableList(value.toMutableList()) + require(Protocol.H2_PRIOR_KNOWLEDGE !in protocolList || protocolList.size == 1) { + "protocols containing h2_prior_knowledge cannot use other protocols: $protocolList" + } + require(Protocol.HTTP_1_1 in protocolList || Protocol.H2_PRIOR_KNOWLEDGE in protocolList) { + "protocols doesn't contain http/1.1: $protocolList" + } + require(null !in protocolList as List) { "protocols must not contain null" } + field = protocolList + } + + private var started: Boolean = false + + @Synchronized override fun before() { + if (started) return + try { + start() + } catch (e: IOException) { + throw RuntimeException(e) + } + } + + @JvmName("-deprecated_port") + @Deprecated( + message = "moved to val", + replaceWith = ReplaceWith(expression = "port"), + level = DeprecationLevel.ERROR) + fun getPort(): Int = port + + fun toProxyAddress(): Proxy { + before() + val address = InetSocketAddress(inetSocketAddress!!.address.canonicalHostName, port) + return Proxy(Proxy.Type.HTTP, address) + } + + @JvmName("-deprecated_serverSocketFactory") + @Deprecated( + message = "moved to var", + replaceWith = ReplaceWith( + expression = "run { this.serverSocketFactory = serverSocketFactory }" + ), + level = DeprecationLevel.ERROR) + fun setServerSocketFactory(serverSocketFactory: ServerSocketFactory) = run { + this.serverSocketFactory = serverSocketFactory + } + + /** + * Returns a URL for connecting to this server. + * + * @param path the request path, such as "/". + */ + fun url(path: String): HttpUrl { + return HttpUrl.Builder() + .scheme(if (sslSocketFactory != null) "https" else "http") + .host(hostName) + .port(port) + .build() + .resolve(path)!! + } + + @JvmName("-deprecated_bodyLimit") + @Deprecated( + message = "moved to var", + replaceWith = ReplaceWith( + expression = "run { this.bodyLimit = bodyLimit }" + ), + level = DeprecationLevel.ERROR) + fun setBodyLimit(bodyLimit: Long) = run { this.bodyLimit = bodyLimit } + + @JvmName("-deprecated_protocolNegotiationEnabled") + @Deprecated( + message = "moved to var", + replaceWith = ReplaceWith( + expression = "run { this.protocolNegotiationEnabled = protocolNegotiationEnabled }" + ), + level = DeprecationLevel.ERROR) + fun setProtocolNegotiationEnabled(protocolNegotiationEnabled: Boolean) = run { + this.protocolNegotiationEnabled = protocolNegotiationEnabled + } + + @JvmName("-deprecated_protocols") + @Deprecated( + message = "moved to var", + replaceWith = ReplaceWith(expression = "run { this.protocols = protocols }"), + level = DeprecationLevel.ERROR) + fun setProtocols(protocols: List) = run { this.protocols = protocols } + + @JvmName("-deprecated_protocols") + @Deprecated( + message = "moved to var", + replaceWith = ReplaceWith(expression = "protocols"), + level = DeprecationLevel.ERROR) + fun protocols(): List = protocols + + /** + * Serve requests with HTTPS rather than otherwise. + * + * @param tunnelProxy true to expect the HTTP CONNECT method before negotiating TLS. + */ + fun useHttps(sslSocketFactory: SSLSocketFactory, tunnelProxy: Boolean) { + this.sslSocketFactory = sslSocketFactory + this.tunnelProxy = tunnelProxy + } + + /** + * Configure the server to not perform SSL authentication of the client. This leaves + * authentication to another layer such as in an HTTP cookie or header. This is the default and + * most common configuration. + */ + fun noClientAuth() { + this.clientAuth = CLIENT_AUTH_NONE + } + + /** + * Configure the server to [want client auth][SSLSocket.setWantClientAuth]. If the + * client presents a certificate that is [trusted][TrustManager] the handshake will + * proceed normally. The connection will also proceed normally if the client presents no + * certificate at all! But if the client presents an untrusted certificate the handshake + * will fail and no connection will be established. + */ + fun requestClientAuth() { + this.clientAuth = CLIENT_AUTH_REQUESTED + } + + /** + * Configure the server to [need client auth][SSLSocket.setNeedClientAuth]. If the + * client presents a certificate that is [trusted][TrustManager] the handshake will + * proceed normally. If the client presents an untrusted certificate or no certificate at all the + * handshake will fail and no connection will be established. + */ + fun requireClientAuth() { + this.clientAuth = CLIENT_AUTH_REQUIRED + } + + /** + * Awaits the next HTTP request, removes it, and returns it. Callers should use this to verify the + * request was sent as intended. This method will block until the request is available, possibly + * forever. + * + * @return the head of the request queue + */ + @Throws(InterruptedException::class) + fun takeRequest(): RecordedRequest = requestQueue.take() + + /** + * Awaits the next HTTP request (waiting up to the specified wait time if necessary), removes it, + * and returns it. Callers should use this to verify the request was sent as intended within the + * given time. + * + * @param timeout how long to wait before giving up, in units of [unit] + * @param unit a [TimeUnit] determining how to interpret the [timeout] parameter + * @return the head of the request queue + */ + @Throws(InterruptedException::class) + fun takeRequest(timeout: Long, unit: TimeUnit): RecordedRequest? = requestQueue.poll(timeout, unit) + + @JvmName("-deprecated_requestCount") + @Deprecated( + message = "moved to val", + replaceWith = ReplaceWith(expression = "requestCount"), + level = DeprecationLevel.ERROR) + fun getRequestCount(): Int = requestCount + + /** + * Scripts [response] to be returned to a request made in sequence. The first request is + * served by the first enqueued response; the second request by the second enqueued response; and + * so on. + * + * @throws ClassCastException if the default dispatcher has been + * replaced with [setDispatcher][dispatcher]. + */ + fun enqueue(response: MockResponse) = + (dispatcher as QueueDispatcher).enqueueResponse(response.clone()) + + /** + * Starts the server on the loopback interface for the given port. + * + * @param port the port to listen to, or 0 for any available port. Automated tests should always + * use port 0 to avoid flakiness when a specific port is unavailable. + */ + @Throws(IOException::class) + @JvmOverloads fun start(port: Int = 0) = start(InetAddress.getByName("localhost"), port) + + /** + * Starts the server on the given address and port. + * + * @param inetAddress the address to create the server socket on + * @param port the port to listen to, or 0 for any available port. Automated tests should always + * use port 0 to avoid flakiness when a specific port is unavailable. + */ + @Throws(IOException::class) + fun start(inetAddress: InetAddress, port: Int) = start(InetSocketAddress(inetAddress, port)) + + + fun threadFactory( + name: String, + daemon: Boolean + ): ThreadFactory = ThreadFactory { runnable -> + Thread(runnable, name).apply { + isDaemon = daemon + } + } + + + /** Execute [block], setting the executing thread's name to [name] for the duration. */ + inline fun Executor.execute(name: String, crossinline block: () -> Unit) { + execute { + threadName(name) { + block() + } + } + } + + inline fun threadName(name: String, block: () -> Unit) { + val currentThread = Thread.currentThread() + val oldName = currentThread.name + currentThread.name = name + try { + block() + } finally { + currentThread.name = oldName + } + } + + /** Closes this, ignoring any checked exceptions. */ + fun ServerSocket.closeQuietly() { + try { + close() + } catch (rethrown: RuntimeException) { + throw rethrown + } catch (_: Exception) { + } + } + + + /** Closes this, ignoring any checked exceptions. */ + fun Socket.closeQuietly() { + try { + close() + } catch (e: AssertionError) { + throw e + } catch (rethrown: RuntimeException) { + throw rethrown + } catch (_: Exception) { + } + } + + + /** Closes this, ignoring any checked exceptions. */ + fun Closeable.closeQuietly() { + try { + close() + } catch (rethrown: RuntimeException) { + throw rethrown + } catch (_: Exception) { + } + } + + /** + * Starts the server and binds to the given socket address. + * + * @param inetSocketAddress the socket address to bind the server on + */ + @Synchronized @Throws(IOException::class) + private fun start(inetSocketAddress: InetSocketAddress) { + require(!started) { "start() already called" } + started = true + + executor = Executors.newCachedThreadPool(threadFactory("MockWebServer", false)) + this.inetSocketAddress = inetSocketAddress + + serverSocket = serverSocketFactory!!.createServerSocket() + + // Reuse if the user specified a port + serverSocket!!.reuseAddress = inetSocketAddress.port != 0 + serverSocket!!.bind(inetSocketAddress, 50) + + portField = serverSocket!!.localPort + executor!!.execute("MockWebServer $portField") { + try { + logger.info("${this@MockWebServer} starting to accept connections") + acceptConnections() + } catch (e: Throwable) { + logger.log(Level.WARNING, "${this@MockWebServer} failed unexpectedly", e) + } + + // Release all sockets and all threads, even if any close fails. + serverSocket?.closeQuietly() + + val openClientSocket = openClientSockets.iterator() + while (openClientSocket.hasNext()) { + openClientSocket.next().closeQuietly() + openClientSocket.remove() + } + + val httpConnection = openConnections.iterator() + while (httpConnection.hasNext()) { + httpConnection.next().closeQuietly() + httpConnection.remove() + } + dispatcher.shutdown() + executor!!.shutdown() + } + } + + @Throws(Exception::class) + private fun acceptConnections() { + while (true) { + val socket: Socket + try { + socket = serverSocket!!.accept() + } catch (e: SocketException) { + logger.info("${this@MockWebServer} done accepting connections: ${e.message}") + return + } + + val socketPolicy = dispatcher.peek().socketPolicy + if (socketPolicy === SocketPolicy.DISCONNECT_AT_START) { + dispatchBookkeepingRequest(0, socket) + socket.close() + } else { + openClientSockets.add(socket) + serveConnection(socket) + } + } + } + + @Synchronized + @Throws(IOException::class) + fun shutdown() { + if (!started) return + require(serverSocket != null) { "shutdown() before start()" } + + // Cause acceptConnections() to break out. + serverSocket!!.close() + + // Await shutdown. + try { + if (!executor!!.awaitTermination(5, TimeUnit.SECONDS)) { + throw IOException("Gave up waiting for executor to shut down") + } + } catch (e: InterruptedException) { + throw AssertionError() + } + } + + @Synchronized override fun after() { + try { + shutdown() + } catch (e: IOException) { + logger.log(Level.WARNING, "MockWebServer shutdown failed", e) + } + } + + private fun serveConnection(raw: Socket) { + executor!!.execute("MockWebServer ${raw.remoteSocketAddress}") { + try { + SocketHandler(raw).handle() + } catch (e: IOException) { + logger.info("${this@MockWebServer} connection from ${raw.inetAddress} failed: $e") + } catch (e: Exception) { + logger.log(Level.SEVERE, + "${this@MockWebServer} connection from ${raw.inetAddress} crashed", e) + } + } + } + + internal inner class SocketHandler(private val raw: Socket) { + private var sequenceNumber = 0 + + @Throws(Exception::class) + fun handle() { + val socketPolicy = dispatcher.peek().socketPolicy + var protocol = Protocol.HTTP_1_1 + val socket: Socket + when { + sslSocketFactory != null -> { + if (tunnelProxy) { + createTunnel() + } + if (socketPolicy === SocketPolicy.FAIL_HANDSHAKE) { + dispatchBookkeepingRequest(sequenceNumber, raw) + processHandshakeFailure(raw) + return + } + socket = sslSocketFactory!!.createSocket(raw, raw.inetAddress.hostAddress, + raw.port, true) + val sslSocket = socket as SSLSocket + sslSocket.useClientMode = false + if (clientAuth == CLIENT_AUTH_REQUIRED) { + sslSocket.needClientAuth = true + } else if (clientAuth == CLIENT_AUTH_REQUESTED) { + sslSocket.wantClientAuth = true + } + openClientSockets.add(socket) + + if (protocolNegotiationEnabled) { + Platform.get().configureTlsExtensions(sslSocket, null, protocols) + } + + sslSocket.startHandshake() + + if (protocolNegotiationEnabled) { + val protocolString = Platform.get().getSelectedProtocol(sslSocket) + protocol = + if (protocolString != null) Protocol.get(protocolString) else Protocol.HTTP_1_1 + Platform.get().afterHandshake(sslSocket) + } + openClientSockets.remove(raw) + } + Protocol.H2_PRIOR_KNOWLEDGE in protocols -> { + socket = raw + protocol = Protocol.H2_PRIOR_KNOWLEDGE + } + else -> socket = raw + } + + if (socketPolicy === SocketPolicy.STALL_SOCKET_AT_START) { + return // Ignore the socket until the server is shut down! + } + + if (protocol === Protocol.HTTP_2 || protocol === Protocol.H2_PRIOR_KNOWLEDGE) { + val http2SocketHandler = Http2SocketHandler(socket, protocol) + val connection = Http2Connection.Builder(false) + .socket(socket) + .listener(http2SocketHandler) + .build() + connection.start() + openConnections.add(connection) + openClientSockets.remove(socket) + return + } else if (protocol !== Protocol.HTTP_1_1) { + throw AssertionError() + } + + val source = socket.source().buffer() + val sink = socket.sink().buffer() + + while (processOneRequest(socket, source, sink)) { + } + + if (sequenceNumber == 0) { + logger.warning( + "${this@MockWebServer} connection from ${raw.inetAddress} didn't make a request") + } + + socket.close() + openClientSockets.remove(socket) + } + + /** + * Respond to CONNECT requests until a SWITCH_TO_SSL_AT_END response is + * dispatched. + */ + @Throws(IOException::class, InterruptedException::class) + private fun createTunnel() { + val source = raw.source().buffer() + val sink = raw.sink().buffer() + while (true) { + val socketPolicy = dispatcher.peek().socketPolicy + check(processOneRequest(raw, source, sink)) { "Tunnel without any CONNECT!" } + if (socketPolicy === SocketPolicy.UPGRADE_TO_SSL_AT_END) return + } + } + + /** + * Reads a request and writes its response. Returns true if further calls should be attempted + * on the socket. + */ + @Throws(IOException::class, InterruptedException::class) + private fun processOneRequest( + socket: Socket, + source: BufferedSource, + sink: BufferedSink + ): Boolean { + val request = readRequest(socket, source, sink, sequenceNumber) ?: return false + + atomicRequestCount.incrementAndGet() + requestQueue.add(request) + + val response = dispatcher.dispatch(request) + if (response.socketPolicy === SocketPolicy.DISCONNECT_AFTER_REQUEST) { + socket.close() + return false + } + if (response.socketPolicy === SocketPolicy.NO_RESPONSE) { + // This read should block until the socket is closed. (Because nobody is writing.) + if (source.exhausted()) return false + throw ProtocolException("unexpected data") + } + + var reuseSocket = true + val requestWantsWebSockets = "Upgrade".equals(request.getHeader("Connection"), + ignoreCase = true) && "websocket".equals(request.getHeader("Upgrade"), + ignoreCase = true) + val responseWantsWebSockets = response.webSocketListener != null + if (requestWantsWebSockets && responseWantsWebSockets) { + handleWebSocketUpgrade(socket, source, sink, request, response) + reuseSocket = false + } else { + writeHttpResponse(socket, sink, response) + } + + if (logger.isLoggable(Level.INFO)) { + logger.info( + "${this@MockWebServer} received request: $request and responded: $response") + } + + // See warnings associated with these socket policies in SocketPolicy. + when (response.socketPolicy) { + SocketPolicy.DISCONNECT_AT_END -> { + socket.close() + return false + } + SocketPolicy.SHUTDOWN_INPUT_AT_END -> socket.shutdownInput() + SocketPolicy.SHUTDOWN_OUTPUT_AT_END -> socket.shutdownOutput() + SocketPolicy.SHUTDOWN_SERVER_AFTER_RESPONSE -> shutdown() + else -> { + } + } + sequenceNumber++ + return reuseSocket + } + } + + @Throws(Exception::class) + private fun processHandshakeFailure(raw: Socket) { + val context = SSLContext.getInstance("TLS") + context.init(null, arrayOf(UNTRUSTED_TRUST_MANAGER), SecureRandom()) + val sslSocketFactory = context.socketFactory + val socket = sslSocketFactory.createSocket( + raw, raw.inetAddress.hostAddress, raw.port, true) as SSLSocket + try { + socket.startHandshake() // we're testing a handshake failure + throw AssertionError() + } catch (expected: IOException) { + } + socket.close() + } + + @Throws(InterruptedException::class) + private fun dispatchBookkeepingRequest(sequenceNumber: Int, socket: Socket) { + val request = RecordedRequest( + "", Headers.of(), emptyList(), 0L, Buffer(), sequenceNumber, socket) + atomicRequestCount.incrementAndGet() + requestQueue.add(request) + dispatcher.dispatch(request) + } + + /** @param sequenceNumber the index of this request on this connection.*/ + @Throws(IOException::class) + private fun readRequest( + socket: Socket, + source: BufferedSource, + sink: BufferedSink, + sequenceNumber: Int + ): RecordedRequest? { + val request: String + try { + request = source.readUtf8LineStrict() + } catch (streamIsClosed: IOException) { + return null // no request because we closed the stream + } + + if (request.isEmpty()) { + return null // no request because the stream is exhausted + } + val headers = Headers.Builder() + var contentLength = -1L + var chunked = false + var expectContinue = false + while (true) { + val header = source.readUtf8LineStrict() + if (header.isEmpty()) { + break + } + headers.add(header) + val lowercaseHeader = header.toLowerCase(Locale.US) + if (contentLength == -1L && lowercaseHeader.startsWith("content-length:")) { + contentLength = header.substring(15).trim().toLong() + } + if (lowercaseHeader.startsWith("transfer-encoding:") && lowercaseHeader.substring( + 18).trim() == "chunked") { + chunked = true + } + if (lowercaseHeader.startsWith("expect:") && lowercaseHeader.substring( + 7).trim().equals("100-continue", ignoreCase = true)) { + expectContinue = true + } + } + + val socketPolicy = dispatcher.peek().socketPolicy + if (expectContinue && socketPolicy === SocketPolicy.EXPECT_CONTINUE || socketPolicy === SocketPolicy.CONTINUE_ALWAYS) { + sink.writeUtf8("HTTP/1.1 100 Continue\r\n") + sink.writeUtf8("Content-Length: 0\r\n") + sink.writeUtf8("\r\n") + sink.flush() + } + + var hasBody = false + val requestBody = TruncatingBuffer(bodyLimit) + val chunkSizes = mutableListOf() + val policy = dispatcher.peek() + if (contentLength != -1L) { + hasBody = contentLength > 0L + throttledTransfer(policy, socket, source, requestBody.buffer(), contentLength, true) + } else if (chunked) { + hasBody = true + while (true) { + val chunkSize = Integer.parseInt(source.readUtf8LineStrict().trim(), 16) + if (chunkSize == 0) { + readEmptyLine(source) + break + } + chunkSizes.add(chunkSize) + throttledTransfer(policy, socket, source, + requestBody.buffer(), chunkSize.toLong(), true) + readEmptyLine(source) + } + } + + val method = request.substringBefore(' ') + require(!hasBody || HttpMethod.permitsRequestBody(method)) { + "Request must not have a body: $request" + } + + return RecordedRequest(request, headers.build(), chunkSizes, requestBody.receivedByteCount, + requestBody.buffer, sequenceNumber, socket) + } + + @Throws(IOException::class) + private fun handleWebSocketUpgrade( + socket: Socket, + source: BufferedSource, + sink: BufferedSink, + request: RecordedRequest, + response: MockResponse + ) { + val key = request.getHeader("Sec-WebSocket-Key") + response.setHeader("Sec-WebSocket-Accept", WebSocketProtocol.acceptHeader(key!!)) + + writeHttpResponse(socket, sink, response) + + // Adapt the request and response into our Request and Response domain model. + val scheme = if (request.tlsVersion != null) "https" else "http" + val authority = request.getHeader("Host") // Has host and port. + val fancyRequest = Request.Builder() + .url("$scheme://$authority/") + .headers(request.headers) + .build() + val statusParts = response.status.split(' ', limit = 3) + val fancyResponse = Response.Builder() + .code(Integer.parseInt(statusParts[1])) + .message(statusParts[2]) + .headers(response.headers) + .request(fancyRequest) + .protocol(Protocol.HTTP_1_1) + .build() + + val connectionClose = CountDownLatch(1) + val streams = object : RealWebSocket.Streams(false, source, sink) { + override fun close() = connectionClose.countDown() + } + val webSocket = RealWebSocket(fancyRequest, response.webSocketListener!!, SecureRandom(), 0) + response.webSocketListener!!.onOpen(webSocket, fancyResponse) + val name = "MockWebServer WebSocket ${request.path!!}" + webSocket.initReaderAndWriter(name, streams) + try { + webSocket.loopReader() + + // Even if messages are no longer being read we need to wait for the connection close signal. + connectionClose.await() + } catch (e: IOException) { + webSocket.failWebSocket(e, null) + } finally { + source.closeQuietly() + } + } + + @Throws(IOException::class) + private fun writeHttpResponse(socket: Socket, sink: BufferedSink, response: MockResponse) { + sleepIfDelayed(response.getHeadersDelay(TimeUnit.MILLISECONDS)) + sink.writeUtf8(response.status) + sink.writeUtf8("\r\n") + + writeHeaders(sink, response.headers) + + val body = response.getBody() ?: return + sleepIfDelayed(response.getBodyDelay(TimeUnit.MILLISECONDS)) + throttledTransfer(response, socket, body, sink, body.size, false) + + if ("chunked".equals(response.headers["Transfer-Encoding"], ignoreCase = true)) { + writeHeaders(sink, response.trailers) + } + } + + @Throws(IOException::class) + private fun writeHeaders(sink: BufferedSink, headers: Headers) { + for (name in headers.names()) { + sink.writeUtf8(name) + sink.writeUtf8(": ") + sink.writeUtf8(headers.get(name) ?: "") + sink.writeUtf8("\r\n") + } + sink.writeUtf8("\r\n") + sink.flush() + } + + private fun sleepIfDelayed(delayMs: Long) { + if (delayMs != 0L) { + Thread.sleep(delayMs) + } + } + + /** + * Transfer bytes from [source] to [sink] until either [byteCount] bytes have + * been transferred or [source] is exhausted. The transfer is throttled according to [policy]. + */ + @Throws(IOException::class) + private fun throttledTransfer( + policy: MockResponse, + socket: Socket, + source: BufferedSource, + sink: BufferedSink, + byteCount: Long, + isRequest: Boolean + ) { + var byteCountNum = byteCount + if (byteCountNum == 0L) return + + val buffer = Buffer() + val bytesPerPeriod = policy.throttleBytesPerPeriod + val periodDelayMs = policy.getThrottlePeriod(TimeUnit.MILLISECONDS) + + val halfByteCount = byteCountNum / 2 + val disconnectHalfway = if (isRequest) { + policy.socketPolicy === SocketPolicy.DISCONNECT_DURING_REQUEST_BODY + } else { + policy.socketPolicy === SocketPolicy.DISCONNECT_DURING_RESPONSE_BODY + } + + while (!socket.isClosed) { + var b = 0L + while (b < bytesPerPeriod) { + // Ensure we do not read past the allotted bytes in this period. + var toRead = minOf(byteCountNum, bytesPerPeriod - b) + // Ensure we do not read past halfway if the policy will kill the connection. + if (disconnectHalfway) { + toRead = minOf(toRead, byteCountNum - halfByteCount) + } + + val read = source.read(buffer, toRead) + if (read == -1L) return + + sink.write(buffer, read) + sink.flush() + b += read + byteCountNum -= read + + if (disconnectHalfway && byteCountNum == halfByteCount) { + socket.close() + return + } + + if (byteCountNum == 0L) return + } + + if (periodDelayMs != 0L) { + Thread.sleep(periodDelayMs) + } + } + } + + @Throws(IOException::class) + private fun readEmptyLine(source: BufferedSource) { + val line = source.readUtf8LineStrict() + check(line.isEmpty()) { "Expected empty but was: $line" } + } + + override fun toString(): String = "MockWebServer[$portField]" + + @Throws(IOException::class) + override fun close() = shutdown() + + /** A buffer wrapper that drops data after [bodyLimit] bytes. */ + private class TruncatingBuffer internal constructor( + private var remainingByteCount: Long + ) : Sink { + internal val buffer = Buffer() + internal var receivedByteCount = 0L + + @Throws(IOException::class) + override fun write(source: Buffer, byteCount: Long) { + val toRead = minOf(remainingByteCount, byteCount) + if (toRead > 0L) { + source.read(buffer, toRead) + } + val toSkip = byteCount - toRead + if (toSkip > 0L) { + source.skip(toSkip) + } + remainingByteCount -= toRead + receivedByteCount += byteCount + } + + @Throws(IOException::class) + override fun flush() { + } + + override fun timeout(): Timeout = Timeout.NONE + + @Throws(IOException::class) + override fun close() { + } + } + + /** Processes HTTP requests layered over HTTP/2. */ + private inner class Http2SocketHandler constructor( + private val socket: Socket, + private val protocol: Protocol + ) : Http2Connection.Listener() { + private val sequenceNumber = AtomicInteger() + + @Throws(IOException::class) + override fun onStream(stream: Http2Stream) { + val peekedResponse = dispatcher.peek() + if (peekedResponse.socketPolicy === SocketPolicy.RESET_STREAM_AT_START) { + dispatchBookkeepingRequest(sequenceNumber.getAndIncrement(), socket) + stream.close(ErrorCode.fromHttp2(peekedResponse.http2ErrorCode)!!,null) + return + } + + val request = readRequest(stream) + atomicRequestCount.incrementAndGet() + requestQueue.add(request) + + val response: MockResponse = dispatcher.dispatch(request) + + if (response.socketPolicy === SocketPolicy.DISCONNECT_AFTER_REQUEST) { + socket.close() + return + } + writeResponse(stream, request, response) + if (logger.isLoggable(Level.INFO)) { + logger.info( + "${this@MockWebServer} received request: $request " + + "and responded: $response protocol is $protocol") + } + + if (response.socketPolicy === SocketPolicy.DISCONNECT_AT_END) { + val connection = stream.connection + connection.shutdown(ErrorCode.NO_ERROR) + } + } + + @Throws(IOException::class) + private fun readRequest(stream: Http2Stream): RecordedRequest { + val streamHeaders = stream.takeHeaders() + val httpHeaders = Headers.Builder() + var method = "<:method omitted>" + var path = "<:path omitted>" + var readBody = true + for (name in streamHeaders.names()) { + val value = streamHeaders.get(name).toString() + if (name == Header.TARGET_METHOD_UTF8) { + method = value + } else if (name == Header.TARGET_PATH_UTF8) { + path = value + } else if (protocol === Protocol.HTTP_2 || protocol === Protocol.H2_PRIOR_KNOWLEDGE) { + httpHeaders.add(name, value) + } else { + throw IllegalStateException() + } + if (name == "expect" && value.equals("100-continue", ignoreCase = true)) { + // Don't read the body unless we've invited the client to send it. + readBody = false + } + } + val headers = httpHeaders.build() + + val peek = dispatcher.peek() + if (!readBody && peek.socketPolicy === SocketPolicy.EXPECT_CONTINUE) { + val continueHeaders = + listOf(Header(Header.RESPONSE_STATUS, "100 Continue".encodeUtf8())) + stream.writeHeaders(continueHeaders, false,false) + stream.connection.flush() + readBody = true + } + + val body = Buffer() + if (readBody && !peek.isDuplex) { + val contentLengthString = headers["content-length"] + val byteCount = contentLengthString?.toLong() ?: Long.MAX_VALUE + throttledTransfer(peek, socket, stream.getSource().buffer(), + body, byteCount, true) + } + + val requestLine = "$method $path HTTP/1.1" + val chunkSizes = emptyList() // No chunked encoding for HTTP/2. + return RecordedRequest(requestLine, headers, chunkSizes, body.size, body, + sequenceNumber.getAndIncrement(), socket) + } + + @Throws(IOException::class) + private fun writeResponse( + stream: Http2Stream, + request: RecordedRequest, + response: MockResponse + ) { + val settings = response.settings + stream.connection.setSettings(settings) + + if (response.socketPolicy === SocketPolicy.NO_RESPONSE) { + return + } + val http2Headers = mutableListOf
() + val statusParts = response.status.split(' ', limit = 3) + + if (statusParts.size < 2) { + throw AssertionError("Unexpected status: ${response.status}") + } + // TODO: constants for well-known header names. + http2Headers.add(Header(Header.RESPONSE_STATUS, statusParts[1])) + val headers = response.headers + for (name in headers.names()) { + http2Headers.add(Header(name, headers.get(name))) + } + val trailers = response.trailers + + sleepIfDelayed(response.getHeadersDelay(TimeUnit.MILLISECONDS)) + + val body = response.getBody() + val outFinished = (body == null && + response.pushPromises.isEmpty() && + !response.isDuplex) + val flushHeaders = body == null + require(!outFinished || trailers.size() == 0) { + "unsupported: no body and non-empty trailers $trailers" + } + stream.writeHeaders(http2Headers, outFinished,flushHeaders) + if (trailers.size() > 0) { + + stream.enqueueTrailers(trailers) + } + pushPromises(stream, request, response.pushPromises) + if (body != null) { + stream.sink.buffer().use { sink -> + sleepIfDelayed(response.getBodyDelay(TimeUnit.MILLISECONDS)) + throttledTransfer(response, socket, body, sink, body.size, false) + } + } else if (response.isDuplex) { + stream.sink.buffer().use { sink -> + stream.source.buffer().use { source -> + val duplexResponseBody = response.duplexResponseBody + duplexResponseBody!!.onRequest(request, source, sink) + } + } + } else if (!outFinished) { + stream.close(ErrorCode.NO_ERROR,null) + } + } + + @Throws(IOException::class) + private fun pushPromises( + stream: Http2Stream, + request: RecordedRequest, + promises: List + ) { + for (pushPromise in promises) { + val pushedHeaders = mutableListOf
() + pushedHeaders.add(Header(Header.TARGET_AUTHORITY, url(pushPromise.path).host())) + pushedHeaders.add(Header(Header.TARGET_METHOD, pushPromise.method)) + pushedHeaders.add(Header(Header.TARGET_PATH, pushPromise.path)) + val pushPromiseHeaders = pushPromise.headers + for (name in pushPromiseHeaders.names()) { + pushedHeaders.add(Header(name, pushPromiseHeaders.get(name))) + } + val requestLine = "${pushPromise.method} ${pushPromise.path} HTTP/1.1" + val chunkSizes = emptyList() // No chunked encoding for HTTP/2. + requestQueue.add(RecordedRequest(requestLine, pushPromise.headers, chunkSizes, 0, + Buffer(), sequenceNumber.getAndIncrement(), socket)) + val hasBody = pushPromise.response.getBody() != null + val pushedStream = stream.connection.pushStream(stream.id, pushedHeaders, hasBody) + writeResponse(pushedStream, request, pushPromise.response) + } + } + } + + companion object { + init { + MwsDuplexAccess.instance = object : MwsDuplexAccess() { + override fun setBody( + mockResponse: MockResponse, + duplexResponseBody: DuplexResponseBody + ) { + mockResponse.setBody(duplexResponseBody) + } + } + } + + private const val CLIENT_AUTH_NONE = 0 + private const val CLIENT_AUTH_REQUESTED = 1 + private const val CLIENT_AUTH_REQUIRED = 2 + + private val UNTRUSTED_TRUST_MANAGER = object : X509TrustManager { + @Throws(CertificateException::class) + override fun checkClientTrusted( + chain: Array, + authType: String + ) = throw CertificateException() + + override fun checkServerTrusted( + chain: Array, + authType: String + ) = throw AssertionError() + + override fun getAcceptedIssuers(): Array = throw AssertionError() + } + + private val logger = Logger.getLogger(MockWebServer::class.java.name) + } +} diff --git a/module_common/src/debug/java/com/gh/gamecenter/mock/mockwebserver3/PushPromise.kt b/module_common/src/debug/java/com/gh/gamecenter/mock/mockwebserver3/PushPromise.kt new file mode 100644 index 0000000000..a414094329 --- /dev/null +++ b/module_common/src/debug/java/com/gh/gamecenter/mock/mockwebserver3/PushPromise.kt @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2014 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.gh.gamecenter.mock.mockwebserver3 + + +import okhttp3.Headers +/** An HTTP request initiated by the server. */ +class PushPromise( + @get:JvmName("method") val method: String, + @get:JvmName("path") val path: String, + @get:JvmName("headers") val headers: Headers, + @get:JvmName("response") val response: MockResponse +) { + + @JvmName("-deprecated_method") + @Deprecated( + message = "moved to val", + replaceWith = ReplaceWith(expression = "method"), + level = DeprecationLevel.ERROR) + fun method() = method + + @JvmName("-deprecated_path") + @Deprecated( + message = "moved to val", + replaceWith = ReplaceWith(expression = "path"), + level = DeprecationLevel.ERROR) + fun path() = path + + @JvmName("-deprecated_headers") + @Deprecated( + message = "moved to val", + replaceWith = ReplaceWith(expression = "headers"), + level = DeprecationLevel.ERROR) + fun headers() = headers + + @JvmName("-deprecated_response") + @Deprecated( + message = "moved to val", + replaceWith = ReplaceWith(expression = "response"), + level = DeprecationLevel.ERROR) + fun response() = response +} diff --git a/module_common/src/debug/java/com/gh/gamecenter/mock/mockwebserver3/QueueDispatcher.kt b/module_common/src/debug/java/com/gh/gamecenter/mock/mockwebserver3/QueueDispatcher.kt new file mode 100644 index 0000000000..2b6e72fbee --- /dev/null +++ b/module_common/src/debug/java/com/gh/gamecenter/mock/mockwebserver3/QueueDispatcher.kt @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2012 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.gh.gamecenter.mock.mockwebserver3 + +import java.net.HttpURLConnection +import java.net.HttpURLConnection.HTTP_UNAVAILABLE +import java.util.concurrent.BlockingQueue +import java.util.concurrent.LinkedBlockingQueue +import java.util.logging.Logger + +/** + * Default dispatcher that processes a script of responses. Populate the script by calling [enqueueResponse]. + */ +open class QueueDispatcher : Dispatcher() { + protected val responseQueue: BlockingQueue = LinkedBlockingQueue() + private var failFastResponse: MockResponse? = null + + @Throws(InterruptedException::class) + override fun dispatch(request: RecordedRequest): MockResponse { + // To permit interactive/browser testing, ignore requests for favicons. + val requestLine = request.requestLine + if (requestLine == "GET /favicon.ico HTTP/1.1") { + logger.info("served $requestLine") + return MockResponse().setResponseCode(HttpURLConnection.HTTP_NOT_FOUND) + } + + if (failFastResponse != null && responseQueue.peek() == null) { + // Fail fast if there's no response queued up. + return failFastResponse!! + } + + val result = responseQueue.take() + + // If take() returned because we're shutting down, then enqueue another dead letter so that any + // other threads waiting on take() will also return. + if (result == DEAD_LETTER) responseQueue.add(DEAD_LETTER) + + return result + } + + override fun peek(): MockResponse { + return responseQueue.peek() ?: failFastResponse ?: super.peek() + } + + open fun enqueueResponse(response: MockResponse) { + responseQueue.add(response) + } + + open fun clear() { + responseQueue.clear() + } + + override fun shutdown() { + responseQueue.add(DEAD_LETTER) + } + + open fun setFailFast(failFast: Boolean) { + val failFastResponse = if (failFast) { + MockResponse().setResponseCode(HttpURLConnection.HTTP_NOT_FOUND) + } else { + null + } + setFailFast(failFastResponse) + } + + open fun setFailFast(failFastResponse: MockResponse?) { + this.failFastResponse = failFastResponse + } + + companion object { + /** + * Enqueued on shutdown to release threads waiting on [dispatch]. Note that this response + * isn't transmitted because the connection is closed before this response is returned. + */ + private val DEAD_LETTER = MockResponse().apply { + this.status = "HTTP/1.1 $HTTP_UNAVAILABLE shutting down" + } + + private val logger = Logger.getLogger(QueueDispatcher::class.java.name) + } +} diff --git a/module_common/src/debug/java/com/gh/gamecenter/mock/mockwebserver3/RecordedRequest.kt b/module_common/src/debug/java/com/gh/gamecenter/mock/mockwebserver3/RecordedRequest.kt new file mode 100644 index 0000000000..c4573d448f --- /dev/null +++ b/module_common/src/debug/java/com/gh/gamecenter/mock/mockwebserver3/RecordedRequest.kt @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2011 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.gh.gamecenter.mock.mockwebserver3 + +import java.io.IOException +import java.net.Inet6Address +import java.net.Socket +import javax.net.ssl.SSLSocket +import okhttp3.Handshake +import okhttp3.Headers +import okhttp3.HttpUrl +import okhttp3.TlsVersion +import okio.Buffer + +/** An HTTP request that came into the mock web server. */ +class RecordedRequest @JvmOverloads constructor( + val requestLine: String, + + /** All headers. */ + val headers: Headers, + + /** + * The sizes of the chunks of this request's body, or an empty list if the request's body + * was empty or unchunked. + */ + val chunkSizes: List, + + /** The total size of the body of this POST request (before truncation).*/ + val bodySize: Long, + + /** The body of this POST request. This may be truncated. */ + val body: Buffer, + + /** + * The index of this request on its HTTP connection. Since a single HTTP connection may serve + * multiple requests, each request is assigned its own sequence number. + */ + val sequenceNumber: Int, + socket: Socket, + + /** + * The failure MockWebServer recorded when attempting to decode this request. If, for example, + * the inbound request was truncated, this exception will be non-null. + */ + val failure: IOException? = null +) { + val method: String? + val path: String? + + /** + * The TLS handshake of the connection that carried this request, or null if the request was + * received without TLS. + */ + val handshake: Handshake? + val requestUrl: HttpUrl? + + @get:JvmName("-deprecated_utf8Body") + @Deprecated( + message = "Use body.readUtf8()", + replaceWith = ReplaceWith("body.readUtf8()"), + level = DeprecationLevel.ERROR) + val utf8Body: String + get() = body.readUtf8() + + /** Returns the connection's TLS version or null if the connection doesn't use SSL. */ + val tlsVersion: TlsVersion? + get() = handshake?.tlsVersion() + + init { + if (socket is SSLSocket) { + try { + this.handshake = Handshake.get(socket.session) + } catch (e: IOException) { + throw IllegalArgumentException(e) + } + } else { + this.handshake = null + } + + if (requestLine.isNotEmpty()) { + val methodEnd = requestLine.indexOf(' ') + val pathEnd = requestLine.indexOf(' ', methodEnd + 1) + this.method = requestLine.substring(0, methodEnd) + var path = requestLine.substring(methodEnd + 1, pathEnd) + if (!path.startsWith("/")) { + path = "/" + } + this.path = path + + val scheme = if (socket is SSLSocket) "https" else "http" + val localPort = socket.localPort + val hostAndPort = headers[":authority"] + ?: headers["Host"] + ?: when (val inetAddress = socket.localAddress) { + is Inet6Address -> "[${inetAddress.hostAddress}]:$localPort" + else -> "${inetAddress.hostAddress}:$localPort" + } + + // Allow null in failure case to allow for testing bad requests + this.requestUrl = HttpUrl.get("$scheme://$hostAndPort$path") + } else { + this.requestUrl = null + this.method = null + this.path = null + } + } + + @Deprecated( + message = "Use body.readUtf8()", + replaceWith = ReplaceWith("body.readUtf8()"), + level = DeprecationLevel.WARNING) + fun getUtf8Body(): String = body.readUtf8() + + /** Returns the first header named [name], or null if no such header exists. */ + fun getHeader(name: String): String? = headers.values(name).firstOrNull() + + override fun toString(): String = requestLine +} diff --git a/module_common/src/debug/java/com/gh/gamecenter/mock/mockwebserver3/SocketPolicy.kt b/module_common/src/debug/java/com/gh/gamecenter/mock/mockwebserver3/SocketPolicy.kt new file mode 100644 index 0000000000..fa22e03f21 --- /dev/null +++ b/module_common/src/debug/java/com/gh/gamecenter/mock/mockwebserver3/SocketPolicy.kt @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2011 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.gh.gamecenter.mock.mockwebserver3 + +/** + * What should be done with the incoming socket. + * + * Be careful when using values like [DISCONNECT_AT_END], [SHUTDOWN_INPUT_AT_END] + * and [SHUTDOWN_OUTPUT_AT_END] that close a socket after a response, and where there are + * follow-up requests. The client is unblocked and free to continue as soon as it has received the + * entire response body. If and when the client makes a subsequent request using a pooled socket the + * server may not have had time to close the socket. The socket will be closed at an indeterminate + * point before or during the second request. It may be closed after client has started sending the + * request body. If a request body is not retryable then the client may fail the request, making + * client behavior non-deterministic. Add delays in the client to improve the chances that the + * server has closed the socket before follow up requests are made. + */ +enum class SocketPolicy { + + /** + * Shutdown [MockWebServer] after writing response. + */ + SHUTDOWN_SERVER_AFTER_RESPONSE, + + /** + * Keep the socket open after the response. This is the default HTTP/1.1 behavior. + */ + KEEP_OPEN, + + /** + * Close the socket after the response. This is the default HTTP/1.0 behavior. For HTTP/2 + * connections, this sends a [GOAWAYframe](https://tools.ietf.org/html/rfc7540#section-6.8) + * immediately after the response and will close the connection when the client's socket + * is exhausted. + * + * See [SocketPolicy] for reasons why this can cause test flakiness and how to avoid it. + */ + DISCONNECT_AT_END, + + /** + * Wrap the socket with SSL at the completion of this request/response pair. Used for CONNECT + * messages to tunnel SSL over an HTTP proxy. + */ + UPGRADE_TO_SSL_AT_END, + + /** + * Request immediate close of connection without even reading the request. Use to simulate buggy + * SSL servers closing connections in response to unrecognized TLS extensions. + */ + DISCONNECT_AT_START, + + /** + * Close connection after reading the request but before writing the response. Use this to + * simulate late connection pool failures. + */ + DISCONNECT_AFTER_REQUEST, + + /** Close connection after reading half of the request body (if present). */ + DISCONNECT_DURING_REQUEST_BODY, + + /** Close connection after writing half of the response body (if present). */ + DISCONNECT_DURING_RESPONSE_BODY, + + /** Don't trust the client during the SSL handshake. */ + FAIL_HANDSHAKE, + + /** + * Shutdown the socket input after sending the response. For testing bad behavior. + * + * See [SocketPolicy] for reasons why this can cause test flakiness and how to avoid it. + */ + SHUTDOWN_INPUT_AT_END, + + /** + * Shutdown the socket output after sending the response. For testing bad behavior. + * + * See [SocketPolicy] for reasons why this can cause test flakiness and how to avoid it. + */ + SHUTDOWN_OUTPUT_AT_END, + + /** + * After accepting the connection and doing TLS (if configured) don't do HTTP/1.1 or HTTP/2 + * framing. Ignore the socket completely until the server is shut down. + */ + STALL_SOCKET_AT_START, + + /** + * Read the request but don't respond to it. Just keep the socket open. For testing read response + * header timeout issue. + */ + NO_RESPONSE, + + /** + * Fail HTTP/2 requests without processing them by sending an [MockResponse.getHttp2ErrorCode]. + */ + RESET_STREAM_AT_START, + + /** + * Transmit a `HTTP/1.1 100 Continue` response before reading the HTTP request body. + * Typically this response is sent when a client makes a request with the header `Expect: 100-continue`. + */ + EXPECT_CONTINUE, + + /** + * Transmit a `HTTP/1.1 100 Continue` response before reading the HTTP request body even + * if the client does not send the header `Expect: 100-continue` in its request. + */ + CONTINUE_ALWAYS +} \ No newline at end of file diff --git a/module_common/src/debug/java/com/gh/gamecenter/mock/mockwebserver3/internal/duplex/DuplexResponseBody.kt b/module_common/src/debug/java/com/gh/gamecenter/mock/mockwebserver3/internal/duplex/DuplexResponseBody.kt new file mode 100644 index 0000000000..4b4bde8ca5 --- /dev/null +++ b/module_common/src/debug/java/com/gh/gamecenter/mock/mockwebserver3/internal/duplex/DuplexResponseBody.kt @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2018 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.gh.gamecenter.mock.mockwebserver3.internal.duplex + +import java.io.IOException +import com.gh.gamecenter.mock.mockwebserver3.RecordedRequest +import okio.BufferedSink +import okio.BufferedSource + +fun interface DuplexResponseBody { + @Throws(IOException::class) + fun onRequest(request: RecordedRequest, requestBody: BufferedSource, responseBody: BufferedSink) +} diff --git a/module_common/src/debug/java/com/gh/gamecenter/mock/mockwebserver3/internal/duplex/MockDuplexResponseBody.kt b/module_common/src/debug/java/com/gh/gamecenter/mock/mockwebserver3/internal/duplex/MockDuplexResponseBody.kt new file mode 100644 index 0000000000..477273326d --- /dev/null +++ b/module_common/src/debug/java/com/gh/gamecenter/mock/mockwebserver3/internal/duplex/MockDuplexResponseBody.kt @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2019 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.gh.gamecenter.mock.mockwebserver3.internal.duplex + +import java.io.IOException +import java.util.concurrent.CountDownLatch +import java.util.concurrent.FutureTask +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit +import com.gh.gamecenter.mock.mockwebserver3.RecordedRequest +import okhttp3.internal.http2.ErrorCode +import okhttp3.internal.http2.Http2Stream +import okio.BufferedSink +import okio.BufferedSource +import okio.buffer +import okio.utf8Size +import org.junit.Assert + +private typealias Action = (RecordedRequest, BufferedSource, BufferedSink) -> Unit + +/** + * A scriptable request/response conversation. Create the script by calling methods like + * [receiveRequest] in the sequence they are run. + */ +class MockDuplexResponseBody : DuplexResponseBody { + private val actions = LinkedBlockingQueue() + private val results = LinkedBlockingQueue>() + + fun receiveRequest(expected: String) = apply { + actions += { _, requestBody, _ -> + Assert.assertEquals(expected, requestBody.readUtf8(expected.utf8Size())) + } + } + + fun exhaustRequest() = apply { + actions += { _, requestBody, _ -> Assert.assertTrue(requestBody.exhausted()) } + } + + fun requestIOException() = apply { + actions += { _, requestBody, _ -> + try { + requestBody.exhausted() + Assert.fail() + } catch (expected: IOException) { + } + } + } + + @JvmOverloads fun sendResponse( + s: String, + responseSent: CountDownLatch = CountDownLatch(0) + ) = apply { + actions += { _, _, responseBody -> + responseBody.writeUtf8(s) + responseBody.flush() + responseSent.countDown() + } + } + + fun exhaustResponse() = apply { + actions += { _, _, responseBody -> responseBody.close() } + } + + fun sleep(duration: Long, unit: TimeUnit) = apply { + actions += { _, _, _ -> Thread.sleep(unit.toMillis(duration)) } + } + + override fun onRequest( + request: RecordedRequest, + requestBody: BufferedSource, + responseBody: BufferedSink + ) { + val futureTask = FutureTask { + while (true) { + val action = actions.poll() ?: break + action(request, requestBody, responseBody) + } + return@FutureTask null + } + results.add(futureTask) + futureTask.run() + } + + /** Returns once the duplex conversation completes successfully. */ + fun awaitSuccess() { + val futureTask = results.poll(5, TimeUnit.SECONDS) + ?: throw AssertionError("no onRequest call received") + futureTask.get(5, TimeUnit.SECONDS) + } +} diff --git a/module_common/src/debug/java/com/gh/gamecenter/mock/okhttp3/internal/duplex/MwsDuplexAccess.kt b/module_common/src/debug/java/com/gh/gamecenter/mock/okhttp3/internal/duplex/MwsDuplexAccess.kt new file mode 100644 index 0000000000..8e26ae9730 --- /dev/null +++ b/module_common/src/debug/java/com/gh/gamecenter/mock/okhttp3/internal/duplex/MwsDuplexAccess.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2018 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.gh.gamecenter.mock.okhttp3.internal.duplex + +import com.gh.gamecenter.mock.mockwebserver3.MockResponse +import com.gh.gamecenter.mock.mockwebserver3.internal.duplex.DuplexResponseBody + + +/** + * Internal access to MockWebServer APIs. Don't use this, don't use internal, these APIs are not + * stable. + */ +abstract class MwsDuplexAccess { + + abstract fun setBody(mockResponse: MockResponse, duplexResponseBody: DuplexResponseBody) + + companion object { + @JvmField var instance: MwsDuplexAccess? = null + } +} diff --git a/module_core/build.gradle b/module_core/build.gradle index 55a22b7e0d..9d515aecdd 100644 --- a/module_core/build.gradle +++ b/module_core/build.gradle @@ -57,7 +57,8 @@ dependencies { api "io.reactivex.rxjava2:rxandroid:${rxAndroid2}" api "com.jakewharton.rxbinding2:rxbinding:${rxBinding2}" api "com.github.tbruyelle:rxpermissions:${rxPermissions}" - api "com.squareup.okhttp3:okhttp:${okHttp}" + releaseApi "com.squareup.okhttp3:okhttp:${okHttp}" + debugApi "com.squareup.okhttp3:okhttp:${okHttpForDebug}" api "com.squareup.retrofit2:retrofit:${retrofit}" api "com.squareup.retrofit2:converter-gson:${retrofit}" // include gson 2.7 api "com.squareup.retrofit2:adapter-rxjava2:${retrofit}"