Skip to content

Commit 6003e47

Browse files
committed
Implement client support to retrieve workspaces
- created by logged user - new models, i.e User, RebuildMessage, Workspace & WorskpaceStat - gson serializers for java.time.Instant - merged the two Coder Client API's, no need to have one only for https scheme
1 parent b945bf1 commit 6003e47

13 files changed

+232
-40
lines changed

CHANGELOG.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@
99
* welcome screen
1010
* basic connector view triggered by the Coder's welcome view. It asks the user a Coder hostname, port, email and password.
1111
* back button to return to the main welcome view
12-
* basic Coder http client which authenticates and retrieves a session token
12+
* basic Coder http client which authenticates, retrieves a session token and uses it to retrieve the Workspaces created by the
13+
user that is logged.
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
package com.coder.gateway.models
22

3-
internal data class LoginModel(var host: String = "localhost", var port: Int = 7080, var email: String = "example@email.com", var password: String? = "")
3+
internal data class LoginModel(var uriScheme: UriScheme = UriScheme.HTTP, var host: String = "localhost", var port: Int = 7080, var email: String = "example@email.com", var password: String? = "")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.coder.gateway.models
2+
3+
import com.google.gson.annotations.SerializedName
4+
import java.time.Duration
5+
6+
data class RebuildMessage(
7+
@SerializedName("text") val text: String,
8+
@SerializedName("required") val required: Boolean,
9+
@SerializedName("auto_off_threshold") val auto_off_threshold: Duration
10+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package com.coder.gateway.models
2+
3+
enum class UriScheme(val scheme: String) {
4+
HTTP("http"), HTTPS("https")
5+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.coder.gateway.models
2+
3+
import com.google.gson.annotations.SerializedName
4+
import java.time.Instant
5+
6+
7+
data class User(
8+
@SerializedName("id") val id: String,
9+
@SerializedName("email") val email: String,
10+
@SerializedName("username") val username: String,
11+
@SerializedName("name") val name: String,
12+
@SerializedName("roles") val roles: Set<String>,
13+
@SerializedName("temporary_password") val temporaryPassword: Boolean,
14+
@SerializedName("login_type") val loginType: Boolean,
15+
@SerializedName("key_regenerated_at") val keyRegeneratedAt: Boolean,
16+
@SerializedName("created_at") val createdAt: Instant,
17+
@SerializedName("updated_at") val updatedAt: Instant,
18+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.coder.gateway.models
2+
3+
import com.google.gson.annotations.SerializedName
4+
import java.time.Instant
5+
6+
data class Workspace(
7+
@SerializedName("id") val id: String,
8+
@SerializedName("name") val name: String,
9+
@SerializedName("image_id") val imageId: String,
10+
@SerializedName("image_tag") val imageTag: String,
11+
@SerializedName("organization_id") val organizationId: String,
12+
@SerializedName("user_id") val userId: String,
13+
@SerializedName("last_built_at") val lastBuiltAt: Instant,
14+
@SerializedName("cpu_cores") val cpuCores: Float,
15+
@SerializedName("memory_gb") val memoryGB: Float,
16+
@SerializedName("disk_gb") val disk_gb: Int,
17+
@SerializedName("gpus") val gpus: Int,
18+
@SerializedName("updating") val updating: Boolean,
19+
@SerializedName("latest_stat") val latestStat: WorkspaceStat,
20+
@SerializedName("rebuild_messages") val rebuildMessages: List<RebuildMessage>,
21+
@SerializedName("created_at") val createdAt: Instant,
22+
@SerializedName("updated_at") val updatedAt: Instant,
23+
@SerializedName("last_opened_at") val lastOpenedAt: Instant,
24+
@SerializedName("last_connection_at") val lastConnectionAt: Instant,
25+
@SerializedName("auto_off_threshold") val autoOffThreshold: Long,
26+
@SerializedName("use_container_vm") val useContainerVM: Boolean,
27+
@SerializedName("resource_pool_id") val resourcePoolId: String,
28+
29+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.coder.gateway.models
2+
3+
import com.google.gson.annotations.SerializedName
4+
import java.time.Instant
5+
6+
data class WorkspaceStat(
7+
@SerializedName("time") val time: Instant,
8+
@SerializedName("last_online") val last_online: Instant,
9+
@SerializedName("container_status") val container_status: String,
10+
@SerializedName("stat_error") val stat_error: String,
11+
@SerializedName("cpu_usage") val cpu_usage: Float,
12+
@SerializedName("memory_total") val memory_total: Long,
13+
@SerializedName("memory_usage") val memory_usage: Float,
14+
@SerializedName("disk_total") val disk_total: Long,
15+
@SerializedName("disk_used") val disk_used: Long,
16+
)

src/main/kotlin/com/coder/gateway/sdk/CoderAuthenticatonRestService.kt

-14
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,71 @@
11
package com.coder.gateway.sdk
22

3+
import com.coder.gateway.models.UriScheme
4+
import com.coder.gateway.models.User
5+
import com.coder.gateway.models.Workspace
36
import com.coder.gateway.sdk.ex.AuthenticationException
4-
import com.intellij.openapi.Disposable
7+
import com.google.gson.Gson
8+
import com.google.gson.GsonBuilder
59
import com.intellij.openapi.components.Service
6-
import com.intellij.openapi.diagnostic.Logger
10+
import com.jetbrains.gateway.sdk.convertors.InstantConverter
11+
import okhttp3.OkHttpClient
12+
import okhttp3.logging.HttpLoggingInterceptor
713
import retrofit2.Retrofit
814
import retrofit2.converter.gson.GsonConverterFactory
15+
import java.time.Instant
916

1017
@Service(Service.Level.APP)
11-
class CoderClientService : Disposable {
18+
class CoderClientService {
1219
private lateinit var retroRestClient: CoderRestService
1320

1421
lateinit var sessionToken: String
22+
lateinit var me: User
1523

1624
/**
1725
* This must be called before anything else. It will authenticate with coder and retrieve a session token
1826
* @throws [AuthenticationException] if authentication failed
1927
*/
20-
fun initClientSession(host: String, port: Int, email: String, password: String) {
28+
fun initClientSession(uriScheme: UriScheme, host: String, port: Int, email: String, password: String) {
2129
val hostPath = host.trimEnd('/')
22-
val sessionTokenResponse = Retrofit.Builder()
23-
.baseUrl("http://$hostPath:$port")
24-
.addConverterFactory(GsonConverterFactory.create())
30+
31+
val gson: Gson = GsonBuilder()
32+
.registerTypeAdapter(Instant::class.java, InstantConverter())
33+
.setPrettyPrinting()
34+
.create()
35+
36+
val interceptor = HttpLoggingInterceptor()
37+
interceptor.setLevel(HttpLoggingInterceptor.Level.BODY)
38+
39+
retroRestClient = Retrofit.Builder()
40+
.baseUrl("${uriScheme.scheme}://$hostPath:$port")
41+
.client(OkHttpClient.Builder().addInterceptor(interceptor).build())
42+
.addConverterFactory(GsonConverterFactory.create(gson))
2543
.build()
26-
.create(CoderAuthenticatonRestService::class.java).authenticate(LoginRequest(email, password)).execute()
44+
.create(CoderRestService::class.java)
45+
46+
val sessionTokenResponse = retroRestClient.authenticate(LoginRequest(email, password)).execute()
2747

2848
if (!sessionTokenResponse.isSuccessful) {
29-
throw AuthenticationException("Authentication failed with code:${sessionTokenResponse.code()}, reason: ${sessionTokenResponse.errorBody().toString()}")
49+
throw AuthenticationException("Authentication failed with code:${sessionTokenResponse.code()}, reason: ${sessionTokenResponse.message()}")
3050
}
3151
sessionToken = sessionTokenResponse.body()!!.sessionToken
32-
retroRestClient = Retrofit.Builder()
33-
.baseUrl("https://$hostPath:$port")
34-
.addConverterFactory(GsonConverterFactory.create())
35-
.build()
36-
.create(CoderRestService::class.java)
52+
53+
val userResponse = retroRestClient.me(sessionToken).execute()
54+
55+
if (!userResponse.isSuccessful) {
56+
throw IllegalStateException("Could not retrieve information about logged use:${userResponse.code()}, reason: ${userResponse.message()}")
57+
}
58+
59+
me = userResponse.body()!!
3760
}
3861

39-
override fun dispose() {
62+
fun workspaces(): List<Workspace> {
63+
val workspacesResponse = retroRestClient.workspaces(sessionToken, me.id).execute()
64+
if (!workspacesResponse.isSuccessful) {
65+
throw IllegalStateException("Could not retrieve Coder Workspaces:${workspacesResponse.code()}, reason: ${workspacesResponse.message()}")
66+
}
67+
68+
return workspacesResponse.body()!!
4069

4170
}
4271
}
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,23 @@
11
package com.coder.gateway.sdk
22

3+
import com.coder.gateway.models.User
4+
import com.coder.gateway.models.Workspace
5+
import retrofit2.Call
6+
import retrofit2.http.Body
7+
import retrofit2.http.GET
8+
import retrofit2.http.Header
9+
import retrofit2.http.POST
10+
import retrofit2.http.Query
311

4-
interface CoderRestService
12+
13+
interface CoderRestService {
14+
15+
@POST("auth/basic/login")
16+
fun authenticate(@Body loginRequest: LoginRequest): Call<LoginResponse>
17+
18+
@GET("api/v0/users/me")
19+
fun me(@Header("Session-Token") sessionToken: String): Call<User>
20+
21+
@GET("api/v0/workspaces")
22+
fun workspaces(@Header("Session-Token") sessionToken: String, @Query("users") users: String): Call<List<Workspace>>
23+
}

src/main/kotlin/com/coder/gateway/views/CoderGatewayLoginView.kt

+20-7
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ package com.coder.gateway.views
22

33
import com.coder.gateway.CoderGatewayBundle
44
import com.coder.gateway.models.LoginModel
5+
import com.coder.gateway.models.UriScheme
56
import com.coder.gateway.sdk.CoderClientService
67
import com.intellij.credentialStore.CredentialAttributes
78
import com.intellij.credentialStore.askPassword
89
import com.intellij.ide.IdeBundle
910
import com.intellij.openapi.Disposable
1011
import com.intellij.openapi.application.ApplicationManager
1112
import com.intellij.openapi.diagnostic.Logger
13+
import com.intellij.openapi.ui.DialogPanel
1214
import com.intellij.openapi.ui.panel.ComponentPanelBuilder
1315
import com.intellij.openapi.wm.impl.welcomeScreen.WelcomeScreenUIManager
1416
import com.intellij.ui.IconManager
@@ -18,8 +20,10 @@ import com.intellij.ui.dsl.builder.BottomGap
1820
import com.intellij.ui.dsl.builder.RightGap
1921
import com.intellij.ui.dsl.builder.TopGap
2022
import com.intellij.ui.dsl.builder.bindIntText
23+
import com.intellij.ui.dsl.builder.bindItem
2124
import com.intellij.ui.dsl.builder.bindText
2225
import com.intellij.ui.dsl.builder.panel
26+
import com.intellij.ui.dsl.builder.toNullableProperty
2327
import com.intellij.ui.dsl.gridLayout.HorizontalAlign
2428
import com.intellij.util.ui.JBFont
2529
import com.intellij.util.ui.JBUI
@@ -40,6 +44,8 @@ class CoderGatewayLoginView : BorderLayoutPanel(), Disposable {
4044
private val model = LoginModel()
4145
private val coderClient: CoderClientService = ApplicationManager.getApplication().getService(CoderClientService::class.java)
4246

47+
private lateinit var loginPanel: DialogPanel
48+
4349
init {
4450
initView()
4551
}
@@ -50,8 +56,8 @@ class CoderGatewayLoginView : BorderLayoutPanel(), Disposable {
5056
addToBottom(createBackComponent())
5157
}
5258

53-
private fun createLoginComponent(): Component {
54-
return panel {
59+
private fun createLoginComponent(): DialogPanel {
60+
loginPanel = panel {
5561
indent {
5662
row {
5763
label(CoderGatewayBundle.message("gateway.connector.view.login.header.text")).applyToComponent {
@@ -65,10 +71,15 @@ class CoderGatewayLoginView : BorderLayoutPanel(), Disposable {
6571
row {
6672
browserLink(CoderGatewayBundle.message("gateway.connector.view.login.documentation.action"), "https://coder.com/docs/coder/latest/workspaces")
6773
}.bottomGap(BottomGap.MEDIUM)
68-
row(CoderGatewayBundle.message("gateway.connector.view.login.host.label")) {
74+
row {
75+
label(CoderGatewayBundle.message("gateway.connector.view.login.scheme.label"))
76+
comboBox(UriScheme.values().toList()).bindItem(model::uriScheme.toNullableProperty())
77+
label(CoderGatewayBundle.message("gateway.connector.view.login.host.label"))
6978
textField().resizableColumn().horizontalAlign(HorizontalAlign.FILL).gap(RightGap.SMALL).bindText(model::host)
70-
intTextField(0..65536).bindIntText(model::port).label(CoderGatewayBundle.message("gateway.connector.view.login.port.label"))
79+
label(CoderGatewayBundle.message("gateway.connector.view.login.port.label"))
80+
intTextField(0..65536).bindIntText(model::port)
7181
button(CoderGatewayBundle.message("gateway.connector.view.login.connect.action")) {
82+
loginPanel.apply()
7283
model.password = askPassword(
7384
null,
7485
CoderGatewayBundle.message("gateway.connector.view.login.credentials.dialog.title"),
@@ -77,10 +88,10 @@ class CoderGatewayLoginView : BorderLayoutPanel(), Disposable {
7788
false
7889
)
7990
cs.launch {
80-
withContext(Dispatchers.IO) {
81-
coderClient.initClientSession(model.host, model.port, model.email, model.password!!)
91+
val workspaces = withContext(Dispatchers.IO) {
92+
coderClient.initClientSession(model.uriScheme, model.host, model.port, model.email, model.password!!)
93+
coderClient.workspaces()
8294
}
83-
logger.info("Session token:${coderClient.sessionToken}")
8495
}
8596

8697
}.applyToComponent {
@@ -98,6 +109,8 @@ class CoderGatewayLoginView : BorderLayoutPanel(), Disposable {
98109
}.apply {
99110
background = WelcomeScreenUIManager.getMainAssociatedComponentBackground()
100111
}
112+
113+
return loginPanel
101114
}
102115

103116
private fun createBackComponent(): Component {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package com.jetbrains.gateway.sdk.convertors
2+
3+
import com.google.gson.JsonDeserializationContext
4+
import com.google.gson.JsonDeserializer
5+
import com.google.gson.JsonElement
6+
import com.google.gson.JsonParseException
7+
import com.google.gson.JsonPrimitive
8+
import com.google.gson.JsonSerializationContext
9+
import com.google.gson.JsonSerializer
10+
import java.lang.reflect.Type
11+
import java.time.Instant
12+
import java.time.format.DateTimeFormatter
13+
import java.time.temporal.TemporalAccessor
14+
15+
/**
16+
* GSON serialiser/deserialiser for converting [Instant] objects.
17+
*/
18+
class InstantConverter : JsonSerializer<Instant?>, JsonDeserializer<Instant?> {
19+
/**
20+
* Gson invokes this call-back method during serialization when it encounters a field of the
21+
* specified type.
22+
*
23+
*
24+
*
25+
* In the implementation of this call-back method, you should consider invoking
26+
* [JsonSerializationContext.serialize] method to create JsonElements for any
27+
* non-trivial field of the `src` object. However, you should never invoke it on the
28+
* `src` object itself since that will cause an infinite loop (Gson will call your
29+
* call-back method again).
30+
*
31+
* @param src the object that needs to be converted to Json.
32+
* @param typeOfSrc the actual type (fully genericized version) of the source object.
33+
* @return a JsonElement corresponding to the specified object.
34+
*/
35+
override fun serialize(src: Instant?, typeOfSrc: Type?, context: JsonSerializationContext?): JsonElement? {
36+
return JsonPrimitive(FORMATTER.format(src))
37+
}
38+
39+
/**
40+
* Gson invokes this call-back method during deserialization when it encounters a field of the
41+
* specified type.
42+
*
43+
*
44+
*
45+
* In the implementation of this call-back method, you should consider invoking
46+
* [JsonDeserializationContext.deserialize] method to create objects
47+
* for any non-trivial field of the returned object. However, you should never invoke it on the
48+
* the same type passing `json` since that will cause an infinite loop (Gson will call your
49+
* call-back method again).
50+
*
51+
* @param json The Json data being deserialized
52+
* @param typeOfT The type of the Object to deserialize to
53+
* @return a deserialized object of the specified type typeOfT which is a subclass of `T`
54+
* @throws JsonParseException if json is not in the expected format of `typeOfT`
55+
*/
56+
@Throws(JsonParseException::class)
57+
override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Instant {
58+
return FORMATTER.parse(json.asString) { temporal: TemporalAccessor? -> Instant.from(temporal) }
59+
}
60+
61+
companion object {
62+
/** Formatter. */
63+
private val FORMATTER = DateTimeFormatter.ISO_INSTANT
64+
}
65+
}

src/main/resources/messages/CoderGatewayBundle.properties

+2-1
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ gateway.connector.action.text=Connect to Coder Workspaces
44
gateway.connector.view.login.header.text=Coder Workspaces
55
gateway.connector.view.login.comment.text=Self-hosted developer workspaces in the cloud or on premise. Coder Workspaces empower developers with secure, consistent, and fast developer workspaces.
66
gateway.connector.view.login.documentation.action=Explore Coder Workspaces
7+
gateway.connector.view.login.scheme.label=Scheme:
78
gateway.connector.view.login.host.label=Host:
89
gateway.connector.view.login.port.label=Port:
910
gateway.connector.view.login.email.label=Email:
1011
gateway.connector.view.login.password.label=Password:
1112
gateway.connector.view.login.connect.action=Connect
12-
gateway.connector.view.login.credentials.dialog.title = Coder Credentials
13+
gateway.connector.view.login.credentials.dialog.title=Coder Credentials

0 commit comments

Comments
 (0)