Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 11 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,22 +22,22 @@

### Metrics
```text
15288 number of properties
10573 number of functions
8954 number of classes
240 number of packages
3543 number of kt files
15266 number of properties
10543 number of functions
8951 number of classes
241 number of packages
3544 number of kt files
```


### Complexity Report
```text
267458 lines of code (loc)
166374 source lines of code (sloc)
121548 logical lines of code (lloc)
72562 comment lines of code (cloc)
25100 cyclomatic complexity (mcc)
20431 cognitive complexity
267318 lines of code (loc)
166270 source lines of code (sloc)
121387 logical lines of code (lloc)
72569 comment lines of code (cloc)
25062 cyclomatic complexity (mcc)
20439 cognitive complexity
0 number of total code smells
43 comment source ratio
206 mcc per 1,000 lloc
Expand Down
290 changes: 197 additions & 93 deletions api/Kotlin-Lab.api

Large diffs are not rendered by default.

26 changes: 19 additions & 7 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -344,14 +344,25 @@ dependencies {
implementation(okhttp)
implementation(sandwich.retrofit)
implementation(okhttp.logging)
implementation("org.openjdk.jol:jol-core:0.17")
implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
implementation(jsoup)
implementation("com.google.protobuf:protobuf-java:4.32.0")
implementation("com.google.protobuf:protobuf-kotlin-lite:4.32.0")
implementation("io.grpc:grpc-stub:1.74.0")
implementation("io.grpc:grpc-protobuf:1.74.0")

ktor.apply {
client.apply {
implementation(client.core)
implementation(client.okhttp)
implementation(client.content.negotiation)
implementation(client.logging)
implementation(client.cio)
}
implementation(ktor.serialization.kotlinx.json)
}
Comment on lines +348 to +357
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Avoid including multiple Ktor client engines unless required

Both CIO and OkHttp engines are added. This increases classpath size and can cause confusion. Prefer a single engine (CIO is already the default in createKtorClient).

Apply one of the diffs to keep a single engine:

Option A (keep CIO):

             client.apply {
                 implementation(client.core)
-                implementation(client.okhttp)
                 implementation(client.content.negotiation)
                 implementation(client.logging)
                 implementation(client.cio)
             }

Option B (keep OkHttp):

             client.apply {
                 implementation(client.core)
                 implementation(client.okhttp)
                 implementation(client.content.negotiation)
                 implementation(client.logging)
-                implementation(client.cio)
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
ktor.apply {
client.apply {
implementation(client.core)
implementation(client.okhttp)
implementation(client.content.negotiation)
implementation(client.logging)
implementation(client.cio)
}
implementation(ktor.serialization.kotlinx.json)
}
ktor.apply {
client.apply {
implementation(client.core)
implementation(client.content.negotiation)
implementation(client.logging)
implementation(client.cio)
}
implementation(ktor.serialization.kotlinx.json)
}
🤖 Prompt for AI Agents
In build.gradle.kts around lines 342 to 351, the Ktor client is including both
CIO and OkHttp engines; remove the redundant engine to avoid classpath
bloat—prefer keeping CIO (the default). Edit the block to delete the
implementation(client.okhttp) line so only implementation(client.cio) remains
(or if you intentionally prefer OkHttp, delete implementation(client.cio)
instead); keep all other client implementations unchanged.

implementation("ch.qos.logback:logback-classic:1.4.11")
implementation(jsoup)
implementation(libs.jol.core)
implementation(libs.retrofit2.kotlinx.serialization.converter)
implementation(libs.protobuf.java)
implementation(libs.protobuf.kotlin.lite)
implementation(libs.grpc.stub)
implementation(libs.grpc.protobuf)

testImplementation(mockk)
testImplementation(junit)
Expand All @@ -375,6 +386,7 @@ dependencies {
testImplementation(okhttp.mockwebserver)
testImplementation(turbine)
testImplementation(truth)
testImplementation(ktor.client.mock)
}
implementation("org.xerial:sqlite-jdbc:3.45.3.0")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit")
Expand Down
26 changes: 26 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ protobufPlugin = "0.9.5" # https://mvnrepository.com/artifact/com.google.prot
protobufKotlin = "4.32.0" # https://mvnrepository.com/artifact/com.google.protobuf/protobuf-kotlin
protoc = "4.32.0" # https://mvnrepository.com/artifact/com.google.protobuf/protoc
binaryCompatibility = "0.18.1"
ktor = "3.2.3"
jol = "0.17"
retrofit-kotlinx-serialization = "1.0.0"
protobuf = "4.31.1"
grpc = "1.74.0"
Comment on lines +39 to +43
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Consolidate versions: drop redundant aliases and avoid future drift

  • Remove the retrofit-kotlinx-serialization ad-hoc version and reuse the Retrofit version via the official converter (see comment on Lines 85–87).
  • Unify protobuf versions: keep a single version alias. You already have protobufKotlin and protoc above; using an extra protobuf alias invites accidental skew.

Apply this diff to simplify:

 ktor = "3.2.3"
 jol = "0.17"
-retrofit-kotlinx-serialization = "1.0.0"
-protobuf = "4.31.1"
 grpc = "1.74.0"

Would you like me to follow through with a repo-wide update (search/replace) after switching the Retrofit converter to ensure no references to the removed alias remain?


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Show gradle/libs.versions.toml (if exists) ==="
if [ -f gradle/libs.versions.toml ]; then
  sed -n '1,240p' gradle/libs.versions.toml || true
else
  echo "File not found: gradle/libs.versions.toml"
fi

echo
echo "=== Search for alias usages: 'retrofit-kotlinx-serialization' ==="
rg -n --hidden --no-ignore -S "retrofit-kotlinx-serialization" || true

echo
echo "=== Search for 'retrofit' references to kotlinx serialization converter ==="
rg -n --hidden --no-ignore -S "kotlinx-serialization.*retrofit|converter.*kotlinx.*retrofit|retrofit.*kotlinx.*serialization" || true

echo
echo "=== Search for protobuf-related aliases/versions ==="
rg -n --hidden --no-ignore -S "^\s*protobuf\s*=" gradle || true
rg -n --hidden --no-ignore -S "protobufKotlin|protoc|protobuf =|\"4.31.1\"" || true

echo
echo "=== Search repository for uses of 'libs.protobuf' or similar dependency alias usage ==="
rg -n --hidden --no-ignore -S "libs\.protobuf|libs\.protobufKotlin|libs\.protoc" || true

echo
echo "=== Search for occurrences of the version string 4.31.1 anywhere ==="
rg -n --hidden --no-ignore -S "4\.31\.1" || true

echo
echo "=== Done ==="

Length of output: 14641


Consolidate Protobuf versions; keep Retrofit converter alias (don't drop without verifying compatibility)

Short: protobuf has a redundant version alias — remove it and point protobuf artifacts to the existing protobufKotlin/protoc alias. Do NOT remove the retrofit-kotlinx-serialization alias blindly: the kotlinx-serialization Retrofit converter is a separately versioned artifact (build.gradle.kts references libs.retrofit2.kotlinx.serialization.converter), so changing it to the Retrofit version may break builds unless you verify the converter actually publishes the matching Retrofit version.

Places to update:

  • gradle/libs.versions.toml
    • [versions]: remove the redundant protobuf = "4.31.1" entry.
    • [libraries]: change protobuf-java and protobuf-kotlin-lite to use version.ref = "protobufKotlin".
  • build.gradle.kts:353 — confirms usage of the converter (implementation(libs.retrofit2.kotlinx.serialization.converter)); keep the converter version alias until you verify compatibility.

Suggested diffs:

 ktor = "3.2.3"
 jol = "0.17"
 retrofit-kotlinx-serialization = "1.0.0"
-protobuf = "4.31.1"
 grpc = "1.74.0"
-protobuf-java = { module = "com.google.protobuf:protobuf-java", version.ref = "protobuf" }
-protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobuf" }
+protobuf-java = { module = "com.google.protobuf:protobuf-java", version.ref = "protobufKotlin" }
+protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobufKotlin" }

If you want, I can follow up with a repo-wide replace of the protobuf library refs (safe) and/or verify the retrofit-kotlinx-serialization converter versions before attempting to switch it to the Retrofit alias.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In gradle/libs.versions.toml around lines 39 to 43, remove the redundant
[versions] entry protobuf = "4.31.1" and update the protobuf library definitions
so that protobuf-java and protobuf-kotlin-lite use version.ref =
"protobufKotlin" (pointing to the existing protobufKotlin/protoc alias) instead
of the removed protobuf alias; do NOT change or remove the
retrofit-kotlinx-serialization alias — leave
libs.retrofit2.kotlinx.serialization.converter as-is (build.gradle.kts line 353
references it) until you have verified the converter’s published version
compatibility with the Retrofit version.


[libraries]
kotlin-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-jdk8", version.ref = "coroutines" }
Expand Down Expand Up @@ -66,6 +71,27 @@ jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
sandwich-retrofit = { group = "com.github.skydoves", name = "sandwich-retrofit", version.ref = "sandwich" }
protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protoc" }
protobuf-kotlin = { group = "com.google.protobuf", name = "protobuf-kotlin", version.ref = "protobufKotlin" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" }

# OpenJDK JOL
jol-core = { module = "org.openjdk.jol:jol-core", version.ref = "jol" }

# Retrofit kotlinx.serialization converter
retrofit2-kotlinx-serialization-converter = { module = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter", version.ref = "retrofit-kotlinx-serialization" }
Comment on lines +85 to +86
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Retrofit 3 likely incompatible with Jake Wharton’s retrofit2-kotlinx-serialization converter.

The artifact targets Retrofit 2.x (note retrofit2- prefix) and may not support 3.x.

Preferred fix: switch to Square’s official converter aligned with Retrofit 3 coordinates.

-# Retrofit kotlinx.serialization converter
-retrofit2-kotlinx-serialization-converter = { module = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter", version.ref = "retrofit-kotlinx-serialization" }
+# Retrofit kotlinx.serialization converter (align version with Retrofit)
+retrofit-kotlinx-serialization = { module = "com.squareup.retrofit2:converter-kotlinx-serialization", version.ref = "retrofit" }

If unsure, please check the Retrofit 3 docs for the recommended converter artifact and version:


🌐 Web query:

Retrofit 3 official kotlinx-serialization converter artifact and version alignment guidance

💡 Result:

Short answer

  • Official Retrofit 3 kotlinx‑serialization converter artifact:
    com.squareup.retrofit2:converter-kotlinx-serialization:3.0.0. (mvnrepository.com)

What this means and version-alignment rules

  • The kotlinx‑serialization converter is now distributed as a first‑party Retrofit module (group com.squareup.retrofit2) and its versions follow Retrofit’s release stream (e.g., converter 3.0.0 with retrofit 3.0.0). Use the converter version that matches the Retrofit major/minor you use, or manage all Retrofit modules together with the retrofit-bom. (mvnrepository.com)
  • The converter has a runtime dependency on kotlinx.serialization runtime libraries (kotlinx-serialization-core / kotlinx-serialization-json) and on Kotlin stdlib. Make sure your kotlinx-serialization runtime and the Kotlin compiler/plugin you use are compatible (use the kotlinx.serialization series recommended for your Kotlin version). The Kotlin docs and kotlinx.serialization releases are the authoritative compatibility source. (kotlinlang.org, github.com)
  • If you previously used Jake Wharton’s retrofit2-kotlinx-serialization-converter, migrate to the first‑party artifact (the old repo is archived). (github.com)

Recommended Gradle setups (examples)

  • Using the Retrofit BOM (recommended to keep retrofit + converters aligned)

    • Kotlin DSL (build.gradle.kts):
      implementation(platform("com.squareup.retrofit2:retrofit-bom:3.0.0"))
      implementation("com.squareup.retrofit2:retrofit")
      implementation("com.squareup.retrofit2:converter-kotlinx-serialization")

    • Groovy:
      implementation platform('com.squareup.retrofit2:retrofit-bom:3.0.0')
      implementation 'com.squareup.retrofit2:retrofit'
      implementation 'com.squareup.retrofit2:converter-kotlinx-serialization'

    The BOM pins the Retrofit main artifact and its official converters to matching versions. (mvnrepository.com)

  • Add kotlinx.serialization runtime and Kotlin serialization plugin (example targets that work with Retrofit 3 era):

    • Kotlin DSL (minimum required lines):
      plugins {
      kotlin("jvm") version "2.1.20" // or your project Kotlin version
      kotlin("plugin.serialization") version "2.1.20"
      }
      dependencies {
      implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
      }
    • Check the kotlinx.serialization release notes / documentation to pick the right library version for your Kotlin version. (Examples shown here reflect the 1.8/1.9 series available in 2024–2025.) (github.com, kotlinlang.org)

Usage example (Kotlin)

  • Typical builder:
    val contentType = "application/json; charset=UTF-8".toMediaType()
    val json = Json { ignoreUnknownKeys = true } // configure as needed
    val retrofit = Retrofit.Builder()
    .baseUrl("https://api.example.com/")
    .addConverterFactory(json.asConverterFactory(contentType))
    .build()

Notes / gotchas

  • Order of converters matters: the kotlinx‑serialization converter usually claims many types; if mixing converters, add other converters first and kotlinx‑serialization last if you want the others to get first shot at compatible types. (github.com)
  • Align Kotlin compiler (plugin) version, Kotlin stdlib, and kotlinx-serialization runtime. Mismatches produce compile-time or runtime errors. Consult the kotlinx.serialization release notes for compatibility (e.g., 1.8.x / 1.9.x series align with Kotlin 2.0–2.2 era releases). (github.com, kotlinlang.org)
  • If you want to pin everything and avoid manual cross-checks, use retrofit-bom for retrofit modules and follow the Kotlin docs for the serialization plugin/runtime versions. (mvnrepository.com, kotlinlang.org)

If you want, I can:

  • produce an exact build.gradle(.kts) snippet for your project (tell me your Kotlin and Retrofit versions), or
  • check the currently published latest versions in your build environment right now and produce a copy‑paste dependency block.

Citations:


Align with Retrofit 3’s first-party kotlinx-serialization converter
The Jake Wharton retrofit2-kotlinx-serialization-converter targets Retrofit 2.x and is archived; Retrofit 3 provides its own converter under com.squareup.retrofit2. Update your version catalog to use the official module and keep versions in sync (via the retrofit version or the Retrofit BOM).

• In gradle/libs.versions.toml (around lines 85–86):

-# Retrofit kotlinx.serialization converter
-retrofit2-kotlinx-serialization-converter = { module = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter", version.ref = "retrofit-kotlinx-serialization" }
+# Retrofit kotlinx.serialization converter (official Retrofit 3)
+converter-kotlinx-serialization = { module = "com.squareup.retrofit2:converter-kotlinx-serialization", version.ref = "retrofit" }

Optional: Add the Retrofit BOM to your Gradle setup to lock all Retrofit artifacts (core + converters) to the same 3.x version:

// build.gradle.kts
implementation(platform("com.squareup.retrofit2:retrofit-bom:3.0.0"))
implementation("com.squareup.retrofit2:retrofit")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization")
🤖 Prompt for AI Agents
In gradle/libs.versions.toml around lines 85–86, the catalog currently
references Jake Wharton’s archived retrofit2-kotlinx-serialization-converter
which targets Retrofit 2; replace that entry with the official Retrofit 3
first‑party converter module
(com.squareup.retrofit2:converter-kotlinx-serialization) and point its version
to the same retrofit version reference (or remove the explicit version and rely
on the Retrofit BOM), and optionally add instructions to use the Retrofit BOM in
build.gradle.kts to lock core + converter to the same 3.x version.


# Protobuf
protobuf-java = { module = "com.google.protobuf:protobuf-java", version.ref = "protobuf" }
protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobuf" }

Comment on lines +88 to +91
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Unify protobuf version references

You’re introducing a second protobuf version alias (“protobuf”) while an existing alias (“protobufKotlin”) is already defined above. Prefer a single alias to avoid eventual skew.

Apply this diff to reference the existing protobufKotlin alias:

 # Protobuf
-protobuf-java = { module = "com.google.protobuf:protobuf-java", version.ref = "protobuf" }
-protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobuf" }
+protobuf-java = { module = "com.google.protobuf:protobuf-java", version.ref = "protobufKotlin" }
+protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobufKotlin" }

Since the protobuf alias (Line 42) becomes unused after this change, remove it as suggested in Lines 39–43.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Protobuf
protobuf-java = { module = "com.google.protobuf:protobuf-java", version.ref = "protobuf" }
protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobuf" }
# Protobuf
protobuf-java = { module = "com.google.protobuf:protobuf-java", version.ref = "protobufKotlin" }
protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobufKotlin" }
🤖 Prompt for AI Agents
In gradle/libs.versions.toml around lines 88 to 91, the new protobuf entries
introduce a second version alias ("protobuf") causing duplication with the
existing "protobufKotlin" alias; update the two entries to reference the
existing protobufKotlin alias instead of "protobuf", and then remove the
now-unused "protobuf" alias declaration at lines 39–43 so only protobufKotlin
remains as the single source of truth for protobuf versions.

# gRPC
grpc-stub = { module = "io.grpc:grpc-stub", version.ref = "grpc" }
grpc-protobuf = { module = "io.grpc:grpc-protobuf", version.ref = "grpc" }

# testing libs
junit = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }
Expand Down
8 changes: 8 additions & 0 deletions src/main/kotlin/dev/shtanko/api/Constants.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package dev.shtanko.api

internal const val BASE_URL = "https://api.github.com/"
internal const val JSON_CONTENT_TYPE = "application/json"
internal const val AUTH_HEADER = "Authorization"
internal const val ACCEPT_HEADER = "Accept"
internal const val ACCEPT_HEADER_VALUE = "application/vnd.github.v3+json"
internal const val AUTH_BASIC = "Basic %s"
5 changes: 5 additions & 0 deletions src/main/kotlin/dev/shtanko/api/Dependencies.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package dev.shtanko.api

import kotlinx.serialization.json.Json

internal val json = Json { ignoreUnknownKeys = true }
31 changes: 2 additions & 29 deletions src/main/kotlin/dev/shtanko/api/GitHubService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@
package dev.shtanko.api

import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import dev.shtanko.api.model.Repo
import dev.shtanko.api.model.User
import java.lang.String.format
import java.util.Base64
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
Expand Down Expand Up @@ -56,34 +57,6 @@ interface GitHubService {
): Response<List<User>>
}

@Serializable
data class Repo(
val id: Long,
val name: String,
)

@Serializable
data class User(
val login: String,
val contributions: Int,
)

@Serializable
data class RequestData(
val username: String,
val password: String,
val org: String,
)

private val json = Json { ignoreUnknownKeys = true }

private const val BASE_URL = "https://api.github.com/"
private const val JSON_CONTENT_TYPE = "application/json"
private const val AUTH_HEADER = "Authorization"
private const val ACCEPT_HEADER = "Accept"
private const val ACCEPT_HEADER_VALUE = "application/vnd.github.v3+json"
private const val AUTH_BASIC = "Basic %s"

fun createHttpClient(authToken: String): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor { chain ->
Expand Down
58 changes: 58 additions & 0 deletions src/main/kotlin/dev/shtanko/api/KtorGithubService.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package dev.shtanko.api

import dev.shtanko.api.model.Repo
import dev.shtanko.api.model.User
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.defaultRequest
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.parameter
import io.ktor.http.HttpHeaders
import io.ktor.serialization.kotlinx.json.json
import java.util.Base64

fun createKtorClient(
username: String,
password: String,
engine: HttpClientEngine = CIO.create(),
): HttpClient {
val authToken = String.format(
AUTH_BASIC,
Base64.getEncoder().encodeToString("$username:$password".toByteArray(Charsets.UTF_8)),
)

return HttpClient(engine) {
install(ContentNegotiation) {
json(json)
}
install(Logging) {
level = LogLevel.INFO
}
defaultRequest {
header(ACCEPT_HEADER, ACCEPT_HEADER_VALUE)
header(HttpHeaders.Authorization, authToken)
url(BASE_URL)
}
}
}
Comment on lines +20 to +43
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Consider security improvements for authentication and logging

The current implementation has security considerations:

  1. Basic authentication transmits credentials with every request. Consider using GitHub Personal Access Tokens or OAuth tokens instead.
  2. LogLevel.INFO might expose sensitive data in logs. Consider using LogLevel.HEADERS or LogLevel.NONE in production, or implement custom sanitization.

🏁 Script executed:

#!/bin/bash
# Verify that the required constants are defined in the codebase
echo "Checking for AUTH_BASIC constant..."
rg "AUTH_BASIC\s*=" -A 2

echo "Checking for ACCEPT_HEADER constant..."
rg "ACCEPT_HEADER\s*=" -A 2

echo "Checking for ACCEPT_HEADER_VALUE constant..."
rg "ACCEPT_HEADER_VALUE\s*=" -A 2

echo "Checking for BASE_URL constant..."
rg "BASE_URL\s*=" -A 2

echo "Checking for json instance in Dependencies..."
rg "val json\s*=" src/main/kotlin/dev/shtanko/api/Dependencies.kt -A 3

Length of output: 1865


Enhance authentication security and tighten logging

The required constants (AUTH_BASIC, ACCEPT_HEADER, ACCEPT_HEADER_VALUE, BASE_URL, and the json instance) have been confirmed in Constants.kt and Dependencies.kt. To harden the client:

  • Move away from Basic auth
    Sending raw credentials on every request increases risk. Prefer a GitHub Personal Access Token (PAT) or OAuth flow, passed in the Authorization header (e.g. token $PAT), to avoid embedding username/password in your client.

  • Reduce log verbosity
    Using LogLevel.INFO may inadvertently log headers or payloads containing sensitive information. In production, switch to LogLevel.HEADERS, LogLevel.NONE, or implement a custom logger that redacts the Authorization header.

  • Optional Kotlin refinements

    • Instead of String.format(AUTH_BASIC, encoded), you can use Kotlin string templates:
      header(HttpHeaders.Authorization, "Basic $encoded")
    • You already explicitly set Charsets.UTF_8, which is good practice.

Please address these security recommendations before merging.

🤖 Prompt for AI Agents
In src/main/kotlin/dev/shtanko/api/KtorGithubService.kt around lines 20 to 43,
replace passing raw username/password with a token-based approach and reduce
logging: change the client to accept a single secret token (or an optional token
fallback) and set the Authorization header to use the token scheme (e.g., "token
$PAT" or "Bearer $PAT") instead of Basic auth; lower the Logging level to
HEADERS or NONE (or implement a custom logger that redacts the Authorization
header) to avoid leaking credentials; optionally simplify the header
construction using Kotlin string templates when forming the Authorization
header.


class KtorGitHubApi(private val client: HttpClient) {

suspend fun getOrgRepos(org: String): List<Repo> {
return client.get("orgs/$org/repos") {
parameter("per_page", 100)
}.body()
}
Comment on lines +47 to +51
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

Add error handling and pagination support for getOrgRepos

The current implementation has limitations:

  1. No error handling for HTTP failures or deserialization errors
  2. GitHub API returns maximum 100 items per page - large organizations may have more repositories
  3. No input validation for the org parameter

Consider implementing pagination and error handling:

suspend fun getOrgRepos(org: String): List<Repo> {
    require(org.isNotBlank()) { "Organization name cannot be blank" }
    
    return try {
        // TODO: Implement pagination to fetch all repositories
        client.get("orgs/$org/repos") {
            parameter("per_page", 100)
            // parameter("page", pageNumber) for pagination
        }.body()
    } catch (e: Exception) {
        // Log error and handle appropriately
        throw GitHubApiException("Failed to fetch repositories for org: $org", e)
    }
}
🤖 Prompt for AI Agents
In src/main/kotlin/dev/shtanko/api/KtorGithubService.kt around lines 47 to 51,
validate the org input (e.g., require(org.isNotBlank())), implement pagination
by repeatedly requesting "orgs/$org/repos" with parameter per_page=100 and
page=N, accumulating bodies until an empty page (or until Link header indicates
no next page), and handle HTTP/deserialization errors by catching exceptions,
logging them, and rethrowing a domain-specific exception (e.g.,
GitHubApiException) that wraps the original error; ensure resources or client
calls are used correctly and avoid infinite loops by incrementing the page
counter and breaking when no more items are returned.


suspend fun getRepoContributors(owner: String, repo: String): List<User> {
return client.get("repos/$owner/$repo/contributors") {
parameter("per_page", 100)
}.body()
}
Comment on lines +53 to +57
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

Add error handling and pagination support for getRepoContributors

Similar to getOrgRepos, this function needs:

  1. Error handling for HTTP and deserialization failures
  2. Pagination support (repositories can have more than 100 contributors)
  3. Input validation for owner and repo parameters

}
82 changes: 82 additions & 0 deletions src/main/kotlin/dev/shtanko/api/KtorInterceptorExample.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package dev.shtanko.api

import dev.shtanko.api.ktor.ApiKeyInterceptor
import dev.shtanko.api.ktor.HttpLogInterceptor
import dev.shtanko.api.ktor.RequestResponseProcessorInterceptor
import dev.shtanko.api.ktor.RetryInterceptor
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.client.statement.request
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import java.time.Instant
import kotlinx.coroutines.runBlocking

@Suppress("MagicNumber", "TooGenericExceptionCaught")
fun main() = runBlocking {
val client = HttpClient(CIO) {
// Install built-in logging
install(Logging) {
level = LogLevel.BODY
}
Comment on lines +24 to +26
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

Do not log secrets and full bodies in examples; sanitize headers and avoid LogLevel.BODY.

With logHeaders = true and the built-in Logging at LogLevel.BODY, Authorization and other secrets get printed to stdout. This is a security risk and an antipattern in sample code.

  • Disable header logging in your custom interceptor or
  • Keep header logging but sanitize sensitive headers and reduce built-in logging to HEADERS.

Apply this diff to harden logging here:

 install(Logging) {
-    level = LogLevel.BODY
+    level = LogLevel.HEADERS
+    // Optional: if you keep built-in Logging, sanitize secrets
+    logger = io.ktor.client.plugins.logging.Logger.DEFAULT
+    sanitizeHeader { h ->
+        h.equals(HttpHeaders.Authorization, ignoreCase = true) ||
+        h.equals("X-API-Key", ignoreCase = true)
+    }
 }
 
 // Install custom interceptors
 install(HttpLogInterceptor) {
     logRequest = true
     logResponse = true
-    logHeaders = true
+    // Disable header logging here to avoid leaking secrets (or add redaction in the plugin)
+    logHeaders = false
 }
 
 install(ApiKeyInterceptor) {
-    apiKey = "your-api-key-here"
-    headerName = "Authorization"
+    // Prefer Authorization bearer token or a custom header; do not log it
+    headerName = HttpHeaders.Authorization
+    apiKey = "Bearer ${System.getenv("API_TOKEN") ?: "your-api-key-here"}"
 }

Also applies to: 28-37

🤖 Prompt for AI Agents
In src/main/kotlin/dev/shtanko/api/KtorInterceptorExample.kt around lines 23-25
(and similarly lines 28-37), the example enables LogLevel.BODY which prints full
request/response bodies and headers (including Authorization/Cookie) — change
the logging to avoid leaking secrets: set the built-in Logging to at most
LogLevel.HEADERS (not BODY) or disable it entirely, and in your custom
interceptor disable header logging or explicitly sanitize sensitive headers
(Authorization, Cookie, Set-Cookie, X-Api-Key, etc.) before logging; ensure no
full bodies are logged by removing any body logging calls and only log
non-sensitive headers or summary information.


// Prevent requests from hanging indefinitely
install(HttpTimeout) {
requestTimeoutMillis = 10_000
connectTimeoutMillis = 5_000
socketTimeoutMillis = 10_000
}
// Install custom interceptors
install(HttpLogInterceptor) {
logRequest = true
logResponse = true
logHeaders = true
}

install(ApiKeyInterceptor) {
apiKey = "your-api-key-here"
headerName = "Authorization"
}

install(RetryInterceptor) {
maxRetries = 2
baseDelayMs = 500
retryOn = setOf(HttpStatusCode.InternalServerError, HttpStatusCode.ServiceUnavailable)
}

install(RequestResponseProcessorInterceptor) {
requestProcessor = { request ->
// Add timestamp to all requests
request.headers.append("X-Request-Time", Instant.now().toString())
request.headers.append("X-Client-Version", "1.0.0")
}

responseProcessor = { response ->
// Process response (logging, metrics, etc.)
println("📋 Processing response from: ${response.request.url}")
println("📊 Response size: ${response.headers[HttpHeaders.ContentLength] ?: "unknown"}")
}
}
}

try {
// Test the interceptors
val response1 = client.get("https://httpbin.org/get")
println("Response 1 status: ${response1.status}")
println("Response 1 body: ${response1.bodyAsText()}")

// Test with a endpoint that might fail (for retry testing)
val response2 = client.get("https://httpbin.org/status/500")
println("Response 2 status: ${response2.status}")

} catch (e: Exception) {
println("Error: ${e.message}")
} finally {
client.close()
}
}
4 changes: 2 additions & 2 deletions src/main/kotlin/dev/shtanko/api/contributors/Contributors.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@

package dev.shtanko.api.contributors

import dev.shtanko.api.RequestData
import dev.shtanko.api.User
import dev.shtanko.api.contributors.Contributors.LoadingStatus.CANCELED
import dev.shtanko.api.contributors.Contributors.LoadingStatus.COMPLETED
import dev.shtanko.api.contributors.Contributors.LoadingStatus.IN_PROGRESS
Expand All @@ -30,6 +28,8 @@ import dev.shtanko.api.contributors.Variant.NOT_CANCELLABLE
import dev.shtanko.api.contributors.Variant.PROGRESS
import dev.shtanko.api.contributors.Variant.SUSPEND
import dev.shtanko.api.createGitHubService
import dev.shtanko.api.model.RequestData
import dev.shtanko.api.model.User
import dev.shtanko.api.tasks.loadContributorsBackground
import dev.shtanko.api.tasks.loadContributorsBlocking
import dev.shtanko.api.tasks.loadContributorsCallbacks
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

package dev.shtanko.api.contributors

import dev.shtanko.api.User
import dev.shtanko.api.model.User
import java.awt.event.ActionListener
import kotlinx.coroutines.Job

Expand Down
6 changes: 3 additions & 3 deletions src/main/kotlin/dev/shtanko/api/contributors/Logger.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@

package dev.shtanko.api.contributors

import dev.shtanko.api.Repo
import dev.shtanko.api.RequestData
import dev.shtanko.api.User
import dev.shtanko.api.model.Repo
import dev.shtanko.api.model.RequestData
import dev.shtanko.api.model.User
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import retrofit2.Response
Expand Down
34 changes: 34 additions & 0 deletions src/main/kotlin/dev/shtanko/api/ktor/ApiKeyInterceptor.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package dev.shtanko.api.ktor

import io.ktor.client.HttpClient
import io.ktor.client.plugins.HttpClientPlugin
import io.ktor.client.request.HttpRequestPipeline
import io.ktor.util.AttributeKey

class ApiKeyInterceptor private constructor(
private val apiKey: String,
private val headerName: String,
) {

class Config {
var apiKey: String = ""
var headerName: String = "X-API-Key"
}

companion object : HttpClientPlugin<Config, ApiKeyInterceptor> {
override val key = AttributeKey<ApiKeyInterceptor>("ApiKeyInterceptor")

override fun prepare(block: Config.() -> Unit): ApiKeyInterceptor {
val config = Config().apply(block)
require(config.apiKey.isNotEmpty()) { "API key cannot be empty" }
return ApiKeyInterceptor(config.apiKey, config.headerName)
}
Comment on lines +21 to +25
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Validate headerName to prevent invalid/blank headers at runtime.

A blank header name will cause request failures. Guard it in prepare.

Apply this diff:

         override fun prepare(block: Config.() -> Unit): ApiKeyInterceptor {
             val config = Config().apply(block)
             require(config.apiKey.isNotEmpty()) { "API key cannot be empty" }
+            require(config.headerName.isNotBlank()) { "Header name cannot be blank" }
             return ApiKeyInterceptor(config.apiKey, config.headerName)
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
override fun prepare(block: Config.() -> Unit): ApiKeyInterceptor {
val config = Config().apply(block)
require(config.apiKey.isNotEmpty()) { "API key cannot be empty" }
return ApiKeyInterceptor(config.apiKey, config.headerName)
}
override fun prepare(block: Config.() -> Unit): ApiKeyInterceptor {
val config = Config().apply(block)
require(config.apiKey.isNotEmpty()) { "API key cannot be empty" }
require(config.headerName.isNotBlank()) { "Header name cannot be blank" }
return ApiKeyInterceptor(config.apiKey, config.headerName)
}
🤖 Prompt for AI Agents
In src/main/kotlin/dev/shtanko/api/ktor/ApiKeyInterceptor.kt around lines 21 to
25, the prepare method currently only validates that apiKey is not empty but
does not validate headerName; add a guard to validate that config.headerName is
not blank (use isNotBlank or trim().isNotEmpty()) and throw a clear
IllegalArgumentException via require(...) with a message like "Header name
cannot be blank" so invalid/empty header names are caught at startup rather than
causing runtime request failures.


override fun install(plugin: ApiKeyInterceptor, scope: HttpClient) {
scope.requestPipeline.intercept(HttpRequestPipeline.State) {
context.headers.append(plugin.headerName, plugin.apiKey)
println("🔑 Added API key header: ${plugin.headerName}")
}
Comment on lines +27 to +31
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Avoid duplicate headers; don’t println from a library plugin.

Use “set” semantics to ensure only one value is present. Printing to stdout in a library is a code smell; prefer a logger or remove.

Apply this diff:

         override fun install(plugin: ApiKeyInterceptor, scope: HttpClient) {
             scope.requestPipeline.intercept(HttpRequestPipeline.State) {
-                context.headers.append(plugin.headerName, plugin.apiKey)
-                println("🔑 Added API key header: ${plugin.headerName}")
+                // Replace any existing value to avoid duplicates
+                context.headers.remove(plugin.headerName)
+                context.headers.append(plugin.headerName, plugin.apiKey)
+                // Optional: route through your logging facility instead of stdout if needed
+                // logger.debug("Added API key header: ${plugin.headerName}")
             }
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
override fun install(plugin: ApiKeyInterceptor, scope: HttpClient) {
scope.requestPipeline.intercept(HttpRequestPipeline.State) {
context.headers.append(plugin.headerName, plugin.apiKey)
println("🔑 Added API key header: ${plugin.headerName}")
}
override fun install(plugin: ApiKeyInterceptor, scope: HttpClient) {
scope.requestPipeline.intercept(HttpRequestPipeline.State) {
// Replace any existing value to avoid duplicates
context.headers.remove(plugin.headerName)
context.headers.append(plugin.headerName, plugin.apiKey)
// Optional: route through your logging facility instead of stdout if needed
// logger.debug("Added API key header: ${plugin.headerName}")
}
}
🤖 Prompt for AI Agents
In src/main/kotlin/dev/shtanko/api/ktor/ApiKeyInterceptor.kt around lines 27 to
31, replace the headers.append and println usage: ensure the header is set with
"set" semantics so duplicates aren't created (e.g., remove any existing header
or use headers.set/headerName = apiKey) and remove the println call; if logging
is required use the project's logger instead of System.out. Ensure the code sets
the single header value and does not print to stdout.

}
}
}
Loading
Loading