5 |
6 | Welcome to the this workshop! In this hands-on workshop, we will guide you through the process of building a full stack application using Rust for the API, Actix-Web as the web framework, SQLx for database connectivity, Dioxus for the front-end, and Shuttle for deployment. This workshop assumes that you have a basic understanding of Rust and its syntax.
7 |
8 | Throughout the workshop, you will learn how to set up a Rust project with Actix-Web, implement CRUD operations for movies, establish database connectivity with PostgreSQL using SQLx, design a responsive front-end with Dioxus, and deploy the application to a hosting environment using Shuttle.
9 |
10 | By the end of the workshop, you will have built a functional movie collection manager application. You will understand how to create APIs with Actix-Web, work with databases using SQLx, design and develop the front-end with Dioxus, and deploy the application using Shuttle. This workshop will provide you with practical experience and insights into building full stack applications with Rust.
11 |
12 | ## Workshop Guide
13 |
14 | You can find the workshop guide [here](https://bcnrust.github.io/devbcn-workshop/).
15 |
--------------------------------------------------------------------------------
/Shuttle.toml:
--------------------------------------------------------------------------------
1 | name = "devbcn"
2 |
--------------------------------------------------------------------------------
/api.http:
--------------------------------------------------------------------------------
1 | # @host = https://devbcn.shuttleapp.rs
2 | @host = http://localhost:8080
3 | @film_id = 6f05e5f2-133c-11ee-be9f-0ab7e0d8c876
4 |
5 | ### health
6 | GET {{host}}/api/health HTTP/1.1
7 |
8 | ### create film
9 | POST {{host}}/api/v1/films HTTP/1.1
10 | Content-Type: application/json
11 |
12 | {
13 | "title": "Death in Venice",
14 | "director": "Luchino Visconti",
15 | "year": 1971,
16 | "poster": "https://th.bing.com/th/id/R.0d441f68f2182fd7c129f4e79f6a66ef?rik=h0j7Ecvt7NBYrg&pid=ImgRaw&r=0"
17 | }
18 |
19 | ### update film
20 | PUT {{host}}/api/v1/films HTTP/1.1
21 | Content-Type: application/json
22 |
23 | {
24 | "id": "{{film_id}}",
25 | "title": "Death in Venice",
26 | "director": "Benjamin Britten",
27 | "year": 1981,
28 | "poster": "https://image.tmdb.org/t/p/original//tmT12hTzJorZxd9M8YJOQOJCqsP.jpg"
29 | }
30 |
31 | ### get all films
32 | GET {{host}}/api/v1/films HTTP/1.1
33 |
34 | ### get film
35 | GET {{host}}/api/v1/films/{{film_id}} HTTP/1.1
36 |
37 | ### get bad film
38 | GET {{host}}/api/v1/films/356e42a8-e659-406f-98 HTTP/1.1
39 |
40 |
41 | ### delete film
42 | DELETE {{host}}/api/v1/films/{{film_id}} HTTP/1.1
43 |
--------------------------------------------------------------------------------
/api/actix/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "api-actix"
3 | version = "0.1.0"
4 | edition = "2021"
5 | publish = false
6 |
7 | [dependencies]
8 | # internal
9 | api-lib = { workspace = true }
10 | # db
11 | sqlx = { workspace = true }
12 | # actix
13 | actix-web = { workspace = true }
14 | actix-files = { workspace = true }
15 | actix-cors = "0.7.0"
16 | # utils
17 | dotenv = "0.15"
18 | # tracing
19 | tracing = { workspace = true }
20 | tracing-subscriber = { version = "0.3", features = [
21 | "env-filter",
22 | "json",
23 | "time",
24 | ] }
25 |
--------------------------------------------------------------------------------
/api/actix/src/main.rs:
--------------------------------------------------------------------------------
1 | use actix_cors::Cors;
2 | use actix_web::{web, App, HttpServer};
3 |
4 | #[actix_web::main]
5 | async fn main() -> std::io::Result<()> {
6 | // init env vars
7 | dotenv::dotenv().ok();
8 | // init tracing subscriber
9 | let tracing = tracing_subscriber::fmt()
10 | .with_timer(tracing_subscriber::fmt::time::UtcTime::rfc_3339())
11 | .with_env_filter(tracing_subscriber::EnvFilter::from_default_env());
12 |
13 | if cfg!(debug_assertions) {
14 | tracing.pretty().init();
15 | } else {
16 | tracing.json().init();
17 | }
18 |
19 | // building address
20 | let port = std::env::var("PORT").unwrap_or("8080".to_string());
21 | let address = format!("127.0.0.1:{}", port);
22 |
23 | // repository
24 | let repo = get_repo().await.expect("Couldn't get the repository");
25 | let repo = web::Data::new(repo);
26 | tracing::info!("Repository initialized");
27 |
28 | // starting the server
29 | tracing::info!("🚀🚀🚀 Starting Actix server at {}", address);
30 |
31 | // static files
32 | let static_folder = std::env::var("STATIC_FOLDER").unwrap_or("./front/dist".to_string());
33 |
34 | HttpServer::new(move || {
35 | // CORS
36 | let cors = Cors::permissive();
37 |
38 | App::new()
39 | .wrap(cors)
40 | .service(
41 | web::scope("/api")
42 | .app_data(repo.clone())
43 | .configure(api_lib::health::service)
44 | .configure(
45 | api_lib::v1::service::,
46 | ),
47 | )
48 | .service(
49 | actix_files::Files::new("/", &static_folder)
50 | .show_files_listing()
51 | .index_file("index.html"),
52 | )
53 | })
54 | .bind(&address)
55 | .unwrap_or_else(|err| {
56 | panic!(
57 | "🔥🔥🔥 Couldn't start the server in port {}: {:?}",
58 | port, err
59 | )
60 | })
61 | .run()
62 | .await
63 | }
64 |
65 | async fn get_repo() -> Result {
66 | let conn_str =
67 | std::env::var("DATABASE_URL").map_err(|e| sqlx::Error::Configuration(Box::new(e)))?;
68 | let pool = sqlx::PgPool::connect(&conn_str).await?;
69 | Ok(api_lib::film_repository::PostgresFilmRepository::new(pool))
70 | }
71 |
--------------------------------------------------------------------------------
/api/db/schema.sql:
--------------------------------------------------------------------------------
1 | CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
2 |
3 | CREATE TABLE IF NOT EXISTS films
4 | (
5 | id uuid DEFAULT uuid_generate_v1() NOT NULL CONSTRAINT films_pkey PRIMARY KEY,
6 | title text NOT NULL,
7 | director text NOT NULL,
8 | year smallint NOT NULL,
9 | poster text NOT NULL,
10 | created_at timestamp with time zone default CURRENT_TIMESTAMP,
11 | updated_at timestamp with time zone
12 | );
13 |
--------------------------------------------------------------------------------
/api/lib/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "api-lib"
3 | version = "0.1.0"
4 | edition = "2021"
5 | publish = false
6 |
7 | [dependencies]
8 | shared = { workspace = true, features = ["backend"] }
9 |
10 | # db
11 | sqlx = { workspace = true }
12 | # actix
13 | actix-web = { workspace = true }
14 | # serde
15 | serde = { workspace = true }
16 | serde_json = "1.0"
17 | # utils
18 | uuid = { workspace = true }
19 | chrono = { workspace = true }
20 | async-trait = "0.1.82"
21 | tracing = { workspace = true }
22 |
23 | [dev-dependencies]
24 | actix-rt = "2"
25 | mockall = "0.12.1"
26 |
--------------------------------------------------------------------------------
/api/lib/src/film_repository/mod.rs:
--------------------------------------------------------------------------------
1 | mod memory_film_repository;
2 | mod postgres_film_repository;
3 |
4 | pub use memory_film_repository::MemoryFilmRepository;
5 | pub use postgres_film_repository::PostgresFilmRepository;
6 |
7 | use async_trait::async_trait;
8 | use shared::models::{CreateFilm, Film};
9 | use uuid::Uuid;
10 |
11 | pub type FilmError = String;
12 | pub type FilmResult = Result;
13 |
14 | #[cfg_attr(test, mockall::automock)]
15 | #[async_trait]
16 | pub trait FilmRepository: Send + Sync + 'static {
17 | async fn get_films(&self) -> FilmResult>;
18 | async fn get_film(&self, id: &Uuid) -> FilmResult;
19 | async fn create_film(&self, id: &CreateFilm) -> FilmResult;
20 | async fn update_film(&self, id: &Film) -> FilmResult;
21 | async fn delete_film(&self, id: &Uuid) -> FilmResult;
22 | }
23 |
--------------------------------------------------------------------------------
/api/lib/src/film_repository/postgres_film_repository.rs:
--------------------------------------------------------------------------------
1 | use async_trait::async_trait;
2 | use shared::models::{CreateFilm, Film};
3 | use uuid::Uuid;
4 |
5 | use super::{FilmRepository, FilmResult};
6 |
7 | pub struct PostgresFilmRepository {
8 | pool: sqlx::PgPool,
9 | }
10 |
11 | impl PostgresFilmRepository {
12 | pub fn new(pool: sqlx::PgPool) -> Self {
13 | Self { pool }
14 | }
15 | }
16 |
17 | #[async_trait]
18 | impl FilmRepository for PostgresFilmRepository {
19 | async fn get_films(&self) -> FilmResult> {
20 | sqlx::query_as::<_, Film>(
21 | r#"
22 | SELECT id, title, director, year, poster, created_at, updated_at
23 | FROM films
24 | "#,
25 | )
26 | .fetch_all(&self.pool)
27 | .await
28 | .map_err(|e| e.to_string())
29 | }
30 |
31 | async fn get_film(&self, film_id: &uuid::Uuid) -> FilmResult {
32 | sqlx::query_as::<_, Film>(
33 | r#"
34 | SELECT id, title, director, year, poster, created_at, updated_at
35 | FROM films
36 | WHERE id = $1
37 | "#,
38 | )
39 | .bind(film_id)
40 | .fetch_one(&self.pool)
41 | .await
42 | .map_err(|e| e.to_string())
43 | }
44 |
45 | async fn create_film(&self, create_film: &CreateFilm) -> FilmResult {
46 | sqlx::query_as::<_, Film>(
47 | r#"
48 | INSERT INTO films (title, director, year, poster)
49 | VALUES ($1, $2, $3, $4)
50 | RETURNING id, title, director, year, poster, created_at, updated_at
51 | "#,
52 | )
53 | .bind(&create_film.title)
54 | .bind(&create_film.director)
55 | .bind(create_film.year as i16)
56 | .bind(&create_film.poster)
57 | .fetch_one(&self.pool)
58 | .await
59 | .map_err(|e| e.to_string())
60 | }
61 |
62 | async fn update_film(&self, film: &Film) -> FilmResult {
63 | sqlx::query_as::<_, Film>(
64 | r#"
65 | UPDATE films
66 | SET title = $2, director = $3, year = $4, poster = $5
67 | WHERE id = $1
68 | RETURNING id, title, director, year, poster, created_at, updated_at
69 | "#,
70 | )
71 | .bind(film.id)
72 | .bind(&film.title)
73 | .bind(&film.director)
74 | .bind(film.year as i16)
75 | .bind(&film.poster)
76 | .fetch_one(&self.pool)
77 | .await
78 | .map_err(|e| e.to_string())
79 | }
80 |
81 | async fn delete_film(&self, film_id: &uuid::Uuid) -> FilmResult {
82 | sqlx::query_scalar::<_, Uuid>(
83 | r#"
84 | DELETE FROM films
85 | WHERE id = $1
86 | RETURNING id
87 | "#,
88 | )
89 | .bind(film_id)
90 | .fetch_one(&self.pool)
91 | .await
92 | .map_err(|e| e.to_string())
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/api/lib/src/health.rs:
--------------------------------------------------------------------------------
1 | use actix_web::{
2 | web::{self, ServiceConfig},
3 | HttpResponse,
4 | };
5 |
6 | pub const API_VERSION: &str = "v0.0.3";
7 |
8 | pub fn service(cfg: &mut ServiceConfig) {
9 | cfg.route("/health", web::get().to(health_check));
10 | }
11 |
12 | async fn health_check() -> HttpResponse {
13 | HttpResponse::Ok()
14 | .append_header(("health-check", API_VERSION))
15 | .finish()
16 | }
17 |
18 | #[cfg(test)]
19 | mod tests {
20 | use actix_web::http::StatusCode;
21 |
22 | use super::*;
23 |
24 | #[actix_rt::test]
25 | async fn health_check_works() {
26 | let res = health_check().await;
27 | assert!(res.status().is_success());
28 | assert_eq!(res.status(), StatusCode::OK);
29 | let data = res
30 | .headers()
31 | .get("health-check")
32 | .and_then(|h| h.to_str().ok());
33 | assert_eq!(data, Some(API_VERSION));
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/api/lib/src/lib.rs:
--------------------------------------------------------------------------------
1 | pub mod film_repository;
2 | pub mod health;
3 | pub mod v1;
4 |
--------------------------------------------------------------------------------
/api/lib/src/v1/films.rs:
--------------------------------------------------------------------------------
1 | use actix_web::{
2 | web::{self, ServiceConfig},
3 | HttpResponse,
4 | };
5 | use shared::models::{CreateFilm, Film};
6 | use uuid::Uuid;
7 |
8 | use crate::film_repository::FilmRepository;
9 |
10 | pub fn service(cfg: &mut ServiceConfig) {
11 | cfg.service(
12 | web::scope("/films")
13 | // GET
14 | .route("", web::get().to(get_all::))
15 | .route("/{film_id}", web::get().to(get::))
16 | // POST
17 | .route("", web::post().to(post::))
18 | // PUT
19 | .route("", web::put().to(put::))
20 | // DELETE
21 | .route("/{film_id}", web::delete().to(delete::)),
22 | );
23 | }
24 |
25 | async fn get_all(repo: web::Data) -> HttpResponse {
26 | match repo.get_films().await {
27 | Ok(films) => HttpResponse::Ok().json(films),
28 | Err(e) => HttpResponse::NotFound().body(format!("Internal server error: {:?}", e)),
29 | }
30 | }
31 |
32 | async fn get(film_id: web::Path, repo: web::Data) -> HttpResponse {
33 | match repo.get_film(&film_id).await {
34 | Ok(film) => HttpResponse::Ok().json(film),
35 | Err(_) => HttpResponse::NotFound().body("Not found"),
36 | }
37 | }
38 |
39 | async fn post(
40 | create_film: web::Json,
41 | repo: web::Data,
42 | ) -> HttpResponse {
43 | match repo.create_film(&create_film).await {
44 | Ok(film) => HttpResponse::Ok().json(film),
45 | Err(e) => {
46 | HttpResponse::InternalServerError().body(format!("Internal server error: {:?}", e))
47 | }
48 | }
49 | }
50 |
51 | async fn put(film: web::Json, repo: web::Data) -> HttpResponse {
52 | match repo.update_film(&film).await {
53 | Ok(film) => HttpResponse::Ok().json(film),
54 | Err(e) => HttpResponse::NotFound().body(format!("Internal server error: {:?}", e)),
55 | }
56 | }
57 |
58 | async fn delete(film_id: web::Path, repo: web::Data) -> HttpResponse {
59 | match repo.delete_film(&film_id).await {
60 | Ok(film) => HttpResponse::Ok().json(film),
61 | Err(e) => {
62 | HttpResponse::InternalServerError().body(format!("Internal server error: {:?}", e))
63 | }
64 | }
65 | }
66 |
67 | #[cfg(test)]
68 | mod tests {
69 |
70 | use super::*;
71 | use crate::film_repository::MockFilmRepository;
72 | use actix_web::body::to_bytes;
73 | use chrono::Utc;
74 |
75 | pub fn create_test_film(id: Uuid, title: String) -> Film {
76 | Film {
77 | id,
78 | title,
79 | director: "Director test name".to_string(),
80 | year: 2001,
81 | poster: "Poster test name".to_string(),
82 | created_at: Some(Utc::now()),
83 | updated_at: None,
84 | }
85 | }
86 |
87 | #[actix_rt::test]
88 | async fn get_all_works() {
89 | let film_id = uuid::Uuid::new_v4();
90 | let film_title1 = "Film test title1";
91 | let film_title2 = "Film test title2";
92 |
93 | let mut repo = MockFilmRepository::default();
94 | repo.expect_get_films().returning(move || {
95 | let film = create_test_film(film_id, film_title1.to_string());
96 | let film2 = create_test_film(film_id, film_title2.to_string());
97 | Ok(vec![film, film2])
98 | });
99 |
100 | let result = get_all(web::Data::new(repo)).await;
101 |
102 | let body = to_bytes(result.into_body()).await.unwrap();
103 | let films = serde_json::from_slice::<'_, Vec>(&body).unwrap();
104 |
105 | assert_eq!(films.len(), 2);
106 | assert_eq!(films[0].title, film_title1);
107 | assert_eq!(films[1].title, film_title2);
108 | }
109 |
110 | #[actix_rt::test]
111 | async fn get_works() {
112 | let film_id = uuid::Uuid::new_v4();
113 | let film_title = "Film test title";
114 |
115 | let mut repo = MockFilmRepository::default();
116 | repo.expect_get_film().returning(move |id| {
117 | let film = create_test_film(*id, film_title.to_string());
118 | Ok(film)
119 | });
120 |
121 | let result = get(web::Path::from(film_id), web::Data::new(repo)).await;
122 |
123 | let body = to_bytes(result.into_body()).await.unwrap();
124 | let film = serde_json::from_slice::<'_, Film>(&body).unwrap();
125 |
126 | assert_eq!(film.id, film_id);
127 | assert_eq!(film.title, film_title);
128 | }
129 |
130 | #[actix_rt::test]
131 | async fn create_works() {
132 | let film_id = uuid::Uuid::new_v4();
133 | let title = "Film test title";
134 | let create_film = CreateFilm {
135 | title: title.to_string(),
136 | director: "Director test name".to_string(),
137 | year: 2001,
138 | poster: "Poster test name".to_string(),
139 | };
140 |
141 | let mut repo = MockFilmRepository::default();
142 | repo.expect_create_film().returning(move |create_film| {
143 | Ok(Film {
144 | id: film_id,
145 | title: create_film.title.to_owned(),
146 | director: create_film.director.to_owned(),
147 | year: create_film.year,
148 | poster: create_film.poster.to_owned(),
149 | created_at: Some(Utc::now()),
150 | updated_at: None,
151 | })
152 | });
153 |
154 | let result = post(web::Json(create_film), web::Data::new(repo)).await;
155 |
156 | let body = to_bytes(result.into_body()).await.unwrap();
157 | let film = serde_json::from_slice::<'_, Film>(&body).unwrap();
158 |
159 | assert_eq!(film.id, film_id);
160 | assert_eq!(film.title, title);
161 | }
162 |
163 | #[actix_rt::test]
164 | async fn update_works() {
165 | let film_id = uuid::Uuid::new_v4();
166 | let film_title = "Film test title";
167 | let new_film = create_test_film(film_id, film_title.to_string());
168 |
169 | let mut repo = MockFilmRepository::default();
170 | repo.expect_update_film()
171 | .returning(|film| Ok(film.to_owned()));
172 |
173 | let result = put(web::Json(new_film), web::Data::new(repo)).await;
174 |
175 | let body = to_bytes(result.into_body()).await.unwrap();
176 | let film = serde_json::from_slice::<'_, Film>(&body).unwrap();
177 |
178 | assert_eq!(film.id, film_id);
179 | assert_eq!(film.title, film_title);
180 | }
181 |
182 | #[actix_rt::test]
183 | async fn delete_works() {
184 | let film_id = uuid::Uuid::new_v4();
185 |
186 | let mut repo = MockFilmRepository::default();
187 | repo.expect_delete_film().returning(|id| Ok(id.to_owned()));
188 |
189 | let result = delete(web::Path::from(film_id), web::Data::new(repo)).await;
190 |
191 | let body = to_bytes(result.into_body()).await.unwrap();
192 | let uuid = serde_json::from_slice::<'_, Uuid>(&body).unwrap();
193 |
194 | assert_eq!(uuid, film_id);
195 | }
196 | }
197 |
--------------------------------------------------------------------------------
/api/lib/src/v1/mod.rs:
--------------------------------------------------------------------------------
1 | use actix_web::web::{self, ServiceConfig};
2 |
3 | use crate::film_repository::FilmRepository;
4 |
5 | mod films;
6 |
7 | pub fn service(cfg: &mut ServiceConfig) {
8 | cfg.service(web::scope("/v1").configure(films::service::));
9 | }
10 |
--------------------------------------------------------------------------------
/api/lib/tests/health.rs:
--------------------------------------------------------------------------------
1 | mod integration {
2 |
3 | use actix_web::{http::StatusCode, App};
4 | use api_lib::health::{service, API_VERSION};
5 |
6 | #[actix_rt::test]
7 | async fn health_check_works() {
8 | let app = App::new().configure(service);
9 | let mut app = actix_web::test::init_service(app).await;
10 | let req = actix_web::test::TestRequest::get()
11 | .uri("/health")
12 | .to_request();
13 | let res = actix_web::test::call_service(&mut app, req).await;
14 | assert!(res.status().is_success());
15 | assert_eq!(res.status(), StatusCode::OK);
16 | let data = res
17 | .headers()
18 | .get("health-check")
19 | .and_then(|h| h.to_str().ok());
20 | assert_eq!(data, Some(API_VERSION));
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/api/shuttle/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "api-shuttle"
3 | version = "0.1.0"
4 | edition = "2021"
5 | publish = false
6 |
7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
8 |
9 | [dependencies]
10 | # internal
11 | api-lib = { workspace = true }
12 | # shuttle
13 | shuttle-runtime = "0.47.0"
14 | shuttle-actix-web = "0.47.0"
15 | # db
16 | # shuttle-aws-rds = { version = "0.18.0", features = ["postgres"] }
17 | shuttle-shared-db = { version = "0.47.0", features = ["postgres", "sqlx"] }
18 | sqlx = { workspace = true }
19 | # actixs
20 | actix-web = { workspace = true }
21 | actix-files = { workspace = true }
22 | tokio = "1.28.2"
23 |
--------------------------------------------------------------------------------
/api/shuttle/src/main.rs:
--------------------------------------------------------------------------------
1 | use actix_files::Files;
2 | use actix_web::web::{self, ServiceConfig};
3 | use shuttle_actix_web::ShuttleActixWeb;
4 | use shuttle_runtime::CustomError;
5 | use sqlx::Executor;
6 |
7 | #[shuttle_runtime::main]
8 | async fn actix_web(
9 | #[shuttle_shared_db::Postgres] pool: sqlx::PgPool,
10 | ) -> ShuttleActixWeb {
11 | // initialize the database if not already initialized
12 | pool.execute(include_str!("../../db/schema.sql"))
13 | .await
14 | .map_err(CustomError::new)?;
15 |
16 | // create a film repository. In this case for postgres.
17 | let film_repository = api_lib::film_repository::PostgresFilmRepository::new(pool);
18 | let film_repository = web::Data::new(film_repository);
19 |
20 | // start the service
21 | let config = move |cfg: &mut ServiceConfig| {
22 | cfg.service(
23 | web::scope("/api")
24 | .app_data(film_repository)
25 | .configure(api_lib::health::service)
26 | .configure(
27 | api_lib::v1::service::,
28 | ),
29 | )
30 | .service(Files::new("/", "static").index_file("index.html"));
31 | };
32 |
33 | Ok(config.into())
34 | }
35 |
--------------------------------------------------------------------------------
/docs/.gitignore:
--------------------------------------------------------------------------------
1 | book
2 |
--------------------------------------------------------------------------------
/docs/README.md:
--------------------------------------------------------------------------------
1 | # Book
2 |
3 | We're using [mdbook](https://rust-lang.github.io/mdBook/) in order to create our documentation.
4 |
5 | If you want to `serve` or `build` the book locally, bear in mind that it will not work unless you copy the `README.md` from the root of this repository in the `docs/src` folder.
6 |
7 | To avoid having to do this manually, and because we want to keep the root `README.md` file as the source of truth, we have created some [cargo-make](https://github.com/sagiegurari/cargo-make) tasks that will automate this process:
8 |
9 | - `makers book-serve`
10 | - `makers book-build`
11 |
12 | ## Preprocessors
13 |
14 | We are using some mdbook plugins:
15 |
16 | - [mdbook-mermaid](https://github.com/badboy/mdbook-mermaid)
17 | - [mdbook-toc](https://github.com/badboy/mdbook-toc)
18 | - [mdbook-admonish](https://github.com/tommilligan/mdbook-admonish)
19 |
20 | If you use the `cargo-make` commands above, you don't need to worry about installing them, as they will be installed automatically.
21 |
--------------------------------------------------------------------------------
/docs/book.toml:
--------------------------------------------------------------------------------
1 | [book]
2 | authors = ["Alberto Méndez", "Roberto Huertas"]
3 | language = "en"
4 | multilingual = false
5 | src = "src"
6 | title = "Rust Full Stack Workshop"
7 |
8 | [build]
9 | create-missing = true
10 |
11 | [output.html]
12 | default-theme = "light"
13 | preferred-dark-theme = "ayu"
14 | # copy-fonts = true
15 | # additional-css = ["custom.css", "custom2.css"]
16 | # additional-js = ["custom.js"]
17 | # no-section-label = false
18 | git-repository-url = "https://github.com/bcnrust/devbcn-workshop"
19 | additional-js = ["src/assets/mermaid.min.js", "src/assets/mermaid-init.js"]
20 | additional-css = ["src/assets/mdbook-admonish.css"]
21 |
22 | [preprocessor]
23 |
24 | [preprocessor.toc]
25 | command = "mdbook-toc"
26 | renderer = ["html"]
27 | max-level = 4
28 |
29 | [preprocessor.mermaid]
30 | command = "mdbook-mermaid"
31 |
32 | [preprocessor.admonish]
33 | command = "mdbook-admonish"
34 | assets_version = "3.0.2" # do not edit: managed by `mdbook-admonish install`
35 | # git-repository-icon = "fa-github"
36 | # edit-url-template = "https://github.com/rust-lang/mdBook/edit/master/guide/{path}"
37 | # site-url = "/example-book/"
38 | # cname = "myproject.rs"
39 | # input-404 = "not-found.md"
40 |
--------------------------------------------------------------------------------
/docs/src/README.md:
--------------------------------------------------------------------------------
1 | # Building a Movie Collection Manager - Full Stack Workshop with Rust, Actix, SQLx, Dioxus, and Shuttle
2 |
3 |
4 |
5 |
6 | Welcome to the this workshop! In this hands-on workshop, we will guide you through the process of building a full stack application using Rust for the API, Actix-Web as the web framework, SQLx for database connectivity, Dioxus for the front-end, and Shuttle for deployment. This workshop assumes that you have a basic understanding of Rust and its syntax.
7 |
8 | Throughout the workshop, you will learn how to set up a Rust project with Actix-Web, implement CRUD operations for movies, establish database connectivity with PostgreSQL using SQLx, design a responsive front-end with Dioxus, and deploy the application to a hosting environment using Shuttle.
9 |
10 | By the end of the workshop, you will have built a [functional movie collection manager application](https://devbcn.shuttleapp.rs/). You will understand how to create APIs with Actix-Web, work with databases using SQLx, design and develop the front-end with Dioxus, and deploy the application using Shuttle. This workshop will provide you with practical experience and insights into building full stack applications with Rust.
11 |
12 | ## Prerequisites:
13 |
14 | - Basic knowledge of the Rust programming language
15 | - Familiarity with HTML, CSS, and JavaScript is helpful but not required
16 |
17 | Check the [Prerequisites](./prerequisites.md) section of the workshop guide for more details.
18 |
19 | **Workshop Duration: 4,5 hours**
20 |
21 | ## Workshop schedule
22 |
23 | ```txt
24 | 1. Knowing the audience and installing everything
25 | - Introduction to the workshop
26 | - Installing Rust, Cargo, and other dependencies
27 |
28 | 2. Building the API with Actix-Web, SQLx and Shuttle
29 | - Introduction to Shuttle, Actix-Web and its features
30 | - Setting up and deploying an Actix-Web project
31 | - Establishing database connectivity with SQLx
32 | - Creating API endpoints for movie listing
33 | - Implementing CRUD operations for movies
34 |
35 | 3. Designing the Front-End with Dioxus
36 | - Introduction to Dioxus
37 | - Setup and installation
38 | - Components
39 | - State management
40 | - Event handling
41 | - Building
42 | ```
43 |
44 | ```admonish info
45 | The revised workshop schedule incorporates deployment with Shuttle, allowing participants to learn how to prepare and deploy the application to a hosting environment.
46 | ```
47 |
48 | ## Repository Structure:
49 |
50 | ```txt
51 | ├── api # Rust API code
52 | │ ├── lib # Actix-Web API code
53 | │ └── shuttle # Shuttle project
54 | ├── front # Dioxus front-end code
55 | ├── shared # Common code shared between the API and the front-end
56 | └── README.md # Workshop instructions and guidance
57 | ```
58 |
59 | ## Resources:
60 |
61 | - Rust: https://www.rust-lang.org/
62 | - Actix-Web: https://actix.rs/
63 | - SQLx: https://github.com/launchbadge/sqlx
64 | - Dioxus: https://dioxuslabs.com/
65 | - Shuttle: https://www.shuttle.rs/
66 |
67 | We hope you enjoy the workshop and gain valuable insights into building full stack applications with Rust, Actix, SQLx, Dioxus, and Shuttle. If you have any questions or need assistance, please don't hesitate to ask during the workshop. Happy coding!
68 |
69 |
70 |
--------------------------------------------------------------------------------
/docs/src/SUMMARY.md:
--------------------------------------------------------------------------------
1 | # Summary
2 |
3 | [Introduction](./README.md)
4 |
5 | # Before you start
6 |
7 | - [Prerequisites](./prerequisites.md)
8 |
9 | # Backend
10 | - [Backend](./backend/00_backend.md)
11 | - [Workspace Setup](./backend/01_workspace_setup.md)
12 | - [Exploring Shuttle](./backend/02_shuttle.md)
13 | - [Deploying with Shuttle](./backend/03_deploying_with_shuttle.md)
14 | - [Shuttle CLI and Console](./backend/04_shuttle_cli_console.md)
15 | - [Working with a Database](./backend/05_working_with_a_database.md)
16 | - [Setting up the Database](./backend/06_setting_up_the_database.md)
17 | - [Connecting to the Database](./backend/07_connecting_the_database.md)
18 | - [Deploying the Database](./backend/08_deploying_the_database.md)
19 | - [Debugging](./backend/09_debugging.md)
20 | - [Instrumentation](./backend/10_instrumentation.md)
21 | - [Watch Mode](./backend/11_watch_mode.md)
22 | - [Moving our endpoints to a library](./backend/12_library.md)
23 | - [Creating a health check endpoint](./backend/13_health_check.md)
24 | - [Using the configure method](./backend/14_configure_method.md)
25 | - [Unit and Integration tests](./backend/15_testing.md)
26 | - [Films endpoints](./backend/16_films_endpoints.md)
27 | - [Models](./backend/17_models.md)
28 | - [Serde](./backend/18_serde.md)
29 | - [Film Repository](./backend/19_film_repository.md)
30 | - [Implementing Film Repository](./backend/20_implementing_trait.md)
31 | - [Injecting the repository](./backend/21_injecting_repository.md)
32 | - [Implementing the endpoints](./backend/22_implementing_endpoints.md)
33 | - [Static dispatch](./backend/23_static_dispatching.md)
34 | - [Serving static files](./backend/24_serving_static_files.md)
35 | - [Bonus: Makefile.toml](./backend/25_makefile_toml.md)
36 |
37 | # Frontend
38 | - [Frontend](./frontend/03_frontend.md)
39 | - [Setup](./frontend/03_01_setup.md)
40 | - [Starting the Application](./frontend/03_02_app_startup.md)
41 | - [Components](./frontend/03_03_components.md)
42 | - [Layout Components](./frontend/03_03_01_layout.md)
43 | - [Reusable Components](./frontend/03_03_02_reusable_components.md)
44 | - [State management](./frontend/03_04_state_management.md)
45 | - [Global state](./frontend/03_04_01_global_state.md)
46 | - [Local state](./frontend/03_04_02_local_state.md)
47 | - [App effects](./frontend/03_04_03_effects.md)
48 | - [Event handlers](./frontend/03_05_event_handlers.md)
49 | - [Building](./frontend/03_06_building.md)
50 |
--------------------------------------------------------------------------------
/docs/src/assets/backend/01/cargo_build.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BcnRust/devbcn-workshop/0f45130131914a5458066a03ca19923807913ca1/docs/src/assets/backend/01/cargo_build.png
--------------------------------------------------------------------------------
/docs/src/assets/backend/01/gitignore.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BcnRust/devbcn-workshop/0f45130131914a5458066a03ca19923807913ca1/docs/src/assets/backend/01/gitignore.png
--------------------------------------------------------------------------------
/docs/src/assets/backend/01/workspace_error.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BcnRust/devbcn-workshop/0f45130131914a5458066a03ca19923807913ca1/docs/src/assets/backend/01/workspace_error.png
--------------------------------------------------------------------------------
/docs/src/assets/backend/02/cargo_shuttle_run.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BcnRust/devbcn-workshop/0f45130131914a5458066a03ca19923807913ca1/docs/src/assets/backend/02/cargo_shuttle_run.png
--------------------------------------------------------------------------------
/docs/src/assets/backend/03/deployed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BcnRust/devbcn-workshop/0f45130131914a5458066a03ca19923807913ca1/docs/src/assets/backend/03/deployed.png
--------------------------------------------------------------------------------
/docs/src/assets/backend/03/login_error.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BcnRust/devbcn-workshop/0f45130131914a5458066a03ca19923807913ca1/docs/src/assets/backend/03/login_error.png
--------------------------------------------------------------------------------
/docs/src/assets/backend/03/login_shuttle.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BcnRust/devbcn-workshop/0f45130131914a5458066a03ca19923807913ca1/docs/src/assets/backend/03/login_shuttle.png
--------------------------------------------------------------------------------
/docs/src/assets/backend/03/login_shuttle_terminal.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BcnRust/devbcn-workshop/0f45130131914a5458066a03ca19923807913ca1/docs/src/assets/backend/03/login_shuttle_terminal.png
--------------------------------------------------------------------------------
/docs/src/assets/backend/03/login_terminal.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BcnRust/devbcn-workshop/0f45130131914a5458066a03ca19923807913ca1/docs/src/assets/backend/03/login_terminal.png
--------------------------------------------------------------------------------
/docs/src/assets/backend/03/login_with_github.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BcnRust/devbcn-workshop/0f45130131914a5458066a03ca19923807913ca1/docs/src/assets/backend/03/login_with_github.png
--------------------------------------------------------------------------------
/docs/src/assets/backend/03/project_not_found_error.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BcnRust/devbcn-workshop/0f45130131914a5458066a03ca19923807913ca1/docs/src/assets/backend/03/project_not_found_error.png
--------------------------------------------------------------------------------
/docs/src/assets/backend/03/project_started.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BcnRust/devbcn-workshop/0f45130131914a5458066a03ca19923807913ca1/docs/src/assets/backend/03/project_started.png
--------------------------------------------------------------------------------
/docs/src/assets/backend/03/shuttle_toml.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BcnRust/devbcn-workshop/0f45130131914a5458066a03ca19923807913ca1/docs/src/assets/backend/03/shuttle_toml.png
--------------------------------------------------------------------------------
/docs/src/assets/backend/05/docker_error.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BcnRust/devbcn-workshop/0f45130131914a5458066a03ca19923807913ca1/docs/src/assets/backend/05/docker_error.png
--------------------------------------------------------------------------------
/docs/src/assets/backend/05/local_connectionstring.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BcnRust/devbcn-workshop/0f45130131914a5458066a03ca19923807913ca1/docs/src/assets/backend/05/local_connectionstring.png
--------------------------------------------------------------------------------
/docs/src/assets/backend/06/table_created.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BcnRust/devbcn-workshop/0f45130131914a5458066a03ca19923807913ca1/docs/src/assets/backend/06/table_created.png
--------------------------------------------------------------------------------
/docs/src/assets/backend/08/cloud_database.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BcnRust/devbcn-workshop/0f45130131914a5458066a03ca19923807913ca1/docs/src/assets/backend/08/cloud_database.png
--------------------------------------------------------------------------------
/docs/src/assets/backend/08/console_resources.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BcnRust/devbcn-workshop/0f45130131914a5458066a03ca19923807913ca1/docs/src/assets/backend/08/console_resources.png
--------------------------------------------------------------------------------
/docs/src/assets/backend/09/breakpoint.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BcnRust/devbcn-workshop/0f45130131914a5458066a03ca19923807913ca1/docs/src/assets/backend/09/breakpoint.png
--------------------------------------------------------------------------------
/docs/src/assets/backend/09/breakpoint_hit.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BcnRust/devbcn-workshop/0f45130131914a5458066a03ca19923807913ca1/docs/src/assets/backend/09/breakpoint_hit.png
--------------------------------------------------------------------------------
/docs/src/assets/backend/16/send_request.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BcnRust/devbcn-workshop/0f45130131914a5458066a03ca19923807913ca1/docs/src/assets/backend/16/send_request.png
--------------------------------------------------------------------------------
/docs/src/assets/backend/22/sql_log.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BcnRust/devbcn-workshop/0f45130131914a5458066a03ca19923807913ca1/docs/src/assets/backend/22/sql_log.png
--------------------------------------------------------------------------------
/docs/src/assets/bcnrust.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BcnRust/devbcn-workshop/0f45130131914a5458066a03ca19923807913ca1/docs/src/assets/bcnrust.png
--------------------------------------------------------------------------------
/docs/src/assets/devbcn.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BcnRust/devbcn-workshop/0f45130131914a5458066a03ca19923807913ca1/docs/src/assets/devbcn.png
--------------------------------------------------------------------------------
/docs/src/assets/ferris.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BcnRust/devbcn-workshop/0f45130131914a5458066a03ca19923807913ca1/docs/src/assets/ferris.png
--------------------------------------------------------------------------------
/docs/src/assets/frontend-final.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BcnRust/devbcn-workshop/0f45130131914a5458066a03ca19923807913ca1/docs/src/assets/frontend-final.png
--------------------------------------------------------------------------------
/docs/src/assets/hacker.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BcnRust/devbcn-workshop/0f45130131914a5458066a03ca19923807913ca1/docs/src/assets/hacker.jpg
--------------------------------------------------------------------------------
/docs/src/assets/mermaid-init.js:
--------------------------------------------------------------------------------
1 | mermaid.initialize();
2 |
--------------------------------------------------------------------------------
/docs/src/assets/movie_collection.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BcnRust/devbcn-workshop/0f45130131914a5458066a03ca19923807913ca1/docs/src/assets/movie_collection.jpg
--------------------------------------------------------------------------------
/docs/src/assets/workshop.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BcnRust/devbcn-workshop/0f45130131914a5458066a03ca19923807913ca1/docs/src/assets/workshop.jpg
--------------------------------------------------------------------------------
/docs/src/backend/00_backend.md:
--------------------------------------------------------------------------------
1 | # Backend
2 |
3 | The goal of this part of the workshop is to create a simple API that will be used by the front-end.
4 |
5 | ## Tools and Frameworks
6 |
7 | - [Actix Web](https://actix.rs/)
8 | - [SQLx](https://github.com/launchbadge/sqlx)
9 | - [Shuttle](https://www.shuttle.rs/)
10 |
11 | Take the time to read the documentation of each of these tools and frameworks to learn more.
12 |
13 | ## Guide
14 |
15 | If you get lost during the workshop, you can always refer to:
16 | - the workshop conductors
17 | - the [workshop GitHub repository](https://github.com/bcnrust/devbcn-workshop) which contains the final code with tests, mocks, memory database, CI/CD, etc.
18 | - the [dry-run workshop GitHub repository](https://github.com/BcnRust/devbcn-workshop-dryrun/commits/master): each commit corresponds to a step of the workshop. You will see that some sections will instruct you to commit your code. You can always refer to this repository to see what the code should look like at that point.
19 |
--------------------------------------------------------------------------------
/docs/src/backend/01_workspace_setup.md:
--------------------------------------------------------------------------------
1 | # Workspace Setup
2 |
3 | Let's start by creating a new [workspace](https://doc.rust-lang.org/book/ch14-03-cargo-workspaces.html) for our project.
4 |
5 | ```admonish tip title="_Cargo Workspaces_"
6 | You can learn more about workspaces in the [Rust Book](https://doc.rust-lang.org/book/ch14-03-cargo-workspaces.html#creating-a-workspace)
7 | ```
8 |
9 | The basic idea is that we will create a **monorepo** with different [crates](https://doc.rust-lang.org/book/ch07-01-packages-and-crates.html) that will be compiled together.
10 |
11 | Remember that our project will have this structure:
12 |
13 | ```txt
14 | ├── api # Rust API code
15 | │ ├── lib # Actix-Web API code
16 | │ └── shuttle # Shuttle project
17 | ├── front # Dioxus front-end code
18 | ├── shared # Common code shared between the API and the front-end
19 | └── README.md # Workshop instructions and guidance
20 | ```
21 |
22 | ## Creating the workspace
23 |
24 | **Create a new folder** for the project and **initialize a new workspace** by creating a `Cargo.toml` file with the following content:
25 |
26 | ```toml
27 | [workspace]
28 | members = [
29 | "api/lib",
30 | "api/shuttle",
31 | "shared"
32 | ]
33 | resolver = "2"
34 | ```
35 |
36 | We will add the `front` crate later, don't worry.
37 |
38 | ## Initializing the repository
39 |
40 | Let's initialize a new git repository for our project.
41 |
42 | ```bash
43 | git init
44 | ```
45 |
46 | ## Creating the crates
47 |
48 | Now that we have a **workspace**, we can create the crates that will be part of it.
49 |
50 | For the `API`, we will create **two crates**:
51 | - `lib`: Library containing the code for the API.
52 | - `shuttle`: Executable for the shuttle project.
53 |
54 | Having two different crates is totally optional, but it will allow us to have a cleaner project structure and will make it easy to reuse the `API` library code if we decide to not use [Shuttle](https://www.shuttle.rs/) in the future.
55 |
56 | ``` admonish tip title="Shuttle"
57 | Shuttle will allow us to run our API locally and deploy it to the cloud with minimal effort but it is not required to build the API.
58 |
59 | We could decide to use a different executable to run our API that would use the `lib` crate as a dependency. For instance, we could use Actix Web directly to create such a binary and release it as a Docker image.
60 | ```
61 |
62 | ### Creating the `lib` crate
63 |
64 | Let's create the `lib` crate by running the following command:
65 |
66 | ```bash
67 | cargo new api/lib --name api-lib --lib --vcs none
68 | ```
69 |
70 |
71 | ```admonish tip title="Cargo New"
72 | Note that we are using the `--lib` flag to create a library crate. If you forget to add this flag, you will have to manually change the `Cargo.toml` file to make it a library crate.
73 |
74 | The `vsc` flag is used in this case to tell `cargo` to not initialize a new git repository. Remember that we already did that in the previous step.
75 | ```
76 |
77 | ```admonish warning
78 | Don't worry if you receive the error message below, it is expected. We will fix it later.
79 | ```
80 |
81 | 
82 |
83 | ### Creating the `shuttle` crate
84 |
85 | Let's create the `shuttle` crate by running the following command:
86 |
87 | ```bash
88 | cargo shuttle init api/shuttle -t actix-web --name api-shuttle
89 | ```
90 |
91 | ### Creating the `shared` crate
92 |
93 | Finally, let's create the `shared` crate by running the following command:
94 |
95 | ```bash
96 | cargo new shared --lib
97 | ```
98 |
99 | ```admonish tip
100 | Note we're not using the `--name` flag this time. This is because the name of the crate will be inferred from the name of the folder.
101 | ```
102 |
103 | ## Buidling the project
104 |
105 | Let's **build the project** to make sure everything is working as expected.
106 |
107 | ```bash
108 | cargo build
109 | ```
110 |
111 | You should see something like this:
112 |
113 | 
114 |
115 | ```admonish warning
116 | Don't commit yet as we still have some work to do!
117 | ```
118 |
119 | As you can see, a new `target` folder has been created. This folder **contains the compiled code** for all the crates in the workspace and that's why we're seeing so many objects to be committed.
120 |
121 | The `target` folder should be **ignored** by git.
122 |
123 | Let's **create** a `.gitignore` file in the **root of our repo** and add the following content:
124 |
125 | ```.gitingnore
126 | target/
127 | Secrets*.toml
128 | ```
129 |
130 | Aside from that, **remove** all the `.gitignore` files from the crates as they are not needed anymore.
131 |
132 | This is how it should look like:
133 |
134 | 
135 |
136 | ## Committing the changes
137 |
138 | If you have arrived here, you can **commit** your changes.
139 |
140 | ```bash
141 | git add .
142 | git commit -m "Initial commit"
143 | ```
144 |
--------------------------------------------------------------------------------
/docs/src/backend/02_shuttle.md:
--------------------------------------------------------------------------------
1 | # Exploring Shuttle
2 |
3 | Open the `api/shuttle` folder and look for the `src/main.rs` file. This is the entry point of our application.
4 |
5 | You'll see something like this:
6 |
7 | ```rust
8 | use actix_web::{get, web::ServiceConfig};
9 | use shuttle_actix_web::ShuttleActixWeb;
10 |
11 | #[get("/")]
12 | async fn hello_world() -> &'static str {
13 | "Hello World!"
14 | }
15 |
16 | #[shuttle_runtime::main]
17 | async fn actix_web(
18 | ) -> ShuttleActixWeb {
19 | let config = move |cfg: &mut ServiceConfig| {
20 | cfg.service(hello_world);
21 | };
22 |
23 | Ok(config.into())
24 | }
25 | ```
26 |
27 | [Shuttle](https://www.shuttle.rs) has generated a simple `hello-world` [Actix Web](https://actix.rs) application for us.
28 |
29 | As you can see, it's pretty straight-forward.
30 |
31 | The `actix_web` function is the entry point of our application. It returns a `ShuttleActixWeb` instance that will be used by [Shuttle](https://www.shuttle.rs) to run our application.
32 |
33 | In this function, we're going to configure our different routes. In this case, we only have one route: `/`, which is mapped to the `hello_world` function.
34 |
35 | ## Let's run it!
36 |
37 | In the **root of the project**, run the following command:
38 |
39 | ```bash
40 | cargo shuttle run
41 | ```
42 |
43 | You should see something like this:
44 |
45 | 
46 |
47 | Now *curl* the `/` route:
48 |
49 | ```bash
50 | curl localhost:8000
51 | ```
52 |
53 | Or *open* it in your browser.
54 |
55 | Hopefully, **you should see a greeting** in your screen!
56 |
57 | And that's how easy it is to create a simple API with [Shuttle](https://www.shuttle.rs)!
58 |
59 | > Try to add more routes and see what happens!
60 |
61 | ```admonish
62 | We're using [Actix Web](https://actix.rs) as our web framework, but **you can use any other framework** supported by [Shuttle](https://www.shuttle.rs).
63 |
64 | Check out the [Shuttle documentation](https://docs.shuttle.rs/introduction/welcome) to learn more. Browse through the `Examples` section to see how to use [Shuttle](https://www.shuttle.rs) with other frameworks.
65 |
66 | At the moment of writing this, [Shuttle](https://www.shuttle.rs/) supports:
67 | - [Actix Web](https://actix.rs)
68 | - [Axum](https://github.com/tokio-rs/axum)
69 | - [Salvo](https://next.salvo.rs/)
70 | - [Poem](https://github.com/poem-web/poem)
71 | - [Thruster](https://github.com/thruster-rs/Thruster)
72 | - [Tower](https://github.com/tower-rs/tower)
73 | - [Warp](https://github.com/seanmonstar/warp)
74 | - [Rocket](https://rocket.rs)
75 | - [Tide](https://github.com/http-rs/tide)
76 |
77 | ```
78 |
--------------------------------------------------------------------------------
/docs/src/backend/03_deploying_with_shuttle.md:
--------------------------------------------------------------------------------
1 | # Deploying with Shuttle
2 |
3 | So far so good. We have a working API and we can run it locally. Now, let's deploy it to the cloud and see how easy it is to do so with Shuttle.
4 |
5 | ## Shuttle.toml file
6 |
7 | [Shuttle](https://shuttle.rs) will use **the name of the workspace directory** as the name of the project.
8 |
9 | As we don't want to collide with other people having named the folder in a similar way, we will use a `Shuttle.toml` file to override the name of the project.
10 |
11 | Go to the root of your workspace and **create a `Shuttle.toml` file** with the following content:
12 |
13 | ```toml
14 | name = "name_you_want"
15 | ```
16 |
17 | Your directory structure should look like this:
18 |
19 | 
20 |
21 | **Commit** the changes to your repository.
22 |
23 | ```bash
24 | git add .
25 | git commit -m "add Shuttle.toml file"
26 | ```
27 |
28 | ## Deploying to the cloud
29 |
30 | Now that we have a `Shuttle.toml` file, we can **deploy our API to the cloud**. To do so, run the following command:
31 |
32 | ```bash
33 | cargo shuttle deploy
34 | ```
35 |
36 | You **should get an error message** similar to this one:
37 |
38 | 
39 |
40 |
41 | ### Login to Shuttle
42 |
43 | Let's do what the previous message suggests and **run** `cargo shuttle login`.
44 |
45 | ```admonish warning
46 | Take into account that you will need to have a [GitHub](https://github.com) account to be able to login.
47 | ```
48 |
49 | The moment you run the `cargo shuttle login` command, you will be redirected to a [Shuttle](https://shuttle.rs) page like this so you can **authorize [Shuttle](https://shuttle.rs)** to access your [GitHub](https://github.com) account.
50 |
51 | 
52 |
53 | In your terminal, you should see something like this:
54 |
55 | 
56 |
57 | Continue the login process in your browser and **copy the code** you get in the **section 03** of the [Shuttle](https://shuttle.rs) page.
58 |
59 | 
60 |
61 | Then **paste the code** in your terminal and press enter.
62 |
63 | 
64 |
65 |
66 | ### Let's deploy!
67 |
68 | Now that we have logged in, we can **deploy our API to the cloud**. To do so, run the following command:
69 |
70 | ```bash
71 | cargo shuttle deploy
72 | ```
73 |
74 | Oh no! We got another **error** message:
75 |
76 | 
77 |
78 | The problem is that we haven't created the project environment yet. Let's do that now.
79 |
80 | ```bash
81 | cargo shuttle project start
82 | ```
83 |
84 | If everything went well, you should see something like this:
85 |
86 | 
87 |
88 | ```admonish tip
89 | Once you've done this, if you want to deploy again, you won't need to do this step again.
90 | ```
91 |
92 | Now, let's **finally deploy our API to the cloud** by running the following command again:
93 |
94 | ```bash
95 | cargo shuttle deploy
96 | ```
97 |
98 | You should see in your terminal how everything is being deployed and compiled in the [Shuttle](https://shuttle.rs) cloud. This can take a while, so be patient and wait for a message like the one below:
99 |
100 | 
101 |
102 | Browse to the URI shown in the message or curl it to see the result:
103 |
104 | ```bash
105 | curl https://.shuttleapp.rs
106 | ```
107 |
108 | *Hello world!* Easy, right?
109 |
110 | **We have deployed our API to the cloud!**
111 |
112 | ```admonish tip
113 | The URI of your project is predictable and will always conform to this convention: `https://.shuttleapp.rs`.
114 | ```
115 |
--------------------------------------------------------------------------------
/docs/src/backend/04_shuttle_cli_console.md:
--------------------------------------------------------------------------------
1 | # Shuttle CLI and Console
2 |
3 | ## CLI
4 |
5 | [Shuttle](https://www.shuttle.rs/) provides a [CLI](https://github.com/shuttle-hq/shuttle/tree/main/cargo-shuttles) that we can use to interact with our project. We already have used it to create the project and to deploy it to the cloud.
6 |
7 | Let's take a look at the available commands:
8 |
9 | ```bash
10 | cargo shuttle --help
11 | ```
12 |
13 | ```admonish
14 | You can also get more information by exploring the [Shuttle CLI documentation](https://github.com/shuttle-hq/shuttle/tree/main/cargo-shuttle).
15 | ```
16 |
17 | ### Interesting commands
18 |
19 | Let's take a look at some of the commands that we will use the most.
20 |
21 | - `cargo shuttle deploy`: Deploy the project to the cloud.
22 | - `cargo shuttle logs`: Display the logs of a deployment.
23 | - `cargo shuttle status`: Display the status of the service.
24 | - `cargo shuttle project status`: Display the status of the project.
25 | - `cargo shuttle project list`: Display a list of projects and their current status.
26 | - `cargo shuttle project restart`: Restart a project. Useful when you need to upgrade the version of your [Shuttle](https://shuttle.rs) dependencies.
27 | - `cargo shuttle resource list`: Display a list of resources and their current status. Useful to see connection strings and other information about the resources used by the project.
28 |
29 | ## Console
30 |
31 | [Shuttle](https://www.shuttle.rs/) also provides a [Console](https://console.shuttle.rs/) that we can use to interact with our project.
32 |
33 | It's **still in the early days** but it already provides some interesting features. For instance, we can use it to see the logs of our project.
34 |
--------------------------------------------------------------------------------
/docs/src/backend/05_working_with_a_database.md:
--------------------------------------------------------------------------------
1 | # Working with a Database
2 |
3 | For our project we will use a [PostgreSQL](https://www.postgresql.org/) database.
4 |
5 | You may be already thinking about how to provision that database both locally and in the cloud, and the amount of work that it will take to do so. But no worries, we will use [Shuttle](https://shuttle.rs) to do that for us.
6 |
7 | ## Using Shuttle Shared Databases
8 |
9 | Open [this link to the Shuttle Docs](https://docs.shuttle.rs/resources/shuttle-shared-db) and follow the instructions to create a shared database in [AWS](https://aws.amazon.com/).
10 |
11 | As you will be able to see, just by using a [macro](https://doc.rust-lang.org/reference/procedural-macros.html) we will be able to get a database connection injected into our code and a database fully provisioned both locally and in the cloud.
12 |
13 | So let's get started!
14 |
15 | ## Adding the dependencies
16 |
17 | Go to the `Cargo.toml` file in the `api > shuttle` folder and add the following dependencies to the ones you already have:
18 |
19 | ```toml
20 | [dependencies]
21 | ...
22 | # database
23 | shuttle-shared-db = { version = "0.47.0", features = ["postgres", "sqlx"] }
24 | sqlx = { version = "0.7", default-features = false, features = [
25 | "tls-native-tls",
26 | "macros",
27 | "postgres",
28 | "uuid",
29 | "chrono",
30 | "json",
31 | ] }
32 | ```
33 |
34 | ```admonish title="Cargo Dependencies"
35 | If you want to learn more about how to add dependencies to your `Cargo.toml` file, please refer to the [Cargo Docs](https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html).
36 | ```
37 |
38 | We are adding the [shuttle-shared-db](https://docs.rs/shuttle-shared-db/0.21.0/shuttle_shared_db/) dependency to get the database connection injected into our code and the [SQLx](https://github.com/launchbadge/sqlx) dependency to be able to use the database connection.
39 |
40 | Note that the [SQLx](https://github.com/launchbadge/sqlx) dependency has a lot of features enabled. We will use them later on in the project.
41 |
42 | ```admonish title="Features"
43 | If you want to learn more about features in Rust, please refer to the [Cargo Docs](https://doc.rust-lang.org/cargo/reference/features.html).
44 | ```
45 |
46 | ## Injecting the database connection
47 |
48 | Now that we have the dependencies, we need to inject the database connection into our code.
49 |
50 | Open the `main.rs` file in the `api > shuttle > src` folder and add the following code as the **first parameter of the `actix_web` function**:
51 |
52 | ```rust
53 | #[shuttle_shared_db::Postgres] pool: sqlx::PgPool,
54 | ````
55 |
56 | The function should look like this:
57 |
58 | ```rust
59 | #[shuttle_runtime::main]
60 | async fn actix_web(
61 | #[shuttle_shared_db::Postgres] pool: sqlx::PgPool,
62 | ) -> ShuttleActixWeb {
63 | let config = move |cfg: &mut ServiceConfig| {
64 | cfg.service(hello_world);
65 | };
66 |
67 | Ok(config.into())
68 | }
69 | ```
70 |
71 | Let's **build** the project. We will get a warning because we're not using the `pool` variable yet, but we will fix that in a moment.
72 |
73 | ```bash
74 | cargo build
75 | ```
76 |
77 | ## Running the project
78 |
79 | Now that we have the database connection injected into our code, we can run the project and see what happens.
80 |
81 | ```bash
82 | cargo shuttle run
83 | ```
84 |
85 | You will see that the project is building and then it will fail with the following error:
86 |
87 | 
88 |
89 | ### Docker
90 |
91 | The error is telling us that we need to have [Docker](https://www.docker.com/) running in our system.
92 |
93 | Let's **start [Docker](https://www.docker.com/)** and run the project again.
94 |
95 | ```bash
96 | cargo shuttle run
97 | ```
98 |
99 | This time the project will build and run successfully.
100 |
101 | 
102 |
103 | Note that you will be able to find the **connection string to the database in the logs**. We will use that connection string later on in the project.
104 |
105 | ```admonish example "Connect to the database"
106 | Try to connect to the database using a tool like [DBeaver](https://dbeaver.io/) or [pgAdmin](https://www.pgadmin.org/).
107 | ```
108 |
109 |
110 | Commit your changes.
111 |
112 | ```bash
113 | git add .
114 | git commit -m "add database connection"
115 | ```
116 |
--------------------------------------------------------------------------------
/docs/src/backend/06_setting_up_the_database.md:
--------------------------------------------------------------------------------
1 | # Setting up the Database
2 |
3 | In this section we will setup the database for our project.
4 |
5 | This is going to be a **very simple** [CRUD](https://en.wikipedia.org/wiki/Create,_read,_update_and_delete) application, so we will only need **one table for our movies**.
6 |
7 | ## Creating the initial script
8 |
9 | There are many ways to work with a database. We could use the [SQLx CLI](https://github.com/launchbadge/sqlx/tree/main/sqlx-cli#create-and-run-migrations) or [Refinery](https://github.com/rust-db/refinery) to create and manage our database migrations, but as this is out of the scope of this workshop, we will **create a simple script** that will create the table for us.
10 |
11 | Create a new file `api/db/schema.sql` with the following content:
12 |
13 | ```sql
14 | CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
15 |
16 | CREATE TABLE IF NOT EXISTS films
17 | (
18 | id uuid DEFAULT uuid_generate_v1() NOT NULL CONSTRAINT films_pkey PRIMARY KEY,
19 | title text NOT NULL,
20 | director text NOT NULL,
21 | year smallint NOT NULL,
22 | poster text NOT NULL,
23 | created_at timestamp with time zone default CURRENT_TIMESTAMP,
24 | updated_at timestamp with time zone
25 | );
26 | ```
27 |
28 | You can see that this script will **create a table** called `films` only **if that table does not exist** yet.
29 |
30 | ## Executing the initial script
31 |
32 | Now that we have the script, we need to execute it.
33 |
34 | Open the `main.rs` file in the `api > shuttle > src` folder and **add the following code as the first line** in the body of the `actix_web` function:
35 |
36 | ```rust
37 | // initialize the database if not already initialized
38 | pool.execute(include_str!("../../db/schema.sql"))
39 | .await
40 | .map_err(CustomError::new)?;
41 | ```
42 |
43 | Add the following **imports to the top of the file**:
44 |
45 | ```rust
46 | use shuttle_runtime::CustomError;
47 | use sqlx::Executor;
48 | ```
49 |
50 | ```admonish warning
51 | Be sure that the path to the `schema.sql` file is correct. Try changing the path to something else and see what happens when you try to compile the project: `cargo build`.
52 | ```
53 |
54 | ## Running the project
55 |
56 | Let's run the project again and see if the database is created as expected.
57 |
58 | ```bash
59 | cargo shuttle run
60 | ```
61 |
62 | If you check your database, you should see that the `films` table has been created:
63 |
64 | 
65 |
66 | Commit your changes.
67 |
68 | ```bash
69 | git add .
70 | git commit -m "setup database"
71 | ```
72 |
--------------------------------------------------------------------------------
/docs/src/backend/07_connecting_the_database.md:
--------------------------------------------------------------------------------
1 | # Connecting to the Database
2 |
3 | Now that we have everything we need, let's start by doing a simple *endpoint* that will get the database version.
4 |
5 | This will help us to get acquainted with [SQLx](https://github.com/launchbadge/sqlx).
6 |
7 | ## Creating the endpoint
8 |
9 | > Can you create the endpoint yourself?
10 |
11 | Don't worry about how to retrieve the information from the database, we will do that in a moment. Just focus on creating and endpoint that will return a string. The string can be anything you want and the route should be `/version`.
12 |
13 | If you're not sure about how to do it, expand the section below to see the solution.
14 |
15 | ~~~admonish tip title="Solution" collapsible=true
16 | Open the `main.rs` file in the `api > shuttle > src` folder and add the following code:
17 | ```rust
18 | #[get("/version")]
19 | async fn version() -> &'static str {
20 | "version 0.0.0"
21 | }
22 | ```
23 | ~~~
24 |
25 | ## Setting up the endpoint
26 |
27 | You may have noticed that if you run the project and go to the `/version` route, you will get a `404` error.
28 |
29 | ```bash
30 | curl -i http://localhost:8000/version # HTTP/1.1 404 Not Found
31 | ```
32 |
33 | This is because we haven't set up the endpoint yet.
34 |
35 | > Can you guess how to do it?
36 |
37 | ~~~admonish tip title="Solution" collapsible=true
38 | Add this to the line containing this piece of code `cfg.service(hello_world);` in the `main.rs` file in the `api > shuttle > src` folder: `.service(version)`.
39 |
40 | The line should look like this
41 |
42 | ```rust
43 | let config = move |cfg: &mut ServiceConfig| {
44 | // NOTE: this is the modified line
45 | cfg.service(hello_world).service(version);
46 | };
47 |
48 | ```
49 | ~~~
50 |
51 | Now, let's try it again:
52 |
53 | ```bash
54 | curl -i http://localhost:8000/version # HTTP/1.1 200 OK version 0.0.0
55 | ```
56 |
57 | Did it work? If so, congratulations! You have just created your first endpoint.
58 |
59 | ## Connecting to the database
60 |
61 | Now that we have the endpoint, let's connect to the database and retrieve the version.
62 |
63 | For that we will need to do a couple of things:
64 |
65 | 1. Pass the database connection pool to the endpoint.
66 | 1. Execute a query in the endpoint and return the result.
67 |
68 | ### Passing the database connection pool to the endpoint
69 |
70 | In order to pass the connection pool to the endpoints we're going to leverage the [Application State Extractor](https://actix.rs/docs/extractors#application-state-extractor) from [Actix Web](https://actix.rs/).
71 |
72 | ``` admonish info title="State in Actix Web"
73 | You can learn more about how to handle the [state in Actix Web](https://actix.rs/docs/application/#state) in the official documentation.
74 | ```
75 |
76 | Ok, so just after the line where we initialized our database, **let's add the following code**:
77 |
78 | ```rust
79 | let pool = actix_web::web::Data::new(pool);
80 | ```
81 |
82 | ```admonish info title="Variable shadowing"
83 | You may have noticed that we're using the same name for the variable that holds the connection pool and the one that holds the data. This is called [variable shadowing](https://doc.rust-lang.org/book/ch03-01-variables-and-mutability.html#shadowing).
84 | ```
85 |
86 | Now, we need to change again the line we changed before when we added a new endpoint and use the [.app_data method](https://actix.rs/docs/application#state) like this:
87 |
88 | ```rust
89 | let config = move |cfg: &mut ServiceConfig| {
90 | cfg.app_data(pool).service(hello_world).service(version);
91 | };
92 | ```
93 |
94 | ### Executing a query in the endpoint and returning the result
95 |
96 | Now let's change our `version` endpoint so we can get the connection pool from the state and execute a query. If you have taken a look to the [Application State Extractor documentation](https://actix.rs/docs/extractors#application-state-extractor) it should be pretty straightforward.
97 |
98 | We have to add a parameter to the `version` function that will be our access to the database connection pool. We will call it `db` and it will be of type `actix_web::web::Data`.
99 |
100 | ```rust
101 | #[get("/version")]
102 | async fn version(db: actix_web::web::Data) -> &'static str {
103 | "version 0.0.0"
104 | }
105 | ```
106 |
107 | Now, we need to execute a query. For that, we will use the [sqlx::query_scalar](https://docs.rs/sqlx/latest/sqlx/query/struct.QueryScalar.html) function.
108 |
109 | Let's change the `version` function to this:
110 |
111 | ```rust
112 | #[get("/version")]
113 | async fn version(db: actix_web::web::Data) -> String {
114 | let result: Result = sqlx::query_scalar("SELECT version()")
115 | .fetch_one(db.get_ref())
116 | .await;
117 |
118 | match result {
119 | Ok(version) => version,
120 | Err(e) => format!("Error: {:?}", e),
121 | }
122 | }
123 | ```
124 |
125 | There are a couple of things going on here, so let's break it down.
126 |
127 | First of all, it's worth noticing that **we changed the return type** of the function to `String`. This is for two different reasons:
128 |
129 | 1. We don't want our endpoint to fail. If the query fails, we will have to return an error message as a `String`.
130 | 1. We need that return to be a `String` because the version of the database will come to us as a `String`.
131 |
132 | On the other hand, we have the [sqlx::query_scalar](https://docs.rs/sqlx/latest/sqlx/query/struct.QueryScalar.html) function. This function will execute a query and return a single value. In our case, the version of the database.
133 |
134 | As you can see, the **query is pretty simple**. We're just selecting the version of the database. The most interesting part in there is that we need to use the `.get_ref()` method to get a **reference** to the inner `sqlx::PgPool` from the `actix_web::web::Data`.
135 |
136 | Finally, we have the [match expression](https://doc.rust-lang.org/reference/expressions/match-expr.html). The [sqlx::query_scalar](https://docs.rs/sqlx/latest/sqlx/query/struct.QueryScalar.html) function will return a [Result](https://doc.rust-lang.org/std/result/enum.Result.html) with either the version of the database or an error. With the [match expression](https://doc.rust-lang.org/reference/expressions/match-expr.html) we're covering both cases and we will make sure that we will always return a `String`.
137 |
138 | ```admonish tip
139 | Note that most of the time we don't need the return keyword in Rust. The last expression in a function will be the return value.
140 | ```
141 |
142 | ```admonish example title="Try the error"
143 | Introduce an error in the query and see what happens. Take some time to check out how the [format macro](https://doc.rust-lang.org/std/macro.format.html) works.
144 | ```
145 |
146 | Note that even if you introduce an error in the query, the endpoint will not fail or even return a 500 error. This is because we're handling the error in the match expression.
147 |
148 | We will see later how to handle errors in a more elegant way.
149 |
150 | For now, let's commit our changes:
151 |
152 | ```bash
153 | git add .
154 | git commit -m "add version endpoint"
155 | ```
156 |
--------------------------------------------------------------------------------
/docs/src/backend/08_deploying_the_database.md:
--------------------------------------------------------------------------------
1 | # Deploying the Database
2 |
3 | By now, this should be a familiar process. We'll use the same `shuttle` command we used to deploy the backend to deploy the database.
4 |
5 | ```bash
6 | cargo shuttle deploy
7 | ```
8 |
9 | As you've seen, we **don't need to do anything special to deploy the database**. [Shuttle](https://shuttle.rs) will detect that we have a database dependency in our code and will provision it for us. Neat, isn't it?
10 |
11 | ```admonish title="Infrastructure From Code"
12 | While the deployment takes place, you can take a look at this [blog post](https://www.shuttle.rs/blog/2022/05/09/ifc) to learn more about the concept of [Infrastructure From Code](https://www.shuttle.rs/blog/2022/05/09/ifc).
13 | ```
14 |
15 | Once the deployment is complete, you can **check the database connection string** in the terminal.
16 |
17 | 
18 |
19 | Don't worry if you missed it. You can always **check the database connection string** in the terminal by running the following command.
20 |
21 | ```bash
22 | cargo shuttle resource list
23 | ```
24 |
25 | You can also go to the [Shuttle Console](https://console.shuttle.rs/) and check the database connection string there.
26 |
27 | 
28 |
29 | ## Testing the new endpoint
30 |
31 | ```bash
32 | curl -i https://your-project-name.shuttleapp.rs/version
33 | ```
34 |
35 | You should get a response similar to the following.
36 |
37 | ```bash
38 | HTTP/1.1 200 OK
39 | content-length: 115
40 | content-type: text/plain; charset=utf-8
41 | date: Sat, 01 Jul 2023 16:27:07 GMT
42 | server: shuttle.rs
43 |
44 | PostgreSQL 14.8 (Debian 14.8-1.pgdg120+1) on x86_64-pc-linux-gnu, compiled by gcc (Debian 12.2.0-14) 12.2.0, 64-bit
45 | ```
46 |
47 |
--------------------------------------------------------------------------------
/docs/src/backend/09_debugging.md:
--------------------------------------------------------------------------------
1 | # Debugging
2 |
3 | In the section we are going to cover how to debug the backend using [Visual Studio Code](https://code.visualstudio.com/).
4 |
5 | Make sure that you have installed these two extensions:
6 |
7 | - [Rust Analyzer](https://marketplace.visualstudio.com/items?itemName=matklad.rust-analyzer)
8 | - [CodeLLDB](https://marketplace.visualstudio.com/items?itemName=vadimcn.vscode-lldb)
9 |
10 | Once you have them installed, **create a new file** in the root of the project called `.vscode/launch.json` with the following content:
11 |
12 | ```json
13 | {
14 | // Use IntelliSense to learn about possible attributes.
15 | // Hover to view descriptions of existing attributes.
16 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
17 | "version": "0.2.0",
18 | "configurations": [
19 | {
20 | "type": "lldb",
21 | "request": "attach",
22 | "name": "Attach to Shuttle",
23 | "program": "${workspaceFolder}/target/debug/api-shuttle"
24 | }
25 | ]
26 | }
27 | ```
28 |
29 | The most important point to take into account here is that the `program` attribute **must point to the binary that you want to debug**.
30 |
31 | So, in order to test that this is working, let's put a breakpoint in our `version` endpoint:
32 |
33 | 
34 |
35 |
36 | Now, run the project with `cargo shuttle run` and then press `F5` to start debugging.
37 |
38 | `curl` the `version` endpoint:
39 |
40 | ```bash
41 | curl -i https://localhost:8000/version
42 | ```
43 |
44 | 
45 |
46 | Commit your changes:
47 |
48 | ```bash
49 | git add .
50 | git commit -m "add debugging configuration"
51 | ```
52 |
--------------------------------------------------------------------------------
/docs/src/backend/10_instrumentation.md:
--------------------------------------------------------------------------------
1 | # Instrumentation
2 |
3 | In order to instrument our backend, we are going to use the [tracing crate](https://docs.rs/tracing/latest/tracing/).
4 |
5 | Let's add this dependency to the `Cargo.toml` file of our [Shuttle](https://shuttle.rs) crate:
6 |
7 | ```toml
8 | tracing = "0.1"
9 | ```
10 |
11 | Now, let's add some instrumentation to our `main.rs` file. Feel free to add as many logs as you want. For example:
12 |
13 | ```rust
14 | #[get("/version")]
15 | async fn version(db: actix_web::web::Data) -> String {
16 | // NOTE: added line below:
17 | tracing::info!("Getting version");
18 | let result: Result = sqlx::query_scalar("SELECT version()")
19 | .fetch_one(db.get_ref())
20 | .await;
21 |
22 | match result {
23 | Ok(version) => version,
24 | Err(e) => format!("Error: {:?}", e),
25 | }
26 | }
27 | ```
28 |
29 | If you run the application now, and hit the `version` endpoint, you should see something like this in the logs of your terminal:
30 |
31 | ```bash
32 | 2023-07-01T19:47:32.836809924+02:00 INFO api_shuttle: Getting version
33 | ```
34 |
35 | ## Log level
36 |
37 | By default, the log level is set to `info`. This means that all logs with a level of `info` or higher will be displayed. If you want to change the log level, you can do so by setting the `RUST_LOG` environment variable. For example, if you want to see all logs, you can set the log level to `trace`:
38 |
39 | ```bash
40 | RUST_LOG=trace cargo shuttle run
41 | ```
42 |
43 | ```admonish info
44 | For more information about [Telemetry and Shuttle](https://docs.shuttle.rs/introduction/telemetry), please refer to the [Shuttle documentation](https://docs.shuttle.rs/introduction/telemetry).
45 | ```
46 |
47 | Let's commit our changes:
48 |
49 | ```bash
50 | git add .
51 | git commit -m "added instrumentation"
52 | ```
53 |
--------------------------------------------------------------------------------
/docs/src/backend/11_watch_mode.md:
--------------------------------------------------------------------------------
1 | # Watch Mode
2 |
3 | You may be thinking that it would be nice to have a way to **automatically restart the backend when you make changes to the code**.
4 |
5 | Well, you're in luck! Enter [cargo-watch](https://github.com/watchexec/cargo-watch).
6 |
7 | If you don't have it already installed, you can do so by running:
8 |
9 | ```bash
10 | cargo install cargo-watch
11 | ```
12 |
13 | Next, in order to start the backend in watch mode, you can run:
14 |
15 | ```bash
16 | cargo watch -x "shuttle run"
17 | ```
18 |
19 | This will start the backend in watch mode. Now, whenever you make changes to the code, the backend will automatically restart.
20 |
21 | ```admonish example "Try it out"
22 | Test it out by making a change to the `src/main.rs` file.
23 | ```
24 |
--------------------------------------------------------------------------------
/docs/src/backend/12_library.md:
--------------------------------------------------------------------------------
1 | # Moving our endpoints to a library
2 |
3 | The idea behind this section is to **move our endpoints** to a library so that we can **reuse** them in case we want to provide a different binary that doesn't rely on [Shuttle](https://shuttle.rs).
4 |
5 | Imagine, for example, that you want to deploy your API to your cloud of choice. Most probably you'll want to use a container to do so. In that case, having our endpoints in a library will allow us to create a binary that works purely on [Actix Web](https://actix.rs).
6 |
7 | ## Adding a local dependency
8 |
9 | Remember that we already created a `lib` crate in one of the previous sections? Well, we are going to use that crate to move our endpoints there.
10 |
11 | But first of all, we need to add a dependency to our `api-shuttle` `Cargo.toml` file. We can do so by adding the following line:
12 |
13 | ```toml
14 | [dependencies]
15 | ...
16 | api-lib = { path = "../lib" }
17 | ```
18 |
19 | `api-lib` is the name we gave to that library (you can check that in the `Cargo.toml` file in the `api > lib` folder).
20 |
21 | Compile and check that you don't receive any compiler error:
22 |
23 | ```bash
24 | # just compile
25 | cargo build
26 | # or compile in watch mode
27 | cargo watch -x build
28 | # or run the binary
29 | cargo shuttle run
30 | # or run the binary in watch mode
31 | cargo watch -x "shuttle run"
32 | ```
33 |
34 | As you can see, adding a local dependency is trivial. You just need to specify the path to the library.
35 |
36 | ## Moving the endpoints
37 |
38 | Open the `api > lib > src` folder and create a new file called `health.rs`. This file will contain just one endpoint that will be used to check the health of the API, but for the sake of the example, we are going to **temporary move** our previous endpoints here.
39 |
40 | Copy the following code in `api > shuttle > src > main.rs` to our recently created file `health.rs`:
41 |
42 | ```rust
43 | #[get("/")]
44 | async fn hello_world() -> &'static str {
45 | "Hello World!"
46 | }
47 |
48 | #[get("/version")]
49 | async fn version(db: actix_web::web::Data) -> String {
50 | tracing::info!("Getting version");
51 | let result: Result = sqlx::query_scalar("SELECT version()")
52 | .fetch_one(db.get_ref())
53 | .await;
54 |
55 | match result {
56 | Ok(version) => version,
57 | Err(e) => format!("Error: {:?}", e),
58 | }
59 | }
60 | ```
61 |
62 | ```admonish
63 | If you are in **watch mode** or you try to **compile**, you will see that you don't get any kind of error. That's because the code in `health.rs` is **not being used yet**.
64 | ```
65 |
66 | Let's use it now. Open the `api > lib > src > lib.rs` file, remove all the content, and add the following line at the top of the file:
67 |
68 | ```rust
69 | pub mod health;
70 | ```
71 |
72 | A couple of things to take into account here:
73 |
74 | - `lib.rs` files are the default entrypoint for library crates.
75 | - The line we introduced in the `lib.rs` file is doing two things.
76 | - First of all, it is declaring a new module called `health` (hence the compiler will care about our `health.rs` file's content).
77 | - Secondly, it is making that module public. This means that we can access everything that we export from that module.
78 |
79 | Now, if you compile, you should be getting errors from the compiler complaining about dependencies. Let's add them to the `Cargo.toml` file:
80 |
81 | ```toml
82 | [dependencies]
83 | # actix
84 | actix-web = "4.9.0"
85 | # database
86 | sqlx = { version = "0.7", default-features = false, features = [
87 | "tls-native-tls",
88 | "macros",
89 | "postgres",
90 | "uuid",
91 | "chrono",
92 | "json",
93 | ] }
94 | # tracing
95 | tracing = "0.1"
96 | ```
97 |
98 | We will be adding more dependencies in the future, but for now, this is enough.
99 |
100 | Finally, to make the compiler happy, let's import this in the top of the `health.rs` file:
101 |
102 | ```rust
103 | use actix_web::get;
104 | ```
105 |
106 | Everything should compile by now.
107 |
108 | ```admonish
109 | Note that we're not using any [Shuttle](https://shuttle.rs) dependency in this crate.
110 | ```
111 |
112 | ## Using the endpoints
113 |
114 | Now that we have our endpoints in a library, we can use them in our `main.rs` file. Let's do that.
115 |
116 | Open the `api > shuttle > src > main.rs` file and remove the endpoints code that we copied before. Get rid of the unused `use` statements as well.
117 |
118 | > Do you know what to do next?
119 |
120 | ~~~admonish tip title="Solution" collapsible=true
121 | Yes, you only have to import the endpoints from the library. It's a one-liner:
122 |
123 | ```rust
124 | use api_lib::health::{hello_world, version};
125 | ```
126 | ~~~
127 |
128 | Is it working? It should!
129 |
130 | ```admonish example title="Actix Standalone"
131 | If you want to try out the endpoints without using [Shuttle](https://shuttle.rs), you can create a new binary crate in the `api` folder and use the endpoints there. Check the [Actix Web documentation](https://actix.rs/) for more information.
132 |
133 | This is out of the scope of this workshop because of time constraints but feel free to explore that option. You can also take a look at the [workshop's GitHub repository](https://github.com/BcnRust/devbcn-workshop) to see how to do it.
134 | ```
135 |
136 | Commit your changes:
137 |
138 | ```bash
139 | git add .
140 | git commit -m "move endpoints to a library"
141 | ```
142 |
--------------------------------------------------------------------------------
/docs/src/backend/13_health_check.md:
--------------------------------------------------------------------------------
1 | # Creating a health check endpoint
2 |
3 | We're going to get rid of the previous endpoints and create a health check endpoint. This endpoint will be used to check if the application is running and ready to receive requests.
4 |
5 | This endpoint will be very basic and will just **return a 200 OK response** with custom header containing the version (this is just for fun, not really needed at all).
6 |
7 | Armed with the knowledge we've gained so far, we should be able to handle this change.
8 |
9 |
10 | ```admonish example title="Exercise: Create a health check endpoint"
11 | - The route should be `/health` and use the `GET` method.
12 | - The response should be a `200 OK` with a custom header named `version`containing the version (a `&str` containing "v0.0.1" for example).
13 | - As a hint, you can check the code in [Actix Web docs](https://actix.rs/docs/server) to see how to return a simple `200 OK` response.
14 | - You can also check out the [HttpResponse docs](https://docs.rs/actix-web/4.9.0/actix_web/struct.HttpResponse.html) to see how to add a header to the response.
15 | ```
16 |
17 | > Can you do it?
18 |
19 | ~~~admonish tip title="Solution" collapsible=true
20 | - Remove the previous endpoints.
21 | - Create a new endpoint with the route `/health` and the method `GET`.
22 |
23 | ```rust
24 | use actix_web::{get, HttpResponse};
25 |
26 | #[get("/health")]
27 | async fn health() -> HttpResponse {
28 | HttpResponse::Ok()
29 | .append_header(("version", "0.0.1"))
30 | .finish()
31 | }
32 | ```
33 |
34 | - Configure the services in your shuttle crate. Remove the previous services and add the new one.
35 |
36 | Your `api > shuttle > src > main.rs` file should look like this:
37 |
38 | ```rust
39 | use actix_web::web::ServiceConfig;
40 | use api_lib::health::health;
41 | use shuttle_actix_web::ShuttleActixWeb;
42 | use shuttle_runtime::CustomError;
43 | use sqlx::Executor;
44 |
45 | #[shuttle_runtime::main]
46 | async fn actix_web(
47 | #[shuttle_shared_db::Postgres] pool: sqlx::PgPool,
48 | ) -> ShuttleActixWeb {
49 | // initialize the database if not already initialized
50 | pool.execute(include_str!("../../db/schema.sql"))
51 | .await
52 | .map_err(CustomError::new)?;
53 |
54 | let pool = actix_web::web::Data::new(pool);
55 |
56 | let config = move |cfg: &mut ServiceConfig| {
57 | cfg.app_data(pool).service(health);
58 | };
59 |
60 | Ok(config.into())
61 | }
62 | ```
63 | ~~~
64 |
65 | Test that everything is working by running the following command:
66 |
67 | ```bash
68 | curl -i http://localhost:8000/health
69 | ```
70 |
71 | You should get something like this:
72 |
73 | ```text
74 | HTTP/1.1 200 OK
75 | content-length: 0
76 | version: v0.0.1
77 | date: Sun, 02 Jul 2023 10:35:15 GMT
78 | ```
79 |
80 | Commit your changes.
81 |
82 | ```bash
83 | git add .
84 | git commit -m "add health check endpoint"
85 | ```
86 |
--------------------------------------------------------------------------------
/docs/src/backend/14_configure_method.md:
--------------------------------------------------------------------------------
1 | # Using the configure method
2 |
3 | You may have noticed that when our `health.rs` file contained two different endpoints, we had to add them as a `service` to the [ServiceConfig](https://docs.rs/actix-web/4.9.0/actix_web/web/struct.ServiceConfig.html) in the `actix_web` function. This is not a problem when we have a few endpoints, but it can become a problem when we have many endpoints.
4 |
5 | In order to make our code cleaner, we can use the [configure](https://actix.rs/docs/application#configure) function.
6 |
7 | Take a look at the [Actix Web docs](https://actix.rs/docs/application#configure) to see how to use it.
8 |
9 | Indeed, if you take a look at the code we have in our `shuttle` crate, you will see that we are already using it:
10 |
11 | ```rust
12 | let config = move |cfg: &mut ServiceConfig| {
13 | cfg.app_data(pool).service(health);
14 | };
15 | ```
16 |
17 | You could change this code to this and it should work the same:
18 |
19 | ```rust
20 | let config = move |cfg: &mut ServiceConfig| {
21 | cfg.app_data(pool).configure(|c| {
22 | c.service(health);
23 | });
24 | };
25 | ```
26 |
27 | Try it out!
28 |
29 | ## Refactoring our code
30 |
31 | Let's refactor our code to use the `configure` method both in the `health.rs` file and in the `main.rs` file.
32 |
33 | In the `main.rs` file, we will be expecting to receive a `configure` function from the `health` module, so we will change the code to this:
34 |
35 | ```rust
36 | let config = move |cfg: &mut ServiceConfig| {
37 | cfg.app_data(pool).configure(api_lib::health::service);
38 | };
39 | ```
40 |
41 | Note that it won't compile, because we haven't changed the `health.rs` file yet.
42 |
43 | So, in the `health.rs` file, we need to export a function called `service` that receives a `ServiceConfig` and returns nothing.
44 |
45 | ```rust
46 | // add the use statement for ServiceConfig
47 | use actix_web::{get, web::ServiceConfig, HttpResponse};
48 |
49 | pub fn service(cfg: &mut ServiceConfig) {
50 | cfg.service(health);
51 | }
52 | ```
53 |
54 | Now, we can run the code and it should work the same as before.
55 |
56 | There are, though, a few things that we can change.
57 |
58 | Not sure if you notice it but we required the `pub` keyword to be in front of the `service` function. This is because we are calling the function from another module. If we were calling it from the same module, we wouldn't need the `pub` keyword.
59 |
60 | But then, how is that we didn't need that for the `health` function as well? Well, that's because we are using the `#[get("/health")]` macro, which automatically adds the `pub` keyword to the function.
61 |
62 | Let's opt out of using [macros](https://doc.rust-lang.org/reference/macros-by-example.html) and do it manually.
63 |
64 | We will leverage the [route method](https://docs.rs/actix-web/4.3.1/actix_web/web/struct.ServiceConfig.html#method.route) of the [ServiceConfig struct](https://docs.rs/actix-web/4.3.1/actix_web/web/struct.ServiceConfig.html). Check out the [docs](https://docs.rs/actix-web/4.3.1/actix_web/web/struct.ServiceConfig.html#method.route).
65 |
66 | ```rust
67 | use actix_web::{
68 | web::{self, ServiceConfig},
69 | HttpResponse,
70 | };
71 |
72 | pub fn service(cfg: &mut ServiceConfig) {
73 | cfg.route("/health", web::get().to(health));
74 | }
75 |
76 | async fn health() -> HttpResponse {
77 | HttpResponse::Ok()
78 | .append_header(("version", "v0.0.1"))
79 | .finish()
80 | }
81 | ```
82 |
83 | Everything should still work. Check it out and commit your changes.
84 |
85 | ```bash
86 | git add .
87 | git commit -m "use configure"
88 | ```
89 |
--------------------------------------------------------------------------------
/docs/src/backend/15_testing.md:
--------------------------------------------------------------------------------
1 | # Unit and Integration tests
2 |
3 | Although `testing` is a little bit out of the scope of this workshop, we thought it would be interesting to show you how to write tests for your API.
4 |
5 | These will be simple examples of how to test the `health` endpoint.
6 |
7 | ```admonish info title="Learn more"
8 | For more information about testing in [Actix Web](https://actix.rs), please refer to the [Actix Web documentation](https://actix.rs/docs/testing/).
9 |
10 | For more information about testing in [Rust](https://www.rust-lang.org), please refer to the [Rust Book](https://doc.rust-lang.org/book/ch11-01-writing-tests.html).
11 | ```
12 |
13 | ## Unit tests
14 |
15 | Unit tests are usually created in the same file containin the subject under test. In our case, we will create a unit test for the `health` endpoint in the `api > lib > src > health.rs` file.
16 |
17 | The common practice is to create a new module called `tests`. But before that, we will need to add a `dev-dependency` to the `Cargo.toml` file of our library:
18 |
19 | ```toml
20 | [dev-dependencies]
21 | actix-rt = "2.0.0"
22 | ```
23 |
24 | Now, let's add this to our `health.rs` file:
25 |
26 | ```rust
27 | #[cfg(test)]
28 | mod tests {
29 | use actix_web::http::StatusCode;
30 |
31 | use super::*;
32 |
33 | #[actix_rt::test]
34 | async fn health_check_works() {
35 | let res = health().await;
36 |
37 | assert!(res.status().is_success());
38 | assert_eq!(res.status(), StatusCode::OK);
39 |
40 | let data = res
41 | .headers()
42 | .get("health-check")
43 | .and_then(|h| h.to_str().ok());
44 |
45 | assert_eq!(data, Some("v0.0.1"));
46 | }
47 | }
48 | ```
49 |
50 | A **few things to note** here:
51 |
52 | - The `#[cfg(test)]` annotation tells the compiler to only compile the code in this module if we are running tests.
53 | - The `#[actix_rt::test]` annotation tells the compiler to run this test in the `Actix` runtime (giving you async support).
54 | - The ` use super::*;` statement imports all the items from the parent module even if they're not public (in this case, the `health` function).
55 |
56 | ### Running the tests
57 |
58 | To run the tests, you can use the following command:
59 |
60 | ```bash
61 | cargo test
62 | # or, if you prefer to test in watch mode:
63 | cargo watch -x test
64 | ```
65 |
66 | > We introduced a bug in our test. Can you fix it?
67 |
68 | ~~~admonish tip title="Solution" collapsible=true
69 | The name of the header is `version`, not `health-check`. So, the either we change the name of the header in the test or we change the name of the header in the `health` function. Your call ;D.
70 | ~~~
71 |
72 | > Can you extract the version to a constant so we can reuse it in the test?
73 |
74 | ~~~admonish tip title="Solution" collapsible=true
75 | Declare the constant in the `health.rs` file and then use it in the `health` function and in the test:
76 |
77 | ```rust
78 | const API_VERSION: &str = "v0.0.1";
79 | ```
80 | ~~~
81 |
82 | ## Integration tests
83 |
84 | Next, we're going to create an integration test for the `health` endpoint. This test will run the whole application and make a request to the `health` endpoint.
85 |
86 | The **convention** is to have a `tests` folder in the root of the crate under test.
87 |
88 | Let's create this folder and add a new file called `health.rs` in it. The path of the file should be `api > lib > tests > health.rs`.
89 |
90 | Copy this content into it:
91 |
92 | ```rust
93 | use actix_web::{http::StatusCode, App};
94 | use api_lib::health::{service, API_VERSION};
95 |
96 | #[actix_rt::test]
97 | async fn health_check_works() {
98 | let app = App::new().configure(service);
99 | let mut app = actix_web::test::init_service(app).await;
100 | let req = actix_web::test::TestRequest::get()
101 | .uri("/health")
102 | .to_request();
103 |
104 | let res = actix_web::test::call_service(&mut app, req).await;
105 |
106 | assert!(res.status().is_success());
107 | assert_eq!(res.status(), StatusCode::OK);
108 | let data = res.headers().get("version").and_then(|h| h.to_str().ok());
109 | assert_eq!(data, Some(API_VERSION));
110 | }
111 | ```
112 |
113 | > This code will fail, can you figure out why?
114 |
115 | ~~~admonish tip title="Solution" collapsible=true
116 | We need to make the `API_VERSION` constant **public** so we can use it in the test. To do that, we need to add the `pub` keyword to the constant declaration
117 | ~~~
118 |
119 | ```admonish info title="Learn more"
120 | For more information about `Integration Tests` check the links we provided above in the beginning of this section:
121 | - [Actix Web Testing](https://actix.rs/docs/testing/)
122 | - [Rust Book - Writing Tests](https://doc.rust-lang.org/book/ch11-01-writing-tests.html)
123 | ```
124 |
125 | Don't forget to **commit** your changes:
126 |
127 | ```bash
128 | git add .
129 | git commit -m "add unit and integration tests"
130 | ```
131 |
--------------------------------------------------------------------------------
/docs/src/backend/16_films_endpoints.md:
--------------------------------------------------------------------------------
1 | # Films endpoints
2 |
3 | We are going to build now the endpoints needed to manage films.
4 |
5 | For now, don't worry about the implementation details, we will cover them in the next chapter. We will return a `200 OK` response for all the endpoints.
6 |
7 | We're going to provide the following endpoints:
8 |
9 | - `GET /v1/films`: returns a list of films.
10 | - `GET /v1/films/{id}`: returns a film by id.
11 | - `POST /v1/films`: creates a new film.
12 | - `PUT /v1/films`: updates a film.
13 | - `DELETE /v1/films/{id}`: deletes a film by id.
14 |
15 | ## Creating the films module
16 |
17 | Let's start by creating the `films` module in a similar way we did with the `health` module.
18 |
19 | Create a new file called `films.rs` in the `api > lib> src` folder and declare the module in the `lib.rs` file:
20 |
21 | ```rust
22 | pub mod films;
23 | ```
24 |
25 | Now, let's create a new function called `service` in the `films` module which will be responsible of declaring all the routes for the `films` endpoints. Make it public. You can base all this code in the `health` module.
26 |
27 | > Can you guess how to create all the endpoints?
28 |
29 | ~~~admonish tip
30 | Take a look at the [actix_web::Scope](https://docs.rs/actix-web/4.9.0/actix_web/struct.Scope.html) documentation to learn how to share a common path prefix for all the routes in the scope.
31 | ~~~
32 |
33 |
34 | ~~~admonish tip title="Solution" collapsible=true
35 | ```rust
36 | use actix_web::{
37 | web::{self, ServiceConfig},
38 | HttpResponse,
39 | };
40 |
41 | pub fn service(cfg: &mut ServiceConfig) {
42 | cfg.service(
43 | web::scope("/v1/films")
44 | // get all films
45 | .route("", web::get().to(get_all))
46 | // get by id
47 | .route("/{film_id}", web::get().to(get))
48 | // post new film
49 | .route("", web::post().to(post))
50 | // update film
51 | .route("", web::put().to(put))
52 | // delete film
53 | .route("/{film_id}", web::delete().to(delete)),
54 | );
55 | }
56 |
57 | async fn get_all() -> HttpResponse {
58 | HttpResponse::Ok().finish()
59 | }
60 |
61 | async fn get() -> HttpResponse {
62 | HttpResponse::Ok().finish()
63 | }
64 |
65 | async fn post() -> HttpResponse {
66 | HttpResponse::Ok().finish()
67 | }
68 |
69 | async fn put() -> HttpResponse {
70 | HttpResponse::Ok().finish()
71 | }
72 |
73 | async fn delete() -> HttpResponse {
74 | HttpResponse::Ok().finish()
75 | }
76 |
77 | ```
78 | ~~~
79 |
80 | ## Serving the films endpoints
81 |
82 | In order to expose these newly created endpoints we need to configure the service in our `shuttle` crate.
83 |
84 | Open the `main.rs` file in the `api > shuttle > src` folder and add a new service:
85 |
86 | ```diff
87 | - cfg.app_data(pool).configure(api_lib::health::service);
88 | + cfg.app_data(pool)
89 | + .configure(api_lib::health::service)
90 | + .configure(api_lib::films::service);
91 | ```
92 |
93 | Compile the code and check that everything works as expected.
94 |
95 | You can use [curl](https://curl.se/) or [Postman](https://postman.com) to test the new endpoints.
96 |
97 | Alternatively, if you have installed the [REST Client](https://marketplace.visualstudio.com/items?itemName=humao.rest-client) extension for [Visual Studio Code](https://code.visualstudio.com/), you can create a file called `api.http` in the root of the project and copy the following content:
98 |
99 | ```bash
100 | @host = http://localhost:8000
101 | @film_id = 6f05e5f2-133c-11ee-be9f-0ab7e0d8c876
102 |
103 | ### health
104 | GET {{host}}/health HTTP/1.1
105 |
106 | ### create film
107 | POST {{host}}/v1/films HTTP/1.1
108 | Content-Type: application/json
109 |
110 | {
111 | "title": "Death in Venice",
112 | "director": "Luchino Visconti",
113 | "year": 1971,
114 | "poster": "https://th.bing.com/th/id/R.0d441f68f2182fd7c129f4e79f6a66ef?rik=h0j7Ecvt7NBYrg&pid=ImgRaw&r=0"
115 | }
116 |
117 | ### update film
118 | PUT {{host}}/v1/films HTTP/1.1
119 | Content-Type: application/json
120 |
121 | {
122 | "id": "{{film_id}}",
123 | "title": "Death in Venice",
124 | "director": "Benjamin Britten",
125 | "year": 1981,
126 | "poster": "https://image.tmdb.org/t/p/original//tmT12hTzJorZxd9M8YJOQOJCqsP.jpg"
127 | }
128 |
129 | ### get all films
130 | GET {{host}}/v1/films HTTP/1.1
131 |
132 | ### get film
133 | GET {{host}}/v1/films/{{film_id}} HTTP/1.1
134 |
135 | ### get bad film
136 | GET {{host}}/v1/films/356e42a8-e659-406f-98 HTTP/1.1
137 |
138 |
139 | ### delete film
140 | DELETE {{host}}/v1/films/{{film_id}} HTTP/1.1
141 | ```
142 |
143 | Open it and just click on the `Send Request` link next to each request to send it to the server.
144 |
145 | 
146 |
147 | Commit your changes:
148 |
149 | ```bash
150 | git add .
151 | git commit -m "feat: add films endpoints"
152 | ```
153 |
--------------------------------------------------------------------------------
/docs/src/backend/17_models.md:
--------------------------------------------------------------------------------
1 | # Models
2 |
3 | So now we have the films endpoints working, but they don't really do anything nor return any data.
4 |
5 | In order to return data **we need to create a model for our films**.
6 |
7 | As we want to **share** the model between the `api` and the `frontend` crates we will use the `shared` crate for this.
8 |
9 | The `shared` crate is a `library` crate. This means that it can be used by other crates in the workspace.
10 |
11 | Let's import the dependency in the `Cargo.toml` file of our `api-lib` crate:
12 |
13 | ```diff
14 | [dependencies]
15 | + # shared
16 | + shared = { path = "../../shared" }
17 | ```
18 |
19 | Verify that the project is still compiling.
20 |
21 | ## Creating the `Film` model
22 |
23 | We are going to create a new module called `models` in the `shared` crate.
24 |
25 | Create a **new file** called `models.rs` in the `shared > src` folder and add the following code:
26 |
27 | ```rust
28 | pub struct Film {
29 | pub id: uuid::Uuid, // we will be using uuids as ids
30 | pub title: String,
31 | pub director: String,
32 | pub year: u16, // only positive numbers
33 | pub poster: String, // we will use the url of the poster here
34 | pub created_at: Option>,
35 | pub updated_at: Option>,
36 | }
37 | ```
38 |
39 | We could make it more complicated but for the sake of simplicity we will just use a `struct` with a small amount of fields.
40 |
41 | Now, remove everything from the `lib.rs` file in the `shared` crate and add the following code:
42 |
43 | ```rust
44 | pub mod models;
45 | ```
46 |
47 | Soon you will notice that the compiler will complain about the `chrono` and `uuid` dependencies.
48 |
49 | Let's add them:
50 |
51 | ```diff
52 | [dependencies]
53 | + uuid = { version = "1.3.4", features = ["serde", "v4", "js"] }
54 | + chrono = { version = "0.4", features = ["serde"] }
55 | ```
56 |
57 | ```admonish info
58 | Most of the features you see are related to the fact that we want our API to be able to serialize and deserialize the models to and from JSON.
59 | ```
60 |
61 | Compile the code and check that everything is fine.
62 |
63 | ## Creating a model for the post endpoint
64 |
65 | In our `POST` endpoint we will receive a JSON object with the following structure:
66 |
67 | ```json
68 | {
69 | "title": "The Lord of the Rings: The Fellowship of the Ring",
70 | "director": "Peter Jackson",
71 | "year": 2001,
72 | "poster": "https://www.imdb.com/title/tt0120737/mediaviewer/rm1340569600/",
73 | }
74 | ```
75 |
76 | We don't need to pass the `id` or the `created_at` and `updated_at` fields as they will be generated by the API, so let's create a new model for that.
77 |
78 | ```rust
79 | pub struct CreateFilm {
80 | pub title: String,
81 | pub director: String,
82 | pub year: u16,
83 | pub poster: String,
84 | }
85 | ```
86 |
87 | Compile again just in case and commit your changes:
88 |
89 | ```bash
90 | git add .
91 | git commit -m "add models"
92 | ```
93 |
--------------------------------------------------------------------------------
/docs/src/backend/18_serde.md:
--------------------------------------------------------------------------------
1 | # Serde
2 |
3 | [Serde](https://serde.rs/) is a framework for **serializing and deserializing** Rust data structures efficiently and generically.
4 |
5 | We are going to use it to add **serialization and deserialization** support to our models.
6 |
7 | ## Adding the dependency
8 |
9 | Let's add the `serde` dependency to the `Cargo.toml` file of the `shared` crate:
10 |
11 | ```diff
12 | [dependencies]
13 | + serde = { version = "1.0", features = ["derive"] }
14 | ```
15 |
16 | Adding the `derive` feature will allow us to use the `#[derive(Serialize, Deserialize)]` macro on our models, which will automatically implement the `Serialize` and `Deserialize` [traits](https://doc.rust-lang.org/book/ch10-02-traits.html) for us.
17 |
18 | As we will be working with `JSON` in our API, we need to bring in the `serde_json` crate as well in the `Cargo.toml` file of the `api-lib` crate:
19 |
20 | ```diff
21 | [dependencies]
22 | + # serde
23 | + serde = "1.0"
24 | + serde_json = "1.0"
25 | ```
26 |
27 | ## Adding the `Serialize` and `Deserialize` traits to our models
28 |
29 | Let's add the `Serialize` and `Deserialize` traits to our `Film` and `CreateFilm` models.
30 |
31 | For that, we are going to use the [derive macro](https://doc.rust-lang.org/rust-by-example/trait/derive.html):
32 |
33 | ```diff
34 | + use serde::{Deserialize, Serialize};
35 |
36 | + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Default)]
37 | pub struct Film {
38 | pub id: uuid::Uuid, // we will be using uuids as ids
39 | pub title: String,
40 | pub director: String,
41 | pub year: u16, // only positive numbers
42 | pub poster: String, // we will use the url of the poster here
43 | pub created_at: Option>,
44 | pub updated_at: Option>,
45 | }
46 |
47 | + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Default)]
48 | pub struct CreateFilm {
49 | pub title: String,
50 | pub director: String,
51 | pub year: u16,
52 | pub poster: String,
53 | }
54 | ```
55 |
56 | ```admonish info
57 | Note that we added more [traits](https://doc.rust-lang.org/book/ch10-02-traits.html). It's a common practice for libraries to implement some of those [traits](https://doc.rust-lang.org/book/ch10-02-traits.html) to avoid issues when using them. See the [orphan rule](https://serde.rs/remote-derive.html) for more information.
58 | ```
59 |
60 | Commit your changes:
61 |
62 | ```bash
63 | git add .
64 | git commit -m "add serde dependency and derive traits"
65 | ```
66 |
--------------------------------------------------------------------------------
/docs/src/backend/19_film_repository.md:
--------------------------------------------------------------------------------
1 | # Film Repository
2 |
3 | Today, our API will work with a [Postgres](https://www.postgresql.org/) database. But this may change in the future.
4 |
5 | Even if that never happens (which is the most probable thing) we will still want to **decouple our API from the database** to make it easier to test and maintain.
6 |
7 | To do that, we will leverage [traits](https://doc.rust-lang.org/book/ch10-02-traits.html) to define the behavior of our **film repository**.
8 |
9 | This will also allow us to take a look at:
10 | - [traits](https://doc.rust-lang.org/book/ch10-02-traits.html)
11 | - [async-trait](https://docs.rs/async-trait/latest/async_trait/)
12 | - [Static vs Dynamic dispatch](https://doc.rust-lang.org/book/ch17-02-trait-objects.html#trait-objects-perform-dynamic-dispatch)
13 |
14 | ## Defining the `FilmRepository` trait
15 |
16 | We will define this [trait](https://doc.rust-lang.org/book/ch10-02-traits.html) in the `api-lib` crate although it could be its own crate if we wanted.
17 |
18 | To keep it simple create a new `film_repository` folder in `api > lib > src` and add a `mod.rs` file with the following content:
19 |
20 | ```rust
21 | pub type FilmError = String;
22 | pub type FilmResult = Result;
23 |
24 | pub trait FilmRepository: Send + Sync + 'static {
25 | async fn get_films(&self) -> FilmResult>;
26 | async fn get_film(&self, id: &Uuid) -> FilmResult;
27 | async fn create_film(&self, id: &CreateFilm) -> FilmResult;
28 | async fn update_film(&self, id: &Film) -> FilmResult;
29 | async fn delete_film(&self, id: &Uuid) -> FilmResult;
30 | }
31 | ```
32 |
33 | Don't forget to add the module to the `lib.rs` file:
34 |
35 | ```rust
36 | pub mod film_repository;
37 | ```
38 |
39 | The code won't compile. But don't worry, we will fix that in a minute.
40 |
41 | Let's review for a moment that piece of code:
42 |
43 | 1. We define two type aliases: `FilmError` and `FilmResult`. This will allow us to easily change the `error` type if we need to and to avoid boilerplate when having to write the return of our functions.
44 | 1. The [Send & Sync traits](https://doc.rust-lang.org/book/ch16-04-extensible-concurrency-sync-and-send.html?highlight=sync#allowing-access-from-multiple-threads-with-sync) will allow us to share and send this the types implementiong this trait between threads.
45 | 1. The `'static` lifetime will make our life easier as we know that the repository will live for the entire duration of the program.
46 | 1. Finally, you see that we have defined 5 functions that will allow us to interact with our database. We will implement them in the next section.
47 |
48 | Then, why does this code not compile?
49 |
50 | The reason is that we are using the `async` keyword in our trait definition. This is not allowed by the Rust compiler.
51 |
52 | To fix this, we will use the [async-trait](https://docs.rs/async-trait/latest/async_trait/) crate.
53 |
54 | ## async-trait
55 |
56 | Let's bring this dependency into our `api-lib` crate by adding it to the `Cargo.toml` file. As we will be using the `uuid` crate in our repository, we will also add it to the `Cargo.toml` file:
57 |
58 | ```diff
59 | [dependencies]
60 | + # utils
61 | + async-trait = "0.1.82"
62 | + uuid = { version = "1.3.4", features = ["serde", "v4", "js"] }
63 | ```
64 |
65 | Now, let's mark our [trait](https://doc.rust-lang.org/book/ch10-02-traits.html) as `async` and add all the `use` statements we need:
66 |
67 | ```diff
68 | + use shared::models::{CreateFilm, Film};
69 | + use uuid::Uuid;
70 |
71 | pub type FilmError = String;
72 | pub type FilmResult = Result;
73 |
74 | + #[async_trait::async_trait]
75 | pub trait FilmRepository: Send + Sync + 'static {
76 | async fn get_films(&self) -> FilmResult>;
77 | async fn get_film(&self, id: &Uuid) -> FilmResult;
78 | async fn create_film(&self, id: &CreateFilm) -> FilmResult;
79 | async fn update_film(&self, id: &Film) -> FilmResult;
80 | async fn delete_film(&self, id: &Uuid) -> FilmResult;
81 | }
82 | ```
83 |
84 | Now, the code compiles. But we still need to implement the trait. We will do it in the next section.
85 |
86 | ## mod.rs
87 |
88 | You probably noticed that we created file called `mod.rs` in the `film_repository` folder.
89 |
90 | So far, whenever we wanted to create a new module, we just used a file with the same name as the module. For example, we created a `film` module by creating a `film.rs` file.
91 |
92 | ```admonish info
93 | There are several ways to work with modules, you can learn more about it [here](https://doc.rust-lang.org/book/ch07-05-separating-modules-into-different-files.html).
94 | ```
95 |
96 | This is the old way of doing things with modules but it's still valid and widely used in the Rust community.
97 |
98 | Most of the time, you will do this if you plan to add more modules under the `film_repository` folder. For example, you could add a `memory_film_repository` module to implement a memory repository.
99 |
100 | For now, let's commit our changes:
101 |
102 | ```bash
103 | git add .
104 | git commit -m "add film repository trait"
105 | ```
106 |
--------------------------------------------------------------------------------
/docs/src/backend/21_injecting_repository.md:
--------------------------------------------------------------------------------
1 | # Injecting the repository
2 |
3 | Ok, so now we have our shared library working both for the `frontend` and the `backend`. We have our `FilmRepository` trait and even a [Postgres](https://www.postgresql.org/) implementation of it. Now we need to inject the repository into our handlers.
4 |
5 | If you take a look again at the `main.rs` file of our `api-shuttle` crate, you will see that we were already sharing the `sqlx::PgPool` between the handlers.
6 |
7 | We will do the same with the `FilmRepository` trait.
8 |
9 | ## Creating a `PostgresFilmRepository` struct
10 |
11 | Let's create a new instance of the `PostgresFilmRepository` struct in the `main.rs` file of our `api-shuttle` crate:
12 |
13 | ```diff
14 | - let pool = actix_web::web::Data::new(pool);
15 | + let film_repository = api_lib::film_repository::PostgresFilmRepository::new(pool);
16 | + let film_repository = actix_web::web::Data::new(film_repository);
17 |
18 | - cfg.app_data(pool)
19 | + cfg.app_data(film_repository)
20 | ```
21 |
22 | Once you apply this change, everything should compile and work as before.
23 |
24 | Commit your changes:
25 |
26 | ```bash
27 | git add .
28 | git commit -m "inject film repository"
29 | ```
30 |
--------------------------------------------------------------------------------
/docs/src/backend/22_implementing_endpoints.md:
--------------------------------------------------------------------------------
1 | # Implementing the endpoints
2 |
3 | In this section we are going to implement all the `film` endpoints.
4 |
5 | One thing we know for sure is that all our handlers will need to access to a `FilmRepository` instance to do their work.
6 |
7 | We already injected a particular implementation of the `FilmRepository` trait in our `api-shuttle` crate, but remember that here, we don't know which particular implementation we are going to use.
8 |
9 | Indeed, we **shouldn't care about the implementation details** of the `FilmRepository` trait in our `api-lib` crate. We should only care about the fact that we have a `FilmRepository` trait that we can use to interact with the database.
10 |
11 | So, it seems clear that we need to get access to the `FilmRepository` instance in our handlers. But how can we do that?
12 |
13 | ```admonish tip
14 | Refresh your memory by reading about how to handle State in Actix Web in the [official documentation](https://actix.rs/docs/application/#state).
15 | ```
16 |
17 | As you can see, it should be pretty straightforward isn't it? But, wait a minute. We have a problem here.
18 |
19 | In all these examples, in order **to extract a particular state we need to know its type**. But we said we don't care about the particular type of the `FilmRepository` instance, we only care about the fact that we have a `FilmRepository` instance.
20 |
21 | How can we reconcile these two things?
22 |
23 | We have **2 options** here.
24 |
25 | We're going to cover them both briefly as this is out of the scope of the workshop.
26 |
27 | ## Dynamic dispatch
28 |
29 | The first option is to use [dynamic dispatch](https://en.wikipedia.org/wiki/Dynamic_dispatch).
30 |
31 | This will generally make our code less performant (some times it doesn't really matter) but it will allow us to easily **abstract away the particular trait implementations**.
32 |
33 | ```admonish info title="Trait Objects"
34 | Learn more about this topic in the [official Rust book](https://doc.rust-lang.org/book/ch17-02-trait-objects.html).
35 | ```
36 |
37 | The basic idea here is that we will use a `Box` as our state type. This will allow us to store any type that implements the `FilmRepository` trait in our state.
38 |
39 | ```diff
40 | - let film_repository = actix_web::web::Data::new(film_repository);
41 | + let film_repository: actix_web::web::Data> =
42 | + actix_web::web::Data::new(Box::new(film_repository));
43 | ```
44 |
45 | Then, in our handlers, we will add this parameter:
46 |
47 | ```rust
48 | repo: actix_web::web::Data>
49 | ```
50 |
51 | For instance, in our `get_all` handler, we would use it like this:
52 |
53 | ```rust
54 | match repo.get_films().await {
55 | Ok(films) => HttpResponse::Ok().json(films),
56 | Err(e) => HttpResponse::NotFound().body(format!("Internal server error: {:?}", e)),
57 | }
58 | ```
59 |
60 | If you test that endpoint, you will see that it works as expected.
61 |
62 | If you look into your terminal, you should be able to see the SQL query that was executed:
63 |
64 | 
65 |
66 | This is fairly easy, it works, and it's a common option.
67 |
68 | Let's implement all the endpoints with this approach and then we'll see the second option.
69 |
70 | ## Implementing the endpoints
71 |
72 | > Do you want to give it a try?
73 |
74 |
75 | ~~~admonish tip title="Solution" collapsible=true
76 | Make sure your code in the `api-lib/src/film.rs` file looks like this:
77 |
78 | ```rust
79 | use actix_web::{
80 | web::{self, ServiceConfig},
81 | HttpResponse,
82 | };
83 | use shared::models::{CreateFilm, Film};
84 | use uuid::Uuid;
85 |
86 | use crate::film_repository::FilmRepository;
87 |
88 | type Repository = web::Data>;
89 |
90 | pub fn service(cfg: &mut ServiceConfig) {
91 | cfg.service(
92 | web::scope("/v1/films")
93 | // get all films
94 | .route("", web::get().to(get_all))
95 | // get by id
96 | .route("/{film_id}", web::get().to(get))
97 | // post new film
98 | .route("", web::post().to(post))
99 | // update film
100 | .route("", web::put().to(put))
101 | // delete film
102 | .route("/{film_id}", web::delete().to(delete)),
103 | );
104 | }
105 |
106 | async fn get_all(repo: Repository) -> HttpResponse {
107 | match repo.get_films().await {
108 | Ok(films) => HttpResponse::Ok().json(films),
109 | Err(e) => HttpResponse::NotFound().body(format!("Internal server error: {:?}", e)),
110 | }
111 | }
112 |
113 | async fn get(film_id: web::Path, repo: Repository) -> HttpResponse {
114 | match repo.get_film(&film_id).await {
115 | Ok(film) => HttpResponse::Ok().json(film),
116 | Err(_) => HttpResponse::NotFound().body("Not found"),
117 | }
118 | }
119 |
120 | async fn post(create_film: web::Json, repo: Repository) -> HttpResponse {
121 | match repo.create_film(&create_film).await {
122 | Ok(film) => HttpResponse::Ok().json(film),
123 | Err(e) => {
124 | HttpResponse::InternalServerError().body(format!("Internal server error: {:?}", e))
125 | }
126 | }
127 | }
128 |
129 | async fn put(film: web::Json, repo: Repository) -> HttpResponse {
130 | match repo.update_film(&film).await {
131 | Ok(film) => HttpResponse::Ok().json(film),
132 | Err(e) => HttpResponse::NotFound().body(format!("Internal server error: {:?}", e)),
133 | }
134 | }
135 |
136 | async fn delete(film_id: web::Path, repo: Repository) -> HttpResponse {
137 | match repo.delete_film(&film_id).await {
138 | Ok(film) => HttpResponse::Ok().json(film),
139 | Err(e) => {
140 | HttpResponse::InternalServerError().body(format!("Internal server error: {:?}", e))
141 | }
142 | }
143 | }
144 | ```
145 | ~~~
146 |
147 | Test the API by using the `api.http` file if you created it in one of the previous sections or by using any other tool.
148 |
149 | Commit your changes:
150 |
151 | ```bash
152 | git add .
153 | git commit -m "implement film endpoints"
154 | ```
155 |
--------------------------------------------------------------------------------
/docs/src/backend/23_static_dispatching.md:
--------------------------------------------------------------------------------
1 | # Static dispatch
2 |
3 | You can check out [this section of the Rust Book](https://doc.rust-lang.org/book/ch17-02-trait-objects.html#trait-objects-perform-dynamic-dispatch) to understand about some of the trade-offs of using dynamic dispatch.
4 |
5 | We're going to learn in this section how to use [Generics](https://doc.rust-lang.org/book/ch10-01-syntax.html#generic-data-types) to leverage static dispatch.
6 |
7 | ## Refactor the `film` endpoints
8 |
9 | Let's change all the code to use `generics` instead of `trait objects`:
10 |
11 | ```rust
12 | use actix_web::{
13 | web::{self, ServiceConfig},
14 | HttpResponse,
15 | };
16 | use shared::models::{CreateFilm, Film};
17 | use uuid::Uuid;
18 |
19 | use crate::film_repository::FilmRepository;
20 |
21 | pub fn service(cfg: &mut ServiceConfig) {
22 | cfg.service(
23 | web::scope("/v1/films")
24 | // get all films
25 | .route("", web::get().to(get_all::))
26 | // get by id
27 | .route("/{film_id}", web::get().to(get::))
28 | // post new film
29 | .route("", web::post().to(post::))
30 | // update film
31 | .route("", web::put().to(put::))
32 | // delete film
33 | .route("/{film_id}", web::delete().to(delete::)),
34 | );
35 | }
36 |
37 | async fn get_all(repo: web::Data) -> HttpResponse {
38 | match repo.get_films().await {
39 | Ok(films) => HttpResponse::Ok().json(films),
40 | Err(e) => HttpResponse::NotFound().body(format!("Internal server error: {:?}", e)),
41 | }
42 | }
43 |
44 | async fn get(film_id: web::Path, repo: web::Data) -> HttpResponse {
45 | match repo.get_film(&film_id).await {
46 | Ok(film) => HttpResponse::Ok().json(film),
47 | Err(_) => HttpResponse::NotFound().body("Not found"),
48 | }
49 | }
50 |
51 | async fn post(
52 | create_film: web::Json,
53 | repo: web::Data,
54 | ) -> HttpResponse {
55 | match repo.create_film(&create_film).await {
56 | Ok(film) => HttpResponse::Ok().json(film),
57 | Err(e) => {
58 | HttpResponse::InternalServerError().body(format!("Internal server error: {:?}", e))
59 | }
60 | }
61 | }
62 |
63 | async fn put(film: web::Json, repo: web::Data) -> HttpResponse {
64 | match repo.update_film(&film).await {
65 | Ok(film) => HttpResponse::Ok().json(film),
66 | Err(e) => HttpResponse::NotFound().body(format!("Internal server error: {:?}", e)),
67 | }
68 | }
69 |
70 | async fn delete(film_id: web::Path, repo: web::Data) -> HttpResponse {
71 | match repo.delete_film(&film_id).await {
72 | Ok(film) => HttpResponse::Ok().json(film),
73 | Err(e) => {
74 | HttpResponse::InternalServerError().body(format!("Internal server error: {:?}", e))
75 | }
76 | }
77 | }
78 | ```
79 |
80 | ## Hinting the compiler
81 |
82 | If you try to compile the code, you'll get an error:
83 |
84 | ```bash
85 | error[E0282]: type annotations needed
86 | --> api/shuttle/src/main.rs:22:24
87 | |
88 | 22 | .configure(api_lib::films::service);
89 | | ^^^^^^^^^^^^^^^^^^^^^^^ cannot infer type of the type parameter `R` declared on the function `service`
90 | |
91 | help: consider specifying the generic argument
92 | |
93 | 22 | .configure(api_lib::films::service::);
94 | | +++++
95 |
96 | For more information about this error, try `rustc --explain E0282`.
97 | error: could not compile `api-shuttle` (bin "api-shuttle") due to previous error
98 | Error: Build failed. Is the Shuttle runtime missing?
99 | [Finished running. Exit status: 1]
100 | ```
101 |
102 | But the compiler is giving us a hint on how to fix it. Let's do it.
103 |
104 | Open the `main.rs` file of our `api-shuttle` crate and let's change a couple of things:
105 |
106 | ```diff
107 | let film_repository = api_lib::film_repository::PostgresFilmRepository::new(pool);
108 | - let film_repository: actix_web::web::Data> =
109 | - actix_web::web::Data::new(Box::new(film_repository));
110 | + let film_repository = actix_web::web::Data::new(film_repository);
111 |
112 | let config = move |cfg: &mut ServiceConfig| {
113 | cfg.app_data(film_repository)
114 | .configure(api_lib::health::service)
115 | - .configure(api_lib::films::service);
116 | + .configure(api_lib::films::service::);
117 | };
118 | ```
119 |
120 | This should be enough to make the compiler happy. Now it knows what type to use for the `R` generic parameter.
121 |
122 | ```admonish info title="Monomorphization"
123 | The compiler will generate a different version of the code for each type that we use for the generic parameter. This is called `monomorphization`.
124 |
125 | You can learn more about it [here](https://doc.rust-lang.org/book/ch10-01-syntax.html#performance-of-code-using-generics) and [here](https://rustc-dev-guide.rust-lang.org/backend/monomorph.html#monomorphization)
126 | ```
127 |
128 | Check that everything works as expected and commit your changes:
129 |
130 | ```bash
131 | git add .
132 | git commit -m "refactor film endpoints to use generics"
133 | ```
134 |
--------------------------------------------------------------------------------
/docs/src/backend/24_serving_static_files.md:
--------------------------------------------------------------------------------
1 | # Serving static files
2 |
3 | In this section of the backend part of the workshop we'll learn how to **serve static files** with [Actix Web](https://actix.rs) and [Shuttle](https://shuttle.rs).
4 |
5 | The main goal here is to serve the statics files present in a folder called `static`.
6 |
7 | So the API will serve `statics` in the root path `/` and the `API endpoints` in the `/api` path.
8 |
9 | For this to happen we will need to refactor a little bit our `api-shuttle` main code.
10 |
11 | ## Shuttle dependencies
12 |
13 | Read the [Shuttle documentation for static files](https://docs.shuttle.rs/resources/shuttle-static-folder).
14 |
15 | Some of the **caveats** that you will find explained there **will apply to us** as we are using a workspace, but let's start from the beginning.
16 |
17 | Let's add the `shuttle-static-folder` and the [actix-files](https://docs.rs/actix-files/latest/actix_files/) dependencies to our `api-shuttle` crate.
18 |
19 | ```toml
20 | [dependencies]
21 | # static
22 | actix-files = "0.6.6"
23 | ```
24 |
25 | ## Serving the static files
26 |
27 | Now, let's refactor our `main.rs` file to serve the static files.
28 |
29 |
30 | Let's modify our `ServiceConfig` to serve static files in the `/` path and the API in the `/api` path:
31 |
32 | ```diff
33 | - cfg.app_data(film_repository)
34 | - .configure(api_lib::health::service)
35 | - .configure(api_lib::films::service::);
36 | + cfg.service(
37 | + web::scope("/api")
38 | + .app_data(film_repository)
39 | + .configure(api_lib::health::service)
40 | + .configure(
41 | + api_lib::films::service::,
42 | + ),
43 | + )
44 | + .service(Files::new("/", "static").index_file("index.html"));
45 | ```
46 |
47 | ~~~admonish tip title="Final Code" collapsible=true
48 | ```rust
49 | use actix_web::web::{self, ServiceConfig};
50 | use shuttle_actix_web::ShuttleActixWeb;
51 | use shuttle_runtime::CustomError;
52 | use sqlx::Executor;
53 | use std::path::PathBuf;
54 |
55 | #[shuttle_runtime::main]
56 | async fn actix_web(
57 | #[shuttle_shared_db::Postgres] pool: sqlx::PgPool,
58 | #[shuttle_static_folder::StaticFolder(folder = "static")] static_folder: PathBuf,
59 | ) -> ShuttleActixWeb {
60 | // initialize the database if not already initialized
61 | pool.execute(include_str!("../../db/schema.sql"))
62 | .await
63 | .map_err(CustomError::new)?;
64 |
65 | let film_repository = api_lib::film_repository::PostgresFilmRepository::new(pool);
66 | let film_repository = web::Data::new(film_repository);
67 |
68 | let config = move |cfg: &mut ServiceConfig| {
69 | cfg.service(
70 | web::scope("/api")
71 | .app_data(film_repository)
72 | .configure(api_lib::health::service)
73 | .configure(
74 | api_lib::films::service::,
75 | ),
76 | )
77 | .service(Files::new("/", "static").index_file("index.html"));
78 | };
79 |
80 | Ok(config.into())
81 | }
82 | ```
83 | ~~~
84 |
85 | You will get a **runtime error**:
86 |
87 | ```bash
88 | [Running 'cargo shuttle run']
89 | Building /home/roberto/GIT/github/robertohuertasm/devbcn-dry-run
90 | Compiling api-shuttle v0.1.0 (/home/roberto/GIT/github/robertohuertasm/devbcn-dry-run/api/shuttle)
91 | Finished dev [unoptimized + debuginfo] target(s) in 9.00s
92 | 2023-07-02T18:49:07.514534Z ERROR cargo_shuttle: failed to load your service error="Custom error: failed to provision shuttle_static_folder :: StaticFolder"
93 | [Finished running. Exit status: 1]
94 | ```
95 |
96 | That's mainly because the static folder doesn't exist yet.
97 |
98 | Create a folder called `static` in the `api-shuttle` crate and add a file called `index.html` with this content:
99 |
100 | ```html
101 |
102 |
103 |
104 |
105 |
106 | Hello Shuttle
107 |
108 |
109 | Hello Shuttle
110 |
111 |
112 | ```
113 |
114 | Now if you browse to [http://localhost:8000](http://localhost:8000) you should be able to see the `index.html` file.
115 |
116 | ```admonish warning
117 | Remember that we have changed the path for the API to `/api` so you will need to change that too in your `api.http` file or Postman configuration.
118 | ```
119 |
120 | ## Ignoring the static folder
121 |
122 | As the `static` folder will be generated by the `frontend`, we don't want to commit it to our repository.
123 |
124 | Add this to the `.gitignore` file:
125 |
126 | ```bash
127 | # Ignore the static folder
128 | static/
129 | ```
130 |
131 | Now, to solve a [Shuttle issue affecting static folders in workspaces](https://docs.shuttle.rs/resources/shuttle-static-folder), we need to create a `.ignore` file in the root folder with the following content:
132 |
133 | ```bash
134 | !static/
135 | ```
136 |
137 | Commit your changes:
138 |
139 | ```bash
140 | git add .
141 | git commit -m "serve static files"
142 | ```
143 |
144 | Now, in order to deploy to the cloud and avoid having issues with the `static` folder not being found (remember there's currently an issue in the Shuttle static folder implementation), copy the `static` folder to the root of your project and deploy:
145 |
146 | ```bash
147 | cargo shuttle deploy
148 | ```
149 |
--------------------------------------------------------------------------------
/docs/src/backend/25_makefile_toml.md:
--------------------------------------------------------------------------------
1 | # Bonus: Makefile.toml
2 |
3 | Final section of the backend part of the workshop.
4 |
5 | Create a file in the root of the project called `Makefile.toml` with the following content:
6 |
7 | ```toml
8 | # project tasks
9 | [tasks.api-run]
10 | workspace = false
11 | env = { RUST_LOG="info" }
12 | install_crate = "cargo-shuttle"
13 | command = "cargo"
14 | args = ["shuttle", "run"]
15 |
16 | [tasks.front-serve]
17 | workspace = false
18 | cwd = "./front"
19 | install_crate = "dioxus-cli"
20 | command = "dioxus"
21 | args = ["serve"]
22 |
23 | [tasks.front-build]
24 | workspace = false
25 | script_runner = "@shell"
26 | script = '''
27 | # shuttle issue with static files
28 | # location is different depending on the environment
29 | rm -rf api/shuttle/static static
30 | mkdir api/shuttle/static
31 | mkdir static
32 | cd front
33 | dioxus build --release
34 | # local development
35 | cp -r dist/* ../api/shuttle/static
36 | # production
37 | cp -r dist/* ../static
38 | '''
39 |
40 | # local db
41 | [tasks.db-start]
42 | workspace = false
43 | script_runner = "@shell"
44 | script = '''
45 | docker run -d --name devbcn-workshop -p 5432:5432 -e POSTGRES_PASSWORD=postgres -e POSTGRES_USER=postgres -e POSTGRES_DB=devbcn postgres
46 | '''
47 |
48 | [tasks.db-stop]
49 | workspace = false
50 | script_runner = "@shell"
51 | script = '''
52 | docker stop postgres
53 | docker rm postgres
54 | '''
55 |
56 | # general tasks
57 | [tasks.clippy]
58 | workspace = false
59 | install_crate = "cargo-clippy"
60 | command = "cargo"
61 | args = ["clippy"]
62 |
63 | [tasks.format]
64 | clear = true
65 | workspace = false
66 | install_crate = "rustfmt"
67 | command = "cargo"
68 | args = ["fmt", "--all", "--", "--check"]
69 | ```
70 |
71 | It may be useful, specially for building the frontend.
72 |
73 | ```admonish info
74 | Learn more about [cargo-make](https://sagiegurari.github.io/cargo-make/), [clippy](https://doc.rust-lang.org/stable/clippy/index.html) and [rustfmt](https://github.com/rust-lang/rustfmt).
75 | ```
76 |
77 | Commit this change:
78 |
79 | ```bash
80 | git add .
81 | git commit -m "add Makefile.toml"
82 | ```
83 |
--------------------------------------------------------------------------------
/docs/src/frontend/03_01_setup.md:
--------------------------------------------------------------------------------
1 | # Setup
2 |
3 | This guide outlines the steps necessary to set up a frontend development environment using Dioxus and Tailwind.
4 |
5 | ## Dioxus Configuration
6 |
7 | Dioxus, a Rust framework, allows you to build responsive web applications. To use Dioxus, you need to install the Dioxus Command Line Interface (CLI) and the Rust target `wasm32-unknown-unknown`.
8 |
9 | ### Step 1: Install the Dioxus CLI
10 |
11 | Install the Dioxus CLI by running the following command:
12 |
13 | ```bash
14 | cargo install dioxus-cli
15 | ```
16 |
17 | ### Step 2: Install the Rust Target
18 |
19 | Ensure the `wasm32-unknown-unknown` target for Rust is installed by running:
20 |
21 | ```bash
22 | rustup target add wasm32-unknown-unknown
23 | ```
24 |
25 | ### Step 3: Create a Frontend Crate
26 |
27 | Create a new frontend crate from root of our project by executing:
28 |
29 | ```bash
30 | cargo new --bin front
31 | cd front
32 | ```
33 |
34 | Update the project's workspace configuration by adding the following lines to the `Cargo.toml` file:
35 |
36 | ```diff
37 | [workspace]
38 | members = [
39 | "api/lib",
40 | "api/shuttle",
41 | "shared",
42 | + "front",
43 | ]
44 |
45 | ```
46 |
47 | ### Step 4: Add Dioxus and the Web Renderer as Dependencies
48 |
49 | Add Dioxus and the web renderer as dependencies to your project, modify your `Cargo.toml` file as follows:
50 |
51 |
52 | ```rust
53 | ...
54 |
55 | [dependencies]
56 | # dioxus
57 | dioxus = "0.4.3"
58 | dioxus-web = "0.4.3"
59 | ```
60 |
61 | ## Tailwind Configuration
62 |
63 | Tailwind CSS is a utility-first CSS framework that can be used with Dioxus to build custom designs.
64 |
65 | ### Step 1: Install Node Package Manager and Tailwind CSS CLI
66 |
67 | Install [Node Package Manager (npm)](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) and the [Tailwind CSS CLI](https://tailwindcss.com/docs/installation).
68 |
69 | ### Step 2: Initialize a Tailwind CSS Project
70 |
71 | Initialize a new Tailwind CSS project using the following command:
72 |
73 | ```bash
74 | cd front
75 | npx tailwindcss init
76 | ```
77 |
78 | This command creates a `tailwind.config.js` file in your project's root directory.
79 |
80 | ### Step 3: Modify the Tailwind Configuration File
81 |
82 | Edit the `tailwind.config.js` file to include Rust, HTML, and CSS files from the `src` directory and HTML files from the `dist` directory:
83 |
84 | ```json
85 | module.exports = {
86 | mode: "all",
87 | content: [
88 | // Include all Rust, HTML, and CSS files in the src directory
89 | "./src/**/*.{rs,html,css}",
90 | // Include all HTML files in the output (dist) directory
91 | "./dist/**/*.html",
92 | ],
93 | theme: {
94 | extend: {},
95 | },
96 | plugins: [
97 | require('tailwindcss-animated')
98 | ]
99 | }
100 | ```
101 |
102 | ### Step 4: Create an Input CSS File
103 |
104 | Create an `input.css` file at the root of `front` crate and populate it with the following content:
105 |
106 | ```css
107 | @tailwind base;
108 | @tailwind components;
109 | @tailwind utilities;
110 | ```
111 |
112 | ### Step 5: Tailwind animations
113 | Install npm package `tailwind-animated` for small animations:
114 |
115 | ```bash
116 | npm install tailwindcss-animated --save-dev
117 | ```
118 |
119 | ## Linking Dioxus with Tailwind
120 |
121 | To use Tailwind with Dioxus, create a `Dioxus.toml` file in your project's root directory. This file links to the `tailwind.css` file.
122 |
123 | ### Step 1: Create a `Dioxus.toml` File
124 |
125 | The `Dioxus.toml` file, placed inside our `front` crate root, should contain:
126 |
127 | ```toml
128 | [application]
129 |
130 | # App (Project) Name
131 | name = "rusty-films"
132 |
133 | # Dioxus App Default Platform
134 | # desktop, web, mobile, ssr
135 | default_platform = "web"
136 |
137 | # `build` & `serve` dist path
138 | out_dir = "dist"
139 |
140 | # Resource (public) file folder
141 | asset_dir = "public"
142 |
143 | [web.app]
144 |
145 | # HTML title tag content
146 | title = "🦀 | Rusty Films"
147 |
148 | [web.watcher]
149 |
150 | # When watcher trigger, regenerate the `index.html`
151 | reload_html = true
152 |
153 | # Which files or dirs will be watcher monitoring
154 | watch_path = ["src", "public"]
155 |
156 | [web.resource]
157 |
158 | # CSS style file
159 | style = ["tailwind.css"]
160 |
161 | # Javascript code file
162 | script = []
163 |
164 | [web.resource.dev]
165 |
166 | # serve: [dev-server] only
167 |
168 | # CSS style file
169 | style = []
170 |
171 | # Javascript code file
172 | script = []
173 | ```
174 |
175 | ## Update .gitignore
176 | Ignore node_modules folder in `.gitignore` file:
177 |
178 | ```diff
179 | target/
180 | Secrets*.toml
181 | static/
182 | +dist/
183 | +node_modules/
184 | ```
185 |
186 | ## Additional Steps
187 |
188 | ### Step 1: Install the Tailwind CSS IntelliSense VSCode Extension
189 |
190 | The [Tailwind CSS IntelliSense VSCode extension](https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss) can help you write Tailwind classes and components more efficiently.
191 |
192 | ### Step 2: Enable Regex Support for the Tailwind CSS IntelliSense VSCode Extension
193 |
194 | Navigate to the settings for the Tailwind CSS IntelliSense VSCode extension and locate the experimental regex support section. Edit the `setting.json` file to look like this:
195 |
196 | ```json
197 | "tailwindCSS.experimental.classRegex": ["class: \"(.*)\""],
198 | "tailwindCSS.includeLanguages": {
199 | "rust": "html"
200 | },
201 | ```
202 |
203 | This configuration enables the IntelliSense extension to recognize Tailwind classes in Rust files treated as HTML.
204 |
205 | After completing these steps, your frontend development environment should be ready. You can now start building your web application using Dioxus and Tailwind CSS.
206 |
--------------------------------------------------------------------------------
/docs/src/frontend/03_02_app_startup.md:
--------------------------------------------------------------------------------
1 | # Starting the Application
2 |
3 | Before we proceed, let's ensure that your project directory structure is set up correctly. Here's how the `front` folder should look:
4 |
5 | ```bash
6 | front
7 | ├── Cargo.toml
8 | ├── src
9 | │ └── main.rs
10 | ├── public
11 | │ └── ... (place your static files here such as images)
12 | ├── input.css
13 | ├── tailwind.config.js
14 | └── Dioxus.toml
15 | ```
16 |
17 | Let's detail the contents:
18 |
19 | - `Cargo.toml`: The manifest file for Rust's package manager, Cargo. It holds metadata about your crate and its dependencies.
20 |
21 | - `src/main.rs`: The primary entry point for your application. It contains the main function that boots your Dioxus app and the root component.
22 |
23 | - `public`: This directory is designated for public assets for your application. Static files like images should be placed here. Also, the compiled CSS file (`tailwind.css`) from the Tailwind CSS compiler will be output to this directory.
24 |
25 | - `input.css`: An input file for the Tailwind CSS compiler, which includes the basic Tailwind directives.
26 |
27 | - `tailwind.config.js`: The configuration file for Tailwind CSS. It instructs the compiler where to find your source files and other configuration details.
28 |
29 | - `Dioxus.toml`: This configuration file for Dioxus stipulates application metadata and build configurations.
30 |
31 | ## Image resources
32 |
33 | For this workshop, we have prepared a set of default images that you will be using in the development of the application. Feel free to use your own images if you wish.
34 |
35 | The images should be placed as follows:
36 |
37 | ```bash
38 | public
39 | ├── image1.png
40 | ├── image2.png
41 | ├── image3.png
42 | └── ... (rest of your images)
43 | ```
44 |
45 |
46 |
47 |
48 |
49 | Now that we've confirmed the directory structure, let's proceed to initialize your application...
50 |
51 | To initialize your application, modify your `main.rs` file as follows:
52 |
53 | ```rust
54 | #![allow(non_snake_case)]
55 | // Import the Dioxus prelude to gain access to the `rsx!` macro and the `Scope` and `Element` types.
56 | use dioxus::prelude::*;
57 |
58 | fn main() {
59 | // Launch the web application using the App component as the root.
60 | dioxus_web::launch(App);
61 | }
62 |
63 | // Define a component that renders a div with the text "Hello, world!"
64 | fn App(cx: Scope) -> Element {
65 | cx.render(rsx! {
66 | div {
67 | "Hello, DevBcn!"
68 | }
69 | })
70 | }
71 | ```
72 |
73 | With this setup, we've created a basic Dioxus web application that will display "Hello, world!" when run.
74 |
75 | To launch our application in development mode, we'll need to perform two steps concurrently in separate terminal processes. Navigate to the `front` crate folder that was generated earlier, and proceed as follows:
76 |
77 | 1. **Start the Tailwind CSS compiler**: Run the following command to initiate the Tailwind CSS compiler in watch mode. This will continuously monitor your `input.css` file for changes, compile the CSS using your Tailwind configuration, and output the results to `public/tailwind.css`.
78 |
79 | ```bash
80 | npx tailwindcss -i ./input.css -o ./public/tailwind.css --watch
81 | ```
82 |
83 | 2. **Launch Dioxus in serve mode**: Run the following command to start the Dioxus development server. This server will monitor your source code for changes, recompile your application as necessary, and serve the resulting web application.
84 |
85 | ```bash
86 | dioxus serve --port 8000
87 | ```
88 |
89 | Now, your development environment is up and running. Changes you make to your source code will automatically be reflected in the served application, thanks to the watching capabilities of both the Tailwind compiler and the Dioxus server. You're now ready to start building your Dioxus application!
90 |
91 | ## Logging
92 |
93 | For applications that run in the browser, having a logging mechanism can be very useful for debugging and understanding the application's behavior.
94 |
95 | The first step towards this involves installing the `wasm-logger` crate. You can do this by running the following command:
96 |
97 | ```diff
98 | ...
99 | [dependencies]
100 | # dioxus
101 | dioxus = "0.4.3"
102 | dioxus-web = "0.4.3"
103 | +log = "0.4.19"
104 | +wasm-logger = "0.2.0"
105 | ```
106 |
107 | Once `wasm-logger` is installed, you need to initialize it in your `main.rs` file. Here's how you can do it:
108 |
109 | `main.rs`
110 | ```diff
111 | ...
112 | fn main() {
113 | + wasm_logger::init(wasm_logger::Config::default().module_prefix("front"));
114 | // launch the web app
115 | dioxus_web::launch(App);
116 | }
117 | ...
118 | ```
119 |
120 | With the logger initialized, you can now log messages to your browser's console. The following is an example of how you can log an informational message:
121 |
122 | ```admonish example
123 | log::info!("Message on my console");
124 | ```
125 |
126 | By using this logging mechanism, you can make your debugging process more straightforward and efficient.
127 |
--------------------------------------------------------------------------------
/docs/src/frontend/03_03_01_layout.md:
--------------------------------------------------------------------------------
1 | # Layout Components
2 |
3 | First up, we're going to craft some general layout components for our app. This is a nice, gentle introduction to creating components, and we'll also get some reusable pieces out of it. We're going to create:
4 | - `Header` component
5 | - `Footer` component
6 | - We'll also tweak the `App` component to incorporate these new components
7 |
8 | ## Components Folder
9 |
10 | Time to get our code all nice and organized! We're going to make a `components` folder in our `src` directory. This is where we'll store all of our components. This way, we can easily import them into our `main.rs` file. Neat, right?
11 |
12 | If you want to get a deeper understanding of how to structure your code within a Rust project, the Rust Lang book has a fantastic section on it called [Managing Growing Projects with Packages, Crates, and Modules](https://doc.rust-lang.org/book/ch07-00-managing-growing-projects-with-packages-crates-and-modules.html). Definitely worth checking out!
13 |
14 | Here's what our new structure will look like:
15 |
16 | ```bash
17 | └── src # Source code
18 | ├── components # Components folder
19 | │ ├── mod.rs # Components module
20 | │ ├── footer.rs # Footer component
21 | │ └── header.rs # Header component
22 | ```
23 |
24 | And let's take a peek at what our `mod.rs` file should look like:
25 |
26 | ```rust
27 | mod footer;
28 | mod header;
29 |
30 | pub use footer::Footer;
31 | pub use header::Header;
32 | ```
33 |
34 | We've got our `mod.rs` pulling double duty here. First, it's declaring our `footer` and `header` modules. Then, it's making `Footer` and `Header` available for other modules to use. This sets us up nicely for using these components in our `main.rs` file.
35 |
36 | ## Header Component
37 |
38 | Alright, let's start with the `Header` component. For now, we're keeping it simple, just displaying our app's title and a logo.
39 |
40 | Whenever you're building a new component or working in our `main.rs` file, remember to import `dioxus::prelude::*`. It gives you access to all the macros and functions you need.
41 |
42 | ```admonish title="Tailwind CSS"
43 | You can adjust the Tailwind classes to suit your style.
44 | ```
45 |
46 | `front/src/components/header.rs`
47 | ```rust
48 | use dioxus::prelude::*;
49 |
50 | pub fn Header(cx: Scope) -> Element {
51 | cx.render(rsx!(
52 | header {
53 | class: "sticky top-0 z-10 text-gray-400 bg-blue-300 body-font shadow-md",
54 | div { class: "container mx-auto flex flex-wrap p-0 flex-col md:flex-row justify-between items-center",
55 | a {
56 | class: "flex title-font font-medium items-center text-teal-950 mb-4 md:mb-0",
57 | img {
58 | class: "bg-transparent p-2 animate-jump",
59 | alt: "ferris",
60 | src: "ferris.png",
61 | "loading": "lazy"
62 | }
63 | span { class: "ml-3 text-2xl", "Rusty films"}
64 | }
65 | }
66 | }
67 | ))
68 | }
69 | ```
70 |
71 | ## Footer Component
72 |
73 | Next up, we're going to build the `Footer` component. This one's pretty straightforward – we're just going to stick a couple of images at the bottom of our app.
74 |
75 | `front/src/components/footer.rs`
76 | ```rust
77 | use dioxus::prelude::*;
78 |
79 | pub fn Footer(cx: Scope) -> Element {
80 | cx.render(rsx!(
81 | footer {
82 | class: "bg-blue-200 w-full h-16 p-2 box-border gap-6 flex flex-row justify-center items-center text-teal-950",
83 | a {
84 | class: "w-auto h-full",
85 | href: "https://www.devbcn.com/",
86 | target: "_blank",
87 | img {
88 | class: "h-full w-auto",
89 | alt: "DevBcn",
90 | src: "devbcn.png",
91 | "loading": "lazy"
92 | }
93 | }
94 | svg {
95 | fill: "none",
96 | view_box: "0 0 24 24",
97 | stroke_width: "1.5",
98 | stroke: "currentColor",
99 | class: "w-6 h-6",
100 | path {
101 | stroke_linecap: "round",
102 | stroke_linejoin: "round",
103 | d: "M6 18L18 6M6 6l12 12"
104 | }
105 | }
106 | a {
107 | class: "w-auto h-full",
108 | href: "https://www.meetup.com/es-ES/bcnrust/",
109 | target: "_blank",
110 | img {
111 | class: "h-full w-auto",
112 | alt: "BcnRust",
113 | src: "bcnrust.png",
114 | "loading": "lazy"
115 | }
116 | }
117 | }
118 | ))
119 | }
120 | ```
121 |
122 | Just like we did with the `Header` component, remember to import `dioxus::prelude::*` to have access to all the macros and functions we need. And feel free to change up the Tailwind classes to fit your design.
123 |
124 | Now, we've got a `Header` and `Footer` ready to roll. Next, let's update our `App` component to use these new elements.
125 |
126 | `front/src/main.rs`
127 | ```diff
128 | #![allow(non_snake_case)]
129 | // Import the Dioxus prelude to gain access to the `rsx!` macro and the `Scope` and `Element` types.
130 | +mod components;
131 |
132 | +use components::{Footer, Header};
133 | use dioxus::prelude::*;
134 |
135 |
136 | fn main() {
137 | // Launch the web application using the App component as the root.
138 | dioxus_web::launch(App);
139 | }
140 |
141 | // Define a component that renders a div with the text "Hello, world!"
142 | fn App(cx: Scope) -> Element {
143 | cx.render(rsx! {
144 | - div {
145 | - "Hello, world!"
146 | - }
147 | + main {
148 | + class: "relative z-0 bg-blue-100 w-screen h-auto min-h-screen flex flex-col justify-start items-stretch",
149 | + Header {}
150 | + section {
151 | + class: "md:container md:mx-auto md:py-8 flex-1",
152 | + }
153 | + Footer {}
154 | + }
155 | })
156 | }
157 | ```
158 |
--------------------------------------------------------------------------------
/docs/src/frontend/03_03_components.md:
--------------------------------------------------------------------------------
1 | # Components
2 |
3 | Alright, let's roll up our sleeves and dive into building some reusable components for our app. We'll start with layout components and then craft some handy components that we can use all over our app.
4 |
5 | When you're putting together a component, keep these points in mind:
6 | - Always remember to import `dioxus::prelude::*`. This gives you all the macros and functions you need, right at your fingertips.
7 | - Create a `pub fn` with your chosen component name.
8 | - Your function should include a `cx: Scope` parameter.
9 | - It should return an `Element` type.
10 |
11 | The real meat of our component is in the `cx.render` function. This is where the `rsx!` macro comes into play to create the markup of the component. You can put together your markup using html tags, attributes, and text.
12 |
13 | Inside html tags, you can go wild with any attributes you want. Dioxus has a ton of them ready for you to use. But if you can't find what you're looking for, no problem! You can add it yourself using "double quotes".
14 |
15 | ```rust
16 | use dioxus::prelude::*;
17 |
18 | pub fn MyComponent(cx: Scope) -> Element {
19 | cx.render(rsx!(
20 | div {
21 | class: "my-component",
22 | "data-my-attribute": "my value",
23 | "My component"
24 | }
25 | ))
26 | }
27 | ```
28 |
--------------------------------------------------------------------------------
/docs/src/frontend/03_04_01_global_state.md:
--------------------------------------------------------------------------------
1 | # Implementing Global State
2 |
3 | To begin, let's create a global state responsible for managing the visibility of our Film Modal.
4 |
5 | We will utilize a functionality similar to React's Context. This approach allows us to establish a context that will be accessible to all components contained within the context provider. To this end, we will construct a `use_shared_state_provider` that will be located within our `App` component.
6 |
7 | The value should be initialized using a closure.
8 |
9 | `front/src/main.rs`
10 | ```diff
11 | ...
12 | use components::{FilmModal, Footer, Header};
13 | use dioxus::prelude::*;
14 | +use models::FilmModalVisibility;
15 | ...
16 |
17 | fn App(cx: Scope) -> Element {
18 | + use_shared_state_provider(cx, || FilmModalVisibility(false));
19 |
20 | ...
21 |
22 | }
23 | ```
24 |
25 | Now, by leveraging the `use_shared_state` hook, we can both retrieve the state and modify it. Therefore, it is necessary to incorporate this hook in locations where we need to read or alter the Film Modal visibility.
26 |
27 | `front/src/components/header.rs`
28 | ```diff
29 | use dioxus::prelude::*;
30 | +use crate::{
31 | + components::Button,
32 | + models::{ButtonType, FilmModalVisibility},
33 | +};
34 | ...
35 |
36 | pub fn Header(cx: Scope) -> Element {
37 | + let is_modal_visible = use_shared_state::(cx).unwrap();
38 |
39 | cx.render(rsx!(
40 | header {
41 | class: "sticky top-0 z-10 text-gray-400 bg-blue-300 body-font shadow-md",
42 | div { class: "container mx-auto flex flex-wrap p-0 flex-col md:flex-row justify-between items-center",
43 | a {
44 | class: "flex title-font font-medium items-center text-teal-950 mb-4 md:mb-0",
45 | img {
46 | class: "bg-transparent p-2 animate-jump",
47 | alt: "ferris",
48 | src: "ferris.png",
49 | "loading": "lazy"
50 | }
51 | span { class: "ml-3 text-2xl", "Rusty films"}
52 | }
53 | + Button {
54 | + button_type: ButtonType::Primary,
55 | + onclick: move |_| {
56 | + is_modal_visible.write().0 = true;
57 | + },
58 | + "Add new film"
59 | + }
60 | }
61 | }
62 | ))
63 | }
64 | ```
65 |
66 | The value can be updated using the `write` method, which returns a mutable reference to the value. Consequently, we can use the `=` operator to update the visibility of the Film Modal when the button is clicked.
67 |
68 | `front/src/components/film_modal.rs`
69 | ```diff
70 | ...
71 | -use crate::models::{ButtonType};
72 | +use crate::models::{ButtonType, FilmModalVisibility};
73 | ...
74 | pub fn FilmModal<'a>(cx: Scope<'a, FilmModalProps>) -> Element<'a> {
75 | + let is_modal_visible = use_shared_state::(cx).unwrap();
76 |
77 | ...
78 | + if !is_modal_visible.read().0 {
79 | + return None;
80 | + }
81 | ...
82 | }
83 | ```
84 |
85 | This demonstrates an additional concept of Dioxus: **dynamic rendering**. Essentially, the component is only rendered if the condition is met.
86 | ```admonish info title="Dynamic Rendering"
87 | Dynamic rendering is a technique that enables rendering different content based on a condition. Further information can be found in the [Dioxus Dynamic Rendering documentation](https://dioxuslabs.com/docs/0.3/guide/en/interactivity/dynamic_rendering.html)
88 | ```
89 |
90 | `front/src/main.rs`
91 | ```diff
92 | ...
93 |
94 | fn App(cx: Scope) -> Element {
95 | use_shared_state_provider(cx, || FilmModalVisibility(false));
96 | + let is_modal_visible = use_shared_state::(cx).unwrap();
97 |
98 |
99 | ...
100 | cx.render(rsx! {
101 | main {
102 | ...
103 | FilmModal {
104 | on_create_or_update: move |_| {},
105 | on_cancel: move |_| {
106 | + is_modal_visible.write().0 = false;
107 | }
108 | }
109 | }
110 | })
111 | }
112 | ```
113 | In the same manner we open the modal by altering the value, we can also close it. Here, we close the modal when the cancel button is clicked, invoking the `write` method to update the value.
--------------------------------------------------------------------------------
/docs/src/frontend/03_04_03_effects.md:
--------------------------------------------------------------------------------
1 | # App Effects
2 |
3 | Alright folks, we've got our state management all set up. Now, the magic happens! We need to synchronize the values of that state when different parts of our app interact with our users.
4 |
5 | Imagine our first call to the API to fetch our freshly minted films, or the moment when we open the Film Modal in edit mode. We need to pre-populate the form with the values of the film we're about to edit.
6 |
7 | No sweat, we've got the `use_effect` hook to handle this. This useful hook allows us to execute a function when a value changes, or when the component is mounted or unmounted. Pretty cool, huh?
8 |
9 | Now, let's break down the key parts of the `use_effect` hook:
10 | - It should be nestled inside a closure function.
11 | - If we're planning to use a `use_state` hook inside it, we need to `clone()` it or pass the ownership using `to_owned()` to the closure function.
12 | - The parameters inside the `use_effect()` function include the Scope of our app (`cx`), the `dependencies` that will trigger the effect again, and a `future` that will spring into action when the effect is triggered.
13 |
14 | Here's a quick look at how it works:
15 |
16 | ```rust
17 | {
18 | let some_state = some_state.clone();
19 | use_effect(cx, change_dependency, |_| async move {
20 | // Do something with some_state or something else
21 | })
22 | }
23 | ```
24 |
25 | ## Film Modal
26 |
27 | We will begin by adapting our `FilmModal` component. This will be modified to pre-populate the form with the values of the film that is currently being edited. To accomplish this, we will use the `use_effect` hook.
28 |
29 | `front/src/components/film_modal.rs`
30 | ```diff
31 | ...
32 |
33 | pub fn FilmModal<'a>(cx: Scope<'a, FilmModalProps>) -> Element<'a> {
34 | let is_modal_visible = use_shared_state::(cx).unwrap();
35 | let draft_film = use_state::(cx, || Film {
36 | title: "".to_string(),
37 | poster: "".to_string(),
38 | director: "".to_string(),
39 | year: 1900,
40 | id: Uuid::new_v4(),
41 | created_at: None,
42 | updated_at: None,
43 | });
44 |
45 | + {
46 | + let draft_film = draft_film.clone();
47 | + use_effect(cx, &cx.props.film, |film| async move {
48 | + match film {
49 | + Some(film) => draft_film.set(film),
50 | + None => draft_film.set(Film {
51 | + title: "".to_string(),
52 | + poster: "".to_string(),
53 | + director: "".to_string(),
54 | + year: 1900,
55 | + id: Uuid::new_v4(),
56 | + created_at: None,
57 | + updated_at: None,
58 | + }),
59 | + }
60 | + });
61 | + }
62 |
63 | ...
64 | }
65 | ```
66 |
67 | In essence, we are initiating an effect when the `film` property changes. If the `film` property is `Some(film)`, we set the `draft_film` state to the value of the `film` property. If the `film` property is `None`, we set the `draft_film` state to a new `Film` initial object.
68 |
69 | ## App Component
70 |
71 | Next, we will adapt our `App` component to fetch the films from the API when the app is mounted or when we need to force the API to update the list of films. We'll accomplish this by modifying `force_get_films`. As this state has no type or initial value, it is solely used to trigger the effect.
72 |
73 | We will also add HTTP request configurations to enable these functions. We will use the `reqwest` crate for this purpose, which can be added to our `Cargo.toml` file or installed with the following command:
74 |
75 | ```bash
76 | cargo add reqwest
77 | ```
78 |
79 | To streamline future requests, we will create a `films_endpoint()` function to return the URL of our API endpoint.
80 |
81 | First install some missing dependencies by updating our `Cargo.toml`.
82 |
83 | `front/Cargo.toml`
84 | ```diff
85 | +reqwest = { version = "0.11.18", features = ["json"] }
86 | +web-sys = "0.3.64"
87 | +serde = { version = "1.0.164", features = ["derive"] }
88 | ```
89 |
90 | After that, here are the necessary modifications for the `App` component:
91 |
92 | `front/src/main.rs`
93 | ```diff
94 | ...
95 |
96 | +const API_ENDPOINT: &str = "api/v1";
97 |
98 | +fn films_endpoint() -> String {
99 | + let window = web_sys::window().expect("no global `window` exists");
100 | + let location = window.location();
101 | + let host = location.host().expect("should have a host");
102 | + let protocol = location.protocol().expect("should have a protocol");
103 | + let endpoint = format!("{}//{}/{}", protocol, host, API_ENDPOINT);
104 | + format!("{}/films", endpoint)
105 | +}
106 |
107 | +async fn get_films() -> Vec {
108 | + log::info!("Fetching films from {}", films_endpoint());
109 | + reqwest::get(&films_endpoint())
110 | + .await
111 | + .unwrap()
112 | + .json::>()
113 | + .await
114 | + .unwrap()
115 | +}
116 |
117 | fn App(cx: Scope) -> Element {
118 | ...
119 | let force_get_films = use_state(cx, || ());
120 |
121 | + {
122 | + let films = films.clone();
123 |
124 |
125 | + use_effect(cx, force_get_films, |_| async move {
126 | + let existing_films = get_films().await;
127 | + if existing_films.is_empty() {
128 | + films.set(None);
129 | + } else {
130 | + films.set(Some(existing_films));
131 | + }
132 | + });
133 | + }
134 | }
135 | ```
136 |
137 | What we have done here is trigger an effect whenever there is a need to fetch films from our API. We then evaluate whether there are any films available. If there are, we set the `films` state to these existing films. If not, we set the `films` state to `None`. This allows us to enhance our `App` component with additional functionality.
138 |
--------------------------------------------------------------------------------
/docs/src/frontend/03_04_state_management.md:
--------------------------------------------------------------------------------
1 | # State Management
2 |
3 | In this part of our journey, we're going to dive into the lifeblood of the application — state management. We'll tackle this crucial aspect in two stages: local state management and global state management.
4 |
5 | While we're only scratching the surface to get the application up and running, it's highly recommended that you refer to the [Dioxus Interactivity](https://dioxuslabs.com/docs/0.3/guide/en/interactivity/index.html) documentation. This way, you'll not only comprehend how it operates more fully, but also grasp the extensive capabilities the framework possesses.
6 |
7 | For now, let's start with the basics. **Dioxus** as is very influenced by *React* and its ecosystem, so it's no surprise that it uses the same approach to state management, Hooks.
8 | Hooks are Rust functions that take a reference to `ScopeState` (in a component, you can pass `cx`), and provide you with functionality and state. **Dioxus** allows hooks to maintain state across renders through a reference to `ScopeState`, which is why you must pass `&cx` to them.
9 |
10 | ```admonish tip title="Rules of Hooks"
11 | 1. Hooks may be only used in components or other hooks
12 | 2. On every call to the component function
13 | 1. The same hooks must be called
14 | 2. In the same order
15 | 3. Hooks name's should start with `use_` so you don't accidentally confuse them with regular functions
16 | ```
17 |
--------------------------------------------------------------------------------
/docs/src/frontend/03_05_event_handlers.md:
--------------------------------------------------------------------------------
1 | # Event Handlers
2 |
3 | Event handlers are crucial elements in an interactive application. These functions are invoked in response to certain user events like mouse clicks, keyboard input, or form submissions.
4 |
5 | In the final section of this guide, we will introduce interactivity to our application by implementing creation, updating, and deletion of film actions. For this, we will be spawning `futures` using `cx.spawn` and `async move` closures. It is crucial to remember that `use_state` values should be cloned before being used in `async move` closures.
6 |
7 | ## delete_film Function
8 |
9 | This function will be triggered when a user clicks the delete button of a film card. It will send a `DELETE` request to our API and subsequently call `force_get_films` to refresh the list of films. In the event of a successful operation, a message will be logged to the console. If an error occurs, the error will be logged instead.
10 |
11 | ```rust
12 | let delete_film = move |filmId| {
13 | let force_get_films = force_get_films.clone();
14 | cx.spawn({
15 | async move {
16 | let response = reqwest::Client::new()
17 | .delete(&format!("{}/{}", &films_endpoint(), filmId))
18 | .send()
19 | .await;
20 | match response {
21 | Ok(_data) => {
22 | log::info!("Film deleted");
23 | force_get_films.set(());
24 | }
25 | Err(err) => {
26 | log::info!("Error deleting film: {:?}", err);
27 | }
28 | }
29 | }
30 | });
31 | };
32 | ```
33 |
34 | ## create_or_update_film Function
35 |
36 | This function is invoked when the user clicks the create or update button of the film modal. It sends a `POST` or `PUT` request to our API, followed by a call to `force_get_films` to update the list of films. The decision to edit or create a film depends on whether the `selected_film` state is `Some(film)` or `None`.
37 |
38 | In case of success, a console message is logged, the `selected_film` state is reset, and the modal is hidden. If an error occurs, the error is logged.
39 |
40 | ```rust
41 | let create_or_update_film = move |film: Film| {
42 | let force_get_films = force_get_films.clone();
43 | let current_selected_film = selected_film.clone();
44 | let is_modal_visible = is_modal_visible.clone();
45 |
46 | cx.spawn({
47 | async move {
48 | let response = if current_selected_film.get().is_some() {
49 | reqwest::Client::new()
50 | .put(&films_endpoint())
51 | .json(&film)
52 | .send()
53 | .await
54 | } else {
55 | reqwest::Client::new()
56 | .post(&films_endpoint())
57 | .json(&film)
58 | .send()
59 | .await
60 | };
61 | match response {
62 | Ok(_data) => {
63 | log::info!("Film created");
64 | current_selected_film.set(None);
65 | is_modal_visible.write().0 = false;
66 | force_get_films.set(());
67 | }
68 | Err(err) => {
69 | log::info!("Error creating film: {:?}", err);
70 | }
71 | }
72 | }
73 | });
74 | };
75 | ```
76 |
77 | ## Final Adjustments
78 |
79 | All the subsequent modifications will be implemented on our `App` component.
80 |
81 | `front/src/main.rs`
82 | ```diff
83 | ...
84 |
85 | fn App(cx: Scope) -> Element {
86 | ...
87 | {
88 | let films = films.clone();
89 | use_effect(cx, force_get_films, |_| async move {
90 | let existing_films = get_films().await;
91 | if existing_films.is_empty() {
92 | films.set(None);
93 | } else {
94 | films.set(Some(existing_films));
95 |
96 |
97 | }
98 | });
99 | }
100 |
101 | + let delete_film = move |filmId| {
102 | + let force_get_films = force_get_films.clone();
103 | + cx.spawn({
104 | + async move {
105 | + let response = reqwest::Client::new()
106 | + .delete(&format!("{}/{}", &films_endpoint(), filmId))
107 | + .send()
108 | + .await;
109 | + match response {
110 | + Ok(_data) => {
111 | + log::info!("Film deleted");
112 | + force_get_films.set(());
113 | + }
114 | + Err(err) => {
115 | + log::info!("Error deleting film: {:?}", err);
116 | + }
117 | + }
118 | + }
119 | + });
120 | + };
121 |
122 | + let create_or_update_film = move |film: Film| {
123 | + let force_get_films = force_get_films.clone();
124 | + let current_selected_film = selected_film.clone();
125 | + let is_modal_visible = is_modal_visible.clone();
126 | + cx.spawn({
127 | + async move {
128 | + let response = if current_selected_film.get().is_some() {
129 | + reqwest::Client::new()
130 | + .put(&films_endpoint())
131 | + .json(&film)
132 | + .send()
133 | + .await
134 | + } else {
135 | + reqwest::Client::new()
136 | + .post(&films_endpoint())
137 | + .json(&film)
138 | + .send()
139 | + .await
140 | + };
141 | + match response {
142 | + Ok(_data) => {
143 | + log::info!("Film created");
144 | + current_selected_film.set(None);
145 | + is_modal_visible.write().0 = false;
146 | + force_get_films.set(());
147 | + }
148 | + Err(err) => {
149 | + log::info!("Error creating film: {:?}", err);
150 | + }
151 | + }
152 | + }
153 | + });
154 | + };
155 |
156 | cx.render(rsx! {
157 | ...
158 | section {
159 | class: "md:container md:mx-auto md:py-8 flex-1",
160 | rsx!(
161 | if let Some(films) = films.get() {
162 | ul {
163 | class: "flex flex-row justify-center items-stretch gap-4 flex-wrap",
164 | {films.iter().map(|film| {
165 | rsx!(
166 | FilmCard {
167 | key: "{film.id}",
168 | film: film,
169 | on_edit: move |_| {
170 | selected_film.set(Some(film.clone()));
171 | is_modal_visible.write().0 = true
172 | },
173 | - on_delete: move |_| {}
174 | + on_delete: move |_| {
175 | + delete_film(film.id);
176 | + }
177 | }
178 | )
179 | })}
180 | }
181 | )
182 | }
183 | }
184 | FilmModal {
185 | film: selected_film.get().clone(),
186 | - on_create_or_update: move |new_film| {},
187 | + on_create_or_update: move |new_film| {
188 | + create_or_update_film(new_film);
189 | + },
190 | on_cancel: move |_| {
191 | selected_film.set(None);
192 | is_modal_visible.write().0 = false;
193 | }
194 | }
195 | })
196 | }
197 | ```
198 |
199 | Upon successful implementation of the above changes, the application should now have the capability to create, update, and delete films.
--------------------------------------------------------------------------------
/docs/src/frontend/03_06_building.md:
--------------------------------------------------------------------------------
1 | # Building for production
2 |
3 | Inside our workspace **root** we some handy `cargo-make` tasks for the frontend also. Let's use one of them for building our frontend for production.
4 |
5 | ```bash
6 | makers front-build
7 | ```
8 |
9 | This will build our frontend for production and place the output in the `shuttle/static` directory. Now we can serve our frontend with the backend. Let's deploy it with Shuttle and see our results.
10 |
11 | ```bash
12 | cargo shuttle deploy
13 | ```
14 |
15 | Once the app is deploy it will look like this if everything went well.
16 | 
--------------------------------------------------------------------------------
/docs/src/frontend/03_frontend.md:
--------------------------------------------------------------------------------
1 | # Frontend
2 |
3 | In this guide, we'll be using [Dioxus](https://dioxuslabs.com/) as the frontend for our project. Dioxus is a portable, performant, and ergonomic framework for building cross-platform user interfaces in Rust. Heavily inspired by React, Dioxus allows you to build apps for the Web, Desktop, Mobile, and more. Its core implementation can run anywhere with no platform-dependent linking, which means it's not intrinsically linked to WebSys like many other Rust frontend toolkits. However, it's important to note that Dioxus hasn't reached a stable release yet, so some APIs, particularly for Desktop, may still be unstable.
4 |
5 | As for styling our app, we'll be using [Tailwind CSS](https://tailwindcss.com/). Tailwind is a highly customizable, low-level CSS framework that gives you all of the building blocks you need to build bespoke designs without any annoying opinionated styles you have to fight to override. You can set it up in your project, build something with it in an online playground, and even learn more about it directly from the team on their channel. Tailwind also offers a set of beautiful UI components crafted by its creators to help you speed up your development process.
6 |
7 | This combination of tools will allow us to concentrate our energy on frontend development in Rust, rather than spending excessive time on styling our app.
8 |
9 | In our guide, we'll be providing hints on how to use Tailwind classes with our Dioxus components. This way, you can focus on the logic of your components, while still being able to apply responsive, modern styles to them.
10 |
--------------------------------------------------------------------------------
/docs/src/prerequisites.md:
--------------------------------------------------------------------------------
1 | # Prerequisites
2 |
3 | In order to start the workshop there are a few things that we will have to **install or set up**.
4 |
5 | ## Rust
6 |
7 | If you don't have [Rust](https://www.rust-lang.org) installed in your machine yet, please follow [these instructions](https://www.rust-lang.org/tools/install).
8 |
9 | ## Visual Studio Code
10 |
11 | You can use whatever IDE you want but we're going to use [Visual Studio Code](https://code.visualstudio.com/) as our **code editor**.
12 |
13 | If you're going to use [Visual Studio Code](https://code.visualstudio.com/) as well, please install the following extensions:
14 |
15 | - [Rust Analyzer](https://marketplace.visualstudio.com/items?itemName=matklad.rust-analyzer)
16 | - [CodeLLDB](https://marketplace.visualstudio.com/items?itemName=vadimcn.vscode-lldb)
17 | - [Crates](https://marketplace.visualstudio.com/items?itemName=serayuzgur.crates)
18 | - [Better TOML](https://marketplace.visualstudio.com/items?itemName=bungcip.better-toml)
19 | - [Rust Test Explorer](https://marketplace.visualstudio.com/items?itemName=swellaby.vscode-rust-test-adapter)
20 | - [REST Client](https://marketplace.visualstudio.com/items?itemName=humao.rest-client)
21 |
22 | ## Shuttle
23 |
24 | This is the [tool and platform](https://shuttle.rs) that we're going to use to deploy our **backend** (api & database).
25 |
26 | You can follow [this installation guide](https://docs.shuttle.rs/introduction/installation) or just do:
27 |
28 | ```sh
29 | cargo install cargo-shuttle
30 | ```
31 |
32 | ## Dioxus
33 |
34 | [Dioxus](https://dioxuslabs.com/) is the framework that we're going to use to build our **frontend**.
35 |
36 | Be sure to install the [Dioxus CLI](https://github.com/DioxusLabs/cli):
37 |
38 | ```sh
39 | cargo install dioxus-cli
40 | ```
41 |
42 | After that, make sure the `wasm32-unknown-unknown` target for [Rust](https://www.rust-lang.org) is installed:
43 |
44 | ```sh
45 | rustup target add wasm32-unknown-unknown
46 | ```
47 |
48 | ## Docker
49 |
50 | We will also need to have [Docker](https://www.docker.com/) installed in order to **deploy locally** while we're developing the backend.
51 |
52 | ## DBeaver
53 |
54 | We will use [DBeaver](https://dbeaver.io/) to **connect to the database** and run queries. Feel free to use any other tool that you prefer.
55 |
56 |
57 | ## cargo-watch
58 |
59 | We will also use [cargo-watch](https://github.com/watchexec/cargo-watch) to **automatically recompile** our backend when we make changes to the code.
60 |
61 | ```sh
62 | cargo install cargo-watch
63 | ```
64 |
65 | ## cargo-make
66 |
67 | Finally, let's install [cargo-make](https://github.com/sagiegurari/cargo-make):
68 |
69 | ```sh
70 | cargo install cargo-make
71 | ```
72 |
73 | We're going to leverage [cargo-make](https://github.com/sagiegurari/cargo-make) to **run all the commands** that we need to run in order to build and deploy our backend and frontend.
74 |
--------------------------------------------------------------------------------
/front/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "front"
3 | version = "0.1.0"
4 | edition = "2021"
5 |
6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
7 |
8 | [dependencies]
9 | # shared
10 | shared = { workspace = true }
11 | # dioxus
12 | dioxus = "0.4.3"
13 | dioxus-web = "0.4.3"
14 | reqwest = { version = "0.11.18", features = ["json"] }
15 | serde = { workspace = true }
16 | uuid = { workspace = true }
17 | log = "0.4.19"
18 | wasm-logger = "0.2.0"
19 | web-sys = "0.3.64"
20 |
--------------------------------------------------------------------------------
/front/Dioxus.toml:
--------------------------------------------------------------------------------
1 | [application]
2 |
3 | # App (Project) Name
4 | name = "rusty-films"
5 |
6 | # Dioxus App Default Platform
7 | # desktop, web, mobile, ssr
8 | default_platform = "web"
9 |
10 | # `build` & `serve` dist path
11 | out_dir = "dist"
12 |
13 | # resource (public) file folder
14 | asset_dir = "public"
15 |
16 | [web.app]
17 |
18 | # HTML title tag content
19 | title = "🦀 | Rusty Films"
20 |
21 | [web.watcher]
22 |
23 | # when watcher trigger, regenerate the `index.html`
24 | reload_html = true
25 |
26 | # which files or dirs will be watcher monitoring
27 | watch_path = ["src", "public"]
28 |
29 | # include `assets` in web platform
30 | [web.resource]
31 |
32 | # CSS style file
33 | style = ["tailwind.css"]
34 |
35 | # Javascript code file
36 | script = []
37 |
38 | [web.resource.dev]
39 |
40 | # serve: [dev-server] only
41 |
42 | # CSS style file
43 | style = []
44 |
45 | # Javascript code file
46 | script = []
47 |
--------------------------------------------------------------------------------
/front/README.md:
--------------------------------------------------------------------------------
1 | # Setup
2 |
3 | 1. Install the Dioxus CLI:
4 |
5 | ```bash
6 | cargo install dioxus-cli
7 | ```
8 |
9 | Make sure the `wasm32-unknown-unknown` target for rust is installed:
10 |
11 | ```bash
12 | rustup target add wasm32-unknown-unknown
13 | ```
14 |
15 | 2. Install npm: https://docs.npmjs.com/downloading-and-installing-node-js-and-npm
16 | 3. Install the tailwind css cli: https://tailwindcss.com/docs/installation
17 | 4. Initialize the tailwind css project:
18 |
19 | ```bash
20 | npx tailwindcss init
21 | ```
22 |
23 | Create frontend crate:
24 |
25 | ```bash
26 | cargo new --bin demo
27 | cd demo
28 | ```
29 |
30 | Add Dioxus and the web renderer as dependencies (this will edit your `Cargo.toml`):
31 |
32 | ```bash
33 | cargo add dioxus
34 | cargo add dioxus-web
35 | ```
36 |
37 | This should create a `tailwind.config.js` file in the root of the project.
38 |
39 | 5. Edit the `tailwind.config.js` file to include rust files:
40 |
41 | ```json
42 | module.exports = {
43 | mode: "all",
44 | content: [
45 | // include all rust, html and css files in the src directory
46 | "./src/**/*.{rs,html,css}",
47 | // include all html files in the output (dist) directory
48 | "./dist/**/*.html",
49 | ],
50 | theme: {
51 | extend: {},
52 | },
53 | plugins: [],
54 | }
55 | ```
56 |
57 | 6. Create a `input.css` file with the following content:
58 |
59 | ```css
60 | @tailwind base;
61 | @tailwind components;
62 | @tailwind utilities;
63 | ```
64 |
65 | 7. Create a `Dioxus.toml` file with the following content that links to the `tailwind.css` file:
66 |
67 | ```toml
68 | [application]
69 |
70 | # App (Project) Name
71 | name = "Rusty Films"
72 |
73 | # Dioxus App Default Platform
74 | # desktop, web, mobile, ssr
75 | default_platform = "web"
76 |
77 | # `build` & `serve` dist path
78 | out_dir = "dist"
79 |
80 | # resource (public) file folder
81 | asset_dir = "public"
82 |
83 | [web.app]
84 |
85 | # HTML title tag content
86 | title = "🦀 | Rusty Films"
87 |
88 | [web.watcher]
89 |
90 | # when watcher trigger, regenerate the `index.html`
91 | reload_html = true
92 |
93 | # which files or dirs will be watcher monitoring
94 | watch_path = ["src", "public"]
95 |
96 | # include `assets` in web platform
97 | [web.resource]
98 |
99 | # CSS style file
100 | style = ["tailwind.css"]
101 |
102 | # Javascript code file
103 | script = []
104 |
105 | [web.resource.dev]
106 |
107 | # serve: [dev-server] only
108 |
109 | # CSS style file
110 | style = []
111 |
112 | # Javascript code file
113 | script = []
114 | ```
115 |
116 | ## Bonus Steps
117 |
118 | 8. Install the tailwind css vs code extension
119 | 9. Go to the settings for the extension and find the experimental regex support section. Edit the setting.json file to look like this:
120 |
121 | ```json
122 | "tailwindCSS.experimental.classRegex": ["class: \"(.*)\""],
123 | "tailwindCSS.includeLanguages": {
124 | "rust": "html"
125 | },
126 | ```
127 |
128 | # Development
129 |
130 | 1. Run the following command in the root of the project to start the tailwind css compiler:
131 |
132 | ```bash
133 | npx tailwindcss -i ./input.css -o ./public/tailwind.css --watch
134 | ```
135 |
136 | ## Web
137 |
138 | - Run the following command in the root of the project to start the dioxus dev server:
139 |
140 | ```bash
141 | dioxus serve --port 8000
142 | ```
143 |
144 | - Open the browser to http://localhost:8000
145 |
146 | # Usefull resources
147 | - [Movie posters](https://www.movieposters.com/)
148 | - [Tailwind](https://tailwindcss.com/docs/installation)
149 | - [Tailwind Animated](https://www.tailwindcss-animated.com/)
150 |
--------------------------------------------------------------------------------
/front/input.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
--------------------------------------------------------------------------------
/front/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "devDependencies": {
3 | "tailwindcss-animated": "^1.0.1"
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/front/public/bcnrust.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BcnRust/devbcn-workshop/0f45130131914a5458066a03ca19923807913ca1/front/public/bcnrust.png
--------------------------------------------------------------------------------
/front/public/devbcn.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BcnRust/devbcn-workshop/0f45130131914a5458066a03ca19923807913ca1/front/public/devbcn.png
--------------------------------------------------------------------------------
/front/public/ferris.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/BcnRust/devbcn-workshop/0f45130131914a5458066a03ca19923807913ca1/front/public/ferris.png
--------------------------------------------------------------------------------
/front/src/components/button.rs:
--------------------------------------------------------------------------------
1 | use dioxus::prelude::*;
2 |
3 | use crate::models::ButtonType;
4 |
5 | #[component]
6 | pub fn Button<'a>(
7 | cx: Scope<'a>,
8 | button_type: ButtonType,
9 | onclick: EventHandler<'a, MouseEvent>,
10 | children: Element<'a>,
11 | ) -> Element {
12 | cx.render(rsx!(button {
13 | class: "text-slate-200 inline-flex items-center border-0 py-1 px-3 focus:outline-none rounded mt-4 md:mt-0 {button_type.to_string()}",
14 | onclick: move |event| onclick.call(event),
15 | children
16 | }))
17 | }
18 |
--------------------------------------------------------------------------------
/front/src/components/film_card.rs:
--------------------------------------------------------------------------------
1 | use crate::{components::Button, models::ButtonType};
2 | use dioxus::prelude::*;
3 | use shared::models::Film;
4 |
5 | #[component]
6 | pub fn FilmCard<'a>(
7 | cx: Scope<'a>,
8 | film: &'a Film,
9 | on_edit: EventHandler<'a, MouseEvent>,
10 | on_delete: EventHandler<'a, MouseEvent>,
11 | ) -> Element {
12 | cx.render(rsx!(
13 | li {
14 | class: "film-card md:basis-1/4 p-4 rounded box-border bg-neutral-100 drop-shadow-md transition-all ease-in-out hover:drop-shadow-xl flex-col flex justify-start items-stretch animate-fade animate-duration-500 animate-ease-in-out animate-normal animate-fill-both",
15 | header {
16 | img {
17 | class: "max-h-80 w-auto mx-auto rounded",
18 | src: "{film.poster}"
19 | },
20 | }
21 | section {
22 | class: "flex-1",
23 | h3 {
24 | class: "text-lg font-bold my-3",
25 | "{film.title}"
26 | }
27 | p {
28 | "{film.director}"
29 | }
30 | p {
31 | class: "text-sm text-gray-500",
32 | "{film.year.to_string()}"
33 | }
34 | }
35 | footer {
36 | class: "flex justify-end space-x-2 mt-auto",
37 | Button {
38 | button_type: ButtonType::Secondary,
39 | onclick: move |event| on_delete.call(event),
40 | svg {
41 | fill: "none",
42 | stroke: "currentColor",
43 | stroke_width: "1.5",
44 | view_box: "0 0 24 24",
45 | class: "w-5 h-5",
46 | path {
47 | stroke_linecap: "round",
48 | stroke_linejoin: "round",
49 | d: "M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
50 | }
51 | }
52 | }
53 | Button {
54 | button_type: ButtonType::Primary,
55 | onclick: move |event| on_edit.call(event),
56 | svg {
57 | fill: "none",
58 | stroke: "currentColor",
59 | stroke_width: "1.5",
60 | view_box: "0 0 24 24",
61 | class: "w-5 h-5",
62 | path {
63 | stroke_linecap: "round",
64 | stroke_linejoin: "round",
65 | d: "M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125"
66 | }
67 | }
68 | }
69 | }
70 | }
71 | ))
72 | }
73 |
--------------------------------------------------------------------------------
/front/src/components/film_modal.rs:
--------------------------------------------------------------------------------
1 | use dioxus::prelude::*;
2 | use shared::models::Film;
3 | use uuid::Uuid;
4 |
5 | use crate::components::Button;
6 | use crate::models::{ButtonType, FilmModalVisibility};
7 |
8 | #[derive(Props)]
9 | pub struct FilmModalProps<'a> {
10 | on_create_or_update: EventHandler<'a, Film>,
11 | on_cancel: EventHandler<'a, MouseEvent>,
12 | #[props(!optional)]
13 | film: Option,
14 | }
15 |
16 | pub fn FilmModal<'a>(cx: Scope<'a, FilmModalProps>) -> Element<'a> {
17 | let is_modal_visible = use_shared_state::(cx).unwrap();
18 | let draft_film = use_state::(cx, || Film {
19 | title: "".to_string(),
20 | poster: "".to_string(),
21 | director: "".to_string(),
22 | year: 1900,
23 | id: Uuid::new_v4(),
24 | created_at: None,
25 | updated_at: None,
26 | });
27 |
28 | {
29 | let draft_film = draft_film.clone();
30 | use_effect(cx, &cx.props.film, |film| async move {
31 | match film {
32 | Some(film) => draft_film.set(film),
33 | None => draft_film.set(Film {
34 | title: "".to_string(),
35 | poster: "".to_string(),
36 | director: "".to_string(),
37 | year: 1900,
38 | id: Uuid::new_v4(),
39 | created_at: None,
40 | updated_at: None,
41 | }),
42 | }
43 | });
44 | }
45 |
46 | if !is_modal_visible.read().0 {
47 | return None;
48 | }
49 | cx.render(rsx!(
50 | article {
51 | class: "z-50 w-full h-full fixed top-0 right-0 bg-gray-800 bg-opacity-50 flex flex-col justify-center items-center",
52 | section {
53 | class: "w-1/3 h-auto bg-white rounded-lg flex flex-col justify-center items-center box-border p-6",
54 | header {
55 | class: "mb-4",
56 | h2 {
57 | class: "text-xl text-teal-950 font-semibold",
58 | "🎬 Film"
59 | }
60 | }
61 | form {
62 | class: "w-full flex-1 flex flex-col justify-stretch items-start gap-y-2",
63 | div {
64 | class: "w-full",
65 | label {
66 | class: "text-sm font-semibold",
67 | "Title"
68 | }
69 | input {
70 | class: "w-full border border-gray-300 rounded-lg p-2",
71 | "type": "text",
72 | placeholder: "Enter film title",
73 | value: "{draft_film.get().title}",
74 | oninput: move |evt| {
75 | draft_film.set(Film {
76 | title: evt.value.clone(),
77 | ..draft_film.get().clone()
78 | })
79 | }
80 | }
81 | }
82 | div {
83 | class: "w-full",
84 | label {
85 | class: "text-sm font-semibold",
86 | "Director"
87 | }
88 | input {
89 | class: "w-full border border-gray-300 rounded-lg p-2",
90 | "type": "text",
91 | placeholder: "Enter film director",
92 | value: "{draft_film.get().director}",
93 | oninput: move |evt| {
94 | draft_film.set(Film {
95 | director: evt.value.clone(),
96 | ..draft_film.get().clone()
97 | })
98 | }
99 | }
100 | }
101 | div {
102 | class: "w-full",
103 | label {
104 | class: "text-sm font-semibold",
105 | "Year"
106 | }
107 | input {
108 | class: "w-full border border-gray-300 rounded-lg p-2",
109 | "type": "number",
110 | placeholder: "Enter film year",
111 | value: "{draft_film.get().year.to_string()}",
112 | oninput: move |evt| {
113 | draft_film.set(Film {
114 | year: evt.value.clone().parse::().unwrap_or(1900),
115 | ..draft_film.get().clone()
116 | })
117 | }
118 | }
119 | }
120 | div {
121 | class: "w-full",
122 | label {
123 | class: "text-sm font-semibold",
124 | "Poster"
125 | }
126 | input {
127 | class: "w-full border border-gray-300 rounded-lg p-2",
128 | "type": "text",
129 | placeholder: "Enter film poster URL",
130 | value: "{draft_film.get().poster}",
131 | oninput: move |evt| {
132 | draft_film.set(Film {
133 | poster: evt.value.clone(),
134 | ..draft_film.get().clone()
135 | })
136 | }
137 | }
138 | }
139 | }
140 | footer {
141 | class: "flex flex-row justify-center items-center mt-4 gap-x-2",
142 | Button {
143 | button_type: ButtonType::Secondary,
144 | onclick: move |evt| {
145 | draft_film.set(Film {
146 | title: "".to_string(),
147 | poster: "".to_string(),
148 | director: "".to_string(),
149 | year: 1900,
150 | id: Uuid::new_v4(),
151 | created_at: None,
152 | updated_at: None,
153 | });
154 | cx.props.on_cancel.call(evt)
155 | },
156 | "Cancel"
157 | }
158 | Button {
159 | button_type: ButtonType::Primary,
160 | onclick: move |_| {
161 | cx.props.on_create_or_update.call(draft_film.get().clone());
162 | draft_film.set(Film {
163 | title: "".to_string(),
164 | poster: "".to_string(),
165 | director: "".to_string(),
166 | year: 1900,
167 | id: Uuid::new_v4(),
168 | created_at: None,
169 | updated_at: None,
170 | })
171 | },
172 | "Save film"
173 | }
174 | }
175 | }
176 |
177 | }
178 | ))
179 | }
180 |
--------------------------------------------------------------------------------
/front/src/components/footer.rs:
--------------------------------------------------------------------------------
1 | use dioxus::prelude::*;
2 |
3 | pub fn Footer(cx: Scope) -> Element {
4 | cx.render(rsx!(
5 | footer {
6 | class: "bg-blue-200 w-full h-16 p-2 box-border gap-6 flex flex-row justify-center items-center text-teal-950",
7 | a {
8 | class: "w-auto h-full",
9 | href: "https://www.devbcn.com/",
10 | target: "_blank",
11 | img {
12 | class: "h-full w-auto",
13 | alt: "DevBcn",
14 | src: "devbcn.png",
15 | "loading": "lazy"
16 | }
17 | }
18 | svg {
19 | fill: "none",
20 | view_box: "0 0 24 24",
21 | stroke_width: "1.5",
22 | stroke: "currentColor",
23 | class: "w-6 h-6",
24 | path {
25 | stroke_linecap: "round",
26 | stroke_linejoin: "round",
27 | d: "M6 18L18 6M6 6l12 12"
28 | }
29 | }
30 | a {
31 | class: "w-auto h-full",
32 | href: "https://www.meetup.com/es-ES/bcnrust/",
33 | target: "_blank",
34 | img {
35 | class: "h-full w-auto",
36 | alt: "BcnRust",
37 | src: "bcnrust.png",
38 | "loading": "lazy"
39 | }
40 | }
41 | }
42 | ))
43 | }
44 |
--------------------------------------------------------------------------------
/front/src/components/header.rs:
--------------------------------------------------------------------------------
1 | use dioxus::prelude::*;
2 |
3 | use crate::components::Button;
4 | use crate::models::{ButtonType, FilmModalVisibility};
5 |
6 | pub fn Header(cx: Scope) -> Element {
7 | let is_modal_visible = use_shared_state::(cx).unwrap();
8 |
9 | cx.render(rsx!(
10 | header {
11 | class: "sticky top-0 z-10 text-gray-400 bg-blue-300 body-font shadow-md",
12 | div { class: "container mx-auto flex flex-wrap p-0 flex-col md:flex-row justify-between items-center",
13 | a {
14 | class: "flex title-font font-medium items-center text-teal-950 mb-4 md:mb-0",
15 | img {
16 | class: "bg-transparent p-2 animate-jump",
17 | alt: "ferris",
18 | src: "ferris.png",
19 | "loading": "lazy"
20 | }
21 | span { class: "ml-3 text-2xl", "Rusty films"}
22 | }
23 | Button {
24 | button_type: ButtonType::Primary,
25 | onclick: move |_| {
26 | is_modal_visible.write().0 = true;
27 | },
28 | "Add new film"
29 | }
30 | }
31 | }
32 | ))
33 | }
34 |
--------------------------------------------------------------------------------
/front/src/components/mod.rs:
--------------------------------------------------------------------------------
1 | mod button;
2 | mod film_card;
3 | mod film_modal;
4 | mod footer;
5 | mod header;
6 |
7 | pub use button::Button;
8 | pub use film_card::FilmCard;
9 | pub use film_modal::FilmModal;
10 | pub use footer::Footer;
11 | pub use header::Header;
12 |
--------------------------------------------------------------------------------
/front/src/main.rs:
--------------------------------------------------------------------------------
1 | #![allow(non_snake_case)]
2 | // import the prelude to get access to the `rsx!` macro and the `Scope` and `Element` types
3 | mod components;
4 | mod models;
5 |
6 | use components::{FilmCard, FilmModal, Footer, Header};
7 | use dioxus::prelude::*;
8 | use models::FilmModalVisibility;
9 | use shared::models::Film;
10 |
11 | const API_ENDPOINT: &str = "api/v1";
12 |
13 | fn films_endpoint() -> String {
14 | let window = web_sys::window().expect("no global `window` exists");
15 | let location = window.location();
16 | let host = location.host().expect("should have a host");
17 | let protocol = location.protocol().expect("should have a protocol");
18 | let endpoint = format!("{}//{}/{}", protocol, host, API_ENDPOINT);
19 | format!("{}/films", endpoint)
20 | }
21 |
22 | async fn get_films() -> Vec {
23 | log::info!("Getting films {}", films_endpoint());
24 | reqwest::get(&films_endpoint())
25 | .await
26 | .unwrap()
27 | .json::>()
28 | .await
29 | .unwrap()
30 | }
31 |
32 | fn main() {
33 | wasm_logger::init(wasm_logger::Config::default().module_prefix("front"));
34 | // launch the web app
35 | dioxus_web::launch(App);
36 | }
37 |
38 | // create a component that renders a div with the text "Hello, world!"
39 | fn App(cx: Scope) -> Element {
40 | use_shared_state_provider(cx, || FilmModalVisibility(false));
41 | let is_modal_visible = use_shared_state::(cx).unwrap();
42 | let films = use_state::