summaryrefslogtreecommitdiff
path: root/opendc-web/opendc-web-client/src
diff options
context:
space:
mode:
authorFabian Mastenbroek <mail.fabianm@gmail.com>2022-04-04 17:00:31 +0200
committerGitHub <noreply@github.com>2022-04-04 17:00:31 +0200
commit38769373c7e89783d33849283586bfa0b62e8251 (patch)
tree4fda128ee6b30018c1aa14c584cc53ade80e67f7 /opendc-web/opendc-web-client/src
parent6021aa4278bebb34bf5603ead4b5daeabcdc4c19 (diff)
parent527ae2230f5c2dd22f496f45d5d8e3bd4acdb854 (diff)
merge: Migrate to Quarkus-based web API
This pull request changes the web API to a Quarkus-based version. Currently, the OpenDC web API is written in Python (using Flask). Although Python is a powerful language to develop web services, having another language next to Kotlin/Java and JavaScript introduces some challenges. For instance, the web API and UI lack integration with our Gradle-based build pipeline and require additional steps from the developer to start working with. Furthermore, deploying OpenDC requires having Python installed in addition to the JVM. By converting the web API into a Quarkus application, we can enjoy further integration with our Gradle-based build pipeline and simplify the development/deployment process of OpenDC, by requiring only the JVM and Node to work with OpenDC. ## Implementation Notes :hammer_and_pick: * Move build dependencies into version catalog * Design unified communication protocol * Add Quarkus API implementation * Add new web client implementation * Update runner to use new web client * Fix compatibility with React.js UI * Remove Python build steps from CI pipeline * Update Docker deployment for new web API * Remove obsolete database configuration ## External Dependencies :four_leaf_clover: * Quarkus ## Breaking API Changes :warning: * The new web API only supports SQL-based databases for storing user-data, as opposed to MongoDB currently. We intend to use H2 for development and Postgres for production.
Diffstat (limited to 'opendc-web/opendc-web-client/src')
-rw-r--r--opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/OpenDCClient.kt73
-rw-r--r--opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/PortfolioResource.kt58
-rw-r--r--opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/ProjectResource.kt52
-rw-r--r--opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/ScenarioResource.kt63
-rw-r--r--opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/SchedulerResource.kt36
-rw-r--r--opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/TopologyResource.kt66
-rw-r--r--opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/TraceResource.kt42
-rw-r--r--opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/auth/AuthController.kt40
-rw-r--r--opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/auth/OpenIdAuthController.kt140
-rw-r--r--opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/ClientUtils.kt54
-rw-r--r--opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/OAuthTokenRequest.kt62
-rw-r--r--opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/OAuthTokenResponse.kt40
-rw-r--r--opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/OpenIdConfiguration.kt43
-rw-r--r--opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/runner/JobResource.kt47
-rw-r--r--opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/runner/OpenDCRunnerClient.kt59
-rw-r--r--opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/transport/HttpTransportClient.kt145
-rw-r--r--opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/transport/TransportClient.kt50
17 files changed, 1070 insertions, 0 deletions
diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/OpenDCClient.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/OpenDCClient.kt
new file mode 100644
index 00000000..33f2b41e
--- /dev/null
+++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/OpenDCClient.kt
@@ -0,0 +1,73 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.client
+
+import org.opendc.web.client.auth.AuthController
+import org.opendc.web.client.transport.HttpTransportClient
+import org.opendc.web.client.transport.TransportClient
+import java.net.URI
+
+/**
+ * Client implementation for the user-facing OpenDC REST API (version 2).
+ *
+ * @param client Low-level client for managing the underlying transport.
+ */
+public class OpenDCClient(client: TransportClient) {
+ /**
+ * Construct a new [OpenDCClient].
+ *
+ * @param baseUrl The base url of the API.
+ * @param auth Helper class for managing authentication.
+ */
+ public constructor(baseUrl: URI, auth: AuthController) : this(HttpTransportClient(baseUrl, auth))
+
+ /**
+ * A resource for the available projects.
+ */
+ public val projects: ProjectResource = ProjectResource(client)
+
+ /**
+ * A resource for the topologies available to the user.
+ */
+ public val topologies: TopologyResource = TopologyResource(client)
+
+ /**
+ * A resource for the portfolios available to the user.
+ */
+ public val portfolios: PortfolioResource = PortfolioResource(client)
+
+ /**
+ * A resource for the scenarios available to the user.
+ */
+ public val scenarios: ScenarioResource = ScenarioResource(client)
+
+ /**
+ * A resource for the available schedulers.
+ */
+ public val schedulers: SchedulerResource = SchedulerResource(client)
+
+ /**
+ * A resource for the available workload traces.
+ */
+ public val traces: TraceResource = TraceResource(client)
+}
diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/PortfolioResource.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/PortfolioResource.kt
new file mode 100644
index 00000000..399804e8
--- /dev/null
+++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/PortfolioResource.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.client
+
+import org.opendc.web.client.internal.delete
+import org.opendc.web.client.internal.get
+import org.opendc.web.client.internal.post
+import org.opendc.web.client.transport.TransportClient
+import org.opendc.web.proto.user.Portfolio
+
+/**
+ * A resource representing the portfolios available to the user.
+ */
+public class PortfolioResource internal constructor(private val client: TransportClient) {
+ /**
+ * List all portfolios that belong to the specified [project].
+ */
+ public fun getAll(project: Long): List<Portfolio> = client.get("projects/$project/portfolios") ?: emptyList()
+
+ /**
+ * Obtain the portfolio for [project] with [number].
+ */
+ public fun get(project: Long, number: Int): Portfolio? = client.get("projects/$project/portfolios/$number")
+
+ /**
+ * Create a new portfolio for [project] with the specified [request].
+ */
+ public fun create(project: Long, request: Portfolio.Create): Portfolio {
+ return checkNotNull(client.post("projects/$project/portfolios", request))
+ }
+
+ /**
+ * Delete the portfolio for [project] with [index].
+ */
+ public fun delete(project: Long, index: Int): Portfolio {
+ return requireNotNull(client.delete("projects/$project/portfolios/$index")) { "Unknown portfolio $index" }
+ }
+}
diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/ProjectResource.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/ProjectResource.kt
new file mode 100644
index 00000000..12635b89
--- /dev/null
+++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/ProjectResource.kt
@@ -0,0 +1,52 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.client
+
+import org.opendc.web.client.internal.*
+import org.opendc.web.client.transport.TransportClient
+import org.opendc.web.proto.user.Project
+
+/**
+ * A resource representing the projects available to the user.
+ */
+public class ProjectResource internal constructor(private val client: TransportClient) {
+ /**
+ * List all projects available to the user.
+ */
+ public fun getAll(): List<Project> = client.get("projects") ?: emptyList()
+
+ /**
+ * Obtain the project with [id].
+ */
+ public fun get(id: Long): Project? = client.get("projects/$id")
+
+ /**
+ * Create a new project.
+ */
+ public fun create(name: String): Project = checkNotNull(client.post("projects", Project.Create(name)))
+
+ /**
+ * Delete the project with the specified [id].
+ */
+ public fun delete(id: Long): Project = requireNotNull(client.delete("projects/$id")) { "Unknown project $id" }
+}
diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/ScenarioResource.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/ScenarioResource.kt
new file mode 100644
index 00000000..7055e752
--- /dev/null
+++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/ScenarioResource.kt
@@ -0,0 +1,63 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.client
+
+import org.opendc.web.client.internal.delete
+import org.opendc.web.client.internal.get
+import org.opendc.web.client.internal.post
+import org.opendc.web.client.transport.TransportClient
+import org.opendc.web.proto.user.Scenario
+
+/**
+ * A resource representing the scenarios available to the user.
+ */
+public class ScenarioResource internal constructor(private val client: TransportClient) {
+ /**
+ * List all scenarios that belong to the specified [project].
+ */
+ public fun getAll(project: Long): List<Scenario> = client.get("projects/$project/scenarios") ?: emptyList()
+
+ /**
+ * List all scenarios that belong to the specified [portfolioNumber].
+ */
+ public fun getAll(project: Long, portfolioNumber: Int): List<Scenario> = client.get("projects/$project/portfolios/$portfolioNumber/scenarios") ?: emptyList()
+
+ /**
+ * Obtain the scenario for [project] with [index].
+ */
+ public fun get(project: Long, index: Int): Scenario? = client.get("projects/$project/scenarios/$index")
+
+ /**
+ * Create a new scenario for [portfolio][portfolioNumber] with the specified [request].
+ */
+ public fun create(project: Long, portfolioNumber: Int, request: Scenario.Create): Scenario {
+ return checkNotNull(client.post("projects/$project/portfolios/$portfolioNumber", request))
+ }
+
+ /**
+ * Delete the scenario for [project] with [index].
+ */
+ public fun delete(project: Long, index: Int): Scenario {
+ return requireNotNull(client.delete("projects/$project/scenarios/$index")) { "Unknown scenario $index" }
+ }
+}
diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/SchedulerResource.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/SchedulerResource.kt
new file mode 100644
index 00000000..43b72d88
--- /dev/null
+++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/SchedulerResource.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.client
+
+import org.opendc.web.client.internal.get
+import org.opendc.web.client.transport.TransportClient
+
+/**
+ * A resource representing the schedulers available in the OpenDC instance.
+ */
+public class SchedulerResource internal constructor(private val client: TransportClient) {
+ /**
+ * List all schedulers available.
+ */
+ public fun getAll(): List<String> = client.get("schedulers") ?: emptyList()
+}
diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/TopologyResource.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/TopologyResource.kt
new file mode 100644
index 00000000..c37ae8da
--- /dev/null
+++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/TopologyResource.kt
@@ -0,0 +1,66 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.client
+
+import org.opendc.web.client.internal.delete
+import org.opendc.web.client.internal.get
+import org.opendc.web.client.internal.post
+import org.opendc.web.client.internal.put
+import org.opendc.web.client.transport.TransportClient
+import org.opendc.web.proto.user.Topology
+
+/**
+ * A resource representing the topologies available to the user.
+ */
+public class TopologyResource internal constructor(private val client: TransportClient) {
+ /**
+ * List all topologies that belong to the specified [project].
+ */
+ public fun getAll(project: Long): List<Topology> = client.get("projects/$project/topologies") ?: emptyList()
+
+ /**
+ * Obtain the topology for [project] with [index].
+ */
+ public fun get(project: Long, index: Int): Topology? = client.get("projects/$project/topologies/$index")
+
+ /**
+ * Create a new topology for [project] with [request].
+ */
+ public fun create(project: Long, request: Topology.Create): Topology {
+ return checkNotNull(client.post("projects/$project/topologies", request))
+ }
+
+ /**
+ * Update the topology with [index] for [project] using the specified [request].
+ */
+ public fun update(project: Long, index: Int, request: Topology.Update): Topology? {
+ return client.put("projects/$project/topologies/$index", request)
+ }
+
+ /**
+ * Delete the topology for [project] with [index].
+ */
+ public fun delete(project: Long, index: Long): Topology {
+ return requireNotNull(client.delete("projects/$project/topologies/$index")) { "Unknown topology $index" }
+ }
+}
diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/TraceResource.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/TraceResource.kt
new file mode 100644
index 00000000..8201c432
--- /dev/null
+++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/TraceResource.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.client
+
+import org.opendc.web.client.internal.*
+import org.opendc.web.client.transport.TransportClient
+import org.opendc.web.proto.Trace
+
+/**
+ * A resource representing the workload traces available in the OpenDC instance.
+ */
+public class TraceResource internal constructor(private val client: TransportClient) {
+ /**
+ * List all workload traces available.
+ */
+ public fun getAll(): List<Trace> = client.get("traces") ?: emptyList()
+
+ /**
+ * Obtain the workload trace with the specified [id].
+ */
+ public fun get(id: Long): Trace? = client.get("traces/$id")
+}
diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/auth/AuthController.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/auth/AuthController.kt
new file mode 100644
index 00000000..a4c66f55
--- /dev/null
+++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/auth/AuthController.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.client.auth
+
+import java.net.http.HttpRequest
+
+/**
+ * Helper interface for managing API authentication.
+ */
+public interface AuthController {
+ /**
+ * Inject the authorization token into the specified [request].
+ */
+ public fun injectToken(request: HttpRequest.Builder)
+
+ /**
+ * Refresh the current auth token.
+ */
+ public fun refreshToken()
+}
diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/auth/OpenIdAuthController.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/auth/OpenIdAuthController.kt
new file mode 100644
index 00000000..7f9cbacd
--- /dev/null
+++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/auth/OpenIdAuthController.kt
@@ -0,0 +1,140 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.client.auth
+
+import com.fasterxml.jackson.databind.DeserializationFeature
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import com.fasterxml.jackson.module.kotlin.readValue
+import org.opendc.web.client.internal.OAuthTokenRequest
+import org.opendc.web.client.internal.OAuthTokenResponse
+import org.opendc.web.client.internal.OpenIdConfiguration
+import java.net.URI
+import java.net.http.HttpClient
+import java.net.http.HttpRequest
+import java.net.http.HttpResponse
+
+/**
+ * An [AuthController] for OpenID Connect protected APIs.
+ */
+public class OpenIdAuthController(
+ private val domain: String,
+ private val clientId: String,
+ private val clientSecret: String,
+ private val audience: String = "https://api.opendc.org/v2/",
+ private val client: HttpClient = HttpClient.newHttpClient()
+) : AuthController {
+ /**
+ * The Jackson object mapper to convert messages from/to JSON.
+ */
+ private val mapper = jacksonObjectMapper()
+ .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+
+ /**
+ * The cached [OpenIdConfiguration].
+ */
+ private val openidConfig: OpenIdConfiguration
+ get() {
+ var openidConfig = _openidConfig
+ if (openidConfig == null) {
+ openidConfig = requestConfig()
+ _openidConfig = openidConfig
+ }
+
+ return openidConfig
+ }
+ private var _openidConfig: OpenIdConfiguration? = null
+
+ /**
+ * The cached OAuth token.
+ */
+ private var _token: OAuthTokenResponse? = null
+
+ override fun injectToken(request: HttpRequest.Builder) {
+ var token = _token
+ if (token == null) {
+ token = requestToken()
+ _token = token
+ }
+
+ request.header("Authorization", "Bearer ${token.accessToken}")
+ }
+
+ /**
+ * Refresh the current access token.
+ */
+ override fun refreshToken() {
+ val refreshToken = _token?.refreshToken
+ if (refreshToken == null) {
+ requestToken()
+ return
+ }
+
+ _token = refreshToken(openidConfig, refreshToken)
+ }
+
+ /**
+ * Request the OpenID configuration from the chosen auth domain
+ */
+ private fun requestConfig(): OpenIdConfiguration {
+ val request = HttpRequest.newBuilder(URI("https://$domain/.well-known/openid-configuration"))
+ .GET()
+ .build()
+ val response = client.send(request, HttpResponse.BodyHandlers.ofInputStream())
+ return mapper.readValue(response.body())
+ }
+
+ /**
+ * Request the auth token from the server.
+ */
+ private fun requestToken(openidConfig: OpenIdConfiguration): OAuthTokenResponse {
+ val body = OAuthTokenRequest.ClientCredentials(audience, clientId, clientSecret)
+ val request = HttpRequest.newBuilder(openidConfig.tokenEndpoint)
+ .header("Content-Type", "application/json")
+ .POST(HttpRequest.BodyPublishers.ofByteArray(mapper.writeValueAsBytes(body)))
+ .build()
+ val response = client.send(request, HttpResponse.BodyHandlers.ofInputStream())
+ return mapper.readValue(response.body())
+ }
+
+ /**
+ * Helper method to refresh the auth token.
+ */
+ private fun refreshToken(openidConfig: OpenIdConfiguration, refreshToken: String): OAuthTokenResponse {
+ val body = OAuthTokenRequest.RefreshToken(refreshToken, clientId, clientSecret)
+ val request = HttpRequest.newBuilder(openidConfig.tokenEndpoint)
+ .header("Content-Type", "application/json")
+ .POST(HttpRequest.BodyPublishers.ofByteArray(mapper.writeValueAsBytes(body)))
+ .build()
+ val response = client.send(request, HttpResponse.BodyHandlers.ofInputStream())
+ return mapper.readValue(response.body())
+ }
+
+ /**
+ * Fetch a new access token.
+ */
+ private fun requestToken(): OAuthTokenResponse {
+ val token = requestToken(openidConfig)
+ _token = token
+ return token
+ }
+}
diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/ClientUtils.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/ClientUtils.kt
new file mode 100644
index 00000000..29cf09dc
--- /dev/null
+++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/ClientUtils.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.client.internal
+
+import com.fasterxml.jackson.core.type.TypeReference
+import org.opendc.web.client.transport.TransportClient
+
+/**
+ * Perform a GET request for resource at [path] and convert to type [T].
+ */
+internal inline fun <reified T> TransportClient.get(path: String): T? {
+ return get(path, object : TypeReference<T>() {})
+}
+
+/**
+ * Perform a POST request for resource at [path] and convert to type [T].
+ */
+internal inline fun <B, reified T> TransportClient.post(path: String, body: B): T? {
+ return post(path, body, object : TypeReference<T>() {})
+}
+
+/**
+ * Perform a PUT request for resource at [path] and convert to type [T].
+ */
+internal inline fun <B, reified T> TransportClient.put(path: String, body: B): T? {
+ return put(path, body, object : TypeReference<T>() {})
+}
+
+/**
+ * Perform a DELETE request for resource at [path] and convert to type [T].
+ */
+internal inline fun <reified T> TransportClient.delete(path: String): T? {
+ return delete(path, object : TypeReference<T>() {})
+}
diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/OAuthTokenRequest.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/OAuthTokenRequest.kt
new file mode 100644
index 00000000..25341995
--- /dev/null
+++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/OAuthTokenRequest.kt
@@ -0,0 +1,62 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.client.internal
+
+import com.fasterxml.jackson.annotation.JsonProperty
+import com.fasterxml.jackson.annotation.JsonSubTypes
+import com.fasterxml.jackson.annotation.JsonTypeInfo
+
+/**
+ * Token request sent to the OAuth server.
+ */
+@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "grant_type")
+@JsonSubTypes(
+ value = [
+ JsonSubTypes.Type(value = OAuthTokenRequest.ClientCredentials::class, name = "client_credentials"),
+ JsonSubTypes.Type(value = OAuthTokenRequest.RefreshToken::class, name = "refresh_token")
+ ]
+)
+internal sealed class OAuthTokenRequest {
+ /**
+ * Client credentials grant for OAuth2
+ */
+ data class ClientCredentials(
+ val audience: String,
+ @JsonProperty("client_id")
+ val clientId: String,
+ @JsonProperty("client_secret")
+ val clientSecret: String
+ ) : OAuthTokenRequest()
+
+ /**
+ * Refresh token grant for OAuth2.
+ */
+ data class RefreshToken(
+ @JsonProperty("refresh_token")
+ val refreshToken: String,
+ @JsonProperty("client_id")
+ val clientId: String,
+ @JsonProperty("client_secret")
+ val clientSecret: String
+ ) : OAuthTokenRequest()
+}
diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/OAuthTokenResponse.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/OAuthTokenResponse.kt
new file mode 100644
index 00000000..cd5ccab0
--- /dev/null
+++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/OAuthTokenResponse.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.client.internal
+
+import com.fasterxml.jackson.annotation.JsonProperty
+
+/**
+ * Token response from the OAuth server.
+ */
+internal data class OAuthTokenResponse(
+ @JsonProperty("access_token")
+ val accessToken: String,
+ @JsonProperty("refresh_token")
+ val refreshToken: String? = null,
+ @JsonProperty("token_type")
+ val tokenType: String,
+ val scope: String = "",
+ @JsonProperty("expires_in")
+ val expiresIn: Long
+)
diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/OpenIdConfiguration.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/OpenIdConfiguration.kt
new file mode 100644
index 00000000..23fbf368
--- /dev/null
+++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/internal/OpenIdConfiguration.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.client.internal
+
+import com.fasterxml.jackson.annotation.JsonProperty
+import java.net.URI
+
+/**
+ * OpenID configuration exposed by the auth server.
+ */
+internal data class OpenIdConfiguration(
+ val issuer: String,
+ @JsonProperty("authorization_endpoint")
+ val authorizationEndpoint: URI,
+ @JsonProperty("token_endpoint")
+ val tokenEndpoint: URI,
+ @JsonProperty("userinfo_endpoint")
+ val userInfoEndpoint: URI,
+ @JsonProperty("jwks_uri")
+ val jwksUri: URI,
+ @JsonProperty("scopes_supported")
+ val scopesSupported: Set<String>
+)
diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/runner/JobResource.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/runner/JobResource.kt
new file mode 100644
index 00000000..372a92d7
--- /dev/null
+++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/runner/JobResource.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.client.runner
+
+import org.opendc.web.client.internal.*
+import org.opendc.web.client.transport.TransportClient
+import org.opendc.web.proto.runner.Job
+
+/**
+ * A resource representing the available simulation jobs for the runner.
+ */
+public class JobResource internal constructor(private val client: TransportClient) {
+ /**
+ * Query the pending jobs.
+ */
+ public fun queryPending(): List<Job> = client.get("jobs") ?: emptyList()
+
+ /**
+ * Obtain the job with [id].
+ */
+ public fun get(id: Long): Job? = client.get("jobs/$id")
+
+ /**
+ * Update the job with [id].
+ */
+ public fun update(id: Long, update: Job.Update): Job? = client.post("jobs/$id", update)
+}
diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/runner/OpenDCRunnerClient.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/runner/OpenDCRunnerClient.kt
new file mode 100644
index 00000000..a3cff6c3
--- /dev/null
+++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/runner/OpenDCRunnerClient.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.client.runner
+
+import org.opendc.web.client.*
+import org.opendc.web.client.auth.AuthController
+import org.opendc.web.client.transport.HttpTransportClient
+import org.opendc.web.client.transport.TransportClient
+import java.net.URI
+
+/**
+ * Client implementation for the runner-facing OpenDC REST API (version 2).
+ *
+ * @param client Low-level client for managing the underlying transport.
+ */
+public class OpenDCRunnerClient(client: TransportClient) {
+ /**
+ * Construct a new [OpenDCRunnerClient].
+ *
+ * @param baseUrl The base url of the API.
+ * @param auth Helper class for managing authentication.
+ */
+ public constructor(baseUrl: URI, auth: AuthController) : this(HttpTransportClient(baseUrl, auth))
+
+ /**
+ * A resource for the available simulation jobs.
+ */
+ public val jobs: JobResource = JobResource(client)
+
+ /**
+ * A resource for the available schedulers.
+ */
+ public val schedulers: SchedulerResource = SchedulerResource(client)
+
+ /**
+ * A resource for the available workload traces.
+ */
+ public val traces: TraceResource = TraceResource(client)
+}
diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/transport/HttpTransportClient.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/transport/HttpTransportClient.kt
new file mode 100644
index 00000000..03b3945f
--- /dev/null
+++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/transport/HttpTransportClient.kt
@@ -0,0 +1,145 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.client.transport
+
+import com.fasterxml.jackson.core.type.TypeReference
+import com.fasterxml.jackson.databind.DeserializationFeature
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import org.opendc.web.client.auth.AuthController
+import java.net.URI
+import java.net.http.HttpClient
+import java.net.http.HttpRequest
+import java.net.http.HttpResponse
+import java.nio.file.Paths
+
+/**
+ * A [TransportClient] that accesses the OpenDC API over HTTP.
+ *
+ * @param baseUrl The base url of the API.
+ * @param auth Helper class for managing authentication.
+ * @param client The HTTP client to use.
+ */
+public class HttpTransportClient(
+ private val baseUrl: URI,
+ private val auth: AuthController,
+ private val client: HttpClient = HttpClient.newHttpClient()
+) : TransportClient {
+ /**
+ * The Jackson object mapper to convert messages from/to JSON.
+ */
+ private val mapper = jacksonObjectMapper()
+ .registerModule(JavaTimeModule())
+ .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+
+ /**
+ * Obtain a resource at [path] of [targetType].
+ */
+ override fun <T> get(path: String, targetType: TypeReference<T>): T? {
+ val request = HttpRequest.newBuilder(buildUri(path))
+ .GET()
+ .also { auth.injectToken(it) }
+ .build()
+ val response = client.send(request, HttpResponse.BodyHandlers.ofInputStream())
+
+ return when (val code = response.statusCode()) {
+ in 200..299 -> mapper.readValue(response.body(), targetType)
+ 401 -> {
+ auth.refreshToken()
+ get(path, targetType)
+ }
+ 404 -> null
+ else -> throw IllegalStateException("Invalid response $code")
+ }
+ }
+
+ /**
+ * Update a resource at [path] of [targetType].
+ */
+ override fun <B, T> post(path: String, body: B, targetType: TypeReference<T>): T? {
+ val request = HttpRequest.newBuilder(buildUri(path))
+ .POST(HttpRequest.BodyPublishers.ofByteArray(mapper.writeValueAsBytes(body)))
+ .header("Content-Type", "application/json")
+ .also { auth.injectToken(it) }
+ .build()
+ val response = client.send(request, HttpResponse.BodyHandlers.ofInputStream())
+
+ return when (val code = response.statusCode()) {
+ in 200..299 -> mapper.readValue(response.body(), targetType)
+ 401 -> {
+ auth.refreshToken()
+ post(path, body, targetType)
+ }
+ 404 -> null
+ else -> throw IllegalStateException("Invalid response $code")
+ }
+ }
+
+ /**
+ * Replace a resource at [path] of [targetType].
+ */
+ override fun <B, T> put(path: String, body: B, targetType: TypeReference<T>): T? {
+ val request = HttpRequest.newBuilder(buildUri(path))
+ .PUT(HttpRequest.BodyPublishers.ofByteArray(mapper.writeValueAsBytes(body)))
+ .header("Content-Type", "application/json")
+ .also { auth.injectToken(it) }
+ .build()
+ val response = client.send(request, HttpResponse.BodyHandlers.ofInputStream())
+
+ return when (val code = response.statusCode()) {
+ in 200..299 -> mapper.readValue(response.body(), targetType)
+ 401 -> {
+ auth.refreshToken()
+ put(path, body, targetType)
+ }
+ 404 -> null
+ else -> throw IllegalStateException("Invalid response $code")
+ }
+ }
+
+ /**
+ * Delete a resource at [path] of [targetType].
+ */
+ override fun <T> delete(path: String, targetType: TypeReference<T>): T? {
+ val request = HttpRequest.newBuilder(buildUri(path))
+ .DELETE()
+ .also { auth.injectToken(it) }
+ .build()
+ val response = client.send(request, HttpResponse.BodyHandlers.ofInputStream())
+
+ return when (val code = response.statusCode()) {
+ in 200..299 -> mapper.readValue(response.body(), targetType)
+ 401 -> {
+ auth.refreshToken()
+ delete(path, targetType)
+ }
+ 404 -> null
+ else -> throw IllegalStateException("Invalid response $code")
+ }
+ }
+
+ /**
+ * Build the absolute [URI] to which the request should be sent.
+ */
+ private fun buildUri(path: String): URI = baseUrl.resolve(Paths.get(baseUrl.path, path).toString())
+}
diff --git a/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/transport/TransportClient.kt b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/transport/TransportClient.kt
new file mode 100644
index 00000000..af727ca7
--- /dev/null
+++ b/opendc-web/opendc-web-client/src/main/kotlin/org/opendc/web/client/transport/TransportClient.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright (c) 2022 AtLarge Research
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.opendc.web.client.transport
+
+import com.fasterxml.jackson.core.type.TypeReference
+
+/**
+ * Low-level interface for dealing with the transport layer of the API.
+ */
+public interface TransportClient {
+ /**
+ * Obtain a resource at [path] of [targetType].
+ */
+ public fun <T> get(path: String, targetType: TypeReference<T>): T?
+
+ /**
+ * Update a resource at [path] of [targetType].
+ */
+ public fun <B, T> post(path: String, body: B, targetType: TypeReference<T>): T?
+
+ /**
+ * Replace a resource at [path] of [targetType].
+ */
+ public fun <B, T> put(path: String, body: B, targetType: TypeReference<T>): T?
+
+ /**
+ * Delete a resource at [path] of [targetType].
+ */
+ public fun <T> delete(path: String, targetType: TypeReference<T>): T?
+}