Login Android Con Retrofit
¿Te suena?
Con este artículo comenzamos a completar la app SaludMock que propuse en el artículo Tutorial Retrofit En Android: Planificación Aplicación Médica.
Así que el primer avance que realizaremos será crear el login de usuarios con Retrofit.
¿Cómo hacerlo?
Con los siguientes pasos generales que te propongo concretar:
- Crear Proyecto SaludMock En Android Studio
- Configurar Retrofit En Android Studio
- Crear Screen De Login
- Testear Aplicación SaludMock Con Datos Falsos
- Crear Base De Datos De SaludMock
- Crear Servicio Web RESTful
- Realizar Petición POST Con Retrofit
¿Qué te parece?
Si estás listo y te interesa, entonces sigue leyendo…
Retrofit: Realizar Peticiones HTTP En Android
En el artículo pasado habíamos dicho que Retrofit simplifica la creación de un cliente HTTP que facilita al programador seguir el estilo REST.
Dado que es una librería, debemos comprender que clases, métodos y componentes serán los que usaremos para realizar una petición POST y loguear al afiliado.
Teniendo en cuenta esto, el siguiente es un resumen de pasos o especie de receta para usar Retrofit:
- Agrega las dependencias Gradle para Retrofit y su convertidor de formato.
- Agrega los permisos para usar la red y saber su estado en el AndroidManifest.xml
- Define los objetos planos Java (POJOs) que representan el cuerpo de la petición y respuesta asociados a la llamada HTTP.
- Crea una interfaz que represente al servicio REST, donde estén todas las peticiones a realizar sobre él.
- Crea una instancia de un adaptador HTTP que proporciona la librería a través de la clase Retrofit.
- A través de tu adaptador crea una instancia concreta de la interfaz añadida previamente.
- Invoca los métodos que definiste en la interfaz y ponlos a correr de forma asíncrona. Puedes tomar el resultado de la petición con la interfaz
Callback
.
Ahora, Retrofit usa un sistema de anotaciones para promover el comportamiento de las peticiones.
Las anotaciones más populares que tendremos serán las asociadas a los 4 métodos del CRUD:
@GET
: Realiza una petición GET@POST
: Realiza una petición POST@PUT
: Realiza una petición PUT@DELETE
: Realiza un petición DELETE
Y si deseas personalizar más componentes de la petición tendrás:
@Headers
: Añade las cabeceras que proporciones@Body
: Puedes usarlo para enviar el cuerpo de una petición POST/PUT que será serializado por el convertidor dictaminado@FormUrlEncoded
: Determina que una petición será enviada con parámetros en su URL. Cada parámetro lo declaras con@Field
@Path
: Reemplaza un segmento de URL marcado con un identificador
No te preocupes si no comprendes por el momento. Una vez realices este tutorial te será más claro.
Descargar Proyecto Del Código Final
Si tienes dudas sobre como lucirá y funcionará la app final de este tuto, te dejo el siguiente video ilustrativo:
Ahora, si deseas desbloquear el link de descargar para obtener los códigos completos, sigue estas instrucciones:
[sociallocker id=»7121″][/sociallocker]
Bien…
…comencemos con el desarrollo.
Crear Proyecto SaludMock En Android Studio
Primero lo primero.
Abre Android Studio y selecciona la opción Start a new Android Studio project para crear el nuevo proyecto:
A continuación, configura el proyecto con los siguientes datos:
Si te parece bien, desde ahora guarda los proyectos de mi web en D:android-herpoblog
para que mantengamos un orden.
El siguiente paso es elegir la versión mínima de soporte de la app. Déjalo por defecto en 11.
Cuando te pidan añadir una actividad inicial al proyecto, selecciona el tipo Basic Activity:
Lo siguiente es personalizar la actividad inicial que se agregará como principal.
Esta no será la del login, si no la de citas médicas.
Así nombra a la actividad AppointmentsActivity
para obtener este resultado:
Presiona Finish y tendrás el nuevo proyecto creado.
Configurar Retrofit En Android Studio
La integración de Retrofit en tu proyecto Android Studio requiere añadir la siguiente dependencia en tu archivo app/build.gradle:
dependencies { // Retrofit compile 'com.squareup.retrofit2:retrofit:2.1.0' }
Seguidamente debes elegir un convertidor para los formatos de intercambio que provengan del servicio web.
Debido a que nosotros usaremos la librería Gson para convertir las respuestas JSON en POJOs Java, entonces agregaremos estas dos dependencias:
dependencies { // Retrofit compile 'com.google.code.gson:gson:2.6.2' compile 'com.squareup.retrofit2:retrofit:2.1.0' compile 'com.squareup.retrofit2:converter-gson:2.1.0' }
Retrofit se integra internamente con el convertidor para retornar en objetos planos sin que tú medies en ello.
Teniendo en cuenta esto, la pregunta es:
¿Retrofit soporta más convertidores?
¡Si!
La siguiente tabla muestra el convertidor junto a su dependencia:
Convertidor | Dependencia |
---|---|
Gson | com.squareup.retrofit2:converter-gson:2.1.0 |
Jackson | com.squareup.retrofit2:converter-jackson:2.1.0 |
Moshi | com.squareup.retrofit2:converter-moshi:2.1.0 |
Protobuf | com.squareup.retrofit2:converter-protobuf:2.1.0 |
Wire | com.squareup.retrofit2:converter-wire:2.1.0 |
Simple XML | com.squareup.retrofit2:converter-simplexml:2.1.0 |
Perfecto.
Ya determinadas las dependencias necesarias para usar Retrofit, entonces ve a Tools > Android > Sync Project with Gradle Files para sincronizar la construcción.
Actualizar Permisos Del AndroidManifest.xml
Recuerda que realizaremos peticiones HTTP, lo que requiere añadir los permisos correspondientes al manifesto:
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
Elegir Paleta De Colores De La App
Usaremos azul para los colores primarios y verde para los acentos:
colors.xml
<?xml version="1.0" encoding="utf-8"?> <resources> <color name="colorPrimary">#7FD3FA</color> <color name="colorPrimaryDark">#7FD3FA</color> <color name="colorAccent">#98D743</color> </resources>
Crear Screen De Login
A continuación veremos cómo representar el boceto del Login que realizamos en un layout.
Además generaremos la lógica para realizar un login con datos ficticios que nos permita pasar a la creación del servicio web antes de realizar peticiones HTTP con Retrofit.
Crear Actividad De Login
Android Studio trae consigo una plantilla para actividades llamada Login Activity, la cual incorpora un diseño tradicional de formulario de login más la lógica de validación de credenciales asociadas al evento de un botón.
Una plantilla que nos ahorrará mucho trabajo, ¿no lo crees?
Con esto en mente, sitúate en el paquete Java de tu proyecto y ve a File > New > Activity > Login Activity:
Cuando te salga el asistente de configuración, deja como sigue las opciones:
Presiona Finish y tendrás la clase Java junto al layout prefabricado.
Modificar Layout Del Formulario
El diseño creado tiene varios elementos que usaremos para el formulario.
Sin embargo, este no trae consigo un espacio para el logo o incluso una variación razonable para landscape.
Si observas la jerarquía de elementos actual verás lo siguiente:
El padre LinearLayout
raíz tiene dos hijos directos: un indicador de progreso y el formulario.
Si queremos añadir el logo, entonces podemos insertar como segundo hijo un ImageView
centrado horizontalmente. Al mismo tiempo usa el icono launcher para probar la ubicación de esta:
<ImageView android:id="@+id/image_logo" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="16dp" android:layout_marginTop="8dp" app:srcCompat="@mipmap/ic_launcher" />
Adicionalmente debes cambiar el campo de texto para el número de identificación para que acepte solo valores numéricos.
Este tiene una restricción de 10 caracteres, por lo que puedes dejarlo así:
<EditText android:id="@+id/user_id" android:layout_width="match_parent" android:layout_height="wrap_content" android:hint="@string/prompt_user_id" android:inputType="number" android:maxLength="10" android:maxLines="1" android:textColor="@android:color/white" android:textColorHint="@android:color/white" />
El código final sería:
activity_login.xml
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:gravity="center_horizontal" android:orientation="vertical" android:paddingBottom="@dimen/activity_vertical_margin" android:paddingLeft="@dimen/activity_horizontal_margin" android:paddingRight="@dimen/activity_horizontal_margin" android:paddingTop="@dimen/activity_vertical_margin" tools:context="com.hermosaprogramacion.blog.saludmock.ui.LoginActivity"> <!-- Login progress --> <ProgressBar android:id="@+id/login_progress" style="?android:attr/progressBarStyleLarge" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="8dp" android:theme="@style/WhiteProgress" android:visibility="gone" /> <ImageView android:id="@+id/image_logo" android:layout_width="200dp" android:layout_height="wrap_content" android:layout_marginBottom="16dp" android:layout_marginTop="8dp" app:srcCompat="@drawable/saludmock_logo" /> <ScrollView android:id="@+id/login_form" android:layout_width="match_parent" android:layout_height="match_parent"> <LinearLayout android:id="@+id/email_login_form" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical"> <android.support.design.widget.TextInputLayout android:id="@+id/float_label_user_id" android:layout_width="match_parent" android:layout_height="wrap_content" android:theme="@style/LoginTextField"> <EditText android:id="@+id/user_id" android:layout_width="match_parent" android:layout_height="wrap_content" android:hint="@string/prompt_user_id" android:inputType="number" android:maxLength="10" android:maxLines="1" android:textColor="@android:color/white" android:textColorHint="@android:color/white" /> </android.support.design.widget.TextInputLayout> <android.support.design.widget.TextInputLayout android:id="@+id/float_label_password" android:layout_width="match_parent" android:layout_height="wrap_content" android:theme="@style/LoginTextField" app:passwordToggleEnabled="true"> <EditText android:id="@+id/password" android:layout_width="match_parent" android:layout_height="wrap_content" android:hint="@string/prompt_password" android:imeActionId="@+id/login" android:imeActionLabel="@string/action_sign_in_short" android:imeOptions="actionUnspecified" android:inputType="textPassword" android:maxLines="1" android:textColor="@android:color/white" android:textColorHint="@android:color/white" /> </android.support.design.widget.TextInputLayout> <Button android:id="@+id/email_sign_in_button" style="?android:textAppearanceSmall" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="@string/action_sign_in" android:textColor="@color/colorPrimary" android:textStyle="bold" app:backgroundTint="@android:color/white" /> </LinearLayout> </ScrollView> </LinearLayout>
Crear Variación Landscape Del Login
Ahora crearemos la variación horizontal del layout.
Para ello, ve a la pestaña Preview del layout, selecciona el icono del teléfono en la parte superior derecha y presiona Create Landscape Variation:
El resultado será la creación de un layout con el mismo contenido de login_activity.xml que será ejecutado cuando el dispositivo rote.
Algo más que añadir:
- Necesitamos cambiar el atributo
android:orientation
del padre al valor horizontal - Usaremos el atributo
android:layout_weight
con el valor de1
en el logo y el formulario para tener una división equitativa - Centraremos los contenidos de ambos hijos asignando el valor
center
al atributoandroid:layout_gravity
Aplicando los cambios tendrás esta definición XML:
res/layout-land/activity_login.xml
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:gravity="center_horizontal" android:orientation="horizontal" android:paddingBottom="@dimen/activity_vertical_margin" android:paddingLeft="@dimen/activity_horizontal_margin" android:paddingRight="@dimen/activity_horizontal_margin" android:paddingTop="@dimen/activity_vertical_margin"> <!-- Login progress --> <ProgressBar android:id="@+id/login_progress" style="?android:attr/progressBarStyleLarge" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="8dp" android:theme="@style/WhiteProgress" android:visibility="gone" /> <ImageView android:id="@+id/image_logo" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center" android:layout_marginBottom="16dp" android:layout_marginTop="8dp" android:layout_weight="1" android:padding="16dp" app:srcCompat="@drawable/saludmock_logo" /> <ScrollView android:id="@+id/login_form" android:layout_width="match_parent" android:layout_height="match_parent" android:layout_gravity="center" android:layout_weight="1"> <LinearLayout android:id="@+id/email_login_form" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:orientation="vertical"> <android.support.design.widget.TextInputLayout android:id="@+id/float_label_user_id" android:layout_width="match_parent" android:layout_height="wrap_content" android:theme="@style/LoginTextField"> <EditText android:id="@+id/user_id" android:layout_width="match_parent" android:layout_height="wrap_content" android:hint="@string/prompt_user_id" android:inputType="number" android:maxLength="10" android:maxLines="1" android:textColor="@android:color/white" android:textColorHint="@android:color/white" /> </android.support.design.widget.TextInputLayout> <android.support.design.widget.TextInputLayout android:id="@+id/float_label_password" android:layout_width="match_parent" android:layout_height="wrap_content" android:theme="@style/LoginTextField" app:passwordToggleEnabled="true"> <EditText android:id="@+id/password" android:layout_width="match_parent" android:layout_height="wrap_content" android:hint="@string/prompt_password" android:imeActionId="@+id/login" android:imeActionLabel="@string/action_sign_in_short" android:imeOptions="actionUnspecified" android:inputType="textPassword" android:maxLines="1" android:textColor="@android:color/white" android:textColorHint="@android:color/white" /> </android.support.design.widget.TextInputLayout> <Button android:id="@+id/email_sign_in_button" style="?android:textAppearanceSmall" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="@string/action_sign_in" android:textColor="@color/colorPrimary" android:textStyle="bold" app:backgroundTint="@android:color/white" /> </LinearLayout> </ScrollView> </LinearLayout>
El resultado de la previa sería así:
Modificar Strings Del Login
Lo siguiente es cambiar los strings que creó Android Studio automáticamente para la actividad de login.
Si abres strings.xml verás que todas están en inglés.
Así que adapta cada valor al español según tus preferencias.
En mi caso sería de esta forma:
<resources> <string name="app_name">SaludMock</string> <string name="action_settings">Settings</string> <string name="title_activity_login">Inicio de sesión</string> <!-- Strings related to login --> <string name="prompt_user_id">Número de identificación</string> <string name="prompt_password">Contraseña</string> <string name="action_sign_in">Iniciar sesión</string> <string name="action_sign_in_short">Iniciar sesión</string> <string name="error_invalid_user_id">Número de identificación inválido</string> <string name="error_incorrect_password">La contraseña es incorrecta</string> <string name="error_field_required">Este campo es requerido</string> <string name="error_server">Error en el servidor</string> <string name="error_incorrect_user_id">Número de identificación no registrado</string> <string name="error_network">Conexión de red no disponible</string> <string name="error_invalid_password">Contraseña inválida</string> </resources>
Establecer Condición De Transición Login – Citas Médicas
Mira:
Si ejecutases el proyecto en este momento, la actividad que se vería será la de citas médicas a causa de la etiqueta android.intent.category.LAUNCHER
.
Para exigir que se ejecute el login iremos al método onCreate()
de AppointmentsActivity
y agregaremos una condición mucho antes de que se infle todo el contenido de esta.
Por el momento no tenemos una expresión booleana consistente para hacerlo suceder, así que pondrás un if
con el literal true
sin más:
@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); // Redirección al Login if (true) { startActivity(new Intent(this, LoginActivity.class)); finish(); return; }
De esta forma serás redirigido a LoginActivity
al ejecutar la aplicación.
Implementar Lógica Del Login
La forma en que pensamos realizar el login se basa en estos pasos:
Usuario | Sistema |
---|---|
1. Ingresa su ID y contraseña (la contraseña debe estar oculta) | |
2. Envía los datos | |
3. Determina que el usuario tiene acceso comprobando sus credenciales | |
4. Inicia la sesión | |
5. Presenta la pantalla de citas médicas |
Este es el camino feliz de nuestro caso de uso.
Para implementarlo primero debemos modificar la plantilla generada de LoginActivity
.
Si observas su estructura, verás que existen varios métodos y miembros útiles:
No obstante, hay varias características que no necesitamos relacionadas a la creación de sugerencias basadas en los contactos del dispositivo.
Esos elementos puedes borrarlos.
En cuestión, los únicos comportamientos que nos ayudarán son:
attemptLogin()
isEmailValid()
isPasswordValid()
showProgress()
Veamos como reconstruir el flujo…
Definir miembros de LoginActivity
En primer lugar añadiremos un usuario y contraseña de pruebas que permitirán saber si nuestro algoritmo sirve.
Esto requiere que elimines el arreglo DUMMY_CREDENTIALS
:
/** * Credenciales de pruebas * TODO: remuévelas cuando vayas a implementar una autenticación real. */ private static final String DUMMY_USER_ID = "0000000000"; private static final String DUMMY_PASSWORD = "dummy_password";
Existe una tarea asíncrona interna que simula el proceso de autenticación en un hilo separado llamada UserLoginTask
.
Por el momento la dejaremos, lo que significa que su instancia mAuthTask
vivirá:
/** * Keep track of the login task to ensure we can cancel it if requested. */ private UserLoginTask mAuthTask = null;
Lo siguiente es definir las referencias a los views que tendrán comportamientos de interfaz.
Ya sabemos que tenemos al logo, los campos de texto para el ID y contraseña, la barra de progreso y el formulario como tal:
// UI references. private ImageView mLogoView; private EditText mUserIdView; private EditText mPasswordView; private TextInputLayout mFloatLabelUserId; private TextInputLayout mFloatLabelPassword; private View mProgressView; private View mLoginFormView;
Definir el método onCreate()
En onCreate()
primero obtén las referencias de todos los views que declaramos como instancias:
@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_login); mLogoView = (ImageView) findViewById(R.id.image_logo); mUserIdView = (EditText) findViewById(R.id.user_id); mPasswordView = (EditText) findViewById(R.id.password); mFloatLabelUserId = (TextInputLayout) findViewById(R.id.float_label_user_id); mFloatLabelPassword = (TextInputLayout) findViewById(R.id.float_label_password); Button mSignInButton = (Button) findViewById(R.id.email_sign_in_button); mLoginFormView = findViewById(R.id.login_form); mProgressView = findViewById(R.id.login_progress); }
Ahora estableceremos las escuchas para el evento de edición en el campo de texto del password y el click en el botón de login:
// Setup mPasswordView.setOnEditorActionListener(new TextView.OnEditorActionListener() { @Override public boolean onEditorAction(TextView textView, int id, KeyEvent keyEvent) { if (id == R.id.login || id == EditorInfo.IME_NULL) { attemptLogin(); return true; } return false; } }); mSignInButton.setOnClickListener(new OnClickListener() { @Override public void onClick(View view) { attemptLogin(); } });
Los métodos populateAutoComplete()
y mayRequestContacts()
no lo necesitaremos, así que borralos.
Los métodos isEmailValid() e isPasswordValid()
La verificación de los valores de correo y contraseña que el usuario digita se realizan con estos métodos.
Pero no usaremos email si no un ID numérico.
Así que refactoriza isEmailValid()
para que se llame isUserIdValid()
. Y verifica que su tamaño sea exactamente 10.
Mira:
private boolean isUserIdValid(String userId) { return userId.length() == 10; }
En el password dejaremos la regla que trae la plantilla predeterminada: que su tamaño no sea inferior o igual a 4.
private boolean isPasswordValid(String password) { return password.length() > 4; }
Definir método attemptLogin()
Este método tal cual como está es muy bueno.
Ya que:
- Comprueba que no se hayan enviados datos aún (
mAuthTask
debe sernull
) - Restablece errores por si las anteriores credenciales los dejaron (
setError(null)
) - Obtiene los datos del usuario como variables
String
(userId
ypassword
) - Transmite el foco al primer campo de texto con error (
requestFocus()
) - Valida si los campos de texto no están vacíos (
TextUtils.isEmpty()
) - Asigna los errores flotantes a los campos de texto (
setError()
con recursos string) - Si todo sale bien, entonces muestra el progreso (
showProgress()
) y luego crea una tarea asíncrona con las credenciales validadas.
Si revisas el código, y tan solo cambias la asignación de errores a las etiquetas flotantes, tendrás:
private void attemptLogin() { if (mAuthTask != null) { return; } // Reset errors. mFloatLabelUserId.setError(null); mFloatLabelPassword.setError(null); // Store values at the time of the login attempt. String userId = mUserIdView.getText().toString(); String password = mPasswordView.getText().toString(); boolean cancel = false; View focusView = null; // Check for a valid password, if the user entered one. if (TextUtils.isEmpty(password)) { mFloatLabelPassword.setError(getString(R.string.error_field_required)); focusView = mFloatLabelPassword; cancel = true; } else if (!isPasswordValid(password)) { mFloatLabelPassword.setError(getString(R.string.error_invalid_password)); focusView = mFloatLabelPassword; cancel = true; } // Verificar si el ID tiene contenido. if (TextUtils.isEmpty(userId)) { mFloatLabelUserId.setError(getString(R.string.error_field_required)); focusView = mFloatLabelUserId; cancel = true; } else if (!isUserIdValid(userId)) { mFloatLabelUserId.setError(getString(R.string.error_invalid_user_id)); focusView = mFloatLabelUserId; cancel = true; } if (cancel) { // There was an error; don't attempt login and focus the first // form field with an error. focusView.requestFocus(); } else { // Show a progress spinner, and kick off a background task to // perform the user login attempt. showProgress(true); mAuthTask = new UserLoginTask(userId, password); mAuthTask.execute((Void) null); } }
Mostrar indicador de carga
Ya sabemos que showProgress()
es el encargado de mostrar/ocultar la ProgressBar
en el layout.
El código creado por la plantilla tiene la lógica correcta. Muestra la barra de progreso (View.VISIBLE
) y oculta el formulario (View.GONE
).
Sin embargo nosotros necesitamos ocultar el logo también, por lo que podemos reducirlo a lo siguiente:
private void showProgress(boolean show) { mProgressView.setVisibility(show ? View.VISIBLE : View.GONE); int visibility = show ? View.GONE : View.VISIBLE; mLogoView.setVisibility(visibility); mLoginFormView.setVisibility(visibility); }
Usar tarea asíncrona para simulación
UserLoginTask
simula a través de una espera de n milisegundos la petición al servidor web.
Sin embargo cambiaremos el tercer parámetro por el tipo Integer
, ya que reportaremos un error para el ID y otro para la contraseña:
public class UserLoginTask extends AsyncTask<Void, Void, Integer> {
Su constructor y miembros los dejaremos intactos:
private final String mUserId; private final String mPassword; UserLoginTask(String userId, String password) { mUserId = userId; mPassword = password; }
El método doInBackground()
comienza de forma correcta. Se adormece el hilo 2000 milisegundos para simular la petición web.
Sin embargo cambiaremos la comprobación de credenciales basados en los datos de prueba:
@Override protected Integer doInBackground(Void... params) { try { // Simulate network access. Thread.sleep(2000); } catch (InterruptedException e) { return 4; } if (!mUserId.equals(DUMMY_USER_ID)) { return 2; } if (!mPassword.equals(DUMMY_PASSWORD)) { return 3; } return 1; }
Los errores son un diseño rápido cuyo significado es:
- Petición exitosa
- Número de identificación no registrado
- Password incorrecto
- Error del servidor
Con esta idea en mente, procesamos los resultados en onPostExecute()
de la siguiente forma:
- Dirigimos al usuario a la actividad de citas médicas (
showAppointmentsScreen()
) - Se asigna error al campo de texto del id (
mFloatLabelUserId
) - Se asigna error al campo de texto del password (
mFloatLabelPassword
) - Se muestra un
Toast
con el error de servidor (showLoginError()
)
En código Java tendremos:
@Override protected void onPostExecute(final Integer success) { mAuthTask = null; showProgress(false); switch (success) { case 1: showAppointmentsScreen(); break; case 2: case 3: showLoginError("Número de identificación o contraseña inválidos"); break; case 4: showLoginError(getString(R.string.error_server)); break; } }
Donde showAppointmentsScreen()
y showLoginError()
son métodos de LoginActivity
con la siguiente definición:
private void showAppointmentsScreen() { startActivity(new Intent(this, AppointmentsActivity.class)); finish(); } private void showLoginError(String error) { Toast.makeText(this, error, Toast.LENGTH_LONG).show(); }
Verificar conexión de red
Debo agregar que verificar la disponibilidad de la red es vital para realizar el login, por lo que añadiremos un método llamado isOnline()
con el siguiente contenido:
private boolean isOnline() { ConnectivityManager cm = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE); NetworkInfo activeNetwork = cm.getActiveNetworkInfo(); return activeNetwork != null && activeNetwork.isConnected(); }
Y luego anteponer su resultado antes de llamar a attemptLogin()
:
// Setup mPasswordView.setOnEditorActionListener(new TextView.OnEditorActionListener() { @Override public boolean onEditorAction(TextView textView, int id, KeyEvent keyEvent) { if (id == R.id.login || id == EditorInfo.IME_NULL) { if (!isOnline()) { showLoginError(getString(R.string.error_network)); return false; } attemptLogin(); return true; } return false; } }); mSignInButton.setOnClickListener(new OnClickListener() { @Override public void onClick(View view) { if (!isOnline()) { showLoginError(getString(R.string.error_network)); return; } attemptLogin(); } });
Limpiar actividad
Hasta el momento tendríamos todo lo necesario para simular la autenticación de nuestros usuarios, no obstante aún hay algunos métodos que no usaremos añadidos por Android Studio.
Así que elimina todo aquello que no tenga nada que ver con lo que haremos.
Testear Aplicación SaludMock Con Datos Falsos
Por último ejecuta la aplicación (Run) y contempla el resultado final de la interfaz:
La primer prueba será dejar el ID del usuario y la contraseña vacíos para ver si se trata la restricción.
Prueba los formatos de ambas credenciales:
También prueba enviar los datos sin conexión:
Pon los datos con formato ideal, pero sin registro existente:
Finalmente pon las credenciales de prueba correctamente y verifica que proceda a la pantalla de citas médicas.
Crear Base De Datos Remota De SaludMock
Bien, nuestro próximo paso es crear la base de datos que soporte la app.
Para ello usaremos el gestor MySQL.
Ya sabes que puedes conseguir esta herramienta instalando el paquete XAMPP.
De esta manera podrás seguir los siguientes pasos…
Definir Modelo De Datos Para El Login
Si tomamos como referencia el escenario actual solo tendremos una entidad relacionada: Los afiliados a la entidad promotora de salud.
Si deseáramos darle usuarios para que accedan a la app, entonces sería buena idea tener una entidad para los mismos.
Sin embargo un afiliado solo puede tener un usuario con el que puede acceder.
Lo que produce una relación 1:1.
Desde este punto de vista podríamos tan solo dotar a los afiliados de atributos que les permitan loguearse.
Así que simplificando en un DER tendríamos la siguiente tabla:
El problema en estas instancias no exige más… a menos que tú tengas otras restricciones en tu negocio.
Implementar Base De Datos En MySQL
Debido a que vamos a realizar las pruebas locamente, entonces entra a phpMyAdmin con la ruta localhost/phpmyadmin.
Ahora crea una nueva base de datos presionando el botón Nueva o New en el panel izquierdo. Asígnale como nombre "salud_mock"
y presiona el botón de Crear.
En seguida, selecciona la base de datos y ve a la pestaña SQL.
Allí pon la siguiente sentencia CREATE TABLE
para añadir la tabla affiliate
:
CREATE TABLE affiliate ( id VARCHAR(10) PRIMARY KEY NOT NULL, hash_password VARCHAR(256) NOT NULL, name VARCHAR(128) NOT NULL, address VARCHAR(128) NOT NULL, gender ENUM('F', 'M') NOT NULL, token VARCHAR(255) NOT NULL );
Con eso ya tendrás parcialmente la base de datos para SaludMock.
Crear Servicio Web RESTful
En este punto usaremos PHP 5.6 para procesar las peticiones HTTP, enrutar los parámetros hacia los recursos definidos y retornar una respuesta JSON.
Ahora bien: nos basaremos en el login que creamos en el artículo Servicio Web RESTful Para Android Con Php, Mysql y Json.
Dentro de este contexto adaptaremos el mismo enrutamiento y diseño. Por ende, sería bueno que lo leyeras para no perderte.
Comencemos por definir la estructura de paquetes…
Definir Estructura Del Proyecto PHP
Crea un directorio llamado api.saludmock.com
para el servicio RESTful dentro de xampphtdocs
.
Esta será la raíz donde albergaremos todos los archivos vitales.
Para ser más exactos la estructura general de directorios se verá de esta forma:
El propósito de cada archivo y directorio es el siguiente:
Archivo/Directorio | Descripción |
v1 |
Señala que en su interior estará la versión 1 de la API |
controllers |
Contiene los controladores para cada recurso |
data |
Contiene los componentes relacionados con la base de datos local |
utils |
Guarda elementos de utilidad y relación indirecta |
views |
Contiene las clases encargadas de presentar las respuestas de la API |
.htaccess |
Archivo de configuración Apache para sobrescribir las URLs |
index.php |
Punto de entrada principal de las peticiones HTTP para generación de enrutamiento |
Crear Archivo .htaccess
Para tener URLs limpias y sin extensiones crea un archivo .htaccess
en v1
y agrega las siguientes reglas:
RewriteEngine on RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule ^(.*)$ index.php?PATH_INFO=$1 [L,QSA] RewriteRule .* - [env=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
Diseñar URIs Para Login
¿Cómo serán las URIs para crear la sesión de un afiliado?
Sencillo: podemos usar el plural de afiliados en inglés (affiliates
) para establecer el recurso y luego añadir un segmento login
.
Usaremos el método POST
para determinar la creación de una sesión de usuario. Lo que en resumidas será:
POST |
http://localhost/blog/v1/api.saludmock.com/affiliates/login |
Crea una nueva sesión del afiliado |
Dicha funcionalidad estará incompleta, si no establecemos una ubicación para guardar los afiliados que deseamos autenticar.
Por esta razón usaremos la palabra register
para establecerlo como punto de llegada de los registros. Al igual que el login, este será una creación, por ende usamos POST:
POST |
http://localhost/blog/v1/api.saludmock.com/affiliates/register |
Guarda un nuevo afiliado |
Diseñar Cuerpo De Peticiones Y Respuestas
La petición que apunte al endpoint del login en su expresión más simple debe enviar las credenciales del usuario.
En este tutorial no seguiremos un método de autenticación como Basic Auth, Digest u OAuth. Pero sería excelente que aprendas a usar uno de estos elementos para añadir complejidad a la seguridad de tu API.
Y además adquirir un certificado SSL para no pasar solo texto plano en tu petición.
Mira:
Enviaremos las credenciales en un objeto JSON en el cuerpo de la petición.
Veamos un ejemplo:
{ "userId":"000000000", "password":"dummy_password" }
De otro lado, la respuesta que recibiremos estará compuesta de los siguientes atributos:
(Me basé en estos tips de diseño de Stormpath)
status
: Contiene el error HTTP de forma redundante para evitar el análisis de la respuesta completacode
: Código interno de error para la APImessage
: Mensaje de error corto para el usuario junto a una posible soluciónmoreInfo
: Aquí pones una URL para apuntar a un recurso que muestre más información del error ocurrido.developerMessage
: Es el mensaje en detalle sobre el error ocurrido con datos que puedan ser de interés para el desarrollador. Nos servirá bastante para el modo debug de Android (Log.d()
)
Por ejemplo, si el número de identificación no estuviera registrado tendríamos:
{ "status":401, "code":1001, "message":"El número de identificación no está registrado", "moreInfo":"http://localhost", "developerMessage":"No existe un registro en la tabla "affiliate" cuya columna "id" coincida" }
En caso de que la autorización sea exitosa, entonces tendríamos:
{ "id":9993230213, "name":"Carlos Lopez", "address":"Cra 34 #24-20", "gender":"M", "token":"$2y$10$KjIJ3BNQL.Z9CGGl1P1vBO.dMRMtKUun21k4oGtHL5.eUVnTh.W/C" }
Todos los datos del afiliado junto al token nos permitirán inflar la interfaz y mantener la sesión abierta.
Peticiones y respuestas del registro de usuarios
La petición JSON de un nuevo usuario tendrá todos sus datos excepto el token, ya que lo generaremos internamente:
{ "id":9993230213, "password":"ghyUlV", "name":"Carlos Lopez", "address":"Cra 34 #24-20", "gender":"M" }
Si todo salió bien, entonces tendríamos un estado 201
con el siguiente cuerpo:
{ "status" : "201" "message":"Afiliado registrado" }
Elegir Formatos De Texto Para La API
El formato más popular es JSON, sin embargo también podemos dar soporte a XML como añadidura.
Para que el cliente pueda especificarlo en la petición, daremos la posibilidad de enviar un parámetro llamado format
en la URL.
Por ejemplo:
http://localhost/blog/api.saludmock.com/v1/affiliates/login?format=xml
ó
http://localhost/blog/api.saludmock.com/v1/affiliates/register?format=json
El formato por defecto siempre será JSON, por lo que si deseas ese formato puedes omitir pegar el parámetro format
.
Enrutamiento De Recursos En index.php
Como es sabido, el enrutamiento es el proceso de tomar la respuesta HTTP, analizar la URL, el método y las cabeceras para realizar una acción sobre nuestros recursos.
El lugar donde lo haremos será el index.php
.
Así que agrega a v1
dicho archivo.
La lógica exacta que seguirá será:
- Procesar el parámetro
format
para determinar el formato de la respuesta (JSON/XML) - Registrar un manejador de excepciones globales que surjan en la API
- Extraer los segmentos de la URL
- Determinar el recurso sobre el que se realizará la operación
- Ejecutar la acción sobre el recurso dependiendo del método HTTP especificado en el cliente
- Imprimir la respuesta en el formato establecido
Esto en código se implementa a:
index.php
<?php require 'controllers/affiliates.php'; require 'views/XmlView.php'; require 'views/JsonView.php'; require 'utils/ApiException.php'; // Obtener valor del parámetro 'format' para el formato de la respuesta $format = isset($_GET['format']) ? $_GET['format'] : 'json'; // Crear representación de la vista para el formato elegido if (strcasecmp($format, 'xml') == 0) { $apiView = new XmlView(); } else { $apiView = new JsonView(); } // Registrar manejador de excepciones set_exception_handler( function (ApiException $exception) use ($apiView) { http_response_code($exception->getStatus()); $apiView->render($exception->toArray()); } ); // Extraer segmento de la url if (isset($_GET['PATH_INFO'])) { $urlSegments = explode('/', $_GET['PATH_INFO']); } else { throw new ApiException( 404, 0, "El recurso al que intentas acceder no existe", "http://localhost", "No existe un resource definido en: http://$_SERVER[HTTP_HOST]$_SERVER[REQUEST_URI]"); } // Obtener recurso $resource = array_shift($urlSegments); $apiResources = array('affiliates'); // Comprobar si existe el recurso if (!in_array($resource, $apiResources)) { throw $resourceNotFound; } // Transformar método HTTP a minúsculas $httpMethod = strtolower($_SERVER['REQUEST_METHOD']); // Determinar acción según el método HTTP switch ($httpMethod) { case 'get': case 'post': case 'put': case 'delete': if (method_exists($resource, $httpMethod)) { $apiResponse = call_user_func(array($resource, $httpMethod), $urlSegments); $apiView->render($apiResponse); break; } default: // Método no permitido sobre el recurso $methodNotAllowed = new ApiException( 405, 0, "Acción no permitida", "http://localhost", "No se puede aplicar el método $_SERVER[REQUEST_METHOD] sobre el recurso "$resource""); $apiView->render($methodNotAllowed->toArray()); }
Crear Vistas JSON Y XML
Si observas el index.php verás cómo usamos la clase JsonView
y XmlView
para instancear a $apiView
.
Estas clases son implementaciones concretas de View
: una clase abstracta que representa el formato de impresión de la respuesta.
Basándonos en esta definición, entonces crea la clase View
dentro de views
y añádele un método para imprimir la respuesta llamado render()
.
<?php /** * Clase base para la representación de las vistas */ abstract class View { public abstract function render($body); }
Derivar Vista JSON
Ahora implementaremos una vista dedicada al formato JSON.
Así que añade a views
una clase llamada JsonView
y sobrescribe render()
.
La idea es usar json_encode()
para transformar el array asociativo entrante en un objeto JSON:
<?php require_once "View.php"; /** * Clase para imprimir en la salida respuestas con formato JSON */ class JsonView extends View { public function render($body) { // Set de estado de le respuesta if (isset($body["status"])) { http_response_code($body["status"]); } // Set del contenido de la respuesta header('Content-Type: application/json; charset=utf8'); // Encodificado JSON $jsonResponse = json_encode($body, JSON_PRETTY_PRINT, JSON_UNESCAPED_UNICODE); if (json_last_error() != JSON_ERROR_NONE) { $internalServerError = new ApiException( 500, 0, "Error interno en el servidor. Contacte al administrador", "http://localhost", "Error de parsing JSON en JsonView.php. Causa: " . json_last_error_msg()); throw $internalServerError; } echo $jsonResponse; exit; } }
En parte, el complemento principal de la impresión debe llevar el estado HTTP para setearlo con http_response_code()
y además usar como tipo de contenido application/json
con UTF-8
.
Derivar Vista XML
Al igual que el anterior caso, crea una clase llamada XmlView
en views
y derívala de View
:
<?php require_once "View.php"; /** * Clase para imprimir en la salida respuestas con formato XML */ class XmlView extends View { public function render($body) { // Set de estado de le respuesta if (isset($body["status"])) { http_response_code($body["status"]); } // Set del contenido de la respuesta header('Content-Type: text/xml; charset=utf-8'); $xml = new SimpleXMLElement('<apiResponse/>'); self::arrayToXml($body, $xml); print $xml->asXML(); exit; } public function arrayToXml($data, &$xml) { foreach ($data as $key => $value) { if (is_array($value)) { if (is_numeric($key)) { $key = 'item' . $key; } $subnode = $xml->addChild($key); self::arrayToXml($value, $subnode); } else { $xml->addChild("$key", htmlspecialchars("$value")); } } } }
La clase SimpleXMLElement
nos permitirá crear un nodo XML al cual le añadiremos los hijos dependiendo del array asociativo entrante.
Allí es donde arrayToXml()
comienza a crear la jerarquía XML.
Manejar Errores De La API Con Excepciones
Cuando estemos controlando los errores que producen ciertos flujos de la API, es mucho más sencillo disparar una excepción en ese punto que retornar el resultado entre elementos.
Pensando en ello, creemos una excepción propia llamada ApiException
en la carpeta utils
que herede de Exception
.
La idea es poner como atributos aquellos elementos de las respuestas que diseñamos anteriormente y así retornarlas:
<?php /** * Excepción personalizada para el envío de respuestas */ class ApiException extends Exception { private $status; private $apiCode; private $userMessage; private $moreInfo; private $developerMessage; public function __construct($status, $code, $message, $moreInfo, $developerMessage) { $this->status = $status; $this->apiCode = $code; $this->userMessage = $message; $this->moreInfo = $moreInfo; $this->developerMessage = $developerMessage; } public function getStatus() { return $this->status; } public function getApiCode() { return $this->apiCode; } public function getUserMessage() { return $this->userMessage; } public function getMoreInfo() { return $this->moreInfo; } public function getDeveloperMessage() { return $this->developerMessage; } public function toArray() { $errorBody = array( "status" => $this->status, "code" => $this->apiCode, "message" => $this->userMessage, "moreInfo" => $this->moreInfo, "developerMessage" => $this->developerMessage ); return $errorBody; } }
Si retomas en archivo index.php, verás el lugar donde registramos el manejador de excepciones. Allí se llama al método toArray()
para pasarlo a la vista y setear el estado HTTP proveniente de la excepción ($status
).
Crear Controlador De Afiliados
El manejo del recurso affiliates
será a través de una clase con el mismo nombre que administrará que hacer con el recurso dependiendo de la acción.
Así que crea la clase affiliates
en la carpeta controllers
y pon cuatro métodos para cada acción HTTP:
<?php /** * Controlador del recurso "/affiliates" */ class affiliates { public static function get($urlSegments) { } public static function post($urlSegments) { } public static function put($urlSegments) { } public static function delete($urlSegments) { } }
Si te fijas, cada método recibe un parámetro $urlSegments
para procesar los segmentos de URL extras de la petición.
Cabe aclarar que en index.php usamos el método call_user_func()
para llamar el método correspondiente de los controladores existentes según el recurso y el método HTTP.
De ese modo, si sigues este estilo de diseño, entonces todos tus controllers deben tener los mismos 4 métodos.
Procesar Petición POST Para Registro De Usuarios
Aquí nos preguntamos como registramos a un usuario, según las URIs, entradas y salidas diseñadas.
Es evidente que el código lo escribiremos en el método post()
de affiliates
.
Además piensa en que podemos tener los recursos "affiliates/register"
y "affiliates/login"
.
Por lo que lo más conveniente es procesar los segmentos con un condicional:
public static function post($urlSegments) { switch ($urlSegments[0]) { case "register": return self::saveAffiliate(); break; case "login": return self::authAffiliate(); break; default: throw new ApiException(404, 0, "El recurso al que intentas acceder no existe", "http://localhost.com", "No se encontró el segmento "affiliates/$urlSegments[0]"."); } }
Donde saveAffiliate()
será el método que registre a los afiliados en la base de datos MySQL y authAffiliate()
será el que autentique al usuario.
Insertar Afiliado En La Base De Datos
¡Excelente!
Ahora solo debemos insertar los datos que vienen en la tabla affiliate.
El siguiente flujo describe muy bien las acciones a realizar entro de saveAffiliate()
:
- Obtener parámetros del POST y decodificar su formato JSON (o XML)
- Verificar integridad de los atributos del objeto
- Realizar operación
INSERT
en la tablaaffiliate
. - Procesar el resultado de la base de datos para retornar una respuesta
En código tendríamos lo siguiente:
private static function saveAffiliate() { // Obtener parámetros de la petición $parameters = file_get_contents('php://input'); $decodedParameters = json_decode($parameters); // Verificar integridad de datos // TODO: Implementar restricciones de datos adicionales if (!isset($decodedParameters["id"]) || !isset($decodedParameters["password"]) || !isset($decodedParameters["name"]) || !isset($decodedParameters["address"]) || !isset($decodedParameters["gender"]) ) { // TODO: Crear una excepción individual por cada causa anómala throw new ApiException(400, 0, "Verifique los datos del afiliado tengan formato correcto", "http://localhost.com", "Uno de los atributos del afiliado no está definido en los parámetros"); } // Insertar afiliado $dbResult = self::insertAffiliate($decodedParameters); // Procesar resultado de la inserción if ($dbResult) { return ["status" => 200, "message" => "Afiliado registrado"]; } else { throw new ApiException(500, 2000, "Error del servidor", "http://localhost.com", "Error en la base de datos al ejecutar la inserción del afiliado."); } }
Ahora, insertAffiliate()
es quien opera la base de datos usando una conexión PDO, por lo que antes de ver su lógica primero definiremos el conector BD.
Crear Singleton Para Conexión PDO
La idea es crear la dependencia de un objeto PDO
dentro de una clase manejadora llamada MysqlManager
.
Para ello añádela en el paquete data y asegúrate de que siga el patrón singleton:
MysqlManager.php
<?php /** * Clase que envuelve una instancia de la clase PDO * para el manejo de la base de controladores */ require_once 'login_mysql.php'; class MysqlManager { /** * Única instancia de la clase */ private static $mysqlManager = null; /** * Instancia de PDO */ private static $pdo; final private function __construct() { try { // Crear nueva conexión PDO self::getDb(); } catch (PDOException $e) { // Manejo de excepciones throw new ApiException( 500, 0, "Error de conexión a base de datos", "http://localhost", "La conexión al usuario administrador de MySQL se vío afectada. Detalles: " . $e->getMessage()); } } public static function get() { if (self::$mysqlManager === null) { self::$mysqlManager = new self(); } return self::$mysqlManager; } public function getDb() { if (self::$pdo == null) { // Parámetros de PDO $dsn = sprintf('mysql:dbname=%s; host=%s', MYSQL_DATABASE_NAME, MYSQL_HOST); $username = MYSQL_USERNAME; $passwd = MYSQL_PASSWORD; $options = array( PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8", PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION); self::$pdo = new PDO($dsn, $username, $passwd, $options); } return self::$pdo; } final protected function __clone() { } function _destructor() { self::$pdo = null; } }
Definir Credenciales De MySQL
El constructor de PDO requiere 3 parámetros donde debemos incluir las credenciales de MySQL basados en el host al que accederemos y la base de datos a usar.
Como ya lo hice notar, MysqlManager
tiene una sentencia require_once
para un archivo llamado login_mysql.php
.
Justo allí irán estas credenciales a manera de constantes.
Fíjate:
login_mysql.php
<?php /** * Provee las constantes para conectarse a la base de datos * Mysql. */ // TODO: cambiar por dominio o IP en producción define("MYSQL_HOST", "localhost"); define("MYSQL_DATABASE_NAME", "saludmock"); // TODO: cambiar en producción define("MYSQL_USERNAME", "root"); // TODO: cambiar en producción define("MYSQL_PASSWORD", "");
IMPORTANTE: Cambia estas credenciales cuando estés en producción. Recuerda que estamos usando nuestro PC doméstico como servidor de desarrollo, pero cuando contratas un proveedor los datos deben ajustarse a este ambiente.
Implementar Método insertAffiliate()
Con lo anterior hemos preparado el camino para insertar el afiliado, así que crea el método insertAffiliate()
en affiliates
y aplica tu conocimiento para:
- Extraer individualmente cada dato del afiliado
- Generar los atributos que requieran cálculos o tratos especiales (como la encriptación del password y la creación del token)
- Preparar la sentencia
INSERT
con los datos - Ejecutar la sentencia preparada y retornar el resultado
Veamos:
private static function insertAffiliate($decodedParameters) { //Extraer datos del afiliado $id = $decodedParameters["id"]; $password = $decodedParameters["password"]; $name = $decodedParameters["name"]; $address = $decodedParameters["address"]; $gender = $decodedParameters["gender"]; // Encriptar contraseña $hashPassword = password_hash($password, PASSWORD_DEFAULT); // Generar token $token = uniqid(rand(), TRUE); try { $pdo = MysqlManager::get()->getDb(); // Componer sentencia INSERT $sentence = "inserto into affiliate (id, hash_password, name, address, gender, token)" . " values (?,?,?,?,?,?)"; // Preparar sentencia $preparedStament = $pdo->prepare($sentence); $preparedStament->bindParam(1, $id); $preparedStament->bindParam(2, $hashPassword); $preparedStament->bindParam(3, $name); $preparedStament->bindParam(4, $address); $preparedStament->bindParam(5, $gender); $preparedStament->bindParam(6, $token); // Ejecutar sentencia return $preparedStament->execute(); } catch (PDOException $e) { throw new ApiException( 500, 0, "Error de base de datos en el servidor", "http://localhost.com", "Ocurrió el siguiente error al intentar insertar el afiliado: " . $e->getMessage()); } }
REST Testing: Registro De Afiliados
¿Cómo vas?
La codificación ha sido significativa y es muy parecida al servicio REST de la agenda con contactos que ya hicimos.
Lo que haremos ahora es testear con la herramienta Postman, las peticiones de registro.
Para ello sigue estos pasos:
1. Abre Postman.
2. Configura la petición con las siguientes características:
- Método:
POST
- URL:
http://localhost/blog/v1/api.saludmock.com/affiliates/register
3. Ahora, ve a la pestaña Body, pásate al radio raw y luego selecciona el tipo JSON (application/json).
En la caja del cuerpo inserta los datos de un afiliado de prueba que vimos en el diseño de la petición.
A manera de ejemplo yo agregaré los siguientes:
{ "id":"1234567890", "password":"mypassword", "name":"Fernando", "address":"Calle 23 #2", "gender":"M" }
Con estas condiciones dadas, tendrás la interfaz así:
4. Presiona Send y espera la siguiente respuesta:
{ "status": 200, "message": "Afiliado registrado" }
Con esto tendrás el registro de afiliados completo.
A menos que tengas alguna excepción, la cual no deberías tardarte más de un minuto en resolver, debido a que el atributo developerMessage
te dirá exactamente qué sucede.
IMPORTANTE: El servicio REST actual permite que cualquiera que conozca la URI de registro de afiliados y el formato de petición, pueda guardar usuarios. Si deseas limitar esto, te interesará mucho usar roles, permisos y recursos como te enseño en mi tutorial Tutorial De App Productos Parte 2: Login Y Servidor Virtual DigitalOcean.
Procesar Petición POST Para Login De Usuarios
Una vez creado un afiliado, ahora necesitamos autenticarlo y crear su sesión.
En el apartado anterior vimos que el método post()
de affiliates llamaba al método authAffiliate()
, cuando el segmento de URL complementario era "/login"
.
Lo que nos lleva a crear dicho método e implementar su lógica.
Añadir Lógica De Autenticación
Agrega el método authAffiliate()
al controlador de afiliados y siguiendo el mismo formato que usamos en saveAffiliate()
, aplica estos pasos:
- Obtener parámetros y decodificar su tipo JSON
- Validar integridad de datos de la petición
- Consultar si las credenciales coinciden con un registro de la base de datos
- Retornar la respuesta de la sesión creada o el error generado
Aunque el código PHP es parecido al anterior, te mostraré como quedarían las instrucciones:
private static function authAffiliate() { // Obtener parámetros de la petición $parameters = file_get_contents('php://input'); $decodedParameters = json_decode($parameters, true); // Controlar posible error de parsing JSON if (json_last_error() != JSON_ERROR_NONE) { $internalServerError = new ApiException(500, 0, "Error interno en el servidor. Contacte al administrador", "http://localhost", "Error de parsing JSON. Causa: " . json_last_error_msg()); throw $internalServerError; } // Verificar integridad de datos if (!isset($decodedParameters["id"]) || !isset($decodedParameters["password"]) ) { throw new ApiException( 400, 0, "Las credenciales del afiliado deben estar definidas correctamente", "http://localhost", "El atributo "id" o "password" o ambos, están vacíos o no definidos" ); } $userId = $decodedParameters["id"]; $password = $decodedParameters["password"]; // Buscar usuario en la tabla $dbResult = self::findAffiliateByCredentials($userId, $password); // Procesar resultado de la consulta if ($dbResult != NULL) { return [ "status" => 200, "id" => $dbResult["id"], "name" => $dbResult["name"], "address" => $dbResult["address"], "gender" => $dbResult["gender"], "token" => $dbResult["token"] ]; } else { throw new ApiException( 400, 4000, "Número de identificación o contraseña inválidos", "http://localhost", "Puede que no exista un usuario creado con el id:$userId o que la contraseña:$password sea incorrecta." ); } }
Si observas, findAffiliateByCredentials()
se encarga de verificar en la base de datos, si existe una afiliado con las credenciales entrantes.
Para definirlo sigue leyendo…
Consultar La Tabla De Afiliados Por Credenciales
Una vez obtenidos las credenciales del usuario, entonces creamos el método findAffiliateByCredentials()
para recibirlos.
La idea es:
- Redactar el comando
SELECT
sobreaffiliate
para encontrar al usuario con el ID del parámetro - Preparar la sentencia y ejecutarla
- Si hubo resultados, entonces comprobar el valor de la columna
hash_password
con la contraseña del parámetro - Retornar el usuario (array asociativo) si ambas credenciales coinciden, o un valor de
null
.
Vayamos al código:
private static function findAffiliateByCredentials($userId, $password) { try { $pdo = MysqlManager::get()->getDb(); // Componer sentencia SELECT $sentence = "SELECT * FROM affiliate WHERE id=?"; // Preparar sentencia $preparedSentence = $pdo->prepare($sentence); $preparedSentence->bindParam(1, $userId, PDO::PARAM_INT); // Ejecutar sentencia if ($preparedSentence->execute()) { $affiliateData = $preparedSentence->fetch(PDO::FETCH_ASSOC); // Verificar contraseña if (password_verify($password, $affiliateData["hash_password"])) { return $affiliateData; } else { return null; } } else { throw new ApiException( 500, 5000, "Error de base de datos en el servidor", "http://localhost", "Hubo un error ejecutando una sentencia SQL en la base de datos. Detalles:" . $pdo->errorInfo()[2] ); } } catch (PDOException $e) { throw new ApiException( 500, 0, "Error de base de datos en el servidor", "http://localhost.com", "Ocurrió el siguiente error al consultar el afiliado: " . $e->getMessage()); } }
Rest Testing: Login De Usuarios
De la misma forma que el registro de afiliados, abriremos Postman para testear el login.
En primer lugar crea una nueva pestaña y configura la petición así:
- Método:
POST
- URL:
http://localhost/blog/api.saludmock.com/v1/affiliates/login
El siguiente paso es ir a la pestaña Body, seleccionar el radio raw y decidirnos por JSON (application/json).
Con esas características, pon en el área de texto un objeto JSON con las credenciales de afiliado como habíamos diseñado anteriormente:
{ "id":"1234567890", "password":"mypassword" }
Si todo salió bien, entonces tendrás una respuesta con estado 200
similar a la siguiente:
{ "status": 200, "id": "1234567890", "name": "Fernando", "address": "Calle 23 #2", "gender": "M", "token": "19922585ab9d878a2d3.03088656" }
Realizar Petición POST Con Retrofit
¡Muy bien!
Ya tenemos nuestro servicio REST para el registro y login de afiliados.
Aunque solo necesitaremos el login para que nuestra app parcial de SaludMock funcione.
Así que vamos a ver como modificar el sistema de autenticación falso que tenemos, por una petición POST real con Retrofit…
Crear Interfaz Java Para Representar REST Service
Como vimos en la receta inicial, el primer paso es añadir una interfaz que abstraiga el formato de las peticiones HTTP que recibirá nuestro servicio web.
Así que crea una nueva interfaz llamada SaludMockApi
y define un método POST
para el login de usuarios:
public interface SaludMockApi { // TODO: Cambiar host por "10.0.0.2" para Genymotion. // TODO: Cambiar host por "10.0.0.3" para AVD. // TODO: Cambiar host por IP de tu PC para dispositivo real. public static final String BASE_URL = "http://10.0.0.2/blog/api.saludmock.com/v1"; @POST("affiliates/login") Call<Affiliate> login(@Body LoginBody loginBody); }
La constante BASE_URL
será la raíz que nos ayudará a crear las urls particulares de petición.
Cambia el host por los valores que te dejo en los comentarios TODO
según el lugar donde ejecutes tu app.
Si te fijas en el método login()
verás que usamos la anotación @POST
para indicar que ese será la acción y la ruta parcial del recurso donde loguearemos al afiliado.
El retorno será un tipo Call<Affiliate>
, donde Affiliate
es un objeto plano Java para recoger los datos de la respuesta.
Y el parámetro será un objeto LoginBody
anotado con @Body
para indicar que será transmitido como cuerpo.
Crear Affiliate y LoginBody
Ambos son objetos Java que serán usados en la serialización y deserialzación JSON.
En el caso de Affiliate
tendremos los atributos que resultan de la petición. Por ende crea la clase:
Affiliate.java
public class Affiliate { private String id; private String name; private String address; private String gender; private String token; public Affiliate(String id, String name, String address, String gender, String token) { this.id = id; this.name = name; this.address = address; this.gender = gender; this.token = token; } public String getId() { return id; } public void setId(String id) { this.id = id; } public String getName() { return name; } public void setName(String name) { this.name = name; } public String getAddress() { return address; } public void setAddress(String address) { this.address = address; } public String getGender() { return gender; } public void setGender(String gender) { this.gender = gender; } public String getToken() { return token; } public void setToken(String token) { this.token = token; } }
De acuerdo con el cuerpo de la petición diseñado en el servicio REST, LoginBody solo tendrá como atributos las credenciales de usuario.
Como resultado tendremos:
LoginBody.java
public class LoginBody { @SerializedName("id") private String userId; private String password; public LoginBody(String userId, String password) { this.userId = userId; this.password = password; } public String getUserId() { return userId; } public void setUserId(String userId) { this.userId = userId; } public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } }
IMPORTANTE: Usa la anotación @SerializedName
para aclararle a Gson cuál será el nombre exacto del atributo JSON que será interpretado.
Definir Un Miembro De La Clase Retrofit
A continuación vamos a crear el adaptador de Retrofit en LoginActivity.
Abre la actividad y declara al inicio de la clase la variable mRestAdapter
:
public class LoginActivity extends AppCompatActivity { private Retrofit mRestAdapter;
Luego ve a onCreate()
e inicializa su contenido con su patrón Retrofit.Builder
basado en la URL base y un convertidor Gson (GsonConverterFactory.create()
):
@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_login); // Crear conexión al servicio REST mRestAdapter = new Retrofit.Builder() .baseUrl(SaludMockApi.BASE_URL) .addConverterFactory(GsonConverterFactory.create()) .build(); //...
Crear Instancia De SaludMockApi
Bien. El paso a seguir es crear la implementación concreta del cliente HTTP.
Para ello usa el método create()
de tu adaptador Retrofit
y recoge la instancia en una variable global llamada mSaludMockApi
:
public class LoginActivity extends AppCompatActivity { private SaludMockApi mSaludMockApi; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_login); // ... // Crear conexión a la API de SaludMock mSaludMockApi = mRestAdapter.create(SaludMockApi.class);
Realizar Llamada HTTP Con SaludMockApi
Ya para terminar vamos a modificar el método attemptLogin()
.
Como dije al inicio, puedes realizar la petición de forma asíncrona usando el método Call.enqueue()
.
Para recibir el resultado de este método, usa una callback del tipo Callback
.
Ojo: Esto significa que ya no usaremos la tarea asíncrona UserLoginTask
, en consecuencia bórrala y pon la invocación del método SaludMockApi.login()
.
Veamos como cambia el método:
private void attemptLogin() { // Reset errors. mFloatLabelUserId.setError(null); mFloatLabelPassword.setError(null); // Store values at the time of the login attempt. String userId = mUserIdView.getText().toString(); String password = mPasswordView.getText().toString(); boolean cancel = false; View focusView = null; // Check for a valid password, if the user entered one. if (TextUtils.isEmpty(password)) { mFloatLabelPassword.setError(getString(R.string.error_field_required)); focusView = mFloatLabelPassword; cancel = true; } else if (!isPasswordValid(password)) { mFloatLabelPassword.setError(getString(R.string.error_invalid_password)); focusView = mFloatLabelPassword; cancel = true; } // Verificar si el ID tiene contenido. if (TextUtils.isEmpty(userId)) { mFloatLabelUserId.setError(getString(R.string.error_field_required)); focusView = mFloatLabelUserId; cancel = true; } else if (!isUserIdValid(userId)) { mFloatLabelUserId.setError(getString(R.string.error_invalid_user_id)); focusView = mFloatLabelUserId; cancel = true; } if (cancel) { // There was an error; don't attempt login and focus the first // form field with an error. focusView.requestFocus(); } else { // Mostrar el indicador de carga y luego iniciar la petición asíncrona. showProgress(true); Call<Affiliate> loginCall = mSaludMockApi.login(new LoginBody(userId, password)); loginCall.enqueue(new Callback<Affiliate>() { @Override public void onResponse(Call<Affiliate> call, Response<Affiliate> response) { } @Override public void onFailure(Call<Affiliate> call, Throwable t) { } }); } }
El método onResponse()
se ejecuta si llegó una respuesta HTTP normal. Y onFailure()
se invoca cuando ocurre una excepción sobre la red o si hay malas prácticas en la lógica de Retrofit.
¿Qué debería ir en onResponse()?
Veamos las instrucciones:
- Ocultar el progreso
- Luego determinar si se produjo un error. Esto se hace con el método
Response.isSuccessfull()
. - Si lo hubo, necesitaremos comprobar si el formato es JSON.
- Si lo es, hacemos un parsing JSON para que retorne un POJO del mensaje de error (nueva clase
ApiError
) - Si no, mostramos el mensaje de error con
showLoginError()
- Frenamos el flujo del método.
- Si lo es, hacemos un parsing JSON para que retorne un POJO del mensaje de error (nueva clase
- Si no, tomamos el objeto
Affiliate
que viene de la respuesta y almacenamos sus datos en las preferencias de Android. Esto con el fin de mantener la sesión abierta.
Observemos el código:
Call<Affiliate> loginCall = mSaludMockApi.login(new LoginBody(userId, password)); loginCall.enqueue(new Callback<Affiliate>() { @Override public void onResponse(Call<Affiliate> call, Response<Affiliate> response) { // Mostrar progreso showProgress(false); // Procesar errores if (!response.isSuccessful()) { String error; if (response.errorBody() .contentType() .subtype() .equals("application/json")) { ApiError apiError = ApiError.fromResponseBody(response.errorBody()); error = apiError.getMessage(); Log.d("LoginActivity", apiError.getDeveloperMessage()); } else { error = response.message(); } showLoginError(error); return; } // Guardar afiliado en preferencias SessionPrefs.get(LoginActivity.this).saveAffiliate(response.body()); // Ir a la citas médicas showAppointmentsScreen(); } @Override public void onFailure(Call<Affiliate> call, Throwable t) { showProgress(false); showLoginError(t.getMessage()); } });
Del parámetro Response<Affiliate>
usamos los siguientes métodos:
isSuccessful()
: Es true si se obtienen códigos 2xx.errorBody()
: El contenido plano de una respuesta con errormessage()
: Mensaje de estado HTTPbody()
: Es el cuerpo deserializado de la petición (objetoAffiliate
), si esta fue exitosa.
En onFailure()
tan solo ocultamos el progreso y mostramos el error.
Mantener La Sesión De Usuario Con Las Preferencias De Android
Finalmente…
…guardaremos los datos del afiliado en las preferencias de Android para mantener su sesión abierta.
¿Cómo lo hacemos?
Crea un paquete llamado prefs
dentro de data
y añade una clase con patrón singleton llamada SessionsPrefs
:
public class SessionPrefs { private static SessionPrefs INSTANCE; public static SessionPrefs get() { if (INSTANCE == null) { INSTANCE = new SessionPrefs(); } return INSTANCE; } private SessionPrefs() { } }
La idea es implementar el método saveAffiliate()
que vimos en la sección pasada para guardar la info del afiliado.
Veamos…
Definir Miembros
Los primeros miembros serán constantes para el nombre del archivo de preferencias y las claves de los valores, o sea:
public static final String PREFS_NAME = "SALUDMOCK_PREFS"; public static final String PREF_AFFILIATE_ID = "PREF_USER_ID"; public static final String PREF_AFFILIATE_NAME = "PREF_AFFILIATE_NAME"; public static final String PREF_AFFILIATE_ADDRESS = "PREF_AFFILIATE_ADDRESS"; public static final String PREF_AFFILIATE_GENDER = "PREF_AFFILIATE_GENDER"; public static final String PREF_AFFILAITE_TOKEN = "PREF_AFFILAITE_TOKEN";
Ahora va una instancia de SharedPreferences
:
private final SharedPreferences mPrefs;
También será importante tener una bandera booleana que identifique si el usuario está o no logueado:
private boolean mIsLoggedIn = false;
Definir Constructor
Bien, lo siguiente es inicializar las preferencias en el constructor a través de un parámetro Context
que invoque al método getSharedPreferences().
Además resetearemos el valor de mIsLoggedIn
comprobando el contenido del token:
private SessionPrefs(Context context) { mPrefs = context.getApplicationContext() .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); mIsLoggedIn = !TextUtils.isEmpty(mPrefs.getString(PREF_AFFILAITE_TOKEN, null)); }
Obviamente el método get()
para la instancia cambiará a la siguiente creación:
public static SessionPrefs get(Context context) { if (INSTANCE == null) { INSTANCE = new SessionPrefs(context); } return INSTANCE; }
Facilitar Conocimiento Del Estado Del Usuario
En pocas palabras, crearemos un método público que permita saber si el afiliado está actualmente logueado o no:
public boolean isLoggedIn(){ return mIsLoggedIn; }
Crear Método De Guardado De Afiliado
Y ahora crear el método saveAffiliate()
para que reciba un objeto Affiliate
.
Guarda cada atributo y activa la bandera de logueo:
public void saveAffiliate(Affiliate affiliate) { if (affiliate != null) { SharedPreferences.Editor editor = mPrefs.edit(); editor.putString(PREF_AFFILIATE_ID, affiliate.getId()); editor.putString(PREF_AFFILIATE_NAME, affiliate.getName()); editor.putString(PREF_AFFILIATE_ADDRESS, affiliate.getAddress()); editor.putString(PREF_AFFILIATE_GENDER, affiliate.getGender()); editor.putString(PREF_AFFILAITE_TOKEN, affiliate.getToken()); editor.apply(); mIsLoggedIn = true; } }
Cerrar Sesión De Afiliado
Por último pon un método para eliminar la sesión del usuario.
Esto se logra dándole null
a todas las preferencias y asignando el valor de falso a la bandera:
public void logOut(){ mIsLoggedIn = false; SharedPreferences.Editor editor = mPrefs.edit(); editor.putString(PREF_AFFILIATE_ID, null); editor.putString(PREF_AFFILIATE_NAME, null); editor.putString(PREF_AFFILIATE_ADDRESS, null); editor.putString(PREF_AFFILIATE_GENDER, null); editor.putString(PREF_AFFILAITE_TOKEN, null); editor.apply(); }
Actualizar Redirección Desde Login A Citas Médicas
Ve a la actividad AppointmentsActivity
y modifica la condición de redirección que pusimos en onCreate()
.
Dale como expresión de condición al if el resultado del método SessionPrefs.isLoggedIn()
:
@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); // Redirección al Login if (!SessionPrefs.get(this).isLoggedIn()) { startActivity(new Intent(this, LoginActivity.class)); finish(); return; }
De esta forma ya tendrás lista la app de SaludMock creada de forma parcial funcionando.
¡Bien hecho!
¿Quieres Otro Ejemplo De Login?
Si deseas crear un login más complejo con autorización básica, patrón MVP (Model-View-Presenter), arquitectura CLEAN, y un servicio REST mejor separado, entonces te gustará ver mi tutorial Tutorial De App Productos Parte 2: Login Y Servidor Virtual DigitalOcean.
Sé que te ayudará mucho en la organización, mantenimiento y testing de tu proyecto.
Y para finalizar…
Quiero preguntarte:
- ¿Qué te pareció este tutorial?
- ¿Te fue de utilidad?
- ¿Estas realizando un proyecto similar?
Escribe tu apreciación en la caja de comentarios para dejarme saber qué piensas 🙂
Únete Al Discord De Develou
Si tienes problemas con el código de este tutorial, preguntas, recomendaciones o solo deseas discutir sobre desarrollo Android conmigo y otros desarrolladores, únete a la comunidad de Discord de Develou y siéntete libre de participar como gustes. ¡Te espero!