Skip to content

impl: relax json syntax rules for deserialization #165

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

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
### Changed

- URL validation is stricter in the connection screen and URI protocol handler
- support for verbose logging a sanitized version of the REST API request and responses

## 0.6.0 - 2025-07-25

Expand Down
14 changes: 11 additions & 3 deletions src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ package com.coder.toolbox.sdk
import com.coder.toolbox.CoderToolboxContext
import com.coder.toolbox.sdk.convertors.ArchConverter
import com.coder.toolbox.sdk.convertors.InstantConverter
import com.coder.toolbox.sdk.convertors.LoggingConverterFactory
import com.coder.toolbox.sdk.convertors.OSConverter
import com.coder.toolbox.sdk.convertors.UUIDConverter
import com.coder.toolbox.sdk.ex.APIResponseException
import com.coder.toolbox.sdk.interceptors.LoggingInterceptor
import com.coder.toolbox.sdk.v2.CoderV2RestFacade
import com.coder.toolbox.sdk.v2.models.ApiErrorResponse
import com.coder.toolbox.sdk.v2.models.BuildInfo
Expand Down Expand Up @@ -74,10 +76,10 @@ open class CoderRestClient(
var builder = OkHttpClient.Builder()

if (context.proxySettings.getProxy() != null) {
context.logger.debug("proxy: ${context.proxySettings.getProxy()}")
context.logger.info("proxy: ${context.proxySettings.getProxy()}")
builder.proxy(context.proxySettings.getProxy())
} else if (context.proxySettings.getProxySelector() != null) {
context.logger.debug("proxy selector: ${context.proxySettings.getProxySelector()}")
context.logger.info("proxy selector: ${context.proxySettings.getProxySelector()}")
builder.proxySelector(context.proxySettings.getProxySelector()!!)
}

Expand Down Expand Up @@ -129,11 +131,17 @@ open class CoderRestClient(
}
it.proceed(request)
}
.addInterceptor(LoggingInterceptor(context))
.build()

retroRestClient =
Retrofit.Builder().baseUrl(url.toString()).client(httpClient)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.addConverterFactory(
LoggingConverterFactory.wrap(
context,
MoshiConverterFactory.create(moshi)
)
)
.build().create(CoderV2RestFacade::class.java)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.coder.toolbox.sdk.convertors

import com.coder.toolbox.CoderToolboxContext
import okhttp3.RequestBody
import okhttp3.ResponseBody
import retrofit2.Converter
import retrofit2.Retrofit
import java.lang.reflect.Type

class LoggingConverterFactory private constructor(
private val context: CoderToolboxContext,
private val delegate: Converter.Factory,
) : Converter.Factory() {

override fun responseBodyConverter(
type: Type,
annotations: Array<Annotation>,
retrofit: Retrofit
): Converter<ResponseBody, *>? {
// Get the delegate converter
val delegateConverter = delegate.responseBodyConverter(type, annotations, retrofit)
?: return null

@Suppress("UNCHECKED_CAST")
return LoggingMoshiConverter(context, delegateConverter as Converter<ResponseBody, Any?>)
}

override fun requestBodyConverter(
type: Type,
parameterAnnotations: Array<Annotation>,
methodAnnotations: Array<Annotation>,
retrofit: Retrofit
): Converter<*, RequestBody>? {
return delegate.requestBodyConverter(type, parameterAnnotations, methodAnnotations, retrofit)
}

override fun stringConverter(
type: Type,
annotations: Array<Annotation>,
retrofit: Retrofit
): Converter<*, String>? {
return delegate.stringConverter(type, annotations, retrofit)
}

companion object {
fun wrap(
context: CoderToolboxContext,
delegate: Converter.Factory,
): LoggingConverterFactory {
return LoggingConverterFactory(context, delegate)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.coder.toolbox.sdk.convertors

import com.coder.toolbox.CoderToolboxContext
import okhttp3.ResponseBody
import okhttp3.ResponseBody.Companion.toResponseBody
import retrofit2.Converter

class LoggingMoshiConverter(
private val context: CoderToolboxContext,
private val delegate: Converter<ResponseBody, Any?>
) : Converter<ResponseBody, Any> {

override fun convert(value: ResponseBody): Any? {
val bodyString = value.string()

return try {
// Parse with Moshi
delegate.convert(bodyString.toResponseBody(value.contentType()))
} catch (e: Exception) {
// Log the raw content that failed to parse
context.logger.error(
"""
|Moshi parsing failed:
|Content-Type: ${value.contentType()}
|Content: $bodyString
|Error: ${e.message}
""".trimMargin()
)

// Re-throw so the onFailure callback still gets called
throw e
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package com.coder.toolbox.sdk.interceptors

import com.coder.toolbox.CoderToolboxContext
import com.coder.toolbox.settings.HttpLoggingVerbosity
import okhttp3.Headers
import okhttp3.Interceptor
import okhttp3.MediaType
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.Response
import okhttp3.ResponseBody
import okio.Buffer
import java.nio.charset.StandardCharsets

private val SENSITIVE_HEADERS = setOf("Coder-Session-Token", "Proxy-Authorization")

class LoggingInterceptor(private val context: CoderToolboxContext) : Interceptor {

override fun intercept(chain: Interceptor.Chain): Response {
val logLevel = context.settingsStore.httpClientLogLevel
if (logLevel == HttpLoggingVerbosity.NONE) {
return chain.proceed(chain.request())
}

val request = chain.request()
logRequest(request, logLevel)

val response = chain.proceed(request)
logResponse(response, request, logLevel)

return response
}

private fun logRequest(request: Request, logLevel: HttpLoggingVerbosity) {
val log = buildString {
append("request --> ${request.method} ${request.url}")

if (logLevel >= HttpLoggingVerbosity.HEADERS) {
append("\n${request.headers.sanitized()}")
}

if (logLevel == HttpLoggingVerbosity.BODY) {
request.body?.let { body ->
append("\n${body.toPrintableString()}")
}
}
}

context.logger.info(log)
}

private fun logResponse(response: Response, request: Request, logLevel: HttpLoggingVerbosity) {
val log = buildString {
append("response <-- ${response.code} ${response.message} ${request.url}")

if (logLevel >= HttpLoggingVerbosity.HEADERS) {
append("\n${response.headers.sanitized()}")
}

if (logLevel == HttpLoggingVerbosity.BODY) {
response.body?.let { body ->
append("\n${body.toPrintableString()}")
}
}
}

context.logger.info(log)
}
}

// Extension functions for cleaner code
private fun Headers.sanitized(): String = buildString {
this@sanitized.forEach { (name, value) ->
val displayValue = if (name in SENSITIVE_HEADERS) "<redacted>" else value
append("$name: $displayValue\n")
}
}

private fun RequestBody.toPrintableString(): String {
if (!contentType().isPrintable()) {
return "[Binary body: ${contentLength().formatBytes()}, ${contentType()}]"
}

return try {
val buffer = Buffer()
writeTo(buffer)
buffer.readString(contentType()?.charset() ?: StandardCharsets.UTF_8)
} catch (e: Exception) {
"[Error reading body: ${e.message}]"
}
}

private fun ResponseBody.toPrintableString(): String {
if (!contentType().isPrintable()) {
return "[Binary body: ${contentLength().formatBytes()}, ${contentType()}]"
}

return try {
val source = source()
source.request(Long.MAX_VALUE)
source.buffer.clone().readString(contentType()?.charset() ?: StandardCharsets.UTF_8)
} catch (e: Exception) {
"[Error reading body: ${e.message}]"
}
}

private fun MediaType?.isPrintable(): Boolean = when {
this == null -> false
type == "text" -> true
subtype == "json" || subtype.endsWith("+json") -> true
else -> false
}

private fun Long.formatBytes(): String = when {
this < 0 -> "unknown"
this < 1024 -> "${this}B"
this < 1024 * 1024 -> "${this / 1024}KB"
this < 1024 * 1024 * 1024 -> "${this / (1024 * 1024)}MB"
else -> "${this / (1024 * 1024 * 1024)}GB"
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ interface ReadOnlyCoderSettings {
*/
val fallbackOnCoderForSignatures: SignatureFallbackStrategy

/**
* Controls the logging for the rest client.
*/
val httpClientLogLevel: HttpLoggingVerbosity

/**
* Default CLI binary name based on OS and architecture
*/
Expand Down Expand Up @@ -216,4 +221,32 @@ enum class SignatureFallbackStrategy {
else -> NOT_CONFIGURED
}
}
}

enum class HttpLoggingVerbosity {
NONE,

/**
* Logs URL, method, and status
*/
BASIC,

/**
* Logs BASIC + sanitized headers
*/
HEADERS,

/**
* Logs HEADERS + body content
*/
BODY;

companion object {
fun fromValue(value: String?): HttpLoggingVerbosity = when (value?.lowercase(getDefault())) {
"basic" -> BASIC
"headers" -> HEADERS
"body" -> BODY
else -> NONE
}
}
}
8 changes: 8 additions & 0 deletions src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.coder.toolbox.store

import com.coder.toolbox.settings.Environment
import com.coder.toolbox.settings.HttpLoggingVerbosity
import com.coder.toolbox.settings.ReadOnlyCoderSettings
import com.coder.toolbox.settings.ReadOnlyTLSSettings
import com.coder.toolbox.settings.SignatureFallbackStrategy
Expand Down Expand Up @@ -42,6 +43,8 @@ class CoderSettingsStore(
get() = store[DISABLE_SIGNATURE_VALIDATION]?.toBooleanStrictOrNull() ?: false
override val fallbackOnCoderForSignatures: SignatureFallbackStrategy
get() = SignatureFallbackStrategy.fromValue(store[FALLBACK_ON_CODER_FOR_SIGNATURES])
override val httpClientLogLevel: HttpLoggingVerbosity
get() = HttpLoggingVerbosity.fromValue(store[HTTP_CLIENT_LOG_LEVEL])
override val defaultCliBinaryNameByOsAndArch: String get() = getCoderCLIForOS(getOS(), getArch())
override val binaryName: String get() = store[BINARY_NAME] ?: getCoderCLIForOS(getOS(), getArch())
override val defaultSignatureNameByOsAndArch: String get() = getCoderSignatureForOS(getOS(), getArch())
Expand Down Expand Up @@ -179,6 +182,11 @@ class CoderSettingsStore(
}
}

fun updateHttpClientLogLevel(level: HttpLoggingVerbosity?) {
if (level == null) return
store[HTTP_CLIENT_LOG_LEVEL] = level.toString()
}

fun updateBinaryDirectoryFallback(shouldEnableBinDirFallback: Boolean) {
store[ENABLE_BINARY_DIR_FALLBACK] = shouldEnableBinDirFallback.toString()
}
Expand Down
2 changes: 2 additions & 0 deletions src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ internal const val DISABLE_SIGNATURE_VALIDATION = "disableSignatureValidation"

internal const val FALLBACK_ON_CODER_FOR_SIGNATURES = "signatureFallbackStrategy"

internal const val HTTP_CLIENT_LOG_LEVEL = "httpClientLogLevel"

internal const val BINARY_NAME = "binaryName"

internal const val DATA_DIRECTORY = "dataDirectory"
Expand Down
20 changes: 20 additions & 0 deletions src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
package com.coder.toolbox.views

import com.coder.toolbox.CoderToolboxContext
import com.coder.toolbox.settings.HttpLoggingVerbosity.BASIC
import com.coder.toolbox.settings.HttpLoggingVerbosity.BODY
import com.coder.toolbox.settings.HttpLoggingVerbosity.HEADERS
import com.coder.toolbox.settings.HttpLoggingVerbosity.NONE
import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription
import com.jetbrains.toolbox.api.ui.components.CheckboxField
import com.jetbrains.toolbox.api.ui.components.ComboBoxField
import com.jetbrains.toolbox.api.ui.components.ComboBoxField.LabelledValue
import com.jetbrains.toolbox.api.ui.components.TextField
import com.jetbrains.toolbox.api.ui.components.TextType
import com.jetbrains.toolbox.api.ui.components.UiField
Expand Down Expand Up @@ -44,6 +50,18 @@ class CoderSettingsPage(private val context: CoderToolboxContext, triggerSshConf
settings.fallbackOnCoderForSignatures.isAllowed(),
context.i18n.ptrl("Verify binary signature using releases.coder.com when CLI signatures are not available from the deployment")
)

private val httpLoggingField = ComboBoxField(
ComboBoxField.Label(context.i18n.ptrl("HTTP logging level:")),
settings.httpClientLogLevel,
listOf(
LabelledValue(context.i18n.ptrl("None"), NONE, listOf("" to "No logs")),
LabelledValue(context.i18n.ptrl("Basic"), BASIC, listOf("" to "Method, URL and status")),
LabelledValue(context.i18n.ptrl("Header"), HEADERS, listOf("" to " Basic + sanitized headers")),
LabelledValue(context.i18n.ptrl("Body"), BODY, listOf("" to "Headers + body content")),
)
)

private val enableBinaryDirectoryFallbackField =
CheckboxField(
settings.enableBinaryDirectoryFallback,
Expand Down Expand Up @@ -80,6 +98,7 @@ class CoderSettingsPage(private val context: CoderToolboxContext, triggerSshConf
enableBinaryDirectoryFallbackField,
disableSignatureVerificationField,
signatureFallbackStrategyField,
httpLoggingField,
dataDirectoryField,
headerCommandField,
tlsCertPathField,
Expand All @@ -103,6 +122,7 @@ class CoderSettingsPage(private val context: CoderToolboxContext, triggerSshConf
context.settingsStore.updateEnableDownloads(enableDownloadsField.checkedState.value)
context.settingsStore.updateDisableSignatureVerification(disableSignatureVerificationField.checkedState.value)
context.settingsStore.updateSignatureFallbackStrategy(signatureFallbackStrategyField.checkedState.value)
context.settingsStore.updateHttpClientLogLevel(httpLoggingField.selectedValueState.value)
context.settingsStore.updateBinaryDirectoryFallback(enableBinaryDirectoryFallbackField.checkedState.value)
context.settingsStore.updateHeaderCommand(headerCommandField.contentState.value)
context.settingsStore.updateCertPath(tlsCertPathField.contentState.value)
Expand Down
Loading
Loading