Ktor as HTTP client for Android

    Retrofit2, as an Android developer, I like it, but what about trying to use Ktor as an HTTP client? In my opinion, for Android development, it is no worse and no better, just one of the options, although if you wrap everything up a bit, it can turn out very well. I will consider the basic features with which you can start using Ktor as an HTTP client - creating requests of various kinds, receiving raw answers and responses in the form of text, deserializing json into classes through converters, logging.



    If in general, Ktor is a framework that can act as an HTTP client. I will consider it from the development side for Android. It is unlikely that you will see very complex use cases below, but the basic features are accurate. The code from the examples below can be viewed on GitHub .

    Ktor uses korutin from Kotlin 1.3, a list of available artifacts can be found here , the current version is 1.0.1.
    For requests I will use HttpBin .

    Simple use


    To get started, you will need basic dependencies for the Android client:

    implementation "io.ktor:ktor-client-core:1.0.1"
    implementation "io.ktor:ktor-client-android:1.0.1"
    

    Do not forget to add information in Manifest that you are using the Internet.

    <uses-permissionandroid:name="android.permission.INTERNET"/>

    Let's try to get the server response as a string, what could be simpler?

    privateconstval BASE_URL = "https://httpbin.org"privateconstval GET_UUID = "$BASE_URL/uuid"funsimpleCase() {
        val client = HttpClient()
        GlobalScope.launch(Dispatchers.IO) {
            valdata = client.get<String>(GET_UUID)
            Log.i("$BASE_TAG Simple case ", data)
        }
    }
    

    You can create a client without parameters, just create an instance HttpClient(). In this case, Ktor will select the required engine himself and use it with the default settings (we have one connected engine - Android, but there are others, for example, OkHttp).
    Why korutiny? Because it get()is a suspendfunction.

    What can be done next? You already have data from the server as a string, you just have to parse it and get classes that you can work with. It seems to be easy and fast in this case of use.

    We get a raw answer


    Sometimes it is necessary to get a set of bytes instead of a string. Experiment with asynchrony at the same time.

    funperformAllCases() {
        GlobalScope.launch(Dispatchers.IO) {
            simpleCase()
            bytesCase()
        }
    }
    suspendfunsimpleCase() {
        val client = HttpClient()
        valdata = client.get<String>(GET_UUID)
        Log.i("$BASE_TAG Simple case", data)
    }
    suspendfunbytesCase() {
        val client = HttpClient()
        valdata = client.call(GET_UUID).response.readBytes()
        Log.i("$BASE_TAG Bytes case", data.joinToString(" ", "[", "]") { it.toString(16).toUpperCase() })
    }
    

    In places calling methods HttpClient, such as call(), and get()under the hood will be invoked await(). So in this case, calls simpleCase(), and bytesCase()will always be consistent. It is necessary in parallel - just wrap each call into a separate coruntine. In this example, new methods have appeared. The call call(GET_UUID)will return us an object from which we can get information about the request, its configuration, response, and customer. The object contains a lot of useful information - from the response code and the protocol version to the channel with the same bytes.

    Do you need to close it somehow?


    The developers indicate that for the correct shutdown of the HTTP engine, you need to call the client method close(). If you need to make one call and immediately close the client, you can use the method use{}, since it HttpClientimplements the interface Closable.

    suspendfunclosableSimpleCase() {
        HttpClient().use {
            valdata: String = it.get(GET_UUID)
            Log.i("$BASE_TAG Closable case", data)
        }
    }
    

    Examples besides GET


    In my work, the second most popular method is POST. Consider the example of setting parameters, headers and the request body.

    suspendfunpostHeadersCase(client: HttpClient) {
        valdata: String = client.post(POST_TEST) {
            fillHeadersCaseParameters()
        }
        Log.i("$BASE_TAG Post case", data)
    }
    privatefun HttpRequestBuilder.fillHeadersCaseParameters() {
        parameter("name", "Andrei") // + параметр в строку запроса
        url.parameters.appendAll(
            parametersOf(
                "ducks" to listOf("White duck", "Grey duck"), // + список параметров в строку запроса"fish" to listOf("Goldfish") // + параметр в строку запроса
            )
        )
        header("Ktor", "https://ktor.io") // + заголовок
        headers /* получаем доступ к билдеру списка заголовков */ {
            append("Kotlin", "https://kotl.in")
        }
        headers.append("Planet", "Mars") // + заголовок
        headers.appendMissing("Planet", listOf("Mars", "Earth")) // + только новые заголовки, "Mars" будет пропущен
        headers.appendAll("Pilot", listOf("Starman"))  // ещё вариант добавления заголовка
        body = FormDataContent( // создаем параметры, которые будут переданы в form
            Parameters.build {
                append("Low-level", "C")
                append("High-level", "Java")
            }
        )
    }
    

    In fact, in the last parameter of the function post()you have access to HttpRequestBuilder, with which you can form any request.
    The method post()simply parses the string, converts it to the URL, explicitly specifies the type of the method, and makes the request.

    suspendfunrawPostHeadersCase(client: HttpClient) {
        valdata: String = client.call {
            url.takeFrom(POST_TEST)
            method = HttpMethod.Post
            fillHeadersCaseParameters()
        }
            .response
            .readText()
        Log.i("$BASE_TAG Raw post case", data)
    }
    

    If you run the code from the last two methods, the result will be the same. The difference is not great, but it is more convenient to use wrappers. The situation is similar for the put(), delete(), patch(), head()and options(), therefore, they will not be considered.

    However, if you look closely, you can see that there is a difference in typing. When call()you call, you get a low-level response and you must read the data yourself, but what about automatic typing? After all, we are all used to connect a converter (type Gson) in Retrofit2 and specify the return type as a specific class. We'll talk about conversion into classes later, but the method will help to typify the result without being tied to a specific HTTP method request.

    suspendfuntypedRawPostHeadersCase(client: HttpClient) {
        valdata = client.request<String>() {
            url.takeFrom(POST_TEST)
            method = HttpMethod.Post
            fillHeadersCaseParameters()
        }
        Log.i("$BASE_TAG Typed raw post", data)
    }
    

    Submitting form data


    Usually, you need to pass parameters either in the query string or in the body. We in the example above have already considered how to do this with HttpRequestBuilder. But it can be easier.

    The function submitFormaccepts a url as a string, parameters for the request, and a boolean flag that says how to pass parameters - in the query string or as pairs in a form.

    suspendfunsubmitFormCase(client: HttpClient) {
        val params = Parameters.build {
            append("Star", "Sun")
            append("Planet", "Mercury")
        }
        val getData: String = client.submitForm(GET_TEST, params, encodeInQuery = true) // параметры в строке запросаval postData: String = client.submitForm(POST_TEST, params, encodeInQuery = false) // параметры в form
        Log.i("$BASE_TAG Submit form get", getData)
        Log.i("$BASE_TAG Submit form post", postData)
    }
    

    And what about multipart / form-data?


    In addition to string pairs, you can pass as POST parameters for requesting numbers, arrays of bytes, and various Input streams. Differences in the function and the formation of parameters. See how:

    suspendfunsubmitFormBinaryCase(client: HttpClient) {
        val inputStream = ByteArrayInputStream(byteArrayOf(77, 78, 79))
        val formData = formData {
            append("String value", "My name is") // строковый параметр
            append("Number value", 179) // числовой
            append("Bytes value", byteArrayOf(12, 74, 98)) // набор байт
            append("Input value", inputStream.asInput(), headersOf("Stream header", "Stream header value")) // поток и заголовки
        }
        valdata: String = client.submitFormWithBinaryData(POST_TEST, formData)
        Log.i("$BASE_TAG Submit binary case", data)
    }
    

    As you can see, you can attach a set of headers to each parameter.

    We deserialize the answer to the class.


    It is necessary to get some data from the query not in the form of a string or byte, but immediately converted into a class. For a start, we are recommended in the documentation to connect the feature of working with json, but I want to make a reservation that jvm needs a specific dependency and without kotlinx-serialization, all this will not take off. I suggest using Gson as a converter (links to other supported libraries are in the documentation, links to the documentation will be at the end of the article).

    build.gradle project level:

    buildscript {
        dependencies {
            classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
        }
    }
    allprojects {
        repositories {
            maven { url "https://kotlin.bintray.com/kotlinx" }
        }
    }
    

    build.gradle application level:

    apply plugin: 'kotlinx-serialization'
    dependencies {
        implementation "io.ktor:ktor-client-json-jvm:1.0.1"
        implementation "io.ktor:ktor-client-gson:1.0.1"
    }
    

    Now let's execute the query. From the new there will be only a connection feature of working with Json when creating a client. I will use open weather API. For completeness, show the data model.

    dataclassWeather(
        val consolidated_weather: List<ConsolidatedWeather>,
        val time: String,
        val sun_rise: String,
        val sun_set: String,
        val timezone_name: String,
        val parent: Parent,
        val sources: List<Source>,
        val title: String,
        val location_type: String,
        val woeid: Int,
        val latt_long: String,
        val timezone: String
    )
    dataclassSource(
        val title: String,
        val slug: String,
        val url: String,
        val crawl_rate: Int
    )
    dataclassConsolidatedWeather(
        val id: Long,
        val weather_state_name: String,
        val weather_state_abbr: String,
        val wind_direction_compass: String,
        val created: String,
        val applicable_date: String,
        val min_temp: Double,
        val max_temp: Double,
        val the_temp: Double,
        val wind_speed: Double,
        val wind_direction: Double,
        val air_pressure: Double,
        val humidity: Int,
        val visibility: Double,
        val predictability: Int
    )
    dataclassParent(
        val title: String,
        val location_type: String,
        val woeid: Int,
        val latt_long: String
    )
    privateconstval SF_WEATHER_URL = "https://www.metaweather.com/api/location/2487956/"suspendfungetAndPrintWeather() {
        val client = HttpClient(Android) {
            install(JsonFeature) {
                serializer = GsonSerializer()
            }
        }
        val weather: Weather = client.get(SF_WEATHER_URL)
        Log.i("$BASE_TAG Serialization", weather.toString())
    }    
    

    And what else can


    For example, the server returns an error, and you have the code as in the previous example. In this case, you will get a serialization error, but you can configure the client so that when the response code <300, an error is thrown BadResponseStatus. It is enough to install when building the client expectSuccessin true.

    val client = HttpClient(Android) {
            install(JsonFeature) {
                serializer = GsonSerializer()
            }
            expectSuccess = true
        }
    

    When debugging, logging may be useful. It is enough to add one dependency and tune the client.

    implementation "io.ktor:ktor-client-logging-jvm:1.0.1"
    

    val client = HttpClient(Android) {
            install(Logging) {
                logger = Logger.DEFAULT
                level = LogLevel.ALL
            }
        }
    

    We specify the DEFAULT logger and everything will fall into the LogCat, but you can redefine the interface and make your logger if desired (although I didn’t see any great opportunities there, there is only a message at the entrance, but there is no log level). We also indicate the level of logs to be reflected.

    References:


    What is not considered:

    • Work with OkHttp engine
    • Engine Settings
    • Mock engine and testing
    • Authorization module
    • Separate features such as storing cookies between requests, etc.
    • All that does not apply to the HTTP client for Android (other platforms, work through sockets, server implementation, etc.

    Also popular now: