roble 1.3.0 copy "roble: ^1.3.0" to clipboard
roble: ^1.3.0 copied to clipboard

Cliente Flutter para la plataforma ROBLE de Uninorte OpenLab. Autenticación y CRUD sobre bases de datos PostgreSQL.

📦 roble #

Cliente Flutter para la plataforma ROBLE de Uninorte OpenLab: autenticación y CRUD sobre PostgreSQL.

https://github.com/augustosalazar/roble_api_database

🔁 Existe un equivalente en JavaScript/TypeScript, roble-client, con los mismos métodos y las mismas excepciones.

🚀 Instalación #

flutter pub add roble
import 'package:roble/roble.dart';

🧭 Quick start #

final db = RobleApiDataBase(
	config: RobleApiConfig.fromContract(
		baseUrl: 'https://roble-api.test-openlab.uninorte.edu.co',
		contractId: 'tu_contrato',
	),
);

// 1. Registro
await db.register(
	email: 'ana@correo.com',
	password: 'MiClave!1',
	name: 'Ana García',
	extra: {'programa': 'Sistemas'},
);

// 2. Login: devuelve el perfil
final user = await db.login(email: 'ana@correo.com', password: 'MiClave!1');
print('Hola ${user['name']} (${user['userId']})');

// 3. CRUD
final creado = await db.create('usuarios', {'nombre': 'Ana', 'edad': 28});
final todos = await db.read('usuarios');
await db.update('usuarios', creado['_id'], {'edad': 29});
await db.delete('usuarios', creado['_id']);

// 4. Cerrar sesión
await db.logout();

Todos los métodos son asíncronos y lanzan alguna subclase de RobleApiException. Ver Manejo de errores.


⚙️ Configuración #

RobleApiConfig es inmutable. Lo habitual es componerla desde el host y el identificador del contrato:

final config = RobleApiConfig.fromContract(
	baseUrl: 'https://roble-api.test-openlab.uninorte.edu.co',
	contractId: 'tu_contrato',
	timeout: Duration(seconds: 30),
);
Parámetro Tipo Obligatorio Descripción
baseUrl String Host de la API. Una barra final se ignora.
contractId String Identificador del contrato, con el que se componen las rutas de auth y de datos.
timeout Duration no Tiempo máximo por petición. Por defecto 30 s.

Content-Type: application/json y Authorization: Bearer … los gestiona el cliente; no hay que declararlos.

fromContract es la única forma de crear la configuración: las URLs se componen siempre a partir del host y del contrato.

Constructor del cliente #

RobleApiDataBase({
	required RobleApiConfig config,
	http.Client? client,          // solo para tests
	RobleTokenStorage? storage,   // solo para tests
})

Los tokens no se exponen. El paquete los guarda en el almacén seguro del sistema, los adjunta a cada petición, los renueva ante un 401 y los borra al cerrar sesión. Lo único que se consulta desde fuera es db.isLoggedIn.


🔐 Sesión #

bool get isLoggedIn #

true si este cliente tiene una sesión iniciada. No consulta al servidor.

if (db.isLoggedIn) mostrarPerfil();

Future<bool> restoreSession({bool verify = true}) #

Restaura la sesión guardada y comprueba contra el servidor que siga viva. Llámalo al arrancar la app.

Parámetro Tipo Por defecto Descripción
verify bool true Si es true, renueva el access token contra el servidor. Con false solo lee el almacenamiento (más rápido, pero la sesión puede estar caducada).

Devuelve true si la sesión sirve; false si no había sesión guardada o el refresh token ya no vale (en ese caso limpia la sesión).

Errores

Excepción Cuándo
RobleApiNetworkException Sin conexión. No borra la sesión: distínguelo de "sesión caducada" y reintenta.
RobleApiTimeoutException El servidor no respondió a tiempo. Tampoco borra la sesión.
try {
	if (await db.restoreSession()) {
		irAlInicio();
	} else {
		irAlLogin();
	}
} on RobleApiNetworkException {
	mostrarPantallaSinConexion();
}

Persistencia entre reinicios #

No hay que configurar nada. El paquete guarda la sesión en el almacén seguro del sistema (Keychain en iOS/macOS, Keystore en Android, almacenamiento cifrado en web, gestor de secretos en escritorio) mediante flutter_secure_storage. El refresh token es la credencial de larga duración, así que no va a SharedPreferences.

El ciclo completo es:

final db = RobleApiDataBase(config: config);   // sin storage

await db.login(email: …, password: …);          // se guarda sola
// … la app se cierra y se vuelve a abrir …
await db.restoreSession();                      // vuelve la sesión
await db.logout();                              // se borra

En pruebas puedes sustituirlo por RobleMemoryStorage, que guarda en un Map:

final db = RobleApiDataBase(config: config, storage: RobleMemoryStorage());

Si el almacén no está disponible (por ejemplo en un test de Dart puro, sin plataforma), las operaciones fallan en silencio: la sesión sigue viva en memoria pero no se persiste.


🔑 Autenticación #

register #

Future<Map<String, dynamic>> register({
	required String email,
	required String password,
	required String name,
	Map<String, dynamic>? extra,
	bool autoLogin = false,
	bool persistSession = true,
})

Registra un usuario sin verificación por correo. La cuenta queda activa de inmediato. POST /signup-direct.

Parámetro Tipo Por defecto Descripción
email String Correo del usuario.
password String Mínimo 8 caracteres, con mayúscula, minúscula, número y un símbolo de ! @ # $ _ - .
name String Nombre visible.
extra Map<String, dynamic>? null Campos adicionales que el backend guarda con el usuario y devuelve en login y currentUser.
autoLogin bool false Si es true, inicia sesión al terminar el registro.
persistSession bool true Solo se aplica con autoLogin: true. Igual que en login.

Devuelve depende de autoLogin:

autoLogin Devuelve
false El mensaje del servidor: {'message': 'Usuario registrado correctamente.'}
true El perfil del usuario, lo mismo que login

Si el registro funciona pero el login automático falla, la cuenta ya está creada: el error se propaga y db.isLoggedIn sigue en false, así que basta con reintentar login() sin volver a registrar.

registerWithVerification no tiene autoLogin: hasta validar el código del correo la cuenta no puede iniciar sesión.

Errores

Excepción Mensaje típico
RobleApiHttpException (400) El email ya está registrado · contraseña que no cumple las reglas
RobleApiHttpException (500) Error interno al registrar el usuario.
// Registro y a la pantalla principal en un solo paso
final user = await db.register(
	email: 'ana@correo.com',
	password: 'MiClave!1',
	name: 'Ana García',
	extra: {'rol': 'estudiante', 'programa': 'Sistemas'},
	autoLogin: true,
);
print(user['userId']);

registerWithVerification #

Misma firma que [register], pero envía un código de 6 dígitos por correo. POST /signup. El usuario no queda activo hasta llamar a verifyEmail.

await db.registerWithVerification(
	email: 'ana@correo.com',
	password: 'MiClave!1',
	name: 'Ana García',
);

verifyEmail #

Future<Map<String, dynamic>> verifyEmail({
	required String email,
	required String code,
})

Confirma el correo con el código recibido. POST /verify-email.

Errores: RobleApiHttpException (400) si el código es inválido o expiró.

await db.verifyEmail(email: 'ana@correo.com', code: '123456');

resendCode #

Future<Map<String, dynamic>> resendCode({required String email})

Reenvía el código de verificación. POST /resend-code.

login #

Future<Map<String, dynamic>> login({
	required String email,
	required String password,
	bool persistSession = true,
})

Inicia sesión y devuelve el perfil del usuario. Hace POST /login y, con el token ya guardado, GET /me.

Parámetro Tipo Por defecto Descripción
email String Correo.
password String Contraseña.
persistSession bool true Si la sesión debe sobrevivir al cierre de la app. Es el clásico "recordarme".

Con persistSession: false la sesión vive solo en memoria: todo funciona igual mientras la app esté abierta, pero al reiniciar habrá que volver a entrar. Además borra cualquier sesión guardada antes, para que no quede una sesión anterior recuperable en el dispositivo.

await db.login(
	email: email,
	password: password,
	persistSession: recordarme, // p. ej. el valor de un checkbox
);

Devuelve

Campo Tipo Descripción
userId String Id del usuario. Es con lo que se comparan campos como autorId.
email String Correo.
name String Nombre.
extra Map? Lo enviado en register, o null.
id String Id del registro de perfil.
createdAt / updatedAt String Fechas ISO-8601.

Errores

Excepción Cuándo
RobleApiHttpException (401) Credenciales incorrectas.
RobleApiNetworkException Sin conexión.

Si POST /login funciona pero GET /me falla, la sesión queda activa y la excepción se propaga. db.isLoggedIn distingue los dos casos:

try {
	final user = await db.login(email: email, password: password);
	irAlInicio(user);
} catch (e) {
	if (db.isLoggedIn) {
		irAlInicio(await db.currentUser()); // credenciales OK, falló el perfil
	} else {
		mostrarError('Correo o contraseña incorrectos');
	}
}

currentUser #

Future<Map<String, dynamic>> currentUser()

Perfil del usuario autenticado. GET /me. Mismo mapa que devuelve login.

Errores: RobleApiHttpException (401) si no hay sesión válida.

logout #

Future<void> logout()

Cierra la sesión en el servidor y borra los tokens locales y del almacenamiento. POST /logout.

Errores: RobleApiAuthExceptionNo hay token activo para cerrar sesión.

forgotPassword #

Future<Map<String, dynamic>> forgotPassword({required String email})

Envía el correo de restablecimiento. POST /forgot-password.

Errores: RobleApiHttpException (400) si el correo no está registrado.

resetPassword #

Future<Map<String, dynamic>> resetPassword({
	required String token,
	required String newPassword,
})

Restablece la contraseña con el token que llega en el enlace del correo. POST /reset-password.

Errores: RobleApiHttpException (400) si el token es inválido o expiró.

deleteAccount #

Future<void> deleteAccount()

Elimina la cuenta autenticada de forma permanente y limpia la sesión. DELETE /account. No se puede deshacer: pide confirmación antes de llamarla.

Errores: RobleApiAuthExceptionNo hay sesión activa para eliminar la cuenta.


🗄️ Datos #

create #

Future<Map<String, dynamic>> create(String tableName, Map<String, dynamic> data)

Inserta un registro y devuelve la fila creada, con su _id. POST /insert-one.

Errores: RobleApiHttpException (400) Columnas inválidas: … si algún campo no existe en la tabla; (500) si la tabla no existe.

final creado = await db.create('usuarios', {'nombre': 'Ana', 'edad': 28});
print(creado['_id']);

createMany #

Future<RobleInsertResult> createMany(
	String tableName,
	List<Map<String, dynamic>> records, {
	bool strict = false,
})

Inserta varios registros. POST /insert.

Devuelve RobleInsertResult:

Campo Tipo Descripción
inserted List<Map<String, dynamic>> Filas insertadas, con su _id.
skipped List<RobleSkippedRecord> Rechazadas: index y reason.
hasSkipped bool true si hubo rechazos.

⚠️ El servidor responde 200 aunque rechace registros. Revisa siempre skipped, o usa strict: true para que no se te olvide.

Con strict: true un rechazo parcial deja de ser algo que haya que recordar mirar y pasa a ser un error:

try {
	await db.createMany('usuarios', registros, strict: true);
} on RoblePartialInsertException catch (e) {
	// e.result.inserted -> lo que SÍ se escribió (útil para deshacer)
	// e.result.skipped  -> qué se rechazó y por qué
	print(e.message);
}

Sin strict hay que comprobarlo a mano:

final res = await db.createMany('usuarios', registros);
if (res.hasSkipped) {
	for (final s in res.skipped) {
		print('Fila ${s.index} rechazada: ${s.reason}');
	}
}

read #

Future<List<Map<String, dynamic>>> read(
	String tableName, {
	Map<String, dynamic>? filters,
})

Lee registros. GET /read. Cada entrada de filters viaja como query param y solo admite igualdad: no hay LIKE, rangos, orden ni paginación. Para eso está executeQuery.

final admins = await db.read('usuarios', filters: {'rol': 'admin'});

Errores: RobleApiHttpException (400) si la tabla o una columna no existen.

update #

Future<Map<String, dynamic>> update(
	String tableName,
	dynamic id,
	Map<String, dynamic> data,
)

Actualiza el registro cuyo _id coincida. PUT /update. Las claves _id e id se eliminan del cuerpo automáticamente.

Errores: RobleApiHttpException (404) si el registro no existe.

delete #

Future<Map<String, dynamic>> delete(String tableName, dynamic id)

Elimina el registro cuyo _id coincida. DELETE /delete.

publicRead #

Future<List<Map<String, dynamic>>> publicRead(
	String tableName, {
	Map<String, dynamic>? filters,
})

Lee una tabla marcada como pública, sin autenticación. GET /public-read.

Errores: RobleApiHttpException (403) — Esta tabla no está configurada para acceso público. Es configuración de la tabla en la consola, no un problema de token.

executeQuery #

Future<RobleQueryResult> executeQuery(String id, {List<dynamic>? params})

Ejecuta una consulta guardada en la consola de Roble. POST /execute-query. Es la vía para joins, agregados, orden y paginación.

Devuelve RobleQueryResult con success, command, rowCount, rows y fields.

final res = await db.executeQuery(
	'ca7fe9c1-e740-4e50-82ba-bec89a0eec98',
	params: ['activo'],
);
print('${res.rowCount} filas');

❌ Manejo de errores #

Todo lo que lanza el paquete hereda de RobleApiException, así que puedes capturar el tipo concreto:

Excepción Cuándo Mensaje
RobleApiNetworkException Sin red o DNS no resuelto Sin conexión a internet
RobleApiTimeoutException Se supera config.timeout Tiempo de espera agotado
RobleApiFormatException Respuesta con forma inesperada Respuesta con formato inválido · No se pudo insertar el registro · El servidor no devolvió el ID generado.
RobleApiHttpException Código fuera de 2xx El message del servidor. Expone además statusCode.
RobleApiAuthException Problemas de sesión Token expirado y no se pudo refrescar: … · No hay token activo para cerrar sesión. · No hay refresh token disponible.
RoblePartialInsertException createMany(strict: true) con filas rechazadas El servidor rechazó 1 de 3 registros: fila 2 (…). Expone result.
RobleApiException Cualquier otro Error inesperado: …

RobleApiConfig.fromContract lanza ArgumentError (no RobleApiException) si baseUrl no es una URL o si el contractId está vacío o sigue siendo un valor de ejemplo: es un fallo de programación, no del servidor.

Además, un 500 en autenticación es lo que devuelve Roble cuando el contrato no existe, así que a ese mensaje se le añade una pista:

Error inesperado al autenticar — revisa que el contractId sea correcto (mi_contrato_mal)
try {
	final usuarios = await db.read('usuarios');
} on RobleApiHttpException catch (e) {
	debugPrint('El servidor respondió ${e.statusCode}: ${e.message}');
} on RobleApiAuthException {
	irAlLogin();
} on RobleApiNetworkException {
	mostrarPantallaSinConexion();
} on RobleApiException catch (e) {
	debugPrint(e.message);
}

Captura siempre RobleApiException al final: es la clase base de todas.

Refresco automático. Si una petición de datos responde 401 y hay refresh token, el cliente renueva el access token y reintenta una sola vez. Es interno: no hay método público para refrescar a mano.


🧪 Testing #

El constructor acepta un http.Client inyectado, así que se puede probar sin red:

import 'package:http/testing.dart';
import 'package:http/http.dart' as http;

final db = RobleApiDataBase(
	config: RobleApiConfig.fromContract(
		baseUrl: 'https://fake.test',
		contractId: 'proj',
	),
	storage: RobleMemoryStorage(), // evita tocar el almacén del sistema
	client: MockClient((request) async {
		return http.Response('[{"_id":"1","nombre":"Ana"}]', 200);
	}),
);

final usuarios = await db.read('usuarios');

📱 Ejemplo completo #

example/ es una app Flutter que ejercita registro con autoLogin, login con "recordarme", restauración de sesión al arrancar, currentUser, el CRUD completo e inserción múltiple con registros rechazados, con un log de cada operación.

cd example
flutter run

👥 Autoría #

Creado originalmente por Arias3. Mantenido actualmente por Augusto Salazar (augustosalazar@uninorte.edu.co), Universidad del Norte, como líder de desarrollo.


🛠️ Contribuciones #

Las contribuciones son bienvenidas. Abre un issue si encuentras un bug o quieres proponer una mejora.

0
likes
150
points
312
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Cliente Flutter para la plataforma ROBLE de Uninorte OpenLab. Autenticación y CRUD sobre bases de datos PostgreSQL.

Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

flutter, flutter_secure_storage, http

More

Packages that depend on roble