Repository: terraware/terraware-server
Pull request #3145: SW-7000 Add basic GeoServer client
Author: login: sgrimm
Created: 2025-05-29T22:18:48Z | Merged: 2025-05-30T18:07:07Z

Add a client class to send requests to GeoServer with optional authentication.

Some GeoServer API endpoints can only return XML responses, not JSON ones; add the
Jackson XML serialization library and an example usage in the form of a very
stripped-down version of the endpoint that fetches the server's capabilities.


Diff:
diff --git a/build.gradle.kts b/build.gradle.kts
index fd19ac74d5ef..c4013f895a48 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -100,6 +100,7 @@ dependencies {
   implementation("ch.qos.logback.access:logback-access-tomcat:2.0.6")
   implementation("com.drewnoakes:metadata-extractor:2.19.0")
   implementation("com.dropbox.core:dropbox-core-sdk:7.0.0")
+  implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-xml")
   implementation("com.google.api-client:google-api-client:2.8.0")
   implementation("com.google.auth:google-auth-library-oauth2-http:1.35.0")
   implementation("com.google.apis:google-api-services-drive:v3-rev20250511-2.0.0")
diff --git a/src/main/kotlin/com/terraformation/backend/config/TerrawareServerConfig.kt b/src/main/kotlin/com/terraformation/backend/config/TerrawareServerConfig.kt
index 3d0eb9ed388f..5f10a7a5f519 100644
--- a/src/main/kotlin/com/terraformation/backend/config/TerrawareServerConfig.kt
+++ b/src/main/kotlin/com/terraformation/backend/config/TerrawareServerConfig.kt
@@ -99,6 +99,9 @@ class TerrawareServerConfig(
     /** Configures how the server interacts with Dropbox. */
     val dropbox: DropboxConfig = DropboxConfig(),
 
+    /** Configures how terraware-server interacts with GeoServer. */
+    val geoServer: GeoServerConfig = GeoServerConfig(),
+
     /** Configures how the server interacts with HubSpot. */
     val hubSpot: HubSpotConfig = HubSpotConfig(),
 
@@ -254,6 +257,16 @@ class TerrawareServerConfig(
       val distributionSources: List<String>? = null,
   )
 
+  class GeoServerConfig(
+      val password: String? = null,
+      val username: String? = null,
+      /**
+       * URL of the WFS API endpoint. This will look something like
+       * `https://server-hostname/geoserver/wfs`.
+       */
+      val wfsUrl: URI? = null,
+  )
+
   class BalenaConfig(
       val apiKey: String? = null,
       @DefaultValue("false") val enabled: Boolean = false,
diff --git a/src/main/kotlin/com/terraformation/backend/gis/geoserver/GeoServerClient.kt b/src/main/kotlin/com/terraformation/backend/gis/geoserver/GeoServerClient.kt
new file mode 100644
index 000000000000..0582c7b64563
--- /dev/null
+++ b/src/main/kotlin/com/terraformation/backend/gis/geoserver/GeoServerClient.kt
@@ -0,0 +1,99 @@
+package com.terraformation.backend.gis.geoserver
+
+import com.fasterxml.jackson.databind.DeserializationFeature
+import com.fasterxml.jackson.databind.MapperFeature
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.dataformat.xml.XmlMapper
+import com.fasterxml.jackson.module.kotlin.registerKotlinModule
+import com.terraformation.backend.config.TerrawareServerConfig
+import io.ktor.client.HttpClient
+import io.ktor.client.call.body
+import io.ktor.client.engine.java.Java
+import io.ktor.client.plugins.auth.Auth
+import io.ktor.client.plugins.auth.providers.BasicAuthCredentials
+import io.ktor.client.plugins.auth.providers.basic
+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.client.plugins.defaultRequest
+import io.ktor.client.request.parameter
+import io.ktor.client.request.request
+import io.ktor.client.statement.HttpResponse
+import io.ktor.http.ContentType
+import io.ktor.http.HttpMethod
+import io.ktor.serialization.jackson.JacksonConverter
+import jakarta.inject.Named
+import jakarta.ws.rs.core.MediaType
+import kotlinx.coroutines.runBlocking
+
+@Named
+class GeoServerClient(
+    private val config: TerrawareServerConfig,
+    private val objectMapper: ObjectMapper,
+) {
+  private val wfsVersion = "2.0.0"
+  private val httpClient: HttpClient by lazy { createHttpClient() }
+
+  fun getCapabilities(): WfsCapabilities {
+    return sendGetRequest("GetCapabilities")
+  }
+
+  private inline fun <reified T> sendGetRequest(
+      command: String,
+      params: Map<String, Any> = emptyMap()
+  ): T {
+    return runBlocking { sendRequest(HttpMethod.Get, command, params).body() }
+  }
+
+  private suspend fun sendRequest(
+      requestMethod: HttpMethod,
+      command: String,
+      params: Map<String, Any> = emptyMap(),
+  ): HttpResponse {
+    return httpClient.request {
+      method = requestMethod
+      parameter("request", command)
+      parameter("service", "wfs")
+      parameter("version", wfsVersion)
+      params.forEach { parameter(it.key, it.value.toString()) }
+
+      // Prefer JSON if supported for the operation; some operations only support XML and
+      // will ignore this parameter.
+      parameter("outputFormat", MediaType.APPLICATION_JSON)
+    }
+  }
+
+  private fun createHttpClient(): HttpClient {
+    val wfsUrl =
+        config.geoServer.wfsUrl ?: throw IllegalStateException("GeoServer URL not configured")
+
+    return HttpClient(Java) {
+      defaultRequest { url(wfsUrl.toString()) }
+
+      install(ContentNegotiation) {
+        register(ContentType.Application.Json, JacksonConverter(objectMapper))
+        register(
+            ContentType.Application.Xml,
+            JacksonConverter(
+                XmlMapper.builder()
+                    .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+                    .configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES, true)
+                    .defaultUseWrapper(false)
+                    .build()
+                    .registerKotlinModule()))
+      }
+
+      // By default, throw exceptions if we get non-2xx responses.
+      expectSuccess = true
+
+      // Use basic authentication if configured; otherwise send anonymous requests.
+      if (config.geoServer.username != null) {
+        install(Auth) {
+          basic {
+            credentials {
+              BasicAuthCredentials(config.geoServer.username!!, config.geoServer.password ?: "")
+            }
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/src/main/kotlin/com/terraformation/backend/gis/geoserver/WfsCapabilities.kt b/src/main/kotlin/com/terraformation/backend/gis/geoserver/WfsCapabilities.kt
new file mode 100644
index 000000000000..f63a31649e8f
--- /dev/null
+++ b/src/main/kotlin/com/terraformation/backend/gis/geoserver/WfsCapabilities.kt
@@ -0,0 +1,35 @@
+package com.terraformation.backend.gis.geoserver
+
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty
+import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement
+
+data class FeatureType(
+    val name: String,
+    val title: String = name,
+)
+
+data class FeatureTypeList(
+    @JacksonXmlProperty(localName = "FeatureType") //
+    val featureTypes: List<FeatureType>,
+)
+
+data class Operation(@JacksonXmlProperty(isAttribute = true) val name: String)
+
+data class OperationsMetadata(
+    @JacksonXmlProperty(localName = "Operation") //
+    val operations: List<Operation>,
+)
+
+@JacksonXmlRootElement(localName = "WFS_Capabilities")
+data class WfsCapabilities(
+    @JacksonXmlProperty(localName = "FeatureTypeList") //
+    val featureTypeList: FeatureTypeList,
+    @JacksonXmlProperty(localName = "OperationsMetadata")
+    val operationsMetadata: OperationsMetadata,
+) {
+  val featureTypes
+    get() = featureTypeList.featureTypes
+
+  val operations
+    get() = operationsMetadata.operations
+}
diff --git a/src/test/kotlin/com/terraformation/backend/gis/geoserver/GeoServerClientExternalTest.kt b/src/test/kotlin/com/terraformation/backend/gis/geoserver/GeoServerClientExternalTest.kt
new file mode 100644
index 000000000000..d7849bcf289c
--- /dev/null
+++ b/src/test/kotlin/com/terraformation/backend/gis/geoserver/GeoServerClientExternalTest.kt
@@ -0,0 +1,48 @@
+package com.terraformation.backend.gis.geoserver
+
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import com.terraformation.backend.config.TerrawareServerConfig
+import com.terraformation.backend.db.GeometryModule
+import com.terraformation.backend.getEnvOrSkipTest
+import java.net.URI
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+
+class GeoServerClientExternalTest {
+  private lateinit var client: GeoServerClient
+
+  @BeforeEach
+  fun setUp() {
+    val wfsUrl = URI.create(getEnvOrSkipTest("TERRAWARE_GEOSERVER_WFSURL"))
+    val username = System.getenv("TERRAWARE_GEOSERVER_USERNAME")
+    val password = System.getenv("TERRAWARE_GEOSERVER_PASSWORD")
+
+    val config =
+        TerrawareServerConfig(
+            geoServer =
+                TerrawareServerConfig.GeoServerConfig(
+                    password = password,
+                    username = username,
+                    wfsUrl = wfsUrl,
+                ),
+            keycloak =
+                TerrawareServerConfig.KeycloakConfig(
+                    apiClientId = "test",
+                    apiClientGroupName = "test",
+                    apiClientUsernamePrefix = "test"),
+            webAppUrl = URI("https://terraware.io"),
+        )
+
+    client = GeoServerClient(config, jacksonObjectMapper().registerModule(GeometryModule()))
+  }
+
+  @Test
+  fun `can get capabilities`() {
+    val capabilities = client.getCapabilities()
+
+    assertThat(capabilities.operations)
+        .extracting("name")
+        .containsAll(listOf("DescribeFeatureType", "GetFeature"))
+  }
+}


Reviews:
user: login: ice1080
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-29T22:50:03Z
user: login: sgrimm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-30T18:04:17Z
user: login: ice1080
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-30T18:05:16Z

Review comments:
diff_hunk: @@ -0,0 +1,100 @@
+package com.terraformation.backend.gis.geoserver
+
+import com.fasterxml.jackson.databind.DeserializationFeature
+import com.fasterxml.jackson.databind.MapperFeature
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.dataformat.xml.XmlMapper
+import com.fasterxml.jackson.module.kotlin.registerKotlinModule
+import com.terraformation.backend.config.TerrawareServerConfig
+import io.ktor.client.HttpClient
+import io.ktor.client.call.body
+import io.ktor.client.engine.java.Java
+import io.ktor.client.plugins.auth.Auth
+import io.ktor.client.plugins.auth.providers.BasicAuthCredentials
+import io.ktor.client.plugins.auth.providers.basic
+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.client.plugins.defaultRequest
+import io.ktor.client.request.get
+import io.ktor.client.request.parameter
+import io.ktor.client.request.request
+import io.ktor.client.statement.HttpResponse
+import io.ktor.http.ContentType
+import io.ktor.http.HttpMethod
+import io.ktor.serialization.jackson.JacksonConverter
+import jakarta.inject.Named
+import jakarta.ws.rs.core.MediaType
+import kotlinx.coroutines.runBlocking
+
+@Named
+class GeoServerClient(
+    private val config: TerrawareServerConfig,
+    private val objectMapper: ObjectMapper,
+) {
+  private val wfsVersion = "2.0.0"
+  private val httpClient: HttpClient by lazy { createHttpClient() }
+
+  fun getCapabilities(): WfsCapabilities {
+    return sendGetRequest("GetCapabilities")
+  }
+
+  private inline fun <reified T> sendGetRequest(
+      command: String,
+      params: Map<String, Any> = emptyMap()
+  ): T {
+    return runBlocking { sendRequest(HttpMethod.Get, command, params).body() }
+  }
+
+  private suspend fun sendRequest(
+      requestMethod: HttpMethod,
+      command: String,
+      params: Map<String, Any> = emptyMap(),
+  ): HttpResponse {
+    return httpClient.request {
+      method = requestMethod
+      parameter("request", command)
+      parameter("service", "wfs")
+      parameter("version", wfsVersion)
+      params.forEach { parameter(it.key, it.value.toString()) }
+
+      // Prefer JSON if supported for the operation; some operations only support XML and
+      // will ignore this parameter.
+      parameter("outputFormat", MediaType.APPLICATION_JSON)
+    }
+  }
+
+  private fun createHttpClient(): HttpClient {
+    val wfsUrl =
+        config.geoServer.wfsUrl ?: throw IllegalStateException("GeoServer URL not configured")
path: src/main/kotlin/com/terraformation/backend/gis/geoserver/GeoServerClient.kt
user: login: ice1080
body: Not sure if it matters or not, but is there any concern about the url not being configured and us not knowing about it until someone makes a geoserver request because of the lazy instantiation here?
created_at: 2025-05-29T22:48:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,100 @@
+package com.terraformation.backend.gis.geoserver
+
+import com.fasterxml.jackson.databind.DeserializationFeature
+import com.fasterxml.jackson.databind.MapperFeature
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.dataformat.xml.XmlMapper
+import com.fasterxml.jackson.module.kotlin.registerKotlinModule
+import com.terraformation.backend.config.TerrawareServerConfig
+import io.ktor.client.HttpClient
+import io.ktor.client.call.body
+import io.ktor.client.engine.java.Java
+import io.ktor.client.plugins.auth.Auth
+import io.ktor.client.plugins.auth.providers.BasicAuthCredentials
+import io.ktor.client.plugins.auth.providers.basic
+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.client.plugins.defaultRequest
+import io.ktor.client.request.get
+import io.ktor.client.request.parameter
+import io.ktor.client.request.request
+import io.ktor.client.statement.HttpResponse
+import io.ktor.http.ContentType
+import io.ktor.http.HttpMethod
+import io.ktor.serialization.jackson.JacksonConverter
+import jakarta.inject.Named
+import jakarta.ws.rs.core.MediaType
+import kotlinx.coroutines.runBlocking
+
+@Named
+class GeoServerClient(
+    private val config: TerrawareServerConfig,
+    private val objectMapper: ObjectMapper,
+) {
+  private val wfsVersion = "2.0.0"
+  private val httpClient: HttpClient by lazy { createHttpClient() }
+
+  fun getCapabilities(): WfsCapabilities {
+    return sendGetRequest("GetCapabilities")
+  }
+
+  private inline fun <reified T> sendGetRequest(
+      command: String,
+      params: Map<String, Any> = emptyMap()
+  ): T {
+    return runBlocking { sendRequest(HttpMethod.Get, command, params).body() }
+  }
+
+  private suspend fun sendRequest(
+      requestMethod: HttpMethod,
+      command: String,
+      params: Map<String, Any> = emptyMap(),
+  ): HttpResponse {
+    return httpClient.request {
+      method = requestMethod
+      parameter("request", command)
+      parameter("service", "wfs")
+      parameter("version", wfsVersion)
+      params.forEach { parameter(it.key, it.value.toString()) }
+
+      // Prefer JSON if supported for the operation; some operations only support XML and
+      // will ignore this parameter.
+      parameter("outputFormat", MediaType.APPLICATION_JSON)
+    }
+  }
+
+  private fun createHttpClient(): HttpClient {
+    val wfsUrl =
+        config.geoServer.wfsUrl ?: throw IllegalStateException("GeoServer URL not configured")
path: src/main/kotlin/com/terraformation/backend/gis/geoserver/GeoServerClient.kt
user: login: sgrimm
body: The idea is to not require people to set this up in dev environments where they aren't interacting with GeoServer. We use the same pattern for our other external integrations like HubSpot and Atlassian.
created_at: 2025-05-30T18:04:17Z
author_association: CONTRIBUTOR

PR comments:
user: login: sgrimm
created_at: 2025-05-29T22:19:03Z
body: * **#3146** <a href="https://app.graphite.dev/github/pr/terraware/terraware-server/3146?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#3145** <a href="https://app.graphite.dev/github/pr/terraware/terraware-server/3145?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈 <a href="https://app.graphite.dev/github/pr/terraware/terraware-server/3145?utm_source=stack-comment-view-in-graphite" target="_blank">(View in Graphite)</a>
* `main`


<h2></h2>

This stack of pull requests is managed by <a href="https://graphite.dev?utm-source=stack-comment"><b>Graphite</b></a>. Learn more about <a href="https://stacking.dev/?utm_source=stack-comment">stacking</a>.
<!-- Current dependencies on/for this PR: -->
author_association: CONTRIBUTOR
user: login: sgrimm
created_at: 2025-05-30T18:06:58Z
body: ### Merge activity

* **May 30, 6:06 PM UTC**: A user started a stack merge that includes this pull request via [Graphite](https://app.graphite.dev/github/pr/terraware/terraware-server/3145).
* **May 30, 6:07 PM UTC**: @sgrimm merged this pull request with [Graphite](https://app.graphite.dev/github/pr/terraware/terraware-server/3145).

author_association: CONTRIBUTOR