add methods to routes, allow custom headers and response codes

This commit is contained in:
Murphy 2023-07-02 22:15:24 -04:00
parent f2d9a1cc27
commit 41fe16978e
17 changed files with 263 additions and 135 deletions

2
.gitignore vendored
View file

@ -1 +1,3 @@
/target
config
test

16
Cargo.lock generated
View file

@ -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"

View file

@ -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/)

View file

@ -1 +0,0 @@
/ /usr/bin/neofetch

10
examples/body.sh Executable file
View 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
View 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
View 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

View file

@ -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())
}

View file

@ -1,8 +0,0 @@
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub enum Code {
Success = 200,
MethodNotAllowed = 405,
TooManyRequests = 429,
InternalServerError = 500,
}

View file

@ -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 {

View file

@ -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",
}
}
}

View file

@ -1,4 +1,3 @@
pub mod code;
pub mod method;
pub mod uri;
pub mod request;

View file

@ -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 {

View file

@ -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 {

View file

@ -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
View 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
View 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;
},
}
}