roble 1.0.0
roble: ^1.0.0 copied to clipboard
Cliente Flutter para la plataforma ROBLE de Uninorte OpenLab. Autenticación y CRUD sobre bases de datos PostgreSQL.
📦 roble #
Paquete para Flutter que facilita la comunicación con la plataforma Roble API. https://roble.openlab.uninorte.edu.co/
Este paquete provee una capa ligera para autenticación y operaciones CRUD sobre las bases de datos expuestas por Roble, manteniendo una interfaz simple y adecuada para aplicaciones móviles y de escritorio con Flutter.
https://github.com/augustosalazar/roble_api_database
🚀 Instalación #
Agrega la dependencia en tu proyecto Flutter:
flutter pub add roble
Importa el paquete donde lo necesites:
import 'package:roble/roble.dart';
🧭 Quick start #
Ejemplo mínimo de uso (async/await):
final db = RobleApiDataBase(
config: RobleApiConfig.fromContract(
baseUrl: 'https://tu-api.com',
contractId: 'tu-contrato',
),
);
// Registrar usuario
final user = await db.register(
email: 'usuario@email.com',
password: 'Password123!',
name: 'Nombre Usuario',
);
// Iniciar sesión (guarda los tokens internamente)
await db.login(
email: 'usuario@email.com',
password: 'Password123!',
);
// Cerrar sesión (limpia los tokens)
await db.logout();
// CREATE - Crear registro
final nuevoUsuario = await db.create('usuarios', {
'nombre': 'Ana García',
'email': 'ana@email.com',
'edad': 28,
});
// READ - Leer todos los registros
final usuarios = await db.read('usuarios');
// UPDATE - Actualizar registro
final actualizado = await db.update('usuarios', usuarioId, {
'edad': 29,
});
// DELETE - Eliminar registro
final eliminado = await db.delete('usuarios', usuarioId);
Nota: todos los métodos son asíncronos y lanzan alguna subclase de
RobleApiExceptionen caso de error de red o respuesta no esperada. Usatry/catchalrededor de tus llamadas.
🔁 Este paquete tiene un equivalente en JavaScript/TypeScript,
roble-client, que expone exactamente los mismos métodos con las mismas excepciones.
⚙️ Configuración (RobleApiConfig) #
RobleApiConfig es inmutable. Lo habitual es componerla desde el host y el identificador del contrato:
final config = RobleApiConfig.fromContract(
baseUrl: 'https://roble.test-openlab.uninorte.edu.co',
contractId: 'token_contract_xyz',
timeout: Duration(seconds: 30), // opcional, 30 s por defecto
);
// authUrl: https://roble.test-openlab.uninorte.edu.co/auth/token_contract_xyz
// dataUrl: https://roble.test-openlab.uninorte.edu.co/database/token_contract_xyz
Una barra final en baseUrl se ignora. Eso es toda la configuración: Content-Type: application/json y Authorization: Bearer … los gestiona el cliente por su cuenta.
Roble expone dos hosts, uno para autenticación y otro para datos. Si por algún motivo usan identificadores distintos, pasa las URLs completas al constructor principal:
const config = RobleApiConfig(
authUrl: 'https://roble-api.openlab.uninorte.edu.co/auth/tu_contrato',
dataUrl: 'https://roble-api.openlab.uninorte.edu.co/database/tu_proyecto',
);
Otros miembros #
| Miembro | Descripción |
|---|---|
RobleApiConfig.fromStrings({baseAuthUrl, baseDataUrl, timeout}) |
Constructor abreviado a partir de dos URLs. |
copyWith({authUrl, dataUrl, timeout}) |
Clona la configuración reemplazando solo lo indicado. |
validate() |
Lanza ArgumentError si alguna URL no empieza por http. |
🔐 Manejo de tokens #
Tras un login() exitoso el cliente guarda internamente el accessToken y el refreshToken, y los adjunta como Authorization: Bearer … en todas las peticiones siguientes. No necesitas pasar el token manualmente en cada llamada.
db.accessToken; // String?
db.refreshToken; // String?
// Restaurar una sesión persistida (por ejemplo, desde SharedPreferences)
db.setTokens(accessToken: guardado.access, refreshToken: guardado.refresh);
// Descartar la sesión en memoria
db.clearTokens();
// Reaccionar a cada cambio del access token
db.onTokenUpdate = (token) => persistir(token);
Refresco automático: si una petición de datos responde 401 y hay un refreshToken disponible, el cliente llama a refresh-token, actualiza el accessToken y reintenta la petición una sola vez. Esto ocurre de forma interna: no existe un método público para refrescar a mano. Si el refresco falla, lanza RobleApiAuthException con el detalle.
El timeout por petición se configura en RobleApiConfig.timeout (30 segundos por defecto).
📚 Referencia de métodos #
Autenticación #
| Método | Endpoint | Descripción |
|---|---|---|
register({email, password, name, extra}) |
POST /signup-direct |
Registra un usuario sin verificación por correo. Devuelve el usuario creado. |
registerWithVerification({email, password, name, extra}) |
POST /signup |
Registra y envía un código de 6 dígitos por correo. |
verifyEmail({email, code}) |
POST /verify-email |
Confirma el correo con el código recibido. |
resendCode({email}) |
POST /resend-code |
Reenvía el código de verificación. |
login({email, password}) |
POST /login |
Inicia sesión y almacena los tokens internamente. Devuelve {accessToken, refreshToken}. |
currentUser() |
GET /verify-token |
Datos del usuario autenticado (sub, email, dbName, sessionId). Único endpoint que expone la identidad. |
forgotPassword({email}) |
POST /forgot-password |
Envía el correo de restablecimiento. |
resetPassword({token, newPassword}) |
POST /reset-password |
Restablece la contraseña con el token del correo. |
logout() |
POST /logout |
Cierra la sesión y limpia los tokens. Lanza RobleApiAuthException si no hay sesión activa. |
deleteAccount() |
DELETE /account |
Elimina la cuenta permanentemente y limpia la sesión. Irreversible. |
El refresco del token es interno (ver Manejo de tokens); no se expone ningún método público para invocarlo.
Ambos métodos de registro aceptan un extra opcional con campos adicionales que el backend guarda junto al usuario:
await db.register(
email: 'ana@mail.com',
password: 'MiClave!1',
name: 'Ana García',
extra: {'rol': 'admin', 'programa': 'Ingeniería de Sistemas'},
);
await db.login(email: 'ana@mail.com', password: 'Password123!');
// db ya está autenticado a partir de aquí
await db.logout(); // cierra sesión y limpia los tokens
Tablas #
| Método | Endpoint | Descripción |
|---|---|---|
createTable(tableName, columns) |
POST /create-table |
Crea una tabla con las columnas indicadas. ⚠️ Endpoint no documentado por la API. |
createTableFromTemplate({tableName, templateTableName}) |
POST /create-table-from-template |
Clona la estructura de columnas de una tabla existente. Único mecanismo documentado para crear tablas. |
getTableData(tableName) |
GET /table-data?schema=public&table=… |
Devuelve los datos de la tabla en el esquema public. ⚠️ Endpoint no documentado por la API. |
await db.createTable('usuarios_test', [
{'name': 'nombre', 'type': 'text'},
{'name': 'rol', 'type': 'text'},
]);
final filas = await db.getTableData('usuarios_test');
Cada columna es un mapa con al menos name y type. La descripción de la tabla se envía automáticamente.
CRUD #
| Método | Endpoint | Descripción |
|---|---|---|
create(tableName, data) |
POST /insert-one |
Inserta un registro y devuelve la fila creada, con su _id. |
createMany(tableName, records) |
POST /insert |
Inserta varios registros. Devuelve RobleInsertResult con inserted y skipped. |
read(tableName, {filters}) |
GET /read |
Lee registros. Cada entrada de filters se envía como query param. Solo igualdad. |
publicRead(tableName, {filters}) |
GET /public-read |
Lee una tabla pública sin autenticación. Un 403 indica que la tabla no está marcada como pública. |
update(tableName, id, data) |
PUT /update |
Actualiza por _id. Las claves _id e id se eliminan del cuerpo automáticamente. |
delete(tableName, id) |
DELETE /delete |
Elimina el registro cuyo _id coincida. |
executeQuery(id, {params}) |
POST /execute-query |
Ejecuta una consulta guardada en la consola. Vía para joins, orden y paginación. |
⚠️
createManypuede tener éxito parcial./insertresponde200aunque rechace registros. Revisa siempreskipped:final res = await db.createMany('usuarios', registros); if (res.hasSkipped) { for (final s in res.skipped) { print('Fila ${s.index} rechazada: ${s.reason}'); } }
final creado = await db.create('usuarios', {'nombre': 'Juan', 'rol': 'admin'});
final admins = await db.read('usuarios', filters: {'rol': 'admin'});
await db.update('usuarios', creado['_id'], {'rol': 'editor'});
await db.delete('usuarios', creado['_id']);
update y delete identifican el registro siempre por la columna _id; no es configurable desde el paquete.
Conveniencia #
| Método | Equivale a | Descripción |
|---|---|---|
getAll(tableName) |
read(tableName) |
Todos los registros de la tabla. |
getById(tableName, id) |
read(…, filters: {'_id': id}) |
Un registro o null si no existe. |
getWhere(tableName, column, value) |
read(…, filters: {column: value}) |
Registros que coinciden con una columna. |
final todos = await db.getAll('usuarios');
final uno = await db.getById('usuarios', 'customid1234');
final editores = await db.getWhere('usuarios', 'rol', 'editor');
⚡ Realtime #
El servicio Realtime es un árbol JSON por proyecto, con una API al estilo de Firebase Realtime Database. El primer segmento de la ruta es la colección.
final mensajes = db.realtime.ref('messages/general');
final id = await mensajes.push({'texto': 'Hola', 'autor': 'ana'});
await mensajes.child(id).update({'status': 'read'});
final todos = await mensajes.get();
final soloClaves = await mensajes.get(shallow: true);
await mensajes.child(id).remove();
| Método | HTTP | Descripción |
|---|---|---|
db.realtime.ref([path]) |
— | Referencia a una ruta. Sin argumentos, la raíz del proyecto. |
db.realtime.collections() |
GET /realtime/{db} |
Nombres de las colecciones. |
db.realtime.health() |
GET /realtime/health |
Estado de PostgreSQL, event bus y CDC. Sin autenticación. |
ref.get({shallow}) |
GET |
Valor JSON en la ruta. Con shallow, solo las claves inmediatas. |
ref.set(value) |
PUT |
Sobrescribe. Crea la colección si no existe. |
ref.update(fields) |
PATCH |
Fusiona campos con el objeto existente. |
ref.push(value) |
POST |
Agrega un hijo con ID autogenerado. Devuelve el ID. |
ref.remove() |
DELETE |
Elimina la ruta. Si es solo la colección, la elimina completa. |
Las referencias son inmutables y navegables: ref.child('a/b'), ref.parent, ref.key, ref.path.
Requiere que la configuración tenga realtimeUrl, que RobleApiConfig.fromContract() compone automáticamente.
Suscripciones en tiempo real #
Escuchar cambios abre un WebSocket contra el host de realtime. Ambas escuchas son Stream, así que se cancelan con cancel().
// Valor del nodo: emite al suscribirse y tras cada cambio.
final sub = db.realtime.ref('messages/general').onValue.listen((valor) {
setState(() => mensajes = valor);
});
await sub.cancel();
Para el evento crudo, sin releer nada:
final sub = db.realtime.ref('messages/general').onEvent.listen((e) {
print('${e.operation.name} en ${e.pathString}: ${e.newValue}');
});
| Miembro | Descripción |
|---|---|
ref.onValue |
Stream con el valor actual del nodo al suscribirse y tras cada cambio. |
ref.onEvent |
Stream<RobleRealtimeEvent>: operation, path, pathString, oldValue, newValue, raw. |
db.realtime.status |
RobleRealtimeStatus.disconnected/connecting/connected/error. |
db.realtime.onStatusChange |
Callback en cada cambio de estado. |
db.realtime.close() |
Cierra el socket y cancela todas las escuchas. |
Una escucha recibe los cambios de su ruta y de sus descendientes. El socket se abre solo cuando hay al menos una escucha, se comparte entre todas, se resuscribe al reconectar y se cierra cuando no queda ninguna.
onValue relee el nodo por REST tras cada evento. El newValue del servidor es parcial y no distingue PATCH (fusiona) de PUT (sobrescribe), así que reconstruirlo en el cliente daría resultados incorrectos tras un set(). Si solo necesitas el evento, onEvent no hace ninguna petición extra.
⚠️
set()solo acepta objetos y mapas/listas. A diferencia de Firebase, el servidor rechaza un escalar como cuerpo:set(0),set(false)yset('texto')devuelven400. Para guardar un valor suelto, envuélvelo:ref.set({'valor': 0}).
❌ Manejo de errores #
Todas las llamadas lanzan una excepción que hereda de RobleApiException, así que puedes capturar el tipo concreto para reaccionar de forma distinta a cada fallo:
| Excepción | Cuándo se lanza | Mensaje |
|---|---|---|
RobleApiNetworkException |
Sin red o DNS no resuelto | Sin conexión a internet |
RobleApiTimeoutException |
La petición supera los 30 s | Tiempo de espera agotado |
RobleApiFormatException |
La respuesta no se puede parsear | Respuesta con formato inválido |
RobleApiHttpException |
El servidor responde con un código fuera de 2xx | El message del servidor (o el cuerpo crudo). Expone además statusCode. |
RobleApiAuthException |
No hay refresh token, el refresco falla o su respuesta es inválida | Token expirado y no se pudo refrescar: … |
RobleApiException |
Cualquier otro error inesperado | Error inesperado: … |
try {
final usuarios = await db.read('usuarios');
} on RobleApiHttpException catch (e) {
debugPrint('El servidor respondió ${e.statusCode}: ${e.message}');
} on RobleApiAuthException catch (e) {
debugPrint('Sesión expirada: ${e.message}');
// redirigir al login…
} on RobleApiNetworkException {
debugPrint('Revisa tu conexión.');
} on RobleApiException catch (e) {
debugPrint('Fallo la lectura: ${e.message}');
}
Captura siempre RobleApiException al final como red de seguridad: es la clase base de todas las anteriores.
🧪 Testing #
El constructor acepta un http.Client inyectado, lo que permite probar sin red:
import 'package:http/testing.dart';
import 'package:http/http.dart' as http;
final db = RobleApiDataBase(
config: RobleApiConfig.fromStrings(
baseAuthUrl: 'https://fake/auth/proj',
baseDataUrl: 'https://fake/database/proj',
),
client: MockClient((request) async {
return http.Response('[{"_id":"1","nombre":"Ana"}]', 200);
}),
);
final usuarios = await db.read('usuarios');
📱 Ejemplo completo #
El directorio example/ contiene una app Flutter que ejercita registro, login, logout, creación de tabla e inserción, y el ciclo CRUD completo, mostrando un log de cada operación.
cd example
flutter run
🛠️ Contribuciones #
Las contribuciones son bienvenidas. Si encuentras un bug o quieres proponer una mejora:
Resumen #
roble es un cliente ligero para Flutter que simplifica las peticiones HTTPS hacia la plataforma Roble. No abstrae la lógica de negocio del backend: su objetivo es facilitar el consumo de endpoints estandarizados (auth + CRUD) con manejo consistente de errores y facilidad para testing.
¡Las contribuciones y mejoras son muy bienvenidas! 🚀