├── .editorconfig ├── .gitignore ├── .vscode ├── launch.json └── settings.json ├── Cargo.toml ├── README.md ├── Rocket.toml ├── hero-manager-axum ├── .env ├── Cargo.toml ├── justfile ├── migrations │ ├── 20221108190627_create_heroes_table.down.sql │ ├── 20221108190627_create_heroes_table.up.sql │ ├── 20221108190628_add_heroes_check_constraints.down.sql │ ├── 20221108190628_add_heroes_check_constraints.up.sql │ ├── 20221109142615_add_unique_name.down.sql │ └── 20221109142615_add_unique_name.up.sql ├── readme.md ├── requests.http └── src │ ├── data.rs │ ├── error.rs │ ├── healthcheck.rs │ ├── heroes.rs │ ├── main.rs │ └── model.rs ├── justfile ├── requests.http ├── rustfmt.toml ├── spin.toml ├── todo-actix-web ├── Cargo.toml └── src │ └── main.rs ├── todo-axum ├── Cargo.toml └── src │ └── main.rs ├── todo-logic ├── Cargo.toml └── src │ └── lib.rs ├── todo-rocket ├── Cargo.toml └── src │ └── main.rs ├── todo-spin ├── Cargo.toml └── src │ ├── extractors.rs │ ├── lib.rs │ └── responders.rs └── todo-warp ├── Cargo.toml └── src └── main.rs /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | indent_style = space 9 | indent_size = 4 10 | 11 | [*.md] 12 | # double whitespace at end of line 13 | # denotes a line break in Markdown 14 | trim_trailing_whitespace = false 15 | 16 | [*.yml] 17 | indent_size = 2 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug/ 4 | target/ 5 | 6 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 7 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 8 | Cargo.lock 9 | 10 | # These are backup files generated by rustfmt 11 | **/*.rs.bk 12 | 13 | # MSVC Windows builds of rustc generate these, which store debugging information 14 | *.pdb 15 | 16 | todo_store.json 17 | -------------------------------------------------------------------------------- /.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 executable 'hero-manager-axum'", 11 | "cargo": { 12 | "args": [ 13 | "build", 14 | "--bin=hero-manager-axum", 15 | "--package=hero-manager-axum" 16 | ], 17 | "filter": { 18 | "name": "hero-manager-axum", 19 | "kind": "bin" 20 | } 21 | }, 22 | "args": [], 23 | "cwd": "${workspaceFolder}", 24 | "env": { 25 | "DATABASE_URL": "postgres://postgres:mysecretpassword@localhost/heroes" 26 | } 27 | }, 28 | { 29 | "type": "lldb", 30 | "request": "launch", 31 | "name": "Debug unit tests in executable 'hero-manager-axum'", 32 | "cargo": { 33 | "args": [ 34 | "test", 35 | "--no-run", 36 | "--bin=hero-manager-axum", 37 | "--package=hero-manager-axum" 38 | ], 39 | "filter": { 40 | "name": "hero-manager-axum", 41 | "kind": "bin" 42 | } 43 | }, 44 | "args": [], 45 | "cwd": "${workspaceFolder}" 46 | } 47 | ] 48 | } 49 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "rust-analyzer.checkOnSave.command": "clippy", 3 | "rest-client.rememberCookiesForSubsequentRequests": true 4 | } 5 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "todo-logic", 4 | "todo-axum", 5 | "todo-rocket", 6 | "todo-actix-web", 7 | "todo-warp", 8 | "todo-spin", 9 | "hero-manager-axum", 10 | ] 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Building Web APIs with Rust - State of the Union 2 | 3 | ## Introduction 4 | 5 | This repository contains the sample code for Rainer Stropek's talk at *Eurorust 2022*. They have been updated for Rainer's talk at the BASTA conference in Frankfurt in 2023. The accompanying slides can be found [here](https://slides.com/rainerstropek/rust-api-fxs/fullscreen). 6 | 7 | ## Abstract 8 | 9 | Many people primarily see Rust as a platform for doing systems programming. It is great in that area, but you can do so much more with Rust. In this talk, Rainer will focus on how to build web APIs with Rust. Modern web APIs typically run in the cloud and Rust’s ability to produce small and blazingly fast apps is perfectly suited for keeping your cloud bills small. 10 | 11 | In his talk, Rainer will do a high-level comparison of the frameworks Actix, Rocket, Warp, and Axum. How does typical API code look like in these frameworks? What are the most fundamental abstractions in them? How active and mature are they? Rainer will prepare a sample and use it to show similarities and differences. In addition to traditional frameworks, Rainer will also speak about Wasm-based options like WAGI and Spin and put them in perspective. 12 | 13 | The session will be code-heavy. The audience should have a solid understanding of the Rust programming language. However, people attending this session do not need to be Rust experts with years of practical experience. The general messages of the talk should be understandable for people who want to build web APIs and are in the process of evaluating whether they should invest more time in learning Rust. 14 | -------------------------------------------------------------------------------- /Rocket.toml: -------------------------------------------------------------------------------- 1 | [default] 2 | address = "0.0.0.0" 3 | port = 3000 4 | -------------------------------------------------------------------------------- /hero-manager-axum/.env: -------------------------------------------------------------------------------- 1 | CONTAINER_DB_DSN=postgres://postgres:mysecretpassword@postgres/heroes 2 | DATABASE_URL=postgres://postgres:mysecretpassword@localhost/heroes 3 | -------------------------------------------------------------------------------- /hero-manager-axum/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "hero-manager-axum" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | axum = "0.6" 8 | clap = { version = "4.1", features = ["derive", "cargo", "env"] } 9 | tokio = { version = "1.26", features = ["full"] } 10 | serde_json = "1.0" 11 | serde = { version = "1.0", features = ["derive"] } 12 | sqlx = { version = "0.7", features = [ "runtime-tokio-native-tls" , "postgres", "chrono" ] } 13 | chrono = { version = "0.4", features = [ "serde" ] } 14 | anyhow = "1.0" 15 | http-api-problem = { version = "0.57", features = [ "axum" ] } 16 | tracing = "0.1" 17 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } 18 | tower = { version = "0.4", features = ["timeout"] } 19 | tower-http = { version = "0.4", features = ["trace", "catch-panic"] } 20 | axum-macros = "0.3" 21 | validator = { version = "0.16", features = ["derive"] } 22 | thiserror = "1.0" 23 | mockall_double = "0.3" 24 | 25 | [dev-dependencies] 26 | mockall = "0.11" 27 | rstest = "0.18" 28 | tower = { version = "0.4", features = ["util"] } 29 | hyper = { version = "0.14", features = ["full"] } 30 | -------------------------------------------------------------------------------- /hero-manager-axum/justfile: -------------------------------------------------------------------------------- 1 | set dotenv-load 2 | 3 | install-sqlx: 4 | cargo install sqlx-cli 5 | 6 | start-pg: 7 | docker run --name postgres-dev -e POSTGRES_PASSWORD=mysecretpassword -d -p 5432:5432 postgres 8 | 9 | start-psql: 10 | docker run -it --rm --link postgres-dev:postgres postgres psql -h postgres -U postgres 11 | 12 | run-psql stmt: 13 | docker run -t --rm --link postgres-dev:postgres postgres psql -d "$CONTAINER_DB_DSN" -c "{{stmt}}" 14 | 15 | get-db-user: 16 | just run-psql "SELECT current_user" 17 | 18 | create-db: 19 | sqlx database create 20 | 21 | create-migrations: 22 | sqlx migrate add -r create_heroes_table 23 | sqlx migrate add -r add_heroes_check_constraints 24 | sqlx migrate add -r add_unique_name 25 | 26 | apply-migrations: 27 | sqlx migrate run 28 | 29 | describe-table table: 30 | just run-psql "\d {{table}}" 31 | -------------------------------------------------------------------------------- /hero-manager-axum/migrations/20221108190627_create_heroes_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS heroes; 2 | -------------------------------------------------------------------------------- /hero-manager-axum/migrations/20221108190627_create_heroes_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS heroes ( 2 | id bigserial PRIMARY KEY, 3 | first_seen timestamptz(0) NOT NULL DEFAULT NOW(), 4 | name text NOT NULL, 5 | can_fly boolean NOT NULL DEFAULT false, 6 | realName text NULL, 7 | abilities text[] NOT NULL, 8 | version integer NOT NULL DEFAULT 1 9 | ); 10 | -------------------------------------------------------------------------------- /hero-manager-axum/migrations/20221108190628_add_heroes_check_constraints.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE heroes DROP CONSTRAINT abilities_length_check; 2 | -------------------------------------------------------------------------------- /hero-manager-axum/migrations/20221108190628_add_heroes_check_constraints.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE heroes ADD CONSTRAINT abilities_length_check CHECK (array_length(abilities, 1) BETWEEN 1 AND 5); 2 | -------------------------------------------------------------------------------- /hero-manager-axum/migrations/20221109142615_add_unique_name.down.sql: -------------------------------------------------------------------------------- 1 | DROP INDEX IF EXISTS IX_name; 2 | -------------------------------------------------------------------------------- /hero-manager-axum/migrations/20221109142615_add_unique_name.up.sql: -------------------------------------------------------------------------------- 1 | CREATE UNIQUE INDEX IX_name ON heroes (name); 2 | -------------------------------------------------------------------------------- /hero-manager-axum/readme.md: -------------------------------------------------------------------------------- 1 | # Hero Manager 2 | 3 | This is a larger API sample using Axum and Sqlx. 4 | -------------------------------------------------------------------------------- /hero-manager-axum/requests.http: -------------------------------------------------------------------------------- 1 | GET http://localhost:4000/health_1 2 | 3 | ### 4 | GET http://localhost:4000/health_2 5 | 6 | ### 7 | GET http://localhost:4000/health_3 8 | 9 | ### 10 | GET http://localhost:4000/health_4 11 | 12 | ### 13 | GET http://localhost:4000/health_failing_1 14 | 15 | ### 16 | GET http://localhost:4000/health_failing_2 17 | 18 | ### 19 | GET http://localhost:4000/heroes?name=%man% 20 | 21 | ### 22 | POST http://localhost:4000/heroes 23 | Content-Type: application/json 24 | 25 | { 26 | "name": "Superman2111", 27 | "firstSeen": "1935-01-01T00:00:00Z", 28 | "canFly": true, 29 | "realname": "Clark Kent", 30 | "abilities": "super strong, can disguise with glasses" 31 | } 32 | 33 | ### 34 | POST http://localhost:4000/heroes/cleanup 35 | 36 | ### 37 | POST http://localhost:4000/heroes/slow 38 | 39 | ### 40 | POST http://localhost:4000/heroes/panic 41 | -------------------------------------------------------------------------------- /hero-manager-axum/src/data.rs: -------------------------------------------------------------------------------- 1 | // Data access layer for our sample 2 | // 3 | // This sample module implements a simple data access layer with sqlx and Postgres. 4 | // A core idea of this sample implementation is the use of a trait with automock. 5 | // With that, web API handler functions can be unit-tested. 6 | // 7 | // Note that this sample focusses on web APIs and Axum. Therefore, no integration 8 | // tests have been developed with sqlx (read more about that topic at 9 | // https://docs.rs/sqlx/latest/sqlx/attr.test.html). 10 | 11 | use crate::model::{Hero, IdentifyableHero}; 12 | use axum::async_trait; 13 | #[cfg(test)] 14 | use mockall::automock; 15 | use sqlx::PgPool; 16 | use tracing::error; 17 | 18 | /// Represents primary key and version data for a hero 19 | pub struct HeroPkVersion { 20 | pub id: i64, 21 | pub version: i32, 22 | } 23 | 24 | /// Logs an sqlx error 25 | pub fn log_error(e: sqlx::Error) -> sqlx::Error { 26 | error!("Failed to execute SQL statement: {:?}", e); 27 | e 28 | } 29 | 30 | /// Repository for maintaining heroes in the DB 31 | #[cfg_attr(test, automock)] 32 | #[async_trait] 33 | pub trait HeroesRepositoryTrait { 34 | /// Deletes all heroes from the DB 35 | async fn cleanup(&self) -> Result<(), sqlx::error::Error>; 36 | 37 | /// Gets a list of heroes from the DB filted by name 38 | async fn get_by_name(&self, name: &str) -> Result, sqlx::error::Error>; 39 | 40 | /// Insert a new hero in the DB 41 | async fn insert(&self, hero: &Hero) -> Result; 42 | } 43 | 44 | /// Implementation of the heroes repository 45 | pub struct HeroesRepository(pub PgPool); 46 | 47 | #[async_trait] 48 | impl HeroesRepositoryTrait for HeroesRepository { 49 | async fn cleanup(&self) -> Result<(), sqlx::error::Error> { 50 | sqlx::query("DELETE FROM heroes").execute(&self.0).await?; 51 | Ok(()) 52 | } 53 | 54 | async fn get_by_name(&self, name: &str) -> Result, sqlx::error::Error> { 55 | sqlx::query_as::<_, IdentifyableHero>("SELECT * FROM heroes WHERE name LIKE $1") 56 | .bind(name) 57 | .fetch_all(&self.0) 58 | .await 59 | } 60 | 61 | async fn insert(&self, hero: &Hero) -> Result { 62 | let pk: (i64, i32) = sqlx::query_as( 63 | r#" 64 | INSERT INTO heroes (first_seen, name, can_fly, realname, abilities) 65 | VALUES ($1, $2, $3, $4, $5) 66 | RETURNING id, version"#, 67 | ) 68 | .bind(hero.first_seen) 69 | .bind(&hero.name) 70 | .bind(hero.can_fly) 71 | .bind(&hero.realname) 72 | .bind(&hero.abilities) 73 | .fetch_one(&self.0) 74 | .await?; 75 | Ok(HeroPkVersion { 76 | id: pk.0, 77 | version: pk.1, 78 | }) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /hero-manager-axum/src/error.rs: -------------------------------------------------------------------------------- 1 | // Helpers for error handling 2 | 3 | use axum::{ 4 | body::Body, 5 | http::{header, StatusCode}, 6 | response::{IntoResponse, Response}, 7 | Json, 8 | }; 9 | use http_api_problem::HttpApiProblem; 10 | use std::any::Any; 11 | use validator::ValidationErrors; 12 | 13 | /// Represents an application-level error 14 | #[derive(thiserror::Error, Debug)] 15 | pub enum Error { 16 | #[error("an internal database error occurred")] 17 | Sqlx(#[from] sqlx::Error), 18 | 19 | #[error("an internal server error occurred")] 20 | Anyhow(#[from] anyhow::Error), 21 | 22 | #[error("validation error in request body")] 23 | InvalidEntity(#[from] ValidationErrors), 24 | } 25 | 26 | /// Type alias for Results that use our application-level error enum 27 | pub type Result = std::result::Result; 28 | 29 | impl IntoResponse for Error { 30 | fn into_response(self) -> Response { 31 | let payload = match self { 32 | Self::InvalidEntity(errors) => HttpApiProblem::new(StatusCode::UNPROCESSABLE_ENTITY) 33 | .type_url("https://example.com/errors/unprocessable-entity") 34 | .title("Unprocessable entity in request body") 35 | .detail(errors.to_string()), 36 | _ => HttpApiProblem::new(StatusCode::INTERNAL_SERVER_ERROR) 37 | .type_url("https://example.com/errors/internal-error") 38 | .title("Internal Server Error"), 39 | }; 40 | (payload.status.unwrap(), Json(payload)).into_response() 41 | } 42 | } 43 | 44 | pub fn handle_panic(err: Box) -> Response { 45 | let mut problem = HttpApiProblem::new(StatusCode::INTERNAL_SERVER_ERROR) 46 | .type_url("https://example.com/errors/internal-error") 47 | .title("Internal Server Error"); 48 | 49 | if let Some(s) = err.downcast_ref::() { 50 | tracing::error!("Panic: {}", s); 51 | problem = problem.detail(s) 52 | } else if let Some(s) = err.downcast_ref::<&str>() { 53 | tracing::error!("Panic: {}", s); 54 | problem = problem.detail(s.to_string()) 55 | } 56 | 57 | Response::builder() 58 | .status(StatusCode::INTERNAL_SERVER_ERROR) 59 | .header(header::CONTENT_TYPE, "application/json") 60 | .body(Body::from(serde_json::to_string(&problem).unwrap())) 61 | .unwrap() 62 | } 63 | -------------------------------------------------------------------------------- /hero-manager-axum/src/healthcheck.rs: -------------------------------------------------------------------------------- 1 | /// Healtheck routes and handlers 2 | /// 3 | /// This part of the sample demonstrates various ways for how to 4 | /// build web responses based on a healthcheck endpoint. 5 | /// We also use the healthcheck endpoints to demonstrate some 6 | /// principles about testing handlers. 7 | 8 | use axum::{ 9 | body::{Bytes, Full}, 10 | extract::State, 11 | http::{header, StatusCode}, 12 | response::{IntoResponse, Response}, 13 | routing::get, 14 | Json, Router, 15 | }; 16 | use serde::Serialize; 17 | use serde_json::{json, Value}; 18 | use std::{convert::Infallible, sync::Arc}; 19 | 20 | use crate::{AppConfiguration, Environment, error}; 21 | 22 | /// Setup healthcheck API routes 23 | pub fn healthcheck_routes(shared_state: Arc) -> Router { 24 | // Note that we are using the new state sharing API of the latest RC of Axum here. 25 | Router::new() 26 | .route("/health_1", get(healthcheck_handler_1)) 27 | .route("/health_2", get(healthcheck_handler_2)) 28 | .route("/health_3", get(healthcheck_handler_3)) 29 | .route("/health_4", get(healthcheck_handler_4)) 30 | .route("/health_failing_1", get(failing_healthcheck_1)) 31 | .route("/health_failing_2", get(failing_healthcheck_2)) 32 | .with_state(shared_state) 33 | } 34 | 35 | /// Healthcheck handler 36 | /// 37 | /// This implementation demonstrates how to manually build a response. 38 | /// For more details see https://docs.rs/axum/0.6.0-rc.4/axum/response/index.html#building-responses 39 | pub async fn healthcheck_handler_1(State(state): State>) -> impl IntoResponse { 40 | ( 41 | StatusCode::OK, 42 | [(header::CONTENT_TYPE, "application/json")], 43 | format!(r#"{{"version":"{0}","env":"{1:?}"}}"#, state.version, state.env), 44 | ) 45 | } 46 | 47 | /// Healthcheck handler 48 | /// 49 | /// This implementation demonstrates how to build a response with low-level builder. 50 | /// For more details see https://docs.rs/axum/0.6.0-rc.4/axum/response/index.html#building-responses 51 | pub async fn healthcheck_handler_2(State(state): State>) -> Response> { 52 | Response::builder() 53 | .status(StatusCode::OK) 54 | .header(header::CONTENT_TYPE, "application/json") 55 | .body(Full::from(format!( 56 | r#"{{"version":"{0}","env":"{1:?}"}}"#, 57 | state.version, state.env 58 | ))) 59 | .unwrap() 60 | } 61 | 62 | /// Healthcheck handler 63 | /// 64 | /// This implementation demonstrates how to build a JSON response with Json. 65 | /// For more details see https://docs.rs/axum/0.6.0-rc.4/axum/struct.Json.html 66 | pub async fn healthcheck_handler_3(State(state): State>) -> Json { 67 | let value = json!({ 68 | "version": state.version, 69 | "env": format!("{:?}", state.env), 70 | }); 71 | Json(value) 72 | } 73 | 74 | #[derive(Serialize)] 75 | pub struct HealthcheckResponseDto { 76 | version: String, 77 | env: Environment, 78 | } 79 | 80 | /// Healthcheck handler 81 | /// 82 | /// This implementation demonstrates how to build a JSON response with Axum's Json responder. 83 | /// For more details see https://docs.rs/axum/0.6.0-rc.4/axum/struct.Json.html 84 | pub async fn healthcheck_handler_4(State(state): State>) -> Json { 85 | Json(HealthcheckResponseDto { 86 | version: state.version.to_string(), 87 | env: state.env.clone(), 88 | }) 89 | } 90 | 91 | pub async fn failing_healthcheck_1() -> error::Result<()> { 92 | Err(error::Error::Anyhow(anyhow::anyhow!("Something bad happened"))) 93 | } 94 | 95 | pub async fn failing_healthcheck_2() -> Infallible { 96 | panic!("Something very bad happened"); 97 | } 98 | 99 | #[cfg(test)] 100 | mod tests { 101 | use std::net::{SocketAddr, TcpListener}; 102 | 103 | use super::*; 104 | use axum::http::Request; 105 | use rstest::rstest; 106 | use tower::ServiceExt; 107 | 108 | #[rstest] 109 | #[case("/health_1")] 110 | #[case("/health_2")] 111 | #[case("/health_3")] 112 | #[case("/health_4")] 113 | #[tokio::test] 114 | async fn healthchecks(#[case] uri: &str) { 115 | let app = healthcheck_routes(Arc::new(AppConfiguration { 116 | env: Environment::Development, 117 | version: "1.0.0", 118 | })); 119 | 120 | // `Router` implements `tower::Service>` so we can 121 | // call it like any tower service, no need to run an HTTP server. 122 | let response = app 123 | .oneshot(Request::builder().uri(uri).body(hyper::Body::empty()).unwrap()) 124 | .await 125 | .unwrap(); 126 | 127 | assert_eq!(response.status(), StatusCode::OK); 128 | 129 | let body = hyper::body::to_bytes(response.into_body()).await.unwrap(); 130 | let body: Value = serde_json::from_slice(&body).unwrap(); 131 | 132 | assert_eq!(body, json!({ "version": "1.0.0", "env": "Development" })); 133 | } 134 | 135 | #[rstest] 136 | #[case("/health_1")] 137 | #[case("/health_2")] 138 | #[case("/health_3")] 139 | #[case("/health_4")] 140 | #[tokio::test] 141 | async fn healthchecks_real(#[case] url: &str) { 142 | let listener = TcpListener::bind("0.0.0.0:0".parse::().unwrap()).unwrap(); 143 | let addr = listener.local_addr().unwrap(); 144 | 145 | let app = healthcheck_routes(Arc::new(AppConfiguration { 146 | env: Environment::Development, 147 | version: "1.0.0", 148 | })) 149 | .into_make_service(); 150 | 151 | tokio::spawn(async move { 152 | axum::Server::from_tcp(listener) 153 | .unwrap() 154 | .serve(app) 155 | .await 156 | .unwrap(); 157 | }); 158 | 159 | let client = hyper::Client::new(); 160 | 161 | let response = client 162 | .request( 163 | Request::builder() 164 | .uri(format!("http://{addr}{url}")) 165 | .body(hyper::Body::empty()) 166 | .unwrap(), 167 | ) 168 | .await 169 | .unwrap(); 170 | 171 | assert_eq!(response.status(), StatusCode::OK); 172 | 173 | let body = hyper::body::to_bytes(response.into_body()).await.unwrap(); 174 | let body: Value = serde_json::from_slice(&body).unwrap(); 175 | 176 | assert_eq!(body, json!({ "version": "1.0.0", "env": "Development" })); 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /hero-manager-axum/src/heroes.rs: -------------------------------------------------------------------------------- 1 | /// API handler for hero management 2 | /// 3 | /// The most important aspect of this part of the sample is dependency 4 | /// injection with a trait. Our goal is to unit-test our handlers using 5 | /// mocked versions of our data access layer. 6 | use crate::{ 7 | data::{log_error, HeroesRepositoryTrait}, 8 | model::{Hero, IdentifyableHero}, error, 9 | }; 10 | use axum::{ 11 | extract::{Query, State}, 12 | http::{header::LOCATION, HeaderMap, StatusCode}, 13 | response::{IntoResponse, Response}, 14 | routing::post, 15 | Json, Router, 16 | }; 17 | use serde::Deserialize; 18 | use tokio::time::sleep; 19 | use std::{sync::Arc, time::Duration}; 20 | use validator::Validate; 21 | 22 | /// Type alias for our shared state 23 | /// 24 | /// Note that we use a dyn state so that we can easily replace it 25 | /// with a mock object. 26 | pub type DynHeroesRepository = Arc; 27 | 28 | /// Setup hero management API routes 29 | pub fn heroes_routes(repo: DynHeroesRepository) -> Router { 30 | Router::new() 31 | .route("/", post(insert_hero).get(get_heroes)) 32 | .route("/cleanup", post(cleanup_heroes)) 33 | .route("/slow", post(do_something_slow)) 34 | .route("/panic", post(panic)) 35 | .with_state(repo) 36 | } 37 | 38 | #[derive(Deserialize)] 39 | pub struct GetHeroFilter { 40 | #[serde(rename = "name")] 41 | name_filter: Option, 42 | // In practice, add additional query parameters here 43 | } 44 | 45 | pub async fn get_heroes( 46 | State(repo): State, 47 | filter: Query, 48 | ) -> error::Result>> { 49 | let heroes = repo 50 | .get_by_name(filter.name_filter.as_deref().unwrap_or("%")) 51 | .await 52 | .map_err(log_error)?; 53 | Ok(Json(heroes)) 54 | } 55 | 56 | pub async fn cleanup_heroes(State(repo): State) -> error::Result { 57 | repo.cleanup().await.map_err(log_error)?; 58 | Ok(StatusCode::NO_CONTENT) 59 | } 60 | 61 | pub async fn insert_hero( 62 | State(repo): State, 63 | Json(hero): Json, 64 | ) -> error::Result { 65 | hero.validate()?; 66 | 67 | let hero_pk = repo.insert(&hero).await.map_err(log_error)?; 68 | 69 | let mut headers = HeaderMap::new(); 70 | headers.insert( 71 | LOCATION, 72 | format!("/heroes/{}", hero_pk.id) 73 | .parse() 74 | .expect("Parsing location header should never fail"), 75 | ); 76 | Ok(( 77 | StatusCode::OK, 78 | headers, 79 | Json(IdentifyableHero { 80 | id: hero_pk.id, 81 | inner_hero: hero, 82 | version: hero_pk.version, 83 | }), 84 | ) 85 | .into_response()) 86 | } 87 | 88 | pub async fn do_something_slow() -> error::Result { 89 | // Wait for 10 seconds 90 | sleep(Duration::from_secs(10)).await; 91 | Ok(StatusCode::OK) 92 | } 93 | 94 | pub async fn panic() -> error::Result { 95 | panic!("Something very bad happened"); 96 | } 97 | 98 | #[cfg(test)] 99 | mod tests { 100 | use crate::data::MockHeroesRepositoryTrait; 101 | 102 | use super::*; 103 | use axum::http::Request; 104 | use hyper::Body; 105 | use mockall::predicate::*; 106 | use rstest::rstest; 107 | use serde_json::Value; 108 | use sqlx::Error; 109 | use tower::ServiceExt; 110 | 111 | #[rstest] 112 | #[case(Ok(()), StatusCode::NO_CONTENT)] 113 | #[case(Err(Error::WorkerCrashed), StatusCode::INTERNAL_SERVER_ERROR)] 114 | #[tokio::test] 115 | async fn cleanup(#[case] result: Result<(), sqlx::error::Error>, #[case] status_code: StatusCode) { 116 | let mut repo_mock = MockHeroesRepositoryTrait::new(); 117 | repo_mock.expect_cleanup().return_once(|| result); 118 | 119 | let repo = Arc::new(repo_mock) as DynHeroesRepository; 120 | 121 | let app = heroes_routes(repo);//.into_service(); 122 | let response = app 123 | .oneshot( 124 | Request::builder() 125 | .uri("/cleanup") 126 | .method("POST") 127 | .body(hyper::Body::empty()) 128 | .unwrap(), 129 | ) 130 | .await 131 | .unwrap(); 132 | 133 | assert_eq!(response.status(), status_code); 134 | } 135 | 136 | #[tokio::test] 137 | async fn get_heroes() { 138 | let mut repo_mock = MockHeroesRepositoryTrait::new(); 139 | repo_mock.expect_get_by_name() 140 | .with(eq("Super%")) 141 | .returning(|_| Ok(vec![Default::default()])); 142 | 143 | let repo = Arc::new(repo_mock) as DynHeroesRepository; 144 | 145 | let app = heroes_routes(repo);//.into_service(); 146 | let response = app 147 | .oneshot( 148 | Request::builder() 149 | .uri("/?name=Super%") 150 | .method("GET") 151 | .body(Body::empty()) 152 | .unwrap(), 153 | ) 154 | .await 155 | .unwrap(); 156 | 157 | assert_eq!(response.status(), StatusCode::OK); 158 | 159 | let body = hyper::body::to_bytes(response.into_body()).await.unwrap(); 160 | let body: Value = serde_json::from_slice(&body).unwrap(); 161 | 162 | assert!(matches!(body, Value::Array { .. })); 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /hero-manager-axum/src/main.rs: -------------------------------------------------------------------------------- 1 | use crate::{data::HeroesRepository, heroes::DynHeroesRepository, model::AppConfiguration}; 2 | use axum::{error_handling::HandleErrorLayer, http, BoxError, Router}; 3 | use clap::{crate_version, Parser}; 4 | use model::Environment; 5 | use sqlx::postgres::PgPoolOptions; 6 | use std::{net::SocketAddr, sync::Arc, time::Duration}; 7 | use tokio::signal; 8 | use tower::ServiceBuilder; 9 | use tower_http::{catch_panic::CatchPanicLayer, trace::TraceLayer}; 10 | use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; 11 | 12 | mod data; 13 | mod error; 14 | mod healthcheck; 15 | mod heroes; 16 | mod model; 17 | 18 | /// Arguments for clap 19 | #[derive(Parser, Debug)] 20 | #[command(author, version, about, long_about = None)] 21 | struct Args { 22 | #[arg(short, long, default_value_t = 4000)] 23 | port: u16, 24 | 25 | #[arg(short, long, default_value_t = Environment::Development, value_enum)] 26 | env: Environment, 27 | 28 | #[arg(short, long, default_value = "", env = "DATABASE_URL")] 29 | database_url: String, 30 | } 31 | 32 | #[tokio::main] 33 | async fn main() { 34 | // Parse command-line args 35 | let cli = Args::parse(); 36 | 37 | // Setup connection pool 38 | let pool = PgPoolOptions::new() 39 | .max_connections(5) 40 | .connect(&cli.database_url) 41 | .await 42 | .expect("can connect to database"); 43 | 44 | // Build app configuration object 45 | let app_config = Arc::new(AppConfiguration { 46 | version: crate_version!(), 47 | env: cli.env, 48 | }); 49 | 50 | // Configure tracing 51 | tracing_subscriber::registry() 52 | .with(tracing_subscriber::EnvFilter::new( 53 | std::env::var("RUST_LOG").unwrap_or_else(|_| "hero_manager_axum=debug,tower_http=debug,sqlx=debug".into()), 54 | )) 55 | .with(tracing_subscriber::fmt::layer()) 56 | .init(); 57 | 58 | let repo = Arc::new(HeroesRepository(pool)) as DynHeroesRepository; 59 | 60 | // Setup top-level router 61 | let app = Router::new() 62 | // Add healthcheck routes 63 | .merge(healthcheck::healthcheck_routes(app_config.clone())) 64 | // Add heroes routes under /heroes 65 | .nest("/heroes", heroes::heroes_routes(repo)) 66 | .layer( 67 | ServiceBuilder::new() 68 | .layer(TraceLayer::new_for_http()) 69 | .layer(HandleErrorLayer::new(|_: BoxError| async { 70 | http::StatusCode::REQUEST_TIMEOUT 71 | })) 72 | .timeout(Duration::from_secs(2)) 73 | .layer(CatchPanicLayer::custom(error::handle_panic)), 74 | ); 75 | 76 | let addr = SocketAddr::from(([0, 0, 0, 0], cli.port)); 77 | println!("listening on {}", addr); 78 | axum::Server::bind(&addr) 79 | .serve(app.into_make_service()) 80 | .with_graceful_shutdown(shutdown_signal()) 81 | .await 82 | .unwrap(); 83 | } 84 | 85 | async fn shutdown_signal() { 86 | let ctrl_c = async { 87 | signal::ctrl_c().await.expect("failed to install Ctrl+C handler"); 88 | }; 89 | 90 | #[cfg(unix)] 91 | let terminate = async { 92 | signal::unix::signal(signal::unix::SignalKind::terminate()) 93 | .expect("failed to install signal handler") 94 | .recv() 95 | .await; 96 | }; 97 | 98 | #[cfg(not(unix))] 99 | let terminate = std::future::pending::<()>(); 100 | 101 | tokio::select! { 102 | _ = ctrl_c => {}, 103 | _ = terminate => {}, 104 | } 105 | 106 | println!("signal received, starting graceful shutdown"); 107 | } 108 | -------------------------------------------------------------------------------- /hero-manager-axum/src/model.rs: -------------------------------------------------------------------------------- 1 | // Model for our sample 2 | // 3 | // We are maintaining a list of heroes. In this sample, we use identical model classes 4 | // for web API and database access layer. This is for brevity. In more complicated projects, 5 | // you will probably have different models for DB and web API. 6 | // 7 | // Nevertheless, the model has been chosen to demonstrate some interesting aspects: 8 | // * Storing vectors in Postgres (abilities) 9 | // * Customizing serde for properties (abilities) 10 | // * Various serde macros (e.g. camelCase, flatten) 11 | // * Auto-mapping DB columns to fiels (FromRow) 12 | 13 | use chrono::{DateTime, Utc}; 14 | use clap::ValueEnum; 15 | use serde::{Deserialize, Deserializer, Serialize, Serializer}; 16 | use sqlx::FromRow; 17 | use validator::Validate; 18 | 19 | #[derive(Clone, ValueEnum, Debug, Serialize, PartialEq, Eq)] 20 | pub enum Environment { 21 | Development, 22 | Test, 23 | Production, 24 | } 25 | 26 | /// Application configuration 27 | #[derive(Clone)] 28 | pub struct AppConfiguration { 29 | pub version: &'static str, 30 | pub env: Environment 31 | } 32 | 33 | /// Represents a hero 34 | #[derive(Serialize, Deserialize, Validate, Clone)] 35 | #[serde(rename_all = "camelCase")] 36 | #[derive(FromRow, Default)] 37 | pub struct Hero { 38 | pub first_seen: DateTime, 39 | pub name: String, 40 | pub can_fly: bool, 41 | pub realname: Option, 42 | #[serde( 43 | deserialize_with = "deserialize_abilities", 44 | serialize_with = "serialize_abilities", 45 | default 46 | )] 47 | #[validate(length(max = 5))] 48 | pub abilities: Option>, 49 | } 50 | 51 | /// Represents a hero with primary key and version 52 | #[derive(Serialize)] 53 | #[serde(rename_all = "camelCase")] 54 | #[derive(FromRow, Default)] 55 | pub struct IdentifyableHero { 56 | pub id: i64, 57 | #[serde(flatten)] 58 | #[sqlx(flatten)] 59 | pub inner_hero: Hero, 60 | pub version: i32, 61 | } 62 | 63 | /// Deserialize vector of abilities from comma-separated string 64 | fn deserialize_abilities<'de, D>(deserializer: D) -> Result>, D::Error> 65 | where 66 | D: Deserializer<'de>, 67 | { 68 | let concat_abilities = Option::::deserialize(deserializer)?; 69 | match concat_abilities { 70 | Some(abilities) => Ok(Some(abilities.split(',').map(|s| s.trim().to_string()).collect())), 71 | None => Ok(None), 72 | } 73 | } 74 | 75 | /// Serialize vector of abilities into comma-separated string 76 | fn serialize_abilities(x: &Option>, serializer: S) -> Result 77 | where 78 | S: Serializer, 79 | { 80 | match x { 81 | Some(abilities) => serde::Serialize::serialize(&abilities.join(", "), serializer), 82 | None => serde::Serialize::serialize(&Option::>::None, serializer), 83 | } 84 | } 85 | 86 | #[cfg(test)] 87 | mod tests { 88 | // The following tests verify that abilities are serialized 89 | // and deserialized properly. 90 | 91 | use serde::{Deserialize, Serialize}; 92 | 93 | #[derive(Serialize, Deserialize)] 94 | struct JustAbilities { 95 | #[serde( 96 | serialize_with = "super::serialize_abilities", 97 | deserialize_with = "super::deserialize_abilities", 98 | default 99 | )] 100 | pub abilities: Option>, 101 | } 102 | 103 | #[derive(Deserialize)] 104 | struct DummyWithString { 105 | pub abilities: Option, 106 | } 107 | 108 | #[test] 109 | fn serialize_abilities() { 110 | let serialized = serde_json::to_string_pretty(&JustAbilities { 111 | abilities: Some(vec!["a".to_string(), "b".to_string()]), 112 | }) 113 | .unwrap(); 114 | let result: DummyWithString = serde_json::from_str(&serialized).unwrap(); 115 | assert_eq!("a, b", result.abilities.unwrap()); 116 | } 117 | 118 | #[test] 119 | fn serialize_none() { 120 | let serialized = serde_json::to_string_pretty(&JustAbilities { abilities: None }).unwrap(); 121 | let result: DummyWithString = serde_json::from_str(&serialized).unwrap(); 122 | assert!(result.abilities.is_none()); 123 | } 124 | 125 | #[test] 126 | fn deserialize_abilities() { 127 | let serialized: JustAbilities = serde_json::from_str("{ \"abilities\": \"a, b\" }").unwrap(); 128 | assert_eq!(vec!["a", "b"], serialized.abilities.unwrap()); 129 | } 130 | 131 | #[test] 132 | fn deserialize_none() { 133 | let serialized: JustAbilities = serde_json::from_str("{}").unwrap(); 134 | assert!(serialized.abilities.is_none()); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | run platform: 2 | cargo run --bin todo-{{platform}} 3 | 4 | build: 5 | cargo build 6 | 7 | run-spin: (build-spin) 8 | spin up 9 | 10 | build-spin: 11 | spin build 12 | 13 | check: 14 | cargo clippy 15 | -------------------------------------------------------------------------------- /requests.http: -------------------------------------------------------------------------------- 1 | @host=http://localhost:8080 2 | 3 | ### 4 | GET {{host}}/todos 5 | 6 | ### 7 | GET {{host}}/todos?offset=1&limit=2 8 | 9 | ### 10 | # @name newTodo 11 | POST {{host}}/todos 12 | Content-Type: application/json 13 | 14 | { 15 | "title": "Learn Rust", 16 | "notes": "Rust is a systems programming language that runs blazingly fast, prevents segfaults, and guarantees thread safety.", 17 | "assigned_to": "Rainer", 18 | "completed": false 19 | } 20 | 21 | ### 22 | @addedTodoId={{newTodo.response.body.$.id}} 23 | GET {{host}}/todos/{{addedTodoId}} 24 | 25 | ### 26 | @addedTodoId={{newTodo.response.body.$.id}} 27 | PATCH {{host}}/todos/{{addedTodoId}} 28 | Content-Type: application/json 29 | 30 | { 31 | "completed": true 32 | } 33 | 34 | ### 35 | @addedTodoId={{newTodo.response.body.$.id}} 36 | DELETE {{host}}/todos/{{addedTodoId}} 37 | 38 | ### 39 | POST {{host}}/todos/persist 40 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 120 2 | comment_width = 100 3 | match_block_trailing_comma = true 4 | wrap_comments = true 5 | edition = "2021" 6 | error_on_line_overflow = true 7 | version = "Two" 8 | -------------------------------------------------------------------------------- /spin.toml: -------------------------------------------------------------------------------- 1 | spin_manifest_version = "1" 2 | authors = ["Rainer Stropek"] 3 | description = "Todo API in spin" 4 | name = "todo-spin" 5 | trigger = { type = "http", base = "/" } 6 | version = "0.1.0" 7 | 8 | [[component]] 9 | id = "todo-spin" 10 | source = "target/wasm32-wasi/debug/todo_spin.wasm" 11 | allowed_http_hosts = [] 12 | [component.trigger] 13 | route = "/todos/..." 14 | [component.build] 15 | command = "cargo build -p todo-spin --target wasm32-wasi" 16 | watch = ["src/**/*.rs", "Cargo.toml"] 17 | -------------------------------------------------------------------------------- /todo-actix-web/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "todo-actix-web" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | actix-web = "4" 8 | todo-logic ={ path = "../todo-logic" } 9 | tokio = { version = "1.0", features = ["full"] } 10 | simplelog= "0" 11 | log = "0.4" 12 | -------------------------------------------------------------------------------- /todo-actix-web/src/main.rs: -------------------------------------------------------------------------------- 1 | use actix_web::{ 2 | delete, get, 3 | http::StatusCode, 4 | middleware::Logger, 5 | patch, post, web, 6 | web::{Data, Json, Path, Query}, 7 | App, Either, HttpResponse, HttpServer, Responder, ResponseError, 8 | }; 9 | use log::debug; 10 | use simplelog::{Config, LevelFilter, SimpleLogger}; 11 | use std::{fmt::Display, sync::Arc}; 12 | use todo_logic::{IdentifyableTodoItem, Pagination, TodoItem, TodoStore, TodoStoreError, UpdateTodoItem}; 13 | use tokio::sync::RwLock; 14 | 15 | /// Type for our shared state 16 | type Db = Arc>; 17 | 18 | #[actix_web::main] 19 | async fn main() -> std::io::Result<()> { 20 | // Initialize logging. 21 | // Actix's Logger middleware (https://actix.rs/actix-web/actix_web/middleware/struct.Logger.html) 22 | // uses the log crate (https://crates.io/crates/log) to log requests. You can use any 23 | // compatible logger, but for this example we'll use simplelog. 24 | SimpleLogger::init(LevelFilter::Debug, Config::default()).unwrap(); 25 | 26 | // Create shared data store 27 | let state = Data::new(Db::default()); 28 | 29 | HttpServer::new(move || { 30 | App::new() 31 | // Register a middleware to log requests. 32 | // More about writing custom middleware at https://actix.rs/docs/middleware/ 33 | .wrap(Logger::default()) 34 | // Register our shared state. 35 | // More about using shared state at https://actix.rs/docs/application/ 36 | .app_data(state.clone()) 37 | // Register our routes. Actix supports working with (service) 38 | // and without macros (route). 39 | .service(get_todos) 40 | .service(add_todo) 41 | .service(delete_todo) 42 | .service(update_todo) 43 | .service(persist) 44 | .route("/todos/{id}", web::get().to(get_todo)) 45 | }) 46 | // Start the server. 47 | // More about server at https://actix.rs/docs/server/ 48 | .bind(("0.0.0.0", 3000))? 49 | .run() 50 | .await 51 | } 52 | 53 | /// Get list of todo items 54 | /// 55 | /// Note the use of Extractors to extract data from the query 56 | /// and the shared state. More about extractors at 57 | /// https://actix.rs/docs/extractors/. 58 | /// 59 | /// Also note the Responder trait (https://actix.rs/docs/extractors/). 60 | /// Actix comes with a lot of built-in responders, but you can also 61 | /// implement your own. 62 | #[get("/todos")] 63 | async fn get_todos(pagination: Query, db: Data) -> impl Responder { 64 | let todos = db.read().await; 65 | let Query(pagination) = pagination; 66 | Json(todos.get_todos(pagination)) 67 | } 68 | 69 | /// If a method returns different return types, Actix offers 70 | /// the Either enum (https://actix.rs/docs/handlers/). 71 | type ItemOrStatus = Either, HttpResponse>; 72 | 73 | /// Get a single todo item 74 | async fn get_todo(id: Path, db: Data) -> ItemOrStatus { 75 | let todos = db.read().await; 76 | if let Some(item) = todos.get_todo(*id) { 77 | Either::Left(Json(item.clone())) 78 | } else { 79 | // Use HttpResponse to build responses with status code, 80 | // body, headers, etc. 81 | Either::Right(HttpResponse::NotFound().body("Not found")) 82 | } 83 | } 84 | 85 | /// Add a new todo item 86 | /// 87 | /// Note the use of the Json extractor to extract the body. 88 | #[post("/todos")] 89 | async fn add_todo(db: Data, todo: Json) -> impl Responder { 90 | let mut todos = db.write().await; 91 | let todo = todos.add_todo(todo.clone()); 92 | HttpResponse::Created().json(todo) 93 | } 94 | 95 | /// Delete a todo item 96 | /// 97 | /// Note the use of another Extractor, Path, to extract the id. 98 | #[delete("/todos/{id}")] 99 | async fn delete_todo(id: Path, db: Data) -> impl Responder { 100 | match db.write().await.remove_todo(*id) { 101 | Some(_) => HttpResponse::NoContent(), 102 | None => HttpResponse::NotFound(), 103 | } 104 | } 105 | 106 | /// Update a todo item 107 | #[patch("/todos/{id}")] 108 | async fn update_todo(id: Path, db: Data, input: Json) -> ItemOrStatus { 109 | let mut todos = db.write().await; 110 | let res = todos.update_todo(&id, input.into_inner()); 111 | match res { 112 | Some(todo) => Either::Left(Json(todo.clone())), 113 | None => Either::Right(HttpResponse::NotFound().finish()), 114 | } 115 | } 116 | 117 | /// Application-level error object 118 | #[derive(Debug)] 119 | enum AppError { 120 | TodoStore(TodoStoreError), 121 | // In practice, we would have more error types here. 122 | } 123 | impl From for AppError { 124 | fn from(inner: TodoStoreError) -> Self { 125 | AppError::TodoStore(inner) 126 | } 127 | } 128 | 129 | impl Display for AppError { 130 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 131 | match self { 132 | AppError::TodoStore(e) => write!(f, "Todo store related error: {e}"), 133 | // In practice, we would have more error types here. 134 | } 135 | } 136 | } 137 | 138 | /// Implement a custom error response. 139 | /// 140 | /// More about error handling at https://actix.rs/docs/errors/. 141 | impl ResponseError for AppError { 142 | fn status_code(&self) -> actix_web::http::StatusCode { 143 | StatusCode::INTERNAL_SERVER_ERROR 144 | } 145 | 146 | fn error_response(&self) -> HttpResponse { 147 | HttpResponse::build(self.status_code()).json(match self { 148 | AppError::TodoStore(e) => match e { 149 | TodoStoreError::FileAccessError(_) => "Error while writing to file", 150 | TodoStoreError::SerializationError(_) => "Error during serialization", 151 | }, 152 | }) 153 | } 154 | } 155 | 156 | /// Persist the todo store to disk 157 | /// 158 | /// Note the return type here. We can return our custom error type 159 | /// AppError as it implements ResponseError. 160 | #[post("/todos/persist")] 161 | async fn persist(db: Data) -> Result<&'static str, AppError> { 162 | // Write a log message 163 | debug!("Persisting todos"); 164 | 165 | let todos = db.read().await; 166 | todos.persist().await?; 167 | Ok("") 168 | } 169 | -------------------------------------------------------------------------------- /todo-axum/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "todo-axum" 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 | axum = "0.7" 10 | tokio = { version = "1.0", features = ["full"] } 11 | tracing = "0.1" 12 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } 13 | tower = { version = "0.4", features = ["util", "timeout"] } 14 | tower-http = { version = "0.5", features = ["add-extension", "trace"] } 15 | serde_json = "1" 16 | todo-logic ={ path = "../todo-logic" } 17 | regex = { version = "1", features = ["unicode-case"] } 18 | -------------------------------------------------------------------------------- /todo-axum/src/main.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | extract::{Path, Query, State}, 3 | http::StatusCode, 4 | response::{Html, IntoResponse, Response}, 5 | routing::{delete, get, post}, 6 | Json, Router, 7 | }; 8 | use serde_json::json; 9 | use std::sync::Arc; 10 | use todo_logic::{Pagination, TodoItem, TodoStore, TodoStoreError, UpdateTodoItem}; 11 | use tokio::{net::TcpListener, sync::RwLock}; 12 | use tower_http::trace::TraceLayer; 13 | use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; 14 | 15 | /// Type for our shared state 16 | /// 17 | /// In our sample application, we store the todo list in memory. As the state is shared 18 | /// between concurrently running web requests, we need to make it thread-safe. 19 | type Db = Arc>; 20 | 21 | #[tokio::main] 22 | async fn main() { 23 | // Enable tracing using Tokio's https://tokio.rs/#tk-lib-tracing 24 | tracing_subscriber::registry() 25 | .with( 26 | tracing_subscriber::EnvFilter::try_from_default_env() 27 | .unwrap_or_else(|_| "todo_axum=debug,tower_http=debug".into()), 28 | ) 29 | .with(tracing_subscriber::fmt::layer()) 30 | .init(); 31 | 32 | // Create shared data store 33 | let db = Db::default(); 34 | 35 | // We register our shared state so that handlers can get it using the State extractor. 36 | // Note that this will change in Axum 0.6. See more at 37 | // https://docs.rs/axum/0.6.0-rc.4/axum/index.html#sharing-state-with-handlers 38 | let app = Router::new() 39 | // Here we setup the routes. Note: No macros 40 | .route("/", get(say_hello)) 41 | .route("/todos", get(get_todos).post(add_todo)) 42 | .route("/todos/:id", delete(delete_todo).patch(update_todo).get(get_todo)) 43 | .route("/todos/persist", post(persist)) 44 | .with_state(db) 45 | // Using tower to add tracing layer 46 | .layer(TraceLayer::new_for_http()); 47 | 48 | // In practice: Use graceful shutdown. 49 | // Note that Axum has great examples for a log of practical scenarios, 50 | // including graceful shutdown (https://github.com/tokio-rs/axum/tree/main/examples) 51 | let listener = TcpListener::bind("0.0.0.0:3000").await.unwrap(); 52 | tracing::debug!("listening on {}", listener.local_addr().unwrap()); 53 | axum::serve(listener, app).await.unwrap(); 54 | } 55 | 56 | /// Say hello 57 | async fn say_hello() -> Html<&'static str> { 58 | Html("

Hello, World!

") 59 | } 60 | 61 | /// Get list of todo items 62 | /// 63 | /// Note how the Query extractor is used to get query parameters. Note how the State 64 | /// extractor is used to get the database (changes in Axum 0.6 RC). 65 | /// Extractors are technically types that implement FromRequest. You can create 66 | /// your own extractors or use the ones provided by Axum. 67 | async fn get_todos(pagination: Option>, State(db): State) -> impl IntoResponse { 68 | let todos = db.read().await; 69 | let Query(pagination) = pagination.unwrap_or_default(); 70 | // Json is an extractor and a response. 71 | Json(todos.get_todos(pagination)) 72 | } 73 | 74 | /// Get a single todo item 75 | /// 76 | /// Note how the Path extractor is used to get query parameters. 77 | async fn get_todo(Path(id): Path, State(db): State) -> impl IntoResponse { 78 | let todos = db.read().await; 79 | if let Some(item) = todos.get_todo(id) { 80 | // Note how to return Json 81 | Json(item).into_response() 82 | } else { 83 | // Note how a tuple can be turned into a response 84 | (StatusCode::NOT_FOUND, "Not found").into_response() 85 | } 86 | } 87 | 88 | /// Add a new todo item 89 | /// 90 | /// Note that this time, Json is used as an extractor. This means that the request body 91 | /// will be deserialized into a TodoItem. 92 | async fn add_todo(State(db): State, Json(todo): Json) -> impl IntoResponse { 93 | let mut todos = db.write().await; 94 | let todo = todos.add_todo(todo); 95 | (StatusCode::CREATED, Json(todo)) 96 | } 97 | 98 | /// Delete a todo item 99 | async fn delete_todo(Path(id): Path, State(db): State) -> impl IntoResponse { 100 | if db.write().await.remove_todo(id).is_some() { 101 | StatusCode::NO_CONTENT 102 | } else { 103 | StatusCode::NOT_FOUND 104 | } 105 | } 106 | 107 | /// Update a todo item 108 | async fn update_todo( 109 | Path(id): Path, 110 | State(db): State, 111 | Json(input): Json, 112 | ) -> Result { 113 | let mut todos = db.write().await; 114 | let res = todos.update_todo(&id, input); 115 | match res { 116 | Some(todo) => Ok(Json(todo.clone())), 117 | None => Err(StatusCode::NOT_FOUND), 118 | } 119 | } 120 | 121 | /// Application-level error object 122 | enum AppError { 123 | UserRepo(TodoStoreError), 124 | } 125 | impl From for AppError { 126 | fn from(inner: TodoStoreError) -> Self { 127 | AppError::UserRepo(inner) 128 | } 129 | } 130 | 131 | /// Logic for turning an error into a response. 132 | /// 133 | /// By providing this trait, handlers can return AppError and Axum will automatically 134 | /// convert it into a response. 135 | impl IntoResponse for AppError { 136 | fn into_response(self) -> Response { 137 | let (status, error_message) = match self { 138 | AppError::UserRepo(TodoStoreError::FileAccessError(_)) => { 139 | (StatusCode::INTERNAL_SERVER_ERROR, "Error while writing to file") 140 | }, 141 | AppError::UserRepo(TodoStoreError::SerializationError(_)) => { 142 | (StatusCode::INTERNAL_SERVER_ERROR, "Error during serialization") 143 | }, 144 | }; 145 | 146 | let body = Json(json!({ 147 | "error": error_message, 148 | })); 149 | 150 | (status, body).into_response() 151 | } 152 | } 153 | 154 | /// Persist the todo store to disk 155 | async fn persist(State(db): State) -> Result<(), AppError> { 156 | tracing::debug!("Persisting todos"); 157 | let todos = db.read().await; 158 | todos.persist().await?; 159 | Ok(()) 160 | } 161 | -------------------------------------------------------------------------------- /todo-logic/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "todo-logic" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | serde = { version = "1", features = ["derive"] } 8 | serde_json = "1" 9 | tokio = { version= "1", features = ["fs"], optional = true } 10 | thiserror = "1" 11 | 12 | [features] 13 | default = ["persist"] 14 | persist = ["dep:tokio"] 15 | -------------------------------------------------------------------------------- /todo-logic/src/lib.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use std::{ 3 | collections::HashMap, 4 | sync::atomic::{AtomicUsize, Ordering}, 5 | }; 6 | 7 | #[cfg(feature = "persist")] 8 | use tokio::fs; 9 | 10 | /// Represents a single todo item 11 | #[derive(Serialize, Deserialize, Debug, Clone)] 12 | pub struct TodoItem { 13 | pub title: String, 14 | pub notes: String, 15 | pub assigned_to: String, 16 | pub completed: bool, 17 | } 18 | 19 | /// DTO for patching a todo item 20 | #[derive(Serialize, Deserialize, Debug, Clone)] 21 | pub struct UpdateTodoItem { 22 | pub title: Option, 23 | pub notes: Option, 24 | pub assigned_to: Option, 25 | pub completed: Option, 26 | } 27 | 28 | /// Represents a todo item with an id 29 | #[derive(Serialize, Deserialize, Debug, Clone)] 30 | pub struct IdentifyableTodoItem { 31 | pub id: usize, 32 | 33 | #[serde(flatten)] 34 | pub item: TodoItem, 35 | } 36 | 37 | impl IdentifyableTodoItem { 38 | pub fn new(id: usize, item: TodoItem) -> IdentifyableTodoItem { 39 | IdentifyableTodoItem { id, item } 40 | } 41 | } 42 | 43 | /// Parameters for pagination 44 | /// 45 | /// Used to demonstrate handling of query parameters. 46 | #[derive(Serialize, Deserialize, Debug, Clone, Default)] 47 | pub struct Pagination { 48 | pub offset: Option, 49 | pub limit: Option, 50 | } 51 | impl Pagination { 52 | pub fn new(offset: Option, limit: Option) -> Pagination { 53 | Pagination { offset, limit } 54 | } 55 | } 56 | 57 | /// Error type for the todo items store 58 | #[derive(thiserror::Error, Debug)] 59 | pub enum TodoStoreError { 60 | #[error("persistent data store error")] 61 | FileAccessError(#[from] std::io::Error), 62 | #[error("serialization error")] 63 | SerializationError(#[from] serde_json::error::Error), 64 | } 65 | 66 | /// Todo items store 67 | #[derive(Default)] 68 | pub struct TodoStore { 69 | store: HashMap, 70 | id_generator: AtomicUsize, 71 | } 72 | impl TodoStore { 73 | pub fn from_hashmap(store: HashMap) -> Self { 74 | let id_generator = AtomicUsize::new( 75 | store 76 | .keys() 77 | .max() 78 | .map(|v| v + 1) 79 | .unwrap_or(0), 80 | ); 81 | TodoStore { 82 | store, 83 | id_generator, 84 | } 85 | } 86 | 87 | /// Get list of todo items 88 | /// 89 | /// Supports pagination. 90 | pub fn get_todos(&self, pagination: Pagination) -> Vec { 91 | self.store 92 | .values() 93 | .skip(pagination.offset.unwrap_or(0)) 94 | .take(pagination.limit.unwrap_or(usize::MAX)) 95 | .cloned() 96 | .collect::>() 97 | } 98 | 99 | /// Get a single todo item by id 100 | pub fn get_todo(&self, id: usize) -> Option<&IdentifyableTodoItem> { 101 | self.store.get(&id) 102 | } 103 | 104 | /// Create a new todo item 105 | pub fn add_todo(&mut self, todo: TodoItem) -> IdentifyableTodoItem { 106 | let id = self.id_generator.fetch_add(1, Ordering::Relaxed); 107 | let new_item = IdentifyableTodoItem::new(id, todo); 108 | self.store.insert(id, new_item.clone()); 109 | new_item 110 | } 111 | 112 | /// Remove a todo item by id 113 | pub fn remove_todo(&mut self, id: usize) -> Option { 114 | self.store.remove(&id) 115 | } 116 | 117 | /// Patch a todo item by id 118 | pub fn update_todo(&mut self, id: &usize, todo: UpdateTodoItem) -> Option<&IdentifyableTodoItem> { 119 | if let Some(item) = self.store.get_mut(id) { 120 | if let Some(title) = todo.title { 121 | item.item.title = title; 122 | } 123 | if let Some(notes) = todo.notes { 124 | item.item.notes = notes; 125 | } 126 | if let Some(assigned_to) = todo.assigned_to { 127 | item.item.assigned_to = assigned_to; 128 | } 129 | if let Some(completed) = todo.completed { 130 | item.item.completed = completed; 131 | } 132 | 133 | Some(item) 134 | } else { 135 | None 136 | } 137 | } 138 | 139 | /// Store todo items to disk 140 | /// 141 | /// Used to demonstrate error handling. 142 | #[cfg(feature = "persist")] 143 | pub async fn persist(&self) -> Result<(), TodoStoreError> { 144 | const FILENAME: &str = "todo_store.json"; 145 | 146 | let json = serde_json::to_string_pretty(&self.store.values().collect::>()) 147 | .map_err(TodoStoreError::SerializationError)?; 148 | fs::write(FILENAME, json.as_bytes()) 149 | .await 150 | .map_err(TodoStoreError::FileAccessError)?; 151 | Ok(()) 152 | } 153 | } 154 | 155 | impl From for HashMap { 156 | fn from(value: TodoStore) -> Self { 157 | value.store 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /todo-rocket/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "todo-rocket" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | rocket = { version = "0.5.0-rc.2", features = [ "json" ] } 8 | todo-logic ={ path = "../todo-logic" } 9 | log = "0.4" 10 | simplelog= "0" 11 | -------------------------------------------------------------------------------- /todo-rocket/src/main.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate rocket; 3 | 4 | use log::{debug, LevelFilter}; 5 | use rocket::http::Status; 6 | use rocket::response::status::Created; 7 | use rocket::serde::json::Json; 8 | use rocket::tokio::sync::RwLock; 9 | use rocket::{uri, State}; 10 | use simplelog::{Config, SimpleLogger}; 11 | use std::sync::Arc; 12 | use todo_logic::{IdentifyableTodoItem, Pagination, TodoItem, TodoStore, TodoStoreError, UpdateTodoItem}; 13 | 14 | /// Type for our shared state 15 | /// 16 | /// In our sample application, we store the todo list in memory. As the state is shared 17 | /// between concurrently running web requests, we need to make it thread-safe. 18 | type Db = Arc>; 19 | 20 | /// Rocket relies heavily on macros. The launch macro will generate a 21 | /// tokio main function for us. 22 | #[launch] 23 | fn rocket() -> _ { 24 | // Initialize logging. 25 | // Rocket uses the log crate (https://crates.io/crates/log) to log requests. You can use any 26 | // compatible logger, but for this example we'll use simplelog. Enhancements in terms 27 | // of more flexible logging are planned for future releases 28 | // (https://github.com/SergioBenitez/Rocket/issues/21). 29 | SimpleLogger::init(LevelFilter::Debug, Config::default()).unwrap(); 30 | 31 | // Create shared data store 32 | let db = Db::default(); 33 | 34 | rocket::build() 35 | // Here we mount our routes. More details about route mounting 36 | // at https://rocket.rs/v0.5-rc/guide/overview/#mounting. 37 | .mount( 38 | "/", 39 | routes![get_todos, get_todo, add_todo, update_todo, delete_todo, persist], 40 | ) 41 | // Register our shared state. 42 | // More about using shared state at https://rocket.rs/v0.5-rc/guide/state/. 43 | .manage(db) 44 | } 45 | 46 | /// Get list of todo items 47 | /// 48 | /// Rocket implements the FromParam trait for typically used data types so they 49 | /// can be used to extract data from the query string. Of course you can implement 50 | /// FromParam for custom data types, too. 51 | /// More about it at https://rocket.rs/v0.5-rc/guide/requests/#query-strings. 52 | /// 53 | /// Also note the Responder trait (https://rocket.rs/v0.5-rc/guide/responses/#custom-responders). 54 | /// Rocket comes with a lot of built-in responders, but you can also 55 | /// implement the trait for your own custom types. 56 | #[get("/todos?&")] 57 | async fn get_todos(offset: Option, limit: Option, db: &State) -> Json> { 58 | let todos = db.read().await; 59 | let pagination = Pagination::new(offset, limit); 60 | Json(todos.get_todos(pagination)) 61 | } 62 | 63 | /// Get a single todo item 64 | /// 65 | /// Note that Option implements the Responder trait, too. This makes it really 66 | /// simple to return a 404 if the requested item does not exist. 67 | #[get("/todos/")] 68 | async fn get_todo(id: usize, db: &State) -> Option> { 69 | let todos = db.read().await; 70 | todos.get_todo(id).map(|item| Json(item.clone())) 71 | } 72 | 73 | /// Add a new todo item 74 | /// 75 | /// Note the use of a "Request Guard" (FromRequest trait) here. Here it is used 76 | /// to extract the JSON body of the request. You can implement your own guards, too 77 | /// (https://rocket.rs/v0.5-rc/guide/requests/#custom-guards). Many things that you 78 | /// would do with middlewares in other frameworks are done with request guards in Rocket. 79 | #[post("/todos", format = "json", data = "")] 80 | async fn add_todo(todo: Json, db: &State) -> Created> { 81 | let mut todos = db.write().await; 82 | let todo = todos.add_todo(todo.0); 83 | 84 | // Nice detail here: The uri macro helps you to generate URIs for your routes. 85 | // Very useful for building the location header. 86 | let location = uri!("/", get_todo(todo.id)); 87 | Created::new(location.to_string()).body(Json(todo)) 88 | } 89 | 90 | /// Delete a todo item 91 | /// 92 | /// Note the extraction of the id from the path. 93 | #[delete("/todos/")] 94 | async fn delete_todo(id: usize, db: &State) -> Status { 95 | match db.write().await.remove_todo(id) { 96 | // Note that Status represents the HTTP status code 97 | Some(_) => Status::NoContent, 98 | None => Status::NotFound, 99 | } 100 | } 101 | 102 | /// Update a todo item 103 | #[patch("/todos/", format = "json", data = "")] 104 | async fn update_todo(id: usize, input: Json, db: &State) -> Option> { 105 | let mut todos = db.write().await; 106 | let res = todos.update_todo(&id, input.0); 107 | res.map(|todo| Json(todo.clone())) 108 | } 109 | 110 | /// Application-level error object 111 | /// 112 | /// Note how easy it is to implement Rocket's Responder trait with 113 | /// the macros that Rocket provides. 114 | #[derive(Responder)] 115 | enum AppError { 116 | #[response(status = 500)] 117 | InternalError(String), 118 | } 119 | impl From for AppError { 120 | fn from(inner: TodoStoreError) -> Self { 121 | AppError::InternalError(Json(inner).to_string()) 122 | } 123 | } 124 | 125 | /// Persist the todo store to disk 126 | #[post("/todos/persist")] 127 | async fn persist(db: &State) -> Result<(), AppError> { 128 | debug!("Persisting todos"); 129 | let todos = db.read().await; 130 | todos.persist().await?; 131 | Ok(()) 132 | } 133 | -------------------------------------------------------------------------------- /todo-spin/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "todo-spin" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [lib] 7 | crate-type = [ "cdylib" ] 8 | 9 | [dependencies] 10 | # Useful crate to handle errors. 11 | anyhow = "1" 12 | # The Spin SDK. 13 | spin-sdk = { git = "https://github.com/fermyon/spin", tag = "v1.5.1" } 14 | # General-purpose crate with common HTTP types. 15 | http = "0.2" 16 | # Crate to simplify working with bytes. 17 | bytes = "1" 18 | serde = "1" 19 | serde_json = "1" 20 | base64 = "0.21" 21 | regex = "1" 22 | todo-logic ={ path = "../todo-logic", default-features = false } 23 | -------------------------------------------------------------------------------- /todo-spin/src/extractors.rs: -------------------------------------------------------------------------------- 1 | use base64::{Engine, engine::general_purpose}; 2 | use regex::Regex; 3 | use spin_sdk::{ 4 | http::Request, 5 | }; 6 | use todo_logic::{Pagination, TodoItem, TodoStore}; 7 | 8 | // Rather naive, manual extractors. Anybody wants to write a framework for that? 😉 9 | 10 | pub fn extract_db(req: &Request) -> TodoStore { 11 | if let Some(db) = req 12 | .headers() 13 | .get_all("cookie") 14 | .into_iter() 15 | .find(|c| c.to_str().unwrap().starts_with("db=")) 16 | { 17 | let db = db.to_str().unwrap(); 18 | let re = Regex::new(r"db=([a-zA-Z0-9]+)").unwrap(); 19 | let mut cap = re.captures_iter(db); 20 | if let Some(re) = cap.next() { 21 | let re = &re[1]; 22 | let db = general_purpose::STANDARD_NO_PAD.decode(re).unwrap(); 23 | return TodoStore::from_hashmap(serde_json::from_str(std::str::from_utf8(&db).unwrap()).unwrap()); 24 | } 25 | } 26 | 27 | TodoStore::default() 28 | } 29 | 30 | pub fn extract_pagination(req: &Request) -> Pagination { 31 | let query = req.uri().query().unwrap_or(""); 32 | let mut pagination = Pagination::default(); 33 | 34 | for pair in query.split('&').filter(|s| !s.is_empty()) { 35 | let mut parts = pair.split('='); 36 | let key = parts.next().unwrap(); 37 | let value = parts.next().unwrap(); 38 | 39 | match key { 40 | "offset" => pagination.offset = value.parse().map(Some).unwrap_or(None), 41 | "limit" => pagination.limit = value.parse().map(Some).unwrap_or(None), 42 | _ => {}, 43 | } 44 | } 45 | 46 | pagination 47 | } 48 | 49 | pub fn extract_todo_item(req: &Request) -> TodoItem { 50 | let body = req.body().as_ref().unwrap(); 51 | serde_json::from_str(std::str::from_utf8(body.as_ref()).unwrap()).unwrap() 52 | } 53 | 54 | pub fn extract_id(req: &Request) -> usize { 55 | let path = req.uri().path().to_string(); 56 | let re = Regex::new(r"/todos/([0-9]+)").unwrap(); 57 | let mut cap = re.captures_iter(&path); 58 | let re = cap.next().unwrap(); 59 | let re = &re[1]; 60 | re.parse().unwrap() 61 | } 62 | -------------------------------------------------------------------------------- /todo-spin/src/lib.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use http::{Method, StatusCode}; 3 | use spin_sdk::{ 4 | http::{Request, Response}, 5 | http_component, 6 | }; 7 | use todo_logic::{IdentifyableTodoItem, Pagination, TodoItem, TodoStore}; 8 | 9 | mod extractors; 10 | mod responders; 11 | use crate::{extractors::{extract_db, extract_pagination, extract_todo_item, extract_id}, responders::to_response}; 12 | 13 | #[http_component] 14 | fn todo_manager(req: Request) -> Result { 15 | let path = req.uri().path().to_string(); 16 | 17 | // In Spin, we cannot store data in memory. We have to persist or round-trip it anywhere. 18 | // In this simple example, we use cookies to store the todos. We use a hand-written 19 | // "extractor" to get the store from Spin's session cookie. 20 | let mut db = extract_db(&req); 21 | 22 | // In Spin, we don't have a fancy router yet. We have to manually match the path. 23 | if path.ends_with("/todos") || path.ends_with("/todos/") { 24 | match *req.method() { 25 | Method::GET => { 26 | // In Spin, there are no "extractors" yet. We have to manually get the 27 | // pagination data out of the query string. 28 | let pagination = extract_pagination(&req); 29 | let result = get_todos(pagination, &db); 30 | 31 | // In Spin, there are no "responders" yet. We have to manually turn 32 | // our result into a HTTP response. 33 | to_response(StatusCode::OK, Some(result), None) 34 | }, 35 | Method::POST => { 36 | let todo = extract_todo_item(&req); 37 | let result = add_todo(todo, &mut db); 38 | to_response(StatusCode::OK, Some(result), Some(db)) 39 | }, 40 | _ => to_response(StatusCode::METHOD_NOT_ALLOWED, None::, None), 41 | } 42 | } else if path.starts_with("/todos/") { 43 | let id = extract_id(&req); 44 | match *req.method() { 45 | Method::GET => { 46 | let result = get_todo(id, &db); 47 | to_response(match result { 48 | Some(_) => StatusCode::OK, 49 | None => StatusCode::NOT_FOUND, 50 | }, result, None) 51 | }, 52 | Method::DELETE => { 53 | let res = delete_todo(id, &mut db); 54 | to_response( 55 | match res { 56 | Some(_) => StatusCode::NO_CONTENT, 57 | None => StatusCode::NOT_FOUND, 58 | }, 59 | None::, 60 | Some(db), 61 | ) 62 | }, 63 | _ => to_response(StatusCode::METHOD_NOT_ALLOWED, None::, None), 64 | } 65 | } else { 66 | to_response(StatusCode::NOT_FOUND, None::, None) 67 | } 68 | } 69 | 70 | fn get_todos(pagination: Pagination, todos: &TodoStore) -> Vec { 71 | todos.get_todos(pagination) 72 | } 73 | 74 | fn add_todo(todo: TodoItem, todos: &mut TodoStore) -> IdentifyableTodoItem { 75 | todos.add_todo(todo) 76 | } 77 | 78 | fn delete_todo(id: usize, todos: &mut TodoStore) -> Option { 79 | todos.remove_todo(id) 80 | } 81 | 82 | fn get_todo(id: usize, todos: &TodoStore) -> Option<&IdentifyableTodoItem> { 83 | todos.get_todo(id) 84 | } 85 | -------------------------------------------------------------------------------- /todo-spin/src/responders.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use anyhow::Result; 4 | use base64::{engine::general_purpose, Engine}; 5 | use http::StatusCode; 6 | use serde::Serialize; 7 | use spin_sdk::http::Response; 8 | use todo_logic::{IdentifyableTodoItem, TodoStore}; 9 | 10 | // Rather naive, manual responders. Anybody wants to write a framework for that? 😉 11 | 12 | pub fn to_response(status: StatusCode, result: Option, todos: Option) -> Result 13 | where 14 | T: Serialize, 15 | { 16 | let mut builder = http::Response::builder(); 17 | let mut body = None; 18 | 19 | if let Some(result) = result { 20 | let response = serde_json::to_string_pretty(&result)?.as_bytes().to_vec(); 21 | builder = builder.header("Content-Type", "application/json"); 22 | body = Some(response); 23 | } 24 | 25 | if let Some(todos) = todos { 26 | let db = serde_json::to_string(&Into::>::into(todos))?; 27 | let db = format!("db={}", general_purpose::STANDARD_NO_PAD.encode(db)); 28 | builder = builder.header("Set-Cookie", format!("{}; SameSite=Strict; Path=/", db)); 29 | } 30 | 31 | Ok(builder.status(status).body(body.map(|body| body.into()))?) 32 | } 33 | -------------------------------------------------------------------------------- /todo-warp/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "todo-warp" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | warp = "0.3" 8 | tokio = { version = "1", features = ["full"] } 9 | todo-logic ={ path = "../todo-logic" } 10 | simplelog= "0" 11 | log = "0.4" 12 | -------------------------------------------------------------------------------- /todo-warp/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::{convert::Infallible, sync::Arc}; 2 | 3 | use log::{debug, LevelFilter}; 4 | use simplelog::{Config, SimpleLogger}; 5 | use todo_logic::{Pagination, TodoItem, TodoStore, TodoStoreError, UpdateTodoItem}; 6 | use tokio::sync::RwLock; 7 | use warp::http::StatusCode; 8 | use warp::{reject, reply}; 9 | use warp::{Filter, Rejection, Reply}; 10 | 11 | /// Type for our shared state 12 | type Db = Arc>; 13 | 14 | #[tokio::main] 15 | async fn main() { 16 | // Initialize logging. 17 | // Warp uses the log crate (https://crates.io/crates/log) to log requests. You can use any 18 | // compatible logger, but for this example we'll use simplelog. 19 | SimpleLogger::init(LevelFilter::Debug, Config::default()).unwrap(); 20 | 21 | // Create shared data store 22 | let db = Db::default(); 23 | 24 | // Note that you would probably create dedicated functions for each filter. 25 | // However, to make Warp's approach more obvious, we'll inline the filters. 26 | // Note that Warp makes less use of macros than e.g. Rocket. Only the route 27 | // is defined using a kind of DSL. Everything else is specified using filters. 28 | // The filters also ensure that the handler function has the correct signature. 29 | let get_db = db.clone(); 30 | let get = warp::path!("todos") 31 | .and(warp::get()) 32 | // The query filter is used to extract the query parameters. 33 | .and(warp::query::()) 34 | // Here we inject our shared state into the handler function. 35 | .and(warp::any().map(move || get_db.clone())) 36 | // ...and finally we connect the handler. 37 | .and_then(get_todos); 38 | 39 | let add_db = db.clone(); 40 | let add = warp::path!("todos") 41 | .and(warp::post()) 42 | // The body filter is used to extract the request body (JSON). 43 | .and(warp::body::json()) 44 | .and(warp::any().map(move || add_db.clone())) 45 | .and_then(add_todo); 46 | 47 | let get_single_db = db.clone(); 48 | let get_single = warp::path!("todos" / usize) 49 | .and(warp::get()) 50 | .and(warp::any().map(move || get_single_db.clone())) 51 | .and_then(get_todo); 52 | 53 | let delete_db = db.clone(); 54 | let delete = warp::path!("todos" / usize) 55 | .and(warp::delete()) 56 | .and(warp::any().map(move || delete_db.clone())) 57 | .and_then(delete_todo); 58 | 59 | let update_db = db.clone(); 60 | let update = warp::path!("todos" / usize) 61 | .and(warp::patch()) 62 | .and(warp::body::json()) 63 | .and(warp::any().map(move || update_db.clone())) 64 | .and_then(update_todo); 65 | 66 | let persist_db = db.clone(); 67 | let persist = warp::path!("todos" / "persist") 68 | .and(warp::post()) 69 | .and(warp::any().map(move || persist_db.clone())) 70 | .and_then(persist) 71 | // The persist can handler can return a Rejection in case of an error. 72 | // Rejections are handled by the `recover` filter. It turns the error 73 | // object into a response. 74 | .recover(handle_rejection); 75 | 76 | // The final API consists of all the filters we defined above 77 | // connected with the `or` combinator. 78 | let api = get.or(add).or(get_single).or(delete).or(update).or(persist); 79 | 80 | // For logging, we wrap the API with a wrapping filter (similar to a middleware 81 | // in other frameworks). 82 | let routes = api.with(warp::log("todo_warp")); 83 | warp::serve(routes).run(([0, 0, 0, 0], 3000)).await; 84 | } 85 | 86 | /// Get list of todo items 87 | /// 88 | /// Note that we do not need any special handling of the parameters. 89 | /// The previously defined filters already extracted query parameters, 90 | /// body, path parameters, etc. 91 | async fn get_todos(pagination: Pagination, db: Db) -> Result { 92 | let todos = db.read().await; 93 | Ok(reply::json(&todos.get_todos(pagination))) 94 | } 95 | 96 | /// Get a single todo item 97 | /// 98 | /// Note that this method returns different return types. 99 | /// into_response converts the result into a reply. 100 | async fn get_todo(id: usize, db: Db) -> Result { 101 | let todos = db.read().await; 102 | if let Some(item) = todos.get_todo(id) { 103 | Ok(reply::json(item).into_response()) 104 | } else { 105 | Ok(reply::with_status("Not found", StatusCode::NOT_FOUND).into_response()) 106 | } 107 | } 108 | 109 | /// Add a new todo item 110 | async fn add_todo(todo: TodoItem, db: Db) -> Result { 111 | let mut todos = db.write().await; 112 | let todo = todos.add_todo(todo.clone()); 113 | Ok(reply::json(&todo)) 114 | } 115 | 116 | /// Delete a todo item 117 | async fn delete_todo(id: usize, db: Db) -> Result { 118 | if db.write().await.remove_todo(id).is_some() { 119 | Ok(reply::with_status("", StatusCode::NO_CONTENT)) 120 | } else { 121 | Ok(reply::with_status("", StatusCode::NOT_FOUND)) 122 | } 123 | } 124 | 125 | /// Update a todo item 126 | async fn update_todo(id: usize, input: UpdateTodoItem, db: Db) -> Result { 127 | let mut todos = db.write().await; 128 | let res = todos.update_todo(&id, input); 129 | match res { 130 | Some(todo) => Ok(reply::json(todo).into_response()), 131 | None => Ok(reply::with_status("", StatusCode::NOT_FOUND).into_response()), 132 | } 133 | } 134 | 135 | /// Application-level error object 136 | #[derive(Debug)] 137 | enum AppError { 138 | UserRepo(TodoStoreError), 139 | } 140 | impl From for AppError { 141 | fn from(inner: TodoStoreError) -> Self { 142 | AppError::UserRepo(inner) 143 | } 144 | } 145 | 146 | /// Add marker trait to AppError for custom rejections 147 | impl reject::Reject for AppError {} 148 | 149 | async fn persist(db: Db) -> Result { 150 | // Write a log message 151 | debug!("Persisting todos"); 152 | 153 | let todos = db.read().await; 154 | todos 155 | .persist() 156 | .await 157 | // In case of an error, we return a custom rejection. It will be handled 158 | // by teh `recover` filter. 159 | .map_err(|e| warp::reject::custom::(e.into()))?; 160 | Ok::<_, Rejection>(reply::with_status("", StatusCode::OK).into_response()) 161 | } 162 | 163 | /// Handles custom rejection and turns it into a response. 164 | async fn handle_rejection(err: Rejection) -> Result { 165 | if let Some(e) = err.find::() { 166 | return match e { 167 | AppError::UserRepo(e) => Ok(reply::with_status( 168 | match e { 169 | TodoStoreError::FileAccessError(_) => "Error while writing to file", 170 | TodoStoreError::SerializationError(_) => "Error during serialization", 171 | }, 172 | StatusCode::INTERNAL_SERVER_ERROR, 173 | )), 174 | }; 175 | } 176 | Ok(reply::with_status( 177 | "INTERNAL_SERVER_ERROR", 178 | StatusCode::INTERNAL_SERVER_ERROR, 179 | )) 180 | } 181 | --------------------------------------------------------------------------------