viernes, 14 de enero de 2022

Integrar un servidor web http en Android

Quieres integrar un servidor Web Http en Android para poder realizar peticiones REST (GET, POST, PUT, DELETE, etc). Estás en el lugar correcto, en este tutorial vamos aprender las bases para crear un servidor web en Android.

Antes de comenzar recuerda que puedes descargar el código completo en Github y si lo prefieres puedes ver el videotutorial de este artículo:


Para este tutorial vamos a utilizar las siguientes herramientas:

  • Lenguaje de programación Kotlin
  • La librería Ktor para crear el servidor web
  • La librería Koin para la inyección de dependencias

En el siguiente diagrama podemos ver la arquitectura que vamos a utilizar en este proyecto:

Básicamente dividimos el código en 3 secciones:

Controller: Esta sección contiene las API 's y es el punto de entrada de las peticiones del cliente.
Service Layer: Aquí vamos a escribir toda la lógica de negocios.
Repository: Esta sección se conecta directamente con la base de datos. Aquí escribimos y leemos información de la base de datos.

Nota: En este tutorial no vamos a conectarnos a ninguna base de datos. Vamos a crear una lista en la clase UserRepository para almacenar la información en memoria.

Dependencias o librerías

Vamos a comenzar agregando todas las dependencias necesarias a nuestro archivo build.gradle

 // 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"

Modelos

Son clases que nos van ayudar a representar los objetos de nuestro programa. Primero tenemos la clase User:

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

También tenemos otra clase que voy a llamar ResponseBase que nos va ayudar a generar la estructura del JSON que vamos a responder en cada petición.

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

Si analizas la clase ResponseBase te vas a dar cuenta que va generar un JSON que se verá así:

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

Repository

Esta capa es la encargada de escribir y leer la base de datos pero para mantener este tutorial sencillo no vamos a crear ninguna base de datos, toda la información la vamos a mantener en memoria en una lista.

Primero vamos a crear una interfaz UserRepository que nos va permitir abstraer la implementación de nuestro repositorio y hacer nuestro código más fácil de testear.

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

Después creamos la clase UserRepositoryImp donde vamos a implementar las funciones de nuestra interfaz.

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")
    }
}

¿Qué es lo importante que podemos ver en esta implementación?

  • Tenemos un contador llamado idCount que incrementamos con cada usuario nuevo. Este contador es el ID de cada usuario nuevo.
  • También podemos ver que los usuarios los almacenamos en la lista userList.
  • Por último cuando queremos eliminar un usuario, si no lo encontramos dentro de la lista vamos a lanzar una excepción GeneralException que vamos a crear más adelante.

Service Layer

Aquí vamos a escribir la lógica de negocio de nuestro servidor web. Para este tutorial solo vamos a verificar que al escribir un nuevo usuario el nombre y la edad sean correctos. Si no son correctos lanzamos una excepción.

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)
}

También podemos ver que utilizamos la librería Koin para inyectar la dependencia de UserRepository, por lo que la clase debe implementar KoinComponent

Controller

Aquí vamos a crear las Rest Apis que van a ser llamadas desde el cliente:

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)))
    }
}

Podemos ver que existen 3 Rest Apis que son GET, POST y DELETE. Esta clase tiene una dependencia en UserService que inyectamos por medio de la librería Koin. Hay que notar que la respuesta que enviamos al cliente en la función call.respond() siempre es del tipo ResponseBase lo único que cambia es la propiedad ResponseBase.data.

Manejo de excepciones

Para lanzar excepciones personalizadas en cualquier parte de mi código y enviar una respuesta adecuada al cliente, vamos a utilizar el plugin StatusPages. Voy a crear un archivo CustomExceptions y va quedar así:

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)

Veamos que tenemos 2 excepciones personalizadas que son MissingParamsException y GeneralException que heredan de CustomExceptions. Podemos lanzar una excepción desde cualquier parte del código y podemos ver que el siguiente bloque de código se va encargar de enviar una respuesta adecuada al cliente:

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

Iniciar el servidor

Para iniciar el servidor web vamos a crear un Service de Android que va iniciar cada vez que reinicia el dispositivo. Para eso creamos un archivo llamada HttpService que va contener el siguiente código:

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.

También debemos agregar los permisos, servicios y receivers en el archivo AndroidManifest.xml por lo que al final se va ver así:

<?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>

Últimas modificaciones

Si quieres que el servicio corra justo después de iniciar la app puedes iniciar el servicio en tu MainActivity:

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

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

Y si quieres probar con postman y el emulador de Android recuerda que debes hacer forward de las peticiones. Mira esta respuesta en stackoverflow para más información.

Postman

Utilizamos postman para realizar peticiones a nuestro servidor y podemos ver los resultados en las siguientes imágenes:

Pruebas unitarias

Por último les dejo las 2 clases y unos ejemplos que pueden usar para crear las pruebas unitarias que pueden correr con el comando ./gradlew clean test --info

Clase 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)
    }
}

Clase 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)
    }
}
    

0 comments:

Publicar un comentario

Entradas populares