diff --git a/core-network/src/commonMain/kotlin/com/reqlab/core/network/KtorApiClient.kt b/core-network/src/commonMain/kotlin/com/reqlab/core/network/KtorApiClient.kt index f49a634..cc4ca61 100644 --- a/core-network/src/commonMain/kotlin/com/reqlab/core/network/KtorApiClient.kt +++ b/core-network/src/commonMain/kotlin/com/reqlab/core/network/KtorApiClient.kt @@ -25,19 +25,17 @@ import io.ktor.http.HttpHeaders import io.ktor.http.HttpMethod import io.ktor.http.Parameters import io.ktor.http.contentType -import io.ktor.http.encodeURLPath import io.ktor.http.formUrlEncode import io.ktor.serialization.kotlinx.json.json +import io.ktor.websocket.Frame +import io.ktor.websocket.readText import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.withTimeout import kotlinx.datetime.Clock import kotlinx.serialization.builtins.serializer -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -import io.ktor.websocket.Frame -import io.ktor.websocket.readText class KtorApiClient( private val httpClient: HttpClient = defaultHttpClient(), @@ -58,6 +56,7 @@ class KtorApiClient( var attempt = 0 var lastThrowable: Throwable? = null + var isRetryExhausted = false while (attempt < retryPolicy.maxAttempts) { attempt++ @@ -93,7 +92,13 @@ class KtorApiClient( } val delayMs = retryPolicy.delayForAttempt(attempt) - emit(NetworkEvent.RetryScheduled(attempt, delayMs, "status=${mappedResponse.statusCode}")) + emit( + NetworkEvent.RetryScheduled( + attempt, + delayMs, + "status=${mappedResponse.statusCode}" + ) + ) delay(delayMs) } catch (throwable: Throwable) { lastThrowable = throwable @@ -101,11 +106,20 @@ class KtorApiClient( interceptors.forEach { interceptor -> interceptor.onFailure(throwable, attempt) } if (attempt == retryPolicy.maxAttempts) { + isRetryExhausted = true + break + } else if (retryPolicy.avoidRetryOnThrowable.any { it.isInstance(throwable) }) { break } val delayMs = retryPolicy.delayForAttempt(attempt) - emit(NetworkEvent.RetryScheduled(attempt, delayMs, throwable.message ?: "unknown error")) + emit( + NetworkEvent.RetryScheduled( + attempt, + delayMs, + throwable.message ?: "unknown error" + ) + ) delay(delayMs) } } @@ -116,7 +130,7 @@ class KtorApiClient( requestId = request.id, message = lastThrowable?.message ?: "Request failed", cause = lastThrowable, - isRetryExhausted = true + isRetryExhausted = isRetryExhausted ) ) ) @@ -143,11 +157,13 @@ class KtorApiClient( builder.header(header.key, VariableResolver.resolve(header.value, variableLayers)) } - if (request.cookies.isNotEmpty()) { - builder.header(HttpHeaders.Cookie, request.cookies.filter { it.enabled } - .joinToString(separator = "; ") { cookie -> - "${cookie.key}=${VariableResolver.resolve(cookie.value, variableLayers)}" - }) + val enabledCookies = request.cookies.filter { it.enabled } + if (enabledCookies.isNotEmpty()) { + builder.header( + HttpHeaders.Cookie, enabledCookies + .joinToString(separator = "; ") { cookie -> + "${cookie.key}=${VariableResolver.resolve(cookie.value, variableLayers)}" + }) } applyAuth(builder, request, variableLayers) @@ -165,8 +181,10 @@ class KtorApiClient( when (auth.type) { AuthType.NONE -> Unit AuthType.BASIC -> { - val username = VariableResolver.resolve(auth.params["username"].orEmpty(), variableLayers) - val password = VariableResolver.resolve(auth.params["password"].orEmpty(), variableLayers) + val username = + VariableResolver.resolve(auth.params["username"].orEmpty(), variableLayers) + val password = + VariableResolver.resolve(auth.params["password"].orEmpty(), variableLayers) val value = "$username:$password".encodeToByteArray().encodeBase64() builder.header(HttpHeaders.Authorization, "Basic $value") } @@ -191,7 +209,8 @@ class KtorApiClient( } AuthType.OAUTH2 -> { - val accessToken = VariableResolver.resolve(auth.params["accessToken"].orEmpty(), variableLayers) + val accessToken = + VariableResolver.resolve(auth.params["accessToken"].orEmpty(), variableLayers) if (accessToken.isNotBlank()) { builder.header(HttpHeaders.Authorization, "Bearer $accessToken") } @@ -259,11 +278,40 @@ class KtorApiClient( val body = request.body when (body.type) { BodyType.NONE -> Unit - BodyType.JSON -> applyRawBody(builder, ContentType.Application.Json, body.content, variableLayers) - BodyType.RAW_TEXT -> applyRawBody(builder, ContentType.Text.Plain, body.content, variableLayers) - BodyType.XML -> applyRawBody(builder, ContentType.Application.Xml, body.content, variableLayers) - BodyType.HTML -> applyRawBody(builder, ContentType.Text.Html, body.content, variableLayers) - BodyType.JAVASCRIPT -> applyRawBody(builder, ContentType.parse("application/javascript"), body.content, variableLayers) + BodyType.JSON -> applyRawBody( + builder, + ContentType.Application.Json, + body.content, + variableLayers + ) + + BodyType.RAW_TEXT -> applyRawBody( + builder, + ContentType.Text.Plain, + body.content, + variableLayers + ) + + BodyType.XML -> applyRawBody( + builder, + ContentType.Application.Xml, + body.content, + variableLayers + ) + + BodyType.HTML -> applyRawBody( + builder, + ContentType.Text.Html, + body.content, + variableLayers + ) + + BodyType.JAVASCRIPT -> applyRawBody( + builder, + ContentType.parse("application/javascript"), + body.content, + variableLayers + ) BodyType.GRAPHQL -> { builder.contentType(ContentType.Application.Json) diff --git a/core-network/src/commonMain/kotlin/com/reqlab/core/network/RetryPolicy.kt b/core-network/src/commonMain/kotlin/com/reqlab/core/network/RetryPolicy.kt index 1af91da..7a7a0c5 100644 --- a/core-network/src/commonMain/kotlin/com/reqlab/core/network/RetryPolicy.kt +++ b/core-network/src/commonMain/kotlin/com/reqlab/core/network/RetryPolicy.kt @@ -1,10 +1,21 @@ package com.reqlab.core.network +import kotlin.coroutines.cancellation.CancellationException +import kotlin.reflect.KClass + data class RetryPolicy( val maxAttempts: Int = 1, val baseDelayMs: Long = 250, val maxDelayMs: Long = 2_500, - val retryOnStatusCodes: Set = setOf(408, 429, 500, 502, 503, 504) + val retryOnStatusCodes: Set = setOf(408, 429, 500, 502, 503, 504), + val avoidRetryOnThrowable: Set> = setOf( + CancellationException::class, + Error::class, + NullPointerException::class, + IllegalArgumentException::class, + IllegalStateException::class, + IndexOutOfBoundsException::class, + ) ) { init { require(maxAttempts >= 1) { "maxAttempts must be at least 1" } diff --git a/core-network/src/commonTest/kotlin/com/reqlab/core/network/KtorApiClientTest.kt b/core-network/src/commonTest/kotlin/com/reqlab/core/network/KtorApiClientTest.kt index 4f82b5d..00f98dd 100644 --- a/core-network/src/commonTest/kotlin/com/reqlab/core/network/KtorApiClientTest.kt +++ b/core-network/src/commonTest/kotlin/com/reqlab/core/network/KtorApiClientTest.kt @@ -1,10 +1,10 @@ package com.reqlab.core.network +import com.reqlab.core.model.BodyType import com.reqlab.core.model.HttpMethodType import com.reqlab.core.model.KeyValueEntry import com.reqlab.core.model.RequestBody import com.reqlab.core.model.RequestDefinition -import com.reqlab.core.model.BodyType import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.respond @@ -16,6 +16,7 @@ import io.ktor.http.HttpStatusCode import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.runTest +import kotlinx.io.IOException import kotlinx.serialization.json.Json import kotlin.test.Test import kotlin.test.assertEquals @@ -29,7 +30,10 @@ class KtorApiClientTest { respond( content = "{\"ok\":true}", status = HttpStatusCode.OK, - headers = io.ktor.http.headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + headers = io.ktor.http.headersOf( + HttpHeaders.ContentType, + ContentType.Application.Json.toString() + ) ) } @@ -78,7 +82,10 @@ class KtorApiClientTest { respond( content = "ok", status = HttpStatusCode.OK, - headers = io.ktor.http.headersOf(HttpHeaders.ContentType, ContentType.Text.Plain.toString()) + headers = io.ktor.http.headersOf( + HttpHeaders.ContentType, + ContentType.Text.Plain.toString() + ) ) } @@ -89,7 +96,8 @@ class KtorApiClientTest { expectSuccess = false } - val apiClient = KtorApiClient(httpClient = client, retryPolicy = RetryPolicy(maxAttempts = 1)) + val apiClient = + KtorApiClient(httpClient = client, retryPolicy = RetryPolicy(maxAttempts = 1)) val request = RequestDefinition( id = "req-dyn", @@ -117,7 +125,10 @@ class KtorApiClientTest { respond( content = "{\"result\":42}", status = HttpStatusCode.OK, - headers = io.ktor.http.headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + headers = io.ktor.http.headersOf( + HttpHeaders.ContentType, + ContentType.Application.Json.toString() + ) ) } @@ -126,7 +137,8 @@ class KtorApiClientTest { expectSuccess = false } - val apiClient = KtorApiClient(httpClient = client, retryPolicy = RetryPolicy(maxAttempts = 1)) + val apiClient = + KtorApiClient(httpClient = client, retryPolicy = RetryPolicy(maxAttempts = 1)) val request = RequestDefinition( id = "req-timing", @@ -156,7 +168,10 @@ class KtorApiClientTest { respond( content = "ok", status = HttpStatusCode.OK, - headers = io.ktor.http.headersOf(HttpHeaders.ContentType, ContentType.Text.Plain.toString()) + headers = io.ktor.http.headersOf( + HttpHeaders.ContentType, + ContentType.Text.Plain.toString() + ) ) } @@ -195,7 +210,9 @@ class KtorApiClientTest { "Expected multipart payload content, got '$capturedBody'", ) assertTrue( - capturedBody.contains("name") && capturedBody.contains("alice") && capturedBody.contains("role") && capturedBody.contains("tester"), + capturedBody.contains("name") && capturedBody.contains("alice") && capturedBody.contains( + "role" + ) && capturedBody.contains("tester"), "Multipart payload should contain form fields, got '$capturedBody'", ) } @@ -203,7 +220,7 @@ class KtorApiClientTest { @Test fun emits_retry_scheduled_and_failure_when_retries_exhausted() = runTest { val mockEngine = MockEngine { - throw IllegalStateException("timeout-like failure") + throw IOException("timeout-like failure") } val client = HttpClient(mockEngine) { @@ -228,12 +245,17 @@ class KtorApiClientTest { ) val events = apiClient.execute(request).toList() - + println(events.joinToString("\n") { it.toString() }) assertTrue(events.first() is NetworkEvent.Started) assertEquals(1, events.count { it is NetworkEvent.RetryScheduled }) assertTrue(events.last() is NetworkEvent.Failure) val failure = events.last() as NetworkEvent.Failure assertTrue(failure.error.isRetryExhausted) - assertTrue(failure.error.message.contains("failed", ignoreCase = true) || failure.error.message.contains("timeout", ignoreCase = true)) + assertTrue( + failure.error.message.contains( + "failed", + ignoreCase = true + ) || failure.error.message.contains("timeout", ignoreCase = true) + ) } } diff --git a/editor-core/src/commonMain/kotlin/com/reqlab/editor/core/DocumentModel.kt b/editor-core/src/commonMain/kotlin/com/reqlab/editor/core/DocumentModel.kt index 517d5b1..05e8c58 100644 --- a/editor-core/src/commonMain/kotlin/com/reqlab/editor/core/DocumentModel.kt +++ b/editor-core/src/commonMain/kotlin/com/reqlab/editor/core/DocumentModel.kt @@ -58,7 +58,7 @@ class DocumentModel(initialText: String = "") { return if (nextStart == Int.MAX_VALUE) { buffer.length } else { - // nextStart points to the char after '\n'; subtract 1 to exclude '\n' + // nextStart points to the char after '\n'; subtract 1 to point to '\n' (end of line) (nextStart - 1).coerceAtLeast(lineIndex.lineStart(line)) } } @@ -136,5 +136,6 @@ class DocumentModel(initialText: String = "") { return sb.toString() } - override fun toString(): String = "DocumentModel(${buffer.length} chars, ${lineIndex.lineCount} lines, v$version)" + override fun toString(): String = + "DocumentModel(${buffer.length} chars, ${lineIndex.lineCount} lines, v$version)" } diff --git a/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/persistence/ImportExportFixturesIntegrationTest.kt b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/persistence/ImportExportFixturesIntegrationTest.kt index 5574d76..23be9ff 100644 --- a/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/persistence/ImportExportFixturesIntegrationTest.kt +++ b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/persistence/ImportExportFixturesIntegrationTest.kt @@ -1,5 +1,6 @@ -package com.reqlab.ui.shared.persistence +package com.reqlab.ui.desktop.persistence +import com.reqlab.ui.shared.persistence.ImportExportRepository import com.reqlab.ui.shared.state.AppState import java.io.File import kotlin.test.Test @@ -15,8 +16,10 @@ class ImportExportFixturesIntegrationTest { fun imports_collection_and_environment_from_deterministic_fixtures() { val state = AppState(openDefaultTab = false, withDemoData = false) - val importedCollection = ImportExportRepository.importCollectionFromString(state, collectionFixture.readText()) - val importedEnvironment = ImportExportRepository.importEnvironmentFromString(state, environmentFixture.readText()) + val importedCollection = + ImportExportRepository.importCollectionFromString(state, collectionFixture.readText()) + val importedEnvironment = + ImportExportRepository.importEnvironmentFromString(state, environmentFixture.readText()) assertEquals("ReqLab Test Suite", importedCollection) assertEquals("Local Dev – Sample Server", importedEnvironment) @@ -44,7 +47,7 @@ class ImportExportFixturesIntegrationTest { val root = restored.collections.firstOrNull { it.name == "ReqLab Test Suite" } assertTrue(root != null) - assertTrue(root.children.any { it.isFolder && it.name == "New Script APIs Coverage" }) + assertTrue(root.children.any { it.isFolder && it.name == "Scripting Runtime Coverage" }) val env = restored.environments.firstOrNull { it.name == "Local Dev – Sample Server" } assertTrue(env != null) diff --git a/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/persistence/WorkspaceRepositoryTest.kt b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/persistence/WorkspaceRepositoryTest.kt index 8b42897..66bc471 100644 --- a/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/persistence/WorkspaceRepositoryTest.kt +++ b/ui-desktop/src/desktopTest/kotlin/com/reqlab/ui/desktop/persistence/WorkspaceRepositoryTest.kt @@ -1,13 +1,14 @@ -package com.reqlab.ui.shared.persistence +package com.reqlab.ui.desktop.persistence import androidx.compose.runtime.mutableStateListOf import com.reqlab.core.model.BodyType import com.reqlab.core.model.HttpMethodType +import com.reqlab.ui.shared.persistence.SettingsRepository +import com.reqlab.ui.shared.persistence.WorkspaceRepository import com.reqlab.ui.shared.platform.PlatformStorage import com.reqlab.ui.shared.state.AppState import com.reqlab.ui.shared.state.CollectionNode import com.reqlab.ui.shared.state.EnvState -import com.reqlab.ui.shared.state.MutableKeyValue import org.junit.After import org.junit.Before import org.junit.Test @@ -35,8 +36,6 @@ class WorkspaceRepositoryTest { PlatformStorage.remove(WORKSPACE_KEY) PlatformStorage.remove(SETTINGS_ENV_KEY) } - PlatformStorage.remove(WORKSPACE_KEY) - } // ── Basic save / load ──────────────────────────────────────────────────── @@ -90,7 +89,7 @@ class WorkspaceRepositoryTest { @Test fun save_and_load_with_multiple_large_per_type_bodies_preserves_all_types() { val jsonBody = "{\"j\":\"" + "j".repeat(100_000) + "\"}" - val xmlBody = "" + "x".repeat(100_000) + "" + val xmlBody = "" + "x".repeat(100_000) + "" val request = CollectionNode( id = "multi-body-req-1", @@ -102,7 +101,7 @@ class WorkspaceRepositoryTest { bodyContent = jsonBody, bodyContents = mapOf( BodyType.JSON.name to jsonBody, - BodyType.XML.name to xmlBody, + BodyType.XML.name to xmlBody, ), ) val collection = CollectionNode( @@ -140,7 +139,7 @@ class WorkspaceRepositoryTest { @Test fun save_overwrites_previous_save_with_updated_content() { val original = "original body" - val updated = "updated body " + "u".repeat(10_000) + val updated = "updated body " + "u".repeat(10_000) fun buildState(body: String): AppState { val req = CollectionNode( @@ -172,7 +171,11 @@ class WorkspaceRepositoryTest { .firstOrNull { it.name == "Overwrite Collection" } ?.children?.firstOrNull()?.bodyContent - assertEquals(updated, body, "Second save must overwrite the first; restored body must match updated value") + assertEquals( + updated, + body, + "Second save must overwrite the first; restored body must match updated value" + ) } // ── Selected environment persistence ───────────────────────────────────── @@ -195,10 +198,14 @@ class WorkspaceRepositoryTest { SettingsRepository.load(restored.settings) WorkspaceRepository.load(restored) // resolves selectedEnvName → index - assertEquals(1, restored.selectedEnvIndex, - "selectedEnvIndex must be 1 (Staging) after restore") - assertEquals("Staging", restored.selectedEnvironment?.name, - "selectedEnvironment must be Staging after restore") + assertEquals( + 1, restored.selectedEnvIndex, + "selectedEnvIndex must be 1 (Staging) after restore" + ) + assertEquals( + "Staging", restored.selectedEnvironment?.name, + "selectedEnvironment must be Staging after restore" + ) } @Test @@ -216,8 +223,10 @@ class WorkspaceRepositoryTest { SettingsRepository.load(restored.settings) WorkspaceRepository.load(restored) - assertEquals(0, restored.selectedEnvIndex, - "Must fall back to index 0 when saved environment name is not found") + assertEquals( + 0, restored.selectedEnvIndex, + "Must fall back to index 0 when saved environment name is not found" + ) assertEquals("Development", restored.selectedEnvironment?.name) } @@ -236,8 +245,10 @@ class WorkspaceRepositoryTest { SettingsRepository.load(restored.settings) WorkspaceRepository.load(restored) - assertEquals(0, restored.selectedEnvIndex, - "Must default to index 0 when no env name was saved") + assertEquals( + 0, restored.selectedEnvIndex, + "Must default to index 0 when no env name was saved" + ) } @Test @@ -253,8 +264,10 @@ class WorkspaceRepositoryTest { SettingsRepository.load(restored.settings) WorkspaceRepository.load(restored) - assertEquals(0, restored.selectedEnvIndex, - "selectedEnvIndex must be 0 when environment list is empty") + assertEquals( + 0, restored.selectedEnvIndex, + "selectedEnvIndex must be 0 when environment list is empty" + ) } @Test