--- TL;DR: Open source crates like Nickel.rs and the MongoDB Rust Driver make it possible to create RESTful APIs in the Rust language. In this article, we cover how to do
GET
, POST
, and DELETE
requests on user data. Check out the repo to get the code.--- Rust is a fairly new systems programming language that is developed and maintained by Mozilla. It surfaced in 2010 and has been gaining a lot of traction since.
Rust has many concepts that are familiar and seen frequently in other languages and some that aren't. A unique feature that Rust has is the way it enforces memory safety. It doesn't have a garbage collector like some other languages do, but rather handles memory allocation with the concept of ownership. With ownership, the compiler automatically deallocates memory when something goes out of scope.
“A unique feature that Rust has is the way it enforces memory safety.”
Tweet This
While Rust is a general-purpose programming language, there are many packages available that make it possible to spin up a web server with it. This means that Rust might be the ideal choice for a web project if memory safety and speed are non-trivial.
In this article, we'll see how we can create a simple RESTful API with Rust. We'll also connect it to MongoDB so we can get a feel for a full end-to-end API solution. It's possible that many readers will be familiar with JavaScript and, in particular, NodeJS. For this reason, we'll intentionally draw some comparisons between our Rust implementation and how an API would be created in NodeJS. While this won't be a crash course in the Rust language itself, we'll also take some time to explain syntax and semantics in certain places.
Getting Started
To create and serve our API, we'll use Nickel.rs, and to interact with the database, we'll use the MongoDB Rust Driver. There are other crates (aka packages) available, especially for creating a server and API, but Nickel.rs offers an abstraction that provides a similar feel to NodeJS and, in particular, Express. This can be helpful for those coming from a Node background.
If you don't already have Rust installed, you can check out the installation instructions to get going.
Let's declare our dependencies in the
Cargo.toml
file.[package] name = "rust-users" version = "0.0.1" authors = [ "Firstname Lastname <you@email.com>" ] [dependencies] nickel = "*" mongodb = "*" bson = "*" rustc-serialize = "*"
We've already mentioned that we need Nickel.rs and the MongoDB Rust Driver. We also need the BSON crate to encode and decode BSON data, as well as rustc-serialize for formatting the JSON that will be returned by the API.
Setting Up the API
Our API will have three endpoints:
- GET
/users
- retrieves a JSON string of all the users
- POST
/users/new
- saves a new user
- DELETE
/users/:id
- deletes a user based on the record's objectId
To take things one step at a time, let's first get the API running and simply return a message to confirm things are working from each endpoint.
// src/main.rs #[macro_use] extern crate nickel; use nickel::{Nickel, JsonBody, HttpRouter, Request, Response, MiddlewareResult, MediaType}; fn main() { let mut server = Nickel::new(); let mut router = Nickel::router(); router.get("/users", middleware! { |request, response| format!("Hello from GET /users") }); router.post("/users/new", middleware! { |request, response| format!("Hello from POST /users/new") }); router.delete("/users/:id", middleware! { |request, response| format!("Hello from DELETE /users/:id") }); server.utilize(router); server.listen("127.0.0.1:9000"); }
Starting at the top, we're referencing the external Nickel.rs crate and loading in all of its macros with
#[macro_use]
. Like functions, macros in Rust let us abstract away code into reusable blocks. One of the differences with a macro is that it can be abstracted at the syntactic level, which can offer some benefits over functions.In the
main
function, we first assign server and router instances to mutable variables. Next, we set up our endpoint routing and provide a simple message in the format!
macro to be displayed when these endpoints are accessed. The middleware!
macro is provided by Nickel.rs and reduces the amount of boilerplate code needed for each route. Double-pipe characters represent a closure in Rust, and this is where our request
and response
parameters go.Finally, we need to
utilize
the server and listen
for it on localhost:9000
.At this point, it's easy to see some similarities between Nickel.rs and Express. This is by design and is nice for those coming to Rust from NodeJS.
If we compile the program with
cargo run
, we can see the API is working.Connecting to a MongoDB Collection
The MongoDB Rust Driver provides a nice interface for interacting with databases, collections, and cursors. With it, we can establish a database connection and create, read, update, and delete documents as we typically would.
MongoDB will need to be installed and running at this point, which we won't cover in this article. To get set up with MongoDB, follow the getting started guide.
Let's start by establishing a connection and getting the POST
/users/new
route working. We'll need to bring in the dependencies we have yet to reference and use
their components.// src/main.rs #[macro_use] extern crate nickel; extern crate rustc_serialize; #[macro_use(bson, doc)] extern crate bson; extern crate mongodb; // Nickel use nickel::{Nickel, JsonBody, HttpRouter}; use nickel::status::StatusCode::{self}; // MongoDB use mongodb::{Client, ThreadedClient}; use mongodb::db::ThreadedDatabase; use mongodb::error::Result as MongoResult; // bson use bson::{Bson, Document}; use bson::oid::ObjectId; // rustc_serialize use rustc_serialize::json::{Json, ToJson}; ...
We need to create a
struct
that is encodable and decoable and that models our user data.// src/main.rs ... #[derive(RustcDecodable, RustcEncodable)] struct User { firstname: String, lastname: String, email: String }
A
struct
in Rust give us a way to create data types that can be arbitrarily complex.Saving User Data
Now within our
/users/new
route, we can connect to the database and insert a document.// src/main.rs ... router.post("/users/new", middleware! { |request, response| // Accept a JSON string that corresponds to the User struct let user = request.json_as::<User>().unwrap(); let firstname = user.firstname.to_string(); let lastname = user.lastname.to_string(); let email = user.email.to_string(); // Connect to the database let client = Client::connect("localhost", 27017) .ok().expect("Error establishing connection."); // The users collection let coll = client.db("rust-users").collection("users"); // Insert one user match coll.insert_one(doc! { "firstname" => firstname, "lastname" => lastname, "email" => email }, None) { Ok(_) => (StatusCode::Ok, "Item saved!"), Err(e) => return response.send(format!("{}", e)) } }); ...
We accept the request as a JSON string that should conform to the
User
struct and create some variables to hold its data. The unwrap
method is one of several ways that Rust provides for assigning a value. While Rust provides a match
statement to check for a value's existence and then respond with both a success and error case, unwrap
is a quick way of assuming that the value will be present. We can see the match
statement at work when we use coll.insert_doc
to insert the user data. In the Ok
condition, we respond with a success message, and in the Err
condition we respond with an error.For this route, we need to send a JSON string to the endpoint to save the data.
We should get a success message and be able to see the user saved in the database.
Fetching User Data
It's easy to return JSON data from an endpoint when building a NodeJS app with MongoDB and Express, but it's a bit trickier with the current MongoDB driver implementation. One way to return JSON data effectively is to format MongoDB documents as a JSON string and then return the string. This would then need to be parsed on the front end with
JSON.parse
.// src/main.rs ... fn get_data_string(result: MongoResult<Document>) -> Result<Json, String> { match result { Ok(doc) => Ok(Bson::Document(doc).to_json()), Err(e) => Err(format!("{}", e)) } } router.get("/users", middleware! { // Connect to the database let client = Client::connect("localhost", 27017) .ok().expect("Error establishing connection."); // The users collection let coll = client.db("rust-users").collection("users"); // Create cursor that finds all documents let mut cursor = coll.find(None, None).unwrap(); // Opening for the JSON string to be returned let mut data_result = "{\"data\":[".to_owned(); for (i, result) in cursor.enumerate() { match get_data_string(result) { Ok(data) => { let string_data = if i == 0 { format!("{}", data) } else { format!("{},", data) }; data_result.push_str(&string_data); }, Err(e) => return response.send(format!("{}", e)) } } // Close the JSON string data_result.push_str("]}"); // Set the returned type as JSON response.set(MediaType::Json); // Send back the result format!("{}", data_result) }); ...
For our GET
/users
route, we establish a cursor
for the endpoint that uses the find
method to get all the documents in the users
collection. We then iterate over the results with a for
loop and match the results against a function called get_data_string
. This function expects an argument of type MongoResult
and returns a JSON string using Bson::Document
for decoding, which happens in the Ok
branch of the match
statement.After pushing any results onto the
data_result
string and closing it off, we set the MediaType
as Json
so that it is returned in JSON form instead of a string.Deleting User Data
The final step for this example API is to allow for users to be deleted by their
objectId
. We can do this with the MongoDB Rust Driver's delete_one
method.// src/main.rs ... router.delete("/users/:id", middleware! { |request, response| let client = Client::connect("localhost", 27017) .ok().expect("Failed to initialize standalone client."); // The users collection let coll = client.db("rust-users").collection("users"); // Get the objectId from the request params let object_id = request.param("id").unwrap(); // Match the user id to an bson ObjectId let id = match ObjectId::with_string(object_id) { Ok(oid) => oid, Err(e) => return response.send(format!("{}", e)) }; match coll.delete_one(doc! {"_id" => id}, None) { Ok(_) => (StatusCode::Ok, "Item deleted!"), Err(e) => return response.send(format!("{}", e)) } }); ...
We use the
ObjectId::with_string
helper to decode the string representation of the objectId
, after which it can be used in the delete_one
method to remove the document for that user.With the DELETE
/users/:id
route in place, we should be able to remove users from the database when we make a request to it and include the objectId
as a parameter.Implementing JWT Authentication for the Rust API
JWT authentication can be implemented for a Nickel.rs API by using a crate like rust-jwt to encode and decode tokens, along with a custom middleware to protect the API routes.
Step 1: Bring in Additional Dependencies
To start, let's add rust-jwt, hyper, and rust-crypto to our
Cargo.toml
file.... jwt = "*" hyper = "*" rust-crypto = "*"
Step 2: Create a Login Route
We need a
login
route that accepts a username and password and returns a JWT if authentication is valid.Note: For simplicity, we are checking against a locally-stored placeholder password in this example.
// src/main.rs ... extern crate jwt; extern crate hyper; extern crate crypto; ... // Nickel use nickel::status::StatusCode::{self, Forbidden}; // hyper use hyper::header; use hyper::header::{Authorization, Bearer}; // jwt use std::default::Default; use crypto::sha2::Sha256; use jwt::{ Header, Registered, Token, }; ... static AUTH_SECRET: &'static str = "some_secret_key"; ' ... #[derive(RustcDecodable, RustcEncodable)] struct UserLogin { email: String, password: String } ... router.post("/login", middleware! { |request| // Accept a JSON string that corresponds to the User struct let user = request.json_as::<UserLogin>().unwrap(); // Get the email and password let email = user.email.to_string(); let password = user.password.to_string(); // Simple password checker if password == "secret".to_string() { let header: Header = Default::default(); // For the example, we just have one claim // You would also want iss, exp, iat etc let claims = Registered { sub: Some(email.into()), ..Default::default() }; let token = Token::new(header, claims); // Sign the token let jwt = token.signed(AUTH_SECRET.as_bytes(), Sha256::new()).unwrap(); format!("{}", jwt) } else { format!("Incorrect username or password") } }); ...
This route accepts a JSON object from a POST request and checks it against the
UserLogin
struct, which requires a username
and password
to be provided. We're accepting all email addresses and using "secret" as our placeholder password here, but you would of course want to check your users against a database with hashed passwords.If the password passes, a new token is created with
Token::new()
and is signed with the secret. In this example, we're only putting the sub
(subject) claim in the payload, but in a real app we would need other claims such as iat
(issued at) and exp
(expiry). The token is sent in the response so that it can be saved and used to access protected routes.Step 3: Implement Middleware to Protect the API Routes
The next step is to protect our API endpoints so that only requests with a valid JWT in the
Authorization
header are able to access them. We can create our own custom middleware to accomplish this, which will be used by the Nickel.rs server to protect the routes.We need a function to act as the middleware, and in this case, we'll call it
authenticator
.// src/main.rs ... fn authenticator<'mw>(request: &mut Request, response: Response<'mw>, ) ->MiddlewareResult<'mw> { ' // Check if we are getting an OPTIONS request if request.origin.method.to_string() == "OPTIONS".to_string() { // The middleware should not be used for OPTIONS, so continue response.next_middleware() } else { // We do not want to apply the middleware to the login route if request.origin.uri.to_string() == "/login".to_string() { response.next_middleware() } else { // Get the full Authorization header from the incoming request headers let auth_header = match request.origin.headers.get::<Authorization<Bearer>>() { Some(header) => header, None => panic!("No authorization header found") }; // Format the header to only take the value let jwt = header::HeaderFormatter(auth_header).to_string(); // We don't need the Bearer part, // so get whatever is after an index of 7 let jwt_slice = &jwt[7..]; // Parse the token let token = Token::<Header, Registered>::parse(jwt_slice).unwrap(); // Get the secret key as bytes let secret = AUTH_SECRET.as_bytes(); // Verify the token if token.verify(&secret, Sha256::new()) { response.next_middleware() } else { response.error(Forbidden, "Access denied") } } } } ...
Our
authenticator
function takes a request
and response
and returns a MiddlewareResult
. For our purposes, the result will either be next_middleware
, which lets the request pass through to the endpoint, or error
, which will stop the request.We shouldn't have the middleware apply to
OPTIONS
requests, and the user doesn't need to be authenticated to access the /login
route, so we check against those conditions first. For all other routes, we need to get hold of the Authorization
header, which we do with the getter provided by Hyper. To make use of the JWT in the header, we need to get it as a string, which we do with HeaderFormatter
. This string will be of the form Bearer <token>
, and we don't need the Bearer
part, so we take a subset of the string from index 7 onward and save it in jwt_slice
. Taking only the token from the string could also be done with a regular expression to be more robust, but using the index operator to take the slice is a quick and convenient way of accomplishing it.To get the token as the correct type, we use rust-jwt's
parse
method. Finally, we use verify
on the token and pass in the AUTH_SECRET
in byte form. If the token checks out, next_middleware
is called to send the user through to the endpoint. If the token is invalid, a Forbidden
error is thrown.Last, we need to make sure the server is using this middleware.
// src/main.rs ... server.utilize(authenticator); ...
We can test everything out by including the JWT as a header when making a request to
/users
.Aside: Authenticating Your Rust API with Auth0
Auth0 issues JSON Web Tokens on every login for your users. Adding authentication to your Rust API based on your Auth0 account is simple--just replace the secret key in the example above with your Auth0 secret key.
// src/main.rs ... static AUTH_SECRET: &'static str = "your_auth0_secret_key"; ...
With this, you no longer need to implement a
/login
route for your API--we take care of authenticating your users for you.To obtain tokens for your users, you can use our drop-in Lock Widget on the front end of your app, or you can make requests to the Auth0 API with your settings and user's credentials.
Once a token is obtained, you can use it with rust-jwt. However, you need to base64 decode the
AUTH_SECRET
first.// src/main.rs ... // Get the secret base64 decoded let secret = AUTH_SECRET.as_bytes().from_base64().unwrap(); ...
Note: The token's expiry isn't checked in these examples. This could be done by comparing the
claim to the current time and responding with aexp
if the token has expired.401
Wrapping Up
As a language, Rust offers some great benefits, especially around memory safety, pattern matching, and data-race avoidance. This can be really important in some applications. For projects that also need to expose a data API, crates like Nickel.rs and the MongoDB Rust Driver can work well together. We can also add JWT authentication to our API by tapping into Nickel.rs's middleware and using rust-jwt to issue and decode tokens.
Typically, an API written in Rust will require more code than if it were written in NodeJS using Express. Ultimately, any decision regarding which to use comes down to the tradeoffs associated with both, as well as what is most appropriate for a given project. Developing an API with NodeJS can be faster and more concise, but Rust offers guarantees around memory safety that make it attractive.
About the author
Ryan Chenkie
Developer
Ryan is a Google Developer Expert, the host of the Entrepreneurial Coder Podcast, the author of Securing Angular Applications, and an all-around fanatic about application security.View profile