├── .gitignore ├── README.md ├── backend ├── .env ├── Cargo.toml ├── diesel.toml ├── media │ └── images │ │ └── articles │ │ └── ferris.webp ├── migrations │ ├── .gitkeep │ ├── 00000000000000_diesel_initial_setup │ │ ├── down.sql │ │ └── up.sql │ └── 2021-10-09-223351_create_articles │ │ ├── down.sql │ │ └── up.sql └── src │ ├── errors │ ├── database_error.rs │ └── mod.rs │ ├── handlers │ ├── articles.rs │ └── mod.rs │ ├── main.rs │ ├── models │ ├── articles.rs │ ├── from_model.rs │ └── mod.rs │ └── schema.rs ├── deploy.sh ├── deploy ├── run-backend.sh ├── run-frontend.sh └── run.sh └── frontend ├── .env ├── Cargo.toml ├── Trunk.toml ├── index.html ├── src ├── app.rs ├── components │ ├── article.rs │ ├── articles.rs │ ├── mod.rs │ └── page_not_found.rs ├── entities │ ├── interfaces.rs │ └── mod.rs ├── main.rs ├── routes │ └── mod.rs ├── service │ ├── articles.rs │ ├── fetch.rs │ ├── future.rs │ └── mod.rs └── utils │ ├── date.rs │ └── mod.rs └── static └── styles └── app.css /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug/ 4 | target/ 5 | TODO.txt 6 | 7 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 8 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 9 | Cargo.lock 10 | 11 | # These are backup files generated by rustfmt 12 | **/*.rs.bk 13 | 14 | .DS_Store 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Notes 2 | 3 | **Code that goes along with [this article](https://guimauve.io/articles/50)** 4 | 5 | This is a complete article about how to deploy a Rust web application on an AWS EC2 instance using a classic backend/frontend structure. It's derived from my own experience building [my own website](https://github.com/guimauveb/guimauve.io) using the same tools and having a great time doing so. 6 | 7 | This application is made of a WebAssembly frontend displaying articles retrieved from an API. The purpose of the article is to show how to build and deploy a minimal yet complete full stack Rust web application from a local machine to a web server. 8 | 9 | ## Frameworks used 10 | 11 | - Backend 12 | - Actix 13 | - Postgres 14 | - Diesel 15 | 16 | - Frontend 17 | - Yew 18 | 19 | 20 | ## Running the application 21 | 22 | ### Backend 23 | #### From `backend/` 24 | `cargo run` 25 | 26 | or if you want hot reload with `cargo-watch`: 27 | 28 | `cargo watch -x 'run backend` 29 | 30 | ### Frontend 31 | #### From `frontend/` 32 | `trunk serve` 33 | -------------------------------------------------------------------------------- /backend/.env: -------------------------------------------------------------------------------- 1 | DATABASE_URL="postgres://rust_app:rust_app@localhost/rust_app" 2 | API_URL="http://localhost:8000" 3 | -------------------------------------------------------------------------------- /backend/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rust-app-backend" 3 | version = "0.1.0" 4 | authors = ["guimauve "] 5 | edition = "2021" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | actix-web = "3.3.2" 11 | actix-files = "0.5.0" 12 | actix-cors = "0.5.4" 13 | actix-service = "1.0.1" 14 | chrono = { version = "0.4.10", features = ["serde"] } 15 | derive_more = "0.99.2" 16 | diesel = { version = "1.4.8", features = ["postgres", "r2d2", "chrono"] } 17 | diesel-derive-enum = { version = "1.1.1", features = ["postgres"] } 18 | diesel_full_text_search = "1.0.1" 19 | r2d2 = "0.8.8" 20 | serde = { version = "1.0", features = ["derive"] } 21 | dotenv_codegen = "0.15.0" 22 | 23 | [profile.release] 24 | opt-level = 3 25 | # Less code to include into binary 26 | panic = 'abort' 27 | # Optimization over all codebase ( better optimization, slower build ) 28 | codegen-units = 1 29 | # Link time optimization using using whole-program analysis 30 | lto = true 31 | -------------------------------------------------------------------------------- /backend/diesel.toml: -------------------------------------------------------------------------------- 1 | # For documentation on how to configure this file, 2 | # see diesel.rs/guides/configuring-diesel-cli 3 | 4 | [print_schema] 5 | file = "src/schema.rs" 6 | -------------------------------------------------------------------------------- /backend/media/images/articles/ferris.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guimauveb/rust-app-aws/b3eae9cd76ec35a5b7b7d72624282f4723fe57f3/backend/media/images/articles/ferris.webp -------------------------------------------------------------------------------- /backend/migrations/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guimauveb/rust-app-aws/b3eae9cd76ec35a5b7b7d72624282f4723fe57f3/backend/migrations/.gitkeep -------------------------------------------------------------------------------- /backend/migrations/00000000000000_diesel_initial_setup/down.sql: -------------------------------------------------------------------------------- 1 | -- This file was automatically created by Diesel to setup helper functions 2 | -- and other internal bookkeeping. This file is safe to edit, any future 3 | -- changes will be added to existing projects as new migrations. 4 | 5 | DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass); 6 | DROP FUNCTION IF EXISTS diesel_set_updated_at(); 7 | -------------------------------------------------------------------------------- /backend/migrations/00000000000000_diesel_initial_setup/up.sql: -------------------------------------------------------------------------------- 1 | -- This file was automatically created by Diesel to setup helper functions 2 | -- and other internal bookkeeping. This file is safe to edit, any future 3 | -- changes will be added to existing projects as new migrations. 4 | 5 | 6 | 7 | 8 | -- Sets up a trigger for the given table to automatically set a column called 9 | -- `updated_at` whenever the row is modified (unless `updated_at` was included 10 | -- in the modified columns) 11 | -- 12 | -- # Example 13 | -- 14 | -- ```sql 15 | -- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW()); 16 | -- 17 | -- SELECT diesel_manage_updated_at('users'); 18 | -- ``` 19 | CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$ 20 | BEGIN 21 | EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s 22 | FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl); 23 | END; 24 | $$ LANGUAGE plpgsql; 25 | 26 | CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$ 27 | BEGIN 28 | IF ( 29 | NEW IS DISTINCT FROM OLD AND 30 | NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at 31 | ) THEN 32 | NEW.updated_at := current_timestamp; 33 | END IF; 34 | RETURN NEW; 35 | END; 36 | $$ LANGUAGE plpgsql; 37 | -------------------------------------------------------------------------------- /backend/migrations/2021-10-09-223351_create_articles/down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE articles; 2 | -------------------------------------------------------------------------------- /backend/migrations/2021-10-09-223351_create_articles/up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS articles 2 | ( 3 | id SERIAL PRIMARY KEY, 4 | title TEXT NOT NULL, 5 | pub_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 6 | published BOOLEAN NOT NULL DEFAULT FALSE, 7 | headline TEXT NOT NULL, 8 | image TEXT NOT NULL, 9 | content TEXT NOT NULL 10 | ); 11 | -------------------------------------------------------------------------------- /backend/src/errors/database_error.rs: -------------------------------------------------------------------------------- 1 | use { 2 | actix_web::{error::BlockingError, HttpResponse, ResponseError}, 3 | derive_more::{Display, From}, 4 | diesel::result::Error as DieselError, 5 | }; 6 | 7 | #[derive(Display, From, Debug)] 8 | pub struct DatabaseError(pub BlockingError); 9 | 10 | impl std::error::Error for DatabaseError {} 11 | 12 | impl ResponseError for DatabaseError { 13 | fn error_response(&self) -> HttpResponse { 14 | match *self { 15 | DatabaseError(BlockingError::Error(DieselError::NotFound)) => { 16 | HttpResponse::NotFound().finish() 17 | } 18 | _ => HttpResponse::InternalServerError().finish(), 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /backend/src/errors/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod database_error; 2 | -------------------------------------------------------------------------------- /backend/src/handlers/articles.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{errors::database_error::DatabaseError, models::articles::Article, Pool}, 3 | actix_web::{web, Error, HttpResponse}, 4 | }; 5 | 6 | // /articles/{id}/ 7 | pub async fn get(pool: web::Data, id: web::Path) -> Result { 8 | let connection = pool.get().unwrap(); 9 | Ok(web::block(move || Article::get(&id, &connection)) 10 | .await 11 | .map(|article| HttpResponse::Ok().json(article)) 12 | .map_err(DatabaseError)?) 13 | } 14 | 15 | // /articles/ 16 | pub async fn list(pool: web::Data) -> Result { 17 | let connection = pool.get().unwrap(); 18 | Ok(web::block(move || Article::list(&connection)) 19 | .await 20 | .map(|articles| HttpResponse::Ok().json(articles)) 21 | .map_err(DatabaseError)?) 22 | } 23 | -------------------------------------------------------------------------------- /backend/src/handlers/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod articles; 2 | -------------------------------------------------------------------------------- /backend/src/main.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate diesel; 3 | #[macro_use] 4 | extern crate dotenv_codegen; 5 | 6 | use { 7 | actix_cors::Cors, 8 | actix_files as fs, 9 | actix_web::{http, web, App, HttpServer}, 10 | diesel::r2d2::{self, ConnectionManager}, 11 | diesel::PgConnection, 12 | }; 13 | 14 | mod errors; 15 | mod handlers; 16 | mod models; 17 | mod schema; 18 | 19 | // Constants 20 | const API_URL: &str = dotenv!("API_URL"); 21 | const DATABASE_URL: &str = dotenv!("DATABASE_URL"); 22 | 23 | pub type Pool = r2d2::Pool>; 24 | 25 | #[actix_web::main] 26 | async fn main() -> std::io::Result<()> { 27 | #[cfg(debug_assertions)] 28 | std::env::set_var("RUST_LOG", "actix_web=debug"); 29 | 30 | let manager = ConnectionManager::::new(DATABASE_URL); 31 | let pool: Pool = r2d2::Pool::builder() 32 | .build(manager) 33 | .expect("Failed to create pool."); 34 | 35 | HttpServer::new(move || { 36 | App::new() 37 | .wrap( 38 | Cors::default() 39 | .allowed_origin("http://localhost:3001") 40 | .allowed_origin("http://localhost:3333") 41 | .allowed_origin("http://127.0.0.1:3000") 42 | .allowed_origin("https://www.your-domain.com") 43 | .allowed_origin("https://your-domain.com") 44 | .allowed_methods(vec!["GET", "OPTIONS"]) 45 | .allowed_headers(vec![http::header::AUTHORIZATION, http::header::ACCEPT]) 46 | .allowed_header(http::header::CONTENT_TYPE) 47 | .max_age(3600), 48 | ) 49 | .service(fs::Files::new("/media", "./media").show_files_listing()) 50 | .data(pool.clone()) 51 | .route("/articles/{id}", web::get().to(handlers::articles::get)) 52 | .route("/articles", web::get().to(handlers::articles::list)) 53 | }) 54 | .bind(("127.0.0.1", 8000))? 55 | .run() 56 | .await 57 | } 58 | -------------------------------------------------------------------------------- /backend/src/models/articles.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::from_model::FromModel, 3 | crate::{schema::articles, API_URL}, 4 | diesel::{PgConnection, QueryDsl, RunQueryDsl}, 5 | serde::Serialize, 6 | }; 7 | 8 | #[derive(Identifiable, Debug, Serialize, Queryable, Clone, AsChangeset)] 9 | pub struct Article { 10 | pub id: i32, 11 | pub title: String, 12 | pub pub_date: chrono::NaiveDateTime, 13 | pub published: bool, 14 | pub headline: String, 15 | pub image: String, 16 | pub content: String, 17 | } 18 | 19 | #[derive(Debug, Serialize)] 20 | pub struct ArticleRepresentation { 21 | pub id: i32, 22 | pub title: String, 23 | pub pub_date: chrono::NaiveDateTime, 24 | pub published: bool, 25 | pub headline: String, 26 | pub image: String, 27 | pub content: String, 28 | } 29 | 30 | impl FromModel
for ArticleRepresentation { 31 | fn from_model(article: Article) -> ArticleRepresentation { 32 | ArticleRepresentation { 33 | id: article.id, 34 | title: article.title, 35 | pub_date: article.pub_date, 36 | published: article.published, 37 | headline: article.headline, 38 | image: API_URL.to_owned() + &article.image, 39 | content: article.content, 40 | } 41 | } 42 | } 43 | 44 | impl Article { 45 | pub fn get( 46 | id: &i32, 47 | connection: &PgConnection, 48 | ) -> Result { 49 | let article = articles::table.find(id).first::
(connection)?; 50 | Ok(ArticleRepresentation::from_model(article)) 51 | } 52 | 53 | pub fn list( 54 | connection: &PgConnection, 55 | ) -> Result, diesel::result::Error> { 56 | let articles = articles::table 57 | .load::
(connection) 58 | .expect("Could not load articles."); 59 | let results = articles 60 | .into_iter() 61 | .map(ArticleRepresentation::from_model) 62 | .collect(); 63 | 64 | Ok(results) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /backend/src/models/from_model.rs: -------------------------------------------------------------------------------- 1 | pub trait FromModel { 2 | fn from_model(model: M) -> Self; 3 | } 4 | -------------------------------------------------------------------------------- /backend/src/models/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod articles; 2 | pub mod from_model; 3 | -------------------------------------------------------------------------------- /backend/src/schema.rs: -------------------------------------------------------------------------------- 1 | table! { 2 | articles (id) { 3 | id -> Int4, 4 | title -> Text, 5 | pub_date -> Timestamp, 6 | published -> Bool, 7 | headline -> Text, 8 | image -> Text, 9 | content -> Text, 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | ssh -i ~/.ssh/my-key.pem ec2-user@ec2-instance.compute.amazonaws.com ' 3 | cd ~/rust-app/deploy && 4 | ./run.sh > ~/logs/rust-app-deploy 2>&1 5 | ' 6 | -------------------------------------------------------------------------------- /deploy/run-backend.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | kill "$(<~/pids/rust-app-backend)" && 3 | cd ~/rust-app/backend && 4 | printf "DATABASE_URL=postgres://rust_app:rust_app@localhost/rust_app\nAPI_URL=http://api.your-domain.com" > .env 5 | cd ~/rust-app/backend && 6 | cargo build --release > ~/logs/rust-app-backend 2>&1 && (./target/release/rust-app-backend > ~/logs/rust-app-backend 2>&1 & echo $! > ~/pids/rust-app-backend) 7 | -------------------------------------------------------------------------------- /deploy/run-frontend.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | kill "$(<~/pids/rust-app-frontend)" && 3 | cd ~/rust-app/frontend && 4 | printf "API_URL=http://api.your-domain.com" > .env 5 | TRUNK_BUILD_RELEASE=true 6 | trunk serve > ~/logs/rust-app-frontend 2>~/logs/rust-app-frontend & echo $! > ~/pids/rust-app-frontend 7 | -------------------------------------------------------------------------------- /deploy/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd ~/rust-app/deploy && 3 | git pull > ~/logs/rust-app-git-pull 2>&1 & 4 | ./run-backend.sh && 5 | ./run-frontend.sh 6 | 7 | -------------------------------------------------------------------------------- /frontend/.env: -------------------------------------------------------------------------------- 1 | API_URL="http://localhost:8000" 2 | -------------------------------------------------------------------------------- /frontend/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rust-app-frontend" 3 | version = "0.0.1" 4 | authors = ["guimauve "] 5 | edition = "2021" 6 | 7 | [dependencies] 8 | yew = { rev="d1f15b6f862d555023c18a48c5fb154e539be22b", git = "https://github.com/yewstack/yew"} 9 | yew-functional = { rev="d1f15b6f862d555023c18a48c5fb154e539be22b", git = "https://github.com/yewstack/yew"} 10 | yew-router = { rev="d1f15b6f862d555023c18a48c5fb154e539be22b", git = "https://github.com/yewstack/yew"} 11 | 12 | log = { version = "0.4.5", features = ["std"] } 13 | chrono = { version = "0.4.10", features = ["wasmbind", "serde"] } 14 | wasm-logger = "0.2.0" 15 | wee_alloc = "0.4.5" 16 | wasm-bindgen = { version = "0.2.78", features = ["serde-serialize"] } 17 | wasm-bindgen-futures = "0.4.20" 18 | serde = { version = "1.0.80", features = ["derive"] } 19 | dotenv_codegen = "0.15.0" 20 | 21 | [dependencies.web-sys] 22 | version = "0.3.4" 23 | features = [ 24 | "Document", 25 | "Element", 26 | "HtmlCollection", 27 | "HtmlElement", 28 | 'Window', 29 | 'ScrollToOptions', 30 | 'HtmlDocument', 31 | 'Headers', 32 | 'Request', 33 | 'RequestInit', 34 | 'RequestMode', 35 | 'RequestRedirect', 36 | 'Response', 37 | ] 38 | 39 | [dev-dependencies] 40 | wasm-bindgen-test = "0.3.14" 41 | 42 | [profile.release] 43 | # Less code to include into binary 44 | panic = 'abort' 45 | # Optimization over all codebase ( better optimization, slower build ) 46 | codegen-units = 1 47 | # Optimization for size ( most aggressive ) 48 | opt-level = 'z' 49 | # Link time optimization using using whole-program analysis 50 | lto = true 51 | 52 | -------------------------------------------------------------------------------- /frontend/Trunk.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | # The index HTML file to drive the bundling process. 3 | target = "index.html" 4 | # Build in release mode. 5 | release = true 6 | # The output dir for all final assets. 7 | dist = "dist" 8 | # The public URL from which assets are to be served. 9 | public_url = "/" 10 | 11 | [serve] 12 | # The port to serve on. 13 | port = 3001 14 | # Open a browser tab once the initial build is complete. 15 | open = false 16 | # Disable auto-reload of the web app. 17 | no_autoreload = false 18 | 19 | [clean] 20 | # The output dir for all final assets. 21 | dist = "dist" 22 | # Optionally perform a cargo clean. 23 | cargo = false 24 | 25 | [tools] 26 | # Default dart-sass version to download. 27 | sass = "1.37.5" 28 | # Default wasm-bindgen version to download. 29 | wasm_bindgen = "0.2.78" 30 | # Default wasm-opt version to download. 31 | wasm_opt = "version_101" 32 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | your-domain.com 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /frontend/src/app.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{ 3 | components::{article::Article, articles::Articles, page_not_found::PageNotFound}, 4 | routes::*, 5 | }, 6 | yew::html, 7 | yew_functional::function_component, 8 | yew_router::{components::RouterAnchor, prelude::Route, router::Router, switch::Permissive}, 9 | }; 10 | 11 | #[function_component(App)] 12 | pub fn app() -> Html { 13 | html! { 14 |
15 | route=AppRoute::Articles> 16 |

{"Articles"}

17 |
> 18 |
19 | 20 | render = Router::render(move |route: AppRoute| { 21 | match route { 22 | AppRoute::Article { id } => html! { 23 |
24 | }, 25 | AppRoute::Articles => html! { 26 | 27 | }, 28 | AppRoute::PageNotFound(Permissive(None)) => html! {}, 29 | AppRoute::PageNotFound(Permissive(Some(missed_route))) => html! { 30 | 31 | } 32 | } 33 | }) 34 | redirect = Router::redirect(|route: Route<()>| { 35 | AppRoute::PageNotFound(Permissive(Some(route.route))) 36 | }) 37 | /> 38 |
39 |
40 |

{"guimauve"}

41 |
42 |
43 | } 44 | } 45 | -------------------------------------------------------------------------------- /frontend/src/components/article.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{ 3 | entities::interfaces::{IArticle, Status}, 4 | service::{articles::get_article, future::handle_future}, 5 | utils::date::format_date, 6 | }, 7 | yew::{html, Properties}, 8 | yew_functional::{function_component, use_effect_with_deps, use_state}, 9 | }; 10 | 11 | #[derive(Properties, Clone, PartialEq)] 12 | pub struct ArticleProps { 13 | pub id: i32, 14 | } 15 | 16 | #[function_component(Article)] 17 | pub fn article(ArticleProps { id }: &ArticleProps) -> Html { 18 | let id = *id; 19 | 20 | let (is_loading, set_loading) = use_state(|| false); 21 | let (article, set_article) = use_state(move || IArticle::default()); 22 | 23 | use_effect_with_deps( 24 | move |_| { 25 | set_loading(true); 26 | let future = async move { get_article(&id).await }; 27 | handle_future(future, move |data: Result| { 28 | match data { 29 | Ok(article) => set_article(article), 30 | Err(_) => (), 31 | }; 32 | set_loading(false); 33 | }); 34 | || {} 35 | }, 36 | (), 37 | ); 38 | 39 | html! { 40 | {if *is_loading { 41 | html! {} 42 | } else { 43 | html! { 44 |
45 |
46 |

{&article.title}

47 |
48 | {match format_date(&article.pub_date) { 49 | Ok(date) => html! {

{&date}

}, 50 | Err(_) => html! {

{"An error occured!"}

}, 51 | }} 52 |
53 | 54 |

{&article.headline}

55 |

{&article.content}

56 |
57 |
58 | } 59 | }} 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /frontend/src/components/articles.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::{ 3 | entities::interfaces::{IArticle, Status}, 4 | routes::AppRoute, 5 | service::{articles::get_article_list, future::handle_future}, 6 | }, 7 | yew::html, 8 | yew_functional::{function_component, use_effect_with_deps, use_state}, 9 | yew_router::components::RouterAnchor, 10 | }; 11 | 12 | #[function_component(Articles)] 13 | pub fn articles() -> Html { 14 | let (is_loading, set_loading) = use_state(|| false); 15 | let (articles, set_articles) = use_state(move || vec![]); 16 | 17 | use_effect_with_deps( 18 | move |_| { 19 | set_loading(true); 20 | let future = async { get_article_list().await }; 21 | handle_future(future, move |data: Result, Status>| { 22 | match data { 23 | Ok(articles) => set_articles(articles), 24 | Err(_) => (), 25 | }; 26 | set_loading(false); 27 | }); 28 | || {} 29 | }, 30 | (), 31 | ); 32 | html! { 33 | {if *is_loading { 34 | html! {} 35 | } else { 36 | html! { 37 |
38 |
39 | {for articles.iter().map(move |article| { 40 | html! { 41 | <> 42 |
43 | route=AppRoute::Article{id: article.id}> 44 | {&article.title} 45 | > 46 |
47 |
48 | 49 | } 50 | }) 51 | } 52 |
53 |
54 | } 55 | }} 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /frontend/src/components/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod article; 2 | pub mod articles; 3 | pub mod page_not_found; 4 | -------------------------------------------------------------------------------- /frontend/src/components/page_not_found.rs: -------------------------------------------------------------------------------- 1 | use { 2 | yew::{html, Properties}, 3 | yew_functional::function_component, 4 | }; 5 | 6 | #[derive(Properties, Clone, PartialEq)] 7 | pub struct PageNotFoundProps { 8 | #[prop_or(String::from("Page not found."))] 9 | pub page_name: String, 10 | } 11 | 12 | #[function_component(PageNotFound)] 13 | pub fn page_not_found(PageNotFoundProps { page_name }: &PageNotFoundProps) -> Html { 14 | html! { 15 |
16 |

{page_name}

17 |
18 | } 19 | } 20 | -------------------------------------------------------------------------------- /frontend/src/entities/interfaces.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | 3 | #[derive(Deserialize)] 4 | pub enum Status { 5 | Success, 6 | Error, 7 | Unknown, 8 | } 9 | 10 | #[derive(Debug, Deserialize, Default, Clone, PartialEq, Eq)] 11 | pub struct IArticle { 12 | pub id: i32, 13 | pub title: String, 14 | pub pub_date: String, 15 | pub published: bool, 16 | pub headline: String, 17 | pub image: String, 18 | pub content: String, 19 | } 20 | -------------------------------------------------------------------------------- /frontend/src/entities/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod interfaces; 2 | -------------------------------------------------------------------------------- /frontend/src/main.rs: -------------------------------------------------------------------------------- 1 | #![recursion_limit = "512"] 2 | 3 | #[macro_use] 4 | extern crate dotenv_codegen; 5 | 6 | use wasm_bindgen::prelude::*; 7 | 8 | pub mod app; 9 | pub mod components; 10 | pub mod entities; 11 | pub mod routes; 12 | pub mod service; 13 | pub mod utils; 14 | 15 | const API_URL: &str = dotenv!("API_URL"); 16 | 17 | #[cfg(feature = "wee_alloc")] 18 | #[global_allocator] 19 | static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT; 20 | 21 | #[wasm_bindgen] 22 | pub fn run_app() -> Result<(), JsValue> { 23 | #[cfg(debug_assertions)] 24 | wasm_logger::init(wasm_logger::Config::default()); 25 | yew::start_app::(); 26 | Ok(()) 27 | } 28 | 29 | fn main() { 30 | if let Err(err) = run_app() { 31 | println!("{:#?}", err) 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /frontend/src/routes/mod.rs: -------------------------------------------------------------------------------- 1 | use {yew_router::prelude::Switch, yew_router::switch::Permissive}; 2 | 3 | #[derive(Switch, Clone, PartialEq)] 4 | pub enum AppRoute { 5 | #[to = "/articles/{id}"] 6 | Article { id: i32 }, 7 | #[to = "/articles!"] 8 | Articles, 9 | #[to = "/404"] 10 | PageNotFound(Permissive), 11 | } 12 | -------------------------------------------------------------------------------- /frontend/src/service/articles.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::fetch::Fetch, 3 | crate::{ 4 | entities::interfaces::{IArticle, Status}, 5 | API_URL, 6 | }, 7 | }; 8 | 9 | pub async fn get_article_list() -> Result, Status> { 10 | let url = format!("{}/articles", API_URL); 11 | let json = Fetch::get(url).await; 12 | 13 | match json { 14 | Ok(json) => Ok(json.into_serde::>().unwrap()), 15 | Err(_err) => Err(Status::Error), 16 | } 17 | } 18 | 19 | pub async fn get_article(id: &i32) -> Result { 20 | let url = format!("{}/articles/{}", API_URL, id); 21 | let json = Fetch::get(url).await; 22 | 23 | match json { 24 | Ok(json) => Ok(json.into_serde::().unwrap()), 25 | Err(_err) => Err(Status::Error), 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /frontend/src/service/fetch.rs: -------------------------------------------------------------------------------- 1 | use { 2 | wasm_bindgen::{JsCast, JsValue}, 3 | wasm_bindgen_futures::JsFuture, 4 | web_sys::{Request, RequestInit, RequestMode, RequestRedirect, Response}, 5 | yew::web_sys, 6 | }; 7 | 8 | pub enum Method { 9 | GET, 10 | } 11 | 12 | pub async fn fetch(url: String, method: String) -> Result { 13 | let mut opts = RequestInit::new(); 14 | opts.method(&method); 15 | opts.mode(RequestMode::Cors); 16 | opts.redirect(RequestRedirect::Follow); 17 | 18 | let request = Request::new_with_str_and_init(&url, &opts)?; 19 | request.headers().set("Accept", "application/json")?; 20 | request.headers().set("Content-Type", "application/json")?; 21 | request.headers().set( 22 | "Access-Control-Request-Headers", 23 | "Content-Type, Authorization", 24 | )?; 25 | request 26 | .headers() 27 | .set("Access-Control-Request-Method", &method)?; 28 | 29 | let window = web_sys::window().unwrap(); 30 | let resp_value = JsFuture::from(window.fetch_with_request(&request)).await?; 31 | 32 | assert!(resp_value.is_instance_of::()); 33 | let resp: Response = resp_value.dyn_into().unwrap(); 34 | 35 | // Convert a JS Promise into a Rust Future 36 | let json = JsFuture::from(resp.json()?).await?; 37 | 38 | Ok(json) 39 | } 40 | 41 | pub struct Fetch(); 42 | 43 | impl Fetch { 44 | async fn fetch(url: String, method: Method) -> Result { 45 | let method = match method { 46 | Method::GET => "GET", 47 | }; 48 | fetch(url, method.to_string()).await 49 | } 50 | 51 | pub async fn get(url: String) -> Result { 52 | Fetch::fetch(url, Method::GET).await 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /frontend/src/service/future.rs: -------------------------------------------------------------------------------- 1 | use {std::future::Future, wasm_bindgen_futures::spawn_local}; 2 | 3 | pub fn handle_future(future: F, handler: H) 4 | where 5 | F: Future + 'static, 6 | H: Fn(T) + 'static, 7 | { 8 | spawn_local(async move { 9 | let rs: T = future.await; 10 | handler(rs); 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /frontend/src/service/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod articles; 2 | pub mod fetch; 3 | pub mod future; 4 | -------------------------------------------------------------------------------- /frontend/src/utils/date.rs: -------------------------------------------------------------------------------- 1 | use chrono::naive::NaiveDateTime; 2 | 3 | pub fn format_date(date_string: &str) -> Result { 4 | let datetime = NaiveDateTime::parse_from_str(date_string, "%Y-%m-%dT%H:%M:%S.%f")?; 5 | Ok(datetime.format("%B %d, %Y").to_string()) 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod date; 2 | -------------------------------------------------------------------------------- /frontend/static/styles/app.css: -------------------------------------------------------------------------------- 1 | html { 2 | height: 100%; 3 | font-family: Helvetica; 4 | display: flex; 5 | } 6 | 7 | body { 8 | display: flex; 9 | flex: 1; 10 | } 11 | --------------------------------------------------------------------------------