add methods to routes, allow custom headers and response codes
This commit is contained in:
parent
f2d9a1cc27
commit
41fe16978e
17 changed files with 263 additions and 135 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -1 +1,3 @@
|
|||
/target
|
||||
config
|
||||
test
|
||||
|
|
16
Cargo.lock
generated
16
Cargo.lock
generated
|
@ -53,6 +53,14 @@ dependencies = [
|
|||
"rustc-demangle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bashhttp"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
|
@ -448,14 +456,6 @@ version = "0.2.87"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1"
|
||||
|
||||
[[package]]
|
||||
name = "web"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
|
|
45
README.md
45
README.md
|
@ -4,20 +4,53 @@ A very goofy http server that runs scripts based of the given URI route
|
|||
|
||||
## Config
|
||||
|
||||
All routs and scripts must be layed out in a file called config, or you can specift the path by setting the `CONFIG_PATH` env variable.
|
||||
All routes and scripts must be layed out in a file called config, or you can specift the path by setting the `CONFIG_PATH` env variable.
|
||||
|
||||
An example config is shown below
|
||||
```
|
||||
/ ./hello_world
|
||||
/neo /usr/bin/neofetch
|
||||
/joe ./bide
|
||||
GET / command.sh
|
||||
POST /joe headers.sh
|
||||
POST /echo body.sh
|
||||
```
|
||||
As shown above, each route to script is a single line seperated by a single space.
|
||||
As shown above, each line is the method route given to run a given script.
|
||||
|
||||
## Scripts
|
||||
|
||||
The request body (if it has one) is set to argument 1 of the script. If there was on body provided the argument will be left empty. Anything written to the scripts stdout will be returnd to the http request.
|
||||
Anything written to a scripts stdout will be treated as part of the HTTP Response.
|
||||
|
||||
Therefore the first things returned will be treated as headers. To stop reading headers, print a `\n` to stdout to
|
||||
tell the HTTP Response that the headers have ended.
|
||||
|
||||
All content after the headers will be treated as the response body, and the scripts exit code will be treated as the http response code.
|
||||
|
||||
An example script that gives custom headers is shown below:
|
||||
```sh
|
||||
#!/bin/bash
|
||||
|
||||
# print headers that will be returned
|
||||
printf "Content-Type: text/plain\n"
|
||||
printf "aaaaaaaa: AAAAAAAAAA\n"
|
||||
printf "\n"
|
||||
|
||||
printf "joe\n"
|
||||
|
||||
# return http code 200
|
||||
exit 200
|
||||
```
|
||||
|
||||
Finally if you wish to read the request body given by the user, that is always stored in argument 1. For example to create a echo endpoint:
|
||||
```sh
|
||||
#!/bin/bash
|
||||
|
||||
# even though we have no headers
|
||||
# we still need to tell there will be no more headers
|
||||
printf "\n"
|
||||
|
||||
# the body of any http request made will be stored in argument 1
|
||||
printf "$1"
|
||||
|
||||
exit 200
|
||||
```
|
||||
## License
|
||||
|
||||
This project is licensed under the [WTFPL](https://www.wtfpl.net/)
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
/ /usr/bin/neofetch
|
10
examples/body.sh
Executable file
10
examples/body.sh
Executable file
|
@ -0,0 +1,10 @@
|
|||
#!/bin/bash
|
||||
|
||||
# even though we have no headers
|
||||
# we still need to tell there will be no more headers
|
||||
printf "\n"
|
||||
|
||||
# the body of any http request made will be stored in argument 1
|
||||
printf "$1"
|
||||
|
||||
exit 200
|
11
examples/command.sh
Executable file
11
examples/command.sh
Executable file
|
@ -0,0 +1,11 @@
|
|||
#!/bin/bash
|
||||
|
||||
# even though we have no headers
|
||||
# we still need to tell there will be no more headers
|
||||
printf "\n"
|
||||
|
||||
neofetch
|
||||
|
||||
# return http code 418 im a teapot
|
||||
exit 418
|
||||
|
11
examples/headers.sh
Executable file
11
examples/headers.sh
Executable file
|
@ -0,0 +1,11 @@
|
|||
#!/bin/bash
|
||||
|
||||
# print headers that will be returned
|
||||
printf "Content-Type: text/plain\n"
|
||||
printf "aaaaaaaa: AAAAAAAAAA\n"
|
||||
printf "\n"
|
||||
|
||||
printf "joe\n"
|
||||
|
||||
# return http code 200
|
||||
exit 200
|
41
src/bash.rs
41
src/bash.rs
|
@ -1,41 +0,0 @@
|
|||
use std::{env, collections::HashMap, fs::read_to_string, process::{exit, Command}};
|
||||
|
||||
pub fn load_config() -> HashMap<String, String> {
|
||||
|
||||
let config_path = env::var("CONFIG_PATH").unwrap_or_else(|_| String::from("config"));
|
||||
let config = match read_to_string(&config_path) {
|
||||
Ok(data) => data,
|
||||
Err(err) => {
|
||||
eprintln!("cannot load '{config_path}': {err}");
|
||||
exit(1);
|
||||
},
|
||||
};
|
||||
|
||||
let mut map = HashMap::new();
|
||||
let lines = config.split("\n").into_iter();
|
||||
|
||||
for line in lines {
|
||||
let mut parts = line.trim().split(" ");
|
||||
let Some(route) = parts.next() else { continue };
|
||||
let Some(script) = parts.next() else { continue };
|
||||
|
||||
println!("adding entry {route} => {script}");
|
||||
map.insert(route.to_owned(), script.to_owned());
|
||||
}
|
||||
|
||||
map
|
||||
}
|
||||
|
||||
pub fn handle_script(script: &str, body: Option<&String>) -> Result<String, String> {
|
||||
let mut command = Command::new(script);
|
||||
if let Some(body) = body {
|
||||
command.args([body]);
|
||||
}
|
||||
|
||||
let output = match command.output() {
|
||||
Ok(o) => o,
|
||||
Err(err) => return Err(format!("{err}")),
|
||||
};
|
||||
|
||||
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
|
||||
}
|
|
@ -1,8 +0,0 @@
|
|||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub enum Code {
|
||||
Success = 200,
|
||||
MethodNotAllowed = 405,
|
||||
TooManyRequests = 429,
|
||||
InternalServerError = 500,
|
||||
}
|
|
@ -57,9 +57,7 @@ impl HeaderMap {
|
|||
string
|
||||
}
|
||||
|
||||
pub fn serialize(lines: &mut Split<char>) -> Self {
|
||||
|
||||
let mut headers = Self::new();
|
||||
pub fn serialize(&mut self, lines: &mut Split<char>) {
|
||||
|
||||
loop {
|
||||
let Some(header) = lines.next() else { break };
|
||||
|
@ -69,10 +67,9 @@ impl HeaderMap {
|
|||
let Some(key) = parts.next() else { continue };
|
||||
let Some(value) = parts.next() else { continue };
|
||||
|
||||
headers.put(Header::new(key.trim(), value.trim()));
|
||||
self.put(Header::new(key.trim(), value.trim()));
|
||||
}
|
||||
|
||||
headers
|
||||
}
|
||||
|
||||
pub fn new() -> Self {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Hash, Eq, PartialEq)]
|
||||
pub enum Method {
|
||||
Get,
|
||||
Head,
|
||||
|
@ -26,5 +26,19 @@ impl Method {
|
|||
_ => None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deserialize(&self) -> &str {
|
||||
match self {
|
||||
Method::Get => "GET",
|
||||
Method::Head => "HEAD",
|
||||
Method::Post => "POST",
|
||||
Method::Put => "PUT",
|
||||
Method::Delete => "DELETE",
|
||||
Method::Connect => "CONNECT",
|
||||
Method::Options => "OPTIONS",
|
||||
Method::Trace => "TRACE",
|
||||
Method::Patch => "PATCH",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
pub mod code;
|
||||
pub mod method;
|
||||
pub mod uri;
|
||||
pub mod request;
|
||||
|
|
|
@ -39,7 +39,9 @@ impl Request {
|
|||
return None
|
||||
};
|
||||
|
||||
let headers = HeaderMap::serialize(&mut lines);
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.serialize(&mut lines);
|
||||
|
||||
let body: String = lines.collect();
|
||||
|
||||
Some(Self {
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
use super::{code::Code, header::{HeaderMap, Header}};
|
||||
use super::{header::{HeaderMap, Header}};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Response {
|
||||
pub status: Code,
|
||||
pub status: u16,
|
||||
pub headers: HeaderMap,
|
||||
pub body: Option<String>
|
||||
}
|
||||
|
@ -20,7 +20,7 @@ impl Response {
|
|||
headers.put(Header::new("Server", "bashttp"));
|
||||
|
||||
return Self {
|
||||
status: Code::Success,
|
||||
status: 200,
|
||||
headers,
|
||||
body: None
|
||||
}
|
||||
|
@ -29,7 +29,7 @@ impl Response {
|
|||
pub fn deserialize(&self) -> String {
|
||||
let mut string = String::new();
|
||||
|
||||
string += &format!("HTTP/1.1 {}\n", self.status.clone() as u16);
|
||||
string += &format!("HTTP/1.1 {}\n", self.status);
|
||||
string += &self.headers.deserialize();
|
||||
|
||||
if let Some(body) = &self.body {
|
||||
|
|
72
src/main.rs
72
src/main.rs
|
@ -1,69 +1,23 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use http::code::Code;
|
||||
use http::header::Header;
|
||||
use http::request::Request;
|
||||
use http::response::Response;
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
use crate::bash::handle_script;
|
||||
use std::{env, sync::Arc};
|
||||
use tokio::net::TcpListener;
|
||||
use crate::server::handle_connection;
|
||||
|
||||
mod http;
|
||||
mod bash;
|
||||
|
||||
async fn handle_response(mut socket: TcpStream, code: Code, body: String) {
|
||||
let mut res = Response::new();
|
||||
res.headers.put(Header::new("Content-Type", "text/plain"));
|
||||
res.status = code;
|
||||
res.body = Some(body);
|
||||
|
||||
let res_str = res.deserialize();
|
||||
|
||||
let _ = socket.write(res_str.as_bytes()).await;
|
||||
}
|
||||
|
||||
async fn handle_connection(mut socket: TcpStream, config: Arc<HashMap<String, String>>) {
|
||||
|
||||
let mut buf = [0; 1204];
|
||||
|
||||
let n: usize = match socket.read(&mut buf).await {
|
||||
Ok(n) if n == 0 => return,
|
||||
Ok(n) => n as usize,
|
||||
Err(e) => {
|
||||
eprintln!("failed to read from socket; err = {:?}", e);
|
||||
return
|
||||
}
|
||||
};
|
||||
|
||||
let str = String::from_utf8_lossy(&buf[0..n]);
|
||||
|
||||
let Some(req) = Request::serialize(&str) else {
|
||||
return
|
||||
};
|
||||
|
||||
|
||||
let Some(script) = config.get(&req.uri.route) else {
|
||||
handle_response(socket, Code::MethodNotAllowed, "Method Not Allowed".to_owned()).await;
|
||||
return
|
||||
};
|
||||
|
||||
match handle_script(script, req.body.as_ref()) {
|
||||
Ok(out) => {
|
||||
handle_response(socket, Code::Success, out).await;
|
||||
},
|
||||
Err(err) => {
|
||||
handle_response(socket, Code::MethodNotAllowed, err).await;
|
||||
},
|
||||
}
|
||||
}
|
||||
mod script;
|
||||
mod server;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
|
||||
let config = Arc::new(bash::load_config());
|
||||
let config = match script::Config::load() {
|
||||
Ok(config) => Arc::new(config),
|
||||
Err(err) => {
|
||||
eprintln!("failed to load config: {err}");
|
||||
return
|
||||
}
|
||||
};
|
||||
|
||||
let port = std::env::var("PORT")
|
||||
let port = env::var("PORT")
|
||||
.unwrap_or_else(|_| String::from("8080"))
|
||||
.parse::<u16>()
|
||||
.unwrap_or_else(|_| 8080);
|
||||
|
|
94
src/script.rs
Normal file
94
src/script.rs
Normal file
|
@ -0,0 +1,94 @@
|
|||
use std::{env, collections::HashMap, fs::read_to_string, process::Command, result::Result, error::Error};
|
||||
|
||||
use crate::http::{response::Response, method::Method};
|
||||
|
||||
pub struct Script {
|
||||
path: String
|
||||
}
|
||||
|
||||
impl Script {
|
||||
fn new(path: String) -> Self {
|
||||
Self { path }
|
||||
}
|
||||
|
||||
pub fn run(&self, body: Option<&String>) -> Result<Response, std::io::Error> {
|
||||
let mut res = Response::new();
|
||||
|
||||
let mut command = Command::new(&self.path);
|
||||
if let Some(body) = body {
|
||||
command.args([body]);
|
||||
}
|
||||
|
||||
let output = match command.output() {
|
||||
Ok(output) => output,
|
||||
Err(err) => return Err(err)
|
||||
};
|
||||
|
||||
let status = output.status.code().unwrap_or(500) as u16;
|
||||
|
||||
let response = String::from_utf8_lossy(&output.stdout).into_owned();
|
||||
let mut lines = response.split('\n').into_iter();
|
||||
|
||||
res.headers.serialize(&mut lines);
|
||||
|
||||
let body: String = lines.collect::<Vec<&str>>().join("\n");
|
||||
if body.len() > 0 {
|
||||
res.body = Some(body);
|
||||
}
|
||||
|
||||
res.status = status;
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Config {
|
||||
map: HashMap<String, Script>
|
||||
}
|
||||
|
||||
impl Config {
|
||||
|
||||
fn key(method: &Method, route: &str) -> String {
|
||||
format!("{}:{}", method.deserialize(), route)
|
||||
}
|
||||
|
||||
pub fn load() -> Result<Self, Box<dyn Error>> {
|
||||
|
||||
let config_path = env::var("CONFIG_PATH").unwrap_or_else(|_| String::from("config"));
|
||||
let config = read_to_string(&config_path)?;
|
||||
|
||||
let mut map = HashMap::new();
|
||||
let lines = config.split("\n").into_iter();
|
||||
|
||||
for (i, line) in lines.enumerate() {
|
||||
let mut parts = line.split_whitespace();
|
||||
|
||||
let Some(method_str) = parts.next() else { continue };
|
||||
let method = Method::serialize(&method_str)
|
||||
.ok_or(format!("config line {i} has invalid http method: {method_str}"))?;
|
||||
|
||||
let route = parts.next()
|
||||
.ok_or(format!("config line {i} missing http route"))?;
|
||||
|
||||
let script = parts.next()
|
||||
.ok_or(format!{"config line {i} missing script path"})?;
|
||||
|
||||
let key = Self::key(&method, route);
|
||||
let value = Script::new(script.to_owned());
|
||||
|
||||
println!("adding script {script} for {} {route}", method.deserialize());
|
||||
map.insert(key, value);
|
||||
}
|
||||
|
||||
if map.len() < 1 {
|
||||
return Err("cannot run server, config is empty".into())
|
||||
}
|
||||
|
||||
Ok(Self { map })
|
||||
}
|
||||
|
||||
pub fn get(&self, method: &Method, route: &str) -> Option<&Script> {
|
||||
self.map.get(&Self::key(method, route))
|
||||
}
|
||||
|
||||
}
|
51
src/server.rs
Normal file
51
src/server.rs
Normal file
|
@ -0,0 +1,51 @@
|
|||
use std::sync::Arc;
|
||||
use tokio::{net::TcpStream, io::{AsyncWriteExt, AsyncReadExt}};
|
||||
use crate::{http::{header::Header, response::Response, request::Request}, script::Config};
|
||||
|
||||
async fn send_response(mut socket: TcpStream, code: u16, body: String) {
|
||||
let mut res = Response::new();
|
||||
res.headers.put(Header::new("Content-Type", "text/plain"));
|
||||
res.status = code;
|
||||
res.body = Some(body);
|
||||
|
||||
let res_str = res.deserialize();
|
||||
|
||||
let _ = socket.write(res_str.as_bytes()).await;
|
||||
}
|
||||
|
||||
pub async fn handle_connection(mut socket: TcpStream, config: Arc<Config>) {
|
||||
|
||||
let mut buf = [0; 1204];
|
||||
|
||||
let n: usize = match socket.read(&mut buf).await {
|
||||
Ok(n) if n == 0 => return,
|
||||
Ok(n) => n as usize,
|
||||
Err(e) => {
|
||||
eprintln!("failed to read from socket; err = {:?}", e);
|
||||
return
|
||||
}
|
||||
};
|
||||
|
||||
let str = String::from_utf8_lossy(&buf[0..n]);
|
||||
|
||||
let Some(req) = Request::serialize(&str) else {
|
||||
return
|
||||
};
|
||||
|
||||
|
||||
let Some(script) = config.get(&req.method, &req.uri.route) else {
|
||||
send_response(socket, 405, "Method Not Allowed".to_owned()).await;
|
||||
return
|
||||
};
|
||||
|
||||
match script.run(req.body.as_ref()) {
|
||||
Ok(res) => {
|
||||
let res_str = res.deserialize();
|
||||
let _ = socket.write(res_str.as_bytes()).await;
|
||||
},
|
||||
Err(err) => {
|
||||
println!("{err}");
|
||||
send_response(socket, 500, format!("{err}")).await;
|
||||
},
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue