Step 7 of 7
Build a Weather Library: Ktor API, SQLDelight cache, clean API for Android and iOS
Workshop — สร้าง Weather Library ที่ fetch API, cache ใน SQLDelight, expose ให้ Android+iOS
Build a complete Weather Library with KMP. It fetches weather from a public API (Ktor), caches it locally (SQLDelight), and exposes one clean API to Android and iOS targets. This workshop combines everything: expect/actual, networking, persistence, and a public SDK surface.
The API is Open-Meteo, which is free and needs no key.
A library with a single entry point:
val weather = WeatherService()
val result = weather.getWeather(40.71, -74.00) // New York
println(result.temperatureCelsius) // e.g. 18.3
The same call works on Android and iOS, with offline caching built in.
Use the KMP wizard. Pick Android and iOS targets, and choose the Library option (or convert a shared module to a library later). Name it WeatherLib.
// shared/build.gradle.kts (plugins)
plugins {
kotlin("multiplatform")
kotlin("plugin.serialization") version "2.4.10"
id("app.cash.sqldelight") version "2.1.0"
}
kotlin {
androidTarget { publishLibraryVariants("release") }
iosArm64(); iosX64(); iosSimulatorArm64()
sourceSets {
commonMain.dependencies {
implementation("io.ktor:ktor-client-core:3.2.0")
implementation("io.ktor:ktor-client-content-negotiation:3.2.0")
implementation("io.ktor:ktor-serialization-kotlinx-json:3.2.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
implementation("app.cash.sqldelight:coroutines-extensions:2.1.0")
}
androidMain.dependencies {
implementation("io.ktor:ktor-client-okhttp:3.2.0")
implementation("app.cash.sqldelight:android-driver:2.1.0")
}
iosMain.dependencies {
implementation("io.ktor:ktor-client-darwin:3.2.0")
implementation("app.cash.sqldelight:native-driver:2.1.0")
}
}
}
sqldelight {
databases {
create("WeatherDatabase") { packageName.set("com.weather.db") }
}
}
// commonMain
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class WeatherResponse(
val current: CurrentWeather,
)
@Serializable
data class CurrentWeather(
val temperature: Double,
@SerialName("windspeed") val windSpeed: Double,
)
data class Weather(
val temperatureCelsius: Double,
val windSpeed: Double,
)
WeatherResponse matches the JSON. Weather is the clean type your public API returns.
Create shared/src/commonMain/sqldelight/com/weather/db/Weather.sq:
CREATE TABLE CachedWeather (
latitude REAL NOT NULL,
longitude REAL NOT NULL,
temperature REAL NOT NULL,
windSpeed REAL NOT NULL,
updatedAt INTEGER NOT NULL,
PRIMARY KEY (latitude, longitude)
);
upsert:
INSERT OR REPLACE INTO CachedWeather(latitude, longitude, temperature, windSpeed, updatedAt)
VALUES (?, ?, ?, ?, ?);
selectByLocation:
SELECT * FROM CachedWeather
WHERE latitude = ? AND longitude = ?;
// commonMain
import app.cash.sqldelight.db.SqlDriver
expect class DatabaseDriverFactory {
fun createDriver(): SqlDriver
}
// androidMain
import android.content.Context
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
actual class DatabaseDriverFactory(private val context: Context) {
actual fun createDriver(): SqlDriver =
AndroidSqliteDriver(WeatherDatabase.Schema, context, "weather.db")
}
// iosMain
import app.cash.sqldelight.driver.native.NativeSqliteDriver
actual class DatabaseDriverFactory {
actual fun createDriver(): SqlDriver =
NativeSqliteDriver(WeatherDatabase.Schema, "weather.db")
}
// commonMain
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
open class WeatherApi {
private val client = HttpClient {
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
}
open suspend fun fetch(lat: Double, lon: Double): WeatherResponse =
client.get("https://api.open-meteo.com/v1/forecast") {
url {
parameters.append("latitude", lat.toString())
parameters.append("longitude", lon.toString())
parameters.append("current", "temperature_2m,wind_speed_10m")
}
}.body()
}
Mark the class and fetch as open so tests can subclass it with a fake.
The repository fetches fresh data, saves it, and returns cached data on failure (offline support).
// commonMain
class WeatherRepository(
private val api: WeatherApi,
private val driverFactory: DatabaseDriverFactory,
) {
private val db = WeatherDatabase(driverFactory.createDriver())
suspend fun getWeather(lat: Double, lon: Double): Weather = runCatching {
val response = api.fetch(lat, lon)
val now = currentTimeMillis()
db.cachedWeatherQueries.upsert(
lat, lon, response.current.temperature, response.current.windSpeed, now
)
response.current.toWeather()
}.getOrElse {
cached(lat, lon) ?: error("No weather and no cache")
}
private fun cached(lat: Double, lon: Double): Weather? =
db.cachedWeatherQueries.selectByLocation(lat, lon)
.executeAsList()
.lastOrNull()
?.let { Weather(it.temperature, it.windSpeed) }
}
private fun CurrentWeather.toWeather() = Weather(temperature, windSpeed)
Add expect fun currentTimeMillis(): Long in commonMain with actual implementations on Android (SystemClock) and iOS (NSDate).
// commonMain
class WeatherService(driverFactory: DatabaseDriverFactory? = null) {
private val repo = WeatherRepository(
api = WeatherApi(),
driverFactory = driverFactory ?: defaultDriverFactory(),
)
suspend fun getWeather(lat: Double, lon: Double): Weather =
repo.getWeather(lat, lon)
}
internal expect fun defaultDriverFactory(): DatabaseDriverFactory
Each platform supplies defaultDriverFactory() so callers do not need to wire the database.
Android:
// androidMain
val service = WeatherService(DatabaseDriverFactory(context))
val weather = service.getWeather(40.71, -74.00)
iOS (Swift):
// iosMain
class IosWeatherService {
private val service = WeatherService()
func getWeather(lat: Double, lon: Double) async -> Double {
await service.getWeather(lat, lon).temperatureCelsius
}
}
// iosApp (Swift)
let lib = IosWeatherService()
let temp = await lib.getWeather(lat: 40.71, lon: -74.00)
print(temp)
Test the offline fallback with a fake API and an in-memory driver. Keep the real HTTP client out of unit tests.
// commonTest
import kotlin.test.Test
import kotlin.test.assertEquals
class FakeWeatherApi(var response: WeatherResponse) : WeatherApi() {
override suspend fun fetch(lat: Double, lon: Double) = response
}
class WeatherRepositoryTest {
@Test
fun returnsFreshData() = runTest {
val api = FakeWeatherApi(WeatherResponse(CurrentWeather(20.0, 5.0)))
val repo = WeatherRepository(api, InMemoryDriverFactory())
val result = repo.getWeather(0.0, 0.0)
assertEquals(20.0, result.temperatureCelsius)
}
}
For the SqlDriver in tests, use app.cash.sqldelight.driver.jdbc.JdbcSqliteDriver (JDBC, commonTest with JVM target) or an in-memory fake. Test the repository logic, not the network.
Diagram: The Weather Library layers. The public service hides the repository, API, and cache from both app targets.
Loading diagram...
Forecast data class.getWeather(city: String) that geocodes with Open-Meteo first.observeWeather(lat, lon): Flow<Weather> that re-emits on cache updates.error(...) with a sealed WeatherResult (Success / Cached / Error).mavenPublishing to ship the library to a Maven repository.| Feature | Where you used it |
|---|---|
| Project setup | KMP wizard, library target |
| Serialization | @Serializable matching JSON keys |
| Networking | Ktor 3.x GET with query parameters |
| Persistence | SQLDelight .sq, INSERT OR REPLACE, per-platform drivers |
| expect/actual | DatabaseDriverFactory, currentTimeMillis, defaultDriverFactory |
| Offline support | runCatching with cache fallback |
| Clean API | WeatherService hiding internal layers |
| Testing | Fake API, repository logic, in-memory driver |
| Platform interop | Swift async wrapper calling Kotlin suspend |
Previous
Compose Multiplatform UI Sharing
Final step