Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

implement error-metrics network call #10

Merged
merged 7 commits into from
Sep 18, 2024
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
1 change: 1 addition & 0 deletions core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ kotlin {
implementation(kotlin("test"))
implementation("com.russhwolf:multiplatform-settings-test:$settingsVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion")
implementation("io.ktor:ktor-client-mock:$ktorVersion")
}
}
val androidMain by getting {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.sourcepoint.mobile_core

import android.os.Build

actual class DeviceInformationConcrete actual constructor() : DeviceInformation {
override val osName: OSName = OSName.Android
override val osVersion = Build.VERSION.SDK_INT.toString()
override val deviceFamily = Build.MODEL ?: "model-unknown"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.sourcepoint.mobile_core.network

import com.sourcepoint.core.BuildConfig
import com.sourcepoint.mobile_core.network.requests.DefaultRequest
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals

class DefaultRequestTest {
@Test
fun containsTheRightAttributes() = runTest {
val request = DefaultRequest()
assertEquals("mobile-core-Android", request.scriptType)
assertEquals(BuildConfig.Version, request.scriptVersion)
assertEquals("prod", request.env)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@ package com.sourcepoint.mobile_core
// override val name = UIDevice.currentDevice.systemName()
// override val version: String = UIDevice.currentDevice.systemVersion
// override val settings: Settings = NSUserDefaultsSettings(delegate)
//}
//}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.sourcepoint.mobile_core

enum class OSName {
iOS,
tvOS,
Android
}

interface DeviceInformation {
val osName: OSName
val osVersion: String
val deviceFamily: String
}

expect class DeviceInformationConcrete(): DeviceInformation

val Device = DeviceInformationConcrete()
10 changes: 0 additions & 10 deletions core/src/commonMain/kotlin/com/sourcepoint/mobile_core/Platform.kt

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.sourcepoint.mobile_core.models

open class SPError(
val code: String = "sp_metric_generic_mobile-core_error",
val description: String = "Something went wrong in the Mobile Core",
open val campaignType: SPCampaignType? = null
): Exception(description)

open class SPUnknownNetworkError(path: String): SPError(
code = "sp_metric_unknown_network_error_${path}",
description = "Something went wrong while performing a request to $path.",
)

open class SPClientTimeout(
path: String,
timeoutInSeconds: Int,
): SPError(
code = "sp_metric_network_error_${path}_${timeoutInSeconds}",
description = "The SDK timedout before being able to complete the request in $timeoutInSeconds seconds.",
)

open class SPNetworkError(
statusCode: Int,
path: String,
override val campaignType: SPCampaignType? = null
): SPError(
code = "sp_metric_network_error_${path}_${statusCode}",
description = "The server responded with HTTP $statusCode.",
campaignType = campaignType
)

open class SPUnableToParseBodyError(
bodyName: String?,
): SPError(
code = "sp_metric_invalid_response_${bodyName}",
description = "The server responded with HTTP 200, but the body doesn't match the expected response type: $bodyName",
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.sourcepoint.mobile_core.network

import com.sourcepoint.mobile_core.models.SPCampaignType
import com.sourcepoint.mobile_core.network.requests.DefaultRequest
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class ErrorMetricsRequest(
val code: String,
val accountId: String,
val description: String,
val sdkVersion: String,
@SerialName("OSVersion") val osVersion: String,
val deviceFamily: String,
val propertyId: String,
val propertyName: String,
val campaignType: SPCampaignType?
): DefaultRequest()
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
package com.sourcepoint.mobile_core.network

import com.sourcepoint.core.BuildConfig
import com.sourcepoint.mobile_core.Device
import com.sourcepoint.mobile_core.DeviceInformation
import com.sourcepoint.mobile_core.models.SPError
import com.sourcepoint.mobile_core.models.SPNetworkError
import com.sourcepoint.mobile_core.models.SPPropertyName
import com.sourcepoint.mobile_core.models.SPUnableToParseBodyError
import com.sourcepoint.mobile_core.network.requests.ConsentStatusRequest
import com.sourcepoint.mobile_core.network.requests.MetaDataRequest
import com.sourcepoint.mobile_core.network.requests.MessagesRequest
Expand All @@ -9,81 +15,164 @@ import com.sourcepoint.mobile_core.network.responses.ConsentStatusResponse
import com.sourcepoint.mobile_core.network.responses.MessagesResponse
import com.sourcepoint.mobile_core.network.responses.MetaDataResponse
import io.ktor.client.HttpClient
import io.ktor.client.HttpClientConfig
import io.ktor.client.call.body
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.plugins.HttpResponseValidator
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.plugins.logging.SIMPLE
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.request
import io.ktor.http.URLBuilder
import io.ktor.http.path
import io.ktor.serialization.kotlinx.json.json
import kotlin.reflect.KSuspendFunction1

interface SPClient {
@Throws(Exception::class)
suspend fun getMetaData(campaigns: MetaDataRequest.Campaigns): MetaDataResponse

@Throws(Exception::class)
suspend fun getConsentStatus(authId: String?, metadata: ConsentStatusRequest.MetaData): ConsentStatusResponse

@Throws(Exception::class)
suspend fun getMessages(request: MessagesRequest): MessagesResponse

suspend fun errorMetrics(error: SPError)
}

class SourcepointClient(
val accountId: Int,
val propertyId: Int,
val propertyName: SPPropertyName,
private val http: HttpClient,
private val accountId: Int,
private val propertyId: Int,
private val propertyName: SPPropertyName,
httpEngine: HttpClientEngine?,
private val device: DeviceInformation,
private val version: String,
private val requestTimeoutInSeconds: Int
): SPClient {
constructor(accountId: Int, propertyId: Int, propertyName: SPPropertyName) : this(
private val config: HttpClientConfig<*>.() -> Unit = {
install(HttpTimeout) { requestTimeoutMillis = requestTimeoutInSeconds.toLong() * 1000 }
install(WrapHttpTimeoutError) { timeoutInSeconds = requestTimeoutInSeconds }
install(ContentNegotiation) { json(json) }
install(Logging) {
logger = Logger.SIMPLE
level = LogLevel.BODY
}
expectSuccess = false
HttpResponseValidator {
validateResponse { response ->
if (response.request.url.pathSegments.contains("custom-metrics")) {
return@validateResponse
}

if (response.status.value !in 200..299) {
throw reportErrorAndThrow(SPNetworkError(
statusCode = response.status.value,
path = response.request.url.pathSegments.last(),
campaignType = null
))
}
}
}
}
private val http = if (httpEngine != null) HttpClient(httpEngine, config) else HttpClient(config)

constructor(
accountId: Int,
propertyId: Int,
propertyName: SPPropertyName,
requestTimeoutInSeconds: Int = 5
) : this(
accountId,
propertyId,
propertyName,
HttpClient {
install(ContentNegotiation) { json(json) }
install(Logging) {
logger = Logger.SIMPLE
level = LogLevel.BODY
}
},
httpEngine = null,
device = Device,
version = BuildConfig.Version,
requestTimeoutInSeconds = requestTimeoutInSeconds
)

constructor(
accountId: Int,
propertyId: Int,
propertyName: SPPropertyName,
httpEngine: HttpClientEngine,
requestTimeoutInSeconds: Int = 5,
) : this(
accountId,
propertyId,
propertyName,
httpEngine = httpEngine,
device = Device,
version = BuildConfig.Version,
requestTimeoutInSeconds = requestTimeoutInSeconds
)

private val baseWrapperUrl = "https://cdn.privacy-mgmt.com/"

private fun getMetaDataUrl(campaigns: MetaDataRequest.Campaigns) =
URLBuilder(baseWrapperUrl)
.apply {
path("wrapper", "v2", "meta-data")
withParams(MetaDataRequest(accountId = accountId, propertyId = propertyId, metadata = campaigns))
}.build()

@Throws(Exception::class)
override suspend fun getMetaData(campaigns: MetaDataRequest.Campaigns): MetaDataResponse =
http.get(getMetaDataUrl(campaigns)).body()

private fun getConsentStatusUrl(authId: String?, metadata: ConsentStatusRequest.MetaData) =
URLBuilder(baseWrapperUrl)
.apply {
path("wrapper", "v2", "consent-status")
withParams(ConsentStatusRequest(propertyId = propertyId, authId = authId, metadata = metadata))
}.build()

@Throws(Exception::class)
override suspend fun getConsentStatus(authId: String?, metadata: ConsentStatusRequest.MetaData): ConsentStatusResponse =
http.get(getConsentStatusUrl(authId, metadata)).body()
override suspend fun getMetaData(campaigns: MetaDataRequest.Campaigns): MetaDataResponse = http.get(
URLBuilder(baseWrapperUrl).apply {
path("wrapper", "v2", "meta-data")
withParams(
MetaDataRequest(
accountId = accountId,
propertyId = propertyId,
metadata = campaigns
)
)
}.build()
).bodyOr(::reportErrorAndThrow)

private fun getMessagesUrl(request: MessagesRequest) =
URLBuilder(baseWrapperUrl)
.apply {
path("wrapper", "v2", "messages")
withParams(request)
}.build()
override suspend fun getConsentStatus(authId: String?, metadata: ConsentStatusRequest.MetaData): ConsentStatusResponse =
http.get(URLBuilder(baseWrapperUrl).apply {
path("wrapper", "v2", "consent-status")
withParams(
ConsentStatusRequest(
propertyId = propertyId,
authId = authId,
metadata = metadata
)
)}.build()
).bodyOr(::reportErrorAndThrow)

@Throws(Exception::class)
override suspend fun getMessages(request: MessagesRequest): MessagesResponse =
http.get(getMessagesUrl(request)).body()
http.get(URLBuilder(baseWrapperUrl).apply {
path("wrapper", "v2", "messages")
withParams(request)
}.build()).bodyOr(::reportErrorAndThrow)

override suspend fun errorMetrics(error: SPError) {
http.post(URLBuilder(baseWrapperUrl).apply {
path("wrapper", "metrics", "v1", "custom-metrics")
withParams(ErrorMetricsRequest(
accountId = accountId.toString(),
propertyId = propertyId.toString(),
propertyName = propertyName,
osVersion = device.osVersion,
deviceFamily = device.deviceFamily,
sdkVersion = version,
code = error.code,
description = error.description,
campaignType = error.campaignType
))
}.build())
}

private suspend fun reportErrorAndThrow(error: SPError): SPError {
errorMetrics(error)
return error
}
}

suspend inline fun <reified T> HttpResponse.bodyOr(loggingFunction: KSuspendFunction1<SPError, SPError>): T {
try {
return body()
} catch (_: Exception) {
throw loggingFunction(SPUnableToParseBodyError(bodyName = T::class.qualifiedName))
}
}

// Maps a Serializable class into query params using toQueryParams function
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.sourcepoint.mobile_core.network

import com.sourcepoint.mobile_core.models.SPClientTimeout
import com.sourcepoint.mobile_core.models.SPUnknownNetworkError
import io.ktor.client.plugins.HttpRequestTimeoutException
import io.ktor.client.plugins.api.Send
import io.ktor.client.plugins.api.createClientPlugin
import kotlin.coroutines.cancellation.CancellationException

class WrapHttpTimeoutErrorConfig {
var timeoutInSeconds: Int = 5
}

// Wraps CancellationException (HttpTimeoutException) into SPClientTimeout
val WrapHttpTimeoutError = createClientPlugin(name = "WrapHttpTimeoutError", ::WrapHttpTimeoutErrorConfig) {
on(Send) { request ->
try {
proceed(request)
} catch (error: Exception) {
val path = request.url.pathSegments.last()
when (error) {
is CancellationException,
is HttpRequestTimeoutException -> throw SPClientTimeout(
timeoutInSeconds = pluginConfig.timeoutInSeconds,
path = path
)
else -> throw SPUnknownNetworkError(path)
}
}
}
}
Loading
Loading