Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Multiplatform OSM API client #5686

Merged
merged 51 commits into from
Aug 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
a055bcd
replace osmapi-traces with httpclient-based implementation
westnordost Jun 5, 2024
f1b0ecc
Highlight city limit signs in maxspeed quest
burrscurr Jun 4, 2024
80e32af
Merge branch 'master' into osmapi
westnordost Jun 5, 2024
536cad4
add user api
westnordost Jun 6, 2024
8ae1446
consistent naming of classes that are a client of an API
westnordost Jun 6, 2024
5a315e7
wip notes api client
westnordost Jun 6, 2024
f4f1c36
add notes parser and notes parser test
westnordost Jun 6, 2024
f338aac
add notes api client test
westnordost Jun 6, 2024
fd52345
put parser for user api stuff into own class etc
westnordost Jun 6, 2024
4b08c02
add test
westnordost Jun 7, 2024
edf5b32
rewrite some tests
westnordost Jun 7, 2024
b2e111e
displayName -> name
westnordost Jun 7, 2024
fe55253
Merge branch 'master' into osmapi
westnordost Jun 7, 2024
a20c003
add deps
westnordost Jun 7, 2024
ca9c100
consistent error handling + tests
westnordost Jun 8, 2024
d27d6e4
add changeset api client
westnordost Jun 10, 2024
f4e8a6e
wip MapDataApiClient
westnordost Jun 10, 2024
daf52ac
reduce copypasta
westnordost Jun 11, 2024
f1fe47b
ignore relation types
westnordost Jun 11, 2024
1e1878b
add test for mapdataapiserializer
westnordost Jun 11, 2024
8097e55
refactor UpdatedElementsHandler and test
westnordost Jun 12, 2024
2fc8349
handle errors
westnordost Jun 12, 2024
3488c80
add more tests
westnordost Jun 12, 2024
f27af4b
use mapdata after all
westnordost Jun 12, 2024
8af84f2
fix MapDataDownloader
westnordost Jun 12, 2024
19a1b7b
remove OsmConnection
westnordost Jun 12, 2024
64ac7fd
make ElementEditUploader suspending
westnordost Jun 12, 2024
8410ab1
fix tests
westnordost Jun 13, 2024
a5e124a
fix tests
westnordost Jun 13, 2024
36a88d7
name -> displayName
westnordost Jun 13, 2024
9292b4b
rather use Android http client as engine for ktor-client for now
westnordost Jun 13, 2024
c7efe01
lint
westnordost Jun 13, 2024
5fe65b5
add missing test case
westnordost Jun 13, 2024
a2c3abb
experiment: do manual parsing
westnordost Jun 14, 2024
0759076
implement manual xml serialization/parsing
westnordost Jun 15, 2024
7aa5a85
Merge branch 'osmapi-manual-parsing' into osmapi
westnordost Jun 15, 2024
d4db287
remove now obsolete declaration
westnordost Jun 15, 2024
563fd60
Merge branch 'master' into osmapi
westnordost Jun 15, 2024
fac3c94
Merge branch 'master' into osmapi
westnordost Jun 17, 2024
e08c08b
fix build.gradle
westnordost Jul 7, 2024
4deb7d0
Merge branch 'master' into osmapi
westnordost Jul 7, 2024
cf5b135
post-merge fix
westnordost Jul 7, 2024
8725d94
Merge branch 'master' into osmapi
westnordost Jul 17, 2024
8e4e051
Merge branch 'master' into osmapi
westnordost Aug 8, 2024
210a254
split MapDataApiSerializer into two files
westnordost Aug 8, 2024
c6b4d37
fix tests
westnordost Aug 9, 2024
2698ff5
Merge branch 'master' into osmapi
westnordost Aug 14, 2024
e262999
use generic implementation
westnordost Aug 14, 2024
68fe45b
Merge branch 'master' into osmapi
westnordost Aug 15, 2024
984467b
tag disused:oldkey=oldvalue when selecting that something is vacant n…
westnordost Aug 15, 2024
24f061c
Merge branch 'master' into osmapi
westnordost Aug 15, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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