Skip to content

Commit

Permalink
Multiplatform OSM API client (#5686)
Browse files Browse the repository at this point in the history
  • Loading branch information
westnordost authored Aug 15, 2024
1 parent 8d488a0 commit 6090c26
Show file tree
Hide file tree
Showing 83 changed files with 2,849 additions and 1,627 deletions.
20 changes: 5 additions & 15 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -102,14 +102,6 @@ repositories {
mavenCentral()
}

configurations {
all {
// it's already included in Android
exclude(group = "net.sf.kxml", module = "kxml2")
exclude(group = "xmlpull", module = "xmlpull")
}
}

dependencies {
val mockitoVersion = "3.12.4"

Expand Down Expand Up @@ -180,19 +172,16 @@ dependencies {

// HTTP Client
implementation("io.ktor:ktor-client-core:2.3.12")
implementation("io.ktor:ktor-client-cio:2.3.12")
implementation("io.ktor:ktor-client-android:2.3.12")
testImplementation("io.ktor:ktor-client-mock:2.3.12")
// TODO: as soon as both ktor-client and kotlinx-serialization have been refactored to be based
// on kotlinx-io, revisit sending and receiving xml/json payloads via APIs, currently it
// is all String-based, i.e. no KMP equivalent of InputStream/OutputStream involved

// finding in which country we are for country-specific logic
implementation("de.westnordost:countryboundaries:2.1")
// finding a name for a feature without a name tag
implementation("de.westnordost:osmfeatures:6.1")
// talking with the OSM API
implementation("de.westnordost:osmapi-map:3.0")
implementation("de.westnordost:osmapi-changesets:3.0")
implementation("de.westnordost:osmapi-notes:3.0")
implementation("de.westnordost:osmapi-traces:3.1")
implementation("de.westnordost:osmapi-user:3.0")

// widgets
implementation("androidx.viewpager2:viewpager2:1.1.0")
Expand All @@ -210,6 +199,7 @@ dependencies {
// serialization
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1")
implementation("com.charleskorn.kaml:kaml:0.61.0")
implementation("io.github.pdvrieze.xmlutil:core:0.90.1")

// map and location
implementation("org.maplibre.gl:android-sdk:11.1.0")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package de.westnordost.streetcomplete.data

import io.ktor.client.plugins.ClientRequestException
import io.ktor.client.plugins.ServerResponseException
import io.ktor.http.HttpStatusCode
import kotlinx.io.IOException
import kotlinx.serialization.SerializationException

inline fun <T> wrapApiClientExceptions(block: () -> T): T =
try {
block()
}
// server replied with (server) error 5xx
catch (e: ServerResponseException) {
throw ConnectionException(e.message, e)
}
// unexpected answer by server -> server issue
catch (e: SerializationException) {
throw ConnectionException(e.message, e)
}
// issue with establishing a connection -> nothing we can do about
catch (e: IOException) {
throw ConnectionException(e.message, e)
}
// server replied with (client) error 4xx
catch (e: ClientRequestException) {
when (e.response.status) {
// request timeout is rather a temporary connection error
HttpStatusCode.RequestTimeout -> {
throw ConnectionException(e.message, e)
}
// rate limiting is treated like a temporary connection error, i.e. try again later
HttpStatusCode.TooManyRequests -> {
throw ConnectionException(e.message, e)
}
// authorization is something we can handle (by requiring (re-)login of the user)
HttpStatusCode.Forbidden, HttpStatusCode.Unauthorized -> {
throw AuthorizationException(e.message, e)
}
else -> {
throw ApiClientException(e.message, e)
}
}
}

/** The server responded with an unhandled error code */
class ApiClientException(message: String? = null, cause: Throwable? = null)
: RuntimeException(message, cause)

/** An error occurred while trying to communicate with an API over the internet. Either the
* connection with the API cannot be established, the server replies with a server error (5xx),
* request timeout (408) or it responds with an unexpected response, i.e. an error occurs while
* parsing the response. */
class ConnectionException(message: String? = null, cause: Throwable? = null)
: RuntimeException(message, cause)

/** While posting an update to an API over the internet, the API reports that our data is based on
* outdated data */
class ConflictException(message: String? = null, cause: Throwable? = null)
: RuntimeException(message, cause)

/** When a query made on an API over an internet would (probably) return a too large result */
class QueryTooBigException (message: String? = null, cause: Throwable? = null)
: RuntimeException(message, cause)

/** An error that indicates that the user either does not have the necessary authorization or
* authentication to execute an action through an API over the internet. */
class AuthorizationException(message: String? = null, cause: Throwable? = null)
: RuntimeException(message, cause)

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
package de.westnordost.streetcomplete.data

import de.westnordost.osmapi.OsmConnection
import de.westnordost.osmapi.user.UserApi
import de.westnordost.streetcomplete.ApplicationConstants
import de.westnordost.streetcomplete.data.osm.mapdata.MapDataApi
import de.westnordost.streetcomplete.data.osm.mapdata.MapDataApiImpl
import de.westnordost.streetcomplete.data.osmnotes.NotesApi
import de.westnordost.streetcomplete.data.osmnotes.NotesApiImpl
import de.westnordost.streetcomplete.data.osmtracks.TracksApi
import de.westnordost.streetcomplete.data.osmtracks.TracksApiImpl
import de.westnordost.streetcomplete.data.preferences.Preferences
import de.westnordost.streetcomplete.data.osm.edits.upload.changesets.ChangesetApiClient
import de.westnordost.streetcomplete.data.osm.edits.upload.changesets.ChangesetApiSerializer
import de.westnordost.streetcomplete.data.user.UserApiClient
import de.westnordost.streetcomplete.data.osm.mapdata.MapDataApiClient
import de.westnordost.streetcomplete.data.osm.mapdata.MapDataApiParser
import de.westnordost.streetcomplete.data.osm.mapdata.MapDataApiSerializer
import de.westnordost.streetcomplete.data.osmnotes.NotesApiClient
import de.westnordost.streetcomplete.data.osmnotes.NotesApiParser
import de.westnordost.streetcomplete.data.osmtracks.TracksApiClient
import de.westnordost.streetcomplete.data.osmtracks.TracksSerializer
import de.westnordost.streetcomplete.data.user.UserApiParser
import org.koin.androidx.workmanager.dsl.worker
import org.koin.core.qualifier.named
import org.koin.dsl.module
Expand All @@ -19,17 +20,21 @@ private const val OSM_API_URL = "https://api.openstreetmap.org/api/0.6/"
val osmApiModule = module {
factory { Cleaner(get(), get(), get(), get(), get(), get()) }
factory { CacheTrimmer(get(), get()) }
factory<MapDataApi> { MapDataApiImpl(get()) }
factory<NotesApi> { NotesApiImpl(get()) }
factory<TracksApi> { TracksApiImpl(get()) }
factory { MapDataApiClient(get(), OSM_API_URL, get(), get(), get()) }
factory { NotesApiClient(get(), OSM_API_URL, get(), get()) }
factory { TracksApiClient(get(), OSM_API_URL, get(), get()) }
factory { UserApiClient(get(), OSM_API_URL, get(), get()) }
factory { ChangesetApiClient(get(), OSM_API_URL, get(), get()) }

factory { Preloader(get(named("CountryBoundariesLazy")), get(named("FeatureDictionaryLazy"))) }
factory { UserApi(get()) }

single { OsmConnection(
OSM_API_URL,
ApplicationConstants.USER_AGENT,
get<Preferences>().oAuth2AccessToken
) }
factory { UserApiParser() }
factory { NotesApiParser() }
factory { TracksSerializer() }
factory { MapDataApiParser() }
factory { MapDataApiSerializer() }
factory { ChangesetApiSerializer() }

single { UnsyncedChangesCountSource(get(), get()) }

worker { CleanerWorker(get(), get(), get()) }
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,24 @@ import de.westnordost.streetcomplete.data.ConflictException
import de.westnordost.streetcomplete.data.osm.edits.ElementEdit
import de.westnordost.streetcomplete.data.osm.edits.ElementIdProvider
import de.westnordost.streetcomplete.data.osm.edits.upload.changesets.OpenChangesetsManager
import de.westnordost.streetcomplete.data.osm.mapdata.MapDataApi
import de.westnordost.streetcomplete.data.osm.mapdata.MapDataApiClient
import de.westnordost.streetcomplete.data.osm.mapdata.MapDataChanges
import de.westnordost.streetcomplete.data.osm.mapdata.MapDataController
import de.westnordost.streetcomplete.data.osm.mapdata.MapDataUpdates
import de.westnordost.streetcomplete.data.osm.mapdata.RemoteMapDataRepository

class ElementEditUploader(
private val changesetManager: OpenChangesetsManager,
private val mapDataApi: MapDataApi,
private val mapDataApi: MapDataApiClient,
private val mapDataController: MapDataController
) {

/** Apply the given change to the given element and upload it
*
* @throws ConflictException if element has been changed server-side in an incompatible way
*/
fun upload(edit: ElementEdit, getIdProvider: () -> ElementIdProvider): MapDataUpdates {
val remoteChanges by lazy { edit.action.createUpdates(mapDataApi, getIdProvider()) }
suspend fun upload(edit: ElementEdit, getIdProvider: () -> ElementIdProvider): MapDataUpdates {
val remoteChanges by lazy { edit.action.createUpdates(RemoteMapDataRepository(mapDataApi), getIdProvider()) }
val localChanges by lazy { edit.action.createUpdates(mapDataController, getIdProvider()) }

val mustUseRemoteData = edit.action::class in ApplicationConstants.EDIT_ACTIONS_NOT_ALLOWED_TO_USE_LOCAL_CHANGES
Expand All @@ -48,13 +49,16 @@ class ElementEditUploader(
}
}

private fun uploadChanges(edit: ElementEdit, mapDataChanges: MapDataChanges, newChangeset: Boolean): MapDataUpdates {
val changesetId =
if (newChangeset) {
changesetManager.createChangeset(edit.type, edit.source, edit.position)
} else {
changesetManager.getOrCreateChangeset(edit.type, edit.source, edit.position, edit.isNearUserLocation)
}
return mapDataApi.uploadChanges(changesetId, mapDataChanges, ApplicationConstants.IGNORED_RELATION_TYPES)
private suspend fun uploadChanges(
edit: ElementEdit,
changes: MapDataChanges,
newChangeset: Boolean
): MapDataUpdates {
val changesetId = if (newChangeset) {
changesetManager.createChangeset(edit.type, edit.source, edit.position)
} else {
changesetManager.getOrCreateChangeset(edit.type, edit.source, edit.position, edit.isNearUserLocation)
}
return mapDataApi.uploadChanges(changesetId, changes, ApplicationConstants.IGNORED_RELATION_TYPES)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import de.westnordost.streetcomplete.data.osm.mapdata.Element
import de.westnordost.streetcomplete.data.osm.mapdata.ElementKey
import de.westnordost.streetcomplete.data.osm.mapdata.ElementType
import de.westnordost.streetcomplete.data.osm.mapdata.MapData
import de.westnordost.streetcomplete.data.osm.mapdata.MapDataApi
import de.westnordost.streetcomplete.data.osm.mapdata.MapDataApiClient
import de.westnordost.streetcomplete.data.osm.mapdata.MapDataController
import de.westnordost.streetcomplete.data.osm.mapdata.MapDataUpdates
import de.westnordost.streetcomplete.data.osm.mapdata.MutableMapData
Expand All @@ -30,7 +30,7 @@ class ElementEditsUploader(
private val noteEditsController: NoteEditsController,
private val mapDataController: MapDataController,
private val singleUploader: ElementEditUploader,
private val mapDataApi: MapDataApi,
private val mapDataApi: MapDataApiClient,
private val statisticsController: StatisticsController
) {
var uploadedChangeListener: OnUploadedChangeListener? = null
Expand Down Expand Up @@ -95,12 +95,10 @@ class ElementEditsUploader(
}

private suspend fun fetchElementComplete(elementType: ElementType, elementId: Long): MapData? =
withContext(Dispatchers.IO) {
when (elementType) {
ElementType.NODE -> mapDataApi.getNode(elementId)?.let { MutableMapData(listOf(it)) }
ElementType.WAY -> mapDataApi.getWayComplete(elementId)
ElementType.RELATION -> mapDataApi.getRelationComplete(elementId)
}
when (elementType) {
ElementType.NODE -> mapDataApi.getNode(elementId)?.let { MutableMapData(listOf(it)) }
ElementType.WAY -> mapDataApi.getWayComplete(elementId)
ElementType.RELATION -> mapDataApi.getRelationComplete(elementId)
}

companion object {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package de.westnordost.streetcomplete.data.osm.edits.upload.changesets

import de.westnordost.streetcomplete.data.AuthorizationException
import de.westnordost.streetcomplete.data.ConflictException
import de.westnordost.streetcomplete.data.ConnectionException
import de.westnordost.streetcomplete.data.user.UserLoginSource
import de.westnordost.streetcomplete.data.wrapApiClientExceptions
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.ClientRequestException
import io.ktor.client.plugins.expectSuccess
import io.ktor.client.request.bearerAuth
import io.ktor.client.request.put
import io.ktor.client.request.setBody
import io.ktor.http.HttpStatusCode

class ChangesetApiClient(
private val httpClient: HttpClient,
private val baseUrl: String,
private val userLoginSource: UserLoginSource,
private val serializer: ChangesetApiSerializer,
) {
/**
* Open a new changeset with the given tags
*
* @param tags tags of this changeset. Usually it is comment and source.
*
* @throws AuthorizationException if the application does not have permission to edit the map
* (OAuth scope "write_api")
* @throws ConnectionException if a temporary network connection problem occurs
*
* @return the id of the changeset
*/
suspend fun open(tags: Map<String, String>): Long = wrapApiClientExceptions {
val response = httpClient.put(baseUrl + "changeset/create") {
userLoginSource.accessToken?.let { bearerAuth(it) }
setBody(serializer.serialize(tags))
expectSuccess = true
}
return response.body<String>().toLong()
}

/**
* Closes the given changeset.
*
* @param id id of the changeset to close
*
* @throws ConflictException if the changeset has already been closed or does not exist
* @throws AuthorizationException if the application does not have permission to edit the map
* (OAuth scope "write_api")
* @throws ConnectionException if a temporary network connection problem occurs
*/
suspend fun close(id: Long): Unit = wrapApiClientExceptions {
try {
httpClient.put(baseUrl + "changeset/$id/close") {
userLoginSource.accessToken?.let { bearerAuth(it) }
expectSuccess = true
}
} catch (e: ClientRequestException) {
when (e.response.status) {
HttpStatusCode.Conflict, HttpStatusCode.NotFound -> {
throw ConflictException(e.message, e)
}
else -> throw e
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package de.westnordost.streetcomplete.data.osm.edits.upload.changesets

import de.westnordost.streetcomplete.util.ktx.attribute
import de.westnordost.streetcomplete.util.ktx.endTag
import de.westnordost.streetcomplete.util.ktx.startTag
import nl.adaptivity.xmlutil.XmlWriter
import nl.adaptivity.xmlutil.newWriter
import nl.adaptivity.xmlutil.xmlStreaming

class ChangesetApiSerializer {
fun serialize(changesetTags: Map<String, String>): String {
val buffer = StringBuilder()
xmlStreaming.newWriter(buffer).serializeChangeset(changesetTags)
return buffer.toString()
}
}

private fun XmlWriter.serializeChangeset(changesetTags: Map<String, String>) {
startTag("osm")
startTag("changeset")
for ((k, v) in changesetTags) {
startTag("tag")
attribute("k", k)
attribute("v", v)
endTag("tag")
}
endTag("changeset")
endTag("osm")
}
Loading

0 comments on commit 6090c26

Please sign in to comment.