Monday, July 18, 2022

Embed an http web server in Android

Do you want to embed an HTTP web server in Android to create Rest Api’s (GET, POST, PUT, DELETE, etc.)? This is the right place. In this article, we are going to learn how to do it. The tools that we are going to use are:

  • Kotlin
  • Ktor to create the web server
  • Koin to do dependency injection

The architecture of our project will look like this:

We divide the code into three sections:

Controller: Contains the Rest Api’s and is the entry point for the request made by the client.
Service Layer: Contains all the business logic.
Repository: Here, we read & write data to the database

Important: In this tutorial, we will not create a real database. We will store the data in a List inside the class UserRepository. The reason is that I want to keep this tutorial simple.

Dependencies

We start by adding all the dependencies we will need in this project to the build.gradle file.

 // Ktor
def ktor_version = "1.6.1"
implementation "io.ktor:ktor:$ktor_version"
implementation "io.ktor:ktor-server-netty:$ktor_version"
implementation "io.ktor:ktor-gson:$ktor_version"

// Dependency Injection
implementation "io.insert-koin:koin-ktor:3.1.2"

// Test Ktor
testImplementation "io.ktor:ktor-server-tests:$ktor_version"
testImplementation "io.mockk:mockk:1.12.0"

Models

These classes will help us to represent the objects inside our project. First, we have the User class:

data class User(
    val id: Int? = null,
    val name: String? = null,
    val age: Int? = null
)

Also, we have a class that I will call ResponseBase. This class will help us to build the JSON response for each request made by the client

data class ResponseBase<T>(
    val status: Int = 0,
    val data: T? = null,
    val message: String = "Success"
)

If you look carefully, you will notice that the class ResponseBase will create a JSON that will look like this:

{
    "message": "Success",
    "status": 999,
    "data": "La informaciĆ³n en data puede cambiar"
}

Repository

Usually, in this layer, we will read and write into the database, but in this tutorial, we will not create a database. All the data will be stored inside a List.

We start by creating an interface, UserRepository. It will help abstract the repository’s implementation and make our code easier to test.

interface UserRepository {
    fun personList(): ArrayList<User>
    fun addPerson(user: User): User
    fun removePerson(id: Int): User
}

Then we create the class UserRepositoryImp. In this class, we will implement the interface’s functions.

class UserRepositoryImp : UserRepository {
    private var idCount = 0;
    private val userList = ArrayList<User>()

    override fun userList(): ArrayList<User> = userList

    override fun addUser(user: User): User {
        val newUser = user.copy(id = ++idCount);
        userList.add(newUser)
        return newUser
    }

    override fun removeUser(id: Int): User {
        userList.find { it.id == id }?.let {
            userList.remove(it)
            return it
        }
        throw GeneralException("Cannot remove user: $id")
    }
}

Some things to notice are:

  • We have a counter idCount that will increase each time we add a user. This counter is the ID of a new user.
  • We store all the users in the list userList.
  • When we want to delete a user, and we cannot find him, we will throw a GeneralException that we will look at in detail later.

Service Layer

In this layer, we will write our web server’s business logic. In this tutorial, we will only check if the user’s name and age are correct. If they are not right, we will throw an exception.

class UserService : KoinComponent {

    private val userRepository by inject<UserRepository>()

    fun userList(): List<User> = userRepository.userList()

    fun addUser(user: User): User {
        if (user.name == null)
            throw MissingParamsException("name")
        if (user.age == null)
            throw MissingParamsException("age")
        if (user.age < 0)
            throw GeneralException("Age cannot be negative number")
        return userRepository.addUser(user)
    }

    fun removeUser(id: Int): User = userRepository.removeUser(id)
}

Also, we can see that we are using dependency injection with Koin to add UserRepository, so our class must implement KoinComponent.

Controller

In this section we will write the code to create the Rest Api’s:

fun Route.userController() {
    val userService by inject<UserService>()

    get("/user") {
        call.respond(ResponseBase(data = userService.userList()))
    }

    post("/user") {
        val person = call.receive<User>()
        call.respond(ResponseBase(data = userService.addUser(person)))
    }

    delete("/user/{id}") {
        val id = call.parameters["id"]?.toInt()!! // Force just for this example
        call.respond(ResponseBase(data = userService.removeUser(id)))
    }
}

We just created three Api 's GET, POST & DELETE. This class depends on UserService, so we use Koin to inject it. As you can see, the response we send to the client is made in the function call.respond(), and this response is always of the type ResponseBase. The only thing that changes is the property ResponseBase.data.

Exception handling

Because we want to throw custom exceptions wherever we are in our code we can use the plugin StatusPages. Now everytime we throw an exception we can catch it and send an appropriate response to the client. We create a file CustomExceptions and the content will be:

val handleException: Application.() -> Unit = {
    install(StatusPages) {
        exception<CustomExceptions> {
            call.respond(ResponseBase(it.status, null, it.description))
        }
        exception<Throwable> {
            it.printStackTrace()
            call.respond(ResponseBase(9999, null, "Unknown error"))
        }
    }
}

open class CustomExceptions(val status: Int, val description: String) : Exception(description)

class MissingParamsException(param: String) : CustomExceptions(100, "Missing parameter: $param")
class GeneralException(description: String) : CustomExceptions(999, description)

We have 2 custom exceptions MissingParamsException & GeneralException both extends from CustomExceptions. Now when we throw an exception the next code will handle it and will send the appropriate response to the client:

    install(StatusPages) {
        exception<CustomExceptions> {
            call.respond(ResponseBase(it.status, null, it.description))
        }
        exception<Throwable> {
            it.printStackTrace()
            call.respond(ResponseBase(9999, null, "Unknown error"))
        }
    }

Initialize the server

We will create the server inside an Android Service and start the service every time the device boots. We create a file HttpService with the next code:

const val PORT = 8080

class HttpService : Service() {
    override fun onCreate() {
        super.onCreate()
        Thread {
            InternalLoggerFactory.setDefaultFactory(JdkLoggerFactory.INSTANCE)
            embeddedServer(Netty, PORT) {
                install(ContentNegotiation) { gson {} }
                handleException()
                install(Koin) {
                    modules(
                        module {
                            single<UserRepository> { UserRepositoryImp() }
                            single { UserService() }
                        }
                    )
                }
                install(Routing) {
                    userController()
                }
            }.start(wait = true)
        }.start()
    }

    override fun onBind(intent: Intent): IBinder? = null
}

class BootCompletedReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
            Log.d("BootCompletedReceiver", "starting service HttpService...")
            context.startService(Intent(context, HttpService::class.java))
        }
    }
}

Android Manifest.

We update the manifest to add the permissions, services & receivers. After updating the manifest will look like:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.nopalsoft.http.server">

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

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.AndroidHttpServer">
        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

        <service
            android:name=".server.HttpService"
            android:enabled="true" />

        <receiver android:name=".server.BootCompletedReceiver">
            <intent-filter>
                <action android:name="android.intent.action.BOOT_COMPLETED" />
                <action android:name="android.intent.action.QUICKBOOT_POWERON" />
            </intent-filter>
        </receiver>
    </application>

</manifest>

Final steps

If you want to start the service as soon as the app is launched. You can start the service in the MainActivity like this:

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        startService(Intent(this, HttpService::class.java))
 }

If you want to test with postman and the Android Emulator, you must forward the requests. Look at this answer in stackoverflow to get more information.

Postman

We use postman to call our Rest Api’s, and you can see the results in the following images:

Unit testing

Finally, these two classes will help you to understand how to add unit tests to the code. Use the ./gradlew clean test --info command to run the unit tests.

Class BaseModuleTest:

abstract class BaseModuleTest {

    private val gson = Gson()
    protected var koinModules: Module? = null
    protected var moduleList: Application.() -> Unit = { }

    init {
        stopKoin()
    }

    fun <R> withBaseTestApplication(test: TestApplicationEngine.() -> R) {
        withTestApplication({
            install(ContentNegotiation) { gson { } }
            handleException()
            koinModules?.let {
                install(Koin) {
                    modules(it)
                }
            }
            moduleList()
        }) {
            test()
        }
    }

    fun toJsonBody(obj: Any): String = gson.toJson(obj)

    fun <T> TestApplicationResponse.parseBody(clazz: Class<T>): ResponseBase<T> {
        val typeOfT: Type = TypeToken.getParameterized(ResponseBase::class.java, clazz).type
        return gson.fromJson(content, typeOfT)
    }

    fun <T> TestApplicationResponse.parseListBody(clazz: Class<T>): ResponseBase<List<T>> {
        val typeList = TypeToken.getParameterized(List::class.java, clazz).type
        val typeOfT: Type = TypeToken.getParameterized(ResponseBase::class.java, typeList).type
        return gson.fromJson(content, typeOfT)
    }
}

Class UserModuleTest:

class UserModuleTest : BaseModuleTest() {
    private val userRepositoryMock: UserRepository = mockk()

    init {
        koinModules = module {
            single { userRepositoryMock }
            single { UserService() }
        }
        moduleList = {
            install(Routing) {
                userController()
            }
        }
    }

    @Test
    fun `Get users return successfully`() = withBaseTestApplication {
        coEvery { userRepositoryMock.userList() } returns arrayListOf(User(1, "Yayo", 28))

        val call = handleRequest(HttpMethod.Get, "/user")

        val response = call.response.parseListBody(User::class.java)

        assertEquals(call.response.status(), HttpStatusCode.OK)
        assertEquals(response.data?.get(0)?.name, "Yayo")
        assertEquals(response.data?.get(0)?.age, 28)
    }

    @Test
    fun `Missing name parameter`() = withBaseTestApplication {
        val call = handleRequest(HttpMethod.Post, "/user") {
            addHeader(HttpHeaders.ContentType, ContentType.Application.Json.toString())
            setBody(toJsonBody(User(age = 27)))
        }
        val response = call.response.parseBody(User::class.java)

        assertEquals(call.response.status(), HttpStatusCode.OK)
        assertEquals(response.data, null)
        assertEquals(response.status, 100)
        assertEquals(response.message.contains("name"), true)
    }

    @Test
    fun `Missing age parameter`() = withBaseTestApplication {
        val call = handleRequest(HttpMethod.Post, "/user") {
            addHeader(HttpHeaders.ContentType, ContentType.Application.Json.toString())
            setBody(toJsonBody(User(name = "Yayo")))
        }
        val response = call.response.parseBody(User::class.java)

        assertEquals(call.response.status(), HttpStatusCode.OK)
        assertEquals(response.data, null)
        assertEquals(response.status, 100)
        assertEquals(response.message.contains("age"), true)
    }

    @Test
    fun `Age under zero error`() = withBaseTestApplication {
        val call = handleRequest(HttpMethod.Post, "/user") {
            addHeader(HttpHeaders.ContentType, ContentType.Application.Json.toString())
            setBody(toJsonBody(User(name = "Yayo", age = -5)))
        }
        val response = call.response.parseBody(User::class.java)

        assertEquals(call.response.status(), HttpStatusCode.OK)
        assertEquals(response.data, null)
        assertEquals(response.status, 999)
        assertEquals(response.message.contains("Age cannot be negative number"), true)
    }
}

Source code and video tutorial

Source code can be found in github and remember there is also a video tutorial of this article in youtube (in spanish):

0 comments:

Post a Comment

Entradas populares