A full guide on creating a Rust-based web server to plot Naurt geocodes on a map
Rust is a powerful modern language with strong typing and memory safety which makes it a perfect backend choice. In this blog post, we’ll be integrating Naurt’s geocoder into a simple Rust server, with the end goal of displaying a map of the results. We’ll use Rocket for the server, with Tera templates and Plotly for the map.
Make sure you have Rust installed correctly - cargo version
should output something on your command line. Run
$ cargo init naurt_rust
Then add the following into the Cargo.toml
file
1[dependencies]
2plotly = "0.8.4"
3rocket = "0.5.1"
4rocket_codegen = "0.5.1"
5reqwest = { version = "0.12.5", features = ["json"] }
6tokio = { version = "1.38", features = ["full"] }
7rocket_dyn_templates = { version = "0.1.0", features = ["tera"] }
8serde = { version = "1.0", features = ["derive"] }
9serde_json = "1.0.118"
You’ll also need an API key to use Naurt. You can get one for free from our dashboard, it comes loaded with thousands of API calls and we don’t require a credit card for sign up. Place your API key in a file called api.key
next to the Cargo.toml
.
One final thing we need to do is add
#[macro_use]
extern crate rocket;
To the top of main.rs
- this will allow us to use the macros from the Rocket crate.
Let’s start by just getting a hello world from a server. Rocket is an extremely easy to use and powerful web server.
1#[macro_use]
2extern crate rocket;
3
4#[rocket::main]
5async fn main() {
6 let figment = rocket::Config::figment()
7 .merge(("port", 8080))
8 .merge(("address", "0.0.0.0"));
9
10 if let Err(e) = rocket::custom(figment)
11 .mount("/", routes![handler])
12 .launch()
13 .await
14 {
15 println!("Did not run. Error: {:?}", e)
16 }
17}
18
19#[get("/")]
20async fn handler() -> String {
21 return "Hello From Rocket!".to_string();
22}
23
This is the bare bones Rocket server. We create the server itself in main
and then give it a list of routes - in this case just handler
. Rust’s powerful macro system is already on full display - #[get(/)]
means all GET requests to /
are sent to handler
. Any other HTTP method will respond with the built in 404.
Also note that Rocket can have async
or non-async functions as handlers, but we’re going to be making web requests in this function later on, so we might as well mark it appropriately now.
If you go to http://localhost:8080/ you should see our hello message!
Since we will want to make requests to Naurt which requires an API key, then we’ll need that in our program. It’s already in the project in a file called api.key
but reading from disc every time would be inefficient. Thankfully, Rocket provides first class support for managing state safely between threads. Edit the main
function to contain this:
1let api_key = fs::read_to_string("api.key").unwrap();
2
3let figment = rocket::Config::figment()
4 .merge(("port", 8080))
5 .merge(("address", "0.0.0.0"));
6
7if let Err(e) = rocket::custom(figment)
8 .mount("/", routes![handler])
9 .manage(api_key)
10 .launch()
11 .await
12{
13 println!("Did not run. Error: {:?}", e)
14}
(make sure you import std::fs
)
It’s as simple as the call to .manage(api_key)
- now every handler we like has access to this across threads. To bring it into the handler, all we have to do is edit the function signature
async fn handler(api_key: &State<String>) -> String { ...
Make sure you import use rocket::State; . And it’s done! While we’re here, Rocket does state on a per type basis - so it can only manage one String at a time. If you needed to manage multiple strings you could either compose them into a single struct or use multiple tuple structs. By doing it on a per type basis, Rocket knows at compile time exactly which state member you want accessed in that function.
Now that we have the API key in the handler
we’re ready to start making web requests - we’ll use reqwest
for that. Start by making a constant
const NAURT_URL: &'static str = "https://api.naurt.net/final-destination/v1";
And import some new things
use reqwest::Client;
use rocket::response::content::RawJson;
Now we can update the handler
to this
1#[get("/")]
2async fn handler(api_key: &State<String>) -> RawJson<String> {
3 let client = Client::new();
4
5 let body = "{\"address_string\":\"The Grand Hotel, Brighton\"}";
6
7 let response = client
8 .post(NAURT_URL)
9 .body(body)
10 .header("Authorization", api_key.as_str())
11 .header("content-type", "application/json")
12 .send()
13 .await;
14
15 let reply = match response {
16 Ok(x) => x,
17 Err(y) => return RawJson(format!("\"Request Error\":\"{}\"", y)),
18 };
19
20 let body = reply.text().await;
21
22 return match body {
23 Ok(x) => RawJson(x),
24 Err(y) => RawJson(format!("\"Body Error\":\"{}\"", y)),
25 };
26}
We’re making a static request to Naurt, and we’re simply sending the JSON that Naurt sends forward. That’s what the RawJson<>
type does - it sorts out the response and headers for you.
Rust has a very powerful type system and first class serlisation and deserialisation support. We can use this to create strongly typed requests so we don’t accidentally make mistakes. The Naurt request can be made like so
1use serde::Serialize;
2
3#[derive(Serialize)]
4struct NaurtRequest {
5 #[serde(skip_serializing_if = "Option::is_none")]
6 pub address_string: Option<String>,
7 #[serde(skip_serializing_if = "Option::is_none")]
8 pub latitude: Option<f64>,
9 #[serde(skip_serializing_if = "Option::is_none")]
10 pub longitude: Option<f64>,
11 #[serde(skip_serializing_if = "Option::is_none")]
12 pub additional_matches: Option<bool>,
13}
We only need to derive Serialize
since we’ll be converting this struct into a JSON. Notice how we made every field an Option<>
- this is because we only need to specify either an address or location when using Naurt (or both). We also tagged each field with #[serde(skip_serializing_if = "Option::is_none")]
which will mean that field won’t appear in the JSON if it is None
.
To use it, we can remake the request like so
1let body = NaurtRequest {
2 address_string: Some("The Grand Hotel, Brighton".to_string()),
3 latitude: None,
4 longitude: None,
5 additional_matches: None,
6};
7
8let response = client
9 .post(NAURT_URL)
10 .json(&body)
11 .header("Authorization", api_key.as_str())
12 .send()
13 .await;
Notice how we no longer specify the header content-type
and have changed from using .body()
to .json()
. Since the Rust ecosystem is so cohesive, reqwest
comes with first class serde
support out of the box! Any struct
that derives Serialize
can be used there.
Right now, you need to restart the server for every different request, which is obviously less than helpful. Instead, we want to use URL arguments to be able to generate different requests. Thankfully, Rocket has one of the most robust and intuitive argument systems ever made. All we need to do is modify the handler
like so
1#[get("/?<address>&<latitude>&<longitude>&<additional_matches>")]
2async fn handler(
3 api_key: &State<String>,
4 address: Option<String>,
5 latitude: Option<f64>,
6 longitude: Option<f64>,
7 additional_matches: Option<bool>,
8) -> RawJson<String> {
9 let client = Client::new();
10
11 let body = NaurtRequest {
12 address_string: address,
13 latitude: latitude,
14 longitude: longitude,
15 additional_matches: additional_matches,
16 };
17...
The beauty of this system compared with others is we don’t need to worry about whether the arguments are present or their types - Rocket will only pass a request to this function if the guards can be satisfied. Therefore, we already have and can use these as native Rust types. For example, in the Go blog we had to check if the argument was present, represent it with a pointer and handle parsing the string as a number. This is just so much better.
Just passing the JSON back to the user isn’t the most useful thing ever, so we’d like to do something with it. In this case our end goal is plotting it on a map, but whatever we want to do strongly typing the response is really helpful. Thankfully, since Rust has first-class serialisation support this is super simple.
1use serde::Deserialize;
2use serde_json::Value;
3
4#[derive(Deserialize)]
5struct NaurtResponse {
6 pub best_match: DestinationResponse,
7 pub additional_matches: Option<Vec<DestinationResponse>>,
8 pub version: String,
9}
10
11#[derive(Deserialize)]
12struct DestinationResponse {
13 pub id: String,
14 pub address: String,
15 pub geojson: NaurtGeojson,
16 pub distance: Option<f64>,
17}
18
19#[derive(Deserialize)]
20struct NaurtGeojson {
21 pub features: Vec<Feature>,
22 #[serde(rename(deserialize = "type"))]
23 pub type_val: String
24}
25
26#[derive(Deserialize)]
27struct Feature {
28 pub geometry: Option<Coordinates>,
29 #[serde(rename(deserialize = "type"))]
30 pub type_val: String,
31 pub properties: Properties,
32}
33
34#[derive(Deserialize)]
35struct Coordinates {
36 pub coordinates: CoordinatesWrapper,
37 #[serde(rename(deserialize = "type"))]
38 pub type_val: String,
39}
40
41#[derive(Deserialize)]
42#[serde(untagged)]
43enum CoordinatesWrapper {
44 Number(Vec<Vec<f64>>),
45 NestedNumber(Vec<Vec<Vec<f64>>>),
46}
47
48#[derive(Deserialize)]
49struct Properties {
50 pub naurt_type: String,
51 pub contributors: Option<Vec<String>>,
52}
Notice how for all of these we only derive Deserialize
since we only ever convert from a JSON to these structs. A few notes to point out here. First, the word type appears in the Naurt response, to conform to the geojson standard. However, type
is a reserved keyword in Rust, so we tell serde to rename it with #[serde(rename(deserialize = "type"))]
. In the CoordinatesWrapper
struct we need to use #[serde(untagged)]
- this tells serde that CoordinatesWrapper will not actually appear in the JSON. We’re doing this because the type of coordinates
in the JSON is either a double nested array or a triple nested array. You can read more about working with enums in Rust here.
Now that we have the NaurtResponse
as a struct, we can think about plotting Naurt on a map. For this, we’ll use Plotly. Rust has great Plotly support, so it’s a pretty simple task.
We want to do the following things
We can do that with the following code
1use plotly::{
2 common::{Marker, Mode},
3 layout::{Center, Mapbox, MapboxStyle},
4 scatter_mapbox::Fill,
5 Layout, Plot, ScatterMapbox, Trace,
6};
7
8fn extract_naurt(response: &NaurtResponse) -> String {
9 let mut plot = Plot::new();
10 let mut layout = None;
11 let mut traces: Vec<Box<dyn Trace>> = vec![];
12
13 extract_naurt_inner(&response.best_match, &mut traces, &mut layout, true);
14
15 if let Some(additional_matches) = &response.additional_matches {
16 for dest in additional_matches {
17 extract_naurt_inner(dest, &mut traces, &mut layout, false);
18 }
19 }
20
21 plot.add_traces(traces);
22 plot.set_layout(layout.unwrap());
23
24 return plot.to_inline_html(Some("mapDiv"));
25}
26
27fn extract_naurt_inner(
28 data: &DestinationResponse,
29 traces: &mut Vec<Box<dyn Trace>>,
30 layout: &mut Option<Layout>,
31 best_match: bool,
32) {
33 for feat in &data.geojson.features {
34 let geometry = match &feat.geometry {
35 Some(x) => x,
36 None => continue,
37 };
38
39 match &geometry.coordinates {
40 CoordinatesWrapper::Number(coords) => {
41 if best_match {
42 *layout = Some(
43 Layout::new()
44 .mapbox(
45 Mapbox::new()
46 .style(MapboxStyle::CartoPositron)
47 .center(Center::new(coords[0][1], coords[0][0]))
48 .zoom(15),
49 )
50 .show_legend(false)
51 .auto_size(true),
52 );
53 }
54
55 let trace = ScatterMapbox::new(
56 coords.iter().map(|z| z[1]).collect(),
57 coords.iter().map(|z| z[0]).collect(),
58 )
59 .text(format!("{} - {}", data.address, feat.properties.naurt_type))
60 .mode(Mode::Markers)
61 .marker(Marker::new().size(13));
62
63 traces.push(trace);
64 }
65 CoordinatesWrapper::NestedNumber(coords) => {
66 for shape in coords {
67 let trace = ScatterMapbox::new(
68 shape.iter().map(|z| z[1]).collect(),
69 shape.iter().map(|z| z[0]).collect(),
70 )
71 .text(format!("{} - {}", data.address, feat.properties.naurt_type))
72 .mode(Mode::Lines)
73 .fill(Fill::ToSelf);
74
75 traces.push(trace);
76 }
77 }
78 }
79 }
80}
The strengths of the Rust typing system are on full display now. By matching on the features we can automatically know that we’re working with the right datatype for the plot (i.e. points or shapes). This works better than in the TypeScript blog where we had to string match on the naurt_type
and then coerce the datatype of the array, which could easily crash in runtime.
We use the to_inline_html
function from Plotly to turn this into an already rendered Plotly plot. All that we have to do now is simply serve up some HTML to contain this.
We need a template to work with for this. Start by making a folder and file
$ mkdir templates
$ touch templates/index.html.tera
The template HTML is the following
1<!DOCTYPE >
2<html>
3
4<head>
5 <meta charset="UTF-8">
6 <title>Plotly Map</title>
7 <script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
8</head>
9<style>
10 .container {
11 width: 100%;
12 height: 100vh;
13 }
14</style>
15
16<body>
17 <div class="container" id="mapDiv">{{data | safe}}</div>
18</body>
19
20</html>
Since we are going to embed HTML into the template, we need to use the safe
keyword or the browser will render it as plaintext.
We need to tell Rocket about the templates. We need to import Templates
use rocket_dyn_templates::Template;
And then we can attach it in the main
function
1if let Err(e) = rocket::custom(figment)
2 .attach(Template::fairing())
3 .mount("/", routes![handler])
4 .manage(api_key)
5 .launch()
6 .await
7{
8 println!("Did not run. Error: {:?}", e)
9}
Now the Tera templates are available for us to render.
Finally, to actually use all of this we need to return a map to the user!
1use rocket_dyn_templates::tera::Context;
2
3#[get("/?<address>&<latitude>&<longitude>&<additional_matches>")]
4async fn handler(
5 api_key: &State<String>,
6 address: Option<String>,
7 latitude: Option<f64>,
8 longitude: Option<f64>,
9 additional_matches: Option<bool>,
10) -> Result<Template, RawJson<String>> {
11 let client = Client::new();
12
13 let request = NaurtRequest {
14 address_string: address,
15 latitude: latitude,
16 longitude: longitude,
17 additional_matches: additional_matches,
18 };
19
20 let response = client
21 .post(NAURT_URL)
22 .json(&request)
23 .header("Authorization", api_key.as_str())
24 .send()
25 .await;
26
27 let reply = match response {
28 Ok(x) => x,
29 Err(y) => return Err(RawJson(format!("\"Request Error\":\"{}\"", y))),
30 };
31
32 let body = reply.text().await;
33
34 let json_text = match body {
35 Ok(x) => x,
36 Err(y) => return Err(RawJson(format!("\"Body Error\":\"{}\"", y))),
37 };
38
39 let naurt_response = match serde_json::from_str::<NaurtResponse>(&json_text) {
40 Ok(x) => x,
41 Err(y) => return Err(RawJson(format!("\"Json Error\":\"{}\"", y))),
42 };
43
44 let plotly = extract_naurt(&naurt_response);
45
46 let mut context = Context::new();
47 context.insert("data", &plotly);
48
49 return Ok(Template::render("index", context.into_json()));
50}
Notice how we change the response type to Result<Template, RawJson<String>>
- this way we can send back a rendered Template
if everything goes well or send back an Error JSON if it doesn’t. Rust has really great error handling, and usually handling errors properly is easier in Rust than not handling them. As here, great error handling more or less falls out of the code naturally.
Try going to http://localhost:8080/?additional_matches=true&address=The Grand Hotel, Brighton and you should see the following map!
And we’re done! If you want to view the full code you can go to our GitHub