From e380eff9b5b2d0bebad1ba281e8c8b328bbca62e Mon Sep 17 00:00:00 2001 From: traxys Date: Wed, 17 May 2023 18:58:52 +0200 Subject: [PATCH] templates: Add a template for web applications --- flake.nix | 4 + templates/webapp/.envrc | 4 + templates/webapp/.gitignore | 3 + templates/webapp/Cargo.toml | 22 +++++ templates/webapp/api/Cargo.toml | 9 +++ templates/webapp/api/src/lib.rs | 12 +++ templates/webapp/app/Cargo.toml | 13 +++ templates/webapp/app/index.html | 8 ++ templates/webapp/app/src/main.rs | 40 ++++++++++ templates/webapp/flake.nix | 36 +++++++++ templates/webapp/src/main.rs | 124 +++++++++++++++++++++++++++++ templates/webapp/src/routes/mod.rs | 53 ++++++++++++ 12 files changed, 328 insertions(+) create mode 100644 templates/webapp/.envrc create mode 100644 templates/webapp/.gitignore create mode 100644 templates/webapp/Cargo.toml create mode 100644 templates/webapp/api/Cargo.toml create mode 100644 templates/webapp/api/src/lib.rs create mode 100644 templates/webapp/app/Cargo.toml create mode 100644 templates/webapp/app/index.html create mode 100644 templates/webapp/app/src/main.rs create mode 100644 templates/webapp/flake.nix create mode 100644 templates/webapp/src/main.rs create mode 100644 templates/webapp/src/routes/mod.rs diff --git a/flake.nix b/flake.nix index 385fae2..92f138b 100644 --- a/flake.nix +++ b/flake.nix @@ -90,6 +90,10 @@ path = ./templates/perseus; description = "A perseus frontend with rust-overlay & direnv"; }; + webapp = { + path = ./templates/webapp; + description = "A template for a web application (frontend + backend)"; + }; }; packages.x86_64-linux = pkgList "x86_64-linux" nixpkgs.legacyPackages.x86_64-linux.callPackage; diff --git a/templates/webapp/.envrc b/templates/webapp/.envrc new file mode 100644 index 0000000..5e9005b --- /dev/null +++ b/templates/webapp/.envrc @@ -0,0 +1,4 @@ +if ! has nix_direnv_version || ! nix_direnv_version 2.1.1; then + source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/2.1.1/direnvrc" "sha256-b6qJ4r34rbE23yWjMqbmu3ia2z4b2wIlZUksBke/ol0=" +fi +use flake diff --git a/templates/webapp/.gitignore b/templates/webapp/.gitignore new file mode 100644 index 0000000..c8f24f8 --- /dev/null +++ b/templates/webapp/.gitignore @@ -0,0 +1,3 @@ +/target +/.direnv +/app/dist diff --git a/templates/webapp/Cargo.toml b/templates/webapp/Cargo.toml new file mode 100644 index 0000000..29446f8 --- /dev/null +++ b/templates/webapp/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "todo_change_name" +version = "0.1.0" +authors = ["traxys "] +edition = "2021" + +[workspace] +members = [".", "api", "app"] + +[dependencies] +anyhow = "1.0.71" +axum = "0.6.18" +base64 = "0.21.0" +config = "0.13.3" +jwt-simple = "0.11.5" +serde = { version = "1.0.163", features = ["derive"] } +tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread"] } +tracing = "0.1.37" +tracing-subscriber = "0.3.17" +api = { path = "./api" } +thiserror = "1.0.40" +tower-http = { version = "0.4.0", features = ["cors", "fs"] } diff --git a/templates/webapp/api/Cargo.toml b/templates/webapp/api/Cargo.toml new file mode 100644 index 0000000..7efb64a --- /dev/null +++ b/templates/webapp/api/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "api" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +serde = { version = "1.0.163", features = ["derive"] } diff --git a/templates/webapp/api/src/lib.rs b/templates/webapp/api/src/lib.rs new file mode 100644 index 0000000..b68cd6a --- /dev/null +++ b/templates/webapp/api/src/lib.rs @@ -0,0 +1,12 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +pub struct LoginRequest { + pub username: String, + pub password: String, +} + +#[derive(Serialize, Deserialize)] +pub struct LoginResponse { + pub token: String, +} diff --git a/templates/webapp/app/Cargo.toml b/templates/webapp/app/Cargo.toml new file mode 100644 index 0000000..45af8c2 --- /dev/null +++ b/templates/webapp/app/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "app" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +api = { version = "0.1.0", path = "../api" } +console_log = { version = "1.0.0", features = ["color"] } +log = "0.4.17" +yew = { version = "0.20.0", features = ["csr"] } +yew-router = "0.17.0" diff --git a/templates/webapp/app/index.html b/templates/webapp/app/index.html new file mode 100644 index 0000000..995c87d --- /dev/null +++ b/templates/webapp/app/index.html @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/templates/webapp/app/src/main.rs b/templates/webapp/app/src/main.rs new file mode 100644 index 0000000..2f11d2f --- /dev/null +++ b/templates/webapp/app/src/main.rs @@ -0,0 +1,40 @@ +use log::Level; +use yew::prelude::*; +use yew_router::prelude::*; + +#[derive(Routable, Debug, Clone, Copy, PartialEq, Eq)] +enum Route { + #[at("/")] + Index, + #[at("/404")] + #[not_found] + NotFound, +} + +#[function_component] +fn App() -> Html { + html! { + +
+ render={switch} /> +
+
+ } +} + +fn switch(route: Route) -> Html { + match route { + Route::Index => html! { + "Index" + }, + Route::NotFound => html! { + "Page not found" + }, + } +} + +fn main() { + console_log::init_with_level(Level::Debug).unwrap(); + + yew::Renderer::::new().render(); +} diff --git a/templates/webapp/flake.nix b/templates/webapp/flake.nix new file mode 100644 index 0000000..67b3f15 --- /dev/null +++ b/templates/webapp/flake.nix @@ -0,0 +1,36 @@ +{ + description = "A basic flake with a shell"; + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + inputs.flake-utils.url = "github:numtide/flake-utils"; + inputs.naersk.url = "github:nix-community/naersk"; + inputs.rust-overlay.url = "github:oxalica/rust-overlay"; + + outputs = { + self, + nixpkgs, + flake-utils, + naersk, + rust-overlay, + }: + flake-utils.lib.eachDefaultSystem (system: let + pkgs = import nixpkgs { + inherit system; + overlays = [(import rust-overlay)]; + }; + rust = pkgs.rust-bin.stable.latest.default.override { + targets = ["wasm32-unknown-unknown"]; + }; + naersk' = pkgs.callPackage naersk { + cargo = rust; + rustc = rust; + }; + in { + devShell = pkgs.mkShell { + nativeBuildInputs = [rust pkgs.trunk pkgs.httpie]; + RUST_PATH = "${rust}"; + RUST_DOC_PATH = "${rust}/share/doc/rust/html/std/index.html"; + }; + + defaultPackage = naersk'.buildPackage ./.; + }); +} diff --git a/templates/webapp/src/main.rs b/templates/webapp/src/main.rs new file mode 100644 index 0000000..abd37cb --- /dev/null +++ b/templates/webapp/src/main.rs @@ -0,0 +1,124 @@ +use std::{net::SocketAddr, path::PathBuf, sync::Arc}; + +use axum::Router; +use base64::{engine::general_purpose, Engine}; +use config::{Config, ConfigError}; +use jwt_simple::prelude::HS256Key; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use tower_http::services::ServeDir; + +mod routes; + +#[derive(Clone)] +pub(crate) struct Base64(pub(crate) HS256Key); + +impl std::fmt::Debug for Base64 { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + r#"b64"{}""#, + &general_purpose::STANDARD_NO_PAD.encode(self.0.to_bytes()) + ) + } +} + +impl Serialize for Base64 { + fn serialize(&self, ser: S) -> Result + where + S: Serializer, + { + ser.serialize_str(&general_purpose::STANDARD_NO_PAD.encode(self.0.to_bytes())) + } +} + +impl<'de> Deserialize<'de> for Base64 { + fn deserialize(de: D) -> Result + where + D: Deserializer<'de>, + { + use serde::de::Visitor; + + struct DecodingVisitor; + impl<'de> Visitor<'de> for DecodingVisitor { + type Value = Base64; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("must be a base 64 string") + } + + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + general_purpose::STANDARD_NO_PAD + .decode(v) + .map_err(E::custom) + .map(|b| HS256Key::from_bytes(&b)) + .map(Base64) + } + } + + de.deserialize_str(DecodingVisitor) + } +} + +#[derive(Deserialize, Debug)] +struct Settings { + jwt_secret: Base64, + host: String, + port: u16, + api_allowed: Option, + serve_app: Option, +} + +impl Settings { + pub fn new() -> Result { + let cfg = Config::builder() + .add_source(config::Environment::with_prefix("WEBAPP")) + .set_default("host", "127.0.0.1")? + .set_default("port", "8085")? + .set_default("api_allowed", None::)? + .set_default("serve_app", None::)? + .build()?; + + cfg.try_deserialize() + } +} + +struct AppState { + jwt_secret: Base64, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_max_level(tracing::Level::DEBUG) + .with_test_writer() + .init(); + + let config = Settings::new()?; + + tracing::info!("Settings: {config:?}"); + + let addr: SocketAddr = format!("{}:{}", config.host, config.port).parse()?; + + let state = Arc::new(AppState { + jwt_secret: config.jwt_secret, + }); + + let router = Router::new() + .nest( + "/api", + routes::router(config.api_allowed.map(|s| s.parse()).transpose()?), + ) + .with_state(state); + + let router = match config.serve_app { + None => router, + Some(path) => router.fallback_service(ServeDir::new(path)), + }; + + Ok(axum::Server::bind(&addr) + .serve(router.into_make_service()) + .await?) +} diff --git a/templates/webapp/src/routes/mod.rs b/templates/webapp/src/routes/mod.rs new file mode 100644 index 0000000..e14056f --- /dev/null +++ b/templates/webapp/src/routes/mod.rs @@ -0,0 +1,53 @@ +use std::sync::Arc; + +use api::{LoginRequest, LoginResponse}; +use axum::{ + extract::State, + http::{header::CONTENT_TYPE, HeaderValue, Method, StatusCode}, + response::IntoResponse, + routing::post, + Json, Router, +}; +use tower_http::cors::{self, AllowOrigin, CorsLayer}; + +#[derive(thiserror::Error, Debug)] +enum RouteError { + #[error("This account does not exist")] + UnknownAccount, +} + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + match self { + RouteError::UnknownAccount => { + (StatusCode::NOT_FOUND, "Account not found").into_response() + } + } + } +} + +type JsonResult = Result, E>; + +type AppState = Arc; + +async fn login( + State(_state): State, + Json(_req): Json, +) -> JsonResult { + Err(RouteError::UnknownAccount) +} + +pub(crate) fn router(api_allowed: Option) -> Router { + let origin: AllowOrigin = match api_allowed { + Some(n) => n.into(), + None => cors::Any.into(), + }; + + let cors_base = CorsLayer::new() + .allow_headers([CONTENT_TYPE]) + .allow_origin(origin); + + let mk_service = |m: Vec| cors_base.clone().allow_methods(m); + + Router::new().route("/login", post(login).layer(mk_service(vec![Method::POST]))) +}