Rust es un lenguaje tremendamente versátil, prueba de esto es que podemos hacer videojuegos, ciencias de datos, crypto, aplicaciones de sistemas o como en el caso que nos ocupa; web apps y APIs.
Hola soy Hector Pulido y en este blogpost te voy a enseñar a crear un Blog con Rust, te llevare desde la elección de la librerías hasta el deploy (en heroku por que toi chikito 🤕).
Curiosamente en Rust no tenemos un equivalente de Django, más bien tenemos varios Flasks (si vuestro paralelo es Python, como en mi caso). En este caso vamos a usar un web framework llamado Actix combinado con un ORM llamado diesel y un manager de templates llamado Tera.
Como aprendimos en un post pasado sobre Rust; vamos a generar un nuevo proyecto de Rust usando el comando cargo new mi_web_app
si no tienes instalado Cargo/Rust revisa el post anterior.
Vamos a trabajar primero con Actix, para añadirlo puedes abrir tu archivo cargo.toml
y añadir las siguientes líneas:
[dependencies]
actix-web = "3"
con actix instalado en el proyecto puedes ir a tu archivo main.rs
y añadir estas líneas:
use actix_web::{get, App, HttpResponse, HttpServer, Responder};
#[get("/")]
async fnhello() ->impl Responder {
HttpResponse::Ok().body("Hello world!")
}
#[actix_web::main]
async fnmain() -> std::io::Result<()> {
HttpServer::new(move || App::new().service(hello))
.bind("127.0.0.1:8080")?
.run()
.await
}
Con esto ahora tenemos un hola mundo web listo para funcionar, carga tu programa con cargo run
y abre http://127.0.0.1:8080/
, si has entendido esto, ¡felicidades! ahora es tiempo de subir un poco el nivel, intenta cambiar un poco el código para añadir otro endpoint, uno que te permita añadir un parámetro en la url, puedes encontrar más info en la documentación de actix.
Base de datos y ORM Diesel
Dejemos los endpoints por un momento de lado, vamos con la base de datos, necesito que tengas acceso a alguna una base de datos postgres abierta y disponible, vamos a usar el ORM Diesel, nos permitirá conectarnos a la base de datos y usarla como si de objetos e instancias se tratase. Añadamos a el cargo.toml
las siguientes líneas:
[dependencies]
...
diesel = { version = "1.4.4", features = ["postgres", "r2d2"] }
dotenv = "0.15.0"
Crea un archivo llamado .env
en la carpeta raíz del proyecto y ponemos una linea con:
DATABASE_URL=TU_URL_DE_POSTGRES
Para iniciar vamos a instalar Diesel cli con este comando cargo install diesel_cli --no-default-features --features postgres
(asegurate de tener postgres instalado) la instalacion es muy sencilla, solo sigue los pasos.
Para iniciar hagamos un setup del proyecto, inicializamos el schema con diesel setup
dentro de la carpeta del proyecto, crearemos una primera migración con diesel migration generate posts
esto generará nuestra migración. Se generará una carpeta con un nombre raro como 2021-08-11-105320_posts
dentro dos scripts de Rust llamados up.rs
y down.rs
dentro de up pondremos:
CREATE TABLE posts (
id SERIAL PRIMARY KEY,
title VARCHAR NOT NULL,
slug varchar NOT NULL unique,
body TEXT NOT NULL
)
Como te darás cuenta es SQL simple, crear una tablita llamada posts
, este es casi casi el único sql que escribiremos a partir de ahora; las migraciones. Para correr la migración podemos hacerlo con diesel migration run
, Esto generará un archivo schema.rs
con una estructura parecida a esta:
table! {
posts (id) {
id -> Int4,
title -> Varchar,
slug -> Varchar,
body -> Text,
}
}
Agreguemos a nuestro main.rs las funciones de base de datos
// Nuevas dependencias
pubmod schema;
#[macro_use]
externcrate diesel;
externcrate dotenv;
use diesel::pg::PgConnection;
use diesel::r2d2::{self, ConnectionManager};
use dotenv::dotenv;
use std::env;
// Antiguas dependencias
use actix_web::{get, App, HttpResponse, HttpServer, Responder};
use diesel::r2d2::Pool;
...
pubtypeDbPool = r2d2::Pool>;
#[actix_web::main]
async fnmain() -> std::io::Result<()> {
// Variables de entorno
dotenv().ok();
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be setted");
// Creamos el manager de la base de datos
let connection = ConnectionManager::::new(database_url);
let pool = Pool::builder() // Creamos una pool de conexiones
.build(connection)
.expect("Failed to create pool.");
// Esta es la parte que más cambia
HttpServer::new(move || App::new().data(pool.clone()).service(hello))
.bind("127.0.0.1:8080")?
.run()
.await
}
Vamos a manejar muchísimo json así que vamos a importar lo que necesitamos en el archivo cargo.tml
[dependencies]
...
serde = "1.0.126"
serde_json = "1.0"
json = "0.12"
Crearemos un nuevo archivo llamado [models.rs](http://models.rs)
en donde tendremos lo siguiente
use super::schema::posts;
use diesel::pg::PgConnection;
use diesel::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Serialize, Deserialize, Debug, Queryable)]
pubstructPost {
pub id: i32,
pub title: String,
pub slug: String,
pub body: String,
}
#[derive(Insertable)]
#[table_name = "posts"]
pubstructNewPost<'a> {
pub title: &'astr,
pub body: &'astr,
pub slug: &'astr,
}
#[derive(Clone, Serialize, Deserialize, Debug)]
pubstructNewPostHandler {
pub title: String,
pub body: String,
}
impl Post {
pubfnslugify(title: &String) ->String {
return title.replace(" ", "-").to_lowercase();
}
pubfncreate_post<'a>(
conn: &PgConnection,
post: &NewPostHandler,
) ->Result {
let slug = Post::slugify(&post.title.clone());
let post = NewPost {
title: &post.title,
body: &post.body,
slug: &slug,
};
diesel::insert_into(posts::table)
.values(post)
.get_result(conn)
}
}
Parece mucho texto pero todo es necesario, Post
es el objeto completo, puede parecer que NewPost
es redundante, pero es necesario para definir que datos obligatorios al crear el Post
, id por ejemplo es automático, así que no es necesario, es lo que se llama un insertable. NewPostHandler
definitivamente parece aún más redundante, pero lo necesitamos para simplificar el manejador de json. Por ultimo hacemos la inserción con la función create_post
usamos la función slugify
para demostrar que también podemos introducir logica en el “insertador”.
Nuestro primer endpoint, vamos a crear el primer blogpost
Y ya estamos listos para terminar nuestro primer endpoint, el endpoint de crear posts, va a ser un endpoint llamado /new_post
con el verbo POST
use models::NewPostHandler;
...
#[post("/new_post")]
async fnnew_post(pool: web::Data, post_data: web::Json) ->impl Responder {
let conn = pool.get().expect("could not get db connection");
match web::block(move || Post::create_post(&conn, &post_data)).await {
Ok(data) => HttpResponse::Ok().json(data),
Err(err) => HttpResponse::InternalServerError().body(err.to_string()),
}
}
hay cosillas que no hemos explicado por falta de tiempo, como web::Json
que representa el formato que necesita el json para ser valido, pool.get()
que es la forma con la que obtenemos una conexión a la base de datos o web::block(
que es lo que usamos para bloquear el thread porque diesel no soporta al 100% el asincronismo, pero confio en tu capacidad para entender todo lo demas.
Also, no olvides registrar el new_post
en la app:
HttpServer::new(move || {
App::new()
.data(pool.clone())
.service(index)
.service(post)
.service(new_post) // Justo así
})
.bind("127.0.0.1:8080")?
.run()
.await
Podemos probar hacer una petición con postman a http://127.0.0.1:8080/new_post/
con el verbo POST y el body:
{
"title": "Como hacer un blog con rust",
"body": "Este es un increible post sobre como usar rust"
}
Y debemos recibir algo así
{
"id": 1,
"title": "Como hacer un blog con rust",
"slug": "como-hacer-un-blog-con-rust",
"body": "Este es un increible post sobre como usar rust"
}
Si, lo sé, increíble, impresionante, maravilloso… pero ahora vamos a buscar la forma de mostrar estos posts que hemos hecho, para ello vamos a por nuestro segundo endpoint. Este es un poco más largo pero sigue la misma estructura: query, match por si hay algún error y por ultimo render.
use diesel::prelude::*;
use schema::posts::dsl::*;
...
#[get("/{blog_slug}/")]// define el endpoint pero con un parametro
async fnpost(pool: web::Data, web::Path(blog_slug): web::Path<String>) ->impl Responder {
let conn = pool.get().expect("could not get db connection");
match web::block(move || {
//Query
posts
.filter(slug.eq(blog_slug))
.limit(1)
.load::(&conn)
})
.await
{
//Match por si hay errores
Ok(data) => {
// Si no existe, retornamos 404
if data.len() == 0 {
return HttpResponse::NotFound().finish();
}
// Si existe, renderizamos el primero
return HttpResponse::Ok().json(&data[0]);
}
Err(err) => HttpResponse::InternalServerError().body(err.to_string()),
}
}
Nota como estamos haciendo las queries, esta sentencia:
posts
.filter(slug.eq(blog_slug))
.limit(1)
.load::(&conn)
Es equivalente a este SQL:
SELECT * FROM posts WHERE slug = '{blog_slug}'LIMIT1
De nuevo, no olvides registrar el endpoint en la app… Ahora si entramos a http://127.0.0.1:8080/como-hacer-un-blog-con-rust/
desde el navegador (o postman con el verbo GET) deberías obtener algo así
{
"id":1,
"title":"Como hacer un blog con rust",
"slug":"como-hacer-un-blog-con-rust",
"body":"Este es un increible post sobre como usar rust"
}
Si, adivinaste, es exactamente el mismo post que acabamos de crear ¿no es increíble? ya que estamos on fire, vamos a crear rápidamente la lista de posts (la de el index), otro endpoint de verbo GET casi, casi igual que el anterior:
#[get("/")]
async fnindex(pool: web::Data) ->impl Responder {
let conn = pool.get().expect("could not get db connection");
//Query
match web::block(move || posts.order(id.desc()).load::(&conn)).await {
//Match
Ok(data) => HttpResponse::Ok().json(data), // render
Err(err) => HttpResponse::InternalServerError().body(err.to_string()),
}
}
Si vamos al navegador a http://127.0.0.1:8080/
deberíamos obtener lo siguiente (he agregado algunos posts más):
[
{
"id":4,
"title":"Debes compartir este post con todos tus amigos",
"slug":"debes-compartir-este-post-con-todos-tus-amigos",
"body":"O si no, el equipo del blog me golpeará y no me dejaran subir mas blogs D:"
},
{
"id":3,
"title":"Como hacer un blog con rust 2",
"slug":"como-hacer-un-blog-con-rust-2",
"body":"Este es un increible post sobre como usar rust"
},
{
"id":1,
"title":"Como hacer un blog con rust",
"slug":"como-hacer-un-blog-con-rust",
"body":"Este es un increible post sobre como usar rust"
}
]
Templates o como hacer que nuestra web se vea wonita
Vale, estoy seguro de que ahora mismo estas muy sorprendide por lo que hemos logrado, pero hasta ahora tenemos una simple API, no una WEB, vamos a añadir un sistema de templates como Jinja2 en Python, este se llama Tera.
Para empezar vamos a crear una carpeta llamada Templates en la raíz del proyecto, adentro vamos a añadir 2 archivos html, index.html
y post.html
index.html
va a tener:
<htmllang="es">
<head>
<metacharset="UTF-8">
<metahttp-equiv="X-UA-Compatible"content="IE=edge">
<metaname="viewport"content="width=device-width, initial-scale=1.0">
<title>Mi hermoso blogtitle>
head>
<body>
<main>
{% for post in posts %}
<div>
<aclass="h1"href="/{{ post.slug }}/">{{ post.title }}a>
<p>{{ post.body | truncate(length=150) }}p>
div>
{% endfor %}
main>
body>
html>
No voy a marear mucho con el html por que no soy frontend, así que lo haremos muy sencillo (y feo) solo quiero que te fijes en las estructuras de control como el for
o el truncate
Por su parte post.html
va a tener:
<htmllang="es">
<head>
<metacharset="UTF-8">
<metahttp-equiv="X-UA-Compatible"content="IE=edge">
<metaname="viewport"content="width=device-width, initial-scale=1.0">
<title>Mi hermoso blogtitle>
head>
<body>
<main>
<div>
<h1>{{ post.title }}h1>
<p>{{ post.body }}p>
<ahref="/">Volvera>
div>
main>
body>
html>
Para añadir Tera al proyecto… bueno ya te la sabes en cargo.toml
...
tera = "1.10.0"
En nuestro [main.rs](http://main.rs)
vamos a alterar ligeramente la app para que reciba a Tera
use tera::Tera;
...
HttpServer::new(move || {
// Esta es la carpeta de nuestros templates
let tera = Tera::new(concat!(env!("CARGO_MANIFEST_DIR"), "/templates/**/*")).unwrap();
App::new()
.data(tera) // aquí añadimos nuestro template manager
.data(pool.clone())
.service(index)
.service(post)
.service(new_post)
})
.bind("127.0.0.1:8080")?
.run()
.await
...
Sip, nuestros endpoints tienen que cambiar ligeramente para hacerlos compatibles con Tera, nuestro endpoint de post
queda con esta estructura
#[get("/{blog_slug}/")]
async fnpost(
tmpl: web::Data, // Añadimos el template
pool: web::Data,
web::Path(blog_slug): web::Path<String>,
) ->impl Responder {
let conn = pool.get().expect("could not get db connection");
match web::block(move || {
posts
.filter(slug.eq(blog_slug))
.limit(1)
.load::(&conn)
})
.await
{
Ok(data) => {
if data.len() == 0 {
return HttpResponse::NotFound().finish();
}
//contexto; podemos acceder a estos datos desde el template
letmut ctx = tera::Context::new();
ctx.insert("post", &data[0]);
//render: Ahora usamos HTML en lugar de JSON
return HttpResponse::Ok()
.content_type("text/html")
.body(tmpl.render("post.html", &ctx).unwrap());
}
Err(err) => HttpResponse::InternalServerError().body(err.to_string()),
}
}
Como ves es bastante sencillo, te dejo como reto el endpoint de index
. Si ahora reinicias el server y entras a http://127.0.0.1:8080/como-hacer-un-blog-con-rust/
podras ver algo parecido a esto:

Deploy o vamo’ a subi’ esta vaina
Por supuesto a esto le falta un montón de cariño, pero ya podemos decir que tenemos que tenemos una web… ¿o no? bueno para decir que tenemos una web tenemos que subirla a algún lugar ¿no? en este caso usaremos heroku, un hosting gratuito.
Asumiré que ya tienes una cuenta de heroku y estas logeade, también voy a asumir que tienes instalado docker y la app de heroku en tu ordenador. Puedes entrar al dash de heroku y crear una nueva aplicación, guarda bien el nombre, lo usaremos mucho.
Vamos a resources
y donde dice Add-ons
busquemos Heroku Postgres
sigamos las instrucciones y ya tendremos una base de datos andando, si nos vamos a settings
luego clicamos en Reveal Config Vars
… ahi está, el secret de nuestra base de datos, podemos ponerlo en nuestro .env
y correr las migraciones, eso configurará la base de datos.
Vamos a agregar estas líneas en nuestro .env
...
HOST=0.0.0.0
PORT=8081
Y vamos a modificar nuestra app para que soporte el puerto y host arbitrario que nos entrega heroku:
...
dotenv().ok();
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be setted");
let host = env::var("HOST").expect("HOST must be setted");
let port = env::var("PORT").expect("PORT must be setted");
HttpServer::new(move || {
...
})
.bind(format!("{}:{}", host, port))?
.run()
.await
Vamos a crear un archivo Dockerfile
en la raiz de nuestro proyecto, tendra algo así:
# Este docker se divide en dos partes
# Esta parte descarga Rust
FROM ubuntu:18.04
RUN apt-get update && apt-get install curl pkg-config libssl-dev build-essential libpq-dev -y
RUNcurl https://sh.rustup.rs -sSf | sh -s -- -y
ENV PATH="/root/.cargo/bin:${PATH}"
# Aqui compila nuestra app
WORKDIR/app
COPY./ /app
RUNcargo build --release
# Esta parte borra lo anterior y crea una nueva maquina
FROM ubuntu:18.04
RUN apt-get update && apt-get install curl pkg-config libssl-dev build-essential libpq-dev -y
WORKDIR/app
# Guardamos unicamente el archivo compilado y el .env
COPY--from=0 /app/.env /app
COPY--from=0 /app/target/release/mi_web_app /app
COPY/templates/ /app/templates
CMD./mi_web_app
Necesitamos también un .dockerignore
a menos que queramos esperar 50 años por deploy
target/
Cargo.lock
Ya tenemos todo listo para correr:
- Vamos a comenzar buildeando nuestra web con
docker build -t web:latest .
- Luego vamos a correr el contenedor con
docker run -d --name
-e "PORT=8765" -e "DEBUG=0" -p 8007:8765 web:latest - Vamos a logearnos en docker
heroku login
heroku container:login
heroku container:push web -a
heroku container:release web -a
Si hiciste todo correcto ahora deberías poder acceder a https://
puedes agregar posts y verlos, claro aún te falta un millon de cosas por hacer para tener un blog completo, Usuarios, Roles, Frontend más bonito y SOBRE TODO, por favor refactoriza todo este espagueti que hicimos, aquí está si quieres ver el proyecto terminado y así quedaría la web final.
Espero que te haya gustado este post, por favor comparte este post por todas partes para que me dejen seguir subiendo posts estonoesunabromatienenamifamilia 👀.
Curso de Rust básico