Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,18 @@ import com.facebook.flipper.plugins.inspector.InspectorFlipperPlugin
import com.facebook.flipper.plugins.network.NetworkFlipperPlugin
import com.facebook.flipper.plugins.sharedpreferences.SharedPreferencesFlipperPlugin
import com.facebook.soloader.SoLoader
import com.woocommerce.android.apifaker.ApiFakerUiHelper
import com.woocommerce.android.util.SystemVersionUtils
import com.woocommerce.android.util.WooLog
import com.woocommerce.android.util.WooLog.T
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject

@HiltAndroidApp
class WooCommerceDebug : WooCommerce() {
@Inject
lateinit var apiFakerUiHelper: ApiFakerUiHelper

override fun onCreate() {
if (FlipperUtils.shouldEnableFlipper(this)) {
SoLoader.init(this, false)
Expand All @@ -33,6 +38,7 @@ class WooCommerceDebug : WooCommerce() {
enableWebContentDebugging()
super.onCreate()
enableStrictMode()
apiFakerUiHelper.attachToApplication(this)
}

/**
Expand Down
1 change: 1 addition & 0 deletions libs/apifaker/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ android {

dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.navigation.compose)

implementation(platform(libs.androidx.compose.bom))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Protocol
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import okhttp3.internal.EMPTY_RESPONSE
import javax.inject.Inject

private const val ARTIFICIAL_DELAY_MS = 500L
Expand Down Expand Up @@ -39,7 +40,10 @@ internal class ApiFakerInterceptor @Inject constructor(
.message("Fake Response")
.code(fakeResponse.statusCode)
// TODO check if it's safe to always use JSON as the content type
.body(fakeResponse.body?.toResponseBody("application/json".toMediaType()))
.body(
fakeResponse.body?.toResponseBody("application/json".toMediaType())
?: EMPTY_RESPONSE
)
.addHeader("content-type", "application/json")
.build()
} else {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package com.woocommerce.android.apifaker

import android.annotation.SuppressLint
import android.app.Activity
import android.app.Application
import android.app.Application.ActivityLifecycleCallbacks
import android.graphics.Color
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.MarginLayoutParams
import android.widget.FrameLayout
import android.widget.TextView
import androidx.activity.ComponentActivity
import androidx.appcompat.app.AlertDialog
import androidx.core.view.doOnLayout
import androidx.core.view.updateLayoutParams
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import java.lang.ref.WeakReference
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class ApiFakerUiHelper @Inject constructor() : ActivityLifecycleCallbacks {
@Inject
internal lateinit var apiFakerConfig: ApiFakerConfig

private val apiFakerHintId = View.generateViewId()

fun attachToApplication(application: Application) {
application.registerActivityLifecycleCallbacks(this)
}

override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
(activity as? ComponentActivity)?.lifecycleScope?.launch {
updateApiFakerHint(WeakReference(activity))
}
}

override fun onActivityStarted(activity: Activity) = Unit

override fun onActivityResumed(activity: Activity) = Unit

override fun onActivityPaused(activity: Activity) = Unit

override fun onActivityStopped(activity: Activity) = Unit

override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit

override fun onActivityDestroyed(activity: Activity) = Unit

private suspend fun updateApiFakerHint(
activityReference: WeakReference<ComponentActivity>
) {
apiFakerConfig.enabled.collect { enabled ->
activityReference.get()?.let { activity ->
if (enabled) {
activity.window.decorView.post {
activity.window.decorView.showApiFakerHint(activity)
}
} else {
activity.window.decorView.post {
activity.window.decorView.hideApiFakerHint()
}
}
}
}
}

@SuppressLint("SetTextI18n")
private fun View.showApiFakerHint(activity: ComponentActivity) {
// This works only for activities that has the content view as a direct child of the FrameLayout, which is true
// for all AppCompat activities, so it should work for all the cases we need.
val contentLayout = findViewById<View>(android.R.id.content) as? FrameLayout ?: return
val activityLayout = contentLayout.getChildAt(0)

val apiFakerHint = FrameLayout(context).apply {
id = apiFakerHintId
setBackgroundColor(Color.RED)
addView(
TextView(context).apply {
text = "ApiFaker Enabled"
textAlignment = View.TEXT_ALIGNMENT_CENTER
setTextColor(Color.WHITE)
}
)
setOnClickListener {
AlertDialog.Builder(context)
.setTitle("ApiFaker")
.setMessage("ApiFaker is enabled. Do you want to disable it?")
.setPositiveButton("Yes") { _, _ ->
activity.lifecycleScope.launch {
apiFakerConfig.setStatus(false)
}
}
.setNegativeButton("No") { _, _ -> }
.show()
}
}
contentLayout.addView(
apiFakerHint,
FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.WRAP_CONTENT
).apply {
gravity = android.view.Gravity.BOTTOM
}
)

apiFakerHint.doOnLayout { view ->
activityLayout.updateLayoutParams<MarginLayoutParams> { bottomMargin = view.measuredHeight }
}
}

private fun View.hideApiFakerHint() {
val contentLayout = findViewById<ViewGroup>(android.R.id.content)
contentLayout.findViewById<View>(apiFakerHintId)?.let { apiFakerHint ->
contentLayout.removeView(apiFakerHint)
}
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package com.woocommerce.android.apifaker

import android.util.Log
import com.woocommerce.android.apifaker.db.EndpointDao
import com.woocommerce.android.apifaker.models.ApiType
import com.woocommerce.android.apifaker.models.HttpMethod
import com.woocommerce.android.apifaker.models.QueryParameter
import com.woocommerce.android.apifaker.models.Response
import com.woocommerce.android.apifaker.util.JSONObjectProvider
import okhttp3.HttpUrl
Expand All @@ -18,23 +20,33 @@ internal class EndpointProcessor @Inject constructor(
private val jsonObjectProvider: JSONObjectProvider
) {
fun fakeRequestIfNeeded(request: Request): Response? {
// TODO match against method and query parameters too
val endpointData = when {
request.url.host == WPCOM_HOST -> request.extractDataFromWPComEndpoint()
request.url.encodedPath.startsWith("/wp-json") -> request.extractDataFromWPApiEndpoint()
else -> request.extractDataFromCustomEndpoint()
}

return with(endpointData) {
endpointDao.queryEndpoint(apiType, endpointData.httpMethod, path.trimEnd('/'), body.orEmpty())
}?.response?.let {
endpointDao.queryEndpoint(apiType, httpMethod, path.trimEnd('/'), body.orEmpty())
}.filter {
request.url.checkQueryParameters(it.request.queryParameters)
}.also {
if (it.size > 1) {
Log.w(
LOG_TAG,
"More than one endpoint matched the request: $request, " +
"the endpoints matched are\n$it\n" +
"The first one will be used."
)
}
}.firstOrNull()?.response?.let {
it.copy(body = it.body?.wrapBodyIfNecessary(request.url))
}
}

private fun Request.extractDataFromWPComEndpoint(): EndpointData {
val originalBody = readBody()
return if (url.encodedPath.trimEnd('/').matches(Regex(JETPACK_TUNNEL_REGEX))) {
return if (url.isJetpackTunnelRequest) {
val (path, method, body) = if (method == "GET") {
Triple(
url.queryParameter("path")!!.substringBefore("&"),
Expand Down Expand Up @@ -87,6 +99,25 @@ internal class EndpointProcessor @Inject constructor(
)
}

private fun HttpUrl.checkQueryParameters(mockedQueryParameters: List<QueryParameter>): Boolean {
if (mockedQueryParameters.isEmpty()) return true

val requestQueryParameters = if (isJetpackTunnelRequest) {
queryParameter("query")?.let {
val json = jsonObjectProvider.parseString(it)
json.keys().asSequence().map { key ->
key to json.getString(key)
}.toMap()
} ?: emptyMap()
} else {
queryParameterNames.associateWith { queryParameter(it) }
}

return mockedQueryParameters.all { queryParameter ->
requestQueryParameters[queryParameter.name] == queryParameter.value
}
}

private fun Request.readBody(): String {
val requestBody = body
return if (requestBody != null) {
Expand All @@ -100,8 +131,7 @@ internal class EndpointProcessor @Inject constructor(
}

private fun String.wrapBodyIfNecessary(url: HttpUrl): String {
return if (url.host == WPCOM_HOST &&
url.encodedPath.trimEnd('/').matches(Regex(JETPACK_TUNNEL_REGEX)) &&
return if (url.isJetpackTunnelRequest &&
!startsWith("{\"data\":")
) {
"{\"data\": $this}"
Expand All @@ -113,6 +143,9 @@ internal class EndpointProcessor @Inject constructor(
private val Request.httpMethod
get() = HttpMethod.valueOf(this.method.uppercase())

private val HttpUrl.isJetpackTunnelRequest
get() = host == WPCOM_HOST && encodedPath.trimEnd('/').matches(Regex(JETPACK_TUNNEL_REGEX))

private data class EndpointData(
val apiType: ApiType,
val path: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,13 @@ import com.woocommerce.android.apifaker.models.Response
Request::class,
Response::class
],
version = 1,
version = 2,
exportSchema = false
)
@TypeConverters(EndpointTypeConverter::class)
@TypeConverters(
EndpointTypeConverter::class,
QueryParameterConverter::class
)
internal abstract class ApiFakerDatabase : RoomDatabase() {
companion object {
fun buildDb(applicationContext: Context) = Room
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ internal interface EndpointDao {
:body LIKE COALESCE(body, '%')
"""
)
fun queryEndpoint(type: ApiType, httpMethod: HttpMethod, path: String, body: String): MockedEndpoint?
fun queryEndpoint(type: ApiType, httpMethod: HttpMethod, path: String, body: String): List<MockedEndpoint>

@Transaction
@Query("Select * FROM Request WHERE id = :id")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.woocommerce.android.apifaker.db

import androidx.room.TypeConverter
import com.woocommerce.android.apifaker.models.QueryParameter

internal class QueryParameterConverter {
@TypeConverter
fun fromQueryParameters(queryParameters: List<QueryParameter>): String {
return queryParameters.joinToString("&") { "${it.name}:${it.value}" }
}

@TypeConverter
fun toQueryParameters(query: String): List<QueryParameter> {
return query.takeIf { it.isNotBlank() }?.split("&")?.map { parts ->
val (name, value) = parts.split(":")
QueryParameter(name, value)
} ?: emptyList()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.woocommerce.android.apifaker.models

internal data class QueryParameter(
val name: String,
val value: String
)
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import androidx.room.PrimaryKey
internal data class Request(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val type: ApiType,
val httpMethod: HttpMethod?,
val path: String,
val body: String?
val httpMethod: HttpMethod? = null,
val queryParameters: List<QueryParameter> = emptyList(),
val body: String? = null
)
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,5 @@ import androidx.room.PrimaryKey
internal data class Response(
@PrimaryKey val endpointId: Long = 0,
val statusCode: Int,
val body: String?,
val body: String? = null
)
Loading
Loading