Skip to content
This repository was archived by the owner on Sep 5, 2023. It is now read-only.

Commit 167cf39

Browse files
joselufoFran Montiel
joselufo
authored and
Fran Montiel
committed
Network error mappings improvements:
- Add NetworkConnectivityException to configureExceptionErrorMapping - Now configureExceptionErrorMapping sets expectSuccess = false - Changes on GenericNetworkDataSource - Add tests for network error mapping
1 parent 7df8c2c commit 167cf39

File tree

8 files changed

+210
-41
lines changed

8 files changed

+210
-41
lines changed

harmony-kotlin/build.gradle

+1
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ kotlin {
118118
dependencies {
119119
implementation kotlin('test-common')
120120
implementation kotlin('test-annotations-common')
121+
implementation("io.ktor:ktor-client-mock:$ktor_version")
121122
}
122123
}
123124

harmony-kotlin/src/commonMain/kotlin/com.harmony.kotlin/data/datasource/network/GenericNetworkDataSource.kt

+21-21
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import com.harmony.kotlin.data.datasource.DeleteDataSource
44
import com.harmony.kotlin.data.datasource.GetDataSource
55
import com.harmony.kotlin.data.datasource.PutDataSource
66
import com.harmony.kotlin.data.error.QueryNotSupportedException
7+
import com.harmony.kotlin.data.mapper.IdentityMapper
78
import com.harmony.kotlin.data.mapper.Mapper
89
import com.harmony.kotlin.data.query.Query
910
import io.ktor.client.HttpClient
@@ -12,23 +13,21 @@ import kotlinx.serialization.builtins.ListSerializer
1213
import kotlinx.serialization.builtins.serializer
1314
import kotlinx.serialization.json.Json
1415

15-
open class GetNetworkDataSource<T>(
16+
class GetNetworkDataSource<T>(
1617
private val url: String,
1718
private val httpClient: HttpClient,
1819
private val serializer: KSerializer<T>,
1920
private val json: Json,
2021
private val globalHeaders: List<Pair<String, String>> = emptyList(),
21-
private val exceptionMapper: Mapper<Exception, Exception> = GenericNetworkExceptionMapper()
22+
private val exceptionMapper: Mapper<Exception, Exception> = IdentityMapper()
2223
) : GetDataSource<T> {
2324

2425
/**
2526
* GET request returning an object
2627
*/
2728
override suspend fun get(query: Query): T {
28-
val response: String = try {
29+
val response: String = tryOrThrow(exceptionMapper) {
2930
executeGetRequest(query)
30-
} catch (e: Exception) {
31-
throw exceptionMapper.map(e)
3231
}
3332
return json.decodeFromString(serializer, response)
3433

@@ -38,11 +37,10 @@ open class GetNetworkDataSource<T>(
3837
* GET request returning a list of objects
3938
*/
4039
override suspend fun getAll(query: Query): List<T> {
41-
val response: String = try {
40+
val response: String = tryOrThrow(exceptionMapper) {
4241
executeGetRequest(query)
43-
} catch (e: Exception) {
44-
throw exceptionMapper.map(e)
4542
}
43+
4644
return json.decodeFromString(ListSerializer(serializer), response)
4745

4846
}
@@ -63,26 +61,25 @@ open class GetNetworkDataSource<T>(
6361
}
6462
}
6563

66-
open class PutNetworkDataSource<T>(
64+
class PutNetworkDataSource<T>(
6765
private val url: String,
6866
private val httpClient: HttpClient,
6967
private val serializer: KSerializer<T>,
7068
private val json: Json,
7169
private val globalHeaders: List<Pair<String, String>> = emptyList(),
72-
private val exceptionMapper: Mapper<Exception, Exception> = GenericNetworkExceptionMapper()
70+
private val exceptionMapper: Mapper<Exception, Exception> = IdentityMapper()
71+
7372
) : PutDataSource<T> {
7473

7574
/**
7675
* POST or PUT request returning an object
7776
* @throws IllegalArgumentException if both value and content-type of the query method are defined
7877
*/
7978
override suspend fun put(query: Query, value: T?): T {
80-
val response: String = try {
79+
val response: String = tryOrThrow(exceptionMapper) {
8180
validateQuery(query)
8281
.sanitizeContentType(value)
8382
.executeKtorRequest(httpClient = httpClient, baseUrl = url, globalHeaders = globalHeaders)
84-
} catch (e: Exception) {
85-
throw exceptionMapper.map(e)
8683
}
8784

8885
return if (serializer.descriptor != Unit.serializer().descriptor) {
@@ -97,14 +94,11 @@ open class PutNetworkDataSource<T>(
9794
* @throws IllegalArgumentException if both value and content-type of the query method are defined
9895
*/
9996
override suspend fun putAll(query: Query, value: List<T>?): List<T> {
100-
val response: String = try {
97+
val response: String = tryOrThrow(exceptionMapper) {
10198
validateQuery(query)
10299
.sanitizeContentType(value)
103100
.executeKtorRequest(httpClient = httpClient, baseUrl = url, globalHeaders = globalHeaders)
104-
} catch (e: Exception) {
105-
throw exceptionMapper.map(e)
106101
}
107-
108102
return if (serializer.descriptor != Unit.serializer().descriptor) {
109103
json.decodeFromString(ListSerializer(serializer), response)
110104
} else { // If Unit.serializer() is used is because we want to ignore the response and just return an empty list of Unit
@@ -153,17 +147,15 @@ class DeleteNetworkDataSource(
153147
private val url: String,
154148
private val httpClient: HttpClient,
155149
private val globalHeaders: List<Pair<String, String>> = emptyList(),
156-
private val exceptionMapper: Mapper<Exception, Exception> = GenericNetworkExceptionMapper()
150+
private val exceptionMapper: Mapper<Exception, Exception> = IdentityMapper()
157151
) : DeleteDataSource {
158152

159153
/**
160154
* DELETE request
161155
*/
162156
override suspend fun delete(query: Query) {
163-
try {
157+
tryOrThrow(exceptionMapper) {
164158
validateQuery(query).executeKtorRequest(httpClient = httpClient, baseUrl = url, globalHeaders = globalHeaders)
165-
} catch (e: Exception) {
166-
throw exceptionMapper.map(e)
167159
}
168160
}
169161

@@ -179,3 +171,11 @@ class DeleteNetworkDataSource(
179171
return query
180172
}
181173
}
174+
175+
private suspend fun <T> tryOrThrow(exceptionMapper: Mapper<Exception, Exception>, block: suspend () -> T): T {
176+
return try {
177+
block()
178+
} catch (e: Exception) {
179+
throw exceptionMapper.map(e)
180+
}
181+
}

harmony-kotlin/src/commonMain/kotlin/com.harmony.kotlin/data/datasource/network/GenericNetworkExceptionMapper.kt

-16
This file was deleted.

harmony-kotlin/src/commonMain/kotlin/com.harmony.kotlin/data/datasource/network/ktor/KtorConfig.kt

+19-3
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,40 @@ import com.harmony.kotlin.common.exceptions.tryOrNull
44
import com.harmony.kotlin.data.datasource.network.DefaultUnauthorizedResolution
55
import com.harmony.kotlin.data.datasource.network.UnauthorizedResolution
66
import com.harmony.kotlin.data.datasource.network.error.HttpException
7+
import com.harmony.kotlin.data.datasource.network.error.NetworkConnectivityException
78
import com.harmony.kotlin.data.error.DataException
89
import com.harmony.kotlin.data.error.DataNotFoundException
910
import com.harmony.kotlin.data.error.UnauthorizedException
1011
import io.ktor.client.HttpClientConfig
11-
import io.ktor.client.call.receive
1212
import io.ktor.client.features.HttpResponseValidator
1313
import io.ktor.client.statement.HttpResponse
14+
import io.ktor.client.statement.readText
1415
import io.ktor.http.HttpStatusCode
1516
import io.ktor.http.isSuccess
17+
import io.ktor.utils.io.charsets.Charsets
18+
import io.ktor.utils.io.errors.IOException
1619

20+
/**
21+
* Helper that configures the error mapping by:
22+
* - Setting expectSuccess to false
23+
* - Adding a HttpResponseValidator used to map errors
24+
*/
1725
fun HttpClientConfig<*>.configureExceptionErrorMapping(unauthorizedResolution: UnauthorizedResolution = DefaultUnauthorizedResolution) {
26+
expectSuccess = false
27+
1828
HttpResponseValidator {
1929
validateResponse { response ->
2030
val httpStatus = response.status
2131
if (!httpStatus.isSuccess()) {
2232
throw response.toDataException(unauthorizedResolution)
2333
}
2434
}
35+
36+
handleResponseException {
37+
if (it is IOException) {
38+
throw NetworkConnectivityException(cause = it)
39+
}
40+
}
2541
}
2642
}
2743

@@ -39,8 +55,8 @@ suspend fun HttpResponse.toDataException(unauthorizedResolution: UnauthorizedRes
3955
}
4056
}
4157

42-
suspend fun HttpResponse.contentAsString(): String? {
58+
private suspend fun HttpResponse.contentAsString(): String? {
4359
return tryOrNull {
44-
this.receive<String>()
60+
this.readText(Charsets.UTF_8)
4561
}
4662
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.harmony.kotlin.data.mapper
2+
3+
/**
4+
* Mapper that returns the same object obtained
5+
*/
6+
class IdentityMapper<T> : Mapper<T, T> {
7+
override fun map(from: T): T {
8+
return from
9+
}
10+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package com.harmony.kotlin.data.datasource.network
2+
3+
import com.harmony.kotlin.data.datasource.network.ktor.configureExceptionErrorMapping
4+
import io.ktor.client.HttpClient
5+
import io.ktor.client.engine.mock.MockEngine
6+
import io.ktor.client.engine.mock.respond
7+
import io.ktor.client.features.json.JsonFeature
8+
import io.ktor.client.features.json.serializer.KotlinxSerializer
9+
import io.ktor.client.request.request
10+
import io.ktor.client.request.url
11+
import io.ktor.http.ContentType
12+
import io.ktor.http.Headers
13+
import io.ktor.http.HttpMethod
14+
import io.ktor.http.HttpStatusCode
15+
import io.ktor.http.Url
16+
import io.ktor.http.headersOf
17+
import kotlinx.serialization.json.Json
18+
19+
class ApiMock {
20+
private val client = HttpClient(ApiMockEngine().get()) {
21+
install(JsonFeature) {
22+
serializer = KotlinxSerializer(
23+
Json {
24+
isLenient = false
25+
ignoreUnknownKeys = true
26+
}
27+
)
28+
}
29+
30+
configureExceptionErrorMapping()
31+
}
32+
33+
suspend fun executeRequest(request: MockRequest) {
34+
request.executeRequest(client)
35+
}
36+
37+
}
38+
39+
private class ApiMockEngine {
40+
fun get() = client.engine
41+
42+
private val client = HttpClient(MockEngine) {
43+
val mockRequests = listOf(
44+
UnauthorizedRequest,
45+
NotFoundRequest,
46+
BadRequest
47+
)
48+
49+
engine {
50+
addHandler { request ->
51+
try {
52+
val mockRequest = mockRequests.first {
53+
it.url == request.url
54+
}
55+
respond(mockRequest.responseBody, mockRequest.statusCode, mockRequest.responseHeaders)
56+
} catch (_: Exception) {
57+
error("Unhandled ${request.url.encodedPath}")
58+
}
59+
}
60+
}
61+
}
62+
}
63+
64+
interface MockRequest {
65+
val method: HttpMethod
66+
val url: Url
67+
val responseHeaders: Headers
68+
get() {
69+
return headersOf("Content-Type" to listOf(ContentType.Application.Json.toString()))
70+
}
71+
val responseBody: String
72+
get() = ""
73+
val statusCode: HttpStatusCode
74+
75+
suspend fun executeRequest(client: HttpClient) {
76+
client.request<String> {
77+
method = HttpMethod.Get
78+
url(this@MockRequest.url)
79+
}
80+
}
81+
}
82+
83+
object UnauthorizedRequest : MockRequest {
84+
override val method: HttpMethod = HttpMethod.Get
85+
override val url = Url("https://mockrequest.com/unauthorized")
86+
override val statusCode: HttpStatusCode = HttpStatusCode.Unauthorized
87+
}
88+
89+
object NotFoundRequest : MockRequest {
90+
override val method: HttpMethod = HttpMethod.Get
91+
override val url = Url("https://mockrequest.com/not_found")
92+
override val statusCode: HttpStatusCode = HttpStatusCode.NotFound
93+
}
94+
95+
object BadRequest : MockRequest {
96+
override val method: HttpMethod = HttpMethod.Get
97+
override val url = Url("https://mockrequest.com/bad_request")
98+
override val responseBody: String = "bad_request"
99+
override val statusCode: HttpStatusCode = HttpStatusCode.BadRequest
100+
}
101+
102+
object InvalidJsonResponseRequest : MockRequest {
103+
override val method: HttpMethod = HttpMethod.Get
104+
override val url = Url("https://mockrequest.com/invalid_json_response")
105+
override val responseBody: String = "{ \"fo_o\": \"bar\" }"
106+
override val statusCode: HttpStatusCode = HttpStatusCode.OK
107+
}
108+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
@file:Suppress("IllegalIdentifier")
2+
3+
package com.harmony.kotlin.data.datasource.network
4+
5+
import com.harmony.kotlin.common.BaseTest
6+
import com.harmony.kotlin.data.datasource.network.error.HttpException
7+
import com.harmony.kotlin.data.error.DataNotFoundException
8+
import com.harmony.kotlin.data.error.UnauthorizedException
9+
import kotlin.test.Test
10+
import kotlin.test.assertEquals
11+
import kotlin.test.assertFailsWith
12+
import kotlin.test.fail
13+
14+
class NetworkErrorMappingTests : BaseTest() {
15+
16+
private val apiMock: ApiMock = ApiMock()
17+
18+
@Test
19+
fun `should throw UnauthorizedException when backend returns 401`() {
20+
assertFailsWith<UnauthorizedException> {
21+
runTest {
22+
apiMock.executeRequest(UnauthorizedRequest)
23+
}
24+
}
25+
}
26+
27+
@Test
28+
fun `should throw DataNotFound when backend returns 404`() {
29+
assertFailsWith<DataNotFoundException> {
30+
runTest {
31+
apiMock.executeRequest(NotFoundRequest)
32+
}
33+
}
34+
}
35+
36+
@Test
37+
fun `should throw HttpException when backend returns any 40X (minus 401 & 404) & 50X`() {
38+
try {
39+
runTest {
40+
apiMock.executeRequest(BadRequest)
41+
}
42+
} catch (e: HttpException) {
43+
assertEquals(e.statusCode, BadRequest.statusCode.value)
44+
assertEquals(e.response, BadRequest.responseBody)
45+
} catch (e: Exception) {
46+
fail()
47+
}
48+
}
49+
}

sample-core/src/commonMain/kotlin/com/mobilejazz/kmmsample/core/NetworkProvider.kt

+2-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.mobilejazz.kmmsample.core
22

33
import com.harmony.kotlin.common.logger.KtorHarmonyLogger
44
import com.harmony.kotlin.common.logger.Logger
5+
import com.harmony.kotlin.data.datasource.network.ktor.configureExceptionErrorMapping
56
import io.ktor.client.HttpClient
67
import io.ktor.client.features.json.JsonFeature
78
import io.ktor.client.features.json.serializer.KotlinxSerializer
@@ -44,7 +45,7 @@ class NetworkDefaultModule(private val coreLogger: Logger) : NetworkComponent {
4445
level = LogLevel.HEADERS
4546
}
4647

47-
expectSuccess = false
48+
configureExceptionErrorMapping()
4849
}
4950
}
5051
}

0 commit comments

Comments
 (0)