7 releases

0.1.6 May 30, 2024
0.1.5 May 30, 2024

#222 in HTTP server

MIT license

55KB
1.5K SLoC

Zeke

A set of simple http primitives used to build web services, written in Rust.

Quickstart

Installation

In your cargo.toml:

[dependencies]
zeke = '0.1.3'

Create a Router

Routers are used to define and serve our http endpoints.

#[tokio::main]
async fn main() {
	let r = Router::new();
}

Create a Handler

Any function that returns a Handler can be associated with an endpoint:

#[tokio::main]
async fn main() {
	let r = Router::new();
    r.add(Route::new("GET /", hello_world()));
}

async fn hello_world() -> Handler {
    return Handler::new(|request| {
        // enables our handlers to by async
        Box::pin(async move {
            let response = Response::new()
                .status(200);
            return (request, response);
        })
    });
}

Serving

To serve the application, called Router.serve:

#[tokio::main]
async fn main() {
    // --snip
	let result = r.serve(&host).await;
	if result.is_err() {
		println!("Error: {:?}", err);
	}
}

Context Keys

Any data shared between middleware, handlers, and outerware is referred to as context.

Keys are required to encode and decode context. An enum which implements the Contextable trait can be used to keep track of these keys:

pub enum AppContext {
    Trace,
}

impl Contextable for AppContext {
    fn key(&self) -> &'static str {
        match self {
            AppContext::Trace => {"TRACE"},
        }
    }
}

HttpTrace

HttpTrace is a context (because it is intended to be shared between middleware, handlers, and outware) that helps us keep track of how long each request cycle takes.

You must derive Serialize and Deserialize for any data intended to be used as context.

#[derive(Debug, Serialize, Deserialize)]
pub struct HttpTrace {
    pub time_stamp: String,
}

impl HttpTrace {
    pub fn get_time_elapsed(&self) -> String {
        if let Ok(time_set) = DateTime::parse_from_rfc3339(&self.time_stamp) {
            let time_set = time_set.with_timezone(&Utc);
            let now = Utc::now();
            let duration = now.signed_duration_since(time_set);
            let micros = duration.num_microseconds();
            match micros {
                Some(micros) => {
                    if micros < 1000 {
                        return format!("{}µ", micros);
                    }
                },
                None => {

                }
            }
            let millis = duration.num_milliseconds();
            return format!("{}ms", millis);
        } else {
            return "failed to parse time_stamp".to_string();
        }
    }
}

Middleware

Any function that returns a Middleware can be used as middleware in our application.

Let's make use of the HttpTrace type we created in the previous section.

The following middleware will initialize HttpTrace prior to calling our handler:

pub async fn mw_trace() -> Middleware {
    Middleware::new(|mut request: &mut Request| {
        let trace = HttpTrace {
            time_stamp: chrono::Utc::now().to_rfc3339(),
        };
        let trace_encoded = serde_json::to_string(&trace);
        if trace_encoded.is_err() {
            return Some(Response::new()
                .status(500)
                .body("failed to encode trace")
            );
        }
        let trace_encoded = trace_encoded.unwrap();
        request.set_context(AppContext::Trace, trace_encoded);
        None
    })
}

Let's take a moment to notice a few key things going on here.

  1. We initalize our trace type and then encode it into json:
let trace = HttpTrace{
    time_stamp: chrono::Utc::now().to_rfc3339(),
};
let trace_encoded = serde_json::to_string(&trace);
  1. We ensure the trace has been encoded correctly:
if trace_encoded.is_err() {
    return Some(Response::new()
        .status(500)
        .body("failed to encode trace")
    );
}
  1. Finally (and most importantly) we call set_context on our Request type, using our AppContext::Trace key
let trace_encoded = trace_encoded.unwrap();
request.set_context(AppContext::Trace, trace_encoded);

Now the json data for the HttpTrace type is associated with the Request type and can be used later in the request cycle.

We can attach our middleware to a Route like so:

#[tokio::main]
async fn main() {
	let r = Router::new();
    r.add(Route::new("GET /", hello_world())
        .middleware(mw_trace())
    );
    let result = r.serve(&host).await;
	if result.is_err() {
		println!("Error: {:?}", err);
	}
}

Outerware

Any function that returns a Middleware can be used as outerware in our application.

Middleware is ran before the handler is called.

Outerware is ran after the handler is called.

We can create an outerware to decode our HttpTrace type after the request cycle is over. We can then calculate how much time it took the entire request to process and print it to the terminal.

pub async fn mw_trace_log() -> Middleware {
    Middleware::new(|request: &mut Request | {
        let trace = request.get_context(AppContext::Trace);
        if trace.is_empty() {
            return Some(Response::new()
                .status(500)
                .body("failed to get trace")
            );
        }
        let trace: HttpTrace = serde_json::from_str(&trace).unwrap();
        let elapsed_time = trace.get_time_elapsed();
        let log_message = format!("[{:?}][{}][{}]", request.method, request.path, elapsed_time);
        println!("{}", log_message);
        None
    })
}

Let's take a closer look at a few things.

  1. We use our AppContext::Trace key to get the encoded HttpTrace using request.get_context.
let trace = request.get_context(AppContext::Trace);
  1. We ensure the trace exists:
if trace == "" {
    return Some(Response::new()
        .status(500)
        .body("failed to get trace")
    );
}
  1. We decode the HttpTrace:
let trace: HttpTrace = serde_json::from_str(&trace).unwrap();
  1. Finally, we calculate the elapsed time and log results to the terminal:
let elapsed_time = trace.get_time_elapsed();
let log_message = format!("[{:?}][{}][{}]", request.method, request.path, elapsed_time);
println!("{}", log_message);

We can use this outerware in our application like so:

#[tokio::main]
async fn main() {
	let r = Router::new();
    r.add(Route::new("GET /", hello_world())
        .middleware(mw_trace().await)
        .outerware(mw_trace_log().await)
    );
    let result = r.serve(&host).await;
	if result.is_err() {
		println!("Error: {:?}", err);
	}
}

Middleware Groups

Any function that returns a MiddlewareGroup can be used as a middleware group in our application.

Middleware groups enable us to group middleware together. Let's see if we can group our mw_trace and mw_trace_log functions together:

pub fn mw_group_trace() -> MiddlewareGroup {
    return MiddlewareGroup::new(vec![mw_trace().await], vec![mw_trace_log().await]);
}

Now we can simply use the group:

#[tokio::main]
async fn main() {
	let r = Router::new();
    r.add(Route::new("GET /", hello_world())
        .group(mw_group_trace().await)
    );
    let result = r.serve(&host).await;
	if result.is_err() {
		println!("Error: {:?}", err);
	}
}

Dependencies

~9–20MB
~294K SLoC