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 a aprender las bases para crear un servidor web en Android usando Ktor.
Antes de comenzar recuerda que puedes descargar el código completo en Github
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.
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 a 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 a 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 de que va a 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 a 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 a 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 a 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 a iniciar cada vez que reinicia el dispositivo.
Para eso creamos un archivo llamado HttpService
que va a 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 a 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 tú 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)
}
}