diff --git a/data/locations/build.gradle.kts b/data/locations/build.gradle.kts index 95236a00c..f6a48e44b 100644 --- a/data/locations/build.gradle.kts +++ b/data/locations/build.gradle.kts @@ -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) diff --git a/data/locations/src/main/java/de/mm20/launcher2/locations/providers/openstreetmaps/OsmLocationProvider.kt b/data/locations/src/main/java/de/mm20/launcher2/locations/providers/openstreetmaps/OsmLocationProvider.kt index 73c79279f..b458133af 100644 --- a/data/locations/src/main/java/de/mm20/launcher2/locations/providers/openstreetmaps/OsmLocationProvider.kt +++ b/data/locations/src/main/java/de/mm20/launcher2/locations/providers/openstreetmaps/OsmLocationProvider.kt @@ -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 { - 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 = overpassApi.first()?.runCatching { - this.search( - OverpassIdQuery( - id = id + ): UpdateResult { + + 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 = diff --git a/data/locations/src/main/java/de/mm20/launcher2/locations/providers/openstreetmaps/OverpassApi.kt b/data/locations/src/main/java/de/mm20/launcher2/locations/providers/openstreetmaps/OverpassApi.kt index 4fe44c727..8323a01b2 100644 --- a/data/locations/src/main/java/de/mm20/launcher2/locations/providers/openstreetmaps/OverpassApi.kt +++ b/data/locations/src/main/java/de/mm20/launcher2/locations/providers/openstreetmaps/OverpassApi.kt @@ -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, -) - -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?, -) - -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 { - 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 { - 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, - methodAnnotations: Array, - 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, +) + +@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?, +) + +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() + } +} + +