Skip to content

Commit 735be6c

Browse files
authored
Merge pull request #1577 from DimensionDev/bugfix/mastodon_paging
fallback to last item id for mastodon paging cursor
2 parents 4fe413b + 0027899 commit 735be6c

File tree

4 files changed

+141
-22
lines changed

4 files changed

+141
-22
lines changed

gradle/libs.versions.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version
160160
ktor-client-darwin = { group = "io.ktor", name = "ktor-client-darwin", version.ref = "ktor" }
161161
ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" }
162162
ktor-client-curl = { group = "io.ktor", name = "ktor-client-curl", version.ref = "ktor" }
163+
ktor-client-mock = { group = "io.ktor", name = "ktor-client-mock", version.ref = "ktor" }
163164

164165
twitter-parser = { group = "moe.tlaster", name = "twitter-parser", version.ref = "twitter-parser" }
165166
swiper = { group = "com.github.tlaster", name = "swiper", version = "0.7.1" }

shared/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ kotlin {
8787
implementation(kotlin("test"))
8888
implementation(libs.kotlinx.coroutines.test)
8989
implementation(libs.paging.testing)
90+
implementation(libs.ktor.client.mock)
9091
}
9192
}
9293
val androidJvmMain by getting {

shared/src/commonMain/kotlin/dev/dimension/flare/data/network/mastodon/api/model/MastodonPaging.kt

Lines changed: 33 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package dev.dimension.flare.data.network.mastodon.api.model
22

33
import de.jensklingenberg.ktorfit.Ktorfit
4-
import de.jensklingenberg.ktorfit.Response
54
import de.jensklingenberg.ktorfit.converter.Converter
65
import de.jensklingenberg.ktorfit.converter.KtorfitResult
76
import de.jensklingenberg.ktorfit.converter.TypeData
@@ -17,19 +16,6 @@ internal class MastodonPaging<T>(
1716
val next: String? = null,
1817
val prev: String? = null,
1918
) : List<T> by data {
20-
companion object {
21-
fun <T> from(response: Response<List<T>>): MastodonPaging<T> {
22-
val link = response.headers["link"]
23-
val next = link?.let { "max_id=(\\d+)".toRegex().find(it) }?.groupValues?.getOrNull(1)
24-
val prev = link?.let { "min_id=(\\d+)".toRegex().find(it) }?.groupValues?.getOrNull(1)
25-
return MastodonPaging(
26-
data = response.body() ?: emptyList(),
27-
next = next,
28-
prev = prev,
29-
)
30-
}
31-
}
32-
3319
operator fun plus(other: List<T>): MastodonPaging<T> =
3420
MastodonPaging(
3521
data = this.data.plus(other),
@@ -53,9 +39,6 @@ internal class MastodonPagingConverterFactory : Converter.Factory {
5339
}
5440

5541
is KtorfitResult.Success -> {
56-
val link = result.response.headers["link"]
57-
val next = link?.let { "max_id=(\\d+)".toRegex().find(it) }?.groupValues?.getOrNull(1)
58-
val prev = link?.let { "min_id=(\\d+)".toRegex().find(it) }?.groupValues?.getOrNull(1)
5942
val body =
6043
result.response.bodyAsText().decodeJson(
6144
ListSerializer(
@@ -65,11 +48,39 @@ internal class MastodonPagingConverterFactory : Converter.Factory {
6548
.serializer(),
6649
),
6750
)
68-
MastodonPaging(
69-
data = body,
70-
next = next,
71-
prev = prev,
72-
)
51+
val link = result.response.headers["link"]
52+
if (result.response.headers.contains("link") && link != null) {
53+
val next =
54+
"max_id=(\\d+)"
55+
.toRegex()
56+
.find(link)
57+
?.groupValues
58+
?.getOrNull(1)
59+
val prev =
60+
"min_id=(\\d+)"
61+
.toRegex()
62+
.find(link)
63+
?.groupValues
64+
?.getOrNull(1)
65+
MastodonPaging(
66+
data = body,
67+
next = next,
68+
prev = prev,
69+
)
70+
} else {
71+
val next =
72+
when (val last = body.lastOrNull()) {
73+
is Status -> last.id
74+
is Notification -> last.id
75+
is Account -> last.id
76+
is MastodonList -> last.id
77+
else -> null
78+
}
79+
MastodonPaging(
80+
data = body,
81+
next = next,
82+
)
83+
}
7384
}
7485
}
7586
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package dev.dimension.flare.data.network.mastodon.api.model
2+
3+
import de.jensklingenberg.ktorfit.Ktorfit
4+
import de.jensklingenberg.ktorfit.converter.KtorfitResult
5+
import de.jensklingenberg.ktorfit.converter.TypeData
6+
import io.ktor.client.HttpClient
7+
import io.ktor.client.engine.mock.MockEngine
8+
import io.ktor.client.engine.mock.respond
9+
import io.ktor.client.request.get
10+
import io.ktor.client.statement.HttpResponse
11+
import io.ktor.http.Headers
12+
import io.ktor.http.HttpHeaders
13+
import io.ktor.http.HttpStatusCode
14+
import io.ktor.util.reflect.typeInfo
15+
import kotlinx.coroutines.ExperimentalCoroutinesApi
16+
import kotlinx.coroutines.test.runTest
17+
import kotlin.test.Test
18+
import kotlin.test.assertEquals
19+
import kotlin.test.assertNull
20+
21+
@OptIn(ExperimentalCoroutinesApi::class)
22+
class MastodonPagingConverterFactoryTest {
23+
private val ktorfit =
24+
Ktorfit
25+
.Builder()
26+
.baseUrl("https://example.com/")
27+
.build()
28+
29+
private val pagingType =
30+
TypeData.createTypeData(
31+
qualifiedTypename =
32+
MastodonPaging::class
33+
.qualifiedName
34+
?.let { pagingName ->
35+
Status::class.qualifiedName?.let { statusName -> "$pagingName<$statusName>" }
36+
}
37+
?: "",
38+
typeInfo = typeInfo<MastodonPaging<Status>>(),
39+
)
40+
41+
private val converter =
42+
MastodonPagingConverterFactory()
43+
.suspendResponseConverter(pagingType, ktorfit)
44+
?: error("Expected MastodonPaging converter")
45+
46+
@Test
47+
fun convert_extractsCursorValuesFromLinkHeader() =
48+
runTest {
49+
val response =
50+
createResponse(
51+
body = statusesJson("1", "2"),
52+
linkHeader =
53+
"<https://example.com/api?max_id=42>; rel=\"next\", <https://example.com/api?min_id=21>; rel=\"prev\"",
54+
)
55+
56+
val paging = converter.convert(KtorfitResult.Success(response))
57+
58+
val ids = paging.map { (it as? Status)?.id }
59+
assertEquals(listOf("1", "2"), ids)
60+
assertEquals("42", paging.next)
61+
assertEquals("21", paging.prev)
62+
}
63+
64+
@Test
65+
fun convert_fallsBackToLastItemIdWhenLinkMissing() =
66+
runTest {
67+
val response =
68+
createResponse(
69+
body = statusesJson("alpha", "beta", "cursor"),
70+
)
71+
72+
val paging = converter.convert(KtorfitResult.Success(response))
73+
74+
assertEquals("cursor", paging.next)
75+
assertNull(paging.prev)
76+
}
77+
78+
private suspend fun createResponse(
79+
body: String,
80+
linkHeader: String? = null,
81+
): HttpResponse {
82+
val headers =
83+
Headers.build {
84+
if (linkHeader != null) {
85+
append(HttpHeaders.Link, linkHeader)
86+
}
87+
}
88+
89+
val client =
90+
HttpClient(MockEngine) {
91+
engine {
92+
addHandler {
93+
respond(
94+
content = body,
95+
status = HttpStatusCode.OK,
96+
headers = headers,
97+
)
98+
}
99+
}
100+
}
101+
102+
return client.get("https://example.com")
103+
}
104+
105+
private fun statusesJson(vararg ids: String): String = ids.joinToString(prefix = "[", postfix = "]") { id -> "{\"id\":\"$id\"}" }
106+
}

0 commit comments

Comments
 (0)