diff --git a/.buildkite/commands/run-unit-tests.sh b/.buildkite/commands/run-unit-tests.sh index 7419ce409912..9d28c534e675 100755 --- a/.buildkite/commands/run-unit-tests.sh +++ b/.buildkite/commands/run-unit-tests.sh @@ -2,7 +2,7 @@ echo "--- 🧪 Testing" set +e -./gradlew testJalapenoDebugUnitTest lib:cardreader:testDebugUnitTest lib:iap:testDebugUnitTest +./gradlew testJalapenoDebugUnitTest testDebugUnitTest TESTS_EXIT_STATUS=$? set -e diff --git a/WooCommerce/build.gradle b/WooCommerce/build.gradle index 97070403ed02..71dc80c92912 100644 --- a/WooCommerce/build.gradle +++ b/WooCommerce/build.gradle @@ -359,6 +359,7 @@ dependencies { implementation(libs.google.play.services.wearable) // Debug dependencies + debugImplementation project(":libs:apifaker") debugImplementation(libs.facebook.flipper.main) debugImplementation(libs.facebook.soloader) debugImplementation(libs.facebook.flipper.network.plugin) { diff --git a/WooCommerce/src/debug/kotlin/com/woocommerce/android/WooCommerceDebug.kt b/WooCommerce/src/debug/kotlin/com/woocommerce/android/WooCommerceDebug.kt index fa3b41b9b846..bb5860acba95 100644 --- a/WooCommerce/src/debug/kotlin/com/woocommerce/android/WooCommerceDebug.kt +++ b/WooCommerce/src/debug/kotlin/com/woocommerce/android/WooCommerceDebug.kt @@ -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) @@ -33,6 +38,7 @@ class WooCommerceDebug : WooCommerce() { enableWebContentDebugging() super.onCreate() enableStrictMode() + apiFakerUiHelper.attachToApplication(this) } /** diff --git a/WooCommerce/src/debug/kotlin/com/woocommerce/android/apifaker/ApiFakerHostFragment.kt b/WooCommerce/src/debug/kotlin/com/woocommerce/android/apifaker/ApiFakerHostFragment.kt new file mode 100644 index 000000000000..8bc1b3a355e3 --- /dev/null +++ b/WooCommerce/src/debug/kotlin/com/woocommerce/android/apifaker/ApiFakerHostFragment.kt @@ -0,0 +1,32 @@ +package com.woocommerce.android.apifaker + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.navigation.findNavController +import com.woocommerce.android.apifaker.ui.ApiFakerNavHost +import com.woocommerce.android.ui.base.BaseFragment +import com.woocommerce.android.ui.compose.theme.WooThemeWithBackground +import com.woocommerce.android.ui.main.AppBarStatus + +class ApiFakerHostFragment : BaseFragment() { + override val activityAppBarStatus: AppBarStatus + get() = AppBarStatus.Hidden + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + + setContent { + WooThemeWithBackground { + ApiFakerNavHost( + onExit = { findNavController().navigateUp() } + ) + } + } + } + } +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/prefs/developer/DeveloperOptionsFragment.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/prefs/developer/DeveloperOptionsFragment.kt index 7e1af080bde4..db4df3d44c21 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/prefs/developer/DeveloperOptionsFragment.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/prefs/developer/DeveloperOptionsFragment.kt @@ -6,6 +6,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.woocommerce.android.R import com.woocommerce.android.ui.base.BaseFragment @@ -46,6 +47,12 @@ class DeveloperOptionsFragment : BaseFragment() { selectedValue = event.selectedValue ) } + + is DeveloperOptionsViewModel.DeveloperOptionsEvents.OpenApiFaker -> { + findNavController().navigate( + DeveloperOptionsFragmentDirections.actionDeveloperOptionsFragmentToApiFaker() + ) + } } } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/prefs/developer/DeveloperOptionsViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/prefs/developer/DeveloperOptionsViewModel.kt index 3e7738b412f8..ec757489ad70 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/prefs/developer/DeveloperOptionsViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/prefs/developer/DeveloperOptionsViewModel.kt @@ -16,6 +16,7 @@ import com.woocommerce.android.viewmodel.MultiLiveEvent import com.woocommerce.android.viewmodel.ScopedViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import javax.inject.Inject @@ -77,11 +78,24 @@ class DeveloperOptionsViewModel @Inject constructor( ) } + private val apiFakerFlow = flowOf( + NonToggleableListItem( + icon = R.drawable.ic_globe, + iconTint = R.color.color_primary, + label = UiString.UiStringText("API Faker"), + isEnabled = true, + onClick = { + triggerEvent(DeveloperOptionsEvents.OpenApiFaker) + } + ) + ) + val viewState = combine( simulatedCardReaderFlow, readerUpdateFrequencyFlow, interacPaymentEnabledFlow, - savedPrivacySettingsOnDialogFlow + savedPrivacySettingsOnDialogFlow, + apiFakerFlow ) { items -> DeveloperOptionsViewState( rows = items.filterNotNull() @@ -123,6 +137,8 @@ class DeveloperOptionsViewModel @Inject constructor( val options: List, var selectedValue: UpdateFrequencyUiModel, ) : DeveloperOptionsEvents() + + data object OpenApiFaker : DeveloperOptionsEvents() } data class DeveloperOptionsViewState( diff --git a/WooCommerce/src/main/res/navigation/nav_graph_settings.xml b/WooCommerce/src/main/res/navigation/nav_graph_settings.xml index 41056b472cee..b29db0c9c24e 100644 --- a/WooCommerce/src/main/res/navigation/nav_graph_settings.xml +++ b/WooCommerce/src/main/res/navigation/nav_graph_settings.xml @@ -7,6 +7,7 @@ + + android:label="DeveloperOptionsFragment"> + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ce02a55b13e6..fd3a632077cc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,6 +22,7 @@ androidx-navigation = '2.7.7' androidx-preference = '1.2.1' androidx-recyclerview-main = '1.3.2' androidx-recyclerview-selection = '1.1.0' +androidx-room = "2.6.1" androidx-test-espresso = '3.4.0' androidx-test-ext = '1.1.5' androidx-test-main = '1.4.0' @@ -130,6 +131,7 @@ androidx-hilt-navigation-fragment = { group = "androidx.hilt", name = "hilt-navi androidx-hilt-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "androidx-hilt" } androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "androidx-lifecycle" } androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "androidx-lifecycle" } +androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-viewmodel-savedstate = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-savedstate", version.ref = "androidx-lifecycle" } androidx-navigation-common = { group = "androidx.navigation", name = "navigation-common", version.ref = "androidx-navigation" } @@ -141,6 +143,9 @@ androidx-preference-main = { group = "androidx.preference", name = "preference", androidx-preference-ktx = { group = "androidx.preference", name = "preference-ktx", version.ref = "androidx-preference" } androidx-recyclerview-main = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "androidx-recyclerview-main" } androidx-recyclerview-selection = { group = "androidx.recyclerview", name = "recyclerview-selection", version.ref = "androidx-recyclerview-selection" } +androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "androidx-room" } +androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "androidx-room" } +androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "androidx-room" } androidx-test-espresso-contrib = { group = "androidx.test.espresso", name = "espresso-contrib", version.ref = "androidx-test-espresso" } androidx-test-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidx-test-espresso" } androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext" } diff --git a/libs/apifaker/build.gradle b/libs/apifaker/build.gradle new file mode 100644 index 000000000000..db4e680480ad --- /dev/null +++ b/libs/apifaker/build.gradle @@ -0,0 +1,81 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.google.dagger.hilt) + alias(libs.plugins.ksp) +} + +repositories { + maven { + url 'https://a8c-libs.s3.amazonaws.com/android' + content { + includeGroup "org.wordpress" + includeGroup "org.wordpress.fluxc" + includeGroup "org.wordpress.fluxc.plugins" + includeGroup "org.wordpress.wellsql" + includeGroup "org.wordpress.mediapicker" + includeGroup "com.automattic" + includeGroup "com.automattic.tracks" + } + } + + maven { + url "https://a8c-libs.s3.amazonaws.com/android/jcenter-mirror" + content { + includeVersion "com.android.volley", "volley", "1.1.1" + } + } +} + +android { + namespace 'com.woocommerce.android.apifaker' + + defaultConfig { + minSdkVersion gradle.ext.minSdkVersion + targetSdk gradle.ext.targetSdkVersion + compileSdk gradle.ext.compileSdkVersion + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildFeatures { + compose true + } + + compileOptions { + sourceCompatibility libs.versions.java.get() + targetCompatibility libs.versions.java.get() + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.material.main) + implementation(libs.androidx.compose.runtime.livedata) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material.icons.extended) + debugImplementation(libs.androidx.compose.ui.tooling.main) + + implementation("${gradle.ext.fluxCBinaryPath}:${libs.versions.wordpress.fluxc.get()}") { + exclude group: "com.android.support" + exclude group: "org.wordpress", module: "utils" + } + + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + ksp(libs.androidx.room.compiler) + + implementation(libs.google.dagger.hilt.android.main) + implementation(libs.androidx.hilt.navigation.compose) + ksp(libs.google.dagger.hilt.compiler) + + testImplementation(libs.junit) + testImplementation(libs.mockito.kotlin) + testImplementation(libs.mockito.inline) +} diff --git a/libs/apifaker/src/main/AndroidManifest.xml b/libs/apifaker/src/main/AndroidManifest.xml new file mode 100644 index 000000000000..8bdb7e14b389 --- /dev/null +++ b/libs/apifaker/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/ApiFakerConfig.kt b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/ApiFakerConfig.kt new file mode 100644 index 000000000000..a666c1182d99 --- /dev/null +++ b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/ApiFakerConfig.kt @@ -0,0 +1,51 @@ +package com.woocommerce.android.apifaker + +import android.content.Context +import android.content.SharedPreferences +import com.woocommerce.android.apifaker.db.EndpointDao +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject +import javax.inject.Singleton + +private const val PREF_FILE_NAME = "api_faker" +private const val PREFERENCE_KEY = "api_faker_enabled" + +@Singleton +internal class ApiFakerConfig @Inject constructor( + context: Context, + endpointDao: EndpointDao +) { + private val configScope = CoroutineScope(Dispatchers.Main) + private val preferences = context.getSharedPreferences(PREF_FILE_NAME, Context.MODE_PRIVATE) + + private val prefFlow = preferences.prefFlow(PREFERENCE_KEY, false) + + val enabled = combine( + prefFlow, + endpointDao.observeEndpointsCount().map { it == 0 } + ) { pref, isEmpty -> + pref && !isEmpty + }.stateIn(configScope, SharingStarted.Eagerly, false) + + fun setStatus(enabled: Boolean) { + preferences.edit().putBoolean(PREFERENCE_KEY, enabled).apply() + } + + private fun SharedPreferences.prefFlow(key: String, defaultValue: Boolean) = callbackFlow { + val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, listenerKey -> + if (listenerKey == key) { + trySend(getBoolean(key, defaultValue)) + } + } + registerOnSharedPreferenceChangeListener(listener) + trySend(getBoolean(key, defaultValue)) + awaitClose { unregisterOnSharedPreferenceChangeListener(listener) } + } +} diff --git a/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/ApiFakerInterceptor.kt b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/ApiFakerInterceptor.kt new file mode 100644 index 000000000000..73f4cad56213 --- /dev/null +++ b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/ApiFakerInterceptor.kt @@ -0,0 +1,53 @@ +package com.woocommerce.android.apifaker + +import android.util.Log +import okhttp3.Interceptor +import okhttp3.Interceptor.Chain +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 + +internal class ApiFakerInterceptor @Inject constructor( + private val apiFakerConfig: ApiFakerConfig, + private val endpointProcessor: EndpointProcessor +) : Interceptor { + @Suppress("TooGenericExceptionCaught", "SwallowedException") + override fun intercept(chain: Chain): Response { + if (!apiFakerConfig.enabled.value) { + return chain.proceed(chain.request()) + } + + Log.d(LOG_TAG, "Intercepting request: ${chain.request().url}") + val request = chain.request() + val fakeResponse = try { + endpointProcessor.fakeRequestIfNeeded(request) + } catch (e: Exception) { + Log.w(LOG_TAG, "Faking request: ${chain.request()} failed, ignoring") + null + } + + return if (fakeResponse != null) { + Log.d(LOG_TAG, "Matched request: ${chain.request().url}:\nSending Mocked Response: $fakeResponse") + Thread.sleep(ARTIFICIAL_DELAY_MS) + Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .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()) + ?: EMPTY_RESPONSE + ) + .addHeader("content-type", "application/json") + .build() + } else { + chain.proceed(request) + } + } +} diff --git a/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/ApiFakerUiHelper.kt b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/ApiFakerUiHelper.kt new file mode 100644 index 000000000000..7bb7ecbb2690 --- /dev/null +++ b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/ApiFakerUiHelper.kt @@ -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 + ) { + 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(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 { bottomMargin = view.measuredHeight } + } + } + + private fun View.hideApiFakerHint() { + val contentLayout = findViewById(android.R.id.content) + contentLayout.findViewById(apiFakerHintId)?.let { apiFakerHint -> + contentLayout.removeView(apiFakerHint) + } + } +} diff --git a/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/EndpointProcessor.kt b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/EndpointProcessor.kt new file mode 100644 index 000000000000..b7090206c6e2 --- /dev/null +++ b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/EndpointProcessor.kt @@ -0,0 +1,155 @@ +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 +import okhttp3.Request +import okio.Buffer +import javax.inject.Inject + +private const val WPCOM_HOST = "public-api.wordpress.com" +private const val JETPACK_TUNNEL_REGEX = "/rest/v1.1/jetpack-blogs/\\d+/rest-api" + +internal class EndpointProcessor @Inject constructor( + private val endpointDao: EndpointDao, + private val jsonObjectProvider: JSONObjectProvider +) { + fun fakeRequestIfNeeded(request: Request): Response? { + 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, 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.isJetpackTunnelRequest) { + val (path, method, body) = if (method == "GET") { + Triple( + url.queryParameter("path")!!.substringBefore("&"), + url.queryParameter("_method") ?: "GET", + null + ) + } else { + val jsonObject = jsonObjectProvider.parseString(originalBody) + val pathParts = jsonObject.getString("path").split("&") + val path = pathParts[0] + val method = pathParts.firstOrNull { it.startsWith("_method") }?.split("=")?.getOrNull(1) ?: "POST" + Triple( + path, + method, + jsonObject.optString("body") + ) + } + + EndpointData( + apiType = ApiType.WPApi, + httpMethod = HttpMethod.valueOf(method.uppercase()), + path = path, + body = body + ) + } else { + EndpointData( + apiType = ApiType.WPCom, + httpMethod = httpMethod, + path = url.encodedPath.substringAfter("/rest"), + body = originalBody + ) + } + } + + private fun Request.extractDataFromWPApiEndpoint(): EndpointData { + return EndpointData( + apiType = ApiType.WPApi, + httpMethod = httpMethod, + path = url.encodedPath.substringAfter("/wp-json"), + body = readBody() + ) + } + + private fun Request.extractDataFromCustomEndpoint(): EndpointData { + return EndpointData( + apiType = ApiType.Custom(host = url.host), + httpMethod = httpMethod, + path = url.encodedPath, + body = readBody() + ) + } + + private fun HttpUrl.checkQueryParameters(mockedQueryParameters: List): 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) { + val buffer = Buffer() + requestBody.writeTo(buffer) + + buffer.readUtf8() + } else { + "" + } + } + + private fun String.wrapBodyIfNecessary(url: HttpUrl): String { + return if (url.isJetpackTunnelRequest && + !startsWith("{\"data\":") + ) { + "{\"data\": $this}" + } else { + this + } + } + + 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, + val httpMethod: HttpMethod, + val body: String? + ) +} diff --git a/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/LogTag.kt b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/LogTag.kt new file mode 100644 index 000000000000..3bc6836eb1d5 --- /dev/null +++ b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/LogTag.kt @@ -0,0 +1,3 @@ +package com.woocommerce.android.apifaker + +internal const val LOG_TAG = "WCApiFaker" diff --git a/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/db/ApiFakerDatabase.kt b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/db/ApiFakerDatabase.kt new file mode 100644 index 000000000000..f8b5c5ee01fe --- /dev/null +++ b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/db/ApiFakerDatabase.kt @@ -0,0 +1,36 @@ +package com.woocommerce.android.apifaker.db + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import com.woocommerce.android.apifaker.models.Request +import com.woocommerce.android.apifaker.models.Response + +@Database( + entities = [ + Request::class, + Response::class + ], + version = 2, + exportSchema = false +) +@TypeConverters( + EndpointTypeConverter::class, + QueryParameterConverter::class +) +internal abstract class ApiFakerDatabase : RoomDatabase() { + companion object { + fun buildDb(applicationContext: Context) = Room + .databaseBuilder( + context = applicationContext, + klass = ApiFakerDatabase::class.java, + name = "api-faker-db" + ) + .fallbackToDestructiveMigration() + .build() + } + + abstract val endpointDao: EndpointDao +} diff --git a/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/db/EndpointDao.kt b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/db/EndpointDao.kt new file mode 100644 index 000000000000..9ddb2d87d353 --- /dev/null +++ b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/db/EndpointDao.kt @@ -0,0 +1,54 @@ +package com.woocommerce.android.apifaker.db + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import com.woocommerce.android.apifaker.models.ApiType +import com.woocommerce.android.apifaker.models.HttpMethod +import com.woocommerce.android.apifaker.models.MockedEndpoint +import com.woocommerce.android.apifaker.models.Request +import com.woocommerce.android.apifaker.models.Response +import kotlinx.coroutines.flow.Flow + +@Dao +internal interface EndpointDao { + @Transaction + @Query("Select * FROM Request") + fun observeEndpoints(): Flow> + + @Query("Select COUNT(*) FROM Request") + fun observeEndpointsCount(): Flow + + @Transaction + @Query( + """Select * FROM Request WHERE + type = :type AND + (httpMethod is NULL OR httpMethod = :httpMethod) AND + :path LIKE path AND + :body LIKE COALESCE(body, '%') + """ + ) + fun queryEndpoint(type: ApiType, httpMethod: HttpMethod, path: String, body: String): List + + @Transaction + @Query("Select * FROM Request WHERE id = :id") + suspend fun getEndpoint(id: Long): MockedEndpoint? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertRequest(request: Request): Long + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertResponse(response: Response) + + @Delete + suspend fun deleteRequest(request: Request) + + @Transaction + suspend fun insertEndpoint(request: Request, response: Response) { + val id = insertRequest(request) + insertResponse(response.copy(endpointId = id)) + } +} diff --git a/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/db/EndpointTypeConverter.kt b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/db/EndpointTypeConverter.kt new file mode 100644 index 000000000000..c7b59095718f --- /dev/null +++ b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/db/EndpointTypeConverter.kt @@ -0,0 +1,24 @@ +package com.woocommerce.android.apifaker.db + +import androidx.room.TypeConverter +import com.woocommerce.android.apifaker.models.ApiType + +internal class EndpointTypeConverter { + @TypeConverter + fun fromEndpointType(apiType: ApiType?): String? { + if (apiType == null) return null + return apiType::class.simpleName + + if (apiType is ApiType.Custom) ":${apiType.host}" else "" + } + + @TypeConverter + fun toEndpointType(value: String?): ApiType? { + if (value == null) return null + val parts = value.split(":") + return when (parts[0]) { + ApiType.WPApi::class.simpleName -> ApiType.WPApi + ApiType.WPCom::class.simpleName -> ApiType.WPCom + else -> ApiType.Custom(parts[1]) + } + } +} diff --git a/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/db/QueryParameterConverter.kt b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/db/QueryParameterConverter.kt new file mode 100644 index 000000000000..a7fcb0784181 --- /dev/null +++ b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/db/QueryParameterConverter.kt @@ -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): String { + return queryParameters.joinToString("&") { "${it.name}:${it.value}" } + } + + @TypeConverter + fun toQueryParameters(query: String): List { + return query.takeIf { it.isNotBlank() }?.split("&")?.map { parts -> + val (name, value) = parts.split(":") + QueryParameter(name, value) + } ?: emptyList() + } +} diff --git a/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/di/ApiFakerModule.kt b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/di/ApiFakerModule.kt new file mode 100644 index 000000000000..22cb93f9af92 --- /dev/null +++ b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/di/ApiFakerModule.kt @@ -0,0 +1,34 @@ +package com.woocommerce.android.apifaker.di + +import android.content.Context +import com.woocommerce.android.apifaker.ApiFakerConfig +import com.woocommerce.android.apifaker.ApiFakerInterceptor +import com.woocommerce.android.apifaker.EndpointProcessor +import com.woocommerce.android.apifaker.db.ApiFakerDatabase +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet +import okhttp3.Interceptor +import javax.inject.Named +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object ApiFakerModule { + @Provides + @Singleton + internal fun providesDatabase(context: Context) = ApiFakerDatabase.buildDb(context) + + @Provides + internal fun providesEndpointDao(db: ApiFakerDatabase) = db.endpointDao + + @Provides + @IntoSet + @Named("interceptors") + internal fun providesInterceptor( + apiFakerConfig: ApiFakerConfig, + endpointProcessor: EndpointProcessor + ): Interceptor = ApiFakerInterceptor(apiFakerConfig, endpointProcessor) +} diff --git a/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/models/ApiType.kt b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/models/ApiType.kt new file mode 100644 index 000000000000..b00d636763c4 --- /dev/null +++ b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/models/ApiType.kt @@ -0,0 +1,12 @@ +package com.woocommerce.android.apifaker.models + +internal sealed interface ApiType { + companion object { + fun defaultValues(): List = listOf(WPApi, WPCom, Custom("")) + } + + data object WPApi : ApiType + data object WPCom : ApiType + + data class Custom(val host: String) : ApiType +} diff --git a/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/models/HttpMethod.kt b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/models/HttpMethod.kt new file mode 100644 index 000000000000..718b85e5c64b --- /dev/null +++ b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/models/HttpMethod.kt @@ -0,0 +1,5 @@ +package com.woocommerce.android.apifaker.models + +internal enum class HttpMethod { + GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD, TRACE, CONNECT +} diff --git a/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/models/MockedEndpoint.kt b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/models/MockedEndpoint.kt new file mode 100644 index 000000000000..b7cf3bbccb0c --- /dev/null +++ b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/models/MockedEndpoint.kt @@ -0,0 +1,13 @@ +package com.woocommerce.android.apifaker.models + +import androidx.room.Embedded +import androidx.room.Relation + +internal data class MockedEndpoint( + @Embedded val request: Request, + @Relation( + parentColumn = "id", + entityColumn = "endpointId" + ) + val response: Response +) diff --git a/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/models/QueryParameter.kt b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/models/QueryParameter.kt new file mode 100644 index 000000000000..ebfc7e0bc5c4 --- /dev/null +++ b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/models/QueryParameter.kt @@ -0,0 +1,6 @@ +package com.woocommerce.android.apifaker.models + +internal data class QueryParameter( + val name: String, + val value: String +) diff --git a/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/models/Request.kt b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/models/Request.kt new file mode 100644 index 000000000000..037631e4578b --- /dev/null +++ b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/models/Request.kt @@ -0,0 +1,14 @@ +package com.woocommerce.android.apifaker.models + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity +internal data class Request( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val type: ApiType, + val path: String, + val httpMethod: HttpMethod? = null, + val queryParameters: List = emptyList(), + val body: String? = null +) diff --git a/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/models/Response.kt b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/models/Response.kt new file mode 100644 index 000000000000..e1368bed4f57 --- /dev/null +++ b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/models/Response.kt @@ -0,0 +1,21 @@ +package com.woocommerce.android.apifaker.models + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey + +@Entity( + foreignKeys = [ + ForeignKey( + entity = Request::class, + parentColumns = ["id"], + childColumns = ["endpointId"], + onDelete = ForeignKey.CASCADE + ) + ] +) +internal data class Response( + @PrimaryKey val endpointId: Long = 0, + val statusCode: Int, + val body: String? = null +) diff --git a/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/ui/ApiFakerNavHost.kt b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/ui/ApiFakerNavHost.kt new file mode 100644 index 000000000000..9ef7354ae7de --- /dev/null +++ b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/ui/ApiFakerNavHost.kt @@ -0,0 +1,39 @@ +package com.woocommerce.android.apifaker.ui + +import androidx.compose.runtime.Composable +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import com.woocommerce.android.apifaker.ui.details.EndpointDetailsScreen +import com.woocommerce.android.apifaker.ui.details.MISSING_ENDPOINT_ID +import com.woocommerce.android.apifaker.ui.home.HomeScreen + +@Composable +fun ApiFakerNavHost( + onExit: () -> Unit +) { + val navController = rememberNavController() + + NavHost( + navController = navController, + startDestination = Screen.Home.route() + ) { + composable(Screen.Home.route()) { + HomeScreen(viewModel = hiltViewModel(), navController = navController, onExit = onExit) + } + composable( + Screen.EndpointDetails.routeTemplate, + arguments = listOf( + navArgument(Screen.EndpointDetails.endpointIdArgumentName) { + type = NavType.LongType + defaultValue = MISSING_ENDPOINT_ID + } + ) + ) { + EndpointDetailsScreen(hiltViewModel(), navController) + } + } +} diff --git a/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/ui/DropDownMenu.kt b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/ui/DropDownMenu.kt new file mode 100644 index 000000000000..b1d29cd70860 --- /dev/null +++ b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/ui/DropDownMenu.kt @@ -0,0 +1,61 @@ +package com.woocommerce.android.apifaker.ui + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.ExposedDropdownMenuBox +import androidx.compose.material.ExposedDropdownMenuDefaults +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier + +@OptIn(ExperimentalMaterialApi::class) +@Composable +internal fun DropDownMenu( + label: String, + currentValue: T, + values: List, + onValueChange: (T) -> Unit, + modifier: Modifier = Modifier, + formatter: (T) -> String = { it.toString() }, +) { + var expanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + modifier = modifier + ) { + OutlinedTextField( + readOnly = true, + label = { Text(label) }, + value = formatter(currentValue), + onValueChange = {}, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon( + expanded = expanded + ) + }, + colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), + modifier = Modifier.fillMaxWidth() + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + values.forEach { value -> + DropdownMenuItem(onClick = { + expanded = false + onValueChange(value) + }) { + Text(formatter(value)) + } + } + } + } +} diff --git a/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/ui/Screen.kt b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/ui/Screen.kt new file mode 100644 index 000000000000..306eeda07c3a --- /dev/null +++ b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/ui/Screen.kt @@ -0,0 +1,16 @@ +package com.woocommerce.android.apifaker.ui + +internal sealed class Screen(val baseRoute: String) { + object Home : Screen("home") { + fun route() = baseRoute + } + + object EndpointDetails : Screen("/endpoint-details") { + const val endpointIdArgumentName = "endpointId" + val routeTemplate = "$baseRoute?$endpointIdArgumentName={$endpointIdArgumentName}" + + fun route(endpointId: Long) = "$baseRoute?$endpointIdArgumentName=$endpointId" + + fun routeForCreation() = baseRoute + } +} diff --git a/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/ui/details/EndpointDetailsScreen.kt b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/ui/details/EndpointDetailsScreen.kt new file mode 100644 index 000000000000..3db6534389a6 --- /dev/null +++ b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/ui/details/EndpointDetailsScreen.kt @@ -0,0 +1,637 @@ +package com.woocommerce.android.apifaker.ui.details + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Chip +import androidx.compose.material.ChipDefaults +import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.LocalTextStyle +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.TextFieldDefaults +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.navigation.NavController +import androidx.navigation.compose.rememberNavController +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.Request +import com.woocommerce.android.apifaker.models.Response +import com.woocommerce.android.apifaker.ui.DropDownMenu +import kotlin.math.min + +@Composable +internal fun EndpointDetailsScreen( + viewModel: EndpointDetailsViewModel, + navController: NavController +) { + if (viewModel.state.isEndpointSaved) { + navController.navigateUp() + } + + EndpointDetailsScreen( + state = viewModel.state, + navController = navController, + onSaveClicked = viewModel::onSaveClicked, + onApiTypeChanged = viewModel::onApiTypeChanged, + onRequestHttpMethodChanged = viewModel::onRequestHttpMethodChanged, + onRequestPathChanged = viewModel::onRequestPathChanged, + onQueryParameterAdded = viewModel::onQueryParameterAdded, + onQueryParameterDeleted = viewModel::onQueryParameterDeleted, + onRequestBodyChanged = viewModel::onRequestBodyChanged, + onResponseStatusCodeChanged = viewModel::onResponseStatusCodeChanged, + onResponseBodyChanged = viewModel::onResponseBodyChanged, + ) +} + +@Composable +private fun EndpointDetailsScreen( + state: EndpointDetailsViewModel.UiState, + navController: NavController, + onSaveClicked: () -> Unit = {}, + onApiTypeChanged: (ApiType) -> Unit = {}, + onRequestHttpMethodChanged: (HttpMethod?) -> Unit = {}, + onRequestPathChanged: (String) -> Unit = {}, + onQueryParameterAdded: (String, String) -> Unit = { _, _ -> }, + onQueryParameterDeleted: (QueryParameter) -> Unit = {}, + onRequestBodyChanged: (String) -> Unit = {}, + onResponseStatusCodeChanged: (Int) -> Unit = {}, + onResponseBodyChanged: (String) -> Unit = {}, +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(text = "Endpoint Definition") }, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { + Icon( + Icons.AutoMirrored.Default.ArrowBack, + contentDescription = "Back" + ) + } + }, + actions = { + TextButton( + onClick = onSaveClicked, + enabled = state.isEndpointValid, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colors.onSurface) + ) { + Text( + text = "Save" + ) + } + }, + backgroundColor = MaterialTheme.colors.surface, + elevation = 4.dp + ) + }, + backgroundColor = MaterialTheme.colors.surface + ) { paddingValues -> + Column( + Modifier + .padding(paddingValues) + .verticalScroll(rememberScrollState()) + ) { + RequestDefinitionSection( + request = state.request, + onApiTypeChanged = onApiTypeChanged, + onHttpMethodChanged = onRequestHttpMethodChanged, + onPathChanged = onRequestPathChanged, + onQueryParameterAdded = onQueryParameterAdded, + onQueryParameterDeleted = onQueryParameterDeleted, + onBodyChanged = onRequestBodyChanged, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) + Divider( + Modifier.padding(horizontal = 8.dp), + thickness = 2.dp + ) + + ResponseSection( + response = state.response, + onStatusCodeChanged = onResponseStatusCodeChanged, + onBodyChanged = onResponseBodyChanged, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) + } + } +} + +@Composable +private fun RequestDefinitionSection( + request: Request, + onApiTypeChanged: (ApiType) -> Unit, + onHttpMethodChanged: (HttpMethod?) -> Unit, + onPathChanged: (String) -> Unit, + onQueryParameterAdded: (String, String) -> Unit, + onQueryParameterDeleted: (QueryParameter) -> Unit, + onBodyChanged: (String) -> Unit, + modifier: Modifier = Modifier +) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = modifier + ) { + Text( + text = "Endpoint Conditions", + style = MaterialTheme.typography.h6 + ) + EndpointTypeField( + apiType = request.type, + onApiTypeChanged = onApiTypeChanged, + modifier = Modifier.fillMaxWidth() + ) + + HttpMethodField( + method = request.httpMethod, + onHttpMethodChanged = onHttpMethodChanged, + modifier = Modifier.fillMaxWidth() + ) + + PathField( + path = request.path, + apiType = request.type, + onPathChanged = onPathChanged, + modifier = Modifier.fillMaxWidth() + ) + + QueryParametersField( + queryParameters = request.queryParameters, + onQueryParameterAdded = onQueryParameterAdded, + onQueryParameterDeleted = onQueryParameterDeleted, + modifier = Modifier.fillMaxWidth() + ) + + RequestBodyField( + body = request.body, + onBodyChanged = onBodyChanged, + modifier = Modifier.fillMaxWidth() + ) + } +} + +@Composable +private fun ResponseSection( + response: Response, + onStatusCodeChanged: (Int) -> Unit, + onBodyChanged: (String) -> Unit, + modifier: Modifier +) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = modifier + ) { + Text( + text = "Response", + style = MaterialTheme.typography.h6 + ) + StatusCodeField( + statusCode = response.statusCode, + onStatusCodeChanged = onStatusCodeChanged, + modifier = Modifier.fillMaxWidth() + ) + ResponseBodyField( + body = response.body, + onBodyChanged = onBodyChanged, + modifier = Modifier.fillMaxWidth() + ) + } +} + +@Composable +private fun EndpointTypeField( + apiType: ApiType, + onApiTypeChanged: (ApiType) -> Unit, + modifier: Modifier = Modifier +) { + fun ApiType.label() = when (this) { + ApiType.WPApi -> "WordPress REST API" + ApiType.WPCom -> "WordPress.com REST API" + is ApiType.Custom -> "Custom" + } + + DropDownMenu( + label = "Type", + currentValue = apiType, + values = ApiType.defaultValues(), + onValueChange = onApiTypeChanged, + formatter = ApiType::label, + modifier = modifier.fillMaxWidth() + ) + if (apiType is ApiType.Custom) { + OutlinedTextField( + label = { Text(text = "Host (without scheme)") }, + value = apiType.host, + onValueChange = { onApiTypeChanged(apiType.copy(host = it)) }, + modifier = Modifier.fillMaxWidth() + ) + } +} + +@Composable +private fun HttpMethodField( + method: HttpMethod?, + onHttpMethodChanged: (HttpMethod?) -> Unit, + modifier: Modifier = Modifier +) { + DropDownMenu( + label = "HTTP Method", + currentValue = method, + values = listOf(null) + HttpMethod.values(), + onValueChange = onHttpMethodChanged, + formatter = { it?.name ?: "Any" }, + modifier = modifier.fillMaxWidth() + ) +} + +@Composable +private fun PathField( + path: String, + apiType: ApiType, + onPathChanged: (String) -> Unit, + modifier: Modifier = Modifier +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + ) { + OutlinedTextField( + label = { Text(text = "Path") }, + value = path, + onValueChange = onPathChanged, + modifier = Modifier.fillMaxWidth() + ) + val prefix = when (apiType) { + ApiType.WPApi -> "/wp-json" + ApiType.WPCom -> "/rest" + is ApiType.Custom -> "host" + } + val caption = buildAnnotatedString { + append("Enter the path after the") + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append(" $prefix ") + } + append("part, without the query arguments") + append("\n") + append("Use") + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append(" % ") + } + append("as a wildcard for one or more characters") + } + Text( + text = caption, + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) + ) + } +} + +@Composable +private fun QueryParametersField( + queryParameters: List, + onQueryParameterAdded: (String, String) -> Unit, + onQueryParameterDeleted: (QueryParameter) -> Unit, + modifier: Modifier = Modifier +) { + @Composable + fun AddDialog(onAdd: (String, String) -> Unit, onDismiss: () -> Unit) { + var name by remember { mutableStateOf("") } + var value by remember { mutableStateOf("") } + Dialog(onDismissRequest = onDismiss) { + Column( + Modifier + .background(MaterialTheme.colors.surface, MaterialTheme.shapes.medium) + .padding(16.dp) + ) { + Text(text = "Add Query Parameter (unencoded)") + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + label = { Text(text = "Name") }, + value = name, + onValueChange = { name = it }, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + label = { Text(text = "Value") }, + value = value, + onValueChange = { value = it }, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + Row( + horizontalArrangement = Arrangement.End, + modifier = Modifier.fillMaxWidth() + ) { + Button(onClick = onDismiss) { + Text(text = "Cancel") + } + Spacer(modifier = Modifier.width(8.dp)) + Button(onClick = { onAdd(name, value) }) { + Text(text = "Add") + } + } + } + } + } + + var isAddDialogShown by remember { mutableStateOf(false) } + + Column(modifier) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = "Query Parameters", + style = MaterialTheme.typography.subtitle1 + ) + IconButton( + onClick = { isAddDialogShown = true }, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "Add query parameter" + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "A request is matched only if it contains all the parameters listed here", + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) + ) + if (queryParameters.isNotEmpty()) { + queryParameters.forEach { queryParameter -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + ) { + Text( + text = "${queryParameter.name} = ${queryParameter.value}", + style = MaterialTheme.typography.caption + ) + IconButton( + onClick = { onQueryParameterDeleted(queryParameter) }, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Delete query parameter" + ) + } + } + Divider() + } + } + } + + if (isAddDialogShown) { + AddDialog( + onAdd = { name, value -> + onQueryParameterAdded(name, value) + isAddDialogShown = false + }, + onDismiss = { isAddDialogShown = false } + ) + } +} + +@Composable +private fun RequestBodyField( + body: String?, + onBodyChanged: (String) -> Unit, + modifier: Modifier = Modifier +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + ) { + OutlinedTextField( + label = { Text(text = "Body") }, + value = body.orEmpty(), + placeholder = { Text(text = "An empty value will match everything") }, + textStyle = if (body != null) { + LocalTextStyle.current + } else { + LocalTextStyle.current.copy(color = Color.Gray) + }, + onValueChange = onBodyChanged, + modifier = Modifier + .fillMaxWidth() + ) + val caption = buildAnnotatedString { + append("Use") + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append(" % ") + } + append("as a wildcard for one or more characters") + } + Text( + text = caption, + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) + ) + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun StatusCodeField( + statusCode: Int, + onStatusCodeChanged: (Int) -> Unit, + modifier: Modifier +) { + @Composable + fun StatusCodeChip( + text: String, + selected: Boolean, + onClick: () -> Unit, + ) { + Chip( + onClick = onClick, + border = ChipDefaults.outlinedBorder, + colors = ChipDefaults.outlinedChipColors( + backgroundColor = if (selected) MaterialTheme.colors.primary else MaterialTheme.colors.surface, + contentColor = if (selected) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onSurface + ) + ) { + Text(text = text) + } + } + + @Composable + fun CustomStatusCodeDialog( + statusCode: Int, + onDismiss: () -> Unit, + onStatusCodeChanged: (Int) -> Unit + ) { + Dialog(onDismissRequest = onDismiss) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .background(MaterialTheme.colors.surface, shape = MaterialTheme.shapes.medium) + .padding(16.dp) + ) { + var fieldValue by remember { + mutableStateOf(statusCode.toString()) + } + OutlinedTextField( + label = { Text(text = "Custom status code") }, + value = fieldValue, + onValueChange = { + fieldValue = it.substring(0, min(it.length, 3)) + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth() + ) + + TextButton( + onClick = { + onStatusCodeChanged(fieldValue.toInt()) + }, + enabled = fieldValue.length == 3 && fieldValue.toIntOrNull() != null, + modifier = Modifier.align(Alignment.End) + ) { + Text(text = "OK") + } + } + } + } + + val defaultStatusCodes = remember { + arrayOf(200, 403, 404, 500) + } + val isUsingACustomValue = remember(statusCode) { + !defaultStatusCodes.contains(statusCode) + } + + var showCustomStatusCodeDialog by remember { + mutableStateOf(false) + } + + Column(modifier) { + Text( + text = "Status Code", + style = MaterialTheme.typography.subtitle1 + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + defaultStatusCodes.forEach { code -> + StatusCodeChip( + text = code.toString(), + selected = statusCode == code, + onClick = { onStatusCodeChanged(code) } + ) + } + + StatusCodeChip( + text = "Custom${if (isUsingACustomValue) " ($statusCode) " else ""}", + selected = isUsingACustomValue, + onClick = { showCustomStatusCodeDialog = true } + ) + } + if (showCustomStatusCodeDialog) { + CustomStatusCodeDialog( + statusCode = statusCode, + onDismiss = { showCustomStatusCodeDialog = false }, + onStatusCodeChanged = { + showCustomStatusCodeDialog = false + onStatusCodeChanged(it) + } + ) + } + } +} + +@Composable +private fun ResponseBodyField( + body: String?, + onBodyChanged: (String) -> Unit, + modifier: Modifier +) { + OutlinedTextField( + label = { Text(text = "Body") }, + value = body.orEmpty(), + onValueChange = onBodyChanged, + modifier = modifier + .defaultMinSize(minHeight = TextFieldDefaults.MinHeight * 2) + ) +} + +@Composable +@Preview +private fun EndpointDetailsScreenPreview() { + Surface(color = MaterialTheme.colors.background) { + EndpointDetailsScreen( + state = EndpointDetailsViewModel.UiState( + request = Request( + type = ApiType.Custom("https://example.com"), + httpMethod = HttpMethod.GET, + queryParameters = listOf( + QueryParameter("name", "value"), + QueryParameter("name2", "value2") + ), + path = "/wc/v3/products", + body = null + ), + response = Response( + statusCode = 300, + body = "" + ) + ), + navController = rememberNavController() + ) + } +} diff --git a/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/ui/details/EndpointDetailsViewModel.kt b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/ui/details/EndpointDetailsViewModel.kt new file mode 100644 index 000000000000..ddb2b041e5d1 --- /dev/null +++ b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/ui/details/EndpointDetailsViewModel.kt @@ -0,0 +1,135 @@ +package com.woocommerce.android.apifaker.ui.details + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot.Companion.withMutableSnapshot +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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.Request +import com.woocommerce.android.apifaker.models.Response +import com.woocommerce.android.apifaker.ui.Screen +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +const val MISSING_ENDPOINT_ID = 0L + +@HiltViewModel +internal class EndpointDetailsViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val endpointDao: EndpointDao +) : ViewModel() { + private val id = checkNotNull(savedStateHandle.get(Screen.EndpointDetails.endpointIdArgumentName)) + + var state: UiState by mutableStateOf(defaultEndpoint()) + private set + + init { + if (id != MISSING_ENDPOINT_ID && state.request.id == MISSING_ENDPOINT_ID) { + loadEndpoint() + } + } + + fun onApiTypeChanged(apiType: ApiType) { + withMutableSnapshot { + state = state.copy(request = state.request.copy(type = apiType)) + } + } + + fun onRequestHttpMethodChanged(httpMethod: HttpMethod?) { + withMutableSnapshot { + state = state.copy(request = state.request.copy(httpMethod = httpMethod)) + } + } + + fun onRequestPathChanged(path: String) { + withMutableSnapshot { + state = state.copy(request = state.request.copy(path = path)) + } + } + + fun onQueryParameterAdded(name: String, value: String) { + val queryParameter = QueryParameter(name, value) + withMutableSnapshot { + state = state.copy( + request = state.request.copy( + queryParameters = state.request.queryParameters + queryParameter + ) + ) + } + } + + fun onQueryParameterDeleted(queryParameter: QueryParameter) { + withMutableSnapshot { + state = state.copy( + request = state.request.copy( + queryParameters = state.request.queryParameters - queryParameter + ) + ) + } + } + + fun onRequestBodyChanged(body: String) { + withMutableSnapshot { + state = state.copy(request = state.request.copy(body = body.ifEmpty { null })) + } + } + + fun onResponseStatusCodeChanged(statusCode: Int) { + withMutableSnapshot { + state = state.copy(response = state.response.copy(statusCode = statusCode)) + } + } + + fun onResponseBodyChanged(body: String) { + withMutableSnapshot { + state = state.copy(response = state.response.copy(body = body)) + } + } + + fun onSaveClicked() { + viewModelScope.launch { + endpointDao.insertEndpoint(state.request, state.response) + state = state.copy(isEndpointSaved = true) + } + } + + private fun loadEndpoint() = viewModelScope.launch { + state = endpointDao.getEndpoint(id)!!.let { + UiState( + it.request, + it.response + ) + } + } + + data class UiState( + val request: Request, + val response: Response, + val isEndpointSaved: Boolean = false + ) { + val isEndpointValid: Boolean + get() = request.path.isNotBlank() + } + + private fun defaultEndpoint() = UiState( + Request( + id = MISSING_ENDPOINT_ID, + type = ApiType.WPApi, + httpMethod = null, + path = "", + body = null + ), + Response( + endpointId = MISSING_ENDPOINT_ID, + statusCode = 200, + body = "" + ) + ) +} diff --git a/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/ui/home/HomeScreen.kt b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/ui/home/HomeScreen.kt new file mode 100644 index 000000000000..e9830f023c27 --- /dev/null +++ b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/ui/home/HomeScreen.kt @@ -0,0 +1,233 @@ +package com.woocommerce.android.apifaker.ui.home + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.Card +import androidx.compose.material.DismissDirection +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.FloatingActionButton +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.SwipeToDismiss +import androidx.compose.material.Switch +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.rememberDismissState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import androidx.navigation.compose.rememberNavController +import com.woocommerce.android.apifaker.models.ApiType +import com.woocommerce.android.apifaker.models.HttpMethod +import com.woocommerce.android.apifaker.models.MockedEndpoint +import com.woocommerce.android.apifaker.models.Request +import com.woocommerce.android.apifaker.models.Response +import com.woocommerce.android.apifaker.ui.Screen + +@Composable +internal fun HomeScreen( + viewModel: HomeViewModel, + navController: NavController, + onExit: () -> Unit +) { + HomeScreen( + endpoints = viewModel.endpoints.collectAsStateWithLifecycle().value, + isEnabled = viewModel.isEnabled.collectAsState(initial = false).value, + navController = navController, + onRemoveRequest = viewModel::onRemoveRequest, + onMockingToggleChanged = viewModel::onMockingToggleChanged, + onExit = onExit + ) +} + +@Composable +private fun HomeScreen( + endpoints: List, + isEnabled: Boolean, + navController: NavController, + onRemoveRequest: (Request) -> Unit, + onMockingToggleChanged: (Boolean) -> Unit, + onExit: () -> Unit +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(text = "API Faker") }, + navigationIcon = { + IconButton(onClick = onExit) { + Icon( + Icons.AutoMirrored.Default.ArrowBack, + contentDescription = "Back" + ) + } + }, + actions = { + Switch(checked = isEnabled, onCheckedChange = onMockingToggleChanged) + }, + backgroundColor = MaterialTheme.colors.surface, + elevation = 4.dp + ) + } + ) { paddingValues -> + Box( + modifier = Modifier + .padding(paddingValues) + .padding(16.dp) + .fillMaxSize() + ) { + if (endpoints.isNotEmpty()) { + LazyColumn { + items(endpoints, { endpoint -> endpoint.request.id }) { endpoint -> + EndpointItem( + endpoint, + onRemoveRequest = onRemoveRequest, + navController, + Modifier.padding(vertical = 8.dp) + ) + } + } + } else { + Text(text = "Start by adding some endpoints") + } + + FloatingActionButton( + onClick = { navController.navigate(Screen.EndpointDetails.routeForCreation()) }, + backgroundColor = MaterialTheme.colors.primary, + contentColor = MaterialTheme.colors.onPrimary, + modifier = Modifier.align(Alignment.BottomEnd) + ) { + Icon(imageVector = Icons.Filled.Add, contentDescription = "Add endpoint") + } + } + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun EndpointItem( + endpoint: MockedEndpoint, + onRemoveRequest: (Request) -> Unit, + navController: NavController, + modifier: Modifier = Modifier +) { + val dismissState = rememberDismissState() + + if (dismissState.isDismissed(DismissDirection.EndToStart)) { + onRemoveRequest(endpoint.request) + } + SwipeToDismiss( + state = dismissState, + directions = setOf(DismissDirection.EndToStart), + dismissThresholds = { + @Suppress("DEPRECATION") + androidx.compose.material.FractionalThreshold(0.3f) + }, + modifier = modifier, + background = { + Box( + Modifier + .fillMaxSize() + .background(Color.Red, MaterialTheme.shapes.medium) + .padding(horizontal = 20.dp), + contentAlignment = Alignment.CenterEnd + ) { + Icon( + Icons.Default.Delete, + contentDescription = "Delete" + ) + } + }, + dismissContent = { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = { navController.navigate(Screen.EndpointDetails.route(endpoint.request.id)) }), + elevation = 4.dp + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(8.dp) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.weight(1f) + ) { + Text( + text = when (endpoint.request.type) { + ApiType.WPApi -> "WordPress API" + ApiType.WPCom -> "WordPress.com API" + is ApiType.Custom -> "Host: ${endpoint.request.type.host}" + }, + style = MaterialTheme.typography.subtitle1, + fontWeight = FontWeight.SemiBold + ) + val pathLine = endpoint.request.httpMethod?.let { "$it " }.orEmpty() + endpoint.request.path + Text( + text = pathLine, + style = MaterialTheme.typography.body1 + ) + } + Text( + text = endpoint.response.statusCode.toString(), + style = MaterialTheme.typography.subtitle1 + ) + } + } + } + ) +} + +@Composable +@Preview +private fun HomeScreenPreview() { + HomeScreen( + endpoints = listOf( + MockedEndpoint( + Request( + type = ApiType.WPApi, + httpMethod = HttpMethod.GET, + path = "/wc/v3/products", + body = "" + ), + Response(statusCode = 200, body = "") + ), + MockedEndpoint( + Request( + type = ApiType.WPCom, + httpMethod = HttpMethod.GET, + path = "/v1.1/me/sites", + body = "" + ), + Response(statusCode = 404, body = "") + ) + ), + isEnabled = true, + navController = rememberNavController(), + onRemoveRequest = {}, + onMockingToggleChanged = {}, + onExit = {} + ) +} diff --git a/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/ui/home/HomeViewModel.kt b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/ui/home/HomeViewModel.kt new file mode 100644 index 000000000000..dc8fdbcce419 --- /dev/null +++ b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/ui/home/HomeViewModel.kt @@ -0,0 +1,36 @@ +package com.woocommerce.android.apifaker.ui.home + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.woocommerce.android.apifaker.ApiFakerConfig +import com.woocommerce.android.apifaker.db.EndpointDao +import com.woocommerce.android.apifaker.models.Request +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +internal class HomeViewModel @Inject constructor( + private val endpointDao: EndpointDao, + private val config: ApiFakerConfig +) : ViewModel() { + @Suppress("MagicNumber") + val endpoints = endpointDao.observeEndpoints() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + val isEnabled = config.enabled + + fun onMockingToggleChanged(enabled: Boolean) { + viewModelScope.launch { + config.setStatus(enabled) + } + } + + fun onRemoveRequest(request: Request) { + viewModelScope.launch { + endpointDao.deleteRequest(request) + } + } +} diff --git a/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/util/JSONObjectProvider.kt b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/util/JSONObjectProvider.kt new file mode 100644 index 000000000000..dd6411c4fe27 --- /dev/null +++ b/libs/apifaker/src/main/java/com/woocommerce/android/apifaker/util/JSONObjectProvider.kt @@ -0,0 +1,8 @@ +package com.woocommerce.android.apifaker.util + +import org.json.JSONObject +import javax.inject.Inject + +internal class JSONObjectProvider @Inject constructor() { + fun parseString(content: String): JSONObject = JSONObject(content) +} diff --git a/libs/apifaker/src/test/java/com/woocommerce/android/apifaker/EndpointProcessorTest.kt b/libs/apifaker/src/test/java/com/woocommerce/android/apifaker/EndpointProcessorTest.kt new file mode 100644 index 000000000000..35d7c6e335d1 --- /dev/null +++ b/libs/apifaker/src/test/java/com/woocommerce/android/apifaker/EndpointProcessorTest.kt @@ -0,0 +1,298 @@ +package com.woocommerce.android.apifaker + +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.MockedEndpoint +import com.woocommerce.android.apifaker.models.QueryParameter +import com.woocommerce.android.apifaker.models.Request +import com.woocommerce.android.apifaker.models.Response +import com.woocommerce.android.apifaker.util.JSONObjectProvider +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import okhttp3.Request as OkHttpRequest + +class EndpointProcessorTest { + private val endpointDaoMock = mock() + private val jsonObjectProvider = mock() + private val endpointProcessor = EndpointProcessor( + endpointDao = endpointDaoMock, + jsonObjectProvider = jsonObjectProvider + ) + + @Test + fun `when processing a GET WPCom endpoint, then extract data correctly`() { + val request = OkHttpRequest.Builder() + .method("GET", null) + .url("https://public-api.wordpress.com/rest/v1.1/me?param=value") + .build() + + endpointProcessor.fakeRequestIfNeeded(request) + + verify(endpointDaoMock).queryEndpoint( + type = ApiType.WPCom, + httpMethod = HttpMethod.GET, + path = "/v1.1/me", + body = "" + ) + } + + @Test + fun `when processing a POST WPCom endpoint, then extract data correctly`() { + val body = "Test Body" + val request = OkHttpRequest.Builder() + .method("POST", body.toRequestBody()) + .url("https://public-api.wordpress.com/rest/v1.1/me?param=value") + .build() + + endpointProcessor.fakeRequestIfNeeded(request) + + verify(endpointDaoMock).queryEndpoint( + type = ApiType.WPCom, + httpMethod = HttpMethod.POST, + path = "/v1.1/me", + body = body + ) + } + + @Test + fun `when processing a GET Jetpack Tunnel endpoint, then extract data correctly`() { + val request = OkHttpRequest.Builder() + .method("GET", null) + .url( + "https://public-api.wordpress.com/rest/v1.1/jetpack-blogs/161477129/rest-api/" + + "?path=/wc/v3/products&_method=get" + ) + .build() + + endpointProcessor.fakeRequestIfNeeded(request) + + verify(endpointDaoMock).queryEndpoint( + type = ApiType.WPApi, + httpMethod = HttpMethod.GET, + path = "/wc/v3/products", + body = "" + ) + } + + @Test + fun `when processing a POST Jetpack Tunnel endpoint, then extract data correctly`() { + val body = """ + "path": "/wc/v3/products&_method=put", + "body": "test body" + """.trimIndent() + + val jsonObject = mock { + on { getString("path") } doReturn "/wc/v3/products&_method=put" + on { optString("body") } doReturn "test body" + } + whenever(jsonObjectProvider.parseString(body)).thenReturn(jsonObject) + + val request = OkHttpRequest.Builder() + .method("POST", body.toRequestBody()) + .url("https://public-api.wordpress.com/rest/v1.1/jetpack-blogs/161477129/rest-api") + .build() + + endpointProcessor.fakeRequestIfNeeded(request) + + verify(endpointDaoMock).queryEndpoint( + type = ApiType.WPApi, + httpMethod = HttpMethod.PUT, + path = "/wc/v3/products", + body = "test body" + ) + } + + @Test + fun `when processing a GET WPApi endpoint, then extract data correctly`() { + val request = OkHttpRequest.Builder() + .method("GET", null) + .url("https://test-site.com/wp-json/wc/v3/products?param=value") + .build() + + endpointProcessor.fakeRequestIfNeeded(request) + + verify(endpointDaoMock).queryEndpoint( + type = ApiType.WPApi, + httpMethod = HttpMethod.GET, + path = "/wc/v3/products", + body = "" + ) + } + + @Test + fun `when processing a POST WPApi endpoint, then extract data correctly`() { + val body = "Test Body" + val request = OkHttpRequest.Builder() + .method("POST", body.toRequestBody()) + .url("https://test-site.com/wp-json/wc/v3/products") + .build() + + endpointProcessor.fakeRequestIfNeeded(request) + + verify(endpointDaoMock).queryEndpoint( + type = ApiType.WPApi, + httpMethod = HttpMethod.POST, + path = "/wc/v3/products", + body = body + ) + } + + @Test + fun `when processing a GET Custom endpoint, then extract data correctly`() { + val request = OkHttpRequest.Builder() + .method("GET", null) + .url("https://test-site.com/an/endpoint?param=value") + .build() + + endpointProcessor.fakeRequestIfNeeded(request) + + verify(endpointDaoMock).queryEndpoint( + type = ApiType.Custom("test-site.com"), + httpMethod = HttpMethod.GET, + path = "/an/endpoint", + body = "" + ) + } + + @Test + fun `when processing a POST Custom endpoint, then extract data correctly`() { + val body = "Test Body" + val request = OkHttpRequest.Builder() + .method("POST", body.toRequestBody()) + .url("https://test-site.com/an/endpoint") + .build() + + endpointProcessor.fakeRequestIfNeeded(request) + + verify(endpointDaoMock).queryEndpoint( + type = ApiType.Custom("test-site.com"), + httpMethod = HttpMethod.POST, + path = "/an/endpoint", + body = body + ) + } + + @Test + fun `when processing a GET jetpack tunnel endpoint, then wrap body if necessary`() { + val mockEndpoint = MockedEndpoint( + request = Request( + id = 0, + type = ApiType.WPApi, + httpMethod = HttpMethod.GET, + path = "/wc/v3/products", + body = null + ), + response = Response( + endpointId = 0, + statusCode = 200, + body = "{\"key\":\"value\"}" + ) + ) + whenever( + endpointDaoMock.queryEndpoint( + type = mockEndpoint.request.type, + httpMethod = mockEndpoint.request.httpMethod!!, + path = mockEndpoint.request.path, + body = mockEndpoint.request.body.orEmpty() + ) + ).thenReturn(listOf(mockEndpoint)) + + val request = OkHttpRequest.Builder() + .method(mockEndpoint.request.httpMethod.name, null) + .url( + "https://public-api.wordpress.com/rest/v1.1/jetpack-blogs/161477129/rest-api/" + + "?path=${mockEndpoint.request.path}&_method=${mockEndpoint.request.httpMethod.name}" + ) + .build() + + val response = endpointProcessor.fakeRequestIfNeeded(request) + assert(response?.body == "{\"data\": {\"key\":\"value\"}}") + } + + @Test + fun `when processing a GET jetpack tunnel endpoint with query parameters, then check query parameters`() { + val mockEndpoint = MockedEndpoint( + request = Request( + id = 0, + type = ApiType.WPApi, + httpMethod = HttpMethod.GET, + path = "/wc/v3/products", + queryParameters = listOf( + QueryParameter("param", "value") + ), + body = null + ), + response = Response( + endpointId = 0, + statusCode = 200 + ) + ) + whenever( + endpointDaoMock.queryEndpoint( + type = mockEndpoint.request.type, + httpMethod = mockEndpoint.request.httpMethod!!, + path = mockEndpoint.request.path, + body = mockEndpoint.request.body.orEmpty() + ) + ).thenReturn(listOf(mockEndpoint)) + val jsonObject = mock { + on { keys() } doReturn listOf("param").iterator() + on { getString("param") } doReturn "value" + } + whenever(jsonObjectProvider.parseString("{\"param\":\"value\"}")).thenReturn(jsonObject) + + val request = OkHttpRequest.Builder() + .method(mockEndpoint.request.httpMethod.name, null) + .url( + "https://public-api.wordpress.com/rest/v1.1/jetpack-blogs/161477129/rest-api/" + + "?path=${mockEndpoint.request.path}&_method=${mockEndpoint.request.httpMethod.name}" + + "&query={\"param\":\"value\"}" + ) + .build() + + val response = endpointProcessor.fakeRequestIfNeeded(request) + assert(response?.statusCode == 200) + } + + @Test + fun `when processing a regular endpoint with query parameters, then check query parameters`() { + val mockEndpoint = MockedEndpoint( + request = Request( + id = 0, + type = ApiType.WPApi, + httpMethod = HttpMethod.GET, + path = "/wc/v3/products", + queryParameters = listOf( + QueryParameter("param", "value") + ), + body = null + ), + response = Response( + endpointId = 0, + statusCode = 200 + ) + ) + whenever( + endpointDaoMock.queryEndpoint( + type = mockEndpoint.request.type, + httpMethod = mockEndpoint.request.httpMethod!!, + path = mockEndpoint.request.path, + body = mockEndpoint.request.body.orEmpty() + ) + ).thenReturn(listOf(mockEndpoint)) + + val request = OkHttpRequest.Builder() + .method(mockEndpoint.request.httpMethod.name, null) + .url("https://test-site.com/wp-json/wc/v3/products?param=value") + .build() + + val response = endpointProcessor.fakeRequestIfNeeded(request) + assert(response?.statusCode == 200) + } +} diff --git a/settings.gradle b/settings.gradle index 794b7eb05e33..6506be3dedd8 100644 --- a/settings.gradle +++ b/settings.gradle @@ -26,6 +26,7 @@ include ':quicklogin' include ':libs:commons' include ':libs:cardreader' include ':libs:iap' +include ':libs:apifaker' include ':WooCommerce' gradle.ext.fluxCBinaryPath = "org.wordpress:fluxc"