Migrate locations module to Ktor

This commit is contained in:
MM20
2025-09-07 18:22:29 +02:00
parent 99af7d27c3
commit f9d5d2a36c
3 changed files with 231 additions and 256 deletions

View File

@@ -46,8 +46,7 @@ dependencies {
implementation(libs.bundles.androidx.lifecycle)
implementation(libs.okhttp)
implementation(libs.bundles.retrofit)
implementation(libs.bundles.ktor)
implementation(libs.koin.android)

View File

@@ -1,7 +1,6 @@
package de.mm20.launcher2.locations.providers.openstreetmaps
import android.content.Context
import android.util.Log
import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.locations.providers.AndroidLocation
import de.mm20.launcher2.locations.providers.LocationProvider
@@ -11,89 +10,42 @@ import de.mm20.launcher2.search.Location
import de.mm20.launcher2.search.ResultScore
import de.mm20.launcher2.search.UpdateResult
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import retrofit2.HttpException
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.net.UnknownHostException
private val Scope = CoroutineScope(Job() + Dispatchers.IO)
private val HttpClient = OkHttpClient()
internal class OsmLocationProvider(
private val context: Context,
settings: LocationSearchSettings
private val settings: LocationSearchSettings
) : LocationProvider<Long> {
private val overpassApi = settings.overpassUrl.map {
try {
Retrofit.Builder()
.client(HttpClient)
.baseUrl(it?.takeIf { it.isNotBlank() }
?: LocationSearchSettings.DefaultOverpassUrl)
.addConverterFactory(OverpassQueryConverterFactory())
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(OverpassApi::class.java)
} catch (e: Exception) {
CrashReporter.logException(e)
null
}
}.stateIn(Scope, SharingStarted.Eagerly, null)
private val overpassApi = OverpassApi()
suspend fun update(
id: Long
): UpdateResult<Location> = overpassApi.first()?.runCatching {
this.search(
OverpassIdQuery(
id = id
): UpdateResult<Location> {
val baseUrl = settings.overpassUrl.first()?.takeIf { it.isNotBlank() }
?: LocationSearchSettings.DefaultOverpassUrl
val response = try {
overpassApi.interpreter(
baseUrl,
OverpassIdQuery(
id = id
),
)
).let {
OsmLocation.fromOverpassResponse(it, context)
}.first().apply {
} catch (e: Exception) {
CrashReporter.logException(e)
return UpdateResult.TemporarilyUnavailable(e)
}
val locations = OsmLocation.fromOverpassResponse(response, context)
if (locations.isEmpty()) return UpdateResult.PermanentlyUnavailable()
return UpdateResult.Success(locations.first().apply {
updatedSelf = { update(id) }
}
}?.fold(
onSuccess = { UpdateResult.Success(it) },
onFailure = {
when (it) {
is CancellationException, is UnknownHostException -> {
// network
UpdateResult.TemporarilyUnavailable(it)
}
is HttpException -> when (it.code()) {
in 400..499 -> UpdateResult.PermanentlyUnavailable(it)
else -> UpdateResult.TemporarilyUnavailable(it)
}
is NoSuchElementException -> {
// empty response
UpdateResult.PermanentlyUnavailable(it)
}
else -> {
if (it is Exception) {
CrashReporter.logException(it)
}
UpdateResult.TemporarilyUnavailable(it)
}
}
}
) ?: let {
Log.e("OsmProvider", "overpassApi was not initialized")
UpdateResult.TemporarilyUnavailable()
})
}
override suspend fun search(
@@ -107,12 +59,12 @@ internal class OsmLocationProvider(
return emptyList()
}
withContext(Dispatchers.IO) {
HttpClient.dispatcher.cancelAll()
}
val baseUrl = settings.overpassUrl.first()?.takeIf { it.isNotBlank() }
?: LocationSearchSettings.DefaultOverpassUrl
return overpassApi.first()?.runCatching {
search(
val response = try {
overpassApi.interpreter(
baseUrl,
OverpassFuzzyRadiusQuery(
query = query,
tagGroups = delocalizeToQueryableTags(query),
@@ -121,22 +73,24 @@ internal class OsmLocationProvider(
longitude = userLocation.longitude
)
)
}?.onFailure {
if (it !is HttpException && it !is CancellationException) {
Log.e("OsmLocationProvider", "Failed to search for: $query", it)
}
}?.getOrNull()?.let {
OsmLocation.fromOverpassResponse(it, context)
}?.asSequence()?.filter {
} catch (e: Exception) {
CrashReporter.logException(e)
return emptyList()
}
val locations = OsmLocation.fromOverpassResponse(response, context)
return locations.asSequence().filter {
(!hideUncategorized || (it.category != null)) && it.distanceTo(userLocation) < searchRadiusMeters
}?.groupBy {
}.groupBy {
it.label.lowercase()
}?.flatMap { (_, duplicates) ->
}.flatMap { (_, duplicates) ->
// deduplicate results with same labels, if
// - same category
// - distance is less than 100m
if (duplicates.size < 2) duplicates
else {
if (duplicates.size < 2) {
duplicates
} else {
val luckyFirst = duplicates.first()
duplicates
.drop(1)
@@ -145,105 +99,117 @@ internal class OsmLocationProvider(
it.distanceTo(luckyFirst) > 100.0
} + luckyFirst
}
}?.sortedBy {
}.sortedBy {
it.distanceTo(userLocation)
}?.take(9)?.toImmutableList() ?: emptyList()
}.take(9)
}
private val poiCategories = mapOf(
R.string.poi_category_restaurant to listOf("amenity=restaurant"),
R.string.poi_category_fast_food to listOf("amenity=fast_food"),
R.string.poi_category_bar to listOf("amenity=bar"),
R.string.poi_category_cafe to listOf("amenity=cafe"),
R.string.poi_category_hotel to listOf("tourism=hotel"),
R.string.poi_category_supermarket to listOf("shop=supermarket"),
R.string.poi_category_school to listOf("amenity=school"),
R.string.poi_category_parking to listOf("amenity=parking"),
R.string.poi_category_fuel to listOf("amenity=fuel"),
R.string.poi_category_toilets to listOf("amenity=toilets"),
R.string.poi_category_pharmacy to listOf("amenity=pharmacy"),
R.string.poi_category_hospital to listOf("amenity=hospital"),
R.string.poi_category_post_office to listOf("amenity=post_office"),
R.string.poi_category_pub to listOf("amenity=pub"),
R.string.poi_category_doctors to listOf("amenity=doctors"),
R.string.poi_category_police to listOf("amenity=police"),
R.string.poi_category_dentist to listOf("amenity=dentist"),
R.string.poi_category_library to listOf("amenity=library"),
R.string.poi_category_ice_cream to listOf("amenity=ice_cream"),
R.string.poi_category_theater to listOf("amenity=theatre"),
R.string.poi_category_cinema to listOf("amenity=cinema"),
R.string.poi_category_nightclub to listOf("amenity=nightclub"),
R.string.poi_category_clinic to listOf("amenity=clinic"),
R.string.poi_category_university to listOf("amenity=university"),
R.string.poi_category_clothes to listOf("shop=clothes"),
R.string.poi_category_convenience to listOf("shop=convenience"),
R.string.poi_category_hairdresser to listOf("shop=hairdresser"),
R.string.poi_category_books to listOf("shop=books"),
R.string.poi_category_bakery to listOf("shop=bakery"),
R.string.poi_category_car_rental to listOf("amenity=car_rental"),
R.string.poi_category_car_sharing to listOf("amenity=car_sharing"),
R.string.poi_category_mobile_phone to listOf("shop=mobile_phone"),
R.string.poi_category_furniture to listOf("shop=furniture"),
R.string.poi_category_alcohol to listOf("shop=alcohol"),
R.string.poi_category_florist to listOf("shop=florist"),
R.string.poi_category_mall to listOf("shop=mall"),
R.string.poi_category_optician to listOf("shop=optician"),
R.string.poi_category_jewelry to listOf("shop=jewelry"),
R.string.poi_category_laundry to listOf("amenity=laundry"),
R.string.poi_category_bank to listOf("amenity=bank"),
R.string.poi_category_soccer to listOf("leisure=pitch,sport=soccer"),
R.string.poi_category_basketball to listOf("leisure=pitch,sport=basketball"),
R.string.poi_category_tennis to listOf("leisure=pitch,sport=tennis"),
R.string.poi_category_atm to listOf("amenity=atm"),
R.string.poi_category_kiosk to listOf("shop=kiosk"),
R.string.poi_category_museum to listOf("tourism=museum"),
R.string.poi_category_fitness_center to listOf("leisure=fitness_centre"),
R.string.poi_category_church to listOf("amenity=place_of_worship,religion=christian"),
R.string.poi_category_mosque to listOf("amenity=place_of_worship,religion=muslim"),
R.string.poi_category_buddhist_temple to listOf("amenity=place_of_worship,religion=buddhist"),
R.string.poi_category_hindu_temple to listOf("amenity=place_of_worship,religion=hindu"),
R.string.poi_category_synagogue to listOf("amenity=place_of_worship,religion=jewish"),
R.string.poi_category_pizza_restaurant to listOf("amenity=restaurant,cuisine=pizza"),
R.string.poi_category_burger_restaurant to listOf("amenity=restaurant,cuisine=burger"),
R.string.poi_category_place_of_worship to listOf("amenity=place_of_worship"),
R.string.poi_category_chinese_restaurant to listOf("amenity=restaurant,cuisine=chinese"),
R.string.poi_category_japanese_restaurant to listOf("amenity=restaurant,cuisine=japanese"),
R.string.poi_category_kebab_restaurant to listOf("amenity=restaurant,cuisine=kebab"),
R.string.poi_category_asian_restaurant to listOf("amenity=restaurant,cuisine=asian"),
R.string.poi_category_ramen_restaurant to listOf("amenity=restaurant,cuisine=ramen"),
R.string.poi_category_soup_restaurant to listOf("amenity=restaurant,cuisine=soup"),
R.string.poi_category_brunch_restaurant to listOf("amenity=restaurant,cuisine=brunch"),
R.string.poi_category_car_wash to listOf("amenity=car_wash"),
R.string.poi_category_charging_station to listOf("amenity=charging_station"),
R.string.poi_category_motorcycle_rental to listOf("amenity=motorcycle_rental"),
R.string.poi_category_gallery to listOf("tourism=gallery"),
R.string.poi_category_amusement_park to listOf("tourism=theme_park"),
R.string.poi_category_concert_hall to listOf("amenity=concert_hall"),
R.string.poi_category_stadium to listOf("leisure=stadium"),
R.string.poi_category_casino to listOf("amenity=casino"),
R.string.poi_category_discount_store to listOf("shop=discount"),
R.string.poi_category_pet to listOf("shop=pet"),
R.string.poi_category_shopping to listOf("shop=mall"),
R.string.poi_category_swimming to listOf("leisure=swimming_pool"),
R.string.poi_category_martial_arts to listOf("leisure=sports_centre", "leisure=sports_hall").map { "$it,sport=martial_arts" },
R.string.poi_category_golf to listOf("leisure=golf_course"),
R.string.poi_category_gymnastics to listOf("leisure=sports_hall", "leisure=sports_centre").map { "$it,sport=gymnastics" },
R.string.poi_category_ice_hockey to listOf("leisure=sports_hall", "leisure=sports_centre").map { "$it,sport=ice_hockey" },
R.string.poi_category_baseball to listOf("leisure=pitch,sport=baseball"),
R.string.poi_category_american_football to listOf("leisure=pitch,sport=american_football"),
R.string.poi_category_handball to listOf("leisure=pitch,sport=handball"),
R.string.poi_category_volleyball to listOf("leisure=pitch,sport=volleyball"),
R.string.poi_category_skiing to listOf("leisure=piste"),
R.string.poi_category_cricket to listOf("leisure=pitch,sport=cricket"),
R.string.poi_category_park to listOf("leisure=park"),
R.string.poi_category_monument to listOf("historic=monument"),
R.string.poi_category_government_building to listOf("building=government"),
R.string.poi_category_fire_station to listOf("amenity=fire_station"),
R.string.poi_category_courthouse to listOf("amenity=courthouse"),
R.string.poi_category_townhall to listOf("amenity=townhall"),
R.string.poi_category_stationery to listOf("shop=stationery"),
R.string.poi_category_climbing_gym to listOf("leisure=sports_hall", "leisure=sports_centre").map { "$it,sport=climbing" },
R.string.poi_category_hackerspace to listOf("leisure=hackerspace")
R.string.poi_category_restaurant to listOf("amenity=restaurant"),
R.string.poi_category_fast_food to listOf("amenity=fast_food"),
R.string.poi_category_bar to listOf("amenity=bar"),
R.string.poi_category_cafe to listOf("amenity=cafe"),
R.string.poi_category_hotel to listOf("tourism=hotel"),
R.string.poi_category_supermarket to listOf("shop=supermarket"),
R.string.poi_category_school to listOf("amenity=school"),
R.string.poi_category_parking to listOf("amenity=parking"),
R.string.poi_category_fuel to listOf("amenity=fuel"),
R.string.poi_category_toilets to listOf("amenity=toilets"),
R.string.poi_category_pharmacy to listOf("amenity=pharmacy"),
R.string.poi_category_hospital to listOf("amenity=hospital"),
R.string.poi_category_post_office to listOf("amenity=post_office"),
R.string.poi_category_pub to listOf("amenity=pub"),
R.string.poi_category_doctors to listOf("amenity=doctors"),
R.string.poi_category_police to listOf("amenity=police"),
R.string.poi_category_dentist to listOf("amenity=dentist"),
R.string.poi_category_library to listOf("amenity=library"),
R.string.poi_category_ice_cream to listOf("amenity=ice_cream"),
R.string.poi_category_theater to listOf("amenity=theatre"),
R.string.poi_category_cinema to listOf("amenity=cinema"),
R.string.poi_category_nightclub to listOf("amenity=nightclub"),
R.string.poi_category_clinic to listOf("amenity=clinic"),
R.string.poi_category_university to listOf("amenity=university"),
R.string.poi_category_clothes to listOf("shop=clothes"),
R.string.poi_category_convenience to listOf("shop=convenience"),
R.string.poi_category_hairdresser to listOf("shop=hairdresser"),
R.string.poi_category_books to listOf("shop=books"),
R.string.poi_category_bakery to listOf("shop=bakery"),
R.string.poi_category_car_rental to listOf("amenity=car_rental"),
R.string.poi_category_car_sharing to listOf("amenity=car_sharing"),
R.string.poi_category_mobile_phone to listOf("shop=mobile_phone"),
R.string.poi_category_furniture to listOf("shop=furniture"),
R.string.poi_category_alcohol to listOf("shop=alcohol"),
R.string.poi_category_florist to listOf("shop=florist"),
R.string.poi_category_mall to listOf("shop=mall"),
R.string.poi_category_optician to listOf("shop=optician"),
R.string.poi_category_jewelry to listOf("shop=jewelry"),
R.string.poi_category_laundry to listOf("amenity=laundry"),
R.string.poi_category_bank to listOf("amenity=bank"),
R.string.poi_category_soccer to listOf("leisure=pitch,sport=soccer"),
R.string.poi_category_basketball to listOf("leisure=pitch,sport=basketball"),
R.string.poi_category_tennis to listOf("leisure=pitch,sport=tennis"),
R.string.poi_category_atm to listOf("amenity=atm"),
R.string.poi_category_kiosk to listOf("shop=kiosk"),
R.string.poi_category_museum to listOf("tourism=museum"),
R.string.poi_category_fitness_center to listOf("leisure=fitness_centre"),
R.string.poi_category_church to listOf("amenity=place_of_worship,religion=christian"),
R.string.poi_category_mosque to listOf("amenity=place_of_worship,religion=muslim"),
R.string.poi_category_buddhist_temple to listOf("amenity=place_of_worship,religion=buddhist"),
R.string.poi_category_hindu_temple to listOf("amenity=place_of_worship,religion=hindu"),
R.string.poi_category_synagogue to listOf("amenity=place_of_worship,religion=jewish"),
R.string.poi_category_pizza_restaurant to listOf("amenity=restaurant,cuisine=pizza"),
R.string.poi_category_burger_restaurant to listOf("amenity=restaurant,cuisine=burger"),
R.string.poi_category_place_of_worship to listOf("amenity=place_of_worship"),
R.string.poi_category_chinese_restaurant to listOf("amenity=restaurant,cuisine=chinese"),
R.string.poi_category_japanese_restaurant to listOf("amenity=restaurant,cuisine=japanese"),
R.string.poi_category_kebab_restaurant to listOf("amenity=restaurant,cuisine=kebab"),
R.string.poi_category_asian_restaurant to listOf("amenity=restaurant,cuisine=asian"),
R.string.poi_category_ramen_restaurant to listOf("amenity=restaurant,cuisine=ramen"),
R.string.poi_category_soup_restaurant to listOf("amenity=restaurant,cuisine=soup"),
R.string.poi_category_brunch_restaurant to listOf("amenity=restaurant,cuisine=brunch"),
R.string.poi_category_car_wash to listOf("amenity=car_wash"),
R.string.poi_category_charging_station to listOf("amenity=charging_station"),
R.string.poi_category_motorcycle_rental to listOf("amenity=motorcycle_rental"),
R.string.poi_category_gallery to listOf("tourism=gallery"),
R.string.poi_category_amusement_park to listOf("tourism=theme_park"),
R.string.poi_category_concert_hall to listOf("amenity=concert_hall"),
R.string.poi_category_stadium to listOf("leisure=stadium"),
R.string.poi_category_casino to listOf("amenity=casino"),
R.string.poi_category_discount_store to listOf("shop=discount"),
R.string.poi_category_pet to listOf("shop=pet"),
R.string.poi_category_shopping to listOf("shop=mall"),
R.string.poi_category_swimming to listOf("leisure=swimming_pool"),
R.string.poi_category_martial_arts to listOf(
"leisure=sports_centre",
"leisure=sports_hall"
).map { "$it,sport=martial_arts" },
R.string.poi_category_golf to listOf("leisure=golf_course"),
R.string.poi_category_gymnastics to listOf(
"leisure=sports_hall",
"leisure=sports_centre"
).map { "$it,sport=gymnastics" },
R.string.poi_category_ice_hockey to listOf(
"leisure=sports_hall",
"leisure=sports_centre"
).map { "$it,sport=ice_hockey" },
R.string.poi_category_baseball to listOf("leisure=pitch,sport=baseball"),
R.string.poi_category_american_football to listOf("leisure=pitch,sport=american_football"),
R.string.poi_category_handball to listOf("leisure=pitch,sport=handball"),
R.string.poi_category_volleyball to listOf("leisure=pitch,sport=volleyball"),
R.string.poi_category_skiing to listOf("leisure=piste"),
R.string.poi_category_cricket to listOf("leisure=pitch,sport=cricket"),
R.string.poi_category_park to listOf("leisure=park"),
R.string.poi_category_monument to listOf("historic=monument"),
R.string.poi_category_government_building to listOf("building=government"),
R.string.poi_category_fire_station to listOf("amenity=fire_station"),
R.string.poi_category_courthouse to listOf("amenity=courthouse"),
R.string.poi_category_townhall to listOf("amenity=townhall"),
R.string.poi_category_stationery to listOf("shop=stationery"),
R.string.poi_category_climbing_gym to listOf(
"leisure=sports_hall",
"leisure=sports_centre"
).map { "$it,sport=climbing" },
R.string.poi_category_hackerspace to listOf("leisure=hackerspace")
).mapKeys { context.getString(it.key) }
private fun delocalizeToQueryableTags(localizedQuery: String): List<String> =

View File

@@ -1,21 +1,28 @@
package de.mm20.launcher2.locations.providers.openstreetmaps
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import retrofit2.Converter
import retrofit2.Retrofit
import retrofit2.http.Body
import retrofit2.http.POST
import java.lang.reflect.Type
import de.mm20.launcher2.serialization.Json
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.path
import io.ktor.http.takeFrom
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.Serializable
import kotlin.math.cos
internal sealed interface OverpassQuery {
fun toQueryString(): String
}
/**
* Overpass API query builder
* Searches for nodes and ways that at least:
* - match the query string in their name or brand tag
* - match one of the given tag groups
*/
data class OverpassFuzzyRadiusQuery(
internal data class OverpassFuzzyRadiusQuery(
/**
* Free text query to search for.
*/
@@ -31,41 +38,9 @@ data class OverpassFuzzyRadiusQuery(
val radius: Int,
val latitude: Double,
val longitude: Double,
)
data class OverpassIdQuery(
val id: Long,
)
data class OverpassResponse(
val elements: List<OverpassResponseElement>,
)
data class OverpassResponseElementCenter(
val lat: Double,
val lon: Double,
)
data class OverpassResponseElement(
val type: String,
val id: Long,
val lat: Double?,
val lon: Double?,
val center: OverpassResponseElementCenter?,
val tags: Map<String, String>?,
)
interface OverpassApi {
@POST("api/interpreter")
suspend fun search(@Body data: OverpassFuzzyRadiusQuery): OverpassResponse
@POST("api/interpreter")
suspend fun search(@Body data: OverpassIdQuery): OverpassResponse
}
class OverpassFuzzyRadiusQueryConverter : Converter<OverpassFuzzyRadiusQuery, RequestBody> {
override fun convert(value: OverpassFuzzyRadiusQuery): RequestBody {
val encodedQuery = value.query.split(' ')
) : OverpassQuery {
override fun toQueryString(): String {
val encodedQuery = query.split(' ')
.joinToString(
separator = ".*",
prefix = "\"",
@@ -73,18 +48,18 @@ class OverpassFuzzyRadiusQueryConverter : Converter<OverpassFuzzyRadiusQuery, Re
) { Regex.escapeReplacement(it) }
val overpassQlBuilder = StringBuilder()
val latDegreeChange = value.radius * 0.00001 / 1.11
val lonDegreeChange = latDegreeChange / cos(Math.toRadians(value.latitude))
val latDegreeChange = radius * 0.00001 / 1.11
val lonDegreeChange = latDegreeChange / cos(Math.toRadians(latitude))
val boundingBox = arrayOf(
value.latitude - latDegreeChange, value.longitude - lonDegreeChange,
value.latitude + latDegreeChange, value.longitude + lonDegreeChange
latitude - latDegreeChange, longitude - lonDegreeChange,
latitude + latDegreeChange, longitude + lonDegreeChange
)
overpassQlBuilder.append("[out:json][timeout:10][bbox:" + boundingBox.joinToString(",") + "];")
overpassQlBuilder.append("(")
overpassQlBuilder.append("nw[name~$encodedQuery,i];")
overpassQlBuilder.append("nw[brand~$encodedQuery,i];")
for (tag in value.tagGroups) {
for (tag in tagGroups) {
val tags = tag.split(',')
if (tags.isEmpty()) continue
@@ -95,32 +70,67 @@ class OverpassFuzzyRadiusQueryConverter : Converter<OverpassFuzzyRadiusQuery, Re
// center to add the center coordinate of a way to the result, if applicable
overpassQlBuilder.append("out center;")
return overpassQlBuilder.toString().toRequestBody()
return overpassQlBuilder.toString()
}
}
class OverpassIdQueryConverter : Converter<OverpassIdQuery, RequestBody> {
override fun convert(value: OverpassIdQuery): RequestBody = """
[out:json];
nw(${value.id});
out center;
""".trimIndent().toRequestBody()
}
class OverpassQueryConverterFactory : Converter.Factory() {
override fun requestBodyConverter(
type: Type,
parameterAnnotations: Array<out Annotation>,
methodAnnotations: Array<out Annotation>,
retrofit: Retrofit
): Converter<*, RequestBody>? {
if (type == OverpassFuzzyRadiusQuery::class.java)
return OverpassFuzzyRadiusQueryConverter()
if (type == OverpassIdQuery::class.java)
return OverpassIdQueryConverter()
return null
internal data class OverpassIdQuery(
val id: Long,
) : OverpassQuery {
override fun toQueryString(): String {
return """
[out:json];
nw($id);
out center;
""".trimIndent()
}
}
@Serializable
internal data class OverpassResponse(
val elements: List<OverpassResponseElement>,
)
@Serializable
internal data class OverpassResponseElementCenter(
val lat: Double,
val lon: Double,
)
@Serializable
internal data class OverpassResponseElement(
val type: String,
val id: Long,
val lat: Double?,
val lon: Double?,
val center: OverpassResponseElementCenter?,
val tags: Map<String, String>?,
)
internal class OverpassApi {
private val httpClient by lazy {
HttpClient {
install(ContentNegotiation) {
json(Json.Lenient)
}
}
}
suspend fun interpreter(
baseUrl: String,
query: OverpassQuery,
): OverpassResponse {
return httpClient.post {
url {
takeFrom(baseUrl)
if (pathSegments.isEmpty()) {
path("api", "interpreter")
}
}
setBody(query.toQueryString())
}.body()
}
}