10 · Project — Weather CLI¶
The Level 1 capstone was a single .kt file compiled straight with
kotlinc. This one is a real Gradle project: multiple source files, two
external dependencies, a test suite, and live network calls. It pulls
together nearly everything in Level 2 —
coroutines for concurrent HTTP,
advanced null safety for API fields that may
simply not be there, collections for
reshaping the response, sealed types for modelling
per-city outcomes, kotlinx.serialization for
parsing, Gradle for building, and
tests for the parts that don't need a
network.
What you'll build¶
A weather command that takes one or more city names and prints current
conditions plus a three-day outlook for each:
- Resolves each city name to coordinates via Open-Meteo's geocoding API
- Fetches the forecast for those coordinates
- Runs every city's lookup concurrently, not one after another
- Handles "city doesn't exist" and "network blew up" as ordinary results rather than crashes
- Reports which of the requested cities is warmest right now
The API¶
Open-Meteo is free for non-commercial use and needs no API key — which is exactly why it's a good teaching target. Two endpoints are involved.
Geocoding, to turn a name into coordinates:
{"results":[{"id":1269843,"name":"Hyderabad","latitude":17.38405,
"longitude":78.45636,"elevation":515.0,"country_code":"IN",
"timezone":"Asia/Kolkata","population":6993262,"country":"India",
"admin1":"Telangana","admin2":"Hyderabad District"}],
"generationtime_ms":0.386}
Forecast, to get the weather at those coordinates:
https://api.open-meteo.com/v1/forecast?latitude=51.5&longitude=-0.13
¤t=temperature_2m,wind_speed_10m,weather_code&timezone=auto
{"latitude":51.51,"longitude":-0.13,"timezone":"Europe/London",
"current":{"time":"2026-08-04T15:15","interval":900,
"temperature_2m":27.7,"wind_speed_10m":21.2,"weather_code":3}}
Two details shape the whole design. First, when nothing matches a search
the results key is absent entirely — not an empty list — so the
Kotlin field has to be List<Place>? with a default. Second, the daily
forecast comes back as parallel arrays (time, temperature_2m_max,
temperature_2m_min, weather_code), not a list of day objects, so
something has to zip them back together.
Project layout¶
weather-cli/
build.gradle.kts
settings.gradle.kts
src/
main/kotlin/com/example/weather/
Models.kt -- @Serializable API shapes + the sealed result type
Format.kt -- pure functions: codes, dates, rendering
WeatherApi.kt -- suspend functions that do the HTTP
Main.kt -- argument parsing + concurrent fetch
test/kotlin/com/example/weather/
WeatherTest.kt
build.gradle.kts¶
plugins {
kotlin("jvm") version "2.4.10"
kotlin("plugin.serialization") version "2.4.10" // the serialization compiler plugin
application
}
version = "1.0.0"
repositories {
mavenCentral()
}
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
testImplementation(kotlin("test"))
}
application {
mainClass.set("com.example.weather.MainKt")
}
tasks.test {
useJUnitPlatform()
testLogging {
events("passed", "failed") // print one line per test
}
}
There's no HTTP library dependency — the JDK's built-in
java.net.http.HttpClient is enough.
Models.kt — the shapes¶
package com.example.weather
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** One match from the geocoding API. `country`/`admin1` are often absent. */
@Serializable
data class Place(
val name: String,
val latitude: Double,
val longitude: Double,
val country: String? = null,
val admin1: String? = null,
) {
/** "London, England, United Kingdom" -- skipping whichever parts are null. */
val label: String
get() = listOfNotNull(name, admin1, country).joinToString(", ")
}
/** `results` is missing entirely -- not an empty list -- when nothing matches. */
@Serializable
data class GeocodingResponse(val results: List<Place>? = null)
@Serializable
data class CurrentWeather(
val time: String,
@SerialName("temperature_2m") val temperature: Double,
@SerialName("wind_speed_10m") val windSpeed: Double,
@SerialName("weather_code") val weatherCode: Int,
)
/** The API returns parallel arrays, one entry per day, not a list of objects. */
@Serializable
data class DailyBlock(
val time: List<String>,
@SerialName("temperature_2m_max") val maxTemps: List<Double>,
@SerialName("temperature_2m_min") val minTemps: List<Double>,
@SerialName("weather_code") val weatherCodes: List<Int>,
)
@Serializable
data class ForecastResponse(
val timezone: String,
val current: CurrentWeather,
val daily: DailyBlock,
)
/** One day, after the parallel arrays have been zipped back together. */
data class DayForecast(
val date: String,
val minTemp: Double,
val maxTemp: Double,
val weatherCode: Int,
)
/** What the CLI actually prints: a place plus its weather. */
data class Report(val place: Place, val forecast: ForecastResponse)
/**
* A per-city outcome. Sealed, so the `when` in `render()` is exhaustive and
* the compiler will flag any new case we forget to handle.
*/
sealed interface CityResult {
data class Success(val report: Report) : CityResult
data class NotFound(val query: String) : CityResult
data class Failed(val query: String, val reason: String) : CityResult
}
@SerialName maps snake_case JSON keys onto idiomatic Kotlin names, so
nothing downstream has to say temperature_2m. listOfNotNull(...) in
label is the null-safety idiom doing real work: a city with no admin1
just prints one fewer comma-separated part, with no if ladder.
Format.kt — pure functions¶
Everything here is deterministic and network-free, which is what makes the test suite easy.
package com.example.weather
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Locale
/** WMO weather interpretation codes -- the subset Open-Meteo actually emits. */
private val WEATHER_CODES: Map<Int, String> = mapOf(
0 to "Clear sky", 1 to "Mainly clear", 2 to "Partly cloudy", 3 to "Overcast",
45 to "Fog", 48 to "Depositing rime fog",
51 to "Light drizzle", 53 to "Moderate drizzle", 55 to "Dense drizzle",
61 to "Slight rain", 63 to "Moderate rain", 65 to "Heavy rain",
71 to "Slight snow", 73 to "Moderate snow", 75 to "Heavy snow",
80 to "Rain showers", 81 to "Moderate rain showers", 82 to "Violent rain showers",
95 to "Thunderstorm", 96 to "Thunderstorm with hail",
)
fun describeWeatherCode(code: Int): String = WEATHER_CODES[code] ?: "Unknown ($code)"
private val DAY_FORMAT: DateTimeFormatter =
DateTimeFormatter.ofPattern("EEE dd MMM", Locale.ENGLISH)
fun formatDate(isoDate: String): String =
runCatching { LocalDate.parse(isoDate).format(DAY_FORMAT) }.getOrDefault(isoDate)
/**
* Zip the API's four parallel arrays back into one object per day, stopping at
* the shortest array so a truncated response can't blow up with an index error.
*/
fun DailyBlock.toDays(): List<DayForecast> {
val count = minOf(time.size, minTemps.size, maxTemps.size, weatherCodes.size)
return (0 until count).map { i ->
DayForecast(time[i], minTemps[i], maxTemps[i], weatherCodes[i])
}
}
fun Double.asTemp(): String = "%.1f°C".format(Locale.ENGLISH, this)
fun Report.render(): String = buildString {
appendLine("${place.label} (${forecast.timezone})")
appendLine(
" Now: ${forecast.current.temperature.asTemp()}, " +
"${describeWeatherCode(forecast.current.weatherCode)}, " +
"wind %.1f km/h".format(Locale.ENGLISH, forecast.current.windSpeed)
)
forecast.daily.toDays().forEach { day ->
appendLine(
" ${formatDate(day.date)} " +
"${day.minTemp.asTemp()} - ${day.maxTemp.asTemp()} " +
describeWeatherCode(day.weatherCode)
)
}
}.trimEnd()
/** Exhaustive over the sealed CityResult -- no `else` branch needed. */
fun CityResult.render(): String = when (this) {
is CityResult.Success -> report.render()
is CityResult.NotFound -> "$query: no matching place found"
is CityResult.Failed -> "$query: lookup failed ($reason)"
}
/** Warmest city among the successful lookups, or null if none succeeded. */
fun List<CityResult>.warmest(): Report? =
filterIsInstance<CityResult.Success>()
.map { it.report }
.maxByOrNull { it.forecast.current.temperature }
warmest() is a three-line pipeline that would be a loop with a
bestSoFar variable in most languages: filterIsInstance narrows the
sealed type and casts in one step, and maxByOrNull returns null
rather than throwing on an empty list, which the caller then handles with
an Elvis.
WeatherApi.kt — the coroutine layer¶
package com.example.weather
import java.net.URI
import java.net.URLEncoder
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.nio.charset.StandardCharsets
import java.time.Duration
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
class WeatherApi(
private val client: HttpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build(),
) {
// Open-Meteo sends far more fields than we model, so be lenient.
private val json = Json { ignoreUnknownKeys = true }
/** Blocking HTTP, moved off the caller's thread onto the IO dispatcher. */
private suspend fun get(url: String): String = withContext(Dispatchers.IO) {
val request = HttpRequest.newBuilder(URI.create(url))
.timeout(Duration.ofSeconds(15))
.header("Accept", "application/json")
.GET()
.build()
val response = client.send(request, HttpResponse.BodyHandlers.ofString())
if (response.statusCode() != 200) {
throw WeatherApiException("HTTP ${response.statusCode()} from ${URI.create(url).host}")
}
response.body()
}
/** Returns null when the API has no match for [city] -- not an exception. */
suspend fun geocode(city: String): Place? {
val encoded = URLEncoder.encode(city, StandardCharsets.UTF_8)
val body = get("$GEOCODING_URL?name=$encoded&count=1&language=en&format=json")
return json.decodeFromString<GeocodingResponse>(body).results?.firstOrNull()
}
suspend fun forecast(place: Place, days: Int = 3): ForecastResponse {
val body = get(
buildString {
append(FORECAST_URL)
append("?latitude=${place.latitude}&longitude=${place.longitude}")
append("¤t=temperature_2m,wind_speed_10m,weather_code")
append("&daily=temperature_2m_max,temperature_2m_min,weather_code")
append("&forecast_days=$days&timezone=auto")
}
)
return json.decodeFromString<ForecastResponse>(body)
}
/** Geocode then forecast -- the two calls a single city needs. */
suspend fun lookup(city: String, days: Int = 3): CityResult =
try {
val place = geocode(city)
if (place == null) {
CityResult.NotFound(city)
} else {
CityResult.Success(Report(place, forecast(place, days)))
}
} catch (e: Exception) {
CityResult.Failed(city, e.message ?: e::class.simpleName ?: "unknown error")
}
companion object {
const val GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search"
const val FORECAST_URL = "https://api.open-meteo.com/v1/forecast"
}
}
class WeatherApiException(message: String) : Exception(message)
HttpClient.send() blocks the calling thread, so it's wrapped in
withContext(Dispatchers.IO) — the pattern from
Module 3 for making a blocking API safe to call
from a coroutine. Note the two different failure styles: a city that
doesn't exist is a normal null/NotFound value, while a broken network
is an exception, caught once in lookup and converted into a Failed
result. Nothing above this layer ever needs a try/catch.
Main.kt — fetching concurrently¶
package com.example.weather
import kotlin.system.exitProcess
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runBlocking
private const val USAGE = "Usage: weather <city> [city ...]"
/** Fetch every city concurrently instead of one after another. */
suspend fun fetchAll(api: WeatherApi, cities: List<String>): List<CityResult> =
coroutineScope {
cities.map { city -> async { api.lookup(city) } }.awaitAll()
}
fun main(args: Array<String>) = runBlocking {
val cities = args.filter { it.isNotBlank() }
if (cities.isEmpty()) {
println(USAGE)
exitProcess(1)
}
val results = fetchAll(WeatherApi(), cities)
results.forEach { println(it.render()) }
if (cities.size > 1) {
val warmest = results.warmest()
println(
if (warmest == null) "No city could be resolved."
else "Warmest right now: ${warmest.place.name} " +
"at ${warmest.forecast.current.temperature.asTemp()}"
)
}
if (results.any { it !is CityResult.Success }) exitProcess(2)
}
fetchAll is the heart of it: map { async { ... } } starts every city's
lookup immediately, and awaitAll() waits for all of them. Three cities
take about as long as the slowest one, not the sum of all three — but
awaitAll still returns results in argument order, so output stays
predictable. Because it's a coroutineScope, a crash in one lookup would
cancel the rest instead of leaking a runaway coroutine.
WeatherTest.kt — testing without the network¶
The pure functions and the parsing are all testable offline; only
WeatherApi.get() touches the internet.
package com.example.weather
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlinx.serialization.json.Json
class WeatherTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun `known weather codes map to descriptions`() {
assertEquals("Clear sky", describeWeatherCode(0))
assertEquals("Overcast", describeWeatherCode(3))
assertEquals("Thunderstorm", describeWeatherCode(95))
}
@Test
fun `unknown weather codes degrade gracefully`() {
assertEquals("Unknown (999)", describeWeatherCode(999))
}
@Test
fun `parallel daily arrays zip into one object per day`() {
val block = DailyBlock(
time = listOf("2026-08-04", "2026-08-05"),
maxTemps = listOf(29.4, 24.4),
minTemps = listOf(20.0, 18.3),
weatherCodes = listOf(3, 61),
)
val days = block.toDays()
assertEquals(2, days.size)
assertEquals(DayForecast("2026-08-04", 20.0, 29.4, 3), days[0])
assertEquals(61, days[1].weatherCode)
}
@Test
fun `a truncated daily block stops at the shortest array`() {
val block = DailyBlock(
time = listOf("2026-08-04", "2026-08-05"),
maxTemps = listOf(29.4), // one value short
minTemps = listOf(20.0, 18.3),
weatherCodes = listOf(3, 61),
)
assertEquals(1, block.toDays().size) // no IndexOutOfBoundsException
}
@Test
fun `place label skips null parts`() {
assertEquals(
"London, England, United Kingdom",
Place("London", 51.5, -0.13, country = "United Kingdom", admin1 = "England").label,
)
assertEquals("Atlantis", Place("Atlantis", 0.0, 0.0).label)
}
@Test
fun `geocoding response with no results decodes to null`() {
val body = """{"generationtime_ms":0.6}"""
val decoded = json.decodeFromString<GeocodingResponse>(body)
assertNull(decoded.results?.firstOrNull())
}
@Test
fun `forecast json decodes into the model`() {
val body = """
{"latitude":51.51,"longitude":-0.13,"elevation":16.0,"timezone":"Europe/London",
"current":{"time":"2026-08-04T10:30","interval":900,"temperature_2m":25.3,
"wind_speed_10m":8.6,"weather_code":2},
"daily":{"time":["2026-08-04"],"temperature_2m_max":[29.4],
"temperature_2m_min":[20.0],"weather_code":[3]}}
""".trimIndent()
val forecast = json.decodeFromString<ForecastResponse>(body)
assertEquals(25.3, forecast.current.temperature)
assertEquals("Partly cloudy", describeWeatherCode(forecast.current.weatherCode))
assertEquals(1, forecast.daily.toDays().size)
}
@Test
fun `failed and not-found results render without throwing`() {
assertEquals("Narnia: no matching place found", CityResult.NotFound("Narnia").render())
assertTrue(CityResult.Failed("Paris", "timeout").render().contains("timeout"))
}
@Test
fun `warmest picks the highest current temperature and ignores failures`() {
val results = listOf(
CityResult.Success(sampleReport("Oslo", 14.2)),
CityResult.NotFound("Narnia"),
CityResult.Success(sampleReport("Cairo", 36.8)),
)
assertEquals("Cairo", results.warmest()?.place?.name)
assertNull(listOf<CityResult>(CityResult.NotFound("Narnia")).warmest())
}
@Test
fun `dates format as English day labels`() {
assertEquals("Tue 04 Aug", formatDate("2026-08-04"))
assertEquals("not-a-date", formatDate("not-a-date")) // falls back to the raw string
}
private fun sampleReport(city: String, temp: Double) = Report(
place = Place(city, 0.0, 0.0),
forecast = ForecastResponse(
timezone = "UTC",
current = CurrentWeather("2026-08-04T10:30", temp, 5.0, 0),
daily = DailyBlock(listOf("2026-08-04"), listOf(temp), listOf(temp - 5), listOf(0)),
),
)
}
That extra "interval":900 field in the test JSON is deliberate: it isn't
in CurrentWeather, and the test proves ignoreUnknownKeys = true copes
with the real API sending more than you modelled.
Running it¶
> Task :test
WeatherTest > forecast json decodes into the model() PASSED
WeatherTest > warmest picks the highest current temperature and ignores failures() PASSED
WeatherTest > known weather codes map to descriptions() PASSED
WeatherTest > a truncated daily block stops at the shortest array() PASSED
WeatherTest > parallel daily arrays zip into one object per day() PASSED
WeatherTest > dates format as English day labels() PASSED
WeatherTest > failed and not-found results render without throwing() PASSED
WeatherTest > unknown weather codes degrade gracefully() PASSED
WeatherTest > geocoding response with no results decodes to null() PASSED
WeatherTest > place label skips null parts() PASSED
BUILD SUCCESSFUL in 3s
installDist builds a launcher script, which is nicer than ./gradlew run
for a CLI that returns meaningful exit codes:
London, England, United Kingdom (Europe/London)
Now: 27.6°C, Overcast, wind 21.2 km/h
Tue 04 Aug 20.0°C - 29.7°C Overcast
Wed 05 Aug 18.2°C - 24.3°C Overcast
Thu 06 Aug 14.9°C - 22.1°C Overcast
Hyderabad, Telangana, India (Asia/Kolkata)
Now: 25.5°C, Light drizzle, wind 6.0 km/h
Tue 04 Aug 23.2°C - 28.8°C Thunderstorm
Wed 05 Aug 22.7°C - 29.9°C Dense drizzle
Thu 06 Aug 23.6°C - 29.7°C Moderate drizzle
Reykjavik, Capital Region, Iceland (Atlantic/Reykjavik)
Now: 13.2°C, Mainly clear, wind 11.9 km/h
Tue 04 Aug 10.1°C - 13.4°C Overcast
Wed 05 Aug 10.4°C - 13.0°C Overcast
Thu 06 Aug 10.0°C - 11.7°C Slight rain
Warmest right now: London at 27.6°C
An unresolvable city degrades instead of crashing — the other cities still
print, and the process exits 2:
$ ./build/install/weather-cli/bin/weather-cli Zzqqxyz Oslo
Zzqqxyz: no matching place found
Oslo, Oslo, Norway (Europe/Oslo)
Now: 23.3°C, Overcast, wind 15.1 km/h
Tue 04 Aug 14.1°C - 23.6°C Dense drizzle
Wed 05 Aug 14.7°C - 18.3°C Heavy rain
Thu 06 Aug 16.7°C - 20.1°C Slight rain
Warmest right now: Oslo at 23.3°C
$ echo $?
2
Try a name you think is fictional
Narnia resolves — there's a real place by that name in Bangladesh.
Geocoders match far more strings than you'd expect, which is exactly
why NotFound has to be a normal, printable result rather than an
edge case you hope never happens.
Where each Level 2 idea shows up¶
| Module | In this project |
|---|---|
| 01 · OOP | sealed interface CityResult with an exhaustive when in render() |
| 02 · Lambdas | map/filter/forEach pipelines; runCatching { }.getOrDefault(...) |
| 03 · Coroutines | suspend HTTP, withContext(Dispatchers.IO), async/awaitAll in a coroutineScope |
| 04 · Null safety | List<Place>?, ?.firstOrNull(), listOfNotNull, Elvis fallbacks |
| 05 · Collections | filterIsInstance, maxByOrNull, zipping the parallel daily arrays |
| 06 · Testing | Offline tests over the pure functions and JSON decoding |
| 07 · JSON | @Serializable, @SerialName, ignoreUnknownKeys |
| 08 · Gradle | Two plugins, two dependencies, application, installDist |
How It Actually Works¶
withContext(Dispatchers.IO) doesn't create a new coroutine — it suspends
the current one, hands its continuation off to a different thread pool
(Dispatchers.IO, tuned with many more threads than CPU cores since I/O
threads mostly wait rather than compute), runs the blocking client.send()
call there, and resumes the original coroutine with the result once it
returns — potentially on a completely different underlying thread than the
one that called withContext in the first place. This is the state-machine
mechanism from Module 3 doing real work: the compiler-generated
continuation for get() doesn't care which physical thread calls
resumeWith on it, because all the state it needs to continue (the request,
the eventual response) lives as fields on the generated state-machine
object, not on a particular thread's call stack.
lookup's two failure paths compile to genuinely different bytecode shapes:
the null-based "not found" path is just a value flowing through ordinary
if/when branches (no exception machinery at all), while the try/catch
around geocode/forecast compiles to a JVM exception table entry — a
range of bytecode offsets paired with a handler address — that the JVM
consults only when something actually throws, at essentially zero cost when
nothing goes wrong. That's the mechanical reason the two failure styles feel
so different to use: one is data flowing through normal control flow, the
other unwinds the stack (in this case, unwinds through the coroutine's
suspended state machine, which is why any pending withContext frame gets a
chance to clean up before catch runs) and jumps straight to the handler
frame.
The sealed interface CityResult referenced in the table below compiles to
an interface with a closed, compiler-known set of implementing classes,
recorded in the class file's metadata — which is exactly what lets when
(result) over a CityResult be checked for exhaustiveness at compile time
without an else branch, the same static analysis discussed for when and
NoWhenBranchMatchedException back in Level 1.
Stretch goals¶
- Cache lookups. Geocoding results never change; store them in a
Map<String, Place>on disk as JSON so repeat runs skip a request. - Add units. A
--fahrenheitflag — Open-Meteo accepts&temperature_unit=fahrenheit, but doing the conversion inasTemp()keeps the API layer unchanged. - Make it testable without the network. Extract an interface for
get(url), pass a fake implementation returning canned JSON, and testlookup()end to end withrunTestfromkotlinx-coroutines-test. - Add a timeout per city. Wrap each
asyncbody inwithTimeoutOrNull(5_000)and turn anullintoCityResult.Failed(city, "timed out"). - Hourly view. Request
&hourly=temperature_2mand usechunked/windowedfrom Module 5 to print a compact sparkline of the next 24 hours. - Generic response envelope. Rework the decoding through a
Result<T>-style wrapper using Module 9's generics so every endpoint shares one error-handling path.
Finishing this means you've built a real multi-file, dependency-managed, tested Kotlin application against a live API — you're ready for Level 3 · Advanced.