diff --git a/.github/workflows/coverage_verification_worflow.yml b/.github/workflows/coverage_verification_worflow.yml index 99b3a05..c94a0c3 100644 --- a/.github/workflows/coverage_verification_worflow.yml +++ b/.github/workflows/coverage_verification_worflow.yml @@ -20,7 +20,7 @@ jobs: run: chmod +x ./gradlew - name: Validate coverage - run: ./gradlew jacocoProjectMiddleWareCoverageVerification + run: ./gradlew jacocoProjectMiddlewareCoverageVerification - name: Generate Tests Report (If JUnit finds violations) if: failure() @@ -34,4 +34,4 @@ jobs: uses: actions/upload-artifact@v4 with: name: Middleware-Coverage-Report - path: 'build/reports/jacoco/test/html/' \ No newline at end of file + path: 'build/reports/jacoco/jacocoProjectMiddlewareCoverageReport/html/' \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5406092..0bfb28a 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,5 @@ bin/ .DS_Store local.properties -**/ClientHiddenConstants.kt \ No newline at end of file +**/ClientHiddenConstants.kt +CLAUDE.md diff --git a/build-logic/convention/src/main/kotlin/JacocoModuleConventionPlugin.kt b/build-logic/convention/src/main/kotlin/JacocoModuleConventionPlugin.kt index 8d884d5..8bd4943 100644 --- a/build-logic/convention/src/main/kotlin/JacocoModuleConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/JacocoModuleConventionPlugin.kt @@ -43,7 +43,7 @@ class JacocoModuleConventionPlugin : Plugin { group = "verification" description = "Validates $module coverage." - setupCoverageVerification() + setupCoverageVerification(this) dependsOn(report) if (isJvm()) { diff --git a/build-logic/convention/src/main/kotlin/JacocoMultiModuleConventionPlugin.kt b/build-logic/convention/src/main/kotlin/JacocoMultiModuleConventionPlugin.kt index a928682..38dc5d5 100644 --- a/build-logic/convention/src/main/kotlin/JacocoMultiModuleConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/JacocoMultiModuleConventionPlugin.kt @@ -49,7 +49,7 @@ class JacocoMultiModuleConventionPlugin : Plugin { group = "verification" description = "Validates $module coverage." - setupCoverageVerification() + setupCoverageVerification(this) dependsOn(report) subprojects.forEach { subproject -> diff --git a/build-logic/convention/src/main/kotlin/extensions/ProjectExtensions.kt b/build-logic/convention/src/main/kotlin/extensions/ProjectExtensions.kt index 44c574b..38be574 100644 --- a/build-logic/convention/src/main/kotlin/extensions/ProjectExtensions.kt +++ b/build-logic/convention/src/main/kotlin/extensions/ProjectExtensions.kt @@ -87,10 +87,15 @@ private fun Project.configureKotlin() { * @receiver The JacocoCoverageVerification to configure the settings for. * @param minimumCoverage The minimum coverage percentage to enforce. */ -internal fun JacocoCoverageVerification.setupCoverageVerification( - minimumCoverage: Double = 0.80 +internal fun Project.setupCoverageVerification( + task: JacocoCoverageVerification ) { - violationRules { + val minimumCoverage = rootProject.findProperty("minimumCoverage") + ?.toString() + ?.toDoubleOrNull() + ?: 0.80 + + task.violationRules { rule { limit { minimum = minimumCoverage.toBigDecimal() @@ -145,9 +150,34 @@ internal fun Project.setupJacoco() { internal fun Project.setJacocoJvmDirectories( jacocoReport: JacocoReportBase ) { + val buildDir = layout.buildDirectory.get().asFile.absolutePath + val fileFilter = listOf( + "**/build/generated/**", + "**/Application.kt", + "**/*Configuration.kt", + "**/*Kt$*", + "**/internal/**", + "**/R.class", + "**/BuildConfig.*", + "**/session/**", + "**/model/**", + "**/plugins/**", + "**/di/**", + "**/security/**", + ) + jacocoReport.apply { - executionData.from(fileTree("${layout.buildDirectory}/jacoco/test.exec")) - setJacocoDirectories(this) + executionData.from(fileTree("$buildDir/jacoco") { include("test.exec") }) + + classDirectories.from( + files( + fileTree(mapOf("dir" to "$buildDir/classes/kotlin/main", "excludes" to fileFilter)) + ) + ) + + val sourceDirs = listOf("src/main/kotlin", "src/main/java") + sourceDirectories.from(files(sourceDirs)) + additionalSourceDirs.from(files(sourceDirs)) } } @@ -160,8 +190,11 @@ internal fun Project.setJacocoJvmDirectories( internal fun Project.setJacocoAndroidDirectories( jacocoReport: JacocoReportBase ) { + val buildDir = layout.buildDirectory.get().asFile.absolutePath jacocoReport.apply { - executionData.from(fileTree("${layout.buildDirectory}/jacoco/testDebugUnitTest.exec")) + executionData.from( + fileTree("$buildDir/jacoco") { include("testDebugUnitTest.exec") } + ) setJacocoDirectories(this) } } @@ -175,6 +208,7 @@ internal fun Project.setJacocoAndroidDirectories( internal fun Project.setJacocoDirectories( jacocoReport: JacocoReportBase ) { + val buildDir = layout.buildDirectory.get().asFile.absolutePath val fileFilter = listOf( "**/build/generated/**", "**/Application.kt", @@ -193,18 +227,18 @@ internal fun Project.setJacocoDirectories( jacocoReport.apply { val javaTree = fileTree( mapOf( - "dir" to "${layout.buildDirectory}/intermediates/javac/debug/classes", + "dir" to "$buildDir/intermediates/javac/debug/classes", "excludes" to fileFilter ) ) val kotlinTree = fileTree( mapOf( - "dir" to "${layout.buildDirectory}/tmp/kotlin-classes/debug", + "dir" to "$buildDir/tmp/kotlin-classes/debug", "excludes" to fileFilter ) ) - classDirectories.setFrom(files(javaTree, kotlinTree)) + classDirectories.from(files(javaTree, kotlinTree)) val sourceDirs = listOf( "src/main/kotlin", @@ -212,8 +246,8 @@ internal fun Project.setJacocoDirectories( "src/debug/kotlin", "src/debug/java" ) - sourceDirectories.setFrom(files(sourceDirs)) - additionalSourceDirs.setFrom(files(sourceDirs)) + sourceDirectories.from(files(sourceDirs)) + additionalSourceDirs.from(files(sourceDirs)) } } diff --git a/documentation/MappingRequest.md b/documentation/MappingRequest.md index f026113..d72f569 100644 --- a/documentation/MappingRequest.md +++ b/documentation/MappingRequest.md @@ -14,6 +14,31 @@ You can also include the following optional fields in the root of your mapping r - **`preConfiguredHeaders`**: Define any custom headers that should be included in the request. - **`preConfiguredBody`**: Predefine the body content for requests that require a specific payload. +### External API Authentication: + +To authenticate calls to the original external API, include an `authHeader` field inside `originalRoute`. This is independent from the Middleware's own authentication (handled via the `Authorization` header when calling the Middleware). + +Supported auth types: +- **`None`** — No authentication header is sent to the external API. +- **`Bearer`** — Sends `Authorization: Bearer `. +- **`Basic`** — Sends `Authorization: Basic `. + +```json +"originalRoute": { + "path": "api/json/v1/1/random.php", + "method": "Get", + "originalApi": { + "baseUrl": "https://www.example.com/" + }, + "authHeader": { + "type": "Bearer", + "token": "your-api-token-here" + } +} +``` + +> **Note:** The Middleware's own `Authorization` header (used by clients to authenticate with the Middleware) is never forwarded to the external API. Only `authHeader` inside `originalRoute` controls external API authentication. + ### Example Additional Fields: ```json diff --git a/gradle.properties b/gradle.properties index 0dee8cb..94a9beb 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,5 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 android.useAndroidX=true kotlin.code.style=official -android.nonTransitiveRClass=true \ No newline at end of file +android.nonTransitiveRClass=true +minimumCoverage=0.62 \ No newline at end of file diff --git a/impl/ktor-client/src/main/kotlin/io/lb/impl/ktor/client/util/ClientExtensions.kt b/impl/ktor-client/src/main/kotlin/io/lb/impl/ktor/client/util/ClientExtensions.kt index 43b8e70..2405937 100644 --- a/impl/ktor-client/src/main/kotlin/io/lb/impl/ktor/client/util/ClientExtensions.kt +++ b/impl/ktor-client/src/main/kotlin/io/lb/impl/ktor/client/util/ClientExtensions.kt @@ -51,7 +51,7 @@ internal suspend fun HttpClient.request( if (it.key == HttpHeaders.ContentType) { accept(ContentType.parse(it.value)) contentType(ContentType.parse(it.value)) - } else { + } else if (it.key != HttpHeaders.Authorization) { headers[it.key] = it.value } } @@ -91,7 +91,7 @@ internal suspend fun HttpClient.request( private fun getHttpMethod(originalRoute: OriginalRoute) = when (originalRoute.method) { MiddlewareHttpMethods.Get -> HttpMethod.Get MiddlewareHttpMethods.Post -> HttpMethod.Post - MiddlewareHttpMethods.Head -> HttpMethod.Put + MiddlewareHttpMethods.Head -> HttpMethod.Head MiddlewareHttpMethods.Delete -> HttpMethod.Delete MiddlewareHttpMethods.Patch -> HttpMethod.Patch MiddlewareHttpMethods.Put -> HttpMethod.Put diff --git a/impl/ktor-client/src/test/kotlin/io/lb/impl/ktor/client/ClientServiceImplTest.kt b/impl/ktor-client/src/test/kotlin/io/lb/impl/ktor/client/ClientServiceImplTest.kt index f108905..9636fd6 100644 --- a/impl/ktor-client/src/test/kotlin/io/lb/impl/ktor/client/ClientServiceImplTest.kt +++ b/impl/ktor-client/src/test/kotlin/io/lb/impl/ktor/client/ClientServiceImplTest.kt @@ -5,6 +5,7 @@ import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.respond import io.ktor.client.engine.mock.toByteArray import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.http.HttpHeaders import io.ktor.http.HttpMethod import io.ktor.http.HttpStatusCode import io.ktor.http.headersOf @@ -22,6 +23,7 @@ import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.Json import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows @@ -132,6 +134,118 @@ class ClientServiceImplTest { } } + @Test + fun `When calls route with Bearer auth, expect Authorization header sent`() = runTest { + val route = OriginalRoute( + path = "test-route", + originalApi = OriginalApi("https://10.0.2.2:8885/"), + method = MiddlewareHttpMethods.Get, + authHeader = MiddlewareAuthHeader(MiddlewareAuthHeaderType.Bearer, "my-token"), + ) + service.request(route, emptyMap(), emptyMap(), null) + advanceUntilIdle() + assertEquals("Bearer my-token", mockEngine.requestHistory.last().headers[HttpHeaders.Authorization]) + } + + @Test + fun `When calls route with Basic auth, expect Authorization header sent`() = runTest { + val route = OriginalRoute( + path = "test-route", + originalApi = OriginalApi("https://10.0.2.2:8885/"), + method = MiddlewareHttpMethods.Get, + authHeader = MiddlewareAuthHeader(MiddlewareAuthHeaderType.Basic, "base64creds"), + ) + service.request(route, emptyMap(), emptyMap(), null) + advanceUntilIdle() + assertEquals("Basic base64creds", mockEngine.requestHistory.last().headers[HttpHeaders.Authorization]) + } + + @Test + fun `When calls route with no auth header, expect no Authorization sent`() = runTest { + val route = OriginalRoute( + path = "test-route", + originalApi = OriginalApi("https://10.0.2.2:8885/"), + method = MiddlewareHttpMethods.Get, + authHeader = null, + ) + service.request(route, emptyMap(), emptyMap(), null) + advanceUntilIdle() + assertNull(mockEngine.requestHistory.last().headers[HttpHeaders.Authorization]) + } + + @Test + fun `When uses Head method, expect Head HTTP method sent to server`() = runTest { + val route = OriginalRoute( + path = "test-route", + originalApi = OriginalApi("https://10.0.2.2:8885/"), + method = MiddlewareHttpMethods.Head, + authHeader = null, + ) + service.request(route, emptyMap(), emptyMap(), null) + advanceUntilIdle() + assertEquals(HttpMethod.Head, mockEngine.requestHistory.last().method) + } + + @Test + fun `When preConfiguredHeaders is empty, expect originalRoute headers used`() = runTest { + val route = OriginalRoute( + path = "test-route", + originalApi = OriginalApi("https://10.0.2.2:8885/"), + method = MiddlewareHttpMethods.Get, + authHeader = null, + headers = mapOf("X-Custom" to "custom-value", "Content-Type" to "application/json"), + ) + service.request(route, emptyMap(), emptyMap(), null) + advanceUntilIdle() + assertEquals("custom-value", mockEngine.requestHistory.last().headers["X-Custom"]) + } + + @Test + fun `When preConfiguredQueries is empty, expect originalRoute queries used`() = runTest { + val route = OriginalRoute( + path = "test-route", + originalApi = OriginalApi("https://10.0.2.2:8885/"), + method = MiddlewareHttpMethods.Get, + authHeader = null, + queries = mapOf("key" to "value"), + ) + service.request(route, emptyMap(), emptyMap(), null) + advanceUntilIdle() + val params = mockEngine.requestHistory.last().url.parameters.toMap() + assertEquals(mapOf("key" to listOf("value")), params) + } + + @Test + fun `When preConfiguredBody is null, expect originalRoute body used`() = runTest { + val route = OriginalRoute( + path = "test-route", + originalApi = OriginalApi("https://10.0.2.2:8885/"), + method = MiddlewareHttpMethods.Get, + authHeader = null, + headers = mapOf("Content-Type" to "application/json"), + body = json.decodeFromString("""{"key":"from-route"}"""), + ) + service.request(route, emptyMap(), emptyMap(), null) + advanceUntilIdle() + val body = mockEngine.requestHistory.last().body.toByteArray().decodeToString() + assertEquals("""{"key":"from-route"}""", body) + } + + @Test + fun `When preConfiguredHeaders contains Authorization, expect authHeader takes priority`() = runTest { + val route = OriginalRoute( + path = "test-route", + originalApi = OriginalApi("https://10.0.2.2:8885/"), + method = MiddlewareHttpMethods.Get, + authHeader = MiddlewareAuthHeader(MiddlewareAuthHeaderType.Bearer, "correct-token"), + headers = mapOf("Authorization" to "Bearer wrong-token"), + ) + service.request(route, emptyMap(), emptyMap(), null) + advanceUntilIdle() + val auth = mockEngine.requestHistory.last().headers[HttpHeaders.Authorization] + assertEquals("Bearer correct-token", auth) + } + @Test fun `When URL with parameters, body and headers, expect them to be added`() = runTest { val route = OriginalRoute( diff --git a/impl/ktor-server/src/main/kotlin/io/lb/impl/ktor/server/service/ServerServiceImpl.kt b/impl/ktor-server/src/main/kotlin/io/lb/impl/ktor/server/service/ServerServiceImpl.kt index eca10a4..0f07be0 100644 --- a/impl/ktor-server/src/main/kotlin/io/lb/impl/ktor/server/service/ServerServiceImpl.kt +++ b/impl/ktor-server/src/main/kotlin/io/lb/impl/ktor/server/service/ServerServiceImpl.kt @@ -2,6 +2,7 @@ package io.lb.impl.ktor.server.service import io.ktor.client.request.post import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.server.application.ApplicationCall import io.ktor.server.application.call @@ -187,9 +188,9 @@ internal class ServerServiceImpl( val queries = call.request.queryParameters.toMap().mapValues { query -> query.value.first() } - val headers = call.request.headers.toMap().mapValues { header -> - header.value.first() - } + val headers = call.request.headers.toMap() + .mapValues { it.value.first() } + .filterKeys { it != HttpHeaders.Authorization } val body = try { call.receiveNullable() } catch (e: Exception) { diff --git a/impl/ktor-server/src/test/kotlin/io/lb/impl/ktor/server/service/ServerServiceImplTest.kt b/impl/ktor-server/src/test/kotlin/io/lb/impl/ktor/server/service/ServerServiceImplTest.kt index d8a3958..8fd99cd 100644 --- a/impl/ktor-server/src/test/kotlin/io/lb/impl/ktor/server/service/ServerServiceImplTest.kt +++ b/impl/ktor-server/src/test/kotlin/io/lb/impl/ktor/server/service/ServerServiceImplTest.kt @@ -22,6 +22,8 @@ import io.lb.common.data.model.OriginalRoute import io.lb.common.data.request.MiddlewareAuthHeader import io.lb.common.data.request.MiddlewareAuthHeaderType import io.lb.common.data.request.MiddlewareHttpMethods +import io.lb.common.data.util.MiddlewareStatusCode +import io.lb.common.shared.error.MiddlewareException import io.lb.impl.ktor.server.model.MappedRouteParameter import io.lb.impl.ktor.server.model.OriginalApiParameter import io.lb.impl.ktor.server.model.OriginalRouteParameter @@ -36,6 +38,7 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonObject import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Test class ServerServiceImplTest { @@ -245,6 +248,122 @@ class ServerServiceImplTest { return Pair(testMappedRoute, onRequestMock) } + @Test + fun `When request has no auth, expect unauthorized`() = + serverServiceTestApplication(MiddlewareHttpMethods.Get) { + val response = client.get("v1/b1b3b3b3-3b3b-3b3b-3b3b-3b3b3b3b3b3b/test-path") { + header(io.ktor.http.HttpHeaders.ContentType, "application/json") + } + assertEquals(HttpStatusCode.Unauthorized, response.status) + } + + @Test + fun `When request has multiple custom headers, all headers forwarded to handler`() { + val capturedHeaders = mutableMapOf() + testApplication { + setupApplication() + application { + val engine: NettyApplicationEngine = mockk(relaxed = true) + every { engine.application } returns this + serverService = ServerServiceImpl(engine) + serverService.createMappedRoute(createTestMappedRoute(MiddlewareHttpMethods.Get)) { + capturedHeaders.putAll(it.originalRoute.headers) + MappedResponse(statusCode = 200, body = "{}") + } + } + val response = client.get("v1/b1b3b3b3-3b3b-3b3b-3b3b-3b3b3b3b3b3b/test-path") { + setupRequest() + header("X-First-Header", "first-value") + header("X-Second-Header", "second-value") + header("X-Third-Header", "third-value") + } + assertEquals(HttpStatusCode.OK, response.status) + assertEquals("first-value", capturedHeaders["X-First-Header"]) + assertEquals("second-value", capturedHeaders["X-Second-Header"]) + assertEquals("third-value", capturedHeaders["X-Third-Header"]) + } + } + + @Test + fun `When Authorization header is in request, expect it not forwarded to handler`() { + val capturedHeaders = mutableMapOf() + testApplication { + setupApplication() + application { + val engine: NettyApplicationEngine = mockk(relaxed = true) + every { engine.application } returns this + serverService = ServerServiceImpl(engine) + serverService.createMappedRoute(createTestMappedRoute(MiddlewareHttpMethods.Get)) { + capturedHeaders.putAll(it.originalRoute.headers) + MappedResponse(statusCode = 200, body = "{}") + } + } + client.get("v1/b1b3b3b3-3b3b-3b3b-3b3b-3b3b3b3b3b3b/test-path") { + setupRequest() + } + assertFalse(capturedHeaders.containsKey(io.ktor.http.HttpHeaders.Authorization)) + } + } + + @Test + fun `When mapping route throws MiddlewareException, expect Conflict status`() { + testApplication { + setupApplication() + application { + val engine: NettyApplicationEngine = mockk(relaxed = true) + every { engine.application } returns this + serverService = ServerServiceImpl(engine) + serverService.startGenericMappingRoute { + throw MiddlewareException(MiddlewareStatusCode.CONFLICT, "Route already exists") + } + } + val response = client.post("v1/mapping") { + setupRequest() + setBody(Json.encodeToString(createMappedRoute())) + } + assertEquals(HttpStatusCode.Conflict, response.status) + } + } + + @Test + fun `When mapping route throws IllegalArgumentException, expect bad request`() { + testApplication { + setupApplication() + application { + val engine: NettyApplicationEngine = mockk(relaxed = true) + every { engine.application } returns this + serverService = ServerServiceImpl(engine) + serverService.startGenericMappingRoute { + throw IllegalArgumentException("Invalid route configuration") + } + } + val response = client.post("v1/mapping") { + setupRequest() + setBody(Json.encodeToString(createMappedRoute())) + } + assertEquals(HttpStatusCode.BadRequest, response.status) + } + } + + @Test + fun `When routes route throws MiddlewareException, expect Conflict status`() { + testApplication { + setupApplication() + application { + val engine: NettyApplicationEngine = mockk(relaxed = true) + every { engine.application } returns this + serverService = ServerServiceImpl(engine) + serverService.startQueryAllRoutesRoute { + throw MiddlewareException(MiddlewareStatusCode.CONFLICT, "Error listing routes") + } + } + val response = client.get("v1/routes") { + setupRequest() + } + assertEquals(HttpStatusCode.Conflict, response.status) + } + } + private fun createPreviewRequest(): PreviewRequestBody { return PreviewRequestBody( originalResponse = Json.parseToJsonElement("{}").jsonObject, @@ -252,6 +371,25 @@ class ServerServiceImplTest { ) } + private fun createTestMappedRoute(method: MiddlewareHttpMethods) = MappedRoute( + uuid = "b1b3b3b3-3b3b-3b3b-3b3b-3b3b3b3b3b3b", + method = method, + path = "v1/b1b3b3b3-3b3b-3b3b-3b3b-3b3b3b3b3b3b/test-path", + rulesAsString = "test-rules", + originalRoute = OriginalRoute( + path = "test-route", + method = method, + originalApi = OriginalApi("https://10.0.2.2:8885/"), + authHeader = MiddlewareAuthHeader(MiddlewareAuthHeaderType.None, ""), + headers = mapOf("Content-Type" to "application/json"), + body = json.decodeFromString("{}"), + ), + mappedApi = MappedApi( + "a1b3b3b3-3b3b-3b3b-3b3b-3b3b3b3b3b3b", + originalApi = OriginalApi(baseUrl = "https://www.themealdb.com/api/") + ), + ) + private fun createMappedRoute(): MappedRouteParameter { return MappedRouteParameter( path = "getTeryiaki", diff --git a/middleware/mapper/src/main/java/io/lb/middleware/mapper/service/MapperServiceImpl.kt b/middleware/mapper/src/main/java/io/lb/middleware/mapper/service/MapperServiceImpl.kt index a6f641c..14f5631 100644 --- a/middleware/mapper/src/main/java/io/lb/middleware/mapper/service/MapperServiceImpl.kt +++ b/middleware/mapper/src/main/java/io/lb/middleware/mapper/service/MapperServiceImpl.kt @@ -118,9 +118,9 @@ internal class MapperServiceImpl : MapperService { } else { element?.jsonObject } ?: throw MiddlewareException( - code = MiddlewareStatusCode.BAD_REQUEST, - "Parent key '$key' not found in original JSON." - ) + code = MiddlewareStatusCode.BAD_REQUEST, + "Parent key '$key' not found in original JSON." + ) } } else { val element = currentElement?.jsonObject?.get(keys[0]) @@ -221,7 +221,11 @@ internal class MapperServiceImpl : MapperService { } return when (type) { - "String" -> JsonPrimitive(value?.jsonPrimitive?.content?.trim().orEmpty()) + "String" -> if (value == null || value is JsonNull) { + JsonNull + } else { + JsonPrimitive(value.jsonPrimitive.content.trim()) + } "Int" -> JsonPrimitive(value?.jsonPrimitive?.content?.toIntOrNull()) "Double" -> JsonPrimitive(value?.jsonPrimitive?.content?.toDoubleOrNull()) "Boolean" -> JsonPrimitive(value?.jsonPrimitive?.content?.toBoolean()) diff --git a/middleware/mapper/src/test/java/io/lb/middleware/mapper/service/ConcatenatedHelper.kt b/middleware/mapper/src/test/java/io/lb/middleware/mapper/service/ConcatenatedHelper.kt index e9a9e70..263fd42 100644 --- a/middleware/mapper/src/test/java/io/lb/middleware/mapper/service/ConcatenatedHelper.kt +++ b/middleware/mapper/src/test/java/io/lb/middleware/mapper/service/ConcatenatedHelper.kt @@ -1,10 +1,10 @@ package io.lb.middleware.mapper.service -internal fun getConcatenatedMappingRule() = +internal fun getConcatenatedMappingRule(ignoreEmptyValues: Boolean = true) = json.parseToJsonElement( """ { - "ignoreEmptyValues":true, + "ignoreEmptyValues":$ignoreEmptyValues, $CONCATENATED_NEW_FIELDS $concatenatedOldFields } @@ -132,3 +132,24 @@ internal fun expectedMeasuredResponse() = } """.trimIndent() ).toString() + +internal fun expectedMeasuredResponseWithEmptyValues() = + json.parseToJsonElement( + """ + { + "id":53058, + "name":"Croatian Bean Stew", + "thumbnail":"https://www.themealdb.com/images/media/meals/tnwy8m1628770384.jpg", + "ingredients":[ + "2 cans Cannellini Beans", + "3 tbs Vegetable Oil", + "2 cups Tomatoes", + "5 Challots", + "2 cloves Garlic", + "Pinch Parsley", + "1/2 kg chopped Chorizo", + "", "", "", "", "", "", "", "", "", "", "", "", "" + ] + } + """.trimIndent() + ).toString() diff --git a/middleware/mapper/src/test/java/io/lb/middleware/mapper/service/MapperServiceImplTest.kt b/middleware/mapper/src/test/java/io/lb/middleware/mapper/service/MapperServiceImplTest.kt index e771ad4..85c2788 100644 --- a/middleware/mapper/src/test/java/io/lb/middleware/mapper/service/MapperServiceImplTest.kt +++ b/middleware/mapper/src/test/java/io/lb/middleware/mapper/service/MapperServiceImplTest.kt @@ -8,7 +8,9 @@ import io.lb.middleware.mapper.model.NewBodyMappingRule import io.lb.middleware.mapper.model.OldBodyField import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows @@ -159,13 +161,158 @@ class MapperServiceImplTest { } @Test - fun `mapResponse shaould return a MappedResponse with correct statusCode and mapped body`() { + fun `mapResponse should return a MappedResponse with concatenated fields`() { val originalResponse = createOriginalResponse() val mappedResponse: MappedResponse = mapperService.mapResponse(getConcatenatedMappingRule(), originalResponse) assertEquals(200, mappedResponse.statusCode) assertEquals(expectedMeasuredResponse(), json.parseToJsonElement(mappedResponse.body!!).toString()) } + + @Test + fun `mapResponse should return concatenated fields with empty values when ignoreEmptyValues is false`() { + val originalResponse = createOriginalResponse() + val mappedResponse: MappedResponse = mapperService.mapResponse( + getConcatenatedMappingRule(false), + originalResponse + ) + + assertEquals(200, mappedResponse.statusCode) + val expected = expectedMeasuredResponseWithEmptyValues() + assertEquals(expected, json.parseToJsonElement(mappedResponse.body!!).toString()) + } + + @Test + fun `mapResponse should map Double type field correctly`() { + val mappingRule = Json.encodeToString( + NewBodyMappingRule( + newBodyFields = mapOf("rating" to NewBodyField("rating", "Double")), + oldBodyFields = mapOf("rating" to OldBodyField(listOf("rating"), "Double")) + ) + ) + val mappedResponse = mapperService.mapResponse(mappingRule, createFlatOriginalResponse()) + + assertEquals(200, mappedResponse.statusCode) + assertEquals( + json.parseToJsonElement("""{"rating":4.5}""").toString(), + json.parseToJsonElement(mappedResponse.body!!).toString() + ) + } + + @Test + fun `mapResponse should map Boolean type field correctly`() { + val mappingRule = Json.encodeToString( + NewBodyMappingRule( + newBodyFields = mapOf("available" to NewBodyField("available", "Boolean")), + oldBodyFields = mapOf("available" to OldBodyField(listOf("available"), "Boolean")) + ) + ) + val mappedResponse = mapperService.mapResponse(mappingRule, createFlatOriginalResponse()) + + assertEquals(200, mappedResponse.statusCode) + assertEquals( + json.parseToJsonElement("""{"available":true}""").toString(), + json.parseToJsonElement(mappedResponse.body!!).toString() + ) + } + + @Test + fun `mapResponse should map root-level fields without parents`() { + val mappingRule = Json.encodeToString( + NewBodyMappingRule( + newBodyFields = mapOf( + "id" to NewBodyField("id", "Int"), + "title" to NewBodyField("title", "String") + ), + oldBodyFields = mapOf( + "id" to OldBodyField(listOf("id"), "Int"), + "title" to OldBodyField(listOf("title"), "String") + ) + ) + ) + val mappedResponse = mapperService.mapResponse(mappingRule, createFlatOriginalResponse()) + + assertEquals(200, mappedResponse.statusCode) + assertEquals( + json.parseToJsonElement("""{"id":101,"title":"Pasta Carbonara"}""").toString(), + json.parseToJsonElement(mappedResponse.body!!).toString() + ) + } + + @Test + fun `mapResponse should preserve non-200 status code`() { + val mappingRule = Json.encodeToString( + NewBodyMappingRule( + newBodyFields = mapOf("id" to NewBodyField("id", "Int")), + oldBodyFields = mapOf("id" to OldBodyField(listOf("id"), "Int")) + ) + ) + val originalResponse = OriginalResponse(statusCode = 404, body = """{"id":"42"}""") + val mappedResponse = mapperService.mapResponse(mappingRule, originalResponse) + + assertEquals(404, mappedResponse.statusCode) + assertEquals( + json.parseToJsonElement("""{"id":42}""").toString(), + json.parseToJsonElement(mappedResponse.body!!).toString() + ) + } + + @Test + fun `mapResponse should map fields with multiple parent levels`() { + val mappingRule = Json.encodeToString( + NewBodyMappingRule( + newBodyFields = mapOf( + "id" to NewBodyField("id", "Int"), + "name" to NewBodyField("name", "String") + ), + oldBodyFields = mapOf( + "id" to OldBodyField(listOf("id"), "Int", parents = listOf("data", "recipes")), + "name" to OldBodyField(listOf("name"), "String", parents = listOf("data", "recipes")) + ) + ) + ) + val mappedResponse = mapperService.mapResponse(mappingRule, createDeepNestedOriginalResponse()) + + assertEquals(200, mappedResponse.statusCode) + assertEquals( + json.parseToJsonElement("""{"id":200,"name":"Tiramisu"}""").toString(), + json.parseToJsonElement(mappedResponse.body!!).toString() + ) + } + + @Test + fun `mapResponse should omit null String fields when ignoreEmptyValues is true`() { + val mappingRule = Json.encodeToString( + NewBodyMappingRule( + ignoreEmptyValues = true, + newBodyFields = mapOf( + "idMeal" to NewBodyField("id", "Int"), + "drinkAlternate" to NewBodyField("comment", "String") + ), + oldBodyFields = mapOf( + "idMeal" to OldBodyField(listOf("idMeal"), "Int", parents = listOf("meals")), + "drinkAlternate" to OldBodyField(listOf("strDrinkAlternate"), "String", parents = listOf("meals")) + ) + ) + ) + val mappedResponse = mapperService.mapResponse(mappingRule, createOriginalResponse()) + val body = json.parseToJsonElement(mappedResponse.body!!).jsonObject + + assertFalse(body.containsKey("comment")) + } + + @Test + fun `responseJsonPreview should throw MiddlewareException when mapping rules are invalid`() { + val exception = assertThrows { + mapperService.responseJsonPreview("Invalid json", createOriginalResponse().body!!) + } + + assertEquals( + "Failed to parse mapping rules. Checkout our documentation: " + + "https://github.com/LeonardoBai12-Org/ProjectMiddleware", + exception.message + ) + } } val json = Json { diff --git a/middleware/mapper/src/test/java/io/lb/middleware/mapper/service/RegularHelper.kt b/middleware/mapper/src/test/java/io/lb/middleware/mapper/service/RegularHelper.kt index 8665eea..4001b0f 100644 --- a/middleware/mapper/src/test/java/io/lb/middleware/mapper/service/RegularHelper.kt +++ b/middleware/mapper/src/test/java/io/lb/middleware/mapper/service/RegularHelper.kt @@ -2,6 +2,11 @@ package io.lb.middleware.mapper.service import io.lb.common.data.model.OriginalResponse +private const val INSTRUCTIONS = "Heat the oil in a pan. Add the chopped vegetables and sauté until tender. " + + "Take a pot, empty the beans together with the vegetables into it, put the sausages inside and cook " + + "for further 20 minutes on a low heat. Or, put it in an oven and bake it at a temperature of " + + "180ºC/350ºF for 30 minutes. This dish is even better reheated the next day." + internal fun createOriginalResponse(): OriginalResponse { return OriginalResponse( statusCode = 200, @@ -14,7 +19,7 @@ internal fun createOriginalResponse(): OriginalResponse { "strDrinkAlternate":null, "strCategory":"Beef", "strArea":"Croatian", - "strInstructions":"Heat the oil in a pan. Add the chopped vegetables and saut\u00e9 until tender. Take a pot, empty the beans together with the vegetables into it, put the sausages inside and cook for further 20 minutes on a low heat. Or, put it in an oven and bake it at a temperature of 180\u00baC\/350\u00baF for 30 minutes. This dish is even better reheated the next day.", + "strInstructions":"$INSTRUCTIONS", "strMealThumb":"https:\/\/www.themealdb.com\/images\/media\/meals\/tnwy8m1628770384.jpg", "strTags":"Warming, Soup, Beans", "strYoutube":"https:\/\/www.youtube.com\/watch?v=mrjnQal3S1A", @@ -280,3 +285,32 @@ internal fun getMappingRule(ignoreEmptyValues: Boolean = true) = } """.trimIndent() ).toString() + +internal fun createFlatOriginalResponse() = OriginalResponse( + statusCode = 200, + body = """ + { + "id": "101", + "title": "Pasta Carbonara", + "rating": "4.5", + "available": "true", + "category": "Italian" + } + """.trimIndent() +) + +internal fun createDeepNestedOriginalResponse() = OriginalResponse( + statusCode = 200, + body = """ + { + "data": { + "recipes": [ + { + "id": "200", + "name": "Tiramisu" + } + ] + } + } + """.trimIndent() +)