diff --git a/pluto-plugins/plugins/network/core/lib-no-op/src/main/java/com/pluto/plugins/network/intercept/NetworkData.kt b/pluto-plugins/plugins/network/core/lib-no-op/src/main/java/com/pluto/plugins/network/intercept/NetworkData.kt index be3bb14a..395ac78b 100644 --- a/pluto-plugins/plugins/network/core/lib-no-op/src/main/java/com/pluto/plugins/network/intercept/NetworkData.kt +++ b/pluto-plugins/plugins/network/core/lib-no-op/src/main/java/com/pluto/plugins/network/intercept/NetworkData.kt @@ -11,6 +11,7 @@ class NetworkData { ) data class Response( + val request: Request, val statusCode: Int, val body: Body?, val headers: Map, diff --git a/pluto-plugins/plugins/network/core/lib/src/main/java/com/pluto/plugins/network/intercept/NetworkData.kt b/pluto-plugins/plugins/network/core/lib/src/main/java/com/pluto/plugins/network/intercept/NetworkData.kt index d9572b05..d81c61df 100644 --- a/pluto-plugins/plugins/network/core/lib/src/main/java/com/pluto/plugins/network/intercept/NetworkData.kt +++ b/pluto-plugins/plugins/network/core/lib/src/main/java/com/pluto/plugins/network/intercept/NetworkData.kt @@ -3,6 +3,7 @@ package com.pluto.plugins.network.intercept import com.pluto.plugins.network.internal.Status import com.pluto.plugins.network.internal.interceptor.logic.mapCode2Message import io.ktor.http.ContentType +import org.json.JSONObject class NetworkData { @@ -11,32 +12,68 @@ class NetworkData { val method: String, val body: Body?, val headers: Map, - val sentTimestamp: Long + val sentTimestamp: Long, ) { + data class GraphqlData( + val queryType: String, + val queryName: String, + ) + + val graphqlData: GraphqlData? = parseGraphqlData() + + private fun parseGraphqlData(): GraphqlData? { + if (method != "POST" || + body == null || + !body.isLikelyJson + ) return null + val json = runCatching { JSONObject(body!!.body.toString()) }.getOrNull() ?: return null + val query = json.optString("query") ?: return null + val match = graqphlQueryRegex.find(query)?.groupValues ?: return null + return GraphqlData( + queryType = match[1], + queryName = match[2], + ) + } + internal val isGzipped: Boolean get() = headers["Content-Encoding"].equals("gzip", ignoreCase = true) } data class Response( + val request: Request, private val statusCode: Int, val body: Body?, val headers: Map, val sentTimestamp: Long, val receiveTimestamp: Long, val protocol: String = "", - val fromDiskCache: Boolean = false + val fromDiskCache: Boolean = false, ) { + val hasGraphqlErrors = parseHasGraphqlErrors() + internal val status: Status - get() = Status(statusCode, mapCode2Message(statusCode)) + get() = Status(statusCode, getStatusMessage()) val isSuccessful: Boolean - get() = statusCode in 200..299 + get() = statusCode in 200..299 && !hasGraphqlErrors internal val isGzipped: Boolean get() = headers["Content-Encoding"].equals("gzip", ignoreCase = true) + + private fun getStatusMessage() = mapCode2Message(statusCode) + + if (hasGraphqlErrors) ", Response with errors" else "" + + private fun parseHasGraphqlErrors(): Boolean { + if (request.graphqlData == null || + body == null || + !body.isLikelyJson + ) return false + val json = runCatching { JSONObject(body!!.body.toString()) }.getOrNull() ?: return false + return json.has("errors") + } } data class Body( val body: CharSequence, - val contentType: String + val contentType: String, ) { private val contentTypeInternal: ContentType = ContentType.parse(contentType) private val mediaType: String = contentTypeInternal.contentType @@ -44,9 +81,11 @@ class NetworkData { internal val isBinary: Boolean = BINARY_MEDIA_TYPES.contains(mediaType) val sizeInBytes: Long = body.length.toLong() internal val mediaTypeFull: String = "$mediaType/$mediaSubtype" + val isLikelyJson get() = !isBinary && body.startsWith('{') } companion object { internal val BINARY_MEDIA_TYPES = listOf("audio", "video", "image", "font") + private val graqphlQueryRegex = Regex("""\b(query|mutation)\s+(\w+)""") } } diff --git a/pluto-plugins/plugins/network/core/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/ui/list/ApiItemHolder.kt b/pluto-plugins/plugins/network/core/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/ui/list/ApiItemHolder.kt index c9dea7ec..efeacf82 100644 --- a/pluto-plugins/plugins/network/core/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/ui/list/ApiItemHolder.kt +++ b/pluto-plugins/plugins/network/core/lib/src/main/java/com/pluto/plugins/network/internal/interceptor/ui/list/ApiItemHolder.kt @@ -4,6 +4,7 @@ import android.view.View.GONE import android.view.View.INVISIBLE import android.view.View.VISIBLE import android.view.ViewGroup +import androidx.core.view.isVisible import com.pluto.plugins.network.R import com.pluto.plugins.network.databinding.PlutoNetworkItemNetworkBinding import com.pluto.plugins.network.intercept.NetworkData.Response @@ -30,6 +31,7 @@ internal class ApiItemHolder(parent: ViewGroup, actionListener: DiffAwareAdapter private val error = binding.error private val timeElapsed = binding.timeElapsed private val proxyIndicator = binding.proxyIndicator + private val graphqlIcon = binding.graphqlIcon override fun onBind(item: ListItem) { if (item is ApiCallData) { @@ -37,9 +39,13 @@ internal class ApiItemHolder(parent: ViewGroup, actionListener: DiffAwareAdapter timeElapsed.text = item.request.sentTimestamp.asTimeElapsed() binding.root.setBackgroundColor(context.color(R.color.pluto___transparent)) + val method = (item.request.graphqlData?.queryType ?: item.request.method).uppercase() + val urlOrQuery = item.request.graphqlData?.queryName ?: Url(item.request.url).encodedPath + graphqlIcon.isVisible = item.request.graphqlData != null + url.setSpan { - append(fontColor(item.request.method.uppercase(), context.color(R.color.pluto___text_dark_60))) - append(" ${Url(item.request.url).encodedPath}") + append(fontColor(method, context.color(R.color.pluto___text_dark_60))) + append(" $urlOrQuery") } progress.visibility = VISIBLE status.visibility = INVISIBLE diff --git a/pluto-plugins/plugins/network/core/lib/src/main/res/drawable/pluto_network___ic_graphql.xml b/pluto-plugins/plugins/network/core/lib/src/main/res/drawable/pluto_network___ic_graphql.xml new file mode 100644 index 00000000..e1f69254 --- /dev/null +++ b/pluto-plugins/plugins/network/core/lib/src/main/res/drawable/pluto_network___ic_graphql.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + diff --git a/pluto-plugins/plugins/network/core/lib/src/main/res/layout/pluto_network___item_network.xml b/pluto-plugins/plugins/network/core/lib/src/main/res/layout/pluto_network___item_network.xml index 8f3bc2bf..eb626f64 100644 --- a/pluto-plugins/plugins/network/core/lib/src/main/res/layout/pluto_network___item_network.xml +++ b/pluto-plugins/plugins/network/core/lib/src/main/res/layout/pluto_network___item_network.xml @@ -51,18 +51,17 @@ android:id="@+id/url" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_marginStart="@dimen/pluto___margin_small" + android:layout_marginStart="@dimen/pluto___margin_mini" + app:layout_goneMarginStart="@dimen/pluto___margin_small" android:layout_marginTop="@dimen/pluto___margin_medium" - android:layout_marginLeft="@dimen/pluto___margin_small" android:fontFamily="@font/muli_semibold" android:textColor="@color/pluto___text_dark" android:textSize="@dimen/pluto___text_small" android:layout_marginEnd="@dimen/pluto___margin_mini" - app:layout_constraintStart_toEndOf="@+id/status" + app:layout_constraintStart_toEndOf="@+id/graphqlIcon" app:layout_constraintTop_toTopOf="parent" - android:layout_marginRight="@dimen/pluto___margin_mini" app:layout_constraintEnd_toStartOf="@+id/proxyIndicator" - tools:text="api endpoint" /> + tools:text="POST /api/v2" /> + + diff --git a/pluto-plugins/plugins/network/interceptor-ktor/lib/src/main/kotlin/com/pluto/plugins/network/ktor/PlutoKtorHelper.kt b/pluto-plugins/plugins/network/interceptor-ktor/lib/src/main/kotlin/com/pluto/plugins/network/ktor/PlutoKtorHelper.kt index 542a995e..a8e5c42b 100644 --- a/pluto-plugins/plugins/network/interceptor-ktor/lib/src/main/kotlin/com/pluto/plugins/network/ktor/PlutoKtorHelper.kt +++ b/pluto-plugins/plugins/network/interceptor-ktor/lib/src/main/kotlin/com/pluto/plugins/network/ktor/PlutoKtorHelper.kt @@ -19,7 +19,8 @@ private val saveAttributeKey = AttributeKey("ResponseBodySaved") fun HttpClient.addPlutoKtorInterceptor() { plugin(HttpSend).intercept { requestUnBuilt -> val request = requestUnBuilt.build() - val networkInterceptor = NetworkInterceptor.intercept(request.convert(), NetworkInterceptor.Option(NAME)) + val convertedRequest = request.convert() + val networkInterceptor = NetworkInterceptor.intercept(convertedRequest, NetworkInterceptor.Option(NAME)) val callResult = try { requestUnBuilt.url(networkInterceptor.actualOrMockRequestUrl) execute(requestUnBuilt) @@ -34,7 +35,7 @@ fun HttpClient.addPlutoKtorInterceptor() { newCall.attributes.put(saveAttributeKey, Unit) newCall } - networkInterceptor.onResponse(res.response.convert()) + networkInterceptor.onResponse(res.response.convert(convertedRequest)) res } } diff --git a/pluto-plugins/plugins/network/interceptor-ktor/lib/src/main/kotlin/com/pluto/plugins/network/ktor/internal/KtorResponseConverter.kt b/pluto-plugins/plugins/network/interceptor-ktor/lib/src/main/kotlin/com/pluto/plugins/network/ktor/internal/KtorResponseConverter.kt index 888e1b05..b201e74d 100644 --- a/pluto-plugins/plugins/network/interceptor-ktor/lib/src/main/kotlin/com/pluto/plugins/network/ktor/internal/KtorResponseConverter.kt +++ b/pluto-plugins/plugins/network/interceptor-ktor/lib/src/main/kotlin/com/pluto/plugins/network/ktor/internal/KtorResponseConverter.kt @@ -1,5 +1,6 @@ package com.pluto.plugins.network.ktor.internal +import com.pluto.plugins.network.intercept.NetworkData import com.pluto.plugins.network.intercept.NetworkData.Body import com.pluto.plugins.network.intercept.NetworkData.Response import io.ktor.client.statement.HttpResponse @@ -9,15 +10,16 @@ import io.ktor.http.Headers import io.ktor.http.contentType internal object KtorResponseConverter : ResponseConverter { - override suspend fun HttpResponse.convert(): Response { + override suspend fun HttpResponse.convert(request: NetworkData.Request): Response { return Response( + request = request, statusCode = status.value, body = extractBody(), protocol = version.name, fromDiskCache = false, headers = headersMap(headers), sentTimestamp = requestTime.timestamp, - receiveTimestamp = responseTime.timestamp + receiveTimestamp = responseTime.timestamp, ) } diff --git a/pluto-plugins/plugins/network/interceptor-ktor/lib/src/main/kotlin/com/pluto/plugins/network/ktor/internal/ResponseConverter.kt b/pluto-plugins/plugins/network/interceptor-ktor/lib/src/main/kotlin/com/pluto/plugins/network/ktor/internal/ResponseConverter.kt index a6ec250d..d40bdf41 100644 --- a/pluto-plugins/plugins/network/interceptor-ktor/lib/src/main/kotlin/com/pluto/plugins/network/ktor/internal/ResponseConverter.kt +++ b/pluto-plugins/plugins/network/interceptor-ktor/lib/src/main/kotlin/com/pluto/plugins/network/ktor/internal/ResponseConverter.kt @@ -1,7 +1,8 @@ package com.pluto.plugins.network.ktor.internal +import com.pluto.plugins.network.intercept.NetworkData import com.pluto.plugins.network.intercept.NetworkData.Response internal interface ResponseConverter { - suspend fun T.convert(): Response + suspend fun T.convert(request: NetworkData.Request): Response } diff --git a/pluto-plugins/plugins/network/interceptor-okhttp/lib/src/main/kotlin/com/pluto/plugins/network/okhttp/PlutoOkhttpInterceptor.kt b/pluto-plugins/plugins/network/interceptor-okhttp/lib/src/main/kotlin/com/pluto/plugins/network/okhttp/PlutoOkhttpInterceptor.kt index fd85306d..4b709c36 100644 --- a/pluto-plugins/plugins/network/interceptor-okhttp/lib/src/main/kotlin/com/pluto/plugins/network/okhttp/PlutoOkhttpInterceptor.kt +++ b/pluto-plugins/plugins/network/interceptor-okhttp/lib/src/main/kotlin/com/pluto/plugins/network/okhttp/PlutoOkhttpInterceptor.kt @@ -24,7 +24,8 @@ class PlutoOkhttpInterceptor { override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request() - val networkInterceptor = NetworkInterceptor.intercept(request.convert(), NetworkInterceptor.Option(NAME)) + val convertedRequest = request.convert() + val networkInterceptor = NetworkInterceptor.intercept(convertedRequest, NetworkInterceptor.Option(NAME)) val response: Response = try { val builder = request.newBuilder().url(networkInterceptor.actualOrMockRequestUrl) chain.proceed(builder.build()) @@ -32,18 +33,18 @@ class PlutoOkhttpInterceptor { networkInterceptor.onError(e) throw e } - return response.processBody { networkInterceptor.onResponse(it) } + return response.processBody(convertedRequest) { networkInterceptor.onResponse(it) } } } } -private fun Response.processBody(onComplete: (NetworkData.Response) -> Unit): Response { +private fun Response.processBody(request: NetworkData.Request, onComplete: (NetworkData.Response) -> Unit): Response { if (!hasBody()) { - onComplete.invoke(convert(null)) + onComplete.invoke(convert(request, null)) return this } val responseBody: ResponseBody = body as ResponseBody - val sideStream = ReportingSink(PlutoInterface.files.createFile(), ResponseReportingSinkCallback(this, onComplete)) + val sideStream = ReportingSink(PlutoInterface.files.createFile(), ResponseReportingSinkCallback(this, request, onComplete)) val processedResponseBody: ResponseBody = DepletingSource(TeeSource(responseBody.source(), sideStream)) .buffer() .asResponseBody(responseBody) diff --git a/pluto-plugins/plugins/network/interceptor-okhttp/lib/src/main/kotlin/com/pluto/plugins/network/okhttp/internal/DataConvertor.kt b/pluto-plugins/plugins/network/interceptor-okhttp/lib/src/main/kotlin/com/pluto/plugins/network/okhttp/internal/DataConvertor.kt index da21cafc..38740512 100644 --- a/pluto-plugins/plugins/network/interceptor-okhttp/lib/src/main/kotlin/com/pluto/plugins/network/okhttp/internal/DataConvertor.kt +++ b/pluto-plugins/plugins/network/interceptor-okhttp/lib/src/main/kotlin/com/pluto/plugins/network/okhttp/internal/DataConvertor.kt @@ -36,8 +36,9 @@ internal fun Request.headerMap(contentLength: Long): Map { return map } -internal fun Response.convert(body: NetworkData.Body?): NetworkData.Response { +internal fun Response.convert(request: NetworkData.Request, body: NetworkData.Body?): NetworkData.Response { return NetworkData.Response( + request = request, statusCode = code, body = body, protocol = protocol.name, diff --git a/pluto-plugins/plugins/network/interceptor-okhttp/lib/src/main/kotlin/com/pluto/plugins/network/okhttp/internal/ResponseReportingSinkCallback.kt b/pluto-plugins/plugins/network/interceptor-okhttp/lib/src/main/kotlin/com/pluto/plugins/network/okhttp/internal/ResponseReportingSinkCallback.kt index e94648ba..cb2f3021 100644 --- a/pluto-plugins/plugins/network/interceptor-okhttp/lib/src/main/kotlin/com/pluto/plugins/network/okhttp/internal/ResponseReportingSinkCallback.kt +++ b/pluto-plugins/plugins/network/interceptor-okhttp/lib/src/main/kotlin/com/pluto/plugins/network/okhttp/internal/ResponseReportingSinkCallback.kt @@ -12,6 +12,7 @@ import java.io.IOException class ResponseReportingSinkCallback( private val response: Response, + private val request: NetworkData.Request, private val onComplete: (NetworkData.Response) -> Unit ) : ReportingSink.Callback { @@ -20,7 +21,7 @@ class ResponseReportingSinkCallback( readResponseBuffer(f, response.isGzipped)?.let { val responseBody = response.body ?: return val body = responseBody.processBody(it) - onComplete.invoke(response.convert(body)) + onComplete.invoke(response.convert(request, body)) } f.delete() } diff --git a/sample/src/main/java/com/sampleapp/functions/network/DemoNetworkFragment.kt b/sample/src/main/java/com/sampleapp/functions/network/DemoNetworkFragment.kt index e0825e9f..be36d2a6 100644 --- a/sample/src/main/java/com/sampleapp/functions/network/DemoNetworkFragment.kt +++ b/sample/src/main/java/com/sampleapp/functions/network/DemoNetworkFragment.kt @@ -34,6 +34,10 @@ class DemoNetworkFragment : Fragment(R.layout.fragment_demo_network) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + binding.graphqlQuery.setOnClickListener { okhttpViewModel.graphqlQuery() } + binding.graphqlQueryError.setOnClickListener { okhttpViewModel.graphqlQueryError() } + binding.graphqlMutation.setOnClickListener { okhttpViewModel.graphqlMutation() } + binding.graphqlMutationError.setOnClickListener { okhttpViewModel.graphqlMutationError() } binding.postCall.setOnClickListener { okhttpViewModel.post() } binding.getCall.setOnClickListener { okhttpViewModel.get() } binding.getCallKtor.setOnClickListener { ktorViewModel.get() } diff --git a/sample/src/main/java/com/sampleapp/functions/network/internal/custom/CustomViewModel.kt b/sample/src/main/java/com/sampleapp/functions/network/internal/custom/CustomViewModel.kt index 24eee482..dc8ece7a 100644 --- a/sample/src/main/java/com/sampleapp/functions/network/internal/custom/CustomViewModel.kt +++ b/sample/src/main/java/com/sampleapp/functions/network/internal/custom/CustomViewModel.kt @@ -12,21 +12,23 @@ class CustomViewModel : ViewModel() { @SuppressWarnings("MagicNumber") fun customTrace() { viewModelScope.launch { + val request = NetworkData.Request( + url = "https://google.com", + method = "GET", + body = NetworkData.Body( + body = "{\"message\": \"body\"}", + contentType = "application/json", + ), + headers = emptyMap(), + sentTimestamp = System.currentTimeMillis() + ) val networkInterceptor = NetworkInterceptor.intercept( - NetworkData.Request( - url = "https://google.com", - method = "GET", - body = NetworkData.Body( - body = "{\"message\": \"body\"}", - contentType = "application/json", - ), - headers = emptyMap(), - sentTimestamp = System.currentTimeMillis() - ) + request, ) delay(5_000) networkInterceptor.onResponse( NetworkData.Response( + request = request, statusCode = 503, body = NetworkData.Body( body = "body", diff --git a/sample/src/main/java/com/sampleapp/functions/network/internal/okhttp/ApiService.kt b/sample/src/main/java/com/sampleapp/functions/network/internal/okhttp/ApiService.kt index cc7e6258..556d070d 100644 --- a/sample/src/main/java/com/sampleapp/functions/network/internal/okhttp/ApiService.kt +++ b/sample/src/main/java/com/sampleapp/functions/network/internal/okhttp/ApiService.kt @@ -30,4 +30,8 @@ interface ApiService { ) @POST("xml") suspend fun xml(@Body hashMapOf: RequestBody): Any + + // https://studio.apollographql.com/public/SpaceX-pxxbxen/variant/current/home + @POST("https://spacex-production.up.railway.app/") + suspend fun graphql(@Body body: Any): Any } diff --git a/sample/src/main/java/com/sampleapp/functions/network/internal/okhttp/OkhttpViewModel.kt b/sample/src/main/java/com/sampleapp/functions/network/internal/okhttp/OkhttpViewModel.kt index f6a0584f..b03e58af 100644 --- a/sample/src/main/java/com/sampleapp/functions/network/internal/okhttp/OkhttpViewModel.kt +++ b/sample/src/main/java/com/sampleapp/functions/network/internal/okhttp/OkhttpViewModel.kt @@ -25,6 +25,58 @@ class OkhttpViewModel : ViewModel() { } } + fun graphqlQuery() { + viewModelScope.launch { + enqueue { + apiService.graphql( + mapOf( + GQL_QUERY to "query Launches(\$limit: Int){launches(limit: \$limit){mission_name}}", + GQL_VARIABLES to mapOf("limit" to GQL_LIMIT_VALID), + ) + ) + } + } + } + + fun graphqlQueryError() { + viewModelScope.launch { + enqueue { + apiService.graphql( + mapOf( + GQL_QUERY to "query Launches(\$limit: Int){launches(limit: \$limit){mission_name}}", + GQL_VARIABLES to mapOf("limit" to GQL_LIMIT_INVALID), + ) + ) + } + } + } + + fun graphqlMutation() { + viewModelScope.launch { + enqueue { + apiService.graphql( + mapOf( + GQL_QUERY to "mutation Insert_users(\$objects: [users_insert_input!]!) {insert_users(objects: \$objects) {affected_rows}}", + GQL_VARIABLES to mapOf("objects" to emptyList()), + ) + ) + } + } + } + + fun graphqlMutationError() { + viewModelScope.launch { + enqueue { + apiService.graphql( + mapOf( + GQL_QUERY to "mutation Insert_users(\$objects: [users_insert_input!]!) {insert_users112231321(objects: \$objects) {affected_rows}}", + GQL_VARIABLES to mapOf("objects" to emptyList()), + ) + ) + } + } + } + fun post() { val label = "POST call" viewModelScope.launch { @@ -79,4 +131,11 @@ class OkhttpViewModel : ViewModel() { ) } } + + companion object { + private const val GQL_QUERY = "query" + private const val GQL_LIMIT_VALID = 3 + private const val GQL_LIMIT_INVALID = -1111 + private const val GQL_VARIABLES = "variables" + } } diff --git a/sample/src/main/res/layout/fragment_container.xml b/sample/src/main/res/layout/fragment_container.xml index e9de71c6..e1c13520 100644 --- a/sample/src/main/res/layout/fragment_container.xml +++ b/sample/src/main/res/layout/fragment_container.xml @@ -14,7 +14,7 @@ + + + + + + + + + \ No newline at end of file