├── .editorconfig ├── .env.example ├── .github └── workflows │ ├── book.yaml │ └── shuttle.yaml ├── .gitignore ├── .ignore ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── Makefile.toml ├── README.md ├── Shuttle.toml ├── api.http ├── api ├── actix │ ├── Cargo.toml │ └── src │ │ └── main.rs ├── db │ └── schema.sql ├── lib │ ├── Cargo.toml │ ├── src │ │ ├── film_repository │ │ │ ├── memory_film_repository.rs │ │ │ ├── mod.rs │ │ │ └── postgres_film_repository.rs │ │ ├── health.rs │ │ ├── lib.rs │ │ └── v1 │ │ │ ├── films.rs │ │ │ └── mod.rs │ └── tests │ │ ├── health.rs │ │ └── v1.rs └── shuttle │ ├── Cargo.toml │ └── src │ └── main.rs ├── docs ├── .gitignore ├── README.md ├── book.toml └── src │ ├── README.md │ ├── SUMMARY.md │ ├── assets │ ├── backend │ │ ├── 16 │ │ │ └── send_request.png │ │ ├── 22 │ │ │ └── sql_log.png │ │ ├── 01 │ │ │ ├── cargo_build.png │ │ │ ├── gitignore.png │ │ │ └── workspace_error.png │ │ ├── 02 │ │ │ └── cargo_shuttle_run.png │ │ ├── 03 │ │ │ ├── deployed.png │ │ │ ├── login_error.png │ │ │ ├── login_shuttle.png │ │ │ ├── login_shuttle_terminal.png │ │ │ ├── login_terminal.png │ │ │ ├── login_with_github.png │ │ │ ├── project_not_found_error.png │ │ │ ├── project_started.png │ │ │ └── shuttle_toml.png │ │ ├── 05 │ │ │ ├── docker_error.png │ │ │ └── local_connectionstring.png │ │ ├── 06 │ │ │ └── table_created.png │ │ ├── 08 │ │ │ ├── cloud_database.png │ │ │ └── console_resources.png │ │ └── 09 │ │ │ ├── breakpoint.png │ │ │ └── breakpoint_hit.png │ ├── bcnrust.png │ ├── devbcn.png │ ├── ferris.png │ ├── frontend-final.png │ ├── hacker.jpg │ ├── mdbook-admonish.css │ ├── mermaid-init.js │ ├── mermaid.min.js │ ├── movie_collection.jpg │ └── workshop.jpg │ ├── backend │ ├── 00_backend.md │ ├── 01_workspace_setup.md │ ├── 02_shuttle.md │ ├── 03_deploying_with_shuttle.md │ ├── 04_shuttle_cli_console.md │ ├── 05_working_with_a_database.md │ ├── 06_setting_up_the_database.md │ ├── 07_connecting_the_database.md │ ├── 08_deploying_the_database.md │ ├── 09_debugging.md │ ├── 10_instrumentation.md │ ├── 11_watch_mode.md │ ├── 12_library.md │ ├── 13_health_check.md │ ├── 14_configure_method.md │ ├── 15_testing.md │ ├── 16_films_endpoints.md │ ├── 17_models.md │ ├── 18_serde.md │ ├── 19_film_repository.md │ ├── 20_implementing_trait.md │ ├── 21_injecting_repository.md │ ├── 22_implementing_endpoints.md │ ├── 23_static_dispatching.md │ ├── 24_serving_static_files.md │ └── 25_makefile_toml.md │ ├── frontend │ ├── 03_01_setup.md │ ├── 03_02_app_startup.md │ ├── 03_03_01_layout.md │ ├── 03_03_02_reusable_components.md │ ├── 03_03_components.md │ ├── 03_04_01_global_state.md │ ├── 03_04_02_local_state.md │ ├── 03_04_03_effects.md │ ├── 03_04_state_management.md │ ├── 03_05_event_handlers.md │ ├── 03_06_building.md │ └── 03_frontend.md │ └── prerequisites.md ├── front ├── Cargo.toml ├── Dioxus.toml ├── README.md ├── input.css ├── package-lock.json ├── package.json ├── public │ ├── bcnrust.png │ ├── devbcn.png │ ├── ferris.png │ └── tailwind.css ├── src │ ├── components │ │ ├── button.rs │ │ ├── film_card.rs │ │ ├── film_modal.rs │ │ ├── footer.rs │ │ ├── header.rs │ │ └── mod.rs │ ├── main.rs │ └── models │ │ ├── button.rs │ │ ├── film.rs │ │ └── mod.rs └── tailwind.config.js └── shared ├── Cargo.toml └── src ├── lib.rs └── models.rs /.editorconfig: -------------------------------------------------------------------------------- 1 | indent_style = space 2 | indent_size = 4 3 | insert_final_newline = true 4 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | PORT=8080 2 | DATABASE_URL=postgres://posgres:postgres@db.shuttle.rs:5432/your-db 3 | RUST_LOG=info 4 | STATIC_FOLDER=./front/dist 5 | -------------------------------------------------------------------------------- /.github/workflows/book.yaml: -------------------------------------------------------------------------------- 1 | # mdBook to GitHub pages 2 | name: Book to GitHub Pages 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ['main'] 8 | paths: 9 | - docs/** 10 | - README.md 11 | - .github/workflows/book.yaml 12 | 13 | # Allows you to run this workflow manually from the Actions tab 14 | workflow_dispatch: 15 | 16 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 17 | permissions: 18 | contents: read 19 | pages: write 20 | id-token: write 21 | 22 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 23 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 24 | concurrency: 25 | group: 'pages' 26 | cancel-in-progress: false 27 | 28 | jobs: 29 | build: 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v3 34 | 35 | # - name: Setup mdBook 36 | # uses: peaceiris/actions-mdbook@v1 37 | # with: 38 | # mdbook-version: 'latest' 39 | - name: Setup mdBook 40 | uses: jontze/action-mdbook@v2 41 | with: 42 | token: ${{secrets.GITHUB_TOKEN}} 43 | use-linkcheck: false 44 | use-mermaid: true 45 | use-toc: true 46 | use-opengh: false 47 | use-admonish: true 48 | use-katex: false 49 | 50 | - name: Setup Pages 51 | uses: actions/configure-pages@v3 52 | 53 | - name: Getting cargo make 54 | uses: davidB/rust-cargo-make@v1 55 | 56 | - name: Build with mdBook 57 | run: cargo make book-build-ci 58 | 59 | - name: Upload artifact 60 | uses: actions/upload-pages-artifact@v1 61 | with: 62 | path: docs/book 63 | 64 | deploy: 65 | environment: 66 | name: github-pages 67 | url: ${{ steps.deployment.outputs.page_url }} 68 | runs-on: ubuntu-latest 69 | needs: build 70 | steps: 71 | - name: Deploy to GitHub Pages 72 | id: deployment 73 | uses: actions/deploy-pages@v2 74 | -------------------------------------------------------------------------------- /.github/workflows/shuttle.yaml: -------------------------------------------------------------------------------- 1 | name: Shuttle deploy 2 | 3 | on: 4 | release: 5 | types: [published] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v3 14 | 15 | - name: Setup Rust 16 | uses: actions-rs/toolchain@v1 17 | with: 18 | toolchain: stable 19 | target: wasm32-unknown-unknown 20 | override: true 21 | 22 | - name: Getting cargo make 23 | uses: davidB/rust-cargo-make@v1 24 | 25 | - name: Install Dioxus CLI 26 | run: cargo install dioxus-cli 27 | 28 | - name: Build the front-end 29 | run: cargo make front-build 30 | 31 | - name: deployment 32 | uses: shuttle-hq/deploy-action@main 33 | with: 34 | deploy-key: ${{ secrets.SHUTTLE_DEPLOY_KEY }} 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | dist/ 3 | Secrets.toml 4 | .env 5 | node_modules 6 | api.local.http 7 | static/ 8 | -------------------------------------------------------------------------------- /.ignore: -------------------------------------------------------------------------------- 1 | !static/ 2 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "lldb", 9 | "request": "launch", 10 | "name": "Debug Actix API", 11 | "cargo": { 12 | "args": ["build", "--bin=api-actix", "--package=api-actix"], 13 | "filter": { 14 | "name": "api-actix", 15 | "kind": "bin" 16 | } 17 | }, 18 | "args": [], 19 | "cwd": "${workspaceFolder}" 20 | }, 21 | 22 | { 23 | "type": "lldb", 24 | "request": "attach", 25 | "name": "Debug Shuttle", 26 | "program": "${workspaceFolder}/target/debug/api-shuttle", 27 | "preLaunchTask": "shuttle:run:background" 28 | }, 29 | 30 | { 31 | "type": "lldb", 32 | "request": "attach", 33 | "name": "Attach to Shuttle", 34 | "program": "${workspaceFolder}/target/debug/api-shuttle" 35 | } 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "vsicons.associations.files": [ 3 | { "icon": "dotenv", "extensions": [".env", ".env.example"] } 4 | ], 5 | "rust-analyzer.linkedProjects": [ 6 | "./front/Cargo.toml" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "cargo", 6 | "command": "check", 7 | "problemMatcher": ["$rustc", "$rust-panic"], 8 | "group": { 9 | "kind": "build", 10 | "isDefault": false 11 | }, 12 | "label": "rust: cargo check" 13 | }, 14 | { 15 | "type": "cargo", 16 | "command": "run", 17 | "label": "actix:run", 18 | "args": ["--bin", "api-actix"], 19 | "problemMatcher": ["$rustc", "$rust-panic"], 20 | "group": { 21 | "kind": "build", 22 | "isDefault": true 23 | } 24 | }, 25 | { 26 | "type": "cargo", 27 | "label": "shuttle:watch", 28 | "command": "watch", 29 | "problemMatcher": { 30 | "owner": "rust", 31 | "fileLocation": ["relative", "${workspaceRoot}"], 32 | "pattern": { 33 | "regexp": "^(.*):(\\d+):(\\d+):\\s+(warning|error):\\s+(.*)$", 34 | "file": 1, 35 | "line": 2, 36 | "column": 3, 37 | "severity": 4, 38 | "message": 5 39 | }, 40 | "background": { 41 | "activeOnStart": true, 42 | "beginsPattern": { 43 | "regexp": "^.*Building.*" 44 | }, 45 | "endsPattern": { 46 | "regexp": "^.*Starting api-shuttle.*" 47 | } 48 | } 49 | }, 50 | "args": ["-x", "shuttle run"], 51 | "isBackground": true, 52 | "options": { 53 | "env": { 54 | "RUST_LOG": "info" 55 | // "PORT": "8080" 56 | } 57 | }, 58 | "group": { 59 | "kind": "build", 60 | "isDefault": false 61 | } 62 | }, 63 | { 64 | "type": "cargo", 65 | "label": "shuttle:run", 66 | "command": "shuttle", 67 | "problemMatcher": ["$rustc", "$rust-panic"], 68 | "args": ["run"], 69 | "isBackground": false, 70 | "options": { 71 | "env": { 72 | "RUST_LOG": "info" 73 | } 74 | }, 75 | "group": { 76 | "kind": "build", 77 | "isDefault": false 78 | } 79 | }, 80 | { 81 | "type": "cargo", 82 | "label": "shuttle:run:background", 83 | "command": "shuttle", 84 | "problemMatcher": { 85 | "owner": "rust", 86 | "fileLocation": ["relative", "${workspaceRoot}"], 87 | "pattern": { 88 | "regexp": "^(.*):(\\d+):(\\d+):\\s+(warning|error):\\s+(.*)$", 89 | "file": 1, 90 | "line": 2, 91 | "column": 3, 92 | "severity": 4, 93 | "message": 5 94 | }, 95 | "background": { 96 | "activeOnStart": true, 97 | "beginsPattern": { 98 | "regexp": "^.*Building.*" 99 | }, 100 | "endsPattern": { 101 | "regexp": "^.*Starting api-shuttle.*" 102 | } 103 | } 104 | }, 105 | "args": ["run"], 106 | "isBackground": true, 107 | "options": { 108 | "env": { 109 | "RUST_LOG": "info" 110 | } 111 | }, 112 | "group": { 113 | "kind": "build", 114 | "isDefault": false 115 | } 116 | } 117 | ] 118 | } 119 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["api/lib", "api/actix", "api/shuttle", "front", "shared"] 3 | resolver = "2" 4 | 5 | [workspace.dependencies] 6 | # internal 7 | shared = { version = "0.1.0", path = "./shared" } 8 | api-lib = { version = "0.1.0", path = "./api/lib" } 9 | # actix and sqlx 10 | actix-web = "4.9.0" 11 | actix-files = "0.6.6" 12 | sqlx = { version = "0.7", default-features = false, features = [ 13 | "tls-native-tls", 14 | "macros", 15 | "postgres", 16 | "uuid", 17 | "chrono", 18 | "json", 19 | ] } 20 | # serde 21 | serde = { version = "1.0.164", features = ["derive"] } 22 | # utils 23 | tracing = "0.1" 24 | uuid = { version = "1.3.4", features = ["serde", "v4", "js"] } 25 | chrono = { version = "0.4.38", features = ["serde"] } 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 BcnRust 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile.toml: -------------------------------------------------------------------------------- 1 | # project tasks 2 | [tasks.api-run] 3 | workspace = false 4 | env = { RUST_LOG = "info" } 5 | install_crate = "cargo-shuttle" 6 | command = "cargo" 7 | args = ["shuttle", "run"] 8 | 9 | [tasks.api-run-actix] 10 | workspace = false 11 | command = "cargo" 12 | args = ["run", "--bin", "api-actix"] 13 | 14 | [tasks.front-serve] 15 | workspace = false 16 | cwd = "./front" 17 | install_crate = "dioxus-cli" 18 | command = "dx" 19 | args = ["serve", "--port", "8000"] 20 | 21 | [tasks.front-build] 22 | workspace = false 23 | script_runner = "@shell" 24 | script = ''' 25 | # shuttle issue with static files 26 | # location is different depending on the environment 27 | rm -rf api/shuttle/static static 28 | mkdir api/shuttle/static 29 | mkdir static 30 | cd front 31 | dx build --release 32 | # local development 33 | cp -r dist/* ../api/shuttle/static 34 | # production 35 | cp -r dist/* ../static 36 | ''' 37 | 38 | # local db 39 | [tasks.db-start] 40 | workspace = false 41 | script_runner = "@shell" 42 | script = ''' 43 | docker run -d --name devbcn-workshop -p 5432:5432 -e POSTGRES_PASSWORD=postgres -e POSTGRES_USER=postgres -e POSTGRES_DB=devbcn postgres 44 | ''' 45 | 46 | [tasks.db-stop] 47 | workspace = false 48 | script_runner = "@shell" 49 | script = ''' 50 | docker stop postgres 51 | docker rm postgres 52 | ''' 53 | 54 | # general tasks 55 | [tasks.clippy] 56 | workspace = false 57 | install_crate = "cargo-clippy" 58 | command = "cargo" 59 | args = ["clippy"] 60 | 61 | [tasks.format] 62 | clear = true 63 | workspace = false 64 | install_crate = "rustfmt" 65 | command = "cargo" 66 | args = ["fmt", "--all", "--", "--check"] 67 | 68 | # book tasks 69 | [tasks.book-preprocessors] 70 | workspace = false 71 | script_runner = "@shell" 72 | script = ''' 73 | cargo install mdbook-mermaid 74 | cargo install mdbook-admonish 75 | cargo install mdbook-toc 76 | ''' 77 | 78 | [tasks.book-build-ci] 79 | workspace = false 80 | run_task = { name = ["book-build-inner"] } 81 | 82 | [tasks.book-build] 83 | workspace = false 84 | run_task = { name = ["book-preprocessors", "book-build-inner"] } 85 | 86 | [tasks.book-serve] 87 | workspace = false 88 | run_task = { name = ["book-preprocessors", "book-serve-inner"] } 89 | 90 | [tasks.book-build-inner] 91 | workspace = false 92 | cwd = "./docs" 93 | install_crate = "mdbook" 94 | command = "mdbook" 95 | args = ["build"] 96 | 97 | [tasks.book-serve-inner] 98 | workspace = false 99 | cwd = "./docs" 100 | install_crate = "mdbook" 101 | command = "mdbook" 102 | args = ["serve"] 103 | -------------------------------------------------------------------------------- /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. 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 | ![Workspace Error](../assets/backend/01/workspace_error.png) 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 | ![Cargo Build](../assets/backend/01/cargo_build.png) 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 | ![.gitignore](../assets/backend/01/gitignore.png) 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 | ![shuttle_run](../assets/backend/02/cargo_shuttle_run.png) 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 | ![Shuttle.toml](../assets/backend/03/shuttle_toml.png) 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 | ![Error Message](../assets/backend/03/login_error.png) 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 | ![Login with GitHub](../assets/backend/03/login_with_github.png) 52 | 53 | In your terminal, you should see something like this: 54 | 55 | ![Login terminal](../assets/backend/03/login_terminal.png) 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 | ![Login successful](../assets/backend/03/login_shuttle.png) 60 | 61 | Then **paste the code** in your terminal and press enter. 62 | 63 | ![Login successful terminal](../assets/backend/03/login_shuttle_terminal.png) 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 | ![Project not found error](../assets/backend/03/project_not_found_error.png) 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 | ![project started](../assets/backend/03/project_started.png) 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 | ![deployed](../assets/backend/03/deployed.png) 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 | ![Docker Error](../assets/backend/05/docker_error.png) 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 | ![Local ConnectionString](../assets/backend/05/local_connectionstring.png) 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 | ![Table created](../assets/backend/06/table_created.png) 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 | ![Database connection string](../assets/backend/08/cloud_database.png) 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 | ![Console Resources](../assets/backend/08/console_resources.png) 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 | ![Breakpoint](../assets/backend/09/breakpoint.png) 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 | ![Breakpoint hit](../assets/backend/09/breakpoint_hit.png) 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 | ![Send Request](../assets/backend/16/send_request.png) 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 | ![sql log](../assets/backend/22/sql_log.png) 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 | ![Alt text](../assets/frontend-final.png) -------------------------------------------------------------------------------- /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::>>(cx, || None); 43 | let selected_film = use_state::>(cx, || None); 44 | let force_get_films = use_state(cx, || ()); 45 | 46 | { 47 | let films = films.clone(); 48 | use_effect(cx, force_get_films, |_| async move { 49 | let existing_films = get_films().await; 50 | if existing_films.is_empty() { 51 | films.set(None); 52 | } else { 53 | films.set(Some(existing_films)); 54 | } 55 | }); 56 | } 57 | 58 | let delete_film = move |filmId| { 59 | let force_get_films = force_get_films.clone(); 60 | cx.spawn({ 61 | async move { 62 | let response = reqwest::Client::new() 63 | .delete(&format!("{}/{}", &films_endpoint(), filmId)) 64 | .send() 65 | .await; 66 | match response { 67 | Ok(_data) => { 68 | log::info!("Film deleted"); 69 | force_get_films.set(()); 70 | } 71 | Err(err) => { 72 | log::info!("Error deleting film: {:?}", err); 73 | } 74 | } 75 | } 76 | }); 77 | }; 78 | 79 | let create_or_update_film = move |film: Film| { 80 | let force_get_films = force_get_films.clone(); 81 | let current_selected_film = selected_film.clone(); 82 | let is_modal_visible = is_modal_visible.clone(); 83 | 84 | cx.spawn({ 85 | async move { 86 | let response = if current_selected_film.get().is_some() { 87 | reqwest::Client::new() 88 | .put(&films_endpoint()) 89 | .json(&film) 90 | .send() 91 | .await 92 | } else { 93 | reqwest::Client::new() 94 | .post(&films_endpoint()) 95 | .json(&film) 96 | .send() 97 | .await 98 | }; 99 | match response { 100 | Ok(_data) => { 101 | log::info!("Film created"); 102 | current_selected_film.set(None); 103 | is_modal_visible.write().0 = false; 104 | force_get_films.set(()); 105 | } 106 | Err(err) => { 107 | log::info!("Error creating film: {:?}", err); 108 | } 109 | } 110 | } 111 | }); 112 | }; 113 | 114 | cx.render(rsx! { 115 | main { 116 | class: "relative z-0 bg-blue-100 w-screen h-auto min-h-screen flex flex-col justify-start items-stretch", 117 | Header {} 118 | section { 119 | class: "md:container md:mx-auto md:py-8 flex-1", 120 | if let Some(films) = films.get() { 121 | rsx!( 122 | ul { 123 | class: "flex flex-row justify-center items-stretch gap-4 flex-wrap", 124 | {films.iter().map(|film| { 125 | rsx!( 126 | FilmCard { 127 | key: "{film.id}", 128 | film: film, 129 | on_edit: move |_| { 130 | selected_film.set(Some(film.clone())); 131 | is_modal_visible.write().0 = true 132 | }, 133 | on_delete: move |_| { 134 | delete_film(film.id) 135 | } 136 | } 137 | ) 138 | })} 139 | } 140 | ) 141 | } 142 | } 143 | Footer {} 144 | } 145 | FilmModal { 146 | film: selected_film.get().clone(), 147 | on_create_or_update: move |new_film| { 148 | create_or_update_film(new_film); 149 | }, 150 | on_cancel: move |_| { 151 | selected_film.set(None); 152 | is_modal_visible.write().0 = false; 153 | } 154 | } 155 | }) 156 | } 157 | -------------------------------------------------------------------------------- /front/src/models/button.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | pub enum ButtonType { 4 | Primary, 5 | Secondary, 6 | } 7 | 8 | impl fmt::Display for ButtonType { 9 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 10 | match self { 11 | ButtonType::Primary => write!(f, "bg-blue-700 hover:bg-blue-800 active:bg-blue-900"), 12 | ButtonType::Secondary => write!(f, "bg-rose-700 hover:bg-rose-800 active:bg-rose-900"), 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /front/src/models/film.rs: -------------------------------------------------------------------------------- 1 | pub struct FilmModalVisibility(pub bool); 2 | -------------------------------------------------------------------------------- /front/src/models/mod.rs: -------------------------------------------------------------------------------- 1 | mod button; 2 | mod film; 3 | 4 | pub use button::ButtonType; 5 | pub use film::FilmModalVisibility; 6 | -------------------------------------------------------------------------------- /front/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | mode: 'all', 4 | content: [ 5 | "./src/**/*.{rs,html,css}", 6 | "./dist/**/*.html", 7 | ], 8 | theme: { 9 | extend: {}, 10 | }, 11 | plugins: [ 12 | require('tailwindcss-animated') 13 | ], 14 | } 15 | 16 | -------------------------------------------------------------------------------- /shared/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "shared" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [features] 7 | backend = ["sqlx"] 8 | 9 | [dependencies] 10 | # serde 11 | serde = { workspace = true } 12 | # Sqlx, only when the backend add this as dependency is compiled 13 | sqlx = { workspace = true, optional = true } 14 | # utils 15 | uuid = { workspace = true } 16 | chrono = { workspace = true } 17 | -------------------------------------------------------------------------------- /shared/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod models; 2 | -------------------------------------------------------------------------------- /shared/src/models.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[cfg_attr(feature = "backend", derive(sqlx::FromRow))] 4 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Default)] 5 | pub struct Film { 6 | pub id: uuid::Uuid, 7 | pub title: String, 8 | pub director: String, 9 | #[cfg_attr(feature = "backend", sqlx(try_from = "i16"))] 10 | pub year: u16, 11 | pub poster: String, 12 | pub created_at: Option>, 13 | pub updated_at: Option>, 14 | } 15 | 16 | #[cfg_attr(feature = "backend", derive(sqlx::FromRow))] 17 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Default)] 18 | pub struct CreateFilm { 19 | pub title: String, 20 | pub director: String, 21 | #[cfg_attr(feature = "backend", sqlx(try_from = "i16"))] 22 | pub year: u16, 23 | pub poster: String, 24 | } 25 | --------------------------------------------------------------------------------