A muchos programadores les intimida la curva de aprendizaje de Rust (en este artículo os damos una breve introducción a este lenguaje), pero la verdad es que no necesitas ser un experto ni controlar todos sus entresijos para hacer una API Rest con todas las funcionalidades habituales. Aquí te lo enseño.

Los ingredientes de la receta…

Es habitual en el desarrollo web que, junto al lenguaje de programación escogido, se elija un framework. En este caso, hemos elegido Actix-web, que además de ser práctico de usar, es sencillo y uno de los más rápidos que existen (mayor número de respuestas por segundo). Como Actix es unopinionated (sin opinión, en la lengua de Cervantes), lo que significa que no tiene un ORM por defecto, hemos escogido Diesel, el más utilizado para bases de datos SQL. La base de datos que hemos escogido para el proyecto es PostgreSQL, pero otras también nos valdrían. Este va a ser el primero de una serie de artículos relacionados, ya que quiero cubrir varios aspectos, desde queries complejas a gestión de errores personalizada.

Estructura del proyecto y explicación

Hemos hecho un pequeño proyecto de ejemplo que podéis encontrar aquí. Consiste en un repositorio de libros, con la información básica del mismo, su autor y el contenido de sus páginas. El proyecto se ha estructurado por capas:

Capas del proyecto

El lector puede ver algo particular, y es que no hay una capa de repositorio o DAO. Esto es debido a que por el funcionamiento de Diesel resulta más oportuno escribir las llamadas a base de datos en el modelo, como veremos más adelante. En la carpeta migrations vamos a guardar los scripts de BBDD que se necesitan para recrearla desde 0. Diesel tiene la maravillosa funcionalidad de mantener un registro de los cambios que se han hecho y aplicar los que se requieren.

Hablemos ahora de la estructura del modelo:

estructura del modelo

Un libro tiene múltiples páginas y un autor, y cada autor puede haber publicado varios libros.

El SQL del modelo es el siguiente:

CREATE TABLE IF NOT EXISTS books (
  id SERIAL PRIMARY KEY,
  title VARCHAR NOT NULL,
 author_id SERIAL REFERENCES(author)
);
CREATE TABLE IF NOT EXISTS pages (
  id SERIAL PRIMARY KEY,
  page_number INT NOT NULL,
  content TEXT NOT NULL,
  book_id SERIAL REFERENCES books(id)
);

CREATE TABLE IF NOT EXISTS authors (
     id SERIAL PRIMARY KEY,
     name VARCHAR NOT NULL
);

La magia de Diesel es que a partir de este fichero .sql es capaz de generar un schema.rs que contiene una serie de macros que a su vez nos permiten tener anotaciones para nuestras estructuras de datos. Lo entenderemos al verlo, primero, mostramos el schema que nos genera Diesel:

// @generated automatically by Diesel CLI.

diesel::table! {
    authors (id) {
        id -> Int4,
        name -> Varchar,
    }
}

diesel::table! {
    books (id) {
        id -> Int4,
        title -> Varchar,
        author_id -> Int4,
    }
}

diesel::table! {
    pages (id) {
        id -> Int4,
        page_number -> Int4,
        content -> Text,
        book_id -> Int4,
    }
}

diesel::joinable!(books -> authors (author_id));
diesel::joinable!(pages -> books (book_id));

diesel::allow_tables_to_appear_in_same_query!(
    authors,
    books,
    pages
);

En Rust, los nombres acabados en ! son macros, y básicamente son funciones que generan código Rust en tiempo de compilación. Las macros nos permiten, a su vez, otro tipo de macros que utilizaremos en nuestras estructuras. Es un poco complicado, pero básicamente son como las anotaciones de Java, pero muy superiores en rendimiento, como trataré en otro artículo. Pero centrémonos en ver cómo queda nuestro modelo. Por una parte, tenemos Author:

#[derive(Queryable, Identifiable, Selectable, Serialize)]
#[diesel(table_name = authors)]
#[derive(Queryable, Identifiable, Selectable, Serialize)]
#[diesel(table_name = authors)]
pub struct Author {
    pub id: i32,
    pub name: String,
}

#[derive(Insertable, Deserialize)]
#[diesel(table_name = authors)]
pub struct NewAuthor {
    pub name: String,
}

impl Author {

    pub fn create_author(new_author: NewAuthor, conn: &mut PgConnection) -> Result<Author, String> {

        let result: QueryResult<Author> = new_author.insert_into(author_table)
    .get_result(conn);
        match result {
            Ok(author) => Ok(author),
            Err(err) => match err {
                DatabaseError(_kind, info) =>
                    Err(info.message().to_owned()),
                _ => Err(format!("unknown error"))
            }
        }
    }
}

Expliquemos el código anterior. Lo que estamos haciendo con esas anotaciones en derive es permitirnos usar una serie de métodos out of the box de Diesel para hacer queries contra la Base de Datos. En concreto, hemos creado una struct NewBook con los parámetros indispensables para crear un nuevo registro en la tabla books y lo anotamos con Insertable. Esta anotación hace que NewBook herede el Trait Insertable con todos sus métodos. Eso nos permite llamar el método insert_into para cualquier NewAuthor, como tenemos en el método create_author. insert_into nos insertará el registro correspondiente al objeto en la tabla que se le indique. Se nos devuelve un QueryResult, básicamente una estructura que contiene dos posibles Enum, Ok si ha ido bien (y el Author correspondiente) o Err si ha ido mal (y el error correspondiente). La palabra clave match nos permite decidir qué hacer con cada posible resultado, y hemos elegido mapear el posible error a una cadena de texto.

El author_service es simple, ya que no hay lógica adicional, sirve de pasarela al controller.

//author_service
pub fn create_author(new_author: NewAuthor, pool: &web::Data<Pool>) -> Result<Author, String> {
    Author::create_author(new_author, &mut pool.get().unwrap())
}
//author_controller

#[post("/")]
pub async fn create_author(request: Json<NewAuthor>, pool: Data<Pool>) -> HttpResponse {    
    let result: Result<Author, String> =  author_service::create_author(request.0, &pool);
    match result {
            Ok(author) => HttpResponse::Ok().json(author),
            Err(message) => HttpResponse::BadRequest().body(message)
    }
}

Volvemos a utilizar match para devolver el registro autor serializado como json si ha sido la operación con un código Http Ok o bien un BadRequest y el mensaje si ha fallado. En esta última pieza de código hay varias cosas a tener en cuenta. Por un lado, el trait Deserialize que le habíamos añadido a Author a través de la macro Derive es lo que permite que podamos aplicar la función .json sobre él, puesto que es la forma en la que el compilador de Rust sabrá cómo convertir los binarios a JSON. Por otro lado, tenemos la macro post de Actix-web, que marca la función create_author como el interceptor de las llamadas HTTP tipo POST a la dirección relativa “/”. El parámetro request recibirá el body de la llamada deserializado en un NewAuthor (Actix devuelve BadRequest si no se ha podido deserializar) y, por otra parte, recibimos el pool de conexiones a la base de datos desde el AppData de Actix. Pero veremos esto más adelante. Antes, debemos completar nuestro controller. No basta con añadirle la macro post para que Actix sepa a donde enviar las llamadas. Para eso, vamos a crear un config_service donde modificamos el ServiceConfig de base de Actix para registrar los endpoints:

use actix_web::web::{self,scope};
use crate::controller::author_controller::*;

pub fn config_services(cfg: &mut web::ServiceConfig) {
    cfg..service(scope("/authors")
        .service(create_author));
}

Aquí registramos nuestros endpoints y sus correspondientes rutas relativas. En el caso de create_author, vemos que está localizado debajo de “/authors”. Aún nos queda algo, debemos aplicar esa función que acabamos de crear. Esto lo hacemos en el main del proyecto:

#[actix_web::main]
async fn main() -> std::io::Result<()> {

    dotenv().ok();
    let db_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
    let app_host = env::var("APP_HOST").expect("APP_HOST not found.");
    let app_port  = env::var("APP_PORT").expect("APP_PORT not found.");
    let app_url = format!("{}:{}", &app_host, &app_port);
    let pool = config::db::init_db_pool(&db_url);

    HttpServer::new(move || {
        App::new()
            .app_data(web::Data::new(pool.clone()))
            .configure(config::app::config_services)
    })
        .bind(&app_url)?
        .run()
        .await
}

En el fichero main.rs (obligatorio en el proyecto) debe haber una función main asíncrona y que devuelva un Result para que Actix pueda exponer los endpoints Http. Y es dentro de esta función donde especificamos el pool de conexiones a base de datos, host del servidor y la configuración que hemos creado antes. En app_data pasamos el pool de conexiones que hemos definido en db.rs:

use diesel::{
    pg::PgConnection,
    r2d2::{self, ConnectionManager},
};

pub type Connection = PgConnection;

pub type Pool = r2d2::Pool<ConnectionManager<Connection>>;

pub fn init_db_pool(url: &str) -> Pool {
    use log::info;

    info!("Connecting to database...");
    let manager = ConnectionManager::<Connection>::new(url);
    let pool = r2d2::Pool::builder()
        .build(manager)
        .expect("Failed to create pool.");

    pool
}

Conclusiones

Hemos visto que con unas bases de programación web no es muy difícil hacer una pequeña API en Rust. En siguientes artículos veremos cómo podemos mejorar la gestión de errores que hemos visto cuando hemos creado un autor, como añadir el CRUD de los libros y relacionar cada uno de ellos con su autor y sus páginas.

Cuéntanos qué te parece.

Los comentarios serán moderados. Serán visibles si aportan un argumento constructivo. Si no estás de acuerdo con algún punto, por favor, muestra tus opiniones de manera educada.

Suscríbete

Estamos comprometidos.

Tecnología, personas e impacto positivo.