12.
Networking
Written by Carlos Mota
Fetching data from the internet is one of the core features of most mobile apps. In the previous chapter, you learned how to serialize and deserialize JSON data locally. Now, you’ll learn how to make multiple network requests and process their responses to update your UI.
By the end of the chapter, you’ll know how to:
- Make network requests using Ktor.
- Parse network responses.
- Test your network implementation.
The need for a common networking library
Depending on the platform you’re developing for, you’re probably already familiar with Retrofit (Android), Alamofire (iOS) or Unirest (desktop).
Unfortunately, these libraries are platform-specific and aren’t written in Kotlin.
Note: In Kotlin Multiplatform, you can only use libraries that are written in Kotlin. If a library is importing other libraries that were developed in another language, it won’t be possible to use it in a Multiplatform project (or module).
Developers needed a new library — a library that could provide the same functionalities as the ones mentioned above, but was built for Multiplatform applications. With that in mind, Ktor was created.
Using Ktor
Ktor is an open-source library created and maintained by JetBrains (and the community). It’s available for both client and server applications.
It’s fully written in Kotlin and uses coroutines for asynchronous calls. In the upcoming sections, you’ll see how easy it is to use it in your applications.
Note: Find more information about Ktor on the official website.
Adding Ktor
Open build.gradle.kts from shared. Inside the commonMain dependencies section, add the following dependencies at the end:
implementation("io.ktor:ktor-client-core:2.0.0-beta-1")
implementation("io.ktor:ktor-client-serialization:2.0.0-beta-1")
Here, you’re adding the Ktor core library along with the serialization library that it will use to parse the responses and transform the data into objects the app can process.
Ktor has different HTTP client engines depending on the platform to which you’re compiling the project. Although desktop doesn’t require a specific library, since you’re also targeting Android and iOS, you’ll need to add the following in androidMain and iosMain respectively:
implementation("io.ktor:ktor-client-android:2.0.0-beta-1")
implementation("io.ktor:ktor-client-ios:2.0.0-beta-1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0-native-mt") {
version {
strictly("1.6.0-native-mt")
}
}
Looking at the iOS implementation, you’ll see that you’ve also set a specific version of the kotlinx-coroutines. This is required because the coroutines version that’s bundled with Ktor only supports single-thread usage. You can read more about this in Chapter 13, “Concurrency”.
The recommended version for Kotlin 1.6.10 is to use 1.6.0-native-mt.
Note: You need to add this version to the
iOSMaindependencies section and not oncommonMainbecause the constraint is with iOS.
Click Sync Now to synchronize and wait for Android Studio to fetch and import these new libraries.
Connecting to the API with Ktor
To build learn, you need to make three different requests to:
- The RSS feed of a specific topic.
- An article webpage.
- Your Gravatar account.
The data for the first one is in the RW_CONTENT property inside the FeedPresenter.kt file located in the shared module. It can be one of the following:
Each of these requests loads the latest 20 articles published for its category.
The second request corresponds to the link field of the RWEntry. Since an RSS entry doesn’t contain a URL for the article image, you’ll need to fetch it manually from raywenderlich.com.
Finally, make the last request to Gravatar, a service that allows you to define an online profile that can be used across external sites. Your picture from raywenderlich.com, for example, is retrieved from this service.
How to make a network request
Open the data folder inside the shared/src/commonMain module and create a new file named FeedAPI.kt. Add the following code:
//1
public const val GRAVATAR_URL = "https://en.gravatar.com/"
public const val GRAVATAR_RESPONSE_FORMAT = ".json"
//2
@ThreadLocal
public object FeedAPI {
//3
private val client: HttpClient = HttpClient()
//4
public suspend fun fetchRWEntry(feedUrl: String): HttpResponse = client.get(feedUrl)
//5
public suspend fun fetchMyGravatar(hash: String): HttpResponse =
client.get("$GRAVATAR_URL$hash$GRAVATAR_RESPONSE_FORMAT")
}
When prompted for imports, use the following:
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.client.statement.HttpResponse
import kotlin.native.concurrent.ThreadLocal
In the code above:
-
The constants
fetchMyGravatarwill use to make its request: the URL and the response format. -
This annotation is only valid for iOS (Kotlin/Native). It’s ignored in both Android and desktop. Using
@ThreadLocal, theFeedAPIwon’t be shared across other threads that try to access it. Instead, a new copy will be made. This guarantees the object won’t freeze. Read more about this in Chapter 13, “Concurrency”. -
Initialization of the
HttpClientthat you’ll use to make the requests. -
This function receives a feed URL for a specific topic, makes the request and returns it as a response via a
HttpResponse. In this object, you can get additional information about the status code of the response, its body, etc. -
Finally, you’ll access Gravatar to retrieve information about your profile.
You’re making a GET request in learn. Other HTTP methods are also available with Ktor: POST, PUT, DELETE, HEAD, OPTION and PATCH.
Note: If you look closely at these functions, you’ll see they’re declared using the keyword
suspend. It’s used so the current thread won’t get blocked while waiting for a response. You’ll learn more about it and coroutines in Chapter 13, “Concurrency”.
You’ve made the requests, and now it’s time to process the responses.
Plugins
Ktor has a set of plugins already built in that are disabled by default. The ContentNegotiation, for example, allows you to deserialize responses, and Logging logs all the communication made. You’ll see an example of both later in this chapter.
These plugins intercept all the requests and responses made, then process them according to their purpose.
Parsing network responses
To deserialize a JSON response you need to add two new libraries. Open the build.gradle.kts file and in commonMain/dependencies section, add:
implementation("io.ktor:ktor-client-content-negotiation:2.0.0-beta-1")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.0.0-beta-1")
Synchronize the project.
In FeedAPI, you’ve got two functions that return an HttpResponse:
-
fetchRWEntryaccesses the raywenderlich.com XML feed. Since there’s no direct way to support its serialization from Ktor or an official library from JetBrains at the moment, you’ll use one from the community: KorIO. -
fetchMyGravataris set to receive a JSON response containing information about your Gravatar account.
You’ll start with fetchMyGravatar. Since it’s JSON, you can install ContentNegotiation for json so the response from this function will be the deserialized object. To achieve this, update the client initialization with:
private val client: HttpClient = HttpClient {
install(ContentNegotiation) {
json(nonStrictJson)
}
}
Ktor will now use json to deserialize the response body. Additionally, you also need to define the nonStrictJson property. Declare it before the HttpClient:
private val nonStrictJson = Json { isLenient = true; ignoreUnknownKeys = true }
When prompted for imports, add:
import io.ktor.client.plugins.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
To keep your app stable on any future server update, it’s always a good approach to define isLenient and ignoreUnknownKeys as true. Otherwise, the deserialization might throw an exception if there’s malformed input or there are properties in the JSON that don’t exist in the serializable object.
Since fetchMyGravatar is the only request that receives a JSON response and you’ve already enabled the plugin, update the existing fetchMyGravatarreturn type to:
public suspend fun fetchMyGravatar(hash: String): GravatarProfile =
client.get("$GRAVATAR_URL$hash$GRAVATAR_RESPONSE_FORMAT").body()
Now, when it calls the function, instead of receiving a HttpResponse that you would need to process, you’ll receive the deserialized object that you can use.
To set the GravatarProfile and GravatarEntry as Serializable, open the GravatarEntry.kt file in the data folder and add the annotation @Serializable to both data classes.
Logging your requests and responses
Logging all the communication with the server is important so you can identify any error that might exist — and, of course, so you can know who to blame. :]
Ktor has native support for logging. Before writing the logger, you need to open the build.gradle.kts file, and in the commonMain dependencies, add:
implementation("io.ktor:ktor-client-logging:2.0.0-beta-1")
Do a Gradle sync.
When ready, return to the FeedAPI.kt file and add the following code inside HttpClient initialization lambda:
//1
install(Logging) {
//2
logger = Logger.DEFAULT
//3
level = LogLevel.ALL
}
Here’s what’s happening in the code above:
- You install the
Loggingfeature in the app. When installed, it will intercept all the network requests and responses. - This is the logger class that you’ll use to
logall the network communication. UsingDEFAULTfalls back to calling theprintlnfunction. - This specified the data that needs to be logged.
The different types of logging levels are:
-
LogLevel.ALL: Where everything is logged. Importantly, with this log level, if you’re uploading a large file, all of its content will be printed. This ultimately can lead to a buffer overflow error and crash your app. Don’t forget to cover this scenario. -
LogLevel.HEADERS: Logs the request and response headers. -
LogLevel.BODY: With this level set, it prints only the body. -
LogLevel.INFO: Logs the URL and the method that’s going to be used for requests. For responses, this means its status, method and the “from” field. -
LogLevel.NONE: Nothing will be logged. As a safety mechanism, if you’re building your app for production, you should select this level. Otherwise, you risk that someone might access your network logs by simply opening Logcat with the device plugged into the computer.
Additionally, you can define a custom logger class. To accomplish this, go to the data folder inside the shared module and create a HttpClientLogger.kt file with the following code:
import com.raywenderlich.learn.platform.Logger
private const val TAG = "HttpClientLogger"
public object HttpClientLogger : io.ktor.client.plugins.logging.Logger {
override fun log(message: String) {
Logger.d(TAG, message)
}
}
Here, you’re extending Ktor Logger and changing the one that should be used to log the requests and responses. You do this by overriding the log function. Instead of using the default one, you’re going to use the app Logger defined on shared.
Now, return to FeedAPI.kt and update the previously added install call to instead use:
logger = HttpClientLogger
Build and run the apps to confirm everything is correct. For now, since there are no requests made, you won’t find any log message when filtering for HttpClientLogger both in Android Studio and Xcode. After the next section, you’ll try this again.
You can use the filter fields both in Android Studio and Xcode to display only messages that match a specific tag.
Note: Since the logger you created receives a
TAGparameter that corresponds to theHttpClientLoggerclass, you can use that to filter on Logcat for all the network requests and responses made.
Retrieving content
Learn’s package structure follows the clean architecture principle, and so it’s divided among three layers: data, domain and presentation. In the data layer, there’s the FeedAPI.kt that contains the functions responsible for making the requests. Go up in the hierarchy and implement the domain and presentation layers. The UI will interact with the presentation layer.
Interacting with Gravatar
Open the GetFeedData.kt file inside the domain folder of the shared module. Inside the class declaration, replace the TODO commentary with:
//1
public suspend fun invokeGetMyGravatar(
hash: String,
onSuccess: (GravatarEntry) -> Unit,
onFailure: (Exception) -> Unit
) {
try {
//2
val result = FeedAPI.fetchMyGravatar(hash)
Logger.d(TAG, "invokeGetMyGravatar | result=$result")
//3
if (result.entry.isEmpty()) {
coroutineScope {
onFailure(Exception("No profile found for hash=$hash"))
}
//4
} else {
coroutineScope {
onSuccess(result.entry[0])
}
}
//5
} catch (e: Exception) {
Logger.e(TAG, "Unable to fetch my gravatar. Error: $e")
coroutineScope {
onFailure(e)
}
}
}
When importing the Logger library, don’t forget that you’re using the one from the shared module:
import com.raywenderlich.learn.platform.Logger
Here’s what’s happening:
- This function receives a
hashproperty that’s going to be used to build the request to Gravatar and two lambda functions that will be called depending on if the operation succeeded or not.onSuccessis triggered for the first case andonFailurefor the second. -
fetchMyGravataruses theContentNegotiationyou previously installed, so instead of returning anHttpResponse(like the other functions will), it’s going to return an object containing the response data. - A response is valid if there’s at least one element in
result. If this list is empty, it means the response is empty, and thereforeonFailureis triggered. - If it retrieves a response containing at least one entry, though,
onSuccessis called with the first object of the list. - Finally, if anything fails during this process,
onFailureis called with the exception that caused the problem.
Now that the domain logic is ready, move to the presentation layer. Open FeedPresenter.kt. Before the class declaration, add and define your GRAVATAR_EMAIL:
private const val GRAVATAR_EMAIL = "YOUR_GRAVATAR_EMAIL"
You’ll use this later to build the request. Create an account if you don’t already have one, and replace YOUR_GRAVATAR_EMAIL with your Gravatar email. Once done, add the functions the UI is going to call inside the existing class:
//1
public fun fetchMyGravatar(cb: FeedData) {
Logger.d(TAG, "fetchMyGravatar")
//2
MainScope().launch {
//3
feed.invokeGetMyGravatar(
//4
hash = md5(GRAVATAR_EMAIL),
//5
onSuccess = { cb.onMyGravatarData(it) },
onFailure = { cb.onMyGravatarData(GravatarEntry()) }
)
}
}
Here’s a step-by-step breakdown of this logic:
- This is a function that allows you to set a listener for the UI to receive updates for the call to
fetchMyGravatar. TheFeedDataargument is an interface used to notify the UI when new data is available. This scenario triggersonMyGravatarData. - Since
invokeGetMyGravataris declared using asuspendfunction, you need to call it from a coroutine. To keep things simple in this chapter, you’re going to useMainScopefor that. - Calls the
invokeGetMyGravatarto make the request for the Gravatar. - The Gravatar request requires an md5 hash of the email the user has registered. It’s easier to call this method from Utils.kt directly.
- If the request succeeds, it calls the
onSuccessexpression with the received data. Otherwise,onFailureis triggered and an emptyGravatarEntryis sent.
Finally, it’s time to update the apps.
Go over to androidApp and in the FeedViewModel.kt file inside the ui/home folder, update the existing fetchMyGravatar to call the entry point that you defined before:
fun fetchMyGravatar() {
Logger.d(TAG, "fetchMyGravatar")
presenter.fetchMyGravatar(this)
}
When the Gravatar profile is available, it triggers onMyGravatarData. Update it to set this data on the _profile property:
override fun onMyGravatarData(item: GravatarEntry) {
Logger.d(TAG, "onMyGravatarData | item=$item")
viewModelScope.launch {
_profile.value = item
}
}
Now that you’ve got everything ready, build and run the Android app.
To implement the same feature on the desktop app, open its FeedViewModel.kt, located in the desktopApp module’s ui/home folder. Similar to what you added for Android, update the existing fetchMyGravatar function to:
fun fetchMyGravatar() {
Logger.d(TAG, "fetchMyGravatar")
presenter.fetchMyGravatar(this)
}
To make the corresponding requests and update the onMyGravatarData to notify the UI once they are available, update the onMyGravatarData method to the following:
override fun onMyGravatarData(item: GravatarEntry) {
Logger.d(TAG, "onMyGravatarData | item=$item")
viewModelScope.launch {
profile.value = item
}
}
Finally, compile and run your app using the following command:
./gradlew desktopApp:run
Switch to Xcode, and navigate to the extensions’ folder. Here, open the FeedClient.swift class and find the fetchProfile function. Before assigning the completion to the handlerProfile, add this code to fetch the Gravatar profile:
feedPresenter.fetchMyGravatar(cb: self)
Build and run your iOS app.
Interacting with the raywenderlich.com RSS feed
Now that you’re receiving the information from Gravatar, it’s time to get the RSS feed. Once again, open the GetFeedData.kt file in shared/domain and add the following above invokeGetMyGravatar:
//1
public suspend fun invokeFetchRWEntry(
platform: PLATFORM,
feedUrl: String,
onSuccess: (List<RWEntry>) -> Unit,
onFailure: (Exception) -> Unit
) {
try {
//2
val result = FeedAPI.fetchRWEntry(feedUrl)
Logger.d(TAG, "invokeFetchRWEntry | feedUrl=$feedUrl")
//3
val xml = Xml.parse(result.bodyAsText())
val feed = mutableListOf<RWEntry>()
for (node in xml.allNodeChildren) {
val parsed = parseNode(platform, node)
if (parsed != null) {
feed += parsed
}
}
//4
coroutineScope {
onSuccess(feed)
}
} catch (e: Exception) {
Logger.e(TAG, "Unable to fetch feed:$feedUrl. Error: $e")
//5
coroutineScope {
onFailure(e)
}
}
}
Here’s a step-by-step breakdown of this logic:
- This function receives a
PLATFORMenum value that corresponds to one of the different areas of articles you have at raywenderlich.com: all, Android, iOS, Unity and Flutter. This is used to give the possibility to the UI to filter for specific types. -
resultholds theHttpResponsethat’s returned fromfetchRWEntry. The parameter sent here is the URL where the request should be made. - Since there’s no direct support for XML serialization in Ktor, you need to use a third-party library. In this case, due to its popularity, you’re going to use KorIO. It will parse through all the nodes of the XML and return a list of RWEntry.
- If everything worked until this next code block, this function ends by sending the
feedto theonSuccessexpression. - On the contrary, if there was any issue,
onFailureis triggered instead.
It’s now time to move in the hierarchy and open the FeedPresenter.kt file on the presentation layer inside shared. With the request implemented, you need to add an entry point the UI can call.
To achieve this, add the following functions above fetchMyGravatar:
//1
public fun fetchAllFeeds(cb: FeedData) {
Logger.d(TAG, "fetchAllFeeds")
//2
for (feed in content) {
fetchFeed(feed.platform, feed.url, cb)
}
}
private fun fetchFeed(platform: PLATFORM, feedUrl: String, cb: FeedData) {
MainScope().launch {
//3
feed.invokeFetchRWEntry(
platform = platform,
feedUrl = feedUrl,
//4
onSuccess = { cb.onNewDataAvailable(it, platform, null) },
onFailure = { cb.onNewDataAvailable(emptyList(), platform, it) }
)
}
}
Here’s a logic breakdown:
- The
cbyou’ll use to notify the UI when new data is available. -
contentcorresponds to the deserialization of theRW_CONTENTproperty. It should contain five different platform types: all, Android, iOS, Unity and Flutter, each with its own feed URL. You’re going to fetch them all. -
invokeFetchRWEntryis going to call theGetFeedDatathat then calls theFeedAPIand sends the network request. - Finally, the
onSuccessandonFailureexpressions call thecbfunctions with the response data. In case the operation succeeds, the received list ofRWEntryis sent, otherwise an empty list is sent.
With this, you’ve finished the business (shared) logic for the network requests. It’s now time to connect it to the Android, desktop and iOS apps. Starting with Android, open the FeedViewModel.kt file. Look for the fetchAllFeeds function and add the following code inside the function:
presenter.fetchAllFeeds(this)
This will trigger the network request that you defined before. Scrolling down this file, you’ll see the onNewDataAvailable implementation. Update it with the following code block so the items property can be updated:
override fun onNewDataAvailable(items: List<RWEntry>, platform: PLATFORM, exception: Exception?) {
Logger.d(TAG, "onNewDataAvailable | platform=$platform items=${items.size}")
viewModelScope.launch {
_items[platform] = items
}
}
This is important because MainActivity.kt is observing all the changes on items. Open this class and you’ll see that items contains the data required to populate the app screens.
Build and run the Android application. You’ll see a screen similar to this one:
Navigate to the desktopApp project and add the same logic. On FeedViewModel.kt, find the fetchAllFeeds function and add:
presenter.fetchAllFeeds(this)
Main.kt calls this function to fetch all the available feeds. When they’re ready, onNewDataAvailable is called with all the items. Update this function to:
override fun onNewDataAvailable(newItems: List<RWEntry>, platform: PLATFORM, exception: Exception?) {
Logger.d(TAG, "onNewDataAvailable | platform=$platform items=${items.size}")
viewModelScope.launch {
_items[platform] = items
}
}
Now that the desktop app is ready, enter the compilation and run command at the Android Studio terminal:
./gradlew desktopApp:run
You’ll see an app similar to this one:
Finally, update the iOS app. Open the FeedClient file inside the extensions folder, and search for fetchFeeds. Here, before assigning the completion to the handler, add:
feedPresenter.fetchAllFeeds(cb: self)
That’s it! Build and run the app, then see which articles the team recently published.
Adding headers to your request
You have two possibilities to add headers to your requests: by defining them when the HttpClient is configured, or when calling the client individually. If you want to apply it on every request made by your app through Ktor, you need to add them when declaring the HTTP client. Otherwise, you can set them on a specific request.
Imagine that you want to add a custom header to identify your app name.
First create a Values.kt file in the shared/commonMain module root folder. It should be located at the same level as domain and platform.
Then, add a constant that’s going to be used to identify the parameter that you want to add as a header:
public const val X_APP_NAME: String = "X-App-Name"
This constant will be the header’s key on both implementations.
Now, define its value by adding another property — this time it should correspond to the app name:
public const val APP_NAME: String = "learn"
Since this value should be the same for both platforms, you’re going to use it as the value for the header request.
Now, if you want to add this header to all requests done through Ktor, you need to locate client in the FeedAPI.kt file. When you’re overriding the client, before the call to install add:
defaultRequest {
header(X_APP_NAME, APP_NAME)
}
Calling defaultRequest directly is the equivalent of:
install(DefaultRequest)
In other words, similar to what you did for logging, you’re setting the default configuration for every request. In this case, you’re adding an X_APP_NAME header.
Now, compile the app on all three applications. By opening Logcat (Android), terminal (desktop) and Xcode console (iOS), confirm in the log messages that you’re sending this new header.
Hint: Don’t forget that you can filter your logs using the tag
HttpClientLogger.
On the contrary, if you want to add this header for a specific request, you just need to override the HttpRequestBuilder to set it. Here’s a real example: imagine that you want to add it only when you’re fetching your Gravatar profile. Remove the previously added header, and in the fetchMyGravatar declaration, update it to:
public suspend fun fetchMyGravatar(hash: String): GravatarProfile =
client.get("$GRAVATAR_URL$hash$GRAVATAR_RESPONSE_FORMAT") {
header(X_APP_NAME, APP_NAME)
}.body()
With this, only this request contains the X-APP_NAME header.
To validate your implementation, compile the project again, and with the HttpClientLogger filter, search for this particular request.
Uploading files
With Multiplatform in mind, uploading a file can be quite challenging because each platform deals with them differently. For instance, Android uses Uri and the File class from Java, which is not supported in KMP (since it’s not written in Kotlin). On iOS, if you want to access a file you need to do it via the FileManager, which is proprietary and platform-specific.
The solution is to find a common ground — in this case at a lower level. Their implementations generate a ByteArray that can be accessed and processed at the shared module.
Start by creating a data class that’s going to represent an image. Go to commonMain and inside platform create a PlatformMediaFile.kt file:
public expect class MediaFile
public expect fun MediaFile.toByteArray(): ByteArray
Here, you’re defining the class and function that’s you’ll use to represent a file. At the platform level, the MediaFile class and the corresponding toByteArray function will be defined.
With this in mind, navigate to androidMain. Inside platform, create the corresponding actual file — PlatformMediaFile.kt:
public actual typealias MediaFile = MediaUri
public actual fun MediaFile.toByteArray(): ByteArray = contentResolver.openInputStream(uri)?.use {
it.readBytes()
} ?: throw IllegalStateException("Couldn't open inputStream $uri")
Here, you’re defining the reference of MediaFile as MediaUri. Every time MediaFile is accessed, the properties and functions that will be called are the ones from MediaUri. This class doesn’t yet exist. You’ll need to create it, because in order to get the ByteArray from a file, Android needs to access the openInputStream from contentResolver that only exists in the activity context.
Inside data/model, create a MediaUri.kt file and add:
public data class MediaUri(public val uri: Uri, public val contentResolver: ContentResolver)
This contentResolver property is the one that’s accessed in toByteArray, from which you can openInputStream.
Once done, it’s now time to define the iOS implementation. Create the PlatformMediaFile.kt in the platform package inside the iosMain folder and add:
public actual typealias MediaFile = UIImage
public actual fun MediaFile.toByteArray(): ByteArray {
return UIImageJPEGRepresentation(this, compressionQuality = 1.0)?.toByteArray() ?: emptyArray<Byte>().toByteArray()
}
In this case, MediaFile is represented as a UIImage. The ByteArray required for the upload is retrieved from the call to UIImageJPEGRepresentation.
With these implementations, you can now access the file’s content and upload it. Although it’s beyond the scope of this chapter, it’s worth showing you an example of how it can be made at Ktor level.
Imagine that you selected an image to upload. Assuming your server supports multipart requests, you could write a similar function:
//1
public suspend fun uploadAvatar(data: MediaFile): HttpResponse {
//2
return client.post(UPLOAD_AVATAR_URL) {
//3
body = MultiPartFormDataContent(
formData {
appendInput("filedata", Headers.build {
//4
append(HttpHeaders.ContentType, "application/octet-stream")
}) {
//5
buildPacket { writeFully(data.toByteArray()) }
}
})
}
}
Here’s what’s happening:
- You need to receive the
MediaFilethat contains a reference to your image. The important part of this object is thetoByteArrayfunction that’s used on 5. - The
clientin this example is the same that you’ve been using until now. There’s no need to install additional plugins or set any configuration. - In this case, the file will be sent through a multipart request, so the
bodyof the request needs to contain this information. - Most servers require that the request contains the content type of the file — in this case,
application/octet-stream. - Depending on the total size of the file, more than one part might need to be sent. Although the result is always an array of bytes, depending on the platform that your app is running,
toByteArraywill call different functions.
Note: Depending on the file type you want to send and the server requirements, you may need to implement a different method. For more information, read the official documentation from Ktor.
Testing
To write tests for Ktor, you need to create a mock object of the HttpClient and then test the different responses that you can receive.
Before writing, you need to open the build.gradle.kts file from shared and include the following in commonTest:
implementation(kotlin("test-junit"))
implementation("junit:junit:4.13.2")
implementation("io.ktor:ktor-client-mock:2.0.0-beta-1")
Wait for the project to synchronize.
After it finishes, open commonTest and inside shared, create a NetworkTests class. All your network tests will be here.
Before creating the tests, you need to mock some objects. After the class declaration, add:
private val profile = GravatarProfile(
entry = listOf(
GravatarEntry(
id = "1000",
hash = "1000",
preferredUsername = "Ray Wenderlich",
thumbnailUrl = "https://avatars.githubusercontent.com/u/4722515?s=200&v=4"
)
)
)
This will be the GravatarProfile that you’re expecting to receive on mocked network calls.
Now, you’ll need to mock the HttpClient. Add it below the code you just pasted:
private val nonStrictJson = Json { isLenient = true; ignoreUnknownKeys = true }
private fun getHttpClient(): HttpClient {
//1
return HttpClient(MockEngine) {
//2
install(ContentNegotiation) {
json(nonStrictJson)
}
engine {
addHandler { request ->
//3
if (request.url.toString().contains(GRAVATAR_URL)) {
respond(
//4
content = Json.encodeToString(profile),
//5
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()))
}
else {
//6
error("Unhandled ${request.url}")
}
}
}
}
}
Here’s a step-by-step breakdown of this logic:
- Unit tests need to be mocked since you won’t be making any network calls. The goal is to go through all the possible scenarios and validate that the app behaves accordingly. For that, you’re initializing the
HttpClientwith aMockEngine. - To create a valid test, you need to follow the same configuration that you used when defining the requests. In this case, you need to use the
ContentNegotationplugin. - This
HttpClientcan be used by different requests, so you need to be able to identify who made the request and which response should be created. - The
contentdefines the body of the response. - Defines the content-type of the response.
- Generates an error in case the request URL doesn’t match with any of the existing conditions.
Note: Sometimes Android Studio is unable to resolve
encodeToString. If this is the case, manually addimport kotlinx.serialization.encodeToString.
Finally, with the request and response defined, write the following test:
@Test
public fun testFetchMyGravatar() = runTest {
val client = getHttpClient()
assertEquals(profile, client.request
("$GRAVATAR_URL${profile.entry[0].hash}$GRAVATAR_RESPONSE_FORMAT").body())
}
The test passes if the response it receives is the same as the profile object mocked; it fails otherwise.
To run a test, right-click the class name NetworkTests, then click in “Run ‘NetworkTests’”, or with the file open, just click on the green arrows shown next to a test and choose android (:testDebugUnitTest).
Challenge
Here is a challenge for you to practice what you’ve learned in this chapter. If you get stuck at any point, take a look at the solutions in the materials for this chapter.
Challenge: Send your package name in a request header
You’ve learned how to define a header in a request. In that example, you were sending the app name as its value. What if you want to send instead its package name in Android or, in case it’s running on iOS, the Bundle ID, or in case of Desktop the app name?
Note: You should implement this logic on the shared module.
Key points
- Ktor is a set of networking libraries written in Kotlin. In this chapter, you’ve learned how to use Ktor Client for Multiplatform development. It can also be used independently in Android or desktop. There’s also Ktor Server; that’s used server-side.
- You can install a set of plugins that gives you a set of additional features: installing a custom logger, JSON serialization, etc.
Where to go from here?
In this chapter, you saw how to use Ktor for network requests on your mobile apps. Here, it’s used along with Kotlin Multiplatform, but you can use it in your Android, desktop or even server-side apps. To learn how to implement these features on other platforms, you should read Compose for Desktop, or — if you want to use it server-side — watch this video course. Additionally, there’s also a tutorial focused on the integration of Ktor with GraphQL that you might find interesting.
The next chapter is focused on concurrency — in particular, how to use coroutines in your application.
See you there.