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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/coverage_verification_worflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -34,4 +34,4 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: Middleware-Coverage-Report
path: 'build/reports/jacoco/test/html/'
path: 'build/reports/jacoco/jacocoProjectMiddlewareCoverageReport/html/'
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,5 @@ bin/
.DS_Store

local.properties
**/ClientHiddenConstants.kt
**/ClientHiddenConstants.kt
CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ class JacocoModuleConventionPlugin : Plugin<Project> {
group = "verification"
description = "Validates $module coverage."

setupCoverageVerification()
setupCoverageVerification(this)
dependsOn(report)

if (isJvm()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ class JacocoMultiModuleConventionPlugin : Plugin<Project> {
group = "verification"
description = "Validates $module coverage."

setupCoverageVerification()
setupCoverageVerification(this)
dependsOn(report)

subprojects.forEach { subproject ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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))
}
}

Expand All @@ -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)
}
}
Expand All @@ -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",
Expand All @@ -193,27 +227,27 @@ 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",
"src/main/java",
"src/debug/kotlin",
"src/debug/java"
)
sourceDirectories.setFrom(files(sourceDirs))
additionalSourceDirs.setFrom(files(sourceDirs))
sourceDirectories.from(files(sourceDirs))
additionalSourceDirs.from(files(sourceDirs))
}
}

Expand Down
25 changes: 25 additions & 0 deletions documentation/MappingRequest.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>`.
- **`Basic`** — Sends `Authorization: Basic <token>`.

```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
Expand Down
3 changes: 2 additions & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
android.nonTransitiveRClass=true
minimumCoverage=0.62
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<JsonObject>()
} catch (e: Exception) {
Expand Down
Loading
Loading