A Small Rust API with Actix

May 10, 2018 · 6 min read

I had the need for a very small API for this website. So small, in fact, that only one endpoint was required. I've been doing a lot of development in Rust lately, so naturally Rust seemed like a good candidate to build this API in. I also wanted to try out a newer Rust web framework called Actix web. It claims to be a "small, pragmatic, and extremely fast rust web framework", which sounded perfect for what I needed.

Getting started with Actix web is pretty straightforward. First create a new Rust project

cargo new my_api --bin

Cargo (the Rust package manager) is installed along with the popular Rust installer, Rustup. Adding Actix web server to your project can be done by first adding dependencies to Cargo.toml:

[dependencies]
actix = "0.5"
actix-web = "0.6"

and then starting a server in main:

extern crate actix_web;
use actix_web::{server, App, HttpRequest};

fn index(req: HttpRequest) -> &'static str {
    "Hello world!"
}

fn main() {
    server::new(
        || App::new()
            .resource("/", |r| r.f(index)))
        .bind("127.0.0.1:8088").expect("Can not bind to 127.0.0.1:8088")
        .run();
}

The Actix web quickstart guide gives a pretty good overview of getting started with Actix web.

The functionality I wanted for this particular API was to return some stats about my running for the year to display on this website. In order to get that data, I needed to make a couple of GET requests to Running Ahead, parse that data and return a JSON structure showing the total mileage run for the year and mileage from my 5 most recent runs.

{
  "year": "422",
  "latest": [
    "6.9",
    "7.78",
    "6.98",
    "7.71",
    "6.96"
  ]
}

The first thing to do was to figure out how to do the GET requests. Actix (the underlying Actor framework for Actix web) has a ClientRequest struct that allows you to make standard HTTP requests. I used ClientRequest to fetch a page from Running Ahead and return a Boxed Future which parses the resulting content into Vec of String.

/// get mileage for 5 latest runs
fn get_latest() -> Box<Future<Item=Vec<String>, Error=Error>> {
    Box::new(
        client::ClientRequest::get("https://www.runningahead.com/scripts/<my_user_id>/latest")
            .finish().unwrap()
            .send()
            .map_err(Error::from)
            .and_then(
                |resp| resp.body()
                    .from_err()
                    .and_then(|body| {
                        let re = Regex::new(">([0-9]*?.[0-9]*?|[0-9]*?) mi").unwrap();
                        fut_ok(re.captures_iter(str::from_utf8(&body).unwrap())
                            .into_iter()
                            .map(|item| {
                                item[1].to_string()
                            })
                            .collect())
                    })
            ),
    )
}

Note that I'm using str::from_utf8 to convert the body that is returned into a String that can be matched in a regular expression.

The request to get the total mileage for the year is very similar.

/// Get total miles for the year
fn get_year() -> Box<Future<Item=String, Error=Error>> {
    Box::new(
        client::ClientRequest::get("https://www.runningahead.com/scripts/<my_user_id>/last")
            .finish().unwrap()
            .send()
            .map_err(Error::from)
            .and_then(
                |resp| resp.body()
                    .from_err()
                    .and_then(|body| {
                        let re = Regex::new("(?s)<th>Year:</th><td>(.*?) mi</td>").unwrap();
                        let mat = re.captures(str::from_utf8(&body).unwrap()).unwrap();
                        fut_ok(mat[1].to_string())
                    })
            ),
    )
}

Remember that these functions both return Futures as we want to make the requests simultaneously and combine the results when they have both returned. In order to do this, the calls can be chained together and combined in an endpoint like so:

fn running(req: HttpRequest) -> Box<Future<Item=HttpResponse, Error=Error>> {
    get_year()
        .and_then(|miles_year| {
            get_latest().and_then(|miles_latest| {
                Ok(HttpResponse::Ok()
                    .content_type("application/json")
                    .body(serde_json::to_string(&MilesData {
                        year: miles_year,
                        latest: miles_latest,
                    }).unwrap()).into())
            })
        }).responder()
}

All of this got me most of the way to where I needed to be. However, since the calls to Running Ahead are https, SSL needs to be enabled for the Actix dependency. This can be done by adding the alpn feature to Actix:

[dependencies]
actix-web = { version="0.6", features=["alpn"] }

Once I had alpn enabled, all worked well on my local (macOS) machine. However, when I went to deploy to a Linux server with an nginx process to provide SSL, I was met with a strange error message:

Error occured during request handling: Failed to connect to host: OpenSSL error: error:1416F086:SSL routines:tls_process_server_certificate:certificate verify failed:ssl/statem/statem_clnt.c:1245:

Very strange. After much Googleing, I found a reference that suggested trying openssl-probe. This tool searches out locations of SSL certificates on the system and was exactly what I needed. Using openssl-probe requires adding the dependency to Cargo.toml

[dependencies]
openssl-probe = "0.1.2"

and adding adding this to the src/main.rs

extern crate openssl_probe;

fn main() {
    openssl_probe::init_ssl_cert_env_vars();
    //... your code
}

Here is the final Cargo.toml

[package]
name = "stevezeidner-api"
version = "0.1.0"
authors = ["Steve Zeidner <steve@stevezeidner.com>"]

[dependencies]
futures = "0.1"
env_logger = "0.5"
actix = "0.5"
actix-web = { version="0.5", features=["alpn"] }
openssl-probe = "0.1.2"

serde = "1.0"
serde_json = "1.0"
serde_derive = "1.0"
json = "*"
regex = "0.2"

and source for src/main.rs

#![allow(unused_variables)]
#![cfg_attr(feature = "cargo-clippy", allow(needless_pass_by_value))]

extern crate actix;
extern crate actix_web;
extern crate env_logger;
extern crate futures;
extern crate json;
extern crate openssl_probe;
extern crate regex;
#[macro_use]
extern crate serde_derive;
extern crate serde_json;

use actix_web::{App, AsyncResponder, error, Error, fs,
                HttpMessage, HttpRequest, HttpResponse, pred, Result, server};
use actix_web::{client, middleware};
use actix_web::http::{Method, StatusCode};
use futures::{Future, future::ok as fut_ok};
use regex::Regex;
use std::{env, io};
use std::str;

#[derive(Debug, Deserialize, Serialize)]
struct MilesData {
    year: String,
    latest: Vec<String>,
}

/// Get total miles for the year
fn get_year() -> Box<Future<Item=String, Error=Error>> {
    Box::new(
        client::ClientRequest::get("https://www.runningahead.com/scripts/<my_user_id>/last")
            .finish().unwrap()
            .send()
            .map_err(Error::from)
            .and_then(
                |resp| resp.body()
                    .from_err()
                    .and_then(|body| {
                        let re = Regex::new("(?s)<th>Year:</th><td>(.*?) mi</td>").unwrap();
                        let mat = re.captures(str::from_utf8(&body).unwrap()).unwrap();
                        fut_ok(mat[1].to_string())
                    })
            ),
    )
}

/// get mileage for 5 latest runs
fn get_latest() -> Box<Future<Item=Vec<String>, Error=Error>> {
    Box::new(
        client::ClientRequest::get("https://www.runningahead.com/scripts/<my_user_id>/latest")
            .finish().unwrap()
            .send()
            .map_err(Error::from)
            .and_then(
                |resp| resp.body()
                    .from_err()
                    .and_then(|body| {
                        let re = Regex::new(">([0-9]*?.[0-9]*?|[0-9]*?) mi").unwrap();
                        fut_ok(re.captures_iter(str::from_utf8(&body).unwrap())
                            .into_iter()
                            .map(|item| {
                                item[1].to_string()
                            })
                            .collect())
                    })
            ),
    )
}

fn running(req: HttpRequest) -> Box<Future<Item=HttpResponse, Error=Error>> {
    get_year()
        .and_then(|miles_year| {
            get_latest().and_then(|miles_latest| {
                Ok(HttpResponse::Ok()
                    .content_type("application/json")
                    .body(serde_json::to_string(&MilesData {
                        year: miles_year,
                        latest: miles_latest,
                    }).unwrap()).into())
            })
        }).responder()
}

/// 404 handler
fn p404(req: HttpRequest) -> Result<fs::NamedFile> {
    Ok(fs::NamedFile::open("static/404.html")?
        .set_status_code(StatusCode::NOT_FOUND))
}

fn main() {
    openssl_probe::init_ssl_cert_env_vars();
    env::set_var("RUST_LOG", "actix_web=debug");
    env::set_var("RUST_BACKTRACE", "1");
    env_logger::init();
    let sys = actix::System::new("stevezeidner-api");

    let addr = server::new(
        || App::new()
            // enable logger
            .middleware(middleware::Logger::default())
            .middleware(middleware::DefaultHeaders::new()
                    .header("Access-Control-Allow-Origin", "*"))
            .resource("/running", |r| r.method(Method::GET).a(running))
            .resource("/error", |r| r.f(|req| {
                error::InternalError::new(
                    io::Error::new(io::ErrorKind::Other, "test"), StatusCode::INTERNAL_SERVER_ERROR)
            }))
            // default
            .default_resource(|r| {
                // 404 for GET request
                r.method(Method::GET).f(p404);

                // all requests that are not `GET`
                r.route().filter(pred::Not(pred::Get())).f(
                    |req| HttpResponse::MethodNotAllowed());
            }))

        .bind("127.0.0.1:8888").expect("Can not bind to 127.0.0.1:8888")
        .shutdown_timeout(0)    // <- Set shutdown timeout to 0 seconds (default 60s)
        .start();

    println!("Starting http server: 127.0.0.1:8888");
    let _ = sys.run();
}