Ktor Client: Set up ktor client and get data from typicode
Video: Ktor Client: Set up ktor client and get data from typicode by Taught by Celeste AI - AI Coding Coach
Ktor Client: Fetch JSON from a REST API
Build an HTTP client with the CIO engine plus content negotiation.
@Serializabledata classes for the response shape.client.get(url).body<T>()for the typed call.
JetBrains' Ktor is the standard Kotlin HTTP client — coroutine-based, multiplatform, and pairs cleanly with kotlinx.serialization for typed JSON. Today: fetch a list of posts from JSONPlaceholder.
Dependencies
In build.gradle.kts:
plugins {
kotlin("plugin.serialization") version "2.1.0"
}
dependencies {
implementation("io.ktor:ktor-client-core:2.3.12")
implementation("io.ktor:ktor-client-cio:2.3.12")
implementation("io.ktor:ktor-client-content-negotiation:2.3.12")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.12")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
}
Four Ktor artifacts (core API + CIO engine + content negotiation + JSON serialization) plus the kotlinx.serialization runtime. Don't forget the serialization plugin — without it @Serializable annotations are ignored at compile time.
The Copilot prompt
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.request.*
import io.ktor.client.call.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
// Define a Post data class, set up Ktor client with JSON, fetch from typicode
Copilot generates:
@Serializable
data class Post(
val userId: Int,
val id: Int,
val title: String,
val body: String,
)
fun main() = runBlocking {
val client = HttpClient(CIO) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
}
val posts: List<Post> = client.get("https://jsonplaceholder.typicode.com/posts").body()
posts.take(3).forEach { println(it) }
client.close()
}
Walkthrough
1. The @Serializable data class. kotlinx.serialization reads the annotation at compile time and generates serialize/deserialize code. No reflection; no runtime cost.
The field names match the API's JSON keys exactly (userId, id, title, body). For mismatches, use @SerialName:
@Serializable
data class Post(
@SerialName("user_id") val userId: Int,
// ...
)
2. HttpClient(CIO) { ... }. Build a Ktor client with the CIO (Coroutine-based I/O) engine — pure Kotlin, no Java dependencies, fast for most workloads. Other engines: OkHttp, Apache, Java (Java 11+).
install(ContentNegotiation) { json(...) } plugs in JSON support. Json { ignoreUnknownKeys = true } is forgiving — if the API adds new fields, your code doesn't crash.
3. client.get(url).body(). A typed call. client.get(...) returns an HttpResponse; .body<T>() deserializes the response JSON into T using the registered content negotiator.
The return type is inferred from the variable annotation: val posts: List<Post> = ... .body(). Without the annotation, the compiler doesn't know what type to deserialize into.
4. runBlocking { } for the main function. Ktor's API is suspend-based — client.get is a suspend function. runBlocking is the simplest way to call suspend code from a non-suspend main. For real apps, use a proper coroutine scope (lifecycle-aware on Android, coroutineScope { } in libraries).
5. client.close(). Releases the underlying HTTP/connection pool. Always close when done. For long-lived clients, share one instance and close on app exit.
Adding query parameters
val posts: List<Post> = client.get("https://jsonplaceholder.typicode.com/posts") {
parameter("userId", 1)
parameter("limit", 10)
}.body()
parameter(key, value) adds ?userId=1&limit=10 to the URL. Multiple parameter calls accumulate.
Headers and authentication
val response = client.get("https://api.example.com/me") {
header("Authorization", "Bearer ${token}")
header("User-Agent", "MyApp/1.0")
contentType(ContentType.Application.Json)
}
header(key, value) for arbitrary headers. contentType(...) for the standard one (sugar over header("Content-Type", ...)).
For cleaner auth:
val client = HttpClient(CIO) {
install(Auth) {
bearer {
loadTokens {
BearerTokens(accessToken = "...", refreshToken = "...")
}
}
}
}
The Auth plugin handles Authorization headers automatically.
Posting JSON
val newPost = Post(userId = 1, id = 0, title = "Hi", body = "Hello")
val created: Post = client.post("https://jsonplaceholder.typicode.com/posts") {
contentType(ContentType.Application.Json)
setBody(newPost)
}.body()
setBody(value) serializes the value using the registered content negotiator. Same body<T>() pattern to read the response.
Error handling
import io.ktor.client.plugins.*
try {
val posts: List<Post> = client.get("https://jsonplaceholder.typicode.com/posts").body()
} catch (e: ClientRequestException) {
println("HTTP ${e.response.status}: ${e.message}")
} catch (e: ServerResponseException) {
println("Server error ${e.response.status}")
} catch (e: ConnectTimeoutException) {
println("Connection timeout")
}
Ktor distinguishes:
ClientRequestException— 4xx errors.ServerResponseException— 5xx errors.RedirectResponseException— 3xx errors (rare; usually followed automatically).ConnectTimeoutException/HttpRequestTimeoutException— network timeouts.
For app-level retry, install the HttpRequestRetry plugin:
install(HttpRequestRetry) {
retryOnServerErrors(maxRetries = 3)
exponentialDelay()
}
Logging
install(Logging) {
logger = Logger.DEFAULT
level = LogLevel.INFO
}
Logs all requests and responses. Useful in development; turn off (or set to LogLevel.NONE) in production.
Sharing the client
Don't create a new HttpClient per request — that's expensive (TCP handshakes, TLS setup, thread pool). Share one across the app:
object Api {
val client = HttpClient(CIO) {
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
}
suspend fun getPosts(): List<Post> =
client.get("https://jsonplaceholder.typicode.com/posts").body()
}
Or — better — inject via DI (Koin, Hilt) so tests can swap in MockEngine.
Common mistakes
Forgetting the serialization plugin. Compiles, but @Serializable is silently ignored. Runtime: "Serializer for class 'Post' is not found."
Forgetting ignoreUnknownKeys = true. API adds a field → app crashes on parse. Prevention: always allow unknown keys unless strict matching is needed.
Calling body() without a type annotation. val posts = ....body() defaults to String. Always annotate: val posts: List<Post> = ....body().
Not closing the client. Long-running JVMs leak connections. Use one shared client and close on shutdown.
Mismatched JSON keys. API has user_id, your data class has userId, no @SerialName. Parse fails silently with userId = 0.
runBlocking in production code. Fine for main, scripts, demos. In Android UI code, use viewModelScope.launch { ... }. In libraries, take a CoroutineScope or be a suspend function.
What's next
Episode 9: Find the maximum value in a list. A small one-liner that demos maxOrNull() plus the null-safety idioms that make it production-grade.
Recap
Ktor: HttpClient(CIO) { install(ContentNegotiation) { json(...) } }. client.get(url).body<T>() for typed JSON. @Serializable data class with field names matching the API (or @SerialName). Always ignoreUnknownKeys = true for forward compatibility. Share one client; don't recreate per call. runBlocking { ... } only for main/scripts/demos. Errors via ClientRequestException (4xx) and ServerResponseException (5xx).
Next episode: max value in a list.