diff --git a/.gitignore b/.gitignore index 465919c..0592392 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ /target .DS_Store -.DS_Store diff --git a/Cargo.lock b/Cargo.lock index 6ad12f9..83825ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,7 +37,7 @@ checksum = "7ff69b9dd49fd426c69a0db9fc04dd934cdb6645ff000864d98f7e2af8830eaa" [[package]] name = "cafetera" -version = "0.2.5" +version = "0.3.0" dependencies = [ "hteapot", "serde", @@ -154,9 +154,9 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hteapot" -version = "0.2.5" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31bd443606244879d3c447196f540fbeef0d505274ac36bd015b07b627b82c68" +checksum = "ef36b290b07caeb44d832a9c0869a493f11c79b9d2621f235c12db08eefe2688" [[package]] name = "iana-time-zone" diff --git a/Cargo.toml b/Cargo.toml index a2d3bb4..199084e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cafetera" -version = "0.2.5" +version = "0.3.0" edition = "2021" description = "simple HTTP mock server" license = "MIT" @@ -12,4 +12,5 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.115" serde_with = "3.7.0" toml = "0.8.14" -hteapot = "0.2.5" \ No newline at end of file +hteapot = "0.4.2" +#hteapot = { path = "../hteapot" } diff --git a/README.md b/README.md index d4ea42f..952a820 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Run the server with the following command, replacing with your desired po ```shell cargo run ``` -OR +OR ```shell CAFETERA @@ -86,11 +86,86 @@ body = ''' "email": "" } ''' + +[[db]] +path = "/db/users" +data = ''' +{ + "users": [ + { + "name": "Jhon", + "surname": "Smith", + "age": 35 + } + ], + "last_id": "3bed620f-6020-444b-b825-d06240bfa632" +} +''' ``` ## Usage After starting the server, it will listen for HTTP requests on the specified port. The server matches incoming requests against the paths defined in the configuration file and responds with the corresponding status code and body. +### DB mode +The database consists of simple JSON structures that can be accessed and modified using GET, POST, PATCH, and DELETE requests + +#### Read Data +```HTTP +GET /db/users/users/0 HTTP/1.1 +``` +This request retrieves the JSON object at the specified path +```JSON +{ + "age": 35, + "name": "Jhon", + "surname": "Smith" +} +``` +You can also filter results using query parameters. For example: +for example +```HTTP +GET /db/users/users?name="Jhon" HTTP/1.1 +``` +This request returns all users matching the specified criteria. + + +#### Create + +```HTTP +PUSH /db/users/users HTTP/1.1 + +{ + "age": 19, + "name": "Sarah", + "surname": "Brown" +} +``` +This request adds a new entry to the users array. + +Additionally, you can add new attributes to existing objects dynamically. + +#### UPDATE + +```HTTP +PATCH /db/users/users/1 HTTP/1.1 + +{"name":"Sara"} +``` +This request updates the user at index 1, changing the name from "Sarah" to "Sara". + +Using PATCH, only the specified attributes will be modified, while the rest of the object remains unchanged. If the provided attribute does not exist, it will not be added. + +#### DELETE + +```HTTP +DELETE /db/users/users/1 HTTP/1.1 +``` + +This request removes the user at index 1 from the database. + + + + Available wildcard variables: - [x] {{path}}: The path of the request - [ ] {{query}}: The query string of the request diff --git a/demo_config.toml b/demo_config.toml index 942874a..41f4268 100644 --- a/demo_config.toml +++ b/demo_config.toml @@ -48,4 +48,69 @@ body = ''' "name": "Jane Doe", "email": "" } -''' \ No newline at end of file +''' + + +[[db]] +path = "/db/cafetera" +data = ''' +{ + "db_name": "messages", + "owner": "Albruiz", + "users": [ + { + "id": 0, + "name": "Alberto", + "surname": "Ruiz", + "age": 25, + "admin": true + }, + { + "id": 1, + "name": "Eithne", + "surname": "Flor", + "age": 21, + "admin": false + }, + { + "id": 2, + "name": "Juan", + "surname": "Perez", + "age": 52, + "admin": false + } + ], + "messages": [ + { + "id": 1, + "from": 0, + "to": 1, + "content": "Hello, how are you?" + }, + { + "id": 2, + "from": 1, + "to": 0, + "content": "I'm good, thanks! How about you?" + }, + { + "id": 3, + "from": 2, + "to": 0, + "content": "Hey, what's up?" + }, + { + "id": 4, + "from": 0, + "to": 2, + "content": "Not much, just working on a project." + }, + { + "id": 5, + "from": 1, + "to": 2, + "content": "Are you free to chat later?" + } + ] +} +''' diff --git a/src/config_parser.rs b/src/config_parser.rs index b93a71c..8e7be5c 100644 --- a/src/config_parser.rs +++ b/src/config_parser.rs @@ -1,50 +1,50 @@ - -use std::{collections::HashMap, fs}; use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, fs}; -use toml; use crate::utils::compare_path; +use toml; -#[derive(Serialize, Deserialize,Clone, Debug)] +#[derive(Serialize, Deserialize, Clone, Debug)] pub struct Endpoint { - pub path: String, - pub status: u16, - pub body: String, + pub path: String, + pub status: u16, + pub body: String, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct DB { + pub path: String, + pub data: String, } pub trait EndpointSearch { - fn get_iter(&self, key: &str) -> Option; + fn get_iter(&self, key: &str) -> Option; } impl EndpointSearch for Vec { - fn get_iter(&self, key: &str) -> Option { - for endpoint in self { - if compare_path(endpoint.path.to_string(), key.to_string()) { - return Some(endpoint.clone()); - } else { - continue; - } + fn get_iter(&self, key: &str) -> Option { + for endpoint in self { + if compare_path(endpoint.path.to_string(), key.to_string()) { + return Some(endpoint.clone()); + } else { + continue; + } + } + return None; } - return None; - } } - - #[derive(Serialize, Debug, Deserialize)] pub struct Config { - pub endpoints: HashMap> + pub endpoints: HashMap>, + pub db: Vec, } - impl Config { - - pub fn import(path: &str) -> Self { - let config_toml = fs::read_to_string(path).unwrap(); - // Parsear el TOML - let config: Config = toml::from_str(&config_toml).unwrap(); - return config; - - } - -} \ No newline at end of file + pub fn import(path: &str) -> Self { + let config_toml = fs::read_to_string(path).unwrap(); + // Parsear el TOML + let config: Config = toml::from_str(&config_toml).unwrap(); + return config; + } +} diff --git a/src/db_handle.rs b/src/db_handle.rs new file mode 100644 index 0000000..650b483 --- /dev/null +++ b/src/db_handle.rs @@ -0,0 +1,360 @@ +use hteapot::HttpStatus; +use serde_json::Value; +use std::collections::HashMap; +// DB Module to manage quick mock of dbs +// this allow basic CRUD whit a mock DB in config +// EXAMPLE JSON DB +// [ +// { +// name: "Alberto", +// surname: "Ruiz", +// age: 25, +// admin: True, +// +// }, +// { +// name: "Eithne", +// surname: "Flor", +// age: 21, +// admin: False +// }, +// { +// name: "Alberto", +// surname: "Ruiz", +// age: 25, +// admin: True +// }, +// ] + +pub struct DbHandle { + pub root_path: String, + db_data: Value, +} + +pub struct HttpErr { + pub status: HttpStatus, + pub text: &'static str, +} + +impl DbHandle { + pub fn new(root_path: String, json: String) -> Result { + let db_data = serde_json::from_str(json.as_str()); + if db_data.is_err() { + println!("{:?}", db_data.err()); + return Err("Invalid db json"); + } + let db_data: Value = db_data.unwrap(); + + Ok(DbHandle { root_path, db_data }) + } + fn split_path(path: &str) -> Option<(&str, &str)> { + let mut parts = path.rsplitn(2, '/'); + let attribute = parts.next()?; + let parent = parts.next().unwrap_or(""); + Some((parent, attribute)) + } + + fn delete(&mut self, path: String, _args: HashMap) -> Result { + let _ = self.db_data.pointer(&path).ok_or(HttpErr { + status: HttpStatus::NotFound, + text: "Invalud path", + }); + let (parent, attr) = Self::split_path(&path).ok_or(HttpErr { + status: HttpStatus::BadRequest, + text: "Can't remove all the db", + })?; + let pointer = self.db_data.pointer_mut(&parent).ok_or(HttpErr { + status: HttpStatus::NotFound, + text: "Parent not found", + })?; + if pointer.is_array() { + let index = attr.parse::().map_err(|_| HttpErr { + status: HttpStatus::BadRequest, + text: "Invalid path", + })?; + pointer + .as_array_mut() + .ok_or(HttpErr { + status: HttpStatus::BadRequest, + text: "Invalid Path", + })? + .remove(index); + } else if pointer.is_object() { + pointer + .as_object_mut() + .ok_or(HttpErr { + status: HttpStatus::BadRequest, + text: "Invalid Path", + })? + .remove(attr); + } + let result = pointer.to_string(); + return Ok(result); + } + + fn patch( + &mut self, + path: String, + _args: HashMap, + body: Option, + ) -> Result { + let pointer = self.db_data.pointer_mut(&path).ok_or(HttpErr { + status: HttpStatus::NotFound, + text: "Path Not Found", + })?; + if pointer.is_array() { + return Err(HttpErr { + status: HttpStatus::BadRequest, + text: "Invalid Path", + }); + } else { + let body_c = body.clone().ok_or(HttpErr { + status: HttpStatus::BadRequest, + text: "Invalid Body", + })?; + let body_obj = body_c.as_object().ok_or(HttpErr { + status: HttpStatus::BadRequest, + text: "Invalid Body", + })?; + for (k, v) in body_obj.clone() { + if pointer.get(k.clone()).is_none() { + continue; + }; + pointer[k] = v.clone(); + } + } + let result = serde_json::to_string(&pointer); + match result { + Ok(r) => { + if r == "null" { + Err(HttpErr { + status: HttpStatus::NotFound, + text: "Not Found", + }) + } else { + Ok(r) + } + } + Err(_) => Err(HttpErr { + status: HttpStatus::InternalServerError, + text: "Error parsing result", + }), + } + } + + fn post( + &mut self, + path: String, + _args: HashMap, + body: Option, + ) -> Result { + let pointer = self.db_data.pointer_mut(&path).ok_or(HttpErr { + status: HttpStatus::BadRequest, + text: "Invalid Path", + })?; + if pointer.is_array() { + let list = pointer.as_array_mut().ok_or(HttpErr { + status: HttpStatus::BadRequest, + text: "Invalid Path", + })?; + let r = list.push(body.ok_or(HttpErr { + status: HttpStatus::BadRequest, + text: "Invalid Body", + })?); + let _ = serde_json::to_string(&r.clone()); + } else { + let body_c = body.clone().ok_or(HttpErr { + status: HttpStatus::BadRequest, + text: "Invalid Body", + })?; + let body_obj = body_c.as_object().ok_or(HttpErr { + status: HttpStatus::BadRequest, + text: "Invalid Body", + })?; + for (k, v) in body_obj.clone() { + pointer[k] = v.clone(); + } + } + let result = serde_json::to_string(&pointer); + match result { + Ok(r) => { + if r == "null" { + Err(HttpErr { + status: HttpStatus::NotFound, + text: "Not Found", + }) + } else { + Ok(r) + } + } + Err(_) => Err(HttpErr { + status: HttpStatus::InternalServerError, + text: "Error parsing result", + }), + } + } + + fn get(&self, path: String, args: HashMap) -> Result { + let mut pointer = self + .db_data + .pointer(&path) + .ok_or(HttpErr { + status: HttpStatus::BadRequest, + text: "Invalid Path", + })? + .clone(); + if pointer.is_array() { + let mut array: Vec = pointer.as_array().unwrap().clone(); + for (k, v) in args { + let k: &str = k.as_str().as_ref(); + array = array + .into_iter() + .filter(|i| i[k].to_string() == v) + .collect::>(); + } + pointer = Value::Array(array); + } + let result = serde_json::to_string(&pointer); + match result { + Ok(r) => { + if r == "null" { + Err(HttpErr { + status: HttpStatus::NotFound, + text: "Not Found", + }) + } else { + Ok(r) + } + } + Err(_) => Err(HttpErr { + status: HttpStatus::InternalServerError, + text: "Error parsing result", + }), + } + } + + pub fn is_match(&self, path: &String) -> bool { + path.starts_with(self.root_path.as_str()) + } + + pub fn process( + &mut self, + method: &str, + path: String, + args: HashMap, + body: String, + ) -> Result { + let mut path = path; + let root_path = if self.root_path.ends_with('/') { + let mut chars = self.root_path.chars(); + chars.next_back(); + chars.as_str() + } else { + self.root_path.as_str() + }; + if path.starts_with(root_path) { + path = path.strip_prefix(root_path).unwrap().to_string(); + } else { + return Err(HttpErr { + status: HttpStatus::BadRequest, + text: "Invalid Path", + }); + } + let path = if path.ends_with('/') { + let mut path = path.clone(); + path.pop(); + path + } else { + path + }; + let body = serde_json::from_str::(&body); + let body = if body.is_err() { + None + } else { + Some(body.unwrap()) + }; + match method { + "GET" => self.get(path, args), + "POST" => self.post(path, args, body), + "PATCH" => self.patch(path, args, body), + "DELETE" => self.delete(path, args), + _ => Err(HttpErr { + status: HttpStatus::MethodNotAllowed, + text: "Method Not Allowed", + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_dbhandle_new_valid_json() { + let json_data = json!({"key": "value"}).to_string(); + let db = DbHandle::new("/test".to_string(), json_data); + assert!(db.is_ok()); + } + + #[test] + fn test_dbhandle_new_invalid_json() { + let json_data = "invalid_json".to_string(); + let db = DbHandle::new("/test".to_string(), json_data); + assert!(db.is_err()); + } + + #[test] + fn test_delete_valid_key() { + let json_data = json!({"parent": {"child": "value"}}).to_string(); + let mut db: DbHandle = DbHandle::new("/test".to_string(), json_data).unwrap(); + let result = db.delete("/parent/child".to_string(), HashMap::new()); + assert!(result.is_ok()) + //assert_eq!(result.unwrap(), "{}"); + } + + #[test] + fn test_delete_invalid_key() { + let json_data = json!({"parent": {"child": "value"}}).to_string(); + let mut db: DbHandle = DbHandle::new("/test".to_string(), json_data).unwrap(); + let result = db.delete("/paren/non_existent".to_string(), HashMap::new()); + assert!(result.is_err()); + } + + #[test] + fn test_patch_valid_key() { + let json_data = json!({"key": {"subkey": "old_value"}}).to_string(); + let mut db: DbHandle = DbHandle::new("/test".to_string(), json_data).unwrap(); + let patch_body = json!({"subkey": "new_value"}); + let result = db.patch("/key".to_string(), HashMap::new(), Some(patch_body)); + assert!(result.is_ok()); + //assert_eq!(result.unwrap(), "{\"subkey\":\"new_value\"}"); + } + + #[test] + fn test_post_valid_key() { + let json_data = json!({"list": []}).to_string(); + let mut db = DbHandle::new("/test".to_string(), json_data).unwrap(); + let post_body = json!("new_item"); + let result = db.post("/list".to_string(), HashMap::new(), Some(post_body)); + assert!(result.is_ok()); + } + + #[test] + fn test_get_valid_key() { + let json_data = json!({"key": "value"}).to_string(); + let db = DbHandle::new("/test".to_string(), json_data).unwrap(); + let result = db.get("/key".to_string(), HashMap::new()); + assert!(result.is_ok()); + //assert_eq!(result.unwrap(), "\"value\""); + } + + #[test] + fn test_get_invalid_key() { + let json_data = json!({"key": "value"}).to_string(); + let db = DbHandle::new("/test".to_string(), json_data).unwrap(); + let result = db.get("/non_existent".to_string(), HashMap::new()); + assert!(result.is_err()); + } +} diff --git a/src/main.rs b/src/main.rs index d59039f..2946182 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,49 +1,98 @@ mod config_parser; +mod db_handle; mod utils; +use std::sync::{Arc, Mutex}; +use config_parser::{Config, EndpointSearch}; +use db_handle::DbHandle; use hteapot::headers; -use hteapot::HttpMethod; -use utils::SimpleRNG; +use hteapot::{Hteapot, HttpMethod, HttpResponse, HttpStatus}; use utils::clean_arg; -use hteapot::{HttpStatus, Hteapot}; -use config_parser::{Config, EndpointSearch}; +use utils::SimpleRNG; // section MAIN fn main() { - let args = std::env::args().collect::>(); - if args.len() != 3 { - println!("Usage: {} ", args[0]); - return; + let args = std::env::args().collect::>(); + if args.len() < 3 { + println!("Usage: {} ", args[0]); + return; + } + let addr: String = String::from("0.0.0.0"); + let port: u16 = args[1].clone().parse().unwrap_or(8080); + let config = Config::import(&args[2]); + let silent = args + .get(3) + .is_some() + .then(|| args.get(3).unwrap().eq("-s")) + .is_some(); + let mut dbs: Vec = Vec::new(); + for method in config.endpoints.keys() { + for endpoint in config.endpoints[method].iter() { + println!("Loaded {} {}", method, endpoint.path) } - let addr: String = String::from("0.0.0.0"); - let port: u16 = args[1].clone().parse().unwrap_or(8080); - let config = Config::import(&args[2]); - for method in config.endpoints.keys() { - for endpoint in config.endpoints[method].iter() { - println!("Loaded {} {}",method, endpoint.path) - } + } + for db in config.db { + let dbh = db_handle::DbHandle::new(db.path, db.data); + if dbh.is_err() { + println!("Error loading db: {:?}", dbh.err()); + continue; } - let teapot = Hteapot::new(&addr, port); - println!("Listening on http://{}:{}", addr, port); - teapot.listen(move|req| { - println!("{} {}", req.method.to_str(), req.path); - println!("{:?}", req.headers); - println!("{}", req.body); - println!(); + let dbh = dbh.unwrap(); + println!("Loaded {} as db", dbh.root_path); + dbs.push(dbh); + } + let dbs: Arc>> = Arc::new(Mutex::new(dbs)); + let dbsc = dbs.clone(); + let teapot = Hteapot::new(&addr, port); + println!("Listening on http://{}:{}", addr, port); + teapot.listen(move|req| { + if !silent { + let headers: String = req.headers.iter() + .map(|(k, v)| format!("- {}: {}", k, v)) + .collect::>() + .join("\n"); + + let output = format!( + "{} {}\n{}\n\n{}", + req.method.to_str(), + req.path, + headers, + req.body + ); + + println!("{}", output); + + } if req.method == HttpMethod::OPTIONS { let star = &"*".to_string(); let origin = req.headers.get("Origin").unwrap_or(star); - return Hteapot::response_maker(HttpStatus::NoContent, "", headers!("Allow" => "GET, POST, OPTIONS, HEAD", "Access-Control-Allow-Origin" => origin, "Access-Control-Allow-Headers" => "Content-Type, Authorization" )); + return HttpResponse::new(HttpStatus::NoContent, "", headers!("Allow" => "GET, POST, OPTIONS, HEAD", "Access-Control-Allow-Origin" => origin, "Access-Control-Allow-Headers" => "Content-Type, Authorization" )); } + + + + { + let mut dbs = dbsc.lock().unwrap(); + let dbh = dbs.iter_mut().find(|dbh| dbh.is_match(&req.path)); + if dbh.is_some() { + let dbh = dbh.unwrap(); + let result = dbh.process(req.method.to_str(), req.path, req.args,req.body); + return match result { + Ok(r) => HttpResponse::new(HttpStatus::OK, r,None ), + Err(err) => HttpResponse::new(err.status, err.text ,None ) + } + } + } + let response = config.endpoints.get(&req.method.to_str().to_string()); match response { Some(response) => { let config_item = response.get_iter(&req.path); match config_item { Some(endpoint) => { - let status = HttpStatus::from_u16(endpoint.status); + let status = HttpStatus::from_u16(endpoint.status).unwrap_or(HttpStatus::OK); let mut body = endpoint.body.to_string() .replace("{{path}}", &req.path) .replace("{{body}}", &req.body) @@ -59,18 +108,17 @@ fn main() { body = _body.replace(&format!("{{{{{key}}}}}", key=key), &value); } } - return Hteapot::response_maker(status, &body,headers!("Allow" => "GET, POST, OPTIONS, HEAD", "Access-Control-Allow-Origin" => "*", "Access-Control-Allow-Headers" => "Content-Type, Authorization" ) ); + return HttpResponse::new(status, &body,headers!("Allow" => "GET, POST, OPTIONS, HEAD", "Access-Control-Allow-Origin" => "*", "Access-Control-Allow-Headers" => "Content-Type, Authorization" ) ); } None => { - return Hteapot::response_maker(HttpStatus::NotFound, "Not Found", None); + return HttpResponse::new(HttpStatus::NotFound, "Not Found", None); } } } None => { - return Hteapot::response_maker(HttpStatus::NotFound, "Method Not Found", None); + return HttpResponse::new(HttpStatus::NotFound, "Method Not Found", None); } - } + } }); - }