Skip to content

Commit faf0483

Browse files
committed
feat(cache): Implement data caching and persistence across modules
This commit introduces a caching layer using `CacheManager` to improve performance and provide offline support across the search, profile, and repository detail features. - **feat(profile)**: Implemented fetching and caching of the current user's profile in `ProfileRepositoryImpl`. - Added logic to `ProfileViewModel` to automatically load the user profile upon login and clear it upon logout. - Updated `SharedModule` and build dependencies to support Ktor and Coroutines in the profile data module. - **feat(search)**: Added caching for repository search results in `SearchRepositoryImpl` using a hashed query key. - **feat(details)**: Integrated caching for repository details, releases, README content, stats, and user profiles. - Implemented stale-cache fallbacks for several repository detail operations to maintain functionality during network failures. - **refactor**: Updated Koin modules across `profile`, `search`, and `details` features to inject the `CacheManager`.
1 parent 3db20f0 commit faf0483

8 files changed

Lines changed: 319 additions & 81 deletions

File tree

feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/di/SharedModule.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ val detailsModule = module {
99
DetailsRepositoryImpl(
1010
logger = get(),
1111
httpClient = get(),
12-
localizationManager = get()
12+
localizationManager = get(),
13+
cacheManager = get()
1314
)
1415
}
1516
}

feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt

Lines changed: 205 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import kotlinx.coroutines.CoroutineStart
99
import kotlinx.coroutines.async
1010
import kotlinx.coroutines.awaitAll
1111
import kotlinx.coroutines.coroutineScope
12+
import kotlinx.serialization.Serializable
13+
import zed.rainxch.core.data.cache.CacheManager
14+
import zed.rainxch.core.data.cache.CacheTtl
1215
import zed.rainxch.core.data.network.executeRequest
1316
import zed.rainxch.core.data.services.LocalizationManager
1417
import zed.rainxch.core.domain.model.GithubRelease
@@ -29,9 +32,17 @@ import zed.rainxch.details.domain.repository.DetailsRepository
2932
class DetailsRepositoryImpl(
3033
private val httpClient: HttpClient,
3134
private val localizationManager: LocalizationManager,
32-
private val logger: GitHubStoreLogger
35+
private val logger: GitHubStoreLogger,
36+
private val cacheManager: CacheManager
3337
) : DetailsRepository {
3438

39+
@Serializable
40+
private data class CachedReadme(
41+
val content: String,
42+
val languageCode: String?,
43+
val path: String
44+
)
45+
3546
private val readmeHelper = ReadmeLocalizationHelper(localizationManager)
3647

3748
private fun RepoByIdNetwork.toGithubRepoSummary(): GithubRepoSummary {
@@ -58,64 +69,132 @@ class DetailsRepositoryImpl(
5869
}
5970

6071
override suspend fun getRepositoryById(id: Long): GithubRepoSummary {
61-
return httpClient.executeRequest<RepoByIdNetwork> {
72+
val cacheKey = "details:repo_id:$id"
73+
74+
cacheManager.get<GithubRepoSummary>(cacheKey)?.let { cached ->
75+
logger.debug("Cache hit for repo id=$id")
76+
return cached
77+
}
78+
79+
val result = httpClient.executeRequest<RepoByIdNetwork> {
6280
get("/repositories/$id") {
6381
header(HttpHeaders.Accept, "application/vnd.github+json")
6482
}
6583
}.getOrThrow().toGithubRepoSummary()
84+
85+
cacheManager.put(cacheKey, result, CacheTtl.REPO_DETAILS)
86+
return result
6687
}
6788

6889
override suspend fun getRepositoryByOwnerAndName(owner: String, name: String): GithubRepoSummary {
69-
return httpClient.executeRequest<RepoByIdNetwork> {
70-
get("/repos/$owner/$name") {
71-
header(HttpHeaders.Accept, "application/vnd.github+json")
90+
val cacheKey = "details:repo:$owner/$name"
91+
92+
cacheManager.get<GithubRepoSummary>(cacheKey)?.let { cached ->
93+
logger.debug("Cache hit for repo $owner/$name")
94+
return cached
95+
}
96+
97+
return try {
98+
val result = httpClient.executeRequest<RepoByIdNetwork> {
99+
get("/repos/$owner/$name") {
100+
header(HttpHeaders.Accept, "application/vnd.github+json")
101+
}
102+
}.getOrThrow().toGithubRepoSummary()
103+
104+
cacheManager.put(cacheKey, result, CacheTtl.REPO_DETAILS)
105+
result
106+
} catch (e: Exception) {
107+
cacheManager.getStale<GithubRepoSummary>(cacheKey)?.let { stale ->
108+
logger.debug("Network error, using stale cache for $owner/$name")
109+
return stale
72110
}
73-
}.getOrThrow().toGithubRepoSummary()
111+
throw e
112+
}
74113
}
75114

76115
override suspend fun getLatestPublishedRelease(
77116
owner: String,
78117
repo: String,
79118
defaultBranch: String
80119
): GithubRelease? {
81-
val releases = httpClient.executeRequest<List<ReleaseNetwork>> {
82-
get("/repos/$owner/$repo/releases") {
83-
header(HttpHeaders.Accept, "application/vnd.github+json")
84-
parameter("per_page", 10)
85-
}
86-
}.getOrNull() ?: return null
120+
val cacheKey = "details:latest_release:$owner/$repo"
87121

88-
val latest = releases
89-
.asSequence()
90-
.filter { (it.draft != true) && (it.prerelease != true) }
91-
.maxByOrNull { it.publishedAt ?: it.createdAt ?: "" }
92-
?: return null
122+
cacheManager.get<GithubRelease>(cacheKey)?.let { cached ->
123+
logger.debug("Cache hit for latest release $owner/$repo")
124+
return cached
125+
}
93126

94-
return latest.copy(
95-
body = processReleaseBody(latest.body, owner, repo, defaultBranch)
96-
).toDomain()
127+
return try {
128+
val releases = httpClient.executeRequest<List<ReleaseNetwork>> {
129+
get("/repos/$owner/$repo/releases") {
130+
header(HttpHeaders.Accept, "application/vnd.github+json")
131+
parameter("per_page", 10)
132+
}
133+
}.getOrNull() ?: return null
134+
135+
val latest = releases
136+
.asSequence()
137+
.filter { (it.draft != true) && (it.prerelease != true) }
138+
.maxByOrNull { it.publishedAt ?: it.createdAt ?: "" }
139+
?: return null
140+
141+
val result = latest.copy(
142+
body = processReleaseBody(latest.body, owner, repo, defaultBranch)
143+
).toDomain()
144+
145+
cacheManager.put(cacheKey, result, CacheTtl.RELEASES)
146+
result
147+
} catch (e: Exception) {
148+
cacheManager.getStale<GithubRelease>(cacheKey)?.let { stale ->
149+
logger.debug("Network error, using stale cache for latest release $owner/$repo")
150+
return stale
151+
}
152+
throw e
153+
}
97154
}
98155

99156
override suspend fun getAllReleases(
100157
owner: String,
101158
repo: String,
102159
defaultBranch: String
103160
): List<GithubRelease> {
104-
val releases = httpClient.executeRequest<List<ReleaseNetwork>> {
105-
get("/repos/$owner/$repo/releases") {
106-
header(HttpHeaders.Accept, "application/vnd.github+json")
107-
parameter("per_page", 30)
161+
val cacheKey = "details:releases:$owner/$repo"
162+
163+
cacheManager.get<List<GithubRelease>>(cacheKey)?.let { cached ->
164+
if (cached.isNotEmpty()) {
165+
logger.debug("Cache hit for all releases $owner/$repo: ${cached.size} releases")
166+
return cached
108167
}
109-
}.getOrNull() ?: return emptyList()
110-
111-
return releases
112-
.filter { it.draft != true }
113-
.map { release ->
114-
release.copy(
115-
body = processReleaseBody(release.body, owner, repo, defaultBranch)
116-
).toDomain()
168+
}
169+
170+
return try {
171+
val releases = httpClient.executeRequest<List<ReleaseNetwork>> {
172+
get("/repos/$owner/$repo/releases") {
173+
header(HttpHeaders.Accept, "application/vnd.github+json")
174+
parameter("per_page", 30)
175+
}
176+
}.getOrNull() ?: return emptyList()
177+
178+
val result = releases
179+
.filter { it.draft != true }
180+
.map { release ->
181+
release.copy(
182+
body = processReleaseBody(release.body, owner, repo, defaultBranch)
183+
).toDomain()
184+
}
185+
.sortedByDescending { it.publishedAt }
186+
187+
if (result.isNotEmpty()) {
188+
cacheManager.put(cacheKey, result, CacheTtl.RELEASES)
189+
}
190+
result
191+
} catch (e: Exception) {
192+
cacheManager.getStale<List<GithubRelease>>(cacheKey)?.let { stale ->
193+
logger.debug("Network error, using stale cache for releases $owner/$repo")
194+
return stale
117195
}
118-
.sortedByDescending { it.publishedAt }
196+
throw e
197+
}
119198
}
120199

121200
private fun processReleaseBody(
@@ -142,6 +221,32 @@ class DetailsRepositoryImpl(
142221
owner: String,
143222
repo: String,
144223
defaultBranch: String
224+
): Triple<String, String?, String>? {
225+
val cacheKey = "details:readme:$owner/$repo"
226+
227+
cacheManager.get<CachedReadme>(cacheKey)?.let { cached ->
228+
logger.debug("Cache hit for readme $owner/$repo")
229+
return Triple(cached.content, cached.languageCode, cached.path)
230+
}
231+
232+
val result = fetchReadmeFromApi(owner, repo, defaultBranch)
233+
234+
if (result != null) {
235+
val cachedReadme = CachedReadme(
236+
content = result.first,
237+
languageCode = result.second,
238+
path = result.third
239+
)
240+
cacheManager.put(cacheKey, cachedReadme, CacheTtl.README)
241+
}
242+
243+
return result
244+
}
245+
246+
private suspend fun fetchReadmeFromApi(
247+
owner: String,
248+
repo: String,
249+
defaultBranch: String
145250
): Triple<String, String?, String>? {
146251
val attempts = readmeHelper.generateReadmeAttempts()
147252
val baseUrl = "https://raw.githubusercontent.com/$owner/$repo/$defaultBranch/"
@@ -263,40 +368,76 @@ class DetailsRepositoryImpl(
263368
}
264369

265370
override suspend fun getRepoStats(owner: String, repo: String): RepoStats {
266-
val info = httpClient.executeRequest<RepoInfoNetwork> {
267-
get("/repos/$owner/$repo") {
268-
header(HttpHeaders.Accept, "application/vnd.github+json")
269-
}
270-
}.getOrThrow()
371+
val cacheKey = "details:stats:$owner/$repo"
271372

272-
return RepoStats(
273-
stars = info.stars,
274-
forks = info.forks,
275-
openIssues = info.openIssues,
276-
)
373+
cacheManager.get<RepoStats>(cacheKey)?.let { cached ->
374+
logger.debug("Cache hit for repo stats $owner/$repo")
375+
return cached
376+
}
377+
378+
return try {
379+
val info = httpClient.executeRequest<RepoInfoNetwork> {
380+
get("/repos/$owner/$repo") {
381+
header(HttpHeaders.Accept, "application/vnd.github+json")
382+
}
383+
}.getOrThrow()
384+
385+
val result = RepoStats(
386+
stars = info.stars,
387+
forks = info.forks,
388+
openIssues = info.openIssues,
389+
)
390+
391+
cacheManager.put(cacheKey, result, CacheTtl.REPO_STATS)
392+
result
393+
} catch (e: Exception) {
394+
cacheManager.getStale<RepoStats>(cacheKey)?.let { stale ->
395+
logger.debug("Network error, using stale cache for stats $owner/$repo")
396+
return stale
397+
}
398+
throw e
399+
}
277400
}
278401

279402
override suspend fun getUserProfile(username: String): GithubUserProfile {
280-
val user = httpClient.executeRequest<UserProfileNetwork> {
281-
get("/users/$username") {
282-
header(HttpHeaders.Accept, "application/vnd.github+json")
403+
val cacheKey = "details:profile:$username"
404+
405+
cacheManager.get<GithubUserProfile>(cacheKey)?.let { cached ->
406+
logger.debug("Cache hit for user profile $username")
407+
return cached
408+
}
409+
410+
return try {
411+
val user = httpClient.executeRequest<UserProfileNetwork> {
412+
get("/users/$username") {
413+
header(HttpHeaders.Accept, "application/vnd.github+json")
414+
}
415+
}.getOrThrow()
416+
417+
val result = GithubUserProfile(
418+
id = user.id,
419+
login = user.login,
420+
name = user.name,
421+
bio = user.bio,
422+
avatarUrl = user.avatarUrl,
423+
htmlUrl = user.htmlUrl,
424+
followers = user.followers,
425+
following = user.following,
426+
publicRepos = user.publicRepos,
427+
location = user.location,
428+
company = user.company,
429+
blog = user.blog,
430+
twitterUsername = user.twitterUsername
431+
)
432+
433+
cacheManager.put(cacheKey, result, CacheTtl.USER_PROFILE)
434+
result
435+
} catch (e: Exception) {
436+
cacheManager.getStale<GithubUserProfile>(cacheKey)?.let { stale ->
437+
logger.debug("Network error, using stale cache for profile $username")
438+
return stale
283439
}
284-
}.getOrThrow()
285-
286-
return GithubUserProfile(
287-
id = user.id,
288-
login = user.login,
289-
name = user.name,
290-
bio = user.bio,
291-
avatarUrl = user.avatarUrl,
292-
htmlUrl = user.htmlUrl,
293-
followers = user.followers,
294-
following = user.following,
295-
publicRepos = user.publicRepos,
296-
location = user.location,
297-
company = user.company,
298-
blog = user.blog,
299-
twitterUsername = user.twitterUsername
300-
)
440+
throw e
441+
}
301442
}
302-
}
443+
}

feature/profile/data/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ kotlin {
1414
implementation(projects.feature.profile.domain)
1515

1616
implementation(libs.bundles.koin.common)
17+
implementation(libs.bundles.ktor.common)
18+
implementation(libs.kotlinx.coroutines.core)
1719
}
1820
}
1921

feature/profile/data/src/commonMain/kotlin/zed/rainxch/profile/data/di/SharedModule.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ val settingsModule = module {
88
single<ProfileRepository> {
99
ProfileRepositoryImpl(
1010
authenticationState = get(),
11-
tokenStore = get()
11+
tokenStore = get(),
12+
httpClient = get(),
13+
cacheManager = get(),
14+
logger = get()
1215
)
1316
}
1417
}

0 commit comments

Comments
 (0)