Have you ever forgotten to handle a specific thing, resulting in your app being vulnerable or incomplete? Or maybe you're tired of recompiling your whole backend to change one thing on a page, without using scripted programming languages? For both of those cases, Rust and Drain are an excellent solution. From now on, I assume, that you've previously worked on a web app in some other environment and learned Rust.
Let's begin, shall we?
Table of contents
- Initial preparation
- Installing Drain stack
- Configuration
- Hello, World!
- Functionalities
1. Initial preparation
Begin with choosing your preferred IDE and installing Rust. Personally, I'd go with VSCode (or VSCodium) + rust-analyzer or RustRover.
For Rust installation, refer to https://www.rust-lang.org/tools/install (RustRover might make this fully automatic).
2. Installing Drain stack
Once you have your environment set up properly, make sure you have OpenSSL installed on your machine.
On Windows, OpenSSL can be troublesome, so I really encourage you to use something Unix-based and POSIX compliant, to some extent (if you can't bare to run anything other than Windows, you probably need to use this).
If you do, go ahead and install Drain. You can either clone its repository (up to the most recent commit) and use cargo run
inside its root, or run cargo install drain_server
(source release from crates.io).
When you encounter compile-time errors related to OpenSSL, try setting the OPENSSL_DIR
environment variable to where it should be installed on your system.
3. Configuration
Drain can be configured via a config file in JSON format fetched in the runtime. Full list of fields available in the config is inside the README on GitHub and crates.io along with the description of each of them.
Keep in mind, that some of the fields are optional, ergo they can be omitted.
At this point, you can skip endpoints
and endpoints_library
fields, as we will discuss them later.
Example config can be found here; if you want, you can copy-paste it and change according to your preferences.
Once you're done with config.json, set the path to it in DRAIN_CONFIG
environment variable and run the server to test if it's working. If it isn't, error messages will likely tell you, what's wrong.
From this point forward, I'll assume your config is all set up and working except for endpoints
and endpoints_library
fields.
4. Hello, World!
Now, we can proceed with running a "Hello, World!"-ish example to test this dynamic page functionality.
Start with git clone https://github.com/fooooter/drain_page_template
. This template is a working example of a dynamic webpage, so it can be already built and used.
We will use it to make things simple for the sake of the tutorial + it also has two error pages predefined, so it will be easier to change them later on.
use drain_common::RequestData::*;
use drain_macros::{drain_endpoint, set_header};
#[drain_endpoint("index")]
pub fn index() {
let content: Vec<u8> = Vec::from(format!(r#"
<!DOCTYPE html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Index</title>
</head>
<body>
Hello, world! {} request was sent.
</body>
</html>"#, match request_data {
Get(_) => "GET",
Post{..} => "POST",
Head(_) => "HEAD"
}));
set_header!("Content-Type", "text/html; charset=utf-8");
Some(content)
}
This is our index page! It displays the "Hello, World!" and tells, what kind of HTTP request was sent to the server. Go ahead and run cargo build
! It will produce a shared object file (.so on Linux and .dll on Windows) in target/debug. Copy it to the directory specified in config's server_root
field and set endpoints_library
to its path relative to server_root
("libdynamic_pages.so" or "dynamic_pages.dll" most likely). Make sure "index" is specified inside the endpoints
field.
Restart the server if it's already running, open your favorite web browser and type "localhost" or whatever address and port you've bound the server to. You should see the following:
5. Functionalities
Let's now talk in-depth about how to use Drain to create endpoints and use its functionalities.
drain_macros
and drain_common
crates
drain_macros
and drain_common
are two out of three main components, that make for Drain's environment. Those crates are providing tools for an efficient endpoint creation.
Make sure they're included in Cargo.toml as dependencies.
Also, you can shorten the path to each of them or to the content inside them.
#[drain_endpoint()]
(drain_macros
)
To mark a function in the library as an endpoint, define it using this macro.
It takes URL path as an argument, so if you want it to execute each time the client requests "https://example.com/api/get_data", specify #[drain_endpoint("api/get_data")]
(#[drain_endpoint(api/get_data)]
should also work). Once again, don't forget to put "api/get_data" inside endpoints
in config.json.
DISCLAIMER: Don't put non-existent endpoint paths inside config.json, as it produces an undefined behavior. Access to such a path by the client can either lead to segmentation fault/other runtime errors or be handled by Drain. I completely agree with the statements about it being unsafe, because so is the dynamic library loading in Rust, around which Drain is based.
You can name the function as you wish; it doesn't matter.
EVERY Drain endpoint returns Option<Vec<u8>>
, keep that in mind.
Handling requests
In Drain, requests can be handled using the request_data
of the type RequestData
. It's always present in the endpoint's scope, but be careful, because it might be dropped (use references).
It's an enum with three variants: GET
, POST
, HEAD
(currently only those are supported); corresponding to each HTTP request method. You can match against request_data
to handle each case.
...
match request_data {
Get(params) => {
// do something with params
},
Post {data, params} => {
// do something with data and/or params
},
Head(params) => {
// do something with params
}
}
...
params
are pretty straightforward, but data
in POST has the Option<RequestBody>
type.
RequestBody
is an enum, that tells about the media type of data sent inside POST and holds them.
It has two variants: XWWWFormUrlEncoded(HashMap<String, String>)
and FormData(HashMap<String, FormDataValue>)
. They correspond to application/x-www-form-urlencoded
and multipart/form-data
, respectively (again, only those two are supported).
This way, you can explicitly handle both the situation when the client doesn't send any data and both MIME types separately.
x-www-form-urlencoded
is also pretty straightforward, but form-data
is represented by a HashMap
. The name of a field is just String
, but the field itself is represented, by FormDataValue
type defined as follows:
pub struct FormDataValue {
pub filename: Option<String>,
pub headers: HashMap<String, String>,
pub value: Vec<u8>
}
As you can see, it can contain binary data unlike x-www-form-urlencoded
and an optional filename
in case of files. Headers for a particular field are also provided, just in case.
cookies!()
(drain_macros
)
To manage cookies, you can use the cookies!()
macro. It returns Option<HashMap<String, String>>
. It's None
when the client hasn't sent any cookies. HashMap
provides a get()
method - provide it the cookie name to return its value.
It's advised to create a let
binding, so you won't have to call it multiple times, which would cost performance, but I'm planning to make it a static
.
Here is an example on how to use it:
...
let cookies = cookies!();
match cookies {
Some(cookies) => {
if let Some(token) = cookies.get("token") {
...
}
return Some(Vec::from("token is required!"));
},
None => {
return Some(Vec::from("you haven't sent any cookies!"));
}
}
...
set_header!()
(drain_macros
)
You can set a response header field with this macro.
You have to put Content-Type
manually when you return any data (it's a precaution).
You can even put it at the beginning of your endpoint definition, if plan on returning the same media type in every situation!
Doing this is as simple as:
...
set_header!("Content-Type", "text/plain");
...
It works with every header field, though you need to be careful with using it.
If you set Location
, the client will be redirected with 302 Found status, though I'm planning to make a user able to set those statuses in config for each resource, i.e. file, webpage, endpoint etc.
header!()
(drain_macros
)
With this one, you're able to fetch a particular header field sent by the client. It returns Option<String>
.
Drain automatically converts each header field to lowercase.
Here is an example on how to use it:
...
let user_agent = header!("user-agent");
match user_agent {
Some(user_agent) => {
...
},
None => {
return Some(Vec::from("user agent is required!"));
}
}
...
set_cookie
and SetCookie
(drain_common
)
set_cookie
is a HashMap<String, SetCookie>
containing every cookie, that the server wants the client to set.
Use insert()
method to append the cookie you want to set.
Do it in the following way:
...
set_cookie.insert(String::from("token"), SetCookie {
value: token,
domain: None,
expires: None,
httponly: false,
max_age: Some(63113851),
partitioned: false,
path: None,
samesite: Some(SameSite::Lax),
secure: false
});
...
I know it's a bit long, but trust me, it's much more safe when it's explicit!
In case you wanted definitions for SetCookie
and SameSite
, there you go!
pub enum SameSite {
Strict,
Lax,
None
}
pub struct SetCookie {
pub value: String,
pub domain: Option<String>,
pub expires: Option<String>,
pub httponly: bool,
pub max_age: Option<i32>,
pub partitioned: bool,
pub path: Option<String>,
pub samesite: Option<SameSite>,
pub secure: bool
}
Fun tricks
Did you know you're likely able to create a static
, which lives even after you reload the website? To make that clear, I'll provide an example.
Consider a simple counter, which gets incremented each time you reload the page. You can do it in the following way:
use std::sync::{LazyLock, Mutex};
use drain_common::RequestData::*;
use drain_macros::{drain_endpoint, set_header};
static COUNTER: LazyLock<Mutex<u32>> = LazyLock::new(|| {
Mutex::new(0)
});
#[drain_endpoint("index")]
pub fn index() {
let mut counter = COUNTER.lock().unwrap(); // it's only a reference to the COUNTER, not the actual counter, so nothing is cloned here!
let content: Vec<u8> = Vec::from(format!(r#"
<!DOCTYPE html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Index</title>
</head>
<body>
Hello, world! Counter: {counter}.
</body>
</html>"#));
*counter = *counter + 1;
set_header!("Content-Type", "text/html; charset=utf-8");
Some(content)
}
It's just a modified "Hello, World!" example from above. Once you build it and redo steps in step 4 (which you should have memorized by now!), run the server and try refreshing the page a couple of times. The counter should increment each time you refresh.
Pretty cool, right?
Furthermore, Drain allows you to import any crate you want inside the dynamic library for your endpoints. It also applies to the crates supporting Tokio, such as SQLx. If you want your sweet database connection, go ahead!
And about that, you can actually use the previous tip about static
s!
use std::sync::{Mutex, LazyLock};
use tokio::runtime::Handle;
use sqlx::{Connection, mysql::MySqlConnection};
use tokio::task;
pub static CONN: LazyLock<Mutex<MySqlConnection>> = LazyLock::new(|| {
Mutex::new(
task::block_in_place(move || {
Handle::current().block_on(async {
MySqlConnection::connect("mysql://root:@localhost/example").await.unwrap()
})
})
)
});
It's a bit complicated and must stay this way, because that static
is lazy evaluated and gets initialized the first time it's used.
Therefore, because it's supposed to be ALWAYS used for the first time inside Drain's endpoint (which is inside a Tokio runtime), we can't use tokio::Runtime::new().unwrap().block_on(async {...})
stuff, because a runtime cannot be created inside a runtime (even if it's temporary).
And, of course, as we can see, MySqlConnection::connect
returns a Future
, which can't be normally awaited inside a static
. This applies to everything, that returns Future
.
But what if you wanted to panic inside the endpoint? It wouldn't crash the entire server, would it?
There are good news; panicking inside the endpoint makes the server output the panic!()
message as a regular error, sending the 500 Internal Server Error page to the client.
Task failed successfully, I guess!
This wraps on all the fun stuff you can do with Drain and the list is growing larger and larger with each release.
Thank you for reading; there's definitely a lot to process about this environment and it might not be easy for everybody to understand, but I've tried my best to explain everything as clearly as possible.
If you have any doubts about this framework or the tutorial itself, feel free to ask in the comments down below! I'll try to respond to your comments.
If you find any bugs, want to request a feature or add a feature yourself, use GitHub's Issues and Pull Requests respectively.
If you like this project or find it interesting, consider giving it a star on GitHub - you'll have my endless gratitude.