Daily dispatch #10 - Ktor is a Delight
I recently started changing the project’s API client from Retrofit to Ktor.
And it’s been a delight. As a reference here’s the before and after code.
class MoviesAPIService {
private val API_KEY_MOVIEDB = "It's Mine You can't have it"
private val BASE_URL_MOVIEDB = "https://api.themoviedb.org/3/"
private val api = Retrofit.Builder()
.baseUrl(BASE_URL_MOVIEDB)
.addConverterFactory(GsonConverterFactory.create()) // Converts JSON to GSON
.addCallAdapterFactory(RxJava2CallAdapterFactory.create()) // creates Single from GSON
.build()
.create(MoviesAPI::class.java)
fun getMovies(): Single<ManyMoviesResponse> {
return api.getMovies()
}
fun getMovie(id: String): Single<JsonObject> {
return api.getMovie(id)
}
}
data class ManyMoviesResponse(
val page: Int,
val results: List<Movie>,
val total_pages: Int,
val total_results: Int
)
typealias SingleMovie = Single<Movie>
interface MoviesAPI {
@GET("discover/movie?primary_release_year=2021&sort_by=vote_average.desc&$apiKey")
fun getUnpopularMovies(): Single<ManyMoviesResponse>
@GET("discover/movie?sort_by=popularity.desc&$apiKey")
fun getMovies(): Single<ManyMoviesResponse>
@GET("movie/{movieId}?$apiKey&append_to_response=credits")
fun getMovie(@Path("movieId", encoded = true) movieId: String): Single<JsonObject
}
class MovieDetailViewModel(application: Application) : BaseViewModel(application) {
...
fun fetchMovieFromRemote(id: Int) {
// loading.value = true
disposable.add(
moviesService.getMovie(id.toString())
.subscribeOn(Schedulers.newThread())
.observeOn(AndroidSchedulers.mainThread())
.subscribeWith(object : DisposableSingleObserver<JsonObject>() {
override fun onSuccess(movie: JsonObject) {
val newMovie = Movie(
title = (movie["title"] as JsonPrimitive).asString,
id = (movie["id"] as JsonPrimitive).asString,
releaseDate = (movie["release_date"] as JsonPrimitive).asString,
duration = (movie["runtime"] as JsonPrimitive).asString,
director = getDirectorFromCredits(movie),
description = (movie["overview"] as JsonPrimitive).asString,
score = (movie["vote_average"] as JsonPrimitive).asString,
imageUrl = (movie["poster_path"] as JsonPrimitive).asString,
)
movieLiveData.value = newMovie
Toast.makeText(getApplication(), "Movie retrieved", Toast.LENGTH_SHORT).show()
}
override fun onError(e: Throwable) {
// moviesLoadError.value = true
// loading.value = false
e.printStackTrace()
}
})
)
}
fun getDirectorFromCredits(creditsJsonObject: JsonObject): String {
try {
val credits =
creditsJsonObject["credits"]
val crew: JsonArray = (credits as JsonObject)["crew"] as JsonArray
var director = ""
for(i in 0 until crew.size()) {
val currCrew: JsonObject =
crew[i] as JsonObject
val currJob = currCrew["job"].asString
if(currJob == "Director") {
director = currCrew["name"].asString
break
}
}
return director
} catch (e: Exception) {
return ""
}
}
}
And I’d have to create those Single responses from RxJava2. Then there was the @GET annotations that made it a mess to put query parameters. Ugly as hell.
Then enters Ktor:
internal expect val ApplicationDispatcher: CoroutineDispatcher
class KTorSimpleClient {
private val movieApiAddress = "https://api.themoviedb.org/3"
private val apiKey = "It's Mine you can't have it"
val client = HttpClient() {
install(ContentNegotiation) {
Json {
prettyPrint = true isLenient = true
}
}
}
suspend fun getMovies(): MovieListResult {
val httpResponse =
client.get { url("$movieApiAddress/discover/movie") parameter ("sort_by", "popularity.desc") parameter("api_key", apiKey) } return Json.decodeFromString(
httpResponse.bodyAsText()
)
}
suspend fun getMovie(id: String): Movie {
val httpResponse =
client.get {
url("$movieApiAddress/movie/634649")
parameter ("append_to_response", "credits")
parameter("api_key", apiKey)
}
val movie = Json.decodeFromString<Movie>(httpResponse.bodyAsText())
val director = getDirectorFromCredits(movie)
movie.director = director
return movie
}
private fun getDirectorFromCredits(movie: Movie): String {
return try {
val credits = movie.credits
val crew = credits.crew
var director = ""
for (element in crew) {
val currJob = element.job
if (currJob == "Director") {
director = element.name
break
}
}
director
} catch (e: Exception) {
""
}
}
}
It just looks delightful, it’s clean, it has it’s own Domain Specific Language, so very little Annotations required.
Why I love Ktor
Let’s go over why I love Ktor more than retrofit.
First of all Ktor is multiplatform.
Second, it uses Kotlin Coroutines instead of rxjava2.
So we get to develop a KMM module for our app straight out of the box.
Json to Data Class Plugin
Another useful thing I’ve found is the Json plug-in for Android Studio.
You give it a json response from an api and it generates kotlin data classes for ya.
Check out what it generated for me from this JSON:
{
"page":1,
"results":[
{
"adult":false,
"backdrop_path":"/EnDlndEvw6Ptpp8HIwmRcSSNKQ.jpg",
"genre_ids":[
14,
35,
12
],
"id":425909,
"original_language":"en",
"original_title":"Ghostbusters: Afterlife",
"overview":"When a single mom and her two kids arrive in a small town, they begin to discover their connection to the original Ghostbusters and the secret legacy their grandfather left behind.",
"popularity":1136.17,
"poster_path":"/sg4xJaufDiQl7caFEskBtQXfD4x.jpg",
"release_date":"2021-11-11",
"title":"Ghostbusters: Afterlife",
"video":false,
"vote_average":7.7,
"vote_count":2102
},
{
"adult":false,
"backdrop_path":"/8pgKccb5PfE1kWB9qqiXJem83VC.jpg",
"genre_ids":[
28,
53
],
"id":522016,
"original_language":"en",
"original_title":"The 355",
"overview":"A group of top female agents from American, British, Chinese, Columbian and German government agencies are drawn together to try and stop an organization from acquiring a deadly weapon to send the world into chaos.",
"popularity":1183.696,
"poster_path":"/uQt2dJFMnJmAp9zLAWNfGilK0BW.jpg",
"release_date":"2022-01-05",
"title":"The 355",
"video":false,
"vote_average":6.1,
"vote_count":265
},
{
"adult":false,
"backdrop_path":"/cTTggc927lEPCMsWUsdugSj6wAY.jpg",
"genre_ids":[
28,
12
],
"id":335787,
"original_language":"en",
"original_title":"Uncharted",
"overview":"A young street-smart, Nathan Drake and his wisecracking partner Victor “Sully” Sullivan embark on a dangerous pursuit of “the greatest treasure never found” while also tracking clues that may lead to Nathan’s long-lost brother.",
"popularity":1133.259,
"poster_path":"/tlZpSxYuBRoVJBOpUrPdQe9FmFq.jpg",
"release_date":"2022-02-10",
"title":"Uncharted",
"video":false,
"vote_average":7.2,
"vote_count":451
}
],
"total_pages":32509,
"total_results":650173
}
The generated Data Classes:
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class MovieListResult(
@SerialName("page")
val page: Int,
@SerialName("results")
val results: List<Result>,
@SerialName("total_pages")
val totalPages: Int,
@SerialName("total_results")
val totalResults: Int
)
@Serializable
data class Result(
@SerialName("adult")
val adult: Boolean,
@SerialName("backdrop_path")
val backdropPath: String,
@SerialName("genre_ids")
val genreIds: List<Int>,
@SerialName("id")
val id: Int,
@SerialName("original_language")
val originalLanguage: String,
@SerialName("original_title")
val originalTitle: String,
@SerialName("overview")
val overview: String,
@SerialName("popularity")
val popularity: Double,
@SerialName("poster_path")
val posterPath: String,
@SerialName("release_date")
val releaseDate: String,
@SerialName("title")
val title: String,
@SerialName("video")
val video: Boolean,
@SerialName("vote_average")
val voteAverage: Double,
@SerialName("vote_count")
val voteCount: Int
)
Pretty Cool hm?
Manga Web Portal
On a more entrepreneurial note. I’ve been thinking about a simple app to buy and sell Manga with verified users and sellers.
Could be called Golduru.
I think I could set up a website on firebase and backend on it as well.
Maybe eventually link account with a block chain wallet to help verify scammers as well.
I think there is enough demand in this market to be able to sell this service as an yearly subscription.